Add skills-lock.json to manage skill dependencies for drawio-skill and prd
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
# 01. 工程结构 / 分包策略
|
||||
|
||||
## 决策
|
||||
|
||||
使用 **[Melos](https://melos.invertase.dev/) monorepo**,按 **feature** 拆分成独立 Dart package,而不是单一 Flutter package 内部用文件夹分层。
|
||||
|
||||
## 包结构总览
|
||||
|
||||
```
|
||||
conti-app/
|
||||
pubspec.yaml # 根 workspace 配置(melos 8.x 不再有独立 melos.yaml,见下文)
|
||||
.fvmrc # 锁定 Flutter SDK 版本
|
||||
analysis_options.yaml # 全仓库共享 lint 规则
|
||||
app/ # 壳工程:唯一的 Flutter application,负责路由汇总、DI 装配、编译出 ipa/apk
|
||||
packages/
|
||||
core_ui/ # 通用组件、主题、设计 token
|
||||
core_network/ # dio 封装、拦截器、统一异常、ApiResult 解包
|
||||
core_storage/ # 本地存储抽象(Drift + shared_preferences 封装)
|
||||
core_auth/ # 登录态、token 管理、secure storage、门店上下文
|
||||
core_router/ # 路由聚合、公共 route guard、动态菜单映射
|
||||
core_webview/ # F6 H5 容器 + JSBridge(见 10-webview-h5.md)
|
||||
core_analytics/ # 埋点统一 API(见 13-observability-analytics.md)
|
||||
core_logging/ # 日志规范、脱敏、崩溃上报接入
|
||||
feature_auth/ # 登录、验证码、用户协议与隐私政策
|
||||
feature_home/ # 首页工作台:动态菜单、待办、预警、公告、促销位
|
||||
feature_store_mgmt/ # 店铺管理:基础信息、服务信息、执照、人员管理
|
||||
feature_sales/ # 销售流程:客户查询、历史工单、商机(H5 承载的部分走 core_webview)
|
||||
feature_purchase/ # 采购:产品查询、购物车、结算、订单、收货
|
||||
feature_inventory/ # 库存:明细、安全库存、盘点、DOT
|
||||
feature_analytics/ # 经营分析:对账单、核销收入、返利、报表
|
||||
feature_profile/ # 个人中心:地址、热线、客服
|
||||
feature_scan/ # 扫码业务入口(VIN/车牌/二维码/条码 → 分发到对应业务)
|
||||
native_scan/ # 原生插件包:扫码 + VIN 识别(车牌走云端 OCR,不在这里)
|
||||
native_media/ # 相机、相册、文件选择/上传
|
||||
native_device/ # 拨号、设备信息、权限申请
|
||||
native_crash/ # Bugly 崩溃上报的原生桥(见 13-observability-analytics.md)
|
||||
```
|
||||
|
||||
包清单按 [PRD 第 4 章](../prd/Continental-Retail-APP-PRD.md) 的模块划分列出,实际开工时按迭代顺序逐个建,不需要一次性全建出来。
|
||||
|
||||
## 依赖规则(编译期强制边界,是这套结构的核心价值)
|
||||
|
||||
- `app` 可以依赖所有 `core_*` 和 `feature_*`。
|
||||
- `feature_*` **只能**依赖 `core_*` 和 `native_*`,**不能**相互依赖(`feature_purchase` 的 `pubspec.yaml` 里不允许出现 `feature_inventory` 的 path dependency)。
|
||||
- `core_*` 可以依赖 `native_*`(`core_webview` 的 JSBridge 需要调起扫码/相机/上传)。
|
||||
- `native_*` 只依赖 Flutter SDK 和 [Pigeon](https://pub.dev/packages/pigeon) 生成的代码,不依赖任何 `core_*` / `feature_*`——保证原生插件包可以脱离业务单独编译、单独测试(详见 [07-native-integration.md](./07-native-integration.md))。
|
||||
|
||||
`core_*` 之间原则上不互相依赖,允许的例外只有下面三条,多一条都要走评审:
|
||||
|
||||
| 允许的依赖 | 原因 |
|
||||
|---|---|
|
||||
| `core_network` → `core_auth` | 取 token 附加到请求头、401 时触发刷新 |
|
||||
| `core_router` → `core_auth` | 路由 `redirect` 里判断登录态(见 [04-routing.md](./04-routing.md)) |
|
||||
| `core_webview` → `core_auth` | H5 换票需要当前登录态与门店上下文(见 [10-webview-h5.md](./10-webview-h5.md)) |
|
||||
|
||||
两条容易踩的反向约束,必须记住:
|
||||
|
||||
- **`core_auth` 不依赖 `core_network`**。`core_auth` 要发 refresh 请求,如果依赖 `core_network` 就和上表第一行构成循环依赖。做法是:`core_auth` 直接依赖 `dio` 包,内部自建一个**不挂任何拦截器的裸 `Dio` 实例**专门用于刷新——这同时也避免了"刷新请求本身被 `AuthInterceptor` 拦截 → 401 → 再刷新"的递归(见 [05-networking.md](./05-networking.md))。
|
||||
- **`core_auth` 不依赖 `core_storage`**。token / refresh token 走 `flutter_secure_storage`,这个依赖**归 `core_auth` 独占**;`core_storage` 只负责 Drift 和 `shared_preferences`(见 [06-local-storage.md](./06-local-storage.md))。这样划分是为了不让 `core_*` 之间再多一条依赖边。
|
||||
|
||||
`feature_*` 不直接依赖 `go_router`,路由相关类型由 `core_router` 统一 re-export(`export 'package:go_router/go_router.dart';`),这样将来换路由库时只有 `core_router` 一个包要改。
|
||||
|
||||
这些规则由 Dart 的包依赖机制**物理强制**:`feature_a` 根本 import 不到 `feature_b` 的任何符号,不是靠代码规范或 review 口头约束。
|
||||
|
||||
## Feature 间通信怎么处理
|
||||
|
||||
这是最容易被绕开、也是这套边界能否守住的关键点,必须写清楚合法方式:
|
||||
|
||||
1. **路由跳转 + 可序列化参数**(多数场景)——比如从 `feature_home` 跳到 `feature_purchase`,通过 `core_router` 声明的路径 + query/extra 参数传递,不直接引用对方的 Dart 类型。
|
||||
2. **通过 `core_*` 定义的抽象接口 + DI 注册实现**——真正需要跨 feature 拿数据或发通知的场景(比如切换门店后要清空购物车),在某个 `core_*` 包里定义接口,各 feature 各自实现并在 `app` 层注册,调用方只依赖 `core_*` 里的抽象类型(门店切换的级联失效见 [11-store-context-and-session.md](./11-store-context-and-session.md))。
|
||||
|
||||
**不允许**的做法:任何 `feature_*` 在 `pubspec.yaml` 里直接 path dependency 另一个 `feature_*`,哪怕只是想复用一个 widget——这种情况应该把这个 widget 提到 `core_ui`。
|
||||
|
||||
## 命名规范
|
||||
|
||||
- `core_xxx`:基础设施层,不含具体业务逻辑。
|
||||
- `feature_xxx`:对应一个业务域(多数是原来的某个小程序,也有全新的,如首页工作台)。
|
||||
- `native_xxx`:原生能力插件包,首版含 `android/`、`ios/` 两套原生实现目录(OHOS 不在首版范围,见 [07-native-integration.md](./07-native-integration.md))。
|
||||
|
||||
## SDK 版本基线
|
||||
|
||||
| 项 | 版本 | 说明 |
|
||||
|---|---|---|
|
||||
| Flutter | **3.44.9** | 用 [FVM](https://fvm.app/) 锁定,仓库根目录提交 `.fvmrc` |
|
||||
| Dart | 随 Flutter 3.44.9 附带(3.12.x) | 具体号以 `flutter --version` 实测为准;`environment: sdk: ^3.12.0` 对整个 3.12.x 都成立 |
|
||||
|
||||
**为什么不跟最新 stable(3.47.0 / Dart 3.13.0,2026-08-12 发布)**:鸿蒙(OpenHarmony)的 Flutter 分支适配落后于官方 stable 一段时间,虽然 OHOS 不在首版范围(见 [07-native-integration.md](./07-native-integration.md) 的「OHOS 后续演进」),但 SDK 基线要为后续接 OHOS 留出兼容窗口,所以刻意停在 3.44.9 而不是追最新。这条约束在决定升级 Flutter 版本时必须重新评估,不要因为"新版本有新特性"就单方面升。
|
||||
|
||||
**为什么必须用 FVM 锁**:monorepo 里各人本地 Flutter 版本不一致,会导致同一份代码有人 `flutter analyze` 过、有人不过,生成代码(`build_runner` 产物)也可能不一致——这类问题排查成本远高于装一次 FVM。CI 也用 `.fvmrc` 里的版本,保证本地和流水线一致。
|
||||
|
||||
```json
|
||||
// .fvmrc
|
||||
{ "flutter": "3.44.9" }
|
||||
```
|
||||
|
||||
## Melos 配置示例(8.x,基于 Dart Pub Workspaces)
|
||||
|
||||
Melos 7.0 起改用 Dart 官方原生的 **[Pub Workspaces](https://dart.dev/tools/pub/workspaces)** 机制,不再有独立的 `melos.yaml` 文件,配置写进根目录 `pubspec.yaml`;每个子包的 `pubspec.yaml` 需要加 `resolution: workspace`。
|
||||
|
||||
两个不同的 SDK 下限,别搞混:
|
||||
|
||||
- **Pub Workspaces 机制本身**要求 Dart SDK ≥ **3.6.0**。
|
||||
- **melos 8.2.2 这个工具**自己要求 Dart SDK **^3.9.0**。
|
||||
|
||||
我们的基线(Dart 3.12.x)两条都满足。
|
||||
|
||||
根目录 `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
name: conti_app
|
||||
publish_to: none
|
||||
environment:
|
||||
sdk: ^3.12.0
|
||||
|
||||
workspace:
|
||||
- app
|
||||
- packages/core_ui
|
||||
- packages/core_network
|
||||
- packages/core_storage
|
||||
- packages/core_auth
|
||||
- packages/core_router
|
||||
- packages/core_webview
|
||||
- packages/core_analytics
|
||||
- packages/core_logging
|
||||
- packages/feature_auth
|
||||
- packages/feature_home
|
||||
- packages/feature_purchase
|
||||
- packages/native_scan
|
||||
# ... 其余包按实际建包进度追加
|
||||
|
||||
dev_dependencies:
|
||||
melos: ^8.2.2
|
||||
|
||||
melos:
|
||||
scripts:
|
||||
analyze:
|
||||
run: melos exec --fail-fast -- flutter analyze
|
||||
test:
|
||||
# --dir-exists=test 跳过还没有测试目录的包(如新建的 native_*),
|
||||
# 否则批量命令会因为「找不到 test 目录」整体失败
|
||||
run: melos exec --dir-exists=test --fail-fast -- flutter test
|
||||
format:
|
||||
run: melos exec -- dart format --set-exit-if-changed .
|
||||
gen:
|
||||
# 代码生成:riverpod_generator / drift_dev / json_serializable
|
||||
run: melos exec --depends-on=build_runner -- dart run build_runner build --delete-conflicting-outputs
|
||||
pigeon:
|
||||
# 原生接口生成,见 07-native-integration.md
|
||||
run: melos exec --scope="native_*" -- dart run pigeon --input pigeons/
|
||||
```
|
||||
|
||||
每个子包(比如 `packages/feature_purchase/pubspec.yaml`):
|
||||
|
||||
```yaml
|
||||
name: feature_purchase
|
||||
resolution: workspace
|
||||
|
||||
dependencies:
|
||||
core_ui:
|
||||
path: ../core_ui
|
||||
core_network:
|
||||
path: ../core_network
|
||||
core_router:
|
||||
path: ../core_router
|
||||
```
|
||||
|
||||
## 共享 lint 配置
|
||||
|
||||
根目录一份 `analysis_options.yaml`,各子包 include 它,不允许各包自己维护一套规则(选型与具体规则见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md)):
|
||||
|
||||
```yaml
|
||||
# packages/feature_purchase/analysis_options.yaml
|
||||
include: ../../analysis_options.yaml
|
||||
```
|
||||
|
||||
用到 `custom_lint`(`riverpod_lint` 依赖它)的包,需要各自在 `dev_dependencies` 里加 `custom_lint`,并在自己的 `analysis_options.yaml` 里启用 `custom_lint` 插件——`custom_lint` 是按包运行的,不能只在根目录配一次(见 [03-state-management.md](./03-state-management.md))。
|
||||
|
||||
## 新增 feature 包的标准脚手架
|
||||
|
||||
```
|
||||
feature_xxx/
|
||||
pubspec.yaml # resolution: workspace + 依赖 core_ui / core_network / core_router 等,不依赖其他 feature
|
||||
lib/
|
||||
feature_xxx.dart # 唯一对外导出文件(barrel file):只暴露路由注册函数和必要的 public widget
|
||||
src/
|
||||
presentation/
|
||||
domain/ # 可选,见下方分层规范文档
|
||||
data/
|
||||
test/
|
||||
```
|
||||
|
||||
`src/` 目录下的内容视为包内私有实现,只有 `feature_xxx.dart` 这一个文件是对外契约——这条靠 code review 检查,Dart 语言本身没有强制的 package-private 关键字。`domain/` 目录的取舍规则详见 [02-layering.md](./02-layering.md)。
|
||||
|
||||
## 版本管理
|
||||
|
||||
不发布到 pub.dev,全部用 melos 的 path dependency,包版本号跟随 `app` 的整体版本号统一管理(fixed versioning),不做 melos 的 independent versioning——没有对外发布需求,独立版本号只会增加维护负担。
|
||||
|
||||
## 附录:Melos 是什么,日常怎么用
|
||||
|
||||
给没接触过 Dart 多包仓库工具的同学看的入门说明。
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
Dart 官方的包管理工具 `pub` 天生只认"一个 `pubspec.yaml` = 一个包"。如果要在同一个 git 仓库里维护多个互相依赖的私有包(比如 `app` 依赖 `feature_scan`,`feature_scan` 依赖 `core_network`),原生 pub 只支持手动在每个包的 `pubspec.yaml` 里写 `path: ../../packages/core_network` 这种相对路径依赖——能跑,但没有任何批量操作能力:想给所有包统一跑一次 `flutter analyze`、`flutter test`,或者统一升级某个第三方库版本,都得一个包一个包手动进去执行。
|
||||
|
||||
**Melos 就是给这种多包仓库提供批量管理能力的工具**,类似 JS 生态里的 [Lerna](https://lerna.js.org/)/Nx,只不过是 Dart/Flutter 版本。它不改变 Dart 语言或 pub 本身的机制,只是在多个包外面包一层"批处理脚本 + 配置"。
|
||||
|
||||
### 核心概念
|
||||
|
||||
1. **根目录 `pubspec.yaml` 里的 `workspace:` 字段 + `melos:` 配置块**:8.x 版本不再有独立的 `melos.yaml` 文件(7.0 之前是独立文件,现已合并进 Dart 官方原生的 Pub Workspaces 机制)。`workspace:` 列出所有子包路径,`melos:` 块下的 `scripts:` 定义可复用脚本(见上文示例)。
|
||||
2. **`melos bootstrap`**(简写 `melos bs`):一键解析 workspace 内所有包之间的依赖关系。在 8.x 的 Pub Workspaces 模式下,它的效果约等于"在仓库根目录跑一次 `flutter pub get` + 校验各包 `resolution: workspace` 配置是否正确"——包间链接由 pub 原生的 workspace 机制完成,**不再生成 `pubspec_overrides.yaml`**(那是 7.0 之前的实现方式)。**新人拉下代码后第一步永远是跑这个命令**。
|
||||
3. **`melos exec`**:在每一个包目录下依次/并行执行同一条命令,比如 `melos exec -- flutter test` 就是把所有包都跑一遍测试,替代手动 `cd packages/feature_purchase && flutter test && cd ../feature_inventory && ...`。常用过滤参数:`--scope`(只跑匹配名字的包)、`--dir-exists=test`(只跑有测试目录的包)、`--fail-fast`(有一个包失败就停)。
|
||||
4. **`melos run <script-name>`**:调用根目录 `pubspec.yaml` 里 `melos: scripts:` 下预定义的脚本别名(比如上文的 `melos run test`),团队里统一敲固定命令,不用记 `exec` 的完整写法。
|
||||
|
||||
### 日常开发流程(拿本仓库举例)
|
||||
|
||||
```bash
|
||||
# 0. 一次性:安装 fvm 并装上基线版本的 Flutter
|
||||
dart pub global activate fvm
|
||||
fvm install # 读 .fvmrc,装 3.44.9
|
||||
fvm flutter --version
|
||||
|
||||
# 1. 第一次拉代码,或者别人加了新包/新依赖之后
|
||||
melos bootstrap
|
||||
|
||||
# 2. 正常改代码,比如在 feature_purchase 里改一个页面
|
||||
cd packages/feature_purchase
|
||||
flutter run # 这一步跟平时写单个 Flutter 项目完全一样,感觉不到 melos 的存在
|
||||
|
||||
# 3. 改了带注解的代码(Riverpod / Drift / json_serializable)之后
|
||||
melos run gen
|
||||
|
||||
# 4. 提交前,跑一遍全仓库检查
|
||||
melos run analyze
|
||||
melos run test
|
||||
|
||||
# 5. 新增了 feature 包,或者某个包新增了对另一个包的依赖之后
|
||||
melos bootstrap # 重新解析依赖关系
|
||||
```
|
||||
|
||||
**关键体感**:平时在某一个包里写代码、`flutter run`、热重载,跟没有 melos 时完全一样——melos 只在"跨包操作"(装依赖、批量测试、批量分析)时才会用到,不侵入日常单包开发的手感。
|
||||
|
||||
### 常见的坑
|
||||
|
||||
- 加了新包,或改了某个包的依赖之后忘记跑 `melos bootstrap`,会出现"明明加了依赖但 import 不到"的报错——看到这个报错先跑一遍 bootstrap 再排查。
|
||||
- 8.x 基于 Pub Workspaces 后,正常的包间链接**不再**依赖 `pubspec_overrides.yaml`(这是 7.0 之前版本的机制);只有配置了额外的 `dependencyOverridePaths`(用于覆盖外部第三方依赖,不是本仓库包之间的常规场景)时才会生成这个文件。如果看到这个文件出现却不记得配置过覆盖路径,说明配置可能有误,需要检查。
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
dart pub global activate melos
|
||||
```
|
||||
|
||||
全局命令,装一次即可,不需要每个项目单独安装。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Melos 官方文档](https://melos.invertase.dev/)
|
||||
- [melos | Dart package (pub.dev)](https://pub.dev/packages/melos)
|
||||
- [Melos changelog](https://pub.dev/packages/melos/changelog)
|
||||
- [Melos Configuration overview](https://melos.invertase.dev/configuration/overview)
|
||||
- [Dart Pub Workspaces 官方文档](https://dart.dev/tools/pub/workspaces)
|
||||
- [FVM(Flutter Version Management)](https://fvm.app/)
|
||||
- [Pigeon | Dart package](https://pub.dev/packages/pigeon)
|
||||
- [Drift | Dart package](https://pub.dev/packages/drift)
|
||||
- [Lerna(JS 生态对标工具)](https://lerna.js.org/)
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# 02. 分层架构规范
|
||||
|
||||
## 决策
|
||||
|
||||
每个 `feature_*` 包内部采用简化版分层,`domain` 层**可选**,不强制每个 feature 都有:
|
||||
|
||||
```
|
||||
feature_xxx/
|
||||
lib/
|
||||
feature_xxx.dart # 对外唯一导出文件
|
||||
src/
|
||||
presentation/ # widgets + Riverpod provider/notifier
|
||||
domain/ # 可选:entity + repository 接口 + use case
|
||||
data/ # repository 实现 + remote/local datasource
|
||||
test/
|
||||
```
|
||||
|
||||
## 各层职责
|
||||
|
||||
- **presentation**:widgets、Riverpod `Notifier`/`Provider`。只处理 UI 状态和用户交互,不直接调用 `data` 层的具体实现类,通过依赖注入拿到抽象类型。
|
||||
- **domain**(可选):`entity` 定义业务模型,`repository` 接口声明数据契约,`use case` 封装跨 repository 协调或多步骤业务规则。
|
||||
- **data**:`repository` 接口的具体实现,内部再拆 `remote_datasource`(走 `core_network`)和 `local_datasource`(走 `core_storage`)。
|
||||
|
||||
## 何时可以跳过 domain 层
|
||||
|
||||
判断标准:
|
||||
|
||||
- **可以跳过**:功能是简单 CRUD、没有跨 repository 协调、没有多步骤业务规则——`presentation` 直接依赖 `data` 层定义的 repository 接口即可,`repository` 接口挪到 `data` 层里声明。
|
||||
- **必须要有**:涉及多步骤业务规则(如支付的多步校验)、需要协调多个 repository、包含状态机或需要独立于 UI 单元测试的核心业务逻辑——`repository` 接口放在 `domain`,`data` 层依赖 `domain` 反向实现接口。
|
||||
|
||||
## Repository 接口的位置规则
|
||||
|
||||
- 有 `domain` 层:接口定义在 `domain/repository/`,`data/repository_impl/` 实现它,`presentation` 只依赖 `domain` 里的抽象类型。
|
||||
- 无 `domain` 层:接口直接定义在 `data/repository/`,同文件或同目录下给出实现类,`presentation` 依赖这个接口类型。
|
||||
|
||||
两种情况下,`presentation` 都不允许直接依赖 `data` 层的具体实现类(如 `XxxRepositoryImpl`),只依赖接口——这条不因为是否跳过 domain 层而改变。
|
||||
|
||||
## 跨层依赖规则
|
||||
|
||||
```
|
||||
presentation → domain(或直接 → data 的接口,若跳过 domain)
|
||||
domain → 不依赖 presentation / data
|
||||
data → 依赖 domain 的接口(若有),依赖 core_network / core_storage
|
||||
```
|
||||
|
||||
`domain` 层禁止 import 的东西,不只是 Flutter SDK:
|
||||
|
||||
- `package:flutter/...`(UI 框架)
|
||||
- `package:dio/...`(网络库)
|
||||
- `package:drift/...`(数据库)
|
||||
- 任何做 IO 的第三方库
|
||||
|
||||
`domain` 只允许 `dart:core`/`dart:async` 这类纯语言能力和项目内的纯 Dart 类型。这条如果松了,"domain 可以脱离 UI 和网络单独跑 unit test"就名存实亡——只要 import 了 `dio`,测试就得处理它的初始化和平台依赖。
|
||||
|
||||
## 数据模型与 JSON 序列化
|
||||
|
||||
**决策**:DTO 用 [json_serializable](https://pub.dev/packages/json_serializable) 生成 `fromJson`/`toJson`,不手写;**不引入 freezed**。
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
json_annotation: ^4.9.0
|
||||
|
||||
dev_dependencies:
|
||||
json_serializable: ^6.9.0
|
||||
build_runner: ^2.15.2
|
||||
```
|
||||
|
||||
- **为什么不上 freezed**:freezed 主要提供不可变类、`copyWith`、联合类型(sealed class)。Dart 3 已经原生支持 `sealed class`/`final class` 和模式匹配,联合类型这块的收益大幅缩水;而 `copyWith` 的收益不足以抵消"再加一个 codegen 目标 + 生成文件体积翻倍 + 编译变慢"的成本。项目里已经有 `riverpod_generator`、`drift_dev`、`json_serializable`、`pigeon` 四个 codegen 目标,能不加就不加(同 [09-testing.md](./09-testing.md) 里不选 `mockito` 的理由)。
|
||||
- **DTO 与 entity 是否分两套类型**:默认**不分**,`data` 层的 DTO 直接当 `domain` 的 entity 用,只在下面两种情况才拆两套并写转换函数:
|
||||
1. 后端字段结构明显不适合业务使用(比如时间戳是字符串、状态是魔法数字、嵌套层级很深)。
|
||||
2. 同一个业务概念由多个接口拼出来(比如首页 tile 聚合了多个 Mini 域的返回)。
|
||||
|
||||
拆两套要付出双份类型 + 一份转换代码的成本,多数简单 CRUD 场景不值得。
|
||||
- 有 `domain` 层的 feature 如果拆了两套类型,转换函数放在 `data` 层(`domain` 不能知道 JSON 长什么样)。
|
||||
|
||||
## 后端统一响应包装在哪一层解开
|
||||
|
||||
后端所有接口返回 `ApiResult<T> { code, message, data, traceId }`(见 [backend/06-api-design.md](../backend/06-api-design.md))。**解包统一发生在 `core_network` 的拦截器里,不在各 feature 的 repository 里重复写**:
|
||||
|
||||
- `code == 0` → 把 `data` 取出来交给 repository,repository 的 `fromJson` 只需要认识 `data` 的结构,完全不用感知外层包装。
|
||||
- `code != 0` → 直接抛 `BusinessException(code, message, traceId)`。
|
||||
- `traceId` 无论成功失败都记录进日志。
|
||||
|
||||
完整契约见 [12-error-and-api-contract.md](./12-error-and-api-contract.md)。这条规则的意义是:以后如果后端调整了包装格式,只有 `core_network` 一个地方要改。
|
||||
|
||||
## 分页的统一约定
|
||||
|
||||
列表页要支持分页/分段加载。repository 层的分页方法统一签名,不让每个 feature 各自发明一套参数名:
|
||||
|
||||
```dart
|
||||
// core_network 里定义的通用分页类型
|
||||
class PageQuery {
|
||||
const PageQuery({required this.page, this.size = 20});
|
||||
final int page; // 从 1 开始
|
||||
final int size;
|
||||
}
|
||||
|
||||
class PageResult<T> {
|
||||
const PageResult({required this.items, required this.total, required this.page});
|
||||
final List<T> items;
|
||||
final int total;
|
||||
final int page;
|
||||
bool get hasMore => items.length + (page - 1) * items.length < total;
|
||||
}
|
||||
|
||||
// feature 侧
|
||||
abstract class PurchaseOrderRepository {
|
||||
Future<PageResult<PurchaseOrder>> fetchOrders(PageQuery query);
|
||||
}
|
||||
```
|
||||
|
||||
具体字段名以后端最终约定为准(backend 06 的「待补充」里也挂着分页约定这一项),联调前需要跟后端对齐一次。
|
||||
|
||||
## 附录:分层架构是什么,为什么要分层
|
||||
|
||||
给还没接触过这套分层习惯的同学看的入门说明。
|
||||
|
||||
> 下面示例里的 `feature_payment` / `feature_store` 是为了讲清分层概念用的简化例子,不是最终包清单(实际包清单见 [01-project-structure.md](./01-project-structure.md))。
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
如果 UI 代码里直接写网络请求、直接 new 一个 `Dio` 实例、直接操作数据库——短期能跑,但会导致两个问题:
|
||||
|
||||
1. **没法单独测试业务逻辑**:想验证"支付金额校验规则对不对",得连 widget 一起跑测试,跑得慢还容易因为 UI 变了导致业务逻辑测试跟着挂。
|
||||
2. **换底层实现要动 UI 代码**:比如把网络库从 `dio` 换掉,或者把本地存储从 `shared_preferences` 换成 `Drift`,如果 UI 直接依赖具体实现类,改动会散落得到处都是。
|
||||
|
||||
**分层的本质**:把"业务规则"和"业务规则的具体实现方式(用什么网络库、存什么数据库)"分开,中间用抽象接口隔开。这就是 [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) 这套思想的核心,我们只取最简化的三层版本,不套用它完整的同心圆规则。
|
||||
|
||||
### 依赖方向是关键
|
||||
|
||||
三层最重要的不是"分了几层",而是**依赖只能单向流动**:
|
||||
|
||||
```
|
||||
presentation ──依赖──> domain ──定义接口,不依赖任何人
|
||||
▲
|
||||
│ 实现接口(依赖倒置)
|
||||
data
|
||||
```
|
||||
|
||||
`domain` 不 import `data`,也不 import `presentation`——它甚至不知道 `data` 层是用 `dio` 还是别的什么网络库实现的,只定义"我需要一个能拿到 `PaymentOrder` 的东西"(接口),至于这个东西具体怎么实现,由 `data` 层负责,这就是[依赖倒置原则](https://en.wikipedia.org/wiki/Dependency_inversion_principle)。好处是:`domain` 层的业务规则可以完全脱离网络、脱离 UI 单独写单元测试。
|
||||
|
||||
### 示例一:有 domain 层(`feature_payment`,支付确认——多步骤业务规则)
|
||||
|
||||
```dart
|
||||
// domain/entity/payment_order.dart
|
||||
class PaymentOrder {
|
||||
final String orderId;
|
||||
final int amountCents;
|
||||
final PaymentStatus status;
|
||||
const PaymentOrder({required this.orderId, required this.amountCents, required this.status});
|
||||
}
|
||||
|
||||
// domain/repository/payment_repository.dart
|
||||
abstract class PaymentRepository {
|
||||
Future<PaymentOrder> fetchOrder(String orderId);
|
||||
Future<void> confirmPayment(String orderId, String pinToken);
|
||||
}
|
||||
|
||||
// domain/use_case/confirm_payment_use_case.dart
|
||||
class ConfirmPaymentUseCase {
|
||||
final PaymentRepository _repository;
|
||||
ConfirmPaymentUseCase(this._repository);
|
||||
|
||||
Future<void> call(String orderId, String pinToken) async {
|
||||
final order = await _repository.fetchOrder(orderId);
|
||||
if (order.status != PaymentStatus.pending) {
|
||||
throw StateError('订单状态不允许支付: ${order.status}');
|
||||
}
|
||||
if (order.amountCents <= 0) {
|
||||
throw ArgumentError('订单金额非法');
|
||||
}
|
||||
await _repository.confirmPayment(orderId, pinToken);
|
||||
}
|
||||
}
|
||||
|
||||
// data/repository/payment_repository_impl.dart
|
||||
class PaymentRepositoryImpl implements PaymentRepository {
|
||||
final ApiClient _api; // 来自 core_network,不是裸 Dio,见 05-networking.md
|
||||
PaymentRepositoryImpl(this._api);
|
||||
|
||||
@override
|
||||
Future<PaymentOrder> fetchOrder(String orderId) async {
|
||||
// 注意:返回的已经是 ApiResult 里的 data 部分——
|
||||
// { code, message, data, traceId } 这层包装由 core_network 的拦截器统一解开,
|
||||
// repository 不感知它的存在(见上文「后端统一响应包装在哪一层解开」)
|
||||
final json = await _api.get<Map<String, dynamic>>('/api/v1/orders/$orderId');
|
||||
return PaymentOrder(
|
||||
orderId: json['orderId'] as String,
|
||||
amountCents: json['amountCents'] as int,
|
||||
status: PaymentStatus.values.byName(json['status'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> confirmPayment(String orderId, String pinToken) =>
|
||||
_api.post('/api/v1/orders/$orderId/confirm', data: {'pinToken': pinToken});
|
||||
}
|
||||
```
|
||||
|
||||
`ConfirmPaymentUseCase` 的多步校验规则可以直接用假的 `PaymentRepository` 实现来做单元测试,完全不需要启动 Flutter engine 或起一个 mock server。
|
||||
|
||||
### 示例二:跳过 domain 层(`feature_store`,门店列表——简单 CRUD)
|
||||
|
||||
```dart
|
||||
// data/repository/store_repository.dart
|
||||
abstract class StoreRepository {
|
||||
Future<List<Store>> fetchNearbyStores(double lat, double lng);
|
||||
}
|
||||
|
||||
class StoreRepositoryImpl implements StoreRepository {
|
||||
final ApiClient _api;
|
||||
StoreRepositoryImpl(this._api);
|
||||
|
||||
@override
|
||||
Future<List<Store>> fetchNearbyStores(double lat, double lng) async {
|
||||
// 同上:拿到的是解开 ApiResult 包装之后的 data
|
||||
final list = await _api.get<List<dynamic>>(
|
||||
'/api/v1/stores',
|
||||
query: {'lat': lat, 'lng': lng},
|
||||
);
|
||||
return list.map((e) => Store.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
没有多步骤规则、没有跨 repository 协调,接口和实现直接放在 `data` 层,`presentation` 直接依赖 `StoreRepository` 这个接口,省掉一层 `domain` 目录和 use case 模板代码。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Flutter 官方状态管理文档](https://docs.flutter.dev/data-and-backend/state-mgmt)
|
||||
- [The Clean Architecture(Uncle Bob 原文)](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
||||
- [依赖倒置原则(Dependency Inversion Principle)](https://en.wikipedia.org/wiki/Dependency_inversion_principle)
|
||||
- [json_serializable | Dart package](https://pub.dev/packages/json_serializable)
|
||||
- [Dart 3 sealed class 与模式匹配](https://dart.dev/language/patterns)
|
||||
@@ -0,0 +1,227 @@
|
||||
# 03. 状态管理方案
|
||||
|
||||
## 决策
|
||||
|
||||
使用 **[Riverpod](https://riverpod.dev/)**(`flutter_riverpod` + `riverpod_generator` 代码生成),不使用 Bloc/Provider/GetX。
|
||||
|
||||
版本基线:`flutter_riverpod: ^3.4.2`(当前 stable,2026-08 快照,需在实际开工时用 `flutter pub outdated` 复核)。
|
||||
|
||||
## 依赖
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
flutter_riverpod: ^3.4.2
|
||||
riverpod_annotation: ^3.4.2
|
||||
|
||||
dev_dependencies:
|
||||
riverpod_generator: ^3.4.2
|
||||
build_runner: ^2.15.2
|
||||
custom_lint: ^0.8.1
|
||||
riverpod_lint: ^3.1.8
|
||||
```
|
||||
|
||||
> `custom_lint` 的版本必须是 `^0.8.x`:`riverpod_lint 3.x` 依赖的是 `custom_lint 0.8.x`,写成 `^0.6.0` 会直接 `pub get` 解析失败。`custom_lint` 的版本约束比较严,每次升 `riverpod_lint` 都要顺带核一下它要求的 `custom_lint` 版本。
|
||||
|
||||
## 使用规则
|
||||
|
||||
- 所有跨 widget 共享的状态、依赖注入,统一通过 Riverpod provider 暴露,不额外引入 `get_it`/`provider` 等其他 DI 方案。
|
||||
- 优先使用 `riverpod_generator` 的注解写法(`@riverpod`),不手写裸 `Provider`/`StateNotifierProvider` 模板代码。
|
||||
- `Notifier`/`AsyncNotifier` 用于承载可变的 feature 状态;无状态的计算/依赖注入用普通 `Provider`。
|
||||
- `domain`/`data` 层的 repository 实现通过 provider 注入到 `presentation` 层,`presentation` 只依赖 provider 暴露的接口类型(见 [02-layering.md](./02-layering.md))。
|
||||
- 每个 `feature_*` 包各自维护自己的 provider,不跨包直接引用另一个 feature 的 provider(同 [01-project-structure.md](./01-project-structure.md) 的 feature 隔离规则);跨 feature 共享的 provider 定义在对应的 `core_*` 包里。
|
||||
|
||||
## Riverpod 3 的自动重试:全局关掉
|
||||
|
||||
Riverpod 3 起,**provider 抛异常后会自动重试**,默认策略是指数退避(200ms 起,翻倍到 6.4s 封顶)。这个默认行为在本项目里弊大于利,有三个具体问题:
|
||||
|
||||
1. **和 401 刷新打架**:access token 过期时,`core_network` 的 `AuthInterceptor` 已经在做刷新 + 重放(见 [05-networking.md](./05-networking.md))。provider 层再自动重试一轮,等于同一个失败被两套机制各重试一次,日志里会出现莫名其妙的重复请求。更糟的是后端 refresh token 是**一次性轮换**的(见 [backend/04-security-auth.md](../backend/04-security-auth.md)),并发刷新会被判定为重放攻击,导致该用户所有 refresh token 被撤销、被强制登出。
|
||||
2. **错误提示会闪**:UI 拿到 `AsyncError` 弹了错误提示,200ms 后自动重试又切回 `AsyncLoading`,用户看到的是提示一闪而过。
|
||||
3. **测试 flaky**:单测里断言 `AsyncError` 时,后台还挂着一个待重试的定时器,测试跑完 container 被 dispose 会报 pending timer,或者断言时机不对直接读到 `AsyncLoading`。
|
||||
|
||||
**决策**:在 `ProviderScope` 上全局关闭 retry,需要重试的地方显式打开。
|
||||
|
||||
```dart
|
||||
// app/lib/main.dart
|
||||
void main() {
|
||||
runApp(
|
||||
ProviderScope(
|
||||
// 全局关掉自动重试:返回 null 表示"不重试"
|
||||
retry: (retryCount, error) => null,
|
||||
child: const ContiApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
单个 provider 确实需要重试时(比如首页 tile 这种失败了自己悄悄重试一次比弹错更好的场景),在该 provider 上单独开:
|
||||
|
||||
```dart
|
||||
@Riverpod(retry: _homeTileRetry)
|
||||
Future<List<Tile>> homeTiles(Ref ref) async { /* ... */ }
|
||||
|
||||
// 只重试一次,且只对网络类错误重试;业务错误(BusinessException)重试没有意义
|
||||
Duration? _homeTileRetry(int retryCount, Object error) {
|
||||
if (retryCount >= 1) return null;
|
||||
if (error is! NetworkException) return null;
|
||||
return const Duration(milliseconds: 500);
|
||||
}
|
||||
```
|
||||
|
||||
规则:**重试只对"重试一次可能就好了"的错误有意义**——超时、连接失败。业务错误码(后端返回 `code != 0`)、401、参数错误重试多少次都是同样的结果,只是在浪费用户的时间和流量。
|
||||
|
||||
## 缓存生命周期:默认 autoDispose,长驻要写理由
|
||||
|
||||
`@riverpod` 注解生成的 provider **默认是 autoDispose 的**(没有 listener 时自动销毁并释放状态)。这个默认值保持不变,原因是门店切换的场景下(见下一节)"用完就销毁"能省掉一大堆手动清理。
|
||||
|
||||
要改成长驻的写 `@Riverpod(keepAlive: true)`,并且**必须在注释里写清为什么**。目前认可的长驻场景只有三类:
|
||||
|
||||
- 全局单例依赖(`Dio` 实例、`Database` 实例、`SharedPreferences`)——本来就该活到进程结束。
|
||||
- 全局会话状态(登录态、当前门店上下文,见 [11-store-context-and-session.md](./11-store-context-and-session.md))。
|
||||
- 明确要跨页面保留的数据(比如工作台数据,用户从子页面返回时不希望再 loading 一次)。
|
||||
|
||||
除此之外一律 autoDispose。列表页数据尤其不要 keepAlive——门店切了、权限变了,长驻的旧数据会直接显示成错的。
|
||||
|
||||
需要"短时间内返回不重新加载、但也不永久长驻"的,用 `ref.keepAlive()` + 定时器的写法,别直接 `keepAlive: true`:
|
||||
|
||||
```dart
|
||||
@riverpod
|
||||
Future<List<Store>> storeList(Ref ref) async {
|
||||
final link = ref.keepAlive();
|
||||
final timer = Timer(const Duration(minutes: 5), link.close); // 5 分钟后允许被回收
|
||||
ref.onDispose(timer.cancel);
|
||||
return ref.watch(storeRepositoryProvider).fetchStores();
|
||||
}
|
||||
```
|
||||
|
||||
## 门店切换 / 登出时的批量失效
|
||||
|
||||
PRD REQ-LGN-010(门店上下文)要求切换门店时级联失效所有门店相关缓存与在途请求——购物车、待办、预警、订单上下文全部跟着切。落到 Riverpod 上,**不能靠每个 feature 自己去监听门店变化**——总会漏掉一个,而漏掉的表现是"用户在 A 门店看到 B 门店的数据",属于严重问题。
|
||||
|
||||
统一做法:所有与门店相关的 provider 都 `ref.watch(currentStoreIdProvider)`,让 Riverpod 的依赖图自己完成级联失效。
|
||||
|
||||
```dart
|
||||
@riverpod
|
||||
Future<List<PurchaseOrder>> purchaseOrders(Ref ref) async {
|
||||
// watch 而不是 read:门店一变,这个 provider 自动重建
|
||||
final storeId = ref.watch(currentStoreIdProvider);
|
||||
return ref.watch(purchaseRepositoryProvider).fetchOrders(storeId);
|
||||
}
|
||||
```
|
||||
|
||||
这条规则要写进 code review checklist:**任何请求带 storeId 的 provider,storeId 必须来自 `ref.watch(currentStoreIdProvider)`,不允许从别处传参或 `ref.read`**。`ref.read` 拿到的是快照,门店变了不会触发重建,这正是最容易漏的地方。
|
||||
|
||||
依赖图管不到的部分(Drift 本地缓存、H5 会话、导航栈)需要显式清理,完整清单见 [11-store-context-and-session.md](./11-store-context-and-session.md)。
|
||||
|
||||
## 测试
|
||||
|
||||
- `Notifier`/`AsyncNotifier` 的单元测试用 **`ProviderContainer.test()`** 直接实例化,不依赖 widget tree——这是 Riverpod 3 新增的测试专用构造,自带 `addTearDown(container.dispose)`,不需要再手写。
|
||||
- Widget 测试中用 `ProviderScope(overrides: [...])` 注入 mock 依赖。
|
||||
- 测试里如果某个 provider 单独开了 retry,断言错误状态前记得覆盖掉,否则会遇到 pending timer(详见 [09-testing.md](./09-testing.md))。
|
||||
|
||||
## 附录:Riverpod 是什么,日常怎么用
|
||||
|
||||
给还没接触过 Riverpod 的同学看的入门说明。
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
Flutter 官方最早推荐的状态管理方式是 [`InheritedWidget`](https://docs.flutter.dev/data-and-backend/state-mgmt/inherited-widget)——通过 widget 树往下传数据。写法繁琐,社区后来做了一层封装叫 [`Provider`](https://pub.dev/packages/provider),但 `Provider` 本质还是绑定在 widget 树上:拿依赖必须要有 `BuildContext`,写错了会在运行时才报错(比如 `ProviderNotFoundException`),而且没法很方便地在 widget 树之外(比如后台任务、单元测试)读取状态。
|
||||
|
||||
**Riverpod** 是 `Provider` 的原作者 Remi Rousselet 重新设计的下一代方案:把状态容器从 widget 树里剥离出来,变成一套独立的依赖图,`BuildContext` 不再是拿依赖的必要条件,错误也从运行时提前到**编译期**发现(比如 provider 类型不匹配会直接编译报错,而不是运行时崩溃)。
|
||||
|
||||
### 核心概念
|
||||
|
||||
1. **`Provider`**:声明一个"如何创建某个值"的配方,值可以是同步的、异步的(`Future`/`Stream`)、也可以是可变的状态。
|
||||
2. **`Notifier` / `AsyncNotifier`**:承载**可变**状态的载体,通过方法修改状态(类似过去 `StateNotifier` 的角色,3.x 里统一成 `Notifier`)。
|
||||
3. **`ref.watch(xxxProvider)`**:在 widget 或另一个 provider 里订阅某个 provider,值变化时自动触发重建/重新计算。
|
||||
4. **`ref.read(xxxProvider)`**:只读取一次当前值,不订阅变化(一般用在按钮点击等一次性事件回调里)。
|
||||
5. **`@riverpod` 注解 + 代码生成**:手写 `Provider`/`NotifierProvider` 样板代码容易出错(尤其是泛型),项目统一用 `riverpod_generator` 的注解写法,跑 `build_runner` 自动生成对应的 provider。
|
||||
|
||||
### 使用示例(`feature_store`:拉取附近门店列表)
|
||||
|
||||
```dart
|
||||
// presentation/store_list_notifier.dart
|
||||
part 'store_list_notifier.g.dart';
|
||||
|
||||
@riverpod
|
||||
class StoreListNotifier extends _$StoreListNotifier {
|
||||
@override
|
||||
Future<List<Store>> build() async {
|
||||
final repository = ref.watch(storeRepositoryProvider);
|
||||
final position = ref.watch(currentPositionProvider); // 定位也是一个 provider,不是 notifier 的字段
|
||||
return repository.fetchNearbyStores(position.lat, position.lng);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
// 让 Riverpod 重跑 build(),而不是自己去调 build()
|
||||
ref.invalidateSelf();
|
||||
await future; // 等这一轮重建完成,方便下拉刷新的 RefreshIndicator 收起动画
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **不要写成 `state = await AsyncValue.guard(() => build())`。** `build()` 里有 `ref.watch`,只有 Riverpod 自己在重建流程中调用它才能正确重建订阅关系;手动调用会让旧的订阅残留、新的订阅重复注册。需要重跑 `build()` 就用 `ref.invalidateSelf()`。
|
||||
>
|
||||
> 只想改一部分状态、不想重跑整个 `build()` 时,才用 `AsyncValue.guard`,而且里面调的是 repository 而不是 `build()`:
|
||||
>
|
||||
> ```dart
|
||||
> Future<void> loadMore() async {
|
||||
> final current = state.valueOrNull ?? const [];
|
||||
> state = await AsyncValue.guard(() async {
|
||||
> final next = await ref.read(storeRepositoryProvider).fetchNearbyStores(/* ... */);
|
||||
> return [...current, ...next];
|
||||
> });
|
||||
> }
|
||||
> ```
|
||||
|
||||
```dart
|
||||
// presentation/store_list_page.dart
|
||||
class StoreListPage extends ConsumerWidget {
|
||||
const StoreListPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final storesAsync = ref.watch(storeListNotifierProvider);
|
||||
|
||||
return storesAsync.when(
|
||||
data: (stores) => ListView.builder(
|
||||
itemCount: stores.length,
|
||||
itemBuilder: (_, i) => ListTile(title: Text(stores[i].name)),
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (err, _) => Text('加载失败: $err'),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`storeRepositoryProvider` 定义在 `data` 层(见 [02-layering.md](./02-layering.md) 的跳过 domain 层示例),`StoreListNotifier` 通过 `ref.watch` 拿到接口类型,不关心具体实现——这就是 Riverpod 承担依赖注入职责的地方,不需要额外的 `get_it`。
|
||||
|
||||
### 测试示例
|
||||
|
||||
```dart
|
||||
test('刷新后状态应更新为最新门店列表', () async {
|
||||
// ProviderContainer.test() 是 Riverpod 3 的测试专用构造,
|
||||
// 自动注册 tearDown 做 dispose,不用再写 addTearDown(container.dispose)
|
||||
final container = ProviderContainer.test(
|
||||
overrides: [
|
||||
storeRepositoryProvider.overrideWithValue(FakeStoreRepository()),
|
||||
],
|
||||
);
|
||||
|
||||
final stores = await container.read(storeListNotifierProvider.future);
|
||||
expect(stores, isNotEmpty);
|
||||
});
|
||||
```
|
||||
|
||||
`ProviderContainer` 让整个依赖图脱离 widget 树单独运行,`overrides` 直接替换掉真实的 repository,这也是"编译期安全 + 好测试"这条评价的具体体现。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Riverpod 官方文档](https://riverpod.dev/)
|
||||
- [Riverpod 3 迁移指南](https://riverpod.dev/docs/whats_new)
|
||||
- [Riverpod: Automatic retry](https://riverpod.dev/docs/whats_new#automatic-retry)
|
||||
- [riverpod_generator | Dart package](https://pub.dev/packages/riverpod_generator)
|
||||
- [flutter_riverpod | Dart package](https://pub.dev/packages/flutter_riverpod)
|
||||
- [riverpod_lint | Dart package](https://pub.dev/packages/riverpod_lint)
|
||||
- [InheritedWidget(Flutter 官方文档)](https://docs.flutter.dev/data-and-backend/state-mgmt/inherited-widget)
|
||||
- [provider | Dart package](https://pub.dev/packages/provider)
|
||||
@@ -0,0 +1,198 @@
|
||||
# 04. 路由方案
|
||||
|
||||
## 决策
|
||||
|
||||
使用 **[go_router](https://pub.dev/packages/go_router)**(`^17.5.0`,2026-08 快照,Flutter 官方维护),声明式路由 + 嵌套 `ShellRoute`,不使用 `Navigator 1.0` 命令式 push/pop 作为主路由方式。
|
||||
|
||||
## 依赖
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
go_router: ^17.5.0
|
||||
```
|
||||
|
||||
## 路由注册规则
|
||||
|
||||
- 每个 `feature_*` 包在自己的 `feature_xxx.dart`(对外唯一导出文件)里暴露一个 `List<RouteBase> buildXxxRoutes()` 函数,只声明属于自己的路由,不感知其他 feature。
|
||||
- `core_router` 包负责把所有 feature 的路由函数聚合成最终的 `GoRouter` 实例,是唯一知道"全部路由长什么样"的地方。
|
||||
- 路径命名统一用 `kebab-case`,前缀按业务域分组,例如 `/store/:storeId`、`/payment/confirm`。
|
||||
- 底部导航等常驻 UI 用 `ShellRoute`/`StatefulShellRoute` 包裹对应的 feature 路由,不在每个页面里重复搭一遍导航栏。
|
||||
- 登录态校验统一在 `core_router` 聚合层用 `redirect` 实现,不在每个页面里各自判断 token 是否过期。
|
||||
- 跨 feature 跳转只能传**可序列化参数**(path 参数、query 参数,或可序列化的 `extra`),不允许把一个 feature 内部的 Dart 类实例通过 `extra` 传给另一个 feature——这是 [01-project-structure.md](./01-project-structure.md) "Feature 间通信" 规则在路由层的具体落地。
|
||||
- `feature_*` 不直接依赖 `go_router`,而是依赖 `core_router`,由 `core_router` re-export `GoRoute`/`RouteBase`/`GoRouterState` 等类型。这样将来换路由库或升大版本时,只有 `core_router` 一个地方要动。
|
||||
|
||||
## `GoRouter` 实例不能因为登录态变化被重建
|
||||
|
||||
这是 go_router + Riverpod 组合里最常见的一个坑,写错了表现是"用户在三级页面停留时 token 刷新了一下,人被弹回首页"。
|
||||
|
||||
`GoRouter` 内部持有导航栈。如果 provider 里写 `ref.watch(authStateProvider)`,登录态一变整个 provider 重建、旧 `GoRouter` 被丢弃、新的从 `initialLocation` 开始——导航栈就没了。
|
||||
|
||||
**正确写法**:`redirect` 里用 `ref.read` 读当前登录态,外面用 `ref.listen` 监听变化并调 `router.refresh()` 让 go_router 重跑一次 `redirect`。
|
||||
|
||||
```dart
|
||||
// packages/core_router/lib/src/app_router.dart
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
final goRouterProvider = Provider<GoRouter>((ref) {
|
||||
final router = GoRouter(
|
||||
navigatorKey: rootNavigatorKey, // 全局 dialog / 顶层跳转需要它
|
||||
initialLocation: '/home',
|
||||
observers: [NavigationObserver(ref.read(crashReporterProvider))], // 崩溃前的页面路径,见 13
|
||||
redirect: (context, state) {
|
||||
// read 不是 watch:这里只要当前值,订阅由下面的 listen 负责
|
||||
final auth = ref.read(authStateProvider);
|
||||
final loggingIn = state.matchedLocation == '/login';
|
||||
|
||||
if (!auth.isLoggedIn) {
|
||||
if (loggingIn) return null;
|
||||
// 带上原目标,登录成功后回跳
|
||||
return '/login?from=${Uri.encodeComponent(state.uri.toString())}';
|
||||
}
|
||||
if (loggingIn) {
|
||||
final from = state.uri.queryParameters['from'];
|
||||
return (from == null || from.isEmpty) ? '/home' : Uri.decodeComponent(from);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
errorBuilder: (context, state) => RouteNotFoundPage(location: state.uri.toString()),
|
||||
routes: [
|
||||
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navigationShell) => MainShell(navigationShell: navigationShell),
|
||||
branches: [
|
||||
StatefulShellBranch(routes: buildHomeRoutes()),
|
||||
StatefulShellBranch(routes: buildPurchaseRoutes()),
|
||||
StatefulShellBranch(routes: buildProfileRoutes()),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// 登录态变化时只重跑 redirect,不重建 router,导航栈得以保留
|
||||
ref.listen(authStateProvider, (_, __) => router.refresh());
|
||||
ref.onDispose(router.dispose);
|
||||
return router;
|
||||
});
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- `redirect` 里**只能 `ref.read`**,不能 `ref.watch`(`Provider` 的 `create` 已经跑完了,`watch` 在回调里语义也不对)。
|
||||
- `ref.onDispose(router.dispose)` 不能漏:`GoRouter` 持有 `Listenable`,不 dispose 在热重载和测试里会泄漏。
|
||||
- 用 `ref.listen` 而不是 `refreshListenable`,是因为登录态本身是一个 Riverpod provider,用 `refreshListenable` 还要额外包一个 `ChangeNotifier` 适配层,没必要。
|
||||
|
||||
### `errorBuilder` 是必须的
|
||||
|
||||
不写 `errorBuilder`,遇到未注册的路径(深链接拼错、后端下发了一个 App 还不认识的菜单 code、H5 回跳的 URL 有问题)go_router 会显示一个英文的默认错误页,对门店一线员工来说等于崩溃。统一给一个"页面不存在,请检查是否需要升级 App"的兜底页,并把 `state.uri` 上报(见 [13-observability-analytics.md](./13-observability-analytics.md))——这个上报很有价值,能直接暴露出后端下发了 App 不支持的菜单。
|
||||
|
||||
## 后端动态菜单 → 本地路由的映射
|
||||
|
||||
PRD 第 4.2.5 节(导航收敛与角色化配置):工作台菜单由后端按角色权限下发,不是写死在 App 里的。但**路由表必须是编译期写死的**(页面是 Dart 代码,不可能动态下发)。所以中间需要一张映射表。
|
||||
|
||||
约定:后端下发的每个菜单项带一个稳定的 `code`(如 `PURCHASE_ORDER`、`INVENTORY_CHECK`),`core_router` 里维护 `code → 路由路径` 的映射。
|
||||
|
||||
```dart
|
||||
// packages/core_router/lib/src/menu_route_map.dart
|
||||
const menuRouteMap = <String, String>{
|
||||
'PURCHASE_ORDER': '/purchase/orders',
|
||||
'INVENTORY_CHECK': '/inventory/check',
|
||||
'QUOTE_ORDER': '/webview?target=QUOTE_ORDER', // H5 承载的功能也走这张表
|
||||
// ...
|
||||
};
|
||||
|
||||
/// 未知 code 返回 null,调用方据此决定隐藏还是提示升级
|
||||
String? resolveMenuRoute(String code) => menuRouteMap[code];
|
||||
```
|
||||
|
||||
**未知 `code` 的兜底策略**:直接**隐藏**该菜单项,同时上报一条 `menu_code_unsupported` 事件(带 code 和 App 版本)。
|
||||
|
||||
- 不选"提示升级":老版本 App 上会因为后端加了新菜单就弹升级提示,对完全不需要这个新功能的门店是骚扰。
|
||||
- 隐藏 + 上报的组合能让我们从数据上看到"有多少用户因为版本旧看不到新功能",需要推升级时再针对性推。
|
||||
|
||||
`code` 一旦定义就不能改含义(改了等于老版本 App 跳错页面),新增功能只能加新 `code`。这条要在后端接口评审时对齐。
|
||||
|
||||
## H5 页面的路由约定
|
||||
|
||||
PRD 第 7.3 节(F6 集成边界)里的核心功能(报价开单、施工查车、结算收银)走 Embedded H5。这些页面在路由表里的形态统一为:
|
||||
|
||||
```
|
||||
/webview?target=<TARGET_CODE>&title=<可选标题>
|
||||
```
|
||||
|
||||
**只传目标标识,不传裸 URL。** 真实 URL 由 `core_webview` 拿 `target` 去 App Backend 换票后拿到(见 [10-webview-h5.md](./10-webview-h5.md))。
|
||||
|
||||
理由:如果路由里能直接塞 URL,那么任何能构造深链接的地方(推送、H5 内跳转、剪贴板)都能让 App 打开任意网页,是一个明确的安全洞。`target` 是一个白名单枚举,能打开哪些页面完全由后端和 App 共同决定。
|
||||
|
||||
即便如此,`core_webview` 拿到后端返回的 URL 后**仍要做一次域名白名单校验**——纵深防御,后端被打穿或配置写错时还有一道。
|
||||
|
||||
## 门店切换后的路由重置
|
||||
|
||||
PRD REQ-LGN-010:切换门店后所有业务上下文跟着切。导航栈是其中一部分——用户在 A 门店的"采购单详情 `/purchase/orders/123`"页面切到 B 门店,这个订单 ID 在 B 门店可能不存在,或者更糟,存在但是另一张单。
|
||||
|
||||
**规则:切换门店成功后,清空导航栈回工作台。**
|
||||
|
||||
```dart
|
||||
// 门店切换成功的回调里
|
||||
ref.read(goRouterProvider).go('/home'); // go 而不是 push:替换整个栈
|
||||
```
|
||||
|
||||
`StatefulShellRoute` 的各 branch 栈也会跟着重置。这个动作和 provider 失效、缓存清理、H5 会话失效是一组,统一在 `11-store-context-and-session.md` 里编排,不散在各处调用。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [go_router 官方文档](https://pub.dev/packages/go_router)
|
||||
- [go_router: Redirection](https://pub.dev/documentation/go_router/latest/topics/Redirection-topic.html)
|
||||
- [go_router: Navigation(go vs push)](https://pub.dev/documentation/go_router/latest/topics/Navigation-topic.html)
|
||||
- [StatefulShellRoute API](https://pub.dev/documentation/go_router/latest/go_router/StatefulShellRoute-class.html)
|
||||
|
||||
## 附录:go_router 是什么,日常怎么用
|
||||
|
||||
给还没接触过声明式路由的同学看的入门说明。
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
`Navigator 1.0` 的命令式写法(`Navigator.push(context, MaterialPageRoute(...))`)在页面不多的时候很直观,但规模上来后有几个明显问题:
|
||||
|
||||
1. **深链接(deep link)/ Web URL 支持差**:命令式 push 本质是"从当前页面跳到下一个页面",很难直接根据一个 URL 字符串恢复出正确的页面栈——比如从推送通知直接打开"门店详情页",命令式写法需要手动拼一串 `push` 调用重建整个栈。
|
||||
2. **没有统一的登录拦截点**:每个需要登录态的页面都要自己在 `initState` 里判断要不要跳转到登录页,逻辑散落在各处。
|
||||
3. **底部导航这种"多个 tab 各自维护自己的页面栈"的场景很难优雅表达**。
|
||||
|
||||
**go_router** 是 Flutter 官方团队维护的声明式路由方案:路由表是一份**声明式配置**(一棵 `GoRoute` 树),当前 URL 决定当前应该显示什么页面栈,而不是"一步步 push 出来的"。因为路由是声明式的、和 URL 强绑定,深链接、Web 浏览器前进/后退、登录拦截都能用同一套机制解决。
|
||||
|
||||
### 核心概念
|
||||
|
||||
1. **`GoRoute`**:一条路由规则,`path` 是路径模板(支持 `:id` 这种参数),`builder`/`pageBuilder` 返回对应页面。
|
||||
2. **`ShellRoute` / `StatefulShellRoute`**:包一层常驻 UI(比如带底部导航栏的外壳),内部嵌套的子路由切换时,外壳本身不重建;`StatefulShellRoute` 还能让每个 tab 各自保留自己的页面栈(切 tab 不丢失之前的浏览位置)。
|
||||
3. **`GoRouterState`**:在 `builder` 里能拿到当前路由的 path 参数(`state.pathParameters`)、query 参数(`state.uri.queryParameters`)、`extra` 对象。
|
||||
4. **`redirect`**:每次路由变化前会先跑一遍 `redirect` 回调,返回非空字符串就强制跳转——这是实现"未登录访问需要登录的页面 → 自动跳登录页"的地方。
|
||||
5. **`context.go()` / `context.push()`**:`go` 是替换当前路由(浏览器前进后退语义),`push` 是在当前栈上叠加一层(可以 `pop` 回去)——日常最容易混淆的两个 API,选错会导致返回键行为不符合预期。
|
||||
|
||||
### 使用示例(底部导航 + 门店详情页)
|
||||
|
||||
> 完整的 `goRouterProvider`(含登录拦截、回跳、错误兜底)见上文「`GoRouter` 实例不能因为登录态变化被重建」,这里只演示 feature 侧怎么声明自己的路由。
|
||||
|
||||
```dart
|
||||
// packages/feature_store_mgmt/lib/feature_store_mgmt.dart
|
||||
List<RouteBase> buildStoreRoutes() => [
|
||||
GoRoute(
|
||||
path: '/store',
|
||||
builder: (context, state) => const StoreListPage(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':storeId', // 完整路径 /store/:storeId
|
||||
builder: (context, state) {
|
||||
final storeId = state.pathParameters['storeId']!;
|
||||
return StoreDetailPage(storeId: storeId);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
```
|
||||
|
||||
```dart
|
||||
// 从任意页面跳转到门店详情
|
||||
context.push('/store/${store.id}');
|
||||
```
|
||||
|
||||
`buildStoreRoutes()` 只在 `feature_store_mgmt` 包内声明,`app_router.dart` 里只 import 这个函数、不 import 该 feature 的任何页面 widget 类型——保持 [01-project-structure.md](./01-project-structure.md) 的编译期边界。
|
||||
@@ -0,0 +1,447 @@
|
||||
# 05. 网络层设计
|
||||
|
||||
## 决策
|
||||
|
||||
使用 **[dio](https://pub.dev/packages/dio)**(`^5.11.0`,2026-08 快照)作为唯一 HTTP client,统一封装在 `core_network` 包里,通过拦截器链处理鉴权、日志、响应解包、异常归一化,不允许各 `feature_*` 自建 `Dio` 实例。
|
||||
|
||||
`feature_*` 的 repository **不直接依赖 `Dio`,而是依赖 `core_network` 暴露的 `ApiClient`**——原因见下文「为什么要在 `Dio` 外面再包一层 `ApiClient`」。
|
||||
|
||||
## 依赖
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
dio: ^5.11.0
|
||||
uuid: ^4.5.1 # 生成客户端 traceId
|
||||
```
|
||||
|
||||
## 使用规则
|
||||
|
||||
- `core_network` 暴露一个单例 `Dio` 实例和基于它的 `ApiClient`(通过 Riverpod provider 注入,见 [03-state-management.md](./03-state-management.md)),所有 `feature_*` 的 repository 只能通过依赖注入拿这个实例,不允许 `Dio()` 直接 new。
|
||||
- 拦截器按固定顺序注册:`LogInterceptor`(仅 dev/staging 环境开启)→ `AuthInterceptor`(附加 token,401 时串行刷新)→ `ApiResultInterceptor`(解开后端统一响应包装)→ `ErrorMappingInterceptor`(把 `DioException` 统一转成项目自定义的 `AppException` 体系)。
|
||||
- 业务代码只捕获 `AppException` 及其子类(如 `NetworkException`、`UnauthorizedException`、`BusinessException`),不直接处理 `DioException`——异常归一化只在 `core_network` 内部发生一次。
|
||||
- 每个 feature 的 repository 只声明自己需要的接口方法,不直接拼接完整 URL 字符串到处写,`baseUrl` 和超时时间统一在 `core_network` 里按环境配置(见 [08-build-flavors.md](./08-build-flavors.md))。
|
||||
- 需要取消请求的场景(比如页面销毁时中断未完成的请求)统一使用 `CancelToken`,在对应 provider 的 `ref.onDispose` 里调用 `cancel()`。
|
||||
|
||||
## 后端契约:统一响应包装
|
||||
|
||||
后端所有接口返回 `ApiResult<T> { code, message, data, traceId }`(见 [backend/06-api-design.md](../backend/06-api-design.md))。**解包只在 `ApiResultInterceptor` 里做一次**,repository 拿到的 `response.data` 已经是里层的 `data`。
|
||||
|
||||
```dart
|
||||
// packages/core_network/lib/src/api_result_interceptor.dart
|
||||
class ApiResultInterceptor extends Interceptor {
|
||||
ApiResultInterceptor(this._logger);
|
||||
final AppLogger _logger;
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
final body = response.data;
|
||||
// 非 JSON 对象响应(如文件下载)不走解包
|
||||
if (body is! Map<String, dynamic> || !body.containsKey('code')) {
|
||||
return handler.next(response);
|
||||
}
|
||||
|
||||
// 用 num 而不是 int:万一后端某个字段序列化成了 JSON 浮点数,as int 会直接抛
|
||||
final code = (body['code'] as num?)?.toInt();
|
||||
final traceId = body['traceId'] as String?;
|
||||
_logger.d('[api] ${response.requestOptions.uri} code=$code traceId=$traceId');
|
||||
|
||||
if (code == 0) {
|
||||
// 把外层包装剥掉,repository 的 fromJson 只需要认识 data 的结构
|
||||
response.data = body['data'];
|
||||
return handler.next(response);
|
||||
}
|
||||
|
||||
// code != 0 一律转成业务异常,不让它以"成功响应"的形态流到业务层
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
error: BusinessException(
|
||||
code: code ?? -1,
|
||||
message: (body['message'] as String?) ?? '请求失败',
|
||||
traceId: traceId,
|
||||
),
|
||||
),
|
||||
true, // callFollowingErrorInterceptor
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`traceId` 必须留存**:backend 06/08 明确指望"用户报一个 traceId,后端就能在日志里定位这次请求"。所以
|
||||
|
||||
- 每条 API 日志都带 `traceId`(成功失败都带)。
|
||||
- 错误提示 UI 上要能看到 traceId(不用显眼,可以放在"详情"里或长按复制),具体展示形式见 [12-error-and-api-contract.md](./12-error-and-api-contract.md)。
|
||||
- 崩溃/错误上报时把 traceId 作为 tag 带上(见 [13-observability-analytics.md](./13-observability-analytics.md))。
|
||||
|
||||
## 统一请求头
|
||||
|
||||
```dart
|
||||
// packages/core_network/lib/src/header_interceptor.dart
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
final env = _ref.read(appEnvProvider);
|
||||
options.headers.addAll({
|
||||
'X-Trace-Id': const Uuid().v4(), // 客户端生成,便于端到端串联
|
||||
'X-App-Version': env.appVersion, // 如 1.4.0+142
|
||||
'X-Platform': Platform.isIOS ? 'ios' : 'android',
|
||||
'X-Device-Id': _ref.read(deviceIdProvider), // 安装级匿名 ID,不是 IMEI/IDFA
|
||||
});
|
||||
// 当前门店上下文;未登录/未选门店时不带
|
||||
final storeId = _ref.read(currentStoreIdProvider.select((s) => s));
|
||||
if (storeId != null) options.headers['X-Store-Id'] = '$storeId';
|
||||
handler.next(options);
|
||||
}
|
||||
```
|
||||
|
||||
- `X-Store-Id` 是**冗余信息**:access token 的 claims 里已经有 `storeId`(backend 04),后端以 token 为准。带这个头只是为了日志排查时能一眼看出客户端当时认为自己在哪个门店——如果两者不一致,说明切换门店后 token 没换,是个 bug 信号。
|
||||
- **`X-Trace-Id` 需要和后端对齐一次**:backend 06 说 traceId 由后端入口 filter 生成。约定是**后端优先复用请求头里的 `X-Trace-Id`,没有才自己生成**,否则客户端日志和服务端日志会各用一套 ID 对不上。这条挂在待确认项里。
|
||||
- 不采集 IMEI/IDFA/MAC 等设备唯一标识,`deviceId` 用首次安装时生成并存本地的随机 UUID,避免踩合规红线(见 [07-native-integration.md](./07-native-integration.md) 的隐私清单部分)。
|
||||
|
||||
## Token 刷新:必须串行,失败即登出
|
||||
|
||||
这一段是整个网络层最容易写错、错了后果最严重的地方,因为它和后端的 **refresh token 轮换策略**强耦合。
|
||||
|
||||
按 [backend/04-security-auth.md](../backend/04-security-auth.md):
|
||||
|
||||
- refresh token 是**一次性**的,每次换 access token 都会签发新的、旧的立刻 `revokedAt`。
|
||||
- **旧 token 再被用一次 = 判定为泄漏重放,该用户名下所有 refresh token 全部撤销**。
|
||||
|
||||
由此推出三条客户端硬性约束:
|
||||
|
||||
1. **绝对不能并发刷新。** 两个请求同时 401、同时拿同一个旧 refresh token 去换,第二个必然被判为重放 → 用户被全设备强制登出。这就是刷新队列存在的真正原因,不是为了"省一次请求"。
|
||||
2. **刷新失败不能重试。** 失败意味着 refresh token 已过期/已撤销/已被重放,再试一次结果一样。直接登出跳登录页。
|
||||
3. **刷新请求本身不能走带 `AuthInterceptor` 的那个 `Dio`**,否则刷新接口返回 401 时会再次触发刷新,无限递归。`core_auth` 内部自建一个**裸 `Dio`**(不装任何拦截器)专门发刷新请求——这也是 [01-project-structure.md](./01-project-structure.md) 里 "`core_auth` 不依赖 `core_network`" 这条规则的由来。
|
||||
|
||||
```dart
|
||||
// packages/core_network/lib/src/auth_interceptor.dart
|
||||
class AuthInterceptor extends Interceptor {
|
||||
AuthInterceptor(this._ref);
|
||||
final Ref _ref;
|
||||
|
||||
/// 同一时刻最多一个刷新在跑;其他 401 请求 await 同一个 Future
|
||||
Future<void>? _refreshing;
|
||||
|
||||
static const _retriedKey = 'x-retried';
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
final token = _ref.read(authStateProvider).accessToken;
|
||||
if (token != null) options.headers['Authorization'] = 'Bearer $token';
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
if (err.response?.statusCode != 401) return handler.next(err);
|
||||
|
||||
// 一次性重试标记:带着新 token 重放后又 401,说明不是 token 的问题,别再刷了
|
||||
if (err.requestOptions.extra[_retriedKey] == true) {
|
||||
_ref.read(authStateProvider.notifier).logout();
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
try {
|
||||
// 用一个共享的 Future 天然实现串行:先到的发起刷新,后到的复用同一个 Future
|
||||
_refreshing ??= _ref.read(authRepositoryProvider).refreshToken();
|
||||
await _refreshing;
|
||||
} catch (e) {
|
||||
// 刷新失败 = refresh token 已失效,不重试,直接登出
|
||||
_ref.read(authStateProvider.notifier).logout();
|
||||
return handler.next(err);
|
||||
} finally {
|
||||
_refreshing = null;
|
||||
}
|
||||
|
||||
// 刷新成功,用新 token 重放原请求
|
||||
try {
|
||||
final options = err.requestOptions
|
||||
..extra[_retriedKey] = true
|
||||
..headers['Authorization'] =
|
||||
'Bearer ${_ref.read(authStateProvider).accessToken}';
|
||||
handler.resolve(await _ref.read(dioProvider).fetch(options));
|
||||
} on DioException catch (e) {
|
||||
handler.next(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 对比:常见的"`bool _isRefreshing` + `List<Completer>` 队列"写法有个致命缺陷——`catch` 分支里如果忘了对队列里的 `Completer` 调 `completeError` 并清空,所有排队的请求会**永久挂起**(`await completer.future` 永不返回),表现是 UI 一直转圈、用户只能杀进程。用共享 `Future` 的写法从结构上就不存在这个问题:刷新失败时 `await _refreshing` 对每个等待者都会抛异常,各自走各自的 `catch`,没有需要手动清理的队列。
|
||||
|
||||
`core_auth` 侧的刷新实现:
|
||||
|
||||
```dart
|
||||
// packages/core_auth/lib/src/token_refresher.dart
|
||||
class TokenRefresher {
|
||||
// 裸 Dio:不装任何拦截器,避免刷新请求自己再触发一轮刷新
|
||||
final _bare = Dio(BaseOptions(
|
||||
baseUrl: AppEnv.current.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
|
||||
Future<TokenPair> refresh(String refreshToken) async {
|
||||
final res = await _bare.post('/api/v1/auth/refresh', data: {'refreshToken': refreshToken});
|
||||
final data = res.data['data'] as Map<String, dynamic>; // 裸 Dio 没有解包拦截器,手动取
|
||||
// 后端轮换:新的 refreshToken 必须立刻覆盖存储,旧的已经作废了
|
||||
return TokenPair(
|
||||
accessToken: data['accessToken'] as String,
|
||||
refreshToken: data['refreshToken'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新的 refresh token 一定要写回 secure storage**(见 [06-local-storage.md](./06-local-storage.md))。写回失败或写回前进程被杀,下次启动用旧 token 就会触发重放判定——所以写回要在"通知 `authState` 更新"之前完成。
|
||||
|
||||
## 异常归一化
|
||||
|
||||
```dart
|
||||
// packages/core_network/lib/src/error_mapping_interceptor.dart
|
||||
class ErrorMappingInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
// 已经是 AppException 的(比如 ApiResultInterceptor 抛的 BusinessException)直接放行,
|
||||
// 不要二次包装成 NetworkException
|
||||
if (err.error is AppException) return handler.next(err);
|
||||
|
||||
final mapped = switch (err.type) {
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.sendTimeout ||
|
||||
DioExceptionType.receiveTimeout => NetworkException('网络超时,请检查网络后重试'),
|
||||
DioExceptionType.cancel => RequestCancelledException(),
|
||||
DioExceptionType.badResponse when err.response?.statusCode == 401 =>
|
||||
UnauthorizedException(),
|
||||
DioExceptionType.badResponse => HttpException(
|
||||
statusCode: err.response?.statusCode ?? -1,
|
||||
message: '服务异常(${err.response?.statusCode})',
|
||||
),
|
||||
_ => NetworkException('网络异常,请稍后重试'),
|
||||
};
|
||||
handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
response: err.response,
|
||||
error: mapped,
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 为什么要在 `Dio` 外面再包一层 `ApiClient`
|
||||
|
||||
拦截器**没有办法让 `dio.get()` 抛出 `AppException`**。dio 的错误通道只认 `DioException`,`handler.reject(...)` 传进去的必须是 `DioException`,我们的 `AppException` 只能挂在它的 `error` 字段上。也就是说,如果 repository 直接调 `dio.get()`,业务层写
|
||||
|
||||
```dart
|
||||
try { ... } on UnauthorizedException { ... } // ❌ 永远进不来
|
||||
```
|
||||
|
||||
是**捕获不到的**——实际抛出来的仍然是 `DioException`。
|
||||
|
||||
解决办法是在 `core_network` 的出口把 `DioException.error` 拆出来重抛:
|
||||
|
||||
```dart
|
||||
// packages/core_network/lib/src/api_client.dart
|
||||
class ApiClient {
|
||||
ApiClient(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<T> get<T>(String path, {Map<String, dynamic>? query, CancelToken? cancelToken}) =>
|
||||
_run(() => _dio.get<T>(path, queryParameters: query, cancelToken: cancelToken));
|
||||
|
||||
Future<T> post<T>(String path, {Object? data, CancelToken? cancelToken}) =>
|
||||
_run(() => _dio.post<T>(path, data: data, cancelToken: cancelToken));
|
||||
|
||||
Future<T> _run<T>(Future<Response<T>> Function() send) async {
|
||||
try {
|
||||
final res = await send();
|
||||
return res.data as T;
|
||||
} on DioException catch (e, st) {
|
||||
final error = e.error;
|
||||
// 拦截器已经归一化过,这里只负责把它从 DioException 的壳里拿出来重抛
|
||||
if (error is AppException) Error.throwWithStackTrace(error, st);
|
||||
Error.throwWithStackTrace(NetworkException('网络异常,请稍后重试'), st);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**规则:repository 一律注入 `ApiClient`,不注入 `Dio`。** 只有 `core_network` 内部和 `core_auth` 的裸 Dio 会直接碰 `Dio` 类型。这样上面那段 `on UnauthorizedException` 才真的成立。
|
||||
|
||||
`Error.throwWithStackTrace` 保留原始堆栈,否则上报到崩溃平台的堆栈会全部指向 `_run` 这一行,等于没有堆栈。
|
||||
|
||||
## 超时、重试与幂等
|
||||
|
||||
```dart
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
sendTimeout: const Duration(seconds: 30), // 上传单独放宽,见下文
|
||||
)
|
||||
```
|
||||
|
||||
**默认不做自动重试。** 理由和 [03-state-management.md](./03-state-management.md) 里全局关掉 Riverpod retry 是同一条:多层重试叠加会让一次用户操作变成难以预测的 N 次请求,日志也没法看。需要重试的地方显式写、并且必须满足:
|
||||
|
||||
- **只重试 GET**,或后端明确支持幂等键(`Idempotency-Key` 头)的 POST。
|
||||
- 只对超时/连接失败重试,业务错误码和 4xx 不重试。
|
||||
- 最多 1 次。
|
||||
|
||||
`f6-integration` 侧后端已经配了重试和熔断([backend/05-integration-layer.md](../backend/05-integration-layer.md)),客户端再叠一层意义不大,反而会把后端的熔断窗口打满。
|
||||
|
||||
## `CancelToken` 与 provider 生命周期
|
||||
|
||||
```dart
|
||||
@riverpod
|
||||
Future<List<PurchaseOrder>> purchaseOrders(Ref ref) async {
|
||||
final cancelToken = CancelToken();
|
||||
ref.onDispose(cancelToken.cancel); // 页面销毁 / 门店切换导致 provider 重建时自动中断
|
||||
|
||||
final storeId = ref.watch(currentStoreIdProvider);
|
||||
return ref.watch(purchaseRepositoryProvider).fetchOrders(storeId, cancelToken: cancelToken);
|
||||
}
|
||||
```
|
||||
|
||||
被取消的请求会抛 `RequestCancelledException`。**UI 层必须把它当"什么都不做"处理,不能弹错误提示**——用户主动离开页面时看到"请求失败"是很糟的体验。这条在 [12-error-and-api-contract.md](./12-error-and-api-contract.md) 的错误展示规则里统一约定。
|
||||
|
||||
## 文件与图片上传
|
||||
|
||||
PRD 第 7.3 节的 JSBridge 能力清单(H5 桥接的图片选择/上传)和施工照片场景都要用到。
|
||||
|
||||
```dart
|
||||
// packages/core_network/lib/src/api_client.dart
|
||||
Future<T> upload<T>(
|
||||
String path, {
|
||||
required List<File> files,
|
||||
Map<String, dynamic>? fields,
|
||||
void Function(int sent, int total)? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
final formData = FormData.fromMap({
|
||||
...?fields,
|
||||
'files': [
|
||||
for (final f in files)
|
||||
await MultipartFile.fromFile(f.path, filename: p.basename(f.path)),
|
||||
],
|
||||
});
|
||||
return _run(() => _dio.post<T>(
|
||||
path,
|
||||
data: formData,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: onProgress,
|
||||
// 上传单独放宽超时,用全局的 30s 传几张原图会超
|
||||
options: Options(sendTimeout: const Duration(minutes: 3)),
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- **上传前必须压缩**。门店员工用手机直接拍的照片通常 3–8 MB,原图上传在门店 WiFi 环境下大概率超时。统一压到长边 1600px、JPEG 质量 80,超过 2 MB 再降一档。
|
||||
- **进度必须可见**:多图上传要有整体进度,否则用户会以为卡死反复点。
|
||||
- **失败要能单张重传**,不能因为第 5 张失败就让前 4 张重来。所以 UI 上传状态按单张维护。
|
||||
- `FormData` **不可重用**:dio 的 `FormData` 是流,重试必须重新构造一个,直接复用会报 stream already listened。
|
||||
|
||||
## 传输安全
|
||||
|
||||
- **全环境强制 HTTPS**,包括 dev。Android 侧在 `network_security_config.xml` 里关掉明文流量(`cleartextTrafficPermitted="false"`),iOS 不放开 ATS 例外。这样"某个环境不小心配了 http 的 baseUrl"会在开发阶段就直接失败,而不是上线后才发现。
|
||||
- **证书 pinning:首版不做。** 取舍如下——pinning 能防中间人抓包,但代价是证书轮换时必须发新版 App,否则全线不可用;而门店 App 走的是公司自有域名 + 标准 CA,主要威胁模型是"员工手机装了抓包工具看接口",这个用 pinning 挡的收益不高。如果后续有合规要求再加,届时用**双证书 pin(当前 + 备用)** 并且 pin 到中间 CA 而不是叶子证书,留出轮换空间。
|
||||
- 日志脱敏:`LogInterceptor` 只在 dev/staging 开启,且 `Authorization` 头、密码、手机号在打日志前替换成掩码。这条同样适用于上报到崩溃平台的面包屑(见 [13-observability-analytics.md](./13-observability-analytics.md))。
|
||||
|
||||
## 待确认项
|
||||
|
||||
- `X-Trace-Id` 由客户端生成、后端复用——需与后端确认入口 filter 的实现。
|
||||
- 分页参数字段名(backend 06 的「待补充」里也挂着这一项,见 [02-layering.md](./02-layering.md))。
|
||||
- 错误码表(backend 06 待补充),拿到后补进 [12-error-and-api-contract.md](./12-error-and-api-contract.md) 的映射表。
|
||||
- 上传接口的大小上限、允许的文件类型、是否走对象存储直传。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [dio 官方文档](https://pub.dev/packages/dio)
|
||||
- [Dio Interceptors 文档](https://pub.dev/packages/dio#interceptors)
|
||||
- [Dio CancelToken](https://pub.dev/packages/dio#cancellation)
|
||||
- [Android network security config](https://developer.android.com/privacy-and-security/security-config)
|
||||
|
||||
## 附录:dio 是什么,日常怎么用
|
||||
|
||||
给还没接触过这套网络层封装方式的同学看的入门说明。
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
Dart 内置的 `http` 包只提供最基础的请求能力,实际项目里几乎总会遇到这些需求:
|
||||
|
||||
1. **每个请求都要带 token**,token 过期了要先刷新再重试,不能让每个业务代码自己判断"要不要刷新 token"。
|
||||
2. **统一的错误处理**:网络超时、服务端返回的业务错误码、HTTP 状态码错误,最终都要转换成 UI 层能直接判断的统一异常类型,而不是每个 feature 各自写一遍 `try/catch` 判断状态码。
|
||||
3. **请求/响应日志**:调试时想看到完整的请求参数和返回值,上线后又不希望日志暴露敏感数据。
|
||||
|
||||
`http` 包本身没有"拦截器"这个概念,要实现上面这些都得自己在每次调用前后手动包一层逻辑,容易漏、容易不一致。**dio** 内置了 [`Interceptor`](https://pub.dev/packages/dio#interceptors) 机制,可以把这些横切逻辑集中写一次,所有请求自动生效。
|
||||
|
||||
### 核心概念
|
||||
|
||||
1. **`Dio` 实例**:一个 client 对象,带 `BaseOptions`(`baseUrl`、`connectTimeout` 等默认配置),项目里应该只有一个全局实例,而不是每个地方各建一个。
|
||||
2. **`Interceptor`**:可以拦截请求发出前(`onRequest`)、响应回来后(`onResponse`)、出错时(`onError`)三个时机。**注意 dio 的执行顺序**:三个时机都是按注册顺序**正向**执行的,不是"请求正向、响应反向"的洋葱模型——这一点和很多人的直觉不同,配置拦截器顺序时要留意。
|
||||
3. **`DioException`**:dio 所有的错误(超时、连接失败、非 2xx 状态码等)都会包装成这个类型,可以在拦截器里统一识别、转换。
|
||||
4. **`CancelToken`**:用来主动取消一个还没完成的请求,常见场景是页面销毁或用户切走时中断请求,避免拿到结果时 widget 已经不存在了。
|
||||
|
||||
### 拦截器链的组装
|
||||
|
||||
```dart
|
||||
// packages/core_network/lib/src/dio_client.dart
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
final env = ref.watch(appEnvProvider);
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: env.apiBaseUrl, // 见 08-build-flavors.md
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
sendTimeout: const Duration(seconds: 30),
|
||||
));
|
||||
|
||||
dio.interceptors.addAll([
|
||||
HeaderInterceptor(ref),
|
||||
if (env.enableLog) LogInterceptor(responseBody: false),
|
||||
AuthInterceptor(ref),
|
||||
ApiResultInterceptor(ref.watch(loggerProvider)),
|
||||
ErrorMappingInterceptor(),
|
||||
]);
|
||||
|
||||
return dio;
|
||||
});
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient(ref.watch(dioProvider)));
|
||||
```
|
||||
|
||||
顺序的理由:`AuthInterceptor` 必须排在 `ErrorMappingInterceptor` 前面,才能在 401 被归一化成 `UnauthorizedException` **之前**先尝试刷新 token;`ApiResultInterceptor` 排在 `ErrorMappingInterceptor` 前面,是因为它抛出的 `BusinessException` 需要能被后者识别并放行(后者第一行就是判断 `err.error is AppException`)。
|
||||
|
||||
### 业务层看到的样子
|
||||
|
||||
```dart
|
||||
// data/repository/purchase_repository_impl.dart
|
||||
class PurchaseRepositoryImpl implements PurchaseRepository {
|
||||
PurchaseRepositoryImpl(this._api);
|
||||
final ApiClient _api;
|
||||
|
||||
@override
|
||||
Future<List<PurchaseOrder>> fetchOrders(int storeId, {CancelToken? cancelToken}) async {
|
||||
// 返回的已经是 ApiResult 里的 data,外层包装由拦截器解开
|
||||
final list = await _api.get<List<dynamic>>(
|
||||
'/api/v1/purchase/orders',
|
||||
query: {'storeId': storeId},
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
return list.map((e) => PurchaseOrder.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// presentation 层
|
||||
try {
|
||||
final orders = await repository.fetchOrders(storeId);
|
||||
} on UnauthorizedException {
|
||||
// 已经被 AuthInterceptor 处理过登出,这里一般只需要静默
|
||||
} on BusinessException catch (e) {
|
||||
showToast('${e.message}(${e.traceId})');
|
||||
} on RequestCancelledException {
|
||||
// 用户主动离开,什么都不做
|
||||
} on AppException catch (e) {
|
||||
showToast(e.message);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,323 @@
|
||||
# 06. 本地存储方案
|
||||
|
||||
## 决策
|
||||
|
||||
按数据类型分三档存储,`feature_*` 不直接依赖底层存储库:
|
||||
|
||||
| 数据类型 | 方案 | 版本(2026-08 快照) | 归属包 |
|
||||
|---|---|---|---|
|
||||
| 结构化/关系型数据(门店列表缓存、订单历史等) | **[Drift](https://pub.dev/packages/drift)** | `^2.34.3` | `core_storage` |
|
||||
| 敏感数据(token、refresh token) | **[flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage)** | `11.0.0`(锁死) | **`core_auth`** |
|
||||
| 简单非敏感 KV(是否看过引导页、用户偏好设置) | **[shared_preferences](https://pub.dev/packages/shared_preferences)** | `^2.5.5` | `core_storage` |
|
||||
|
||||
> **secure storage 归 `core_auth` 独占,不放进 `core_storage`。** 唯一读写 token 的地方就是 `core_auth`,把它放进 `core_storage` 会逼出一条 `core_auth → core_storage` 的依赖,而 `core_storage` 里其他东西 `core_auth` 一样都用不上(见 [01-project-structure.md](./01-project-structure.md) 的依赖例外表)。代价是 `core_auth` 自己要依赖 `flutter_secure_storage`,这比多一条包间依赖划算。
|
||||
|
||||
## 依赖
|
||||
|
||||
```yaml
|
||||
# core_storage
|
||||
dependencies:
|
||||
drift: ^2.34.3
|
||||
drift_flutter: ^0.3.1 # 打开数据库的官方 Flutter 胶水包
|
||||
path_provider: ^2.1.6
|
||||
shared_preferences: ^2.5.5
|
||||
|
||||
dev_dependencies:
|
||||
drift_dev: ^2.34.5
|
||||
build_runner: ^2.15.2
|
||||
|
||||
# core_auth
|
||||
dependencies:
|
||||
flutter_secure_storage: 11.0.0 # 锁死,不用 ^,理由见下文
|
||||
```
|
||||
|
||||
> **不要再写 `sqlite3_flutter_libs`。** 这个包已经 **EOL**(最新版本号就叫 `0.6.0+eol`),sqlite3 3.x 起不再需要它。drift 官方现在的推荐组合是 `drift_flutter` + `path_provider`,`driftDatabase()` 会帮你处理原生库加载、数据库文件路径、以及后台 isolate。
|
||||
|
||||
|
||||
## 使用规则
|
||||
|
||||
- 全仓库**只有一个 Drift 数据库实例**,定义在 `core_storage` 里,不允许每个 `feature_*` 各自建一个 SQLite 文件——避免多个数据库文件之间做跨 feature 查询/事务的麻烦。
|
||||
- 每个 feature 拥有自己的表(`Table` 类)和 DAO(`DriftAccessor`),表名加 feature 前缀(如 `store_cache`、`payment_history`)避免命名冲突,但都注册进同一个 `AppDatabase`。
|
||||
- feature 的 `data` 层 `local_datasource` 只依赖自己的 DAO 类型,不直接操作 `AppDatabase` 或访问其他 feature 的表。
|
||||
- token / refresh token 只能经过 `core_auth` 包里封装的 secure storage 读写方法,不允许其他 `core_*`/`feature_*` 直接调用 `FlutterSecureStorage` 实例。
|
||||
- 数据库表结构变更必须写 migration(`onUpgrade` + `schemaVersion` 递增),不允许直接改字段定义后期望"重装了事"——线上用户已有数据需要平滑迁移。
|
||||
|
||||
## 所有业务缓存表必须带 `storeId`
|
||||
|
||||
PRD REQ-LGN-010(门店上下文)要求切换门店时级联失效所有门店相关缓存——购物车、待办、预警、订单上下文全部跟着切。本地缓存是最容易漏的一环——provider 失效了,但 Drift 里 A 门店的数据还在,切到 B 门店离线打开页面就会读到 A 门店的数据。
|
||||
|
||||
**硬性规则**:任何缓存业务数据的表都必须有 `storeId` 列,并且
|
||||
|
||||
- 所有查询都带 `where(tbl.storeId.equals(currentStoreId))`,不允许无门店条件的全表查询;
|
||||
- `storeId` 建索引;
|
||||
- 表的主键包含 `storeId`(或用 `(storeId, businessId)` 联合主键),避免不同门店的同 ID 记录互相覆盖。
|
||||
|
||||
```dart
|
||||
class PurchaseOrderCache extends Table {
|
||||
IntColumn get storeId => integer()();
|
||||
TextColumn get orderId => text()();
|
||||
TextColumn get payload => text()();
|
||||
DateTimeColumn get cachedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {storeId, orderId}; // 联合主键,天然按门店隔离
|
||||
}
|
||||
```
|
||||
|
||||
不设 `storeId` 的表只有一类:**与门店无关的全局数据**(如 App 配置、引导页标记),这类应该放 `shared_preferences` 而不是 Drift。
|
||||
|
||||
## 登出 / 切换门店的清理策略
|
||||
|
||||
| 场景 | Drift 业务表 | shared_preferences | secure storage (token) | H5 会话 |
|
||||
|---|---|---|---|---|
|
||||
| **切换门店** | 删除**非当前门店**的行(或全清,见下) | 保留 | 保留 | 失效(见 [10-webview-h5.md](./10-webview-h5.md)) |
|
||||
| **登出** | **全部清空** | 只清与用户相关的键,保留 App 级偏好 | **全部清空** | 失效 + 清 Cookie/LocalStorage |
|
||||
| **切换账号** | 同登出 | 同登出 | 同登出 | 同登出 |
|
||||
|
||||
**切换门店时是"只留当前门店"还是"全清"**:选**全清**。理由是保留其他门店的旧数据没有实际收益(用户切回去时数据早已过期,还是要重新拉),但会带来"用户看到的是几天前的数据却没有任何提示"这类问题;而全清的代价只是切回去时多一次 loading。
|
||||
|
||||
```dart
|
||||
// packages/core_storage/lib/src/app_database.dart
|
||||
extension StoreScopedCleanup on AppDatabase {
|
||||
/// 切换门店 / 登出时调用;在一个事务里清,避免清一半被杀进程留下不一致状态
|
||||
Future<void> clearBusinessCache() => transaction(() async {
|
||||
for (final table in allTables.where(_isBusinessCache)) {
|
||||
await delete(table).go();
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**清理动作由谁触发**:统一在 `11-store-context-and-session.md` 定义的会话编排里调用,各 feature 不自己监听门店变化去清自己的表——分散清理必然会漏。
|
||||
|
||||
**清理顺序也有讲究**:先切断新写入(让 provider 失效、请求取消),再清库。反过来会出现"刚清完,一个在途请求的回调又把旧门店数据写回去了"。
|
||||
|
||||
## 缓存 TTL
|
||||
|
||||
Drift 里的缓存**默认都是"降级用"的,不是"优先用"的**:正常路径永远走网络,缓存只在网络失败或首屏加载时先垫一下。这样 TTL 的作用就不是"过期就不能用",而是"过期了就不要再拿它当有效内容展示"。
|
||||
|
||||
| 数据 | TTL | 过期后行为 |
|
||||
|---|---|---|
|
||||
| 门店列表 | 24h | 仍展示,但顶部提示"数据可能不是最新" |
|
||||
| 工作台 tile 数据 | 5min | 不展示缓存,直接走 loading |
|
||||
| 订单/采购单列表 | 10min | 展示缓存 + 下拉刷新 |
|
||||
| 经营/财务分析数据 | 不缓存 | — |
|
||||
|
||||
每张缓存表都有 `cachedAt` 列,判断逻辑写在 `local_datasource` 里,不散落在 UI。
|
||||
|
||||
**经营/财务类数据不落本地**:这类是敏感数据,手机丢失或被拿去 root 后 SQLite 文件可以直接读。收益(离线可看)远小于风险,直接不缓存最省事——也就不需要引入 SQLCipher 这类数据库加密方案(引入的话要处理密钥存哪、密钥丢了怎么办、以及原生库体积增加)。这条如果后续业务要求离线查看经营数据,再重新评估。
|
||||
|
||||
## Migration 必须被验证,不能只靠"写了"
|
||||
|
||||
"必须写 migration"这条规则没有配套验证手段的话,等于没有——migration 写错的表现是**线上用户升级后 App 一启动就崩**,而开发机上因为是全新安装,永远测不出来。
|
||||
|
||||
Drift 官方提供了 schema 快照 + 验证工具链,纳入流程:
|
||||
|
||||
```bash
|
||||
# 1. 每次 schemaVersion 递增后,导出当前 schema 快照(产物入库)
|
||||
fvm dart run drift_dev schema dump lib/src/app_database.dart drift_schemas/
|
||||
|
||||
# 2. 生成迁移测试的辅助代码
|
||||
fvm dart run drift_dev schema generate drift_schemas/ test/generated_migrations/
|
||||
```
|
||||
|
||||
```dart
|
||||
// packages/core_storage/test/migration_test.dart
|
||||
void main() {
|
||||
late SchemaVerifier verifier;
|
||||
setUpAll(() => verifier = SchemaVerifier(GeneratedHelper()));
|
||||
|
||||
test('从 v1 到最新版本的迁移都能跑通', () async {
|
||||
for (var from = 1; from < AppDatabase.latestSchemaVersion; from++) {
|
||||
final connection = await verifier.startAt(from);
|
||||
final db = AppDatabase.forTesting(connection);
|
||||
await verifier.migrateAndValidate(db, AppDatabase.latestSchemaVersion);
|
||||
await db.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `drift_schemas/` 下的 JSON 快照**入 git**,每次改表结构必须跟着生成新快照,PR 里能直接看到 schema diff。
|
||||
- 迁移测试进 `melos run test`,CI 卡点(见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。
|
||||
- `migrateAndValidate` 只验证**结构**,不验证数据。涉及数据搬迁(拆表、改语义)的迁移要额外写一个"造老数据 → 迁移 → 断言新数据"的用例。
|
||||
|
||||
## flutter_secure_storage 11.0.0 的升级风险
|
||||
|
||||
`flutter_secure_storage 11.0.0` 是 2026-08 才发的大版本,**改了 Android 侧的默认加密实现**(RSA OAEP + AES-GCM)。这意味着:
|
||||
|
||||
- 用旧版本写入的数据,升级后有**读不出来**的风险(返回 null 或抛异常)。对我们来说就是"用户升级 App 后被登出"。
|
||||
- 版本号在 `pubspec.yaml` 里**写死 `11.0.0`,不用 `^`**。这个包的历史上出现过 minor 版本改加密实现的情况,`^` 会让某次 `pub upgrade` 悄悄换掉加密方式,而问题只在真机升级路径上暴露,CI 和新装都测不出来。升级它必须是一次显式的、带回归验证的动作。
|
||||
- **首版是新 App,不存在历史数据**,所以本次没有实际迁移风险;这条规则是为**后续升级**立的。
|
||||
|
||||
读取失败的兜底必须写:
|
||||
|
||||
```dart
|
||||
Future<String?> readAccessToken() async {
|
||||
try {
|
||||
return await _storage.read(key: _kAccessToken);
|
||||
} catch (e, st) {
|
||||
// 读不出来一律当作未登录:清空 + 跳登录页,而不是抛异常让用户卡在启动页
|
||||
_logger.e('secure storage 读取失败,按未登录处理', error: e, stackTrace: st);
|
||||
await clear();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**绝对不能让 secure storage 的异常向上冒到启动流程**——那会变成"升级后一打开就白屏/崩溃",比重新登录严重得多。
|
||||
|
||||
## 数据库在后台 isolate 打开
|
||||
|
||||
大批量写入(比如一次同步几百条订单)在主 isolate 上跑会掉帧。`drift_flutter` 的 `driftDatabase()` **默认就用后台 isolate**,只要不手动关掉即可:
|
||||
|
||||
```dart
|
||||
// packages/core_storage/lib/src/connection.dart
|
||||
QueryExecutor openConnection() => driftDatabase(
|
||||
name: 'conti_app',
|
||||
native: const DriftNativeOptions(
|
||||
databaseDirectory: getApplicationSupportDirectory, // iOS 上不要用 Documents,会被 iCloud 备份
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
iOS 上数据库文件放 `Application Support` 而不是 `Documents`:`Documents` 会被 iCloud 备份,缓存数据没必要占用户的 iCloud 空间,苹果审核也可能因此提意见。
|
||||
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Drift 官方文档](https://drift.simonbinder.eu/)
|
||||
- [drift_flutter | Dart package](https://pub.dev/packages/drift_flutter)
|
||||
- [Drift: Migrations 与 schema 验证](https://drift.simonbinder.eu/Migrations/tests/)
|
||||
- [flutter_secure_storage | Dart package](https://pub.dev/packages/flutter_secure_storage)
|
||||
- [shared_preferences | Dart package](https://pub.dev/packages/shared_preferences)
|
||||
|
||||
## 附录:Drift 是什么,日常怎么用
|
||||
|
||||
给还没接触过这套本地数据库封装方式的同学看的入门说明。
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
Flutter 生态里直接操作本地 SQLite 最常见的是 [`sqflite`](https://pub.dev/packages/sqflite),但它是纯 SQL 字符串拼接:
|
||||
|
||||
```dart
|
||||
// sqflite 写法,容易手滑打错字段名/表名,编译期完全发现不了
|
||||
await db.rawQuery('SELECT * FROM stroe WHERE nmae = ?', [name]);
|
||||
```
|
||||
|
||||
字段名、表名全靠字符串,拼错了只有运行时才报错;查询结果是 `Map<String, Object?>`,还得手动转成业务对象;数据变化了想让 UI 自动刷新,也得自己手写一套通知机制。
|
||||
|
||||
**Drift** 在 `sqflite`(或更底层的 `sqlite3`)之上加了一层代码生成:用 Dart 类定义表结构,`build_runner` 生成类型安全的查询代码,写错字段名/类型在编译期就会报错;查询结果直接是强类型的 Dart 对象;还内置了 `.watch()` 方法,数据变化时自动推送新结果,天然适合配合 Riverpod 的 `StreamProvider`/`AsyncNotifier` 做响应式 UI。
|
||||
|
||||
### 核心概念
|
||||
|
||||
1. **`Table` 类**:用 Dart 代码声明表结构(字段名、类型、约束),而不是手写 `CREATE TABLE` 语句。
|
||||
2. **`DriftAccessor`(DAO)**:给一组相关表写查询/增删改方法的地方,业务代码只调用 DAO 方法,不直接写 SQL。
|
||||
3. **`.watch()` vs `.get()`**:`.get()` 是一次性查询,`.watch()` 返回一个 `Stream`,只要底层数据变化(哪怕是另一个页面改的)就会自动推送新结果——不需要手动刷新。
|
||||
4. **`schemaVersion` + `onUpgrade`**:数据库版本号和迁移回调,改表结构时递增版本号并在 `onUpgrade` 里写迁移逻辑(加字段、建索引等),保证已安装用户的本地数据不会因为升级直接报错或丢失。
|
||||
|
||||
### 使用示例(`feature_store`:门店列表本地缓存)
|
||||
|
||||
```dart
|
||||
// packages/core_storage/lib/src/tables/store_table.dart
|
||||
class StoreCache extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get name => text()();
|
||||
RealColumn get lat => real()();
|
||||
RealColumn get lng => real()();
|
||||
DateTimeColumn get cachedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// packages/core_storage/lib/src/daos/store_dao.dart
|
||||
part 'store_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [StoreCache])
|
||||
class StoreDao extends DatabaseAccessor<AppDatabase> with _$StoreDaoMixin {
|
||||
StoreDao(super.db);
|
||||
|
||||
Future<void> upsertAll(List<StoreCacheCompanion> stores) =>
|
||||
batch((b) => b.insertAllOnConflictUpdate(storeCache, stores));
|
||||
|
||||
Stream<List<StoreCacheData>> watchAll() => select(storeCache).watch();
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// packages/core_storage/lib/src/app_database.dart
|
||||
@DriftDatabase(tables: [StoreCache, PurchaseOrderCache], daos: [StoreDao, PurchaseOrderDao])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(openConnection());
|
||||
AppDatabase.forTesting(super.connection); // 迁移测试用
|
||||
|
||||
static const latestSchemaVersion = 2;
|
||||
|
||||
@override
|
||||
int get schemaVersion => latestSchemaVersion;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onUpgrade: (m, from, to) async {
|
||||
if (from < 2) {
|
||||
await m.addColumn(storeCache, storeCache.cachedAt);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> 门店列表这张表存的是"当前用户能访问哪些门店",属于用户级而不是门店级数据,所以没有 `storeId` 列——它是上文那条"业务缓存表必须带 `storeId`"规则的合理例外。`PurchaseOrderCache` 那种才是典型的门店级数据。
|
||||
|
||||
```dart
|
||||
// feature_store 的 local_datasource 只依赖 StoreDao,不直接碰 AppDatabase
|
||||
class StoreLocalDataSource {
|
||||
final StoreDao _dao;
|
||||
StoreLocalDataSource(this._dao);
|
||||
|
||||
Stream<List<Store>> watchCachedStores() =>
|
||||
_dao.watchAll().map((rows) => rows.map(Store.fromCacheRow).toList());
|
||||
}
|
||||
```
|
||||
|
||||
配合 Riverpod 做响应式 UI(离线也能展示上次缓存的门店列表,等网络数据回来再刷新):
|
||||
|
||||
```dart
|
||||
@riverpod
|
||||
Stream<List<Store>> cachedStores(Ref ref) {
|
||||
final localDataSource = ref.watch(storeLocalDataSourceProvider);
|
||||
return localDataSource.watchCachedStores();
|
||||
}
|
||||
```
|
||||
|
||||
### secure storage 使用示例(token 存取)
|
||||
|
||||
```dart
|
||||
// packages/core_auth/lib/src/token_storage.dart
|
||||
class TokenStorage {
|
||||
final FlutterSecureStorage _storage;
|
||||
TokenStorage(this._storage);
|
||||
|
||||
Future<void> saveTokens({required String accessToken, required String refreshToken}) =>
|
||||
Future.wait([
|
||||
_storage.write(key: 'access_token', value: accessToken),
|
||||
_storage.write(key: 'refresh_token', value: refreshToken),
|
||||
]);
|
||||
|
||||
Future<String?> readAccessToken() => _storage.read(key: 'access_token');
|
||||
|
||||
Future<void> clear() => _storage.deleteAll();
|
||||
}
|
||||
```
|
||||
|
||||
`TokenStorage` 是全仓库唯一直接持有 `FlutterSecureStorage` 实例的类,其他包只能通过 `core_auth` 暴露的 provider 间接读写 token。
|
||||
|
||||
## 待确认项
|
||||
|
||||
- 经营/财务数据是否需要离线查看。如果需要,要重新评估数据库加密(SQLCipher)方案,涉及密钥保管和原生库体积。
|
||||
- 门店切换时"全清缓存"在门店数量多、切换频繁的用户上的实际体验,上线后看埋点再调。
|
||||
@@ -0,0 +1,404 @@
|
||||
# 07. 原生能力集成方式
|
||||
|
||||
## 决策
|
||||
|
||||
原生能力(扫码、支付、蓝牙等)统一封装成独立的 `native_*` Dart package(结构见 [01-project-structure.md](./01-project-structure.md)),跨语言接口用 **[Pigeon](https://pub.dev/packages/pigeon)**(`^27.3.0`,2026-08 快照)生成,不手写裸 `MethodChannel`/`invokeMethod` 字符串调用。
|
||||
|
||||
## 依赖
|
||||
|
||||
```yaml
|
||||
dev_dependencies:
|
||||
pigeon: ^27.3.0
|
||||
```
|
||||
|
||||
## 包结构规则
|
||||
|
||||
```
|
||||
native_scan/
|
||||
pubspec.yaml # 必须有 flutter: plugin: platforms: 声明,见下文
|
||||
pigeons/
|
||||
scan_api.dart # 接口 schema 定义,唯一手写的源文件
|
||||
lib/
|
||||
native_scan.dart # 对外导出:封装好的公共 API 类(调用方只调这个)
|
||||
src/
|
||||
generated/ # pigeon 生成的 Dart 端代码,不手动修改
|
||||
android/
|
||||
src/main/kotlin/.../ScanApi.g.kt # pigeon 生成
|
||||
src/main/kotlin/.../ScanApiImpl.kt # 手写:生成的 Kotlin host API 接口的实现
|
||||
src/main/kotlin/.../NativeScanPlugin.kt # 手写:插件注册入口
|
||||
ios/
|
||||
Classes/ScanApi.g.swift # pigeon 生成
|
||||
Classes/ScanApiImpl.swift # 手写:生成的 Swift host API 协议的实现
|
||||
Classes/NativeScanPlugin.swift # 手写:插件注册入口
|
||||
```
|
||||
|
||||
首版只有 `android/` 和 `ios/`(OHOS 不在首版范围,见文末「OHOS 后续演进」)。
|
||||
|
||||
### `pubspec.yaml` 必须声明 plugin platforms
|
||||
|
||||
这是最容易漏、漏了最难排查的一条:**`native_*` 包如果没有 `flutter: plugin:` 声明,`android/`、`ios/` 下的原生代码根本不会被编译进宿主 App**。表现是 Dart 侧调用直接抛 `MissingPluginException`,而代码看上去哪里都没问题。
|
||||
|
||||
```yaml
|
||||
# packages/native_scan/pubspec.yaml
|
||||
name: native_scan
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.0
|
||||
flutter: '>=3.44.0'
|
||||
|
||||
flutter:
|
||||
plugin:
|
||||
platforms:
|
||||
android:
|
||||
package: com.conti.native_scan
|
||||
pluginClass: NativeScanPlugin
|
||||
ios:
|
||||
pluginClass: NativeScanPlugin
|
||||
```
|
||||
|
||||
`pluginClass` 指向的类需要实现 `FlutterPlugin`(Android)/ `FlutterPlugin` 协议(iOS),在 `onAttachedToEngine` 里把 `ScanApiImpl` 注册到 pigeon 生成的 `setUp` 方法上:
|
||||
|
||||
```kotlin
|
||||
// android/src/main/kotlin/com/conti/native_scan/NativeScanPlugin.kt
|
||||
class NativeScanPlugin : FlutterPlugin, ActivityAware {
|
||||
private var impl: ScanApiImpl? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
impl = ScanApiImpl()
|
||||
ScanHostApi.setUp(binding.binaryMessenger, impl) // pigeon 生成的注册方法
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
ScanHostApi.setUp(binding.binaryMessenger, null)
|
||||
impl = null
|
||||
}
|
||||
|
||||
// 扫码需要 Activity(起 CameraX 预览页),通过 ActivityAware 拿
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) { impl?.activity = binding.activity }
|
||||
override fun onDetachedFromActivity() { impl?.activity = null }
|
||||
override fun onReattachedToActivityForConfigChanges(b: ActivityPluginBinding) = onAttachedToActivity(b)
|
||||
override fun onDetachedFromActivityForConfigChanges() = onDetachedFromActivity()
|
||||
}
|
||||
```
|
||||
|
||||
> `ActivityAware` 不能省。扫码、相册选择、拨号这类能力都需要 `Activity`(起页面、申请权限、收 `onActivityResult`),只在 `onAttachedToEngine` 里拿 `applicationContext` 是不够的。而且 `onDetachedFromActivity` 里必须把引用置空,否则横竖屏切换或后台回收后会持有已销毁的 Activity,导致内存泄漏和崩溃。
|
||||
|
||||
## 扫码的归属:App 原生实现
|
||||
|
||||
**扫码由 App 原生实现(`native_scan`),不是 F6 的功能。**
|
||||
|
||||
`native_scan` 同时服务两个调用方:
|
||||
|
||||
```
|
||||
feature_scan(App 内的扫码页:扫码入库、扫码查件)
|
||||
↘
|
||||
native_scan → 原生相机 + 解码
|
||||
↗
|
||||
core_webview 的 JSBridge(H5 页面调起扫码,见 10-webview-h5.md)
|
||||
```
|
||||
|
||||
这也是 [01-project-structure.md](./01-project-structure.md) 里"`core_*` 允许依赖 `native_*`"这条例外存在的原因——如果只允许 `feature_* → native_*`,`core_webview` 的 JSBridge 就没法调起扫码,只能退化成"复制一份扫码实现"或者"让 core_webview 反向依赖 feature_scan",两条都不可接受。
|
||||
|
||||
> **与前期材料的冲突(已裁决)**:前期草稿和 `202606-Conti-Retail-APP-Component-data-source.md` 里把扫码写成"嵌入 F6 扫码页",与此处不一致。已按本文档裁决(扫码是 App 原生做的),现行 PRD 的 **REQ-INT-003** 已校正。
|
||||
|
||||
### VIN 码与车牌识别:车牌走阿里云 OCR,其余在端上解
|
||||
|
||||
PRD 要求扫描 **VIN 码**和**车牌**。但通用扫码库(`mobile_scanner`、ZXing、MLKit Barcode Scanning)解的是**二维码/条形码**,识别不了车牌这种自然场景文字;VIN 虽然常以 Code 39 条码形式印在车身铭牌上,但也大量存在"只有印刷字符、没有条码"的情况。这两个都需要 **OCR**。
|
||||
|
||||
**结论:车牌用付费的[阿里云视觉智能开放平台车牌识别](https://help.aliyun.com/zh/viapi/developer-reference/api-u92rj0)(`RecognizeLicensePlate`),上传图片换识别结果,不做端侧模型。**
|
||||
|
||||
| 需求 | 能力 | 方案 | 在哪跑 |
|
||||
|---|---|---|---|
|
||||
| 二维码 / 条形码(商品、库位) | Barcode | MLKit Barcode Scanning(Android)/ Vision(iOS) | **端上**,离线 |
|
||||
| VIN 条码 | Barcode(Code 39) | 同上 | **端上**,离线 |
|
||||
| VIN 印刷字符 | OCR + 校验位算法 | MLKit Text Recognition / Vision 通用 OCR,**用 VIN 第 9 位校验码过滤误识别** | **端上**,离线 |
|
||||
| **车牌** | **云端 OCR** | **阿里云 `RecognizeLicensePlate`** | **云端**,联网 |
|
||||
|
||||
这张表最重要的是最后一列:**只有车牌这一路需要联网**,其余三路都在端上离线完成。下面的约束全部由这个差异推出来。
|
||||
|
||||
#### 车牌这一路和其它三路完全不是一回事
|
||||
|
||||
它不再是 `native_scan` 的一种扫码模式,而是「**拍照 → 上传 → 等结果**」的网络请求。三个直接后果:
|
||||
|
||||
**1. 交互从"取景框自动识别"变成"按快门"。** 端侧方案可以逐帧识别、对准就出结果;云端 API 按次计费且有网络往返,**不允许连续帧调用**。所以车牌入口的交互是拍一张照、上传、等一个明确的结果。设计稿如果画的是扫码式取景框自动识别,需要按这条调整。
|
||||
|
||||
**2. 弱网下这个功能直接不可用。** 门店地下车库、施工区网络条件差,而接车是高频动作。所以:
|
||||
|
||||
- **手工输码是常驻的并列入口,不是识别失败后的降级路径**(PRD 首页「扫码 / 车牌」本来就是两个按钮)。
|
||||
- 上传前**必须压缩**:API 限制单图 ≤ 4 MB、分辨率 15×15 ~ 4096×4096,而手机原图动辄十几 MB。压到长边 1920 左右、JPEG 质量 80 通常既满足识别又能在弱网下传得动。
|
||||
- 超时和重试上限要设死。**失败就退回手工输码,不要自动重试第二次** —— 每次调用都要花钱,而且用户已经在等了。
|
||||
|
||||
**3. 图片要离开设备,隐私政策必须写到。** 阿里云是境内服务,**不涉及数据出境**,但"车辆照片上传至第三方进行识别"属于必须告知的处理行为,要进隐私政策,并计入 `REQ-NFR-023`。
|
||||
|
||||
#### 客户端不直连阿里云
|
||||
|
||||
**AK/SK 绝对不能进客户端。** 打进 APK 的密钥等同于公开,反编译就能拿到,之后任何人都能拿我们的账号刷调用量。而且 `RecognizeLicensePlate` 收的是 `ImageURL`(OSS 链接),不是图片二进制 —— 客户端直连还得自己处理 OSS 上传凭证,更没必要。
|
||||
|
||||
**客户端只调我们后端的一个接口**,阿里云的存在对客户端完全透明:
|
||||
|
||||
```
|
||||
App ──① 压缩后的图片──▶ 后端 ──② 落 OSS──▶ 阿里云 OSS
|
||||
│
|
||||
└──③ RecognizeLicensePlate(ImageURL)──▶ 阿里云 OCR
|
||||
App ◀───────④ { plateNumber, confidence } ──────┘
|
||||
```
|
||||
|
||||
好处是换供应商、加缓存、加调用量管控都只动后端。代价是图片多走一跳(门店 → 我们的后端 → OSS)。**如果实测上传耗时不可接受**,再换成"后端签发 STS 临时凭证、客户端直传 OSS、只把 URL 交给后端"的两段式,但首版不必要 —— 别为还没测出来的问题先加一层复杂度。
|
||||
|
||||
后端侧的接法(超时、熔断、错误码段)按 [../backend/05-integration-layer.md](../backend/05-integration-layer.md) 的规矩走,阿里云 OCR 是一个和 F6、Mini 同级的外部依赖。
|
||||
|
||||
#### 置信度要用起来
|
||||
|
||||
响应里的 `Confidence` 不是装饰。约定:
|
||||
|
||||
- **低于阈值不直接填进表单**,而是把识别结果作为"待确认"展示,让用户点一下确认或改。阈值实测后定。
|
||||
- 识别出的字符串还要过一次**车牌格式校验**(省份简称 + 字母 + 5~6 位、新能源 8 位),不合规一律当失败处理。API 返回一个高置信度的非法车牌,比返回失败更危险 —— 它会被直接写进工单。
|
||||
|
||||
#### 为什么不自己训模型
|
||||
|
||||
评估过"自训练 YOLO 定位 + PaddleOCR 识别"的端侧方案,没有采用:
|
||||
|
||||
| | 阿里云 OCR(采用) | 自训练 YOLO + PaddleOCR |
|
||||
|---|---|---|
|
||||
| 准确率 | **供应商负责**,开箱可用 | 要自己调到可用,工程风险集中在这里 |
|
||||
| 投入 | 按次付费 | 算法工程 + 数据标注 + 持续调优的人力 |
|
||||
| 离线可用 | ❌ **必须联网** | ✅ 完全离线 |
|
||||
| 数据合规 | 图片上传第三方(境内),需写进隐私政策 | 图片不出设备 |
|
||||
| 包体积 | 无增量 | 模型文件增量 |
|
||||
| 迭代 | 供应商升级即受益 | 每次优化都要发版 |
|
||||
|
||||
**取舍**:用调用费和联网依赖,换掉一整条算法工程链路和"准确率自负"的风险。对一个门店业务 APP 来说这笔账是划算的 —— 车牌识别不是我们的核心竞争力,没有理由自己养一套模型。离线不可用由手工输码入口兜住,这本来就是必须有的。
|
||||
|
||||
VIN 印刷字符**继续在端上用通用 OCR + 校验位过滤**,不一并上云:VIN 是标准印刷字符,通用 OCR 本来就擅长,第 9 位校验码能把误识别挡在外面 —— 这是车牌没有的优势,白白花钱和牺牲离线能力没有道理。真到实测准确率不够,阿里云同一套 OCR 里也有 VIN 识别接口可以顶上。
|
||||
|
||||
## 权限与合规
|
||||
|
||||
`native_*` 涉及的运行时权限:
|
||||
|
||||
| 能力 | Android 权限 | iOS `Info.plist` key |
|
||||
|---|---|---|
|
||||
| 扫码 / 拍照 | `CAMERA` | `NSCameraUsageDescription` |
|
||||
| 相册选择 | `READ_MEDIA_IMAGES`(API 33+) | `NSPhotoLibraryUsageDescription` |
|
||||
| 保存图片 | `WRITE_EXTERNAL_STORAGE`(API ≤ 28) | `NSPhotoLibraryAddUsageDescription` |
|
||||
| 拨号 | 无需权限(`ACTION_DIAL` 不需要 `CALL_PHONE`) | 无(`tel:` scheme) |
|
||||
|
||||
规则:
|
||||
|
||||
- **权限申请必须在用到的那一刻发起,不在启动时批量申请。** 启动就要相机权限是应用商店审核和用户流失的双重风险。
|
||||
- **被拒绝后要有引导**:拒绝一次 → 说明为什么需要 + 再次申请;选了"不再询问" → 提示并提供跳转系统设置的入口。不能只是 toast 一句"没有权限"然后什么也做不了。
|
||||
- iOS 用途说明文案要写具体("用于扫描商品条码入库"),写"需要相机权限"这种会被审核打回。
|
||||
- 拨号用 `ACTION_DIAL` / `tel:` **拉起拨号盘让用户自己按拨出**,不用 `CALL_PHONE` 直接拨号——后者要额外的危险权限,还容易被审核质疑。
|
||||
|
||||
### iOS 隐私清单 `PrivacyInfo.xcprivacy`(上架强制)
|
||||
|
||||
苹果自 2024 年起强制要求 App 及其使用的三方 SDK 提供隐私清单,**没有会直接被拒**。每个 `native_*` 包如果访问了需要声明的 API,要在 `ios/Resources/PrivacyInfo.xcprivacy` 里声明:
|
||||
|
||||
```xml
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array><string>C617.1</string></array>
|
||||
</dict>
|
||||
</array>
|
||||
```
|
||||
|
||||
同时确认三方依赖(相机/图片压缩/崩溃上报 SDK)是否自带隐私清单——不带的需要我们在主 App 里替它声明,或者换一个带的。这条要在**首次提交 TestFlight 前**验证,别留到上架当天。
|
||||
|
||||
**我们不采集设备唯一标识**(IMEI/IDFA/MAC),所以不需要声明 `NSPrivacyTracking`(见 [05-networking.md](./05-networking.md) 的 `X-Device-Id` 约定)。
|
||||
|
||||
## Pigeon 的工程化
|
||||
|
||||
生成命令不写在 README 里让人手敲,而是把配置写进 schema、动作做成 melos script。
|
||||
|
||||
```dart
|
||||
// native_scan/pigeons/scan_api.dart
|
||||
@ConfigurePigeon(PigeonOptions(
|
||||
dartOut: 'lib/src/generated/scan_api.g.dart',
|
||||
dartOptions: DartOptions(),
|
||||
kotlinOut: 'android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'com.conti.native_scan'),
|
||||
swiftOut: 'ios/Classes/ScanApi.g.swift',
|
||||
swiftOptions: SwiftOptions(),
|
||||
dartPackageName: 'native_scan',
|
||||
))
|
||||
library;
|
||||
|
||||
@HostApi()
|
||||
abstract class ScanHostApi { /* ... */ }
|
||||
```
|
||||
|
||||
配置写进 `@ConfigurePigeon` 之后,生成命令就退化成一行,不会出现"某人生成时路径敲错,生成物落到别的目录":
|
||||
|
||||
```bash
|
||||
fvm dart run pigeon --input pigeons/scan_api.dart
|
||||
```
|
||||
|
||||
melos script(见 [01-project-structure.md](./01-project-structure.md)):
|
||||
|
||||
```yaml
|
||||
pigeon:
|
||||
run: melos exec --scope="native_*" -- dart run pigeon --input pigeons/
|
||||
```
|
||||
|
||||
生成产物的处理:
|
||||
|
||||
- `*.g.dart` / `*.g.kt` / `*.g.swift` **入 git**(同 riverpod/drift 的生成物,理由见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。
|
||||
- Dart 生成物在根 `analysis_options.yaml` 里排除 lint(`analyzer: exclude: - "**/*.g.dart"`)。
|
||||
- CI 要有一步"重新生成后 `git diff --exit-code`",防止有人改了 schema 但忘了提交生成物。
|
||||
|
||||
## OHOS 后续演进
|
||||
|
||||
鸿蒙(OpenHarmony)**不在首版范围**,但基线决策是为它留了口子的,这里记录清楚,避免后面接的时候重新走一遍弯路。
|
||||
|
||||
接 OHOS 需要处理三件事:
|
||||
|
||||
1. **SDK 分支不同**:OHOS 用的是 OpenHarmony 社区维护的 Flutter 分支,版本落后于官方 stable 一段时间。这正是 [01-project-structure.md](./01-project-structure.md) 里 SDK 基线刻意停在 **3.44.9** 而不追 3.47.0 的原因——基线跑太前,OHOS 分支跟不上就接不进来。
|
||||
2. **Pigeon 没有 ArkTS 生成器**:Pigeon 官方只生成 Kotlin/Java、Swift/Objective-C、C++、GObject,**没有 ArkTS/OHOS**。所以 OHOS 侧的 channel 代码只能**手写**,需要人工保证方法名、参数结构与 Pigeon 生成的 Dart 端编解码格式一致——这是一份实打实的额外维护成本,接 OHOS 时要预留出来。
|
||||
3. **`native_*` 包要加 `ohos:` 平台声明**,并新增 `ohos/` 目录。
|
||||
|
||||
在此之前,`native_*` 的公共 API 类里遇到不支持的平台,一律抛明确的 `UnsupportedPlatformException`,不静默返回空值或占位假数据——静默返回会让"这个平台其实没实现"的问题一直藏到用户手里。
|
||||
|
||||
|
||||
## 使用规则
|
||||
|
||||
- `pigeons/xxx_api.dart` 是**唯一手写**的接口定义文件,Dart 端和两端原生的桩代码全部由 `dart run pigeon --input pigeons/xxx_api.dart` 生成,生成产物不手动修改,改需求就改 schema 重新生成。
|
||||
- Dart 调原生用 `@HostApi()`;原生主动推事件给 Dart(比如扫码结果的持续回调)用 `@FlutterApi()`——不允许为了图省事用 `@HostApi()` 硬凑双向通信。
|
||||
- **调用方只允许依赖 `native_*` 包 `lib/native_xxx.dart` 导出的公共 API 类**,不允许直接 import `src/generated/` 里的生成代码。调用方包括 `feature_*` 和 `core_webview`(JSBridge)。
|
||||
- 原生侧异常需要在生成的 host API 实现里捕获并转换成 Pigeon schema 里声明的错误类型,Dart 侧统一映射成 [05-networking.md](./05-networking.md) 里同一套 `AppException` 体系,不让原生异常类型(如 `PlatformException`)直接抛到业务代码里。
|
||||
- 某一端暂未实现的能力,公共 API 类里对应平台分支抛明确的 `UnsupportedPlatformException`,不允许静默返回空值或占位假数据。
|
||||
|
||||
## 待确认项
|
||||
|
||||
- **车牌识别的置信度阈值**:低于多少不直接回填、改成"待确认"让用户核对,需实测后定。
|
||||
- **图片压缩参数**(长边、JPEG 质量):要同时满足阿里云 ≤ 4 MB 的限制、弱网可传、以及识别准确率不明显下降,实测后固化。
|
||||
- **上传路径首版走"经我们后端中转"还是"STS 直传 OSS"**:默认中转(简单、密钥不出服务端),实测上传耗时不可接受再改,见上文。
|
||||
- **调用量管控与计费口径**:单次接车允许几次识别、失败是否计费、月度用量上限与告警,和后端一起定。
|
||||
- VIN 印刷字符 OCR 的实际准确率,需要拿真实车辆铭牌照片做一轮验证;不达标则改用阿里云的 VIN 识别接口。
|
||||
- 三方 SDK 的 iOS 隐私清单覆盖情况,首次提交 TestFlight 前核完。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Pigeon 官方文档](https://pub.dev/packages/pigeon)
|
||||
- [Flutter 平台通道官方文档](https://docs.flutter.dev/platform-integration/platform-channels)
|
||||
- [编写 Flutter plugin package](https://docs.flutter.dev/packages-and-plugins/developing-packages#plugin-platforms)
|
||||
- [Apple: 隐私清单文件](https://developer.apple.com/documentation/bundleresources/privacy-manifest-files)
|
||||
- [Android 运行时权限最佳实践](https://developer.android.com/training/permissions/requesting)
|
||||
|
||||
## 附录:Pigeon 是什么,日常怎么用
|
||||
|
||||
给还没接触过跨语言原生集成的同学看的入门说明。
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
Flutter 原生的 [`MethodChannel`](https://docs.flutter.dev/platform-integration/platform-channels) 机制本质是"字符串方法名 + 弱类型参数"的消息传递:
|
||||
|
||||
```dart
|
||||
// 手写 MethodChannel,容易出的问题:
|
||||
final result = await MethodChannel('scan_channel').invokeMethod('startScan', {'timeout': 5000});
|
||||
// 1. 'startScan' 是字符串,原生那边方法名打错了,运行时才报 "not implemented"
|
||||
// 2. 参数是 Map,字段名/类型对不上,运行时才崩,编译期完全看不出来
|
||||
// 3. 返回值类型是 dynamic,还要自己强转、自己判断 null
|
||||
```
|
||||
|
||||
三个问题的共性是:**Dart 和原生代码之间没有共享的类型系统**,接口的一致性完全靠开发者手动保证、runtime 才能发现错误。
|
||||
|
||||
**Pigeon** 用一个 Dart 文件定义"接口 schema"(有哪些方法、参数和返回值类型),然后生成 Dart 端 + Android(Kotlin) + iOS(Swift) 三端的强类型桩代码——方法名、参数、返回类型三端保持一致,改了 schema 忘记同步实现,编译期就会报错(生成的原生接口是抽象类/协议,没实现完整会编译不过),彻底消灭"方法名打错""参数字段对不上"这类只有运行时才发现的问题。
|
||||
|
||||
### 核心概念
|
||||
|
||||
1. **Schema 文件**(`pigeons/xxx_api.dart`):用普通 Dart 类和注解描述接口,不是真的可执行代码,只是给 pigeon 生成器读的"接口契约"。
|
||||
2. **`@HostApi()`**:声明一个"Dart 调用原生"的接口,pigeon 生成 Dart 端可直接调用的类,以及原生端需要实现的抽象类/协议。
|
||||
3. **`@FlutterApi()`**:声明一个"原生调用 Dart"的接口(方向相反),用于原生侧主动推送事件(比如蓝牙扫描持续上报发现的设备)。
|
||||
4. **生成命令**:`dart run pigeon --input pigeons/xxx_api.dart` 会同时生成 Dart、Kotlin、Swift 三份代码,开发者只需要去实现原生那两个抽象类/协议里的方法体。
|
||||
|
||||
### 使用示例(`native_scan`:扫码能力)
|
||||
|
||||
```dart
|
||||
// native_scan/pigeons/scan_api.dart —— 唯一手写的 schema 文件
|
||||
@ConfigurePigeon(PigeonOptions(
|
||||
dartOut: 'lib/src/generated/scan_api.g.dart',
|
||||
kotlinOut: 'android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'com.conti.native_scan'),
|
||||
swiftOut: 'ios/Classes/ScanApi.g.swift',
|
||||
dartPackageName: 'native_scan',
|
||||
))
|
||||
library;
|
||||
|
||||
@HostApi()
|
||||
abstract class ScanHostApi {
|
||||
@async
|
||||
ScanResult startScan(ScanOptions options);
|
||||
void stopScan();
|
||||
}
|
||||
|
||||
/// 端上能解的两类。**车牌不在这里** —— 它是"拍照 + 调后端接口",
|
||||
/// 不是取景框里的实时识别,见上文「VIN 码与车牌识别」
|
||||
enum ScanMode { barcode, vin }
|
||||
|
||||
class ScanOptions {
|
||||
ScanOptions({required this.mode, required this.timeoutMs});
|
||||
final ScanMode mode;
|
||||
final int timeoutMs;
|
||||
}
|
||||
|
||||
class ScanResult {
|
||||
ScanResult({required this.value, required this.format});
|
||||
final String value;
|
||||
final String format; // QR_CODE / CODE_39 / OCR_TEXT ...
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 配置已写进 @ConfigurePigeon,命令里不用再重复一遍输出路径
|
||||
fvm dart run pigeon --input pigeons/scan_api.dart
|
||||
```
|
||||
|
||||
Android 端实现生成的抽象类(`ScanApiImpl.kt`,非生成代码,是需要手写的实现):
|
||||
|
||||
```kotlin
|
||||
class ScanApiImpl : ScanHostApi {
|
||||
var activity: Activity? = null // 由 NativeScanPlugin 的 ActivityAware 回调注入
|
||||
|
||||
override fun startScan(options: ScanOptions, callback: (Result<ScanResult>) -> Unit) {
|
||||
val act = activity ?: return callback(Result.failure(
|
||||
FlutterError("NO_ACTIVITY", "扫码需要前台 Activity", null)))
|
||||
// 调用具体的扫码 SDK,拿到结果后:
|
||||
callback(Result.success(ScanResult(value = "123456", format = "QR_CODE")))
|
||||
}
|
||||
|
||||
override fun stopScan() {
|
||||
// 停止扫码 SDK
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dart 端对外的公共 API(`native_scan.dart`,调用方唯一能用的入口):
|
||||
|
||||
```dart
|
||||
class NativeScan {
|
||||
final ScanHostApi _api = ScanHostApi();
|
||||
|
||||
Future<ScanResult> startScan({
|
||||
ScanMode mode = ScanMode.barcode,
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
try {
|
||||
return await _api.startScan(
|
||||
ScanOptions(mode: mode, timeoutMs: timeout.inMilliseconds),
|
||||
);
|
||||
} on PlatformException catch (e, st) {
|
||||
// 原生异常不外泄,统一转成 05 里的 AppException 体系
|
||||
Error.throwWithStackTrace(
|
||||
NativeCapabilityException('扫码失败: ${e.message}', code: e.code), st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopScan() => _api.stopScan();
|
||||
}
|
||||
```
|
||||
|
||||
`feature_scan` 和 `core_webview` 的 JSBridge 都只 import `NativeScan` 这一个类,完全不知道底层是 Pigeon 生成的还是手写 `MethodChannel`——这也是把原生能力做成独立 `native_*` package(而不是散落在各 feature 里直接写平台通道代码)的意义:原生实现细节被这一层完全封装,将来换扫码 SDK 或补 OHOS 实现,调用方一行都不用动。
|
||||
@@ -0,0 +1,303 @@
|
||||
# 08. 多环境构建
|
||||
|
||||
## 决策
|
||||
|
||||
App 侧维护 **3 个 flavor:`dev` / `uat` / `prod`**,环境划分与现有后端 CI/CD(见 `gitlab-cicd-azure-deployment-diagram.drawio` 里的 Dev/UAT/Prod Azure 环境)保持一致命名,三个 flavor 各自对应不同的 API 地址、应用图标/名称、包名后缀,可在同一台设备上同时安装、互不覆盖。CI 沿用现有 GitLab CI,但产物是 App 二进制(apk/ipa),分发渠道与后端的 ACR/Azure App Hosting 不同。
|
||||
|
||||
> ⚠️ **Android 复用与后端共用的 Linux Runner,iOS 走单独的 Mac 机器。** `flutter build ipa` 必须跑在 macOS 上,本项目通过一台**远程 Mac** 出 iOS 包,详见下文「iOS 构建:远程 Mac」。
|
||||
|
||||
## Flavor 划分规则
|
||||
|
||||
| Flavor | Application ID / Bundle ID | API 目标 | 分发渠道 |
|
||||
|---|---|---|---|
|
||||
| `dev` | `com.conti.retail.dev` | Dev 环境(对应后端 Dev) | 内部测试分发(托管平台,见下文) |
|
||||
| `uat` | `com.conti.retail.uat` | UAT 环境(对应后端 UAT) | 内部测试分发(托管平台 / TestFlight,见下文) |
|
||||
| `prod` | `com.conti.retail` | Prod 环境(对应后端 Prod) | App Store Connect / 各安卓应用市场 |
|
||||
|
||||
## 使用规则
|
||||
|
||||
- 每个 flavor 对应一个独立的 Dart 入口文件(`main_dev.dart`/`main_uat.dart`/`main_prod.dart`),三者都只是设置好环境标识后调用同一个共享的 `bootstrap()` 启动函数,不允许在入口文件里写业务逻辑分支。
|
||||
- 环境相关的可变配置(API base URL、是否开启日志等,见 [05-networking.md](./05-networking.md) 的 `appEnvProvider`)通过 `--dart-define-from-file=env/{flavor}.json` 注入,不写死在代码里、也不用 `if (flavor == 'dev')` 这种运行时字符串判断来分支配置。
|
||||
- `env/*.json` 只包含非敏感配置(API 地址等);密钥类配置(如第三方 SDK App Key)通过 CI 变量在构建时注入,不提交进仓库。
|
||||
- Android 侧用 Gradle `productFlavors` 区分 `applicationIdSuffix`/图标/`versionNameSuffix`;iOS 侧用对应的 xcconfig + Scheme 区分 `Bundle Identifier`/图标;两端 flavor 名称必须完全一致(`dev`/`uat`/`prod`),不允许两端用不同命名。
|
||||
- CI 流水线阶段固定为:`melos run analyze` → `melos run test` → 按 flavor `flutter build apk/ipa --flavor {flavor} --dart-define-from-file=env/{flavor}.json` → 上传对应分发渠道。`prod` flavor 的构建触发条件是打 tag,不是每次 push 都触发(避免误发生产包)。
|
||||
|
||||
## 用 `applicationIdSuffix` 而不是覆盖 `applicationId`
|
||||
|
||||
```gradle
|
||||
productFlavors {
|
||||
dev { dimension "env"; applicationIdSuffix ".dev"; versionNameSuffix "-dev" }
|
||||
uat { dimension "env"; applicationIdSuffix ".uat"; versionNameSuffix "-uat" }
|
||||
prod { dimension "env" } // 用 defaultConfig 的 applicationId,不加后缀
|
||||
}
|
||||
```
|
||||
|
||||
理由:直接覆盖 `applicationId` 会让 `applicationId` 和 **Kotlin 源码的 package 名脱钩**。Android 的 `R` 类、`BuildConfig` 类是按 `namespace`(源码 package)生成的,而 `applicationId` 只影响安装标识——两者写成不同的值本身合法,但很多三方 SDK(推送、地图、统计)的初始化会隐式假设它们一致,配错的表现是"dev 包能跑,uat 包某个 SDK 静默失效"。用 `applicationIdSuffix` 只在末尾加后缀,`namespace` 保持不变,从结构上避免这类问题。
|
||||
|
||||
对应地 iOS 侧 xcconfig 里也用 `PRODUCT_BUNDLE_IDENTIFIER = com.conti.retail$(BUNDLE_ID_SUFFIX)`,`BUNDLE_ID_SUFFIX` 按 Build Configuration 取 `.dev` / `.uat` / 空。
|
||||
|
||||
## Release 构建:剥离符号,但不混淆
|
||||
|
||||
```bash
|
||||
fvm flutter build appbundle \
|
||||
--flavor prod --target lib/main_prod.dart \
|
||||
--dart-define-from-file=env/prod.json \
|
||||
--split-debug-info=build/symbols/$CI_COMMIT_TAG
|
||||
```
|
||||
|
||||
- `--split-debug-info` 把调试符号剥离到单独目录(同时显著减小包体)。
|
||||
- **`--obfuscate` 是刻意不加的。** 崩溃上报走 Bugly + 神策,两者都没有还原 Dart 混淆堆栈的能力(见 [13-observability-analytics.md](./13-observability-analytics.md))。加上混淆的结果是**线上占比最大的那一半崩溃在后台是一串 `_x12`**,每条都要人工 `flutter symbolize`。不混淆时上报回来的堆栈类名方法名直接可读(`OrderRepository.submit`),代价是 Dart 符号留在产物里、逆向门槛降一档——**这个取舍要和安全侧确认,见待确认项**。
|
||||
- 若安全侧要求改回混淆,**两个参数必须一起用**:只写 `--obfuscate` 不写 `--split-debug-info` 会被工具链拒绝;同时 13 篇里的排查流程要改成"人工 symbolize"。
|
||||
- **符号表必须归档**:按 `版本号+构建号` 存成 CI artifact 保留至少 1 年。不混淆之后它不再是日常排查的必需品,但仍是拿到精确行号的唯一手段——`--split-debug-info` 把行号剥离出去了,堆栈里只剩类名和方法名。**丢了符号表 = 那个版本再也拿不到行号**,不可逆。
|
||||
- 符号表目录按版本号区分(用 tag 或 `versionName+versionCode`),不能所有版本堆一个目录。
|
||||
- **Android mapping(R8)和 iOS dSYM 照旧归档,并在 build 之后上传 Bugly**(Bugly 提供符号表上传的命令行工具)。原生侧的自动符号化是 Bugly 的强项,这一条不受上面那个 Dart 决策影响。
|
||||
|
||||
## 版本号规则
|
||||
|
||||
| 字段 | 来源 | 示例 |
|
||||
|---|---|---|
|
||||
| `versionName` | git tag(去掉 `v` 前缀) | tag `v1.4.0` → `1.4.0` |
|
||||
| `versionCode` / `CFBundleVersion` | CI pipeline ID(单调递增) | `$CI_PIPELINE_ID` → `48213` |
|
||||
|
||||
要点:
|
||||
|
||||
- `versionCode` **必须单调递增且永不重复**——Google Play 和 App Store Connect 都会拒绝重复或回退的版本号,而这个错误只在上传那一刻才暴露,很容易卡在发版当天。用 `CI_PIPELINE_ID` 天然满足递增,比手工维护数字可靠。
|
||||
- `pubspec.yaml` 里的 `version:` 在 CI 构建时被 `--build-name` / `--build-number` 覆盖,仓库里的值只作为本地开发的占位,不作为发版依据。
|
||||
- dev/uat 包的 `versionName` 带 `-dev`/`-uat` 后缀,测试反馈时一眼能看出装的是哪个环境的包。
|
||||
|
||||
## Android 签名与 keystore 注入
|
||||
|
||||
keystore **不入 git**(包括 dev 的)。CI 里通过变量注入:
|
||||
|
||||
```yaml
|
||||
# GitLab CI 变量(类型选 File,masked)
|
||||
# ANDROID_KEYSTORE_BASE64 - keystore 文件的 base64
|
||||
# ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD
|
||||
before_script:
|
||||
- echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/release.keystore
|
||||
- |
|
||||
cat > android/key.properties <<EOF
|
||||
storeFile=release.keystore
|
||||
storePassword=$ANDROID_KEYSTORE_PASSWORD
|
||||
keyAlias=$ANDROID_KEY_ALIAS
|
||||
keyPassword=$ANDROID_KEY_PASSWORD
|
||||
EOF
|
||||
after_script:
|
||||
- rm -f android/app/release.keystore android/key.properties
|
||||
```
|
||||
|
||||
- **`prod` 的 keystore 一旦丢失,就再也无法给已上架的 App 发更新**(Google Play 的 Play App Signing 有救回机制,但前提是当初开启了;App Store 走的是苹果的证书体系,另说)。除了 CI 变量,必须在公司密钥管理系统里另存一份,并且有至少两个人能拿到。
|
||||
- dev/uat 可以共用一个非正式 keystore,prod 单独一个。
|
||||
- `android/key.properties` 加进 `.gitignore`。
|
||||
|
||||
## iOS 构建:远程 Mac
|
||||
|
||||
`flutter build ipa` 依赖 Xcode,**必须在 macOS 上跑**,而现有 GitLab Runner 与后端共用、是 Linux runner。**本项目的方案是用一台远程 Mac 出 iOS 包**,不为此改造现有 Linux runner,也不引入云端 mac 构建服务(省掉把签名证书上传第三方带来的安全评审)。
|
||||
|
||||
两个阶段:
|
||||
|
||||
| 阶段 | 做法 |
|
||||
|---|---|
|
||||
| **当前** | 远程连上 Mac 手工执行构建脚本出 ipa。脚本进仓库(`scripts/build_ios.sh`),保证每次构建参数一致,不靠人记命令 |
|
||||
| **后续** | 同一台 Mac 注册成 GitLab Runner(打 `macos` tag),iOS job 落到它上面,与 Android job 并行 |
|
||||
|
||||
**当前阶段的两条纪律**,它们是"手工出包"唯一的真实风险来源:
|
||||
|
||||
- **构建命令必须来自仓库里的脚本**,flavor、`--dart-define-from-file`、`--split-debug-info` 路径都在脚本里写死。手敲命令漏一个参数,出来的包看起来正常,实际连的是错的环境或者没有归档符号表。
|
||||
- **符号表和 dSYM 要从 Mac 上带回来归档**(见上文 Release 构建)。这是手工出包最容易漏的一步——Linux 上有 CI artifact 自动兜着,Mac 上没有。
|
||||
|
||||
证书和描述文件(Provisioning Profile)无论哪个阶段都用 [fastlane match](https://docs.fastlane.tools/actions/match/) 管理,存在一个私有 git 仓库里,**不靠人肉在钥匙串之间导来导去**。这一条在只有一台 Mac 的情况下更重要:机器坏了、人换了,签名能力不能跟着丢。
|
||||
|
||||
注册成 runner 之后,iOS job 打 tag 落到 mac runner:
|
||||
|
||||
```yaml
|
||||
build_ios_uat:
|
||||
stage: build
|
||||
tags: [macos] # 只有 mac runner 有这个 tag
|
||||
script:
|
||||
- fvm flutter build ipa --flavor uat --target lib/main_uat.dart \
|
||||
--dart-define-from-file=env/uat.json --export-options-plist=ios/ExportOptions-uat.plist
|
||||
```
|
||||
|
||||
## 内测分发:托管平台 + 后台发布管理
|
||||
|
||||
**结论:用托管平台做内测分发,不引入 Firebase App Distribution。** 使用者是中国境内门店的一线员工,Firebase 的下载域名在国内可达性和速度都不稳定,"链接点开一直转圈装不上"会直接拖垮 UAT 验收效率。
|
||||
|
||||
| 平台 | Android | iOS |
|
||||
|---|---|---|
|
||||
| **自建 / 公司托管的 OTA 分发页** | apk 直链下载 | `itms-services://` + plist(需企业签名或把设备 UDID 加进 ad-hoc 描述文件) |
|
||||
| **TestFlight** | — | 上架前必经的验证路径,国内可达性没问题,**推荐 iOS 走这条** |
|
||||
|
||||
**下一步很可能是把分发收进后台管理端**:后台已经规划了「APP 配置」类功能(见 PRD 的后台模块),再加一个「APP 发布管理」是顺理成章的——版本列表、上传包、灰度范围、**强制升级开关**。做了它就同时解决三件事:内测分发、版本更新检查接口、强制升级,而不是各做各的。**这一条尚未定案**,见待确认项。
|
||||
|
||||
无论最终托管在哪,两条不变:
|
||||
|
||||
- **包要按 flavor 和版本号归档**,不能只留"最新一个"。回归验证经常要装回上一版。
|
||||
- **分发入口要有访问控制**。apk 直链裸放在公网上,等于把内测包(含 uat 环境地址)交给任何人。
|
||||
|
||||
## Firebase 配置文件按 flavor 放置
|
||||
|
||||
本项目**不引入 Firebase**(崩溃上报走 Bugly + 神策,见 [13-observability-analytics.md](./13-observability-analytics.md);内测分发见上文)。以下写法仅在将来确实要引入某个 Firebase 服务时适用,留作参考——**配置文件必须按 flavor 分开放**,否则三个环境的数据会混进同一个项目:
|
||||
|
||||
```
|
||||
android/app/src/dev/google-services.json
|
||||
android/app/src/uat/google-services.json
|
||||
android/app/src/prod/google-services.json
|
||||
|
||||
ios/Runner/Firebase/dev/GoogleService-Info.plist # 通过 Xcode Run Script 按 Configuration 复制
|
||||
ios/Runner/Firebase/uat/GoogleService-Info.plist
|
||||
ios/Runner/Firebase/prod/GoogleService-Info.plist
|
||||
```
|
||||
|
||||
Android 的 flavor 源集目录(`src/{flavor}/`)会自动生效;iOS 没有等价机制,需要在 Build Phases 加一个 Run Script,按 `$CONFIGURATION` 把对应文件复制到 `Runner/GoogleService-Info.plist`。
|
||||
|
||||
## 待确认项
|
||||
|
||||
- **release 到底混不混淆**——本文的决策是**不混淆**(理由见上文 Release 构建一节),需要安全侧确认能否接受 Dart 符号暴露在产物里。改回混淆的话,[13-observability-analytics.md](./13-observability-analytics.md) 的崩溃排查流程要一并改成"人工 symbolize"。
|
||||
- **内测分发的托管位置与访问控制**——自建 OTA 页放在哪、谁维护、怎么鉴权,需要和运维确认。
|
||||
- **「APP 发布管理」是否进后台管理端**——做了它就一并解决版本更新检查与强制升级(对应 PRD 的 `REQ-NFR-036` / `REQ-NFR-037`),需要产品和后端一起裁决。
|
||||
- 远程 Mac 何时注册成 GitLab Runner(当前是手工出包,长期不宜停在这一步——"能出 iOS 包的只有某一台机器 + 某一个人"是典型的单点依赖)。
|
||||
- Android 上架渠道清单(华为/小米/OPPO/vivo 各家应用市场是否都要上,各家的加固/隐私合规要求不同)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Flutter 官方 Flavors 文档](https://docs.flutter.dev/deployment/flavors)
|
||||
- [--dart-define-from-file 官方说明](https://docs.flutter.dev/deployment/flavors#configuration-approaches)
|
||||
- [Flutter: 混淆 Dart 代码](https://docs.flutter.dev/deployment/obfuscate)
|
||||
- [Android: 从命令行构建并签名](https://developer.android.com/build/building-cmdline)
|
||||
- [fastlane match(证书管理)](https://docs.fastlane.tools/actions/match/)
|
||||
|
||||
## 附录:Flavor 是什么,日常怎么用
|
||||
|
||||
给还没接触过多环境构建方式的同学看的入门说明。
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
一个 App 通常需要同时存在"开发中还没上线的版本"和"已经上线的正式版本",测试期间还需要一个给验收测试用的版本——这三个版本理想情况下要能**同时装在同一台测试手机上**,方便对比测试,而不是每次切换环境都要卸载重装。同时它们各自要打到不同的后端地址(Dev/UAT/Prod API),不能一个 apk 通过运行时开关切换后端就完事——因为如果只用运行时环境变量,三个环境包的 `applicationId`/`Bundle ID` 完全一样,装第二个会直接覆盖第一个。
|
||||
|
||||
**Flavor** 是 Android(Gradle `productFlavors`,历史悠久的原生概念)和 iOS(Xcode Build Configuration/Scheme)本来就有的机制:在同一份代码基础上,用不同的编译配置产出`applicationId`/图标/名称都不同的多个安装包。Flutter 从工具链层面(`flutter build --flavor xxx`)把两端的 flavor 机制包装成统一的命令行接口。
|
||||
|
||||
### 核心概念
|
||||
|
||||
1. **Android `productFlavors`**:在 `android/app/build.gradle` 里声明多套 `applicationId`/`versionNameSuffix`/资源目录,编译时用 `--flavor` 选择其中一套。
|
||||
2. **iOS Scheme + xcconfig**:iOS 没有 Gradle 那样的单文件配置,而是通过 Xcode 里多个 Build Configuration(对应不同的 `.xcconfig` 文件设置 Bundle ID 等)+ 多个 Scheme 组合实现同样的效果,`flutter build ipa --flavor xxx` 背后就是选中同名 Scheme。
|
||||
3. **Dart 入口文件(`main_xxx.dart`)**:flavor 决定的是"编译出什么样的原生外壳",Dart 代码本身默认只有一个 `main.dart` 入口——项目约定用多个入口文件对应各 flavor,让每个 flavor 能设置不同的启动参数(比如传给 `bootstrap()` 一个环境枚举)。
|
||||
4. **`--dart-define-from-file`**:flavor 解决的是原生层面的差异(图标、包名),但 API 地址这类 Dart 侧读取的配置,用编译期注入的 JSON 文件解决,避免打包进一个写死 `http://dev-api...` 的字符串常量。
|
||||
|
||||
### 使用示例
|
||||
|
||||
Android 侧 flavor 声明(`android/app/build.gradle`):
|
||||
|
||||
```gradle
|
||||
android {
|
||||
namespace "com.conti.retail" // 源码 package,三个 flavor 都一样
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.conti.retail"
|
||||
}
|
||||
|
||||
flavorDimensions "env"
|
||||
productFlavors {
|
||||
dev {
|
||||
dimension "env"
|
||||
applicationIdSuffix ".dev" // → com.conti.retail.dev
|
||||
versionNameSuffix "-dev"
|
||||
resValue "string", "app_name", "Conti Retail(Dev)"
|
||||
}
|
||||
uat {
|
||||
dimension "env"
|
||||
applicationIdSuffix ".uat"
|
||||
versionNameSuffix "-uat"
|
||||
resValue "string", "app_name", "Conti Retail(UAT)"
|
||||
}
|
||||
prod {
|
||||
dimension "env"
|
||||
resValue "string", "app_name", "Conti Retail"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
环境配置文件(`env/dev.json`,非敏感部分):
|
||||
|
||||
```json
|
||||
{
|
||||
"API_BASE_URL": "https://dev-api.conti-retail.com",
|
||||
"ENABLE_LOG": true
|
||||
}
|
||||
```
|
||||
|
||||
共享启动入口 + 各 flavor 的 Dart 入口文件:
|
||||
|
||||
```dart
|
||||
// lib/bootstrap.dart —— 三个 flavor 共用的启动逻辑
|
||||
Future<void> bootstrap(AppEnv env) async {
|
||||
runApp(ProviderScope(
|
||||
overrides: [appEnvProvider.overrideWithValue(env)],
|
||||
child: const App(),
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// lib/main_dev.dart
|
||||
void main() => bootstrap(AppEnv.fromDartDefine(name: 'dev'));
|
||||
```
|
||||
|
||||
构建命令:
|
||||
|
||||
```bash
|
||||
fvm flutter build apk \
|
||||
--flavor dev \
|
||||
--target lib/main_dev.dart \
|
||||
--dart-define-from-file=env/dev.json
|
||||
```
|
||||
|
||||
GitLab CI 片段(衔接现有 Runner;注意 Android 和 iOS 必须落到不同的 runner 上):
|
||||
|
||||
```yaml
|
||||
.flutter_base: &flutter_base
|
||||
image: ghcr.io/cirruslabs/flutter:3.44.9 # 与 .fvmrc 保持一致
|
||||
before_script:
|
||||
- dart pub global activate melos
|
||||
- melos bootstrap
|
||||
|
||||
build_android_dev:
|
||||
<<: *flutter_base
|
||||
stage: build
|
||||
script:
|
||||
- melos run analyze
|
||||
- melos run test
|
||||
- flutter build apk --flavor dev --target lib/main_dev.dart --dart-define-from-file=env/dev.json
|
||||
artifacts:
|
||||
paths: [build/app/outputs/flutter-apk/app-dev-release.apk]
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "develop"'
|
||||
|
||||
build_android_prod:
|
||||
<<: *flutter_base
|
||||
stage: build
|
||||
script:
|
||||
- flutter build appbundle --flavor prod --target lib/main_prod.dart
|
||||
--dart-define-from-file=env/prod.json
|
||||
--build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID
|
||||
--split-debug-info=build/symbols/$CI_COMMIT_TAG
|
||||
artifacts:
|
||||
paths:
|
||||
- build/app/outputs/bundle/prodRelease/
|
||||
- build/symbols/ # 符号表必须归档,丢了就拿不到崩溃堆栈的行号
|
||||
expire_in: 1 year
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG' # 只在打 tag 时触发,避免误发生产包
|
||||
|
||||
build_ios_prod:
|
||||
stage: build
|
||||
tags: [macos] # 必须是 mac runner,Linux runner 跑不了,见上文「iOS 构建:远程 Mac」
|
||||
script:
|
||||
- fvm flutter build ipa --flavor prod --target lib/main_prod.dart
|
||||
--dart-define-from-file=env/prod.json
|
||||
--build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID
|
||||
--split-debug-info=build/symbols/$CI_COMMIT_TAG
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
```
|
||||
@@ -0,0 +1,254 @@
|
||||
# 09. 测试策略
|
||||
|
||||
## 决策
|
||||
|
||||
采用三层测试金字塔,覆盖顺序从多到少:**单元测试**(domain 业务规则 + Riverpod `Notifier`)> **Widget 测试**(关键页面的 loading/data/error 状态)> **集成测试**(仅覆盖 1-2 条黄金路径,如登录→下单→支付)。Mock 框架统一用 **[mocktail](https://pub.dev/packages/mocktail)**(`^1.0.5`),不用 `mockito`——避免再引入一套 `build_runner` codegen 目标(项目里 `riverpod_generator`/`drift_dev`/`pigeon` 已经用了 codegen,`mocktail` 不需要生成代码,减少构建链路复杂度)。
|
||||
|
||||
## 依赖
|
||||
|
||||
```yaml
|
||||
dev_dependencies:
|
||||
mocktail: ^1.0.5
|
||||
test: any # 纯 Dart 单元测试
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
```
|
||||
|
||||
## 分层测试规则
|
||||
|
||||
- **domain 层(有 domain 的 feature)**:use case 用纯 Dart 单元测试,mock 掉 `repository` 接口,覆盖多步骤业务规则的分支(如 [02-layering.md](./02-layering.md) 里 `ConfirmPaymentUseCase` 的状态校验、金额校验)。
|
||||
- **data 层**:repository 实现用单元测试,mock 掉 `ApiClient`(见 [05-networking.md](./05-networking.md)),验证请求参数拼装和响应解析是否正确,不发真实网络请求。拦截器本身(`ApiResultInterceptor`/`AuthInterceptor`/`ErrorMappingInterceptor`)单独测,用 `DioAdapter` 造假响应——**401 刷新的串行逻辑必须有测试**,它是最容易写错、出错代价最高的一段(见 05 里关于并发刷新会导致全设备登出的说明)。
|
||||
- **presentation 层(Notifier)**:用 `ProviderContainer.test()` + `overrides` 直接测试 `Notifier`/`AsyncNotifier` 的状态流转(见 [03-state-management.md](./03-state-management.md) 的测试示例),不需要启动完整 widget 树。
|
||||
- **Widget 测试**:只覆盖有实际业务分支的页面(比如列表的 loading/data/error 三态渲染是否正确),纯展示型 widget(无状态分支)不强制要求。
|
||||
- **集成测试**:只覆盖黄金路径(1-2 条最核心的用户旅程),跑在真实/模拟设备上,验证跨 feature 的路由跳转和端到端流程;不追求覆盖所有页面组合,避免集成测试维护成本超过收益。
|
||||
- 每个 `feature_*` 包的 `test/` 目录结构镜像 `lib/src/`(如 `test/domain/`、`test/data/`、`test/presentation/`),单元测试和 Widget 测试都通过 `melos run test`(见 [01-project-structure.md](./01-project-structure.md))统一跑;集成测试单独一个 CI job,不并入这条批量命令(跑得慢、需要设备/模拟器,不适合每次 `analyze`/`test` 都触发)。
|
||||
|
||||
## mocktail 的 `registerFallbackValue`
|
||||
|
||||
**用 `any()` 匹配自定义类型的参数时,必须先 `registerFallbackValue`**,否则运行时直接报错。这是 mocktail 最常见的踩坑点,而且报错信息不看文档很难对上号。
|
||||
|
||||
```dart
|
||||
class FakePageQuery extends Fake implements PageQuery {}
|
||||
class FakeCancelToken extends Fake implements CancelToken {}
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
// 每个会出现在 any() 位置的非基础类型都要注册一次,注册一次即可全局生效
|
||||
registerFallbackValue(FakePageQuery());
|
||||
registerFallbackValue(FakeCancelToken());
|
||||
});
|
||||
|
||||
test('...', () {
|
||||
when(() => repo.fetchOrders(any(), cancelToken: any(named: 'cancelToken')))
|
||||
.thenAnswer((_) async => const PageResult(items: [], total: 0, page: 1));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`int`/`String`/`bool`/`double` 这些基础类型不需要注册。约定:`registerFallbackValue` 统一写在包的 `test/helpers/fallbacks.dart` 里,各测试文件的 `setUpAll` 调用同一个 `registerAllFallbacks()`,避免每个文件各注册一遍、漏一个就挂。
|
||||
|
||||
## 测试里必须关掉 Riverpod 的自动重试
|
||||
|
||||
Riverpod 3 的 provider 失败后会自动重试(见 [03-state-management.md](./03-state-management.md))。虽然我们在 `ProviderScope` 上全局关掉了,但**测试不走 `main.dart`,`ProviderContainer` 默认仍带着重试策略**。后果是断言 `AsyncError` 的测试会 flaky,或者测试跑完报 "A Timer is still pending"。
|
||||
|
||||
统一在测试辅助里建 container:
|
||||
|
||||
```dart
|
||||
// test/helpers/container.dart
|
||||
ProviderContainer makeContainer({List<Override> overrides = const []}) =>
|
||||
ProviderContainer.test(
|
||||
retry: (_, __) => null, // 与线上 ProviderScope 的配置保持一致
|
||||
overrides: overrides,
|
||||
);
|
||||
```
|
||||
|
||||
所有测试用 `makeContainer()`,不直接 `ProviderContainer.test(...)`——这样将来全局策略变了只改一处。
|
||||
|
||||
## 覆盖率门禁
|
||||
|
||||
```yaml
|
||||
# 根 pubspec.yaml 的 melos: scripts:
|
||||
test:
|
||||
run: melos exec --dir-exists=test --fail-fast -- flutter test --coverage
|
||||
coverage:
|
||||
run: |
|
||||
dart pub global run coverde value -i coverage/lcov.info --min-coverage 60
|
||||
```
|
||||
|
||||
阈值定 **60%**,只卡**整体**、不卡单文件。理由:
|
||||
|
||||
- 卡单文件会逼着大家给 `*.g.dart`、纯展示 widget、`toString()` 这类东西补无意义的测试,产出的是"覆盖率数字"而不是"信心"。
|
||||
- 60% 不是终点,是**不允许倒退的地板**。真正该高覆盖的是 domain use case 和 repository,这两块应该接近 90%,靠 review 保证而不是靠数字。
|
||||
- 生成产物(`**/*.g.dart`)、生成的 pigeon 代码要从 lcov 里排除,否则数字会被生成代码稀释得没有参考价值。
|
||||
|
||||
覆盖率报告作为 CI artifact 上传,PR 上能看到(见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。
|
||||
|
||||
## 集成测试在 CI 的运行环境
|
||||
|
||||
| 平台 | Runner | 说明 |
|
||||
|---|---|---|
|
||||
| Android | 现有 **Linux** runner + Android Emulator | 可行。用 `avdmanager` 起一个无头模拟器(`-no-window -gpu swiftshader`),或用 Docker 镜像。启动慢(1–3 分钟),所以只跑黄金路径 |
|
||||
| iOS | 需要 **mac runner** | 与 [08-build-flavors.md](./08-build-flavors.md) 的 iOS 构建链路是同一个阻塞项。mac runner 落地前,iOS 集成测试**手工在本机跑**,并在发版 checklist 里列为必做项 |
|
||||
|
||||
集成测试的触发时机:**不进每次 push 的流水线**,只在合入 `develop`/`main` 和打 tag 时跑。每次 push 都跑模拟器,流水线时间会从 3 分钟涨到 10 分钟以上,实际效果是大家开始绕过 CI。
|
||||
|
||||
集成测试连的是 **UAT 后端**,需要一组固定的测试账号和测试门店,数据由后端侧准备并保证可重复(这一项要和后端对齐)。
|
||||
|
||||
## JSBridge 的测试策略
|
||||
|
||||
`core_webview` 的 JSBridge(见 [10-webview-h5.md](./10-webview-h5.md))分两块测,**不要试图在 CI 里跑真实 H5 页面**:
|
||||
|
||||
1. **协议编解码 → 纯 Dart 单元测试**。`{id, method, params}` 的解析、未知 `method` 的处理、参数缺失/类型错误的报错、回包格式、来源域名校验——这些都是纯函数,不需要 WebView,覆盖率应该接近 100%。这是 JSBridge 里最容易出错也最好测的部分。
|
||||
2. **原生能力调用 → mock 掉 `native_*` 的公共 API 类**。验证"H5 发来 `scan` 请求 → 调了 `NativeScan.startScan` → 回包格式正确",不真的起相机。
|
||||
3. **端到端联调 → 走契约用例,不进 `melos run test`**。维护一个 H5 侧和 App 侧共用的 bridge 契约用例清单(12 项能力各一条),联调时人工逐条过,作为 checklist 而不是自动化测试。真起 WebView 加载真 H5 的自动化测试在 CI 上又慢又不稳定,投入产出比很差。
|
||||
|
||||
## Golden 测试:`core_ui` 做,业务页面不做
|
||||
|
||||
**结论**:只对 `core_ui` 里的基础组件(按钮、输入框、卡片、状态占位图)写 golden 测试,`feature_*` 的业务页面不写。
|
||||
|
||||
理由:
|
||||
|
||||
- `core_ui` 组件被所有 feature 复用,改一处影响面大,而它们的输出是稳定的——正是 golden 测试的适用场景。
|
||||
- 业务页面的 UI 改动频繁,golden 会变成"每次改 UI 都要 `--update-goldens` 一遍"的负担,而且没人真的去看那张图对不对,最后退化成走过场。
|
||||
- golden 图片对**渲染环境敏感**(字体、平台、Flutter 版本)。必须在 CI 里用固定环境生成和比对,本机生成的图传上去大概率对不上。所以 golden 测试**只在 Linux runner 上跑**,本地开发时用 `--tags golden` 排除掉。
|
||||
|
||||
字体要显式加载,不然 golden 里全是方块:
|
||||
|
||||
```dart
|
||||
setUpAll(() async {
|
||||
await loadAppFonts(); // golden_toolkit 或自己写的 FontLoader 封装
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Flutter 官方测试文档](https://docs.flutter.dev/testing)
|
||||
- [mocktail | Dart package](https://pub.dev/packages/mocktail)
|
||||
- [mocktail: registerFallbackValue](https://pub.dev/packages/mocktail#how-it-works)
|
||||
- [integration_test 官方文档](https://docs.flutter.dev/testing/integration-tests)
|
||||
- [Flutter: golden 文件测试](https://api.flutter.dev/flutter/flutter_test/matchesGoldenFile.html)
|
||||
|
||||
## 附录:分层怎么测,日常怎么写
|
||||
|
||||
给还没接触过这套测试分层习惯的同学看的入门说明。
|
||||
|
||||
### 为什么要分层测
|
||||
|
||||
不同层次的代码,"测试成本"和"能捕获的问题"是不对称的:domain 层的一条业务规则用纯 Dart 单元测试几毫秒就能跑完,覆盖所有分支;同样的规则如果只写在集成测试里验证,跑一次要几十秒甚至更久(要真的启动 App、走完整个页面流程),而且大部分时间花在跟这条业务规则无关的 UI 渲染上。**金字塔的意思是:能在下层用低成本测试覆盖的逻辑,就不要指望上层的少量集成测试兜底**——集成测试数量少,只用来确认"各层拼在一起没有断裂",不负责覆盖业务规则细节。
|
||||
|
||||
### 这不是 Flutter 独有的能力
|
||||
|
||||
原生 iOS([XCTest](https://developer.apple.com/documentation/xctest),2013 年至今)和 Android(JUnit + [Espresso](https://developer.android.com/training/testing/espresso)/[Robolectric](http://robolectric.org/))的单元测试、UI 自动化测试工具链其实比这里用的这套还要成熟。真正决定"业务逻辑好不好单独测"的是**架构**,不是工具:传统 MVC/MVP 项目里业务逻辑和 `ViewController`/`Activity` 强耦合(网络回调直接写在 `viewDidLoad`/`onCreate` 里),想测一条规则得连带整个页面生命周期一起启动测试环境,成本高、写起来别扭。domain 层纯 Dart、UI 状态与业务逻辑分离,本质是分层架构把业务逻辑从 UI 里解耦的结果——同样的分层思路(Clean Architecture + MVVM)搬到原生 iOS/Android 上,一样能达到这种测试体验。
|
||||
|
||||
### 单元测试示例:domain use case
|
||||
|
||||
```dart
|
||||
class MockPaymentRepository extends Mock implements PaymentRepository {}
|
||||
|
||||
void main() {
|
||||
late MockPaymentRepository repository;
|
||||
late ConfirmPaymentUseCase useCase;
|
||||
|
||||
setUp(() {
|
||||
repository = MockPaymentRepository();
|
||||
useCase = ConfirmPaymentUseCase(repository);
|
||||
});
|
||||
|
||||
test('订单状态非 pending 时应抛出 StateError', () async {
|
||||
when(() => repository.fetchOrder('order1')).thenAnswer(
|
||||
(_) async => PaymentOrder(orderId: 'order1', amountCents: 100, status: PaymentStatus.paid),
|
||||
);
|
||||
|
||||
expect(() => useCase.call('order1', 'pin'), throwsA(isA<StateError>()));
|
||||
});
|
||||
|
||||
test('校验通过时应调用 confirmPayment', () async {
|
||||
when(() => repository.fetchOrder('order1')).thenAnswer(
|
||||
(_) async => PaymentOrder(orderId: 'order1', amountCents: 100, status: PaymentStatus.pending),
|
||||
);
|
||||
when(() => repository.confirmPayment('order1', 'pin')).thenAnswer((_) async {});
|
||||
|
||||
await useCase.call('order1', 'pin');
|
||||
|
||||
verify(() => repository.confirmPayment('order1', 'pin')).called(1);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 单元测试示例:Riverpod Notifier
|
||||
|
||||
```dart
|
||||
void main() {
|
||||
test('刷新失败时状态应变为 AsyncError', () async {
|
||||
final repository = MockStoreRepository();
|
||||
when(() => repository.fetchNearbyStores(any(), any()))
|
||||
.thenThrow(NetworkException('超时'));
|
||||
|
||||
// makeContainer 内部是 ProviderContainer.test(retry: (_, __) => null):
|
||||
// 自动 dispose + 关掉自动重试,否则这条断言会 flaky
|
||||
final container = makeContainer(
|
||||
overrides: [storeRepositoryProvider.overrideWithValue(repository)],
|
||||
);
|
||||
|
||||
await container.read(storeListNotifierProvider.future).catchError((_) {});
|
||||
final state = container.read(storeListNotifierProvider);
|
||||
|
||||
expect(state, isA<AsyncError>());
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Widget 测试示例:门店列表三态
|
||||
|
||||
```dart
|
||||
void main() {
|
||||
testWidgets('加载失败时应展示错误文案', (tester) async {
|
||||
final repository = MockStoreRepository();
|
||||
when(() => repository.fetchNearbyStores(any(), any()))
|
||||
.thenThrow(NetworkException('网络异常'));
|
||||
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [storeRepositoryProvider.overrideWithValue(repository)],
|
||||
child: const MaterialApp(home: StoreListPage()),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('加载失败'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 集成测试示例:黄金路径骨架
|
||||
|
||||
```dart
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('登录 -> 浏览门店 -> 完成支付', (tester) async {
|
||||
await tester.pumpWidget(const ProviderScope(child: App()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byKey(const Key('login_username')), 'test_user');
|
||||
await tester.tap(find.byKey(const Key('login_submit')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('store_item_0')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('confirm_payment')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('支付成功'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
集成测试用真实的(或半真实的、通过测试环境后端的)依赖跑通整条链路,不 mock 掉 repository——这条测试的意义就是验证各层真实拼接在一起没有问题,跟单元测试的定位互补而不是重复。
|
||||
|
||||
## 待确认项
|
||||
|
||||
- 集成测试用的 UAT 测试账号/测试门店,以及数据可重复性由后端保证的方式。
|
||||
- 覆盖率阈值 60% 是起点,跑一个迭代后按实际情况调。
|
||||
@@ -0,0 +1,361 @@
|
||||
# 10. Embedded H5 容器与 JSBridge
|
||||
|
||||
## 为什么单独一篇
|
||||
|
||||
PRD 第 7.3 节(F6 集成边界)定义的 Embedded H5 承载了 App 最核心的几条业务链路(报价开单、施工查车、结算收银),它不是"顺带加个 WebView",而是一个有票据换取、双向桥接、生命周期管理和安全边界的完整子系统。这些内容放不进 01-09 的任何一篇,所以单独成篇。
|
||||
|
||||
**适用范围**:Embedded H5 **仅用于承载 F6 页面**,不做通用外链容器(PRD 第 7.3 节)。任何"能不能顺便用它打开某个网页"的需求,默认答案是不能。
|
||||
|
||||
## 决策
|
||||
|
||||
| 项 | 决策 |
|
||||
|---|---|
|
||||
| WebView 库 | **[webview_flutter](https://pub.dev/packages/webview_flutter) `^4.14.1`** |
|
||||
| 归属包 | `core_webview`(依赖 `core_auth`、`native_scan`/`native_media`/`native_device`) |
|
||||
| 桥接通道 | 单一 JavaScript Channel `ContiBridge`,统一 `{id, method, params}` 协议 |
|
||||
| URL 来源 | 只接受 App Backend 换票后下发的 URL,**路由里不传裸 URL** |
|
||||
|
||||
### 为什么选 webview_flutter 而不是 flutter_inappwebview
|
||||
|
||||
| | webview_flutter | flutter_inappwebview |
|
||||
|---|---|---|
|
||||
| 维护方 | **Flutter 官方(flutter.dev)** | 社区个人维护 |
|
||||
| 最新 stable | `4.14.1`,一个月前发布,持续更新 | `6.1.5`,**距今约 22 个月**,新特性都在 `6.2.0-beta` |
|
||||
| 能力覆盖 | 基础能力齐全,高级能力走平台特定 controller | 更丰富(拦截请求、Cookie 精细管理、下载) |
|
||||
| 我们实际需要的 | JS Channel、导航拦截、文件选择、Cookie 清理 | 同 |
|
||||
|
||||
`flutter_inappwebview` 能力更全,但**它的 stable 版本已经近两年没发布**,新功能和 bugfix 都压在 beta 上。对一个要跑核心交易链路、生命周期以年计的 App 来说,这是不能接受的维护风险——真出问题时我们只能自己 fork。
|
||||
|
||||
`webview_flutter` 的能力缺口(Android 的 `<input type="file">`)有官方解法,用平台特定 controller 即可:
|
||||
|
||||
```dart
|
||||
if (controller.platform is AndroidWebViewController) {
|
||||
await AndroidWebViewController.enableDebugging(env.enableLog);
|
||||
(controller.platform as AndroidWebViewController)
|
||||
.setOnShowFileSelector(_onShowFileSelector); // 交给 native_media 处理
|
||||
}
|
||||
```
|
||||
|
||||
**如果后续发现 F6 页面用到了 `webview_flutter` 确实做不了的能力**(比如需要拦截并改写请求),再评估切换;届时因为所有 WebView 交互都收在 `core_webview` 一个包里,切换代价是可控的。这也是不让 `feature_*` 直接依赖 WebView 库的原因。
|
||||
|
||||
## H5 启动流程
|
||||
|
||||
对应 PRD 第 7.3 节的接入流程:
|
||||
|
||||
```
|
||||
用户点击功能入口(feature_* 或工作台菜单)
|
||||
↓
|
||||
context.push('/webview?target=QUOTE_ORDER') ← 路由里只有 target,没有 URL
|
||||
↓
|
||||
core_webview: POST /api/v1/h5/launch { target }
|
||||
↓
|
||||
App Backend: 校验登录态 / 门店上下文 / 角色权限
|
||||
→ 经 F6 Integration Adapter 取票据
|
||||
↓
|
||||
返回 { url, ticket, expiresIn, title }
|
||||
↓
|
||||
core_webview: 域名白名单校验 → WebViewController.loadRequest(url)
|
||||
```
|
||||
|
||||
```dart
|
||||
// packages/core_webview/lib/src/h5_launch_repository.dart
|
||||
class H5LaunchInfo {
|
||||
final String url; // 已由后端拼好票据和上下文参数
|
||||
final String title;
|
||||
final Duration ttl; // 票据有效期,用于判断是否需要换票
|
||||
}
|
||||
```
|
||||
|
||||
**启动上下文参数(PRD 第 7.3 节)由 App Backend 拼进 URL,客户端不参与拼接。** 客户端拼参数意味着 `userId`/`storeId`/`roleCode` 这些权限相关字段可以被本地篡改,而后端拼接时这些值都从服务端的会话上下文取,客户端只能说"我要开 `QUOTE_ORDER`"。
|
||||
|
||||
客户端唯一负责传的是 `traceId`——请求 `/h5/launch` 时带的 `X-Trace-Id`(见 [05-networking.md](./05-networking.md)),后端把它带进 H5 URL,这样"用户在 H5 里遇到问题"能一路追到 App 侧的请求。
|
||||
|
||||
## 域名白名单
|
||||
|
||||
```dart
|
||||
// packages/core_webview/lib/src/url_guard.dart
|
||||
class UrlGuard {
|
||||
const UrlGuard(this._allowedHosts);
|
||||
final Set<String> _allowedHosts; // 来自 env/{flavor}.json,各环境不同
|
||||
|
||||
bool isAllowed(Uri uri) {
|
||||
if (uri.scheme != 'https') return false; // 只允许 HTTPS(PRD REQ-NFR-016)
|
||||
final host = uri.host.toLowerCase();
|
||||
return _allowedHosts.any((allowed) =>
|
||||
host == allowed || host.endsWith('.$allowed'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
白名单在**三个位置**都要生效,缺一不可:
|
||||
|
||||
1. **首次加载前**:后端返回的 URL 校验一次(防后端配置错误)。
|
||||
2. **导航拦截**(`NavigationDelegate.onNavigationRequest`):H5 内部跳转到非白名单域名一律 `NavigationDecision.prevent`,并记一条埋点。
|
||||
3. **JSBridge 消息处理时**:每条消息都校验当前页面的 host(见下文「来源校验」)。
|
||||
|
||||
`endsWith('.$allowed')` 而不是 `contains`:`contains` 会让 `f6.example.com.evil.com` 通过校验,这是白名单实现里最经典的一个洞。
|
||||
|
||||
非白名单链接(比如 H5 里的外部帮助文档)不是静默阻止,而是**弹确认框后用系统浏览器打开**,避免用户点了没反应以为坏了。
|
||||
|
||||
## JSBridge 协议
|
||||
|
||||
### 通道与消息格式
|
||||
|
||||
只开**一个** JavaScript Channel,所有能力走同一个通道分发。开多个 channel(每个能力一个)会让来源校验、日志、错误处理各写一遍。
|
||||
|
||||
```dart
|
||||
controller.addJavaScriptChannel(
|
||||
'ContiBridge',
|
||||
onMessageReceived: (message) => _bridge.handle(message.message),
|
||||
);
|
||||
```
|
||||
|
||||
H5 侧调用:
|
||||
|
||||
```js
|
||||
// 由 App 在页面加载完成后注入的一小段 JS 提供(见下文「JS 侧胶水」)
|
||||
const result = await window.ContiBridge.call('scan', { mode: 'barcode' });
|
||||
```
|
||||
|
||||
**请求**(H5 → App):
|
||||
|
||||
```json
|
||||
{ "id": "c8f1-...", "method": "scan", "params": { "mode": "barcode" } }
|
||||
```
|
||||
|
||||
**回包**(App → H5):
|
||||
|
||||
```json
|
||||
{ "id": "c8f1-...", "ok": true, "data": { "value": "6901234567892", "format": "EAN_13" } }
|
||||
{ "id": "c8f1-...", "ok": false, "error": { "code": "PERMISSION_DENIED", "message": "未授予相机权限" } }
|
||||
```
|
||||
|
||||
**主动事件**(App → H5,无 `id`):
|
||||
|
||||
```json
|
||||
{ "event": "storeChanged", "payload": { "storeId": 7 } }
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- `id` 由 **H5 侧生成**并原样回传,App 不生成——这样 H5 侧的 Promise 映射表完全由它自己管理。
|
||||
- **所有回包都是异步的**,即使是同步能力(如 `getStoreContext`)。统一异步避免 H5 侧写两套调用方式。
|
||||
- `error.code` 是**稳定的字符串枚举**,不是数字,也不透传原生错误码。H5 侧按 code 分支处理,`message` 只用于展示。
|
||||
- 未知 `method` 返回 `{ code: "UNSUPPORTED_METHOD" }` 而不是静默忽略——H5 版本比 App 新时能明确知道"这个 App 版本不支持这个能力",可以降级而不是卡死。
|
||||
|
||||
### 能力清单(PRD 第 7.3 节 JSBridge 能力清单)
|
||||
|
||||
| method | 说明 | 底层 | 备注 |
|
||||
|---|---|---|---|
|
||||
| `scan` | 打开扫码 | `native_scan` | `params.mode`: `barcode`/`vin`/`plate`(见 [07](./07-native-integration.md)) |
|
||||
| `camera` | 打开相机拍照 | `native_media` | 返回压缩后的本地路径 |
|
||||
| `pickImage` | 打开相册 | `native_media` | 支持多选,`params.maxCount` |
|
||||
| `uploadFile` | 上传图片/文件 | `core_network` | 带进度事件,见下文 |
|
||||
| `dial` | 调起拨号 | `native_device` | `ACTION_DIAL`/`tel:`,不直接拨出 |
|
||||
| `closePage` | 关闭当前 H5 页 | `core_router` | 等价于 `context.pop()` |
|
||||
| `goBack` | H5 内返回上一页 | WebView | 无历史时降级为 `closePage` |
|
||||
| `refresh` | 刷新页面 | WebView | |
|
||||
| `getAuthState` | 获取登录态 / 触发换票 | `core_auth` | **不返回 token 明文**,见安全约定 |
|
||||
| `getStoreContext` | 获取当前门店上下文 | `core_auth` | 返回 `storeId`/`storeCode`/`orgId`/`roleCode` |
|
||||
| `toast` / `dialog` / `loading` | 弹出提示 | `core_ui` | 用原生控件,保证与 App 其他页面视觉一致 |
|
||||
| `navigate` | 跳转 App 原生页面 | `core_router` | `params.route` 必须是**预定义的路由白名单**,不接受任意路径 |
|
||||
| `setTitle` | 设置导航栏标题 | `core_ui` | 与自动的 `title` 同步互补 |
|
||||
|
||||
`navigate` 的路由白名单和 `04-routing.md` 的「后端动态菜单 → 本地路由」用同一张 `menuRouteMap`——不允许 H5 拼一个任意路由字符串跳过去(那等于把 App 的所有内部页面都暴露给了 H5)。
|
||||
|
||||
### 来源校验(PRD 第 8.4 节 安全与合规)
|
||||
|
||||
**JavaScript Channel 会注入到 WebView 的所有 frame,包括 iframe。** 如果 F6 页面里嵌了第三方 iframe,那个 iframe 里的脚本也能调 `ContiBridge`。所以每条消息进来都要校验:
|
||||
|
||||
```dart
|
||||
Future<void> handle(String raw) async {
|
||||
// 1. 当前页面必须在白名单内
|
||||
final current = await _controller.currentUrl();
|
||||
if (current == null || !_urlGuard.isAllowed(Uri.parse(current))) {
|
||||
_logger.w('[bridge] 拒绝来自非白名单页面的调用: $current');
|
||||
return; // 静默丢弃,不回包——不给探测者任何反馈
|
||||
}
|
||||
|
||||
// 2. 解析必须容错:H5 传了畸形 JSON 不能让 App 崩
|
||||
final Map<String, dynamic> req;
|
||||
try {
|
||||
req = jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return _logger.w('[bridge] 无法解析的消息');
|
||||
}
|
||||
|
||||
final id = req['id'] as String?;
|
||||
final method = req['method'] as String?;
|
||||
if (id == null || method == null) return;
|
||||
|
||||
final handler = _handlers[method];
|
||||
if (handler == null) {
|
||||
return _reply(id, error: const BridgeError('UNSUPPORTED_METHOD', '当前 App 版本不支持该能力'));
|
||||
}
|
||||
|
||||
try {
|
||||
_reply(id, data: await handler(req['params'] as Map<String, dynamic>? ?? const {}));
|
||||
} on AppException catch (e) {
|
||||
_reply(id, error: BridgeError(e.bridgeCode, e.message));
|
||||
} catch (e, st) {
|
||||
_logger.e('[bridge] $method 未预期异常', error: e, stackTrace: st);
|
||||
_reply(id, error: const BridgeError('INTERNAL_ERROR', '操作失败,请重试'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `currentUrl()` 返回的是**主 frame** 的 URL,所以这个校验能挡住"整页被导航到恶意站点后调 bridge",但挡不住"白名单页面内的恶意 iframe"。后者的正确解法是不让 F6 页面嵌不受信的 iframe(协议层面约定),以及在导航拦截里限制 iframe 加载的域名。这个限制要在与 F6 的接口评审里明确。
|
||||
|
||||
### 其他安全约定
|
||||
|
||||
- **`getAuthState` 不返回 token 明文**(PRD REQ-NFR-018:H5 不传递 token 明文)。它返回的是 `{ loggedIn: true, ticketRefreshed: true }` 这类状态,H5 需要新票据时由 App 重新换票并 `loadRequest` 新 URL,票据始终在 URL 参数里由后端控制,不经 bridge 传递。
|
||||
- **H5 侧的所有输入都当作不可信**:`params` 里的路径、路由、URL 一律校验后再用。特别是 `uploadFile` 的文件路径,必须限制在 App 沙盒内的临时目录,否则 H5 可以让 App 上传任意本地文件。
|
||||
- **供应商错误不透传**:F6 返回的原始错误信息转换成用户能懂的提示(PRD REQ-NFR-013 统一错误处理),原始信息只进日志。
|
||||
|
||||
### JS 侧胶水
|
||||
|
||||
`window.ContiBridge` 只是一个原始的 `postMessage` 通道,H5 侧直接用很难写。App 在 `onPageFinished` 时注入一段封装,把它包成 Promise:
|
||||
|
||||
```dart
|
||||
const _bridgeShim = r'''
|
||||
(function () {
|
||||
if (window.__contiBridgeReady) return;
|
||||
const pending = new Map();
|
||||
window.__contiBridgeCallback = function (resp) {
|
||||
const p = pending.get(resp.id);
|
||||
if (!p) return;
|
||||
pending.delete(resp.id);
|
||||
resp.ok ? p.resolve(resp.data) : p.reject(resp.error);
|
||||
};
|
||||
window.__contiBridgeEvent = function (evt) {
|
||||
window.dispatchEvent(new CustomEvent('conti:' + evt.event, { detail: evt.payload }));
|
||||
};
|
||||
const raw = window.ContiBridge;
|
||||
window.ContiBridge = {
|
||||
call: function (method, params) {
|
||||
const id = String(Date.now()) + Math.random().toString(36).slice(2);
|
||||
return new Promise(function (resolve, reject) {
|
||||
pending.set(id, { resolve: resolve, reject: reject });
|
||||
raw.postMessage(JSON.stringify({ id: id, method: method, params: params || {} }));
|
||||
});
|
||||
},
|
||||
};
|
||||
window.__contiBridgeReady = true;
|
||||
})();
|
||||
''';
|
||||
```
|
||||
|
||||
**注入时机是 `onPageFinished`,不是 `onPageStarted`**——`onPageStarted` 时 H5 的脚本可能还没执行完,重复注入或时序错乱。同时 `__contiBridgeReady` 做幂等保护,因为 SPA 内部路由变化可能触发多次回调。
|
||||
|
||||
H5 侧要处理"bridge 还没就绪"的情况(比如页面脚本跑得比注入早),约定 H5 等待 `window.__contiBridgeReady` 或监听一个 `conti:ready` 事件。**这条要写进给 F6 的接入文档**。
|
||||
|
||||
## 生命周期管理(PRD 第 7.3 节)
|
||||
|
||||
| 场景 | 处理 |
|
||||
|---|---|
|
||||
| **标题同步** | `onPageFinished` 后读 `document.title` 写入导航栏;`setTitle` bridge 调用优先级更高 |
|
||||
| **返回 vs 关闭** | 导航栏同时有「返回」和「关闭」。返回:有 H5 历史则 `goBack()`,无历史则退出容器。关闭:直接退出容器,不管 H5 历史 |
|
||||
| **Android 物理返回键** | 与「返回」按钮同语义。**必须拦截**,否则一次返回直接退出整个 H5,用户填了一半的表单就没了 |
|
||||
| **缓存策略** | 默认走 WebView 的 HTTP 缓存(F6 的静态资源应带 `Cache-Control`)。**不做 App 侧的离线包**——首版没有这个必要,且离线包会引入版本管理复杂度 |
|
||||
| **票据过期** | 见下文 |
|
||||
| **白屏/超时** | 见下文 |
|
||||
| **上传中断** | 见下文 |
|
||||
| **门店切换 / 登出** | 见下文 |
|
||||
|
||||
### 票据过期后重新换票
|
||||
|
||||
票据是短时的(F6 侧决定,通常几分钟到几十分钟)。两种触发路径:
|
||||
|
||||
1. **H5 主动发现**:F6 页面收到票据失效的响应,调 `getAuthState` 请求刷新 → App 重新调 `/h5/launch` 拿新 URL → `loadRequest` 新 URL。
|
||||
2. **App 预判**:进入前台时若距离上次换票已超过 `ttl * 0.8`,主动换票并 reload。
|
||||
|
||||
**不要在票据过期时静默 reload**——用户正在填表单,reload 会丢数据。正确做法是弹一个"登录信息已过期,需要重新加载页面"的确认框,让用户决定。如果 H5 侧能保存草稿就更好(这一项要和 F6 对齐)。
|
||||
|
||||
### 白屏、超时、网络失败兜底
|
||||
|
||||
WebView 加载失败时用户看到的是一片空白,没有任何提示——这是 H5 容器体验最差的一类问题,必须显式处理:
|
||||
|
||||
```dart
|
||||
NavigationDelegate(
|
||||
onPageStarted: (_) => _startWatchdog(const Duration(seconds: 15)),
|
||||
onPageFinished: (_) { _cancelWatchdog(); _injectShim(); },
|
||||
onWebResourceError: (error) {
|
||||
// 只处理主文档的错误,子资源(某张图、某个 JS)失败不该整页报错
|
||||
if (!error.isForMainFrame!) return;
|
||||
_showErrorState(error);
|
||||
},
|
||||
onHttpError: (error) {
|
||||
if (error.response?.statusCode == 404) _showErrorState(...);
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
- **15 秒看门狗**:`onPageStarted` 后 15 秒还没 `onPageFinished` 就展示"加载超时,请重试"。WebView 在某些网络状况下既不成功也不报错,只有超时能兜住。
|
||||
- 错误页给「重试」和「返回」两个按钮,重试重新走完整的换票流程(票据可能已经过期了),不是简单 `reload()`。
|
||||
- 每次白屏/超时都**上报埋点**(`h5_failed`,带 `target`、错误码、耗时、`traceId`),见 [13-observability-analytics.md](./13-observability-analytics.md)。**这一类失败后端完全看不到**——换票请求是成功的,页面加载失败发生在 WebView 内部,所以它必须由客户端报。这是 H5 链路健康度最重要的指标。
|
||||
|
||||
### 上传中断与重新提交
|
||||
|
||||
`uploadFile` 是耗时最长、最容易被打断的桥接能力(切后台、网络切换、用户误触返回)。约定:
|
||||
|
||||
- 上传期间**拦截返回和关闭**,弹确认框「上传未完成,确定要离开吗?」。
|
||||
- 上传进度通过主动事件推给 H5(`{ event: "uploadProgress", payload: { taskId, sent, total } }`),让 H5 自己画进度条——比 App 弹一个盖住页面的 loading 体验好。
|
||||
- 上传失败的回包里带 `taskId`,H5 可以用同一个 `taskId` 重试,避免重复上传已成功的部分。
|
||||
- 具体上传实现(压缩、超时、单张重传)复用 [05-networking.md](./05-networking.md) 的 `ApiClient.upload`,`core_webview` 不自己写一套。
|
||||
|
||||
### 门店切换与登出时的会话失效
|
||||
|
||||
PRD 第 7.3 节的默认策略是硬要求:
|
||||
|
||||
- **门店切换后,当前 H5 页面必须失效并提示用户重新进入。**
|
||||
- **用户退出登录后,所有 H5 会话必须同步失效。**
|
||||
|
||||
```dart
|
||||
// packages/core_webview/lib/src/webview_session.dart
|
||||
class WebViewSession {
|
||||
/// 门店切换 / 登出时由会话编排调用(见 11-store-context-and-session.md)
|
||||
Future<void> invalidateAll({required bool clearCookies}) async {
|
||||
for (final controller in _openControllers) {
|
||||
await controller.loadRequest(Uri.parse('about:blank')); // 先停掉页面,防止在途请求继续
|
||||
}
|
||||
if (clearCookies) {
|
||||
await WebViewCookieManager().clearCookies();
|
||||
await _controller.clearLocalStorage();
|
||||
await _controller.clearCache();
|
||||
}
|
||||
_openControllers.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
区别:
|
||||
|
||||
- **门店切换**:关闭已打开的 H5 页并提示"门店已切换,请重新进入",**不清 Cookie**(用户还是同一个人,清了会导致 F6 侧重新走一遍登录)。
|
||||
- **登出**:关闭所有 H5 页 + **清 Cookie / LocalStorage / Cache**。不清的话下一个登录的人可能直接进到上一个人的 F6 会话——同一台门店共用设备上这是真实会发生的。
|
||||
|
||||
清理动作**必须等待完成**再让新用户登录,不能 fire-and-forget。
|
||||
|
||||
## 与 F6 的接口对齐清单
|
||||
|
||||
以下几项需要和 F6 侧明确约定,不对齐会在联调阶段集中爆发:
|
||||
|
||||
1. `ContiBridge` 的 13 项能力,H5 侧如何检测可用性(`__contiBridgeReady` 的等待方式)。
|
||||
2. 票据过期时 F6 页面的表现(返回什么响应,是否能保存草稿)。
|
||||
3. F6 页面是否嵌第三方 iframe,若有需要哪些域名。
|
||||
4. F6 静态资源的 `Cache-Control` 策略。
|
||||
5. `error.code` 枚举表(App 侧定义,F6 侧按 code 分支)。
|
||||
6. H5 内部跳转是否会离开白名单域名。
|
||||
|
||||
## 待确认项
|
||||
|
||||
- 各环境的域名白名单具体值(写进 `env/{flavor}.json`)。
|
||||
- `/api/v1/h5/launch` 的接口契约(后端侧对应 `bff-orchestration` + `webview-ticket`,见 [backend/05-integration-layer.md](../backend/05-integration-layer.md)),需要与后端一起定。
|
||||
- 是否需要 H5 离线包(首版不做,若 F6 首屏加载慢再评估)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [webview_flutter | Dart package](https://pub.dev/packages/webview_flutter)
|
||||
- [webview_flutter: JavaScript Channel](https://pub.dev/packages/webview_flutter#javascript-channels)
|
||||
- [AndroidWebViewController.setOnShowFileSelector](https://pub.dev/documentation/webview_flutter_android/latest/webview_flutter_android/AndroidWebViewController/setOnShowFileSelector.html)
|
||||
- [OWASP MASVS:WebView 安全](https://mas.owasp.org/MASVS/)
|
||||
- [PRD 第 7 章 系统集成与架构边界](../prd/Continental-Retail-APP-PRD.md)
|
||||
@@ -0,0 +1,302 @@
|
||||
# 11. 门店上下文与会话管理
|
||||
|
||||
## 为什么单独一篇
|
||||
|
||||
门店上下文是**贯穿整个 App 的隐式依赖**:首页 tile、菜单、购物车、待办、预警、订单、缓存表、H5 页面全部与"当前门店"绑定(PRD REQ-LGN-010:当前门店影响所有业务数据)。它不属于任何一个 `feature_*`,但每个 `feature_*` 都依赖它。
|
||||
|
||||
更关键的是**切换门店时的级联失效**——这是最容易漏、漏了就会出"看到别的门店数据"这种严重问题的地方。之前 01-10 里只在各自话题下提了一句(03 讲 provider 失效、06 讲缓存清理、10 讲 H5 失效),没有一个地方定义完整顺序。这一篇负责收口。
|
||||
|
||||
## 会话状态模型
|
||||
|
||||
```
|
||||
AppSession
|
||||
├── AuthState 登录态(token 生命周期,归 core_auth)
|
||||
├── UserContext 用户上下文(PRD 第 4.1 节)
|
||||
└── StoreContext 门店上下文(PRD REQ-LGN-010)
|
||||
```
|
||||
|
||||
```dart
|
||||
// packages/core_auth/lib/src/model/app_session.dart
|
||||
sealed class AppSession {}
|
||||
|
||||
/// 冷启动读本地态期间,UI 停在 splash
|
||||
class SessionLoading extends AppSession {}
|
||||
|
||||
class SessionUnauthenticated extends AppSession {
|
||||
final LogoutReason? reason; // 主动登出 / token 失效 / 被踢,用于登录页提示文案
|
||||
}
|
||||
|
||||
/// 已登录但还没确定门店(多门店用户需要选,或门店列表拉取失败)
|
||||
class SessionAwaitingStore extends AppSession {
|
||||
final UserContext user;
|
||||
}
|
||||
|
||||
class SessionActive extends AppSession {
|
||||
final UserContext user;
|
||||
final StoreContext store;
|
||||
}
|
||||
```
|
||||
|
||||
**四个状态,不是布尔值。** 用 `bool isLoggedIn` 表达会立刻遇到两个说不清的场景:冷启动期间算不算已登录(算,会闪一下首页;不算,会闪一下登录页),以及"已登录但没门店"该去哪(PRD REQ-LGN-010 要求登录后必须确定唯一「当前门店」,缺失时需引导重新选择,这是一个独立页面,既不是登录页也不是首页)。sealed class 让 `04-routing.md` 的 redirect 能穷举分支,漏一个编译器就报错。
|
||||
|
||||
```dart
|
||||
final class UserContext {
|
||||
final String userId, employeeId, phone, roleCode, channel;
|
||||
final Set<String> permissions; // 权限集
|
||||
}
|
||||
|
||||
final class StoreContext {
|
||||
final int storeId;
|
||||
final String storeCode, storeName;
|
||||
final int orgId;
|
||||
final String? parentStoreId; // 所属总店,无则为分店/独立店
|
||||
final List<MenuItem> menus; // 当前门店可访问菜单,见 04-routing.md 的 menuRouteMap
|
||||
}
|
||||
```
|
||||
|
||||
`menus` 放在 `StoreContext` 里而不是 `UserContext` 里——PRD 第 4.2.5 节(导航收敛与角色化配置)明确菜单是**门店维度**的(同一个人在 A 店是店长、在 B 店是店员,菜单不同)。放错地方会导致切店后菜单不刷新。
|
||||
|
||||
## 唯一真相源
|
||||
|
||||
```dart
|
||||
@riverpod
|
||||
class SessionNotifier extends _$SessionNotifier {
|
||||
@override
|
||||
Future<AppSession> build() async { ... }
|
||||
}
|
||||
|
||||
/// 全 App 读 storeId 的唯一入口
|
||||
@riverpod
|
||||
int currentStoreId(Ref ref) {
|
||||
final session = ref.watch(sessionNotifierProvider).valueOrNull;
|
||||
return switch (session) {
|
||||
SessionActive(:final store) => store.storeId,
|
||||
_ => throw StateError('在没有门店上下文时访问了 currentStoreId'),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
规则(与 [03-state-management.md](./03-state-management.md) 一致):
|
||||
|
||||
- **任何请求里带 storeId 的 provider,必须 `ref.watch(currentStoreIdProvider)` 拿它**,不能 `ref.read`,也不能作为参数从上层传下来。这样切店时依赖图自动失效,不需要维护"哪些 provider 要手动 invalidate"的清单——那份清单一定会漏。
|
||||
- `currentStoreId` 在非 `SessionActive` 时**抛异常而不是返回 0 或 null**。能读到这个 provider 说明 UI 已经渲染到了业务页面,此时没有门店上下文是路由守卫的 bug,应该在开发期直接炸出来,而不是发一个 `storeId=0` 的请求让后端返回一堆空数据。
|
||||
|
||||
## 登录流程
|
||||
|
||||
PRD REQ-LGN-010:登录后必须确定唯一「当前门店」。
|
||||
|
||||
```
|
||||
输入手机号 + 验证码(或账号密码)
|
||||
↓
|
||||
POST /api/v1/auth/login → { accessToken, refreshToken, user }
|
||||
↓
|
||||
写入 secure storage(core_auth 独占,见 06)
|
||||
↓
|
||||
GET /api/v1/stores/accessible → 门店列表
|
||||
↓
|
||||
┌────┴────┬──────────────┐
|
||||
0 个 1 个 多个
|
||||
↓ ↓ ↓
|
||||
"无门店权限" 直接选中 上次门店仍在列表 → 选中
|
||||
提示 + 登出 否则 → 门店选择页
|
||||
↓
|
||||
POST /api/v1/stores/{id}/switch → StoreContext(含菜单)
|
||||
↓
|
||||
SessionActive → 跳首页
|
||||
```
|
||||
|
||||
几个容易做错的点:
|
||||
|
||||
- **`stores/accessible` 失败不等于登录失败**。token 已经拿到了,此时应该进 `SessionAwaitingStore` 并展示一个可重试的页面,而不是回登录页让用户重新发一遍验证码。
|
||||
- **"上次门店"只是一个提示,不是权限依据**。它存在 `shared_preferences`(非敏感,见 [06-local-storage.md](./06-local-storage.md)),冷启动/登录时用来预选,但**必须先确认它在后端返回的可访问列表里**——用户的门店权限可能已经被管理员回收了。
|
||||
- **0 个门店时必须登出**,不能停在一个空白首页。PRD 第 4.1.1 节的异常流程把「账号无任何门店归属」列为阻断登录的分支。
|
||||
|
||||
## 切换门店:级联失效清单
|
||||
|
||||
这是本篇的核心。PRD REQ-LGN-010:切换门店时级联失效所有门店相关缓存与在途请求。
|
||||
|
||||
**顺序是有意义的**,不能随便调:
|
||||
|
||||
```dart
|
||||
Future<void> switchStore(int targetStoreId) async {
|
||||
// ── 0. 前置:有未完成的写操作就拦住 ──────────────────
|
||||
if (ref.read(pendingWriteProvider).isNotEmpty) {
|
||||
throw const PreconditionException('有未完成的操作,请稍后再试'); // 本地判定,不编后端错误码,见 12
|
||||
}
|
||||
|
||||
// ── 1. 先让 UI 进入切换中,挡住用户继续操作 ──────────
|
||||
state = const AsyncLoading();
|
||||
|
||||
// ── 2. 服务端切换(失败则整个流程中止,本地状态不动)──
|
||||
final newStore = await _repo.switchStore(targetStoreId);
|
||||
|
||||
// ── 3. 关闭 H5 会话(不清 Cookie,见 10)─────────────
|
||||
await ref.read(webViewSessionProvider).invalidateAll(clearCookies: false);
|
||||
|
||||
// ── 4. 清本地业务缓存(事务内,见 06)────────────────
|
||||
await ref.read(appDatabaseProvider).clearBusinessCache();
|
||||
|
||||
// ── 5. 落新的门店上下文 → 依赖 currentStoreId 的 provider 自动失效 ──
|
||||
state = AsyncData(SessionActive(user: _user, store: newStore));
|
||||
|
||||
// ── 6. 路由清栈回首页(见 04)────────────────────────
|
||||
ref.read(goRouterProvider).go('/home');
|
||||
|
||||
// ── 7. 记住这次选择,供下次冷启动预选 ────────────────
|
||||
await ref.read(prefsProvider).setInt('last_store_id', newStore.storeId);
|
||||
|
||||
// ── 8. 同步观测上下文(见 13)────────────────────────
|
||||
ref.read(crashReporterProvider).setTag('storeId', '${newStore.storeId}');
|
||||
ref.read(analyticsProvider).registerSuperProperties({'storeId': newStore.storeId});
|
||||
// 切店事件本身由后端从 /stores/{id}/switch 的接口日志出,客户端不重复上报,见 13
|
||||
}
|
||||
```
|
||||
|
||||
| 步 | 为什么必须在这个位置 |
|
||||
|---|---|
|
||||
| 2 在 3/4 之前 | 服务端切换失败(网络断、权限被回收)时**本地必须原样不动**。反过来先清缓存再请求,一旦失败用户就停在一个"门店没变但数据全没了"的状态 |
|
||||
| 3 在 5 之前 | H5 页面里可能有在途请求。先 `about:blank` 停掉,再换上下文,否则旧门店的 H5 请求会带着新门店的票据回来 |
|
||||
| 4 在 5 之前 | 缓存表带 `storeId`(见 06),但**清理和新上下文之间不能有窗口期**:如果先落新上下文,provider 立刻失效并重新请求,可能在清理完成前就把新数据写进去,然后被 `clearBusinessCache()` 一起删掉 |
|
||||
| 6 在 5 之后 | 清栈时目标页面(首页)要用新上下文渲染 |
|
||||
| 8 在 5 之后 | 观测上下文要和业务上下文保持一致;漏了这一步的表现是**切店后的崩溃和埋点还挂在旧门店名下**,按门店维度分析时数据是错的,而且错得很隐蔽 |
|
||||
|
||||
**关于步骤 0(未完成写操作)**:切店时用户可能正在提交订单或上传图片。默认策略是**阻止切换并提示**,而不是静默取消——取消一个已经发出去的下单请求,客户端不知道服务端到底成没成。`pendingWriteProvider` 由发起写操作的 feature 自己注册/注销。
|
||||
|
||||
**关于购物车**:PRD 要求切店后购物车同步切换。购物车走 `clearBusinessCache()` 一起清(它是门店维度的业务数据)。如果后续产品要求"每个门店各自保留购物车",那就改成按 `storeId` 分区保留而不是清空——表结构已经带 `storeId`,改动只在这一处。
|
||||
|
||||
## 登出:清理清单
|
||||
|
||||
PRD REQ-LGN-008(登出):清理本地会话、门店上下文、缓存的业务数据与 WebView Cookie。
|
||||
|
||||
```dart
|
||||
Future<void> logout({LogoutReason reason = LogoutReason.userInitiated}) async {
|
||||
// 1. 通知服务端撤销 refresh token(尽力而为,失败不阻断本地登出)
|
||||
if (reason == LogoutReason.userInitiated) {
|
||||
await _repo.revokeSession().timeout(const Duration(seconds: 3)).catchError((_) {});
|
||||
}
|
||||
|
||||
// 2. H5 会话 + Cookie/LocalStorage/Cache 全清(见 10)
|
||||
await ref.read(webViewSessionProvider).invalidateAll(clearCookies: true);
|
||||
|
||||
// 3. 本地数据
|
||||
await ref.read(appDatabaseProvider).clearAllUserData(); // Drift 业务表
|
||||
await ref.read(secureStorageProvider).deleteAll(); // token
|
||||
await ref.read(prefsProvider).clearUserScoped(); // 只清用户相关的 key
|
||||
|
||||
// 4. 状态置为未登录 → 路由守卫自动跳登录页
|
||||
state = AsyncData(SessionUnauthenticated(reason: reason));
|
||||
|
||||
// 5. 断开观测/埋点的用户关联(门店设备是共用的,不断开会让下一个人的数据串到上一个人身上)
|
||||
ref.read(analyticsProvider)
|
||||
..track(AnalyticsEvent.logout, {'reason': reason.name}) // 报完再 reset,顺序不能反
|
||||
..reset();
|
||||
ref.read(crashReporterProvider).setUser('');
|
||||
|
||||
// 6. 兜底:清掉所有 provider 缓存
|
||||
ref.invalidate(...); // 或在 ProviderScope 层重建,见下文
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **第 1 步失败不能阻断登出**。网络不通时用户点登出必须能退出去,否则用户体验是"这个 App 退不出来"。服务端 token 会自然过期,不撤销的代价可以接受。加 3 秒超时。
|
||||
- **`prefs.clearUserScoped()` 而不是 `prefs.clear()`**。`shared_preferences` 里还有"是否同意过协议""夜间模式偏好""是否看过新手引导"这类设备级配置,全清会导致下一个用户看一遍新手引导。约定:用户相关的 key 统一加 `u_` 前缀,`clearUserScoped()` 按前缀删。
|
||||
- **必须等第 2/3 步完成再切状态**。fire-and-forget 会出现"新用户已经登录进首页了,上一个用户的缓存清理才刚跑完",然后把新用户的数据也删了。门店共用设备上这不是理论问题。
|
||||
- **`clearCookies: true` 在登出时是硬要求**。不清的话下一个人打开 H5 会直接落进上一个人的 F6 会话——这是本项目最有可能出现的一个真实安全事故。
|
||||
|
||||
### 登出兜底:为什么还要一步 provider 清理
|
||||
|
||||
`ref.invalidate` 一个个点名会漏。更稳的做法是让整个业务 provider 树挂在一个 key 上重建:
|
||||
|
||||
```dart
|
||||
// main.dart
|
||||
ProviderScope(
|
||||
retry: (_, __) => null,
|
||||
child: Consumer(builder: (context, ref, _) {
|
||||
final sessionKey = ref.watch(sessionKeyProvider); // 每次登录/登出自增
|
||||
return KeyedSubtree(key: ValueKey(sessionKey), child: const ContiApp());
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
**注意这只重建 widget 树,不重建 provider(provider 挂在 `ProviderScope` 上,在 `KeyedSubtree` 外面)。** 真正让业务 provider 全部失效的是"它们都直接或间接 `ref.watch(currentStoreIdProvider)` / `sessionNotifierProvider`"这条规则 —— 状态一变,`autoDispose` 的 provider 自然重算,`keepAlive` 的少数几个(见 03 的三类白名单)**必须在登出时显式 invalidate**,清单就是那三类,是有限且可维护的。
|
||||
|
||||
## 与 refresh token 轮换的配合
|
||||
|
||||
后端采用**一次性 refresh token + 重放即全量撤销**(见 [backend/04-security-auth.md](../backend/04-security-auth.md))。这对客户端有两条硬约束,已经在 [05-networking.md](./05-networking.md) 的 `AuthInterceptor` 里实现,这里说明它和会话状态的关系:
|
||||
|
||||
1. **刷新必须串行**。并发刷新会把同一个 refresh token 用两次,后端判定为重放,**撤销该用户所有设备的会话**——用户会在自己毫无操作的情况下被全端踢下线。
|
||||
2. **刷新失败立即登出,不重试**。失败意味着 refresh token 已失效(过期、被撤销、或已被重放),重试只会再触发一次重放判定。
|
||||
|
||||
```dart
|
||||
// TokenRefresher 刷新失败 → 通知会话层
|
||||
void _onRefreshFailed() {
|
||||
ref.read(sessionNotifierProvider.notifier).logout(reason: LogoutReason.tokenExpired);
|
||||
}
|
||||
```
|
||||
|
||||
`LogoutReason.tokenExpired` 让登录页能显示「登录已过期,请重新登录」而不是一个没有解释的空登录页。用户在别处被踢(`sessionRevoked`)时提示文案也不同。
|
||||
|
||||
## 冷启动恢复
|
||||
|
||||
```
|
||||
App 启动 → SessionLoading(splash)
|
||||
↓
|
||||
读 secure storage 的 token
|
||||
↓
|
||||
没有 → SessionUnauthenticated
|
||||
有 → GET /api/v1/auth/me + /stores/accessible
|
||||
↓
|
||||
┌───┴────────────────┬─────────────────┐
|
||||
成功 401 网络失败
|
||||
↓ ↓ ↓
|
||||
预选 last_store_id → 登出 进首页 + 用本地缓存渲染
|
||||
→ SessionActive (见 12 的降级约定)
|
||||
```
|
||||
|
||||
- **secure storage 读失败要当作未登录处理**,不能让异常冒到启动流程里(见 [06-local-storage.md](./06-local-storage.md) 关于 `flutter_secure_storage 11.0.0` 的说明)。启动崩溃是最难排查也最致命的一类问题。
|
||||
- **网络失败时不要把用户踢到登录页**。门店里网络不稳是常态,本地有 token 就先按已登录处理,用缓存渲染首页,顶部提示"数据可能不是最新"。真正无效的 token 会在第一个业务请求返回 401 时被发现,那时再登出。
|
||||
- splash 有**最长等待时间**(3 秒)。超时就按"网络失败"分支走,不能无限转圈。
|
||||
|
||||
## 回到前台时的一致性校验
|
||||
|
||||
App 从后台回来时,服务端的门店权限可能已经变了(管理员回收了权限、门店被停用)。
|
||||
|
||||
```dart
|
||||
// 冷时间超过 5 分钟才校验,避免频繁切前后台打接口
|
||||
if (elapsedSinceBackground > const Duration(minutes: 5)) {
|
||||
final stores = await _repo.fetchAccessibleStores();
|
||||
if (!stores.any((s) => s.storeId == currentStoreId)) {
|
||||
// 当前门店已不可访问
|
||||
await switchStore(stores.first.storeId); // 或引导重选
|
||||
showToast('您对当前门店的权限已变更,已切换到 ${stores.first.storeName}');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
不做这个校验的后果是:用户带着一个已失效的 storeId 继续操作,每个请求都被后端拒绝,界面上表现为"什么都点不动但也不说为什么"。
|
||||
|
||||
## 埋点
|
||||
|
||||
会话相关事件大部分**由后端从自己的接口日志出**(登录、切店都是接口调用),客户端不重复报(见 [13-observability-analytics.md](./13-observability-analytics.md) 的分工原则)。客户端只补后端看不到的两件事:
|
||||
|
||||
| 事件 | 谁报 | 关键字段 |
|
||||
|---|---|---|
|
||||
| 登录成功/失败、门店切换 | **后端** | 接口日志即可,客户端不重复上报 |
|
||||
| `logout` | **客户端** | `reason`(userInitiated / tokenExpired / sessionRevoked)。**被动登出往往没有对应的接口调用**——token 刷新失败是客户端本地判定的,后端只看到一个失败的刷新请求,看不到"用户因此被踢了出去" |
|
||||
| `session_restore_failed` | **客户端** | 失败阶段(读 storage / me / stores)。冷启动恢复失败在读 secure storage 这一步时**完全不产生网络请求**,后端无从知晓 |
|
||||
|
||||
`logout` 的 `reason` 分布是最有价值的一个指标——如果 `tokenExpired` 占比异常高,说明刷新逻辑有问题(很可能就是并发刷新触发了后端的重放撤销)。这个指标只能由客户端提供。
|
||||
|
||||
## 待确认项
|
||||
|
||||
- `/api/v1/stores/accessible` 与 `/api/v1/stores/{id}/switch` 的接口契约,以及切换是否需要服务端记录(影响多端一致性)。
|
||||
- 切店时"未完成写操作"的判定粒度:是全局阻止,还是只阻止发起写操作的那个 feature。
|
||||
- 购物车是否需要按门店分别保留(当前决策:清空)。
|
||||
- 前台一致性校验的触发阈值(当前定 5 分钟)需要跑一个迭代后按实际接口压力调整。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [PRD 第 4.1 节 账号登录(REQ-LGN-008 登出 / REQ-LGN-010 门店上下文)](../prd/Continental-Retail-APP-PRD.md)
|
||||
- [backend/04-security-auth.md:refresh token 轮换](../backend/04-security-auth.md)
|
||||
- [Riverpod: Combining requests](https://riverpod.dev/docs/essentials/combining_requests)
|
||||
@@ -0,0 +1,383 @@
|
||||
# 12. 错误处理与 API 契约
|
||||
|
||||
## 为什么单独一篇
|
||||
|
||||
[05-networking.md](./05-networking.md) 定义了"网络层怎么抛异常",但没定义"UI 层怎么显示、什么时候降级、用户看到什么文案"。这两件事必须一起定,否则会出现每个 feature 各写一套错误提示:有的弹 Toast、有的弹 Dialog、有的整页红字、有的干脆什么都不显示。
|
||||
|
||||
这一篇负责三件事:**客户端侧的 `ApiResult` 契约**、**`AppException` 体系全貌**、**错误到 UI 的映射规则(含降级)**。
|
||||
|
||||
## 一、`ApiResult` 客户端契约
|
||||
|
||||
后端所有接口统一返回(见 [backend/06-api-design.md](../backend/06-api-design.md)):
|
||||
|
||||
```json
|
||||
{ "code": 0, "message": "success", "data": { ... }, "traceId": "a1b2c3..." }
|
||||
```
|
||||
|
||||
**`code` 是数字,`0` 表示成功。** 客户端契约(在 `core_network` 的 `ApiResultInterceptor` 里实现,见 05):
|
||||
|
||||
| 情况 | 客户端行为 |
|
||||
|---|---|
|
||||
| HTTP 2xx + `code == 0` | 解包,业务层只拿到 `data` |
|
||||
| HTTP 2xx + `code != 0` | 抛 `BusinessException(code, message, traceId)` |
|
||||
| HTTP 4xx/5xx + body 是 `ApiResult` | 同上,按 `code` 抛 `BusinessException` |
|
||||
| HTTP 4xx/5xx + body 不是 `ApiResult`(网关、CDN、Nginx 返回的 HTML) | 抛 `ServerException(statusCode, traceId: null)` |
|
||||
| 连接失败 / 超时 | 抛 `NetworkException` |
|
||||
|
||||
**第四行是最容易漏的。** 请求不一定能到达后端——网关 502、Nginx 413(上传超限)、运营商劫持返回的 HTML 页面,都不会带 `ApiResult` 结构。直接 `jsonDecode` 会抛 `FormatException`,业务层完全接不住。所以解包前必须判断 body 是不是 `Map` 且含 `code` 字段。
|
||||
|
||||
### 数字错误码的代价,以及怎么消化它
|
||||
|
||||
数字码在日志和监控里聚合方便(可以直接 `group by code` 出趋势),但**它不自解释**:日志里一条 `code=10403` 不看码表完全不知道是什么。所以配套要求:
|
||||
|
||||
1. **必须有一份双方共享、和代码一起维护的码表**,不能只存在于某个人的 Excel 里。
|
||||
2. **客户端不允许出现字面量数字**。所有用到的码定义成命名常量,`if (e.code == ApiCode.forbidden)` 而不是 `if (e.code == 10403)`。
|
||||
3. **日志里 code 和 message 一起打**,因为 `message` 是唯一能让人在不查码表时看懂的东西。
|
||||
|
||||
### 分段方案
|
||||
|
||||
**5 位数字,前 2 位是域段**(与 `backend/06-api-design.md` 一致):
|
||||
|
||||
| 段 | 域 | 例 |
|
||||
|---|---|---|
|
||||
| `0` | 成功 | `0` |
|
||||
| `10xxx` | 平台通用 | `10001` 参数错误、`10401` 未登录、`10403` 无权限、`10500` 系统错误 |
|
||||
| `11xxx` | 认证与门店 | `11001` 门店不可访问、`11002` 无门店权限 |
|
||||
| `20xxx` | 采购 | |
|
||||
| `21xxx` | 库存 | |
|
||||
| `3xxxx` | 外部系统集成 | `30xxx` F6、`31xxx` Mini 域、`32xxx` 阿里云 OCR;后端做过转换,不透传供应商原始码 |
|
||||
|
||||
分段的价值是**看到码的前两位就知道该找谁**。全局连续编号(1、2、3…)在多域并行开发时必然撞号。
|
||||
|
||||
### `data` 为 `null` 的语义
|
||||
|
||||
`code == 0` 但 `data == null` 是合法的(后端 `ApiResult.ok(Unit)`)。约定:
|
||||
|
||||
```dart
|
||||
Future<void> → data 可以为 null,忽略
|
||||
Future<T> → data 为 null 时抛 ServerException('响应缺少 data'),不返回 null
|
||||
Future<T?> → 显式声明可空时才允许 null
|
||||
```
|
||||
|
||||
不加这层校验的话,后端某个字段漏返回会变成 UI 层莫名其妙的 `Null check operator used on a null value`,排查时完全看不出是接口问题。
|
||||
|
||||
### 基础码先定死,业务码开发时增补
|
||||
|
||||
**分段方案和下面这组基础码现在就定死,各 domain 段内的业务码在开发对应模块时随接口一起定。** 不等一份"完整码表"齐了再开工——那份表在需求还在动的时候不可能齐,等它等于卡住所有人。
|
||||
|
||||
配套的两条策略让码表不齐也能正常工作:
|
||||
|
||||
- **默认直接展示后端的 `message`**。后端的 `GlobalExceptionHandler` 已经保证了 `message` 是给人看的(未预期异常统一兜底成"系统繁忙,请稍后重试",不泄漏堆栈)。**新增业务码不需要客户端改代码**,走的就是这条默认路径。
|
||||
- **客户端只对"需要特殊 UX 而不只是提示文案"的 code 做分支**,这组要尽可能小。下面这份就是当前的全集:
|
||||
|
||||
```dart
|
||||
// packages/core_network/lib/src/error/api_code.dart
|
||||
abstract final class ApiCode {
|
||||
static const ok = 0;
|
||||
|
||||
// 10xxx 平台通用
|
||||
static const invalidParam = 10001; // → 表单内联报错,不弹 Toast
|
||||
static const unauthorized = 10401; // → 触发刷新 / 登出
|
||||
static const forbidden = 10403; // → 权限变更,可能要重拉门店上下文
|
||||
static const notFound = 10404; // → 资源不存在,页面级空态
|
||||
static const rateLimited = 10429; // → 提示稍后重试,不自动重试
|
||||
static const internalError = 10500; // → 展示 traceId
|
||||
|
||||
// 11xxx 认证与门店
|
||||
static const storeNotAccessible = 11001; // → 引导重选门店
|
||||
static const noStorePermission = 11002; // → 退回门店选择页
|
||||
|
||||
// 3xxxx 集成
|
||||
static const ocrUnavailable = 32001; // → 车牌识别不可用,直接切手工输码
|
||||
static const ocrNoPlateFound = 32002; // → 没识别到车牌,提示重拍
|
||||
}
|
||||
```
|
||||
|
||||
**增补一个业务码的门槛**:只有当客户端需要"展示文案之外的动作"(跳转、重拉上下文、内联标红、拦截重试)时才加进这个类;只是文案不同的,一律走默认展示。这条不守住,`ApiCode` 会在半年内长成后端码表的副本。
|
||||
|
||||
码表本身**和后端代码放在一起维护**(见 `backend/06-api-design.md`),客户端这份常量是它的子集,不是第二份真相。
|
||||
|
||||
## 二、`AppException` 体系
|
||||
|
||||
```dart
|
||||
// packages/core_network/lib/src/error/app_exception.dart
|
||||
sealed class AppException implements Exception {
|
||||
const AppException(this.message, {this.traceId});
|
||||
final String message;
|
||||
final String? traceId;
|
||||
}
|
||||
|
||||
/// 网络不通、超时、DNS 失败——用户重试可能就好了
|
||||
final class NetworkException extends AppException {
|
||||
const NetworkException(super.message, {this.kind});
|
||||
final NetworkErrorKind? kind; // connectTimeout / receiveTimeout / noConnection
|
||||
}
|
||||
|
||||
/// 后端返回了 code != 0,message 可直接展示
|
||||
final class BusinessException extends AppException {
|
||||
const BusinessException(this.code, super.message, {super.traceId});
|
||||
final int code;
|
||||
}
|
||||
|
||||
/// 5xx、非 ApiResult 响应、解析失败——用户重试大概率也不好
|
||||
final class ServerException extends AppException {
|
||||
const ServerException(super.message, {this.statusCode, super.traceId});
|
||||
final int? statusCode;
|
||||
}
|
||||
|
||||
/// token 失效且刷新失败,已触发登出
|
||||
final class UnauthorizedException extends AppException {}
|
||||
|
||||
/// 请求被 CancelToken 取消(页面销毁、用户主动退出)
|
||||
final class RequestCancelledException extends AppException {}
|
||||
|
||||
/// 客户端本地判定的前置条件不满足(如切店时有未完成的写操作),message 可直接展示
|
||||
/// 不复用 BusinessException:后者的 code 来自后端错误码表,纯本地的判定没有、也不该编一个 code
|
||||
final class PreconditionException extends AppException {
|
||||
const PreconditionException(super.message);
|
||||
}
|
||||
|
||||
/// 本地存储 / 数据库错误
|
||||
final class StorageException extends AppException {}
|
||||
|
||||
/// 原生能力错误(权限拒绝、设备不支持),见 07
|
||||
final class NativeException extends AppException {
|
||||
const NativeException(this.code, super.message);
|
||||
final String code; // PERMISSION_DENIED / UNAVAILABLE / CANCELLED
|
||||
}
|
||||
```
|
||||
|
||||
`sealed` 是有意的:UI 层的错误映射用 `switch` 穷举,将来新增一种异常类型,所有映射点编译报错,逼着人去处理,而不是悄悄落进 `default` 分支变成"未知错误"。
|
||||
|
||||
**`RequestCancelledException` 必须被 UI 静默处理**(见 05)。用户返回上一页时在途请求被取消,弹一个"请求已取消"的 Toast 是纯粹的噪音。
|
||||
|
||||
## 三、错误 → UI 映射
|
||||
|
||||
### 三种展示形态,按"用户当时在干什么"选
|
||||
|
||||
| 形态 | 适用 | 例子 |
|
||||
|---|---|---|
|
||||
| **整页错误态** | 用户在等这个页面的主数据,没数据页面就是空的 | 订单列表加载失败 |
|
||||
| **局部错误态** | 页面有多块数据,一块失败不影响其他 | 首页某个 tile 失败 |
|
||||
| **Toast / SnackBar** | 用户主动触发了一个动作,失败了要立刻知道 | 提交订单失败、下拉刷新失败 |
|
||||
| **表单内联** | 参数校验类错误,要指到具体字段 | `ApiCode.invalidParam` |
|
||||
|
||||
**不要用 Dialog 报错**,除非错误需要用户做决定("登录已过期,是否重新登录")。Dialog 阻断操作,而大部分错误用户能做的只有"知道了"。
|
||||
|
||||
### 统一的错误文案映射
|
||||
|
||||
```dart
|
||||
// packages/core_ui/lib/src/error/error_presenter.dart
|
||||
({String title, String? detail, bool retryable, bool showTraceId}) present(AppException e) =>
|
||||
switch (e) {
|
||||
NetworkException(kind: NetworkErrorKind.noConnection) =>
|
||||
(title: '网络未连接', detail: '请检查网络后重试', retryable: true, showTraceId: false),
|
||||
NetworkException() =>
|
||||
(title: '网络不太稳定', detail: '请稍后重试', retryable: true, showTraceId: false),
|
||||
ServerException() =>
|
||||
(title: '系统繁忙', detail: '请稍后重试', retryable: true, showTraceId: true),
|
||||
BusinessException(:final message) =>
|
||||
(title: message, detail: null, retryable: false, showTraceId: false),
|
||||
StorageException() =>
|
||||
(title: '本地数据异常', detail: '请重启 App', retryable: false, showTraceId: false),
|
||||
NativeException(code: 'PERMISSION_DENIED', :final message) =>
|
||||
(title: message, detail: '可在系统设置中开启', retryable: false, showTraceId: false),
|
||||
NativeException(:final message) =>
|
||||
(title: message, detail: null, retryable: false, showTraceId: false),
|
||||
UnauthorizedException() || RequestCancelledException() =>
|
||||
(title: '', detail: null, retryable: false, showTraceId: false), // 不展示
|
||||
};
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **`BusinessException` 的 `retryable` 是 `false`**。业务错误(比如"库存不足""订单已支付")重试没有意义,给一个重试按钮只会让用户反复点。
|
||||
- **`NetworkException` 不展示 traceId**。请求根本没到后端,traceId 在服务端日志里查不到,展示出来只会误导。
|
||||
- `ServerException` 展示 traceId——这正是 `traceId` 存在的意义(见 backend/06 附录)。
|
||||
|
||||
### traceId 怎么展示
|
||||
|
||||
**`traceId` 保留,但它是一个低成本、低存在感的字段,不要为它做重的交互。** 后端侧它本来就有(`TraceIdFilter` 写 MDC + 落 ELK,见 [backend/08-observability.md](../backend/08-observability.md)),响应里多带一个字符串对客户端来说接近零成本;它唯一的价值是**把一次用户投诉精确定位到一条服务端日志**,省掉"大概是下午三点多,某个门店"这种模糊排查。所以:
|
||||
|
||||
- **绝大多数错误不展示它**,只有 `ServerException`(5xx / 系统错误)才展示——那正是需要研发介入的场景。
|
||||
- 无条件写进本地日志和错误上报(见 [13-observability-analytics.md](./13-observability-analytics.md)),这部分不依赖 UI。
|
||||
|
||||
不要把 `traceId` 直接印在主文案里(用户看到一串乱码只会更慌)。约定:
|
||||
|
||||
```
|
||||
系统繁忙
|
||||
请稍后重试
|
||||
|
||||
[ 重试 ] 问题反馈 ›
|
||||
```
|
||||
|
||||
「问题反馈」展开后显示 `traceId` 并提供**一键复制**。客服话术是"请点击问题反馈,把那串编号发给我"。
|
||||
|
||||
同时 traceId **无条件写进本地日志**(不管展不展示),见 [13-observability-analytics.md](./13-observability-analytics.md)。
|
||||
|
||||
### 通用错误 Widget
|
||||
|
||||
`core_ui` 提供,所有 feature 复用,不各写一套:
|
||||
|
||||
```dart
|
||||
// 整页
|
||||
AsyncValueView<T>(
|
||||
value: ref.watch(orderListProvider),
|
||||
onRetry: () => ref.invalidate(orderListProvider),
|
||||
data: (orders) => OrderList(orders),
|
||||
)
|
||||
|
||||
// 局部(tile 级降级)
|
||||
TileErrorView(error: e, onRetry: ...) // 尺寸自适应,不撑破布局
|
||||
```
|
||||
|
||||
`AsyncValueView` 内部统一处理:loading 骨架屏、error → `present()` → 错误态、`RequestCancelledException` 静默、空数据 → 空态图。**每个 feature 自己写 `switch (asyncValue)` 是最常见的重复劳动,也是三态处理不一致的根源。**
|
||||
|
||||
## 四、降级:局部失败不能拖垮整页
|
||||
|
||||
PRD REQ-NFR-012:单一外围系统故障时局部降级,首页其余卡片正常展示并标注「暂不可用」——Mini 某一服务失败只影响对应模块,F6 异常不得导致主 APP 全部不可用。
|
||||
|
||||
### 首页的降级模型
|
||||
|
||||
首页由多块数据组成(门店信息、菜单、待办、预警、公告、促销位),它们来自**不同的后端聚合**,失败是独立的。
|
||||
|
||||
**做法:每块数据一个独立 provider,页面不做 `Future.wait`。**
|
||||
|
||||
```dart
|
||||
// ❌ 错的:任何一块失败,整个首页变成错误态
|
||||
@riverpod
|
||||
Future<HomeData> homeData(Ref ref) async {
|
||||
final (menus, todos, alerts) = await (
|
||||
ref.watch(menuProvider.future),
|
||||
ref.watch(todoProvider.future),
|
||||
ref.watch(alertProvider.future),
|
||||
).wait;
|
||||
return HomeData(menus, todos, alerts);
|
||||
}
|
||||
|
||||
// ✅ 对的:各自独立,各自渲染,各自重试
|
||||
class HomePage extends ConsumerWidget {
|
||||
Widget build(context, ref) => ListView(children: [
|
||||
const StoreHeader(),
|
||||
MenuSection(), // 内部 watch(menuProvider)
|
||||
TodoSection(), // 内部 watch(todoProvider)
|
||||
AlertSection(),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
`Future.wait` 看起来更"干净",但它把 N 个独立的失败面耦合成了一个——公告服务挂了,用户连待办都看不到。这直接违反 PRD REQ-NFR-012。
|
||||
|
||||
**唯一的例外是"没有它整页就没意义"的数据**:门店上下文和菜单。这两块失败时首页确实应该整页错误态,因为菜单没了首页就是一个空壳。
|
||||
|
||||
### 降级的粒度约定
|
||||
|
||||
| 数据 | 失败时 |
|
||||
|---|---|
|
||||
| 门店上下文、菜单 | **整页错误态 + 重试**(没有它首页无意义) |
|
||||
| 待办、预警、公告、促销位 | **该区块显示局部错误态**,其余正常 |
|
||||
| 首页各 tile 的数字/角标 | **降级为不显示角标**,不显示错误 UI——一个角标加载失败不值得占用用户注意力 |
|
||||
| H5 页面 | 容器内错误页,不影响 App 其他部分(见 [10-webview-h5.md](./10-webview-h5.md)) |
|
||||
|
||||
### 有缓存时优先展示缓存
|
||||
|
||||
网络失败但本地有缓存(见 [06-local-storage.md](./06-local-storage.md))时,**展示缓存 + 顶部提示条**,比展示一个错误页好得多——门店里网络不稳是常态。
|
||||
|
||||
```dart
|
||||
// 顶部一条细提示条,不遮挡内容
|
||||
if (state.isFromCache) StaleDataBanner(updatedAt: state.cachedAt, onRefresh: ...)
|
||||
```
|
||||
|
||||
前提是缓存**必须带时间戳并显示**("更新于 10 分钟前")。展示旧数据却不告诉用户是旧的,比展示错误更危险——尤其是库存和价格。
|
||||
|
||||
## 五、兜底:没被 catch 的异常
|
||||
|
||||
```dart
|
||||
// main.dart
|
||||
void main() {
|
||||
runZonedGuarded(() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// widget 构建/布局/绘制期的错误
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details); // 保留控制台输出
|
||||
reporter.recordFlutterError(details);
|
||||
};
|
||||
|
||||
// 平台层/异步的未捕获错误(Flutter 3.3+)
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
reporter.recordError(error, stack, fatal: true);
|
||||
return true;
|
||||
};
|
||||
|
||||
runApp(ProviderScope(
|
||||
retry: (_, __) => null, // 全局关掉自动重试,见 03
|
||||
observers: [ErrorObserver()],
|
||||
child: const ContiApp(),
|
||||
));
|
||||
}, (error, stack) => reporter.recordError(error, stack, fatal: true));
|
||||
}
|
||||
```
|
||||
|
||||
另外在 Riverpod 侧加一个全局观察者,把所有 provider 抛出的错误上报(即使 UI 已经优雅处理了):
|
||||
|
||||
```dart
|
||||
class ErrorObserver extends ProviderObserver {
|
||||
@override
|
||||
void providerDidFail(context, error, stackTrace) {
|
||||
if (error is RequestCancelledException) return; // 取消不是错误
|
||||
reporter.recordError(error, stackTrace, fatal: false, context: {'provider': ...});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**"UI 优雅处理了"和"不需要上报"是两回事。** 用户看到一个漂亮的错误页,我们仍然需要知道有多少人看到了它。上报细节见 [13-observability-analytics.md](./13-observability-analytics.md)。
|
||||
|
||||
### release 模式的错误页
|
||||
|
||||
```dart
|
||||
ErrorWidget.builder = (details) => const AppCrashView(); // 不显示红屏
|
||||
```
|
||||
|
||||
默认的红色错误屏在 release 下也会出现(虽然是灰色的)。换成一个统一的"页面出错了,请返回重试"视图。
|
||||
|
||||
## 六、错误处理的反模式
|
||||
|
||||
这几条在 review 时直接打回:
|
||||
|
||||
```dart
|
||||
// ❌ 吞掉异常
|
||||
try { await repo.submit(); } catch (_) {}
|
||||
|
||||
// ❌ 用 catch-all 把所有错误变成同一句话,丢掉了 BusinessException 的 message
|
||||
try { ... } catch (e) { showToast('操作失败'); }
|
||||
|
||||
// ❌ 在 repository / use case 里弹 UI
|
||||
class OrderRepository {
|
||||
Future<void> submit() async {
|
||||
try { ... } catch (e) { showToast(...); } // data 层不能碰 UI,见 02
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 用 message 内容做判断
|
||||
if (e.message.contains('库存')) { ... } // 后端改一个字就失效
|
||||
|
||||
// ❌ 写裸数字错误码
|
||||
if (e.code == 10403) { ... } // 用 ApiCode.forbidden
|
||||
```
|
||||
|
||||
正确做法:异常一路向上抛到 `Notifier`,由 `AsyncValue` 承载,UI 层统一映射。需要分支时用 `ApiCode` 常量,不用 `message`、不用字面量数字。
|
||||
|
||||
## 待确认项
|
||||
|
||||
- **各 domain 段内的业务码**:分段方案和 `ApiCode` 里的基础码已定死,采购(`20xxx`)、库存(`21xxx`)、外部集成(`3xxxx`)段内的其余码值在开发对应模块时随接口一起定。新增码默认走 `message` 展示,只有需要特殊 UX 的才进 `ApiCode`。
|
||||
- 幂等:提交类接口(下单、入库)超时后客户端是否重试,需要后端提供幂等键(`Idempotency-Key`)支持才能安全重试。当前决策是**不重试、提示用户手动确认结果**。
|
||||
- 是否需要一个统一的"错误反馈"入口(用户可以带 traceId 一键提交问题)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [backend/06-api-design.md:`ApiResult` 与全局异常处理](../backend/06-api-design.md)
|
||||
- [backend/08-observability.md:traceId 全链路](../backend/08-observability.md)
|
||||
- [Flutter: Handling errors](https://docs.flutter.dev/testing/errors)
|
||||
- [Riverpod: ProviderObserver](https://pub.dev/documentation/riverpod/latest/riverpod/ProviderObserver-class.html)
|
||||
- [Dart 3 patterns: switch expressions](https://dart.dev/language/patterns)
|
||||
@@ -0,0 +1,464 @@
|
||||
# 13. 可观测性与埋点
|
||||
|
||||
## 为什么单独一篇
|
||||
|
||||
PRD REQ-NFR-015(崩溃与错误上报)和第 6.8 节(埋点)有明确要求——主链路 Trace ID、H5 打开/关闭/失败事件、关键业务审计日志、埋点事件——但 01-12 里完全没有落点。同时,**App 侧的可观测性是排查线上问题唯一的手段**——后端有 ELK 可以查日志,App 装在几百家门店的员工手机上,没有上报就等于全盲。
|
||||
|
||||
这一篇定三件事:**崩溃上报**、**日志规范**、**埋点规范**。
|
||||
|
||||
## 决策
|
||||
|
||||
| 项 | 决策 |
|
||||
|---|---|
|
||||
| 崩溃上报 | **腾讯 Bugly**(原生崩溃 / ANR / 启动崩溃),通过自建的 `native_crash` Pigeon 包接入 |
|
||||
| 错误明细与看板 | **神策自定义事件 `app_error` + 在神策上二次开发错误看板**,不引入独立崩溃平台 |
|
||||
| release 混淆 | **不开 `--obfuscate`**,只 `--split-debug-info` 归档符号表,见下文「必须关掉混淆」 |
|
||||
| 本地日志 | **[logger](https://pub.dev/packages/logger) `^2.7.0`**,封装在 `core_logging` 的 `AppLogger` 后面 |
|
||||
| 客户端埋点 | **[神策 `sensors_analytics_flutter_plugin`](https://pub.dev/packages/sensors_analytics_flutter_plugin) `^4.2.3`**(官方 verified publisher `sensorsdata.cn`;团队过往项目用过) |
|
||||
| 业务埋点 | **以后端为主**,客户端只补后端看不到的那部分 |
|
||||
| 链路关联 | 客户端生成 `X-Trace-Id`(见 05),写入本地日志并作为崩溃/错误上报的自定义属性 |
|
||||
|
||||
## 一、崩溃上报:Bugly + 神策
|
||||
|
||||
### 分工
|
||||
|
||||
崩溃上报要收两类东西,它们的技术性质完全不同:
|
||||
|
||||
- **原生崩溃**(SIGSEGV、ANR、iOS crash、启动期崩溃)—— 交给 **Bugly**。团队现成在用,国内可达性没有任何问题,原生侧自动符号化是它的强项。
|
||||
- **Dart 异常**(`setState` 期间抛错、null check、JSON 解析失败、未 catch 的 `Future`)—— 我们**自己捕获**,作为结构化的自定义事件 `app_error` 上报到**神策**,在神策上二次开发出错误看板(按 `errorType` / `route` / `storeId` / `appVersion` 分组看趋势和影响设备数)。
|
||||
- 同一条 Dart 异常**再通过 Bugly 的自定义异常接口报一份**,这样 Bugly 后台的崩溃率是全量口径,不会因为"Dart 异常不在里面"而虚低。两边口径见下文「两边报同一件事,怎么不打架」。
|
||||
|
||||
不引入独立崩溃平台(Sentry 等),省掉一次采购、一套部署运维和一轮跨境数据合规评估。**代价在下一节,必须一起接受。**
|
||||
|
||||
### 必须关掉混淆
|
||||
|
||||
一个独立崩溃平台在这件事上唯一真正的优势,是能自动上传 Dart 符号表并在后台还原混淆堆栈。**Bugly 和神策都做不到** —— Bugly 只能把 Dart 异常当成一段字符串收下,神策更是只当事件属性存着。
|
||||
|
||||
如果继续按原计划用 `--obfuscate` 打 release 包,结果是**线上占比最大的那一半崩溃,堆栈是一串读不出来的 `_x12`**,每次排查都要人工把堆栈拷出来跑一遍 `flutter symbolize`。所以这里必须做一个连带决策:
|
||||
|
||||
| release 构建参数 | 堆栈可读性 | 结论 |
|
||||
|---|---|---|
|
||||
| `--obfuscate --split-debug-info` | 类名方法名全部混淆,必须 `flutter symbolize` 才能看 | ❌ 放弃(原计划) |
|
||||
| **只 `--split-debug-info`** | **类名、方法名直接可读**(`OrderRepository.submit`),行号被剥离到符号表,需要精确行号时再 `symbolize` | ✅ **采用** |
|
||||
| 两个都不加 | 完全可读(带 `file:line`),但产物变大、Dart 符号全部留在包里 | ❌ 不采用 |
|
||||
|
||||
**这是本次选型真正付出的东西**:上报回来的堆栈能直接定位到出错的类和方法,够用;代价是 Dart 层的符号不再混淆,逆向门槛降了一档。这一条要和安全侧确认(见待确认项)——安全侧若坚持要混淆,就必须接受"每条 Dart 崩溃都要人工 symbolize"这个排查成本,并把 [08-build-flavors.md](./08-build-flavors.md) 的构建参数改回去。
|
||||
|
||||
**符号表照旧归档**(08 要求 ≥1 年)。关掉混淆后它不再是日常排查的必需品,但仍是拿精确行号的唯一手段,且丢了不可逆。
|
||||
|
||||
### 存档:为什么不另起一个崩溃平台
|
||||
|
||||
| | Bugly + 神策(采用) | 独立崩溃平台(如 Sentry) |
|
||||
|---|---|---|
|
||||
| **Dart 堆栈还原** | 做不到,靠"不混淆"绕开 | 做得到(插件自动上传符号表) |
|
||||
| Flutter 官方 SDK | Bugly **没有**,pub.dev 上只有 unverified 社区插件,要自己写 `native_crash` | 有,`pubspec.yaml` 加两行 |
|
||||
| 原生崩溃 | Bugly 强项,自动符号化 | 支持 |
|
||||
| 账号 / 采购 | **Bugly、神策都是现成的** | 要新开,自建还要内网资源和运维承接方 |
|
||||
| 数据出境 | 无(都在境内) | SaaS 形态属数据出境,要走合规评估 |
|
||||
| 门店网络可达性 | 无问题 | SaaS 形态丢报率无法预估 |
|
||||
| 接入成本 | 高:`native_crash` + 三个 Dart 捕获入口 + 神策看板都要自己做 | 低 |
|
||||
|
||||
**决定性的是「现成」那一行。** 崩溃平台这类基础设施,"已经在用、有人维护、合规口径已经过"的价值,高于"SDK 接入省两天"。混淆那一条是可以用构建参数换掉的,采购周期和数据出境评估换不掉。
|
||||
|
||||
|
||||
### `native_crash`:Bugly 得自己包
|
||||
|
||||
Bugly 只有 Android / iOS 原生 SDK。pub.dev 上那两个社区插件(`flutter_bugly`、`bugly_pro_flutter`)都是 unverified,**不采用**——崩溃上报是出了问题最不该再出问题的一环,不押在一个可能停更的包上。
|
||||
|
||||
所以 `native_*` 里**要加一个 `native_crash` 包**(包清单见 [01-project-structure.md](./01-project-structure.md),Pigeon 约定见 [07-native-integration.md](./07-native-integration.md)),几十行 Kotlin/Swift:
|
||||
|
||||
```dart
|
||||
// packages/native_crash/pigeons/crash_api.dart
|
||||
@HostApi()
|
||||
abstract class NativeCrashApi {
|
||||
void setUserId(String userId);
|
||||
void putUserData(String key, String value); // storeId / roleCode / flavor 等维度
|
||||
void postException(String type, String message, String stackTrace, Map<String, String> extra);
|
||||
void log(int level, String tag, String message); // 进 Bugly 的崩溃附加日志
|
||||
}
|
||||
```
|
||||
|
||||
**`native_crash` 和其它 `native_*` 包有一条本质区别:它的初始化不在 Dart 侧。** Bugly SDK 必须在原生的 `Application.onCreate()` / `AppDelegate` 里尽早初始化,**启动期原生崩溃发生在 Flutter engine 起来之前**,等 Dart 调过来就已经漏了。因此:
|
||||
|
||||
- SDK 初始化写在原生侧,`appId` / `appKey` 按 flavor 从原生资源里取(Android 走 `src/{flavor}/`,iOS 走 xcconfig,见 [08-build-flavors.md](./08-build-flavors.md)),**不经过 `--dart-define`**——Dart 侧拿到的时候太晚了。
|
||||
- Pigeon 接口里因此**没有 `init()`**,只有初始化之后才用得上的那几个方法。
|
||||
- 但**「用户同意隐私政策前不初始化」这条硬要求依然成立**:原生侧读一个本地标记位,没同意就不初始化 SDK。这个标记位由 Dart 侧在同意后写入(见「不采集什么」)。
|
||||
|
||||
### Dart 异常的三个捕获入口
|
||||
|
||||
没有 SDK 帮忙接管,三个入口要自己写全,**漏掉哪个就是那一类异常静默丢失**。完整的 `main()` 写法见 [12-error-and-api-contract.md](./12-error-and-api-contract.md) 第五节,这里只强调三者缺一不可:
|
||||
|
||||
| 入口 | 覆盖 |
|
||||
|---|---|
|
||||
| `FlutterError.onError` | widget 构建 / 布局 / 绘制期的异常 |
|
||||
| `PlatformDispatcher.instance.onError` | 框架之外、engine 层冒上来的异步异常 |
|
||||
| `runZonedGuarded` 的 onError | zone 内未捕获的异步异常(`Future` 里 throw 且没人 catch) |
|
||||
|
||||
**这里和"用现成 SDK"是一个反转的风险**:接三方 SDK 时最常见的错误是"SDK 已经接管了还手写一遍,同一个异常报两次";自己接反过来变成**"以为有人管,结果三个入口都没写"**,表现是后台一片安静、看起来 App 特别稳定。这比重复上报危险得多,见下文「验证接入真的成功了」。
|
||||
|
||||
### `ErrorReporter`:业务代码只见这一个接口
|
||||
|
||||
```dart
|
||||
// packages/core_logging/lib/src/error_reporter.dart
|
||||
abstract interface class ErrorReporter {
|
||||
void setUser(String userId);
|
||||
void setTag(String key, String value);
|
||||
void leaveBreadcrumb(String message);
|
||||
void recordFlutterError(FlutterErrorDetails details);
|
||||
void recordError(Object error, StackTrace? stack, {bool fatal = false, Map<String, String> context = const {}});
|
||||
}
|
||||
```
|
||||
|
||||
`feature_*` 不直接 import `native_crash`,也不直接 import 神策。这一层的价值有三个:测试里能 mock 掉、`feature_*` 少一条对三方 SDK 的直接依赖(见 [01-project-structure.md](./01-project-structure.md) 的依赖规则),以及**"一条异常同时进 Bugly 和神策"这个双写逻辑只写在一个地方**。
|
||||
|
||||
默认实现做的事:
|
||||
|
||||
```dart
|
||||
void recordError(Object error, StackTrace? stack, {bool fatal = false, Map<String, String> context = const {}}) {
|
||||
final type = error.runtimeType.toString();
|
||||
final scrubbed = scrub(stack.toString()); // 见「脱敏」
|
||||
final extra = {...context, ...currentTags}; // storeId / roleCode / route / traceId / flavor
|
||||
|
||||
// ① Bugly:进崩溃率口径,堆栈按 SDK 的长度上限截断
|
||||
_native.postException(type, scrub(error.toString()), truncate(scrubbed), extra);
|
||||
|
||||
// ② 神策:进错误看板,属性可查询、可分组
|
||||
_analytics.track('app_error', {
|
||||
'errorType': type,
|
||||
'errorMessage': scrub(error.toString()),
|
||||
'stackTrace': truncate(scrubbed),
|
||||
'fatal': fatal,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**双写只在这个方法里发生,业务侧永远只调一次 `recordError`。**
|
||||
|
||||
### 两边报同一件事,怎么不打架
|
||||
|
||||
同一条 Dart 异常在 Bugly 和神策各有一份,两边数字对不上是必然的(采样、投递时机、去重规则都不同)。所以先把口径定死,**不要指望两个数字相等**:
|
||||
|
||||
| | Bugly | 神策 `app_error` |
|
||||
|---|---|---|
|
||||
| 看什么 | **崩溃率、稳定性趋势、版本对比** | **错误明细、按门店/角色/路由下钻、和业务事件关联** |
|
||||
| 权威口径 | ✅ 对外汇报稳定性用这个 | 参考 |
|
||||
| 强项 | 原生崩溃自动符号化、设备/系统分布 | 自定义属性可查询,能和 `api_failed`、`h5_failed` 放在同一套分析里 |
|
||||
|
||||
**"这个版本稳不稳"看 Bugly,"这个错误是怎么来的"看神策。** 任何一份周报里不要把两个数字并排放,会引出解释不清的问题。
|
||||
|
||||
### 神策上的错误看板要自己搭
|
||||
|
||||
神策不是崩溃平台,`app_error` 对它来说就是一个普通事件。所以下面这些是**必须自己做的二次开发**,排期里要留出来:
|
||||
|
||||
- **事件属性先在神策后台建好**(`errorType`、`errorMessage`、`stackTrace`、`fatal`、`route`、`traceId`)。属性没预先定义,上报上去会被丢弃或者变成不可分组的字符串。
|
||||
- **`stackTrace` 必须截断**。神策的字符串属性有长度上限,超长属性会被截断甚至导致整条事件入库失败。约定**只取前若干帧**(建议 20 帧)而不是按字符数硬切——按字符切会把最关键的顶层帧留下、底层调用链切没,反而是切错了方向。具体上限值以所用神策版本的文档为准(见待确认项)。
|
||||
- **看板**:错误数趋势、`errorType` TOP N、影响设备数、按 `appVersion` 对比、按 `storeId` 分布。
|
||||
- **告警**:新版本发布后错误数突增、某个 `errorType` 首次出现。神策的预警功能够用,不需要另做。
|
||||
|
||||
**`stackTrace` 不做去重聚合是这套方案最大的短板。** 崩溃平台会自动把同一个错误的不同实例归并成一个 issue,神策没有这个能力——只能靠 `errorType` + 堆栈首帧拼一个 `errorGroup` 属性自己分组。**这个 `errorGroup` 要在客户端算好再上报**,放到神策里用公式算不出来。
|
||||
|
||||
### 用户与门店上下文
|
||||
|
||||
会话状态变化时同步(见 [11-store-context-and-session.md](./11-store-context-and-session.md)):
|
||||
|
||||
```dart
|
||||
ErrorReporter.instance
|
||||
..setUser(session.user.userId) // 只传 ID,不传手机号/姓名
|
||||
..setTag('storeId', '${session.store.storeId}')
|
||||
..setTag('roleCode', session.user.roleCode)
|
||||
..setTag('flavor', env.flavorName);
|
||||
```
|
||||
|
||||
内部同时落到两侧:`setUser` 走 Bugly 的 `setUserId` 和神策的 `login`,`setTag` 走 Bugly 的 `putUserData` 和神策的超级属性。**这也是「一次调用、两处生效」只写在 `ErrorReporter` 里的原因**——散到调用点去写,迟早有一侧漏掉。
|
||||
|
||||
**`storeId` 一定要带。** 它能直接回答"这个崩溃是不是只发生在某几家门店"——门店设备型号和网络环境高度集中,很多崩溃是设备相关的,没有这个维度只能盲猜。
|
||||
|
||||
`traceId` 在网络相关的错误上报时作为自定义属性带上,这样一条崩溃能直接关联到后端 ELK 里的那次请求(见 [backend/08-observability.md](../backend/08-observability.md))。
|
||||
|
||||
### 崩溃前的页面路径
|
||||
|
||||
崩溃报告里最有用的上下文之一是"崩之前用户在哪几个页面"。go_router 的 `observers` 挂一个 `NavigationObserver`(见 [04-routing.md](./04-routing.md)),把最近的路由变化写进环形缓冲,随崩溃一起上报:
|
||||
|
||||
```dart
|
||||
class NavigationObserver extends NavigatorObserver {
|
||||
NavigationObserver(this._reporter);
|
||||
final ErrorReporter _reporter;
|
||||
|
||||
@override
|
||||
void didPush(Route route, Route? previous) =>
|
||||
_reporter.leaveBreadcrumb('nav: ${route.settings.name}');
|
||||
}
|
||||
```
|
||||
|
||||
**这里没有 SDK 自带的面包屑可用**,环形缓冲是我们自己的:Bugly 侧靠 `log()` 写进崩溃附加日志,神策侧作为 `app_error` 的 `breadcrumbs` 属性带上(同样要截断)。当前路由名另外单独作为 `route` 属性上报——它是错误看板里最常用的分组维度,埋在一段拼接文本里就没法分组了。
|
||||
|
||||
注意**记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据(见脱敏一节)。同理再补三处业务关键节点:H5 启动/失败、门店切换、扫码。这三条链路最长、最容易出问题。
|
||||
|
||||
### 上报什么、不上报什么
|
||||
|
||||
- **上报**:未捕获的 Dart 异常、原生崩溃、ANR、Riverpod provider 抛出的异常(通过 `ErrorObserver`,即使 UI 已经优雅处理了——"用户看到了漂亮的错误页"和"不需要知道有多少人看到"是两回事,见 12)。
|
||||
- **不上报**:`RequestCancelledException`(用户正常退出页面)、`UnauthorizedException`(正常的登出流程)。这两类是业务流程的一部分,上报只会把真正的崩溃淹掉。
|
||||
|
||||
### 验证接入真的成功了
|
||||
|
||||
崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为 App 很稳定。自己接三个捕获入口之后,这个风险比用现成 SDK 时更高,所以验证是硬要求:
|
||||
|
||||
- `core_logging` 暴露一个 `throwTestException()`,**只在 dev flavor 下可调**,每次发版前在 dev 上验证一遍 Android 和 iOS。
|
||||
- **三个入口要分别触发、分别验证**:build 期抛错(`FlutterError.onError`)、`Future` 里抛错不 catch(`runZonedGuarded`)、原生侧主动 crash(Bugly)。只测一个入口是通不过的——漏写的那个恰恰是没被测到的那个。
|
||||
- **每次验证都要确认两侧都收到了**:Bugly 后台有这条崩溃、神策里有对应的 `app_error` 事件。只对一侧就等于双写逻辑没验证。
|
||||
- **验证堆栈是不是可读的**——这一步比"能收到"更容易漏。打一个 release 包(按上文的 `--split-debug-info` 且**不带** `--obfuscate`)触发异常,确认上报回来的是 `OrderRepository.submit` 而不是 `_x12`。看到 `_x12` 就说明构建参数被改回去了。
|
||||
- uat 环境跑一个迭代后,对一下 Bugly 的错误数和神策里 `app_error` 的数量级,差得离谱说明有一侧漏了。
|
||||
|
||||
## 二、日志规范
|
||||
|
||||
### `AppLogger`
|
||||
|
||||
```dart
|
||||
// packages/core_logging/lib/src/app_logger.dart
|
||||
abstract interface class AppLogger {
|
||||
void d(String message, {Map<String, Object?>? data});
|
||||
void i(String message, {Map<String, Object?>? data});
|
||||
void w(String message, {Object? error, StackTrace? stackTrace});
|
||||
void e(String message, {Object? error, StackTrace? stackTrace});
|
||||
}
|
||||
```
|
||||
|
||||
各包**不直接用 `logger` 包,也不用 `print`/`debugPrint`**,统一注入 `AppLogger`。理由:将来换日志实现只改一处;同时 `print` 在 release 下不会被剥离,是一条实打实的信息泄漏通道。
|
||||
|
||||
### 级别与环境
|
||||
|
||||
| 环境 | 级别 | 输出 |
|
||||
|---|---|---|
|
||||
| dev | `debug` | 控制台,带颜色和调用栈 |
|
||||
| uat | `info` | 控制台 + 内存环形缓冲(最近 500 条) |
|
||||
| prod | `warning` | **不输出到控制台**,只进内存环形缓冲 + 随崩溃上报 |
|
||||
|
||||
**prod 不打控制台日志**:Android 上 `logcat` 是全局可读的,任何装了 adb 或第三方日志 App 的人都能看到。门店设备上这不是理论风险。
|
||||
|
||||
**内存环形缓冲**的作用是:崩溃时把最近 N 条日志一起传上去,相当于一个"黑匣子"。不落磁盘,App 退出即消失,避免日志文件成为新的泄漏面。实现上由 `ErrorReporter.recordError` 在上报时取出,Bugly 侧走 `log()` 写进崩溃附加日志、神策侧作为事件属性带上;两边都有长度上限,所以实际带的是**最近 30 条**,不是全部 500 条。
|
||||
|
||||
### 脱敏(PRD REQ-NFR-019 日志脱敏)
|
||||
|
||||
```dart
|
||||
// packages/core_logging/lib/src/scrubber.dart
|
||||
const _sensitiveKeys = {
|
||||
'token', 'accessToken', 'refreshToken', 'ticket', 'password',
|
||||
'code', // 短信验证码
|
||||
'phone', 'mobile', 'idCard', 'bankCard',
|
||||
};
|
||||
|
||||
String maskPhone(String v) => v.length >= 11 ? '${v.substring(0, 3)}****${v.substring(7)}' : '***';
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **请求/响应体不整体打日志**。只打 method、path、状态码、耗时、`code`、`traceId`。真要看 body 只在 dev 下开,且过一遍脱敏器。
|
||||
- **`Authorization` 头永远不打**,一个字符都不打——打前 8 位也不行,那既足够辅助暴力破解,也足够在日志里认出是谁的 token。
|
||||
- **H5 URL 打日志前必须去掉 query**:URL 里带着 `ticket`,整条打出去等于打 token。
|
||||
- 崩溃上报前再做一遍同样的脱敏——环形缓冲里的日志、异常 message 和堆栈都会随崩溃一起传上去。
|
||||
|
||||
**脱敏必须做在 `ErrorReporter` 的出口,不是各调用点。** 自己接上报有一个隐蔽的好处:没有任何 SDK 会背着我们自动记录 HTTP 面包屑,我们在 05/10 里保证的"URL 不落日志"不会被某个默认行为绕过去。但反过来,**凡是要送出去的东西都得自己过一遍脱敏器**,没有 SDK 的钩子兜底:
|
||||
|
||||
```dart
|
||||
// packages/core_logging/lib/src/scrubber.dart
|
||||
/// 异常 message 与堆栈里同样可能出现 URL、手机号、车牌
|
||||
String scrub(String raw) => raw
|
||||
.replaceAllMapped(_urlPattern, (m) => Uri.parse(m[0]!).replace(query: '').toString())
|
||||
.replaceAllMapped(_phonePattern, (m) => maskPhone(m[0]!));
|
||||
```
|
||||
|
||||
最容易漏的是**异常 message 本身**:`DioException` 的 `toString()` 里带着完整 URL(含 `ticket`),原样上报等于把 token 发出去。`recordError` 里对 `error.toString()` 和 `stack.toString()` 都要跑一遍 `scrub`,见上文的实现。
|
||||
|
||||
### 不采集什么
|
||||
|
||||
出于合规(个人信息保护法「最小必要」原则)和 PRD REQ-NFR-019:
|
||||
|
||||
| 项 | 结论 |
|
||||
|---|---|
|
||||
| 崩溃截图 / 页面录制 / 控件树 | **不采集**。收银、经营分析页面上有金额和客户信息 |
|
||||
| 精确位置 | 不采集。App 没有需要精确位置的功能 |
|
||||
| IMEI / IDFA / MAC / AndroidID | **不采集**(见 05,`X-Device-Id` 用的是匿名安装 UUID)。**神策原生 SDK 默认会采集设备标识来生成 `distinct_id`,必须在初始化时逐项关掉**;**Bugly 默认也会采集设备信息,同样要逐项过一遍并按隐私政策裁剪** |
|
||||
| 通讯录、短信 | 不申请权限 |
|
||||
| 用户输入的原文 | 不打日志(包括搜索关键词里可能出现的车牌、手机号) |
|
||||
|
||||
这份清单要和 App 的隐私政策(PRD REQ-LGN-005,由 App Backend 下发)**逐条对齐**——隐私政策里没写的,代码里就不能采。**第三方 SDK 的默认采集行为是最容易在合规审查时出问题的地方**:神策和 Bugly 的隐私说明都要单独过一遍,并且要在**用户同意隐私政策之前不初始化**,否则「同意前不采集」这条硬要求就破了。
|
||||
|
||||
**Bugly 这一条要特别注意**:它在原生侧初始化(见上文 `native_crash`),"延迟到同意之后"不是 Dart 里加个 if 就行的——原生侧要读一个本地标记位来决定初不初始化,标记位由 Dart 在用户同意后写入并持久化。**这个标记位一旦漏写,表现是"合规过不了"而不是"崩溃收不到",测不出来。**
|
||||
|
||||
## 三、埋点:以后端为主
|
||||
|
||||
**大部分业务埋点由后端从自己的请求日志和审计日志里出,客户端不重复做一遍。**
|
||||
|
||||
理由很直接:任何一个业务动作(登录、切店、下单、入库、打开 H5)都会打到 App Backend 的接口上,后端已经有 `traceId`、用户上下文、门店上下文和 `@Audited` 审计通道(见 [backend/08-observability.md](../backend/08-observability.md))。客户端再报一遍,得到的是同一件事的两份数据——而且客户端那份还更不可靠(可能丢、可能延迟、可能被篡改)。
|
||||
|
||||
**这两份数据最好落到同一个地方。** 神策有服务端 SDK / 数据导入接口,后端把业务事件写进同一个神策项目的话,运营就能做「扫码失败的门店,后续下单转化率是不是更低」这种跨端漏斗;分成两套系统也能跑,但每次跨端分析都要人工对数。**这一条要和后端确认**,见待确认项。
|
||||
|
||||
### Metabase 不是神策的替代品
|
||||
|
||||
后端侧提到过 Metabase。**它和神策不冲突,也不是二选一**——两者根本不在一层:
|
||||
|
||||
| | 神策 | Metabase |
|
||||
|---|---|---|
|
||||
| 客户端采集 SDK | **有** | **没有**,它不采集任何数据 |
|
||||
| 数据来源 | 自己的 SDK / 服务端导入 | 接已有的数据库、数仓 |
|
||||
| 定位 | 采集 + 管道 + 分析平台 | BI / 看板层 |
|
||||
|
||||
Metabase 官网自己把 Mixpanel、PostHog、Amplitude 列为**上游集成**——由那些工具负责采集,Metabase 在导出的数据上出图。这就说明了它的位置。
|
||||
|
||||
所以合理的分工是:**神策收客户端事件;后端的业务埋点本来就在自己库里,Metabase 接上去出报表。** 后端如果已经在用 Metabase,那是个好消息而不是冲突信号——它意味着上面「两份数据落到一个地方」这条有了第二种解法:不把业务数据推进神策,而是反过来把神策的客户端事件导出到同一个库,用 Metabase 统一出图,还能直接和订单、门店主数据 join。哪一种更合适取决于后端的数仓现状,一并列进待确认项。
|
||||
|
||||
### 分工
|
||||
|
||||
| 埋点事件 | 谁来出 | 说明 |
|
||||
|---|---|---|
|
||||
| 登录成功/失败 | **后端** | 登录本身就是接口调用 |
|
||||
| 首页曝光 | **后端** | 首页聚合接口的调用即曝光 |
|
||||
| 门店切换 | **后端** | 切换接口 |
|
||||
| 采购下单 | **后端** | |
|
||||
| 入库成功 | **后端** | |
|
||||
| 待办点击 | **后端** | 点击后会请求详情接口 |
|
||||
| **扫码成功/失败** | **客户端** | 扫码是 App 原生实现(见 07),**不产生任何请求**,后端完全看不到 |
|
||||
| **H5 关闭 / 异常** | **客户端** | 「打开」有 `/h5/launch` 请求后端能看到;**关闭、白屏、超时、加载失败后端看不到** |
|
||||
| 客服点击 | **客户端** | 拨号、企微二维码是纯客户端行为 |
|
||||
|
||||
**客户端埋点的判据只有一条:这件事会不会产生一次后端请求?不会,才由客户端上报。**
|
||||
|
||||
### 客户端事件表
|
||||
|
||||
按上面的判据筛下来,客户端只需要这几个:
|
||||
|
||||
| 事件 | 触发 | 关键参数 |
|
||||
|---|---|---|
|
||||
| `scan_succeeded` / `scan_failed` | 扫码结果 | `mode`(barcode/vin/plate)、`durationMs`、`failReason` |
|
||||
| `h5_closed` | H5 页关闭 | `target`、`stayDurationMs` |
|
||||
| `h5_failed` | 白屏 / 超时 / 加载失败 | `target`、`errorCode`、`elapsedMs`、`traceId` |
|
||||
| `h5_first_paint` | H5 首屏完成 | `target`、`ticketMs`(换票耗时)、`loadMs`(页面加载耗时) |
|
||||
| `support_clicked` | 客服入口点击 | `channel`(hotline/dealer/o2o) |
|
||||
| `api_failed` | 请求失败 | `path`、`code`、`httpStatus`、`traceId` |
|
||||
| `app_cold_start` | 冷启动完成 | `durationMs` |
|
||||
| `logout` | 登出 | `reason`(userInitiated / tokenExpired / sessionRevoked)。**被动登出没有对应的接口调用**,见 [11](./11-store-context-and-session.md) |
|
||||
| `session_restore_failed` | 冷启动恢复会话失败 | 失败阶段(读 storage / me / stores)。卡在读 secure storage 时不产生任何网络请求 |
|
||||
|
||||
两条说明:
|
||||
|
||||
- **`h5_first_paint` 必须把耗时拆成 `ticketMs` 和 `loadMs` 两段**。合成一个数字的话,慢了不知道该找 App Backend / F6 / 还是网络——这是这个 App 里最长的一条跨系统链路,也是最容易互相甩锅的地方。
|
||||
- **`api_failed` 客户端也要报**,虽然后端也能看到失败。因为**后端看不到"请求根本没发出去"和"响应没收到"**:超时、连接失败、DNS 失败、运营商劫持,这些在后端日志里要么完全没有记录,要么表现为一次正常的成功响应。门店网络不稳时这类失败占大头。
|
||||
|
||||
### 实现约定:神策 SDK,外面包一层
|
||||
|
||||
客户端埋点走**神策 `sensors_analytics_flutter_plugin`**:官方 verified publisher `sensorsdata.cn`,`4.2.3` 一个多月前发布,是当前维护中的官方插件——这在 pub.dev 上的国内三方 SDK 里不多见。**团队过往项目用过,事件模型和数据接入的坑踩过一遍**,这是选它最实在的理由。
|
||||
|
||||
**注意本项目里神策承担了两件事**:客户端行为埋点(本节),以及上文的错误明细与看板(`app_error` 事件)。后者是二次开发出来的,不是神策的现成能力——**排期要按"两块工作"算**。
|
||||
|
||||
#### 神策不是"开箱即用",这些活一样要干
|
||||
|
||||
先把预期摆正,否则排期一定会低估。**接了神策之后,下面这些工作量和自建一套上报是完全一样的**:
|
||||
|
||||
- **事件方案设计**——事件名、属性、口径对齐。这才是埋点的大头,跟用什么 SDK 无关。
|
||||
- **`core_analytics` 的接口封装**(见下)。
|
||||
- **接入点的编排**——超级属性什么时候注册、切店后重注册、登录/登出的 ID 关联。神策给了 API,但在哪调是我们的事(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。
|
||||
- **私有化部署的运维**(如果走私有化)。
|
||||
|
||||
**神策真正替我们省掉的只有一件具体的事:客户端的可靠投递。** 原生 SDK 自带本地缓存、批量上报、弱网重传、后台 flush 和进程被杀后的补发。门店网络不稳,这个模块不能省,自己写的话是**容易写得看起来对、实际在丢数据**的那一类——丢了还不会有人发现。这一条就是选现成 SDK 的全部收益,其余都要照做。
|
||||
|
||||
#### 依赖与封装
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
sensors_analytics_flutter_plugin: ^4.2.3
|
||||
```
|
||||
|
||||
业务代码仍然只见 `core_analytics` 的接口,不直接 import 神策:
|
||||
|
||||
```dart
|
||||
// packages/core_analytics/lib/src/analytics.dart
|
||||
abstract interface class Analytics {
|
||||
void track(String event, [Map<String, Object?> params = const {}]);
|
||||
void registerSuperProperties(Map<String, Object?> props); // 公共属性,注册一次全局附加
|
||||
void identify(String userId); // 登录成功后调
|
||||
void reset(); // 登出时调
|
||||
}
|
||||
```
|
||||
|
||||
理由和 `ErrorReporter` 一样:测试里能 mock,`feature_*` 不多一条对三方 SDK 的直接依赖。**另外它也是神策不可用时的缓冲**——接口先定、事件方案先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件(代价就是上面那条可靠投递要自己补,而且错误看板要跟着换地方)。
|
||||
|
||||
接入约定:
|
||||
|
||||
- **公共属性用「超级属性」注册一次,不在每个调用点手写**:`storeId`、`roleCode`、`flavor`、`appVersion`、`buildNumber`。`storeId` 尤其重要——运营侧几乎所有分析都按门店维度看,靠每个调用点自己传一定会漏。**门店切换后必须重新注册**(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。
|
||||
- **登录/登出走 `login()` / `logout()`**:登录成功后用后端的 `userId` 关联匿名 ID,登出时断开,否则同一台设备上换人登录的数据会串到一起(门店设备是共用的,这个场景一定会发生)。
|
||||
- **埋点失败绝不能影响业务**:`track()` 内部 try-catch 兜住,任何异常只记日志不外抛。
|
||||
- **dev/uat 与 prod 必须分开**——独立项目,或至少用不同的数据接收地址。共用一个项目的话,测试数据会直接污染运营报表,且事后无法剔除。
|
||||
|
||||
#### 全埋点(AutoTrack):只开启动/退出,其余关掉
|
||||
|
||||
神策的全埋点支持 `APP_START` / `APP_END` / `APP_CLICK` / `APP_VIEW_SCREEN` 四类。我们的结论:
|
||||
|
||||
| 类型 | 结论 |
|
||||
|---|---|
|
||||
| `APP_START` / `APP_END` | **开**。启动次数、使用时长是零成本拿到的基础指标 |
|
||||
| `APP_CLICK` | **关**。Flutter 的控件树没有原生 `id`/`resource-name`,采上来的元素标识基本不可读,是纯噪音 |
|
||||
| `APP_VIEW_SCREEN` | **关**。改用我们自己的 `NavigationObserver` 上报路由名——既更准,也**避免把 `/webview?target=X&ticket=...` 整条 URL 采上去**(见脱敏一节) |
|
||||
|
||||
“少采一点”在这里不是保守,是因为**采上来读不懂的数据比没有更糟**:它会让报表看起来有数据,实际没法用。
|
||||
|
||||
#### H5 内部的埋点不归我们
|
||||
|
||||
F6 的 H5 页面是外部系统,页面内部的行为埋点由 F6 自己负责。**客户端只报容器级事件**(打开/关闭/失败/首屏耗时),不往 WebView 里注入神策的 JS SDK——注进去就等于我们要为别人页面里的数据质量负责,而且 JSBridge 的能力清单([10-webview-h5.md](./10-webview-h5.md) 的 13 项)里也没有埋点这一项。
|
||||
|
||||
### 命名约定
|
||||
|
||||
`snake_case`,`对象_动作` 或 `对象_动作_结果`。结果类用过去式(`succeeded`/`failed`),动作类用现在式(`clicked`)。
|
||||
|
||||
事件名和参数名一旦上线**不再改**——改名意味着历史数据断裂,运营报表要重做。要加维度就加参数。事件名统一定义为常量(`AnalyticsEvent.scanSucceeded`),不允许在调用处写字符串字面量:拼写错误编译期发现不了,在报表里表现为"这个事件怎么没数据"。
|
||||
|
||||
## 四、性能指标
|
||||
|
||||
| 指标 | 怎么测 | 目标 |
|
||||
|---|---|---|
|
||||
| 冷启动到首帧 | `WidgetsBinding.instance.addTimingsCallback` | < 2s |
|
||||
| 冷启动到首页可用 | `main()` → 首页数据渲染完成 | < 3s |
|
||||
| H5 打开耗时 | `h5_first_paint` 的 `ticketMs + loadMs` | < 3s(PRD REQ-NFR-005 要求定义 H5 白屏超时阈值) |
|
||||
| 接口耗时 | 后端侧统计即可,客户端不重复报 | P95 < 1s |
|
||||
| 帧率 | 先不做自动采集,用 DevTools 人工测关键页面 | — |
|
||||
|
||||
**接口耗时不由客户端报**:后端有完整的请求日志和 Micrometer 指标(见 backend/08)。客户端唯一能补充的是"客户端观测到的耗时 - 服务端处理耗时 = 网络耗时",这个差值有价值但不是首版必须,先不做。
|
||||
|
||||
## 五、和后端审计日志的分工
|
||||
|
||||
`backend/08-observability.md` 已经有 `@Audited` 审计日志通道。**审计以服务端为准,客户端不做审计**——客户端日志可被篡改,不能作为审计依据。
|
||||
|
||||
| | 客户端埋点 | 服务端日志/审计 |
|
||||
|---|---|---|
|
||||
| 目的 | 补齐后端看不到的行为 | 业务分析、合规追溯 |
|
||||
| 可信度 | 参考 | 权威 |
|
||||
| 覆盖 | 纯客户端行为、请求失败 | 所有到达服务端的操作 |
|
||||
|
||||
## 待确认项
|
||||
|
||||
- **release 到底混不混淆**——本篇最需要拍板的一条。本文的决策是**不混淆**(只 `--split-debug-info`),换来 Dart 堆栈直接可读;要和安全侧确认能否接受 Dart 符号暴露。**若安全侧坚持混淆,就要接受每条 Dart 崩溃人工 `flutter symbolize` 的排查成本**,[08-build-flavors.md](./08-build-flavors.md) 的构建参数需一并改回。
|
||||
- **Bugly 的 appId / appKey 与项目划分**:dev/uat/prod 是三个 Bugly 产品还是一个产品靠版本号区分(建议 prod 单独一个,避免测试崩溃污染线上崩溃率)。
|
||||
- **神策的数据接收地址与项目划分**:私有化部署还是神策云,dev/uat/prod 怎么分。地址走 `--dart-define-from-file`,代码里不写死。
|
||||
- **神策字符串属性的实际长度上限**(决定 `stackTrace` 截多少帧),以所用神策版本的官方文档为准,接入时实测确认。
|
||||
- **`errorGroup` 的分组算法**:`errorType` + 堆栈首帧够不够用,还是要按包名过滤掉框架帧再取第一条业务帧。这一条直接决定错误看板可不可用,建议接入后拿真实数据调一轮。
|
||||
- 神策原生 SDK 与 Bugly SDK 的默认设备信息采集项(`distinct_id` 生成方式、是否取 AndroidID/IDFA),需逐项关闭并与隐私政策对齐(法务侧)。
|
||||
- 两个 SDK 都要在**用户同意隐私政策之后**才初始化。Bugly 在原生侧初始化,需要一个由 Dart 写入、原生读取的持久化标记位,具体时机要和 `feature_auth` 的协议弹窗流程对齐。
|
||||
- **客户端事件和后端业务数据怎么汇到一起**:是后端用神策服务端 SDK 写进同一个神策项目,还是把神策的客户端事件导出到后端数仓、统一用 Metabase 出图。取决于后端数仓现状和 Metabase 的实际使用情况,要和后端一起定。
|
||||
- **后端的业务埋点写不写进同一个神策项目**(用神策服务端 SDK / 数据导入),还是留在自己的 ELK 里出报表。影响的是能不能做跨端漏斗分析。
|
||||
- 后端从请求日志出业务埋点的具体口径(哪个接口对应哪个事件),需要和后端一起把埋点事件逐条落到接口上。
|
||||
- 性能指标目标值需在真机(门店常用的中低端 Android)实测后校准,上表是初始预期值。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [腾讯 Bugly](https://bugly.qq.com/)(崩溃上报,原生侧)
|
||||
- [Bugly Android SDK 接入文档](https://bugly.qq.com/docs/user-guide/instruction-manual-android/)
|
||||
- [Bugly iOS SDK 接入文档](https://bugly.qq.com/docs/user-guide/instruction-manual-ios/)
|
||||
- [sensors_analytics_flutter_plugin | Dart package](https://pub.dev/packages/sensors_analytics_flutter_plugin)
|
||||
- [神策:Flutter 插件集成文档](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_plugin/v0300)
|
||||
- [神策:Flutter 全埋点](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_auto_track/v0205)
|
||||
- [Metabase](https://www.metabase.com/)(BI 层,非采集方案;官网把 Mixpanel/PostHog/Amplitude 列为上游采集集成)
|
||||
- [Flutter: 混淆与 `flutter symbolize`](https://docs.flutter.dev/deployment/obfuscate)
|
||||
- [Flutter: 错误处理(`FlutterError.onError` / `PlatformDispatcher.onError`)](https://docs.flutter.dev/testing/errors)
|
||||
- [logger | Dart package](https://pub.dev/packages/logger)
|
||||
- [backend/08-observability.md:traceId 与审计日志](../backend/08-observability.md)
|
||||
- [PRD 第 6.8 节 埋点 / 第 8.3 节 可用性与容错](../prd/Continental-Retail-APP-PRD.md)
|
||||
@@ -0,0 +1,289 @@
|
||||
# 14. 工程规范与 CI 门禁
|
||||
|
||||
## 为什么单独一篇
|
||||
|
||||
这一篇是**建项目当天就要用上**的东西:lint 配置、格式化、生成产物是否入库、分支和提交规范、CI 卡什么。这些规则本身不难,难的是"没有在第一天定下来"——等到有 10 个人各写各的风格再统一,成本是第一天的几十倍。
|
||||
|
||||
## 一、SDK 版本锁定
|
||||
|
||||
```
|
||||
# .fvmrc(仓库根目录,入库)
|
||||
{ "flutter": "3.44.9" }
|
||||
```
|
||||
|
||||
所有人用 [FVM](https://fvm.app/) 装同一个版本,命令统一走 `fvm flutter ...`。理由见 [01-project-structure.md](./01-project-structure.md):monorepo 里 SDK 版本不一致会导致 `.dart_tool` 反复重建、生成代码差异、以及"我这跑得好好的"这类无法复现的问题。CI 也用 FVM 装同一版本,保证本地和 CI 完全一致。
|
||||
|
||||
`flutter --version` 的实测 Dart 版本要写进 README,因为 `environment.sdk` 的约束以它为准。
|
||||
|
||||
## 二、静态分析
|
||||
|
||||
### 选 `flutter_lints`,不选 `very_good_analysis`
|
||||
|
||||
| | `flutter_lints` 6.0.0 | `very_good_analysis` 10.3.0 |
|
||||
|---|---|---|
|
||||
| 维护方 | **Flutter 官方** | Very Good Ventures |
|
||||
| 规则数量 | 适中,只收官方认为普遍适用的 | 非常多,包含大量风格约束 |
|
||||
| 跟随 SDK | 随 Flutter 版本同步更新 | 独立节奏 |
|
||||
|
||||
`very_good_analysis` 更严格,但它在一个新项目上开箱会产生**成百上千条 warning**,其中很大一部分是纯风格问题(比如强制所有 public API 写文档注释、强制 `final` 局部变量)。团队的第一反应必然是批量 `// ignore:` 或者在 `analysis_options.yaml` 里关掉一半规则——最后既没享受到严格的好处,还多了一层配置负担。
|
||||
|
||||
**结论:以 `flutter_lints` 为底,手动加一小组"能抓真 bug"的规则,而不是"管风格"的规则。**
|
||||
|
||||
### 根级共享配置
|
||||
|
||||
```yaml
|
||||
# analysis_options.yaml(仓库根目录)
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
language:
|
||||
strict-casts: true # 禁止 dynamic 隐式转型——最容易藏 bug 的一条
|
||||
strict-raw-types: true # 禁止裸 List/Map,逼着写类型参数
|
||||
strict-inference: true
|
||||
errors:
|
||||
invalid_annotation_target: ignore # json_serializable + 注解组合会误报
|
||||
# 下面几条从 warning 提到 error,即 CI 直接失败
|
||||
unused_import: error
|
||||
dead_code: error
|
||||
unawaited_futures: error
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
- "**/generated/**" # pigeon 生成产物,见 07
|
||||
plugins:
|
||||
- custom_lint # riverpod_lint,见 03
|
||||
|
||||
formatter:
|
||||
page_width: 100
|
||||
|
||||
linter:
|
||||
rules:
|
||||
# —— 能抓真 bug 的 ——
|
||||
- always_declare_return_types
|
||||
- avoid_dynamic_calls
|
||||
- avoid_slow_async_io
|
||||
- cancel_subscriptions # StreamSubscription 忘了 cancel 是常见内存泄漏
|
||||
- close_sinks
|
||||
- discarded_futures # 忘了 await 的异步调用
|
||||
- unawaited_futures
|
||||
- no_adjacent_strings_in_list # 少写一个逗号导致字符串被拼接
|
||||
- test_types_in_equals
|
||||
- throw_in_finally
|
||||
- unnecessary_statements
|
||||
# —— 团队约定 ——
|
||||
- prefer_single_quotes
|
||||
- require_trailing_commas # 配合 formatter,diff 更干净
|
||||
- directives_ordering
|
||||
- sort_pub_dependencies
|
||||
```
|
||||
|
||||
各包的 `analysis_options.yaml` 只写一行继承,不允许在包级关规则(要关就在根上关,让所有人都看得见):
|
||||
|
||||
```yaml
|
||||
# packages/feature_xxx/analysis_options.yaml
|
||||
include: ../../analysis_options.yaml
|
||||
```
|
||||
|
||||
### `strict-casts` 值得单独说
|
||||
|
||||
它是这份配置里**唯一一条会真的挡住线上 bug** 的开关。没有它,`jsonDecode(...)` 返回的 `dynamic` 可以隐式赋给任何类型,类型错误要到运行时才炸;开了之后必须显式 `as Map<String, dynamic>`,写的人会被迫想一下"这里到底是什么类型"。
|
||||
|
||||
代价是接手 JSON 解析时要多写一些 `as`。这个代价值得付。
|
||||
|
||||
### `custom_lint` 在 workspace 下的接法
|
||||
|
||||
`riverpod_lint`(见 [03-state-management.md](./03-state-management.md))通过 `custom_lint` 插件运行。在 pub workspace 下:
|
||||
|
||||
- `custom_lint` 和 `riverpod_lint` 加在**根 `pubspec.yaml` 的 `dev_dependencies`**(workspace 共享)。
|
||||
- 检查命令是 `dart run custom_lint`,**它不包含在 `flutter analyze` 里**——两条命令都要跑,CI 里是两个独立步骤。这一点很多人不知道,结果 riverpod_lint 装了但从来没生效过。
|
||||
|
||||
## 三、格式化
|
||||
|
||||
```bash
|
||||
dart format --set-exit-if-changed --line-length 100 .
|
||||
```
|
||||
|
||||
- **行宽 100,不是默认的 80。** Dart 3.9 起可以写在 `analysis_options.yaml` 的 `formatter: page_width:` 里(上面已配),命令行参数是给 CI 用的双保险。80 在 Flutter 的 widget 嵌套下换行过于频繁,一个三层嵌套的 `Column` 就能占满整屏。100 是一个在宽屏和可读性之间比较平衡的值。
|
||||
- **不允许手动排版**。`dart format` 的结果就是唯一正确的结果,不接受"我觉得这样更好看"。省下的是每次 review 里关于换行的争论。
|
||||
- CI 用 `--set-exit-if-changed` 卡死。
|
||||
|
||||
## 四、生成产物是否入库
|
||||
|
||||
**这是一个必须明确的二选一,模糊处理会导致仓库里一半入库一半不入库。**
|
||||
|
||||
| 类型 | 结论 | 理由 |
|
||||
|---|---|---|
|
||||
| `*.g.dart`(riverpod / json_serializable / drift) | **不入库** | 这类文件改动频繁且巨大,几乎每个 PR 都会产生冲突,而冲突的正确解法永远是"重新生成"——那入库就没有意义。加进 `.gitignore` |
|
||||
| pigeon 生成产物(Dart + Kotlin + Swift) | **入库** | 见 [07-native-integration.md](./07-native-integration.md)。原生侧的 Kotlin/Swift 文件要被 Gradle/Xcode 编译,而**这两条工具链不会跑 `build_runner`**。不入库的话原生构建直接失败 |
|
||||
| `pubspec.lock` | 根目录**入库**,各 package 的**不入库** | workspace 模式下只有根 lock 生效 |
|
||||
|
||||
不入库 `.g.dart` 的代价是:**新克隆仓库后必须先跑一次生成,否则 IDE 满屏报错**。所以:
|
||||
|
||||
```yaml
|
||||
# 根 pubspec.yaml 的 melos scripts
|
||||
gen:
|
||||
run: melos exec --depends-on=build_runner -- dart run build_runner build --delete-conflicting-outputs
|
||||
gen:watch:
|
||||
run: melos exec --depends-on=build_runner -- dart run build_runner watch --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
README 的"第一次跑起来"步骤必须是:`fvm flutter pub get` → `melos run gen` → `fvm flutter run`。**少写这一步,每个新人入职第一天都会卡住。**
|
||||
|
||||
CI 在 analyze 之前必须先 `melos run gen`。
|
||||
|
||||
**pigeon 产物入库需要一道防腐**:CI 里重新生成后 `git diff --exit-code`,确保有人改了 schema 但忘了提交生成结果时流水线会红(见 07)。
|
||||
|
||||
## 五、分支与提交
|
||||
|
||||
### 分支
|
||||
|
||||
```
|
||||
main ← 生产,只接受来自 release/* 和 hotfix/* 的合并,打 tag 出包
|
||||
develop ← 集成,日常合并目标
|
||||
feature/<jira-id>-<短描述>
|
||||
fix/<jira-id>-<短描述>
|
||||
release/<version>
|
||||
hotfix/<version>
|
||||
```
|
||||
|
||||
`main`/`develop` **保护分支,禁止直接 push**,只能通过 MR 合入。
|
||||
|
||||
### 提交信息
|
||||
|
||||
用 [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(feature_purchase): 支持采购单批量提交
|
||||
fix(core_network): 修复 401 并发刷新导致全端登出
|
||||
docs(05): 补充上传失败重传约定
|
||||
chore(deps): 升级 drift 到 2.34.5
|
||||
```
|
||||
|
||||
`scope` 用**包名**(`feature_purchase`、`core_network`)或文档编号。monorepo 里没有 scope 的提交信息基本等于没有信息——`fix: 修复崩溃` 在半年后完全无法定位。
|
||||
|
||||
不引入自动化的 changelog 生成(首版没这个需求),但格式先立住,将来要加成本为零。
|
||||
|
||||
### MR 规范
|
||||
|
||||
- MR 标题同 commit 规范。
|
||||
- 描述里必须有:**改了什么**、**为什么**、**怎么验证的**。
|
||||
- **一个 MR 只做一件事。** 顺手格式化半个仓库的 MR 直接打回——它会让 review 变成不可能。
|
||||
- 至少 1 人 approve。涉及 `core_*` 的改动需要 2 人(这些包被所有 feature 依赖,改错影响面最大)。
|
||||
|
||||
## 六、CI 门禁
|
||||
|
||||
```yaml
|
||||
# .gitlab-ci.yml(App 部分,与 08-build-flavors.md 的构建 job 拼在一起)
|
||||
stages: [setup, verify, test, build]
|
||||
|
||||
.flutter_base: &flutter_base
|
||||
image: <内部 flutter 镜像,预装 FVM 3.44.9>
|
||||
before_script:
|
||||
- fvm flutter --version
|
||||
- dart pub global activate melos 8.2.2
|
||||
- melos bootstrap
|
||||
- melos run gen # 生成产物不入库,必须先生成
|
||||
cache:
|
||||
key: "$CI_COMMIT_REF_SLUG"
|
||||
paths: [.dart_tool/, .pub-cache/]
|
||||
|
||||
format:
|
||||
<<: *flutter_base
|
||||
stage: verify
|
||||
script: dart format --set-exit-if-changed --line-length 100 .
|
||||
|
||||
analyze:
|
||||
<<: *flutter_base
|
||||
stage: verify
|
||||
script:
|
||||
- melos exec -- fvm flutter analyze --fatal-infos
|
||||
- dart run custom_lint # riverpod_lint,analyze 不含它
|
||||
|
||||
pigeon_check:
|
||||
<<: *flutter_base
|
||||
stage: verify
|
||||
script:
|
||||
- melos run gen:pigeon
|
||||
- git diff --exit-code || (echo "pigeon 生成产物未提交" && exit 1)
|
||||
|
||||
test:
|
||||
<<: *flutter_base
|
||||
stage: test
|
||||
script:
|
||||
- melos run test
|
||||
- melos run coverage # 阈值 60%,见 09
|
||||
coverage: '/lines\.*: \d+\.\d+\%/'
|
||||
artifacts:
|
||||
paths: [coverage/]
|
||||
reports: { coverage_report: { coverage_format: cobertura, path: coverage/cobertura.xml } }
|
||||
```
|
||||
|
||||
### 门禁清单
|
||||
|
||||
| 检查 | 卡点 | 说明 |
|
||||
|---|---|---|
|
||||
| `dart format` | **阻断** | |
|
||||
| `flutter analyze --fatal-infos` | **阻断** | `--fatal-infos` 让 info 级别也算失败,否则 lint 规则形同虚设 |
|
||||
| `dart run custom_lint` | **阻断** | |
|
||||
| pigeon 产物一致性 | **阻断** | |
|
||||
| 单元测试 + Widget 测试 | **阻断** | |
|
||||
| 覆盖率 ≥ 60% | **阻断** | 见 [09-testing.md](./09-testing.md) |
|
||||
| 集成测试 | **不卡 MR**,只在合入 develop/main 时跑 | 慢,见 09 |
|
||||
| Android release 构建 | 只在 tag 上跑 | 见 [08-build-flavors.md](./08-build-flavors.md) |
|
||||
|
||||
`--fatal-infos` 值得强调:不加这个参数,`flutter analyze` 对 info 级别的问题只是打印一下就返回 0,CI 永远绿。半年后仓库里会积累几百条 info,然后没人再看 analyze 的输出。
|
||||
|
||||
### 关于 `melos bootstrap` 的缓存
|
||||
|
||||
`.pub-cache` 必须缓存,否则每次 CI 都要重新下载所有依赖,一个 monorepo 下来是几分钟。缓存 key 用 `$CI_COMMIT_REF_SLUG`(按分支),并配一个按 `pubspec.yaml` 哈希的 fallback key。
|
||||
|
||||
## 七、本地钩子(可选但推荐)
|
||||
|
||||
```yaml
|
||||
# lefthook.yml
|
||||
pre-commit:
|
||||
parallel: true
|
||||
commands:
|
||||
format:
|
||||
glob: "*.dart"
|
||||
run: dart format --line-length 100 {staged_files} && git add {staged_files}
|
||||
analyze:
|
||||
glob: "*.dart"
|
||||
run: fvm flutter analyze --fatal-infos {staged_files}
|
||||
```
|
||||
|
||||
**只跑 format 和 analyze,不跑测试。** pre-commit 跑测试会让每次提交等几十秒,人的第一反应是 `--no-verify`,钩子就废了。测试留给 CI。
|
||||
|
||||
钩子是**建议不是强制**——CI 才是真正的门禁。钩子的价值只是让人少推一次红色流水线。
|
||||
|
||||
## 八、目录与命名速查
|
||||
|
||||
| 项 | 约定 |
|
||||
|---|---|
|
||||
| 包名 / 目录 / 文件 | `snake_case` |
|
||||
| 类 / enum | `UpperCamelCase` |
|
||||
| 变量 / 方法 | `lowerCamelCase`,私有加 `_` |
|
||||
| 常量 | `lowerCamelCase`(Dart 惯例,不是 `SCREAMING_CASE`) |
|
||||
| 文件名 | 与主类名对应:`OrderListPage` → `order_list_page.dart` |
|
||||
| provider | `xxxProvider`,由 `@riverpod` 生成,不手写 |
|
||||
| 测试文件 | `<被测文件>_test.dart`,目录镜像 `lib/src/` |
|
||||
| 包的公共 API | 只从 `lib/<package>.dart` 导出,`lib/src/` 下的一律视为私有(见 01) |
|
||||
|
||||
import 顺序由 `directives_ordering` 强制:`dart:` → `package:`(外部)→ `package:`(本仓库)→ 相对路径。
|
||||
|
||||
**包内用相对路径 import,跨包用 `package:`。** 混用会导致同一个类被 Dart 认为是两个不同的类型(典型症状:`type 'X' is not a subtype of type 'X'`),这个错误看起来完全不可理喻,实际就是 import 路径不一致。
|
||||
|
||||
## 待确认项
|
||||
|
||||
- GitLab Runner 上是否已有可用的 Flutter 镜像,还是需要自建(与 [08-build-flavors.md](./08-build-flavors.md) 的 runner 问题一起解决)。
|
||||
- JIRA(或其他)issue key 的格式,用于分支和提交信息里的 `<jira-id>`。
|
||||
- 是否引入 lefthook(需要每个人本地 `lefthook install` 一次)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [flutter_lints | Dart package](https://pub.dev/packages/flutter_lints)
|
||||
- [Dart: Customizing static analysis](https://dart.dev/tools/analysis)
|
||||
- [Dart linter rules 全量列表](https://dart.dev/tools/linter-rules)
|
||||
- [FVM 官方文档](https://fvm.app/)
|
||||
- [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- [Melos 官方文档](https://melos.invertase.dev/)
|
||||
Reference in New Issue
Block a user