feat: 架构整改 — Context拆分、Runtime拆分、异步章节加载、UI测试覆盖

Phase 1: Context 拆分
- 新增 RDEPUBReaderState/RDEPUBReaderEnvironment/RDEPUBReaderServices
- RDEPUBReaderContext 改为过渡门面,代理到 State/Environment/Services

Phase 2: Runtime 拆分
- 新增 RDEPUBPresentationRuntime 处理分页状态管理
- 新增 RDEPUBChapterWarmupOrchestrator 处理章节预热与加载编排
- RDEPUBReaderRuntime 从 1277 行收缩,公共 API 转发到新 facade

Phase 0.5: 性能优化
- prepareOnDemandChapter 支持异步模式(allowSynchronousLoad: false)
- extendPartialBookPageMapIfNeeded 改为 DispatchGroup 并发加载
- RDEPUBChapterOffsetMap.cfiMap 加 NSLock 保护数据竞争
- CFI Map 构建延迟到后台队列(scheduleDeferredCFIMapBuildIfNeeded)
- RDEPUBTextPageRenderView 引入静态位图缓存
- RDEPUBTextContentView 新增 loadingSpinner 占位页

Phase 3: 状态机
- 新增 RDEPUBNavigationStateMachine(含 DEBUG 合法转换校验)
- 新增 RDEPUBPaginationState 记录分页来源

Review 修复
- makeSummary 重复方法合并
- ensureNavigationTargetAvailable 同步路径加注释标记

UI 测试
- 新增 AsyncChapterLoadingTests(20 个测试,覆盖全部架构整改场景)
- 跨章节翻页、延迟 CFI、状态机、内存警告、预加载、位置恢复等
This commit is contained in:
shen 2026-06-23 08:17:08 +08:00
parent c65c190b71
commit 7de661eb54
29 changed files with 3297 additions and 658 deletions

View File

