Senior
Coroutines в Android, Compose и тестах
Владение coroutine, Flow в UI, Compose effects, lifecycle и детерминированное тестирование coroutine.
На этой странице
Это русскоязычная подготовленная версия предоставленного конспекта. Все исходные кодовые блоки сохранены как read-only примеры. Примеры не компилировались и не запускались в ходе подготовки.
Теория
Архитектурная схема
Compose
│ events
▼
ViewModel
│ suspend / Flow
▼
UseCase
│
▼
Repository
│
├── Network
├── Database
└── Cache
Обратно:
Repository
│
Flow
▼
ViewModel
│
StateFlow<UiState>
▼
Compose
Официальная документация: Документация Kotlin Coroutines.
Кто должен создавать coroutine
Полезный принцип:
Coroutine запускает слой, который владеет lifecycle операции.
Repository чаще предоставляет:
suspend fun save(data: Data)
а caller решает, где запускать:
viewModelScope.launch {
repository.save(data)
}
Официальная документация: Документация Kotlin Coroutines.
Почему GlobalScope в repository плох
fun save(data: Data) {
GlobalScope.launch {
api.save(data)
}
}
Caller теряет возможность:
- await;
- cancel;
- обработать failure;
- связать работу со своим lifecycle.
Main-safe repository
class Repository(
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun readFile(): Data =
withContext(ioDispatcher) {
blockingFileRead()
}
}
ViewModel не должна микроменеджить implementation details data layer.
Официальная документация: Документация Kotlin Coroutines.
Не оборачивай любой suspend API в IO
@GET("venue")
suspend fun venue(): VenueDto
Если API уже asynchronous/non-blocking, дополнительный withContext(Dispatchers.IO) обычно не нужен.
Официальная документация: Документация Kotlin Coroutines.
ViewModel + StateFlow
data class UiState(
val loading: Boolean = false,
val data: Data? = null,
val error: Throwable? = null,
)
private val _state =
MutableStateFlow(UiState())
val state =
_state.asStateFlow()
Обновление:
_state.update {
it.copy(loading = true)
}
Официальная документация: Flow API.
Compose collection
Обычно:
val state by viewModel.state.collectAsStateWithLifecycle()
Pipeline:
StateFlow
↓
collectAsStateWithLifecycle
↓
Compose State
↓
Recomposition
Официальная документация: Flow API.
repeatOnLifecycle
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect {
render(it)
}
}
}
Block перезапускается при повторном входе lifecycle в нужное состояние.
Официальная документация: repeatOnLifecycle.
Несколько Flow внутри repeatOnLifecycle
Плохо:
repeatOnLifecycle(STARTED) {
flowA.collect { ... }
flowB.collect { ... }
}
Первый infinite collect не даст дойти до второго.
Правильно:
repeatOnLifecycle(STARTED) {
launch {
flowA.collect { ... }
}
launch {
flowB.collect { ... }
}
}
Официальная документация: Flow API.
LaunchedEffect
LaunchedEffect(key) {
...
}
Если key меняется, старая coroutine отменяется и запускается новая.
LaunchedEffect(userId) {
viewModel.load(userId)
}
Официальная документация: Compose side effects.
rememberCoroutineScope
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
sheetState.hide()
}
}
)
Ментальная модель:
LaunchedEffect
→ declarative side effect
rememberCoroutineScope
→ imperative launch из callback
Официальная документация: CoroutineScope API.
Не запускай coroutine прямо в composition
Плохо:
@Composable
fun Screen() {
scope.launch {
load()
}
}
Recomposition может породить повторные launches.
Используй effect APIs или ViewModel.
UI-specific vs business coroutine
rememberCoroutineScope хорошо подходит для:
- animation;
- drawer;
- snackbar;
- scroll;
- sheet.
Долгоживущая business operation чаще принадлежит ViewModel.
Официальная документация: CoroutineScope API.
Events сложнее State
private val _events =
MutableSharedFlow<UiEvent>()
val events =
_events.asSharedFlow()
Нужно спросить:
Что произойдёт, если event emitted, когда UI STOPPED?
Потеря event может быть допустимой или недопустимой в зависимости от semantics.
Официальная документация: Flow API.
Когда State лучше Event
Если информация описывает durable/current state системы, лучше моделировать её состоянием.
Например PaymentState.Success может быть надёжнее transient NavigateToPaymentSuccess, если success должен пережить recreation.
Race между запросами
fun load(id: String) {
viewModelScope.launch {
val user = repository.load(id)
_state.update {
it.copy(user = user)
}
}
}
Пользователь быстро вызывает:
load(A)
load(B)
B может завершиться раньше A, а затем старый A затрёт новый state.
Latest semantics
Imperative вариант:
private var loadJob: Job? = null
fun load(id: String) {
loadJob?.cancel()
loadJob = viewModelScope.launch {
...
}
}
Flow-вариант часто выразительнее:
selectedId
.flatMapLatest { id ->
repository.observe(id)
}
Официальная документация: Job API.
Declarative search pipeline
query
.debounce(300)
.distinctUntilChanged()
.mapLatest { query ->
repository.search(query)
}
.stateIn(...)
Вместо ручного хранения и отмены Job semantics выражена операторами Flow.
Официальная документация: Job API.
runTest
@Test
fun loadsUser() = runTest {
val user = repository.load()
assertEquals(expected, user)
}
runTest предоставляет coroutine-test environment и virtual time.
Официальная документация: Тестирование coroutine.
Virtual time
@Test
fun test() = runTest {
delay(10_000)
}
Тесту не обязательно реально ждать 10 секунд.
Официальная документация: Тестирование coroutine.
TestDispatcher
Частые варианты:
StandardTestDispatcher
UnconfinedTestDispatcher
StandardTestDispatcher полезен для детерминированного управления scheduler.
advanceUntilIdle
viewModel.load()
advanceUntilIdle()
assertEquals(
expected,
viewModel.state.value,
)
Выполняет scheduled coroutine tasks до idle.
advanceTimeBy
advanceTimeBy(1000)
Полезно для тестирования:
- debounce;
- timeout;
- retry delays;
- delayed events.
Dispatcher injection в тестах
val dispatcher =
StandardTestDispatcher(testScheduler)
val repository =
Repository(dispatcher)
Так production code становится управляемым test scheduler.
Dispatchers.Main в JVM tests
Обычный JVM unit test не имеет Android Main Looper.
Часто используется MainDispatcherRule, которая на время теста подменяет Main на TestDispatcher и затем сбрасывает его.
Официальная документация: Dispatchers API.
Тест Flow
@Test
fun flowEmitsValues() = runTest {
val values =
repository.observe()
.take(3)
.toList()
assertEquals(
listOf(1, 2, 3),
values,
)
}
Для infinite/hot streams collector lifecycle надо контролировать особенно внимательно.
Официальная документация: Flow API.
Тест StateFlow
Если важен только итоговый state:
viewModel.load()
advanceUntilIdle()
assertEquals(
UiState.Content(expected),
viewModel.state.value,
)
Если важна последовательность Loading → Content, тестируй emissions.
Официальная документация: Flow API.
Тестируй semantics, а не implementation
Лучше:
Given repository returns X
When user requests load
Then state becomes Loading → Content(X)
чем проверять внутренние launch, dispatcher switch и точный порядок private calls.
Senior case: dispatcher management
Рабочий, но архитектурно слабый код:
fun load() {
viewModelScope.launch(Dispatchers.IO) {
val user = api.user()
withContext(Dispatchers.Main) {
_state.value = UiState.User(user)
}
}
}
Предпочтительнее:
viewModelScope.launch {
val user = repository.user()
_state.value = UiState.User(user)
}
Repository отвечает за main-safety implementation.
Официальная документация: Dispatchers API.
Interview: порядок выполнения
runBlocking {
println("A")
launch {
println("B")
delay(100)
println("C")
}
println("D")
}
Для обычного runBlocking типичная последовательность:
A
D
B
C
На собеседовании важнее объяснить scheduling и structured concurrency, чем просто назвать буквы.
coroutineScope ждёт children
runBlocking {
println("A")
coroutineScope {
launch {
delay(100)
println("B")
}
println("C")
}
println("D")
}
A
C
B
D
D после B, потому что coroutineScope ждёт child.
Failure внутри coroutineScope
viewModelScope.launch {
try {
coroutineScope {
launch {
delay(100)
error("Boom")
}
launch {
delay(1000)
println("Finished")
}
}
} catch (e: Exception) {
println("Caught")
}
}
Child 1 fails
↓
scope cancelled
↓
Child 2 cancelled
↓
failure leaves coroutineScope
↓
catch
Finished не печатается.
С supervisorScope
supervisorScope {
launch {
error("Boom")
}
launch {
delay(1000)
println("Finished")
}
}
Failure первого child не отменяет второго sibling.
runCatching case
suspend fun load(): Result<Data> =
runCatching {
api.load()
}
Проблема — возможное swallowing cancellation.
Безопаснее явно rethrow CancellationException.
Официальная документация: Документация Kotlin Coroutines.
Ad-hoc scope
Плохо:
fun load() {
CoroutineScope(Dispatchers.IO).launch {
repository.load()
}
}
Нужно спросить:
- кто owner?
- кто отменит scope?
- куда уйдёт failure?
- должен ли caller ждать результат?
Официальная документация: CoroutineScope API.
Последовательный async
val a = async { requestA() }.await()
val b = async { requestB() }.await()
Операции последовательны.
Если независимы:
val a = async { requestA() }
val b = async { requestB() }
val resultA = a.await()
val resultB = b.await()
Но async нужен не всегда
Если B зависит от результата A:
val user = loadUser()
val permissions =
loadPermissions(user.id)
Sequential execution правильно отражает dependency.
Медленный Flow consumer
flow.collect {
delay(5000)
render(it)
}
Правильный вопрос:
Какая нужна backpressure semantics?
Варианты:
нужны все значения → collect
нужен latest → collectLatest
можно пропускать → conflate
нужно decouple producer → buffer
Официальная документация: Flow API.
Search Flow
Вместо:
query.collect { query ->
repository.search(query)
}
часто:
query
.debounce(300)
.distinctUntilChanged()
.mapLatest { query ->
repository.search(query)
}
Официальная документация: Flow API.
StateFlow vs SharedFlow
Не просто «StateFlow для UI, SharedFlow для events».
Правильнее:
StateFlow — когда существует понятие current value и новый subscriber должен получить актуальное состояние.
SharedFlow — hot broadcast stream с configurable replay/buffering без обязательной current-state semantics.
Официальная документация: Flow API.
Flow vs suspend
один async result
↓
suspend
stream изменений
↓
Flow
Например:
suspend fun refreshBookings()
fun observeBookings(): Flow<List<Booking>>
Официальная документация: Документация Kotlin Coroutines.
Flow vs Channel
Channel
→ communication primitive
→ send / receive
→ queue / rendezvous
Flow
→ stream abstraction
→ transform / combine / collect
Выбор Channel/SharedFlow для events должен исходить из delivery requirements.
Официальная документация: Flow API.
Главный архитектурный вопрос
При code review спроси:
WHO OWNS THIS WORK?
UI animation
→ Composable
Screen operation
→ ViewModel
App-wide session observation
→ application-level scope
Short repository operation
→ caller
Guaranteed work across process death
→ durable scheduler, например WorkManager
Официальная документация: WorkManager.
Coroutine не переживает process death
Даже:
applicationScope.launch {
uploadCriticalData()
}
не переживёт убийство Android process.
Для гарантированно возобновляемой фоновой работы нужен persistent/durable scheduling mechanism.
Уточнение virtual time
advanceTimeBy(duration) передвигает виртуальные часы, но задачи, назначенные
ровно на достигнутый момент времени, запускаются отдельным шагом scheduler.
После него вызовите runCurrent() (либо используйте advanceUntilIdle(), когда
это соответствует проверяемой семантике), прежде чем утверждать состояние на
границе времени.
Практика
Вопросы для разбора
Для каждого примера нарисуйте иерархию Job, определите CoroutineContext и Dispatcher, отметьте точки suspension/cancellation, затем проследите failure вверх и cancellation вниз. Для Flow дополнительно определите cold/hot semantics, владельца collection, поведение медленного consumer, replay и последствия lifecycle stop/start.
Проверка архитектурного решения
Спросите: кто владеет этой работой, кто может её отменить, где обрабатывается ошибка, нужна ли каждая emission или только latest, и переживает ли результат пересоздание UI. Coroutine не переживает process death; для гарантированной фоновой работы нужен durable scheduler, например WorkManager.
Дополнительные материалы
Итоговый чек-лист
К Middle+/Senior собеседованию нужно уверенно объяснять:
| A | B |
|---|---|
| Coroutine | Thread |
| suspension | blocking |
launch |
async |
coroutineScope |
supervisorScope |
Job |
SupervisorJob |
| failure | cancellation |
delay |
Thread.sleep |
Dispatchers.IO |
Dispatchers.Default |
collect |
collectLatest |
buffer |
conflate |
combine |
zip |
map |
mapLatest |
flatMapConcat |
flatMapMerge |
flatMapMerge |
flatMapLatest |
| cold Flow | hot Flow |
StateFlow |
SharedFlow |
stateIn |
shareIn |
| Flow | suspend |
| Flow | Channel |
lifecycleScope |
viewModelScope |
LaunchedEffect |
rememberCoroutineScope |
runBlocking |
runTest |
Алгоритм разбора Coroutines
Для любого coroutine-кода:
- Нарисуй Job hierarchy.
- Определи CoroutineContext / Dispatcher.
- Найди suspension и cancellation points.
- Проследи failure вверх.
- Проследи cancellation вниз.
- Найди supervision boundaries.
- Определи, где реально находится exception handling boundary.
Алгоритм разбора Flow
Дополнительно спроси:
- Flow cold или hot?
- Кто запускает upstream?
- Кто владеет collection?
- Что происходит с медленным consumer?
- Нужны все значения или только latest?
- Что увидит новый subscriber?
- Нужен ли replay?
- Не запускается ли дорогой cold upstream несколько раз?
- Что происходит при lifecycle stop/start?
- Что происходит при cancellation?
Главная ментальная карта
Coroutine
│
┌────────────────┼────────────────┐
│ │ │
Lifecycle Execution Data
│ │ │
Job Dispatcher Flow
│ │ │
Parent/Child Main/IO/ Cold / Hot
│ Default │
Cancellation ┌───┴───┐
│ │ │
Exceptions StateFlow SharedFlow
│
Supervision
Под всем этим:
suspend
↓
Continuation
↓
state machine
Главная цель подготовки — не запомнить отдельные API, а научиться выводить поведение coroutine-кода из Job hierarchy, structured concurrency, cancellation semantics и Flow semantics.