成为全栈·Flutter App 篇·第四批复盘:复用了什么,移动端又新增了什么
到这一篇,四个批次做完了。
先摆数据,因为复盘最容易变成感想,而数字不会骗人:
| 端 | 文件 | 行数 |
|---|---|---|
| Node 后端 | 98 | 10820 |
| React 管理后台 | 143 | 17260 |
| Next.js 前台 | 129 | 19747 |
| Taro 小程序 | 51 | 3417 |
| Flutter App(生产) | 122 | 10918 |
| Flutter 测试 | 11 | 2096 |
契约:54 paths / 68 operations,五端共享。
四篇正文(工程骨架、Dio + Repository、Refresh Token、错误限流)加起来写了大约 6300 行 Dart。而真正的原因是:移动端不是"写第四遍",而是"第一次真的遇到客户端问题"。
这篇讲两件事:哪些东西真的复用了 (比预期少),以及新增了什么(比预期多)。

复用了什么:比想象少
先说复用,因为这一条的结论可能和直觉相反。
| 复用了 | 复用程度 | 说明 |
|---|---|---|
| 契约 | ⭐⭐⭐⭐⭐ | 68 个操作直接生成模型,一个字段没手改 |
| 信封格式 | ⭐⭐⭐⭐⭐ | {code, message, data} 完全一致 |
| 错误码分段 | ⭐⭐⭐⭐⭐ | 1xxx/2xxx/3xxx/4xxx/5xxx 直接可用 |
| 缓存策略思路 | ⭐⭐⭐ | 概念复用,实现全部重写 |
| Repository 分层 | ⭐⭐ | 名字一样,代码几乎不一样 |
| 状态管理 | ⭐ | Dart 的 Riverpod 和 React 完全不同 |
| 表单校验 | ⭐ | RHF + Zod 的经验要重新翻译 |
看起来最该复用的两个,实际复用度最低。
Repository 名字一样,代码不一样
M4-07 讲的 ReaderRepository,和 M2 的 React 请求层,概念上是同一个东西:网络层、缓存层、模型适配层。
但代码上几乎没有可搬的。原因是:
前端的 Repository 面对的是"多形态响应 + 浏览器缓存 + 用户态切换",而 Flutter 面对的是"跨会话代次 + 离线优先 + 私有隔离"。
举个具体的:React 那边用内存缓存(页面刷新就没了),Flutter 那边要处理 App 被杀后磁盘缓存还在。而且 Flutter 的缓存键要带 session:${userId}:${epoch},React 那边用 localStorage 存 token 时压根没这个问题(因为每个页面的数据是现取的)。
所以复用的是"分层这件事本身",而不是"分层的代码"。
缓存概念复用,实现重写
M4-23 讲的 fresh/stale/expired 三态,在 Web 那边对应的是 Service Worker + Cache-Control 策略。
概念几乎一样,但代码一行都用不上 ------浏览器的缓存是 HTTP 层做的,客户端管不了;而 Dart 这边要自己实现 fence、_versions 哨兵、后台更新这些机制。
而 Web 那边也有 Dart 这边没有的问题:CORS、浏览器缓存的粒度不可控、Service Worker 的更新时机。
两边各有各的坑,而且完全不同。 这算不算"复用"?我倾向于算------知道另一端怎么处理,能帮你判断"移动端这个设计是不是合理"。 比如知道 Web 端会因为缓存粒度不可控而必须做缓存失效,就理解了为什么 Flutter 这边要把标签映射做得那么细。
新增了什么:四个后端没有的问题
复用的少,新增的多。而新增的这四类,后端和 Web 都基本不涉及。
一、跨会话代次
这是 Flutter 端独有的核心概念。
后端无状态,不需要考虑"会话换代"。Web 端有 token,但刷新失败就跳登录页,状态很简单。
而 Flutter 这边的场景是:用户可能同时有多条异步操作在飞,而它们跨越了一次会话切换。
M4-09 讲的 epoch 就是为这个存在的:登出时 ++,之后所有在途请求检查它,不匹配就丢弃。
dart
class SessionChanged implements Exception {}
这个概念在 Web 端找不到对应物。 因为浏览器里发出去的请求要么已经到服务端了,要么还在排队,没有"App 在后台被回收然后换了用户"这种状态跃迁。
而 M4-12 的 serial、M4-10 的 ticket、M4-15 的 url == currentUrl ------这些其实是同一个模式在四个地方的应用:
异步发起时记下身份,回来时核对身份,不匹配就丢弃。
Dart 单线程,没有数据竞争,但逻辑竞争一样存在。而 Flutter 的 Widget 生命周期让"逻辑竞争"更容易发生(因为页面随时可能被销毁重建)。
二、资源预算
第二类新增:内存和磁盘都有硬上限。
dart
abstract final class CacheLimits {
static const memoryEntries = 300;
static const memoryBytes = 20 * mib;
static const imageDiskBytes = 150 * mib;
static const decodedImageBytes = 64 * mib; // ← 这个是移动端特有
...
}
decodedImageBytes 那行值得单独说:它限制的是解码后的位图内存,而磁盘上压缩文件只有几 MB。 一张 2MB 的 JPEG 按 4000×3000 解码要占 48MB。
Web 端没有这个问题------浏览器的图片解码由浏览器管,页面用多少内存不由你控制(但也不会 OOM)。
而 Flutter 端必须自己管,因为一个列表页滚动时可能同时持有十几个解码后的位图。不限制的话,低端机上直接 OOM 崩溃。
顺带说这也解释了 M4-13 讲的那个"代码块缓存不缓存主题颜色"------移动端的内存压力让每一个字节都要算。Web 端不会有这种精细的取舍。
三、跨平台差异
第三类:同一份代码,两个平台行为不同。
M4-21 讲的 Android retrieveLostData 是最好的例子:
| 平台 | 行为 |
|---|---|
| Android | App 被回收后恢复选择结果,需要 retrieveLostData 取回,且必须再调一次清空队列 |
| iOS | 结果直接丢,没有恢复机制 |
这个差异不写代码是发现不了的,因为文档不会主动告诉你"Android 上这个 API 有个反直觉的清理约定"。
类似的还有 M4-27 说的 ATS:iOS 默认禁止明文 HTTP,而 Android 随便发。所以本地开发在 iOS 上要额外配置。
而平台返回手势的差异更大------Android 硬件返回键、iOS 边缘滑动、Web 里根本没有。 M4-15 那个"返回回来源文章"的决策,要正确实现就得处理三条路径。
这一类是纯经验,没有捷径。 只能靠"这个平台会怎样"的经验,而经验的积累很慢。
四、离线优先的语义
第四类,也是最影响架构的一类。
Web 端默认假设网络存在。而 Flutter 这边,用户可能在地铁里、电梯里、山里。
这带来的不只是"要缓存",而是缓存必须有状态:
| 状态 | 界面表现 |
|---|---|
| 首次加载中 | 骨架屏 |
| 有缓存 + 后台更新中 | 显示缓存 + 不打扰 |
| 有缓存 + 更新失败 | 显示缓存 + 提示"更新失败" |
| 无缓存 + 失败 | 全屏错误 + 重试 |
| 无缓存 + 成功 | 正常 |
M4-10 讲的 AsyncPane 那段 build 里三层判断,实现的就是这张表。
而"有缓存但更新失败时显示缓存加提示"这个状态,在 Web 端基本不存在------因为数据是现取的,没有"上次取的那份"这个概念。
三个跨端通用的模式
虽然实现不同,但有四个模式在五端重复出现,而且它们都是可以提前设计的。
一、先查白名单,不是黑名单
M4-07 讲的缓存策略:policy() 返回 null 就直接联网。
dart
CachePolicy? policy(String path) =>
path.endsWith(Endpoints.likeSuffix) ? null : _rules[family(path)]?.policy;
白名单意味着"忘记配置 = 不缓存",黑名单意味着"忘记配置 = 全缓存"。
在涉及私有数据的地方,前者安全得多。这条经验在管理后台也用过(哪些页面要权限检查)。
二、失败时保留旧值,只在"重试也没用"时清空
这个模式在四个批次里出现:
| 场景 | 保留 | 清空 |
|---|---|---|
| 列表加载失败 | 超时、429、网络错误 | 401/403/404 |
| 缓存事件失败 | 后台失败 | 缓存被删除 |
AsyncPane 加载失败 |
同上 | 同上 |
| 概览加载失败 | 同上 | 同上 |
dart
if (repo.forbidden(e) || dependencies.any((k) => !repo.cache.usable(k))) {
items = [];
page = 0;
}
判断标准是"重试会不会变",而不是"有没有错误"。 这个逻辑在后端也用得上(比如管理后台的表格加载)。
三、服务端约束只能收紧
M4-23 讲的那个:
dart
if (age < fresh) fresh = age; // 只在更短时生效
这个模式在别的地方也成立:多个来源给出同一约束时,取最严的。
比如后端的参数校验 + 客户端校验,以服务端为准;超时设置 + 上游网关超时,取小的。
四、命名代替下标
M4-18 讲的那个:
dart
{
'favorites': Endpoints.meFavorites,
'history': Endpoints.meHistory,
}.entries.map((entry) async => MapEntry(entry.key, await read(entry.value)))
顺序是一种隐式契约,而它没有编译期保护。
同样的思路在 M4-07 的 ResourceFamily 枚举里------17 个家族用枚举而不是字符串比较,改错了编译器会告诉你。

