错误处理与异常边界:Result类型、密封类与全局异常捕获

错误处理这件事,说实话,是很多Android项目里最容易被忽视的环节。我见过太多项目,代码里全是try-catch,catch块里就一行Log.e,然后啥也不干。这种写法,说白了就是掩耳盗铃——异常是捕获了,但问题根本没解决。

今天我们来聊聊,在MVVM + Clean Architecture的架构下,怎么把错误处理做得优雅、可维护。我会结合我自己的踩坑经历,把Result类型、密封类、全局异常捕获这几个点讲透。

为什么传统的try-catch不够用?

先问一个问题:你在ViewModel里调用一个Repository方法,如果网络请求失败了,你怎么告诉UI层?

最直接的做法是抛异常。但你想过没有,异常在Kotlin里是未检查异常,编译器不会强制你处理。一旦你忘了catch,App就崩了。我在项目中遇到过好几次,因为某个网络请求的异常没被捕获,导致整个应用闪退。用户反馈说「点了一下就闪退」,查了半天才发现是某个API返回了500,但代码里没处理。

另一个问题是,异常不适合携带「业务语义」。比如「用户不存在」和「网络超时」,虽然都是异常,但前者是业务错误,后者是系统错误。用同一个Exception类去表示,代码会变得很难看。

Result类型:把错误变成数据

我个人习惯的做法是,用Result类型来包装返回值。Kotlin标准库其实已经提供了Result类,但它有一些限制——比如不能作为sealed class的子类型。所以我更推荐自己定义一个。

sealed class Result<out T> {
    data class Success<out T>(val data: T) : Result<T>()
    data class Error(val exception: Throwable, val message: String? = null) : Result<Nothing>()
}

你看,这样定义之后,Repository的每个方法都返回Result。调用方必须用when表达式来处理两种结果,编译器会强制你覆盖所有分支。这就从根本上避免了「忘记处理异常」的问题。

举个例子,我在一个用户登录的Repository里是这样写的:

class UserRepositoryImpl(private val api: UserApi) : UserRepository {
    override suspend fun login(username: String, password: String): Result<User> {
        return try {
            val response = api.login(LoginRequest(username, password))
            if (response.isSuccessful) {
                Result.Success(response.body()!!)
            } else {
                Result.Error(
                    exception = HttpException(response),
                    message = "登录失败:${response.code()}"
                )
            }
        } catch (e: Exception) {
            Result.Error(e, "网络异常,请稍后重试")
        }
    }
}

这里有个细节:我把HTTP错误码也封装成了Error,而不是抛异常。为什么?因为HTTP 401、403这些状态码,本质上也是业务逻辑的一部分。把它们当作异常处理,会让ViewModel的代码变得混乱。

密封类:让错误类型更清晰

Result.Error只带一个Throwable,有时候不够用。比如你想区分「网络错误」「业务错误」「未知错误」,怎么办?

这时候密封类就派上用场了。我一般会定义一个更细粒度的错误类型:

sealed class AppError : Throwable() {
    data class NetworkError(val cause: Throwable) : AppError() {
        override val message: String get() = "网络连接失败,请检查网络设置"
    }
    
    data class BusinessError(val code: Int, override val message: String) : AppError()
    
    data class UnknownError(val cause: Throwable) : AppError() {
        override val message: String get() = "未知错误,请稍后重试"
    }
}

然后在Repository里,把具体的异常映射成AppError:

override suspend fun fetchData(): Result<Data> {
    return try {
        val data = api.getData()
        Result.Success(data)
    } catch (e: UnknownHostException) {
        Result.Error(AppError.NetworkError(e))
    } catch (e: HttpException) {
        Result.Error(AppError.BusinessError(e.code(), e.message()))
    } catch (e: Exception) {
        Result.Error(AppError.UnknownError(e))
    }
}

这样做的好处是,ViewModel里可以根据不同的错误类型,展示不同的UI。比如网络错误弹一个「重试」按钮,业务错误弹一个「我知道了」的提示框。你想想看,如果所有错误都用一个Exception,UI层就得用instanceof去判断,那代码得多难看。

全局异常捕获:兜底方案

即使我们用了Result,也难免有漏网之鱼。比如某个协程里忘了调用try-catch,或者第三方SDK内部抛了异常。这时候就需要一个全局的异常捕获机制。

