Любой сетевой запрос в приложении может закончиться тремя способами: данные загружаются, данные получены, что-то пошло не так. Эти три состояния — 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. Простая, предсказуемая цепочка.