В какой-то момент мне прилетел баг-репорт, который довольно точно описывает боль социальных приложений:

«Я поставила в групповой привычке „выходной", потом нажала „возобновить" и выполнила. У меня всё ОК. У подруги — у меня до сих пор „выходной"».

Локально одно состояние, у других участников другое. Не UI-баг и не «кнопка не нажимается», а расхождение данных между устройствами.

Ниже — про то, как я делал Android-приложение Habit Tracker: личные и групповые привычки, челленджи, виджет, офлайн-режим. Основную часть времени съели не экраны, а синхронизация Room ↔ Firestore, и именно про неё имеет смысл рассказывать.

Приложение в RuStore: Habit Tracker

Сколько это заняло на самом деле

Первый коммит — 26 июля 2025 года. К моменту, когда я сел писать эту статью, в репозитории 335 коммитов: 124 feat, 55 fix, 30 refactor. Около 120 тысяч строк Kotlin в 427 файлах. В сторе к февралю была версия 1.5.4, тринадцатая сборка.

Это пет-проект, и занимался я им, когда оставалось время после работы и семейных дел: вечерами по два-четыре часа, далеко не каждый день. В ноябре не было ни одного коммита — просто не дошли руки. Если перевести в человеко-часы, получается что-то около полутора месяцев полной занятости, растянутых на восемь календарных.

В заголовке у меня написано «за полгода» — округлил в момент публикации, но по коммитам видно, что от первого до релизной версии прошло восемь месяцев. Пишу это, потому что «восемь месяцев на трекер привычек» звучит странно, пока не уточнишь, что за восемь месяцев.

Откуда взялась идея

Идею подкинул друг. Я до этого про трекеры привычек не знал вообще ничего: не понимал, зачем они нужны и кто ими пользуется. Друг перебрал несколько приложений, в каждом нашёл что-то полезное, но целиком его не устроило ни одно — и он предложил сделать своё.

Хотелось ему примерно следующего: личные привычки с отметкой выполнения и счётчиком (отжался 20 раз, выпил 8 стаканов воды), групповые привычки, которые выполняешь вместе с друзьями, возможность видеть чужой прогресс и «пнуть» отстающего, челленджи, фотоподтверждения, комментарии и немного геймификации.

Из этого и вырос список того, что в итоге есть: личные и групповые привычки, инвайты, прогресс по участникам, фотопруфы, статистика за день, неделю и год, рейтинг друзей, виджет со списком «на сегодня» и офлайн-режим с последующей синхронизацией.

Splash-экран приложения Habit Tracker
Splash-экран приложения Habit Tracker
Главный экран «Мои привычки» (тёмная тема)
Главный экран «Мои привычки» (тёмная тема)
Экран деталей групповой привычки с прогрессом участников
Экран деталей групповой привычки с прогрессом участников
Экран статистики: уровни, активность и рейтинг друзей (тёмная тема)
Экран статистики: уровни, активность и рейтинг друзей (тёмная тема)

Стек

Kotlin, корутины и Flow, Jetpack Compose с Navigation, Hilt, Room локально, Firebase (Auth, Firestore, FCM), WorkManager, Crashlytics с Firebase Performance и AppMetrica для аналитики.

Архитектура обычная: Clean Architecture с разделением на presentation, domain и data, MVVM в презентационном слое. Room — единственный источник правды для UI; Firestore появляется только в data-слое. Выбрал так не из любви к слоям, а потому что искать гонки в синхронизации проще, когда точно знаешь, какой слой за что отвечает: почти все ошибки, которые я потом ловил, жили на стыке Room и Firestore, и их удобно было изолировать.

Синхронизация: Room-first, outbox, realtime merge

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

