Files
conti-retail-app/SCAFFOLD-NOTES.md
T
2026-08-17 15:29:55 +08:00

12 KiB
Raw Blame History

脚手架落地记录:偏离文档的地方

这份文件是给 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。但这个体系里有 StorageExceptioncore_storage 要用)和 UnauthorizedException core_auth 要用),而 01 明令 core_storage/core_auth 不许依赖 core_network
  • AppEnvcore_networkbaseUrl)、core_authTokenRefresher)、 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 的 HeaderInterceptorif (storeId != null) 提供两个 providercurrentStoreIdProvider(非空,业务用)和 currentStoreIdOrNullProvider(可空,基础设施用)
10 melos 脚本名 pigeon01vs gen:pigeon14 统一 gen:pigeon
11 analyze 脚本 01 无 --fatal-infos14 的 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.dartSessionRemote / SessionScopedStore / SessionObserver
  • core_network/lib/src/ports.dartClientInfo / ApiLogSink
  • core_router/lib/src/ports.dartappRoutesProvider / navigatorObserversProvider / RouteReporter
  • core_webview/lib/src/h5_launch.dartH5LaunchRepository

代价要说清楚:这些 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

不属于文档偏离,但会反复咬人,记在这里:

  1. AsyncValue.valueOrNull 没了,用 .value
  2. copyWithPrevious 是 internal,用了会报 invalid_use_of_internal_member
  3. Override 类型不公开导出overrides: [...] 不要写类型参数。
  4. riverpod_generator 4.x 只剥 Notifier 后缀SessionNotifiersessionProvider,但 LoginControllerloginControllerProvider
  5. 所有 provider 默认 autoDispose。测试里 container.read 之后没有监听者, 状态立刻被回收;要 container.listen(p, (_, _) {}) 顶住。
  6. ProviderObserverbase,子类必须标 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_testfail()