(KMP-Net进阶)第一篇:AppResult<T>——网络层到底应该 throw,还是返回统一 Result?
前十二篇里,我们最终形成的异常链路大致是:
Network / Timeout / HTTP / Parse / Business
↓
Throwable
↓
ExceptionMapper
↓
AppError
↓
AppException
↓
throw
于是 ApiService 可以保持非常干净:
suspend fun getUser(): User {
return networkClient.get(
path = "/user"
)
}
失败时:
getUser()
↓
throw AppException
但是实际项目里还有另一种非常常见的方案:
suspend fun getUser(): AppResult<User>
成功:
AppResult.Success(User)
失败:
AppResult.Failure(AppError.Network)
于是就出现了一个非常经典的问题:
网络层到底应该直接 throw,还是应该把成功和失败统一包装成 AppResult**<T>****?**
这一篇就把这个问题讲清楚。
一、先看两种设计到底有什么区别
方案 A:Exception 模型
suspend fun getUser(): User
成功:
return User
失败:
throw AppException
调用:
try {
val user =
repository.getUser()
} catch (
exception: AppException
) {
when (
exception.error
) {
AppError.Network -> {
...
}
AppError.Timeout -> {
...
}
else -> {
...
}
}
}
它的模型是:
Success
↓
正常返回 T
Failure
↓
异常通道 throw
方案 B:Result 模型
suspend fun getUser():
AppResult<User>
成功:
AppResult.Success(
user
)
失败:
AppResult.Failure(
AppError.Network
)
调用:
when (
val result =
repository.getUser()
) {
is AppResult.Success -> {
val user =
result.data
}
is AppResult.Failure -> {
when (
result.error
) {
...
}
}
}
它的模型变成:
Success ─┐
├→ 返回值
Failure ─┘
所以最本质的区别是:
throw 模型把失败放在异常通道;AppResult 模型把失败也变成函数返回值的一部分。
二、先不要急着站队
很多文章会直接说:
Exception 不好
Result 更高级
或者:
Kotlin 本来就支持 Exception
Result 是过度封装
其实都太绝对。
真正应该问的是:
失败到底属于哪一层的语义?
例如:
SocketTimeoutException
SerializationException
IOException
明显是:
技术异常
而:
网络不可用
登录失效
订单余额不足
库存不足
已经逐渐变成:
应用可以理解的失败状态
所以这一篇真正讨论的是:
Throwable
↓
AppError
↓
到底什么时候从“异常”
变成“返回值”?
三、先看我们现在的 Exception 模型
前面 NetworkClient 大概是:
suspend inline fun <
reified T
> execute(
request:
() -> HttpResponse,
): T {
return try {
val response =
request()
val result =
response
.body<
ApiResponse<T>
>()
if (
result.code != 0
) {
throw ApiException(
code =
result.code,
message =
result.msg,
)
}
result.data
?: throw ApiException(
code = -1,
message =
"data is null",
)
} catch (
cancellation:
CancellationException
) {
throw cancellation
} catch (
throwable:
Throwable
) {
throw AppException(
exceptionMapper.map(
throwable
)
)
}
}
这样调用:
NetworkClient
↓
成功
↓
T
失败:
NetworkClient
↓
Throwable
↓
ExceptionMapper
↓
AppError
↓
AppException
↓
throw
这个设计其实完全没有问题。
四、throw 模型最大的优点:函数非常自然
例如:
val user =
userApiService
.getUser()
拿到的就是:
User
而不是:
AppResult<User>
所以多个调用组合起来特别自然:
val user =
userApiService
.getUser()
val orders =
orderApiService
.getOrders(
user.id
)
val profile =
buildProfile(
user = user,
orders = orders,
)
流程:
getUser
↓
成功
↓
getOrders
↓
成功
↓
buildProfile
任何一步失败:
throw
↓
直接跳出当前流程
非常符合普通 Kotlin 控制流。
五、Result 模型组合起来会复杂一点
如果:
getUser():
AppResult<User>
然后:
getOrders():
AppResult<List<Order>>
可能出现:
when (
val userResult =
getUser()
) {
is AppResult.Success -> {
when (
val orderResult =
getOrders(
userResult
.data
.id
)
) {
is AppResult.Success -> {
...
}
is AppResult.Failure -> {
...
}
}
}
is AppResult.Failure -> {
...
}
}
马上开始:
Result
↓
Result
↓
Result
↓
嵌套
当然我们可以自己写:
map
flatMap
fold
onSuccess
onFailure
改善它。
但这也说明:
AppResult**<T>**** 并不是没有成本。**
六、那为什么还要 AppResult<T>?
因为它有一个非常明显的优势:
函数签名直接告诉调用者:这件事存在可预期失败。
例如:
suspend fun getUser():
AppResult<User>
看到签名就知道:
成功
+
失败
都需要处理。
而:
suspend fun getUser():
User
光看函数签名:
看不出来它会抛什么
Kotlin 的异常默认是 unchecked,不要求函数签名声明 throws。
所以:
throw
的错误信息更多是:
隐式协议
而:
AppResult<T>
把它变成:
显式类型
七、定义自己的 AppResult<T>
这里我不建议直接把:
kotlin.Result<T>
和我们的:
AppError
混为一谈。
项目可以自己定义:
sealed interface AppResult<
out T
> {
data class Success<T>(
val data: T,
) : AppResult<T>
data class Failure(
val error: AppError,
) : AppResult<Nothing>
}
于是:
Success
↓
业务数据 T
Failure
↓
项目语义 AppError
这和我们前面的:
AppError.Network
AppError.Timeout
AppError.Unauthorized
AppError.Server
AppError.Parse
AppError.Business
AppError.Unknown
刚好形成完整体系。
八、为什么不直接使用 Kotlin Result<T>?
Kotlin 本身有:
Result<T>
例如:
Result.success(
user
)
以及:
Result.failure(
throwable
)
它非常适合:
T
+
Throwable
这种模型。
但我们前面已经做了一件很重要的事:
Throwable
↓
ExceptionMapper
↓
AppError
也就是说,上层真正想处理的已经不是:
SocketTimeoutException
ConnectTimeoutException
SerializationException
而是:
AppError.Timeout
AppError.Network
AppError.Parse
所以如果项目已经建立:
AppError
自己的:
AppResult<T>
通常表达力更直接。
九、一个完整 AppResult<T>
例如:
sealed interface AppResult<
out T
> {
data class Success<T>(
val data: T,
) : AppResult<T>
data class Failure(
val error: AppError,
) : AppResult<Nothing>
}
然后可以增加几个基础扩展:
inline fun <
T,
R
> AppResult<T>.map(
transform:
(T) -> R,
): AppResult<R> {
return when (this) {
is AppResult.Success -> {
AppResult.Success(
transform(
data
)
)
}
is AppResult.Failure -> {
this
}
}
}
还可以:
inline fun <
T,
R
> AppResult<T>.fold(
onSuccess:
(T) -> R,
onFailure:
(AppError) -> R,
): R {
return when (this) {
is AppResult.Success ->
onSuccess(
data
)
is AppResult.Failure ->
onFailure(
error
)
}
}
这样 UI:
repository
.getUser()
.fold(
onSuccess = { user ->
...
},
onFailure = { error ->
...
},
)
就舒服很多。
十、那 NetworkClient 是否应该直接返回 AppResult<T>?
第一种方案就是:
suspend inline fun <
reified T
> get(
path: String,
): AppResult<T>
内部:
return try {
val result =
client.get(path)
.body<
ApiResponse<T>
>()
if (
result.code != 0
) {
AppResult.Failure(
AppError.Business(
code =
result.code,
message =
result.msg,
)
)
} else {
AppResult.Success(
result.data
?: error(
"data is null"
)
)
}
} catch (
cancellation:
CancellationException
) {
throw cancellation
} catch (
throwable:
Throwable
) {
AppResult.Failure(
exceptionMapper.map(
throwable
)
)
}
于是:
NetworkClient
↓
永远返回 AppResult<T>
看起来很统一。
但我并不是最推荐这种方式。
十一、为什么我不太建议最底层 NetworkClient 全部 Result 化?
因为:
HttpClient
NetworkClient
ApiService
本质还是:
执行网络操作
而网络库、序列化、Coroutine 本身大量使用的就是:
Exception
失败传播模型。
如果 NetworkClient 很早就把所有东西:
Throwable
↓
AppResult.Failure
那么后面每一层都会开始携带:
AppResult<T>
例如:
NetworkClient
↓
AppResult<ApiResponse>
ApiService
↓
AppResult<User>
Repository
↓
AppResult<User>
UseCase
↓
AppResult<User>
ViewModel
↓
AppResult<User>
最后:
Result 开始污染整个调用链。
十二、一个非常关键的问题:CancellationException
这是采用 Result 模型最容易踩的坑之一。
很多人会写:
return runCatching {
client.get(...)
}
看起来特别漂亮。
但是:
runCatching
会捕获 Throwable。
而 Coroutine 的取消本身依赖:
CancellationException
传播取消语义。
Kotlin Coroutine 官方文档明确说明,协程取消会通过 CancellationException 在挂起点传播;结构化并发依赖这种取消传播。
所以如果:
CancellationException
↓
被你包装成
AppResult.Failure
那么:
页面退出
↓
Coroutine Cancel
可能被错误理解成:
网络请求失败
这就破坏了协程取消语义。
十三、所以 CancellationException 永远要特别处理
正确:
try {
...
} catch (
cancellation:
CancellationException
) {
throw cancellation
} catch (
throwable:
Throwable
) {
...
}
原则:
Cancellation 不是业务失败,也不是网络失败,它是 Coroutine 生命周期控制信号。
这点不管:
throw 模型
还是:
AppResult 模型
都必须遵守。
十四、我更推荐一种“混合模型”
我更喜欢把两套模式放在不同边界。
底层:
HttpClient
↓
NetworkClient
↓
ApiService
仍然保持:
成功返回 T
失败 throw
例如:
suspend fun getUser():
User
而:
Repository
或者:
UseCase 对外边界
再决定是否:
T
↓
AppResult<T>
也就是:
底层
↓
Exception Model
业务边界
↓
Result Model
十五、完整架构可以变成这样
HttpClient
↓
Throwable
↓
NetworkClient
↓
ExceptionMapper
↓
AppException(AppError)
↓
ApiService
↓
Repository
↓
safeCall
↓
AppResult<T>
↓
ViewModel
成功:
User
↓
AppResult.Success(User)
失败:
AppException
↓
AppResult.Failure(AppError)
Cancellation:
CancellationException
↓
继续 throw
这样两种模式各自在最合适的层级工作。
十六、写一个 safeCall()
例如:
suspend inline fun <
T
> safeCall(
crossinline block:
suspend () -> T,
): AppResult<T> {
return try {
AppResult.Success(
block()
)
} catch (
cancellation:
CancellationException
) {
throw cancellation
} catch (
exception:
AppException
) {
AppResult.Failure(
exception.error
)
}
}
于是 Repository:
class UserRepository(
private val api:
UserApiService,
) {
suspend fun getUser():
AppResult<User> {
return safeCall {
api.getUser()
}
}
}
非常清楚。
十七、完整生命周期
底层:
Ktor
↓
SocketTimeoutException
NetworkClient:
SocketTimeoutException
↓
ExceptionMapper
↓
AppError.Timeout
↓
AppException
ApiService:
throw AppException
Repository:
safeCall
↓
catch AppException
↓
AppResult.Failure(
AppError.Timeout
)
ViewModel:
AppResult.Failure
↓
处理 Timeout
所以:
技术异常
↓
项目异常语义
↓
业务结果语义
三个阶段被分开了。
十八、为什么我更喜欢在 Repository 做转换?
因为 Repository 本来就是:
数据层
↓
业务层
之间的重要边界。
它可能同时处理:
Remote
Local
Cache
Database
例如:
suspend fun getUser():
AppResult<User> {
return safeCall {
val remote =
userApiService
.getUser()
userLocalDataSource
.saveUser(
remote
)
remote
}
}
这里:
网络失败
数据库失败
缓存失败
最终都可以转换成:
业务层可理解的 Result
所以 Result 并不一定应该绑定:
NetworkClient
它更适合:
数据层对业务层的契约。
十九、但是 Repository 一定要返回 AppResult 吗?
也不是。
例如很简单的 App:
Repository
↓
suspend fun getUser(): User
然后 ViewModel:
viewModelScope.launch {
try {
val user =
repository
.getUser()
} catch (
exception:
AppException
) {
handleError(
exception.error
)
}
}
完全可以。
所以:
AppResult**<T>**** 不是架构成熟度的标志。**
不是用了:
sealed class Result
项目就自动高级。
二十、什么时候 throw 更适合?
如果调用链:
简单
而且:
异常处理已经高度统一
例如:
ViewModel
↓
统一 launchSafely
那么:
suspend fun getUser(): User
可能比:
suspend fun getUser():
AppResult<User>
更简洁。
尤其复杂串行逻辑:
A
↓
B
↓
C
↓
D
throw 的传播非常自然。
二十一、什么时候 AppResult 更适合?
如果业务层经常需要显式区分:
Success
Network Failure
Unauthorized
Business Failure
而且调用方必须针对不同失败做不同决策:
成功
↓
进入页面
网络失败
↓
展示重试
登录失效
↓
跳登录
业务失败
↓
展示业务提示
那么:
AppResult<T>
会更加明确。
尤其 Repository 作为公共数据接口时:
suspend fun getUser():
AppResult<User>
调用者一眼就知道:
这个操作存在需要处理的失败分支
二十二、还有一种情况特别适合 Result:业务失败本来就是“正常结果”
例如支付:
支付成功
余额不足
银行卡受限
需要验证码
这些未必应该都理解成:
Exception
因为它们本身就是业务流程的一部分。
例如可以设计:
sealed interface PaymentResult {
data class Success(
val orderId: String,
) : PaymentResult
data object InsufficientBalance :
PaymentResult
data object NeedVerification :
PaymentResult
}
这时候:
Result / sealed state
明显比:
throw InsufficientBalanceException
更加自然。
所以还要区分:
异常失败
和:
业务状态
二十三、不要把所有 Business Error 都机械包装成 Exception
例如:
code = 1001
余额不足
如果在某个项目里它只是:
真正异常
可以:
AppError.Business
但如果它属于:
支付流程的正常分支
更合理的做法可能是:
ApiResponse
↓
Repository / UseCase
↓
PaymentResult.InsufficientBalance
而不是:
throw
↓
catch
↓
再转状态
这已经开始进入:
技术错误和业务状态的边界设计。
二十四、Flow 又应该怎么办?
例如 Repository:
fun observeUser():
Flow<User>
Flow 本身就有:
正常 emit
+
Exception
机制。
例如:
userRepository
.observeUser()
.catch { throwable ->
...
}
.collect { user ->
...
}
如果又变成:
Flow<
AppResult<User>
>
就会出现:
Flow 自己有异常通道
+
AppResult 又有失败通道
于是两个错误模型同时存在。
所以:
Flow<AppResult<T>>
不是绝对不能用,
但一定要明确:
哪些错误进入 Flow exception,哪些错误进入 AppResult。
否则非常容易混乱。
二十五、不要出现“双重 Result”
例如:
Result<
AppResult<User>
>
或者:
Flow<
Result<
AppResult<User>
>
>
基本已经说明:
错误边界没有设计清楚
每一层都担心下一层出错,于是:
包一层
↓
再包一层
↓
再包一层
这是 Result 架构最容易出现的问题。
二十六、AppResult 应该放在哪一层?
我更推荐:
HttpClient
↓
Exception
NetworkClient
↓
ExceptionMapper
↓
AppException(AppError)
ApiService
↓
T / throw
Repository
↓
可选:
AppResult<T>
ViewModel
↓
UI State
继续往上:
AppResult<T>
通常还会转成:
UiState
例如:
sealed interface UserUiState {
data object Loading :
UserUiState
data class Success(
val user: User,
) : UserUiState
data class Error(
val error: AppError,
) : UserUiState
}
于是:
Network Throwable
↓
AppError
↓
AppResult
↓
UiState
每一层表达自己关心的语义。
二十七、为什么不要让 NetworkClient 返回 UiState?
因为:
Loading
Toast
Dialog
Retry Button
属于:
UI
NetworkClient 不应该知道。
所以:
NetworkClient
↓
AppError
已经足够。
不要:
NetworkClient
↓
UiState.Error(
message = "网络不好,请重试"
)
网络层应该描述:
发生了什么
而不是:
UI 怎么展示
二十八、我更推荐的最终方案
结合前十二篇的架构,我建议:
底层网络
↓
throw
项目技术错误
↓
AppError
Repository 对业务层
↓
根据项目复杂度决定
是否使用 AppResult<T>
UI
↓
UiState
完整模型:
Ktor Exception
↓
ExceptionMapper
↓
AppError
↓
AppException
↓
ApiService
↓
Repository
↓
safeCall
↓
AppResult<T>
↓
ViewModel
↓
UiState
二十九、最终 AppResult 代码
sealed interface AppResult<
out T
> {
data class Success<T>(
val data: T,
) : AppResult<T>
data class Failure(
val error: AppError,
) : AppResult<Nothing>
}
safeCall:
suspend inline fun <
T
> safeCall(
crossinline block:
suspend () -> T,
): AppResult<T> {
return try {
AppResult.Success(
block()
)
} catch (
cancellation:
CancellationException
) {
throw cancellation
} catch (
exception:
AppException
) {
AppResult.Failure(
exception.error
)
}
}
Repository:
class UserRepository(
private val api:
UserApiService,
) {
suspend fun getUser():
AppResult<User> {
return safeCall {
api.getUser()
}
}
}
ViewModel:
viewModelScope.launch {
when (
val result =
repository.getUser()
) {
is AppResult.Success -> {
_state.value =
UserUiState.Success(
result.data
)
}
is AppResult.Failure -> {
_state.value =
UserUiState.Error(
result.error
)
}
}
}
这样整条链非常明确。
三十、最终到底选哪个?
可以直接用这个判断:
NetworkClient
ApiService
内部网络调用
↓
优先 T + throw
因为:
组合自然
Coroutine 语义自然
Ktor 异常模型自然
到了:
Repository / UseCase
↓
业务边界
再问:
上层是否需要显式看到失败类型?
如果:
否
↓
继续 throw
如果:
是
↓
AppResult<T>
三十一、不要为了统一而统一
不要规定:
整个项目所有 suspend
必须返回 AppResult<T>
也不要规定:
整个项目永远只能 throw
真正合理的是:
不同层
↓
使用最适合自己的失败表达方式
例如:
底层技术失败
↓
Exception
项目错误语义
↓
AppError
数据层对外契约
↓
AppResult<T>
复杂业务状态
↓
Domain Result
UI
↓
UiState
这样职责最清楚。
三十二、本篇总结
throw 和 AppResult<T> 并不存在:
谁先进
谁落后
的关系。
它们解决的是不同问题。
throw:
成功
↓
正常返回 T
失败
↓
异常通道
优点:
代码自然
串行组合简单
和 Coroutine / Ktor 机制一致
AppResult<T>:
成功
+
失败
↓
都成为返回值
优点:
函数契约明确
上层必须显式处理
可以直接携带 AppError
结合目前这套 KMP + Ktor 架构,我更推荐:
HttpClient
↓
NetworkClient
↓
ApiService
↓
T + throw
然后:
Repository / UseCase
↓
根据业务需要
↓
AppResult<T>
最终:
ViewModel
↓
UiState
形成:
Throwable
↓
AppError
↓
AppResult
↓
UiState
每一层只处理自己应该理解的错误语义。
最后还有一条非常重要:
不管使用 throw 还是 Result,都不要吞掉 CancellationException。
Coroutine 的取消依赖 CancellationException 传播,它属于协程生命周期控制,而不是普通业务失败。
所以这一篇真正应该记住的不是:
“Result 比 Exception 好。”
而是:
Exception 适合传播技术失败,AppError 负责统一项目错误语义,AppResult**<T>**** 则可以在合适的业务边界,把失败显式变成返回值。**
下一篇
进阶第二篇:《Ktor Multipart 文件上传:从普通 POST 到 multipart/form-data》
下一篇开始进入真正的数据传输场景:
普通 JSON POST
↓
setBody(DTO)
和
文件上传
↓
multipart/form-data
到底有什么区别?
并继续解决:
单文件上传
多文件上传
文件 + 普通参数
文件 + JSON
ByteArray / ByteReadChannel
commonMain 为什么不能直接依赖 java.io.File
Android / iOS 文件如何统一抽象
上传 Progress 应该放在哪里
上传接口还能不能继续复用 NetworkClient
从下一篇开始,进阶系列会逐渐进入真正的复杂工程场景。
更多推荐


所有评论(0)