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