feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理
- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip) - 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示 - 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配 - 新增 RDEPUBSelectionLoupeView 选区放大镜 - 新增 MetadataParseWorker/CancellationController 元数据解析取消机制 - 重构 PresentationRuntime/PaginationCoordinator 精简职责 - 优化 ChapterLoader/WarmupOrchestrator 异步章节加载 - CFI 模块微调与 NoteModels 更新 - 清理冗余文档,更新架构/UML/业务逻辑文档 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,115 +0,0 @@
|
||||
# 架构分析:WXRead 参考 vs ReadViewSDK 当前
|
||||
|
||||
**分析日期:** 2026-05-23
|
||||
**分析基础:** `Doc/WXRead/decompiled-doc.md`、`Doc/WXRead/resources-doc.md`、`Doc/WXRead/读书EPUB阅读器实现架构.md`
|
||||
**状态:** Decisions captured
|
||||
|
||||
---
|
||||
|
||||
<domain>
|
||||
## 分析范围
|
||||
|
||||
对比读书 (WeRead v10.0.3) 逆向架构与当前 ReadViewSDK 项目的结构性差距,聚焦四大方向:分页引擎集成、CSS 处理、渲染架构、字体系统。
|
||||
|
||||
读书 EPUB 渲染的核心架构特征:
|
||||
- Path A (EPUB): 纯 CoreText 渲染 — `WRPageView.drawRect:` → `CTFrameDraw`,不用 UITextView/UILabel
|
||||
- 4 级语义断页:`WRCoreTextLayouter` + `WRCoreTextLayoutFrame` (语义边界 > 附件边界 > 块边界 > 帧限制)
|
||||
- 5 层 CSS 级联:`default.css < replace.css < dark.css < EPUB 内嵌 < 用户设置`
|
||||
- 字符级位置精度:`WREpubPositionConverter` (fileIndex, row, column) ↔ 全局字符偏移
|
||||
- 标注直接在 CTFrame 层叠加绘制,搜索高亮同理
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## 已决事项
|
||||
|
||||
### Area 1: 分页引擎集成(P0)
|
||||
|
||||
| 决策 | 说明 |
|
||||
|------|------|
|
||||
| 将 `RDEPUBTextLayouter` 集成到 `RDEPUBTextBookBuilder` | 内部调用 `rd_paginatedFrames(size:)` 替代旧的 `ss_pageRanges(size:)`,外部 API 不变 |
|
||||
| 分页元数据暴露到 `RDEPUBTextPage` | 新增 `metadata: RDEPUBTextPageMetadata` 字段(含 breakReason/blockKinds/semanticHints),对外暴露分页质量数据 |
|
||||
| `RDEPUBTextChapterPaginationDiagnostic` 增强 | 透传 RDEPUBTextLayoutFrame 的诊断信息 |
|
||||
| 分页缓存 | 按 `bookID + fontSize + lineHeightMultiple + contentInsets` 生成缓存 key,缓存完整 `RDEPUBTextBook` 到磁盘 |
|
||||
|
||||
### Area 2: CSS `<link>` 外部样式表处理(P0)
|
||||
|
||||
| 决策 | 说明 |
|
||||
|------|------|
|
||||
| 预处理内联 CSS | 渲染前扫描 HTML 中 `<link>` 标签,从 EPUB 解压目录读取 CSS 内容,注入 `<style>` 替换 `<link>` |
|
||||
| 实现 5 层 CSS 级联 | `RDEPUBTextStyleSheetBuilder` 实现 `default < replace < dark < epub-embedded < user` 五层合并,使用已定义的 `RDEPUBTextStyleSheetPackage`/`RDEPUBTextStyleSheetLayer` |
|
||||
|
||||
### Area 3: CoreText 直接绘制迁移(P1 — 大架构变更)
|
||||
|
||||
| 决策 | 说明 |
|
||||
|------|------|
|
||||
| 直接替换 UITextView | 新实现完全替代 `RDEPUBTextContentView`,不保留 UITextView 渐进迁移路径 |
|
||||
| CoreText 原生选区 | `CTLineGetStringIndexForPosition` 坐标 hit test + 自定义选区绘制,不依赖 UITextView 选区 |
|
||||
| 标注渲染:CGContext 装饰层 | drawRect 中 CTFrameDraw 绘制文本后,遍历当前页 RDEPUBHighlight,CGContext 绘制背景矩形(highlight)/ 下划线(underline) |
|
||||
| 搜索高亮:CGContext 叠加绘制 | 不修改底层 attributedString,在 drawRect 中根据匹配范围直接绘制高亮背景 |
|
||||
|
||||
### Area 4: 字体系统(P1 — 延迟到后续版本)
|
||||
|
||||
| 决策 | 说明 |
|
||||
|------|------|
|
||||
| 方案:内嵌固定字体集 | SDK bundle 内嵌常用中文字体,Settings 面板新增字体选择。不做 CDN 动态下载 |
|
||||
| 本版本范围:暂不做 | 字体切换功能延迟到后续迭代 |
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## 关键参考文档
|
||||
|
||||
**WXRead 逆向参考(必须阅读):**
|
||||
- `Doc/WXRead/decompiled-doc.md` — 44 个逆向文件的职责说明
|
||||
- `Doc/WXRead/resources-doc.md` — CSS/JS 资源文件清单与职责
|
||||
- `Doc/WXRead/读书EPUB阅读器实现架构.md` — 双渲染引擎架构总览
|
||||
- `Doc/WXRead/decompiled/WRCoreTextLayoutFrame.m` — 跨页避让、装饰元素、搜索高亮实现
|
||||
- `Doc/WXRead/decompiled/WRCoreTextLayouter.m` — 4 级语义断页配置
|
||||
- `Doc/WXRead/decompiled/WRPageView.m` — CoreText 直接绘制参考
|
||||
- `Doc/WXRead/decompiled/WREpubTypesetter.m` — CSS 级联合并参考
|
||||
- `Doc/WXRead/resources/css/replace.css` — 5 层 CSS 参考
|
||||
|
||||
**当前项目代码(已有的基础设施):**
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift` — 已实现 4 级语义断页,待集成
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayoutFrame.swift` — 帧模型,含 breakReason/semanticHints
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift` — 已定义 `RDEPUBTextStyleSheetPackage`/`RDEPUBTextStyleSheetLayer`,未完全使用
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` — 当前可能仍在用旧的 `ss_pageRanges`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift` — UITextView 实现,待替换为 CoreText
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<roadmap_mapping>
|
||||
## 与现有 Roadmap 的映射
|
||||
|
||||
| 决策 | 对应 Phase | 说明 |
|
||||
|------|-----------|------|
|
||||
| RDEPUBTextLayouter 集成 | Phase 8 (08-02) | 分页质量改善的核心 |
|
||||
| 分页元数据暴露 | Phase 8 (08-03) | 分页诊断输出的一部分 |
|
||||
| 分页缓存 | Phase 8 (08-01) | 缓存键与失效策略 |
|
||||
| CSS `<link>` 内联 | Phase 7 范畴外 | 当前 Roadmap 未覆盖,需新增 phase 或合并到 Phase 8 |
|
||||
| 5 层 CSS 级联 | Phase 7 + Phase 8 | Phase 7 已完成属性闭环,级联是后续增强 |
|
||||
| **CoreText 直接绘制** | **v1.2 或 v2.0** | **超出 v1.1 Roadmap,需要独立 milestone** |
|
||||
| CoreText 原生选区 | 随 CoreText 迁移 | 同上 |
|
||||
| 标注 CGContext 绘制 | 随 CoreText 迁移 | 同上 |
|
||||
| 字体系统 | 未来版本 | 延迟 |
|
||||
|
||||
**关键发现:** CoreText 直接绘制迁移是最大的架构变更,超出 v1.1 的增量改进范围。建议作为 v1.2 独立 milestone 规划。
|
||||
|
||||
</roadmap_mapping>
|
||||
|
||||
<deferred>
|
||||
## 延迟事项
|
||||
|
||||
- **字体系统**:内嵌固定字体集方案已决,延迟到后续版本
|
||||
- **位置精度提升**(字符级锚点):P2,与 CoreText 迁移关联
|
||||
- **章节数据模型内聚**(WRChapterData 模式):P2,架构清晰度改进
|
||||
- **TTS / DRM / Pencil / 多栏排版**:P3,独立功能模块
|
||||
- **翻页控制器健壮性**(UIPageViewController crash patch):P2,当前未遇到相关崩溃
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*分析范围:WXRead 逆向文档 → ReadViewSDK 架构差距*
|
||||
*产出:4 个 Area、14 项决策、1 项延迟*
|
||||
+2
-2
@@ -105,7 +105,7 @@ paginateTextPublication()
|
||||
│ ├─ makePartialPageMap() // 构建局部 BookPageMap
|
||||
│ └─ applyBookPageMap() // 应用到 UI,用户可立即阅读
|
||||
│
|
||||
└─ paginateMetadataOnly() // 后台元数据解析
|
||||
└─ RDEPUBMetadataParseWorker.start() // 后台元数据解析
|
||||
│
|
||||
├─ 预计算 contentHash(串行) // 读取所有章节 HTML + SHA-256
|
||||
├─ readAll(keys:) 恢复已有缓存
|
||||
@@ -245,7 +245,7 @@ RDEPUBReaderController
|
||||
│
|
||||
├─ RDEPUBReaderPaginationCoordinator // 分页入口与后台元数据解析
|
||||
│ ├─ paginatePublication()
|
||||
│ ├─ paginateMetadataOnly()
|
||||
│ ├─ RDEPUBMetadataParseWorker.start()
|
||||
│ └─ restoreBookPageMapIfPossible()
|
||||
│
|
||||
├─ RDEPUBPresentationRuntime // 分页状态与窗口替换
|
||||
|
||||
@@ -1,606 +0,0 @@
|
||||
# Android EPUB 阅读器实施方案
|
||||
|
||||
## Context
|
||||
|
||||
当前 ReadViewSDK 是一个功能完整的 iOS EPUB 阅读器框架(Swift + UIKit),支持 EPUB 2/3 解析、三种渲染模式(重排/固定布局/网页交互)、全文搜索、高亮/书签/批注、分页管理等。目标是基于现有 iOS 版本的架构和 JS 桥接协议,实现一套 Android 版本的 EPUB 阅读器。
|
||||
|
||||
**核心策略**:JS 桥接层(epub-bridge.js、WeReadApi.js 等)直接复用,Swift 逻辑层用 Kotlin 重写,UIKit UI 层用 Android 原生 View 重写。
|
||||
|
||||
---
|
||||
|
||||
## 第一阶段:项目基础设施(预计 1 周)
|
||||
|
||||
### 1.1 创建 Android 项目
|
||||
|
||||
```
|
||||
ReadViewSDK-Android/
|
||||
├── app/ # Demo 应用
|
||||
├── reader-core/ # Kotlin 核心库(解析、模型、搜索)
|
||||
├── reader-jsbridge/ # WebView JS 桥接层
|
||||
├── reader-ui/ # Android UI 组件(工具栏、搜索面板、设置等)
|
||||
├── reader-view/ # 翻页容器(ViewPager2 封装)
|
||||
└── build-logic/ # Gradle convention plugins
|
||||
```
|
||||
|
||||
- 语言:Kotlin
|
||||
- 最低 API:24(Android 7.0)
|
||||
- 目标 API:34
|
||||
- 构建工具:Gradle + Kotlin DSL
|
||||
- 依赖注入:手动(与 iOS 的 `RDEPUBReaderDependencies` 模式一致)
|
||||
|
||||
### 1.2 Gradle 模块划分
|
||||
|
||||
| 模块 | 对应 iOS 层 | 职责 |
|
||||
|------|------------|------|
|
||||
| `reader-core` | EPUBCore/ + EPUBTextRendering/ | 解析、模型、搜索引擎、CSS 生成、分页算法 |
|
||||
| `reader-jsbridge` | RDEPUBJavaScriptBridge + JS Resources | JS 文件管理、消息收发、脚本生成 |
|
||||
| `reader-ui` | EPUBUI/ | 工具栏、搜索面板、设置面板、目录列表、高亮管理 |
|
||||
| `reader-view` | ReaderView/ | 翻页容器(ViewPager2 + PagerSnapHelper) |
|
||||
|
||||
### 1.3 公共依赖
|
||||
|
||||
```kotlin
|
||||
// reader-core
|
||||
implementation("net.lingala.zip4j:zip4j:2.11.5") // 替代 ZIPFoundation
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
|
||||
implementation("com.google.code.gson:gson:2.11.0") // 替代 Codable
|
||||
implementation("androidx.webkit:webkit:1.10.0")
|
||||
|
||||
// reader-ui
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("androidx.viewpager2:viewpager2:1.0.0")
|
||||
|
||||
// reader-view
|
||||
implementation("androidx.viewpager2:viewpager2:1.0.0")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第二阶段:Core 层 — EPUB 解析与模型(预计 2 周)
|
||||
|
||||
### 2.1 数据模型迁移
|
||||
|
||||
将 iOS 的 Codable struct 迁移为 Kotlin data class:
|
||||
|
||||
| iOS 文件 | Android 文件 | 关键类型 |
|
||||
|----------|-------------|---------|
|
||||
| `RDEPUBModels.swift` | `EpubModels.kt` | `EpubLayout`, `EpubMetadata`, `ManifestItem`, `SpineItem`, `TableOfContentsItem` |
|
||||
| `RDEPUBReadingLocationModels.swift` | `ReadingLocationModels.kt` | `ReadingLocation`, `Viewport`, `ReadingContext` |
|
||||
| `RDEPUBAnnotationModels.swift` | `AnnotationModels.kt` | `Selection`, `Highlight`, `Bookmark`, `Annotation` |
|
||||
| `RDEPUBPaginationModels.swift` | `PaginationModels.kt` | `EpubPage`, `ChapterInfo`, `FixedSpread` |
|
||||
| `RDEPUBSearchModels.swift` | `SearchModels.kt` | `SearchMatch`, `SearchResult`, `SearchState`, `SearchPresentation` |
|
||||
| `RDEPUBPreferences.swift` | `ReaderPreferences.kt` | `ReadingPreferences`(字体、行高、颜色、分栏) |
|
||||
| `RDEPUBRenderRequest.swift` | `RenderRequest.kt` | `ReflowableRenderRequest`, `FixedRenderRequest` |
|
||||
| `RDEPUBReaderConfiguration.swift` | `ReaderConfiguration.kt` | 完整阅读器配置 |
|
||||
| `RDEPUBReaderTheme.swift` | `ReaderTheme.kt` | 6 种预设主题 |
|
||||
|
||||
### 2.2 EPUB 解析器迁移
|
||||
|
||||
| iOS 文件 | Android 文件 | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `RDEPUBParser.swift` | `EpubParser.kt` | 主解析入口 |
|
||||
| `RDEPUBParser+Archive.swift` | `EpubParser+Archive.kt` | zip4j 解压(替代 ZIPFoundation) |
|
||||
| `RDEPUBParser+Package.swift` | `EpubParser+Package.kt` | OPF XML SAX 解析 |
|
||||
| `RDEPUBParser+TOC.swift` | `EpubParser+TOC.kt` | NCX/Nav Document 解析 |
|
||||
| `RDEPUBParser+Resources.swift` | `EpubParser+Resources.kt` | HTML/资源文件读取 |
|
||||
| `RDEPUBParser+ReadingProfile.swift` | `EpubParser+ReadingProfile.kt` | 阅读模式判断 |
|
||||
| `RDEPUBPublication.swift` | `EpubPublication.kt` | Facade 门面 |
|
||||
| `RDEPUBResourceResolver.swift` | `ResourceResolver.kt` | URL 规范化 |
|
||||
|
||||
**XML 解析方案**:iOS 使用 `XMLParser`(SAX),Android 使用 `org.xml.sax.XMLReader`(同为 SAX),解析逻辑可逐行对照翻译。
|
||||
|
||||
### 2.3 搜索引擎迁移
|
||||
|
||||
| iOS 文件 | Android 文件 |
|
||||
|----------|-------------|
|
||||
| `RDEPUBSearchEngine.swift` | `EpubSearchEngine.kt` |
|
||||
| `RDEPUBTextSearchEngine.swift` | `TextSearchEngine.kt` |
|
||||
|
||||
搜索引擎核心逻辑(遍历 spine → 读取 HTML → 解析为纯文本 → NSString.range 搜索 → 生成 match)可直接翻译为 Kotlin `String.indexOf` 循环。
|
||||
|
||||
### 2.4 CSS 生成与样式
|
||||
|
||||
| iOS 文件 | Android 文件 |
|
||||
|----------|-------------|
|
||||
| `RDEPUBStyleSheetBuilder.swift` | `StyleSheetBuilder.kt` |
|
||||
| `RDEPUBFixedLayoutTemplate.swift` | `FixedLayoutTemplate.kt` |
|
||||
|
||||
CSS 生成逻辑完全平台无关,直接翻译即可。
|
||||
|
||||
### 2.5 分页算法
|
||||
|
||||
| iOS 文件 | Android 文件 | 说明 |
|
||||
|----------|-------------|------|
|
||||
| `RDEPUBPaginator.swift` | `EpubPaginator.kt` | 离屏 WebView 分页计算 |
|
||||
| `RDEPUBReadingSession.swift` | `ReadingSession.kt` | 状态机 + 页面管理 |
|
||||
|
||||
**关键决策**:Android 版统一使用 WebView 渲染,不实现 `EPUBTextRendering` 层(DTCoreText 的 Android 等价物过于复杂且收益低)。这意味着:
|
||||
|
||||
- **不迁移** `EPUBTextRendering/` 整个目录(18 个文件)
|
||||
- **不迁移** `EPUBUI/TextPage/` 目录(7 个文件)
|
||||
- Android 版的所有内容(包括重排模式)都通过 WebView + CSS column 分页实现
|
||||
- 这与 iOS 的 `webInteractive` 路径一致,已验证可行
|
||||
|
||||
---
|
||||
|
||||
## 第三阶段:JS 桥接层(预计 1 周)
|
||||
|
||||
### 3.1 JS 文件直接复用
|
||||
|
||||
将以下文件直接复制到 Android `assets/` 目录:
|
||||
|
||||
```
|
||||
reader-jsbridge/src/main/assets/js/
|
||||
├── epub-bridge.js # 直接复用
|
||||
├── WeReadApi.js # 直接复用
|
||||
├── cssInjector.js # 直接复用
|
||||
├── rangy-core.js # 直接复用
|
||||
└── rangy-serializer.js # 直接复用
|
||||
```
|
||||
|
||||
### 3.2 WKWebView → Android WebView 桥接映射
|
||||
|
||||
| iOS 机制 | Android 等价物 |
|
||||
|----------|--------------|
|
||||
| `WKScriptMessageHandler.userContentController(_:didReceive:)` | `@JavascriptInterface` 方法 |
|
||||
| `window.webkit.messageHandlers.{name}.postMessage()` | 修改 JS 端调用 `AndroidBridge.{method}()` |
|
||||
| `WKWebView.evaluateJavaScript()` | `WebView.evaluateJavascript()` |
|
||||
| `WKURLSchemeHandler` | `WebViewClient.shouldInterceptRequest()` |
|
||||
| `WKUserScript`(页面加载前注入) | `WebView.addJavascriptInterface()` + `WebViewClient.onPageStarted()` |
|
||||
|
||||
### 3.3 Android Bridge 实现
|
||||
|
||||
```kotlin
|
||||
// EpubBridge.kt — 对应 iOS RDEPUBJavaScriptBridge
|
||||
class EpubBridge(private val callback: BridgeCallback) {
|
||||
|
||||
@JavascriptInterface
|
||||
fun onProgressionChanged(json: String) { callback.onProgressionChanged(json) }
|
||||
|
||||
@JavascriptInterface
|
||||
fun onSelectionChanged(json: String) { callback.onSelectionChanged(json) }
|
||||
|
||||
@JavascriptInterface
|
||||
fun onInternalLink(href: String) { callback.onInternalLink(href) }
|
||||
|
||||
@JavascriptInterface
|
||||
fun onExternalLink(url: String) { callback.onExternalLink(url) }
|
||||
|
||||
@JavascriptInterface
|
||||
fun onJSError(message: String) { callback.onJSError(message) }
|
||||
|
||||
@JavascriptInterface
|
||||
fun onFixedLayoutReady() { callback.onFixedLayoutReady() }
|
||||
}
|
||||
```
|
||||
|
||||
**JS 端修改**:需要将 `window.webkit.messageHandlers.xxx.postMessage(data)` 替换为 `window.AndroidBridge.xxx(JSON.stringify(data))`。可以通过在注入时做字符串替换,或维护一个 Android 版本的 bridge JS 文件。
|
||||
|
||||
### 3.4 WebView 资源拦截
|
||||
|
||||
对应 iOS 的 `ss-reader://book/` 自定义协议:
|
||||
|
||||
```kotlin
|
||||
// EpubSchemeHandler.kt
|
||||
class EpubSchemeHandler(private val publication: EpubPublication) : WebViewClient() {
|
||||
|
||||
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||
val url = request.url.toString()
|
||||
if (url.startsWith("ss-reader://book/")) {
|
||||
val path = url.removePrefix("ss-reader://book/")
|
||||
val data = publication.readResource(path)
|
||||
val mimeType = getMimeType(path)
|
||||
return WebResourceResponse(mimeType, "UTF-8", data.byteInputStream())
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 JavaScriptBridge 脚本生成
|
||||
|
||||
对应 iOS 的 `RDEPUBJavaScriptBridge` 中各种 `*Script()` 方法,生成 `evaluateJavascript()` 调用的 JS 代码字符串。这些方法的核心是拼接 JSON 参数 + 调用 `WeReadApi.*` 方法,逻辑完全相同。
|
||||
|
||||
---
|
||||
|
||||
## 第四阶段:翻页容器(预计 1.5 周)
|
||||
|
||||
### 4.1 PagerView 实现
|
||||
|
||||
对应 iOS `RDReaderView`(UICollectionView + UIPageViewController):
|
||||
|
||||
```kotlin
|
||||
// PagerView.kt — 对应 iOS RDReaderView
|
||||
class PagerView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
private val viewPager: ViewPager2
|
||||
private val adapter: PageAdapter
|
||||
|
||||
enum class DisplayMode { HORIZONTAL, VERTICAL }
|
||||
var displayMode = DisplayMode.HORIZONTAL
|
||||
set(value) { /* 切换 orientation */ }
|
||||
|
||||
fun reloadData() { adapter.notifyDataSetChanged() }
|
||||
fun scrollToPage(index: Int, animated: Boolean) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**PageAdapter**:使用 `RecyclerView.Adapter`,每个 item 是一个 `FrameLayout`,内部放 `WebView`。
|
||||
|
||||
### 4.2 点击区域检测
|
||||
|
||||
对应 iOS `RDReaderTapRegionHandler`:
|
||||
|
||||
```kotlin
|
||||
// TapRegionHandler.kt
|
||||
class TapRegionHandler {
|
||||
enum class Region { LEFT, CENTER, RIGHT }
|
||||
|
||||
fun resolve(x: Float, viewWidth: Float): Region {
|
||||
return when {
|
||||
x < viewWidth / 3 -> Region.LEFT
|
||||
x > viewWidth * 2 / 3 -> Region.RIGHT
|
||||
else -> Region.CENTER
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 预加载控制
|
||||
|
||||
对应 iOS `RDReaderPreloadController`:
|
||||
|
||||
使用 `ViewPager2.offscreenPageLimit` 控制预加载页面数量(默认 1-2 页)。
|
||||
|
||||
---
|
||||
|
||||
## 第五阶段:UI 组件(预计 2 周)
|
||||
|
||||
### 5.1 阅读器主控制器
|
||||
|
||||
对应 iOS `RDEPUBReaderController`:
|
||||
|
||||
```kotlin
|
||||
// EpubReaderFragment.kt — Fragment 优于 Activity,方便宿主嵌入
|
||||
class EpubReaderFragment : Fragment() {
|
||||
|
||||
// 对应 iOS 的各 Coordinator
|
||||
private lateinit var runtime: ReaderRuntime
|
||||
private lateinit var pagerView: PagerView
|
||||
private lateinit var topBar: TopToolBar
|
||||
private lateinit var bottomBar: BottomToolBar
|
||||
private lateinit var searchPanel: SearchPanelView
|
||||
|
||||
// Public API
|
||||
fun openBook(epubUrl: Uri, configuration: ReaderConfiguration = ReaderConfiguration.default)
|
||||
fun goTo(location: ReadingLocation)
|
||||
fun goToPageNumber(pageNumber: Int)
|
||||
fun search(keyword: String)
|
||||
fun addHighlight(selection: Selection, color: Int, note: String?)
|
||||
fun addBookmark(note: String?)
|
||||
// ... 其余 public API
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Coordinator 架构迁移
|
||||
|
||||
保持 iOS 的 Coordinator 模式,每个 Coordinator 作为独立的 Kotlin 类:
|
||||
|
||||
| iOS Coordinator | Android 类 | 职责 |
|
||||
|----------------|-----------|------|
|
||||
| `RDEPUBReaderRuntime` | `ReaderRuntime` | Facade,协调所有子 Coordinator |
|
||||
| `RDEPUBReaderLoadCoordinator` | `LoadCoordinator` | EPUB 文件解析 + Publication 初始化 |
|
||||
| `RDEPUBReaderPaginationCoordinator` | `PaginationCoordinator` | 分页计算 |
|
||||
| `RDEPUBReaderLocationCoordinator` | `LocationCoordinator` | 阅读位置管理 + 持久化 |
|
||||
| `RDEPUBReaderChromeCoordinator` | `ChromeCoordinator` | 工具栏状态同步 |
|
||||
| `RDEPUBReaderAnnotationCoordinator` | `AnnotationCoordinator` | 高亮/书签/批注 CRUD |
|
||||
| `RDEPUBReaderSearchCoordinator` | `SearchCoordinator` | 全文搜索协调 |
|
||||
| `RDEPUBReaderViewportMonitor` | `ViewportMonitor` | 屏幕旋转/尺寸变化检测 |
|
||||
|
||||
### 5.3 WebView 内容视图
|
||||
|
||||
对应 iOS `RDEPUBWebView` + `RDEPUBWebContentView`:
|
||||
|
||||
```kotlin
|
||||
// EpubWebView.kt — 封装 Android WebView
|
||||
class EpubWebView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null
|
||||
) : WebView(context, attrs) {
|
||||
|
||||
private val bridge = EpubBridge(/* ... */)
|
||||
|
||||
fun setup() {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
addJavascriptInterface(bridge, "AndroidBridge")
|
||||
webViewClient = EpubSchemeHandler(publication)
|
||||
webChromeClient = WebChromeClient()
|
||||
}
|
||||
|
||||
fun loadChapter(spineIndex: Int, preferences: ReadingPreferences) {
|
||||
// 生成 HTML 模板 + 注入 CSS + 加载资源
|
||||
}
|
||||
|
||||
fun evaluateScript(script: String, callback: ((String?) -> Unit)? = null) {
|
||||
evaluateJavascript(script) { result -> callback?.invoke(result) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 顶部工具栏
|
||||
|
||||
对应 iOS `RDEPUBReaderTopToolView`:
|
||||
|
||||
```xml
|
||||
<!-- top_tool_bar.xml -->
|
||||
<LinearLayout>
|
||||
<ImageButton android:id="@+id/btnBack" />
|
||||
<TextView android:id="@+id/tvTitle" weight="1" />
|
||||
<ImageButton android:id="@+id/btnSearch" />
|
||||
<ImageButton android:id="@+id/btnBookmark" />
|
||||
</LinearLayout>
|
||||
```
|
||||
|
||||
### 5.5 底部工具栏
|
||||
|
||||
对应 iOS `RDEPUBReaderBottomToolView`:
|
||||
|
||||
```xml
|
||||
<!-- bottom_tool_bar.xml -->
|
||||
<LinearLayout>
|
||||
<ImageButton android:id="@+id/btnTOC" /> <!-- 目录 -->
|
||||
<ImageButton android:id="@+id/btnBookmarks" /> <!-- 书签列表 -->
|
||||
<ImageButton android:id="@+id/btnHighlights" /> <!-- 高亮列表 -->
|
||||
<ImageButton android:id="@+id/btnAddHighlight" /> <!-- 添加高亮 -->
|
||||
<ImageButton android:id="@+id/btnSettings" /> <!-- 设置 -->
|
||||
</LinearLayout>
|
||||
```
|
||||
|
||||
### 5.6 搜索面板
|
||||
|
||||
对应 iOS `RDEPUBReaderSearchBarView`:
|
||||
|
||||
```kotlin
|
||||
// SearchPanelView.kt
|
||||
class SearchPanelView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||
) : LinearLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val searchField: EditText
|
||||
private val cancelButton: Button
|
||||
private val resultsList: RecyclerView // 分组结果列表
|
||||
private val emptyStateLabel: TextView
|
||||
|
||||
fun updateResults(sections: List<SearchSection>, keyword: String, currentMatchIndex: Int?)
|
||||
fun showNoResults()
|
||||
fun showSearching()
|
||||
}
|
||||
```
|
||||
|
||||
### 5.7 设置面板
|
||||
|
||||
对应 iOS `RDEPUBReaderSettingsViewController`:
|
||||
|
||||
使用 `BottomSheetDialogFragment` 实现底部弹出设置面板:
|
||||
- 亮度调节(SeekBar)
|
||||
- 字体大小(+/- 按钮)
|
||||
- 字体选择
|
||||
- 行间距
|
||||
- 主题切换(6 种预设)
|
||||
- 翻页模式切换
|
||||
|
||||
### 5.8 目录列表
|
||||
|
||||
对应 iOS `RDEPUBReaderChapterListController`:
|
||||
|
||||
使用 `BottomSheetDialogFragment` + `RecyclerView` 实现目录列表,支持章节标题显示和点击跳转。
|
||||
|
||||
---
|
||||
|
||||
## 第六阶段:持久化与状态管理(预计 0.5 周)
|
||||
|
||||
### 6.1 持久化实现
|
||||
|
||||
对应 iOS `RDEPUBReaderPersistence`:
|
||||
|
||||
```kotlin
|
||||
// ReaderPersistence.kt
|
||||
interface ReaderPersistence {
|
||||
fun loadLocation(bookId: String): ReadingLocation?
|
||||
fun saveLocation(bookId: String, location: ReadingLocation)
|
||||
fun loadBookmarks(bookId: String): List<Bookmark>
|
||||
fun saveBookmarks(bookId: String, bookmarks: List<Bookmark>)
|
||||
fun loadHighlights(bookId: String): List<Highlight>
|
||||
fun saveHighlights(bookId: String, highlights: List<Highlight>)
|
||||
fun loadSettings(): ReaderSettings
|
||||
fun saveSettings(settings: ReaderSettings)
|
||||
}
|
||||
|
||||
// SharedPreferences 实现
|
||||
class SharedPrefsPersistence(context: Context) : ReaderPersistence {
|
||||
// 使用 Gson 序列化/反序列化
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 状态管理
|
||||
|
||||
使用 Kotlin `StateFlow` 替代 iOS 的手动状态同步:
|
||||
|
||||
```kotlin
|
||||
// ReaderState.kt
|
||||
data class ReaderUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val currentPage: Int = 0,
|
||||
val totalPages: Int = 0,
|
||||
val title: String = "",
|
||||
val isToolbarVisible: Boolean = false,
|
||||
val isSearchVisible: Boolean = false,
|
||||
val bookmarks: List<Bookmark> = emptyList(),
|
||||
val highlights: List<Highlight> = emptyList(),
|
||||
val searchState: SearchState? = null,
|
||||
)
|
||||
|
||||
class ReaderStateManager {
|
||||
private val _uiState = MutableStateFlow(ReaderUiState())
|
||||
val uiState: StateFlow<ReaderUiState> = _uiState.asStateFlow()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第七阶段:主题与外观(预计 0.5 周)
|
||||
|
||||
### 7.1 主题系统
|
||||
|
||||
对应 iOS `RDEPUBReaderTheme`:
|
||||
|
||||
```kotlin
|
||||
// ReaderTheme.kt
|
||||
data class ReaderTheme(
|
||||
val name: String,
|
||||
val contentBackgroundColor: Int, // Color int
|
||||
val textColor: Int,
|
||||
val toolbarBackgroundColor: Int,
|
||||
val toolbarForegroundColor: Int,
|
||||
val isDark: Boolean
|
||||
) {
|
||||
companion object {
|
||||
val LIGHT = ReaderTheme("浅色", Color.WHITE, Color.BLACK, ...)
|
||||
val DARK = ReaderTheme("深色", Color.rgb(0x1A, 0x1A, 0x1A), Color.WHITE, ...)
|
||||
val YELLOW = ReaderTheme("纸质", Color.rgb(0xF5, 0xF0, 0xE0), Color.BLACK, ...)
|
||||
// ... 共 6 种
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 状态栏适配
|
||||
|
||||
根据主题动态设置状态栏颜色(`Window.statusBarColor`)和图标颜色(`WindowInsetsController.isAppearanceLightStatusBars`)。
|
||||
|
||||
---
|
||||
|
||||
## 第八阶段:测试与 Demo(预计 1 周)
|
||||
|
||||
### 8.1 单元测试
|
||||
|
||||
- `EpubParserTest` — EPUB 解析(container.xml、OPF、TOC)
|
||||
- `SearchEngineTest` — 搜索引擎
|
||||
- `StyleSheetBuilderTest` — CSS 生成
|
||||
- `ResourceResolverTest` — URL 解析
|
||||
- `LocationCoordinatorTest` — 位置管理
|
||||
|
||||
### 8.2 集成测试
|
||||
|
||||
- `EpubReaderIntegrationTest` — 完整阅读流程(打开 → 分页 → 翻页 → 搜索 → 高亮)
|
||||
|
||||
### 8.3 Demo App
|
||||
|
||||
对应 iOS `ReadViewDemo`:
|
||||
|
||||
```kotlin
|
||||
// MainActivity.kt
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private fun openBook() {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/epub+zip"
|
||||
}
|
||||
startActivityForResult(intent, REQUEST_OPEN_BOOK)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_OPEN_BOOK && resultCode == RESULT_OK) {
|
||||
val uri = data?.data ?: return
|
||||
val fragment = EpubReaderFragment.newInstance(uri)
|
||||
// 嵌入 Fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 UI 自动化测试
|
||||
|
||||
对应 iOS 的 XCUIApplication 测试(24 个测试文件):
|
||||
- 使用 Espresso + UiAutomator
|
||||
- 覆盖:目录交互、页码导航、书签管理、高亮管理、搜索、设置效果、工具栏状态等
|
||||
|
||||
---
|
||||
|
||||
## 实施时间估算
|
||||
|
||||
| 阶段 | 内容 | 预计工时 |
|
||||
|------|------|---------|
|
||||
| 1 | 项目基础设施 | 1 周 |
|
||||
| 2 | Core 层(解析、模型、搜索、CSS) | 2 周 |
|
||||
| 3 | JS 桥接层 | 1 周 |
|
||||
| 4 | 翻页容器 | 1.5 周 |
|
||||
| 5 | UI 组件 | 2 周 |
|
||||
| 6 | 持久化与状态管理 | 0.5 周 |
|
||||
| 7 | 主题与外观 | 0.5 周 |
|
||||
| 8 | 测试与 Demo | 1 周 |
|
||||
| **合计** | | **约 9.5 周** |
|
||||
|
||||
---
|
||||
|
||||
## 关键技术决策
|
||||
|
||||
1. **统一 WebView 渲染**:不实现 DTCoreText 等效层,所有内容通过 WebView + CSS column 分页。简化实现,与 iOS 的 webInteractive 路径一致。
|
||||
|
||||
2. **Coordinator 模式保留**:与 iOS 架构保持一致,便于两端逻辑对照和维护。
|
||||
|
||||
3. **JS Bridge 微调**:Android 的 `@JavascriptInterface` 与 iOS 的 `WKScriptMessageHandler` 机制不同,JS 端需要适配(用 `AndroidBridge.*` 替代 `window.webkit.messageHandlers.*`)。
|
||||
|
||||
4. **不使用 Kotlin Multiplatform**:当前阶段直接用 Kotlin 重写,避免 KMP 的额外复杂度。未来如果需要共享逻辑层,可以逐步迁移。
|
||||
|
||||
5. **ViewPager2 替代 PageViewController**:Android 无 page curl 效果,使用 ViewPager2 + 自定义 PageTransformer 实现翻页动画(可选深度效果或滑动效果)。
|
||||
|
||||
---
|
||||
|
||||
## 文件映射索引(iOS → Android)
|
||||
|
||||
### 核心映射
|
||||
|
||||
| iOS Swift 文件 | Android Kotlin 文件 | 行数参考 |
|
||||
|---------------|-------------------|---------|
|
||||
| `RDEPUBParser.swift` + extensions | `EpubParser.kt` + extensions | ~600 |
|
||||
| `RDEPUBModels.swift` | `EpubModels.kt` | ~200 |
|
||||
| `RDEPUBPublication.swift` | `EpubPublication.kt` | ~150 |
|
||||
| `RDEPUBJavaScriptBridge.swift` | `EpubJavaScriptBridge.kt` | ~400 |
|
||||
| `RDEPUBStyleSheetBuilder.swift` | `StyleSheetBuilder.kt` | ~300 |
|
||||
| `RDEPUBSearchEngine.swift` | `EpubSearchEngine.kt` | ~150 |
|
||||
| `RDEPUBPaginator.swift` | `EpubPaginator.kt` | ~200 |
|
||||
| `RDEPUBReadingSession.swift` | `ReadingSession.kt` | ~250 |
|
||||
| `RDEPUBReaderController.swift` + extensions | `EpubReaderFragment.kt` + extensions | ~800 |
|
||||
| `RDEPUBReaderContext.swift` | `ReaderContext.kt` | ~150 |
|
||||
| `RDEPUBReaderRuntime.swift` | `ReaderRuntime.kt` | ~200 |
|
||||
| 各 Coordinator 文件 | 对应 `*Coordinator.kt` | 各 ~100-200 |
|
||||
| `RDEPUBWebView.swift` + extensions | `EpubWebView.kt` + extensions | ~500 |
|
||||
| `RDReaderView.swift` + extensions | `PagerView.kt` + extensions | ~400 |
|
||||
| `RDEPUBReaderSearchBarView.swift` | `SearchPanelView.kt` | ~500 |
|
||||
| `RDEPUBReaderTopToolView.swift` | `TopToolBar.kt` | ~150 |
|
||||
| `RDEPUBReaderBottomToolView.swift` | `BottomToolBar.kt` | ~200 |
|
||||
| `RDEPUBReaderSettingsViewController.swift` | `SettingsSheetFragment.kt` | ~300 |
|
||||
|
||||
### 直接复用(不需翻译)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `epub-bridge.js` | JS 核心桥接 |
|
||||
| `WeReadApi.js` | JS API 门面 |
|
||||
| `cssInjector.js` | CSS 注入工具 |
|
||||
| `rangy-core.js` | Range 操作库 |
|
||||
| `rangy-serializer.js` | Range 序列化 |
|
||||
|
||||
### 不迁移(Android 不需要)
|
||||
|
||||
| iOS 文件/目录 | 原因 |
|
||||
|-------------|------|
|
||||
| `EPUBTextRendering/`(18 个文件) | DTCoreText/CoreText 无 Android 等价物,统一用 WebView |
|
||||
| `EPUBUI/TextPage/`(7 个文件) | 原生文字渲染层,Android 不需要 |
|
||||
| `RDEPUBTextContentView.swift` | 同上 |
|
||||
| `RDEPUBTextPageRenderView.swift` | 同上 |
|
||||
| `RDEPUBWebDecorationOverlayView.swift` | Android WebView 的高亮由 JS 直接处理,不需要原生 overlay |
|
||||
@@ -177,7 +177,7 @@ while location < totalLength:
|
||||
│ ├─ 加载窗口内相邻章节
|
||||
│ └─ 应用局部 BookPageMap → 用户可立即阅读
|
||||
│
|
||||
└─ 后台解析(paginateMetadataOnly)
|
||||
└─ 后台解析(RDEPUBMetadataParseWorker.start())
|
||||
├─ 预计算所有章节 contentHash(串行,~1-3s)
|
||||
├─ 恢复已有磁盘缓存
|
||||
├─ 等待用户操作冷却 0.8s
|
||||
@@ -196,7 +196,7 @@ while location < totalLength:
|
||||
var contentHashBySpineIndex: [Int: String] = [:]
|
||||
for spineIndex in allBuildableIndices {
|
||||
let html = parser.htmlString(forRelativePath: href)
|
||||
contentHashBySpineIndex[spineIndex] = html?.sha256Hex ?? ""
|
||||
contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? ""
|
||||
}
|
||||
```
|
||||
|
||||
@@ -206,7 +206,7 @@ for spineIndex in allBuildableIndices {
|
||||
|
||||
**问题:** 如果用户在后台解析进行中更改字号/行距,`context.currentRenderSignature()` 会返回新值,导致部分章节用旧签名、部分用新签名写入磁盘缓存,造成缓存不一致。
|
||||
|
||||
**解决:** 在 `paginateMetadataOnly` 开始时冻结签名:
|
||||
**解决:** 在 `RDEPUBMetadataParseWorker` 初始化时冻结签名:
|
||||
|
||||
```swift
|
||||
let renderSignature = context.currentRenderSignature()
|
||||
|
||||
@@ -328,7 +328,7 @@ func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage?
|
||||
### 7.1 整体流程
|
||||
|
||||
```
|
||||
paginateMetadataOnly(token)
|
||||
RDEPUBMetadataParseWorker.start(token)
|
||||
│
|
||||
├─ 预计算所有章节 contentHash(串行)
|
||||
├─ readAll(keys:) 批量读取磁盘缓存
|
||||
@@ -354,7 +354,7 @@ paginateMetadataOnly(token)
|
||||
var contentHashBySpineIndex: [Int: String] = [:]
|
||||
for spineIndex in allBuildableIndices {
|
||||
let html = parser.htmlString(forRelativePath: href)
|
||||
contentHashBySpineIndex[spineIndex] = html?.sha256Hex ?? ""
|
||||
contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? ""
|
||||
}
|
||||
```
|
||||
|
||||
@@ -364,7 +364,7 @@ for spineIndex in allBuildableIndices {
|
||||
|
||||
**问题**:用户在后台解析进行中更改字号/行距,会导致部分章节用旧签名、部分用新签名写入缓存。
|
||||
|
||||
**解决**:在 `paginateMetadataOnly` 开始时冻结签名:
|
||||
**解决**:在 `RDEPUBMetadataParseWorker` 初始化时冻结签名:
|
||||
|
||||
```swift
|
||||
let renderSignature = context.currentRenderSignature()
|
||||
|
||||
@@ -58,7 +58,10 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
|------|------|
|
||||
| `RDEPUBReaderController+PublicAPI.swift` | 公开 API(跳转、搜索、标注、书签) |
|
||||
| `RDEPUBReaderController+DataSource.swift` | `RDReaderPageProvider` 实现 |
|
||||
| `RDEPUBReaderController+ContentDelegates.swift` | WebView/TextContentView 代理 |
|
||||
| `RDEPUBReaderController+ContentDelegates.swift` | WebView/TextContentView 代理路由 |
|
||||
| `RDEPUBReaderController+LocationResolution.swift` | 页码/位置解析、阅读状态同步 |
|
||||
| `RDEPUBReaderController+ExternalLinks.swift` | 外部链接处理(白名单、确认弹窗) |
|
||||
| `RDEPUBReaderController+AttachmentTooltip.swift` | 附件 alt 文本 tooltip 展示 |
|
||||
| `RDEPUBReaderController+RenderSupport.swift` | 渲染辅助(WebView/TextContent 创建) |
|
||||
| `RDEPUBReaderController+RuntimeBridge.swift` | Runtime 桥接 |
|
||||
| `RDEPUBReaderController+TableOfContents.swift` | 目录处理 |
|
||||
@@ -410,7 +413,8 @@ struct RDEPUBResolvedPage {
|
||||
| `RDEPUBChapterLocation.swift` | 章节位置模型 |
|
||||
| `RDEPUBChapterOffsetMap.swift` | 章节偏移映射 |
|
||||
| `RDEPUBBackgroundTrace.swift` | 后台任务追踪日志 |
|
||||
| `String+SHA256.swift` | SHA256 哈希扩展 |
|
||||
| `RDEPUBMetadataParseWorker.swift` | 后台元数据解析 worker |
|
||||
| `RDEPUBMetadataParseCancellationController.swift` | 解析取消控制器 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
# Reflowable EPUB 使用 WXRead 风格原生渲染:详细设计
|
||||
|
||||
> 文档目的:讨论并固化“将 ReadViewSDK 的 reflowable EPUB 渲染/排版/分页改为参考 Doc/WXRead 的读书(WXRead)原生渲染方式”的可落地设计,供后续开发与回归使用。
|
||||
> 版本:v0(设计草案)
|
||||
> 日期:2026-05-21
|
||||
|
||||
## 0. 背景与结论(先说人话)
|
||||
|
||||
ReadViewSDK 当前对 EPUB 有三类渲染路径:
|
||||
|
||||
- `RDEPUBReadingProfile.webFixedLayout`:Fixed Layout EPUB → `WKWebView`(保持不变)
|
||||
- `RDEPUBReadingProfile.webInteractive`:交互式 EPUB(JS/音视频/表单/iframe/外链/bridge)→ `WKWebView`(保持不变)
|
||||
- `RDEPUBReadingProfile.textReflowable`:普通 reflowable EPUB → **当前走 DTCoreText → NSAttributedString → CoreText 分页 → 原生文本视图**(这是我们要“升级成 WXRead 风格”的主战场)
|
||||
|
||||
本次改造的最小可落地方向是:**保留三分流策略不变**,只增强 `.textReflowable` 分支,使其在“CSS 分层、样式一致性、资源解析、分页稳定性”等方面更接近 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 所描述的 WXRead 管线,而不是引入新的 WebView 渲染。
|
||||
|
||||
## 1. 目标 / 非目标
|
||||
|
||||
### 1.1 目标(In Scope)
|
||||
|
||||
- G1:reflowable EPUB 的正文渲染/排版/分页改为“WXRead 风格原生渲染管线”:
|
||||
- XHTML/HTML →(CSS 分层 + 解析 + 后处理)→ `NSAttributedString`
|
||||
- `NSAttributedString` →(CoreText 分页)→ 单页内容
|
||||
- 单页内容 → 原生绘制/展示
|
||||
- G2:保持 Fixed Layout 与交互式 EPUB 的 `WKWebView` 路径不回归。
|
||||
- G3:不破坏 `RDURLReaderController` 打开 `.epub` / `.txt` 的主流程。
|
||||
- G4:Demo 可用于验证:至少 2-3 本典型 reflowable EPUB 在段落/标题/图片/链接等常见内容下可稳定阅读。
|
||||
|
||||
### 1.2 非目标(Out of Scope)
|
||||
|
||||
- N1:Fixed Layout EPUB 切换为原生渲染(明确不做)。
|
||||
- N2:交互式 EPUB 切换为原生渲染(明确不做)。
|
||||
- N3:一次性复刻 WXRead 对 DTCoreText 的所有深度魔改(例如自定义 CSS 属性体系、复杂后处理、分页避断规则等)——本次先按“问题驱动”逐步对齐。
|
||||
|
||||
## 2. 关键事实核验(当前代码真实路径)
|
||||
|
||||
> 纠偏说明:项目初始化时曾把”reflowable EPUB 当前路径”概括为偏 `WKWebView` 的历史性表述。经本次代码核验,当前真实主路径是 `.textReflowable` → `RDEPUBTextBookBuilder` → `RDEPUBDTCoreTextRenderer` → CoreText 分页 → `RDEPUBTextContentView`。本设计以代码事实为准,并默认后续实现都按此理解推进。
|
||||
|
||||
### 2.1 渲染路径分流(已存在)
|
||||
|
||||
- 判定在 `Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift`:
|
||||
- `metadata.layout == .fixed` → `.webFixedLayout`
|
||||
- `hasInteractiveContent() == true` → `.webInteractive`
|
||||
- 否则 → `.textReflowable`
|
||||
|
||||
### 2.2 `.textReflowable` 当前实现(已存在,且是正确切入点)
|
||||
|
||||
`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`:
|
||||
|
||||
- `publication.readingProfile == .textReflowable` 时:
|
||||
- 使用 `RDEPUBTextBookBuilder(renderer: resolvedTextRenderer())`
|
||||
- 默认 renderer 是 `RDEPUBDTCoreTextRenderer`(`#if canImport(DTCoreText)`)
|
||||
- 分页使用 `NSAttributedString.ss_pageRanges(size:)`(`CTFramesetterCreateFrame` + `CTFrameGetVisibleStringRange`)
|
||||
- UI 展示使用 `RDEPUBTextContentView`
|
||||
|
||||
结论:我们不需要“新起一个阅读器”,只要把 `.textReflowable` 的 **渲染(typesetter)层**与部分 **分页策略**升级即可。
|
||||
|
||||
## 3. WXRead 参考模型(我们要对齐的最小子集)
|
||||
|
||||
来自 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 的管线:
|
||||
|
||||
1) `WREpubParser`:解析 EPUB 结构(spine、manifest、resourceMap)
|
||||
2) `WREpubTypesetter`:XHTML → `NSAttributedString`(CSS 级联 + HTML 解析 + 后处理)
|
||||
3) `WRCoreTextLayouter`:`NSAttributedString` → 分页布局(`CTTypesetter` + 分页算法)
|
||||
4) `WRCoreTextLayoutFrame`:单页 layout frame
|
||||
5) `WRPageView`:绘制到屏幕
|
||||
|
||||
本次设计对齐重点(最小集合):
|
||||
|
||||
- A:**CSS 分层与合成**(default/replace/dark/epub/user)并注入到渲染输入
|
||||
- B:**资源解析**(图片/CSS 的相对路径 baseURL)与稳定性保障
|
||||
- C:在现有分页基础上逐步迭代(先可用,后对齐“避免断页”等高级策略)
|
||||
|
||||
### 3.1 第一阶段实施假设(必须遵守)
|
||||
|
||||
- H1:**第一期只对齐“管线形态”和“CSS 分层策略”**,即把现有 `.textReflowable` renderer 增强为更接近 WXRead 的 typesetter 输入与样式组织方式。
|
||||
- H2:**第一期不实现 WXRead 对 DTCoreText 的深度魔改**,包括但不限于自定义 CSS 属性体系、复杂附件布局规则、完整的 `WRCoreTextLayouter` / `WRCoreTextLayoutFrame` 等价分页器。
|
||||
- H3:当开发过程中遇到图片断页、复杂样式缺失、附件布局异常等问题时,默认先作为“第二阶段问题清单”记录;只有在它阻塞 `REND-01` / `STAB-02` 的最小验收时,才允许做局部补丁,而不是扩展为全面重写分页引擎。
|
||||
|
||||
## 4. 是否能直接使用 Doc/WXRead 中的 JS/CSS?
|
||||
|
||||
结论:**不建议、也不应该直接把“来自读书 App bundle 的私有 JS/CSS”拷贝进 SDK 作为产品代码**;但可以按以下原则“选择性使用”:
|
||||
|
||||
### 4.1 可以使用的情况(需满足其一)
|
||||
|
||||
- 文件本身带有明确开源许可证声明,且我们按许可证要求引入(保留 license、署名、NOTICE 等),并建议从官方 upstream 获取:
|
||||
- 例如 `Doc/WXRead/resources/js/rangy-core.js` 明确标注 MIT
|
||||
- 例如 `Doc/WXRead/resources/js/Readability.js` 明确标注 Apache-2.0
|
||||
|
||||
> 建议:即便文件里有 license 头,也优先从其原始开源仓库拉取对应版本,而不是从逆向提取的副本直接入库,以降低合规风险。
|
||||
|
||||
### 4.2 不建议/不能直接使用的情况
|
||||
|
||||
- 无明确许可证头、看起来是读书私有逻辑/样式(例如 `weread-highlighter.js`、`MediaPlatform.js`、`replace.css` 等):默认视为私有作品,不应直接拷贝使用。
|
||||
- 即便是“Safari 默认样式”类文件(例如 `default.css` 的注释提到 Safari),也不建议直接照搬;我们可以根据需求写一份“SDK 自己的 default.css / replace.css”,只实现必要规则。
|
||||
|
||||
### 4.3 对本次需求的实际影响
|
||||
|
||||
本次 reflowable EPUB 走原生渲染,不依赖 WebView,因此 **JS 不是本次必需**。
|
||||
CSS 方面我们需要的是“分层策略”和一小部分通用排版规则,可在 SDK 内重写为“WXRead 风格的默认样式集合”。
|
||||
|
||||
## 5. 详细设计(核心)
|
||||
|
||||
### 5.1 总体架构:在现有 `.textReflowable` 上增量替换 renderer
|
||||
|
||||
新增一个 renderer(实现 `RDEPUBTextRenderer`):
|
||||
|
||||
- `RDEPUBWXReadTextRenderer`(新)
|
||||
- 输入:`html: String`, `baseURL: URL?`, `style: RDEPUBTextRenderStyle`
|
||||
- 输出:`RDEPUBRenderedChapterContent`(`NSAttributedString` + `fragmentOffsets`)
|
||||
- 内部职责:
|
||||
1) 读取/生成 CSS 各层(default/replace/dark/user)
|
||||
2) 与 EPUB 自带 CSS 共同作用(通过 HTML 注入 + baseURL)
|
||||
3) 调用 DTCoreText builder 生成 attributedString
|
||||
4) 做最小后处理(段落间距/字体/颜色标准化、fragment marker 提取等)
|
||||
|
||||
切换点:
|
||||
|
||||
- 在 `RDEPUBReaderController.resolvedTextRenderer()`(或其配置位置)增加策略:当开关启用时选择 `RDEPUBWXReadTextRenderer()`,否则沿用 `RDEPUBDTCoreTextRenderer()`。
|
||||
- 建议默认先提供“实验开关”(仅 Demo / debug 可见),降低回归风险。
|
||||
|
||||
### 5.2 CSS 分层策略(WXRead 风格)
|
||||
|
||||
我们在 SDK 内实现与 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 一致的分层概念,但不直接照搬其私有样式文件:
|
||||
|
||||
- Layer 1:`default.css`(SDK 自己维护的基础排版规则)
|
||||
- Layer 2:`replace.css`(SDK 自己维护的替换/增强规则:标题、代码块、图片最大宽度等)
|
||||
- Layer 3:`dark.css`(暗色主题覆盖,仅在暗色主题启用)
|
||||
- Layer 4:EPUB 嵌入 CSS(书籍自带,DTCoreText 解析 HTML 时自然生效;相对路径靠 baseURL)
|
||||
- Layer 5:用户设置 CSS(由 `RDEPUBTextRenderStyle` 动态生成:字体、字号、行高、背景色、文字色等)
|
||||
|
||||
实现方式(建议):
|
||||
|
||||
1) 新增 `RDEPUBWXReadStyleSheetBuilder`:
|
||||
- `func makeDefaultCSS() -> String`
|
||||
- `func makeReplaceCSS() -> String`
|
||||
- `func makeDarkCSS(theme: RDEPUBTheme) -> String?`
|
||||
- `func makeUserCSS(style: RDEPUBTextRenderStyle, theme: RDEPUBTheme) -> String`
|
||||
- `func composeCSS(...) -> String`(按层拼接,后层覆盖前层)
|
||||
|
||||
2) 在 renderer 中将合成后的 CSS 注入到 HTML:
|
||||
- 若存在 `<head>`:插入 `<style id="rd-wxread-layered-style">...`
|
||||
- 若不存在:在 `<html>` 后插入 `<head>...`
|
||||
- 保持原 HTML 内容尽量不改动(外部脚本/交互内容已被 readingProfile 判定剔除到 web 分支)
|
||||
|
||||
### 5.3 baseURL 与资源解析
|
||||
|
||||
目前 `RDEPUBTextBookBuilder` 传入:
|
||||
|
||||
- `baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent()`
|
||||
|
||||
原则:
|
||||
|
||||
- baseURL 必须是“章节文件所在目录”,以确保:
|
||||
- `<img src="...">` 相对路径可解析
|
||||
- `<link href="...">` CSS 相对路径可解析(如果 DTCoreText 支持)
|
||||
|
||||
待核验点(实现时做小实验):
|
||||
|
||||
- DTCoreText 对 `<link rel="stylesheet">` 的解析策略是否完整;若不完整:
|
||||
- 兜底策略:在渲染前解析 HTML 中的 `<link rel="stylesheet">`,读取 CSS 内容并内联到 `<style>`(仅限 `file://` 且位于 EPUB 解压目录内)。
|
||||
|
||||
### 5.4 分页策略(阶段性)
|
||||
|
||||
现状:
|
||||
|
||||
- `NSAttributedString.ss_pageRanges(size:)` 使用 `CTFramesetterCreateFrame` + `CTFrameGetVisibleStringRange`,属于“最小可用分页”。
|
||||
|
||||
WXRead 的更高阶策略(参考 `Doc/WXRead/analysis/DTCoreText自定义修改分析.md`)可能包含:
|
||||
|
||||
- 避免孤行/断页
|
||||
- 图片/附件的分页边界处理
|
||||
- 特定块元素的分页规则
|
||||
|
||||
本次建议:
|
||||
|
||||
- Phase 2:先保持现有分页算法,只要渲染输入(CSS 分层 + 后处理)到位,就能显著改善一致性。
|
||||
- Phase 3:针对真实书籍出现的问题,逐条补齐分页规则(问题驱动),避免一开始就引入复杂分页器导致风险扩大。
|
||||
|
||||
> 范围约束:如果某个分页问题需要引入“新的复杂分页器”或大规模模拟 `WRCoreTextLayouter` / `WRCoreTextLayoutFrame`,应先暂停并回到方案讨论,不默认并入第一期实现。
|
||||
|
||||
### 5.5 与现有高亮/搜索/位置映射的兼容
|
||||
|
||||
当前 `.textReflowable` 路径:
|
||||
|
||||
- 高亮/搜索依赖 `RDEPUBTextBook` 的 `fragmentOffsets` 与 `location/progression` 映射(见 `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` 与 `RDEPUBTextBook` 的 `pageNumber(for:)` / `location(forPageNumber:)`)。
|
||||
|
||||
兼容策略:
|
||||
|
||||
- 继续使用现有的 fragment marker 注入与提取:
|
||||
- `RDEPUBTextRendererSupport.injectFragmentMarkers(...)`
|
||||
- `RDEPUBTextRendererSupport.extractFragmentOffsets(...)`
|
||||
- renderer 只改变“CSS 注入与 DTCoreText options/后处理”,不改变 marker 体系与 `RDEPUBTextBook` 数据结构,以降低 UI 层回归。
|
||||
|
||||
## 6. 开发落点(文件 / 类型 / 目录)
|
||||
|
||||
### 6.1 新增文件(建议位置)
|
||||
|
||||
放在 `Sources/RDReaderView/EPUBTextRendering/`(因为它是 textReflowable 的渲染与分页域):
|
||||
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadTextRenderer.swift`(新)
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadStyleSheetBuilder.swift`(新)
|
||||
- (可选)`Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadHTMLPreprocessor.swift`(新:仅当需要内联 `<link>` CSS 时)
|
||||
|
||||
资源文件(建议):
|
||||
|
||||
- `Sources/RDReaderView/Resources/WXRead/default.css`(新,SDK 自己写)
|
||||
- `Sources/RDReaderView/Resources/WXRead/replace.css`(新,SDK 自己写)
|
||||
- `Sources/RDReaderView/Resources/WXRead/dark.css`(新,SDK 自己写)
|
||||
|
||||
> 注意:这些资源需被 `RDReaderView.podspec` 的 resource bundle 覆盖到(当前资源 bundle 为 `RDReaderViewAssets`,来源 `Sources/RDReaderView/Resources/**`)。
|
||||
|
||||
### 6.2 改动文件(建议最小改动)
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- 在 `resolvedTextRenderer()` 或相邻配置处增加选择逻辑(开关 / 版本策略)
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift`
|
||||
- 如需内联 link CSS:在获取 `rawHTML` 后做预处理(保持接口不变)
|
||||
|
||||
## 7. 验收标准与验证方式(对应 REQUIREMENTS)
|
||||
|
||||
### 对应 REND-01 / REND-03
|
||||
|
||||
- 在 Demo 中打开 reflowable EPUB:
|
||||
- 正文渲染不依赖 `WKWebView`(可通过日志/断点确认不走 `RDEPUBWebContentView`)
|
||||
- CSS 分层生效:默认样式可控、主题/字号/行高变化可控
|
||||
- 图片/链接至少可正确显示/响应(链接行为按现有 text 内容策略)
|
||||
|
||||
### 对应 REND-02
|
||||
|
||||
- Fixed Layout EPUB:仍走 `.webFixedLayout` → `WKWebView` 路径
|
||||
- 交互式 EPUB:仍走 `.webInteractive` → `WKWebView` 路径(桥接与外链不回归)
|
||||
|
||||
### 对应 STAB-01 / STAB-02
|
||||
|
||||
- `RDURLReaderController` 打开 `.epub` / `.txt` 主流程不回归
|
||||
- 至少使用以下 3 类 reflowable EPUB 样本进行回归:
|
||||
- 样本 A:纯文本/小说类章节为主,验证基础段落、标题、分页与阅读位置恢复
|
||||
- 样本 B:包含内嵌图片与多段样式的章节,验证图片显示、图片前后分页、基础 CSS 生效
|
||||
- 样本 C:包含外链与多个 CSS 文件引用的章节,验证 baseURL、样式解析与链接呈现稳定性
|
||||
- 对以上样本的共同要求:不崩溃、不白屏、不无限加载;分页/翻页可用
|
||||
|
||||
## 8. 风险清单与降级策略
|
||||
|
||||
### 8.1 主要风险
|
||||
|
||||
- R1:DTCoreText 对 EPUB 内嵌 CSS / `<link>` CSS 支持不足,导致样式缺失
|
||||
- R2:分页质量不足(断页不美观、图片分页异常)
|
||||
- R3:`hasInteractiveContent()` 判定过宽,导致大量书被误判为 `.webInteractive`,覆盖率不足
|
||||
|
||||
### 8.2 降级/灰度(建议)
|
||||
|
||||
- D1:增加一个“渲染引擎开关”(仅 debug 或 demo 可配置),可在出现严重问题时快速回退到现有 `RDEPUBDTCoreTextRenderer`
|
||||
- D2:对 `hasInteractiveContent()` 的判定提供可配置白名单/黑名单(例如按 manifest properties、按 tag 命中级别)
|
||||
|
||||
## 9. 下一步(交接到开发)
|
||||
|
||||
建议按 `Doc/ARCHITECTURE-CONTEXT.md` 中的架构决策逐步推进:
|
||||
|
||||
- 先基于 `Doc/WXRead/analysis/*` 提炼“我们要实现的 CSS 分层最小集合”
|
||||
- 再在 `.textReflowable` renderer 里实现“分层 CSS 注入 + baseURL/资源解析兜底”
|
||||
- 最后用 Demo 书籍做回归,按问题驱动补齐分页/样式细节
|
||||
|
||||
---
|
||||
*Last updated: 2026-05-21 after discuss-feature-solution*
|
||||
@@ -1,365 +0,0 @@
|
||||
# 将高亮选区实现 1:1 复刻为 WXRead 架构
|
||||
|
||||
## Context
|
||||
|
||||
当前 ReadViewSDK 的高亮选区实现与 WXRead 存在根本性架构差异。需要将选区系统从"UITextView 透明代理 + 独立 overlay 层"迁移到 WXRead 的"自定义手势 + CoreText 直接命中测试 + 统一 drawRect 绘制"架构。
|
||||
|
||||
## 核心差异对比
|
||||
|
||||
| 维度 | 当前 ReadViewSDK | WXRead |
|
||||
|------|-----------------|--------|
|
||||
| 选择触发 | UITextView 原生长按(透明文本) | 自定义 long-press(0.5s) + pan 手势 |
|
||||
| 命中测试 | DTCoreText `stringIndex(forPosition:)` | `CTLineGetStringIndexForPosition` + 坐标翻转 |
|
||||
| 选区绘制 | 独立 `RDEPUBSelectionOverlayView` overlay 层 | 同一 `drawRect:` 内绘制(文字+高亮+选区) |
|
||||
| 高亮绘制 | overlay 层计算 rect 后 CG 填充 | `com.weread.highlight` 自定义属性注入 NSAttributedString,在 `drawInContext:` 中读取绘制 |
|
||||
| 菜单系统 | 自定义 `selectionActionBar` UIStackView | `UIMenuController` + 自定义 items |
|
||||
| 手势模型 | 无 pan 手势(UITextView 自带拖拽) | long-press 启动 + pan 扩展,`isSelecting` 控制 pan 启停 |
|
||||
| 视图层级 | 3 层 overlay(background + text + foreground) | 单一 WRPageView 统一绘制 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 移除 UITextView,改用自定义手势 + CoreText 命中测试
|
||||
|
||||
### 1.1 修改 `RDEPUBPageInteractionController.swift`
|
||||
|
||||
当前已正确封装 DTCoreText 的 `stringIndex(forPosition:)` 和 `offset(forStringIndex:)`,算法与 WXRead 一致。
|
||||
|
||||
**新增方法:**
|
||||
- `characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView)` — 将 UIKit 坐标转为相对于 content view 的坐标后调用 `characterIndex(at:)`,对应 WXRead 的 `stringIndexForPoint:` + `WRSFlipPointForCoreText`
|
||||
|
||||
> 注意:DTCoreText 已在内部处理了 UIKit↔CoreText 坐标翻转(`line.baselineOrigin` 是 UIKit 坐标),所以不需要手动翻转 Y 轴。但 WXRead 的自定义 DTCoreText 需要手动翻转。当前项目用的是原版 DTCoreText pod,行为已正确。
|
||||
|
||||
### 1.2 重构 `RDEPUBTextSelectionController.swift`
|
||||
|
||||
**当前状态:** 遵循 `UITextViewDelegate`,通过 `textViewDidChangeSelection` 接收选区变化。`handleLongPress` 方法存在但未被任何手势调用。
|
||||
|
||||
**改为:**
|
||||
- 移除 `UITextViewDelegate` 遵循
|
||||
- 移除 `textViewDidChangeSelection(_:)` 和 `textViewDidChangeSelection(_:, page:)`
|
||||
- 新增状态属性:`selectionStartIndex: Int = NSNotFound`、`selectionEndIndex: Int = NSNotFound`、`isSelecting: Bool = false`
|
||||
- 重构 `handleLongPress`:
|
||||
- `.began`:调用 `characterIndex(at:)` 设置 `selectionStartIndex = selectionEndIndex = index`,设 `isSelecting = true`
|
||||
- `.ended`:设 `isSelectionFromInteraction = false`(保留,供后续扩展)
|
||||
- 新增 `handlePan(_ gesture:, page:, renderView:, interactionController:)`:
|
||||
- `.changed`:计算字符索引,更新 `selectionEndIndex`,计算 range = `(min, max - min)`,计算 rects,更新 renderView
|
||||
- 移除 `clearSelection` 的 `textView:` 参数
|
||||
- `makeSelection(from:, page:)` 保持不变(已正确基于绝对偏移构建 `RDEPUBSelection`)
|
||||
|
||||
### 1.3 重构 `RDEPUBTextContentView.swift` — 移除 UITextView
|
||||
|
||||
**删除:**
|
||||
- `textView: RDEPUBSelectableTextView` 属性及其初始化
|
||||
- `selectionProxyContent(from:)` 方法
|
||||
- `textView.delegate = selectionController` 等 textView 配置代码
|
||||
- `textView.frame = ...` 在 `layoutSubviews` 中的设置
|
||||
- `configure(page:...)` 中所有 `textView.attributedText = ...`、`textView.selectedRange = ...`、`textView.isHidden = ...` 赋值
|
||||
|
||||
**新增手势识别器(对齐 WXRead 的 WRPageView):**
|
||||
```swift
|
||||
private let longPressGR = UILongPressGestureRecognizer(target: ..., action: #selector(handleLongPress))
|
||||
private let panGR = UIPanGestureRecognizer(target: ..., action: #selector(handlePan))
|
||||
private let tapGR = UITapGestureRecognizer(target: ..., action: #selector(handleTap))
|
||||
```
|
||||
- `longPressGR.minimumPressDuration = 0.5`(与 WXRead 一致)
|
||||
- `panGR.isEnabled = false`(初始禁用,long-press began 时启用)
|
||||
- `tapGR.require(toFail: longPressGR)`(与 WXRead 一致)
|
||||
- 三个手势都添加到 contentView 自身
|
||||
|
||||
**手势响应:**
|
||||
- `handleLongPress`:转发给 `selectionController.handleLongPress`,启用 `panGR`
|
||||
- `handlePan`:转发给 `selectionController.handlePan`
|
||||
- `handleTap`:如果 `selectionController.isSelecting` 则 `clearSelection()`,否则转发给 delegate 做工具栏切换
|
||||
|
||||
**菜单改为 UIMenuController(对齐 WXRead):**
|
||||
- 删除 `selectionActionBar: UIStackView` 及相关方法(`showSelectionActionBarIfNeeded`、`hideSelectionActionBar`、`updateSelectionActionBarFrame`、`selectionMenuButton`)
|
||||
- 在 `selectionController.onSelectionChanged` 回调中,当 selection 非 nil 时调用 `showSelectionMenu(in:anchorRect:)`
|
||||
- `RDEPUBTextContentView` 设为 `canBecomeFirstResponder = true`,override `canPerformAction` 仅允许三个自定义 selector
|
||||
- 使用 `UIMenuController.shared` 配置 "拷贝"/"高亮"/"批注" 三个 `UIMenuItem`
|
||||
|
||||
**调整 `clearSelection()`:**
|
||||
```swift
|
||||
func clearSelection() {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
panGR.isEnabled = false
|
||||
selectionController.clearSelection(overlayView: overlayView, backgroundOverlayView: backgroundOverlayView)
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 删除 `RDEPUBSelectableTextView.swift`
|
||||
|
||||
该文件的功能(屏蔽系统菜单、暴露自定义 action)已被 UIMenuController 方案替代,直接删除。
|
||||
|
||||
### 1.5 更新 `RDEPUBReaderController+ContentDelegates.swift`
|
||||
|
||||
`textContentView(_:, didRequestSelectionAction:, selection:)` 中的 `contentView.clearSelection()` 调用无需改动,新的 `clearSelection()` 签名兼容。
|
||||
|
||||
### Phase 1 验证
|
||||
- UI 测试 `ReaderAnnotationTests.testSelectionMenuCreatesHighlight` 必须通过
|
||||
- 手动验证:长按选词 → 弹出 UIMenuController → 点击"高亮" → 高亮创建成功
|
||||
- 手动验证:拖拽扩展选区 → 蓝色选区矩形正确绘制
|
||||
- 手动验证:单击空白处 → 选区清除
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 统一绘制循环 — 高亮/选区在 draw(_:) 中绘制
|
||||
|
||||
### 2.1 扩展 `RDEPUBTextPageRenderView.swift`
|
||||
|
||||
**当前状态:** 仅调用 `layoutFrame.draw(in: context, options:)` 绘制文字。
|
||||
|
||||
**新增属性:**
|
||||
```swift
|
||||
var highlightRanges: [(range: NSRange, color: UIColor)] = [] { didSet { setNeedsDisplay() } }
|
||||
var underlineRanges: [(range: NSRange, color: UIColor, style: Int)] = [] { didSet { setNeedsDisplay() } }
|
||||
var selectionRects: [CGRect] = [] { didSet { setNeedsDisplay() } }
|
||||
var selectionColor: UIColor = UIColor(red: 70/255, green: 140/255, blue: 1, alpha: 0.24)
|
||||
```
|
||||
|
||||
**扩展 `draw(_:)`:**
|
||||
```swift
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext(), let layoutFrame else { return }
|
||||
context.saveGState()
|
||||
|
||||
// 1. 绘制高亮背景(在文字下方,匹配 WXRead 的 drawHighlightsInContext:)
|
||||
drawHighlights(in: context, layoutFrame: layoutFrame)
|
||||
|
||||
// 2. 绘制文字
|
||||
layoutFrame.draw(in: context, options: drawOptions)
|
||||
|
||||
// 3. 绘制选区(在文字上方,匹配 WXRead 的 _drawSelectionInContext:)
|
||||
drawSelection(in: context)
|
||||
|
||||
context.restoreGState()
|
||||
}
|
||||
```
|
||||
|
||||
**`drawHighlights` 算法(对齐 WXRead `WRCoreTextLayoutFrame.drawHighlightsInContext:`):**
|
||||
```swift
|
||||
private func drawHighlights(in context: CGContext, layoutFrame: DTCoreTextLayoutFrame) {
|
||||
for (range, color) in highlightRanges {
|
||||
let lines = layoutFrame.lines as! [DTCoreTextLayoutLine]
|
||||
for line in lines {
|
||||
let overlap = NSIntersectionRange(range, line.stringRange)
|
||||
guard overlap.length > 0 else { continue }
|
||||
let startX = line.offset(forStringIndex: overlap.location)
|
||||
let endX = line.offset(forStringIndex: overlap.location + overlap.length)
|
||||
let rect = CGRect(
|
||||
x: line.baselineOrigin.x + startX,
|
||||
y: line.baselineOrigin.y - line.ascent,
|
||||
width: endX - startX,
|
||||
height: line.ascent + line.descent
|
||||
)
|
||||
color.withAlphaComponent(0.35).setFill() // WXRead 使用 35% alpha
|
||||
context.fill(rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 说明:WXRead 使用 `[color colorWithAlphaComponent:0.3]`,WRPageHighlight 的预设色本身已是 35% alpha,最终效果等同。当前项目使用 0.45 alpha,需调整为 0.35 以完全对齐。
|
||||
|
||||
**`drawSelection` 算法(对齐 WXRead `_drawSelectionInContext:`):**
|
||||
```swift
|
||||
private func drawSelection(in context: CGContext) {
|
||||
guard !selectionRects.isEmpty else { return }
|
||||
selectionColor.setFill()
|
||||
for rect in selectionRects {
|
||||
context.fill(rect)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 简化 `RDEPUBTextContentView.swift` 视图层级
|
||||
|
||||
**删除/保留:**
|
||||
- 删除 `backgroundOverlayView` 属性(高亮背景现在由 renderView 在文字下方绘制)
|
||||
- 保留 `overlayView`(用于非 DTCoreText 回退路径和搜索高亮)
|
||||
- `configure(page:...)` 中,将 highlight 数据传给 `coreTextContentView` 而非 overlayView:
|
||||
|
||||
```swift
|
||||
// DTCoreText 路径
|
||||
coreTextContentView.highlightRanges = highlights.compactMap { highlight -> (NSRange, UIColor)? in
|
||||
guard let rangeInfo = highlight.rangeInfo,
|
||||
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { return nil }
|
||||
let absoluteRange = info.nsRange
|
||||
let overlap = NSIntersectionRange(absoluteRange, pageAbsoluteRange)
|
||||
guard overlap.length > 0 else { return nil }
|
||||
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
|
||||
return (relativeRange, highlight.uiColor)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 更新 `RDEPUBTextSelectionController.swift` — 直接更新 renderView
|
||||
|
||||
- `handleLongPress` 和 `handlePan` 现在直接更新 `renderView.selectionRects` 并调用 `renderView.setNeedsDisplay()`
|
||||
- 移除 `overlayView.updateSelection(absoluteRange:, rects:)` 调用
|
||||
|
||||
### Phase 2 验证
|
||||
- 视觉对比:高亮矩形与文字像素对齐
|
||||
- 性能测试:单次 `draw(_:)` 耗时应与之前持平或更快
|
||||
- 回归测试:搜索高亮仍正常显示
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 高亮属性注入 NSAttributedString(对齐 WXRead `com.weread.highlight`)
|
||||
|
||||
### 3.1 定义自定义属性常量
|
||||
|
||||
```swift
|
||||
// 对齐 WXRead 的 kWRHighlightAttributeName / kWRUnderlineAttributeName
|
||||
let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
|
||||
let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
|
||||
```
|
||||
|
||||
### 3.2 新增 `RDEPUBChapterData.applyHighlights(to:page:highlights:)`
|
||||
|
||||
对齐 WXRead 的 `WRChapterData.addHighlightInRange:key:itemId:color:`:
|
||||
|
||||
```swift
|
||||
func applyHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
for highlight in highlights {
|
||||
guard let rangeInfo = highlight.rangeInfo,
|
||||
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { continue }
|
||||
let absoluteRange = info.nsRange
|
||||
let pageRange = NSRange(location: page.pageStartOffset, length: page.pageEndOffset - page.pageStartOffset)
|
||||
let overlap = NSIntersectionRange(absoluteRange, pageRange)
|
||||
guard overlap.length > 0 else { continue }
|
||||
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
|
||||
|
||||
switch highlight.style {
|
||||
case .highlight:
|
||||
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
|
||||
case .underline:
|
||||
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 `RDEPUBTextPageRenderView.draw(_:)` 从属性读取高亮
|
||||
|
||||
替代 Phase 2 的 `highlightRanges` 属性方案,改为在 `draw(_:)` 中枚举 attributed string 的自定义属性:
|
||||
|
||||
```swift
|
||||
private func drawHighlightsFromAttributes(in context: CGContext, attributedString: NSAttributedString) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
|
||||
guard let color = value as? UIColor else { return }
|
||||
let rects = computeRects(for: range) // 复用 line 迭代 + CTLineGetOffsetForStringIndex
|
||||
color.withAlphaComponent(0.35).setFill()
|
||||
for rect in rects { context.fill(rect) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 更新 `RDEPUBTextContentView.configure(page:...)`
|
||||
|
||||
```swift
|
||||
// 在传给 renderView 之前注入高亮属性
|
||||
let displayContent = darkImageAdjustedContentIfNeeded(...)
|
||||
chapterData.applyHighlights(to: displayContent, page: page, highlights: highlights)
|
||||
coreTextContentView.attributedDisplayContent = displayContent // 新增属性
|
||||
```
|
||||
|
||||
### Phase 3 验证
|
||||
- 高亮在翻页后仍正确显示(属性嵌入 attributed string,不依赖外部状态)
|
||||
- 高亮颜色、alpha、rect 与 WXRead 截图一致
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 手势模型对齐 — long-press + pan + isSelecting
|
||||
|
||||
### 4.1 手势冲突处理
|
||||
|
||||
**风险:** pan 手势(选区扩展)可能与 RDReaderView 的翻页手势冲突。
|
||||
|
||||
**解决方案(对齐 WXRead):**
|
||||
- `panGR` 初始 `isEnabled = false`,仅在 `isSelecting = true` 时启用
|
||||
- `clearSelection()` 时禁用 `panGR`
|
||||
- 新增 delegate 方法通知父视图:
|
||||
```swift
|
||||
func textContentViewDidBeginSelection(_ contentView: RDEPUBTextContentView)
|
||||
func textContentViewDidEndSelection(_ contentView: RDEPUBTextContentView)
|
||||
```
|
||||
- `RDReaderView` 在 `didBeginSelection` 时禁用翻页手势,在 `didEndSelection` 时恢复
|
||||
|
||||
### 4.2 WXRead 手势时序对齐
|
||||
|
||||
WXRead 的手势流程:
|
||||
1. long-press `.began` → 设置 `selectionStartIndex = selectionEndIndex = index`,`isSelecting = true`,启用 panGR,`setNeedsDisplay`
|
||||
2. long-press `.ended` → 无额外操作(保留选区)
|
||||
3. pan `.changed` → 更新 `selectionEndIndex`,计算 rects,`setNeedsDisplay`
|
||||
4. single tap → `clearSelection()`,禁用 panGR
|
||||
|
||||
### Phase 4 验证
|
||||
- 长按选词 → 拖拽扩展 → 单击取消,全流程流畅
|
||||
- 无选区时翻页手势正常
|
||||
- 有选区时翻页手势被禁用
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 菜单系统对齐 + 清理
|
||||
|
||||
### 5.1 UIMenuController 替换 selectionActionBar
|
||||
|
||||
已在 Phase 1 中完成。此阶段仅做清理:
|
||||
- 删除 `RDEPUBSelectableTextView.swift`
|
||||
- 标记 `RDEPUBSelectionOverlayView.swift` 和 `RDEPUBTextPageDecorationView.swift` 为 deprecated(保留给非 DTCoreText 回退路径)
|
||||
|
||||
### 5.2 更新 UI 测试
|
||||
|
||||
`ReaderAnnotationTests` 中查找菜单项的方式需更新:
|
||||
- 当前:`app.buttons["高亮"]`(UIStackView 中的按钮)
|
||||
- 改为:`app.menuItems["高亮"]`(UIMenuController 的菜单项)
|
||||
- 或者:保留 `accessibilityIdentifier` 在 RDEPUBTextContentView 上以便测试定位
|
||||
|
||||
### 5.3 高亮颜色对齐
|
||||
|
||||
WXRead 的 5 种预设色(35% alpha):
|
||||
- Yellow: `(1.0, 0.92, 0.23, 0.35)`
|
||||
- Blue: `(0.26, 0.65, 0.96, 0.35)`
|
||||
- Red: `(0.96, 0.26, 0.26, 0.35)`
|
||||
- Green: `(0.30, 0.85, 0.39, 0.35)`
|
||||
- Purple: `(0.67, 0.33, 0.97, 0.35)`
|
||||
|
||||
当前项目使用 CSS hex 颜色 + 0.45 alpha,需对齐为 WXRead 的 RGBA 值。
|
||||
|
||||
---
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
| 文件 | 操作 | Phase |
|
||||
|------|------|-------|
|
||||
| `RDEPUBPageInteractionController.swift` | 修改:新增 `characterIndexForViewPoint` | 1 |
|
||||
| `RDEPUBTextSelectionController.swift` | 重构:移除 UITextViewDelegate,新增 pan 处理、状态机 | 1 |
|
||||
| `RDEPUBTextContentView.swift` | 重构:移除 textView,新增手势,替换菜单,简化层级 | 1,2,4 |
|
||||
| `RDEPUBSelectableTextView.swift` | **删除** | 1 |
|
||||
| `RDEPUBTextPageRenderView.swift` | 扩展:新增高亮/选区/下划线绘制逻辑 | 2,3 |
|
||||
| `RDEPUBChapterData.swift` | 新增:`applyHighlights(to:page:highlights:)` | 3 |
|
||||
| `RDEPUBReaderController+ContentDelegates.swift` | 小改:适配新 clearSelection 签名 | 1 |
|
||||
| `RDReaderView.swift` | 新增:选区期间禁用翻页手势 | 4 |
|
||||
| `RDEPUBSelectionOverlayView.swift` | 保留(deprecated for DTCoreText path) | 5 |
|
||||
| `RDEPUBTextPageDecorationView.swift` | 保留(deprecated for DTCoreText path) | 5 |
|
||||
| `RDEPUBTextAnnotationOverlay.swift` | 保留(deprecated for DTCoreText path) | 5 |
|
||||
| `ReaderAnnotationTests.swift` | 更新:菜单项查找方式 | 5 |
|
||||
|
||||
## 验证方案
|
||||
|
||||
1. **UI 测试**:`ReaderAnnotationTests` 全部通过
|
||||
2. **手动测试**:
|
||||
- 长按选词 → 蓝色选区高亮显示
|
||||
- 拖拽扩展选区 → 选区跟随手指
|
||||
- 点击"高亮" → 黄色高亮创建成功
|
||||
- 翻页后返回 → 高亮仍存在
|
||||
- 点击"拷贝" → 文本已复制
|
||||
- 点击"批注" → 弹出笔记输入框
|
||||
- 单击空白处 → 选区清除
|
||||
3. **性能测试**:`draw(_:)` 耗时 ≤ 之前(单次绘制 vs 三次绘制)
|
||||
4. **对比验证**:与 WXRead 截图对比高亮颜色、alpha、rect 位置
|
||||
@@ -1,740 +0,0 @@
|
||||
# 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 再拆成更细的文件级改动清单、迁移顺序和验收步骤。
|
||||
@@ -441,10 +441,20 @@ classDiagram
|
||||
class RDEPUBReaderPaginationCoordinator {
|
||||
-context: RDEPUBReaderContext
|
||||
+paginatePublication(restoreLocation:)
|
||||
+paginateMetadataOnly(token:restoreLocation:)
|
||||
+repaginatePreservingCurrentLocation()
|
||||
-restoreBookPageMapIfPossible(publication:) RDEPUBBookPageMap?
|
||||
-buildPageMap(from:summaries:) RDEPUBBookPageMap
|
||||
}
|
||||
|
||||
class RDEPUBMetadataParseWorker {
|
||||
-context: RDEPUBReaderContext
|
||||
-cancellationController: RDEPUBMetadataParseCancellationController
|
||||
+start(token:restoreLocation:)
|
||||
}
|
||||
|
||||
class RDEPUBMetadataParseCancellationController {
|
||||
+token: UUID
|
||||
+attach(queue:)
|
||||
+cancel()
|
||||
+isCancelled: Bool
|
||||
}
|
||||
|
||||
class RDEPUBReaderLoadCoordinator {
|
||||
@@ -660,7 +670,7 @@ sequenceDiagram
|
||||
participant OQ as OperationQueue (N workers)
|
||||
participant Disk as Disk Cache
|
||||
|
||||
Main->>BG: paginateMetadataOnly(token)
|
||||
Main->>BG: RDEPUBMetadataParseWorker.start(token)
|
||||
BG->>BG: 预计算 contentHash (串行)
|
||||
BG->>Disk: readAll(catalog) 批量读取缓存
|
||||
BG->>Main: refreshBookPageMapInPlace (缓存部分)
|
||||
|
||||
+1
-2
@@ -28,7 +28,6 @@
|
||||
| [CONCERNS.md](CONCERNS.md) | 代码库风险与关注点:12 项安全/性能/可维护性风险 |
|
||||
| [TESTING.md](TESTING.md) | 测试基础设施:UI 测试文件清单、运行方式、覆盖率 |
|
||||
| [CONVENTIONS.md](CONVENTIONS.md) | 编码规范:命名约定、代码风格、导入规范、错误处理 |
|
||||
| [ARCHITECTURE-CONTEXT.md](ARCHITECTURE-CONTEXT.md) | WXRead vs ReadViewSDK 架构差距分析:4 个领域、14 项决策 |
|
||||
|
||||
## 专题文档
|
||||
|
||||
@@ -47,7 +46,7 @@
|
||||
## 项目信息
|
||||
|
||||
- **模块总数:** 4 个(EPUBCore、EPUBTextRendering、RDReaderView、EPUBUI)
|
||||
- **Swift 文件数:** 166 个(SDK Sources)
|
||||
- **Swift 文件数:** 142 个(SDK Sources)
|
||||
- **测试用例数:** 23 个测试类,约 99 个测试方法(UI 测试)
|
||||
- **最低 iOS 版本:** 15.6
|
||||
- **构建方式:** CocoaPods(本地 pod)
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# readoor vs ReadViewSDK 功能差距分析
|
||||
|
||||
> 最后更新:2026-06-18
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
本文档对比 readoor(`BookView/EPUB/`)EPUB 阅读器与 ReadViewSDK 的功能差异,列出 readoor 已实现但 ReadViewSDK **尚未实现**的功能,以及实现方式存在差异的功能。
|
||||
|
||||
---
|
||||
|
||||
## 未实现的功能
|
||||
|
||||
### 1. 字体下载
|
||||
|
||||
**readoor 状态:** 已实现。`RBCoreEpubFontDownloadViewController` + `RBCoreEpubFontDownloadHandler` 提供字体下载管理,支持用户从服务端下载自定义字体并通过 `@font-face` 注入阅读器。
|
||||
|
||||
**ReadViewSDK 状态:** 未实现。`RDEPUBFontNormalizer` 仅负责注册 EPUB 内嵌字体(`CTFontManagerRegisterFontsForURL`)和系统字体回退,无字体下载/商店功能。
|
||||
|
||||
**影响:** 用户只能使用系统字体和 EPUB 自带字体,无法扩展字体选择。
|
||||
|
||||
---
|
||||
|
||||
### 2. 服务器同步笔记/书签
|
||||
|
||||
**readoor 状态:** 已实现。`pullEpubNoteInfo`/`pushEpubNoteInfo` 通过 API 同步笔记到云端,支持多设备数据一致性。
|
||||
|
||||
**ReadViewSDK 状态:** 未实现。`RDEPUBReaderDelegate` 提供 `didUpdateHighlights`、`didUpdateBookmarks` 等回调,但仅为本地通知,无网络请求或远程同步逻辑。笔记/书签数据完全在本地管理。
|
||||
|
||||
**影响:** 用户换设备后阅读数据(书签、高亮、笔记)会丢失。
|
||||
|
||||
---
|
||||
|
||||
### 3. 加密 EPUB 解密
|
||||
|
||||
**readoor 状态:** 已实现。`STSRDFileManagerStorage readToFileDeCrypt:password:` 基于 bookID 拼接密码,对所有 EPUB 资源(HTML、CSS、图片)进行解密加载。
|
||||
|
||||
**ReadViewSDK 状态:** 未实现。`RDEPUBParser` 直接解析标准 EPUB 归档,无加解密层。
|
||||
|
||||
**影响:** 无法打开加密的 EPUB 文件。
|
||||
|
||||
---
|
||||
|
||||
### 4. 试读/购买权限控制
|
||||
|
||||
**readoor 状态:** 已实现。支持三种权限模式:免费试读、登录后免费、购买后阅读。通过 API 返回的权限字段控制章节可读性。
|
||||
|
||||
**ReadViewSDK 状态:** 未实现。无试读限制、付费墙或章节权限控制逻辑。
|
||||
|
||||
**影响:** SDK 无商业化能力,无法限制用户只阅读已购买的章节。
|
||||
|
||||
---
|
||||
|
||||
### 5. 阅读统计上报
|
||||
|
||||
**readoor 状态:** 已实现。`RDStatisticsManager` 上报阅读进度(sectionNo + 页面内进度),集成埋点系统。
|
||||
|
||||
**ReadViewSDK 状态:** 未实现。JS 端 `reportProgression()` 仅将进度通过 `webkit.messageHandlers` 传递给原生端,属于本地 bridge 通信,无远程上报。
|
||||
|
||||
**影响:** 无法收集用户阅读行为数据用于运营分析。
|
||||
|
||||
---
|
||||
|
||||
### 6. 图片点击放大
|
||||
|
||||
**readoor 状态:** 已实现。点击 EPUB 内图片发送通知,弹出全屏图片查看(`showImageView`)。
|
||||
|
||||
**ReadViewSDK 状态:** 未实现。JS 端 `handleDocumentClick`(`epub-bridge.js:388-400`)仅处理链接点击,不处理图片点击事件。
|
||||
|
||||
**影响:** 用户无法放大查看 EPUB 中的小图片或细节图。
|
||||
|
||||
---
|
||||
|
||||
## 实现方式存在差异的功能
|
||||
|
||||
### 7. 亮度调节
|
||||
|
||||
**readoor 方式:** 通过黑色半透明 CALayer overlay 暗化屏幕,不改变系统亮度。
|
||||
|
||||
**ReadViewSDK 方式:** 直接设置 `UIScreen.main.brightness`(系统屏幕亮度 API)。已实现,但会影响系统全局亮度。
|
||||
|
||||
---
|
||||
|
||||
### 8. 搜索结果上下文
|
||||
|
||||
**readoor 方式:** 搜索结果显示匹配文本前后各 30 字符上下文。
|
||||
|
||||
**ReadViewSDK 方式:** `previewRadius` 为 12 字符(`RDEPUBReaderController+DataSource.swift:196-201`),上下文较短。
|
||||
|
||||
---
|
||||
|
||||
### 9. CSS 主题切换机制
|
||||
|
||||
**readoor 方式:** 通过 JS `classList.toggle("mycss")` 切换 CSS class 实现主题切换。
|
||||
|
||||
**ReadViewSDK 方式:** 通过 `cssInjector.js` 的 `window.RDInjectedCSS.setStyle(identifier, cssText, options)` 直接操作 style 标签内容。功能等价,但机制不同。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
| # | 功能 | 优先级 | 复杂度 |
|
||||
|---|------|--------|--------|
|
||||
| 1 | 字体下载 | 中 | 中(需后端接口 + 下载管理) |
|
||||
| 2 | 服务器同步 | 高 | 高(需 API 设计 + 冲突解决) |
|
||||
| 3 | 加密 EPUB | 高 | 中(需对接加密方案) |
|
||||
| 4 | 试读/购买权限 | 高 | 低(SDK 层暴露权限接口即可) |
|
||||
| 5 | 阅读统计上报 | 中 | 低(delegate 暴露事件即可) |
|
||||
| 6 | 图片点击放大 | 中 | 低(JS 端监听图片点击 + 原生展示) |
|
||||
@@ -1,757 +0,0 @@
|
||||
# 当前阅读器问题修复开发清单
|
||||
|
||||
> 最后更新:2026-06-13
|
||||
> 依据说明:本清单仅基于当前仓库代码实现整理,不以现有说明文档是否准确为前提。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文将“当前阅读器还存在的问题”展开为可执行的逐任务开发清单,目标是把工作从“方向建议”落到“具体改哪里、怎么改、如何验收”。
|
||||
|
||||
本文聚焦以下四类问题:
|
||||
|
||||
1. 核心交互链路不稳定:选区、高亮、书签、工具栏状态分散。
|
||||
2. 默认安全策略偏弱:外链、解压、日志、持久化默认行为需要收口。
|
||||
3. 资源与分页稳定性不足:大资源内存峰值、离屏分页 WebView 边界控制不足。
|
||||
4. SDK 契约与回归手段不足:默认 no-op 容易误用,自动化观测点不够结构化。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体实施顺序
|
||||
|
||||
建议按以下顺序执行,减少返工:
|
||||
|
||||
1. `bookmark-chrome-state-unification`
|
||||
2. `selection-state-refactor`
|
||||
3. `external-link-policy-hardening`
|
||||
4. `epub-archive-path-validation`
|
||||
5. `webview-debug-sanitization`
|
||||
6. `persistence-defaults-hardening`
|
||||
7. `resource-scheme-streaming-and-limits`
|
||||
8. `pagination-webview-hardening`
|
||||
9. `cache-hardening`
|
||||
10. `sdk-contract-and-test-hardening`
|
||||
|
||||
原因:
|
||||
|
||||
- 任务 2 是已确认的重复 UI 状态写入问题,收益最高、风险相对低,应优先处理。
|
||||
- 任务 1 更偏“状态模型收口优化”,问题真实存在,但严重度低于任务 2。
|
||||
- 第 3-6 项收口默认安全行为,改动局部、收益高。
|
||||
- 第 7-9 项涉及资源与分页底层,副作用更大,放在前面稳定后更容易验证。
|
||||
- 最后一项负责把前面改动固化为稳定契约和测试基线。
|
||||
|
||||
---
|
||||
|
||||
## 3. 任务清单
|
||||
|
||||
## 任务 1:`selection-state-refactor`
|
||||
|
||||
### 3.1 目标
|
||||
|
||||
在不破坏当前清晰分层的前提下,收口选区相关状态的冗余表达,降低 view 层与 controller 层各持有一份选区状态所带来的维护成本和时序问题。
|
||||
|
||||
### 3.2 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
|
||||
### 3.3 修改方案
|
||||
|
||||
当前代码并不是“混乱散写”,而是相对清晰的分层流:
|
||||
|
||||
- gesture
|
||||
- view
|
||||
- coordinator
|
||||
- context/controller
|
||||
|
||||
其中真正值得优化的点主要有两个:
|
||||
|
||||
1. view 层和 controller 层各自持有一份 `currentSelection`
|
||||
2. `menuSelection` 是为 `UIMenuController` 时序保底引入的 workaround,属于合理存在,但可以尝试被更明确的状态模型替代
|
||||
|
||||
新增一个统一的选区状态模型仍然是可行方案,例如:
|
||||
|
||||
```swift
|
||||
enum RDEPUBSelectionState: Equatable {
|
||||
case idle
|
||||
case selecting(anchor: Int)
|
||||
case selected(RDEPUBSelection)
|
||||
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
|
||||
}
|
||||
```
|
||||
|
||||
建议新增文件:
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift`
|
||||
|
||||
建议在 `RDEPUBReaderAnnotationCoordinator` 内新增或抽出一个单一入口:
|
||||
|
||||
- `applySelectionState(_ state: RDEPUBSelectionState)`
|
||||
- `currentSelectionState`
|
||||
|
||||
具体修改:
|
||||
|
||||
1. `RDEPUBTextSelectionController`
|
||||
- 移除“自己就是最终状态源”的职责。
|
||||
- 保留字符范围计算与 `RDEPUBSelection?` 构建。
|
||||
- `isSelecting` 可保留为手势内部临时状态,但不再作为 UI 的最终依据。
|
||||
|
||||
2. `RDEPUBTextContentView`
|
||||
- 不再直接通过 `currentSelection != nil` 作为所有 UI 行为的唯一判断条件。
|
||||
- 长按、拖拽、结束只上报“开始选择 / 更新选择 / 结束选择 / 取消选择”事件。
|
||||
- `menuSelection` 不建议直接删除。
|
||||
- 第一版应先保留 `menuSelection`,并把它明确标注为菜单时序缓冲状态。
|
||||
- 待状态模型稳定后,再评估是否能安全移除。
|
||||
|
||||
3. `RDEPUBReaderAnnotationCoordinator.updateCurrentSelection(_:)`
|
||||
- 改造成 `applySelectionState(_:)`。
|
||||
- 在 `.selected` 时统一:
|
||||
- 更新 `controller.currentSelection`
|
||||
- 决定是否展示工具栏
|
||||
- 更新底部高亮按钮可用性
|
||||
- 通知 delegate
|
||||
- 在 `.idle` 时统一清理:
|
||||
- 清空 `controller.currentSelection`
|
||||
- 关闭菜单
|
||||
- 刷新按钮状态
|
||||
|
||||
4. `RDEPUBReaderController`
|
||||
- `currentSelection` 继续保留为公开只读语义,但底层来源改为 `selectionState` 推导,而不是多处直接赋值。
|
||||
|
||||
### 3.4 实施步骤
|
||||
|
||||
1. 新增 `RDEPUBSelectionState.swift`
|
||||
2. 在 `RDEPUBReaderAnnotationCoordinator` 中接管选区状态
|
||||
3. 重写 `RDEPUBTextContentView` 中选区相关回调
|
||||
4. 先收口零散的 `currentSelection != nil` 控制逻辑
|
||||
5. 保留 `menuSelection`,待验证不再需要后再移除
|
||||
6. 统一 delegate 通知路径
|
||||
|
||||
### 3.5 风险点
|
||||
|
||||
- 选区菜单弹出时机可能变化。
|
||||
- `menuSelection` 是现有时序补丁,若贸然删除,菜单动作可能拿不到正确 selection。
|
||||
- Web 路径和 Native Text 路径都要走统一状态流,避免只修一条。
|
||||
|
||||
### 3.6 验收标准
|
||||
|
||||
- 长按后必然进入“选区中”状态。
|
||||
- 松手后有有效文本时必然进入“已选中”状态。
|
||||
- 复制/高亮/批注后必然回到空闲状态。
|
||||
- `selection=1` 的 Demo 状态与按钮启用状态始终一致。
|
||||
|
||||
### 3.7 优先级修正
|
||||
|
||||
本任务应视为“结构优化 + 降低后续维护成本”,不是当前代码库里最严重的缺陷。建议优先级下调到与任务 2 同级,排在任务 2 之后实施。
|
||||
|
||||
---
|
||||
|
||||
## 任务 2:`bookmark-chrome-state-unification`
|
||||
|
||||
### 3.8 目标
|
||||
|
||||
统一顶部书签按钮、底部书签按钮、高亮按钮、添加高亮按钮的可用状态计算,避免不同协调器各自改一部分 UI。
|
||||
|
||||
### 3.9 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift`
|
||||
|
||||
### 3.10 修改方案
|
||||
|
||||
新增统一 UI 状态模型:
|
||||
|
||||
```swift
|
||||
struct RDEPUBReaderUIState {
|
||||
let canToggleBookmark: Bool
|
||||
let hasBookmarkAtCurrentLocation: Bool
|
||||
let canShowBookmarks: Bool
|
||||
let canAddHighlight: Bool
|
||||
let canShowHighlights: Bool
|
||||
}
|
||||
```
|
||||
|
||||
建议新增文件:
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift`
|
||||
|
||||
具体修改:
|
||||
|
||||
1. `RDEPUBReaderChromeCoordinator`
|
||||
- 新增 `makeUIState()` 和 `applyUIState(_:)`
|
||||
- `updateReaderChrome()` 内不再直接散写多个 `setXxxEnabled`
|
||||
|
||||
2. `RDEPUBReaderAnnotationCoordinator`
|
||||
- `updateBookmarkChrome()` 不再直接写 view,改为触发 `context.updateReaderChrome()`
|
||||
|
||||
3. `RDEPUBReaderTopToolView` / `RDEPUBReaderBottomToolView`
|
||||
- 保持 dumb view,只接收状态,不参与规则判断
|
||||
|
||||
### 3.11 实施步骤
|
||||
|
||||
1. 新增 `RDEPUBReaderUIState.swift`
|
||||
2. 改造 `updateReaderChrome()`
|
||||
3. 不要简单删除 `AnnotationCoordinator` 内的按钮状态刷新
|
||||
4. 区分两类触发时机:
|
||||
- `ChromeCoordinator` 负责整页/整轮 chrome 同步
|
||||
- `AnnotationCoordinator` 负责用户操作后的即时反馈触发
|
||||
5. 将二者统一收口到同一个状态计算入口,例如 `context.updateReaderChrome()`
|
||||
6. 调整书签新增/删除/跳转后的刷新入口为统一 `updateReaderChrome()`
|
||||
|
||||
### 3.12 风险点
|
||||
|
||||
- 如果简单删除 `AnnotationCoordinator` 内的调用,可能丢失“用户操作后立即反馈”的刷新时机。
|
||||
- 如果某些按钮当前依赖副作用显示,需要顺便梳理显示时机。
|
||||
|
||||
### 3.13 验收标准
|
||||
|
||||
- 添加第一个书签后,底部书签列表按钮立即可用。
|
||||
- 删除最后一个书签后,底部书签列表按钮立即禁用。
|
||||
- 当前位置已加书签时,顶部书签按钮始终是选中态。
|
||||
|
||||
---
|
||||
|
||||
## 任务 3:`external-link-policy-hardening`
|
||||
|
||||
### 3.14 目标
|
||||
|
||||
把“外链点击后直接打开系统应用”改成受控策略,避免 SDK 默认行为过于激进。
|
||||
|
||||
### 3.15 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift`
|
||||
|
||||
### 3.16 修改方案
|
||||
|
||||
在 `RDEPUBReaderConfiguration` 新增:
|
||||
|
||||
- `allowedExternalURLSchemes: Set<String>`
|
||||
- `requiresExternalLinkConfirmation: Bool`
|
||||
- 可选:`allowedExternalURLHosts: Set<String>?`
|
||||
|
||||
默认值建议:
|
||||
|
||||
- scheme 仅允许 `https`
|
||||
- 需要确认框
|
||||
|
||||
具体修改:
|
||||
|
||||
1. `RDEPUBWebView+JavaScriptBridge.swift`
|
||||
- 继续识别外链,但不再表示“允许直接打开”。
|
||||
- 只负责把外链事件上抛。
|
||||
|
||||
2. `RDEPUBReaderController+ContentDelegates.swift`
|
||||
- 新增私有方法:
|
||||
- `shouldAllowExternalURL(_:)`
|
||||
- `presentExternalLinkConfirmation(for:)`
|
||||
- `openExternalURLIfAllowed(_:)`
|
||||
- `didActivateExternalLink` 改为:
|
||||
- 先 delegate 回调
|
||||
- 再走配置校验
|
||||
- 再弹确认框
|
||||
- 最后调用 `UIApplication.shared.open`
|
||||
|
||||
3. 可选扩展 `RDEPUBReaderDelegate`
|
||||
- 增加可选拦截钩子:
|
||||
- `epubReader(_:shouldOpenExternalURL:) -> Bool`
|
||||
|
||||
### 3.17 实施步骤
|
||||
|
||||
1. 改配置模型与默认值
|
||||
2. 改控制器外链处理逻辑
|
||||
3. 增加确认对话框
|
||||
4. 增加测试用 mock URL 覆盖 `https`、`mailto`、未知 scheme`
|
||||
|
||||
### 3.18 风险点
|
||||
|
||||
- 某些 Demo 书可能依赖 `mailto` 或 `tel`,要明确它们现在默认不再直接打开。
|
||||
|
||||
### 3.19 验收标准
|
||||
|
||||
- `https` 外链在确认后打开。
|
||||
- 默认配置下 `mailto` 和 `tel` 不直接打开。
|
||||
- 未知 scheme 一律拒绝。
|
||||
|
||||
---
|
||||
|
||||
## 任务 4:`epub-archive-path-validation`
|
||||
|
||||
### 3.20 目标
|
||||
|
||||
为 EPUB 解压路径增加显式安全校验,防止恶意压缩包通过相对路径写出解压根目录。
|
||||
|
||||
### 3.21 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`
|
||||
|
||||
### 3.22 修改方案
|
||||
|
||||
新增私有方法:
|
||||
|
||||
- `validatedExtractionDestination(for:extractionRoot:) -> URL?`
|
||||
|
||||
校验规则:
|
||||
|
||||
1. 拒绝绝对路径。
|
||||
2. 拒绝包含 `..` 的路径段。
|
||||
3. `standardizedFileURL.path` 必须位于 `extractionRoot.standardizedFileURL.path` 下。
|
||||
|
||||
具体修改:
|
||||
|
||||
1. `extractArchiveIfNeeded(epubURL:)`
|
||||
- 在循环内先调用 `validatedExtractionDestination`
|
||||
- 无效 entry 可:
|
||||
- 直接抛错终止解析,或
|
||||
- 记日志并跳过
|
||||
- 建议优先抛错,行为更清晰
|
||||
|
||||
2. 增加自定义错误:
|
||||
- 若当前 `RDEPUBParserError` 里没有合适 case,可新增 `invalidArchiveEntryPath(String)`
|
||||
|
||||
### 3.23 实施步骤
|
||||
|
||||
1. 增加路径校验方法
|
||||
2. 替换原始 `destinationURL` 生成逻辑
|
||||
3. 增加针对恶意 entry path 的单元测试
|
||||
|
||||
### 3.24 风险点
|
||||
|
||||
- 少数历史 EPUB 可能带奇怪路径分隔符,需要兼容性验证。
|
||||
|
||||
### 3.25 验收标准
|
||||
|
||||
- 正常 EPUB 仍可正常解压。
|
||||
- 带 `../` 的恶意 entry 会被拒绝。
|
||||
|
||||
---
|
||||
|
||||
## 任务 5:`webview-debug-sanitization`
|
||||
|
||||
### 3.26 目标
|
||||
|
||||
减少默认调试日志泄露阅读内容的风险,并关闭不必要的 inspectable 默认值。
|
||||
|
||||
### 3.27 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift`
|
||||
|
||||
### 3.28 修改方案
|
||||
|
||||
1. `RDEPUBWebViewDebug`
|
||||
- `logMessage` 改为只打印:
|
||||
- message name
|
||||
- 字段名列表
|
||||
- 文本长度
|
||||
- URL 摘要
|
||||
- 不再打印完整 message body
|
||||
|
||||
2. 新增更细粒度配置
|
||||
- `RDEPUBWebViewDebugEnabled`
|
||||
- `RDEPUBVerboseWebViewDebugEnabled`
|
||||
|
||||
3. `RDEPUBPaginator` 与正常阅读 WebView
|
||||
- `isInspectable` 改为受配置控制
|
||||
- 默认关闭
|
||||
|
||||
4. `RDEPUBReaderConfiguration`
|
||||
- 增加:
|
||||
- `allowsInspectableWebViews`
|
||||
- `enablesVerboseWebViewLogging`
|
||||
|
||||
### 3.29 实施步骤
|
||||
|
||||
1. 改日志输出格式
|
||||
2. 改 inspectable 的默认逻辑
|
||||
3. 为 verbose 模式保留调试后门
|
||||
|
||||
### 3.30 风险点
|
||||
|
||||
- 调试某些 JS 问题时信息会变少,因此要保留显式 verbose 开关。
|
||||
|
||||
### 3.31 验收标准
|
||||
|
||||
- 默认 DEBUG 包不输出完整选中文本。
|
||||
- 默认分页 WebView 不可 inspect。
|
||||
- 打开 verbose 开关后仍能深度调试。
|
||||
|
||||
---
|
||||
|
||||
## 任务 6:`persistence-defaults-hardening`
|
||||
|
||||
### 3.32 目标
|
||||
|
||||
降低默认 `UserDefaults` 持久化的误用风险和数据膨胀风险,同时不破坏当前 API 可用性。
|
||||
|
||||
### 3.33 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
|
||||
### 3.34 修改方案
|
||||
|
||||
短期目标:
|
||||
|
||||
1. 明确 `RDEPUBUserDefaultsPersistence` 是轻量实现。
|
||||
2. 高亮持久化优先依赖:
|
||||
- `location`
|
||||
- `rangeInfo`
|
||||
- `style`
|
||||
- `note`
|
||||
3. `text` 字段作为冗余缓存,而不是唯一恢复依据。
|
||||
|
||||
中期可选目标:
|
||||
|
||||
新增文件持久化实现:
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBFilePersistence.swift`
|
||||
|
||||
### 3.35 具体修改
|
||||
|
||||
1. `RDEPUBReaderPersistence.swift`
|
||||
- 为默认实现增加 DEBUG 警告,提示 bookmarks/settings 默认实现是 no-op
|
||||
|
||||
2. `RDEPUBUserDefaultsPersistence`
|
||||
- 增加大小保护,例如当高亮集合序列化后超阈值时打印告警
|
||||
- 可选增加版本字段,便于后续迁移
|
||||
|
||||
3. `RDEPUBReaderController`
|
||||
- 初始化默认 persistence 时,在注释与命名上明确这是默认轻量实现
|
||||
|
||||
### 3.36 实施步骤
|
||||
|
||||
1. 增加 warning/assert 机制
|
||||
2. 优化高亮持久化字段策略
|
||||
3. 视时间增加 `RDEPUBFilePersistence`
|
||||
|
||||
### 3.37 风险点
|
||||
|
||||
- 如果外部代码依赖 `text` 永远存在,需要做好兼容。
|
||||
|
||||
### 3.38 验收标准
|
||||
|
||||
- 未实现书签持久化时,DEBUG 下能看到明确提示。
|
||||
- 高亮即使 `text` 丢失,也可基于范围信息恢复定位。
|
||||
|
||||
---
|
||||
|
||||
## 任务 7:`resource-scheme-streaming-and-limits`
|
||||
|
||||
### 3.39 目标
|
||||
|
||||
降低 `WKURLSchemeHandler` 对大资源的内存冲击,避免每次都整块 `Data(contentsOf:)` 读入。
|
||||
|
||||
### 3.40 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
||||
|
||||
### 3.41 修改方案
|
||||
|
||||
分两步做:
|
||||
|
||||
第一步,先加大小阈值和告警:
|
||||
|
||||
- 读取文件属性获取大小
|
||||
- 超过阈值时走分块发送
|
||||
|
||||
第二步,再补流式发送:
|
||||
|
||||
- 使用 `FileHandle` 或 `InputStream`
|
||||
- 按固定 chunk size 反复 `didReceive(data)`
|
||||
|
||||
建议新增私有方法:
|
||||
|
||||
- `resourceMetadata(for:)`
|
||||
- `respondWithStreaming(fileURL:requestURL:taskID:urlSchemeTask:)`
|
||||
- `respondWithInMemoryData(...)`
|
||||
|
||||
### 3.42 实施步骤
|
||||
|
||||
1. 先加文件大小检测
|
||||
2. 小文件保留内存直读
|
||||
3. 大文件切换到分块发送
|
||||
4. 增加取消任务时的中断判断
|
||||
|
||||
### 3.43 风险点
|
||||
|
||||
- `WKURLSchemeHandler` 分块发送要注意 stop 之后不继续回调。
|
||||
|
||||
### 3.44 验收标准
|
||||
|
||||
- 大图片/字体加载时内存峰值下降。
|
||||
- 取消任务不会继续发送数据。
|
||||
|
||||
---
|
||||
|
||||
## 任务 8:`pagination-webview-hardening`
|
||||
|
||||
### 3.45 目标
|
||||
|
||||
加强离屏分页 WebView 的边界控制,减少外部资源波动和调试副作用。
|
||||
|
||||
### 3.46 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||||
|
||||
### 3.47 修改方案
|
||||
|
||||
1. 为分页 WebView 增加严格导航限制
|
||||
- 仅允许 `file://`
|
||||
- 仅允许 `ss-reader://`
|
||||
- 拒绝 `http/https`
|
||||
|
||||
2. 将 `isInspectable` 受配置控制
|
||||
|
||||
3. 增加测量稳定性日志
|
||||
- 记录三轮测量结果
|
||||
- 当三轮差异过大时告警
|
||||
|
||||
建议新增私有方法:
|
||||
|
||||
- `shouldAllowPaginatorNavigation(url:)`
|
||||
- `recordMeasurement(pass:value:)`
|
||||
|
||||
### 3.48 实施步骤
|
||||
|
||||
1. 扩展 `WKNavigationDelegate`
|
||||
2. 加入导航 allowlist
|
||||
3. 记录多轮测量差异
|
||||
|
||||
### 3.49 风险点
|
||||
|
||||
- 某些 EPUB 样式若依赖外链资源,分页结果可能与当前行为不同,但这本身就是更安全的策略。
|
||||
|
||||
### 3.50 验收标准
|
||||
|
||||
- 分页 WebView 不发起外部导航。
|
||||
- 三轮测量波动可观测。
|
||||
|
||||
---
|
||||
|
||||
## 任务 9:`cache-hardening`
|
||||
|
||||
### 3.51 目标
|
||||
|
||||
提升章节摘要磁盘缓存的健壮性、可观测性和可清理能力。
|
||||
|
||||
### 3.52 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift`
|
||||
|
||||
### 3.53 修改方案
|
||||
|
||||
1. 原子写入
|
||||
- 先写 `.tmp`
|
||||
- 再 replace 到正式文件
|
||||
|
||||
2. 读失败分类
|
||||
- 文件不存在
|
||||
- 读取失败
|
||||
- 解码失败
|
||||
|
||||
3. 精细化清理
|
||||
- `removeAll()`
|
||||
- `removeAll(forBookID:)`
|
||||
- `removeAll(forRenderSignature:)`
|
||||
|
||||
4. 增加统计接口
|
||||
- 缓存文件数
|
||||
- 总大小
|
||||
|
||||
### 3.54 实施步骤
|
||||
|
||||
1. 改写入策略
|
||||
2. 增加读失败日志
|
||||
3. 增加清理接口
|
||||
4. 补测试覆盖损坏文件、半写文件
|
||||
|
||||
### 3.55 风险点
|
||||
|
||||
- 清理策略接口若公开,需要评估是否作为 public API。
|
||||
|
||||
### 3.56 验收标准
|
||||
|
||||
- 缓存文件损坏不会影响整本书重新打开。
|
||||
- 能按需清理缓存,而不是只能全删。
|
||||
|
||||
---
|
||||
|
||||
## 任务 10:`sdk-contract-and-test-hardening`
|
||||
|
||||
### 3.57 目标
|
||||
|
||||
把前面所有修复固化为更清晰的 SDK 契约和更稳定的测试观测点。
|
||||
|
||||
### 3.58 主要问题代码
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||
- `Sources/RDReaderView/EPUBUI/RDURLReaderController.swift`
|
||||
- `ReadViewDemo/ReadViewDemoUITests/`
|
||||
|
||||
### 3.59 修改方案
|
||||
|
||||
1. 强化 persistence 契约
|
||||
- 继续保留默认实现,但在 DEBUG 下对 no-op 行为给出明确信号
|
||||
|
||||
2. Demo 状态能力修正
|
||||
- 当前仓库并非完全没有结构化状态。
|
||||
- `ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift` 已经提供了状态解析器和 `waitForDemoReaderState(...)` 结构化等待能力。
|
||||
- 真正需要推进的不是再造一个 `DemoReaderSnapshot`,而是把仍然大量存在的 `waitForReaderState(containing:)` 迁移到结构化断言。
|
||||
|
||||
建议方向:
|
||||
|
||||
- 继续保留隐藏 label 输出
|
||||
- 保持 `key=value` 格式
|
||||
- 统一测试侧只通过 `DemoReaderState` 解析和断言
|
||||
- 逐步淘汰 `containing:` 子串匹配,避免 `highlights=1` 命中 `highlights=10` 之类误判
|
||||
|
||||
3. 补测试
|
||||
- 单元测试:
|
||||
- `RDEPUBParserArchiveSafetyTests`
|
||||
- `RDEPUBExternalLinkPolicyTests`
|
||||
- `RDEPUBChapterSummaryDiskCacheTests`
|
||||
- UI 测试:
|
||||
- 选区状态流
|
||||
- 书签启用/禁用
|
||||
- 外链确认框
|
||||
|
||||
### 3.60 实施步骤
|
||||
|
||||
1. 改 Demo 状态串生成逻辑
|
||||
2. 优先将 `waitForReaderState(containing:)` 迁移为 `waitForDemoReaderState(...)`
|
||||
3. 调整 UI 测试断言
|
||||
4. 增加单元测试 target 或现有测试目录内的新文件
|
||||
|
||||
### 3.61 风险点
|
||||
|
||||
- 现有 UI 测试如果强依赖旧状态串,需要同步迁移。
|
||||
|
||||
### 3.62 验收标准
|
||||
|
||||
- UI 测试失败时能明确知道是选区状态、书签状态还是导航状态失配。
|
||||
- persistence 的默认 no-op 行为在开发期不再“静默”。
|
||||
|
||||
---
|
||||
|
||||
## 4. 补充清理项
|
||||
|
||||
以下问题已在代码中可见,但未纳入前 10 个主任务,建议作为补充任务或穿插清理项处理。
|
||||
|
||||
### 4.1 `pageBreakBefore/pageBreakAfter` 相关死代码审计
|
||||
|
||||
涉及代码:
|
||||
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift`
|
||||
- `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift`
|
||||
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift`
|
||||
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift`
|
||||
|
||||
建议动作:
|
||||
|
||||
- 审计 `pageBreakBefore/pageBreakAfter` 从注入到消费的完整链路
|
||||
- 判断是否有“已定义、已注入、但实际无有效触发”的死路径
|
||||
- 若确认无效,删除或补测试锁定其真实行为
|
||||
|
||||
### 4.2 `PAGINATION-DEBUG` 输出清理
|
||||
|
||||
涉及代码:
|
||||
|
||||
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift`
|
||||
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift`
|
||||
|
||||
建议动作:
|
||||
|
||||
- 将裸 `print` 改为受控调试开关
|
||||
- 默认关闭
|
||||
- 避免分页细节和正文片段直接进入控制台
|
||||
|
||||
### 4.3 `ChapterRuntime` 旧代码与遗留路径清理
|
||||
|
||||
建议动作:
|
||||
|
||||
- 梳理 `ChapterRuntime` 下是否存在已不再被主流程使用的辅助类型或过渡实现
|
||||
- 对“仍被引用但语义已经过时”的代码先补注释标记
|
||||
- 对“完全无调用”的代码建立删除候选清单
|
||||
|
||||
---
|
||||
|
||||
## 5. 推荐里程碑切分
|
||||
|
||||
### 里程碑 A:核心交互稳定
|
||||
|
||||
包含任务:
|
||||
|
||||
- `selection-state-refactor`
|
||||
- `bookmark-chrome-state-unification`
|
||||
|
||||
完成标准:
|
||||
|
||||
- 选区、高亮、书签相关 UI 测试恢复稳定
|
||||
|
||||
### 里程碑 B:默认安全行为收口
|
||||
|
||||
包含任务:
|
||||
|
||||
- `external-link-policy-hardening`
|
||||
- `epub-archive-path-validation`
|
||||
- `webview-debug-sanitization`
|
||||
- `persistence-defaults-hardening`
|
||||
|
||||
完成标准:
|
||||
|
||||
- 默认配置下不再直接打开高风险外链
|
||||
- 解压路径具备显式校验
|
||||
- 默认调试日志不泄露完整阅读内容
|
||||
|
||||
### 里程碑 C:底层稳定性优化
|
||||
|
||||
包含任务:
|
||||
|
||||
- `resource-scheme-streaming-and-limits`
|
||||
- `pagination-webview-hardening`
|
||||
- `cache-hardening`
|
||||
|
||||
完成标准:
|
||||
|
||||
- 大资源加载和分页更稳定
|
||||
- 缓存更健壮、可清理
|
||||
|
||||
### 里程碑 D:契约与测试固化
|
||||
|
||||
包含任务:
|
||||
|
||||
- `sdk-contract-and-test-hardening`
|
||||
|
||||
完成标准:
|
||||
|
||||
- SDK 默认行为更清晰
|
||||
- 回归测试对关键状态具备稳定观测能力
|
||||
|
||||
---
|
||||
|
||||
## 6. 建议的开发节奏
|
||||
|
||||
若按 2 周一个小迭代,可参考:
|
||||
|
||||
1. 第 1 周:`bookmark-chrome-state-unification` + `selection-state-refactor`
|
||||
2. 第 2 周:`external-link-policy-hardening` + `epub-archive-path-validation` + `webview-debug-sanitization`
|
||||
3. 第 3 周:`persistence-defaults-hardening` + `resource-scheme-streaming-and-limits` + `pagination-webview-hardening`
|
||||
4. 第 4 周:`cache-hardening` + `sdk-contract-and-test-hardening` 与回归收口
|
||||
|
||||
如果只想先做最值的部分,建议最小交付集合为:
|
||||
|
||||
1. `bookmark-chrome-state-unification`
|
||||
2. `selection-state-refactor`
|
||||
3. `external-link-policy-hardening`
|
||||
4. `epub-archive-path-validation`
|
||||
|
||||
这四项完成后,阅读器的“主观可用性”和“默认安全性”会先提升一个台阶。
|
||||
@@ -1,744 +0,0 @@
|
||||
# 标准级 EPUB 定位与兼容能力开发蓝图
|
||||
|
||||
> 最后更新:2026-06-18
|
||||
> 适用范围:`EPUBCore/CFI`、`EPUBCore/Notes`、`EPUBTextRendering`、`EPUBUI`
|
||||
> 目标:把当前阅读器从“主流文本书可用”推进到“商业级 EPUB 阅读器必须具备的定位与兼容能力”。
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文不是概念方案,而是可直接拆任务、排期、开发和回归的实施蓝图,覆盖三项“必须做”的能力:
|
||||
|
||||
1. `EPUB CFI` 标准级定位与持久化
|
||||
2. 脚注 / 尾注弹层阅读
|
||||
3. 嵌入字体回退 + CSS 兼容层
|
||||
|
||||
其中 `EPUB CFI` 按完整版路线执行,核心要求是:
|
||||
|
||||
1. 不依赖 `id` 也能做章节内精确 DOM 定位
|
||||
2. 支持“阅读位置 -> CFI”和“CFI -> 阅读位置”的双向恢复
|
||||
3. 在换字号、换字体、换行距、重新分页后仍能稳定恢复
|
||||
4. 为高亮、书签、搜索命中、脚注锚点、未来跨设备同步提供统一锚点
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前基线
|
||||
|
||||
当前仓库已经有一部分基础设施,不是从零开始:
|
||||
|
||||
### 2.1 已有能力
|
||||
|
||||
1. 已有 `RDEPUBLocation.cfi / lastCFI / rangeCFI`
|
||||
2. 已有 `RDEPUBCFIParser / Serializer / Resolver / Generator`
|
||||
3. 已有 `RDEPUBCFIMap` 和章节级 `marker`
|
||||
4. 已有基于文本节点路径的初版 `cfiMap` 构建
|
||||
5. 已有脚注检测、解析和弹层 UI 的第一版入口
|
||||
6. 已有 CSS 兼容层与字体 fallback resolver 的第一版模型
|
||||
|
||||
### 2.2 当前仍然不够的地方
|
||||
|
||||
当前实现更像“标准级路线的 Phase 0.5”,还差以下闭环:
|
||||
|
||||
1. 无 `id` 节点的 DOM 路径恢复还只是“文本对齐驱动”,未达到标准级双向校准
|
||||
2. `CFI -> 章内偏移` 仍主要依赖单个 marker 命中,缺少区间级、断言级回退链
|
||||
3. 还没有持久化“DOM 恢复所需的结构指纹”,章节缓存命中后无法做更强恢复
|
||||
4. 脚注弹层还没有完整纳入 CFI 锚点、回跳和高亮链路
|
||||
5. CSS 兼容层仍偏“样式修正函数”,还不是完整的兼容策略包
|
||||
6. 缺少样书库、诊断报告和标准级回归基线
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么要做“标准级无 id 节点双向恢复”
|
||||
|
||||
### 3.1 要解决的真实问题
|
||||
|
||||
如果只用 `href + progression` 或 `href + chapterOffset`:
|
||||
|
||||
1. 用户改字号、字体、行距、分栏后,位置会漂
|
||||
2. 高亮恢复会落到错误字词
|
||||
3. 搜索命中重开书后会跳偏
|
||||
4. 脚注回跳在长章节里不稳定
|
||||
5. 将来做跨设备同步时,同步点不可复用
|
||||
|
||||
### 3.2 做到标准级后的收益
|
||||
|
||||
1. 阅读位置恢复从“章节大致位置”升级到“字符级稳定锚点”
|
||||
2. 高亮、批注、书签、搜索、脚注全部共用同一套定位协议
|
||||
3. 章节重排后仍能尽可能落到同一语义位置
|
||||
4. 对不规范 EPUB 的容错能力显著提升
|
||||
5. 后续可以继续扩展到 WebView 路径、CFI 导出、跨端同步
|
||||
|
||||
---
|
||||
|
||||
## 4. 目标能力定义
|
||||
|
||||
## 4.1 CFI 能力分级
|
||||
|
||||
### L1 基础可用
|
||||
|
||||
1. 能解析 / 序列化单点 CFI 与 Range CFI
|
||||
2. 能持久化阅读位置和高亮范围
|
||||
3. 对带 `id` 的节点定位稳定
|
||||
|
||||
### L2 当前已接近
|
||||
|
||||
1. 能为文本节点生成路径
|
||||
2. 能在章节内做初步文本节点映射
|
||||
3. 能在部分重排场景下恢复位置
|
||||
|
||||
### L3 本文目标:标准级
|
||||
|
||||
1. 无 `id` 节点可精确恢复
|
||||
2. 位置恢复有多级回退链
|
||||
3. 断言、结构指纹、上下文窗口共同参与校准
|
||||
4. 章节缓存可直接携带恢复元数据
|
||||
5. 所有消费方统一走 `CFI` 主链路
|
||||
|
||||
---
|
||||
|
||||
## 5. 总体架构
|
||||
|
||||
建议把定位系统拆成四层:
|
||||
|
||||
1. `CFI Syntax Layer`
|
||||
2. `DOM Anchor Extraction Layer`
|
||||
3. `Recovery & Calibration Layer`
|
||||
4. `Consumer Integration Layer`
|
||||
|
||||
### 5.1 CFI Syntax Layer
|
||||
|
||||
职责:
|
||||
|
||||
1. 负责解析、序列化、生成、Range 拼装
|
||||
2. 不关心具体渲染结果
|
||||
|
||||
对应目录:
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/CFI/`
|
||||
|
||||
### 5.2 DOM Anchor Extraction Layer
|
||||
|
||||
职责:
|
||||
|
||||
1. 从原始 HTML 建立 DOM 路径
|
||||
2. 为文本节点、fragment、note anchor 建立索引
|
||||
3. 产出章节级恢复元数据
|
||||
|
||||
对应目录:
|
||||
|
||||
- `Sources/RDReaderView/EPUBCore/CFI/`
|
||||
- `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/`
|
||||
|
||||
### 5.3 Recovery & Calibration Layer
|
||||
|
||||
职责:
|
||||
|
||||
1. 输入 `CFI`,输出稳定的章节字符偏移
|
||||
2. 输入字符偏移,输出稳定 `CFI`
|
||||
3. 在 DOM、文本、分页变动时完成校准与回退
|
||||
|
||||
对应目录:
|
||||
|
||||
- `Sources/RDReaderView/EPUBTextRendering/`
|
||||
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/`
|
||||
|
||||
### 5.4 Consumer Integration Layer
|
||||
|
||||
职责:
|
||||
|
||||
1. 阅读位置恢复
|
||||
2. 高亮 / 批注 / 书签
|
||||
3. 搜索命中
|
||||
4. 脚注弹层与回跳
|
||||
|
||||
对应目录:
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/`
|
||||
|
||||
---
|
||||
|
||||
## 6. 核心数据结构
|
||||
|
||||
## 6.1 在现有模型上补强,不推翻
|
||||
|
||||
### `RDEPUBCFIMap`
|
||||
|
||||
现有结构:
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIMap: Codable, Equatable {
|
||||
public var href: String
|
||||
public var markers: [RDEPUBCFIMarker]
|
||||
public var textAssertions: [String: RDEPUBCFITextAssertion]
|
||||
}
|
||||
```
|
||||
|
||||
建议扩展为:
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIMap: Codable, Equatable {
|
||||
public var href: String
|
||||
public var renderVersion: Int
|
||||
public var domVersion: Int
|
||||
public var markers: [RDEPUBCFIMarker]
|
||||
public var textAssertions: [String: RDEPUBCFITextAssertion]
|
||||
public var pathRanges: [RDEPUBCFIPathRange]
|
||||
public var recoveryMetadata: RDEPUBCFIRecoveryMetadata
|
||||
}
|
||||
```
|
||||
|
||||
新增原因:
|
||||
|
||||
1. `markers` 适合单点命中,但不足以做标准级回退
|
||||
2. 需要显式记录“某个 DOM 路径覆盖哪段文本”
|
||||
3. 需要缓存 DOM 恢复辅助信息,避免每次重扫 HTML
|
||||
|
||||
### `RDEPUBCFIMarker`
|
||||
|
||||
建议扩展字段:
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIMarker: Codable, Equatable {
|
||||
public var cfiPath: RDEPUBCFIPath
|
||||
public var chapterOffset: Int?
|
||||
public var fragmentID: String?
|
||||
public var textNodeLength: Int?
|
||||
public var textNodeChecksum: UInt64?
|
||||
public var normalizedTextPreview: String?
|
||||
public var domSiblingSignature: String?
|
||||
}
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
1. `textNodeChecksum` 用于节点文本快速比对
|
||||
2. `normalizedTextPreview` 用于短窗口断言
|
||||
3. `domSiblingSignature` 用于路径偏移时的邻接恢复
|
||||
|
||||
### 新增 `RDEPUBCFIPathRange`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIPathRange: Codable, Equatable {
|
||||
public var cfiPath: RDEPUBCFIPath
|
||||
public var startOffset: Int
|
||||
public var endOffset: Int
|
||||
public var textNodeLength: Int
|
||||
}
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
1. 表示某个文本节点在章节纯文本中的覆盖区间
|
||||
2. 支撑“按区间查找最近路径”
|
||||
3. 支撑 `chapterOffset -> nearest CFI path`
|
||||
|
||||
### 新增 `RDEPUBCFIRecoveryMetadata`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIRecoveryMetadata: Codable, Equatable {
|
||||
public var domFingerprint: String
|
||||
public var normalizedTextChecksum: String
|
||||
public var tokenIndex: [RDEPUBCFITokenAnchor]
|
||||
public var fragmentPathMap: [String: RDEPUBCFIPath]
|
||||
}
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
1. `domFingerprint` 判断原始 HTML 是否变化
|
||||
2. `normalizedTextChecksum` 判断章节标准化文本是否变化
|
||||
3. `tokenIndex` 为无 `id` 恢复提供次级定位锚
|
||||
4. `fragmentPathMap` 保留锚点和脚注入口
|
||||
|
||||
### 新增 `RDEPUBCFITokenAnchor`
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFITokenAnchor: Codable, Equatable {
|
||||
public var token: String
|
||||
public var occurrence: Int
|
||||
public var chapterOffset: Int
|
||||
public var cfiPath: RDEPUBCFIPath
|
||||
}
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
1. 当文本节点路径不再精确命中时,用稀疏 token 锚做恢复
|
||||
2. 对长章节做局部二次定位
|
||||
|
||||
---
|
||||
|
||||
## 7. 标准级无 id 节点双向恢复算法
|
||||
|
||||
## 7.1 正向:阅读位置 -> CFI
|
||||
|
||||
输入:
|
||||
|
||||
1. `fileIndex`
|
||||
2. `chapterOffset`
|
||||
3. `RDEPUBCFIMap`
|
||||
|
||||
输出:
|
||||
|
||||
1. 标准 `CFI`
|
||||
2. 如有需要,附带 text assertion
|
||||
|
||||
算法顺序:
|
||||
|
||||
1. 在 `pathRanges` 中查找覆盖 `chapterOffset` 的文本节点
|
||||
2. 计算该节点内的 `localOffset`
|
||||
3. 生成 `contentPath + characterOffset`
|
||||
4. 从前后文提取 assertion
|
||||
5. 若该节点不存在,则回退到最近 `fragment`
|
||||
6. 若 `fragment` 也缺失,则退化为 offset-backed CFI
|
||||
|
||||
### 关键要求
|
||||
|
||||
1. `chapterOffset` 不能直接依赖页码
|
||||
2. assertion 必须来自标准化文本,而不是原始 HTML 片段
|
||||
3. 对选区范围,`startCFI / endCFI` 必须分别独立生成,不能只存一个起点
|
||||
|
||||
## 7.2 逆向:CFI -> 阅读位置
|
||||
|
||||
输入:
|
||||
|
||||
1. `RDEPUBCFI`
|
||||
2. `RDEPUBCFIMap`
|
||||
3. `chapterText`
|
||||
|
||||
输出:
|
||||
|
||||
1. `chapterOffset`
|
||||
2. 必要时输出 `confidence`
|
||||
|
||||
建议新增恢复结果:
|
||||
|
||||
```swift
|
||||
public struct RDEPUBCFIRecoveryResult: Equatable {
|
||||
public enum Confidence: Int {
|
||||
case exactPath
|
||||
case assertionCalibrated
|
||||
case siblingRecovered
|
||||
case tokenRecovered
|
||||
case fragmentFallback
|
||||
case offsetFallback
|
||||
}
|
||||
|
||||
public var chapterOffset: Int
|
||||
public var confidence: Confidence
|
||||
}
|
||||
```
|
||||
|
||||
恢复链路必须固定为:
|
||||
|
||||
1. `exact-path`
|
||||
- `cfiPath` 直接命中 marker 或 pathRange
|
||||
2. `assertion-calibrated`
|
||||
- 路径命中后,用 text assertion 微调偏移
|
||||
3. `sibling-recovered`
|
||||
- 路径不命中时,用父路径 + 兄弟签名查找邻近文本节点
|
||||
4. `token-recovered`
|
||||
- 使用 token anchor 在局部窗口重定位
|
||||
5. `fragment-fallback`
|
||||
- 回退到最近 fragment 起点
|
||||
6. `offset-fallback`
|
||||
- 使用旧式 chapterOffset 或 progression 兜底
|
||||
|
||||
这里的关键不是“某一步一定成功”,而是恢复链必须稳定、可解释、可诊断。
|
||||
|
||||
---
|
||||
|
||||
## 8. 为什么要加“结构指纹 + token 锚”
|
||||
|
||||
只做文本节点路径有两个问题:
|
||||
|
||||
1. 某些 EPUB 会在无 `id` 场景下插入大量包装标签,导致路径整体漂移
|
||||
2. 某些章节文本重复度高,单靠短 assertion 可能落到错误位置
|
||||
|
||||
所以标准级方案需要两层辅助:
|
||||
|
||||
### 8.1 结构指纹
|
||||
|
||||
针对每个文本节点记录:
|
||||
|
||||
1. 父路径
|
||||
2. 左右兄弟摘要
|
||||
3. 标签名序列
|
||||
4. 局部文本 checksum
|
||||
|
||||
这样即便 `cfiPath` 因中间插入一个 wrapper 节点失效,也能在兄弟集合里恢复出最可能目标。
|
||||
|
||||
### 8.2 token 锚
|
||||
|
||||
在章节标准化文本中抽样稀疏 token,例如:
|
||||
|
||||
1. 每 `N` 个词或每 `M` 个中文字符窗口抽一个 token
|
||||
2. 每个 token 记录其 occurrence、offset 和所属 `cfiPath`
|
||||
|
||||
当路径和断言都不稳时:
|
||||
|
||||
1. 先用 token 锁定大致位置
|
||||
2. 再在局部窗口里做文本节点回溯
|
||||
|
||||
这就是“无 id 节点也能做标准级恢复”的核心。
|
||||
|
||||
---
|
||||
|
||||
## 9. 分阶段实施
|
||||
|
||||
## Phase 1:结构补强与缓存升级
|
||||
|
||||
### 目标
|
||||
|
||||
让章节缓存携带足够的 DOM 恢复元数据,为后续标准级恢复打底。
|
||||
|
||||
### 需要改的模块
|
||||
|
||||
1. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift`
|
||||
2. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift`
|
||||
3. `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift`
|
||||
4. `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift`
|
||||
5. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift`
|
||||
6. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift`
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 扩展 `RDEPUBCFIMap`、`RDEPUBCFIMarker`
|
||||
2. 新增 `RDEPUBCFIPathRange`
|
||||
3. 新增 `RDEPUBCFIRecoveryMetadata`
|
||||
4. 在章节构建阶段产出 `pathRanges / tokenIndex / domFingerprint`
|
||||
5. 升级 chapter summary schema version
|
||||
6. 为缓存加版本兼容分支,旧缓存 miss 后自动重建
|
||||
|
||||
### 验收
|
||||
|
||||
1. 新章节缓存可落盘完整 `cfiMap`
|
||||
2. 旧缓存不会引发崩溃
|
||||
3. 二次打开时不需要重新扫描整章 HTML 才能恢复定位
|
||||
|
||||
## Phase 2:标准级 `CFI -> offset` 恢复链
|
||||
|
||||
### 目标
|
||||
|
||||
把当前“marker 命中 + chapterOffset 回退”的恢复方式升级为多级恢复链。
|
||||
|
||||
### 需要改的模块
|
||||
|
||||
1. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift`
|
||||
2. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift`
|
||||
3. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift`
|
||||
4. 建议新增:
|
||||
- `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift`
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 抽出 `RDEPUBCFIRecoveryEngine`
|
||||
2. 输出 `RDEPUBCFIRecoveryResult`
|
||||
3. 实现六级恢复链
|
||||
4. 为每次恢复产出 `confidence`
|
||||
5. 为 diagnostics 埋点:
|
||||
- exact-path 命中率
|
||||
- assertion 校准命中率
|
||||
- token 恢复命中率
|
||||
- offset 回退比例
|
||||
|
||||
### 验收
|
||||
|
||||
1. 无 `id` 文本节点可稳定恢复
|
||||
2. 换字号 / 字体 / 行距后,阅读位置恢复成功率显著提升
|
||||
3. 高亮与搜索命中恢复不再大面积偏移
|
||||
|
||||
## Phase 3:标准级 `offset -> CFI` 生成链
|
||||
|
||||
### 目标
|
||||
|
||||
让所有阅读位置、选区、高亮和搜索结果都优先生成标准文本节点 CFI。
|
||||
|
||||
### 需要改的模块
|
||||
|
||||
1. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift`
|
||||
2. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift`
|
||||
3. `Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift`
|
||||
4. `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift`
|
||||
5. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
6. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift`
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. `chapterOffset -> pathRange` 精确映射
|
||||
2. 统一单点 CFI 与范围 CFI 生成
|
||||
3. 所有消费方优先写入 `cfi/rangeCFI`
|
||||
4. 保留 `fragment/progression` 作为兜底兼容字段
|
||||
|
||||
### 验收
|
||||
|
||||
1. 书签、新建高亮、搜索命中都能生成标准 CFI
|
||||
2. 相同文本范围在重新分页后仍能正确恢复
|
||||
|
||||
## Phase 4:脚注 / 尾注弹层闭环
|
||||
|
||||
### 目标
|
||||
|
||||
把脚注从“跳出当前阅读流”改为“就地预览 + 可回跳”。
|
||||
|
||||
### 需要改的模块
|
||||
|
||||
1. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift`
|
||||
2. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift`
|
||||
3. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteModels.swift`
|
||||
4. `Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift`
|
||||
5. `Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift`
|
||||
6. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 完善脚注链接识别:
|
||||
- `epub:type=noteref`
|
||||
- `role=doc-noteref`
|
||||
- `href=#footnote-*`
|
||||
- 章节内 / 跨章节 note link
|
||||
2. note target 解析后落为 `CFI`
|
||||
3. 弹层展示注释正文,而不是强制整章跳转
|
||||
4. 弹层内支持:
|
||||
- 查看上下文
|
||||
- 跳到原文
|
||||
- 跳到注释原位置
|
||||
5. 建立“入口位置 CFI -> 弹层 -> 回跳位置 CFI”的闭环
|
||||
|
||||
### 验收
|
||||
|
||||
1. 点击脚注不打断当前阅读主流程
|
||||
2. 长注释可滚动
|
||||
3. 关闭弹层后能回到点击时的位置
|
||||
4. 跨章节尾注也能稳定打开
|
||||
|
||||
## Phase 5:样式兼容层与字体回退闭环
|
||||
|
||||
### 目标
|
||||
|
||||
减少“某些 EPUB 打开排版异常”的商业级缺陷。
|
||||
|
||||
### 需要改的模块
|
||||
|
||||
1. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift`
|
||||
2. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift`
|
||||
3. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift`
|
||||
4. `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift`
|
||||
5. `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBHTMLNormalizer.swift`
|
||||
6. `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBRenderDiagnosticsCollector.swift`
|
||||
|
||||
### 具体任务
|
||||
|
||||
1. 建立 CSS 兼容策略包:
|
||||
- 非法 `font-family` 归一化
|
||||
- 极端 `line-height` 修正
|
||||
- 过大 `margin/padding` 收口
|
||||
- 表格 / 图片 / SVG 溢出保护
|
||||
- 嵌套 `white-space` 异常归一化
|
||||
2. 建立字体 fallback 链:
|
||||
- 书内嵌入字体
|
||||
- 用户选中字體
|
||||
- 语言 fallback
|
||||
- 系统兜底字体
|
||||
3. diagnostics 输出:
|
||||
- 缺失字体
|
||||
- 被修正的 CSS 规则
|
||||
- 可能导致排版异常的资源
|
||||
|
||||
### 验收
|
||||
|
||||
1. 缺字库 EPUB 不崩溃、不出现大面积 tofu
|
||||
2. 非法 CSS 不导致正文不可读
|
||||
3. diagnostics 可定位问题书源
|
||||
|
||||
---
|
||||
|
||||
## 10. 关键实现细节
|
||||
|
||||
## 10.1 DOM 路径构建规则
|
||||
|
||||
1. 元素节点使用偶数 step
|
||||
2. 文本节点使用奇数 step
|
||||
3. 忽略注释节点、doctype、处理指令
|
||||
4. `script/style` 默认不参与文本定位
|
||||
5. `ruby`、`rt`、`rp` 需要单独定义标准化策略,避免正文偏移
|
||||
|
||||
## 10.2 标准化文本规则必须固定
|
||||
|
||||
以下规则必须全链路共用同一份实现,否则 CFI 会漂:
|
||||
|
||||
1. HTML entity 解码
|
||||
2. 连续空白压缩
|
||||
3. 换行归一化
|
||||
4. 零宽字符处理
|
||||
5. `nbsp` 处理
|
||||
6. 附件占位字符策略
|
||||
7. 中文全角 / 半角是否归一
|
||||
|
||||
建议把规则集中到一个入口,避免 `Builder`、`Search`、`Selection` 各写一套。
|
||||
|
||||
## 10.3 `renderSignature` 与 `domFingerprint` 分工
|
||||
|
||||
1. `renderSignature`
|
||||
- 描述“分页语义”
|
||||
- 用于判定页图、chapter summary、layout cache 是否可复用
|
||||
2. `domFingerprint`
|
||||
- 描述“章节 DOM 结构”
|
||||
- 用于判定 `cfiMap` 是否可复用
|
||||
|
||||
这两个概念不能混用。
|
||||
|
||||
## 10.4 本地缓存策略
|
||||
|
||||
建议缓存分三层:
|
||||
|
||||
1. `raw parse cache`
|
||||
- OPF、manifest、spine、resource bytes
|
||||
2. `chapter summary cache`
|
||||
- 章节文本、fragmentOffsets、cfiMap、pathRanges、tokenIndex
|
||||
3. `page map / runtime cache`
|
||||
- 与具体排版参数绑定
|
||||
|
||||
原则:
|
||||
|
||||
1. 字号、字体、行距变化时,第三层必须失效
|
||||
2. 第二层尽量复用
|
||||
3. `cfiMap` 属于第二层,不应因单纯重排被清空
|
||||
|
||||
---
|
||||
|
||||
## 11. 文件改动清单
|
||||
|
||||
## 11.1 必改文件
|
||||
|
||||
### CFI 核心
|
||||
|
||||
1. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift`
|
||||
2. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift`
|
||||
3. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift`
|
||||
4. `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift`
|
||||
5. 新增 `Sources/RDReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift`
|
||||
|
||||
### 构建与缓存
|
||||
|
||||
1. `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookModels.swift`
|
||||
2. `Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift`
|
||||
3. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift`
|
||||
4. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift`
|
||||
|
||||
### 定位消费方
|
||||
|
||||
1. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextIndexTable.swift`
|
||||
2. `Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift`
|
||||
3. `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextSearchEngine.swift`
|
||||
4. `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift`
|
||||
5. `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift`
|
||||
6. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||
7. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift`
|
||||
8. `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterOffsetMap.swift`
|
||||
9. `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift`
|
||||
|
||||
### 脚注
|
||||
|
||||
1. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteDetector.swift`
|
||||
2. `Sources/RDReaderView/EPUBCore/Notes/RDEPUBNoteResolver.swift`
|
||||
3. `Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupCoordinator.swift`
|
||||
4. `Sources/RDReaderView/EPUBUI/Notes/RDEPUBNotePopupViewController.swift`
|
||||
|
||||
### 样式兼容
|
||||
|
||||
1. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBStyleCompatibilityModels.swift`
|
||||
2. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBCSSCompatibilityLayer.swift`
|
||||
3. `Sources/RDReaderView/EPUBTextRendering/Typesetter/Compatibility/RDEPUBFontFallbackResolver.swift`
|
||||
4. `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBStyleSheetComposer.swift`
|
||||
|
||||
## 11.2 建议新增测试
|
||||
|
||||
1. `ReadViewDemo/ReadViewDemoTests/CFI/RDEPUBCFIRecoveryEngineTests.swift`
|
||||
2. `ReadViewDemo/ReadViewDemoTests/CFI/RDEPUBCFITextNodeMapBuilderTests.swift`
|
||||
3. `ReadViewDemo/ReadViewDemoTests/Notes/RDEPUBNoteResolverTests.swift`
|
||||
4. `ReadViewDemo/ReadViewDemoTests/Compatibility/RDEPUBCSSCompatibilityLayerTests.swift`
|
||||
5. `ReadViewDemo/ReadViewDemoUITests/ReaderUITests/LocationPersistenceTests.swift`
|
||||
6. `ReadViewDemo/ReadViewDemoUITests/ReaderUITests/FootnotePopupTests.swift`
|
||||
|
||||
---
|
||||
|
||||
## 12. 回归样书与验收用例
|
||||
|
||||
至少准备以下样书:
|
||||
|
||||
1. 标准文本 EPUB,节点 `id` 完整
|
||||
2. 无 `id` 文本 EPUB
|
||||
3. 重复文本很多的长章节 EPUB
|
||||
4. 脚注密集型 EPUB
|
||||
5. 嵌入字体缺失或损坏 EPUB
|
||||
6. CSS 激进覆盖型 EPUB
|
||||
7. 跨章节尾注 EPUB
|
||||
|
||||
关键验收:
|
||||
|
||||
1. 阅读到某章第 5 页,改字号后仍落在同一阅读语义位置
|
||||
2. 高亮一段正文,改字体后还能精确回到同一段
|
||||
3. 搜索命中结果重开书后可稳定定位
|
||||
4. 点击脚注弹层展示,不强制整章跳转
|
||||
5. 关闭脚注后回到点击前位置
|
||||
6. 不规范 CSS 的书籍仍可阅读
|
||||
7. 缺失嵌入字体时能稳定 fallback
|
||||
|
||||
---
|
||||
|
||||
## 13. 性能与风险要求
|
||||
|
||||
### 性能目标
|
||||
|
||||
1. 单章节 `cfiMap` 构建增量耗时 P50 `< 12ms`,P95 `< 35ms`
|
||||
2. `CFI -> offset` 恢复耗时 P50 `< 2ms`,P95 `< 8ms`
|
||||
3. 二次打开时不因 CFI 恢复而重新解析整本书
|
||||
|
||||
### 主要风险
|
||||
|
||||
1. 章节标准化文本规则不统一,导致生成和恢复不一致
|
||||
2. token 锚过密,导致缓存膨胀
|
||||
3. 断言窗口过短,在重复文本章节误命中
|
||||
4. 兼容层修正规则过激,伤及正常书籍排版
|
||||
|
||||
对应策略:
|
||||
|
||||
1. 文本标准化统一收口
|
||||
2. token 锚做稀疏抽样并设章节上限
|
||||
3. diagnostics 输出恢复置信度
|
||||
4. CSS 修正规则全部可开关并支持样书回归
|
||||
|
||||
---
|
||||
|
||||
## 14. 推荐排期
|
||||
|
||||
建议按 4 个迭代执行:
|
||||
|
||||
1. 第 1 迭代
|
||||
- Phase 1
|
||||
- Phase 2
|
||||
2. 第 2 迭代
|
||||
- Phase 3
|
||||
- 核心位置恢复 UI 回归
|
||||
3. 第 3 迭代
|
||||
- Phase 4
|
||||
- 脚注体验打磨
|
||||
4. 第 4 迭代
|
||||
- Phase 5
|
||||
- 样书库、diagnostics、稳定性回归
|
||||
|
||||
---
|
||||
|
||||
## 15. 最终交付定义
|
||||
|
||||
完成本蓝图后,阅读器至少应达到以下标准:
|
||||
|
||||
1. 阅读位置、高亮、书签、搜索命中全部优先基于标准 CFI
|
||||
2. 无 `id` 节点的 EPUB 仍能做精确字符级恢复
|
||||
3. 脚注 / 尾注不再破坏主阅读流
|
||||
4. 字体缺失和异常 CSS 不再轻易把正文排坏
|
||||
5. 章节缓存、页图缓存、定位缓存职责清晰,二次打开可复用
|
||||
|
||||
这时阅读器才算从“功能可用”进入“商业级可发布”的基线。
|
||||
Reference in New Issue
Block a user