Files
lixi 1891d9b7b4
CI / docs-build (push) Successful in 1m3s
docs: M2 开工前分析 10 份报告入档 + ADR-009~015 拍板决策
- iteration-2 报告 01-08(PM 拆解/后端/Flutter 评估/现实核查/UI 规范/埋点规划/证据基线/Git 规划),04、06 已由正式角色复核定稿
- mkdocs 挂「第二迭代」导航,build --strict 通过
- ADR-009 新建 patbond-pet 模块、ADR-010 照片剪出 M2、ADR-011 dev 主干/master 发布、ADR-012 北极星与 H1-H4、ADR-013 废弃 health_record_action、ADR-014 DEBT-1 随 M2、ADR-015 照护人邀请后置

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 13:54:35 +08:00

18 KiB
Raw Permalink Blame History

03 · Flutter 前端技术评估(M2:宠物健康档案)

作者:Frontend Developer 日期:2026-09-07 依据:第一迭代收官报告(iteration-1/08、12、13 号)、ADR-005 珊瑚橙正典、patbond-doc/docs/api/openapi.yaml 性质:开工前评估,只读分析 + 验证性测试,未改动任何生产代码。

0. 基线验证

$ flutter test          # patbond-flutter @ Flutter 3.44.6 stable
00:03 +34: All tests passed!    # 34 个测试全绿,与第一迭代收官记录一致

测试分布:auth_repository 10、token_refresher 5、login_page 4、register_page 4、analytics_service 4、app_text_field 3、primary_button 3、widget_test(导航冒烟)1。M2 以 34 为基线,收官时只增不减。

1. 现状盘点(实际读码结论)

1.1 路由结构

  • 无命名路由、无 go_router。根路由是 app.dart 里基于 SessionManager.statusAnimatedSwitcher(Splash ↔ 登录 ↔ 主壳,300ms fade),不走 Navigator。
  • 主壳 main_shell_page.dartIndexedStack + NavigationBar 承载 5 个 Tab(首页/创作/档案/服务/我的),Tab 切换是 setState不产生路由事件
  • 二级页用 Navigator.pushMaterialPageRoutecore/navigation/fade_route.dartfadePageRoute),目前都没有传 RouteSettings.name
  • MaterialApp 目前没有挂任何 navigatorObservers——RouteObserver 是空白,正好是遗留项 2 的落点。

1.2 状态管理与数据层

  • 模式统一为 ChangeNotifier + 构造器注入,无第三方状态库:AppStatedemo 数据 + shared_preferences 持久化)、SessionManager(认证状态机 + flutter_secure_storage)。页面通过 ListenableBuilder/AnimatedBuilder 订阅。
  • 网络层已完备:ApiClient.request()(错误信封 → 类型化异常;401/40101 单飞刷新后重放一次;429 → ApiRateLimitException)、AuthInterceptorrequiresAuth extra 标记 + X-Device-Id)。健康档案接口可直接复用,无需动网络层
  • 仓储模式已确立:AuthRepository 抽象接口 + ApiAuthRepository 实现,widget 测试注入假实现。健康档案照此办理即可。
  • 模型全部手写 fromJson/toJsonlib/models/models.dart),无 codegen。已有 PetProfile / VaccineRecord / VaccineItem,但它们是 demo 数据形态(如 birthday 为字符串、无服务端 id),对接后端契约时需要新建模型而非硬改。

1.3 档案 Tab 现状(M2 主改造对象)

features/pets/pets_page.dart724 行)目前完全跑在 AppState demo 数据上:宠物资料卡 + 疫苗进度 + 硬编码的「成长足迹」时间线(两条写死的 _TimelineTile)+ 硬编码健康提醒/本月花费。编辑走 showModalBottomSheetEditPetSheet / VaccineSheet),表单校验是 SnackBar 弹错的旧模式,未用登录纵切确立的 errorText 受控模式。M2 的健康档案页面族基本等于重写这一 Tab 及其下钻页

1.4 Analytics 模块现状(与 13 号规范有实质偏差,须在 M2 修正)

实际读 lib/analytics/analytics_service.dart 与装配代码,发现四处与 13 号埋点规范 / 收官记录不一致:

# 规范/记忆中的状态 代码实际状态 位置
1 shared_preferences 分段队列、500 条上限、指数退避 纯内存队列,攒 20 条上传一次,失败整批丢弃(M0 简化注释自认) analytics_service.dart:18-25
2 eventId 用 UUIDv7 Uuid().v4() analytics_service.dart:50
3 sessionId 冷启动/后台 30 分钟重生成 每个事件随机生成一个 v4(注释标注 M0 简化) analytics_service.dart:55
4 5 个挂接点已挂 3 个 ApiAuthRepository 里 login/register/logout 挂接点齐全(成功/失败共 5 处 track),app.dart 组装时根本没传 analytics 实例——生产构建里 _analytics 恒为 null埋点实际未接线 app.dart:46-50auth_repository.dart:38,45

