Первые десять минут уточняйте требования, затем набросайте архитектуру клиента и API, а потом углубитесь в одну-две части, которые сложны именно на телефоне. Оценивают умение рассуждать о компромиссах с учётом ограничений iOS, а не количество квадратиков на схеме.
Тайминг, который работает
Требования (от 5 до 10 мин): что делает пользователь, какие экраны входят в задачу, и нефункциональная сторона: офлайн, реалтайм, объём данных, минимальная версия iOS. Вслух скажите, что оставляете за рамками.
Цифры (от 2 до 3 мин): сколько элементов на страницу, какой размер ответа и картинок, как часто приходят обновления. От них зависят кэширование, транспорт и память.
Общая схема (от 10 до 15 мин): экраны, вью-модели, репозиторий поверх сетевого клиента и локального хранилища, и контракт API: эндпоинты, пагинация, форма ответа. На мобильной секции API весит больше, чем внутреннее устройство сервера.
Углубление (от 15 до 20 мин): самая рискованная часть (синхронизация, медиа, реалтайм, доставка в фоне); если интервьюер не выбирает, предложите варианты.
Итоги (5 мин): сценарии отказов, метрики, раскатка за фича-флагом.
За что ставят оценку
Вопросы до квадратиков, и каждое решение привязано к требованию: «пагинация по курсору, потому что лента меняется, пока её листают».
Реалии iOS: приложение замораживается (suspended) через секунды после ухода пользователя, замороженное или выгруженное приложение просыпается только от пуша или когда решит система (задача BGTaskScheduler, завершённая фоновая передача), jetsam ограничивает память, а старые версии остаются установленными месяцами.
Умение вести разговор: в этом и состоит большая часть того, что здесь значит «senior». Начать с диаграммы классов или ни разу не сказать, что будет при упавшем запросе, значит провалить секцию.
iOSSenior
System Design на iOS
Раунд проектирования: лента, чат, трекинг в реальном времени, загрузки, пагинация, кэш, идемпотентность, server-driven UI, аналитика, суперприложения
Закрыто вопросов
0 / 16
- 0Повторить
Вопросы
По умолчанию берите REST с JSON: URLSession, Codable, HTTP-кэш и любой отладочный прокси работают из коробки. GraphQL окупается, когда многим экранам нужны разные срезы многих сервисов, gRPC, когда компания уже живёт на protobuf или нужен стриминг; оба переносят работу в клиент.
REST: один URL на ресурс, поэтому URLCache, ETag и CDN кэшируют GET-запросы бесплатно, а трафик читается в прокси. Цена в количестве запросов: экран из пяти ресурсов делает пять запросов, если бэкенд не добавит эндпоинт под форму экрана.
GraphQL: один запрос возвращает ровно те поля, которые нужны экрану, а Apollo iOS генерирует Swift-типы из запросов. Цена: запросы обычно идут POST-ом на один URL, так что вместо HTTP-кэша нужен нормализованный клиентский кэш с ключом по id; ошибки приходят в массиве errors со статусом 200; сервер должен защищаться от дорогих запросов. Persisted queries (хэш вместо текста запроса) уменьшают запросы, позволяют серверу принимать только известные и могут уходить кэшируемыми GET-запросами.
gRPC: контракты на protobuf генерируют Swift-код (grpc-swift, SwiftProtobuf), бинарные ответы маленькие и быстро парсятся, стриминг встроен. Цена: HTTP/2 на всём пути, нечитаемый трафик в прокси, нет кэширования в CDN и бинарник крупнее.
Эволюция важнее стиля: сборка, выпущенная сегодня, будет говорить с API годами. Номера полей в protobuf и схема GraphQL, которая только расширяется, справляются с этим хорошо; с JSON декодер не должен падать на незнакомых полях, а каждое изменение должно только добавлять поля.
Смешивать нормально: REST или GraphQL для экранов, WebSocket или gRPC-стрим для живых данных.
Offset отсчитывает строки сверху, поэтому каждый пост, добавленный или удалённый выше пользователя, сдвигает окно: вставка повторяет последний элемент прошлой страницы, удаление пропускает один. Курсор указывает на последний полученный элемент и просит то, что идёт после него, и вставки сверху ему не мешают.
Что такое курсор: ключ сортировки последней строки (created_at плюс id на случай равенства), который сервер кодирует в непрозрачную строку. Приложение хранит её и отправляет обратно как after=; оно никогда не разбирает и не собирает курсор само, поэтому сервер может менять кодировку.
Зачем второй ключ: у многих строк одинаковое время, и без id в ключе граница страницы может пройти между ними и потерять строки.
Новые посты сверху: это отдельный запрос, «новее моего первого элемента». Покажите плашку «Новые посты» вместо вставки над пользователем, чтобы список не прыгал под пальцем.
Ранжированные ленты: когда рекомендательная модель меняет порядок на каждый запрос, стабильного ключа нет. Сервер делает снимок ранжирования на сессию, и курсор указывает в этот снимок; когда снимок истекает, лента начинается заново.
Всё равно дедуплицируйте на клиенте: повторённая или переранжированная страница может повторить элемент, а diffable data source падает на дублирующихся id, поэтому отбрасывайте id, которые уже есть.
Бонус: keyset-запрос идёт по индексу и стоит одинаково на 500-й странице и на первой, а OFFSET 10000 читает десять тысяч строк и выбрасывает их.
Спрашивайте не о том, может ли значение устареть, а о том, чего это стоит для каждого вида данных. Большинство из сорока запросов тянут конфиг, справочники и контент: их можно сразу показать с диска и обновить в фоне. Лишь немногое, вроде баланса или цены при оплате, нужно запросить, прежде чем верить.
Разложите данные
Меняется редко (фича-флаги, города, категории): кэшируйте на часы или дни, ревалидируйте по ETag, кладите дефолт в бандл для первого запуска и отдавайте всё одним эндпоинтом конфига вместо восьми.
Контент (лента, каталог, профиль): stale-while-revalidate. Отрисуйте последнюю копию из хранилища, обновите в фоне, замените на месте. Свежий здесь значит свежий через секунды после открытия, а не до первого кадра.
Должно быть точным (баланс, остатки при оплате): запрашивайте каждый раз, но показывайте последнее известное значение с подписью «обновлено 5 мин назад» вместо спиннера. Сервер всё равно проверяет цену при оплате, поэтому в списке можно показать закэшированную.
Урежьте холодный старт
До первого кадра идут только запросы первого экрана; остальные ждут открытия своего экрана или уходят после запуска с более низким приоритетом.
Склеивайте дубли: три компонента, которые при запуске просят /me, делят один запрос в полёте.
Инвалидируйте по событиям, а не короткими сроками жизни: после редактирования обновите кэш из ответа; номер версии в конфиге (catalogVersion: 42) или silent push сообщают приложению, что изменилось.
Затем измеряйте запросы и байты на холодный старт и время до первого контента: эти цифры показывают продукту, что экраны достаточно свежие.
Пусть каждый виджет грузится, падает и повторяет запрос сам, начиная с последнего значения из кэша, чтобы экран не зависел от самого медленного сервиса. На бэкенде дайте экрану один агрегирующий эндпоинт с дедлайном на каждую секцию, который отдаёт частичный результат, а не ждёт всех.
Клиент
Рисуйте лейаут сразу: у каждой карточки последние известные данные с диска и своё состояние: загрузка, загружено или ошибка с кнопкой «Повторить». Упавшая карточка не оставляет соседей пустыми.
Загружайте секции независимо: балансы первыми, предложения последними. Баг обычно в одном await на все шесть результатов.
Резервируйте высоту каждой карточки, чтобы поздний ответ не сдвинул вниз баланс, который пользователь читает, и дайте каждой секции свой таймаут.
Бэкенд
Эндпоинт BFF (backend for frontend) параллельно опрашивает шесть сервисов внутри дата-центра, где вызов стоит миллисекунды, а не сотни миллисекунд мобильной сети, и возвращает один ответ в форме экрана.
Он ставит дедлайн на каждую секцию и отдаёт то, что успел собрать, с ошибкой на каждую недостающую часть; следующий шаг: отдавать части по мере готовности (chunked JSON, @defer в GraphQL).
Медленные некритичные секции, например предложения, кэшируются на сервере.
Закэшированные балансы чувствительны: храните их с FileProtectionType.complete, удаляйте при выходе из аккаунта и закрывайте экран, когда сцена перестаёт быть активной, чтобы снимок в переключателе приложений их не показал.
Между двумя относительными запросами toggleLike возникает гонка, а откат переворачивает текущее состояние вместо того, чтобы вернуть серверное: когда первый запрос падает по таймауту уже после того, как сервер его применил, его catch снова закрашивает сердечко, очищенное вторым тапом.
Почему так происходит: таймаут не значит, что сервер ничего не сделал. Toggle не идемпотентен, поэтому итоговое состояние на сервере зависит от того, какие запросы дошли, а catch предполагает, что упавший запрос был последним действием пользователя.
Отправляйте абсолютное состояние: PUT /posts/42/like и DELETE можно безопасно повторять и переставлять; решает последний отправленный.
Один запрос на пост: храните желаемое состояние, состояние, подтверждённое сервером, и флаг unsent. Отправляйте, когда есть неотправленный тап и ничего не в полёте, а когда запрос завершился, отправьте снова, если пользователь тем временем нажал ещё раз. Десять быстрых тапов во время одного запроса дадут максимум один повторный.
Отправляйте повторный запрос, даже если он совпадает с подтверждённым состоянием: запрос, упавший по таймауту, мог изменить сервер, так что confirmed хранит лишь последний полученный ответ.
Откатывайте к подтверждённому состоянию, и только если пользователь с тех пор не нажимал; тихий тост, а не алерт. Счётчик берётся из ответа сервера, а не из арифметики +1.
Одиночный таймаут остаётся неопределённым: повторите идемпотентный PUT, прежде чем откатывать, а остальное поправит следующее обновление поста.
Одно хранилище с ключом по id поста, чтобы лента, экран деталей и вторая ячейка с тем же постом показывали одно сердечко.
Ключом идемпотентности, который привязан к платежу, а не к запросу: приложение создаёт его один раз на заказ, сохраняет до отправки и переиспользует при каждом повторе, а сервер сохраняет первый результат под этим ключом и на любой повтор возвращает его вместо нового списания.
На сервере
Вставьте ключ в таблицу с уникальным ограничением в рамках пользователя до вызова платёжного провайдера; повтор с тем же ключом читает сохранённый статус и ответ.
Повтор, пока первый запрос ещё обрабатывается, получает 409, и приложение вскоре спрашивает снова. Тот же ключ с другой суммой означает баг клиента и получает 422.
Передавайте ключ и провайдеру (Stripe и большинство эквайеров его принимают), потому что у собственного повтора сервера к провайдеру та же проблема. Ключи живут около суток.
Вторая линия обороны: заказ можно оплатить только один раз (UPDATE orders SET status = 'paid' WHERE id = ? AND status = 'pending').
В приложении
Таймаут значит, что исход неизвестен, а не что платёж не прошёл. Покажите «Проверяем платёж» и спросите у сервера статус по этому ключу вместо кнопки «Оплатить ещё раз».
Заблокированная кнопка хороша для UX, но ничего не гарантирует: перезапуск, второе устройство или слой повторов её обходят.
Сохраняйте намерение на диск: если приложение убили посреди оплаты, следующий запуск найдёт незавершённый ключ и продолжит с ним.
Новый ключ только для нового решения: изменилась корзина или после отказа выбрана другая карта.
Никаким часам на устройстве: дату можно поменять в Настройках, а «полночь» в каждом поясе своя. Окончание задаёт сервер как абсолютный момент и после него отказывает в покупке; приложение только показывает его по времени сервера и монотонным часам, которые не зависят от Настроек.
Почему пользователи её видели: приложение сравнивало endsAt с Date() на телефоне, где дата переведена на день назад (автоматическое время выключено), или закэшированный ответ пережил распродажу, потому что срок его жизни был больше оставшегося времени.
Отправляйте моменты, а не даты: "endsAt": "2026-10-24T21:00:00Z", вычисленный на сервере. «До полуночи» в виде строки с датой превращается в полночь того пояса, в котором находится телефон. Если продукт имеет в виду локальную полночь пользователя, приложение отправляет TimeZone.current.identifier, а момент всё равно вычисляет сервер.
Часы, которые пользователь не сдвинет: возьмите serverTime из ответа и привяжите к ContinuousClock (iOS 16+): эти часы монотонные, идут, пока телефон спит, и не зависят от даты в Настройках. При перезагрузке они сбрасываются, поэтому никогда не сохраняйте Instant: синхронизируйтесь заново по следующему ответу и при каждом возврате на передний план.
Кэш не дольше распродажи: закэшированный список предложений истекает в самый ранний endsAt, который в нём есть, а закончившиеся предложения отфильтровываются по серверным часам, даже если список пришёл из кэша.
Окончание обеспечивает сервер: эндпоинт оплаты сам проверяет время. Всё, что на устройстве, служит только для отображения.
Лента представляет собой локальный кэш, за которым следит UI: репозиторий подгружает страницы по курсору в хранилище, список рисуется из хранилища, медиа грузится с опережением скролла в размере ячейки, а позиция сохраняется как id якорного поста, чтобы перезапуск вернул пользователя туда же.
Поток данных
API, репозиторий, хранилище (SQLite через GRDB или SwiftData), вью-модель, затем UICollectionView с diffable data source или LazyVStack. Экран никогда не рисует сетевой ответ напрямую, поэтому холодный старт сразу показывает последнюю ленту, в том числе офлайн.
Страницы приходят по курсору; pull to refresh просит посты новее верхнего и показывает плашку «Новые посты» вместо вставки над пользователем.
Лайки и счётчики комментариев живут в одном хранилище с ключом по id поста, поэтому все экраны показывают одни и те же цифры.
Предзагрузка и медиа
Запрашивайте следующую страницу за несколько экранов до конца, из UICollectionViewDataSourcePrefetching или по порогу видимости, и отменяйте предзагрузку для строк, которые пролистали.
В ответе есть соотношение сторон каждой картинки и крошечный плейсхолдер (доминирующий цвет или BlurHash), поэтому у ячейки финальная высота ещё до первого пикселя, и список не прыгает.
Просите версию картинки под размер ячейки (API отдаёт несколько ширин) и декодируйте её с даунсэмплингом; пайплайн загрузки картинок проектируется отдельно (см. вопрос про кэширование картинок).
Видео автоматически играет только в самой видимой ячейке, без звука, из небольшого пула AVPlayer; остальные ячейки показывают постер.
Назад к тому же посту
Сохраняйте id верхнего видимого поста и смещение внутри его ячейки, а не contentOffset, который ломается, как только меняется контент выше. Сохраняйте на .background вместе с курсором и закэшированными страницами.
При запуске рисуйте из хранилища и прокрутите к якорю: scrollToItem(at:at:animated:) плюс сохранённое смещение или scrollPosition(id:) в SwiftUI.
Договоритесь о продуктовом правиле: через несколько часов большинство лент открываются сверху со свежим контентом, потому что восстановленная устаревшая позиция выглядит как сломанное обновление.
Измеряйте хитчи при скролле, время до первого поста на холодном старте и то, как часто пользователь доходит до конца загруженного и видит спиннер.
Предзагружайте на шаг вперёд в каждом направлении, куда может пойти пользователь: следующий сегмент текущей сторис и первый сегмент следующего человека. Держите два-три плеера для текущего и следующего сегмента и переиспользуйте их, а память считайте в декодированных полноэкранных кадрах.
Что предзагружать
Жестов два: тап (следующий сегмент) и свайп (следующий человек), поэтому предзагружайте ровно эти две цели. Всё дальше остаётся догадкой, за которую платит трафик пользователя.
Картинки: скачивайте и декодируйте заранее в размере экрана. Видео: загрузите свойства ассета (try await asset.load(.duration, .isPlayable)) и дайте AVPlayerItem забуферизовать первые секунды; preferredForwardBufferDuration держит этот буфер маленьким.
Предпочитайте HLS, чтобы плеер подстраивал битрейт под сеть; снижайте preferredPeakBitRate на сотовой сети и предзагружайте меньше в режиме экономии трафика (NWPath.isConstrained) или энергосбережения.
Плееры
Два-три AVPlayer со своими слоями, переиспользуемые через replaceCurrentItem(with:). Плеер на каждый сегмент платит за настройку декодера на каждом тапе, а число видеодекодеров, которые железо держит одновременно, ограничено и не документировано, так что накопление плееров кончается ошибками воспроизведения.
Ставьте на паузу и освобождайте item, когда страница уходит с экрана, когда приложение уходит в фон и при прерывании аудио.
Бюджет памяти
Декодированный кадр на экране 1290 × 2796 весит около 14 МБ (ширина × высота × 4 байта). Держите декодированными текущую, следующую и предыдущую картинки, примерно 45 МБ, в NSCache с totalCostLimit; всё остальное лежит на диске в сжатом виде.
По didReceiveMemoryWarningNotification сбрасывайте предзагрузку первой; jetsam может завершить приложение без второго предупреждения.
Остальной просмотр
Прогресс-бар следует за временем плеера (addPeriodicTimeObserver), а не за таймером, поэтому при подвисании он встаёт. Долгое нажатие ставит на паузу, зоны тапа листают назад и вперёд.
Отметки «просмотрено» копятся в очереди и уходят пачками, а лента сторис переупорядочивается только после закрытия просмотра, чтобы ничего не двигалось под пользователем.
Сначала сохраните черновик и обработанные фото локально, загружайте каждое фото задачей из файла в фоновой URLSession, а объявление создавайте идемпотентным запросом, когда у всех фото есть серверный id.
Подготовка фото
PhotosPicker или PHPickerViewController не требуют доступа к фотобиблиотеке и возвращают только то, что выбрал пользователь.
Уменьшайте через ImageIO до размера, который показывает маркетплейс (скажем, 2048 px по длинной стороне), по одному фото и не на главном акторе: десять полноразмерных декодирований разом могут убить приложение по памяти.
Кодируйте в JPEG, если бэкенд не читает HEIC; новое кодирование отбрасывает EXIF вместе с GPS-координатами, что публичному объявлению и нужно. kCGImageSourceCreateThumbnailWithTransform применяет ориентацию к самим пикселям.
Черновик
Строка с полями объявления, порядком фото и, для каждого фото, локальным файлом, состоянием (pending, uploading, uploaded(serverID), failed) и числом попыток; сохраняется при каждом изменении.
Обработанные файлы лежат в Application Support: tmp и Caches система может очистить, пока черновик ждёт.
Очередь загрузки
Одна задача загрузки на фото, из файла на pre-signed URL хранилища, и все десять сразу отдаются одной фоновой сессии: они идут, пока приложение заморожено, и будят его по завершении (принудительное закрытие их отменяет). Если подавать их по одной из фоновых пробуждений, сработает ограничитель частоты, чья задержка растёт с каждым перезапуском.
Сессия сама пережидает пропажу сети и, начиная с iOS 17, продолжает прерванную загрузку, если сервер это поддерживает. Фото, которое сервер отклонил, получает failed и повторяется с backoff.
После перезапуска пересоздайте сессию с тем же идентификатором и сверьте getAllTasks с черновиком: фото в состоянии uploading без задачи возвращается в очередь.
Публикация
POST /listings с id фото в порядке черновика и сгенерированным на клиенте id объявления, чтобы повтор публикации не создал два объявления. Если последнее фото загрузилось во время фонового пробуждения, публикуйте прямо там под beginBackgroundTask.
Сервер удаляет загруженные фото, которые за сутки не попали ни в одно объявление.
Покажите «Публикуем, 6 из 10» и дайте пользователю уйти; о неудаче, которая требует его участия, сообщит локальное уведомление.
Чат живёт в локальной базе: UI рисуется из неё, исходящее сообщение сначала записывается туда с клиентским id, а слой синхронизации пользуется сокетом, пока приложение открыто, и догоняет пропуски по номеру последовательности. В фоне работу берут на себя APNs и notification service extension.
Транспорт
На переднем плане: одно постоянное соединение (WebSocket или собственный протокол поверх TCP, как MTProto у Telegram) для входящих сообщений, индикатора набора и отметок о прочтении. Отправка может идти по нему же или обычным HTTPS.
В фоне: iOS замораживает приложение через секунды после ухода пользователя, и сокет умирает вместе с ним. Новые сообщения приходят видимыми пушами; переподключайтесь при каждом возврате на передний план.
Исключение составляют звонки: VoIP-пуши PushKit, каждый из которых передаётся в CallKit.
Статусы сообщений
sending (сохранено локально, показано сразу), sent (сервер подтвердил и присвоил id и номер последовательности), delivered, read и failed с кнопкой повтора.
Клиент генерирует UUID на каждое сообщение и шлёт его с каждой попыткой, поэтому сервер отбрасывает дубли, а подтверждение находит локальную строку.
Хранение и порядок
SQLite через GRDB или Core Data: сообщения с индексом по чату и серверной последовательности плюс строка в outbox, записанная в той же транзакции, что и сообщение.
Сортируйте по серверной последовательности внутри чата, никогда по часам устройства; ожидающее сообщение остаётся внизу, пока не получит номер. История грузится страницами по последовательности, от новых к старым.
Пропуски, офлайн и фон
Каждое обновление несёт номер последовательности чата. Скачок значит, что что-то пропущено: запросите всё после последнего номера, который у вас есть, и отправляйте тот же запрос после каждого переподключения.
Без сети отправка только добавляет сообщение в outbox, который проигрывается по порядку с теми же клиентскими id, когда соединение возвращается.
Notification service extension (mutable-content: 1) расшифровывает payload, записывает сообщение в базу в контейнере App Group и добавляет аватар отправителя через INSendMessageIntent, так что чат уже на месте, когда пользователь нажмёт на уведомление.
Бейдж берётся из серверного счётчика непрочитанных в aps.badge: только сервер знает, что прочитано на других устройствах.
Приложение курьера шлёт на сервер прореженные точки геолокации, сервер привязывает их к дороге и пушит клиенту через сокет, пока открыта карта. Когда клиент уходит из приложения или блокирует телефон, эстафету принимает Live Activity, обновляемая пушами: замороженное приложение ничего не получает.
Телефон курьера
CLLocationUpdate.liveUpdates(.otherNavigation) (iOS 17) вместе с CLBackgroundActivitySession или CLLocationManager с allowsBackgroundLocationUpdates обеспечивают поток точек всю смену; обоим нужен фоновый режим location.
Отправляйте раз в несколько секунд или каждые несколько десятков метров, смотря что наступит раньше, пачками по HTTPS, и отбрасывайте точки с плохой horizontalAccuracy. Батарею тратят GPS и радиомодуль, поэтому, пока курьер стоит, отправляйте реже.
Без сети точки копятся на диске и позже уходят по порядку; живому экрану нужна только последняя.
От сервера к клиенту
Пока открыт экран заказа: WebSocket или server-sent events для этого заказа, обновление раз в несколько секунд, ETA считается на сервере.
Карта анимирует маркер вдоль маршрута между точками, а не перепрыгивает, и показывает «обновлено 40 с назад», когда обновления прекращаются, вместо замершего маркера, который выглядит живым.
После каждого переподключения сначала запросите последнюю позицию, а потом применяйте поток.
В фоне или на заблокированном экране
Сокет умирает через секунды после ухода клиента из приложения. Запустите Live Activity, когда заказ принят: она висит на экране блокировки и в Dynamic Island, а сервер обновляет её пушами APNs.
Live Activity сама не может ходить в сеть или получать геолокацию; всё, что она показывает, приходит в content-state пуша. Пушьте то, что меняет картинку (минуты ETA, статус, грубое расстояние), а не каждую точку: пуши с высоким приоритетом ограничены часовым бюджетом, а NSSupportsLiveActivitiesFrequentUpdates лишь поднимает его.
Вехи, которые нельзя пропустить, вроде «курьер у двери», дополнительно уходят видимыми пушами.
Приватность
Показывайте позицию курьера только между забором заказа и доставкой и держите её грубой, пока он не подъехал близко.
Фичи вызывают один типизированный API track, который сразу возвращает управление; события попадают в очередь на диске, уходят сжатыми пачками с уникальным id у каждого и раздаются адаптерам вендоров за протоколом. Согласие и сэмплирование решаются один раз, в начале этого конвейера.
API
Типизированные события (enum или структура на событие) вместо строковых имён, чтобы опечатка ломала сборку, а один реестр описывал каждое свойство.
track никогда не блокирует: передаёт событие актору или последовательной очереди и возвращается. Ничего из этого слоя не работает до первого кадра.
Буферизация и доставка
Дописывайте каждое событие в очередь на диске (SQLite или append-only файл), прежде чем пытаться его отправить. Буфер в памяти теряет всё при краше, а именно краш нужен вам больше всего.
Сбрасывайте по количеству (50 событий), по времени (30 с) и когда сцена уходит в фон, под beginBackgroundTask; сжимайте пачку gzip.
Каждое событие несёт UUID, время устройства, порядковый номер в рамках установки и id сессии. Сервер отбрасывает повторные дубли по id и поправляет расхождение часов по собственному времени приёма.
Ограничьте очередь несколькими тысячами событий и, если пользователь неделю офлайн, выбрасывайте сначала самые старые малоценные.
Сэмплирование
Сэмплируйте пользователей, а не события: один раз захэшируйте id установки, чтобы вся воронка выбранного пользователя целиком попадала в выборку или не попадала.
Никогда не сэмплируйте покупки и ошибки и помечайте каждое событие его долей выборки, чтобы дашборды могли пересчитать полные значения.
Согласие
Там, где согласие обязательно (например, по GDPR), держите события в памяти, пока пользователь не ответит, и выбрасывайте их при отказе.
App Tracking Transparency управляет отдельной вещью: IDFA и отслеживанием в приложениях других компаний, а не вашей собственной продуктовой аналитикой.
Объявите собираемые данные в PrivacyInfo.xcprivacy, не кладите персональные данные в свойства событий, и пусть отказ от аналитики удаляет и очередь.
Смена вендора
Фичи зависят от вашего протокола Analytics; Firebase, Amplitude или AppMetrica работают как получатели за ним. При миграции оба получателя работают один релиз, их счётчики сравниваются, и только потом старый убирается.
Он окупается на экранах, которые продукт перестраивает каждую неделю (главная, промо, раскладки каталога), где узкое место в App Review и медленных обновлениях. Старые сборки справляются: запрос сообщает, какие компоненты сборка умеет рисовать, а неизвестный тип декодируется в фолбэк и не роняет экран.
Когда того стоит
Лейаут и контент часто меняются, один экран выходит на iOS, Android и в вебе, а экспериментам нужны новые раскладки без релиза.
Не для форм с логикой, сложных жестов, анимаций и всего, где критична производительность: эта дорога ведёт к медленному браузеру и второму языку программирования, написанному в JSON.
Как устроено
Сервер присылает дерево компонентов, у каждого тип, props и действия; приложение сопоставляет каждый тип с нативной вью из дизайн-системы.
Действия составляют маленький закрытый набор: открыть диплинк, вызвать эндпоинт, отправить событие аналитики. Именно с произвольной логики в payload такие системы и начинают гнить.
Правило App Review 2.5.2 запрещает скачивать код, который меняет функции приложения; раскладывать нативные компоненты, которые уже есть в бинарнике, можно.
Старые версии
Каждый запрос сообщает, что сборка умеет отрисовать (список типов компонентов или одну версию схемы), и сервер строит лейаут под неё. Делать это ему придётся для каждой версии выше минимально поддерживаемой.
Неизвестный тип рисует серверный фолбэк или ничего; один новый компонент никогда не должен ронять весь экран.
Последний лейаут кэшируется на диске, а дефолтный лежит в бандле, поэтому первый запуск и офлайн всё равно рисуются.
Контрактные тесты на сервере рендерят каждый экран для каждой поддерживаемой версии схемы.
Цена
Отладка переезжает на бэкенд: прикладывайте id лейаута к каждому краш-репорту и событию аналитики, иначе жалобу «главная выглядит сломанной» не воспроизвести.
Как тонкий хост на общем платформенном слое с парой модулей на бизнес: маленький интерфейсный модуль, который импортируют другие команды, и реализация, которую линкует только хост. При запуске ничего не стартует, бюджеты проверяются в CI, а фичи выходят выключенными за флагами в общем релизном поезде.
Модули и границы
Платформа: сеть, авторизация, дизайн-система, аналитика, роутер и фича-флаги; ей владеет платформенная команда.
Каждый бизнес представлен двумя Swift-пакетами: TaxiInterface (протоколы, маршруты, общие модели) и Taxi (экраны и логика). Еда открывает поездку через TaxiInterface, не завися от кода такси, поэтому изменение внутри Taxi больше ничего не пересобирает.
Приложение-хост служит composition root: линкует реализации и регистрирует их в роутере.
У каждой фичи есть демо-приложение, которое собирает только её и платформу, поэтому команда проверяет изменение за секунды, а не собирает всё приложение.
Владение явное: CODEOWNERS на модуль и правила зависимостей, проверяемые в CI.
Запуск и размер
Фича регистрирует ленивую фабрику и ничего не делает до первого открытия; SDK, который требует старта при запуске, оборачивается и откладывается.
Статическая линковка или mergeable libraries (с Xcode 15) не дают сотне модулей превратиться в сотню динамических фреймворков, которые должен загрузить dyld.
Каждый пул-реквест измеряет время запуска и размер бинарника против бюджетов на модуль, так что у регрессии появляется владелец ещё до релиза.
Релизы
Один бинарник означает один релизный поезд, например еженедельный с отсечкой; команды мержат за фича-флагами и включают их независимо.
В App Store нет отката, поэтому удалённый рубильник (kill switch) на каждое мини-приложение остаётся единственным способом выключить сломанную фичу за минуты.
Краш в модуле еды всё равно обрывает поездку на такси: внутри iOS-приложения нет изоляции процессов, поэтому краш-рейт отслеживается по команде-владельцу.
Веб-мини-приложения
Некоторые супераппы запускают партнёрские мини-приложения как веб-контент. Правило App Review 4.7 разрешает мини-приложения на HTML5 и JavaScript, если хост отвечает за них (приватность, модерация контента, встроенные покупки для цифровых товаров), а 4.7.2 запрещает открывать им нативные API без разрешения Apple.