Files

223 lines
12 KiB
Markdown
Raw Permalink Normal View History

2026-08-17 15:29:55 +08:00
# 脚手架落地记录:偏离文档的地方
这份文件是给 `conti-docs` 的**回写清单**。搭这个骨架的过程中,文档里有一部分
内容无法照抄——有的是版本过期,有的是两篇文档互相矛盾,有的是照抄会直接编译
不过。每一条的裁决都记在下面,代码里对应位置也留了注释。
**本次没有改动 `conti-docs` 仓库的任何文件。** 更新 01 / 03 / 07 / 12 / 14 四篇
文档时以这份清单为准。
日期:2026-08-17。工具链:Flutter 3.44.9 / Dart 3.12.2 / Melos 8.2.2。
---
## A. 版本纠错
文档 03 写的 riverpod 版本已经过期,但**不能直接升到 pub.dev 上的最新版**:
| 包 | 文档 | 最新 | 本仓库实际 | 为什么不是最新 |
|---|---|---|---|---|
| `riverpod_annotation` | `^3.4.2` | 4.0.6 | **4.0.3** | 见下 |
| `riverpod_generator` | `^3.4.2` | 4.0.8 | **4.0.4** | 见下 |
| `flutter_riverpod` | `^3.4.2` | 3.4.2 | **3.3.2** | 见下 |
**约束来自 `flutter_test`**:它 pin 了 `test_api 0.7.11`,而 riverpod 4.0.6+ 要
`analyzer 13.x`,两者的解析结果冲突。往上升之前先确认这条链路,光看 pub.dev 的
最新版会浪费半天。
`build_runner 2.15.1` / `analyzer 12.1.0` 是被上面这条连带定死的。
其余 22 个包与文档一致。
## B. `riverpod_lint` 不再走 `custom_lint`
文档 03/14 要求装 `custom_lint`、在 `analysis_options.yaml` 里写
`analyzer: plugins: - custom_lint`、CI 里单独跑 `dart run custom_lint`
**实测 `riverpod_lint 3.1.8` 的依赖里没有 `custom_lint`**,它依赖
`analysis_server_plugin`。官方 changelog
> 3.1.0 — `riverpod_lint` is no-longer implemented using `custom_lint`, but
> instead `analysis_server_plugin`
因此本仓库:
- `analysis_options.yaml` 用**顶层** `plugins:` 映射,不是 `analyzer.plugins`
- 所有 `dev_dependencies` 里没有 `custom_lint`
- CI 里没有 `dart run custom_lint` 这一步,lint 由 `flutter analyze` 直接执行。
**顺带解决了 01 和 14 关于「custom_lint 装根还是装每个包」的长期矛盾**——这个
问题不存在了。
## C. 新增了一个文档里没有的包:`core_foundation`
这是本次唯一的结构性增补。理由是文档现有的依赖规则**自相矛盾**,不加就无法
同时满足:
- 文档 12 把 `sealed AppException` 放在 `core_network`。但这个体系里有
`StorageException``core_storage` 要用)和 `UnauthorizedException`
`core_auth` 要用),而 01 明令 `core_storage`/`core_auth` **不许**依赖
`core_network`
- `AppEnv``core_network`baseUrl)、`core_auth`TokenRefresher)、
`core_webview`(域名白名单)、`core_logging`(日志级别)同时需要。放进任何
一个现有 `core_*` 都会造出非法依赖边或循环。
方案:`packages/core_foundation` 是一个**叶子包**,只依赖 `flutter_riverpod`
内容是 `AppEnv` + `AppException` 体系 + `NetworkErrorKind` + `ApiCode` +
`NativeErrorCode`。所有 `core_*` / `feature_*` 都可以依赖它;`native_*` **不**
依赖(保住 01 那条「native_* 只依赖 Flutter SDK 和 Pigeon 产物」)。
叶子包不可能形成循环,01「core_* 之间不互相依赖」的立法目的(防循环)不受损。
`PageQuery` / `PageResult` 按文档 02 留在 `core_network`
## D. `native_*` 无法抛 `AppException`
文档 07 写「原生异常不外泄,统一转成 AppException 体系」,但 `native_*` 不许
依赖任何 `core_*`(含 `core_foundation`)。
裁决:`native_scan` 抛包内自定义的 `NativeScanException`(纯 Dart 无依赖),
**映射到 `NativeException` 的动作放在调用方**feature_scan / core_webview 的
bridge handler)。文档 07 的 `NativeCapabilityException` /
`UnsupportedPlatformException` 收敛成文档 12 的 `NativeException(code, message)`
## E. 逐条冲突裁决
| # | 冲突 | 裁决 |
|---|---|---|
| 1 | `BusinessException` 构造签名:05 用具名,12 用位置参数 | 按 **12**(定义类的那篇),05 的拦截器代码相应调整 |
| 2 | 05 的 `HttpException` 不在 12 的 sealed 体系里,且与 `dart:io` 同名 | 改用 12 的 `ServerException` |
| 3 | `NetworkErrorKind` 被 12 的 `present()` 用了但从未声明 | 在 core_foundation 声明;`ErrorMappingInterceptor` 负责填 |
| 4 | `UnauthorizedException` / `RequestCancelledException` / `StorageException` 无构造函数,父类却要求位置参数 | 各补 `const` 构造 + 默认文案 |
| 5 | **`PreconditionException` 不在 12 的 `present()` switch 里** | 补分支。sealed 穷尽,不补**编译不过** |
| 6 | `AppException.bridgeCode` 被 core_webview 用了但未定义 | 在 `AppException` 上加 getter,子类 override。码表待与 F6 对齐 |
| 7 | 05 的拦截器顺序正文与附录不一致 | 按**附录**Header → Log → Auth → ApiResult → ErrorMapping |
| 8 | 06 有两个 `readAccessToken` 实现 | 用带 try/catch 兜底那版:读失败按未登录处理 + `clear()`,绝不让异常逃进启动流程 |
| 9 | `currentStoreIdProvider` 返回非空且会 throw(11),但 05 的 `HeaderInterceptor``if (storeId != null)` | 提供**两个** provider`currentStoreIdProvider`(非空,业务用)和 `currentStoreIdOrNullProvider`(可空,基础设施用) |
| 10 | melos 脚本名 `pigeon`01vs `gen:pigeon`14 | 统一 `gen:pigeon` |
| 11 | `analyze` 脚本 01 无 `--fatal-infos`,14 的 CI 有 | 脚本里加上 |
| 12 | melos 脚本里 `flutter` vs `fvm flutter` | 用裸 `flutter`(按 01),版本靠 `.fvmrc` + PATH 保证 |
| 13 | 02 的 `PageResult.hasMore` 计算逻辑有误 | 用后端返回的 `hasMore`,不在客户端算 |
| 14 | 02 正文说 `data/repository_impl/`,示例写 `data/repository/` | 统一 `data/repository/` |
| 15 | `Analytics` 接口:13 正文说 `login()`/`logout()`,代码块用 `identify()`/`reset()` | 按代码块 |
## F. 本次范围调整
- **Drift / 本地数据库暂缓**。骨架阶段没有任何业务表,`core_storage` 现在只有
KV 一档(`Prefs`)。文档 06 的表结构、迁移策略、`{storeId, orderId}` 联合主键
规则仍然有效,等第一张业务表落地时按 06 建。
> 顺带:melos 8.x 和 `drift_dev` 有一个 `cli_util` 的版本冲突,接 Drift 时会
> 撞上,先有个心理准备。
- **原生 Kotlin/Swift 实现不做**。`native_scan` 的 Pigeon 接口和 Dart 侧齐了,
原生侧是 `flutter create` 的模板 + TODO。
- **不做 lefthook**、不做 `git init` / 首次提交(按需求)。
- **i18n 只做结构预留**`app/l10n.yaml` + 一个 `app_zh.arb`,文案还写在 Widget
里。理由见 conti-docs README 的待补充清单——等 30 个页面都写死中文再抽,成本
是现在的几十倍。
## G. 依赖反转("端口")模式
**这是本次落地里最值得回写文档的一条。** 文档里有若干处伪代码违反了 01 自己
定的依赖规则:
| 文档处 | 想做的事 | 为什么不行 |
|---|---|---|
| 11 `SessionNotifier` | 调 `authRepository.login()` | core_auth 不许依赖 core_network / feature_* |
| 11 切店第 6 步 | `goRouterProvider.go('/home')` | core_auth 不许依赖 core_router |
| 11 切店级联 | 清 WebView cookie | core_auth 不许依赖 core_webview |
| 05 `HeaderInterceptor` | 读 `deviceIdProvider` | core_network 不许依赖 core_storage |
| 05 `ApiResultInterceptor` | 收一个 `AppLogger` | core_network 不许依赖 core_logging |
| 10 H5 换票 | 发 `/h5/launch` 请求 | core_webview 不许依赖 core_network |
| 04 未知菜单编码 | 上报埋点 | core_router 不许依赖 core_analytics |
统一解法:**包内只声明"我需要什么",不声明"谁来满足"**。
```dart
// core_auth/lib/src/session_ports.dart
abstract interface class SessionRemote { Future<UserContext> fetchCurrentUser(); ... }
final Provider<SessionRemote> sessionRemoteProvider = Provider<SessionRemote>(
(Ref ref) => throw UnimplementedError('sessionRemoteProvider 必须在 bootstrap() 里 override'),
);
```
```dart
// app/lib/bootstrap.dart —— 唯一知道所有包的地方
sessionRemoteProvider.overrideWith((Ref ref) => ref.watch(authRepositoryProvider)),
```
现有的端口文件:
- `core_auth/lib/src/session_ports.dart``SessionRemote` / `SessionScopedStore`
/ `SessionObserver`
- `core_network/lib/src/ports.dart``ClientInfo` / `ApiLogSink`
- `core_router/lib/src/ports.dart``appRoutesProvider` /
`navigatorObserversProvider` / `RouteReporter`
- `core_webview/lib/src/h5_launch.dart``H5LaunchRepository`
代价要说清楚:**这些 override 是运行期才炸的,编译器不管**。新增一个端口就要
同时在 `bootstrap()` 里接上。默认值给不给有讲究——`appEnvProvider` /
`sessionRemoteProvider` 故意不给(忘了接必须立刻炸),`apiLogSinkProvider` /
`sessionObserversProvider` 给空实现(忘了接只是没日志,不影响功能)。
切店后回工作台那一条,实现成了 `goRouterProvider` 里的一个 `ref.listen`
```dart
if (before is SessionActive && after is SessionActive &&
before.store.storeId != after.store.storeId) {
router.go(AppRoutes.home); // go 而不是 push:替换整个栈
}
```
方向反过来了(core_router 观察 core_auth,而不是 core_auth 调 core_router),
符合允许的依赖边。
## H. Pub Workspace 让「包边界靠编译器强制」这句话不成立
**这条建议直接回写进 01。** 01 的立论是「违反分包规则的代码根本写不出来,
因为 pubspec 里没声明就 import 不到」。在 Pub Workspace 下**这是错的**:所有
成员包共用根目录一份 `.dart_tool/package_config.json`,任何成员都能解析到任何
其他成员,不管 pubspec 里写没写。
实测(在 `feature_home/lib/src/` 放一个 import `feature_auth` 的文件,
`feature_home/pubspec.yaml` 里**不**声明这条依赖):
```
flutter test → All tests passed! 编译通过,跑得起来
flutter analyze --fatal-infos → info: depend_on_referenced_packages ✗
```
只有一条 **info**级 lint。结论:
> **`--fatal-infos` 是这套包边界唯一的强制点。** 它没了,边界当天失效,且
> 没有任何别的信号——不会编译失败,测试还是绿的。
所以 §E 第 11 条(`analyze` 脚本要不要加 `--fatal-infos`)不是风格问题,是
**这套架构成不成立的问题**,01 和 14 都应该按这个高度重写那一段。相应地,
`depend_on_referenced_packages` 不允许在任何包的 `analysis_options.yaml` 里被
ignore,建议在 14 的 CI 门禁一节里单列。
(改用 path dependency 而不是 workspace 可以拿回编译期强制,代价是失去统一
版本解析——不建议为此推翻 workspace,把 `--fatal-infos` 守住即可。)
## I. 写代码时踩到的坑(Riverpod 3 / flutter_test
不属于文档偏离,但会反复咬人,记在这里:
1. **`AsyncValue.valueOrNull` 没了**,用 `.value`
2. **`copyWithPrevious` 是 internal**,用了会报 `invalid_use_of_internal_member`
3. **`Override` 类型不公开导出**。`overrides: [...]` 不要写类型参数。
4. **riverpod_generator 4.x 只剥 `Notifier` 后缀**`SessionNotifier`
`sessionProvider`,但 `LoginController``loginControllerProvider`
5. **所有 provider 默认 autoDispose**。测试里 `container.read` 之后没有监听者,
状态立刻被回收;要 `container.listen(p, (_, _) {})` 顶住。
6. **`ProviderObserver``base` 类**,子类必须标 `final` / `base` / `sealed`
7. **`testWidgets` 跑在 fake async 区里**:里面 `await` 一个真实的 Future
(比如 `container.refresh(p.future)`)**永远不会完成**,会挂到 10 分钟超时。
碰真 provider 生命周期必须包一层 `await tester.runAsync(() async { ... })`
8. **`ProviderContainer` + 异步 provider**:同步 `read` 拿到的是 loading 态,
要先 `await container.read(p.future)`
9. Pigeon 生成的 `@HostApi` 是**具体类**不是接口,测试替身要 `extends` 不是
`implements`
10. 局部变量别叫 `fail`——会遮蔽 `flutter_test``fail()`