另有两处小问题顺带记录:appVersion / osVersion 是硬编码字符串(TODO 注 package_info_plus / device_info_plus);Platform.isAndroid 判断在 web/桌面上会抛(当前只出 mobile 包,暂不阻塞)。

结论:两个遗留项(sessionId 生命周期、page_viewed)落地前,必须先把 AnalyticsService 在 app.dart 接线,否则做了也是空转。接线本身改动极小(见 §3.1 清单第 3 条)。

1.5 主题与设计债

主题体系健康:语义 tokenAppColors/AppRadius)集中于 app_theme.dart,健康档案新页面直接引用 token 即可,无需扩色板;健康类语义色(success/successInk/successSurface sage 系)现成可用。DEBT-1TagPill 11px sage 文字对比约 2.4:1)在档案时间线的状态标签处会高频复现——健康档案是 TagPill 密度最高的页面族,建议 M2 内一并偿还(方案归 UI Designer 定,候选:文字换 successInk、或加深底色;前端改动约 1 处组件 + 全局回归)。

1.6 契约依赖(阻塞项)

openapi.yaml 当前只有 5 条 auth/me 路径,尚无任何 pet/health 接口。健康档案前端开发严格依赖契约冻结先行(延续第一迭代流程)。前端可先行的部分:两个遗留埋点项、页面骨架/空态/表单 UI、模型与仓储接口留假实现。

2. 健康档案页面族方案草案

2.1 页面与路由规划

沿用「档案 Tab 为入口、Navigator.push 下钻」的现有结构,不引入新路由框架(权衡见 §4-A):

页面 形态 路由名(供 page_viewed 说明
档案首页(重构 PetsPage Tab 页 pet_archiveTab 视图名) 宠物资料卡 + 健康概览 + 健康记录时间线(倒序、按类型图标区分),替换现硬编码内容
健康记录列表(如首页时间线只展示近 N 条) push /health/records 全量时间线,支持按类型筛选;列表即时间线,不做独立列表页与时间线两套 UI
记录详情 push /health/record 只读展示 + 编辑/删除入口
新增/编辑记录表单 push 全屏页 /health/record/edit 类型(疫苗/驱虫/体检/就诊/体重…按契约枚举)、日期、标题/机构、备注、数值字段随类型联动
宠物资料编辑 保留 bottom sheet sheet 不计路由曝光) 沿用 EditPetSheet 交互形态,校验改造为 errorText 受控模式

要点:

  • 新增/编辑用全屏 push 页而非 bottom sheet:健康记录字段多于宠物资料,sheet 内长表单 + 键盘 + 校验错误的可用性差;也让 page_viewed 能自然覆盖(权衡见 §4-B)。
  • 所有 Navigator.push 从 M2 起必须传 RouteSettings(name: ...),这是 page_viewed 的取数来源(§3.2)。
  • 目录按现约定放 lib/features/health/health_models.darthealth_repository.darthealth_store.dartpet_archive_page.dartrecord_detail_page.dartrecord_edit_page.dart

2.2 状态管理与数据层(沿用现有模式,零新依赖)

HealthRepository(抽象接口)
  Future<PetDetail> getPet();
  Future<List<HealthRecord>> listRecords({RecordType? type});
  Future<HealthRecord> createRecord(HealthRecordDraft draft);   // Idempotency-Key: uuid.v4(沿用注册的幂等键模式)
  Future<HealthRecord> updateRecord(String id, HealthRecordDraft draft);
  Future<void> deleteRecord(String id);

ApiHealthRepository implements HealthRepository   // ApiClient.request(..., requiresAuth: true)
HealthStore extends ChangeNotifier               // 列表/宠物数据 + 加载状态机,页面 ListenableBuilder 订阅
  • HealthStore 持一个显式加载状态机 idle → loading → ready / empty / failed,替代 AppState.isReady 那种单布尔(列表页需要区分空态与失败态)。
  • 装配处在 app.dart_buildRepository() 同层:复用同一个 ApiClient 实例,MainShellPage 构造器注入 store;测试注入 FakeHealthRepository(复刻 auth 测试的注入手法,test/helpers/ 已有先例)。
  • 模型手写 JSON(延续现约定,不引 codegen);字段名以冻结后的契约为准,不复用 demo 形态的 PetProfile/VaccineRecorddemo 模型与 AppState 中对应字段在档案 Tab 重构完成后择机下线。
  • 错误处理复用类型化异常分层,与登录纵切一致:ApiBusinessException 按 code 映射字段级/表单级文案;SessionExpiredException 由状态机自动送回登录页(无需页面处理);ApiNetworkException → SnackBar + 重试;ApiRateLimitException → 表单级横幅。

