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

187. Экран прибора на Flutter: виджеты, состояние, команды, тесты

Поток кадров есть (раздел 186) — пора его показать. Экран прибора устроен просто: показания, состояние связи, счётчик помех и кнопка, которая шлёт команду плате. Сложность не в рисовании, а в трёх вещах: где живёт состояние, что перерисовывается и как это проверить без прибора и без телефона.

Всё, что ниже, проверено пятью виджет-тестами: flutter test в образе ghcr.io/cirruslabs/flutter:stable (Flutter 3.44) — без эмулятора, за доли секунды.

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

Оригинал Перевод
widget виджет: описание куска экрана
StatelessWidget / StatefulWidget виджет без состояния и со состоянием
State, setState состояние виджета и его изменение
build построение: из состояния получается описание экрана
immutable неизменяемый
const constructor постоянный конструктор: виджет-константа
rebuild перестроение
Key ключ: по нему виджет находят и узнают
widget test, pump виджет-тест, прокрутка кадра
dispose освобождение: отписаться, закрыть

187.1 Состояние — это запись, а не разбросанные поля

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

MeterView apply(Object event) => switch (event) {
      LinkState s => MeterView(state: s, last: last, errors: errors),
      FrameReady(data: final d) when d.length >= 8 =>
        MeterView(state: LinkState.online, last: decodeMeasurement(d), errors: errors),
      FrameError() => MeterView(state: state, last: last, errors: errors + 1),
      _ => this,
    };

Почему не набор полей volts, amps, online, errors: их легко изменить по отдельности и получить экран, который показывает свежий ток при старом напряжении и «связь есть» после обрыва. Запись меняется целиком — либо новое состояние, либо старое.

Кадр в измерение превращает decodeMeasurement из раздела 185 — тот же код, что проверен перекрёстным прогоном с прошивкой.


187.2 Подписка живёт в состоянии, а не в построении

build может вызываться много раз и в любой момент — подписываться в нём нельзя. Место подписки — initState, место отписки — dispose:

@override
void initState() {
  super.initState();
  _sub = widget.events.listen((Object e) => setState(() => view = view.apply(e)));
}

@override
void dispose() {
  _sub?.cancel();        // без этого подписка переживёт экран
  super.dispose();
}

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

Экран прибора: что перестраивается на каждое событие


187.3 Что перестраивается на каждый кадр

setState помечает грязным весь экран, но Flutter перестраивает не всё: виджет, созданный как const, при перестроении родителя пропускается, потому что это буквально тот же объект.

Проверка считает вызовы build у двух частей экрана: у заголовка (const MeterHeader(...)) и у показаний (MeterReadings(view: view)). Десять событий:

Что считаем Значение
построений заголовка 1
построений показаний 11

Одиннадцать — это первый кадр плюс десять событий. Заголовок построен один раз за всё время. Отсюда правило, которое на приборном экране важнее, чем в обычном приложении: всё неизменное — const, иначе двадцать кадров в секунду будут пересобирать разметку целиком.

Дерево экрана прибора: состояние MeterScreen с подпиской на кадры вызывает setState десять раз; const MeterHeader — тот же объект и пропускается, построений 1; MeterReadings с полем view — новый объект каждый раз, построений 11: первый кадр плюс десять событий


187.4 Команда плате — тот же кадр

Кнопка не «отправляет байты в порт», а вызывает переданный обработчик: экран не знает, что там — UART, сокет или BLE.

ElevatedButton(
  key: const Key('load'),
  onPressed: () => widget.onCommand(encodeFrame(<int>[0x01, 0x01])),
  child: const Text('Включить нагрузку'),
),

В тесте обработчик просто складывает кадры в список, и проверка сравнивает отправленное с encodeFrame(<int>[0x01, 0x01]) — то есть с кадром, который соберёт та же функция, что и в разделе 185. Экран проверяется без единого байта настоящей связи.


187.5 Виджет-тесты: экран без телефона

flutter test поднимает экран в памяти. Ключевой приём — события из потока доходят микрозадачей, поэтому после add нужно два кадра:

Future<void> send(WidgetTester tester, Object event) async {
  events.add(event);
  await tester.pump();
  await tester.pump();
}

Пять проверок закрывают весь экран:

Проверка Что подтверждает
показания появляются после кадра «3,356 В» и «−1,000 А» на экране
обрыв связи виден на экране «связи нет» появляется и исчезает
ошибки разбора считаются «помех: 2» после двух битых кадров
кнопка отправляет кадр команды ушёл ровно один правильный кадр
перерисовывается только то, что подписано 1 построение заголовка против 11

Ни одной из них не нужен ни прибор, ни эмулятор: это обычный модульный уровень из раздела 184, только для экрана.


187.6 Грабли

Подписка в build. Каждое перестроение добавляет ещё одну — и показания начинают «дёргаться» вдвое чаще, чем приходят кадры.

Забытый cancel в dispose. Экран закрыт, поток жив, setState у мёртвого состояния — исключение в журнале и утечка.

Поля вместо записи. Половина состояния обновилась, половина нет: «связь есть» при мёртвом приборе.

Ничего не const. Каждый кадр пересобирает весь экран целиком; на телефоне это заметно сразу.

Тест ждёт один кадр. Событие ещё не доставлено — проверка падает через раз, и её «чинят» заменой на pumpAndSettle вместо понимания.

Форматирование чисел точкой. «3.356 В» вместо «3,356 В» — мелочь, которая сразу выдаёт, что экран никто не смотрел.


187.7 Итоги

Дальше: раздел 188 «Сеть приложения: HTTP, WebSocket и MQTT».

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