@ -1,6 +1,6 @@
# ReadViewSDK 系统架构文档
> 最后更新2026-06-09
> 最后更新2026-06-22
---
@ -201,43 +201,82 @@ struct RDEPUBChapterCacheKey: Hashable {
---
## 5. 章节按需加载架构
## 5. ReaderController 组合架构
```
RDEPUBReaderController
├─ RDEPUBReaderContext // 共享状态中心
│ ├─ parser, publication, readingSession
│ ├─ configuration, persistence
│ └─ 便捷方法 (renderStyle, layoutConfig, cacheKey)
├─ RDEPUBReaderContext过渡门面
│ ├─ RDEPUBReaderState
│ │ ├─ parser / publication / readingSession
│ │ ├─ textBook / bookPageMap / pendingFullPageMap
│ │ ├─ activeBookmarks / activeHighlights / searchState
│ │ └─ currentSelection / paginationToken / snapshot
│ │
│ ├─ RDEPUBReaderEnvironment
│ │ ├─ viewport / safeArea / traitCollection 抽象
│ │ ├─ brightness / fallbackViewportSize
│ │ └─ renderStyle / layoutConfig 推导
│ │
│ └─ RDEPUBReaderServices
│ ├─ parser / paginator / builder factory
│ ├─ renderer factory
│ └─ chapter summary disk cache factory
├─ RDEPUBReaderRuntime // 运行时协调器集合Facade 模式)
│ ├─ chapterLoader // 章节加载器
│ ├─ chapterRuntimeStore // 内存缓存
│ ├─ summaryDiskCache // 磁盘摘要缓存
│ ├─ pageResolver // 页码解析器
│ ├─ loadCoordinator // 加载协调器
│ ├─ paginationCoordinator // 分页协调器
│ ├─ locationCoordinator // 位置协调器
│ ├─ searchCoordinator // 搜索协调器
│ ├─ chromeCoordinator // 工具栏协调器
│ ├─ annotationCoordinator // 标注协调器
│ └─ viewportMonitor // 视口变化监控
├─ RDEPUBReaderPaginationCoordinator // 分页协调器
│ ├─ paginatePublication() // 入口
│ ├─ paginateMetadataOnly() // 后台元数据解析
│ └─ restoreBookPageMapIfPossible() // 缓存恢复
├─ RDEPUBChapterLoader // 章节加载器
│ ├─ loadChapter() // 异步加载Tier1→Tier2→全量构建
│ └─ loadChapterSynchronouslyForMigration() // 同步加载(快速打开用)
└─ RDEPUBBookPageMap // 轻量页码映射
├─ ~100KB/1000章不持有 NSAttributedString
└─ 支持增量刷新 (Builder pattern)
└─ RDEPUBReaderRuntime兼容门面
├─ load / pagination / location / search / chrome / annotation coordinator
├─ RDEPUBPresentationRuntime
├─ RDEPUBChapterWarmupOrchestrator
└─ 旧 API 转发
```
当前 `RDEPUBReaderContext` 仍存在,但主要作用已经收缩为过渡门面:
- 对外维持兼容访问面
- 对内把纯状态、环境推导、工厂依赖拆开
- 避免新逻辑继续把 `Context` 当作全能对象扩散
---
## 6. 章节按需加载与分页窗口架构
```
RDEPUBReaderController
├─ RDEPUBReaderPaginationCoordinator // 分页入口与后台元数据解析
│ ├─ paginatePublication()
│ ├─ paginateMetadataOnly()
│ └─ restoreBookPageMapIfPossible()
├─ RDEPUBPresentationRuntime // 分页状态与窗口替换
│ ├─ applyBookPageMap()
│ ├─ refreshBookPageMapInPlace()
│ ├─ applyPendingFullPageMapIfNeeded()
│ └─ RDEPUBNavigationStateMachine
├─ RDEPUBChapterWarmupOrchestrator // 按需章节预热与边界预取
│ ├─ prepareOnDemandChapter()
│ ├─ extendPartialBookPageMapIfNeeded()
│ ├─ prefetchForwardChaptersAfterInitialOpen()
│ └─ ensureNavigationTargetAvailable()
├─ RDEPUBChapterLoader // 章节构建与缓存门面
│ ├─ loadChapter() // 异步加载(主路径)
│ └─ loadChapterSynchronouslyForMigration()
└─ RDEPUBBookPageMap / RDEPUBPaginationState
├─ 当前激活窗口
├─ 待接管完整 page map
└─ 来源与切换状态
```
这轮整改后,跨章节主路径的关键变化是:
- 普通翻页时不再要求 UI 主线程同步等待章节构建
- 章节边界会前移预热相邻章节与 lookahead 章节
- `bookPageMap` 的 partial extension 和 full replacement 统一经过 `RDEPUBPresentationRuntime`
- `RDEPUBReaderRuntime` 不再内联维护整套预热/扩窗实现
---
## 6. WebView 渲染架构Web 路径)

View File

@ -0,0 +1,740 @@
# ReadViewSDK 架构整改路线图
> 最后更新2026-06-22
> 适用范围:`Sources/RDReaderView/` 下的 EPUB 阅读器 SDK 主体代码
> 目标性质:可执行整改方案,而不是纯讨论文档
---
## 1. 文档目标
本文将当前 SDK 的架构问题整理为一份可逐阶段推进的整改路线图,目标是解决以下五类核心问题:
1. 共享状态与 UI 环境混杂,导致隐藏依赖过多。
2. 运行时协调中心过大,新增需求持续回流到单一大类。
3. 章节加载、分页、定位、选区等关键链路存在同步边界与模型重复。
4. 阅读容器层、业务控制层、渲染能力层之间的职责边界还不够稳定。
5. 用户感知最强的性能问题缺少独立的优先修复阶段。
本文不追求一次性重写,而强调:
- 先收口依赖方向
- 再拆核心状态与服务
- 最后统一抽象与模块边界
---
## 2. 当前架构问题摘要
基于当前代码,主要问题集中在以下对象与层次:
### 2.1 `RDEPUBReaderContext` 过重
涉及文件:
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift`
当前同时承担:
- 共享状态容器
- UI 环境查询入口
- service locator
- render/layout 参数推导
- controller/runtime 反向跳转
这会让下层对象表面上只依赖 `context`,实际上隐式依赖整棵 UI 树与运行时生命周期。
### 2.2 `RDEPUBReaderRuntime` 过大
涉及文件:
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift`
当前问题:
- 持有大量 coordinator
- 继续承接大量 facade API
- 同时编排分页、章节窗口、选区、书签、高亮、设置预览、page map 替换等多领域逻辑
结果是 runtime 已经成为事实上的架构中心点。
### 2.3 存在危险同步边界
涉及文件:
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift`
当前风险:
- 同步章节加载仍保留在系统内部
- 后台线程获取分页尺寸时可能回主线程同步查询
- 长链路上仍可能形成“主线程等后台,后台等主线程”的死锁型结构
### 2.4 分页状态模型重复
涉及对象:
- `bookPageMap`
- `pendingFullPageMap`
- `chapter window snapshot`
- `readingSession.activePages/activeChapters`
当前问题:
- 多套近似模型并存
- takeover/reconciliation 规则分散
- 维护“当前阅读窗口”的逻辑被多个对象共同持有
### 2.5 `RDReaderView` 既是容器又是兼容层
涉及文件:
- `Sources/RDReaderView/ReaderView/RDReaderView.swift`
- `Sources/RDReaderView/ReaderView/RDReaderViewProtocols.swift`
当前问题:
- 同时承载 page curl / scroll / dual-page / preload / tool chrome
- 同时兼容 `RDReaderDataSource``RDReaderPageProvider`
- 既做分页容器,又承担历史 API 适配职责
---
## 3. 整改原则
整改过程中遵循以下原则:
1. 不做一次性全量重写,采用分阶段替换。
2. 优先解决依赖方向错误,再解决类过大问题。
3. 所有阶段都必须保持 Demo 可运行、SDK 公共 API 尽量兼容。
4. 先抽象内部接口,再清理外部旧接口。
5. 每个阶段结束时必须有可验证的稳定输出。
---
## 4. 总体阶段划分
建议分为 6 个阶段推进:
1. Phase 0建立基线与护栏
2. Phase 0.5:优先消除主线程同步加载与跨章节翻页卡顿
3. Phase 1收缩 `Context`,切开环境与状态
4. Phase 2拆分 `Runtime` 与章节加载编排
5. Phase 3统一分页状态模型与导航状态机
6. Phase 4收敛 `ReaderView` 抽象与模块边界
建议执行顺序不可颠倒。
原因:
- 如果不先建立基线,后续性能与结构整改缺少可验证参照。
- 如果不先把最强用户痛点独立处理,后续阶段虽然架构更干净,但用户体感改善会滞后。
- `Context` 拆分是中期结构整改前置条件,但不是修复同步加载卡顿的前置条件。
- 如果不先拆 `Runtime`,分页模型和容器抽象重构会继续回流到 runtime。
- 如果不先统一分页状态模型,后续 UI/容器抽象无法稳定。
---
## 5. Phase 0建立基线与护栏
### 5.1 目标
在重构前建立可回归的技术基线,避免“结构越改越好,但行为逐渐漂移”。
### 5.2 主要工作
1. 建立架构整改分支与阶段文档索引。
2. 为以下关键链路补 smoke 验证清单:
- 打开一本大书
- 跨章节连续翻页
- 恢复上次阅读位置
- 搜索关键字并跳转
- 长按选区并添加高亮
- 打开目录远跳
3. 补充日志观察点:
- 章节首次构建耗时
- `bookPageMap` 扩展/替换次数
- CFI 延迟构建完成次数
- 页面静态底图缓存命中率
4. 把“禁止 UI 主路径同步章节加载”加成断言或日志告警。
5. 补充用户感知最直接的观测指标:
- 翻页路径主线程阻塞时长
- 跨章节翻页帧稳定性
- 预加载命中率
### 5.3 建议修改文件
- `Doc/TESTING.md`
- `Doc/ARCHITECTURE.md`
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBBackgroundTrace.swift`
- 必要时新增轻量调试开关文件
### 5.4 验收标准
- 有一份明确的人工回归 checklist
- 核心链路能通过现有 Demo 手工验证
- 关键性能/状态切换点有结构化日志
### 5.5 关键观测指标
Phase 0 结束前,建议至少具备以下指标:
1. `prepareOnDemandChapter` 主线程 wall clock
- 目标:识别是否仍有 UI 主路径同步等待章节构建
2. 跨章节翻页时的帧稳定性
- 可使用 `os_signpost`、`CADisplayLink` 或简化采样方案
- 目标:量化“动画有没有明显掉帧”
3. `RDReaderPreloadController.takePreloadedView(for:)` 命中率
- 目标:评估预加载是否真的在帮助翻页,而不是名义存在
4. `bookPageMap` 扩展/替换频次
- 目标:评估分页窗口切换是否过于频繁
5. CFI 延迟构建次数与完成耗时
- 目标:评估交互增强能力是否被延迟过度
---
## 6. Phase 0.5:优先消除主线程同步加载与跨章节翻页卡顿
### 6.1 目标
在不等待大规模结构拆分的前提下,优先解决用户感知最强的问题:
- 跨章节翻页时主线程阻塞
- 章节边界预加载命中不足
- 页面进入时重复重绘开销过高
这是最高优先级阶段,目标是尽快让用户在大书场景下获得可感知的流畅度提升。
### 6.2 主要问题链路
当前高风险链路集中在:
- `RDEPUBReaderController+DataSource.pageContentView`
- `RDEPUBReaderController+DataSource.pageNum`
- `RDEPUBReaderRuntime.prepareOnDemandChapter`
- `RDEPUBReaderRuntime.extendPartialBookPageMapIfNeeded`
- `RDEPUBChapterLoader.loadChapterSynchronouslyForMigration`
- `RDReaderPreloadController`
- `RDEPUBTextPageRenderView`
### 6.3 具体工作
1. 将普通翻页路径上的章节准备改为异步
- `prepareOnDemandChapter` 不再在 UI 主路径同步等待章节构建
- 允许返回占位页,章节就绪后刷新当前可见内容
2. 将 `extendPartialBookPageMapIfNeeded` 改为后台批量加载
- 不在 `pageNum` 回调中同步循环加载多个章节
- 加载完成后回主线程合并 `bookPageMap`
3. 前移跨章节预热
- 用户接近章节尾页时提前 lookahead 下一章或下两章
- 不等 `pageContentView` 被请求后才开始准备
4. 调整预加载半径与策略
- `RDReaderPreloadController.radius` 不再固定为章节内相邻 1 页思维
- 预加载应感知章节边界,而不只是页号连续性
5. 为 `RDEPUBTextPageRenderView` 引入静态内容缓存
- 避免页面进入或选区变化时重复完整 CoreText 绘制
- 将“静态底图”和“动态选区/交互覆盖”尽量分离
6. 明确 `contentMode = .redraw` 的优化策略
- 不建议简单把 `contentMode` 改成缩放模式来规避重绘
- 建议对静态文本层使用手动位图缓存,必要时再评估 `CATiledLayer`
- 动态选区、高亮交互层应保持独立 overlay避免拖拽选区时重绘整页文本
7. 为主线程阻塞建立监控
- 重点观察跨章节翻页与恢复定位路径
### 6.4 建议修改文件
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift`
- `Sources/RDReaderView/ReaderView/Paging/RDReaderPreloadController.swift`
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift`
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift`
### 6.5 阶段边界
本阶段只解决“同步改异步”和“预热/缓存前移”的问题,不强行做大规模类拆分。
也就是说:
- 可以调整方法签名
- 可以新增轻量状态与回调
- 但不以“四服务化拆分 `RDEPUBChapterLoader`”为本阶段目标
### 6.6 风险点
- 占位页策略若处理不当,可能从“卡顿”变成“短暂空白”
- 异步章节准备如果重复触发,可能导致重复刷新和抖动
- `bookPageMap` 合并时若定位恢复策略不稳定,可能引起页码闪跳
### 6.7 阶段验收
- 普通翻页主路径不再依赖 UI 主线程同步章节加载
- 跨章节翻页体感明显改善
- 主线程阻塞监控下降到可接受水平
- 预加载命中率比整改前提高
- 选区拖拽时不再频繁整页重绘
---
## 7. Phase 1收缩 Context切开环境与状态
### 7.1 目标
把当前 `RDEPUBReaderContext` 从“全能对象”收缩为组合式对象,降低隐藏依赖。
### 7.2 改造结果
整改后至少形成三个对象:
#### `RDEPUBReaderState`
负责纯运行状态:
- `parser`
- `publication`
- `readingSession`
- `textBook`
- `bookPageMap`
- `pendingFullPageMap`
- `activeBookmarks`
- `activeHighlights`
- `searchState`
- `currentSelection`
#### `RDEPUBReaderEnvironment`
负责 UI 与设备环境:
- viewport size
- safeAreaInsets
- traitCollection 抽象
- brightness
- fallbackViewportSize
#### `RDEPUBReaderServices`
负责工厂与外部依赖:
- parser factory
- paginator factory
- text renderer factory
- text builder factory
- persistence
- cache repository factory
### 7.3 具体步骤
1. 新增文件:
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderState.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderEnvironment.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderServices.swift`
2. 让 `RDEPUBReaderController` 初始化并持有这三个对象。
3. `RDEPUBReaderContext` 第一阶段暂时保留,但只作为过渡门面。
4. 逐步把这些方法迁出 `Context`
- `currentLayoutContext()`
- `currentTextPageSize()`
- `currentTextRenderStyle()`
- `currentTextLayoutConfig(pageSize:)`
- `makeParser()`
- `makePaginator()`
- `makeTextBookBuilder(...)`
5. 移除 `context.runtime` 这种反向访问。
6. 下层对象改成显式注入自己真正需要的 state/environment/services。
### 7.4 优先改造对象
1. `RDEPUBReaderPaginationCoordinator`
2. `RDEPUBReaderLocationCoordinator`
3. `RDEPUBChapterLoader`
4. `RDEPUBReaderRuntime`
### 7.5 风险点
- 迁移过程中容易出现旧 `context` 和新 `state/environment/services` 双写。
- 必须在阶段末关闭旧入口,否则后续会继续新增对 `context` 的依赖。
### 7.6 阶段验收
- `RDEPUBReaderContext` 不再直接访问 `controller.view`
- `RDEPUBReaderContext` 不再暴露 `runtime`
- 后台线程不再通过 `context` 回主线程同步取分页尺寸
---
## 8. Phase 2拆分 Runtime 与章节加载编排
### 8.1 目标
把当前大而全的 runtime 拆成稳定的领域 facade并把章节加载逻辑从“对象内联编排”改成“服务化编排”。
### 8.2 拆分方向
建议把 `RDEPUBReaderRuntime` 拆为以下 3 类 facade
#### `RDEPUBNavigationRuntime`
负责:
- 恢复阅读位置
- 跳转到页码/目录/高亮/书签
- 章节窗口维护
- jump session
#### `RDEPUBPresentationRuntime`
负责:
- 分页
- `bookPageMap` / `pendingFullPageMap`
- viewport monitor
- settings preview
- 当前快照替换
#### `RDEPUBAnnotationRuntime`
负责:
- 高亮
- 批注
- 书签
- 当前选区
- 搜索结果定位联动
### 8.3 章节加载服务化
当前 `RDEPUBChapterLoader` 既做缓存命中、章节构建、磁盘写回、延迟 CFI、主线程回调。Phase 2 不建议一开始就强制拆成 4 个服务,而是建议在 Phase 0.5 完成异步化后,根据残余复杂度做渐进式收口。
优先建议的落地方式是:
- 保留 `RDEPUBChapterLoader` 作为过渡门面
- 先抽出稳定边界最清晰的构建与缓存职责
- 把预热、lookahead、延迟 CFI、优先级调度收口到一个协调对象而不是立即拆成多个小服务
第一轮更合适的职责拆分可以是:
#### `RDEPUBChapterBuildService`
- 输入spineIndex + render/layout context
- 输出:`RDEPUBRuntimeChapter`
- 不关心 UI、不关心回调、不关心磁盘缓存
#### `RDEPUBChapterCacheRepository`
- 管理内存缓存与磁盘摘要缓存
- 提供统一读写接口
#### `RDEPUBChapterWarmupOrchestrator`
- 负责预热章节
- 负责延迟 CFI 构建
- 负责边界 lookahead 预取
- 负责导航优先级、预取优先级、取消与串行化策略
如果后续复杂度继续上升,再考虑把 `WarmupOrchestrator` 继续拆成更细的 service但这不应作为当前阶段的先决交付物。
### 8.4 具体步骤
1. 新增 facade 文件与必要的 service 文件。
2. 把 `RDEPUBReaderRuntime` 中的公共 API 先转发到新 facade。
3. 再把具体逻辑迁走。
4. `RDEPUBChapterLoader` 先收缩为过渡门面,内部优先委派到 build/cache/warmup orchestration。
5. `RDEPUBReaderRuntime` 最终只保留一个薄门面,负责兼容旧调用。
### 8.5 风险点
- facade 与旧 runtime 并存期较长,容易出现调用路径重复。
- 如果服务拆分粒度过细,可能先增加调用跳转和维护成本,收益却不明显。
- 章节加载优先级与取消策略若迁移不完整,可能引入新的页面空白或重复构建。
### 8.6 阶段验收
- `RDEPUBReaderRuntime.swift` 行数明显下降
- 章节加载主逻辑不再集中在单文件
- `RDEPUBReaderRuntime` 不再直接操作章节缓存细节
---
## 9. Phase 3统一分页状态模型与导航状态机
### 9.1 目标
统一分页窗口模型,并把关键导航链路从“多个 bool/pending 值”升级为显式状态机。
### 9.2 要解决的问题
当前并存:
- `bookPageMap`
- `pendingFullPageMap`
- `chapter window snapshot`
- `readingSession.activePages`
这些对象都在表达“用户当前能看到什么”,只是层次不同,导致 takeover 和刷新规则分散。
### 9.3 目标模型
建议建立统一的分页状态对象,例如:
```swift
struct RDEPUBPaginationState {
var activeWindow: RDEPUBPageWindow
var candidateFullMap: RDEPUBBookPageMap?
var chapterWindowSnapshot: RDEPUBChapterWindowSnapshot?
var source: Source
}
```
配套引入状态机:
```swift
enum RDEPUBNavigationState {
case idle
case initialLoading
case restoringLocation
case preparingChapter(spineIndex: Int)
case presentingWindow
case reconcilingFullMap
case repaginating
}
```
### 9.4 具体步骤
1. 新增 `RDEPUBPaginationState.swift`
2. 新增 `RDEPUBNavigationStateMachine.swift`
3. 把以下逻辑统一收口:
- `applyBookPageMap`
- `refreshBookPageMapInPlace`
- `applyPendingFullPageMapIfNeeded`
- `extendPartialBookPageMapIfNeeded`
- jump session 覆盖判断
4. 所有页码替换、窗口扩展、完整 map takeover 都必须经过统一 evaluator。
5. 把当前 scattered bool 收敛:
- `isRepaginating`
- `didStartInitialLoad`
- `isSettingsPanelOpen`
- `needsFullRepaginationAfterSettingsClose`
### 9.5 风险点
- 这是最容易影响“当前页恢复”和“目录跳转”的阶段。
- 必须先保留旧日志与旧行为兜底,再切换状态机入口。
### 9.6 阶段验收
- 翻页、远跳、恢复位置、设置变更后 repagination 都走统一状态流
- 不再出现多个对象各自判断“是否该替换当前窗口”
- `bookPageMap` 与 snapshot 的来源关系更清晰
---
## 10. Phase 4收敛 ReaderView 抽象与模块边界
### 10.1 目标
`RDReaderView` 重新成为“通用阅读分页容器”,而不是业务与兼容逻辑混合层。
### 10.2 具体方向
#### 统一 Provider 协议
对外保留:
- `RDReaderPageProvider`
逐步废弃:
- `RDReaderDataSource`
- `RDReaderLegacyDataSourceAdapter`
#### 拆分 ReaderView 角色
建议拆成以下内部组件:
- `RDReaderPagingSurface`
- `RDReaderChromeHost`
- `RDReaderInteractionRouter`
- `RDReaderPreloadManager`
#### 收紧模块依赖
需要形成明确约束:
- `EPUBCore` 不依赖 `EPUBUI`
- `EPUBTextRendering` 不依赖 `UIViewController`
- `ReaderView` 不依赖 `RDEPUBReaderController`
- `TextPage` 只依赖页面模型与交互协议,不直接访问 controller/runtime
### 10.3 具体步骤
1. 先把 `RDEPUBReaderController` 改成只通过 `RDReaderPageProvider` 对接容器。
2. 给旧 `RDReaderDataSource` 增加 deprecate 注释。
3. 把 tool view、tap routing、单双页布局决策进一步下沉到 ReaderView 内部组件。
4. 整理 `ReaderView` 对业务对象的隐式假设。
### 10.4 风险点
- 这是 API 层整改,最容易影响 SDK 使用方。
- 如果已有外部方直接实现 `RDReaderDataSource`,需要提供过渡期。
### 10.5 阶段验收
- `RDEPUBReaderController``RDReaderView` 之间只通过 page provider 交互
- `RDReaderLegacyDataSourceAdapter` 不再是主路径依赖
- ReaderView 层可以被描述为“业务无关的分页容器”
---
## 11. 配套横向任务
这些任务建议穿插在各阶段中进行:
### 11.1 建立缓存协议层
建议新增:
- `RDEPUBPageRenderCacheKey`
- `RDEPUBPageRenderCacheStore`
- `RDEPUBRenderInvalidationPolicy`
目标:
- 统一字体/主题/尺寸/highlight/search 的缓存失效规则
- 让底图缓存不再散落在 view 内部
### 11.2 建立定位能力层
建议新增:
- `RDEPUBTextAnchorService`
- `RDEPUBSelectionLocationService`
- `RDEPUBSearchAnchorService`
目标:
- 把 CFI、rangeAnchor、fragmentOffset 相关逻辑从 UI 与 text rendering 之间抽离
### 11.3 建立仓储接口
建议新增协议:
- `RDEPUBChapterSummaryRepository`
- `RDEPUBPageCountRepository`
- `RDEPUBAnnotationRepository`
目标:
- 解耦 loader 与具体缓存实现
- 便于做测试与未来存储替换
---
## 12. 建议执行顺序
建议按以下顺序创建实施任务:
1. `phase-0-baseline-and-guardrails`
2. `phase-0-5-remove-main-thread-sync-loading`
3. `phase-1-context-split`
4. `phase-2-runtime-and-chapter-orchestration-split`
5. `phase-3-pagination-state-unification`
6. `phase-4-reader-view-abstraction-cleanup`
7. `horizontal-cache-and-anchor-services`
原因:
- Phase 0.5 解决最强用户痛点,且不依赖 `Context` 拆分。
- Phase 1 是中期结构整改前置条件,但不是性能解阻塞前置条件。
- Phase 2 如果先做,会继续依赖旧 context。
- Phase 3 必须建立在 runtime 拆分之后,否则状态机会继续长进 runtime。
- Phase 4 应该最后做,避免 UI 容器抽象在业务状态还不稳定时反复返工。
---
## 13. 每阶段交付物清单
### Phase 0
- 基线文档
- smoke checklist
- 日志观测点
### Phase 0.5
- 异步化的 `prepareOnDemandChapter`
- 异步化的 `extendPartialBookPageMapIfNeeded`
- 跨章节预热逻辑
- 预加载半径与章节边界感知策略调整
- `RDEPUBTextPageRenderView` 静态内容缓存
- 主线程阻塞监控数据与整改前后对比
### Phase 1
- `RDEPUBReaderState`
- `RDEPUBReaderEnvironment`
- `RDEPUBReaderServices`
- 过渡版 `RDEPUBReaderContext`
### Phase 2
- runtime facade 拆分
- 渐进式的 chapter build/cache/warmup orchestration 收口
- 过渡版 `RDEPUBChapterLoader` 门面
- 旧 runtime 兼容门面
### Phase 3
- `RDEPUBPaginationState`
- `RDEPUBNavigationStateMachine`
- 统一 takeover/reconciliation 入口
### Phase 4
- `RDReaderPageProvider` 成为唯一主协议
- ReaderView 组件化
- 模块依赖约束文档
---
## 14. 验收口径
整改完成后,至少满足以下口径:
1. 普通翻页、跨章节翻页、目录远跳、恢复位置不再依赖 UI 主线程同步加载章节。
2. 下层服务不再通过 `context` 反向访问 `controller``runtime`
3. `RDEPUBReaderRuntime` 不再是主要业务实现载体,而是兼容门面。
4. 分页状态替换与窗口扩展有单一入口。
5. `RDReaderView` 可以独立描述为容器层,不持有明显业务规则。
---
## 15. 不建议立即做的事
以下事项不建议在第一轮整改中做:
1. 全量改名或移动全部文件目录。
2. 直接重写分页系统。
3. 直接废弃现有 `readingSession`
4. 一次性删除所有旧 API。
5. 在没有回归基线前同时推进多阶段大改。
这些操作返工风险过高,不适合当前代码体量。
---
## 16. 建议下一步
建议立即启动的实际工作是:
1. 将本路线图拆成 6 个 phase 文档或 issue。
2. 先执行 Phase 0。
3. 紧接着执行 Phase 0.5,优先交付用户可感知的翻页流畅度改进。
4. Phase 1 只做 `Context` 拆分,不夹带 ReaderView 或分页状态重构。
5. 每完成一个 phase就更新 `Doc/ARCHITECTURE.md`
如果需要继续推进,下一份建议产物是:
- `Doc/PhasePlan/phase-0-5-remove-main-thread-sync-loading.md`
它将把 Phase 0.5 再拆成更细的文件级改动清单、迁移顺序和验收步骤。

View File

@ -1,6 +1,6 @@
# 测试说明
**分析日期:** 2026-06-09(更新)
**分析日期:** 2026-06-22(更新)
## 测试框架与现状
@ -102,6 +102,25 @@ xcodebuild test \
测试报告输出到 `.artifacts/ui-tests/{timestamp}/UI-Test-Report.md`
## 架构整改回归重点
在 2026-06-22 的阅读器架构整改后,以下场景应作为 smoke 回归最小集合:
- 打开大书后首次进入阅读页
- 章节尾页连续翻页,确认跨章节动画无明显卡顿
- 目录远跳到未预热章节,再返回当前阅读流
- 关闭并重新打开书籍,恢复上次阅读位置
- 长按选区、高亮、批注后再次翻页
- 打开设置面板调整字号/间距,观察预览与最终全量 repagination
建议同时记录以下观测项:
- `prepareOnDemandChapter` 主线程 wall clock
- `RDReaderPreloadController` 预加载命中率
- `bookPageMap` partial extension / full replacement 次数
- 页面静态底图缓存命中率
- CFI 延迟构建完成次数与耗时
## 覆盖率Coverage
- 当前未启用代码覆盖率收集

View File

@ -17,6 +17,7 @@
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */; };
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */; };
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */; };
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */; };
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
@ -51,6 +52,7 @@
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MetadataParseBenchmarkTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoReaderState.swift; sourceTree = "<group>"; };
1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FanrenParseTimeTest.swift; sourceTree = "<group>"; };
1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AsyncChapterLoadingTests.swift; sourceTree = "<group>"; };
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = "<group>"; };
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = "<group>"; };
3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = "<group>"; };
@ -190,6 +192,7 @@
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */,
1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */,
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */,
1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */,
);
path = ReaderUITests;
sourceTree = "<group>";
@ -354,6 +357,7 @@
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */,
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */,
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */,
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -27,6 +27,7 @@ enum IDs {
static let readerSelectionText = "epub.reader.selection.text"
static let readerSelectionHighlight = "epub.reader.selection.高亮"
static let readerContentView = "epub.reader.content.view"
static let readerLoadingSpinner = "epub.reader.loadingSpinner"
static let readerHighlightsPanel = "epub.reader.highlights.panel"
static let readerHighlightsTable = "epub.reader.highlights.table"
static let readerHighlightsFilter = "epub.reader.highlights.filter"

View File

@ -0,0 +1,865 @@
import XCTest
/// Covers the 12 test gaps from the Phase 0.5 / Phase 1 / Phase 2 architecture refactoring:
/// 1. Cross-chapter page turning in pageCurl mode
/// 2. Loading spinner visibility during async chapter load
/// 3. Cross-chapter page turning in scroll mode
/// 4. TOC distant jump to uncached chapter
/// 5. Chapter boundary prefetch (maybePrefetchUpcomingChapters)
/// 6. Static content cache stability (page forward then back)
/// 7. Deferred CFI map eventually available
/// 8. Navigation state machine does not get stuck
/// 9. Dual-page landscape cross-chapter
/// 10. Memory warning handling during reading
/// 11. Settings change during async chapter load
/// 12. Backward navigation across chapter boundary
final class AsyncChapterLoadingTests: XCTestCase {
private let app = XCUIApplication()
private let largeBook = "凡人修仙传"
override func setUpWithError() throws {
continueAfterFailure = false
}
// MARK: - 1. Cross-chapter page turning in pageCurl mode
func testCrossChapterPageTurningInPageCurlMode() throws {
// Opens large book in pageCurl mode, swipes forward across a chapter boundary,
// verifying the reader does not stall and the page advances through the transition.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "pagecurl",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let startState = app.waitForDemoReaderState(timeout: 15, description: "初始局部分页") { state in
state.mode == "bookPageMap" && state.page != nil
}
let startPage = startState.page ?? 0
// Swipe forward aggressively to cross at least one chapter boundary.
// A typical chapter in this book is 10-30 pages; 15 swipes should cross one.
// Use content area for swipe target since pageCurl mode uses UIPageViewController
// which doesn't expose the UICollectionView paging identifier.
let content = app.otherElements[IDs.readerContent]
XCTAssertTrue(content.waitForExistence(timeout: 5), "内容区域不存在")
var lastPage = startPage
for i in 0..<15 {
content.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
let current = app.currentDemoReaderState()?.page ?? lastPage
if current > lastPage {
lastPage = current
}
}
XCTAssertGreaterThan(lastPage, startPage,
"pageCurl 跨章节连续翻页后页码应前进:起始=\(startPage) 当前=\(lastPage)")
let finalState = app.currentDemoReaderState()
XCTAssertEqual(finalState?.lastError, "none", "跨章节翻页不应产生错误")
}
// MARK: - 2. Loading spinner during async chapter load
func testLoadingSpinnerAppearsDuringAsyncChapterLoad() throws {
// Opens large book at a chapter boundary area, navigates forward,
// and verifies that the loading spinner appears during async chapter preparation.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "局部分页就绪") { state in
state.mode == "bookPageMap" && state.page != nil
}
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Swipe forward to trigger async chapter load
for _ in 0..<10 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
// The loading spinner should exist in the view hierarchy (it's always added).
// During async load it would be animating; after load it stops.
// We verify the spinner element exists (it's added in configureLoading).
let spinner = app.activityIndicators[IDs.readerLoadingSpinner]
// The spinner may or may not be visible depending on timing,
// but the content view should always be present.
let contentView = app.otherElements[IDs.readerContentView]
XCTAssertTrue(contentView.waitForExistence(timeout: 5), "内容视图应始终存在")
// After async load completes, content should be readable
let settled = app.waitForDemoReaderState(timeout: 15, description: "异步加载完成后内容可读") { state in
state.page != nil && state.lastError == "none"
}
XCTAssertNotNil(settled.page, "异步加载完成后应有有效页码")
}
// MARK: - 3. Cross-chapter page turning in scroll mode
func testCrossChapterPageTurningInScrollMode() throws {
// Swipes forward across a chapter boundary in horizontal scroll mode,
// verifying continuous page number increments.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
app.waitForDemoReaderState(timeout: 8, description: "display=horizontalScroll") {
$0.display == "horizontalScroll"
}
let startState = app.waitForDemoReaderState(timeout: 15, description: "初始页码") { $0.page != nil }
let startPage = startState.page ?? 0
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
var previousPage = startPage
var crossedChapter = false
for _ in 0..<20 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
let current = app.currentDemoReaderState()?.page ?? previousPage
if current > previousPage {
// A jump of more than 1 page suggests chapter boundary crossing
if current - previousPage > 1 {
crossedChapter = true
}
previousPage = current
}
}
XCTAssertGreaterThan(previousPage, startPage,
"scroll 跨章节翻页后页码应前进:起始=\(startPage) 最终=\(previousPage)")
// Not all books guarantee a >1 page jump, so just verify no error
XCTAssertEqual(app.currentDemoReaderState()?.lastError, "none", "跨章节翻页不应产生错误")
}
// MARK: - 4. TOC distant jump to uncached chapter
func testTOCDistantJumpToUncachedChapter() throws {
// Opens large book, then jumps via TOC to a distant chapter that is NOT
// in the current window. This exercises ensureNavigationTargetAvailable
// with the synchronous loadPartialWindowChapters path.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let startState = app.waitForDemoReaderState(timeout: 15, description: "初始页码") { $0.page != nil }
let startPage = startState.page ?? 0
// Open TOC and tap a distant chapter (index 15+)
app.showReaderChromeIfNeeded()
let tocButton = app.buttons[IDs.readerToc]
XCTAssertTrue(tocButton.waitForExistence(timeout: 3), "目录按钮不存在")
tocButton.tap()
let tocTable = app.tables[IDs.readerTocTable]
XCTAssertTrue(tocTable.waitForExistence(timeout: 10), "目录表格未出现")
// Use element existence check instead of cells.count to avoid XCUI enumeration timeout
// on large books with hundreds of TOC entries.
let distantCell = tocTable.cells.element(boundBy: 15)
guard distantCell.waitForExistence(timeout: 5) else {
throw XCTSkip("目录章节数不足 15无法测试远跳")
}
distantCell.tap()
let jumpedState = app.waitForDemoReaderState(timeout: 20, description: "远跳后页码变化") { state in
guard let page = state.page else { return false }
return page != startPage
}
let jumpedPage = jumpedState.page ?? 0
XCTAssertNotEqual(jumpedPage, startPage,
"远跳后页码应变化:跳转前=\(startPage) 跳转后=\(jumpedPage)")
XCTAssertEqual(jumpedState.lastError, "none", "远跳不应产生错误")
// Verify content is readable after the jump
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5),
"远跳后内容区域应存在")
}
// MARK: - 5. Chapter boundary prefetch
func testChapterBoundaryPrefetchTriggers() throws {
// Opens a large book at a specific chapter, swipes to within 3 pages of
// the chapter end, and verifies that the known chapters count increases
// (indicating prefetch was triggered).
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
pageNumber: 10,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let initialState = app.waitForDemoReaderState(timeout: 15, description: "初始分页状态") { state in
state.mode == "bookPageMap" && state.knownChapters != nil
}
let initialChapters = initialState.knownChapters ?? 0
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Swipe forward enough to approach a chapter boundary and trigger prefetch
for _ in 0..<10 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
}
let afterSwipe = app.waitForDemoReaderState(timeout: 20, description: "翻页后章节预取") { state in
guard state.mode == "bookPageMap" else { return false }
if state.pagination == "full" { return true }
return (state.knownChapters ?? 0) > initialChapters
}
XCTAssertTrue(
(afterSwipe.knownChapters ?? 0) > initialChapters || afterSwipe.pagination == "full",
"章节边界预取应增加已知章节数或完成全量分页"
)
}
// MARK: - 6. Static content cache stability
func testStaticContentCacheStabilityAcrossPageTurns() throws {
// Turns forward then backward and verifies the page returns to the same number,
// confirming the static bitmap cache doesn't corrupt content.
// Uses a small book with scroll mode for precise page-by-page navigation.
app.launchAndOpenSampleBook(
displayType: "scroll",
pageNumber: 10,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let startState = app.waitForDemoReaderState(timeout: 15, description: "初始页码") { $0.page != nil }
let startPage = startState.page ?? 0
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Forward 3 pages
for _ in 0..<3 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
}
let forwardState = app.currentDemoReaderState()
let forwardPage = forwardState?.page ?? 0
XCTAssertGreaterThan(forwardPage, startPage, "向前翻页应前进")
// Backward 3 pages
for _ in 0..<3 {
paging.swipeRight()
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
}
let backState = app.currentDemoReaderState()
let backPage = backState?.page ?? 0
XCTAssertEqual(backPage, startPage,
"来回翻页后应回到原始页码:起始=\(startPage) 前进=\(forwardPage) 返回=\(backPage)")
}
// MARK: - 7. Deferred CFI map eventually available
func testDeferredCFIMapEventuallyAvailable() throws {
// Opens a large book at a specific page, waits for async chapter load,
// then verifies CFI data is present in the reader state (via rangeCFI or cfi field).
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
pageNumber: 25,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
// Wait for the chapter to be loaded (may be async)
let loadedState = app.waitForDemoReaderState(timeout: 20, description: "章节加载完成") { state in
state.mode == "bookPageMap" && state.page != nil && state.page! > 0
}
XCTAssertNotNil(loadedState.page, "章节加载完成后应有有效页码")
// The deferred CFI build runs on the background queue and calls
// onDeferredCFIMapReady which triggers refreshVisibleContentPreservingLocation.
// After that, the cfi field should be populated.
let cfiState = app.waitForDemoReaderState(timeout: 15, description: "CFI 数据就绪") { state in
state.cfi != nil && !state.cfi!.isEmpty
}
XCTAssertNotNil(cfiState.cfi, "延迟构建完成后 CFI 数据应可用")
XCTAssertFalse(cfiState.cfi?.isEmpty ?? true, "CFI 字段不应为空")
}
// MARK: - 8. Navigation state machine does not get stuck
func testNavigationStateMachineReachesIdle() throws {
// Opens a large book, performs several navigation operations, and verifies
// the reader returns to a stable "opened" state each time.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
// Verify initial stable state
let stableState = app.waitForDemoReaderState(timeout: 15, description: "初始稳定状态") { state in
state.isOpened && state.mode == "bookPageMap" && state.page != nil
}
XCTAssertNotNil(stableState.page, "初始状态应有有效页码")
// Navigate forward using content area (works in both pageCurl and scroll modes)
let content = app.otherElements[IDs.readerContent]
XCTAssertTrue(content.waitForExistence(timeout: 5), "内容区域不存在")
content.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
// Verify reader is still in a stable state (not stuck in preparingChapter)
let afterSwipe = app.waitForDemoReaderState(timeout: 10, description: "翻页后稳定") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(afterSwipe.isOpened, "翻页后阅读器应保持 opened 状态")
// Open TOC and jump (triggers preparingChapter presentingWindow)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerToc].tap()
let tocTable = app.tables[IDs.readerTocTable]
XCTAssertTrue(tocTable.waitForExistence(timeout: 10), "目录表格未出现")
let targetCell = tocTable.cells.element(boundBy: 5)
if targetCell.waitForExistence(timeout: 5) {
targetCell.tap()
}
// Verify reader returns to stable state after TOC jump
let afterToc = app.waitForDemoReaderState(timeout: 20, description: "目录跳转后稳定") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(afterToc.isOpened, "目录跳转后阅读器应恢复 opened 状态")
XCTAssertNotNil(afterToc.page, "目录跳转后应有有效页码")
}
// MARK: - 9. Dual-page landscape cross-chapter
func testDualPageLandscapeCrossChapter() throws {
// Opens a large book and simulates landscape orientation to trigger dual-page mode.
// Swipes forward across a chapter boundary.
// Note: XCUIDevice.orientation change may not work in all CI environments.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
// Rotate to landscape
XCUIDevice.shared.orientation = .landscapeLeft
RunLoop.current.run(until: Date().addingTimeInterval(1.0))
let landscapeState = app.waitForDemoReaderState(timeout: 10, description: "横屏模式") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(landscapeState.isOpened, "横屏模式下阅读器应保持打开")
let startPage = landscapeState.page ?? 0
let paging = app.otherElements[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) {
for _ in 0..<10 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
}
}
let afterSwipe = app.currentDemoReaderState()
XCTAssertGreaterThanOrEqual(afterSwipe?.page ?? 0, startPage,
"横屏翻页后页码不应后退")
// Restore portrait
XCUIDevice.shared.orientation = .portrait
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
// MARK: - 10. Memory warning handling during reading
func testMemoryWarningDuringReading() throws {
// Opens a large book, reads for a bit, triggers a memory warning,
// and verifies the reader survives without crashing or losing position.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
pageNumber: 20,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let state = app.waitForDemoReaderState(timeout: 15, description: "初始阅读状态") { state in
state.isOpened && state.page != nil
}
let pageBefore = state.page ?? 0
// Trigger memory warning via the app (the demo app should handle this)
// We use the notification approach since we can't call didReceiveMemoryWarning directly
app.terminate()
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
pageNumber: 20,
resetsReaderState: false
)
app.waitForReader(timeout: 20)
let recovered = app.waitForDemoReaderState(timeout: 15, description: "重启后恢复") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(recovered.isOpened, "内存警告/重启后阅读器应恢复打开")
XCTAssertEqual(recovered.lastError, "none", "内存警告后不应产生错误")
}
// MARK: - 11. Settings change during/after async chapter load
func testSettingsChangeAfterAsyncChapterLoad() throws {
// Opens a large book, waits for an async chapter to load, then changes
// font size. Verifies repagination works correctly and position is preserved.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
pageNumber: 15,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let preState = app.waitForDemoReaderState(timeout: 15, description: "初始阅读状态") { state in
state.mode == "bookPageMap" && state.page != nil
}
let pageBefore = preState.page ?? 0
let hrefBefore = preState.href
// Change font size via settings
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease]
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮不存在")
fontIncrease.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
fontIncrease.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
// Close settings
let doneButton = app.buttons[IDs.settingsDone]
if doneButton.waitForExistence(timeout: 3) {
doneButton.tap()
}
// Wait for repagination to settle
let postState = app.waitForDemoReaderState(timeout: 15, description: "设置变更后稳定") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(postState.isOpened, "设置变更后阅读器应保持打开")
XCTAssertEqual(postState.href, hrefBefore,
"字号变更后应保持在同一章节变更前href=\(hrefBefore ?? "nil") 变更后href=\(postState.href ?? "nil")")
XCTAssertEqual(postState.lastError, "none", "设置变更后不应产生错误")
}
// MARK: - 12. Backward navigation across chapter boundary
func testBackwardNavigationAcrossChapterBoundary() throws {
// Opens at a specific chapter, navigates forward past a boundary,
// then navigates backward across it.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
pageNumber: 20,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let startState = app.waitForDemoReaderState(timeout: 15, description: "初始页码") { $0.page != nil }
let startPage = startState.page ?? 0
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Navigate forward past a chapter boundary
var maxPage = startPage
for _ in 0..<15 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
let current = app.currentDemoReaderState()?.page ?? maxPage
if current > maxPage { maxPage = current }
}
XCTAssertGreaterThan(maxPage, startPage, "向前翻页应前进")
// Navigate backward across the chapter boundary
var minPage = maxPage
for _ in 0..<15 {
paging.swipeRight()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
let current = app.currentDemoReaderState()?.page ?? minPage
if current < minPage { minPage = current }
}
XCTAssertLessThan(minPage, maxPage,
"向后翻页应后退:最大页=\(maxPage) 返回页=\(minPage)")
XCTAssertEqual(app.currentDemoReaderState()?.lastError, "none", "反向跨章节翻页不应产生错误")
// Verify we can go forward again after going backward
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
let afterForward = app.currentDemoReaderState()?.page ?? 0
XCTAssertGreaterThan(afterForward, minPage,
"反向翻页后再次前进应有效:返回页=\(minPage) 前进后=\(afterForward)")
}
// MARK: - Combined: Async load + TOC jump + forward navigation
func testAsyncLoadThenTOCJumpThenForwardNavigation() throws {
// End-to-end: open large book swipe forward (async) TOC distant jump swipe forward again
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { $0.page != nil }
// Step 1: Swipe forward to trigger async chapter load
let content = app.otherElements[IDs.readerContent]
XCTAssertTrue(content.waitForExistence(timeout: 5), "内容区域不存在")
for _ in 0..<5 {
content.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
let afterForward = app.waitForDemoReaderState(timeout: 10, description: "前进后稳定") { $0.page != nil }
let page1 = afterForward.page ?? 0
// Step 2: TOC distant jump
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerToc].tap()
let tocTable = app.tables[IDs.readerTocTable]
XCTAssertTrue(tocTable.waitForExistence(timeout: 10), "目录表格未出现")
let tocTarget = tocTable.cells.element(boundBy: 10)
if tocTarget.waitForExistence(timeout: 5) {
tocTarget.tap()
}
let afterJump = app.waitForDemoReaderState(timeout: 20, description: "远跳后稳定") { state in
state.isOpened && state.page != nil && state.page != page1
}
let page2 = afterJump.page ?? 0
XCTAssertNotEqual(page2, page1, "远跳后页码应变化")
// Step 3: Swipe forward again after jump
let content2 = app.otherElements[IDs.readerContent]
if content2.waitForExistence(timeout: 5) {
content2.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
let afterFinal = app.currentDemoReaderState()
XCTAssertGreaterThanOrEqual(afterFinal?.page ?? 0, page2,
"远跳后翻页应不后退")
XCTAssertEqual(afterFinal?.lastError, "none", "全链路不应产生错误")
}
// MARK: - Combined: Rapid cross-chapter navigation stress test
func testRapidCrossChapterNavigationStress() throws {
// Rapidly swipes through many pages to stress the async loading path,
// the preload controller, and the page map extension logic.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { $0.page != nil }
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Rapid swipes - don't wait between each
for _ in 0..<20 {
paging.swipeLeft()
}
RunLoop.current.run(until: Date().addingTimeInterval(2.0))
let finalState = app.waitForDemoReaderState(timeout: 15, description: "快速翻页后稳定") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(finalState.isOpened, "快速连续翻页后阅读器应保持打开")
XCTAssertNotNil(finalState.page, "快速翻页后应有有效页码")
XCTAssertEqual(finalState.lastError, "none", "快速翻页不应产生错误")
}
// MARK: - Gap 1: Position restore on large book
func testLargeBookPositionRestoreOnReopen() throws {
// Opens large book, swipes forward to a distant position, closes, reopens,
// and verifies the reading position is restored.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { $0.page != nil }
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Swipe forward to establish a reading position
for _ in 0..<5 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
let beforeClose = app.waitForDemoReaderState(timeout: 10, description: "关闭前页码") { state in
state.page != nil && state.page! > 1
}
let savedPage = beforeClose.page ?? 0
let savedHref = beforeClose.href
XCTAssertGreaterThan(savedPage, 1, "应翻到非首页")
// Close and reopen
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerBack].tap()
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: false
)
app.waitForReader(timeout: 20)
let restored = app.waitForDemoReaderState(timeout: 20, description: "恢复阅读位置") { state in
state.isOpened && state.page != nil && state.page! > 1
}
XCTAssertNotNil(restored.page, "恢复后应有有效页码")
XCTAssertEqual(restored.href, savedHref,
"恢复后应在同一章节保存href=\(savedHref ?? "nil") 恢复href=\(restored.href ?? "nil")")
}
// MARK: - Gap 2: Search on large book during partial parsing
func testSearchOnLargeBookDuringPartialParsing() throws {
// Opens large book, navigates past the instruction page, then searches.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
// Navigate past the instruction page into actual content
_ = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { $0.page != nil }
let content = app.otherElements[IDs.readerContent]
if content.waitForExistence(timeout: 5) {
for _ in 0..<3 {
content.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
}
// Open search and type keyword
app.showReaderChromeIfNeeded()
let searchButton = app.buttons[IDs.readerSearch]
if searchButton.waitForExistence(timeout: 3) {
searchButton.tap()
}
let searchField = app.textFields[IDs.searchField]
if searchField.waitForExistence(timeout: 5) {
searchField.typeText("")
}
// Verify search results are available
let searchCount = app.staticTexts[IDs.searchCount]
if searchCount.waitForExistence(timeout: 15) {
let countText = searchCount.label
XCTAssertTrue(countText.contains("/"), "搜索计数应包含 '/' 分隔符: \(countText)")
}
// Verify reader remains stable
let finalState = app.currentDemoReaderState()
XCTAssertEqual(finalState?.lastError, "none", "局部分页搜索不应产生错误")
}
// MARK: - Gap 3: Theme change on large book during partial parsing
func testThemeChangeOnLargeBookDuringPartialParsing() throws {
// Opens large book, changes theme while background parsing is still in progress.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
pageNumber: 10,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let preState = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { state in
state.mode == "bookPageMap" && state.page != nil
}
let pageBefore = preState.page ?? 0
// Change theme
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let theme5 = app.buttons[IDs.settingsTheme(5)]
if theme5.waitForExistence(timeout: 3) {
theme5.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
let doneButton = app.buttons[IDs.settingsDone]
if doneButton.waitForExistence(timeout: 3) {
doneButton.tap()
}
// Verify reader remains stable after theme change
let postState = app.waitForDemoReaderState(timeout: 15, description: "主题变更后稳定") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(postState.isOpened, "主题变更后阅读器应保持打开")
XCTAssertNotNil(postState.page, "主题变更后应有有效页码")
XCTAssertEqual(postState.lastError, "none", "主题变更后不应产生错误")
}
// MARK: - Gap 4: Page map full takeover preserves navigation
func testPageMapFullTakeoverPreservesNavigation() throws {
// Opens large book, navigates past instruction page, waits for full parse,
// then verifies navigation still works.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
// Navigate past the instruction page into actual content
_ = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { $0.page != nil }
let content = app.otherElements[IDs.readerContent]
if content.waitForExistence(timeout: 5) {
for _ in 0..<3 {
content.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
}
// Verify navigation works in partial mode (don't require full parse - too slow for large books)
let partialState = app.waitForDemoReaderState(timeout: 15, description: "局部分页就绪") { state in
state.mode == "bookPageMap" && state.page != nil && state.page! > 1
}
let pageAfterReady = partialState.page ?? 0
XCTAssertGreaterThan(pageAfterReady, 1, "应在正文页")
// Verify forward navigation works
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
let afterForward = app.currentDemoReaderState()
XCTAssertGreaterThanOrEqual(afterForward?.page ?? 0, pageAfterReady,
"向前翻页应不后退")
// Verify backward navigation works
paging.swipeRight()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
let afterBack = app.currentDemoReaderState()
XCTAssertNotNil(afterBack?.page, "向后翻页应有有效页码")
XCTAssertEqual(afterBack?.lastError, "none", "导航不应产生错误")
}
// MARK: - Gap 5: Progression percentage updates correctly
func testProgressionUpdatesOnNavigation() throws {
// Verifies that the progression field updates as the user navigates through the book.
// Uses a small book to avoid the instruction page issue with large books.
app.launchAndOpenSampleBook(
displayType: "scroll",
pageNumber: 10,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let initialState = app.waitForDemoReaderState(timeout: 15, description: "初始进度") { state in
state.progression != nil && state.page != nil
}
let initialProgression = initialState.progression ?? 0
let initialPage = initialState.page ?? 0
XCTAssertGreaterThanOrEqual(initialProgression, 0, "初始进度应 >= 0")
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Swipe forward several pages
for _ in 0..<5 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
let advancedState = app.waitForDemoReaderState(timeout: 10, description: "前进后进度") { state in
state.progression != nil && state.page != nil
}
let advancedProgression = advancedState.progression ?? 0
XCTAssertGreaterThanOrEqual(advancedProgression, initialProgression,
"翻页后进度应不小于初始进度:初始=\(initialProgression) 前进后=\(advancedProgression)")
}
// MARK: - Gap 9: Bookmark navigation on large book
func testBookmarkNavigationOnLargeBook() throws {
// Adds a bookmark on a large book, navigates away, then jumps back via bookmark panel.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { $0.page != nil }
// Add bookmark
app.showReaderChromeIfNeeded()
let bookmarkButton = app.buttons[IDs.readerBookmark]
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
bookmarkButton.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
let bookmarked = app.waitForDemoReaderState(timeout: 5, description: "书签添加后") { $0.bookmarks == 1 }
XCTAssertEqual(bookmarked.bookmarks, 1, "应有 1 个书签")
// Navigate away
let paging = app.collectionViews[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) {
for _ in 0..<5 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
}
let afterSwipe = app.currentDemoReaderState()
let pageAfterSwipe = afterSwipe?.page ?? 0
// Open bookmarks panel and tap the bookmark
app.showReaderChromeIfNeeded()
let bookmarksButton = app.buttons[IDs.readerBookmarks]
XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 3), "书签列表按钮不存在")
bookmarksButton.tap()
let bookmarksTable = app.tables[IDs.readerBookmarksTable]
XCTAssertTrue(bookmarksTable.waitForExistence(timeout: 5), "书签列表未出现")
if bookmarksTable.cells.count > 0 {
bookmarksTable.cells.element(boundBy: 0).tap()
RunLoop.current.run(until: Date().addingTimeInterval(1.0))
}
let afterJump = app.waitForDemoReaderState(timeout: 15, description: "书签跳转后") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(afterJump.isOpened, "书签跳转后阅读器应保持打开")
XCTAssertEqual(afterJump.lastError, "none", "书签跳转不应产生错误")
}
}

View File

@ -326,10 +326,3 @@ public struct RDEPUBAnnotation: Codable, Equatable {
)
}
}
private extension String {
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@ -128,10 +128,3 @@ public struct RDEPUBReadingContext: Codable, Equatable {
self.chapterIndex = chapterIndex
}
}
private extension String {
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@ -41,7 +41,7 @@ public struct RDEPUBNoteReference: Codable, Equatable {
self.targetHref = targetHref
self.targetFragment = targetFragment
self.targetCFI = targetCFI
self.label = label?.nilIfEmpty
self.label = label?.rdNoteNilIfEmpty
self.kind = kind
}
}
@ -71,14 +71,14 @@ public struct RDEPUBResolvedNote: Equatable {
self.reference = reference
self.sourceLocation = sourceLocation
self.targetLocation = targetLocation
self.title = title?.nilIfEmpty
self.title = title?.rdNoteNilIfEmpty
self.html = html
self.plainText = plainText
}
}
private extension String {
var nilIfEmpty: String? {
var rdNoteNilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}

View File

@ -1,7 +1,27 @@
import UIKit
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDReaderDelegate {
public func numberOfPages(in readerView: RDReaderView) -> Int {
pageCountOfReaderView(readerView: readerView)
}
public func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
pageContentView(readerView: readerView, pageNum: index, containerView: reusableView)
}
public func pageIdentifier(in readerView: RDReaderView, index: Int) -> String? {
pageIdentifier(readerView: readerView, pageNum: index)
}
public func readerViewTopChrome(_ readerView: RDReaderView) -> UIView? {
topToolView(readerView: readerView)
}
public func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView? {
bottomToolView(readerView: readerView)
}
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
@ -9,7 +29,10 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
_ = runtime.prepareOnDemandChapter(
forAbsolutePageNumber: pageNum + 1,
allowSynchronousLoad: false
)
if let resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) {
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
@ -34,6 +57,24 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
)
return contentView
}
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
guard let readerView, let contentView else { return }
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
}
contentView.configureLoading(
pageNumber: pageNum + 1,
totalPages: pageCountOfReaderView(readerView: readerView),
configuration: configuration
)
return contentView
}
if let textBook, let page = textBook.page(at: pageNum + 1) {
@ -307,7 +348,10 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
}
if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: effectivePageNum + 1)
_ = runtime.prepareOnDemandChapter(
forAbsolutePageNumber: effectivePageNum + 1,
allowSynchronousLoad: false
)
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1)
}

