- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
20 KiB
ReadViewSDK 业务逻辑文档
最后更新:2026-06-09
1. EPUB 解析流程
1.1 文件解压
入口: RDEPUBParser.parse(epubURL:)
EPUB 文件本质是 ZIP 压缩包。解压流程:
- 计算缓存目录:
~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/ - 如果缓存目录已存在且包含
META-INF/container.xml,跳过解压 - 否则使用
ZIPFoundation解压到缓存目录 - 解压是幂等的,重复调用不会重复解压
1.2 OPF 解析
OPF(Open 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(目录)按优先级尝试三种来源:
- NCX(EPUB 2): 解析
<navPoint>树形结构,支持嵌套 - Navigation Document(EPUB 3): 解析
<nav epub:type="toc">中的<ol>/<li>/<a>结构 - 回退: 直接使用 spine 条目的 title 列表
1.4 阅读配置文件判断
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 个阶段:
阶段 1:HTML 规范化 (RDEPUBHTMLNormalizer)
- 删除
<hr lang="zh-CN">分页符</hr>分页标记 - CR → LF,合并连续空行
- 规范化附件 HTML 标记:
div.qrbodyPic / div.bodyPic→ 合并样式到<img>img.qqreader-footnote→ 行内 1em×1emh1.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>中
阶段 4:CSS 层合成 (RDEPUBStyleSheetComposer)
- 构建 5 层 CSS,按优先级从低到高:
- default - 基础阅读器样式(隐藏 head/title/style,默认字体大小)
- replace - 微信读书风格格式化(代码块、标题、引用)
- dark - 暗色模式覆盖(仅暗色主题时注入)
- epub - EPUB 自带样式
- user - 用户设置(字号、行距、颜色,!important)
- 语言检测:检查 lang 属性和文本采样,拉丁语言使用专用 CSS
阶段 5:字体注册 (RDEPUBFontNormalizer)
- 解析
@font-face { url(...) }块 - 通过
CTFontManagerRegisterFontsForURL(.process)注册嵌入字体 - 已注册字体路径缓存在
registeredFontPaths集合中,避免重复注册
阶段 6:Base URL 注入 (RDEPUBHTMLNormalizer)
- 注入
<base href="...">用于相对路径解析
阶段 7:Fragment 标记注入 (RDEPUBFragmentMarkerInjector)
- 查找
<tag id="xxx">标签 - 在其前面注入
${id=xxx}标记
阶段 8:图片诊断收集 (RDEPUBRenderDiagnosticsCollector)
- 扫描
<img src="...">标签 - 解析引用路径,检查文件是否存在
- 收集诊断信息用于调试
2.2 HTML→NSAttributedString
RDEPUBDTCoreTextRenderer.renderChapter(request:):
- 将 HTML 编码为
Data - 使用
DTHTMLAttributedStringBuilder构建NSAttributedString - 在
willFlushCallback中对每个 DOM 元素调用RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering() - 后处理:
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() 三遍处理:
- 删除空白中间帧: 无可见字符且无附件的帧
- 删除空白尾部帧: 从末尾开始删除同类空白帧
- 合并短尾帧: 如果最后一帧 ≤2 个可见字符且前一帧 ≥8 倍长,合并
3. 后台元数据解析(大书优化)
3.1 整体流程
对于《凡人修仙传》这样的大书(~1000 章),后台解析是核心性能路径。
打开书籍
│
├─ 尝试磁盘缓存恢复(restoreBookPageMapIfPossible)
│ └─ 成功 → 直接显示完整页码
│
├─ 快速打开(3-5 章窗口)
│ ├─ 同步加载首个可渲染章节
│ ├─ 加载窗口内相邻章节
│ └─ 应用局部 BookPageMap → 用户可立即阅读
│
└─ 后台解析(RDEPUBMetadataParseWorker.start())
├─ 预计算所有章节 contentHash(串行,~1-3s)
├─ 恢复已有磁盘缓存
├─ 等待用户操作冷却 0.8s
├─ 并发渲染未缓存章节(OperationQueue,N=CPU核心数)
├─ 每 32 章增量刷新 UI
└─ 最终完整刷新
3.2 预计算 contentHash 优化
问题: 每个章节在渲染后需要构建缓存键,原始实现会重新读取 HTML 并计算 SHA-256。
优化: 在后台解析开始时,串行预计算所有章节的 contentHash:
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
let html = parser.htmlString(forRelativePath: href)
contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? ""
}
后续所有 chapterCacheKey 调用都使用预计算值,避免重复 I/O 和 SHA-256 计算。
3.3 冻结 renderSignature
问题: 如果用户在后台解析进行中更改字号/行距,context.currentRenderSignature() 会返回新值,导致部分章节用旧签名、部分用新签名写入磁盘缓存,造成缓存不一致。
解决: 在 RDEPUBMetadataParseWorker 初始化时冻结签名:
let renderSignature = context.currentRenderSignature()
// 后续所有 chapterCacheKey 调用使用此固定值
token 机制确保设置变更会触发新的解析任务(新 token),旧任务自动废弃。
3.4 锁区瘦身
原始实现: resultLock 内调用 buildPageMap(),该方法遍历全量 catalog 和 summaries。
优化: 锁内只做写入和计数,快照数据后锁外构建 pageMap:
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 可配置刷新间隔
static var pageMapRefreshInterval: Int = 32 // 默认 32 章
可实测调优:32 / 48 / 64。值越大,UI 刷新频率越低,后台解析吞吐越高。
4. 章节按需加载
4.1 加载优先级
RDEPUBChapterLoader.loadChapter(spineIndex:store:) 按以下优先级:
- Tier 1 内存缓存 -
store.chapterData(for: spineIndex)→ 直接返回 - Tier 1 页数缓存 -
store.pageCount(for: cacheKey)→ 跳过分页计算 - Tier 2 磁盘缓存 -
summaryDiskCache.read(for: cacheKey)→ 恢复页范围 - 全量构建 - 渲染 + 分页 + 写缓存
4.2 窗口驱逐策略
RDEPUBChapterRuntimeStore.setCurrentChapter(spineIndex:totalSpineCount:windowRadius:):
- 保留范围:
[spineIndex - windowRadius, spineIndex + windowRadius] - 窗口半径由
configuration.chapterWindowRadius控制 - 超出窗口的章节从内存缓存中驱逐
- 内存警告时驱逐除当前章节外的所有缓存
4.3 BookPageMap 增量刷新
RDEPUBBookPageMap 是轻量页码映射(~100KB/1000 章),不持有 NSAttributedString。
Builder 模式支持增量构建:
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
页面配对逻辑(RDEpubReaderSpreadResolver):
- 有封面页:封面独占一屏,后续页面两两配对
- 配对规则:
(coverIndex, nil),(1, 2),(3, 4), ...
- 配对规则:
- 无封面页:标准偶奇配对
- 配对规则:
(0, 1),(2, 3),(4, 5), ...
- 配对规则:
封面感知布局(RDEpubReaderFlowLayout):
封面页: [====全屏宽度====]
配对页: [==半宽==][==半宽==]
5.3 页面预加载
RDEpubReaderPreloadController 管理两个缓存:
preloadedPageViews- 预渲染的页面视图pageCurlCachedViews- 当前显示的页面视图
预加载策略:
- 每次页面变化后调用
prime(around:preferredForward:) - 计算预测目标:前后各
radius个展开页 + 预测方向额外 1 个展开页 - 将目标页面预渲染到隐藏的
preloadHostView - 缓存签名(displayType + isLandscape + pagesPerScreen + boundsSize)变化时清除缓存
5.4 点击区域处理
屏幕三等分:
[ 左 1/3 ][ 中 1/3 ][ 右 1/3 ]
上一页 切换工具栏 下一页
RTL 模式下左右互换。工具栏显示时,左右区域变为 .center(隐藏工具栏)。
6. 标注系统
6.1 数据模型
// 统一标注类型
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 渲染和原生文本渲染:
路径 A:WebView 渲染(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 属性注入:
// 注入高亮属性
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:):
- 遍历
RDEPUBTextBook.chapters中的所有章节 - 对每个章节的
attributedContent(已渲染的 NSAttributedString)执行搜索 - 使用
NSString.range(of:options:.caseInsensitive)进行大小写不敏感搜索 - 为每个匹配生成:
progression:0.0-1.0 的阅读进度previewText:匹配位置前后各 12 字符rangeAnchor:精确的文本锚点(RDEPUBTextRangeAnchor)
纯文本文件搜索:RDEPUBTextSearchEngine.searchWithoutPublication(textBook:keyword:)
静态方法,用于 .txt 文件(有 TextBook 但无 Publication)的搜索:
- 遍历 TextBook 章节的 attributedContent
- 跳过 publication 依赖的 href 规范化
- 不生成 rangeAnchor(纯文本无 CoreText 锚点)
WebView 渲染路径:RDEPUBHTMLSearchEngine
- 遍历所有 linear spine 条目(html/xhtml 类型)
- 读取 HTML,转为纯文本(正则去除标签)
- 执行大小写不敏感搜索
- 通过 JS Bridge 在 WebView DOM 中绘制搜索高亮
7.2 搜索结果导航
搜索结果通过 RDEPUBSearchState 管理:
struct RDEPUBSearchState {
let keyword: String
let matches: [RDEPUBSearchMatch]
var currentMatchIndex: Int?
}
搜索结果导航流程:
RDEPUBReaderSearchCoordinator 协调搜索结果的前进/后退导航:
goToNextMatch()/goToPreviousMatch()更新currentMatchIndex- 根据匹配位置计算目标页码,触发页面跳转
- 通知
RDEPUBReaderController更新搜索计数显示
搜索结果渲染:
- WebView 路径: 通过 JS Bridge 接收搜索高亮数据,在 DOM 中绘制高亮矩形
- 原生文本路径:
RDEPUBTextContentView根据rangeAnchor在 CoreText 绘制层高亮匹配文本
搜索栏生命周期:
搜索栏采用延迟安装模式:
showSearchBar()设置isSearchBarVisible = true,仅在工具栏可见时立即安装视图- 若工具栏隐藏,延迟到
handleToolViewVisibilityChanged(isVisible: true)时安装 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 协议持久化:
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) 支持
RDEpubPlainTextBookBuilder 复用 EPUB 渲染管线处理 .txt 文件:
- 解码: 依次尝试 UTF-8 → GB18030 → GBK
- 分章: 正则匹配
^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$ - 包装 HTML: 每行包裹
<p>标签 - 渲染+分页: 复用
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 |