- 新增字体选择(系统/宋体/圆体/等宽)与暗色图片柔化配置 - 文本选择改为自定义手势+操作栏(拷贝/高亮/批注) - 添加 accessibilityIdentifier 支持自动化 UI 测试 - 新增 UITests 覆盖阅读器打开/关闭、工具栏、设置面板、批注等 - 添加 Demo 测试用 EPUB 书源(宝山辽墓材料与释读) - 新增文档:UI 自动化测试、功能开发计划、阅读器规划
21 KiB
阅读器功能开发计划
基于 阅读器规划.md 中的三方对比,本文档给出阅读器功能的开发计划与当前落地状态。 功能完整度部分(书架、批注导出、阅读统计、TTS、全局搜索、云端同步、夜间定时)暂不展开。
一、渲染质量
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 自动化已覆盖设置面板字体选择。
后续增强计划:
- 引入更多内置字体包(建议:思源宋体、思源黑体、方正书宋、方正兰亭黑、Lora、OpenDyslexic)。
- 使用
CTFontManagerRegisterFontsForURL注册 bundle 字体。 - 字体列表从枚举升级为资源驱动模型,每项包含
displayName、fontName、previewText、可用性状态。 - 字体选择 UI 从分段控件升级为横向预览卡片,展示真实字体效果。
- 增加字体资源加载失败兜底,失败时回退系统字体并保留用户设置。
2. 暗色模式图片处理
当前状态:✅ 基础能力已完成。
暗色主题下会对正文图片做显示副本调暗,降低白底图片在深色背景上的刺眼程度。处理只作用于展示层,不修改分页原始内容;封面和小图会跳过,避免误处理封面、图标和装饰图。
已落地文件清单:
| 文件 | 改动 | 状态 |
|---|---|---|
RDEPUBReaderConfiguration.swift |
新增 darkImageAdjustmentEnabled 和 darkImageBlendRatio |
已完成 |
RDEPUBReaderController+RuntimeBridge.swift |
主题和暗色图片配置变更触发可见页刷新 | 已完成 |
RDEPUBTextContentView.swift |
DTCoreText 展示内容增加暗色图片显示副本处理 | 已完成 |
RDEPUBTextContentView.swift |
增加暗色图片缓存,避免重复生成 | 已完成 |
SettingsPanelTests.swift |
UI 自动化覆盖暗色主题切换 | 已完成 |
当前实现策略:
- 仅当背景亮度低于阈值、配置开启、混合比例大于 0 时启用。
- 仅处理正文 inline image attachment。
- 跳过封面和小于 80x80 的图片。
- 使用
NSCache缓存处理后的图片。 - 使用主题背景色按比例覆盖原图,透明区域保持透明。
当前验收结果:
- ✅ 暗色模式下图片不再完全以原始亮色块直出。
- ✅ 图片内容仍保持可辨认,不做反色。
- ✅ 处理结果有缓存。
- ✅ UI 自动化已覆盖暗色主题入口。
后续增强计划:
- 增加真实 EPUB 图片样本的截图回归测试。
- 按图片平均亮度自适应
darkImageBlendRatio。 - 增加用户侧开关,允许关闭暗色图片处理。
- 如果后续恢复 WebView 路径,再补充 CSS filter 策略。
3. 简繁转换
当前状态:零实现。语言元数据已提取(publication.metadata.language),但仅用于拉丁/CJK CSS 分轨。
WXRead 实现参考:
WREpubTypesetter检测book.language含Hant/TW/HK时调用_WRConvertHansToHantIfNeeded()- 使用
CFStringTransform两步转换:Hans → Latin → Hant
实施步骤:
Step 1:转换工具
- 新增
RDEPUBTextChineseConverter.swift,提供:enum RDEPUBChineseScript { case hans, hant } func convert(_ text: String, to script: RDEPUBChineseScript) -> String - 实现使用
CFStringTransform:
或直接使用 Apple 的let mutable = NSMutableString(string: text) as CFMutableString CFStringTransform(mutable, nil, kCFStringTransformToLatin, false) // Hans → Pinyin CFStringTransform(mutable, nil, kCFStringTransformStripDiacritics, false) // Pinyin → stripped // 然后通过字典映射到繁体kCFStringTransformHansToHant(如果可用)。
Step 2:判断是否需要转换
3. 在 RDEPUBTextBookBuilder.build() 或 RDEPUBTextRendererSupport.makeChapterRenderRequest() 中:
- 读取
publication.metadata.language - 如果语言为
zh-Hant/zh-TW/zh-HK且用户设置为简体,或语言为zh-Hans/zh-CN且用户设置为繁体,触发转换
- 新增
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 路径
- 在
RDEPUBTextLayouter.layoutFramesUsingCoreText()的分页循环中(~行 63-137),在adjustedRange之后、追加 frame 之前,增加寡行检查:if config.avoidWidows { finalRange = trimmedRangeForAvoidWidows( from: ctFrame, proposed: finalRange, lineRanges: lineRanges ) } 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 降级路径
- 在
RDEPUBTextRendererSupport.paragraphStyle(lineSpacing:)(行 929-934)中增加:style.hyphenationFactor = 1.0 - 在
normalizeReadingAttributes()(行 137-143)中,对已有的NSMutableParagraphStyle增加:paragraphStyle.hyphenationFactor = 1.0
Step 2:CoreText 直绘路径
3. 在 RDEPUBTextLayouter 的 frame 构造中,对 CTParagraphStyle 增加 hyphenation factor:
var hyphenationFactor: Float = 1.0
let settings = [CTParagraphStyleSetting(
spec: .hyphenationFactor,
valueSize: MemoryLayout<Float>.size,
value: &hyphenationFactor
)]
let paragraphStyle = CTParagraphStyleCreate(settings, settings.count)
- 将此 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 测试 | ✅ 已完成 |
当前验证结果:
xcodebuild build: BUILD SUCCEEDED
SettingsPanelTests: TEST SUCCEEDED
ReadViewDemoUITests 全量 8 个 UI 测试: TEST SUCCEEDED
下一步实施步骤:
Step 1:单元测试基础设施
- 在
ReadViewDemo.xcodeproj中新增 Unit Test targetReadViewSDKTests - 创建
Tests/目录,配置import RDReaderView或通过 Demo target 暴露内部测试入口 - 新增
.xctestplan配置,把 UI 测试和单元测试拆分为不同测试组
Step 2:分页回归测试(最高优先级)
4. 新增 RDEPUBPaginationRegressionTests.swift,固定 6 类样本章节:
- 纯正文章节
- 图 + 图注 + 正文章节
- 脚注小图标章节
- 多段标题章节
- 大图跨页章节
- 长段落连续章节
- 每个样本:构建
RDEPUBTextBook,断言总页数不变、已知页的contentRange不变 - 使用 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 集成
- 在
RDEPUBTextPerformanceSampler中引入os.signpost:import os.signpost let log = OSLog(subsystem: "com.rdreader", category: .pointsOfInterest) - 在 build/render/paginate 各阶段插入
os_signpost(.begin, ...)/os_signpost(.end, ...) - 这样 Instruments 的 Points of Interest 能直接可视化各阶段耗时
Step 2:内存监控
4. 新增 RDEPUBMemoryMonitor:
func currentMemoryFootprint() -> UInt64 // task_info.resident_size
- 在
RDEPUBTextBookBuilder.build()的每章循环中记录内存峰值 - 在
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 捕获
- 新增
RDEPUBCrashGuard工具类:static func performSafely(_ block: () throws -> Void) rethrows static func performWithObjCExceptionHandling(_ block: () -> Void) -> Bool - 在关键入口包裹
@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:内存警告响应
- 在
RDEPUBReaderController中监听UIApplication.didReceiveMemoryWarningNotification - 收到警告时:
- 清空
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:章节级构建接口
- 在
RDEPUBTextBookBuilder上新增:func buildChapter( at spineIndex: Int, publication: RDEPUBPublication, parser: RDEPUBParser, pageSize: CGSize, style: RDEPUBTextRenderStyle ) -> RDEPUBTextChapter? - 内部逻辑从
build()的循环体中提取,单章可独立构建
Step 2:按需加载
3. RDEPUBReaderLoadCoordinator 在打开书时只构建当前章节和相邻 ±1 章
4. 用户翻到新章节时,后台异步构建该章节
5. 使用 DispatchQueue 串行队列保证线程安全
Step 3:与缓存联动
6. RDEPUBPaginationCacheCoordinator 支持单章缓存命中检查
7. 缓存命中时跳过构建,直接加载
验收标准:
- 大书首次打开时间缩短(只构建 3 章而非全书)
- 翻到未构建章节时无明显卡顿(后台预构建)
- 全书构建 API 保持不变(兼容现有调用方)
11. 缓存管理
当前状态:RDEPUBTextBookCache 磁盘缓存无大小限制、无淘汰策略、无选择性失效。无内存缓存。
实施步骤:
Step 1:磁盘缓存容量控制
- 新增
maxDiskCacheSize: UInt64(默认 200MB) - 在
save(_:key:)写入后检查总大小 - 超限时按修改时间删除最旧的缓存文件,直到低于阈值
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:工具栏无障碍(最低门槛)
RDEPUBReaderTopToolView:backButton.accessibilityLabel = "返回"bookmarkButton.accessibilityLabel = "书签"+accessibilityValue反映当前状态(已添加/未添加)titleLabel.accessibilityLabel= 书籍标题
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 变化。
实施步骤:
- 在
RDEPUBReaderSettingsViewController中监听UIContentSizeCategory.didChangeNotification - 当系统字体大小变化时,根据新的
UIContentSizeCategory计算等效字号 - 或者:在
RDEPUBReaderConfiguration中增加useSystemFontSize: Bool,启用时忽略fontSize,使用系统推荐值 - 字号映射表(参考 Apple 的
preferredFont(forTextStyle:)返回值)
验收标准:
- 系统字体调大后,阅读器字号自动跟随
- 用户手动调节字号后,覆盖系统设置
14. 高对比度
当前状态:6 个固定主题预设,不跟随系统 UIAccessibility.isDarkerSystemColorsEnabled。
实施步骤:
RDEPUBReaderTheme增加highContrastVariant: RDEPUBReaderTheme?属性- 监听
UIAccessibility.darkerSystemColorsStatusDidChangeNotification - 高对比度启用时,自动切换到高对比度主题变体(更大色彩对比度)
- 或提供独立的"高对比度"主题预设
验收标准:
- 系统高对比度开启后,阅读器自动切换到高对比度主题
- 关闭后恢复原主题
15. DRM
当前状态:需求文档明确声明 DRM 不在 SDK 范围内。如果需要支持,建议通过以下方式:
建议方案:
- 不在 SDK 内实现 DRM,而是通过
RDEPUBParser的输入端控制:RDEPUBParser接受Data或URL,调用方可以先解密再传入- 或新增
RDEPUBDRMProvider协议:protocol RDEPUBDRMProvider { func decryptedData(for resourceURL: URL) -> Data? func isProtected(_ resourceURL: URL) -> Bool }
- SDK 内的
ss-reader://URL scheme handler 在读取资源时调用DRMProvider - 商业 DRM(如 Adobe ADEPT、Readium LCP)由调用方集成,SDK 提供接入点
验收标准:
- SDK 提供清晰的 DRM 接入协议
- 不引入 DRM 依赖,保持 SDK 轻量
- 调用方能通过协议接入自己的 DRM 方案