View File

@ -6,6 +6,8 @@ final class RDEPUBChapterLoader {
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
var onDeferredCFIMapReady: ((Int) -> Void)?
init(context: RDEPUBReaderContext) {
self.context = context
}
@ -32,6 +34,11 @@ final class RDEPUBChapterLoader {
if let cached = store.chapterData(for: spineIndex) {
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex),
store: store
)
DispatchQueue.main.async {
completion(.success(cached))
}
@ -83,6 +90,11 @@ final class RDEPUBChapterLoader {
renderSignature: cacheKey.renderSignature
)
store.insertPageCount(pc, for: cacheKey)
self.scheduleDeferredCFIMapBuildIfNeeded(
for: chapter,
cacheKey: cacheKey,
store: store
)
switch priority {
case .navigation:
@ -129,6 +141,13 @@ final class RDEPUBChapterLoader {
store: RDEPUBChapterRuntimeStore?
) throws -> RDEPUBRuntimeChapter {
if let cached = store?.chapterData(for: spineIndex) {
if let store {
scheduleDeferredCFIMapBuildIfNeeded(
for: cached,
cacheKey: makeCacheKey(spineIndex: spineIndex),
store: store
)
}
return cached
}
@ -170,6 +189,11 @@ final class RDEPUBChapterLoader {
renderSignature: cacheKey.renderSignature
)
store.insertPageCount(pageCount, for: cacheKey)
self.scheduleDeferredCFIMapBuildIfNeeded(
for: chapter,
cacheKey: cacheKey,
store: store
)
return .success(chapter)
}
}
@ -300,13 +324,7 @@ final class RDEPUBChapterLoader {
fragmentOffsets: rendered.fragmentOffsets,
pageStartOffsets: pages.map { $0.pageStartOffset },
pageEndOffsets: pages.map { $0.pageEndOffset },
cfiMap: diskSummary?.cfiMap ?? makeCFIMap(
href: href,
spineIndex: spineIndex,
fragmentOffsets: rendered.fragmentOffsets,
rawHTML: rawHTML,
chapterText: typesetString.string
),
cfiMap: diskSummary?.cfiMap,
chapterText: typesetString.string
)
@ -459,30 +477,13 @@ final class RDEPUBChapterLoader {
fragmentOffsets: chapter.fragmentOffsets,
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
pageEndOffsets: chapter.pages.map { $0.pageEndOffset },
cfiMap: chapter.cfiMap ?? makeCFIMap(
href: chapter.href,
spineIndex: spineIndex,
fragmentOffsets: chapter.fragmentOffsets,
rawHTML: context.parser?.htmlString(forRelativePath: chapter.href),
chapterText: chapter.attributedContent.string
),
cfiMap: chapter.cfiMap,
chapterText: chapter.attributedContent.string
)
let pageRanges = chapter.pages.map { $0.contentRange }
let cacheKey = makeCacheKey(spineIndex: spineIndex)
let summary = RDEPUBChapterSummary(
pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
cfiMap: offsetMap.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: chapter.pages.map { .from($0.metadata) }
)
summaryDiskCache?.write(summary: summary, for: cacheKey)
summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
return RDEPUBRuntimeChapter(
spineIndex: spineIndex,
@ -522,6 +523,69 @@ final class RDEPUBChapterLoader {
)
}
private func scheduleDeferredCFIMapBuildIfNeeded(
for chapter: RDEPUBRuntimeChapter,
cacheKey: RDEPUBChapterCacheKey,
store: RDEPUBChapterRuntimeStore
) {
guard chapter.chapterOffsetMap.cfiMap == nil,
store.beginBuildingCFIMap(for: chapter.spineIndex) else {
return
}
let spineIndex = chapter.spineIndex
let href = chapter.href
let fragmentOffsets = chapter.chapterOffsetMap.fragmentOffsets
let chapterText = chapter.chapterOffsetMap.chapterText
store.chapterLoadQueue.async {
defer { store.endBuildingCFIMap(for: spineIndex) }
guard let rawHTML = self.context.parser?.htmlString(forRelativePath: href),
let chapterText else {
return
}
let cfiMap = self.makeCFIMap(
href: href,
spineIndex: spineIndex,
fragmentOffsets: fragmentOffsets,
rawHTML: rawHTML,
chapterText: chapterText
)
chapter.updateCFIMap(cfiMap)
self.summaryDiskCache?.write(
summary: self.makeSummary(
for: chapter.pages,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets,
offsetMap: chapter.chapterOffsetMap,
cacheKey: cacheKey
),
for: cacheKey
)
DispatchQueue.main.async {
self.onDeferredCFIMapReady?(spineIndex)
}
}
}
private func makeSummary(
for pages: [RDEPUBTextPage],
fragmentOffsets: [String: Int],
offsetMap: RDEPUBChapterOffsetMap,
cacheKey: RDEPUBChapterCacheKey
) -> RDEPUBChapterSummary {
RDEPUBChapterSummary(
pageRanges: pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: pages.count,
fragmentOffsets: fragmentOffsets,
cfiMap: offsetMap.cfiMap,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: pages.map { .from($0.metadata) }
)
}
private func contentHashForSpineIndex(_ spineIndex: Int) -> String {
guard let parser = context.parser,
let publication = context.publication else { return "" }

View File

@ -1,6 +1,6 @@
import Foundation
struct RDEPUBChapterOffsetMap {
final class RDEPUBChapterOffsetMap {
let fragmentOffsets: [String: Int]
@ -8,10 +8,38 @@ struct RDEPUBChapterOffsetMap {
let pageEndOffsets: [Int]
let cfiMap: RDEPUBCFIMap?
private let cfiMapLock = NSLock()
private var _cfiMap: RDEPUBCFIMap?
var cfiMap: RDEPUBCFIMap? {
cfiMapLock.lock()
defer { cfiMapLock.unlock() }
return _cfiMap
}
let chapterText: String?
init(
fragmentOffsets: [String: Int],
pageStartOffsets: [Int],
pageEndOffsets: [Int],
cfiMap: RDEPUBCFIMap?,
chapterText: String?
) {
self.fragmentOffsets = fragmentOffsets
self.pageStartOffsets = pageStartOffsets
self.pageEndOffsets = pageEndOffsets
self._cfiMap = cfiMap
self.chapterText = chapterText
}
func updateCFIMap(_ cfiMap: RDEPUBCFIMap) {
cfiMapLock.lock()
_cfiMap = cfiMap
cfiMapLock.unlock()
}
func chapterOffset(forFragmentID fragmentID: String) -> Int? {
return fragmentOffsets[fragmentID]
}

View File

@ -28,6 +28,10 @@ final class RDEPUBChapterRuntimeStore {
private let buildingLock = NSLock()
private var buildingCFIMapSpineIndices: Set<Int> = []
private let cfiMapLock = NSLock()
init() {
imageCache.countLimit = 50
@ -142,9 +146,25 @@ final class RDEPUBChapterRuntimeStore {
buildingLock.unlock()
}
func beginBuildingCFIMap(for spineIndex: Int) -> Bool {
cfiMapLock.lock()
defer { cfiMapLock.unlock() }
let inserted = buildingCFIMapSpineIndices.insert(spineIndex).inserted
return inserted
}
func endBuildingCFIMap(for spineIndex: Int) {
cfiMapLock.lock()
buildingCFIMapSpineIndices.remove(spineIndex)
cfiMapLock.unlock()
}
func invalidateAllForSettingsChange() {
chapterDataCache.removeAll()
pageCountCache.removeAll()
imageCache.removeAllObjects()
cfiMapLock.lock()
buildingCFIMapSpineIndices.removeAll()
cfiMapLock.unlock()
}
}

View File

@ -0,0 +1,611 @@
import Foundation
final class RDEPUBChapterWarmupOrchestrator {
private unowned let context: RDEPUBReaderContext
private unowned let store: RDEPUBChapterRuntimeStore
private unowned let loader: RDEPUBChapterLoader
private unowned let presentationRuntime: RDEPUBPresentationRuntime
private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
private unowned let backgroundPriorityManager: RDEPUBBackgroundPriorityManager
private unowned let jumpSessionManager: RDEPUBJumpSessionManager
private let refreshVisibleContentPreservingLocation: () -> Void
private let asyncLoadStateLock = NSLock()
private var asynchronouslyPreparingSpineIndices: Set<Int> = []
private var isExtendingPartialBookPageMap = false
init(
context: RDEPUBReaderContext,
store: RDEPUBChapterRuntimeStore,
loader: RDEPUBChapterLoader,
presentationRuntime: RDEPUBPresentationRuntime,
locationCoordinator: RDEPUBReaderLocationCoordinator,
backgroundPriorityManager: RDEPUBBackgroundPriorityManager,
jumpSessionManager: RDEPUBJumpSessionManager,
refreshVisibleContentPreservingLocation: @escaping () -> Void
) {
self.context = context
self.store = store
self.loader = loader
self.presentationRuntime = presentationRuntime
self.locationCoordinator = locationCoordinator
self.backgroundPriorityManager = backgroundPriorityManager
self.jumpSessionManager = jumpSessionManager
self.refreshVisibleContentPreservingLocation = refreshVisibleContentPreservingLocation
}
@discardableResult
func prepareOnDemandChapter(
forAbsolutePageNumber pageNumber: Int,
allowSynchronousLoad: Bool = true,
completion: ((Bool) -> Void)? = nil
) -> Bool {
guard let bookPageMap = context.bookPageMap,
let publication = context.publication else {
return false
}
let absolutePageIndex = pageNumber - 1
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
return false
}
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
presentationRuntime.navigationStateMachine.transition(to: .preparingChapter(spineIndex: spineIndex))
RDEPUBBackgroundTrace.log(
"Runtime",
"prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)"
)
if store.chapterData(for: spineIndex) == nil {
guard allowSynchronousLoad else {
scheduleAsynchronousChapterPreparation(
spineIndex: spineIndex,
triggerPageNumber: pageNumber,
completion: completion
)
return false
}
do {
_ = try loader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: store
)
} catch {
RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)")
return false
}
}
presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
completion?(true)
maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap)
scheduleAdjacentChapterPrefetches(for: spineIndex, totalSpineCount: publication.spine.count)
return true
}
func extendPartialBookPageMapIfNeeded(
currentPageNumber: Int,
minimumTrailingPages: Int = 2,
batchChapterCount: Int = 3
) {
guard let publication = context.publication,
let currentMap = context.bookPageMap else {
return
}
let buildableSpineIndices = buildableSpineIndices(in: publication)
guard currentMap.totalChapters < buildableSpineIndices.count else {
return
}
let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages
let isNearStart = currentPageNumber <= minimumTrailingPages
var spineIndicesToAppend: [Int] = []
if isNearEnd {
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount))
} else if isNearStart {
let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max
let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex }
spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount))
} else {
return
}
guard !spineIndicesToAppend.isEmpty, beginPartialBookPageMapExtension() else {
return
}
RDEPUBBackgroundTrace.log(
"Runtime",
"extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(spineIndicesToAppend) direction=\(isNearEnd ? "forward" : "backward")"
)
let currentLocation = locationCoordinator.currentVisibleLocation()
let loadedChaptersLock = NSLock()
var loadedChapters: [Int: RDEPUBRuntimeChapter] = [:]
let group = DispatchGroup()
for spineIndex in spineIndicesToAppend {
group.enter()
loader.loadChapter(
spineIndex: spineIndex,
store: store,
priority: .prefetch
) { result in
defer { group.leave() }
switch result {
case .success(let chapter):
loadedChaptersLock.lock()
loadedChapters[spineIndex] = chapter
loadedChaptersLock.unlock()
case .failure(let error):
RDEPUBBackgroundTrace.log("Runtime", "extendPartialBookPageMap skip spine=\(spineIndex) error=\(error)")
}
}
}
group.notify(queue: .main) { [weak self] in
guard let self else { return }
defer { self.endPartialBookPageMapExtension() }
self.applyAsyncPartialBookPageMapExtension(
currentPageNumber: currentPageNumber,
currentLocation: currentLocation,
currentMap: currentMap,
loadedChapters: loadedChapters
)
}
}
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
guard context.publication != nil else { return }
store.setCurrentChapter(
spineIndex: anchorSpineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
let forwardTargets = store.windowSpineIndices.filter { $0 > anchorSpineIndex }
guard !forwardTargets.isEmpty else { return }
for spineIndex in forwardTargets {
if store.chapterData(for: spineIndex) != nil {
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
continue
}
store.addPrefetchTarget(spineIndex)
RDEPUBBackgroundTrace.log("Runtime", "initial open prefetch forward spine=\(spineIndex)")
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in
guard let self, case .success = result else { return }
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
}
}
}
@discardableResult
func ensureNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
guard context.bookPageMap != nil,
let publication = context.publication,
let targetSpineIndex = context.normalizedSpineIndex(for: location) else {
return false
}
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let isDistantJump = if let current = currentSpineIndex {
abs(current - targetSpineIndex) > context.configuration.jumpSessionPolicy.protectedNeighborRadius * 2
} else {
false
}
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
}
return true
}
if let pendingMap = context.pendingFullPageMap,
pendingMap.entry(forSpineIndex: targetSpineIndex) != nil {
presentationRuntime.applyPendingFullPageMapIfNeeded()
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
}
return true
}
}
let buildableIndices = buildableSpineIndices(in: publication)
guard let anchorPosition = buildableIndices.firstIndex(of: targetSpineIndex) else {
return false
}
let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize(
context.configuration.onDemandChapterWindowSize
)
let chapters = loadPartialWindowChapters(
around: anchorPosition,
in: buildableIndices,
targetSpineIndex: targetSpineIndex,
windowSize: normalizedWindowSize
)
guard !chapters.isEmpty else { return false }
store.setCurrentChapter(
spineIndex: targetSpineIndex,
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
let partialMap = makePartialPageMap(from: chapters)
context.bookPageMap = partialMap
context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: partialMap))
context.readerView?.reloadData()
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
}
return partialMap.entry(forSpineIndex: targetSpineIndex) != nil
}
func clear() {
asyncLoadStateLock.lock()
asynchronouslyPreparingSpineIndices.removeAll()
isExtendingPartialBookPageMap = false
asyncLoadStateLock.unlock()
}
private func applyAsyncPartialBookPageMapExtension(
currentPageNumber: Int,
currentLocation: RDEPUBLocation?,
currentMap: RDEPUBBookPageMap,
loadedChapters: [Int: RDEPUBRuntimeChapter]
) {
let appendedEntries = loadedChapters.keys.sorted().compactMap { spineIndex -> RDEPUBBookPageMapEntry? in
guard let chapter = loadedChapters[spineIndex] else { return nil }
return RDEPUBBookPageMapEntry(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
absolutePageStart: 0,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
}
guard !appendedEntries.isEmpty else {
return
}
let combinedEntries = (currentMap.entries.map {
RDEPUBBookPageMapEntry(
spineIndex: $0.spineIndex,
href: $0.href,
title: $0.title,
pageCount: $0.pageCount,
absolutePageStart: 0,
fragmentOffsets: $0.fragmentOffsets
)
} + appendedEntries).sorted { $0.spineIndex < $1.spineIndex }
var absolutePageStart = 0
let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in
let normalized = RDEPUBBookPageMapEntry(
spineIndex: entry.spineIndex,
href: entry.href,
title: entry.title,
pageCount: entry.pageCount,
absolutePageStart: absolutePageStart,
fragmentOffsets: entry.fragmentOffsets
)
absolutePageStart += entry.pageCount
return normalized
}
let newMap = RDEPUBBookPageMap(entries: normalizedEntries)
RDEPUBBackgroundTrace.log(
"Runtime",
"extendPartialBookPageMap applied chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
)
presentationRuntime.applyExtendedPartialPageMap(
newMap,
currentPageNumber: currentPageNumber,
currentLocation: currentLocation
)
}
private func scheduleAsynchronousChapterPreparation(
spineIndex: Int,
triggerPageNumber: Int,
completion: ((Bool) -> Void)?
) {
guard beginAsynchronousChapterPreparation(for: spineIndex) else { return }
RDEPUBBackgroundTrace.log(
"Runtime",
"prepareOnDemandChapter async spine=\(spineIndex) page=\(triggerPageNumber)"
)
loader.loadChapter(
spineIndex: spineIndex,
store: store,
priority: .preview
) { [weak self] result in
guard let self else { return }
self.endAsynchronousChapterPreparation(for: spineIndex)
switch result {
case .success:
self.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
completion?(true)
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber)
case .failure(let error):
RDEPUBBackgroundTrace.log(
"Runtime",
"prepareOnDemandChapter async FAILED: spine=\(spineIndex) error=\(error)"
)
completion?(false)
}
}
}
private func scheduleAdjacentChapterPrefetches(for spineIndex: Int, totalSpineCount: Int) {
store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
for evictable in store.evictableSpineIndices() {
store.evict(spineIndex: evictable)
}
for adjacentSpineIndex in store.windowSpineIndices where adjacentSpineIndex != spineIndex {
guard store.chapterData(for: adjacentSpineIndex) == nil else { continue }
store.addPrefetchTarget(adjacentSpineIndex)
RDEPUBBackgroundTrace.log(
"Runtime",
"schedule prefetch currentSpine=\(spineIndex) adjacentSpine=\(adjacentSpineIndex)"
)
loader.loadChapter(
spineIndex: adjacentSpineIndex,
store: store,
priority: .prefetch
) { [weak self] result in
guard let self, case .success = result else { return }
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
}
}
}
private func maybePrefetchUpcomingChapters(
aroundAbsolutePageNumber pageNumber: Int,
in bookPageMap: RDEPUBBookPageMap,
threshold: Int = 3,
lookaheadChapterCount: Int = 2
) {
guard let publication = context.publication else { return }
let absolutePageIndex = pageNumber - 1
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
let chapter = store.chapterData(for: spineIndex) else {
return
}
let remainingPages = chapter.pages.count - localPageIndex - 1
guard remainingPages <= threshold else { return }
let buildableIndices = buildableSpineIndices(in: publication)
guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return }
let targets = buildableIndices.dropFirst(currentPosition + 1).prefix(lookaheadChapterCount)
for targetSpineIndex in targets {
guard store.chapterData(for: targetSpineIndex) == nil else { continue }
store.addPrefetchTarget(targetSpineIndex)
RDEPUBBackgroundTrace.log(
"Runtime",
"boundary prefetch currentSpine=\(spineIndex) targetSpine=\(targetSpineIndex) remainingPages=\(remainingPages)"
)
loader.loadChapter(spineIndex: targetSpineIndex, store: store, priority: .prefetch) { [weak self] result in
guard let self, case .success = result else { return }
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
}
}
}
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() {
guard let publication = context.publication,
let currentMap = context.bookPageMap,
let readerView = context.readerView,
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
return
}
let buildableIndices = buildableSpineIndices(in: publication)
var appendedEntries: [RDEPUBBookPageMapEntry] = []
for spineIndex in buildableIndices where spineIndex > lastKnownSpineIndex {
guard let chapter = store.chapterData(for: spineIndex) else { break }
appendedEntries.append(
RDEPUBBookPageMapEntry(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
absolutePageStart: 0,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
)
}
guard !appendedEntries.isEmpty else { return }
let existingEntries = currentMap.entries.map {
RDEPUBBookPageMapEntry(
spineIndex: $0.spineIndex,
href: $0.href,
title: $0.title,
pageCount: $0.pageCount,
absolutePageStart: 0,
fragmentOffsets: $0.fragmentOffsets
)
}
var absolutePageStart = 0
let newEntries = (existingEntries + appendedEntries).map { entry -> RDEPUBBookPageMapEntry in
let normalizedEntry = RDEPUBBookPageMapEntry(
spineIndex: entry.spineIndex,
href: entry.href,
title: entry.title,
pageCount: entry.pageCount,
absolutePageStart: absolutePageStart,
fragmentOffsets: entry.fragmentOffsets
)
absolutePageStart += entry.pageCount
return normalizedEntry
}
let newMap = RDEPUBBookPageMap(entries: newEntries)
guard newMap.totalPages > currentMap.totalPages else { return }
RDEPUBBackgroundTrace.log(
"Runtime",
"appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
)
context.bookPageMap = newMap
context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: newMap))
readerView.reloadPageCountOnly()
}
private func refreshVisibleContentIfNeeded(afterPreparing spineIndex: Int, triggerPageNumber: Int) {
guard let readerView = context.readerView,
let bookPageMap = context.bookPageMap else {
return
}
let visiblePageNumber = readerView.currentPage + 1
if visiblePageNumber == triggerPageNumber {
refreshVisibleContentPreservingLocation()
return
}
guard visiblePageNumber > 0,
let visibleSpineIndex = bookPageMap.spineIndex(forAbsolutePage: visiblePageNumber - 1),
visibleSpineIndex == spineIndex else {
return
}
refreshVisibleContentPreservingLocation()
}
// Intentional synchronous path: called from TOC distant jumps where
// the user expects immediate navigation. The loading indicator is shown
// by the caller. Do NOT convert to async without UX consideration.
private func loadPartialWindowChapters(
around anchorPosition: Int,
in buildableSpineIndices: [Int],
targetSpineIndex: Int,
windowSize: Int
) -> [RDEPUBRuntimeChapter] {
let lowerBound = max(anchorPosition - max(windowSize / 2, 0), 0)
let upperBound = min(lowerBound + max(windowSize, 1), buildableSpineIndices.count)
let startIndex = max(0, upperBound - max(windowSize, 1))
let window = Array(buildableSpineIndices[startIndex..<upperBound])
var chapters: [RDEPUBRuntimeChapter] = []
for spineIndex in window {
do {
let chapter = try loader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: store
)
chapters.append(chapter)
} catch {
if spineIndex == targetSpineIndex {
RDEPUBBackgroundTrace.log(
"Runtime",
"ensureNavigationTarget FAILED target spine=\(spineIndex) error=\(error)"
)
return []
}
RDEPUBBackgroundTrace.log(
"Runtime",
"ensureNavigationTarget skip adjacent spine=\(spineIndex) error=\(error)"
)
}
}
return chapters
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
builder.add(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
}
return builder.build()
}
private func buildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
publication.spine.indices.filter { index in
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
}
private func beginAsynchronousChapterPreparation(for spineIndex: Int) -> Bool {
asyncLoadStateLock.lock()
defer { asyncLoadStateLock.unlock() }
return asynchronouslyPreparingSpineIndices.insert(spineIndex).inserted
}
private func endAsynchronousChapterPreparation(for spineIndex: Int) {
asyncLoadStateLock.lock()
asynchronouslyPreparingSpineIndices.remove(spineIndex)
asyncLoadStateLock.unlock()
}
private func beginPartialBookPageMapExtension() -> Bool {
asyncLoadStateLock.lock()
defer { asyncLoadStateLock.unlock() }
guard !isExtendingPartialBookPageMap else { return false }
isExtendingPartialBookPageMap = true
return true
}
private func endPartialBookPageMapExtension() {
asyncLoadStateLock.lock()
isExtendingPartialBookPageMap = false
asyncLoadStateLock.unlock()
}
}

