Сначала договоритесь о границах задачи и цифрах, нарисуйте слои клиента и нужный им API, а основное время отдайте одной-двум проблемам, которые делают этот продукт трудным. Оценивают то, как вы рассуждаете о компромиссах и ограничениях мобильной платформы, а не то, сколько доски заполнено.
Как делится время
Требования, 5-8 минут: два-три сценария в рамках задачи и цифры: сколько элементов, насколько свежими должны быть данные, нужен ли офлайн, нужен ли реалтайм; назовите, что сознательно не проектируете
Архитектура и API, около 15 минут: виджеты, state holder, репозитории, локальная база (drift) рядом с API-клиентом (dio) и сокетом; затем эндпоинты или события, идентификаторы и способ пагинации
Углубление, около 20 минут: то, что делает этот продукт трудным: слияние офлайн-правок в заметках, очередь отправки и отметки о прочтении в чате, пул плееров в видеоленте
Итог, около 5 минут: сценарии отказов, метрики, за которыми вы бы следили, раскатка за удалённым флагом
За что ставят оценку
Уточняющие вопросы до первой схемы, и у каждого решения названы цена и отвергнутый вариант
Реалии мобильной платформы без подсказки: нестабильная сеть, смерть процесса, ограничения фоновой работы, пользователи на старых версиях
Разговор ведёте вы сами; десять минут про Bloc против Riverpod или про серверную базу данных уходят на то, что здесь не оценивают
FlutterSenior
System Design на Flutter
Раунд проектирования: ленты, чат, offline-first и конфликты, обновления в реальном времени, медиа, фоновые загрузки, server-driven UI
Закрыто вопросов
0 / 17
- 0Повторить
- 1Часто спрашивают
Вопросы
Мобильное приложение живёт в сети, которая обрывается, в процессе, который ОС может убить в любой момент, на батарее и в версиях, которые уже не обновить. Каждое условие становится правилом дизайна: кэшировать и ставить в очередь, сохранять заранее, слать пачками, держать API обратно совместимым.
Нестабильная сеть: у каждого экрана есть закэшированное состояние и состояние ошибки, а записи несут ключи идемпотентности, чтобы повтор после таймаута не применился дважды
Смерть процесса: Android убивает фоновое приложение, чтобы освободить память, iOS усыпляет его за секунды; черновики и очередь работы живут в базе, а не в Bloc
Батарея и радиомодуль: каждый запрос держит сотовый модуль включённым ещё несколько секунд после завершения, поэтому аналитику отправляйте пачками, от поллинга откажитесь, а будить приложение доверьте пушу
Ограничения фона: Dart-код останавливается, когда приложение усыплено; длинные загрузки и синхронизации уходят в WorkManager или фоновую URLSession через плагины, и ОС всё равно может их отложить
Старые версии на руках: пользователи месяцами не обновляются, поэтому изменения API только добавляют поля и эндпоинты, клиент игнорирует незнакомые поля, а проверка минимальной версии есть уже в первом релизе
Медленные исправления и слабые устройства: фикс через стор доходит до большинства пользователей за дни, поэтому рискованные фичи прячут за удалёнными флагами; у бюджетных телефонов 2-3 ГБ памяти, поэтому картинки декодируют под размер на экране
Перестаньте ждать все пять разом: у каждой секции свой запрос и свои состояния загрузки, ошибки и данных, а первая отрисовка идёт из локального кэша. Затем попросите у бэкенда один эндпоинт под этот экран, чтобы его важная часть стоила одного round trip.
**Future.wait и есть пустой экран: он завершается только после самого медленного вызова и падает целиком, если упал любой, так что нестабильный сервис рекомендаций прячет собственные данные пользователя
Своё состояние у каждой секции: по провайдеру или кубиту на секцию; шапка отрисовывается, как только пришёл профиль, остальное показывает скелетоны нужной высоты, и ничего не прыгает
Отрисовка с диска: последний ответ каждой секции сохранён, поэтому вернувшийся пользователь сразу видит вчерашние цифры, а свежие данные заменяют их по мере прихода
Приоритеты и таймауты: запросы для первого экрана идут первыми, остальные после первого кадра; медленная необязательная секция сдаётся через несколько секунд и предлагает «Повторить»
Соединения**: клиент dart:io умеет только HTTP/1.1, поэтому пять параллельных вызовов могут означать пять TLS-рукопожатий; native_dio_adapter (Cronet, URLSession) даёт мультиплексирование HTTP/2
От API: эндпоинт backend-for-frontend, который собирает данные на сервере, где вызовы быстрые и параллельные, и ETag, чтобы обновление часто заканчивалось ответом 304
Выбирайте по направлению, частоте и состоянию приложения: поллинг для данных, меняющихся раз в несколько минут, SSE для одностороннего потока, WebSocket, когда обе стороны говорят часто, пуш для закрытого приложения. Любой канал теряет события, поэтому после переподключения догружайте пропущенное.
Обычный поллинг: GET каждые 30-60 с, пока экран виден; делается тривиально, кэшируется, проходит через любой прокси и тратит ресурсы впустую, когда ничего не изменилось; останавливайте таймер, когда приложение на паузе
Long polling: сервер держит запрос, пока не появится новость или не истечёт таймаут; почти реальное время поверх обычного HTTP (так работает getUpdates в Telegram Bot API) ценой одного открытого запроса на клиента
Server-sent events: один длинный HTTP-ответ, в который сервер продолжает писать события; односторонний, возобновляется по Last-Event-ID; подходит для статуса заказа или токенов LLM. В мобильном Flutter нет встроенного EventSource, так что поток разбирают сами или берут пакет
WebSocket: полный дуплекс и дешёвые сообщения, для чата, совместного редактирования и игр; heartbeat, переподключения с backoff, аутентификация и догрузка пропущенного остаются на вас
Пуш (FCM, APNs): единственный канал, который доходит до фонового или убитого приложения, но каналом данных он не служит: доставка не гарантирована, полезная нагрузка около 4 КБ, тихие пуши ограничиваются. Отправляйте «что-то изменилось», а данные приложение загрузит само
Обычная связка: сокет или SSE, пока экран открыт, пуш для всего остального и запрос «что я пропустил после номера N» после каждого переподключения
Каждый экран рисуется из локальной базы, на которую подписан, а ответы сети попадают только в эту базу. Запись в одной транзакции меняет строку и добавляет операцию в outbox, UI помечает строку как ожидающую, а воркер синхронизации отправляет очередь, когда может.
Чтение
Один источник истины: watch() в drift (или репозиторий, который эмитит после каждой записи) отдаёт результаты запроса в state holder, поэтому синхронизация, записавшая строки, обновляет все экраны без повторной загрузки
Изменения после токена: сервер возвращает то, что изменилось после непрозрачного токена, включая tombstone-записи для удалений; коммитьте новый токен вместе со строками, иначе падение между ними потеряет изменения
Никаких спиннеров поверх кэша: показывайте сохранённое с его возрастом («обновлено 10 мин назад») и баннер об офлайне
Запись
Строка и операция вместе: одна транзакция содержит новые значения плюс строку outbox с ключом идемпотентности и версией сервера, от которой началась правка
Отправка по порядку: от старых к новым, по одной операции; операция удаляется только после ответа сервера, а правки той же строки, ещё стоящие в очереди, переносятся на версию, которую он вернул
Триггеры: сразу после записи, при возврате в приложение, когда connectivity_plus сообщает о сети (это подсказка, а не доказательство), и из периодической задачи workmanager, которая работает в своём изоляте и открывает своё соединение с базой
Что показывает UI
Изменённое значение сразу, с небольшой отметкой «ожидает отправки»; «синхронизировано» только тогда, когда для этой строки в очереди ничего не осталось
«Ошибка» с кнопкой «Повторить» для постоянного сбоя (403, ошибка валидации), но никогда не молчаливый откат того, что ввёл пользователь
Предупреждение при выходе из аккаунта, пока изменения ждут отправки: очистка outbox их выбросит
Конфликты
Сервер сравнивает базовую версию операции со своей текущей и отвечает 409 со своей копией; клиент сливает изменения или спрашивает пользователя, как в вопросе о конфликтах
Обнаруживайте конфликт на сервере по версии, а затем берите самое дешёвое правило, которое допускают данные: last write wins там, где потеря одной правки приемлема, трёхстороннее слияние по полям для записей и CRDT только для текста или списков, которые люди действительно правят вместе.
Last write wins
Одна правка молча исчезает: годится для переключателя, статуса или настройки и не годится для всего, что человек набрал руками
«Последняя» означает порядок прихода на сервер или гибридные логические часы, но никогда не часы устройства: телефон, спешащий на сорок минут, выигрывает каждый конфликт
Трёхстороннее слияние по полям
Нужна базовая версия, от которой начались обе правки: поле, изменённое только с одной стороны, берёт значение этой стороны, поэтому правка заголовка на одном телефоне и закрепление на другом выживают обе
Настоящий конфликт возникает только в поле, которое обе стороны изменили по-разному: примените правило этого поля или покажите оба значения
Цена: клиент хранит базу (базовую версию и старые значения в операции outbox), а текст заметки является одним полем, так что две правки текста всё равно столкнутся
CRDT
Каждое устройство хранит операции или метаданные на каждый элемент, которые сливаются в любом порядке в один и тот же результат без арбитража сервера; на этой идее построены Automerge и Yjs, а в Dart пакет crdt делает это для записей с гибридными логическими часами
Цена: метаданные и tombstone-записи растут вместе с историей и требуют уплотнения, экосистема в Dart скромная, ошибки трудно разбирать, а серверу нужно понимать формат, чтобы валидировать данные или искать по ним
Оправдано для общего документа или чек-листа, но не для формы профиля
Решите и это
Удаление против правки: возвращает ли правка удалённую заметку или побеждает удаление?
Когда ни одно правило не может решить, сохраните обе версии («конфликтующая копия»), только бы не потерять текст
Первую копию отдавайте одним готовым файлом SQLite, а не 50 000 строк JSON, поддерживайте её свежей лентой изменений и tombstone-записей после серверного курсора, а ищите через индекс FTS5 во встроенной сборке SQLite, вне UI-изолята.
Первая загрузка
Сервер каждую ночь собирает сжатый файл базы (товары, цены, поисковый индекс) для каждого региона; приложение скачивает его через background_downloader, чтобы загрузка возобновлялась и шла в фоне, проверяет хэш и атомарно подменяет базу
Если вместо этого постранично складывать JSON в базу, телефону придётся 50 000 раз декодировать и вставлять: пачками по несколько тысяч строк на транзакцию в фоновом изоляте, иначе первая синхронизация займёт минуты и заморозит UI
Спрашивайте, прежде чем качать большой файл через мобильный интернет, и показывайте прогресс в мегабайтах
Дельта-обновления
GET /catalog/changes?cursor=... возвращает upsert- и tombstone-записи; применяйте каждую страницу вместе с новым курсором в одной транзакции
Держите быстро меняющиеся данные (цены, остатки) отдельно от медленных (названия, фото), чтобы ежедневная дельта оставалась маленькой
Курсор старше истории изменений на сервере получает 410 Gone, и приложение скачивает свежий файл вместо того, чтобы проигрывать изменения за несколько месяцев
Поиск
Таблица FTS5 по названию, бренду и описанию с префиксными запросами (мол*) и ранжированием через bm25(), плюс обычный индекс по артикулу и штрихкоду для точного поиска
Системный SQLite на Android не гарантирует наличие FTS5, поэтому используйте NativeDatabase из drift поверх SQLite, который встраивает пакет sqlite3 3.x, и открывайте её через createInBackground, чтобы запрос никогда не блокировал кадр
Нормализуйте текст одинаково при индексации и при поиске: нижний регистр, ё в е; стеммера для русского в FTS5 нет, его работу делает префиксный поиск, а токенизатор trigram находит подстроки ценой заметно большего индекса
Добавьте debounce на поле поиска и отбрасывайте результаты устаревшего запроса
Картинки
В синхронизацию не входят: загружайте и кэшируйте их по требованию, а заранее подтягивайте только ходовые товары этого представителя
Грузите страницы по серверному курсору в локальную таблицу, на которую подписан список, стройте строки лениво, резервируя высоту по соотношению сторон картинки, декодируйте картинки под размер на экране и запоминайте, какой пост был вверху экрана, чтобы перезапуск открывал то же место.
API
GET /feed?cursor=...&limit=20 возвращает посты с автором, счётчиками, URL картинок в нескольких ширинах, а также шириной и высотой каждой картинки; nextCursor: null означает конец ленты
Новые посты появляются плашкой «12 новых постов» сверху, а не строками, вставленными над тем, что пользователь читает
Кэш и состояние
Страницы ложатся в таблицу drift с ключом по id поста и позицией; список читает эту таблицу, поэтому холодный старт мгновенно рисует ленту прошлой сессии и обновляет её в фоне
Посты нормализованы по id: лайк на экране деталей меняет одну строку сразу везде, оптимистично и с откатом
Храните последние несколько сотен постов, а остальное подрезайте при запуске
Скролл
ListView.builder или SliverList строит только то, что рядом с viewport; следующую страницу запрашивайте примерно за пять постов до конца, по одному запросу за раз
Каждая строка резервирует AspectRatio по размерам из API, поэтому поздно пришедшая картинка не сдвигает список
Строки остаются дешёвыми: никаких слоёв Opacity или клипа на каждую строку, const-поддеревья и счётчик лайков, который перестраивается отдельно через select
Картинки
cached_network_image грузит URL с ближайшей к строке шириной, а memCacheWidth равен ширине строки, умноженной на device pixel ratio
Предзагружайте картинки следующих постов тем же провайдером и с тем же ресайзом, что и строка; другой ключ кэша означает выброшенную работу
Видео в постах играют, только когда видны почти целиком (visibility_detector), и делят небольшой пул плееров
Позиция после перезапуска
Смещение в пикселях теряет смысл, как только меняется контент: храните id верхнего видимого поста и смещение внутри этой строки, а когда закэшированная страница отрисовалась, переходите к этому элементу (super_sliver_list и scrollable_positioned_list умеют прыгать к индексу среди строк разной высоты)
Вертикальный PageView.builder поверх небольшого пула плееров: текущий ролик играет, следующие один-два инициализированы и буферизованы, но стоят на паузе, а всё дальше освобождено. Постер закрывает страницу до первого кадра, а медленную сеть сглаживают варианты HLS разного качества.
Плееры
За каждым VideoPlayerController стоит нативный плеер с декодером, буферами и текстурой, а телефон держит одновременно лишь несколько аппаратных декодеров: бюджет составляет три-пять живых контроллеров, а не по одному на страницу
Ключ по индексу страницы: в onPageChanged запустите новый текущий ролик, поставьте на паузу старый, инициализируйте следующий (а на быстрой сети и ещё один), освободите всё, что позади предыдущего
Контроллерами владеет пул, а виджет страницы только отрисовывает выданный ему, поэтому ребилд никогда не пересоздаёт плеер
Предзагрузка и сеть
Отдавайте HLS с несколькими вариантами качества, включая низкий битрейт, на котором может стартовать слабая сеть: AVPlayer и ExoPlayer сами выбирают стартовый вариант (AVPlayer перестал следовать порядку в плейлисте с iOS 13) и дальше переключаются вверх или вниз
У video_player нет дискового кэша, поэтому повторный просмотр скачивает ролик заново; cached_video_player_plus добавляет кэш, а нативный плеер за platform channel даёт полный контроль над буферизацией
На медленном или платном соединении предзагружайте только следующий ролик и показывайте постер со спиннером, но не чёрный кадр
Память и жизненный цикл
Ставьте на паузу при AppLifecycleState.paused и когда ленту перекрывает другой маршрут (RouteAware); возобновляйте только текущую страницу
Постеры остаются маленькими JPEG, декодированными под ширину экрана; список роликов хранит простые данные и может быть длинным, а пул плееров не может
Что измерять
Время до первого кадра, число ребуферизаций в минуту и пропущенные кадры во время свайпа
Экран чата подписан на локальную базу, которую наполняет один сервис сокета вне дерева виджетов. Отправленное сообщение сначала живёт локальной строкой с id от приложения; подтверждение возвращает этот id и переводит строку в «отправлено». Старую историю грузят, когда кончились локальные строки.
Транспорт
Единственный WebSocket, который открывает сервис, стартующий при входе и останавливающийся при выходе; он аутентифицируется при подключении, пингует, чтобы заметить мёртвое соединение, и переподключается с растущими случайными задержками; ни один виджет сам сокет не открывает
iOS усыпляет фоновое приложение за секунды, и сокет умирает вместе с ним; на Android держать сокет открытым из foreground-сервиса стоит батареи и уведомления, о котором никто не просил. В фоне новые сообщения приносит пуш
События, отправленные, пока сокет лежал, через него уже не придут, поэтому каждое переподключение начинается с запроса «всё после seq N»; так работает getDifference в Telegram
Статусы сообщений
Строка хранит client_id, server_id, seq и status: sending, затем sent, когда сервер подтвердил с seq, затем delivered и read, или failed с кнопкой «Повторить»
Повторная отправка после таймаута несёт тот же client_id, и сервер отвечает уже сохранённым сообщением, а не создаёт второе
Отметки о прочтении передаются как «прочитано до seq N» на чат, а не отдельным событием на каждое сообщение
Офлайн-очередь
Строка сообщения и её запись в outbox пишутся вместе; когда сокет вернулся, очередь каждого чата уходит от старых к новым, чтобы ответ не обогнал вопрос
Фото становится задачей загрузки в background_downloader, пока пузырь показывает локальный файл; само сообщение уходит, когда сервер вернул id медиа
История
Сортируйте по seq, который сервер назначил внутри чата: часы телефонов расходятся, поэтому время устройства порядок не определяет; неподтверждённые сообщения остаются внизу
Индекс (chat_id, seq) и пагинация из базы; более старую страницу запрашивайте с сервера, только когда кончились локальные строки, и держите якорь скролла на месте при добавлении сверху
NativeDatabase.createInBackground из drift уводит большие запросы и записи синхронизации с UI-изолята
Сервер решает, кому положен пуш, и собирает полезную нагрузку под каждую платформу; приложение само рисует уведомления чатов, где это возможно, пропускает чат, открытый на экране, держит одно уведомление на чат и снимает его, когда другое устройство сообщило о прочтении.
Что отправляет сервер
Сервер знает про отключённый звук, тихие часы и непрочитанные на всех устройствах, поэтому чат без звука не получает пуш вовсе, а число на бейдже приходит с сервера
Android: высокоприоритетное FCM-сообщение только с данными; фоновый обработчик строит уведомление через flutter_local_notifications в стиле MessagingStyle (отправитель, аватар, несколько строк), чего не умеет notification-нагрузка, которую рисует система
iOS: alert-пуш с thread-id, равным id чата, и mutable-content: 1, чтобы Notification Service Extension (нативный код на Swift) расшифровал текст или приложил аватар до показа; тихие пуши система ограничивает, поэтому сообщения никогда на них не полагаются
Уже открытый чат
В foreground сообщение приходит в onMessage, а не в шторку (если не попросить iOS показывать баннеры в foreground); обработчик проверяет текущий маршрут и для открытого чата играет короткий звук или ничего не делает
Группировка
Один id уведомления на чат, выведенный из id чата: новое сообщение обновляет это уведомление, а не добавляет ещё одно
Android группирует чаты под сводным уведомлением через groupKey; iOS собирает их в стопки по thread-id
Удаление после прочтения на другом устройстве
Сервер отправляет другим устройствам пользователя событие «прочитано до seq N»
На Android сообщение с данными будит обработчик, и тот снимает уведомление этого чата по его id
На iOS для этого нужен фоновый пуш, который может прийти поздно или не прийти вовсе, поэтому удаление работает по принципу best effort: дополнительно очищайте уведомления чата каждый раз, когда приложение его открывает
Приложение курьера каждые несколько секунд шлёт координаты на сервер, а тот раздаёт их через сокет, пока у клиента открыт экран отслеживания, и через пуш и живое уведомление на экране блокировки, когда закрыт. Клиент анимирует маркер между точками и показывает возраст последней.
Получение обновлений
Сторона курьера: фоновая геолокация через geolocator с foreground-сервисом на Android и фоновым режимом location на iOS (для поездки, начатой на переднем плане, хватает разрешения «При использовании»); точку отправляйте, только если курьер сдвинулся, а без сети копите в буфере
Сторона клиента: подписка на координаты заказа через уже открытый сокет или SSE, только пока экран виден, и отписка на паузе
Смена статуса (забрал заказ, подъезжает) идёт пушем, поэтому о ней узнаёт и закрытое приложение; Live Activity на iOS (плагин live_activities) или постоянное уведомление на Android показывают время прибытия на экране блокировки и обновляются пушем
Движение маркера
Точки приходят каждые 3-5 секунд: анимируйте маркер от нарисованной позиции к новой точке примерно за этот интервал и поворачивайте его по направлению движения
В google_maps_flutter маркер нативный: каждый шаг анимации означает обновление маркера через platform channel, поэтому делайте 10-15 шагов в секунду, а не 60. В flutter_map маркер является обычным виджетом, и AnimationController (или flutter_map_animations) дёшево двигает его каждый кадр
Привязку маршрута к дорогам делайте на сервере и отбрасывайте точку старше уже нарисованной: пакеты приходят не по порядку
Когда обновления пропали
Примерно через 30 секунд без новой точки остановите анимацию там, где она есть, сделайте маркер серым и напишите «Обновлено 1 мин назад»; никогда не экстраполируйте по маршруту, будто курьер всё ещё едет
Продолжайте показывать серверное время прибытия и статус заказа, которые приходят по отдельному каналу
При переподключении сначала загрузите последнюю позицию, а потом анимируйте, чтобы маркер сдвинулся один раз, а не проигрывал накопившееся
Очередь бэкапа держите в своей базе, по строке на фото, и отдавайте фоновому сервису передачи ОС только небольшое окно загрузок: оно продолжает работать, пока приложение усыплено. Загрузки возобновляемые, дедуплицируются по хэшу содержимого и идут только по Wi-Fi, если пользователь не разрешил иное.
Поиск фото
photo_manager отдаёт ассеты с id и датами изменения; сравнивайте их с очередью при запуске, при возврате в приложение и в периодической фоновой задаче
На iOS оригинал может лежать только в iCloud, и его получение само по себе является загрузкой: делайте это внутри задачи, а не во время скролла списка
С iOS 26.1 в PhotoKit есть расширение Background Resource Upload ровно для этой задачи: нативный код на Swift, который система запускает, когда позволяют сеть и питание; оно создаёт задачи загрузки, система сама скачивает оригиналы из iCloud и отправляет их (с докачкой, если сервер поддерживает IETF resumable uploads), а записи попадают в приложение через app group
Очередь
Одна строка на ассет: id ассета, хэш содержимого, состояние, URL сессии загрузки, подтверждённые байты, число попыток; источник истины здесь база, а не плагин
Сервер дедуплицирует по хэшу, поэтому переустановка или второй телефон никогда не загрузят одно фото дважды
Передача байтов
Экспортируйте каждый оригинал в файл в собственной директории приложения: фоновая URLSession на iOS загружает только из файлов. background_downloader передаёт этот файл в URLSession на iOS и в WorkManager на Android
Держите в очереди ОС около десяти загрузок и пополняйте окно по мере их завершения, а не ставьте 5000 разом; удаляйте каждый экспортированный файл после загрузки
Большие видео идут по возобновляемому протоколу (tus, S3 multipart), чтобы оборванное соединение стоило одного чанка, а не всего файла
Батарея, трафик и пользователь
По умолчанию requiresWiFi и переключатель «использовать мобильный интернет»; задача workmanager, которая пополняет окно, умеет ещё и ждать зарядки (requiresCharging)
Принудительное закрытие на iOS отменяет фоновые передачи, force-stop на Android задерживает запланированную работу до следующего запуска, и этот запуск продолжает с места в очереди
Показывайте «Сохранено 1240 из 3000, ждём Wi-Fi» и считайте сбои по причинам, чтобы застрявший бэкап был виден в метриках
Пусть повторяет ОС: zonedSchedule из flutter_local_notifications с DateTimeComponents.time срабатывает каждый день без запущенного приложения. Сохраните пояс каждого напоминания, перепланируйте все при его смене, а после перезагрузки Android положитесь на boot receiver плагина.
Часовые пояса
Ежедневный повтор привязан к поясу, в котором его запланировали: плагин хранит его и на Android, и на iOS, поэтому пользователь, улетевший из Москвы в Алматы, всё равно получит напоминание на 08:00 по Москве, то есть в 10:00 по местному времени
Привычке нужно время на часах там, где сейчас пользователь, поэтому сравнивайте сохранённый пояс с FlutterTimezone.getLocalTimezone() при запуске и при каждом возврате в приложение и перепланируйте, если он отличается
На Android нативный приёмник ACTION_TIMEZONE_CHANGED может запустить это без открытия приложения; у iOS такой точки входа нет, и исправление ждёт следующего запуска
Повтор по компонентам времени на часах переживает и переход на летнее время, а прибавление 24 часов нет
Правила платформ
Android сбрасывает будильники при перезагрузке: зарегистрируйте ScheduledNotificationBootReceiver плагина. Android 13+ требует разрешения POST_NOTIFICATIONS, а точные будильники требуют SCHEDULE_EXACT_ALARM, которое с Android 14 по умолчанию не выдаётся, поэтому трекер привычек использует неточный режим
Force-stop от пользователя и «экономия батареи» у некоторых производителей отменяют все будильники, пока приложение снова не откроют
iOS хранит только 64 ближайших запланированных уведомления на приложение: повторяющийся триггер считается одним, но по семь напоминаний на дни недели для каждой из десяти привычек уже не помещаются
Недели без открытия приложения
Повторы продолжают срабатывать, но их текст заморожен: пишите текст, который останется правдой, а не «День 12 вашей серии»
Чтобы напоминания заброшенного приложения постепенно затихли, планируйте одиночные уведомления на две недели вперёд вместо повтора и продлевайте окно при каждом запуске
Когда текст должен меняться, альтернативой служит серверный пуш ценой сети и бэкенда
Срезайте объём на каждом этапе: подписывайтесь только на инструменты на экране, декодируйте в долгоживущем фоновом изоляте, храните лишь последний тик по инструменту и обновляйте notifier строки не чаще десяти раз в секунду, чтобы тик перерисовывал одну строку, а не весь список.
До телефона
Подписывайтесь на видимые строки плюс запас и меняйте подписку по мере скролла; сервер склеивает обновления до нескольких в секунду на инструмент
Компактный бинарный формат вроде protobuf вместо JSON уменьшает и байты, и время декодирования
От сокета до UI-изолята
Сокет и декодирование живут в изоляте, запущенном один раз через Isolate.spawn; Isolate.run на каждое сообщение платил бы за запуск тысячи раз
Этот изолят отправляет пачки, одно сообщение раз в 50-100 мс, а не по сообщению на тик: сообщения между изолятами копируются
Склейка
На стороне UI ожидающие котировки лежат в map по символу: более новый тик перезаписывает тот, который никто не увидел, а таймер сбрасывает map в ValueNotifier каждого символа
Десять изменений цены в секунду никто не прочитает: 5-10 обновлений на строку вполне достаточно, и цветовая подсветка остаётся читаемой
Виджеты
ListView.builder с фиксированным itemExtent, чтобы раскладка сводилась к арифметике; каждая строка является ValueListenableBuilder внутри RepaintBoundary, поэтому тик перестраивает и перерисовывает только её
Выше строк цены никто не слушает: никакого setState на весь экран и никакого провайдера, на который подписан весь список
Пересортировка по изменению раз в несколько секунд или по запросу, но никогда на каждый тик: строки прыгали бы, и каждая перестраивалась бы
График рисует CustomPainter, у которого repaint является listenable, поэтому он вообще пропускает фазу build
Фичи обращаются только к вашему типизированному классу Analytics, который сразу возвращает управление и не может уронить вызывающего: он проставляет контекст, проверяет согласие и пишет события в буфер на диске. Загрузчик шлёт буфер пачками, а каждый вендор подключён сменным адаптером.
API для фич
Классы событий, сгенерированные из плана трекинга (CheckoutStarted(cartValue: ...)), а не track('chekout_start', {...}): опечатка ломает сборку, а не попадает в релиз пустым графиком
Просмотры экранов приходят из одного NavigatorObserver на роутере, а не с каждой страницы
Версию приложения, платформу, id сессии и варианты экспериментов пользователя проставляет сам класс, а не каждый вызывающий
Конвейер
track() кладёт событие в память и возвращается; сброс пишет буфер в таблицу drift или файл каждые несколько секунд и на AppLifecycleState.paused, потому что после этого Android может убить процесс без новых колбэков
У буфера есть предел: при переполнении первыми выбрасываются показы, а не покупки, и число выброшенных событий тоже отправляется
Отправляйте пачки в gzip по 50-100 событий или раз в 30 секунд в foreground, с экспоненциальным backoff; UUID у каждого события означает, что пачка, повторённая после таймаута, учитывается один раз
Отправляйте и время события, и время отправки, чтобы сервер мог поправить телефон с неверными часами
Сэмплирование, согласие, приватность
Оставляйте 1 из N шумных событий (показы, спаны производительности) и пишите N в каждое, чтобы дашборд мог пересчитать обратно; шаги воронки и покупки всегда уходят целиком
До согласия загрузчик выключен, отзыв согласия удаляет буфер, а фильтр вычищает email, телефоны и свободный текст ещё до записи
App Tracking Transparency на iOS важна, только если вы отслеживаете пользователей в приложениях и на сайтах других компаний
Вендоры и крэши
Firebase Analytics, Amplitude и собственный эндпоинт подключены адаптерами одного Dart-интерфейса; таблица маршрутов решает, какой адаптер получает какое событие
Крэши идут в обход этого буфера: FlutterError.onError и PlatformDispatcher.instance.onError передают их в Crashlytics или Sentry, чья нативная часть записывает отчёт даже при падении процесса и прикладывает кольцевой буфер последних строк лога
Он окупается там, где раскладка меняется каждую неделю, а блоки остаются прежними: главная магазина, страницы акций, онбординг. Бэкенд возвращает JSON с именами компонентов, уже встроенных в приложение, их данными и именованными действиями, а реестр превращает каждый узел в виджет.
Как построить
Версионированная схема: {"type": "product_carousel", "props": {...}, "action": {...}}; приложение передаёт свою версию и версию схемы, а сервер использует только компоненты, которые эта сборка поддерживает
Реестр Map<String, Widget Function(Node)>, заполняемый в composition root; узел типа, о котором эта сборка не знает, пропускается и логируется
Сервер говорит, что должно произойти (open_route, add_to_cart, track), но не как: каждому имени действия соответствует Dart-код, который уже есть в бинарнике
Сохраняйте последнюю раскладку, которая успешно отрисовалась, и кладите раскладку по умолчанию в ассеты, чтобы первый запуск в самолёте не оказался пустой страницей
Собственный пакет Flutter rfw (Remote Flutter Widgets) отрисовывает деревья виджетов, описанные в его формате, с привязкой данных и событиями обратно в приложение; его README рекомендует пакет для интерфейсов из готовых виджетов и предостерегает от переходов между страницами, drag and drop и своих painter-ов
Где он начинает вредить
JSON обрастает условиями, циклами и выражениями, пока не станет нетипизированным языком программирования без анализатора, тестов и отладчика
Каждый экран становится обобщённым: никаких const-поддеревьев, глубокие деревья обёрток, более медленный скролл и подписи для accessibility, за которые никто не отвечает
Оформлению заказа, камере и редакторам нужны свои жесты, валидация и офлайн-поведение, которые универсальный набор компонентов передаёт плохо; оставьте их обычными Flutter-экранами
Чтобы воспроизвести баг, теперь нужны версия приложения, JSON раскладки, который получил этот пользователь, и бэкенд, который его собрал, а QA проверяет раскладки, которых не существовало на момент релиза
Сторы принимают перестановку того, что уже есть в бинарнике, но не доставку новых фич; менять сам Dart-код умеет Shorebird, со своими ограничениями