网络层与本地存储
客户端最容易被低估的两件事:网络层要按「弱网是第一现场」来设计,存储要按「设备可能随时消失」来设计。本页把各端通用(iOS / Android / Flutter / RN)的网络与存储工程要点收在一起,平台示例标注对应 API,细节可回到各平台页。
网络栈分层
客户端网络不是「发个请求」,而是可分层治理的系统:
| 层 | 职责 | 要点 |
|---|---|---|
| 业务请求层 | 参数组装、返回模型、错误语义 | 一处封装,别每个页面自己拼 URL |
| 传输会话层 | 超时、重试、头注入、日志 | 拦截器/中间件统一做 |
| 缓存层 | HTTP 缓存、内存/磁盘缓存 | 命中本地先行(见下) |
| 连接感知层 | 联网状态、网络切换 | NWPathMonitor / Connectivity |
| 传输层 | TLS、socket | 交给系统与框架,别手写 |
统一「错误语义」是分层最重要的小事:给 UI 一个结构化的失败原因(无网 / 超时 / 服务端错误码 / 业务拒绝),而不是让 UI 解析字符串。
swift
enum APIError: Error { case noNetwork, timeout, server(Int), business(String) }弱网策略(比强网更该设计)
移动网络不是 Web 的网:电梯、地铁、跨网切换是常态。
| 问题 | 工程对策 |
|---|---|
| 无网或极弱 | 连接状态变化驱动:先给缓存 + 离线标记,恢复后自动补拉 |
| 超时误判 | 分层超时:连接/读/总超时分开;弱网放宽读超时 |
| 瞬时抖动失败 | 自动重试:指数退避 + 抖动,最多 2-3 次;写请求要幂等才能重试 |
| 用户不耐烦重试 | 请求去重/合并:同一资源在途时并发请求只发一次 |
| 列表滚动连发 | 节流与预取:滚近底部才拉下一页,图片按可视区加载 |
一个「重试要幂等」的反例:支付/下单这类写请求,如果客户端在服务端已成功后重试,会导致重复扣款——写请求必须有幂等键(由业务生成,服务端去重),这套思想与后端消息队列的幂等设计同源。
缓存体系:四层
| 层 | 载体 | 用途 | 失效 |
|---|---|---|---|
| 内存缓存 | NSCache / 自建 LRU | 高频小数据、图片解码结果 | 内存告警即清,容量上限 |
| 磁盘缓存 | 沙盒 cacheDir / 自建 | 图片、大 JSON | LRU 淘汰 + 容量上限 |
| HTTP 缓存 | URLCache / OkHttp cache | 服务端带头信息的响应 | ETag / Last-Modified |
| 业务缓存 | 本地库/文件 | 首页、feed 离线快照 | 版本或时间窗 |
条件请求是「有缓存先展示、后台静默刷新」的关键:请求带 If-None-Match/If-Modified-Since,服务端返回 304 表示没变(复用缓存),200 表示更新。这比「总是全量重拉」省流量省时间。
原则:界面优先快(缓存),一致性交给后台刷新——用户可感知的延迟多来自不必要的等待,而非多 100ms 的渲染。
离线写入与冲突处理
离线做写入(评论、笔记、收藏)需要「先本地后同步」:
- 本地先行:写入本地队列 + 乐观 UI(先显示成功),后台同步;
- 幂等:队列里每条带本地幂等键;
- 冲突必然发生,别假装不会:
- 简单策略:last-write-wins(最后写入者胜)——要有明确的时间基准,否则两端各自「我赢」;
- 服务端权威:服务器版本号/时间戳裁决,客户端检测到冲突走合并 UI(提示用户取舍);
- 慎用「本地永远覆盖服务器」——除非产品明确「本机为准」(如本地笔记)。
序列化与模型兼容
后端接口演进会删字段/加字段,客户端必须能「向前兼容」:
- 新加字段:客户端模型给默认值 /
decodeIfPresent,老客户端解析新响应不崩; - 删字段/改语义:按服务端 API 版本规划,客户端显式处理旧值;
- 本地缓存里的模型也要带 schema 版本,升级后做迁移(同桌面端配置迁移思路)。
本地存储盘点
| 平台 | 轻量键值 | 偏好推荐 | 结构化 DB | 敏感数据 |
|---|---|---|---|---|
| iOS | UserDefaults | 小配置 | SwiftData / Core Data / GRDB | Keychain |
| Android | SharedPreferences(旧) | DataStore | Room / SQLite | Keystore + 加密存储 |
| Flutter | shared_preferences 插件 | 同左 | sqflite / drift | flutter_secure_storage(封装 Keychain/Keystore) |
| RN | AsyncStorage / MMKV | MMKV | expo-sqlite / WatermelonDB | expo-secure-store / react-native-keychain |
| 桌面 | electron-store / tauri-plugin-store | 同左 | SQLite | OS 钥匙串/凭据管理器 |
选择铁律:
- UserDefaults / DataStore / SharedPreferences 只放非敏感偏好(主题、语言、引导状态),不放 token/口令;
- 敏感数据(token、私钥、口令)一律进 Keychain / Android Keystore / 对应 OS 安全存储——它们硬件/系统级加密且随账号走;就算数据库加密,密钥来源也应是 Keychain 而非硬编码;
- 结构化、可查询、要事务 → SQLite 家族;纯键值 → 键值;大文件(图片/媒体)→ 文件系统 + 索引,别塞数据库。
敏感数据存放红线
- 不在代码里硬编码密钥/证书;调试用不同 bundle/应用标识,避免真密钥混入(密钥保管见打包、签名与发布,「客户端安全与合规」主题在模块规划清单中待补齐);
- 日志/崩溃上报里别打 token、完整手机号、密钥——统一脱敏器后再出日志;
- 系统剪贴板、截屏、后台任务里对敏感界面做保护(Android FLAG_SECURE 等按产品需要);
- 越狱/Root 设备上 Keychain/Keystore 防护减弱,风险由产品定(不在本页展开)。
常见坑速查
| 坑 | 现象 | 对策 |
|---|---|---|
| 弱网只调超时 | 电梯里永远转圈/失败 | 连接感知 + 缓存先行 + 恢复补拉 |
| 重试不幂等 | 写请求重复执行扣两次 | 写请求带幂等键,服务端去重 |
| 缓存全盘禁 | 每次都全量拉数据 | 分级缓存 + 条件请求 |
| 内存缓存无上限 | 图片缓存把内存吃满 | 容量上限 + 低内存自动清 |
| 离线直接禁用 | 没网就白屏 | 离线快照 + 乐观队列 + 恢复同步 |
| token 存偏好 | 被备份/明文可读 | 进 Keychain/Keystore |
| 忘记脱敏 | 崩溃日志带出 token | 统一日志脱敏层 |
| 本地库塞大文件 | DB 膨胀、IO 变慢 | 文件与索引分离 |
| 模型没默认值 | 接口加字段后老包崩 | decode 给默认值/可选 |
| 冲突覆盖用户数据 | 双端同时改互相丢 | 服务端裁决 + 合并提示 |
| 退到前台不刷新 | 数据永远是旧的 | scenePhase/App 生命周期挂刷新策略 |
检查清单
- [ ] 网络统一封装:分层超时、错误语义结构化、日志脱敏;
- [ ] 弱网:连接感知 + 缓存先行 + 恢复补拉已实现;
- [ ] 写请求全部幂等,同步队列有重试与冲突处理;
- [ ] 缓存四层有容量上限与低内存清理;
- [ ] 模型解码带默认值与 schema 版本,本地缓存可迁移;
- [ ] 敏感数据在 Keychain/Keystore,无硬编码密钥。
相关链接:iOS 开发 · Android 开发 · Flutter · React Native · 写作规范见客户端开发索引。