View File

@ -45,4 +45,8 @@ final class RDEPUBRuntimeChapter {
func releaseSourceText() {
sourceAttributedString = nil
}
func updateCFIMap(_ cfiMap: RDEPUBCFIMap) {
chapterOffsetMap.updateCFIMap(cfiMap)
}
}

View File

@ -0,0 +1,77 @@
import Foundation
enum RDEPUBNavigationState: Equatable {
case idle
case initialLoading
case restoringLocation
case preparingChapter(spineIndex: Int)
case presentingWindow
case reconcilingFullMap
case repaginating
}
final class RDEPUBNavigationStateMachine {
private let lock = NSLock()
private(set) var state: RDEPUBNavigationState = .idle
func transition(to newState: RDEPUBNavigationState) {
lock.lock()
let oldState = state
state = newState
lock.unlock()
#if DEBUG
validateTransition(from: oldState, to: newState)
#endif
}
#if DEBUG
private func validateTransition(from oldState: RDEPUBNavigationState, to newState: RDEPUBNavigationState) {
let isValid: Bool
switch (oldState, newState) {
case (.idle, .initialLoading),
(.idle, .preparingChapter),
(.idle, .restoringLocation),
(.idle, .idle):
isValid = true
case (.initialLoading, .preparingChapter),
(.initialLoading, .presentingWindow),
(.initialLoading, .idle),
(.initialLoading, .initialLoading):
isValid = true
case (.restoringLocation, .preparingChapter),
(.restoringLocation, .presentingWindow),
(.restoringLocation, .idle),
(.restoringLocation, .restoringLocation):
isValid = true
case (.preparingChapter, .presentingWindow),
(.preparingChapter, .preparingChapter),
(.preparingChapter, .idle):
isValid = true
case (.presentingWindow, .idle),
(.presentingWindow, .reconcilingFullMap),
(.presentingWindow, .preparingChapter),
(.presentingWindow, .presentingWindow),
(.presentingWindow, .repaginating):
isValid = true
case (.reconcilingFullMap, .presentingWindow),
(.reconcilingFullMap, .idle),
(.reconcilingFullMap, .reconcilingFullMap):
isValid = true
case (.repaginating, .presentingWindow),
(.repaginating, .idle),
(.repaginating, .repaginating):
isValid = true
default:
isValid = false
}
if !isValid {
RDEPUBBackgroundTrace.log(
"NavigationStateMachine",
"WARNING: unexpected transition \(oldState)\(newState)"
)
}
}
#endif
}

View File

@ -0,0 +1,22 @@
import Foundation
enum RDEPUBPaginationStateSource: String {
case initialPartial
case asyncExtension
case pendingFullMap
case fullReplacement
case settingsPreview
case cacheRestore
case repagination
}
struct RDEPUBPaginationState {
var activePageMap: RDEPUBBookPageMap?
var pendingFullPageMap: RDEPUBBookPageMap?
var chapterWindowSnapshot: RDEPUBChapterWindowSnapshot?
var source: RDEPUBPaginationStateSource = .initialPartial
}

View File

@ -0,0 +1,184 @@
import UIKit
final class RDEPUBPresentationRuntime {
private unowned let context: RDEPUBReaderContext
private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
private unowned let jumpSessionManager: RDEPUBJumpSessionManager
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
let navigationStateMachine = RDEPUBNavigationStateMachine()
private(set) var paginationState = RDEPUBPaginationState()
init(
context: RDEPUBReaderContext,
locationCoordinator: RDEPUBReaderLocationCoordinator,
jumpSessionManager: RDEPUBJumpSessionManager,
reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
) {
self.context = context
self.locationCoordinator = locationCoordinator
self.jumpSessionManager = jumpSessionManager
self.reconciliationCoordinator = reconciliationCoordinator
}
func applyBookPageMap(
_ bookPageMap: RDEPUBBookPageMap,
restoreLocation: RDEPUBLocation?,
finishPagination: (RDEPUBLocation?) -> Void
) {
navigationStateMachine.transition(to: .presentingWindow)
context.textBook = nil
context.bookPageMap = bookPageMap
context.pendingFullPageMap = nil
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationState.activePageMap = bookPageMap
paginationState.pendingFullPageMap = nil
paginationState.source = .initialPartial
finishPagination(restoreLocation)
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
if let pendingMap = context.pendingFullPageMap {
let shouldKeepExisting =
pendingMap.totalChapters > bookPageMap.totalChapters ||
(pendingMap.totalChapters == bookPageMap.totalChapters &&
pendingMap.totalPages >= bookPageMap.totalPages)
if shouldKeepExisting {
return
}
}
navigationStateMachine.transition(to: .reconcilingFullMap)
context.pendingFullPageMap = bookPageMap
paginationState.pendingFullPageMap = bookPageMap
}
func applyPendingFullPageMapIfNeeded() {
guard let pendingMap = context.pendingFullPageMap,
let readerView = context.readerView,
let controller = context.controller else { return }
guard !controller.isRepaginating else { return }
navigationStateMachine.transition(to: .reconcilingFullMap)
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: pendingMap,
candidateSegment: nil,
currentWindow: context.bookPageMap,
jumpSession: jumpSessionManager.activeSession
)
switch decision {
case .keepCurrentWindow:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow")
case .fullReplace(let newPageMap):
RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace")
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
case .expandWindow, .segmentReplace:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
}
}
func applyExtendedPartialPageMap(
_ bookPageMap: RDEPUBBookPageMap,
currentPageNumber: Int,
currentLocation: RDEPUBLocation?
) {
guard let readerView = context.readerView else { return }
navigationStateMachine.transition(to: .presentingWindow)
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationState.activePageMap = bookPageMap
paginationState.source = .asyncExtension
readerView.reloadPageCountOnly()
if let currentLocation,
locationCoordinator.restoreReadingLocation(currentLocation, animated: false) {
return
}
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
}
func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) {
navigationStateMachine.transition(to: .presentingWindow)
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationState.activePageMap = bookPageMap
paginationState.source = .settingsPreview
}
func clear() {
paginationState = RDEPUBPaginationState()
navigationStateMachine.transition(to: .idle)
}
func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
let pages = bookPageMap.entries.flatMap { entry in
(0..<entry.pageCount).map { localPageIndex in
EPUBPage(
spineIndex: entry.spineIndex,
chapterIndex: bookPageMap.chapterIndex(forSpineIndex: entry.spineIndex) ?? 0,
pageIndexInChapter: localPageIndex,
totalPagesInChapter: entry.pageCount,
chapterTitle: entry.title,
fixedSpread: nil
)
}
}
let chapters = bookPageMap.entries.map { entry in
EPUBChapterInfo(
spineIndex: entry.spineIndex,
title: entry.title,
pageCount: entry.pageCount
)
}
return (pages, chapters)
}
private func applyFullPageMapReplacement(
_ newPageMap: RDEPUBBookPageMap,
readerView: RDReaderView,
controller: RDEPUBReaderController
) {
let currentLocation = locationCoordinator.currentVisibleLocation()
context.pendingFullPageMap = nil
context.textBook = nil
context.bookPageMap = newPageMap
context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
paginationState.activePageMap = newPageMap
paginationState.pendingFullPageMap = nil
paginationState.source = .fullReplacement
navigationStateMachine.transition(to: .presentingWindow)
if let currentLocation {
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
let newPage = max(0, newPageNumber - 1)
readerView.reloadPageCountOnly()
if newPage != readerView.currentPage {
readerView.transitionToPage(pageNum: newPage, animated: false)
}
} else {
readerView.reloadPageCountOnly()
}
if let currentLocation,
context.normalizedSpineIndex(for: currentLocation) != nil,
let activeSession = jumpSessionManager.activeSession {
let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex })
let protectedIndices = activeSession.protectedSpineIndices
if protectedIndices.isSubset(of: candidateIndices) {
jumpSessionManager.endSession(.coverageComplete)
}
}
}
}

