Управління станом в додатках на Flutter

Загальні принципи

Flutter — реактивний фреймворк, і для розробника, що спеціалізується на нативної розробці, його філософія може бути незвична. Тому почнемо з невеликого огляду.

Користувальницький інтерфейс на Flutter, як і в більшості сучасних фреймворків, складається з дерева компонентів (віджетів). При зміні якого-небудь компонента, відбувається перерендеринг цього і всіх його дочірніх компонентів (з внутрішніми оптимизациями, про які нижче). При суттєвій зміні відображення (наприклад, повороті екрану), перемальовується все дерево віджетів.

 

Цей підхід може здатися неефективним, але насправді він передає програмісту контроль за швидкістю роботи. Якщо виробляти оновлення інтерфейсу на самому верхньому рівні без необхідності — все буде працювати повільно, але при правильній компонуванні віджетів, додатки на Flutter можуть бути дуже швидкими.

 

У Flutter існує два типи віджетів — Stateless і Stateful. Перші (аналог Pure Components в React) не мають стану і повністю описуються своїми параметрами. Якщо не змінюються умови відображення (скажімо, розмір області, в якій повинен показуватися віджет) та його параметри, система переиспользует раніше створене візуальне подання віджета, тому використання Stateless віджетів добре позначається на продуктивності. При цьому все одно при кожній перемальовуванні віджета формально створюється новий об’єкт і запускається конструктор.

Stateful віджети зберігають деякий стан між рендерингами. Для цього вони описуються двома класами. Перший з класів, власне віджет, описує об’єкти, які створюються при кожній візуалізації. Другий клас, що описує стан віджета та його об’єкти передаються у створювані об’єкти віджета. Зміна стану Stateful віджетів є основним джерелом перемальовування інтерфейсів. Для цього потрібно змінити його властивості всередині виклику методу SetState. Таким чином, на відміну від багатьох інших фреймворків, під Flutter немає неявного відстеження стану — будь-яка зміна властивостей віджета поза методу SetState не призводить до перемальовуванні інтерфейсу.

Тепер, після опису основ, можна почати з простого додатка, що використовує Stateless і Stateful віджети:

Базове додаток

import 'dart:math';

import 'package:flutter/material.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
@override
 Widget build(BuildContext context) {
 return new MaterialApp(
 title: 'Flutter Demo',
 theme: new ThemeData(
 primarySwatch: Colors.blue,
),
 home: Scaffold(
 appBar: AppBar(
 title: Text('Sample app'),
),
 body: new MyHomePage(),
),
);
}
}

class MyHomePage extends StatefulWidget {
@override
 _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

 Random rand = Random();

@override
 Widget build(BuildContext context) {
 return new ListView.builder(itemBuilder: (BuildContext context, int index) {
 return Text('Random number ${rand.nextInt(100)}',);
});
}
}

Повний прикладРезультат

Якщо потрібні більш живучі стану

Йдемо далі. Стан Stateful віджетів зберігається між перерисовками інтерфейсів, але тільки до тих пір, поки віджет потрібен, тобто реально знаходиться на екрані. Проведемо простий експеримент — розмістимо наш список на вкладці:

Читайте також  Як безпечно позбутися своїх електронних пристроїв

Додаток з вкладками

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {

 Random rand = Random();

 TabController _tabController;

 final List<Tab> myTabs = <Tab>[
 new Tab(text: 'FIRST'),
 new Tab(text: 'SECOND'),
];

@override
 void initState() {
super.initState();
 _tabController = new TabController(vsync: this, length: myTabs.length);
}

@override
 Widget build(BuildContext context) {
 return Scaffold(
 appBar: AppBar(
 title: Text('Sample app'),
),
 body: new TabBarView(
 controller: _tabController,
 children: [
 new ListView.builder(itemBuilder: (BuildContext context, int index) {
 return Text('Random number ${rand.nextInt(100)}',);
}),
 Text('Second tab'),
],),
 bottomNavigationBar: new TabBar(
 controller: _tabController,
 tabs: myTabs,
 labelColor: Colors.blue,
),
);
}
}

 

Повний приклад

Результат

 

При запуску можна побачити, що при перемиканні між вкладками, стан видаляється (викликається метод dispose()), при поверненні створюється знову (метод initState()). Це розумно, так як зберігання стану неотображаемых віджетів буде віднімати ресурси системи. У тому випадку, коли стан віджета повинно переживати його повне приховування, можливі кілька підходів:

По-перше, можна використовувати окремі об’єкти (ViewModel) для зберігання стану. Dart на рівні мови підтримує фабричні конструктори, які можна використовувати для створення фабрик і сінглтон, що зберігають необхідні дані.

Мені більше подобається цей підхід, оскільки він дозволяє ізолювати бізнес-логіку від користувальницького інтерфейсу. Це особливо актуально у зв’язку з тим, що Flutter Release Preview 2 додав можливість створювати pixel-perfect інтерфейси для iOS, але робити це потрібно, зрозуміло, на відповідних віджетах.

