- 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>
18 KiB
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.status的AnimatedSwitcher(Splash ↔ 登录 ↔ 主壳,300ms fade),不走 Navigator。 - 主壳
main_shell_page.dart用IndexedStack+NavigationBar承载 5 个 Tab(首页/创作/档案/服务/我的),Tab 切换是setState,不产生路由事件。 - 二级页用
Navigator.push(MaterialPageRoute或core/navigation/fade_route.dart的fadePageRoute),目前都没有传RouteSettings.name。 MaterialApp目前没有挂任何navigatorObservers——RouteObserver 是空白,正好是遗留项 2 的落点。
1.2 状态管理与数据层
- 模式统一为 ChangeNotifier + 构造器注入,无第三方状态库:
AppState(demo 数据 + shared_preferences 持久化)、SessionManager(认证状态机 + flutter_secure_storage)。页面通过ListenableBuilder/AnimatedBuilder订阅。 - 网络层已完备:
ApiClient.request()(错误信封 → 类型化异常;401/40101 单飞刷新后重放一次;429 →ApiRateLimitException)、AuthInterceptor(requiresAuthextra 标记 +X-Device-Id)。健康档案接口可直接复用,无需动网络层。 - 仓储模式已确立:
AuthRepository抽象接口 +ApiAuthRepository实现,widget 测试注入假实现。健康档案照此办理即可。 - 模型全部手写
fromJson/toJson(lib/models/models.dart),无 codegen。已有PetProfile/VaccineRecord/VaccineItem,但它们是 demo 数据形态(如birthday为字符串、无服务端 id),对接后端契约时需要新建模型而非硬改。
1.3 档案 Tab 现状(M2 主改造对象)
features/pets/pets_page.dart(724 行)目前完全跑在 AppState demo 数据上:宠物资料卡 + 疫苗进度 + 硬编码的「成长足迹」时间线(两条写死的 _TimelineTile)+ 硬编码健康提醒/本月花费。编辑走 showModalBottomSheet(EditPetSheet / 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-50、auth_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 主题与设计债
主题体系健康:语义 token(AppColors/AppRadius)集中于 app_theme.dart,健康档案新页面直接引用 token 即可,无需扩色板;健康类语义色(success/successInk/successSurface sage 系)现成可用。DEBT-1(TagPill 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_archive(Tab 视图名) |
宠物资料卡 + 健康概览 + 健康记录时间线(倒序、按类型图标区分),替换现硬编码内容 |
| 健康记录列表(如首页时间线只展示近 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.dart、health_repository.dart、health_store.dart、pet_archive_page.dart、record_detail_page.dart、record_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/VaccineRecord,demo 模型与AppState中对应字段在档案 Tab 重构完成后择机下线。 - 错误处理复用类型化异常分层,与登录纵切一致:
ApiBusinessException按 code 映射字段级/表单级文案;SessionExpiredException由状态机自动送回登录页(无需页面处理);ApiNetworkException→ SnackBar + 重试;ApiRateLimitException→ 表单级横幅。
2.3 表单校验(复用登录纵切的 errorText 受控模式)
record_edit_page.dart 逐条复刻 login_page.dart 已验证的模式:
- 每字段一个
String? _xxxErrorstate +AppTextField(errorText: ...); - blur 校验:
Focus(onFocusChange: (has) { if (!has) _validateXxxOnBlur(); }); - 输入即清错:
onChanged里清本字段错误与表单级横幅; - 提交前全量校验,服务端字段级错误(如契约给出 422 字段错误)映射回对应
errorText,业务级错误走InlineErrorBanner+SemanticsService.sendAnnouncement(无障碍播报,登录页已有先例); - 提交中
PrimaryButton(isLoading: true)+ 字段enabled: !_submitting。 - 非文本控件(日期、类型选择)错误提示:
AppTextField之外的控件没有 errorText 通道,用控件下方 12pxAppColors.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;顺手把 eventId 从 v4() 改 v7()(uuid ^4.6.0 原生支持,对齐规范) |
| 3 | lib/app/app.dart |
initState 实例化 AnalyticsService + SessionTracker,WidgetsBinding.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/dialog(route is PageRoute)才 track('page_viewed', {'pageName': name, 'previousPageName': ...})
// didPop 上报的是「回退后重新曝光的前一页」
}
覆盖三类非 Navigator 的「页面曝光」需手动补点(这是本仓库路由结构的特殊性,纯 RouteObserver 覆盖不到):
- 主壳 Tab 切换(IndexedStack 无路由事件):
MainShellPage.selectTab内 track,Tab 名映射home / create / pet_archive / services / profile;初始 Tab 在initState补一次。 - 认证状态机切页(根部 AnimatedSwitcher 无路由事件):Splash/登录/主壳的切换在
app.dart的_homeForStatus分支处补点(或仅对 login 页补,待拍板颗粒度)。 - bottom sheet 不计入 page_viewed(与 §2.1 约定一致)。
page_viewed 是事件字典 v1(11 个 auth 事件)之外的新事件,需要在 13 号字典追加条目(eventVersion: 1,属性:pageName、previousPageName、可选 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-1(TagPill 对比度) | 修复方案归 UI Designer | 建议纳入 M2(§1.5),健康档案是 TagPill 最密页面 |
5. 风险与依赖小结
- 契约冻结是关键路径:openapi.yaml 尚无 health 接口;第一波先做两个埋点遗留项 + 表单/空态骨架可完全并行。
- 埋点未接线(§1.4 #4)是收官记录与代码的最大出入,接线动作已并入 §3.1 清单第 3 条,成本极低但必须做。
- 档案 Tab 重构会触碰
AppStatedemo 数据的退役边界(pet/vaccines 字段),首页问候卡、主壳头像也引用appState.pet——重构时需全局 grep 引用面,避免半迁移状态。 - 本评估未改任何生产代码;测试基线 34 全绿已复验。
Frontend Developer · 2026-09-07