本节摘要:本节处理界面加载最脆弱的一环——失败。用 Error 协议给事故分类,用 throws 声明"这台戏可能出事故",用 do-catch 在界面上接住它;并对比 try、try?、try! 与 Result 四种姿态的取舍。结论先行:错误处理不是补丁,而是界面状态机的第三条分支(加载中 / 成功 / 失败)。
替补演员(2.2 节)能把"稍后的动作"递到现场,但递回来的可能是坏消息——这一节给坏消息写剧本。
排片界面发起请求后,至少有四种姿势摔倒:断网、服务器抽风、返回的数据缺字段、用户没登录。前三种发生在"取数据"环节,第四种发生在"读凭据"环节。Swift 错误处理的第一步,是把这些事故枚举成类型:
// 错误枚举:实现 Error 协议(空协议,签个名即可) enum BoardError: Error { case offline // 断网 case server(Int) // 服务器异常,携带状态码 case decodeFailed(field: String) // 解码失败,携带字段名 case unauthorized // 未登录 }
错误枚举的价值在于让失败原因成为编译器认识的东西。字符串报错("加载失败了")传三层就没人知道原始含义;枚举带关联值,server(503) 与 server(404) 在 catch 里能分开处理。
第二步,给可能出事故的函数挂上 throws:
// 模拟取凭据:可能失败 func currentToken() throws -> String { // 假设本地没有存登录凭据 throw BoardError.unauthorized // throw:当场摔台 } // 模拟加载排片:错误会向上抛 func loadBoard() throws -> [String] { _ = try currentToken() // try:明知此处可能摔,先试试 // 模拟网络返回了损坏数据 throw BoardError.decodeFailed(field: "showtimes") }
try 不吞错误,只表示"我知道这里可能炸"。错误沿调用链向上飞,直到有人 do-catch 接住——这正是 2.2 节逃逸闭包之外另一条"控制流拐弯"的路径:
光有错误类型不够,界面上要有对应的呈现。背景:排片界面要给每种失败配一条用户能看懂的话和一个动作。操作分两步,先在数据层抛错,再在界面层翻译:
// 数据层:假装从服务端拿排片 func fetchBoard(token: String) throws -> [String] { if token.isEmpty { throw BoardError.unauthorized } // 模拟:服务器今天 503 throw BoardError.server(503) } // 界面层:do-catch 是救场主力 func showBoard() { do { let board = try fetchBoard(token: "") print("渲染 \(board.count) 条场次") // 成功分支(本例走不到) } catch BoardError.unauthorized { print("请先登录,正在跳转登录页") // 输出:请先登录,正在跳转登录页 } catch BoardError.server(let code) where code >= 500 { print("影院服务器打盹了,稍后自动重试") // 500 以上算服务端问题 } catch BoardError.decodeFailed(let field) { print("数据缺了 \(field),已上报") } catch { print("未知情况:\(error)") // 兜底分支,error 是隐式常量 } } showBoard()
注意 catch BoardError.server(let code) where code >= 500——catch 也支持模式匹配(第6章会系统讲),错误码可以继续分流。结果解读:这个函数把"失败"从崩溃改写成界面文案,四种事故各有台词。变式:给 showBoard 加一个 retry: () -> Void 参数,在 500 分支里自动调用,就成了带重试的加载器雏形。
同样是"可能失败",四种写法各有适用面:
// 姿势一:try? —— 只要结果,不要原因 let board1 = try? fetchBoard(token: "abc") print(board1 ?? []) // 输出:[](失败被压成 nil) // 姿势二:try! —— 拿职业尊严担保不会失败(本例会真崩,注释掉保命) // let board2 = try! fetchBoard(token: "") // 姿势三:Result —— 把成功失败装箱成一个值,适合跨闭包传递 enum Result<Value> { case success(Value) case failure(Error) } func loadBoardAsResult(token: String, completion: (Result<[String]>) -> Void) { do { let board = try fetchBoard(token: token) completion(.success(board)) } catch { completion(.failure(error)) // 失败也被装箱成值送回去 } } loadBoardAsResult(token: "") { result in switch result { case .success(let board): print("成功:\(board)") case .failure(let error): print("失败:\(error)") // 输出:失败:unauthorized } }
| 姿态 | 失败时得到 | 适用场景 | 风险 |
|---|---|---|---|
| do-catch | 错误详情+分支 | 界面层最终处理 | 无 |
| try? | nil | 不在乎原因的中间步骤 | 丢失错误信息 |
| try! | 直接崩溃 | 单测里确定成功、字面量转换 | 上线即事故 |
| Result | 装箱的成败值 | 回调式接口、跨层传递 | 样板代码多 |
⚠️ 常见坑:一路
try?上去,最后界面上只剩"加载失败"四个字,用户和排查的人都不知道为什么。原则:离用户最近的一层必须 do-catch 并区分原因;中间层可以上抛(把错误写进函数签名),但不允许静默吞掉。
💡 关键直觉:throws 改变的是函数的"身份"。看到
throws就知道这个函数有三分之一的概率不走成功分支——它和可选值的问号一样,是签名里诚实的一部分。第5章会看到 async 与 throws 组合成async throws,语义完全同源。
第一幕到此收场:函数、闭包、错误处理让界面"能加载、能回调、能摔了再爬起来"。但数据还住在散装字典里——第二幕组建剧团,用类、结构体、枚举与协议给每个角色正式编制。