По-друге, можна використовувати знайомий програмістам React підхід підняття стану, коли дані зберігаються в компонентах, розташованих вище по дереву. Оскільки Flutter перемальовує інтерфейс тільки при виклику методу setState(), ці дані можна змінювати і використовувати без візуалізації. Такий підхід трохи більш складний і підвищує зв’язність віджетів в структурі, але дозволяє точечно задавати рівень зберігання даних.

Нарешті, існують бібліотеки зберігання стану, наприклад flutter_redux.

Для простоти використаємо перший підхід. Зробимо окремий клас ListData, сінглтон, зберігає значення для нашого списку. При відображенні будемо використовувати цей клас.

Додаток з вкладками і відновленням даних

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {

 TabController _tabController;

 final List<Tab> myTabs = <Tab>[
 new Tab(text: 'FIRST'),
 new Tab(text: 'SECOND'),
];

@override
 void initState() {
super.initState();
 _tabController = new TabController(vsync: this, length: myTabs.length);
}

@override
 Widget build(BuildContext context) {
 return Scaffold(
 appBar: AppBar(
 title: Text('Sample app'),
),
 body: new TabBarView(
 controller: _tabController,
 children: [
 new ListView.builder(itemBuilder: ListData().build),
 Text('Second tab'),
],),
 bottomNavigationBar: new TabBar(
 controller: _tabController,
 tabs: myTabs,
 labelColor: Colors.blue,
),
);
}
}

class ListData {
 static ListData _instance = ListData._internal();

ListData._internal();

 factory ListData() {
 return _instance;
}

 Random _rand = Random();
 Map<int, int> _values = new Map();

 Widget build (BuildContext context, int index) {
 if (!_values.containsKey(index)) {
 _values[index] = _rand.nextInt(100);
}

 return Text('Random number ${_values[index]}',);
}
}

 

Повний приклад

Результат

Збереження позиції скролла

Якщо скрутити список з попереднього прикладу вниз, потім перейти між вкладками, неважко помітити, що позиція прокрутки не зберігається. Це логічно, так як в нашому класі ListData вона не зберігається, а власний стан віджета не переживає перемикання між табами. Реалізуємо зберігання стану прокрутки вручну, але для інтересу складемо її не в окремий клас і не в ListData, а в стан більш високого рівня, щоб показати, як з цим працювати.

Читайте також  Кротові нори в JavaScript

Зверніть увагу на віджети ScrollController і NotificationListener (а також раніше використаний DefaultTabController). Концепція віджетів, які не мають свого відображення повинна бути знайома розробникам, працюючим з React/Redux — в цій зв’язці активно використовуються компоненти-контейнери. У Flutter віджети без відображення зазвичай використовуються для додавання функціональності до дочірнім віджетів. Це дозволяє залишити самі візуальні віджети легковажними і не обробляти системні події там, де вони не потрібні.