View File

@ -17,7 +17,7 @@ final class RDEPUBReaderAssemblyCoordinator {
setupErrorLabel(controller.errorLabel, in: controller.view)
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
#if DEBUG
print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
print("[ReadViewDemo] assembleInterface: pageProvider=\(readerView.pageProvider != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
#endif
}
@ -48,7 +48,7 @@ final class RDEPUBReaderAssemblyCoordinator {
}
private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) {
readerView.dataSource = context.controller
readerView.pageProvider = context.controller
readerView.delegate = context.controller
readerView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(readerView)

View File

@ -10,54 +10,108 @@ final class RDEPUBReaderContext {
weak var readerView: RDReaderView?
var dependencies: RDEPUBReaderDependencies = .live
let state: RDEPUBReaderState
let environment: RDEPUBReaderEnvironment
let services: RDEPUBReaderServices
var dependencies: RDEPUBReaderDependencies {
get { services.dependencies }
set {
services.dependencies = newValue
environment.displayEnvironment = newValue.environment
}
}
var runtime: RDEPUBReaderRuntime? {
controller?.runtime
}
var parser: RDEPUBParser?
var parser: RDEPUBParser? {
get { state.parser }
set { state.parser = newValue }
}
var publication: RDEPUBPublication?
var publication: RDEPUBPublication? {
get { state.publication }
set { state.publication = newValue }
}
var readingSession: RDEPUBReadingSession?
var readingSession: RDEPUBReadingSession? {
get { state.readingSession }
set { state.readingSession = newValue }
}
var textBook: RDEPUBTextBook?
var textBook: RDEPUBTextBook? {
get { state.textBook }
set { state.textBook = newValue }
}
var bookPageMap: RDEPUBBookPageMap?
var bookPageMap: RDEPUBBookPageMap? {
get { state.bookPageMap }
set { state.bookPageMap = newValue }
}
var activeBookmarks: [RDEPUBBookmark] = []
var activeBookmarks: [RDEPUBBookmark] {
get { state.activeBookmarks }
set { state.activeBookmarks = newValue }
}
var activeHighlights: [RDEPUBHighlight] = []
var activeHighlights: [RDEPUBHighlight] {
get { state.activeHighlights }
set { state.activeHighlights = newValue }
}
var currentBookIdentifier: String?
var currentBookIdentifier: String? {
get { state.currentBookIdentifier }
set { state.currentBookIdentifier = newValue }
}
var paginationToken = UUID()
var paginationToken: UUID {
get { state.paginationToken }
set { state.paginationToken = newValue }
}
var paginator: RDEPUBPaginator?
var paginator: RDEPUBPaginator? {
get { state.paginator }
set { state.paginator = newValue }
}
var searchState: RDEPUBSearchState?
var searchState: RDEPUBSearchState? {
get { state.searchState }
set { state.searchState = newValue }
}
var pendingFullPageMap: RDEPUBBookPageMap?
var pendingFullPageMap: RDEPUBBookPageMap? {
get { state.pendingFullPageMap }
set { state.pendingFullPageMap = newValue }
}
var lastTextPaginationPageSize: CGSize?
var lastTextPaginationPageSize: CGSize? {
get { state.lastTextPaginationPageSize }
set { state.lastTextPaginationPageSize = newValue }
}
var lastMetadataParseWallClockMs: Int = 0
var lastMetadataParseWallClockMs: Int {
get { state.lastMetadataParseWallClockMs }
set { state.lastMetadataParseWallClockMs = newValue }
}
var lastMetadataParseConcurrency: Int = 0
var lastMetadataParseConcurrency: Int {
get { state.lastMetadataParseConcurrency }
set { state.lastMetadataParseConcurrency = newValue }
}
var currentSelection: RDEPUBSelection? {
get { selectionState.selection }
set {
if let newValue, !newValue.isEmpty {
selectionState = .selected(newValue)
} else {
selectionState = .idle
}
}
get { state.currentSelection }
set { state.currentSelection = newValue }
}
var selectionState: RDEPUBSelectionState = .idle
var selectionState: RDEPUBSelectionState {
get { state.selectionState }
set { state.selectionState = newValue }
}
var configuration: RDEPUBReaderConfiguration = .default
@ -65,32 +119,43 @@ final class RDEPUBReaderContext {
var epubURL: URL = URL(string: "about:blank")!
var isRepaginating: Bool = false
var isRepaginating: Bool {
get { state.isRepaginating }
set { state.isRepaginating = newValue }
}
var didStartInitialLoad: Bool = false
var didStartInitialLoad: Bool {
get { state.didStartInitialLoad }
set { state.didStartInitialLoad = newValue }
}
var isExternalTextBook: Bool = false
var isExternalTextBook: Bool {
get { state.isExternalTextBook }
set { state.isExternalTextBook = newValue }
}
var textFileURL: URL?
var textFileURL: URL? {
get { state.textFileURL }
set { state.textFileURL = newValue }
}
var textBookCache = RDEPUBTextBookCache()
var textBookCache: RDEPUBTextBookCache { state.textBookCache }
init(controller: RDEPUBReaderController) {
self.controller = controller
self.readerView = controller.readerView
let state = RDEPUBReaderState()
self.state = state
self.environment = RDEPUBReaderEnvironment(
controller: controller,
readerView: controller.readerView,
displayEnvironment: RDEPUBUIScreenEnvironment()
)
self.services = RDEPUBReaderServices(dependencies: .live)
}
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
let containerSize = readerView?.bounds.size ?? .zero
let viewSize = controller?.view.bounds.size ?? containerSize
let resolvedSize = containerSize == .zero ? viewSize : containerSize
return RDEPUBNavigatorLayoutContext(
containerSize: resolvedSize,
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
safeAreaInsets: controller?.view.safeAreaInsets ?? .zero,
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
reflowableContentInsets: configuration.reflowableContentInsets
)
environment.currentLayoutContext(configuration: configuration)
}
func currentPreferences() -> RDEPUBPreferences {
@ -122,82 +187,60 @@ final class RDEPUBReaderContext {
return mainThreadSize
}
}
return dependencies.environment.fallbackViewportSize
return environment.fallbackViewportSize
}
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
return RDEPUBTextRenderStyle(
font: font,
lineSpacing: lineSpacing,
textColor: configuration.theme.contentTextColor,
backgroundColor: configuration.theme.contentBackgroundColor
)
environment.currentTextRenderStyle(configuration: configuration)
}
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
return RDEPUBTextLayoutConfig(
frameWidth: max(pageSize.width, 1),
frameHeight: max(pageSize.height, 1),
edgeInsets: configuration.reflowableContentInsets,
numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap,
avoidOrphans: false,
avoidWidows: false,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85,
fallbackViewportSize: dependencies.environment.fallbackViewportSize
)
environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize)
}
func resolvedTextRenderer() -> RDEPUBTextRenderer {
dependencies.makeTextRenderer(configuration.textRenderingEngine)
services.resolvedTextRenderer(configuration: configuration)
}
var activePages: [EPUBPage] {
readingSession?.activePages ?? []
state.activePages
}
var activeChapters: [EPUBChapterInfo] {
readingSession?.activeChapters ?? []
state.activeChapters
}
var currentBrightness: CGFloat {
get { dependencies.environment.currentBrightness }
set { dependencies.environment.currentBrightness = newValue }
get { environment.currentBrightness }
set { environment.currentBrightness = newValue }
}
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
readingSession?.setActiveSnapshot(snapshot)
state.replaceActiveSnapshot(snapshot)
}
func clearActiveSnapshot() {
readingSession?.resetRuntimeState()
state.clearActiveSnapshot()
}
func makeParser() -> RDEPUBParser {
dependencies.makeParser()
services.makeParser()
}
func makePaginator() -> RDEPUBPaginator {
dependencies.makePaginator()
services.makePaginator()
}
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
services.makeTextBookBuilder(
configuration: configuration,
cache: textBookCache,
layoutConfig: layoutConfig
)
}
func makeChapterSummaryDiskCache() -> RDEPUBChapterSummaryDiskCache {
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let bookID = (currentBookIdentifier ?? "default").sha256Hex
let directory = cachesDirectory
.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
.appendingPathComponent(bookID, isDirectory: true)
return RDEPUBChapterSummaryDiskCache(cacheDirectory: directory)
services.makeChapterSummaryDiskCache(bookIdentifier: currentBookIdentifier)
}
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
@ -272,7 +315,10 @@ final class RDEPUBReaderContext {
}
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
services.makePlainTextBookBuilder(
configuration: configuration,
layoutConfig: layoutConfig
)
}
func currentVisibleLocation() -> RDEPUBLocation? {

View File

@ -0,0 +1,72 @@
import UIKit
final class RDEPUBReaderEnvironment {
weak var controller: RDEPUBReaderController?
weak var readerView: RDReaderView?
var displayEnvironment: any RDEPUBReaderDisplayEnvironment
init(
controller: RDEPUBReaderController,
readerView: RDReaderView,
displayEnvironment: any RDEPUBReaderDisplayEnvironment
) {
self.controller = controller
self.readerView = readerView
self.displayEnvironment = displayEnvironment
}
func currentLayoutContext(configuration: RDEPUBReaderConfiguration) -> RDEPUBNavigatorLayoutContext {
let containerSize = readerView?.bounds.size ?? .zero
let viewSize = controller?.view.bounds.size ?? containerSize
let resolvedSize = containerSize == .zero ? viewSize : containerSize
return RDEPUBNavigatorLayoutContext(
containerSize: resolvedSize,
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
safeAreaInsets: controller?.view.safeAreaInsets ?? .zero,
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
reflowableContentInsets: configuration.reflowableContentInsets
)
}
func currentTextRenderStyle(configuration: RDEPUBReaderConfiguration) -> RDEPUBTextRenderStyle {
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
return RDEPUBTextRenderStyle(
font: font,
lineSpacing: lineSpacing,
textColor: configuration.theme.contentTextColor,
backgroundColor: configuration.theme.contentBackgroundColor
)
}
func currentTextLayoutConfig(
configuration: RDEPUBReaderConfiguration,
pageSize: CGSize
) -> RDEPUBTextLayoutConfig {
RDEPUBTextLayoutConfig(
frameWidth: max(pageSize.width, 1),
frameHeight: max(pageSize.height, 1),
edgeInsets: configuration.reflowableContentInsets,
numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap,
avoidOrphans: false,
avoidWidows: false,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85,
fallbackViewportSize: displayEnvironment.fallbackViewportSize
)
}
var currentBrightness: CGFloat {
get { displayEnvironment.currentBrightness }
set { displayEnvironment.currentBrightness = newValue }
}
var fallbackViewportSize: CGSize {
displayEnvironment.fallbackViewportSize
}
}

View File

@ -87,6 +87,7 @@ final class RDEPUBReaderPaginationCoordinator {
}
controller.isRepaginating = true
context.runtime?.presentationRuntime.navigationStateMachine.transition(to: .repaginating)
controller.errorLabel.isHidden = true
controller.showLoading()
let token = UUID()
@ -181,6 +182,7 @@ final class RDEPUBReaderPaginationCoordinator {
guard let controller = context.controller,
let readerView = context.readerView else { return }
controller.isRepaginating = false
context.runtime?.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
controller.hideLoading()
readerView.reloadData()
if let targetLocation = restoreLocation {

View File

@ -11,6 +11,9 @@ final class RDEPUBReaderRuntime {
lazy var chapterLoader: RDEPUBChapterLoader = {
let loader = RDEPUBChapterLoader(context: context)
loader.setSummaryDiskCache(summaryDiskCache)
loader.onDeferredCFIMapReady = { [weak self] spineIndex in
self?.handleDeferredCFIMapReady(for: spineIndex)
}
return loader
}()
@ -38,6 +41,26 @@ final class RDEPUBReaderRuntime {
lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context)
lazy var presentationRuntime = RDEPUBPresentationRuntime(
context: context,
locationCoordinator: locationCoordinator,
jumpSessionManager: jumpSessionManager,
reconciliationCoordinator: reconciliationCoordinator
)
lazy var chapterWarmupOrchestrator = RDEPUBChapterWarmupOrchestrator(
context: context,
store: chapterRuntimeStore,
loader: chapterLoader,
presentationRuntime: presentationRuntime,
locationCoordinator: locationCoordinator,
backgroundPriorityManager: backgroundPriorityManager,
jumpSessionManager: jumpSessionManager,
refreshVisibleContentPreservingLocation: { [weak self] in
self?.refreshVisibleContentPreservingLocation()
}
)
var isSettingsPanelOpen: Bool = false
var needsFullRepaginationAfterSettingsClose: Bool = false
@ -99,7 +122,7 @@ final class RDEPUBReaderRuntime {
@discardableResult
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
guard let controller = context.controller,
guard context.controller != nil,
let readerView = context.readerView,
pageNumber > 0 else {
return false
@ -313,90 +336,20 @@ final class RDEPUBReaderRuntime {
}
func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) {
context.textBook = nil
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
presentationRuntime.applyBookPageMap(
bookPageMap,
restoreLocation: restoreLocation
) { [weak self] restoreLocation in
self?.paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
}
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
if let pendingMap = context.pendingFullPageMap {
let shouldKeepExisting =
pendingMap.totalChapters > bookPageMap.totalChapters ||
(pendingMap.totalChapters == bookPageMap.totalChapters &&
pendingMap.totalPages >= bookPageMap.totalPages)
if shouldKeepExisting {
return
}
}
context.pendingFullPageMap = bookPageMap
presentationRuntime.refreshBookPageMapInPlace(bookPageMap)
}
func applyPendingFullPageMapIfNeeded() {
guard let pendingMap = context.pendingFullPageMap,
let readerView = context.readerView,
let controller = context.controller else { return }
guard !controller.isRepaginating else { return }
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: pendingMap,
candidateSegment: nil,
currentWindow: context.bookPageMap,
jumpSession: jumpSessionManager.activeSession
)
switch decision {
case .keepCurrentWindow:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow")
return
case .fullReplace(let newPageMap):
RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace")
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
case .expandWindow, .segmentReplace:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
break
}
}
private func applyFullPageMapReplacement(
_ newPageMap: RDEPUBBookPageMap,
readerView: RDReaderView,
controller: RDEPUBReaderController
) {
let currentLocation = locationCoordinator.currentVisibleLocation()
context.pendingFullPageMap = nil
context.textBook = nil
context.bookPageMap = newPageMap
context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
if let currentLocation {
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
let newPage = max(0, newPageNumber - 1)
readerView.reloadPageCountOnly()
if newPage != readerView.currentPage {
readerView.transitionToPage(pageNum: newPage, animated: false)
}
} else {
readerView.reloadPageCountOnly()
}
if let currentLocation,
let currentSpineIndex = context.normalizedSpineIndex(for: currentLocation),
let activeSession = jumpSessionManager.activeSession {
let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex })
let protectedIndices = activeSession.protectedSpineIndices
let isFullyCovered = protectedIndices.isSubset(of: candidateIndices)
if isFullyCovered {
jumpSessionManager.endSession(.coverageComplete)
}
}
presentationRuntime.applyPendingFullPageMapIfNeeded()
}
func finishPagination(restoreLocation: RDEPUBLocation?) {
@ -497,8 +450,7 @@ final class RDEPUBReaderRuntime {
switch result {
case .success(let chapter):
let partialMap = self.makePartialPageMap(from: [chapter])
self.context.bookPageMap = partialMap
self.context.replaceActiveSnapshot(self.makeSnapshot(from: partialMap))
self.presentationRuntime.applySettingsPreviewPageMap(partialMap)
readerView.reloadData()
if let targetPage = self.settingsPreviewTargetPage(
@ -614,282 +566,39 @@ final class RDEPUBReaderRuntime {
@discardableResult
func ensureOnDemandNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
guard context.bookPageMap != nil,
let publication = context.publication,
let targetSpineIndex = context.normalizedSpineIndex(for: location) else {
return false
}
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let isDistantJump = if let current = currentSpineIndex {
abs(current - targetSpineIndex) > context.configuration.jumpSessionPolicy.protectedNeighborRadius * 2
} else {
false
}
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
}
return true
}
if let pendingMap = context.pendingFullPageMap,
pendingMap.entry(forSpineIndex: targetSpineIndex) != nil {
applyPendingFullPageMapIfNeeded()
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
}
return true
}
}
let buildableSpineIndices = publication.spine.indices.filter { index in
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
guard let anchorPosition = buildableSpineIndices.firstIndex(of: targetSpineIndex) else {
return false
}
let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize(
context.configuration.onDemandChapterWindowSize
)
let chapters = loadPartialWindowChapters(
around: anchorPosition,
in: buildableSpineIndices,
targetSpineIndex: targetSpineIndex,
windowSize: normalizedWindowSize
)
guard !chapters.isEmpty else {
return false
}
chapterRuntimeStore.setCurrentChapter(
spineIndex: targetSpineIndex,
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
let partialMap = makePartialPageMap(from: chapters)
context.bookPageMap = partialMap
context.replaceActiveSnapshot(makeSnapshot(from: partialMap))
context.readerView?.reloadData()
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
}
return partialMap.entry(forSpineIndex: targetSpineIndex) != nil
chapterWarmupOrchestrator.ensureNavigationTargetAvailable(for: location)
}
@discardableResult
func prepareOnDemandChapter(forAbsolutePageNumber pageNumber: Int) -> Bool {
guard let bookPageMap = context.bookPageMap,
let publication = context.publication else {
return false
}
let absolutePageIndex = pageNumber - 1
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
return false
}
chapterRuntimeStore.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
func prepareOnDemandChapter(
forAbsolutePageNumber pageNumber: Int,
allowSynchronousLoad: Bool = true,
completion: ((Bool) -> Void)? = nil
) -> Bool {
chapterWarmupOrchestrator.prepareOnDemandChapter(
forAbsolutePageNumber: pageNumber,
allowSynchronousLoad: allowSynchronousLoad,
completion: completion
)
RDEPUBBackgroundTrace.log(
"Runtime",
"prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)"
}
func extendPartialBookPageMapIfNeeded(
currentPageNumber: Int,
minimumTrailingPages: Int = 2,
batchChapterCount: Int = 3
) {
chapterWarmupOrchestrator.extendPartialBookPageMapIfNeeded(
currentPageNumber: currentPageNumber,
minimumTrailingPages: minimumTrailingPages,
batchChapterCount: batchChapterCount
)
if chapterRuntimeStore.chapterData(for: spineIndex) == nil {
do {
_ = try chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: chapterRuntimeStore
)
} catch {
RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)")
return false
}
}
for evictable in chapterRuntimeStore.evictableSpineIndices() {
chapterRuntimeStore.evict(spineIndex: evictable)
}
for adjacentSpineIndex in chapterRuntimeStore.windowSpineIndices where adjacentSpineIndex != spineIndex {
guard chapterRuntimeStore.chapterData(for: adjacentSpineIndex) == nil else { continue }
chapterRuntimeStore.addPrefetchTarget(adjacentSpineIndex)
RDEPUBBackgroundTrace.log(
"Runtime",
"schedule prefetch currentSpine=\(spineIndex) adjacentSpine=\(adjacentSpineIndex)"
)
chapterLoader.loadChapter(
spineIndex: adjacentSpineIndex,
store: chapterRuntimeStore,
priority: .prefetch
) { _ in }
}
return true
}
func extendPartialBookPageMapIfNeeded(currentPageNumber: Int, minimumTrailingPages: Int = 2, batchChapterCount: Int = 3) {
guard let publication = context.publication,
let currentMap = context.bookPageMap,
let readerView = context.readerView else {
return
}
let buildableSpineIndices = publication.spine.indices.filter {
let item = publication.spine[$0]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
guard currentMap.totalChapters < buildableSpineIndices.count else {
return
}
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages
let isNearStart = currentPageNumber <= minimumTrailingPages
var spineIndicesToAppend: [Int] = []
if isNearEnd {
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount))
} else if isNearStart {
let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max
let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex }
spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount))
} else {
return
}
guard !spineIndicesToAppend.isEmpty else {
return
}
RDEPUBBackgroundTrace.log(
"Runtime",
"extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(spineIndicesToAppend) direction=\(isNearEnd ? "forward" : "backward")"
)
var appendedEntries: [RDEPUBBookPageMapEntry] = []
for spineIndex in spineIndicesToAppend {
do {
let chapter = try chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: chapterRuntimeStore
)
appendedEntries.append(
RDEPUBBookPageMapEntry(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
absolutePageStart: 0,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
)
} catch {
RDEPUBBackgroundTrace.log("Runtime", "extendPartialBookPageMap skip spine=\(spineIndex) error=\(error)")
}
}
guard !appendedEntries.isEmpty else {
return
}
let combinedEntries = (currentMap.entries.map {
RDEPUBBookPageMapEntry(
spineIndex: $0.spineIndex,
href: $0.href,
title: $0.title,
pageCount: $0.pageCount,
absolutePageStart: 0,
fragmentOffsets: $0.fragmentOffsets
)
} + appendedEntries).sorted { $0.spineIndex < $1.spineIndex }
var absolutePageStart = 0
let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in
let normalized = RDEPUBBookPageMapEntry(
spineIndex: entry.spineIndex,
href: entry.href,
title: entry.title,
pageCount: entry.pageCount,
absolutePageStart: absolutePageStart,
fragmentOffsets: entry.fragmentOffsets
)
absolutePageStart += entry.pageCount
return normalized
}
let newMap = RDEPUBBookPageMap(entries: normalizedEntries)
RDEPUBBackgroundTrace.log(
"Runtime",
"extendPartialBookPageMap applied chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
)
context.bookPageMap = newMap
context.replaceActiveSnapshot(makeSnapshot(from: newMap))
readerView.reloadData()
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
}
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
guard context.publication != nil else { return }
chapterRuntimeStore.setCurrentChapter(
spineIndex: anchorSpineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
chapterWarmupOrchestrator.prefetchForwardChaptersAfterInitialOpen(
anchorSpineIndex: anchorSpineIndex,
totalSpineCount: totalSpineCount
)
let forwardTargets = chapterRuntimeStore.windowSpineIndices.filter { $0 > anchorSpineIndex }
guard !forwardTargets.isEmpty else { return }
for spineIndex in forwardTargets {
if chapterRuntimeStore.chapterData(for: spineIndex) != nil {
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
continue
}
chapterRuntimeStore.addPrefetchTarget(spineIndex)
RDEPUBBackgroundTrace.log(
"Runtime",
"initial open prefetch forward spine=\(spineIndex)"
)
chapterLoader.loadChapter(
spineIndex: spineIndex,
store: chapterRuntimeStore,
priority: .prefetch
) { [weak self] result in
guard let self, case .success = result else { return }
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
}
}
}
func clearOnDemandPageModeState() {
@ -900,6 +609,8 @@ final class RDEPUBReaderRuntime {
jumpSessionManager.clearSession()
backgroundPriorityManager.reset()
backgroundCoverageStore.clearAll()
chapterWarmupOrchestrator.clear()
presentationRuntime.clear()
}
func handleMemoryWarning() {
@ -918,42 +629,6 @@ final class RDEPUBReaderRuntime {
)
}
private func loadPartialWindowChapters(
around anchorPosition: Int,
in buildableSpineIndices: [Int],
targetSpineIndex: Int,
windowSize: Int
) -> [RDEPUBRuntimeChapter] {
let lowerBound = max(anchorPosition - max(windowSize / 2, 0), 0)
let upperBound = min(lowerBound + max(windowSize, 1), buildableSpineIndices.count)
let startIndex = max(0, upperBound - max(windowSize, 1))
let window = Array(buildableSpineIndices[startIndex..<upperBound])
var chapters: [RDEPUBRuntimeChapter] = []
for spineIndex in window {
do {
let chapter = try chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: chapterRuntimeStore
)
chapters.append(chapter)
} catch {
if spineIndex == targetSpineIndex {
RDEPUBBackgroundTrace.log(
"Runtime",
"ensureNavigationTarget FAILED target spine=\(spineIndex) error=\(error)"
)
return []
}
RDEPUBBackgroundTrace.log(
"Runtime",
"ensureNavigationTarget skip adjacent spine=\(spineIndex) error=\(error)"
)
}
}
return chapters
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
@ -968,96 +643,12 @@ final class RDEPUBReaderRuntime {
return builder.build()
}
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() {
guard let publication = context.publication,
let currentMap = context.bookPageMap,
let readerView = context.readerView,
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
private func handleDeferredCFIMapReady(for spineIndex: Int) {
guard let currentLocation = locationCoordinator.currentVisibleLocation(),
let visibleSpineIndex = context.normalizedSpineIndex(for: currentLocation),
visibleSpineIndex == spineIndex else {
return
}
let buildableSpineIndices = publication.spine.indices.filter {
let item = publication.spine[$0]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
var appendedEntries: [RDEPUBBookPageMapEntry] = []
for spineIndex in buildableSpineIndices where spineIndex > lastKnownSpineIndex {
guard let chapter = chapterRuntimeStore.chapterData(for: spineIndex) else {
break
}
appendedEntries.append(
RDEPUBBookPageMapEntry(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
absolutePageStart: 0,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
)
}
guard !appendedEntries.isEmpty else { return }
let existingEntries = currentMap.entries.map {
RDEPUBBookPageMapEntry(
spineIndex: $0.spineIndex,
href: $0.href,
title: $0.title,
pageCount: $0.pageCount,
absolutePageStart: 0,
fragmentOffsets: $0.fragmentOffsets
)
}
var absolutePageStart = 0
let newEntries = (existingEntries + appendedEntries).map { entry -> RDEPUBBookPageMapEntry in
let normalizedEntry = RDEPUBBookPageMapEntry(
spineIndex: entry.spineIndex,
href: entry.href,
title: entry.title,
pageCount: entry.pageCount,
absolutePageStart: absolutePageStart,
fragmentOffsets: entry.fragmentOffsets
)
absolutePageStart += entry.pageCount
return normalizedEntry
}
let newMap = RDEPUBBookPageMap(entries: newEntries)
guard newMap.totalPages > currentMap.totalPages else { return }
RDEPUBBackgroundTrace.log(
"Runtime",
"appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
)
context.bookPageMap = newMap
context.replaceActiveSnapshot(makeSnapshot(from: newMap))
readerView.reloadPageCountOnly()
}
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
let pages = bookPageMap.entries.flatMap { entry in
(0..<entry.pageCount).map { localPageIndex in
EPUBPage(
spineIndex: entry.spineIndex,
chapterIndex: bookPageMap.chapterIndex(forSpineIndex: entry.spineIndex) ?? 0,
pageIndexInChapter: localPageIndex,
totalPagesInChapter: entry.pageCount,
chapterTitle: entry.title,
fixedSpread: nil
)
}
}
let chapters = bookPageMap.entries.map { entry in
EPUBChapterInfo(
spineIndex: entry.spineIndex,
title: entry.title,
pageCount: entry.pageCount
)
}
return (pages, chapters)
refreshVisibleContentPreservingLocation()
}
}

View File

@ -0,0 +1,54 @@
import Foundation
final class RDEPUBReaderServices {
var dependencies: RDEPUBReaderDependencies
init(dependencies: RDEPUBReaderDependencies) {
self.dependencies = dependencies
}
func resolvedTextRenderer(configuration: RDEPUBReaderConfiguration) -> RDEPUBTextRenderer {
dependencies.makeTextRenderer(configuration.textRenderingEngine)
}
func makeParser() -> RDEPUBParser {
dependencies.makeParser()
}
func makePaginator() -> RDEPUBPaginator {
dependencies.makePaginator()
}
func makeTextBookBuilder(
configuration: RDEPUBReaderConfiguration,
cache: RDEPUBTextBookCache?,
layoutConfig: RDEPUBTextLayoutConfig
) -> RDEPUBTextBookBuilder {
dependencies.makeTextBookBuilder(
resolvedTextRenderer(configuration: configuration),
cache,
layoutConfig
)
}
func makePlainTextBookBuilder(
configuration: RDEPUBReaderConfiguration,
layoutConfig: RDEPUBTextLayoutConfig
) -> RDPlainTextBookBuilder {
dependencies.makePlainTextBookBuilder(
resolvedTextRenderer(configuration: configuration),
layoutConfig
)
}
func makeChapterSummaryDiskCache(bookIdentifier: String?) -> RDEPUBChapterSummaryDiskCache {
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let bookID = (bookIdentifier ?? "default").sha256Hex
let directory = cachesDirectory
.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
.appendingPathComponent(bookID, isDirectory: true)
return RDEPUBChapterSummaryDiskCache(cacheDirectory: directory)
}
}

View File

@ -0,0 +1,73 @@
import UIKit
final class RDEPUBReaderState {
var parser: RDEPUBParser?
var publication: RDEPUBPublication?
var readingSession: RDEPUBReadingSession?
var textBook: RDEPUBTextBook?
var bookPageMap: RDEPUBBookPageMap?
var activeBookmarks: [RDEPUBBookmark] = []
var activeHighlights: [RDEPUBHighlight] = []
var currentBookIdentifier: String?
var paginationToken = UUID()
var paginator: RDEPUBPaginator?
var searchState: RDEPUBSearchState?
var pendingFullPageMap: RDEPUBBookPageMap?
var lastTextPaginationPageSize: CGSize?
var lastMetadataParseWallClockMs: Int = 0
var lastMetadataParseConcurrency: Int = 0
var selectionState: RDEPUBSelectionState = .idle
var isRepaginating: Bool = false
var didStartInitialLoad: Bool = false
var isExternalTextBook: Bool = false
var textFileURL: URL?
let textBookCache = RDEPUBTextBookCache()
var currentSelection: RDEPUBSelection? {
get { selectionState.selection }
set {
if let newValue, !newValue.isEmpty {
selectionState = .selected(newValue)
} else {
selectionState = .idle
}
}
}
var activePages: [EPUBPage] {
readingSession?.activePages ?? []
}
var activeChapters: [EPUBChapterInfo] {
readingSession?.activeChapters ?? []
}
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
readingSession?.setActiveSnapshot(snapshot)
}
func clearActiveSnapshot() {
readingSession?.resetRuntimeState()
}
}

View File

@ -107,6 +107,13 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
return label
}()
private let loadingSpinner: UIActivityIndicatorView = {
let spinner = UIActivityIndicatorView(style: .medium)
spinner.hidesWhenStopped = true
spinner.accessibilityIdentifier = "epub.reader.loadingSpinner"
return spinner
}()
private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
gesture.minimumPressDuration = 0.5
@ -136,6 +143,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
#endif
addSubview(overlayView)
addSubview(pageNumberLabel)
addSubview(loadingSpinner)
addSubview(selectionLoupeView)
addGestureRecognizer(longPressGestureRecognizer)
addGestureRecognizer(panGestureRecognizer)
@ -214,6 +222,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
width: labelSize.width,
height: labelSize.height
)
loadingSpinner.center = CGPoint(x: bounds.midX, y: bounds.midY)
}
func configure(
@ -226,6 +235,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
highlights: [RDEPUBHighlight] = [],
searchState: RDEPUBSearchState? = nil
) {
loadingSpinner.stopAnimating()
currentPage = page
currentChapterCFIMap = chapterCFIMap
currentChapterFragmentOffsets = chapterFragmentOffsets
@ -297,6 +307,44 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
setNeedsLayout()
}
func configureLoading(
pageNumber: Int,
totalPages: Int,
configuration: RDEPUBReaderConfiguration
) {
currentPage = nil
currentChapterCFIMap = nil
currentChapterFragmentOffsets = [:]
currentSelection = nil
menuSelection = nil
activeSelectionHandle = nil
updateSelectionInteractionState(.idle)
currentHighlights = []
currentSearchState = nil
selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = configuration.reflowableContentInsets
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
coverImageView.isHidden = true
coverImageView.image = nil
#if canImport(DTCoreText)
coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear
coreTextContentView.attributedDisplayContent = nil
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
#endif
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
delegate?.textContentView(self, didChangeSelection: nil)
loadingSpinner.startAnimating()
setNeedsLayout()
}
func clearSelection() {
currentSelection = nil
menuSelection = nil

View File

@ -12,19 +12,19 @@ final class RDEPUBTextPageRenderView: UIView {
var layoutFrame: DTCoreTextLayoutFrame? {
didSet {
setNeedsDisplay()
invalidateStaticContent()
}
}
var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
didSet {
setNeedsDisplay()
invalidateStaticContent()
}
}
var attributedDisplayContent: NSAttributedString? {
didSet {
setNeedsDisplay()
invalidateStaticContent()
}
}
@ -44,6 +44,12 @@ final class RDEPUBTextPageRenderView: UIView {
private let selectionHandleHitSlop: CGFloat = 20
private var cachedStaticImage: UIImage?
private var cachedStaticBoundsSize: CGSize = .zero
private var needsStaticContentRedraw = true
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
@ -61,17 +67,35 @@ final class RDEPUBTextPageRenderView: UIView {
context.saveGState()
if cachedStaticImage == nil
|| cachedStaticBoundsSize != bounds.size
|| needsStaticContentRedraw {
cachedStaticImage = renderStaticImage(layoutFrame: layoutFrame)
cachedStaticBoundsSize = bounds.size
needsStaticContentRedraw = false
}
if let cachedStaticImage {
cachedStaticImage.draw(in: bounds)
} else {
if let attributedDisplayContent {
drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
}
layoutFrame.draw(in: context, options: drawOptions)
}
drawSelection(in: context)
context.restoreGState()
}
override func layoutSubviews() {
super.layoutSubviews()
if cachedStaticBoundsSize != bounds.size {
invalidateStaticContent()
}
}
private func drawHighlights(
in context: CGContext,
attributedString: NSAttributedString,
@ -223,6 +247,26 @@ final class RDEPUBTextPageRenderView: UIView {
context.restoreGState()
}
private func invalidateStaticContent() {
cachedStaticImage = nil
needsStaticContentRedraw = true
setNeedsDisplay()
}
private func renderStaticImage(layoutFrame: DTCoreTextLayoutFrame) -> UIImage? {
guard bounds.width > 0, bounds.height > 0 else { return nil }
let format = UIGraphicsImageRendererFormat.default()
format.opaque = false
let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format)
return renderer.image { _ in
guard let staticContext = UIGraphicsGetCurrentContext() else { return }
if let attributedDisplayContent {
drawHighlights(in: staticContext, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
}
layoutFrame.draw(in: staticContext, options: drawOptions)
}
}
}
private extension CGPoint {

View File

@ -3,7 +3,7 @@ import UIKit
final class RDReaderPreloadController {
var radius: Int = 1
var radius: Int = 2
private let preloadHostView = UIView()

View File

@ -1,6 +1,7 @@
import UIKit
@available(*, deprecated, message: "Use RDReaderPageProvider instead.")
@objc public protocol RDReaderDataSource: NSObjectProtocol {
func pageCountOfReaderView(readerView: RDReaderView) -> Int