复盘本身踩的一个坑:以为复用失败了
写这篇的时候我做了一件很典型的事:统计代码复用率。
我按"这段逻辑在别的端能不能直接用"逐个模块过了一遍,结论是------复用率很低。 于是我的第一版复盘写得有点沮丧:辛辛苦苦做完第四端,结果什么都是新的。
后来我去翻 M1 和 M2 的代码,发现两件事:
第一,我数错了。 我只数了"能复制粘贴的代码",而没数"因为契约相同所以不用重新设计"的部分。
那 68 个接口在五端共享,这件事在代码里几乎看不到------它体现为每一个端都少了一次"这个接口返回什么形状"的思考。而这部分思考才是契约最有价值的地方。
第二,我把"复用"这个词定义窄了。 我心里想的是"代码能直接搬",但在这个多端项目里,真正的复用是"决策能直接搬":
dart
// 五端共享的东西,不是代码,是这些规则
ResourceFamily.reaction => {
'reactions:${Endpoints.articleId(path)}',
Endpoints.meLikes,
'overview',
};
M4-07 讲的那句"只读缓存失败时保留旧值,只在 forbidden 时清空"------M2 的 React 请求层有几乎一模一样的判断。但那不是复制粘贴出来的,是我想明白了之后独立写出来的。
而独立写出来反而更好,因为它经得起自己想一遍。复制粘贴的代码能跑,但复制时没经过的思考会在下一个 bug 里暴露。
所以我把结论改了:复用度低是因为这些逻辑需要独立思考,而不是因为它们没价值。
这个纠正花了点时间,因为我得承认自己一开始的判断是错的------而那套统计方法本身是有问题的(它把"可复制"等同于"有价值")。
一笔真实的债
最后说三个我知道欠着的东西。
一、iOS 一行没验证。 M4-27 说得很直白。这一批所有关于 iOS 的表述都是"应该可以",不是"验证过"。
二、缓存的 LRU 续期副作用没解决。 M4-23 提过:读缓存会更新 saved 时间戳,导致通知这类高频读取的数据永远不进 stale 状态,也就不会后台更新。
解法应该是拆成两个字段 (saved 给 maxAge 用,fetchedAt 给 fresh 用),但我没做------因为它需要改缓存层的核心数据结构,而收益只在通知这类场景上。
三、测试覆盖了缓存,没覆盖 UI。 M4-26 说了我不为覆盖率补测试。但这里有个具体的缺口:AsyncPane 的三层状态判断没有 Widget 测试(我测了它加载什么,没测它在"有缓存+失败"时渲染什么)。
而那三层判断恰好是 M4-10 说的"接上真实接口才暴露问题"的地方。这个缺口和我当时决定不补覆盖率是一致的,只是它有具体位置。