Код заснований на рішенні, запропонованому Marcin Szałek на Stakoverflow ( https://stackoverflow.com/questions/45341721/flutter-listview-inside-on-a-tabbarview-loses-its-scroll-position ). План такий:

  1. Додаємо до списку ScrollController, щоб працювати з положенням прокрутки.
  2. Додаємо до списку NotificationListener, щоб передавати стан прокрутки.
  3. Зберігаємо положення прокручування _MyHomePageState (яке знаходиться за рівнем вище табів) і пов’язуємо його з прокруткою списку.

Додаток із збереженням положення прокручування

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {

 double listViewOffset=0.0;

 TabController _tabController;

 final List<Tab> myTabs = <Tab>[
 new Tab(text: 'FIRST'),
 new Tab(text: 'SECOND'),
];

@override
 void initState() {
super.initState();
 _tabController = new TabController(vsync: this, length: myTabs.length);
}

@override
 Widget build(BuildContext context) {
 return Scaffold(
 appBar: AppBar(
 title: Text('Sample app'),
),
 body: new TabBarView(
 controller: _tabController,
 children: [new ListTab(
 getOffsetMethod: () => listViewOffset,
 setOffsetMethod: (offset) => this.listViewOffset = offset,
),
 Text('Second tab'),
],),
 bottomNavigationBar: new TabBar(
 controller: _tabController,
 tabs: myTabs,
 labelColor: Colors.blue,
),
);
}
}

class ListTab extends StatefulWidget {

 ListTab({Key key, this.getOffsetMethod, this.setOffsetMethod}) : super(key key);

 final GetOffsetMethod getOffsetMethod;
 final SetOffsetMethod setOffsetMethod;

@override
 _ListTabState createState() => _ListTabState();
}

class _ListTabState extends State<ListTab> {

 ScrollController scrollController;

@override
 void initState() {
super.initState();
 //Init scrolling to preserve it
 scrollController = new ScrollController(
 initialScrollOffset: widget.getOffsetMethod()
);
}

@override
 Widget build(BuildContext context) {
return
NotificationListener(
 child: new ListView.builder(
 controller: scrollController,
 itemBuilder: ListData().build,
),
 onNotification: (notification) {
 if (notification is ScrollNotification) {
widget.setOffsetMethod(notification.metrics.pixels);
}
},
);
}
}

 

Повний приклад

Результат

Переживаємо вимикання програми

Збереження інформації на час роботи програми — це добре, але часто хочеться зберігати її і між сеансами, особливо враховуючи звичку операційних систем закривати фонові додатки при нестачі пам’яті. Основні варіанти постійного зберігання даних у Flutter це:

  1. Shared preferences ( https://pub.dartlang.org/packages/shared_preferences ) є обгорткою навколо NSUserDefaults (на iOS) і SharedPreferences (на Android) і дозволяє зберігати невелику кількість пар ключ-значення. Відмінно підходить для зберігання налаштувань.
  2. sqflite ( https://pub.dartlang.org/packages/sqflite ) — плагін для роботи з SQLite (з некоторомы обмеженнями). Підтримує як низькорівневі запити, так і хелпери. Крім того, за аналогією з Room дозволяє працювати з версіями схеми БД і задавати код для оновлення схеми при оновленні програми.
  3. Cloud Firestore ( https://pub.dartlang.org/packages/cloud_firestore ) — частина сімейства офіційних плагінів для роботи з FireBase.
Читайте також  Чисельна перевірка abc-гіпотези (так, тієї самої)

Для демонстрації зробимо збереження стану прокручування Shared preferences. Для цього додамо відновлення позиції скролла при ініціалізації стану _MyHomePageState і збереження при прокручуванні.

Тут потрібно трохи зупинитися на асинхронної моделі Flutter/Dart, оскільки всі зовнішні служби працюють на асинхронних виклики. Принцип роботи цієї моделі схожий з node.js — є один основний потік (thread), який переривається на асинхронні виклики. На кожному наступному перериванні (а UI робить їх постійно) обробляються результати завершених асинхронних операцій.При цьому є можливість запускати великовагові обчислення фонових threads (через функцію compute).

Отже, запис і читання в SharedPreferences робляться асинхронно (хоча бібліотека дозволяє синхронне читання з пам’яті). Для початку розберемося з читанням. Стандартний підхід до асинхронного отримання даних виглядає так — запустити асинхронний процес, по його завершенню виконати SetState, записавши отримані значення. В результаті користувальницький інтерфейс буде оновлено з використанням одержаних даних. Однак у даному випадку ми працюємо не з даними, а з положенням прокрутки. Нам не потрібно оновлювати інтерфейс, потрібно лише викликати метод jumpTo у ScrollController. Проблема в тому, що результат обробки асинхронного запиту може повернутися в будь-який момент і зовсім не обов’язково буде що і куди прокручувати. Щоб гарантовано виконати операцію на повністю инициализированном інтерфейсі, нам потрібно … все-таки прокрутити всередині setState.

 

Отримуємо приблизно такий код:

Встановлення стану

@override
 void initState() {
super.initState();
 //Init scrolling to preserve it
 scrollController = new ScrollController(
 initialScrollOffset: widget.getOffsetMethod()
);

 _restoreState().then((double value) => scrollController.jumpTo(value));
}

 Future<double> _restoreState() async {
 SharedPreferences prefs = await SharedPreferences.getInstance();
 return prefs.getDouble('listViewOffset');
}

 void setScroll(double value) {
 setState(() {
scrollController.jumpTo(value);
});
}

 

Із записом все цікавіше. Справа в тому, що в процесі прокрутки, повідомляють про це події приходять постійно. Запуск асинхронної запису при кожній зміні значення може призвести до помилок програми. Нам потрібно обробляти тільки остання подія з ланцюжка. У термінах реактивного програмування це називається debounce і його ми і будемо використовувати. Dart підтримує основні можливості реактивного програмування через потоки даних (stream), відповідно нам потрібно буде створити потік оновлень позиції прокрутки і підписатися на нього, перетворюючи його з допомогою Debounce. Для перетворення нам потрібно бібліотека stream_transform. В якості альтернативного підходу, можна використовувати RxDart і працювати в термінах ReactiveX.

Виходить такий код:

Запис стану

 StreamSubscription _stream;
 StreamController<double> _controller = new StreamController<double>.broadcast();

@override
 void initState() {
super.initState();
 _tabController = new TabController(vsync: this, length: myTabs.length);

 _stream = _controller.stream.transform(debounce(new Duration(milliseconds: 500))).listen(_saveState);
}

 void _saveState(double value) async {
 SharedPreferences prefs = await SharedPreferences.getInstance();
 await prefs.setDouble('listViewOffset', value);
}

 

Степан Лютий

Обожнюю технології в сучасному світі. Хоча частенько і замислююся над тим, як далеко вони нас заведуть. Не те, щоб я прям і знаюся на ядрах, пікселях, коллайдерах і інших парсеках. Просто приходжу в захват від того, що може в творчому пориві вигадати людський розум.

You may also like...

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *