2.3 异常收容:try与runCatching


2.3 异常收容:try 与 runCatching

本节摘要:null 与异常是"缺失"的两种表达——一个表达值的缺失,一个表达操作的失败。Kotlin 取消了受检异常,把"要不要处理失败"的选择权交给设计者;同时标准库提供 runCatching,把"可能抛异常的代码"收容为 Result 值。本节讲清两种错误风格的边界:异常风格适合意外失败,值风格(Result)适合业务可预期的失败,以及在安卓分层架构里如何切分。

先看 Kotlin 删掉了什么

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:把异常装进盒子里

标准库函数 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 会吞掉 CancellationException

协程场景下 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 不需要模拟异常栈)、可读(错误转换逻辑集中在一处)。

领域层对"业务失败"的表态则更进一步:余额不足不是异常,是正常业务的一个分支,用返回值表达(密封类型),用异常表达就浪费了异常的"栈展开"成本与语义重量。判断标准:调用方是否总能预期这种失败并需要针对性处理——是,返回值;否(真正的意外,如磁盘满、内存耗尽),异常。

try 还是 Result:决策图

异常的另一半:不要静默吞掉

无论哪种风格,有一条底线纪律:失败必须留下痕迹。安卓上最恶劣的写法是空 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 块里做的事是"构造一个新对象",多半是值风格场景;如果是"清理然后返回或重抛",是异常风格场景。两种风格在同一工程共存没问题,但同一条调用链上不要混用——中途切换风格是最容易出错的写法。

本节要点回顾

  • Kotlin 无受检异常:签名不标异常,编译器不逼处理,失败暴露方式的责任移交给 API 设计者;
  • try 是表达式:可直接产出值,配合 finally 与 use 扩展覆盖 Java 的资源管理场景;
  • runCatching 把异常变成值:Result 支持 map、recover、fold,错误处理从控制流分叉变成数据变换;
  • 协程里慎用裸 runCatching:CancellationException 被装盒会破坏取消语义,收容前先放行取消;
  • 分层收容:技术异常死在数据层、业务失败用返回值、UI 只认错误状态——每层错误语义稳定可测;
  • 异常与返回值的分界:调用方总能预期并需针对性处理的失败用返回值,真正的意外才用异常;
  • 不许静默吞掉:空 catch 与过宽捕获是两类方向相反的事故,捕获范围从窄到宽逐级放宽。

值的缺失、初始化的空窗、操作的失败——第二章把三种"缺失"全部纳入类型系统的管理。下一章进入第二根支柱:密封类如何让"分支遗漏"从静默错误变成编译失败。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U