2.3 表单校验(复用登录纵切的 errorText 受控模式)

record_edit_page.dart 逐条复刻 login_page.dart 已验证的模式:

  • 每字段一个 String? _xxxError state + AppTextField(errorText: ...)
  • blur 校验:Focus(onFocusChange: (has) { if (!has) _validateXxxOnBlur(); })
  • 输入即清错:onChanged 里清本字段错误与表单级横幅;
  • 提交前全量校验,服务端字段级错误(如契约给出 422 字段错误)映射回对应 errorText,业务级错误走 InlineErrorBanner + SemanticsService.sendAnnouncement(无障碍播报,登录页已有先例);
  • 提交中 PrimaryButton(isLoading: true) + 字段 enabled: !_submitting
  • 非文本控件(日期、类型选择)错误提示:AppTextField 之外的控件没有 errorText 通道,用控件下方 12px AppColors.error 辅助文案行,样式对齐 errorStyle

同时把 EditPetSheet / VaccineSheet 的 SnackBar 弹错改造为同一模式,消除仓库内两套校验风格并存。

2.4 加载 / 空态 / 离线

处理
加载 首屏 CircularProgressIndicator(复用主壳 isReady 的样式);M2 不做骨架屏(页面族小,收益低)
空态 无任何健康记录:插画位(爪印 Icon + surfaceTint 底)+ 引导文案 + 「记录第一条」CTA 直达新增表单
失败 列表加载失败:页内错误态 + 重试按钮(复刻 Splash 失败态版式);操作失败按 §2.2 错误分层
下拉刷新 RefreshIndicator 包列表,成功静默、失败 SnackBar
离线 M2 推荐只读缓存:列表成功响应 JSON 落 shared_preferences(非敏感数据,符合 13 号规范的存储红线),冷启动/断网先渲染缓存并标注「展示的是上次同步数据」,后台刷新成功后替换;写操作不做离线排队(冲突处理复杂度不匹配 M2 体量),断网提交直接走网络错误分层。权衡见 §4-C,待拍板

3. 两个遗留高优先项:实现方案与改动点清单

两项都建议排在 M2 第一波(不依赖健康契约冻结,可与契约评审并行),且共享前置:把 AnalyticsService 在 app.dart 接线(§1.4 #4)。

3.1 sessionId 生命周期(WidgetsBindingObserver

方案(对齐 13 号规范 §3.1 session_tracker.dart 设计):

新建 lib/analytics/session_tracker.dart

class SessionTracker with WidgetsBindingObserver {
  // 冷启动:构造时生成 sessionId = Uuid().v7()
  // didChangeAppLifecycleState:
  //   paused/inactive → 记 _lastPausedAt(内存即可,进程死了本来就是冷启动)
  //   resumed → 距 _lastPausedAt 超 30 分钟则重新生成 sessionId
  String get sessionId;
}
  • 30 分钟阈值做成构造参数(默认 30min),时钟做成 DateTime Function() now 注入,测试免等待。
  • lastActiveAt 落不落 shared_preferences:规范原文要求持久化(pb.analytics.lastActiveAt),但其唯一作用是跨进程判定,而冷启动本来就必然换新 sessionId,持久化无增量价值——建议不持久化,纯内存(偏离规范一处,需数据侧确认,待拍板 §4-D)。

改动点清单

# 文件 改动
1 lib/analytics/session_tracker.dart 新建(约 40 行)
2 lib/analytics/analytics_service.dart 构造器增加 String Function() getSessionId;删除 'sessionId': const Uuid().v4() 改为调用注入的 getter;顺手把 eventIdv4()v7()uuid ^4.6.0 原生支持,对齐规范)
3 lib/app/app.dart initState 实例化 AnalyticsService + SessionTrackerWidgetsBinding.instance.addObserver(tracker)_buildRepository() 把 analytics 传入 ApiAuthRepository修复未接线);dispose removeObserver
4 test/analytics/session_tracker_test.dart 新建:冷启动生成、resume<30min 不变、resume≥30min 重生成、连续 pause/resume 幂等(TestWidgetsFlutterBinding.handleAppLifecycleStateChanged 驱动 + 注入假时钟)
5 test/analytics/analytics_service_test.dart 现有 4 测试补断言:同一 tracker 下多事件 sessionId 相同

