(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

这样职责最清楚。


三十二、本篇总结

throwAppResult<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

从下一篇开始,进阶系列会逐渐进入真正的复杂工程场景。

相关推荐
网安蟹佬霸1 小时前
区块链与智能合约安全实战:从Solidity审计到DeFi漏洞深度剖析
运维·前端·网络·安全·自动化·区块链·智能合约
今夕资源网2 小时前
NapCatQQ:把最难接的 QQ 协议层,变成一组能直接调用的接口
网络·qq·napcat·napcatqq·q协议·qq协议
GuoFeng.Wan2 小时前
深入理解经典蓝牙的channel
linux·服务器·网络
影寂ldy3 小时前
C# WinForm TCP-Socket
网络·tcp/ip·c#
周周哈哈哈3 小时前
网络间通信---TCP
网络
程序猿乐锅3 小时前
从 dsh 源码看「一切皆插件」与 Spring IoC
java·网络·数据库·人工智能·后端·spring
艾醒(AiXing-w)3 小时前
LangChain 1.0 入门(五):提示词工程、partial变量、ChatPromptTemplate、Hub模板库
服务器·网络·langchain
wno7044 小时前
nginx反向代理设置ssl,支持https
运维·服务器·网络
FreeTinker4 小时前
联想个人云存储:大厂入局NAS赛道,体验如何?
网络