PureLive 本地快照刷新生命周期审计:本地响应式分页控制器中外部回调、布局事务与关闭语义的治理
PureLive 本地快照刷新生命周期审计:本地响应式分页控制器中外部回调、布局事务与关闭语义的治理
本篇基于 本地快照刷新与 IPTV 生命周期审计报告,拆解 PureLive(纯粹直播)中"本地响应式分页"这一页类型在刷新、布局切换与页面关闭三类并发场景下的竞态缺陷,并逐条对照 本地分页基类、热门页控制器 与 公共布局事务骨架 的现行源码,说明"活动 Future 登记—分页模式提交—指示器归属—关闭检查"这套契约是如何落地的。读完后你可以掌握:如何为一个"取数在外部回调中异步完成"的本地分页页设计不重入、不提前提交、关闭可安全退出的刷新生命周期,以及红测→绿测→联合验证的完整证据链。
一、背景:什么是"本地快照刷新"页
PureLive 的分页体系里有一类页面不直接持有服务端游标:它们先一次性拉取一批数据写入本地原始池(raw pool),之后所有"翻页、改页大小、切换桌面/移动布局"都只对这个本地池做同步投影(projection)。热门页就是典型——实际热门页会把 IPTV 等站点路由到 PopularLocalReactiveController,其推荐列表读取包含异步的频道数据库查询,以及空列表时的资源同步和重新查询,因此它不是纯同步投影,取数链路必须走外部回调。
这套结构的关键分界在基类 BasePageScrollAndStateBone:
- 布局断点:
Get.width > 680 && !PlatformUtils.isMobile判定桌面分页模式(usesDesktopPagination,见 base_page_scroll_bone.dart#L112),桌面模式按pageSize切片,移动端整池显示; - 布局事务:
checkAndNotifyLayoutChange带 120 ms debounce,到期后通过只读属性activePageOperation等待在途分页任务,未稳定前不提交新的分页模式与页大小(见 base_page_scroll_bone.dart#L75-L110)。上一批布局事务审计已经让四类网络分页暴露了当前任务,但本地响应式页的外部刷新链路仍是空白——本批审计正是补齐这块。
本地调用清单确认了外部刷新回调的生产赋值来自 PopularLocalReactiveController;审计起点为 928ce6f4010de511b4851a020f9efc44f53ea825,只修改本地基类与 Popular 派生类两个文件,不动平台注册、播放列表排序、配置格式或版本。
二、第一处错误状态:四类竞态
审计报告逐条确认了修订前的问题形态(对应"第一处错误状态"一节):
- 重复刷新同时进入多个回调。基类直接调用外部回调,且没有任务句柄:连续两次刷新会各自触发一次回调;与此同时布局侧持有的
activePageOperation仍为空,页大小也立即改变。在途快照因此没有稳定的尺寸和模式。 - 本地分发读取即时
Get.width,而非已提交分页模式。这绕过了公共布局事务的等待,投影结果可能与布局即将提交的维度不一致。 - 刷新提示过早结束。回调中
updateLocalReactivePool会立即结束刷新指示器,但回调此时仍有异步工作在进行。更隐蔽的是:Popular 的内部loadData捕获错误并完成fail后,外层refreshData随后再次投影,把状态覆盖为成功/noMore——一次失败被伪装成成功。 - 关闭后无任何防护。异步返回后的投影、错误展示和
finally加载状态没有关闭检查;非 IPTV 的派生取数还会在页面退出后继续读取SettingsService的排序设置。
冻结上游提交 c6c9bd70aedc503c003110dae10a83ad0bb891d8 中这两个文件具有相同的"无任务句柄、无关闭保护、双层刷新"结构,因此分类为 upstream-existing(上游既存缺陷);审计只读取本地冻结提交,没有同步上游。
三、修订契约:六条规则与现行源码的逐条对应
报告给出的修订契约共 6 条,现行 local_reactive_page_controller.dart 的字段设计几乎是一一对应实现的:
| 契约条目 | 源码落点 |
|---|---|
| ① 外部快照拥有一个活动 Future,先登记再执行回调 | _activeExternalRefresh + loadExternalSnapshot |
| ② Future 暴露给布局事务;页大小延迟提交 | activePageOperation getter + _applyPendingPageSize |
| ③ 回调负责发布数据,任务拥有者负责 loading/错误/刷新提示 | _performExternalRefresh 与 _finishLocalProjection |
④ Popular 初始 loadData 复用快照入口,外部回调不再递归调用 loadData |
popular_grid_controller.dart#L46-L47 |
| ⑤ 关闭后的入口/发布/错误/复位均退出;已发出 Future 继续观察至终态 | 各方法开头的 isClosed 检查与 onClose 语义 |
| ⑥ IPTV 保留列表原序;其他平台异步返回后先查关闭再读设置 | getLocalRawData |
3.1 活动 Future 的登记顺序与同步重入
loadExternalSnapshot 的关键细节在注释里写得很直白:在调用任何回调或响应式 loading 监听器之前(包括同步发布数据的回调)就完成所有权登记(L63-L67):
final completion = Completer<void>();
final operation = completion.future;
// Publish ownership before invoking any callback or reactive loading
// listener, including callbacks that publish their data synchronously.
_activeExternalRefresh = operation;
这解决了"同步重入":若回调同步调用 updateLocalReactivePool,投影发生时 _activeExternalRefresh 已非空,后续任何经 loadExternalSnapshot 进入的调用直接返回同一个 Future(L61-L62),重复初始加载因此共享同一任务。
而显式刷新走 refreshData:先置 _refreshPending = true,用 while 循环等待当前活动 Future(等待者醒来后重新检查当前任务,因为等待期间可能又有新任务),再消费待处理意图。也就是说,重复刷新不会派生多份回调,只保留一个"后续最新刷新意图"——设置可能在上一份快照读取期间改变,醒来后重新走一次快照入口即可拿到最新设置。
3.2 指示器归属:谁的任务谁结束
回调内部的同步投影被保留(快速刷新体验不变),但不再提前结束刷新提示。实现上用一个运行标记区分"投影发起者":
void _finishLocalProjection(IndicatorResult result) {
// A callback may publish before all of its asynchronous work is done.
// Only the owner of that operation may finish the refresh indicator.
if (!_externalRefreshRunning) finishRefreshControllers(result);
}
只有当不在外部刷新运行中时,投影才自行结束指示器;刷新任务的 loading、错误与 finishRefreshControllers 统一由 _performExternalRefresh 拥有(L99 的成功路径按 canLoadMore 给出 success/noMore,L100-L104 的失败路径不再被后续投影覆盖)。这正是针对第二节的"错误被成功覆盖"缺陷。
3.3 首空快照与同引用快速路径
updateLocalReactivePool 有两个容易被删掉但必须保留的行为:
- 首空快照仍然发布。源码注释记录了原因:旧的同长度快速路径在两个空列表相等时提前返回,导致
BasePageView永远停在不确定加载态——全新安装时刷新转圈永不停止,空闲期持续消耗 CPU/GPU 帧。因此_hasPublishedSnapshot之前的空列表也会完整投影; - 同引用快照走 identical 快速路径:等长且逐项
identical时直接返回,避免无意义的响应式通知。配合契约③中"未发布新快照时仍做一次成功投影",缓存命中同一元素引用的快速路径依然能正常结束刷新。
3.4 页大小待处理、布局模式已提交
setPageSize 收到零/负数直接丢弃(L126-L131);合法值先经 persistUserPageSize 写回全局默认(hive 持久化,见 base_page_scroll_bone.dart#L213-L222),再存入 _pendingPageSize,由 _applyPendingPageSize 等当前快照结束才提交——重新选择原尺寸时清掉旧意图(L140-L142)。提交时的页号映射用实际旧 pageSize 而非硬编码值重算 currentPage,保持"当前首个元素"不动。桌面切片投影 _processDesktopSlicing 越界时收敛到末页而不是产生空页(L179-L201)。
goToPage 与 loadMoreData 同样先检查 _activeExternalRefresh:跳页在活动刷新期间直接拒绝(L114-L123),加载更多则直接返回活动 Future 供 UI 观察(L160-L166)。而 goToPage 的维度判断、_processDataDistribution 的分发都依赖 _lastIsDesktop 记录的已提交模式,而不是即时 Get.width——这就是契约②在本地链路的兑现。
3.5 关闭语义的边界
契约⑤明确了两件事:关闭后的入口、结果发布、错误展示和公开状态复位全部退出(源码中 isClosed 检查遍布 refreshData、loadExternalSnapshot、updateLocalReactivePool、_performExternalRefresh 的 catch/finally 以及 loadData);但已发出的 Future 继续被观察到终态,仅清除内部句柄(identical(_activeExternalRefresh, operation) 保护下置空,见 L68-L79)。同时报告特别界定:这里不是取消底层数据库/资源同步,也不回滚回调已经发布的有效快照——数据一旦发布即视为有效,取消语义属于原生取数层自己的职责。
四、Popular 案例:IPTV 原序与关闭后设置读取
PopularLocalReactiveController 的回调实现正是契约④⑥的载体:
onExternalRefresh = () async {
final rooms = await getLocalRawData();
if (isClosed) return; // 异步返回后先查关闭
updateLocalReactivePool(rooms); // 再发布快照
};
getLocalRawData 内取数前后各有一次 isClosed 检查;排序策略上 IPTV 站点保留服务端列表原序(site.id == Sites.iptvSite ? rooms : _rankForCurrentSettings(rooms)),其他平台在确认页面仍然打开后才读取 SettingsService 的 preferRealOnlineCounts / realOnlinePlatforms,经 rankPopularRoomsByAudience 归一化为降序观众数排序——这既防止适配器字段漂移,也兑现了"退出后不再读设置"的承诺(popular_grid_controller.dart#L9-L54)。
另外注意 PopularLocalReactiveController.loadData() => loadExternalSnapshot():初始加载直接复用快照入口,外部回调不再递归调用 loadData;而通用本地 loadData(基类 L154-L158)继续只投影缓存,允许通用回调等待该投影而不等待自身。
需要划清边界的是 FavoriteController:它虽继承本地基类,但覆盖了自己的 activePageOperation(_startupRefresh ?? _activeRoomRefresh ?? super.activePageOperation,见 favorite_controller.dart#L79-L80),使用独立锁 Lock() 和启动验证链路。本批审计不把该独立链路计作已修复——仅继承基类的关闭投影保护,不代表整个收藏刷新已完成。
五、复现与验证证据链
审计文档登记了完整证据(测试与产物路径以报告记录为准;当前仓库测试目录已重组,原文档引用的 test/local_refresh_lifecycle_test.dart 不再位于该路径,证据产物留存于 local-artifacts/local-refresh-lifecycle-20260910/):
- 首轮红测:受控 Future、实际控制器和 Get
onDelete构造的 15 项测试 1 PASS / 14 FAIL,失败覆盖移动模式投影、关闭后写入、迟到异常逃逸、提示过早完成、重复回调、尺寸提前提交、IPTV 失败被成功覆盖、退出后设置读取——均非初始化失败或网络抖动;红测记录20260909T231238918Z-local-refresh-lifecycle-red.json; - 绿测:同一输入修订后 15/15,记录
20260909T231505074Z-local-refresh-lifecycle-green.json;红测曾等待java#31716等构建进程自然释放,复用原会话至终态,未另起重复测试; - 扩展覆盖(共 25 项新增):同步重入、通用缓存回调、同引用快照、取消待处理尺寸、退出丢弃待处理意图、空 IPTV 首次加载,以及实际 BasePageView 400↔900 视口变化——这两项通过原始 GetMaterialApp home 挂载生产
BasePageView、确认内容节点可见后再改变测试视口,没有用直接调用布局方法替代视口事件; - 联合验证:代码提交
d8e2b7375a87ca54bf779f557aedd1e8064c155b,九文件联合 129/129,三个改动文件严格分析 No issues found。联合范围还包含首次空本地快照、收藏下拉/标签/启动策略/房间有效性、热门排序、基础页面布局和上一批分页事务测试——收藏通过的是这些既有场景,不是其独立异步生命周期; - 资源与哈希:最终记录
20260909T232135260Z-local-refresh-lifecycle-final.json为 passed,日志20260909T231644691Z-final.log;总耗时 290.461 秒(含排队),峰值 CPU 21.18%,工作集 12,904,550,400 B,结束活跃重型进程 0。提交前三个输入文件逐项重算 SHA-256 并匹配:测试F74C9B069DA6D563DB84A9F153D6CF7A73F1838B4C1FA604B3E906C5FD0E2B4F、本地基类8EB7DF55373C8615D462E2BD35F6D8EDA5C0B7E59DDC4B484E058F83F25B092E、热门派生文件5906D1B2170E8227986DBD6933041BCD1D149366B6A9A6D5B5E8236E84C05205; - 明确的不含项:没有全应用门禁、本批构建、真机或发布证据。
六、边界、留账与回滚
- Favorite 自身启动验证的外层
finally、设置读取和独立活动任务暴露需要下一批审计; - 用户管理 Firebase 专属取数、真实数据库/同步迟延、Android 横屏与 Windows 拖窗等组合仍待对应代码或原生证据;
- 本批不使用 ADB/MT/LSP,不改手机配置、不安装 APK;Android
152cf151/ Windows2fb471d3候选仍未包含近期生命周期与事务修订,正式 3.2.0 继续等待完整验收; - 宏观账本仍为 20 PASS / 32 RUN / 10 NR,42 组未完成,8 组参考平台未注册——局部测试通过不折算成宏观验收组通过;
- 回滚仅限本地基类与 Popular 本地派生类两个文件,无数据迁移;红绿测试保留以观察旧行为。
七、可迁移的工程经验
从本批审计可以提炼出几条对任何"取数在外部、投影在本地"的分页实现都适用的规则:
- 任务句柄先于副作用登记:任何会在回调中同步触发的状态写入,其所有权登记必须发生在调用之前,否则同步重入无法收敛到同一任务;
- UI 指示器归任务拥有者所有:数据发布方(投影)与任务生命周期方(loading/错误/刷新提示)职责分离,投影不得替任务"提前交卷";
- 维度提交与任务完成互为前置:布局模式、页大小只在任务间隙提交,投影只消费已提交模式,杜绝"即时宽度"这类旁路;
- 关闭检查放在异步返回点而非仅入口:数据库查询返回、排序读取、
finally复位都是潜在写入点; - 红测先行并保留产物:15 项红测先固化全部竞态形态,再修订至 15/15,配合 SHA-256 与资源记录,使"修好了什么、没动什么"可复核。
这套"活动 Future + 延迟提交 + 指示器归属 + 关闭防护"的契约,与上一批布局断点与分页事务、页大小事务共同构成了 PureLive 分页体系在桌面/移动断点快速穿越、窗口拖拽与页面关闭场景下的一致性基础。