3.2 page_viewed 路由埋点(RouteObserver

方案NavigatorObserver 派生类而非 RouteObserver<PageRoute> + RouteAware(后者要求每个页面 State mixin RouteAware 并注册/注销,N 个页面 N 处样板;前者集中一处、页面零侵入。权衡见 §4-E)。

新建 lib/analytics/analytics_route_observer.dart

class AnalyticsRouteObserver extends NavigatorObserver {
  // didPush / didPop / didReplace:取 route.settings.name
  // 非空且非 sheet/dialogroute is PageRoute)才 track('page_viewed', {'pageName': name, 'previousPageName': ...})
  // didPop 上报的是「回退后重新曝光的前一页」
}

覆盖三类非 Navigator 的「页面曝光」需手动补点(这是本仓库路由结构的特殊性,纯 RouteObserver 覆盖不到):

  1. 主壳 Tab 切换IndexedStack 无路由事件):MainShellPage.selectTab 内 trackTab 名映射 home / create / pet_archive / services / profile;初始 Tab 在 initState 补一次。
  2. 认证状态机切页(根部 AnimatedSwitcher 无路由事件):Splash/登录/主壳的切换在 app.dart_homeForStatus 分支处补点(或仅对 login 页补,待拍板颗粒度)。
  3. bottom sheet 不计入 page_viewed(与 §2.1 约定一致)。

page_viewed事件字典 v1(11 个 auth 事件)之外的新事件,需要在 13 号字典追加条目(eventVersion: 1,属性:pageNamepreviousPageName、可选 source: push/pop/tab/auth_switch),字典变更须经数据侧确认——前端不擅自开报。

改动点清单

# 文件 改动
1 lib/analytics/analytics_route_observer.dart 新建(约 50 行)
2 lib/app/app.dart MaterialApp(navigatorObservers: [analyticsRouteObserver])
3 lib/features/main/main_shell_page.dart 注入 analytics(或回调);selectTab + initState 补 Tab 曝光点
4 现有全部 Navigator.push 调用点(main_shell_page.dart openPost、login_page.dart _goRegister、fade_route.dart 签名加可选 settings RouteSettings(name: ...)M2 新页面从第一天就带 name
5 patbond-doc 13 号字典 追加 page_viewed 条目(数据侧评审后)
6 test/analytics/analytics_route_observer_test.dart 新建:push/pop/无名路由不报/sheet 不报;Tab 切换补点在 shell 冒烟测试中断言

注意:两项落地后事件量将从「每会话 <10 条」上升(page_viewed 是高频事件),§1.4 #1 的内存队列(失败整批丢弃)会放大数据丢失。建议把 13 号规范的 shared_preferences 分段队列列入 M2 第二波(不阻塞两个遗留项,但应在健康档案功能埋点铺开前就位)。

4. 权衡与待拍板

# 议题 选项 推荐
A 路由框架 ① 维持 Navigator 1.0 + push;② 引入 go_router 。页面族仅 3 个下钻页,无 deep link 需求;go_router 迁移波及登录纵切已验证的 AnimatedSwitcher 认证切换结构,风险收益不匹配。deep link 需求出现时(推送直达记录详情)再评估
B 新增/编辑表单形态 ① 全屏 push 页;② bottom sheet(与 EditPetSheet 一致) 。字段多 + 键盘 + errorText 校验在 sheet 内可用性差,且 sheet 不产生路由事件、埋点需再补点。代价:与宠物资料编辑(保留 sheet)形态不一,需 UI Designer 认可
C 离线策略 ① 纯在线 + 失败重试;② 只读缓存最近列表;③ 完整离线(写排队+冲突解决) 。成本约一个缓存读写封装,显著改善弱网首屏;③ 明确出 M2 范围
D sessionTracker 的 lastActiveAt 是否持久化 ① 按规范落 prefs;② 纯内存 (理由见 §3.1)。属对 13 号规范的偏离,需数据侧点头
E page_viewed 采集机制 ① NavigatorObserver 集中式;② RouteObserver + RouteAware 分布式 。零页面侵入、单点测试;②仅在需要「页面 resume 时长统计」时更优,当前事件不含时长
F 埋点队列升级时机 ① M2 第二波做分段队列;② 推 M3 (理由见 §3.2 注意),且 EventQueue 接口规范里已设计好,实现面可控
G DEBT-1TagPill 对比度) 修复方案归 UI Designer 建议纳入 M2(§1.5),健康档案是 TagPill 最密页面

5. 风险与依赖小结

  1. 契约冻结是关键路径openapi.yaml 尚无 health 接口;第一波先做两个埋点遗留项 + 表单/空态骨架可完全并行。
  2. 埋点未接线(§1.4 #4)是收官记录与代码的最大出入,接线动作已并入 §3.1 清单第 3 条,成本极低但必须做。
  3. 档案 Tab 重构会触碰 AppState demo 数据的退役边界(pet/vaccines 字段),首页问候卡、主壳头像也引用 appState.pet——重构时需全局 grep 引用面,避免半迁移状态。
  4. 本评估未改任何生产代码;测试基线 34 全绿已复验。

Frontend Developer · 2026-09-07