Предыстория
Я достаточно долгое время писал мобильные приложения исключительно на 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и изменяемое состояние через методstateclass 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
