Предисловие
Я — соло-разработчик 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 при изменении
mutableStateListOfRoom гарантирует потокобезопасность через
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. Вот почему:
Независимость экранов — каждый экран полностью самодостаточен
Системные переходы — Android'овские анимации Activity transition бесплатно
Deep linking — каждая Activity имеет свой Intent
Отладка — 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 }
Что я бы сделал иначе
ViewModel — хотя бы для экранов с тяжёлой логикой (BudgetActivity)
Нормальные миграции Room — destructive migration в проде — плохо
Модульность — вынести feature-модули (:budget, :todo, :notes)
Тесты — да, их нет. Для соло-проекта я полагаюсь на ручное тестирование
Но приложение работает, пользователи довольны, а я доволен скоростью разработки.
Цифры
Метрика | Значение |
|---|---|
Размер APK (release, R8) | 8.5 МБ |
Размер AAB | 14 МБ |
Количество Activity | 7 |
Количество Room-сущностей | 6 |
Время cold start | ~400ms (Pixel 6) |
Минимальный SDK | 26 (Android 8.0) |
Ссылки
GitHub: github.com/emil-a-dev/todofin
Если есть вопросы по архитектуре или хотите подискутировать про multi-Activity vs single-Activity — welcome в комментарии.
