Предисловие

Я — соло-разработчик Android-приложения Todo Budget. Это не очередной TODO-лист и не очередной трекер расходов. Это комбайн, в котором живут задачи, заметки, бюджет, аналитика, помодоро-таймер и цели накоплений — всё в одном APK весом 8.5 МБ. В предыдущих статьях я уже упоминал свое приложение но уже в готовом виде, теперь я бы хотел рассказать как я начинал.

В этой статье я расскажу, как устроена архитектура приложения и почему я принял те решения, которые принял. Будет код, будут грабли, будет честный разбор.


Стек

Kotlin 1.9.22
Jetpack Compose + Material 3
Room (SQLite)
DataStore (Preferences)
Foreground Service
AppWidgetProvider
Yandex Mobile Ads SDK
Gradle 8.2, AGP 8.2.2, compileSdk 35

Никаких Dagger/Hilt, никакого Retrofit, никакого Firebase. Всё локально. Данные пользователя не покидают устройство.


Архитектура: почему не MVVM и не Clean Architecture

Когда у тебя одноэкранные Activity с Compose-контентом внутри, а данные лежат в Room — городить слой UseCase'ов и Repository для каждой сущности — это оверинжиниринг. Я выбрал подход Activity + Room DAO + State, где:

  • Каждый экран — отдельная ComponentActivity

  • Состояние хранится в mutableStateOf / mutableStateListOf прямо в setContent {}

  • Room DAO вызывается через LaunchedEffect и coroutineScope

class BudgetActivity : ComponentActivity() {
    private lateinit var db: AppDatabase

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        db = Room.databaseBuilder(this, AppDatabase::class.java, "todobudgettodo_database")
            .fallbackToDestructiveMigration()
            .build()

        setContent {
            val transactions = remember { mutableStateListOf<Transaction>() }
            val coroutineScope = rememberCoroutineScope()

            LaunchedEffect(Unit) {
                transactions.addAll(db.transactionDao().getAll())
            }
            // UI...
        }
    }
}

Почему это работает:

  • Одиночная разработка → минимум абстракций → быстрее итерации

  • Compose сам реактивно перерисовывает UI при изменении mutableStateListOf

  • Room гарантирует потокобезопасность через suspend функции

Почему это может не работать в масштабе:

  • Тестируемость — сложнее мокать DAO напрямую

  • При увеличении команды → нужны чёткие контракты между слоями

Я осознанно принял этот trade-off. Для соло-проекта с 6 экранами это оправдано.


Room: одна база — шесть сущностей

@Database(
    entities = [
        Task::class,
        Note::class,
        Transaction::class,
        Category::class,
        SavingsGoal::class,
        RecurringTransaction::class
    ],
    version = 9
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao
    abstract fun noteDao(): NoteDao
    abstract fun transactionDao(): TransactionDao
    abstract fun categoryDao(): CategoryDao
    abstract fun savingsGoalDao(): SavingsGoalDao
    abstract fun recurringTransactionDao(): RecurringTransactionDao
}

Миграции: слон в комнате

Честно: я использую fallbackToDestructiveMigration(). Для продакшена с сотнями тысяч пользователей это неприемлемо. Для приложения версии 4.0 с растущей базой пользователей — это осознанный компромисс, который я планирую убрать в 5.0, написав нормальные Migration(8, 9).

Почему пока так:

  • Схема менялась радикально между версиями (добавлялись целые таблицы)

  • Потеря данных для пользователей v3 → v4 минимальна (приложение предупреждает)

  • Писать миграции для 9 версий при одном разработчике — дорого по времени


Навигация: Activity vs Single Activity

Controversial take: я использую несколько Activity вместо одной с NavHost. Вот почему:

  1. Независимость экранов — каждый экран полностью самодостаточен

  2. Системные переходы — Android'овские анимации Activity transition бесплатно

  3. Deep linking — каждая Activity имеет свой Intent

  4. Отладка — stack trace сразу показывает, где ты

// Из MainActivity
startActivity(Intent(this, BudgetActivity::class.java))
startActivity(Intent(this, TodoActivity::class.java))
startActivity(Intent(this, NotesActivity::class.java))
startActivity(Intent(this, ProductivityToolsActivity::class.java))
startActivity(Intent(this, SettingsActivity::class.java))

Compose Navigation — отличный инструмент. Но мне не нужны shared element transitions между TODO-списком и бюджетом. Это концептуально разные экраны.


Dark Theme через DataStore

Material 3 поддерживает динамические цвета, но я хотел один toggle в настройках:

// Preferences.kt — общий для всех Activity
object Preferences {
    private val Context.dataStore by preferencesDataStore(name = "settings")

    object PrefsKeys {
        val DARK_THEME = booleanPreferencesKey("dark_theme")
        val PIN_ENABLED = booleanPreferencesKey("pin_enabled")
        val PIN_CODE = stringPreferencesKey("pin_code")
    }

    fun getDataStore(context: Context) = context.dataStore
}

Каждый Activity читает тему:

val darkTheme = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
    Preferences.getDataStore(context).data.collect { prefs ->
        darkTheme.value = prefs[Preferences.PrefsKeys.DARK_THEME] ?: false
    }
}

TodoBudgetTheme(darkTheme = darkTheme.value) {
    // Content
}

Что я бы сделал иначе

  1. ViewModel — хотя бы для экранов с тяжёлой логикой (BudgetActivity)

  2. Нормальные миграции Room — destructive migration в проде — плохо

  3. Модульность — вынести feature-модули (:budget, :todo, :notes)

  4. Тесты — да, их нет. Для соло-проекта я полагаюсь на ручное тестирование

Но приложение работает, пользователи довольны, а я доволен скоростью разработки.


Цифры

Метрика

Значение

Размер APK (release, R8)

8.5 МБ

Размер AAB

14 МБ

Количество Activity

7

Количество Room-сущностей

6

Время cold start

~400ms (Pixel 6)

Минимальный SDK

26 (Android 8.0)


Ссылки


Если есть вопросы по архитектуре или хотите подискутировать про multi-Activity vs single-Activity — welcome в комментарии.