Embedded HandbookПриложения
Все разделы › XXIII. Приложение к прибору: Dart и Flutter
STM32F103, STM32F407, STM32G431, STM32L431 · уровень средний · 6 мин чтения
Открыть в интерактивном справочнике

186. Асинхронность: поток кадров, таймауты, переподключение, изолят

Прибор шлёт байты когда захочет, отвечает не всегда и отключается в самый неподходящий момент. Приложение при этом обязано не зависать и не врать. Раздел — про четыре вещи, которые это обеспечивают: поток, таймаут, повтор и изолят.

Всё проверено прогоном: пакет _verify/dart186 и семь проверок в dart test, плюс замер разбора на 200 000 кадрах.

Как это называется в документации

Оригинал Перевод
Future, async/await будущее значение, ожидание без блокировки
Stream, async* , yield поток значений, порождающая функция
broadcast stream поток с несколькими подписчиками
backpressure сдерживание: потребитель медленнее источника
timeout срок ожидания
exponential backoff повтор с растущей паузой
reconnect переподключение
isolate изолят: отдельная нить со своей памятью
event loop цикл событий

186.1 Поток байт превращается в поток кадров

Порт, сокет и BLE отдают байты кусками произвольной длины: кадр может прийти по частям, а может прийти два сразу. Разборщик из раздела 185 уже умеет это переживать — осталось обернуть его в поток:

Stream<FrameEvent> frames(Stream<List<int>> bytes) async* {
  final parser = FrameParser();
  await for (final chunk in bytes) {
    for (final b in chunk) {
      final e = parser.feed(b);
      if (e is! FrameNone) yield e;
    }
  }
}

Три строчки дают ровно то, что нужно: состояние живёт между посылками, а наружу идут только события. Проверено обоими крайними случаями: кадр, разрезанный на три куска, собирается в один; два кадра в одной посылке дают два события.

Источник байт не назван: это может быть последовательный порт, сокет или BLE-характеристика — о них дальше в этой части. Разбор от этого не зависит — и потому проверяется без прибора.

Байты из порта становятся событиями приложения


186.2 Ошибка — это событие, а не исключение

Битая CRC и мусор в проводе — не исключительная ситуация, а обычный режим работы: помеха, отключение, перезапуск прибора. Если бросить исключение, поток закроется, и связь придётся поднимать заново. Поэтому разборщик отдаёт FrameError событием, и поток живёт дальше:

Что подали Что получили
мусор, кадр с битой CRC и правильный кадр одно событие ошибки и один кадр

Исключения остаются для того, что действительно исключение: порт исчез, разрешение не выдано, устройство отключили.


186.3 Запрос-ответ: таймаут и повтор с растущей паузой

Команда без срока ожидания — это зависшее приложение. Срок ставится всегда:

final answer = _events.firstWhere((e) => e is FrameReady).timeout(timeout);
sent++;
_out(encodeFrame(payload));

Подписка оформляется до отправки: иначе быстрый ответ успевает прийти раньше, чем его начали ждать, — классическая гонка, которая воспроизводится раз в сто запусков и отлаживается неделю.

Второе условие — ответ узнают по номеру. firstWhere((e) => e is FrameReady) берёт первый попавшийся кадр: в опыте прибор отвечал на каждый запрос, но позже срока, и вторая попытка засчитала себе ответ на первую — отправлено 2, ответ от попытки 1. Любой кадр телеметрии тоже «ответит» на любую команду. Поэтому в каждый запрос кладут счётчик, прибор возвращает его в ответе, а ждут firstWhere((e) => e is FrameReady && seqOf(e) == seq), где seqOf достаёт номер из кадра. И поток событий должен быть широковещательным — в Link его делает таким events.asBroadcastStream(): у обычного потока второй firstWhere при повторе бросает «Bad state: Stream has already been listened to».

Повтор — с растущей паузой: 1, 2, 4 срока. В проверке прибор отвечает только на третью попытку:

Что считаем Значение
отправлено запросов 3
повторов 2
ответ получен да

Растущая пауза нужна не из вежливости: если прибор перезагружается, частые повторы просто съедают его входной буфер и мешают ему встать. За десять секунд молчания приложение с постоянной паузой 20 мс отправит 500 запросов, а с удвоением до потолка 2 с — 10.

Повтор с растущей паузой: пауза и число запросов молчащему прибору


186.4 Переподключение: состояние связи как часть протокола

Обрыв — не ошибка программы, а событие. Поток кадров, который его переживает, выглядит так: открыть источник, отдавать кадры, при закрытии или отказе сообщить offline, подождать и открыть снова — пауза удваивается до предела.

yield LinkState.connecting;
try {
  final source = await open();
  yield LinkState.online;
  wait = pause;                 // связь была — пауза снова короткая
  yield* frames(source);
  yield LinkState.offline;
} catch (_) {
  yield LinkState.offline;
}

В проверке первая попытка открытия падает, вторая удаётся; наружу идёт ровно та последовательность состояний, которую ждёт экран: connecting → offline → connecting → online → offline, а между ними — разобранный кадр.

Сбрасывать паузу после успешного подключения обязательно, иначе после одного плохого дня приложение будет ждать по две секунды даже при живом приборе.


186.5 Изолят: разбор не должен трогать экран

Dart однопоточный: пока считает ваш код, не рисуется ничего. Насколько это важно, видно на замере — 200 000 кадров, 2 200 000 байт:

Что считаем Значение
разобрано кадров 200 000
скорость разбора больше 1 000 000 кадров в секунду
тиков таймера 1 мс во время разбора в главном потоке не больше 2
тиков во время разбора в изоляте больше 10

Сам разбор быстрый — узкое место не он. Но даже эти десятки миллисекунд видны глазом: пока идёт разбор в главном потоке, таймер на 1 мс не тикает почти ни разу, то есть кадр анимации пропущен. Тот же разбор в изоляте главный поток не трогает:

final int parsed = await Isolate.run(() => parseAll(bytes));

На вебе изолятов нет: Isolate.run там не собирается, а compute выполняет работу в том же потоке. Там пачку режут на куски и между ними отдают управление: await Future<void>.delayed(Duration.zero).

Правило простое: в изолят уходит то, что считает пачкой — разбор журнала, прореживание графика, проверка обновления прошивки. Поток живых кадров с прибора обычно оставляют в главном: пересылка данных между изолятами тоже не бесплатна.


186.6 Грабли

Нет срока ожидания. Прибор молчит — приложение ждёт вечно, кнопка не отвечает, пользователь перезапускает.

Подписка после отправки. Быстрый ответ теряется, и запрос уходит в таймаут ровно тогда, когда всё работает.

Повтор засчитал чужой ответ. Ответ ждут по типу кадра, а не по номеру запроса: поздний ответ на первую попытку достаётся второй.

Повтор без растущей паузы. Перезагружающийся прибор получает шквал запросов и не успевает подняться.

Пауза не сбрасывается после успеха. Связь давно есть, а приложение всё ещё думает по две секунды.

Ошибка разбора рвёт поток. Одна помеха — и связь «потеряна» до перезапуска приложения.

Тяжёлый разбор в главном потоке. Экран замирает на десятки миллисекунд; на слабом телефоне — на сотни.


186.7 Итоги

Дальше: раздел 187 «Экран прибора на Flutter: виджеты, состояние, команды, тесты».

Открыть в интерактивном справочнике