docs: 补充大书优化方案的 WXRead 复刻依据与缓存语义
添加 3.0 节记录 WXRead 逆向依据(WRReaderViewController 核心语义), 完善 pageCountCache 失效策略与 key 生成说明,对齐 WXRead 运行时行为。
This commit is contained in:
parent
9801af05d3
commit
584976ac5f
@ -65,6 +65,32 @@
|
|||||||
|
|
||||||
以下部分要求与 WXRead 保持同方向:
|
以下部分要求与 WXRead 保持同方向:
|
||||||
|
|
||||||
|
### 3.0 本次复刻依据
|
||||||
|
|
||||||
|
本方案以 `Doc/WXRead` 中的本地逆向资料为依据,尤其是:
|
||||||
|
|
||||||
|
- `Doc/WXRead/内存优化方案_WXRead策略.md`
|
||||||
|
- `Doc/WXRead/decompiled/WRReaderViewController.m`
|
||||||
|
- `Doc/WXRead/decompiled/WRReaderViewController.h`
|
||||||
|
- `Doc/WXRead/decompiled/WRChapterPageCount.m`
|
||||||
|
- `Doc/WXRead/analysis/03_数据结构与API协议定义.md`
|
||||||
|
|
||||||
|
WXRead 中需要被复刻的运行时语义如下:
|
||||||
|
|
||||||
|
- `WRReaderViewController` 持有 `chapterDataCache`、`pageCountCache`、`currentChapterData`、`currentChapterPageCount`、`chapterLoadQueue`
|
||||||
|
- `chapterLoadQueue = dispatch_queue_create("com.weread.chapterload", DISPATCH_QUEUE_SERIAL)`
|
||||||
|
- `gotoChapterIdx:position:positionOfFile:` 是章节跳转主入口:
|
||||||
|
- 先更新 `readingProgress.chapterIndex/pageIndex/charIndex`
|
||||||
|
- 命中 `chapterDataCache[@(chapterIdx)]` 时直接显示
|
||||||
|
- 未命中时异步 `_loadChapterAtIndex`
|
||||||
|
- `positionOfFile > 0` 时用 `WRChapterPageCount.pageIndexForCharacterIndex:` 恢复章内页
|
||||||
|
- `didFlipPage` 只更新当前章内页码与 `charIndex`,再保存进度并触发相邻章预取
|
||||||
|
- `_handleMemoryWarning:` 保留当前章,清空 `chapterDataCache` 后放回当前章,并清空全部 `pageCountCache`
|
||||||
|
- 设置变化时清空 `pageCountCache`;全局排版变化时清空 `chapterDataCache + pageCountCache`,再重载当前章
|
||||||
|
- `_prefetchAdjacentChaptersForIndex:` 只围绕当前章的上一章/下一章工作,不做正文运行时全书预加载
|
||||||
|
|
||||||
|
因此 ReadViewSDK 的实现必须复刻这些语义,而不是继续沿用“快速首章进入 + 后台补齐整书 `RDEPUBTextBook` + staged/full apply”的路线。
|
||||||
|
|
||||||
### 3.1 章节级主缓存
|
### 3.1 章节级主缓存
|
||||||
|
|
||||||
- 章节缓存粒度为单章
|
- 章节缓存粒度为单章
|
||||||
@ -273,7 +299,14 @@ final class RDEPUBRuntimeChapter {
|
|||||||
|
|
||||||
- `chapterDataCache` 命中时,以章节对象内部 `pageRanges` 为准
|
- `chapterDataCache` 命中时,以章节对象内部 `pageRanges` 为准
|
||||||
- `pageCountCache` 不能覆盖章节对象真值
|
- `pageCountCache` 不能覆盖章节对象真值
|
||||||
- 章节淘汰时,应同步清理与该章关联的 `pageCountCache`
|
- 常规章节淘汰时,可以同步清理与该章关联的 `pageCountCache`
|
||||||
|
- 内存警告与全局排版设置变化时,按 WXRead 语义直接清空全部 `pageCountCache`
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- WXRead 的 `pageCountCache` key 由 `WRChapterPageCount.currentCacheKeyWithBookId:` 生成,编码书籍 ID 与当前排版设置
|
||||||
|
- ReadViewSDK 可以把 `spineIndex/chapterContentHash` 纳入 key,增强失效精度
|
||||||
|
- 但运行时语义必须保持一致:`pageCountCache` 是可重建的分页结构缓存,不是稳定正文真值
|
||||||
|
|
||||||
### 7.3 三级缓存:轻量章节摘要磁盘缓存
|
### 7.3 三级缓存:轻量章节摘要磁盘缓存
|
||||||
|
|
||||||
@ -329,18 +362,31 @@ struct RDEPUBChapterOffsetMap {
|
|||||||
- `fontName`
|
- `fontName`
|
||||||
- `fontSize`
|
- `fontSize`
|
||||||
- `lineHeightMultiple`
|
- `lineHeightMultiple`
|
||||||
- `contentInsets`
|
- `lineSpacing`
|
||||||
- `pageSize`
|
|
||||||
- `layoutConfigSignature`
|
- `layoutConfigSignature`
|
||||||
- `schemaVersion`
|
- `schemaVersion`
|
||||||
|
|
||||||
它是分页结构与轻量摘要层的核心失效依据。
|
它是分页结构与轻量摘要层的核心失效依据。
|
||||||
|
|
||||||
|
补充约束:
|
||||||
|
|
||||||
|
- `contentInsets`、`pageSize`、`frameWidth/frameHeight` 不在外层重复编码,由 `layoutConfig.cacheSignature` 统一覆盖
|
||||||
|
- `renderSignature` 的职责是组合“字体/行距/布局签名/版本号”,避免和 `layoutConfig.cacheSignature` 双重编码导致无意义失效
|
||||||
|
|
||||||
### 8.2.1 `layoutConfig.cacheSignature` 字段组成
|
### 8.2.1 `layoutConfig.cacheSignature` 字段组成
|
||||||
|
|
||||||
`cacheSignature` 是 `RDEPUBTextLayoutConfig` 的签名摘要,必须覆盖所有影响分页结果的排版参数。任何参数变化都必须导致签名不同,从而使旧缓存自然失效。
|
`cacheSignature` 是 `RDEPUBTextLayoutConfig` 的签名摘要,必须覆盖所有影响分页结果的排版参数。任何参数变化都必须导致签名不同,从而使旧缓存自然失效。
|
||||||
|
|
||||||
必须包含的字段:
|
WXRead 的 `WRChapterPageCount.currentCacheKeyWithBookId:` 至少编码:
|
||||||
|
|
||||||
|
- `bookId`
|
||||||
|
- 字号
|
||||||
|
- 行距
|
||||||
|
- 页面宽高
|
||||||
|
|
||||||
|
ReadViewSDK 需要在这个基础上增加当前工程已有的分页参数。必须以当前 `RDEPUBTextLayoutConfig` 的真实字段为准,不允许照抄不存在的伪字段。
|
||||||
|
|
||||||
|
当前工程必须包含的字段:
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
extension RDEPUBTextLayoutConfig {
|
extension RDEPUBTextLayoutConfig {
|
||||||
@ -348,18 +394,17 @@ extension RDEPUBTextLayoutConfig {
|
|||||||
/// 任何字段变化都必须导致签名变化,否则会出现缓存命中但分页结果不一致的 bug
|
/// 任何字段变化都必须导致签名变化,否则会出现缓存命中但分页结果不一致的 bug
|
||||||
var cacheSignature: String {
|
var cacheSignature: String {
|
||||||
let fields: [String] = [
|
let fields: [String] = [
|
||||||
"columnCount:\(columnCount)", // 分栏数
|
"frameWidth:\(frameWidth)",
|
||||||
"columnSpacing:\(columnSpacing)", // 栏间距
|
"frameHeight:\(frameHeight)",
|
||||||
"paragraphSpacing:\(paragraphSpacing)", // 段间距
|
"edgeInsets:\(edgeInsets.top),\(edgeInsets.left),\(edgeInsets.bottom),\(edgeInsets.right)",
|
||||||
"paragraphFirstLineIndent:\(paragraphFirstLineIndent)", // 首行缩进
|
"numberOfColumns:\(numberOfColumns)",
|
||||||
"hyphenationEnabled:\(hyphenationEnabled)", // 连字开关
|
"columnGap:\(columnGap)",
|
||||||
"textAlignment:\(textAlignment.rawValue)", // 对齐方式
|
"avoidOrphans:\(avoidOrphans)",
|
||||||
"wordSpacing:\(wordSpacing)", // 字间距
|
"avoidWidows:\(avoidWidows)",
|
||||||
"letterSpacing:\(letterSpacing)", // 字母间距
|
"avoidPageBreakInsideEnabled:\(avoidPageBreakInsideEnabled)",
|
||||||
"imageMaxScale:\(imageMaxScale)", // 图片最大缩放比
|
"hyphenation:\(hyphenation)",
|
||||||
"imageInlineMaxHeight:\(imageInlineMaxHeight)", // 内联图片最大高度
|
"imageMaxHeightRatio:\(imageMaxHeightRatio)",
|
||||||
"attachmentPlacement:\(attachmentPlacement.rawValue)", // 附件放置策略
|
"fallbackViewportSize:\(fallbackViewportSize.width),\(fallbackViewportSize.height)",
|
||||||
"breakStrategy:\(breakStrategy.rawValue)", // 分页策略
|
|
||||||
]
|
]
|
||||||
return fields.joined(separator: "|")
|
return fields.joined(separator: "|")
|
||||||
}
|
}
|
||||||
@ -369,20 +414,16 @@ extension RDEPUBTextLayoutConfig {
|
|||||||
设计原则:
|
设计原则:
|
||||||
|
|
||||||
- **只包含影响分页结果的字段**:纯展示参数(如高亮颜色、选中样式)不进签名
|
- **只包含影响分页结果的字段**:纯展示参数(如高亮颜色、选中样式)不进签名
|
||||||
- **不包含 `edgeInsets`**:`edgeInsets` 已在 `renderSignature` 中独立编码,避免重复
|
- **`renderSignature` 负责组合**:`fontName/fontSize/lineHeightMultiple/lineSpacing/layoutConfig.cacheSignature/schemaVersion` 必须在最终 key 中同时出现
|
||||||
- **不包含 `lineHeightMultiple`**:同上,已在 `renderSignature` 中独立编码
|
|
||||||
- **字段顺序固定**:签名拼接顺序必须稳定,避免因字段顺序变化导致无意义的缓存失效
|
- **字段顺序固定**:签名拼接顺序必须稳定,避免因字段顺序变化导致无意义的缓存失效
|
||||||
- **新增字段时必须追加到签名末尾**:并在 `schemaVersion` 中递增,确保旧缓存不被错误命中
|
- **新增字段时必须追加到签名末尾**:并在 `schemaVersion` 中递增,确保旧缓存不被错误命中
|
||||||
|
|
||||||
不包含的字段(及其原因):
|
不包含的字段:
|
||||||
|
|
||||||
| 字段 | 原因 |
|
- `highlightColor`:不影响分页结果
|
||||||
|------|------|
|
- `selectionColor`:不影响分页结果
|
||||||
| `edgeInsets` | 已在 `renderSignature` 独立编码 |
|
- `debugShowPageBounds`:调试开关,不影响分页结果
|
||||||
| `lineHeightMultiple` | 已在 `renderSignature` 独立编码 |
|
- 文档或历史方案中提到但当前模型不存在的字段,例如 `paragraphSpacing`、`letterSpacing`、`attachmentPlacement`,不能进入实现
|
||||||
| `highlightColor` | 不影响分页结果 |
|
|
||||||
| `selectionColor` | 不影响分页结果 |
|
|
||||||
| `debugShowPageBounds` | 调试开关,不影响分页结果 |
|
|
||||||
|
|
||||||
### 8.3 `RDEPUBChapterCacheKey`
|
### 8.3 `RDEPUBChapterCacheKey`
|
||||||
|
|
||||||
@ -577,23 +618,19 @@ struct RDEPUBChapterWindowSnapshot {
|
|||||||
|
|
||||||
### 13.2 `pageCountCache` 策略
|
### 13.2 `pageCountCache` 策略
|
||||||
|
|
||||||
这里需要明确和之前版本不同的结论:
|
这里按 WXRead 的 `_handleMemoryWarning:` 复刻:
|
||||||
|
|
||||||
- `pageCountCache` 不是独立真值
|
- `pageCountCache` 不是独立真值
|
||||||
- 当前章的页范围已经包含在 `RDEPUBRuntimeChapter`
|
- 当前章的页范围已经包含在 `RDEPUBRuntimeChapter`
|
||||||
|
- 内存警告时直接清空全部 `pageCountCache`
|
||||||
|
|
||||||
因此两种实现都可接受,但必须文档化:
|
ReadViewSDK 不再采用“保留当前章 pageCountCache”的保守方案。原因:
|
||||||
|
|
||||||
1. 更保守方案
|
- WXRead 的内存警告策略就是清空全部分页缓存
|
||||||
- 保留当前章对应的 `pageCountCache`
|
- 当前章显示依赖 `RDEPUBRuntimeChapter.pageRanges/pages`,不依赖 `pageCountCache`
|
||||||
2. 更简化方案
|
- `pageCountCache` 可由当前章对象或磁盘摘要重新回填
|
||||||
- 直接清空全部 `pageCountCache`
|
|
||||||
- 因为当前章显示不依赖它
|
|
||||||
|
|
||||||
推荐:
|
普通窗口淘汰时可以移除对应章节的 `pageCountCache` 条目;内存警告、全局排版变化、schemaVersion 变化时必须清空全部。
|
||||||
|
|
||||||
- 默认采用“保守方案”,保留当前章对应的 `pageCountCache`
|
|
||||||
- 章节淘汰时同步淘汰对应 `pageCountCache`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -694,6 +731,8 @@ struct RDEPUBChapterWindowSnapshot {
|
|||||||
- `RDEPUBChapterRuntimeStore + RDEPUBChapterLoader + RDEPUBChapterWindowCoordinator` 组成唯一正文主路径
|
- `RDEPUBChapterRuntimeStore + RDEPUBChapterLoader + RDEPUBChapterWindowCoordinator` 组成唯一正文主路径
|
||||||
- `RDReaderView` 的数据源直接消费窗口快照
|
- `RDReaderView` 的数据源直接消费窗口快照
|
||||||
- 位置持久化、翻章、搜索结果定位统一落到章节语义
|
- 位置持久化、翻章、搜索结果定位统一落到章节语义
|
||||||
|
- P0 可以保留旧代码文件作为未调用的回退脚手架,但 textReflowable 启动入口不得再进入 `paginateTextPublication -> buildQuickTextBook -> staged/full apply`
|
||||||
|
- P4 的职责是删除未调用旧代码,而不是才切换主入口
|
||||||
|
|
||||||
### 15.2 P0 风险控制
|
### 15.2 P0 风险控制
|
||||||
|
|
||||||
@ -702,7 +741,8 @@ P0 改成:
|
|||||||
- `P0-1` 建立章节 store 与 loader
|
- `P0-1` 建立章节 store 与 loader
|
||||||
- `P0-2` 建立窗口数据源适配
|
- `P0-2` 建立窗口数据源适配
|
||||||
- `P0-3` 打通打开书、翻章、持久化三条核心链路
|
- `P0-3` 打通打开书、翻章、持久化三条核心链路
|
||||||
- `P0-4` 验证通过后移除旧整书主路径相关依赖
|
- `P0-4` 将 textReflowable 的 `paginatePublication` 委托到 `ChapterWindowCoordinator.openBook(at:)`
|
||||||
|
- `P0-5` 验证通过后,P4 再删除旧整书主路径相关未调用代码
|
||||||
|
|
||||||
这样可以避免“主设计仍在描述双路径”,让实现和文档保持一致。
|
这样可以避免“主设计仍在描述双路径”,让实现和文档保持一致。
|
||||||
|
|
||||||
@ -717,6 +757,7 @@ P0 改成:
|
|||||||
3. 新建 `RDEPUBChapterWindowSnapshot`
|
3. 新建 `RDEPUBChapterWindowSnapshot`
|
||||||
4. 让 `RDEPUBReaderController+DataSource` 直接消费窗口快照
|
4. 让 `RDEPUBReaderController+DataSource` 直接消费窗口快照
|
||||||
5. 打通打开书、翻章、位置持久化
|
5. 打通打开书、翻章、位置持久化
|
||||||
|
6. textReflowable 路径停止调用 staged/full `RDEPUBTextBook` apply
|
||||||
|
|
||||||
### P1:建立完整章节缓存运行时
|
### P1:建立完整章节缓存运行时
|
||||||
|
|
||||||
@ -786,9 +827,11 @@ P0 改成:
|
|||||||
|
|
||||||
## 19. 伪代码级别落实方案
|
## 19. 伪代码级别落实方案
|
||||||
|
|
||||||
以下按 P0→P4 阶段给出每个核心类型的伪代码,精确到属性、方法签名和关键逻辑分支,可直接作为开发参照。
|
以下按 P0→P4 阶段给出每个核心类型的伪代码,精确到属性、方法签名和关键逻辑分支,可作为开发参照;实现时仍需按当前工程真实 API 名称适配,不能逐字复制尚未存在的 helper。
|
||||||
|
|
||||||
### 19.1 P0:引入章节真值但不破坏旧链路
|
### 19.1 P0:切换 textReflowable 到章节真值主路径
|
||||||
|
|
||||||
|
P0 的目标不是继续保留双主链路,而是让 textReflowable 的正文显示从第一屏开始就由章节窗口驱动。旧整书分页代码可以暂时留在文件中,但不再作为 textReflowable 的运行时入口。
|
||||||
|
|
||||||
#### 19.1.1 RDEPUBChapterRuntimeStore
|
#### 19.1.1 RDEPUBChapterRuntimeStore
|
||||||
|
|
||||||
@ -809,7 +852,8 @@ final class RDEPUBChapterRuntimeStore {
|
|||||||
let imageCache = NSCache<NSString, UIImage>()
|
let imageCache = NSCache<NSString, UIImage>()
|
||||||
|
|
||||||
/// 串行加载队列(等价 WXRead com.weread.chapterload)
|
/// 串行加载队列(等价 WXRead com.weread.chapterload)
|
||||||
let chapterLoadQueue = DispatchQueue(label: “com.rdreader.chapterload”, qos: .utility)
|
let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .utility)
|
||||||
|
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
|
||||||
|
|
||||||
// MARK: - 窗口状态
|
// MARK: - 窗口状态
|
||||||
|
|
||||||
@ -839,6 +883,11 @@ final class RDEPUBChapterRuntimeStore {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
imageCache.countLimit = 50
|
imageCache.countLimit = 50
|
||||||
|
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNotOnChapterLoadQueue() {
|
||||||
|
dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 缓存查询(线程安全,通过 cache wrapper 的 lock 保护)
|
// MARK: - 缓存查询(线程安全,通过 cache wrapper 的 lock 保护)
|
||||||
@ -897,12 +946,8 @@ final class RDEPUBChapterRuntimeStore {
|
|||||||
if let ch = currentChapter {
|
if let ch = currentChapter {
|
||||||
chapterDataCache[spineIndex: current] = ch
|
chapterDataCache[spineIndex: current] = ch
|
||||||
}
|
}
|
||||||
// pageCountCache 保守方案:保留当前章的
|
// WXRead 语义:内存警告时 pageCountCache 全量清空
|
||||||
let currentPageCounts = pageCountCache.entriesForSpineIndex(current)
|
|
||||||
pageCountCache.removeAll()
|
pageCountCache.removeAll()
|
||||||
for (key, value) in currentPageCounts {
|
|
||||||
pageCountCache[key] = value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 内存警告
|
// MARK: - 内存警告
|
||||||
@ -1276,6 +1321,48 @@ final class RDEPUBChapterLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 仅供 legacy 位置迁移使用的同步加载入口。
|
||||||
|
///
|
||||||
|
/// 约束:
|
||||||
|
/// - 必须复用同一个 `chapterLoadQueue`,保持 WXRead 的单章串行语义
|
||||||
|
/// - 不允许恢复整书 `RDEPUBTextBook`
|
||||||
|
/// - 只允许从主线程或明确的非 `chapterLoadQueue` 上下文调用
|
||||||
|
/// - 调用前必须执行 `store.assertNotOnChapterLoadQueue()`,避免 semaphore 等待自身队列造成死锁
|
||||||
|
/// - 只在首次迁移且目标章未命中缓存时使用
|
||||||
|
func loadChapterSynchronouslyForMigration(
|
||||||
|
spineIndex: Int,
|
||||||
|
store: RDEPUBChapterRuntimeStore?
|
||||||
|
) throws -> RDEPUBRuntimeChapter {
|
||||||
|
if let cached = store?.chapterData(for: spineIndex) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let store else {
|
||||||
|
throw RDEPUBChapterLoadError.missingParser
|
||||||
|
}
|
||||||
|
|
||||||
|
store.assertNotOnChapterLoadQueue()
|
||||||
|
|
||||||
|
var result: Result<RDEPUBRuntimeChapter, Error>?
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
store.chapterLoadQueue.async {
|
||||||
|
do {
|
||||||
|
let chapter = try self.buildChapter(
|
||||||
|
spineIndex: spineIndex,
|
||||||
|
availablePageRanges: nil,
|
||||||
|
diskSummary: nil
|
||||||
|
)
|
||||||
|
store.insertChapter(chapter)
|
||||||
|
result = .success(chapter)
|
||||||
|
} catch {
|
||||||
|
result = .failure(error)
|
||||||
|
}
|
||||||
|
semaphore.signal()
|
||||||
|
}
|
||||||
|
semaphore.wait()
|
||||||
|
return try result!.get()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 单章构建(支持轻量缓存命中后跳过分页)
|
// MARK: - 单章构建(支持轻量缓存命中后跳过分页)
|
||||||
|
|
||||||
private func buildChapter(
|
private func buildChapter(
|
||||||
@ -1342,15 +1429,16 @@ final class RDEPUBChapterLoader {
|
|||||||
let spineItem = publication.spine[spineIndex]
|
let spineItem = publication.spine[spineIndex]
|
||||||
let href = spineItem.href
|
let href = spineItem.href
|
||||||
let title = spineItem.title ?? ""
|
let title = spineItem.title ?? ""
|
||||||
let baseURL = parser.baseURL(for: href)
|
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
|
||||||
|
|
||||||
// 1. 只做 HTML → NSAttributedString 渲染,不做分页
|
// 1. 只做 HTML → NSAttributedString 渲染,不做分页
|
||||||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||||||
href: href,
|
href: href,
|
||||||
title: title,
|
title: title,
|
||||||
rawHTML: try parser.htmlContent(for: href),
|
rawHTML: try requireHTMLString(parser, href: href),
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
style: style,
|
style: style,
|
||||||
|
resourceResolver: publication.resourceResolver,
|
||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
layoutConfig: layoutConfig
|
layoutConfig: layoutConfig
|
||||||
)
|
)
|
||||||
@ -1436,7 +1524,6 @@ final class RDEPUBChapterLoader {
|
|||||||
}
|
}
|
||||||
return RDEPUBTextPage(
|
return RDEPUBTextPage(
|
||||||
absolutePageIndex: -1, // 全书绝对页码:章节模式下不赋值,保持 -1;由上层按需回填
|
absolutePageIndex: -1, // 全书绝对页码:章节模式下不赋值,保持 -1;由上层按需回填
|
||||||
windowPageIndex: 0, // 窗口内页码:在 RDEPUBChapterWindowSnapshot.from() 中重新编号
|
|
||||||
chapterIndex: 0, // 所属章节在窗口 chapters 中的索引:在快照构建时重设
|
chapterIndex: 0, // 所属章节在窗口 chapters 中的索引:在快照构建时重设
|
||||||
spineIndex: spineIndex,
|
spineIndex: spineIndex,
|
||||||
href: href,
|
href: href,
|
||||||
@ -1469,25 +1556,33 @@ final class RDEPUBChapterLoader {
|
|||||||
var trailingFragmentID: String? = nil
|
var trailingFragmentID: String? = nil
|
||||||
|
|
||||||
string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in
|
string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in
|
||||||
if let kind = value as? RDEPUBTextAttachmentKind {
|
if let rawValue = value as? String,
|
||||||
|
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue) {
|
||||||
attachmentRanges.append(attrRange)
|
attachmentRanges.append(attrRange)
|
||||||
attachmentKinds.append(kind)
|
attachmentKinds.append(kind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in
|
string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in
|
||||||
if let kind = value as? RDEPUBTextBlockKind, !blockKinds.contains(kind) {
|
if let rawValue = value as? String,
|
||||||
|
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
|
||||||
|
!blockKinds.contains(kind) {
|
||||||
blockKinds.append(kind)
|
blockKinds.append(kind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in
|
string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in
|
||||||
if let hints = value as? [RDEPUBTextSemanticHint] {
|
if let rawValue = value as? String {
|
||||||
|
let hints = rawValue
|
||||||
|
.split(separator: ",")
|
||||||
|
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||||
for hint in hints where !semanticHints.contains(hint) {
|
for hint in hints where !semanticHints.contains(hint) {
|
||||||
semanticHints.append(hint)
|
semanticHints.append(hint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in
|
string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in
|
||||||
if let placement = value as? RDEPUBTextAttachmentPlacement, !attachmentPlacements.contains(placement) {
|
if let rawValue = value as? String,
|
||||||
|
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
|
||||||
|
!attachmentPlacements.contains(placement) {
|
||||||
attachmentPlacements.append(placement)
|
attachmentPlacements.append(placement)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1563,29 +1658,26 @@ final class RDEPUBChapterLoader {
|
|||||||
private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey {
|
private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||||
let style = context.currentTextRenderStyle()
|
let style = context.currentTextRenderStyle()
|
||||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
|
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
|
||||||
let pageSize = context.currentTextPageSize()
|
|
||||||
|
|
||||||
// renderSignature 必须覆盖 §8.2 定义的全部参数
|
// renderSignature 必须覆盖 §8.2 定义的全部参数
|
||||||
// 任何排版参数变化都必须导致 key 不同,从而自然失效旧缓存
|
// 任何排版参数变化都必须导致 key 不同,从而自然失效旧缓存
|
||||||
// 注意:直接使用配置层的 lineHeightMultiple 参数值,不通过换算派生
|
// 注意:当前 RDEPUBTextRenderStyle 只有 lineSpacing,lineHeightMultiple 来自 reader configuration
|
||||||
// 避免换算关系变更导致 key 与文档定义漂移
|
// 不能写成 style.lineHeightMultiple,否则实现会引用不存在的字段
|
||||||
let lineHeightMultiple = style.lineHeightMultiple
|
let lineHeightMultiple = context.configuration.lineHeightMultiple
|
||||||
?? (style.font.lineHeight + style.lineSpacing) / style.font.lineHeight
|
|
||||||
|
|
||||||
let renderSignature = [
|
let renderSignature = [
|
||||||
style.font.fontName, // fontName
|
style.font.fontName, // fontName
|
||||||
“\(style.font.pointSize)”, // fontSize
|
"\(style.font.pointSize)", // fontSize
|
||||||
“\(lineHeightMultiple)”, // lineHeightMultiple(直接编码,不换算)
|
"\(lineHeightMultiple)", // lineHeightMultiple
|
||||||
“\(layoutConfig.edgeInsets)”, // contentInsets
|
"\(style.lineSpacing)", // lineSpacing: 实际 CoreText 段落样式使用值
|
||||||
“\(pageSize.width)x\(pageSize.height)”, // pageSize
|
|
||||||
layoutConfig.cacheSignature, // layoutConfigSignature
|
layoutConfig.cacheSignature, // layoutConfigSignature
|
||||||
“\(6)” // schemaVersion
|
"\(6)" // schemaVersion
|
||||||
].joined(separator: “|”)
|
].joined(separator: "|")
|
||||||
|
|
||||||
let contentHash = contentHashForSpineIndex(spineIndex)
|
let contentHash = contentHashForSpineIndex(spineIndex)
|
||||||
|
|
||||||
return RDEPUBChapterCacheKey(
|
return RDEPUBChapterCacheKey(
|
||||||
bookID: context.currentBookIdentifier ?? “”,
|
bookID: context.currentBookIdentifier ?? "",
|
||||||
spineIndex: spineIndex,
|
spineIndex: spineIndex,
|
||||||
renderSignature: renderSignature,
|
renderSignature: renderSignature,
|
||||||
chapterContentHash: contentHash
|
chapterContentHash: contentHash
|
||||||
@ -1594,16 +1686,29 @@ final class RDEPUBChapterLoader {
|
|||||||
|
|
||||||
private func contentHashForSpineIndex(_ spineIndex: Int) -> String {
|
private func contentHashForSpineIndex(_ spineIndex: Int) -> String {
|
||||||
guard let parser = context.parser,
|
guard let parser = context.parser,
|
||||||
let publication = context.publication else { return “” }
|
let publication = context.publication else { return "" }
|
||||||
let href = publication.spine[spineIndex].href
|
let href = publication.spine[spineIndex].href
|
||||||
let html = try? parser.htmlContent(for: href)
|
guard let html = parser.htmlString(forRelativePath: href) else { return "" }
|
||||||
return html?.sha256Prefix ?? “”
|
return stableContentHash(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
|
||||||
|
guard let html = parser.htmlString(forRelativePath: href) else {
|
||||||
|
throw RDEPUBChapterLoadError.emptyChapterHref(href)
|
||||||
|
}
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 示例 helper:实现时可用 CryptoKit 或现有项目内哈希工具生成稳定字符串摘要。
|
||||||
|
private func stableContentHash(_ html: String) -> String {
|
||||||
|
html
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RDEPUBChapterLoadError: Error {
|
enum RDEPUBChapterLoadError: Error {
|
||||||
case missingParser
|
case missingParser
|
||||||
case emptyChapter(spineIndex: Int)
|
case emptyChapter(spineIndex: Int)
|
||||||
|
case emptyChapterHref(String)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -1617,8 +1722,9 @@ struct RDEPUBChapterWindowSnapshot {
|
|||||||
let chapters: [RDEPUBRuntimeChapter]
|
let chapters: [RDEPUBRuntimeChapter]
|
||||||
|
|
||||||
/// 展平后的连续页数组(供 RDReaderView 消费)
|
/// 展平后的连续页数组(供 RDReaderView 消费)
|
||||||
/// 每页携带独立的 windowPageIndex(窗口内连续编号),
|
/// 窗口内页码不写回 `RDEPUBTextPage` 模型;
|
||||||
/// 与 RDEPUBTextPage.absolutePageIndex(全书绝对页码)严格区分。
|
/// `flattenedPages` 的数组下标就是窗口内连续页码(从 0 开始)。
|
||||||
|
/// 这和 `RDEPUBTextPage.absolutePageIndex`(全书绝对页码)严格区分。
|
||||||
let flattenedPages: [RDEPUBTextPage]
|
let flattenedPages: [RDEPUBTextPage]
|
||||||
|
|
||||||
/// 当前章在 chapters 数组中的索引
|
/// 当前章在 chapters 数组中的索引
|
||||||
@ -1655,19 +1761,16 @@ struct RDEPUBChapterWindowSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 展平页数组,编号规则:
|
// 展平页数组,编号规则:
|
||||||
// windowPageIndex — 窗口内连续编号(从 0 开始),供 RDReaderView 消费
|
// flattenedPages 下标 — 窗口内连续编号(从 0 开始),供 RDReaderView 消费
|
||||||
// chapterIndex — 当前页所属章节在 chapters 数组中的索引
|
// chapterIndex — 当前页所属章节在 chapters 数组中的索引
|
||||||
// absolutePageIndex — 保持原值不动(全书绝对页码,章节模式下通常为 -1 或由上层按需赋值)
|
// absolutePageIndex — 保持原值不动(全书绝对页码,章节模式下通常为 -1 或由上层按需赋值)
|
||||||
// 这三个编号语义严格独立,不可混用。
|
// 其中“窗口内页码”不新增到 RDEPUBTextPage 模型,避免污染现有文本页结构。
|
||||||
var allPages: [RDEPUBTextPage] = []
|
var allPages: [RDEPUBTextPage] = []
|
||||||
var windowPageIdx = 0
|
|
||||||
for (chIdx, ch) in chapters.enumerated() {
|
for (chIdx, ch) in chapters.enumerated() {
|
||||||
for var page in ch.pages {
|
for var page in ch.pages {
|
||||||
page.windowPageIndex = windowPageIdx
|
|
||||||
page.chapterIndex = chIdx
|
page.chapterIndex = chIdx
|
||||||
// absolutePageIndex 不在这里赋值,保持章节构建时的原始值
|
// absolutePageIndex 不在这里赋值,保持章节构建时的原始值
|
||||||
allPages.append(page)
|
allPages.append(page)
|
||||||
windowPageIdx += 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1682,13 +1785,19 @@ struct RDEPUBChapterWindowSnapshot {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 兼容边界:
|
||||||
|
/// - `flattenedPages` 下标 = 章节窗口路径中的唯一页码真值
|
||||||
|
/// - `RDEPUBTextPage.absolutePageIndex` 在章节窗口路径中不参与定位
|
||||||
|
/// - 旧的 `RDEPUBChapterData` / `RDEPUBTextIndexTable` / 搜索定位逻辑若仍依赖 absolutePageIndex,
|
||||||
|
/// 必须在 P3 迁移期间通过适配层改成消费 `flattenedPages` 下标或 `RDEPUBChapterLocation`
|
||||||
|
|
||||||
// MARK: - 查询
|
// MARK: - 查询
|
||||||
|
|
||||||
/// 窗口内页码(windowPageIndex)-> 所属章节
|
/// 窗口内页码(即 flattenedPages 下标)-> 所属章节
|
||||||
func chapterForPage(windowPageIndex: Int) -> RDEPUBRuntimeChapter? {
|
func chapterForPage(flattenedPageIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||||
var offset = 0
|
var offset = 0
|
||||||
for ch in chapters {
|
for ch in chapters {
|
||||||
if windowPageIndex < offset + ch.pages.count {
|
if flattenedPageIndex < offset + ch.pages.count {
|
||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
offset += ch.pages.count
|
offset += ch.pages.count
|
||||||
@ -1696,9 +1805,9 @@ struct RDEPUBChapterWindowSnapshot {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 窗口内页码(windowPageIndex)-> 所属章节的 spineIndex
|
/// 窗口内页码(即 flattenedPages 下标)-> 所属章节的 spineIndex
|
||||||
func spineIndexForPage(windowPageIndex: Int) -> Int? {
|
func spineIndexForPage(flattenedPageIndex: Int) -> Int? {
|
||||||
return chapterForPage(windowPageIndex: windowPageIndex)?.spineIndex
|
return chapterForPage(flattenedPageIndex: flattenedPageIndex)?.spineIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 总页数(窗口内)
|
/// 总页数(窗口内)
|
||||||
@ -1768,7 +1877,9 @@ final class RDEPUBChapterWindowCoordinator {
|
|||||||
nextChapter: next
|
nextChapter: next
|
||||||
)
|
)
|
||||||
currentSnapshot = snapshot
|
currentSnapshot = snapshot
|
||||||
|
isApplyingSnapshot = true
|
||||||
onSnapshotChanged?(snapshot)
|
onSnapshotChanged?(snapshot)
|
||||||
|
isApplyingSnapshot = false
|
||||||
|
|
||||||
// 预取 ±1
|
// 预取 ±1
|
||||||
prefetchAdjacent(current: current)
|
prefetchAdjacent(current: current)
|
||||||
@ -1871,7 +1982,7 @@ final class RDEPUBChapterWindowCoordinator {
|
|||||||
|
|
||||||
/// 预取成功后刷新窗口快照
|
/// 预取成功后刷新窗口快照
|
||||||
/// 硬性约束:仅在阅读器完全空闲时才允许刷新快照
|
/// 硬性约束:仅在阅读器完全空闲时才允许刷新快照
|
||||||
/// 空闲定义 = 非构建中 + 非切章中 + readerView 无翻页动画 + 无人机交互进行中
|
/// 空闲定义 = 非构建中 + 非切章中 + 协调器内部未处于“正在应用新快照”状态
|
||||||
/// 不满足条件时延后重试,绝不打断当前阅读状态
|
/// 不满足条件时延后重试,绝不打断当前阅读状态
|
||||||
func refreshSnapshot() {
|
func refreshSnapshot() {
|
||||||
guard let current = store.currentSpineIndex,
|
guard let current = store.currentSpineIndex,
|
||||||
@ -1898,7 +2009,9 @@ final class RDEPUBChapterWindowCoordinator {
|
|||||||
|
|
||||||
if snapshotContentChanged(old: currentSnapshot, new: newSnapshot) {
|
if snapshotContentChanged(old: currentSnapshot, new: newSnapshot) {
|
||||||
currentSnapshot = newSnapshot
|
currentSnapshot = newSnapshot
|
||||||
|
isApplyingSnapshot = true
|
||||||
onSnapshotChanged?(newSnapshot)
|
onSnapshotChanged?(newSnapshot)
|
||||||
|
isApplyingSnapshot = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1946,61 +2059,81 @@ final class RDEPUBChapterWindowCoordinator {
|
|||||||
guard !store.isBuilding else { return false }
|
guard !store.isBuilding else { return false }
|
||||||
// 2. 没有切章操作正在进行
|
// 2. 没有切章操作正在进行
|
||||||
guard !isSwitchingChapter else { return false }
|
guard !isSwitchingChapter else { return false }
|
||||||
// 3. readerView 没有正在执行的翻页动画
|
// 3. 当前没有正在应用新快照/跳转目标页
|
||||||
// CATransaction 仍在执行时说明页面切换动画未完成
|
guard !isApplyingSnapshot else { return false }
|
||||||
if let readerView = context.readerView {
|
|
||||||
guard !readerView.isAnimating else { return false }
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 切章进行中标记(flipToChapter 开始设 true,completion 回调后设 false)
|
/// 切章进行中标记(flipToChapter 开始设 true,completion 回调后设 false)
|
||||||
private var isSwitchingChapter: Bool = false
|
private var isSwitchingChapter: Bool = false
|
||||||
|
/// 应用快照进行中标记(避免预取回调和主动切章同时刷新 UI)
|
||||||
|
private var isApplyingSnapshot: Bool = false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 19.1.8 DataSource 适配(窗口快照直连)
|
#### 19.1.8 PageProvider 适配(窗口快照直连)
|
||||||
|
|
||||||
> **作用域**:以下 DataSource 改造仅适用于 `textReflowable` 路径。`RDEPUBReaderController` 需在初始化时根据书籍类型选择 DataSource 策略:textReflowable 走窗口快照路径,其他类型(fixed-layout、PDF 等)维持原有整书 `pages` 数组供页。
|
> **作用域**:以下分页提供者改造仅适用于 `textReflowable` 路径。`RDEPUBReaderController` 需在初始化时根据书籍类型选择分页策略:textReflowable 走窗口快照路径并设置 `readerView.pageProvider`,其他类型(fixed-layout、PDF 等)维持原有整书 `pages` 数组/DataSource 路径。
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
// ============ 修改文件:RDEPUBReaderController+DataSource.swift ============
|
// ============ 修改文件:RDEPUBReaderController+DataSource.swift ============
|
||||||
|
|
||||||
// 重要:此扩展仅在 textReflowable 路径启用。
|
// 重要:此扩展仅在 textReflowable 路径启用,并通过 RDReaderPageProvider 接入。
|
||||||
// RDEPUBReaderController 初始化时需判断书籍类型:
|
// RDEPUBReaderController 初始化时需判断书籍类型:
|
||||||
// if publication.metadata.layout == .reflowable {
|
// if publication.metadata.layout == .reflowable {
|
||||||
// setupChapterWindowDataSource() // 走本扩展
|
// readerView.pageProvider = self // 走本扩展
|
||||||
// } else {
|
// } else {
|
||||||
// setupLegacyBookDataSource() // 走原有整书 pages 路径
|
// readerView.pageProvider = nil
|
||||||
|
// readerView.dataSource = self // 走原有整书 pages 路径
|
||||||
// }
|
// }
|
||||||
|
|
||||||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
extension RDEPUBReaderController: RDReaderPageProvider, RDReaderDelegate {
|
||||||
|
|
||||||
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
|
public func numberOfPages(in readerView: RDReaderView) -> Int {
|
||||||
guard let snapshot = windowCoordinator?.currentSnapshot else { return 0 }
|
guard let snapshot = windowCoordinator?.currentSnapshot else { return 0 }
|
||||||
return snapshot.pageCount
|
return snapshot.pageCount
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
|
public func readerView(_ readerView: RDReaderView, viewForPageAt pageNum: Int, reusableView: UIView?) -> UIView {
|
||||||
guard let snapshot = windowCoordinator?.currentSnapshot else {
|
guard let snapshot = windowCoordinator?.currentSnapshot else {
|
||||||
return RDEPUBTextContentView()
|
return reusableView ?? RDEPUBTextContentView()
|
||||||
}
|
}
|
||||||
return textContentViewFromSnapshot(snapshot, pageNum: pageNum, containerView: containerView)
|
return textContentViewFromSnapshot(snapshot, pageNum: pageNum, reusableView: reusableView)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 页面内容构建
|
public func pageIdentifier(in readerView: RDReaderView, index: Int) -> String? {
|
||||||
|
NSStringFromClass(RDEPUBTextContentView.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func readerViewTopChrome(_ readerView: RDReaderView) -> UIView? {
|
||||||
|
topToolView
|
||||||
|
}
|
||||||
|
|
||||||
|
public func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView? {
|
||||||
|
bottomToolView
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 页面内容构建 / 视图复用
|
||||||
|
|
||||||
private func textContentViewFromSnapshot(
|
private func textContentViewFromSnapshot(
|
||||||
_ snapshot: RDEPUBChapterWindowSnapshot,
|
_ snapshot: RDEPUBChapterWindowSnapshot,
|
||||||
pageNum: Int,
|
pageNum: Int,
|
||||||
containerView: UIView?
|
reusableView: UIView?
|
||||||
) -> UIView {
|
) -> UIView {
|
||||||
guard pageNum >= 0, pageNum < snapshot.flattenedPages.count else {
|
guard pageNum >= 0, pageNum < snapshot.flattenedPages.count else {
|
||||||
return RDEPUBTextContentView() // 空 page
|
return reusableView ?? RDEPUBTextContentView()
|
||||||
}
|
}
|
||||||
let page = snapshot.flattenedPages[pageNum]
|
let page = snapshot.flattenedPages[pageNum]
|
||||||
let contentView = RDEPUBTextContentView()
|
let contentView = (reusableView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||||
contentView.configure(with: page, highlights: highlightsForPage(page))
|
contentView.delegate = self
|
||||||
|
contentView.configure(
|
||||||
|
page: page,
|
||||||
|
pageNumber: pageNum + 1,
|
||||||
|
totalPages: snapshot.pageCount,
|
||||||
|
configuration: configuration,
|
||||||
|
highlights: highlightsForPage(page),
|
||||||
|
searchState: searchState
|
||||||
|
)
|
||||||
return contentView
|
return contentView
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2227,7 +2360,7 @@ extension RDEPUBChapterRuntimeStore {
|
|||||||
final class RDEPUBChapterSummaryDiskCache {
|
final class RDEPUBChapterSummaryDiskCache {
|
||||||
private let cacheDirectory: URL
|
private let cacheDirectory: URL
|
||||||
private let fileManager = FileManager.default
|
private let fileManager = FileManager.default
|
||||||
private let queue = DispatchQueue(label: “com.rdreader.summarydiskcache”, qos: .utility)
|
private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility)
|
||||||
|
|
||||||
init(cacheDirectory: URL) {
|
init(cacheDirectory: URL) {
|
||||||
self.cacheDirectory = cacheDirectory
|
self.cacheDirectory = cacheDirectory
|
||||||
@ -2258,9 +2391,9 @@ final class RDEPUBChapterSummaryDiskCache {
|
|||||||
/// hashValue 跨进程不稳定,会导致二次打开缓存失效
|
/// hashValue 跨进程不稳定,会导致二次打开缓存失效
|
||||||
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
||||||
// 用 SHA256 对完整 key 内容取摘要,保证跨进程稳定且无文件名冲突
|
// 用 SHA256 对完整 key 内容取摘要,保证跨进程稳定且无文件名冲突
|
||||||
let rawKey = “\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)”
|
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
|
||||||
let digest = rawKey.sha256Hex
|
let digest = rawKey.sha256Hex
|
||||||
return cacheDirectory.appendingPathComponent(“\(digest).json”)
|
return cacheDirectory.appendingPathComponent("\(digest).json")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2572,21 +2705,15 @@ let chapterLoc = persistence.loadChapterLocation(for: bookID) { legacyLoc in
|
|||||||
chapterLength = cached.typesetAttributedString.length
|
chapterLength = cached.typesetAttributedString.length
|
||||||
} else {
|
} else {
|
||||||
// 3. 缓存未命中(首次打开)→ 主路径:先构建目标章,再用真实长度
|
// 3. 缓存未命中(首次打开)→ 主路径:先构建目标章,再用真实长度
|
||||||
// 这是保证”主路径一定先拿到真实章节长度”的关键步骤
|
// 这是保证"主路径一定先拿到真实章节长度"的关键步骤
|
||||||
let pageSize = context.resolvedPageSize()
|
// 具体实现必须通过章节 loader 的串行构建接口完成,不允许直接恢复整书 TextBook
|
||||||
let style = context.currentStyle
|
let runtimeChapter = try? context.chapterLoader?.loadChapterSynchronouslyForMigration(
|
||||||
let layoutConfig = context.currentLayoutConfig
|
|
||||||
let runtimeChapter = try? context.chapterLoader?.buildChapter(
|
|
||||||
spineIndex: spineIndex,
|
spineIndex: spineIndex,
|
||||||
parser: parser,
|
store: context.chapterRuntimeStore
|
||||||
publication: publication,
|
|
||||||
pageSize: pageSize,
|
|
||||||
style: style,
|
|
||||||
layoutConfig: layoutConfig
|
|
||||||
)
|
)
|
||||||
// 构建成功后放入缓存,后续章节加载可复用
|
// 构建成功后放入缓存,后续章节加载可复用
|
||||||
if let chapter = runtimeChapter {
|
if let chapter = runtimeChapter {
|
||||||
context.chapterRuntimeStore?.set(chapter, for: spineIndex)
|
context.chapterRuntimeStore?.insertChapter(chapter)
|
||||||
}
|
}
|
||||||
chapterLength = runtimeChapter?.typesetAttributedString.length
|
chapterLength = runtimeChapter?.typesetAttributedString.length
|
||||||
}
|
}
|
||||||
@ -2605,8 +2732,8 @@ let chapterLoc = persistence.loadChapterLocation(for: bookID) { legacyLoc in
|
|||||||
|
|
||||||
- 主持久化格式只保留 `RDEPUBChapterLocation`
|
- 主持久化格式只保留 `RDEPUBChapterLocation`
|
||||||
- 历史格式在首次加载时自动迁移并覆盖,不需要单独的迁移脚本
|
- 历史格式在首次加载时自动迁移并覆盖,不需要单独的迁移脚本
|
||||||
- 本方案不维护”运行期双格式并存”或”新旧链路双写”
|
- 本方案不维护"运行期双格式并存"或"新旧链路双写"
|
||||||
- **主路径保证**:`migrator` 闭包内显式处理了”缓存未命中时先构建目标章”的逻辑,确保 `chapterLengthProvider` 在首次打开时也能返回真实章节长度,而不是直接掉到 `progression * 10000` 粗估 fallback
|
- **主路径保证**:`migrator` 闭包内显式处理了"缓存未命中时先构建目标章"的逻辑,确保 `chapterLengthProvider` 在首次打开时也能返回真实章节长度,而不是直接掉到 `progression * 10000` 粗估 fallback
|
||||||
- 构建目标章的开销在首次迁移时只发生一次(迁移后立即覆盖为新格式,后续打开走新格式直接读取)
|
- 构建目标章的开销在首次迁移时只发生一次(迁移后立即覆盖为新格式,后续打开走新格式直接读取)
|
||||||
|
|
||||||
#### 19.4.3 跨章功能适配
|
#### 19.4.3 跨章功能适配
|
||||||
@ -2625,7 +2752,10 @@ extension RDEPUBReaderSearchCoordinator {
|
|||||||
var allMatches: [RDEPUBSearchMatch] = []
|
var allMatches: [RDEPUBSearchMatch] = []
|
||||||
|
|
||||||
for (spineIndex, item) in publication.spine.enumerated() {
|
for (spineIndex, item) in publication.spine.enumerated() {
|
||||||
guard let html = try? context.parser?.htmlContent(for: item.href) else { continue }
|
guard
|
||||||
|
let parser = context.parser,
|
||||||
|
let html = try? parser.htmlString(forRelativePath: item.href)
|
||||||
|
else { continue }
|
||||||
|
|
||||||
// 搜索必须在渲染后纯文本空间进行,不能直接在原始 HTML 上匹配。
|
// 搜索必须在渲染后纯文本空间进行,不能直接在原始 HTML 上匹配。
|
||||||
// 原因:正文链路的 chapterOffset 基于 typesetAttributedString(经 HTML → 渲染 → 去标签),
|
// 原因:正文链路的 chapterOffset 基于 typesetAttributedString(经 HTML → 渲染 → 去标签),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user