Skip to content

Scene Reader 改进与验收计划(2026-09) ​

  • 日期:2026-09-20。
  • 代码审阅基线:48083e0b4(fix(reader): prevent cover transition page flicker)。
  • 状态:部分执行(2026-09-20 已交付:§4.3 口径修正、§5.2 semantics、§6.1 范围查询、§6.2 I1 护栏、§8.1 资源窗口 planner 契约、§8.2 :reader-core 模块抽取、§8.3 两处依赖反转与路线图记录、§4.1 场景 1 A/B 基线、§4.2 场景 2/3/4 分页矩阵基线、2.5× 超时帧 trace 提取(交付 10)、zoom 长卡顿根因修复——session 关闭移出主线程(交付 11,700ms 停顿消除)、7 场景超时帧证据归档与 §4.2.1 分组 SLO 制定(交付 12,高倍率组由「待验收」转为有数值门禁)、CS-7 基准门禁脚本化与阈值判定(交付 13,--selftest 55/55,7/7 归档场景通过)、阶段 B 余项第一批——翻页样式/生命周期矩阵定性与 3 个新发现(交付 14)、阶段 B 余项第二批——分页宿主 viewport resize 丢页根因修复与生命周期 resize 用例(交付 15,5/5 通过)、阶段 D 可行性探针——immediate 过渡帧无 retained 收益,负结果收尾(交付 16)、高图 1:1 设备用例失败的归因勘误(交付 17,harness 上限 + 既有取样策略,非宿主几何缺陷)、长章节组夹具与固定窗口测量(交付 18,50/500/5000 页全部持平);B 回归矩阵其余项、残差 tile 到达调度优化与 §8.3 后续模块轮次未启动,见 §10.1 交付记录)。本文其余未执行部分仍为设计提案。
  • 范围:漫画 Scene Reader;不包含小说阅读器、视频播放器或依赖升级。
  • 关联:Scene Reader 收尾计划、ADR 0002。

1. 目标与决策原则 ​

沿用 Scene 几何、阅读语义、图片资源与 Compose 渲染分离的架构,优先补齐可复现的正确性验收, 再通过受控实验降低分页绘制成本。架构成熟度以行为、性能与维护成本为依据。

本文补充现有 CS 清单,不替代其历史实验记录,不自动修改默认开关或放宽既有门槛。 如果采用本文提出的分场景性能策略,应先同步 CS-7 和验收脚本,再据此作出 promotion 决策。

必须保持以下边界:

  1. ReaderCore 不依赖 Android、Compose 或 renderer 类型(I1)。
  2. 页码、进度、排布、缩放归属和手势语义由 core/阅读状态持有,GraphicsLayer 不成为真相之源(I3)。
  3. 替换 renderer 不要求重写阅读语义(I6)。
  4. Benchmark 与语义 parity 均通过,才允许分页 promotion。
  5. Retained layer、资源窗口统一和模块化是独立工作项,不因列入计划自动成为发布阻塞项。

2. 事实、假设与证据限制 ​

项目当前依据判定
Paged 全量扫描PagedReaderScene.slotIndexOf、resolveActiveSlot、resolve 遍历 slots;宿主 draw 使用 filter/map/sortedBy代码已核实,可优化;耗时占比未测量
Paged CPU 尾部差距收尾计划记录 Scene 7.563ms、legacy 5.200ms 的 CPU P99历史结果,不能视为本 HEAD 的复测结果
Retained layer 收益已排除两处热路径嫌疑;官方支持重放 layer 绘制指令有机制依据,收益与完整归因仍待 A/B
Semantics 缺口三个 ComposeScene* 宿主未显式提供 semantics/testTag 等能力宿主级缺口成立;完整无障碍体验需设备验证
Core 隔离当前为 app 内 package;PagedSpreadResolver 引用应用层 ZoomMode没有独立模块边界,需先处理反向依赖
高倍率性能收尾计划记录 2×/2.5× 的正 overrun 尾部仍有截止期超时,不能称为全面达到 120Hz

历史 A/B 排除若干嫌疑,并不能单独证明剩余开销全部来自 display list 重新录制。 Retained layer 是否有效,必须由同条件实验验证。

统计解释统一为:

  • frameOverrunMs > 0 表示该帧超过 deadline。
  • P99 overrun 为负不等于零掉帧,最慢约 1% 的帧仍可能超时。
  • CPU P99 小于 8.33ms 不能单独证明 120Hz 达标;CPU 工作耗时与显示 deadline 分开评价。
  • 单台设备、单个旅程或多轮 P99 的中位数,不能证明所有设备、所有帧均达标。

3. 执行顺序与依赖 ​

阶段工作对应 CS完成条件
A固定基线、修正指标口径、建立结果记录CS-1A / CS-7同条件可复测,产物可追溯
BCover/Curl 回归、恢复矩阵、基础 semanticsCS-2 / CS-3 / CS-8必需行为逐项有结论和证据
CPaged 范围查询/索引、I1 构建护栏CS-6,分页热路径语义不变、检查可执行、长章节成本收敛
DSLIDE/COVER retained layer PoCADR PoC B得出采用、调整或放弃的实测结论
E资源窗口统一、抽取 :reader-coreCS-9 / CS-6输出契约明确,依赖边界由构建约束
F可选输入/ARR 适配、Scene 2.0P4有独立需求和收益证据后启动

B 的语义验收是 promotion 前置;C/D/E 可独立交付。 若性能门禁未通过,应定位具体瓶颈,而不是预先规定必须采用 retained layer 才能转正。 每阶段完成后记录证据再启动依赖工作,不把所有改动打包成一次重构。

4. 阶段 A:基线与性能门禁 ​

4.1 固定实验条件 ​

每轮至少记录:commit 与未提交 diff、APK 哈希、包名/variant、设备与系统版本、分辨率、 刷新率/ARR 配置、温度状态、编译模式、夹具、旅程、冷暖缓存策略、迭代次数。 安装后核对实际运行包,避免 benchmark 使用旧 APK。

保留现有 CompilationMode.Full 作为历史对照;面向发布的验证另记录实际 Baseline Profile 配置, 两类结果分别展示,不直接混合比较。A/B 顺序应交错,减少温度和时间漂移影响。

4.2 场景分组 ​

组别旅程评价要求
常规阅读普通单页、双页跨章、SLIDE/COVER/CURL 往返保留 P99 overrun ≤ 0 的既有目标;同时记录超时帧比例与连续超时
大图常用倍率6000×9000,1×/1.5×,平移与翻页独立检查尾延迟、纹理上传和峰值/稳态内存
高倍率压力同夹具 2×/2.5×,快速往返与缩放切换独立 SLO;具体上限未制定前不得宣布通过或自动豁免
长章节50/500/5000 页元数据,固定可见窗口检查查询耗时与分配是否随总页数增长

高倍率分级仅为提案。若接受正 overrun 尾部,必须在测量改动收益前固定: P95/P99 上限、超时帧比例、连续超时帧限制、内存上限及相对基线退化容忍度。 没有足够数据时保持“待验收”,不能用“可控尾部”代替数值。

4.2.1 分组 SLO(2026-09-20 制定,交付 12) ​

上段的前置条件已满足:下列数值在测量任何优化收益之前,依据 67a243fc6 上 7 个场景的基线分布 (§10.1 交付 12;每场景 5 迭代,trace 与 harness JSON 逐样本核对)固定。后续 A/B 与优化轮次 不再改动;需要改动时按本节末尾的「重设条件」重新立项并留证据。

判定口径(三组共用)

  • overrun = max(actual.end, RT.end) − expected.end;本配置(1280×2772、请求 120Hz)的平台 expected 预算为固定 13.6666ms,不是裸 vsync 周期,也不是 8.33ms。
  • 分位数用合并样本的 (N−1)×p 线性插值;比例 = 合并超时帧数/合并帧数;最大连续超时按单轮 有序帧序列计数,不跨迭代连接。
  • 超时比例、最大连续超时与最差帧必须来自同一次运行归档的 trace,并与 benchmarkData.json 的 frameCount/frameDurationCpuMs/frameOverrunMs 逐样本一致(scripts/analyze_reader_frames.py 在不一致时直接报错)。只有 JSON、没有 trace 时不得判定通过。
  • 判定前必须记录:commit 与未提交 diff、APK sha256、安装核对(lastUpdateTime 变化)、 refresh 前置(≥119Hz)、每场景运行前后的电池温度。
维度常规阅读组(单页 / 双页)大图常用倍率组(大图 1× / 1.5×)高倍率压力组(2.0× / 2.5×)
overrun P95≤ 0≤ 0≤ 0(≥95% 帧在预算内)
overrun P99≤ 0≤ 0≤ +6ms
超时帧比例≤ 0.5%≤ 0.5%≤ 2.5%
最大连续超时≤ 1 帧≤ 1 帧≤ 3 帧
最大单帧 overrun≤ 20ms≤ 20ms≤ 80ms
最大主线程帧≤ 50ms≤ 50ms≤ 80ms
RssAnon Max≤ 400MB≤ 550MB≤ 650MB
硬性失败类(三组一致)任一主线程帧 ≥100ms 或单帧 overrun ≥100ms同左同左

基线实测(上表数值的来源;各场景 5 迭代合并)

场景帧数超时比例最大连续overrun P95/P99最大单帧最大主线程帧RssAnon Max
单页 1×2,9920.067%1−7.95 / −5.49+2.713.6323MB
双页往返4,9100.020%1−7.94 / −5.36+0.111.7229MB
大图 1×3,0430.066%1−7.97 / −5.36+0.811.8242MB
大图 1.5×3,1010.097%1−7.92 / −4.48+3.311.2436MB
2.0×4,1181.918%3−8.09 / +5.70+21.928.6532MB
2.5× held(fit-height,开 1×)3,1250.416%3−7.76 / −4.10+21.027.8423MB
2.5× 开即 2.5×3,9841.632%2−8.25 / +3.16+16.227.1528MB

判读要点:

  1. 接受正 overrun 尾部的前提被显式固定:P99 允许为正的前提是同时受「比例 ≤2.5%、 连续 ≤3 帧、单帧 ≤80ms、主线程帧 ≤80ms」四项约束;P99 为负不能代替掉帧结论 —— 2.5× held 的 P99 为 −4.10ms,却仍有 0.416% 超时与 3 帧连续超时,正是 §4.3 口径警告的情形。
  2. 硬性失败类来自交付 10/11 的证据:主线程同步关闭 region decoder 曾造成 723–753ms 单段睡眠;任何 ≥100ms 的主线程帧都按该类回归处理,不再看比例。
  3. 跨轮可复现:2.5× 开即 1.632% / P99 +3.156ms(本轮)与交付 11 B 侧 1.628% / +3.116ms 几乎逐项相同;其余六个场景与交付 8/9 的数字同量级(详见交付 12),因此该组数值可作为门禁。
  4. 三组 SLO 只对本轮固定条件成立:M332BF / 1280×2772 / 请求 120Hz(预算 13.6666ms)/ CompilationMode.Full / 5 迭代 / pagedTurns 旅程 / 现有 fixture。

相对基线退化容忍度(后续 A/B 与优化轮次):CPU P99 ≤ 基线 +0.5ms;overrun P99 ≤ 基线 +1.5ms;超时帧比例 ≤ 基线 +0.5pp;最大单帧 overrun ≤ 基线 ×2 且不越过组上限; RssAnon Max ≤ 基线 ×1.10;GPU Max ≤ 基线 ×1.10。

重设条件:设备、分辨率/刷新率、夹具、旅程、编译模式或迭代数任一变化,必须重设基线; 出现设备温度 >38°C、残留 root perfetto 进程、可用内存异常等状态偏差时(真机验证方法第 6 条), 不得跨状态比较 P99/overrun,须在同一状态下重测。

2026-09-21 适用范围声明(交付 35):本节全部场景与 SLO 判定的测量路径是 benchmark 活动(ReaderProductionBenchmarkActivity),它使用自实现的 BenchmarkProductionImagePipeline,因此不经过生产图像管线 (KototoroImagePipelineAdapter / DecodePlanner 的 LOD、tile 阶梯、硬件位图策略均不在路径上; 该文件中构造该适配器的次数为 0,实测)。由此:

  • 上表与 §10.1 中的所有结论只适用于「场景宿主 + Compose 渲染器 + 布局/输入」;
  • harness JSON 里的 tile/解码类计数按构造即为 0,不能据此判断「页面是否走 tile」;
  • 任何关于解码策略、LOD、位图驻留或画质分辨率的因果结论,都不得由 benchmark 帧时序得出; 这类测量必须走真实阅读入口(ReaderActivity)。 要让 journey 改走真实适配器,属于「评估条件变化」,须按上面的重设条件重新立项并重设全部基线; 本文不做该改动,只声明边界。来源代码 ReaderProductionBenchmarkActivity.kt:518 的 KDoc 已同步。

2026-09-21 门控保真度声明(候选路径 ≠ 当前默认路径):本节场景与 SLO 判定的对象是 分页场景宿主,而该宿主在 release 默认配置下并不启用:

  • 门控是两个按系列独立的开关(2026-09-21 交付 45 起,此前是 A && B 的合取): isExperimentalSceneReaderEnabled(ReaderSettings.kt:34,默认 true)= 条漫系列 (WEBTOON)的渲染器开关;isExperimentalPagedSceneReaderEnabled(ReaderSettings.kt:35, 默认 false)= 分页系列(单页/双页/上下)的渲染器开关。归属由 resolveSceneReaderEnabled(mode, isDoublePage, webtoon, paged)( reader/ui/config/SceneReaderGate.kt)唯一决定,ComposeReaderScreenRoot 三处宿主分支都走它。 默认配置下单页/双页由 legacy ComposePagedReader 承担,场景分页宿主只有打开该开关的 用户(设置页或阅读器更多面板的「阅读」页)会遇到。CONTINUOUS_HORIZONTAL 是唯一的 scene-only 模式(没有 legacy 渲染器可回退),因此不受条漫开关影响。 历史注记:交付 42/43 的真实入口测量是在旧合取门控下做的(当时两个开关都为真,分页场景 宿主处于启用状态),独立化不改变那两轮的配置,也不影响其结论。
  • benchmark 绕过该门控:ReaderProductionBenchmarkActivity.kt:227 直接定 ReaderMode.STANDARD、 :239 直接构造 ComposeScenePagedReader(...);app/src/benchmark/ 全目录对 isExperimental* 与 AppSettings 的匹配数为 0(实测)。因此该设计对 promotion 判定是正确的 —— 门禁要测的 正是「若把候选路径变成默认,用户会付出什么代价」;但任何把本节数字读作「当前用户体验」的表述 都是错的,用户拨动设置页里的开关也不会改变本节任何测量结果,历史归档与 SLO 数字不因此作废。
  • 覆盖面与暴露面是反的:CS-7 门禁的 7 个场景全部是 paged*SceneFull (scripts/check_reader_benchmark.py:86-92),即门禁只覆盖默认关闭的那条路径; 而默认启用的 Webtoon/Horizontal 场景宿主没有任何帧时序门禁。
  • 由此产生的口径要求:①「1.5× 违规」描述的是候选路径的代价,不是现有 release 用户的卡顿; ② 若要回答「真实用户是否遇到」,测量必须走真实阅读入口(ReaderActivity),且两侧都必须把 isExperimentalPagedSceneReaderEnabled 当作受控实验条件固定并记录 —— benchmark 变体没有 applicationIdSuffix,与 release 同为 org.skepsun.kototoro、还直接吃 src/release/ 源码 (app/build.gradle:106/162-163),因此两者共享数据目录与 prefs,开关状态不一致时两侧的 「真实入口」根本不是同一个渲染器(比后台 worker 更隐蔽的一类污染,见交付 41)。

2026-09-20 修订(CS-7,交付 13):把本表落成可执行门禁时,用真实历史样本回放判定器, 发现高倍率组原来的 50ms 单帧/主线程帧上界会把交付 11 已修复的构建判失败 (该构建实测过 +50.518ms 单帧)。这两项上界据此放宽到 80ms(基线最大 21.9ms / 28.6ms, 仍留 2.7–3.7× 余量,硬性失败类 ≥100ms 不变),其余数值未改。改动是「把口径变成门禁」 的验证产物,不属于设备/夹具变化导致的重设。

4.3 必须输出的指标 ​

  • CPU 与 overrun 的 P50/P95/P99,各轮值及汇总方法。
  • 超时帧比例、最大连续超时帧数和最差帧 trace;汇总工具不提供时从 trace 提取。
  • RSS/GPU 峰值与结束值,多轮遍历后的趋势。
  • presentation assets、tile 驻留/解码/驱逐计数与分配/GC 证据。
  • 场景失败、空白页、闪烁、错误进度等正确性结果。

新增自动判定脚本时必须先用通过/失败样本验证其判断;缺少必需字段应判为证据不足。 修正现有计划中“P99 为负即零掉帧”的表述,同时保留原始历史数字。

5. 阶段 B:语义与视觉正确性 ​

5.1 最小回归矩阵 ​

维度必测行为
翻页样式SLIDE/COVER/CURL,前进/后退、拖动取消、快速反向、动画被新输入打断
方向与排布LTR/RTL/TTB;单页/双页、封面偏移、宽页独占、跨章双页阻断
缩放归属平移到边界后残余位移翻页;切页保留、回翻恢复;切模式后旧手势取消
生命周期旋转、分屏尺寸变化、后台进程死亡后恢复;区分 Activity 重建与真正进程重建
异步内容冷加载、tile 延迟到达、失败重试、动图;翻页时不冻结或显示上一页资源
输入触摸、TalkBack 动作、DPAD、音量键;边界动作不产生错误进度
连续模式Webtoon 与连续横向的方向、章节边界和已有门控要求

恢复检查使用章节/PageId 与归一化页内锚点,不以旋转前后的绝对像素相等作为判据。 组合矩阵覆盖风险交叉点;不用无差别穷举所有组合,但每个维度必须有证据。

5.2 Semantics 实现范围 ​

先为 viewport 提供稳定的语义节点:页码/总页数、章节、必要状态描述和上一页/下一页动作。 状态更新跟随阅读语义变化,不把每帧 offset 暴露为播报状态。

  • 复用现有导航入口,避免新增一套无障碍翻页逻辑。
  • 使用稳定 viewport testTag,页身份用语义属性表达,避免 tag 随帧变化。
  • 双页描述要反映实际阅读顺序;首尾页不可执行的动作应正确禁用或不提供。
  • 章节跳转、缩放动作按已支持能力添加,不虚构产品行为。
  • 错误重试等独立控件保留自己的可访问节点;viewport 节点不能吞掉它们。
  • TalkBack 焦点、键盘/DPAD 焦点分别验证;testTag 不能替代可访问性。

验收:至少有经 semantics 定位并执行翻页的 instrumentation 测试,以及 TalkBack 真机记录。 视觉问题保留截图或视频;语义/恢复问题保留可自动断言的状态。

6. 阶段 C:Paged 查询与依赖护栏 ​

6.1 Paged 范围查询 ​

利用固定 slot primary extent 直接定位候选范围,然后枚举实际相交 slots:目标复杂度为 O(1) 范围定位 + O(k) 候选处理。保留精确相交判断,不假定所有 viewport 永远只覆盖两页。

resolveActiveSlot 的最近中心语义和 resolve 的最大相交面积语义分别保留,尤其要保持 等距/等面积时的选择规则、页序和 progress anchor 不变。

建立 PageId 到 page index/slot index 的索引,并在重建 slots 时更新。 几何提示更新、双页重新分组及章节变化后,索引不得沿用旧位置。

绘制、transition 固定页、资源预取可以共用基础范围计算,但分别构造各自集合: 预取集合通常大于可见集合,固定页还涉及变换后的屏幕可见区域。 不要为减少扫描而把三者强制设为同一范围。

优先局部函数与现有类方法;出现明确复用需求后再提取 resolver。

验收:

  • 用原线性算法作为测试 oracle,对随机及边界 viewport 比较 nodes、顺序和 progress。
  • 覆盖空场景、首尾越界、slot 边界、等距、宽 viewport、双页、方向和重建索引。
  • 50/500/5000 页固定窗口测量证明查询成本不再线性增长;重建成本单独统计。
  • 普通章节 benchmark 无明显回归,不预先承诺 P99 收益。

6.2 I1 护栏 ​

先增加可被 check/CI 执行的依赖检查,禁止 core 引入 Android、Compose 和 renderer 类型。 若采用文本扫描,应覆盖全限定名与 import alias,并明确它不是完整的依赖图证明。 用故意违规的测试夹具验证护栏确实失败,避免仅测试现有目录恰好通过。

同步列出 core 对 app 类型的引用;优先处理 ZoomMode 的归属或边界映射,为模块化准备。 不为抽模块创建空壳接口或大量一对一转发层。

7. 阶段 D:Retained GraphicsLayer 实验 ​

7.1 范围与所有权 ​

首轮仅支持 SLIDE/COVER,保留 immediate 路径作为对照与不支持内容的回退。 图层属于 Compose renderer;scene、分页状态、zoom/pan 及进度所有权保持不变。

图层记录 slot 局部坐标的页面内容,transition 变换与裁剪在其外部应用。 相同内容的位移变化不应触发重新 record;是否在静止后立即释放由测量决定。 GraphicsLayer 保存绘制指令,不等于自动生成或永久缓存整页位图。

7.2 失效与生命周期契约 ​

变化要求
翻页进度、外部位移只更新必要变换/裁剪,不重新录制静态内容
图片/tile 到达、重试成功、LOD 替换内容版本变化,重新录制受影响图层
动图换帧更新相应图层,或明确回退 immediate;不得冻结动画
slot 身份、排布、viewport 尺寸变化重新计算内容与图层尺寸,废弃不兼容记录
zoom/pan 变化可分离变换单独更新;可见内容或 LOD 变化仍需失效
取消、跳页、反向拖动、宿主销毁正确复用或释放,不能遗留旧页引用
资源驱逐图层仍使用的资源不得被提前回收;资源固定必须计入预算

内容版本使用现有可观察资源状态扩展,避免每帧扫描全部 tile 来计算签名。 特别验证“初次只录到窄可见带,随后固定页显示整屏”的 COVER 情况,防止重新引入闪烁。

7.3 实验方法与退出条件 ​

在同一版本、夹具和设备上切换 immediate/retained,除渲染策略外保持条件一致。 分别测量热资源、冷资源、异步 tile 到达和快速往返;增加 record 次数、首个过渡帧耗时、 存活图层数及其资源引用指标。预期目标是内容不变时 record 次数不随动画帧数增长。

采用条件:正确性矩阵通过;预先固定的性能目标有可重复改善;首帧、尾延迟和内存没有超出 已制定的回归容忍度;所有资源在取消/结束/销毁后按策略释放。

若收益落在噪声内、频繁内容失效抵消收益、或内存/首帧明显退化,应调整或放弃,记录负结果。 不得为了保留方案而扩大缓存预算或放宽门槛。CURL 仅在首轮证据成立后单独实验, 届时可评估保留页面内容、即时计算折叠路径与阴影的组合。

8. 阶段 E:资源契约与模块化 ​

8.1 资源窗口统一 ​

由 scene/阅读策略计算可见、固定、预取需求,统一输出资源窗口契约;连续与分页保留各自策略。 ImagePipeline 根据资源需求执行加载与预算管理,不接收用于决定阅读行为的模式分支。

能从需求分布猜出阅读模式不等于依赖泄漏;验收重点是管线是否依赖模式枚举或阅读规则。 契约需明确页身份、优先级、所需区域/LOD 信息、保留期限及取消/替换规则,具体字段按现有消费者确定。

验证范围:不同方向的前瞻、快速反向、章节切换、transition 固定页、缩放 LOD 与预算压力。 要求不缺失屏幕实际所需资源,窗口/资产数量有界,旧请求不会覆盖新状态。

8.2 抽取 :reader-core ​

先完成 app 类型依赖审计,再抽纯 Kotlin/JVM 模块:geometry、scene、camera、progress、drag 和 纯 transition 语义模型。将对应单测迁入,app 单向依赖 core。

模块不添加 Android/Compose 编译依赖;保持 I1 护栏,防止以后通过新增依赖重新破坏边界。 首轮不拆 :reader-image / :reader-render-compose,不顺带引入 KMP;JVM 模块化不等于 commonMain 兼容。

验收:core 可独立测试,app Kotlin 编译通过,相关集成/手势测试通过,依赖图无回指 app。

8.3 后续模块化路线图(外部评审输入,2026-09-20 记录) ​

外部评审建议提出四层拆分目标,与 §8.2 的「首轮只抽 :reader-core」兼容,作为后续轮次的 结构记录(每轮独立交付、独立证据,不打包执行):

text
reader-core(已完成,2026-09-20 交付 6)
        ↓
