Пример камеры
Контроллер камеры управляет устройством: каждая операция занимает время,
устройство может сломаться само, а закрывать его приходится намеренно. Эта
страница собирает такой контроллер по одному решению за раз: правила состояния,
политики очереди, освобождение устройства. Заканчивается она кодом запускаемого
пакета example/: последний фрагмент каждого
метода ниже повторяет его код в example/lib/src/camera_controller.dart, если
не считать комментариев.
Состояния:
sealed class CameraState { const CameraState();}
sealed class NotDisposed extends CameraState { const NotDisposed();}
final class Initial extends NotDisposed { const Initial();}
final class Preparing extends NotDisposed { const Preparing();}
final class Ready extends NotDisposed { final double zoom; final Point<double>? focusPoint; final bool paused;
const Ready({this.zoom = 1, this.focusPoint, this.paused = false});
Ready copyWith({double? zoom, Point<double>? focusPoint, bool? paused}) => Ready( zoom: zoom ?? this.zoom, focusPoint: focusPoint ?? this.focusPoint, paused: paused ?? this.paused, );}
final class Broken extends NotDisposed { final Object error;
const Broken(this.error);}
final class Disposed extends CameraState { const Disposed();}NotDisposed объединяет все состояния, в которых с устройством ещё можно
работать, а в Disposed уже нельзя. В Broken попадает сбой устройства;
Failed это состояние не назвали, потому что так называется исход. В примере
каждое состояние ещё и печатает себя, и этот текст показывают журналы ниже;
а два Ready с одинаковыми полями там равны.
null, переданный в copyWith, оставляет старое значение, поэтому copyWith
не может очистить focusPoint, где null означает автоматический фокус.
resetFocusPoint в примере публикует новый Ready и сохраняет в нём текущий
масштаб.
Устройством служит FakeCameraHardware из примера, и контроллер держит его
в поле hw. Каждая операция занимает десять миллисекунд, съёмка занимает
тридцать и возвращает Photo. failures заставляет названную операцию упасть
после её задержки, fail сообщает о сбое вне всякой задачи и для этого
вызывает onError, колбэк, который ставит контроллер, а log записывает, где
каждая операция начинается и кончается.
Журналы ниже печатает для этого кода наблюдатель примера. В них видны задача
started, finished с исходом или dropped до старта, error, о которой она
сообщила, строка log, каждая смена state: и closed, когда контроллер
закрылся. Задача названа своим ключом, значением перечисления CameraKey
из примера, а за ним идёт текст её describe, если он есть:
[setZoom: zoom: 2.0]. Строки дочерней задачи начинаются с >.
Открытие камеры
Заголовок раздела «Открытие камеры»init публикует Preparing, открывает устройство и публикует Ready.
Стартовать эта задача может только из Initial.
Первая попытка
Заголовок раздела «Первая попытка»// Первая попытка: первый emit выводит из рабочего типа.Job<void> init() => enqueue<Initial, void>( key: CameraKey.init, policy: Policy.droppable, (ctx) async { ctx.emit(const Preparing()); await ctx.join(hw.open); ctx.emit(const Ready()); },);[init] startedstate: Preparing()[init] finished Cancelled(rules: is not Initial)Рабочий тип задаёт состояние, которого правила задачи требуют, пока она идёт,
и enqueue<Initial, void> требует Initial. Первый же emit тела из него
уходит, join на следующей строке — контрольная точка, которая застаёт
состояние вне Initial, и задача кончается Cancelled раньше, чем hw.open
вообще вызван: журнал устройства остаётся пустым. Контроллер остаётся
в Preparing, и следующая init() отбрасывается на старте по той же причине:
открыть камеру больше нельзя вовсе.
Рабочий тип пошире
Заголовок раздела «Рабочий тип пошире»Job<void> init() => enqueue<NotDisposed, void>( key: CameraKey.init, policy: Policy.droppable, canStart: (state) => state is Initial, (ctx) async { ctx.emit(const Preparing()); await ctx.join(hw.open); ctx.emit(const Ready()); },);NotDisposed нужен телу на всём его протяжении: Preparing и Ready оба
в него входят, и публикации остаются внутри правил. Откуда задача может
стартовать, решается отдельно, и на этот вопрос отвечает canStart. Без него
вторая init() на уже открытой камере запускается снова и открывает устройство
второй раз. С ним вызов кончается до старта:
[init] dropped Cancelled(rules: canStart)Вторая init(), вызванная, пока первая ещё идёт, до этого не доходит:
Policy.droppable отдаёт ей ту Job, что уже в работе, и устройство
открывается один раз.
Открытие, которое не удалось
Заголовок раздела «Открытие, которое не удалось»Устройство может отказаться открываться, пока камеру держит другое приложение, а задачу могут отменить посреди открытия. В обоих случаях контроллер должен оказаться в состоянии, из которого камеру можно будет открыть позже.
Первая попытка
Заголовок раздела «Первая попытка»// Первая попытка: отмена оставляет состояние в Preparing.(ctx) async { ctx.emit(const Preparing()); try { await ctx.join(hw.open); } on Object catch (error) { ctx.emit(Broken(error)); rethrow; } ctx.emit(const Ready());},Сбой попадает куда нужно: catch публикует Broken, и задача кончается
Failed. Отмена проходит через тот же catch, и там emit ничего
не публикует. На уже отменённой задаче он работает точкой проверки и вместо
публикации бросает Cancelled:
[init] startedstate: Preparing()[init] finished Cancelled(manual)join дождался открытия, так что устройство открыто, а состояние говорит, что
оно ещё открывается. Из Preparing не стартует ни один из двух способов
открыть камеру: init хочет Initial, а reopen, обратный путь примера,
хочет Ready или Broken. Это ловушка из раздела
«Состояние после ошибки или отмены»
страницы о состоянии, только за спиннером стоит устройство.
Обработчики enqueue
Заголовок раздела «Обработчики enqueue»Job<void> init() => enqueue<NotDisposed, void>( key: CameraKey.init, policy: Policy.droppable, canStart: (state) => state is Initial, ifFailed: (state, error, stackTrace) => Broken(error), ifCancelled: (state, cancelled) => Broken(cancelled), (ctx) async { ctx.emit(const Preparing()); await ctx.join(hw.open); ctx.emit(const Ready()); },);Обработчики вычисляют состояние, когда тело уже закончилось, поэтому отмена
доходит до своего обработчика, а не до отклонённого emit. И сбой, и отмена
приводят в Broken, а оттуда стартует reopen:
[init] startedstate: Preparing()[init] error Bad state: camera in usestate: Broken(Bad state: camera in use)[init] finished Failed(Bad state: camera in use)[init] startedstate: Preparing()state: Broken(Cancelled(manual))[init] finished Cancelled(manual)Задача, отброшенная до старта, не доходит ни до одного обработчика, так что
вторая init() из раздела выше по-прежнему кончается одной строкой dropped.
У reopen те же два обработчика.
Сбой, о котором сообщили во время открытия
Заголовок раздела «Сбой, о котором сообщили во время открытия»Устройство и само сообщает о сбоях, и сообщение может прийти, пока камера
открывается. Контроллер превращает его в Broken, и открытие должно оставить
Broken на месте: камера, которую оно открывало, сломана.
Первая попытка
Заголовок раздела «Первая попытка»// Первая попытка: Ready публикуется поверх сообщённого сбоя.(ctx) async { ctx.emit(const Preparing()); await ctx.join(hw.open); ctx.emit(const Ready());},Это тело init из раздела «Обработчики enqueue»,
а устройство сообщает о сбое, пока идёт hw.open:
[init] startedstate: Preparing()state: Broken(Bad state: cable pulled)state: Ready(zoom: 1.0, focusPoint: null, paused: false)[init] finished Done(null)Broken тоже относится к NotDisposed, поэтому ни одно правило задачи его
не отвергает. Ожидание возвращается как обычно, тело публикует Ready поверх
сообщения, и задача кончается Done, с которым не вызывается ни один
обработчик. Камера говорит, что готова, после того как устройство сказало, что
нет. reopen делает то же со сбоем, о котором сообщили, пока он открывает
устройство или восстанавливает масштаб.
keepWhile
Заголовок раздела «keepWhile»Job<void> init() => enqueue<NotDisposed, void>( key: CameraKey.init, policy: Policy.droppable, canStart: (state) => state is Initial, keepWhile: (state) => state is! Broken, ifFailed: (state, error, stackTrace) => Broken(error), ifCancelled: (state, cancelled) => Broken(cancelled), (ctx) async { ctx.emit(const Preparing()); await ctx.join(hw.open); ctx.emit(const Ready()); },);keepWhile отвергает то, что пропускает рабочий тип: в Broken открытие
не продолжается. Сообщение записывает это состояние через externalSetState,
правило его отвергает, и задачу отменяют её правила. join остаётся
с устройством, пока оно не откроется, а потом вместо возврата бросает
Cancelled, так что до emit дело не доходит:
[init] startedstate: Preparing()state: Broken(Bad state: cable pulled)[init] finished Cancelled(rules: keepWhile)Задача, которую отменили её правила, оставляет состояние как есть,
а обработчики выключаются в момент самого сообщения. Отмена или сбой hw.open,
пришедшие после него, не доходят ни до одного из них, и сообщённый сбой
остаётся. Это то же правило, которое раздел
«Сохранение несовместимого внешнего состояния»
страницы о состоянии даёт загрузке, у которой обрывается соединение.
reopen стартует из Broken, и сбой, о котором сообщили, пока он закрывает
устройство, — ровно то, что ему надо исправить. Поэтому его правило действует
с того Preparing, который он записывает. Правило читает флаг, а тело
поднимает его прямо перед этим emit: emit не спрашивает правил задачи,
а сообщение, пришедшее, пока Preparing доставляется, правило тоже отвергает:
Job<void> reopen() { var opening = false; return enqueue<NotDisposed, void>( key: CameraKey.reopen, policy: Policy.droppable, canStart: (state) => state is Ready || state is Broken, keepWhile: (state) => !opening || state is! Broken, ifFailed: (state, error, stackTrace) => Broken(error), ifCancelled: (state, cancelled) => Broken(cancelled), (ctx) async { final zoom = switch (ctx.state) { Ready(:final zoom) => zoom, _ => 1.0, }; await ctx.run(_closeCameraJob()); opening = true; ctx.emit(const Preparing()); await ctx.join(hw.open); await ctx.join(() => hw.setZoom(zoom)); ctx.emit(Ready(zoom: zoom)); }, );}Сбой, о котором сообщили, пока он открывает устройство или восстанавливает
масштаб, завершает его так же, как завершает init. Сбой, о котором сообщили,
пока он закрывает устройство, не мешает ему продолжить. reopen(), вызванный
после сообщения, получает свою задачу: та, которую отменило сообщение, ещё
сворачивается, и droppable её не отдаёт.
Только последний масштаб
Заголовок раздела «Только последний масштаб»Щипок запрашивает масштаб на каждом кадре, и важен только последний запрос.
Первая попытка
Заголовок раздела «Первая попытка»// Первая попытка: каждый зум доходит до объектива.Job<void> setZoom(double zoom) => enqueue<Ready, void>( key: CameraKey.setZoom, describe: () => 'zoom: $zoom', (ctx) async { await ctx.join(() => hw.setZoom(zoom)); ctx.emit(ctx.state.copyWith(zoom: zoom)); },);Три вызова в одном такте, setZoom(2), setZoom(3) и setZoom(4),
заканчиваются на Ready(zoom: 4.0, focusPoint: null, paused: false), как
и версия ниже: по состоянию их не различить. Устройство различает. Каждый
запрос ждёт в очереди и доходит до объектива:
zoom 2.0: beginzoom 2.0: endzoom 3.0: beginzoom 3.0: endzoom 4.0: beginzoom 4.0: endPolicy.keepLatest
Заголовок раздела «Policy.keepLatest»Job<void> setZoom(double zoom) => enqueue<Ready, void>( key: CameraKey.setZoom, policy: Policy.keepLatest, describe: () => 'zoom: $zoom', canStart: (state) => !state.paused, (ctx) async { await ctx.join(() => hw.setZoom(zoom)); ctx.emit(ctx.state.copyWith(zoom: zoom)); },);keepLatest ставит в очередь новую задачу и затем отбрасывает ждущую с тем же
ключом, так что те же три вызова доходят до устройства один раз:
[setZoom: zoom: 2.0] dropped Cancelled(replaced)[setZoom: zoom: 3.0] dropped Cancelled(replaced)[setZoom: zoom: 4.0] startedstate: Ready(zoom: 4.0, focusPoint: null, paused: false)[setZoom: zoom: 4.0] finished Done(null)Задача, которая уже идёт, доработает. Если setZoom(3) и setZoom(4)
приходят, пока масштаб 2 ещё выставляется, объектив всё равно встанет на 2,
запрос на 3 отбрасывается, а потом объектив встаёт на 4. canStart не даёт
камере на паузе принять команду вовсе.
Команды, пришедшие во время снимка
Заголовок раздела «Команды, пришедшие во время снимка»Job<Photo> takePhoto() => enqueue<Ready, Photo>( key: CameraKey.takePhoto, policy: Policy.droppable, canStart: (state) => !state.paused, (ctx) async { final photo = await ctx.join(hw.capture); queue.clear(); ctx.log('captured $photo'); return photo; },);Съёмка занимает втрое больше других операций, и команды, запрошенные тем
временем, ждут в очереди. Применить их не к чему: пока идёт съёмка, кадр
зафиксирован, а после неё пользователю показывают готовый снимок. Поэтому,
когда снимок готов, queue.clear() их отбрасывает. Масштаб, запрошенный
на пятой миллисекунде съёмки:
[takePhoto] started[setZoom: zoom: 3.0] dropped Cancelled(manual)[takePhoto] log captured Photo#1[takePhoto] finished Done(Photo#1)Без очистки этот масштаб выставляется, когда снимок закончен. queue.clear()
не трогает работающую задачу, а работает здесь сам снимок. Не трогает очистка
и неотменяемые задачи, а таких этот контроллер ставит в очередь один вид,
освобождение.
Освобождение камеры
Заголовок раздела «Освобождение камеры»dispose() закрывает устройство и публикует Disposed, чем бы камера ни была
занята в момент вызова.
Первая попытка
Заголовок раздела «Первая попытка»// Первая попытка: освобождение ждёт в конце очереди.Job<void> dispose() => enqueue<CameraState, void>( key: CameraKey.dispose, policy: Policy.droppable, cancellable: false, canStart: (state) => state is! Disposed, (ctx) async { if (ctx.state is! Initial) { await ctx.run(_closeCameraJob()); } ctx.emit(const Disposed()); },);
SoloJob<void> _closeCameraJob() => job<NotDisposed, void>( key: CameraKey.closeCamera, cancellable: false, (ctx) async => hw.close(),);Вызванное, пока масштаб выставляется, а за ним ждёт снимок, это освобождение встаёт в конец очереди:
[setZoom: zoom: 2.0] startedstate: Ready(zoom: 2.0, focusPoint: null, paused: false)[setZoom: zoom: 2.0] finished Done(null)[takePhoto] started[takePhoto] log captured Photo#1[takePhoto] finished Done(Photo#1)[dispose] started> [closeCamera] started> [closeCamera] finished Done(null)state: Disposed()[dispose] finished Done(null)Всё, что стоит перед ним, по-прежнему выполняется, и камера делает снимок уже
после того, как ей велели выключаться. cancellable: false при этом верен:
отменить задачу после старта нельзя, как и её дочернюю задачу, закрывающую
устройство, так что устройство никогда не остаётся закрытым наполовину.
Вторая попытка
Заголовок раздела «Вторая попытка»// Вторая попытка: повторный dispose() сообщает об отмене.Job<void> dispose() { queue.clear(); current?.cancel(); return enqueue<CameraState, void>( key: CameraKey.dispose, policy: Policy.droppable, cancellable: false, canStart: (state) => state is! Disposed, (ctx) async { if (ctx.state is! Initial) { await ctx.run(_closeCameraJob()); } ctx.emit(const Disposed()); }, );}[setZoom: zoom: 2.0] started[takePhoto] dropped Cancelled(manual)[setZoom: zoom: 2.0] finished Cancelled(manual)[dispose] started> [closeCamera] started> [closeCamera] finished Done(null)state: Disposed()[dispose] finished Done(null)queue.clear() отбрасывает то, что не стартовало, и снимок до устройства
не доходит. current?.cancel() просит идущую установку масштаба остановиться,
и объектив всё равно встаёт на 2: масштаб ждёт через join, а он выпускает
задачу только тогда, когда начатая операция закончилась. Каждый метод ожидания
показывает таблица в начале страницы «Отмена». Отмена
экономит остаток тела: масштаб не публикует состояния для камеры, которую
вот-вот закроют, и освобождение стартует, как только устройство свободно.
Изъян виден на вызове после конца освобождения. Он ставит в очередь свою
задачу, и canStart отбрасывает её до старта:
[dispose] dropped Cancelled(rules: canStart)Камера освобождена, а вызывающему сказано, что освобождение отменено.
Проверка в теле
Заголовок раздела «Проверка в теле»Job<void> dispose() { queue.clear(); current?.cancel(); return enqueue<CameraState, void>( key: CameraKey.dispose, policy: Policy.droppable, cancellable: false, (ctx) async { if (ctx.state is Disposed) { return; } if (ctx.state is! Initial) { await ctx.run(_closeCameraJob()); } ctx.emit(const Disposed()); }, );}[dispose] started[dispose] finished Done(null)Тот же вызов теперь стартует, видит освобождённую камеру и кончается Done;
журнал устройства остаётся пустым. canStart нужен задаче, которой в каком-то
состоянии выполняться нельзя, а освобождению освобождённой камеры можно: ему
просто нечего делать.
Очистка не принудительная. Освобождение, которое ещё стоит в очереди, её
переживает, потому что оно неотменяемое, и Policy.droppable отдаёт его
второму вызову. Два dispose() подряд получают одну и ту же Job, и оба
вызывающих видят Done. queue.clear(force: true) вместо этого отбросил бы ту
задачу, и её вызывающий получил бы Cancelled(manual) за камеру, которая
всё-таки освобождена.
Закрытие контроллера
Заголовок раздела «Закрытие контроллера»После освобождения сам контроллер отпускается через close().
Первая попытка
Заголовок раздела «Первая попытка»// Первая попытка: close() снимает освобождение из очереди.camera.dispose();await camera.close();[dispose] dropped Cancelled(closed)closedclose() смотрит на то, где задача стоит, а не на её флаг. Идущую задачу он
ждёт, и неотменяемая доходит до конца. Задача, которая ещё в очереди,
не стартует вовсе. close() завершает её с Cancelled(closed), отменяемая она
или нет, и тот, кто её ждёт, сразу получает этот исход, как описывает раздел
«Отмена работы и закрытие контроллера»
страницы об отмене. dispose() поставил задачу в очередь, close() пришёл
в том же такте, и освобождение кончилось, не выйдя из очереди. В журнале
устройства нет close. Камера остаётся открытой, состояние всё ещё говорит
Ready, а контроллер, который мог бы её закрыть, закрыт сам.
Вторая попытка
Заголовок раздела «Вторая попытка»// Вторая попытка: сбой освобождения уходит в зону.camera.dispose();await camera.close(mode: SoloCloseMode.drain);SoloCloseMode.drain выполняет очередь перед закрытием, и освобождение вместе
с ней: устройство закрывается, состояние становится Disposed. Изъян виден,
когда устройство не закрывается:
[dispose] started> [closeCamera] started> [closeCamera] error Bad state: close timed out> [closeCamera] finished Failed(Bad state: close timed out)[dispose] error Bad state: close timed out[dispose] finished Failed(Bad state: close timed out)closedОсвобождение упало, камера всё ещё открыта, а контроллер закрылся сразу после
него, и второй dispose() возвращается Cancelled(closed). Исход первого
никто не читает, а Solo.unansweredHandler не задан, поэтому его провал уходит
в зону необработанной ошибкой, как описывает раздел
«Обработанные и необработанные ошибки»
страницы об ошибках. Провал приходит туда, где с ним уже ничего не сделать.
Дождаться освобождения
Заголовок раздела «Дождаться освобождения»final camera = CameraController(FakeCameraHardware());await camera.init().value;
switch (await camera.dispose().done) { case Done(): print('disposed'); await camera.close(); case Cancelled(:final reason): print('cancelled: $reason'); case Failed(:final error): print('failed: $error');}close() стоит в ветке Done: освобождение заканчивается до его вызова,
и close() нечего отменять.
Failed надо обработать: закрытие, которое упало, освобождением не считается.
ctx.run бросает то, что бросила дочерняя задача, тело не доходит
до emit(Disposed()), и состояние остаётся прежним. Код выше в этом случае
оставляет контроллер открытым, так что второй dispose() может попробовать
снова. Cancelled в этом коде не возвращается. Стартовавшее освобождение
отклоняет любую отмену; ждущее из очереди забирают только close() или
принудительное удаление вроде queue.clear(force: true), а здесь
ни то ни другое не вызывается, пока исход не пришёл. switch называет этот
случай, потому что исход всегда один из трёх. Возвращается Cancelled, когда
close() успевает первым, как в первой попытке, или когда контроллер закрыли
раньше, чем вызвали dispose().
Сбой после освобождения
Заголовок раздела «Сбой после освобождения»Устройство может сообщить о сбое и после освобождения, и контроллер превращает
каждое сообщение в Broken. Когда камера освобождена, сообщение не должно
ничего менять: контроллер заканчивает на Disposed.
Первая попытка
Заголовок раздела «Первая попытка»// Первая попытка: слушатель переживает освобождение.CameraController(this.hw) : super(const Initial()) { hw.onError = (error) => externalSetState(Broken(error));}Слушатель поставлен, и его никто не снимает. Камеру освобождают, а контроллер
закрывают, как в разделе «Дождаться освобождения»,
и между этими шагами, после Disposed и до close(), устройство сообщает
о сбое:
[dispose] started> [closeCamera] started> [closeCamera] finished Done(null)state: Disposed()[dispose] finished Done(null)state: Broken(Bad state: cable pulled)Освобождение решило, каким будет последнее состояние, а следующее сообщение
устройства его заменило. После close() то же сообщение состояния не меняет:
состояние закрытого контроллера окончательно, так что externalSetState
бросает StateError, и бросает его в собственный колбэк устройства.
Вторая попытка
Заголовок раздела «Вторая попытка»// Вторая попытка: сообщение до close() всё ещё затирает Disposed.@overridevoid onClose() => hw.onError = null;Гасить источник в onClose советует раздел
«externalSetState» страницы о состоянии: движок
вызывает onClose один раз, когда последняя задача закончилась, а контроллер
вот-вот закончит закрытие. Сообщение, пришедшее после close(), теперь
не находит слушателя, и ничего не бросается. Сообщение из журнала выше пришло
до close(), и эта версия печатает тот же журнал: камера заканчивает со своим
устройством, когда её освобождают, а контроллер закрывается после этого.
Сначала отключить источник
Заголовок раздела «Сначала отключить источник»Job<void> dispose() { hw.onError = null; queue.clear(); current?.cancel(); return enqueue<CameraState, void>( key: CameraKey.dispose, policy: Policy.droppable, cancellable: false, (ctx) async { if (ctx.state is Disposed) { return; } if (ctx.state is! Initial) { await ctx.run(_closeCameraJob()); } ctx.emit(const Disposed()); }, );}Слушатель уходит раньше всего остального, так что последнее состояние решает
одно освобождение, а сбою, о котором устройство сообщит после него, сообщать
некому. Камера остаётся Disposed. Упавшее освобождение слушателя тоже
не возвращает: пока второй dispose() не пройдёт, контроллер своё устройство
не слышит. onClose остаётся в примере для контроллера, который закрывают без
освобождения.
Запуск примера
Заголовок раздела «Запуск примера»cd exampledart pub getdart run bin/main.dartdart testbin/main.dart открывает камеру, меняет масштаб, выставляет точку фокуса,
начинает снимок и освобождает камеру посреди него, печатая журнал по ходу дела.
В примере есть ещё reopen, pause, resume и операции фокуса, и его тесты
проходят каждую из них.