ReadViewSDK/Doc/BUSINESS_LOGIC.md
shenlei d15f20b097 feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理
- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip)
- 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示
- 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配
- 新增 RDEPUBSelectionLoupeView 选区放大镜
- 新增 MetadataParseWorker/CancellationController 元数据解析取消机制
- 重构 PresentationRuntime/PaginationCoordinator 精简职责
- 优化 ChapterLoader/WarmupOrchestrator 异步章节加载
- CFI 模块微调与 NoteModels 更新
- 清理冗余文档,更新架构/UML/业务逻辑文档

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 17:47:24 +08:00

20 KiB
Raw Blame History

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 阅读配置文件判断

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 → 用户可立即阅读
    │
    └─ 后台解析RDEPUBMetadataParseWorker.start()
        ├─ 预计算所有章节 contentHash串行~1-3s
        ├─ 恢复已有磁盘缓存
        ├─ 等待用户操作冷却 0.8s
        ├─ 并发渲染未缓存章节OperationQueueN=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:) 按以下优先级:

  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 模式支持增量构建:

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 数据模型

// 统一标注类型
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 属性注入:

// 注入高亮属性
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. 为每个匹配生成:
    • progression0.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 管理:

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 协议持久化:

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. 渲染+分页: 复用 RDEPUBTextRendererRDEPUBCoreTextPageFrameFactory

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