网络层封装复盘:三段式解析、错误码归一,以及启动时的「配置门」
项目 social-app
1. 背景与问题
这是一款面向日本市场的语音社交 App。iOS 客户端的业务接口有好几百个,分布在动态、语音房、消息、登录、内购等模块里。网络层要解决三件事:
- 写起来简单:业务方发一个请求、拿到一个强类型模型,不想每次都处理状态码、业务码和 JSON 解析。
- 错误口径统一:网络断开、超时、服务端返回业务错误、登录失效,这些情况在界面上的表现各不相同,必须在一个地方归类。
- 请求之前先有配置:App 冷启动时,首页、消息、个人页往往同时发请求,但几乎每个请求都依赖一份「全局配置」:当前走哪个环境、域名名单、资源地址、活动开关等。配置还没回来就发业务请求,可能打错环境,或拿到不完整的数据。
第三点是这篇的重点。最朴素的写法是「每个请求发出前都去拉一次配置」,冷启动时就会出现十几个一模一样的配置请求同时在路上。这里需要的是:不管多少请求同时到达,配置只拉一次,其余的排队等结果。
2. 整体设计
2.1 分层
- 接口声明层:用 Moya 的声明式接口描述路径、方法、参数,所有业务接口集中在一份枚举里。
- 请求方法层:每个业务模块一份扩展,对外暴露「一行发请求」的方法。各业务模块在这套底座上按自己的领域加请求方法。
- 请求核心:RxSwift 封装请求,HandyJSON 做泛型模型映射,超时 30 秒。
- 配置门:所有非配置请求的必经之路。
2.2 配置门的时序
这其实就是 Go 里常说的 singleflight:同一个关键请求,在途期间只飞一次,所有等待者共享结果。区别在于它只针对「全局配置」这一个请求,不是对任意同参数请求的通用去重。
3. 关键实现
3.1 三段式解析
服务端响应外层是统一结构:业务码、提示文案、数据体。解析按三段走:
网络类错误(断网、超时、连接失败)统一收成一个「无网络」码,界面只需要判断这一种情况就能决定是弹 toast 还是展示空页面。
3.2 配置门(示意,非原始代码)
func request<T>(_ api: API) -> Single<T> {
if api.isConfig || ConfigGate.shared.hasConfig {
return send(api)
}
return Single.create { observer in
onMain { // 等待队列只在主线程读写
ConfigGate.shared.waitConfig { ok in
ok ? send(api).subscribe(observer)
: observer(.failure(.configUnavailable))
}
}
return Disposables.create()
}
}
final class ConfigGate {
private var waiters: [(Bool) -> Void] = []
private var fetching = false
func waitConfig(_ cb: @escaping (Bool) -> Void) {
assertMainThread()
if hasConfig { cb(true); return }
waiters.append(cb)
if fetching { return } // 已在拉取:只排队
fetching = true
fetchConfig { result in
if case .success(let c) = result { self.save(c) }
let ok = result.isSuccess
self.waiters.forEach { $0(ok) } // 批量放行或批量失败
self.waiters.removeAll()
self.fetching = false
}
}
}
几个细节:
- 用主线程串行化,而不是加锁。等待队列是普通数组,所有读写都切到主线程。回调本来就要回主线程更新界面,所以这是成本最低的串行化方式;代价是调用方必须先切线程,因此对「在非主线程进入配置门」做了异常打点。
- 失败也要批量放行。配置拉失败时,排队中的请求全部失败,而不是在没有配置的情况下各自偷偷发出去;否则又回到了「打错环境」的问题。
- 配置就绪后做的事:刷新域名名单(失败最多重试 3 次)、按需下载打招呼素材、发出「配置已就绪」通知。打招呼素材改为进入聊天页再下载,并且改到子线程执行。
3.3 登录失效只登出一次
冷启动时如果会话已经失效,十几个请求会几乎同时收到「登录过期」。如果每个都发登出通知,用户会看到登录页被连续推好几次。做法是账号管理器上挂一个一次性标志:第一次发出登出通知后置位,之后的同类错误直接忽略;登录成功时复位。
3.4 另一类「合并」:批量拉用户资料
消息列表、群成员、语音房观众,都需要把一批用户 ID 换成头像和昵称。这里用的是另一套手段:批量接口加分批并发。
| 场景 | 每批数量 | 说明 |
|---|---|---|
| 用户资料、精简资料 | 100 | 信号量限制最多 2 路并发 |
| 群资料 | 50 | 会话列表进入时拉取 |
| 会员信息 | 50 | 批量查询 |
这里先拷一份缓存字典再下到后台队列,是为了不在后台线程直接读写主线程上的字典;同时断言「不要在主线程执行」,防止有人在主线程调用后被信号量卡住界面。用户资料走的是同一套分批合并,和配置门不是一个队列。
3.5 公共请求头
| 字段(概念) | 用途 |
|---|---|
| 客户端版本(带平台前缀) | 服务端按版本做兼容 |
| 设备标识、渠道 | 统计与风控 |
| 毫秒时间戳 | 防重放、排查 |
| 会话凭证 | 鉴权 |
| 语言 | 中文环境下附带 locale |
4. 边界与失败处理
4.1 非主线程进入配置门导致的崩溃
线上出现过一类数组并发修改的崩溃,最后加上了「保证配置请求在主线程」的约束。这类问题可以这样处理:
- 怎么复现:在冷启动阶段,让一个后台线程的回调(比如某个 SDK 的初始化回调)里发业务请求,同时主线程也在发请求。配合 Thread Sanitizer 跑几次,通常能看到对等待队列的数据竞争报告;不开 TSan 时则表现为偶现的数组越界或野指针,很难稳定复现。
- 怎么定位:崩溃栈落在数组的 append 或遍历上,且两个线程栈同时出现配置门的方法。顺着调用方往上找,看哪些入口没有切线程。
- 怎么修:入口处统一切回主线程再进入配置门,同时对非主线程调用打点,把「谁没切线程」这件事暴露出来,而不是悄悄兜住。
4.2 其它边界
- 超时闭包里的悬挂:请求超时闭包中,如果构造请求对象失败,代码直接返回而没有调用完成回调,这个请求会一直挂着,上层既拿不到成功也拿不到失败。这是代码审查时能看出来的问题,正确做法是失败分支也要回调错误。
- 配置失败:排队的请求全部失败,界面按「无网络」处理,用户下拉刷新会重新触发配置拉取。
- 配置只拉一次:配置是否需要拉取,只看「内存里有没有这份模型」。好处是简单;代价是长时间驻留后台的进程拿不到新配置。
5. 演进
底座的形状在这几步里基本没变,说明「一行发请求 + 统一错误码」够用。变化集中在配置门的线程约束和批量拉取。
5.1 把配置门再拆开
一次业务请求先看配置,再看状态码和业务码。
配置本身只有几个状态,后来的请求不再另开一次拉取。
多个请求同时收到登录失效时,只通知一次。
用户资料是另一种合并:按批拉取,不是配置门。
从声明式接口到配置门,再到主线程约束和分批,是同一层上的几步,不是换了一套网络库。
6. 复盘与可改进之处
做对了的地方
- 三段式解析加错误码归一,让几百个接口的错误处理只有一套口径。
- 配置门用最小的代价解决了冷启动的请求风暴,而且失败时宁可全部失败,也不在没有配置的情况下发请求。
- 登出去重是很小的改动,却直接决定了会话失效时的用户体验。
还可以做得更好
- 请求生命周期:订阅结束后仍挂在一个全局的
DisposeBag上,页面销毁时取消不了请求,订阅对象也会随请求数增长。可以让每个请求跟随调用方的生命周期,或者迁移到async/await,用Task的取消语义。 - 配置过期:给配置加版本号或过期时间,回到前台时按需刷新;登出时明确清空。
- 通用去重:目前只有配置这一个请求做了 in-flight 合并。可以用「方法 + 路径 + 参数」生成签名,在途期间相同签名共享同一个任务,适用于资料、详情这类读请求。
- 超时与失败分支必须回调:所有提前返回都要配一次失败回调,可以用
defer或统一的完成器保证「恰好回调一次」。 - 用 actor 代替主线程串行化:在 Swift Concurrency 下,配置门天然适合写成一个
actor,不再依赖调用方切线程。