English version: A reference design: quotes and watchlist MVP in Flutter。这是交易类 App 架构系列的收尾篇。
前面每一篇都在论证一种机制。这一篇给出一个明确的方案:一个带搜索、自选和图表的行情应用,由两三个人建造,交易功能稍后再加。
“给出明确方案”这件事本身有意义。写“视情况而定”很容易,真正有用的产出是一个具体计划、得出它的推理,以及一份明确写出的“拒绝建造什么”。
诚实地写下约束
在选任何东西之前,先把总览里那六个量级,针对这个产品回答一遍:
| 约束 | 我们的量级 |
|---|---|
| 新鲜度 | 一秒一次行情,可见标的几百只 |
| 不可逆 | v1 没有,只读 |
| 目录 | 约 15 万只标的,需离线搜索 |
| 平台 | iOS 加 Android,一个小团队 |
| 团队 | 一个 |
| 威胁 | 公开行情数据;用户账户在 v2 |
六条里有五条都很小。这就决定了架构的大部分,而且决定的方向是更少。
方案
WebSocket ──▶ 解码 (Dart) ──▶ FrameCoalescer ──▶ QuoteStore ──▶ 叶子组件
│
REST ──▶ 目录增量同步 ──▶ SQLite(打包快照)──▶ 搜索
传输。 一条 WebSocket 走实时行情,其余走 REST。在这个速率下 JSON 完全够用;二进制编码是以后的优化,不是起步时的决定。
接收。 一开始就在 UI isolate 上解码。每秒几百条消息,耗时是微秒级的。等性能剖析说该挪的那天再挪到 isolate,不要提前。
合并。 状态那篇里的 FrameCoalescer 放在套接字和 store 之间。即使速率不高,这一个也值得在第一天就写:它只有三十行,却能永久地让渲染成本与数据速率无关。
状态。 QuoteStore 里按标的分的 ValueNotifier,叶子各自订阅。页面级状态你想用什么都行,粒度纪律只在热路径上才要紧。
目录。 预构建的 SQLite 文件作为资源发布,首次运行拷贝,按版本增量同步,用带拼音和首字母列的 FTS 搜索,见离线那篇。
图表。 CustomPainter,静态层和实时层放在各自的重绘边界里。如果现成库够用就先用库;等你需要指标副图或画线工具时再自己写。
结构。 一个 package,目录清晰。不搞 monorepo,理由见下。
我们刻意不建什么
这份清单才是这个方案真正的内容。任何一项都可以后加,纪律在于顺序。
- 不写原生代码,不用 FFI。 一个 1 Hz 的数据源,不足以支撑一条要你付出调用栈完整性和两套工具链的边界。
- 不做自研二进制协议。 在报文体积或解析耗时被测量出问题之前,就用 WebSocket 上的 JSON。
- 不做共享跨平台核心。 一份 Flutter 代码已经覆盖两个平台;共享领域层解决的是我们没有的问题。
- 不做一功能一 package 的 monorepo。 一个团队一个应用,package 边界在这里是开销,不是强制力。
- 不做 add-to-app。 没有可嵌入的宿主。
- 不做证书固定。 数据是公开的,而证书固定是一项我们目前还兑现不了的运维承诺。把标准 TLS 做扎实。
- 不做订单状态机。 因为没有订单。等它们到来时,那一篇就从选读变成必读。
每一行都是可以撤销的决定。写下来的意义正在于此:每一项都会有人提议,而答案应该是“还不到时候,以及什么会让我改变主意”。
建造顺序
第一阶段,目录与搜索。 发布快照,做出搜索,列出标的。此时还没有实时数据。这样做是为了在任何东西依赖它之前,先验证那个最大、最难回头的资产:数据库和它的同步。
第二阶段,实时行情。 WebSocket、合并器、按标的的 notifier、自选页。至此响应式链路端到端打通。从一开始就把带退避的重连和重新订阅写进去;事后补重连非常痛苦,因为它会牵动每一个订阅点。
第三阶段,图表。 走 REST 拉历史,自绘,两层。这是第一个光栅线程性能真正要紧的界面,也是你第一次该在中端设备上做性能剖析的地方。
第四阶段,账户与交易。 先做认证,然后是完整的订单可靠性设计:发送前落盘的幂等键、未知态、重连时对账。这一阶段会改变威胁模型,所以安全那篇从它的第一个提交起就适用。
一到三阶段没有不可逆动作,这正是它们可以快的原因。第四阶段是“谨慎”不再可选的地方,它值得一个更慢的节奏。
升级路径
这个方案真正的质量,不在于它现在能做什么,而在于它能在哪里生长而不必重写。每个重家伙都有明确的接缝和一个被测量到的触发条件:
| 元素 | 插在哪里 | 何时引入 |
|---|---|---|
| 解码用 isolate | 数据源接口之后 | UI 线程剖析显示解码已占用帧预算 |
| 二进制协议 | 数据源内部的编解码器 | 报文体积或解析耗时被测出问题 |
| 原生 / FFI 接收 | 替换数据源实现 | Dart 里的合并跟不上了;在这个速率下大概永远不会 |
| 功能 package | 从目录里抽出来 | 两个人反复冲突,或某一层需要隔离测试 |
| 共享领域核心 | 抽出 domain/ | 出现第二个不允许与之分叉的客户端 |
| 订单机制 | 新模块 | 第一个不可逆动作上线时 |
需要你今天就刻意设计的,是那些接缝:把数据源暴露为接口而不是一个具体的套接字类、目录访问放在仓储之后、业务规则不写进组件。这些现在都不花钱,而正是它们让未来的改动变成一次抽取,而不是一次重写。
唯一值得记住的事
如果读者只从这个系列里带走一个想法,那就是:在选择任何东西之前,先写下你的六个量级;然后每个重家伙都等到某次测量要求它的那天再引入。
大型架构并不更先进。它们是同样的约束在满量级下的样子,外加多年累积的迁移中间态。过早照抄它,意味着你付了它的协调成本,却完全没有它的规模。这是整个主题里最常见、也最昂贵的错误。
参考
API 与渲染器相关断言按 Flutter 3.47.0 / Dart 3.13(2026 年 8 月)核对。
- M. Fowler,“Yagni”,2015——预设功能的代价不只是构建它,还有背着它。
- D. McKinley,“Choose Boring Technology”,2015——创新代币,以及为什么 WebSocket 上跑 JSON 是正确的第一答案。
- J. Bezos,Amazon 2015 年致股东信,2016——Type 1(单向门)与 Type 2(双向门)决策。
上一篇:Flutter 工程结构 · 返回总览。