NetworkResult: оборачиваем сетевой запрос в sealed-класс

Любой сетевой запрос в приложении может закончиться тремя способами: данные загружаются, данные получены, что-то пошло не так. Эти три состояния — Loading, Success, Error — повторяются из экрана в экран, из проекта в проект. Каждый раз писать try/catch, вручную переключать флаги isLoading и isError — утомительно и чревато ошибками.

Вместо этого можно упаковать все три состояния в один тип и передавать их через Flow. Интерфейс подписывается на поток и реагирует на смену состояний: показывает индикатор загрузки, отрисовывает данные или выводит сообщение об ошибке.

Sealed interface: три состояния, один тип

sealed interface NetworkResult<out T> {
    data object Loading : NetworkResult<Nothing>
    data class Success<out T>(val data: T) : NetworkResult<T>
    data class Error(
        val throwable: Throwable,
        val code: Int? = null,
        val message: String? = null
    ) : NetworkResult<Nothing>
}

Почему Loading — это data object, а не data class? Потому что загрузка не несёт данных. Нам не нужно каждый раз создавать новый экземпляр — достаточно одного на всё приложение. Синглтон экономит память и, что важнее, позволяет корректно работать сравнению: NetworkResult.Loading == NetworkResult.Loading всегда true. Для StateFlow это критично — если два подряд значения равны, подписчик не получит лишнего обновления.

Success и Error — дата-классы, потому что каждый успех и каждая ошибка уникальны: разные данные, разные коды, разные сообщения.

Параметр out T и тип Nothing позволяют хранить все три состояния в одном потоке Flow<NetworkResult<T>>, где T — тип данных при успешном ответе. Loading и Error не содержат данных типа T, поэтому используют Nothing — подтип любого типа в Kotlin.

Функция-обёртка: callAsFlow

Теперь нужен механизм, который оборачивает произвольный сетевой вызов в Flow<NetworkResult<T>>. Идея простая: сначала эмиттим Loading, потом выполняем запрос, и в зависимости от результата эмиттим Success или Error.

fun <T> callAsFlow(block: suspend () -> T): Flow<NetworkResult<T>> = flow {
    emit(NetworkResult.Loading)
    try {
        val result = block()
        emit(NetworkResult.Success(result))
    } catch (e: CancellationException) {
        throw e // не перехватываем отмену корутины
    } catch (e: Exception) {
        emit(NetworkResult.Error(throwable = e, message = e.message))
    }
}

Обратите внимание на перехват CancellationException. Это принципиальный момент. В мире корутин CancellationException — это не ошибка, а штатный механизм отмены. Если поглотить его в catch, корутина не сможет корректно завершиться. Поэтому всегда пробрасываем.

Использование:

class UserRepository(private val api: ApiService) {
    fun getProfile(userId: Int): Flow<NetworkResult<User>> =
        callAsFlow { api.getUser(userId) }
}

Репозиторий возвращает холодный Flow. Каждый вызов collect() выполнит запрос заново. Во ViewModel подогреваем:

class ProfileViewModel(private val repository: UserRepository) : ViewModel() {

    val profile: StateFlow<NetworkResult<User>> = repository
        .getProfile(userId = 42)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = NetworkResult.Loading
        )
}

UI: три ветки when — и готово

В Composable весь экран описывается через when:

@Composable
fun ProfileScreen(viewModel: ProfileViewModel = viewModel()) {
    val state by viewModel.profile.collectAsStateWithLifecycle()

    when (val result = state) {
        is NetworkResult.Loading -> {
            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator()
            }
        }
        is NetworkResult.Success -> {
            val user = result.data
            Column(Modifier.padding(16.dp)) {
                Text(user.name, fontSize = 24.sp)
                Text(user.email, color = Color.Gray)
            }
        }
        is NetworkResult.Error -> {
            Column(
                Modifier.fillMaxSize(),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.Center
            ) {
                Text("Не удалось загрузить профиль")
                Text(result.message ?: "Неизвестная ошибка", color = Color.Gray)
            }
        }
    }
}

Благодаря sealed interface компилятор проверяет, что мы обработали все ветки. Забыли Error — получили предупреждение. Этот паттерн масштабируется: каждый экран, работающий с сетью, использует один и тот же NetworkResult, но с разным типом T.

Расширение: retry, таймауты, логирование

Базовая версия callAsFlow намеренно минимальна. В реальном проекте её можно расширить: добавить повторные попытки при сетевых ошибках, обработку HTTP-кодов, логирование. Вот набросок с retry:

fun <T> callAsFlow(
    retries: Int = 2,
    retryDelayMs: Long = 1000,
    block: suspend () -> T
): Flow<NetworkResult<T>> = flow {
    emit(NetworkResult.Loading)
    var lastException: Exception? = null

    repeat(retries + 1) { attempt ->
        try {
            val result = block()
            emit(NetworkResult.Success(result))
            return@flow
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            lastException = e
            if (attempt < retries) {
                delay(retryDelayMs * (attempt + 1))
            }
        }
    }

    emit(NetworkResult.Error(
        throwable = lastException!!,
        message = lastException?.message
    ))
}

Три попытки, нарастающая задержка между ними. Если все три неудачны — Error. Если хотя бы одна удалась — Success, и цикл прерывается.


Паттерн NetworkResult + callAsFlow — это несколько десятков строк кода, которые стандартизируют работу с сетью во всём приложении. Репозиторий возвращает Flow<NetworkResult<T>>, ViewModel подогревает его через stateIn(), UI реагирует через when. Простая, предсказуемая цепочка.

Этот разбор — часть большого руководства «Корутины в Kotlin», где тема собрана целиком: от первой корутины до реактивной архитектуры.