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. Простая, предсказуемая цепочка.