Миксины
SoloListenable делает контроллер ValueListenable, а значит, и Listenable.
ValueListenableBuilder, ListenableBuilder и AnimatedBuilder фреймворка
принимают контроллер как есть, а README пакета показывает первый из них
в разделе
«Контроллер как ValueListenable».
Билдеры этого пакета, SoloBuilder и SoloSelector, принимают любой
контроллер, с миксином и без. Эта страница о трёх вещах вокруг миксина:
- «Контроллер с обеими доставками»:
SoloListenableкомбинируется сSoloStreamизsolo, в любом порядке. Тогда у одного изменения два маршрута ошибок, аclose()ждёт каждого подписчикаstream. - «Экран на стриме»: бейдж на
stream, который должен показывать состояние с первого же кадра. - «Базовый класс без Flutter»: общая база
контроллеров приложения, в пакете без Flutter, сохраняет свой отчёт об ошибке
слушателя, когда контроллеры подмешивают
SoloListenable.
Строки под блоком кода показывают, что даёт его запуск: текст на экране после каждого шага или место, куда ушло сообщение об ошибке слушателя.
Контроллер с обеими доставками
Заголовок раздела «Контроллер с обеими доставками»SoloListenable комбинируется с SoloStream, когда контроллер, который кормит
виджеты, ещё и отдаёт broadcast-stream коду, который его принимает:
sealed class SessionState { const SessionState();}
final class SignedOut extends SessionState { const SignedOut();}
final class SignedIn extends SessionState { final String name;
const SignedIn(this.name);}
final class Session extends Solo<SessionState> with SoloStream, SoloListenable { Session(super.initialState);
Job<void> signIn(String name) => enqueue<SessionState, void>((ctx) async => ctx.emit(SignedIn(name)));}Обе доставки работают, каждая по своему расписанию: слушатель
за ValueListenableBuilder срабатывает синхронно, внутри изменения, а событие
stream приходит микротаской позже. Порядок двух миксинов ничего не меняет:
ни один не переопределяет того, что переопределяет другой.
Комбинация стоит двух вещей. У одного изменения два маршрута ошибок: ошибка
слушателя идёт в FlutterError, а ошибка подписчика stream идёт в зону,
в которой он подписался. И await close() ждёт, пока каждый подписчик stream
не получит событие завершения, тогда как контроллер с одним SoloListenable
не ждёт никого из своих слушателей. С SoloStream, с SoloListenable или без
него, await for по stream держит close(), пока его тело ещё чего-то ждёт.
Всё это время pending равен SoloPendingStream, а как его спросить,
показывает раздел
«Что удерживает контроллер»
страницы о диагностике solo.
Экран на стриме
Заголовок раздела «Экран на стриме»Требование обычное: бейдж показывает состояние той сессии, которую ему дали,
с первого же кадра. У Session выше есть stream, а у фреймворка есть виджет,
который стрим принимает.
Первая попытка
Заголовок раздела «Первая попытка»// Первая попытка: бейдж ждёт следующего изменения.class SessionBadge extends StatelessWidget { final Session session;
const SessionBadge(this.session, {super.key});
@override Widget build(BuildContext context) => StreamBuilder<SessionState>( stream: session.stream, builder: (context, snapshot) => Text(switch (snapshot.data) { SignedIn(:final name) => 'signed in as $name', SignedOut() => 'signed out', null => 'nothing yet', }), );}Ветка null написана потому, что она есть у типа, и с неё экран открывается:
mounted over a session signed in as Ada: nothing yetafter Bob signs in: signed in as BobBroadcast-стрим ничего не повторяет. Подписчик слышит изменения, пришедшие
после подписки, а состояние, в котором контроллер уже был, к ним не относится.
Поэтому бейдж ждёт изменения, чтобы показать то, что было верно ещё до его
постройки. Состояние всё это время на месте: currentState держит SignedIn
в том же билдере, который рисует nothing yet.
Экран встречает это каждый раз, когда строится заново: когда его маршрут
открыли, когда с вкладки ушли и на неё вернулись. Ждать приходится
до следующего изменения, а для сессии это может быть остаток дня. Закрытие
делает ожидание бесконечным: после close() стрим завершён, и события уже
не будет никогда, а currentState по-прежнему держит состояние, которого бейдж
так и не показал.
mounted over a session signed in as Ada: nothing yetafter close(): nothing yetВторая попытка
Заголовок раздела «Вторая попытка»initialData даёт билдеру состояние, с которого начать, а состояние, в котором
сессия находится сейчас, отдаёт currentState:
// Вторая попытка: при другой сессии остаются старые данные.StreamBuilder<SessionState>( stream: session.stream, initialData: session.currentState, builder: (context, snapshot) => Text(switch (snapshot.requireData) { SignedIn(:final name) => 'signed in as $name', SignedOut() => 'signed out', }),),Данные снимка и currentState отличаются тем, откуда они берутся. Данные
снимка стрим доставил этому подписчику, а пока он ничего не доставил, это
initialData; currentState хранит состояние, в котором контроллер находится
сейчас. initialData отдаёт подписчику это состояние в момент подписки,
поэтому данные у снимка есть с первого кадра, и requireData читает их без
ветки null. Изъян виден, когда бейджу дают другую сессию:
mounted over a session signed in as Ada: signed in as Adaafter Bob signs in: signed in as Bobhanded another session, signed in as Cy: signed in as BobStreamBuilder читает initialData один раз, при первой постройке. Получив
новый стрим, он подписывается на него и оставляет данные, которые доставил
старый, поэтому бейдж показывает Боба поверх сессии Сая, пока та не изменится.
Если рядом с initialData стоит key: ObjectKey(session), StreamBuilder для
другой сессии строится заново и читает initialData ещё раз, и бейдж сразу
показывает Сая: два аргумента, которые несёт каждый виджет на стриме.
Билдер, который принимает контроллер
Заголовок раздела «Билдер, который принимает контроллер»Билдер этого пакета принимает контроллер, а не доставку, поэтому передавать на первый кадр нечего:
SoloBuilder<SessionState>( solo: session, builder: (context, state, _) => Text(switch (state) { SignedIn(:final name) => 'signed in as $name', SignedOut() => 'signed out', }),),mounted over a session signed in as Ada: signed in as Adaafter Bob signs in: signed in as Bobhanded another session, signed in as Cy: signed in as CySoloBuilder читает currentState в build и переходит на контроллер,
который ему дали. Бейдж поэтому показывает состояние своей сессии с первого
кадра и с того кадра, в котором ему дали другую. То же делает
ValueListenableBuilder, которому Session годится, потому что подмешивает
SoloListenable: с valueListenable: session он показывает те же три строки.
stream нужен не виджетам: журналу, мосту в код, который принимает Stream,
тесту, которому нужна вся последовательность изменений.
Базовый класс без Flutter
Заголовок раздела «Базовый класс без Flutter»Общий базовый класс контроллеров приложения может жить в пакете без Flutter.
Этот пакет зависит от solo, а SoloListenable подмешивает уже контроллер
приложения, который наследуется от базы. В базу идёт то, что общее у всех
контроллеров, и здесь это сообщение об ошибке слушателя в собственный журнал
приложения, AppLog. Profile и Empty в коде ниже взяты из раздела
«Как пользоваться»
README пакета: там объявлены его состояния.
Первая попытка
Заголовок раздела «Первая попытка»У движка есть хук для этой ошибки, и база его переопределяет:
// Первая попытка: переопределение миксина побеждает базовое.// Свой пакет, без Flutter.import 'package:meta/meta.dart';import 'package:solo/solo.dart';
abstract class AppController<S extends Object> extends Solo<S> { AppController(super.initialState);
@protected @override void onListenerError(Object error, StackTrace stackTrace) => AppLog.error(error, stackTrace);}
// Приложение.final class ProfileController extends AppController<Profile> with SoloListenable { ProfileController() : super(Empty());}Слушатель ProfileController бросает, и сообщение проходит мимо базы:
AppLog: nothingFlutterError: Bad state: the listener blew upОт того, где стоит миксин, зависит, чей onListenerError сообщит об ошибке
слушателя. Миксин стоит над классом, написанным перед with, поэтому вызов
хука начинается с контроллера приложения и идёт вниз в таком порядке:
ProfileController, SoloListenable, AppController, Solo. Срабатывает
первый onListenerError на этом пути, здесь тот, что в SoloListenable, так
что побеждает его переопределение с отчётом через FlutterError,
а переопределение базы не вызывается вовсе. Анализатор об этом молчит. Через
super контроллер приложения до базы тоже не дотянется: super там ведёт
в миксин, а переопределение, которое только его и зовёт, анализатор называет
лишним.
Отчёт под другим именем
Заголовок раздела «Отчёт под другим именем»База держит свой отчёт в методе под другим именем, которого не переопределяет ни один миксин, и её собственный хук зовёт его:
// Свой пакет, без Flutter.import 'package:meta/meta.dart';import 'package:solo/solo.dart';
abstract class AppController<S extends Object> extends Solo<S> { AppController(super.initialState);
@protected void reportListenerError(Object error, StackTrace stackTrace) => AppLog.error(error, stackTrace);
@protected @override void onListenerError(Object error, StackTrace stackTrace) => reportListenerError(error, stackTrace);}
// Приложение.final class ProfileController extends AppController<Profile> with SoloListenable { ProfileController() : super(Empty());
@protected @override void onListenerError(Object error, StackTrace stackTrace) => reportListenerError(error, stackTrace);}AppLog: Bad state: the listener blew upFlutterError: nothingХук базы сообщает за контроллер, который ничего не подмешивает, а хук
ProfileController отправляет туда же ошибки своих слушателей. Контроллер
приложения, которому нужен ещё и отчёт через FlutterError, зовёт
super.onListenerError рядом с reportListenerError. @protected повторён
на каждом переопределении, потому что Dart его не наследует: без него хук
становится публичным членом этого класса и каждого класса, который наследует
такое переопределение. Аннотация лежит в package:meta, а его не экспортируют
ни solo, ни этот пакет: пакет без Flutter ради неё зависит от meta,
а в приложении её экспортирует package:flutter/foundation.dart.
Это переопределение пишет сам контроллер приложения, и тот, который подмешал
SoloListenable без него, сообщает через FlutterError, как в первой попытке,
при таком же молчании анализатора. Приложение с несколькими контроллерами пишет
его один раз, в собственном классе между базой и контроллерами:
// Приложение.abstract class ListenableController<S extends Object> extends AppController<S> with SoloListenable { ListenableController(super.initialState);
@protected @override void onListenerError(Object error, StackTrace stackTrace) => reportListenerError(error, stackTrace);}
final class ProfileController extends ListenableController<Profile> { ProfileController() : super(Empty());}Класс стоит над миксинами, которые подмешивает, поэтому срабатывает
переопределение ListenableController, и так у каждого контроллера, который
от него наследуется: ProfileController здесь сообщает в AppLog, а не через
FlutterError. База, в которой Flutter есть, так же подмешивает
SoloListenable сама и сохраняет своё переопределение. Только подмешивайте его
один раз: подмешанный ещё раз в контроллере, который наследуется от такого
класса, он встаёт над этим переопределением и глушит его, как в первой попытке.
Четыре контроллера приложения из этого раздела рядом, у каждого цепочка
от контроллера вниз, жирным — тот onListenerError, который срабатывает:
ProfileController |
Цепочка от контроллера вниз | Отчёт |
|---|---|---|
extends AppController with SoloListenable |
ProfileController → SoloListenable → AppController → Solo |
FlutterError |
| то же, со своим переопределением | ProfileController → SoloListenable → AppController → Solo |
AppLog |
extends ListenableController |
ProfileController → ListenableController → SoloListenable → AppController → Solo |
AppLog |
extends ListenableController with SoloListenable |
ProfileController → SoloListenable → ListenableController → SoloListenable → AppController → Solo |
FlutterError |