suspend fun enqueueUpsert(
    entityType: String,
    entityId: String,
    payloadJson: String,
    updatedAt: Long,
    operationId: String = UUID.randomUUID().toString()
) = withContext(Dispatchers.IO) {
    outboxDao.insert(
        SyncOutboxEntity(
            operationId = operationId,
            entityType = entityType,
            entityId = entityId,
            opType = "UPSERT",
            payloadJson = payloadJson,
            updatedAt = updatedAt
        )
    )
}

Очередь разгребает периодический воркер WorkManager. Но полагаться только на него оказалось нельзя: пользователь отмечает привычку и сразу открывает экран друга, а изменение всё ещё лежит локально. Пришлось после пользовательских действий дополнительно «пинать» синхронизацию вручную — в коде это называется KickSync.

override suspend fun doWork(): Result {
    return try {
        syncManager.drainPending()
        Result.success()
    } catch (e: Exception) {
        Result.retry()
    }
}

Обратное направление — realtime-слушатели Firestore, которые мержат чужие изменения в Room. Здесь я наступил на грабли, которые, кажется, обязательны для всех, кто делает офлайн-первый клиент: если мержить входящий снапшот безусловно, он затирает локальные изменения, которые ещё не успели уехать. Лечится проверкой очереди перед мержем.

val hasPending = syncManager.hasPendingOperations("habit_entry", entityId)
if (hasPending) {
    continue // не перетираем локальные pending-изменения
}

Плюс last-write-wins по времени обновления для случаев, когда изменения пришли с двух устройств. Связка pending-guard и LWW убрала заметную часть расхождений — этот коммит датирован 22 декабря, и до него я довольно долго ловил «мигающие» состояния, не понимая, почему отметка иногда откатывается сама.

История про soft-delete, которой я не горжусь

30 сентября я выкинул soft-delete. В комментарии к коммиту написано честно: «prevents database clutter from deleted habits» — мне не нравилось, что в Firestore копятся документы с isActive = false, и я заменил их обычным document.delete().

23 декабря я вернул soft-delete обратно, уже в виде tombstones с полем deletedAtMillis.

Причина простая, и её стоило предвидеть. Когда документ удалён физически, устройство, которое было офлайн в момент удаления, при следующем подключении не узнаёт, что что-то удалили: оно видит отсутствие документа и спокойно заливает свою локальную копию обратно. Привычка воскресает. С tombstone удаление — это тоже запись, и она доезжает как любое другое изменение.

Три месяца между «выкинул» и «вернул» — это ровно та ситуация, когда решение выглядит разумным, пока смотришь на одну сторону задачи. Чистота коллекции в Firestore — аргумент. Просто он проигрывает консистентности, а понял я это, когда начали приходить жалобы на «удалил, а оно вернулось».

Разбор бага из начала статьи

Тот самый кейс с «выходным» я чинил 9 февраля, почти перед публикацией. Ломался он потому, что флаг isRestDay жил отдельно от факта выполнения, «возобновить» и «выполнить» приходили двумя событиями, и клиенты успевали зафиксировать промежуточное состояние.

Что пришлось сделать: при выполнении привычки принудительно снимать isRestDay, нормализовать переходы состояний в domain-слое и не давать старому снапшоту затирать локальное pending-состояние.

val updatedEntry = existingEntry.copy(
    completed = true,
    completedAt = now,
    note = note,
    isRestDay = false,
    updatedAt = today
)
updateEntry(updatedEntry)

Проверял руками на двух устройствах и прогонял тесты. После этого кейс перестал расходиться.

Виджет: мелочь, съевшая вечер

На виджете «Сегодня» важны две вещи: понятное ручное обновление и предсказуемое автоматическое. Автообновление в итоге разнесено на три момента — сразу после полуночи, утром и днём:

schedule(context, ACTION_REFRESH_MIDNIGHT, requestCode = 2001, hour = 0, minute = 10)
schedule(context, ACTION_REFRESH_AFTERNOON, requestCode = 2002, hour = 15, minute = 0)
schedule(context, ACTION_FORCE_REFRESH_MORNING, requestCode = 2003, hour = 5, minute = 0)

