11 KiB
Улучшения архитектуры мобильного приложения
Дата: 4 апреля 2026 г.
Резюме изменений
Выполнены все 5 рекомендаций по улучшению архитектуры Android-приложения «Ласточка».
1. ✅ Splash Screen
Проблема: При запуске приложения на секунду появлялся основной интерфейс, затем экран авторизации.
Решение:
Добавлена зависимость
// app/build.gradle.kts
implementation("androidx.core:core-splashscreen:1.0.1")
Создана тема для Splash Screen
res/values/themes.xml—Theme.App.Starting(Android 12+)res/values-night/themes.xml— ночная тема- Использует
Theme.SplashScreenсwindowSplashScreenAnimatedIcon
Обновлён AndroidManifest
android:theme="@style/Theme.App.Starting"
Интеграция в MainActivity
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
var keepSplashOnScreen = true
splashScreen.setKeepOnScreenCondition { keepSplashOnScreen }
LaunchedEffect(authState) {
keepSplashOnScreen = false
}
}
Результат: Плавный переход от splash к основному интерфейсу без мигания.
2. ✅ Единый источник правды для auth
Проблема: TinodeClient.isAuthenticated и SessionRepository.authState создавали дублирование и путаницу.
Решение:
Изменения в TinodeClient
// Было: публичный метод
fun isAuthenticated(): Boolean = httpClient.isAuthenticated
// Стало: приватный метод (для внутренней логики)
private fun isAuthenticated(): Boolean = httpClient.isAuthenticated
SessionRepository — единственный источник правды
sealed class AuthState {
data object Unauthenticated : AuthState()
data class Authenticated(val uid: String) : AuthState()
data object SessionExpired : AuthState()
}
val authState: StateFlow<AuthState>
val isAuthenticated: Boolean // proxy-свойство
ChatRepository делегирует SessionRepository
fun isAuthenticated(): Boolean = sessionRepository.isAuthenticated
val authState: Flow<AuthState> = sessionRepository.authState
Результат: Чёткая иерархия — SessionRepository → ChatRepository → UI.
3. ✅ Retry logic с exponential backoff
Проблема: Сетевые операции не повторялись при временных сбоях.
Решение:
Создан утилитарный класс
util/RetryWithBackoff.kt:
suspend fun <T> retryWithBackoff(
maxRetries: Int = 3,
initialDelayMs: Long = 1_000L,
maxDelayMs: Long = 30_000L,
backoffFactor: Double = 2.0,
shouldRetry: (Throwable) -> Boolean = { true },
block: suspend () -> T
): Result<T>
Предустановленные политики
RetryPolicy.Quick // 2 retry, 500ms-2s (для auth)
RetryPolicy.Network // 3 retry, 1s-10s (для сети)
RetryPolicy.Conservative // 5 retry, 2s-30s (для критичных операций)
Интеграция в SessionRepository
suspend fun login(username: String, password: String): Result<Unit> {
return RetryPolicy.Quick {
tinodeClient.login(username, password)
}
}
suspend fun autoLogin(): Result<Unit> {
return RetryPolicy.Network(
shouldRetry = { e ->
// Не retry если токен недействителен
e.message?.contains("401") != true
}
) {
tinodeClient.autoLogin()
}
}
Результат: Устойчивость к временным сбоям сети, автоматические повторные попытки.
4. ✅ Тесты для SessionRepository
Созданные файлы:
SessionRepositoryTest.kt (13 тестов)
initial state is Unauthenticatedlogin success updates auth statelogin failure keeps Unauthenticatedregister success updates auth stateregister failure keeps Unauthenticatedlogout sets state to UnauthenticatedautoLogin success updates auth stateautoLogin failure keeps UnauthenticatedisAuthenticated returns correct valuemyUid returns current uidconnectionState delegates to tinodeClientregisterWithFullProfile successhasSavedToken returns true when authenticated
RetryWithBackoffTest.kt (10 тестов)
success on first attemptretries on failure and eventually succeedsfails after max retries exhaustedshouldRetry predicate controls retryRetryPolicy Quick/Network/ConservativeRetryPolicy invoke operatorexponential backoff increases delaymaxDelayMs caps the delay
Итого: 23 новых теста + существующие 34 = 57 тестов
Команда запуска:
./gradlew :app:testDebugUnitTest
5. ✅ Логирование с Timber
Проблема: Использование println и android.util.Log без единой стратегии.
Решение:
Добавлена зависимость
implementation("com.jakewharton.timber:timber:5.0.1")
Инициализация в LastochkaApp
override fun onCreate() {
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
// Production: Timber.plant(CrashlyticsTree())
}
Timber.d("LastochkaApp onCreate")
}
Замена в TinodeHttpClient
// Было:
e.printStackTrace()
println("WebSocket onFailure: ...")
// Стало:
Timber.e(e, "Failed to parse WebSocket message")
Timber.e(t, "WebSocket onFailure: code=$statusCode, message=$errorMsg")
Замена в TinodeClient
Timber.d("Connecting to Tinode server...")
Timber.d("WebSocket connected")
Timber.e("WebSocket connection error")
Замена в SessionRepository
// Было:
android.util.Log.d("SessionRepository", "login result: $result")
// Стало:
Timber.d("Attempting login for user: $username")
Timber.d("Login result: $result, myUid=${tinodeClient.myUid}")
Timber.w("Login failed: ${result.exceptionOrNull()?.message}")
Результат: Структурированное логирование с тегами, автоматическое отключение в production.
Изменённые файлы
| Файл | Изменения |
|---|---|
LastochkaApp.kt |
+Timber, убран connect() |
MainActivity.kt |
+SplashScreen API |
SessionRepository.kt |
+Timber, +Retry, +autoLogin в init |
TinodeClient.kt |
+Timber, -публичный isAuthenticated, +connect в autoLogin |
TinodeHttpClient.kt |
+Timber |
ChatRepository.kt |
Без изменений (уже делегировал) |
build.gradle.kts |
+core-splashscreen, +timber |
AndroidManifest.xml |
Theme.App.Starting |
themes.xml |
Theme.App.Starting |
themes.xml (night) |
Ночная тема для splash |
Новые файлы
| Файл | Описание |
|---|---|
util/RetryWithBackoff.kt |
Утилита retry с exponential backoff |
data/SessionRepositoryTest.kt |
13 тестов для SessionRepository |
util/RetryWithBackoffTest.kt |
10 тестов для retry утилиты |
Итоговая архитектура
┌─────────────────────────────────────────────────┐
│ UI Layer │
│ MainActivity (SplashScreen + NavHost) │
│ LoginScreen → AuthViewModel │
│ ChatListScreen → ChatListViewModel │
│ ChatScreen → ChatViewModel │
├─────────────────────────────────────────────────┤
│ Domain / ViewModel Layer │
│ AuthViewModel ──┐ │
│ ChatListViewModel┼──> ChatRepository │
│ ChatViewModel ──┘ ↓ │
├─────────────────────────────────────────────────┤
│ Data Layer │
│ SessionRepository (единственный auth source) │
│ ├─ RetryPolicy (автоматические retry) │
│ ├─ DataStore (persist UID) │
│ └─ TinodeClient │
│ ├─ TinodeHttpClient (WebSocket) │
│ └─ Timber (логирование) │
│ ChatRepository (делегирует SessionRepository) │
│ AppDatabase (Room кэш) │
├─────────────────────────────────────────────────┤
│ Infrastructure │
│ NetworkMonitor (автореконнект) │
│ FCM Service (push-уведомления) │
│ Timber DebugTree (dev) / CrashlyticsTree (prod) │
└─────────────────────────────────────────────────┘
Поток авторизации (исправленный)
1. LastochkaApp.onCreate()
└─> Plant Timber (debug only)
└─> Инициализация TinodeClient (без подключения)
2. SessionRepository init (@Inject)
└─> loadUid() → есть UID?
└─> autoLogin() с RetryPolicy.Network
3. TinodeClient.autoLogin()
└─> connect() если не подключён
└─> awaitConnection()
└─> httpClient.loginToken(token)
4. SessionRepository._authState
└─> Authenticated(uid) при успехе
└─> Unauthenticated при неудаче
5. MainActivity
└─> SplashScreen ждёт authState
└─> Authenticated → MainAppScreen
└─> Unauthenticated → LoginScreen
✨ Без мигания!
Рекомендации для дальнейшего улучшения
- Добавить CrashlyticsTree для production сбоев
- Интеграция с FCM для push-уведомлений
- EncryptedSharedPreferences для хранения токена
- Прогулка по графу навигации — добавить deep links
- Compose Previews для всех UI компонентов
- Benchmark тесты для проверки производительности
Команды для проверки
# Сборка
./gradlew :app:assembleDebug
# Тесты
./gradlew :app:testDebugUnitTest
# Проверка компиляции
./gradlew :app:compileDebugKotlin
# Lint
./gradlew :app:lintDebug