本节摘要:null 与异常是"缺失"的两种表达——一个表达值的缺失,一个表达操作的失败。Kotlin 取消了受检异常,把"要不要处理失败"的选择权交给设计者;同时标准库提供 runCatching,把"可能抛异常的代码"收容为 Result 值。本节讲清两种错误风格的边界:异常风格适合意外失败,值风格(Result)适合业务可预期的失败,以及在安卓分层架构里如何切分。
Java 的受检异常(checked exception)要求方法签名声明可能抛出的异常类型,调用方必须 try-catch 或继续上抛。设计初衷是好的——失败信息进签名,编译器强制处理。但二十年实践下来,业界共识是它败了:
// Java:每个调用点被逼出要么处理要么上抛 public void loadUser() { try { User u = api.getUser(id); // throws IOException show(u); } catch (IOException e) { e.printStackTrace(); // 大多数人的"处理"长这样 } }
签名里挂着异常,调用方不想处理时只能写空 catch 或层层上抛,直到某个顶层 catch 统一吞掉——失败信息在签名里,处理质量却在人心里。C#、Scala、Kotlin 都拒绝了受检异常。Kotlin 的立场:所有异常都是非受检的,签名不标、编译器不逼;作为交换,库设计者必须更认真地思考失败的暴露方式——这正是 runCatching 与 Result 存在的理由。
try 语法本身保留,且 try 是表达式:
val port = try { prefs.getString("port")?.toInt() } catch (e: NumberFormatException) { 80 // 解析失败给默认端口 }
finally 依旧支持,try-with-resources 的对应物是 use 扩展(第 6 章会看到它如何挂在 Closeable 上)。基础语法一天就过完了,本节真正的主题是:失败应该以什么形态在代码里流动。
标准库函数 runCatching 执行一个代码块,把结果包成 Result<T>——成功是 Result.success(value),任何异常被捕获为 Result.failure(e):
val result: Result<User> = runCatching { api.getUser(id) } result .onSuccess { user -> render(user) } .onFailure { e -> log("获取用户失败", e) }
失败不再需要 catch 块接住,它成了一个值——可以被 map、可以缓存、可以放进集合、可以层层传递。几种常用拆箱方式:
val user: User? = result.getOrNull() // 失败得 null val user2: User = result.getOrElse { fallbackUser } // 失败得默认值 val user3: User = result.getOrThrow() // 失败重新抛出 result.exceptionOrNull() // 失败得异常本体
对照同业务的两种风格:
// 异常风格:失败靠 catch 结构传递 fun render() { val user = try { api.getUser(id) } catch (e: IOException) { showError(e.message ?: "网络异常") return } bind(user) } // 值风格:失败是数据,一路 map 到 UI fun render() { viewModelScope.launch { val state = runCatching { api.getUser(id) } .map { UiState.Data(it) as UiState } .recover { UiState.Error(it.message ?: "网络异常") } .getOrThrow() _uiState.value = state } }
第二种风格里,"失败"被转换成 UI 状态的正常数据流——这正是第 3 章密封类建模 UiState 与第 8 章 StateFlow 数据流的连接点。值风格的核心收益:错误处理从控制流的分叉变成数据的变换,而数据变换是可以组合、可以类型检查的。
协程场景下 runCatching 有个著名的坑,值得单独拎出来:
viewModelScope.launch { val result = runCatching { api.getUser(id) } // 危险 result.onSuccess { ... } }
如果 api.getUser 是挂起函数,用户退出页面时协程被取消,取消以 CancellationException 的形式抛出——而 runCatching 不挑食,把取消异常也装进盒子里。结果:协程没有按预期终止,onSuccess/onFailure 照常执行,页面已销毁还在更新状态。正确姿势是显式放行取消:
suspend fun <T> runSuspendCatching(block: suspend () -> T): Result<T> = try { Result.success(block()) } catch (e: CancellationException) { throw e // 取消不是失败,让它通过 } catch (e: Throwable) { Result.failure(e) }
这个例子说明本节的核心论点:值风格不是无条件更好,它要求你回答"什么算失败"。取消不是失败、中断不是失败,把它们装进 Result 盒子等于改变了系统的控制语义。第 5 章协程异常处理一节会回到这个话题,那里有更完整的传播规则。
安卓分层架构里的错误处理有一条清晰的分工线:
| 层 | 失败形态 | 处理方式 |
|---|---|---|
| 数据层(网络、数据库) | IOException、SQLiteException 等技术异常 | catch 或 runCatching 收容,转换为领域错误 |
| 领域层 | 业务规则失败(余额不足、参数非法) | 返回密封的结果类型,不抛异常 |
| UI 层 | 收到错误状态 | 渲染提示、埋点、重试入口 |
数据层收容技术异常的典型写法:
class OrderRepo(private val api: OrderApi) { suspend fun fetch(id: String): FetchResult = runSuspendCatching { api.getOrder(id) } .mapCatching { it.toDomain() } .fold( onSuccess = { FetchResult.Ok(it) }, onFailure = { FetchResult.Err(it.toUserMessage()) } ) } sealed interface FetchResult { data class Ok(val order: Order) : FetchResult data class Err(val message: String) : FetchResult }
技术异常在数据层死掉,换算成领域结果向上流动。UI 层永远不需要知道 IOException 的存在,它只认 FetchResult。对照常见的反面架构——异常一路抛到 Activity 的 onClick 里再 catch——分层收容的优势是:每层的错误语义稳定、可测试(mock 不需要模拟异常栈)、可读(错误转换逻辑集中在一处)。
领域层对"业务失败"的表态则更进一步:余额不足不是异常,是正常业务的一个分支,用返回值表达(密封类型),用异常表达就浪费了异常的"栈展开"成本与语义重量。判断标准:调用方是否总能预期这种失败并需要针对性处理——是,返回值;否(真正的意外,如磁盘满、内存耗尽),异常。
无论哪种风格,有一条底线纪律:失败必须留下痕迹。安卓上最恶劣的写法是空 catch——崩溃没有了,问题也没有了,只剩线上数据悄悄不对:
try { tracker.report(event) } catch (e: Exception) { // 埋点失败无所谓?也许。但至少记一行日志 }
即便"失败无所谓",也建议 Log.w 一行。全局兜底则交给 Thread 的未捕获异常处理器与崩溃平台(第 8 章静态防线一节会配置)。另一个方向的反面教材是 catch Exception——过宽的捕获把本该暴露的编程错误(数组越界、类型转换失败)也吞了,调试时凭空消失的 bug 多半源于此。捕获类型永远从窄到宽:先具体异常,再 Exception,每放宽一级都要有理由。
值风格把异常装进盒子后,"失败去哪了"需要重新设计。一个可落地的分层方案:数据层的收容点记调试日志(开发期定位用,级别 debug,带上下文字段);领域层不记日志(纯函数,失败以返回值表达,记录是调用方的责任);UI 层的消费点记用户可见的后果(埋点、崩溃平台的非致命上报)。全局兜底交给未捕获异常处理器:它接住的是"设计上不该发生的异常",上报崩溃平台;而 Result 盒子里的失败是"设计内的失败",走业务监控(成功率的分母)。
这个分层的价值在事故复盘时显现:设计内失败看业务大盘(哪个接口失败率涨了),设计外失败看崩溃榜(哪里还有漏网的 !! 或边界缺口)。两种数字混在一个桶里,团队就会用修崩溃的方式对待业务失败——加 try 加吞,越修越糟。
问:Result 到处传,最后总得有个地方"打开盒子",在哪打开合适?
打开盒子的位置就是做决策的位置。UI 需要区分成功失败来渲染——在 ViewModel 的状态转换处打开(fold 成密封状态);重试策略需要判断失败类型——在重试调度处打开;日志需要失败详情——在边界收容点顺手记录。反模式是在数据层深处就把盒子拆掉抛回异常,或在 UI 层层层透传 Result 让每个 Activity 都写一遍 fold。判断标准:谁消费失败的后果,谁打开盒子;中间层只做 map 与传递,让失败以值的形式安全旅行。
问:Kotlin 没有受检异常,团队怎么防止"忘了处理失败"?
三道手段按成本递增。其一,签名表达失败:可能失败的函数返回密封结果类型而不是裸抛,调用方拿到 FetchResult 就必须面对两个分支(配合第 3 章穷举检查,漏处理编译不过)——这是最根本的手段,把"记得处理"变成类型义务。其二,静态检查:Detekt 的 SwallowedException 与 TooGenericExceptionCaught 拦截空 catch 与过宽捕获。其三,评审清单:涉及 IO 与解析的函数签名必须显式声明失败形态。受检异常的失败在于它强迫所有人处理所有异常(包括处理不了的),值风格的失败在于自由(包括忘记处理)——密封结果类型是两者的折中:失败进类型、处理靠穷举、粒度由设计者定制。
问:runCatching 和 coroutineScope 里直接 try-catch,风格上选哪个?
看失败的去向。失败要变成数据(状态、缓存降级、重试判断)用 runCatching 加 map 与 fold;失败要中断当前控制流(参数非法、前置条件不满足)用 throw 或 try-catch 保留异常路径。一个实用的嗅觉测试:如果 catch 块里做的事是"构造一个新对象",多半是值风格场景;如果是"清理然后返回或重抛",是异常风格场景。两种风格在同一工程共存没问题,但同一条调用链上不要混用——中途切换风格是最容易出错的写法。
值的缺失、初始化的空窗、操作的失败——第二章把三种"缺失"全部纳入类型系统的管理。下一章进入第二根支柱:密封类如何让"分支遗漏"从静默错误变成编译失败。