312 lines
16 KiB
Markdown
312 lines
16 KiB
Markdown
# EPUBUI 功能实现逻辑
|
||
|
||
## 1. 范围与目标
|
||
|
||
- 代码范围:`Sources/RDReaderView/EPUBUI/`(16 个 Swift 文件)
|
||
- 目标:说明 EPUBUI 如何作为开箱即用的阅读器 UI 层,协调 EPUBCore 解析、EPUBTextRendering 文本渲染、RDReaderView 分页容器,提供完整的阅读体验(工具栏、目录、高亮批注、设置面板、阅读位置持久化、搜索)。
|
||
- 主链路关键词:`RDEPUBReaderController.init -> 解析 EPUB -> 分页 -> 渲染 -> 用户交互(翻页/工具栏/设置/高亮/搜索)-> 持久化`。
|
||
|
||
## 2. 关键对象职责
|
||
|
||
### 2.1 主控制器 `RDEPUBReaderController`
|
||
|
||
- 文件:`EPUBUI/RDEPUBReaderController.swift`(~1255 行)
|
||
- 入口方法:
|
||
- `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`(默认 40/16/40/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 个可选方法(全部有默认空实现):
|
||
- `didOpen`:成功打开出版物
|
||
- `didUpdateLocation`:翻页或滚动时位置更新
|
||
- `didReachEnd`:到达最后一页
|
||
- `didChangeSelection`:文本选择变化或清除
|
||
- `didUpdateHighlights`:高亮增删改
|
||
- `didUpdateBookmarks`:书签增删改
|
||
- `didUpdateSearchResult`:搜索状态变化
|
||
- `didChangeCurrentSearchMatch`:当前搜索匹配项变化
|
||
- `didUpdateCurrentTableOfContentsItem`:翻页时匹配的目录项
|
||
- `didActivateExternalLink`:外部链接点击
|
||
- `didFailWithError`:解析或分页错误
|
||
- `configureTopToolView`:自定义顶部工具栏
|
||
|
||
### 2.4 持久化 `RDEPUBReaderPersistence`
|
||
|
||
- 文件:`EPUBUI/RDEPUBReaderPersistence.swift`
|
||
- 协议定义 6 个方法:loadLocation / saveLocation / loadHighlights / saveHighlights / loadReaderSettings / saveReaderSettings
|
||
- 默认实现 `RDEPUBUserDefaultsPersistence`:
|
||
- 位置:`ssreader.epub.location.<bookIdentifier>`(JSON 编码)
|
||
- 高亮:`ssreader.epub.highlights.<bookIdentifier>`(JSON 编码)
|
||
- 设置:`ssreader.epub.settings`(全局,非按书)
|
||
|
||
### 2.5 主题 `RDEPUBReaderTheme`
|
||
|
||
- 文件:`EPUBUI/RDEPUBReaderTheme.swift`
|
||
- 6 个颜色属性:contentBackgroundColor、contentTextColor、toolBackgroundColor、toolControlTextColor、toolControlBorderUnselectColor、toolLineColor
|
||
- 6 个内置预设:`.light`、`.dark`、`.yellow`、`.green`、`.pink`、`.blue`
|
||
- 计算属性 `themeBackgroundColorCSS` / `themeTextColorCSS` 用于 Web 渲染路径
|
||
|
||
### 2.6 设置面板 `RDEPUBReaderSettingsViewController`
|
||
|
||
- 文件:`EPUBUI/RDEPUBReaderSettingsViewController.swift`
|
||
- 5 个控制项:
|
||
- 亮度滑块(0-1)
|
||
- 字号 A-/A+(范围 12-36,步长 1)
|
||
- 行高分段(紧凑 1.3 / 标准 1.6 / 宽松 1.9)
|
||
- 显示模式分段(仿真 / 横滑 / 竖滑 / 覆盖)
|
||
- 主题选择(6 个圆形色块按钮)
|
||
- 所有变更通过闭包实时回调
|
||
|
||
### 2.7 内容视图
|
||
|
||
- `RDEPUBTextContentView`(`EPUBUI/RDEPUBTextContentView.swift`):textReflowable 路径,UITextView 展示富文本,支持系统选区、标注菜单、用户标注叠加和搜索高亮叠加
|
||
- `RDEPUBWebContentView`(`EPUBUI/RDEPUBWebContentView.swift`):web 路径,包装 `RDEPUBWebView`,转发位置、选区、标注菜单、链接和 JS 错误事件
|
||
|
||
### 2.8 其他 UI 组件
|
||
|
||
- `RDEPUBReaderTopToolView`:顶部导航栏(返回按钮 + 标题)
|
||
- `RDEPUBReaderBottomToolView`:底部工具栏(目录 / 批注 / 标注 / 设置 4 个按钮)
|
||
- `RDEPUBReaderChapterListController`:目录列表(modal UITableViewController,当前项高亮 systemBlue)
|
||
- `RDEPUBReaderHighlightsViewController`:标注管理(全部 / 批注 / 划线过滤,列表 + 编辑备注 + 删除 + 跳转,空态按过滤条件提示)
|
||
|
||
## 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:)`:保存 textBook,reloadData
|
||
|
||
**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` rangeInfo,Web 内容通过 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.01,reflowable 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 解析失败:显示 errorLabel,delegate 收到 `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 Book Identifier
|
||
|
||
- 优先使用 `parser.metadata.identifier`
|
||
- 回退使用 `epubURL.lastPathComponent`
|
||
|
||
### 5.3 设置快照
|
||
|
||
```swift
|
||
RDEPUBReaderSettings (Codable)
|
||
├── brightness: CGFloat
|
||
├── fontSize: CGFloat
|
||
├── lineHeightMultiple: CGFloat
|
||
├── displayMode: RDEPUBReaderDisplayMode
|
||
└── themePreset: RDEPUBReaderThemePreset?
|
||
```
|
||
|
||
### 5.4 目录扁平化项
|
||
|
||
```swift
|
||
RDEPUBReaderTableOfContentsItem
|
||
├── title: String
|
||
├── href: String
|
||
├── depth: Int // 缩进层级
|
||
└── pageNumber: Int? // 可能为 nil
|
||
```
|
||
|
||
## 6. 视图绑定规则
|
||
|
||
- 规则 1:内容视图通过 class name 注册到 RDReaderView,出队时根据 textBook 是否存在选择类型
|
||
- 规则 2:RDEPUBTextContentView 展示 attributedText + 搜索高亮叠加 + 页码标签("N / M")
|
||
- 规则 3:RDEPUBWebContentView 包装 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 处理
|