Irbisa · Шпаргалка

System Design мобилки

Offline-first, синхронизация, пагинация, кэш, модуляризация, фича-флаги

Senior-разработчик · 20 вопросов

  1. 01

    Как распределить 45 минут на мобильном system design интервью, чтобы закрыть то, что оценивает интервьюер?

    Сначала уточните рамки, затем определите API и модель данных, набросайте архитектуру клиента, углубитесь в две-три самые сложные части этого продукта и закончите сквозными вопросами и компромиссами.

  2. 02

    Вы ставите лайк на экране деталей, возвращаетесь назад, а в ленте пост по-прежнему без лайка. Что не так с архитектурой?

    Два экрана держат две копии одного поста: лайк меняет одну копию, а вторая об этом не узнаёт.

    // Wrong: each screen owns a copy, so a write on one is invisible to the otherclass DetailViewModel(private val api: Api) : ViewModel() {  val post = MutableStateFlow<Post?>(null)   fun like(id: String) = viewModelScope.launch {    post.update { it?.copy(liked = true) }   // only this screen will ever know…
  3. 03

    Как спроектировать поиск по мере ввода, чтобы он был отзывчивым и не заваливал бэкенд?

    Дебаунсите ввод, отменяйте предыдущий запрос, когда пришёл новый, и показывайте локальные результаты, пока сеть догоняет.

    private val query = MutableStateFlow("") val results: StateFlow<SearchUi> = query  .map { it.trim() }  .distinctUntilChanged()  .debounce(250)…
  4. 04

    Продукт требует свежести на каждом экране, а приложение всё равно шлёт сорок запросов на холодном старте. Как решить, что кэшировать и насколько?

    Свежесть решается для каждого ресурса отдельно, а не одним числом на всё приложение: разложите эндпоинты по тому, насколько плохо показать их устаревшими, и дайте каждому классу свою политику кэша.

    // One policy per resource class, not one number for the whole appenum class Freshness(val ttl: Duration) {  NEAR_STATIC(24.hours),   // serve from cache within the ttl  USER_OWNED(5.minutes),   // serve from cache within the ttl  VOLATILE(Duration.ZERO)  // never render silently from cache}…
  5. 05

    Открытие экрана профиля отправляет семь запросов, и экран остаётся пустым, пока не ответит самый медленный. Что менять на клиенте и на бэкенде?

    Первая отрисовка не должна ждать самый медленный из семи запросов: схлопните их в один эндпоинт под форму экрана и рисуйте каждую секцию, как только пришли её данные.

    // Wrong: awaitAll means the screen is blank until the slowest call finishesval state = coroutineScope {  val header = async { api.header(id) }  val stats = async { api.stats(id) }  val recs = async { api.recommendations(id) }   // 900 ms p95, blocks the name  ProfileUi(header.await(), stats.await(), recs.await())…
  6. 06

    iOS показывает одну сумму заказа, Android показывает другую, а в письме с чеком третья. Что сломано в дизайне системы?

    Три клиента считают число, владеть которым может только одна система: итог должен вычисляться один раз, на сервере, а во всех остальных местах только отображаться.

  7. 07

    Ограниченная акция должна закончиться в полночь, но часть пользователей видит её ещё сутки. Каким часам приложение может доверять?

    Только не часам устройства: системное время (wall clock) пользователь может поменять, оно плывёт и прыгает, поэтому всё, где есть дедлайн или порядок, должно быть привязано к серверному времени.

    // Wrong: the countdown and the gate both trust a clock the user can changeval remaining = offer.endsAtMillis - System.currentTimeMillis() // Right: one anchor, refreshed on every response, used wherever "now" is neededclass ServerTime {  private data class Anchor(val serverMs: Long, val elapsedMs: Long)…
  8. 08

    Через полгода после релиза люди всё ещё открывают ту старую сборку. Что это меняет в проектировании?

    Выпущенную мобильную сборку нельзя отозвать, поэтому любое изменение API обязано оставаться совместимым с клиентами, которыми вы больше не управляете, а важные рычаги должны жить на сервере, а не в бинарнике.

  9. 09

    Как бы вы спроектировали экран ленты в приложении вроде Instagram, чтобы скролл ощущался непрерывным и переживал поворот экрана и смерть процесса?

    Читайте ленту только из локальной базы, запрашивайте следующую страницу за несколько строк до конца при одном запросе в полёте, дедуплицируйте по id и сохраняйте страницы и курсор, и тогда скролл не прерывается, а поворот и смерть процесса ничего не стоят.

    class FeedViewModel(private val repo: FeedRepository) : ViewModel() {  val items: StateFlow<List<FeedItem>> = repo.observeFeed()      // rows from the local DB    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())   private var nextCursor: String? = null                         // persist it in a real app  private var endReached = false…
  10. 10

    Как вести feature-флаги и remote config в большом приложении, не накопив сотни протухших веток?

    Feature-флаг представляет собой переключатель, который включает или выключает код без релиза.

    // One registry: every flag has an owner and a removal date, so deletion is scheduled workenum class Flag(val key: String, val owner: String, val removeBy: LocalDate?) {  CHECKOUT_V2("checkout_v2", owner = "payments", removeBy = LocalDate.parse("2026-12-01")),  NEW_SEARCH("new_search", owner = "discovery", removeBy = LocalDate.parse("2027-01-15")),  PAYMENTS_ENABLED("payments_enabled", owner = "payments", removeBy = null), // permanent kill switch}…
  11. 11

    Спроектируйте кэш в приложении со слоями в памяти и на диске: как реализовать LRU-вытеснение, лимиты размера и потокобезопасность?

    Два уровня за одним интерфейсом: небольшой LRU в памяти, ограниченный байтами, а не числом записей, и больший LRU на диске; чтение идёт память → диск → сеть, а попадание поднимает запись выше.

    class LruMemoryCache<K : Any, V : Any>(  private val maxBytes: Long,  private val sizeOf: (V) -> Long,) {  // accessOrder = true: every get() moves the entry to the tail (most recently used)  private val map = LinkedHashMap<K, V>(16, 0.75f, true)…
  12. 12

    Как спроектировать offline-first приложение: локальный источник истины, синхронизация при нестабильной сети и разрешение конфликтов?

    UI читает только локальную базу, а сеть её только обновляет.

    class NotesRepository(  private val db: AppDatabase,  private val work: WorkManager,) {  fun observe(): Flow<List<Note>> = db.notes().observeAll()      // the UI reads only this…
  13. 13

    Как спроектировать чат вроде WhatsApp: транспорт, статусы доставки, офлайн-очередь и история сообщений?

    Локальная база служит источником истины для экрана, WebSocket приносит живые события, REST постранично отдаёт историю, а каждое исходящее сообщение получает клиентский id, чтобы показать его сразу и сопоставить с серверной копией, когда придёт подтверждение.

    suspend fun send(chatId: String, text: String) {  val clientId = UUID.randomUUID().toString()  db.withTransaction {    messages.insert(      MessageEntity(clientId = clientId, chatId = chatId, text = text,        serverSeq = null, state = State.SENDING)            // shown at once, at the bottom…
  14. 14

    Как спроектировать библиотеку загрузки изображений вроде Coil или Glide?

    Это конвейер запроса: ключ → кэш в памяти → дисковый кэш → сеть, затем декодирование в целевой размер вне главного потока, и всё это привязано к жизненному циклу view, чтобы переиспользованная строка отменяла старый запрос.

    class ImageLoader(  private val memory: LruCache<String, Bitmap>,          // sizeOf() returns byteCount  private val disk: DiskCache,  private val fetcher: Fetcher,  private val decodeDispatcher: CoroutineDispatcher = Dispatchers.Default.limitedParallelism(2),) {…
  15. 15

    Спроектируйте вертикальную ленту коротких видео, как в TikTok или Reels: предзагрузка, пул плееров, кэш сегментов и мгновенный старт воспроизведения при свайпе.

    VerticalPager поверх постраничного списка клипов, пул из трёх ExoPlayer, привязанных к текущему клипу и его соседям, и менеджер предзагрузки, который держит первые секунды ближайших клипов в памяти и на диске, так что свайп приводит на клип, данные и первый кадр которого уже готовы.

    @OptIn(UnstableApi::class)class ShortsPlayback(context: Context, cache: Cache, clips: List<Clip>) {  private var current = 0  private val builder = DefaultPreloadManager.Builder(context) { index: Int ->    when (abs(index - current)) {      1 -> PreloadStatus.specifiedRangeLoaded(2_000L)            // neighbours: 2 s in memory…
  16. 16

    Спроектируйте менеджер загрузки и выгрузки больших файлов: очередь, повторы, докачка и прогресс, которые переживают убийство приложения.

    Каждую передачу сохраняйте строкой в очереди, запускайте как надёжную фоновую работу, которую ОС возобновит после смерти процесса, передавайте байты возобновляемыми частями и отдавайте прогресс из этой строки.

    class DownloadWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {  override suspend fun doWork(): Result {    val job = dao.get(inputData.getString("id")!!) ?: return Result.failure()    val part = File(job.targetPath + ".part")    val done = if (part.exists()) part.length() else 0L…
  17. 17

    Спроектируйте SDK аналитики и логирования для приложения: батчи, офлайн-буфер, сэмплирование и смена вендора.

    Типизированный track() только дописывает событие в локальную очередь; отдельный отправщик собирает батчи, переживает смерть процесса и раздаёт их адаптерам вендоров за вашим интерфейсом.

    interface AnalyticsSink { suspend fun send(batch: List<StoredEvent>) }  // vendor adapters class Analytics(  private val queue: EventDao,  private val scope: CoroutineScope,  private val sampler: Sampler,…
  18. 18

    Когда server-driven UI оправдан и что ломается, если увлечься?

    Server-driven UI означает, что бэкенд присылает описание экрана, а клиент рисует его из фиксированного набора компонентов, которые у него уже есть.

    @Serializablesealed interface Block {  @Serializable @SerialName("hero")  data class Hero(val imageUrl: String, val title: String, val action: String) : Block   @Serializable @SerialName("product_row")…
  19. 19

    Как спроектировать клиент такси или сервиса «друзья рядом», который показывает живые координаты и не сажает батарею?

    Разделите задачу на публикацию своей геопозиции и наблюдение за чужими: публикуйте через fused-провайдер с точностью по состоянию поездки, отправляйте только значимые перемещения по одному соединению, а маркеры других анимируйте между редкими обновлениями, а не запрашивайте их чаще.

  20. 20

    Как структурировать суперприложение, в которое десяток команд поставляет фичи в одно Android-приложение?

    Сделайте каждую фичу парой модулей, которая общается с другими только через маленькие api-модули и несколько общих core-модулей, а оболочка :app связывает всё через DI и навигацию.