223 lines
12 KiB
Markdown
223 lines
12 KiB
Markdown
# 脚手架落地记录:偏离文档的地方
|
||
|
||
这份文件是给 `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<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()`。
|