Как загружать данные в Kotlin с корутинами (Первая часть)
Retrofit с корутинами - это сочетание, которое изменило мой взгляд на работу с HTTP API. Помню времена, когда каждый сетевой запрос превращался в танцы с колбэками, обработкой потоков и ручным парсингом JSON. С появлением поддержки suspend-функций в Retrofit 2.6 всё стало настолько просто, что первое время я не верил - неужели больше не нужны адаптеры, обёртки и сложные цепочки преобразований?
Загрузка из REST API через Retrofit
Основа работы с Retrofit и корутинами Kotlin - suspend методы в интерфейсе REST API. Никаких Call<T> объектов, никаких enqueue() вызовов:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
| interface UserApiService {
@GET("users/{id}")
suspend fun getUser(@Path("id") userId: String): User
@POST("users")
suspend fun createUser(@Body user: CreateUserRequest): User
@PUT("users/{id}")
suspend fun updateUser(@Path("id") userId: String, @Body user: User): User
@DELETE("users/{id}")
suspend fun deleteUser(@Path("id") userId: String): Response<Unit>
} |
|
Suspend методы автоматически выполняются в IO потоке, поэтому дополнительное переключение контекста часто не требуется. Retrofit самостоятельно управляет потоками выполнения, оставляя вам только бизнес-логику.
Для обработки ошибок HTTP я создаю wrapper функцию, которая преобразует исключения в удобный для работы формат:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| class ApiClient(private val userApi: UserApiService) {
suspend fun <T> safeApiCall(apiCall: suspend () -> T): ApiResult<T> {
return try {
val result = apiCall()
ApiResult.Success(result)
} catch (e: HttpException) {
when (e.code()) {
404 -> ApiResult.Error.NotFound
401 -> ApiResult.Error.Unauthorized
in 500..599 -> ApiResult.Error.ServerError(e.message())
else -> ApiResult.Error.Unknown(e.message())
}
} catch (e: IOException) {
ApiResult.Error.NetworkError(e.message ?: "Network unavailable")
} catch (e: Exception) {
ApiResult.Error.Unknown(e.message ?: "Unknown error")
}
}
suspend fun loadUser(userId: String): ApiResult<User> {
return safeApiCall { userApi.getUser(userId) }
}
} |
|
Когда API возвращает списки данных, часто нужна дополнительная обработка - фильтрация, сортировка, трансформация. Корутины позволяют делать это прямо в цепочке вызовов:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
| suspend fun loadActiveUsers(): List<User> = withContext(Dispatchers.IO) {
val allUsers = userApi.getAllUsers()
allUsers
.filter { it.isActive }
.sortedBy { it.lastLoginDate }
.map { user ->
// Дополнительная обработка каждого пользователя
user.copy(displayName = formatDisplayName(user.firstName, user.lastName))
}
} |
|
Параллельные запросы к API особенно впечатляют. Раньше для загрузки данных из нескольких эндпоинтов приходилось создавать сложную логику синхронизации. С async/await это становится элегантным:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
| suspend fun loadUserDashboard(userId: String): DashboardData = coroutineScope {
val userDeferred = async { userApi.getUser(userId) }
val postsDeferred = async { userApi.getUserPosts(userId) }
val friendsDeferred = async { userApi.getUserFriends(userId) }
val statisticsDeferred = async { userApi.getUserStatistics(userId) }
DashboardData(
user = userDeferred.await(),
posts = postsDeferred.await().take(10), // последние 10 постов
friends = friendsDeferred.await(),
statistics = statisticsDeferred.await()
)
} |
|
Все четыре запроса выполняются одновременно, что значительно сокращает общее время загрузки.
Retrofit отлично интегрируется с Flow для создания реактивных потоков данных. Можно легко создать поток, который периодически обновляет данные с сервера:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| class NewsRepository(private val newsApi: NewsApiService) {
fun observeLatestNews(): Flow<List<NewsArticle>> = flow {
while (true) {
try {
val news = newsApi.getLatestNews()
emit(news)
} catch (e: Exception) {
// В случае ошибки не останавливаем поток
emit(emptyList())
}
delay(TimeUnit.MINUTES.toMillis(5)) // обновляем каждые 5 минут
}
}.flowOn(Dispatchers.IO)
} |
|
Для сложных API операций, требующих нескольких последовательных запросов, корутины делают код читаемым и поддерживаемым:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| suspend fun completeUserRegistration(
email: String,
password: String,
profile: UserProfile
): RegistrationResult = coroutineScope {
// Шаг 1: Создаём аккаунт
val account = userApi.createAccount(CreateAccountRequest(email, password))
// Шаг 2: Загружаем аватар параллельно с созданием профиля
val uploadAvatarJob = async {
if (profile.avatarFile != null) {
userApi.uploadAvatar(account.id, profile.avatarFile)
} else null
}
// Шаг 3: Создаём профиль пользователя
val userProfile = userApi.createProfile(
account.id,
profile.copy(avatarUrl = uploadAvatarJob.await()?.url)
)
// Шаг 4: Отправляем welcome email
userApi.sendWelcomeEmail(account.email)
RegistrationResult.Success(account, userProfile)
} |
|
Такой подход превращает сложные multi-step операции в понятный последовательный код, где каждый шаг очевиден и легко тестируется отдельно.
Каким самым простым методом (Android приложение на Kotlin ) можно забрать данные с сервера? Добрый день.
Проект состоит из:
- Контроллера, который отправляет данные на сервер GET... Как при появлении Wifi загружать данные Есть приложение. В нем есть много фрагментов которые подгружают данные из сервера, но юзер зашел в... Kotlin "it" как перевести на java? есть пример кода на котлине:
override fun one(id: Long): Observable<Banner> {
return... Как исправить утечку памяти Android Studio Kotlin Я обращаюсь к базе данных получаю ответ в json и процессе того как обрабатывается возникает ошибка....
GraphQL запросы с корутинами
GraphQL с корутинами открывает совершенно новые возможности для работы с API. После двух лет использования Apollo GraphQL в проектах на Kotlin могу сказать: это сочетание решает многие проблемы традиционного REST подхода, особенно когда речь идёт о сложных запросах данных и real-time обновлениях. Основное преимущество GraphQL - возможность запросить именно те данные, которые нужны для конкретного экрана. Никакого overfetching, никаких лишних полей. Apollo Kotlin автоматически генерирует suspend-функции из GraphQL схем:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| class UserRepository(private val apolloClient: ApolloClient) {
suspend fun getUserProfile(userId: String): ApolloResponse<GetUserProfileQuery.Data> {
return apolloClient.query(
GetUserProfileQuery(userId = userId)
).execute()
}
suspend fun updateUserName(userId: String, newName: String): ApolloResponse<UpdateUserMutation.Data> {
return apolloClient.mutation(
UpdateUserMutation(userId = userId, name = newName)
).execute()
}
} |
|
Сгенерированные методы уже являются suspend-функциями и автоматически выполняются в подходящем контексте. Apollo клиент самостоятельно управляет потоками, оставляя вам фокус на бизнес-логике.
Фрагменты GraphQL особенно мощны в сочетании с корутинами. Можно создавать переиспользуемые части запросов и комбинировать их для разных экранов:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| suspend fun loadDashboardData(userId: String): DashboardData = coroutineScope {
val profileDeferred = async {
apolloClient.query(GetUserProfileQuery(userId)).execute()
}
val postsDeferred = async {
apolloClient.query(GetUserPostsQuery(userId, first = 10)).execute()
}
val friendsDeferred = async {
apolloClient.query(GetUserFriendsQuery(userId)).execute()
}
val profileResponse = profileDeferred.await()
val postsResponse = postsDeferred.await()
val friendsResponse = friendsDeferred.await()
DashboardData(
profile = profileResponse.data?.user?.userProfile,
posts = postsResponse.data?.user?.posts?.nodes ?: emptyList(),
friends = friendsResponse.data?.user?.friends?.nodes ?: emptyList()
)
} |
|
GraphQL подписки (subscriptions) превосходно работают с Flow, создавая реактивные потоки данных:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| class ChatRepository(private val apolloClient: ApolloClient) {
fun observeMessages(chatId: String): Flow<ChatMessage> =
apolloClient
.subscription(MessageSubscription(chatId = chatId))
.toFlow()
.mapNotNull { response ->
response.data?.messageAdded?.let { message ->
ChatMessage(
id = message.id,
text = message.text,
author = message.author.name,
timestamp = message.createdAt
)
}
}
.catch { exception ->
when (exception) {
is ApolloNetworkException -> emit(ChatMessage.connectionError())
else -> throw exception
}
}
} |
|
Кэширование в Apollo работает автоматически, но можно тонко настроить политики получения данных для оптимизации производительности:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| suspend fun loadUserWithCaching(userId: String, forceRefresh: Boolean = false): User? {
val fetchPolicy = if (forceRefresh) {
FetchPolicy.NetworkOnly
} else {
FetchPolicy.CacheFirst
}
val response = apolloClient.query(
GetUserQuery(userId = userId)
).fetchPolicy(fetchPolicy).execute()
return response.data?.user?.let { userData ->
User(
id = userData.id,
name = userData.name,
email = userData.email,
avatarUrl = userData.avatar?.url
)
}
} |
|
Батчинг запросов - ещё одна мощная возможность GraphQL с корутинами. Можно объединять несколько операций в один сетевой запрос:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| suspend fun batchUpdateUsers(updates: List<UserUpdate>): List<User> = coroutineScope {
val mutations = updates.map { update ->
async {
apolloClient.mutation(
UpdateUserMutation(
userId = update.userId,
name = update.name,
email = update.email
)
).execute()
}
}
mutations.awaitAll().mapNotNull { response ->
response.data?.updateUser?.user?.let { userData ->
User(userData.id, userData.name, userData.email)
}
}
} |
|
Обработка ошибок в GraphQL требует особого внимания, поскольку ответ может содержать как данные, так и ошибки одновременно:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| suspend fun safeGraphQLCall<T>(
operation: suspend () -> ApolloResponse<T>
): GraphQLResult<T> {
return try {
val response = operation()
when {
response.hasErrors() -> {
val errorMessages = response.errors?.map { it.message } ?: emptyList()
GraphQLResult.Error(errorMessages.joinToString(", "))
}
response.data != null -> GraphQLResult.Success(response.data!!)
else -> GraphQLResult.Error("Empty response")
}
} catch (e: ApolloException) {
GraphQLResult.Error(e.message ?: "GraphQL operation failed")
}
} |
|
GraphQL с корутинами кардинально упрощает работу с сложными API, где требуется гибкость в запросах данных и real-time обновления.
Кэширование и Room базы данных
Room с корутинами - это союз, который превратил работу с локальными данными из мучения в удовольствие. Помню, как раньше приходилось городить конструкции с AsyncTask или ThreadPoolExecutor для каждого запроса к SQLite. Теперь всё выглядит так элегантно, что иногда забываешь о том, что под капотом происходят сложные операции с базой данных.
Основа любого Room DAO с корутинами - suspend методы. Они автоматически выполняются в background потоке, не блокируя UI:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| @Dao
interface UserDao {
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUser(userId: String): User?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: User): Long
@Update
suspend fun updateUser(user: User)
@Delete
suspend fun deleteUser(user: User)
@Query("SELECT * FROM users WHERE last_sync < :timestamp")
suspend fun getStaleUsers(timestamp: Long): List<User>
} |
|
Flow в Room DAO создаёт реактивные потоки данных, которые автоматически обновляются при изменении таблицы. Это идеально для живых списков в UI:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
| @Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY published_date DESC")
fun observeAllArticles(): Flow<List<Article>>
@Query("SELECT * FROM articles WHERE is_favorite = 1")
fun observeFavoriteArticles(): Flow<List<Article>>
@Query("SELECT * FROM articles WHERE category = :category")
fun observeArticlesByCategory(category: String): Flow<List<Article>>
} |
|
Классический паттерн cache-aside с Room выглядит невероятно чисто с корутинами:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| class ArticleRepository(
private val articleDao: ArticleDao,
private val articleApi: ArticleApiService
) {
suspend fun getArticle(id: String): Article? {
// Сначала ищем в кэше
val cached = articleDao.getArticle(id)
if (cached != null && !cached.isStale()) {
return cached
}
// Загружаем с сервера
return try {
val fresh = articleApi.getArticle(id)
articleDao.insertArticle(fresh.copy(cachedAt = System.currentTimeMillis()))
fresh
} catch (e: Exception) {
// Fallback на устаревший кэш
cached
}
}
fun observeArticles(): Flow<List<Article>> = articleDao.observeAllArticles()
.map { articles ->
if (articles.isEmpty() || articles.any { it.isStale() }) {
refreshArticlesInBackground()
}
articles
}
private fun refreshArticlesInBackground() {
CoroutineScope(Dispatchers.IO).launch {
try {
val fresh = articleApi.getAllArticles()
articleDao.insertAll(fresh)
} catch (e: Exception) {
// Логируем, но не ломаем UI
}
}
}
} |
|
Room Transactions с корутинами обеспечивают атомарность сложных операций. Если любая операция внутри транзакции упадёт, все изменения откатятся:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| @Dao
abstract class UserDao {
@Insert
abstract suspend fun insertUser(user: User): Long
@Insert
abstract suspend fun insertUserPreferences(preferences: UserPreferences)
@Transaction
open suspend fun insertUserWithPreferences(user: User, preferences: UserPreferences) {
val userId = insertUser(user)
insertUserPreferences(preferences.copy(userId = userId))
}
} |
|
Для сложных запросов с джойнами Room автоматически генерирует эффективный SQL:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| data class UserWithPosts(
@Embedded val user: User,
@Relation(
parentColumn = "id",
entityColumn = "user_id"
)
val posts: List<Post>
)
@Dao
interface UserDao {
@Transaction
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUserWithPosts(userId: String): UserWithPosts?
@Transaction
@Query("SELECT * FROM users WHERE is_active = 1")
fun observeActiveUsersWithPosts(): Flow<List<UserWithPosts>>
} |
|
Миграции базы данных тоже стали проще благодаря корутинам. Можно выполнять тяжёлые операции по переносу данных без блокировки приложения:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
// Создаём новую таблицу
database.execSQL("CREATE TABLE user_settings (id INTEGER PRIMARY KEY, user_id TEXT, theme TEXT)")
// Мигрируем данные (в реальности это может быть очень долго)
database.execSQL("INSERT INTO user_settings SELECT id, id, 'dark' FROM users")
}
}
@Database(
entities = [User::class, UserSettings::class],
version = 2
)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
} |
|
Комбинирование нескольких источников данных через Room Flow создаёт мощные реактивные конструкции:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| class DashboardRepository(
private val userDao: UserDao,
private val notificationDao: NotificationDao,
private val settingsDao: SettingsDao
) {
fun observeDashboardData(userId: String): Flow<DashboardData> =
combine(
userDao.observeUser(userId),
notificationDao.observeUnreadCount(userId),
settingsDao.observeUserSettings(userId)
) { user, unreadCount, settings ->
DashboardData(
user = user,
unreadNotifications = unreadCount,
isDarkTheme = settings.theme == "dark",
showNotifications = settings.notificationsEnabled
)
}
} |
|
Room с корутинами превращает локальное хранение данных из источника проблем в надёжный инструмент, который работает предсказуемо и эффективно.
Комбинирование нескольких источников
Реальные приложения редко работают с одним источником данных. Обычно нужно объединить информацию из API, локальной базы, кэша, настроек пользователя и других источников. Корутины предоставляют элегантные способы комбинирования этих потоков без превращения кода в спагетти. Самый простой случай - параллельная загрузка из нескольких API с последующим объединением результатов:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| class ProfileRepository(
private val userApi: UserApiService,
private val avatarApi: AvatarApiService,
private val settingsApi: SettingsApiService
) {
suspend fun loadCompleteProfile(userId: String): CompleteProfile = coroutineScope {
val userInfoDeferred = async { userApi.getBasicInfo(userId) }
val avatarDeferred = async { avatarApi.getUserAvatar(userId) }
val settingsDeferred = async { settingsApi.getUserSettings(userId) }
val userInfo = userInfoDeferred.await()
val avatar = avatarDeferred.await()
val settings = settingsDeferred.await()
CompleteProfile(
basicInfo = userInfo,
avatarUrl = avatar.highResUrl,
preferences = settings,
isComplete = userInfo.isVerified && avatar.isUploaded
)
}
} |
|
Но что делать, когда источники данных имеют разную скорость обновления? Flow операторы combine и combineLatest решают эту задачу изящно:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| class HomeRepository(
private val newsDao: NewsDao,
private val weatherApi: WeatherApi,
private val notificationManager: NotificationManager
) {
fun observeHomeScreen(): Flow<HomeScreenData> = combine(
newsDao.observeLatestNews().map { it.take(5) },
flow {
while (true) {
emit(weatherApi.getCurrentWeather())
delay(TimeUnit.MINUTES.toMillis(15))
}
},
notificationManager.observeUnreadCount()
) { news, weather, unreadCount ->
HomeScreenData(
topNews = news,
currentWeather = weather,
hasUnreadNotifications = unreadCount > 0,
lastUpdated = System.currentTimeMillis()
)
}
} |
|
Сложнее становится, когда нужно реагировать на изменения в одном источнике, чтобы обновить другие. Например, при изменении настроек языка нужно перезагрузить локализованный контент:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| class ContentRepository(
private val settingsDao: SettingsDao,
private val contentApi: ContentApiService,
private val contentDao: ContentDao
) {
fun observeLocalizedContent(): Flow<LocalizedContent> =
settingsDao.observeLanguageSettings()
.distinctUntilChanged()
.switchMap { settings ->
combine(
contentDao.observeCachedContent(settings.languageCode),
refreshContentForLanguage(settings.languageCode).asFlow()
) { cached, fresh ->
if (fresh != null && fresh.version > cached.version) {
contentDao.insertContent(fresh)
fresh
} else {
cached
}
}
}
private suspend fun refreshContentForLanguage(lang: String): LocalizedContent? {
return try {
contentApi.getContentForLanguage(lang)
} catch (e: Exception) {
null
}
}
} |
|
Иногда требуется более сложная логика - например, fallback цепочка, где каждый следующий источник используется только если предыдущий недоступен:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| class RobustDataRepository(
private val primaryApi: PrimaryApiService,
private val backupApi: BackupApiService,
private val localCache: LocalCacheDao
) {
suspend fun loadCriticalData(id: String): CriticalData? {
return try {
// Первый приоритет - основное API
primaryApi.getData(id).also { data ->
localCache.saveData(data) // сохраняем в кэш
}
} catch (primaryError: Exception) {
try {
// Второй приоритет - backup API
backupApi.getData(id).also { data ->
localCache.saveData(data)
}
} catch (backupError: Exception) {
// Последняя надежда - локальный кэш
localCache.getData(id).also { cachedData ->
if (cachedData == null) {
throw DataUnavailableException(
"All sources failed",
listOf(primaryError, backupError)
)
}
}
}
}
}
} |
|
Для агрегации данных из множества источников, где каждый может работать независимо, я использую паттерн "собирателя":
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| class DashboardAggregator(
private val repositories: List<DashboardDataSource>
) {
fun observeAggregatedDashboard(): Flow<DashboardState> =
repositories
.map { source -> source.observeData() }
.let { flows ->
combine(flows) { dataArray ->
DashboardState(
sections = dataArray.mapNotNull { it },
loadingCount = dataArray.count { it == null },
lastUpdate = System.currentTimeMillis()
)
}
}
} |
|
Комбинирование источников данных с корутинами превращает сложную логику синхронизации в читаемый, декларативный код, где легко понять зависимости между компонентами системы.
WebSocket соединения с корутинами
WebSocket с корутинами - это область, где я наделал много ошибок в первые месяцы работы. Казалось бы, что сложного? Открыл соединение, слушай сообщения, отправляй ответы. Но реальность оказалась куда хитрее: разрывы соединения, переподключения, обработка больших объёмов данных и управление жизненным циклом соединения в контексте Android lifecycle. Основа работы с WebSocket в корутинах - преобразование callback-based API в Flow. OkHttp WebSocket работает через listener паттерн, а нам нужен реактивный поток данных:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| class WebSocketManager(private val client: OkHttpClient) {
private val _messages = MutableSharedFlow<String>()
val messages: SharedFlow<String> = _messages.asSharedFlow()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private var webSocket: WebSocket? = null
fun connect(url: String): Flow<WebSocketEvent> = channelFlow {
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
_connectionState.value = ConnectionState.Connected
trySend(WebSocketEvent.Connected)
}
override fun onMessage(webSocket: WebSocket, text: String) {
_messages.tryEmit(text)
trySend(WebSocketEvent.MessageReceived(text))
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
_connectionState.value = ConnectionState.Disconnecting
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
_connectionState.value = ConnectionState.Error(t.message ?: "Unknown error")
trySend(WebSocketEvent.Error(t))
}
}
val request = Request.Builder().url(url).build()
webSocket = client.newWebSocket(request, listener)
awaitClose { webSocket?.close(1000, "Connection closed") }
}
} |
|
Автоматическое переподключение - критически важная функция для production приложений. Пользователи переключаются между WiFi и мобильным интернетом, заходят в лифты, теряют сигнал:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| class ReconnectingWebSocket(
private val webSocketManager: WebSocketManager
) {
private val reconnectionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun connectWithRetry(url: String): Flow<String> = flow {
var retryCount = 0
val maxRetries = 5
while (true) {
try {
webSocketManager.connect(url)
.collect { event ->
when (event) {
is WebSocketEvent.Connected -> {
retryCount = 0 // сбрасываем счётчик при успешном подключении
}
is WebSocketEvent.MessageReceived -> {
emit(event.message)
}
is WebSocketEvent.Error -> {
if (retryCount < maxRetries) {
val delay = (2.0.pow(retryCount) * 1000).toLong()
delay(delay)
retryCount++
} else {
throw ConnectionException("Max retries exceeded")
}
}
}
}
} catch (e: Exception) {
if (retryCount < maxRetries) {
delay(5000) // базовая задержка между попытками
retryCount++
} else {
throw e
}
}
}
}.distinctUntilChanged()
} |
|
Отправка сообщений через WebSocket требует буферизации на случай временной недоступности соединения:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| class BufferedWebSocketSender {
private val messageQueue = Channel<String>(Channel.UNLIMITED)
private val connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
init {
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
connectionState
.filter { it is ConnectionState.Connected }
.collect {
// Отправляем все накопленные сообщения при подключении
while (!messageQueue.isEmpty) {
val message = messageQueue.tryReceive().getOrNull()
message?.let { webSocket?.send(it) }
}
}
}
}
suspend fun sendMessage(message: String) {
if (connectionState.value is ConnectionState.Connected) {
webSocket?.send(message)
} else {
messageQueue.trySend(message) // буферизуем для отправки позже
}
}
} |
|
Обработка крупных сообщений или потоковых данных требует особого подхода. WebSocket может получать данные частями, которые нужно собирать:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| class StreamingWebSocketProcessor {
private val messageBuilder = StringBuilder()
private val completeMessages = MutableSharedFlow<ProcessedMessage>()
suspend fun processIncomingData(data: String) {
messageBuilder.append(data)
// Предполагаем, что сообщения разделены специальным маркером
val delimiter = "\n##END##\n"
var delimiterIndex = messageBuilder.indexOf(delimiter)
while (delimiterIndex != -1) {
val completeMessage = messageBuilder.substring(0, delimiterIndex)
messageBuilder.delete(0, delimiterIndex + delimiter.length)
val processed = processMessage(completeMessage)
completeMessages.emit(processed)
delimiterIndex = messageBuilder.indexOf(delimiter)
}
}
private suspend fun processMessage(raw: String): ProcessedMessage = withContext(Dispatchers.Default) {
// Тяжёлая обработка сообщения в background потоке
val parsed = parseJsonMessage(raw)
val validated = validateMessage(parsed)
ProcessedMessage(validated, System.currentTimeMillis())
}
} |
|
WebSocket соединения с корутинами превращают сложную асинхронную коммуникацию в управляемые потоки данных, но требуют тщательного внимания к деталям управления соединением и обработки ошибок.
Миграция легаси-кода с Thread и AsyncTask
Миграция legacy кода на корутины - это процесс, который я прошёл в десятках проектов. В каждом старом приложении обязательно найдутся остатки AsyncTask, Thread или даже Handler с Runnable. Избавляться от этого наследия нужно аккуратно, понимая, что каждый кусок старого кода когда-то решал конкретную проблему.
AsyncTask был основным инструментом для фоновых операций в Android до появления корутин. Типичный код выглядел ужасающе:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // Старый подход с AsyncTask
private inner class LoadUserDataTask : AsyncTask<String, Void, User?>() {
override fun doInBackground(vararg userIds: String): User? {
return try {
userApi.getUser(userIds[0])
} catch (e: Exception) {
null
}
}
override fun onPostExecute(result: User?) {
if (result != null) {
displayUser(result)
} else {
showError("Failed to load user")
}
}
} |
|
Миграция такого кода на корутины превращает 20+ строк в несколько элегантных:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
| // Новый подход с корутинами
fun loadUser(userId: String) {
viewModelScope.launch {
try {
val user = userRepository.getUser(userId)
_userState.value = UserState.Success(user)
} catch (e: Exception) {
_userState.value = UserState.Error(e.message ?: "Unknown error")
}
}
} |
|
Thread с Handler требует более деликатного подхода при миграции. Часто такой код содержит сложную логику управления потоками:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // Legacy Thread код
private val backgroundThread = HandlerThread("DataProcessor").apply { start() }
private val backgroundHandler = Handler(backgroundThread.looper)
private val mainHandler = Handler(Looper.getMainLooper())
fun processData(data: List<String>) {
backgroundHandler.post {
val processed = data.map { processItem(it) }
mainHandler.post {
updateUI(processed)
}
}
} |
|
Корутины упрощают переключение контекстов до одной функции:
| Kotlin | 1
2
3
4
5
6
7
8
9
| // Миграция на корутины
suspend fun processData(data: List<String>) {
val processed = withContext(Dispatchers.Default) {
data.map { processItem(it) }
}
withContext(Dispatchers.Main) {
updateUI(processed)
}
} |
|
Самая коварная проблема при миграции - управление жизненным циклом. AsyncTask и Thread легко создают утечки памяти, если не отменяются при уничтожении Activity. Корутины решают это элегантно через scope:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| class MigratedRepository {
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Заменяет ExecutorService
fun startBackgroundWork() {
repositoryScope.launch {
while (isActive) {
processBackgroundTasks()
delay(5000)
}
}
}
fun cleanup() {
repositoryScope.cancel() // отменяет все активные операции
}
} |
|
При миграции сложных операций с несколькими потоками я рекомендую поэтапный подход. Сначала оборачиваем existing код в suspend-функции, затем постепенно заменяем внутреннюю логику:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // Этап 1: обёртка legacy кода
suspend fun legacyDataLoad(): List<Data> = withContext(Dispatchers.IO) {
// Временно используем старый код
val completableFuture = CompletableFuture<List<Data>>()
legacyDataLoader.loadData { result ->
completableFuture.complete(result)
}
completableFuture.await()
}
// Этап 2: полная замена на корутины
suspend fun modernDataLoad(): List<Data> = withContext(Dispatchers.IO) {
dataRepository.loadData()
} |
|
Такой подход позволяет мигрировать код постепенно, не ломая существующий функционал и тщательно тестируя каждый этап замены.
Пагинация и бесконечная прокрутка через Flow
Пагинация - это та область, где корутины показывают себя во всей красе. До их появления реализация бесконечной прокрутки превращалась в кошмар из колбэков, состояний загрузки и ручного управления потоками. Сейчас, после трёх лет работы с Flow-based пагинацией, могу сказать: это один из самых элегантных способов работы с большими наборами данных.
Основа любой пагинации - состояние загрузки и данные. Flow позволяет объединить их в единый реактивный поток:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| sealed class PaginationState<T> {
data class Loading<T>(val currentData: List<T> = emptyList()) : PaginationState<T>()
data class Success<T>(val data: List<T>, val hasMore: Boolean) : PaginationState<T>()
data class Error<T>(val currentData: List<T>, val error: Throwable) : PaginationState<T>()
}
class PaginatedRepository<T>(
private val pageSize: Int = 20,
private val dataLoader: suspend (page: Int, size: Int) -> List<T>
) {
private val _paginationState = MutableStateFlow<PaginationState<T>>(PaginationState.Loading())
val paginationState: StateFlow<PaginationState<T>> = _paginationState.asStateFlow()
private var currentPage = 0
private var isLoading = false
suspend fun loadNextPage() {
if (isLoading) return
val currentState = _paginationState.value
val existingData = when (currentState) {
is PaginationState.Success -> currentState.data
is PaginationState.Error -> currentState.currentData
is PaginationState.Loading -> currentState.currentData
}
isLoading = true
_paginationState.value = PaginationState.Loading(existingData)
try {
val newData = dataLoader(currentPage, pageSize)
val allData = existingData + newData
_paginationState.value = PaginationState.Success(
data = allData,
hasMore = newData.size >= pageSize
)
if (newData.isNotEmpty()) {
currentPage++
}
} catch (e: Exception) {
_paginationState.value = PaginationState.Error(existingData, e)
} finally {
isLoading = false
}
}
} |
|
Интеграция с Room базой данных для офлайн-first пагинации требует более сложной логики. Нужно сочетать локальные данные с сетевыми запросами:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| class OfflinePaginatedRepository(
private val api: ArticleApiService,
private val dao: ArticleDao
) {
fun observePaginatedArticles(): Flow<PaginationState<Article>> =
dao.observeAllArticles()
.map<List<Article>, PaginationState<Article>> { localArticles ->
if (localArticles.isEmpty()) {
PaginationState.Loading()
} else {
PaginationState.Success(localArticles, hasMore = true)
}
}
.onStart { loadMoreIfNeeded() }
private suspend fun loadMoreIfNeeded() {
val localCount = dao.getArticleCount()
if (localCount == 0 || shouldRefresh()) {
try {
val page = (localCount / 20) + 1
val newArticles = api.getArticles(page = page, size = 20)
dao.insertArticles(newArticles)
} catch (e: Exception) {
// Логируем ошибку, но не прерываем поток данных
}
}
}
suspend fun loadNextPage() {
val currentCount = dao.getArticleCount()
val nextPage = (currentCount / 20) + 1
try {
val newArticles = api.getArticles(page = nextPage, size = 20)
if (newArticles.isNotEmpty()) {
dao.insertArticles(newArticles)
}
} catch (e: Exception) {
throw PaginationException("Failed to load page $nextPage", e)
}
}
} |
|
Для оптимизации производительности при больших списках использую предварительную загрузку. Когда пользователь приближается к концу списка, запускается загрузка следующей страницы:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| class PreloadingPaginator<T> {
private val preloadThreshold = 5 // загружать когда остается 5 элементов до конца
fun shouldPreload(currentPosition: Int, totalItems: Int): Boolean {
return currentPosition >= totalItems - preloadThreshold
}
fun observePaginatedData(): Flow<List<T>> = combine(
paginationRepository.paginationState,
scrollPositionFlow
) { paginationState, scrollPosition ->
val data = when (paginationState) {
is PaginationState.Success -> paginationState.data
is PaginationState.Error -> paginationState.currentData
is PaginationState.Loading -> paginationState.currentData
}
if (shouldPreload(scrollPosition, data.size) &&
paginationState is PaginationState.Success &&
paginationState.hasMore) {
// Запускаем предварительную загрузку в фоне
GlobalScope.launch { paginationRepository.loadNextPage() }
}
data
}
} |
|
Обработка ошибок в пагинации требует особого подхода - нужно различать ошибки первой загрузки и ошибки догрузки:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| class ErrorAwarePaginator<T> {
suspend fun loadWithRetry(
maxRetries: Int = 3,
retryDelay: Long = 1000
) {
var attempt = 0
while (attempt < maxRetries) {
try {
loadNextPage()
return
} catch (e: Exception) {
attempt++
if (attempt >= maxRetries) {
val currentData = getCurrentData()
_paginationState.value = if (currentData.isEmpty()) {
PaginationState.Error(emptyList(), e)
} else {
// Для догрузки показываем snackbar, но данные сохраняем
showRetrySnackbar("Failed to load more items")
PaginationState.Success(currentData, hasMore = true)
}
return
}
delay(retryDelay * attempt) // экспоненциальная задержка
}
}
}
} |
|
Flow-based пагинация превращает сложную логику управления состоянием и данными в декларативные потоки, где каждый компонент системы отвечает за свою часть работы.
Retry-механизмы и экспоненциальная задержка
Retry логика - это та область, где я научился не доверять сети и внешним сервисам. За пять лет разработки мобильных приложений понял: если что-то может упасть, оно обязательно упадёт, причём в самый неподходящий момент. Пользователь зайдёт в лифт прямо во время загрузки критичных данных, или сервер ляжет на секунду именно тогда, когда нужно сохранить важную информацию.
Простейший retry механизм выглядит обманчиво просто, но дьявол кроется в деталях. Наивный подход с фиксированными интервалами быстро приводит к проблемам:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
| // Плохой подход - фиксированные интервалы
suspend fun naiveRetry(maxAttempts: Int = 3) {
repeat(maxAttempts) { attempt ->
try {
return apiService.getData()
} catch (e: Exception) {
if (attempt == maxAttempts - 1) throw e
delay(1000) // всегда одна секунда
}
}
} |
|
Проблема такого подхода - все клиенты атакуют сервер с одинаковыми интервалами. Если сервис упал под нагрузкой, синхронные retry атаки только усугубят ситуацию.
Экспоненциальная задержка с jitter решает эту проблему элегантно. Каждая следующая попытка происходит через значительно больший интервал, а случайная компонента распределяет нагрузку:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| class RetryPolicy(
private val maxAttempts: Int = 5,
private val baseDelayMs: Long = 1000,
private val maxDelayMs: Long = 30000,
private val jitterFactor: Double = 0.1
) {
suspend fun <T> executeWithRetry(operation: suspend () -> T): T {
var lastException: Exception? = null
repeat(maxAttempts) { attempt ->
try {
return operation()
} catch (e: CancellationException) {
throw e // не ретраим отмены корутин
} catch (e: Exception) {
lastException = e
if (attempt == maxAttempts - 1) {
throw RetryExhaustedException("All $maxAttempts attempts failed", e)
}
if (!shouldRetry(e)) {
throw e // некоторые ошибки не стоит ретраить
}
val delayMs = calculateDelay(attempt)
delay(delayMs)
}
}
throw lastException ?: IllegalStateException("This should never happen")
}
private fun calculateDelay(attempt: Int): Long {
val exponentialDelay = (baseDelayMs * (2.0.pow(attempt))).toLong()
val cappedDelay = minOf(exponentialDelay, maxDelayMs)
val jitter = (cappedDelay * jitterFactor * Random.nextDouble()).toLong()
return cappedDelay + jitter
}
private fun shouldRetry(exception: Exception): Boolean {
return when (exception) {
is UnknownHostException -> true // проблемы с сетью
is SocketTimeoutException -> true
is ConnectException -> true
is HttpException -> when (exception.code()) {
in 500..599 -> true // серверные ошибки
429 -> true // rate limiting
408 -> true // request timeout
else -> false // клиентские ошибки не ретраим
}
else -> false
}
}
} |
|
Интеграция retry логики с существующими suspend-функциями происходит через extension функции или высшие порядки:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| suspend fun <T> suspend (() -> T).withRetry(
maxAttempts: Int = 3,
delayMs: Long = 1000
): T {
var lastException: Exception? = null
repeat(maxAttempts) { attempt ->
try {
return this()
} catch (e: Exception) {
lastException = e
if (attempt < maxAttempts - 1) {
val delay = delayMs * (attempt + 1)
delay(delay)
}
}
}
throw lastException!!
}
// Использование
suspend fun loadUserData(userId: String): User {
return { userApi.getUser(userId) }.withRetry(maxAttempts = 5)
} |
|
Для сложных сценариев с различными типами операций создаю специализированные retry стратегии:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| class NetworkRetryManager {
private val quickRetry = RetryPolicy(maxAttempts = 3, baseDelayMs = 500)
private val slowRetry = RetryPolicy(maxAttempts = 5, baseDelayMs = 2000)
private val criticalRetry = RetryPolicy(maxAttempts = 10, baseDelayMs = 1000, maxDelayMs = 60000)
suspend fun <T> executeQuickOperation(operation: suspend () -> T): T {
return quickRetry.executeWithRetry(operation)
}
suspend fun <T> executeSlowOperation(operation: suspend () -> T): T {
return slowRetry.executeWithRetry(operation)
}
suspend fun <T> executeCriticalOperation(operation: suspend () -> T): T {
return criticalRetry.executeWithRetry(operation)
}
} |
|
Circuit breaker паттерн дополняет retry механизмы, предотвращая бесконечные попытки подключения к недоступному сервису:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
| class CircuitBreakerRetry(
private val failureThreshold: Int = 5,
private val recoveryTimeMs: Long = 30000
) {
private var failureCount = 0
private var lastFailureTime = 0L
private var state = CircuitState.CLOSED
suspend fun <T> execute(operation: suspend () -> T): T {
when (state) {
CircuitState.OPEN -> {
if (System.currentTimeMillis() - lastFailureTime > recoveryTimeMs) {
state = CircuitState.HALF_OPEN
} else {
throw CircuitBreakerException("Circuit breaker is OPEN")
}
}
CircuitState.HALF_OPEN -> {
return try {
val result = operation()
reset()
result
} catch (e: Exception) {
recordFailure()
throw e
}
}
CircuitState.CLOSED -> {
return try {
operation()
} catch (e: Exception) {
recordFailure()
throw e
}
}
}
return operation()
}
private fun recordFailure() {
failureCount++
lastFailureTime = System.currentTimeMillis()
if (failureCount >= failureThreshold) {
state = CircuitState.OPEN
}
}
private fun reset() {
failureCount = 0
state = CircuitState.CLOSED
}
} |
|
Retry механизмы превращают ненадёжные внешние зависимости в устойчивые компоненты системы, но требуют тонкой балансировки между настойчивостью и разумной сдержанностью.
Параллельная загрузка данных с async/await
Параллельная загрузка - это место, где корутины показывают свою настоящую силу. Помню, как раньше пытался организовать одновременные HTTP-запросы через ExecutorService, жонглировал Future объектами и молился, чтобы все потоки завершились без deadlock. С появлением async/await всё изменилось кардинально - параллелизм стал таким же простым, как последовательный код.
Основная идея async/await проста: async запускает корутину и сразу возвращает Deferred объект, await блокирует выполнение до получения результата. Но дьявол, как всегда, в деталях:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| class DashboardService(
private val userApi: UserApiService,
private val newsApi: NewsApiService,
private val weatherApi: WeatherApiService
) {
suspend fun loadDashboard(userId: String): DashboardData = coroutineScope {
val userDeferred = async { userApi.getProfile(userId) }
val newsDeferred = async { newsApi.getLatestNews(userId) }
val weatherDeferred = async { weatherApi.getCurrentWeather(userId) }
// Все три запроса выполняются параллельно
// await блокирует только до завершения конкретной операции
DashboardData(
user = userDeferred.await(),
news = newsDeferred.await(),
weather = weatherDeferred.await()
)
}
} |
|
В этом примере три API вызова стартуют одновременно, а общее время выполнения равно самой медленной операции, а не сумме всех трёх.
Ключевое отличие async от launch - async возвращает результат. Если результат не нужен, используйте launch. Если нужен - async с последующим await:
| Kotlin | 1
2
3
4
5
6
| // Неправильно - launch не возвращает результат
val job = launch { userApi.getProfile(userId) } // результат потерян
// Правильно - async возвращает Deferred<User>
val deferred = async { userApi.getProfile(userId) }
val user = deferred.await() |
|
Обработка ошибок в параллельных операциях требует особого внимания. Если одна из async корутин упадёт, остальные будут отменены (при использовании обычного coroutineScope):
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| suspend fun loadWithErrorHandling(): DashboardData = coroutineScope {
val userResult = async {
try { userApi.getProfile(userId) }
catch (e: Exception) { User.empty() }
}
val newsResult = async {
try { newsApi.getLatestNews() }
catch (e: Exception) { emptyList<News>() }
}
DashboardData(
user = userResult.await(),
news = newsResult.await()
)
} |
|
Для независимых операций, где падение одной не должно влиять на другие, использую supervisorScope:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
| suspend fun loadIndependentData(): PartialDashboard = supervisorScope {
val userJob = async { loadUserData() }
val newsJob = async { loadNewsData() }
val weatherJob = async { loadWeatherData() }
PartialDashboard(
user = try { userJob.await() } catch (e: Exception) { null },
news = try { newsJob.await() } catch (e: Exception) { emptyList() },
weather = try { weatherJob.await() } catch (e: Exception) { null }
)
} |
|
При работе с коллекциями часто нужно применить async операцию к каждому элементу. map + async создаёт элегантные конструкции:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| suspend fun loadMultipleUsers(userIds: List<String>): List<User> = coroutineScope {
userIds.map { userId ->
async { userApi.getUser(userId) }
}.awaitAll()
}
// Или с фильтрацией результатов
suspend fun loadValidUsers(userIds: List<String>): List<User> = coroutineScope {
userIds.map { userId ->
async {
try { userApi.getUser(userId) }
catch (e: Exception) { null }
}
}.awaitAll().filterNotNull()
} |
|
Ограничение параллелизма критично для защиты внешних сервисов от перегрузки. Semaphore + async позволяют контролировать количество одновременных операций:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
| class ConcurrencyLimitedLoader {
private val semaphore = Semaphore(5) // максимум 5 одновременных запросов
suspend fun loadManyUsers(userIds: List<String>): List<User> = coroutineScope {
userIds.map { userId ->
async {
semaphore.withPermit {
userApi.getUser(userId)
}
}
}.awaitAll()
}
} |
|
Timeout для параллельных операций применяется на уровне всей группы:
| Kotlin | 1
2
3
4
5
6
7
8
| suspend fun loadWithTimeout(): DashboardData? = withTimeoutOrNull(5000) {
coroutineScope {
val user = async { userApi.getProfile() }
val news = async { newsApi.getLatestNews() }
DashboardData(user.await(), news.await())
}
} |
|
async/await превращает сложную координацию параллельных операций в читаемый код, где легко понять зависимости между задачами и контролировать их выполнение.
Обработка файловых операций и потоков данных
Файловые операции с корутинами - это область, где я сталкивался с самыми коварными проблемами производительности. Блокирование UI потока чтением больших файлов, утечки памяти при работе с потоками, падения приложений при обработке поврежденных данных. После четырёх лет оптимизации файловых операций в продакшене могу сказать: корутины превращают эту сложную область в управляемый процесс.
Основная ошибка новичков - попытка читать файлы целиком в память. Для небольших конфигурационных файлов это работает, но стоит пользователю выбрать видео на несколько гигабайт, и приложение рухнет с OutOfMemoryError:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // Плохо - загружает весь файл в память
suspend fun readFileWrong(file: File): String = withContext(Dispatchers.IO) {
file.readText() // может съесть всю память
}
// Правильно - потоковое чтение с буферизацией
suspend fun readFileSafely(file: File): Flow<String> = flow {
file.bufferedReader().use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
emit(line!!)
}
}
}.flowOn(Dispatchers.IO) |
|
Flow идеально подходит для обработки больших файлов порциями. Каждая строка или блок данных обрабатывается отдельно, что позволяет контролировать потребление памяти:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| class LogFileProcessor {
suspend fun processLargeLogFile(logFile: File): ProcessingResult =
logFile.bufferedReader().use { reader ->
reader.lineSequence()
.asFlow()
.map { line -> parseLogEntry(line) }
.filter { entry -> entry.isError() }
.chunked(100) // обрабатываем пакетами по 100 записей
.collect { errorBatch ->
database.insertErrors(errorBatch)
yield() // даём другим корутинам возможность выполниться
}
}
private suspend fun parseLogEntry(line: String): LogEntry = withContext(Dispatchers.Default) {
// CPU-интенсивный парсинг в Default диспетчере
LogEntry.parse(line)
}
} |
|
Копирование файлов требует особого внимания к прогрессу операции и возможности отмены. Пользователи ненавидят операции, которые нельзя остановить:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| class FileManager {
suspend fun copyFileWithProgress(
source: File,
destination: File,
onProgress: (bytesTransferred: Long, totalBytes: Long) -> Unit
) = withContext(Dispatchers.IO) {
source.inputStream().use { input ->
destination.outputStream().use { output ->
val totalBytes = source.length()
var bytesTransferred = 0L
val buffer = ByteArray(8192)
while (isActive) {
val bytesRead = input.read(buffer)
if (bytesRead == -1) break
output.write(buffer, 0, bytesRead)
bytesTransferred += bytesRead
withContext(Dispatchers.Main) {
onProgress(bytesTransferred, totalBytes)
}
if (bytesTransferred % (1024 * 1024) == 0L) {
yield() // проверяем отмену каждый мегабайт
}
}
}
}
}
} |
|
Работа с JSON файлами часто требует потоковой обработки. Gson и Moshi поддерживают streaming API, которые отлично сочетаются с корутинами:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| class JsonStreamProcessor {
suspend fun processLargeJsonArray(file: File): Flow<ProcessedItem> = flow {
file.inputStream().use { input ->
val reader = JsonReader(InputStreamReader(input))
reader.beginArray()
while (reader.hasNext()) {
ensureActive() // проверяем отмену
val item = gson.fromJson<RawItem>(reader, RawItem::class.java)
val processed = processItem(item)
emit(processed)
}
reader.endArray()
}
}.flowOn(Dispatchers.IO)
private suspend fun processItem(item: RawItem): ProcessedItem = withContext(Dispatchers.Default) {
// Тяжёлая обработка в background потоке
ProcessedItem(item.transform())
}
} |
|
Для обработки бинарных файлов использую Channel для передачи данных между производителем и потребителем:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| class BinaryFileProcessor {
suspend fun processVideoFile(videoFile: File) = coroutineScope {
val frameChannel = Channel<VideoFrame>(capacity = 10)
// Producer корутина - читает фреймы
val readerJob = launch(Dispatchers.IO) {
videoFile.inputStream().use { input ->
val buffer = ByteArray(frameSize)
while (input.read(buffer) != -1) {
val frame = VideoFrame.fromBytes(buffer)
frameChannel.send(frame)
}
}
frameChannel.close()
}
// Consumer корутина - обрабатывает фреймы
launch(Dispatchers.Default) {
for (frame in frameChannel) {
val processed = applyVideoFilter(frame)
saveProcessedFrame(processed)
}
}
readerJob.join()
}
} |
|
Обработка архивов требует комбинирования файловых операций с потоковой обработкой содержимого:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| suspend fun extractAndProcess(zipFile: File) = withContext(Dispatchers.IO) {
ZipInputStream(zipFile.inputStream()).use { zipStream ->
generateSequence { zipStream.nextEntry }
.filterNot { it.isDirectory }
.asFlow()
.map { entry ->
async(Dispatchers.Default) {
val content = zipStream.readBytes()
ProcessedEntry(entry.name, processContent(content))
}
}
.buffer(5) // ограничиваем параллелизм
.map { it.await() }
.collect { processedEntry ->
saveToDatabase(processedEntry)
}
}
} |
|
Корутины превращают файловые операции из источника головной боли в контролируемые, отменяемые и эффективные процессы, которые не блокируют пользовательский интерфейс.
Интеграция с Firebase и облачными сервисами
Firebase с корутинами - это сочетание, которое кардинально изменило мой подход к разработке мобильных приложений. Когда Google добавил поддержку корутин в Firebase SDK, исчезла необходимость в бесконечных колбэках и сложных цепочках асинхронных операций. Теперь работа с облачными сервисами выглядит так же просто, как обычные функции. Firestore Database предоставляет extension функции для корутин, которые превращают callback-based API в элегантные suspend методы:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| class FirestoreRepository {
private val firestore = FirebaseFirestore.getInstance()
suspend fun saveUser(user: User): String =
firestore.collection("users")
.add(user)
.await()
.id
suspend fun getUser(userId: String): User? =
firestore.collection("users")
.document(userId)
.get()
.await()
.toObject<User>()
suspend fun updateUserField(userId: String, field: String, value: Any) {
firestore.collection("users")
.document(userId)
.update(field, value)
.await()
}
} |
|
Реактивные запросы к Firestore через Flow создают живые соединения с базой данных. Изменения отображаются в UI моментально:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| fun observeUserPosts(userId: String): Flow<List<Post>> = callbackFlow {
val listener = firestore.collection("posts")
.whereEqualTo("authorId", userId)
.orderBy("timestamp", Query.Direction.DESCENDING)
.addSnapshotListener { snapshot, error ->
if (error != null) {
close(error)
return@addSnapshotListener
}
val posts = snapshot?.documents?.mapNotNull {
it.toObject<Post>()
} ?: emptyList()
trySend(posts)
}
awaitClose { listener.remove() }
} |
|
Firebase Authentication интегрируется с корутинами через те же await() extensions:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| class AuthRepository {
private val auth = FirebaseAuth.getInstance()
suspend fun signInWithEmail(email: String, password: String): AuthResult =
auth.signInWithEmailAndPassword(email, password).await()
suspend fun createAccount(email: String, password: String): AuthResult =
auth.createUserWithEmailAndPassword(email, password).await()
fun observeAuthState(): Flow<FirebaseUser?> = callbackFlow {
val listener = auth.addAuthStateListener { auth ->
trySend(auth.currentUser)
}
awaitClose { auth.removeAuthStateListener(listener) }
}
} |
|
Cloud Functions вызовы тоже становятся suspend функциями:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| class CloudFunctionsService {
private val functions = FirebaseFunctions.getInstance()
suspend fun processPayment(paymentData: PaymentData): PaymentResult {
val data = hashMapOf("payment" to paymentData.toMap())
return functions
.getHttpsCallable("processPayment")
.call(data)
.await()
.data as PaymentResult
}
suspend fun sendNotification(userId: String, message: String) {
val data = mapOf(
"userId" to userId,
"message" to message
)
functions
.getHttpsCallable("sendNotification")
.call(data)
.await()
}
} |
|
Firebase Storage операции требуют особого внимания к прогрессу загрузки:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| class StorageRepository {
private val storage = FirebaseStorage.getInstance()
suspend fun uploadFileWithProgress(
file: File,
path: String,
onProgress: (bytesTransferred: Long, totalBytes: Long) -> Unit
): String = suspendCancellableCoroutine { continuation ->
val ref = storage.reference.child(path)
val uploadTask = ref.putFile(file.toUri())
uploadTask
.addOnProgressListener { snapshot ->
onProgress(snapshot.bytesTransferred, snapshot.totalByteCount)
}
.addOnSuccessListener {
continuation.resume(ref.path)
}
.addOnFailureListener { exception ->
continuation.resumeWithException(exception)
}
continuation.invokeOnCancellation {
uploadTask.cancel()
}
}
} |
|
Комбинирование нескольких Firebase сервисов в единую операцию демонстрирует всю мощь корутин:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| suspend fun createUserProfile(
email: String,
password: String,
profileData: ProfileData,
avatarFile: File?
): UserProfile = coroutineScope {
// Параллельно создаём аккаунт и загружаем аватар
val authResult = async { authRepository.createAccount(email, password) }
val avatarUrl = avatarFile?.let {
async { storageRepository.uploadFile(it, "avatars/${UUID.randomUUID()}") }
}
val user = authResult.await().user!!
val profile = profileData.copy(
id = user.uid,
avatarUrl = avatarUrl?.await()
)
// Сохраняем профиль в Firestore
firestoreRepository.saveUser(profile)
profile
} |
|
Корутины превращают работу с Firebase из лабиринта колбэков в линейный, понятный код, где каждая операция видна и контролируема.
Подводные камни и решения
После пяти лет активного использования корутин в продакшене я накопил внушительную коллекцию "граблей", на которые наступал сам и которые регулярно встречаю в code review коллег. Корутины кажутся простыми на поверхности, но под капотом скрывается множество тонкостей, которые могут превратить элегантный асинхронный код в источник трудноотлавливаемых багов. Самая коварная ловушка - это ложное ощущение безопасности. Когда переходишь с Thread и AsyncTask на корутины, кажется, что все проблемы concurrency волшебным образом исчезли. На самом деле они просто стали менее очевидными. Race conditions, deadlock и memory leaks никуда не делись - они просто приобрели новые формы.
Классический пример - неправильное использование GlobalScope. Я видел проекты, где разработчики бездумно запускали корутины через GlobalScope.launch, не понимая последствий. Такие корутины живут весь жизненный цикл приложения и могут продолжать работу даже после закрытия экранов, потребляя ресурсы и вызывая крэши.
| Kotlin | 1
2
3
4
5
6
7
8
9
| // Плохо - создаёт неконтролируемые корутины
class BadViewModel : ViewModel() {
fun loadData() {
GlobalScope.launch { // неправильно!
val data = repository.getData()
updateUI(data) // может вызвать крэш если UI уничтожен
}
}
} |
|
Проблемы с тестированием асинхронного кода тоже заслуживают отдельного упоминания. Обычные unit тесты не работают с корутинами из коробки - нужны специальные TestDispatcher и понимание того, как контролировать время выполнения в тестовой среде.
Ещё одна распространённая ошибка - попытка вызывать suspend функции из неподходящих мест. Новички часто пытаются использовать suspend методы в конструкторах, init блоках или обычных функциях, получая ошибки компиляции и не понимая, почему "простой" код не работает.
Memory leaks в ViewModels
Утечки памяти в ViewModels с корутинами - это проблема, которая преследовала меня первые два года работы с новой архитектурой. Самое коварное в этих утечках то, что они проявляются не сразу. Приложение может работать месяцами без видимых проблем, а потом внезапно начать падать с OutOfMemoryError в самых неожиданных местах.
Корень зла - неправильное понимание жизненного цикла ViewModel и способов запуска корутин. Когда пользователь поворачивает экран или переходит между фрагментами, Android создаёт новые экземпляры UI компонентов, но ViewModel может оставаться в памяти. Если в старой ViewModel продолжают работать корутины, держащие ссылки на View или Context, получается классическая утечка памяти.
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // Опасный код - создаёт утечку памяти
class LeakyViewModel(private val context: Context) : ViewModel() {
fun startBackgroundWork() {
// Корутина держит ссылку на context через замыкание
GlobalScope.launch {
while (true) {
val data = heavyComputation(context) // утечка!
delay(1000)
}
}
}
private fun heavyComputation(context: Context): String {
// Использование context в долгоживущей корутине
return context.getString(R.string.computed_value)
}
} |
|
Правильное решение - использование viewModelScope и избегание прямых ссылок на Android компоненты:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| class SafeViewModel(
private val repository: DataRepository
) : ViewModel() {
fun startBackgroundWork() {
viewModelScope.launch {
while (isActive) {
val data = repository.computeData() // безопасно
_dataState.value = data
delay(1000)
}
}
}
} |
|
Ещё одна распространённая ошибка - хранение ссылок на View внутри ViewModel. Даже если корутина завершается корректно, ссылка на уничтоженный View может оставаться в памяти до сборки мусора:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // Плохо - ViewModel держит ссылку на View
class BadViewModel : ViewModel() {
private var currentView: View? = null
fun setView(view: View) {
currentView = view // потенциальная утечка
}
fun updateView() {
viewModelScope.launch {
val result = repository.getData()
currentView?.let { view ->
// View может быть уничтожен, но ссылка остаётся
updateUI(view, result)
}
}
}
} |
|
Проблемы возникают и с неправильным использованием Flow в ViewModel. Холодные Flow создаются заново для каждого подписчика, а если подписчик не отписывается корректно, накапливаются активные подписки:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| class FlowLeakViewModel : ViewModel() {
// Проблемный подход
fun getDataStream(): Flow<Data> = flow {
while (true) {
emit(repository.getCurrentData())
delay(5000)
}
}.flowOn(Dispatchers.IO) // каждый подписчик создаёт новый поток
// Правильный подход
private val dataStream = repository.observeData()
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
replay = 1
)
fun getDataStream(): SharedFlow<Data> = dataStream
} |
|
Коллекции coroutines тоже могут стать источником утечек, если не управлять ими аккуратно:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| class TaskManagerViewModel : ViewModel() {
private val runningTasks = mutableListOf<Job>()
fun startTask() {
val job = viewModelScope.launch {
processLongRunningTask()
}
runningTasks.add(job) // потенциальная утечка
}
override fun onCleared() {
super.onCleared()
runningTasks.forEach { it.cancel() }
runningTasks.clear()
}
} |
|
Лучше полагаться на автоматическую отмену через viewModelScope:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
| class BetterTaskManagerViewModel : ViewModel() {
fun startTask() {
viewModelScope.launch {
try {
processLongRunningTask()
} finally {
// cleanup происходит автоматически при onCleared()
}
}
}
} |
|
Профилирование памяти в Android Studio помогает выявлять утечки на ранней стадии. Heap dumps показывают, какие объекты остаются в памяти дольше ожидаемого, а LeakCanary автоматически детектирует самые распространённые паттерны утечек связанных с корутинами.
Отмена операций при закрытии экрана
Отмена операций при закрытии экрана - это область, где я научился не доверять автоматике Android системы. Пользователи имеют привычку закрывать экраны в самые неподходящие моменты: во время загрузки критичных данных, в процессе отправки форм, при выполнении тяжёлых вычислений. Если не отменить эти операции корректно, они продолжат выполняться в фоне, потребляя ресурсы и иногда вызывая крэши при попытке обновить несуществующий UI.
Самая распространённая ошибка - использование applicationContext или GlobalScope для операций, связанных с конкретным экраном. Такие корутины живут независимо от UI lifecycle и могут работать даже после уничтожения Activity:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
| // Плохой подход - операция не отменится при закрытии экрана
class BadActivity : AppCompatActivity() {
fun loadData() {
GlobalScope.launch {
val data = repository.loadHeavyData()
runOnUiThread {
updateUI(data) // может вызвать крэш если Activity уничтожена
}
}
}
} |
|
Правильное решение - привязка корутин к lifecycle компонентов через lifecycleScope:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
| class SafeActivity : AppCompatActivity() {
fun loadData() {
lifecycleScope.launch {
try {
val data = repository.loadHeavyData()
updateUI(data) // безопасно - корутина отменится с Activity
} catch (e: CancellationException) {
// Корутина была отменена - это нормально
}
}
}
} |
|
В Fragment ситуация сложнее из-за двойного жизненного цикла - самого фрагмента и его View. Использование неправильного scope может привести к попыткам обновления уничтоженного View:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
| class DataFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Привязываем к lifecycle View, а не Fragment
viewLifecycleOwner.lifecycleScope.launch {
repository.observeData().collect { data ->
updateRecyclerView(data) // безопасно - отменится при onDestroyView
}
}
}
} |
|
Для ViewModel правильный выбор scope критичен. viewModelScope автоматически отменяется при вызове onCleared(), что происходит когда ViewModel больше не нужна:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| class DataViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
fun loadData() {
viewModelScope.launch {
try {
val data = repository.getData()
_uiState.value = UiState.Success(data)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message)
}
}
}
} |
|
Иногда нужна более тонкая настройка отмены. Например, операция загрузки файла должна завершиться корректно, даже если пользователь покинул экран:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| class FileUploadManager {
private val uploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun uploadFile(file: File): Job {
return uploadScope.launch {
try {
val result = fileService.upload(file)
notificationManager.showUploadSuccess(result)
} catch (e: CancellationException) {
// Отмена загрузки - удаляем частично загруженный файл
cleanupPartialUpload(file)
throw e
}
}
}
fun cancelAllUploads() {
uploadScope.cancel()
}
} |
|
Комбинирование нескольких scope позволяет создавать сложные сценарии управления жизненным циклом:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| class ComplexDataScreen : Fragment() {
private val screenScope = CoroutineScope(SupervisorJob())
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Привязано к View lifecycle
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState.collect { state ->
updateUI(state)
}
}
// Независимо от View, но завершается при onDestroy Fragment
screenScope.launch {
backgroundDataSync()
}
}
override fun onDestroy() {
super.onDestroy()
screenScope.cancel()
}
} |
|
Правильная отмена операций при закрытии экрана предотвращает утечки ресурсов, крэши приложения и обеспечивает предсказуемое поведение асинхронного кода в контексте Android lifecycle.
Тестирование асинхронного кода
Тестирование корутин - это кошмар, который я переживал первые полгода работы с новой архитектурой. Привычные unit тесты превращались в flaky кошмар, где результат зависел от скорости выполнения машины и фазы луны. Асинхронные операции завершались в непредсказуемом порядке, тесты падали с timeout ошибками, а mock объекты не успевали срабатывать до проверки результатов.
Основная проблема - обычные тесты не умеют ждать завершения корутин. JUnit запускает тест, видит что функция вернула результат, и считает тест завершенным. А корутина может продолжать работать в фоне:
| Kotlin | 1
2
3
4
5
6
| // Неправильно - тест завершится до окончания корутины
@Test
fun badAsyncTest() {
viewModel.loadData() // запускает корутину и сразу возвращается
assertEquals(expectedData, viewModel.data.value) // падает - данные ещё не загружены
} |
|
runTest из kotlinx-coroutines-test решает базовые проблемы синхронизации. Он создаёт контролируемую тестовую среду, где время можно ускорять или замедлять:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
| @Test
fun correctAsyncTest() = runTest {
val repository = FakeRepository()
val viewModel = DataViewModel(repository)
viewModel.loadData()
// Дожидаемся завершения всех корутин
assertEquals(expectedData, viewModel.data.value)
} |
|
TestDispatcher даёт полный контроль над выполнением корутин в тестах. StandardTestDispatcher выполняет корутины только при явном вызове, UnconfinedTestDispatcher выполняет их немедленно:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| class ViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
@Test
fun testDataLoading() = testScope.runTest {
val viewModel = DataViewModel(testDispatcher)
viewModel.loadData()
// Корутина ещё не выполнилась
assertEquals(LoadingState, viewModel.state.value)
// Принудительно выполняем все pending корутины
advanceUntilIdle()
assertEquals(LoadedState, viewModel.state.value)
}
} |
|
Тестирование Flow требует особого подхода. Flow cold по своей природе, поэтому каждый collect создаёт новую подписку:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| @Test
fun testFlowOperations() = runTest {
val repository = FakeRepository()
val results = mutableListOf<DataState>()
val job = launch {
repository.observeData()
.take(3) // ограничиваем количество значений
.collect { results.add(it) }
}
repository.emitData(firstData)
repository.emitData(secondData)
repository.emitData(thirdData)
job.join()
assertEquals(3, results.size)
assertEquals(firstData, results[0])
} |
|
Mock объекты с suspend функциями требуют правильной настройки временных задержек:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| class RepositoryTest {
@Mock
private lateinit var apiService: ApiService
@Test
fun testWithDelay() = runTest {
// Мокаем с задержкой
whenever(apiService.getData()).thenSuspend {
delay(1000)
testData
}
val repository = DataRepository(apiService)
val startTime = currentTime
val result = repository.getData()
assertEquals(testData, result)
assertEquals(1000, currentTime - startTime) // проверяем что delay сработал
}
} |
|
Тестирование ошибок в корутинах требует понимания того, как исключения распространяются через scope:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| @Test
fun testErrorHandling() = runTest {
val failingRepository = object : Repository {
override suspend fun getData() = throw NetworkException("Network failed")
}
val viewModel = DataViewModel(failingRepository)
viewModel.loadData()
advanceUntilIdle()
val state = viewModel.state.value
assertTrue(state is ErrorState)
assertEquals("Network failed", (state as ErrorState).message)
} |
|
Для сложных сценариев с несколькими корутинами использую TestScope.backgroundScope для разделения основных и фоновых операций:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| @Test
fun testComplexScenario() = runTest {
val viewModel = ComplexViewModel()
// Основная операция
viewModel.startMainTask()
// Фоновые операции
backgroundScope.launch {
repeat(10) {
viewModel.backgroundUpdate()
delay(100)
}
}
advanceTimeBy(1000) // прокручиваем время на секунду
assertEquals(10, viewModel.backgroundUpdateCount)
assertTrue(viewModel.isMainTaskCompleted)
} |
|
Тестирование отмены корутин проверяет корректность cleanup операций:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
| @Test
fun testCancellation() = runTest {
val repository = spy(TestRepository())
val job = launch {
repository.longRunningOperation()
}
advanceTimeBy(500)
job.cancel()
verify(repository).cleanup() // проверяем что cleanup вызван
} |
|
Правильное тестирование асинхронного кода превращает хаотичные flaky тесты в надёжные проверки, которые точно отражают поведение продакшн кода.
Проблемы с GlobalScope и их альтернативы
GlobalScope - это самая опасная ловушка в мире корутин, в которую попадают 90% разработчиков на начальном этапе изучения. Помню, как сам активно использовал его первые месяцы, наивно полагая, что раз Google предоставил такой удобный API, значит им можно пользоваться везде. Результат был предсказуем - приложения начали течь память, корутины работали после закрытия экранов, а отладка превратилась в кошмар.
Основная проблема GlobalScope в том, что он живёт весь жизненный цикл приложения. Корутины, запущенные через GlobalScope.launch, продолжают выполняться даже после уничтожения Activity, Fragment или ViewModel. Это приводит к множеству критичных проблем:
| Kotlin | 1
2
3
4
5
6
7
8
9
| // Катастрофически неправильный код
class BadViewModel : ViewModel() {
fun loadUserData() {
GlobalScope.launch { // зло!
val userData = userRepository.getData()
_userState.value = UserState.Success(userData) // может вызвать крэш
}
}
} |
|
Когда пользователь закрывает экран, ViewModel уничтожается, но корутина продолжает работать. Попытка обновить _userState после уничтожения ViewModel приводит к непредсказуемым результатам, а ссылка на repository может вызвать утечку памяти.
Ещё хуже становится, когда в GlobalScope корутинах используются ссылки на Android компоненты. Context, View, Fragment - все эти объекты могут остаться в памяти гораздо дольше их естественного жизненного цикла:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // Утечка памяти гарантирована
class LeakyActivity : AppCompatActivity() {
fun startBackgroundWork() {
GlobalScope.launch {
while (true) {
val result = heavyComputation()
runOnUiThread {
updateUI(result) // Activity может быть уничтожена!
}
delay(5000)
}
}
}
} |
|
Правильное решение - использование scope, привязанных к жизненному циклу компонентов. viewModelScope автоматически отменяется при вызове onCleared():
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| class SafeViewModel : ViewModel() {
fun loadUserData() {
viewModelScope.launch {
try {
val userData = userRepository.getData()
_userState.value = UserState.Success(userData)
} catch (e: CancellationException) {
// Корректная отмена - ничего не делаем
throw e
} catch (e: Exception) {
_userState.value = UserState.Error(e.message)
}
}
}
} |
|
Для Activity и Fragment используйте lifecycleScope, который автоматически управляется Android lifecycle:
| Kotlin | 1
2
3
4
5
6
7
8
| class SafeActivity : AppCompatActivity() {
fun loadData() {
lifecycleScope.launch {
val data = repository.getData()
updateUI(data) // безопасно - корутина отменится с Activity
}
}
} |
|
Когда действительно нужны long-running задачи, создавайте собственные scope с явным управлением жизненным циклом:
| Kotlin | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| class BackgroundSyncManager {
private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun startPeriodicSync() {
syncScope.launch {
while (isActive) {
try {
syncData()
delay(TimeUnit.HOURS.toMillis(1))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Логируем и продолжаем
}
}
}
}
fun stopSync() {
syncScope.cancel() // явно останавливаем все операции
}
} |
|
Альтернатива GlobalScope для одноразовых операций - создание временного scope с coroutineScope:
| Kotlin | 1
2
3
4
5
| suspend fun processMultipleFiles(files: List<File>) = coroutineScope {
files.map { file ->
async { processFile(file) }
}.awaitAll()
} |
|
Такой scope автоматически дождётся завершения всех дочерних корутин и корректно обработает отмену.
GlobalScope имеет очень ограниченную область применения - только для операций, которые должны продолжаться весь жизненный цикл приложения и не держат ссылки на UI компоненты. В 99% случаев существует лучшая альтернатива.
Как перетащить мусор в корзину? Drag 'n drop в Kotlin Здравствуйте.
Есть мини-игра, где надо перетащить клочок бумаги в корзину. Есть код, который вроде... [Kotlin] Как узнать и сравнить положение двух imageView? Есть две картинки: клочок бумаги и корзина. Как сделать так, чтобы бумага исчезала, когда ее... Как учить Kotlin и стоит ли? Добрый день, вообще я фрилансер пишу сайты на таких технологиях HTML, CSS, jQuery, WordPress,... Как последовательно выполнять методы используя kotlin coroutines? У меня есть картинки в recyclerview по нажатию на которые выполняется запрос к апи, при чём они... firebase database как получать сообщения и выводить их на экран на kotlin Не понимаю что нужно писать в onClickListener, чтобы при нажатии на кнопку брался текст с базы... Как запускать параллельные таймеры (kotlin)? Программа для раздельного старта спортсменов. Есть EditText для количества участников, по нему... [Kotlin] Как заставить Service работать, даже если приложение выгружено из списка задач? Здравствуйте. Пишу приложение для напоминаний. Пользователь вводит текст, который хочет вывести,... Как встроить admob в kotlin проект? Есть проект созданный в android studio на kotlin, при встраивание admob проект собирается но при... Как использовать RxJava2, Retrofit2 в Android Kotlin Здравствуйте. Пишу приложение, где нужно прочитать JSON файл из интернета и вывести на экран.... Как исправить ошибку Can't create handler inside thread that has not called Looper.prepare() на Kotlin? У меня есть activity в котором я подключаюсь к веб-сокету. Он должен присылать сообщения с помощью... Kotlin приложение для просмотра картинок. Как зациклить этот адаптер? Здравствуйте.
Не хватает знаний/мозгов как в идеале сделать "карусель" из картинок (т.е. чтобы... Как преобразовать Нейросеть в Kotlin из Python? Код на Python
import numpy as np
def sigmoid(x): return 1 / (1 + np.exp(-x)) # создаем...
|