# 脚手架落地记录:偏离文档的地方 这份文件是给 `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`(01)vs `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 fetchCurrentUser(); ... } final Provider sessionRemoteProvider = Provider( (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()`。