Простой, но масштабируемый 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 {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
…
Как использовать?
Импортируйте библиотеку:
import "package:beholder_flutter/beholder_flutter.dart";
Определите
ViewModel
и изменяемое состояние через методstate
class 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([]);
/// `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