跨端网络层写了两套就后悔了:请求拦截、缓存和错误处理怎么抽成一份跑在 iOS、Android 和 Web 上
看到不少项目在 iOS 和 Android 各自维护一套网络层代码,等 Web 端再加进来时,三个平台三套拦截器、三套缓存逻辑、三套错误码映射,改一个超时策略要改三处,上线后出 bug 的概率直接翻三倍。跨端网络层复用这件事,早做早解脱,越拖越痛苦。
这件事的核心不是“能不能跨”,而是“哪些层值得跨、哪些层不该跨”。把所有平台差异全部抹平写进同一份代码里,反而会制造一个新的怪物。真正合理的做法是:把请求拦截、缓存策略、错误处理这些与平台无关的逻辑抽成共享核心,只把真正依赖平台的传输层和存储层留给各端各自实现。
请求拦截链:用中间件模式替代继承
大部分团队做拦截器的第一反应是写一个 BaseHttpClient,然后 iOS 继承它、Android 也继承它。问题在于,一旦两个平台的拦截需求开始分化——比如 iOS 需要加一个设备指纹 header,Android 需要加一个渠道号——继承体系就崩了,要么在基类里塞满 if-else,要么各自重写,共享层形同虚设。
更好的做法是中间件链。把每个拦截逻辑定义为独立函数,签名统一:
type Interceptor = (
request: RequestConfig,
next: (req: RequestConfig) => Promise<Response>
) => Promise<Response>;
共享层维护一个拦截器注册表,各平台在初始化时注入自己的平台专属拦截器。共享的 token 刷新、通用 header 注入、请求签名这些都放在共享核心里,iOS 的 IDFA 注入、Android 的 GAID 注入各自作为平台拦截器挂载到链上。
一个真实踩过的坑:token 刷新拦截器如果在链上位置不对,会导致死循环。正确的做法是把 token 刷新拦截器放在链的最外层(最先执行),并且在内部用标记位防止递归调用。我们项目里用了 3 层拦截器链:外层 token 刷新 → 中层通用签名 → 内层平台参数注入,顺序错一次线上就炸,这个教训值 200 万用户。
具体实现上,拦截器链的执行顺序是洋葱模型——请求从外向内穿过所有拦截器,响应从内向外返回。这意味着你可以在 token 刷新拦截器里同时处理请求前的 token 注入和响应后的 401 重试:
async function tokenRefreshInterceptor(
config: RequestConfig,
next: (req: RequestConfig) => Promise<Response>
): Promise<Response> {
// 请求前:注入 token
if (!config.skipAuth) {
config.headers['Authorization'] = `Bearer ${await getToken()}`;
}
const response = await next(config);
// 响应后:处理 401
if (response.status === 401 && !config._retried) {
await refreshToken();
config._retried = true;
return tokenRefreshInterceptor(config, next);
}
return response;
}
缓存策略:把策略和存储解耦
缓存最容易犯的错误是把缓存逻辑和存储实现绑死。iOS 团队用 NSURLCache,Android 团队用 OkHttp 的 Cache,Web 团队用 localStorage——然后各自实现一套“有缓存就返回缓存、同时发起网络请求更新”的策略。三套代码,三种行为,测试的时候发现 iOS 的缓存过期策略和 Android 不一致,这就是典型的技术债利息。
正确的分层是:策略层共享,存储层平台化。
策略层只关心三件事:缓存键的生成规则、缓存有效期的判断逻辑、缓存更新策略(stale-while-revalidate、cache-first、network-only 等)。这部分逻辑和平台完全无关,用纯函数实现:
interface CachePolicy {
shouldUseCache(key: string, maxAge: number): boolean;
shouldUpdateCache(key: string): boolean;
}
class StaleWhileRevalidate implements CachePolicy {
shouldUseCache(key: string, maxAge: number): boolean {
// 只要缓存未过期就先用缓存
return hasCache(key) && !isExpired(key, maxAge);
}
shouldUpdateCache(key: string): boolean {
// 只要缓存存在,无论是否过期都在后台更新
return hasCache(key);
}
}
存储层只暴露一个统一的 Storage 接口,iOS 用 MMKV 或文件系统实现,Android 同样用 MMKV,Web 用 IndexedDB。关键是这个接口必须支持同步读取——在请求拦截器里判断缓存时,异步读取会导致整个拦截链变成异步的,影响性能:
interface CacheStorage {
get(key: string): CacheEntry | null; // 同步读
set(key: string, entry: CacheEntry): Promise<void>; // 异步写
delete(key: string): Promise<void>;
}
实际项目中,我们遇到过缓存键冲突的问题。不同接口的 URL 可能只有 query 参数不同,直接用 URL 做缓存键会导致错误的缓存命中。解决方案是在缓存键中加入请求体的 hash 或版本号,确保同一接口不同参数的请求不会互相污染。这个逻辑放在策略层统一处理,三个平台一次性修复。
错误处理:错误码映射和重试策略的归一化
跨端项目里错误处理最容易碎片化。后端返回的错误码是一样的,但 iOS 把 401 映射成“登录过期”,Android 映射成“认证失败”,Web 映射成“请重新登录”——用户看到的文案都不一样,产品和 QA 会追着你问为什么。
错误处理共享层要解决两个问题:错误码到用户提示的映射统一,以及重试策略的统一。
错误码映射用一张配置表,放在共享核心里:
const ERROR_MAP: Record<number, ErrorConfig> = {
401: { message: '登录已过期,请重新登录', action: 'relogin', retryable: false },
403: { message: '暂无访问权限', action: 'alert', retryable: false },
500: { message: '服务器繁忙,请稍后重试', action: 'retry', retryable: true, maxRetries: 3 },
502: { message: '网关异常', action: 'retry', retryable: true, maxRetries: 2 },
// 网络层错误用负数区分
[-1]: { message: '网络连接异常,请检查网络设置', action: 'alert', retryable: true },
[-2]: { message: '请求超时', action: 'retry', retryable: true, maxRetries: 2 },
};
重试策略用指数退避,iOS、Android、Web 三个平台的网络环境差异很大——移动端切基站、进电梯、弱网场景远比 Web 复杂。所以重试策略虽然逻辑共享,但参数要允许平台微调。我们在共享层定义默认重试策略(3 次重试,指数退避,初始间隔 1 秒),各平台可以在初始化时覆盖参数:
interface RetryConfig {
maxRetries: number;
baseDelay: number; // 初始延迟毫秒
maxDelay: number; // 最大延迟上限
backoffMultiplier: number; // 退避倍数
retryableStatuses: number[];
}
一个血泪教训:不要对所有错误都重试。500 可以重试,但 502 在网关层已经有重试机制了,客户端再重试可能造成雪崩。403 重试也没意义。401 更不应该重试——先刷新 token 再重试,这叫 token 刷新的逻辑,不叫重试。把这些规则编码进共享层的错误处理模块里,避免各端自作主张。
传输层的平台适配:最小化差异面
共享层终究需要一个底层的 HTTP 传输能力。这部分没办法完全跨平台,但可以把差异面压到最小。定义一个 Transport 接口,只包含最基础的 request 方法:
interface Transport {
request(config: RequestConfig): Promise<Response>;
}
iOS 侧用 URLSession 实现,Android 侧用 OkHttp,Web 侧用 fetch。关键点是这个 Transport 实现不做任何业务逻辑——不拦截、不缓存、不重试、不映射错误码。它只负责把字节发出去、收回来。所有智能逻辑都在上一层的共享核心里完成。
我们项目里 Transport 接口的实现代码,iOS 侧只有 120 行,Android 侧 150 行,Web 侧 60 行。真正复用的共享网络层代码超过 2000 行。这个比例说明做对了。
还有一个容易忽略的点:请求取消。URLSession 的 cancellation、OkHttp 的 Call.cancel()、fetch 的 AbortController,三者的取消机制完全不同。共享层需要抽象出一个 CancelToken,各端 Transport 实现时把 CancelToken 映射到自己的取消机制上。我们在 2023 年初上线的一个版本里没处理好这个,结果 iOS 端页面退出后后台请求还在跑,导致内存泄漏和无效请求堆积,线上崩溃率上升了 0.3%。
共享代码的组织方式
技术上能跨,不代表工程上能落地。共享代码放哪里、怎么引入到各平台,这决定了方案能不能活过第一个迭代。
我们的做法是把共享网络层编译成一个独立的包,发布到私有 npm registry(Web 直接用),iOS 通过 CocoaPods 的 subspec 引入编译后的 JS bundle 或直接引入 TypeScript 源码用 JSC 引擎执行,Android 同理。关键在于保证三端运行的运行时环境一致——我们用 Hermes 在移动端执行共享层的 JS 代码,Web 端直接用浏览器引擎。Hermes 的 JS 引擎在 iOS 和 Android 上行为一致,避免了 JSCore 在 iOS 和 Android WebView 里行为不一致的问题。
如果你已经在用 React Native 或 Flutter,这件事会简单很多——直接在框架的网络层基础上封装即可。但如果你的项目是原生 iOS + 原生 Android + Web,就需要自己搭一个轻量的 JS 运行时桥。这个桥虽然有一定初始成本,但相比三套网络层的长期维护成本,投入产出比极高。
常见问题
跨端网络层共享代码用什么语言写最好?
TypeScript。类型系统能在编译期拦截大量参数错误,这对于需要同时对接三个平台的共享层至关重要。而且 TypeScript 编译到 JS 后可以跑在 Hermes、JSC、V8 等主流引擎上,各端运行时兼容性最好。我们试过用 Kotlin Multiplatform,iOS 侧的互操作层比预期复杂不少,最终放弃了。
如果三端的网络请求库已经定了(iOS 用 Alamofire、Android 用 Retrofit),还能做共享层吗?
能。共享层不替代底层的 Alamofire 或 Retrofit,而是坐在它们上面。共享层处理拦截链、缓存策略、错误映射这些纯逻辑,最后调用各端的 Transport 实现去发请求。Transport 实现内部用 Alamofire 或 Retrofit 都可以,对外暴露统一接口就行。
共享层的缓存和平台自带的 HTTP 缓存(如 NSURLCache)会不会冲突?
会,必须关掉平台层的 HTTP 缓存,统一交给共享层管理。NSURLCache 和 OkHttp Cache 的过期策略、缓存键规则和共享层不一致,两层缓存同时存在会导致数据不一致。我们在初始化 Transport 时显式关闭了平台缓存:iOS 设置 URLSessionConfiguration 的 urlCache 为 nil,Android 设置 OkHttpClient 的 cache 为 null。
共享层逻辑改了怎么同步到三个平台?
通过 CI 自动发布。共享层代码 merge 到主分支后,CI 自动跑三端的集成测试,通过后发布新版本包,iOS 和 Android 通过依赖管理工具更新版本号即可。关键是要有一套覆盖三端的集成测试,否则改了共享代码不知道哪个端会炸。我们用 Detox 跑移动端 E2E,Playwright 跑 Web 端,每次共享层变更都触发全量回归。