Лекции

Senior

Coroutines в Android, Compose и тестах

Владение coroutine, Flow в UI, Compose effects, lifecycle и детерминированное тестирование coroutine.

androidcomposetestingcoroutinesflow
На этой странице

Это русскоязычная подготовленная версия предоставленного конспекта. Все исходные кодовые блоки сохранены как 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-кода:

  1. Нарисуй Job hierarchy.
  2. Определи CoroutineContext / Dispatcher.
  3. Найди suspension и cancellation points.
  4. Проследи failure вверх.
  5. Проследи cancellation вниз.
  6. Найди supervision boundaries.
  7. Определи, где реально находится exception handling boundary.

Алгоритм разбора Flow

Дополнительно спроси:

  1. Flow cold или hot?
  2. Кто запускает upstream?
  3. Кто владеет collection?
  4. Что происходит с медленным consumer?
  5. Нужны все значения или только latest?
  6. Что увидит новый subscriber?
  7. Нужен ли replay?
  8. Не запускается ли дорогой cold upstream несколько раз?
  9. Что происходит при lifecycle stop/start?
  10. Что происходит при 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.