我们的大部分工作是向API (通常是HTTP或GraphQL)发出请求,并为所有可能的状态提供 UI——比如加载、成功、错误和空。
这应该很简单!但是……我们有没有安排时间更深入地思考它?从我 8 年多的 Android 开发者经验来看,我们往往会把简单的事情复杂化。
这就是今天文章的主题——如何使用Kotlin Flows(异步数据流)和Ktor Client(多平台 HTTP 客户端;不过下面的代码片段可以与Retrofit、Apollo一起使用,以简单而优雅的方式从服务器发送和接收数据Kotlin GraphQL或您喜欢的任何客户端)。
这是一个与互联网一样古老的问题。我们所有人(前端开发人员)都会面对它,无论平台是什么——Android、iOS、Web 或桌面。因此,让我们检查一下!
在理想情况下,上图应该完美地说明了后端调用应该是什么样子。翻译成Kotlin:
suspend fun backendCall (r: Request ) : Result<Err, Ok>
然而,在许多 Android 项目的实践中,伪装成“OOP 最佳实践”,我看到了一些如此简单的东西:
suspend fun backendCall (r: Request ) : Ok
您永远无法确定这些是它可以产生的所有可能的异常。例如,SSLException
怎么样?
区分系统异常(例如没有互联网)和域异常(例如无效凭据、商品缺货)并不容易。
即使您收到Ok,您也不能确定所有必需的字段都存在且不为空。
万恶之源——它不是类型安全的。
所以让我们解决这个问题!有一种叫做ADT(代数数据类型)的东西实际上只是不可变的data class’s (产品*类型),sealed interface 's (SUM+类型)以及它们之间的组合。
对于后端调用结果,我们需要一个Result具有两种可能情况的特殊类型:Ok(包含成功时预期的所有数据)|(或) Err(可以是任何可能的错误)。
Either
来自ArrowKt
(Kotlin 标准库的功能伴侣)是这项工作的完美人选。当然,我们可以轻松创建我们自己的sealed class Result
,但您会在本文中更深入地了解为什么Arrow 的 Either
依赖项是更好的选择。
https://arrow-kt.io/
// Note: You need dependency to 'io.arrow-kt:arrow-core'
// https://arrow-kt.io/docs/quickstart/#bom-file
import arrow.core.Either
// Throws no exceptions. Always terminates (no inf. loops).
suspend fun <R, E, T> remoteCall(request: R): Either<E, T> = TODO()
定义1 一个好的远程调用:是一个这样的调用:1) 是类型安全的→ 总是以明确定义的类型导致 Ok 或 Err;2)是一个总函数→不抛异常不卡死(0…Timeout秒内完成)
定义2 总函数:是为所有输入值定义(不抛出异常且永不卡住)的函数。例如,
fun divide(a: Int, b: Int): Int = a / b
是部分的(不是全部的)因为对于 b = 0 它抛出异常。
现在我们已经定义了什么是“好的”远程调用,让我们深入研究 Ktor 的实现。
本文中代码完整地址:
https://github.com/ILIYANGERMANOV/flawless-requests-demo
在你的build.gradle
文件中添加下面依赖
// region Ktor Http Client
var ktor = "2.0.3" // use the latest stable version
implementation "io.ktor:ktor-client-core:$ktor"
implementation "io.ktor:ktor-client-okhttp:$ktor"
implementation "io.ktor:ktor-client-logging:$ktor"
implementation "io.ktor:ktor-client-content-negotiation:$ktor"
implementation "io.ktor:ktor-serialization-gson:$ktor"
implementation "com.google.code.gson:gson:2.9.0" // use the latest stable version
// endregion
,您可以将 Ktor 客户端设为单例(推荐)并通过您的DI 框架 (如Hilt、Dagger或Koin)连接它。
KtorClient.kt
import android.util.Log
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.http.*
import io.ktor.serialization.gson.*
/**
* Creates and configures a new instance of the Ktor [HttpClient].
*
* [Official docs](https://ktor.io/docs/create-client.html#close-client):
* Note that **creating HttpClient is not a cheap operation**,
* and it's better to **reuse (@Singleton)** its instance in the case of multiple requests.
*
* **Note:** You also need to call the [HttpClient.close] function when you're done with it
* to free resources. If you need to use [HttpClient] for a single request,
* call the [HttpClient.use] function, which automatically calls [HttpClient.close].
*
* @return a new pre-configured Ktor [HttpClient] instance.
*/
fun ktorClient(): HttpClient = HttpClient {
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
Log.d("KTOR", message)
}
}
level = LogLevel.ALL // logs everything
}
install(HttpTimeout) {
requestTimeoutMillis = 10_000 // 10s
connectTimeoutMillis = 10_000 // 10s
}
install(ContentNegotiation) {
gson(
contentType = ContentType.Any // workaround for broken APIs
)
}
}
我们需要一个可以向任意Web API发出任意HTTP 请求从而导致成功或错误的总函数。让我们看一下代码。
import arrow.core.Either
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
sealed interface HttpError {
data class API(val response: HttpResponse) : HttpError
data class Unknown(val exception: Exception) : HttpError
}
suspend inline fun <reified Data> httpRequest(
ktorClient: HttpClient,
crossinline request: suspend HttpClient.() -> HttpResponse
): Either<HttpError, Data> = withContext(Dispatchers.IO) {
try {
val response = request(ktorClient)
if (response.status.isSuccess()) {
// Success: 200 <=status code <=299.
Either.Right(response.body())
} else {
// Failure: unsuccessful status code.
Either.Left(HttpError.API(response))
}
} catch (exception: Exception) {
// Failure: exceptional, something wrong.
Either.Left(HttpError.Unknown(exception))
}
}
让我们从一个简单的GET 请求示例开始。
import arrow.core.Either
import com.flawlessrequests.network.HttpError
import com.flawlessrequests.network.httpRequest
import com.flawlessrequests.network.ktorClient
// Depends on: 'com.google.code.gson:gson' if you're using GSON
import com.google.gson.annotations.SerializedName
import io.ktor.client.request.*
val ktorSingleton = ktorClient()
const val PRODUCTS_PER_PAGE = 24
data class ProductsResponse(
@SerializedName("products")
val products: List<Product>
)
data class Product(
@SerializedName("name")
val name: String,
@SerializedName("price")
val price: Double,
)
// Imaginary API
suspend fun fetchProductsFromAPI(page: Int): Either<HttpError, ProductsResponse> =
httpRequest(ktorSingleton) {
get("https://www.myawesomeapi.com/prodcuts") {
headers {
set("API_KEY", "{YOUR_API_KEY}")
}
parameter("offset", page * PRODUCTS_PER_PAGE)
parameter("limit", PRODUCTS_PER_PAGE)
}
}
Ktor 的Post请求
import arrow.core.Either
import com.flawlessrequests.network.HttpError
import com.flawlessrequests.network.httpRequest
import com.google.gson.annotations.SerializedName
import io.ktor.client.*
import io.ktor.client.request.*
const val API_BASE = "https://www.myawesomeapi.com"
data class LoginRequest(
@SerializedName("email")
val email: String,
@SerializedName("password")
val password: String,
)
data class LoginResponse(
@SerializedName("userId")
val userId: String,
@SerializedName("sessionToken")
val sessionToken: String,
)
// Imaginary API
suspend fun HttpClient.login(request: LoginRequest): Either<HttpError, LoginResponse> =
httpRequest(this) {
post("$API_BASE/login") {
setBody(request) // the body will be serialized to JSON
}
}
发送标准 HTTP 请求非常简单且类型安全。我已经演示了使用 Ktor 构建请求的多种方法之一。支持HttpClient强大的 Kotlin DSL,请移步官方文档
https://ktor.io/docs/request.html
我已经向您保证,我们将探讨为什么我们选择Either
(ArrowKt’s Core)而不是自定义Result
类型。原因是这Either是一个Monad
类型。Either
支持许多内置功能,包括通过 .bind()
完成链调用(这是对 nested.flatMap{}
的理解)
[ArrowKt’s Core]https://arrow-kt.io/docs/core/
[either官方文档]https://arrow-kt.io/docs/apidocs/arrow-core/arrow.core/-either/
你可以通过官方文档理解,也可以通过下面的示例代码了解
import arrow.core.Either
import arrow.core.computations.either
import com.flawlessrequests.network.HttpError
@JvmInline
value class TrackingId(val id: String)
suspend fun getOrderId(): Either<HttpError, String> = TODO()
suspend fun confirmOrder(orderId: String): Either<HttpError, Boolean> = TODO()
suspend fun trackOrder(orderId: String): Either<HttpError, TrackingId> = TODO()
suspend fun placeOrderChain(): Either<HttpError, TrackingId?> = either { // 0
val orderId = getOrderId().bind() // 1
val canBeTracked = confirmOrder(orderId).bind() // 2
if (canBeTracked) {
trackOrder(orderId).bind() // 2a
} else {
null
}
}
Monad
是一个巨大的话题,值得单独撰写一系列文章,但我们将尝试实际解释这里发生的事情。
0:either提供对扩展功能的访问的效果范围(单子理解).bind()。
1:发送getOrderId(): Either
请求,如果失败则终止整个链Either.Left
。否则,成功getOrderId().bind()
将返回String类型Either.Right
。
2, 2a: 与“1:”的工作方式相同。简而言之,.bind()
仅当操作成功时才继续 → 返回Either.Right
。
提示:要链接多个请求,它们必须具有相同的错误类型(
Either.Left
),因为在失败的情况下,整个链只能以单个“Left”(Error)类型终止。要链接具有不同错误类型的请求,请使用Either#mapLeft{}
, 有点类似request1().mapLeft { ... }.bind()
或者,上面的代码“在引擎盖下.bind()
转换为以下链
// Re-written without the "either" effect scope (it's uglier!)
suspend fun placeOrderFlatMap(): Either<HttpError, TrackingId?> =
getOrderId().flatMap { orderId ->
confirmOrder(orderId).flatMap { canBeTracked ->
if (canBeTracked) {
trackOrder(orderId).flatMap { trackingId ->
Either.Right(trackingId)
}
} else Either.Right(null)
}
}
现在关于 Monads
和 Either
已经足够了。让我们关注更多与 Android 开发相关的内容 — 如何在 UI 中无缝处理请求状态(Loading, Success, Error)。
如果你已经走到这一步——恭喜!这对我们 Android 开发者来说是最有益的部分。我们将创建一个响应式机制,该机制将发送 HTTP 请求并使用 Kotlin Flows自动处理Loading、Success和Error状态以及所有必需的重试逻辑。
【本文涉及的代码】https://github.com/ILIYANGERMANOV/flawless-requests-demo
我们首先需要定义一个类型来表示 HTTP 请求在 UI 中可能具有的 3 种可能状态:Loading| Success(data)| Error(error)
. 因为对于许多与 UI 相关的事情(例如在本地保存大文件或转换位图)来说,具有 Loading 和 Error 状态是很常见的,所以让我们的定义更通用,以便我们可以重新使用它。
Operation.kt
/**
* Defines a potentially long-running operation that:
* 1) Must have a [Operation.Loading] state
* 2) Results in either [Operation.Ok] or [Operation.Error]
*
* _Example: A good use-case for an [Operation] is sending HTTP requests to a server._
*/
sealed interface Operation<out Err, out Data> {
/**
* Loading state.
*/
object Loading : Operation<Nothing, Nothing>
/**
* Success state with [Data].
*/
data class Ok<out Data>(val data: Data) : Operation<Nothing, Data>
/**
* Error state with [Err].
*/
data class Error<out Err>(val error: Err) : Operation<Err, Nothing>
}
现在我们有了我们需要的类型。让我们再定义两个辅助函数来映射(转换)Ok和Error可能派上用场的状态。
// sealed interface Operation {}
// ...
/**
* Transforms the [Operation.Ok] case using the [transform] lambda.
* [Operation.Loading] and [Operation.Error] remain unchanged.
* @param transform transformation (mapping) function for the [Operation.Ok]'s case.
* @return a new [Operation] with transformed [Operation.Ok] case.
*/
fun <E, D1, D2> Operation<E, D1>.mapSuccess(
transform: (D1) -> D2
): Operation<E, D2> = when (this) {
is Operation.Error -> this
is Operation.Loading -> this
is Operation.Ok -> Operation.Ok(
data = transform(this.data)
)
}
/**
* Transforms the [Operation.Error] case using the [transform] lambda.
* [Operation.Loading] and [Operation.Ok] remain unchanged.
* @param transform transformation (mapping) function for the [Operation.Error]'s case.
* @return a new [Operation] with transformed [Operation.Error] case.
*/
fun <E, E2, D> Operation<E, D>.mapError(
transform: (E) -> E2
): Operation<E2, D> = when (this) {
is Operation.Error -> Operation.Error(
error = transform(this.error)
)
is Operation.Loading -> this
is Operation.Ok -> this
}
同样,为什么只限于 Ktor HTTP 请求?我们可以轻松地使抽象适用于任何需要加载和错误状态的任意操作。
OperationFlow.kt
**
* Transforms a **potentially long-running operation that can result in [Either] success or error**
* to a [Flow]<[Operation]> that'll automatically emit [Operation.Loading] before the operation
* is started and provide an out of the box [OperationFlow.retry] capabilities.
*
* **Usage:**
* 1) Extend [OperationFlow].
* 2) Implement (override) [OperationFlow.operation] :: [Input] -> Either<[Err], [Data]>.
* 3) Call [OperationFlow.flow] ([Input]) to trigger a [Flow] of
* [Operation.Loading] -> [Operation.Ok]/[Operation.Error].
* 4) In case of an error, you can retry the [Operation] by calling [OperationFlow.retry].
*/
abstract class OperationFlow<in Input, out Err, out Data> {
protected abstract suspend fun operation(input: Input): Either<Err, Data>
/**
* Used to trigger the [operation] execution.
*/
private val triggerFlow = MutableSharedFlow<Unit>(
// trigger the first operation() execution when flow(Input) is called later
replay = 1,
)
init {
// trigger the first operation() execution when flow(Input) is called later
triggerFlow.tryEmit(Unit)
}
/**
* Creates a flow that'll immediately execute the [OperationFlow.operation].
* @param input input that will be supplied to the [OperationFlow.operation]
* @return a new [Flow]<[Operation]> with the supplied [Input].
*/
fun flow(input: Input): Flow<Operation<Err, Data>> = channelFlow {
// "channelFlow" because we may collect from different coroutines
triggerFlow.collectLatest {
send(Operation.Loading)
send(
when (val result = operation(input)) {
is Either.Left -> Operation.Error(result.value)
is Either.Right -> Operation.Ok(result.value)
}
)
}
}
/**
* Makes the [OperationFlow.flow] re-execute the operation with the last supplied [Input].
* If the [OperationFlow.flow] isn't collected, nothing will happen.
*/
suspend fun retry() {
triggerFlow.emit(Unit)
}
}
OperationFlow
将suspend fun f(i: Input): Either
输入 a在执行之前Flow
自动发出,然后根据结果发出或发出。Operation.LoadingfOperation.OkOperation.Error
Operation#retry()
提供将使用最后提供的输入重试操作的方法。在下一个最后一章中,我们将看到一些示例,说明我们如何使用迄今为止构建的所有内容…
事不宜迟,让我们进入代码!
GET 请求示例
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import arrow.core.Either
import com.flawlessrequests.network.Operation
import com.flawlessrequests.network.OperationFlow
import com.flawlessrequests.network.httpRequest
import com.google.gson.annotations.SerializedName
import dagger.hilt.android.lifecycle.HiltViewModel
import io.ktor.client.*
import io.ktor.client.request.*
import kotlinx.coroutines.launch
import javax.inject.Inject
object PeopleError // Domain error
data class Person( // Domain type
val names: String,
val age: Int,
)
data class PersonDto(
@SerializedName("first_name")
val firstName: String,
@SerializedName("last_name")
val lastName: String?,
@SerializedName("age")
val age: Int,
)
data class PeopleResponse(
@SerializedName("people")
val people: List<PersonDto>
)
class PeopleRequest @Inject constructor(
private val ktorClient: HttpClient,
) : OperationFlow<Unit, PeopleError, List<Person>>() {
override suspend fun operation(input: Unit): Either<PeopleError, List<Person>> =
httpRequest<PeopleResponse>(ktorClient) {
get("{PEOPLE_API_URL}")
}.mapLeft { httpError ->
PeopleError // map HttpError to domain error
}.map { response ->
// map Response (DTO) to domain
response.people.map { dto ->
Person(
names = listOfNotNull(dto.firstName, dto.lastName).joinToString(" "),
age = dto.age,
)
}
}
}
@HiltViewModel
class PeopleViewModel @Inject constructor(
private val peopleRequest: PeopleRequest
) : ViewModel() {
val opPeopleFlow = peopleRequest.flow(Unit)
fun retryPeopleRequest() {
viewModelScope.launch {
peopleRequest.retry()
}
}
}
@Composable
fun PeopleScreen() {
val viewModel: PeopleViewModel = viewModel()
val opPeople by viewModel.opPeopleFlow.collectAsState(Operation.Loading)
when (opPeople) {
is Operation.Error -> {
Button(onClick = {
viewModel.retryPeopleRequest()
}) {
Text(text = "Error! Tap to retry.")
}
}
Operation.Loading -> {
// Loading state
}
is Operation.Ok -> {
// Success state
}
}
}
注意:这不是编写ViewModels + Compose UI
的推荐方式。事实上,它很糟糕!但是,这是我们可以编写的最短代码来演示我们构建的内容。
Post请求示例
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import arrow.core.Either
import com.flawlessrequests.network.HttpError
import com.flawlessrequests.network.OperationFlow
import com.flawlessrequests.network.httpRequest
import com.google.gson.annotations.SerializedName
import dagger.hilt.android.lifecycle.HiltViewModel
import io.ktor.client.*
import io.ktor.client.request.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import javax.inject.Inject
data class Credentials(
val username: String,
val password: String,
)
data class AuthRequestBody(
@SerializedName("username")
val username: String,
@SerializedName("password")
val password: String,
)
data class AuthResponseDto(
@SerializedName("accessToken")
val accessToken: String,
)
class AuthenticationRequest @Inject constructor(
private val ktorClient: HttpClient
) : OperationFlow<Credentials, HttpError, AuthResponseDto>() {
override suspend fun operation(
input: Credentials
): Either<HttpError, AuthResponseDto> = httpRequest(ktorClient) {
post("{API URL GOES HERE") {
setBody(AuthRequestBody(input.username, input.password))
}
}
}
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authRequest: AuthenticationRequest
) : ViewModel() {
private val credentialsFlow = MutableStateFlow<Credentials?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
val authFlow = credentialsFlow.flatMapLatest { credentials ->
// the request will be send only when valid credentials are provided
if (credentials != null)
authRequest.flow(credentials) else flowOf(null)
}
fun retry() {
viewModelScope.launch {
// the request will be retried with the last non-null credentials if any
// else nothing will happen
authRequest.retry()
}
}
}
本文探讨了一直简洁的HTTP请求方式,当然没有最好的方案,只有适合自己的方案,如果你有更好的意见和建议,请在评论区留言。