first commit
This commit is contained in:
348
lastochka-android-compose/ARCHITECTURE_IMPROVEMENTS.md
Normal file
348
lastochka-android-compose/ARCHITECTURE_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# Улучшения архитектуры мобильного приложения
|
||||
|
||||
> Дата: 4 апреля 2026 г.
|
||||
|
||||
## Резюме изменений
|
||||
|
||||
Выполнены все 5 рекомендаций по улучшению архитектуры Android-приложения «Ласточка».
|
||||
|
||||
---
|
||||
|
||||
## 1. ✅ Splash Screen
|
||||
|
||||
**Проблема:** При запуске приложения на секунду появлялся основной интерфейс, затем экран авторизации.
|
||||
|
||||
**Решение:**
|
||||
|
||||
### Добавлена зависимость
|
||||
```kotlin
|
||||
// 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
|
||||
```xml
|
||||
android:theme="@style/Theme.App.Starting"
|
||||
```
|
||||
|
||||
### Интеграция в MainActivity
|
||||
```kotlin
|
||||
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
|
||||
```kotlin
|
||||
// Было: публичный метод
|
||||
fun isAuthenticated(): Boolean = httpClient.isAuthenticated
|
||||
|
||||
// Стало: приватный метод (для внутренней логики)
|
||||
private fun isAuthenticated(): Boolean = httpClient.isAuthenticated
|
||||
```
|
||||
|
||||
### SessionRepository — единственный источник правды
|
||||
```kotlin
|
||||
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
|
||||
```kotlin
|
||||
fun isAuthenticated(): Boolean = sessionRepository.isAuthenticated
|
||||
val authState: Flow<AuthState> = sessionRepository.authState
|
||||
```
|
||||
|
||||
**Результат:** Чёткая иерархия — `SessionRepository` → `ChatRepository` → UI.
|
||||
|
||||
---
|
||||
|
||||
## 3. ✅ Retry logic с exponential backoff
|
||||
|
||||
**Проблема:** Сетевые операции не повторялись при временных сбоях.
|
||||
|
||||
**Решение:**
|
||||
|
||||
### Создан утилитарный класс
|
||||
`util/RetryWithBackoff.kt`:
|
||||
|
||||
```kotlin
|
||||
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>
|
||||
```
|
||||
|
||||
### Предустановленные политики
|
||||
```kotlin
|
||||
RetryPolicy.Quick // 2 retry, 500ms-2s (для auth)
|
||||
RetryPolicy.Network // 3 retry, 1s-10s (для сети)
|
||||
RetryPolicy.Conservative // 5 retry, 2s-30s (для критичных операций)
|
||||
```
|
||||
|
||||
### Интеграция в SessionRepository
|
||||
```kotlin
|
||||
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 тестов**
|
||||
|
||||
**Команда запуска:**
|
||||
```bash
|
||||
./gradlew :app:testDebugUnitTest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. ✅ Логирование с Timber
|
||||
|
||||
**Проблема:** Использование `println` и `android.util.Log` без единой стратегии.
|
||||
|
||||
**Решение:**
|
||||
|
||||
### Добавлена зависимость
|
||||
```kotlin
|
||||
implementation("com.jakewharton.timber:timber:5.0.1")
|
||||
```
|
||||
|
||||
### Инициализация в LastochkaApp
|
||||
```kotlin
|
||||
override fun onCreate() {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
} else {
|
||||
// Production: Timber.plant(CrashlyticsTree())
|
||||
}
|
||||
Timber.d("LastochkaApp onCreate")
|
||||
}
|
||||
```
|
||||
|
||||
### Замена в TinodeHttpClient
|
||||
```kotlin
|
||||
// Было:
|
||||
e.printStackTrace()
|
||||
println("WebSocket onFailure: ...")
|
||||
|
||||
// Стало:
|
||||
Timber.e(e, "Failed to parse WebSocket message")
|
||||
Timber.e(t, "WebSocket onFailure: code=$statusCode, message=$errorMsg")
|
||||
```
|
||||
|
||||
### Замена в TinodeClient
|
||||
```kotlin
|
||||
Timber.d("Connecting to Tinode server...")
|
||||
Timber.d("WebSocket connected")
|
||||
Timber.e("WebSocket connection error")
|
||||
```
|
||||
|
||||
### Замена в SessionRepository
|
||||
```kotlin
|
||||
// Было:
|
||||
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 тесты** для проверки производительности
|
||||
|
||||
---
|
||||
|
||||
## Команды для проверки
|
||||
|
||||
```bash
|
||||
# Сборка
|
||||
./gradlew :app:assembleDebug
|
||||
|
||||
# Тесты
|
||||
./gradlew :app:testDebugUnitTest
|
||||
|
||||
# Проверка компиляции
|
||||
./gradlew :app:compileDebugKotlin
|
||||
|
||||
# Lint
|
||||
./gradlew :app:lintDebug
|
||||
```
|
||||
Reference in New Issue
Block a user