Files
2026-08-17 15:29:55 +08:00

223 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 脚手架落地记录:偏离文档的地方
这份文件是给 `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()`