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:
parent
d20196ee34
commit
0e7c0577e3
@ -1,507 +1,440 @@
|
|||||||
# RDReaderView 架构文档
|
# ReadViewSDK 系统架构文档
|
||||||
|
|
||||||
|
> 最后更新:2026-06-04
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. 项目概览
|
## 1. 项目概览
|
||||||
|
|
||||||
RDReaderView 是一个 iOS 阅读器组件库(CocoaPods),提供开箱即用的 EPUB 阅读能力,并保留对纯文本翻页的支持。
|
ReadViewSDK 是一个 iOS EPUB 阅读器 SDK,支持文本重排(Reflowable)和固定布局(Fixed Layout)两种 EPUB 格式,同时兼容纯文本 (.txt) 文件。SDK 提供完整的 EPUB 解析、文本渲染、分页计算、阅读 UI 和标注管理能力。
|
||||||
|
|
||||||
- **最低 iOS 版本**:15.0
|
**技术栈:**
|
||||||
- **Swift 版本**:5.10+
|
- 语言:Swift 5,最低 iOS 15.6
|
||||||
- **依赖**:ZIPFoundation(EPUB 解压)、DTCoreText(文本 EPUB 渲染)
|
- 依赖:DTCoreText(HTML→NSAttributedString)、ZIPFoundation(EPUB 解压)、SnapKit(Auto Layout)、SSAlertSwift(弹窗)
|
||||||
- **Demo 额外依赖**:SnapKit、SSAlertSwift
|
- 构建:CocoaPods,本地 pod 引用
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 总体分层
|
## 2. 分层架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ ReadViewDemo (Demo App) │
|
||||||
|
│ ViewController · LaunchAutomationPlan · UITests │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│ imports RDReaderView
|
||||||
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
|
│ EPUBUI 层 │
|
||||||
|
│ RDEPUBReaderController · Coordinators · Settings · TextPage │
|
||||||
|
│ ChapterRuntime(Loader · Store · DiskCache · PageMap) │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
|
│ RDReaderView 层 │
|
||||||
|
│ RDReaderView · FlowLayout · PreloadController │
|
||||||
|
│ SpreadResolver · TapRegionHandler · PagingController │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
|
│ EPUBTextRendering 层 │
|
||||||
|
│ TypesetterPipeline · DTCoreTextRenderer · Pagination │
|
||||||
|
│ BuildPipeline(TextBookBuilder · Cache · Sampler) │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────────────────▼──────────────────────────────┐
|
||||||
|
│ EPUBCore 层 │
|
||||||
|
│ RDEPUBParser · Publication · ResourceResolver │
|
||||||
|
│ ReadingSession · WebView · JavaScriptBridge · Paginator │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 各层职责
|
||||||
|
|
||||||
|
| 层 | 职责 | 核心类 |
|
||||||
|
|---|---|---|
|
||||||
|
| **EPUBCore** | EPUB 文件解析、资源管理、WebView 渲染、JS 桥接 | `RDEPUBParser`, `RDEPUBPublication`, `RDEPUBWebView` |
|
||||||
|
| **EPUBTextRendering** | HTML→NSAttributedString 转换、排版、分页、全书构建 | `RDEPUBTextBookBuilder`, `RDEPUBCoreTextPageFrameFactory` |
|
||||||
|
| **RDReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDReaderView`, `RDReaderFlowLayout`, `RDReaderPreloadController` |
|
||||||
|
| **EPUBUI** | 阅读器控制器、协调器模式、设置面板、按需加载、磁盘缓存 | `RDEPUBReaderController`, `RDEPUBReaderPaginationCoordinator` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心数据流
|
||||||
|
|
||||||
|
### 3.1 打开书籍流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户点击书籍
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RDEPUBReaderController.openBook(epubURL:)
|
||||||
|
│
|
||||||
|
├─ RDEPUBParser.parse(epubURL:) // 解压 ZIP → 解析 OPF → 构建 spine/TOC
|
||||||
|
│ └─ extractArchiveIfNeeded() // ZIPFoundation 解压到 Caches
|
||||||
|
│ └─ parseContainerRootFile() // SAX 解析 container.xml
|
||||||
|
│ └─ parseOPF() // SAX 解析 OPF (metadata/manifest/spine)
|
||||||
|
│ └─ parseTOC() // NCX 或 Navigation Document
|
||||||
|
│
|
||||||
|
├─ RDEPUBPublication(parser:) // 创建门面对象
|
||||||
|
│
|
||||||
|
├─ 判断 readingProfile:
|
||||||
|
│ ├─ .textReflowable → 文本排版路径(本 SDK 核心路径)
|
||||||
|
│ ├─ .webInteractive → WebView 渲染路径
|
||||||
|
│ └─ .webFixedLayout → 固定布局路径
|
||||||
|
│
|
||||||
|
└─ paginateTextPublication() // 进入分页流程
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 文本重排分页流程(大书优化路径)
|
||||||
|
|
||||||
|
```
|
||||||
|
paginateTextPublication()
|
||||||
|
│
|
||||||
|
├─ restoreBookPageMapIfPossible() // 尝试从磁盘恢复完整 BookPageMap
|
||||||
|
│ └─ summaryDiskCache.readAll(keys:) // 批量读取磁盘摘要
|
||||||
|
│ └─ 如果完整 → 直接 applyBookPageMap() → 完成
|
||||||
|
│
|
||||||
|
├─ 快速打开路径:
|
||||||
|
│ ├─ loadFirstRenderableRuntimeChapter() // 加载第一个可渲染章节
|
||||||
|
│ ├─ loadInitialRuntimeChapters() // 加载窗口内相邻章节
|
||||||
|
│ ├─ makePartialPageMap() // 构建局部 BookPageMap
|
||||||
|
│ └─ applyBookPageMap() // 应用到 UI,用户可立即阅读
|
||||||
|
│
|
||||||
|
└─ paginateMetadataOnly() // 后台元数据解析
|
||||||
|
│
|
||||||
|
├─ 预计算 contentHash(串行) // 读取所有章节 HTML + SHA-256
|
||||||
|
├─ readAll(keys:) 恢复已有缓存
|
||||||
|
├─ waitForReadingInteractionToSettle() // 等待用户操作冷却 0.8s
|
||||||
|
│
|
||||||
|
├─ OperationQueue (并发 N):
|
||||||
|
│ ├─ buildChapter() // 渲染单章 (HTML→NSAttrStr→分页)
|
||||||
|
│ ├─ chapterCacheKey() // 构建缓存键(复用预计算 hash)
|
||||||
|
│ ├─ RDEPUBChapterSummary 写盘 // 异步写入磁盘摘要
|
||||||
|
│ └─ 每 32 章刷新 BookPageMap // 增量更新 UI
|
||||||
|
│
|
||||||
|
└─ 最终 applyBookPageMap() // 完整页码映射
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 单章渲染管线
|
||||||
|
|
||||||
|
```
|
||||||
|
Raw HTML (从 EPUB 解压目录读取)
|
||||||
|
│
|
||||||
|
▼ [RDEPUBTextTypesetterPipeline]
|
||||||
|
├─ RDEPUBHTMLNormalizer // 去 CR、合并空行、规范化附件标记
|
||||||
|
├─ RDEPUBSemanticMarkerInjector // 注入分页语义标记 ${rd-sem-start/end}
|
||||||
|
├─ RDEPUBRenderDiagnosticsCollector // 内联 <link stylesheet>、收集图片诊断
|
||||||
|
├─ RDEPUBStyleSheetComposer // 构建 5 层 CSS (default/replace/dark/epub/user)
|
||||||
|
├─ RDEPUBFontNormalizer // 注册 @font-face 嵌入字体
|
||||||
|
├─ RDEPUBFragmentMarkerInjector // 注入 fragment 锚点标记 ${id=xxx}
|
||||||
|
│
|
||||||
|
▼ [RDEPUBDTCoreTextRenderer]
|
||||||
|
├─ DTHTMLAttributedStringBuilder // HTML → NSAttributedString
|
||||||
|
├─ applyPaginationSemantics() // 将语义标记转为NSAttributedString属性
|
||||||
|
├─ extractFragmentOffsets() // 提取 fragment ID→offset 映射
|
||||||
|
├─ normalizeReadingAttributes() // 规范化字体、行距、颜色、附件
|
||||||
|
│
|
||||||
|
▼ [RDEPUBChapterPageCounter]
|
||||||
|
├─ CTFramesetter 创建帧
|
||||||
|
├─ RDEPUBPageBreakPolicy 应用语义边界规则
|
||||||
|
│ ├─ avoidPageBreakInside // 避免块内分页
|
||||||
|
│ ├─ keepWithNext // 标题与正文不分离
|
||||||
|
│ ├─ widow/orphan 控制 // 孤行/寡行控制
|
||||||
|
│ └─ pageRelate // 微信读书跨页关联
|
||||||
|
├─ RDEPUBChapterTailNormalizer // 删除/合并空白尾页
|
||||||
|
│
|
||||||
|
▼ [RDEPUBTextBookBuilder]
|
||||||
|
└─ 组装 RDEPUBTextPage[] → RDEPUBTextChapter → RDEPUBTextBook
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 缓存架构(三级缓存)
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ Demo / 宿主 App │
|
│ Tier 1: 内存缓存 (RDEPUBChapterRuntimeStore) │
|
||||||
│ ViewController → RDURLReaderController(demo 级路由控制器)│
|
│ ├─ chapterDataCache: [Int: RDEPUBRuntimeChapter] │
|
||||||
└───────────────────────────┬─────────────────────────────┘
|
│ ├─ pageCountCache: [CacheKey: RDEPUBRuntimePageCount] │
|
||||||
│
|
│ ├─ imageCache: NSCache<NSString, UIImage> (50 上限) │
|
||||||
┌───────────────────────────▼─────────────────────────────┐
|
│ └─ 窗口驱逐:只保留当前章节 ± windowRadius 的章节 │
|
||||||
│ EPUBUI 层(library 级读者 UI) │
|
└──────────────────────────┬──────────────────────────────┘
|
||||||
│ │
|
│ miss
|
||||||
│ 主控制器 │
|
┌──────────────────────────▼──────────────────────────────┐
|
||||||
│ RDEPUBReaderController(开箱即用入口) │
|
│ Tier 2: 磁盘摘要缓存 (RDEPUBChapterSummaryDiskCache) │
|
||||||
│ +ContentDelegates / +DataSource / +PublicAPI │
|
│ ├─ 路径: ~/Caches/RDEPUBChapterSummaryCache/{bookID}/ │
|
||||||
│ +RenderSupport / +RuntimeBridge / +TableOfContents │
|
│ ├─ 文件名: SHA256(bookID_spineIdx_renderSig_contentHash)│
|
||||||
│ RDURLReaderController(URL 阅读入口) │
|
│ ├─ 格式: JSON (RDEPUBChapterSummary) │
|
||||||
│ │
|
│ ├─ 写入: 异步(serial DispatchQueue) │
|
||||||
│ ReaderController/(协调器) │
|
│ └─ 读取: 同步(readAll 批量读取) │
|
||||||
│ RDEPUBReaderRuntime(中央运行时协调器) │
|
└──────────────────────────┬──────────────────────────────┘
|
||||||
│ RDEPUBReaderContext(上下文状态容器) │
|
│ miss
|
||||||
│ RDEPUBReaderDependencies(依赖注入) │
|
┌──────────────────────────▼──────────────────────────────┐
|
||||||
│ RDEPUBReaderLoadCoordinator(EPUB 加载) │
|
│ Tier 3: 全书分页缓存 (RDEPUBTextBookCache) │
|
||||||
│ RDEPUBReaderPaginationCoordinator(分页协调) │
|
│ ├─ 路径: ~/Caches/RDEPUBTextBookCache/ │
|
||||||
│ RDEPUBReaderLocationCoordinator(位置持久化) │
|
│ ├─ 格式: NSSecureCoding archive │
|
||||||
│ RDEPUBReaderAnnotationCoordinator(标注管理) │
|
│ ├─ 内容: 每章的 pageRanges + breakReasons + semanticHints│
|
||||||
│ RDEPUBReaderSearchCoordinator(搜索) │
|
│ └─ 失效: schemaVersion 变更时全部失效 │
|
||||||
│ RDEPUBReaderChromeCoordinator(工具栏) │
|
└─────────────────────────────────────────────────────────┘
|
||||||
│ RDEPUBReaderAssemblyCoordinator(UI 组装) │
|
|
||||||
│ RDEPUBReaderViewportMonitor(视口变化监听) │
|
|
||||||
│ │
|
|
||||||
│ Settings/(配置与主题) │
|
|
||||||
│ RDEPUBReaderConfiguration / RDEPUBReaderSettings │
|
|
||||||
│ RDEPUBReaderSettingsViewController / RDEPUBReaderTheme │
|
|
||||||
│ │
|
|
||||||
│ TextPage/(文本页面交互) │
|
|
||||||
│ RDEPUBTextContentView / RDEPUBTextPageRenderView │
|
|
||||||
│ RDEPUBSelectableTextView / RDEPUBTextSelectionController│
|
|
||||||
│ RDEPUBSelectionOverlayView / RDEPUBTextAnnotationOverlay│
|
|
||||||
│ RDEPUBPageInteractionController / RDEPUBPageLayoutSnapshot│
|
|
||||||
│ RDEPUBTextPageDecorationView │
|
|
||||||
│ │
|
|
||||||
│ 工具栏与面板 │
|
|
||||||
│ RDEPUBReaderTopToolView / RDEPUBReaderBottomToolView │
|
|
||||||
│ RDEPUBReaderToolView(基类) │
|
|
||||||
│ RDEPUBReaderChapterListController(目录面板) │
|
|
||||||
│ RDEPUBReaderHighlightsViewController(高亮管理) │
|
|
||||||
│ RDEPUBReaderPersistence(位置持久化) │
|
|
||||||
│ RDEPUBReaderDelegate / RDEPUBReaderTableOfContentsItem│
|
|
||||||
│ RDEPUBWebContentView / RDEPUBWebDecorationOverlayView │
|
|
||||||
│ RDEPUBViewportTypes / UIColor+RDEPUBHex │
|
|
||||||
└───────────────────────────┬─────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌───────────────────────────▼─────────────────────────────┐
|
|
||||||
│ 翻页容器层(RDReaderView) │
|
|
||||||
│ │
|
|
||||||
│ RDReaderView(UIView,统一翻页外壳) │
|
|
||||||
│ 3 种翻页模式:pageCurl / horizontalScroll / │
|
|
||||||
│ verticalScroll │
|
|
||||||
│ RDReaderViewProtocols(DataSource / Delegate / DisplayType)│
|
|
||||||
│ +CollectionView / +ContentAccess / +PageCurl / +ToolView │
|
|
||||||
│ RDReaderFlowLayout / RDReaderContentCell │
|
|
||||||
│ RDReaderPageChildViewController(pageCurl 页包装) │
|
|
||||||
│ RDReaderGestureController │
|
|
||||||
│ │
|
|
||||||
│ Paging/(翻页控制) │
|
|
||||||
│ RDReaderPagingController(转场与排队) │
|
|
||||||
│ RDReaderPreloadController(预加载与缓存) │
|
|
||||||
│ RDReaderSpreadResolver(双页配对) │
|
|
||||||
│ RDReaderTapRegionHandler(手势分区) │
|
|
||||||
└───────────────────────────┬─────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌───────────────────────────▼─────────────────────────────┐
|
|
||||||
│ EPUBCore 层(EPUB 引擎) │
|
|
||||||
│ │
|
|
||||||
│ 解析与模型 │
|
|
||||||
│ RDEPUBParser(+Archive / +Package / +TOC / │
|
|
||||||
│ +ReadingProfile / +Resources) │
|
|
||||||
│ RDEPUBPublication(出版物聚合对象) │
|
|
||||||
│ RDEPUBModels(metadata / manifest / spine 模型) │
|
|
||||||
│ Models/ │
|
|
||||||
│ RDEPUBReadingLocationModels(location 模型) │
|
|
||||||
│ RDEPUBPaginationModels(分页模型) │
|
|
||||||
│ RDEPUBAnnotationModels(标注模型) │
|
|
||||||
│ RDEPUBTextAnchor / RDEPUBTextRangeAnchor(文本锚点) │
|
|
||||||
│ RDEPUBRenderRequest(渲染请求模型) │
|
|
||||||
│ │
|
|
||||||
│ 服务层 │
|
|
||||||
│ RDEPUBResourceResolver(资源 URL 统一入口) │
|
|
||||||
│ RDEPUBResourceURLSchemeHandler(ss-reader:// 协议) │
|
|
||||||
│ RDEPUBPreferences(展示参数聚合) │
|
|
||||||
│ RDEPUBPaginator(离屏分页服务) │
|
|
||||||
│ RDEPUBStyleSheetBuilder / RDEPUBJavaScriptBridge │
|
|
||||||
│ RDEPUBFixedLayoutTemplate / RDEPUBAssetRepository │
|
|
||||||
│ │
|
|
||||||
│ 会话与导航 │
|
|
||||||
│ RDEPUBReadingSession(状态机 + 会话协调) │
|
|
||||||
│ RDEPUBNavigatorState(状态枚举) │
|
|
||||||
│ RDEPUBNavigatorLayoutContext │
|
|
||||||
│ │
|
|
||||||
│ WebView 渲染 │
|
|
||||||
│ RDEPUBWebView(+Configuration / +Reflowable / │
|
|
||||||
│ +FixedLayout / +JavaScriptBridge / │
|
|
||||||
│ +Search) │
|
|
||||||
│ RDEPUBWebViewDebug(调试日志工具) │
|
|
||||||
│ │
|
|
||||||
│ 搜索 │
|
|
||||||
│ RDEPUBSearchEngine(协议)/ RDEPUBHTMLSearchEngine │
|
|
||||||
│ RDEPUBSearchModels(SearchMatch/Result/State/Presentation)│
|
|
||||||
└───────────────────────────┬─────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌───────────────────────────▼─────────────────────────────┐
|
|
||||||
│ EPUBTextRendering 层(文本 EPUB 渲染) │
|
|
||||||
│ │
|
|
||||||
│ 渲染 │
|
|
||||||
│ RDEPUBTextRenderer(协议) │
|
|
||||||
│ RDEPUBDTCoreTextRenderer(DTCoreText 实现) │
|
|
||||||
│ RDPlainTextBookBuilder(纯文本书籍构建) │
|
|
||||||
│ RDEPUBTextPositionConverter(位置转换器) │
|
|
||||||
│ RDEPUBTextSearchEngine(文本搜索引擎) │
|
|
||||||
│ RDEPUBTextIndexTable / RDEPUBChapterData │
|
|
||||||
│ │
|
|
||||||
│ BuildPipeline/(构建管线) │
|
|
||||||
│ RDEPUBTextBookBuilder(分页书籍构建器) │
|
|
||||||
│ RDEPUBTextBookCache / RDEPUBTextBookModels │
|
|
||||||
│ RDEPUBTextBuildPipelineInterfaces(管线协议) │
|
|
||||||
│ RDEPUBPaginationCacheCoordinator(缓存协调) │
|
|
||||||
│ RDEPUBChapterTailNormalizer(章尾规范化) │
|
|
||||||
│ RDEPUBBuildDiagnosticsReporter(诊断报告) │
|
|
||||||
│ RDEPUBTextPerformanceSampler(性能采样) │
|
|
||||||
│ │
|
|
||||||
│ Pagination/(分页引擎) │
|
|
||||||
│ RDEPUBTextLayouter / RDEPUBTextLayoutFrame │
|
|
||||||
│ RDEPUBChapterPageCounter / RDEPUBCoreTextPageFrameFactory│
|
|
||||||
│ RDEPUBPageBreakPolicy(断页策略) │
|
|
||||||
│ RDEPUBTextPaginationInterfaces(分页协议) │
|
|
||||||
│ RDEPUBTextPaginationSupport(分页支持) │
|
|
||||||
│ │
|
|
||||||
│ Typesetter/(排版管线) │
|
|
||||||
│ RDEPUBTypesettingPipeline(排版管线编排) │
|
|
||||||
│ RDEPUBHTMLNormalizer(HTML 规范化) │
|
|
||||||
│ RDEPUBStyleSheetComposer(CSS 组合) │
|
|
||||||
│ RDEPUBFontNormalizer(字体规范化) │
|
|
||||||
│ RDEPUBAttachmentNormalizer(附件规范化) │
|
|
||||||
│ RDEPUBFragmentMarkerInjector(Fragment 标记注入) │
|
|
||||||
│ RDEPUBSemanticMarkerInjector(语义标记注入) │
|
|
||||||
│ RDEPUBRenderDiagnosticsCollector(渲染诊断) │
|
|
||||||
│ RDEPUBTextRendererSupport(渲染辅助工具) │
|
|
||||||
└──────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 缓存键设计
|
||||||
|
|
||||||
## 3. 翻页容器层(RDReaderView)
|
|
||||||
|
|
||||||
### 3.1 三种翻页模式
|
|
||||||
|
|
||||||
| 模式 | 实现方式 | 特点 |
|
|
||||||
|------|----------|------|
|
|
||||||
| `pageCurl` | UIPageViewController | 原生翻书效果,手势由系统提供 |
|
|
||||||
| `horizontalScroll` | UICollectionView + RDReaderFlowLayout | 每屏显示 2 项,水平分页滚动 |
|
|
||||||
| `verticalScroll` | UICollectionView + RDReaderFlowLayout | 全宽项目,垂直连续滚动 |
|
|
||||||
|
|
||||||
### 3.2 数据源协议
|
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
public protocol RDReaderDataSource: NSObjectProtocol {
|
struct RDEPUBChapterCacheKey: Hashable {
|
||||||
func pageCountOfReaderView(readerView: RDReaderView) -> Int
|
let bookID: String // 书籍唯一标识
|
||||||
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
let spineIndex: Int // 章节索引
|
||||||
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
|
let renderSignature: String // 渲染参数签名(字体/字号/行距/布局/版本)
|
||||||
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
|
let chapterContentHash: String // 章节 HTML 的 SHA-256
|
||||||
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 手势分区(scroll 模式)
|
缓存键的四元组设计确保:
|
||||||
|
- 换字体/字号 → renderSignature 变化 → 缓存失效
|
||||||
屏幕水平三等分:
|
- EPUB 内容更新 → contentHash 变化 → 缓存失效
|
||||||
- **左 1/3**:上一页
|
- 不同书籍 → bookID 不同 → 互不干扰
|
||||||
- **中 1/3**:显示 / 隐藏工具栏
|
|
||||||
- **右 1/3**:下一页
|
|
||||||
|
|
||||||
### 3.4 翻页模式切换
|
|
||||||
|
|
||||||
`switchReaderDisplayType(_ type:)` 会完全销毁并重建底层 view(pageViewController 或 collectionView),然后重新加载数据。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. EPUB 引擎层(EPUBCore)
|
## 5. 章节按需加载架构
|
||||||
|
|
||||||
### 4.1 Publication 层:解析与聚合
|
|
||||||
|
|
||||||
**主链路:**
|
|
||||||
|
|
||||||
```
|
```
|
||||||
epubURL
|
RDEPUBReaderController
|
||||||
→ RDEPUBParser.parse(epubURL:)
|
│
|
||||||
→ extractArchive # ZIP 解压到沙盒临时目录
|
├─ RDEPUBReaderContext // 共享状态中心
|
||||||
→ parseContainerXML # 定位 OPF 路径
|
│ ├─ parser, publication, readingSession
|
||||||
→ parseOPF # 解析 metadata / manifest / spine
|
│ ├─ configuration, persistence
|
||||||
→ parseTOC # 解析 NCX 或 Nav 目录
|
│ └─ 便捷方法 (renderStyle, layoutConfig, cacheKey)
|
||||||
→ RDEPUBPublication(parser:) # 聚合解析结果,挂载 resourceResolver
|
│
|
||||||
|
├─ RDEPUBReaderRuntime // 运行时协调器集合
|
||||||
|
│ ├─ chapterLoader // 章节加载器
|
||||||
|
│ ├─ chapterRuntimeStore // 内存缓存
|
||||||
|
│ ├─ summaryDiskCache // 磁盘摘要缓存
|
||||||
|
│ └─ viewportMonitor // 视口变化监控
|
||||||
|
│
|
||||||
|
├─ RDEPUBReaderPaginationCoordinator // 分页协调器
|
||||||
|
│ ├─ paginatePublication() // 入口
|
||||||
|
│ ├─ paginateMetadataOnly() // 后台元数据解析
|
||||||
|
│ └─ restoreBookPageMapIfPossible() // 缓存恢复
|
||||||
|
│
|
||||||
|
├─ RDEPUBChapterLoader // 章节加载器
|
||||||
|
│ ├─ loadChapter() // 异步加载(Tier1→Tier2→全量构建)
|
||||||
|
│ └─ loadChapterSynchronouslyForMigration() // 同步加载(快速打开用)
|
||||||
|
│
|
||||||
|
└─ RDEPUBBookPageMap // 轻量页码映射
|
||||||
|
├─ ~100KB/1000章,不持有 NSAttributedString
|
||||||
|
└─ 支持增量刷新 (Builder pattern)
|
||||||
```
|
```
|
||||||
|
|
||||||
**RDEPUBPublication 暴露的能力:**
|
---
|
||||||
|
|
||||||
| 属性 / 方法 | 说明 |
|
## 6. WebView 渲染架构(Web 路径)
|
||||||
|------------|------|
|
|
||||||
| `metadata` | 书名、作者、语言、layout 等 |
|
|
||||||
| `manifest` | id → RDEPUBManifestItem 映射 |
|
|
||||||
| `spine` | 有序 spine 列表(包含 href) |
|
|
||||||
| `tableOfContents` | 树形目录 |
|
|
||||||
| `layout` | `.reflowable` 或 `.fixed` |
|
|
||||||
| `readingProfile` | `.webInteractive` / `.webFixedLayout` / `.textReflowable` |
|
|
||||||
| `resourceResolver` | 统一资源 URL 解析入口 |
|
|
||||||
| `fixedLayoutSpreadEnabled(for:viewportSize:)` | 判断是否启用双页 spread |
|
|
||||||
| `makeFixedSpreads(preferences:viewportSize:)` | 生成 fixed spread 页模型 |
|
|
||||||
|
|
||||||
**readingProfile 判定逻辑:**
|
|
||||||
|
|
||||||
```
|
```
|
||||||
layout == .fixed → webFixedLayout
|
RDEPUBWebView (UIView)
|
||||||
layout == .reflowable + 含交互脚本 → webInteractive
|
│
|
||||||
layout == .reflowable + 无交互脚本 → textReflowable
|
├─ WKWebView (RDEPUBAnnotationWebView)
|
||||||
|
│ ├─ 自定义选择菜单(拷贝/高亮/批注)
|
||||||
|
│ └─ 禁用系统手势(滚动手势由原生控制)
|
||||||
|
│
|
||||||
|
├─ RDEPUBResourceURLSchemeHandler
|
||||||
|
│ ├─ 协议: ss-reader://book/<path>
|
||||||
|
│ ├─ 映射: URL → 本地 EPUB 解压文件
|
||||||
|
│ └─ 容错: 缺失资源返回空响应(不 404)
|
||||||
|
│
|
||||||
|
├─ JavaScript Bridge (6 种消息)
|
||||||
|
│ ├─ ssReaderProgressionChanged // 阅读进度更新
|
||||||
|
│ ├─ ssReaderSelectionChanged // 文本选区变化
|
||||||
|
│ ├─ ssReaderInternalLink // EPUB 内部链接
|
||||||
|
│ ├─ ssReaderExternalLink // 外部链接
|
||||||
|
│ ├─ ssReaderJSError // JS 运行时错误
|
||||||
|
│ └─ ssReaderFixedLayoutReady // 固定布局加载完成
|
||||||
|
│
|
||||||
|
└─ 注入脚本
|
||||||
|
├─ epub-bridge.js (502 行) // 核心桥接,分页/滚动/选区/高亮
|
||||||
|
├─ cssInjector.js (45 行) // CSS 注入管理
|
||||||
|
├─ WeReadApi.js (67 行) // 微信读书 API 兼容层
|
||||||
|
├─ rangy-core.js / rangy-serializer.js // DOM Range 序列化
|
||||||
|
└─ CSS: default/replace/dark/latin // 4 套样式表
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 Services 层
|
---
|
||||||
|
|
||||||
#### RDEPUBResourceResolver
|
## 7. 翻页容器架构(RDReaderView)
|
||||||
|
|
||||||
统一处理所有资源路径转换,是 WebView 和 Paginator 访问资源的唯一入口:
|
```
|
||||||
|
RDReaderView (UIView)
|
||||||
|
│
|
||||||
|
├─ 三种翻页模式:
|
||||||
|
│ ├─ .pageCurl → UIPageViewController (翻页动画)
|
||||||
|
│ ├─ .horizontalScroll → UICollectionView (水平滑动)
|
||||||
|
│ └─ .verticalScroll → UICollectionView (垂直滚动)
|
||||||
|
│
|
||||||
|
├─ 组合对象:
|
||||||
|
│ ├─ RDReaderPagingController // 翻页状态管理、请求队列
|
||||||
|
│ ├─ RDReaderPreloadController // 页面预加载、缓存管理
|
||||||
|
│ ├─ RDReaderSpreadResolver // 双页展开计算
|
||||||
|
│ └─ RDReaderTapRegionHandler // 点击区域分类(左/中/右)
|
||||||
|
│
|
||||||
|
├─ 数据源协议:
|
||||||
|
│ ├─ RDReaderPageProvider (新) // 格式无关,优先级高
|
||||||
|
│ └─ RDReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配
|
||||||
|
│
|
||||||
|
└─ 手势流:
|
||||||
|
点击 → TapRegionHandler → 分类(左/中/右)
|
||||||
|
├─ 左 → goPreviousPage()
|
||||||
|
├─ 右 → goNextPage()
|
||||||
|
└─ 中 → tapCenter() (切换工具栏)
|
||||||
|
```
|
||||||
|
|
||||||
| 方法 | 说明 |
|
---
|
||||||
|------|------|
|
|
||||||
| `fileURL(forHref:)` | href → 本地文件 URL |
|
|
||||||
| `schemeURL(forHref:)` | href → `ss-reader://` 协议 URL |
|
|
||||||
| `href(forSpineIndex:)` | spineIndex → href |
|
|
||||||
| `normalizedHref(_:)` | 相对路径标准化(统一相对 OPF) |
|
|
||||||
|
|
||||||
#### RDEPUBPaginator
|
## 8. 配置与设置
|
||||||
|
|
||||||
使用隐藏的 `WKWebView` 离屏加载每个 spine 资源,通过 JS 注入分页 CSS,回调每个资源的页数:
|
### RDEPUBReaderConfiguration
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
paginator.calculate(publication: publication, preferences: preferences, viewportSize: size) { pageCounts in
|
struct RDEPUBReaderConfiguration {
|
||||||
// pageCounts[i] = spine[i] 的页数
|
var fontSize: CGFloat // 默认 16
|
||||||
|
var fontChoice: FontChoice // 系统/宋体/楷体
|
||||||
|
var lineHeightMultiple: CGFloat // 默认 1.5
|
||||||
|
var numberOfColumns: Int // 1 或 2
|
||||||
|
var columnGap: CGFloat // 默认 20
|
||||||
|
var theme: ReaderTheme // 6 种主题
|
||||||
|
var displayType: RDReaderView.DisplayType // pageCurl/horizontal/vertical
|
||||||
|
var onDemandChapterWindowSize: Int // 默认 3(奇数,最小 3,最大 15)
|
||||||
|
var metadataParsingConcurrency: Int // 默认 CPU 核心数
|
||||||
|
var chapterWindowRadius: Int // 内存缓存窗口半径
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### RDEPUBPreferences
|
### 持久化
|
||||||
|
|
||||||
聚合 WebView 和 Paginator 共用的展示参数:字号、行距、主题色、边距、布局适配模式、spread 模式。
|
| 数据 | 存储方式 | Key 前缀 |
|
||||||
|
|------|----------|----------|
|
||||||
### 4.3 Navigator 层
|
| 阅读位置 | UserDefaults | `ssreader.epub.location.{bookID}` |
|
||||||
|
| 书签 | UserDefaults | `ssreader.epub.bookmarks.{bookID}` |
|
||||||
#### RDEPUBNavigatorState 状态机
|
| 高亮标注 | UserDefaults | `ssreader.epub.highlights.{bookID}` |
|
||||||
|
| 全局设置 | UserDefaults | `ssreader.epub.settings` |
|
||||||
```
|
| 章节摘要 | 磁盘文件 | `~/Caches/RDEPUBChapterSummaryCache/` |
|
||||||
initializing → loading → idle ←→ jumping
|
| 全书分页 | 磁盘文件 | `~/Caches/RDEPUBTextBookCache/` |
|
||||||
←→ moving
|
|
||||||
←→ repaginating
|
|
||||||
```
|
|
||||||
|
|
||||||
| 状态 | 含义 | 允许动作 |
|
|
||||||
|------|------|----------|
|
|
||||||
| `initializing` | 刚打开书籍 | 接收初始恢复位置 |
|
|
||||||
| `loading` | 生成首轮 page model | 缓存 pending navigation |
|
|
||||||
| `idle` | 显示稳定 | 应用 staged snapshot、处理跳转、保存位置 |
|
|
||||||
| `jumping` | 目录 / 内部链接跳转中 | 重放 pending location |
|
|
||||||
| `moving` | 用户翻页中 | 更新当前 pageNum |
|
|
||||||
| `repaginating` | 重新分页中(字号/横竖屏变化) | 缓存当前位置,等待完成 |
|
|
||||||
|
|
||||||
#### RDEPUBReadingSession
|
|
||||||
|
|
||||||
会话级协调者,持有:
|
|
||||||
|
|
||||||
- `publication`:出版物只读视图
|
|
||||||
- `activePages / activeChapters`:当前展示的页模型和章节信息
|
|
||||||
- `stagedPages / stagedChapters`:后台分页完成后暂存,等待 idle 状态时应用
|
|
||||||
- `pendingNavigationLocation / pendingNavigationPageNum`:等待当前加载完成后再执行的跳转请求
|
|
||||||
- `currentViewport / currentReadingContext`:当前可见区域的位置信息
|
|
||||||
|
|
||||||
关键操作:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
session.stageSnapshot(snapshot, restoreLocation:) // 后台分页完成,暂存结果
|
|
||||||
session.consumeStagedSnapshotIfAllowed() // idle 时消费暂存(线程安全切换)
|
|
||||||
session.transition(to: .idle) // 状态跃迁
|
|
||||||
session.clearPendingNavigation() // 取消待执行跳转
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 Resource View 层
|
|
||||||
|
|
||||||
#### RDEPUBWebView
|
|
||||||
|
|
||||||
承载单个 spine 资源的 WKWebView,按职责拆分为 4 个扩展文件:
|
|
||||||
|
|
||||||
| 扩展 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `+Configuration` | WKWebViewConfiguration、schemeHandler 注册、user scripts |
|
|
||||||
| `+Reflowable` | 注入分页 CSS、滚动到指定 progression、接收 JS 事件 |
|
|
||||||
| `+FixedLayout` | fixed-layout HTML wrapper 生成和加载 |
|
|
||||||
| `+JavaScriptBridge` | JS ↔ Swift 消息路由、选区、高亮、进度上报 |
|
|
||||||
| `+Search` | 搜索高亮装饰 |
|
|
||||||
|
|
||||||
#### ss-reader:// 协议
|
|
||||||
|
|
||||||
所有 spine 资源(XHTML、CSS、图片、字体)统一通过 `ss-reader://book/<relative-path>` 访问,由 `RDEPUBResourceURLSchemeHandler` 从本地解压目录读取并返回。
|
|
||||||
|
|
||||||
优点:
|
|
||||||
- 相对资源路径在 WebView 中自然解析
|
|
||||||
- fixed-layout iframe 不再白屏
|
|
||||||
- 无需 `allowingReadAccessTo` 路径权限
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 文本 EPUB 渲染层(EPUBTextRendering)
|
## 9. 目录结构
|
||||||
|
|
||||||
适用于 `readingProfile == .textReflowable` 的书籍(纯文本小说类 EPUB2)。
|
|
||||||
|
|
||||||
### 5.1 渲染链路
|
|
||||||
|
|
||||||
```
|
```
|
||||||
章节 HTML 文件
|
Sources/RDReaderView/
|
||||||
→ RDEPUBTextRendererSupport.injectFragmentMarkers(into:) # 注入 fragment 标记
|
├── EPUBCore/ # EPUB 解析与 WebView 渲染
|
||||||
→ RDEPUBDTCoreTextRenderer.renderChapter(html:baseURL:style:)
|
│ ├── Models/ # 数据模型
|
||||||
→ DTHTMLAttributedStringBuilder # HTML → NSAttributedString
|
│ │ ├── RDEPUBAnnotationModels.swift
|
||||||
→ extractFragmentOffsets # fragment → 字符偏移量映射
|
│ │ ├── RDEPUBPaginationModels.swift
|
||||||
→ normalizeReadingAttributes # 统一字体/行距
|
│ │ └── RDEPUBReadingLocationModels.swift
|
||||||
→ RDEPUBRenderedChapterContent
|
│ ├── Resources/ # JS/CSS/HTML 静态资源
|
||||||
.attributedString # 渲染后的富文本
|
│ │ ├── epub-bridge.js
|
||||||
.fragmentOffsets # fragment id → 字符偏移
|
│ │ ├── cssInjector.js
|
||||||
|
│ │ ├── WeReadApi.js
|
||||||
|
│ │ ├── rangy-core.js / rangy-serializer.js
|
||||||
|
│ │ ├── wxread-default.css / wxread-dark.css / wxread-replace.css
|
||||||
|
│ │ └── epub-fixed-layout.html
|
||||||
|
│ ├── RDEPUBParser.swift # 核心解析器
|
||||||
|
│ ├── RDEPUBParser+Archive.swift # ZIP 解压
|
||||||
|
│ ├── RDEPUBParser+Package.swift # OPF 解析
|
||||||
|
│ ├── RDEPUBParser+TOC.swift # 目录解析
|
||||||
|
│ ├── RDEPUBParser+Resources.swift # 资源访问
|
||||||
|
│ ├── RDEPUBPublication.swift # 出版物门面
|
||||||
|
│ ├── RDEPUBResourceResolver.swift # URL 解析
|
||||||
|
│ ├── RDEPUBReadingSession.swift # 阅读会话状态机
|
||||||
|
│ ├── RDEPUBWebView.swift # WebView 主类
|
||||||
|
│ ├── RDEPUBWebView+*.swift # WebView 扩展(配置/重排/固定/搜索/JS桥接)
|
||||||
|
│ ├── RDEPUBPaginator.swift # 离屏分页计算器
|
||||||
|
│ ├── RDEPUBSearchEngine.swift # 全文搜索引擎
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
├── EPUBTextRendering/ # 文本渲染引擎
|
||||||
|
│ ├── Typesetter/ # 排版管线
|
||||||
|
│ │ ├── RDEPUBTypesettingPipeline.swift
|
||||||
|
│ │ ├── RDEPUBHTMLNormalizer.swift
|
||||||
|
│ │ ├── RDEPUBSemanticMarkerInjector.swift
|
||||||
|
│ │ ├── RDEPUBFragmentMarkerInjector.swift
|
||||||
|
│ │ ├── RDEPUBFontNormalizer.swift
|
||||||
|
│ │ ├── RDEPUBAttachmentNormalizer.swift
|
||||||
|
│ │ ├── RDEPUBStyleSheetComposer.swift
|
||||||
|
│ │ ├── RDEPUBRenderDiagnosticsCollector.swift
|
||||||
|
│ │ └── RDEPUBTextRendererSupport.swift
|
||||||
|
│ ├── Pagination/ # 分页引擎
|
||||||
|
│ │ ├── RDEPUBChapterPageCounter.swift
|
||||||
|
│ │ ├── RDEPUBCoreTextPageFrameFactory.swift
|
||||||
|
│ │ ├── RDEPUBPageBreakPolicy.swift
|
||||||
|
│ │ ├── RDEPUBTextLayoutFrame.swift
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── BuildPipeline/ # 全书构建
|
||||||
|
│ │ ├── RDEPUBTextBookBuilder.swift
|
||||||
|
│ │ ├── RDEPUBTextBookModels.swift
|
||||||
|
│ │ ├── RDEPUBTextBookCache.swift
|
||||||
|
│ │ ├── RDEPUBChapterTailNormalizer.swift
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── RDEPUBTextRenderer.swift # 渲染器协议与类型定义
|
||||||
|
│ ├── RDEPUBDTCoreTextRenderer.swift # DTCoreText 渲染器实现
|
||||||
|
│ ├── RDEPUBChapterData.swift # 章节查询门面
|
||||||
|
│ ├── RDEPUBTextIndexTable.swift # 全书索引表
|
||||||
|
│ └── RDPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器
|
||||||
|
│
|
||||||
|
├── ReaderView/ # 通用翻页容器
|
||||||
|
│ ├── Paging/ # 翻页子系统
|
||||||
|
│ │ ├── RDReaderPagingController.swift
|
||||||
|
│ │ ├── RDReaderPreloadController.swift
|
||||||
|
│ │ ├── RDReaderSpreadResolver.swift
|
||||||
|
│ │ └── RDReaderTapRegionHandler.swift
|
||||||
|
│ ├── RDReaderView.swift # 主容器视图
|
||||||
|
│ ├── RDReaderView+*.swift # 扩展(CollectionView/PageCurl/ToolView/ContentAccess)
|
||||||
|
│ ├── RDReaderFlowLayout.swift # 自定义 CollectionView 布局
|
||||||
|
│ ├── RDReaderContentCell.swift # 滚动模式 Cell
|
||||||
|
│ └── RDReaderPageChildViewController.swift // 翻页模式子 VC
|
||||||
|
│
|
||||||
|
└── EPUBUI/ # 阅读器 UI
|
||||||
|
├── ReaderController/ # 控制器与协调器
|
||||||
|
│ ├── ChapterRuntime/ # 章节运行时
|
||||||
|
│ │ ├── RDEPUBChapterLoader.swift
|
||||||
|
│ │ ├── RDEPUBChapterRuntimeStore.swift
|
||||||
|
│ │ ├── RDEPUBChapterSummaryDiskCache.swift
|
||||||
|
│ │ ├── RDEPUBChapterCacheKey.swift
|
||||||
|
│ │ ├── RDEPUBBookPageMap.swift
|
||||||
|
│ │ ├── RDEPUBChapterSummary.swift
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── RDEPUBReaderContext.swift // 共享状态中心
|
||||||
|
│ ├── RDEPUBReaderRuntime.swift // 运行时协调器
|
||||||
|
│ ├── RDEPUBReaderPaginationCoordinator.swift // 分页协调器
|
||||||
|
│ ├── RDEPUBReaderAnnotationCoordinator.swift // 标注协调器
|
||||||
|
│ ├── RDEPUBReaderSearchCoordinator.swift // 搜索协调器
|
||||||
|
│ ├── RDEPUBReaderNavigationCoordinator.swift // 导航协调器
|
||||||
|
│ └── ...
|
||||||
|
├── Settings/ # 设置面板
|
||||||
|
│ ├── RDEPUBReaderSettingsViewController.swift
|
||||||
|
│ ├── RDEPUBReaderThemeSelector.swift
|
||||||
|
│ └── ...
|
||||||
|
├── TextPage/ # 文本页面渲染
|
||||||
|
│ ├── RDEPUBTextContentView.swift
|
||||||
|
│ ├── RDEPUBTextPageViewController.swift
|
||||||
|
│ └── ...
|
||||||
|
├── RDEPUBReaderController.swift # 主控制器
|
||||||
|
├── RDEPUBReaderConfiguration.swift # 配置模型
|
||||||
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 分页
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBTextBookBuilder.buildBook(publication:style:pageSize:renderer:)
|
|
||||||
→ 逐章节调用 renderer.renderChapter(...)
|
|
||||||
→ RDEPUBTextPaginationSupport.paginate(attributedString:pageSize:)
|
|
||||||
→ RDEPUBTextBook(章节 + 页模型 + fragment 索引)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 内容显示
|
|
||||||
|
|
||||||
`RDEPUBTextContentView` 基于 `NSAttributedString` + `UITextView`(或 DTCoreText 自定义绘制),实现:
|
|
||||||
- 每页显示对应字符范围的内容
|
|
||||||
- 高亮重叠渲染
|
|
||||||
- 支持按 fragment 偏移量跳转
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. EPUBUI 层(开箱即用读者 UI)
|
## 10. 关键设计模式
|
||||||
|
|
||||||
`RDEPUBReaderController` 是 library 层提供的完整读者入口,调用方只需传入 EPUB 文件 URL:
|
| 模式 | 应用场景 |
|
||||||
|
|
||||||
```swift
|
|
||||||
let controller = RDEPUBReaderController(
|
|
||||||
epubURL: url,
|
|
||||||
configuration: .default,
|
|
||||||
persistence: RDEPUBReaderPersistence(storageKey: "my-book")
|
|
||||||
)
|
|
||||||
controller.delegate = self
|
|
||||||
present(controller, animated: true)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.1 内置能力
|
|
||||||
|
|
||||||
| 能力 | 对应文件 |
|
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| 顶部工具栏(书名、返回、目录、高亮入口) | RDEPUBReaderTopToolView |
|
| **Coordinator** | 分页/标注/搜索/导航各一个协调器,解耦 Controller |
|
||||||
| 底部工具栏(进度条、页码) | RDEPUBReaderBottomToolView |
|
| **Facade** | `RDEPUBPublication` 封装 `RDEPUBParser`,`RDEPUBChapterData` 封装章节查询 |
|
||||||
| 目录面板 | RDEPUBReaderChapterListController |
|
| **Builder** | `RDEPUBBookPageMap.Builder` 增量构建页码映射 |
|
||||||
| 高亮管理 | RDEPUBReaderHighlightsViewController |
|
| **Strategy** | `RDEPUBTextRenderer` 协议,可替换渲染器实现 |
|
||||||
| 设置面板(字号、行距、主题、翻页模式) | RDEPUBReaderSettingsViewController |
|
| **Pipeline** | `RDEPUBTextTypesetterPipeline` 8 阶段排版管线 |
|
||||||
| 阅读位置持久化 | RDEPUBReaderPersistence |
|
| **State Machine** | `RDEPUBNavigatorState` 管理阅读器状态转换 |
|
||||||
|
| **Adapter** | `RDReaderLegacyDataSourceAdapter` 适配旧数据源协议 |
|
||||||
### 6.2 配置项(RDEPUBReaderConfiguration)
|
| **三级缓存** | 内存 → 磁盘摘要 → 全书分页,逐级降级 |
|
||||||
|
| **Token 取消** | `paginationToken` 确保过期异步任务不干扰新任务 |
|
||||||
| 属性 | 默认值 | 说明 |
|
| **Frozen Parameters** | 后台任务冻结 `renderSignature`,避免运行中参数漂移 |
|
||||||
|------|--------|------|
|
|
||||||
| `fontSize` | 15 | 字号(pt) |
|
|
||||||
| `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` | fixed-layout spread 模式 |
|
|
||||||
| `textRenderingEngine` | `.dtCoreText` | 文本 EPUB 渲染引擎 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 关键数据流
|
|
||||||
|
|
||||||
### 7.1 打开 EPUB 书籍
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBReaderController.init(epubURL:)
|
|
||||||
→ viewDidLoad
|
|
||||||
→ RDEPUBParser.parse(epubURL:)
|
|
||||||
→ RDEPUBPublication(parser:)
|
|
||||||
→ RDEPUBReadingSession(publication:)
|
|
||||||
→ readingProfile 判断渲染路径
|
|
||||||
textReflowable:
|
|
||||||
RDEPUBTextBookBuilder.buildBook(...) # 后台分页
|
|
||||||
→ session.stageSnapshot(...)
|
|
||||||
→ session.consumeStagedSnapshotIfAllowed() # idle 时应用
|
|
||||||
→ readerView.reloadData()
|
|
||||||
webInteractive / webFixedLayout:
|
|
||||||
RDEPUBPaginator.calculate(...) # 离屏 WebView 分页
|
|
||||||
→ session.stageSnapshot(...)
|
|
||||||
→ readerView.reloadData()
|
|
||||||
→ persistence.restoreLocation() # 恢复上次阅读位置
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 翻页
|
|
||||||
|
|
||||||
```
|
|
||||||
用户手势(tap / swipe)
|
|
||||||
→ RDReaderView 检测手势分区 / 翻页方向
|
|
||||||
→ delegate.pageNum(readerView:pageNum:)
|
|
||||||
→ RDEPUBReaderController 根据 pageNum 找 EPUBPage
|
|
||||||
→ RDEPUBReadingSession.transition(to: .moving)
|
|
||||||
→ readerView.pageContentView(pageNum:) 回调
|
|
||||||
→ 创建 RDEPUBWebContentView 或 RDEPUBTextContentView
|
|
||||||
→ loadPage(spineIndex: pageIndexInChapter: preferences:)
|
|
||||||
→ JS bridge 上报 progression → session.currentViewport 更新
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 定位模型(RDEPUBLocation)
|
|
||||||
|
|
||||||
EPUB 是可重排内容,字号 / 横竖屏变化会使页号失效。定位模型使用 `href + progression`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct RDEPUBLocation: Codable, Equatable {
|
|
||||||
var bookIdentifier: String? // 隔离不同书的进度
|
|
||||||
var href: String // OPF 相对路径(对应 spine 资源)
|
|
||||||
var progression: Double // 视口起始位置 [0, 1]
|
|
||||||
var lastProgression: Double? // 视口末尾位置 [0, 1]
|
|
||||||
var fragment: String? // 锚点
|
|
||||||
var rangeAnchor: RDEPUBTextRangeAnchor? // 文本范围锚点(用于文本 EPUB 高亮定位)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**恢复流程:**
|
|
||||||
|
|
||||||
1. 标准化 `href`(相对 OPF)
|
|
||||||
2. 找到对应 spineIndex
|
|
||||||
3. 由 `navigationProgression` 估算扁平页号
|
|
||||||
4. 若有 `fragment`,加载后在 WebView 中锚点滚动
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 已知限制与后续待办
|
|
||||||
|
|
||||||
| 问题 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| RDEPUBReaderController 过大 | 约 1995 行,仍混有 EPUB 加载、UI 管理、数据源逻辑,待进一步拆分 |
|
|
||||||
| 部分 UI 文件仍然偏大 | RDEPUBTextContentView ~728 行、RDEPUBTextBookBuilder ~717 行,需继续拆分 |
|
|
||||||
| 横竖屏切换已接入一阶段支持 | `RDEPUBReaderController.viewWillTransition()` 统一接管正文重分页,`RDReaderView` 保留容器级双页布局刷新;仍需补齐固定回归矩阵 |
|
|
||||||
| 固定手势分区比例 | 三等分固定写死,不支持自定义 |
|
|
||||||
| 自动化测试为首批接入状态 | 已有 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests` 与 parser / resolver / persistence / smoke 用例,分页、恢复链路和更多 UI 闭环仍需继续补齐 |
|
|
||||||
| 基线验证待跑 | 四本样书(凡人修仙传、爱忘事熊爷爷、宝山辽墓、张学良传)的分页耗时、目录命中率、末页事件等数据尚未收集 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 构建与依赖
|
|
||||||
|
|
||||||
### 9.1 安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd RDReaderDemo
|
|
||||||
pod install
|
|
||||||
# 打开 RDReaderDemo.xcworkspace,选 RDReaderDemo scheme,构建
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.2 主要依赖
|
|
||||||
|
|
||||||
| 依赖 | 用途 | 使用层 |
|
|
||||||
|------|------|--------|
|
|
||||||
| ZIPFoundation (~> 0.9) | EPUB ZIP 解压 | RDEPUBParser+Archive |
|
|
||||||
| DTCoreText | HTML → NSAttributedString | RDEPUBDTCoreTextRenderer |
|
|
||||||
| SnapKit | Demo 布局 | Demo 层 |
|
|
||||||
| SSAlertSwift | Demo 弹窗 | Demo 层 |
|
|
||||||
|
|
||||||
### 9.3 podspec 关键配置
|
|
||||||
|
|
||||||
- `s.source_files = 'Sources/RDReaderView/**/*.{swift}'`(递归包含所有子目录)
|
|
||||||
- `s.resource_bundles`:包含 JS、CSS 等资源文件
|
|
||||||
- Library 本身无需 demo 层依赖
|
|
||||||
|
|||||||
546
Doc/BUSINESS_LOGIC.md
Normal file
546
Doc/BUSINESS_LOGIC.md
Normal 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 解析
|
||||||
|
|
||||||
|
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(目录)按优先级尝试三种来源:
|
||||||
|
|
||||||
|
1. **NCX(EPUB 2):** 解析 `<navPoint>` 树形结构,支持嵌套
|
||||||
|
2. **Navigation Document(EPUB 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 个阶段:
|
||||||
|
|
||||||
|
**阶段 1:HTML 规范化** (`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>` 中
|
||||||
|
|
||||||
|
**阶段 4:CSS 层合成** (`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` 集合中,避免重复注册
|
||||||
|
|
||||||
|
**阶段 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:)`:
|
||||||
|
|
||||||
|
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
|
||||||
|
├─ 并发渲染未缓存章节(OperationQueue,N=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 |
|
||||||
@ -1,295 +0,0 @@
|
|||||||
# ReadViewSDK 代码规范
|
|
||||||
|
|
||||||
## 适用范围
|
|
||||||
|
|
||||||
本文档适用于 `ReadViewSDK` 新增代码与重构代码。
|
|
||||||
|
|
||||||
- 规范覆盖 `Sources/` 与 `RDReaderDemo/` 中的 Swift 代码。
|
|
||||||
- 命名、分层与职责边界以 SDK 可维护性和可扩展性为优先。
|
|
||||||
- 本文档分为两类内容:
|
|
||||||
- `已观察到的约定`:当前工程常见写法。
|
|
||||||
- `建议统一的规范`:后续统一执行的规则。
|
|
||||||
|
|
||||||
## 新架构目录与职责
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- `Sources/RDReaderView`:阅读核心容器、翻页能力与基础视图。
|
|
||||||
- `EPUBCore`:EPUB 解析、资源定位、导航状态、分页与会话协调。
|
|
||||||
- `EPUBTextRendering`:文本渲染引擎与分页支持。
|
|
||||||
- `EPUBUI`:可开箱即用的 Reader UI 层。
|
|
||||||
- `RDReaderDemo`:示例应用与调试入口。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- 新增业务能力优先归入 `Sources/RDReaderView` 下的对应模块目录。
|
|
||||||
- `Core` 层只承载解析、会话、状态机与通用能力,不写页面级交互。
|
|
||||||
- `UI` 层只承载展示、事件分发和轻量状态同步,不直接处理底层解析逻辑。
|
|
||||||
- 若模块持续膨胀,优先在当前模块下继续拆分子文件,不跨目录散落实现。
|
|
||||||
|
|
||||||
## 命名规范
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- 当前工程历史命名以 `SS`、`RDEPUB` 开头。
|
|
||||||
- 控制器常用 `...Controller`,视图常用 `...View`,会话对象常用 `...Session`。
|
|
||||||
- 扩展文件采用 `类型名+功能域.swift`,如 `Parser+Archive.swift`。
|
|
||||||
- 事件方法常使用 `Action` 结尾,例如 `pageTapAction()`、`themeChangeAction()`。
|
|
||||||
- 绑定数据的方法常使用 `bind`、`update`、`refresh`、`configure` 等动词。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- **所有新增类型必须以 `RD` 开头。**
|
|
||||||
- EPUB 相关类型统一以 `RDEPUB` 开头。
|
|
||||||
- 方法名、变量名沿用 Swift 小驼峰,不增加额外前缀。
|
|
||||||
- 类型名应反映职责,不使用过宽泛的后缀;只有真正承担协调逻辑时才使用 `Manager`、`Handler`。
|
|
||||||
- 事件处理方法统一使用"对象/意图 + Action"命名,例如 `pageTapAction`、`themeChangeAction`。
|
|
||||||
- 数据绑定方法优先使用以下语义:
|
|
||||||
- `bind...`:将模型绑定到视图或模块。
|
|
||||||
- `update...`:增量刷新已有界面或状态。
|
|
||||||
- `configure...`:一次性配置样式或依赖。
|
|
||||||
- `refresh...`:重新拉取或重建数据状态。
|
|
||||||
- 避免新增拼写不一致的方法名;若发现历史命名拼写错误,新增代码必须使用正确拼写,旧接口修复时应配合调用点一起调整。
|
|
||||||
|
|
||||||
命名示例:
|
|
||||||
|
|
||||||
- `RDReaderView`
|
|
||||||
- `RDEPUBParser`
|
|
||||||
- `RDEPUBPublication`
|
|
||||||
- `RDEPUBReadingSession`
|
|
||||||
- `RDEPUBReaderController`
|
|
||||||
- `RDEPUBReaderTheme`
|
|
||||||
|
|
||||||
扩展文件命名示例:
|
|
||||||
|
|
||||||
```text
|
|
||||||
RDEPUBParser.swift
|
|
||||||
RDEPUBParser+Archive.swift
|
|
||||||
RDEPUBParser+Package.swift
|
|
||||||
RDEPUBParser+TOC.swift
|
|
||||||
RDEPUBParser+Resources.swift
|
|
||||||
RDEPUBWebView.swift
|
|
||||||
RDEPUBWebView+Configuration.swift
|
|
||||||
RDEPUBWebView+Reflowable.swift
|
|
||||||
```
|
|
||||||
|
|
||||||
## 分层与职责边界
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- 阅读入口负责容器装配、翻页模式切换和事件分发。
|
|
||||||
- EPUB 核心层负责解析、导航、分页、资源读取与定位。
|
|
||||||
- 渲染层负责 HTML/富文本渲染与分页支持。
|
|
||||||
- UI 层负责主题、工具栏、目录、设置等交互能力。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- `RD...Controller`:负责页面级编排与流程调度,不承载复杂渲染细节。
|
|
||||||
- `RD...View`:负责展示与局部交互,不承载完整业务流程。
|
|
||||||
- `RDEPUB...Core`:负责解析、会话状态与数据模型,不依赖具体页面。
|
|
||||||
- 配置、主题、定位、进度模型统一下沉为 `struct`。
|
|
||||||
- 跨层通信优先通过会话层或协议,不做跨层直接写状态。
|
|
||||||
|
|
||||||
## UI 与布局规范
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- 视图多采用 `lazy var` 初始化,并在闭包内完成默认配置。
|
|
||||||
- 自定义 View 通常在 `init(frame:)` 或业务绑定后调用 `initView()` 完成视图树搭建。
|
|
||||||
- 复杂页面使用分区 extension 组织代理与事件实现。
|
|
||||||
- 页面或组件内部按职责拆分样式方法,例如 `topBarStyle()`、`contentStyle()`。
|
|
||||||
- 页面经常通过回调闭包把交互抛给外层,例如 `pageChangeCallback`、`selectionCallback`。
|
|
||||||
- 模块内存在多处布局方式混用情况。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- SDK 层新增 UI 使用 Auto Layout 原生约束,Demo 层可使用 SnapKit;单文件内不混用多套布局体系。
|
|
||||||
- 视图层初始化顺序保持一致:
|
|
||||||
- 定义属性与子视图
|
|
||||||
- 在 `initView()` 中组装视图树
|
|
||||||
- 在独立方法中拆分样式和状态刷新逻辑
|
|
||||||
- 当约束会被多次切换时:
|
|
||||||
- 首次创建使用 `makeConstraints`(SnapKit)或 `NSLayoutConstraint`
|
|
||||||
- 重建结构使用 `remakeConstraints`(SnapKit)或先移除再添加
|
|
||||||
- 仅修改常量时使用 `updateConstraints`(SnapKit)或修改 `constant` 属性
|
|
||||||
- 布局分支明显时,优先拆成语义化私有方法,不要把所有状态分支堆在一个超长方法里。
|
|
||||||
- 对外暴露的 UI 刷新入口建议以 `bind` 或 `update` 开头,避免把布局细节暴露给调用方。
|
|
||||||
- 交互事件通过闭包或 delegate 抛出,避免子视图持有上层业务依赖。
|
|
||||||
|
|
||||||
## 交互与状态处理规范
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- 事件处理使用 `@objc` + selector。
|
|
||||||
- 异步回调中广泛使用 `[weak self]`。
|
|
||||||
- 状态判断常通过 `guard` 提前返回。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- 按钮、通知、系统回调放入独立 extension 分组。
|
|
||||||
- 异步闭包默认先使用 `[weak self]`,仅在必要时改强引用。
|
|
||||||
- 多前置条件入口统一先 `guard` 校验,减少嵌套。
|
|
||||||
- 导航与进度恢复统一通过 `RDEPUBReadingSession` 协调。
|
|
||||||
|
|
||||||
## 代码风格细则
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- extension 分区较常见。
|
|
||||||
- 解析与渲染模型多数采用 `struct`。
|
|
||||||
- 存在少量历史命名不统一与可选值处理不一致情况。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- 默认遵循”最小可见性”:`private` > `fileprivate` > `internal` > `public`。
|
|
||||||
- 纯数据模型使用 `struct` + `Codable` + `Equatable`。
|
|
||||||
- 服务对象使用 `final class`,避免无意义继承。
|
|
||||||
- 错误类型统一使用 `enum + LocalizedError`。
|
|
||||||
- 新增代码避免强制解包;若当前上下文无法避免,至少先在上层收敛边界。
|
|
||||||
- 统一优先使用 `guard` 做前置失败处理,减少深层嵌套。
|
|
||||||
- extension 的拆分原则以”单一职责”优先:
|
|
||||||
- 事件处理一组
|
|
||||||
- 代理 / DataSource 实现一组
|
|
||||||
- 工具方法一组
|
|
||||||
- 通知适配一组
|
|
||||||
- 调试代码继续使用 `#if DEBUG` 包裹,不把调试边框、日志、测试分支直接带入正式逻辑。
|
|
||||||
|
|
||||||
## 文件组织规范
|
|
||||||
|
|
||||||
### 已观察到的约定
|
|
||||||
|
|
||||||
- 目录按阅读容器、EPUB Core、渲染、UI 分层组织。
|
|
||||||
- 大类通过 `+Extension` 文件拆分职责。
|
|
||||||
|
|
||||||
### 建议统一的规范
|
|
||||||
|
|
||||||
- 目录保持以下分层,不跨层放置实现:
|
|
||||||
- `Sources/RDReaderView/EPUBCore`
|
|
||||||
- `Sources/RDReaderView/EPUBTextRendering`
|
|
||||||
- `Sources/RDReaderView/EPUBUI`
|
|
||||||
- 单文件建议不超过 `600` 行;超出后按职责拆分 extension 文件。
|
|
||||||
- extension 文件命名统一 `RD类型名+功能域.swift`。
|
|
||||||
|
|
||||||
## 注释规范
|
|
||||||
|
|
||||||
- 注释、错误提示、日志统一使用中文。
|
|
||||||
- 关键流程方法保留”为什么这样做”的注释,不写重复代码字面行为的注释。
|
|
||||||
- 调试输出统一放在 `#if DEBUG` 下。
|
|
||||||
- 新代码保留有信息量的注释,避免重复描述显而易见的代码行为。
|
|
||||||
|
|
||||||
## 已观察到的项目模式
|
|
||||||
|
|
||||||
### 模式 1:统一入口 + 扩展拆分
|
|
||||||
|
|
||||||
- `RDEPUBParser` 负责 EPUB 解析主入口,不同能力拆到 `+Archive`、`+Package`、`+TOC`、`+Resources` 等扩展文件。
|
|
||||||
- `RDEPUBReadingSession` 负责阅读会话主入口,状态管理、分页、定位等能力拆到扩展文件。
|
|
||||||
- 该模式适合继续用于解析器、会话管理、控制器工具方法等横向能力。
|
|
||||||
|
|
||||||
### 模式 2:页面编排在 Controller,局部交互下沉到 View
|
|
||||||
|
|
||||||
- `RDEPUBReaderController` 负责阅读页整体编排:翻页容器装配、工具栏切换、阅读位置恢复。
|
|
||||||
- `RDReaderView` 负责分页容器布局与翻页交互,并通过 DataSource / Delegate 把数据需求交回外层。
|
|
||||||
- 该模式保持 Controller 管流程、View 管展示的职责分离。
|
|
||||||
|
|
||||||
### 模式 3:列表与容器逻辑通过扩展拆开
|
|
||||||
|
|
||||||
- 复杂容器将 `UICollectionViewDataSource`、`UICollectionViewDelegate`、`UICollectionViewDelegateFlowLayout` 分别拆分到 extension。
|
|
||||||
- 该模式降低单文件中主逻辑与代理逻辑的耦合,适合继续用于任何包含列表或容器的组件。
|
|
||||||
|
|
||||||
### 模式 4:渲染路径抽象
|
|
||||||
|
|
||||||
- `RDEPUBReadingProfile` 根据 EPUB 特征自动选择渲染路径(`webFixedLayout` / `webInteractive` / `textReflowable`)。
|
|
||||||
- 新增渲染相关功能时,必须评估对三种路径的覆盖情况。
|
|
||||||
|
|
||||||
## 待统一项
|
|
||||||
|
|
||||||
- 当前访问控制级别存在混用:同一类里 `public`、默认 `internal`、`private` 并存,建议后续新增代码默认从最小可见范围开始声明。
|
|
||||||
- 当前存在少量强制解包,建议新增代码优先通过前置校验收敛风险。
|
|
||||||
- 当前存在拼写不一致问题,建议后续新增代码统一使用标准英文单词,旧接口如需修复应配合调用点一起调整。
|
|
||||||
- 当前部分注释偏”过程说明”或遗留调试注释,建议新代码保留有信息量的注释。
|
|
||||||
- 当前个别 View 在数据绑定阶段再次调用 `initView()` 重建界面,这种方式在复杂组件中容易引入重复添加子视图或状态不一致。建议新增组件优先区分”初始化视图结构”和”刷新数据状态”两个阶段。
|
|
||||||
|
|
||||||
## 禁忌事项
|
|
||||||
|
|
||||||
| 禁忌 | 替代做法 |
|
|
||||||
|------|----------|
|
|
||||||
| 新增类型不加 `RD` 前缀 | 所有新增类型统一 `RD` / `RDEPUB` 前缀 |
|
|
||||||
| UI 层直接拼装解析状态 | 通过 `RDEPUBReadingSession` 获取状态 |
|
|
||||||
| 控制器直接操作底层解析细节 | 通过 `RDEPUBPublication`、`RDEPUBParser` 暴露接口 |
|
|
||||||
| 强制解包可选值 | `guard let` / `if let` |
|
|
||||||
| 用页号单独恢复阅读进度 | 统一使用 `href + progression` |
|
|
||||||
|
|
||||||
## 使用建议
|
|
||||||
|
|
||||||
- 新增功能前先确定目录归属和职责边界。
|
|
||||||
- 命名先定前缀再落代码:类型一律 `RD` 开头。
|
|
||||||
- 若需迁移历史 `SS` 前缀,按模块渐进替换,优先替换新增与重构触达文件。
|
|
||||||
|
|
||||||
## 旧 SS 命名迁移到 RD 的执行状态
|
|
||||||
|
|
||||||
**迁移已完成**:源码中已无 `SS` 前缀类型定义,全部使用 `RD`/`RDEPUB` 前缀。新增代码必须继续遵守此规则。
|
|
||||||
|
|
||||||
### 阶段 0:冻结新增 SS 命名(已完成)
|
|
||||||
|
|
||||||
- 目标:从当前时点开始,不再引入新的 `SS`/`RDEPUB` 类型名。
|
|
||||||
- 动作:
|
|
||||||
- 新增类型统一使用 `RD`/`RDEPUB` 前缀。
|
|
||||||
- Code Review 增加命名检查项:发现新增 `SS` 命名必须驳回。
|
|
||||||
- 在 PR 模板中加入”本次是否新增旧前缀命名”勾选项。
|
|
||||||
- 验收:
|
|
||||||
- 新提交代码中,新增类型 `SS` 前缀数量为 `0`。
|
|
||||||
|
|
||||||
### 阶段 1:建立迁移映射表(第 1 周)
|
|
||||||
|
|
||||||
- 目标:明确“旧名 -> 新名”一一映射,避免多人并行改名冲突。
|
|
||||||
- 动作:
|
|
||||||
- 统计核心公开类型、内部核心类型、测试类型三类清单。
|
|
||||||
- 建立命名映射表,例如:
|
|
||||||
- `RDReaderView` -> `RDReaderView`
|
|
||||||
- `RDEPUBParser` -> `RDEPUBParser`
|
|
||||||
- `RDEPUBReadingSession` -> `RDEPUBReadingSession`
|
|
||||||
- 对外 API 单独标记“需兼容过渡”的类型。
|
|
||||||
- 验收:
|
|
||||||
- 映射表覆盖全部高频核心类型,且团队评审通过。
|
|
||||||
|
|
||||||
### 阶段 2:先迁移内部类型(第 2-3 周)
|
|
||||||
|
|
||||||
- 目标:优先改内部实现,降低外部兼容压力。
|
|
||||||
- 动作:
|
|
||||||
- 按模块分批迁移:`EPUBCore` -> `EPUBTextRendering` -> `EPUBUI`。
|
|
||||||
- 每批次只改一个子模块,避免超大 PR。
|
|
||||||
- 同步修复调用点、扩展文件名与注释中的旧命名。
|
|
||||||
- 验收:
|
|
||||||
- 目标模块内类型命名全部满足 `RD` 规则。
|
|
||||||
- 编译通过,Demo 阅读主流程可用。
|
|
||||||
|
|
||||||
### 阶段 3:迁移公开 API 并保留兼容层(第 3-4 周)
|
|
||||||
|
|
||||||
- 目标:完成对外接口改名,同时给接入方提供平滑升级窗口。
|
|
||||||
- 动作:
|
|
||||||
- 对外公开类型切换为 `RD` 命名。
|
|
||||||
- 旧公开类型保留兼容别名,并标注废弃说明(`deprecated`)。
|
|
||||||
- 在 Release Note 提供“旧名/新名对照表”和迁移示例。
|
|
||||||
- 验收:
|
|
||||||
- 新接入示例仅使用 `RD` 命名。
|
|
||||||
- 旧接入代码在兼容期内无需立即改动即可编译。
|
|
||||||
|
|
||||||
### 阶段 4:清理兼容层与收口(下一主版本)
|
|
||||||
|
|
||||||
- 目标:在约定主版本移除旧前缀,完成命名收口。
|
|
||||||
- 动作:
|
|
||||||
- 删除 `SS` 兼容别名与过渡代码。
|
|
||||||
- 清理文档、注释、示例工程中的旧前缀残留。
|
|
||||||
- 对外发布最终迁移公告与升级说明。
|
|
||||||
- 验收:
|
|
||||||
- 工程内无 `SS`/`RDEPUB` 类型定义残留。
|
|
||||||
- 文档与示例代码全部为 `RD`/`RDEPUB` 命名。
|
|
||||||
|
|
||||||
### 迁移过程约束
|
|
||||||
|
|
||||||
- 每次迁移 PR 必须包含:
|
|
||||||
- 命名改动清单
|
|
||||||
- 影响范围说明
|
|
||||||
- 回归验证结果(编译、Demo 主流程、关键阅读路径)
|
|
||||||
- 禁止在同一 PR 中同时做“大规模命名迁移 + 业务逻辑重构”。
|
|
||||||
- 若改名会影响外部接入,必须先补迁移文档再合并代码。
|
|
||||||
@ -1,402 +0,0 @@
|
|||||||
# EPUBCore 功能实现逻辑
|
|
||||||
|
|
||||||
## 1. 范围与目标
|
|
||||||
|
|
||||||
- 代码范围:`Sources/RDReaderView/EPUBCore/`(31 个 Swift 文件 + 2 个资源文件)
|
|
||||||
- 目标:说明 EPUBCore 如何完成 EPUB 解析、资源定位、阅读会话管理、离屏分页、JS 桥接渲染和全文搜索。
|
|
||||||
- 主链路关键词:`epubURL -> RDEPUBParser.parse -> container.xml -> OPF -> spine/TOC -> RDEPUBPublication -> RDEPUBReadingSession -> RDEPUBWebView/Paginator -> 分页/渲染`。
|
|
||||||
|
|
||||||
## 2. 关键对象职责
|
|
||||||
|
|
||||||
### 2.1 解析器 `RDEPUBParser`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBParser.swift` + 5 个扩展文件
|
|
||||||
- 入口方法:`parse(epubURL:)`
|
|
||||||
- 职责:
|
|
||||||
- ZIP 解压到缓存目录(`RDEPUBParser+Archive.swift`)
|
|
||||||
- 解析 `META-INF/container.xml` 定位 OPF 根文件
|
|
||||||
- 解析 OPF 文档提取 metadata / manifest / spine(`RDEPUBParser+Package.swift`)
|
|
||||||
- 解析目录支持 NCX(EPUB 2)和 Nav Document(EPUB 3)(`RDEPUBParser+TOC.swift`)
|
|
||||||
- 判断阅读配置文件类型(`RDEPUBParser+ReadingProfile.swift`)
|
|
||||||
- 资源路径转换:相对路径 ↔ 文件 URL ↔ `ss-reader://` 自定义 scheme URL(`RDEPUBParser+Resources.swift`)
|
|
||||||
|
|
||||||
### 2.2 Publication 门面 `RDEPUBPublication`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBPublication.swift`
|
|
||||||
- 职责:
|
|
||||||
- 包装 `RDEPUBParser` + `RDEPUBResourceResolver`,对外暴露只读接口
|
|
||||||
- 提供 metadata、manifest、spine、TOC、layout、readingProfile、readingProgression
|
|
||||||
- 计算 fixed layout 的 spread 组合:`makeFixedSpreads(preferences:viewportSize:)`
|
|
||||||
|
|
||||||
### 2.3 阅读会话 `RDEPUBReadingSession`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBReadingSession.swift` + `RDEPUBNavigatorState.swift`
|
|
||||||
- 职责:
|
|
||||||
- 管理导航状态机:`initializing -> loading -> idle <-> jumping/moving/repaginating`
|
|
||||||
- 维护活跃/暂存分页快照(`activePages` / `activeChapters` / `stagedPages` / `stagedChapters`)
|
|
||||||
- 管理待定导航目标(`pendingNavigationLocation` / `pendingNavigationPageNum`)
|
|
||||||
- 更新阅读上下文(`currentReadingContext`:location + viewport + pageNumber + chapterIndex)
|
|
||||||
- 构建分页快照:`makePaginationSnapshot(pageCounts:preferences:layoutContext:)`
|
|
||||||
|
|
||||||
### 2.4 WebView 渲染层 `RDEPUBWebView`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBWebView.swift` + 5 个扩展文件(+Configuration / +Reflowable / +FixedLayout / +JavaScriptBridge / +Search)
|
|
||||||
- 职责:
|
|
||||||
- 内部持有 WKWebView,配置自定义 scheme handler 和 JS 桥接
|
|
||||||
- 可重排内容加载:`loadPage(parser:spineIndex:pageIndex:...)`(`+Reflowable.swift`)
|
|
||||||
- 固定版式内容加载:`loadFixedSpread(parser:spread:...)`(`+FixedLayout.swift`)
|
|
||||||
- 处理 JS 桥接消息和链接导航拦截(`+JavaScriptBridge.swift`)
|
|
||||||
- 搜索高亮装饰(`+Search.swift`)
|
|
||||||
- 定义 `RDEPUBWebViewDelegate` 协议(6 个回调方法)
|
|
||||||
|
|
||||||
### 2.5 离屏分页器 `RDEPUBPaginator`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBPaginator.swift`
|
|
||||||
- 职责:
|
|
||||||
- 创建隐藏的 WKWebView 加载每个 spine 项的 HTML
|
|
||||||
- 注入分页 CSS,运行 JS 测量 `scrollWidth`
|
|
||||||
- 计算 `ceil(scrollWidth / viewportWidth)` 作为页数
|
|
||||||
- 多次测量取最大值(延迟 0ms / 80ms / 180ms)确保布局稳定
|
|
||||||
|
|
||||||
### 2.6 资源解析 `RDEPUBResourceResolver` + `RDEPUBResourceURLSchemeHandler`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBResourceResolver.swift`、`EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
|
||||||
- 职责:
|
|
||||||
- `RDEPUBResourceResolver`:对外门面,提供 href 标准化、spine 索引查找、Location 构建
|
|
||||||
- `RDEPUBResourceURLSchemeHandler`:实现 `WKURLSchemeHandler`,将 `ss-reader://book/<path>` 映射到解压后的文件系统路径,缺失的可选资源(字体/图片/CSS/JS)返回空 200
|
|
||||||
|
|
||||||
### 2.7 JS 桥接
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBJavaScriptBridge.swift`、`Resources/epub-bridge.js`
|
|
||||||
- 6 种消息类型(JS → Swift):
|
|
||||||
- `ssReaderProgressionChanged`:阅读进度变化
|
|
||||||
- `ssReaderSelectionChanged`:文本选择变化
|
|
||||||
- `ssReaderInternalLink`:内部链接点击
|
|
||||||
- `ssReaderExternalLink`:外部链接点击
|
|
||||||
- `ssReaderJSError`:JS 错误
|
|
||||||
- `ssReaderFixedLayoutReady`:固定版式渲染完成
|
|
||||||
- JS 端 `window.RDReaderBridge` 暴露:`applyPagination`、`setPageMetrics`、`scrollToPage`、`scrollToLocation`、`setHighlights`、`clearHighlights`、`reportProgression`、`selectionPayload`
|
|
||||||
|
|
||||||
### 2.8 搜索引擎 `RDEPUBSearchEngine` / `RDEPUBHTMLSearchEngine`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBSearchEngine.swift`、`EPUBCore/RDEPUBSearchModels.swift`
|
|
||||||
- 职责:
|
|
||||||
- `RDEPUBSearchEngine`:搜索协议定义
|
|
||||||
- `RDEPUBHTMLSearchEngine`:WebView 路径实现,将 HTML 转为纯文本(NSAttributedString 或正则兜底),执行大小写不敏感的全文搜索
|
|
||||||
- 搜索模型:`RDEPUBSearchMatch`、`RDEPUBSearchResult`、`RDEPUBSearchState`、`RDEPUBSearchPresentation`
|
|
||||||
|
|
||||||
### 2.9 配置与样式
|
|
||||||
|
|
||||||
- `RDEPUBPreferences`(`EPUBCore/RDEPUBPreferences.swift`):用户阅读偏好(字号、行高、内容边距、主题色、固定版式适配模式),构建 `RDEPUBPresentationStyle` 和 `RDEPUBRenderRequest`
|
|
||||||
- `RDEPUBNavigatorLayoutContext`(`EPUBCore/RDEPUBNavigatorLayoutContext.swift`):容器布局上下文(容器尺寸、每屏页数、安全区域、设备类型)
|
|
||||||
- `RDEPUBStyleSheetBuilder`(`EPUBCore/RDEPUBStyleSheetBuilder.swift`):生成分页 CSS、渲染 CSS 和测量 JS 脚本
|
|
||||||
- `RDEPUBFixedLayoutTemplate`(`EPUBCore/RDEPUBFixedLayoutTemplate.swift`):生成固定版式 HTML 模板
|
|
||||||
|
|
||||||
### 2.10 文本锚点 `RDEPUBTextAnchor` / `RDEPUBTextRangeAnchor`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBTextAnchor.swift`
|
|
||||||
- 职责:
|
|
||||||
- `RDEPUBTextAnchor`:精确定位到 EPUB 中的某个字符位置,包含 fileIndex(spine 索引)、row(行号)、column(列号)、chapterOffset(章节内字符偏移)、fragmentID(最近的 fragment)
|
|
||||||
- `RDEPUBTextRangeAnchor`:由起止锚点组成的文本区间,可转换为 NSRange
|
|
||||||
- 支持 Codable 序列化,兼容 `fileIndex` 和 `spineIndex` 两种 key
|
|
||||||
- 用于文本 EPUB 的高亮精确锚定和跨会话恢复
|
|
||||||
|
|
||||||
### 2.11 渲染请求模型 `RDEPUBRenderRequest`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBRenderRequest.swift`
|
|
||||||
- 职责:
|
|
||||||
- `RDEPUBFixedLayoutFit`:固定版式适配模式枚举(`.auto` / `.page` / `.width`)
|
|
||||||
- `RDEPUBFixedLayoutSpreadMode`:Spread 显示模式枚举(`.automatic` / `.always` / `.never`)
|
|
||||||
- `RDEPUBPresentationStyle`:WebView 和 Paginator 共用的视觉参数(viewportSize、contentInsets、fontSize、lineHeightMultiple、主题色)
|
|
||||||
- `RDEPUBReflowableRenderRequest`:可重排内容渲染请求(spineIndex、href、pageIndex、presentation、highlights、searchPresentation)
|
|
||||||
- `RDEPUBFixedRenderRequest`:固定版式渲染请求(spread、viewportSize、fit、searchPresentation)
|
|
||||||
- `RDEPUBRenderRequest`:统一枚举(`.reflowable` / `.fixed`),WebView 根据此类型选择渲染路径
|
|
||||||
|
|
||||||
### 2.12 调试工具 `RDEPUBWebViewDebug`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBWebViewDebug.swift`
|
|
||||||
- 职责:
|
|
||||||
- WebView 调试日志工具集,DEBUG 模式默认开启
|
|
||||||
- 支持导航事件、JS 执行、消息接收、URL Scheme 任务的日志记录
|
|
||||||
- 可通过 UserDefaults `"RDEPUBWebViewDebugEnabled"` 覆盖开关
|
|
||||||
|
|
||||||
### 2.13 资源加载器 `RDEPUBAssetRepository`
|
|
||||||
|
|
||||||
- 文件:`EPUBCore/RDEPUBAssetRepository.swift`
|
|
||||||
- 职责:
|
|
||||||
- 从资源包加载 JS 桥接脚本(`epub-bridge.js`)和固定版式 HTML 模板(`epub-fixed-layout.html`)
|
|
||||||
- 支持 `{{token}}` 模板变量替换
|
|
||||||
- 自动定位 `RDReaderViewAssets.bundle`(优先已解析子 bundle,兜底宿主 bundle)
|
|
||||||
|
|
||||||
## 3. 主流程(代码级)
|
|
||||||
|
|
||||||
### 3.1 EPUB 解析全流程
|
|
||||||
|
|
||||||
1. 入口:`RDEPUBParser.parse(epubURL:)`。
|
|
||||||
2. 调用 `extractArchiveIfNeeded(epubURL:)`:
|
|
||||||
- 缓存路径:`~/Library/Caches/ssreaderview-epub/<slug>-<fileSize>-<timestamp>/`
|
|
||||||
- 若目录已存在则跳过解压
|
|
||||||
- 否则用 ZIPFoundation 解压 ZIP 到缓存目录
|
|
||||||
3. 解析 `META-INF/container.xml`:
|
|
||||||
- `ContainerXMLParserDelegate`(SAX)找到第一个 `<rootfile>` 的 `full-path` 属性
|
|
||||||
4. 解析 OPF 文档:
|
|
||||||
- `OPFPackageParserDelegate`(SAX)分段解析 metadata / manifest / spine
|
|
||||||
- 提取 `<package>` 版本和 unique-identifier
|
|
||||||
- 提取 `<dc:identifier>` / `<dc:title>` / `<dc:creator>` / `<dc:language>`
|
|
||||||
- 提取 `<meta property="rendition:layout">` 判断 fixed/reflowable
|
|
||||||
- 提取 manifest 每个 `<item>` 为 `RDEPUBManifestItem`
|
|
||||||
- 提取 spine 每个 `<itemref>` 为 `OPFSpineReference`
|
|
||||||
5. 构建 spine:`buildSpine(from:opfURL:)`
|
|
||||||
- 将 spine reference 匹配到 manifest item
|
|
||||||
- 标准化 href(相对于 OPF 目录)
|
|
||||||
- 确定每项的有效 layout(显式属性覆盖出版级设置)
|
|
||||||
- 返回 `[RDEPUBSpineItem]`,空 spine 抛 `emptySpine` 错误
|
|
||||||
6. 解析目录:`parseTOC(from:opfURL:)`
|
|
||||||
- 优先 NCX(`NCXParserDelegate`),其次 Nav Document(`NavDocumentParserDelegate`)
|
|
||||||
- 兜底使用 spine 标题
|
|
||||||
- href 标准化保留 fragment 标识符
|
|
||||||
7. 创建 Publication:`makePublication()` → `RDEPUBPublication(parser: self)`
|
|
||||||
|
|
||||||
### 3.2 阅读配置文件判断
|
|
||||||
|
|
||||||
- 入口:`RDEPUBParser+ReadingProfile.swift`
|
|
||||||
- 判断逻辑:
|
|
||||||
- 扫描 manifest 检查是否有 `scripted` 属性的项
|
|
||||||
- 扫描 HTML body 检查是否有 `script`、`iframe`、`video` 等交互元素
|
|
||||||
- 结果:
|
|
||||||
- `.webFixedLayout`:固定版式 EPUB(漫画、绘本)— WKWebView 渲染
|
|
||||||
- `.webInteractive`:可重排 + 交互脚本 EPUB — WKWebView 渲染
|
|
||||||
- `.textReflowable`:纯文本可重排 EPUB — DTCoreText 渲染
|
|
||||||
|
|
||||||
### 3.3 离屏分页测量
|
|
||||||
|
|
||||||
1. 入口:`RDEPUBPaginator.calculate(parser:hostingView:presentation:completion:)`
|
|
||||||
2. 初始化所有 spine 项页数为 `[1, 1, ..., 1]`
|
|
||||||
3. 固定版式直接返回(每 spread = 1 页)
|
|
||||||
4. 顺序遍历可渲染的 HTML/XHTML spine 项
|
|
||||||
5. 对每项:
|
|
||||||
- `webView.loadFileURL(...)` 加载文件
|
|
||||||
- `didFinish` 后启动多次测量(`scheduleMeasurementPass`)
|
|
||||||
- 3 次测量延迟 [0ms, 80ms, 180ms]
|
|
||||||
- 每次执行 `measurementScript`:注入分页 CSS → 测量 `scrollWidth` → 返回 `Math.max(1, Math.ceil(totalWidth / viewportWidth))`
|
|
||||||
- 取多次测量的最大值
|
|
||||||
6. 全部完成后回调 `completion([Int])` 页数数组
|
|
||||||
7. `activeSessionID`(UUID)防止过期回调污染结果
|
|
||||||
|
|
||||||
### 3.4 WebView 可重排内容加载
|
|
||||||
|
|
||||||
1. 入口:`RDEPUBWebView.loadPage(parser:spineIndex:pageIndex:...)`
|
|
||||||
2. 计算 `loadSignature`(包含 publication key、spine index、href、viewport、字号、行高、主题色、目标位置、高亮等)
|
|
||||||
3. 若签名与 `currentLoadSignature` 相同且已渲染/正在加载,跳过重复请求
|
|
||||||
4. 若签名匹配但文档 URL 已加载,仅调用 `applyPresentation()` 重新应用样式
|
|
||||||
5. 否则 `handleReflowableLoad(...)` 执行完整加载:
|
|
||||||
- 读取 HTML 内容
|
|
||||||
- 调用 `applyPresentationScript(for:)` 生成 JS
|
|
||||||
- JS 执行:`RDReaderBridge.applyPagination(css)` → `setPageMetrics` → 清除并重新应用高亮 → 滚动到目标位置 → 报告进度 → 应用搜索装饰
|
|
||||||
|
|
||||||
### 3.5 固定版式内容加载
|
|
||||||
|
|
||||||
1. 入口:`RDEPUBWebView.loadFixedSpread(parser:spread:...)`
|
|
||||||
2. `RDEPUBFixedLayoutTemplate.html(for:publication:)` 从模板生成 HTML,包含 spread 中每个资源的 `<iframe>`
|
|
||||||
3. `webView.loadHTMLString(html, baseURL: "ss-reader://book/")` 加载
|
|
||||||
4. 模板 JS 加载每个 iframe,检测页面尺寸,计算缩放(auto/page/width 适配模式),定位 iframe
|
|
||||||
5. 所有 iframe 加载完成后 JS 发送 `ssReaderFixedLayoutReady`
|
|
||||||
6. Swift 收到消息后应用搜索装饰并调用 `rendered()`
|
|
||||||
7. 1 秒兜底定时器确保即使 JS 消息丢失也能调用 `rendered()`
|
|
||||||
|
|
||||||
### 3.6 JS 桥接消息处理
|
|
||||||
|
|
||||||
- 入口:`RDEPUBWebView+JavaScriptBridge.swift` 的 `userContentController(_:didReceive:)`
|
|
||||||
- 分发逻辑:
|
|
||||||
- `progressionChanged` → 提取 progression/lastProgression/fragment → `delegate?.epubWebView(_:didUpdateLocation:spineIndex:)`
|
|
||||||
- `selectionChanged` → 提取 text/rangeInfo → 创建 `RDEPUBSelection` → `delegate?.epubWebView(_:didChangeSelection:spineIndex:)`
|
|
||||||
- `internalLink` → 创建 `RDEPUBLocation` → `delegate?.epubWebView(_:didActivateInternalLink:fromSpineIndex:)`
|
|
||||||
- `externalLink` → 提取 URL → `delegate?.epubWebView(_:didActivateExternalLink:)`
|
|
||||||
- `javaScriptError` → `delegate?.epubWebView(_:didLogJavaScriptError:)`
|
|
||||||
- `fixedLayoutReady` → 应用搜索装饰 → `rendered()`
|
|
||||||
|
|
||||||
### 3.7 链接导航拦截
|
|
||||||
|
|
||||||
- 入口:`decidePolicyFor navigationAction`
|
|
||||||
- 逻辑:
|
|
||||||
- `ss-reader://` URL + `.linkActivated` → 内部链接代理回调,取消导航
|
|
||||||
- `http/https/mailto/tel` → 外部链接代理回调,取消导航
|
|
||||||
- 其他 → 允许导航
|
|
||||||
|
|
||||||
## 4. 异常与边界处理
|
|
||||||
|
|
||||||
- `RDEPUBParserError`(7 种,全部 `LocalizedError`,中文描述):
|
|
||||||
- `archiveOpenFailed(URL)`:ZIPFoundation 无法打开 EPUB 文件
|
|
||||||
- `missingContainerXML`:`META-INF/container.xml` 不存在
|
|
||||||
- `missingRootFile`:container.xml 中无 `<rootfile>`
|
|
||||||
- `invalidRootFilePath(String)`:rootfile 路径对应的 OPF 文件不存在
|
|
||||||
- `invalidXML(URL)`:XMLParser 无法创建或解析失败
|
|
||||||
- `missingManifestItem(idref:)`:spine 引用了不存在的 manifest 项
|
|
||||||
- `emptySpine`:构建后 spine 为空
|
|
||||||
- 目录解析:NCX 和 Nav Document 解析器失败时返回空数组,不抛异常,兜底使用 spine 标题
|
|
||||||
- 资源缺失:`RDEPUBResourceURLSchemeHandler` 对缺失的可选资源(字体/图片/CSS/JS)返回空 200,避免 WKWebView 控制台噪音
|
|
||||||
- 分页失败:`RDEPUBPaginator` 导航失败(`didFail` / `didFailProvisional`)时将页数设为 1 并继续下一项
|
|
||||||
- HTML 转纯文本兜底:`RDEPUBHTMLSearchEngine` 在 NSAttributedString 失败时回退到正则去标签
|
|
||||||
- 固定版式 1 秒兜底:`scheduleFixedLayoutReadyFallback()` 确保即使 JS ready 消息丢失也能调用 `rendered()`
|
|
||||||
- 重复请求跳过:`handleReflowableLoad` 在 loadSignature 匹配且已渲染/正在加载时跳过
|
|
||||||
|
|
||||||
## 5. 数据结构与字段映射
|
|
||||||
|
|
||||||
### 5.1 EPUB 元数据
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBParser
|
|
||||||
├── metadata: RDEPUBMetadata
|
|
||||||
│ ├── identifier, title, author, language, version
|
|
||||||
│ ├── layout: RDEPUBLayout (.reflowable | .fixed)
|
|
||||||
│ ├── readingProgression: RDEPUBReadingProgression (.ltr | .rtl | .auto)
|
|
||||||
│ └── spread: String?
|
|
||||||
├── manifest: [String: RDEPUBManifestItem]
|
|
||||||
│ ├── id, href, mediaType
|
|
||||||
│ ├── properties: [String] (nav, scripted, cover-image, rendition:layout-*)
|
|
||||||
│ └── title: String?
|
|
||||||
├── spine: [RDEPUBSpineItem]
|
|
||||||
│ ├── idref -> manifest[id]
|
|
||||||
│ ├── href (normalized), mediaType, title, linear
|
|
||||||
│ ├── pageSpread: RDEPUBPageSpread? (.left | .right | .center)
|
|
||||||
│ └── layout: RDEPUBLayout? (per-item override)
|
|
||||||
└── tableOfContents: [EPUBTableOfContentsItem]
|
|
||||||
├── title, href
|
|
||||||
└── children: [EPUBTableOfContentsItem] (recursive)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 阅读位置模型
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBLocation
|
|
||||||
├── bookIdentifier, href
|
|
||||||
├── progression: Double? (0..1,章节内起始位置)
|
|
||||||
├── lastProgression: Double? (0..1,章节内结束位置)
|
|
||||||
├── fragment: String? (#anchor)
|
|
||||||
├── rangeAnchor: RDEPUBTextRangeAnchor? (文本范围锚点,用于高亮精确定位)
|
|
||||||
└── navigationProgression (computed: midpoint of progression and lastProgression)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 视口模型
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBViewport
|
|
||||||
├── resources: [RDEPUBViewportResource]
|
|
||||||
│ ├── href, spineIndex
|
|
||||||
│ ├── progression, lastProgression, fragment
|
|
||||||
├── visiblePageNumber, chapterIndex
|
|
||||||
└── isFixedLayout: Bool
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.4 渲染请求
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBRenderRequest (enum)
|
|
||||||
├── .reflowable(RDEPUBReflowableRenderRequest)
|
|
||||||
│ ├── spineIndex, href, pageIndex, totalPagesInChapter
|
|
||||||
│ ├── presentation: RDEPUBPresentationStyle
|
|
||||||
│ │ ├── viewportSize, contentInsets
|
|
||||||
│ │ ├── fontSize, lineHeightMultiple
|
|
||||||
│ │ └── themeBackgroundColor, themeTextColor
|
|
||||||
│ ├── targetLocation: RDEPUBLocation?
|
|
||||||
│ ├── highlights: [RDEPUBHighlight]
|
|
||||||
│ └── searchPresentation: RDEPUBSearchPresentation?
|
|
||||||
└── .fixed(RDEPUBFixedRenderRequest)
|
|
||||||
├── spread: EPUBFixedSpread
|
|
||||||
│ └── resources: [EPUBFixedSpreadResource]
|
|
||||||
├── viewportSize, contentInset
|
|
||||||
├── backgroundColorCSS
|
|
||||||
├── fit: RDEPUBFixedLayoutFit (.auto | .page | .width)
|
|
||||||
└── searchPresentation: RDEPUBSearchPresentation?
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.5 高亮与选择
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBHighlight
|
|
||||||
├── id, bookIdentifier
|
|
||||||
├── location: RDEPUBLocation
|
|
||||||
├── text, rangeInfo (JSON-serialized DOM range 或 text-offset)
|
|
||||||
├── style: RDEPUBHighlightStyle (.highlight | .underline)
|
|
||||||
├── color: String, note: String
|
|
||||||
└── createdAt: Date
|
|
||||||
|
|
||||||
RDEPUBSelection
|
|
||||||
├── bookIdentifier
|
|
||||||
├── location: RDEPUBLocation
|
|
||||||
├── text, rangeInfo
|
|
||||||
└── createdAt: Date
|
|
||||||
|
|
||||||
RDEPUBBookmark
|
|
||||||
├── id, bookIdentifier
|
|
||||||
├── location: RDEPUBLocation
|
|
||||||
├── title: String?
|
|
||||||
├── note: String?
|
|
||||||
└── createdAt: Date
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.6 搜索模型
|
|
||||||
|
|
||||||
```
|
|
||||||
RDEPUBSearchState
|
|
||||||
├── keyword: String
|
|
||||||
├── matches: [RDEPUBSearchMatch]
|
|
||||||
│ ├── href, progression
|
|
||||||
│ ├── previewText, localMatchIndex
|
|
||||||
│ ├── rangeLocation, rangeLength
|
|
||||||
└── currentMatchIndex: Int
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 分页与渲染机制
|
|
||||||
|
|
||||||
### 6.1 CSS 多栏分页原理
|
|
||||||
|
|
||||||
- HTML 内容被包裹为 `body > div#ss-reader-viewport > div#ss-reader-content`
|
|
||||||
- `#ss-reader-content` 应用 `-webkit-column-width: <viewportWidth>px`
|
|
||||||
- `html` / `body` 设置 `overflow: hidden`
|
|
||||||
- 页间导航通过 `transform: translate3d(-N*stride, 0, 0)` 实现
|
|
||||||
|
|
||||||
### 6.2 重新布局触发条件
|
|
||||||
|
|
||||||
- `scheduleRelayout()`(60ms 防抖)在以下事件触发:
|
|
||||||
- `window.load`、`window.resize`
|
|
||||||
- `document.fonts.ready`
|
|
||||||
- 每个 `<img>` / `<iframe>` / `<video>` 的 load/error 事件
|
|
||||||
|
|
||||||
### 6.3 选择变化处理
|
|
||||||
|
|
||||||
- `selectionchange` 监听器(120ms 防抖)发送 `ssReaderSelectionChanged` 消息,携带 text、rangeInfo、progression
|
|
||||||
|
|
||||||
## 7. 缓存策略
|
|
||||||
|
|
||||||
- **EPUB 解压缓存**:解压目录按 `文件大小 + 修改时间` 生成缓存 key,已存在则跳过解压
|
|
||||||
- **Load Signature 去重**:每次渲染请求计算签名(含 publication key、layout、spine index、viewport、字号、行高、主题色、目标位置、高亮),相同签名跳过重复加载
|
|
||||||
- **WKWebView 复用**:同一 publication 配置下复用已有 WKWebView 实例,仅切换 publication 时才 teardown
|
|
||||||
- **非持久化数据存储**:`WKWebViewConfiguration().websiteDataStore = .nonPersistent()`,不缓存 Web 内容到磁盘
|
|
||||||
|
|
||||||
## 8. 联调与排查建议
|
|
||||||
|
|
||||||
- 排查 1:EPUB 解析失败
|
|
||||||
- 检查 `parse(epubURL:)` 抛出的 `RDEPUBParserError` 具体类型
|
|
||||||
- 检查 EPUB 文件是否损坏(ZIP 能否正常打开)
|
|
||||||
- 检查 `META-INF/container.xml` 是否存在且格式正确
|
|
||||||
- 排查 2:目录为空
|
|
||||||
- 检查 OPF 中是否有 NCX 或 Nav Document 引用
|
|
||||||
- 检查 NCX 文件路径是否正确(相对于 OPF 目录)
|
|
||||||
- 目录解析失败时会兜底使用 spine 标题
|
|
||||||
- 排查 3:页面内容空白
|
|
||||||
- 检查 `ss-reader://` scheme handler 是否正确映射资源路径
|
|
||||||
- 检查 `RDEPUBResourceURLSchemeHandler` 日志(`RDEPUBWebViewDebug`)
|
|
||||||
- 确认 HTML 文件在解压目录中实际存在
|
|
||||||
- 排查 4:分页页数不正确
|
|
||||||
- 检查 `RDEPUBPaginator` 多次测量结果是否一致
|
|
||||||
- 检查 viewport 尺寸计算是否正确(容器宽度 / pagesPerScreen)
|
|
||||||
- 检查 CSS 分页样式是否正确注入
|
|
||||||
- 排查 5:JS 桥接消息未收到
|
|
||||||
- 确认 `epub-bridge.js` 已正确注入(检查 `WKUserContentController`)
|
|
||||||
- 检查 JS 控制台是否有错误
|
|
||||||
- 固定版式检查是否收到 `ssReaderFixedLayoutReady`,否则看 1 秒兜底定时器
|
|
||||||
- 排查 6:内部链接不跳转
|
|
||||||
- 检查链接 href 是否以 `ss-reader://` 开头
|
|
||||||
- 检查 `decidePolicyFor navigationAction` 中的链接类型判断
|
|
||||||
- 确认 `RDEPUBLocation` 构建是否正确(href + fragment)
|
|
||||||
@ -1,352 +0,0 @@
|
|||||||
# EPUBTextRendering 功能实现逻辑
|
|
||||||
|
|
||||||
## 1. 范围与目标
|
|
||||||
|
|
||||||
- 代码范围:`Sources/RDReaderView/EPUBTextRendering/`(13 个 Swift 文件)
|
|
||||||
- 目标:说明文本渲染路径如何将 EPUB HTML 转换为 NSAttributedString、按字符范围分页、构建书籍模型,并支持全文搜索与 textReflowable 标注定位。
|
|
||||||
- 主链路关键词:`RDEPUBParser HTML -> DTCoreText 渲染 -> 片段标记注入/提取 -> CoreText 分页 -> RDEPUBTextBook -> 页面查找/位置转换`。
|
|
||||||
- 适用范围:仅用于 `textReflowable` 阅读配置文件(纯文本可重排 EPUB,如小说)。固定版式和交互式 EPUB 使用 WebView 渲染路径。
|
|
||||||
|
|
||||||
## 2. 关键对象职责
|
|
||||||
|
|
||||||
### 2.1 渲染协议 `RDEPUBTextRenderer`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBTextRenderer.swift`
|
|
||||||
- 职责:
|
|
||||||
- 定义双方法协议:
|
|
||||||
- `renderChapter(request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent`(主方法,携带完整上下文)
|
|
||||||
- `renderChapter(html:baseURL:style:) throws -> RDEPUBRenderedChapterContent`(便捷方法,默认实现转发到主方法)
|
|
||||||
- 作为渲染后端的抽象点,当前仅 `RDEPUBDTCoreTextRenderer` 一个实现
|
|
||||||
|
|
||||||
### 2.2 DTCoreText 渲染器 `RDEPUBDTCoreTextRenderer`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift`
|
|
||||||
- 职责:
|
|
||||||
- 实现 `RDEPUBTextRenderer` 协议
|
|
||||||
- 使用 `DTHTMLAttributedStringBuilder` 将 HTML 转为 NSAttributedString
|
|
||||||
- 渲染后处理:提取片段标记、统一字体/行间距/颜色
|
|
||||||
- DTCoreText 不可用时回退到纯文本渲染
|
|
||||||
|
|
||||||
### 2.3 渲染支持工具 `RDEPUBTextRendererSupport`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBTextRendererSupport.swift`
|
|
||||||
- 职责:
|
|
||||||
- 片段标记注入:扫描 HTML 中带 `id` 属性的元素,在其前注入 `${id=<fragmentID>}` 不可见标记
|
|
||||||
- 片段偏移提取:从 NSAttributedString 中提取标记位置并删除标记
|
|
||||||
- 字体标准化:保留 HTML 中的粗体/斜体 trait,统一使用配置的基础字体族和字号
|
|
||||||
- 段落样式创建:强制应用配置的行间距和段间距
|
|
||||||
- 兜底渲染:DTCoreText 不可用时从原始 HTML 字符串生成纯文本 NSAttributedString
|
|
||||||
|
|
||||||
### 2.4 分页支持 `NSAttributedString.ss_pageRanges(size:)` / `rd_paginatedFrames(size:)`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBTextPaginationSupport.swift`
|
|
||||||
- 职责:
|
|
||||||
- `rd_paginatedFrames(size:) -> [RDEPUBTextLayoutFrame]`:基于 `RDEPUBTextLayouter` 的增强分页,返回带语义元数据的帧对象
|
|
||||||
- `ss_pageRanges(size:) -> [NSRange]`:便捷方法,内部调用 `rd_paginatedFrames` 后提取 contentRange
|
|
||||||
- 从位置 0 开始,反复创建 CTFrame 测量可见字符范围
|
|
||||||
- 返回 `[NSRange]`,每个 range 对应一页
|
|
||||||
- 安全保护:`visibleRange.length == 0` 时 break 防止死循环
|
|
||||||
|
|
||||||
### 2.5 书籍构建器 `RDEPUBTextBookBuilder`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBTextBookBuilder.swift`
|
|
||||||
- 职责:
|
|
||||||
- 遍历 publication 的 linear spine 项(仅 HTML/XHTML 类型)
|
|
||||||
- 对每项:HTML 标准化 → 渲染 → 跳过空白封面/标题页 → 分页 → 构建 `RDEPUBTextPage` 数组
|
|
||||||
- 汇总所有章节为 `RDEPUBTextBook`
|
|
||||||
- 后台队列执行,主线程回调结果
|
|
||||||
|
|
||||||
### 2.6 全文搜索引擎 `RDEPUBTextSearchEngine`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBTextSearchEngine.swift`
|
|
||||||
- 职责:
|
|
||||||
- 实现 `RDEPUBSearchEngine` 协议
|
|
||||||
- 在已渲染的 NSAttributedString 纯文本上执行线性搜索
|
|
||||||
- 返回 `RDEPUBSearchMatch` 数组(含 href、progression、预览文本、匹配位置)
|
|
||||||
|
|
||||||
### 2.6.1 章节数据模型 `RDEPUBChapterData`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBChapterData.swift`
|
|
||||||
- 职责:
|
|
||||||
- 每章节的数据聚合模型,管理高亮、搜索结果和页面查询
|
|
||||||
- 支持按页码范围过滤当前页的高亮列表
|
|
||||||
- 支持搜索结果在章节内的定位和匹配索引计算
|
|
||||||
|
|
||||||
### 2.7 CoreText 分页引擎 `RDEPUBTextLayouter` / `RDEPUBTextLayoutFrame`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBTextLayouter.swift`、`EPUBTextRendering/RDEPUBTextLayoutFrame.swift`
|
|
||||||
- 职责:
|
|
||||||
- `RDEPUBTextLayouter`:封装 `CTFramesetter`,逐帧计算分页结果,支持语义边界调整(避免在附件/代码块/列表中间断页)
|
|
||||||
- `RDEPUBTextLayoutFrame`:单帧分页结果,包含 contentRange、breakReason、blockRange、attachment 信息、语义提示和诊断数据
|
|
||||||
- 替代原有的纯 `NSRange` 分页,返回带丰富元数据的帧对象
|
|
||||||
|
|
||||||
### 2.8 纯文本构建器 `RDPlainTextBookBuilder`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDPlainTextBookBuilder.swift`
|
|
||||||
- 职责:
|
|
||||||
- 从纯文本文件(.txt 等)构建 `RDEPUBTextBook`
|
|
||||||
- 将纯文本按段落切分后渲染为 NSAttributedString
|
|
||||||
- 按页面尺寸分页,输出与 EPUB 文本路径一致的书籍模型
|
|
||||||
|
|
||||||
### 2.9 文本索引表 `RDEPUBTextIndexTable`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBTextIndexTable.swift`
|
|
||||||
- 职责:
|
|
||||||
- 构建全书的文本结构映射:章节起始偏移、href 与章节/spine 索引对应、fragment 偏移映射、行列索引映射
|
|
||||||
- `RDEPUBRowColumnIndex`:行-列索引条目,记录每行的字符范围
|
|
||||||
- 支持锚点与位置之间的双向转换:
|
|
||||||
- `anchor(forAbsoluteIndex:in:)`:绝对索引 → 文本锚点
|
|
||||||
- `anchor(for:)`:阅读位置 → 文本锚点(优先 rangeAnchor,其次 fragment/progression)
|
|
||||||
- `absoluteIndex(for:)`:锚点 → 全书绝对索引
|
|
||||||
- `location(for:in:bookIdentifier:)`:锚点/范围锚点 → 阅读位置
|
|
||||||
- 支持页码查找:`pageNumber(for:in:)` 通过锚点定位到对应页面
|
|
||||||
|
|
||||||
### 2.10 性能采样器 `RDEPUBTextPerformanceSampler`
|
|
||||||
|
|
||||||
- 文件:`EPUBTextRendering/RDEPUBTextPerformanceSampler.swift`
|
|
||||||
- 职责:
|
|
||||||
- `RDEPUBTextPerformanceSample`:单章节性能数据(chapterHref、renderDuration、paginateDuration、pageCount、attributedStringLength、cacheHit)
|
|
||||||
- `RDEPUBTextPerformanceSampler`:书籍构建过程的性能采样器
|
|
||||||
- 由 `RDEPUBTextBookBuilder` 在构建过程中使用,每章记录一个采样点
|
|
||||||
- `summary()` 输出汇总报告:总渲染/分页耗时和缓存命中率
|
|
||||||
|
|
||||||
## 3. 主流程(代码级)
|
|
||||||
|
|
||||||
### 3.1 完整渲染-分页流程
|
|
||||||
|
|
||||||
1. 入口:`RDEPUBTextBookBuilder.build(parser:publication:pageSize:style:)`
|
|
||||||
2. 遍历 `publication.spine` 中 `item.linear == true` 的项
|
|
||||||
3. 过滤仅处理 mediaType 包含 "html" 或 "xhtml" 的项
|
|
||||||
4. 读取 `parser.htmlString(forRelativePath: item.href)` 获取原始 HTML
|
|
||||||
5. HTML 标准化:
|
|
||||||
- 移除中文分页标记:`<hr lang="zh-CN">分页符</hr>`
|
|
||||||
- `\r` 替换为 `\n`
|
|
||||||
- 连续 `\n` 合并为单个
|
|
||||||
6. 检查是否跳过(空白且 href 包含 "cover" 或 "title")
|
|
||||||
7. 调用 `renderer.renderChapter(html:baseURL:style:)` 渲染
|
|
||||||
|
|
||||||
### 3.2 DTCoreText 渲染管线
|
|
||||||
|
|
||||||
1. 片段标记注入:`RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)`
|
|
||||||
- 正则 `(<[^>]+\sid="([^"]+)"[^>]*>)` 匹配带 id 的元素
|
|
||||||
- 在匹配标签前插入 `${id=<fragmentID>}` 文本标记
|
|
||||||
2. UTF-8 编码为 Data
|
|
||||||
3. `DTHTMLAttributedStringBuilder(html:options:documentAttributes:)` 执行渲染
|
|
||||||
4. DTCoreText 选项:
|
|
||||||
- `NSTextSizeMultiplierDocumentOption: 1.0`
|
|
||||||
- `DTDefaultFontFamily` / `DTDefaultFontName` / `DTDefaultFontSize`:来自 `style.font`
|
|
||||||
- `DTDefaultLineHeightMultiplier`:`(font.lineHeight + lineSpacing) / max(font.lineHeight, 1)`
|
|
||||||
- `DTUseiOS6Attributes: true`:生成 UIKit 兼容属性
|
|
||||||
- `NSBaseURLDocumentOption`:资源相对路径解析
|
|
||||||
- `DTDefaultTextColor`:可选文本颜色
|
|
||||||
5. 后处理:
|
|
||||||
- 提取片段偏移并删除标记
|
|
||||||
- `normalizeReadingAttributes`:遍历每个属性 run,统一字体(保留粗/斜体 trait)、强制行间距/段间距、覆盖前景色
|
|
||||||
|
|
||||||
### 3.3 CoreText 分页算法
|
|
||||||
|
|
||||||
1. 从 `NSAttributedString` 创建 `CTFramesetter`
|
|
||||||
2. 创建页面大小的 `CGPath`
|
|
||||||
3. 从 location = 0 开始循环:
|
|
||||||
- 创建 CTFrame(range 从当前 location 到字符串末尾)
|
|
||||||
- `CTFrameGetVisibleStringRange(frame)` 获取可见字符数
|
|
||||||
- 追加 `NSRange(location: currentLocation, length: visibleRange.length)`
|
|
||||||
- location += visibleRange.length
|
|
||||||
4. 循环终止条件:`location + visibleRange.length >= attributedString.length`
|
|
||||||
5. 安全保护:`visibleRange.length == 0` 时 break
|
|
||||||
|
|
||||||
### 3.4 页面与位置互转
|
|
||||||
|
|
||||||
**Location → Page Number**(`RDEPUBTextBook.pageNumber(for:resolver:bookIdentifier:)`):
|
|
||||||
1. 有 fragment 时:用 `fragmentOffsets` 查找字符偏移,再定位到对应页面
|
|
||||||
2. 无 fragment 时:用 `navigationProgression`(progression 和 lastProgression 的中点)计算字符偏移
|
|
||||||
3. 遍历 pages 找到 `pageStartOffset <= offset < pageEndOffset` 的页面
|
|
||||||
|
|
||||||
**Page Number → Location**(`RDEPUBTextBook.location(forPageNumber:bookIdentifier:)`):
|
|
||||||
1. 通过 page number 查找 `RDEPUBTextPage`
|
|
||||||
2. 计算 `progression = pageStartOffset / totalCharacters`
|
|
||||||
3. 计算 `lastProgression = pageEndOffset / totalCharacters`
|
|
||||||
4. 构建 `RDEPUBLocation`(href + progression + lastProgression)
|
|
||||||
|
|
||||||
### 3.5 标注定位与恢复
|
|
||||||
|
|
||||||
textReflowable 路径不复用 WebView 的 DOM range,而使用章节字符偏移作为精确锚点:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"kind": "text-offset",
|
|
||||||
"href": "chapter.xhtml",
|
|
||||||
"start": 1234,
|
|
||||||
"end": 1260
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
1. `RDEPUBTextContentView` 保留当前 `RDEPUBTextPage`,从 `page.pageStartOffset` 将页内 `UITextView.selectedRange` 映射为章节全局 `start/end`
|
|
||||||
2. 生成 `RDEPUBSelection` 后回传 `RDEPUBReaderController`,共享层继续负责创建、去重、持久化、列表和跳转
|
|
||||||
3. 页面重绘时,`RDEPUBTextContentView` 仅消费 `kind == "text-offset"` 的 rangeInfo,计算当前页 `[pageStartOffset, pageEndOffset]` 与标注 `[start, end)` 的重叠
|
|
||||||
4. `highlight` 样式叠加 `.backgroundColor`,`underline` 样式叠加 `.underlineStyle` 和 `.underlineColor`
|
|
||||||
5. 字号、行高、主题变化导致重新分页后,标注仍以章节全局字符偏移恢复到新的页面切片
|
|
||||||
|
|
||||||
## 4. 异常与边界处理
|
|
||||||
|
|
||||||
- `RDEPUBTextRenderingError.htmlEncodingFailed`:HTML 字符串无法编码为 UTF-8 Data(极罕见)
|
|
||||||
- DTCoreText builder 返回 nil:回退到 `fallbackAttributedString`,生成纯文本(含 HTML 标签原文),降级体验
|
|
||||||
- 空白章节跳过:href 包含 "cover" 或 "title" 且渲染后文本为空的章节被跳过
|
|
||||||
- 分页零结果兜底:`pageRanges` 为空但 content 非空时,整个章节作为一页
|
|
||||||
- CoreText 零高度安全保护:`visibleRange.length == 0` 时 break 防止无限循环
|
|
||||||
- Token 取消:`paginationToken`(UUID)防止异步完成后的过期结果污染 UI
|
|
||||||
|
|
||||||
## 5. 数据结构与字段映射
|
|
||||||
|
|
||||||
### 5.1 渲染配置
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBTextRenderStyle
|
|
||||||
├── font: UIFont // 基础字体
|
|
||||||
├── lineSpacing: CGFloat // 额外行间距
|
|
||||||
├── textColor: UIColor? // 文本颜色(nil 时保持 HTML 原始颜色)
|
|
||||||
└── backgroundColor: UIColor? // 背景色
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 渲染请求上下文
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBTextChapterRenderRequest
|
|
||||||
├── context: RDEPUBTextChapterContext
|
|
||||||
│ ├── href: String
|
|
||||||
│ ├── title: String
|
|
||||||
│ ├── html: String
|
|
||||||
│ ├── baseURL: URL?
|
|
||||||
│ ├── stylesheet: RDEPUBTextStyleSheetPackage
|
|
||||||
│ │ ├── layers: [RDEPUBTextStyleSheetLayer]
|
|
||||||
│ │ │ └── kind (.default/.replace/.dark/.epub/.user) + css
|
|
||||||
│ │ └── combinedCSS (computed)
|
|
||||||
│ └── resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
|
||||||
└── style: RDEPUBTextRenderStyle
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 渲染输出
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBRenderedChapterContent
|
|
||||||
├── attributedString: NSAttributedString // 渲染后的富文本
|
|
||||||
├── fragmentOffsets: [String: Int] // fragment ID -> 字符偏移映射
|
|
||||||
└── resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.4 分页帧模型(新增)
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBTextLayoutFrame
|
|
||||||
├── contentRange: NSRange // 本帧字符范围
|
|
||||||
├── breakReason: RDEPUBTextPageBreakReason // 分页原因
|
|
||||||
├── blockRange: NSRange? // 所属 block 范围
|
|
||||||
├── attachmentRanges: [NSRange] // 附件范围
|
|
||||||
├── attachmentKinds: [RDEPUBTextAttachmentKind]
|
|
||||||
├── blockKinds: [RDEPUBTextBlockKind] // (.paragraph/.list/.table/.code/.blockquote/.attachment/.generic)
|
|
||||||
├── semanticHints: [RDEPUBTextSemanticHint] // (.avoidPageBreakInside/.pageBreakBefore/.pageBreakAfter/.pageRelate)
|
|
||||||
├── attachmentPlacements: [RDEPUBTextAttachmentPlacement] // (.inline/.baseline/.centered)
|
|
||||||
├── trailingFragmentID: String? // 帧尾最近的 fragment ID
|
|
||||||
└── diagnostics: [String] // 分页诊断信息
|
|
||||||
|
|
||||||
RDEPUBTextLayouter
|
|
||||||
├── init(attributedString:pageSize:)
|
|
||||||
└── layoutFrames(fragmentOffsets:) -> [RDEPUBTextLayoutFrame]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.5 页面模型
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBTextPage
|
|
||||||
├── absolutePageIndex: Int // 全局页码
|
|
||||||
├── chapterIndex: Int // 章节索引
|
|
||||||
├── spineIndex: Int // spine 索引
|
|
||||||
├── href: String // 章节 href
|
|
||||||
├── chapterTitle: String? // 章节标题
|
|
||||||
├── pageIndexInChapter: Int // 章节内页码
|
|
||||||
├── totalPagesInChapter: Int // 章节总页数
|
|
||||||
├── content: NSAttributedString // 本页的富文本切片
|
|
||||||
├── contentRange: NSRange // 在整章富文本中的 range
|
|
||||||
├── pageStartOffset: Int // 起始字符偏移
|
|
||||||
└── pageEndOffset: Int // 结束字符偏移
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.6 章节模型
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBTextChapter
|
|
||||||
├── chapterIndex: Int
|
|
||||||
├── spineIndex: Int
|
|
||||||
├── href: String
|
|
||||||
├── title: String?
|
|
||||||
├── attributedContent: NSAttributedString // 整章渲染后的富文本
|
|
||||||
├── fragmentOffsets: [String: Int]
|
|
||||||
└── pages: [RDEPUBTextPage]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.7 书籍模型
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBTextBook
|
|
||||||
├── chapters: [RDEPUBTextChapter]
|
|
||||||
├── pages: [RDEPUBTextPage] // 扁平化页面列表
|
|
||||||
├── page(at:) -> RDEPUBTextPage? // 1-based 页码查找
|
|
||||||
├── pageNumber(for:resolver:bookIdentifier:) -> Int? // Location -> 页码
|
|
||||||
└── location(forPageNumber:bookIdentifier:) -> RDEPUBLocation? // 页码 -> Location
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 与 EPUBCore 层的集成
|
|
||||||
|
|
||||||
### 消费的数据
|
|
||||||
|
|
||||||
| 来源 | 数据 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| `RDEPUBParser` | `htmlString(forRelativePath:)` | 读取章节 HTML 内容 |
|
|
||||||
| `RDEPUBParser` | `fileURL(forRelativePath:)` | 计算 DTCoreText 的 baseURL |
|
|
||||||
| `RDEPUBPublication` | `spine` | 遍历 linear HTML/XHTML 项 |
|
|
||||||
| `RDEPUBPublication` | `tableOfContents` | 匹配章节标题 |
|
|
||||||
| `RDEPUBSpineItem` | `href`, `mediaType`, `linear`, `title` | 过滤和标识章节 |
|
|
||||||
| `RDEPUBResourceResolver` | `normalizedLocation` / `normalizedHref` | 位置查找时标准化 href |
|
|
||||||
| `RDEPUBLocation` | `href`, `fragment`, `navigationProgression` | 页面定位 |
|
|
||||||
| `RDEPUBSearchEngine` | 协议接口 | `RDEPUBTextSearchEngine` 实现此协议 |
|
|
||||||
| `RDEPUBHighlight` | `style`, `rangeInfo`, `color`, `note` | text-offset 标注恢复与页内绘制 |
|
|
||||||
|
|
||||||
## 7. 配置参数
|
|
||||||
|
|
||||||
- **字号**:`RDEPUBReaderConfiguration.fontSize`,默认 15pt
|
|
||||||
- **行高倍数**:`lineHeightMultiple`,默认 1.6(行间距 = font.lineHeight * 0.6,最低 4pt)
|
|
||||||
- **内容边距**:`reflowableContentInsets`,默认 (40, 16, 40, 16),影响有效页面尺寸
|
|
||||||
- **主题色**:通过 `RDEPUBReaderTheme` 的 `contentTextColor` / `contentBackgroundColor` 传入
|
|
||||||
- **渲染引擎**:`RDEPUBTextRenderingEngine`,当前仅 `.dtCoreText`
|
|
||||||
|
|
||||||
## 8. 性能特征
|
|
||||||
|
|
||||||
- **全文渲染**:每章一次性渲染为完整的 NSMutableAttributedString,无分块/懒加载
|
|
||||||
- **内存**:`RDEPUBTextBook` 同时持有 chapters(含完整 attributedContent)和 pages(含子串切片),存在一定程度的内存重复
|
|
||||||
- **后台执行**:`build` 方法在 `DispatchQueue.global(qos: .userInitiated)` 执行,不阻塞主线程
|
|
||||||
- **搜索性能**:线性扫描每章的 `attributedContent.string`,无索引,搜索时间与总文本量线性相关
|
|
||||||
- **分页缓存**:`RDEPUBTextBookCache` 支持磁盘缓存分页结果,字号/行高变化时优先从缓存恢复
|
|
||||||
- **性能采样**:`RDEPUBTextPerformanceSampler` 记录每章的渲染和分页耗时,支持缓存命中率统计
|
|
||||||
|
|
||||||
## 9. 联调与排查建议
|
|
||||||
|
|
||||||
- 排查 1:渲染后文本为空
|
|
||||||
- 检查 `parser.htmlString(forRelativePath:)` 是否返回非 nil
|
|
||||||
- 检查 spine 项的 mediaType 是否包含 "html" 或 "xhtml"
|
|
||||||
- 空白封面/标题页会被 `shouldSkipChapter` 跳过
|
|
||||||
- 排查 2:分页页数不正确
|
|
||||||
- 检查 `pageSize` 计算是否正确(viewport - contentInsets)
|
|
||||||
- 检查 DTCoreText 行高倍数是否正确应用
|
|
||||||
- 检查 CoreText 分页是否有 `visibleRange.length == 0` 的异常情况
|
|
||||||
- 排查 3:字体/颜色不正确
|
|
||||||
- 检查 `RDEPUBTextRenderStyle` 配置
|
|
||||||
- 检查 `normalizeReadingAttributes` 是否正确保留粗/斜体 trait
|
|
||||||
- 检查 `foregroundColor` 覆盖逻辑
|
|
||||||
- 排查 4:Fragment 定位失败
|
|
||||||
- 检查 `injectFragmentMarkers` 是否正确注入标记
|
|
||||||
- 检查 `extractFragmentOffsets` 是否正确提取偏移
|
|
||||||
- 确认标记在 DTCoreText 渲染后仍存在于 attributed string 中
|
|
||||||
- 排查 5:搜索结果不准确
|
|
||||||
- 搜索基于已渲染的纯文本(去 HTML 标签),非原始 HTML
|
|
||||||
- 确认 `attributedContent.string` 包含预期的文本内容
|
|
||||||
- 搜索为大小写不敏感的线性扫描
|
|
||||||
@ -1,324 +0,0 @@
|
|||||||
# EPUBUI 功能实现逻辑
|
|
||||||
|
|
||||||
## 1. 范围与目标
|
|
||||||
|
|
||||||
- 代码范围:`Sources/RDReaderView/EPUBUI/`(19 个 Swift 文件)
|
|
||||||
- 目标:说明 EPUBUI 如何作为开箱即用的阅读器 UI 层,协调 EPUBCore 解析、EPUBTextRendering 文本渲染、RDReaderView 分页容器,提供完整的阅读体验(工具栏、目录、高亮批注、设置面板、阅读位置持久化、搜索)。
|
|
||||||
- 主链路关键词:`RDEPUBReaderController.init -> 解析 EPUB -> 分页 -> 渲染 -> 用户交互(翻页/工具栏/设置/高亮/搜索)-> 持久化`。
|
|
||||||
|
|
||||||
## 2. 关键对象职责
|
|
||||||
|
|
||||||
### 2.1 主控制器 `RDEPUBReaderController`
|
|
||||||
|
|
||||||
- 文件:`EPUBUI/RDEPUBReaderController.swift`(~1995 行)
|
|
||||||
- 入口方法:
|
|
||||||
- `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`(默认 top:40 left:16 bottom:40 right: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 个可选方法(全部有默认空实现):
|
|
||||||
- `epubReader(_:didOpen:)`:成功打开出版物
|
|
||||||
- `epubReader(_:didUpdateLocation:)`:翻页或滚动时位置更新
|
|
||||||
- `epubReaderDidReachEnd(_:)`:到达最后一页
|
|
||||||
- `epubReader(_:didChangeSelection:)`:文本选择变化或清除
|
|
||||||
- `epubReader(_:didUpdateHighlights:)`:高亮增删改
|
|
||||||
- `epubReader(_:didUpdateBookmarks:)`:书签增删改
|
|
||||||
- `epubReader(_:didUpdateSearchResult:)`:搜索状态变化
|
|
||||||
- `epubReader(_:didChangeCurrentSearchMatch:)`:当前搜索匹配项变化
|
|
||||||
- `epubReader(_:didUpdateCurrentTableOfContentsItem:)`:翻页时匹配的目录项
|
|
||||||
- `epubReader(_:didActivateExternalLink:)`:外部链接点击
|
|
||||||
- `epubReader(_:didFailWithError:)`:解析或分页错误
|
|
||||||
- `epubReader(_:configureTopToolView:)`:自定义顶部工具栏
|
|
||||||
|
|
||||||
### 2.4 持久化 `RDEPUBReaderPersistence`
|
|
||||||
|
|
||||||
- 文件:`EPUBUI/RDEPUBReaderPersistence.swift`
|
|
||||||
- 协议定义 8 个方法:loadLocation / saveLocation / loadHighlights / saveHighlights / loadBookmarks / saveBookmarks / loadReaderSettings / saveReaderSettings
|
|
||||||
- 默认实现 `RDEPUBUserDefaultsPersistence`:
|
|
||||||
- 位置:`ssreader.epub.location.<bookIdentifier>`(JSON 编码)
|
|
||||||
- 高亮:`ssreader.epub.highlights.<bookIdentifier>`(JSON 编码)
|
|
||||||
- 书签:`ssreader.epub.bookmarks.<bookIdentifier>`(JSON 编码)
|
|
||||||
- 设置:`ssreader.epub.settings`(全局,非按书)
|
|
||||||
|
|
||||||
### 2.5 主题 `RDEPUBReaderTheme`
|
|
||||||
|
|
||||||
- 文件:`EPUBUI/RDEPUBReaderTheme.swift`(~125 行)
|
|
||||||
- 6 个颜色属性:contentBackgroundColor、contentTextColor、toolBackgroundColor、toolControlTextColor、toolControlBorderUnselectColor、toolLineColor
|
|
||||||
- 6 个内置预设:`.light`、`.dark`、`.yellow`、`.green`、`.pink`、`.blue`
|
|
||||||
- `RDEPUBReaderThemePreset` 枚举将预设映射为可序列化值,用于持久化
|
|
||||||
- 计算属性 `themeBackgroundColorCSS` / `themeTextColorCSS` 用于 Web 渲染路径
|
|
||||||
|
|
||||||
### 2.6 设置面板 `RDEPUBReaderSettingsViewController`
|
|
||||||
|
|
||||||
- 文件:`EPUBUI/RDEPUBReaderSettingsViewController.swift`(~311 行)
|
|
||||||
- 5 个控制项:
|
|
||||||
- 亮度滑块(0-1)
|
|
||||||
- 字号 A-/A+(范围 12-36,步长 1)
|
|
||||||
- 行高分段(紧凑 1.3 / 标准 1.6 / 宽松 1.9)
|
|
||||||
- 显示模式分段(仿真 / 横滑 / 竖滑 / 覆盖)
|
|
||||||
- 主题选择(6 个圆形色块按钮)
|
|
||||||
- 所有变更通过闭包实时回调:onBrightnessChange / onFontSizeChange / onLineHeightChange / onDisplayTypeChange / onThemeChange
|
|
||||||
|
|
||||||
### 2.7 内容视图
|
|
||||||
|
|
||||||
- `RDEPUBTextContentView`(`EPUBUI/RDEPUBTextContentView.swift`):textReflowable 路径,UITextView 展示富文本,支持系统选区、标注菜单、用户标注叠加和搜索高亮叠加
|
|
||||||
- `RDEPUBWebContentView`(`EPUBUI/RDEPUBWebContentView.swift`):web 路径,包装 `RDEPUBWebView`,转发位置、选区、标注菜单、链接和 JS 错误事件
|
|
||||||
|
|
||||||
### 2.8 其他 UI 组件
|
|
||||||
|
|
||||||
- `RDEPUBReaderToolView`(`EPUBUI/RDEPUBReaderToolView.swift`):工具栏基类,提供分隔线和主题适配的通用逻辑
|
|
||||||
- `RDEPUBReaderTopToolView`:顶部导航栏(返回按钮 + 书签切换 + 标题)
|
|
||||||
- `RDEPUBReaderBottomToolView`:底部工具栏(目录 / 书签 / 标注 / 设置 4 个按钮)
|
|
||||||
- `RDEPUBReaderChapterListController`:目录列表(modal UITableViewController,当前项高亮 systemBlue)
|
|
||||||
- `RDEPUBReaderHighlightsViewController`:标注管理(全部 / 批注 / 划线过滤,列表 + 编辑备注 + 删除 + 跳转,空态按过滤条件提示)
|
|
||||||
- `RDEPUBReaderSettings`(`EPUBUI/RDEPUBReaderSettings.swift`):可持久化用户设置模型(brightness / fontSize / lineHeightMultiple / displayMode / themePreset),含 `RDEPUBReaderDisplayMode` 和 `RDEPUBReaderThemePreset` 枚举
|
|
||||||
- `RDEPUBReaderTableOfContentsItem`(`EPUBUI/RDEPUBReaderTableOfContentsItem.swift`):展平后的目录条目(title / href / depth / pageNumber)
|
|
||||||
- `RDEPUBPageInteractionController`(`EPUBUI/RDEPUBPageInteractionController.swift`):CoreText 页面级交互控制器,处理文本选区和高亮装饰
|
|
||||||
- `RDEPUBSelectionOverlayView`(`EPUBUI/RDEPUBSelectionOverlayView.swift`):选区覆盖视图,显示选中文本的高亮装饰
|
|
||||||
- `RDEPUBPageLayoutSnapshot`(`EPUBUI/RDEPUBPageLayoutSnapshot.swift`):页面几何模型,存储 CoreText 渲染的页面布局信息
|
|
||||||
- `RDURLReaderController`(`EPUBUI/RDURLReaderController.swift`,~221 行):最简 URL 入口,传入 URL 即可打开书籍(.epub → RDEPUBReaderController,其他 → RDPlainTextBookBuilder 构建后传入),分页失败时回退到纯 UITextView 展示
|
|
||||||
|
|
||||||
## 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 设置模型
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDEPUBReaderSettings (Codable)
|
|
||||||
├── brightness: CGFloat?
|
|
||||||
├── fontSize: CGFloat?
|
|
||||||
├── lineHeightMultiple: CGFloat?
|
|
||||||
├── displayMode: RDEPUBReaderDisplayMode?
|
|
||||||
└── themePreset: RDEPUBReaderThemePreset?
|
|
||||||
```
|
|
||||||
|
|
||||||
`RDEPUBReaderDisplayMode`:可序列化的翻页模式枚举(pageCurl / horizontalScroll / verticalScroll),与 `RDReaderView.DisplayType` 相互转换。注意:历史版本遗留的 `horizontalCoverScroll` 会自动映射为 `horizontalScroll`。
|
|
||||||
|
|
||||||
`RDEPUBReaderThemePreset`:可序列化的主题预设枚举(light / yellow / green / pink / blue / dark),与 `RDEPUBReaderTheme` 相互转换。
|
|
||||||
|
|
||||||
### 5.3 Book Identifier
|
|
||||||
|
|
||||||
- 优先使用 `parser.metadata.identifier`
|
|
||||||
- 回退使用 `epubURL.lastPathComponent`
|
|
||||||
|
|
||||||
### 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 处理
|
|
||||||
@ -1,377 +0,0 @@
|
|||||||
# EPUB 阅读模块维护与排查指南
|
|
||||||
|
|
||||||
## 1. 文档目的
|
|
||||||
|
|
||||||
本文档说明 EPUB 阅读模块的关键文件职责、主要数据流、常见问题排查和后续扩展建议,供维护时快速建立上下文。
|
|
||||||
|
|
||||||
完整架构设计请参考 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
|
||||||
编码规范请参考 [CODING_STYLE.md](CODING_STYLE.md)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 关键文件速查
|
|
||||||
|
|
||||||
### 2.1 EPUBCore 层
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `RDEPUBParser.swift` + 5 扩展 | EPUB 解压、container.xml、OPF、manifest、spine、TOC/NCX 解析、阅读配置文件判断 |
|
|
||||||
| `RDEPUBPublication.swift` | 解析结果的唯一聚合入口,外部不直接访问 parser 字段 |
|
|
||||||
| `RDEPUBModels.swift` | metadata / manifest / spine / TOC 基础模型 |
|
|
||||||
| `RDEPUBReadingModels.swift` | RDEPUBLocation、RDEPUBViewport、RDEPUBHighlight、RDEPUBBookmark、选区模型 |
|
|
||||||
| `RDEPUBResourceResolver.swift` | href → fileURL / schemeURL 的统一入口,`normalizedHref` 标准化路径 |
|
|
||||||
| `RDEPUBPaginator.swift` | 离屏 WKWebView 分页,计算每个 spine 资源被视口切成多少页 |
|
|
||||||
| `RDEPUBReadingSession.swift` | 状态机 + 会话协调,管理 staged/active/pending 页模型 |
|
|
||||||
| `RDEPUBNavigatorState.swift` | 状态枚举(initializing/loading/idle/jumping/moving/repaginating) |
|
|
||||||
| `RDEPUBWebView.swift` + 5 扩展 | 承载 spine 资源的 WKWebView,分页 CSS 注入、JS bridge、fixed wrapper、搜索装饰 |
|
|
||||||
| `RDEPUBResourceURLSchemeHandler.swift` | `ss-reader://` 协议,从本地解压目录提供所有 EPUB 资源 |
|
|
||||||
| `RDEPUBStyleSheetBuilder.swift` | 生成注入 WebView 的 CSS |
|
|
||||||
| `RDEPUBJavaScriptBridge.swift` | JS ↔ Swift 消息定义和编解码 |
|
|
||||||
| `RDEPUBSearchEngine.swift` | 搜索协议定义 + WebView 全文搜索引擎 |
|
|
||||||
| `RDEPUBSearchModels.swift` | 搜索模型(SearchMatch/Result/State/Presentation) |
|
|
||||||
| `RDEPUBTextAnchor.swift` | 文本锚点和范围锚点(精确定位字符位置) |
|
|
||||||
| `RDEPUBRenderRequest.swift` | 渲染请求模型、展示样式、固定版式适配/Spread 枚举 |
|
|
||||||
| `RDEPUBWebViewDebug.swift` | WebView 调试日志工具(导航/JS/消息/Scheme) |
|
|
||||||
| `RDEPUBAssetRepository.swift` | 静态资源加载器(JS 脚本、HTML 模板、模板变量替换) |
|
|
||||||
| `RDEPUBPreferences.swift` | 用户阅读偏好聚合 |
|
|
||||||
| `RDEPUBNavigatorLayoutContext.swift` | 容器布局上下文 |
|
|
||||||
| `RDEPUBFixedLayoutTemplate.swift` | 固定版式 HTML 模板生成 |
|
|
||||||
|
|
||||||
### 2.2 EPUBTextRendering 层
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `RDEPUBTextRenderer.swift` | 渲染引擎协议 + 错误类型 + 样式 / 内容模型 |
|
|
||||||
| `RDEPUBDTCoreTextRenderer.swift` | DTCoreText 实现,HTML → NSAttributedString |
|
|
||||||
| `RDEPUBTextRendererSupport.swift` | fragment 标记注入、fragment offset 提取、属性标准化 |
|
|
||||||
| `RDEPUBTextPaginationSupport.swift` | NSAttributedString 切页算法 |
|
|
||||||
| `RDEPUBTextBookBuilder.swift` | 驱动逐章节渲染和分页,生成 RDEPUBTextBook |
|
|
||||||
| `RDEPUBTextLayouter.swift` | CoreText 分页引擎(~810 行,含 4 级语义边界调整) |
|
|
||||||
| `RDEPUBTextLayoutFrame.swift` | 单帧分页结果模型(contentRange、breakReason、语义提示) |
|
|
||||||
| `RDEPUBTextBookCache.swift` | 分页结果磁盘缓存 |
|
|
||||||
| `RDEPUBChapterData.swift` | 章节数据聚合模型(高亮、搜索结果、页面查询) |
|
|
||||||
| `RDEPUBTextSearchEngine.swift` | 纯文本全文搜索引擎 |
|
|
||||||
| `RDPlainTextBookBuilder.swift` | 纯文本(.txt)书籍构建器 |
|
|
||||||
| `RDEPUBTextIndexTable.swift` | 全书文本索引表(章节偏移、行列映射、锚点转换) |
|
|
||||||
| `RDEPUBTextPerformanceSampler.swift` | 性能采样器(渲染/分页耗时、缓存命中率) |
|
|
||||||
|
|
||||||
### 2.3 EPUBUI 层
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `RDEPUBReaderController.swift` | 开箱即用读者控制器(~1995 行),整合 session / readerView / UI |
|
|
||||||
| `RDEPUBReaderConfiguration.swift` | 外部配置项(13 个:字号、主题、翻页模式等) |
|
|
||||||
| `RDEPUBReaderPersistence.swift` | 阅读位置、高亮、书签的本地持久化(8 个方法) |
|
|
||||||
| `RDEPUBReaderSettings.swift` | 可持久化用户设置模型 + DisplayMode/ThemePreset 枚举 |
|
|
||||||
| `RDEPUBReaderTheme.swift` | 6 个内置主题预设 + 颜色属性 |
|
|
||||||
| `RDEPUBReaderDelegate.swift` | 12 个可选委托方法 |
|
|
||||||
| `RDEPUBReaderToolView.swift` | 工具栏基类(分隔线 + 主题适配) |
|
|
||||||
| `RDEPUBReaderTopToolView.swift` | 顶部导航栏(返回 + 书签 + 标题) |
|
|
||||||
| `RDEPUBReaderBottomToolView.swift` | 底部工具栏(目录/书签/标注/设置) |
|
|
||||||
| `RDEPUBReaderChapterListController.swift` | 目录列表面板 |
|
|
||||||
| `RDEPUBReaderHighlightsViewController.swift` | 标注管理面板(过滤、编辑、删除、跳转) |
|
|
||||||
| `RDEPUBReaderSettingsViewController.swift` | 设置面板(亮度、字号、行高、模式、主题) |
|
|
||||||
| `RDEPUBReaderTableOfContentsItem.swift` | 展平目录条目模型 |
|
|
||||||
| `RDEPUBWebContentView.swift` | WKWebView 内容视图,供 RDReaderView 渲染 WebView 类 spine |
|
|
||||||
| `RDEPUBTextContentView.swift` | 文本内容视图(~728 行),供 RDReaderView 渲染文本类 spine |
|
|
||||||
| `RDEPUBPageInteractionController.swift` | CoreText 页面级交互控制器 |
|
|
||||||
| `RDEPUBSelectionOverlayView.swift` | 选区覆盖视图 |
|
|
||||||
| `RDEPUBPageLayoutSnapshot.swift` | 页面几何模型 |
|
|
||||||
| `RDURLReaderController.swift` | 最简 URL 入口(.epub/.txt 自动识别) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 资源服务协议说明
|
|
||||||
|
|
||||||
加载 spine 资源统一使用 `ss-reader://` 协议,不直接使用 `file://`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// 正确方式
|
|
||||||
let schemeURL = publication.resourceResolver.schemeURL(forHref: spineItem.href)
|
|
||||||
webView.load(URLRequest(url: schemeURL))
|
|
||||||
|
|
||||||
// 禁止方式(relative 资源、图片、CSS 会失效)
|
|
||||||
webView.loadHTMLString(htmlString, baseURL: nil)
|
|
||||||
```
|
|
||||||
|
|
||||||
这样 HTML 内部的相对 CSS、图片、字体和 iframe 资源都会通过同一个 schemeHandler 解析,避免 `loadHTMLString` 或 `allowingReadAccessTo` 导致的资源失效问题。
|
|
||||||
|
|
||||||
### 3.1 DTCoreText 实现逻辑(textReflowable 路径)
|
|
||||||
|
|
||||||
当 `readingProfile == .textReflowable` 时,渲染链路不再走 WebView 列分页,而是走 `EPUBTextRendering/` 的 DTCoreText 文本渲染链。
|
|
||||||
|
|
||||||
**端到端流程:**
|
|
||||||
|
|
||||||
1. `RDEPUBTextBookBuilder` 按 spine 顺序读取章节 HTML。
|
|
||||||
2. `RDEPUBTextRendererSupport.injectFragmentMarkers(into:)` 注入 fragment 标记,保证目录锚点可映射到文本偏移。
|
|
||||||
3. `RDEPUBDTCoreTextRenderer.renderChapter(html:baseURL:style:)` 执行章节渲染。
|
|
||||||
4. 渲染成功时,内部通过 `DTHTMLAttributedStringBuilder` 将 HTML 转为 `NSAttributedString`。
|
|
||||||
5. `RDEPUBTextRendererSupport.extractFragmentOffsets(from:)` 提取 `fragment -> 字符偏移` 映射。
|
|
||||||
6. `RDEPUBTextRendererSupport.normalizeReadingAttributes(in:style:)` 统一字体、行距、颜色等阅读属性。
|
|
||||||
7. `RDEPUBTextPaginationSupport` 将富文本按可视区域切页,构建页模型。
|
|
||||||
8. `RDEPUBTextBookBuilder` 汇总章节与页映射,交给 `RDEPUBTextContentView` 呈现。
|
|
||||||
|
|
||||||
**关键实现点:**
|
|
||||||
|
|
||||||
1. 条件编译:`#if canImport(DTCoreText)`
|
|
||||||
- 可用时走 DTCoreText 路径。
|
|
||||||
- 不可用时走 fallback(纯 `NSAttributedString` 降级),确保工程可编译可运行。
|
|
||||||
2. `dtOptions(baseURL:style:)` 会注入:
|
|
||||||
- 默认字体族 / 字体名 / 字号
|
|
||||||
- 行高倍数(由字体行高与行距折算)
|
|
||||||
- `NSBaseURLDocumentOption`(保证相对资源解析)
|
|
||||||
- 默认文本颜色
|
|
||||||
3. fragment 偏移在“属性标准化前后”都要保持稳定,避免目录跳转漂移。
|
|
||||||
4. 文本分页以字符范围为准,不以 WebView 像素滚动位置为准。
|
|
||||||
|
|
||||||
**失败与降级策略:**
|
|
||||||
|
|
||||||
1. HTML 转 UTF-8 失败:抛出 `htmlEncodingFailed`。
|
|
||||||
2. DTCoreText builder 失败:进入 fallback 渲染路径。
|
|
||||||
3. fallback 仍会执行 fragment 提取与阅读属性标准化,保证目录跳转和阅读样式能力不丢失。
|
|
||||||
|
|
||||||
**与定位恢复的关系:**
|
|
||||||
|
|
||||||
1. 仍使用 `RDEPUBLocation`(`href + progression`)作为跨会话恢复模型。
|
|
||||||
2. textReflowable 模式下,`fragmentOffsets` 用于将目录锚点映射到分页后的字符区间,再换算到页索引。
|
|
||||||
3. 字号/行距变化触发重分页后,优先按 `href + progression` 回落恢复,再用 fragment 做细化定位。
|
|
||||||
|
|
||||||
**推荐日志点(DTCoreText 专用):**
|
|
||||||
|
|
||||||
1. `RDEPUBDTCoreTextRenderer.renderChapter(...)`:记录章节 href、输入 HTML 长度、渲染耗时。
|
|
||||||
2. `makeAttributedString(from:baseURL:style:)`:记录 DTCoreText 是否成功返回 attributed string。
|
|
||||||
3. `extractFragmentOffsets(from:)`:记录 fragment 数量与关键锚点是否命中。
|
|
||||||
4. `RDEPUBTextPaginationSupport`:记录总页数、每页字符范围边界。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 常见问题排查
|
|
||||||
|
|
||||||
### 4.1 EPUB 打不开或解析失败
|
|
||||||
|
|
||||||
**优先检查:**
|
|
||||||
|
|
||||||
1. `RDEPUBParser.parse(epubURL:)` 是否抛出异常,打印具体错误
|
|
||||||
2. `RDEPUBParser+Archive.swift` — 解压是否成功
|
|
||||||
3. `META-INF/container.xml` 是否存在
|
|
||||||
4. OPF 中 manifest / spine 是否完整
|
|
||||||
|
|
||||||
**常见原因:**
|
|
||||||
|
|
||||||
- 文件不是标准 EPUB ZIP 结构
|
|
||||||
- `container.xml` 找不到 OPF 路径
|
|
||||||
- OPF manifest 或 spine 格式异常
|
|
||||||
- 文件路径包含特殊字符导致解压目录路径错误
|
|
||||||
|
|
||||||
**排查顺序:**
|
|
||||||
|
|
||||||
1. 确认 EPUB 已成功复制到沙盒
|
|
||||||
2. 确认解压目录存在(`extractionRootURL`)
|
|
||||||
3. 确认 `opfDirectoryURL` 不为 nil
|
|
||||||
4. 确认 `spine.count > 0`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.2 EPUB 显示空白页
|
|
||||||
|
|
||||||
**优先检查:**
|
|
||||||
|
|
||||||
1. `RDEPUBWebContentView.loadPage(...)` 传入的 spineIndex 是否在范围内
|
|
||||||
2. `RDEPUBWebView+Reflowable` — 分页 CSS 注入是否成功
|
|
||||||
3. `WKNavigationDelegate.didFinish` 是否正常触发
|
|
||||||
4. `RDEPUBResourceURLSchemeHandler` 是否正确返回资源数据
|
|
||||||
|
|
||||||
**常见原因:**
|
|
||||||
|
|
||||||
1. scheme URL 拼装错误,资源请求 404
|
|
||||||
2. 注入的 CSS 破坏了原始布局(尤其是 `column-width` 样式冲突)
|
|
||||||
3. fixed-layout 书籍走了 reflowable 渲染路径(readingProfile 判定错误)
|
|
||||||
|
|
||||||
**排查顺序:**
|
|
||||||
|
|
||||||
1. 确认 `schemeURL(forHref:)` 返回的 URL 对应文件真实存在
|
|
||||||
2. 用 `RDEPUBWebViewDebug` 打开 WebView inspector 查看网络请求
|
|
||||||
3. 确认 readingProfile 判定正确(webInteractive / webFixedLayout / textReflowable)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.3 修改字号后位置恢复不准
|
|
||||||
|
|
||||||
**原因:** 字号变化触发重新分页(repaginating),页号失效。
|
|
||||||
|
|
||||||
**正确做法:**
|
|
||||||
|
|
||||||
当前实现依赖 `href + progression` 恢复位置,不依赖纯页号。排查重点:
|
|
||||||
|
|
||||||
1. `RDEPUBLocation.href` 是否已通过 `normalizedHref` 标准化
|
|
||||||
2. `progression` 是否在字号变化后重新从 WebView 读取
|
|
||||||
3. `RDEPUBReadingSession.navigatorState` 是否在重分页后回到 `.idle` 并消费了 staged snapshot
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.4 目录点击后跳错位置
|
|
||||||
|
|
||||||
**优先检查:**
|
|
||||||
|
|
||||||
1. `RDEPUBParser+TOC.swift` — TOC href 是否正确标准化为相对 OPF 路径
|
|
||||||
2. `RDEPUBResourceResolver.normalizedHref(_:)` — 跳转时是否使用了标准化 href
|
|
||||||
3. `fragment` 是否正确传入 WebView 锚点滚动
|
|
||||||
|
|
||||||
**常见原因:**
|
|
||||||
|
|
||||||
1. TOC 链接是相对 Nav 或 NCX 文件目录,未转为相对 OPF
|
|
||||||
2. `href#fragment` 只命中了资源,未命中锚点
|
|
||||||
3. 同一资源中多个 TOC 条目被折叠成同一页
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 打印原始 TOC href 和标准化后 href 进行比较
|
|
||||||
2. 确认 `fragment` 字段被保留在 RDEPUBLocation 中
|
|
||||||
3. 确认 WebView 收到 `fragment` 后执行了 `scrollToFragment(fragment:)` JS 调用
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.5 正文内部链接不工作
|
|
||||||
|
|
||||||
**优先检查:**
|
|
||||||
|
|
||||||
1. `RDEPUBWebView+JavaScriptBridge` 中内部链接拦截逻辑
|
|
||||||
2. `RDEPUBReaderController.navigateToLocation(_:)` — 跨资源跳转路径
|
|
||||||
|
|
||||||
**常见原因:**
|
|
||||||
|
|
||||||
1. 链接是空 `#fragment`,但当前 DOM 中找不到对应元素
|
|
||||||
2. 跨资源链接 href 未被 normalizedHref 处理
|
|
||||||
3. 对应 spine 资源没有分页结果(pageCounts[spineIndex] == 0)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.6 图片多时位置跳动
|
|
||||||
|
|
||||||
**原因:** 图片异步加载导致 DOM 高度变化,进度报告时机早于实际布局稳定。
|
|
||||||
|
|
||||||
**排查:**
|
|
||||||
|
|
||||||
1. 检查 JS `ResizeObserver` 是否在图片加载后触发了进度重同步
|
|
||||||
2. 确认 `didUpdateLocation` 不在 pending navigation 未消费前覆盖目标位置
|
|
||||||
3. 确认 `scrollToLocation()` JS 调用时序在 `didFinish` 之后
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.7 高亮未显示或恢复失败
|
|
||||||
|
|
||||||
**优先检查:**
|
|
||||||
|
|
||||||
1. `RDEPUBHighlight` 是否成功写入 persistence
|
|
||||||
2. `RDEPUBReaderController.activeHighlights` 是否正确过滤出当前 spine 的高亮
|
|
||||||
3. `RDEPUBWebView+JavaScriptBridge` 中 `setHighlights()` JS 调用是否触发
|
|
||||||
4. JS 中 `rangeFromInfo()` 是否能找到对应 DOM 节点(嵌套节点恢复容易失败)
|
|
||||||
|
|
||||||
**排查顺序:**
|
|
||||||
|
|
||||||
1. 确认有效选区(`currentSelection` 不为 nil)
|
|
||||||
2. 确认 `addHighlight()` 调用后 persistence 中数据已写入
|
|
||||||
3. 确认当前页传入的 highlights 数组不为空
|
|
||||||
4. 确认 JS 收到了 highlights 数据并尝试了 rangeFromInfo 恢复
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.8 fixed-layout 书籍显示异常
|
|
||||||
|
|
||||||
**优先检查:**
|
|
||||||
|
|
||||||
1. `RDEPUBPublication.readingProfile` 是否判定为 `.webFixedLayout`
|
|
||||||
2. `RDEPUBFixedLayoutTemplate` — wrapper HTML 是否正确生成
|
|
||||||
3. viewport meta 尺寸是否与书籍 OPF 中声明一致
|
|
||||||
4. spread 模式是否正确(单页 vs 双页)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 推荐日志点
|
|
||||||
|
|
||||||
遇到复杂问题时,建议先在以下位置加日志:
|
|
||||||
|
|
||||||
1. `RDEPUBParser.parse(epubURL:)` — 打印解压路径、OPF 路径、spine 数量
|
|
||||||
2. `RDEPUBReadingSession.transition(to:)` — 打印状态跃迁
|
|
||||||
3. `RDEPUBPaginator` — 打印每个 spine 资源的分页结果
|
|
||||||
4. `RDEPUBReaderController.restoreLocation(_:)` — 打印恢复位置的 href / progression
|
|
||||||
5. `RDEPUBWebView+JavaScriptBridge` — 打印所有 JS 消息收发
|
|
||||||
6. `RDEPUBResourceURLSchemeHandler` — 打印资源请求 URL 和响应状态
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 标准排查顺序
|
|
||||||
|
|
||||||
当 EPUB 功能出问题时,建议按以下顺序排查:
|
|
||||||
|
|
||||||
1. **解析是否成功** — parser.spine.count > 0?metadata 正确?
|
|
||||||
2. **readingProfile 是否正确** — 用了对的渲染路径?
|
|
||||||
3. **TOC href 是否标准化** — 与 spine href 格式一致?
|
|
||||||
4. **分页结果是否合理** — 每个 spine 资源的 pageCount > 0?
|
|
||||||
5. **资源是否能正常加载** — ss-reader:// 请求是否 200?
|
|
||||||
6. **JS bridge 是否正常** — progression 上报是否到达 Swift 侧?
|
|
||||||
7. **状态机是否流转正确** — session.navigatorState 是否符合预期?
|
|
||||||
8. **控制器是否正确映射页号** — EPUBPage 列表与 RDReaderView 页号对应是否正确?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 后续扩展建议
|
|
||||||
|
|
||||||
1. **拆分 RDEPUBReaderController**:约 1995 行,EPUB 加载、UI、数据源职责仍混在一起,建议继续向 library 层迁移
|
|
||||||
2. **基线验证**:用四本样书跑一轮分页耗时、目录命中率、末页事件、设置恢复稳定性数据
|
|
||||||
3. **横竖屏支持回归矩阵**:控制器级 `viewWillTransition()` 与 viewport 去重已接入,下一步重点是补齐 pageCurl / scroll、三条正文路径和 iPad 视口变化场景的固定回归
|
|
||||||
4. **Pod 分层**:评估是否将 EPUBCore / EPUBTextRendering / EPUBUI 拆为独立 subspec
|
|
||||||
5. **RDEPUBParser 继续收敛**:OPF metadata 细节处理仍偏重,可考虑按 OPFMetadataParser 单独拆出
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 阅读器能力现状与下一阶段规划
|
|
||||||
|
|
||||||
### 8.1 当前已具备的核心能力
|
|
||||||
|
|
||||||
当前 SDK 已具备一套可用的 EPUB 阅读器主链路,包含:
|
|
||||||
|
|
||||||
1. **EPUB 打开与解析**:支持 ZIP 解压、OPF / spine / TOC 解析,以及三类 readingProfile 分流
|
|
||||||
2. **正文渲染与分页**:支持 WebView 路径和 DTCoreText 文本路径,覆盖固定版式、交互式可重排和纯文本可重排 EPUB
|
|
||||||
3. **完整阅读交互**:支持目录跳转、翻页、阅读位置恢复、主题切换、字号和行高调整、亮度调整
|
|
||||||
4. **标注能力**:支持高亮、划线、批注、标注列表、编辑、删除、跳转与持久化恢复
|
|
||||||
5. **书签能力**:支持当前位置书签添加/取消、书签列表、删除、跳转与持久化恢复
|
|
||||||
6. **自动化测试基础能力**:已接入 `ReadViewSDKDemoTests` / `ReadViewSDKDemoUITests`、样书夹具定位工具,以及 parser / resolver / persistence 首批 XCTest 和阅读器启动 smoke 用例
|
|
||||||
7. **搜索能力**:支持全文搜索、匹配项跳转、当前匹配高亮
|
|
||||||
|
|
||||||
### 8.2 高优先级待补能力
|
|
||||||
|
|
||||||
从“可用 MVP”走向“稳定可交付阅读器”,当前最值得优先完成的是:
|
|
||||||
|
|
||||||
1. **样书基线验证**
|
|
||||||
需要基于四本样书补齐分页耗时、目录命中率、末页事件、设置恢复稳定性等基线数据
|
|
||||||
|
|
||||||
### 8.3 面向完整商业阅读器的下一阶段能力
|
|
||||||
|
|
||||||
若目标是继续向完整阅读产品演进,建议后续补齐以下能力:
|
|
||||||
|
|
||||||
1. **阅读进度展示**
|
|
||||||
包括全书进度、章节进度、页码信息、剩余页数或剩余章节等
|
|
||||||
2. **更完整的标注管理**
|
|
||||||
包括按章节分组、排序、筛选、批量删除、标注导出、复制批注内容等
|
|
||||||
3. **更成熟的搜索体验**
|
|
||||||
包括搜索结果列表页、章节聚合、上下文预览、搜索历史等
|
|
||||||
4. **更多阅读增强能力**
|
|
||||||
包括字体切换、自定义主题、翻页手势自定义、TTS 朗读、无障碍支持等
|
|
||||||
|
|
||||||
### 8.4 工程侧持续建设建议
|
|
||||||
|
|
||||||
除了阅读器功能本身,还建议并行推进以下工程项:
|
|
||||||
|
|
||||||
1. **控制器继续拆分**
|
|
||||||
`RDEPUBReaderController` 与 demo 层控制器都偏大,继续叠加功能会增加维护成本
|
|
||||||
2. **Parser 与模块职责继续收敛**
|
|
||||||
`RDEPUBParser` 仍承担较多 OPF metadata 解析细节,后续可继续按职责拆分
|
|
||||||
3. **回归验收矩阵固化**
|
|
||||||
建议把样书、渲染路径、主题、字号、翻页模式、标注恢复等整理为固定回归清单
|
|
||||||
|
|
||||||
### 8.5 推荐下一阶段实施顺序
|
|
||||||
|
|
||||||
如果以“最小投入换最大稳定性提升”为目标,建议按下面顺序推进:
|
|
||||||
|
|
||||||
1. **样书基线验证**
|
|
||||||
@ -1,233 +0,0 @@
|
|||||||
# RDReaderView 功能实现逻辑
|
|
||||||
|
|
||||||
## 1. 范围与目标
|
|
||||||
|
|
||||||
- 代码范围:`Sources/RDReaderView/ReaderView/`(5 个 Swift 文件)
|
|
||||||
- 目标:说明分页阅读器容器如何管理三种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。
|
|
||||||
- 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐`。
|
|
||||||
|
|
||||||
## 2. 关键对象职责
|
|
||||||
|
|
||||||
### 2.1 核心容器 `RDReaderView`
|
|
||||||
|
|
||||||
- 文件:`Sources/RDReaderView/ReaderView/RDReaderView.swift`(~1219 行)
|
|
||||||
- 入口方法:`reloadData()`
|
|
||||||
- 职责:
|
|
||||||
- 管理三种显示模式的视图层级切换
|
|
||||||
- 持有 `UIPageViewController`(pageCurl 模式)或 `UICollectionView`(滚动模式)
|
|
||||||
- 处理点击手势(左/中/右三区域)
|
|
||||||
- 管理工具栏(topToolView / bottomToolView)的显示/隐藏动画
|
|
||||||
- 检测横竖屏变化并触发重新布局
|
|
||||||
- 管理双屏配对逻辑(含封面页处理)
|
|
||||||
- RTL 语言方向支持
|
|
||||||
|
|
||||||
### 2.2 自定义布局 `RDReaderFlowLayout`
|
|
||||||
|
|
||||||
- 文件:`Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift`(~375 行)
|
|
||||||
- 职责:
|
|
||||||
- 继承 `UICollectionViewFlowLayout`,为两种滚动模式提供布局计算
|
|
||||||
- 水平滚动:全屏宽 item,pagingEnabled
|
|
||||||
- 垂直滚动:可变高度 item,累加计算
|
|
||||||
- 封面感知帧计算:封面页全屏宽,后续页面两两配对半屏宽
|
|
||||||
|
|
||||||
### 2.3 内容 Cell `RDReaderContentCell`
|
|
||||||
|
|
||||||
- 文件:`Sources/RDReaderView/ReaderView/RDReaderContentCell.swift`(~55 行)
|
|
||||||
- 职责:
|
|
||||||
- `UICollectionViewCell` 子类,作为内容视图的薄壳宿主
|
|
||||||
- `containerView` 属性 setter 自动移除旧视图、添加新视图
|
|
||||||
- `layoutSubviews` 将 containerView 填满 contentView.bounds
|
|
||||||
|
|
||||||
### 2.4 页面子控制器 `RDReaderPageChildViewController`
|
|
||||||
|
|
||||||
- 文件:`Sources/RDReaderView/ReaderView/RDReaderPageChildViewController.swift`(~89 行)
|
|
||||||
- 职责:
|
|
||||||
- 仅用于 pageCurl 模式,作为 `UIPageViewController` 的子控制器
|
|
||||||
- 持有 `contentView: UIView?` 和 `pageNum: Int`
|
|
||||||
- `contentView` didSet 在 view 已加载时自动调用 `installContentView()`
|
|
||||||
|
|
||||||
### 2.5 手势控制器 `RDReaderGestureController`
|
|
||||||
|
|
||||||
- 文件:`Sources/RDReaderView/ReaderView/RDReaderGestureController.swift`(~58 行)
|
|
||||||
- 职责:
|
|
||||||
- 当前为占位组件,存储 topToolView / bottomToolView 引用
|
|
||||||
- 实际手势逻辑在 `RDReaderView.tapCenter()` 中实现
|
|
||||||
|
|
||||||
## 3. 主流程(代码级)
|
|
||||||
|
|
||||||
### 3.1 协议定义
|
|
||||||
|
|
||||||
**RDReaderDataSource**(数据供给):
|
|
||||||
```swift
|
|
||||||
func pageCountOfReaderView(readerView: RDReaderView) -> Int
|
|
||||||
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
|
||||||
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
|
|
||||||
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
|
|
||||||
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
|
|
||||||
```
|
|
||||||
|
|
||||||
**RDReaderDelegate**(事件回调):
|
|
||||||
```swift
|
|
||||||
func pageNum(readerView: RDReaderView, pageNum: Int)
|
|
||||||
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 初始化与数据加载
|
|
||||||
|
|
||||||
1. 调用方创建 `RDReaderView`,设置 `dataSource` 和 `delegate`。
|
|
||||||
2. 调用 `reloadData()`。
|
|
||||||
3. `reloadData` 内部调用 `switchReaderDisplayType(currentDisplayType)` 重建视图层级。
|
|
||||||
4. 同时从 `dataSource` 获取 `topToolView` 和 `bottomToolView` 并添加到视图层级。
|
|
||||||
|
|
||||||
### 3.3 三种显示模式切换
|
|
||||||
|
|
||||||
**pageCurl 模式**:
|
|
||||||
- 创建 `UIPageViewController`(transitionStyle: .pageCurl)
|
|
||||||
- 通过 `attachPageViewControllerIfNeeded()` 添加为父 VC 的 child VC
|
|
||||||
- 横屏双页时重建 `UIPageViewController`(spineLocation: .mid, isDoubleSided: true)
|
|
||||||
- RTL 时翻转导航方向
|
|
||||||
|
|
||||||
**horizontalScroll 模式**:
|
|
||||||
- 移除 pageViewController,插入 `UICollectionView`
|
|
||||||
- `RDReaderFlowLayout` 设置 scrollDirection = .horizontal, isPagingEnabled = true
|
|
||||||
- itemSize 宽度 = 容器宽度 / pagesPerScreen
|
|
||||||
- RTL 时对 collectionView 做 `scaleX: -1` 翻转,cell contentView 再翻转回来
|
|
||||||
|
|
||||||
**verticalScroll 模式**:
|
|
||||||
- 同一 collectionView,scrollDirection = .vertical, isPagingEnabled = false
|
|
||||||
- 页面高度可变,通过 `RDReaderFlowLayoutDataSoure.heigtOfVerticalScrollPage` 查询
|
|
||||||
- collectionViewContentSize 为所有页面高度之和
|
|
||||||
|
|
||||||
### 3.4 翻页交互
|
|
||||||
|
|
||||||
**点击手势**(`tapAction(tap:)`):
|
|
||||||
- 屏幕分为左 1/3、中 1/3、右 1/3 三个区域
|
|
||||||
- 左区域:工具栏隐藏时翻上一页(RTL 时翻下一页);工具栏显示时触发 `tapCenter()`
|
|
||||||
- 中区域:始终触发 `tapCenter()`
|
|
||||||
- 右区域:工具栏隐藏时翻下一页(RTL 时翻上一页);工具栏显示时触发 `tapCenter()`
|
|
||||||
|
|
||||||
**goNextPage() / goPreviousPage()**:
|
|
||||||
- 双页模式使用 `adjacentDualPage(from:forward:)` 计算目标页
|
|
||||||
- 单页模式直接 +1 / -1
|
|
||||||
- 调用 `transitionToPage(pageNum:animated:true)` 执行跳转
|
|
||||||
|
|
||||||
**工具栏切换**(`tapCenter()`):
|
|
||||||
- 显示时:topToolView 从上方滑入,bottomToolView 从下方滑入(CGAffineTransform translationY)
|
|
||||||
- 隐藏时:反向动画
|
|
||||||
- 工具栏显示期间禁用 collectionView 和 pageViewController 的用户交互
|
|
||||||
|
|
||||||
### 3.5 双页配对逻辑
|
|
||||||
|
|
||||||
**`dualPagePair(for pageNum:)`** 返回 `(left: Int, right: Int?)`:
|
|
||||||
- 有封面页:封面页 → (coverIndex, nil);封面后的页面两两配对(coverIndex+1 与 coverIndex+2,coverIndex+3 与 coverIndex+4...)
|
|
||||||
- 无封面页:标准偶奇配对(0+1, 2+3, 4+5...)
|
|
||||||
|
|
||||||
**空白哨兵页**:
|
|
||||||
- `blankPageNum = Int.max`,`blankEndPageNum = Int.max - 1`
|
|
||||||
- 仅用于 pageCurl 双页模式,填充封面页独占或总页数为奇数时的右侧空白
|
|
||||||
|
|
||||||
### 3.6 横竖屏变化处理
|
|
||||||
|
|
||||||
1. `layoutSubviews()` 检测 `previousIsLandscape` 与当前 `isLandscape` 的变化
|
|
||||||
2. 异步调用 `orientationChanged(isNowLandscape:)` 避免嵌套布局
|
|
||||||
3. 通知 delegate `readerViewOrientationWillChange`
|
|
||||||
4. 更新 `layout.isLandscapeDualPage` 和 `layout.coverPageIndex`
|
|
||||||
5. pageCurl 模式:重建 UIPageViewController(spineLocation 不可变)并跳转到保存的页面
|
|
||||||
6. 滚动模式:invalidate layout,reload data,强制布局,无动画滚动到保存的 offset
|
|
||||||
|
|
||||||
### 3.7 CollectionView DataSource / Layout Delegate
|
|
||||||
|
|
||||||
**cellForItemAt**:
|
|
||||||
- 用 `dataSource.pageIdentifier` 获取重用标识符
|
|
||||||
- 出队 `RDReaderContentCell`
|
|
||||||
- 调用 `dataSource.pageContentView(pageNum:containerView:)` 传入 cell 已有的 containerView 以便复用
|
|
||||||
- RTL 水平模式对 cell.contentView 做 `scaleX: -1` 翻转
|
|
||||||
|
|
||||||
**pageNum(flowLayout:pageIndex:)**:
|
|
||||||
- 布局检测到滚动中页面变化时更新 `currentPage`
|
|
||||||
|
|
||||||
## 4. 异常与边界处理
|
|
||||||
|
|
||||||
- `currentPage` 初始值为 -1,首次设置时 delegate 会收到回调
|
|
||||||
- 空白哨兵页(Int.max / Int.max-1)在 delegate 回调中被过滤,不会通知到外部
|
|
||||||
- pageCurl 模式下 collectionView 的 DataSource 方法存在但不被调用(collectionView 已从视图层级移除)
|
|
||||||
- 横竖屏变化时异步处理避免嵌套 layoutSubviews
|
|
||||||
- `UIView.ss_superViewController` 通过响应链查找最近的父 VC,用于正确挂载 pageViewController
|
|
||||||
|
|
||||||
## 5. 数据结构与字段映射
|
|
||||||
|
|
||||||
### 5.1 显示模式
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDReaderView.DisplayType
|
|
||||||
├── .pageCurl // UIPageViewController 翻页效果
|
|
||||||
├── .horizontalScroll // UICollectionView 水平滚动
|
|
||||||
└── .verticalScroll // UICollectionView 垂直滚动
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 翻页方向
|
|
||||||
|
|
||||||
```swift
|
|
||||||
RDReaderView.PageDirection
|
|
||||||
├── .leftToRight // 默认,中文/英文
|
|
||||||
└── .rightToLeft // 日文漫画等
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 关键公开属性
|
|
||||||
|
|
||||||
| 属性 | 类型 | 默认值 | 用途 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| `dataSource` | `RDReaderDataSource?` | nil | 弱引用数据源 |
|
|
||||||
| `delegate` | `RDReaderDelegate?` | nil | 弱引用事件监听 |
|
|
||||||
| `currentDisplayType` | `DisplayType` | .pageCurl | 当前显示模式 |
|
|
||||||
| `toolViewAnimationDuration` | `TimeInterval` | 0.3 | 工具栏动画时长 |
|
|
||||||
| `landscapeDualPageEnabled` | `Bool` | false | 横屏双页 |
|
|
||||||
| `pageDirection` | `PageDirection` | .leftToRight | 翻页方向 |
|
|
||||||
| `coverPageIndex` | `Int?` | nil | 封面页索引 |
|
|
||||||
| `currentPage` | `Int` | -1 | 当前页码(didSet 通知 delegate) |
|
|
||||||
|
|
||||||
### 5.4 计算属性
|
|
||||||
|
|
||||||
- `pagesPerScreen: Int`:横屏 + 双页启用 + 非垂直滚动 → 2;否则 1
|
|
||||||
- `isLandscape: Bool`:`bounds.width > bounds.height`
|
|
||||||
- `hasCoverPage: Bool`:`coverPageIndex != nil`
|
|
||||||
|
|
||||||
## 6. 视图绑定规则
|
|
||||||
|
|
||||||
- 规则 1:内容视图通过 `register(contentView:contentViewWithReuseIdentifier:)` 注册,内部注册对应的 `RDReaderContentCell`
|
|
||||||
- 规则 2:Cell 复用两层:`RDReaderContentCell`(UICollectionViewCell)宿主 `containerView`(实际内容视图)
|
|
||||||
- 规则 3:`pageContentView(readerView:pageNum:containerView:)` 的 `containerView` 参数是从回收 cell 中取出的旧视图,允许数据源复用或重新渲染
|
|
||||||
- 规则 4:工具栏通过 `topToolView` / `bottomToolView` 协议方法提供,由 `RDReaderView` 管理显隐动画
|
|
||||||
- 规则 5:RTL 支持通过 collectionView `scaleX: -1` + cell contentView `scaleX: -1` 实现,文本正确显示
|
|
||||||
|
|
||||||
## 7. 通知协作与回调机制
|
|
||||||
|
|
||||||
- 无 NotificationCenter 使用
|
|
||||||
- 所有通信通过协议和闭包:
|
|
||||||
- `RDReaderDataSource`:数据供给(页数、内容视图、标识符、工具栏)
|
|
||||||
- `RDReaderDelegate`:事件通知(页面变化、横竖屏即将变化)
|
|
||||||
- `RDReaderFlowLayoutDelegate`:布局检测到页面变化时通知
|
|
||||||
- `RDReaderFlowLayoutDataSoure`:垂直滚动时提供每页高度
|
|
||||||
|
|
||||||
## 8. 联调与排查建议
|
|
||||||
|
|
||||||
- 排查 1:页面不显示
|
|
||||||
- 确认 `dataSource` 已设置且 `reloadData()` 已调用
|
|
||||||
- 确认 `pageCountOfReaderView` 返回值 > 0
|
|
||||||
- 确认 `pageContentView(pageNum:containerView:)` 返回非 nil 视图
|
|
||||||
- 排查 2:翻页无反应
|
|
||||||
- 确认工具栏是否正在显示(工具栏显示时左右点击变为切换工具栏)
|
|
||||||
- 确认 `currentPage` 的 didSet 是否被触发
|
|
||||||
- pageCurl 模式检查 `UIPageViewController` 的 dataSource 方法是否正确返回
|
|
||||||
- 排查 3:横屏双页异常
|
|
||||||
- 确认 `landscapeDualPageEnabled` 是否为 true
|
|
||||||
- pageCurl 模式需重建 UIPageViewController(spineLocation 不可变)
|
|
||||||
- 检查 `dualPagePair` 配对逻辑和空白哨兵页处理
|
|
||||||
- 排查 4:RTL 方向错误
|
|
||||||
- 确认 `pageDirection` 是否设为 `.rightToLeft`
|
|
||||||
- 水平滚动模式检查 collectionView 是否做了 `scaleX: -1`
|
|
||||||
- pageCurl 模式检查导航方向是否翻转
|
|
||||||
- 排查 5:工具栏动画异常
|
|
||||||
- 确认 `topToolView` / `bottomToolView` 是否通过 dataSource 正确提供
|
|
||||||
- 检查 `toolViewAnimationDuration` 值
|
|
||||||
- 确认工具栏显示期间用户交互是否被正确禁用/恢复
|
|
||||||
@ -1,415 +0,0 @@
|
|||||||
# ReadViewSDK UI 自动化测试可执行方案
|
|
||||||
|
|
||||||
## 1. 目标与边界
|
|
||||||
|
|
||||||
本文档描述当前架构下可落地的 XCUITest 方案。目标不是一次性覆盖所有 SDK API,而是先为 Demo App 的核心阅读链路建立稳定回归网:
|
|
||||||
|
|
||||||
- 书库页面能展示样本书。
|
|
||||||
- 样本书能打开阅读器。
|
|
||||||
- 点击阅读区域中部能显示顶部和底部工具栏。
|
|
||||||
- 顶部返回按钮能关闭阅读器并回到书库。
|
|
||||||
- 设置面板能打开、操作、关闭。
|
|
||||||
- 三种翻页模式能通过启动参数切换并保持阅读器可用。
|
|
||||||
|
|
||||||
XCUITest 运行在独立进程,不能直接调用 `RDEPUBReaderController` 的 Swift 公共 API。因此需要通过 UI 元素、launch arguments、Demo 测试状态标签或截图附件验证结果。SDK 公共 API 可以由单元测试或 Demo 自动化入口间接驱动,不应写成 XCUITest 的直接依赖。
|
|
||||||
|
|
||||||
## 2. 当前可用基础
|
|
||||||
|
|
||||||
### 2.1 已有 accessibilityIdentifier
|
|
||||||
|
|
||||||
| 标识符 | 当前文件 | 用途 |
|
|
||||||
|--------|----------|------|
|
|
||||||
| `epub.reader.back` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift` | 顶部返回按钮 |
|
|
||||||
| `epub.reader.bookmark` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift` | 顶部书签按钮 |
|
|
||||||
| `epub.reader.title` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift` | 顶部标题 |
|
|
||||||
| `epub.reader.toc` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 目录按钮 |
|
|
||||||
| `epub.reader.bookmarks` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 书签列表按钮 |
|
|
||||||
| `epub.reader.highlights` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 高亮列表按钮 |
|
|
||||||
| `epub.reader.add-highlight` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 新建高亮按钮 |
|
|
||||||
| `epub.reader.settings` | `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift` | 设置按钮 |
|
|
||||||
| `demo.root` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | Demo 根视图 |
|
|
||||||
| `demo.status` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | Demo 状态标签 |
|
|
||||||
| `demo.books.table` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | 书库表格 |
|
|
||||||
| `demo.books.empty` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | 空书库提示 |
|
|
||||||
| `demo.book.{n}` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | 书库第 n 本书 |
|
|
||||||
| `demo.reader.host` | `ReadViewDemo/ReadViewDemo/ViewController.swift` | modal 场景下的阅读器宿主 |
|
|
||||||
|
|
||||||
### 2.2 已有 Demo 启动参数
|
|
||||||
|
|
||||||
`ReadViewDemo/ReadViewDemo/ViewController.swift` 已有 `LaunchAutomationPlan`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
--demo-book-title <关键词>
|
|
||||||
--demo-display-type <pagecurl|scroll|vertical>
|
|
||||||
--demo-page <页码>
|
|
||||||
--demo-display-sequence <pagecurl,scroll,vertical>
|
|
||||||
--demo-step-delay <秒>
|
|
||||||
```
|
|
||||||
|
|
||||||
首批测试优先使用 `--demo-book-title 回归验证样本`。该样本目前存在于 `ReadViewDemo/ReadViewDemo/book/回归验证样本.txt`,适合作为稳定自动化入口。
|
|
||||||
|
|
||||||
## 3. 当前架构下的落点
|
|
||||||
|
|
||||||
不要把测试辅助职责重新塞回 `RDEPUBReaderController.swift`。identifier 和测试状态应按真实 UI/职责归属放置:
|
|
||||||
|
|
||||||
| 能力 | 落点 | 原因 |
|
|
||||||
|------|------|------|
|
|
||||||
| 顶部工具栏容器 | `RDEPUBReaderTopToolView.swift` | 工具栏自身创建和维护顶部按钮 |
|
|
||||||
| 底部工具栏容器 | `RDEPUBReaderBottomToolView.swift` | 工具栏自身创建和维护底部按钮 |
|
|
||||||
| 阅读点击区域 | `RDReaderView.swift` | 点击中区、翻页手势都由 ReaderView 处理 |
|
|
||||||
| 滚动翻页容器 | `RDReaderView.swift` 的 `collectionView` | 横滑/竖滑模式使用 collection view |
|
|
||||||
| 单页内容 cell | `RDReaderContentCell.swift` 或当前分页 cell 文件 | 验证 cell 存在,不验证文本排版细节 |
|
|
||||||
| 设置面板控件 | `EPUBUI/Settings/RDEPUBReaderSettingsViewController.swift` | 设置面板已从 EPUBUI 根目录迁移到 Settings |
|
|
||||||
| 目录列表 | `RDEPUBReaderChapterListController.swift` | 目录是独立 UITableViewController |
|
|
||||||
| 高亮/书签列表 | `RDEPUBReaderHighlightsViewController.swift` | 高亮和书签管理器在该文件中 |
|
|
||||||
| Demo 自动化状态 | `ViewController.swift` 或 `RDURLReaderController.swift` | XCUITest 通过文本/identifier 读取状态 |
|
|
||||||
|
|
||||||
## 4. 需要新增的最小标识符
|
|
||||||
|
|
||||||
### 4.1 P0 必需
|
|
||||||
|
|
||||||
| 标识符 | 建议文件 | 用途 |
|
|
||||||
|--------|----------|------|
|
|
||||||
| `epub.reader.topToolbar` | `RDEPUBReaderTopToolView.swift` | 断言顶部工具栏显示/隐藏 |
|
|
||||||
| `epub.reader.bottomToolbar` | `RDEPUBReaderBottomToolView.swift` | 断言底部工具栏显示/隐藏 |
|
|
||||||
| `epub.reader.content` | `RDReaderView.swift` | 点击阅读区域中部、滑动翻页 |
|
|
||||||
| `epub.reader.paging` | `RDReaderView.swift` 的 `collectionView` | 横滑/竖滑容器存在性 |
|
|
||||||
| `epub.reader.settings.scroll` | `RDEPUBReaderSettingsViewController.swift` | 设置面板已打开 |
|
|
||||||
| `epub.reader.settings.brightness` | `RDEPUBReaderSettingsViewController.swift` | 亮度 slider |
|
|
||||||
| `epub.reader.settings.font.decrease` | `RDEPUBReaderSettingsViewController.swift` | 字号减 |
|
|
||||||
| `epub.reader.settings.font.increase` | `RDEPUBReaderSettingsViewController.swift` | 字号加 |
|
|
||||||
| `epub.reader.settings.font.value` | `RDEPUBReaderSettingsViewController.swift` | 字号值 |
|
|
||||||
| `epub.reader.settings.displayType` | `RDEPUBReaderSettingsViewController.swift` | 翻页模式分段控件 |
|
|
||||||
| `epub.reader.settings.done` | `RDEPUBReaderSettingsViewController.swift` | 完成按钮 |
|
|
||||||
| `demo.reader.state` | Demo 层 | 输出当前打开状态、页码、翻页模式 |
|
|
||||||
|
|
||||||
### 4.2 P1 后续补充
|
|
||||||
|
|
||||||
| 标识符 | 建议文件 | 用途 |
|
|
||||||
|--------|----------|------|
|
|
||||||
| `epub.reader.toc.list` | `RDEPUBReaderChapterListController.swift` | 目录列表 |
|
|
||||||
| `epub.reader.toc.cell.{n}` | `RDEPUBReaderChapterListController.swift` | 目录项 |
|
|
||||||
| `epub.reader.bookmark.list` | `RDEPUBReaderHighlightsViewController.swift` 的书签控制器 | 书签列表 |
|
|
||||||
| `epub.reader.bookmark.cell.{n}` | `RDEPUBReaderHighlightsViewController.swift` 的书签控制器 | 书签项 |
|
|
||||||
| `epub.reader.highlight.list` | `RDEPUBReaderHighlightsViewController.swift` | 高亮列表 |
|
|
||||||
| `epub.reader.highlight.cell.{n}` | `RDEPUBReaderHighlightsViewController.swift` | 高亮项 |
|
|
||||||
| `epub.reader.settings.lineHeight` | `RDEPUBReaderSettingsViewController.swift` | 行距分段控件 |
|
|
||||||
| `epub.reader.settings.columns` | `RDEPUBReaderSettingsViewController.swift` | 栏数分段控件 |
|
|
||||||
| `epub.reader.settings.theme.{n}` | `RDEPUBReaderSettingsViewController.swift` | 主题按钮 |
|
|
||||||
|
|
||||||
`epub.reader.pageIndicator` 暂不列为必需项,因为当前没有稳定的页码指示器 UI。若需要断言页码,优先通过 `demo.reader.state` 暴露 `page=...`,或者给 `RDReaderView` 设置 `accessibilityValue`。
|
|
||||||
|
|
||||||
## 5. Demo 测试状态设计
|
|
||||||
|
|
||||||
建议增加一个仅用于自动化的状态标签:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
stateLabel.accessibilityIdentifier = "demo.reader.state"
|
|
||||||
stateLabel.isHidden = true
|
|
||||||
stateLabel.text = "reader=opened page=1 display=pagecurl toolbar=hidden"
|
|
||||||
```
|
|
||||||
|
|
||||||
状态来源可以在 Demo 层更新,不要求 SDK 为测试暴露内部对象:
|
|
||||||
|
|
||||||
- 打开阅读器后:`reader=opened`
|
|
||||||
- 返回书库后:`reader=closed`
|
|
||||||
- 跳页或翻页后:`page=<n>`
|
|
||||||
- 切换翻页模式后:`display=pagecurl|scroll|vertical`
|
|
||||||
- 工具栏显示后:`toolbar=visible`
|
|
||||||
|
|
||||||
这个标签能显著减少 XCUITest 对动画、布局和截图的猜测,是当前架构下最稳的可执行方案。
|
|
||||||
|
|
||||||
## 6. UI Test Target 创建方式
|
|
||||||
|
|
||||||
使用 workspace,不使用单独的 xcodeproj:
|
|
||||||
|
|
||||||
1. 打开 `ReadViewDemo/ReadViewDemo.xcworkspace`
|
|
||||||
2. File -> New -> Target -> iOS UI Testing Bundle
|
|
||||||
3. Product Name: `ReadViewDemoUITests`
|
|
||||||
4. Target to be Tested: `ReadViewDemo`
|
|
||||||
5. 新增测试目录:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ReadViewDemo/
|
|
||||||
ReadViewDemoUITests/
|
|
||||||
Helpers/
|
|
||||||
AccessibilityIdentifiers.swift
|
|
||||||
XCUIApplication+Launch.swift
|
|
||||||
XCUIElement+Wait.swift
|
|
||||||
ReaderUITests/
|
|
||||||
BookListTests.swift
|
|
||||||
ReaderOpenCloseTests.swift
|
|
||||||
ReaderToolbarTests.swift
|
|
||||||
SettingsPanelTests.swift
|
|
||||||
DisplayTypeTests.swift
|
|
||||||
SmokeScreenshotTests.swift
|
|
||||||
```
|
|
||||||
|
|
||||||
首批不要拆太多测试类,避免还没稳定就出现维护成本。
|
|
||||||
|
|
||||||
## 7. P0 测试用例
|
|
||||||
|
|
||||||
### 7.1 启动辅助
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import XCTest
|
|
||||||
|
|
||||||
enum IDs {
|
|
||||||
static let demoStatus = "demo.status"
|
|
||||||
static let demoBooksTable = "demo.books.table"
|
|
||||||
static func demoBook(_ n: Int) -> String { "demo.book.\(n)" }
|
|
||||||
static let demoReaderState = "demo.reader.state"
|
|
||||||
|
|
||||||
static let readerBack = "epub.reader.back"
|
|
||||||
static let readerTitle = "epub.reader.title"
|
|
||||||
static let readerTopToolbar = "epub.reader.topToolbar"
|
|
||||||
static let readerBottomToolbar = "epub.reader.bottomToolbar"
|
|
||||||
static let readerContent = "epub.reader.content"
|
|
||||||
static let readerSettings = "epub.reader.settings"
|
|
||||||
|
|
||||||
static let settingsScroll = "epub.reader.settings.scroll"
|
|
||||||
static let settingsFontIncrease = "epub.reader.settings.font.increase"
|
|
||||||
static let settingsFontDecrease = "epub.reader.settings.font.decrease"
|
|
||||||
static let settingsFontValue = "epub.reader.settings.font.value"
|
|
||||||
static let settingsDone = "epub.reader.settings.done"
|
|
||||||
}
|
|
||||||
|
|
||||||
extension XCUIApplication {
|
|
||||||
func launchAndOpenSampleBook(
|
|
||||||
displayType: String? = nil,
|
|
||||||
pageNumber: Int? = nil
|
|
||||||
) {
|
|
||||||
var args = ["--demo-book-title", "回归验证样本"]
|
|
||||||
if let displayType {
|
|
||||||
args += ["--demo-display-type", displayType]
|
|
||||||
}
|
|
||||||
if let pageNumber {
|
|
||||||
args += ["--demo-page", "\(pageNumber)"]
|
|
||||||
}
|
|
||||||
launchArguments = args
|
|
||||||
launch()
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func waitForReader(timeout: TimeInterval = 10) -> XCUIElement {
|
|
||||||
let backButton = buttons[IDs.readerBack]
|
|
||||||
XCTAssertTrue(backButton.waitForExistence(timeout: timeout))
|
|
||||||
return backButton
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 书库与打开关闭
|
|
||||||
|
|
||||||
```swift
|
|
||||||
final class ReaderOpenCloseTests: XCTestCase {
|
|
||||||
private let app = XCUIApplication()
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
continueAfterFailure = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func testBookListShowsSampleBooks() {
|
|
||||||
app.launch()
|
|
||||||
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
|
|
||||||
XCTAssertTrue(app.cells[IDs.demoBook(0)].waitForExistence(timeout: 5))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testLaunchArgumentOpensReader() {
|
|
||||||
app.launchAndOpenSampleBook()
|
|
||||||
app.waitForReader()
|
|
||||||
XCTAssertTrue(app.staticTexts[IDs.readerTitle].exists)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testBackButtonReturnsToBookList() {
|
|
||||||
app.launchAndOpenSampleBook()
|
|
||||||
app.waitForReader().tap()
|
|
||||||
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 工具栏显示
|
|
||||||
|
|
||||||
```swift
|
|
||||||
final class ReaderToolbarTests: XCTestCase {
|
|
||||||
private let app = XCUIApplication()
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
continueAfterFailure = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func testTapCenterShowsToolbars() {
|
|
||||||
app.launchAndOpenSampleBook()
|
|
||||||
app.waitForReader()
|
|
||||||
|
|
||||||
let content = app.otherElements[IDs.readerContent]
|
|
||||||
XCTAssertTrue(content.waitForExistence(timeout: 5))
|
|
||||||
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
||||||
|
|
||||||
XCTAssertTrue(app.otherElements[IDs.readerTopToolbar].waitForExistence(timeout: 3))
|
|
||||||
XCTAssertTrue(app.otherElements[IDs.readerBottomToolbar].waitForExistence(timeout: 3))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.4 设置面板
|
|
||||||
|
|
||||||
```swift
|
|
||||||
final class SettingsPanelTests: XCTestCase {
|
|
||||||
private let app = XCUIApplication()
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
continueAfterFailure = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func testOpenChangeFontAndCloseSettings() {
|
|
||||||
app.launchAndOpenSampleBook()
|
|
||||||
app.waitForReader()
|
|
||||||
|
|
||||||
app.buttons[IDs.readerSettings].tap()
|
|
||||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
|
|
||||||
|
|
||||||
let fontValue = app.staticTexts[IDs.settingsFontValue]
|
|
||||||
XCTAssertTrue(fontValue.waitForExistence(timeout: 3))
|
|
||||||
let before = fontValue.label
|
|
||||||
|
|
||||||
app.buttons[IDs.settingsFontIncrease].tap()
|
|
||||||
XCTAssertNotEqual(before, fontValue.label)
|
|
||||||
|
|
||||||
app.buttons[IDs.settingsFontDecrease].tap()
|
|
||||||
app.buttons[IDs.settingsDone].tap()
|
|
||||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.5 翻页模式 Smoke Test
|
|
||||||
|
|
||||||
```swift
|
|
||||||
final class DisplayTypeTests: XCTestCase {
|
|
||||||
private let app = XCUIApplication()
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
continueAfterFailure = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func testOpenWithPageCurl() {
|
|
||||||
app.launchAndOpenSampleBook(displayType: "pagecurl")
|
|
||||||
app.waitForReader()
|
|
||||||
}
|
|
||||||
|
|
||||||
func testOpenWithHorizontalScroll() {
|
|
||||||
app.launchAndOpenSampleBook(displayType: "scroll")
|
|
||||||
app.waitForReader()
|
|
||||||
}
|
|
||||||
|
|
||||||
func testOpenWithVerticalScroll() {
|
|
||||||
app.launchAndOpenSampleBook(displayType: "vertical")
|
|
||||||
app.waitForReader()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. P1 测试用例
|
|
||||||
|
|
||||||
P0 稳定后再加入:
|
|
||||||
|
|
||||||
- 目录打开、目录列表存在、点击第一项返回阅读器。
|
|
||||||
- 添加书签后按钮状态变化,打开书签列表存在对应 cell。
|
|
||||||
- 设置面板切换行距、栏数、主题后阅读器仍可用。
|
|
||||||
- 横滑/竖滑模式下拖动内容区域后 `demo.reader.state` 的 page 变化。
|
|
||||||
- 高亮列表打开和空态显示。
|
|
||||||
|
|
||||||
搜索和文本选择高亮不建议作为第一批 UI 自动化。它们依赖文本命中、长按选择、浮层菜单和异步渲染,flaky 风险更高。可以先用单元测试覆盖搜索引擎,用 P1/P2 再补 UI 冒烟。
|
|
||||||
|
|
||||||
## 9. CI 集成
|
|
||||||
|
|
||||||
### 9.1 手动命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
xcodebuild test \
|
|
||||||
-workspace ReadViewDemo/ReadViewDemo.xcworkspace \
|
|
||||||
-scheme ReadViewDemo \
|
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
|
||||||
-only-testing:ReadViewDemoUITests
|
|
||||||
```
|
|
||||||
|
|
||||||
如果本机没有 `iPhone 16`,先查看可用模拟器:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
xcrun simctl list devices available
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.2 GitHub Actions
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: UI Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main, develop]
|
|
||||||
push:
|
|
||||||
branches: [main, develop]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ui-tests:
|
|
||||||
runs-on: macos-15
|
|
||||||
timeout-minutes: 40
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Pods
|
|
||||||
run: cd ReadViewDemo && pod install
|
|
||||||
|
|
||||||
- name: Run UI Tests
|
|
||||||
run: |
|
|
||||||
xcodebuild test \
|
|
||||||
-workspace ReadViewDemo/ReadViewDemo.xcworkspace \
|
|
||||||
-scheme ReadViewDemo \
|
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
|
|
||||||
-only-testing:ReadViewDemoUITests \
|
|
||||||
-resultBundlePath ui-test-results.xcresult
|
|
||||||
|
|
||||||
- name: Upload xcresult
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ui-test-results
|
|
||||||
path: ui-test-results.xcresult
|
|
||||||
```
|
|
||||||
|
|
||||||
CI 首批只跑 P0。P1 可以先本地或 nightly 跑,稳定后再进入 PR 必过。
|
|
||||||
|
|
||||||
## 10. 实施顺序
|
|
||||||
|
|
||||||
| 阶段 | 内容 | 验收标准 |
|
|
||||||
|------|------|----------|
|
|
||||||
| 1 | 补 P0 identifiers | XCUITest 能定位内容区、工具栏、设置控件 |
|
|
||||||
| 2 | 增加 `demo.reader.state` | 能通过 hidden label 读到 opened/page/display/toolbar |
|
|
||||||
| 3 | 创建 `ReadViewDemoUITests` | `xcodebuild test` 能发现测试 target |
|
|
||||||
| 4 | 实现 P0 用例 | 本地模拟器连续跑 3 次通过 |
|
|
||||||
| 5 | 接入 CI | PR 中 P0 UI Tests 可运行并上传 xcresult |
|
|
||||||
| 6 | 扩展 P1 | 目录、书签、主题、翻页断言逐步加入 |
|
|
||||||
|
|
||||||
## 11. 工时预估
|
|
||||||
|
|
||||||
| 工作 | 预估 |
|
|
||||||
|------|------|
|
|
||||||
| P0 identifiers + Demo 状态标签 | 2-3h |
|
|
||||||
| UI Test Target + helpers | 1-2h |
|
|
||||||
| P0 测试实现与稳定性调整 | 4-6h |
|
|
||||||
| CI 接入 | 1-2h |
|
|
||||||
| P1 首批扩展 | 4-8h |
|
|
||||||
|
|
||||||
首个可用版本建议按 1.5 到 2 天排期。后续每加入一组复杂交互,先本地观察稳定性,再进入 CI 必过集合。
|
|
||||||
|
|
||||||
## 12. 风险与约束
|
|
||||||
|
|
||||||
- UI 自动化不能依赖固定动画时间,优先使用 `waitForExistence` 和状态标签。
|
|
||||||
- 不要用截图像素对比作为 PR 必过项;截图附件适合作为人工排查材料。
|
|
||||||
- 样本书必须稳定存在,优先使用 `回归验证样本.txt`。
|
|
||||||
- 工具栏默认可能隐藏,测试应先点击阅读区域中部再断言底部按钮。
|
|
||||||
- 设置面板、目录面板是导航/弹出结构,测试应等待面板根控件,而不是立即点击内部控件。
|
|
||||||
- 若未来 Reader UI 迁移到真实 App,Demo-only 的 `demo.reader.state` 不应进入 SDK 公共 API。
|
|
||||||
687
Doc/UML_CLASS_DIAGRAMS.md
Normal file
687
Doc/UML_CLASS_DIAGRAMS.md
Normal file
@ -0,0 +1,687 @@
|
|||||||
|
# ReadViewSDK UML 类图
|
||||||
|
|
||||||
|
> 最后更新:2026-06-04
|
||||||
|
> 图表格式:Mermaid(可在 GitHub / VS Code / Mermaid Live Editor 中渲染)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 整体模块关系
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph EPUBCore
|
||||||
|
Parser[RDEPUBParser]
|
||||||
|
Pub[RDEPUBPublication]
|
||||||
|
Resolver[RDEPUBResourceResolver]
|
||||||
|
Session[RDEPUBReadingSession]
|
||||||
|
WebView[RDEPUBWebView]
|
||||||
|
Paginator[RDEPUBPaginator]
|
||||||
|
SearchEngine[RDEPUBHTMLSearchEngine]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph EPUBTextRendering
|
||||||
|
Builder[RDEPUBTextBookBuilder]
|
||||||
|
Renderer[RDEPUBDTCoreTextRenderer]
|
||||||
|
Pipeline[RDEPUBTextTypesetterPipeline]
|
||||||
|
Counter[RDEPUBChapterPageCounter]
|
||||||
|
FrameFactory[RDEPUBCoreTextPageFrameFactory]
|
||||||
|
BreakPolicy[RDEPUBPageBreakPolicy]
|
||||||
|
BookCache[RDEPUBTextBookCache]
|
||||||
|
ChapterData[RDEPUBChapterData]
|
||||||
|
IndexTable[RDEPUBTextIndexTable]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph RDReaderView
|
||||||
|
ReaderView[RDReaderView]
|
||||||
|
FlowLayout[RDReaderFlowLayout]
|
||||||
|
Preload[RDReaderPreloadController]
|
||||||
|
Spread[RDReaderSpreadResolver]
|
||||||
|
TapRegion[RDReaderTapRegionHandler]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph EPUBUI
|
||||||
|
Controller[RDEPUBReaderController]
|
||||||
|
Context[RDEPUBReaderContext]
|
||||||
|
Runtime[RDEPUBReaderRuntime]
|
||||||
|
PaginationCoord[RDEPUBReaderPaginationCoordinator]
|
||||||
|
Loader[RDEPUBChapterLoader]
|
||||||
|
RuntimeStore[RDEPUBChapterRuntimeStore]
|
||||||
|
DiskCache[RDEPUBChapterSummaryDiskCache]
|
||||||
|
PageMap[RDEPUBBookPageMap]
|
||||||
|
Config[RDEPUBReaderConfiguration]
|
||||||
|
end
|
||||||
|
|
||||||
|
Parser --> Pub
|
||||||
|
Pub --> Resolver
|
||||||
|
Session --> Pub
|
||||||
|
WebView --> Pub
|
||||||
|
WebView --> Resolver
|
||||||
|
Paginator --> Parser
|
||||||
|
|
||||||
|
Builder --> Renderer
|
||||||
|
Builder --> Pipeline
|
||||||
|
Builder --> Counter
|
||||||
|
Counter --> FrameFactory
|
||||||
|
FrameFactory --> BreakPolicy
|
||||||
|
Builder --> BookCache
|
||||||
|
ChapterData --> IndexTable
|
||||||
|
|
||||||
|
Controller --> Context
|
||||||
|
Context --> Runtime
|
||||||
|
Runtime --> Loader
|
||||||
|
Runtime --> RuntimeStore
|
||||||
|
Runtime --> DiskCache
|
||||||
|
PaginationCoord --> Context
|
||||||
|
PaginationCoord --> PageMap
|
||||||
|
Loader --> RuntimeStore
|
||||||
|
Loader --> DiskCache
|
||||||
|
|
||||||
|
Controller --> ReaderView
|
||||||
|
Controller --> Session
|
||||||
|
Controller --> Builder
|
||||||
|
ReaderView --> FlowLayout
|
||||||
|
ReaderView --> Preload
|
||||||
|
ReaderView --> Spread
|
||||||
|
ReaderView --> TapRegion
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. EPUBCore 核心类图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RDEPUBParser {
|
||||||
|
+metadata: RDEPUBMetadata
|
||||||
|
+manifest: [String: RDEPUBManifestItem]
|
||||||
|
+spine: [RDEPUBSpineItem]
|
||||||
|
+tableOfContents: [EPUBTableOfContentsItem]
|
||||||
|
+extractionRootURL: URL
|
||||||
|
+opfURL: URL
|
||||||
|
+opfDirectoryURL: URL
|
||||||
|
+parse(epubURL: URL) throws
|
||||||
|
+makePublication() RDEPUBPublication
|
||||||
|
+htmlString(forRelativePath:) String?
|
||||||
|
+htmlString(forSpineIndex:) String?
|
||||||
|
+coverImage() UIImage?
|
||||||
|
+resourceURL(forRelativePath:) URL
|
||||||
|
+fileURL(forRelativePath:) URL?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBPublication {
|
||||||
|
+parser: RDEPUBParser
|
||||||
|
+resourceResolver: RDEPUBResourceResolver
|
||||||
|
+metadata: RDEPUBMetadata
|
||||||
|
+manifest: [RDEPUBManifestItem]
|
||||||
|
+spine: [RDEPUBSpineItem]
|
||||||
|
+tableOfContents: [EPUBTableOfContentsItem]
|
||||||
|
+layout: RDEPUBLayout
|
||||||
|
+readingProfile: RDEPUBReadingProfile
|
||||||
|
+readingProgression: RDEPUBReadingProgression
|
||||||
|
+bookIdentifier: String?
|
||||||
|
+fixedLayoutSpreadEnabled(preferences:viewportSize:) Bool
|
||||||
|
+makeFixedSpreads(preferences:viewportSize:) [EPUBFixedSpread]
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBResourceResolver {
|
||||||
|
+parser: RDEPUBParser
|
||||||
|
+fileURL(forRelativePath:) URL?
|
||||||
|
+resourceURL(forRelativePath:) URL
|
||||||
|
+normalizedHref(_:relativeToSpineIndex:) String?
|
||||||
|
+normalizedLocation(_:relativeToSpineIndex:bookIdentifier:) RDEPUBLocation?
|
||||||
|
+spineIndex(forNormalizedHref:) Int?
|
||||||
|
+spineIndex(for:) Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReadingSession {
|
||||||
|
+publication: RDEPUBPublication
|
||||||
|
+navigatorState: RDEPUBNavigatorState
|
||||||
|
+activePages: [EPUBPage]
|
||||||
|
+activeChapters: [EPUBChapterInfo]
|
||||||
|
+currentViewport: RDEPUBViewport?
|
||||||
|
+currentReadingContext: RDEPUBReadingContext?
|
||||||
|
+transition(to: RDEPUBNavigatorState)
|
||||||
|
+setActiveSnapshot(_:)
|
||||||
|
+queueNavigation(to:relativeToSpineIndex:bookIdentifier:) Int
|
||||||
|
+updateReadingContext(...)
|
||||||
|
+makePaginationSnapshot(pageCounts:preferences:layoutContext:)
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBWebView {
|
||||||
|
+publication: RDEPUBPublication?
|
||||||
|
+currentRenderRequest: RDEPUBRenderRequest?
|
||||||
|
+webView: WKWebView
|
||||||
|
+delegate: RDEPUBWebViewDelegate?
|
||||||
|
+load(publication:request:)
|
||||||
|
+reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBNavigatorState {
|
||||||
|
<<enum>>
|
||||||
|
initializing
|
||||||
|
loading
|
||||||
|
idle
|
||||||
|
jumping
|
||||||
|
moving
|
||||||
|
repaginating
|
||||||
|
+isStableForSnapshotApplication: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
RDEPUBParser --> RDEPUBPublication : creates
|
||||||
|
RDEPUBPublication --> RDEPUBResourceResolver : owns
|
||||||
|
RDEPUBReadingSession --> RDEPUBPublication : wraps
|
||||||
|
RDEPUBReadingSession --> RDEPUBNavigatorState : manages
|
||||||
|
RDEPUBWebView --> RDEPUBPublication : uses
|
||||||
|
RDEPUBWebView --> RDEPUBResourceResolver : uses
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. EPUBTextRendering 类图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RDEPUBTextBookBuilder {
|
||||||
|
+renderer: RDEPUBTextRenderer
|
||||||
|
+cache: RDEPUBTextBookCache?
|
||||||
|
+layoutConfig: RDEPUBTextLayoutConfig
|
||||||
|
+renderPipeline: RDEPUBChapterRenderPipeline
|
||||||
|
+paginationPipeline: RDEPUBChapterPaginationPipeline
|
||||||
|
+tailNormalizer: RDEPUBChapterTailNormalizer
|
||||||
|
+build(parser:publication:pageSize:style:) throws RDEPUBTextBook
|
||||||
|
+buildChapter(parser:publication:spineIndex:pageSize:style:) throws RDEPUBTextChapterBuildResult?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextRenderer {
|
||||||
|
<<protocol>>
|
||||||
|
+renderChapter(request:) throws RDEPUBRenderedChapterContent
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBDTCoreTextRenderer {
|
||||||
|
+renderChapter(request:) throws RDEPUBRenderedChapterContent
|
||||||
|
+renderChapter(html:baseURL:style:) throws RDEPUBRenderedChapterContent
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextTypesetterPipeline {
|
||||||
|
+makeRequest(from: RDEPUBTypesettingInput) RDEPUBTypesettingOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBChapterPageCounter {
|
||||||
|
+factory: RDEPUBCoreTextPageFrameFactory
|
||||||
|
+layoutFrames(fragmentOffsets:) [RDEPUBTextLayoutFrame]
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBCoreTextPageFrameFactory {
|
||||||
|
+attributedString: NSAttributedString
|
||||||
|
+pageSize: CGSize
|
||||||
|
+config: RDEPUBTextLayoutConfig
|
||||||
|
+makeFrames(...) [RDEPUBTextLayoutFrame]
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBPageBreakPolicy {
|
||||||
|
+lineIsInAvoidPageBreakInsideBlock(_:) Bool
|
||||||
|
+lineIsInKeepWithNextBlock(_:) Bool
|
||||||
|
+adjustedRange(from:totalLength:lineRanges:factory:) RDEPUBPageBreakDecision
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextBook {
|
||||||
|
+chapters: [RDEPUBTextChapter]
|
||||||
|
+pages: [RDEPUBTextPage]
|
||||||
|
+indexTable: RDEPUBTextIndexTable
|
||||||
|
+positionConverter: RDEPUBTextPositionConverter
|
||||||
|
+chapterData(for href:) RDEPUBChapterData?
|
||||||
|
+chapterData(forPageNumber:) RDEPUBChapterData?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextChapter {
|
||||||
|
+chapterIndex: Int
|
||||||
|
+spineIndex: Int
|
||||||
|
+href: String
|
||||||
|
+title: String
|
||||||
|
+attributedContent: NSAttributedString
|
||||||
|
+fragmentOffsets: [String: Int]
|
||||||
|
+pages: [RDEPUBTextPage]
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextPage {
|
||||||
|
+absolutePageIndex: Int
|
||||||
|
+chapterIndex: Int
|
||||||
|
+spineIndex: Int
|
||||||
|
+contentRange: NSRange
|
||||||
|
+metadata: RDEPUBTextPageMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextBookCache {
|
||||||
|
+schemaVersion: Int
|
||||||
|
+load(key:) [String: RDEPUBTextChapterPaginationCache]?
|
||||||
|
+save(_:key:)
|
||||||
|
+invalidateAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBChapterData {
|
||||||
|
+chapterIndex: Int
|
||||||
|
+spineIndex: Int
|
||||||
|
+href: String
|
||||||
|
+title: String
|
||||||
|
+pageCount: Int
|
||||||
|
+page(containing:) RDEPUBTextPage?
|
||||||
|
+pageNumber(containing:) Int?
|
||||||
|
+absoluteRange(for location:) ClosedRange<Int>?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextIndexTable {
|
||||||
|
+chapterStartOffsets: [Int]
|
||||||
|
+totalCharacterCount: Int
|
||||||
|
+anchor(forAbsoluteIndex:in:) RDEPUBTextAnchor
|
||||||
|
+pageNumber(for:in:) Int
|
||||||
|
+globalIndex(for:) Int
|
||||||
|
}
|
||||||
|
|
||||||
|
RDEPUBTextBookBuilder --> RDEPUBTextRenderer : uses
|
||||||
|
RDEPUBDTCoreTextRenderer ..|> RDEPUBTextRenderer
|
||||||
|
RDEPUBTextBookBuilder --> RDEPUBTextTypesetterPipeline : uses
|
||||||
|
RDEPUBTextBookBuilder --> RDEPUBChapterPageCounter : uses
|
||||||
|
RDEPUBChapterPageCounter --> RDEPUBCoreTextPageFrameFactory : uses
|
||||||
|
RDEPUBCoreTextPageFrameFactory --> RDEPUBPageBreakPolicy : uses
|
||||||
|
RDEPUBTextBookBuilder --> RDEPUBTextBookCache : uses
|
||||||
|
RDEPUBTextBook --> RDEPUBTextChapter : contains
|
||||||
|
RDEPUBTextChapter --> RDEPUBTextPage : contains
|
||||||
|
RDEPUBTextBook --> RDEPUBTextIndexTable : computes
|
||||||
|
RDEPUBChapterData --> RDEPUBTextChapter : wraps
|
||||||
|
RDEPUBChapterData --> RDEPUBTextIndexTable : uses
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. ReaderView 类图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RDReaderView {
|
||||||
|
+currentPage: Int
|
||||||
|
+currentDisplayType: DisplayType
|
||||||
|
+pageProvider: RDReaderPageProvider?
|
||||||
|
+delegate: RDReaderDelegate?
|
||||||
|
+landscapeDualPageEnabled: Bool
|
||||||
|
+pageDirection: PageDirection
|
||||||
|
+coverPageIndex: Int?
|
||||||
|
+pagesPerScreen: Int
|
||||||
|
+transitionToPage(pageNum:animated:)
|
||||||
|
+reloadData()
|
||||||
|
+switchReaderDisplayType(_:)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DisplayType {
|
||||||
|
<<enum>>
|
||||||
|
pageCurl
|
||||||
|
horizontalScroll
|
||||||
|
verticalScroll
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDReaderPageProvider {
|
||||||
|
<<protocol>>
|
||||||
|
+numberOfPages(in:) Int
|
||||||
|
+readerView(_:viewForPageAt:reusableView:) UIView
|
||||||
|
+pageIdentifier(in:index:) String?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDReaderFlowLayout {
|
||||||
|
+displayType: DisplayType
|
||||||
|
+isLandscapeDualPage: Bool
|
||||||
|
+coverPageIndex: Int?
|
||||||
|
+pagesPerScreen: Int
|
||||||
|
+currentPage: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDReaderPreloadController {
|
||||||
|
+radius: Int
|
||||||
|
+pageViewForDisplay(pageNum:environment:contentViewProvider:) UIView?
|
||||||
|
+takePreloadedView(for:) UIView?
|
||||||
|
+prime(around:preferredForward:parentView:environment:contentViewProvider:)
|
||||||
|
+invalidate(environment:)
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDReaderSpreadResolver {
|
||||||
|
+isFullScreenPage(...) Bool
|
||||||
|
+dualPagePair(for:totalPages:coverPageIndex:) (Int, Int?)
|
||||||
|
+nextPage(from:totalPages:pagesPerScreen:coverPageIndex:forward:) Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDReaderTapRegionHandler {
|
||||||
|
+resolveTapEvent(point:viewFrame:isToolViewVisible:) TapEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
class TapEvent {
|
||||||
|
<<enum>>
|
||||||
|
none
|
||||||
|
left
|
||||||
|
center
|
||||||
|
right
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDReaderPagingController {
|
||||||
|
+isTransitioning: Bool
|
||||||
|
+didBuildUI: Bool
|
||||||
|
+pendingTransitionRequest: PageTransitionRequest?
|
||||||
|
+shouldQueuePageTransition(_:currentDisplayType:) Bool
|
||||||
|
+finishPageCurlTransition() PageTransitionRequest?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDReaderContentCell {
|
||||||
|
+containerView: UIView?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDReaderPageChildViewController {
|
||||||
|
+contentView: UIView?
|
||||||
|
+pageNum: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
RDReaderView --> DisplayType : uses
|
||||||
|
RDReaderView --> RDReaderPageProvider : delegates
|
||||||
|
RDReaderView --> RDReaderFlowLayout : owns
|
||||||
|
RDReaderView --> RDReaderPreloadController : owns
|
||||||
|
RDReaderView --> RDReaderSpreadResolver : owns
|
||||||
|
RDReaderView --> RDReaderTapRegionHandler : owns
|
||||||
|
RDReaderView --> RDReaderPagingController : owns
|
||||||
|
RDReaderView --> RDReaderContentCell : creates
|
||||||
|
RDReaderView --> RDReaderPageChildViewController : creates
|
||||||
|
RDReaderTapRegionHandler --> TapEvent : produces
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. EPUBUI 控制器与运行时类图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RDEPUBReaderController {
|
||||||
|
+delegate: RDEPUBReaderDelegate?
|
||||||
|
+configuration: RDEPUBReaderConfiguration
|
||||||
|
+currentLocation: RDEPUBLocation?
|
||||||
|
+currentPageNumber: Int?
|
||||||
|
+currentSelection: RDEPUBSelection?
|
||||||
|
+highlights: [RDEPUBHighlight]
|
||||||
|
+bookmarks: [RDEPUBBookmark]
|
||||||
|
+tableOfContents: [EPUBTableOfContentsItem]
|
||||||
|
+readerView: RDReaderView
|
||||||
|
+readerContext: RDEPUBReaderContext
|
||||||
|
+runtime: RDEPUBReaderRuntime?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReaderContext {
|
||||||
|
+parser: RDEPUBParser?
|
||||||
|
+publication: RDEPUBPublication?
|
||||||
|
+readingSession: RDEPUBReadingSession?
|
||||||
|
+textBook: RDEPUBTextBook?
|
||||||
|
+bookPageMap: RDEPUBBookPageMap?
|
||||||
|
+configuration: RDEPUBReaderConfiguration
|
||||||
|
+persistence: RDEPUBReaderPersistence?
|
||||||
|
+currentRenderSignature() String
|
||||||
|
+chapterCacheKey(forSpineIndex:) RDEPUBChapterCacheKey
|
||||||
|
+chapterCacheKey(forSpineIndex:precomputedContentHash:renderSignature:) RDEPUBChapterCacheKey
|
||||||
|
+makeTextBookBuilder(layoutConfig:) RDEPUBTextBookBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReaderRuntime {
|
||||||
|
+chapterLoader: RDEPUBChapterLoader
|
||||||
|
+chapterRuntimeStore: RDEPUBChapterRuntimeStore
|
||||||
|
+summaryDiskCache: RDEPUBChapterSummaryDiskCache
|
||||||
|
+viewportMonitor: RDEPUBViewportMonitor
|
||||||
|
+applyBookPageMap(_:restoreLocation:)
|
||||||
|
+refreshBookPageMapInPlace(_:)
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReaderPaginationCoordinator {
|
||||||
|
-context: RDEPUBReaderContext
|
||||||
|
+paginatePublication(restoreLocation:)
|
||||||
|
+paginateMetadataOnly(token:restoreLocation:)
|
||||||
|
+repaginatePreservingCurrentLocation()
|
||||||
|
-restoreBookPageMapIfPossible(publication:) RDEPUBBookPageMap?
|
||||||
|
-buildPageMap(from:summaries:) RDEPUBBookPageMap
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBChapterLoader {
|
||||||
|
-context: RDEPUBReaderContext
|
||||||
|
+loadChapter(spineIndex:store:) async throws RDEPUBRuntimeChapter
|
||||||
|
+loadChapterSynchronouslyForMigration(spineIndex:store:) throws RDEPUBRuntimeChapter
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBChapterRuntimeStore {
|
||||||
|
+chapterDataCache: [Int: RDEPUBRuntimeChapter]
|
||||||
|
+pageCountCache: RDEPUBPageCountCache
|
||||||
|
+imageCache: NSCache
|
||||||
|
+setCurrentChapter(spineIndex:totalSpineCount:windowRadius:)
|
||||||
|
+chapterData(for:) RDEPUBRuntimeChapter?
|
||||||
|
+pageCount(for:) RDEPUBRuntimePageCount?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBChapterSummaryDiskCache {
|
||||||
|
+cacheDirectory: URL
|
||||||
|
+read(for:) RDEPUBChapterSummary?
|
||||||
|
+readAll(keys:) ([Int: RDEPUBChapterSummary], RDEPUBBookPageMap.Builder)
|
||||||
|
+write(summary:for:)
|
||||||
|
+flushPendingWrites()
|
||||||
|
+isCacheComplete(keys:) Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBBookPageMap {
|
||||||
|
+totalChapters: Int
|
||||||
|
+totalPages: Int
|
||||||
|
+chapters: [EPUBChapterInfo]
|
||||||
|
+pages: [EPUBPage]
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBChapterCacheKey {
|
||||||
|
+bookID: String
|
||||||
|
+spineIndex: Int
|
||||||
|
+renderSignature: String
|
||||||
|
+chapterContentHash: String
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReaderConfiguration {
|
||||||
|
+fontSize: CGFloat
|
||||||
|
+fontChoice: FontChoice
|
||||||
|
+lineHeightMultiple: CGFloat
|
||||||
|
+numberOfColumns: Int
|
||||||
|
+theme: ReaderTheme
|
||||||
|
+displayType: DisplayType
|
||||||
|
+onDemandChapterWindowSize: Int
|
||||||
|
+metadataParsingConcurrency: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
RDEPUBReaderController --> RDEPUBReaderContext : owns
|
||||||
|
RDEPUBReaderController --> RDEPUBReaderRuntime : owns
|
||||||
|
RDEPUBReaderController --> RDEPUBReaderPaginationCoordinator : owns
|
||||||
|
RDEPUBReaderContext --> RDEPUBReaderConfiguration : holds
|
||||||
|
RDEPUBReaderRuntime --> RDEPUBChapterLoader : owns
|
||||||
|
RDEPUBReaderRuntime --> RDEPUBChapterRuntimeStore : owns
|
||||||
|
RDEPUBReaderRuntime --> RDEPUBChapterSummaryDiskCache : owns
|
||||||
|
RDEPUBReaderPaginationCoordinator --> RDEPUBReaderContext : uses
|
||||||
|
RDEPUBChapterLoader --> RDEPUBReaderContext : uses
|
||||||
|
RDEPUBChapterLoader --> RDEPUBChapterRuntimeStore : uses
|
||||||
|
RDEPUBChapterLoader --> RDEPUBChapterSummaryDiskCache : uses
|
||||||
|
RDEPUBChapterSummaryDiskCache --> RDEPUBChapterCacheKey : uses
|
||||||
|
RDEPUBBookPageMap --> EPUBChapterInfo : contains
|
||||||
|
RDEPUBBookPageMap --> EPUBPage : contains
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 数据模型关系图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class RDEPUBLocation {
|
||||||
|
+bookIdentifier: String?
|
||||||
|
+href: String
|
||||||
|
+progression: Double
|
||||||
|
+lastProgression: Double?
|
||||||
|
+fragment: String?
|
||||||
|
+rangeAnchor: RDEPUBTextRangeAnchor?
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextAnchor {
|
||||||
|
+fileIndex: Int
|
||||||
|
+row: Int
|
||||||
|
+column: Int
|
||||||
|
+chapterOffset: Int
|
||||||
|
+fragmentID: String?
|
||||||
|
+spineIndex: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBTextRangeAnchor {
|
||||||
|
+start: RDEPUBTextAnchor
|
||||||
|
+end: RDEPUBTextAnchor
|
||||||
|
+nsRange: NSRange
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBSelection {
|
||||||
|
+bookIdentifier: String?
|
||||||
|
+location: RDEPUBLocation
|
||||||
|
+text: String
|
||||||
|
+rangeInfo: String?
|
||||||
|
+createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBHighlight {
|
||||||
|
+id: String
|
||||||
|
+bookIdentifier: String?
|
||||||
|
+location: RDEPUBLocation
|
||||||
|
+text: String
|
||||||
|
+style: RDEPUBHighlightStyle
|
||||||
|
+color: String
|
||||||
|
+note: String?
|
||||||
|
+createdAt: Date
|
||||||
|
+uiColor: UIColor
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBBookmark {
|
||||||
|
+id: String
|
||||||
|
+bookIdentifier: String?
|
||||||
|
+location: RDEPUBLocation
|
||||||
|
+chapterTitle: String?
|
||||||
|
+note: String?
|
||||||
|
+createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBAnnotation {
|
||||||
|
+id: String
|
||||||
|
+kind: RDEPUBAnnotationKind
|
||||||
|
+location: RDEPUBLocation
|
||||||
|
+text: String?
|
||||||
|
+bookmark: RDEPUBBookmark?
|
||||||
|
+highlight: RDEPUBHighlight?
|
||||||
|
}
|
||||||
|
|
||||||
|
class EPUBPage {
|
||||||
|
+spineIndex: Int
|
||||||
|
+chapterIndex: Int
|
||||||
|
+pageIndexInChapter: Int
|
||||||
|
+totalPagesInChapter: Int
|
||||||
|
+chapterTitle: String
|
||||||
|
+fixedSpread: EPUBFixedSpread?
|
||||||
|
}
|
||||||
|
|
||||||
|
class EPUBChapterInfo {
|
||||||
|
+spineIndex: Int
|
||||||
|
+title: String
|
||||||
|
+pageCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBViewport {
|
||||||
|
+resources: [RDEPUBViewportResource]
|
||||||
|
+visiblePageNumber: Int
|
||||||
|
+chapterIndex: Int?
|
||||||
|
+isFixedLayout: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
class RDEPUBReadingContext {
|
||||||
|
+location: RDEPUBLocation
|
||||||
|
+viewport: RDEPUBViewport
|
||||||
|
+pageNumber: Int
|
||||||
|
+chapterIndex: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
RDEPUBLocation --> RDEPUBTextRangeAnchor : optional
|
||||||
|
RDEPUBTextRangeAnchor --> RDEPUBTextAnchor : start/end
|
||||||
|
RDEPUBSelection --> RDEPUBLocation : references
|
||||||
|
RDEPUBHighlight --> RDEPUBLocation : references
|
||||||
|
RDEPUBBookmark --> RDEPUBLocation : references
|
||||||
|
RDEPUBAnnotation --> RDEPUBLocation : references
|
||||||
|
RDEPUBReadingContext --> RDEPUBLocation : holds
|
||||||
|
RDEPUBReadingContext --> RDEPUBViewport : holds
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 后台解析并发模型
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Main as Main Thread
|
||||||
|
participant BG as Background Queue
|
||||||
|
participant OQ as OperationQueue (N workers)
|
||||||
|
participant Disk as Disk Cache
|
||||||
|
|
||||||
|
Main->>BG: paginateMetadataOnly(token)
|
||||||
|
BG->>BG: 预计算 contentHash (串行)
|
||||||
|
BG->>Disk: readAll(catalog) 批量读取缓存
|
||||||
|
BG->>Main: refreshBookPageMapInPlace (缓存部分)
|
||||||
|
BG->>BG: waitForReadingInteractionToSettle (0.8s)
|
||||||
|
|
||||||
|
loop 每个未缓存章节
|
||||||
|
BG->>OQ: addOperation(spineIndex)
|
||||||
|
OQ->>OQ: buildChapter (渲染+分页)
|
||||||
|
OQ->>OQ: chapterCacheKey (复用预计算hash)
|
||||||
|
OQ->>Disk: write(summary) 异步写盘
|
||||||
|
OQ->>OQ: resultLock: 累加结果
|
||||||
|
alt 每32章或最后一章
|
||||||
|
OQ->>Main: refreshBookPageMapInPlace
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
OQ->>BG: waitUntilAllOperationsAreFinished
|
||||||
|
BG->>Disk: flushPendingWrites
|
||||||
|
BG->>Main: refreshBookPageMapInPlace (最终)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 缓存键与失效策略
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph CacheKey["RDEPUBChapterCacheKey"]
|
||||||
|
A[bookID]
|
||||||
|
B[spineIndex]
|
||||||
|
C[renderSignature]
|
||||||
|
D[chapterContentHash]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph RenderSignature["renderSignature 组成"]
|
||||||
|
E[fontName]
|
||||||
|
F[fontSize]
|
||||||
|
G[lineHeightMultiple]
|
||||||
|
H[lineSpacing]
|
||||||
|
I[layoutConfig.cacheSignature]
|
||||||
|
J[schemaVersion]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CacheSignature["layoutConfig.cacheSignature"]
|
||||||
|
K[frameWidth]
|
||||||
|
L[frameHeight]
|
||||||
|
M[edgeInsets]
|
||||||
|
N[numberOfColumns]
|
||||||
|
O[columnGap]
|
||||||
|
P[avoidOrphans/Widows]
|
||||||
|
Q[hyphenation]
|
||||||
|
R[imageMaxHeightRatio]
|
||||||
|
end
|
||||||
|
|
||||||
|
C --> E & F & G & H & I & J
|
||||||
|
I --> K & L & M & N & O & P & Q & R
|
||||||
|
|
||||||
|
style CacheKey fill:#e1f5fe
|
||||||
|
style RenderSignature fill:#fff3e0
|
||||||
|
style CacheSignature fill:#e8f5e9
|
||||||
|
```
|
||||||
42
Doc/index.md
42
Doc/index.md
@ -1,37 +1,27 @@
|
|||||||
# ReadViewSDK 文档索引
|
# ReadViewSDK 文档索引
|
||||||
|
|
||||||
## 架构与规范
|
> 最后更新:2026-06-04
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心文档
|
||||||
|
|
||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | 四层架构总览(EPUBCore → EPUBTextRendering → RDReaderView → EPUBUI)、数据流、分页模式、位置模型、已知限制 |
|
| [ARCHITECTURE.md](ARCHITECTURE.md) | 系统架构总览:分层架构、核心数据流、缓存架构、目录结构、设计模式 |
|
||||||
| [CODING_STYLE.md](CODING_STYLE.md) | 命名规范(RD/RDEPUB 前缀)、分层规则、UI 布局规范、代码风格细则、extension 拆分、SS→RD 迁移计划 |
|
| [UML_CLASS_DIAGRAMS.md](UML_CLASS_DIAGRAMS.md) | UML 类图(Mermaid 格式):模块关系、核心类图、并发模型、缓存键设计 |
|
||||||
| [EPUB_MAINTENANCE.md](EPUB_MAINTENANCE.md) | 文件职责表、DTCoreText 渲染管线、常见排查场景、推荐日志断点、阅读器后续能力规划 |
|
| [BUSINESS_LOGIC.md](BUSINESS_LOGIC.md) | 业务逻辑详解:EPUB 解析、文本渲染管线、后台解析优化、翻页容器、标注系统、搜索、设置 |
|
||||||
|
|
||||||
## 模块功能实现逻辑
|
## 专题文档
|
||||||
|
|
||||||
| 文档 | 对应模块 | 说明 |
|
|
||||||
|------|----------|------|
|
|
||||||
| [EPUBCore_功能实现逻辑.md](EPUBCore_功能实现逻辑.md) | `Sources/RDReaderView/EPUBCore/`(31 Swift + 2 资源) | EPUB 解析全流程(ZIP→container.xml→OPF→spine/TOC)、阅读会话状态机、离屏分页测量、`ss-reader://` 资源协议、JS 桥接(6 种消息)、WebView 渲染管线、文本锚点定位、渲染请求模型、缓存策略 |
|
|
||||||
| [EPUBTextRendering_功能实现逻辑.md](EPUBTextRendering_功能实现逻辑.md) | `Sources/RDReaderView/EPUBTextRendering/`(13 文件) | DTCoreText HTML→NSAttributedString 渲染管线、片段标记注入/提取、CoreText 分页引擎(含语义边界调整)、文本索引表、Location↔PageNumber 双向转换、分页缓存、性能采样、全文搜索引擎、纯文本构建器 |
|
|
||||||
| [RDReaderView_功能实现逻辑.md](RDReaderView_功能实现逻辑.md) | `Sources/RDReaderView/ReaderView/`(5 文件) | 三种显示模式(pageCurl/horizontalScroll/verticalScroll)、DataSource/Delegate 协议、点击三区域翻页、工具栏动画、双页配对与哨兵页、横竖屏适配、RTL 支持 |
|
|
||||||
| [EPUBUI_功能实现逻辑.md](EPUBUI_功能实现逻辑.md) | `Sources/RDReaderView/EPUBUI/`(19 文件) | RDEPUBReaderController 全生命周期、三条渲染路径分发、配置变更检测与响应、工具栏/目录/高亮/书签/搜索/设置面板交互流、阅读位置持久化、主题管理、CoreText 页面交互 |
|
|
||||||
|
|
||||||
## 方案讨论与规划文档
|
|
||||||
|
|
||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| [阅读器功能开发计划.md](阅读器功能开发计划.md) | 渲染质量三方对比(ReadViewSDK vs WXRead)、功能开发计划与落地状态、优先级路线图 |
|
| [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s |
|
||||||
| [架构对比分析_WXRead_vs_ReadViewSDK.md](架构对比分析_WXRead_vs_ReadViewSDK.md) | ReadViewSDK 与 WXRead 的逐项架构核查,确认主链路复刻完成度 |
|
|
||||||
| [ReflowableEPUB_WXReadRenderer_Design.md](FeatureSolution/ReflowableEPUB_WXReadRenderer_Design.md) | 基于读书的 CoreText 渲染架构,设计 Reflowable EPUB 的增强文本渲染方案(CSS 分层、类型器升级) |
|
|
||||||
|
|
||||||
## 测试与质量
|
## 项目信息
|
||||||
|
|
||||||
| 文档 | 说明 |
|
- **模块总数:** 4 个(EPUBCore、EPUBTextRendering、RDReaderView、EPUBUI)
|
||||||
|------|------|
|
- **Swift 文件数:** 138 个
|
||||||
| [UI自动化测试工作文档.md](UI自动化测试工作文档.md) | XCUITest UI 自动化测试完整工作计划:现状分析、accessibilityIdentifier 补充清单、UI Test Target 创建、测试用例设计(书库/阅读器/工具栏/设置/书签/高亮/目录/截图)、CI 集成方案、实施检查清单 |
|
- **测试用例数:** 18 个测试类,66 个测试方法
|
||||||
|
- **最低 iOS 版本:** 15.6
|
||||||
## 目录约定
|
- **构建方式:** CocoaPods(本地 pod)
|
||||||
|
|
||||||
- `Doc/`:项目级文档(架构、规范、维护指南、模块实现逻辑)
|
|
||||||
- `Doc/FeatureSolution/`:方案讨论文档(由 `discuss-sdk-feature-solution` skill 生成)
|
|
||||||
|
|||||||
@ -1,379 +0,0 @@
|
|||||||
# 双层 PageMap 方案:估算 + 精确混合
|
|
||||||
|
|
||||||
> 目标:大书首次打开时,快速给出全书总页数(< 1s),同时保留当前窗口章节的精确分页结果。
|
|
||||||
> 约束:估算值不覆盖已精确分页的 quick-window 章节,不写入磁盘缓存,不伪装成精确 summary。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 问题
|
|
||||||
|
|
||||||
当前 `paginateMetadataOnly` 对全书逐章做完整渲染 + CoreText 分页 + 写磁盘缓存。2470 章在真机上串行约 867s(并发 6 约 256s)。在此期间:
|
|
||||||
|
|
||||||
- 总页数持续变化(从 partial 到 full)
|
|
||||||
- 进度条不准确
|
|
||||||
- 目录页码缺失
|
|
||||||
|
|
||||||
用户看到的是"总页数从 0 慢慢涨到 18595",体验差。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 方案概述
|
|
||||||
|
|
||||||
```
|
|
||||||
quick open(现有,不变)
|
|
||||||
→ 当前窗口 3-5 章精确分页
|
|
||||||
→ applyBookPageMap(partialMap, restoreLocation:)
|
|
||||||
→ 用户可立即阅读
|
|
||||||
|
|
||||||
估算补全(新增,< 1s)
|
|
||||||
→ 遍历所有非窗口章节,快速估算 pageCount
|
|
||||||
→ 与 quick open 的精确值合并为 mixedMap
|
|
||||||
→ refreshBookPageMapInPlace(mixedMap)
|
|
||||||
→ 用户立即看到接近真实的全书总页数
|
|
||||||
|
|
||||||
精确解析(现有,后台逐步)
|
|
||||||
→ paginateMetadataOnly 逐章精确渲染
|
|
||||||
→ 每 32 章用精确值替换 mixedMap 中的估算值
|
|
||||||
→ refreshBookPageMapInPlace(updatedMap)
|
|
||||||
→ 总页数微调至精确值
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 核心约束
|
|
||||||
|
|
||||||
### 3.1 不覆盖已精确分页的章节
|
|
||||||
|
|
||||||
quick open 已经对当前窗口章(`initialWindowSpineIndices` 返回的 3-5 章)做了完整渲染 + CoreText 分页,生成了精确的 `partialMap`。这个 map 立刻用于 `applyBookPageMap(restoreLocation:)` 恢复阅读位置。
|
|
||||||
|
|
||||||
估算层**不能**用全书估算 map 整体替换这个 partial map,否则:
|
|
||||||
- 当前窗口章的精确 pageCount 被冲掉
|
|
||||||
- `pageNumber(for:)` 的绝对页号映射漂移
|
|
||||||
- `prepareOnDemandChapter(forAbsolutePageNumber:)` 定位出错
|
|
||||||
|
|
||||||
正确做法:**精确局部 + 估算尾部**的混合 map。窗口章保留精确值,其余章节填估算值。
|
|
||||||
|
|
||||||
### 3.2 估算值只存内存,不落盘
|
|
||||||
|
|
||||||
估算的 pageCount 必须**只存在于内存态的 BookPageMap**中,不能写入:
|
|
||||||
- `RDEPUBChapterSummaryDiskCache`(否则 loader 误以为有精确 pageRanges 可复用)
|
|
||||||
- `RDEPUBPageCountCache`(否则 loader 跳过完整分页)
|
|
||||||
- 任何磁盘持久化存储
|
|
||||||
|
|
||||||
后续 `paginateMetadataOnly` 的精确结果会逐章替换估算值,并写入磁盘缓存。磁盘上永远只有精确数据。
|
|
||||||
|
|
||||||
### 3.3 估算方法必须足够快
|
|
||||||
|
|
||||||
目标:< 1s 完成全书估算(2470 章)。每章允许 ~0.4ms。
|
|
||||||
|
|
||||||
可行方法:遍历 spine,对每章只做 HTML 纯文本提取(`NSString` 去标签),用 `textLength / estimatedCharsPerPage` 得到页数。不做 CSS 渲染、不做NSAttributedString 构建、不做 CoreText 分页。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 数据流
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────┐
|
|
||||||
│ quick open (现有) │
|
|
||||||
│ 当前窗口章 → 精确 partialMap │
|
|
||||||
└──────────────┬──────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────▼──────────────┐
|
|
||||||
│ estimateRemainingChapters │
|
|
||||||
│ 非窗口章 → 估算 pageCount │
|
|
||||||
└──────────────┬──────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────▼──────────────┐
|
|
||||||
│ mergePreciseAndEstimated │
|
|
||||||
│ 精确窗口 + 估算尾部 → mixedMap │
|
|
||||||
└──────────────┬──────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────▼──────────────┐
|
|
||||||
│ refreshBookPageMapInPlace │
|
|
||||||
│ 用户立即看到全书总页数 │
|
|
||||||
└──────────────┬──────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────▼──────────────┐
|
|
||||||
│ paginateMetadataOnly (现有) │
|
|
||||||
│ 逐章精确渲染,替换估算值 │
|
|
||||||
│ 每 32 章刷新一次 │
|
|
||||||
└──────────────┬──────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────▼──────────────┐
|
|
||||||
│ 最终精确 BookPageMap │
|
|
||||||
└─────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 实现细节
|
|
||||||
|
|
||||||
### 5.1 估算方法
|
|
||||||
|
|
||||||
在 `RDEPUBReaderPaginationCoordinator` 中新增:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
/// 快速估算非窗口章节的页数,只使用纯文本长度,不渲染。
|
|
||||||
/// 返回 spineIndex -> estimatedPageCount 的字典。
|
|
||||||
private func estimateChapterPageCounts(
|
|
||||||
for spineIndices: [Int],
|
|
||||||
publication: RDEPUBPublication,
|
|
||||||
parser: RDEPUBParser,
|
|
||||||
pageSize: CGSize,
|
|
||||||
style: RDEPUBTextRenderStyle
|
|
||||||
) -> [Int: Int] {
|
|
||||||
let charsPerPage = estimatedCharsPerPage(pageSize: pageSize, style: style)
|
|
||||||
var result: [Int: Int] = [:]
|
|
||||||
for spineIndex in spineIndices {
|
|
||||||
let item = publication.spine[spineIndex]
|
|
||||||
guard item.linear,
|
|
||||||
item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
|
||||||
let htmlString = parser.htmlString(forRelativePath: item.href) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// 快速提取纯文本:去 HTML 标签
|
|
||||||
let plainText = htmlString
|
|
||||||
.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let textLength = plainText.count
|
|
||||||
let estimatedPages = max(1, Int(ceil(Double(textLength) / Double(charsPerPage))))
|
|
||||||
result[spineIndex] = estimatedPages
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据排版参数估算每页字符数。
|
|
||||||
private func estimatedCharsPerPage(
|
|
||||||
pageSize: CGSize,
|
|
||||||
style: RDEPUBTextRenderStyle
|
|
||||||
) -> Double {
|
|
||||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
|
||||||
let columns = max(1, layoutConfig.numberOfColumns)
|
|
||||||
let columnGap = layoutConfig.columnGap
|
|
||||||
let insets = layoutConfig.edgeInsets
|
|
||||||
let usableWidth = pageSize.width - insets.left - insets.right - CGFloat(columns - 1) * columnGap
|
|
||||||
let usableHeight = pageSize.height - insets.top - insets.bottom
|
|
||||||
let lineHeight = style.fontSize * style.lineHeightMultiple
|
|
||||||
let linesPerPage = Int(usableHeight / lineHeight) * columns
|
|
||||||
// 中文平均字符宽度约 0.5 * fontSize,英文约 0.6 * fontSize
|
|
||||||
// 取中位数 0.55 作为粗估
|
|
||||||
let avgCharWidth = style.fontSize * 0.55
|
|
||||||
let charsPerLine = max(1, Int(usableWidth / avgCharWidth))
|
|
||||||
return Double(linesPerPage * charsPerLine)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 混合 Map 构建
|
|
||||||
|
|
||||||
```swift
|
|
||||||
/// 构建混合 BookPageMap:窗口章用精确值,其余用估算值。
|
|
||||||
private func buildMixedPageMap(
|
|
||||||
preciseEntries: [Int: RDEPUBBookPageMapEntry], // quick open 的精确结果
|
|
||||||
estimatedPageCounts: [Int: Int], // 估算的 pageCount
|
|
||||||
catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
|
|
||||||
) -> RDEPUBBookPageMap {
|
|
||||||
var builder = RDEPUBBookPageMap.Builder()
|
|
||||||
for item in catalog {
|
|
||||||
if let precise = preciseEntries[item.spineIndex] {
|
|
||||||
// 窗口章:用精确值
|
|
||||||
builder.add(
|
|
||||||
spineIndex: item.spineIndex,
|
|
||||||
href: item.href,
|
|
||||||
title: item.title,
|
|
||||||
pageCount: precise.pageCount,
|
|
||||||
fragmentOffsets: precise.fragmentOffsets
|
|
||||||
)
|
|
||||||
} else if let estimatedCount = estimatedPageCounts[item.spineIndex] {
|
|
||||||
// 非窗口章:用估算值,不写 fragmentOffsets
|
|
||||||
builder.add(
|
|
||||||
spineIndex: item.spineIndex,
|
|
||||||
href: item.href,
|
|
||||||
title: item.title,
|
|
||||||
pageCount: estimatedCount,
|
|
||||||
fragmentOffsets: [:]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return builder.build()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 集成点
|
|
||||||
|
|
||||||
在 `paginateTextPublication` 中,quick open 完成后、`paginateMetadataOnly` 之前插入:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// 现有:quick open 生成精确 partialMap
|
|
||||||
let quickWindowChapters = try loadInitialRuntimeChapters(...)
|
|
||||||
let partialMap = makePartialPageMap(from: quickWindowChapters)
|
|
||||||
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
|
|
||||||
|
|
||||||
// 新增:估算剩余章节,构建混合 map
|
|
||||||
let windowSpineIndices = Set(quickWindowChapters.map(\.spineIndex))
|
|
||||||
let allBuildable = allBuildableSpineIndices(in: publication)
|
|
||||||
let remainingSpineIndices = allBuildable.filter { !windowSpineIndices.contains($0) }
|
|
||||||
let estimatedCounts = estimateChapterPageCounts(
|
|
||||||
for: remainingSpineIndices,
|
|
||||||
publication: publication,
|
|
||||||
parser: parser,
|
|
||||||
pageSize: pageSize,
|
|
||||||
style: style
|
|
||||||
)
|
|
||||||
let preciseEntries = makePreciseEntries(from: quickWindowChapters)
|
|
||||||
let catalog = allBuildable.map { ... } // 同 paginateMetadataOnly 的 catalog 构建
|
|
||||||
let mixedMap = buildMixedPageMap(
|
|
||||||
preciseEntries: preciseEntries,
|
|
||||||
estimatedPageCounts: estimatedCounts,
|
|
||||||
catalog: catalog
|
|
||||||
)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
runtime.refreshBookPageMapInPlace(mixedMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 现有:后台精确解析(会逐章替换估算值)
|
|
||||||
paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.4 精确值替换估算值
|
|
||||||
|
|
||||||
`paginateMetadataOnly` 现有逻辑不需要大改。只需确保:
|
|
||||||
|
|
||||||
1. `summariesBySpineIndex` 初始包含缓存命中 + 估算值(作为 fallback)
|
|
||||||
2. 每完成一章精确解析,用精确值覆盖该 spineIndex 的条目
|
|
||||||
3. `buildPageMap` 时,精确值自然替换估算值
|
|
||||||
|
|
||||||
具体改动:在 `paginateMetadataOnly` 的 `cachedSummaries` 之后,把估算值也注入 `summariesBySpineIndex`,但标记为估算(比如用一个 `Set<Int>` 记录哪些是估算值)。精确解析完成后,精确值会自动覆盖同 key 的估算值。
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// 在 paginateMetadataOnly 内部,cachedSummaries 初始化后:
|
|
||||||
var summariesBySpineIndex = cachedSummaries
|
|
||||||
|
|
||||||
// 注入估算值(仅填充未缓存的章节)
|
|
||||||
for (spineIndex, pageCount) in estimatedPageCounts {
|
|
||||||
if !cachedSpineIndices.contains(spineIndex) {
|
|
||||||
// 用估算值创建一个最小 summary,只填 pageCount
|
|
||||||
// 不写 pageRanges、不写 fragmentOffsets
|
|
||||||
summariesBySpineIndex[spineIndex] = RDEPUBChapterSummary.estimated(
|
|
||||||
pageCount: pageCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
需要在 `RDEPUBChapterSummary` 上新增一个工厂方法:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
extension RDEPUBChapterSummary {
|
|
||||||
/// 创建估算用的最小摘要,只含 pageCount,不含精确 pageRanges。
|
|
||||||
/// 此摘要不写入磁盘缓存。
|
|
||||||
static func estimated(pageCount: Int) -> RDEPUBChapterSummary {
|
|
||||||
RDEPUBChapterSummary(
|
|
||||||
pageRanges: [],
|
|
||||||
pageCount: pageCount,
|
|
||||||
fragmentOffsets: [:],
|
|
||||||
renderSignature: "estimated",
|
|
||||||
schemaVersion: currentSchemaVersion,
|
|
||||||
chapterContentHash: "estimated",
|
|
||||||
pageMetadataList: []
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isEstimated: Bool { renderSignature == "estimated" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.5 磁盘缓存保护
|
|
||||||
|
|
||||||
在 `paginateMetadataOnly` 的写盘逻辑中,跳过估算 summary:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// 写盘前检查
|
|
||||||
if !summary.isEstimated {
|
|
||||||
summaryDiskCache?.write(summary: summary, for: cacheKey)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 需要修改的文件
|
|
||||||
|
|
||||||
| 文件 | 改动 | 行数估算 |
|
|
||||||
|---|---|---|
|
|
||||||
| `RDEPUBReaderPaginationCoordinator.swift` | 新增 `estimateChapterPageCounts`、`estimatedCharsPerPage`、`buildMixedPageMap`;在 `paginateTextPublication` 中集成;`paginateMetadataOnly` 中注入估算值并跳过估算写盘 | ~80 行 |
|
|
||||||
| `RDEPUBChapterSummary` (in `RDEPUBChapterSummaryDiskCache.swift`) | 新增 `estimated(pageCount:)` 工厂方法和 `isEstimated` 属性 | ~10 行 |
|
|
||||||
| `RDEPUBBookPageMap` (in `RDEPUBBookPageMap.swift`) | 确认 `Builder.add()` 支持 `fragmentOffsets: [:]`(空字典) | 可能 0 行(已支持) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 不需要修改的文件
|
|
||||||
|
|
||||||
| 文件 | 原因 |
|
|
||||||
|---|---|
|
|
||||||
| `RDEPUBChapterLoader` | 没有精确缓存时自动走完整渲染 + 分页,已有 fallback |
|
|
||||||
| `RDEPUBChapterRuntimeStore` | 不涉及 |
|
|
||||||
| `RDEPUBReaderRuntime` | `refreshBookPageMapInPlace` 已支持替换式刷新 |
|
|
||||||
| `RDEPUBChapterWindowCoordinator` | 不涉及 |
|
|
||||||
| `RDReaderView` | 不涉及 |
|
|
||||||
| `RDEPUBReaderConfiguration` | 不涉及 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 验证方式
|
|
||||||
|
|
||||||
### 8.1 单元验证
|
|
||||||
|
|
||||||
- 打开《凡人修仙传》,记录从 `applyBookPageMap` 到 `refreshBookPageMapInPlace(mixedMap)` 的耗时,应 < 1s
|
|
||||||
- 验证 mixedMap 中窗口章的 pageCount 与精确 partialMap 一致
|
|
||||||
- 验证 mixedMap 中非窗口章的 pageCount 是估算值(与精确值有偏差但量级正确)
|
|
||||||
- 验证 `paginateMetadataOnly` 完成后,所有章节的 pageCount 被精确值替换
|
|
||||||
|
|
||||||
### 8.2 UI 验证
|
|
||||||
|
|
||||||
- 首次打开大书,总页数在 1s 内从 0 跳到接近真实值(如 18000+),而非逐步增长
|
|
||||||
- 当前窗口章的翻页、位置恢复不受影响
|
|
||||||
- 后台精确解析期间,总页数微调(如 18000 → 18595),无大幅跳变
|
|
||||||
- 二次打开仍走精确缓存路径,不走估算
|
|
||||||
|
|
||||||
### 8.3 基准对比
|
|
||||||
|
|
||||||
| 指标 | 当前实现 | 双层方案 |
|
|
||||||
|---|---|---|
|
|
||||||
| 首次显示全书总页数 | ~256s(并发 6) | < 1s |
|
|
||||||
| 总页数精度 | 100%(精确) | 首屏 ~95%(估算),后台 100% |
|
|
||||||
| 当前章体验 | 不受影响 | 不受影响 |
|
|
||||||
| 磁盘缓存 | 只有精确值 | 只有精确值(估算不落盘) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 风险与缓解
|
|
||||||
|
|
||||||
### 9.1 估算页数与精确页数偏差大
|
|
||||||
|
|
||||||
**风险**:某些章节(图片多、CSS 复杂、中英文混排)的估算页数可能与精确值偏差 20%+。
|
|
||||||
|
|
||||||
**缓解**:
|
|
||||||
- 估算只影响总页数显示,不影响阅读体验
|
|
||||||
- 后台精确解析会逐步替换,偏差是暂时的
|
|
||||||
- 可在 UI 上标注"页数计算中..."降低用户预期
|
|
||||||
|
|
||||||
### 9.2 纯文本提取不准确
|
|
||||||
|
|
||||||
**风险**:`<script>`、`<style>` 标签内的文本被误计入,导致估算偏高。
|
|
||||||
|
|
||||||
**缓解**:正则去标签时先移除 `<script>...</script>` 和 `<style>...</style>` 块。
|
|
||||||
|
|
||||||
### 9.3 估算值干扰精确值的合并
|
|
||||||
|
|
||||||
**风险**:`paginateMetadataOnly` 注入估算值后,如果某章精确解析失败,该章会保留估算值而非报错。
|
|
||||||
|
|
||||||
**缓解**:在 `buildPageMap` 中检查 `isEstimated`,对仍然为估算值的章节标记为 unknown(页数 0),而非保留可能不准确的估算值。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 实施顺序
|
|
||||||
|
|
||||||
1. `RDEPUBChapterSummary` 新增 `estimated(pageCount:)` 和 `isEstimated`
|
|
||||||
2. `RDEPUBReaderPaginationCoordinator` 新增估算方法
|
|
||||||
3. 在 `paginateTextPublication` 的 quick open 之后、`paginateMetadataOnly` 之前插入混合 map 构建
|
|
||||||
4. `paginateMetadataOnly` 中注入估算值、跳过估算写盘
|
|
||||||
5. 真机验证《凡人修仙传》的首屏总页数显示速度和精度
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,438 +0,0 @@
|
|||||||
# 《凡人修仙传》快速进入阅读器方案
|
|
||||||
|
|
||||||
> 适用场景:`textReflowable` 路径打开超大正文 EPUB,典型样本为《凡人修仙传》精校版全本。
|
|
||||||
> 目标:把“进入阅读器前必须等全书分页完成”改成“先快速可读,再后台补齐全书能力”。
|
|
||||||
> 结论先行:当前首屏慢的主因不是单章分页太慢,而是 **打开流程要求先完成全书 `RDEPUBTextBookBuilder.build()`**。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 当前慢在哪里
|
|
||||||
|
|
||||||
结合当前代码,打开 reflowable EPUB 的主链路是:
|
|
||||||
|
|
||||||
```text
|
|
||||||
RDEPUBReaderController.viewDidLoad
|
|
||||||
-> RDEPUBReaderLoadCoordinator.loadPublication()
|
|
||||||
-> applyParsedPublication(...)
|
|
||||||
-> RDEPUBReaderPaginationCoordinator.paginatePublication()
|
|
||||||
-> if publication.readingProfile == .textReflowable
|
|
||||||
-> RDEPUBTextBookBuilder.build(...)
|
|
||||||
-> 遍历全部 spine item
|
|
||||||
-> 每章 render + paginate
|
|
||||||
-> 汇总成完整 RDEPUBTextBook
|
|
||||||
-> applyTextBook(...)
|
|
||||||
-> readerView.reloadData()
|
|
||||||
-> restoreReadingLocation(...)
|
|
||||||
```
|
|
||||||
|
|
||||||
关键事实:
|
|
||||||
|
|
||||||
- [RDEPUBReaderPaginationCoordinator.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift) 在 `textReflowable` 路径里会先 `showLoading()`,然后后台执行 `builder.build(...)`,构建完成前不会进入正文。
|
|
||||||
- [RDEPUBTextBookBuilder.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift) 的 `build()` 会遍历全部 `publication.spine`,逐章完成:
|
|
||||||
- HTML 读取
|
|
||||||
- `DTCoreText` 渲染
|
|
||||||
- CoreText 分页
|
|
||||||
- 页面模型组装
|
|
||||||
- 全书 `RDEPUBTextBook` 汇总
|
|
||||||
- 也就是说,对《凡人修仙传》这种章节数多、正文长的大书,当前实际是“**全书构建完成后才能看到第一页**”。
|
|
||||||
|
|
||||||
这条链路的体验问题是:
|
|
||||||
|
|
||||||
- 首屏等待时间和“全书总字数/总章节数”线性相关,而不是和“当前阅读位置附近内容规模”相关。
|
|
||||||
- 即使用户只想看第一页,也要先支付整本书的 render + paginate 成本。
|
|
||||||
- 恢复到历史位置时也是同样问题,因为当前恢复逻辑依赖完整 `RDEPUBTextBook` 的页码与位置映射。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 方案目标
|
|
||||||
|
|
||||||
### 用户目标
|
|
||||||
|
|
||||||
- 点击书籍后,阅读器应尽快进入正文页,而不是长时间停留在 loading。
|
|
||||||
- 即使全书尚未构建完成,也至少能:
|
|
||||||
- 看到当前章节
|
|
||||||
- 翻当前章节内的页
|
|
||||||
- 恢复到“接近上次阅读位置”的章节
|
|
||||||
|
|
||||||
### 技术目标
|
|
||||||
|
|
||||||
- 首屏进入从“全书 ready”改成“当前章节 ready”。
|
|
||||||
- 全书构建改为后台增量完成。
|
|
||||||
- 不破坏现有 `RDEPUBReaderController` / `RDReaderView` 的主公开 API。
|
|
||||||
- 保留当前 `RDEPUBTextBookCache` 的价值,但把缓存粒度从“整本书一次命中”扩展到“单章可命中”。
|
|
||||||
|
|
||||||
### 首期验收指标
|
|
||||||
|
|
||||||
- 大书首次打开时,进入正文的等待时间显著短于当前实现。
|
|
||||||
- 首屏进入只依赖“当前章节”或“当前位置附近章节”构建完成。
|
|
||||||
- 后台继续构建剩余章节时,不阻塞阅读。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 推荐方案:两阶段进入 + 章节级增量构建
|
|
||||||
|
|
||||||
## 阶段 A:快速进入
|
|
||||||
|
|
||||||
打开书后只做这些事情:
|
|
||||||
|
|
||||||
1. 解析 EPUB 基础元数据、spine、TOC
|
|
||||||
2. 确定恢复位置对应的 `spineIndex`
|
|
||||||
3. 只构建当前章节,必要时附带相邻 `±1` 章
|
|
||||||
4. 先生成一个“局部 TextBook / 局部 Snapshot”
|
|
||||||
5. 立即进入阅读器并恢复到该章节内位置
|
|
||||||
|
|
||||||
这一阶段的原则是:
|
|
||||||
|
|
||||||
- 先解决“能进入”
|
|
||||||
- 不要求立刻具备全书搜索、全书目录页码、全书绝对页码精度
|
|
||||||
|
|
||||||
## 阶段 B:后台补全
|
|
||||||
|
|
||||||
进入正文后,再后台串行完成:
|
|
||||||
|
|
||||||
1. 当前章节相邻章节预构建
|
|
||||||
2. 剩余章节逐步构建
|
|
||||||
3. 持续补齐全书:
|
|
||||||
- `RDEPUBTextBook.chapters`
|
|
||||||
- `RDEPUBTextBook.pages`
|
|
||||||
- `RDEPUBTextIndexTable`
|
|
||||||
- 全书页码/位置映射
|
|
||||||
4. 构建完成后静默替换到完整模型
|
|
||||||
|
|
||||||
这样用户的体感是:
|
|
||||||
|
|
||||||
- 很快进书
|
|
||||||
- 越读越完整
|
|
||||||
- 而不是“先等很久,之后一次性全有”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 为什么这是最快可落地的方案
|
|
||||||
|
|
||||||
相比继续优化单次 `build()` 的 CPU 细节,这个方案收益更直接:
|
|
||||||
|
|
||||||
- 《凡人修仙传》的核心问题是“全书串行工作量太大”,不是“当前章节单章慢到不可接受”。
|
|
||||||
- 当前代码已经天然按“章节”组织:
|
|
||||||
- `publication.spine`
|
|
||||||
- `RDEPUBTextChapter`
|
|
||||||
- `RDEPUBChapterData`
|
|
||||||
- 每章 `render + paginate`
|
|
||||||
- [阅读器功能开发计划.md](/Users/shenlei/Work/ReadViewSDK/Doc/阅读器功能开发计划.md) 里也已经明确把“增量构建”列为方向,说明这条路和现有架构一致。
|
|
||||||
|
|
||||||
换句话说,这不是推翻重做,而是把现有 `RDEPUBTextBookBuilder.build()` 从“必须一次性跑完整本书”拆成“可按章节单独执行”。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 具体改造点
|
|
||||||
|
|
||||||
## 5.1 构建层:把全书构建拆成章节级能力
|
|
||||||
|
|
||||||
当前:
|
|
||||||
|
|
||||||
- [RDEPUBTextBookBuilder.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift) 只有全书 `build(...)`
|
|
||||||
|
|
||||||
建议新增:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
func buildChapter(
|
|
||||||
parser: RDEPUBParser,
|
|
||||||
publication: RDEPUBPublication,
|
|
||||||
spineIndex: Int,
|
|
||||||
pageSize: CGSize,
|
|
||||||
style: RDEPUBTextRenderStyle
|
|
||||||
) throws -> RDEPUBTextChapterBuildResult
|
|
||||||
```
|
|
||||||
|
|
||||||
建议返回:
|
|
||||||
|
|
||||||
- `chapter: RDEPUBTextChapter`
|
|
||||||
- `paginationDiagnostic`
|
|
||||||
- `resourceDiagnostics`
|
|
||||||
- `performanceSample`
|
|
||||||
|
|
||||||
这样做的好处:
|
|
||||||
|
|
||||||
- 章节构建逻辑可以和现有 `build()` 共享
|
|
||||||
- 后续“首屏只构建一章”和“后台补全整本书”都走同一套实现
|
|
||||||
|
|
||||||
## 5.2 数据层:允许 TextBook 从“不完整”逐步变完整
|
|
||||||
|
|
||||||
当前:
|
|
||||||
|
|
||||||
- `RDEPUBTextBook` 默认假设 `chapters/pages/indexTable` 已经是完整全书
|
|
||||||
|
|
||||||
建议新增一个运行时模型,例如:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
final class RDEPUBIncrementalTextBookStore
|
|
||||||
```
|
|
||||||
|
|
||||||
职责:
|
|
||||||
|
|
||||||
- 保存已完成构建的章节
|
|
||||||
- 维护 `spineIndex -> chapter` 映射
|
|
||||||
- 动态生成当前可用的 pages snapshot
|
|
||||||
- 在“部分章节可用”时提供章节内阅读支持
|
|
||||||
|
|
||||||
建议暴露能力:
|
|
||||||
|
|
||||||
- `chapter(forSpineIndex:)`
|
|
||||||
- `availablePagesSnapshot()`
|
|
||||||
- `merge(chapter:)`
|
|
||||||
- `isChapterReady(_:)`
|
|
||||||
- `readyChapterRange(around:)`
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- `RDEPUBTextBook` 更适合“完整产物”
|
|
||||||
- 增量加载需要一个“半成品但可读”的状态容器
|
|
||||||
|
|
||||||
## 5.3 分页协调层:把一次性 loading 改成两阶段 loading
|
|
||||||
|
|
||||||
当前:
|
|
||||||
|
|
||||||
- [RDEPUBReaderPaginationCoordinator.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift) 中 `paginatePublication()` 直接把全书构建作为进入阅读器前置条件
|
|
||||||
|
|
||||||
建议改成:
|
|
||||||
|
|
||||||
### Step 1:首次只构建目标章节
|
|
||||||
|
|
||||||
- 根据 `restoreLocation` 算出目标 `spineIndex`
|
|
||||||
- 若无恢复位置,默认 `spineIndex = 0`
|
|
||||||
- 只构建该章,必要时加 `±1` 章
|
|
||||||
|
|
||||||
### Step 2:先应用局部 snapshot
|
|
||||||
|
|
||||||
- `readerView.reloadData()`
|
|
||||||
- 允许用户开始阅读
|
|
||||||
- tool chrome 可先显示,但某些依赖全书的能力先降级
|
|
||||||
|
|
||||||
### Step 3:后台继续全书补建
|
|
||||||
|
|
||||||
- 串行队列逐章构建剩余章节
|
|
||||||
- 每完成一章,就 merge 进 store
|
|
||||||
- 必要时再刷新目录页码、搜索索引、页码总数
|
|
||||||
|
|
||||||
## 5.4 位置恢复:首期优先恢复“章节”,二期补齐“页内精度”
|
|
||||||
|
|
||||||
当前:
|
|
||||||
|
|
||||||
- 恢复逻辑大量依赖完整 `RDEPUBTextBook` 的 pageNumber / location 映射
|
|
||||||
|
|
||||||
首期建议:
|
|
||||||
|
|
||||||
- 打开时先把恢复目标收敛到 `spineIndex + fragment/rangeAnchor`
|
|
||||||
- 只要目标章节 ready,就先进入该章节
|
|
||||||
- 若该章节内页内恢复信息尚未完整,先恢复到该章节接近位置
|
|
||||||
|
|
||||||
这样能显著减少“为了恢复精确页码,必须先构建全书”的耦合。
|
|
||||||
|
|
||||||
## 5.5 缓存层:从整书缓存扩展到单章缓存
|
|
||||||
|
|
||||||
当前:
|
|
||||||
|
|
||||||
- `RDEPUBPaginationCacheCoordinator` / `RDEPUBTextBookCache` 更偏整书结果
|
|
||||||
|
|
||||||
建议:
|
|
||||||
|
|
||||||
- 缓存 key 增加 `spineIndex`
|
|
||||||
- 支持单章 page ranges 命中
|
|
||||||
- 首次打开大书时,优先读取:
|
|
||||||
- 当前章节缓存
|
|
||||||
- 相邻章节缓存
|
|
||||||
|
|
||||||
收益:
|
|
||||||
|
|
||||||
- 同一本大书二次打开时,可直接秒开到当前章节
|
|
||||||
- 不用再等待整本书缓存完全重建
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 首期最小可交付版本
|
|
||||||
|
|
||||||
为了最快解决《凡人修仙传》慢启动,建议首期只做下面这些:
|
|
||||||
|
|
||||||
1. 为 `RDEPUBTextBookBuilder` 抽出章节级构建接口
|
|
||||||
2. `RDEPUBReaderPaginationCoordinator` 首次打开时只构建目标章节
|
|
||||||
3. 用“局部 pages snapshot”驱动 `RDReaderView`
|
|
||||||
4. 后台串行构建剩余章节
|
|
||||||
5. 当前章节缓存命中优先
|
|
||||||
|
|
||||||
首期明确不做:
|
|
||||||
|
|
||||||
- 全书搜索实时可用
|
|
||||||
- 全书总页数一开始就准确
|
|
||||||
- 目录面板一开始就显示所有章节页码
|
|
||||||
|
|
||||||
这些能力可以在后台补建完成后逐步恢复。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. UI 与交互建议
|
|
||||||
|
|
||||||
为了让“快速进入但后台仍在准备”体验自然,建议增加轻量提示:
|
|
||||||
|
|
||||||
### 阅读器内状态提示
|
|
||||||
|
|
||||||
- 首屏进入后不再是全屏 loading
|
|
||||||
- 改为顶部或底部轻提示:
|
|
||||||
- `正在准备后续章节...`
|
|
||||||
- `已进入阅读,可继续翻页`
|
|
||||||
|
|
||||||
### 未就绪章节翻页策略
|
|
||||||
|
|
||||||
当用户快速翻到尚未构建的章节时:
|
|
||||||
|
|
||||||
- 优先命中后台预构建结果
|
|
||||||
- 如果还未就绪:
|
|
||||||
- 显示章节级 loading skeleton
|
|
||||||
- 不要退回全屏 blocking loading
|
|
||||||
|
|
||||||
### 目录与搜索降级
|
|
||||||
|
|
||||||
- 目录先显示标题,不强依赖页码
|
|
||||||
- 搜索面板可在全书索引未完成时显示:
|
|
||||||
- `正文已可阅读,全文搜索仍在准备中`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 风险与应对
|
|
||||||
|
|
||||||
## 风险 1:现有很多 API 默认依赖完整 TextBook
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
- `pageNumber(for:)`
|
|
||||||
- `location(forPageNumber:)`
|
|
||||||
- `chapterData(forPageNumber:)`
|
|
||||||
|
|
||||||
应对:
|
|
||||||
|
|
||||||
- 首期不要强行让这些 API 在“半本书”状态下也完整成立
|
|
||||||
- 先给增量模式增加“可用性边界”
|
|
||||||
- 在运行时根据 `isFullyBuilt` / `isChapterReady` 分流
|
|
||||||
|
|
||||||
## 风险 2:局部 snapshot 和完整 snapshot 切换时页码跳动
|
|
||||||
|
|
||||||
应对:
|
|
||||||
|
|
||||||
- 局部模式优先用章节内位置恢复,不强调绝对页码稳定
|
|
||||||
- 后台切换到完整模型时,按 `location` 而不是按 `pageNumber` 恢复
|
|
||||||
|
|
||||||
## 风险 3:后台补建打断当前交互
|
|
||||||
|
|
||||||
应对:
|
|
||||||
|
|
||||||
- 章节构建使用串行后台队列
|
|
||||||
- UI 合并更新节流,例如“每完成 N 章再刷新一次目录状态”
|
|
||||||
- 当前可见章节不重复重建
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 推荐实施顺序
|
|
||||||
|
|
||||||
### Phase 1:快速进入 MVP
|
|
||||||
|
|
||||||
- 抽 `buildChapter(...)`
|
|
||||||
- 首次打开只构建目标章节
|
|
||||||
- 局部 snapshot 驱动阅读器
|
|
||||||
- 后台补建剩余章节
|
|
||||||
|
|
||||||
### Phase 2:缓存加速
|
|
||||||
|
|
||||||
- 单章分页缓存
|
|
||||||
- 恢复位置附近章节优先命中
|
|
||||||
- 二次打开大书进一步提速
|
|
||||||
|
|
||||||
### Phase 3:全书能力渐进恢复
|
|
||||||
|
|
||||||
- 全书搜索索引后台构建
|
|
||||||
- TOC 页码后台补齐
|
|
||||||
- 全书总页数在构建完成后更新
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 建议验收方式
|
|
||||||
|
|
||||||
建议拿《凡人修仙传》精校版全本做专项验证,记录以下指标:
|
|
||||||
|
|
||||||
- 点击书籍到首屏可读的耗时
|
|
||||||
- 点击书籍到全书构建完成的耗时
|
|
||||||
- 首屏进入时用户是否已经可以翻当前章节
|
|
||||||
- 翻到下一章节时是否出现明显阻塞
|
|
||||||
- 二次打开同一本书时是否明显快于首次
|
|
||||||
|
|
||||||
重点不是只看“总构建时长”,而是看:
|
|
||||||
|
|
||||||
- `Time To First Readable Page`
|
|
||||||
- `Time To Full Book Ready`
|
|
||||||
|
|
||||||
这两个指标在大书场景里要分开看。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 最终建议
|
|
||||||
|
|
||||||
对《凡人修仙传》这类超大正文书,最快见效的方案不是继续压榨单次全书分页性能,而是:
|
|
||||||
|
|
||||||
**把阅读器打开流程从“全书先构建完”改成“当前章节先可读,剩余章节后台补齐”。**
|
|
||||||
|
|
||||||
这是当前代码架构下收益最高、侵入性也相对可控的做法,因为:
|
|
||||||
|
|
||||||
- 现有数据天然按章节组织
|
|
||||||
- `RDEPUBTextBookBuilder` 已经具备章节级循环结构
|
|
||||||
- `RDEPUBReaderPaginationCoordinator` 也已经是集中调度入口
|
|
||||||
|
|
||||||
如果只允许做一件事来解决《凡人修仙传》打开慢的问题,我建议优先做:
|
|
||||||
|
|
||||||
**Phase 1:章节级增量构建 + 两阶段进入阅读器。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 当前落地状态(2026-06-02)
|
|
||||||
|
|
||||||
本轮已按 Phase 1 做了首期快速进入优化,当前实现状态如下。
|
|
||||||
|
|
||||||
### 已完成
|
|
||||||
|
|
||||||
- `RDEPUBTextBookBuilder` 已抽出章节级构建接口 `buildChapter(...)`,单章构建复用原有 render、分页、尾页规范化、诊断和分页缓存逻辑。
|
|
||||||
- `RDEPUBReaderPaginationCoordinator` 的 `textReflowable` 路径已改为两阶段:
|
|
||||||
- 第一阶段:按恢复位置优先构建可用章节,并立即应用局部 `RDEPUBTextBook` 进入阅读器。
|
|
||||||
- 第二阶段:后台继续按章节增量构建,但增量结果会先暂存,只在用户空闲时再合并到当前可读内容,最终补齐为完整全书模型。
|
|
||||||
- 快速进入阶段不只尝试单个 spine,而是按离恢复位置最近的可构建 HTML/XHTML spine 逐个尝试,避免封面、版权页、空白扉页导致首屏快速路径落空。
|
|
||||||
- 已修正阅读路径误判:普通静态 SVG 封面不再把整本小说误判为 `webInteractive`,避免错误掉回 `WKWebView` 分页路径。
|
|
||||||
- 首包策略已调整为“目标章节 + 后续 2 章”,默认先提供 3 章连续可读内容。
|
|
||||||
- 后台补齐策略已调整为“每新增 20 章生成一份新的局部结果”,并优先补当前可读窗口之后的章节,再回补前文。
|
|
||||||
- 增量结果不再一生成就立即 `applyTextBook`,而是只保留最近一份 staged `RDEPUBTextBook`,等用户停止翻页且阅读器回到 idle 后再统一合并,减少翻页过程中的 UI reload 和主线程抖动。
|
|
||||||
- 已对后台分页任务做降干扰处理:章节补建切到较低优先级队列,用户刚翻页时后台任务会短暂停让,减少 pageCurl 翻页时的 CPU 抢占。
|
|
||||||
- 后台完整构建失败时,如果局部章节已经成功进入阅读器,则不再把用户退回阻塞式错误流程,只结束 loading;如果局部章节也未成功,则按原错误处理。
|
|
||||||
|
|
||||||
### 仍未完成
|
|
||||||
|
|
||||||
- 还没有引入独立的 `RDEPUBIncrementalTextBookStore`,当前首期实现采用“局部 `RDEPUBTextBook` 先应用,完整 `RDEPUBTextBook` 后替换”的 MVP 路径。
|
|
||||||
- 目录页码、全文搜索、全书总页数仍依赖后台完整构建完成后恢复。
|
|
||||||
- 尚未加入可视化的“后续章节准备中”轻提示。
|
|
||||||
|
|
||||||
### 当前预期效果
|
|
||||||
|
|
||||||
对《凡人修仙传》精校版全本这类章节多、正文长的大书,首屏进入不再等待整本书逐章 render + paginate 全部完成,而是先等待目标正文附近 3 章构建完成。全书分页仍会继续执行,但它从“进入阅读器前置条件”变成了“阅读器内后台补齐任务”;后台每补齐 20 章会生成一份新的局部结果,不过这份结果会先暂存,等用户空闲时再并入当前可读内容。
|
|
||||||
|
|
||||||
### 验证状态
|
|
||||||
|
|
||||||
已通过 CocoaPods workspace 构建 Demo,确认本轮快速进入优化可编译:
|
|
||||||
|
|
||||||
```text
|
|
||||||
xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace \
|
|
||||||
-scheme ReadViewDemo \
|
|
||||||
-configuration Debug \
|
|
||||||
-derivedDataPath /private/tmp/ReadViewDemoDerivedData \
|
|
||||||
CODE_SIGNING_ALLOWED=NO \
|
|
||||||
CODE_SIGNING_REQUIRED=NO \
|
|
||||||
build
|
|
||||||
```
|
|
||||||
|
|
||||||
结果:`BUILD SUCCEEDED`。
|
|
||||||
|
|
||||||
注意:直接构建 `ReadViewDemo.xcodeproj` 会绕开 Pods,导致 `import RDReaderView` 模块解析失败;验证时应使用 `ReadViewDemo/ReadViewDemo.xcworkspace`。
|
|
||||||
|
|
||||||
后续仍需要在真机或模拟器上用《凡人修仙传》精校版全本做实际打开耗时对比,重点记录 `Time To First Readable Page` 和 `Time To Full Book Ready`。
|
|
||||||
@ -1,290 +0,0 @@
|
|||||||
# 架构对比分析:读书 vs ReadViewSDK
|
|
||||||
|
|
||||||
> 基于读书 v10.0.3 (Build 79) 逆向文档,与当前 ReadViewSDK 代码在 2026-06-02 的核查结果整合。
|
|
||||||
> 本文档已吸收原 [WXRead剩余问题修复计划.md](/Users/shen/Work/Code/ReadViewSDK/Doc/WXRead剩余问题修复计划.md) 的阶段方案,后续以本文档作为单一真值。
|
|
||||||
|
|
||||||
> 标注说明:
|
|
||||||
> - `✅ 已实现`:当前代码已经按接近 WXRead 的路径落地
|
|
||||||
> - `⚠️ 有差异/有问题`:已经实现一部分,但仍与 WXRead 有结构差异,或仍有已知问题
|
|
||||||
> - `❌ 未实现`:当前代码中仍缺失
|
|
||||||
> - `❌ 明确不实现`:经确认不纳入当前 EPUB 阅读器复刻范围
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 核心结论
|
|
||||||
|
|
||||||
ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链路已经收口到:
|
|
||||||
|
|
||||||
- CoreText 页面直绘
|
|
||||||
- `RDEPUBTextLayouter` 分页
|
|
||||||
- 5 层 CSS 级联
|
|
||||||
- `<link>` 样式表内联
|
|
||||||
- 页面级 hit test / 选区 / 高亮 / 批注
|
|
||||||
- 字符锚点与 `fileIndex/row/column` 语义的完整位置模型
|
|
||||||
|
|
||||||
核查结论:ReadViewSDK 已经完成 EPUB 阅读器主链路的大部分复刻,但当前代码里仍有少量“能力面已接入、实现路径未完全等价”的差异。和 2026-05-24 版本文档相比,当前最需要重新标注的是:
|
|
||||||
|
|
||||||
1. 文本分页主路径现已真正消费多栏 path,并补上 `avoidOrphans` / `avoidWidows` / `hyphenation` 行为
|
|
||||||
2. 代码仍保留若干 fallback / degrade 路径,与 WXRead 的单一主链路实现方式不同
|
|
||||||
3. `replaceForMPChapter.css`、繁简转换、TTS / DRM / Pencil 仍属于不适用或明确不实现范围
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 核查摘要
|
|
||||||
|
|
||||||
### ✅ 已实现
|
|
||||||
|
|
||||||
- 文本页已经改成页面级 CoreText 直绘。
|
|
||||||
- `RDEPUBTextLayouter` 已接入 `RDEPUBTextBookBuilder` / `RDPlainTextBookBuilder`。
|
|
||||||
- 4 级语义断页、`avoidPageBreakInside` 页尾回退、分页缓存都已经落地。
|
|
||||||
- 5 层 CSS 级联和 EPUB `<link>` 外部样式表内联都已经落地。
|
|
||||||
- CoreText 页面 hit test、长按选区、菜单锚点、复制/高亮/批注主链路已经接入页面几何。
|
|
||||||
- CoreText 路径的高亮、注释、搜索命中已经走页面装饰层,而不是继续改正文布局真值。
|
|
||||||
- WebView 路径的标注绘制、搜索高亮已经改成“JS 产出 rect,原生 overlay 负责 CGContext 绘制”,不再依赖 DOM 包裹着色。
|
|
||||||
- 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext`、`weread-page-relate` 页首借行这几类 WXRead 风格规则。
|
|
||||||
- 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。
|
|
||||||
- `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。
|
|
||||||
- CoreText 分页路径现已消费多栏 layout path,不再把 `numberOfColumns` 只停留在配置层。
|
|
||||||
- `avoidOrphans`、`avoidWidows`、`hyphenation` 现已接入实际排版/分页行为,而不是仅存在于配置模型。
|
|
||||||
- `RDEPUBChapterData` 已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义,章节模型主链路已经收口。
|
|
||||||
|
|
||||||
### ⚠️ 有差异/有问题
|
|
||||||
|
|
||||||
- 仍保留降级链路:`RDEPUBDTCoreTextRenderer` 在 `DTCoreText` 不可用或构建失败时会退回 HTML 纯文本渲染;`RDURLReaderController` 在纯文本分页失败时会退回 `UITextView` 展示。这说明 ReadViewSDK 仍不是 WXRead 那种完全单一路径的生产态实现。
|
|
||||||
- `RDReaderGestureController` 仍是未接入的占位组件,实际点击分区逻辑直接落在 `RDReaderView`,与 WXRead 更完整的翻页/手势控制器拆分相比,宿主层职责仍稍偏重。
|
|
||||||
|
|
||||||
### ❌ 未实现
|
|
||||||
|
|
||||||
- 繁简转换(明确不实现)
|
|
||||||
- TTS / DRM / Pencil(明确不实现)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 渲染路径对比
|
|
||||||
|
|
||||||
| 维度 | 读书 | ReadViewSDK | 核查 |
|
|
||||||
|------|---------|-------------|------|
|
|
||||||
| EPUB 文本渲染 | `WRPageView.drawRect:` → `CTFrameDraw` 到 CGContext | `RDEPUBTextContentView` → `RDEPUBDirectCoreTextPageView.draw(_:)` + `DTCoreTextLayoutFrame.draw(in:)` 页面级直绘 | ✅ 已实现 |
|
|
||||||
| WebView 路径 | WKWebView(公众号/文集文章) | WKWebView(interactive/fixed-layout EPUB) | ✅ 已实现 |
|
|
||||||
| 文本选区 | `CTLineGetStringIndexForPosition` 坐标级 hit test | `RDEPUBPageInteractionController` + `RDEPUBSelectionOverlayView` 已接入主链路 | ✅ 已实现 |
|
|
||||||
| 标注绘制 | `CTFrame` 层叠加绘制(CGContext) | CoreText / WebView 两条链路都已收口到原生页面装饰层绘制 | ✅ 已实现 |
|
|
||||||
| 搜索高亮 | `WRCoreTextLayoutFrame.highlightSearchResults:` 直接绘制 | CoreText / WebView 两条链路都已统一为原生页面装饰对象绘制 | ✅ 已实现 |
|
|
||||||
|
|
||||||
**结论:** 渲染层里“页面级绘制 + 页面装饰对象收口”这一块已经完成,页面几何回查也已经补到与 WXRead 同一路径的页面快照闭环。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 分页引擎对比
|
|
||||||
|
|
||||||
| 维度 | 读书 | ReadViewSDK | 核查 |
|
|
||||||
|------|---------|-------------|------|
|
|
||||||
| 分页核心 | `WRCoreTextLayouter` + `WRCoreTextLayoutFrame` | `RDEPUBTextLayouter` + `RDEPUBTextLayoutFrame` | ✅ 已实现 |
|
|
||||||
| 断页策略 | 4 级语义断页 | 已启用 4 级语义断页 | ✅ 已实现 |
|
|
||||||
| `avoidPageBreakInside` | 页尾最多回退若干行避免破碎 | 已实现页尾最多回退 3 行 | ✅ 已实现 |
|
|
||||||
| inline footnote | 行内脚注不应触发整段挪页 | 已修正为仅块级 attachment 参与整块挪页 | ✅ 已实现 |
|
|
||||||
| 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 |
|
|
||||||
| `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 |
|
|
||||||
| 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 |
|
|
||||||
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并已接入多栏 path、孤行/寡行保护与 hyphenation 行为 | ✅ 已实现 |
|
|
||||||
| 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 |
|
|
||||||
|
|
||||||
**结论:** 分页主链路已经按 WXRead 的主要思路收口;当前剩余差异不再集中在分页配置或分页算法本身,而更多落在 fallback 路径和宿主层实现细节。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. CSS 处理对比
|
|
||||||
|
|
||||||
| 维度 | 读书 | ReadViewSDK | 核查 |
|
|
||||||
|------|---------|-------------|------|
|
|
||||||
| CSS 级联层级 | 5 级:`default < replace < dark < epub < user` | `RDEPUBTextStyleSheetPackage` 已实际生成并注入 5 层 | ✅ 已实现 |
|
|
||||||
| EPUB `<link>` CSS | 解析前级联合并外部样式 | 已实现抽取、内联、URL 重写与诊断 | ✅ 已实现 |
|
|
||||||
| 自定义 CSS 属性 | `wr-vertical-center-style`、`weread-page-relate`、断页相关私有属性 | 已按 WXRead 语义接入 `wr-vertical-center-style`、`weread-page-relate`、`page-break-* / break-*`、`avoidPageBreakInside` 与 attachment placement | ✅ 已实现 |
|
|
||||||
| WXRead 私有 CSS 文件 | 使用 WeRead 自带 `default/replace/dark/...` | 已把 `default/replace/dark` 作为 SDK 内置资源镜像注入 5 层级联 | ✅ 已实现 |
|
|
||||||
| `replaceForLatinLanguageBook.css` | 拉丁语言专项字体规则 | 已按章节语言 / 正文拉丁字符占比自动切换 Latin replace 层 | ✅ 已实现 |
|
|
||||||
|
|
||||||
**结论:** CSS 主机制、私有资源层和拉丁语言分支都已经按 WXRead 路径收口;这一节当前不再是剩余差距。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 位置模型对比
|
|
||||||
|
|
||||||
| 维度 | 读书 | ReadViewSDK | 核查 |
|
|
||||||
|------|---------|-------------|------|
|
|
||||||
| 主模型 | `WREpubPositionConverter` `(fileIndex, row, column)` | `RDEPUBTextPositionConverter` + `RDEPUBTextAnchor/rangeAnchor`,已补齐全书字符位置与页码双向转换 | ✅ 已实现 |
|
|
||||||
| 恢复精度 | 字符级双向恢复 | 当前位置、选区、高亮、搜索命中都已优先走 `rangeAnchor` / `fileIndex-row-column` 恢复,`progression` 仅作回退 | ✅ 已实现 |
|
|
||||||
| 旧数据兼容 | 兼容历史位置模型 | 已兼容旧 `spineIndex` 等旧字段解码 | ✅ 已实现 |
|
|
||||||
| 全量双向转换 | 文件位置 <-> 全书字符位置 <-> 页码 | `RDEPUBTextPositionConverter` / `RDEPUBTextIndexTable` 已补齐 `fileIndex,row,column <-> chapterOffset <-> globalOffset <-> pageNumber <-> location` | ✅ 已实现 |
|
|
||||||
|
|
||||||
**结论:** 位置模型这条链路已经按 WXRead 的 `PositionConverter` 思路收口,当前不再是位置恢复能力缺失的问题。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 章节数据模型对比
|
|
||||||
|
|
||||||
| 维度 | 读书 | ReadViewSDK | 核查 |
|
|
||||||
|------|---------|-------------|------|
|
|
||||||
| 核心模型 | `WRChapterData` 一体化承载渲染结果 + 标注 + 搜索 + 目录 | `RDEPUBChapterData` 现已统一承载章节分页结果、位置查询、搜索/高亮回查与目录语义 | ✅ 已实现 |
|
|
||||||
| 页内高亮查询 | 章节对象直接提供 | `RDEPUBChapterData.highlights(on:from:)` 已提供 | ✅ 已实现 |
|
|
||||||
| 页内搜索查询 | 章节对象直接提供 | `RDEPUBChapterData.searchResults(on:from:)` 已提供 | ✅ 已实现 |
|
|
||||||
| 页码/位置查询 | 章节对象内聚 | `RDEPUBChapterData` 已统一提供 chapter/page/location/searchMatch 双向回查 | ✅ 已实现 |
|
|
||||||
| 目录与章节语义 | 基于章节数据统一管理 | 已由 `RDEPUBChapterData` 统一提供 TOC 命中、主目录项解析与章节页码定位 | ✅ 已实现 |
|
|
||||||
|
|
||||||
**结论:** 章节对象这条链路已经按 WXRead 的 `WRChapterData` 思路收口,章节分页、位置、搜索、高亮与目录语义现在都围绕 `RDEPUBChapterData` 统一组织。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 翻页控制器与宿主层对比
|
|
||||||
|
|
||||||
| 维度 | 读书 | ReadViewSDK | 核查 |
|
|
||||||
|------|---------|-------------|------|
|
|
||||||
| 翻页动画 | curl / slide / fade / none | 已有多种翻页模式 | ✅ 已实现 |
|
|
||||||
| `UIPageViewController` crash patch | 3 个以上专项 patch | 已补 pageCurl 异常检测、转场期间跳转排队、动画后校验与异步重建恢复 | ✅ 已实现 |
|
|
||||||
| 预加载 | `WRForecastUtils` 预测 + 后台准备 | 已围绕当前页、当前 spread 和可见方向预热相邻页视图,并在布局环境变化时裁剪缓存 | ✅ 已实现 |
|
|
||||||
| 预测翻页 | 基于用户方向预测预建内容 | 已按手势/滚动方向和 pending target 预测下一屏,优先向预测方向额外预热一屏 | ✅ 已实现 |
|
|
||||||
| 显示切换一致性 | 翻页模式/单双页/方向切换稳定恢复 | 已补切换前位置快照、切换后按 location 恢复,以及横竖屏过渡期间的延迟恢复链路 | ✅ 已实现 |
|
|
||||||
|
|
||||||
**结论:** 翻页控制器与宿主层这条链路已经按 WXRead 的主实现思路收口,pageCurl 稳定性、预测预加载和显示切换恢复现在都走统一闭环。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 资源体系对比
|
|
||||||
|
|
||||||
### CSS
|
|
||||||
|
|
||||||
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|
|
||||||
|------|-------------|-----------------|------|
|
|
||||||
| `default.css` | 基础 HTML 标签样式 | 已以内置资源镜像接入 | ✅ 已实现 |
|
|
||||||
| `replace.css` | 标题、图片、引用、分页控制 | 已以内置资源镜像接入 | ✅ 已实现 |
|
|
||||||
| `dark.css` | 暗色主题 | 已以内置资源镜像接入,并保留主题色覆盖层 | ✅ 已实现 |
|
|
||||||
| `replaceForLatinLanguageBook.css` | 拉丁语言字体 | 已接入章节语言自动切换 | ✅ 已实现 |
|
|
||||||
| `replaceForMPChapter.css` | 公众号文章专用 | EPUB 主链路不需要 | ✅ EPUB 主链路不适用 |
|
|
||||||
|
|
||||||
### JS
|
|
||||||
|
|
||||||
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|
|
||||||
|------|-------------|-----------------|------|
|
|
||||||
| `weread-highlighter.js` | Web 高亮引擎 | `epub-bridge.js` 现只负责 rect 解析与桥接,最终由原生 overlay 绘制 | ✅ 已实现 |
|
|
||||||
| `rangy-*` | 选区/高亮库 | CoreText 路径不需要;`webInteractive` / `webFixedLayout` 已接入 `rangy-core.js` + `rangy-serializer.js` 等价层,统一负责 DOM range 序列化与 iframe 文档回放 | ✅ 已实现 |
|
|
||||||
| `cssInjector.js` | 动态 CSS 注入 | 已接入 `cssInjector.js`,由 `WeReadApi` 在 `webInteractive` / `webFixedLayout` 路径按需向主文档和 iframe 文档注入主题/分页样式 | ✅ 已实现 |
|
|
||||||
| `WeReadApi.js` | JS-Native 桥接 | 已接入 `WeReadApi.js`,并由 `window.WeReadApi` 统一封装分页、主题、搜索、高亮和 progression bridge | ✅ 已实现 |
|
|
||||||
|
|
||||||
**结论:** CoreText 路径不需要 `rangy` / `cssInjector` / `WeReadApi`;但 `webInteractive` 和 `webFixedLayout` 两条 Web 路径都需要。当前资源体系已经把这套 Web 资源层完整接回 SDK,JS 侧职责也已按 WXRead 思路收口。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 缺失能力清单
|
|
||||||
|
|
||||||
| 能力 | 核查 | 当前优先级 |
|
|
||||||
|------|------|-----------|
|
|
||||||
| 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ✅ 已实现 | - |
|
|
||||||
| `WRBookmark` 统一标注模型 | ✅ 已实现 | - |
|
|
||||||
| 多栏排版 | ✅ 已实现 | - |
|
|
||||||
| `avoidOrphans / avoidWidows / hyphenation` 行为落地 | ✅ 已实现 | - |
|
|
||||||
| 纯文本/渲染失败 fallback 清理 | ⚠️ 仍保留纯文本 fallback 路径 | 低 |
|
|
||||||
| 字体动态加载 | ✅ 已实现 | - |
|
|
||||||
| 繁简转换 | ❌ 明确不实现 | - |
|
|
||||||
| TTS / DRM / Pencil | ❌ 明确不实现 | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 收口路线(整合原修复计划)
|
|
||||||
|
|
||||||
### P1:页面几何与分页行为闭环
|
|
||||||
|
|
||||||
- `RDEPUBPageLayoutSnapshot` 已补齐 line/run/attachment/contentBounds 与 absoluteRange 命中闭环
|
|
||||||
- overlay / decoration / selection 回查已经统一到页面快照层
|
|
||||||
- 问题书分页细节已回收到现有分页规则,不再构成架构缺口
|
|
||||||
|
|
||||||
当前状态:`✅ 已实现`
|
|
||||||
|
|
||||||
### P2:位置模型与章节数据内聚
|
|
||||||
|
|
||||||
- `rangeAnchor` / `fileIndex/row/column` 双向位置转换已完成
|
|
||||||
- `RDEPUBChapterData` 章节语义内聚已完成
|
|
||||||
- `RDEPUBAnnotation` 已把书签/高亮统一收口到单一标注语义层
|
|
||||||
|
|
||||||
当前状态:`✅ 已实现`
|
|
||||||
|
|
||||||
### P3:宿主层稳定性
|
|
||||||
|
|
||||||
- 宿主层 pageCurl 修复、预测预加载和显示切换恢复已完成
|
|
||||||
- 剩余工作不再是主链路缺失,而是问题书和极端交互场景的持续回归
|
|
||||||
|
|
||||||
当前状态:`✅ 已实现`
|
|
||||||
|
|
||||||
### P4:外围能力
|
|
||||||
|
|
||||||
- 多栏阅读器开关 / UI 暴露已完成
|
|
||||||
- EPUB 内嵌字体动态注册已完成
|
|
||||||
- 繁简转换、TTS / DRM / Pencil 明确不实现,不纳入收口范围
|
|
||||||
|
|
||||||
当前状态:`✅ 范围内已实现`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 补充说明:本次复核新增结论(2026-06-02)
|
|
||||||
|
|
||||||
和上一篇版本相比,当前最重要的修正不是“新增缺很多能力”,而是把原先写得过满的结论收回来:
|
|
||||||
|
|
||||||
- `RDEPUBTextLayoutConfig` 这一轮已经从“配置面补齐”推进到“行为面落地”,`avoidOrphans`、`avoidWidows`、`hyphenation` 不再只是模型字段。
|
|
||||||
- 多栏能力这一轮已经接到文本 `CoreText` 分页主路径,`numberOfColumns` 不再只影响 Web/CSS 与设置入口。
|
|
||||||
- 文本阅读主链路虽然已经不再依赖旧 `UITextView`,但仓库中仍保留 `DTCoreText` 不可用时的纯文本 fallback,以及纯文本分页失败时的 `UITextView` fallback,应视作与 WXRead 的实现差异,而不是主链路能力。
|
|
||||||
- 公众号文章专用 `replaceForMPChapter.css` 仍不属于当前 EPUB 主链路缺口,应继续按“不适用”记录,而不是“待补齐”。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 读书关键类职责速查
|
|
||||||
|
|
||||||
| 类名 | 职责 | ReadViewSDK 对应 | 核查 |
|
|
||||||
|------|------|-----------------|------|
|
|
||||||
| `WRReaderViewController` | 阅读器主控制器 | `RDEPUBReaderController` | ✅ 已有对应 |
|
|
||||||
| `WRPageViewController` | 翻页控制器 + crash patch | `RDReaderView` | ✅ 已实现 |
|
|
||||||
| `WRPageView` | CoreText 直接绘制页面 | `RDEPUBTextContentView` + `RDEPUBDirectCoreTextPageView` | ✅ 已实现 |
|
|
||||||
| `WREpubTypesetter` | HTML → NSAttributedString(CSS 级联) | `RDEPUBDTCoreTextRenderer` | ✅ 已实现 |
|
|
||||||
| `WRCoreTextLayouter` | CoreText 排版引擎 | `RDEPUBTextLayouter` | ✅ 已实现 |
|
|
||||||
| `WRCoreTextLayoutFrame` | 排版帧(绘制/选区/搜索/装饰) | `RDEPUBTextLayoutFrame` + snapshot/interaction/overlay | ✅ 已实现 |
|
|
||||||
| `WRChapterData` | 章节数据模型 | `RDEPUBChapterData` | ✅ 已实现 |
|
|
||||||
| `WRChapterPageCount` | 分页计算 + 缓存 key | `RDEPUBTextBookBuilder` + `RDEPUBTextBookCache` | ✅ 已实现 |
|
|
||||||
| `WREpubPositionConverter` | 字符级位置双向转换 | `RDEPUBTextPositionConverter` + `RDEPUBTextIndexTable` | ✅ 已实现 |
|
|
||||||
| `WRBookmark` | 标注统一模型 | `RDEPUBAnnotation`(兼容 `RDEPUBHighlight` / `RDEPUBBookmark`) | ✅ 已实现 |
|
|
||||||
| `DTHTMLAttributedStringBuilder` | HTML DOM → NSAttributedString | 同(复用 DTCoreText) | ✅ 已实现 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 当前数据流
|
|
||||||
|
|
||||||
```text
|
|
||||||
EPUB 文件
|
|
||||||
-> RDEPUBParser.parse (container.xml -> OPF -> spine)
|
|
||||||
-> readingProfile 分流:
|
|
||||||
textReflowable:
|
|
||||||
-> RDEPUBDTCoreTextRenderer
|
|
||||||
-> 5 层 CSS 级联
|
|
||||||
-> <link> CSS 内联
|
|
||||||
-> 自定义分页语义注入
|
|
||||||
-> RDEPUBTextBookBuilder
|
|
||||||
-> RDEPUBTextLayouter
|
|
||||||
-> RDEPUBTextBookCache
|
|
||||||
-> RDEPUBTextContentView
|
|
||||||
-> RDEPUBDirectCoreTextPageView
|
|
||||||
-> RDEPUBPageInteractionController
|
|
||||||
-> RDEPUBSelectionOverlayView
|
|
||||||
webInteractive:
|
|
||||||
-> RDEPUBPaginator
|
|
||||||
-> RDEPUBWebContentView
|
|
||||||
-> rangy-core.js / rangy-serializer.js
|
|
||||||
-> cssInjector.js
|
|
||||||
-> WeReadApi.js
|
|
||||||
-> epub-bridge.js
|
|
||||||
webFixedLayout:
|
|
||||||
-> RDEPUBWebContentView
|
|
||||||
-> rangy-core.js / rangy-serializer.js
|
|
||||||
-> cssInjector.js
|
|
||||||
-> WeReadApi.js
|
|
||||||
-> epub-bridge.js
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*分析基础:读书 v10.0.3 (Build 79) 逆向文档*
|
|
||||||
*当前代码核查日期:2026-05-24*
|
|
||||||
617
Doc/阅读器功能开发计划.md
617
Doc/阅读器功能开发计划.md
@ -1,617 +0,0 @@
|
|||||||
# 阅读器功能开发计划
|
|
||||||
|
|
||||||
> 本文档整合了渲染质量三方对比(ReadViewSDK vs WXRead)与功能开发计划,作为阅读器能力演进的单一真值。
|
|
||||||
> 基于读书 v10.0.3 逆向分析,与当前 ReadViewSDK 代码核查结果整合。
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
渲染内核的架构对齐解决的是"代码可维护性"问题。本文档梳理从"能用的阅读器"到"能上架的商业阅读器"还需要补齐哪些能力,每项能力标注三方状态和具体实施计划。
|
|
||||||
|
|
||||||
### 三方对比总览(渲染质量)
|
|
||||||
|
|
||||||
| 缺失项 | 严重度 | ReadViewSDK | WXRead | 差距说明 |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| **竖排文字** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `writing-mode` 支持 |
|
|
||||||
| **Ruby 注音** | 高 | ❌ 未实现 | ❌ 未实现 | 双方均无 `<ruby>`/`<rt>` 处理 |
|
|
||||||
| **数学公式** | 中 | ❌ 未实现 | ⚠️ 仅字体回退 | 非原生渲染范畴,需 WebView 回退 |
|
|
||||||
| **复杂图文分页质量** | 高 | ✅ 已实现 | ✅ 已实现 | 双方均已实现 |
|
|
||||||
| **字体选择器** | 中 | ✅ 已实现 | ⚠️ 管线完备,UI 未见 | 已支持四档字体选择 |
|
|
||||||
| **连字/断字** | 中 | ⚠️ 仅配置标记 | ⚠️ 仅属性声明 | 双方均未实际实现 |
|
|
||||||
| **多语言排版回退** | 中 | ⚠️ 语言检测有,简繁转换无 | ✅ 已实现 | WXRead 额外有简繁转换 |
|
|
||||||
|
|
||||||
### ReadViewSDK 领先 WXRead 的项
|
|
||||||
|
|
||||||
| 项 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| **`keepWithNext`** | 已实现 `trimmedRangeForKeepWithNext`,WXRead 反编译代码中未找到对应实现 |
|
|
||||||
|
|
||||||
### 不需要对标 WXRead 的项
|
|
||||||
|
|
||||||
以下项 WXRead 自身也未实现,不属于必须补齐的能力:竖排文字、Ruby 注音、数学公式、Hyphenation 断字。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、渲染质量
|
|
||||||
|
|
||||||
### 1. 字体选择器
|
|
||||||
|
|
||||||
**当前状态**:✅ 基础能力已完成。
|
|
||||||
|
|
||||||
已支持系统、宋体、圆体、等宽四档字体选择;设置项可持久化;切换字体会触发重新分页;分页缓存签名已包含字体信息,避免不同字体复用旧缓存。
|
|
||||||
|
|
||||||
**已落地文件清单**:
|
|
||||||
|
|
||||||
| 文件 | 改动 | 状态 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `RDEPUBReaderConfiguration.swift` | 新增 `RDEPUBReaderFontChoice`,配置增加 `fontChoice` | 已完成 |
|
|
||||||
| `RDEPUBReaderSettings.swift` | 新增 `fontChoice` 持久化,更新 `applying(to:)` 和 `capture(configuration:brightness:)` | 已完成 |
|
|
||||||
| `RDEPUBReaderContext.swift` | `currentTextRenderStyle()` 改用 `configuration.fontChoice.font(ofSize:)` | 已完成 |
|
|
||||||
| `RDURLReaderController.swift` | URL 阅读器同步使用 `fontChoice` 生成文字样式 | 已完成 |
|
|
||||||
| `RDEPUBReaderController+RuntimeBridge.swift` | `requiresRepagination(from:to:)` 增加 `fontChoice` 变更检查 | 已完成 |
|
|
||||||
| `RDEPUBPaginationCacheCoordinator.swift` | 分页缓存签名增加字体名 | 已完成 |
|
|
||||||
| `RDEPUBReaderSettingsViewController.swift` | 新增 `onFontChoiceChange` 回调、字体分段控件和 accessibility identifier | 已完成 |
|
|
||||||
| `RDEPUBReaderChromeCoordinator.swift` | 设置面板接线字体变更回调 | 已完成 |
|
|
||||||
| `SettingsPanelTests.swift` | UI 自动化覆盖字体选择 | 已完成 |
|
|
||||||
|
|
||||||
**当前验收结果**:
|
|
||||||
- ✅ 切换字体后触发重排。
|
|
||||||
- ✅ 字体选择可随阅读器设置持久化。
|
|
||||||
- ✅ 分页缓存按字体隔离。
|
|
||||||
- ✅ UI 自动化已覆盖设置面板字体选择。
|
|
||||||
|
|
||||||
**后续增强计划**:
|
|
||||||
1. 引入更多内置字体包(建议:思源宋体、思源黑体、方正书宋、方正兰亭黑、Lora、OpenDyslexic)。
|
|
||||||
2. 使用 `CTFontManagerRegisterFontsForURL` 注册 bundle 字体。
|
|
||||||
3. 字体列表从枚举升级为资源驱动模型,每项包含 `displayName`、`fontName`、`previewText`、可用性状态。
|
|
||||||
4. 字体选择 UI 从分段控件升级为横向预览卡片,展示真实字体效果。
|
|
||||||
5. 增加字体资源加载失败兜底,失败时回退系统字体并保留用户设置。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 暗色模式图片处理
|
|
||||||
|
|
||||||
**当前状态**:✅ 基础能力已完成。
|
|
||||||
|
|
||||||
暗色主题下会对正文图片做显示副本调暗,降低白底图片在深色背景上的刺眼程度。处理只作用于展示层,不修改分页原始内容;封面和小图会跳过,避免误处理封面、图标和装饰图。
|
|
||||||
|
|
||||||
**已落地文件清单**:
|
|
||||||
|
|
||||||
| 文件 | 改动 | 状态 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `RDEPUBReaderConfiguration.swift` | 新增 `darkImageAdjustmentEnabled` 和 `darkImageBlendRatio` | 已完成 |
|
|
||||||
| `RDEPUBReaderController+RuntimeBridge.swift` | 主题和暗色图片配置变更触发可见页刷新 | 已完成 |
|
|
||||||
| `RDEPUBTextContentView.swift` | DTCoreText 展示内容增加暗色图片显示副本处理 | 已完成 |
|
|
||||||
| `RDEPUBTextContentView.swift` | 增加暗色图片缓存,避免重复生成 | 已完成 |
|
|
||||||
| `SettingsPanelTests.swift` | UI 自动化覆盖暗色主题切换 | 已完成 |
|
|
||||||
|
|
||||||
**当前实现策略**:
|
|
||||||
- 仅当背景亮度低于阈值、配置开启、混合比例大于 0 时启用。
|
|
||||||
- 仅处理正文 inline image attachment。
|
|
||||||
- 跳过封面和小于 80x80 的图片。
|
|
||||||
- 使用 `NSCache` 缓存处理后的图片。
|
|
||||||
- 使用主题背景色按比例覆盖原图,透明区域保持透明。
|
|
||||||
|
|
||||||
**当前验收结果**:
|
|
||||||
- ✅ 暗色模式下图片不再完全以原始亮色块直出。
|
|
||||||
- ✅ 图片内容仍保持可辨认,不做反色。
|
|
||||||
- ✅ 处理结果有缓存。
|
|
||||||
- ✅ UI 自动化已覆盖暗色主题入口。
|
|
||||||
|
|
||||||
**后续增强计划**:
|
|
||||||
1. 增加真实 EPUB 图片样本的截图回归测试。
|
|
||||||
2. 按图片平均亮度自适应 `darkImageBlendRatio`。
|
|
||||||
3. 增加用户侧开关,允许关闭暗色图片处理。
|
|
||||||
4. 如果后续恢复 WebView 路径,再补充 CSS filter 策略。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 简繁转换
|
|
||||||
|
|
||||||
**当前状态**:零实现。语言元数据已提取(`publication.metadata.language`),但仅用于拉丁/CJK CSS 分轨。
|
|
||||||
|
|
||||||
**WXRead 实现参考**:
|
|
||||||
- `WREpubTypesetter` 检测 `book.language` 含 `Hant`/`TW`/`HK` 时调用 `_WRConvertHansToHantIfNeeded()`
|
|
||||||
- 使用 `CFStringTransform` 两步转换:Hans → Latin → Hant
|
|
||||||
|
|
||||||
**实施步骤**:
|
|
||||||
|
|
||||||
**Step 1:转换工具**
|
|
||||||
1. 新增 `RDEPUBTextChineseConverter.swift`,提供:
|
|
||||||
```swift
|
|
||||||
enum RDEPUBChineseScript { case hans, hant }
|
|
||||||
|
|
||||||
func convert(_ text: String, to script: RDEPUBChineseScript) -> String
|
|
||||||
```
|
|
||||||
2. 实现使用 `CFStringTransform`:
|
|
||||||
```swift
|
|
||||||
let mutable = NSMutableString(string: text) as CFMutableString
|
|
||||||
CFStringTransform(mutable, nil, kCFStringTransformToLatin, false) // Hans → Pinyin
|
|
||||||
CFStringTransform(mutable, nil, kCFStringTransformStripDiacritics, false) // Pinyin → stripped
|
|
||||||
// 然后通过字典映射到繁体
|
|
||||||
```
|
|
||||||
或直接使用 Apple 的 `kCFStringTransformHansToHant`(如果可用)。
|
|
||||||
|
|
||||||
**Step 2:判断是否需要转换**
|
|
||||||
3. 在 `RDEPUBTextBookBuilder.build()` 或 `RDEPUBTextRendererSupport.makeChapterRenderRequest()` 中:
|
|
||||||
- 读取 `publication.metadata.language`
|
|
||||||
- 如果语言为 `zh-Hant`/`zh-TW`/`zh-HK` 且用户设置为简体,或语言为 `zh-Hans`/`zh-CN` 且用户设置为繁体,触发转换
|
|
||||||
4. 新增 `RDEPUBReaderConfiguration.chineseScript: RDEPUBChineseScript?`(nil = 跟随书籍)
|
|
||||||
|
|
||||||
**Step 3:应用转换**
|
|
||||||
5. 在 `normalizeReadingAttributes(in:style:)` 之后、返回 `RDEPUBTextChapterRenderRequest` 之前,对 attributed string 的文本内容执行转换
|
|
||||||
6. 需要保持 attributed string 的属性(字体、颜色、附件)不变,只替换字符
|
|
||||||
|
|
||||||
**验收标准**:
|
|
||||||
- 简体 EPUB 在繁体模式下显示繁体
|
|
||||||
- 繁体 EPUB 在简体模式下显示简体
|
|
||||||
- 转换不影响分页结果(转换后字符数可能变化,需验证)
|
|
||||||
- 高亮、搜索功能在转换后仍然正常
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 孤行/寡行控制
|
|
||||||
|
|
||||||
**当前状态**:`RDEPUBTextLayoutConfig` 声明了 `avoidOrphans`/`avoidWidows`(默认 true),但 `RDEPUBTextLayouter` 完全不读取这两个标记。
|
|
||||||
|
|
||||||
**实现原理**:
|
|
||||||
- **寡行(widow)**:段落最后一行单独出现在页底 → 应将该行拉到下一页
|
|
||||||
- **孤行(orphan)**:段落第一行单独出现在页首 → 应将前一页最后一行拉过来
|
|
||||||
- 通常只处理寡行(保留至少 2 行在页底),孤行处理会引发连锁重排
|
|
||||||
|
|
||||||
**实施步骤**:
|
|
||||||
|
|
||||||
**Step 1:CoreText 路径**
|
|
||||||
1. 在 `RDEPUBTextLayouter.layoutFramesUsingCoreText()` 的分页循环中(~行 63-137),在 `adjustedRange` 之后、追加 frame 之前,增加寡行检查:
|
|
||||||
```swift
|
|
||||||
if config.avoidWidows {
|
|
||||||
finalRange = trimmedRangeForAvoidWidows(
|
|
||||||
from: ctFrame,
|
|
||||||
proposed: finalRange,
|
|
||||||
lineRanges: lineRanges
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
2. `trimmedRangeForAvoidWidows` 实现:
|
|
||||||
- 从 proposed range 的最后一行向前检查
|
|
||||||
- 如果最后一行是一个段落的最后一行,且该段落在此页只有 1 行 → 回退到上一个段落边界
|
|
||||||
- 最多移除 2 行(避免过度收缩)
|
|
||||||
|
|
||||||
**Step 2:DTCoreText 路径**
|
|
||||||
3. 在 `layoutFramesUsingDTCoreText()` 中增加相同逻辑
|
|
||||||
|
|
||||||
**Step 3:段落边界检测**
|
|
||||||
4. 利用已有的 `paragraphRange(containing:)` 方法(`NSString.paragraphRange`)获取段落范围
|
|
||||||
5. 对比当前页最后一行的 range 和段落的 range,判断是否为段落唯一一行
|
|
||||||
|
|
||||||
**验收标准**:
|
|
||||||
- 分页页数可能轻微增加(1-3 页),但排版质量提升
|
|
||||||
- 已知的寡行问题页在修复后不再出现段落最后一行孤悬页底
|
|
||||||
- `avoidOrphans`/`avoidWidows = false` 时行为不变
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Hyphenation 断字
|
|
||||||
|
|
||||||
**当前状态**:`hyphenation: Bool = true` 配置标记已声明,但 `NSParagraphStyle.hyphenationFactor` 从未设置。CoreText 路径更不支持断字。
|
|
||||||
|
|
||||||
**技术约束**:
|
|
||||||
- `NSParagraphStyle.hyphenationFactor` 对 `UITextView`(降级路径)有效
|
|
||||||
- CoreText 直绘路径需要设置 `kCTParagraphStyleSpecifierHyphenationFactor`
|
|
||||||
- 中文场景断字影响较小(中文无空格分词),主要改善西文排版
|
|
||||||
|
|
||||||
**实施步骤**:
|
|
||||||
|
|
||||||
**Step 1:UITextView 降级路径**
|
|
||||||
1. 在 `RDEPUBTextRendererSupport.paragraphStyle(lineSpacing:)`(行 929-934)中增加:
|
|
||||||
```swift
|
|
||||||
style.hyphenationFactor = 1.0
|
|
||||||
```
|
|
||||||
2. 在 `normalizeReadingAttributes()`(行 137-143)中,对已有的 `NSMutableParagraphStyle` 增加:
|
|
||||||
```swift
|
|
||||||
paragraphStyle.hyphenationFactor = 1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2:CoreText 直绘路径**
|
|
||||||
3. 在 `RDEPUBTextLayouter` 的 frame 构造中,对 `CTParagraphStyle` 增加 hyphenation factor:
|
|
||||||
```swift
|
|
||||||
var hyphenationFactor: Float = 1.0
|
|
||||||
let settings = [CTParagraphStyleSetting(
|
|
||||||
spec: .hyphenationFactor,
|
|
||||||
valueSize: MemoryLayout<Float>.size,
|
|
||||||
value: &hyphenationFactor
|
|
||||||
)]
|
|
||||||
let paragraphStyle = CTParagraphStyleCreate(settings, settings.count)
|
|
||||||
```
|
|
||||||
4. 将此 paragraph style 设置到 attributed string 的段落属性上
|
|
||||||
|
|
||||||
**Step 3:条件控制**
|
|
||||||
5. 读取 `config.hyphenation` 标记,为 false 时跳过上述设置
|
|
||||||
|
|
||||||
**验收标准**:
|
|
||||||
- 西文长单词在行末正确断字(显示连字符)
|
|
||||||
- 中文排版不受影响
|
|
||||||
- `hyphenation = false` 时回到当前行为
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、工程成熟度
|
|
||||||
|
|
||||||
### 6. 自动化测试
|
|
||||||
|
|
||||||
**当前状态**:✅ UI 自动化测试基础设施已完成,单元测试和分页回归基准待补。
|
|
||||||
|
|
||||||
已在 Demo 工程中接入 `ReadViewDemoUITests`,覆盖打开书籍、阅读器基础交互、顶部/底部工具栏、设置面板、字体选择和暗色主题切换。
|
|
||||||
|
|
||||||
**已落地内容**:
|
|
||||||
|
|
||||||
| 项 | 状态 |
|
|
||||||
| --- | --- |
|
|
||||||
| UI Test target `ReadViewDemoUITests` | ✅ 已完成 |
|
|
||||||
| Demo 自动打开测试 EPUB 的入口 | ✅ 已完成 |
|
|
||||||
| Accessibility identifier 体系 | ✅ 已完成 |
|
|
||||||
| 阅读器基础打开测试 | ✅ 已完成 |
|
|
||||||
| 顶部/底部工具栏测试 | ✅ 已完成 |
|
|
||||||
| 设置面板测试 | ✅ 已完成 |
|
|
||||||
| 字体选择 UI 测试 | ✅ 已完成 |
|
|
||||||
| 暗色主题切换 UI 测试 | ✅ 已完成 |
|
|
||||||
|
|
||||||
**当前验证结果**:
|
|
||||||
|
|
||||||
```text
|
|
||||||
xcodebuild build: BUILD SUCCEEDED
|
|
||||||
SettingsPanelTests: TEST SUCCEEDED
|
|
||||||
ReadViewDemoUITests 全量 8 个 UI 测试: TEST SUCCEEDED
|
|
||||||
```
|
|
||||||
|
|
||||||
**下一步实施步骤**:
|
|
||||||
|
|
||||||
**Step 1:单元测试基础设施**
|
|
||||||
1. 在 `ReadViewDemo.xcodeproj` 中新增 Unit Test target `ReadViewSDKTests`
|
|
||||||
2. 创建 `Tests/` 目录,配置 `import RDReaderView` 或通过 Demo target 暴露内部测试入口
|
|
||||||
3. 新增 `.xctestplan` 配置,把 UI 测试和单元测试拆分为不同测试组
|
|
||||||
|
|
||||||
**Step 2:分页回归测试(最高优先级)**
|
|
||||||
4. 新增 `RDEPUBPaginationRegressionTests.swift`,固定 6 类样本章节:
|
|
||||||
- 纯正文章节
|
|
||||||
- 图 + 图注 + 正文章节
|
|
||||||
- 脚注小图标章节
|
|
||||||
- 多段标题章节
|
|
||||||
- 大图跨页章节
|
|
||||||
- 长段落连续章节
|
|
||||||
5. 每个样本:构建 `RDEPUBTextBook`,断言总页数不变、已知页的 `contentRange` 不变
|
|
||||||
6. 使用 golden file 对比 `RDEPUBTextChapterPaginationDiagnostic` 输出
|
|
||||||
|
|
||||||
**Step 3:位置映射测试**
|
|
||||||
7. 新增 `RDEPUBLocationMappingTests.swift`:
|
|
||||||
- `location → pageNumber` 正向映射
|
|
||||||
- `pageNumber → location` 逆向映射
|
|
||||||
- 往返一致性断言
|
|
||||||
|
|
||||||
**Step 4:渲染管线测试**
|
|
||||||
8. 新增 `RDEPUBTypesetterTests.swift`:
|
|
||||||
- `normalizeHTML` 输入输出对比
|
|
||||||
- fragment marker 注入/提取一致性
|
|
||||||
- 语义标记注入后 `rdPage*` 属性计数不变
|
|
||||||
|
|
||||||
**Step 5:CI(可选)**
|
|
||||||
9. 配置 GitHub Actions 或 Jenkins,在 PR 时自动运行测试
|
|
||||||
|
|
||||||
**验收标准**:
|
|
||||||
- UI 自动化全量测试稳定通过
|
|
||||||
- 6 个分页基准样本的页数和 contentRange 稳定
|
|
||||||
- 位置映射往返测试通过
|
|
||||||
- 新 PR 触发测试自动运行
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. 性能基线
|
|
||||||
|
|
||||||
**当前状态**:`RDEPUBTextPerformanceSampler` 用 `CFAbsoluteTimeGetCurrent()` 采样,结果仅 `print()` 输出。无 Instruments 可视化、无内存监控、无阈值告警。
|
|
||||||
|
|
||||||
**实施步骤**:
|
|
||||||
|
|
||||||
**Step 1:os_signpost 集成**
|
|
||||||
1. 在 `RDEPUBTextPerformanceSampler` 中引入 `os.signpost`:
|
|
||||||
```swift
|
|
||||||
import os.signpost
|
|
||||||
let log = OSLog(subsystem: "com.rdreader", category: .pointsOfInterest)
|
|
||||||
```
|
|
||||||
2. 在 build/render/paginate 各阶段插入 `os_signpost(.begin, ...)` / `os_signpost(.end, ...)`
|
|
||||||
3. 这样 Instruments 的 Points of Interest 能直接可视化各阶段耗时
|
|
||||||
|
|
||||||
**Step 2:内存监控**
|
|
||||||
4. 新增 `RDEPUBMemoryMonitor`:
|
|
||||||
```swift
|
|
||||||
func currentMemoryFootprint() -> UInt64 // task_info.resident_size
|
|
||||||
```
|
|
||||||
5. 在 `RDEPUBTextBookBuilder.build()` 的每章循环中记录内存峰值
|
|
||||||
6. 在 `RDEPUBTextPerformanceSample` 中增加 `peakMemoryBytes: UInt64`
|
|
||||||
|
|
||||||
**Step 3:阈值告警**
|
|
||||||
7. 定义基准值:首屏 < 2s、单章渲染 < 500ms、全书构建 < 30s(按书的大小可调)
|
|
||||||
8. 超出阈值时通过 `os_log(.error, ...)` 输出警告
|
|
||||||
9. 在 Demo 中展示性能摘要面板
|
|
||||||
|
|
||||||
**Step 4:持久化**
|
|
||||||
10. 性能样本可选持久化到文件,供回归对比
|
|
||||||
|
|
||||||
**验收标准**:
|
|
||||||
- Instruments 能看到各阶段的 signpost 区间
|
|
||||||
- 内存峰值在大书(79MB 样本)构建过程中有记录
|
|
||||||
- 超出阈值时有日志输出
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. 崩溃防护
|
|
||||||
|
|
||||||
**当前状态**:`RDReaderView` 有 pageCurl 崩溃检测和异步恢复,但无全局异常捕获。
|
|
||||||
|
|
||||||
**实施步骤**:
|
|
||||||
|
|
||||||
**Step 1:NSException 捕获**
|
|
||||||
1. 新增 `RDEPUBCrashGuard` 工具类:
|
|
||||||
```swift
|
|
||||||
static func performSafely(_ block: () throws -> Void) rethrows
|
|
||||||
static func performWithObjCExceptionHandling(_ block: () -> Void) -> Bool
|
|
||||||
```
|
|
||||||
2. 在关键入口包裹 `@try/@catch`:
|
|
||||||
- `RDEPUBTextBookBuilder.build()` 的每章循环体
|
|
||||||
- `RDEPUBTextLayouter.layoutFrames()` 的分页循环
|
|
||||||
- `RDEPUBTextRendererSupport.makeChapterRenderRequest()` 的 HTML 处理
|
|
||||||
|
|
||||||
**Step 2:分页/渲染单章隔离**
|
|
||||||
3. 单章渲染/分页失败时,降级到空白页或上一次缓存结果,不中断全书构建
|
|
||||||
4. 在 `RDEPUBTextChapterPaginationDiagnostic` 中记录错误状态
|
|
||||||
|
|
||||||
**Step 3:全局信号处理(可选)**
|
|
||||||
5. 注册 `NSSetUncaughtExceptionHandler` 记录 ObjC 异常
|
|
||||||
6. 注册 signal handler(SIGABRT, SIGSEGV)记录崩溃现场
|
|
||||||
7. 下次启动时上报(如果后续有上报系统)
|
|
||||||
|
|
||||||
**验收标准**:
|
|
||||||
- 单章渲染异常不导致整个 build 中断
|
|
||||||
- pageCurl 崩溃场景已有保护,验证不退化
|
|
||||||
- 崩溃日志可追踪到具体章节和阶段
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. 内存管理
|
|
||||||
|
|
||||||
**当前状态**:无显式内存预算。无 `didReceiveMemoryWarning` 处理。`RDEPUBTextBookCache` 磁盘缓存无大小限制。
|
|
||||||
|
|
||||||
**实施步骤**:
|
|
||||||
|
|
||||||
**Step 1:内存警告响应**
|
|
||||||
1. 在 `RDEPUBReaderController` 中监听 `UIApplication.didReceiveMemoryWarningNotification`
|
|
||||||
2. 收到警告时:
|
|
||||||
- 清空 `RDReaderPreloadController` 的预加载缓存
|
|
||||||
- 通知 `RDEPUBTextBookCache` 的内存缓存(如果有)清空
|
|
||||||
- 释放非当前可见章节的渲染结果
|
|
||||||
|
|
||||||
**Step 2:章节级内存释放**
|
|
||||||
3. 在 `RDEPUBTextBookBuilder` 构建完成后,不再持有已构建章节的 `NSAttributedString`
|
|
||||||
4. 当前只持有 `RDEPUBTextBook`(包含 `[RDEPUBTextPage]` 的 NSRange),内存占用已较低
|
|
||||||
5. 如果后续引入 attributed string 缓存,需增加 LRU 淘汰策略
|
|
||||||
|
|
||||||
**Step 3:图片内存控制**
|
|
||||||
6. `RDReaderPreloadController` 预加载的 page view 数量与内存挂钩
|
|
||||||
7. 当内存压力时减少 `preloadRadius`(从 1 降到 0)
|
|
||||||
|
|
||||||
**验收标准**:
|
|
||||||
- 在大书(79MB)构建过程中收到内存警告时,app 不被系统杀掉
|
|
||||||
- 预加载缓存在内存压力下自动收缩
|
|
||||||
- 当前页内容不丢失
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 10. 增量构建
|
|
||||||
|
|
||||||
**当前状态**:全书一次性分页。`RDEPUBTextBookBuilder.build()` 遍历全部 spine item,输出完整 `RDEPUBTextBook`。
|
|
||||||
|
|
||||||
**实施步骤**:
|
|
||||||
|
|
||||||
**Step 1:章节级构建接口**
|
|
||||||
1. 在 `RDEPUBTextBookBuilder` 上新增:
|
|
||||||
```swift
|
|
||||||
func buildChapter(
|
|
||||||
at spineIndex: Int,
|
|
||||||
publication: RDEPUBPublication,
|
|
||||||
parser: RDEPUBParser,
|
|
||||||
pageSize: CGSize,
|
|
||||||
style: RDEPUBTextRenderStyle
|
|
||||||
) -> RDEPUBTextChapter?
|
|
||||||
```
|
|
||||||
2. 内部逻辑从 `build()` 的循环体中提取,单章可独立构建
|
|
||||||
|
|
||||||
**Step 2:按需加载**
|
|
||||||
3. `RDEPUBReaderLoadCoordinator` 在打开书时只构建当前章节和相邻 ±1 章
|
|
||||||
4. 用户翻到新章节时,后台异步构建该章节
|
|
||||||
5. 使用 `DispatchQueue` 串行队列保证线程安全
|
|
||||||
|
|
||||||
**Step 3:与缓存联动**
|
|
||||||
6. `RDEPUBPaginationCacheCoordinator` 支持单章缓存命中检查
|
|
||||||
7. 缓存命中时跳过构建,直接加载
|
|
||||||
|
|
||||||
**验收标准**:
|
|
||||||
- 大书首次打开时间缩短(只构建 3 章而非全书)
|
|
||||||
- 翻到未构建章节时无明显卡顿(后台预构建)
|
|
||||||
- 全书构建 API 保持不变(兼容现有调用方)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 11. 缓存管理
|
|
||||||
|
|
||||||
**当前状态**:`RDEPUBTextBookCache` 磁盘缓存无大小限制、无淘汰策略、无选择性失效。无内存缓存。
|
|
||||||
|
|
||||||
**实施步骤**:
|
|
||||||
|
|
||||||
**Step 1:磁盘缓存容量控制**
|
|
||||||
1. 新增 `maxDiskCacheSize: UInt64`(默认 200MB)
|
|
||||||
2. 在 `save(_:key:)` 写入后检查总大小
|
|
||||||
3. 超限时按修改时间删除最旧的缓存文件,直到低于阈值
|
|
||||||
|
|
||||||
**Step 2:选择性失效**
|
|
||||||
4. 新增 `invalidate(key:)` 方法,删除指定缓存文件
|
|
||||||
5. 当用户修改 `fontChoice` 或 `fontSize` 时,只失效当前书的缓存(而非 `invalidateAll`)
|
|
||||||
|
|
||||||
**Step 3:内存缓存(NSCache)**
|
|
||||||
6. 在 `RDEPUBTextBookCache` 上层增加 `NSCache<NSString, PaginationCacheArchive>`
|
|
||||||
7. `load(key:)` 先查内存缓存,miss 后查磁盘并回填
|
|
||||||
8. 内存警告时清空 NSCache
|
|
||||||
|
|
||||||
**Step 4:缓存统计持久化**
|
|
||||||
9. `RDEPUBTextBookCache` 新增 `cacheStats` 属性,记录总命中/缺失次数
|
|
||||||
10. 可用于调试和性能分析
|
|
||||||
|
|
||||||
**验收标准**:
|
|
||||||
- 磁盘缓存不超过设定上限
|
|
||||||
- 修改字号/字体后,旧缓存被正确失效
|
|
||||||
- 连续打开同一本书时,第二次命中内存缓存(< 1ms)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、可访问性
|
|
||||||
|
|
||||||
### 12. VoiceOver
|
|
||||||
|
|
||||||
**当前状态**:9 个 `accessibilityIdentifier`(仅用于 UI 测试),无 `accessibilityLabel`、`accessibilityHint`、`accessibilityTraits`。阅读内容区域无任何无障碍支持。
|
|
||||||
|
|
||||||
**实施步骤**:
|
|
||||||
|
|
||||||
**Step 1:工具栏无障碍(最低门槛)**
|
|
||||||
1. `RDEPUBReaderTopToolView`:
|
|
||||||
- `backButton.accessibilityLabel = "返回"`
|
|
||||||
- `bookmarkButton.accessibilityLabel = "书签"` + `accessibilityValue` 反映当前状态(已添加/未添加)
|
|
||||||
- `titleLabel.accessibilityLabel` = 书籍标题
|
|
||||||
2. `RDEPUBReaderBottomToolView`:
|
|
||||||
- 各按钮补充 `accessibilityLabel`("目录"、"书签列表"、"高亮列表"、"添加高亮"、"设置")
|
|
||||||
- 进度 slider 补充 `accessibilityValue`("第 X 页,共 Y 页")
|
|
||||||
|
|
||||||
**Step 2:设置面板**
|
|
||||||
3. `RDEPUBReaderSettingsViewController` 所有控件补充 label 和 traits
|
|
||||||
|
|
||||||
**Step 3:阅读内容区域(中等难度)**
|
|
||||||
4. `RDEPUBTextContentView` 设置 `isAccessibilityElement = true`
|
|
||||||
5. `accessibilityLabel` = 当前页纯文本内容
|
|
||||||
6. 翻页时发出 `UIAccessibility.pageScrolledNotification`
|
|
||||||
|
|
||||||
**Step 4:目录和高亮列表**
|
|
||||||
7. `RDEPUBReaderChapterListController` 列表项设置 `accessibilityLabel`(章节标题 + 页码)
|
|
||||||
8. `RDEPUBReaderHighlightsViewController` 列表项设置 `accessibilityLabel`(高亮文本 + 章节)
|
|
||||||
|
|
||||||
**验收标准**:
|
|
||||||
- VoiceOver 用户能完整导航工具栏
|
|
||||||
- 翻页时 VoiceOver 朗读新页内容
|
|
||||||
- 目录和高亮列表可被 VoiceOver 逐项朗读
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 13. Dynamic Type
|
|
||||||
|
|
||||||
**当前状态**:字号由 `RDEPUBReaderConfiguration.fontSize` 控制,不响应系统 `UIContentSizeCategory` 变化。
|
|
||||||
|
|
||||||
**实施步骤**:
|
|
||||||
|
|
||||||
1. 在 `RDEPUBReaderSettingsViewController` 中监听 `UIContentSizeCategory.didChangeNotification`
|
|
||||||
2. 当系统字体大小变化时,根据新的 `UIContentSizeCategory` 计算等效字号
|
|
||||||
3. 或者:在 `RDEPUBReaderConfiguration` 中增加 `useSystemFontSize: Bool`,启用时忽略 `fontSize`,使用系统推荐值
|
|
||||||
4. 字号映射表(参考 Apple 的 `preferredFont(forTextStyle:)` 返回值)
|
|
||||||
|
|
||||||
**验收标准**:
|
|
||||||
- 系统字体调大后,阅读器字号自动跟随
|
|
||||||
- 用户手动调节字号后,覆盖系统设置
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 14. 高对比度
|
|
||||||
|
|
||||||
**当前状态**:6 个固定主题预设,不跟随系统 `UIAccessibility.isDarkerSystemColorsEnabled`。
|
|
||||||
|
|
||||||
**实施步骤**:
|
|
||||||
|
|
||||||
1. `RDEPUBReaderTheme` 增加 `highContrastVariant: RDEPUBReaderTheme?` 属性
|
|
||||||
2. 监听 `UIAccessibility.darkerSystemColorsStatusDidChangeNotification`
|
|
||||||
3. 高对比度启用时,自动切换到高对比度主题变体(更大色彩对比度)
|
|
||||||
4. 或提供独立的"高对比度"主题预设
|
|
||||||
|
|
||||||
**验收标准**:
|
|
||||||
- 系统高对比度开启后,阅读器自动切换到高对比度主题
|
|
||||||
- 关闭后恢复原主题
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 15. DRM
|
|
||||||
|
|
||||||
**当前状态**:需求文档明确声明 DRM 不在 SDK 范围内。如果需要支持,建议通过以下方式:
|
|
||||||
|
|
||||||
**建议方案**:
|
|
||||||
|
|
||||||
1. 不在 SDK 内实现 DRM,而是通过 `RDEPUBParser` 的输入端控制:
|
|
||||||
- `RDEPUBParser` 接受 `Data` 或 `URL`,调用方可以先解密再传入
|
|
||||||
- 或新增 `RDEPUBDRMProvider` 协议:
|
|
||||||
```swift
|
|
||||||
protocol RDEPUBDRMProvider {
|
|
||||||
func decryptedData(for resourceURL: URL) -> Data?
|
|
||||||
func isProtected(_ resourceURL: URL) -> Bool
|
|
||||||
}
|
|
||||||
```
|
|
||||||
2. SDK 内的 `ss-reader://` URL scheme handler 在读取资源时调用 `DRMProvider`
|
|
||||||
3. 商业 DRM(如 Adobe ADEPT、Readium LCP)由调用方集成,SDK 提供接入点
|
|
||||||
|
|
||||||
**验收标准**:
|
|
||||||
- SDK 提供清晰的 DRM 接入协议
|
|
||||||
- 不引入 DRM 依赖,保持 SDK 轻量
|
|
||||||
- 调用方能通过协议接入自己的 DRM 方案
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、功能完整度(暂未展开)
|
|
||||||
|
|
||||||
以下功能需求已识别但暂未进入详细开发计划:
|
|
||||||
|
|
||||||
| 缺失项 | 严重度 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **书架/书库管理** | 高 | SDK 只能打开单本书,无书架 UI、阅读历史、分类管理 |
|
|
||||||
| **批注导出/分享** | 高 | 高亮/笔记只有本地存储,无导出、分享、复制到剪贴板 |
|
|
||||||
| **阅读统计** | 中 | 无阅读时长追踪、阅读速度、连续阅读天数 |
|
|
||||||
| **TTS 朗读** | 中 | 微信读书核心功能之一,当前无任何语音相关代码 |
|
|
||||||
| **全局搜索** | 中 | 当前搜索只在单本书内,无跨书搜索 |
|
|
||||||
| **离线/云端同步** | 高 | 无 iCloud/自建同步,阅读进度和笔记只在本地 |
|
|
||||||
| **夜间模式定时切换** | 低 | 有暗色主题但不能跟随系统或定时切换 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、按优先级排序的建议路线
|
|
||||||
|
|
||||||
### P0 — 影响商业发布
|
|
||||||
|
|
||||||
1. **分页回归基准** — UI 自动化测试已启动,下一步需要把分页结果、首屏时间、截图差异纳入回归基准
|
|
||||||
2. **字体选择器增强** — 基础字体选择器已实现,后续需补:更多内置字体包、字体预览、字体资源加载失败兜底
|
|
||||||
3. **书架/书库管理** — 商业阅读器的入口
|
|
||||||
4. **暗色模式图片处理增强** — 基础处理已实现,后续可补:按图片亮度自适应混合比例
|
|
||||||
|
|
||||||
### P1 — 影响用户留存
|
|
||||||
|
|
||||||
5. **批注导出/分享** — 深度阅读用户的核心需求
|
|
||||||
6. **阅读统计/时长追踪** — 用户粘性和产品数据的基础
|
|
||||||
7. **性能基线与大书优化** — 大书卡顿是用户流失的主要原因
|
|
||||||
8. **简繁转换** — 面向港澳台用户需要
|
|
||||||
|
|
||||||
### P2 — 提升竞争力
|
|
||||||
|
|
||||||
9. **TTS 朗读** — 通勤场景、无障碍场景刚需
|
|
||||||
10. **云端同步** — 多设备用户的基本需求
|
|
||||||
11. **VoiceOver 完善** — 合规和品牌形象
|
|
||||||
12. **全局搜索** — 藏书量大时的效率工具
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、总结
|
|
||||||
|
|
||||||
经过三方对比,渲染质量层面的真实差距比最初评估要小:
|
|
||||||
|
|
||||||
- **图文分页质量**:双方基本对齐,我们甚至在 `keepWithNext` 上领先
|
|
||||||
- **真正的差距**:字体包数量、分页回归基准、简繁转换
|
|
||||||
- **WXRead 也没做的**:竖排、ruby、公式、hyphenation——这些不是必须对齐的
|
|
||||||
|
|
||||||
如果目标是"能上架的商业阅读器",当前已补齐字体选择器、暗色模式图片处理和基础 UI 自动化测试。下一步最值得投入的是分页回归基准、更多字体资源和书架/书库管理。
|
|
||||||
3392
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
3392
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -12,6 +12,11 @@
|
|||||||
1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */; };
|
1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */; };
|
||||||
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */; };
|
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */; };
|
||||||
1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */; };
|
1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */; };
|
||||||
|
1A2B3C4D0000000BAABBCC01 /* ConfigurableWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */; };
|
||||||
|
1A2B3C4D0000000DAABBCC01 /* ConcurrentParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */; };
|
||||||
|
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */; };
|
||||||
|
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */; };
|
||||||
|
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */; };
|
||||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
||||||
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
||||||
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
|
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
|
||||||
@ -21,10 +26,7 @@
|
|||||||
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
|
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
|
||||||
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
|
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
|
||||||
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
|
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
|
||||||
1A2B3C4D0000000BAABBCC01 /* ConfigurableWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */; };
|
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
|
||||||
1A2B3C4D0000000DAABBCC01 /* ConcurrentParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */; };
|
|
||||||
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */; };
|
|
||||||
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@ -44,6 +46,11 @@
|
|||||||
1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsExtendedTests.swift; sourceTree = "<group>"; };
|
1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsExtendedTests.swift; sourceTree = "<group>"; };
|
||||||
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PageNavigationTests.swift; sourceTree = "<group>"; };
|
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PageNavigationTests.swift; sourceTree = "<group>"; };
|
||||||
1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationExtendedTests.swift; sourceTree = "<group>"; };
|
1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationExtendedTests.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigurableWindowTests.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConcurrentParsingTests.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MetadataParseBenchmarkTests.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoReaderState.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FanrenParseTimeTest.swift; sourceTree = "<group>"; };
|
||||||
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = "<group>"; };
|
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = "<group>"; };
|
||||||
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = "<group>"; };
|
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = "<group>"; };
|
||||||
3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = "<group>"; };
|
3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
@ -54,12 +61,9 @@
|
|||||||
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
||||||
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
||||||
1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigurableWindowTests.swift; sourceTree = "<group>"; };
|
|
||||||
1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConcurrentParsingTests.swift; sourceTree = "<group>"; };
|
|
||||||
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MetadataParseBenchmarkTests.swift; sourceTree = "<group>"; };
|
|
||||||
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoReaderState.swift; sourceTree = "<group>"; };
|
|
||||||
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
||||||
|
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@ -184,6 +188,8 @@
|
|||||||
1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */,
|
1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */,
|
||||||
1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */,
|
1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */,
|
||||||
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */,
|
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */,
|
||||||
|
1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */,
|
||||||
|
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */,
|
||||||
);
|
);
|
||||||
path = ReaderUITests;
|
path = ReaderUITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -314,14 +320,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";
|
||||||
@ -350,6 +352,8 @@
|
|||||||
1A2B3C4D0000000BAABBCC01 /* ConfigurableWindowTests.swift in Sources */,
|
1A2B3C4D0000000BAABBCC01 /* ConfigurableWindowTests.swift in Sources */,
|
||||||
1A2B3C4D0000000DAABBCC01 /* ConcurrentParsingTests.swift in Sources */,
|
1A2B3C4D0000000DAABBCC01 /* ConcurrentParsingTests.swift in Sources */,
|
||||||
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */,
|
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */,
|
||||||
|
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */,
|
||||||
|
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -18,6 +18,7 @@ final class ViewController: UIViewController {
|
|||||||
let windowSize: Int?
|
let windowSize: Int?
|
||||||
let concurrency: Int?
|
let concurrency: Int?
|
||||||
let clearsCache: Bool
|
let clearsCache: Bool
|
||||||
|
let searchKeyword: String?
|
||||||
|
|
||||||
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
|
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
|
||||||
switch rawValue.lowercased() {
|
switch rawValue.lowercased() {
|
||||||
@ -56,6 +57,7 @@ final class ViewController: UIViewController {
|
|||||||
let windowSize = value(after: "--demo-window-size").flatMap(Int.init)
|
let windowSize = value(after: "--demo-window-size").flatMap(Int.init)
|
||||||
let concurrency = value(after: "--demo-concurrency").flatMap(Int.init)
|
let concurrency = value(after: "--demo-concurrency").flatMap(Int.init)
|
||||||
let clearsCache = arguments.contains("--demo-clear-cache")
|
let clearsCache = arguments.contains("--demo-clear-cache")
|
||||||
|
let searchKeyword = value(after: "--demo-search-keyword")
|
||||||
|
|
||||||
return LaunchAutomationPlan(
|
return LaunchAutomationPlan(
|
||||||
bookTitleQuery: bookTitleQuery,
|
bookTitleQuery: bookTitleQuery,
|
||||||
@ -66,7 +68,8 @@ final class ViewController: UIViewController {
|
|||||||
stepDelay: stepDelay,
|
stepDelay: stepDelay,
|
||||||
windowSize: windowSize,
|
windowSize: windowSize,
|
||||||
concurrency: concurrency,
|
concurrency: concurrency,
|
||||||
clearsCache: clearsCache
|
clearsCache: clearsCache,
|
||||||
|
searchKeyword: searchKeyword
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -229,6 +232,11 @@ final class ViewController: UIViewController {
|
|||||||
stepDelay: automationPlan.stepDelay
|
stepDelay: automationPlan.stepDelay
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if let searchKeyword = automationPlan.searchKeyword {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
controller?.performDemoSearch(keyword: searchKeyword)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,14 @@ enum IDs {
|
|||||||
static let readerSelectionText = "epub.reader.selection.text"
|
static let readerSelectionText = "epub.reader.selection.text"
|
||||||
static let readerSelectionHighlight = "epub.reader.selection.高亮"
|
static let readerSelectionHighlight = "epub.reader.selection.高亮"
|
||||||
|
|
||||||
|
static let readerSearch = "epub.reader.search"
|
||||||
|
static let searchBar = "epub.reader.search.bar"
|
||||||
|
static let searchField = "epub.reader.search.field"
|
||||||
|
static let searchPrevious = "epub.reader.search.previous"
|
||||||
|
static let searchNext = "epub.reader.search.next"
|
||||||
|
static let searchClose = "epub.reader.search.close"
|
||||||
|
static let searchCount = "epub.reader.search.count"
|
||||||
|
|
||||||
static let settingsScroll = "epub.reader.settings.scroll"
|
static let settingsScroll = "epub.reader.settings.scroll"
|
||||||
static let settingsBrightness = "epub.reader.settings.brightness"
|
static let settingsBrightness = "epub.reader.settings.brightness"
|
||||||
static let settingsFontIncrease = "epub.reader.settings.font.increase"
|
static let settingsFontIncrease = "epub.reader.settings.font.increase"
|
||||||
|
|||||||
@ -8,7 +8,8 @@ extension XCUIApplication {
|
|||||||
resetsReaderState: Bool = true,
|
resetsReaderState: Bool = true,
|
||||||
windowSize: Int? = nil,
|
windowSize: Int? = nil,
|
||||||
concurrency: Int? = nil,
|
concurrency: Int? = nil,
|
||||||
clearsCache: Bool = false
|
clearsCache: Bool = false,
|
||||||
|
searchKeyword: String? = nil
|
||||||
) {
|
) {
|
||||||
var args = ["--demo-book-title", bookTitleQuery]
|
var args = ["--demo-book-title", bookTitleQuery]
|
||||||
if resetsReaderState {
|
if resetsReaderState {
|
||||||
@ -29,6 +30,9 @@ extension XCUIApplication {
|
|||||||
if let concurrency {
|
if let concurrency {
|
||||||
args += ["--demo-concurrency", "\(concurrency)"]
|
args += ["--demo-concurrency", "\(concurrency)"]
|
||||||
}
|
}
|
||||||
|
if let searchKeyword {
|
||||||
|
args += ["--demo-search-keyword", searchKeyword]
|
||||||
|
}
|
||||||
launchArguments = args
|
launchArguments = args
|
||||||
launch()
|
launch()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,54 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class FanrenParseTimeTest: XCTestCase {
|
||||||
|
private let app = XCUIApplication()
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试凡人修仙传后台解析耗时
|
||||||
|
func testFanrenParseTime() throws {
|
||||||
|
let cpuCount = ProcessInfo.processInfo.activeProcessorCount
|
||||||
|
print("[Fanren] 开始测试 - 设备CPU核心数: \(cpuCount)")
|
||||||
|
|
||||||
|
// 打开凡人修仙传,清缓存,使用默认并发数
|
||||||
|
app.launchAndOpenSampleBook(
|
||||||
|
bookTitleQuery: "凡人修仙传",
|
||||||
|
resetsReaderState: true,
|
||||||
|
concurrency: cpuCount,
|
||||||
|
clearsCache: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// 等待阅读器打开
|
||||||
|
app.waitForReader(timeout: 20)
|
||||||
|
|
||||||
|
// 等待首屏可读
|
||||||
|
_ = app.waitForDemoReaderState(timeout: 30, description: "首屏加载") { state in
|
||||||
|
state.mode == "bookPageMap" && (state.page ?? 0) >= 1
|
||||||
|
}
|
||||||
|
print("[Fanren] 首屏加载完成")
|
||||||
|
|
||||||
|
// 等待后台解析完成(parseMs > 0)
|
||||||
|
let completed = app.waitForDemoReaderState(timeout: 900, description: "后台解析完成") { state in
|
||||||
|
(state.parseMs ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let parseMs = completed.parseMs ?? 0
|
||||||
|
let concurrency = completed.parseConcurrency ?? 0
|
||||||
|
|
||||||
|
print("[Fanren] ---- 测试结果 ----")
|
||||||
|
print("[Fanren] 后台解析耗时: \(parseMs)ms")
|
||||||
|
print("[Fanren] 并发数: \(concurrency)")
|
||||||
|
print("[Fanren] CPU核心数: \(cpuCount)")
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
XCTAssertGreaterThan(parseMs, 0, "parseMs应大于0")
|
||||||
|
|
||||||
|
// 返回书架
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
if app.buttons[IDs.readerBack].waitForExistence(timeout: 5) {
|
||||||
|
app.buttons[IDs.readerBack].tap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -70,6 +70,79 @@ final class LargeBookOnDemandTests: XCTestCase {
|
|||||||
XCTAssertEqual(reopenedState.knownChapters, reopenedState.buildableChapters)
|
XCTAssertEqual(reopenedState.knownChapters, reopenedState.buildableChapters)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testMiddleChapterOpenPositionStableAfterFullParse() throws {
|
||||||
|
// 从中间章节打开,等后台解析完成,验证阅读位置不跳转
|
||||||
|
app.launchAndOpenSampleBook(
|
||||||
|
bookTitleQuery: largeBookQuery,
|
||||||
|
pageNumber: 50,
|
||||||
|
resetsReaderState: true
|
||||||
|
)
|
||||||
|
app.waitForReader(timeout: 20)
|
||||||
|
|
||||||
|
let partialState = app.waitForDemoReaderState(timeout: 15, description: "中间章节首开进入局部分页") { state in
|
||||||
|
state.mode == "bookPageMap" && state.pagination == "partial" && (state.page ?? 0) >= 1
|
||||||
|
}
|
||||||
|
let pageAfterOpen = partialState.page ?? 0
|
||||||
|
XCTAssertGreaterThan(pageAfterOpen, 1, "应从中间章节打开,当前页=\(pageAfterOpen)")
|
||||||
|
|
||||||
|
// 等待后台解析完成
|
||||||
|
let fullState = app.waitForDemoReaderState(timeout: 90, description: "后台解析完成") { state in
|
||||||
|
state.pagination == "full"
|
||||||
|
}
|
||||||
|
XCTAssertEqual(fullState.pagination, "full", "后台解析应完成")
|
||||||
|
|
||||||
|
// 验证页码未跳转(方案 C:pending map 不会在解析完成时应用)
|
||||||
|
let pageAfterParse = fullState.page ?? 0
|
||||||
|
XCTAssertEqual(pageAfterParse, pageAfterOpen,
|
||||||
|
"后台解析完成后页码不应跳转:解析前=\(pageAfterOpen) 解析后=\(pageAfterParse)")
|
||||||
|
|
||||||
|
// 验证翻页功能正常(触发 pending map 应用)
|
||||||
|
let paging = app.collectionViews[IDs.readerPaging]
|
||||||
|
if paging.waitForExistence(timeout: 5) {
|
||||||
|
paging.swipeLeft()
|
||||||
|
}
|
||||||
|
|
||||||
|
let afterSwipe = app.waitForDemoReaderState(timeout: 10, description: "翻页后页码推进") { state in
|
||||||
|
state.page != nil && state.page != pageAfterParse
|
||||||
|
}
|
||||||
|
XCTAssertGreaterThan(afterSwipe.page ?? 0, pageAfterParse,
|
||||||
|
"翻页后页码应前进:翻页前=\(pageAfterParse) 翻页后=\(afterSwipe.page ?? 0)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMiddleChapterOpenSwipeAfterFullParseResolvesCorrectly() throws {
|
||||||
|
// 从中间章节打开,等解析完成,连续翻页验证页码连续性
|
||||||
|
app.launchAndOpenSampleBook(
|
||||||
|
bookTitleQuery: largeBookQuery,
|
||||||
|
pageNumber: 30,
|
||||||
|
resetsReaderState: true
|
||||||
|
)
|
||||||
|
app.waitForReader(timeout: 20)
|
||||||
|
|
||||||
|
_ = app.waitForDemoReaderState(timeout: 15, description: "局部分页就绪") { state in
|
||||||
|
state.mode == "bookPageMap" && state.pagination == "partial"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待解析完成
|
||||||
|
_ = app.waitForDemoReaderState(timeout: 90, description: "后台解析完成") { state in
|
||||||
|
state.pagination == "full"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连续翻 3 页,验证页码连续递增
|
||||||
|
let paging = app.collectionViews[IDs.readerPaging]
|
||||||
|
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
|
||||||
|
|
||||||
|
var previousPage = app.currentDemoReaderState()?.page ?? 0
|
||||||
|
for i in 0..<3 {
|
||||||
|
paging.swipeLeft()
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(1))
|
||||||
|
let currentState = app.currentDemoReaderState()
|
||||||
|
let currentPage = currentState?.page ?? 0
|
||||||
|
XCTAssertGreaterThan(currentPage, previousPage,
|
||||||
|
"第 \(i + 1) 次翻页后页码应递增:前=\(previousPage) 后=\(currentPage)")
|
||||||
|
previousPage = currentPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testLargeBookContinuousPagingExtendsKnownPageMap() throws {
|
func testLargeBookContinuousPagingExtendsKnownPageMap() throws {
|
||||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, displayType: "scroll", resetsReaderState: true)
|
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, displayType: "scroll", resetsReaderState: true)
|
||||||
app.waitForReader(timeout: 20)
|
app.waitForReader(timeout: 20)
|
||||||
|
|||||||
250
ReadViewDemo/ReadViewDemoUITests/ReaderUITests/SearchTests.swift
Normal file
250
ReadViewDemo/ReadViewDemoUITests/ReaderUITests/SearchTests.swift
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class SearchTests: XCTestCase {
|
||||||
|
private let app = XCUIApplication()
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 搜索入口
|
||||||
|
|
||||||
|
func testSearchButtonExistsInToolbar() {
|
||||||
|
app.launchAndOpenSampleBook()
|
||||||
|
app.waitForReader()
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
|
||||||
|
let searchButton = app.buttons[IDs.readerSearch]
|
||||||
|
XCTAssertTrue(searchButton.waitForExistence(timeout: 5), "搜索按钮应出现在顶部工具栏")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 搜索栏显示/隐藏
|
||||||
|
|
||||||
|
func testTapSearchButtonShowsSearchBar() {
|
||||||
|
app.launchAndOpenSampleBook()
|
||||||
|
app.waitForReader()
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
|
||||||
|
let searchButton = app.buttons[IDs.readerSearch]
|
||||||
|
XCTAssertTrue(searchButton.waitForExistence(timeout: 5), "搜索按钮应存在")
|
||||||
|
searchButton.tap()
|
||||||
|
|
||||||
|
let searchBar = app.otherElements[IDs.searchBar]
|
||||||
|
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "点击搜索按钮后搜索栏应出现")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchBarAutoFocusesTextField() {
|
||||||
|
app.launchAndOpenSampleBook()
|
||||||
|
app.waitForReader()
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
|
||||||
|
let searchButton = app.buttons[IDs.readerSearch]
|
||||||
|
XCTAssertTrue(searchButton.waitForExistence(timeout: 5))
|
||||||
|
searchButton.tap()
|
||||||
|
|
||||||
|
let searchBar = app.otherElements[IDs.searchBar]
|
||||||
|
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索栏应出现")
|
||||||
|
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 3), "搜索栏出现后键盘应弹出")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCloseButtonHidesSearchBar() {
|
||||||
|
app.launchAndOpenSampleBook()
|
||||||
|
app.waitForReader()
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
|
||||||
|
openSearchBar()
|
||||||
|
|
||||||
|
let closeButton = app.buttons[IDs.searchClose]
|
||||||
|
XCTAssertTrue(closeButton.waitForExistence(timeout: 3), "关闭按钮应存在")
|
||||||
|
closeButton.tap()
|
||||||
|
|
||||||
|
let searchBar = app.otherElements[IDs.searchBar]
|
||||||
|
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "点击关闭后搜索栏应消失")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 搜索执行与匹配(通过 launch argument 触发搜索)
|
||||||
|
|
||||||
|
func testSearchFindsMatchesAndShowsCount() {
|
||||||
|
app.launchAndOpenSampleBook(searchKeyword: "的")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let countLabel = app.staticTexts[IDs.searchCount]
|
||||||
|
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "匹配计数标签应出现")
|
||||||
|
|
||||||
|
let countText = countLabel.label
|
||||||
|
XCTAssertTrue(countText.contains("/"), "计数格式应为 'N/M',实际:\(countText)")
|
||||||
|
let parts = countText.split(separator: "/")
|
||||||
|
XCTAssertEqual(parts.count, 2, "计数应包含两部分,实际:\(countText)")
|
||||||
|
if let total = Int(parts[1]) {
|
||||||
|
XCTAssertGreaterThan(total, 0, "关键词 '的' 应有匹配结果")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchNavigatesToFirstMatch() {
|
||||||
|
app.launchAndOpenSampleBook(searchKeyword: "的")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let countLabel = app.staticTexts[IDs.searchCount]
|
||||||
|
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "匹配计数应出现")
|
||||||
|
|
||||||
|
let countText = countLabel.label
|
||||||
|
XCTAssertTrue(countText.hasPrefix("1/"), "搜索后应自动跳转到第一个匹配,当前:\(countText)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchNextButtonAdvancesMatch() {
|
||||||
|
app.launchAndOpenSampleBook(searchKeyword: "的")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let countLabel = app.staticTexts[IDs.searchCount]
|
||||||
|
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue(countLabel.label.hasPrefix("1/"), "初始应在第 1 个匹配")
|
||||||
|
|
||||||
|
let nextButton = app.buttons[IDs.searchNext]
|
||||||
|
XCTAssertTrue(nextButton.waitForExistence(timeout: 3))
|
||||||
|
nextButton.tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(countLabel.label.hasPrefix("2/"), "点击下一个后应变为第 2 个匹配,当前:\(countLabel.label)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchPreviousButtonGoesBack() {
|
||||||
|
app.launchAndOpenSampleBook(searchKeyword: "的")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let countLabel = app.staticTexts[IDs.searchCount]
|
||||||
|
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
|
// 前进到第 2 个
|
||||||
|
let nextButton = app.buttons[IDs.searchNext]
|
||||||
|
XCTAssertTrue(nextButton.waitForExistence(timeout: 3))
|
||||||
|
nextButton.tap()
|
||||||
|
XCTAssertTrue(countLabel.label.hasPrefix("2/"))
|
||||||
|
|
||||||
|
// 后退到第 1 个
|
||||||
|
let previousButton = app.buttons[IDs.searchPrevious]
|
||||||
|
XCTAssertTrue(previousButton.waitForExistence(timeout: 3))
|
||||||
|
previousButton.tap()
|
||||||
|
XCTAssertTrue(countLabel.label.hasPrefix("1/"), "点击上一个后应回到第 1 个匹配,当前:\(countLabel.label)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchNextWrapsAround() {
|
||||||
|
app.launchAndOpenSampleBook(searchKeyword: "的")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let countLabel = app.staticTexts[IDs.searchCount]
|
||||||
|
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
|
let countText = countLabel.label
|
||||||
|
let parts = countText.split(separator: "/")
|
||||||
|
guard parts.count == 2, let total = Int(parts[1]), total > 1 else {
|
||||||
|
XCTSkip("需要至少 2 个匹配才能测试循环")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前进到最后一个
|
||||||
|
let nextButton = app.buttons[IDs.searchNext]
|
||||||
|
for _ in 0..<(total - 1) {
|
||||||
|
nextButton.tap()
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(countLabel.label.hasPrefix("\(total)/"), "应到达最后一个匹配")
|
||||||
|
|
||||||
|
// 再前进一次应循环到第 1 个
|
||||||
|
nextButton.tap()
|
||||||
|
XCTAssertTrue(countLabel.label.hasPrefix("1/"), "超过最后一个后应循环到第 1 个,当前:\(countLabel.label)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 空结果
|
||||||
|
|
||||||
|
func testSearchWithNoResultsShowsZeroCount() {
|
||||||
|
app.launchAndOpenSampleBook(searchKeyword: "xyzzy_nonexistent_keyword_12345")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let countLabel = app.staticTexts[IDs.searchCount]
|
||||||
|
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "计数标签应出现")
|
||||||
|
XCTAssertEqual(countLabel.label, "0/0", "无匹配时应显示 0/0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchNavigationDisabledWhenNoResults() {
|
||||||
|
app.launchAndOpenSampleBook(searchKeyword: "xyzzy_nonexistent_keyword_12345")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let countLabel = app.staticTexts[IDs.searchCount]
|
||||||
|
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertEqual(countLabel.label, "0/0")
|
||||||
|
|
||||||
|
let prevButton = app.buttons[IDs.searchPrevious]
|
||||||
|
let nextButton = app.buttons[IDs.searchNext]
|
||||||
|
XCTAssertTrue(prevButton.waitForExistence(timeout: 3))
|
||||||
|
XCTAssertTrue(nextButton.waitForExistence(timeout: 3))
|
||||||
|
XCTAssertFalse(prevButton.isEnabled, "无结果时上一个按钮应禁用")
|
||||||
|
XCTAssertFalse(nextButton.isEnabled, "无结果时下一个按钮应禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 清除搜索
|
||||||
|
|
||||||
|
func testCloseSearchClearsState() {
|
||||||
|
app.launchAndOpenSampleBook(searchKeyword: "的")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let countLabel = app.staticTexts[IDs.searchCount]
|
||||||
|
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertNotEqual(countLabel.label, "0/0", "应有匹配结果")
|
||||||
|
|
||||||
|
// 关闭搜索
|
||||||
|
let closeButton = app.buttons[IDs.searchClose]
|
||||||
|
XCTAssertTrue(closeButton.waitForExistence(timeout: 3))
|
||||||
|
closeButton.tap()
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||||
|
|
||||||
|
// 搜索栏应消失
|
||||||
|
let searchBar = app.otherElements[IDs.searchBar]
|
||||||
|
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "关闭后搜索栏应消失")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 搜索栏与工具栏联动
|
||||||
|
|
||||||
|
func testSearchBarHidesWhenToolbarsHide() {
|
||||||
|
app.launchAndOpenSampleBook(searchKeyword: "的")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let searchBar = app.otherElements[IDs.searchBar]
|
||||||
|
XCTAssertTrue(searchBar.waitForExistence(timeout: 10), "搜索栏应可见")
|
||||||
|
|
||||||
|
// 隐藏工具栏
|
||||||
|
app.hideReaderChromeIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "工具栏隐藏时搜索栏也应隐藏")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchBarRestoresWhenToolbarsShow() {
|
||||||
|
app.launchAndOpenSampleBook(searchKeyword: "的")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let searchBar = app.otherElements[IDs.searchBar]
|
||||||
|
XCTAssertTrue(searchBar.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
|
// 隐藏工具栏
|
||||||
|
app.hideReaderChromeIfNeeded()
|
||||||
|
XCTAssertFalse(searchBar.waitForExistence(timeout: 2))
|
||||||
|
|
||||||
|
// 重新显示工具栏
|
||||||
|
app.showReaderChromeIfNeeded()
|
||||||
|
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "工具栏恢复时搜索栏也应恢复")
|
||||||
|
|
||||||
|
// 搜索状态应保留
|
||||||
|
let countLabel = app.staticTexts[IDs.searchCount]
|
||||||
|
XCTAssertTrue(countLabel.waitForExistence(timeout: 3))
|
||||||
|
XCTAssertNotEqual(countLabel.label, "0/0", "搜索状态应在工具栏恢复后保留")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 辅助方法
|
||||||
|
|
||||||
|
private func openSearchBar() {
|
||||||
|
let searchButton = app.buttons[IDs.readerSearch]
|
||||||
|
if searchButton.waitForExistence(timeout: 5) {
|
||||||
|
searchButton.tap()
|
||||||
|
}
|
||||||
|
let searchBar = app.otherElements[IDs.searchBar]
|
||||||
|
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索栏应出现")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -111,6 +111,10 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
|||||||
readerContext.markUserNavigationActivity()
|
readerContext.markUserNavigationActivity()
|
||||||
updateCurrentSelection(nil)
|
updateCurrentSelection(nil)
|
||||||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
||||||
|
|
||||||
|
// 用户开始导航时,应用后台解析完成的完整 map
|
||||||
|
runtime.applyPendingFullPageMapIfNeeded()
|
||||||
|
|
||||||
if readerContext.bookPageMap != nil {
|
if readerContext.bookPageMap != nil {
|
||||||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
|
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
|
||||||
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: pageNum + 1)
|
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: pageNum + 1)
|
||||||
|
|||||||
@ -155,6 +155,9 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
}
|
}
|
||||||
lazy var topToolView = runtime.makeTopToolView()
|
lazy var topToolView = runtime.makeTopToolView()
|
||||||
lazy var bottomToolView = runtime.makeBottomToolView()
|
lazy var bottomToolView = runtime.makeBottomToolView()
|
||||||
|
lazy var searchBarView = RDEPUBReaderSearchBarView()
|
||||||
|
/// 搜索栏是否当前可见
|
||||||
|
private(set) var isSearchBarVisible = false
|
||||||
var currentBookIdentifier: String? {
|
var currentBookIdentifier: String? {
|
||||||
get { readerContext.currentBookIdentifier }
|
get { readerContext.currentBookIdentifier }
|
||||||
set { readerContext.currentBookIdentifier = newValue }
|
set { readerContext.currentBookIdentifier = newValue }
|
||||||
@ -273,6 +276,9 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
currentBrightness = currentBrightness
|
currentBrightness = currentBrightness
|
||||||
readerAssemblyCoordinator.assembleInterface()
|
readerAssemblyCoordinator.assembleInterface()
|
||||||
readerAssemblyCoordinator.finishExternalTextBookLaunchIfNeeded()
|
readerAssemblyCoordinator.finishExternalTextBookLaunchIfNeeded()
|
||||||
|
readerView.onToolViewVisibilityChanged = { [weak self] isVisible in
|
||||||
|
self?.handleToolViewVisibilityChanged(isVisible: isVisible)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewWillAppear(_ animated: Bool) {
|
public override func viewWillAppear(_ animated: Bool) {
|
||||||
@ -302,4 +308,100 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
runtime.viewportMonitor.viewWillTransition(with: coordinator)
|
runtime.viewportMonitor.viewWillTransition(with: coordinator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 搜索栏管理
|
||||||
|
|
||||||
|
/// 显示搜索栏,将其添加到 readerView 并滑入动画
|
||||||
|
func showSearchBar() {
|
||||||
|
guard !isSearchBarVisible else { return }
|
||||||
|
isSearchBarVisible = true
|
||||||
|
searchBarView.apply(theme: configuration.theme)
|
||||||
|
|
||||||
|
readerView.addSubview(searchBarView)
|
||||||
|
readerView.searchBarView = searchBarView
|
||||||
|
let topToolbarHeight: CGFloat = readerView.safeAreaInsets.top + 52
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
||||||
|
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
||||||
|
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor, constant: topToolbarHeight),
|
||||||
|
searchBarView.heightAnchor.constraint(equalToConstant: 52)
|
||||||
|
])
|
||||||
|
|
||||||
|
searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
|
||||||
|
UIView.animate(withDuration: 0.3) {
|
||||||
|
self.searchBarView.transform = .identity
|
||||||
|
}
|
||||||
|
|
||||||
|
searchBarView.onSearchSubmit = { [weak self] keyword in
|
||||||
|
self?.runtime.search(keyword: keyword)
|
||||||
|
self?.updateSearchCount()
|
||||||
|
}
|
||||||
|
searchBarView.onSearchPrevious = { [weak self] in
|
||||||
|
_ = self?.runtime.searchPrevious()
|
||||||
|
self?.updateSearchCount()
|
||||||
|
}
|
||||||
|
searchBarView.onSearchNext = { [weak self] in
|
||||||
|
_ = self?.runtime.searchNext()
|
||||||
|
self?.updateSearchCount()
|
||||||
|
}
|
||||||
|
searchBarView.onClose = { [weak self] in
|
||||||
|
self?.hideSearchBar(clearSearch: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let keyword = searchState?.keyword, !keyword.isEmpty {
|
||||||
|
searchBarView.restoreKeyword(keyword)
|
||||||
|
updateSearchCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟到下一帧确保视图已布局后再获取焦点
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.searchBarView.textField.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 隐藏搜索栏,带动画滑出
|
||||||
|
/// - Parameter clearSearch: 是否同时清除搜索状态
|
||||||
|
func hideSearchBar(clearSearch: Bool = false) {
|
||||||
|
guard isSearchBarVisible else { return }
|
||||||
|
isSearchBarVisible = false
|
||||||
|
|
||||||
|
searchBarView.textField.resignFirstResponder()
|
||||||
|
UIView.animate(withDuration: 0.3, animations: {
|
||||||
|
self.searchBarView.transform = CGAffineTransform(translationX: 0, y: -52)
|
||||||
|
}) { _ in
|
||||||
|
self.searchBarView.removeFromSuperview()
|
||||||
|
self.searchBarView.transform = .identity
|
||||||
|
self.readerView.searchBarView = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if clearSearch {
|
||||||
|
runtime.clearSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步搜索栏匹配计数
|
||||||
|
private func updateSearchCount() {
|
||||||
|
guard let searchState else {
|
||||||
|
searchBarView.showNoResults()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let index = searchState.currentMatchIndex {
|
||||||
|
searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
|
||||||
|
} else if searchState.matches.isEmpty {
|
||||||
|
searchBarView.showNoResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当工具栏可见性变化时同步搜索栏(由 RDReaderView 回调调用)
|
||||||
|
func handleToolViewVisibilityChanged(isVisible: Bool) {
|
||||||
|
if isVisible {
|
||||||
|
if searchState != nil && !isSearchBarVisible {
|
||||||
|
showSearchBar()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if isSearchBarVisible {
|
||||||
|
hideSearchBar(clearSearch: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
251
Sources/RDReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift
Normal file
251
Sources/RDReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
// MARK: - 搜索栏
|
||||||
|
|
||||||
|
/// 阅读器搜索栏视图
|
||||||
|
/// 提供搜索输入、上一个/下一个匹配导航、匹配计数和关闭功能
|
||||||
|
final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||||
|
// MARK: 回调闭包
|
||||||
|
|
||||||
|
/// 提交搜索关键词回调
|
||||||
|
var onSearchSubmit: ((String) -> Void)?
|
||||||
|
/// 点击上一个匹配回调
|
||||||
|
var onSearchPrevious: (() -> Void)?
|
||||||
|
/// 点击下一个匹配回调
|
||||||
|
var onSearchNext: (() -> Void)?
|
||||||
|
/// 关闭搜索回调
|
||||||
|
var onClose: (() -> Void)?
|
||||||
|
|
||||||
|
// MARK: UI 组件
|
||||||
|
|
||||||
|
private let containerView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.layer.cornerRadius = 8
|
||||||
|
view.layer.masksToBounds = true
|
||||||
|
view.isAccessibilityElement = false
|
||||||
|
view.accessibilityElementsHidden = false
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let searchIcon: UIImageView = {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
imageView.image = UIImage(systemName: "magnifyingglass")
|
||||||
|
}
|
||||||
|
imageView.tintColor = .gray
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
let textField: UITextField = {
|
||||||
|
let field = UITextField()
|
||||||
|
field.placeholder = "搜索..."
|
||||||
|
field.font = UIFont.systemFont(ofSize: 15)
|
||||||
|
field.returnKeyType = .search
|
||||||
|
field.autocorrectionType = .no
|
||||||
|
field.autocapitalizationType = .none
|
||||||
|
field.clearButtonMode = .whileEditing
|
||||||
|
field.isAccessibilityElement = true
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
field.accessibilityTraits = .searchField
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let previousButton = RDEPUBReaderTintButton(type: .system)
|
||||||
|
private let nextButton = RDEPUBReaderTintButton(type: .system)
|
||||||
|
|
||||||
|
private let countLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = UIFont.systemFont(ofSize: 13, weight: .medium)
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let closeButton = RDEPUBReaderTintButton(type: .system)
|
||||||
|
|
||||||
|
// MARK: 布局常量
|
||||||
|
|
||||||
|
private let horizontalInset: CGFloat = 12
|
||||||
|
private let spacing: CGFloat = 6
|
||||||
|
private let containerHeight: CGFloat = 36
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
accessibilityIdentifier = "epub.reader.search.bar"
|
||||||
|
shouldGroupAccessibilityChildren = false
|
||||||
|
isAccessibilityElement = false
|
||||||
|
setupSubviews()
|
||||||
|
setupConstraints()
|
||||||
|
setupActions()
|
||||||
|
updateNavigationEnabled(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 布局
|
||||||
|
|
||||||
|
override func lineFrame(in bounds: CGRect) -> CGRect {
|
||||||
|
CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func apply(theme: RDEPUBReaderTheme) {
|
||||||
|
super.apply(theme: theme)
|
||||||
|
backgroundColor = theme.toolBackgroundColor
|
||||||
|
containerView.backgroundColor = theme.toolControlBorderUnselectColor
|
||||||
|
searchIcon.tintColor = theme.toolControlTextColor
|
||||||
|
textField.textColor = theme.toolControlTextColor
|
||||||
|
textField.attributedPlaceholder = NSAttributedString(
|
||||||
|
string: "搜索...",
|
||||||
|
attributes: [.foregroundColor: theme.toolControlTextColor.withAlphaComponent(0.5)]
|
||||||
|
)
|
||||||
|
countLabel.textColor = theme.toolControlTextColor
|
||||||
|
previousButton.tintColor = theme.toolControlTextColor
|
||||||
|
nextButton.tintColor = theme.toolControlTextColor
|
||||||
|
closeButton.tintColor = theme.toolControlTextColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 公开方法
|
||||||
|
|
||||||
|
/// 更新匹配计数显示
|
||||||
|
func updateMatchCount(current: Int, total: Int) {
|
||||||
|
countLabel.text = "\(current)/\(total)"
|
||||||
|
updateNavigationEnabled(total > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显示无结果状态
|
||||||
|
func showNoResults() {
|
||||||
|
countLabel.text = "0/0"
|
||||||
|
updateNavigationEnabled(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显示搜索中状态
|
||||||
|
func showSearching() {
|
||||||
|
countLabel.text = "搜索中..."
|
||||||
|
updateNavigationEnabled(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 恢复已有的搜索关键词(搜索栏重新显示时)
|
||||||
|
func restoreKeyword(_ keyword: String) {
|
||||||
|
textField.text = keyword
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 私有方法
|
||||||
|
|
||||||
|
private func setupSubviews() {
|
||||||
|
addSubview(containerView)
|
||||||
|
containerView.addSubview(searchIcon)
|
||||||
|
containerView.addSubview(textField)
|
||||||
|
addSubview(previousButton)
|
||||||
|
addSubview(nextButton)
|
||||||
|
addSubview(countLabel)
|
||||||
|
addSubview(closeButton)
|
||||||
|
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
previousButton.setImage(UIImage(systemName: "chevron.up")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||||
|
nextButton.setImage(UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||||
|
closeButton.setImage(UIImage(systemName: "xmark")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||||
|
} else {
|
||||||
|
previousButton.setTitle("▲", for: .normal)
|
||||||
|
nextButton.setTitle("▼", for: .normal)
|
||||||
|
closeButton.setTitle("✕", for: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
previousButton.accessibilityIdentifier = "epub.reader.search.previous"
|
||||||
|
nextButton.accessibilityIdentifier = "epub.reader.search.next"
|
||||||
|
closeButton.accessibilityIdentifier = "epub.reader.search.close"
|
||||||
|
countLabel.accessibilityIdentifier = "epub.reader.search.count"
|
||||||
|
textField.accessibilityIdentifier = "epub.reader.search.field"
|
||||||
|
|
||||||
|
[previousButton, nextButton, closeButton].forEach { button in
|
||||||
|
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||||
|
button.tintColor = .black
|
||||||
|
button.setTitleColor(.black, for: .normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
[containerView, searchIcon, textField, previousButton, nextButton, countLabel, closeButton].forEach {
|
||||||
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
// 容器(搜索输入区域)
|
||||||
|
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset),
|
||||||
|
containerView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
|
containerView.heightAnchor.constraint(equalToConstant: containerHeight),
|
||||||
|
|
||||||
|
// 搜索图标
|
||||||
|
searchIcon.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10),
|
||||||
|
searchIcon.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||||
|
searchIcon.widthAnchor.constraint(equalToConstant: 16),
|
||||||
|
|
||||||
|
// 输入框
|
||||||
|
textField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 6),
|
||||||
|
textField.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
|
||||||
|
textField.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||||
|
textField.heightAnchor.constraint(equalToConstant: containerHeight - 4),
|
||||||
|
|
||||||
|
// 上一个按钮
|
||||||
|
previousButton.leadingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: spacing),
|
||||||
|
previousButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
|
previousButton.widthAnchor.constraint(equalToConstant: 32),
|
||||||
|
previousButton.heightAnchor.constraint(equalToConstant: 32),
|
||||||
|
|
||||||
|
// 下一个按钮
|
||||||
|
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor, constant: spacing),
|
||||||
|
nextButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
|
nextButton.widthAnchor.constraint(equalToConstant: 32),
|
||||||
|
nextButton.heightAnchor.constraint(equalToConstant: 32),
|
||||||
|
|
||||||
|
// 计数标签
|
||||||
|
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor, constant: spacing),
|
||||||
|
countLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
|
countLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
||||||
|
|
||||||
|
// 关闭按钮
|
||||||
|
closeButton.leadingAnchor.constraint(equalTo: countLabel.trailingAnchor, constant: spacing),
|
||||||
|
closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalInset),
|
||||||
|
closeButton.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
|
closeButton.widthAnchor.constraint(equalToConstant: 32),
|
||||||
|
closeButton.heightAnchor.constraint(equalToConstant: 32)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupActions() {
|
||||||
|
textField.addTarget(self, action: #selector(textFieldDidReturn), for: .editingDidEndOnExit)
|
||||||
|
previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
|
||||||
|
nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
|
||||||
|
closeButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNavigationEnabled(_ enabled: Bool) {
|
||||||
|
previousButton.isEnabled = enabled
|
||||||
|
previousButton.alpha = enabled ? 1 : 0.45
|
||||||
|
nextButton.isEnabled = enabled
|
||||||
|
nextButton.alpha = enabled ? 1 : 0.45
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func textFieldDidReturn() {
|
||||||
|
guard let keyword = textField.text, !keyword.isEmpty else { return }
|
||||||
|
onSearchSubmit?(keyword)
|
||||||
|
textField.resignFirstResponder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func previousAction() {
|
||||||
|
onSearchPrevious?()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func nextAction() {
|
||||||
|
onSearchNext?()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func closeAction() {
|
||||||
|
onClose?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,8 +10,11 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
|||||||
var onBack: (() -> Void)?
|
var onBack: (() -> Void)?
|
||||||
/// 书签按钮点击回调
|
/// 书签按钮点击回调
|
||||||
var onToggleBookmark: (() -> Void)?
|
var onToggleBookmark: (() -> Void)?
|
||||||
|
/// 搜索按钮点击回调
|
||||||
|
var onSearch: (() -> Void)?
|
||||||
|
|
||||||
private let backButton = RDEPUBReaderTintButton(type: .system)
|
private let backButton = RDEPUBReaderTintButton(type: .system)
|
||||||
|
private let searchButton = RDEPUBReaderTintButton(type: .system)
|
||||||
private let bookmarkButton = RDEPUBReaderTintButton(type: .system)
|
private let bookmarkButton = RDEPUBReaderTintButton(type: .system)
|
||||||
private let titleLabel: UILabel = {
|
private let titleLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
@ -27,14 +30,17 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
|||||||
accessibilityIdentifier = "epub.reader.topToolbar"
|
accessibilityIdentifier = "epub.reader.topToolbar"
|
||||||
self.backgroundColor = .white
|
self.backgroundColor = .white
|
||||||
addSubview(backButton)
|
addSubview(backButton)
|
||||||
|
addSubview(searchButton)
|
||||||
addSubview(bookmarkButton)
|
addSubview(bookmarkButton)
|
||||||
addSubview(titleLabel)
|
addSubview(titleLabel)
|
||||||
|
|
||||||
backButton.translatesAutoresizingMaskIntoConstraints = false
|
backButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
searchButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
bookmarkButton.translatesAutoresizingMaskIntoConstraints = false
|
bookmarkButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
|
backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
|
||||||
|
searchButton.addTarget(self, action: #selector(searchAction), for: .touchUpInside)
|
||||||
bookmarkButton.addTarget(self, action: #selector(bookmarkAction), for: .touchUpInside)
|
bookmarkButton.addTarget(self, action: #selector(bookmarkAction), for: .touchUpInside)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
@ -50,17 +56,26 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
|||||||
bookmarkButton.widthAnchor.constraint(equalToConstant: 44),
|
bookmarkButton.widthAnchor.constraint(equalToConstant: 44),
|
||||||
bookmarkButton.heightAnchor.constraint(equalToConstant: 44),
|
bookmarkButton.heightAnchor.constraint(equalToConstant: 44),
|
||||||
|
|
||||||
|
searchButton.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -4),
|
||||||
|
searchButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
|
||||||
|
searchButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
|
||||||
|
searchButton.widthAnchor.constraint(equalToConstant: 44),
|
||||||
|
searchButton.heightAnchor.constraint(equalToConstant: 44),
|
||||||
|
|
||||||
titleLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8),
|
titleLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8),
|
||||||
titleLabel.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -8),
|
titleLabel.trailingAnchor.constraint(equalTo: searchButton.leadingAnchor, constant: -8),
|
||||||
titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor)
|
titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor)
|
||||||
])
|
])
|
||||||
|
|
||||||
if #available(iOS 13.0, *) {
|
if #available(iOS 13.0, *) {
|
||||||
backButton.setImage(UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
backButton.setImage(UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||||
|
searchButton.setImage(UIImage(systemName: "magnifyingglass")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||||
} else {
|
} else {
|
||||||
backButton.setTitle("返回", for: .normal)
|
backButton.setTitle("返回", for: .normal)
|
||||||
|
searchButton.setTitle("搜索", for: .normal)
|
||||||
}
|
}
|
||||||
backButton.accessibilityIdentifier = "epub.reader.back"
|
backButton.accessibilityIdentifier = "epub.reader.back"
|
||||||
|
searchButton.accessibilityIdentifier = "epub.reader.search"
|
||||||
bookmarkButton.accessibilityIdentifier = "epub.reader.bookmark"
|
bookmarkButton.accessibilityIdentifier = "epub.reader.bookmark"
|
||||||
titleLabel.accessibilityIdentifier = "epub.reader.title"
|
titleLabel.accessibilityIdentifier = "epub.reader.title"
|
||||||
updateBookmarkButtonAppearance()
|
updateBookmarkButtonAppearance()
|
||||||
@ -76,12 +91,14 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
|||||||
|
|
||||||
override public func apply(theme: RDEPUBReaderTheme) {
|
override public func apply(theme: RDEPUBReaderTheme) {
|
||||||
super.apply(theme: theme)
|
super.apply(theme: theme)
|
||||||
titleLabel.textColor = .black
|
titleLabel.textColor = theme.toolControlTextColor
|
||||||
backButton.tintColor = .black
|
backButton.tintColor = theme.toolControlTextColor
|
||||||
bookmarkButton.tintColor = .black
|
searchButton.tintColor = theme.toolControlTextColor
|
||||||
|
bookmarkButton.tintColor = theme.toolControlTextColor
|
||||||
if #unavailable(iOS 13.0) {
|
if #unavailable(iOS 13.0) {
|
||||||
backButton.setTitleColor(.black, for: .normal)
|
backButton.setTitleColor(theme.toolControlTextColor, for: .normal)
|
||||||
bookmarkButton.setTitleColor(.black, for: .normal)
|
searchButton.setTitleColor(theme.toolControlTextColor, for: .normal)
|
||||||
|
bookmarkButton.setTitleColor(theme.toolControlTextColor, for: .normal)
|
||||||
}
|
}
|
||||||
updateBookmarkButtonAppearance()
|
updateBookmarkButtonAppearance()
|
||||||
}
|
}
|
||||||
@ -104,6 +121,10 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
|||||||
onBack?()
|
onBack?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func searchAction() {
|
||||||
|
onSearch?()
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func bookmarkAction() {
|
@objc private func bookmarkAction() {
|
||||||
onToggleBookmark?()
|
onToggleBookmark?()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -146,6 +146,19 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 执行搜索(Demo 用)
|
||||||
|
/// 显示搜索栏并提交关键词
|
||||||
|
/// - Parameter keyword: 搜索关键词
|
||||||
|
public func performDemoSearch(keyword: String) {
|
||||||
|
guard let readerController else { return }
|
||||||
|
readerController.showSearchBar()
|
||||||
|
// 等搜索栏动画完成后提交搜索
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
readerController.search(keyword: keyword)
|
||||||
|
readerController.updateSearchCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 嵌入阅读器控制器到当前视图层级
|
/// 嵌入阅读器控制器到当前视图层级
|
||||||
/// 根据文件类型选择合适的阅读器控制器,并通过 Child View Controller 方式嵌入
|
/// 根据文件类型选择合适的阅读器控制器,并通过 Child View Controller 方式嵌入
|
||||||
private func embedReaderController() {
|
private func embedReaderController() {
|
||||||
|
|||||||
@ -19,12 +19,15 @@ final class RDEPUBReaderChromeCoordinator {
|
|||||||
context.controller
|
context.controller
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建顶部工具栏视图,绑定返回和书签切换回调。
|
/// 创建顶部工具栏视图,绑定返回、搜索和书签切换回调。
|
||||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||||
let toolView = RDEPUBReaderTopToolView()
|
let toolView = RDEPUBReaderTopToolView()
|
||||||
toolView.onBack = { [weak self] in
|
toolView.onBack = { [weak self] in
|
||||||
self?.handleBackAction()
|
self?.handleBackAction()
|
||||||
}
|
}
|
||||||
|
toolView.onSearch = { [weak self] in
|
||||||
|
self?.toggleSearchBar()
|
||||||
|
}
|
||||||
toolView.onToggleBookmark = { [weak self] in
|
toolView.onToggleBookmark = { [weak self] in
|
||||||
_ = self?.context.runtime?.toggleBookmark()
|
_ = self?.context.runtime?.toggleBookmark()
|
||||||
}
|
}
|
||||||
@ -76,6 +79,7 @@ final class RDEPUBReaderChromeCoordinator {
|
|||||||
controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty
|
controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty
|
||||||
)
|
)
|
||||||
controller.updateBookmarkChrome()
|
controller.updateBookmarkChrome()
|
||||||
|
updateSearchBar()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 弹出阅读设置面板(字号、字体、行距、分栏、主题、亮度等)。
|
/// 弹出阅读设置面板(字号、字体、行距、分栏、主题、亮度等)。
|
||||||
@ -140,6 +144,29 @@ final class RDEPUBReaderChromeCoordinator {
|
|||||||
controller.present(navigationController, animated: true)
|
controller.present(navigationController, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 切换搜索栏的显示/隐藏状态。
|
||||||
|
func toggleSearchBar() {
|
||||||
|
guard let controller else { return }
|
||||||
|
if controller.isSearchBarVisible {
|
||||||
|
controller.hideSearchBar()
|
||||||
|
} else {
|
||||||
|
controller.showSearchBar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步搜索栏的主题和匹配计数。
|
||||||
|
func updateSearchBar() {
|
||||||
|
guard let controller else { return }
|
||||||
|
controller.searchBarView.apply(theme: controller.configuration.theme)
|
||||||
|
if let searchState = controller.searchState {
|
||||||
|
if let index = searchState.currentMatchIndex {
|
||||||
|
controller.searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
|
||||||
|
} else if searchState.matches.isEmpty {
|
||||||
|
controller.searchBarView.showNoResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 处理返回按钮点击,自动判断 pop 或 dismiss 方式关闭阅读器。
|
/// 处理返回按钮点击,自动判断 pop 或 dismiss 方式关闭阅读器。
|
||||||
func handleBackAction() {
|
func handleBackAction() {
|
||||||
guard let controller else { return }
|
guard let controller else { return }
|
||||||
|
|||||||
@ -50,6 +50,9 @@ final class RDEPUBReaderContext {
|
|||||||
var paginator: RDEPUBPaginator?
|
var paginator: RDEPUBPaginator?
|
||||||
/// 全文搜索状态。
|
/// 全文搜索状态。
|
||||||
var searchState: RDEPUBSearchState?
|
var searchState: RDEPUBSearchState?
|
||||||
|
/// 后台解析完成的完整 BookPageMap,等待用户下次导航时应用。
|
||||||
|
/// 避免后台解析完成时直接替换 map 导致当前阅读位置跳转。
|
||||||
|
var pendingFullPageMap: RDEPUBBookPageMap?
|
||||||
/// 上次文本分页时的页面尺寸,用于检测是否需要重新分页。
|
/// 上次文本分页时的页面尺寸,用于检测是否需要重新分页。
|
||||||
var lastTextPaginationPageSize: CGSize?
|
var lastTextPaginationPageSize: CGSize?
|
||||||
/// 后台元数据解析耗时(毫秒),仅包含 OperationQueue 并行阶段。
|
/// 后台元数据解析耗时(毫秒),仅包含 OperationQueue 并行阶段。
|
||||||
@ -205,18 +208,6 @@ final class RDEPUBReaderContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
|
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||||
let style = currentTextRenderStyle()
|
|
||||||
let pageSize = currentTextPageSize()
|
|
||||||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
|
||||||
let renderSignature = [
|
|
||||||
style.font.fontName,
|
|
||||||
"\(style.font.pointSize)",
|
|
||||||
"\(configuration.lineHeightMultiple)",
|
|
||||||
"\(style.lineSpacing)",
|
|
||||||
layoutConfig.cacheSignature,
|
|
||||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
|
||||||
].joined(separator: "|")
|
|
||||||
|
|
||||||
let contentHash: String
|
let contentHash: String
|
||||||
if let parser,
|
if let parser,
|
||||||
let publication,
|
let publication,
|
||||||
@ -226,15 +217,53 @@ final class RDEPUBReaderContext {
|
|||||||
} else {
|
} else {
|
||||||
contentHash = ""
|
contentHash = ""
|
||||||
}
|
}
|
||||||
|
return chapterCacheKey(
|
||||||
|
forSpineIndex: spineIndex,
|
||||||
|
precomputedContentHash: contentHash,
|
||||||
|
renderSignature: currentRenderSignature()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return RDEPUBChapterCacheKey(
|
/// 使用预计算的 contentHash 构建缓存键,避免重复读取 HTML 和计算 SHA-256。
|
||||||
|
/// 后台批量解析必须走此版本。
|
||||||
|
func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
|
||||||
|
chapterCacheKey(
|
||||||
|
forSpineIndex: spineIndex,
|
||||||
|
precomputedContentHash: precomputedContentHash,
|
||||||
|
renderSignature: currentRenderSignature()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用固定的渲染签名与预计算 contentHash 构建缓存键。
|
||||||
|
/// 适合后台任务在启动时冻结分页参数后复用,避免 live context 漂移。
|
||||||
|
func chapterCacheKey(
|
||||||
|
forSpineIndex spineIndex: Int,
|
||||||
|
precomputedContentHash: String,
|
||||||
|
renderSignature: String
|
||||||
|
) -> RDEPUBChapterCacheKey {
|
||||||
|
RDEPUBChapterCacheKey(
|
||||||
bookID: currentBookIdentifier ?? "",
|
bookID: currentBookIdentifier ?? "",
|
||||||
spineIndex: spineIndex,
|
spineIndex: spineIndex,
|
||||||
renderSignature: renderSignature,
|
renderSignature: renderSignature,
|
||||||
chapterContentHash: contentHash
|
chapterContentHash: precomputedContentHash
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前渲染参数签名,所有章节共享同一值。
|
||||||
|
func currentRenderSignature() -> String {
|
||||||
|
let style = currentTextRenderStyle()
|
||||||
|
let pageSize = currentTextPageSize()
|
||||||
|
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||||
|
return [
|
||||||
|
style.font.fontName,
|
||||||
|
"\(style.font.pointSize)",
|
||||||
|
"\(configuration.lineHeightMultiple)",
|
||||||
|
"\(style.lineSpacing)",
|
||||||
|
layoutConfig.cacheSignature,
|
||||||
|
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||||
|
].joined(separator: "|")
|
||||||
|
}
|
||||||
|
|
||||||
func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? {
|
func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? {
|
||||||
runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex))
|
runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import Foundation
|
|||||||
/// - 重建外部纯文本图书
|
/// - 重建外部纯文本图书
|
||||||
final class RDEPUBReaderPaginationCoordinator {
|
final class RDEPUBReaderPaginationCoordinator {
|
||||||
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
||||||
|
/// 每 N 章刷新一次 pageMap,可通过修改此值实测调优。
|
||||||
|
static var pageMapRefreshInterval: Int = 32
|
||||||
|
|
||||||
private unowned let context: RDEPUBReaderContext
|
private unowned let context: RDEPUBReaderContext
|
||||||
|
|
||||||
@ -80,6 +82,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
guard let controller = context.controller else { return }
|
guard let controller = context.controller else { return }
|
||||||
context.textBook = textBook
|
context.textBook = textBook
|
||||||
context.bookPageMap = nil
|
context.bookPageMap = nil
|
||||||
|
context.pendingFullPageMap = nil
|
||||||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||||||
context.replaceActiveSnapshot(snapshot)
|
context.replaceActiveSnapshot(snapshot)
|
||||||
|
|
||||||
@ -99,6 +102,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
guard context.controller != nil else { return }
|
guard context.controller != nil else { return }
|
||||||
context.textBook = nil
|
context.textBook = nil
|
||||||
context.bookPageMap = nil
|
context.bookPageMap = nil
|
||||||
|
context.pendingFullPageMap = nil
|
||||||
context.replaceActiveSnapshot(snapshot)
|
context.replaceActiveSnapshot(snapshot)
|
||||||
|
|
||||||
guard !snapshot.pages.isEmpty else {
|
guard !snapshot.pages.isEmpty else {
|
||||||
@ -388,6 +392,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
let pageSize = context.currentTextPageSize()
|
let pageSize = context.currentTextPageSize()
|
||||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||||
let style = context.currentTextRenderStyle()
|
let style = context.currentTextRenderStyle()
|
||||||
|
let renderSignature = context.currentRenderSignature()
|
||||||
let allBuildableIndices = allBuildableSpineIndices(in: publication)
|
let allBuildableIndices = allBuildableSpineIndices(in: publication)
|
||||||
let summaryDiskCache = context.runtime?.summaryDiskCache
|
let summaryDiskCache = context.runtime?.summaryDiskCache
|
||||||
let workerCount = max(1, context.configuration.metadataParsingConcurrency)
|
let workerCount = max(1, context.configuration.metadataParsingConcurrency)
|
||||||
@ -397,10 +402,30 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
guard context.controller != nil else { return }
|
guard context.controller != nil else { return }
|
||||||
|
|
||||||
|
// 预计算所有章节的 contentHash,避免后续重复读盘 + SHA-256
|
||||||
|
let prewarmStart = CFAbsoluteTimeGetCurrent()
|
||||||
|
var contentHashBySpineIndex: [Int: String] = [:]
|
||||||
|
for spineIndex in allBuildableIndices {
|
||||||
|
guard let href = publication.spine.indices.contains(spineIndex)
|
||||||
|
? publication.spine[spineIndex].href : nil,
|
||||||
|
let html = parser.htmlString(forRelativePath: href) else {
|
||||||
|
contentHashBySpineIndex[spineIndex] = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contentHashBySpineIndex[spineIndex] = html.sha256Hex
|
||||||
|
}
|
||||||
|
let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
|
||||||
|
RDEPUBBackgroundTrace.log("MetadataParse", "prewarmHashMs=\(prewarmMs) chapters=\(allBuildableIndices.count)")
|
||||||
|
|
||||||
let catalog = allBuildableIndices.map { spineIndex in
|
let catalog = allBuildableIndices.map { spineIndex in
|
||||||
let item = publication.spine[spineIndex]
|
let item = publication.spine[spineIndex]
|
||||||
return (
|
return (
|
||||||
key: context.chapterCacheKey(forSpineIndex: spineIndex),
|
key: context.chapterCacheKey(
|
||||||
|
forSpineIndex: spineIndex,
|
||||||
|
precomputedContentHash: contentHashBySpineIndex[spineIndex] ?? "",
|
||||||
|
renderSignature: renderSignature
|
||||||
|
),
|
||||||
spineIndex: spineIndex,
|
spineIndex: spineIndex,
|
||||||
href: item.href,
|
href: item.href,
|
||||||
title: item.title
|
title: item.title
|
||||||
@ -438,6 +463,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
||||||
var totalRenderMs: Double = 0
|
var totalRenderMs: Double = 0
|
||||||
var totalWriteMs: Double = 0
|
var totalWriteMs: Double = 0
|
||||||
|
var totalMergeMs: Double = 0
|
||||||
var completedChapters = 0
|
var completedChapters = 0
|
||||||
var failedChapters = 0
|
var failedChapters = 0
|
||||||
let timingLock = NSLock()
|
let timingLock = NSLock()
|
||||||
@ -447,6 +473,8 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
queue.qualityOfService = .utility
|
queue.qualityOfService = .utility
|
||||||
queue.maxConcurrentOperationCount = workerCount
|
queue.maxConcurrentOperationCount = workerCount
|
||||||
|
|
||||||
|
let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
|
||||||
|
|
||||||
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
|
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
|
||||||
queue.addOperation {
|
queue.addOperation {
|
||||||
guard context.controller != nil,
|
guard context.controller != nil,
|
||||||
@ -471,7 +499,12 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
|
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
|
||||||
|
|
||||||
let chapter = result.chapter
|
let chapter = result.chapter
|
||||||
let cacheKey = context.chapterCacheKey(forSpineIndex: spineIndex)
|
let precomputedHash = contentHashBySpineIndex[spineIndex] ?? ""
|
||||||
|
let cacheKey = context.chapterCacheKey(
|
||||||
|
forSpineIndex: spineIndex,
|
||||||
|
precomputedContentHash: precomputedHash,
|
||||||
|
renderSignature: renderSignature
|
||||||
|
)
|
||||||
let summary = RDEPUBChapterSummary(
|
let summary = RDEPUBChapterSummary(
|
||||||
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||||
pageCount: chapter.pages.count,
|
pageCount: chapter.pages.count,
|
||||||
@ -500,17 +533,24 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
|
|
||||||
guard let renderResult else { return }
|
guard let renderResult else { return }
|
||||||
|
|
||||||
var partialMap: RDEPUBBookPageMap?
|
// 锁内只做写入和计数,快照数据后锁外构建 pageMap
|
||||||
|
var snapshot: [Int: RDEPUBChapterSummary]?
|
||||||
resultLock.lock()
|
resultLock.lock()
|
||||||
summariesBySpineIndex[spineIndex] = renderResult
|
summariesBySpineIndex[spineIndex] = renderResult
|
||||||
totalResolvedCount += 1
|
totalResolvedCount += 1
|
||||||
if totalResolvedCount - lastAppliedCount >= 32 || totalResolvedCount == allBuildableIndices.count {
|
if totalResolvedCount - lastAppliedCount >= refreshInterval || totalResolvedCount == allBuildableIndices.count {
|
||||||
lastAppliedCount = totalResolvedCount
|
lastAppliedCount = totalResolvedCount
|
||||||
partialMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
|
snapshot = summariesBySpineIndex
|
||||||
}
|
}
|
||||||
resultLock.unlock()
|
resultLock.unlock()
|
||||||
|
|
||||||
if let partialMap {
|
if let snapshot {
|
||||||
|
let mergeStart = CFAbsoluteTimeGetCurrent()
|
||||||
|
let partialMap = self.buildPageMap(from: catalog, summaries: snapshot)
|
||||||
|
let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
|
||||||
|
timingLock.lock()
|
||||||
|
totalMergeMs += mergeElapsed
|
||||||
|
timingLock.unlock()
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard context.paginationToken == token,
|
guard context.paginationToken == token,
|
||||||
context.controller != nil else { return }
|
context.controller != nil else { return }
|
||||||
@ -532,6 +572,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
timingLock.lock()
|
timingLock.lock()
|
||||||
let renderTotal = Int(totalRenderMs)
|
let renderTotal = Int(totalRenderMs)
|
||||||
let writeTotal = Int(totalWriteMs)
|
let writeTotal = Int(totalWriteMs)
|
||||||
|
let mergeTotal = Int(totalMergeMs)
|
||||||
let rendered = completedChapters
|
let rendered = completedChapters
|
||||||
let failed = failedChapters
|
let failed = failedChapters
|
||||||
timingLock.unlock()
|
timingLock.unlock()
|
||||||
@ -539,7 +580,8 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
RDEPUBBackgroundTrace.log(
|
RDEPUBBackgroundTrace.log(
|
||||||
"MetadataParse",
|
"MetadataParse",
|
||||||
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
|
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
|
||||||
"renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
|
"prewarmHashMs=\(prewarmMs) renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) " +
|
||||||
|
"mergeTotalMs=\(mergeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
|
||||||
)
|
)
|
||||||
context.lastMetadataParseWallClockMs = wallClockMs
|
context.lastMetadataParseWallClockMs = wallClockMs
|
||||||
context.lastMetadataParseConcurrency = workerCount
|
context.lastMetadataParseConcurrency = workerCount
|
||||||
@ -550,10 +592,12 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let finalMergeStart = CFAbsoluteTimeGetCurrent()
|
||||||
let pageMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
|
let pageMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
|
||||||
|
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
|
||||||
RDEPUBBackgroundTrace.log(
|
RDEPUBBackgroundTrace.log(
|
||||||
"MetadataParse",
|
"MetadataParse",
|
||||||
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages)"
|
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
|
||||||
)
|
)
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -565,15 +609,23 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
|
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
|
||||||
guard let summaryDiskCache = context.runtime?.summaryDiskCache else {
|
guard let summaryDiskCache = context.runtime?.summaryDiskCache,
|
||||||
|
let parser = context.parser else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
let renderSignature = context.currentRenderSignature()
|
||||||
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
|
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
|
||||||
let item = publication.spine[spineIndex]
|
let item = publication.spine[spineIndex]
|
||||||
|
let href = item.href
|
||||||
|
let contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
|
||||||
return (
|
return (
|
||||||
key: context.chapterCacheKey(forSpineIndex: spineIndex),
|
key: context.chapterCacheKey(
|
||||||
|
forSpineIndex: spineIndex,
|
||||||
|
precomputedContentHash: contentHash,
|
||||||
|
renderSignature: renderSignature
|
||||||
|
),
|
||||||
spineIndex: spineIndex,
|
spineIndex: spineIndex,
|
||||||
href: item.href,
|
href: href,
|
||||||
title: item.title
|
title: item.title
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,6 +52,7 @@ final class RDEPUBReaderRuntime {
|
|||||||
context.readingSession = nil
|
context.readingSession = nil
|
||||||
context.textBook = nil
|
context.textBook = nil
|
||||||
context.bookPageMap = nil
|
context.bookPageMap = nil
|
||||||
|
context.pendingFullPageMap = nil
|
||||||
context.activeBookmarks = []
|
context.activeBookmarks = []
|
||||||
context.activeHighlights = []
|
context.activeHighlights = []
|
||||||
context.searchState = nil
|
context.searchState = nil
|
||||||
@ -312,31 +313,37 @@ final class RDEPUBReaderRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||||||
guard let readerView = context.readerView,
|
// 暂存完整 map,等用户下次导航时再应用,避免当前阅读位置跳转
|
||||||
|
// 此时保留旧 map,用户看到的内容和页码完全不变
|
||||||
|
context.pendingFullPageMap = bookPageMap
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户导航时检查并应用待处理的完整 BookPageMap
|
||||||
|
func applyPendingFullPageMapIfNeeded() {
|
||||||
|
guard let pendingMap = context.pendingFullPageMap,
|
||||||
|
let readerView = context.readerView,
|
||||||
let controller = context.controller else { return }
|
let controller = context.controller else { return }
|
||||||
|
|
||||||
let currentPage = max(readerView.currentPage, 0)
|
context.pendingFullPageMap = nil
|
||||||
|
|
||||||
|
// 保存当前位置(在旧 map 下解析)
|
||||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||||
|
|
||||||
|
// 替换 map 和快照
|
||||||
context.textBook = nil
|
context.textBook = nil
|
||||||
context.bookPageMap = bookPageMap
|
context.bookPageMap = pendingMap
|
||||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
context.replaceActiveSnapshot(makeSnapshot(from: pendingMap))
|
||||||
|
|
||||||
// 仅刷新总页数,不重建页面内容(避免后台元数据解析期间刷新掉用户选区)
|
// 用位置在新 map 中重新解析正确的页码
|
||||||
readerView.reloadPageCountOnly()
|
|
||||||
|
|
||||||
// 用户正在选区或正在滑动翻页时跳过页面跳转,避免打断交互
|
|
||||||
let cv = readerView.collectionView
|
|
||||||
let isUserInteracting = cv.isTracking || cv.isDragging || cv.isDecelerating
|
|
||||||
if context.currentSelection == nil, !isUserInteracting, bookPageMap.totalPages > 0 {
|
|
||||||
let maxValidPage = max(bookPageMap.totalPages - 1, 0)
|
|
||||||
if currentPage > maxValidPage {
|
|
||||||
readerView.transitionToPage(pageNum: maxValidPage, animated: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let currentLocation {
|
if let currentLocation {
|
||||||
locationCoordinator.persist(location: currentLocation)
|
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
|
||||||
} else if let resolvedLocation = controller.resolvedTextLocation(forPageNumber: currentPage + 1) {
|
let newPage = max(0, newPageNumber - 1)
|
||||||
locationCoordinator.persist(location: resolvedLocation)
|
readerView.reloadPageCountOnly()
|
||||||
|
if newPage != readerView.currentPage {
|
||||||
|
readerView.transitionToPage(pageNum: newPage, animated: false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
readerView.reloadPageCountOnly()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -530,6 +537,7 @@ final class RDEPUBReaderRuntime {
|
|||||||
func clearOnDemandPageModeState() {
|
func clearOnDemandPageModeState() {
|
||||||
chapterRuntimeStore.invalidateAllForSettingsChange()
|
chapterRuntimeStore.invalidateAllForSettingsChange()
|
||||||
context.bookPageMap = nil
|
context.bookPageMap = nil
|
||||||
|
context.pendingFullPageMap = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
||||||
|
|||||||
@ -55,6 +55,7 @@ extension RDReaderView {
|
|||||||
collectionView.isUserInteractionEnabled = true
|
collectionView.isUserInteractionEnabled = true
|
||||||
pageViewController.view.isUserInteractionEnabled = true
|
pageViewController.view.isUserInteractionEnabled = true
|
||||||
}
|
}
|
||||||
|
onToolViewVisibilityChanged?(isShowToolView)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 判断点击命中的视图是否在指定工具栏内,用于决定是否拦截点击事件
|
/// 判断点击命中的视图是否在指定工具栏内,用于决定是否拦截点击事件
|
||||||
|
|||||||
@ -168,6 +168,10 @@ public class RDReaderView: UIView {
|
|||||||
public var currentDisplayType: RDReaderView.DisplayType = .pageCurl
|
public var currentDisplayType: RDReaderView.DisplayType = .pageCurl
|
||||||
/// 工具栏显示/隐藏动画时长,默认 0.3 秒
|
/// 工具栏显示/隐藏动画时长,默认 0.3 秒
|
||||||
public var toolViewAnimationDuration: TimeInterval = 0.3
|
public var toolViewAnimationDuration: TimeInterval = 0.3
|
||||||
|
/// 工具栏可见性变化回调,参数为是否可见
|
||||||
|
var onToolViewVisibilityChanged: ((Bool) -> Void)?
|
||||||
|
/// 搜索栏视图,点击时不触发 tapCenter
|
||||||
|
var searchBarView: UIView?
|
||||||
/// 是否启用横屏双页显示
|
/// 是否启用横屏双页显示
|
||||||
public var landscapeDualPageEnabled: Bool = false
|
public var landscapeDualPageEnabled: Bool = false
|
||||||
/// 翻页方向,默认从左往右(适用于中文/英文书籍)
|
/// 翻页方向,默认从左往右(适用于中文/英文书籍)
|
||||||
@ -777,6 +781,9 @@ extension RDReaderView: UIGestureRecognizerDelegate {
|
|||||||
if let bottomToolView, isHitView(touch.view, inside: bottomToolView, point: point) {
|
if let bottomToolView, isHitView(touch.view, inside: bottomToolView, point: point) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if let searchBarView, isHitView(touch.view, inside: searchBarView, point: point) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
159
scripts/run_ui_regression.sh
Executable file
159
scripts/run_ui_regression.sh
Executable file
@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -u
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PROJECT_PATH="${PROJECT_PATH:-$ROOT_DIR/ReadViewDemo/ReadViewDemo.xcodeproj}"
|
||||||
|
SCHEME_NAME="${SCHEME_NAME:-ReadViewDemo}"
|
||||||
|
CONFIGURATION="${CONFIGURATION:-Debug}"
|
||||||
|
SIMULATOR_NAME="${SIMULATOR_NAME:-iPhone 16 Plus}"
|
||||||
|
DESTINATION="${DESTINATION:-platform=iOS Simulator,name=${SIMULATOR_NAME}}"
|
||||||
|
RESULTS_ROOT="${RESULTS_ROOT:-$ROOT_DIR/.artifacts/ui-tests}"
|
||||||
|
DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$RESULTS_ROOT/DerivedData}"
|
||||||
|
ONLY_TESTING="${ONLY_TESTING:-}"
|
||||||
|
TEST_PLAN="${TEST_PLAN:-}"
|
||||||
|
TEST_TYPE_ARG="${1:-}"
|
||||||
|
TEST_TYPE="${TEST_TYPE:-${TEST_TYPE_ARG:-quick}}"
|
||||||
|
TIMESTAMP="$(date +"%Y%m%d-%H%M%S")"
|
||||||
|
RUN_DIR="$RESULTS_ROOT/$TIMESTAMP"
|
||||||
|
XCRESULT_PATH="$RUN_DIR/ReadViewDemoUITests.xcresult"
|
||||||
|
RAW_LOG_PATH="$RUN_DIR/xcodebuild.log"
|
||||||
|
REPORT_MD_PATH="$RUN_DIR/UI-Test-Report.md"
|
||||||
|
REPORT_JSON_PATH="$RUN_DIR/ui-test-results.json"
|
||||||
|
REPORT_TXT_PATH="$RUN_DIR/summary.txt"
|
||||||
|
SUMMARY_SCRIPT="$ROOT_DIR/scripts/summarize_ui_results.py"
|
||||||
|
|
||||||
|
TARGET_NAME="ReadViewDemoUITests"
|
||||||
|
|
||||||
|
QUICK_TESTS=(
|
||||||
|
BookmarkManagementTests
|
||||||
|
BookmarkTests
|
||||||
|
DisplayTypeTests
|
||||||
|
ErrorAndEdgeCaseTests
|
||||||
|
HighlightsManagementTests
|
||||||
|
LocationPersistenceTests
|
||||||
|
PageNavigationTests
|
||||||
|
ReaderAnnotationExtendedTests
|
||||||
|
ReaderAnnotationTests
|
||||||
|
ReaderOpenCloseTests
|
||||||
|
ReaderToolbarTests
|
||||||
|
SearchTests
|
||||||
|
SelectionMenuTests
|
||||||
|
SettingsEffectTests
|
||||||
|
SettingsExtendedTests
|
||||||
|
SettingsPanelTests
|
||||||
|
TOCInteractionTests
|
||||||
|
TableOfContentsTests
|
||||||
|
ToolbarStateTests
|
||||||
|
)
|
||||||
|
|
||||||
|
LARGE_BOOK_TESTS=(
|
||||||
|
ConfigurableWindowTests
|
||||||
|
ConcurrentParsingTests
|
||||||
|
LargeBookOnDemandTests
|
||||||
|
)
|
||||||
|
|
||||||
|
PERFORMANCE_TESTS=(
|
||||||
|
FanrenParseTimeTest
|
||||||
|
MetadataParseBenchmarkTests
|
||||||
|
)
|
||||||
|
|
||||||
|
mkdir -p "$RUN_DIR" "$DERIVED_DATA_PATH"
|
||||||
|
|
||||||
|
append_only_testing_items() {
|
||||||
|
local raw_list="$1"
|
||||||
|
local item
|
||||||
|
IFS=',' read -r -a ONLY_TESTING_ITEMS <<< "$raw_list"
|
||||||
|
for item in "${ONLY_TESTING_ITEMS[@]}"; do
|
||||||
|
local trimmed_item
|
||||||
|
trimmed_item="$(echo "$item" | xargs)"
|
||||||
|
if [[ -n "$trimmed_item" ]]; then
|
||||||
|
XCODEBUILD_ARGS+=(-only-testing:"$trimmed_item")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_test_type_filter() {
|
||||||
|
local selected_type="$1"
|
||||||
|
local test_name
|
||||||
|
case "$selected_type" in
|
||||||
|
quick)
|
||||||
|
for test_name in "${QUICK_TESTS[@]}"; do
|
||||||
|
XCODEBUILD_ARGS+=(-only-testing:"$TARGET_NAME/$test_name")
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
large-book)
|
||||||
|
for test_name in "${LARGE_BOOK_TESTS[@]}"; do
|
||||||
|
XCODEBUILD_ARGS+=(-only-testing:"$TARGET_NAME/$test_name")
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
performance)
|
||||||
|
for test_name in "${PERFORMANCE_TESTS[@]}"; do
|
||||||
|
XCODEBUILD_ARGS+=(-only-testing:"$TARGET_NAME/$test_name")
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported TEST_TYPE: $selected_type" >&2
|
||||||
|
echo "Supported values: quick, large-book, performance, all" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "UI regression run"
|
||||||
|
echo " project: $PROJECT_PATH"
|
||||||
|
echo " scheme: $SCHEME_NAME"
|
||||||
|
echo " configuration:$CONFIGURATION"
|
||||||
|
echo " destination: $DESTINATION"
|
||||||
|
echo " test type: $TEST_TYPE"
|
||||||
|
echo " results: $RUN_DIR"
|
||||||
|
|
||||||
|
XCODEBUILD_ARGS=(
|
||||||
|
xcodebuild
|
||||||
|
test
|
||||||
|
-project "$PROJECT_PATH"
|
||||||
|
-scheme "$SCHEME_NAME"
|
||||||
|
-configuration "$CONFIGURATION"
|
||||||
|
-destination "$DESTINATION"
|
||||||
|
-resultBundlePath "$XCRESULT_PATH"
|
||||||
|
-derivedDataPath "$DERIVED_DATA_PATH"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ -n "$TEST_PLAN" ]]; then
|
||||||
|
XCODEBUILD_ARGS+=(-testPlan "$TEST_PLAN")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$ONLY_TESTING" ]]; then
|
||||||
|
append_only_testing_items "$ONLY_TESTING"
|
||||||
|
else
|
||||||
|
apply_test_type_filter "$TEST_TYPE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
set +e
|
||||||
|
"${XCODEBUILD_ARGS[@]}" 2>&1 | tee "$RAW_LOG_PATH"
|
||||||
|
TEST_EXIT_CODE=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
|
||||||
|
python3 "$SUMMARY_SCRIPT" \
|
||||||
|
--xcresult "$XCRESULT_PATH" \
|
||||||
|
--raw-log "$RAW_LOG_PATH" \
|
||||||
|
--output-md "$REPORT_MD_PATH" \
|
||||||
|
--output-json "$REPORT_JSON_PATH" \
|
||||||
|
--output-txt "$REPORT_TXT_PATH" \
|
||||||
|
--test-type "$TEST_TYPE" \
|
||||||
|
--xcodebuild-exit-code "$TEST_EXIT_CODE"
|
||||||
|
|
||||||
|
echo
|
||||||
|
cat "$REPORT_TXT_PATH"
|
||||||
|
echo
|
||||||
|
echo "Artifacts"
|
||||||
|
echo " summary: $REPORT_TXT_PATH"
|
||||||
|
echo " report: $REPORT_MD_PATH"
|
||||||
|
echo " json: $REPORT_JSON_PATH"
|
||||||
|
echo " xcresult: $XCRESULT_PATH"
|
||||||
|
echo " raw log: $RAW_LOG_PATH"
|
||||||
|
|
||||||
|
exit "$TEST_EXIT_CODE"
|
||||||
465
scripts/summarize_ui_results.py
Executable file
465
scripts/summarize_ui_results.py
Executable file
@ -0,0 +1,465 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FailureItem:
|
||||||
|
test_name: str
|
||||||
|
target_name: str
|
||||||
|
category: str
|
||||||
|
summary: str
|
||||||
|
failure_text: str
|
||||||
|
test_identifier: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def run_json_command(command: list[str]) -> dict[str, Any] | list[Any] | None:
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
command,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.CalledProcessError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
stdout = completed.stdout.strip()
|
||||||
|
if not stdout:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compact_text(text: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_failure_text(text: str) -> str:
|
||||||
|
compact = compact_text(text)
|
||||||
|
if not compact:
|
||||||
|
return "No failure message captured"
|
||||||
|
|
||||||
|
for delimiter in (" - ", ". ", "\n", "…"):
|
||||||
|
if delimiter in compact:
|
||||||
|
head = compact.split(delimiter, 1)[0].strip()
|
||||||
|
if len(head) >= 12:
|
||||||
|
return head[:220]
|
||||||
|
return compact[:220]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_failure(text: str, test_name: str) -> str:
|
||||||
|
haystack = f"{test_name} {text}".lower()
|
||||||
|
|
||||||
|
if any(token in haystack for token in [
|
||||||
|
"timed out",
|
||||||
|
"timeout",
|
||||||
|
"failed to fulfill",
|
||||||
|
"waitfordemoreaderstate",
|
||||||
|
"waitforexistence",
|
||||||
|
"等待",
|
||||||
|
"超时",
|
||||||
|
]):
|
||||||
|
return "Timeout"
|
||||||
|
|
||||||
|
if any(token in haystack for token in [
|
||||||
|
"no matches found",
|
||||||
|
"failed to find",
|
||||||
|
"does not exist",
|
||||||
|
"existence",
|
||||||
|
"not hittable",
|
||||||
|
"不存在",
|
||||||
|
"未出现",
|
||||||
|
]):
|
||||||
|
return "Element Missing"
|
||||||
|
|
||||||
|
if any(token in haystack for token in [
|
||||||
|
"simulator",
|
||||||
|
"lost connection",
|
||||||
|
"test runner exited",
|
||||||
|
"application state",
|
||||||
|
"quit unexpectedly",
|
||||||
|
"connection interrupted",
|
||||||
|
"xcodebuild",
|
||||||
|
]):
|
||||||
|
return "Environment"
|
||||||
|
|
||||||
|
if any(token in haystack for token in [
|
||||||
|
"page",
|
||||||
|
"pagination",
|
||||||
|
"bookpagemap",
|
||||||
|
"parsems",
|
||||||
|
"readerstate",
|
||||||
|
"location",
|
||||||
|
"页码",
|
||||||
|
"分页",
|
||||||
|
"位置",
|
||||||
|
]):
|
||||||
|
return "Navigation/State"
|
||||||
|
|
||||||
|
if any(token in haystack for token in [
|
||||||
|
"xctassert",
|
||||||
|
"assertion",
|
||||||
|
"xctfail",
|
||||||
|
"failed:",
|
||||||
|
]):
|
||||||
|
return "Assertion Failure"
|
||||||
|
|
||||||
|
return "Functional Failure"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_root_cause(text: str) -> str:
|
||||||
|
compact = summarize_failure_text(text).lower()
|
||||||
|
compact = re.sub(r"\b\d+\b", "<n>", compact)
|
||||||
|
compact = re.sub(r"'[^']+'", "'<value>'", compact)
|
||||||
|
compact = re.sub(r'"[^"]+"', '"<value>"', compact)
|
||||||
|
return compact[:180]
|
||||||
|
|
||||||
|
|
||||||
|
def load_summary_from_xcresult(xcresult_path: Path) -> dict[str, Any] | None:
|
||||||
|
return run_json_command([
|
||||||
|
"xcrun",
|
||||||
|
"xcresulttool",
|
||||||
|
"get",
|
||||||
|
"test-results",
|
||||||
|
"summary",
|
||||||
|
"--path",
|
||||||
|
str(xcresult_path),
|
||||||
|
"--compact",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def load_tests_from_xcresult(xcresult_path: Path) -> dict[str, Any] | None:
|
||||||
|
return run_json_command([
|
||||||
|
"xcrun",
|
||||||
|
"xcresulttool",
|
||||||
|
"get",
|
||||||
|
"test-results",
|
||||||
|
"tests",
|
||||||
|
"--path",
|
||||||
|
str(xcresult_path),
|
||||||
|
"--compact",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_failed_test_nodes(node: dict[str, Any], out: list[dict[str, Any]]) -> None:
|
||||||
|
node_type = node.get("nodeType")
|
||||||
|
result = node.get("result")
|
||||||
|
if node_type == "Test Case" and result == "Failed":
|
||||||
|
out.append(node)
|
||||||
|
for child in node.get("children", []) or []:
|
||||||
|
flatten_failed_test_nodes(child, out)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_failures(
|
||||||
|
summary: dict[str, Any] | None,
|
||||||
|
tests_payload: dict[str, Any] | None,
|
||||||
|
raw_log_path: Path | None,
|
||||||
|
) -> list[FailureItem]:
|
||||||
|
failures: list[FailureItem] = []
|
||||||
|
|
||||||
|
raw_failures = None
|
||||||
|
if summary:
|
||||||
|
raw_failures = summary.get("testFailures")
|
||||||
|
|
||||||
|
if isinstance(raw_failures, dict):
|
||||||
|
raw_failures = [raw_failures]
|
||||||
|
|
||||||
|
if isinstance(raw_failures, list):
|
||||||
|
for item in raw_failures:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
test_name = item.get("testName") or "Unknown test"
|
||||||
|
target_name = item.get("targetName") or "Unknown target"
|
||||||
|
failure_text = item.get("failureText") or ""
|
||||||
|
failures.append(FailureItem(
|
||||||
|
test_name=test_name,
|
||||||
|
target_name=target_name,
|
||||||
|
category=classify_failure(failure_text, test_name),
|
||||||
|
summary=summarize_failure_text(failure_text),
|
||||||
|
failure_text=compact_text(failure_text),
|
||||||
|
test_identifier=item.get("testIdentifierString") or item.get("testIdentifierURL"),
|
||||||
|
))
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
return dedupe_failures(failures)
|
||||||
|
|
||||||
|
if tests_payload:
|
||||||
|
failed_nodes: list[dict[str, Any]] = []
|
||||||
|
for node in tests_payload.get("testNodes", []) or []:
|
||||||
|
flatten_failed_test_nodes(node, failed_nodes)
|
||||||
|
for node in failed_nodes:
|
||||||
|
test_name = node.get("name") or "Unknown test"
|
||||||
|
details = node.get("details") or ""
|
||||||
|
failures.append(FailureItem(
|
||||||
|
test_name=test_name,
|
||||||
|
target_name="Unknown target",
|
||||||
|
category=classify_failure(details, test_name),
|
||||||
|
summary=summarize_failure_text(details),
|
||||||
|
failure_text=compact_text(details),
|
||||||
|
test_identifier=node.get("nodeIdentifier") or node.get("nodeIdentifierURL"),
|
||||||
|
))
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
return dedupe_failures(failures)
|
||||||
|
|
||||||
|
if raw_log_path and raw_log_path.exists():
|
||||||
|
log_text = raw_log_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
matches = re.findall(
|
||||||
|
r"Test Case '-\[(.*?)\]' failed .*?\n(.*?)(?=\nTest Case '-\[|\Z)",
|
||||||
|
log_text,
|
||||||
|
flags=re.S,
|
||||||
|
)
|
||||||
|
for test_name, block in matches:
|
||||||
|
failure_text = compact_text(block)
|
||||||
|
failures.append(FailureItem(
|
||||||
|
test_name=test_name,
|
||||||
|
target_name="Unknown target",
|
||||||
|
category=classify_failure(failure_text, test_name),
|
||||||
|
summary=summarize_failure_text(failure_text),
|
||||||
|
failure_text=failure_text,
|
||||||
|
))
|
||||||
|
|
||||||
|
return dedupe_failures(failures)
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_failures(failures: list[FailureItem]) -> list[FailureItem]:
|
||||||
|
deduped: dict[tuple[str, str], FailureItem] = {}
|
||||||
|
for failure in failures:
|
||||||
|
key = (failure.test_name, failure.summary)
|
||||||
|
deduped.setdefault(key, failure)
|
||||||
|
return list(deduped.values())
|
||||||
|
|
||||||
|
|
||||||
|
def build_root_causes(failures: list[FailureItem]) -> list[dict[str, Any]]:
|
||||||
|
grouped: dict[str, list[FailureItem]] = defaultdict(list)
|
||||||
|
for failure in failures:
|
||||||
|
grouped[normalize_root_cause(failure.failure_text)].append(failure)
|
||||||
|
|
||||||
|
ranked = sorted(
|
||||||
|
grouped.items(),
|
||||||
|
key=lambda item: (-len(item[1]), item[0]),
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for normalized_text, items in ranked[:5]:
|
||||||
|
result.append({
|
||||||
|
"summary": items[0].summary,
|
||||||
|
"category": items[0].category,
|
||||||
|
"count": len(items),
|
||||||
|
"tests": [failure.test_name for failure in items[:5]],
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_report_payload(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
summary: dict[str, Any] | None,
|
||||||
|
tests_payload: dict[str, Any] | None,
|
||||||
|
failures: list[FailureItem],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
device_summaries = []
|
||||||
|
if summary:
|
||||||
|
devices_and_configurations = summary.get("devicesAndConfigurations")
|
||||||
|
if isinstance(devices_and_configurations, dict):
|
||||||
|
devices_and_configurations = [devices_and_configurations]
|
||||||
|
if isinstance(devices_and_configurations, list):
|
||||||
|
for item in devices_and_configurations:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
device = item.get("device") or {}
|
||||||
|
config = item.get("testPlanConfiguration") or {}
|
||||||
|
device_summaries.append({
|
||||||
|
"device_name": device.get("deviceName"),
|
||||||
|
"model_name": device.get("modelName"),
|
||||||
|
"os_version": device.get("osVersion"),
|
||||||
|
"configuration_name": config.get("configurationName"),
|
||||||
|
"passed_tests": item.get("passedTests"),
|
||||||
|
"failed_tests": item.get("failedTests"),
|
||||||
|
})
|
||||||
|
|
||||||
|
categories = Counter(failure.category for failure in failures)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"xcodebuild_exit_code": args.xcodebuild_exit_code,
|
||||||
|
"test_type": args.test_type,
|
||||||
|
"artifacts": {
|
||||||
|
"xcresult": str(Path(args.xcresult).resolve()) if args.xcresult else None,
|
||||||
|
"raw_log": str(Path(args.raw_log).resolve()) if args.raw_log else None,
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"title": summary.get("title") if summary else None,
|
||||||
|
"environment_description": summary.get("environmentDescription") if summary else None,
|
||||||
|
"result": summary.get("result") if summary else ("Failed" if failures else "Passed"),
|
||||||
|
"total_test_count": summary.get("totalTestCount") if summary else None,
|
||||||
|
"passed_tests": summary.get("passedTests") if summary else None,
|
||||||
|
"failed_tests": summary.get("failedTests") if summary else len(failures),
|
||||||
|
"skipped_tests": summary.get("skippedTests") if summary else None,
|
||||||
|
"devices": device_summaries,
|
||||||
|
},
|
||||||
|
"failure_categories": dict(sorted(categories.items(), key=lambda item: (-item[1], item[0]))),
|
||||||
|
"top_root_causes": build_root_causes(failures),
|
||||||
|
"failures": [asdict(failure) for failure in failures],
|
||||||
|
"token_saving_notes": [
|
||||||
|
"Raw xcodebuild output is intentionally omitted from this report.",
|
||||||
|
"Use the xcresult or raw log paths only when a single failure needs deeper inspection.",
|
||||||
|
"Failures are deduplicated by test name and compact summary.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if tests_payload and not payload["summary"]["total_test_count"]:
|
||||||
|
flat_failed_nodes: list[dict[str, Any]] = []
|
||||||
|
for node in tests_payload.get("testNodes", []) or []:
|
||||||
|
flatten_failed_test_nodes(node, flat_failed_nodes)
|
||||||
|
payload["summary"]["failed_tests"] = len(flat_failed_nodes)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown(payload: dict[str, Any]) -> str:
|
||||||
|
summary = payload["summary"]
|
||||||
|
lines = [
|
||||||
|
"# UI Test Report",
|
||||||
|
"",
|
||||||
|
"## Snapshot",
|
||||||
|
"",
|
||||||
|
f"- Test type: `{payload.get('test_type')}`",
|
||||||
|
f"- Result: `{summary.get('result')}`",
|
||||||
|
f"- xcodebuild exit code: `{payload['xcodebuild_exit_code']}`",
|
||||||
|
f"- Total tests: `{summary.get('total_test_count')}`",
|
||||||
|
f"- Passed: `{summary.get('passed_tests')}`",
|
||||||
|
f"- Failed: `{summary.get('failed_tests')}`",
|
||||||
|
f"- Skipped: `{summary.get('skipped_tests')}`",
|
||||||
|
]
|
||||||
|
|
||||||
|
if summary.get("environment_description"):
|
||||||
|
lines.append(f"- Environment: `{summary['environment_description']}`")
|
||||||
|
|
||||||
|
artifacts = payload["artifacts"]
|
||||||
|
lines.extend([
|
||||||
|
f"- xcresult: `{artifacts.get('xcresult')}`",
|
||||||
|
f"- raw log: `{artifacts.get('raw_log')}`",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
if payload["failure_categories"]:
|
||||||
|
lines.extend([
|
||||||
|
"## Failure Categories",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
for category, count in payload["failure_categories"].items():
|
||||||
|
lines.append(f"- `{category}`: {count}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if payload["top_root_causes"]:
|
||||||
|
lines.extend([
|
||||||
|
"## Top Root Causes",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
for item in payload["top_root_causes"]:
|
||||||
|
lines.append(f"- `{item['category']}` x{item['count']}: {item['summary']}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
"## Failed Tests",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
if payload["failures"]:
|
||||||
|
for failure in payload["failures"]:
|
||||||
|
lines.append(
|
||||||
|
f"- `{failure['test_name']}` [{failure['category']}] {failure['summary']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("- None")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
"## Token-Saving Notes",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
for note in payload["token_saving_notes"]:
|
||||||
|
lines.append(f"- {note}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_text_summary(payload: dict[str, Any]) -> str:
|
||||||
|
summary = payload["summary"]
|
||||||
|
total = summary.get("total_test_count")
|
||||||
|
passed = summary.get("passed_tests")
|
||||||
|
failed = summary.get("failed_tests")
|
||||||
|
skipped = summary.get("skipped_tests")
|
||||||
|
lines = [
|
||||||
|
"UI test summary",
|
||||||
|
f" type: {payload.get('test_type')}",
|
||||||
|
f" result: {summary.get('result')}",
|
||||||
|
f" tests: total={total} passed={passed} failed={failed} skipped={skipped}",
|
||||||
|
]
|
||||||
|
if payload["failure_categories"]:
|
||||||
|
category_text = ", ".join(
|
||||||
|
f"{name}={count}" for name, count in payload["failure_categories"].items()
|
||||||
|
)
|
||||||
|
lines.append(f" categories: {category_text}")
|
||||||
|
if payload["top_root_causes"]:
|
||||||
|
top = payload["top_root_causes"][0]
|
||||||
|
lines.append(f" top root cause: {top['summary']} ({top['count']} tests)")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Summarize UI test xcresult output into a low-token report.")
|
||||||
|
parser.add_argument("--xcresult", help="Path to .xcresult bundle", default="")
|
||||||
|
parser.add_argument("--raw-log", help="Path to xcodebuild raw log", default="")
|
||||||
|
parser.add_argument("--output-md", required=True, help="Markdown report output path")
|
||||||
|
parser.add_argument("--output-json", required=True, help="JSON report output path")
|
||||||
|
parser.add_argument("--output-txt", required=True, help="Plain-text summary output path")
|
||||||
|
parser.add_argument("--test-type", default="unknown", help="Selected UI test group")
|
||||||
|
parser.add_argument("--xcodebuild-exit-code", type=int, required=True, help="Exit code from xcodebuild")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
xcresult_path = Path(args.xcresult) if args.xcresult else None
|
||||||
|
raw_log_path = Path(args.raw_log) if args.raw_log else None
|
||||||
|
|
||||||
|
summary = None
|
||||||
|
tests_payload = None
|
||||||
|
if xcresult_path and xcresult_path.exists():
|
||||||
|
summary = load_summary_from_xcresult(xcresult_path)
|
||||||
|
tests_payload = load_tests_from_xcresult(xcresult_path)
|
||||||
|
|
||||||
|
failures = extract_failures(summary, tests_payload, raw_log_path)
|
||||||
|
payload = build_report_payload(args, summary, tests_payload, failures)
|
||||||
|
|
||||||
|
output_md = Path(args.output_md)
|
||||||
|
output_json = Path(args.output_json)
|
||||||
|
output_txt = Path(args.output_txt)
|
||||||
|
output_md.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
output_json.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
output_md.write_text(render_markdown(payload) + "\n", encoding="utf-8")
|
||||||
|
output_txt.write_text(render_text_summary(payload) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Reference in New Issue
Block a user