English version: When to adopt the heavy machinery: thresholds, not opinions。这是交易类 App 架构的判断标准篇。
这个系列的每一篇结尾,都写了某种版本的“等测量要求时再引入”。就这么说出来,这条建议是没有价值的。它没有指明指标、没有阈值、没有设备,于是在实际工作中,这个决定最后由会上讲话最自信的那个人做出。
所以这里给出数字。有些是算术,放之四海皆准;有些来自感知研究;有些是行业惯例,我会标明它是惯例。请根据你的产品去校准,但校准的应该是数字,不是感觉。
在此之前,先做一处比任何阈值都重要的更正。
不是所有东西都该等阈值
这个系列前面暗示所有事情共用一条决策规则。这是错的,而且是这个错误里更危险的那一半:把“等需要了再加”用在错误的类别上,产出的是一次重写,或者一次损失。
把每个候选项分进两个盒子。
A 盒:等待的代价有上界。 你以后再加,成本和现在加差不多。性能相关的几乎全在这里:isolate、二进制编码、原生接收、自绘图表、功能 package。如果你跳过了而且判断错了,症状是慢,而慢是可见的、可测量的、下个迭代能修的。
B 盒:等待的代价没有上界。 事后补要么意味着改动每一处调用点,要么等你察觉时损害已经无声地发生了。这些在任何规模下都在第一天建好,不存在阈值讨论:
- 不可逆动作上的幂等键,且在第一次发送之前就落盘。事后补意味着审计每一条写入路径,而在你补完之前,重复请求已经在生产环境里发生了。
- 每个请求都在服务端做授权。 一个信任客户端的接口,从它上线那天起就是一个已经存在的漏洞。
- 任何“丢包即损坏状态”的流上的序列号。 没有它,你连事后检测这个损坏都做不到。
- 重连与重新订阅。 事后加会牵动每一个订阅点,而它的缺失看起来和应用正常工作一模一样。
- 金额用十进制,不用二进制浮点。
0.1 + 0.2 != 0.3在账本里是一个舍入错误。把已持久化的结构从浮点迁走极其痛苦。 - 金融动作的只追加事件日志。 你无法重建一段你从未记录过的历史。
B 盒的判据是:如果缺了它,失败是无声的吗?它会损坏数据吗?补上它需要改动每一处调用点吗? 任何一个“是”,就是第一天做。
下面全部内容只针对 A 盒。这个区分,才是“什么时候该上重的东西”真正的答案。
先定义目标
一个阈值要有意义,需要三样东西,而大多数团队一样都没写:
带百分位的指标。 平均值恰好会掩盖你真正在意的问题。二十帧里有一帧耗时 40 毫秒,在均值里看不见,人却看得很清楚。用 p95 和 p99。
一台基准设备。 “在我手机上很流畅”不是数据。选一台具体的、大约三年前的中端机,把它定为准入门槛。旗舰机会让你通过一堆测试,而其中相当一部分用户会失败。
一个真实的负载。 生产环境的消息速率、生产环境的目录规模,以及至少 30 分钟的会话时长;泄漏和缓存增长在两分钟的测试里是看不见的。
写全了,一个可用的目标长这样:在基准设备上,以生产速率喂数据、屏幕上 200 只可见标的、运行 30 分钟后,UI 帧耗时 p95 低于 16 毫秒、p99 低于 24 毫秒。
现在这个阈值可以被违反了,而这正是它从一个意见变成一条决策规则的原因。
帧预算,这部分是算术
60 赫兹下,你每帧有 16.67 毫秒做完所有事;120 赫兹下是 8.33 毫秒。而且有两条线程都要塞进去:UI 线程(你的 Dart、构建、布局)和光栅线程(把结果画成像素)。
给 UI 线程的工作大约留半帧,也就是 60 赫兹下的 8 毫秒左右。其余留给布局、绘制、光栅化,以及任何真实系统都会有的尖峰所需的余量。如果你的 Dart 工作经常超过每帧 8 毫秒,那你离掉帧只差一次小小的波动,而掉帧恰好会聚集在应用最忙的时候。
这就是下面所有数字都以“毫秒每帧”表达的原因。它是唯一真实存在的预算。
逐项阈值
把解码挪到 isolate:当 DevTools 时间线显示,解析在 p95 下持续占用每帧超过 2 到 3 毫秒时。
低于这个数它只是噪声;高于它,你就把三分之一的 UI 预算花在了解析上。但要先走下面那个“便宜修法阶梯”。另外注意,isolate 之间发送的消息是拷贝的,所以一个话多的 isolate 可能比不用 isolate 还慢。要成批发送,用 TransferableTypedData 做零拷贝转移,或者用 Isolate.run——它把结果转移回来而不是拷贝。
换二进制协议:满足以下任一条件时。在合并之后,JSON 解析仍超过每帧约 2 毫秒;移动网络下持续载荷超过约 100 KB/秒;或消息速率超过大约每秒 1000 条。
在这之下,JSON 的成本真实存在,但不是你的瓶颈,而且它换来的可调试性是二进制格式会拿走的。
上原生(FFI):当 Dart 在用了 isolate 和合并之后仍然满足不了预算时。现实中大约是每秒 10000 条消息以上,或者是大数组上的数值计算,比如在十万根以上 K 线上计算指标、且需要亚毫秒级的确定性。
一个诚实的校准:在每秒几千条的量级上,Dart 完全够用;而且合并模式已经让你的渲染成本与数据速率无关。大多数在这个节点伸手要原生的产品,有的是合并问题,不是语言问题。
这里还有一条反向阈值: 如果团队里没有人能在两个平台上维护 C++,那么无论测量结果如何,答案都是不上。一个没人维护得了的优化是一份负债,而这也是唯一一处“团队能力”可以正当地压过数字的地方。
上本地数据库:当目录超过大约 5000 到 10000 行,或约 1 MB,或者只要搜索必须离线可用。
通常还有一个感知层面的论证会更早地定下结论:交互在大约 100 毫秒以内才会感觉是即时的(Nielsen 的经典阈值:0.1 秒即时,1 秒思路不断,10 秒注意力丧失)。移动网络的 p95 往返时延很少能塞进 100 毫秒。所以如果边输入边搜索必须感觉即时,那么本地化就不是优化,而是唯一能满足这条需求的设计。
自绘图表:当图表库表达不了你需要的东西时(联动的指标副图、画线工具、精确的十字光标语义),或者渲染元素超过大约 2000 个,或者序列以帧率更新。在这之下,用库。
抽出功能 package:当同一个代码库上的团队达到大约 4 到 5 名开发者,或增量构建超过 60 秒左右,或同一区域反复产生合并冲突(比如每月三次以上),或某一层需要被隔离测试时。
这些是惯例,不是物理定律。底层变量是协调成本,而它随人数上升,不随代码行数上升。Conway 的预测给了你一个合理性检查:模块数量大致应该等于团队数量。
上共享跨平台核心:当你有两个或更多互相独立的客户端代码库,并且有一批“分叉即缺陷”的领域规则时。如果一个团队用一份 Flutter 代码同时交付两个平台,共享其实已经发生了,再加一个共享核心是在解决你没有的问题。
Add-to-app 根本不是一个阈值,而是一种处境。当一个既有原生应用必须一边持续交付一边迁移时,你才采用它。如果没有可嵌入的宿主,这个问题不成立。
先走便宜的修法,按顺序
大多数阈值被突破的情况,根本不该走到架构改动那一步。沿这个阶梯往下走,在第一个能解决问题的台阶上停下:
- 少做事。 更小的载荷、更少的字段、查询加
LIMIT、分页、更少的可见项。通常收益最大,而且永远最便宜。 - 少做几次。 合并、防抖、缓存、记忆化。单是按帧合并一项,就能把很多“我们需要原生”的处境变成不存在的问题。
- 在更小的范围里做。 收窄重建、拆分重绘图层、减少失效区域。
- 换个地方做。 挪到 isolate,或者挪到服务端。
- 换个更快的语言做。 原生、FFI。
第 5 级正是这个系列里大部分“重家伙”所在的位置。在没试过 1 到 4 就直奔第 5 级,是团队最终得到一套复杂架构、性能却没变的最常见方式。
即使过了阈值,什么时候仍然不该上
四个反向信号,每一个都应该让你停下:
你运维不了它。 没有演练过轮换、也没有熔断开关的证书固定,是在等某张证书过期时给自己制造一次故障。没有维护者的原生代码,是一个你修不了的缺陷。能不能把一样东西跑起来,本身就是它成本的一部分。
触发的是尖峰,不是水位。 开盘时的糟糕一天不算突破阈值。要求这个违反是持续的,看一周的真实使用,而不是一次性能剖析。
更便宜的台阶还没试过。 如果你没做过上面的 1 到 4,你还不知道自己面对的是一个问题,还是一份负载。
它是一扇单向门。 可逆的决定值得快速、廉价地讨论;难以回头的决定值得更慎重。一个下午就能撤销的二进制协议是双向门;一个已持久化的数据结构、一份公开的 API 契约、或者一个已被三个团队依赖的原生模块,则不是,它们值得走这个流程的慢速版本。
决策程序
- 在你需要它之前,先写下目标:指标、百分位、基准设备、负载。
- 在真实条件下测量。不是合成基准测试,不是旗舰手机。
- 一旦违反,从阶梯第 1 级开始往下走。
- 如果阶梯已走完而违反是持续的,就采用;但先过一遍那四个反向信号。
- 记录这个决定:什么触发了它、你测到了什么、以及你需要看到什么才会撤销它。半年之后,这就是“一套架构”和“一堆沉积物”的区别。
第 5 步比它看起来重要。大部分糟糕架构并不是一个糟糕的决定,而是一个没人能重新检视的决定,因为当初的推理从未被写下来。
那么,只有 MVP 吗?
不是,而且它不该被读成上限。那个参考 MVP 是与它的约束相匹配的正确起点,那篇里的升级表指明了各处接缝,而本文提供了触发每一次升级的数字。两者合起来描述的是一条路径,不是一个终点。
大致分四档,你可以据此定位自己:
| 档位 | 负载与团队 | 跑什么 |
|---|---|---|
| 1 | 每秒 10 次以下更新,1 到 2 人 | 一份代码库、REST 加 WebSocket、JSON、内存缓存、图表库 |
| 2 | 每秒约 100 次更新,2 到 5 人 | 加:按帧合并、带增量同步的本地目录库、自绘图表 |
| 3 | 每秒约 1000 次更新,若干团队 | 加:解码 isolate、二进制编码、功能 package;若有两份代码库则加共享领域核心 |
| 4 | 每秒 10000 次以上,多团队,受监管 | 加:原生接收、自研传输、模块注册表、完整审计与对账 |
这张表有两点值得注意。每一档都是前一档的超集,这正是这条路走得通的原因:没有东西被丢弃,只是把接缝填上。以及,本文开头那个 B 盒适用于每一档,包括第 1 档。一个只有一名开发者、但会下真实订单的应用,对幂等键和服务端授权的需要,和第 4 档系统完全一样多。
这就是完整的判断框架:正确性没有阈值;性能有;而阈值是基准设备上的一个数字,不是一个意见。
参考
API 与渲染器相关断言按 Flutter 3.47.0 / Dart 3.13(2026 年 8 月)核对。
- J. Nielsen,“Response Times: The 3 Important Limits”,1993——0.1 秒、1 秒、10 秒。
- J. Dean, L. A. Barroso,“The Tail at Scale”,CACM 56(2),2013——为什么描述用户体验的是分位数而不是平均值。
- J. Bezos,Amazon 2015 年致股东信,2016——Type 1(单向门)与 Type 2(双向门)决策。
- M. E. Conway,“How Do Committees Invent?”,Datamation,1968——系统结构会复刻构建它的组织的沟通结构。
- D. Goldberg,“What Every Computer Scientist Should Know About Floating-Point Arithmetic”,ACM Computing Surveys 23(1),1991——为什么二进制浮点无法表示十进制的钱。
- Dart 文档,Isolates——isolate 之间的消息是拷贝的;
Isolate.run则转移其结果。另见TransferableTypedData。 - Flutter 文档,Use the Performance view——UI 与 raster 时间线、帧预算与重建计数器。