ReadViewSDK/Doc/BUSINESS_LOGIC.md

621 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ReadViewSDK 业务逻辑文档
> 最后更新2026-06-09
---
## 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 bookIdentifier: String?
let kind: RDEPUBAnnotationKind // .bookmark, .highlight, .underline
let location: RDEPUBLocation
let text: String?
let rangeInfo: String? // CoreText 选区范围信息
let chapterTitle: String?
let createdAt: Date
// computed: bookmark, highlight
}
// 书签
struct RDEPUBBookmark {
let id: String
let bookIdentifier: String?
let location: RDEPUBLocation
let chapterTitle: String?
let note: String?
let createdAt: Date
}
// 高亮
struct RDEPUBHighlight {
let id: String
let bookIdentifier: String?
let location: RDEPUBLocation
let text: String
let style: RDEPUBHighlightStyle // .highlight, .underline
let color: String
let note: String?
let rangeInfo: String?
let createdAt: Date
}
```
### 6.2 选区处理流程
SDK 支持两条选区处理路径,分别对应 WebView 渲染和原生文本渲染:
**路径 AWebView 渲染webInteractive / webFixedLayout**
```
用户长按 → WKWebView 选区变化
JS Bridge: ssReaderSelectionChanged
RDEPUBReaderAnnotationCoordinator
├─ 解析选区位置 (RDEPUBLocation)
├─ 提取选中文本
├─ 创建 RDEPUBSelection
└─ 显示操作菜单(拷贝/高亮/批注)
```
**路径 B原生文本渲染textReflowable**
```
用户长按 → RDEPUBTextContentView.handleLongPress(_:)
├─ layoutIfNeeded() // 确保布局完成
RDEPUBTextSelectionController.handleLongPress(_:)
├─ RDEPUBPageInteractionController.characterIndexForViewPoint()
│ └─ CoreText CTLineGetStringIndexForPosition 字符命中
├─ 计算选区范围 (NSRange)
├─ isSelecting = true
用户拖拽 → handlePan(_:)
├─ 更新选区范围
├─ RDEPUBSelectionOverlayView 绘制选区高亮
└─ .ended/.cancelled/.failed → isSelecting = false保留选区
RDEPUBTextContentView delegate → RDEPUBReaderAnnotationCoordinator
├─ 创建 RDEPUBSelection含 rangeInfo
└─ 显示操作菜单(拷贝/高亮/批注)
```
### 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 全文搜索
SDK 提供两个搜索引擎,分别服务于不同的渲染路径:
**文本渲染路径:`RDEPUBTextSearchEngine`**
`RDEPUBTextSearchEngine.search(keyword:)`
1. 遍历 `RDEPUBTextBook.chapters` 中的所有章节
2. 对每个章节的 `attributedContent`(已渲染的 NSAttributedString执行搜索
3. 使用 `NSString.range(of:options:.caseInsensitive)` 进行大小写不敏感搜索
4. 为每个匹配生成:
- `progression`0.0-1.0 的阅读进度
- `previewText`:匹配位置前后各 12 字符
- `rangeAnchor`精确的文本锚点RDEPUBTextRangeAnchor
**纯文本文件搜索:`RDEPUBTextSearchEngine.searchWithoutPublication(textBook:keyword:)`**
静态方法,用于 `.txt` 文件(有 TextBook 但无 Publication的搜索
1. 遍历 TextBook 章节的 attributedContent
2. 跳过 publication 依赖的 href 规范化
3. 不生成 rangeAnchor纯文本无 CoreText 锚点)
**WebView 渲染路径:`RDEPUBHTMLSearchEngine`**
1. 遍历所有 linear spine 条目html/xhtml 类型)
2. 读取 HTML转为纯文本正则去除标签
3. 执行大小写不敏感搜索
4. 通过 JS Bridge 在 WebView DOM 中绘制搜索高亮
### 7.2 搜索结果导航
搜索结果通过 `RDEPUBSearchState` 管理:
```swift
struct RDEPUBSearchState {
let keyword: String
let matches: [RDEPUBSearchMatch]
var currentMatchIndex: Int?
}
```
**搜索结果导航流程:**
`RDEPUBReaderSearchCoordinator` 协调搜索结果的前进/后退导航:
1. `goToNextMatch()` / `goToPreviousMatch()` 更新 `currentMatchIndex`
2. 根据匹配位置计算目标页码,触发页面跳转
3. 通知 `RDEPUBReaderController` 更新搜索计数显示
**搜索结果渲染:**
- **WebView 路径:** 通过 JS Bridge 接收搜索高亮数据,在 DOM 中绘制高亮矩形
- **原生文本路径:** `RDEPUBTextContentView` 根据 `rangeAnchor` 在 CoreText 绘制层高亮匹配文本
**搜索栏生命周期:**
搜索栏采用延迟安装模式:
1. `showSearchBar()` 设置 `isSearchBarVisible = true`,仅在工具栏可见时立即安装视图
2. 若工具栏隐藏,延迟到 `handleToolViewVisibilityChanged(isVisible: true)` 时安装
3. `installSearchBarView()` 负责实际的视图层级添加、约束、动画和焦点管理
---
## 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 |