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
+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 |