Простой, но масштабируемый State Management для Flutter

от автора

Предыстория

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

Как использовать?

  1. Импортируйте библиотеку:

    import "package:beholder_flutter/beholder_flutter.dart";
  2. Определите ViewModel и изменяемое состояние через метод state

    class CounterViewModel extends ViewModel {   late final count = state(0);   void increment() => count.value++; }
  3. Слушайте изменения с помощью 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


ссылка на оригинал статьи https://habr.com/ru/articles/773980/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *