# 参考方案：用 Flutter 做一个行情与自选 MVP

> 给小团队的一套具体、可直接开工的架构：建什么、按什么顺序建、刻意不建什么，以及每个重家伙究竟要等到哪个被测量到的信号出现才配被引入。

- Canonical (HTML): https://kaguc.com/blog/flutter-trading-mvp-zh/
- Date: 2026-08-28


*English version: [A reference design: quotes and watchlist MVP in Flutter](/blog/flutter-trading-mvp/)。这是[交易类 App 架构](/blog/trading-app-architecture-zh/)系列的收尾篇。*

前面每一篇都在论证一种机制。这一篇给出一个明确的方案：一个带搜索、自选和图表的行情应用，由两三个人建造，交易功能稍后再加。

“给出明确方案”这件事本身有意义。写“视情况而定”很容易，真正有用的产出是一个具体计划、得出它的推理，以及一份明确写出的“拒绝建造什么”。

## 诚实地写下约束

在选任何东西之前，先把[总览](/blog/trading-app-architecture-zh/)里那六个量级，针对**这个**产品回答一遍：

| 约束 | 我们的量级 |
|---|---|
| 新鲜度 | 一秒一次行情，可见标的几百只 |
| 不可逆 | v1 没有，只读 |
| 目录 | 约 15 万只标的，需离线搜索 |
| 平台 | iOS 加 Android，一个小团队 |
| 团队 | 一个 |
| 威胁 | 公开行情数据；用户账户在 v2 |

六条里有五条都很小。这就决定了架构的大部分，而且决定的方向是**更少**。

## 方案

```text
WebSocket ──▶ 解码 (Dart) ──▶ FrameCoalescer ──▶ QuoteStore ──▶ 叶子组件
                                                       │
REST ──▶ 目录增量同步 ──▶ SQLite（打包快照）──▶ 搜索
```

**传输。** 一条 WebSocket 走实时行情，其余走 REST。在这个速率下 JSON 完全够用；二进制编码是以后的优化，不是起步时的决定。

**接收。** 一开始就在 UI isolate 上解码。每秒几百条消息，耗时是微秒级的。等性能剖析说该挪的那天再挪到 isolate，不要提前。

**合并。** [状态那篇](/blog/flutter-highfreq-state-zh/)里的 `FrameCoalescer` 放在套接字和 store 之间。即使速率不高，这一个也值得在第一天就写：它只有三十行，却能永久地让渲染成本与数据速率无关。

**状态。** `QuoteStore` 里按标的分的 `ValueNotifier`，叶子各自订阅。页面级状态你想用什么都行，粒度纪律只在热路径上才要紧。

**目录。** 预构建的 SQLite 文件作为资源发布，首次运行拷贝，按版本增量同步，用带拼音和首字母列的 FTS 搜索，见[离线那篇](/blog/flutter-offline-store-zh/)。

**图表。** `CustomPainter`，静态层和实时层放在各自的重绘边界里。如果现成库够用就先用库；等你需要指标副图或画线工具时再自己写。

**结构。** 一个 package，目录清晰。不搞 monorepo，理由见下。

## 我们刻意不建什么

这份清单才是这个方案真正的内容。任何一项都可以后加，纪律在于顺序。

- **不写原生代码，不用 FFI。** 一个 1 Hz 的数据源，不足以支撑一条要你付出调用栈完整性和两套工具链的边界。
- **不做自研二进制协议。** 在报文体积或解析耗时被测量出问题之前，就用 WebSocket 上的 JSON。
- **不做共享跨平台核心。** 一份 Flutter 代码已经覆盖两个平台；共享领域层解决的是我们没有的问题。
- **不做一功能一 package 的 monorepo。** 一个团队一个应用，[package 边界](/blog/flutter-project-structure-zh/)在这里是开销，不是强制力。
- **不做 add-to-app。** 没有可嵌入的宿主。
- **不做证书固定。** 数据是公开的，而证书固定是一项我们目前还兑现不了的运维承诺。把标准 TLS 做扎实。
- **不做订单状态机。** 因为没有订单。等它们到来时，[那一篇](/blog/order-reliability-zh/)就从选读变成必读。

