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

873e7b9508df867d9fd96797d6b3d80b

Предыстория

Я достаточно долгое время писал мобильные приложения исключительно на 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 
{props.vm.count}
; });

Никакой кодогенерации, никакого бойлерплейта — пишешь, какие поля 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([]);

  /// `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>>(const Loading());
  
  Future refresh()  async {
    users
      ..value = Loading()
      ..value = await Result.guard(
        () => ApiClient.fetchUsers(query: search.value)
      );
  }

  // ...
}

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

А вот так:

late final filteredUsers = computed>>((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 projectId;
  
  late final _users = state([]);

  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

© Habrahabr.ru