前十二篇里,我们最终形成的异常链路大致是:

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

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

Logo

一站式 AI 云服务平台

更多推荐