scene-image        // reader/image 的 tile/LOD/residency 引擎,隔离 KototoroImagePipelineAdapter
        ↓
scene-compose      // reader/render/compose 的 Draw-phase renderer + 输入 + semantics
        ↓
kototoro-reader-adapter  // Kototoro 自有:ReaderPage→ScenePage、枚举映射、Coil/PageLoader 桥接

已采纳的判断:

  • 多模块优先,暂不独立仓库、暂不 KMP:与 §8.2 约束一致(JVM 模块化 ≠ commonMain 兼容; 无真实多平台消费者之前不为假想未来付 KMP 工具链复杂度)。发布级命名(scene-reader-* 或其他) 留到真的拆仓库时再定,当前 :reader-core 名称与本仓库 reader/* 词汇一致。
  • 不新造已有抽象:ReaderImagePipeline / TileStore / ComposeReaderImagePipeline 契约 已存在;后续轮次是让消费者依赖既有抽象,而不是引入平行的 SceneImagePipeline / SceneImageSource 新接口(避免一对一转发层)。
  • Phase C(ScenePage/SceneReaderConfig 替代 ReaderPage/ReaderAnimation/ZoomMode 等 宿主模型)是宿主公共 API 重构:须按仓库工作流(brainstorm → 设计 → TDD)单独一轮,验收含 三宿主迁移与手势/语义/恢复测试全绿;chapterId 语义推广为「不能跨组配对的连续内容组」(group) 的想法在该轮设计时一并评估。
  • Phase D(三宿主封装为单一 public SceneReader API)在 C 之后;不为「更库化」提前收窄 API。

对该评审输入的当日执行与修正记录:

  1. 其指出的两处耦合属实且当日已修复(交付 7):SceneImagePresentationCoordinator 改依赖 ReaderImagePipeline(requestTiles 上提为契约方法);Listener 从具体类 ReaderTileManager 移入 TileStore 接口。
  2. 其「进一步直接 KMP(androidTarget/iosArm64/jvm)」与本计划 §8.2 明确约束冲突,按本计划执行: KMP 等真实多平台消费者出现后再立项。

9. Promotion 与后续工作 ​

分页 promotion 检查表:

  • [ ] 当前候选 APK 与 commit 可追溯。
  • [ ] CS-1A 性能验收通过;所有例外都有明确范围、数值与决策记录。
  • [ ] CS-2/CS-3 和阶段 B 必需语义矩阵通过,无未处理的功能退化。
  • [ ] 无资源泄漏、持续内存增长、错误进度或可复现空白/闪烁。
  • [ ] nightly 观察与回退路径明确;翻默认值不同时清除所有对照与回退能力。
  • [ ] 默认开启后的证据充分,再按原 CS-4/CS-5 清退 legacy host 与实验开关。

不以完成 retained layer 或模块化作为成功的替代指标。 若既有 immediate renderer 已满足门禁,其性能优化可继续独立迭代。

后置工作包括 scrollable2D 输入适配、Compose preferredFrameRate adapter、译文 overlay、 scene-aware OCR/SR。只有在独立需求明确且能简化实现或改善实测体验时启动;不为“更 Compose”重写成熟手势。

10. 验证命令与交付记录 ​

沿用仓库 wrapper,并按改动范围选择验证:

bash
./gradlew :app:compileDebugKotlin
./gradlew :app:testDebugUnitTest :reader-core:test --no-daemon
./gradlew :reader-core:test --no-daemon   # 模块独立测试(§8.2 验收:core 可独立测试)
./gradlew :app:connectedDebugAndroidTest

设备测试与 macrobenchmark 按实际测试类筛选;运行前检查安装包与测试 variant, 不因本文列出命令就宣称它们已经通过。模块抽出后的独立测试命令在实施时补充。

每项交付记录:修改范围、关联 CS、测试结果、设备/构建身份、原始结果路径、已知限制和下一步决策。 性能结果至少使用以下表格,不只给出平均值或单个最优轮次:

候选/基线场景CPU P99overrun P99超时比例/最长连续超时RSS/GPU Max/Last正确性结论
待测待测————待验收不作通过判断

10.1 交付记录(2026-09-20,devel @ 48083e0b4 + 本次工作树改动) ​

设备/构建身份:M332BF - 17(ecd4369c,arm64-v8a),debug variant;JVM 测试在 macOS/Temurin 24。 真机 instrumented 结果 XML:app/build/outputs/androidTest-results/connected/debug/(Gradle 运行后即被卸载, 原始文件随运行覆盖);JVM 结果:app/build/test-results/testDebugUnitTest/。

交付 1 — 阶段 A:统计口径修正(§4.3) ​

  • 修改范围:closure plan 两处(场景 1 判读"没有丢帧"、全矩阵判读"即 120Hz 下没有丢帧")与 ADR 0002 两处("零掉帧"、"无掉帧")改为分位数口径:P99 为负仅说明 ≥99% 帧未超时且有余量, 最慢约 1% 帧是否超时需超时帧比例/最差帧 trace 核对。历史数字全部保留,修正处标注本文出处。
  • 关联 CS:CS-7(文档债)。
  • 测试:文档改动,无。
  • 已知限制:固定实验条件(§4.1)与结果记录流程未建立,属后续真机 benchmark 工作。

交付 2 — 阶段 C:PagedReaderScene 范围查询与索引(§6.1) ​

  • 修改范围:reader/core/PagedReaderScene.kt
    • slotIndexRangeFor(bounds):固定 slot primary extent 上的 O(1) 索引算术候选范围(低位一档余量), 布局非均匀 stride 时自动回退全量线性扫描(rebuild 时校验,容差 min(0.5px, 5% stride))。
    • slotsIntersecting(bounds):候选范围内保留逐 slot 精确正面积相交过滤,即 draw 可见集。
    • resolve() 候选枚举 + resolveActiveSlot() O(1) 最近中心(V 形距离两候选 + 实际 bounds 计距), 严格小于的 tie 规则与线性扫描逐字节一致(等距取低 slot、等面积取早 slot)。
    • pageIndexById / slotIndexByPageId HashMap 在 rebuildSlots() 内重建;几何提示更新、 双页重分组、章节前后增删后索引随位置更新(重复 PageId 取首个,与线性一致)。
  • 宿主热路径(ComposeScenePagedReader):draw 循环、updateResourceWindow 的 contentNodes、 loading placements 三处 allSlots.filter/for 全量扫描改用 slotsIntersecting; 预取(active slot ± lookahead)与 COVER 固定页保持各自索引算术,未合并为同一范围。
  • 测试(先红后绿):PagedReaderSceneRangeQueryTest 17 用例。oracle = 原线性算法逐字复刻; fuzz 80 场景 × 30 viewport(随机方向/双页/封面偏移/4 种 zoomMode/宽页/切半/跨章/零尺寸/越界 viewport)
    • 边界用例(空场景、首尾越界、slot 边界对齐、等距中心、等面积、宽 viewport 三方向、重建索引、 scroll position/origin)。已存在的 PagedReaderSceneTest/PagedSpreadResolverTest/ PagedSlotScreenVisibilityTest 全部保持绿。
  • 成本证据(JUnit system-out,同上 XML):固定窗口 1000 次查询(200 次预热): 50 页 1665ns、500 页 1243ns、5000 页 236ns per query —— 不随页数增长(线性扫描量级应 ~100×); rebuild 单独统计 0.12/0.51/4.57ms(O(n) 按设计)。
  • 关联 CS:CS-6、分页热路径。
  • 已知限制/下一步:"普通章节 benchmark 无明显回归"(§6.1 验收第 4 条)需 CS-7 真机门禁流程与固定 基线(阶段 A 余项),本文不据此宣称 P99 收益。

交付 3 — 阶段 C:I1 护栏与 ZoomMode 归属(§6.2) ​

  • ReaderCoreIsolationGuardTest(随 check 执行):扫描 reader/core/**(真实树 >20 文件) 必须零违规 —— 禁止 Android/Compose/renderer/app 层引用,覆盖 import(含 alias)与全限定名, 注释剥离避免误报;KDoc 明示文本扫描不是完整依赖图证明(完整边界需 §8.2 模块抽取)。 5 个故意违规夹具(app/src/test/resources/reader-core-isolation-fixtures/:Android import、 Compose alias import、renderer FQN、inline android 类、注释提及 Android 的干净夹具)逐一验证 护栏确实会失败 / 不误报。
  • ZoomMode 归属:core/model/ZoomMode.kt → reader/core/ZoomMode.kt(git mv + 包名), 17 个引用点更新 import;reader/core 对 app 类型引用审计后清零(此前唯一引用即 ZoomMode)。 未创建空壳接口或转发层。
  • 测试:compileDebugKotlin、compileDebugUnitTestKotlin、compileDebugAndroidTestKotlin、 compileBenchmarkKotlin 全部通过;testDebugUnitTest 全绿。
  • 关联 CS:CS-6。
  • 下一步:§8.2 :reader-core Gradle 模块抽取(把 I1 从文本护栏升级为编译期约束)。

交付 4 — 阶段 B:viewport semantics(§5.2) ​

  • 修改范围:三个宿主(ComposeScenePagedReader / ComposeSceneWebtoonReader / ComposeSceneHorizontalReader)+ 共享 SceneReaderViewportSemantics.kt + 6 条中英字符串 (reader_a11y_*)。
    • 稳定 testTag:kototoro.reader.scene.pagedViewport / webtoonViewport / horizontalViewport (不随帧/页变化);页身份经语义属性表达。
    • contentDescription 用 settled 页窗口(lower–upper,双页反映实际阅读顺序),不把每帧 offset 暴露为播报状态。
    • 上一页/下一页/上一章/下一章 custom actions 复用既有导航入口(从 requestedPage effect 提取的 navigateToSlot / navigateToPagePosition 与 onPullChapter),未新增无障碍专用翻页逻辑; 首尾页不提供不可能动作。
    • 不 merge descendants:错误重试、加载占位等子控件保留自身可访问节点。
  • 测试(先红后绿,真机 ecd4369c):SceneReaderViewportSemanticsTest 经 framework AccessibilityNodeInfo(TalkBack 同路径)按 contentDescription 定位 viewport、执行 "Next page" 动作真翻页、断言第 1 页无 "Previous page"/第 4 页无 "Next page"。红验证:临时禁用 semantics 后同测试失败("viewport description did not settle"),恢复后通过。
  • 真机回归:ScenePagedGestureTest 11 用例 10 过;唯一失败 originalSizeCanPanVerticallyWithoutUserZoom 在未改动 HEAD(48083e0b4,同日基线)同样失败(同断言黑像素),属既有设备问题;连续满负荷运行时 失败集随热状态漂移(HEAD 亦然,A/B 交错对比过)。SceneReaderRecoveryTest 1/1 过。
  • 关联 CS:CS-8。
  • 已知限制/下一步:TalkBack 真机手动记录、DPAD/键盘焦点、§5.1 回归矩阵其余逐格证据未完成; 属阶段 B 余项。

交付 5 — 阶段 E:资源窗口 planner 契约(§8.1) ​

  • 修改范围:
    • 新增 reader/core/SceneResourceWindowPlanner.kt:统一 planner 契约 (plan(SceneResourceWindowRequest) → ReaderResourceWindow?,null=快照未变化、保持现窗口)。 输出契约写入 KDoc 并由测试强制:完整快照(缺页即可驱逐)、可见页覆盖且唯一、 IMMEDIATE⇒PRESENTATION_READY、SOURCE_READY 仅限非可见页、模式不透明(窗口只含页身份/优先级/ 就绪度/区域,管线无需阅读模式分支)、有界(随视口与 lookahead 而非场景规模增长)。
    • 新增 reader/core/PagedSceneResourceWindowStrategy.kt:从分页宿主逐字提取的请求列表 (活动槽+COVER 固定槽 IMMEDIATE、其余可见页 HIGH PRESENTATION、固定槽 lookahead 后方 MEDIUM / 前方 HIGH SOURCE_READY、先到先得去重;活动槽索引由 frame viewport 沿主轴 round(offset/extent) 推导,无需宿主状态)。
    • ReaderPrediction 实现同一接口(连续 strategy,含窗口键抑制语义)。
    • 三个宿主改经 planner 产出窗口:分页宿主删除手写请求列表(保留进度上报、transition 固定槽解析 与 tile 协调在宿主侧——后者依赖宿主 zoom 变换);连续两宿主改调 plan(SceneResourceWindowRequest)。 管线/adapter 侧零改动(本就只消费 ReaderResourceWindow)。
  • 测试(先红后绿):SceneResourceWindowPlannerContractTest 11 用例 —— 分页提取 oracle (活动/固定槽、部分可见邻槽、lookahead 1 vs 2、越界钳制、双页槽、垂直分页、空场景、零视口、 非 PagedReaderScene 拒绝)、连续 strategy 委托等价(含 null 抑制)、两 strategy 共享契约扫掠 (分页 -500..5500 offset×3 pinned + 连续 -500..7500×3 motion)、有界性(≤6 请求/快照)。 两个测试预期先错(末槽 ahead 越界全为 MEDIUM、offset 500 时 lookahead+2 含 page 4),按提取的 实际行为修正——实现本身为逐字提取未改动。
  • 验证::app:testDebugUnitTest 全绿;compileDebugKotlin / compileDebugUnitTestKotlin / compileDebugAndroidTestKotlin / compileBenchmarkKotlin 全过。 真机 ecd4369c(单类分跑):SceneReaderViewportSemanticsTest 1/1、SceneReaderRecoveryTest 1/1、 ScenePagedGestureTest 10/11(唯一失败为交付 4 记录的既有设备问题,HEAD 同败)。
  • 关联 CS:CS-9。
  • 已知限制:契约的「保留期限」仍是隐式的(窗口=完整期望集合,缺页可驱逐)——按 §8.1「具体字段按现有 消费者确定」暂不加显式字段;下一步 §8.2 模块抽取时随 :reader-core 一并固化。

交付 6 — 阶段 E::reader-core 模块抽取(§8.2) ​

  • 修改范围:
    • 新 Gradle 模块 reader-core/(纯 Kotlin/JVM,org.jetbrains.kotlin.jvm,JVM_11 字节码, Groovy DSL):git mv 迁入 reader/core 全部 32 个主源文件(geometry、scene、camera/ progress、drag、transition、prediction、planner 契约)+ 19 个测试类 + I1 违规夹具资源。 未创建空壳接口或转发层;未引入 KMP 结构(JVM 模块化 ≠ commonMain 兼容)。
    • settings.gradle 纳入 :reader-core;app 单向 implementation project(':reader-core')。
    • ReaderCoreIsolationGuardTest 随迁并改为扫描模块自身源码/夹具路径:编译期类路径成为 I1 主边界(模块 build.gradle 无任何 Android/Compose/renderer 依赖),文本扫描降级为第二 道防线(仍捕捉方法体内的 FQN 引用),KDoc 更新如实说明两层关系。
    • WebtoonViewportPolicyParityTest 留在 app 测试源集(桥接 core 与旧 UI 测量函数,依赖 app 类,非纯模块测试)。
    • §10 验证命令补充模块独立测试命令。
  • 验证(全部实际运行):
    • :reader-core:test 独立运行:143 用例 0 失败(含护栏 6 用例 + planner 契约 11 用例);
    • :reader-core compileClasspath 仅 kotlin-stdlib —— 依赖图零回指(编译期保证);
    • :app:compileDebugKotlin / compileDebugUnitTestKotlin / compileDebugAndroidTestKotlin / compileBenchmarkKotlin 全过;:app:testDebugUnitTest 全绿(含迁回的 parity 测试);
    • 真机 ecd4369c:semantics 1/1、recovery 1/1、gesture 10/11(唯一失败为 HEAD 同败的既有 设备问题,见交付 4)。
  • 构建细节记录:KGP 一致性检查要求 compileJava/compileKotlin 同 JVM target(模块无 Java 源, 显式对齐 11);kotest-runner-junit5 只带 jupiter api 不带 engine —— app 侧由其他测试依赖 传递补齐,纯模块需显式声明 org.junit.jupiter:junit-jupiter:5.8.2(与 kotest 的 platform 1.8.2 / jupiter-api 5.8.2 版本对齐)。
  • 已知限制:本仓库 CI(debug/nightly/release workflow)只执行 assemble 不跑单测,护栏与全部 单测仍依赖本地/手动执行 ./gradlew :app:testDebugUnitTest :reader-core:test(已在 §10 记录)。

交付 7 — 阶段 E:两处依赖反转(§8.3 评审输入当日修复) ​

来源:外部评审指出的两处耦合,经代码核实属实后当日修复(均为小反转且有既有契约可依, 不新增平行抽象,符合 §8.2「不创建空壳接口或一对一转发层」)。

  1. requestTiles 上提为 ReaderImagePipeline 契约方法(§8.1「契约需明确…所需区域/LOD 信息」 的补全;接口方法为抽象 —— 无 tile 能力的实现必须显式 no-op,调用方永不按管线类型分支); SceneImagePresentationCoordinator.coordinateVisibleTiles 参数类型从具体 KototoroImagePipelineAdapter 改为 ReaderImagePipeline,renderer 层不再知道具体管线类。
  2. Listener 从具体类 ReaderTileManager 移入 TileStore 接口(声明逐字迁移); TileDrawModifierNode 与两个测试文件的 7 处 ReaderTileManager.Listener 引用改为 TileStore.Listener —— Compose renderer 不再为监听器类型引用具体解码引擎。
  • 测试(先红后绿):SceneImagePresentationCoordinatorTest 先以纯 JVM RecordingPipeline (实现 ReaderImagePipeline)新增 3 用例 —— tiled 可见节点经契约路由(pageId+映射区域)、 非 tiled 资产不请求、零尺寸/未保留节点跳过;编译红(接口缺方法 + 参数要具体类型)后实现转绿。 该协调器的 coordinateVisibleTiles 首次获得 JVM 级测试(此前依赖具体 adapter 无法在 JVM 构造)。
  • 验证::app:testDebugUnitTest 全绿;compileDebugKotlin / UnitTest / AndroidTest / Benchmark / :reader-core:test(143)全过;真机 ecd4369c:semantics 1/1、recovery 1/1、 gesture 10/11(唯一失败为 HEAD 同败既有设备问题)。
  • 关联:§8.1(契约区域信息)、§8.3(scene-image / scene-compose 拆分的前置反转)。

交付 8 — 阶段 A:场景 1(普通单页)A/B 交错基线(§4.1 首项) ​

目的:验证 7 项交付(范围查询、planner 契约、:reader-core 抽取、两处反转等)对普通章节 翻页性能「无明显回归」(§6.1 验收 4),并建立 §4.1 固定条件记录流程。

固定条件(§4.1 全项):

  • A = HEAD 48083e0b4(干净树,APK sha256 b684edd1…);B = 工作树 7 项交付 (APK sha256 332d461f…)。每轮安装前核对 sha256、安装后核对 dumpsys lastUpdateTime。
  • 设备 Redmi M332BF(warsaw),Android 17(SDK 37,CP2A.260605.016,OS4.0.0.9), 1280×2772 @ 60Hz 固定模式(presDeadline 16.7ms),电池 75% 充电中。
  • 温度趋势 33.8→36.8°C(电池温度);A 轮 35.8–36.2,B 轮 36.4–36.8(B 平均略暖,已记录)。
  • CompilationMode.Full,5 迭代/轮,A/B 交错 ×3 轮(顺序 A1 B1 A2 B2 A3 B3)。
  • 场景:pagedSingleSceneFull(普通单页 1×,fixture paged,scene_paged 后端)。

结果(各侧 3 轮中位数):

指标A (HEAD)B (工作树)Δ
frameCount620625旅程一致
CPU P50 (ms)2.52.50
CPU P90 (ms)4.24.4+0.2
CPU P95 (ms)4.64.8+0.2
CPU P99 (ms)7.07.4+0.4
overrun P50 (ms)-10.3-10.30
overrun P95 (ms)-8.1-7.9+0.2
overrun P99 (ms)-5.6-5.5+0.1
RssAnon Max (KB)228,532228,568+36(≈0)
RssAnon Last (KB)175,156176,664+1.5MB
HeapSize Max (KB)125,188124,881-0.3MB
Gpu Max (KB)86,77686,7760

判读(按 §4.2 常规阅读组要求「P99 overrun ≤ 0」):

  • 门禁通过:B 侧 P99 overrun 全轮 ≤ -4.9ms(6 轮全部为负),远在 0 之下。
  • 记录在案的小幅上移:B 侧 P90/P95 一致 +0.2ms、P99 +0.4ms;P99 增量与 A 侧自身 轮间散布(6.7→7.3,0.6ms)同量级,不能仅凭此判为回归;候选归因:R8/dex 布局因模块 抽取改变(局部性)、B 轮温度略高。若后续轮次复现同向偏移,再从 trace 定位。
  • 超时帧比例:P99 overrun ≤ -4.9 → 比例上界 <1%(§4.3 完整比例需 trace 提取, 迭代 trace 每轮被覆盖,仅末轮 5 份留存;后续按需保存)。
  • 内存、GPU、frameCount 均等 —— 无泄漏迹象。

基线建立:A 侧三轮(P50 2.5 / P95 4.6 / P99 7.0 / overrun P99 -5.6)即「改动前」锚点, 供后续轮次(阶段 A 其余场景、阶段 D 实验前对照)引用。

方法论教训(后续 benchmark 轮次必读):

  1. 陈旧 APK 陷阱::macrobenchmark:connectedDebugAndroidTest 的 gradle 任务配对的是 app 的 debug 变体(装到 .debug),而 TARGET_PACKAGE=org.skepsun.kototoro 实际 测的是设备上已装的包 —— 首三轮测的是昨天装的旧 APK(versionCode 1227),数据全部作废 重测。必须:每侧显式 :app:assembleBenchmark → adb install -r -t → 核对 lastUpdateTime 变化。本记录中所有数字均来自修正后流程(每轮 sha + lastUpdateTime 已核)。
  2. perfetto trace 权限:MIUI 上 daemon 以 root:600 写 /data/misc/perfetto-traces/trace_output.pb,benchmark 库(shell 身份)读不到 → IllegalStateException: Cannot check size。解决:root 看护循环 su -c 'while true; do chmod a+r …; sleep 0.3; done' 在跑分期间放开读权限。
  3. 工作树切换用 git stash push -u;发现并清除了一处残留:reader-core/src/test 里 WebtoonViewportPolicyParityTest 的杂散副本(依赖 app 代码,无法在纯 JVM 模块编译; canonical 副本在 app/src/test)。

交付 9 — 阶段 A:场景 2/3/4 基线(§4.2 分页矩阵补全) ​

条件:commit 7db11a1d5(benchmark 变体 APK sha 332d461f…,安装核对);同设备同显示配置; CompilationMode.Full × 5 迭代/场景;场景按序运行,电池温度 36.8→38.6°C 逐场景爬升(zoom 阶梯最热,记录在案);每场景 5 份迭代 trace 仅末场景(zoomed 2.5×)留存并已归档。

各场景汇总(CPU/overrun 为五轮合并样本的分位数,非各轮 P99 中位数; 内存及计数为各轮结果的中位数,RssAnon 单位 KB;交付 10 已核对 2.5× 原始 JSON):

场景后端CPU P99 (ms)overrun P99 (ms)RssAnon Maxtile 解码/驻留判定
2 大图 1× (6000×9000)scene7.9-4.9248,1920 / 0通过(常规门禁)
2 大图 1×legacy5.1-7.5447,628–scene 内存仅 legacy 55%
4 双页往返scene7.8-4.8232,9240 / 0通过
4 双页往返legacy4.9-7.8269,900–legacy CPU 尾更低但内存 +37MB
3 zoom 1.5×scene7.7-4.7446,9680 / 0通过(常用倍率组)
3 zoom 2.0×scene8.6+9.1571,876392 / 90待验收(见下)
3 zoom 2.5×(fit-height,开1×)scene6.5-5.7433,6080 / 0通过
3 zoom 2.5×(开即2.5×)scene7.7+13.1577,372392 / 80待验收(见下)

发现与判读:

  1. zoom 成本悬崖实测定位:1.5×(无 tile 活动,-4.7ms)→ 2.0×(tile 激活:392 次解码 请求、90 驻留,+9.1ms)之间。打开即 2.5× 的 +13.1 是本矩阵最高 P99,不是最差单帧。 原先根据低 CPU P99 推断「CPU 之外的上传/栅格化主导」证据不足:交付 10 发现 2.5× 实际最大 overrun +752.860ms,主线程睡眠等待主导长尾,详见后文。 更热的 held 场景未激活 tile,支持路径相关的嫌疑,但并非隔离温度变量的对照实验。
  2. 高倍率组无 SLO(§4.2 明确约束):+9.1/+13.1 记为待验收数据,不宣布通过也不豁免。 制定 SLO 需要超时帧比例与最大连续超时(trace 提取,见下)。
  3. 内存证据:zoom 场景 RssAnon Max 545-577MB、Last 383-427MB —— 结束值比峰值低 150-195MB;Max→Last 回落支持资源释放,不能单独证明 tile 预算驱逐。 TileEvictions 全场景为 0,需区分预算驱逐和 releasePage 等释放路径。
  4. scene vs legacy 旅程不完全等帧(双页 1045 vs 505 帧、大图 606 vs 522 帧): CPU 分位直接横比有偏,内存与门禁判定不受影响;已按各自原始数字记录。
  5. 场景 2 内存优势:scene 大图 1× 用 248MB vs legacy 448MB —— LOD/概览路径的 实测收益(-45%)。

后续(阶段 A 收尾清单):

  • 超时帧比例 + 最大连续超时:已完成 —— 2.5× 见交付 10,其余 6 个场景见交付 12 (7 场景 × 5 迭代 trace 全部归档并逐场景核对);历史 /tmp/reader-bench/scenarios/zoomed2_5-traces/ 与 /tmp/reader-bench/fix-ab/ 为本机产物,交付 12 的归档在 E:\kototoro_demo\reader-bench\stageA-closure-20260920\。
  • §4.2 长章节组(50/500/5000 页元数据,固定窗口查询耗时/分配)仍无 fixture 与 benchmark 方法(现有 FIXTURE_MODE:standard/ultra_long/paged/paged_large)—— 需新增夹具与测量方法,独立一轮设计。
  • webtoon burst/sustained 套件重跑(可选,历史数据在 closure plan)。

交付 10 — 阶段 A:2.5× 超时帧证据(§4.3) ​

安装用户级 Perfetto trace_processor v58.2,并以 AndroidX Macrobenchmark 1.5.0 的取帧规则 复核留存的 5 份 trace。帧数及全部 CPU/overrun 样本与同次 benchmark JSON 完全一致。 复现脚本:scripts/analyze_reader_frames.py;完整逐轮表、最差帧定位、哈希与命令见 2.5× trace 分析报告。

  • 合并 3,163 帧,41 帧超时(1.296%);各轮比例 1.133%–1.575%。
  • 每轮最大连续超时 2 帧;合并 P99 +13.060ms,真正最大 overrun +752.860ms。
  • 每轮 3 个、共 15 个 UI 长帧,主线程 doFrame 127.198–764.516ms。 最差帧主线程睡眠 724–753ms,实际 running 10.6–14.5ms,RT 仅 7.5–9.2ms。 连续 2 帧不能解释为仅卡顿约 33ms;低 CPU P99 也不能排除稀疏主线程长阻塞。
  • 首要候选:closeSession() 沿用宿主主线程 scope,同步 close() 写锁等待后台 region decode 读者排空。trace 可见后台 BitmapRegionDecoder monitor 竞争,但无主线程等待调用栈; 候选因果需定点 trace/单变量 A/B 验证,本轮不修改渲染或资源实现。
  • iter 1、4 各有一次 packet-loss 标记;iter 0、2、3 同样复现长等待且无该标记。 全部超时位于 measureBlock 内;工具成功复核不等于采集无缺失或场景通过 SLO。

结论:本轮补全该压力场景的超时帧证据;优先验证主线程关闭 decoder 的阻塞候选。 高倍率维持待验收;其余场景 trace、长章节组和阶段 D 仍需独立证据。

交付 11 — zoom 长卡顿根因修复:session 关闭移出主线程 ​

来源:交付 10 的 trace 证据链 + zoom trace 分析 首选候选(主线程 closeSession 等待读者排空写锁)经单变量 A/B 验证成立。

改动:

  • ReaderTileManager.closeSession():关闭协程改跑 decodeDispatcher(不再落在宿主 Main 线程),withContext(NonCancellable) 包裹 await()+close() —— scope 中途死亡时 关闭仍完成(优于现状:同场景下现状直接泄漏 decoder)。
  • 窄 trace section:Reader.TileSessionClose(关闭协程整段)与 Reader.SessionCloseWriteLock(写锁获取 = 读者排空等待)。
  • TDD:ReaderTileManagerTest 新增先红后绿的调度测试 —— releasePage 的 session close 必须发生在 decode dispatcher 线程(红:Test worker @coroutine#45, 绿:close-dispatcher)。

验证(A/B 交错,zoomed 2.5× 两轮 + 2.0× 与普通页各一轮,各侧独立 APK、 安装核对、每轮归档 trace+JSON 并用交付 10 脚本核实):

指标(zoomed 2.5× 合并)A 基线 332d461fB 修复 a9221207
最大单帧 overrun+729.254ms(各轮 710–729ms)+50.518ms(-93%)
overrun P99+11.577ms+3.116ms
超时帧比例1.257%1.628%
总帧数3,1833,931(+23%)
普通单页对照 overrun P99-5.1ms-5.0ms(无回归)

判读:700ms 级主线程长帧消失,关闭路径因果成立。超时比例 1.26%→1.63% 伴随帧数 +23%:被长停顿吞掉的 vsync 恢复,剩余超时为 tile 到达的普通尾延迟(P99 +3.1ms、 最大 50ms),不再由关闭路径主导。:app:testDebugUnitTest 全绿。

高倍率组仍待验收:残差尾延迟的 SLO 制定与可能的 tile 到达调度优化留待后续轮次。

交付 12 — 阶段 A 收尾:全场景超时帧证据归档 + 高倍率组 SLO 制定 ​

  • 目的:补全 §4.2 各组的超时帧证据(此前只有交付 10 的 2.5× 一批),并按 §4.2 的前置要求 在测量任何优化收益之前固定 SLO 数值(已写入 §4.2.1)。
  • 代码改动:无 —— 本轮只采集证据并制定门禁,工作树为干净 67a243fc6。
  • 设备/构建身份:HEAD 67a243fc6;:app:assembleBenchmark 产物 app-arm64-v8a-benchmark.apk sha256 35b2746a32631f312f4d6d1bad73d0f56216e581f12cdecd5205cc97a2d8f773 (versionCode 1227 / versionName 2.1.3 / applicationId org.skepsun.kototoro); adb install -r -t 后 lastUpdateTime 17:02:37 → 18:13:27(设备时钟核对), 并按真机验证方法第 4 条把设备上已安装的包拉回校验:pm path → su -c cp → adb pull 后 sha256 与构建产物逐字节相同(同 35b2746a…)。 设备 M332BF(warsaw)、Android 17(SDK 37,CP2A.260605.016)、1280×2772、CompilationMode.Full、 5 迭代/场景、pagedTurns 旅程;trace 计数器 Reader.ActualRefreshRateHz 记录 60 → 120, expected 预算固定 13.6666ms。
  • 采集与提取:7 场景各 5 份 trace 全部归档(35 份)+ 各场景 JSON,根目录 E:\kototoro_demo\reader-bench\stageA-closure-20260920\<场景>\;提取用仓库 scripts/analyze_reader_frames.py + Perfetto v58.2(Windows prebuilt),每场景输出 「Verified all original samples」。逐 trace SHA-256、JSON SHA-256、逐帧 CSV、最差帧探针位于 各自 verified/report.json、verified/frames-*.csv;补充探针与汇总脚本在同目录 (sql/scenario_probes.sql、summarize_campaign.py、extract_worst_frames.py)。 归档属本机产物(同交付 10 的 /tmp 定位),仓库保存数字、流程与脚本。
  • 结果:7 个场景全部落在 §4.2.1 的新 SLO 内(基线表见该节)。
  • 与历史比对(跨轮复现性):单页 overrun P99 −5.49 / CPU P99 7.08(交付 8 A/B:−5.6/−5.5、7.0/7.4); 大图 1× P99 −5.36、RssAnon 242MB(交付 9:−4.9、248MB);双页 −5.36、229MB(交付 9:−4.8、232MB); 1.5× −4.48、436MB(交付 9:−4.7、447MB);2.5× 开即 1.632% / P99 +3.156ms(交付 11 B 侧: 1.628% / +3.116ms)。2.0× 本轮 P99 +5.70;交付 9 曾记录 +9.1,该轮在 36.8→38.6°C 的更热状态下按序运行, 两轮不构成同条件对照。
  • 残差归因(关闭路径修复之后,2.5× 开即与 2.0×):
    • Reader.TileSessionClose 共 70 / 61 次,全部不在主线程(tile_session_close_on_ui = 0); Reader.SessionCloseWriteLock 与之同量(最大单次 710.7 / 308.9ms,off-UI)。
    • 最差帧两类:① 主线程 postAndWait 主导 —— UI 墙钟 18.6–27.8ms,其中 sleeping 8.4–26.9ms、 running 0.2–13.0ms;② RenderThread 纹理上传主导 —— rt_upload 23.3–28.4ms 而同一帧 UI 仅 1.6–2.0ms。
    • 后台 monitor contention*BitmapRegionDecoder* 每轮 1,191–1,252 次、跨线程累计 410–436s、 最大单次 1.08–1.35s:解码串行是 tile 到达延迟的来源,但 off-UI,不直接等于掉帧。
    • 2.5× held 场景无 tile 解码(解码/驻留/驱逐 0/0/0)却仍有 13 次超时、连续 3 帧 —— 该场景尾部来自 LOD/上传路径而非 tile 解码。
    • 全部 7 场景 ui_frames_over_50ms = 0,最差主线程帧 11.2–28.6ms;交付 10 的 723–753ms 主线程睡眠类未复现。
  • 失败与偏差记录:
    • 2.0× 首轮失败:IllegalArgumentException: Expected ~120Hz display refresh rate, actual=60.000004 Hz —— MIUI 对窗口 high-refresh 请求的应用有延迟,不是代码问题。处理:运行前预热并校验 (启动 benchmark activity → 读 mActiveSfDisplayMode.peakRefreshRate,<119 则重试), 随后 2.0× 与 2.5× 开即均记录 refresh_precondition_hz=120.00001。
    • 每场景开始前冷却至 ≤35.0°C(上限 480s;本机稳态约 35.2–35.9°C,触顶后按上限放行), 运行前后温度逐场景留档(31.1–37.5°C)。2.0× 重跑为 35.5→37.5°C,略暖于其余场景,已记录。
    • 双页第 3 迭代 731 帧(其余 1,040–1,050):脚本已与 JSON 核对一致,属该轮采集长度差异, 不影响合并口径。
  • 关联:§4.2(SLO 前置)、§4.3(超时帧与最差帧证据)、交付 10/11(关闭路径因果)。
  • 已知限制/下一步:残差归因目前是相关性证据(无单变量 A/B)——「tile 到达/上传调度优化」 应作为独立轮次,用 §4.2.1 的退化容忍度做 A/B;§4.2 长章节组仍无夹具; 阈值脚本已由交付 13 落地;本轮未改动任何默认开关,不构成 promotion 决定。

交付 13 — CS-7 基准回归门禁:journey 脚本化 + 阈值判定 ​

  • 目的:闭合收尾计划 CS-7 —— 此前 harness 只能人工 connectedCheck 触发,没有阈值判定, 数字靠手抄。本轮把关键 journey 脚本化并把 §4.2.1 的 SLO 变成可执行门禁。
  • 修改范围(新增 2 个脚本 + 1 份文档重写):
    • scripts/check_reader_benchmark.py:判定器。纯函数 evaluate(metrics, group) 与 证据收集/校验分离;支持 --run-dir/--campaign-root,输出人类表格与 --json-out; 退出码 0 通过 / 1 违反 SLO / 2 证据不足。--selftest 内置通过/失败样本。
    • scripts/run_reader_benchmark_gate.py:journey 编排。构建 benchmark 变体 → 安装并证明身份 (lastUpdateTime 必须变化 + 拉回已安装 base.apk 与构建产物逐字节比对)→ 逐场景预热 刷新率前置(读 mActiveSfDisplayMode.peakRefreshRate,<119Hz 重试)与冷却(≤35.0°C,上限 480s) → 跑 connectedDebugAndroidTest → 归档全部 trace 与 JSON → 调 analyze_reader_frames.py 提取 → 调判定器。支持 --group regular|large|high_zoom|all。
    • macrobenchmark/README.md 重写:清掉「排除真实章节与持续滑动旅程」的过时描述 (该描述只适用于 renderer 对照,生产旅程已覆盖真实章节/大图/双页/持续滑动), 并写明门禁命令、退出码、判定口径与设备协议。
  • 覆盖的门禁维度(全部来自 §4.2.1,不看均值):overrun P95/P99、超时帧比例、最大连续超时、 最大单帧 overrun、最大主线程帧、RssAnon Max、Gpu Max、ActivePresentationAssets Max/Last 有界(≤6;基线最大 3)、RssAnon 跨轮增长(峰值 − 首轮 ≤ max(60MB, 15%)), 以及硬性失败类(任一主线程帧或单帧 overrun ≥100ms ⇒ 直接失败,与比例无关)。
  • 证据完整性(§4.3「缺少必需字段应判为证据不足」):判定前重新哈希归档 trace 与 benchmark JSON、 由逐帧 CSV 重算合并分位数并与 verified/report.json 及 harness 自身 sampledMetrics 双向核对。 以下一律判证据不足而非通过:sampledMetrics 缺 frameDurationCpuMs/frameOverrunMs (trace 属主失败的静默模式)、JSON 在提取后被修改、CSV 与报告不一致、trace 字节变化、 trace 缺失(除非显式 --allow-missing-traces)、迭代不足 5 轮、运行记录为失败、 显示刷新率 <119Hz、场景没有已约定的 SLO(如 burst/sustained 尚未制定 SLO)。
  • 脚本自验(§4.3 要求):--selftest 55/55 通过 —— 44 条阈值用例(每组「等于上限通过 / 超一点失败」边界;负 P99 不能豁免掉帧尾部; 缺字段判证据不足)+ 11 条证据链用例(完整归档接受、清理 trace 后可显式接受、 篡改 JSON/CSV/trace 字节、60Hz、失败运行、迭代不足、无 SLO 场景均拒绝)。 历史真实样本回放:交付 9 的 2.0× P99 +9.1、交付 10 的 +752.860ms、交付 11 A 侧的 +729.254ms 均判违反;交付 11 已修复构建的 +50.518ms 单帧判通过。
  • 该回放暴露一个口径问题并已修正:高倍率组原 50ms 的单帧/主线程帧上界会误杀已知良好构建, 两项上界修订为 80ms(§4.2.1 已同步并留修订说明;基线 21.9ms / 28.6ms,硬性失败类仍 ≥100ms)。
  • 端到端验证:
    • 对交付 12 的 7 场景归档跑判定 → 7/7 pass(每场景输出逐维度数值, 例如 2.0× 单帧 21.9/80ms、主线程帧 28.6/80ms、比例 1.918%/2.5%、RssAnon 534/650MB、 增长 0MB/80MB)。
    • 编排脚本在真机 ecd4369c 上实跑一个场景(归档 E:\kototoro_demo\reader-bench\gate-selftest-20260920\), 验证「构建/安装身份核对 → journey → 归档 → 提取 → 判定」整条链可在一条命令内完成: 安装核对 lastUpdateTime 18:13:27→20:07:39 且拉回 base.apk 与构建产物逐字节相同、 刷新率前置一次通过(120.00001Hz)、归档 7 文件/5 trace、提取 「Verified all original samples; pooled 1/3068 (0.033%), P99 −5.281ms」、判定 pass(退出码 0)。
    • 该次复跑同时印证了交付 12 的冷启动归因:同一场景在热设备上 RssAnon Max 226MB、 跨轮增长 0MB、超时比例 0.033%(交付 12 首次安装后为 323MB / +42MB / 0.067%)。
  • 有意不接 CI:仓库 7 个 workflow(debug/nightly/release/manual-release/docs-pages/issue_moderator/ trigger-site-deploy)均无真机步骤,门禁需要在有设备的机器上显式运行;README 已写明命令与前置。
  • 已知限制:① 门禁不证明不存在小泄漏 —— RssAnon 增长容忍 max(60MB, 15%), 交付 12 中单页场景实测 +42MB 已接近该上界(归因冷启动;交付 13 的热设备复跑为 226MB / +0MB, 支持该归因但未做单变量证明); ② tile 驻留字节/计数只记录不门禁,因为可见瓦片被 pin 时允许超出预算 (TileMemoryBudget KDoc 明示);③ burst/sustained/webtoon 与 renderer 对照暂无 SLO, 判定器对它们返回证据不足;④ 长章节组夹具仍缺(§4.2 余项)。
  • 关联:CS-7、§4.2.1(阈值与本次修订)、§4.3(样本验证与证据不足规则)、交付 12(归档基线)。

交付 14 — 阶段 B 余项(第一批):翻页样式/生命周期矩阵定性 + 3 个新发现 ​

  • 目的:§5.1 矩阵中「翻页样式(SLIDE/COVER/CURL)」与「生命周期(旋转/重建)」此前零自动化证据 (既有设备测试只覆盖缩放/平移归属、失败重试、viewport semantics)。
  • 本轮建立并验证了探针方法:真机探针宿主(IdleProbeActivity,需补 configChanges 与 ReaderActivity 一致,否则旋转会重建 Activity 并丢内容)+ 单手势 waypoint 事件注入 + 状态通道(onPagesChanged)与播报通道(contentDescription)分开观察。样式映射经实现核实: DEFAULT/NONE→SLIDE、ADVANCED→COVER、SIMULATION→CURL。
  • 逐格结论(真机 ecd4369c;以 reader 自身状态为真相):
    维度格结论
    翻页样式前进SLIDE 通过、COVER 通过、CURL 不稳定(发现 3)
    翻页样式后退SLIDE / COVER / CURL 通过
    翻页样式拖动取消(越过阈值后回到起点释放)3 样式通过
    翻页样式动画被新输入打断3 样式通过(落在合法页,无卡死)
    生命周期Activity 重建后按页身份恢复通过
    生命周期旋转后仍可读、页合法通过(内容仍绘制、状态合法)
    生命周期旋转保持当前页待修(发现 2)
    §5.2播报跟随状态已修(发现 1,commit b04ac16d1):翻页后播报自动跟随,红→绿见下
  • 发现 1(已修复):手势翻页后 host 已通过 onPagesChanged 上报新的 settled 窗口 (诊断 reportedLowerUpperActive=[1,1,1],即第 2 页),但 viewport 的 contentDescription 仍播报第 1 页 —— 三种样式都复现(手势后 1.5s 未更新);且不稳定(某轮 SLIDE 在 15s 内追上、 CURL 15s 未追上)。违反 §5.2「状态更新跟随阅读语义变化」。 根因:settled 窗口的写入发生在翻页动画结束时,之后 reader 进入 idle 不再产生帧,而无障碍树 要等再一帧才提交 —— 所以播报停在旧页,直到任意后续输入才追上。 修复(ComposeScenePagedReader 写 lastReportedPages 的 collect 内消费一次 withFrameNanos {},无重建/无重新测量):红→绿证据 —— 修复前 announcedPageFollowsTheReaderState(翻页后静置、无额外输入)失败;修复后该用例去掉 @Ignore 实跑 BUILD SUCCESSFUL,边界用例 announcedPageCatchesUpAfterOneMoreFrame(翻页后制造一帧)保持绿, 回归 SceneReaderViewportSemanticsTest + 2 个翻页样式格一次运行全绿(3 用例 18s)。 提交链 a18c30a09 → c55c8652b → 25dd60418 → b04ac16d1(修复)。
  • 发现 2(待归因):viewport resize(旋转,探针补 configChanges 后走 resize 路径)把 reader 重置到第 1 页(诊断 reportedLowerUpperActive=[0,0,0],页面仍在绘制、无空白)。探针未接 requestedPage(真实宿主用它在 resize 后重新锚定当前位置),因此需在真实 reader 入口确认 生产环境是否也丢页;ESR tsk_8d1cbe98。
  • 发现 3(已撤销):原记录「CURL 前进翻页在同一注入手势下 3 次运行仅 1 次成功」被判为 CURL 缺陷, 经复现修正为harness 注入时序问题:滑动若落在初始 settle 窗口内会被吸收(SLIDE 同样出现过未提交, 实验日志 before-turn activeIndex=0 announced=1 后滑动未提交)。harness 加 awaitStateQuiet()(等 reader 停止上报状态再注入)+ swipeForwardCommitting()(不提交则重试并记录 次数)后,11/11 有效格全部首次注入即提交。结论:不是 CURL 缺陷,不要再按此方向改产品代码; 逐格表与运行方式见下条。
  • 测试基建教训(本轮代价最大、后续必读):
    1. Instrumentation.waitForIdleSync() 在阅读器持续重绘时会永久阻塞(探针文件本就注记为 never-settling idle redraws);手势后应固定 sleep + 轮询可观察状态。
    2. 轮询循环里做无障碍根查询(rootInActiveWindow)同样会挂住整轮;ActivityScenario.close() 需有界(本轮改为独立线程 + 5s join)。
    3. MIUI 会在测试期间 freeze instrumentation app(logcat GreezeManager: freezeUid ... INSTRUMENTATION_APP),是长尾挂起的候选之一。
    4. 稳定化落地(同日续做):waitForIdleSync 移除、轮询内不再查无障碍根、close() 有界(独立线程 + 5s join)、视口尺寸在 applyContent 时缓存(手势循环里不再回调 activity)、首次无上报时重试最多 3 次(每次 20s 窗口)。效果:整类 13 用例从「挂 20–40 分钟」降到 26 秒跑完 11/13(0 失败)。
    5. 剩余不稳点已定位并给出运行方式:整类连跑会在跨测试的 Activity 生命周期上挂起 (第 12 个用例前,ActivityScenario.launch 等前一实例残留的 interrupted-turn 状态); 同一用例单独跑(fresh process)稳定通过 —— 两次整类运行里 coverInterruptedTurnSettlesOnALegalPage 失败/挂起,而 ...MatrixTest#coverInterruptedTurnSettlesOnALegalPage 单跑 → BUILD SUCCESSFUL(1 用例 17s)。 因此设备侧按方法/类分跑执行(与本仓既有「单类分跑」做法一致),整类连跑不作为验收方式。
  • 入库内容(本轮):app/src/androidTest/.../ScenePagedTransitionHarness.kt(探针 harness,双通道观察 + 上述稳定性规则)与 ScenePagedTransitionMatrixTest.kt(13 用例:10 通过 + announcedPageFollowsTheReaderState 与 curlForwardTurnLandsOnTheNextPage 两个 @Ignore 复现)。生命周期类(旋转/重建)本轮未入库 —— 它需要探针补 configChanges 且旋转格要接 requestedPage 才能归因(见发现 2)。
  • 其余矩阵行定性(本轮未新增覆盖):
    行现状
    方向与排布(LTR/RTL/TTB、单双页、封面偏移、宽页独占、跨章阻断)分层已覆盖:设备侧 ScenePagedGestureTest 三方向 overflow→翻页交接;JVM PagedSpreadResolverTest / PagedReaderSceneTest / HorizontalReaderSceneTest / VerticalReaderSceneTest
    缩放归属(边界残余位移翻页、切页保留、回翻恢复、切模式取消旧手势)设备侧 ScenePagedGestureTest 11 用例(10 通过;1 个既有设备缺陷)
    分屏尺寸变化未自动化:需 wm size 或真实分屏,wm size 在测试中断时会残留被改的显示配置,风险高于收益
    后台进程死亡后恢复域层已覆盖(ProgressRestorationIntegrationTest);宿主级进程重建未自动化(同一 instrumentation 进程内无法真正杀进程)
    异步内容(冷加载 / tile 迟到 / 动图)失败重试已覆盖(SceneReaderRecoveryTest);tile 迟到与动图仅 JVM 层(ReaderTileManagerTest 等)
    输入:触摸 / TalkBack 动作已覆盖(ScenePagedGestureTest + SceneReaderViewportSemanticsTest)
    输入:DPAD / 音量键未覆盖:键处理在 ComposeReaderActivityScaffold 及以上(音量键→翻页/滚动分数、TV 呈现的 focusRequester),探针宿主不含该层,需真实 reader 入口的集成测试
    连续模式(webtoon/横向方向与章节边界)JVM 层(VerticalReaderSceneTest / HorizontalReaderSceneTest)+ benchmark 生产旅程;宿主级设备测试未覆盖
  • 关联:§5.1、§5.2、CS-8。已知限制:本轮产出是矩阵定性与 3 个发现,不含新入库测试; 发现 1 已定位到宿主语义通道(lastReportedPages 是 Compose state,但 Box 上的 sceneReaderViewportSemantics 是否随重组重建、a11y 节点是否被跳过更新仍待查)。

交付 15 — 阶段 B 余项(第二批):分页宿主 viewport resize 丢页根因修复 + resize 用例 ​

  • 目的:交付 14 的发现 2(viewport resize 后回到第 1 页)当时只在探针宿主上观察到, 且无法区分「探针没接 requestedPage」与「宿主自身丢页」。本轮把探针换成真实无头宿主调用方式 (ComposeScenePagedReader 直接挂在 activity 上、composition 与 activity 全程存活), 并在同一 composition 内改变阅读器自身视口,得到可自动断言的结论。
  • 先修正交付 14 的一处事实错误:本仓 ReaderActivity(AndroidManifest.xml:159-161) 没有声明 android:configChanges,IdleProbeActivity 也没有 —— 因此真实旋转走 Activity 重建 (onCreate + 状态恢复),而「resize 而不重建」对应的是分屏/折叠形态变化(此时 ReaderActivity.onConfigurationChanged 只重跑 applyDoubleModeAuto(),不重新锚定)。 交付 14 中「探针需补 configChanges 与 ReaderActivity 一致」的说法不成立,以本条为准。 「旋转保持当前页」应作为重建路径验收,「resize 保持当前页」作为同实例路径验收,两者证据分开。
  • 根因(ComposeScenePagedReader):scene 与 scrollState 都以视口尺寸为 remember key 重建, 但 hasAppliedInitialPosition 是裸 remember,不随几何失效 —— resize 后初始定位 effect 不再执行, 新 scrollState 停在 offset=0,即本章第 1 页;同时按下式折算的 zoomedSlotIndex 归零, 缩放归属随之错位:
    • 证据(真机诊断,修复前):scene box size=1280 x 9009 -> 1280 x 6306, reports=1 window=(0,0,0),activePage 1 -> 0;修复后同一路径 activePage 1 -> 1、window=(1,1,1)。
  • 修复:把「是否已定位」的布尔量换成带几何的锚点(lastAnchoredSlot + anchoredViewportWidthPx/HeightPx 记录锚点所属视口),锚定 effect 在几何变化时以 锚定 slot重新解析几何(而不是回落到 initialPage);page-update effect 在新的视口尺寸下 不再抢跑回第 1 页;zoomMode 变化经独立 anchorTrigger(不复用 sceneRevision,避免无谓的 绘制失效)走同一条重锚定路径。恢复判据是 slot/页身份,不是旋转前后的绝对像素(§5.1 要求)。
  • 入库用例(app/src/androidTest/.../ScenePagedViewportResizeTest.kt,5 例): 首页 resize(对照格,前后都在第 1 页)、翻到第 2 页后 resize、KEEP_START 缩放模式下的 resize、 连续两次 resize(0.8 → 0.6)、resize 后播报页仍为第 2 页。 harness 增加 resizeViewport(scale)(只写尺寸 state,不重建 composition)、readerViewportSize()、 reportedWindow();resize 只有在阅读器自己测量到的像素尺寸变化后才算证据(见下条教训)。
  • 红→绿(真机 M332BF - 17 / ecd4369c,debug,单类分跑): 修复前 Starting 5 tests → 4 失败(resizeOnTheFirstPageStaysOnTheFirstPage 通过,符合设计), 诊断逐例显示 activePage 1 -> 0 window=(0,0,0);修复后 Finished 5 tests → 5/5 通过, 诊断逐例 activePage 1 -> 1 window=(1,1,1)。
  • 回归(同设备,按方法/类分跑): SceneReaderViewportSemanticsTest 6 例含 resize 类一次连跑 → 仅 viewportSemanticsExposePagePositionAndExecutePageTurns 失败,单跑通过(交付 14 记录的 「整类连跑会跨测试挂起/互相干扰、设备侧按方法分跑」再次复现); ScenePagedTransitionMatrixTest 12 例分 3 批全绿; ScenePagedGestureTest 11 例 → 10 通过 + 1 个既有设备缺陷 (originalSizeCanPanVerticallyWithoutUserZoom,断言红像素处取到黑,属 ESR tsk_b9a19061, 非本轮回归);ScenePagedViewportResizeTest 5/5。 JVM::reader-core:test + :app:testDebugUnitTest BUILD SUCCESSFUL。
  • 测试基建教训(新增,后续必读):
    1. Modifier.height() 在这种无头宿主里不会真正改变阅读器视口 —— 它先被父约束夹住, 实测 Box 尺寸保持 1280x2772 不变、reports=0,用例会「假绿」(本轮第一次跑通即此坑)。 必须用 requiredHeight(),并且断言前用 readerViewportSize() 确认尺寸真的变了。
    2. 「resize 后页没变」在尺寸没变时也会通过 —— 所以对照格(首页 resize)与尺寸断言缺一不可。
    3. instrumented 测试类必须文件基名与类名一致:本轮两次踩坑(ScenePagedResizeTest.kt 里声明 ScenePagedViewportResizeTest、ScenePagedViewportResizeCaseTest.kt 里声明 ScenePagedViewportResizeTest),症状都是 ClassNotFoundException: Failed loading specified test class, 且 compileDebugAndroidTestKotlin 与 assembleDebugAndroidTest 都不会报错 —— 只有设备侧运行才暴露。
    4. 改了 androidTest 源码后若 assembleDebugAndroidTest 恰好 UP-TO-DATE,connectedDebugAndroidTest 可能装上旧测试 APK:先 assembleDebugAndroidTest 确认 APK 时间戳/含目标类,再跑设备用例。
  • 关联:§5.1「生命周期」行(resize 一半)、ESR tsk_8d1cbe98(本轮闭环)、交付 14(发现 2 归因)。
  • 已知限制/下一步:未入库旋转/Activity 重建路径(真实旋转走重建,需在真实 reader 入口验证状态恢复); 进程死亡后恢复仍只有域层覆盖;连续宿主(webtoon/horizontal)经代码审查无同类缺陷 (两者 rememberComposeScenePrimaryScrollState(key = scene) 共享同一 scene 实例,resize 时状态保留、 max 值由 pages/scene effect 更新),但尚无 resize 设备用例,属下一批。

交付 16 — 阶段 D 可行性探针:immediate 路径的过渡帧没有需要 retained 层去省的开销(负结果) ​

  • 目的:阶段 D 是本文唯一完全未启动的阶段,而其立项前提是「过渡帧重复下发静态内容值得用 retained GraphicsLayer 省掉」。本交付只回答这个前提,不实现图层。

  • 测量对象:ReaderProductionBenchmark.measurePaged() 已用离散翻页 swipe 输入 (pagedTurns:左右各 10 次 × 2 轮),且 animation 是显式参数,因此三种过渡样式 除样式外条件完全一致,可直接比较。新增三条 journey: pagedSingleSceneCoverFull(COVER)、pagedSingleSceneCurlFull(CURL)、 pagedLargeSceneCoverFull(COVER + 6000×9000 大图夹具,标准夹具 800×1200~1800 偏小)。 SLIDE 基线 = 既有 pagedSingleSceneFull(DEFAULT)。

  • 工具(本轮入库,两者都是阶段 D 后续轮次的脚手架): scripts/probe_reader_transition_frames.py(跑 journey → 归档 trace + benchmarkData.json → 调分析器;Gradle 的 connected_android_test_additional_output 会被下一次运行覆盖, 不归档就不是证据;失败也会把 gradle 日志与失败行留在归档目录里)与 scripts/summarize_reader_transition_probe.py(跨场景汇总帧相位表)。 注意 trace_processor 在本机是 C:\Users\chuxi\.local\share\perfetto\prebuilts\trace_processor_shell-*.exe; ~/.local/bin/trace_processor.py 是下载器包装,analyze_reader_frames.py --version 直接调用它会 以 WinError 193(非 Win32 程序)失败,必须传真正的 shell 二进制。

  • 结果(真机 M332BF - 17 / ecd4369c,Full 编译,120Hz,平台预算 13.6666 ms;原始证据 E:\kototoro_demo\reader-bench\stageD-probe-20260920\):

    场景帧数超时比例最长连续最差单帧ui p50ui p95ui maxcpu p95
    SLIDE(pagedSingleSceneFull)30370.000%0-1.521.553.169.824.72
    COVER(标准夹具)30370.000%0-1.521.553.169.824.72
    COVER(大图夹具)29990.033%1+0.341.593.0812.034.68
    CURL30880.162%3+29.721.633.2434.854.93

    (单位 ms;超时比例/最长连续按 §4.3 口径;SLIDE 与 COVER 逐项几乎相同,说明样式本身不是变量。)

  • 关键否定证据(trace 切片分解,iter000):

    • Record View#draw():标准夹具 850 帧共 147.2ms(avg 0.173ms,max 1.61); 大图夹具 718 帧共 142.0ms(avg 0.198ms,max 1.74)。占 UI 线程时间约 11%,占帧预算 约 2%。
    • 大图夹具上 ImageDecoder#decodeBitmap 单页 avg 49.4ms(max 59.1ms)—— 即真正的成本在解码与上传,不在绘制录制;UI 线程 p95 仅 3.08ms。
    • CURL 的 +29.7ms 离群帧:trace 里没有任何 curl/fold 切片,同期是 ImageDecoder#decodeBitmap 10.7ms avg(max 15.9)+ Bitmap#prepareToDraw/uploadTexDataOptimal 数毫秒,且 5 次超时集中在早期迭代(iter0 4 次、iter1 1 次、iter2-4 各 0 次)=冷启动 解码/上传与动画同帧争用,不是折叠路径的开销。
  • 判定(按 §7.3 退出条件):首轮目标的 SLIDE/COVER 在两种夹具尺寸上都零超时或 0.033% 超时、 P99 余量约 5ms;绘制录制只占帧预算约 2%。retained 图层能省的正是这约 2%,即收益落在噪声内; 而 §7.2 要求的内存/首帧代价(常驻图层、资源固定计入预算)是实打实的。按计划原文 「若收益落在噪声内…应调整或放弃,记录负结果」——本轮结论为负结果,不实现 retained 图层, 也不为保留方案扩大缓存预算或放宽门槛。

  • 因此阶段 D 的后续(若仍要做)应换靶子:成本在解码/上传与冷启动争用(与交付 12 的 「残差 tail latency」归因一致),而按 §7.1 retained 图层不改变资源所有权, 对该成本无作用。真正可能有效的是「tile 到达/上传调度」方向(§10.1 未启动项之一), 或把 CURL 的冷启动争用单独立项。

  • 关联:§7.1/§7.2/§7.3、§4.2.1(SLO 与预算)、交付 12(残差归因)。已知限制: 本轮只测翻页 journey(连续滑动/缩放不在此列);CURL 大图夹具变体未跑; 大图 COVER 首跑因环境性失败重跑一次才成功(失败原因记录在该场景 gradle 日志,非 SLO 判定)。

交付 17 — 阶段 B 余项(第三批):高图 1:1 设备用例失败归因(harness 上限 + 管线取样行为,不是宿主几何缺陷) ​

  • 背景:ScenePagedGestureTest#originalSizeCanPanVerticallyWithoutUserZoom 自交付 14 起被登记为 「既有缺陷:高图在 ORIGINAL/KEEP_START 下的 1:1 几何(设备用例确定性失败)」(ESR tsk_b9a19061)。 该用例是 §5.1「缩放归属」行的证据,不修就无法把该行的设备证据补齐。本轮把它查清。
  • 实测根因(逐层证据,真机 M332BF - 17):
    1. 失败断言是 Native image must fill exactly 240 physical pixels:x=239 取到黑。逐点采样 (y=20 一行)得到 x=0..120 RED, x≥180 BLACK —— 内容只画了约 164px 宽,不是 240px。
    2. 在渲染入口打印 VisibleNode 得到决定性数字: scene=FloatRect(0,0,1280,2777)(初始估计几何)→ 随后稳定为 scene=FloatRect(left=0, top=0, right=164.0, bottom=4096.0),asset=ComposeImage。 164 = 240 × 4096/6000:投递到宿主的位图是等比压缩到 4096 上限的 164×4096。
    3. 240×6000 的 PNG 本身完好(BitmapFactory 读出 240×6000),压缩发生在解码侧: 该夹具走整图解码路径,而整图解码受 Canvas.maximumBitmapWidth/Height 上限约束。
    4. 生产解码器(KototoroImagePipelineAdapter + AndroidRegionDecoderFactory)在同条件下 (真机、同 viewport 1280×2772、同一 PNG)实测返回 plan … tiled=false plannedDecodeSize=null,即走 SampledSingle 而不是 tile 阶梯 (DecodePlanner 第 2/3 步:LOD 取样后 targetH <= maxDrawableH 即判定「fits」→ 不做 tile)。 生产侧因此同样拿不到 240 原生像素,但这是刻意的取样策略(DecodePlanner 注释记录了 反向实验:把 fit 页推入 tile 路径会让 1.5× 的 CPU P99 从 7ms 恶化到 452ms), 且对阅读无影响:原图 6000 高 > viewport 2772,取样后仍高于屏幕,清晰度由 240 宽的源图决定。
  • 结论(判定为「harness 上限 + 既有取样策略」,不是宿主 1:1 几何缺陷): 1:1 原生像素契约对高于解码上限的页面在这套 harness 里不可达 —— harness 的 pipeline 直接投递 已解码位图(OriginalReady),位图既然已被上限压缩,任何宿主实现都画不出 240 原生像素或 在 y=320 切分色带。因此原用例的失败断言不是产品行为,之前把它登记成「缺陷」是归因错误。
  • 修改:ScenePagedGestureTest.verifyNativePixels 的逐像素断言只对未被上限压缩的页面执行 (短页 LTR/RTL 两格仍逐像素校验 1:1 与「小图不放大」);高图那一格保留其可验证且真实的部分: 页面确实渲染、且无用户缩放时可纵向平移(拖动后断言仍取到内容),并在代码注释里写明上限与原因。
  • 证据与限制:本轮结论来自三次真机实验(逐点采样、渲染入口 VisibleNode 打印、生产 adapter 的 plan 打印),全部为临时诊断,已在结论落定后从生产代码与测试代码中移除; 诊断脚本与断言失败原文见交付记录与 app/build/outputs/androidTest-results/。
  • 验收(真机 M332BF - 17,按方法分跑):ScenePagedGestureTest 全 11 个用例逐个单独运行 全部 BUILD SUCCESSFUL(含本次改动的 originalSizeCanPanVerticallyWithoutUserZoom; 1 用例约 14–17 秒)。整类连跑同样无效:本轮一次 16 用例连跑出现 2 例失败 (zoomedPageRestoresZoomWhenFlippingBack、originalSizeUsesNativePixelsRtl, 后者失败语为 Paged fixture did not become visible),逐个单跑均通过 —— 再次印证交付 14 记录的「设备侧按方法/类分跑」结论,整类连跑不得作为验收依据。
  • 关联:§5.1「缩放归属」行、ESR tsk_b9a19061、交付 14(发现 2 的登记来源)。已知限制: KEEP_START/ORIGINAL 下「超高页面是否应当绕过上限改用 region decode」属产品决策, 本轮不改变该策略(改变它需按 §4.2.1「重设条件」重新立项并评估内存/首帧代价)。

交付 18 — 阶段 A 收尾:长章节组夹具与固定窗口测量(§4.2 最后一项) ​

  • 目的:§4.2 第四组「长章节」(50/500/5000 页元数据,固定可见窗口)是阶段 A 唯一未启动项, 其判据是「检查查询耗时与分配是否随总页数增长」。交付 2 已在 JVM 层证明 PagedReaderScene 的范围查询为 O(1)(50/500/5000 页分别 1665/1243/236 ns per query),本轮补真机宿主侧证据。

  • 夹具设计(ReaderProductionBenchmarkActivity):新增 long_chapter_50 / long_chapter_500 / long_chapter_5000 三种模式。关键取舍: 页面文件来自有界池(复用 paged 模式的既有 JPEG),而不是为 5000 页各写一张 —— 随章节长度增长的是元数据与窗口查询,不是磁盘上不同图片的数量;5000 张 800×1200 的 JPEG 会花掉分钟级生成时间与数 GB 存储,却测量同一件事。页身份(id/index)仍逐页不同, 而 readerKey、资源窗口与窗口查询都以页身份为键,因此池化只是夹具实现细节。

  • journey(ReaderProductionBenchmark):pagedLongChapter50Full / 500Full / 5000Full, 复用既有 measurePaged(离散翻页输入、5 迭代、Full 编译),页数是唯一变量。 汇总脚本 scripts/summarize_reader_long_chapter.py(注意 frameDurationCpuMs 等采样指标在 sampledMetrics 且按迭代嵌套,需展平后再取分位)。

  • 结果(真机 M332BF - 17 / ecd4369c,120Hz,Full;归档 E:\kototoro_demo\reader-bench\long-chapter-20260921\):

    章节长度帧数CPU p50CPU p95超时比例overrun P99rssAnon MaxGPU MaxHeap Max活跃呈现资源
    50 页32862.254.630.000%-5.594227.6 MB87.5 MB126.3 MB2
    500 页32042.304.660.000%-5.680228.0 MB87.5 MB126.2 MB2
    5000 页31912.304.630.000%-5.649227.1 MB87.5 MB126.1 MB2
  • 判定:页数扩大 100 倍,帧耗时、超时比例、内存(rssAnon/GPU/Heap)与活跃呈现资源全部持平 (CPU p50 差 0.05ms、p95 差 0.03ms、rssAnon 差 0.9MB,均在噪声内;三场景各 5 迭代、 trace 逐样本与 harness JSON 校验通过)。三组均 0 超时。「查询耗时与分配不随总页数增长」 的真机判据成立,与交付 2 的 JVM 证据一致。

  • 关联:§4.2 长章节组、交付 2(O(1) 范围查询)。已知限制:本轮旅程是离散翻页, 不含跨章跳转与跳页(requestedPage 路径);5000 页夹具的图片是池化复用, 因此不度量「5000 张不同图片的解码缓存压力」,那属于内存预算而非本组判据。

交付 19 — 阶段 B 余项(第四批):重建路径与连续宿主 resize 证据 ​

  • 目的:补齐 §5.1「生命周期」行的另一半(重建/程序化锚定)与「连续模式」行的宿主级设备证据。
  • 重建路径(ScenePagedViewportResizeTest,新增 3 例):生产宿主在重建后不是靠 initialPage, 而是把恢复出的位置当作新的启动页、并在布局变化时通过 requestedPage 程序化重新锚定。 harness 因此新增 requestPage(index)(只写 requestedPage prop,与生产同路径)。三格: aRebuiltReaderOpensOnTheRestoredPage(重建后停在恢复位置)、 aProgrammaticRequestLandsOnTheRequestedPage、aProgrammaticRequestSurvivesAResize。 全 8 格(5 resize + 3 重建)按方法分跑通过。
  • 连续宿主(SceneContinuousResizeTest,新增 3 例):webtoon 与 horizontal 此前只有代码审查 结论(「scrollState 以 scene 为 key,位置自然保留」)。本仓已两次因「关于生命周期路径的信念」出错, 故补设备证据:以 initialPage=2, initialScroll=40 启动 → 记录视口尺寸 → resize → 先等阅读器 在新尺寸下完成布局(awaitViewportResized,因为该宿主并非每帧上报,直接断言会读到 resize 前的值) → 断言页身份与页内偏移都不变。
    • horizontalKeepsThePageAcrossAResize:稳定通过(6/6 轮)。
    • webtoonOnTheFirstPageStaysOnTheFirstPage:对照格,稳定通过(5/5 轮)。
    • webtoonKeepsThePageAcrossAResize:曾不稳定(同一断言时通时不通,出现过 3 连失败后 3 连通过)。 交付 19 续做已归因:抖动是断言本身的基线错配 —— 断言要求「夹具请求的页序号 2」, 而宿主按宽高比 hint、随后按解码尺寸推导自己的页几何,合成夹具下两者不必一致。 改为从阅读器读取基线(holdSettled 后取 activePageIndex(),不与夹具参数比较)后 连续 3 次通过;另加确定性格 webtoonKeepsThePageAcrossAResizeWithFullPagePages (640×1800 夹具 → 每页 1280×3600,页高大于视口、页身份无歧义)连续 8 次通过。 临时生产日志另证明:webtoon 的 pages/scene effect 在 resize 时只运行一次(scene 未变), 即 resize 路径本身不重算位置;先前的 2→1「后退」是基线错配而非丢页。 故先前的「webtoon 宿主丢页」结论撤销,「连续模式」行由此转为宿主级设备覆盖 (4 格,各 3 连跑通过)。两次被证伪的中间猜测(首次冷启动 / 应用数据被清空)留档以免重走。
  • 测试基建教训(本轮新增,两次导致错误结论):
    1. connectedDebugAndroidTest 的 TEST-*.xml 会被每次运行覆盖,且编译失败时不会重写 —— 读错run 的结果文件会把编译失败当断言失败、或把上一个用例的结果当本次结果。本轮为此新增 scripts/print_last_androidtest_failure.py(显式打印最新一次运行的用例名与失败首行)。
    2. 「先编译再跑」不可省:connectedDebugAndroidTest 在 compileDebugAndroidTestKotlin 失败时 仍可能只报 BUILD FAILED,把编译错误误读成测试失败会浪费整轮排查。
    3. 断言基线必须取自被测对象,不能取自夹具:合成夹具的页几何与宿主推导出的几何不一致时, 「夹具请求的页序号」不是有效的期望值,据此失败会把测试自身的假设当成产品缺陷(本轮为此 误判出一整个「webtoon 丢页」缺陷)。凡「位置是否保持」类断言,都应先读被测对象当前状态再比较。
  • 关联:§5.1 生命周期/连续模式行、ADR 0002。已知限制:webtoon 抖动已归因为测试基线错配并转绿; 「后台进程死亡后恢复」仍只有域层证据;TalkBack/DPAD 未开始。

交付 20 — 阶段 B「输入」行:DPAD/键盘/音量键映射入库 + 连续宿主 resize 归因撤销 ​

  • 输入行(DPAD/键盘/音量键):§5.1 要求「触摸、TalkBack 动作、DPAD、音量键;边界动作不产生错误进度」。 实际链路是:ReaderActivity.dispatchKeyEvent → ReaderControlDelegate.onKeyDown →(分页) ComposeReaderController.switchPageBy → requestPage → 场景宿主的 requestedPage; (连续)switchPageBy → ComposeWebtoonPageTurnRequest(视口比例滚动, resolveWebtoonPageTurnDistance 已有 JVM 用例)。缺的正是映射本身: ReaderControlDelegate.onKeyDown 此前无任何测试 —— 映射错在这里,外部看起来与阅读器缺陷无异。
  • 入库:app/src/test/kotlin/.../reader/ui/ReaderControlDelegateKeyTest.kt,11 例 JVM,全部通过: 翻页键(NAVIGATE_NEXT/SPACE/PAGE_DOWN/R、NAVIGATE_PREVIOUS/PAGE_UP/L)方向; 左右 DPAD 在 isReaderNavigationInverted 两态下的方向;上下键先滚动、滚动被拒才翻页 (即边界动作不产生错误进度);音量键默认关闭且关闭时不被消费(onKeyDown 返回 false); 音量键在反向下语义;回车类键仅 TV 呈现下生效;DPAD_CENTER 切换控件; TV 控件可见时导航键交给控件、阅读器不产生任何进度;无法识别的键不被消费且零副作用; 音量键抬起仅在启用时消费。
  • 连续宿主 resize 缺陷撤销(交付 19 续):交付 19 登记的「webtoon 宿主 resize 后重锚定落到 本章第 1 页」经查不成立。抖动来自断言基线错配:断言要求「夹具请求的页序号」, 而宿主按宽高比 hint、随后按解码尺寸推导页几何,合成夹具下两者不必一致。 改为从阅读器读取基线后:webtoonKeepsThePageAcrossAResize 连续 3 次通过; 新增确定性格 webtoonKeepsThePageAcrossAResizeWithFullPagePages(每页 1280×3600 > 视口, 页身份无歧义)连续 8 次通过;同批 horizontal 与首页对照格各连续 3 次通过。 临时生产日志另证:webtoon 的 pages/scene effect 在 resize 时只运行一次(scene 未变), resize 路径本身不重算位置。ESR tsk_95ada157 按归因错误关闭。
  • 关联:§5.1 输入行与连续模式行。已知限制:TalkBack 真机记录仍缺(§5.2 验收要求 「经 semantics 定位并执行翻页的 instrumentation 测试」+ TalkBack 真机记录,前者已有 SceneReaderViewportSemanticsTest,后者需要人工操作设备,属唯一剩余的阶段 B 项)。

交付 21 — §9 promotion 检查表逐项判定(第一轮:可判定项已判定,阻塞项已具名) ​

判定基线(本轮冻结):commit 5ea4db9e4(工作树干净)、benchmark APK app/build/outputs/apk/benchmark/app-arm64-v8a-benchmark.apk, sha256 393521bd21054ac2340a9407f1a958ba342e6f4993f1d6fd1f3ccb5bff6b662b (与 gate 归档 run-summary.txt 记录逐字符一致)、设备 M332BF - 17 / ecd4369c、 refresh 前置 120.00001 Hz、电池温度 35.9 → 37.0 ℃(前后各记录)。 归档:E:\kototoro_demo\reader-bench\promotion-20260921\。

  • ① 候选 APK 与 commit 可追溯 —— ✅ 通过。 commit、工作树状态、benchmark APK sha256 (gate 回读复核)、设备序列、refresh 前置与前后电池温度全部记录且可复核; gate 的 refresh_precondition_hz 与实测 dumpsys display 相互印证。
  • ② CS-1A 性能验收 —— ❌ 不成立(交付 26)。 全场景复跑 6 pass / 1 violation: pagedLargeZoom1_5SceneFull 超时比例 0.511%(限 0.500%,基线 0.097%)、最长连续 3 帧(限 1)、 最差单帧 25.1ms(限 20),且独立复跑逐项重现。参考场景(regular)通过 11/11、 大图组通过、高倍率组 3 场景通过。promotion 条件 ② 在归因修复前不成立。 (该参考场景的逐项数值:帧 3050 / 超时 0(0.000%,限 0.500%)/ 最长连续 0(限 1)/ overrun P95 −7.945ms、P99 −5.174ms(限 ≤0)/ 最差单帧 −0.073ms(限 20)/ 主线程最大帧 7.659ms(限 50)/ RssAnon Max 226MB(限 400)/ Gpu Max 85MB(限 150)/ 活跃呈现资源 Max 2、Last 1(限 6)/ RssAnon 增长 8MB(限 60)。) 本轮新增的 COVER/CURL/长章节三条 journey 不在 §4.2.1 的场景表内, 按计划「无 SLO 即证据不足」处理(见交付 16/18),不计入门禁通过。
  • ③ CS-2/CS-3 与阶段 B 语义矩阵 —— 基本通过,含两处具名缺口。
    • 阶段 B 语义:§5.2 验收要求「经 semantics 定位并执行翻页的 instrumentation 测试」—— paged(SceneReaderViewportSemanticsTest)与本轮新增的 webtoon/horizontal (SceneContinuousViewportSemanticsTest,各 3 连跑通过)均已满足;三个宿主同源契约 由共享的 SceneReaderViewportSemantics 保证。
    • 翻页样式矩阵:ScenePagedTransitionMatrixTest 12 例按方法分跑全绿(交付 14/15)。
    • 缺口 A(CS-2):真机「各样式确实渲染不同」的像素度量证据停在 2026-09-19; 本轮只补了「样式落页正确」的行为证据,未重跑视觉像素度量。CLOSURE 计划把 「真机视觉确认」列为 CS-2 未完成项 —— 该项仍开放。
    • 缺口 B(CS-3):isContinuousHorizontalReversed 的 RTL 真机布局确认仍未做 (JVM 侧 SceneReadingDirectionResolverTest 等已覆盖纯逻辑)。
  • ④ 无资源泄漏/持续内存增长/错误进度/可复现空白闪烁 —— 部分判定。 内存无增长:本条参考场景 RssAnon 峰值−首值 8MB(限 60)通过;长章节组 50→5000 页 RssAnon 227.6→227.1MB 持平(交付 18);活跃呈现资源恒为 1–2,无累积。 错误进度:命中/语义/生命周期/连续宿主各格按方法分跑通过,未见错误进度。 可复现空白/闪烁:已由交付 24 转为可执行断言 —— 翻页过程 0 次空白采样; 冷启动实测首个有内容采样在 155ms,已写成 2000ms 预算以防「干脆不画」的静默退化。
  • ⑤ nightly 观察与回退路径 —— ❌ 未开始(这是 CS-1B 的前置,不是本轮遗漏)。 回退路径现成且有证据:isExperimentalPagedSceneReaderEnabled(ReaderSettings.kt:35) 默认 false、且 ComposeReaderScreenRoot 三处宿主分支(双页、条漫、单页)都经 resolveSceneReaderEnabled 按系列取开关(交付 45 起两个开关独立;此前是 isExperimentalSceneReaderEnabled && isExperimentalPagedSceneReaderEnabled 合取), legacy 宿主仍完整在位 —— 即当前默认关闭,回退路径已验证可用。 但「翻转默认值 → 一个 nightly 周期无回归」这一步尚未执行,因为它会改变用户可见默认值, 属需要人工决定的发布动作。
  • ⑥ 默认开启后的证据与 legacy 清退 —— ❌ 前置未满足(依赖 ⑤)。 清退顺序按 CLOSURE 计划是「Paged 默认开启 → legacy 路由降级为 developer fallback → 删除 runtime legacy 宿主与 View 对照组 → 保留纯函数 oracle」;本轮不动 legacy。

判定:检查表 ① 通过、② 参考场景通过、③④ 部分通过且缺口具名、⑤⑥ 未开始。 因此当前不具备「分页默认开启」的完整证据,promotion 决策的阻塞项收敛为三条: (a) CS-2 真机视觉像素度量重跑;(b) CS-3 RTL 真机布局确认;(c) 翻转默认值并过一个 nightly 周期。 (a)(b) 可在设备上执行,(c) 属发布动作、需要人工决定。

交付 22 — promotion 阻塞项 (a) 闭环:CS-2 过渡样式真机像素度量重跑 ​

  • 背景:CLOSURE 计划把「CS-2 真机视觉确认」列为未完成项,交付 21 把它记为 promotion 阻塞项 (a)。 上一轮该证据停在 2026-09-19,根因之一是度量脚本在仓库外(device-evidence/transition_probe.py), CLOSURE 计划 §真机验证方法末尾已写明「待稳定后应移入仓库,以免下次又从零写一遍」。
  • 工具入库(本轮):scripts/transition_probe.py(原样搬入)、 scripts/capture_transition_styles.ps1(采集驱动,把该计划里三条最容易踩错的操作要点写成代码: ① MIUI/HyperOS 必须先 setprop persist.security.adbinput 1,否则注入静默无效; ② 截图必须走 cmd /c adb exec-out screencap >,PowerShell 的 > 会破坏二进制; ③ 抓帧必须在手指仍按下的拖拽中段,否则过渡已经 settle、四种样式看起来一样)、 scripts/judge_transition_styles.py(样式两两像素差 + 变化区前缘几何)、 scripts/characterize_transition_edge.py(前缘逐行分布与逐行比对)。
  • 采集:真机 M332BF - 17 / ecd4369c,benchmark 变体,scene_paged + paged 夹具, 四种样式各抓「静止帧 + 同位置拖拽帧」,raw 均为 14,192,656 字节(1280×2772 RGBA8888), 归档 E:\kototoro_demo\reader-bench\cs2-visual-20260921\。
  • 结论一(本条要的证据):三种样式在真机上确实渲染不同,「静默塌缩」未回归。
    对比变化像素占比判定
    NONE vs DEFAULT0.0001共用 SLIDE 渲染路径(与设计一致)
    DEFAULT vs ADVANCED0.0400渲染不同
    DEFAULT vs SIMULATION0.5152渲染不同
    ADVANCED vs SIMULATION0.4867渲染不同
  • 结论二(几何):滑动样式的变化区前缘是竖直线,折叠样式的前缘随行漂移。
    样式前缘 x 跨度逐行 x std读法
    NONE / DEFAULT247.7(x∈[800,824],88% 行落在 x=800)竖直边界=平移
    ADVANCED / SIMULATION456227.0 / 224.8(x∈[800,1256])前缘随行移动=倾斜/弯曲,平移不可能产生
  • 限制(如实记录,不作为结论):在该拖拽位置上,前缘几何无法区分 COVER 与 CURL —— 两者逐行前缘 95.6% 相同(1980/2071 行),mean_abs_diff 18.7px。 即:像素差(结论一)证明两者渲染不同,但本采集点的前缘指标对两者不敏感。 要把两者几何分开需要多个拖拽相位或直接比较折叠区域形状,属下一轮的可选加强,不影响结论一。
  • 关联:CLOSURE CS-2、§9 检查表 ③、交付 21(阻塞项 a)。已知限制:单拖拽位置采样; 下一轮若需区分 COVER/CURL 几何,应扫多个 drag 相位。

交付 23 — promotion 阻塞项 (b) 闭环:CS-3 连续横向方向的验证拆分 ​

  • 背景:交付 21 把「CS-3 RTL 真机布局确认」列为阻塞项 (b)。CLOSURE 计划 CS-3 的 「仍未完成」原文是:真机 RTL 布局确认(JVM 侧纯逻辑已覆盖)。
  • 本轮先把该缺口拆成两半并分别找证据,而不是直接去截图:
    1. 场景模型在 RTL 下的语义 —— 由 reader-core 的 HorizontalReaderSceneTest#mirror property between LTR and RTL scene holds symmetrically 精确钉住:四条不同宽高比的页面,对 LTR 的每个滚动位置在 RTL 取镜像视口 [totalExtent − x − vpWidth .. totalExtent − x],断言可见页 ID、活跃页、进度三者逐一相等, 在 x ∈ {0, 400, 1220, 2050, 3500, totalExtent−vpWidth} 六个位置上成立。 本轮复跑:该文件 11 例全绿。
    2. 宿主把方向传下去 —— ComposeSceneHorizontalReader.kt:195-205 把 readingDirection 作为 remember key 并直接作为 HorizontalReaderScene(readingDirection = …) 构造参数, 中间没有任何分支或变换(无 if/when/取反)。
  • 为什么不再补设备截图:本轮先写了三版设备探针,得到的结论是该设备证据不可得,且原因可复述:
    • 连续横向是full-bleed 布局 —— 每页按视口高度缩放(HorizontalReaderScene.kt:405/412: width = availableHeight × ratio),因此页永远填满视口、页边缘不可见;
    • 于是「内容贴哪一侧」不是方向的可观察量(第一版探针按内容边缘镜像,测得两侧都是整屏; 第二版按亮度质心,被背景与边距污染);
    • 用页身份做观察量(每页一种颜色,读视口中心像素)时,LTR 与 RTL 都显示第 0 页 —— 这不是缺陷:initialPage = 0 是「本章第一页」,而镜像属性说明的是任意滚动位置的可见集 相等,两者并不矛盾;
    • 结论:在 full-bleed 下,设备截图能反映的恰好就是场景解析结果,而那一半已由上面的 镜面属性用例逐点证明;剩下的宿主→场景接线是一行直传,设备测试只会重复它。
  • 判定:阻塞项 (b) 闭环 —— CS-3 的两半各有证据(场景语义 = 镜面属性 11 例绿; 宿主接线 = 直传参数,代码面可核)。不宣称「真机 RTL 布局已确认」:本轮确认的是 「该缺口的真机形式的证据不可得,且不可得的原因已定位」,这与「没有做过」是两件事,故在此明确区分。
  • 关联:CLOSURE CS-3、§9 检查表 ③、交付 21(阻塞项 b)。已知限制:若将来出现 非 full-bleed 的横向布局(如页间距可见、或按宽度适配),则「内容贴哪一侧」重新变成 可观察量,届时应补设备截图。

交付 24 — §9 检查表 ④ 的空白/闪烁半项:从「没人注意到」变成可执行断言 ​

  • 背景:交付 21 把检查表 ④ 判为「部分通过」,并指出**「可复现空白/闪烁」从未做过系统检测** —— 该项此前建立在「没人注意到」之上。
  • 做法:app/src/androidTest/.../ScenePagedBlankFrameTest.kt。三页纯色夹具(红/绿/蓝), 于是「视口有没有内容」是一个廉价且无歧义的判据:任何一次采样若在稀疏网格上 (每 64px 取一点)都取不到任何夹具颜色(RGB 距离阈值),即记一次空白帧。 采样覆盖两次带动画的翻页(每 30ms 一次,落在动画窗口内部)以及页末静置。
  • 结果(真机 M332BF - 17,3 连跑全绿):
    • 翻页过程中:0 次空白采样 —— 交付 14 的「翻页闪烁」修复在检查表口径下成立;
    • 冷启动:实测首个有内容的采样在 155ms(12 次采样中 2 次空白)。这是宿主 graphicsLayer { alpha = if (lastAnchoredSlot >= 0) 1f else 0f } 的既有设计 (定位锚定前不显示,避免闪出未定位内容),不是翻页缺陷。
  • 该 155ms 被写成预算而不是抹掉(ARRIVAL_CONTENT_BUDGET_MS = 2000):这样冷启动若 变成「干脆不画」会在这里失败,而不是表现成「启动稍慢」—— 正是计划担心的那种静默退化。
  • 关联:§9 检查表 ④、交付 14(翻页闪烁修复)。已知限制:只覆盖分页宿主与本地夹具; 连续宿主的同类采样未做;阈值 60 与 64px 网格是工程折中,不做亚像素闪烁判定。

交付 25 — CS-1A 全场景门禁复跑(进行中)+ 阻塞项 (c) 改动面清点 ​

  • CS-1A 全场景门禁:交付 21 只判定了 regular 组的参考场景,本轮按 --group all 重跑 §4.2.1 覆盖的 7 个场景(pagedSingleSceneFull、pagedDoublePageSceneFull、 pagedLargeSceneFull、pagedLargeZoom1_5SceneFull、pagedLargeZoom2_0SceneFull、 pagedLargeZoomSceneFull、pagedLargeZoomedSceneFull),逐场景跑 journey + 归档 trace + 按组 SLO 判定。归档 E:\kototoro_demo\reader-bench\promotion-full-20260921\。 单场景约 20 分钟(含按 §4.2.1 要求的电池温度冷却回落到阈值以下),整轮预计 1–2 小时; 本轮进行中,已完成的场景 gradle_exit=0。未完成前不推进 ② 的判定。
  • 阻塞项 (c) 改动面清点(为人工决策准备,本轮未改动任何源码): isExperimentalPagedSceneReaderEnabled 的默认值出现在四处,翻转时必须同步,否则设置页 与阅读器面板会显示与实际行为相反的开关状态:
    1. core/prefs/AppSettings.kt:1055 —— prefs.getBoolean(KEY_…, false);
    2. reader/ui/config/ReaderSettings.kt:35 —— 数据类默认值 = false;
    3. settings/compose/ReaderSettingsScreen.kt:1067 —— 设置页开关读同一个 key,默认值也硬编码 false;
    4. reader/ui/compose/ComposeReaderOptionsSheet.kt —— 阅读器更多面板「阅读」页的两个开关 (webtoonSceneReader / pagedSceneReader,交付 45)只读这两个 key,但其数据类默认值同样 镜像了 true / false,翻转默认值时需一并核对。 迁移性质(重要):getBoolean(key, default) 只在 key 不存在时使用默认值,因此 「从未碰过该开关」的用户会拿到新默认值,而显式关掉过它的用户会保留 false —— 即默认值翻转本身不是迁移,但「回退」时要么发一个版本改回默认值,要么让受影响的用户手动打开。 另:该开关与 isExperimentalSceneReaderEnabled(默认 true)在 ComposeReaderScreenRoot.kt:120/404 以 && 门控;翻转 paged 那一个即让分页走场景宿主,同时保留整引擎的 kill switch。
  • 关联:§9 检查表 ② 与 ⑤/⑥、交付 21(阻塞项清单)。已知限制:全场景判定未完成前, ② 仍按「参考场景通过、其余沿用交付 12 归档」记录。

交付 26 — CS-1A 全场景门禁复跑发现可复现 SLO 违规(large 组 1.5×)—— promotion 暂不成立 ​

  • 交付 25 的全场景复跑(--group all,7 场景)结果:6 pass / 1 violation。 违规场景 pagedLargeZoom1_5SceneFull(large 组),失败项与数值:
    指标本次§4.2.1 门限交付 12 基线(67a243fc6)
    超时比例0.511%(复跑 0.514%)≤ 0.500%0.097%
    最长连续超时3 帧(复跑 3)≤ 1未记
    最差单帧25.109ms(复跑 22.953)≤ 20未记
    • 通过项(说明不是整体变慢,而是尾部):overrun P95 −7.824 / P99 −4.023、 主线程最大帧 25.6ms(限 50)、RssAnon 423MB(限 550)、Gpu 106MB(限 250)、 活跃呈现资源 2(限 6)、RssAnon 增长 0MB(限 63)。
  • 可复现性已确认(这是本条的关键):同一构建独立复跑一次,三项失败逐项重现 (0.511% → 0.514%、最长连续 3 → 3、最差单帧 25.109 → 22.953ms), 故不是单次噪声;相对交付 12 冻结基线,超时比例约 5×,且越过了 0.500% 门限。
  • 另一条独立路径复核:scripts/probe_slo_scenario.ps1(本轮新增,走计划 §真机验证方法第 4 条 的快速协议:显式构建 benchmark + 以 root 直跑 macrobenchmark 类,单场景分钟级而非 20 分钟) 复跑同场景,得到 CPU P99 7.8ms、overrun P50 −10.2 / P90 −8.2 / P95 −7.7 / P99 −4.0ms、 RssAnon 中位 423MB、Gpu 中位 106MB —— 与门禁路径逐项一致,两条路径互相印证。
  • 判定:按 §4.2.1 与 §9 检查表 ② 的要求,promotion 条件 ② 目前不成立; 这不是「例外有明确范围与数值」的情形,因为该场景落在 large 组的常规门限内, 不存在预先声明的例外。在归因并修复(或按「重设条件」重新立项)之前,不翻转分页默认值。
  • 归因范围(为下一轮缩小搜索):该基线冻结于 67a243fc6,其后到当前 HEAD 的生产代码改动只有两处, 其余 33 个提交为测试/文档/脚本:
    1. c1ee474ce(ComposeScenePagedReader.kt,交付 15 的 resize 重锚定);
    2. b04ac16d1(同文件,交付 14 的播报下一帧修复)。 首选嫌疑是 ① 中的 LaunchedEffect(… scrollState.offset …) —— 把锚定 effect 挂在 每一个滚动偏移变化上,且在其中(几何变化时)调用 updateResourceWindow; 在 1.5× 缩放 + 连续平移的旅程里,这会显著提高资源窗口重规划的频率。次选是锚定实现改为 每次锚定都 updateResourceWindow(原实现只在首次定位时调用一次)。
  • 关联:§9 检查表 ②、§4.2.1、交付 12(冻结基线)、交付 25。已知限制:尚未做单变量 A/B (把 c1ee474ce 单独回退后重跑同场景),故当前是「范围已缩小到两处改动 + 一个首选假设」, 不是已确认的根因。

交付 27 — 1.5× SLO 违规的单变量 A/B:假设部分成立但不充分,另发现门限本身余量极薄 ​

  • 方法:把交付 26 缩小出的首选嫌疑做成单变量开关(临时常量,测完移除、源码已复原): ANCHOR_FOLLOWS_SCROLL_OFFSET —— true 为现状(锚定 effect 与 settle 流都观察实时滚动偏移), false 恢复冻结基线前的形态(两者都不观察)。其余一切不变、同一构建流程、同一设备。
  • 结果(同场景 pagedLargeZoom1_5SceneFull,large 组):
    变体超时比例(限 0.500%)最长连续(限 1)最差单帧(限 20ms)判定
    A 现状(两次独立)0.511% / 0.514%3 / 325.109 / 22.953violation
    B 开关关闭0.386%220.852violation(仍越线)
    冻结基线(67a243fc6)0.097%13.277pass
    其它指标在 A/B 间几乎不动(CPU P99 均 7.8ms;overrun P90/P95 差 0.1/0.2ms),
    即差异只在尾部。
  • 判定(重要,避免把假设当结论):首选嫌疑只是一个贡献项,不是根因。
    • 关闭开关让超时比例相对现状下降约 25%(0.513% → 0.386%),方向与假设一致;
    • 但它仍然违规(最差单帧 20.852ms 仍 > 20ms 门限),而基线是 3.277ms —— 相差约 6×, 这部分不是该开关造成的;
    • 单次运行不足以做统计结论;以现有两次 A 与一次 B 看,B 更接近基线但仍显著偏坏。
  • 同时发现:该场景的 §4.2.1 门限余量极薄,已接近「设计上就会偶发失败」。 基线自身的余量(用 scripts/print_gate_verdicts.py 从交付 12 归档的 gate-verdict.json 读出):
    场景超时比例/限最长连续/限最差单帧/限
    pagedLargeZoom1_5SceneFull0.097% / 0.500%(19%)1 / 1(100%)3.277 / 20(16%)
    pagedSingleSceneFull0.067% / 0.500%(13%)1 / 1(100%)2.699 / 20(13%)
    pagedLargeSceneFull0.066% / 0.500%(13%)1 / 1(100%)0.771 / 20(4%)
    pagedDoublePageSceneFull0.020% / 0.500%(4%)1 / 1(100%)0.085 / 20(0.4%)
    即 regular 与 large 两组共 4 个场景的基线「最长连续超时」= 1 = 门限本身(余量 0 帧),
    「超时比例」余量也只有 4–19%。按 §4.2.1,这属于「门限在测量改动收益前固定」的既定事实,
    但不改变「当前含 1.5× 在内共有 1 个场景违规」这一判定;它解释的是**为什么该门限对尾部抖动
    格外敏感**,为下一步「按重设条件重新立项」提供依据。
  • 下一步(已收敛为两条,二选一或并行):
    1. 继续归因:A/B 已排除「该开关是全部原因」;剩余嫌疑在生产面内只剩另一处改动 (b04ac16d1 的 withFrameNanos{},交付 14),以及非本次改动的方向 —— 解码/上传调度(交付 12 的残差 tail latency 归因、交付 16 的 ImageDecoder#decodeBitmap 49ms/页证据)。下一轮应做「按 tile 请求/上传次数计数」的 A/B,这是能直接分辨两者的测量。
    2. 重新立项门限:若归因证明尾部来自既有解码调度而非本次改动,则按 §4.2.1「重设条件」 重新制定 large 组的「最长连续超时」(当前为 1 帧、基线即打满)与最差单帧上界, 并留完整证据;不得直接放宽。
  • 关联:§9 检查表 ②、§4.2.1、交付 12(冻结基线)、交付 26(违规发现)。已知限制: A/B 各变体样本量小(A 两次、B 一次),且未做「干净设备 + 多迭代」的方差研究; 临时开关已从源码移除(git status 干净,ANCHOR_FOLLOWS_SCROLL_OFFSET 计数 0)。

交付 28 — 1.5× 尾部归因第二轮:tile 调度假设被排除,证据转向位图驻留/上传 ​

  • 方法:不需要再跑设备 —— 交付 12 的基线归档与本次归档都存了 harness 的全部自定义指标, 直接对比即可分辨「tile/解码调度」假设是否成立。
  • 假设①(解码/上传调度)被证伪:该场景在基线与现状下都没有走 tile 路径。
    指标(中位)基线 67a243fc6现状 HEAD判定
    TileDecodeRequests_Max00无 tile 请求
    TileResidentCount_Max / TileResidentBytes_Max0 / 00 / 0无 tile 驻留
    DrawnTilesPerLayer_Max / DrawnTileBytesPerLayer_Max0 / 00 / 0每帧无 tile 绘制
    RegionSourceCount_Max00无 region 解码源
    CachedAssetCount_Max00—
    即 1.5× 下页面是单张取样位图(DecodePlanner 刻意的取舍:把 fit 页推入 tile 路径会让
    1.5× 的 CPU P99 从 7ms 恶化到 452ms,见交付 16 记录)。**因此 tile 到达/上传调度优化
    (计划未启动项之一)对该违规不可能有改善**,该方向据此从本违规的候选里移除。
  • 指纹:两者的差别在位图驻留,而不在 tile
    指标(中位)基线现状变化
    memoryMaxGpuMaxKb202,128 KB(197MB)108,908 KB(106MB)−47%
    memoryMaxRssAnonMaxKb446,788 KB(436MB)432,948 KB(423MB)−3%
    PresentationWidthPx_Min/Max2731 / 27312731 / 2731不变
    页面宽度(2731px)与 tile 计数都不变,而成像内存几乎减半 —— 方向指向整页位图的驻留位置
    (硬件位图 vs 软件位图,以及由此决定的「每帧纹理上传」与否),而不是解码/上传的调度。
  • 逐帧证据:尾部在 UI(主)线程,且以等待为主 现状最差帧的 overrun_probes(分析器对超时帧的切片探针): running_ms=1.899、sleeping_ms=20.340、post_wait_ms=20.376、record_ms=0.182、animation_ms=1.052。 即该帧主线程只真正运行了 1.9ms,其余 20.3ms 停在 postAndWait(等渲染线程/呈现)。 基线同场景的逐迭代最差帧是 2.908 / 3.275 / 3.277ms(5 迭代中 3 次超时), 现状是 19.7 / 24.6 / 25.1ms。两者都发生在 UI 线程,但量级差约 6×。
  • 判定与下一步:本轮排除了一个方向、把范围收窄到「位图驻留/纹理上传」,但根因仍未确认。 稳妥的判定路径(下一轮按序执行):
    1. 先补方差研究:A/B 各变体目前只有 1–2 次运行,且基线是交付 12 的单次活动; 在干净设备上对同一构建重复 2–3 次,确认 0.5% 与 0.097% 是否分别为两侧的稳定值 —— 若现状其实在 0.1–0.5% 间波动,则「5× 回归」的结论本身需要修正为「该门限对尾部抖动敏感」。
    2. 再做位图驻留的定向测量:确认 1.5× 整页位图在首次解码时是否拿到 RendererCapabilities.Resolved(DecodeAllocatorPolicy.resolve → HARDWARE 才不产生每帧上传)。 已知时序:rendererCapabilitiesReported 在第一次绘制时才置位 (ComposeScenePagedReader.kt:1257-1269),而解码可能在它之前发起 —— 这是一个竞态, 且 GPU 驻留减半正是它的典型指纹。
    3. 若 2 确认竞态:修「能力上报早于首次解码」(例如在组合期读取一次画布上限,或在拿到能力后 重新请求当前页),这属于本次改动之外的既有设计问题,修它不需要放宽门限。
  • 关联:§9 检查表 ②、§4.2.1、交付 12(基线归档)、交付 26/27(违规与 A/B)。已知限制: 竞态尚未实测(第 2 步未做);基线为单次活动,方差未知。

交付 29 — 1.5× 违规的方差研究(结论:回归成立)+ 一次被陈旧构建骗过的探针 ​

  • 方差研究(同场景 pagedLargeZoom1_5SceneFull,同构建,干净设备,逐次归档):
    运行超时比例(限 0.500%)最长连续(限 1)最差单帧(限 20ms)
    A(交付 26)0.511%325.109
    A 复跑(交付 26)0.514%322.953
    B 开关关闭(交付 27)0.386%220.852
    方差 run1(本轮)0.471%322.070
    方差 run2(本轮)0.350%219.760
    现状 5 次合计中位 0.471%(全距 0.350–0.514)2–319.76–25.11
    基线(交付 12,单次活动 5 迭代)0.097%(内部 0.000–0.162)13.277
  • 判定:回归成立,且不属于「门限敏感导致的抖动」这一可豁免情形,理由是分布不重叠: 现状 5 次全部落在 0.350–0.514%(中位 0.471%,约基线 4.9×), 最长连续超时 5 次全部为 2–3(门限 1,基线 5 迭代全部为 1), 最差单帧 5 次中 4 次 > 20ms(基线 3.277ms);基线的内部离散(0.000–0.162%)远小于两组的间距。
  • 同时确认:交付 27 的 A/B 结论需要收紧措辞。开关关闭(B,0.386%)位于现状区间(0.350–0.514%)的 低位、仍在基线之上很远,因此该开关最多是次要贡献项,把它当作「部分根因」偏强; 正确表述是「现状区间内的一个观测点,且未把指标带回基线水平」。
  • 一次被陈旧构建骗过的探针(工具坑,务必记住): 为验证「整页位图是否硬件驻留」,本轮在解码路径加了一行诊断日志 (Bitmap.config == HARDWARE + 当时的 RendererCapabilities), 但该日志一次都没有出现。原因是构建陈旧:
    • ./gradlew :app:assembleBenchmark 报 compileBenchmarkKotlin UP-TO-DATE(源文件已改、mtime 更新);
    • 随后直接检查 APK 的 dex,decode page= 在两个 APK 里都不存在 (debug 构建时间早于改动,benchmark 构建时间晚于改动却仍无该字符串);
    • 即「改了源码 → 构建 → 装到设备」这条链在本仓不可默认为真, 与计划 §真机验证方法第 4 条记录的现象同类(当时是「跑基线的不是这一版」, 这次是「改的代码没进包」)。规程应升级为:任何依赖生产代码改动的设备测量, 必须先按第 4 条第 3 步从设备拉回包并在多 dex 中搜本次新增标识,再跑测量。 该诊断已在确认后移除(git status 干净)。
  • 关联:§9 检查表 ②、§4.2.1、交付 26/27/28、计划 §真机验证方法第 4 条。已知限制: 位图驻留假设仍未测量(探针因上述工具坑作废);基线仍为单次活动, 若要把「4.9×」写成正式结论,建议在基线提交上重跑一次同场景作对照(下一轮首选动作)。

交付 30 — 1.5× 同协议同日对照:环境已漂移,且我上一轮的驻留假设被自己的数据推翻 ​

  • 方法:把基线提交 67a243fc6 签出到独立工作树(E:\kototoro_demo\baseline-67a243fc6, git worktree add … --detach,不干扰主工作区),在其中构建 benchmark APK 与 macrobenchmark 模块, 装到同一台设备后用与现状完全相同的门禁协议(scripts/run_reader_benchmark_gate.py)跑同一场景; 随后重装现状构建、再跑一遍。这样「代码差异」与「环境差异」被分开。
  • 结果(同日、同协议、同设备):
    构建超时比例(限 0.500%)最长连续(限 1)最差单帧(限 20ms)GPU MaxRssAnon Max帧数(中位)
    基线 67a243fc6 今天0.097%2(违规)11.022197MB436MB624
    现状 HEAD 今天0.347%2(违规)27.076106MB423MB638
    基线(交付 12 归档,2026-09-20)0.097%13.277197MB436MB618
  • 发现 1(结论性):测量环境已漂移,交付 12 的「通过」在今天不可复现。 基线构建今天跑出与归档完全相同的超时比例(0.097% vs 0.097%),但最长连续超时变成 2 帧 (门限 1)→ 同一份基线代码今天也是 violation,而归档当时是 pass。 因此「现状相对基线退化 4.9×」这一表述部分归因于环境,不能整体记在代码账上。 同时也说明该门限对连续超时的判定在基线上就已经打满(交付 27 已记录余量 0 帧)。
  • 发现 2(代码贡献仍然真实,只是更小):同日对照下,
    • 超时比例:0.097%(基线)vs 0.347%(现状)—— 现状高出 3.6×,但两边都守在 0.500% 门限内;
    • 最差单帧:11.022ms vs 27.076ms —— 现状的中位数(19.8–25.1ms)与基线的 11.0ms 明显分离;
    • 帧数反而略多(638 vs 624)→ 不是「跑得更久」,而是帧更差。 即:尾部的代码相关性成立,量级小于上一轮基于历史归档的 4.9× 估计。
  • 发现 3(推翻我自己的假设):交付 28 提出的「整页位图驻留(硬件/软件位图 → 每帧纹理上传)」 假设被今天的数据推翻:GPU Max 的 197MB vs 106MB 差异在同一天、同一协议、两种代码上稳定存在, 但它在交付 12 的归档里也是 197MB(即基线代码本来就 197MB)。 更关键的是:基线代码今天的 RssAnon 是 436MB 且最差帧 11.0ms, 而现状是 423MB / 27.1ms —— 若差异真是「是否每帧上传」,应在渲染线程而非主线程留痕; 而交付 28 的逐帧探针显示现状超时帧的主线程只运行 1.9ms。 因此「GPU 驻留差异」与「尾部延迟」之间没有建立因果关系,该假设撤回, 不再作为首选方向(GPU 数字本身仍是一个稳定指纹,但需要能区分因果的测量才能用)。
  • 判定与下一步:
    1. 不再把「与交付 12 归档比较」当作回归证据:归档是另一天的活动,且今天已证明不可复现。 回归证据只认同日 A/B;据此,现状的代码相关退化幅度应收紧为「最差单帧 11→27ms」, 而「超时比例 0.097→0.347%」需要更多同日样本才能区别于环境噪声。
    2. 尾部仍在主线程(1.9ms 运行 / 20.3ms postAndWait),下一步应直接测主线程那 20ms 在等什么: 需要能区分「等 VSYNC」「等渲染线程」「等纹理上传」的测量。交付 28 的切片探针给出的 post_wait_ms 覆盖了「等渲染线程」这一口径,下一轮应把渲染线程同时段切片(rt_upload_ms) 接上 —— 当前探针里该字段是 [NULL],这本身就是一个可修的测量缺口。
  • 工具:新脚本 scripts/benchmark_json_from_output.py(把设备侧 am instrument -w 的输出转成 分析器所需的 benchmarkData.json;注意 frameCount 行只打印 min/median/max 及其对应 trace, 无法还原每迭代计数,故同协议对照仍应走门禁脚本而非设备侧协议)。 基线工作树保留在 E:\kototoro_demo\baseline-67a243fc6 供后续同日对照复用。
  • 关联:§9 检查表 ②、§4.2.1、交付 12(归档)、交付 26–29。已知限制:同日对照目前各只有 1 次运行, 「3.6×」与「11→27ms」都还需重复才能定量;rt_upload_ms 为 NULL 的测量缺口未修。

交付 31 — 1.5× 同日 A/B 定论:代码相关回归成立;并修好一个一直输出 NULL 的测量 ​

  • 先修测量缺口(这是本轮最有价值的发现之一):scripts/analyze_reader_frames.py 的 rt_upload_ms 从写下那天起对每一帧都是 NULL,因为它把纹理上传当作「帧渲染切片的子切片」来查 (descendant_slice(rt_id)),而上传其实是渲染线程上的顶层切片: 在真实 trace 上实测 descendant_slice 命中 0 个、「渲染线程 + 时间窗」命中 10 个。 后果不只是缺一列 —— NULL 会被读成「没有上传」,而它真正想区分的是 「在等渲染线程」还是「在等纹理上传」。已改为「渲染线程 + 帧时间窗」,并顺带补了 rt_upload_texdata_ms(uploadTexDataOptimal*,只算真正的贴图上载)。 另外新增 scripts/print_late_frame_probes.py(逐条打印超时帧探针)与 scripts/compare_frame_phases.py(全帧相位对比,不只看超时帧)。
  • 同日 A/B(基线在独立工作树构建,两侧同协议、同设备、同一天,各 3 次):
    构建帧数超时帧比例(限 0.500%)最差单帧(限 20ms)
    67a243fc6 run1308430.097%11.02ms
    67a243fc6 run2315910.032%0.07ms
    67a243fc6 run3315010.032%0.05ms
    HEAD run13167110.347%27.08ms
    HEAD run23121130.417%23.65ms
    HEAD run33192100.313%21.15ms
  • 判定:回归成立,且这次是同日对照,不再是跨日活动比较。
    • 超时帧数完全分离:基线 1–3 帧,现状 10–13 帧(约 4–10×);
    • 最差单帧完全分离:基线 0.05–11.02ms,现状 21.15–27.08ms —— 现状三次的最差帧 都落在 20ms 门限之外,基线三次都在门限内;
    • 交付 30 说的「环境漂移」仍然成立(基线在今天 run1 就因为「最长连续 2 帧」而 violation, 归档当时是 pass),但漂移不足以解释现状:现状在最差帧上三次一致地差一个量级。
  • 归因进展:范围收窄到「UI 线程的极值尾部」,渲染线程被排除。 全帧相位对比(同一天两份报告):
    帧数ui p50ui p99ui maxrt p50rt p99rt max
    基线30841.455.7020.810.982.2011.72
    现状31671.465.6338.320.992.1712.38
    即:渲染线程的 p50/p99/max 三者几乎相同,主线程 p50/p99 也几乎相同,
    唯一显著差异是主线程 max(20.81 → 38.32ms)。配合修好的探针,超时帧的形状是
    「主线程 running_ms 仅 1.0–2.9ms,sleeping_ms 20–35ms,post_wait_ms 与之吻合,
    同期渲染线程有 23–28ms 的 rt_upload」—— 即主线程在帧边界上被上传阻塞,
    而不是渲染变慢、也不是解码变慢(tile 计数前已证为 0)。
  • 下一步(收敛后的唯一方向):查「为什么这版会周期性地上传贴图」。 渲染线程总量不变而上传集中在少数帧,指向纹理失效/重传(而不是首帧上传): 候选是 pixmap 被反复重录或位图被反复重新解码上传。 可用的直接测量已经就位:ActivePresentationAssets_*、CachedAssetCount_Max (两者两侧均为 0/2,说明缓存没变)与本轮新增的 rt_upload_texdata_ms; 下一轮应统计两侧每帧上传次数的分布(而不是只看超时帧),据此判定是「每帧都在传」还是「某几帧在传」。
  • 关联:§9 检查表 ②、§4.2.1、交付 26–30。已知限制:两侧各 3 次,最差帧的分离已很干净, 但「4–10×」这个比例区间仍宽;根因(周期性上传的来源)未定位。

交付 32 — 1.5× 归因收窄到「单次纹理上传变慢」:上传数量相同,代价 7.3 → 25.8ms ​

  • 方法:直接对同日两侧的 iter000 trace 统计整条旅程的纹理/解码活动总量 (E:\kototoro_demo\reader-bench\queries\upload_totals.sql)。 这一问本来是想区分「每帧都在传」与「某几帧传一次」,结果两种都不是 —— 数量完全一样。
  • 结果(同一天,同一场景,各取 iter000):
    切片族基线 67a243fc6现状 HEAD差异
    Texture upload*10 个 / 合计 45.24ms / 最大 7.33ms10 个 / 合计 64.41ms / 最大 25.82ms数量相同,最大单次 3.5×
    uploadTexDataOptimal*10 / 45.17 / 7.3210 / 64.36 / 25.81同上
    Bitmap#prepareToDraw*9 / 50.56 / 9.649 / 64.04 / 27.36数量相同,最大 2.8×
    ImageDecoder#decodeBitmap*9 / 994.92 / 128.229 / 912.04 / 106.59数量相同,现状还更快
    Drawing*(渲染帧)773 / 688.45 / 8.64734 / 697.85 / 9.83单帧绘制几乎相同
  • 判定(两条结论,都很硬):
    1. 不是「更多上传」,也不是「更多解码」 —— 上传 10 vs 10、解码 9 vs 9 完全一致, 解码总耗时现状还更低(912 vs 995ms)。所以「这版多做了工作」这一整类解释被排除。
    2. 是「同一次上传变慢了」 —— 单次纹理上传的最大值 7.33ms → 25.82ms(3.5×), prepareToDraw 同步点 9.64 → 27.36ms。而超时帧的主线程正是在 post_wait 上等这段时间 (交付 31:running_ms 1.0–2.9ms、sleeping_ms 20–35ms)。
  • 与 GPU 驻留差异合并后的解释(当前最强的候选,尚未直接证实): 同日复现的 memoryMaxGpuMaxKb 197MB(基线)vs 106MB(现状)说明页面大纹理在现状下没有常驻。 若纹理未驻留,同样的上传次数下,每次上传都要真正搬运这份大位图 —— 这正是 「次数不变、单次代价变大」的形状。下一步只需直接观测位图是否硬件驻留即可判定。
  • 工具坑(本轮再次踩到同类问题):perfetto SQL 里 「Drawing* 作帧切片 + 关联子查询/GROUP BY」的两种写法都返回 0 行, 而同一条 SQL 拆成独立标量查询(SELECT count(*) … 各查一次)立刻得到上表数字。 已在 queries/ 留下 debug_upload_query.sql 与 upload_totals.sql 作为可复用的探针形状; 以后遇到「探针返回空」先怀疑查询形状,再怀疑数据。
  • 关联:§9 检查表 ②、交付 31(同日 A/B 与主线程阻塞)。已知限制:只取了 iter000 一条 trace (两侧同迭代号可比);「纹理未驻留」是候选解释,未直接测量。

交付 33 — 1.5× 回归根因定位:现状解析页分辨率只有基线的一半(2731px → 1500px) ​

  • 方法:把「位图是否硬件驻留」做成同一份探针,分别装到主工作区(现状 HEAD)与基线工作树 (67a243fc6)的 benchmark 变体上,跑同一旅程同一参数,读 logcat。
  • 先解决一个把前面几轮探针全部废掉的工具真相:app/proguard-rules.pro 里有 -maximumremovedandroidloglevel 4,其注释写明会连同消息构造一起删掉 info 及以下的日志; 而 benchmark 变体是 initWith release → minify 打开。 所以任何用 Log.i 写在生产代码里的设备探针,在 benchmark/ release 包里根本不存在 —— 这正是交付 19 与交付 29 两次「探针没输出」的真正原因(当时分别归因为「构建陈旧」与「effect 未运行」, 两个归因都不完整)。改用 Log.w(级别 5,不被删)后,探针在两个包里都实测存在 (逐 dex 搜字符串确认),测量才成立。这条必须写进计划的方法节:benchmark 变体的探针一律用 Log.w。
  • 同日 A/B 结果(同一探针、同一参数、同一设备):
    构建解码次数交付给宿主的位图hardwareplannedDecodeSize
    基线 67a243fc62 次1500×2250 → 2731×4096false → false1500×2250 → 3000×4500
    现状 HEAD1 次1500×2250false1500×2250
  • 判定:根因是「页分辨率差一半」,不是多做了工作、也不是硬件位图。
    • 两侧的位图都是软件位图(hardware=false),且 capabilities 两侧都已是 Resolved —— 所以「硬件位图 vs 软件位图」不是差异来源(该假设此前已撤回,这里再确认一次);
    • 真正的差异是现状少了一次高分辨率解码:基线的第二遍解码把页面重解析到 2731×4096 (plannedDecodeSize 3000×4500,实际被 4096 上限截断),现状停在 1500×2250;
    • 这同时解释了两件此前分开看的现象: ①GPU Max 197MB vs 106MB —— 2731×4096 的 RGBA 约 44.7MB、1500×2250 约 13.5MB, 差值约 31MB(纹理副本计入后接近观测到的 ~91MB 差异量级); ②最差单帧 11ms vs 21–27ms —— 上传次数相同(10 次),但被搬的纹理大小差 3.3× (交付 32:单次上传最大 7.33ms → 25.82ms)。
    • 结论:现状把「1.5× 旅程中的页面」当成了不需要更高分辨率的场景,于是既不占那份显存、 也不为它付出上传代价 —— 它更快是因为画得更少,而不是画得更省。
  • 下一步(下一轮直接做):定位「为什么现状不再触发第二遍高分辨率解码」。 差异面只有 ComposeScenePagedReader.kt 的 c1ee474ce 与 b04ac16d1 两处; 结合本轮的探针数据,应检查解码分级(LOD / cameraScale)在现状下是否没有随旅程更新: 基线在旅程中重新规划到 3000×4500,说明它读到了更高的相机倍率; 现状始终按 1500×2250 规划,像是读到的相机倍率一直是 1×。 验证方式同上(Log.w 探针打印 cameraScale 与 LOD 决策),且必须逐 dex 验证探针在包里再测。
  • 关联:§9 检查表 ②、交付 31(同日 A/B)、交付 32(单次上传变慢)。已知限制: 第二轮解码的触发条件尚未定位(怀疑 cameraScale 未更新);该差异是「画质-性能」取舍, 即现状可能是把画质降级当成了性能收益,修复时必须同时确认画质回归基线(否则是把缺陷当优化)。

交付 34 — 1.5× 根因的机制侧进展:定位到「解码与相机 settle 的时序」,并明确探针上下文不足 ​

  • 本轮把交付 33 的「现状只解码一次」往机制里推了一层,并如实记录一次未验证的修复被撤回。
  • 机制(已由探针实测):现状的解码分级读到的相机倍率在解码那一刻还是 1.0。 Log.w 探针实测:
    PLAN page=… cameraScale=1.0 vp=IntSize(1280, 2772) logical=IntSize(6000, 9000) capabilities=Resolved
    (随后直到观测结束都没有第二次 PLAN)
    而首轮探针(交付 33 之前的同一次实验)确实观测到过 CAMERA settled scale=1.531097 紧接着 CAMERA settled scale=1.44375 —— 也就是相机先以 1.53 落定、随后漂回 1.44。 把这些数字代进 shouldAttemptZoomReacquire 的判据(decodedWidth × 1.25 < viewportWidth × scale):
    时机scale需求宽度 = 1280×scale判据(1500×1.25 = 1875)结果
    settle #11.531101959.81875 < 1959.8应重解码
    settle #2(漂回后)1.443751848.01875 > 1848.0不再重解码
    即:只有 settle #1 这一个窗口能触发升级,错过就永久失去(zoomReacquireTargets 也不会留下记录)。
    这个「一次性窗口」是真实的时序脆弱点:解码耗时(实测 ImageDecoder#decodeBitmap 大图
    106–128ms)与 settle 去抖(150ms)同量级,两者谁先到达决定成败。
  • 一次未验证的修复被撤回(记录决策):据此实现了一个「解码完成后再判一次 + settle 时补判 自上次 settle 以来解码的页」的修复(requestZoomTargetIfUnderResolved + decodedSinceSettle), 理由是两条到达顺序都会丢升级。但在单页探针里它一次都没触发:探针场景下 RECHECK … scale=1.0 … should=false,即探针里解码发生在任何 settle 之前, 而实测的两次 settle(1.53/1.44)都没在观测窗内出现。无法验证的修复不放行, 已把该文件完整还原(git status 干净)。
  • 探针上下文不足(本轮的真正限制):单页 + default_scale=1.5 的探针不等价于pagedLargeZoom1_5SceneFull 旅程(后者在缩放状态下连续翻页、会解码多页)。 证据:两侧的 ImageDecoder#decodeBitmap 计数同为 9(交付 32),说明差异不在「多解几次」, 而在每次解出多大 —— 这必须在真实旅程里观测每页的 plannedDecodeSize 才能判定。
  • 下一轮做法(明确):
    1. 把 PLAN 探针挂在旅程上(Log.w,装 benchmark 变体,先逐 dex 验证在包内), 用 scripts/probe_slo_scenario.ps1 的 root 直跑协议跑完整旅程,打印每页每次解码的 plannedDecodeSize 与当时的 cameraScale,两侧对比;
    2. 据此判定「现状是否在旅程中每页都按 fit 尺寸解码」(若是,则升级窗口在每页都存在, 必须修;若否,则差异另有来源);
    3. 修完必须同日 A/B 复验(基线工作树在 E:\kototoro_demo\baseline-67a243fc6), 并同时确认画质回到基线(1.5× 下页面分辨率不得低于基线)。
  • 关联:§9 检查表 ②、交付 33(根因事实)。已知限制:修复未验证、探针上下文不足; 「once-only 时序窗口」是已证实的机制,但它是否解释两侧差异仍待旅程级测量。

交付 35 — 重大方法修正:CS-7 的全部基准都不经过生产图像管线,交付 33 的解码结论撤回 ​

  • 本轮想按交付 34 的计划「把 LOD 探针挂到真实旅程上」,做的第一件事是确认探针会落在哪条路径上 —— 结果发现这条路径根本不在基准里: ReaderProductionBenchmarkActivity(app/src/benchmark)自己实现了一个 BenchmarkProductionImagePipeline : ComposeReaderImagePipeline,observe() 直接吐出 OriginalReady/cachedState() 直接查表(:518-541),并且整个文件里没有一处构造 KototoroImagePipelineAdapter(实测计数 0)。基准把该 pipeline 传进 ComposeScenePagedReader(imagePipeline = pipeline),而传入 pipeline 时适配器整条被替换掉。
  • 后果(必须写清楚,因为它推翻了本计划此前的一条结论):
    1. CS-7 的 7 个场景(以及本计划新增的 COVER/CURL/长章节等等所有 journey)测的都是 「场景宿主 + 渲染器 + Coil 默认解码」,而不是生产解码策略 (DecodePlanner 的 LOD 决策、KEEP_START 与 tile 阶梯、allowHardware 策略全都不在路径上)。 这解释了此前几条互相矛盾的观测:TileDecodeRequests_Max 等 tile 指标两侧恒为 0 (交付 28)并不是「页面没走 tile 路径」,而是生产 tile 路径根本没参与。
    2. 交付 33 的「现状只解码一次 1500×2250、基线解码两次并以 2731×4096 结束」失去依据: 那两次 DECODE 打印来自适配器,而如果探针从 ReaderProductionBenchmarkActivity 启动, 适配器就不会被构造,也就不会有打印。该对比的上下文无法确认, 故「现状把页面按一半分辨率解析」这一根因撤回(相机倍率 1.53→1.44 的 settle 数值 与 shouldAttemptZoomReacquire 判据本身仍然实测有效,但没有证据把它们与 1.5× 违规连起来)。
    3. 交付 34 的「一次性升级窗口」机制同样只证明了判据的敏感性,未被证明是本次回归的原因。
  • 本轮实现并撤回的第二个修复(决策留档):为消除「解码与 settle 谁先到」的时序依赖, 实现了 peakCameraScaleSinceSettle(以「自上次 settle 以来的最高相机倍率」而非瞬时倍率 判断是否重解码)并在资产落地时补判。它在探针里一次都没触发 —— 因为如前所述, 该探针的路径根本不执行这段代码。无法验证的修复不放行,文件已完整还原。
  • 下一步(已重写,替换交付 34 的计划):
    1. 先补产出路径的测量:要判定 1.5× 违规是否与生产解码策略有关,必须在真实阅读入口 (ReaderActivity 的漫画路径,它使用真实适配器)上做同日 A/B,而不是在 benchmark 活动上。 这也是计划里早已记录、却一直被推迟的「真实 reader 入口」验收。
    2. 若真实入口的尾部/分辨率两侧一致,则 1.5× 违规来自基准独有的路径 (BenchmarkProductionImagePipeline + Coil 整页解码),应把该差异作为基准保真度缺陷 单独立项并修(让基准也走生产管线或明确声明它测的是渲染器)。
    3. 在把基准保真度问题解决前,不得再用 benchmark 数据判定「生产解码策略」的因果。
  • 关联:§9 检查表 ②、交付 28(tile 计数恒 0 的重新解释)、交付 33/34(结论撤回)、 §4.2(场景定义)。已知限制:1.5× 违规目前未被归因;两个候选修复都已撤回且留有理由; 真实阅读入口的 A/B 尚未做。

交付 37 — 「现状按一半分辨率解码」被推翻:两侧解码产物逐项相同 ​

  • 方法:不需要任何探针 —— perfetto trace 里已经带着解码产物的尺寸 (Bitmap#prepareToDraw <W>x<H>、Texture upload(<id>) <W>x<H>),直接对同日两侧的 全部各 5 条迭代 trace 统计即可(queries/prepare_sizes.sql、queries/decoded_sizes.sql)。 这也再一次说明:能从不变量里读出来的东西,不该用探针去测。
  • 结果(同一天、同一场景、两侧各 5 迭代,逐迭代一致):
    构建解码产物每迭代解码次数Texture upload 目标尺寸
    基线 67a243fc62731×409692731×4096
    现状 HEAD2731×409692731×4096
  • 判定:交付 33 的根因结论「现状把页面按一半分辨率解析(1500×2250 vs 2731×4096)」推翻。 两侧解码出的位图尺寸与次数逐迭代相同,因此:
    • 不是分辨率/画质差异,「修画质回归」这一整条前提不成立;
    • 交付 32 的「上传数量相同(10 次)」被独立确认(两侧每迭代 9 次解码、尺寸相同);
    • 交付 33 中「1500×2250」的那次观测来源不明(很可能来自前几轮某次被替换掉的构建或 非本场景的参数),不作为证据。
  • 仍然成立的、可复用的部分:交付 32 的差异是同一次上传的耗时(最大 7.33ms → 25.82ms, 3.3×),而不是数据量;交付 31 的差异在主线程极值尾部(ui max 20.8 → 38.3ms), 渲染线程 p50/p99/max 两侧几乎相同。
  • 现在的证据形状(结论):两侧画同样的东西、解同样大的图、传同样多的字节, 差异只在主线程的尾部时序上。这把候选范围从「解码/画质/LOD」整类里移出去, 剩下的方向是主线程调度/争用(而非图形数据本身)——与交付 28 的 「超时帧主线程只运行约 1.9ms、其余停在 postAndWait」一致。
  • 下一步(方向已换):既然数据量两侧相同,就不该再查「画什么」,而应查 「同一份工作在什么时机被提交」:
    • 用 trace 对齐两侧逐帧时间线(超时帧相对其上一次 VSYNC / 渲染线程忙闲的位置), 判断现状的超时帧是否落在「渲染线程正在上传」或「刚错过 VSYNC」的时刻;
    • 若确认是时机问题,则本轮该查的是宿主提交帧的节奏(例如锚定 effect / settle 流的 触发时机),而不是解码策略;这两个 effect 正是基线之后唯一的生产改动。
  • 关联:§9 检查表 ②、交付 31/32(仍成立)、交付 33(本交付推翻其根因)、交付 35(基准保真度声明)。 已知限制:1.5× 违规仍未归因;本交付的价值是删除一个错误方向并把范围钉在 「主线程时机」上;两侧生产改动仍只有 ComposeScenePagedReader.kt 的两处。

交付 38 — 1.5× 归因收敛:解码两端相同,慢在「位图准备/纹理上传」(5 迭代全程复现) ​

  • 方法:不需要探针。perfetto trace 已带 Bitmap#prepareToDraw、Texture upload、 ImageDecoder#decodeBitmap 三类切片,且切片名里带尺寸;对同日两侧全部各 5 条迭代统计 (queries/upload_summary.sql、queries/prepare_sizes.sql)。
  • 5 迭代逐条对比(同一天、同一场景):
    迭代基线 uploads现状 uploads基线 prepare现状 prepare基线 decode现状 decode
    010 / 45.2ms / max 7.310 / 64.4ms / max 25.89 / 50.6 / 9.69 / 64.0 / 27.49 / 995 / 1289 / 912 / 107
    110 / 46.6 / 7.413 / 53.2 / 27.59 / 41.7 / 10.19 / 62.8 / 29.79 / 942 / 1189 / 926 / 109
    210 / 34.6 / 5.911 / 67.4 / 28.19 / 43.7 / 8.49 / 95.8 / 42.69 / 1158 / 1379 / 1132 / 133
    39 / 41.3 / 8.411 / 69.4 / 23.19 / 52.5 / 8.79 / 71.3 / 25.49 / 1235 / 1499 / 1174 / 142
    412 / 38.4 / 5.910 / 68.8 / 28.29 / 53.7 / 21.69 / 67.8 / 34.79 / 1154 / 1379 / 1161 / 134
  • 判定(三条同时成立,都是 5/5 复现):
    1. 解码两端相同:每迭代都是 9 次,且产物都是 2731×4096(交付 37 已证); 解码总耗时现状还略低(912–1174ms vs 942–1235ms)。→ 解码不是原因,且画质无差异。
    2. Bitmap#prepareToDraw 现状系统性更慢:总量 42–54ms → 63–96ms,单次最大 9.6–21.6ms → 25.4–42.6ms。prepareToDraw 是位图进 GPU 前的准备,同一份数据(尺寸与次数 都相同)却慢 1.4–2×。
    3. Texture upload 总量与最大值同向变差:总量 34–47ms → 53–69ms,单次最大 5.9–8.4ms → 23.1–28.2ms。上传字节数相同(交付 32/37),耗时最大 3.3×。
  • 为什么这解释了违规:超时帧的探针显示主线程 running_ms 仅 1.0–2.9ms、sleeping_ms 20–35ms、post_wait_ms 与睡眠吻合, 同期渲染线程有 23–28ms 的上传 —— 即主线程在等这段更慢的准备/上传。 基线只有 3 个超时帧,其中真正"等上传"的那种形状只出现 1 次(其余是 0.06–0.15ms 的轻睡)。
  • 附带发现(真实缺陷,与本回归无关):DecodePlanner 计算出的 DecodePlan.allocatorPolicy(DecodeAllocatorPolicy.HARDWARE/SOFTWARE)在适配器里从未被使用 —— 全文件里唯一的硬件位图控制是 allowHardware(false)(KototoroImagePipelineAdapter.kt:406, 且仅在 RGB_565 时触发)。也就是说「按 PixelUsage 决定硬件/软件位图」这条设计 目前是死代码,位图是否 hardware-backed 完全由 Coil 的默认策略决定。 这是 §8.1「资源契约」的一处未接线,应单独立项;它不能解释本次差异(两侧代码相同)。
  • 下一步(唯一剩下的观测):直接看位图是不是 hardware-backed。 既然 prepareToDraw 慢 1.4–2× 且数据相同,最可能的机制是位图在 CPU 侧、每次需要重新准备/上传。 验证方式:Log.w 打印 Bitmap.config(HARDWARE vs ARGB_8888)与 allowHardware 实际生效值,两侧同日对比;若现状是软件位图,则顺着 「为什么同一份解码产物在现状下不是硬件位图」继续查(注意上面的死代码发现 —— 这条链路上本来就没有任何显式的硬件位图决策)。
  • 关联:§9 检查表 ②、交付 31/32/37、§8.1(allocatorPolicy 未接线)。已知限制: prepareToDraw 变慢的原因仍未定位(本交付证明的是「慢在准备/上传而非解码」, 以及两侧字节数与解码产物相同);allocatorPolicy 死代码属新发现的独立缺陷,未修。

交付 39 — 交错 A/B(A,B,A,B)把差异钉死在「纹理上传」一项,并修正交付 38 的一半 ​

  • 动机:此前所有「同日」对照都是先跑完一侧再跑另一侧,两次相隔数十分钟到数小时, 设备漂移与代码差异混在一起。本轮改成交错:构建基线 → 跑 → 构建现状 → 跑 → 再来一轮 (scripts/interleave_ab_scenario.ps1),并每个构建都记录 APK sha256,漂移对两侧等价。
  • 结果(同一天、交错、每个构建各 2 轮;取每次运行最后一条迭代 trace):
    运行uploadsupload 单次最大prepareToDrawdecode 单次最大
    r1 基线9 / 37.85ms6.56ms9 / 36.73ms212.24ms
    r1 现状9 / 57.17ms23.42ms9 / 24.45ms212.48ms
    r2 基线9 / 32.70ms3.85ms9 / 25.10ms212.10ms
    r2 现状9 / 58.23ms23.38ms9 / 37.06ms212.99ms
  • 判定(本计划至今最干净的一次对照):
    1. 解码完全一致:两侧每次都是 9 次、单次最大 212.1–213.0ms(4 次几乎逐项相同) → 解码策略与画质再次被排除(与交付 37/38 一致)。
    2. 上传的字节数与次数完全一致(9 次),但耗时 3–6×: 总量 32.7–37.9ms → 57.2–58.2ms(两组不重叠),单次最大 3.85–6.56ms → 23.4ms (同样不重叠)。这是唯一在交错对照下稳定分离的量。
    3. 交付 38 关于 prepareToDraw 的说法需要修正:交错数据显示 prepareToDraw 在两组间重叠(基线 25.1/36.7,现状 24.5/37.1)—— 交付 38 把它列为"系统性更慢" 是非交错对照下漂移的产物,撤回。真正干净的信号只有 Texture upload。
  • 结论:两侧解码同样的位图、上传同样多的字节、同样多的次数, 差别只在同一次纹理上传的耗时(基线 4–7ms,现状恒定约 23ms)。 这与超时帧形状完全吻合(主线程 running 1–3ms、sleeping/post_wait 20–35ms, 同期渲染线程上传 23–28ms):主线程在等一次慢 3–6× 的纹理上传。
  • 下一步(范围已极小):既然字节数与次数都相同,慢的是"上传这个动作"本身,可能来源只有两类: ① 纹理对象/缓冲的状态(是否 hardware-backed、是否已驻留、是否每次都需要重新 staging); ② 上传发生的时机(与 vsync/GPU 队列的相对位置),即被排在 GPU 繁忙时。 区分二者的测量:把两侧上传切片相对其前一次 vsync 的偏移对齐,看 23ms 的那次是否 落在 GPU 忙窗内;若落在空闲窗内仍慢,才是①。 注意交付 38 的附带发现(DecodePlan.allocatorPolicy 从未被适配器使用)仍成立, 它与①直接相关,是①的优先检查项。
  • 工具:scripts/interleave_ab_scenario.ps1(交错 A/B:构建→装→跑→取 trace,记录 APK digest; 注意 adb 的 stderr 进度行在 ErrorActionPreference Stop 下会被当成致命错误, 故 pull 走 cmd /c ... 2>nul)。
  • 关联:§9 检查表 ②、交付 31/37/38(38 的 prepareToDraw 结论部分撤回)。 已知限制:每侧 2 轮(交错成本高);上传为何慢 3–6× 仍未定位; 「时机 vs 状态」的区分测量未做。

交付 40 — 交错 A/B 闭环到单次事件:现状的违规由「首个大纹理上传」的 20ms 温差造成 ​

  • 在交付 39 的交错数据上继续下钻(同一批 trace,无需新设备运行): 1. 违规全部来自极少数帧,且形状一致
    运行帧数超时帧最长连续最差最差帧形状(ui / rt)
    基线 r16500(0.000%)0−2.53ms—
    现状 r16963(0.431%)3+15.69msui 1.49 / rt 25.98
    基线 r26620(0.000%)0−3.40ms—
    现状 r26611(0.151%)1+15.53msui 1.29 / rt 25.96
    两次现状的最差帧形状几乎逐项相同(ui≈1.3–1.5ms、rt≈26ms)——主线程在等渲染线程。
    (趋势与门禁一致:基线 0 超时,现状 0.151–0.431%,门限 0.500%/连续 1。)
  • 2. 上传序列显示:慢的是「整个 trace 的第一次上传」,而且它是可重复的
    运行第 1 次上传之后每次上传
    基线 r1texture 37,3.97ms3.36–6.56ms
    基线 r2texture 35,3.82ms3.45–3.85ms
    现状 r1texture 31,23.42ms3.54–6.67ms
    现状 r2texture 31,23.38ms3.54–5.38ms
    决定性细节:现状的 texture 31 被上传了两次 —— 第一次 23.4ms,第二次(2302ms 处)仅 3.56ms。
    即同一张位图、同一尺寸、同一路径,晚一点上传就只要 3.5ms。
    因此不是位图昂贵、不是尺寸、不是 hardware/software 位图,而是这次上传发生的时机:
    它是整条 trace 的第一次 GPU 纹理上传,落在冷启动阶段。
  • 3. 判定:现状与基线的差别集中在这一个事件上(23.4ms vs 3.9ms,差约 20ms), 而门限违规的最差帧是 +15.5~15.7ms —— 数量级吻合。 两次现状运行都复现(23.42 / 23.38ms),两次基线都没有(3.97 / 3.82ms)。 结论:1.5× 违规的实质是「首个大纹理上传落在冷启动窗口内」,而不是解码策略、 位图类型、tile 调度或绘制数据量。
  • 含义(为什么这是一条好消息):这是一个启动/首帧次序问题 —— 两侧的生产改动恰好就是改变首帧次序的那两处(锚定 effect 与 settle 流), 而不是渲染或解码能力的退化。修的方向应是「让首个大纹理上传不与冷启动争用」 (预热、延后、或让首帧先出小内容),而不是改图像管线。
  • 工具:scripts/count_late_frames.py(在设备侧协议下直接统计超时帧:复用分析器的帧匹配, 以「有多少帧超时、超时多少」为目标时不依赖 benchmarkData.json)、 queries/upload_sequence.sql(上传序列 + 间隔)、queries/uploads_by_texture.sql(按纹理 ID 聚合)。
  • 关联:§9 检查表 ②、交付 39(交错对照)、交付 37(两侧解码相同)。已知限制: 每侧仅 2 轮交错;「冷启动争用」是从数据形状推出的机制,尚未用直接测量证实 (例如把首个上传延后或预热后重测);现状 r1 另有 1 个形状不同的超时帧(ui 19.2ms / rt 1.85ms), 属主线程自身的工作,本交付未解释。

交付 41 — 排除后台工作的干扰后,回归是单帧主线程停顿(4/4 复现);交付 40 的冷启动假设撤回 ​

  • 先说方法上的教训:交付 39/40 的交错数据里,round1-current 的 trace 含 GoogleDriveSyncWorker 运行 60 秒、PowerManagerService.WakeLocks 与 30 秒 AsyncOpImpl —— 也就是说测"现状"那几轮期间,App 自己在做 Google Drive 备份工作。 基准要启动 App,App 就会调度自己的后台任务;哪一侧装着的构建碰上 worker 触发,哪一侧就继承它的代价。 这是此前「上传慢 3–6×」「首个上传落在冷窗口」的真实来源: 交付 40 的冷启动争用假设据此撤回(不是错的机制,而是没有在干净设备状态下测得)。
  • 干净交错 A/B(每轮跑前 force-stop 并停掉 App 的 job,两轮 + 复跑共 4 轮):
    运行帧数超时帧最长连续最差
    基线 162311+3.98ms
    基线 265300−2.14ms
    基线 367300—
    基线 464700—
    现状 165632+20.14ms
    现状 264922+16.52ms
    现状 364832+10.87ms
    现状 468022+10.72ms
  • 决定性细节:违规是同一帧,且形状一致
    运行最差帧位置时刻overrunuirt
    现状 1frame #25266.6ms+20.1430.850.98
    现状 2frame #25266.6ms+16.5227.341.11
    现状 3frame #27290.7ms+10.8722.460.89
    现状 4frame #32316.1ms+10.7222.031.18
    即:**4/4 现状运行都在「启动后约 25–32 帧(266–316ms)」这一个主线程帧上停顿 22–31ms,
    而渲染线程当时是空的(rt≈1ms);4/4 基线运行在同一区域完全没有**这样的帧。
  • 同时推翻了交付 39/40 的帧形状:交付 40 说的「ui 1.3–1.5ms / rt≈26ms(在等渲染线程)」 在干净状态下的现状帧里不再出现;干净状态下的现状最差帧是 ui 22–31ms / rt≈1ms, 方向正好相反。因此「等纹理上传」这条线整体作废(它是后台 worker 争用的产物)。
  • 当前证据形状(新一轮的起点):
    1. 回归真实且高度可复现(4/4 对 4/4,且落在同一帧区间);
    2. 位置是启动后 25–32 帧,即首屏阶段,不是稳态旅程;
    3. 代价在主线程(22–31ms),渲染线程为空;
    4. 两侧生产改动仍只有 ComposeScenePagedReader.kt 的两处 (c1ee474ce 锚定 effect / b04ac16d1 settle 流 withFrameNanos), 而这两处都作用于"首帧与 settle 时机" —— 与 25–32 帧的位置吻合。
  • 测量手段的限制(已确认):trace 里主线程只有 Choreographer#doFrame 与少数切片, 停顿落在未打点的代码里(查询 queries/main_thread_work.sql 显示 >0.2ms 的主线程切片 只有 Zygote:FillUsapPool 与 16 次 binder transaction,而现状比基线多的正是那 16 次 binder)。 → 下一步必须给候选代码路径打 atrace 标记才能命名停顿(而不是继续从聚合数字里猜)。
  • 工具:scripts/interleave_ab_clean_scenario.ps1(每轮跑前 force-stop + 停 App job 再测)、 scripts/list_late_frames.py(打印每个超时帧的位置/时刻/相位形状)、 queries/main_thread_work.sql、queries/thread_names.sql、 scripts/audit_worker_activity.py(逐 trace 检查后台 Worker 是否真的占用过时间 —— 那次 GoogleDriveSyncWorker 干扰的廉价守门)、scripts/find_slice_owner.py(按名字找切片并打印其 process/thread 归属,用来判断某条切片是否属于被测应用)。
  • 关联:§9 检查表 ②、交付 39/40(其「纹理上传」与「冷启动窗口」结论撤回)、交付 37(两侧解码相同,仍成立)。 已知限制:停顿的具体代码路径未定位(trace 无该区间的主线程打点); 「25–32 帧」这一位置来自 4 次运行,尚未与代码路径对齐。

交付 42 — 真实入口冷启动 A/B:首屏停顿在真实入口不复现 —— ⚠ 本条已作废(见交付 43) ​

⚠ 2026-09-21 作废声明:本交付的全部结论撤回。原因不是统计或环境,而是被测的阅读器当时 没有渲染任何页面:把 CS-7 夹具目录放进本地库根后,应用为该目录写了 index.json,其中 entries 被固定为下载命名正则 "%08d_%04d\d{4}"(ContentIndex.kt:173),而夹具文件名是 page_000.jpg…,于是 LocalMangaParser.getPages 的正则过滤命中 0 个文件 (LocalMangaParser.kt:428-430)。症状实测一致:阅读器打开后为空白、翻页无效、 work_history.percent 停在 −1(说明 pagesCount = 0)。因此下面那些「147–148 帧、 ui max 4.7–8.4ms」描述的是一个没有页面的活动(廉价重绘),不能用来回答 「真实入口会不会停顿」。 作废的是结论,不是方法:测量链路本身(真实入口 + 归档同款 Perfetto 配置 + 交错 A/B)仍然有效; 修正夹具命名后的重测见交付 43。夹具命名要求已固化进工具 (scripts/real_entry_fixture.ps1 在 setup 时把 page_%03d.jpg 重命名为 00000000_0001%04d.jpg), 并用「读取位置」这一硬证据把「页面真的渲染了」与「只是活动起来了」区分开。 与之相关的独立疑点(本地目录导入是否对用户表现为空白阅读器)已单独立项跟踪。

  • 动机:§4.2.1 的门控保真度声明指出 CS-7 门禁绕过分页场景宿主门控,所以 「1.5× 违规 = 用户会遇到的首屏卡顿」在此之前只是一个未验证的推论。本轮把测量搬到真实入口。
  • 测量链路(新增,可复用):把 CS-7 夹具 v1_paged_large(8 张 6000×9000 JPEG)放进应用自己的 本地库根 files/manga/bench_large_paged,由 LocalIndexUpdateWorker(MainActivity.kt:800)建索引 (manga_id=-722638835630210246,source=LOCAL);由于应用从不给纯本地漫画写 chapters 行 (本地章节平时经 ParcelableContent 传给阅读器),需注入一行 chapters,其 chapter_id 取应用 自己写的 index.json 键 "#<目录 uri>".longHashCode() = 4897191628874209651(已用同算法复算校验, 未自行发明 id)。随后以 am start -W -a org.skepsun.kototoro.action.READ_MANGA -d https://kototoro.app/manga/<id> 冷启动 ReaderActivity,用与归档 trace 逐字相同的 Perfetto 配置采集首屏(配置直接取自归档 trace 的 metadata.trace_config_pbtxt,含帧匹配所需的 android.surfaceflinger.frametimeline)。
  • 内容保真度(实测,非假设;⚠ 见上方作废声明:该结论被误读):4/4 trace 都含 3 条 ID#w=6000;h=9000;dw=2731;dh=4096;src=FileSource{file=…/bench_large_paged/page_000.jpg}; 事后复核:这些解码来自封面兜底(本地漫画的 cover 就是第一页),不是阅读器正文渲染 —— 当时该章节的 pagesCount 为 0,正文为空。
  • 模式保真度:DetectReaderModeUseCase 的 webtoon 判据是 width * 1.8 < height,6000×9000 不满足 → ReaderMode.STANDARD(分页);叠加已开启的 isExperimentalPagedSceneReaderEnabled=true, 实际走的正是 ComposeScenePagedReader —— 即含那两处候选改动的代码路径。
  • 协议:交错 A/B(同轮内先基线后现状,两侧同设备同内容),每侧依次安装 → cmd package compile -m speed -f(Full)→ force-stop + 停 job → 丢弃一次预热 → 采集。2 轮 × 2 侧,逐轮温度 37.0 / 37.0 / 38.0 / 38.0 °C、刷新率 120.00001Hz(记录于 runs.txt)。 与门禁协议的偏差:本轮测的是形状比较而非绝对 SLO 数值,未强制 ≤35.0°C、未做 5 迭代; 设备为图案锁,降温不能熄屏(熄屏即重新锁屏,会让窗口不可见并把 trace 变成 1 帧)。 该偏差不适用于任何绝对门禁判定,只适用于同轮交错的两侧比较。
  • 结果(首 40 帧窗口):
    trace帧数超时帧ui p50ui p99ui max窗口内 ui max窗口内超时窗口最差 overrun
    基线 r114820.734.447.417.412+38.87ms
    现状 r114720.834.175.385.382+34.46ms
    基线 r214820.764.504.744.742+34.11ms
    现状 r214820.684.458.398.392+38.97ms
    • 两侧无法区分:各 2 个超时帧,且都是窗口出现的第 0/1 帧(首帧 overrun 是首帧时间线的常数项, 两侧同现,与代码无关);整条 trace 的主线程帧上界 4.7–8.4ms,远在 20ms 门限内,更远低于 benchmark 现状帧的 22–31ms。
    • 窗口对齐方式不影响结论:真实入口在该区段没有任何 ≥10ms 的主线程帧,无论按帧序号(25–32) 还是按墙钟(230–350ms)对齐都看不到 benchmark 的停顿形状。
  • 判定:benchmark 首屏停顿在真实入口不复现(4/4 运行一致)。由此:
    1. 「1.5× 违规」是候选路径在 benchmark 旅程下的代价,不是 release 用户打开阅读器会遇到的卡顿 —— §4.2.1 门控保真度声明的口径要求由此取得实测支持(此前只是声明)。
    2. promotion 阻塞项 (c) 的用户可感知风险由这条测量排除;但门禁数值违规本身仍然成立, 仍需归因或按「重设条件」重新立项,不得因本条结论直接放宽门限。
    3. 未覆盖:真实入口的稳态翻页旅程(本轮只有冷启动首屏,无翻页、无 1.5× 缩放)。若要把 「旅程代价」也搬到真实入口,需要能驱动真实手势的 journey(下一轮候选)。
    4. 未覆盖:本夹具是本地漫画(离线、单章 8 页);网络源的加载时序不在本轮范围内。
  • 工具:scripts/interleave_ab_real_entry.ps1(含「设备已解锁且窗口可见」双向断言, 防止在锁屏/息屏下测出不可见窗口)、scripts/real_entry_reader_run.sh、 scripts/perfetto_reader_real_entry.cfg、scripts/print_frame_window.py、 scripts/compare_real_entry_runs.py;证据归档 E:\kototoro_demo\reader-bench\realentry-20260921\ab\。
  • 关联:§4.2.1(门控保真度声明)、§9 检查表 ② 与阻塞项 (c)、交付 41(benchmark 侧的停顿形状)、 交付 37(两侧解码相同)。已知限制:n=2 轮/侧(但 4/4 一致且余量 2.4×);温度高于门禁协议; 仅冷启动首屏;本地夹具;未测真实入口的翻页旅程。

交付 43 — 修正后的真实入口测量:首屏与翻页旅程都不复现 benchmark 的停顿 ​

  • 为什么有这一轮:交付 42 的结论被作废,根因是夹具目录解析出 0 页(见该条作废声明)。 本轮把夹具命名改成应用自己的本地命名后重测,并第一次覆盖真实入口的稳态翻页旅程。
  • 修正了什么(三处,缺一不可):
    1. 夹具命名:页文件名必须是应用下载输出约定 %08d_%04d%04d(DownloadWorker.kt:2921、 LocalMangaDirOutput.kt:397),否则 index.json 的 entries 正则(ContentIndex.kt:173) 在 getPages 里把全部文件过滤掉。已固化进 scripts/real_entry_fixture.ps1。
    2. 起始位置受控:阅读器会从上次位置续读,因此每次测量前 real_entry_fixture.ps1 -Action reset 清掉该漫画的 work_history 行,两侧从第 1 页开始。
    3. 有效性硬门:每次运行后读 work_history 的 page/percent,作为「页面真的渲染了/翻页真的提交了」 的证据 —— 这正是交付 42 缺失的那道门。另注:位置在关闭阅读器时落盘,所以被杀掉的进程看起来 像「没翻页」(脚本在 trace 停止后补一次 BACK)。
  • 同时修掉两个自建工具缺陷(方法教训,都会导致假结论): ① 拉库时必须先删本地临时副本:设备上没有 -wal 时 cp 失败会留下上一次的 -wal, SQLite 会把它重放,把刚删掉的行复活;② 设备侧的 /sdcard/Download/refix* 暂存文件同理, 必须一并删除。两者叠加时表现为「reset 明明执行成功,回读却还是旧值」。
  • 协议:交错 A/B(同轮先基线后现状),2 轮 × 2 侧 × 2 模式(screen 冷启动首屏 / journey 首屏 + 4 次整页翻页)。每侧:安装 → cmd package compile -m speed -f(Full)→ force-stop + 停 job → 重置位置 → 丢弃一次预热 → 采集(归档同款 Perfetto 配置)。温度 41.9–42.0°C、刷新率 120.00001Hz。
  • 有效性门(4/4 + 4/4 全过):screen 四条运行全部 page=0 percent=0.125(= 第 1 页 / 共 8 页); journey 四条运行全部 page=4 percent=0.625(4 次翻页全部提交)。
  • 内容证据(页面级):screen 四条都解码 00000000_00010001.jpg;journey 四条都解码 …0001 到 …0005(首屏 1 页 + 4 次翻页各 1 页),两侧逐页相同,即确实在渲染同一份夹具。
  • 结果 1 — 首屏(首 40 帧窗口):
    trace帧数超时帧ui p50ui p99ui maxrt max
    基线 r1 screen9381.109.9310.0510.56
    现状 r1 screen9531.097.5811.757.58
    基线 r2 screen10750.9911.6513.2417.74
    现状 r2 screen8661.108.779.9414.83
  • 结果 2 — 翻页旅程(首屏 + 4 次翻页;翻页段取墙钟 5.0–11.0s):
    trace全 trace 帧数超时帧首屏 ui max翻页段帧数翻页段超时帧翻页段 ui p99翻页段 ui max
    基线 r1 journey324413.8621104.736.14
    现状 r1 journey302710.8821104.695.37
    基线 r2 journey307512.0921204.756.49
    现状 r2 journey29967.9721104.885.96
  • 判定:在真正渲染页面的真实入口上,两个模式都看不出两侧差异:首屏主线程上界 10–13ms、 翻页段 5.4–6.5ms(且翻页段超时帧为 0),对照 benchmark 现状帧的 22–31ms 停顿差一个量级。 因此:
    1. 「1.5× 违规 = 候选路径在 benchmark 旅程下的代价,而不是用户打开阅读器/翻页时会遇到的卡顿」 得到有效证据支持(交付 42 想要的那个结论,现在站得住了)。
    2. promotion 阻塞项 (c) 的用户可感知风险由本条排除;门禁数值违规仍然成立,仍需归因或 按「重设条件」重新立项,不得据此放宽门限。
    3. 附带发现一条独立疑点(本地目录导入疑似空白阅读器)已单独立项(夹具命名机制的用户可达性), 并已由交付 44 定位与修复(selectChapterEntries 回退;真机前后对比如该条)。
  • 工具:scripts/real_entry_fixture.ps1(setup/verify/teardown/reset/position)、 scripts/real_entry_db_row.py、scripts/real_entry_reader_run.sh(screen/journey/warmup)、 scripts/interleave_ab_real_entry.ps1、scripts/compare_real_entry_runs.py(新增 --window-ms)、 scripts/print_frame_window.py、scripts/perfetto_reader_real_entry.cfg。 证据归档 E:\kototoro_demo\reader-bench\realentry-20260921\ab2\{screen,journey}\。
  • 关联:交付 42(作废声明与根因)、§4.2.1 门控保真度声明、§9 检查表 ② 与阻塞项 (c)、交付 41。 已知限制:n=2 轮/侧;温度 41.9–42.0°C 高于门禁协议(比较形状可用,绝对数值不可用); 真实入口未复刻 benchmark 的 1.5× 缩放;本地离线夹具;翻页为注入拖拽(4/4 提交已由位置证据确认)。

交付 44(独立缺陷,非 scene 主线)— 本地目录导入的空白阅读器:已定位并修复 ​

  • 症状:一叠松散图片被放进本地库(导入或拷贝)后打开,阅读器空白、翻页无效、百分比停在 −1。
  • 根因(代码链,逐环可查):① SingleContentImporter.copyInto 复制时保留原始文件名 (SingleContentImporter.kt:171-180,01.jpg 仍是 01.jpg);② 应用扫描目录写 index.json 时, ContentIndex.addChapter 把 entries 固定为下载命名正则 %08d_%04d\d{4} (ContentIndex.kt:173,该命名只由 LocalMangaDirOutput.kt:397 / LocalMangaZipOutput.kt:136 / DownloadWorker.kt:2921 产生);③ LocalMangaParser.getPages 在 index 存在时只按该正则过滤 (旧 LocalMangaParser.kt:428-430)→ 命中 0 个文件 → 章节 0 页。
  • 复现(真实布局 + 真实代码路径):本地库根放入 importtest/01.jpg…04.jpg(夹具页副本), 交给应用自己索引;实测其 index.json 的章节记录为 "entries":"00000000_0001\d{4}", "file":"", 与文件名不可能匹配。修复前打开该漫画:无读取位置记录(未建立有效阅读状态 = 0 页)。
  • 修复:新增 selectChapterEntries()(LocalMangaParser.kt)作为唯一选页决策点 —— index 映射命中则照旧使用;命中为空则回退到「章节目录自身的图片(或 html/xhtml)」, 与「无 index」时的行为一致;index 不认识该章节也不再抛异常(runCatching → 回退)。 纯局部回退,下载命名内容的行为不变。
  • 验证:① JVM ChapterEntriesSelectionTest 5 例(下载映射、命中为空的回退、无 index、 嵌套目录不越界、空章节):tests="5" skipped="0" failures="0" errors="0"; ② 真机同内容前后对比:修复前无读取位置 → 修复后 page=0 percent=0.25(4 页), 再点击翻页 page=1 percent=0.5;③ 回归:下载命名的 8 页夹具仍为 percent=0.125(未受影响)。
  • 未覆盖 / 诚实边界:SAF 那条腿没有在设备上跑通 —— adb 无法投递 ExternalStorageProvider 的 tree URI 授权(实测 am start --grant-read-uri-permission 后 仍报 SecurityException: requires that you obtain access using ACTION_OPEN_DOCUMENT), 因此设备验证用的是导入器产出的同一布局(原始文件名、按 copyInto 语义放置), 而不是真的走一次系统选择器;「导入保留文件名」是代码级证据(①)。
  • 提交 1363e1ddf。证据:app/build/test-results/testDebugUnitTest/TEST-…ChapterEntriesSelectionTest.xml。

交付 45 — 阅读器更多面板「阅读」页:两个按系列独立的场景渲染器开关 ​

  • 需求:在阅读器更多面板的「阅读」页分别提供 webtoon 与 pager 的 scene reader 开关, 默认值保持现状(webtoon 开、pager 关)。
  • 做法:面板新增两行开关(reader_scene_renderer_webtoon / reader_scene_renderer_paged, 中英文各一条),直接读写既有的两个偏好 isExperimentalSceneReaderEnabled / isExperimentalPagedSceneReaderEnabled —— 与设置页共用同一份 pref,不新增存储; 面板 state 的默认值(true / false)与 pref 默认一致,因此默认行为不变。
  • 门控顺带解耦(重要):宿主选择不再写成 A && B,而是收敛到唯一纯函数resolveSceneReaderEnabled(mode, isDoublePage, webtoon, paged) (reader/ui/config/SceneReaderGate.kt):双页与单页/上下取 pager 开关,条漫取 webtoon 开关。 此前「关掉条漫渲染器」会连带关掉分页场景宿主,而设置页把它们呈现为两个功能 —— 独立化让代码与 界面一致,也让 promotion 的回退路径(只关 pager 开关)真正可用。
  • 明确边界:CONTINUOUS_HORIZONTAL 是唯一的 scene-only 模式 (ComposeReaderScreenRoot 直接组合 ComposeSceneHorizontalReader,没有 legacy 可回退), 因此不受条漫开关影响;把开关作用到它只会让该模式变成空白页。此边界写在 SceneReaderGate 的 KDoc 与单测里,不靠注释口口相传。
  • 验证:① JVM SceneReaderGateTest 5 例(条漫只随条漫开关、分页只随分页开关、双页归分页、 两开关互不影响、家族映射)tests="5" failures="0" errors="0"; ② 真机(M332BF,含本次改动的 benchmark 构建)打开阅读器 → 长按中央开面板 → uiautomator 层级中出现两行开关(条漫场景渲染器 / 分页场景渲染器), 且 checkable 节点的 checked 与当前 pref 一致; ③ 逐行点击后回读 pref:分页行 reader_experimental_paged_scene_engine 随点击 true→false→true, 条漫行 reader_experimental_scene_engine 随点击 (缺省)→false→true,互不影响; ④ 在面板里切换后阅读器仍正常渲染(关闭时落盘的读取位置 page=0 percent=0.125,即 8 页)。
  • 关联:交付 42/43(真实入口测量的门控条件,那两轮在旧合取下测得,结论不受影响)、 §4.2.1 门控保真度声明(已按本条更新)、§9 阻塞项 (c)(默认值清单由三处更新为四处)。

交付 46 — 更多面板:双页滚动灵敏度从「页面」页移到「阅读」页 ​

  • 动机:该滑杆只在「横屏时双页」开关打开时才有效,而那个开关在**「阅读」页**;此前它挂在 「页面」页的「分割双页」之下,配置位置与生效条件分离。
  • 改动:ComposeReaderOptionsSheet 中把 ReaderDoublePageSensitivity 从 ReaderPageOptionsPage 移入 ReaderReadingOptionsPage 的双页开关组内(顺序:横屏时双页 → 折叠屏双页 → 首图作为封面 → 双页滚动灵敏度),可见条件不变(仍是 if (state.doublePage),即开关关闭时不显示)。
  • 验证(真机 M332BF,含本次改动的构建):开关关时两个页签都没有该滑杆;开关开后 「阅读」页出现(uiautomator 层级里 双页滚动灵敏度 标签 + android.widget.SeekBar, 标签 bounds [85,2246][1096,2311]),而「页面」页内容为 缩放模式 / 页面裁切 / 分割双页 / 限制图片内存缓存 / 减少页面预加载,不含该滑杆;开关的 pref reader_double_pages 随点击翻转, 验证后已把该键从 pref 文件移除、恢复为缺省(false)。
  • 关联:交付 45(同一面板)、§9 阻塞项 (c)。

交付 47 — 场景宿主翻页时闪「加载中」:把「正在加载」限制为真的在等 ​

  • 症状(用户报告):开启场景渲染器后,分页 + 仿真动画下每次翻页的开始都会闪一下「加载中」, 即使该页早已缓存;legacy 渲染器没有这个问题。
  • 根因(两处,互相放大):
    1. PagedSceneResourceWindowStrategy 只把当前槽位设为 PRESENTATION_READY,前后槽位都是 SOURCE_READY(PagedSceneResourceWindowStrategy.kt:63-75)→ 下一页的解码正好在翻页那一刻开始; 而 KototoroImagePipelineAdapter.acquireAsset 在开始加载时无条件先发布 ReaderImageLoadState.Loading(旧 :277)→ 于是每次翻页都先进入「加载中」状态。
    2. 三个场景宿主的叠加层把除 Ready 以外的一切都当作加载中(else -> ReaderPageLoading(...)), 而 downgradeToSource 会把离开资源窗口的页面的 loadState 整条删掉 (:563)→ 「未知状态」也被渲染成转圈 + 「加载中…」。
  • 修复:
    1. 叠加层唯一判定点:resolveSceneReaderPageOverlay(state, hasRenderableAsset) + 共享的 SceneReaderPageLoadOverlay(...)(ComposeReaderPageComponents.kt):只有显式 Loading 且手上还没有可绘制资源才显示转圈;未知状态不显示;已有资源时不遮盖画面;失败仍然显示错误与重试。 三个宿主(paged / webtoon / horizontal)原先各自复制一份同样的 when,现在共用这一个。
    2. 加载状态延迟发布:acquireAsset 不再一进门就发布 Loading,而是等 LOADING_STATE_DELAY_MS = 120ms 仍未完成才发布(finally 里取消,保证不会在 Ready/Failed 之后补一发假的 Loading)。命中内存缓存的 解码远快于 120ms,因此不再有转圈;真正慢的页仍然会得到指示。
  • 实测(真机 M332BF,8 页夹具,真机 Perfetto + 新增 trace 计数器 Reader.LoadingPages):
    运行翻页每页解码耗时发布过 Loading 的翻页数
    B:回翻已看过的 4 页(暖)4/4 提交(page 4 → 0)51.0 / 52.7ms(另 2 页直接命中保留资源,无需解码)0 / 4
    A:前进 4 页(含首次解码)4/4 提交(page 0 → 4)首次 196.8ms,其余 47–52ms1 / 4(就是那次 196.8ms 的慢解码)
    即:≤120ms 的翻页一律不再出现「加载中」;唯一仍显示指示的是那次真正花了 ~197ms 的首次解码,
    且只显示其超出 120ms 的部分(该次总计约 217ms)。
    旧代码的对照(同一机制、可从同一条 trace 的耗时推出):Loading 在解码开始前发布、到 Ready 才清除,
    因此转圈时长 ≈ 解码时长 —— 暖翻页 51ms、冷翻页 197ms,正是用户报告的「每次翻动开始都闪一下」。
  • 可观测性:新增 Reader.LoadingPages trace 计数器(与既有 Reader.ActivePresentationAssets 同规格, 仅 Trace.isEnabled() 时写入):这类回归在帧时序里看不见,只有状态计数能看见。
  • 验证:SceneReaderPageOverlayTest 5 例(Ready/未知/Loading 无资源/Loading 有资源/Failed)、 ReaderImageAssetTest 新增 2 例(快解码不发布 Loading、慢解码在延迟后发布并在完成后变 Ready), 全量单测 475 套件 2683 例 0 失败;真机前后对照见上表,阅读器位置证据(4/4 与 4/4 提交)与 夹具清理(files/manga 空、无残留 DB 行)均确认。
  • 关联:交付 42/43(同一场景宿主与真实入口测量链路)、交付 45/46(同一面板)。已知边界: 120ms 是一个命名常量、按「暖解码远快于此」选定,未做多机型标定;网络页的 Downloading 进度 仍然即时发布(那是真实进度反馈)。

2026-09-21 追加(用户复测反馈:连续翻页仍闪,高级动画下明显):首版的 120ms 延迟只覆盖了 acquireAsset 的首次发布,没有覆盖进度发布 —— ComposeReaderImagePipeline.observe 对 源状态未缓存的页面会先发 Downloading(progress)(ComposeReaderImagePipeline.kt:113-119), 而连续翻页时每一页都属于这一类;适配器把进度立即转成 Loading,于是「加载中」又回来了。 已把进度发布并入同一道门(AtomicBoolean 门闩:延迟到期后才接受进度发布,慢页仍有进度反馈), 并补了一个能真正区分前后的用例:用 gate 挂住 ready 状态、断言「进度到达时状态不得是 Loading」 ——去掉门闩该用例变红、加回即绿(两次实跑均已确认);此前那版基于 loadStates 流收集的用例 因 StateFlow 合并而不具备区分力,已重写。用户另反馈「先左后右连续拖拽时的闪烁」在上一轮 修复后已消失。 实测(真机 M332BF,8 页夹具,reader_animation2=ADVANCED,连续前进 6 页):有效性门 6/6 提交(page 0 → 6,percent 0.875);Reader.LoadingPages 计数器 7 个采样全为 0 —— 整轮没有发布过一次加载状态;同一轮里第 2–7 页各发生一次真实解码(41.6 / 43.4 / 44.0 / 45.4 / 46.5 / 46.6ms),说明翻页确实在解码而非未动。打开时那次 152.8ms 的首屏慢解码同样没有 闪提示(该页的状态在延迟到期前已由其它路径写入,故按 containsKey 守卫跳过)。

交付 48 — 翻页动画被新手势打断后停在半路(双击 / 轻触 / 长按都会):已修复 ​

  • 症状(用户报告):分页模式下翻页动画运行期间,新手势(例如双击)会中断动画 —— 动画直接停在 中途,页面卡在两个槽位之间;legacy 渲染器没有这个问题。
  • 根因:手势处理在触摸按下时就取消了翻页动画(ComposeScenePagedReader 的 awaitEachGesture 开头 snapAnimationJob?.cancel()),而只有「变成拖拽」的手势会在抬手时重新吸附到目标槽位 (dragState.pageOffset != 0f 分支)。于是任何不成为拖拽的手势 —— 轻触、双击缩放、长按菜单、 被取消的手势 —— 都把翻页动画停在半路:动画进程没了,也没有吸附补上。 关键点:「卡住」是视觉状态;阅读器上报的当前页仍按 round(offset / extent) 取最近槽位, 所以状态通道看起来一切正常 —— 这既是它此前没被发现的原因,也是它没被现有打断矩阵覆盖的原因。
  • 修复(三处,按「谁能接管视图」划分):
    1. 触摸按下不再取消翻页动画:轻触/长按让动画照常落地,与 Compose pager 的行为一致。
    2. 拖拽真正接管时才取消(越过 touchSlop 的那一帧),并把 startOffset 重新基准到当前偏移, 避免「抓住正在翻的页面」时被弹回手指落点。
    3. 捏合与双击缩放:取消动画后用新增纯函数 resolveSettledTurnOffset(offset, primaryExtent, slotCount, maxOffset)(reader-core)把偏移 吸附到最近槽位再缩放 —— 于是「双击缩放的页面」就是屏幕上真实的那一页。
  • 验证:① reader-core 新增 SettledTurnOffsetTest 4 例(已在边界则不吸附、半路吸附到最近槽位、 夹在可滚范围内、退化几何不吸附)0 失败,reader-core 的 I1 隔离守卫仍绿; ② 真机新增 3 个用例(ScenePagedTransitionMatrixTest):tapDuringATurnLetsTheTurnLand、 tapDuringACurlTurnLetsTheTurnLand、doubleTapDuringATurnSettlesAndKeepsTurning 全部通过 (harness 补了 doubleTap() / settledReportCount() / awaitNewReport())。
  • 测试覆盖的边界(诚实披露):上述用例断言的是状态契约 —— 轻触不再阻止翻页落地(动画必须 到达目标页)、双击后阅读器必须重新吸附并上报、之后仍能翻页;而**「不再停在半路」本身是视觉状态**, 基于上报页的 harness 看不到(上报页按最近槽位取整),因此该点由用户在真机上手测确认, 不由测试证明。这也解释了为什么「tests 全绿」不能作为这条修复的唯一证据。
  • 关联:交付 42/43(同一宿主)、交付 47(同一轮用户反馈)。

未启动 ​

  • 阶段 D(retained GraphicsLayer PoC)—— 可行性探针已交付并给出负结果(交付 16): 首轮目标 SLIDE/COVER 的翻页帧零超时、绘制录制仅占帧预算约 2%,retained 能省的收益落在噪声内, 按 §7.3 不实现。若仍要继续,靶子应换成解码/上传调度或 CURL 冷启动争用(见交付 16 末段)。
  • §4.2 长章节组夹具与测量方法 —— 已交付(交付 18):50/500/5000 页三种夹具模式 + 三条 journey,真机实测页数扩大 100 倍而帧耗时/内存/资源全部持平。
  • 高倍率残差归因的进一步实验:tile 到达/上传调度优化(用 §4.2.1 容忍度做 A/B)。 已由交付 28 从 1.5× 违规的候选里移除:该场景基线与现状的 tile 计数全为 0(单张取样位图), tile 调度对它不可能有改善。该方向若要继续,应挂在**高倍率组(2.0×/2.5×)**而非 1.5×。
  • §5.1 回归矩阵其余项、TalkBack 真机记录(阶段 B 余项)。 「生命周期」行已闭环(交付 15 + 交付 19 三格);「连续模式」行已转宿主级设备覆盖(4 格各 3 连跑); 「输入」行的 DPAD/键盘/音量键映射已由交付 20 的 11 例 JVM 覆盖; 仅剩 TalkBack 真机记录(需人工在设备上开启 TalkBack 操作,无法自动化)。
  • §8.3 后续模块轮次(scene-image → scene-compose → kototoro-reader-adapter)与 Phase C 宿主 API 重构。
  • (可选)把基准门禁接到自托管真机 runner:仓库现有 7 个 workflow 都是构建/发布/文档, 托管 CI 没有设备,CS-7 交付的门禁目前只能显式在设备机上运行。
  • §9 promotion 检查表 —— 第一轮判定已交付(交付 21):① 候选身份可追溯通过; ② 参考场景(regular 组)11 项门禁全过;③④ 部分通过、缺口具名;⑤⑥ 未开始。 剩余阻塞项收敛为三条:(a) CS-2 真机视觉像素度量重跑 —— 已由交付 22 闭环; (b) CS-3 RTL 真机布局确认 —— 已由交付 23 闭环(拆分为「场景语义=镜面属性 11 例绿」+ 「宿主接线=一行直传」,并记录该缺口在 full-bleed 布局下真机证据不可得的原因); (c) 翻转 isExperimentalPagedSceneReaderEnabled 默认值并过一个 nightly 周期 —— 唯一剩余阻塞项,属发布动作,需人工决定。 补充(交付 42 → 交付 43):该阻塞项的用户可感知风险已由真实入口 A/B 排除 (首屏与翻页旅程都不复现,且交付 43 的有效性门与页面级证据齐全),但它所依赖的门禁数值违规 仍然成立,故阻塞项未解除,仍需归因或按「重设条件」重新立项。

(已交付项对应的清单项在此移除:§8.2 :reader-core 抽取见交付 6,阶段 A 基线固定与结果记录 见交付 8/9/12,SLO 制定见 §4.2.1,CS-7 门禁见交付 13。)

11. 官方依据 ​

Documentation for Kototoro