docs: 补充大书优化方案的 WXRead 复刻依据与缓存语义

添加 3.0 节记录 WXRead 逆向依据(WRReaderViewController 核心语义),
完善 pageCountCache 失效策略与 key 生成说明,对齐 WXRead 运行时行为。
This commit is contained in:
shen
2026-06-03 07:22:25 +08:00
parent 9801af05d3
commit 584976ac5f
+251 -121
View File
@@ -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 lineSpacinglineHeightMultiple 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 truecompletion false /// flipToChapter truecompletion 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