成为全栈·Flutter App 篇·第四批复盘:复用了什么,移动端又新增了什么

成为全栈·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 之前,已经知道该关注什么了。


这个批次到此结束。下一批是小程序。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
jason.zeng@15022073 小时前
使用vscode+flutter开发android程序(入门)
android·vscode·flutter
jason.zeng@15022074 小时前
Flutter 项目标准分层目录(repository/dto/page,对齐后端思想)
flutter
传奇开心果编程1 天前
【Compose Multiplatform跨端声明式UI实用技术】第7课:列表与惰性布局
移动开发·列表·编程语言理论·开发范式·编程框架·声明式ui·惰性布局
传奇开心果编程2 天前
【Compose Multiplatform跨端声明式UI实用技术】第6课:常用组件与 Material 3 设计系统
移动开发·编程语言理论·开发范式·编程框架·声明式ui·常用组件·material 3 设计系统
程序员老刘2 天前
跨平台开发地图 | 2026年10月
flutter·ai编程·客户端
林瞅瞅2 天前
Flutter 开发环境从零搭建与调试记录
flutter
FungLeo2 天前
成为全栈·Flutter App 篇·Markdown 编辑器:编辑、预览与离开保护
flutter·移动开发·markdown编辑器·表单设计·成为全栈·草稿保存
传奇开心果编程2 天前
【Compose Multiplatform跨端声明式UI实用技术】第3课:Compose Hot Reload 与开发效率工具链
移动开发·编程语言理论·热重载·开发范式·编程框架·声明式ui·效率工具链