PureLive 本地快照刷新生命周期审计:本地响应式分页控制器中外部回调、布局事务与关闭语义的治理

原创2026-10-13 16:56:214,056 阅读
文章标签:音视频直播

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 派生类两个文件,不动平台注册、播放列表排序、配置格式或版本。

二、第一处错误状态:四类竞态

审计报告逐条确认了修订前的问题形态(对应"第一处错误状态"一节):

  1. 重复刷新同时进入多个回调。基类直接调用外部回调,且没有任务句柄:连续两次刷新会各自触发一次回调;与此同时布局侧持有的 activePageOperation 仍为空,页大小也立即改变。在途快照因此没有稳定的尺寸和模式。
  2. 本地分发读取即时 Get.width,而非已提交分页模式。这绕过了公共布局事务的等待,投影结果可能与布局即将提交的维度不一致。
  3. 刷新提示过早结束。回调中 updateLocalReactivePool 会立即结束刷新指示器,但回调此时仍有异步工作在进行。更隐蔽的是:Popular 的内部 loadData 捕获错误并完成 fail 后,外层 refreshData 随后再次投影,把状态覆盖为成功/noMore——一次失败被伪装成成功。
  4. 关闭后无任何防护。异步返回后的投影、错误展示和 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 / Windows 2fb471d3 候选仍未包含近期生命周期与事务修订,正式 3.2.0 继续等待完整验收;
  • 宏观账本仍为 20 PASS / 32 RUN / 10 NR,42 组未完成,8 组参考平台未注册——局部测试通过不折算成宏观验收组通过;
  • 回滚仅限本地基类与 Popular 本地派生类两个文件,无数据迁移;红绿测试保留以观察旧行为。

七、可迁移的工程经验

从本批审计可以提炼出几条对任何"取数在外部、投影在本地"的分页实现都适用的规则:

  1. 任务句柄先于副作用登记:任何会在回调中同步触发的状态写入,其所有权登记必须发生在调用之前,否则同步重入无法收敛到同一任务;
  2. UI 指示器归任务拥有者所有:数据发布方(投影)与任务生命周期方(loading/错误/刷新提示)职责分离,投影不得替任务"提前交卷";
  3. 维度提交与任务完成互为前置:布局模式、页大小只在任务间隙提交,投影只消费已提交模式,杜绝"即时宽度"这类旁路;
  4. 关闭检查放在异步返回点而非仅入口:数据库查询返回、排序读取、finally 复位都是潜在写入点;
  5. 红测先行并保留产物:15 项红测先固化全部竞态形态,再修订至 15/15,配合 SHA-256 与资源记录,使"修好了什么、没动什么"可复核。

这套"活动 Future + 延迟提交 + 指示器归属 + 关闭防护"的契约,与上一批布局断点与分页事务、页大小事务共同构成了 PureLive 分页体系在桌面/移动断点快速穿越、窗口拖拽与页面关闭场景下的一致性基础。

登录后查看全文
pure_live