Предыстория

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

Комментарии (22)


  1. Cobalt
    15.11.2023 06:13

    Видимо автор не имел дело с Java раз боится длинных имен классов и методов ). (сарказм если что)


  1. nin-jin
    15.11.2023 06:13
    -3

    Тогда я и решил написать "свой" mobx...

    А почему не $mol_wire, который по всем аспектам минимум в 2 раза лучше MobX и умеет в прозрачные для потребителя асинхронные реактивные инварианты?


    1. nin-jin
      15.11.2023 06:13
      -4

      Ну а пока вы ставите минусы, продолжу бесплатно просвещать..

      Изменение projectId спровоцирует моментальное изменение filteredUsers

      Это самая бестолковая стратегия.


      1. SashaFarkas Автор
        15.11.2023 06:13

        Возможно, я неправильно выразился - изменение state'ов всегда собирается в "кучу", а обновление computed 'ов выполняется в следующем микротаске.

        Но я бы отнес это к implementation detail, потому как вызов value на computed спровоцирует моментальный rebuild.


        1. nin-jin
          15.11.2023 06:13

          Следующий микротаск - тоже слишком рано, так как в нём могут произойти её какие-то изменения, а значение компутеда всё ещё не понадобиться.


          1. SashaFarkas Автор
            15.11.2023 06:13
            +1

            Если значение computed'а не слушается в данный момент, он не ребилдится.


            1. nin-jin
              15.11.2023 06:13

              Оно может в данный момент слушаться, но в следующем микротаске уже перестать.


              1. SashaFarkas Автор
                15.11.2023 06:13

                Посмотрите реализацию алгоритма - она укладывается в одном файле из 200 строк. Если знаете, как улучшить, буду рад PR'у.


                1. nin-jin
                  15.11.2023 06:13

                  1. SashaFarkas Автор
                    15.11.2023 06:13

                    Это вшито в сам state, который computed использует под капотом.


                    1. nin-jin
                      15.11.2023 06:13

                      Не увидел там аналога doubt состояния.


                      1. SashaFarkas Автор
                        15.11.2023 06:13

                        Если я не ошибаюсь, doubt состояние не нужно в моем алгоритме. Т.к. проход осуществляется в 2 этапа: от рута к листьям, а потом от листьев к руту. Я наверняка знаю, какие узлы должны быть обновлены

                        Хм, я понял, о чем вы. Мой "doubt" реализован в виде кэша в Unit Of Work - если computed'а там нет, то он считается состоянием "doubt".


                      1. nin-jin
                        15.11.2023 06:13

                        То есть вы каждый раз ходите по всему дереву вместо того пути, где есть изменения. Это крайне ресурсоёмко.


                      1. SashaFarkas Автор
                        15.11.2023 06:13

                        Я хожу по дереву от измененных рутов. Если дохожу до хоть одного конечного обзервера (не-computed), то начинаю идти от него и запрашивать обновления.


                      1. nin-jin
                        15.11.2023 06:13

                        Существование у вас "множества рутов" тоже сомнительная практика, так как приводит к недетерминизму поведения со всеми вытекающими. В идеале, у приложении должен быть ровно один рут в точке входа, который гарантирует стабильную очерёдность всех вычислений и своевременное уничтожение атомов до их возможного избыточного пересчёта.


                      1. nin-jin
                        15.11.2023 06:13

                        Кроме того, на сколько я понял, компутеды сами по себе не в курсе, есть ли у них косвенные изменённые зависимости, то есть могут выдавать устаревшее значение, что приводит как минимум к лишним перевычисляениям, а как максимум к побочным эффектам с некорректным поведением.


                      1. SashaFarkas Автор
                        15.11.2023 06:13

                        Можете написать тесты и попробовать сломать алгоритм. Буду рад :D

                        Думаю, так будет гораздо продуктивнее. Можете писать мне в ЛС


                      1. nin-jin
                        15.11.2023 06:13

                        А это кому больше надо: мне или вам? Я не трогал Дарт уже 10 лет, и как-то не планирую к нему больше прикасаться.


                      1. akella122
                        15.11.2023 06:13

                        Но флаттера меньше 10 лет...


                      1. ogregor
                        15.11.2023 06:13

                        Нам повезло что VM Dart писали те же люди что и VM V8 ????


  1. 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..?, ну ладно, окей.

    ---

    Подводя черту, ваш стейт-менеджер может намного больше, под капотом там всё действительно интересно. Хабр хочет внутренностей и живых примеров приложений, основанных на данном пакете. Быть может, стоит показать конкретный пример, на котором сильно забуксуют имеющиеся менеджеры, а ваш решит проблему с лёгкостью. Пишите, пожалуйста, ещё. Независимо от всего, вы молодец, проделали большую работу, а полученный опыт может послужить хорошим фундаментом для будущих улучшений и новых пакетов. ????


    1. 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.

      AsyncStateAsyncValueResult.guard - это всё мне что-то очень сильно напоминает на подход в R..?, ну ладно, окей.

      С AsyncState была опечатка, должен быть AsyncValue.
      Действительно, мне очень понравилось то, как был сделан этот union в riverpod - очень емкое и универсальное средство для описания асинхронных состояний.

      Возможно, напишу статью по внутреннему алгоритму или какой-нибудь туториал с боевым use-case'ом. Очень ценные у Вас советы, еще раз - спасибо!