Сначала уточните рамки, затем определите API и модель данных, набросайте архитектуру клиента, углубитесь в две-три самые сложные части этого продукта и закончите сквозными вопросами и компромиссами. Оценивают структуру и способность принимать решения, а не полноту диаграммы.
Требования, около 5 минут: какие два-три экрана в рамках задачи и нефункциональные требования: офлайн, объём данных, реальное время, платформы; вслух назовите, что в рамки не входит
API и модель данных, около 5 минут: эндпоинты или события сокета, нужные клиенту, способ пагинации, сущности и их id; клиентская сторона контракта входит в вашу зону ответственности
Архитектура клиента, около 10 минут: UI → ViewModel → репозиторий → локальная база плюс сеть, база служит источником истины; назовите библиотеки (Room, Retrofit или Ktor, WorkManager, Coil) и идите дальше
Углубление, около 20 минут: выберите то, что сложно именно в этом продукте: синхронизация и конфликты для заметок, порядок и статусы доставки для чата, кэш и предзагрузка для ленты
Сквозные вопросы и итоги, около 5 минут: батарея, память, смерть процесса, безопасность, наблюдаемость, feature-флаг для раскатки
Частая ошибка: двадцать минут рисовать бэкенд; на мобильном раунде сервер остаётся прямоугольником, пока интервьюер сам не попросит его раскрыть
AndroidSenior
System Design мобилки
Offline-first, синхронизация, пагинация, кэш, модуляризация, фича-флаги
Закрыто вопросов
0 / 20
- 0Повторить
- 1Часто спрашивают
Вопросы
Два экрана держат две копии одного поста: лайк меняет одну копию, а вторая об этом не узнаёт. Чинить надо не уведомление ленты, а дать посту один дом, то есть хранилище, за которым наблюдают оба экрана.
Представьте одну общую доску вместо двух ксерокопий: все смотрят на одну доску, и чужое изменение видят сразу все.
Единый источник истины: таблица в базе или нормализованный кэш в памяти по id; каждый экран подписан на запрос к нему и перестраивается, когда строка меняется
Нормализация по id: ответы-списки хранят id, таблица сущностей хранит саму сущность, так что ответ ленты и ответ деталей пишут одну строку
Оптимистичные записи получаются бесплатно: переключить строку, сходить на сервер, при ошибке откатить; все три шага видны везде сразу
Передавайте id, а не объекты: целый объект в аргументе навигации превращается в снимок, который никогда не обновится
Частая ошибка: флаги «обновить на resume» и шины событий чинят тот экран, про который вспомнили, и пропускают следующий добавленный
Дебаунсите ввод, отменяйте предыдущий запрос, когда пришёл новый, и показывайте локальные результаты, пока сеть догоняет. Так экран остаётся отзывчивым, а бэкенд получает один запрос на паузу в наборе, а не на каждое нажатие.
Debounce: ждите примерно четверть секунды после последнего нажатия и не отправляйте пустые и слишком короткие запросы
Отмена и переключение: flatMapLatest в корутинах, switchToLatest в Combine; без этого медленный ответ на старый запрос приходит после нового, и список мигает; если вы сводите колбэки вручную, помечайте каждый ответ его запросом и отбрасывайте устаревшие
Сначала локальное: недавние запросы или кэш мгновенно заполняют экран, а короткий кэш по запросу избавляет от перезапроса при стирании назад
На бэкенде: дешёвый эндпоинт подсказок отдельно от полного поиска, с лимитом частоты на клиента: сломанный клиент рано или поздно начнёт слать запрос на каждое нажатие
Свежесть решается для каждого ресурса отдельно, а не одним числом на всё приложение: разложите эндпоинты по тому, насколько плохо показать их устаревшими, и дайте каждому классу свою политику кэша.
Почти статика (категории, конфиг, список стран): часы и дни, плюс копия в бандле, чтобы первый запуск что-то показал до ответа сети
Данные пользователя (профиль, лента, сохранённое): показывайте кэш мгновенно и обновляйте в фоне; этот stale-while-revalidate и делает приложение быстрым
Волатильное (цена, баланс, наличие мест): никогда не рисуйте молча из кэша; ждите запрос или подпишите значение «по состоянию на 14:02»
Холодный старт: отправляйте только запросы, нужные первому экрану, а остальное грузите после первой отрисовки или по требованию
Условные запросы: храните ETag и шлите If-None-Match; 304 стоит заголовков вместо тела
Инвалидация при записи и ключ с пользователем: после мутации кладите ответ в кэш и сбрасывайте затронутые ключи, иначе пользователь увидит, как его правка исчезает
Первая отрисовка не должна ждать самый медленный из семи запросов: схлопните их в один эндпоинт под форму экрана и рисуйте каждую секцию, как только пришли её данные.
На мобильной сети один раунд-трип стоит 150–300 мс, а экран ждёт самый медленный вызов, поэтому шанс, что один из семи попадёт в хвост задержки, намного выше, чем у одного запроса; каждый лишний вызов добавляет заголовки, ещё один вариант отказа и время радиомодуля.
Backend-for-frontend: один эндпоинт отдаёт ровно то, что нужно этому экрану; если так нельзя, делите по приоритету: один критичный запрос для первого экрана, остальные отложенные
Рисуйте посекционно: у шапки, статистики и рекомендаций свои состояния загрузки, пустоты и ошибки; имя не ждёт рекомендации
Сначала кэш, потом обновление: вернувшийся пользователь видит слегка устаревший экран, но не пустой
Дедуплицируйте запросы в полёте и отменяйте при уходе с экрана; семь запросов тихо становятся двенадцатью, когда три компонента просят один профиль
Меряйте p95, а не среднее, и дайте медленной зависимости свой таймаут
Три клиента считают число, владеть которым может только одна система: итог должен вычисляться один раз, на сервере, а во всех остальных местах только отображаться.
Правило, перенесённое в Kotlin, Swift и TypeScript, разъезжается на первом же краевом случае и остаётся разъехавшимся: три клиента релизятся по трём графикам, а старые сборки не обновляются вовсе.
Сервер владеет деньгами и правами: скидки, налоги, пороги бесплатной доставки, комбинирование промокодов, доступ к фичам
Отдавайте готовые строки, а не ингредиенты: подпись, сумма в минорных единицах и валюта; клиент может их только показать
Расчёт с id: сервер возвращает расчёт (quote) с id, клиент показывает его строки и шлёт id обратно, чтобы списалось ровно то, что видел пользователь
Что клиенту можно: форматировать, сортировать и фильтровать уже полученное; валидировать ради быстрой обратной связи, пока сервер валидирует по-настоящему
Частая ошибка: «временное» клиентское правило под запуск, которое становится постоянным, а потом неверным на всех установках, до которых уже не дотянуться
Только не часам устройства: системное время (wall clock) пользователь может поменять, оно плывёт и прыгает, поэтому всё, где есть дедлайн или порядок, должно быть привязано к серверному времени. У приложения трое часов для трёх разных задач.
Системные часы (wall clock; System.currentTimeMillis(), Date()) нужны, чтобы показать время, и больше ни для чего; они могут уйти назад посреди сессии
Монотонные часы (SystemClock.elapsedRealtime()) нужны для длительностей внутри сессии: таймауты, лимиты частоты, «сколько уже грузится»; назад не идут, но обнуляются при перезагрузке, поэтому на них нельзя строить сохраняемый TTL
Серверное время: авторитет для всего, о чём должны договориться две стороны; при каждом ответе сохраняйте заголовок Date вместе с SystemClock.elapsedRealtime() и считайте now = serverTime + (elapsedRealtime() - storedAt), чтобы последующая смена часов пользователем ничего не сдвинула; якорь живёт в памяти, поэтому после смерти процесса синхронизируйтесь заново
Android 13+: SystemClock.currentNetworkTimeClock() отдаёт время, синхронизированное по NTP, если оно есть у системы, и бросает исключение, если нет, поэтому серверный offset остаётся запасным вариантом
Дедлайны задаются абсолютными моментами времени: передавайте их как UTC-метки, а не «полночь» без часового пояса
Клиент никогда не решает, что срок истёк: он рисует то, что вернул сервер; клиентская проверка это оптимизация UX, которую сервер перепроверяет
Невозможные значения считайте протухшими: строка кэша с fetchedAt в будущем пришла от кривых часов и не может считаться свежей
Выпущенную мобильную сборку нельзя отозвать, поэтому любое изменение API обязано оставаться совместимым с клиентами, которыми вы больше не управляете, а важные рычаги должны жить на сервере, а не в бинарнике.
Только аддитивные изменения: новые необязательные поля и новые эндпоинты, никаких переименований, удалений и смены типа; и парсер обязан игнорировать незнакомые ключи, иначе любое добавление на сервере обернётся аварией для старых сборок
Версия приложения и платформа в каждом запросе, чтобы на вопрос «можно ли отключить эту сборку» отвечало число
Принудительное обновление с первой версии: проверка должна быть уже в первой сборке, потому что в выпущенную её не добавить; эндпоинт отдаёт минимальную и рекомендуемую версии
Настройки из конфига: таймауты, размеры страниц, адреса и kill switch приходят с сервера, потому что самым быстрым оказывается исправление, которому не нужна новая сборка
Миграции с любой схемы: локальная база должна обновляться с любой старой версии, а не только с предыдущей; пользователи спокойно пропускают десять релизов
Читайте ленту только из локальной базы, запрашивайте следующую страницу за несколько строк до конца при одном запросе в полёте, дедуплицируйте по id и сохраняйте страницы и курсор, и тогда скролл не прерывается, а поворот и смерть процесса ничего не стоят.
Курсор, а не offset: в живой ленте новые посты сдвигают все позиции, поэтому offset-страницы дублируют или теряют элементы; сервер отдаёт next_cursor, клиент хранит его и отправляет обратно, а не вычисляет сам
Порог предзагрузки: грузите следующую страницу примерно за пять строк до конца, и спиннер почти не виден
Один запрос за раз: держите флаг или job «запрос в полёте», иначе быстрый скролл отправит один курсор четыре раза
Дедупликация по id: пересекающаяся строка от сервера считается нормой; объединяйте по стабильному id
Сохраняйте страницы и курсор: пишите их в локальную базу, чтобы поворот экрана или смерть процесса ничего не перезапрашивали, а список работал офлайн
Восстановление позиции скролла: привязывайте её к id элемента, а не к индексу, потому что новые посты сдвигают индексы; rememberLazyListState уже переживает поворот
Ошибки и конец: при сбое показывайте внизу строку повтора, а когда next_cursor равен null, прекращайте запросы
Feature-флаг представляет собой переключатель, который включает или выключает код без релиза. Опасность в том, что флаги копятся: у каждого должен быть владелец, срок жизни и запланированное удаление.
Флаги бывают трёх видов: константы времени сборки, remote config, переключаемый с сервера, и таргетированные флаги для процентных раскаток и экспериментов.
Реестр со сроком: каждый флаг записан с владельцем и датой, а проверка в CI падает, когда флаг пережил свой срок
Один типизированный API: читайте flags.isEnabled(Flag.CHECKOUT_V2), а не строковый ключ в месте вызова, чтобы флаг легко находился, когда придёт время его удалять
По умолчанию выключен: новый флаг выходит отключённым, а в CI тестируются обе ветки; выключенная ветка гниёт молча
Kill switch: несколько постоянных флагов на рискованных путях (платежи, синхронизация, сторонний SDK), чтобы инцидент решался конфигом, а не релизом
Стабильность в рамках сессии: значения Remote Config загружаются и активируются асинхронно; применяйте новое значение в безопасной точке, например при следующем запуске или на следующем экране, но никогда посреди оформления заказа
Частая ошибка: считать удаление флага благим намерением, а не запланированной задачей
Два уровня за одним интерфейсом: небольшой LRU в памяти, ограниченный байтами, а не числом записей, и больший LRU на диске; чтение идёт память → диск → сеть, а попадание поднимает запись выше. Сам LRU устроен как хеш-таблица плюс двусвязный список, и LinkedHashMap с accessOrder = true уже им является.
Уровень в памяти
Размер в байтах: переопределите sizeOf (так делает Android LruCache) и выделите долю от ActivityManager.memoryClass, обычно восьмую
Блокировки: LruCache синхронизирован; самописному LinkedHashMap нужен лок, потому что даже get переставляет элементы списка
Отдавайте память: урезайте кэш в onTrimMemory, когда приложение уходит в фон
Уровень на диске
**Файлы в cacheDir с именем из хеша ключа, плюс журнал или небольшой индекс в SQLite с размером и временем последнего доступа
Атомарная запись: пишите во временный файл и переименовывайте, чтобы падение не оставило половину записи
Вытеснение при записи** сверх бюджета; ОС тоже может очистить cacheDir в любой момент, поэтому промах считается нормальным путём
Корректность
Ключ содержит всё, что меняет значение: id пользователя, локаль, размер картинки
Вытеснение не равно свежести: LRU решает, что хранить, а TTL или ETag решают, чему доверять
UI читает только локальную базу, а сеть её только обновляет. Записи сначала попадают в локальную базу и в outbox, который разбирает фоновый воркер, а конфликты по версиям обнаруживает сервер, а не клиент.
Чтение
Наблюдайте, а не запрашивайте: экран собирает запрос Room как Flow; обновление пишет в базу, и экран обновляется сам
Тяните по серверному курсору: храните непрозрачный курсор, который вернул сервер, в одной транзакции со строками; вычисленный клиентом updated_at > lastSync молча теряет строки
Показывайте свежесть: устаревшие данные с подписью «обновлено 5 мин назад» лучше спиннера
Запись
Одна транзакция пишет новое состояние строки и ожидающую операцию, поэтому UI сразу показывает изменение со значком ожидания
Каждая операция несёт ключ идемпотентности, созданный на клиенте, чтобы повторный запрос не применился дважды, и базовую версию, с которой началась правка
Разгребайте очередь через WorkManager: уникальная работа с условием сети, по порядку для каждой сущности; плюс синхронизация при выходе приложения на передний план и по push-подсказке
Конфликты
Решает сервер: на устаревшую базовую версию он отвечает 409 с текущим состоянием, а не перезаписывает молча
Стратегия слияния по типу поля: last-writer-wins по серверному времени для переключателя, слияние по полям для независимых полей, CRDT или вопрос пользователю нужны только для совместно редактируемого текста
Удаление хранится как tombstone: по отсутствующей строке не понять, удалили ли её на другом устройстве или она ещё не синхронизировалась
Частая ошибка
Считать ответ сети данными экрана, а базу кэшем: получаются два источника истины, и приложение ломается при первом же расхождении
Локальная база служит источником истины для экрана, WebSocket приносит живые события, REST постранично отдаёт историю, а каждое исходящее сообщение получает клиентский id, чтобы показать его сразу и сопоставить с серверной копией, когда придёт подтверждение.
Транспорт
Один сокет на приложение, а не на экран: авторизация при подключении, heartbeat, переподключение с экспоненциальной задержкой и jitter
Догоняйте после каждого переподключения: запрашивайте сообщения после последнего серверного номера, который у вас есть по каждому чату; сокет покрывает только время, пока был открыт
В фоне сокет закрывается: high-priority data-сообщение FCM будит приложение или показывает уведомление
Отправка и статусы доставки
**client_id у каждого сообщения**: статусы sending → sent → delivered → read плюс failed с кнопкой повтора; сервер дедуплицирует по client_id, поэтому повтор никогда не публикует сообщение дважды
Outbox в базе, который разбирается по порядку для каждого чата, когда появляется сеть
Порядок и история
Сортируйте по серверному номеру внутри чата, а не по времени устройства; сообщения приходят не по порядку, поэтому вставляйте по позиции, а не в конец
История листается по курсору (before = oldestSeq) в Room, а список пагинируется поверх базы
Вложения
Сначала загрузка через надёжный конвейер загрузок с локальной миниатюрой на время ожидания, затем отправка сообщения со ссылкой на id медиа
Это конвейер запроса: ключ → кэш в памяти → дисковый кэш → сеть, затем декодирование в целевой размер вне главного потока, и всё это привязано к жизненному циклу view, чтобы переиспользованная строка отменяла старый запрос. Основная часть дизайна касается памяти и отмены, а не скачивания.
Конвейер
Запрос: это данные (URL), целевой размер и трансформации; ключ кэша в памяти включает размер и трансформации
Кэш в памяти для декодированных битмапов, LRU с размером в байтах; дисковый кэш для исходных байтов, чтобы другой размер не скачивался заново
Одна загрузка на ключ: строки, которым нужна одна картинка, делят один запрос в полёте
Декодирование
Уменьшайте до размера view через ImageDecoder.setTargetSize или inSampleSize; фото 4000×3000 в ARGB_8888 весит 48 МБ
Ограниченный параллелизм декодирования, чтобы быстрый флинг не запустил сорок декодов разом
Hardware-битмапы (API 26+) держат пиксели в графической памяти, поэтому отрисовка не требует загрузки в GPU, но их нельзя читать и менять на месте; пул битмапов был важен до Android 8, сейчас гораздо меньше
Жизненный цикл и отмена
Привязка к цели: когда строка переиспользуется или уходит из композиции, запрос отменяется; плейсхолдер ставится сразу, чтобы старая картинка не мелькнула в переиспользованной строке
Предзагрузка картинок для следующих элементов, пока скролл спокоен
API
Короткий вызов для пользователя (AsyncImage, imageView.load(url)), цепочка интерсепторов и подключаемые fetcher и декодеры для SVG, GIF и кадров видео
VerticalPager поверх постраничного списка клипов, пул из трёх ExoPlayer, привязанных к текущему клипу и его соседям, и менеджер предзагрузки, который держит первые секунды ближайших клипов в памяти и на диске, так что свайп приводит на клип, данные и первый кадр которого уже готовы.
Формат медиа
Каждый клип приходит как манифест HLS или DASH с несколькими качествами и URL постера. Короткие сегменты и низкое стартовое качество доставляют первый кадр даже через медленную сеть; дальше адаптивный битрейт поднимает качество.
Пул плееров
Классическая ошибка: по плееру на каждую страницу. Каждый держит кодек, буферы и потоки, а телефон тянет лишь несколько экземпляров аппаратного декодера (CodecCapabilities.getMaxSupportedInstances()).
Три плеера покрывают предыдущий, текущий и следующий клип. Соседние страницы компонуются заранее (beyondViewportPageCount = 1), их плееры подготовлены и стоят на паузе, поэтому страница, которую открывает жест, уже показывает свой первый кадр. Когда свайп завершился, запустите текущий, поставьте остальные на паузу и отдайте плеер, вышедший из диапазона, новому соседу через setMediaSource и prepare, но никогда не через release().
Постер закрывает поверхность, пока не отрисуется первый кадр (rememberPresentationState(player).coverSurface), поэтому пользователь никогда не видит чёрный экран.
Предзагрузка и кэш
DefaultPreloadManager из Media3 ранжирует клипы по расстоянию от текущего: пару секунд в памяти для соседей, кэш на диске на шаг дальше, только манифест за этим пределом. setCurrentPlayingIndex пересчитывает ранги после каждого свайпа.
Один SimpleCache с LeastRecentlyUsedCacheEvictor (несколько сотен МБ в cacheDir) обслуживает и менеджер предзагрузки, и плееры, поэтому свайп назад или повтор не тратят сеть. В лимитной или медленной сети предзагружайте меньше клипов и в более низком качестве.
Измеряйте время до первого кадра на каждый свайп, долю свайпов, которые стартуют без спиннера, и ребуферинг по моделям устройств.
Каждую передачу сохраняйте строкой в очереди, запускайте как надёжную фоновую работу, которую ОС возобновит после смерти процесса, передавайте байты возобновляемыми частями и отдавайте прогресс из этой строки. UI наблюдает за очередью и никогда не владеет передачей.
Очередь
Строка на передачу: id (он же ключ идемпотентности), источник, целевой файл, сколько передано, общий размер, состояние, число попыток, ETag или URL сессии загрузки
Лимиты и приоритет: две-три передачи на хост, а загрузка, запущенная пользователем, важнее предзагрузки
Выполнение
Уникальная работа WorkManager на каждую передачу с условием сети (безлимитная сеть для больших необязательных файлов); долгие передачи вызывают setForeground с уведомлением о прогрессе, а на Android 14+ для этого нужен тип foreground service dataSync; на Android 15 у dataSync есть суточный лимит времени, поэтому очень долгая передача должна докачиваться из файла .part, когда воркер остановлен
Выгрузка сначала копирует источник: разрешение на content:// из пикера умирает вместе с процессом, а оригинал пользователь может удалить
Докачка и повторы
Загрузка отправляет Range: bytes=N- вместе с If-Range: <ETag>, чтобы изменившийся файл начался заново, а не склеился из двух версий; пишите в файл .part, проверяйте размер или хеш и только потом переименовывайте
Выгрузка использует возобновляемый протокол (S3 multipart, tus), а URL сессии хранится в строке
Повторяйте только то, что может пройти: backoff на ошибках ввода-вывода и 5xx; 408 и 429 повторяются с backoff (учитывайте Retry-After), 416 начинает файл заново; 403, 404 и 413 постоянны, поэтому помечайте строку как failed и сообщайте пользователю
Проверяйте свободное место до старта
Прогресс
Пишите прогресс в строку с троттлингом, раз в процент или полсекунды, а UI пусть за ней наблюдает; запись в базу на каждый кусок стоит дороже самой передачи
Типизированный track() только дописывает событие в локальную очередь; отдельный отправщик собирает батчи, переживает смерть процесса и раздаёт их адаптерам вендоров за вашим интерфейсом. Код приложения не трогает SDK вендора, поэтому смена вендора или очистка PII сводится к правке в одном месте.
API
Типизированные события, сгенерированные из схемы: переименованное свойство становится ошибкой компиляции, а не молча пустой колонкой
Общий контекст добавляется централизованно: версия приложения, ОС, id сессии, активные варианты флагов
**track никогда не блокирует и не бросает исключений: аналитика не должна ронять или тормозить экран, который её вызывает
Конвейер
Персистентная ограниченная очередь в Room или в файле; при переполнении отбрасывайте самые старые и считайте отброшенные
Отправка по размеру или по времени, а также при уходе приложения в фон, через WorkManager с условием сети; батч сжимайте gzip
Id у каждого события, чтобы сервер отбрасывал дубликаты при повторной отправке батча
Сэмплирование и стоимость
Сэмплируйте массовые события вроде показов и спанов производительности и записывайте долю в само событие, чтобы аналитика могла перевзвесить; события воронки не сэмплируйте никогда
Debug-сборки пишут в консоль и падают на незнакомых свойствах
Приватность и вендоры
Проверка согласия до того, как что-либо покинет устройство, и фильтр для email и свободного текста
Вендоры служат приёмниками** за одним интерфейсом; краш-репорты идут отдельным путём, потому что должны работать, пока процесс умирает
Server-driven UI означает, что бэкенд присылает описание экрана, а клиент рисует его из фиксированного набора компонентов, которые у него уже есть. Он окупается, когда вёрстка и контент должны меняться быстрее релизов в сторе: главный экран, карусели акций, онбординг.
Контракт: версионированный список типизированных компонентов (hero, ряд товаров, баннер) с данными и действием у каждого; сервер владеет составом и порядком, а клиент владеет отрисовкой, взаимодействием и доступностью
Незнакомые компоненты пропускайте молча: иначе каждый новый компонент роняет все сборки, выпущенные раньше него
Fallback и кэш: у каждого экрана есть вариант по умолчанию, а последний удачный ответ кэшируется, чтобы холодный старт без сети что-то показал
Действия задаются маршрутами или именованными интентами, а не кодом: пересылка поведения превращает клиент в плохой интерпретатор, а на iOS ещё и в проблему с правилами стора
Где этому не место: экраны с насыщенным взаимодействием и анимацией и всё критичное офлайн; камера не собирается из JSON, а отладка теперь размазана на две системы
Разделите задачу на публикацию своей геопозиции и наблюдение за чужими: публикуйте через fused-провайдер с точностью по состоянию поездки, отправляйте только значимые перемещения по одному соединению, а маркеры других анимируйте между редкими обновлениями, а не запрашивайте их чаще.
Публикация своей позиции
Точность зависит от состояния: LocationRequest.Builder с PRIORITY_HIGH_ACCURACY раз в несколько секунд только во время активной поездки, в остальное время сбалансированный или пассивный приоритет; setMinUpdateDistanceMeters пропускает обновления, пока человек стоит на месте
Пакетная доставка: setMaxUpdateDelayMillis позволяет железу копить точки и будить приложение реже
Трекинг в фоне требует foreground service типа location с видимым уведомлением; ACCESS_BACKGROUND_LOCATION запрашивайте, только если он действительно нужен, и будьте готовы объяснить Play зачем
Транспорт
Одно соединение WebSocket или MQTT; точка отправляется, только если сдвиг больше порога
Сервер раздаёт обновления подписчикам по ячейкам geohash и ограничивает частоту пушей одной-двумя секундами
Офлайн: точки поездки пишутся локально с временными метками и выгружаются позже для истории поездки
Отрисовка
Интерполируйте маркеры между обновлениями, а привязку к дорогам делайте на сервере
Кластеризуйте плотные маркеры и собирайте обновления, только пока карта видна
Краевые случаи
Пользователь дал только приблизительную геопозицию, геолокация выключена или процесс умер посреди поездки, и поездку нужно восстановить из базы
Сделайте каждую фичу парой модулей, которая общается с другими только через маленькие api-модули и несколько общих core-модулей, а оболочка :app связывает всё через DI и навигацию. Цель: владение командами и малый радиус поражения; скорость сборки следует из широкого и плоского графа.
Структура модулей
**:feature:x:api и :feature:x:impl: в api-модуле интерфейсы и типы маршрутов; ни одна фича не зависит от impl другой фичи
Маленькие стабильные модули :core:***: сеть, база, дизайн-система, аналитика; модуль-бог :core, который правят все, пересобирает всё
Правила проверяет CI: convention-плагины, version catalog и проверка правил зависимостей, которая падает на запрещённом ребре
Навигация и взаимодействие
Фичи регистрируют свои точки входа через DI multibinding (@IntoSet), а оболочка строит граф из этого набора
Навигация между фичами идёт через типы маршрутов из api-модулей; диплинки разбираются в одном месте
Общее состояние вроде сессии или счётчика корзины приходит из core-сервисов с Flow, а не через вызовы фича-фича
Изоляция в рантайме
**Ни одна фича не инициализируется в Application.onCreate: ленивая инициализация при первом использовании бережёт холодный старт
Один процесс не даёт изоляции падений: краш любой фичи убивает приложение, поэтому каждая команда отвечает за свой crash-free, а у каждой фичи есть удалённый kill switch
Dynamic feature-модули** для больших и редко используемых фич; цена: нет прямых ссылок и сложнее тесты
Процесс команд
CODEOWNERS на каждый модуль, маленькое демо-приложение на каждую фичу для быстрой итерации и ревью платформенной командой для изменений в core