每一行都是可以撤销的决定。写下来的意义正在于此：每一项都会有人提议，而答案应该是“还不到时候，以及什么会让我改变主意”。

## 建造顺序

**第一阶段，目录与搜索。** 发布快照，做出搜索，列出标的。此时还没有实时数据。这样做是为了在任何东西依赖它之前，先验证那个最大、最难回头的资产：数据库和它的同步。

**第二阶段，实时行情。** WebSocket、合并器、按标的的 notifier、自选页。至此响应式链路端到端打通。从一开始就把带退避的重连和重新订阅写进去；事后补重连非常痛苦，因为它会牵动每一个订阅点。

**第三阶段，图表。** 走 REST 拉历史，自绘，两层。这是第一个光栅线程性能真正要紧的界面，也是你第一次该在中端设备上做性能剖析的地方。

**第四阶段，账户与交易。** 先做认证，然后是完整的[订单可靠性](/blog/order-reliability-zh/)设计：发送前落盘的幂等键、未知态、重连时对账。这一阶段会改变威胁模型，所以[安全那篇](/blog/client-security-model-zh/)从它的第一个提交起就适用。

一到三阶段没有不可逆动作，这正是它们可以快的原因。第四阶段是“谨慎”不再可选的地方，它值得一个更慢的节奏。

## 升级路径

这个方案真正的质量，不在于它现在能做什么，而在于它能在哪里生长而不必重写。每个重家伙都有明确的接缝和一个**被测量到**的触发条件：

| 元素 | 插在哪里 | 何时引入 |
|---|---|---|
| 解码用 isolate | 数据源接口之后 | UI 线程剖析显示解码已占用帧预算 |
| 二进制协议 | 数据源内部的编解码器 | 报文体积或解析耗时被测出问题 |
| 原生 / FFI 接收 | 替换数据源实现 | Dart 里的合并跟不上了；在这个速率下大概永远不会 |
| 功能 package | 从目录里抽出来 | 两个人反复冲突，或某一层需要隔离测试 |
| 共享领域核心 | 抽出 `domain/` | 出现第二个不允许与之分叉的客户端 |
| 订单机制 | 新模块 | 第一个不可逆动作上线时 |

需要你今天就刻意设计的，是那些接缝：把数据源暴露为接口而不是一个具体的套接字类、目录访问放在仓储之后、业务规则不写进组件。这些现在都不花钱，而正是它们让未来的改动变成一次抽取，而不是一次重写。

## 唯一值得记住的事

如果读者只从这个系列里带走一个想法，那就是：**在选择任何东西之前，先写下你的六个量级；然后每个重家伙都等到某次测量要求它的那天再引入。**

大型架构并不更先进。它们是同样的约束在满量级下的样子，外加多年累积的迁移中间态。过早照抄它，意味着你付了它的协调成本，却完全没有它的规模。这是整个主题里最常见、也最昂贵的错误。


## 参考

*API 与渲染器相关断言按 Flutter 3.47.0 / Dart 3.13（2026 年 8 月）核对。*

- M. Fowler，["Yagni"](https://martinfowler.com/bliki/Yagni.html)，2015——预设功能的代价不只是构建它，还有背着它。
- D. McKinley，["Choose Boring Technology"](https://boringtechnology.club/)，2015——创新代币，以及为什么 WebSocket 上跑 JSON 是正确的第一答案。
- J. Bezos，[Amazon 2015 年致股东信](https://s2.q4cdn.com/299287126/files/doc_financials/annual/2015-Letter-to-Shareholders.PDF)，2016——Type 1（单向门）与 Type 2（双向门）决策。

---

*上一篇：[Flutter 工程结构](/blog/flutter-project-structure-zh/) · 返回[总览](/blog/trading-app-architecture-zh/)。*