А ручное обновление я сначала повесил на тап по заголовку «Сегодня». Никто из тестировавших про это не догадался: заголовок не выглядит как кнопка. Пришлось добавить явную иконку refresh рядом. Очевидная в общем-то вещь, но обнаружилась только когда приложением начали пользоваться живые люди.

Наблюдаемость

Crashlytics для падений, AppMetrica для продуктовых событий и ошибок, Firebase Performance для перформанса. Ошибки шлю в оба канала — так проще сопоставлять стектрейс с тем, что пользователь делал перед падением.

crashlytics.recordException(throwable)
AppMetrica.reportEvent(AnalyticsEvents.ERROR_OCCURRED, mapOf(...))
AppMetrica.reportError(message ?: "Error", throwable)

Аудитория пока скромная: около сотни установок в RuStore за полтора месяца, 11–14 активных пользователей в среднем. Для пет-проекта без продвижения это нормально, и на такой выборке уже видно, какие сценарии реально используют.

Где здесь AI

Работал я в двух IDE параллельно: Android Studio и Windsurf со встроенными агентами. Модели переключал по задаче и по остатку лимитов — на планирование архитектуры ставил Claude Opus, на саму реализацию модели попроще, вроде Sonnet или GPT. Заодно сравнивал китайские: Kimi, MiniMax, Qwen, GLM. Gemini использовал для дизайн-системы, иконок и изображений, а ещё как «читалку» большой кодовой базы и документации — за счёт объёма контекста ему можно скормить сразу много и спросить по существу. Ближе к концу распробовал плагин Kilo Code: в нём под каждую роль (планирование, кодинг, ревью, оркестрация) настраивается своя модель, и оркестратор сам переключает роли по ходу задачи.

Но вот что стоит сказать честно, потому что это видно по репозиторию. Файлы с контекстом и правилами для агентов — AI_CONTEXT, sync_gaps, а следом GEMINI и описание ролей (всё это обычные md-файлы в репозитории) — появились у меня 26 января и 6 февраля. То есть на седьмом месяце проекта. Первые полгода я работал с моделями без всего этого: объяснял контекст заново в каждой сессии, получал предложения, не учитывающие уже принятые решения, и удивлялся, почему на похожие вопросы приходят разные ответы.

Мой нынешний тезис — что качество результата определяется не столько выбором модели, сколько тем, насколько внятно сформулирована задача и насколько модель понимает проект, — не был у меня в голове с самого начала. Я к нему пришёл через полгода работы, и sync_gaps с честным перечнем непокрытых мест синхронизации родился именно из этого: сначала я описал агенту, где у меня дыры, и только потом получил вменяемую помощь по ним.

Ещё одно наблюдение, которое противоречит тому, что я сам собирался тут написать. Я был уверен, что больше всего времени ушло на синхронизацию. Полез в историю: SyncManager.kt менялся 8 раз, а HabitsScreen.kt — 39, AddEditHabitScreen.kt — 31. То есть по числу правок с большим отрывом лидируют экраны. Синхронизация была сложнее по мысли, экраны — дороже по времени, и одно с другим я до этого не разделял.

Что оказалось дорогим

Если убрать экраны, которые дороги сами по себе, то самое затратное — это консистентность в многопользовательских сценариях и офлайн с последующей синхронизацией без потери действий пользователя. Плюс UX для опасных операций: сбросы, массовые действия, подтверждения. Сделать трекер привычек несложно. Сложно сделать его социальным и при этом не терять данные, когда у двух человек разный интернет.

Дальше планирую держать стабильность релизов, следить за крашами, развивать челленджи и аккуратно смотреть в сторону монетизации.

Про сами AI-инструменты — какие модели на каких задачах показали себя лучше, как устроены инструкции для агентов и что в них стоит писать — это отдельный разговор, который в эту статью не помещается.