多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Kotlin runCatching 进阶:从 Result 类型到协程取消与业务封装

Kotlin runCatching 进阶:从 Result 类型到协程取消与业务封装 最近在好几个项目里看代码发现一个很有意思的现象runCatching的使用频率越来越高但不少朋友其实只把它当成省去 try-catch 打字量的语法糖来用。更常见的是这样写val result runCatching { doSomething() } if (result.isSuccess) { // 做点什么 } else { // 做点别的 }这当然能跑但完全没有发挥出这个魔法的真正威力。诚然runCatching从名字上看就自带安全带气质它确实能把异常转换成标准库里的Result类型让代码从失控的抛出变成可控的返回。但如果你只是把它当 if/else 的开关那其实跟你手写try { } catch { }没有本质区别反而还多了层包裹。这篇东西我不打算写成 API 文档而是想按我自己的踩坑过程把runCatching从是什么讲到什么时候千万别用它再到怎么自己封装一个比官方更适合作业务场景的平替版。适合谁看呢刚接触 Kotlin 不久、开始尝试用Result类型管理异常的初级开发者以及在协程、网络层里被try-catch嵌套搞得很烦的中级开发者。1. 为什么 Kotlin 偏偏要给你一个 runCatching1.1 传统的 try-catch 到底别扭在哪很多从 Java 转过来的朋友习惯了让异常往上抛然后在某个上帝层统一处理。这套思路在大型单体系统里可用性强但在现代 Kotlin 风格里往往会破坏函数式链路的流畅性。举个例子你想从一堆服务器返回的数据里提取一个用户手机号传统写法大概是fun extractPhone(raw: String): String { return try { val json JSONObject(raw) val user json.getJSONObject(user) user.getString(phone) } catch (e: JSONException) { } }这种写法的核心问题是异常路径和成功路径是两条平行的代码线一旦分支多起来阅读者需要在脑子里来回跳跃。更致命的是如果调用方想在异常时执行兜底逻辑 A在成功时执行后处理逻辑 B他只能继续嵌套 try-catch或者定义一个 Flag 变量贯穿全程。代码一长Flag 值来自哪个 try 块已经没人记得清了。而runCatching的思路是把可能失败的计算和对失败结果的处理彻底拆开。前者只负责返回一个ResultT后者负责消费这个Result。说白了就是把异常当成数据来传递。这个思路跟现代函数式编程里用返回值表达错误的哲学是一脉相承的。ResultT本质上就是一个携带了成功值的盒子或者携带了异常对象的盒子它把 try-catch 的隐式控制流显式化了。1.2 从 Nullable 到 Result是一次认知升级写 Kotlin 久了会习惯用可空类型来表示可能没有值。但注意null只是个空标记它带不来任何为什么会空的信息。比如user?.phone返回null你无法区分是用户不存在还是字段缺失还是数据格式崩溃。ResultT则把这层信息补全了它要么是Success(value)要么是Failure(exception)。你拿到异常对象的那一刻崩溃原因、堆栈、类型全都在。我习惯用一个生活化的类比null相当于快递柜里空空如也你只知道没有包裹但不知道为什么没有ResultT则相当于签收时附带的回执上面写清楚了包裹被退回/地址错误/联系不上收件人。很多同学会问既然 Kotlin 有 sealed class为什么我不自己定义一个MyResultout T答案很简单标准库已经帮你做好了而且runCatching这个入口设计得足够优雅你不必重复造轮子。但理解Result的本质仍然很重要——它只是一个平凡的值对象不是魔法。2. 拆开 runCatching 的口袋看看里面到底装了什么2.1 源码级的解读inline contract Result既然要聊透彻我们直接看 Kotlin 标准库里的定义不同版本略有差异但核心逻辑一致public inline fun R runCatching(block: () - R): ResultR { return try { Result.success(block()) } catch (e: Throwable) { Result.failure(e) } }这段代码很短但信息密度很高inline意味着调用处会把这段代码体直接展开内联不会产生额外的函数调用开销。哪怕你在循环里频繁调runCatching也不必担心性能损耗。泛型R它约束了成功值的类型同时允许block是一个返回R的函数引用或者 lambda。try-catch捕获的是Throwable也就是极其宽泛的错误类型Exception反而只是它的一部分。注意这会连VirtualMachineError这种级别的错误也接住后面我会专门讲这个坑。你可能还注意到标准库里真正的实现里用了PublishedApi等注解那是为了配合内联函数访问内部 API正常使用无需关心。但还有一个关键隐藏点runCatching与Result的关系不是调用一次就结束。由于Result是值类型它可以被到处传递、存储、反复折叠。2.2 Result 的 API 全家桶getOrNull、fold、recoverResultT的标准 API 非常多但实际高频的也就是那三四个。先列一张表把我们最常用的全放进去方法作用典型场景getOrNull()成功时返回值失败时返回 null快速忽略异常取值getOrDefault(value)成功时返回值失败时返回默认值兜底配置、简单默认值getOrElse { }成功时返回值失败时用 lambda 计算替代值需要根据异常类型做不同降级fold(onSuccess, onFailure)将两种结果统一映射成同一类型的值最灵活的统一出口recover { }失败时把异常转换成成功值把失败恢复成正常数据流recoverCatching { }失败时执行恢复函数但恢复函数本身也可能抛异常安全恢复map {}/mapCatching {}对成功值做变换mapCatching连变换中的异常都能接住管道式数据流注意fold往往是最被忽略的但它恰恰是处理结果最优雅的入口。我们用fold重写开头的手机号提取函数fun extractPhone(raw: String): String { return runCatching { val json JSONObject(raw) val user json.getJSONObject(user) user.getString(phone) }.fold( onSuccess { it }, onFailure { unknown } ) }这段代码把异常树的两条分支合成了一个表达式没有可变变量没有早退阅读顺序就是执行顺序非常流畅。还有一个许多人没注意的细节Result的mapCatching是支持链式传递失败的。一旦前面的map抛出异常后面的mapCatching不会继续执行异常会乖乖保存在Result里直到你统一处理。3. 实操中的正确打开方式runCatching 在业务代码里的经典配方3.1 配置项读取与兜底最偷懒也最实用的一个场景是读取配置、加载本地数据。这类操作的典型特征是崩溃概率低但你不希望因为它导致整个 APP 崩溃。data class FeatureConfig( val enableNewHome: Boolean false, val cacheSizeMb: Int 32, val apiTimeoutSec: Int 10 ) object ConfigLoader { private const val SP_NAME app_config fun loadConfig(context: Context): FeatureConfig { val prefs context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) return runCatching { FeatureConfig( enableNewHome prefs.getBoolean(enable_new_home, false), cacheSizeMb prefs.getInt(cache_size_mb, 32), apiTimeoutSec prefs.getInt(api_timeout_sec, 10) ) }.getOrDefault(FeatureConfig()) } }这里用getOrDefault十分贴切配置读坏了就用一套默认配置顶上根本不打断业务流程。而且由于runCatching是内联的这种高频读写场景也多花不了几个时钟周期。我在这类场景里特别推荐getOrDefault因为它能直接把兜底值写在调用点比在外层写一堆 catch 干净得多。如果你还想多留一手可以在失败时打一条日志但别在 lambda 里隐藏副作用否则看起来很怪return runCatching { ... } .onFailure { Log.e(ConfigLoader, load config failed, it) } .getOrDefault(FeatureConfig())3.2 从网络层返回可读错误网络请求是现代应用的命脉而网络层的错误处理最容易写成金字塔。我们假设用的是 Retrofit 协程业务层会有个Repositorysealed class ApiResultout T { data class SuccessT(val data: T) : ApiResultT() data class Error(val code: Int, val message: String) : ApiResultNothing() } suspend fun fetchUserProfile(): ApiResultUserProfile { return withContext(Dispatchers.IO) { runCatching { apiService.getUserProfile() } .fold( onSuccess { ApiResult.Success(it.data) }, onFailure { e - // 根据异常类型翻译成用户可读的错误码 ApiResult.Error(translateException(e)) } ) } }注意runCatching本身是同步的它不感知协程的suspend。这里我用withContext(Dispatchers.IO)把网络调用切到 IO 线程池然后把结果统一转换成ApiResult这种业务层类型。好处是上层调用方完全不用处理乱七八糟的IOException看到的永远是一个干净的封闭类。我见过不少团队把Result当网络返回类型直接暴露给 UI 层比如让ViewModel对外暴露StateFlowResultUiState。严格来说这不是不行但会让 UI 层被迫识别异常类型。我的建议是Result用在局部处理上跨层传输时尽量转换成业务语义明确的 sealed class 或 data class不然会在很多地方出现when(result) { is Success - ...; is Failure - ... }的重复分支判断。3.3 多数据源降级与缓存回退还有一类杀手级场景多路数据源依次尝试。比如阅读 App 里加载文章详情先打远程接口失败后查本地缓存再失败才用预设的离线推荐文。用传统的try-catch写这种降级链代码会非常冗长每个 catch 块里都要重新开启一层逻辑。但用runCatching搭配recoverCatching就能把降级串成一条链suspend fun loadArticle(articleId: String): Article { val remote runCatching { apiService.getArticle(articleId) } val cache remote.recoverCatching { localCache.getArticle(articleId) } return cache.getOrElse { Article.offlineFallback(articleId) } }这段代码的执行路径是先尝试远程获取如果远程失败或返回异常走recoverCatching里的本地缓存获取如果本地缓存也失败通过getOrElse兜底返回离线文章。如果你担心远程失败不代表接口不可用还可能是业务错误码那么在recoverCatching的 lambda 里做判断即可。比如远程返回了Result.Success但data是空的你可以主动抛个异常强制走降级这样就打通了业务逻辑错误和技术异常之间的桥梁。这里有一个心法把recover当成恢复路径而不是吞异常。因为recover的返回值类型必须跟成功值一致所以你被迫去思考失败时应当返回什么而不是简单地打印一行日志后继续糊弄。4. runCatching 的经典陷阱什么时候它就是祸根4.1 协程取消被吞掉这是我最想骂人的一个坑如果你在协程里写suspend fun loadData(): String { return runCatching { delay(1000) done }.getOrDefault(timeout) }表面看延迟 1 秒后返回 done失败兜底 timeout看似天衣无缝。但假如外部在 500ms 时取消了协程delay会抛出CancellationException而runCatching会吞掉它把它当成普通失败来处理最终返回timeout。问题在哪协程协作式取消的核心机制是取消信号依靠CancellationException在调用栈中传播。一旦你把这个异常吞掉协程就死而不僵调用方以为任务正常结束了但实际上它已经被取消后续的清理逻辑、资源释放、状态流转全部错乱。最典型的症状是用户在界面点了取消下载下载协程却仍然继续跑完或者一个取消的请求最终走到了onSuccess分支更新了已经被销毁的 UI。解决办法有两种第一种也是最简单的就是不要把协程体的整个delay包进runCatching或者在高风险协程调用里手动保证CancellationException原样抛出suspend fun loadData(): String { return runCatching { delay(1000) done }.recover { e - if (e is CancellationException) throw e timeout }.getOrThrow() }第二种针对确实需要在协程里用runCatching的场景我会自己封装一个suspendRunCatchingsuspend fun T suspendRunCatching(block: suspend () - T): ResultT { return try { Result.success(block()) } catch (e: CancellationException) { throw e } catch (e: Throwable) { Result.failure(e) } }这样协程的取消信号不会被吞其他的异常照常被包进Result。在我个人写的代码里凡是涉及suspend的块我都默认用这个非官方版本的suspendRunCatching标准库那个runCatching反而只有在明确的非协程同步代码里才敢直接用。4.2 它连 Error 都接得住这不是什么好事标准库实现里写的是catch (e: Throwable)意味着连OutOfMemoryError、StackOverflowError这种致命错误也会被包装进Result.Failure。这些东西通常意味着你的程序已经处在不稳定状态继续执行大概率会引发更诡异的问题。真正健壮的代码应该让Error往上抛而不是试图捕获处理。所以如果你在做一个关键任务型的操作我建议不要直接用runCatching兜底而是用runCatching后紧跟recover { if (it is Error) throw it ... }把它筛出去。当然考虑到 99% 的业务代码里Error极少出现这个坑的严重性远不如CancellationException那个但知道了总比不知道强。4.3 Result 嵌套导致的套娃地狱ResultResultT这种类型是怎么产生的很简单——你在一个runCatching的 lambda 里又调用了另一个返回Result的函数。val result: ResultResultUser runCatching { runCatching { fetchUser() } }这会让你后续的onSuccess分支里拿到的不再是User而是ResultUser你还得再做一次解包代码非常难看。解决办法是使用mapCatching或fold去扁平化或者在设计 API 时明确返回类型避免在 lambda 里自己动手再包一层 Result。另一个容易踩的坑是Result上直接调用onSuccess、onFailure时如果你 lambda 内抛异常它们不会自动捕获异常会直接冒泡上去。很多人误以为既然用了Result一切的异常都安全了其实Result只保护产生 Result 的那段代码不保护消费 Result 的后续操作。4.4 性能损耗到底有没有经常有人纠结内联函数会不会导致包体膨胀。实际上runCatching的膨胀极轻微它只是把一段 try-catch 逻辑内联到调用处而Result在 Kotlin 1.5 之后已经优化成了 value class绝大多数场景下不存在额外的装箱分配。真正要警惕的是你把一个巨大的Result对象在跨线程之间到处传递或者把它塞进一个频繁 GC 的集合里那才会有轻微开销。更值得关注的是不要用runCatching去包住一个耗时的同步操作并配合Dispatcher.Default使用这只是把同步阻塞往线程池里丢而已并不会变成异步反而占住了线程资源。这种属于架构问题不是一个函数能解决的。5. 更合口味的自定义封装把 runCatching 变成自己的5.1 数据类 错误码让 Result 更懂业务标准库Result的定位是通用语言库它不知道你的业务长什么样。实际开发中我更喜欢自己封装一个轻量的错误模型把异常类型升级为错误码 提示语 原始异常sealed class BizResultout T { data class SuccessT(val data: T) : BizResultT() data class Failed( val code: Int, val message: String, val cause: Throwable? ) : BizResultNothing() } inline fun T runBizCatching( errorCode: Int, errorMessage: String, block: () - T ): BizResultT { return try { BizResult.Success(block()) } catch (e: CancellationException) { throw e } catch (e: Throwable) { BizResult.Failed(errorCode, errorMessage, e) } }这样在业务层你的数据流就是返回BizResult.Success或BizResult.Failed不再需要到处与Result.Failure中的原始异常斗智斗勇。尤其是面向 UI 层时code跟message可以直接对应到用户的提示语。5.2 与协程容错三剑客合用在协程领域还有一个常用组合supervisorScopeasyncrunCatching。假设你要并发拉取三个配置但只想要能拿到多少拿多少suspend fun loadAllConfigs(): ListConfig { return supervisorScope { listOf( async { fetchConfigA() }, async { fetchConfigB() }, async { fetchConfigC() } ).mapNotNull { deferred - deferred.runCatching { it.await() }.getOrNull() } } }这里的核心是supervisorScope它保证某一个async失败时不会取消它的兄弟任务配合runCatching后每个任务的结果都收敛为可空值最终只收集成功的部分。这种模式在并行加载非关键数据的场景里堪称完美。5.3 别过度使用什么时候我宁可你直接写 try-catch虽然我是runCatching的粉丝但我必须诚实地说不是所有地方都适合它。你需要精细地分别捕获多种异常类型并做不同处理时直接在catch里写多个分支会更清晰。比如网络层要区分SocketTimeoutException、UnknownHostException、SSLException如果全包进Result后面在fold里还得做一大堆类型判断不如传统try-catch直观。异常发生时要释放资源、关闭流、回滚事务这种需要在异常路径上执行命令式清理代码的场景try-catch也更合适。因为runCatching的 lambda 结束后你只能通过纯函数的方式处理结果无法在抛出点就近干预。异常路径本身非常长且复杂包含大量日志记录、指标上报、甚至重新抛出别的异常此时runCatching的fold虽然能承载但可读性并不比命令式代码更好。所以我个人倾向的判断标准是如果失败后你只需要返回一个替代值或者把异常转换成另一个对象优先用runCatching加fold/recover如果失败后你要做一串有依赖顺序的副作用操作就用传统try-catch。把异常处理当成数据流来写带来的收益不是一行行地少打字而是让成功路径和失败路径能像管道一样无缝拼接。5.4 从 runCatching 到 Result 的团队规范最后分享一个团队层面的经验如果你们项目组决定大规模使用Result一定先在代码规范里约定几条红线禁止拿Result跨模块传递尤其禁止让Repository把Result直接抛给ViewModel跨层必须转成业务语义类型。禁止把Result存到集合里长期持有它只是临时结果载体不是领域模型。禁止在协程 body 直接使用标准库runCatching一律用自定义的suspendRunCatching或者先recover再getOrThrow把CancellationException放行。禁止用getOrDefault覆盖任何可能表示系统异常的情况至少要在onFailure里留一句日志。如果你们能守住这几条runCatching就会从随处可见的魔法变成恰到好处的工具。如果守不住你会发现自己引进了比 try-catch 更糟糕的隐性复杂度。6. 我踩过的一些典型问题排查实录6.1 现象界面上偶发出现空数据日志里全是 CancellationException有一次某开发跟我描述了这么个诡异问题列表页偶尔会出现加载失败的占位视图但过一会儿又自己好了。问题本身不频繁但特别难复现。最终在日志里发现了大量CancellationException排查后发现是分页加载的协程被上拉刷新取消了但取消动作被runCatching吞掉导致一个本来已经被取消的旧请求最后却走了getOrNull返回 null被当成空数据渲染了。修复方式就是给所有涉及协程的runCatching换成suspendRunCatching并把CancellationException原样抛出去。从那以后这个现象再也没有出现过。这个案例让我深刻认识到runCatching的错误不在它捕获异常这件事而在于它连取消信号都当成异常来捕获。这在普通函数里问题不大但在协程世界里就是一颗定时炸弹。6.2 现象recover 里的降级没有生效还有一次我在一个离线缓存方案里写了类似上面的recoverCatching { localCache.getArticle() }结果本地明明没有缓存但线上数据仍然能正常展示。后来排查发现本地缓存的函数返回的是null而不是抛异常我的recoverCatching根本没有被触发最终getOrElse兜底也没走数据源是一个空指针的降级值。问题根因是Result关心的是异常而不是null你不主动抛出异常recover压根不会管它。这也提醒了我在使用 Result 风格的错误处理时要把空值也当成一种需要显式表达的失败情况来看待。要么让函数返回ResultT把空值映射成Failure要么在recoverlambda 里手动检查if (cached null) throw IllegalStateException()。6.3 现象明明处理了异常findbugs 还是报警不是技术问题而是工程规范问题。团队启用了静态代码扫描发现凡是使用runCatching的地方都跳不过一个异常被吞掉的提示。这让我们被迫引入了一个自定义注解或用Suppress显式声明此处有意吞异常。这其实是个好信号——它逼着我们重新审视每个runCatching是否真的合理。从此我们在 Code Review 里专门加了一条检查项runCatching后面必须有fold、recover、getOrElse或至少一个onFailure否则视为不合格。因为如果一段runCatching只是配了个getOrNull你等于把异常信息全丢了还不如不要这个白名单。7. 个人使用习惯和经验沉淀写了这么长再说一点个人体会。runCatching这个名字起得确实好它没有直译成捕获异常而是强调我带着结果跑了一圈可能成功也可能失败。这种命名本身就暗示了函数式思维不是中断逻辑去找 catch而是把结果带回来让你从容地决定下一步怎么走。我现在写 Kotlin 代码时的习惯是默认所有可能失败但不属于核心链路的操作第一反应就是runCatching搭配fold只有在必须精确识别异常类型并做分支清理的时候才会回到try-catch。而一旦涉及协程我几乎只用自己的suspendRunCatching绝不给CancellationException一丝被吞掉的机会。另外我还想分享一个很小的技巧Result类型配合when是绝配很多新人不习惯用fold反而喜欢写return when (val r runCatching { ... }) { is Result.Success - r.value is Result.Failure - handleError(r.exception) }这种写法本身也没问题可读性甚至比fold更好。但要注意它只在when是表达式时才有优势如果你只是要个值或兜底fold一行更快。两种风格没有绝对优劣团队统一就好。最后再提醒一句如果你们项目已经全面拥抱协程别忘了在 Gradle 里引入的 Kotlin 版本要够新。老版本对 value class 的优化还不到位Result的分配开销会比新版本多一些虽然不至于成为瓶颈但能省则省。runCatching不是什么玄学魔法它只是一个把异常当成数据来传递的普通工具。当你把它放进协程、放进网络层、放进降级逻辑里反复揉搓之后你会慢慢感受到用返回值表达失败这件事带来的掌控感——代码不再是一堆 try/catch 拼成的迷宫而是一条条清晰可见的管道每一段都知道自己失败时该怎么掉头。这才是runCatching背后真正值得琢磨的东西。
返回列表