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:
shenlei
2026-06-24 17:47:24 +08:00
co-authored by Claude
parent 7de661eb54
commit d15f20b097
59 changed files with 4522 additions and 7220 deletions
-115
View File
@@ -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 绘制文本后,遍历当前页 RDEPUBHighlightCGContext 绘制背景矩形(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
View File
@@ -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 // 分页状态与窗口替换
-606
View File
@@ -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
- 最低 API24Android 7.0
- 目标 API34
- 构建工具: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 |
+3 -3
View File
@@ -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()
+3 -3
View File
@@ -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()
+6 -2
View File
@@ -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`:交互式 EPUBJS/音视频/表单/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
- G1reflowable 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
- N1Fixed 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 4EPUB 嵌入 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 主要风险
- R1DTCoreText 对 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 层 overlaybackground + 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 位置
-740
View File
@@ -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 再拆成更细的文件级改动清单、迁移顺序和验收步骤。
+14 -4
View File
@@ -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
View File
@@ -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
-110
View File
@@ -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. 章节缓存、页图缓存、定位缓存职责清晰,二次打开可复用
这时阅读器才算从“功能可用”进入“商业级可发布”的基线。