12 KiB
脚手架落地记录:偏离文档的地方
这份文件是给 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_lintis no-longer implemented usingcustom_lint, but insteadanalysis_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 |
统一解法:包内只声明"我需要什么",不声明"谁来满足"。
// 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'),
);
// app/lib/bootstrap.dart —— 唯一知道所有包的地方
sessionRemoteProvider.overrideWith((Ref ref) => ref.watch(authRepositoryProvider)),
现有的端口文件:
core_auth/lib/src/session_ports.dart—SessionRemote/SessionScopedStore/SessionObservercore_network/lib/src/ports.dart—ClientInfo/ApiLogSinkcore_router/lib/src/ports.dart—appRoutesProvider/navigatorObserversProvider/RouteReportercore_webview/lib/src/h5_launch.dart—H5LaunchRepository
代价要说清楚:这些 override 是运行期才炸的,编译器不管。新增一个端口就要
同时在 bootstrap() 里接上。默认值给不给有讲究——appEnvProvider /
sessionRemoteProvider 故意不给(忘了接必须立刻炸),apiLogSinkProvider /
sessionObserversProvider 给空实现(忘了接只是没日志,不影响功能)。
切店后回工作台那一条,实现成了 goRouterProvider 里的一个 ref.listen:
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)
不属于文档偏离,但会反复咬人,记在这里:
AsyncValue.valueOrNull没了,用.value。copyWithPrevious是 internal,用了会报invalid_use_of_internal_member。Override类型不公开导出。overrides: [...]不要写类型参数。- riverpod_generator 4.x 只剥
Notifier后缀:SessionNotifier→sessionProvider,但LoginController→loginControllerProvider。 - 所有 provider 默认 autoDispose。测试里
container.read之后没有监听者, 状态立刻被回收;要container.listen(p, (_, _) {})顶住。 ProviderObserver是base类,子类必须标final/base/sealed。testWidgets跑在 fake async 区里:里面await一个真实的 Future (比如container.refresh(p.future))永远不会完成,会挂到 10 分钟超时。 碰真 provider 生命周期必须包一层await tester.runAsync(() async { ... })。ProviderContainer+ 异步 provider:同步read拿到的是 loading 态, 要先await container.read(p.future)。- Pigeon 生成的
@HostApi是具体类不是接口,测试替身要extends不是implements。 - 局部变量别叫
fail——会遮蔽flutter_test的fail()。