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, иначе
двадцать кадров в секунду будут пересобирать разметку целиком.
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 Итоги
- Состояние экрана — одна неизменяемая запись; события пересобирают её целиком, а не правят поля по одному.
- Подписка открывается в
initStateи закрывается вdispose. constу неизменных частей экономит перестроения: на десяти событиях заголовок построен 1 раз, показания — 11.- Команда плате — тот же кадр, что и в прошивке: экран не знает про порт и проверяется без связи.
- Пять виджет-тестов проверяют показания, обрыв, счётчик помех, команду и точечность перерисовки — за доли секунды, без прибора и эмулятора.
Дальше: раздел 188 «Сеть приложения: HTTP, WebSocket и MQTT».