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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Предыстория

Я достаточно долгое время писал мобильные приложения исключительно на Flutter (примерно с версии 1.2) и успел попробовать несколько подходов к State Management'у (в порядке знакомства):

  • Streams и rxdart

  • BLoC

  • Provider

  • riverpod

Не скажу, что я был от них в восторге, но они предоставляли достойное разделение 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(),
      );
    });

  // AsyncState встроен в библиотеку. 
  late final users = state<AsyncState<List<User>>>(const Loading());
  
  Future<void> refresh()  async {
    users
      ..value = Loading()
      ..value = await Result.guard(
        () => ApiClient.fetchUsers(query: search.value)
      );
  }

  // ...
}

Что насчет использованных ранее computed'ов? Как им использовать users, который теперь стал AsyncState?

А вот так:

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: "),
      };
    }
  );
}

Т.к. AsyncState - это sealed union, мы можем исчерпывающе перебрать все возможные варианты. Больше про Pattern Matching - здесь.

Как масштабировать?

ViewModel легко совмещаются посредством композиции(en):

class UsersViewModel {
  SearchUsersViewModel(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 {
  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/


Интересные статьи

Интересные статьи

Всем привет! На связи Юрий Петров, Flutter Team Lead в Friflex. В предыдущей статье мы познакомились с работой шейдеров во Flutter, а также рассмотрели, как написать свой собственный шейдер на языке G...
Меня зовут Алексей, я основатель и frontend разработчик системы автоматизации работы управляющих компаний «Оператор 18». Сейчас я переписываю проекта с нуля, с учётом ошибок применения архитектур...
Доброго времени суток, дорогие читатели! Меня зовут Сурен, и я разработчик. Поскольку моя предыдущая статья о том, как бекендер в мобильную кроссплатформу лез, не утонула в минусах, я решил продолжит...
Приглашаем на эфир Flutter live-coding сессии. Покажем, что такое говнокод по-флаттеровски, и дадим лайфхаки, как писать на Flutter красиво. 26 мая, среда, 18:30 мск.&nbs...
Процесс онбординга в Miro радикально изменился за последний год. Мы быстро растём и прежние подходы, которые работали при найме нескольких человек в месяц в один офис, ок...