Files
lastochka-messenger/lastochka-android-compose/ARCHITECTURE_IMPROVEMENTS.md
Anton Budylin ea171ed95a first commit
2026-04-14 10:12:51 +03:00

11 KiB
Raw Permalink Blame History

Улучшения архитектуры мобильного приложения

Дата: 4 апреля 2026 г.

Резюме изменений

Выполнены все 5 рекомендаций по улучшению архитектуры Android-приложения «Ласточка».


1. Splash Screen

Проблема: При запуске приложения на секунду появлялся основной интерфейс, затем экран авторизации.

Решение:

Добавлена зависимость

// app/build.gradle.kts
implementation("androidx.core:core-splashscreen:1.0.1")

Создана тема для Splash Screen

  • res/values/themes.xmlTheme.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

Результат: Чёткая иерархия — SessionRepositoryChatRepository → 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 Unauthenticated
  • login success updates auth state
  • login failure keeps Unauthenticated
  • register success updates auth state
  • register failure keeps Unauthenticated
  • logout sets state to Unauthenticated
  • autoLogin success updates auth state
  • autoLogin failure keeps Unauthenticated
  • isAuthenticated returns correct value
  • myUid returns current uid
  • connectionState delegates to tinodeClient
  • registerWithFullProfile success
  • hasSavedToken returns true when authenticated

RetryWithBackoffTest.kt (10 тестов)

  • success on first attempt
  • retries on failure and eventually succeeds
  • fails after max retries exhausted
  • shouldRetry predicate controls retry
  • RetryPolicy Quick/Network/Conservative
  • RetryPolicy invoke operator
  • exponential backoff increases delay
  • maxDelayMs 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
   
✨ Без мигания!

Рекомендации для дальнейшего улучшения

  1. Добавить CrashlyticsTree для production сбоев
  2. Интеграция с FCM для push-уведомлений
  3. EncryptedSharedPreferences для хранения токена
  4. Прогулка по графу навигации — добавить deep links
  5. Compose Previews для всех UI компонентов
  6. Benchmark тесты для проверки производительности

Команды для проверки

# Сборка
./gradlew :app:assembleDebug

# Тесты
./gradlew :app:testDebugUnitTest

# Проверка компиляции
./gradlew :app:compileDebugKotlin

# Lint
./gradlew :app:lintDebug