这一批真正学到的东西
如果要我用一句话总结这一批,那就是:
移动端不是"又一个前端",它是一类有自己固有约束的客户端------内存有预算、网络会断、平台会分叉、用户会杀进程。
这些约束产生了一批后端和 Web 端不存在的设计:会话代次、资源上限、跨平台适配、离线优先。
而它们里面有一个共同点:都是"在某个时刻回头看才发现需要"的。
- 会话代次:只有当"用户登出时那个请求还在飞"才会想到
- 内存预算:只有当低端机 OOM 才会想到
- 跨平台差异:只有当 iOS 上跑不通才会想到
- 离线状态:只有当用户在地铁里才会想到
所以"先写第四端"的顺序,其实是有代价的。 更聪明的做法可能是:先把移动端的特有约束列出来,作为设计输入,而不是做的时候才发现。
这个认识对我下一批(Taro 小程序)有直接影响------小程序也有它的特有约束:包体积限制(主包 2MB)、审核周期、真机调试困难、微信 API 的调用配额。
而我在 M5 之前,已经知道该关注什么了。
这个批次到此结束。下一批是小程序。
延伸阅读
- Flutter 工程骨架与 OpenAPI 代码生成
- Flutter 缓存:fresh、stale、expired 与账号边界
- 测试分层:单元、Widget、集成与线上只读验证
- Flutter 构建与发布准备:Android、iOS 和签名边界
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
