ReadViewSDK/Doc/FeatureSolution/TEXT_STATISTICS_PIPELINE_OPTIMIZATION.md
shenlei 8b68975ee1 新增全书字符统计与阅读百分比,并沉淀后台渲染流水线优化方案
- 新增 RDEPUBTextStatisticsCoordinator:后台单线程按章渲染统计全书字符数,footer 显示精确阅读百分比;统计未就绪时(含 PageMap 未覆盖的 loading 页)显示"计算中",不再回退旧页码
- 统计结果按 bookID + renderSignature + 章节内容哈希做磁盘缓存,布局/字号变化自动失效;缓存命中不创建 renderer
- 移除"0.8 秒翻页时间窗轮询让路"逻辑,后续由事件驱动的交互加载门替代(见方案文档 5.6 节)
- 新增 Doc/FeatureSolution/TEXT_STATISTICS_PIPELINE_OPTIMIZATION.md:三条渲染流水线收敛为"交互 + 生产者"、交互加载门、全局后台渲染预算池、后台渲染登记表、两阶段近似百分比、自适应并发的完整优化方案与验收标准

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:38:41 +09:00

265 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 阅读进度统计与后台渲染流水线优化方案
> 创建2026-07-03
> 状态:方案评审中(两阶段近似百分比、自适应并发已确认纳入正式范围)
> 范围:textReflowable 阅读模式(章节级运行时)
---
## 1. 背景与问题
阅读百分比统计(`RDEPUBTextStatisticsCoordinator`)上线后,书籍打开初期(统计计算进行中)用户翻页明显卡顿。此前已做的缓解手段(统计降为单线程、缓存命中不创建 renderer)未根治问题。
根因不是统计线程本身,而是**打开书后同时存在三条互不知晓的章节渲染流水线**,它们做的是同一种重活(DTCoreText HTML → NSAttributedString 构建),同一章最多被渲染三遍,并在首屏后的前几秒集中抢占 CPU——恰好是用户开始翻页的时刻。
### 1.1 三条流水线现状
| 流水线 | 入口 | 队列 / 并发 | 产出 | 缓存 |
|--------|------|-------------|------|------|
| 交互加载(导航/预览/预取) | `RDEPUBChapterLoader` | `chapterLoadQueue`(userInitiated,串行) | RuntimeChapter + ChapterSummary | `RDEPUBChapterSummaryDiskCache` |
| 窗口元数据解析 | `RDEPUBMetadataParseWorker` | 独立 OperationQueue(utility,**并发 = 全部 CPU 核**) | ChapterSummary + PageMap 增量刷新 | 同上(同 cacheKey) |
| 全书字符统计 | `RDEPUBTextStatisticsWorker` | 独立 OperationQueue(utility,串行) | 每章字符数 → 阅读百分比 | `RDEPUBTextStatisticsDiskCache`(独立,仅字符数) |
三者的缓存 key 均为同一个 `RDEPUBChapterCacheKey`(bookID + spineIndex + renderSignature + contentHash),但统计流水线**不读** summary 缓存,summary 生产者也**不通知**统计。
### 1.2 打开书流程的具体问题
`RDEPUBReaderPaginationCoordinator.paginatePublication` 的执行顺序:
1. **统计流水线最先启动**(`paginatePublication` 内先于首章渲染调用 `startTextStatistics`),从 spine 0 按顺序渲染全书,与首屏关键路径抢 CPU;渲染顺序与用户阅读位置无关。
2. **首屏串行等待 1~3 章**:utility 队列上信号量同步等 anchor 章渲染;anchor 章不足 2 页时再同步加载相邻章(`loadInitialInteractiveRuntimeChapters`),之后才回主线程出首屏。
3. **首屏一出即 CPU 风暴**:出首屏的同一个 `main.async` 块内连续触发窗口 prefetch、`MetadataParseWorker`(全核并发)启动;统计已在跑。metadata worker 仅在启动时等一次 0.8 秒交互冷却,之后章间不让路。
4. **`MetadataParseWorker` 在主线程初始化**:init 中同步读取覆盖窗口内每章 HTML 并计算 sha256(`contentHashBySpineIndex`),主线程磁盘 I/O + 哈希,首帧后主线程可见占用。
5. **翻页卡顿的直接机制**:翻到未加载章节时主线程经 `loadChapterSynchronouslyForMigration` 信号量同步等 `chapterLoadQueue` 渲染,而 CPU 已被两条全书/窗口流水线占满,该次渲染耗时被放大 2~3 倍。
6. **架构缺口**:首次打开时 `MetadataParseWorker` 只覆盖 anchor 附近窗口(`metadataCoverageSpineIndices`),全书 PageMap 靠翻页时 3 章一批扩展(`extendPartialBookPageMapIfNeeded`)——当前架构中**不存在全书 summary 生产者**,统计因此只能自己渲染全书,造成整书级别的重复劳动。
### 1.3 关键代码位置
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift`
- `paginatePublication`:统计过早启动(约 L32)
- `paginateTextPublication``loadInitialInteractiveRuntimeChapters`:首屏串行多章(L264)
- `metadataCoverageSpineIndices`:窗口覆盖范围(L341)
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorker.swift`
- init 主线程哈希(L95~105);`workerCount = activeProcessorCount`(L92);启动时单次交互冷却(L187)
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBTextStatisticsCoordinator.swift`:独立渲染 + 独立缓存
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift`
- `loadChapterSynchronouslyForMigration`:主线程信号量同步等待(L182)
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift`
- `textProgressFooterText`:百分比消费点(L142~153)
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift`:`chapterLoadQueue`、pending load 状态
---
## 2. 关键设计事实与约束
方案的取舍建立在以下三个事实上,评审时先对齐。
### 2.1 一个百分比数字由两条流水线各出一半
Footer 百分比的计算是 `statistics.percent(spineIndex:, chapterOffset:)`,两个输入来源不同:
- **分子(当前位置)**:`page.contentRange.location + length`,即当前页在本章**排版后字符串**中的结束偏移——由章节运行时(交互加载/预取)的分页产出;
- **分母(全书总字符数)与本章绝对起点 `absoluteCharacterStart`**:由统计流水线产出。
两者必须在**同一坐标系**:DTCoreText 渲染后 attributedString 的字符长度。这带来一条硬约束:**精确统计不能用"HTML 去标签的文本长度"替代**(坐标系与分子不一致);近似值只允许用于分母侧的估计(见阶段 4),分子永远来自真实分页。
### 2.2 预加载不可被统计扫描替代
两边虽然做同样的渲染动作,产物和时限完全不同:
- 预加载的产物是**驻留内存的可绘制对象**(`RDEPUBRuntimeChapter`:排版串 + layouter + 分页),翻页瞬间直接取用;统计的产物只是一个数字,昂贵的排版结果随 autoreleasepool 丢弃。
- summary 缓存命中**仍需一次完整 DTCoreText 渲染**才能显示(`buildChapterFromCachedPageRanges` 只免分页、不免渲染)。真正保证翻页零等待的是 prefetch 把 RuntimeChapter 提前放进内存,这一角色无可替代。
- 预加载是**带截止时间的需求预测**(用户 30 秒后会翻到下一章);统计是无截止时间的全量扫描。
因此去重方向是**统计不再渲染**(消费预加载产物 + 补渲染远端章节),而不是砍掉预加载。
### 2.3 结构性瓶颈:分母需要全书每章齐全
百分比的分母是全书字符总和,**一章不齐数字就出不来**——"footer 从'计算中'变成百分比"的时刻恒等于全书扫描完成的时刻,与谁来渲染、开几个线程无关(提速只能压缩这个时刻,不能消除)。要突破这个语义,唯一路径是近似分母先行(阶段 4)。
---
## 3. 优化目标
1. **全书每章最多渲染一次**(同一 renderSignature 下)。
2. **交互(翻页/跳转)绝对优先**:任何后台渲染在交互加载进行期间让路,让路机制为事件驱动而非时间窗轮询。
3. **阅读百分比首次可用时间压到亚秒级**(近似值),精确值完成时间显著优于现状(消除重复渲染后后台总量减半)。
4. 首屏时间不劣化,目标改善(去掉首屏路径上的非必要串行工作)。
---
## 4. 方案总览
将"三条流水线"收敛为"**一条交互流水线 + 一条后台生产流水线**":
```
交互流水线(不变,最高优先级,不受后台预算约束)
RDEPUBChapterLoader / chapterLoadQueue (userInitiated)
navigation / preview / prefetch
渲染完成 → RuntimeChapter + Summary 写盘
└──(新增)characterCount 上报统计聚合器
后台生产流水线(由统计流水线改造而来)
QoS .utility / 并发自适应(1~预算上限) / 章间事件驱动让路
排序:距当前阅读位置由近到远(复用 backgroundPriorityManager)
每章:Summary 缓存命中 → 直接取字符数(零渲染)
miss → 渲染 + 分页 → 写 Summary + 上报字符数
产出:① 全书 PageMap 增量收敛(总页数越来越准)
② 全书字符统计(阅读百分比,近似值先行、精确值收敛)
共享组件
交互加载门(5.6):navigation/preview 加载期间后台全体暂停
全局后台渲染预算池(5.7):所有后台渲染共享并发上限
后台渲染登记表(5.8):后台流水线间的飞行中去重
```
核心思想:**统计流水线不再是"为了一个数字渲染全书"的消费者,而是升级为架构中缺失的"全书 summary 生产者"**——一次渲染两个产品(翻页要分页结果,统计要字符数)。chapterLoader 后续翻页大概率命中 summary 缓存,翻页反而更快。
`MetadataParseWorker` 的窗口覆盖职责与新生产者重叠,分两步处理(见阶段 2):先治理(接入预算池与让路门 + init 出主线程),后评估合并移除。
---
## 5. 分阶段实施
### 5.1 阶段 1:统计流水线 → 全书章节索引生产者(核心)
改造 `RDEPUBTextStatisticsCoordinator` / `RDEPUBTextStatisticsWorker`(建议更名为 `RDEPUBBookIndexingCoordinator` / `Worker`,体现双职责):
1. **读 summary 缓存优先**:每章先查 `RDEPUBChapterSummaryDiskCache`(key 一致)。命中时字符数 = 末段 pageRange 的 `location + length`,零渲染。不新增 summary 字段、不动 schemaVersion。**边界兜底**:该等式依赖"分页覆盖全文";读取时校验 pageRanges 非空、末段 end 单调且 `pageCount == pageRanges.count`(`sanitizedPageRanges` 的存在说明缓存出现过脏 range),校验不过视为 miss 重渲染,并加 debug 断言。
2. **miss 时渲染一次、产出两份**:渲染(传入真实 `pageSize`,当前统计传 nil)+ 分页,写 `RDEPUBChapterSummary`(复用 `RDEPUBChapterLoader.makeSummary` 的构造逻辑,抽成共享工具),同时上报字符数。CFI map 沿用"延迟构建"策略,不在生产者内做。
3. **渲染顺序按阅读位置**:废弃 spine 顺序,使用 `backgroundPriorityManager.makeMetadataPriorityOrder`(现成:当前章优先、warm anchor 次之、cold 游标兜底)。
4. **章间让路 + 飞行中去重**:每章开始前先过交互加载门(5.6),再向后台渲染登记表(5.8)登记本章;登记失败(他人在渲染)则跳过取下一章。取消(cancellationController)可随时打断等待。
5. **PageMap 增量喂给主线程**:每产出 N 章(复用 `pageMapRefreshInterval = 32` 或取更小值)将累计 summary 合并为 partial map,走现有 `refreshBookPageMapInPlace` 路径。
6. **接收交互流水线的产出**:`RDEPUBChapterLoader` 渲染完成回调中把 `(spineIndex, characterCount)` 推给统计聚合器——用户翻页产生的渲染同样贡献统计,生产者跳过这些章;用户读得越活跃,统计越快。
7. **启动时机后移**:从 `paginatePublication` 开头移到首屏 `applyBookPageMap` 完成之后(与 prefetch、metadata worker 同一批,但受让路门约束)。
8. **移除 `RDEPUBTextStatisticsDiskCache`**:summary 缓存成为唯一数据源。发布前保留一个版本的只读兼容(读旧统计缓存作为 fallback)可选,建议直接移除以降低复杂度。
聚合器在阶段 1 保持"凑齐才发布"语义(与现状等价,footer 显示"计算中");增量/近似发布在阶段 4 引入。
### 5.2 阶段 2:MetadataParseWorker 治理(先治理,后合并)
**2a. 立即治理(低风险)**
1. 并发接入全局预算池(5.7),不再自行按全核计算;`RDEPUBReaderConfiguration.metadataParsingConcurrency` 语义改为"预算上限的用户侧覆盖",默认跟随预算池。
2. init 中的 `contentHashBySpineIndex` 全量读文件 + sha256 移出主线程:移入 `start()` 的后台块,或 init 改为在调用方后台线程构造。
3. 章间让路 + 飞行中去重:每个 chapter operation 执行块开头接入交互加载门(5.6)与后台渲染登记表(5.8),替代现有"仅启动时等 0.8 秒"的一次性冷却。共存期(阶段 2b 之前)生产者与 metadata worker 的目标章节高度重叠(都从 anchor 附近开始),登记表是保证"每章只渲染一次"的必要条件,不可省略。
**2b. 后续合并(独立评审)**
阶段 1 落地后,`MetadataParseWorker` 的价值收缩为"打开时快速凑齐 anchor 窗口的 PageMap"。评估将其移除,窗口覆盖由生产者的优先级排序天然承担(anchor 窗口排最前)。涉及 `backgroundCoverageStore`、`restoreBookPageMapIfPossible`、retry 逻辑的迁移,单独出 RFC,不与阶段 1 捆绑。
### 5.3 阶段 3:首屏路径优化(独立可做)
1. `loadInitialInteractiveRuntimeChapters` 中相邻章的同步补载改为异步:首屏只等 anchor 章;anchor 章不足 2 页时先出首屏,相邻章以 `.preview` 优先级异步补,完成后走现有 `queueForwardAppendedPageMap` 扩展。
2. `paginateTextPublication` 的编排队列由 `.utility` 提升为 `.userInitiated`(首屏关键路径)。
**有意的 trade-off(需评审确认)**:现状同步补载的目的正是保证首屏出来时至少 2 页可翻。改异步后存在一个短暂窗口——单页首章 + 用户立刻翻页 → 命中 loading 页。缓解:补载走 `.preview` 优先级(userInitiated 队列,通常数百毫秒内就绪)、loading 页 footer 已有"计算中"占位处理;换来的是所有多页首章场景的首屏时间稳定改善。若评审认为该窗口不可接受,退化方案为"仅当 anchor 章 ≥ 2 页时走纯异步"。
### 5.4 阶段 4:两阶段近似百分比(正式纳入)
解决 2.3 的结构性瓶颈:footer 首个百分比时刻从"全书渲染扫完"提前到打开书后亚秒级。
1. **快速近似扫描**:打开书后在后台对全书每章做"HTML 去标签纯文本长度"统计(轻量 XML/正则 strip,不走渲染管线),全书量级为几十~几百毫秒,产出近似分母与近似 `absoluteCharacterStart`
2. **混合分母收敛**:聚合器为每章维护 `精确值 | 近似值` 标记;分母 = 精确值之和 + 未精确章节的近似值之和。精确值(来自 summary 命中、交互渲染上报、生产者渲染)逐章替换近似值,全部精确后与阶段 1 语义完全一致。
3. **坐标系约束(见 2.1)**:近似值只进入分母与前序章节的起点估计;分子 `chapterOffset` 永远来自当前章真实分页(当前章正在显示,必然已渲染,其精确字符数同时可用)。误差来源仅剩前序章节近似与总量近似,典型偏差 <1% 且随阅读单调收敛(误差分级标准见第 8-3 )。
4. **UI 语义**:近似分母就绪即显示百分比,不再显示"计算中"(近似/精确不做视觉区分);"计算中"仅保留给近似扫描也未完成的极短窗口与 PageMap 未覆盖的 loading
5. **实现要点**:`RDEPUBBookTextStatistics` 由一次性不可变构建改为支持逐章替换的聚合模型(内部仍可每次替换后重建不可变快照发布,避免并发可变状态);主线程刷新节流见 5.5-4
6. **持久化**:近似值不落盘(计算便宜,冷启动重算);summary/精确值沿用磁盘缓存
### 5.5 阶段 5:自适应并发渲染(默认启用)
前提:阶段 1(去重)已落地——否则多线程只是把重复劳动乘以 N
1. **并发预算**:来自全局预算池(5.7),上限 `min(3, max(1, activeProcessorCount - 2))`
2. **自适应降速**("取下一章"调度点做准入,不动态改 OperationQueue 配置):
- 活跃翻页期(`secondsSinceLastUserNavigation()` 小于阈值,建议 2 , LocationCoordinator idle 判定一致)→ 收缩为 1;
- `ProcessInfo.thermalState >= .serious` 或低电量模式 收缩为 1;
- 收到内存警告 收缩为 1,并配合现有 evict;
- 静止阅读/空闲 放开至预算上限
3. **QoS 保持 `.utility`**(P+E 核架构下低 QoS 线程主要落能效核,与主线程/chapterLoadQueue 的性能核天然隔离;不选 `.background` 以免 I/O 被系统节流拖长扫描)。
4. **UI 刷新节流**:多线程下章节乱序完成(聚合已是加锁模型,天然支持);主线程刷新(footer/`reloadPageCountOnly`)合并为"每完成 8~16 章或每 0.5 "一次,禁止每章一刷
5. **收益预期**:DTCoreText 存在 libxml/字体缓存等进程级锁,3 线程实际加速 2.2~2.5 ,继续加线程边际收益趋零——这是预算上限取 3 的依据之一
### 5.6 共享组件:交互加载门
新增 `RDEPUBInteractiveLoadGate`(NSCondition 封装),所有后台渲染流水线共用:
- **raise**:`RDEPUBChapterLoader` navigation / preview 优先级的 pending load 注册时计数 +1(prefetch 不计,prefetch 本身就是后台工作)。
- **lower**:对应 load resolve(成功/失败)时计数 -1,归零时 broadcast 唤醒全部等待者
- **wait**:后台流水线章间调用 `waitUntilClear(cancellation:)`;cancellation 触发时立即返回
- 只在章间边界等待,不打断进行中的单章渲染(单章通常数百毫秒,可接受;避免渲染中途状态管理复杂度)。多线程下语义不变:翻页瞬间后台从 N 并发降到 0,翻完集体恢复
该门是此前已移除的"0.8 秒翻页时间窗轮询"的正确形态:无时间参数无轮询翻页结束即恢复
### 5.7 共享组件:全局后台渲染预算池
**动机**:现状各流水线各自决定并发(metadata worker 按全核统计 1chapterLoadQueue 1),局部合理总和超载并发决策必须收敛到一处
- **形式**:计数信号量,`后台渲染总并发 = min(3, max(1, activeProcessorCount - 2))`。所有后台流水线(生产者metadata worker)每次章节渲染前取票完成归还;**交互流水线(navigation/preview)不受预算约束,永远直接执行**。
- **为什么留 2 核而不是 1 **:翻页关键路径上同时有两条热线程——主线程(手势/转场/当前页绘制) `chapterLoadQueue`(未缓存章节的那次渲染,主线程可能正同步等它,其完成时间即翻页延迟)。只留 1 核时两者在最坏时刻互挤,卡顿复现
- **"留核"的真实含义**:iOS 无核绑定 API,可控的只有"后台线程数 × QoS";预算池控制线程数,QoS 控制调度倾向,内存带宽与进程级锁的共享是并发上限取 3 的另一依据
- 与交互加载门正交:门管"交互期间全体暂停",池管"平时最多同时渲染几章"。
### 5.8 共享组件:后台渲染登记表(飞行中去重)
summary 缓存只能去重"已完成"的渲染;同一章**同时**在两条后台流水线中飞行时(谁都还没写盘),缓存查询拦不住双渲染共存期(阶段 2b )生产者与 metadata worker 的目标章节高度重叠,该场景必然发生,不是边角情况
- **形式**:全局 `Set<spineIndex>` + (参照现有 `beginPendingChapterLoad` 模式), renderSignature 命名空间隔离
- **规则**:后台流水线(生产者metadata worker)取章渲染前 `begin(spineIndex:)` 登记,失败(他人在渲染)即跳过取下一章;完成/失败/取消时注销
- **交互流水线不受约束**:navigation/preview 加载不查表不等表—— userInitiated 的翻页渲染去等 utility 的后台渲染是优先级反转交互与后台撞车导致的偶发双渲染予以接受, `duplicateRender` 日志计数(纳入第 8-1 节验证)。
- 生产者取章时同时检查 `store.chapterData` / `hasPendingChapterLoad`,已被交互体系覆盖的章节直接等其上报,不自行渲染
---
## 6. 性能预期(按场景)
| 场景 | 现状 | 方案后 | 依据 |
|------|------|--------|------|
| 冷启动大书,footer 首个百分比 | 全书渲染扫完(几十秒~分钟级) | **亚秒级**(近似值),精确值后台收敛 | 阶段 4 |
| 冷启动大书,精确统计完成时间 | 单线程 × metadata worker 全程抢核 | 持平或更快:去重(窗口章节免渲染)+ 无重复流水线竞争 + 空闲期 2~3 并发;单章成本 + 30%(增加分页)被上述收益覆盖 | 阶段 1 + 5 |
| 二次打开 / 读过一部分 | 统计缓存独立,预加载渲染过的章节仍是 miss,需重渲染 | **快一个量级**:读过/预加载过的章节直接读 summary(毫秒级/) | 阶段 1 |
| 统计期间翻页 | 主线程同步渲染被后台放大 2~3 | 后台交互期间为 0 并发(让路门),平时不超预算;翻页目标章大概率 summary 命中(免分页) | 5.6 + 5.7 |
| 让路对统计的代价 | | 总完成时间仅增加"用户实际交互占用的时长",无空转等待 | 5.6 |
---
## 7. 风险与对策
| 风险 | 对策 |
|------|------|
| 布局变化(转屏字号)导致 renderSignature 失效,生产者产出作废 | 沿用现有 token + cancellationController 机制:设置变更时 cancel 并以新 snapshot 重启;summary 缓存按 signature 命名空间隔离,旧产出不污染近似分母与 renderSignature 无关,无需重算 |
| 生产者渲染中用户跳转,让路门只拦章间 | 单章渲染粒度可接受;跳转走 navigation 优先级,chapterLoadQueue(userInitiated)调度上压过生产者(utility),且交互不受预算池约束 |
| 让路门计数泄漏导致后台永久挂起 | raise/lower 严格配对在 `registerPendingLoad` / `resolvePendingLoad`(已有单点出入口);增加 debug 断言 + 兜底超时日志(不自动放行,只报警) |
| 预算池取票/归还不配对导致后台饿死 | 取票/归还封装为 scoped helper(defer 归还);debug 断言校验余额 |
| 生产者写 summary 覆盖 loader 已写入的更完整 summary(loader 侧经延迟构建后** cfiMap**,生产者侧不含,后写会把 cfiMap 抹掉导致重复构建) | 生产者写盘前 re-check:该 key 已存在 summary 即跳过写入(只取字符数);写路径本身沿用 `RDEPUBChapterSummaryDiskCache` 串行队列 + 原子替换配合 5.8 登记表,正常情况下不会走到同章双写 |
| 近似百分比与精确值偏差被用户感知 | 近似仅用于分母(2.1 坐标系约束),偏差单调收敛;分级标准与抽样验证见第 8-3 |
| PageMap 增量刷新引起当前页码跳动 | 复用现有 `refreshBookPageMapInPlace` / reconciliation 路径,不新增刷新语义;刷新间隔沿用 32 |
| 多线程渲染内存峰值( worker 持有整章排版串 + DTCoreText 中间 DOM) | 每章 `autoreleasepool`;不持有 RuntimeChapter,写盘后即释放;内存警告时并发收缩为 1;3 并发峰值约为单线程 3 ,预算上限已封顶 |
| 多线程撞 DTCoreText/CoreText 进程级锁,加速比不达预期 | 预期管理:2.2~2.5 倍为目标;若实测低于 1.5 ,回退默认并发 1(自适应策略保留,阈值可配) |
---
## 8. 验收与度量
上线前后对比(同一测试书目,冷缓存 / 热缓存各一组):
1. **每章渲染次数 ≤ 1**:扩展 `[TextStatistics]` 日志为来源分布 `summaryCacheHit / rendered / fromInteractiveLoad / approximated`,全书求和验证
2. **footer 首个百分比出现时间**:目标亚秒级(近似值);同时记录精确值收敛完成时间
3. **近似误差(统一分级标准)**:样本书目(不同语言图文比例章节数量级)上对比近似百分比与最终精确百分比——典型值预期 <1%;**验收线为偏差分布 P95 < 1.5%**;任一单本书 > 3% 触发个案分析(定位该书目类型的系统性偏差来源,如实体密集、大量脚注)。
4. **翻页流畅度**:`loadChapterSynchronouslyForMigration` 耗时日志 P50/P95;统计计算期间连续翻页 20 次的主线程 hang 时长(Instruments Hangs)。
5. **统计完成时间**:`lastTextStatisticsWallClockMs`,目标冷缓存下降 ≥ 40%(消除重复渲染 + CPU 竞争;空闲期自适应并发另计)。
6. **多线程加速比**:空闲场景 1 / 2 / 3 并发的完成时间对比,验证 ≥ 2 倍再默认放开;同时记录热状态触发降档的频率。
7. **首屏时间**(阶段 3):打开到首帧渲染完成,目标不劣化、多页首章场景改善。
8. **PageMap 收敛速度**:总页数从"计算中"到稳定值的耗时,目标不劣于现状(生产者兼职覆盖全书,应优于现状)。
---
## 9. 实施顺序建议
1. **交互加载门(5.6)+ 预算池(5.7)+ 登记表(5.8)+ 阶段 2a(治理 metadata worker)**——小改动,立即缓解翻页卡顿。
2. **阶段 1(生产者化)**——核心重构,消除重复渲染。
3. **阶段 4(两阶段近似百分比)**——直接兑现"首个百分比亚秒级"的产品收益;依赖阶段 1 的聚合器改造,不依赖阶段 5。
4. **阶段 3(首屏)**——独立优化,可与 3 并行。
5. **阶段 5(自适应并发,默认启用)**——依赖阶段 1 去重与 5.7 预算池;按第 8-6 节实测加速比决定是否默认放开到 3。
6. **阶段 2b(合并 metadata worker)**——后续 RFC。