feat: add in-reader search and restructure documentation

- Add RDEPUBReaderSearchBarView with animated show/hide, keyword
  navigation, and match counting integrated into the reader controller
- Restructure docs: replace scattered design docs with consolidated
  BUSINESS_LOGIC.md and UML_CLASS_DIAGRAMS.md; update ARCHITECTURE.md
- Add SearchTests and FanrenParseTimeTest; enhance LargeBookOnDemandTests
- Add scripts/run_ui_regression.sh and summarize_ui_results.py for
  automated UI test execution and reporting

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-05 17:34:50 +08:00
co-authored by Claude Opus 4.7
parent d20196ee34
commit 0e7c0577e3
37 changed files with 4941 additions and 7621 deletions
+389 -456
View File
@@ -1,507 +1,440 @@
# RDReaderView 架构文档
# ReadViewSDK 系统架构文档
> 最后更新:2026-06-04
---
## 1. 项目概览
RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即用的 EPUB 阅读能力,并保留对纯文本翻页的支持
ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable)和固定布局(Fixed Layout)两种 EPUB 格式,同时兼容纯文本 (.txt) 文件。SDK 提供完整的 EPUB 解析、文本渲染、分页计算、阅读 UI 和标注管理能力
- **最低 iOS 版本**15.0
- **Swift 版本**5.10+
- **依赖**ZIPFoundationEPUB 解压)、DTCoreText(文本 EPUB 渲染
- **Demo 额外依赖**SnapKit、SSAlertSwift
**技术栈:**
- 语言:Swift 5,最低 iOS 15.6
- 依赖:DTCoreTextHTML→NSAttributedString)、ZIPFoundationEPUB 解压)、SnapKitAuto Layout)、SSAlertSwift(弹窗
- 构建:CocoaPods,本地 pod 引用
---
## 2. 总体分层
## 2. 分层架构
```
┌─────────────────────────────────────────────────────────────┐
│ ReadViewDemo (Demo App) │
│ ViewController · LaunchAutomationPlan · UITests │
└──────────────────────────────┬──────────────────────────────┘
│ imports RDReaderView
┌──────────────────────────────▼──────────────────────────────┐
│ EPUBUI 层 │
│ RDEPUBReaderController · Coordinators · Settings · TextPage │
│ ChapterRuntimeLoader · Store · DiskCache · PageMap
└──────────────────────────────┬──────────────────────────────┘
┌──────────────────────────────▼──────────────────────────────┐
│ RDReaderView 层 │
│ RDReaderView · FlowLayout · PreloadController │
│ SpreadResolver · TapRegionHandler · PagingController │
└──────────────────────────────┬──────────────────────────────┘
┌──────────────────────────────▼──────────────────────────────┐
│ EPUBTextRendering 层 │
│ TypesetterPipeline · DTCoreTextRenderer · Pagination │
│ BuildPipelineTextBookBuilder · Cache · Sampler
└──────────────────────────────┬──────────────────────────────┘
┌──────────────────────────────▼──────────────────────────────┐
│ EPUBCore 层 │
│ RDEPUBParser · Publication · ResourceResolver │
│ ReadingSession · WebView · JavaScriptBridge · Paginator │
└─────────────────────────────────────────────────────────────┘
```
### 各层职责
| 层 | 职责 | 核心类 |
|---|---|---|
| **EPUBCore** | EPUB 文件解析、资源管理、WebView 渲染、JS 桥接 | `RDEPUBParser`, `RDEPUBPublication`, `RDEPUBWebView` |
| **EPUBTextRendering** | HTML→NSAttributedString 转换、排版、分页、全书构建 | `RDEPUBTextBookBuilder`, `RDEPUBCoreTextPageFrameFactory` |
| **RDReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDReaderView`, `RDReaderFlowLayout`, `RDReaderPreloadController` |
| **EPUBUI** | 阅读器控制器、协调器模式、设置面板、按需加载、磁盘缓存 | `RDEPUBReaderController`, `RDEPUBReaderPaginationCoordinator` |
---
## 3. 核心数据流
### 3.1 打开书籍流程
```
用户点击书籍
RDEPUBReaderController.openBook(epubURL:)
├─ RDEPUBParser.parse(epubURL:) // 解压 ZIP → 解析 OPF → 构建 spine/TOC
│ └─ extractArchiveIfNeeded() // ZIPFoundation 解压到 Caches
│ └─ parseContainerRootFile() // SAX 解析 container.xml
│ └─ parseOPF() // SAX 解析 OPF (metadata/manifest/spine)
│ └─ parseTOC() // NCX 或 Navigation Document
├─ RDEPUBPublication(parser:) // 创建门面对象
├─ 判断 readingProfile:
│ ├─ .textReflowable → 文本排版路径(本 SDK 核心路径)
│ ├─ .webInteractive → WebView 渲染路径
│ └─ .webFixedLayout → 固定布局路径
└─ paginateTextPublication() // 进入分页流程
```
### 3.2 文本重排分页流程(大书优化路径)
```
paginateTextPublication()
├─ restoreBookPageMapIfPossible() // 尝试从磁盘恢复完整 BookPageMap
│ └─ summaryDiskCache.readAll(keys:) // 批量读取磁盘摘要
│ └─ 如果完整 → 直接 applyBookPageMap() → 完成
├─ 快速打开路径:
│ ├─ loadFirstRenderableRuntimeChapter() // 加载第一个可渲染章节
│ ├─ loadInitialRuntimeChapters() // 加载窗口内相邻章节
│ ├─ makePartialPageMap() // 构建局部 BookPageMap
│ └─ applyBookPageMap() // 应用到 UI,用户可立即阅读
└─ paginateMetadataOnly() // 后台元数据解析
├─ 预计算 contentHash(串行) // 读取所有章节 HTML + SHA-256
├─ readAll(keys:) 恢复已有缓存
├─ waitForReadingInteractionToSettle() // 等待用户操作冷却 0.8s
├─ OperationQueue (并发 N):
│ ├─ buildChapter() // 渲染单章 (HTML→NSAttrStr→分页)
│ ├─ chapterCacheKey() // 构建缓存键(复用预计算 hash)
│ ├─ RDEPUBChapterSummary 写盘 // 异步写入磁盘摘要
│ └─ 每 32 章刷新 BookPageMap // 增量更新 UI
└─ 最终 applyBookPageMap() // 完整页码映射
```
### 3.3 单章渲染管线
```
Raw HTML (从 EPUB 解压目录读取)
▼ [RDEPUBTextTypesetterPipeline]
├─ RDEPUBHTMLNormalizer // 去 CR、合并空行、规范化附件标记
├─ RDEPUBSemanticMarkerInjector // 注入分页语义标记 ${rd-sem-start/end}
├─ RDEPUBRenderDiagnosticsCollector // 内联 <link stylesheet>、收集图片诊断
├─ RDEPUBStyleSheetComposer // 构建 5 层 CSS (default/replace/dark/epub/user)
├─ RDEPUBFontNormalizer // 注册 @font-face 嵌入字体
├─ RDEPUBFragmentMarkerInjector // 注入 fragment 锚点标记 ${id=xxx}
▼ [RDEPUBDTCoreTextRenderer]
├─ DTHTMLAttributedStringBuilder // HTML → NSAttributedString
├─ applyPaginationSemantics() // 将语义标记转为NSAttributedString属性
├─ extractFragmentOffsets() // 提取 fragment ID→offset 映射
├─ normalizeReadingAttributes() // 规范化字体、行距、颜色、附件
▼ [RDEPUBChapterPageCounter]
├─ CTFramesetter 创建帧
├─ RDEPUBPageBreakPolicy 应用语义边界规则
│ ├─ avoidPageBreakInside // 避免块内分页
│ ├─ keepWithNext // 标题与正文不分离
│ ├─ widow/orphan 控制 // 孤行/寡行控制
│ └─ pageRelate // 微信读书跨页关联
├─ RDEPUBChapterTailNormalizer // 删除/合并空白尾页
▼ [RDEPUBTextBookBuilder]
└─ 组装 RDEPUBTextPage[] → RDEPUBTextChapter → RDEPUBTextBook
```
---
## 4. 缓存架构(三级缓存)
```
┌─────────────────────────────────────────────────────────┐
Demo / 宿主 App
ViewController → RDURLReaderControllerdemo 级路由控制器)
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
│ EPUBUI 层(library 级读者 UI
│ 主控制器 │
RDEPUBReaderController(开箱即用入口)
+ContentDelegates / +DataSource / +PublicAPI
+RenderSupport / +RuntimeBridge / +TableOfContents
RDURLReaderControllerURL 阅读入口)
ReaderController/(协调器)
│ RDEPUBReaderRuntime(中央运行时协调器) │
RDEPUBReaderContext(上下文状态容器)
│ RDEPUBReaderDependencies(依赖注入) │
RDEPUBReaderLoadCoordinatorEPUB 加载)
RDEPUBReaderPaginationCoordinator(分页协调)
RDEPUBReaderLocationCoordinator(位置持久化)
RDEPUBReaderAnnotationCoordinator(标注管理)
RDEPUBReaderSearchCoordinator(搜索)
│ RDEPUBReaderChromeCoordinator(工具栏) │
│ RDEPUBReaderAssemblyCoordinatorUI 组装) │
│ RDEPUBReaderViewportMonitor(视口变化监听) │
│ │
│ Settings/(配置与主题) │
│ RDEPUBReaderConfiguration / RDEPUBReaderSettings │
│ RDEPUBReaderSettingsViewController / RDEPUBReaderTheme │
│ │
│ TextPage/(文本页面交互) │
│ RDEPUBTextContentView / RDEPUBTextPageRenderView │
│ RDEPUBSelectableTextView / RDEPUBTextSelectionController│
│ RDEPUBSelectionOverlayView / RDEPUBTextAnnotationOverlay│
│ RDEPUBPageInteractionController / RDEPUBPageLayoutSnapshot│
│ RDEPUBTextPageDecorationView │
│ │
│ 工具栏与面板 │
│ RDEPUBReaderTopToolView / RDEPUBReaderBottomToolView │
│ RDEPUBReaderToolView(基类) │
│ RDEPUBReaderChapterListController(目录面板) │
│ RDEPUBReaderHighlightsViewController(高亮管理) │
│ RDEPUBReaderPersistence(位置持久化) │
│ RDEPUBReaderDelegate / RDEPUBReaderTableOfContentsItem│
│ RDEPUBWebContentView / RDEPUBWebDecorationOverlayView │
│ RDEPUBViewportTypes / UIColor+RDEPUBHex │
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
│ 翻页容器层(RDReaderView
│ │
│ RDReaderViewUIView,统一翻页外壳) │
│ 3 种翻页模式:pageCurl / horizontalScroll / │
│ verticalScroll │
│ RDReaderViewProtocolsDataSource / Delegate / DisplayType)│
│ +CollectionView / +ContentAccess / +PageCurl / +ToolView │
│ RDReaderFlowLayout / RDReaderContentCell │
│ RDReaderPageChildViewControllerpageCurl 页包装) │
│ RDReaderGestureController │
│ │
│ Paging/(翻页控制) │
│ RDReaderPagingController(转场与排队) │
│ RDReaderPreloadController(预加载与缓存) │
│ RDReaderSpreadResolver(双页配对) │
│ RDReaderTapRegionHandler(手势分区) │
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
│ EPUBCore 层(EPUB 引擎) │
│ │
│ 解析与模型 │
│ RDEPUBParser+Archive / +Package / +TOC / │
│ +ReadingProfile / +Resources
│ RDEPUBPublication(出版物聚合对象) │
│ RDEPUBModelsmetadata / manifest / spine 模型) │
│ Models/ │
│ RDEPUBReadingLocationModelslocation 模型) │
│ RDEPUBPaginationModels(分页模型) │
│ RDEPUBAnnotationModels(标注模型) │
│ RDEPUBTextAnchor / RDEPUBTextRangeAnchor(文本锚点) │
│ RDEPUBRenderRequest(渲染请求模型) │
│ │
│ 服务层 │
│ RDEPUBResourceResolver(资源 URL 统一入口) │
│ RDEPUBResourceURLSchemeHandlerss-reader:// 协议) │
│ RDEPUBPreferences(展示参数聚合) │
│ RDEPUBPaginator(离屏分页服务) │
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
│ │
│ 会话与导航 │
│ RDEPUBReadingSession(状态机 + 会话协调) │
│ RDEPUBNavigatorState(状态枚举) │
│ RDEPUBNavigatorLayoutContext │
│ │
│ WebView 渲染 │
│ RDEPUBWebView+Configuration / +Reflowable / │
│ +FixedLayout / +JavaScriptBridge / │
│ +Search
│ RDEPUBWebViewDebug(调试日志工具) │
│ │
│ 搜索 │
│ RDEPUBSearchEngine(协议)/ RDEPUBHTMLSearchEngine │
│ RDEPUBSearchModelsSearchMatch/Result/State/Presentation)│
└───────────────────────────┬─────────────────────────────┘
┌───────────────────────────▼─────────────────────────────┐
│ EPUBTextRendering 层(文本 EPUB 渲染) │
│ │
│ 渲染 │
│ RDEPUBTextRenderer(协议) │
│ RDEPUBDTCoreTextRendererDTCoreText 实现) │
│ RDPlainTextBookBuilder(纯文本书籍构建) │
│ RDEPUBTextPositionConverter(位置转换器) │
│ RDEPUBTextSearchEngine(文本搜索引擎) │
│ RDEPUBTextIndexTable / RDEPUBChapterData │
│ │
│ BuildPipeline/(构建管线) │
│ RDEPUBTextBookBuilder(分页书籍构建器) │
│ RDEPUBTextBookCache / RDEPUBTextBookModels │
│ RDEPUBTextBuildPipelineInterfaces(管线协议) │
│ RDEPUBPaginationCacheCoordinator(缓存协调) │
│ RDEPUBChapterTailNormalizer(章尾规范化) │
│ RDEPUBBuildDiagnosticsReporter(诊断报告) │
│ RDEPUBTextPerformanceSampler(性能采样) │
│ │
│ Pagination/(分页引擎) │
│ RDEPUBTextLayouter / RDEPUBTextLayoutFrame │
│ RDEPUBChapterPageCounter / RDEPUBCoreTextPageFrameFactory│
│ RDEPUBPageBreakPolicy(断页策略) │
│ RDEPUBTextPaginationInterfaces(分页协议) │
│ RDEPUBTextPaginationSupport(分页支持) │
│ │
│ Typesetter/(排版管线) │
│ RDEPUBTypesettingPipeline(排版管线编排) │
│ RDEPUBHTMLNormalizerHTML 规范化) │
│ RDEPUBStyleSheetComposerCSS 组合) │
│ RDEPUBFontNormalizer(字体规范化) │
│ RDEPUBAttachmentNormalizer(附件规范化) │
│ RDEPUBFragmentMarkerInjectorFragment 标记注入) │
│ RDEPUBSemanticMarkerInjector(语义标记注入) │
│ RDEPUBRenderDiagnosticsCollector(渲染诊断) │
│ RDEPUBTextRendererSupport(渲染辅助工具) │
└──────────────────────────────────────────────────────────┘
Tier 1: 内存缓存 (RDEPUBChapterRuntimeStore)
├─ chapterDataCache: [Int: RDEPUBRuntimeChapter]
│ ├─ pageCountCache: [CacheKey: RDEPUBRuntimePageCount] │
├─ imageCache: NSCache<NSString, UIImage> (50 上限)
│ └─ 窗口驱逐:只保留当前章节 ± windowRadius 的章节 │
└──────────────────────────┬──────────────────────────────┘
miss
┌──────────────────────────▼──────────────────────────────┐
Tier 2: 磁盘摘要缓存 (RDEPUBChapterSummaryDiskCache)
├─ 路径: ~/Caches/RDEPUBChapterSummaryCache/{bookID}/
├─ 文件名: SHA256(bookID_spineIdx_renderSig_contentHash)
├─ 格式: JSON (RDEPUBChapterSummary)
├─ 写入: 异步(serial DispatchQueue
└─ 读取: 同步(readAll 批量读取)
└──────────────────────────┬──────────────────────────────┘
miss
┌──────────────────────────▼──────────────────────────────┐
Tier 3: 全书分页缓存 (RDEPUBTextBookCache)
├─ 路径: ~/Caches/RDEPUBTextBookCache/
├─ 格式: NSSecureCoding archive
├─ 内容: 每章的 pageRanges + breakReasons + semanticHints
└─ 失效: schemaVersion 变更时全部失效
└─────────────────────────────────────────────────────────┘
```
---
## 3. 翻页容器层(RDReaderView
### 3.1 三种翻页模式
| 模式 | 实现方式 | 特点 |
|------|----------|------|
| `pageCurl` | UIPageViewController | 原生翻书效果,手势由系统提供 |
| `horizontalScroll` | UICollectionView + RDReaderFlowLayout | 每屏显示 2 项,水平分页滚动 |
| `verticalScroll` | UICollectionView + RDReaderFlowLayout | 全宽项目,垂直连续滚动 |
### 3.2 数据源协议
### 缓存键设计
```swift
public protocol RDReaderDataSource: NSObjectProtocol {
func pageCountOfReaderView(readerView: RDReaderView) -> Int
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
struct RDEPUBChapterCacheKey: Hashable {
let bookID: String //
let spineIndex: Int //
let renderSignature: String // ////
let chapterContentHash: String // HTML SHA-256
}
```
### 3.3 手势分区(scroll 模式)
屏幕水平三等分:
- **左 1/3**:上一页
- **中 1/3**:显示 / 隐藏工具栏
- **右 1/3**:下一页
### 3.4 翻页模式切换
`switchReaderDisplayType(_ type:)` 会完全销毁并重建底层 viewpageViewController 或 collectionView),然后重新加载数据。
缓存键的四元组设计确保:
- 换字体/字号 → renderSignature 变化 → 缓存失效
- EPUB 内容更新 → contentHash 变化 → 缓存失效
- 不同书籍 → bookID 不同 → 互不干扰
---
## 4. EPUB 引擎层(EPUBCore
### 4.1 Publication 层:解析与聚合
**主链路:**
## 5. 章节按需加载架构
```
epubURL
→ RDEPUBParser.parse(epubURL:)
→ extractArchive # ZIP 解压到沙盒临时目录
parseContainerXML # 定位 OPF 路径
→ parseOPF # 解析 metadata / manifest / spine
→ parseTOC # 解析 NCX 或 Nav 目录
→ RDEPUBPublication(parser:) # 聚合解析结果,挂载 resourceResolver
RDEPUBReaderController
├─ RDEPUBReaderContext // 共享状态中心
├─ parser, publication, readingSession
│ ├─ configuration, persistence
│ └─ 便捷方法 (renderStyle, layoutConfig, cacheKey)
├─ RDEPUBReaderRuntime // 运行时协调器集合
│ ├─ chapterLoader // 章节加载器
│ ├─ chapterRuntimeStore // 内存缓存
│ ├─ summaryDiskCache // 磁盘摘要缓存
│ └─ viewportMonitor // 视口变化监控
├─ RDEPUBReaderPaginationCoordinator // 分页协调器
│ ├─ paginatePublication() // 入口
│ ├─ paginateMetadataOnly() // 后台元数据解析
│ └─ restoreBookPageMapIfPossible() // 缓存恢复
├─ RDEPUBChapterLoader // 章节加载器
│ ├─ loadChapter() // 异步加载(Tier1→Tier2→全量构建)
│ └─ loadChapterSynchronouslyForMigration() // 同步加载(快速打开用)
└─ RDEPUBBookPageMap // 轻量页码映射
├─ ~100KB/1000章,不持有 NSAttributedString
└─ 支持增量刷新 (Builder pattern)
```
**RDEPUBPublication 暴露的能力:**
---
| 属性 / 方法 | 说明 |
|------------|------|
| `metadata` | 书名、作者、语言、layout 等 |
| `manifest` | id → RDEPUBManifestItem 映射 |
| `spine` | 有序 spine 列表(包含 href |
| `tableOfContents` | 树形目录 |
| `layout` | `.reflowable``.fixed` |
| `readingProfile` | `.webInteractive` / `.webFixedLayout` / `.textReflowable` |
| `resourceResolver` | 统一资源 URL 解析入口 |
| `fixedLayoutSpreadEnabled(for:viewportSize:)` | 判断是否启用双页 spread |
| `makeFixedSpreads(preferences:viewportSize:)` | 生成 fixed spread 页模型 |
**readingProfile 判定逻辑:**
## 6. WebView 渲染架构(Web 路径)
```
layout == .fixed → webFixedLayout
layout == .reflowable + 含交互脚本 → webInteractive
layout == .reflowable + 无交互脚本 → textReflowable
RDEPUBWebView (UIView)
├─ WKWebView (RDEPUBAnnotationWebView)
│ ├─ 自定义选择菜单(拷贝/高亮/批注)
│ └─ 禁用系统手势(滚动手势由原生控制)
├─ RDEPUBResourceURLSchemeHandler
│ ├─ 协议: ss-reader://book/<path>
│ ├─ 映射: URL → 本地 EPUB 解压文件
│ └─ 容错: 缺失资源返回空响应(不 404)
├─ JavaScript Bridge (6 种消息)
│ ├─ ssReaderProgressionChanged // 阅读进度更新
│ ├─ ssReaderSelectionChanged // 文本选区变化
│ ├─ ssReaderInternalLink // EPUB 内部链接
│ ├─ ssReaderExternalLink // 外部链接
│ ├─ ssReaderJSError // JS 运行时错误
│ └─ ssReaderFixedLayoutReady // 固定布局加载完成
└─ 注入脚本
├─ epub-bridge.js (502 行) // 核心桥接,分页/滚动/选区/高亮
├─ cssInjector.js (45 行) // CSS 注入管理
├─ WeReadApi.js (67 行) // 微信读书 API 兼容层
├─ rangy-core.js / rangy-serializer.js // DOM Range 序列化
└─ CSS: default/replace/dark/latin // 4 套样式表
```
### 4.2 Services 层
---
#### RDEPUBResourceResolver
## 7. 翻页容器架构(RDReaderView
统一处理所有资源路径转换,是 WebView 和 Paginator 访问资源的唯一入口:
```
RDReaderView (UIView)
├─ 三种翻页模式:
│ ├─ .pageCurl → UIPageViewController (翻页动画)
│ ├─ .horizontalScroll → UICollectionView (水平滑动)
│ └─ .verticalScroll → UICollectionView (垂直滚动)
├─ 组合对象:
│ ├─ RDReaderPagingController // 翻页状态管理、请求队列
│ ├─ RDReaderPreloadController // 页面预加载、缓存管理
│ ├─ RDReaderSpreadResolver // 双页展开计算
│ └─ RDReaderTapRegionHandler // 点击区域分类(左/中/右)
├─ 数据源协议:
│ ├─ RDReaderPageProvider (新) // 格式无关,优先级高
│ └─ RDReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配
└─ 手势流:
点击 → TapRegionHandler → 分类(左/中/右)
├─ 左 → goPreviousPage()
├─ 右 → goNextPage()
└─ 中 → tapCenter() (切换工具栏)
```
| 方法 | 说明 |
|------|------|
| `fileURL(forHref:)` | href → 本地文件 URL |
| `schemeURL(forHref:)` | href → `ss-reader://` 协议 URL |
| `href(forSpineIndex:)` | spineIndex → href |
| `normalizedHref(_:)` | 相对路径标准化(统一相对 OPF) |
---
#### RDEPUBPaginator
## 8. 配置与设置
使用隐藏的 `WKWebView` 离屏加载每个 spine 资源,通过 JS 注入分页 CSS,回调每个资源的页数:
### RDEPUBReaderConfiguration
```swift
paginator.calculate(publication: publication, preferences: preferences, viewportSize: size) { pageCounts in
// pageCounts[i] = spine[i]
struct RDEPUBReaderConfiguration {
var fontSize: CGFloat // 16
var fontChoice: FontChoice // //
var lineHeightMultiple: CGFloat // 1.5
var numberOfColumns: Int // 1 2
var columnGap: CGFloat // 20
var theme: ReaderTheme // 6
var displayType: RDReaderView.DisplayType // pageCurl/horizontal/vertical
var onDemandChapterWindowSize: Int // 3 3 15
var metadataParsingConcurrency: Int // CPU
var chapterWindowRadius: Int //
}
```
#### RDEPUBPreferences
### 持久化
聚合 WebView 和 Paginator 共用的展示参数:字号、行距、主题色、边距、布局适配模式、spread 模式。
### 4.3 Navigator 层
#### RDEPUBNavigatorState 状态机
```
initializing → loading → idle ←→ jumping
←→ moving
←→ repaginating
```
| 状态 | 含义 | 允许动作 |
|------|------|----------|
| `initializing` | 刚打开书籍 | 接收初始恢复位置 |
| `loading` | 生成首轮 page model | 缓存 pending navigation |
| `idle` | 显示稳定 | 应用 staged snapshot、处理跳转、保存位置 |
| `jumping` | 目录 / 内部链接跳转中 | 重放 pending location |
| `moving` | 用户翻页中 | 更新当前 pageNum |
| `repaginating` | 重新分页中(字号/横竖屏变化) | 缓存当前位置,等待完成 |
#### RDEPUBReadingSession
会话级协调者,持有:
- `publication`:出版物只读视图
- `activePages / activeChapters`:当前展示的页模型和章节信息
- `stagedPages / stagedChapters`:后台分页完成后暂存,等待 idle 状态时应用
- `pendingNavigationLocation / pendingNavigationPageNum`:等待当前加载完成后再执行的跳转请求
- `currentViewport / currentReadingContext`:当前可见区域的位置信息
关键操作:
```swift
session.stageSnapshot(snapshot, restoreLocation:) //
session.consumeStagedSnapshotIfAllowed() // idle 线
session.transition(to: .idle) //
session.clearPendingNavigation() //
```
### 4.4 Resource View 层
#### RDEPUBWebView
承载单个 spine 资源的 WKWebView,按职责拆分为 4 个扩展文件:
| 扩展 | 职责 |
|------|------|
| `+Configuration` | WKWebViewConfiguration、schemeHandler 注册、user scripts |
| `+Reflowable` | 注入分页 CSS、滚动到指定 progression、接收 JS 事件 |
| `+FixedLayout` | fixed-layout HTML wrapper 生成和加载 |
| `+JavaScriptBridge` | JS ↔ Swift 消息路由、选区、高亮、进度上报 |
| `+Search` | 搜索高亮装饰 |
#### ss-reader:// 协议
所有 spine 资源(XHTML、CSS、图片、字体)统一通过 `ss-reader://book/<relative-path>` 访问,由 `RDEPUBResourceURLSchemeHandler` 从本地解压目录读取并返回。
优点:
- 相对资源路径在 WebView 中自然解析
- fixed-layout iframe 不再白屏
- 无需 `allowingReadAccessTo` 路径权限
| 数据 | 存储方式 | Key 前缀 |
|------|----------|----------|
| 阅读位置 | UserDefaults | `ssreader.epub.location.{bookID}` |
| 书签 | UserDefaults | `ssreader.epub.bookmarks.{bookID}` |
| 高亮标注 | UserDefaults | `ssreader.epub.highlights.{bookID}` |
| 全局设置 | UserDefaults | `ssreader.epub.settings` |
| 章节摘要 | 磁盘文件 | `~/Caches/RDEPUBChapterSummaryCache/` |
| 全书分页 | 磁盘文件 | `~/Caches/RDEPUBTextBookCache/` |
---
## 5. 文本 EPUB 渲染层(EPUBTextRendering
适用于 `readingProfile == .textReflowable` 的书籍(纯文本小说类 EPUB2)。
### 5.1 渲染链路
## 9. 目录结构
```
章节 HTML 文件
→ RDEPUBTextRendererSupport.injectFragmentMarkers(into:) # 注入 fragment 标记
→ RDEPUBDTCoreTextRenderer.renderChapter(html:baseURL:style:)
→ DTHTMLAttributedStringBuilder # HTML → NSAttributedString
→ extractFragmentOffsets # fragment → 字符偏移量映射
→ normalizeReadingAttributes # 统一字体/行距
→ RDEPUBRenderedChapterContent
.attributedString # 渲染后的富文本
.fragmentOffsets # fragment id → 字符偏移
Sources/RDReaderView/
├── EPUBCore/ # EPUB 解析与 WebView 渲染
│ ├── Models/ # 数据模型
│ │ ├── RDEPUBAnnotationModels.swift
│ │ ├── RDEPUBPaginationModels.swift
│ │ └── RDEPUBReadingLocationModels.swift
│ ├── Resources/ # JS/CSS/HTML 静态资源
│ │ ├── epub-bridge.js
│ │ ├── cssInjector.js
│ │ ├── WeReadApi.js
│ │ ├── rangy-core.js / rangy-serializer.js
│ │ ├── wxread-default.css / wxread-dark.css / wxread-replace.css
│ │ └── epub-fixed-layout.html
│ ├── RDEPUBParser.swift # 核心解析器
│ ├── RDEPUBParser+Archive.swift # ZIP 解压
│ ├── RDEPUBParser+Package.swift # OPF 解析
│ ├── RDEPUBParser+TOC.swift # 目录解析
│ ├── RDEPUBParser+Resources.swift # 资源访问
│ ├── RDEPUBPublication.swift # 出版物门面
│ ├── RDEPUBResourceResolver.swift # URL 解析
│ ├── RDEPUBReadingSession.swift # 阅读会话状态机
│ ├── RDEPUBWebView.swift # WebView 主类
│ ├── RDEPUBWebView+*.swift # WebView 扩展(配置/重排/固定/搜索/JS桥接)
│ ├── RDEPUBPaginator.swift # 离屏分页计算器
│ ├── RDEPUBSearchEngine.swift # 全文搜索引擎
│ └── ...
├── EPUBTextRendering/ # 文本渲染引擎
│ ├── Typesetter/ # 排版管线
│ │ ├── RDEPUBTypesettingPipeline.swift
│ │ ├── RDEPUBHTMLNormalizer.swift
│ │ ├── RDEPUBSemanticMarkerInjector.swift
│ │ ├── RDEPUBFragmentMarkerInjector.swift
│ │ ├── RDEPUBFontNormalizer.swift
│ │ ├── RDEPUBAttachmentNormalizer.swift
│ │ ├── RDEPUBStyleSheetComposer.swift
│ │ ├── RDEPUBRenderDiagnosticsCollector.swift
│ │ └── RDEPUBTextRendererSupport.swift
│ ├── Pagination/ # 分页引擎
│ │ ├── RDEPUBChapterPageCounter.swift
│ │ ├── RDEPUBCoreTextPageFrameFactory.swift
│ │ ├── RDEPUBPageBreakPolicy.swift
│ │ ├── RDEPUBTextLayoutFrame.swift
│ │ └── ...
│ ├── BuildPipeline/ # 全书构建
│ │ ├── RDEPUBTextBookBuilder.swift
│ │ ├── RDEPUBTextBookModels.swift
│ │ ├── RDEPUBTextBookCache.swift
│ │ ├── RDEPUBChapterTailNormalizer.swift
│ │ └── ...
│ ├── RDEPUBTextRenderer.swift # 渲染器协议与类型定义
│ ├── RDEPUBDTCoreTextRenderer.swift # DTCoreText 渲染器实现
│ ├── RDEPUBChapterData.swift # 章节查询门面
│ ├── RDEPUBTextIndexTable.swift # 全书索引表
│ └── RDPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器
├── ReaderView/ # 通用翻页容器
│ ├── Paging/ # 翻页子系统
│ │ ├── RDReaderPagingController.swift
│ │ ├── RDReaderPreloadController.swift
│ │ ├── RDReaderSpreadResolver.swift
│ │ └── RDReaderTapRegionHandler.swift
│ ├── RDReaderView.swift # 主容器视图
│ ├── RDReaderView+*.swift # 扩展(CollectionView/PageCurl/ToolView/ContentAccess
│ ├── RDReaderFlowLayout.swift # 自定义 CollectionView 布局
│ ├── RDReaderContentCell.swift # 滚动模式 Cell
│ └── RDReaderPageChildViewController.swift // 翻页模式子 VC
└── EPUBUI/ # 阅读器 UI
├── ReaderController/ # 控制器与协调器
│ ├── ChapterRuntime/ # 章节运行时
│ │ ├── RDEPUBChapterLoader.swift
│ │ ├── RDEPUBChapterRuntimeStore.swift
│ │ ├── RDEPUBChapterSummaryDiskCache.swift
│ │ ├── RDEPUBChapterCacheKey.swift
│ │ ├── RDEPUBBookPageMap.swift
│ │ ├── RDEPUBChapterSummary.swift
│ │ └── ...
│ ├── RDEPUBReaderContext.swift // 共享状态中心
│ ├── RDEPUBReaderRuntime.swift // 运行时协调器
│ ├── RDEPUBReaderPaginationCoordinator.swift // 分页协调器
│ ├── RDEPUBReaderAnnotationCoordinator.swift // 标注协调器
│ ├── RDEPUBReaderSearchCoordinator.swift // 搜索协调器
│ ├── RDEPUBReaderNavigationCoordinator.swift // 导航协调器
│ └── ...
├── Settings/ # 设置面板
│ ├── RDEPUBReaderSettingsViewController.swift
│ ├── RDEPUBReaderThemeSelector.swift
│ └── ...
├── TextPage/ # 文本页面渲染
│ ├── RDEPUBTextContentView.swift
│ ├── RDEPUBTextPageViewController.swift
│ └── ...
├── RDEPUBReaderController.swift # 主控制器
├── RDEPUBReaderConfiguration.swift # 配置模型
└── ...
```
### 5.2 分页
```
RDEPUBTextBookBuilder.buildBook(publication:style:pageSize:renderer:)
→ 逐章节调用 renderer.renderChapter(...)
→ RDEPUBTextPaginationSupport.paginate(attributedString:pageSize:)
→ RDEPUBTextBook(章节 + 页模型 + fragment 索引)
```
### 5.3 内容显示
`RDEPUBTextContentView` 基于 `NSAttributedString` + `UITextView`(或 DTCoreText 自定义绘制),实现:
- 每页显示对应字符范围的内容
- 高亮重叠渲染
- 支持按 fragment 偏移量跳转
---
## 6. EPUBUI 层(开箱即用读者 UI)
## 10. 关键设计模式
`RDEPUBReaderController` 是 library 层提供的完整读者入口,调用方只需传入 EPUB 文件 URL:
```swift
let controller = RDEPUBReaderController(
epubURL: url,
configuration: .default,
persistence: RDEPUBReaderPersistence(storageKey: "my-book")
)
controller.delegate = self
present(controller, animated: true)
```
### 6.1 内置能力
| 能力 | 对应文件 |
| 模式 | 应用场景 |
|------|----------|
| 顶部工具栏(书名、返回、目录、高亮入口) | RDEPUBReaderTopToolView |
| 底部工具栏(进度条、页码) | RDEPUBReaderBottomToolView |
| 目录面板 | RDEPUBReaderChapterListController |
| 高亮管理 | RDEPUBReaderHighlightsViewController |
| 设置面板(字号、行距、主题、翻页模式) | RDEPUBReaderSettingsViewController |
| 阅读位置持久化 | RDEPUBReaderPersistence |
### 6.2 配置项(RDEPUBReaderConfiguration
| 属性 | 默认值 | 说明 |
|------|--------|------|
| `fontSize` | 15 | 字号(pt |
| `lineHeightMultiple` | 1.6 | 行距倍数 |
| `displayType` | `.pageCurl` | 翻页模式 |
| `landscapeDualPageEnabled` | `true` | 横屏双页 |
| `showsTableOfContents` | `true` | 是否显示目录入口 |
| `allowsHighlights` | `true` | 是否显示高亮入口 |
| `showsSettingsPanel` | `true` | 是否显示设置入口 |
| `reflowableContentInsets` | (40,16,40,16) | 可重排内容内边距 |
| `fixedContentInset` | .zero | 固定版式内容内边距 |
| `theme` | `.light` | 主题 |
| `fixedLayoutFit` | `.page` | 固定版式适配模式 |
| `fixedLayoutSpreadMode` | `.automatic` | fixed-layout spread 模式 |
| `textRenderingEngine` | `.dtCoreText` | 文本 EPUB 渲染引擎 |
---
## 7. 关键数据流
### 7.1 打开 EPUB 书籍
```
RDEPUBReaderController.init(epubURL:)
→ viewDidLoad
→ RDEPUBParser.parse(epubURL:)
→ RDEPUBPublication(parser:)
→ RDEPUBReadingSession(publication:)
→ readingProfile 判断渲染路径
textReflowable:
RDEPUBTextBookBuilder.buildBook(...) # 后台分页
→ session.stageSnapshot(...)
→ session.consumeStagedSnapshotIfAllowed() # idle 时应用
→ readerView.reloadData()
webInteractive / webFixedLayout:
RDEPUBPaginator.calculate(...) # 离屏 WebView 分页
→ session.stageSnapshot(...)
→ readerView.reloadData()
→ persistence.restoreLocation() # 恢复上次阅读位置
```
### 7.2 翻页
```
用户手势(tap / swipe
→ RDReaderView 检测手势分区 / 翻页方向
→ delegate.pageNum(readerView:pageNum:)
→ RDEPUBReaderController 根据 pageNum 找 EPUBPage
→ RDEPUBReadingSession.transition(to: .moving)
→ readerView.pageContentView(pageNum:) 回调
→ 创建 RDEPUBWebContentView 或 RDEPUBTextContentView
→ loadPage(spineIndex: pageIndexInChapter: preferences:)
→ JS bridge 上报 progression → session.currentViewport 更新
```
### 7.3 定位模型(RDEPUBLocation
EPUB 是可重排内容,字号 / 横竖屏变化会使页号失效。定位模型使用 `href + progression`
```swift
struct RDEPUBLocation: Codable, Equatable {
var bookIdentifier: String? //
var href: String // OPF spine
var progression: Double // [0, 1]
var lastProgression: Double? // [0, 1]
var fragment: String? //
var rangeAnchor: RDEPUBTextRangeAnchor? // EPUB
}
```
**恢复流程:**
1. 标准化 `href`(相对 OPF
2. 找到对应 spineIndex
3.`navigationProgression` 估算扁平页号
4. 若有 `fragment`,加载后在 WebView 中锚点滚动
---
## 8. 已知限制与后续待办
| 问题 | 说明 |
|------|------|
| RDEPUBReaderController 过大 | 约 1995 行,仍混有 EPUB 加载、UI 管理、数据源逻辑,待进一步拆分 |
| 部分 UI 文件仍然偏大 | RDEPUBTextContentView ~728 行、RDEPUBTextBookBuilder ~717 行,需继续拆分 |
| 横竖屏切换已接入一阶段支持 | `RDEPUBReaderController.viewWillTransition()` 统一接管正文重分页,`RDReaderView` 保留容器级双页布局刷新;仍需补齐固定回归矩阵 |
| 固定手势分区比例 | 三等分固定写死,不支持自定义 |
| 自动化测试为首批接入状态 | 已有 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests` 与 parser / resolver / persistence / smoke 用例,分页、恢复链路和更多 UI 闭环仍需继续补齐 |
| 基线验证待跑 | 四本样书(凡人修仙传、爱忘事熊爷爷、宝山辽墓、张学良传)的分页耗时、目录命中率、末页事件等数据尚未收集 |
---
## 9. 构建与依赖
### 9.1 安装
```bash
cd RDReaderDemo
pod install
# 打开 RDReaderDemo.xcworkspace,选 RDReaderDemo scheme,构建
```
### 9.2 主要依赖
| 依赖 | 用途 | 使用层 |
|------|------|--------|
| ZIPFoundation (~> 0.9) | EPUB ZIP 解压 | RDEPUBParser+Archive |
| DTCoreText | HTML → NSAttributedString | RDEPUBDTCoreTextRenderer |
| SnapKit | Demo 布局 | Demo 层 |
| SSAlertSwift | Demo 弹窗 | Demo 层 |
### 9.3 podspec 关键配置
- `s.source_files = 'Sources/RDReaderView/**/*.{swift}'`(递归包含所有子目录)
- `s.resource_bundles`:包含 JS、CSS 等资源文件
- Library 本身无需 demo 层依赖
| **Coordinator** | 分页/标注/搜索/导航各一个协调器,解耦 Controller |
| **Facade** | `RDEPUBPublication` 封装 `RDEPUBParser``RDEPUBChapterData` 封装章节查询 |
| **Builder** | `RDEPUBBookPageMap.Builder` 增量构建页码映射 |
| **Strategy** | `RDEPUBTextRenderer` 协议,可替换渲染器实现 |
| **Pipeline** | `RDEPUBTextTypesetterPipeline` 8 阶段排版管线 |
| **State Machine** | `RDEPUBNavigatorState` 管理阅读器状态转换 |
| **Adapter** | `RDReaderLegacyDataSourceAdapter` 适配旧数据源协议 |
| **三级缓存** | 内存 → 磁盘摘要 → 全书分页,逐级降级 |
| **Token 取消** | `paginationToken` 确保过期异步任务不干扰新任务 |
| **Frozen Parameters** | 后台任务冻结 `renderSignature`,避免运行中参数漂移 |
+546
View File
@@ -0,0 +1,546 @@
# ReadViewSDK 业务逻辑文档
> 最后更新:2026-06-04
---
## 1. EPUB 解析流程
### 1.1 文件解压
**入口:** `RDEPUBParser.parse(epubURL:)`
EPUB 文件本质是 ZIP 压缩包。解压流程:
1. 计算缓存目录:`~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/`
2. 如果缓存目录已存在且包含 `META-INF/container.xml`,跳过解压
3. 否则使用 `ZIPFoundation` 解压到缓存目录
4. 解压是幂等的,重复调用不会重复解压
### 1.2 OPF 解析
OPFOpen Packaging Format)是 EPUB 的核心描述文件。使用 SAX 解析(`XMLParser` + `XMLParserDelegate`)以降低内存占用。
**解析内容:**
| 区域 | 提取字段 |
|------|----------|
| `<metadata>` | identifier, title, author, language, version, rendition:layout, rendition:spread, readingProgression |
| `<manifest>` | id, href, media-type, properties, fallback, media-overlay |
| `<spine>` | idref, linear, properties, page-spread |
| `<spine toc="...">` | NCX 文件 id |
**Spine 构建:** 将 spine 引用映射到 manifest 条目,规范化 href 相对于 OPF 目录,解析每个条目的布局覆盖(rendition:layout-pre-paginated/reflowable)。
### 1.3 目录解析
TOC(目录)按优先级尝试三种来源:
1. **NCXEPUB 2):** 解析 `<navPoint>` 树形结构,支持嵌套
2. **Navigation DocumentEPUB 3):** 解析 `<nav epub:type="toc">` 中的 `<ol>/<li>/<a>` 结构
3. **回退:** 直接使用 spine 条目的 title 列表
### 1.4 阅读配置文件判断
```swift
enum RDEPUBReadingProfile {
case webInteractive // EPUB WebView
case webFixedLayout // EPUB
case textReflowable // CoreText SDK
}
```
判断逻辑(`RDEPUBParser+ReadingProfile.swift`):
- `rendition:layout == pre-paginated``.webFixedLayout`
- 纯 HTML/XHTML 内容且无复杂交互 → `.textReflowable`
- 其他 → `.webInteractive`
---
## 2. 文本渲染管线
### 2.1 HTML 预处理(Typesetter Pipeline
`RDEPUBTextTypesetterPipeline.makeRequest(from:)` 按顺序执行 8 个阶段:
**阶段 1HTML 规范化** (`RDEPUBHTMLNormalizer`)
- 删除 `<hr lang="zh-CN">分页符</hr>` 分页标记
- CR → LF,合并连续空行
- 规范化附件 HTML 标记:
- `div.qrbodyPic / div.bodyPic` → 合并样式到 `<img>`
- `img.qqreader-footnote` → 行内 1em×1em
- `h1.frontCover > img` → 封面图片 100% 宽度
**阶段 2:语义标记注入** (`RDEPUBSemanticMarkerInjector`)
- 遍历所有 HTML 标签,维护开标签栈
- 为有分页语义的标签注入标记:
- `${rd-sem-start:id=X;block=...;hints=...;placement=...}`
- `${rd-sem-end:id=X}`
- 空标签(img, br, hr)同时注入开始和结束标记
**阶段 3:样式表内联** (`RDEPUBRenderDiagnosticsCollector`)
- 查找 `<link rel=stylesheet href=...>`
- 读取 CSS 文件内容
- 重写 CSS 中的相对 `url()` 引用
- 内联到 HTML 的 `<head>`
**阶段 4CSS 层合成** (`RDEPUBStyleSheetComposer`)
- 构建 5 层 CSS,按优先级从低到高:
1. **default** - 基础阅读器样式(隐藏 head/title/style,默认字体大小)
2. **replace** - 微信读书风格格式化(代码块、标题、引用)
3. **dark** - 暗色模式覆盖(仅暗色主题时注入)
4. **epub** - EPUB 自带样式
5. **user** - 用户设置(字号、行距、颜色,!important)
- 语言检测:检查 lang 属性和文本采样,拉丁语言使用专用 CSS
**阶段 5:字体注册** (`RDEPUBFontNormalizer`)
- 解析 `@font-face { url(...) }`
- 通过 `CTFontManagerRegisterFontsForURL(.process)` 注册嵌入字体
- 已注册字体路径缓存在 `registeredFontPaths` 集合中,避免重复注册
**阶段 6Base URL 注入** (`RDEPUBHTMLNormalizer`)
- 注入 `<base href="...">` 用于相对路径解析
**阶段 7Fragment 标记注入** (`RDEPUBFragmentMarkerInjector`)
- 查找 `<tag id="xxx">` 标签
- 在其前面注入 `${id=xxx}` 标记
**阶段 8:图片诊断收集** (`RDEPUBRenderDiagnosticsCollector`)
- 扫描 `<img src="...">` 标签
- 解析引用路径,检查文件是否存在
- 收集诊断信息用于调试
### 2.2 HTML→NSAttributedString
`RDEPUBDTCoreTextRenderer.renderChapter(request:)`
1. 将 HTML 编码为 `Data`
2. 使用 `DTHTMLAttributedStringBuilder` 构建 `NSAttributedString`
3.`willFlushCallback` 中对每个 DOM 元素调用 `RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering()`
4. 后处理:
- `applyPaginationSemantics()` - 将 `${rd-sem-start/end}` 标记转为 NSAttributedString 属性
- `extractFragmentOffsets()` - 提取 fragment ID→offset 映射,删除标记文本
- `normalizeReadingAttributes()` - 规范化字体、行距、颜色、附件
### 2.3 分页计算
`RDEPUBChapterPageCounter.layoutFrames()` 使用迭代循环:
```
location = 0
while location < totalLength:
1. 创建 CTFrame(通过 CTFramesetter
2. 获取可见范围 (CTFrameGetVisibleStringRange)
3. 应用 avoidPageBreakInside 规则(最多移除 3 行尾部)
4. 应用 keepWithNext 规则(最多移除 3 行尾部)
5. 应用 widow/orphan 控制
6. 应用 pageBreakPolicy 调整
7. 记录页面范围
8. location = 调整后的范围末尾
```
**分页规则优先级:**
| 规则 | 说明 | 最大调整行数 |
|------|------|-------------|
| avoidPageBreakInside | 块内不分页(标题、图片等) | 3 行 |
| keepWithNext | 标题与正文不分离 | 3 行 |
| widow control | 段落最后一行不留到下一页 | 1 行 |
| orphan control | 段落第一行不单独在上一页 | 1 行 |
| pageRelate | 微信读书跨页关联 | 1 行 |
| attachment boundary | 块级图片前后分页 | 0(精确切分) |
### 2.4 尾页规范化
`RDEPUBChapterTailNormalizer.normalize()` 三遍处理:
1. **删除空白中间帧:** 无可见字符且无附件的帧
2. **删除空白尾部帧:** 从末尾开始删除同类空白帧
3. **合并短尾帧:** 如果最后一帧 ≤2 个可见字符且前一帧 ≥8 倍长,合并
---
## 3. 后台元数据解析(大书优化)
### 3.1 整体流程
对于《凡人修仙传》这样的大书(~1000 章),后台解析是核心性能路径。
```
打开书籍
├─ 尝试磁盘缓存恢复(restoreBookPageMapIfPossible
│ └─ 成功 → 直接显示完整页码
├─ 快速打开(3-5 章窗口)
│ ├─ 同步加载首个可渲染章节
│ ├─ 加载窗口内相邻章节
│ └─ 应用局部 BookPageMap → 用户可立即阅读
└─ 后台解析(paginateMetadataOnly
├─ 预计算所有章节 contentHash(串行,~1-3s
├─ 恢复已有磁盘缓存
├─ 等待用户操作冷却 0.8s
├─ 并发渲染未缓存章节(OperationQueueN=CPU核心数)
├─ 每 32 章增量刷新 UI
└─ 最终完整刷新
```
### 3.2 预计算 contentHash 优化
**问题:** 每个章节在渲染后需要构建缓存键,原始实现会重新读取 HTML 并计算 SHA-256。
**优化:** 在后台解析开始时,串行预计算所有章节的 contentHash
```swift
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
let html = parser.htmlString(forRelativePath: href)
contentHashBySpineIndex[spineIndex] = html?.sha256Hex ?? ""
}
```
后续所有 `chapterCacheKey` 调用都使用预计算值,避免重复 I/O 和 SHA-256 计算。
### 3.3 冻结 renderSignature
**问题:** 如果用户在后台解析进行中更改字号/行距,`context.currentRenderSignature()` 会返回新值,导致部分章节用旧签名、部分用新签名写入磁盘缓存,造成缓存不一致。
**解决:**`paginateMetadataOnly` 开始时冻结签名:
```swift
let renderSignature = context.currentRenderSignature()
// chapterCacheKey 使
```
token 机制确保设置变更会触发新的解析任务(新 token),旧任务自动废弃。
### 3.4 锁区瘦身
**原始实现:** `resultLock` 内调用 `buildPageMap()`,该方法遍历全量 catalog 和 summaries。
**优化:** 锁内只做写入和计数,快照数据后锁外构建 pageMap:
```swift
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
summariesBySpineIndex[spineIndex] = renderResult
totalResolvedCount += 1
if shouldRefresh {
snapshot = summariesBySpineIndex //
}
resultLock.unlock()
if let snapshot {
let partialMap = buildPageMap(from: catalog, summaries: snapshot) //
}
```
### 3.5 可配置刷新间隔
```swift
static var pageMapRefreshInterval: Int = 32 // 32
```
可实测调优:32 / 48 / 64。值越大,UI 刷新频率越低,后台解析吞吐越高。
---
## 4. 章节按需加载
### 4.1 加载优先级
`RDEPUBChapterLoader.loadChapter(spineIndex:store:)` 按以下优先级:
1. **Tier 1 内存缓存** - `store.chapterData(for: spineIndex)` → 直接返回
2. **Tier 1 页数缓存** - `store.pageCount(for: cacheKey)` → 跳过分页计算
3. **Tier 2 磁盘缓存** - `summaryDiskCache.read(for: cacheKey)` → 恢复页范围
4. **全量构建** - 渲染 + 分页 + 写缓存
### 4.2 窗口驱逐策略
`RDEPUBChapterRuntimeStore.setCurrentChapter(spineIndex:totalSpineCount:windowRadius:)`
- 保留范围:`[spineIndex - windowRadius, spineIndex + windowRadius]`
- 窗口半径由 `configuration.chapterWindowRadius` 控制
- 超出窗口的章节从内存缓存中驱逐
- 内存警告时驱逐除当前章节外的所有缓存
### 4.3 BookPageMap 增量刷新
`RDEPUBBookPageMap` 是轻量页码映射(~100KB/1000 章),不持有 `NSAttributedString`
Builder 模式支持增量构建:
```swift
var builder = RDEPUBBookPageMap.Builder()
for item in catalog {
if let summary = summaries[item.spineIndex] {
builder.add(spineIndex:href:title:pageCount:fragmentOffsets:)
}
}
return builder.build()
```
---
## 5. 翻页容器逻辑
### 5.1 三种翻页模式
| 模式 | 底层实现 | 特点 |
|------|----------|------|
| pageCurl | UIPageViewController | 翻页动画,系统手势 |
| horizontalScroll | UICollectionView + 自定义 Layout | 水平滑动,分页锁定 |
| verticalScroll | UICollectionView + 自定义 Layout | 垂直滚动,连续滚动 |
### 5.2 双页展开(Landscape Dual Page
**触发条件:** `landscapeDualPageEnabled && isLandscape && !verticalScroll`
**页面配对逻辑(RDReaderSpreadResolver):**
- 有封面页:封面独占一屏,后续页面两两配对
- 配对规则:`(coverIndex, nil)`, `(1, 2)`, `(3, 4)`, ...
- 无封面页:标准偶奇配对
- 配对规则:`(0, 1)`, `(2, 3)`, `(4, 5)`, ...
**封面感知布局(RDReaderFlowLayout):**
```
封面页: [====全屏宽度====]
配对页: [==半宽==][==半宽==]
```
### 5.3 页面预加载
`RDReaderPreloadController` 管理两个缓存:
- `preloadedPageViews` - 预渲染的页面视图
- `pageCurlCachedViews` - 当前显示的页面视图
**预加载策略:**
1. 每次页面变化后调用 `prime(around:preferredForward:)`
2. 计算预测目标:前后各 `radius` 个展开页 + 预测方向额外 1 个展开页
3. 将目标页面预渲染到隐藏的 `preloadHostView`
4. 缓存签名(displayType + isLandscape + pagesPerScreen + boundsSize)变化时清除缓存
### 5.4 点击区域处理
屏幕三等分:
```
[ 左 1/3 ][ 中 1/3 ][ 右 1/3 ]
上一页 切换工具栏 下一页
```
RTL 模式下左右互换。工具栏显示时,左右区域变为 `.center`(隐藏工具栏)。
---
## 6. 标注系统
### 6.1 数据模型
```swift
//
struct RDEPUBAnnotation {
let id: String
let kind: RDEPUBAnnotationKind // .bookmark, .highlight, .underline
let location: RDEPUBLocation
let text: String?
let color: String?
let note: String?
}
//
struct RDEPUBBookmark {
let id: String
let location: RDEPUBLocation
let chapterTitle: String?
let note: String?
}
//
struct RDEPUBHighlight {
let id: String
let location: RDEPUBLocation
let text: String
let style: RDEPUBHighlightStyle // .highlight, .underline
let color: String
let note: String?
}
```
### 6.2 选区处理流程
```
用户长按 → WKWebView 选区变化
JS Bridge: ssReaderSelectionChanged
RDEPUBReaderAnnotationCoordinator
├─ 解析选区位置 (RDEPUBLocation)
├─ 提取选中文本
├─ 创建 RDEPUBSelection
└─ 显示操作菜单(拷贝/高亮/批注)
```
### 6.3 高亮渲染
文本模式下,高亮通过 NSAttributedString 属性注入:
```swift
//
attributedString.addAttribute(
kRDEPUBHighlightAttributeName, // "com.rdreader.highlight"
value: highlight,
range: nsRange
)
// 线
attributedString.addAttribute(
kRDEPUBUnderlineAttributeName, // "com.rdreader.underline"
value: highlight,
range: nsRange
)
```
CoreText 渲染时识别这些自定义属性并绘制高亮背景/下划线。
---
## 7. 搜索系统
### 7.1 全文搜索
`RDEPUBTextSearchEngine.search(keyword:)`
1. 遍历所有 linear spine 条目(html/xhtml 类型)
2. 读取 HTML,转为纯文本:
- 优先:`NSAttributedString(data:options:documentAttributes:)` with `.documentType: .html`
- 回退:正则去除 HTML 标签
3. 执行大小写不敏感的 `NSString.range(of:options:)` 搜索
4. 为每个匹配生成:
- `progression`0.0-1.0 的阅读进度
- `previewText`:匹配位置前后各 12 字符
- `rangeAnchor`:精确的文本锚点
### 7.2 搜索结果导航
搜索结果通过 `RDEPUBSearchState` 管理:
```swift
struct RDEPUBSearchState {
let keyword: String
let matches: [RDEPUBSearchMatch]
var currentMatchIndex: Int?
}
```
WebView 通过 JS Bridge 接收搜索高亮数据,在 DOM 中绘制高亮矩形。
---
## 8. 设置与主题
### 8.1 配置变更流程
```
用户修改设置
RDEPUBReaderConfiguration 更新
├─ 字号/字体/行距变更 → 触发重新分页
│ └─ renderSignature 变化 → 磁盘缓存失效 → 后台重新解析
├─ 主题变更 → 刷新可见内容
│ └─ CSS 层重建(dark/user 层)
├─ 显示模式变更 → 切换翻页容器
│ └─ switchReaderDisplayType()
└─ 列数变更 → 触发重新分页
└─ layoutConfig.cacheSignature 变化
```
### 8.2 主题系统
6 种内置主题,通过 `RDEPUBReaderThemeSelector` 选择:
| 主题 | 背景色 | 文字色 |
|------|--------|--------|
| 默认白 | #FFFFFF | #333333 |
| 护眼绿 | #CCE8CF | #333333 |
| 牛皮纸 | #E8D8B8 | #333333 |
| 深灰 | #333333 | #CCCCCC |
| 纯黑 | #000000 | #B4B4B6 |
| 暗蓝 | #1A2332 | #8A9BB5 |
暗色主题(深灰/纯黑/暗蓝)注入 `wxread-dark.css` 覆盖层。
### 8.3 持久化策略
设置通过 `RDEPUBReaderPersistence` 协议持久化:
```swift
protocol RDEPUBReaderPersistence {
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
}
```
默认实现使用 `UserDefaults`,键格式:
- 位置:`ssreader.epub.location.{bookID}`
- 书签:`ssreader.epub.bookmarks.{bookID}`
- 高亮:`ssreader.epub.highlights.{bookID}`
- 设置:`ssreader.epub.settings`
---
## 9. 纯文本 (.txt) 支持
`RDPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件:
1. **解码:** 依次尝试 UTF-8 → GB18030 → GBK
2. **分章:** 正则匹配 `^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$`
3. **包装 HTML** 每行包裹 `<p>` 标签
4. **渲染+分页:** 复用 `RDEPUBTextRenderer``RDEPUBCoreTextPageFrameFactory`
---
## 10. 性能关键路径
### 10.1 首次打开(冷启动)
| 阶段 | 耗时占比 | 优化方向 |
|------|----------|----------|
| ZIP 解压 | ~5% | 缓存解压目录 |
| OPF 解析 | ~1% | SAX 流式解析 |
| 首章渲染 | ~15% | 快速打开路径 |
| 后台全量解析 | ~70% | 并发、缓存、预计算 |
| 磁盘 I/O | ~9% | 异步写入、批量读取 |
### 10.2 二次打开(热缓存)
| 阶段 | 耗时占比 | 说明 |
|------|----------|------|
| 磁盘摘要恢复 | ~30% | readAll 批量读取 |
| BookPageMap 构建 | ~5% | Builder 模式 |
| UI 应用 | ~65% | 刷新 collection view |
### 10.3 关键优化项
| 优化 | 收益 | 文件 |
|------|------|------|
| 预计算 contentHash | 消除每章重复 HTML 读取 + SHA-256 | RDEPUBReaderPaginationCoordinator |
| 冻结 renderSignature | 避免参数漂移导致缓存不一致 | RDEPUBReaderContext |
| 锁区瘦身 | 降低并发锁竞争 | RDEPUBReaderPaginationCoordinator |
| 可配置并发数 | 适配不同设备 | RDEPUBReaderConfiguration |
| 可配置刷新间隔 | 平衡 UI 响应和吞吐 | RDEPUBReaderPaginationCoordinator |
-295
View File
@@ -1,295 +0,0 @@
# ReadViewSDK 代码规范
## 适用范围
本文档适用于 `ReadViewSDK` 新增代码与重构代码。
- 规范覆盖 `Sources/``RDReaderDemo/` 中的 Swift 代码。
- 命名、分层与职责边界以 SDK 可维护性和可扩展性为优先。
- 本文档分为两类内容:
- `已观察到的约定`:当前工程常见写法。
- `建议统一的规范`:后续统一执行的规则。
## 新架构目录与职责
### 已观察到的约定
- `Sources/RDReaderView`:阅读核心容器、翻页能力与基础视图。
- `EPUBCore`:EPUB 解析、资源定位、导航状态、分页与会话协调。
- `EPUBTextRendering`:文本渲染引擎与分页支持。
- `EPUBUI`:可开箱即用的 Reader UI 层。
- `RDReaderDemo`:示例应用与调试入口。
### 建议统一的规范
- 新增业务能力优先归入 `Sources/RDReaderView` 下的对应模块目录。
- `Core` 层只承载解析、会话、状态机与通用能力,不写页面级交互。
- `UI` 层只承载展示、事件分发和轻量状态同步,不直接处理底层解析逻辑。
- 若模块持续膨胀,优先在当前模块下继续拆分子文件,不跨目录散落实现。
## 命名规范
### 已观察到的约定
- 当前工程历史命名以 `SS``RDEPUB` 开头。
- 控制器常用 `...Controller`,视图常用 `...View`,会话对象常用 `...Session`
- 扩展文件采用 `类型名+功能域.swift`,如 `Parser+Archive.swift`
- 事件方法常使用 `Action` 结尾,例如 `pageTapAction()``themeChangeAction()`
- 绑定数据的方法常使用 `bind``update``refresh``configure` 等动词。
### 建议统一的规范
- **所有新增类型必须以 `RD` 开头。**
- EPUB 相关类型统一以 `RDEPUB` 开头。
- 方法名、变量名沿用 Swift 小驼峰,不增加额外前缀。
- 类型名应反映职责,不使用过宽泛的后缀;只有真正承担协调逻辑时才使用 `Manager``Handler`
- 事件处理方法统一使用"对象/意图 + Action"命名,例如 `pageTapAction``themeChangeAction`
- 数据绑定方法优先使用以下语义:
- `bind...`:将模型绑定到视图或模块。
- `update...`:增量刷新已有界面或状态。
- `configure...`:一次性配置样式或依赖。
- `refresh...`:重新拉取或重建数据状态。
- 避免新增拼写不一致的方法名;若发现历史命名拼写错误,新增代码必须使用正确拼写,旧接口修复时应配合调用点一起调整。
命名示例:
- `RDReaderView`
- `RDEPUBParser`
- `RDEPUBPublication`
- `RDEPUBReadingSession`
- `RDEPUBReaderController`
- `RDEPUBReaderTheme`
扩展文件命名示例:
```text
RDEPUBParser.swift
RDEPUBParser+Archive.swift
RDEPUBParser+Package.swift
RDEPUBParser+TOC.swift
RDEPUBParser+Resources.swift
RDEPUBWebView.swift
RDEPUBWebView+Configuration.swift
RDEPUBWebView+Reflowable.swift
```
## 分层与职责边界
### 已观察到的约定
- 阅读入口负责容器装配、翻页模式切换和事件分发。
- EPUB 核心层负责解析、导航、分页、资源读取与定位。
- 渲染层负责 HTML/富文本渲染与分页支持。
- UI 层负责主题、工具栏、目录、设置等交互能力。
### 建议统一的规范
- `RD...Controller`:负责页面级编排与流程调度,不承载复杂渲染细节。
- `RD...View`:负责展示与局部交互,不承载完整业务流程。
- `RDEPUB...Core`:负责解析、会话状态与数据模型,不依赖具体页面。
- 配置、主题、定位、进度模型统一下沉为 `struct`
- 跨层通信优先通过会话层或协议,不做跨层直接写状态。
## UI 与布局规范
### 已观察到的约定
- 视图多采用 `lazy var` 初始化,并在闭包内完成默认配置。
- 自定义 View 通常在 `init(frame:)` 或业务绑定后调用 `initView()` 完成视图树搭建。
- 复杂页面使用分区 extension 组织代理与事件实现。
- 页面或组件内部按职责拆分样式方法,例如 `topBarStyle()``contentStyle()`
- 页面经常通过回调闭包把交互抛给外层,例如 `pageChangeCallback``selectionCallback`
- 模块内存在多处布局方式混用情况。
### 建议统一的规范
- SDK 层新增 UI 使用 Auto Layout 原生约束,Demo 层可使用 SnapKit;单文件内不混用多套布局体系。
- 视图层初始化顺序保持一致:
- 定义属性与子视图
-`initView()` 中组装视图树
- 在独立方法中拆分样式和状态刷新逻辑
- 当约束会被多次切换时:
- 首次创建使用 `makeConstraints`SnapKit)或 `NSLayoutConstraint`
- 重建结构使用 `remakeConstraints`SnapKit)或先移除再添加
- 仅修改常量时使用 `updateConstraints`SnapKit)或修改 `constant` 属性
- 布局分支明显时,优先拆成语义化私有方法,不要把所有状态分支堆在一个超长方法里。
- 对外暴露的 UI 刷新入口建议以 `bind``update` 开头,避免把布局细节暴露给调用方。
- 交互事件通过闭包或 delegate 抛出,避免子视图持有上层业务依赖。
## 交互与状态处理规范
### 已观察到的约定
- 事件处理使用 `@objc` + selector。
- 异步回调中广泛使用 `[weak self]`
- 状态判断常通过 `guard` 提前返回。
### 建议统一的规范
- 按钮、通知、系统回调放入独立 extension 分组。
- 异步闭包默认先使用 `[weak self]`,仅在必要时改强引用。
- 多前置条件入口统一先 `guard` 校验,减少嵌套。
- 导航与进度恢复统一通过 `RDEPUBReadingSession` 协调。
## 代码风格细则
### 已观察到的约定
- extension 分区较常见。
- 解析与渲染模型多数采用 `struct`
- 存在少量历史命名不统一与可选值处理不一致情况。
### 建议统一的规范
- 默认遵循”最小可见性”:`private` > `fileprivate` > `internal` > `public`
- 纯数据模型使用 `struct` + `Codable` + `Equatable`
- 服务对象使用 `final class`,避免无意义继承。
- 错误类型统一使用 `enum + LocalizedError`
- 新增代码避免强制解包;若当前上下文无法避免,至少先在上层收敛边界。
- 统一优先使用 `guard` 做前置失败处理,减少深层嵌套。
- extension 的拆分原则以”单一职责”优先:
- 事件处理一组
- 代理 / DataSource 实现一组
- 工具方法一组
- 通知适配一组
- 调试代码继续使用 `#if DEBUG` 包裹,不把调试边框、日志、测试分支直接带入正式逻辑。
## 文件组织规范
### 已观察到的约定
- 目录按阅读容器、EPUB Core、渲染、UI 分层组织。
- 大类通过 `+Extension` 文件拆分职责。
### 建议统一的规范
- 目录保持以下分层,不跨层放置实现:
- `Sources/RDReaderView/EPUBCore`
- `Sources/RDReaderView/EPUBTextRendering`
- `Sources/RDReaderView/EPUBUI`
- 单文件建议不超过 `600` 行;超出后按职责拆分 extension 文件。
- extension 文件命名统一 `RD类型名+功能域.swift`
## 注释规范
- 注释、错误提示、日志统一使用中文。
- 关键流程方法保留”为什么这样做”的注释,不写重复代码字面行为的注释。
- 调试输出统一放在 `#if DEBUG` 下。
- 新代码保留有信息量的注释,避免重复描述显而易见的代码行为。
## 已观察到的项目模式
### 模式 1:统一入口 + 扩展拆分
- `RDEPUBParser` 负责 EPUB 解析主入口,不同能力拆到 `+Archive``+Package``+TOC``+Resources` 等扩展文件。
- `RDEPUBReadingSession` 负责阅读会话主入口,状态管理、分页、定位等能力拆到扩展文件。
- 该模式适合继续用于解析器、会话管理、控制器工具方法等横向能力。
### 模式 2:页面编排在 Controller,局部交互下沉到 View
- `RDEPUBReaderController` 负责阅读页整体编排:翻页容器装配、工具栏切换、阅读位置恢复。
- `RDReaderView` 负责分页容器布局与翻页交互,并通过 DataSource / Delegate 把数据需求交回外层。
- 该模式保持 Controller 管流程、View 管展示的职责分离。
### 模式 3:列表与容器逻辑通过扩展拆开
- 复杂容器将 `UICollectionViewDataSource``UICollectionViewDelegate``UICollectionViewDelegateFlowLayout` 分别拆分到 extension。
- 该模式降低单文件中主逻辑与代理逻辑的耦合,适合继续用于任何包含列表或容器的组件。
### 模式 4:渲染路径抽象
- `RDEPUBReadingProfile` 根据 EPUB 特征自动选择渲染路径(`webFixedLayout` / `webInteractive` / `textReflowable`)。
- 新增渲染相关功能时,必须评估对三种路径的覆盖情况。
## 待统一项
- 当前访问控制级别存在混用:同一类里 `public`、默认 `internal``private` 并存,建议后续新增代码默认从最小可见范围开始声明。
- 当前存在少量强制解包,建议新增代码优先通过前置校验收敛风险。
- 当前存在拼写不一致问题,建议后续新增代码统一使用标准英文单词,旧接口如需修复应配合调用点一起调整。
- 当前部分注释偏”过程说明”或遗留调试注释,建议新代码保留有信息量的注释。
- 当前个别 View 在数据绑定阶段再次调用 `initView()` 重建界面,这种方式在复杂组件中容易引入重复添加子视图或状态不一致。建议新增组件优先区分”初始化视图结构”和”刷新数据状态”两个阶段。
## 禁忌事项
| 禁忌 | 替代做法 |
|------|----------|
| 新增类型不加 `RD` 前缀 | 所有新增类型统一 `RD` / `RDEPUB` 前缀 |
| UI 层直接拼装解析状态 | 通过 `RDEPUBReadingSession` 获取状态 |
| 控制器直接操作底层解析细节 | 通过 `RDEPUBPublication``RDEPUBParser` 暴露接口 |
| 强制解包可选值 | `guard let` / `if let` |
| 用页号单独恢复阅读进度 | 统一使用 `href + progression` |
## 使用建议
- 新增功能前先确定目录归属和职责边界。
- 命名先定前缀再落代码:类型一律 `RD` 开头。
- 若需迁移历史 `SS` 前缀,按模块渐进替换,优先替换新增与重构触达文件。
## 旧 SS 命名迁移到 RD 的执行状态
**迁移已完成**:源码中已无 `SS` 前缀类型定义,全部使用 `RD`/`RDEPUB` 前缀。新增代码必须继续遵守此规则。
### 阶段 0:冻结新增 SS 命名(已完成)
- 目标:从当前时点开始,不再引入新的 `SS`/`RDEPUB` 类型名。
- 动作:
- 新增类型统一使用 `RD`/`RDEPUB` 前缀。
- Code Review 增加命名检查项:发现新增 `SS` 命名必须驳回。
- 在 PR 模板中加入”本次是否新增旧前缀命名”勾选项。
- 验收:
- 新提交代码中,新增类型 `SS` 前缀数量为 `0`
### 阶段 1:建立迁移映射表(第 1 周)
- 目标:明确“旧名 -> 新名”一一映射,避免多人并行改名冲突。
- 动作:
- 统计核心公开类型、内部核心类型、测试类型三类清单。
- 建立命名映射表,例如:
- `RDReaderView` -> `RDReaderView`
- `RDEPUBParser` -> `RDEPUBParser`
- `RDEPUBReadingSession` -> `RDEPUBReadingSession`
- 对外 API 单独标记“需兼容过渡”的类型。
- 验收:
- 映射表覆盖全部高频核心类型,且团队评审通过。
### 阶段 2:先迁移内部类型(第 2-3 周)
- 目标:优先改内部实现,降低外部兼容压力。
- 动作:
- 按模块分批迁移:`EPUBCore` -> `EPUBTextRendering` -> `EPUBUI`
- 每批次只改一个子模块,避免超大 PR。
- 同步修复调用点、扩展文件名与注释中的旧命名。
- 验收:
- 目标模块内类型命名全部满足 `RD` 规则。
- 编译通过,Demo 阅读主流程可用。
### 阶段 3:迁移公开 API 并保留兼容层(第 3-4 周)
- 目标:完成对外接口改名,同时给接入方提供平滑升级窗口。
- 动作:
- 对外公开类型切换为 `RD` 命名。
- 旧公开类型保留兼容别名,并标注废弃说明(`deprecated`)。
- 在 Release Note 提供“旧名/新名对照表”和迁移示例。
- 验收:
- 新接入示例仅使用 `RD` 命名。
- 旧接入代码在兼容期内无需立即改动即可编译。
### 阶段 4:清理兼容层与收口(下一主版本)
- 目标:在约定主版本移除旧前缀,完成命名收口。
- 动作:
- 删除 `SS` 兼容别名与过渡代码。
- 清理文档、注释、示例工程中的旧前缀残留。
- 对外发布最终迁移公告与升级说明。
- 验收:
- 工程内无 `SS`/`RDEPUB` 类型定义残留。
- 文档与示例代码全部为 `RD`/`RDEPUB` 命名。
### 迁移过程约束
- 每次迁移 PR 必须包含:
- 命名改动清单
- 影响范围说明
- 回归验证结果(编译、Demo 主流程、关键阅读路径)
- 禁止在同一 PR 中同时做“大规模命名迁移 + 业务逻辑重构”。
- 若改名会影响外部接入,必须先补迁移文档再合并代码。
-402
View File
@@ -1,402 +0,0 @@
# EPUBCore 功能实现逻辑
## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBCore/`31 个 Swift 文件 + 2 个资源文件)
- 目标:说明 EPUBCore 如何完成 EPUB 解析、资源定位、阅读会话管理、离屏分页、JS 桥接渲染和全文搜索。
- 主链路关键词:`epubURL -> RDEPUBParser.parse -> container.xml -> OPF -> spine/TOC -> RDEPUBPublication -> RDEPUBReadingSession -> RDEPUBWebView/Paginator -> 分页/渲染`
## 2. 关键对象职责
### 2.1 解析器 `RDEPUBParser`
- 文件:`EPUBCore/RDEPUBParser.swift` + 5 个扩展文件
- 入口方法:`parse(epubURL:)`
- 职责:
- ZIP 解压到缓存目录(`RDEPUBParser+Archive.swift`
- 解析 `META-INF/container.xml` 定位 OPF 根文件
- 解析 OPF 文档提取 metadata / manifest / spine`RDEPUBParser+Package.swift`
- 解析目录支持 NCXEPUB 2)和 Nav DocumentEPUB 3)(`RDEPUBParser+TOC.swift`
- 判断阅读配置文件类型(`RDEPUBParser+ReadingProfile.swift`
- 资源路径转换:相对路径 ↔ 文件 URL ↔ `ss-reader://` 自定义 scheme URL`RDEPUBParser+Resources.swift`
### 2.2 Publication 门面 `RDEPUBPublication`
- 文件:`EPUBCore/RDEPUBPublication.swift`
- 职责:
- 包装 `RDEPUBParser` + `RDEPUBResourceResolver`,对外暴露只读接口
- 提供 metadata、manifest、spine、TOC、layout、readingProfile、readingProgression
- 计算 fixed layout 的 spread 组合:`makeFixedSpreads(preferences:viewportSize:)`
### 2.3 阅读会话 `RDEPUBReadingSession`
- 文件:`EPUBCore/RDEPUBReadingSession.swift` + `RDEPUBNavigatorState.swift`
- 职责:
- 管理导航状态机:`initializing -> loading -> idle <-> jumping/moving/repaginating`
- 维护活跃/暂存分页快照(`activePages` / `activeChapters` / `stagedPages` / `stagedChapters`
- 管理待定导航目标(`pendingNavigationLocation` / `pendingNavigationPageNum`
- 更新阅读上下文(`currentReadingContext`location + viewport + pageNumber + chapterIndex
- 构建分页快照:`makePaginationSnapshot(pageCounts:preferences:layoutContext:)`
### 2.4 WebView 渲染层 `RDEPUBWebView`
- 文件:`EPUBCore/RDEPUBWebView.swift` + 5 个扩展文件(+Configuration / +Reflowable / +FixedLayout / +JavaScriptBridge / +Search
- 职责:
- 内部持有 WKWebView,配置自定义 scheme handler 和 JS 桥接
- 可重排内容加载:`loadPage(parser:spineIndex:pageIndex:...)``+Reflowable.swift`
- 固定版式内容加载:`loadFixedSpread(parser:spread:...)``+FixedLayout.swift`
- 处理 JS 桥接消息和链接导航拦截(`+JavaScriptBridge.swift`
- 搜索高亮装饰(`+Search.swift`
- 定义 `RDEPUBWebViewDelegate` 协议(6 个回调方法)
### 2.5 离屏分页器 `RDEPUBPaginator`
- 文件:`EPUBCore/RDEPUBPaginator.swift`
- 职责:
- 创建隐藏的 WKWebView 加载每个 spine 项的 HTML
- 注入分页 CSS,运行 JS 测量 `scrollWidth`
- 计算 `ceil(scrollWidth / viewportWidth)` 作为页数
- 多次测量取最大值(延迟 0ms / 80ms / 180ms)确保布局稳定
### 2.6 资源解析 `RDEPUBResourceResolver` + `RDEPUBResourceURLSchemeHandler`
- 文件:`EPUBCore/RDEPUBResourceResolver.swift``EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
- 职责:
- `RDEPUBResourceResolver`:对外门面,提供 href 标准化、spine 索引查找、Location 构建
- `RDEPUBResourceURLSchemeHandler`:实现 `WKURLSchemeHandler`,将 `ss-reader://book/<path>` 映射到解压后的文件系统路径,缺失的可选资源(字体/图片/CSS/JS)返回空 200
### 2.7 JS 桥接
- 文件:`EPUBCore/RDEPUBJavaScriptBridge.swift``Resources/epub-bridge.js`
- 6 种消息类型(JS → Swift):
- `ssReaderProgressionChanged`:阅读进度变化
- `ssReaderSelectionChanged`:文本选择变化
- `ssReaderInternalLink`:内部链接点击
- `ssReaderExternalLink`:外部链接点击
- `ssReaderJSError`JS 错误
- `ssReaderFixedLayoutReady`:固定版式渲染完成
- JS 端 `window.RDReaderBridge` 暴露:`applyPagination``setPageMetrics``scrollToPage``scrollToLocation``setHighlights``clearHighlights``reportProgression``selectionPayload`
### 2.8 搜索引擎 `RDEPUBSearchEngine` / `RDEPUBHTMLSearchEngine`
- 文件:`EPUBCore/RDEPUBSearchEngine.swift``EPUBCore/RDEPUBSearchModels.swift`
- 职责:
- `RDEPUBSearchEngine`:搜索协议定义
- `RDEPUBHTMLSearchEngine`WebView 路径实现,将 HTML 转为纯文本(NSAttributedString 或正则兜底),执行大小写不敏感的全文搜索
- 搜索模型:`RDEPUBSearchMatch``RDEPUBSearchResult``RDEPUBSearchState``RDEPUBSearchPresentation`
### 2.9 配置与样式
- `RDEPUBPreferences``EPUBCore/RDEPUBPreferences.swift`):用户阅读偏好(字号、行高、内容边距、主题色、固定版式适配模式),构建 `RDEPUBPresentationStyle``RDEPUBRenderRequest`
- `RDEPUBNavigatorLayoutContext``EPUBCore/RDEPUBNavigatorLayoutContext.swift`):容器布局上下文(容器尺寸、每屏页数、安全区域、设备类型)
- `RDEPUBStyleSheetBuilder``EPUBCore/RDEPUBStyleSheetBuilder.swift`):生成分页 CSS、渲染 CSS 和测量 JS 脚本
- `RDEPUBFixedLayoutTemplate``EPUBCore/RDEPUBFixedLayoutTemplate.swift`):生成固定版式 HTML 模板
### 2.10 文本锚点 `RDEPUBTextAnchor` / `RDEPUBTextRangeAnchor`
- 文件:`EPUBCore/RDEPUBTextAnchor.swift`
- 职责:
- `RDEPUBTextAnchor`:精确定位到 EPUB 中的某个字符位置,包含 fileIndex(spine 索引)、row(行号)、column(列号)、chapterOffset(章节内字符偏移)、fragmentID(最近的 fragment
- `RDEPUBTextRangeAnchor`:由起止锚点组成的文本区间,可转换为 NSRange
- 支持 Codable 序列化,兼容 `fileIndex``spineIndex` 两种 key
- 用于文本 EPUB 的高亮精确锚定和跨会话恢复
### 2.11 渲染请求模型 `RDEPUBRenderRequest`
- 文件:`EPUBCore/RDEPUBRenderRequest.swift`
- 职责:
- `RDEPUBFixedLayoutFit`:固定版式适配模式枚举(`.auto` / `.page` / `.width`
- `RDEPUBFixedLayoutSpreadMode`Spread 显示模式枚举(`.automatic` / `.always` / `.never`
- `RDEPUBPresentationStyle`WebView 和 Paginator 共用的视觉参数(viewportSize、contentInsets、fontSize、lineHeightMultiple、主题色)
- `RDEPUBReflowableRenderRequest`:可重排内容渲染请求(spineIndex、href、pageIndex、presentation、highlights、searchPresentation
- `RDEPUBFixedRenderRequest`:固定版式渲染请求(spread、viewportSize、fit、searchPresentation
- `RDEPUBRenderRequest`:统一枚举(`.reflowable` / `.fixed`),WebView 根据此类型选择渲染路径
### 2.12 调试工具 `RDEPUBWebViewDebug`
- 文件:`EPUBCore/RDEPUBWebViewDebug.swift`
- 职责:
- WebView 调试日志工具集,DEBUG 模式默认开启
- 支持导航事件、JS 执行、消息接收、URL Scheme 任务的日志记录
- 可通过 UserDefaults `"RDEPUBWebViewDebugEnabled"` 覆盖开关
### 2.13 资源加载器 `RDEPUBAssetRepository`
- 文件:`EPUBCore/RDEPUBAssetRepository.swift`
- 职责:
- 从资源包加载 JS 桥接脚本(`epub-bridge.js`)和固定版式 HTML 模板(`epub-fixed-layout.html`
- 支持 `{{token}}` 模板变量替换
- 自动定位 `RDReaderViewAssets.bundle`(优先已解析子 bundle,兜底宿主 bundle
## 3. 主流程(代码级)
### 3.1 EPUB 解析全流程
1. 入口:`RDEPUBParser.parse(epubURL:)`
2. 调用 `extractArchiveIfNeeded(epubURL:)`
- 缓存路径:`~/Library/Caches/ssreaderview-epub/<slug>-<fileSize>-<timestamp>/`
- 若目录已存在则跳过解压
- 否则用 ZIPFoundation 解压 ZIP 到缓存目录
3. 解析 `META-INF/container.xml`
- `ContainerXMLParserDelegate`SAX)找到第一个 `<rootfile>``full-path` 属性
4. 解析 OPF 文档:
- `OPFPackageParserDelegate`SAX)分段解析 metadata / manifest / spine
- 提取 `<package>` 版本和 unique-identifier
- 提取 `<dc:identifier>` / `<dc:title>` / `<dc:creator>` / `<dc:language>`
- 提取 `<meta property="rendition:layout">` 判断 fixed/reflowable
- 提取 manifest 每个 `<item>``RDEPUBManifestItem`
- 提取 spine 每个 `<itemref>``OPFSpineReference`
5. 构建 spine`buildSpine(from:opfURL:)`
- 将 spine reference 匹配到 manifest item
- 标准化 href(相对于 OPF 目录)
- 确定每项的有效 layout(显式属性覆盖出版级设置)
- 返回 `[RDEPUBSpineItem]`,空 spine 抛 `emptySpine` 错误
6. 解析目录:`parseTOC(from:opfURL:)`
- 优先 NCX`NCXParserDelegate`),其次 Nav Document`NavDocumentParserDelegate`
- 兜底使用 spine 标题
- href 标准化保留 fragment 标识符
7. 创建 Publication`makePublication()``RDEPUBPublication(parser: self)`
### 3.2 阅读配置文件判断
- 入口:`RDEPUBParser+ReadingProfile.swift`
- 判断逻辑:
- 扫描 manifest 检查是否有 `scripted` 属性的项
- 扫描 HTML body 检查是否有 `script``iframe``video` 等交互元素
- 结果:
- `.webFixedLayout`:固定版式 EPUB(漫画、绘本)— WKWebView 渲染
- `.webInteractive`:可重排 + 交互脚本 EPUB — WKWebView 渲染
- `.textReflowable`:纯文本可重排 EPUB — DTCoreText 渲染
### 3.3 离屏分页测量
1. 入口:`RDEPUBPaginator.calculate(parser:hostingView:presentation:completion:)`
2. 初始化所有 spine 项页数为 `[1, 1, ..., 1]`
3. 固定版式直接返回(每 spread = 1 页)
4. 顺序遍历可渲染的 HTML/XHTML spine 项
5. 对每项:
- `webView.loadFileURL(...)` 加载文件
- `didFinish` 后启动多次测量(`scheduleMeasurementPass`
- 3 次测量延迟 [0ms, 80ms, 180ms]
- 每次执行 `measurementScript`:注入分页 CSS → 测量 `scrollWidth` → 返回 `Math.max(1, Math.ceil(totalWidth / viewportWidth))`
- 取多次测量的最大值
6. 全部完成后回调 `completion([Int])` 页数数组
7. `activeSessionID`UUID)防止过期回调污染结果
### 3.4 WebView 可重排内容加载
1. 入口:`RDEPUBWebView.loadPage(parser:spineIndex:pageIndex:...)`
2. 计算 `loadSignature`(包含 publication key、spine index、href、viewport、字号、行高、主题色、目标位置、高亮等)
3. 若签名与 `currentLoadSignature` 相同且已渲染/正在加载,跳过重复请求
4. 若签名匹配但文档 URL 已加载,仅调用 `applyPresentation()` 重新应用样式
5. 否则 `handleReflowableLoad(...)` 执行完整加载:
- 读取 HTML 内容
- 调用 `applyPresentationScript(for:)` 生成 JS
- JS 执行:`RDReaderBridge.applyPagination(css)``setPageMetrics` → 清除并重新应用高亮 → 滚动到目标位置 → 报告进度 → 应用搜索装饰
### 3.5 固定版式内容加载
1. 入口:`RDEPUBWebView.loadFixedSpread(parser:spread:...)`
2. `RDEPUBFixedLayoutTemplate.html(for:publication:)` 从模板生成 HTML,包含 spread 中每个资源的 `<iframe>`
3. `webView.loadHTMLString(html, baseURL: "ss-reader://book/")` 加载
4. 模板 JS 加载每个 iframe,检测页面尺寸,计算缩放(auto/page/width 适配模式),定位 iframe
5. 所有 iframe 加载完成后 JS 发送 `ssReaderFixedLayoutReady`
6. Swift 收到消息后应用搜索装饰并调用 `rendered()`
7. 1 秒兜底定时器确保即使 JS 消息丢失也能调用 `rendered()`
### 3.6 JS 桥接消息处理
- 入口:`RDEPUBWebView+JavaScriptBridge.swift``userContentController(_:didReceive:)`
- 分发逻辑:
- `progressionChanged` → 提取 progression/lastProgression/fragment → `delegate?.epubWebView(_:didUpdateLocation:spineIndex:)`
- `selectionChanged` → 提取 text/rangeInfo → 创建 `RDEPUBSelection``delegate?.epubWebView(_:didChangeSelection:spineIndex:)`
- `internalLink` → 创建 `RDEPUBLocation``delegate?.epubWebView(_:didActivateInternalLink:fromSpineIndex:)`
- `externalLink` → 提取 URL → `delegate?.epubWebView(_:didActivateExternalLink:)`
- `javaScriptError``delegate?.epubWebView(_:didLogJavaScriptError:)`
- `fixedLayoutReady` → 应用搜索装饰 → `rendered()`
### 3.7 链接导航拦截
- 入口:`decidePolicyFor navigationAction`
- 逻辑:
- `ss-reader://` URL + `.linkActivated` → 内部链接代理回调,取消导航
- `http/https/mailto/tel` → 外部链接代理回调,取消导航
- 其他 → 允许导航
## 4. 异常与边界处理
- `RDEPUBParserError`7 种,全部 `LocalizedError`,中文描述):
- `archiveOpenFailed(URL)`ZIPFoundation 无法打开 EPUB 文件
- `missingContainerXML``META-INF/container.xml` 不存在
- `missingRootFile`container.xml 中无 `<rootfile>`
- `invalidRootFilePath(String)`rootfile 路径对应的 OPF 文件不存在
- `invalidXML(URL)`:XMLParser 无法创建或解析失败
- `missingManifestItem(idref:)`spine 引用了不存在的 manifest 项
- `emptySpine`:构建后 spine 为空
- 目录解析:NCX 和 Nav Document 解析器失败时返回空数组,不抛异常,兜底使用 spine 标题
- 资源缺失:`RDEPUBResourceURLSchemeHandler` 对缺失的可选资源(字体/图片/CSS/JS)返回空 200,避免 WKWebView 控制台噪音
- 分页失败:`RDEPUBPaginator` 导航失败(`didFail` / `didFailProvisional`)时将页数设为 1 并继续下一项
- HTML 转纯文本兜底:`RDEPUBHTMLSearchEngine` 在 NSAttributedString 失败时回退到正则去标签
- 固定版式 1 秒兜底:`scheduleFixedLayoutReadyFallback()` 确保即使 JS ready 消息丢失也能调用 `rendered()`
- 重复请求跳过:`handleReflowableLoad` 在 loadSignature 匹配且已渲染/正在加载时跳过
## 5. 数据结构与字段映射
### 5.1 EPUB 元数据
```
RDEPUBParser
├── metadata: RDEPUBMetadata
│ ├── identifier, title, author, language, version
│ ├── layout: RDEPUBLayout (.reflowable | .fixed)
│ ├── readingProgression: RDEPUBReadingProgression (.ltr | .rtl | .auto)
│ └── spread: String?
├── manifest: [String: RDEPUBManifestItem]
│ ├── id, href, mediaType
│ ├── properties: [String] (nav, scripted, cover-image, rendition:layout-*)
│ └── title: String?
├── spine: [RDEPUBSpineItem]
│ ├── idref -> manifest[id]
│ ├── href (normalized), mediaType, title, linear
│ ├── pageSpread: RDEPUBPageSpread? (.left | .right | .center)
│ └── layout: RDEPUBLayout? (per-item override)
└── tableOfContents: [EPUBTableOfContentsItem]
├── title, href
└── children: [EPUBTableOfContentsItem] (recursive)
```
### 5.2 阅读位置模型
```
RDEPUBLocation
├── bookIdentifier, href
├── progression: Double? (0..1,章节内起始位置)
├── lastProgression: Double? (0..1,章节内结束位置)
├── fragment: String? (#anchor)
├── rangeAnchor: RDEPUBTextRangeAnchor? (文本范围锚点,用于高亮精确定位)
└── navigationProgression (computed: midpoint of progression and lastProgression)
```
### 5.3 视口模型
```
RDEPUBViewport
├── resources: [RDEPUBViewportResource]
│ ├── href, spineIndex
│ ├── progression, lastProgression, fragment
├── visiblePageNumber, chapterIndex
└── isFixedLayout: Bool
```
### 5.4 渲染请求
```
RDEPUBRenderRequest (enum)
├── .reflowable(RDEPUBReflowableRenderRequest)
│ ├── spineIndex, href, pageIndex, totalPagesInChapter
│ ├── presentation: RDEPUBPresentationStyle
│ │ ├── viewportSize, contentInsets
│ │ ├── fontSize, lineHeightMultiple
│ │ └── themeBackgroundColor, themeTextColor
│ ├── targetLocation: RDEPUBLocation?
│ ├── highlights: [RDEPUBHighlight]
│ └── searchPresentation: RDEPUBSearchPresentation?
└── .fixed(RDEPUBFixedRenderRequest)
├── spread: EPUBFixedSpread
│ └── resources: [EPUBFixedSpreadResource]
├── viewportSize, contentInset
├── backgroundColorCSS
├── fit: RDEPUBFixedLayoutFit (.auto | .page | .width)
└── searchPresentation: RDEPUBSearchPresentation?
```
### 5.5 高亮与选择
```
RDEPUBHighlight
├── id, bookIdentifier
├── location: RDEPUBLocation
├── text, rangeInfo (JSON-serialized DOM range 或 text-offset)
├── style: RDEPUBHighlightStyle (.highlight | .underline)
├── color: String, note: String
└── createdAt: Date
RDEPUBSelection
├── bookIdentifier
├── location: RDEPUBLocation
├── text, rangeInfo
└── createdAt: Date
RDEPUBBookmark
├── id, bookIdentifier
├── location: RDEPUBLocation
├── title: String?
├── note: String?
└── createdAt: Date
```
### 5.6 搜索模型
```
RDEPUBSearchState
├── keyword: String
├── matches: [RDEPUBSearchMatch]
│ ├── href, progression
│ ├── previewText, localMatchIndex
│ ├── rangeLocation, rangeLength
└── currentMatchIndex: Int
```
## 6. 分页与渲染机制
### 6.1 CSS 多栏分页原理
- HTML 内容被包裹为 `body > div#ss-reader-viewport > div#ss-reader-content`
- `#ss-reader-content` 应用 `-webkit-column-width: <viewportWidth>px`
- `html` / `body` 设置 `overflow: hidden`
- 页间导航通过 `transform: translate3d(-N*stride, 0, 0)` 实现
### 6.2 重新布局触发条件
- `scheduleRelayout()`60ms 防抖)在以下事件触发:
- `window.load``window.resize`
- `document.fonts.ready`
- 每个 `<img>` / `<iframe>` / `<video>` 的 load/error 事件
### 6.3 选择变化处理
- `selectionchange` 监听器(120ms 防抖)发送 `ssReaderSelectionChanged` 消息,携带 text、rangeInfo、progression
## 7. 缓存策略
- **EPUB 解压缓存**:解压目录按 `文件大小 + 修改时间` 生成缓存 key,已存在则跳过解压
- **Load Signature 去重**:每次渲染请求计算签名(含 publication key、layout、spine index、viewport、字号、行高、主题色、目标位置、高亮),相同签名跳过重复加载
- **WKWebView 复用**:同一 publication 配置下复用已有 WKWebView 实例,仅切换 publication 时才 teardown
- **非持久化数据存储**`WKWebViewConfiguration().websiteDataStore = .nonPersistent()`,不缓存 Web 内容到磁盘
## 8. 联调与排查建议
- 排查 1EPUB 解析失败
- 检查 `parse(epubURL:)` 抛出的 `RDEPUBParserError` 具体类型
- 检查 EPUB 文件是否损坏(ZIP 能否正常打开)
- 检查 `META-INF/container.xml` 是否存在且格式正确
- 排查 2:目录为空
- 检查 OPF 中是否有 NCX 或 Nav Document 引用
- 检查 NCX 文件路径是否正确(相对于 OPF 目录)
- 目录解析失败时会兜底使用 spine 标题
- 排查 3:页面内容空白
- 检查 `ss-reader://` scheme handler 是否正确映射资源路径
- 检查 `RDEPUBResourceURLSchemeHandler` 日志(`RDEPUBWebViewDebug`
- 确认 HTML 文件在解压目录中实际存在
- 排查 4:分页页数不正确
- 检查 `RDEPUBPaginator` 多次测量结果是否一致
- 检查 viewport 尺寸计算是否正确(容器宽度 / pagesPerScreen
- 检查 CSS 分页样式是否正确注入
- 排查 5JS 桥接消息未收到
- 确认 `epub-bridge.js` 已正确注入(检查 `WKUserContentController`
- 检查 JS 控制台是否有错误
- 固定版式检查是否收到 `ssReaderFixedLayoutReady`,否则看 1 秒兜底定时器
- 排查 6:内部链接不跳转
- 检查链接 href 是否以 `ss-reader://` 开头
- 检查 `decidePolicyFor navigationAction` 中的链接类型判断
- 确认 `RDEPUBLocation` 构建是否正确(href + fragment
-352
View File
@@ -1,352 +0,0 @@
# EPUBTextRendering 功能实现逻辑
## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBTextRendering/`13 个 Swift 文件)
- 目标:说明文本渲染路径如何将 EPUB HTML 转换为 NSAttributedString、按字符范围分页、构建书籍模型,并支持全文搜索与 textReflowable 标注定位。
- 主链路关键词:`RDEPUBParser HTML -> DTCoreText 渲染 -> 片段标记注入/提取 -> CoreText 分页 -> RDEPUBTextBook -> 页面查找/位置转换`
- 适用范围:仅用于 `textReflowable` 阅读配置文件(纯文本可重排 EPUB,如小说)。固定版式和交互式 EPUB 使用 WebView 渲染路径。
## 2. 关键对象职责
### 2.1 渲染协议 `RDEPUBTextRenderer`
- 文件:`EPUBTextRendering/RDEPUBTextRenderer.swift`
- 职责:
- 定义双方法协议:
- `renderChapter(request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent`(主方法,携带完整上下文)
- `renderChapter(html:baseURL:style:) throws -> RDEPUBRenderedChapterContent`(便捷方法,默认实现转发到主方法)
- 作为渲染后端的抽象点,当前仅 `RDEPUBDTCoreTextRenderer` 一个实现
### 2.2 DTCoreText 渲染器 `RDEPUBDTCoreTextRenderer`
- 文件:`EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift`
- 职责:
- 实现 `RDEPUBTextRenderer` 协议
- 使用 `DTHTMLAttributedStringBuilder` 将 HTML 转为 NSAttributedString
- 渲染后处理:提取片段标记、统一字体/行间距/颜色
- DTCoreText 不可用时回退到纯文本渲染
### 2.3 渲染支持工具 `RDEPUBTextRendererSupport`
- 文件:`EPUBTextRendering/RDEPUBTextRendererSupport.swift`
- 职责:
- 片段标记注入:扫描 HTML 中带 `id` 属性的元素,在其前注入 `${id=<fragmentID>}` 不可见标记
- 片段偏移提取:从 NSAttributedString 中提取标记位置并删除标记
- 字体标准化:保留 HTML 中的粗体/斜体 trait,统一使用配置的基础字体族和字号
- 段落样式创建:强制应用配置的行间距和段间距
- 兜底渲染:DTCoreText 不可用时从原始 HTML 字符串生成纯文本 NSAttributedString
### 2.4 分页支持 `NSAttributedString.ss_pageRanges(size:)` / `rd_paginatedFrames(size:)`
- 文件:`EPUBTextRendering/RDEPUBTextPaginationSupport.swift`
- 职责:
- `rd_paginatedFrames(size:) -> [RDEPUBTextLayoutFrame]`:基于 `RDEPUBTextLayouter` 的增强分页,返回带语义元数据的帧对象
- `ss_pageRanges(size:) -> [NSRange]`:便捷方法,内部调用 `rd_paginatedFrames` 后提取 contentRange
- 从位置 0 开始,反复创建 CTFrame 测量可见字符范围
- 返回 `[NSRange]`,每个 range 对应一页
- 安全保护:`visibleRange.length == 0` 时 break 防止死循环
### 2.5 书籍构建器 `RDEPUBTextBookBuilder`
- 文件:`EPUBTextRendering/RDEPUBTextBookBuilder.swift`
- 职责:
- 遍历 publication 的 linear spine 项(仅 HTML/XHTML 类型)
- 对每项:HTML 标准化 → 渲染 → 跳过空白封面/标题页 → 分页 → 构建 `RDEPUBTextPage` 数组
- 汇总所有章节为 `RDEPUBTextBook`
- 后台队列执行,主线程回调结果
### 2.6 全文搜索引擎 `RDEPUBTextSearchEngine`
- 文件:`EPUBTextRendering/RDEPUBTextSearchEngine.swift`
- 职责:
- 实现 `RDEPUBSearchEngine` 协议
- 在已渲染的 NSAttributedString 纯文本上执行线性搜索
- 返回 `RDEPUBSearchMatch` 数组(含 href、progression、预览文本、匹配位置)
### 2.6.1 章节数据模型 `RDEPUBChapterData`
- 文件:`EPUBTextRendering/RDEPUBChapterData.swift`
- 职责:
- 每章节的数据聚合模型,管理高亮、搜索结果和页面查询
- 支持按页码范围过滤当前页的高亮列表
- 支持搜索结果在章节内的定位和匹配索引计算
### 2.7 CoreText 分页引擎 `RDEPUBTextLayouter` / `RDEPUBTextLayoutFrame`
- 文件:`EPUBTextRendering/RDEPUBTextLayouter.swift``EPUBTextRendering/RDEPUBTextLayoutFrame.swift`
- 职责:
- `RDEPUBTextLayouter`:封装 `CTFramesetter`,逐帧计算分页结果,支持语义边界调整(避免在附件/代码块/列表中间断页)
- `RDEPUBTextLayoutFrame`:单帧分页结果,包含 contentRange、breakReason、blockRange、attachment 信息、语义提示和诊断数据
- 替代原有的纯 `NSRange` 分页,返回带丰富元数据的帧对象
### 2.8 纯文本构建器 `RDPlainTextBookBuilder`
- 文件:`EPUBTextRendering/RDPlainTextBookBuilder.swift`
- 职责:
- 从纯文本文件(.txt 等)构建 `RDEPUBTextBook`
- 将纯文本按段落切分后渲染为 NSAttributedString
- 按页面尺寸分页,输出与 EPUB 文本路径一致的书籍模型
### 2.9 文本索引表 `RDEPUBTextIndexTable`
- 文件:`EPUBTextRendering/RDEPUBTextIndexTable.swift`
- 职责:
- 构建全书的文本结构映射:章节起始偏移、href 与章节/spine 索引对应、fragment 偏移映射、行列索引映射
- `RDEPUBRowColumnIndex`:行-列索引条目,记录每行的字符范围
- 支持锚点与位置之间的双向转换:
- `anchor(forAbsoluteIndex:in:)`:绝对索引 → 文本锚点
- `anchor(for:)`:阅读位置 → 文本锚点(优先 rangeAnchor,其次 fragment/progression
- `absoluteIndex(for:)`:锚点 → 全书绝对索引
- `location(for:in:bookIdentifier:)`:锚点/范围锚点 → 阅读位置
- 支持页码查找:`pageNumber(for:in:)` 通过锚点定位到对应页面
### 2.10 性能采样器 `RDEPUBTextPerformanceSampler`
- 文件:`EPUBTextRendering/RDEPUBTextPerformanceSampler.swift`
- 职责:
- `RDEPUBTextPerformanceSample`:单章节性能数据(chapterHref、renderDuration、paginateDuration、pageCount、attributedStringLength、cacheHit
- `RDEPUBTextPerformanceSampler`:书籍构建过程的性能采样器
-`RDEPUBTextBookBuilder` 在构建过程中使用,每章记录一个采样点
- `summary()` 输出汇总报告:总渲染/分页耗时和缓存命中率
## 3. 主流程(代码级)
### 3.1 完整渲染-分页流程
1. 入口:`RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)`
2. 遍历 `publication.spine``item.linear == true` 的项
3. 过滤仅处理 mediaType 包含 "html" 或 "xhtml" 的项
4. 读取 `parser.htmlString(forRelativePath: item.href)` 获取原始 HTML
5. HTML 标准化:
- 移除中文分页标记:`<hr lang="zh-CN">分页符</hr>`
- `\r` 替换为 `\n`
- 连续 `\n` 合并为单个
6. 检查是否跳过(空白且 href 包含 "cover" 或 "title"
7. 调用 `renderer.renderChapter(html:baseURL:style:)` 渲染
### 3.2 DTCoreText 渲染管线
1. 片段标记注入:`RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)`
- 正则 `(<[^>]+\sid="([^"]+)"[^>]*>)` 匹配带 id 的元素
- 在匹配标签前插入 `${id=<fragmentID>}` 文本标记
2. UTF-8 编码为 Data
3. `DTHTMLAttributedStringBuilder(html:options:documentAttributes:)` 执行渲染
4. DTCoreText 选项:
- `NSTextSizeMultiplierDocumentOption: 1.0`
- `DTDefaultFontFamily` / `DTDefaultFontName` / `DTDefaultFontSize`:来自 `style.font`
- `DTDefaultLineHeightMultiplier``(font.lineHeight + lineSpacing) / max(font.lineHeight, 1)`
- `DTUseiOS6Attributes: true`:生成 UIKit 兼容属性
- `NSBaseURLDocumentOption`:资源相对路径解析
- `DTDefaultTextColor`:可选文本颜色
5. 后处理:
- 提取片段偏移并删除标记
- `normalizeReadingAttributes`:遍历每个属性 run,统一字体(保留粗/斜体 trait)、强制行间距/段间距、覆盖前景色
### 3.3 CoreText 分页算法
1.`NSAttributedString` 创建 `CTFramesetter`
2. 创建页面大小的 `CGPath`
3. 从 location = 0 开始循环:
- 创建 CTFramerange 从当前 location 到字符串末尾)
- `CTFrameGetVisibleStringRange(frame)` 获取可见字符数
- 追加 `NSRange(location: currentLocation, length: visibleRange.length)`
- location += visibleRange.length
4. 循环终止条件:`location + visibleRange.length >= attributedString.length`
5. 安全保护:`visibleRange.length == 0` 时 break
### 3.4 页面与位置互转
**Location → Page Number**`RDEPUBTextBook.pageNumber(for:resolver:bookIdentifier:)`):
1. 有 fragment 时:用 `fragmentOffsets` 查找字符偏移,再定位到对应页面
2. 无 fragment 时:用 `navigationProgression`progression 和 lastProgression 的中点)计算字符偏移
3. 遍历 pages 找到 `pageStartOffset <= offset < pageEndOffset` 的页面
**Page Number → Location**`RDEPUBTextBook.location(forPageNumber:bookIdentifier:)`):
1. 通过 page number 查找 `RDEPUBTextPage`
2. 计算 `progression = pageStartOffset / totalCharacters`
3. 计算 `lastProgression = pageEndOffset / totalCharacters`
4. 构建 `RDEPUBLocation`href + progression + lastProgression
### 3.5 标注定位与恢复
textReflowable 路径不复用 WebView 的 DOM range,而使用章节字符偏移作为精确锚点:
```json
{
"kind": "text-offset",
"href": "chapter.xhtml",
"start": 1234,
"end": 1260
}
```
1. `RDEPUBTextContentView` 保留当前 `RDEPUBTextPage`,从 `page.pageStartOffset` 将页内 `UITextView.selectedRange` 映射为章节全局 `start/end`
2. 生成 `RDEPUBSelection` 后回传 `RDEPUBReaderController`,共享层继续负责创建、去重、持久化、列表和跳转
3. 页面重绘时,`RDEPUBTextContentView` 仅消费 `kind == "text-offset"` 的 rangeInfo,计算当前页 `[pageStartOffset, pageEndOffset]` 与标注 `[start, end)` 的重叠
4. `highlight` 样式叠加 `.backgroundColor``underline` 样式叠加 `.underlineStyle``.underlineColor`
5. 字号、行高、主题变化导致重新分页后,标注仍以章节全局字符偏移恢复到新的页面切片
## 4. 异常与边界处理
- `RDEPUBTextRenderingError.htmlEncodingFailed`:HTML 字符串无法编码为 UTF-8 Data(极罕见)
- DTCoreText builder 返回 nil:回退到 `fallbackAttributedString`,生成纯文本(含 HTML 标签原文),降级体验
- 空白章节跳过:href 包含 "cover" 或 "title" 且渲染后文本为空的章节被跳过
- 分页零结果兜底:`pageRanges` 为空但 content 非空时,整个章节作为一页
- CoreText 零高度安全保护:`visibleRange.length == 0` 时 break 防止无限循环
- Token 取消:`paginationToken`(UUID)防止异步完成后的过期结果污染 UI
## 5. 数据结构与字段映射
### 5.1 渲染配置
```swift
RDEPUBTextRenderStyle
font: UIFont //
lineSpacing: CGFloat //
textColor: UIColor? // nil HTML
backgroundColor: UIColor? //
```
### 5.2 渲染请求上下文
```swift
RDEPUBTextChapterRenderRequest
context: RDEPUBTextChapterContext
href: String
title: String
html: String
baseURL: URL?
stylesheet: RDEPUBTextStyleSheetPackage
layers: [RDEPUBTextStyleSheetLayer]
kind (.default/.replace/.dark/.epub/.user) + css
combinedCSS (computed)
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
style: RDEPUBTextRenderStyle
```
### 5.3 渲染输出
```swift
RDEPUBRenderedChapterContent
attributedString: NSAttributedString //
fragmentOffsets: [String: Int] // fragment ID ->
resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
```
### 5.4 分页帧模型(新增)
```swift
RDEPUBTextLayoutFrame
contentRange: NSRange //
breakReason: RDEPUBTextPageBreakReason //
blockRange: NSRange? // block
attachmentRanges: [NSRange] //
attachmentKinds: [RDEPUBTextAttachmentKind]
blockKinds: [RDEPUBTextBlockKind] // (.paragraph/.list/.table/.code/.blockquote/.attachment/.generic)
semanticHints: [RDEPUBTextSemanticHint] // (.avoidPageBreakInside/.pageBreakBefore/.pageBreakAfter/.pageRelate)
attachmentPlacements: [RDEPUBTextAttachmentPlacement] // (.inline/.baseline/.centered)
trailingFragmentID: String? // fragment ID
diagnostics: [String] //
RDEPUBTextLayouter
init(attributedString:pageSize:)
layoutFrames(fragmentOffsets:) -> [RDEPUBTextLayoutFrame]
```
### 5.5 页面模型
```swift
RDEPUBTextPage
absolutePageIndex: Int //
chapterIndex: Int //
spineIndex: Int // spine
href: String // href
chapterTitle: String? //
pageIndexInChapter: Int //
totalPagesInChapter: Int //
content: NSAttributedString //
contentRange: NSRange // range
pageStartOffset: Int //
pageEndOffset: Int //
```
### 5.6 章节模型
```swift
RDEPUBTextChapter
chapterIndex: Int
spineIndex: Int
href: String
title: String?
attributedContent: NSAttributedString //
fragmentOffsets: [String: Int]
pages: [RDEPUBTextPage]
```
### 5.7 书籍模型
```swift
RDEPUBTextBook
chapters: [RDEPUBTextChapter]
pages: [RDEPUBTextPage] //
page(at:) -> RDEPUBTextPage? // 1-based
pageNumber(for:resolver:bookIdentifier:) -> Int? // Location ->
location(forPageNumber:bookIdentifier:) -> RDEPUBLocation? // -> Location
```
## 6. 与 EPUBCore 层的集成
### 消费的数据
| 来源 | 数据 | 用途 |
|------|------|------|
| `RDEPUBParser` | `htmlString(forRelativePath:)` | 读取章节 HTML 内容 |
| `RDEPUBParser` | `fileURL(forRelativePath:)` | 计算 DTCoreText 的 baseURL |
| `RDEPUBPublication` | `spine` | 遍历 linear HTML/XHTML 项 |
| `RDEPUBPublication` | `tableOfContents` | 匹配章节标题 |
| `RDEPUBSpineItem` | `href`, `mediaType`, `linear`, `title` | 过滤和标识章节 |
| `RDEPUBResourceResolver` | `normalizedLocation` / `normalizedHref` | 位置查找时标准化 href |
| `RDEPUBLocation` | `href`, `fragment`, `navigationProgression` | 页面定位 |
| `RDEPUBSearchEngine` | 协议接口 | `RDEPUBTextSearchEngine` 实现此协议 |
| `RDEPUBHighlight` | `style`, `rangeInfo`, `color`, `note` | text-offset 标注恢复与页内绘制 |
## 7. 配置参数
- **字号**`RDEPUBReaderConfiguration.fontSize`,默认 15pt
- **行高倍数**`lineHeightMultiple`,默认 1.6(行间距 = font.lineHeight * 0.6,最低 4pt
- **内容边距**`reflowableContentInsets`,默认 (40, 16, 40, 16),影响有效页面尺寸
- **主题色**:通过 `RDEPUBReaderTheme``contentTextColor` / `contentBackgroundColor` 传入
- **渲染引擎**`RDEPUBTextRenderingEngine`,当前仅 `.dtCoreText`
## 8. 性能特征
- **全文渲染**:每章一次性渲染为完整的 NSMutableAttributedString,无分块/懒加载
- **内存**`RDEPUBTextBook` 同时持有 chapters(含完整 attributedContent)和 pages(含子串切片),存在一定程度的内存重复
- **后台执行**`build` 方法在 `DispatchQueue.global(qos: .userInitiated)` 执行,不阻塞主线程
- **搜索性能**:线性扫描每章的 `attributedContent.string`,无索引,搜索时间与总文本量线性相关
- **分页缓存**`RDEPUBTextBookCache` 支持磁盘缓存分页结果,字号/行高变化时优先从缓存恢复
- **性能采样**`RDEPUBTextPerformanceSampler` 记录每章的渲染和分页耗时,支持缓存命中率统计
## 9. 联调与排查建议
- 排查 1:渲染后文本为空
- 检查 `parser.htmlString(forRelativePath:)` 是否返回非 nil
- 检查 spine 项的 mediaType 是否包含 "html" 或 "xhtml"
- 空白封面/标题页会被 `shouldSkipChapter` 跳过
- 排查 2:分页页数不正确
- 检查 `pageSize` 计算是否正确(viewport - contentInsets
- 检查 DTCoreText 行高倍数是否正确应用
- 检查 CoreText 分页是否有 `visibleRange.length == 0` 的异常情况
- 排查 3:字体/颜色不正确
- 检查 `RDEPUBTextRenderStyle` 配置
- 检查 `normalizeReadingAttributes` 是否正确保留粗/斜体 trait
- 检查 `foregroundColor` 覆盖逻辑
- 排查 4Fragment 定位失败
- 检查 `injectFragmentMarkers` 是否正确注入标记
- 检查 `extractFragmentOffsets` 是否正确提取偏移
- 确认标记在 DTCoreText 渲染后仍存在于 attributed string 中
- 排查 5:搜索结果不准确
- 搜索基于已渲染的纯文本(去 HTML 标签),非原始 HTML
- 确认 `attributedContent.string` 包含预期的文本内容
- 搜索为大小写不敏感的线性扫描
-324
View File
@@ -1,324 +0,0 @@
# EPUBUI 功能实现逻辑
## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/EPUBUI/`19 个 Swift 文件)
- 目标:说明 EPUBUI 如何作为开箱即用的阅读器 UI 层,协调 EPUBCore 解析、EPUBTextRendering 文本渲染、RDReaderView 分页容器,提供完整的阅读体验(工具栏、目录、高亮批注、设置面板、阅读位置持久化、搜索)。
- 主链路关键词:`RDEPUBReaderController.init -> 解析 EPUB -> 分页 -> 渲染 -> 用户交互(翻页/工具栏/设置/高亮/搜索)-> 持久化`
## 2. 关键对象职责
### 2.1 主控制器 `RDEPUBReaderController`
- 文件:`EPUBUI/RDEPUBReaderController.swift`~1995 行)
- 入口方法:
- `init(epubURL:configuration:persistence:)` — 标准 EPUB 阅读入口
- `init(textBook:bookIdentifier:title:textFileURL:configuration:)` — 纯文本书籍阅读入口(由 `RDPlainTextBookBuilder` 构建 `RDEPUBTextBook` 后传入)
- 职责:
- 加载 EPUB:后台解析 → 应用分页 → 恢复阅读位置
- 实现 `RDReaderDataSource` / `RDReaderDelegate` 为容器提供数据
- 实现 `RDEPUBWebContentViewDelegate` 接收 WebView 事件
- 管理工具栏显隐、目录面板、高亮管理、设置面板、搜索
- 持久化阅读位置、高亮、用户设置
- 对外暴露 `RDEPUBReaderDelegate` 回调宿主 App
### 2.2 配置 `RDEPUBReaderConfiguration`
- 文件:`EPUBUI/RDEPUBReaderConfiguration.swift`
- 13 个配置项:
- `fontSize`(默认 15)、`lineHeightMultiple`(默认 1.6
- `displayType`(默认 .pageCurl)、`landscapeDualPageEnabled`(默认 true
- `showsTableOfContents`(默认 true)、`allowsHighlights`(默认 true)、`showsSettingsPanel`(默认 true
- `reflowableContentInsets`(默认 top:40 left:16 bottom:40 right:16)、`fixedContentInset`(默认 .zero
- `theme`(默认 .light
- `fixedLayoutFit`(默认 .page)、`fixedLayoutSpreadMode`(默认 .automatic
- `textRenderingEngine`(默认 .dtCoreText
- 变更检测:
- `requiresRepagination`fontSize / lineHeightMultiple / contentInsets / fixedLayoutFit / fixedLayoutSpreadMode / textRenderingEngine 变化 → 完整重新分页
- `requiresVisibleRefresh`:theme 变化 → 刷新可见内容(不重新分页)
### 2.3 委托协议 `RDEPUBReaderDelegate`
- 文件:`EPUBUI/RDEPUBReaderDelegate.swift`
- 12 个可选方法(全部有默认空实现):
- `epubReader(_:didOpen:)`:成功打开出版物
- `epubReader(_:didUpdateLocation:)`:翻页或滚动时位置更新
- `epubReaderDidReachEnd(_:)`:到达最后一页
- `epubReader(_:didChangeSelection:)`:文本选择变化或清除
- `epubReader(_:didUpdateHighlights:)`:高亮增删改
- `epubReader(_:didUpdateBookmarks:)`:书签增删改
- `epubReader(_:didUpdateSearchResult:)`:搜索状态变化
- `epubReader(_:didChangeCurrentSearchMatch:)`:当前搜索匹配项变化
- `epubReader(_:didUpdateCurrentTableOfContentsItem:)`:翻页时匹配的目录项
- `epubReader(_:didActivateExternalLink:)`:外部链接点击
- `epubReader(_:didFailWithError:)`:解析或分页错误
- `epubReader(_:configureTopToolView:)`:自定义顶部工具栏
### 2.4 持久化 `RDEPUBReaderPersistence`
- 文件:`EPUBUI/RDEPUBReaderPersistence.swift`
- 协议定义 8 个方法:loadLocation / saveLocation / loadHighlights / saveHighlights / loadBookmarks / saveBookmarks / loadReaderSettings / saveReaderSettings
- 默认实现 `RDEPUBUserDefaultsPersistence`
- 位置:`ssreader.epub.location.<bookIdentifier>`JSON 编码)
- 高亮:`ssreader.epub.highlights.<bookIdentifier>`JSON 编码)
- 书签:`ssreader.epub.bookmarks.<bookIdentifier>`JSON 编码)
- 设置:`ssreader.epub.settings`(全局,非按书)
### 2.5 主题 `RDEPUBReaderTheme`
- 文件:`EPUBUI/RDEPUBReaderTheme.swift`~125 行)
- 6 个颜色属性:contentBackgroundColor、contentTextColor、toolBackgroundColor、toolControlTextColor、toolControlBorderUnselectColor、toolLineColor
- 6 个内置预设:`.light``.dark``.yellow``.green``.pink``.blue`
- `RDEPUBReaderThemePreset` 枚举将预设映射为可序列化值,用于持久化
- 计算属性 `themeBackgroundColorCSS` / `themeTextColorCSS` 用于 Web 渲染路径
### 2.6 设置面板 `RDEPUBReaderSettingsViewController`
- 文件:`EPUBUI/RDEPUBReaderSettingsViewController.swift`~311 行)
- 5 个控制项:
- 亮度滑块(0-1
- 字号 A-/A+(范围 12-36,步长 1
- 行高分段(紧凑 1.3 / 标准 1.6 / 宽松 1.9
- 显示模式分段(仿真 / 横滑 / 竖滑 / 覆盖)
- 主题选择(6 个圆形色块按钮)
- 所有变更通过闭包实时回调:onBrightnessChange / onFontSizeChange / onLineHeightChange / onDisplayTypeChange / onThemeChange
### 2.7 内容视图
- `RDEPUBTextContentView``EPUBUI/RDEPUBTextContentView.swift`):textReflowable 路径,UITextView 展示富文本,支持系统选区、标注菜单、用户标注叠加和搜索高亮叠加
- `RDEPUBWebContentView``EPUBUI/RDEPUBWebContentView.swift`):web 路径,包装 `RDEPUBWebView`,转发位置、选区、标注菜单、链接和 JS 错误事件
### 2.8 其他 UI 组件
- `RDEPUBReaderToolView``EPUBUI/RDEPUBReaderToolView.swift`):工具栏基类,提供分隔线和主题适配的通用逻辑
- `RDEPUBReaderTopToolView`:顶部导航栏(返回按钮 + 书签切换 + 标题)
- `RDEPUBReaderBottomToolView`:底部工具栏(目录 / 书签 / 标注 / 设置 4 个按钮)
- `RDEPUBReaderChapterListController`:目录列表(modal UITableViewController,当前项高亮 systemBlue
- `RDEPUBReaderHighlightsViewController`:标注管理(全部 / 批注 / 划线过滤,列表 + 编辑备注 + 删除 + 跳转,空态按过滤条件提示)
- `RDEPUBReaderSettings``EPUBUI/RDEPUBReaderSettings.swift`):可持久化用户设置模型(brightness / fontSize / lineHeightMultiple / displayMode / themePreset),含 `RDEPUBReaderDisplayMode``RDEPUBReaderThemePreset` 枚举
- `RDEPUBReaderTableOfContentsItem``EPUBUI/RDEPUBReaderTableOfContentsItem.swift`):展平后的目录条目(title / href / depth / pageNumber
- `RDEPUBPageInteractionController``EPUBUI/RDEPUBPageInteractionController.swift`):CoreText 页面级交互控制器,处理文本选区和高亮装饰
- `RDEPUBSelectionOverlayView``EPUBUI/RDEPUBSelectionOverlayView.swift`):选区覆盖视图,显示选中文本的高亮装饰
- `RDEPUBPageLayoutSnapshot``EPUBUI/RDEPUBPageLayoutSnapshot.swift`):页面几何模型,存储 CoreText 渲染的页面布局信息
- `RDURLReaderController``EPUBUI/RDURLReaderController.swift`,~221 行):最简 URL 入口,传入 URL 即可打开书籍(.epub → RDEPUBReaderController,其他 → RDPlainTextBookBuilder 构建后传入),分页失败时回退到纯 UITextView 展示
## 3. 主流程(代码级)
### 3.1 初始化与加载
1. `init(epubURL:configuration:persistence:)`
- 加载持久化的阅读设置,叠加到 configuration
- 恢复屏幕亮度
2. `viewDidLoad`
- 设置背景色
- `setupReaderView()`:添加 RDReaderView 全屏约束,注册 `RDEPUBTextContentView``RDEPUBWebContentView`
- `setupLoadingIndicator()``setupErrorLabel()`
3. `viewDidAppear`:调用 `startInitialLoadIfNeeded()`
4. 加载序列:
- `loadPublication()` → 读取持久化位置
- 后台 `RDEPUBParser.parse(epubURL:)` 解析
- 主线程 `applyParsedPublication()``paginatePublication()`
### 3.2 分页流程
`paginatePublication(restoreLocation:)` 根据 readingProfile 分三条路径:
**textReflowable 路径**
1. 后台队列:`RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)`
2. 主线程 `applyTextBook(textBook:)`:保存 textBookreloadData
**fixed layout 路径**
1. 创建快照:`pageCounts: Array(repeating: 1, count: publication.spine.count)`
2. `applyPaginationSnapshot(snapshot:restoreLocation:)`
**web interactive 路径**
1. `RDEPUBPaginator.calculate(parser:hostingView:presentation:completion:)`
2. 回调收到页数数组后 `applyPaginationSnapshot(snapshot:restoreLocation:)`
三条路径汇合 `finishPagination(restoreLocation:)`
- `readerView.reloadData()`
- `restoreReadingLocation(restoreLocation)`
### 3.3 页面内容工厂
`pageContentView(readerView:pageNum:containerView:)`
- 有 textBook → 出队/创建 `RDEPUBTextContentView`,调用 `configure(page:pageNumber:totalPages:configuration:searchState:)`
- 无 textBook → 出队/创建 `RDEPUBWebContentView`,调用 `configure(publication:request:pageNumber:totalPages:theme:)`
`pageIdentifier(readerView:pageNum:)`:返回对应类名字符串用于 cell 出队
### 3.4 工具栏交互
- RDReaderView 内部处理点击手势,中心 1/3 区域切换工具栏
- 顶部工具栏:返回按钮 pop/dismiss,标题设为 metadata.title
- 底部工具栏:4 个按钮按配置 flags 控制可见性
- 目录按钮 → present `RDEPUBReaderChapterListController`.pageSheet
- 批注按钮 → present `RDEPUBReaderHighlightsViewController`.pageSheet
- 标注按钮 → `presentAnnotationActionSheet(for:)`(需有 currentSelection,否则 disabled),可创建高亮、划线或带 note 的批注
- 设置按钮 → present `RDEPUBReaderSettingsViewController`.pageSheet
### 3.5 设置变更处理
- 字号变化:`configuration.fontSize = newValue``requiresRepagination``repaginatePreservingCurrentLocation()`
- 行高变化:同上
- 显示模式变化:`readerView.switchReaderDisplayType()` 重建容器
- 主题变化:`configuration.theme = newValue``requiresVisibleRefresh``refreshVisibleContentPreservingLocation()`
- 亮度变化:`UIScreen.main.brightness = value` + 持久化
### 3.6 标注管理
- 创建:`addAnnotation(from:style:color:note:)` → 从 currentSelection 构建 → 去重 → 追加到 activeHighlights → 持久化 → 刷新可见内容
- 兼容入口:`addHighlight(from:color:note:)` 仍保留,内部按 `.highlight` 创建
- 样式:`RDEPUBHighlightStyle.highlight` 使用背景色,`RDEPUBHighlightStyle.underline` 使用下划线;旧数据缺少 style 时默认按普通高亮解码
- 批注:note 非空时同一条 `RDEPUBHighlight` 同时承担批注语义
- 删除:`removeHighlight(id:)` → 移除 → 持久化 → 刷新
- 编辑备注:`updateHighlightNote(id:note:)` → 更新 → 持久化 → 刷新
- 跳转:`go(toHighlightID:animated:)` → 从高亮 location 解析页码 → `transitionToPage`
- WebView 选区:`epub-bridge.js` 生成 `dom-range` rangeInfoWeb 内容通过 payload 的 `style` 字段恢复高亮或划线
- DT 选区:`RDEPUBTextContentView` 把页内 selectedRange 映射为章节全局 text-offset rangeInfo,并在当前页与标注范围重叠时叠加背景或下划线
- 选区菜单:WebView 与 DT 路径都通过自定义菜单动作收口到 `拷贝 / 高亮 / 批注`,动作最终复用同一套创建与批注输入流程
### 3.7 书签管理
- 创建/切换:`toggleBookmark(note:)` 基于 `currentVisibleLocation()` 生成当前位置书签;若同一语义位置已存在书签则执行取消
- 位置范围:书签使用 `RDEPUBLocation`,持久化字段包含 `href / fragment / progression`,不依赖正文选区
- 当前位置命中:优先比较标准化后的 `href`,若双方都有 `fragment` 则直接比较;否则按 `navigationProgression` 容差判断(fixed-layout 0.01reflowable 0.05
- 标题补全:优先复用当前 TOC 项标题,否则根据扁平目录用 href 反查章节标题
- 列表管理:底部工具栏 `书签` 入口弹出 `RDEPUBReaderBookmarksViewController`,支持按创建时间倒序展示、跳转与删除
- 顶部状态:TopToolView 右上角书签按钮显示当前页是否已加书签,点击可直接添加/取消
- 持久化:每次新增/删除都会通过 `RDEPUBReaderPersistence.saveBookmarks(_:for:)` 按书保存,并回调 `didUpdateBookmarks`
### 3.8 搜索流程
1. `search(keyword:)`:标准化输入
2. textBook 路径:`RDEPUBTextSearchEngine.search(keyword:)`
3. web 路径:`RDEPUBHTMLSearchEngine.search(keyword:)`
4. 存储结果到 `searchState`,导航到第一个匹配
5. `searchNext()` / `searchPrevious()`:循环推进 currentMatchIndex 并导航
6. `clearSearch()`:清除状态,刷新可见内容
### 3.9 阅读位置持久化
**保存时机**
- 每次翻页:`pageNum(readerView:pageNum:)``persist(location:)`
- WebView 滚动:`epubWebContentView(_:didUpdateLocation:)``persist(location:)`
**恢复时机**
- 初始加载:`loadPublication()` 读取 → 传递到 `finishPagination()``restoreReadingLocation()`
- 显式导航:`go(to:)` / `go(toTableOfContentsHref:)` / `go(toHighlightID:)` / 搜索导航
**恢复逻辑**
- textBook 路径:`textBook.pageNumber(for:resolver:bookIdentifier:)``readerView.transitionToPage`
- readingSession 路径:`readingSession.queueNavigation(to:)``transitionToPage`
### 3.9 横竖屏适配
- `viewWillTransition(to:with:)`:等待旋转过渡完成后,统一触发 viewport 变化处理
- `viewDidLayoutSubviews`:用于补齐分屏、多窗口、safe area 变化等非旋转型视口变化
- 控制器内部使用 viewport signature 做去重,避免一次旋转触发多次正文重分页
- RDReaderView 的 `readerViewOrientationWillChange` 仅保留容器级双页布局和 pageCurl 重建职责,不再直接驱动 EPUBUI 重新分页
## 4. 异常与边界处理
- EPUB 解析失败:显示 errorLabeldelegate 收到 `didFailWithError`
- 分页失败:主线程 `handle(error:)` 处理
- `paginationToken` 防竞态:每次加载/分页生成新 UUID,后台完成时校验 token 一致才应用结果
- 标注去重:`addAnnotation` 检查相同 location、text、rangeInfo 和 style 是否已存在标注
- 自定义主题不可持久化:仅 6 个内置 preset 可序列化,自定义主题仅当前会话有效
- 目录页码缺失:`flattenedTableOfContents` 中 pageNumber 可能为 nil(无法定位到对应页面的 TOC 项)
## 5. 数据结构与字段映射
### 5.1 持久化键值
| 数据 | UserDefaults Key | 编码 |
|------|-----------------|------|
| 阅读位置 | `ssreader.epub.location.<bookIdentifier>` | JSON → `RDEPUBLocation` |
| 高亮列表 | `ssreader.epub.highlights.<bookIdentifier>` | JSON → `[RDEPUBHighlight]` |
| 书签列表 | `ssreader.epub.bookmarks.<bookIdentifier>` | JSON → `[RDEPUBBookmark]` |
| 阅读设置 | `ssreader.epub.settings` | JSON → `RDEPUBReaderSettings` |
### 5.2 设置模型
```swift
RDEPUBReaderSettings (Codable)
brightness: CGFloat?
fontSize: CGFloat?
lineHeightMultiple: CGFloat?
displayMode: RDEPUBReaderDisplayMode?
themePreset: RDEPUBReaderThemePreset?
```
`RDEPUBReaderDisplayMode`:可序列化的翻页模式枚举(pageCurl / horizontalScroll / verticalScroll),与 `RDReaderView.DisplayType` 相互转换。注意:历史版本遗留的 `horizontalCoverScroll` 会自动映射为 `horizontalScroll`
`RDEPUBReaderThemePreset`:可序列化的主题预设枚举(light / yellow / green / pink / blue / dark),与 `RDEPUBReaderTheme` 相互转换。
### 5.3 Book Identifier
- 优先使用 `parser.metadata.identifier`
- 回退使用 `epubURL.lastPathComponent`
### 5.4 目录扁平化项
```swift
RDEPUBReaderTableOfContentsItem
title: String
href: String
depth: Int //
pageNumber: Int? // nil
```
## 6. 视图绑定规则
- 规则 1:内容视图通过 class name 注册到 RDReaderView,出队时根据 textBook 是否存在选择类型
- 规则 2RDEPUBTextContentView 展示 attributedText + 搜索高亮叠加 + 页码标签("N / M"
- 规则 3RDEPUBWebContentView 包装 RDEPUBWebView + 页码标签
- 规则 4:顶部工具栏固定提供返回与书签切换入口;底部工具栏提供目录、书签、标注、设置等管理入口
- 规则 5:设置面板所有变更实时生效(闭包回调),无需确认按钮
## 7. 通知协作与回调机制
### 外部通信(Delegate
- `RDEPUBReaderDelegate`:12 个可选方法,覆盖打开、位置更新、到达末尾、选择变化、高亮更新、书签更新、搜索更新、目录项更新、外部链接、错误、工具栏自定义
### 内部通信(闭包)
- `RDEPUBReaderTopToolView.onBack / onToggleBookmark`
- `RDEPUBReaderBottomToolView.onShowTableOfContents / onShowBookmarks / onShowHighlights / onAddHighlight / onShowSettings`
- `RDEPUBReaderChapterListController.onSelectItem`
- `RDEPUBReaderHighlightsViewController.onSelectHighlight / onUpdateHighlight / onDeleteHighlight`
- `RDEPUBReaderSettingsViewController.onBrightnessChange / onFontSizeChange / onLineHeightChange / onDisplayTypeChange / onThemeChange`
### 容器通信(协议)
- `RDReaderDataSource` / `RDReaderDelegate`RDEPUBReaderController 实现,为 RDReaderView 提供页面数据和接收页面变化事件
- `RDEPUBWebContentViewDelegate`RDEPUBReaderController 实现,接收 WebView 的位置更新、选择变化、链接激活、JS 错误
## 8. 联调与排查建议
- 排查 1:阅读器打开后空白
- 检查 `RDEPUBParser.parse(epubURL:)` 是否成功
- 检查 `startInitialLoadIfNeeded()` 是否被调用(需要 viewDidAppear
- 检查 errorLabel 是否显示
- 排查 2:翻页后位置不恢复
- 检查 `persistence.loadLocation(for:)` 是否返回非 nil
- 检查 `restoreReadingLocation` 是否在 `finishPagination` 中被调用
- textBook 路径检查 `textBook.pageNumber(for:)` 返回值
- 排查 3:设置变更后无效果
- 字号/行高变化需触发 `repaginatePreservingCurrentLocation()`
- 主题变化只需 `refreshVisibleContentPreservingLocation()`
- 检查 `requiresRepagination` / `requiresVisibleRefresh` 的判断逻辑
- 排查 4:高亮不显示
- 确认 `currentSelection` 非 nil
- 确认 `addHighlight` 成功返回
- 确认 `refreshVisibleContentPreservingLocation()` 被调用
- textBook 路径检查 `RDEPUBTextContentView.configure` 中的搜索高亮逻辑
- 排查 5:搜索无结果
- textBook 路径确认 `RDEPUBTextSearchEngine.search` 被调用
- web 路径确认 `RDEPUBHTMLSearchEngine.search` 被调用
- 检查搜索关键词是否为空
- 排查 6:横屏布局异常
- 确认 `landscapeDualPageEnabled` 是否为 true
- 检查 `viewDidLayoutSubviews` 是否触发了重新分页
- 检查 RDReaderView 的 orientation change 处理
-377
View File
@@ -1,377 +0,0 @@
# EPUB 阅读模块维护与排查指南
## 1. 文档目的
本文档说明 EPUB 阅读模块的关键文件职责、主要数据流、常见问题排查和后续扩展建议,供维护时快速建立上下文。
完整架构设计请参考 [ARCHITECTURE.md](ARCHITECTURE.md)。
编码规范请参考 [CODING_STYLE.md](CODING_STYLE.md)。
---
## 2. 关键文件速查
### 2.1 EPUBCore 层
| 文件 | 职责 |
|------|------|
| `RDEPUBParser.swift` + 5 扩展 | EPUB 解压、container.xml、OPF、manifest、spine、TOC/NCX 解析、阅读配置文件判断 |
| `RDEPUBPublication.swift` | 解析结果的唯一聚合入口,外部不直接访问 parser 字段 |
| `RDEPUBModels.swift` | metadata / manifest / spine / TOC 基础模型 |
| `RDEPUBReadingModels.swift` | RDEPUBLocation、RDEPUBViewport、RDEPUBHighlight、RDEPUBBookmark、选区模型 |
| `RDEPUBResourceResolver.swift` | href → fileURL / schemeURL 的统一入口,`normalizedHref` 标准化路径 |
| `RDEPUBPaginator.swift` | 离屏 WKWebView 分页,计算每个 spine 资源被视口切成多少页 |
| `RDEPUBReadingSession.swift` | 状态机 + 会话协调,管理 staged/active/pending 页模型 |
| `RDEPUBNavigatorState.swift` | 状态枚举(initializing/loading/idle/jumping/moving/repaginating |
| `RDEPUBWebView.swift` + 5 扩展 | 承载 spine 资源的 WKWebView,分页 CSS 注入、JS bridge、fixed wrapper、搜索装饰 |
| `RDEPUBResourceURLSchemeHandler.swift` | `ss-reader://` 协议,从本地解压目录提供所有 EPUB 资源 |
| `RDEPUBStyleSheetBuilder.swift` | 生成注入 WebView 的 CSS |
| `RDEPUBJavaScriptBridge.swift` | JS ↔ Swift 消息定义和编解码 |
| `RDEPUBSearchEngine.swift` | 搜索协议定义 + WebView 全文搜索引擎 |
| `RDEPUBSearchModels.swift` | 搜索模型(SearchMatch/Result/State/Presentation |
| `RDEPUBTextAnchor.swift` | 文本锚点和范围锚点(精确定位字符位置) |
| `RDEPUBRenderRequest.swift` | 渲染请求模型、展示样式、固定版式适配/Spread 枚举 |
| `RDEPUBWebViewDebug.swift` | WebView 调试日志工具(导航/JS/消息/Scheme |
| `RDEPUBAssetRepository.swift` | 静态资源加载器(JS 脚本、HTML 模板、模板变量替换) |
| `RDEPUBPreferences.swift` | 用户阅读偏好聚合 |
| `RDEPUBNavigatorLayoutContext.swift` | 容器布局上下文 |
| `RDEPUBFixedLayoutTemplate.swift` | 固定版式 HTML 模板生成 |
### 2.2 EPUBTextRendering 层
| 文件 | 职责 |
|------|------|
| `RDEPUBTextRenderer.swift` | 渲染引擎协议 + 错误类型 + 样式 / 内容模型 |
| `RDEPUBDTCoreTextRenderer.swift` | DTCoreText 实现,HTML → NSAttributedString |
| `RDEPUBTextRendererSupport.swift` | fragment 标记注入、fragment offset 提取、属性标准化 |
| `RDEPUBTextPaginationSupport.swift` | NSAttributedString 切页算法 |
| `RDEPUBTextBookBuilder.swift` | 驱动逐章节渲染和分页,生成 RDEPUBTextBook |
| `RDEPUBTextLayouter.swift` | CoreText 分页引擎(~810 行,含 4 级语义边界调整) |
| `RDEPUBTextLayoutFrame.swift` | 单帧分页结果模型(contentRange、breakReason、语义提示) |
| `RDEPUBTextBookCache.swift` | 分页结果磁盘缓存 |
| `RDEPUBChapterData.swift` | 章节数据聚合模型(高亮、搜索结果、页面查询) |
| `RDEPUBTextSearchEngine.swift` | 纯文本全文搜索引擎 |
| `RDPlainTextBookBuilder.swift` | 纯文本(.txt)书籍构建器 |
| `RDEPUBTextIndexTable.swift` | 全书文本索引表(章节偏移、行列映射、锚点转换) |
| `RDEPUBTextPerformanceSampler.swift` | 性能采样器(渲染/分页耗时、缓存命中率) |
### 2.3 EPUBUI 层
| 文件 | 职责 |
|------|------|
| `RDEPUBReaderController.swift` | 开箱即用读者控制器(~1995 行),整合 session / readerView / UI |
| `RDEPUBReaderConfiguration.swift` | 外部配置项(13 个:字号、主题、翻页模式等) |
| `RDEPUBReaderPersistence.swift` | 阅读位置、高亮、书签的本地持久化(8 个方法) |
| `RDEPUBReaderSettings.swift` | 可持久化用户设置模型 + DisplayMode/ThemePreset 枚举 |
| `RDEPUBReaderTheme.swift` | 6 个内置主题预设 + 颜色属性 |
| `RDEPUBReaderDelegate.swift` | 12 个可选委托方法 |
| `RDEPUBReaderToolView.swift` | 工具栏基类(分隔线 + 主题适配) |
| `RDEPUBReaderTopToolView.swift` | 顶部导航栏(返回 + 书签 + 标题) |
| `RDEPUBReaderBottomToolView.swift` | 底部工具栏(目录/书签/标注/设置) |
| `RDEPUBReaderChapterListController.swift` | 目录列表面板 |
| `RDEPUBReaderHighlightsViewController.swift` | 标注管理面板(过滤、编辑、删除、跳转) |
| `RDEPUBReaderSettingsViewController.swift` | 设置面板(亮度、字号、行高、模式、主题) |
| `RDEPUBReaderTableOfContentsItem.swift` | 展平目录条目模型 |
| `RDEPUBWebContentView.swift` | WKWebView 内容视图,供 RDReaderView 渲染 WebView 类 spine |
| `RDEPUBTextContentView.swift` | 文本内容视图(~728 行),供 RDReaderView 渲染文本类 spine |
| `RDEPUBPageInteractionController.swift` | CoreText 页面级交互控制器 |
| `RDEPUBSelectionOverlayView.swift` | 选区覆盖视图 |
| `RDEPUBPageLayoutSnapshot.swift` | 页面几何模型 |
| `RDURLReaderController.swift` | 最简 URL 入口(.epub/.txt 自动识别) |
---
## 3. 资源服务协议说明
加载 spine 资源统一使用 `ss-reader://` 协议,不直接使用 `file://`
```swift
//
let schemeURL = publication.resourceResolver.schemeURL(forHref: spineItem.href)
webView.load(URLRequest(url: schemeURL))
// relative CSS
webView.loadHTMLString(htmlString, baseURL: nil)
```
这样 HTML 内部的相对 CSS、图片、字体和 iframe 资源都会通过同一个 schemeHandler 解析,避免 `loadHTMLString``allowingReadAccessTo` 导致的资源失效问题。
### 3.1 DTCoreText 实现逻辑(textReflowable 路径)
`readingProfile == .textReflowable` 时,渲染链路不再走 WebView 列分页,而是走 `EPUBTextRendering/` 的 DTCoreText 文本渲染链。
**端到端流程:**
1. `RDEPUBTextBookBuilder` 按 spine 顺序读取章节 HTML。
2. `RDEPUBTextRendererSupport.injectFragmentMarkers(into:)` 注入 fragment 标记,保证目录锚点可映射到文本偏移。
3. `RDEPUBDTCoreTextRenderer.renderChapter(html:baseURL:style:)` 执行章节渲染。
4. 渲染成功时,内部通过 `DTHTMLAttributedStringBuilder` 将 HTML 转为 `NSAttributedString`
5. `RDEPUBTextRendererSupport.extractFragmentOffsets(from:)` 提取 `fragment -> 字符偏移` 映射。
6. `RDEPUBTextRendererSupport.normalizeReadingAttributes(in:style:)` 统一字体、行距、颜色等阅读属性。
7. `RDEPUBTextPaginationSupport` 将富文本按可视区域切页,构建页模型。
8. `RDEPUBTextBookBuilder` 汇总章节与页映射,交给 `RDEPUBTextContentView` 呈现。
**关键实现点:**
1. 条件编译:`#if canImport(DTCoreText)`
- 可用时走 DTCoreText 路径。
- 不可用时走 fallback(纯 `NSAttributedString` 降级),确保工程可编译可运行。
2. `dtOptions(baseURL:style:)` 会注入:
- 默认字体族 / 字体名 / 字号
- 行高倍数(由字体行高与行距折算)
- `NSBaseURLDocumentOption`(保证相对资源解析)
- 默认文本颜色
3. fragment 偏移在“属性标准化前后”都要保持稳定,避免目录跳转漂移。
4. 文本分页以字符范围为准,不以 WebView 像素滚动位置为准。
**失败与降级策略:**
1. HTML 转 UTF-8 失败:抛出 `htmlEncodingFailed`
2. DTCoreText builder 失败:进入 fallback 渲染路径。
3. fallback 仍会执行 fragment 提取与阅读属性标准化,保证目录跳转和阅读样式能力不丢失。
**与定位恢复的关系:**
1. 仍使用 `RDEPUBLocation``href + progression`)作为跨会话恢复模型。
2. textReflowable 模式下,`fragmentOffsets` 用于将目录锚点映射到分页后的字符区间,再换算到页索引。
3. 字号/行距变化触发重分页后,优先按 `href + progression` 回落恢复,再用 fragment 做细化定位。
**推荐日志点(DTCoreText 专用):**
1. `RDEPUBDTCoreTextRenderer.renderChapter(...)`:记录章节 href、输入 HTML 长度、渲染耗时。
2. `makeAttributedString(from:baseURL:style:)`:记录 DTCoreText 是否成功返回 attributed string。
3. `extractFragmentOffsets(from:)`:记录 fragment 数量与关键锚点是否命中。
4. `RDEPUBTextPaginationSupport`:记录总页数、每页字符范围边界。
---
## 4. 常见问题排查
### 4.1 EPUB 打不开或解析失败
**优先检查:**
1. `RDEPUBParser.parse(epubURL:)` 是否抛出异常,打印具体错误
2. `RDEPUBParser+Archive.swift` — 解压是否成功
3. `META-INF/container.xml` 是否存在
4. OPF 中 manifest / spine 是否完整
**常见原因:**
- 文件不是标准 EPUB ZIP 结构
- `container.xml` 找不到 OPF 路径
- OPF manifest 或 spine 格式异常
- 文件路径包含特殊字符导致解压目录路径错误
**排查顺序:**
1. 确认 EPUB 已成功复制到沙盒
2. 确认解压目录存在(`extractionRootURL`
3. 确认 `opfDirectoryURL` 不为 nil
4. 确认 `spine.count > 0`
---
### 4.2 EPUB 显示空白页
**优先检查:**
1. `RDEPUBWebContentView.loadPage(...)` 传入的 spineIndex 是否在范围内
2. `RDEPUBWebView+Reflowable` — 分页 CSS 注入是否成功
3. `WKNavigationDelegate.didFinish` 是否正常触发
4. `RDEPUBResourceURLSchemeHandler` 是否正确返回资源数据
**常见原因:**
1. scheme URL 拼装错误,资源请求 404
2. 注入的 CSS 破坏了原始布局(尤其是 `column-width` 样式冲突)
3. fixed-layout 书籍走了 reflowable 渲染路径(readingProfile 判定错误)
**排查顺序:**
1. 确认 `schemeURL(forHref:)` 返回的 URL 对应文件真实存在
2.`RDEPUBWebViewDebug` 打开 WebView inspector 查看网络请求
3. 确认 readingProfile 判定正确(webInteractive / webFixedLayout / textReflowable
---
### 4.3 修改字号后位置恢复不准
**原因:** 字号变化触发重新分页(repaginating),页号失效。
**正确做法:**
当前实现依赖 `href + progression` 恢复位置,不依赖纯页号。排查重点:
1. `RDEPUBLocation.href` 是否已通过 `normalizedHref` 标准化
2. `progression` 是否在字号变化后重新从 WebView 读取
3. `RDEPUBReadingSession.navigatorState` 是否在重分页后回到 `.idle` 并消费了 staged snapshot
---
### 4.4 目录点击后跳错位置
**优先检查:**
1. `RDEPUBParser+TOC.swift` — TOC href 是否正确标准化为相对 OPF 路径
2. `RDEPUBResourceResolver.normalizedHref(_:)` — 跳转时是否使用了标准化 href
3. `fragment` 是否正确传入 WebView 锚点滚动
**常见原因:**
1. TOC 链接是相对 Nav 或 NCX 文件目录,未转为相对 OPF
2. `href#fragment` 只命中了资源,未命中锚点
3. 同一资源中多个 TOC 条目被折叠成同一页
**排查步骤:**
1. 打印原始 TOC href 和标准化后 href 进行比较
2. 确认 `fragment` 字段被保留在 RDEPUBLocation 中
3. 确认 WebView 收到 `fragment` 后执行了 `scrollToFragment(fragment:)` JS 调用
---
### 4.5 正文内部链接不工作
**优先检查:**
1. `RDEPUBWebView+JavaScriptBridge` 中内部链接拦截逻辑
2. `RDEPUBReaderController.navigateToLocation(_:)` — 跨资源跳转路径
**常见原因:**
1. 链接是空 `#fragment`,但当前 DOM 中找不到对应元素
2. 跨资源链接 href 未被 normalizedHref 处理
3. 对应 spine 资源没有分页结果(pageCounts[spineIndex] == 0
---
### 4.6 图片多时位置跳动
**原因:** 图片异步加载导致 DOM 高度变化,进度报告时机早于实际布局稳定。
**排查:**
1. 检查 JS `ResizeObserver` 是否在图片加载后触发了进度重同步
2. 确认 `didUpdateLocation` 不在 pending navigation 未消费前覆盖目标位置
3. 确认 `scrollToLocation()` JS 调用时序在 `didFinish` 之后
---
### 4.7 高亮未显示或恢复失败
**优先检查:**
1. `RDEPUBHighlight` 是否成功写入 persistence
2. `RDEPUBReaderController.activeHighlights` 是否正确过滤出当前 spine 的高亮
3. `RDEPUBWebView+JavaScriptBridge``setHighlights()` JS 调用是否触发
4. JS 中 `rangeFromInfo()` 是否能找到对应 DOM 节点(嵌套节点恢复容易失败)
**排查顺序:**
1. 确认有效选区(`currentSelection` 不为 nil
2. 确认 `addHighlight()` 调用后 persistence 中数据已写入
3. 确认当前页传入的 highlights 数组不为空
4. 确认 JS 收到了 highlights 数据并尝试了 rangeFromInfo 恢复
---
### 4.8 fixed-layout 书籍显示异常
**优先检查:**
1. `RDEPUBPublication.readingProfile` 是否判定为 `.webFixedLayout`
2. `RDEPUBFixedLayoutTemplate` — wrapper HTML 是否正确生成
3. viewport meta 尺寸是否与书籍 OPF 中声明一致
4. spread 模式是否正确(单页 vs 双页)
---
## 5. 推荐日志点
遇到复杂问题时,建议先在以下位置加日志:
1. `RDEPUBParser.parse(epubURL:)` — 打印解压路径、OPF 路径、spine 数量
2. `RDEPUBReadingSession.transition(to:)` — 打印状态跃迁
3. `RDEPUBPaginator` — 打印每个 spine 资源的分页结果
4. `RDEPUBReaderController.restoreLocation(_:)` — 打印恢复位置的 href / progression
5. `RDEPUBWebView+JavaScriptBridge` — 打印所有 JS 消息收发
6. `RDEPUBResourceURLSchemeHandler` — 打印资源请求 URL 和响应状态
---
## 6. 标准排查顺序
当 EPUB 功能出问题时,建议按以下顺序排查:
1. **解析是否成功** — parser.spine.count > 0metadata 正确?
2. **readingProfile 是否正确** — 用了对的渲染路径?
3. **TOC href 是否标准化** — 与 spine href 格式一致?
4. **分页结果是否合理** — 每个 spine 资源的 pageCount > 0
5. **资源是否能正常加载** — ss-reader:// 请求是否 200
6. **JS bridge 是否正常** — progression 上报是否到达 Swift 侧?
7. **状态机是否流转正确** — session.navigatorState 是否符合预期?
8. **控制器是否正确映射页号** — EPUBPage 列表与 RDReaderView 页号对应是否正确?
---
## 7. 后续扩展建议
1. **拆分 RDEPUBReaderController**:约 1995 行,EPUB 加载、UI、数据源职责仍混在一起,建议继续向 library 层迁移
2. **基线验证**:用四本样书跑一轮分页耗时、目录命中率、末页事件、设置恢复稳定性数据
3. **横竖屏支持回归矩阵**:控制器级 `viewWillTransition()` 与 viewport 去重已接入,下一步重点是补齐 pageCurl / scroll、三条正文路径和 iPad 视口变化场景的固定回归
4. **Pod 分层**:评估是否将 EPUBCore / EPUBTextRendering / EPUBUI 拆为独立 subspec
5. **RDEPUBParser 继续收敛**OPF metadata 细节处理仍偏重,可考虑按 OPFMetadataParser 单独拆出
---
## 8. 阅读器能力现状与下一阶段规划
### 8.1 当前已具备的核心能力
当前 SDK 已具备一套可用的 EPUB 阅读器主链路,包含:
1. **EPUB 打开与解析**:支持 ZIP 解压、OPF / spine / TOC 解析,以及三类 readingProfile 分流
2. **正文渲染与分页**:支持 WebView 路径和 DTCoreText 文本路径,覆盖固定版式、交互式可重排和纯文本可重排 EPUB
3. **完整阅读交互**:支持目录跳转、翻页、阅读位置恢复、主题切换、字号和行高调整、亮度调整
4. **标注能力**:支持高亮、划线、批注、标注列表、编辑、删除、跳转与持久化恢复
5. **书签能力**:支持当前位置书签添加/取消、书签列表、删除、跳转与持久化恢复
6. **自动化测试基础能力**:已接入 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests`、样书夹具定位工具,以及 parser / resolver / persistence 首批 XCTest 和阅读器启动 smoke 用例
7. **搜索能力**:支持全文搜索、匹配项跳转、当前匹配高亮
### 8.2 高优先级待补能力
从“可用 MVP”走向“稳定可交付阅读器”,当前最值得优先完成的是:
1. **样书基线验证**
需要基于四本样书补齐分页耗时、目录命中率、末页事件、设置恢复稳定性等基线数据
### 8.3 面向完整商业阅读器的下一阶段能力
若目标是继续向完整阅读产品演进,建议后续补齐以下能力:
1. **阅读进度展示**
包括全书进度、章节进度、页码信息、剩余页数或剩余章节等
2. **更完整的标注管理**
包括按章节分组、排序、筛选、批量删除、标注导出、复制批注内容等
3. **更成熟的搜索体验**
包括搜索结果列表页、章节聚合、上下文预览、搜索历史等
4. **更多阅读增强能力**
包括字体切换、自定义主题、翻页手势自定义、TTS 朗读、无障碍支持等
### 8.4 工程侧持续建设建议
除了阅读器功能本身,还建议并行推进以下工程项:
1. **控制器继续拆分**
`RDEPUBReaderController` 与 demo 层控制器都偏大,继续叠加功能会增加维护成本
2. **Parser 与模块职责继续收敛**
`RDEPUBParser` 仍承担较多 OPF metadata 解析细节,后续可继续按职责拆分
3. **回归验收矩阵固化**
建议把样书、渲染路径、主题、字号、翻页模式、标注恢复等整理为固定回归清单
### 8.5 推荐下一阶段实施顺序
如果以“最小投入换最大稳定性提升”为目标,建议按下面顺序推进:
1. **样书基线验证**
-233
View File
@@ -1,233 +0,0 @@
# RDReaderView 功能实现逻辑
## 1. 范围与目标
- 代码范围:`Sources/RDReaderView/ReaderView/`5 个 Swift 文件)
- 目标:说明分页阅读器容器如何管理三种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。
- 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐`
## 2. 关键对象职责
### 2.1 核心容器 `RDReaderView`
- 文件:`Sources/RDReaderView/ReaderView/RDReaderView.swift`~1219 行)
- 入口方法:`reloadData()`
- 职责:
- 管理三种显示模式的视图层级切换
- 持有 `UIPageViewController`pageCurl 模式)或 `UICollectionView`(滚动模式)
- 处理点击手势(左/中/右三区域)
- 管理工具栏(topToolView / bottomToolView)的显示/隐藏动画
- 检测横竖屏变化并触发重新布局
- 管理双屏配对逻辑(含封面页处理)
- RTL 语言方向支持
### 2.2 自定义布局 `RDReaderFlowLayout`
- 文件:`Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift`~375 行)
- 职责:
- 继承 `UICollectionViewFlowLayout`,为两种滚动模式提供布局计算
- 水平滚动:全屏宽 itempagingEnabled
- 垂直滚动:可变高度 item,累加计算
- 封面感知帧计算:封面页全屏宽,后续页面两两配对半屏宽
### 2.3 内容 Cell `RDReaderContentCell`
- 文件:`Sources/RDReaderView/ReaderView/RDReaderContentCell.swift`~55 行)
- 职责:
- `UICollectionViewCell` 子类,作为内容视图的薄壳宿主
- `containerView` 属性 setter 自动移除旧视图、添加新视图
- `layoutSubviews` 将 containerView 填满 contentView.bounds
### 2.4 页面子控制器 `RDReaderPageChildViewController`
- 文件:`Sources/RDReaderView/ReaderView/RDReaderPageChildViewController.swift`~89 行)
- 职责:
- 仅用于 pageCurl 模式,作为 `UIPageViewController` 的子控制器
- 持有 `contentView: UIView?``pageNum: Int`
- `contentView` didSet 在 view 已加载时自动调用 `installContentView()`
### 2.5 手势控制器 `RDReaderGestureController`
- 文件:`Sources/RDReaderView/ReaderView/RDReaderGestureController.swift`~58 行)
- 职责:
- 当前为占位组件,存储 topToolView / bottomToolView 引用
- 实际手势逻辑在 `RDReaderView.tapCenter()` 中实现
## 3. 主流程(代码级)
### 3.1 协议定义
**RDReaderDataSource**(数据供给):
```swift
func pageCountOfReaderView(readerView: RDReaderView) -> Int
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
```
**RDReaderDelegate**(事件回调):
```swift
func pageNum(readerView: RDReaderView, pageNum: Int)
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
```
### 3.2 初始化与数据加载
1. 调用方创建 `RDReaderView`,设置 `dataSource``delegate`
2. 调用 `reloadData()`
3. `reloadData` 内部调用 `switchReaderDisplayType(currentDisplayType)` 重建视图层级。
4. 同时从 `dataSource` 获取 `topToolView``bottomToolView` 并添加到视图层级。
### 3.3 三种显示模式切换
**pageCurl 模式**
- 创建 `UIPageViewController`transitionStyle: .pageCurl
- 通过 `attachPageViewControllerIfNeeded()` 添加为父 VC 的 child VC
- 横屏双页时重建 `UIPageViewController`spineLocation: .mid, isDoubleSided: true
- RTL 时翻转导航方向
**horizontalScroll 模式**
- 移除 pageViewController,插入 `UICollectionView`
- `RDReaderFlowLayout` 设置 scrollDirection = .horizontal, isPagingEnabled = true
- itemSize 宽度 = 容器宽度 / pagesPerScreen
- RTL 时对 collectionView 做 `scaleX: -1` 翻转,cell contentView 再翻转回来
**verticalScroll 模式**
- 同一 collectionViewscrollDirection = .vertical, isPagingEnabled = false
- 页面高度可变,通过 `RDReaderFlowLayoutDataSoure.heigtOfVerticalScrollPage` 查询
- collectionViewContentSize 为所有页面高度之和
### 3.4 翻页交互
**点击手势**`tapAction(tap:)`):
- 屏幕分为左 1/3、中 1/3、右 1/3 三个区域
- 左区域:工具栏隐藏时翻上一页(RTL 时翻下一页);工具栏显示时触发 `tapCenter()`
- 中区域:始终触发 `tapCenter()`
- 右区域:工具栏隐藏时翻下一页(RTL 时翻上一页);工具栏显示时触发 `tapCenter()`
**goNextPage() / goPreviousPage()**
- 双页模式使用 `adjacentDualPage(from:forward:)` 计算目标页
- 单页模式直接 +1 / -1
- 调用 `transitionToPage(pageNum:animated:true)` 执行跳转
**工具栏切换**`tapCenter()`):
- 显示时:topToolView 从上方滑入,bottomToolView 从下方滑入(CGAffineTransform translationY
- 隐藏时:反向动画
- 工具栏显示期间禁用 collectionView 和 pageViewController 的用户交互
### 3.5 双页配对逻辑
**`dualPagePair(for pageNum:)`** 返回 `(left: Int, right: Int?)`
- 有封面页:封面页 → (coverIndex, nil);封面后的页面两两配对(coverIndex+1 与 coverIndex+2coverIndex+3 与 coverIndex+4...
- 无封面页:标准偶奇配对(0+1, 2+3, 4+5...
**空白哨兵页**
- `blankPageNum = Int.max``blankEndPageNum = Int.max - 1`
- 仅用于 pageCurl 双页模式,填充封面页独占或总页数为奇数时的右侧空白
### 3.6 横竖屏变化处理
1. `layoutSubviews()` 检测 `previousIsLandscape` 与当前 `isLandscape` 的变化
2. 异步调用 `orientationChanged(isNowLandscape:)` 避免嵌套布局
3. 通知 delegate `readerViewOrientationWillChange`
4. 更新 `layout.isLandscapeDualPage``layout.coverPageIndex`
5. pageCurl 模式:重建 UIPageViewControllerspineLocation 不可变)并跳转到保存的页面
6. 滚动模式:invalidate layoutreload data,强制布局,无动画滚动到保存的 offset
### 3.7 CollectionView DataSource / Layout Delegate
**cellForItemAt**
-`dataSource.pageIdentifier` 获取重用标识符
- 出队 `RDReaderContentCell`
- 调用 `dataSource.pageContentView(pageNum:containerView:)` 传入 cell 已有的 containerView 以便复用
- RTL 水平模式对 cell.contentView 做 `scaleX: -1` 翻转
**pageNum(flowLayout:pageIndex:)**
- 布局检测到滚动中页面变化时更新 `currentPage`
## 4. 异常与边界处理
- `currentPage` 初始值为 -1,首次设置时 delegate 会收到回调
- 空白哨兵页(Int.max / Int.max-1)在 delegate 回调中被过滤,不会通知到外部
- pageCurl 模式下 collectionView 的 DataSource 方法存在但不被调用(collectionView 已从视图层级移除)
- 横竖屏变化时异步处理避免嵌套 layoutSubviews
- `UIView.ss_superViewController` 通过响应链查找最近的父 VC,用于正确挂载 pageViewController
## 5. 数据结构与字段映射
### 5.1 显示模式
```swift
RDReaderView.DisplayType
.pageCurl // UIPageViewController
.horizontalScroll // UICollectionView
.verticalScroll // UICollectionView
```
### 5.2 翻页方向
```swift
RDReaderView.PageDirection
.leftToRight // /
.rightToLeft //
```
### 5.3 关键公开属性
| 属性 | 类型 | 默认值 | 用途 |
|------|------|--------|------|
| `dataSource` | `RDReaderDataSource?` | nil | 弱引用数据源 |
| `delegate` | `RDReaderDelegate?` | nil | 弱引用事件监听 |
| `currentDisplayType` | `DisplayType` | .pageCurl | 当前显示模式 |
| `toolViewAnimationDuration` | `TimeInterval` | 0.3 | 工具栏动画时长 |
| `landscapeDualPageEnabled` | `Bool` | false | 横屏双页 |
| `pageDirection` | `PageDirection` | .leftToRight | 翻页方向 |
| `coverPageIndex` | `Int?` | nil | 封面页索引 |
| `currentPage` | `Int` | -1 | 当前页码(didSet 通知 delegate |
### 5.4 计算属性
- `pagesPerScreen: Int`:横屏 + 双页启用 + 非垂直滚动 → 2;否则 1
- `isLandscape: Bool``bounds.width > bounds.height`
- `hasCoverPage: Bool``coverPageIndex != nil`
## 6. 视图绑定规则
- 规则 1:内容视图通过 `register(contentView:contentViewWithReuseIdentifier:)` 注册,内部注册对应的 `RDReaderContentCell`
- 规则 2Cell 复用两层:`RDReaderContentCell`UICollectionViewCell)宿主 `containerView`(实际内容视图)
- 规则 3`pageContentView(readerView:pageNum:containerView:)``containerView` 参数是从回收 cell 中取出的旧视图,允许数据源复用或重新渲染
- 规则 4:工具栏通过 `topToolView` / `bottomToolView` 协议方法提供,由 `RDReaderView` 管理显隐动画
- 规则 5RTL 支持通过 collectionView `scaleX: -1` + cell contentView `scaleX: -1` 实现,文本正确显示
## 7. 通知协作与回调机制
- 无 NotificationCenter 使用
- 所有通信通过协议和闭包:
- `RDReaderDataSource`:数据供给(页数、内容视图、标识符、工具栏)
- `RDReaderDelegate`:事件通知(页面变化、横竖屏即将变化)
- `RDReaderFlowLayoutDelegate`:布局检测到页面变化时通知
- `RDReaderFlowLayoutDataSoure`:垂直滚动时提供每页高度
## 8. 联调与排查建议
- 排查 1:页面不显示
- 确认 `dataSource` 已设置且 `reloadData()` 已调用
- 确认 `pageCountOfReaderView` 返回值 > 0
- 确认 `pageContentView(pageNum:containerView:)` 返回非 nil 视图
- 排查 2:翻页无反应
- 确认工具栏是否正在显示(工具栏显示时左右点击变为切换工具栏)
- 确认 `currentPage` 的 didSet 是否被触发
- pageCurl 模式检查 `UIPageViewController` 的 dataSource 方法是否正确返回
- 排查 3:横屏双页异常
- 确认 `landscapeDualPageEnabled` 是否为 true
- pageCurl 模式需重建 UIPageViewControllerspineLocation 不可变)
- 检查 `dualPagePair` 配对逻辑和空白哨兵页处理
- 排查 4RTL 方向错误
- 确认 `pageDirection` 是否设为 `.rightToLeft`
- 水平滚动模式检查 collectionView 是否做了 `scaleX: -1`
- pageCurl 模式检查导航方向是否翻转
- 排查 5:工具栏动画异常
- 确认 `topToolView` / `bottomToolView` 是否通过 dataSource 正确提供
- 检查 `toolViewAnimationDuration`
- 确认工具栏显示期间用户交互是否被正确禁用/恢复
-415
View File
@@ -1,415 +0,0 @@
# ReadViewSDK UI 自动化测试可执行方案
## 1. 目标与边界
本文档描述当前架构下可落地的 XCUITest 方案。目标不是一次性覆盖所有 SDK API,而是先为 Demo App 的核心阅读链路建立稳定回归网:
- 书库页面能展示样本书。
- 样本书能打开阅读器。
- 点击阅读区域中部能显示顶部和底部工具栏。
- 顶部返回按钮能关闭阅读器并回到书库。
- 设置面板能打开、操作、关闭。
- 三种翻页模式能通过启动参数切换并保持阅读器可用。
XCUITest 运行在独立进程,不能直接调用 `RDEPUBReaderController` 的 Swift 公共 API。因此需要通过 UI 元素、launch arguments、Demo 测试状态标签或截图附件验证结果。SDK 公共 API 可以由单元测试或 Demo 自动化入口间接驱动,不应写成 XCUITest 的直接依赖。
## 2. 当前可用基础
### 2.1 已有 accessibilityIdentifier
| 标识符 | 当前文件 | 用途 |
|--------|----------|------|
| `epub.reader.back` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift` | 顶部返回按钮 |
| `epub.reader.bookmark` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift` | 顶部书签按钮 |
| `epub.reader.title` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift` | 顶部标题 |
| `epub.reader.toc` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 目录按钮 |
| `epub.reader.bookmarks` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 书签列表按钮 |
| `epub.reader.highlights` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 高亮列表按钮 |
| `epub.reader.add-highlight` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 新建高亮按钮 |
| `epub.reader.settings` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 设置按钮 |
| `demo.root` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | Demo 根视图 |
| `demo.status` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | Demo 状态标签 |
| `demo.books.table` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | 书库表格 |
| `demo.books.empty` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | 空书库提示 |
| `demo.book.{n}` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | 书库第 n 本书 |
| `demo.reader.host` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | modal 场景下的阅读器宿主 |
### 2.2 已有 Demo 启动参数
`ReadViewDemo/ReadViewDemo/ViewController.swift` 已有 `LaunchAutomationPlan`
```bash
--demo-book-title <关键词>
--demo-display-type <pagecurl|scroll|vertical>
--demo-page <页码>
--demo-display-sequence <pagecurl,scroll,vertical>
--demo-step-delay <秒>
```
首批测试优先使用 `--demo-book-title 回归验证样本`。该样本目前存在于 `ReadViewDemo/ReadViewDemo/book/回归验证样本.txt`,适合作为稳定自动化入口。
## 3. 当前架构下的落点
不要把测试辅助职责重新塞回 `RDEPUBReaderController.swift`。identifier 和测试状态应按真实 UI/职责归属放置:
| 能力 | 落点 | 原因 |
|------|------|------|
| 顶部工具栏容器 | `RDEPUBReaderTopToolView.swift` | 工具栏自身创建和维护顶部按钮 |
| 底部工具栏容器 | `RDEPUBReaderBottomToolView.swift` | 工具栏自身创建和维护底部按钮 |
| 阅读点击区域 | `RDReaderView.swift` | 点击中区、翻页手势都由 ReaderView 处理 |
| 滚动翻页容器 | `RDReaderView.swift``collectionView` | 横滑/竖滑模式使用 collection view |
| 单页内容 cell | `RDReaderContentCell.swift` 或当前分页 cell 文件 | 验证 cell 存在,不验证文本排版细节 |
| 设置面板控件 | `EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift` | 设置面板已从 EPUBUI 根目录迁移到 Settings |
| 目录列表 | `RDEPUBReaderChapterListController.swift` | 目录是独立 UITableViewController |
| 高亮/书签列表 | `RDEPUBReaderHighlightsViewController.swift` | 高亮和书签管理器在该文件中 |
| Demo 自动化状态 | `ViewController.swift``RDURLReaderController.swift` | XCUITest 通过文本/identifier 读取状态 |
## 4. 需要新增的最小标识符
### 4.1 P0 必需
| 标识符 | 建议文件 | 用途 |
|--------|----------|------|
| `epub.reader.topToolbar` | `RDEPUBReaderTopToolView.swift` | 断言顶部工具栏显示/隐藏 |
| `epub.reader.bottomToolbar` | `RDEPUBReaderBottomToolView.swift` | 断言底部工具栏显示/隐藏 |
| `epub.reader.content` | `RDReaderView.swift` | 点击阅读区域中部、滑动翻页 |
| `epub.reader.paging` | `RDReaderView.swift``collectionView` | 横滑/竖滑容器存在性 |
| `epub.reader.settings.scroll` | `RDEPUBReaderSettingsViewController.swift` | 设置面板已打开 |
| `epub.reader.settings.brightness` | `RDEPUBReaderSettingsViewController.swift` | 亮度 slider |
| `epub.reader.settings.font.decrease` | `RDEPUBReaderSettingsViewController.swift` | 字号减 |
| `epub.reader.settings.font.increase` | `RDEPUBReaderSettingsViewController.swift` | 字号加 |
| `epub.reader.settings.font.value` | `RDEPUBReaderSettingsViewController.swift` | 字号值 |
| `epub.reader.settings.displayType` | `RDEPUBReaderSettingsViewController.swift` | 翻页模式分段控件 |
| `epub.reader.settings.done` | `RDEPUBReaderSettingsViewController.swift` | 完成按钮 |
| `demo.reader.state` | Demo 层 | 输出当前打开状态、页码、翻页模式 |
### 4.2 P1 后续补充
| 标识符 | 建议文件 | 用途 |
|--------|----------|------|
| `epub.reader.toc.list` | `RDEPUBReaderChapterListController.swift` | 目录列表 |
| `epub.reader.toc.cell.{n}` | `RDEPUBReaderChapterListController.swift` | 目录项 |
| `epub.reader.bookmark.list` | `RDEPUBReaderHighlightsViewController.swift` 的书签控制器 | 书签列表 |
| `epub.reader.bookmark.cell.{n}` | `RDEPUBReaderHighlightsViewController.swift` 的书签控制器 | 书签项 |
| `epub.reader.highlight.list` | `RDEPUBReaderHighlightsViewController.swift` | 高亮列表 |
| `epub.reader.highlight.cell.{n}` | `RDEPUBReaderHighlightsViewController.swift` | 高亮项 |
| `epub.reader.settings.lineHeight` | `RDEPUBReaderSettingsViewController.swift` | 行距分段控件 |
| `epub.reader.settings.columns` | `RDEPUBReaderSettingsViewController.swift` | 栏数分段控件 |
| `epub.reader.settings.theme.{n}` | `RDEPUBReaderSettingsViewController.swift` | 主题按钮 |
`epub.reader.pageIndicator` 暂不列为必需项,因为当前没有稳定的页码指示器 UI。若需要断言页码,优先通过 `demo.reader.state` 暴露 `page=...`,或者给 `RDReaderView` 设置 `accessibilityValue`
## 5. Demo 测试状态设计
建议增加一个仅用于自动化的状态标签:
```swift
stateLabel.accessibilityIdentifier = "demo.reader.state"
stateLabel.isHidden = true
stateLabel.text = "reader=opened page=1 display=pagecurl toolbar=hidden"
```
状态来源可以在 Demo 层更新,不要求 SDK 为测试暴露内部对象:
- 打开阅读器后:`reader=opened`
- 返回书库后:`reader=closed`
- 跳页或翻页后:`page=<n>`
- 切换翻页模式后:`display=pagecurl|scroll|vertical`
- 工具栏显示后:`toolbar=visible`
这个标签能显著减少 XCUITest 对动画、布局和截图的猜测,是当前架构下最稳的可执行方案。
## 6. UI Test Target 创建方式
使用 workspace,不使用单独的 xcodeproj
1. 打开 `ReadViewDemo/ReadViewDemo.xcworkspace`
2. File -> New -> Target -> iOS UI Testing Bundle
3. Product Name: `ReadViewDemoUITests`
4. Target to be Tested: `ReadViewDemo`
5. 新增测试目录:
```text
ReadViewDemo/
ReadViewDemoUITests/
Helpers/
AccessibilityIdentifiers.swift
XCUIApplication+Launch.swift
XCUIElement+Wait.swift
ReaderUITests/
BookListTests.swift
ReaderOpenCloseTests.swift
ReaderToolbarTests.swift
SettingsPanelTests.swift
DisplayTypeTests.swift
SmokeScreenshotTests.swift
```
首批不要拆太多测试类,避免还没稳定就出现维护成本。
## 7. P0 测试用例
### 7.1 启动辅助
```swift
import XCTest
enum IDs {
static let demoStatus = "demo.status"
static let demoBooksTable = "demo.books.table"
static func demoBook(_ n: Int) -> String { "demo.book.\(n)" }
static let demoReaderState = "demo.reader.state"
static let readerBack = "epub.reader.back"
static let readerTitle = "epub.reader.title"
static let readerTopToolbar = "epub.reader.topToolbar"
static let readerBottomToolbar = "epub.reader.bottomToolbar"
static let readerContent = "epub.reader.content"
static let readerSettings = "epub.reader.settings"
static let settingsScroll = "epub.reader.settings.scroll"
static let settingsFontIncrease = "epub.reader.settings.font.increase"
static let settingsFontDecrease = "epub.reader.settings.font.decrease"
static let settingsFontValue = "epub.reader.settings.font.value"
static let settingsDone = "epub.reader.settings.done"
}
extension XCUIApplication {
func launchAndOpenSampleBook(
displayType: String? = nil,
pageNumber: Int? = nil
) {
var args = ["--demo-book-title", "回归验证样本"]
if let displayType {
args += ["--demo-display-type", displayType]
}
if let pageNumber {
args += ["--demo-page", "\(pageNumber)"]
}
launchArguments = args
launch()
}
@discardableResult
func waitForReader(timeout: TimeInterval = 10) -> XCUIElement {
let backButton = buttons[IDs.readerBack]
XCTAssertTrue(backButton.waitForExistence(timeout: timeout))
return backButton
}
}
```
### 7.2 书库与打开关闭
```swift
final class ReaderOpenCloseTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testBookListShowsSampleBooks() {
app.launch()
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
XCTAssertTrue(app.cells[IDs.demoBook(0)].waitForExistence(timeout: 5))
}
func testLaunchArgumentOpensReader() {
app.launchAndOpenSampleBook()
app.waitForReader()
XCTAssertTrue(app.staticTexts[IDs.readerTitle].exists)
}
func testBackButtonReturnsToBookList() {
app.launchAndOpenSampleBook()
app.waitForReader().tap()
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
}
}
```
### 7.3 工具栏显示
```swift
final class ReaderToolbarTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testTapCenterShowsToolbars() {
app.launchAndOpenSampleBook()
app.waitForReader()
let content = app.otherElements[IDs.readerContent]
XCTAssertTrue(content.waitForExistence(timeout: 5))
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
XCTAssertTrue(app.otherElements[IDs.readerTopToolbar].waitForExistence(timeout: 3))
XCTAssertTrue(app.otherElements[IDs.readerBottomToolbar].waitForExistence(timeout: 3))
}
}
```
### 7.4 设置面板
```swift
final class SettingsPanelTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testOpenChangeFontAndCloseSettings() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
let fontValue = app.staticTexts[IDs.settingsFontValue]
XCTAssertTrue(fontValue.waitForExistence(timeout: 3))
let before = fontValue.label
app.buttons[IDs.settingsFontIncrease].tap()
XCTAssertNotEqual(before, fontValue.label)
app.buttons[IDs.settingsFontDecrease].tap()
app.buttons[IDs.settingsDone].tap()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
}
}
```
### 7.5 翻页模式 Smoke Test
```swift
final class DisplayTypeTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testOpenWithPageCurl() {
app.launchAndOpenSampleBook(displayType: "pagecurl")
app.waitForReader()
}
func testOpenWithHorizontalScroll() {
app.launchAndOpenSampleBook(displayType: "scroll")
app.waitForReader()
}
func testOpenWithVerticalScroll() {
app.launchAndOpenSampleBook(displayType: "vertical")
app.waitForReader()
}
}
```
## 8. P1 测试用例
P0 稳定后再加入:
- 目录打开、目录列表存在、点击第一项返回阅读器。
- 添加书签后按钮状态变化,打开书签列表存在对应 cell。
- 设置面板切换行距、栏数、主题后阅读器仍可用。
- 横滑/竖滑模式下拖动内容区域后 `demo.reader.state` 的 page 变化。
- 高亮列表打开和空态显示。
搜索和文本选择高亮不建议作为第一批 UI 自动化。它们依赖文本命中、长按选择、浮层菜单和异步渲染,flaky 风险更高。可以先用单元测试覆盖搜索引擎,用 P1/P2 再补 UI 冒烟。
## 9. CI 集成
### 9.1 手动命令
```bash
xcodebuild test \
-workspace ReadViewDemo/ReadViewDemo.xcworkspace \
-scheme ReadViewDemo \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:ReadViewDemoUITests
```
如果本机没有 `iPhone 16`,先查看可用模拟器:
```bash
xcrun simctl list devices available
```
### 9.2 GitHub Actions
```yaml
name: UI Tests
on:
pull_request:
branches: [main, develop]
push:
branches: [main, develop]
jobs:
ui-tests:
runs-on: macos-15
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
- name: Install Pods
run: cd ReadViewDemo && pod install
- name: Run UI Tests
run: |
xcodebuild test \
-workspace ReadViewDemo/ReadViewDemo.xcworkspace \
-scheme ReadViewDemo \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
-only-testing:ReadViewDemoUITests \
-resultBundlePath ui-test-results.xcresult
- name: Upload xcresult
if: always()
uses: actions/upload-artifact@v4
with:
name: ui-test-results
path: ui-test-results.xcresult
```
CI 首批只跑 P0。P1 可以先本地或 nightly 跑,稳定后再进入 PR 必过。
## 10. 实施顺序
| 阶段 | 内容 | 验收标准 |
|------|------|----------|
| 1 | 补 P0 identifiers | XCUITest 能定位内容区、工具栏、设置控件 |
| 2 | 增加 `demo.reader.state` | 能通过 hidden label 读到 opened/page/display/toolbar |
| 3 | 创建 `ReadViewDemoUITests` | `xcodebuild test` 能发现测试 target |
| 4 | 实现 P0 用例 | 本地模拟器连续跑 3 次通过 |
| 5 | 接入 CI | PR 中 P0 UI Tests 可运行并上传 xcresult |
| 6 | 扩展 P1 | 目录、书签、主题、翻页断言逐步加入 |
## 11. 工时预估
| 工作 | 预估 |
|------|------|
| P0 identifiers + Demo 状态标签 | 2-3h |
| UI Test Target + helpers | 1-2h |
| P0 测试实现与稳定性调整 | 4-6h |
| CI 接入 | 1-2h |
| P1 首批扩展 | 4-8h |
首个可用版本建议按 1.5 到 2 天排期。后续每加入一组复杂交互,先本地观察稳定性,再进入 CI 必过集合。
## 12. 风险与约束
- UI 自动化不能依赖固定动画时间,优先使用 `waitForExistence` 和状态标签。
- 不要用截图像素对比作为 PR 必过项;截图附件适合作为人工排查材料。
- 样本书必须稳定存在,优先使用 `回归验证样本.txt`
- 工具栏默认可能隐藏,测试应先点击阅读区域中部再断言底部按钮。
- 设置面板、目录面板是导航/弹出结构,测试应等待面板根控件,而不是立即点击内部控件。
- 若未来 Reader UI 迁移到真实 AppDemo-only 的 `demo.reader.state` 不应进入 SDK 公共 API。
+687
View File
@@ -0,0 +1,687 @@
# ReadViewSDK UML 类图
> 最后更新:2026-06-04
> 图表格式:Mermaid(可在 GitHub / VS Code / Mermaid Live Editor 中渲染)
---
## 1. 整体模块关系
```mermaid
graph TB
subgraph EPUBCore
Parser[RDEPUBParser]
Pub[RDEPUBPublication]
Resolver[RDEPUBResourceResolver]
Session[RDEPUBReadingSession]
WebView[RDEPUBWebView]
Paginator[RDEPUBPaginator]
SearchEngine[RDEPUBHTMLSearchEngine]
end
subgraph EPUBTextRendering
Builder[RDEPUBTextBookBuilder]
Renderer[RDEPUBDTCoreTextRenderer]
Pipeline[RDEPUBTextTypesetterPipeline]
Counter[RDEPUBChapterPageCounter]
FrameFactory[RDEPUBCoreTextPageFrameFactory]
BreakPolicy[RDEPUBPageBreakPolicy]
BookCache[RDEPUBTextBookCache]
ChapterData[RDEPUBChapterData]
IndexTable[RDEPUBTextIndexTable]
end
subgraph RDReaderView
ReaderView[RDReaderView]
FlowLayout[RDReaderFlowLayout]
Preload[RDReaderPreloadController]
Spread[RDReaderSpreadResolver]
TapRegion[RDReaderTapRegionHandler]
end
subgraph EPUBUI
Controller[RDEPUBReaderController]
Context[RDEPUBReaderContext]
Runtime[RDEPUBReaderRuntime]
PaginationCoord[RDEPUBReaderPaginationCoordinator]
Loader[RDEPUBChapterLoader]
RuntimeStore[RDEPUBChapterRuntimeStore]
DiskCache[RDEPUBChapterSummaryDiskCache]
PageMap[RDEPUBBookPageMap]
Config[RDEPUBReaderConfiguration]
end
Parser --> Pub
Pub --> Resolver
Session --> Pub
WebView --> Pub
WebView --> Resolver
Paginator --> Parser
Builder --> Renderer
Builder --> Pipeline
Builder --> Counter
Counter --> FrameFactory
FrameFactory --> BreakPolicy
Builder --> BookCache
ChapterData --> IndexTable
Controller --> Context
Context --> Runtime
Runtime --> Loader
Runtime --> RuntimeStore
Runtime --> DiskCache
PaginationCoord --> Context
PaginationCoord --> PageMap
Loader --> RuntimeStore
Loader --> DiskCache
Controller --> ReaderView
Controller --> Session
Controller --> Builder
ReaderView --> FlowLayout
ReaderView --> Preload
ReaderView --> Spread
ReaderView --> TapRegion
```
---
## 2. EPUBCore 核心类图
```mermaid
classDiagram
class RDEPUBParser {
+metadata: RDEPUBMetadata
+manifest: [String: RDEPUBManifestItem]
+spine: [RDEPUBSpineItem]
+tableOfContents: [EPUBTableOfContentsItem]
+extractionRootURL: URL
+opfURL: URL
+opfDirectoryURL: URL
+parse(epubURL: URL) throws
+makePublication() RDEPUBPublication
+htmlString(forRelativePath:) String?
+htmlString(forSpineIndex:) String?
+coverImage() UIImage?
+resourceURL(forRelativePath:) URL
+fileURL(forRelativePath:) URL?
}
class RDEPUBPublication {
+parser: RDEPUBParser
+resourceResolver: RDEPUBResourceResolver
+metadata: RDEPUBMetadata
+manifest: [RDEPUBManifestItem]
+spine: [RDEPUBSpineItem]
+tableOfContents: [EPUBTableOfContentsItem]
+layout: RDEPUBLayout
+readingProfile: RDEPUBReadingProfile
+readingProgression: RDEPUBReadingProgression
+bookIdentifier: String?
+fixedLayoutSpreadEnabled(preferences:viewportSize:) Bool
+makeFixedSpreads(preferences:viewportSize:) [EPUBFixedSpread]
}
class RDEPUBResourceResolver {
+parser: RDEPUBParser
+fileURL(forRelativePath:) URL?
+resourceURL(forRelativePath:) URL
+normalizedHref(_:relativeToSpineIndex:) String?
+normalizedLocation(_:relativeToSpineIndex:bookIdentifier:) RDEPUBLocation?
+spineIndex(forNormalizedHref:) Int?
+spineIndex(for:) Int?
}
class RDEPUBReadingSession {
+publication: RDEPUBPublication
+navigatorState: RDEPUBNavigatorState
+activePages: [EPUBPage]
+activeChapters: [EPUBChapterInfo]
+currentViewport: RDEPUBViewport?
+currentReadingContext: RDEPUBReadingContext?
+transition(to: RDEPUBNavigatorState)
+setActiveSnapshot(_:)
+queueNavigation(to:relativeToSpineIndex:bookIdentifier:) Int
+updateReadingContext(...)
+makePaginationSnapshot(pageCounts:preferences:layoutContext:)
}
class RDEPUBWebView {
+publication: RDEPUBPublication?
+currentRenderRequest: RDEPUBRenderRequest?
+webView: WKWebView
+delegate: RDEPUBWebViewDelegate?
+load(publication:request:)
+reset()
}
class RDEPUBNavigatorState {
<<enum>>
initializing
loading
idle
jumping
moving
repaginating
+isStableForSnapshotApplication: Bool
}
RDEPUBParser --> RDEPUBPublication : creates
RDEPUBPublication --> RDEPUBResourceResolver : owns
RDEPUBReadingSession --> RDEPUBPublication : wraps
RDEPUBReadingSession --> RDEPUBNavigatorState : manages
RDEPUBWebView --> RDEPUBPublication : uses
RDEPUBWebView --> RDEPUBResourceResolver : uses
```
---
## 3. EPUBTextRendering 类图
```mermaid
classDiagram
class RDEPUBTextBookBuilder {
+renderer: RDEPUBTextRenderer
+cache: RDEPUBTextBookCache?
+layoutConfig: RDEPUBTextLayoutConfig
+renderPipeline: RDEPUBChapterRenderPipeline
+paginationPipeline: RDEPUBChapterPaginationPipeline
+tailNormalizer: RDEPUBChapterTailNormalizer
+build(parser:publication:pageSize:style:) throws RDEPUBTextBook
+buildChapter(parser:publication:spineIndex:pageSize:style:) throws RDEPUBTextChapterBuildResult?
}
class RDEPUBTextRenderer {
<<protocol>>
+renderChapter(request:) throws RDEPUBRenderedChapterContent
}
class RDEPUBDTCoreTextRenderer {
+renderChapter(request:) throws RDEPUBRenderedChapterContent
+renderChapter(html:baseURL:style:) throws RDEPUBRenderedChapterContent
}
class RDEPUBTextTypesetterPipeline {
+makeRequest(from: RDEPUBTypesettingInput) RDEPUBTypesettingOutput
}
class RDEPUBChapterPageCounter {
+factory: RDEPUBCoreTextPageFrameFactory
+layoutFrames(fragmentOffsets:) [RDEPUBTextLayoutFrame]
}
class RDEPUBCoreTextPageFrameFactory {
+attributedString: NSAttributedString
+pageSize: CGSize
+config: RDEPUBTextLayoutConfig
+makeFrames(...) [RDEPUBTextLayoutFrame]
}
class RDEPUBPageBreakPolicy {
+lineIsInAvoidPageBreakInsideBlock(_:) Bool
+lineIsInKeepWithNextBlock(_:) Bool
+adjustedRange(from:totalLength:lineRanges:factory:) RDEPUBPageBreakDecision
}
class RDEPUBTextBook {
+chapters: [RDEPUBTextChapter]
+pages: [RDEPUBTextPage]
+indexTable: RDEPUBTextIndexTable
+positionConverter: RDEPUBTextPositionConverter
+chapterData(for href:) RDEPUBChapterData?
+chapterData(forPageNumber:) RDEPUBChapterData?
}
class RDEPUBTextChapter {
+chapterIndex: Int
+spineIndex: Int
+href: String
+title: String
+attributedContent: NSAttributedString
+fragmentOffsets: [String: Int]
+pages: [RDEPUBTextPage]
}
class RDEPUBTextPage {
+absolutePageIndex: Int
+chapterIndex: Int
+spineIndex: Int
+contentRange: NSRange
+metadata: RDEPUBTextPageMetadata
}
class RDEPUBTextBookCache {
+schemaVersion: Int
+load(key:) [String: RDEPUBTextChapterPaginationCache]?
+save(_:key:)
+invalidateAll()
}
class RDEPUBChapterData {
+chapterIndex: Int
+spineIndex: Int
+href: String
+title: String
+pageCount: Int
+page(containing:) RDEPUBTextPage?
+pageNumber(containing:) Int?
+absoluteRange(for location:) ClosedRange<Int>?
}
class RDEPUBTextIndexTable {
+chapterStartOffsets: [Int]
+totalCharacterCount: Int
+anchor(forAbsoluteIndex:in:) RDEPUBTextAnchor
+pageNumber(for:in:) Int
+globalIndex(for:) Int
}
RDEPUBTextBookBuilder --> RDEPUBTextRenderer : uses
RDEPUBDTCoreTextRenderer ..|> RDEPUBTextRenderer
RDEPUBTextBookBuilder --> RDEPUBTextTypesetterPipeline : uses
RDEPUBTextBookBuilder --> RDEPUBChapterPageCounter : uses
RDEPUBChapterPageCounter --> RDEPUBCoreTextPageFrameFactory : uses
RDEPUBCoreTextPageFrameFactory --> RDEPUBPageBreakPolicy : uses
RDEPUBTextBookBuilder --> RDEPUBTextBookCache : uses
RDEPUBTextBook --> RDEPUBTextChapter : contains
RDEPUBTextChapter --> RDEPUBTextPage : contains
RDEPUBTextBook --> RDEPUBTextIndexTable : computes
RDEPUBChapterData --> RDEPUBTextChapter : wraps
RDEPUBChapterData --> RDEPUBTextIndexTable : uses
```
---
## 4. ReaderView 类图
```mermaid
classDiagram
class RDReaderView {
+currentPage: Int
+currentDisplayType: DisplayType
+pageProvider: RDReaderPageProvider?
+delegate: RDReaderDelegate?
+landscapeDualPageEnabled: Bool
+pageDirection: PageDirection
+coverPageIndex: Int?
+pagesPerScreen: Int
+transitionToPage(pageNum:animated:)
+reloadData()
+switchReaderDisplayType(_:)
}
class DisplayType {
<<enum>>
pageCurl
horizontalScroll
verticalScroll
}
class RDReaderPageProvider {
<<protocol>>
+numberOfPages(in:) Int
+readerView(_:viewForPageAt:reusableView:) UIView
+pageIdentifier(in:index:) String?
}
class RDReaderFlowLayout {
+displayType: DisplayType
+isLandscapeDualPage: Bool
+coverPageIndex: Int?
+pagesPerScreen: Int
+currentPage: Int
}
class RDReaderPreloadController {
+radius: Int
+pageViewForDisplay(pageNum:environment:contentViewProvider:) UIView?
+takePreloadedView(for:) UIView?
+prime(around:preferredForward:parentView:environment:contentViewProvider:)
+invalidate(environment:)
}
class RDReaderSpreadResolver {
+isFullScreenPage(...) Bool
+dualPagePair(for:totalPages:coverPageIndex:) (Int, Int?)
+nextPage(from:totalPages:pagesPerScreen:coverPageIndex:forward:) Int?
}
class RDReaderTapRegionHandler {
+resolveTapEvent(point:viewFrame:isToolViewVisible:) TapEvent
}
class TapEvent {
<<enum>>
none
left
center
right
}
class RDReaderPagingController {
+isTransitioning: Bool
+didBuildUI: Bool
+pendingTransitionRequest: PageTransitionRequest?
+shouldQueuePageTransition(_:currentDisplayType:) Bool
+finishPageCurlTransition() PageTransitionRequest?
}
class RDReaderContentCell {
+containerView: UIView?
}
class RDReaderPageChildViewController {
+contentView: UIView?
+pageNum: Int
}
RDReaderView --> DisplayType : uses
RDReaderView --> RDReaderPageProvider : delegates
RDReaderView --> RDReaderFlowLayout : owns
RDReaderView --> RDReaderPreloadController : owns
RDReaderView --> RDReaderSpreadResolver : owns
RDReaderView --> RDReaderTapRegionHandler : owns
RDReaderView --> RDReaderPagingController : owns
RDReaderView --> RDReaderContentCell : creates
RDReaderView --> RDReaderPageChildViewController : creates
RDReaderTapRegionHandler --> TapEvent : produces
```
---
## 5. EPUBUI 控制器与运行时类图
```mermaid
classDiagram
class RDEPUBReaderController {
+delegate: RDEPUBReaderDelegate?
+configuration: RDEPUBReaderConfiguration
+currentLocation: RDEPUBLocation?
+currentPageNumber: Int?
+currentSelection: RDEPUBSelection?
+highlights: [RDEPUBHighlight]
+bookmarks: [RDEPUBBookmark]
+tableOfContents: [EPUBTableOfContentsItem]
+readerView: RDReaderView
+readerContext: RDEPUBReaderContext
+runtime: RDEPUBReaderRuntime?
}
class RDEPUBReaderContext {
+parser: RDEPUBParser?
+publication: RDEPUBPublication?
+readingSession: RDEPUBReadingSession?
+textBook: RDEPUBTextBook?
+bookPageMap: RDEPUBBookPageMap?
+configuration: RDEPUBReaderConfiguration
+persistence: RDEPUBReaderPersistence?
+currentRenderSignature() String
+chapterCacheKey(forSpineIndex:) RDEPUBChapterCacheKey
+chapterCacheKey(forSpineIndex:precomputedContentHash:renderSignature:) RDEPUBChapterCacheKey
+makeTextBookBuilder(layoutConfig:) RDEPUBTextBookBuilder
}
class RDEPUBReaderRuntime {
+chapterLoader: RDEPUBChapterLoader
+chapterRuntimeStore: RDEPUBChapterRuntimeStore
+summaryDiskCache: RDEPUBChapterSummaryDiskCache
+viewportMonitor: RDEPUBViewportMonitor
+applyBookPageMap(_:restoreLocation:)
+refreshBookPageMapInPlace(_:)
}
class RDEPUBReaderPaginationCoordinator {
-context: RDEPUBReaderContext
+paginatePublication(restoreLocation:)
+paginateMetadataOnly(token:restoreLocation:)
+repaginatePreservingCurrentLocation()
-restoreBookPageMapIfPossible(publication:) RDEPUBBookPageMap?
-buildPageMap(from:summaries:) RDEPUBBookPageMap
}
class RDEPUBChapterLoader {
-context: RDEPUBReaderContext
+loadChapter(spineIndex:store:) async throws RDEPUBRuntimeChapter
+loadChapterSynchronouslyForMigration(spineIndex:store:) throws RDEPUBRuntimeChapter
}
class RDEPUBChapterRuntimeStore {
+chapterDataCache: [Int: RDEPUBRuntimeChapter]
+pageCountCache: RDEPUBPageCountCache
+imageCache: NSCache
+setCurrentChapter(spineIndex:totalSpineCount:windowRadius:)
+chapterData(for:) RDEPUBRuntimeChapter?
+pageCount(for:) RDEPUBRuntimePageCount?
}
class RDEPUBChapterSummaryDiskCache {
+cacheDirectory: URL
+read(for:) RDEPUBChapterSummary?
+readAll(keys:) ([Int: RDEPUBChapterSummary], RDEPUBBookPageMap.Builder)
+write(summary:for:)
+flushPendingWrites()
+isCacheComplete(keys:) Bool
}
class RDEPUBBookPageMap {
+totalChapters: Int
+totalPages: Int
+chapters: [EPUBChapterInfo]
+pages: [EPUBPage]
}
class RDEPUBChapterCacheKey {
+bookID: String
+spineIndex: Int
+renderSignature: String
+chapterContentHash: String
}
class RDEPUBReaderConfiguration {
+fontSize: CGFloat
+fontChoice: FontChoice
+lineHeightMultiple: CGFloat
+numberOfColumns: Int
+theme: ReaderTheme
+displayType: DisplayType
+onDemandChapterWindowSize: Int
+metadataParsingConcurrency: Int
}
RDEPUBReaderController --> RDEPUBReaderContext : owns
RDEPUBReaderController --> RDEPUBReaderRuntime : owns
RDEPUBReaderController --> RDEPUBReaderPaginationCoordinator : owns
RDEPUBReaderContext --> RDEPUBReaderConfiguration : holds
RDEPUBReaderRuntime --> RDEPUBChapterLoader : owns
RDEPUBReaderRuntime --> RDEPUBChapterRuntimeStore : owns
RDEPUBReaderRuntime --> RDEPUBChapterSummaryDiskCache : owns
RDEPUBReaderPaginationCoordinator --> RDEPUBReaderContext : uses
RDEPUBChapterLoader --> RDEPUBReaderContext : uses
RDEPUBChapterLoader --> RDEPUBChapterRuntimeStore : uses
RDEPUBChapterLoader --> RDEPUBChapterSummaryDiskCache : uses
RDEPUBChapterSummaryDiskCache --> RDEPUBChapterCacheKey : uses
RDEPUBBookPageMap --> EPUBChapterInfo : contains
RDEPUBBookPageMap --> EPUBPage : contains
```
---
## 6. 数据模型关系图
```mermaid
classDiagram
class RDEPUBLocation {
+bookIdentifier: String?
+href: String
+progression: Double
+lastProgression: Double?
+fragment: String?
+rangeAnchor: RDEPUBTextRangeAnchor?
}
class RDEPUBTextAnchor {
+fileIndex: Int
+row: Int
+column: Int
+chapterOffset: Int
+fragmentID: String?
+spineIndex: Int
}
class RDEPUBTextRangeAnchor {
+start: RDEPUBTextAnchor
+end: RDEPUBTextAnchor
+nsRange: NSRange
}
class RDEPUBSelection {
+bookIdentifier: String?
+location: RDEPUBLocation
+text: String
+rangeInfo: String?
+createdAt: Date
}
class RDEPUBHighlight {
+id: String
+bookIdentifier: String?
+location: RDEPUBLocation
+text: String
+style: RDEPUBHighlightStyle
+color: String
+note: String?
+createdAt: Date
+uiColor: UIColor
}
class RDEPUBBookmark {
+id: String
+bookIdentifier: String?
+location: RDEPUBLocation
+chapterTitle: String?
+note: String?
+createdAt: Date
}
class RDEPUBAnnotation {
+id: String
+kind: RDEPUBAnnotationKind
+location: RDEPUBLocation
+text: String?
+bookmark: RDEPUBBookmark?
+highlight: RDEPUBHighlight?
}
class EPUBPage {
+spineIndex: Int
+chapterIndex: Int
+pageIndexInChapter: Int
+totalPagesInChapter: Int
+chapterTitle: String
+fixedSpread: EPUBFixedSpread?
}
class EPUBChapterInfo {
+spineIndex: Int
+title: String
+pageCount: Int
}
class RDEPUBViewport {
+resources: [RDEPUBViewportResource]
+visiblePageNumber: Int
+chapterIndex: Int?
+isFixedLayout: Bool
}
class RDEPUBReadingContext {
+location: RDEPUBLocation
+viewport: RDEPUBViewport
+pageNumber: Int
+chapterIndex: Int?
}
RDEPUBLocation --> RDEPUBTextRangeAnchor : optional
RDEPUBTextRangeAnchor --> RDEPUBTextAnchor : start/end
RDEPUBSelection --> RDEPUBLocation : references
RDEPUBHighlight --> RDEPUBLocation : references
RDEPUBBookmark --> RDEPUBLocation : references
RDEPUBAnnotation --> RDEPUBLocation : references
RDEPUBReadingContext --> RDEPUBLocation : holds
RDEPUBReadingContext --> RDEPUBViewport : holds
```
---
## 7. 后台解析并发模型
```mermaid
sequenceDiagram
participant Main as Main Thread
participant BG as Background Queue
participant OQ as OperationQueue (N workers)
participant Disk as Disk Cache
Main->>BG: paginateMetadataOnly(token)
BG->>BG: 预计算 contentHash (串行)
BG->>Disk: readAll(catalog) 批量读取缓存
BG->>Main: refreshBookPageMapInPlace (缓存部分)
BG->>BG: waitForReadingInteractionToSettle (0.8s)
loop 每个未缓存章节
BG->>OQ: addOperation(spineIndex)
OQ->>OQ: buildChapter (渲染+分页)
OQ->>OQ: chapterCacheKey (复用预计算hash)
OQ->>Disk: write(summary) 异步写盘
OQ->>OQ: resultLock: 累加结果
alt 每32章或最后一章
OQ->>Main: refreshBookPageMapInPlace
end
end
OQ->>BG: waitUntilAllOperationsAreFinished
BG->>Disk: flushPendingWrites
BG->>Main: refreshBookPageMapInPlace (最终)
```
---
## 8. 缓存键与失效策略
```mermaid
graph LR
subgraph CacheKey["RDEPUBChapterCacheKey"]
A[bookID]
B[spineIndex]
C[renderSignature]
D[chapterContentHash]
end
subgraph RenderSignature["renderSignature 组成"]
E[fontName]
F[fontSize]
G[lineHeightMultiple]
H[lineSpacing]
I[layoutConfig.cacheSignature]
J[schemaVersion]
end
subgraph CacheSignature["layoutConfig.cacheSignature"]
K[frameWidth]
L[frameHeight]
M[edgeInsets]
N[numberOfColumns]
O[columnGap]
P[avoidOrphans/Widows]
Q[hyphenation]
R[imageMaxHeightRatio]
end
C --> E & F & G & H & I & J
I --> K & L & M & N & O & P & Q & R
style CacheKey fill:#e1f5fe
style RenderSignature fill:#fff3e0
style CacheSignature fill:#e8f5e9
```
+16 -26
View File
@@ -1,37 +1,27 @@
# ReadViewSDK 文档索引
## 架构与规范
> 最后更新:2026-06-04
---
## 核心文档
| 文档 | 说明 |
|------|------|
| [ARCHITECTURE.md](ARCHITECTURE.md) | 四层架构总览EPUBCore → EPUBTextRendering → RDReaderView → EPUBUI)、数据流、分页模式、位置模型、已知限制 |
| [CODING_STYLE.md](CODING_STYLE.md) | 命名规范(RD/RDEPUB 前缀)、分层规则、UI 布局规范、代码风格细则、extension 拆分、SS→RD 迁移计划 |
| [EPUB_MAINTENANCE.md](EPUB_MAINTENANCE.md) | 文件职责表、DTCoreText 渲染管线、常见排查场景、推荐日志断点、阅读器后续能力规划 |
| [ARCHITECTURE.md](ARCHITECTURE.md) | 系统架构总览:分层架构、核心数据流、缓存架构、目录结构、设计模式 |
| [UML_CLASS_DIAGRAMS.md](UML_CLASS_DIAGRAMS.md) | UML 类图(Mermaid 格式):模块关系、核心类图、并发模型、缓存键设计 |
| [BUSINESS_LOGIC.md](BUSINESS_LOGIC.md) | 业务逻辑详解:EPUB 解析、文本渲染管线、后台解析优化、翻页容器、标注系统、搜索、设置 |
## 模块功能实现逻辑
| 文档 | 对应模块 | 说明 |
|------|----------|------|
| [EPUBCore_功能实现逻辑.md](EPUBCore_功能实现逻辑.md) | `Sources/RDReaderView/EPUBCore/`31 Swift + 2 资源) | EPUB 解析全流程(ZIP→container.xml→OPF→spine/TOC)、阅读会话状态机、离屏分页测量、`ss-reader://` 资源协议、JS 桥接(6 种消息)、WebView 渲染管线、文本锚点定位、渲染请求模型、缓存策略 |
| [EPUBTextRendering_功能实现逻辑.md](EPUBTextRendering_功能实现逻辑.md) | `Sources/RDReaderView/EPUBTextRendering/`13 文件) | DTCoreText HTML→NSAttributedString 渲染管线、片段标记注入/提取、CoreText 分页引擎(含语义边界调整)、文本索引表、Location↔PageNumber 双向转换、分页缓存、性能采样、全文搜索引擎、纯文本构建器 |
| [RDReaderView_功能实现逻辑.md](RDReaderView_功能实现逻辑.md) | `Sources/RDReaderView/ReaderView/`(5 文件) | 三种显示模式(pageCurl/horizontalScroll/verticalScroll)、DataSource/Delegate 协议、点击三区域翻页、工具栏动画、双页配对与哨兵页、横竖屏适配、RTL 支持 |
| [EPUBUI_功能实现逻辑.md](EPUBUI_功能实现逻辑.md) | `Sources/RDReaderView/EPUBUI/`19 文件) | RDEPUBReaderController 全生命周期、三条渲染路径分发、配置变更检测与响应、工具栏/目录/高亮/书签/搜索/设置面板交互流、阅读位置持久化、主题管理、CoreText 页面交互 |
## 方案讨论与规划文档
## 专题文档
| 文档 | 说明 |
|------|------|
| [阅读器功能开发计划.md](阅读器功能开发计划.md) | 渲染质量三方对比(ReadViewSDK vs WXRead)、功能开发计划与落地状态、优先级路线图 |
| [架构对比分析_WXRead_vs_ReadViewSDK.md](架构对比分析_WXRead_vs_ReadViewSDK.md) | ReadViewSDK 与 WXRead 的逐项架构核查,确认主链路复刻完成度 |
| [ReflowableEPUB_WXReadRenderer_Design.md](FeatureSolution/ReflowableEPUB_WXReadRenderer_Design.md) | 基于读书的 CoreText 渲染架构,设计 Reflowable EPUB 的增强文本渲染方案(CSS 分层、类型器升级) |
| [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s |
## 测试与质量
## 项目信息
| 文档 | 说明 |
|------|------|
| [UI自动化测试工作文档.md](UI自动化测试工作文档.md) | XCUITest UI 自动化测试完整工作计划:现状分析、accessibilityIdentifier 补充清单、UI Test Target 创建、测试用例设计(书库/阅读器/工具栏/设置/书签/高亮/目录/截图)、CI 集成方案、实施检查清单 |
## 目录约定
- `Doc/`:项目级文档(架构、规范、维护指南、模块实现逻辑)
- `Doc/FeatureSolution/`:方案讨论文档(由 `discuss-sdk-feature-solution` skill 生成)
- **模块总数:** 4 个(EPUBCore、EPUBTextRendering、RDReaderView、EPUBUI
- **Swift 文件数:** 138 个
- **测试用例数:** 18 个测试类,66 个测试方法
- **最低 iOS 版本:** 15.6
- **构建方式:** CocoaPods(本地 pod
@@ -1,379 +0,0 @@
# 双层 PageMap 方案:估算 + 精确混合
> 目标:大书首次打开时,快速给出全书总页数(< 1s),同时保留当前窗口章节的精确分页结果。
> 约束:估算值不覆盖已精确分页的 quick-window 章节,不写入磁盘缓存,不伪装成精确 summary。
---
## 1. 问题
当前 `paginateMetadataOnly` 对全书逐章做完整渲染 + CoreText 分页 + 写磁盘缓存。2470 章在真机上串行约 867s(并发 6 约 256s)。在此期间:
- 总页数持续变化(从 partial 到 full
- 进度条不准确
- 目录页码缺失
用户看到的是"总页数从 0 慢慢涨到 18595",体验差。
---
## 2. 方案概述
```
quick open(现有,不变)
→ 当前窗口 3-5 章精确分页
→ applyBookPageMap(partialMap, restoreLocation:)
→ 用户可立即阅读
估算补全(新增,< 1s
→ 遍历所有非窗口章节,快速估算 pageCount
→ 与 quick open 的精确值合并为 mixedMap
→ refreshBookPageMapInPlace(mixedMap)
→ 用户立即看到接近真实的全书总页数
精确解析(现有,后台逐步)
→ paginateMetadataOnly 逐章精确渲染
→ 每 32 章用精确值替换 mixedMap 中的估算值
→ refreshBookPageMapInPlace(updatedMap)
→ 总页数微调至精确值
```
---
## 3. 核心约束
### 3.1 不覆盖已精确分页的章节
quick open 已经对当前窗口章(`initialWindowSpineIndices` 返回的 3-5 章)做了完整渲染 + CoreText 分页,生成了精确的 `partialMap`。这个 map 立刻用于 `applyBookPageMap(restoreLocation:)` 恢复阅读位置。
估算层**不能**用全书估算 map 整体替换这个 partial map,否则:
- 当前窗口章的精确 pageCount 被冲掉
- `pageNumber(for:)` 的绝对页号映射漂移
- `prepareOnDemandChapter(forAbsolutePageNumber:)` 定位出错
正确做法:**精确局部 + 估算尾部**的混合 map。窗口章保留精确值,其余章节填估算值。
### 3.2 估算值只存内存,不落盘
估算的 pageCount 必须**只存在于内存态的 BookPageMap**中,不能写入:
- `RDEPUBChapterSummaryDiskCache`(否则 loader 误以为有精确 pageRanges 可复用)
- `RDEPUBPageCountCache`(否则 loader 跳过完整分页)
- 任何磁盘持久化存储
后续 `paginateMetadataOnly` 的精确结果会逐章替换估算值,并写入磁盘缓存。磁盘上永远只有精确数据。
### 3.3 估算方法必须足够快
目标:< 1s 完成全书估算(2470 章)。每章允许 ~0.4ms。
可行方法:遍历 spine,对每章只做 HTML 纯文本提取(`NSString` 去标签),用 `textLength / estimatedCharsPerPage` 得到页数。不做 CSS 渲染、不做NSAttributedString 构建、不做 CoreText 分页。
---
## 4. 数据流
```
┌─────────────────────────────┐
│ quick open (现有) │
│ 当前窗口章 → 精确 partialMap │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ estimateRemainingChapters │
│ 非窗口章 → 估算 pageCount │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ mergePreciseAndEstimated │
│ 精确窗口 + 估算尾部 → mixedMap │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ refreshBookPageMapInPlace │
│ 用户立即看到全书总页数 │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ paginateMetadataOnly (现有) │
│ 逐章精确渲染,替换估算值 │
│ 每 32 章刷新一次 │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ 最终精确 BookPageMap │
└─────────────────────────────┘
```
---
## 5. 实现细节
### 5.1 估算方法
`RDEPUBReaderPaginationCoordinator` 中新增:
```swift
/// 使
/// spineIndex -> estimatedPageCount
private func estimateChapterPageCounts(
for spineIndices: [Int],
publication: RDEPUBPublication,
parser: RDEPUBParser,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) -> [Int: Int] {
let charsPerPage = estimatedCharsPerPage(pageSize: pageSize, style: style)
var result: [Int: Int] = [:]
for spineIndex in spineIndices {
let item = publication.spine[spineIndex]
guard item.linear,
item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
let htmlString = parser.htmlString(forRelativePath: item.href) else {
continue
}
// HTML
let plainText = htmlString
.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
let textLength = plainText.count
let estimatedPages = max(1, Int(ceil(Double(textLength) / Double(charsPerPage))))
result[spineIndex] = estimatedPages
}
return result
}
///
private func estimatedCharsPerPage(
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) -> Double {
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
let columns = max(1, layoutConfig.numberOfColumns)
let columnGap = layoutConfig.columnGap
let insets = layoutConfig.edgeInsets
let usableWidth = pageSize.width - insets.left - insets.right - CGFloat(columns - 1) * columnGap
let usableHeight = pageSize.height - insets.top - insets.bottom
let lineHeight = style.fontSize * style.lineHeightMultiple
let linesPerPage = Int(usableHeight / lineHeight) * columns
// 0.5 * fontSize 0.6 * fontSize
// 0.55
let avgCharWidth = style.fontSize * 0.55
let charsPerLine = max(1, Int(usableWidth / avgCharWidth))
return Double(linesPerPage * charsPerLine)
}
```
### 5.2 混合 Map 构建
```swift
/// BookPageMap
private func buildMixedPageMap(
preciseEntries: [Int: RDEPUBBookPageMapEntry], // quick open
estimatedPageCounts: [Int: Int], // pageCount
catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for item in catalog {
if let precise = preciseEntries[item.spineIndex] {
//
builder.add(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: precise.pageCount,
fragmentOffsets: precise.fragmentOffsets
)
} else if let estimatedCount = estimatedPageCounts[item.spineIndex] {
// fragmentOffsets
builder.add(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: estimatedCount,
fragmentOffsets: [:]
)
}
}
return builder.build()
}
```
### 5.3 集成点
`paginateTextPublication` 中,quick open 完成后、`paginateMetadataOnly` 之前插入:
```swift
// quick open partialMap
let quickWindowChapters = try loadInitialRuntimeChapters(...)
let partialMap = makePartialPageMap(from: quickWindowChapters)
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
// map
let windowSpineIndices = Set(quickWindowChapters.map(\.spineIndex))
let allBuildable = allBuildableSpineIndices(in: publication)
let remainingSpineIndices = allBuildable.filter { !windowSpineIndices.contains($0) }
let estimatedCounts = estimateChapterPageCounts(
for: remainingSpineIndices,
publication: publication,
parser: parser,
pageSize: pageSize,
style: style
)
let preciseEntries = makePreciseEntries(from: quickWindowChapters)
let catalog = allBuildable.map { ... } // paginateMetadataOnly catalog
let mixedMap = buildMixedPageMap(
preciseEntries: preciseEntries,
estimatedPageCounts: estimatedCounts,
catalog: catalog
)
DispatchQueue.main.async {
runtime.refreshBookPageMapInPlace(mixedMap)
}
//
paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
```
### 5.4 精确值替换估算值
`paginateMetadataOnly` 现有逻辑不需要大改。只需确保:
1. `summariesBySpineIndex` 初始包含缓存命中 + 估算值(作为 fallback
2. 每完成一章精确解析,用精确值覆盖该 spineIndex 的条目
3. `buildPageMap` 时,精确值自然替换估算值
具体改动:在 `paginateMetadataOnly``cachedSummaries` 之后,把估算值也注入 `summariesBySpineIndex`,但标记为估算(比如用一个 `Set<Int>` 记录哪些是估算值)。精确解析完成后,精确值会自动覆盖同 key 的估算值。
```swift
// paginateMetadataOnly cachedSummaries
var summariesBySpineIndex = cachedSummaries
//
for (spineIndex, pageCount) in estimatedPageCounts {
if !cachedSpineIndices.contains(spineIndex) {
// summary pageCount
// pageRanges fragmentOffsets
summariesBySpineIndex[spineIndex] = RDEPUBChapterSummary.estimated(
pageCount: pageCount
)
}
}
```
需要在 `RDEPUBChapterSummary` 上新增一个工厂方法:
```swift
extension RDEPUBChapterSummary {
/// pageCount pageRanges
///
static func estimated(pageCount: Int) -> RDEPUBChapterSummary {
RDEPUBChapterSummary(
pageRanges: [],
pageCount: pageCount,
fragmentOffsets: [:],
renderSignature: "estimated",
schemaVersion: currentSchemaVersion,
chapterContentHash: "estimated",
pageMetadataList: []
)
}
var isEstimated: Bool { renderSignature == "estimated" }
}
```
### 5.5 磁盘缓存保护
`paginateMetadataOnly` 的写盘逻辑中,跳过估算 summary:
```swift
//
if !summary.isEstimated {
summaryDiskCache?.write(summary: summary, for: cacheKey)
}
```
---
## 6. 需要修改的文件
| 文件 | 改动 | 行数估算 |
|---|---|---|
| `RDEPUBReaderPaginationCoordinator.swift` | 新增 `estimateChapterPageCounts``estimatedCharsPerPage``buildMixedPageMap`;在 `paginateTextPublication` 中集成;`paginateMetadataOnly` 中注入估算值并跳过估算写盘 | ~80 行 |
| `RDEPUBChapterSummary` (in `RDEPUBChapterSummaryDiskCache.swift`) | 新增 `estimated(pageCount:)` 工厂方法和 `isEstimated` 属性 | ~10 行 |
| `RDEPUBBookPageMap` (in `RDEPUBBookPageMap.swift`) | 确认 `Builder.add()` 支持 `fragmentOffsets: [:]`(空字典) | 可能 0 行(已支持) |
---
## 7. 不需要修改的文件
| 文件 | 原因 |
|---|---|
| `RDEPUBChapterLoader` | 没有精确缓存时自动走完整渲染 + 分页,已有 fallback |
| `RDEPUBChapterRuntimeStore` | 不涉及 |
| `RDEPUBReaderRuntime` | `refreshBookPageMapInPlace` 已支持替换式刷新 |
| `RDEPUBChapterWindowCoordinator` | 不涉及 |
| `RDReaderView` | 不涉及 |
| `RDEPUBReaderConfiguration` | 不涉及 |
---
## 8. 验证方式
### 8.1 单元验证
- 打开《凡人修仙传》,记录从 `applyBookPageMap``refreshBookPageMapInPlace(mixedMap)` 的耗时,应 < 1s
- 验证 mixedMap 中窗口章的 pageCount 与精确 partialMap 一致
- 验证 mixedMap 中非窗口章的 pageCount 是估算值(与精确值有偏差但量级正确)
- 验证 `paginateMetadataOnly` 完成后,所有章节的 pageCount 被精确值替换
### 8.2 UI 验证
- 首次打开大书,总页数在 1s 内从 0 跳到接近真实值(如 18000+),而非逐步增长
- 当前窗口章的翻页、位置恢复不受影响
- 后台精确解析期间,总页数微调(如 18000 → 18595),无大幅跳变
- 二次打开仍走精确缓存路径,不走估算
### 8.3 基准对比
| 指标 | 当前实现 | 双层方案 |
|---|---|---|
| 首次显示全书总页数 | ~256s(并发 6 | < 1s |
| 总页数精度 | 100%(精确) | 首屏 ~95%(估算),后台 100% |
| 当前章体验 | 不受影响 | 不受影响 |
| 磁盘缓存 | 只有精确值 | 只有精确值(估算不落盘) |
---
## 9. 风险与缓解
### 9.1 估算页数与精确页数偏差大
**风险**:某些章节(图片多、CSS 复杂、中英文混排)的估算页数可能与精确值偏差 20%+。
**缓解**
- 估算只影响总页数显示,不影响阅读体验
- 后台精确解析会逐步替换,偏差是暂时的
- 可在 UI 上标注"页数计算中..."降低用户预期
### 9.2 纯文本提取不准确
**风险**`<script>``<style>` 标签内的文本被误计入,导致估算偏高。
**缓解**:正则去标签时先移除 `<script>...</script>``<style>...</style>` 块。
### 9.3 估算值干扰精确值的合并
**风险**`paginateMetadataOnly` 注入估算值后,如果某章精确解析失败,该章会保留估算值而非报错。
**缓解**:在 `buildPageMap` 中检查 `isEstimated`,对仍然为估算值的章节标记为 unknown(页数 0),而非保留可能不准确的估算值。
---
## 10. 实施顺序
1. `RDEPUBChapterSummary` 新增 `estimated(pageCount:)``isEstimated`
2. `RDEPUBReaderPaginationCoordinator` 新增估算方法
3.`paginateTextPublication` 的 quick open 之后、`paginateMetadataOnly` 之前插入混合 map 构建
4. `paginateMetadataOnly` 中注入估算值、跳过估算写盘
5. 真机验证《凡人修仙传》的首屏总页数显示速度和精度
File diff suppressed because it is too large Load Diff
@@ -1,438 +0,0 @@
# 《凡人修仙传》快速进入阅读器方案
> 适用场景:`textReflowable` 路径打开超大正文 EPUB,典型样本为《凡人修仙传》精校版全本。
> 目标:把“进入阅读器前必须等全书分页完成”改成“先快速可读,再后台补齐全书能力”。
> 结论先行:当前首屏慢的主因不是单章分页太慢,而是 **打开流程要求先完成全书 `RDEPUBTextBookBuilder.build()`**。
---
## 1. 当前慢在哪里
结合当前代码,打开 reflowable EPUB 的主链路是:
```text
RDEPUBReaderController.viewDidLoad
-> RDEPUBReaderLoadCoordinator.loadPublication()
-> applyParsedPublication(...)
-> RDEPUBReaderPaginationCoordinator.paginatePublication()
-> if publication.readingProfile == .textReflowable
-> RDEPUBTextBookBuilder.build(...)
-> 遍历全部 spine item
-> 每章 render + paginate
-> 汇总成完整 RDEPUBTextBook
-> applyTextBook(...)
-> readerView.reloadData()
-> restoreReadingLocation(...)
```
关键事实:
- [RDEPUBReaderPaginationCoordinator.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift) 在 `textReflowable` 路径里会先 `showLoading()`,然后后台执行 `builder.build(...)`,构建完成前不会进入正文。
- [RDEPUBTextBookBuilder.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift) 的 `build()` 会遍历全部 `publication.spine`,逐章完成:
- HTML 读取
- `DTCoreText` 渲染
- CoreText 分页
- 页面模型组装
- 全书 `RDEPUBTextBook` 汇总
- 也就是说,对《凡人修仙传》这种章节数多、正文长的大书,当前实际是“**全书构建完成后才能看到第一页**”。
这条链路的体验问题是:
- 首屏等待时间和“全书总字数/总章节数”线性相关,而不是和“当前阅读位置附近内容规模”相关。
- 即使用户只想看第一页,也要先支付整本书的 render + paginate 成本。
- 恢复到历史位置时也是同样问题,因为当前恢复逻辑依赖完整 `RDEPUBTextBook` 的页码与位置映射。
---
## 2. 方案目标
### 用户目标
- 点击书籍后,阅读器应尽快进入正文页,而不是长时间停留在 loading。
- 即使全书尚未构建完成,也至少能:
- 看到当前章节
- 翻当前章节内的页
- 恢复到“接近上次阅读位置”的章节
### 技术目标
- 首屏进入从“全书 ready”改成“当前章节 ready”。
- 全书构建改为后台增量完成。
- 不破坏现有 `RDEPUBReaderController` / `RDReaderView` 的主公开 API。
- 保留当前 `RDEPUBTextBookCache` 的价值,但把缓存粒度从“整本书一次命中”扩展到“单章可命中”。
### 首期验收指标
- 大书首次打开时,进入正文的等待时间显著短于当前实现。
- 首屏进入只依赖“当前章节”或“当前位置附近章节”构建完成。
- 后台继续构建剩余章节时,不阻塞阅读。
---
## 3. 推荐方案:两阶段进入 + 章节级增量构建
## 阶段 A:快速进入
打开书后只做这些事情:
1. 解析 EPUB 基础元数据、spine、TOC
2. 确定恢复位置对应的 `spineIndex`
3. 只构建当前章节,必要时附带相邻 `±1`
4. 先生成一个“局部 TextBook / 局部 Snapshot”
5. 立即进入阅读器并恢复到该章节内位置
这一阶段的原则是:
- 先解决“能进入”
- 不要求立刻具备全书搜索、全书目录页码、全书绝对页码精度
## 阶段 B:后台补全
进入正文后,再后台串行完成:
1. 当前章节相邻章节预构建
2. 剩余章节逐步构建
3. 持续补齐全书:
- `RDEPUBTextBook.chapters`
- `RDEPUBTextBook.pages`
- `RDEPUBTextIndexTable`
- 全书页码/位置映射
4. 构建完成后静默替换到完整模型
这样用户的体感是:
- 很快进书
- 越读越完整
- 而不是“先等很久,之后一次性全有”
---
## 4. 为什么这是最快可落地的方案
相比继续优化单次 `build()` 的 CPU 细节,这个方案收益更直接:
- 《凡人修仙传》的核心问题是“全书串行工作量太大”,不是“当前章节单章慢到不可接受”。
- 当前代码已经天然按“章节”组织:
- `publication.spine`
- `RDEPUBTextChapter`
- `RDEPUBChapterData`
- 每章 `render + paginate`
- [阅读器功能开发计划.md](/Users/shenlei/Work/ReadViewSDK/Doc/阅读器功能开发计划.md) 里也已经明确把“增量构建”列为方向,说明这条路和现有架构一致。
换句话说,这不是推翻重做,而是把现有 `RDEPUBTextBookBuilder.build()` 从“必须一次性跑完整本书”拆成“可按章节单独执行”。
---
## 5. 具体改造点
## 5.1 构建层:把全书构建拆成章节级能力
当前:
- [RDEPUBTextBookBuilder.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift) 只有全书 `build(...)`
建议新增:
```swift
func buildChapter(
parser: RDEPUBParser,
publication: RDEPUBPublication,
spineIndex: Int,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) throws -> RDEPUBTextChapterBuildResult
```
建议返回:
- `chapter: RDEPUBTextChapter`
- `paginationDiagnostic`
- `resourceDiagnostics`
- `performanceSample`
这样做的好处:
- 章节构建逻辑可以和现有 `build()` 共享
- 后续“首屏只构建一章”和“后台补全整本书”都走同一套实现
## 5.2 数据层:允许 TextBook 从“不完整”逐步变完整
当前:
- `RDEPUBTextBook` 默认假设 `chapters/pages/indexTable` 已经是完整全书
建议新增一个运行时模型,例如:
```swift
final class RDEPUBIncrementalTextBookStore
```
职责:
- 保存已完成构建的章节
- 维护 `spineIndex -> chapter` 映射
- 动态生成当前可用的 pages snapshot
- 在“部分章节可用”时提供章节内阅读支持
建议暴露能力:
- `chapter(forSpineIndex:)`
- `availablePagesSnapshot()`
- `merge(chapter:)`
- `isChapterReady(_:)`
- `readyChapterRange(around:)`
原因:
- `RDEPUBTextBook` 更适合“完整产物”
- 增量加载需要一个“半成品但可读”的状态容器
## 5.3 分页协调层:把一次性 loading 改成两阶段 loading
当前:
- [RDEPUBReaderPaginationCoordinator.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift) 中 `paginatePublication()` 直接把全书构建作为进入阅读器前置条件
建议改成:
### Step 1:首次只构建目标章节
- 根据 `restoreLocation` 算出目标 `spineIndex`
- 若无恢复位置,默认 `spineIndex = 0`
- 只构建该章,必要时加 `±1`
### Step 2:先应用局部 snapshot
- `readerView.reloadData()`
- 允许用户开始阅读
- tool chrome 可先显示,但某些依赖全书的能力先降级
### Step 3:后台继续全书补建
- 串行队列逐章构建剩余章节
- 每完成一章,就 merge 进 store
- 必要时再刷新目录页码、搜索索引、页码总数
## 5.4 位置恢复:首期优先恢复“章节”,二期补齐“页内精度”
当前:
- 恢复逻辑大量依赖完整 `RDEPUBTextBook` 的 pageNumber / location 映射
首期建议:
- 打开时先把恢复目标收敛到 `spineIndex + fragment/rangeAnchor`
- 只要目标章节 ready,就先进入该章节
- 若该章节内页内恢复信息尚未完整,先恢复到该章节接近位置
这样能显著减少“为了恢复精确页码,必须先构建全书”的耦合。
## 5.5 缓存层:从整书缓存扩展到单章缓存
当前:
- `RDEPUBPaginationCacheCoordinator` / `RDEPUBTextBookCache` 更偏整书结果
建议:
- 缓存 key 增加 `spineIndex`
- 支持单章 page ranges 命中
- 首次打开大书时,优先读取:
- 当前章节缓存
- 相邻章节缓存
收益:
- 同一本大书二次打开时,可直接秒开到当前章节
- 不用再等待整本书缓存完全重建
---
## 6. 首期最小可交付版本
为了最快解决《凡人修仙传》慢启动,建议首期只做下面这些:
1.`RDEPUBTextBookBuilder` 抽出章节级构建接口
2. `RDEPUBReaderPaginationCoordinator` 首次打开时只构建目标章节
3. 用“局部 pages snapshot”驱动 `RDReaderView`
4. 后台串行构建剩余章节
5. 当前章节缓存命中优先
首期明确不做:
- 全书搜索实时可用
- 全书总页数一开始就准确
- 目录面板一开始就显示所有章节页码
这些能力可以在后台补建完成后逐步恢复。
---
## 7. UI 与交互建议
为了让“快速进入但后台仍在准备”体验自然,建议增加轻量提示:
### 阅读器内状态提示
- 首屏进入后不再是全屏 loading
- 改为顶部或底部轻提示:
- `正在准备后续章节...`
- `已进入阅读,可继续翻页`
### 未就绪章节翻页策略
当用户快速翻到尚未构建的章节时:
- 优先命中后台预构建结果
- 如果还未就绪:
- 显示章节级 loading skeleton
- 不要退回全屏 blocking loading
### 目录与搜索降级
- 目录先显示标题,不强依赖页码
- 搜索面板可在全书索引未完成时显示:
- `正文已可阅读,全文搜索仍在准备中`
---
## 8. 风险与应对
## 风险 1:现有很多 API 默认依赖完整 TextBook
例如:
- `pageNumber(for:)`
- `location(forPageNumber:)`
- `chapterData(forPageNumber:)`
应对:
- 首期不要强行让这些 API 在“半本书”状态下也完整成立
- 先给增量模式增加“可用性边界”
- 在运行时根据 `isFullyBuilt` / `isChapterReady` 分流
## 风险 2:局部 snapshot 和完整 snapshot 切换时页码跳动
应对:
- 局部模式优先用章节内位置恢复,不强调绝对页码稳定
- 后台切换到完整模型时,按 `location` 而不是按 `pageNumber` 恢复
## 风险 3:后台补建打断当前交互
应对:
- 章节构建使用串行后台队列
- UI 合并更新节流,例如“每完成 N 章再刷新一次目录状态”
- 当前可见章节不重复重建
---
## 9. 推荐实施顺序
### Phase 1:快速进入 MVP
-`buildChapter(...)`
- 首次打开只构建目标章节
- 局部 snapshot 驱动阅读器
- 后台补建剩余章节
### Phase 2:缓存加速
- 单章分页缓存
- 恢复位置附近章节优先命中
- 二次打开大书进一步提速
### Phase 3:全书能力渐进恢复
- 全书搜索索引后台构建
- TOC 页码后台补齐
- 全书总页数在构建完成后更新
---
## 10. 建议验收方式
建议拿《凡人修仙传》精校版全本做专项验证,记录以下指标:
- 点击书籍到首屏可读的耗时
- 点击书籍到全书构建完成的耗时
- 首屏进入时用户是否已经可以翻当前章节
- 翻到下一章节时是否出现明显阻塞
- 二次打开同一本书时是否明显快于首次
重点不是只看“总构建时长”,而是看:
- `Time To First Readable Page`
- `Time To Full Book Ready`
这两个指标在大书场景里要分开看。
---
## 11. 最终建议
对《凡人修仙传》这类超大正文书,最快见效的方案不是继续压榨单次全书分页性能,而是:
**把阅读器打开流程从“全书先构建完”改成“当前章节先可读,剩余章节后台补齐”。**
这是当前代码架构下收益最高、侵入性也相对可控的做法,因为:
- 现有数据天然按章节组织
- `RDEPUBTextBookBuilder` 已经具备章节级循环结构
- `RDEPUBReaderPaginationCoordinator` 也已经是集中调度入口
如果只允许做一件事来解决《凡人修仙传》打开慢的问题,我建议优先做:
**Phase 1:章节级增量构建 + 两阶段进入阅读器。**
---
## 12. 当前落地状态(2026-06-02
本轮已按 Phase 1 做了首期快速进入优化,当前实现状态如下。
### 已完成
- `RDEPUBTextBookBuilder` 已抽出章节级构建接口 `buildChapter(...)`,单章构建复用原有 render、分页、尾页规范化、诊断和分页缓存逻辑。
- `RDEPUBReaderPaginationCoordinator``textReflowable` 路径已改为两阶段:
- 第一阶段:按恢复位置优先构建可用章节,并立即应用局部 `RDEPUBTextBook` 进入阅读器。
- 第二阶段:后台继续按章节增量构建,但增量结果会先暂存,只在用户空闲时再合并到当前可读内容,最终补齐为完整全书模型。
- 快速进入阶段不只尝试单个 spine,而是按离恢复位置最近的可构建 HTML/XHTML spine 逐个尝试,避免封面、版权页、空白扉页导致首屏快速路径落空。
- 已修正阅读路径误判:普通静态 SVG 封面不再把整本小说误判为 `webInteractive`,避免错误掉回 `WKWebView` 分页路径。
- 首包策略已调整为“目标章节 + 后续 2 章”,默认先提供 3 章连续可读内容。
- 后台补齐策略已调整为“每新增 20 章生成一份新的局部结果”,并优先补当前可读窗口之后的章节,再回补前文。
- 增量结果不再一生成就立即 `applyTextBook`,而是只保留最近一份 staged `RDEPUBTextBook`,等用户停止翻页且阅读器回到 idle 后再统一合并,减少翻页过程中的 UI reload 和主线程抖动。
- 已对后台分页任务做降干扰处理:章节补建切到较低优先级队列,用户刚翻页时后台任务会短暂停让,减少 pageCurl 翻页时的 CPU 抢占。
- 后台完整构建失败时,如果局部章节已经成功进入阅读器,则不再把用户退回阻塞式错误流程,只结束 loading;如果局部章节也未成功,则按原错误处理。
### 仍未完成
- 还没有引入独立的 `RDEPUBIncrementalTextBookStore`,当前首期实现采用“局部 `RDEPUBTextBook` 先应用,完整 `RDEPUBTextBook` 后替换”的 MVP 路径。
- 目录页码、全文搜索、全书总页数仍依赖后台完整构建完成后恢复。
- 尚未加入可视化的“后续章节准备中”轻提示。
### 当前预期效果
对《凡人修仙传》精校版全本这类章节多、正文长的大书,首屏进入不再等待整本书逐章 render + paginate 全部完成,而是先等待目标正文附近 3 章构建完成。全书分页仍会继续执行,但它从“进入阅读器前置条件”变成了“阅读器内后台补齐任务”;后台每补齐 20 章会生成一份新的局部结果,不过这份结果会先暂存,等用户空闲时再并入当前可读内容。
### 验证状态
已通过 CocoaPods workspace 构建 Demo,确认本轮快速进入优化可编译:
```text
xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace \
-scheme ReadViewDemo \
-configuration Debug \
-derivedDataPath /private/tmp/ReadViewDemoDerivedData \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
build
```
结果:`BUILD SUCCEEDED`
注意:直接构建 `ReadViewDemo.xcodeproj` 会绕开 Pods,导致 `import RDReaderView` 模块解析失败;验证时应使用 `ReadViewDemo/ReadViewDemo.xcworkspace`
后续仍需要在真机或模拟器上用《凡人修仙传》精校版全本做实际打开耗时对比,重点记录 `Time To First Readable Page``Time To Full Book Ready`
@@ -1,290 +0,0 @@
# 架构对比分析:读书 vs ReadViewSDK
> 基于读书 v10.0.3 (Build 79) 逆向文档,与当前 ReadViewSDK 代码在 2026-06-02 的核查结果整合。
> 本文档已吸收原 [WXRead剩余问题修复计划.md](/Users/shen/Work/Code/ReadViewSDK/Doc/WXRead剩余问题修复计划.md) 的阶段方案,后续以本文档作为单一真值。
> 标注说明:
> - `✅ 已实现`:当前代码已经按接近 WXRead 的路径落地
> - `⚠️ 有差异/有问题`:已经实现一部分,但仍与 WXRead 有结构差异,或仍有已知问题
> - `❌ 未实现`:当前代码中仍缺失
> - `❌ 明确不实现`:经确认不纳入当前 EPUB 阅读器复刻范围
---
## 核心结论
ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链路已经收口到:
- CoreText 页面直绘
- `RDEPUBTextLayouter` 分页
- 5 层 CSS 级联
- `<link>` 样式表内联
- 页面级 hit test / 选区 / 高亮 / 批注
- 字符锚点与 `fileIndex/row/column` 语义的完整位置模型
核查结论:ReadViewSDK 已经完成 EPUB 阅读器主链路的大部分复刻,但当前代码里仍有少量“能力面已接入、实现路径未完全等价”的差异。和 2026-05-24 版本文档相比,当前最需要重新标注的是:
1. 文本分页主路径现已真正消费多栏 path,并补上 `avoidOrphans` / `avoidWidows` / `hyphenation` 行为
2. 代码仍保留若干 fallback / degrade 路径,与 WXRead 的单一主链路实现方式不同
3. `replaceForMPChapter.css`、繁简转换、TTS / DRM / Pencil 仍属于不适用或明确不实现范围
---
## 核查摘要
### ✅ 已实现
- 文本页已经改成页面级 CoreText 直绘。
- `RDEPUBTextLayouter` 已接入 `RDEPUBTextBookBuilder` / `RDPlainTextBookBuilder`
- 4 级语义断页、`avoidPageBreakInside` 页尾回退、分页缓存都已经落地。
- 5 层 CSS 级联和 EPUB `<link>` 外部样式表内联都已经落地。
- CoreText 页面 hit test、长按选区、菜单锚点、复制/高亮/批注主链路已经接入页面几何。
- CoreText 路径的高亮、注释、搜索命中已经走页面装饰层,而不是继续改正文布局真值。
- WebView 路径的标注绘制、搜索高亮已经改成“JS 产出 rect,原生 overlay 负责 CGContext 绘制”,不再依赖 DOM 包裹着色。
- 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext``weread-page-relate` 页首借行这几类 WXRead 风格规则。
- 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。
- `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。
- CoreText 分页路径现已消费多栏 layout path,不再把 `numberOfColumns` 只停留在配置层。
- `avoidOrphans``avoidWidows``hyphenation` 现已接入实际排版/分页行为,而不是仅存在于配置模型。
- `RDEPUBChapterData` 已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义,章节模型主链路已经收口。
### ⚠️ 有差异/有问题
- 仍保留降级链路:`RDEPUBDTCoreTextRenderer``DTCoreText` 不可用或构建失败时会退回 HTML 纯文本渲染;`RDURLReaderController` 在纯文本分页失败时会退回 `UITextView` 展示。这说明 ReadViewSDK 仍不是 WXRead 那种完全单一路径的生产态实现。
- `RDReaderGestureController` 仍是未接入的占位组件,实际点击分区逻辑直接落在 `RDReaderView`,与 WXRead 更完整的翻页/手势控制器拆分相比,宿主层职责仍稍偏重。
### ❌ 未实现
- 繁简转换(明确不实现)
- TTS / DRM / Pencil(明确不实现)
---
## 1. 渲染路径对比
| 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------|
| EPUB 文本渲染 | `WRPageView.drawRect:``CTFrameDraw` 到 CGContext | `RDEPUBTextContentView``RDEPUBDirectCoreTextPageView.draw(_:)` + `DTCoreTextLayoutFrame.draw(in:)` 页面级直绘 | ✅ 已实现 |
| WebView 路径 | WKWebView(公众号/文集文章) | WKWebViewinteractive/fixed-layout EPUB | ✅ 已实现 |
| 文本选区 | `CTLineGetStringIndexForPosition` 坐标级 hit test | `RDEPUBPageInteractionController` + `RDEPUBSelectionOverlayView` 已接入主链路 | ✅ 已实现 |
| 标注绘制 | `CTFrame` 层叠加绘制(CGContext | CoreText / WebView 两条链路都已收口到原生页面装饰层绘制 | ✅ 已实现 |
| 搜索高亮 | `WRCoreTextLayoutFrame.highlightSearchResults:` 直接绘制 | CoreText / WebView 两条链路都已统一为原生页面装饰对象绘制 | ✅ 已实现 |
**结论:** 渲染层里“页面级绘制 + 页面装饰对象收口”这一块已经完成,页面几何回查也已经补到与 WXRead 同一路径的页面快照闭环。
---
## 2. 分页引擎对比
| 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------|
| 分页核心 | `WRCoreTextLayouter` + `WRCoreTextLayoutFrame` | `RDEPUBTextLayouter` + `RDEPUBTextLayoutFrame` | ✅ 已实现 |
| 断页策略 | 4 级语义断页 | 已启用 4 级语义断页 | ✅ 已实现 |
| `avoidPageBreakInside` | 页尾最多回退若干行避免破碎 | 已实现页尾最多回退 3 行 | ✅ 已实现 |
| inline footnote | 行内脚注不应触发整段挪页 | 已修正为仅块级 attachment 参与整块挪页 | ✅ 已实现 |
| 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 |
| `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 |
| 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 |
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并已接入多栏 path、孤行/寡行保护与 hyphenation 行为 | ✅ 已实现 |
| 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 |
**结论:** 分页主链路已经按 WXRead 的主要思路收口;当前剩余差异不再集中在分页配置或分页算法本身,而更多落在 fallback 路径和宿主层实现细节。
---
## 3. CSS 处理对比
| 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------|
| CSS 级联层级 | 5 级:`default < replace < dark < epub < user` | `RDEPUBTextStyleSheetPackage` 已实际生成并注入 5 层 | ✅ 已实现 |
| EPUB `<link>` CSS | 解析前级联合并外部样式 | 已实现抽取、内联、URL 重写与诊断 | ✅ 已实现 |
| 自定义 CSS 属性 | `wr-vertical-center-style``weread-page-relate`、断页相关私有属性 | 已按 WXRead 语义接入 `wr-vertical-center-style``weread-page-relate``page-break-* / break-*``avoidPageBreakInside` 与 attachment placement | ✅ 已实现 |
| WXRead 私有 CSS 文件 | 使用 WeRead 自带 `default/replace/dark/...` | 已把 `default/replace/dark` 作为 SDK 内置资源镜像注入 5 层级联 | ✅ 已实现 |
| `replaceForLatinLanguageBook.css` | 拉丁语言专项字体规则 | 已按章节语言 / 正文拉丁字符占比自动切换 Latin replace 层 | ✅ 已实现 |
**结论:** CSS 主机制、私有资源层和拉丁语言分支都已经按 WXRead 路径收口;这一节当前不再是剩余差距。
---
## 4. 位置模型对比
| 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------|
| 主模型 | `WREpubPositionConverter` `(fileIndex, row, column)` | `RDEPUBTextPositionConverter` + `RDEPUBTextAnchor/rangeAnchor`,已补齐全书字符位置与页码双向转换 | ✅ 已实现 |
| 恢复精度 | 字符级双向恢复 | 当前位置、选区、高亮、搜索命中都已优先走 `rangeAnchor` / `fileIndex-row-column` 恢复,`progression` 仅作回退 | ✅ 已实现 |
| 旧数据兼容 | 兼容历史位置模型 | 已兼容旧 `spineIndex` 等旧字段解码 | ✅ 已实现 |
| 全量双向转换 | 文件位置 <-> 全书字符位置 <-> 页码 | `RDEPUBTextPositionConverter` / `RDEPUBTextIndexTable` 已补齐 `fileIndex,row,column <-> chapterOffset <-> globalOffset <-> pageNumber <-> location` | ✅ 已实现 |
**结论:** 位置模型这条链路已经按 WXRead 的 `PositionConverter` 思路收口,当前不再是位置恢复能力缺失的问题。
---
## 5. 章节数据模型对比
| 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------|
| 核心模型 | `WRChapterData` 一体化承载渲染结果 + 标注 + 搜索 + 目录 | `RDEPUBChapterData` 现已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义 | ✅ 已实现 |
| 页内高亮查询 | 章节对象直接提供 | `RDEPUBChapterData.highlights(on:from:)` 已提供 | ✅ 已实现 |
| 页内搜索查询 | 章节对象直接提供 | `RDEPUBChapterData.searchResults(on:from:)` 已提供 | ✅ 已实现 |
| 页码/位置查询 | 章节对象内聚 | `RDEPUBChapterData` 已统一提供 chapter/page/location/searchMatch 双向回查 | ✅ 已实现 |
| 目录与章节语义 | 基于章节数据统一管理 | 已由 `RDEPUBChapterData` 统一提供 TOC 命中、主目录项解析与章节页码定位 | ✅ 已实现 |
**结论:** 章节对象这条链路已经按 WXRead 的 `WRChapterData` 思路收口,章节分页、位置、搜索、高亮与目录语义现在都围绕 `RDEPUBChapterData` 统一组织。
---
## 6. 翻页控制器与宿主层对比
| 维度 | 读书 | ReadViewSDK | 核查 |
|------|---------|-------------|------|
| 翻页动画 | curl / slide / fade / none | 已有多种翻页模式 | ✅ 已实现 |
| `UIPageViewController` crash patch | 3 个以上专项 patch | 已补 pageCurl 异常检测、转场期间跳转排队、动画后校验与异步重建恢复 | ✅ 已实现 |
| 预加载 | `WRForecastUtils` 预测 + 后台准备 | 已围绕当前页、当前 spread 和可见方向预热相邻页视图,并在布局环境变化时裁剪缓存 | ✅ 已实现 |
| 预测翻页 | 基于用户方向预测预建内容 | 已按手势/滚动方向和 pending target 预测下一屏,优先向预测方向额外预热一屏 | ✅ 已实现 |
| 显示切换一致性 | 翻页模式/单双页/方向切换稳定恢复 | 已补切换前位置快照、切换后按 location 恢复,以及横竖屏过渡期间的延迟恢复链路 | ✅ 已实现 |
**结论:** 翻页控制器与宿主层这条链路已经按 WXRead 的主实现思路收口,pageCurl 稳定性、预测预加载和显示切换恢复现在都走统一闭环。
---
## 7. 资源体系对比
### CSS
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|------|-------------|-----------------|------|
| `default.css` | 基础 HTML 标签样式 | 已以内置资源镜像接入 | ✅ 已实现 |
| `replace.css` | 标题、图片、引用、分页控制 | 已以内置资源镜像接入 | ✅ 已实现 |
| `dark.css` | 暗色主题 | 已以内置资源镜像接入,并保留主题色覆盖层 | ✅ 已实现 |
| `replaceForLatinLanguageBook.css` | 拉丁语言字体 | 已接入章节语言自动切换 | ✅ 已实现 |
| `replaceForMPChapter.css` | 公众号文章专用 | EPUB 主链路不需要 | ✅ EPUB 主链路不适用 |
### JS
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|------|-------------|-----------------|------|
| `weread-highlighter.js` | Web 高亮引擎 | `epub-bridge.js` 现只负责 rect 解析与桥接,最终由原生 overlay 绘制 | ✅ 已实现 |
| `rangy-*` | 选区/高亮库 | CoreText 路径不需要;`webInteractive` / `webFixedLayout` 已接入 `rangy-core.js` + `rangy-serializer.js` 等价层,统一负责 DOM range 序列化与 iframe 文档回放 | ✅ 已实现 |
| `cssInjector.js` | 动态 CSS 注入 | 已接入 `cssInjector.js`,由 `WeReadApi``webInteractive` / `webFixedLayout` 路径按需向主文档和 iframe 文档注入主题/分页样式 | ✅ 已实现 |
| `WeReadApi.js` | JS-Native 桥接 | 已接入 `WeReadApi.js`,并由 `window.WeReadApi` 统一封装分页、主题、搜索、高亮和 progression bridge | ✅ 已实现 |
**结论:** CoreText 路径不需要 `rangy` / `cssInjector` / `WeReadApi`;但 `webInteractive``webFixedLayout` 两条 Web 路径都需要。当前资源体系已经把这套 Web 资源层完整接回 SDK,JS 侧职责也已按 WXRead 思路收口。
---
## 8. 缺失能力清单
| 能力 | 核查 | 当前优先级 |
|------|------|-----------|
| 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ✅ 已实现 | - |
| `WRBookmark` 统一标注模型 | ✅ 已实现 | - |
| 多栏排版 | ✅ 已实现 | - |
| `avoidOrphans / avoidWidows / hyphenation` 行为落地 | ✅ 已实现 | - |
| 纯文本/渲染失败 fallback 清理 | ⚠️ 仍保留纯文本 fallback 路径 | 低 |
| 字体动态加载 | ✅ 已实现 | - |
| 繁简转换 | ❌ 明确不实现 | - |
| TTS / DRM / Pencil | ❌ 明确不实现 | - |
---
## 9. 收口路线(整合原修复计划)
### P1:页面几何与分页行为闭环
- `RDEPUBPageLayoutSnapshot` 已补齐 line/run/attachment/contentBounds 与 absoluteRange 命中闭环
- overlay / decoration / selection 回查已经统一到页面快照层
- 问题书分页细节已回收到现有分页规则,不再构成架构缺口
当前状态:`✅ 已实现`
### P2:位置模型与章节数据内聚
- `rangeAnchor` / `fileIndex/row/column` 双向位置转换已完成
- `RDEPUBChapterData` 章节语义内聚已完成
- `RDEPUBAnnotation` 已把书签/高亮统一收口到单一标注语义层
当前状态:`✅ 已实现`
### P3:宿主层稳定性
- 宿主层 pageCurl 修复、预测预加载和显示切换恢复已完成
- 剩余工作不再是主链路缺失,而是问题书和极端交互场景的持续回归
当前状态:`✅ 已实现`
### P4:外围能力
- 多栏阅读器开关 / UI 暴露已完成
- EPUB 内嵌字体动态注册已完成
- 繁简转换、TTS / DRM / Pencil 明确不实现,不纳入收口范围
当前状态:`✅ 范围内已实现`
---
## 补充说明:本次复核新增结论(2026-06-02)
和上一篇版本相比,当前最重要的修正不是“新增缺很多能力”,而是把原先写得过满的结论收回来:
- `RDEPUBTextLayoutConfig` 这一轮已经从“配置面补齐”推进到“行为面落地”,`avoidOrphans``avoidWidows``hyphenation` 不再只是模型字段。
- 多栏能力这一轮已经接到文本 `CoreText` 分页主路径,`numberOfColumns` 不再只影响 Web/CSS 与设置入口。
- 文本阅读主链路虽然已经不再依赖旧 `UITextView`,但仓库中仍保留 `DTCoreText` 不可用时的纯文本 fallback,以及纯文本分页失败时的 `UITextView` fallback,应视作与 WXRead 的实现差异,而不是主链路能力。
- 公众号文章专用 `replaceForMPChapter.css` 仍不属于当前 EPUB 主链路缺口,应继续按“不适用”记录,而不是“待补齐”。
---
## 10. 读书关键类职责速查
| 类名 | 职责 | ReadViewSDK 对应 | 核查 |
|------|------|-----------------|------|
| `WRReaderViewController` | 阅读器主控制器 | `RDEPUBReaderController` | ✅ 已有对应 |
| `WRPageViewController` | 翻页控制器 + crash patch | `RDReaderView` | ✅ 已实现 |
| `WRPageView` | CoreText 直接绘制页面 | `RDEPUBTextContentView` + `RDEPUBDirectCoreTextPageView` | ✅ 已实现 |
| `WREpubTypesetter` | HTML → NSAttributedStringCSS 级联) | `RDEPUBDTCoreTextRenderer` | ✅ 已实现 |
| `WRCoreTextLayouter` | CoreText 排版引擎 | `RDEPUBTextLayouter` | ✅ 已实现 |
| `WRCoreTextLayoutFrame` | 排版帧(绘制/选区/搜索/装饰) | `RDEPUBTextLayoutFrame` + snapshot/interaction/overlay | ✅ 已实现 |
| `WRChapterData` | 章节数据模型 | `RDEPUBChapterData` | ✅ 已实现 |
| `WRChapterPageCount` | 分页计算 + 缓存 key | `RDEPUBTextBookBuilder` + `RDEPUBTextBookCache` | ✅ 已实现 |
| `WREpubPositionConverter` | 字符级位置双向转换 | `RDEPUBTextPositionConverter` + `RDEPUBTextIndexTable` | ✅ 已实现 |
| `WRBookmark` | 标注统一模型 | `RDEPUBAnnotation`(兼容 `RDEPUBHighlight` / `RDEPUBBookmark` | ✅ 已实现 |
| `DTHTMLAttributedStringBuilder` | HTML DOM → NSAttributedString | 同(复用 DTCoreText | ✅ 已实现 |
---
## 11. 当前数据流
```text
EPUB 文件
-> RDEPUBParser.parse (container.xml -> OPF -> spine)
-> readingProfile 分流:
textReflowable:
-> RDEPUBDTCoreTextRenderer
-> 5 层 CSS 级联
-> <link> CSS 内联
-> 自定义分页语义注入
-> RDEPUBTextBookBuilder
-> RDEPUBTextLayouter
-> RDEPUBTextBookCache
-> RDEPUBTextContentView
-> RDEPUBDirectCoreTextPageView
-> RDEPUBPageInteractionController
-> RDEPUBSelectionOverlayView
webInteractive:
-> RDEPUBPaginator
-> RDEPUBWebContentView
-> rangy-core.js / rangy-serializer.js
-> cssInjector.js
-> WeReadApi.js
-> epub-bridge.js
webFixedLayout:
-> RDEPUBWebContentView
-> rangy-core.js / rangy-serializer.js
-> cssInjector.js
-> WeReadApi.js
-> epub-bridge.js
```
---
*分析基础:读书 v10.0.3 (Build 79) 逆向文档*
*当前代码核查日期:2026-05-24*
-617
View File
@@ -1,617 +0,0 @@
# 阅读器功能开发计划
> 本文档整合了渲染质量三方对比(ReadViewSDK vs WXRead)与功能开发计划,作为阅读器能力演进的单一真值。
> 基于读书 v10.0.3 逆向分析,与当前 ReadViewSDK 代码核查结果整合。
## 背景
渲染内核的架构对齐解决的是"代码可维护性"问题。本文档梳理从"能用的阅读器"到"能上架的商业阅读器"还需要补齐哪些能力,每项能力标注三方状态和具体实施计划。
### 三方对比总览(渲染质量)
| 缺失项 | 严重度 | ReadViewSDK | WXRead | 差距说明 |
| --- | --- | --- | --- | --- |
| **竖排文字** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `writing-mode` 支持 |
| **Ruby 注音** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `<ruby>`/`<rt>` 处理 |
| **数学公式** | 中 | ❌ 未实现 | ⚠️ 仅字体回退 | 非原生渲染范畴,需 WebView 回退 |
| **复杂图文分页质量** | 高 | ✅ 已实现 | ✅ 已实现 | 双方均已实现 |
| **字体选择器** | 中 | ✅ 已实现 | ⚠️ 管线完备,UI 未见 | 已支持四档字体选择 |
| **连字/断字** | 中 | ⚠️ 仅配置标记 | ⚠️ 仅属性声明 | 双方均未实际实现 |
| **多语言排版回退** | 中 | ⚠️ 语言检测有,简繁转换无 | ✅ 已实现 | WXRead 额外有简繁转换 |
### ReadViewSDK 领先 WXRead 的项
| 项 | 说明 |
| --- | --- |
| **`keepWithNext`** | 已实现 `trimmedRangeForKeepWithNext`,WXRead 反编译代码中未找到对应实现 |
### 不需要对标 WXRead 的项
以下项 WXRead 自身也未实现,不属于必须补齐的能力:竖排文字、Ruby 注音、数学公式、Hyphenation 断字。
---
## 一、渲染质量
### 1. 字体选择器
**当前状态**:✅ 基础能力已完成。
已支持系统、宋体、圆体、等宽四档字体选择;设置项可持久化;切换字体会触发重新分页;分页缓存签名已包含字体信息,避免不同字体复用旧缓存。
**已落地文件清单**
| 文件 | 改动 | 状态 |
| --- | --- | --- |
| `RDEPUBReaderConfiguration.swift` | 新增 `RDEPUBReaderFontChoice`,配置增加 `fontChoice` | 已完成 |
| `RDEPUBReaderSettings.swift` | 新增 `fontChoice` 持久化,更新 `applying(to:)``capture(configuration:brightness:)` | 已完成 |
| `RDEPUBReaderContext.swift` | `currentTextRenderStyle()` 改用 `configuration.fontChoice.font(ofSize:)` | 已完成 |
| `RDURLReaderController.swift` | URL 阅读器同步使用 `fontChoice` 生成文字样式 | 已完成 |
| `RDEPUBReaderController+RuntimeBridge.swift` | `requiresRepagination(from:to:)` 增加 `fontChoice` 变更检查 | 已完成 |
| `RDEPUBPaginationCacheCoordinator.swift` | 分页缓存签名增加字体名 | 已完成 |
| `RDEPUBReaderSettingsViewController.swift` | 新增 `onFontChoiceChange` 回调、字体分段控件和 accessibility identifier | 已完成 |
| `RDEPUBReaderChromeCoordinator.swift` | 设置面板接线字体变更回调 | 已完成 |
| `SettingsPanelTests.swift` | UI 自动化覆盖字体选择 | 已完成 |
**当前验收结果**
- ✅ 切换字体后触发重排。
- ✅ 字体选择可随阅读器设置持久化。
- ✅ 分页缓存按字体隔离。
- ✅ UI 自动化已覆盖设置面板字体选择。
**后续增强计划**
1. 引入更多内置字体包(建议:思源宋体、思源黑体、方正书宋、方正兰亭黑、Lora、OpenDyslexic)。
2. 使用 `CTFontManagerRegisterFontsForURL` 注册 bundle 字体。
3. 字体列表从枚举升级为资源驱动模型,每项包含 `displayName``fontName``previewText`、可用性状态。
4. 字体选择 UI 从分段控件升级为横向预览卡片,展示真实字体效果。
5. 增加字体资源加载失败兜底,失败时回退系统字体并保留用户设置。
---
### 2. 暗色模式图片处理
**当前状态**:✅ 基础能力已完成。
暗色主题下会对正文图片做显示副本调暗,降低白底图片在深色背景上的刺眼程度。处理只作用于展示层,不修改分页原始内容;封面和小图会跳过,避免误处理封面、图标和装饰图。
**已落地文件清单**
| 文件 | 改动 | 状态 |
| --- | --- | --- |
| `RDEPUBReaderConfiguration.swift` | 新增 `darkImageAdjustmentEnabled``darkImageBlendRatio` | 已完成 |
| `RDEPUBReaderController+RuntimeBridge.swift` | 主题和暗色图片配置变更触发可见页刷新 | 已完成 |
| `RDEPUBTextContentView.swift` | DTCoreText 展示内容增加暗色图片显示副本处理 | 已完成 |
| `RDEPUBTextContentView.swift` | 增加暗色图片缓存,避免重复生成 | 已完成 |
| `SettingsPanelTests.swift` | UI 自动化覆盖暗色主题切换 | 已完成 |
**当前实现策略**
- 仅当背景亮度低于阈值、配置开启、混合比例大于 0 时启用。
- 仅处理正文 inline image attachment。
- 跳过封面和小于 80x80 的图片。
- 使用 `NSCache` 缓存处理后的图片。
- 使用主题背景色按比例覆盖原图,透明区域保持透明。
**当前验收结果**
- ✅ 暗色模式下图片不再完全以原始亮色块直出。
- ✅ 图片内容仍保持可辨认,不做反色。
- ✅ 处理结果有缓存。
- ✅ UI 自动化已覆盖暗色主题入口。
**后续增强计划**
1. 增加真实 EPUB 图片样本的截图回归测试。
2. 按图片平均亮度自适应 `darkImageBlendRatio`
3. 增加用户侧开关,允许关闭暗色图片处理。
4. 如果后续恢复 WebView 路径,再补充 CSS filter 策略。
---
### 3. 简繁转换
**当前状态**:零实现。语言元数据已提取(`publication.metadata.language`),但仅用于拉丁/CJK CSS 分轨。
**WXRead 实现参考**
- `WREpubTypesetter` 检测 `book.language``Hant`/`TW`/`HK` 时调用 `_WRConvertHansToHantIfNeeded()`
- 使用 `CFStringTransform` 两步转换:Hans → Latin → Hant
**实施步骤**
**Step 1:转换工具**
1. 新增 `RDEPUBTextChineseConverter.swift`,提供:
```swift
enum RDEPUBChineseScript { case hans, hant }
func convert(_ text: String, to script: RDEPUBChineseScript) -> String
```
2. 实现使用 `CFStringTransform`
```swift
let mutable = NSMutableString(string: text) as CFMutableString
CFStringTransform(mutable, nil, kCFStringTransformToLatin, false) // Hans → Pinyin
CFStringTransform(mutable, nil, kCFStringTransformStripDiacritics, false) // Pinyin → stripped
// 然后通过字典映射到繁体
```
或直接使用 Apple 的 `kCFStringTransformHansToHant`(如果可用)。
**Step 2:判断是否需要转换**
3. 在 `RDEPUBTextBookBuilder.build()` 或 `RDEPUBTextRendererSupport.makeChapterRenderRequest()` 中:
- 读取 `publication.metadata.language`
- 如果语言为 `zh-Hant`/`zh-TW`/`zh-HK` 且用户设置为简体,或语言为 `zh-Hans`/`zh-CN` 且用户设置为繁体,触发转换
4. 新增 `RDEPUBReaderConfiguration.chineseScript: RDEPUBChineseScript?`nil = 跟随书籍)
**Step 3:应用转换**
5. 在 `normalizeReadingAttributes(in:style:)` 之后、返回 `RDEPUBTextChapterRenderRequest` 之前,对 attributed string 的文本内容执行转换
6. 需要保持 attributed string 的属性(字体、颜色、附件)不变,只替换字符
**验收标准**
- 简体 EPUB 在繁体模式下显示繁体
- 繁体 EPUB 在简体模式下显示简体
- 转换不影响分页结果(转换后字符数可能变化,需验证)
- 高亮、搜索功能在转换后仍然正常
---
### 4. 孤行/寡行控制
**当前状态**`RDEPUBTextLayoutConfig` 声明了 `avoidOrphans`/`avoidWidows`(默认 true),但 `RDEPUBTextLayouter` 完全不读取这两个标记。
**实现原理**
- **寡行(widow)**:段落最后一行单独出现在页底 → 应将该行拉到下一页
- **孤行(orphan)**:段落第一行单独出现在页首 → 应将前一页最后一行拉过来
- 通常只处理寡行(保留至少 2 行在页底),孤行处理会引发连锁重排
**实施步骤**
**Step 1CoreText 路径**
1. 在 `RDEPUBTextLayouter.layoutFramesUsingCoreText()` 的分页循环中(~行 63-137),在 `adjustedRange` 之后、追加 frame 之前,增加寡行检查:
```swift
if config.avoidWidows {
finalRange = trimmedRangeForAvoidWidows(
from: ctFrame,
proposed: finalRange,
lineRanges: lineRanges
)
}
```
2. `trimmedRangeForAvoidWidows` 实现:
- 从 proposed range 的最后一行向前检查
- 如果最后一行是一个段落的最后一行,且该段落在此页只有 1 行 → 回退到上一个段落边界
- 最多移除 2 行(避免过度收缩)
**Step 2DTCoreText 路径**
3. 在 `layoutFramesUsingDTCoreText()` 中增加相同逻辑
**Step 3:段落边界检测**
4. 利用已有的 `paragraphRange(containing:)` 方法(`NSString.paragraphRange`)获取段落范围
5. 对比当前页最后一行的 range 和段落的 range,判断是否为段落唯一一行
**验收标准**
- 分页页数可能轻微增加(1-3 页),但排版质量提升
- 已知的寡行问题页在修复后不再出现段落最后一行孤悬页底
- `avoidOrphans`/`avoidWidows = false` 时行为不变
---
### 5. Hyphenation 断字
**当前状态**`hyphenation: Bool = true` 配置标记已声明,但 `NSParagraphStyle.hyphenationFactor` 从未设置。CoreText 路径更不支持断字。
**技术约束**
- `NSParagraphStyle.hyphenationFactor` 对 `UITextView`(降级路径)有效
- CoreText 直绘路径需要设置 `kCTParagraphStyleSpecifierHyphenationFactor`
- 中文场景断字影响较小(中文无空格分词),主要改善西文排版
**实施步骤**
**Step 1UITextView 降级路径**
1. 在 `RDEPUBTextRendererSupport.paragraphStyle(lineSpacing:)`(行 929-934)中增加:
```swift
style.hyphenationFactor = 1.0
```
2. 在 `normalizeReadingAttributes()`(行 137-143)中,对已有的 `NSMutableParagraphStyle` 增加:
```swift
paragraphStyle.hyphenationFactor = 1.0
```
**Step 2CoreText 直绘路径**
3. 在 `RDEPUBTextLayouter` 的 frame 构造中,对 `CTParagraphStyle` 增加 hyphenation factor
```swift
var hyphenationFactor: Float = 1.0
let settings = [CTParagraphStyleSetting(
spec: .hyphenationFactor,
valueSize: MemoryLayout<Float>.size,
value: &hyphenationFactor
)]
let paragraphStyle = CTParagraphStyleCreate(settings, settings.count)
```
4. 将此 paragraph style 设置到 attributed string 的段落属性上
**Step 3:条件控制**
5. 读取 `config.hyphenation` 标记,为 false 时跳过上述设置
**验收标准**
- 西文长单词在行末正确断字(显示连字符)
- 中文排版不受影响
- `hyphenation = false` 时回到当前行为
---
## 二、工程成熟度
### 6. 自动化测试
**当前状态**:✅ UI 自动化测试基础设施已完成,单元测试和分页回归基准待补。
已在 Demo 工程中接入 `ReadViewDemoUITests`,覆盖打开书籍、阅读器基础交互、顶部/底部工具栏、设置面板、字体选择和暗色主题切换。
**已落地内容**
| 项 | 状态 |
| --- | --- |
| UI Test target `ReadViewDemoUITests` | ✅ 已完成 |
| Demo 自动打开测试 EPUB 的入口 | ✅ 已完成 |
| Accessibility identifier 体系 | ✅ 已完成 |
| 阅读器基础打开测试 | ✅ 已完成 |
| 顶部/底部工具栏测试 | ✅ 已完成 |
| 设置面板测试 | ✅ 已完成 |
| 字体选择 UI 测试 | ✅ 已完成 |
| 暗色主题切换 UI 测试 | ✅ 已完成 |
**当前验证结果**
```text
xcodebuild build: BUILD SUCCEEDED
SettingsPanelTests: TEST SUCCEEDED
ReadViewDemoUITests 全量 8 个 UI 测试: TEST SUCCEEDED
```
**下一步实施步骤**
**Step 1:单元测试基础设施**
1. 在 `ReadViewDemo.xcodeproj` 中新增 Unit Test target `ReadViewSDKTests`
2. 创建 `Tests/` 目录,配置 `import RDReaderView` 或通过 Demo target 暴露内部测试入口
3. 新增 `.xctestplan` 配置,把 UI 测试和单元测试拆分为不同测试组
**Step 2:分页回归测试(最高优先级)**
4. 新增 `RDEPUBPaginationRegressionTests.swift`,固定 6 类样本章节:
- 纯正文章节
- 图 + 图注 + 正文章节
- 脚注小图标章节
- 多段标题章节
- 大图跨页章节
- 长段落连续章节
5. 每个样本:构建 `RDEPUBTextBook`,断言总页数不变、已知页的 `contentRange` 不变
6. 使用 golden file 对比 `RDEPUBTextChapterPaginationDiagnostic` 输出
**Step 3:位置映射测试**
7. 新增 `RDEPUBLocationMappingTests.swift`
- `location → pageNumber` 正向映射
- `pageNumber → location` 逆向映射
- 往返一致性断言
**Step 4:渲染管线测试**
8. 新增 `RDEPUBTypesetterTests.swift`
- `normalizeHTML` 输入输出对比
- fragment marker 注入/提取一致性
- 语义标记注入后 `rdPage*` 属性计数不变
**Step 5CI(可选)**
9. 配置 GitHub Actions 或 Jenkins,在 PR 时自动运行测试
**验收标准**
- UI 自动化全量测试稳定通过
- 6 个分页基准样本的页数和 contentRange 稳定
- 位置映射往返测试通过
- 新 PR 触发测试自动运行
---
### 7. 性能基线
**当前状态**`RDEPUBTextPerformanceSampler` 用 `CFAbsoluteTimeGetCurrent()` 采样,结果仅 `print()` 输出。无 Instruments 可视化、无内存监控、无阈值告警。
**实施步骤**
**Step 1os_signpost 集成**
1. 在 `RDEPUBTextPerformanceSampler` 中引入 `os.signpost`
```swift
import os.signpost
let log = OSLog(subsystem: "com.rdreader", category: .pointsOfInterest)
```
2. 在 build/render/paginate 各阶段插入 `os_signpost(.begin, ...)` / `os_signpost(.end, ...)`
3. 这样 Instruments 的 Points of Interest 能直接可视化各阶段耗时
**Step 2:内存监控**
4. 新增 `RDEPUBMemoryMonitor`
```swift
func currentMemoryFootprint() -> UInt64 // task_info.resident_size
```
5. 在 `RDEPUBTextBookBuilder.build()` 的每章循环中记录内存峰值
6. 在 `RDEPUBTextPerformanceSample` 中增加 `peakMemoryBytes: UInt64`
**Step 3:阈值告警**
7. 定义基准值:首屏 < 2s、单章渲染 < 500ms、全书构建 < 30s(按书的大小可调)
8. 超出阈值时通过 `os_log(.error, ...)` 输出警告
9. 在 Demo 中展示性能摘要面板
**Step 4:持久化**
10. 性能样本可选持久化到文件,供回归对比
**验收标准**
- Instruments 能看到各阶段的 signpost 区间
- 内存峰值在大书(79MB 样本)构建过程中有记录
- 超出阈值时有日志输出
---
### 8. 崩溃防护
**当前状态**`RDReaderView` 有 pageCurl 崩溃检测和异步恢复,但无全局异常捕获。
**实施步骤**
**Step 1NSException 捕获**
1. 新增 `RDEPUBCrashGuard` 工具类:
```swift
static func performSafely(_ block: () throws -> Void) rethrows
static func performWithObjCExceptionHandling(_ block: () -> Void) -> Bool
```
2. 在关键入口包裹 `@try/@catch`
- `RDEPUBTextBookBuilder.build()` 的每章循环体
- `RDEPUBTextLayouter.layoutFrames()` 的分页循环
- `RDEPUBTextRendererSupport.makeChapterRenderRequest()` 的 HTML 处理
**Step 2:分页/渲染单章隔离**
3. 单章渲染/分页失败时,降级到空白页或上一次缓存结果,不中断全书构建
4. 在 `RDEPUBTextChapterPaginationDiagnostic` 中记录错误状态
**Step 3:全局信号处理(可选)**
5. 注册 `NSSetUncaughtExceptionHandler` 记录 ObjC 异常
6. 注册 signal handlerSIGABRT, SIGSEGV)记录崩溃现场
7. 下次启动时上报(如果后续有上报系统)
**验收标准**
- 单章渲染异常不导致整个 build 中断
- pageCurl 崩溃场景已有保护,验证不退化
- 崩溃日志可追踪到具体章节和阶段
---
### 9. 内存管理
**当前状态**:无显式内存预算。无 `didReceiveMemoryWarning` 处理。`RDEPUBTextBookCache` 磁盘缓存无大小限制。
**实施步骤**
**Step 1:内存警告响应**
1. 在 `RDEPUBReaderController` 中监听 `UIApplication.didReceiveMemoryWarningNotification`
2. 收到警告时:
- 清空 `RDReaderPreloadController` 的预加载缓存
- 通知 `RDEPUBTextBookCache` 的内存缓存(如果有)清空
- 释放非当前可见章节的渲染结果
**Step 2:章节级内存释放**
3. 在 `RDEPUBTextBookBuilder` 构建完成后,不再持有已构建章节的 `NSAttributedString`
4. 当前只持有 `RDEPUBTextBook`(包含 `[RDEPUBTextPage]` 的 NSRange),内存占用已较低
5. 如果后续引入 attributed string 缓存,需增加 LRU 淘汰策略
**Step 3:图片内存控制**
6. `RDReaderPreloadController` 预加载的 page view 数量与内存挂钩
7. 当内存压力时减少 `preloadRadius`(从 1 降到 0
**验收标准**
- 在大书(79MB)构建过程中收到内存警告时,app 不被系统杀掉
- 预加载缓存在内存压力下自动收缩
- 当前页内容不丢失
---
### 10. 增量构建
**当前状态**:全书一次性分页。`RDEPUBTextBookBuilder.build()` 遍历全部 spine item,输出完整 `RDEPUBTextBook`。
**实施步骤**
**Step 1:章节级构建接口**
1. 在 `RDEPUBTextBookBuilder` 上新增:
```swift
func buildChapter(
at spineIndex: Int,
publication: RDEPUBPublication,
parser: RDEPUBParser,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) -> RDEPUBTextChapter?
```
2. 内部逻辑从 `build()` 的循环体中提取,单章可独立构建
**Step 2:按需加载**
3. `RDEPUBReaderLoadCoordinator` 在打开书时只构建当前章节和相邻 ±1 章
4. 用户翻到新章节时,后台异步构建该章节
5. 使用 `DispatchQueue` 串行队列保证线程安全
**Step 3:与缓存联动**
6. `RDEPUBPaginationCacheCoordinator` 支持单章缓存命中检查
7. 缓存命中时跳过构建,直接加载
**验收标准**
- 大书首次打开时间缩短(只构建 3 章而非全书)
- 翻到未构建章节时无明显卡顿(后台预构建)
- 全书构建 API 保持不变(兼容现有调用方)
---
### 11. 缓存管理
**当前状态**`RDEPUBTextBookCache` 磁盘缓存无大小限制、无淘汰策略、无选择性失效。无内存缓存。
**实施步骤**
**Step 1:磁盘缓存容量控制**
1. 新增 `maxDiskCacheSize: UInt64`(默认 200MB
2. 在 `save(_:key:)` 写入后检查总大小
3. 超限时按修改时间删除最旧的缓存文件,直到低于阈值
**Step 2:选择性失效**
4. 新增 `invalidate(key:)` 方法,删除指定缓存文件
5. 当用户修改 `fontChoice` 或 `fontSize` 时,只失效当前书的缓存(而非 `invalidateAll`
**Step 3:内存缓存(NSCache**
6. 在 `RDEPUBTextBookCache` 上层增加 `NSCache<NSString, PaginationCacheArchive>`
7. `load(key:)` 先查内存缓存,miss 后查磁盘并回填
8. 内存警告时清空 NSCache
**Step 4:缓存统计持久化**
9. `RDEPUBTextBookCache` 新增 `cacheStats` 属性,记录总命中/缺失次数
10. 可用于调试和性能分析
**验收标准**
- 磁盘缓存不超过设定上限
- 修改字号/字体后,旧缓存被正确失效
- 连续打开同一本书时,第二次命中内存缓存(< 1ms)
---
## 三、可访问性
### 12. VoiceOver
**当前状态**9 个 `accessibilityIdentifier`(仅用于 UI 测试),无 `accessibilityLabel`、`accessibilityHint`、`accessibilityTraits`。阅读内容区域无任何无障碍支持。
**实施步骤**
**Step 1:工具栏无障碍(最低门槛)**
1. `RDEPUBReaderTopToolView`
- `backButton.accessibilityLabel = "返回"`
- `bookmarkButton.accessibilityLabel = "书签"` + `accessibilityValue` 反映当前状态(已添加/未添加)
- `titleLabel.accessibilityLabel` = 书籍标题
2. `RDEPUBReaderBottomToolView`
- 各按钮补充 `accessibilityLabel`"目录"、"书签列表"、"高亮列表"、"添加高亮"、"设置"
- 进度 slider 补充 `accessibilityValue`"第 X 页,共 Y 页"
**Step 2:设置面板**
3. `RDEPUBReaderSettingsViewController` 所有控件补充 label 和 traits
**Step 3:阅读内容区域(中等难度)**
4. `RDEPUBTextContentView` 设置 `isAccessibilityElement = true`
5. `accessibilityLabel` = 当前页纯文本内容
6. 翻页时发出 `UIAccessibility.pageScrolledNotification`
**Step 4:目录和高亮列表**
7. `RDEPUBReaderChapterListController` 列表项设置 `accessibilityLabel`(章节标题 + 页码)
8. `RDEPUBReaderHighlightsViewController` 列表项设置 `accessibilityLabel`(高亮文本 + 章节)
**验收标准**
- VoiceOver 用户能完整导航工具栏
- 翻页时 VoiceOver 朗读新页内容
- 目录和高亮列表可被 VoiceOver 逐项朗读
---
### 13. Dynamic Type
**当前状态**:字号由 `RDEPUBReaderConfiguration.fontSize` 控制,不响应系统 `UIContentSizeCategory` 变化。
**实施步骤**
1. 在 `RDEPUBReaderSettingsViewController` 中监听 `UIContentSizeCategory.didChangeNotification`
2. 当系统字体大小变化时,根据新的 `UIContentSizeCategory` 计算等效字号
3. 或者:在 `RDEPUBReaderConfiguration` 中增加 `useSystemFontSize: Bool`,启用时忽略 `fontSize`,使用系统推荐值
4. 字号映射表(参考 Apple 的 `preferredFont(forTextStyle:)` 返回值)
**验收标准**
- 系统字体调大后,阅读器字号自动跟随
- 用户手动调节字号后,覆盖系统设置
---
### 14. 高对比度
**当前状态**:6 个固定主题预设,不跟随系统 `UIAccessibility.isDarkerSystemColorsEnabled`。
**实施步骤**
1. `RDEPUBReaderTheme` 增加 `highContrastVariant: RDEPUBReaderTheme?` 属性
2. 监听 `UIAccessibility.darkerSystemColorsStatusDidChangeNotification`
3. 高对比度启用时,自动切换到高对比度主题变体(更大色彩对比度)
4. 或提供独立的"高对比度"主题预设
**验收标准**
- 系统高对比度开启后,阅读器自动切换到高对比度主题
- 关闭后恢复原主题
---
### 15. DRM
**当前状态**:需求文档明确声明 DRM 不在 SDK 范围内。如果需要支持,建议通过以下方式:
**建议方案**
1. 不在 SDK 内实现 DRM,而是通过 `RDEPUBParser` 的输入端控制:
- `RDEPUBParser` 接受 `Data` 或 `URL`,调用方可以先解密再传入
- 或新增 `RDEPUBDRMProvider` 协议:
```swift
protocol RDEPUBDRMProvider {
func decryptedData(for resourceURL: URL) -> Data?
func isProtected(_ resourceURL: URL) -> Bool
}
```
2. SDK 内的 `ss-reader://` URL scheme handler 在读取资源时调用 `DRMProvider`
3. 商业 DRM(如 Adobe ADEPT、Readium LCP)由调用方集成,SDK 提供接入点
**验收标准**
- SDK 提供清晰的 DRM 接入协议
- 不引入 DRM 依赖,保持 SDK 轻量
- 调用方能通过协议接入自己的 DRM 方案
---
## 四、功能完整度(暂未展开)
以下功能需求已识别但暂未进入详细开发计划:
| 缺失项 | 严重度 | 说明 |
| --- | --- | --- |
| **书架/书库管理** | 高 | SDK 只能打开单本书,无书架 UI、阅读历史、分类管理 |
| **批注导出/分享** | 高 | 高亮/笔记只有本地存储,无导出、分享、复制到剪贴板 |
| **阅读统计** | 中 | 无阅读时长追踪、阅读速度、连续阅读天数 |
| **TTS 朗读** | 中 | 微信读书核心功能之一,当前无任何语音相关代码 |
| **全局搜索** | 中 | 当前搜索只在单本书内,无跨书搜索 |
| **离线/云端同步** | 高 | 无 iCloud/自建同步,阅读进度和笔记只在本地 |
| **夜间模式定时切换** | 低 | 有暗色主题但不能跟随系统或定时切换 |
---
## 五、按优先级排序的建议路线
### P0 — 影响商业发布
1. **分页回归基准** — UI 自动化测试已启动,下一步需要把分页结果、首屏时间、截图差异纳入回归基准
2. **字体选择器增强** — 基础字体选择器已实现,后续需补:更多内置字体包、字体预览、字体资源加载失败兜底
3. **书架/书库管理** — 商业阅读器的入口
4. **暗色模式图片处理增强** — 基础处理已实现,后续可补:按图片亮度自适应混合比例
### P1 — 影响用户留存
5. **批注导出/分享** — 深度阅读用户的核心需求
6. **阅读统计/时长追踪** — 用户粘性和产品数据的基础
7. **性能基线与大书优化** — 大书卡顿是用户流失的主要原因
8. **简繁转换** — 面向港澳台用户需要
### P2 — 提升竞争力
9. **TTS 朗读** — 通勤场景、无障碍场景刚需
10. **云端同步** — 多设备用户的基本需求
11. **VoiceOver 完善** — 合规和品牌形象
12. **全局搜索** — 藏书量大时的效率工具
---
## 六、总结
经过三方对比,渲染质量层面的真实差距比最初评估要小:
- **图文分页质量**:双方基本对齐,我们甚至在 `keepWithNext` 上领先
- **真正的差距**:字体包数量、分页回归基准、简繁转换
- **WXRead 也没做的**:竖排、ruby、公式、hyphenation——这些不是必须对齐的
如果目标是"能上架的商业阅读器",当前已补齐字体选择器、暗色模式图片处理和基础 UI 自动化测试。下一步最值得投入的是分页回归基准、更多字体资源和书架/书库管理。