最近在好几个项目里看代码,发现一个很有意思的现象: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 偏偏要给你一个 runCatching
1.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的思路是:把"可能失败的计算"和"对失败结果的处理"彻底拆开。前者只负责返回一个Result<T>,后者负责消费这个Result。说白了,就是把异常当成数据来传递。
这个思路跟现代函数式编程里"用返回值表达错误"的哲学是一脉相承的。Result<T>本质上就是一个"携带了成功值的盒子"或者"携带了异常对象的盒子",它把 try-catch 的隐式控制流显式化了。
1.2 从 Nullable 到 Result,是一次认知升级
写 Kotlin 久了,会习惯用可空类型来表示"可能没有值"。但注意,null只是个"空标记",它带不来任何"为什么会空"的信息。比如user?.phone返回null,你无法区分是"用户不存在"还是"字段缺失"还是"数据格式崩溃"。
Result<T>则把这层信息补全了:它要么是Success(value),要么是Failure(exception)。你拿到异常对象的那一刻,崩溃原因、堆栈、类型,全都在。
我习惯用一个生活化的类比:null相当于快递柜里空空如也,你只知道"没有包裹",但不知道为什么没有;Result<T>则相当于签收时附带的回执,上面写清楚了"包裹被退回/地址错误/联系不上收件人"。
很多同学会问:既然 Kotlin 有 sealed class,为什么我不自己定义一个MyResult<out T>?答案很简单:标准库已经帮你做好了,而且runCatching这个入口设计得足够优雅,你不必重复造轮子。但理解Result的本质仍然很重要——它只是"一个平凡的值对象",不是魔法。
2. 拆开 runCatching 的口袋,看看里面到底装了什么
2.1 源码级的解读:inline + contract + Result
既然要聊透彻,我们直接看 Kotlin 标准库里的定义(不同版本略有差异,但核心逻辑一致):
public inline fun <R> runCatching(block: () -> R): Result<R> { 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、recover
Result<T>的标准 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 + 协程,业务层会有个Repository:
sealed class ApiResult<out T> { data class Success<T>(val data: T) : ApiResult<T>() data class Error(val code: Int, val message: String) : ApiResult<Nothing>() } suspend fun fetchUserProfile(): ApiResult<UserProfile> { 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对外暴露StateFlow<Result<UiState>>。严格来说这不是不行,但会让 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的场景,我会自己封装一个suspendRunCatching:
suspend fun <T> suspendRunCatching(block: suspend () -> T): Result<T> { 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 嵌套导致的"套娃地狱"
Result<Result<T>>这种类型是怎么产生的?很简单——你在一个runCatching的 lambda 里又调用了另一个返回Result的函数。
val result: Result<Result<User>> = runCatching { runCatching { fetchUser() } }这会让你后续的onSuccess分支里拿到的不再是User,而是Result<User>,你还得再做一次解包,代码非常难看。
解决办法是:使用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 BizResult<out T> { data class Success<T>(val data: T) : BizResult<T>() data class Failed( val code: Int, val message: String, val cause: Throwable? ) : BizResult<Nothing>() } inline fun <T> runBizCatching( errorCode: Int, errorMessage: String, block: () -> T ): BizResult<T> { 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 与协程容错三剑客合用
在协程领域,还有一个常用组合:supervisorScope+async+runCatching。假设你要并发拉取三个配置,但只想要"能拿到多少拿多少":
suspend fun loadAllConfigs(): List<Config> { 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 风格的错误处理时,要把"空值"也当成一种需要显式表达的失败情况来看待。要么让函数返回Result<T>,把空值映射成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背后真正值得琢磨的东西。