Предыстория
Я достаточно долгое время писал мобильные приложения исключительно на Flutter (примерно с версии 1.2) и успел попробовать несколько подходов к State Management'у (в порядке знакомства):
Не скажу, что я был от них в восторге, но они предоставляли достойное разделение UI логики и виджетов и выполняли свою работу.
Так получилось, что по зову долга мне пришлось долгое время писать Web на React + MobX, и именно тогда я понял, насколько меня сковывали рамки и неудобства технологий, которые я использовал во Flutter.
Для тех, кто не знаком с MobX, Counter выглядит примерно так:
class CounterViewModel {
@observable
count = 0
constructor() {
makeObservable(this);
}
@action
increment = () => {
count++;
};
}
const CounterButton = observer(props => {
return <div onclick={props.vm.increment}>{props.vm.count}</div>;
});
Никакой кодогенерации, никакого бойлерплейта - пишешь, какие поля observable
- и слушаешь их в "виджете". Для меня все это было как глоток свежего воздуха.
По личным ощущениям я стал больше успевать и меньше уставать, но больше всего мне нравилось то, что я наконец-то мог сконцентрировать на том, "что" я хочу сделать, а не "как".
Тем не менее, в MobX мне не нравились 3 вещи:
Магическая реактивность
observable
работала в 99% случаев, но этот 1% - именно он заставил меня залезть внутрьmobx
репозитория и разобраться, как он устроенИз предыдущего пункта выливается другая неприятность - непонятно, как расширять функциональность. Наследование ограничено, а композиция не такая интуитивная - "достал" property не в том месте и уже потерял "реактивность".
Иногда я банально забывал обернуть компонент в
observer
Вернувшись за Flutter, я захотел попробовать MobX - но был неприятно удивлен необходимостью генерировать код. На достаточно большом проекте в 50 тысяч строк кода `build_runner watch` на M1 Pro выдает такой результат после изменения одного поля в freezed модели:
[INFO] Starting Build
[INFO] Updating asset graph completed, took 4ms
[INFO] Running build completed, took 10.1s
[INFO] Caching finalized dependency graph completed, took 283ms
[INFO] Succeeded after 10.3s with 75 outputs (365 actions)
Тогда я и решил написать "свой" mobx
...
Как использовать?
-
Импортируйте библиотеку:
import "package:beholder_flutter/beholder_flutter.dart";
-
Определите
ViewModel
и изменяемое состояние через методstate
class CounterViewModel extends ViewModel { late final count = state(0); void increment() => count.value++; }
-
Слушайте изменения с помощью
Observer
виджета:// Внутри StatefulWidget final vm = CounterViewModel(); // ... @override Widget build(BuildContext context) { return Observer( builder: (context, watch) { final count = watch(vm.count); return ElevatedButton( onPressed: vm.increment, child: Text("$count"), ); }, ); } // ...
Почему не использовать уже существующее решение?
Riverpod
Не нравится подход со смешиванием DI и State Management'а.
Засорение глобального скоупа
Тяжело масштабировать - неизбежно приходится переписывать State/Future/Stream провайдеры на StateNotifier
BLoC
Определение более-менее сложных состояний требует кодогенерации copyWith.
Нет возможности совместить Cubit и Bloc - иногда только один из event'ов требует debounce'а, но приходится либо писать все через Event'ы, либо разделять логически единую сущность на 2 части (cubit и bloc).
Субъективно, но в больших проектах именование Event'ов и State'ов начинает напоминать энтерпрайз Java:
class RefreshPostsHomeScreenEvent
Я мог бы разобрать каждый доступный подход, но вы, как прожжённый читатель Хабра, понимаете, что я смогу найти в каждом из них фатальный недостаток.
Вы еще здесь? Тогда переходим к фичам
Комбинирование состояний:
class User { /* .. */ }
class SearchUsersScreen extends ViewModel {
late final search = state('');
late final users = state(<User>[]);
/// `computed` позволяет комбинировать значение из `state`ов
/// и других `computed`ов
late final lowercaseSearch = computed((watch) {
return watch(search).toLowerCase();
});
late final filteredUsers = computed((watch) {
return watch(users).where((user) {
final name = user.fullName.toLowerCase();
return name.contains(watch(lowercaseSearch));
}).toList();
})
/// `computedFactory` - это computed, который еще и параметр умеет принимать
late final userById = computedFactory((watch, int id) {
return watch(users).singleWhere((user) => user.id == id);
});
}
"Синхронно каждый может" - скажете Вы, но тут я покажу это:
import "dart:async";
// ...
class SearchUsersScreen extends ViewModel {
Timer? timer;
late final search = state('')
..listen((previous, current) {
timer?.cancel();
timer = Timer(
Duration(seconds: 1),
() => refresh(),
);
});
// AsyncValue встроен в библиотеку.
late final users = state<AsyncValue<List<User>>>(const Loading());
Future<void> refresh() async {
users
..value = Loading()
..value = await Result.guard(
() => ApiClient.fetchUsers(query: search.value)
);
}
// ...
}
Что насчет использованных ранее computed
'ов? Как им использовать users
, который теперь стал AsyncValue
?
А вот так:
late final filteredUsers = computed<AsyncValue<List<User>>>((watch) {
return watch(users).mapValue((users) => users.where((user) {
final name = user.fullName.toLowerCase();
return name.contains(watch(lowercaseSearch));
}).toList());
})
Виджет же будет выглядеть так:
Widget build(BuildContext context) {
return Observer(
builder: (context, watch) {
final users = watch(vm.filteredUsers);
return switch(users) {
Loading() => CircularProgressIndicator(),
Data(value: final users) => ListView(/* .. */),
Failure(:final error) => Text("Error: "),
};
}
);
}
Т.к. AsyncValue
- это sealed union
, мы можем исчерпывающе перебрать все возможные варианты. Больше про Pattern Matching - здесь.
Как масштабировать?
ViewModel
легко совмещаются посредством композиции(en):
class UsersViewModel extends ViewModel {
UsersViewModel(this.projectId);
final Observable<int> projectId;
late final _users = state(<User>[]);
late final filteredUsers = computed((watch) {
final projectId = watch(this.projectId);
return watch(users)
.where((user) => user.projects.contains(projectId))
.toList();
});
}
class TaskTrackerScreenViewModel extends ViewModel {
late final searchUsersVm = SearchUsersViewModel(this.selectedProjectId);
// Изменение projectId спровоцирует моментальное изменение filteredUsers
late final selectedProjectId = state(32);
}
Заключение
Моя первая статья на Habr (и в принципе). Спасибо, что дочитали. Буду рад любому фидбеку - и по статье, и по библиотеке.
API библиотеки достаточно stable, но выпуск 1.0.0 планирую только после 100% test coverage.
Github
Комментарии (22)
nin-jin
15.11.2023 06:13-3Тогда я и решил написать "свой"
mobx
...А почему не $mol_wire, который по всем аспектам минимум в 2 раза лучше MobX и умеет в прозрачные для потребителя асинхронные реактивные инварианты?
nin-jin
15.11.2023 06:13-4Ну а пока вы ставите минусы, продолжу бесплатно просвещать..
Изменение projectId спровоцирует моментальное изменение filteredUsers
SashaFarkas Автор
15.11.2023 06:13Возможно, я неправильно выразился - изменение state'ов всегда собирается в "кучу", а обновление
computed
'ов выполняется в следующем микротаске.
Но я бы отнес это к implementation detail, потому как вызовvalue
наcomputed
спровоцирует моментальный rebuild.nin-jin
15.11.2023 06:13Следующий микротаск - тоже слишком рано, так как в нём могут произойти её какие-то изменения, а значение компутеда всё ещё не понадобиться.
SashaFarkas Автор
15.11.2023 06:13+1Если значение computed'а не слушается в данный момент, он не ребилдится.
nin-jin
15.11.2023 06:13Оно может в данный момент слушаться, но в следующем микротаске уже перестать.
SashaFarkas Автор
15.11.2023 06:13Посмотрите реализацию алгоритма - она укладывается в одном файле из 200 строк. Если знаете, как улучшить, буду рад PR'у.
nin-jin
15.11.2023 06:13Не увидел там отсечения обновлений, когда новое значение компутеда эквивалентно предыдущему.
SashaFarkas Автор
15.11.2023 06:13Это вшито в сам state, который computed использует под капотом.
nin-jin
15.11.2023 06:13Не увидел там аналога doubt состояния.
SashaFarkas Автор
15.11.2023 06:13Если я не ошибаюсь, doubt состояние не нужно в моем алгоритме. Т.к. проход осуществляется в 2 этапа: от рута к листьям, а потом от листьев к руту. Я наверняка знаю, какие узлы должны быть обновлены
Хм, я понял, о чем вы. Мой "doubt" реализован в виде кэша в Unit Of Work - если computed'а там нет, то он считается состоянием "doubt".
nin-jin
15.11.2023 06:13То есть вы каждый раз ходите по всему дереву вместо того пути, где есть изменения. Это крайне ресурсоёмко.
SashaFarkas Автор
15.11.2023 06:13Я хожу по дереву от измененных рутов. Если дохожу до хоть одного конечного обзервера (не-computed), то начинаю идти от него и запрашивать обновления.
nin-jin
15.11.2023 06:13Существование у вас "множества рутов" тоже сомнительная практика, так как приводит к недетерминизму поведения со всеми вытекающими. В идеале, у приложении должен быть ровно один рут в точке входа, который гарантирует стабильную очерёдность всех вычислений и своевременное уничтожение атомов до их возможного избыточного пересчёта.
nin-jin
15.11.2023 06:13Кроме того, на сколько я понял, компутеды сами по себе не в курсе, есть ли у них косвенные изменённые зависимости, то есть могут выдавать устаревшее значение, что приводит как минимум к лишним перевычисляениям, а как максимум к побочным эффектам с некорректным поведением.
SashaFarkas Автор
15.11.2023 06:13Можете написать тесты и попробовать сломать алгоритм. Буду рад :D
Думаю, так будет гораздо продуктивнее. Можете писать мне в ЛС
nin-jin
15.11.2023 06:13А это кому больше надо: мне или вам? Я не трогал Дарт уже 10 лет, и как-то не планирую к нему больше прикасаться.
PackRuble
15.11.2023 06:13Непосредственное нахардкоживание
late final
во вьюмоделях может означать только одно - что нам делать, когда придёт время тестов. И честно, не увидел в readme пакета и не услышал в статье ни одного слова о тестировании: как и возможно ли?Далее, примеры счётчиков настолько заезженные и банальные, что не отражают ровным счётом ничего и плохо пахнут. В противовес вашему примеру, пример на
ValueNotifier
(соблюдая именования и стиль):import 'package:flutter/material.dart'; class CounterViewModel extends ValueNotifier<int> { CounterViewModel() : super(0); void increment() => value++; } // Внутри StatefulWidget final vm = CounterViewModel(); // ... @override Widget build(BuildContext context) { return ValueListenableBuilder<int>( valueListenable: vm, builder: (context, count, _) { return ElevatedButton( onPressed: vm.increment, child: Text("$count"), ); }, ); } // ...
Организация, к примеру, полноценной поисковой строки, с фильтрами и различными состояниями, и парой необычных возможностей была бы куда интересней и продуктивней.
Пункт "Почему не использовать уже существующее решение?" откровенно слаб и очень хочется его реального раскрытия. И вот почему:
Riverpod:
Не нравится подход со смешиванием DI и State Management'а.
Однако заметьте, что в реальном приложении придётся использовать и то и другое (под каким бы соусом не был подан DI). В данном случае, мы бы воспользовались vessel + beholder. А есть ли смысл импортировать два пакета вместо одного?
Засорение глобального скоупа
Тем, что мы имеем один
ProviderScope
, в котором содержится одинProviderContainer
, который и содержит состояния наших провайдеров? Ну я вам скажу, что ещё можно поиграться с UncontrolledProviderScope и контейнеры создавать независимо. А ещё использоватьProviderScope.overrides
иProviderScope.parent
для переопределения для конкретной ветки.Тяжело масштабировать
Пожалуй это самое нелепое обвинение в сторону Riverpod. Начну с того, что
StateNotifier
уже устаревшая концепция. Используйте (Async)NotifierProvider. И комбинируйте состояния ровно также, как вы это делаете в случае с вашей библиотекой (ваш последний пример не ясен, возможно он содержит ошибку в именованииSearchUsersViewModel
|UsersViewModel
):final selectedProjectId = StateProvider((_) => 32); final users = Provider((_) => <User>[]); final filteredUsers = Provider((ref) { final projectId = ref.watch(selectedProjectId); return ref .watch(users) .where((user) => user.projects.contains(projectId)) .toList(); });
Это классический стиль. При необходимости дополнительного namespase перенесите провайдеров в статические поля ваших ViewModel, либо используйте
Notifier
, если планируется управление над получившимся состоянием:class FilteredUsersNotifier extends Notifier<List<User>> { late List<User> _users; late int _projectId; @override List<User> build() { _users = ref.watch(users); _projectId = ref.watch(selectedProjectId); return _users.where((user) => user.projects.contains(_projectId)).toList(); } User findById(id) {/*делайте что-то*/} }
Опять же, такие примеры выглядят глупо из-за отсутствия реальной задачи.
Bloc:
Определение более-менее сложных состояний требует кодогенерации copyWith.
copyWith
используется, когда модели являются иммутабельными и стейт-менеджер основан на сравнении hashcode для обновления состояния. Как в этом плане работает beholder? Если он основан на мутабельном состоянии, то как избежать лишних перестроек, когда данные на самом деле не изменились, но их присвоение произошло?Субъективно, но в больших проектах именование Event'ов и State'ов начинает напоминать энтерпрайз Java:
class RefreshPostsHomeScreenEvent
А как мы это избегаем здесь? ModelView превращаются в повелители всего и вся с сотнями методов и сотнями состояний?
AsyncState
,AsyncValue
,Result.guard
- это всё мне что-то очень сильно напоминает на подход в R..?, ну ладно, окей.---
Подводя черту, ваш стейт-менеджер может намного больше, под капотом там всё действительно интересно. Хабр хочет внутренностей и живых примеров приложений, основанных на данном пакете. Быть может, стоит показать конкретный пример, на котором сильно забуксуют имеющиеся менеджеры, а ваш решит проблему с лёгкостью. Пишите, пожалуйста, ещё. Независимо от всего, вы молодец, проделали большую работу, а полученный опыт может послужить хорошим фундаментом для будущих улучшений и новых пакетов. ????
SashaFarkas Автор
15.11.2023 06:13+1Спасибо за такой развернутый комментарий! Согласен со всем, что написали, но хотел бы прояснить пару моментов:
Непосредственное нахардкоживание
late final
во вьюмоделях может означать только одно - что нам делать, когда придёт время тестов. И честно, не увидел в readme пакета и не услышал в статье ни одного слова о тестировании: как и возможно ли?Пока что не реализовывал библиотеку, предназначенную для тестов, но наличие
late final
не должно ничему помешать. Я обязательно обновлю readme, когда решу, каким способом будет наиболее удобно тестировать.Далее, примеры счётчиков настолько заезженные и банальные, что не отражают ровным счётом ничего и плохо пахнут. В противовес вашему примеру, пример на
ValueNotifier
(соблюдая именования и стиль):Да, понимаю, пример неудачный, но хотел предоставить одновременно информативный и не занудный для начала пример. Более подробный (и, на мой взгляд, интересный пример) - форма регистрации + исходный код beholder_form. Действительно показывает, насколько лаконичные и мощные получаются решения.
Пожалуй это самое нелепое обвинение в сторону Riverpod. Начну с того, что
StateNotifier
уже устаревшая концепция. Используйте (Async)NotifierProvider. И комбинируйте состояния ровно также, как вы это делаете в случае с вашей библиотекой (ваш последний пример не ясен, возможно он содержит ошибку в именованииSearchUsersViewModel
|UsersViewModel
):Ошибку поправил, а про NotifierProvider не знал, работал с riverpod'ом еще 1-ой версии. Виноват, что не проверил :)
Почему у меня не вышло с riverpod:
Началось все с формы из 3 полей. Потом проект разросся, и форм стало много. Чтобы не получать по 4 autocomplete'а на firstNameFieldProvider, я начал их класть в static классы - стало неудобно, часть провайдеров лежала в глобальном неймспейсе, часть - в классах. После этого я решил переиспользовать логику форм, но провайдеры на то и статические - много инстансов не создашь. Пришлось абсолютно все сносить и переписывать на StateNotifier (но, насколько помню, решение все равно получилось некрасивым - либо вследствие отсутствия опыта, либо из-за неуклюжести riverpod'а).copyWith
используется, когда модели являются иммутабельными и стейт-менеджер основан на сравнении hashcode для обновления состояния. Как в этом плане работает beholder? Если он основан на мутабельном состоянии, то как избежать лишних перестроек, когда данные на самом деле не изменились, но их присвоение произошло?Каждый observable принимает equals; по умолчанию - это сравнение (
==
). Значения observable на самом деле иммутабельные - разработчик переприсваивает value отдельных observable также, как BLoC переприсваивает state. Вот и получается, что в BLoC тебе нужно определять стейт целиком, а в beholder - по кусочкам - без нужды в copyWith.AsyncState
,AsyncValue
,Result.guard
- это всё мне что-то очень сильно напоминает на подход в R..?, ну ладно, окей.С AsyncState была опечатка, должен быть AsyncValue.
Действительно, мне очень понравилось то, как был сделан этот union в riverpod - очень емкое и универсальное средство для описания асинхронных состояний.
Возможно, напишу статью по внутреннему алгоритму или какой-нибудь туториал с боевым use-case'ом. Очень ценные у Вас советы, еще раз - спасибо!
Cobalt
Видимо автор не имел дело с Java раз боится длинных имен классов и методов ). (сарказм если что)