- 新增 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>
265 lines
24 KiB
Markdown
265 lines
24 KiB
Markdown
# 阅读进度统计与后台渲染流水线优化方案
|
||
|
||
> 创建: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 按全核、统计 1、chapterLoadQueue 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。
|