新增全书字符统计与阅读百分比,并沉淀后台渲染流水线优化方案
- 新增 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>
This commit is contained in:
parent
71f4feb12f
commit
8b68975ee1
264
Doc/FeatureSolution/TEXT_STATISTICS_PIPELINE_OPTIMIZATION.md
Normal file
264
Doc/FeatureSolution/TEXT_STATISTICS_PIPELINE_OPTIMIZATION.md
Normal file
@ -0,0 +1,264 @@
|
||||
# 阅读进度统计与后台渲染流水线优化方案
|
||||
|
||||
> 创建: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。
|
||||
@ -42,6 +42,7 @@
|
||||
| [大书远距目录跳转与后台补全优化方案.md](大书远距目录跳转与后台补全优化方案.md) | 面向大书按需分页模式的完整优化方案:远距目录跳转、后台补全优先级、页图接管协议与分阶段实施路径 |
|
||||
| [标准级EPUB定位与兼容能力开发蓝图.md](标准级EPUB定位与兼容能力开发蓝图.md) | 面向商业级 EPUB 阅读器的可执行蓝图:完整版 CFI、无 id 节点双向恢复、脚注弹层、字体回退与 CSS 兼容层 |
|
||||
| [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s |
|
||||
| [FeatureSolution/TEXT_STATISTICS_PIPELINE_OPTIMIZATION.md](FeatureSolution/TEXT_STATISTICS_PIPELINE_OPTIMIZATION.md) | 阅读进度统计与后台渲染流水线优化方案:三条渲染流水线收敛为"交互 + 生产者"两条、交互加载门 + 全局后台渲染预算池、两阶段近似百分比、自适应并发、分阶段实施与验收度量 |
|
||||
|
||||
## 项目信息
|
||||
|
||||
|
||||
4
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
4
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
@ -204,6 +204,7 @@
|
||||
6FA0125F16DDEDAD341DB75DEB084F4B /* DTHTMLWriter.m in Sources */ = {isa = PBXBuildFile; fileRef = B23C5F038DAC52B75213C0760C431F2B /* DTHTMLWriter.m */; };
|
||||
7027EF8D77D4082C1BB9B80FF61FCADC /* DTCoreTextLayoutFrame+Cursor.h in Headers */ = {isa = PBXBuildFile; fileRef = 214F0FC87DE99D3FCE69D98B357F3FFC /* DTCoreTextLayoutFrame+Cursor.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
709C4BA2B6C35035434CD7A41C050A9A /* RDEPUBReaderRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 692FB048CB12876AA971EF21B3F71A27 /* RDEPUBReaderRuntime.swift */; };
|
||||
70A1B2C3D4E5F60718293A4B /* RDEPUBTextStatisticsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70B1C2D3E4F5061728394A5B /* RDEPUBTextStatisticsCoordinator.swift */; };
|
||||
70AF1621102FFF12F7DA88DDD8254054 /* RDReaderPagingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E246B080707A0DA836FC0890AE96D529 /* RDReaderPagingController.swift */; };
|
||||
72E1E0B3B3A46A92E75CA522B9FCB7B6 /* RDEPUBBackgroundTrace.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCB06D5D93327CF4E70EB026DFEFE8BE /* RDEPUBBackgroundTrace.swift */; };
|
||||
730ACB99580C5982DC08D50FC5084D41 /* DTAttributedTextView.h in Headers */ = {isa = PBXBuildFile; fileRef = B4D20A2B33F9F4DD8499FB462DE2731F /* DTAttributedTextView.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
@ -698,6 +699,7 @@
|
||||
68D81DF1BB2A14F9C186F75BBA5CF50D /* DTColor+Compatibility.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "DTColor+Compatibility.h"; path = "Core/Source/DTColor+Compatibility.h"; sourceTree = "<group>"; };
|
||||
69076FC9D4201719C319547346DA3E21 /* DTTextHTMLElement.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTTextHTMLElement.m; path = Core/Source/DTTextHTMLElement.m; sourceTree = "<group>"; };
|
||||
692FB048CB12876AA971EF21B3F71A27 /* RDEPUBReaderRuntime.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBReaderRuntime.swift; sourceTree = "<group>"; };
|
||||
70B1C2D3E4F5061728394A5B /* RDEPUBTextStatisticsCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextStatisticsCoordinator.swift; sourceTree = "<group>"; };
|
||||
6AC1E707308CDBC9FFC09CECE3117A9A /* DTAccessibilityViewProxy.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = DTAccessibilityViewProxy.m; path = Core/Source/DTAccessibilityViewProxy.m; sourceTree = "<group>"; };
|
||||
6C3F8DF48C6EB28BFD05F5BB49793DDA /* FileManager+ZIP.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "FileManager+ZIP.swift"; path = "Sources/ZIPFoundation/FileManager+ZIP.swift"; sourceTree = "<group>"; };
|
||||
6C6DCF8FFFAD317C3CA1AEFF60080707 /* RDEPUBTextLayouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBTextLayouter.swift; sourceTree = "<group>"; };
|
||||
@ -1798,6 +1800,7 @@
|
||||
94F5CBFE8EF3307F3C9B0E25A5F70E70 /* RDEPUBReaderUIState.swift */,
|
||||
E8D1DCAA8AAE79FCBD5248D641D583DE /* RDEPUBReaderViewportMonitor.swift */,
|
||||
3BE6C7E2CF696021A9922C1BB675AB1C /* RDEPUBSelectionState.swift */,
|
||||
70B1C2D3E4F5061728394A5B /* RDEPUBTextStatisticsCoordinator.swift */,
|
||||
0C5B69B2CD80274C6E29E5EA8098B935 /* ChapterRuntime */,
|
||||
);
|
||||
name = ReaderController;
|
||||
@ -2439,6 +2442,7 @@
|
||||
02A298783B59EBF1DA7E6734E86384BC /* RDEPUBReaderSettings.swift in Sources */,
|
||||
2925F96E721F741F60C0D2B5AECDBED3 /* RDEPUBReaderSettingsViewController.swift in Sources */,
|
||||
EC832377DBB2E3443B37165DDDD9F9D6 /* RDEPUBReaderState.swift in Sources */,
|
||||
70A1B2C3D4E5F60718293A4B /* RDEPUBTextStatisticsCoordinator.swift in Sources */,
|
||||
0A5BECD47E7FF9EA5C92717197F50DD0 /* RDEPUBReaderTableOfContentsItem.swift in Sources */,
|
||||
4419BDA1F006E749CA40A99C1E6CEDFF /* RDEPUBReaderTheme.swift in Sources */,
|
||||
5CE8BD1A9C7FE28802CD9409E4282D5F /* RDEPUBReaderToolView.swift in Sources */,
|
||||
|
||||
@ -64,7 +64,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
||||
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
||||
highlights: textHighlights(for: resolvedPage.page),
|
||||
searchState: searchState(for: resolvedPage.page)
|
||||
searchState: searchState(for: resolvedPage.page),
|
||||
footerText: textProgressFooterText(for: resolvedPage.page)
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
@ -83,7 +84,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
contentView.configureLoading(
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||
configuration: configuration
|
||||
configuration: configuration,
|
||||
footerText: shouldShowTextStatisticsPlaceholder ? "计算中" : nil
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
@ -108,7 +110,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
|
||||
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
|
||||
highlights: textHighlights(for: page),
|
||||
searchState: searchState(for: page)
|
||||
searchState: searchState(for: page),
|
||||
footerText: textProgressFooterText(for: page)
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
@ -136,6 +139,27 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
: NSStringFromClass(RDEPUBTextContentView.self)
|
||||
}
|
||||
|
||||
private func textProgressFooterText(for page: RDEPUBTextPage) -> String? {
|
||||
guard let statistics = readerContext.textStatistics else {
|
||||
return shouldShowTextStatisticsPlaceholder ? "计算中" : nil
|
||||
}
|
||||
guard let percent = statistics.percent(
|
||||
spineIndex: page.spineIndex,
|
||||
chapterOffset: page.contentRange.location + page.contentRange.length
|
||||
) else {
|
||||
return "计算中"
|
||||
}
|
||||
return String(format: "%.2f%%", percent)
|
||||
}
|
||||
|
||||
private var shouldShowTextStatisticsPlaceholder: Bool {
|
||||
guard !readerContext.isExternalTextBook,
|
||||
let publication = readerContext.publication else {
|
||||
return false
|
||||
}
|
||||
return publication.readingProfile == .textReflowable
|
||||
}
|
||||
|
||||
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(for: page.href) {
|
||||
|
||||
@ -357,6 +357,7 @@ public final class RDURLReaderController: UIViewController {
|
||||
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)",
|
||||
"windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)",
|
||||
"parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)",
|
||||
"textStatsMs=\(readerController?.readerContext.lastTextStatisticsWallClockMs ?? 0)",
|
||||
"parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)",
|
||||
"inspectable=\(inspectable ? 1 : 0)",
|
||||
"streamedResources=\(resourceMetrics.streamedResponses)",
|
||||
|
||||
@ -61,7 +61,8 @@ final class RDEPUBMetadataParseWorker {
|
||||
cancellationController: RDEPUBMetadataParseCancellationController,
|
||||
token: UUID,
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication
|
||||
publication: RDEPUBPublication,
|
||||
targetSpineIndices: [Int]? = nil
|
||||
) {
|
||||
self.context = context
|
||||
self.cancellationController = cancellationController
|
||||
@ -74,11 +75,19 @@ final class RDEPUBMetadataParseWorker {
|
||||
self.layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
self.style = context.currentTextRenderStyle()
|
||||
self.renderSignature = context.currentRenderSignature()
|
||||
self.allBuildableIndices = publication.spine.indices.filter { index in
|
||||
let buildableIndices = publication.spine.indices.filter { index in
|
||||
guard publication.spine.indices.contains(index) else { return false }
|
||||
let item = publication.spine[index]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
if let targetSpineIndices, !targetSpineIndices.isEmpty {
|
||||
let buildableSet = Set(buildableIndices)
|
||||
self.allBuildableIndices = Self.uniquedPreservingOrder(
|
||||
targetSpineIndices.filter { buildableSet.contains($0) }
|
||||
)
|
||||
} else {
|
||||
self.allBuildableIndices = buildableIndices
|
||||
}
|
||||
self.summaryDiskCache = context.runtime?.summaryDiskCache
|
||||
self.workerCount = max(1, context.configuration.metadataParsingConcurrency)
|
||||
self.pageMapRefreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
|
||||
@ -523,4 +532,13 @@ final class RDEPUBMetadataParseWorker {
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private static func uniquedPreservingOrder(_ values: [Int]) -> [Int] {
|
||||
var seen: Set<Int> = []
|
||||
var result: [Int] = []
|
||||
for value in values where seen.insert(value).inserted {
|
||||
result.append(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,6 +164,7 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
lastBuildableSpineIndex: Int
|
||||
) -> RDEPUBPageMapTakeoverDecision {
|
||||
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
|
||||
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
|
||||
let currentEntries = currentWindow.entries.count
|
||||
|
||||
if let currentSpineIndex {
|
||||
@ -189,8 +190,8 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
let isComplete = candidateIndices.count >= currentEntries
|
||||
if isComplete {
|
||||
let coversCurrentWindow = currentIndices.isSubset(of: candidateIndices)
|
||||
if coversCurrentWindow {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"evaluateFullPageMapTakeover: fullReplace — candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries) candidatePages=\(candidatePageMap.totalPages) currentPages=\(currentWindow.totalPages)"
|
||||
@ -198,6 +199,10 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
return .fullReplace(candidatePageMap)
|
||||
}
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Reconciliation",
|
||||
"evaluateFullPageMapTakeover: keepCurrentWindow — candidate does not cover currentWindow candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries)"
|
||||
)
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
|
||||
@ -222,4 +227,4 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
|
||||
return indices
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,6 +62,11 @@ final class RDEPUBReaderContext {
|
||||
set { state.bookPageMap = newValue }
|
||||
}
|
||||
|
||||
var textStatistics: RDEPUBBookTextStatistics? {
|
||||
get { state.textStatistics }
|
||||
set { state.textStatistics = newValue }
|
||||
}
|
||||
|
||||
var activeBookmarks: [RDEPUBBookmark] {
|
||||
get { state.activeBookmarks }
|
||||
set { state.activeBookmarks = newValue }
|
||||
@ -82,6 +87,11 @@ final class RDEPUBReaderContext {
|
||||
set { state.paginationToken = newValue }
|
||||
}
|
||||
|
||||
var textStatisticsToken: UUID {
|
||||
get { state.textStatisticsToken }
|
||||
set { state.textStatisticsToken = newValue }
|
||||
}
|
||||
|
||||
var searchState: RDEPUBSearchState? {
|
||||
get { state.searchState }
|
||||
set { state.searchState = newValue }
|
||||
@ -107,6 +117,11 @@ final class RDEPUBReaderContext {
|
||||
set { state.lastMetadataParseConcurrency = newValue }
|
||||
}
|
||||
|
||||
var lastTextStatisticsWallClockMs: Int {
|
||||
get { state.lastTextStatisticsWallClockMs }
|
||||
set { state.lastTextStatisticsWallClockMs = newValue }
|
||||
}
|
||||
|
||||
var currentSelection: RDEPUBSelection? {
|
||||
get { state.currentSelection }
|
||||
set { state.currentSelection = newValue }
|
||||
|
||||
@ -20,7 +20,7 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
}
|
||||
|
||||
func loadPublication() {
|
||||
guard let context, let controller = context.controller else { return }
|
||||
guard let context else { return }
|
||||
context.showLoading()
|
||||
let loadToken = UUID()
|
||||
context.paginationToken = loadToken
|
||||
|
||||
@ -29,6 +29,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
context.paginationToken = token
|
||||
|
||||
if publication.readingProfile == .textReflowable {
|
||||
context.runtime?.startTextStatistics(parser: parser, publication: publication)
|
||||
paginateTextPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
@ -39,6 +40,8 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
context.runtime?.cancelTextStatistics()
|
||||
|
||||
if publication.layout == .fixed {
|
||||
let snapshot = readingSession.makePaginationSnapshot(
|
||||
pageCounts: Array(repeating: 1, count: publication.spine.count),
|
||||
@ -213,12 +216,18 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
let cancellationController = self.beginMetadataParseCancellationController(for: token)
|
||||
let metadataCoverageSpineIndices = self.metadataCoverageSpineIndices(
|
||||
anchorSpineIndex: runtimeChapter.spineIndex,
|
||||
publication: publication,
|
||||
initialChapters: initialChapters
|
||||
)
|
||||
let worker = RDEPUBMetadataParseWorker(
|
||||
context: context,
|
||||
cancellationController: cancellationController,
|
||||
token: token,
|
||||
parser: parser,
|
||||
publication: publication
|
||||
publication: publication,
|
||||
targetSpineIndices: metadataCoverageSpineIndices
|
||||
)
|
||||
worker.start(token: token, restoreLocation: restoreLocation)
|
||||
}
|
||||
@ -329,6 +338,35 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
private func metadataCoverageSpineIndices(
|
||||
anchorSpineIndex: Int,
|
||||
publication: RDEPUBPublication,
|
||||
initialChapters: [RDEPUBRuntimeChapter]
|
||||
) -> [Int] {
|
||||
let buildableSpineIndices = allBuildableSpineIndices(in: publication)
|
||||
guard let anchorPosition = buildableSpineIndices.firstIndex(of: anchorSpineIndex) else {
|
||||
return initialChapters.map(\.spineIndex)
|
||||
}
|
||||
|
||||
let windowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize(
|
||||
context.configuration.onDemandChapterWindowSize
|
||||
)
|
||||
let radius = windowSize / 2
|
||||
let lowerPosition = max(anchorPosition - radius, 0)
|
||||
let upperPosition = min(anchorPosition + radius, buildableSpineIndices.count - 1)
|
||||
let windowSpineIndices = Array(buildableSpineIndices[lowerPosition...upperPosition])
|
||||
return uniquedPreservingOrder(initialChapters.map(\.spineIndex) + windowSpineIndices)
|
||||
}
|
||||
|
||||
private func uniquedPreservingOrder(_ values: [Int]) -> [Int] {
|
||||
var seen: Set<Int> = []
|
||||
var result: [Int] = []
|
||||
for value in values where seen.insert(value).inserted {
|
||||
result.append(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
|
||||
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||||
}
|
||||
|
||||
@ -23,6 +23,8 @@ final class RDEPUBReaderRuntime {
|
||||
|
||||
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
|
||||
|
||||
lazy var textStatisticsCoordinator = RDEPUBTextStatisticsCoordinator(context: context)
|
||||
|
||||
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
|
||||
|
||||
lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context)
|
||||
@ -105,6 +107,7 @@ final class RDEPUBReaderRuntime {
|
||||
context.readingSession = nil
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
textStatisticsCoordinator.cancel()
|
||||
context.pendingPageMapUpdates.removeAll()
|
||||
context.activeBookmarks = []
|
||||
context.activeHighlights = []
|
||||
@ -324,6 +327,14 @@ final class RDEPUBReaderRuntime {
|
||||
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
func startTextStatistics(parser: RDEPUBParser, publication: RDEPUBPublication) {
|
||||
textStatisticsCoordinator.start(parser: parser, publication: publication)
|
||||
}
|
||||
|
||||
func cancelTextStatistics() {
|
||||
textStatisticsCoordinator.cancel()
|
||||
}
|
||||
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
@ -361,6 +372,7 @@ final class RDEPUBReaderRuntime {
|
||||
if isSettingsPanelOpen {
|
||||
needsFullRepaginationAfterSettingsClose = true
|
||||
paginationCoordinator.cancelActiveMetadataParseWork()
|
||||
cancelTextStatistics()
|
||||
scheduleSettingsPreviewRepagination()
|
||||
} else {
|
||||
paginationCoordinator.repaginatePreservingCurrentLocation()
|
||||
|
||||
@ -16,6 +16,8 @@ final class RDEPUBReaderState {
|
||||
|
||||
var bookPageMap: RDEPUBBookPageMap?
|
||||
|
||||
var textStatistics: RDEPUBBookTextStatistics?
|
||||
|
||||
var activeBookmarks: [RDEPUBBookmark] = []
|
||||
|
||||
var activeHighlights: [RDEPUBHighlight] = []
|
||||
@ -24,6 +26,8 @@ final class RDEPUBReaderState {
|
||||
|
||||
var paginationToken = UUID()
|
||||
|
||||
var textStatisticsToken = UUID()
|
||||
|
||||
var searchState: RDEPUBSearchState?
|
||||
|
||||
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
|
||||
@ -34,6 +38,8 @@ final class RDEPUBReaderState {
|
||||
|
||||
var lastMetadataParseConcurrency: Int = 0
|
||||
|
||||
var lastTextStatisticsWallClockMs: Int = 0
|
||||
|
||||
var selectionState: RDEPUBSelectionState = .idle
|
||||
|
||||
var isRepaginating: Bool = false
|
||||
|
||||
@ -0,0 +1,431 @@
|
||||
import UIKit
|
||||
|
||||
struct RDEPUBChapterTextStatistics {
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let href: String
|
||||
|
||||
let characterCount: Int
|
||||
|
||||
let absoluteCharacterStart: Int
|
||||
}
|
||||
|
||||
struct RDEPUBBookTextStatistics {
|
||||
|
||||
let chapters: [RDEPUBChapterTextStatistics]
|
||||
|
||||
let totalCharacters: Int
|
||||
|
||||
private let indexBySpine: [Int: Int]
|
||||
|
||||
init(chapters: [RDEPUBChapterTextStatistics]) {
|
||||
self.chapters = chapters
|
||||
self.totalCharacters = chapters.reduce(0) { $0 + max($1.characterCount, 0) }
|
||||
self.indexBySpine = Dictionary(
|
||||
uniqueKeysWithValues: chapters.enumerated().map { ($0.element.spineIndex, $0.offset) }
|
||||
)
|
||||
}
|
||||
|
||||
func progress(spineIndex: Int, chapterOffset: Int) -> Double? {
|
||||
guard totalCharacters > 0,
|
||||
let chapterIndex = indexBySpine[spineIndex] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let chapter = chapters[chapterIndex]
|
||||
let safeChapterOffset = min(max(chapterOffset, 0), chapter.characterCount)
|
||||
let absoluteOffset = chapter.absoluteCharacterStart + safeChapterOffset
|
||||
return min(max(Double(absoluteOffset) / Double(totalCharacters), 0), 1)
|
||||
}
|
||||
|
||||
func percent(spineIndex: Int, chapterOffset: Int) -> Double? {
|
||||
guard let progress = progress(spineIndex: spineIndex, chapterOffset: chapterOffset) else {
|
||||
return nil
|
||||
}
|
||||
return min(max(progress * 100, 0), 100)
|
||||
}
|
||||
}
|
||||
|
||||
final class RDEPUBTextStatisticsWorker {
|
||||
|
||||
private struct ChapterCharacterCount {
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let href: String
|
||||
|
||||
let characterCount: Int
|
||||
}
|
||||
|
||||
func calculate(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
bookIdentifier: String,
|
||||
renderSignature: String,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
rendererFactory: @escaping () -> RDEPUBTextRenderer,
|
||||
diskCache: RDEPUBTextStatisticsDiskCache?,
|
||||
maxConcurrency: Int,
|
||||
cancellationController: RDEPUBTextStatisticsCancellationController
|
||||
) -> RDEPUBBookTextStatistics? {
|
||||
|
||||
let buildableItems = publication.spine.enumerated().filter { _, item in
|
||||
isBuildableTextSpine(item)
|
||||
}
|
||||
|
||||
let queue = OperationQueue()
|
||||
queue.name = "com.readview.epub.text-statistics"
|
||||
queue.qualityOfService = .utility
|
||||
queue.maxConcurrentOperationCount = max(1, maxConcurrency)
|
||||
cancellationController.attach(queue: queue)
|
||||
|
||||
let lock = NSLock()
|
||||
var counts: [ChapterCharacterCount] = []
|
||||
|
||||
for (spineIndex, item) in buildableItems {
|
||||
let operation = BlockOperation()
|
||||
operation.addExecutionBlock { [weak operation] in
|
||||
guard !cancellationController.isCancelled,
|
||||
operation?.isCancelled != true else {
|
||||
return
|
||||
}
|
||||
|
||||
let characterCount = self.characterCount(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
bookIdentifier: bookIdentifier,
|
||||
renderSignature: renderSignature,
|
||||
item: item,
|
||||
spineIndex: spineIndex,
|
||||
style: style,
|
||||
layoutConfig: layoutConfig,
|
||||
rendererFactory: rendererFactory,
|
||||
diskCache: diskCache,
|
||||
cancellationController: cancellationController
|
||||
)
|
||||
|
||||
guard !cancellationController.isCancelled,
|
||||
operation?.isCancelled != true else {
|
||||
return
|
||||
}
|
||||
|
||||
lock.lock()
|
||||
counts.append(
|
||||
ChapterCharacterCount(
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
characterCount: characterCount
|
||||
)
|
||||
)
|
||||
lock.unlock()
|
||||
}
|
||||
queue.addOperation(operation)
|
||||
}
|
||||
|
||||
queue.waitUntilAllOperationsAreFinished()
|
||||
|
||||
guard !cancellationController.isCancelled else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries: [RDEPUBChapterTextStatistics] = []
|
||||
var absoluteCharacterStart = 0
|
||||
for count in counts.sorted(by: { $0.spineIndex < $1.spineIndex }) {
|
||||
entries.append(
|
||||
RDEPUBChapterTextStatistics(
|
||||
spineIndex: count.spineIndex,
|
||||
href: count.href,
|
||||
characterCount: count.characterCount,
|
||||
absoluteCharacterStart: absoluteCharacterStart
|
||||
)
|
||||
)
|
||||
absoluteCharacterStart += max(count.characterCount, 0)
|
||||
}
|
||||
|
||||
let statistics = RDEPUBBookTextStatistics(chapters: entries)
|
||||
return statistics.totalCharacters > 0 ? statistics : nil
|
||||
}
|
||||
|
||||
private func characterCount(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
bookIdentifier: String,
|
||||
renderSignature: String,
|
||||
item: RDEPUBSpineItem,
|
||||
spineIndex: Int,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
rendererFactory: () -> RDEPUBTextRenderer,
|
||||
diskCache: RDEPUBTextStatisticsDiskCache?,
|
||||
cancellationController: RDEPUBTextStatisticsCancellationController
|
||||
) -> Int {
|
||||
autoreleasepool { () -> Int in
|
||||
guard let html = parser.htmlString(forRelativePath: item.href) else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let cacheKey = RDEPUBChapterCacheKey(
|
||||
bookID: bookIdentifier,
|
||||
spineIndex: spineIndex,
|
||||
renderSignature: renderSignature,
|
||||
chapterContentHash: html.rd_sha256Hex
|
||||
)
|
||||
if let cached = diskCache?.read(for: cacheKey) {
|
||||
return cached.characterCount
|
||||
}
|
||||
|
||||
guard !cancellationController.isCancelled else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let renderer = rendererFactory()
|
||||
let baseURL = parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent()
|
||||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||||
href: item.href,
|
||||
title: item.title,
|
||||
rawHTML: html,
|
||||
baseURL: baseURL,
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver,
|
||||
contentLanguageCode: publication.metadata.language,
|
||||
pageSize: nil,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
|
||||
do {
|
||||
let rendered = try renderer.renderChapter(request: request)
|
||||
let count = rendered.attributedString.length
|
||||
diskCache?.write(.init(characterCount: count), for: cacheKey)
|
||||
return count
|
||||
} catch {
|
||||
let fallback = RDEPUBTextRendererSupport.fallbackAttributedString(for: html, style: style)
|
||||
let count = fallback.length
|
||||
diskCache?.write(.init(characterCount: count), for: cacheKey)
|
||||
return count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isBuildableTextSpine(_ item: RDEPUBSpineItem) -> Bool {
|
||||
let mediaType = item.mediaType.lowercased()
|
||||
return item.linear && (mediaType.contains("html") || mediaType.contains("xhtml"))
|
||||
}
|
||||
}
|
||||
|
||||
struct RDEPUBTextStatisticsCacheEntry: Codable {
|
||||
|
||||
let schemaVersion: Int
|
||||
|
||||
let characterCount: Int
|
||||
|
||||
init(characterCount: Int, schemaVersion: Int = Self.currentSchemaVersion) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.characterCount = characterCount
|
||||
}
|
||||
|
||||
static let currentSchemaVersion = 1
|
||||
}
|
||||
|
||||
final class RDEPUBTextStatisticsDiskCache {
|
||||
|
||||
private let cacheDirectory: URL
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
private let queue = DispatchQueue(label: "com.rdreader.textstatisticscache", qos: .utility)
|
||||
|
||||
init(bookIdentifier: String?) {
|
||||
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let bookID = (bookIdentifier ?? "default").rd_sha256Hex
|
||||
self.cacheDirectory = cachesDirectory
|
||||
.appendingPathComponent("RDEPUBTextStatisticsCache", isDirectory: true)
|
||||
.appendingPathComponent(bookID, isDirectory: true)
|
||||
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBTextStatisticsCacheEntry? {
|
||||
let fileURL = fileURL(for: key)
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let entry = try? JSONDecoder().decode(RDEPUBTextStatisticsCacheEntry.self, from: data),
|
||||
entry.schemaVersion == RDEPUBTextStatisticsCacheEntry.currentSchemaVersion else {
|
||||
return nil
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func write(_ entry: RDEPUBTextStatisticsCacheEntry, for key: RDEPUBChapterCacheKey) {
|
||||
queue.async {
|
||||
self.writeImmediately(entry, for: key)
|
||||
}
|
||||
}
|
||||
|
||||
func flushPendingWrites() {
|
||||
queue.sync { }
|
||||
}
|
||||
|
||||
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
||||
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
|
||||
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
|
||||
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
|
||||
let digest = rawKey.rd_sha256Hex
|
||||
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
|
||||
}
|
||||
|
||||
private func writeImmediately(_ entry: RDEPUBTextStatisticsCacheEntry, for key: RDEPUBChapterCacheKey) {
|
||||
let fileURL = fileURL(for: key)
|
||||
let tmpURL = fileURL.appendingPathExtension("tmp")
|
||||
do {
|
||||
let data = try JSONEncoder().encode(entry)
|
||||
try data.write(to: tmpURL)
|
||||
if fileManager.fileExists(atPath: fileURL.path) {
|
||||
_ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
|
||||
} else {
|
||||
try fileManager.moveItem(at: tmpURL, to: fileURL)
|
||||
}
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: tmpURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func cacheNamespacePrefix(for rawValue: String) -> String {
|
||||
rawValue.rd_sha256Hex.prefix(12).lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
final class RDEPUBTextStatisticsCancellationController {
|
||||
|
||||
private let lock = NSLock()
|
||||
|
||||
private var queue: OperationQueue?
|
||||
|
||||
private var cancelled = false
|
||||
|
||||
var isCancelled: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return cancelled
|
||||
}
|
||||
|
||||
func attach(queue: OperationQueue) {
|
||||
lock.lock()
|
||||
if cancelled {
|
||||
queue.cancelAllOperations()
|
||||
} else {
|
||||
self.queue = queue
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
lock.lock()
|
||||
cancelled = true
|
||||
let queue = self.queue
|
||||
self.queue = nil
|
||||
lock.unlock()
|
||||
queue?.cancelAllOperations()
|
||||
}
|
||||
}
|
||||
|
||||
final class RDEPUBTextStatisticsCoordinator {
|
||||
|
||||
private weak var context: RDEPUBReaderContext?
|
||||
|
||||
private let worker = RDEPUBTextStatisticsWorker()
|
||||
|
||||
private let cancellationLock = NSLock()
|
||||
|
||||
private var activeCancellationController: RDEPUBTextStatisticsCancellationController?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func start(parser: RDEPUBParser, publication: RDEPUBPublication) {
|
||||
guard let context else { return }
|
||||
|
||||
guard publication.readingProfile == .textReflowable else {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
let token = UUID()
|
||||
cancelActiveWork()
|
||||
context.textStatisticsToken = token
|
||||
context.textStatistics = nil
|
||||
context.lastTextStatisticsWallClockMs = 0
|
||||
|
||||
let snapshot = context.makeLayoutSnapshot()
|
||||
let dependencies = context.dependencies
|
||||
let renderingEngine = context.configuration.textRenderingEngine
|
||||
let workerCount = 1
|
||||
let bookIdentifier = context.currentBookIdentifier ?? ""
|
||||
let diskCache = RDEPUBTextStatisticsDiskCache(bookIdentifier: context.currentBookIdentifier)
|
||||
let cancellationController = RDEPUBTextStatisticsCancellationController()
|
||||
setActiveCancellationController(cancellationController)
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self, weak context] in
|
||||
guard let self else { return }
|
||||
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
||||
let statistics = self.worker.calculate(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
bookIdentifier: bookIdentifier,
|
||||
renderSignature: snapshot.renderSignature,
|
||||
style: snapshot.style,
|
||||
layoutConfig: snapshot.layoutConfig,
|
||||
rendererFactory: { dependencies.makeTextRenderer(renderingEngine) },
|
||||
diskCache: diskCache,
|
||||
maxConcurrency: workerCount,
|
||||
cancellationController: cancellationController
|
||||
)
|
||||
diskCache.flushPendingWrites()
|
||||
let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard let context,
|
||||
context.textStatisticsToken == token,
|
||||
!cancellationController.isCancelled else {
|
||||
return
|
||||
}
|
||||
self.finishActiveCancellationController(cancellationController)
|
||||
context.textStatistics = statistics
|
||||
context.lastTextStatisticsWallClockMs = wallClockMs
|
||||
print("[TextStatistics] chapters=\(statistics?.chapters.count ?? 0) totalCharacters=\(statistics?.totalCharacters ?? 0) concurrency=\(workerCount) elapsed=\(wallClockMs)ms")
|
||||
context.readerView?.reloadPageCountOnly()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
guard let context else { return }
|
||||
cancelActiveWork()
|
||||
context.textStatisticsToken = UUID()
|
||||
context.textStatistics = nil
|
||||
context.lastTextStatisticsWallClockMs = 0
|
||||
}
|
||||
|
||||
private func cancelActiveWork() {
|
||||
cancellationLock.lock()
|
||||
let controller = activeCancellationController
|
||||
activeCancellationController = nil
|
||||
cancellationLock.unlock()
|
||||
controller?.cancel()
|
||||
}
|
||||
|
||||
private func setActiveCancellationController(_ controller: RDEPUBTextStatisticsCancellationController) {
|
||||
cancellationLock.lock()
|
||||
activeCancellationController = controller
|
||||
cancellationLock.unlock()
|
||||
}
|
||||
|
||||
private func finishActiveCancellationController(_ controller: RDEPUBTextStatisticsCancellationController) {
|
||||
cancellationLock.lock()
|
||||
if activeCancellationController === controller {
|
||||
activeCancellationController = nil
|
||||
}
|
||||
cancellationLock.unlock()
|
||||
}
|
||||
}
|
||||
@ -360,7 +360,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
chapterCFIMap: RDEPUBCFIMap? = nil,
|
||||
chapterFragmentOffsets: [String: Int] = [:],
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchState: RDEPUBSearchState? = nil
|
||||
searchState: RDEPUBSearchState? = nil,
|
||||
footerText: String? = nil
|
||||
) {
|
||||
loadingSpinner.stopAnimating()
|
||||
currentPage = page
|
||||
@ -378,7 +379,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
)
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
pageNumberLabel.text = footerText ?? "\(pageNumber) / \(totalPages)"
|
||||
|
||||
if configureCoverIfNeeded(for: page) {
|
||||
#if canImport(DTCoreText)
|
||||
@ -446,7 +447,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
func configureLoading(
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
configuration: RDEPUBReaderConfiguration
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
footerText: String? = nil
|
||||
) {
|
||||
currentPage = nil
|
||||
currentChapterCFIMap = nil
|
||||
@ -463,7 +465,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
)
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
pageNumberLabel.text = footerText ?? "\(pageNumber) / \(totalPages)"
|
||||
coverImageView.isHidden = true
|
||||
coverImageView.image = nil
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user