Android提供了Thread.setDefaultUncaughtExceptionHandler,但这个方法只能捕获主线程的异常。对于协程里的异常,我们需要用CoroutineExceptionHandler。

我一般在Application里这样配置:

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        
        // 主线程异常捕获
        Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
            Log.e("GlobalException", "未捕获异常:${throwable.message}")
            // 上报到Bugly或Firebase
            reportCrash(throwable)
            // 可以在这里做兜底处理,比如重启App
        }
        
        // 协程全局异常处理器
        val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
            Log.e("CoroutineException", "协程异常:${throwable.message}")
            reportCrash(throwable)
        }
        
        // 设置到全局Scope
        GlobalScope.coroutineContext + coroutineExceptionHandler
    }
}

嗯,这里要注意:CoroutineExceptionHandler只对「未捕获的异常」生效。如果你在协程里用了try-catch,异常就不会走到这里。所以它只是一个兜底方案,不能替代正常的错误处理。

在ViewModel层统一处理错误

有了Result和AppError,ViewModel层的代码可以写得很干净。我习惯用一个BaseViewModel,里面封装一个通用的错误处理方法:

abstract class BaseViewModel : ViewModel() {
    protected fun <T> launchWithResult(
        block: suspend () -> Result<T>,
        onSuccess: (T) -> Unit,
        onError: ((AppError) -> Unit)? = null
    ) {
        viewModelScope.launch {
            val result = block()
            when (result) {
                is Result.Success -> onSuccess(result.data)
                is Result.Error -> {
                    val error = result.exception
                    if (error is AppError) {
                        onError?.invoke(error) ?: handleDefaultError(error)
                    } else {
                        handleDefaultError(AppError.UnknownError(error))
                    }
                }
            }
        }
    }
    
    private fun handleDefaultError(error: AppError) {
        // 默认错误处理:弹Toast或者Snackbar
        _errorEvent.postValue(error)
    }
}

这样,每个具体的ViewModel只需要关注成功逻辑,错误处理交给基类。如果某个页面需要特殊处理,可以传入onError回调覆盖默认行为。

避坑指南:我曾经踩过的坑

我曾经在一个项目里,把所有网络请求的异常都统一catch成Exception,然后返回Result.Error。结果上线后发现,有些错误信息根本不对——比如明明是401未授权,却显示「网络异常」。后来排查才发现,Retrofit的HttpException被catch后,它的message是空的,导致错误信息丢失。

所以我的建议是:在Repository层,尽量保留原始异常的信息。如果要做转换,一定要把原始异常作为cause传进去。这样在调试时,你可以通过cause追溯到最根本的原因。

另一个坑是:不要把Result.Error里的exception直接传给UI层。UI层不应该知道具体的异常类型,它只需要知道「发生了什么类型的错误」。所以用密封类AppError做一层抽象,是非常有必要的。

总结一下核心思路

  • 用Result替代try-catch:把错误变成返回值,编译器强制处理
  • 用密封类定义错误类型:让错误携带业务语义,UI层可以精准响应
  • 全局异常捕获做兜底:防止漏网之鱼导致App崩溃
  • ViewModel层统一处理:减少重复代码,保持架构清晰

最后放一张图,帮你理清整个错误处理的流程:

错误处理与异常边界流程图 数据来源(API/DB) Repository 层 try-catch → 映射为 Result<T> 或 AppError ViewModel 层 when(Result) → 区分 Success / Error Success → 更新UI状态 Error → 展示错误提示 全局异常捕获(兜底)

这张图展示了从数据来源到UI层的完整错误处理链路。核心思想就是:在Repository层把异常转化为数据,在ViewModel层根据数据类型做决策,全局异常捕获只作为最后的防线

核心原则:错误处理不是「出了问题怎么办」,而是「如何让错误变得可预测、可管理」。用Result和密封类,就是把不可控的异常,变成可控的数据流。

小技巧:如果你用Kotlin Flow,可以把Result包装在Flow里。这样整个数据流都是类型安全的,错误处理也统一了。

注意:不要滥用全局异常捕获。它只应该处理「意料之外」的异常。对于业务错误(比如密码错误),一定要通过Result返回,而不是靠全局捕获去兜底。

好了,这一章的内容就到这里。错误处理看似简单,但做好了对项目的稳定性提升很大。下一章我们会聊聊依赖注入,看看怎么用Hilt把各个模块优雅地组装起来。


公众号:蓝海资料掘金营,微信deep3321