错误传播——错误从发生位置向上通过调用栈传播到处理程序的机制。当函数无法独立处理错误时,它通过异常(exception)、throws 声明或返回类型将错误传递给调用方。正确实现传播对于移动应用的稳定性至关重要:未处理或错误传递的错误会导致崩溃。根据 Apple Swift Documentation (2026),Swift 中通过 throws 的自动传播允许将错误传递到任何级别而无需样板代码。
要点
错误传播——将错误对象从发生错误的函数向上通过调用链传递到最近的适当处理程序的过程。想象一下调用栈:ViewController 调用 ViewModel,ViewModel 调用 Repository,Repository 调用 API。如果 API 返回网络错误,它必须通过 Repository 和 ViewModel 到达 ViewController,后者将向用户显示消息。每个中间函数决定:处理错误还是进一步传递(传播)。
有两种传播方法:自动和手动。在自动方法(Swift throws、Java 受检异常)中,编译器强制开发者要么处理错误,要么在签名中声明传播。在手动方法(Result 类型、Kotlin Try)中,错误作为值传递——开发者显式编写代码来传递或转换错误。根据 Kotlin Result Docs (2026),Kotlin 中的 Result<T> 不打算直接跨函数边界传播——它需要在每个级别进行转换或处理,这使得传播更有意识,但也更冗长。
方法的选择取决于应用程序的体系结构和语言。在 Swift 中,通过 throws 的自动传播占主导地位;在 Kotlin 中,则是异常(用于意外错误)和类似 Result 的容器(用于预期错误)的混合。重要的是要理解:传播不是目的,而是必要。理想的体系结构通过在最低可能级别处理错误来最小化传播深度,在该级别有足够的上下文来做出决定。
在 Swift 中,通过 throws 的传播自动发生:如果带有 throws 的函数 A 调用带有 throws 的函数 B,并且 A 没有在 do-catch 中处理 B 的错误,则错误自动传递给 A 的调用方。这消除了 Java 受检异常特有的样板代码,后者需要在链的每个方法中声明 throws。Swift 使用原则「链中的一个 throws 函数 = 整个链变成 throws,如果不在中间级别处理的话」。
struct UserRepository {
func fetchUser(id: Int) throws -> User {
let data = try networkService.request(path: "/users/\(id)")
return try parseUser(from: data)
}
}
class UserViewModel {
let repo = UserRepository()
func loadUser(id: Int) throws -> User {
return try repo.fetchUser(id: id)
}
}
// ViewController — 最终处理程序
func onButtonTap() {
let vm = UserViewModel()
do {
let user = try vm.loadUser(id: 42)
updateUI(user)
} catch {
showError("加载用户失败")
}
}
传播链:networkService.request -> fetchUser -> loadUser -> onButtonTap。每个中间函数都用 throws 标记并且不包含 do-catch——错误自动向上传递。ViewController onButtonTap 是带有 do-catch 的最终处理程序。如果 ViewModel 决定转换错误(包装成另一种类型),它可以使用 do-catch 和新的 throw。自动传播缩短了代码:Repository 不需要知道如何处理错误——这是 ViewController 的责任,它有权访问 UI 以向用户显示消息。
在 Kotlin 中,通过异常的传播不需要在签名中声明 throws(所有异常都是非受检的)。异常自动在栈中上升,直到遇到 try-catch。然而,签名中 throws 的缺失使传播变得隐式:开发者从函数签名中看不到它可能抛出异常。这既是优点(更少的样板代码)也是缺点(更容易忘记处理)。Kotlin 通过约定和体系结构模式而不是通过语言本身来解决这个问题。
class UserRepository(
private val api: ApiService,
private val db: Database
) {
suspend fun getUser(id: String): User {
return try {
api.fetchUser(id)
} catch (e: IOException) {
db.getCachedUser(id) ?: throw AppException("User unavailable")
}
}
}
class UserViewModel(private val repo: UserRepository) {
private val _state = MutableStateFlow<UiState<User>>(UiState.Loading)
val state: StateFlow<UiState<User>> = _state
fun loadUser(id: String) {
viewModelScope.launch {
try {
val user = repo.getUser(id)
_state.value = UiState.Success(user)
} catch (e: AppException) {
_state.value = UiState.Error(e.message ?: "Unknown")
}
}
}
}
在 Repository 中带转换的传播:在 IOException(网络不可用)时,函数尝试从数据库获取缓存的数据。如果缓存为空,则抛出 AppException——传播以新的错误类型继续。ViewModel 捕获 AppException 并将其转换为 UiState.Error——错误不再继续,传播在 UI 层级别完成。Kotlin Coroutines 增加了特性:launch 中的异常通过 CoroutineExceptionHandler 自动传播,而 async 中的异常仅在调用 await() 时传播。在设计协程中的传播时考虑这一点很重要——SupervisorJob 防止子协程出错时取消父协程。
异常的替代方案——通过一个类型容器进行传播,该容器将成功或错误作为值传递。在这种方法中,函数不返回值,而是返回一个包装:Swift 中的 Result<T, E>、Kotlin 中的 Result<T>、Dart 中的 Either<L, R>(来自 fpdart 或 dartz 包)。错误不展开栈——它只是放在容器中,下一级别决定如何处理它。这使得传播更加明确和可控。
data class HttpResult<out T>(
val data: T?,
val error: AppError?
) {
val isSuccess: Boolean get() = data != null
val isError: Boolean get() = error != null
}
sealed class AppError {
data class Network(val message: String) : AppError()
data class Auth(val message: String) : AppError()
}
fun fetchUser(id: String): HttpResult<User> {
return try {
val response = api.get("/users/$id")
HttpResult(data = parseUser(response), error = null)
} catch (e: IOException) {
HttpResult(data = null, error = AppError.Network("No internet"))
}
}
HttpResult<T>——一个带有 data 和 error 字段的简单容器。Sealed class AppError 定义了错误类型(Network、Auth)。函数 fetchUser 返回 HttpResult,传播不需要展开栈——调用方只需检查 isSuccess/isError。这种方法在Clean Architecture中特别有用,其中每个层(data、domain、presentation)都可以转换错误:IOError -> DomainError -> UiError。通过容器的传播使这些转换显式且可测试,这与异常不同,在后者的函数签名中看不到转换链。
设计错误处理的关键决策之一是传播(向上传递)与处理(在此处处理)之间的选择。决策规则:在有足够上下文进行有意义的操作的级别处理错误。如果你可以访问 UI——向用户显示消息。如果你可以访问缓存——尝试恢复。如果两者都没有——传播。
| 场景 | 操作 | 理由 |
|---|---|---|
| Repository 中的网络错误 | 传播 | Repository 不知道用户是否想重复请求 |
| Repository 中的解析错误 | 处理(返回默认值) | Repository 知道格式,可以返回备用值 |
| ViewModel 中的超时 | 处理(UiState.Error) | ViewModel 管理 UiState,知道如何转换错误 |
| Interceptor 中的授权错误 | 处理(刷新令牌) | Interceptor 可以访问令牌并可以恢复会话 |
| UseCase 中的未知错误 | 传播 | UseCase 没有 UI 上下文——只有业务逻辑 |
黄金规则:在较低级别最小传播,最大处理。如果 Repository 可以从缓存恢复——它应该这样做,而不将错误向上传递。如果 ViewModel 可以显示 Snackbar——让它显示,而不需要 ViewController 提供额外的代码。每个传播级别都会增加耦合性并使测试复杂化。根据 Google Android Architecture Guide (2026),建议通过使用 sealed class UiState 在 ViewModel 级别表示所有可能的状态(Loading、Success、Error)来最小化跨越层边界的传播,并且不要将异常直接传递到 UI 层。
不正确的传播是移动应用中难以发现的错误的来源。让我们看看开发者面临的五个主要问题及其解决方法。
最常见的问题:在传播过程中,异常被捕获、记录,并且在没有原始异常的情况下抛出新的异常。开发者丢失了 StackTrace,无法理解错误确切发生在哪里。在 Swift 中使用错误链:throw MyError(context: originalError)。在 Kotlin 中:throw AppException(cause = originalException)。在 Dart 中:throw AppException(message, originalException)。永远不要在未传递原因/基础错误的情况下创建新的异常。
catch (e: Exception) { /* 什么也不做 */ } ——这是一种反模式,导致应用程序在不正确的状态下继续运行。如果你确定错误可以被忽略——请添加带有理由的注释。在 Swift 中用于可选忽略使用 try?(错误 -> nil)。在 Kotlin 中 —— Result<T>.onFailure { /* log */ }。不要在没有记录的情况下压制异常。
如果错误通过 5+ 级别而没有处理,体系结构需要重新审视。每个传播级别都是对下层函数 throws 签名的依赖。解决方案:在层边界使用 Failure 容器(sealed class Result { Success, Error }),使传播显式且受限。传播链越短,测试和调试代码就越容易。
在 Kotlin Coroutines 中,launch 中的异常默认取消父协程和所有同级(同一 scope 的子级)。如果 10 个并行任务中有一个失败,其余 9 个将被取消,这通常是不希望的。使用 SupervisorJob 或 supervisorScope 进行错误隔离:一个子级中的错误不会取消同级。ViewModelScope 默认使用 SupervisorJob,这可以在 Android 中防止此问题。
在基于回调的 API 中,错误通常作为回调参数传递。如果回调没有处理错误(或处理不正确),传播变得隐式且容易丢失。解决方案:迁移到 async/await(Swift)或协程(Kotlin),在这些地方传播通过标准的 try-catch 机制工作。如果回调不可避免——使用 Either<Error, T> 或 Result<T> 强制处理两种情况。
常见问题
Throw 是一次性的异常抛出操作。错误传播是错误通过多个栈级别从 throw 到 catch 的整个传递过程。传播包括 throw、通过中间函数的自动或手动传递以及最终处理。这是一个描述错误生命周期的更广泛的概念。
使用在给定场景中抛出异常的模拟对象。检查函数是否正确传播或处理错误,通过 assertThrows(Kotlin/JUnit)或 XCTAssertThrowsError(Swift/XCTest)。对于基于 Result 的传播,检查两种情况下的 isSuccess/isError 和值。
在一个体系结构边界内,Result 传播更适合预期错误(无效数据、业务规则)。异常更适合意外错误(网络丢失、I/O 错误),这些错误应该在高级别处理。带错误的结果不会中断执行流程,异常会中断。
在 Kotlin Coroutines 中,launch 中的异常通过 CoroutineScope 自动传播并取消同级。使用 supervisorScope 或 SupervisorJob 进行隔离:一个协程中的错误不会取消其他协程。对于 async,错误需要在调用 await() 时通过 try-catch 显式处理,否则将被吞没。
理想的最终处理程序是 UI 层(ViewController、Fragment/Composable)。只有它可以访问用户界面,并且可以显示消息、Snackbar 或对话框。中间层(Repository、UseCase、ViewModel)传播错误,必要时将其转换为更抽象的领域类型。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。