Compare commits
3 Commits
1c6108061c
...
9801af05d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9801af05d3 | ||
|
|
c76beed03c | ||
|
|
488350d956 |
459
Doc/WXRead/内存优化方案_WXRead策略.md
Normal file
459
Doc/WXRead/内存优化方案_WXRead策略.md
Normal file
@ -0,0 +1,459 @@
|
||||
# WXRead 阅读器内存优化方案
|
||||
|
||||
> 基于读书 v10.0.3 逆向代码分析,提炼 WXRead 处理大书内存的核心策略。
|
||||
> 创建日期:2026-06-02
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心设计原则
|
||||
|
||||
WXRead 的内存管理建立在一个关键架构决策之上:**永远不持有全书数据模型**。
|
||||
|
||||
与"先构建整本 TextBook 再进入阅读器"不同,WXRead 的数据流是:
|
||||
|
||||
```
|
||||
单章 XHTML
|
||||
→ WREpubTypesetter.attributeStringWithFilePath: (单章排版)
|
||||
→ WRChapterData (单章数据模型)
|
||||
→ WRChapterPageCount (单章分页)
|
||||
→ WRPageView.drawRect: (单页渲染)
|
||||
```
|
||||
|
||||
每一步都是章节级或页面级操作,从不需要同时持有全书的 `NSAttributedString`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 章节级缓存模型
|
||||
|
||||
### 2.1 缓存结构
|
||||
|
||||
```objc
|
||||
// WRReaderViewController ()
|
||||
@property (nonatomic, strong) NSMutableDictionary<NSNumber *, WRChapterData *> *chapterDataCache;
|
||||
@property (nonatomic, strong) NSMutableDictionary<NSString *, WRChapterPageCount *> *pageCountCache;
|
||||
```
|
||||
|
||||
关键设计决策:
|
||||
|
||||
| 设计点 | 选择 | 原因 |
|
||||
|--------|------|------|
|
||||
| 缓存类型 | `NSMutableDictionary` 而非 `NSCache` | 需要精确控制淘汰时机,NSCache 的自动淘汰不可预测 |
|
||||
| 缓存粒度 | 按章节索引(`NSNumber *`) | 每章独立加载、独立释放 |
|
||||
| 分页缓存 | 按缓存键(含排版设置) | 设置变更时整批失效 |
|
||||
|
||||
### 2.2 缓存内容
|
||||
|
||||
每个 `WRChapterData` 持有:
|
||||
|
||||
```objc
|
||||
@interface WRChapterData : NSObject
|
||||
@property (nonatomic, strong) NSMutableAttributedString *typesetAttributedString; // 排版后富文本
|
||||
@property (nonatomic, strong) WRCoreTextLayouter *layouter; // 排版器
|
||||
@property (nonatomic, strong) NSArray<NSValue *> *pageRanges; // 页范围数组
|
||||
@property (nonatomic, strong) NSArray<NSDictionary *> *highlights; // 高亮
|
||||
@property (nonatomic, strong) NSArray<NSDictionary *> *underlines; // 下划线
|
||||
@property (nonatomic, strong) NSSet<NSNumber *> *bookmarkedPages; // 书签
|
||||
@property (nonatomic, strong) NSAttributedString *sourceAttributedString; // 源文本
|
||||
@end
|
||||
```
|
||||
|
||||
**注意**:`WRChapterData` 不持有页面视图、不持有其他章节的引用、不持有全书索引表。章节之间完全解耦。
|
||||
|
||||
---
|
||||
|
||||
## 3. 内存警告处理
|
||||
|
||||
### 3.1 监听注册
|
||||
|
||||
```objc
|
||||
// WRReaderViewController.initWithBook:progress:...
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
addObserver:self
|
||||
selector:@selector(_handleMemoryWarning:)
|
||||
name:UIApplicationDidReceiveMemoryWarningNotification
|
||||
object:nil];
|
||||
```
|
||||
|
||||
在 `initWithBook:` 中注册,确保从阅读器创建之初就监听。
|
||||
|
||||
### 3.2 处理逻辑
|
||||
|
||||
```objc
|
||||
- (void)_handleMemoryWarning:(NSNotification *)note {
|
||||
NSLog(@"[WRReader] Memory warning received, purging chapter cache.");
|
||||
|
||||
// 1. 保留当前章节
|
||||
NSUInteger currentIdx = self.readingProgress.chapterIndex;
|
||||
WRChapterData *currentData = self.chapterDataCache[@(currentIdx)];
|
||||
|
||||
// 2. 清空全部缓存
|
||||
[self.chapterDataCache removeAllObjects];
|
||||
|
||||
// 3. 恢复当前章节
|
||||
if (currentData) {
|
||||
self.chapterDataCache[@(currentIdx)] = currentData;
|
||||
}
|
||||
|
||||
// 4. 清空分页缓存(可在需要时重新计算)
|
||||
[self.pageCountCache removeAllObjects];
|
||||
}
|
||||
```
|
||||
|
||||
策略总结:
|
||||
|
||||
| 步骤 | 操作 | 目的 |
|
||||
|------|------|------|
|
||||
| 1 | 保存当前章节引用 | 当前页不能中断 |
|
||||
| 2 | `removeAllObjects` | 一次性释放所有非当前章 |
|
||||
| 3 | 恢复当前章节 | 保证阅读不中断 |
|
||||
| 4 | 清空分页缓存 | `pageCountCache` 可重建,释放额外内存 |
|
||||
|
||||
### 3.3 为什么不用 NSCache
|
||||
|
||||
WXRead 选择 `NSMutableDictionary` + 手动淘汰而非 `NSCache`,原因:
|
||||
|
||||
1. **确定性**:内存警告时必须立即释放,NSCache 的淘汰时机不可控
|
||||
2. **当前章保护**:NSCache 无法 pin 住当前章不被淘汰
|
||||
3. **可预测性**:开发和调试时行为一致,不会因系统内存压力变化而变化
|
||||
|
||||
---
|
||||
|
||||
## 4. 章节加载与预取
|
||||
|
||||
### 4.1 串行后台队列
|
||||
|
||||
```objc
|
||||
// 初始化
|
||||
_chapterLoadQueue = dispatch_queue_create("com.weread.chapterload", DISPATCH_QUEUE_SERIAL);
|
||||
|
||||
// 加载章节
|
||||
- (void)_loadChapterAtIndex:(NSUInteger)index
|
||||
completion:(void (^)(WRChapterData *, NSError *))completion {
|
||||
// 先查缓存
|
||||
WRChapterData *cached = self.chapterDataCache[@(index)];
|
||||
if (cached) {
|
||||
if (completion) completion(cached, nil);
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复加载
|
||||
if (self.isLoadingChapter) return;
|
||||
self.isLoadingChapter = YES;
|
||||
|
||||
dispatch_async(self.chapterLoadQueue, ^{
|
||||
// 后台:获取内容 → 排版 → 生成 WRChapterData
|
||||
NSError *error = nil;
|
||||
WRChapterData *chapterData = [self _fetchChapterDataForIndex:index error:&error];
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
self.isLoadingChapter = NO;
|
||||
if (chapterData) {
|
||||
self.chapterDataCache[@(index)] = chapterData;
|
||||
if (completion) completion(chapterData, nil);
|
||||
} else {
|
||||
// 重试逻辑
|
||||
if (self.loadRetryCount < self.maxRetryCount) {
|
||||
self.loadRetryCount++;
|
||||
[self _loadChapterAtIndex:index completion:completion];
|
||||
} else {
|
||||
self.loadRetryCount = 0;
|
||||
if (completion) completion(nil, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
设计要点:
|
||||
|
||||
| 要点 | 实现 | 目的 |
|
||||
|------|------|------|
|
||||
| 串行队列 | `com.weread.chapterload` | 避免并发加载导致内存峰值叠加 |
|
||||
| 防重复 | `isLoadingChapter` 标志 | 同时只加载一章,控制内存瞬时占用 |
|
||||
| 先查缓存 | `chapterDataCache[@(index)]` | 命中则直接返回,不触发后台任务 |
|
||||
| 重试机制 | `maxRetryCount = 3` | 网络异常时自动重试 |
|
||||
|
||||
### 4.2 相邻章节预取
|
||||
|
||||
```objc
|
||||
- (void)_prefetchAdjacentChaptersForIndex:(NSUInteger)index {
|
||||
// 预取下一章
|
||||
if (index + 1 < self.totalChapters) {
|
||||
NSUInteger nextIdx = index + 1;
|
||||
if (!self.chapterDataCache[@(nextIdx)]) {
|
||||
dispatch_async(self.chapterLoadQueue, ^{
|
||||
[self _fetchChapterDataForIndex:nextIdx error:NULL];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 预取上一章
|
||||
if (index > 0) {
|
||||
NSUInteger prevIdx = index - 1;
|
||||
if (!self.chapterDataCache[@(prevIdx)]) {
|
||||
dispatch_async(self.chapterLoadQueue, ^{
|
||||
[self _fetchChapterDataForIndex:prevIdx error:NULL];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**触发时机**:
|
||||
1. `renderPageView:` 渲染完成后(第 282 行)
|
||||
2. `didFlipPage` 翻页完成后(第 409 行)
|
||||
|
||||
**预取窗口**:仅当前章 ± 1 章,不做全书预加载。这保证内存占用与用户实际阅读位置绑定。
|
||||
|
||||
---
|
||||
|
||||
## 5. 图片缓存
|
||||
|
||||
### 5.1 NSCache 限制
|
||||
|
||||
```objc
|
||||
// WRCoreTextLayouter.commonInit
|
||||
_imageCache = [[NSCache alloc] init];
|
||||
_imageCache.countLimit = 50; // 最多缓存 50 张缩放后的图片
|
||||
```
|
||||
|
||||
图片是内存大户,WXRead 对图片缓存的处理策略:
|
||||
|
||||
| 策略 | 实现 | 原因 |
|
||||
|------|------|------|
|
||||
| 使用 `NSCache` | 自动在内存压力时淘汰 | 图片可以重新生成,丢失代价低 |
|
||||
| `countLimit = 50` | 限制数量 | 防止图片无限累积 |
|
||||
| 按章节归属 | 每个 `WRCoreTextLayouter` 独立持有 | 章节释放时图片一起释放 |
|
||||
|
||||
### 5.2 CoreText 对象释放
|
||||
|
||||
```objc
|
||||
// WRCoreTextLayouter.dealloc
|
||||
- (void)dealloc {
|
||||
if (_typesetter) {
|
||||
CFRelease(_typesetter);
|
||||
_typesetter = NULL;
|
||||
}
|
||||
if (_framesetter) {
|
||||
CFRelease(_framesetter);
|
||||
_framesetter = NULL;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`CTTypesetter` 和 `CTFramesetter` 是 C 对象,不会被 ARC 自动释放。WXRead 在 `dealloc` 中显式释放,防止内存泄漏。
|
||||
|
||||
---
|
||||
|
||||
## 6. 预加载缓存管理
|
||||
|
||||
### 6.1 预加载场景
|
||||
|
||||
```objc
|
||||
typedef NS_ENUM(NSInteger, WRPreloadScene) {
|
||||
WRPreloadSceneNone = 0,
|
||||
WRPreloadSceneShelf = 1, // 书架页预加载
|
||||
WRPreloadSceneReading = 2, // 阅读时预加载后续章节
|
||||
WRPreloadSceneWiFi = 3, // WiFi 下激进预加载
|
||||
WRPreloadSceneManual = 4, // 用户手动触发
|
||||
};
|
||||
```
|
||||
|
||||
### 6.2 缓存清理接口
|
||||
|
||||
```objc
|
||||
// 清理指定书籍的预加载数据
|
||||
+ (void)removeKVWithBookId:(NSString *)bookId;
|
||||
|
||||
// 清理全部预加载缓存
|
||||
+ (void)clearKV;
|
||||
|
||||
// 计算并可选清理预加载缓存
|
||||
- (void)calcAndClearPreloadBookWithCompletion:(void (^)(NSUInteger totalSize))completion
|
||||
onlyCalc:(BOOL)onlyCalc;
|
||||
```
|
||||
|
||||
预加载数据(已下载但未阅读的章节)存储在磁盘,不占用运行时内存。清理接口用于管理磁盘空间。
|
||||
|
||||
---
|
||||
|
||||
## 7. 排版重排的内存影响
|
||||
|
||||
### 7.1 设置变更时的缓存处理
|
||||
|
||||
```objc
|
||||
- (void)recomposeCurrentPageViewWithSource:(NSString *)source {
|
||||
self.isRecomposing = YES;
|
||||
|
||||
// 清空分页缓存(排版参数变了,旧分页无效)
|
||||
[self.pageCountCache removeAllObjects];
|
||||
|
||||
// 重新排版当前章节
|
||||
WRChapterData *chapterData = self.currentChapterData;
|
||||
if (chapterData) {
|
||||
[self _reTypesetChapterData:chapterData];
|
||||
}
|
||||
|
||||
// 重新渲染
|
||||
[self renderPageView:self.activePageViews.firstObject
|
||||
progressData:self.readingProgress
|
||||
source:source];
|
||||
|
||||
self.isRecomposing = NO;
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 全局设置变更
|
||||
|
||||
```objc
|
||||
- (void)reloadPageViewsWithProgressData:(WRReadingProgress *)progressData
|
||||
source:(NSString *)source {
|
||||
// 清空所有缓存(字号/行距变化影响所有章节的排版)
|
||||
[self.chapterDataCache removeAllObjects];
|
||||
[self.pageCountCache removeAllObjects];
|
||||
|
||||
// 重新加载当前章
|
||||
[self _loadChapterAtIndex:progressData.chapterIndex
|
||||
completion:^(WRChapterData *chapterData, NSError *error) {
|
||||
if (chapterData) {
|
||||
[self _displayChapter:chapterData
|
||||
atPageIndex:progressData.pageIndex
|
||||
animated:NO];
|
||||
}
|
||||
}];
|
||||
}
|
||||
```
|
||||
|
||||
全局设置变更时清空全部缓存,因为排版参数影响所有章节。这是一次性内存释放,后续按需重新加载。
|
||||
|
||||
---
|
||||
|
||||
## 8. 位置持久化
|
||||
|
||||
### 8.1 多字段模型
|
||||
|
||||
```objc
|
||||
@interface WRReadingProgress : NSObject
|
||||
@property (nonatomic, copy) NSString *bookId;
|
||||
@property (nonatomic, assign) NSUInteger chapterIndex; // 章节索引
|
||||
@property (nonatomic, assign) NSUInteger pageIndex; // 章内页码
|
||||
@property (nonatomic, assign) NSUInteger charIndex; // 章内字符偏移
|
||||
@property (nonatomic, assign) CGFloat scrollOffset; // 滚动偏移
|
||||
@property (nonatomic, copy) NSString *chapterId;
|
||||
@property (nonatomic, assign) double readPercentage; // 阅读百分比
|
||||
@end
|
||||
```
|
||||
|
||||
### 8.2 保存时机
|
||||
|
||||
```objc
|
||||
// 1. 每次翻页
|
||||
- (void)didFlipPage {
|
||||
// ...
|
||||
[self _saveReadingProgressAndIsAsync:YES];
|
||||
}
|
||||
|
||||
// 2. 30 秒定时器
|
||||
_progressSaveTimer = [NSTimer scheduledTimerWithTimeInterval:30.0
|
||||
target:self
|
||||
selector:@selector(_periodicProgressSave)
|
||||
userInfo:nil
|
||||
repeats:YES];
|
||||
|
||||
// 3. 退出时同步保存
|
||||
- (void)_periodicProgressSave {
|
||||
[self _saveReadingProgressAndIsAsync:YES];
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 保存格式
|
||||
|
||||
```objc
|
||||
- (void)_saveReadingProgressAndIsAsync:(BOOL)isAsync {
|
||||
NSDictionary *dict = @{
|
||||
@"bookId": progress.bookId ?: @"",
|
||||
@"chapterIndex": @(progress.chapterIndex),
|
||||
@"pageIndex": @(progress.pageIndex),
|
||||
@"charIndex": @(progress.charIndex),
|
||||
@"scrollOffset": @(progress.scrollOffset),
|
||||
@"chapterId": progress.chapterId ?: @"",
|
||||
@"readPercentage": @(progress.readPercentage),
|
||||
@"timestamp": @([[NSDate date] timeIntervalSince1970]),
|
||||
};
|
||||
|
||||
if (isAsync) {
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:key];
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
});
|
||||
} else {
|
||||
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:key];
|
||||
[[NSUserDefaults standardUserDefaults] synchronize];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**位置恢复精度**:`charIndex` 是主锚点。页面变化(字号、屏幕尺寸)不影响恢复,因为字符偏移量是稳定的。
|
||||
|
||||
---
|
||||
|
||||
## 9. 内存占用模型
|
||||
|
||||
### 9.1 单章内存估算
|
||||
|
||||
假设一章 EPUB 约 5000 字符:
|
||||
|
||||
| 组件 | 估算大小 | 说明 |
|
||||
|------|----------|------|
|
||||
| `typesetAttributedString` | ~200-500 KB | 含字体、段落样式、图片附件 |
|
||||
| `WRCoreTextLayouter` | ~50-100 KB | CTTypesetter + CTFramesetter |
|
||||
| `pageRanges` | ~1-5 KB | NSRange 数组 |
|
||||
| 高亮/下划线/书签 | ~1-10 KB | 取决于标注数量 |
|
||||
| 图片缓存 | ~0-2 MB | 取决于章内图片数量 |
|
||||
| **单章合计** | **~0.3-3 MB** | 图片是主要变量 |
|
||||
|
||||
### 9.2 全书内存估算
|
||||
|
||||
WXRead 的内存占用 = 当前章 + 预取章(±1)+ 系统开销:
|
||||
|
||||
| 场景 | 缓存章节数 | 估算内存 |
|
||||
|------|------------|----------|
|
||||
| 正常阅读 | 1(当前)+ 2(预取)= 3 章 | ~1-9 MB |
|
||||
| 内存警告后 | 1 章(仅当前) | ~0.3-3 MB |
|
||||
| 快速连续翻页 | 最多 3 章(串行加载限制) | ~1-9 MB |
|
||||
|
||||
**对比**:如果持有全书数据(500 章 × 1 MB/章 = 500 MB),WXRead 的策略将其控制在个位数 MB。
|
||||
|
||||
---
|
||||
|
||||
## 10. 策略总结
|
||||
|
||||
| 策略 | 实现 | 效果 |
|
||||
|------|------|------|
|
||||
| **章节级缓存** | `NSMutableDictionary<NSNumber *, WRChapterData *>` | 内存与阅读位置绑定,不随全书增长 |
|
||||
| **手动淘汰** | 内存警告时清空非当前章 | 确定性释放,当前章不中断 |
|
||||
| **串行加载** | `com.weread.chapterload` 队列 | 避免并发加载内存峰值叠加 |
|
||||
| **±1 预取** | 翻页后异步预取相邻章 | 平衡流畅性与内存占用 |
|
||||
| **图片 NSCache** | `countLimit = 50` | 图片自动淘汰,章节释放时一起释放 |
|
||||
| **C 对象显式释放** | `dealloc` 中 `CFRelease` | 防止 CoreText 内存泄漏 |
|
||||
| **不分页全书** | 按章节独立分页 | 避免持有全书页范围数组 |
|
||||
| **不持有全书索引** | 无全局 `RDEPUBTextIndexTable` 等价物 | 按需查询,非常驻 |
|
||||
|
||||
---
|
||||
|
||||
## 附录:WXRead 关键文件索引
|
||||
|
||||
| 文件 | 职责 | 内存相关 |
|
||||
|------|------|----------|
|
||||
| `WRReaderViewController.m` | 阅读器主控制器 | `chapterDataCache`、内存警告处理、预取 |
|
||||
| `WRChapterData.h/m` | 章节数据模型 | 持有 `typesetAttributedString`、`layouter` |
|
||||
| `WRCoreTextLayouter.h/m` | CoreText 排版器 | 图片缓存 `NSCache(countLimit=50)`、C 对象释放 |
|
||||
| `WRChapterPageCount.h/m` | 章节分页计算 | `pageRanges` 数组 |
|
||||
| `WRPreloadBookManager.h/m` | 预加载管理 | 磁盘缓存管理,不占运行时内存 |
|
||||
| `WREpubTypesetter.m` | EPUB 排版器 | 单章排版,不持有全书状态 |
|
||||
|
||||
---
|
||||
|
||||
*基于读书 v10.0.3 逆向分析*
|
||||
*文档创建:2026-06-02*
|
||||
2827
Doc/大书优化方案_内存与主线程.md
Normal file
2827
Doc/大书优化方案_内存与主线程.md
Normal file
File diff suppressed because it is too large
Load Diff
438
Doc/大书快速进入阅读器方案_凡人修仙传.md
Normal file
438
Doc/大书快速进入阅读器方案_凡人修仙传.md
Normal file
@ -0,0 +1,438 @@
|
||||
# 《凡人修仙传》快速进入阅读器方案
|
||||
|
||||
> 适用场景:`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,6 +1,6 @@
|
||||
# 架构对比分析:读书 vs ReadViewSDK
|
||||
|
||||
> 基于读书 v10.0.3 (Build 79) 逆向文档,与当前 ReadViewSDK 代码在 2026-05-24 的核查结果整合。
|
||||
> 基于读书 v10.0.3 (Build 79) 逆向文档,与当前 ReadViewSDK 代码在 2026-06-02 的核查结果整合。
|
||||
> 本文档已吸收原 [WXRead剩余问题修复计划.md](/Users/shen/Work/Code/ReadViewSDK/Doc/WXRead剩余问题修复计划.md) 的阶段方案,后续以本文档作为单一真值。
|
||||
|
||||
> 标注说明:
|
||||
@ -22,10 +22,11 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
- 页面级 hit test / 选区 / 高亮 / 批注
|
||||
- 字符锚点与 `fileIndex/row/column` 语义的完整位置模型
|
||||
|
||||
核查结论:在当前约定范围内,ReadViewSDK 已经完成 EPUB 阅读器对 WXRead 文档主链路的复刻。当前文档里不再保留主链路级 `⚠️` 项,剩余仅有两类:
|
||||
核查结论:ReadViewSDK 已经完成 EPUB 阅读器主链路的大部分复刻,但当前代码里仍有少量“能力面已接入、实现路径未完全等价”的差异。和 2026-05-24 版本文档相比,当前最需要重新标注的是:
|
||||
|
||||
1. `replaceForMPChapter.css` 这类公众号文章专用资源,对 EPUB 主链路不适用
|
||||
2. 繁简转换、TTS / DRM / Pencil 这类已明确排除在本次复刻范围之外的外围能力
|
||||
1. 文本分页主路径现已真正消费多栏 path,并补上 `avoidOrphans` / `avoidWidows` / `hyphenation` 行为
|
||||
2. 代码仍保留若干 fallback / degrade 路径,与 WXRead 的单一主链路实现方式不同
|
||||
3. `replaceForMPChapter.css`、繁简转换、TTS / DRM / Pencil 仍属于不适用或明确不实现范围
|
||||
|
||||
---
|
||||
|
||||
@ -43,11 +44,14 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
- 分页器已经补上 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 更完整的翻页/手势控制器拆分相比,宿主层职责仍稍偏重。
|
||||
|
||||
### ❌ 未实现
|
||||
|
||||
@ -81,10 +85,10 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
| 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 |
|
||||
| `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 |
|
||||
| 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 |
|
||||
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并接入分页入口/缓存键/列路径构建 | ✅ 已实现 |
|
||||
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并已接入多栏 path、孤行/寡行保护与 hyphenation 行为 | ✅ 已实现 |
|
||||
| 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 |
|
||||
|
||||
**结论:** 分页主链路已经按 WXRead 收口,当前差异不再落在分页引擎或分页配置能力面上。
|
||||
**结论:** 分页主链路已经按 WXRead 的主要思路收口;当前剩余差异不再集中在分页配置或分页算法本身,而更多落在 fallback 路径和宿主层实现细节。
|
||||
|
||||
---
|
||||
|
||||
@ -175,6 +179,8 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
| 页面几何模型完全等价 `WRCoreTextLayoutFrame` | ✅ 已实现 | - |
|
||||
| `WRBookmark` 统一标注模型 | ✅ 已实现 | - |
|
||||
| 多栏排版 | ✅ 已实现 | - |
|
||||
| `avoidOrphans / avoidWidows / hyphenation` 行为落地 | ✅ 已实现 | - |
|
||||
| 纯文本/渲染失败 fallback 清理 | ⚠️ 仍保留纯文本 fallback 路径 | 低 |
|
||||
| 字体动态加载 | ✅ 已实现 | - |
|
||||
| 繁简转换 | ❌ 明确不实现 | - |
|
||||
| TTS / DRM / Pencil | ❌ 明确不实现 | - |
|
||||
@ -216,6 +222,17 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
||||
|
||||
---
|
||||
|
||||
## 补充说明:本次复核新增结论(2026-06-02)
|
||||
|
||||
和上一篇版本相比,当前最重要的修正不是“新增缺很多能力”,而是把原先写得过满的结论收回来:
|
||||
|
||||
- `RDEPUBTextLayoutConfig` 这一轮已经从“配置面补齐”推进到“行为面落地”,`avoidOrphans`、`avoidWidows`、`hyphenation` 不再只是模型字段。
|
||||
- 多栏能力这一轮已经接到文本 `CoreText` 分页主路径,`numberOfColumns` 不再只影响 Web/CSS 与设置入口。
|
||||
- 文本阅读主链路虽然已经不再依赖旧 `UITextView`,但仓库中仍保留 `DTCoreText` 不可用时的纯文本 fallback,以及纯文本分页失败时的 `UITextView` fallback,应视作与 WXRead 的实现差异,而不是主链路能力。
|
||||
- 公众号文章专用 `replaceForMPChapter.css` 仍不属于当前 EPUB 主链路缺口,应继续按“不适用”记录,而不是“待补齐”。
|
||||
|
||||
---
|
||||
|
||||
## 10. 读书关键类职责速查
|
||||
|
||||
| 类名 | 职责 | ReadViewSDK 对应 | 核查 |
|
||||
|
||||
@ -16,6 +16,11 @@
|
||||
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
|
||||
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
|
||||
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
|
||||
1A2B3C4D00000001AABBCC01 /* BookmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */; };
|
||||
1A2B3C4D00000003AABBCC01 /* TableOfContentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */; };
|
||||
1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */; };
|
||||
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */; };
|
||||
1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -42,6 +47,11 @@
|
||||
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookmarkTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TableOfContentsTests.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>"; };
|
||||
1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationExtendedTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@ -152,11 +162,16 @@
|
||||
EACA0D274176D594361EF6B9 /* ReaderUITests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */,
|
||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */,
|
||||
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */,
|
||||
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */,
|
||||
1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */,
|
||||
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */,
|
||||
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */,
|
||||
1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */,
|
||||
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */,
|
||||
1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */,
|
||||
);
|
||||
path = ReaderUITests;
|
||||
sourceTree = "<group>";
|
||||
@ -309,11 +324,16 @@
|
||||
files = (
|
||||
63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */,
|
||||
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */,
|
||||
1A2B3C4D00000001AABBCC01 /* BookmarkTests.swift in Sources */,
|
||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
|
||||
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */,
|
||||
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */,
|
||||
1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */,
|
||||
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */,
|
||||
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */,
|
||||
1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */,
|
||||
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */,
|
||||
1A2B3C4D00000003AABBCC01 /* TableOfContentsTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@ -12,6 +12,7 @@ final class ViewController: UIViewController {
|
||||
let bookTitleQuery: String
|
||||
let displayType: RDReaderView.DisplayType?
|
||||
let pageNumber: Int?
|
||||
let resetsReaderState: Bool
|
||||
let displaySequence: [RDReaderView.DisplayType]
|
||||
let stepDelay: TimeInterval
|
||||
|
||||
@ -43,6 +44,7 @@ final class ViewController: UIViewController {
|
||||
|
||||
let displayType = value(after: "--demo-display-type").flatMap(parseDisplayType)
|
||||
let pageNumber = value(after: "--demo-page").flatMap(Int.init)
|
||||
let resetsReaderState = arguments.contains("--demo-reset-state")
|
||||
let displaySequence = value(after: "--demo-display-sequence")
|
||||
.map { raw in
|
||||
raw.split(separator: ",").compactMap { parseDisplayType(String($0)) }
|
||||
@ -53,6 +55,7 @@ final class ViewController: UIViewController {
|
||||
bookTitleQuery: bookTitleQuery,
|
||||
displayType: displayType,
|
||||
pageNumber: pageNumber,
|
||||
resetsReaderState: resetsReaderState,
|
||||
displaySequence: displaySequence,
|
||||
stepDelay: stepDelay
|
||||
)
|
||||
@ -714,6 +717,10 @@ final class ViewController: UIViewController {
|
||||
return
|
||||
}
|
||||
|
||||
if launchAutomationPlan.resetsReaderState {
|
||||
resetPersistedReaderState()
|
||||
}
|
||||
|
||||
var configuration = RDEPUBReaderConfiguration.default
|
||||
if let displayType = launchAutomationPlan.displayType {
|
||||
configuration.displayType = displayType
|
||||
@ -721,6 +728,22 @@ final class ViewController: UIViewController {
|
||||
_ = openBook(book, configuration: configuration, automationPlan: launchAutomationPlan)
|
||||
}
|
||||
|
||||
private func resetPersistedReaderState() {
|
||||
let defaults = UserDefaults.standard
|
||||
let prefixes = [
|
||||
"ssreader.epub.location.",
|
||||
"ssreader.epub.bookmarks.",
|
||||
"ssreader.epub.highlights."
|
||||
]
|
||||
let settingsKey = "ssreader.epub.settings"
|
||||
|
||||
for key in defaults.dictionaryRepresentation().keys {
|
||||
if prefixes.contains(where: { key.hasPrefix($0) }) || key == settingsKey {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ViewController: UITableViewDataSource {
|
||||
|
||||
@ -5,7 +5,491 @@
|
||||
它用于覆盖 RDURLReaderController 与 RDPlainTextBookBuilder 的 TXT 路径,
|
||||
确认纯文本分页、打开主流程,以及后续 reader 壳层回归不会失效。
|
||||
|
||||
为了让 UI 自动化可以稳定验证翻页、长按选区、书签和目录行为,
|
||||
这个样本会刻意保持为“可稳定分页”的长度,而不是一屏就显示完的短文本。
|
||||
|
||||
第一节 多页验证
|
||||
|
||||
阅读器在处理纯文本时,会根据当前设备尺寸、字体大小、行距和内边距进行重新分页。
|
||||
这意味着同一份文本在不同设备上看到的“第 2 页、第 3 页”内容可能略有差异,
|
||||
但只要整体篇幅足够长,跳转到后续页码、执行选区手势、验证状态标签,就依然具备稳定性。
|
||||
|
||||
第二节 自动化约束
|
||||
|
||||
自动化测试并不追求复杂叙事,它更在意内容是否连续、段落是否足够、字符分布是否均匀。
|
||||
因此这里使用连续的中文段落,让文本布局更接近真实阅读场景,也方便测试长按选词时命中正文区域。
|
||||
|
||||
第三节 翻页前提
|
||||
|
||||
如果样本文本过短,阅读器即使工作正常,也只能停留在第一页。
|
||||
这会让依赖 page=2 的测试误以为是“启动参数失效”,实际上是目标页根本不存在。
|
||||
为了避免这种误判,样本内容应该明确保证至少能覆盖数页。
|
||||
|
||||
第四节 文本密度
|
||||
|
||||
当前章节继续补充说明:段落长度适中、标点分布自然、句子结构重复但不完全相同。
|
||||
这样既能帮助验证分页器的稳定性,也能帮助标注测试在不同屏幕密度下复现相近的触控位置。
|
||||
|
||||
第五节 选区交互
|
||||
|
||||
长按后拖拽选区,通常依赖可选中的连续文字块。
|
||||
如果页面上只有很少文字,或者文字被标题、空白和分隔线稀释,选区命中率会显著下降。
|
||||
因此这里增加多个连续段落,让第二页和后续页面都具备足够的正文密度。
|
||||
|
||||
第六节 书签行为
|
||||
|
||||
书签测试需要在当前位置创建标记,再验证底部工具栏中的书签列表入口是否可用。
|
||||
如果页面状态更新稍慢,测试就需要等待按钮从“出现”切换到“可点击”。
|
||||
这类状态同步问题和可访问性问题不同,但都应该被自动化显式观察到。
|
||||
|
||||
第七节 目录与章节
|
||||
|
||||
虽然 TXT 没有像 EPUB 那样的正式目录树,但阅读器仍然会以章节标题和文本位置组织阅读内容。
|
||||
在更复杂的样本书中,目录面板与书签面板都是重要的回归点,因此本样本也会保留明显的章节与小节结构。
|
||||
|
||||
第二章 稳定性
|
||||
|
||||
如果这个样本能被成功发现、分页并进入阅读器,
|
||||
就说明 TXT 支持仍然保留在当前收敛后的架构中。
|
||||
|
||||
第一节 持续验证
|
||||
|
||||
稳定性不是指“永不变化”,而是指在进行架构收敛、依赖升级、分页算法调整之后,
|
||||
仍然可以通过一组明确的自动化测试迅速判断主要阅读能力是否保持正常。
|
||||
|
||||
第二节 页面状态
|
||||
|
||||
测试会观察隐藏的状态标签,确认 reader=opened、page=2、toolbar=hidden、selection=1、
|
||||
highlights=1 等关键信号。这些信号本身不是面向用户的 UI,但对于自动化诊断非常有帮助。
|
||||
|
||||
第三节 页面长度
|
||||
|
||||
为了确保这份 TXT 在手机模拟器上可靠地产生多页,
|
||||
这里继续补充几段自然文本。文字越接近真实段落,分页器越能暴露真实问题,
|
||||
比起简单重复同一句话,更容易发现孤行、断段和换页边界上的细节缺陷。
|
||||
|
||||
第四节 回归节奏
|
||||
|
||||
每次修改阅读器入口、分页器、选区菜单或工具栏逻辑时,
|
||||
都可以重新运行这组围绕本样本构建的测试。
|
||||
如果样本依旧能稳定跳到第二页、完成高亮、打开书签面板,
|
||||
那就说明最核心的 TXT 阅读链路依然是健康的。
|
||||
|
||||
第五节 补充段落 A
|
||||
|
||||
这是一段用于扩充篇幅的说明文字。它描述了阅读器在不同字号下的视觉密度变化,
|
||||
也提醒我们不要把单一设备上的页码结果绝对化,而应确保测试样本足够长以适配常见机型。
|
||||
|
||||
第六节 补充段落 B
|
||||
|
||||
这是一段用于扩充篇幅的说明文字。它强调自动化测试需要稳定样本、稳定入口、稳定查询标识,
|
||||
只有这样,失败时我们才能快速区分是产品逻辑缺陷,还是测试前提本身不成立。
|
||||
|
||||
第七节 补充段落 C
|
||||
|
||||
这是一段用于扩充篇幅的说明文字。它补充说明,若未来字体默认值、边距或行距发生变化,
|
||||
只要这份样本文本仍然明显长于一页,依赖 page=2 的测试就仍然具备合理性。
|
||||
|
||||
第八节 补充段落 D
|
||||
|
||||
这是一段用于扩充篇幅的说明文字。它重复强调:自动化要避免建立在偶然时序之上,
|
||||
应等待明确状态,再执行点击、长按或断言,这能显著提升 UI 回归的解释力。
|
||||
|
||||
第九节 补充段落 E
|
||||
|
||||
这是一段用于扩充篇幅的说明文字。它说明目录、书签、标注、设置和翻页并不是孤立功能,
|
||||
而是阅读体验中的连续路径,因此测试设计也应围绕连续操作来展开。
|
||||
|
||||
第十节 收束
|
||||
|
||||
当你读到这里时,这份样本文本的篇幅已经足以覆盖多页。
|
||||
如果阅读器仍然无法跳到第二页,那么问题就更可能出在跳转实现、状态同步或分页结果映射上,
|
||||
而不是样本文本长度不足。
|
||||
|
||||
附录 扩展段落 01
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 02
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 03
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 04
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 05
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 06
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 07
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 08
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 09
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 10
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 11
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 12
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 13
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 14
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 15
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 16
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 17
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 18
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 19
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
附录 扩展段落 20
|
||||
|
||||
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
|
||||
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
|
||||
|
||||
第三章 页面容量保障
|
||||
|
||||
本章的唯一目的是确保样本文本在所有常见 iOS 模拟器上都能产生至少三页。
|
||||
iPhone 17 Pro 的屏幕较大,默认字号下一屏可以容纳较多文字,
|
||||
因此需要更多段落来保证 page=2、page=3 始终存在。
|
||||
|
||||
第一节 模拟器差异
|
||||
|
||||
不同型号的 iPhone 模拟器屏幕尺寸差异明显。iPhone SE 系列屏幕较小,同样长度的文本能产生更多页;
|
||||
而 iPhone Pro Max 和 iPhone 17 Pro 等大屏设备,一屏可以显示更多行,
|
||||
需要更长的文本才能保证至少两页。这就是为什么样本文本需要远超一屏容量的原因。
|
||||
|
||||
第二节 分页器工作原理
|
||||
|
||||
阅读器的分页器会根据当前可用区域(屏幕尺寸减去安全区域和内边距)计算每页可显示的行数,
|
||||
然后将整篇文本按行切分为多页。字号越大、行距越宽,每页能显示的文字越少,总页数越多。
|
||||
在默认字号和行距下,iPhone 17 Pro 大约需要四千到五千个中文字符才能确保至少两页。
|
||||
|
||||
第三节 选区测试的前置条件
|
||||
|
||||
文本选区测试需要在第二页执行长按拖拽手势。如果文本只有一页,
|
||||
那么所有依赖 page=2 的选区测试都会在跳转阶段失败,根本无法到达选区交互步骤。
|
||||
因此保证至少两页是选区测试能够执行的最低前提。
|
||||
|
||||
第四节 书签与标注
|
||||
|
||||
书签测试在当前页添加标记后验证按钮状态变化。标注测试则需要先跳转到第二页,
|
||||
再执行选区和高亮操作。两者都依赖页面导航的正确性,也都需要样本文本足够长。
|
||||
|
||||
第五节 内容质量
|
||||
|
||||
与简单重复同一句话相比,使用自然语言段落能更好地模拟真实阅读场景。
|
||||
分页器在处理不同长度的段落、标题和空行时可能表现出不同的边界行为,
|
||||
因此样本文本应包含多样化的段落长度和结构。
|
||||
|
||||
第六节 持续维护
|
||||
|
||||
当阅读器的默认字号、行距或内边距发生变化时,可能需要重新评估样本文本的长度。
|
||||
如果未来默认设置导致每页显示更多文字,则需要相应增加样本文本以维持至少三页的保障。
|
||||
这是一个需要持续关注的维护点。
|
||||
|
||||
第七节 历史教训
|
||||
|
||||
在之前的测试运行中,样本文本仅有 207 行约 11000 字节,在 iPhone 17 Pro 上仅产生一页。
|
||||
这导致两个标注测试(testSelectionMenuCreatesHighlight 和 testLongPressSelectionDoesNotShowToolbars)
|
||||
因为无法跳转到第二页而失败。这个教训直接促成了本章内容的添加。
|
||||
|
||||
第四章 额外内容填充
|
||||
|
||||
以下段落用于进一步扩充文本总量,确保在最大屏幕的模拟器上也能稳定分页。
|
||||
|
||||
第一节 技术架构
|
||||
|
||||
ReadViewSDK 采用分层架构设计。底层是文本解析和分页引擎,负责将 EPUB 或 TXT 文件
|
||||
转换为结构化的页面数据。中间层是阅读器控制器,管理页面渲染、手势交互和状态同步。
|
||||
顶层是 UI 层,包括工具栏、设置面板、目录和书签等用户可见的界面元素。
|
||||
|
||||
第二节 测试策略
|
||||
|
||||
自动化测试分为多个层次。单元测试覆盖分页算法和数据模型的正确性。
|
||||
UI 测试覆盖用户可见的交互流程,包括打开书籍、翻页、选区、书签和设置。
|
||||
回归测试则在每次代码变更后运行,确保已有功能不被破坏。
|
||||
|
||||
第三节 中文排版
|
||||
|
||||
中文排版有其特殊性。字符等宽、标点禁则、段首缩进等规则都需要分页器正确处理。
|
||||
此外,中英文混排时的间距处理、数字和标点的断行行为也是分页器需要考虑的细节。
|
||||
|
||||
第四节 无障碍支持
|
||||
|
||||
阅读器的无障碍支持包括 VoiceOver 朗读、动态字体大小和高对比度模式。
|
||||
自动化测试中的 accessibilityIdentifier 既服务于 UI 测试的元素定位,
|
||||
也间接验证了无障碍标识的完整性。
|
||||
|
||||
第五节 性能考量
|
||||
|
||||
大文本文件的分页性能是一个重要指标。分页算法的时间复杂度应该是线性的,
|
||||
即处理时间与文本长度成正比。对于超长文本(如百万字小说),
|
||||
分页过程应该在合理时间内完成,不影响用户的打开体验。
|
||||
|
||||
第六节 国际化
|
||||
|
||||
虽然当前样本文本是中文,但阅读器框架需要支持多种语言。
|
||||
不同语言的分页规则可能有所不同,例如阿拉伯语的从右到左排版、
|
||||
日语的竖排模式等。这些都需要在分页器设计中预留扩展点。
|
||||
|
||||
第七节 数据持久化
|
||||
|
||||
阅读器需要持久化用户的阅读进度、书签、标注和设置。
|
||||
这些数据通常存储在 UserDefaults 或本地数据库中。
|
||||
当用户重新打开同一本书时,阅读器应该恢复到上次的阅读位置。
|
||||
|
||||
第八节 网络与离线
|
||||
|
||||
虽然当前 Demo 应用只支持本地书籍,但完整的阅读器产品通常需要支持
|
||||
在线书库、云端同步和离线缓存。这些功能不在当前测试范围内,
|
||||
但架构设计应该为未来的网络功能预留接口。
|
||||
|
||||
第九节 安全性
|
||||
|
||||
阅读器处理用户上传的 EPUB 文件时,需要防范恶意文件的攻击。
|
||||
EPUB 本质上是 ZIP 压缩包,包含 HTML、CSS 和 JavaScript 文件。
|
||||
解析器需要对这些内容进行安全检查,防止路径遍历、XSS 等安全漏洞。
|
||||
|
||||
第十节 版本兼容
|
||||
|
||||
EPUB 格式有多个版本(EPUB 2、EPUB 3),不同版本的规范差异需要解析器正确处理。
|
||||
此外,不同阅读器生成的 EPUB 文件可能有各种非标准扩展,
|
||||
解析器需要具备足够的容错能力来处理这些变体。
|
||||
|
||||
第五章 最终保障
|
||||
|
||||
本章再次追加大量段落,作为最终的页面容量保障。
|
||||
|
||||
第一节 设备矩阵
|
||||
|
||||
测试需要覆盖的设备矩阵包括:iPhone SE(小屏)、iPhone 16(中屏)、
|
||||
iPhone 17 Pro(大屏)、iPhone 17 Pro Max(超大屏)和 iPad(平板)。
|
||||
每种设备的屏幕尺寸和安全区域都不同,分页结果也会有差异。
|
||||
|
||||
第二节 测试稳定性
|
||||
|
||||
UI 自动化测试的一个常见问题是不稳定(flaky test)。
|
||||
造成不稳定的因素包括:网络延迟、动画未完成、异步操作未同步、
|
||||
设备性能差异等。为了提高测试稳定性,应该使用明确的状态等待而不是固定延时。
|
||||
|
||||
第三节 断言策略
|
||||
|
||||
测试断言应该足够具体以便定位问题,又不能过于脆弱导致误报。
|
||||
例如,验证"页面包含某个文本"比验证"页面的精确布局坐标"更稳定,
|
||||
因为后者容易因设备差异或字体渲染变化而失败。
|
||||
|
||||
第四节 测试数据管理
|
||||
|
||||
测试使用的数据(如样本书籍)应该与测试代码一起版本控制。
|
||||
这样可以确保测试环境的一致性,也方便在不同分支上复现问题。
|
||||
|
||||
第五节 持续集成
|
||||
|
||||
自动化测试应该集成到 CI/CD 流程中。每次提交代码后自动运行测试,
|
||||
可以尽早发现问题。测试结果应该有清晰的报告,包括失败的截图和日志。
|
||||
|
||||
第六节 测试覆盖率
|
||||
|
||||
测试覆盖率是衡量测试质量的一个指标,但不是唯一指标。
|
||||
高覆盖率不一定意味着高质量的测试。更重要的是测试是否覆盖了关键路径和边界情况。
|
||||
|
||||
第七节 代码审查
|
||||
|
||||
测试代码本身也需要代码审查。审查的重点包括:测试逻辑是否正确、
|
||||
断言是否有意义、测试数据是否合理、是否有遗漏的边界情况等。
|
||||
|
||||
第八节 文档维护
|
||||
|
||||
测试文档应该与代码同步更新。当测试用例发生变化时,
|
||||
文档应该及时反映这些变化。过时的文档比没有文档更有害。
|
||||
|
||||
第九节 团队协作
|
||||
|
||||
自动化测试需要团队的共同维护。开发人员在修改功能代码时应该同步更新测试,
|
||||
测试人员在发现新的测试场景时应该及时添加测试用例。
|
||||
|
||||
第十节 本章结语
|
||||
|
||||
以上内容的唯一目的是确保回归验证样本在所有目标设备上都能产生足够的页面。
|
||||
如果你正在阅读这段文字,说明样本的篇幅已经达到了预期目标。
|
||||
后续的 UI 自动化测试可以安全地依赖 page=2 的存在性。
|
||||
|
||||
附录 补充内容 A
|
||||
|
||||
本段落继续扩充文本。阅读器在渲染纯文本时,会逐行计算文本高度,
|
||||
并与可用区域进行比较。当累计高度超过可用区域时,就会开始新的一页。
|
||||
这个过程对用户是透明的,但对自动化测试来说,理解这个机制有助于设计更可靠的测试。
|
||||
|
||||
附录 补充内容 B
|
||||
|
||||
阅读器的状态标签是一个隐藏的 UILabel,专门用于自动化测试。
|
||||
它以结构化的格式报告阅读器的内部状态,包括:是否打开、当前页码、显示模式、
|
||||
工具栏可见性、高亮数量和选区状态。测试可以通过轮询这个标签来等待特定状态的出现。
|
||||
|
||||
附录 补充内容 C
|
||||
|
||||
手势交互是阅读器的核心体验之一。轻触屏幕中央可以切换工具栏的显示和隐藏。
|
||||
左滑和右滑可以翻页。长按并拖拽可以选中文本。这些手势的正确实现和测试
|
||||
是阅读器质量保证的重要组成部分。
|
||||
|
||||
附录 补充内容 D
|
||||
|
||||
设置面板允许用户自定义阅读体验。可配置的选项包括:字体大小、字体类型、
|
||||
行距、分栏数、显示模式和主题。每次设置变更都会触发页面重新分页,
|
||||
因此测试需要等待分页完成后才能进行后续操作。
|
||||
|
||||
附录 补充内容 E
|
||||
|
||||
目录面板显示书籍的章节目录。用户可以通过点击目录项快速跳转到指定章节。
|
||||
对于 EPUB 书籍,目录数据来自 OPF 文件中的 NCX 或 nav 文件。
|
||||
对于 TXT 书籍,目录则通过正则表达式匹配章节标题来生成。
|
||||
|
||||
附录 补充内容 F
|
||||
|
||||
书签功能允许用户标记感兴趣的页面。书签数据包括页码、添加时间和可选的备注。
|
||||
书签面板以列表形式展示所有书签,用户可以点击书签快速跳转到对应页面。
|
||||
删除书签需要用户确认,防止误操作。
|
||||
|
||||
附录 补充内容 G
|
||||
|
||||
标注功能(高亮)允许用户选中文本并添加高亮标记。高亮数据包括选中文本的范围、
|
||||
颜色和添加时间。高亮面板以列表形式展示所有高亮,用户可以点击高亮跳转到对应位置。
|
||||
支持多种高亮颜色,方便用户分类管理。
|
||||
|
||||
附录 补充内容 H
|
||||
|
||||
阅读器的翻页动画有三种模式:仿真翻页(模拟纸张翻动效果)、
|
||||
水平滑动(左右平移切换页面)和垂直滚动(连续滚动浏览内容)。
|
||||
用户可以根据个人喜好在设置中选择。不同模式下的分页逻辑略有不同。
|
||||
|
||||
附录 补充内容 I
|
||||
|
||||
夜间模式是阅读器的重要功能之一。在夜间模式下,背景色变为深色,
|
||||
文字色变为浅色,减少屏幕对眼睛的刺激。主题切换需要重新渲染页面内容,
|
||||
因为文字颜色和背景色的变化会影响分页结果。
|
||||
|
||||
附录 补充内容 J
|
||||
|
||||
字号调整是另一个常用功能。增大字号可以让文字更清晰易读,
|
||||
但同时会减少每页显示的文字量,增加总页数。阅读器需要在字号变化后
|
||||
重新分页并尝试保持当前阅读位置不变。
|
||||
|
||||
附录 补充内容 K
|
||||
|
||||
行距调整影响行与行之间的距离。较大的行距可以提高阅读舒适度,
|
||||
但同样会减少每页显示的行数。行距通常以倍数表示,如 1.2 倍、1.5 倍等。
|
||||
|
||||
附录 补充内容 L
|
||||
|
||||
分栏模式在 iPad 等大屏设备上特别有用。双栏模式可以在横屏时
|
||||
同时显示两页内容,提高大屏设备的空间利用率。分栏数的切换也需要重新分页。
|
||||
|
||||
附录 补充内容 M
|
||||
|
||||
阅读进度是用户关心的核心信息之一。阅读器通常通过进度条或页码
|
||||
来显示当前阅读位置。对于 EPUB 书籍,还可以显示章节内的百分比进度。
|
||||
|
||||
附录 补充内容 N
|
||||
|
||||
搜索功能允许用户在书籍中查找特定文本。搜索结果以列表形式展示,
|
||||
用户可以点击结果跳转到对应位置。搜索功能的实现需要考虑性能问题,
|
||||
特别是对于大文本文件的搜索。
|
||||
|
||||
附录 补充内容 O
|
||||
|
||||
分享功能允许用户将感兴趣的段落分享到社交媒体或发送给朋友。
|
||||
分享内容通常包括选中的文本、书籍信息和阅读位置。分享功能的实现
|
||||
需要考虑不同平台的分享接口差异。
|
||||
|
||||
附录 补充内容 P
|
||||
|
||||
阅读统计功能记录用户的阅读习惯,包括每日阅读时长、阅读页数和阅读速度。
|
||||
这些数据可以帮助用户了解自己的阅读习惯,也可以用于推荐系统。
|
||||
|
||||
附录 补充内容 Q
|
||||
|
||||
云同步功能允许用户在不同设备间同步阅读进度、书签和标注。
|
||||
云同步的实现需要考虑数据冲突解决、增量同步和离线支持等问题。
|
||||
|
||||
附录 补充内容 R
|
||||
|
||||
离线缓存功能允许用户在没有网络连接时继续阅读已下载的书籍。
|
||||
缓存策略需要考虑存储空间管理和缓存更新等问题。
|
||||
|
||||
附录 补充内容 S
|
||||
|
||||
阅读器的启动性能是一个重要指标。用户期望阅读器能够快速打开并显示内容。
|
||||
优化启动性能的方法包括:延迟加载非关键组件、预加载常用数据、
|
||||
使用缓存减少重复计算等。
|
||||
|
||||
附录 补充内容 T
|
||||
|
||||
内存管理对于阅读器来说非常重要。大文本文件的渲染需要大量内存,
|
||||
如果不当管理可能导致内存警告甚至应用崩溃。
|
||||
阅读器应该使用分页加载和及时释放不可见页面的策略来控制内存使用。
|
||||
|
||||
附录 最终段落
|
||||
|
||||
本样本的最终目标是为 ReadViewSDK 的 UI 自动化测试提供一个可靠的基础。
|
||||
通过确保样本在所有目标设备上都能产生足够的页面,
|
||||
我们可以建立一套稳定的回归测试体系,为阅读器的质量保驾护航。
|
||||
|
||||
@ -12,15 +12,30 @@ enum IDs {
|
||||
static let readerBottomToolbar = "epub.reader.bottomToolbar"
|
||||
static let readerContent = "epub.reader.content"
|
||||
static let readerPaging = "epub.reader.paging"
|
||||
static let readerBookmark = "epub.reader.bookmark"
|
||||
static let readerToc = "epub.reader.toc"
|
||||
static let readerBookmarks = "epub.reader.bookmarks"
|
||||
static let readerHighlights = "epub.reader.highlights"
|
||||
static let readerAddHighlight = "epub.reader.add-highlight"
|
||||
static let readerSettings = "epub.reader.settings"
|
||||
static let readerTocPanel = "epub.reader.toc.panel"
|
||||
static let readerTocTable = "epub.reader.toc.table"
|
||||
static let readerTocNavBar = "epub.reader.toc.navbar"
|
||||
static let readerBookmarksPanel = "epub.reader.bookmarks.panel"
|
||||
static let readerBookmarksTable = "epub.reader.bookmarks.table"
|
||||
static let readerBookmarksNavBar = "epub.reader.bookmarks.navbar"
|
||||
static let readerSelectionText = "epub.reader.selection.text"
|
||||
static let readerSelectionHighlight = "epub.reader.selection.高亮"
|
||||
|
||||
static let settingsScroll = "epub.reader.settings.scroll"
|
||||
static let settingsBrightness = "epub.reader.settings.brightness"
|
||||
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 settingsFontChoice = "epub.reader.settings.font.choice"
|
||||
static let settingsLineHeight = "epub.reader.settings.lineHeight"
|
||||
static let settingsColumns = "epub.reader.settings.columns"
|
||||
static let settingsDisplayType = "epub.reader.settings.displayType"
|
||||
static let settingsDone = "epub.reader.settings.done"
|
||||
static func settingsTheme(_ index: Int) -> String { "epub.reader.settings.theme.\(index)" }
|
||||
}
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
import XCTest
|
||||
|
||||
extension XCUIApplication {
|
||||
func launchAndOpenSampleBook(displayType: String? = nil, pageNumber: Int? = nil) {
|
||||
var args = ["--demo-book-title", "回归验证样本"]
|
||||
func launchAndOpenSampleBook(
|
||||
bookTitleQuery: String = "回归验证样本",
|
||||
displayType: String? = nil,
|
||||
pageNumber: Int? = nil,
|
||||
resetsReaderState: Bool = true
|
||||
) {
|
||||
var args = ["--demo-book-title", bookTitleQuery]
|
||||
if resetsReaderState {
|
||||
args.append("--demo-reset-state")
|
||||
}
|
||||
if let displayType {
|
||||
args += ["--demo-display-type", displayType]
|
||||
}
|
||||
@ -21,6 +29,10 @@ extension XCUIApplication {
|
||||
return state
|
||||
}
|
||||
|
||||
func waitForReaderPage(_ pageNumber: Int, timeout: TimeInterval = 12) {
|
||||
waitForReaderState(containing: "page=\(pageNumber)", timeout: timeout)
|
||||
}
|
||||
|
||||
func showReaderChromeIfNeeded() {
|
||||
if buttons[IDs.readerBack].exists && buttons[IDs.readerSettings].exists {
|
||||
return
|
||||
@ -34,18 +46,33 @@ extension XCUIApplication {
|
||||
}
|
||||
}
|
||||
|
||||
func hideReaderChromeIfNeeded() {
|
||||
for _ in 0..<3 {
|
||||
guard buttons[IDs.readerBack].exists else { return }
|
||||
|
||||
let content = otherElements[IDs.readerContent]
|
||||
if content.waitForExistence(timeout: 2) {
|
||||
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)).tap()
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
func waitForReaderState(containing expectedText: String, timeout: TimeInterval = 8) {
|
||||
let state = staticTexts[IDs.demoReaderState]
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var lastLabel = "<missing>"
|
||||
|
||||
while Date() < deadline {
|
||||
if state.exists && state.label.contains(expectedText) {
|
||||
return
|
||||
if state.exists {
|
||||
lastLabel = state.label
|
||||
if lastLabel.contains(expectedText) {
|
||||
return
|
||||
}
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
|
||||
let currentLabel = state.exists ? state.label : "<missing>"
|
||||
XCTFail("阅读器状态未包含 \(expectedText),当前:\(currentLabel)")
|
||||
XCTFail("阅读器状态未包含 \(expectedText),当前:\(lastLabel)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
import XCTest
|
||||
|
||||
final class BookmarkTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testBookmarkButtonExistsInToolbar() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮未出现")
|
||||
}
|
||||
|
||||
func testToggleBookmark() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮未出现")
|
||||
bookmarkButton.tap()
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮再次点击后未出现")
|
||||
bookmarkButton.tap()
|
||||
}
|
||||
|
||||
func testBookmarksButtonVisibleAfterBookmarkAdded() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮未出现")
|
||||
bookmarkButton.tap()
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let bookmarksListButton = app.buttons[IDs.readerBookmarks]
|
||||
XCTAssertTrue(bookmarksListButton.waitForExistence(timeout: 5), "书签列表按钮未出现")
|
||||
}
|
||||
|
||||
func testBookmarksListButtonExists() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let bookmarksListButton = app.buttons[IDs.readerBookmarks]
|
||||
XCTAssertTrue(bookmarksListButton.waitForExistence(timeout: 5), "书签列表按钮未出现")
|
||||
}
|
||||
|
||||
func testBookmarksPanelIsAccessible() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮未出现")
|
||||
bookmarkButton.tap()
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let bookmarksListButton = app.buttons[IDs.readerBookmarks]
|
||||
XCTAssertTrue(bookmarksListButton.waitForExistence(timeout: 5), "书签列表按钮未出现")
|
||||
|
||||
let enabledPredicate = NSPredicate(format: "isEnabled == true")
|
||||
expectation(for: enabledPredicate, evaluatedWith: bookmarksListButton)
|
||||
waitForExpectations(timeout: 10)
|
||||
|
||||
XCTAssertTrue(bookmarksListButton.isEnabled, "书签列表按钮未启用")
|
||||
bookmarksListButton.tap()
|
||||
|
||||
let bookmarksPanel = app.tables[IDs.readerBookmarksTable]
|
||||
XCTAssertTrue(bookmarksPanel.waitForExistence(timeout: 5), "书签面板未出现")
|
||||
XCTAssertTrue(app.navigationBars[IDs.readerBookmarksNavBar].waitForExistence(timeout: 2), "书签导航栏未暴露")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import XCTest
|
||||
|
||||
final class PageNavigationTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testContentAreaExistsForNavigation() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
|
||||
let content = app.otherElements[IDs.readerContent]
|
||||
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
||||
}
|
||||
|
||||
func testPagingViewExistsInScrollMode() {
|
||||
app.launchAndOpenSampleBook(displayType: "scroll")
|
||||
app.waitForReader()
|
||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
||||
}
|
||||
|
||||
func testPagingViewExistsInVerticalScrollMode() {
|
||||
app.launchAndOpenSampleBook(displayType: "vertical")
|
||||
app.waitForReader()
|
||||
app.waitForReaderState(containing: "display=verticalScroll", timeout: 5)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
||||
}
|
||||
|
||||
func testSwipeInHorizontalScrollMode() {
|
||||
app.launchAndOpenSampleBook(displayType: "scroll")
|
||||
app.waitForReader()
|
||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5))
|
||||
|
||||
let state = app.staticTexts[IDs.demoReaderState]
|
||||
let beforeLabel = state.label
|
||||
|
||||
paging.swipeLeft()
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
while Date() < deadline {
|
||||
if state.exists && state.label != beforeLabel { break }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
XCTAssertNotEqual(beforeLabel, state.label, "水平滑动后页码未变化")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
import XCTest
|
||||
|
||||
final class ReaderAnnotationExtendedTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testBottomToolbarHasAddHighlightButton() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let addHighlightButton = app.buttons[IDs.readerAddHighlight]
|
||||
XCTAssertTrue(addHighlightButton.waitForExistence(timeout: 5), "底部工具栏标注按钮未出现")
|
||||
}
|
||||
|
||||
func testBottomToolbarHasHighlightsButton() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let highlightsButton = app.buttons[IDs.readerHighlights]
|
||||
XCTAssertTrue(highlightsButton.waitForExistence(timeout: 5), "底部工具栏高亮列表按钮未出现")
|
||||
}
|
||||
|
||||
func testBottomToolbarButtonsLayout() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
XCTAssertTrue(app.buttons[IDs.readerToc].waitForExistence(timeout: 5), "目录按钮未出现")
|
||||
XCTAssertTrue(app.buttons[IDs.readerBookmarks].exists, "书签按钮未出现")
|
||||
XCTAssertTrue(app.buttons[IDs.readerHighlights].exists, "高亮按钮未出现")
|
||||
XCTAssertTrue(app.buttons[IDs.readerAddHighlight].exists, "标注按钮未出现")
|
||||
XCTAssertTrue(app.buttons[IDs.readerSettings].exists, "设置按钮未出现")
|
||||
}
|
||||
|
||||
func testSelectionTextElementExists() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
XCTAssertTrue(selectionText.waitForExistence(timeout: 5), "文本选区元素未出现")
|
||||
}
|
||||
|
||||
func testReaderStateReportsPageInfo() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
|
||||
let state = app.staticTexts[IDs.demoReaderState]
|
||||
XCTAssertTrue(state.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(state.label.contains("reader=opened"), "状态未包含 reader=opened")
|
||||
XCTAssertTrue(state.label.contains("page="), "状态未包含 page 信息")
|
||||
XCTAssertTrue(state.label.contains("display="), "状态未包含 display 信息")
|
||||
}
|
||||
|
||||
func testReaderStateReportsHighlightCount() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
|
||||
let state = app.staticTexts[IDs.demoReaderState]
|
||||
XCTAssertTrue(state.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(state.label.contains("highlights=0"), "初始高亮数应为 0")
|
||||
}
|
||||
}
|
||||
@ -10,13 +10,17 @@ final class ReaderAnnotationTests: XCTestCase {
|
||||
func testSelectionMenuCreatesHighlight() {
|
||||
app.launchAndOpenSampleBook(pageNumber: 2)
|
||||
app.waitForReader()
|
||||
app.waitForReaderPage(2)
|
||||
app.hideReaderChromeIfNeeded()
|
||||
|
||||
let content = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
||||
|
||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42))
|
||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.42))
|
||||
start.press(forDuration: 0.6, thenDragTo: end)
|
||||
app.waitForReaderState(containing: "toolbar=hidden", timeout: 5)
|
||||
|
||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.45))
|
||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.45))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
app.waitForReaderState(containing: "selection=1", timeout: 5)
|
||||
|
||||
@ -36,15 +40,19 @@ final class ReaderAnnotationTests: XCTestCase {
|
||||
func testLongPressSelectionDoesNotShowToolbars() {
|
||||
app.launchAndOpenSampleBook(pageNumber: 2)
|
||||
app.waitForReader()
|
||||
app.waitForReaderPage(2)
|
||||
app.hideReaderChromeIfNeeded()
|
||||
|
||||
let content = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
|
||||
|
||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42))
|
||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.42))
|
||||
start.press(forDuration: 0.6, thenDragTo: end)
|
||||
|
||||
app.waitForReaderState(containing: "selection=1", timeout: 5)
|
||||
app.waitForReaderState(containing: "toolbar=hidden", timeout: 5)
|
||||
|
||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.45))
|
||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.45))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
app.waitForReaderState(containing: "selection=1", timeout: 8)
|
||||
app.waitForReaderState(containing: "toolbar=hidden", timeout: 3)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
import XCTest
|
||||
|
||||
final class SettingsExtendedTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
private func openSettings() {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5))
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
func testBrightnessSliderExists() {
|
||||
openSettings()
|
||||
let slider = app.sliders[IDs.settingsBrightness]
|
||||
XCTAssertTrue(slider.waitForExistence(timeout: 3), "亮度滑块未出现")
|
||||
}
|
||||
|
||||
func testLineHeightControl() {
|
||||
openSettings()
|
||||
let lineHeight = app.segmentedControls[IDs.settingsLineHeight]
|
||||
XCTAssertTrue(lineHeight.waitForExistence(timeout: 3), "行距控件未出现")
|
||||
|
||||
lineHeight.buttons["紧凑"].tap()
|
||||
lineHeight.buttons["标准"].tap()
|
||||
lineHeight.buttons["宽松"].tap()
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
func testColumnCountControl() {
|
||||
openSettings()
|
||||
let columns = app.segmentedControls[IDs.settingsColumns]
|
||||
XCTAssertTrue(columns.waitForExistence(timeout: 3), "分栏控件未出现")
|
||||
|
||||
columns.buttons["单栏"].tap()
|
||||
columns.buttons["双栏"].tap()
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
func testDisplayTypeFromSettings() {
|
||||
openSettings()
|
||||
let displayType = app.segmentedControls[IDs.settingsDisplayType]
|
||||
XCTAssertTrue(displayType.waitForExistence(timeout: 3), "翻页方式控件未出现")
|
||||
|
||||
displayType.buttons["横滑"].tap()
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
||||
}
|
||||
|
||||
func testAllThemeButtonsExist() {
|
||||
openSettings()
|
||||
for index in 0...5 {
|
||||
let themeButton = app.buttons[IDs.settingsTheme(index)]
|
||||
XCTAssertTrue(themeButton.waitForExistence(timeout: 3), "主题按钮 \(index) 未出现")
|
||||
}
|
||||
}
|
||||
|
||||
func testScrollSettingsToReachAllControls() {
|
||||
openSettings()
|
||||
let scroll = app.scrollViews[IDs.settingsScroll]
|
||||
XCTAssertTrue(scroll.waitForExistence(timeout: 3))
|
||||
|
||||
XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3))
|
||||
XCTAssertTrue(app.segmentedControls[IDs.settingsLineHeight].waitForExistence(timeout: 3))
|
||||
XCTAssertTrue(app.segmentedControls[IDs.settingsColumns].waitForExistence(timeout: 3))
|
||||
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 3))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import XCTest
|
||||
|
||||
final class TableOfContentsTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testTocButtonExists() {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let tocButton = app.buttons[IDs.readerToc]
|
||||
XCTAssertTrue(tocButton.waitForExistence(timeout: 5), "目录按钮未出现")
|
||||
}
|
||||
|
||||
func testTocButtonTappable() {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
|
||||
app.waitForReader()
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let tocButton = app.buttons[IDs.readerToc]
|
||||
XCTAssertTrue(tocButton.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(tocButton.isEnabled, "目录按钮不可点击")
|
||||
tocButton.tap()
|
||||
|
||||
let tocPanel = app.tables[IDs.readerTocTable]
|
||||
XCTAssertTrue(tocPanel.waitForExistence(timeout: 5), "目录面板未出现")
|
||||
XCTAssertTrue(app.navigationBars[IDs.readerTocNavBar].waitForExistence(timeout: 2), "目录导航栏未暴露")
|
||||
}
|
||||
}
|
||||
@ -30,16 +30,27 @@ extension RDEPUBParser {
|
||||
return true
|
||||
}
|
||||
|
||||
let interactivePattern = #"<(script|iframe|video|audio|canvas|svg|form)\b|\bon(load|click|touchstart|touchend|mouseover)=|hype_generated_script|swiper|webview"#
|
||||
for item in spine where item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) {
|
||||
guard let html = htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil {
|
||||
if containsInteractiveMarkup(html) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// 检测 HTML 是否包含需要走 Web 交互渲染的特征。
|
||||
/// 注意:普通小说 EPUB 常用静态 SVG 包封面图,不能仅因 `<svg>` 就误判为 interactive。
|
||||
private func containsInteractiveMarkup(_ html: String) -> Bool {
|
||||
let interactivePattern = #"<(script|iframe|video|audio|canvas|object|embed)\b|\bon(load|click|touchstart|touchend|mouseover|submit|change|input)=|hype_generated_script|swiper|webview"#
|
||||
if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
let dynamicSVGPattern = #"<svg\b[^>]*>[\s\S]*?(<(script|foreignObject|animate|animateMotion|animateTransform|set)\b|\bon(load|click|touchstart|touchend|mouseover)=)"#
|
||||
return html.range(of: dynamicSVGPattern, options: [.regularExpression, .caseInsensitive]) != nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,173 +94,20 @@ public final class RDEPUBTextBookBuilder {
|
||||
let cachedPagination = cacheCoordinator.load(key: cacheKey)
|
||||
|
||||
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
|
||||
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// 从目录表中解析章节标题
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let request = RDEPUBTextTypesetterPipeline().makeRequest(
|
||||
from: RDEPUBTypesettingInput(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
rawHTML: rawHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver,
|
||||
contentLanguageCode: publication.metadata.language,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
).request
|
||||
|
||||
// 渲染 HTML → NSAttributedString
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
let rendered = try renderPipeline.render(request)
|
||||
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
|
||||
|
||||
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
|
||||
|
||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
|
||||
}
|
||||
// 跳过空白的封面/扉页章节
|
||||
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterIndex = chapters.count
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
|
||||
// 分页:封面图片章节走特殊路径,缓存命中则跳过分页引擎
|
||||
let paginateStart = CFAbsoluteTimeGetCurrent()
|
||||
let layoutFrames: [RDEPUBTextLayoutFrame]
|
||||
let isCacheHit: Bool
|
||||
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
|
||||
// 纯图片封面:整个内容作为一页
|
||||
layoutFrames = [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: attachmentRanges(in: content),
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"cover fallback: single attachment page",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
isCacheHit = false
|
||||
} else if let cached = cachedPagination?[item.href] {
|
||||
// 缓存命中:直接使用缓存的页范围,跳过 CoreText 分页
|
||||
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
|
||||
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
|
||||
return RDEPUBTextLayoutFrame(
|
||||
contentRange: range,
|
||||
breakReason: breakReason,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: cached.semanticHints,
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: ["page break: \(breakReason.rawValue)", "page range: \(NSStringFromRange(range))", "source: cache hit"]
|
||||
)
|
||||
}
|
||||
isCacheHit = true
|
||||
} else {
|
||||
// 缓存未命中:调用 CoreText 分页引擎
|
||||
layoutFrames = content.length > 0
|
||||
? paginationPipeline.frames(
|
||||
for: content,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig,
|
||||
fragmentOffsets: rendered.fragmentOffsets
|
||||
)
|
||||
: []
|
||||
isCacheHit = false
|
||||
}
|
||||
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
||||
|
||||
// 尾页规范化:丢弃纯空白尾页、合并过短尾页
|
||||
let normalizedFrames = tailNormalizer.normalize(
|
||||
layoutFrames,
|
||||
content: content,
|
||||
href: item.href
|
||||
)
|
||||
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
|
||||
? [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
: normalizedFrames
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||
}
|
||||
|
||||
// 记录性能采样
|
||||
sampler.record(RDEPUBTextPerformanceSample(
|
||||
chapterHref: item.href,
|
||||
renderDuration: renderDuration,
|
||||
paginateDuration: paginateDuration,
|
||||
pageCount: effectiveFrames.count,
|
||||
attributedStringLength: content.length,
|
||||
cacheHit: isCacheHit
|
||||
))
|
||||
if isCacheHit {
|
||||
lastBuildCacheStats.hits += 1
|
||||
} else {
|
||||
lastBuildCacheStats.misses += 1
|
||||
}
|
||||
|
||||
// 构建页面和章节模型
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
|
||||
let range = frame.contentRange
|
||||
return RDEPUBTextPage(
|
||||
absolutePageIndex: flatPages.count + localPageIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectiveFrames.count,
|
||||
chapterContent: chapterAttributedContent,
|
||||
content: content.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0),
|
||||
metadata: frame.metadata
|
||||
)
|
||||
}
|
||||
guard let result = try buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
chapterIndex: chapters.count,
|
||||
absolutePageStartIndex: flatPages.count,
|
||||
cachedPagination: cachedPagination
|
||||
) else { continue }
|
||||
|
||||
if isPaginationDebugEnabled,
|
||||
item.href.contains("Chapter_3.xhtml") {
|
||||
let pages = result.chapter.pages
|
||||
print("[PaginationDebug] href=\(item.href) pages=\(pages.count)")
|
||||
for page in pages {
|
||||
let preview = debugPreview(for: page.content, limit: 36)
|
||||
@ -271,26 +118,16 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
chapters.append(
|
||||
RDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
attributedContent: chapterAttributedContent,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageBreakReasons: pages.map(\.metadata.breakReason),
|
||||
pages: pages
|
||||
)
|
||||
)
|
||||
lastBuildPaginationDiagnostics.append(
|
||||
diagnosticsReporter.chapterDiagnostic(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
pages: pages
|
||||
)
|
||||
)
|
||||
flatPages.append(contentsOf: pages)
|
||||
chapters.append(result.chapter)
|
||||
flatPages.append(contentsOf: result.chapter.pages)
|
||||
lastBuildResourceDiagnostics.append(contentsOf: result.resourceDiagnostics)
|
||||
lastBuildPaginationDiagnostics.append(result.paginationDiagnostic)
|
||||
sampler.record(result.performanceSample)
|
||||
if result.cacheHit {
|
||||
lastBuildCacheStats.hits += 1
|
||||
} else {
|
||||
lastBuildCacheStats.misses += 1
|
||||
}
|
||||
}
|
||||
|
||||
let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
@ -306,6 +143,220 @@ public final class RDEPUBTextBookBuilder {
|
||||
return book
|
||||
}
|
||||
|
||||
/// 构建指定 spine 章节,用于大书快速首屏和后台增量补齐。
|
||||
public func buildChapter(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
chapterIndex: Int = 0,
|
||||
absolutePageStartIndex: Int = 0
|
||||
) throws -> RDEPUBTextChapterBuildResult? {
|
||||
let bookID = publication.metadata.identifier ?? publication.metadata.title
|
||||
let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style)
|
||||
let cachedPagination = cacheCoordinator.load(key: cacheKey)
|
||||
return try buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
chapterIndex: chapterIndex,
|
||||
absolutePageStartIndex: absolutePageStartIndex,
|
||||
cachedPagination: cachedPagination
|
||||
)
|
||||
}
|
||||
|
||||
private func buildChapter(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
chapterIndex: Int,
|
||||
absolutePageStartIndex: Int,
|
||||
cachedPagination: [String: RDEPUBTextChapterPaginationCache]?
|
||||
) throws -> RDEPUBTextChapterBuildResult? {
|
||||
guard publication.spine.indices.contains(spineIndex) else { return nil }
|
||||
let item = publication.spine[spineIndex]
|
||||
guard item.linear,
|
||||
item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let request = RDEPUBTextTypesetterPipeline().makeRequest(
|
||||
from: RDEPUBTypesettingInput(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
rawHTML: rawHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver,
|
||||
contentLanguageCode: publication.metadata.language,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
)
|
||||
).request
|
||||
|
||||
let renderStart = CFAbsoluteTimeGetCurrent()
|
||||
let rendered = try renderPipeline.render(request)
|
||||
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
|
||||
|
||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
|
||||
}
|
||||
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] skipped href=\(item.href)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
let paginateStart = CFAbsoluteTimeGetCurrent()
|
||||
let layoutFrames: [RDEPUBTextLayoutFrame]
|
||||
let isCacheHit: Bool
|
||||
|
||||
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
|
||||
layoutFrames = [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: attachmentRanges(in: content),
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"cover fallback: single attachment page",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
isCacheHit = false
|
||||
} else if let cached = cachedPagination?[item.href] {
|
||||
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
|
||||
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
|
||||
return RDEPUBTextLayoutFrame(
|
||||
contentRange: range,
|
||||
breakReason: breakReason,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: cached.semanticHints,
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: \(breakReason.rawValue)",
|
||||
"page range: \(NSStringFromRange(range))",
|
||||
"source: cache hit"
|
||||
]
|
||||
)
|
||||
}
|
||||
isCacheHit = true
|
||||
} else {
|
||||
layoutFrames = content.length > 0
|
||||
? paginationPipeline.frames(
|
||||
for: content,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig,
|
||||
fragmentOffsets: rendered.fragmentOffsets
|
||||
)
|
||||
: []
|
||||
isCacheHit = false
|
||||
}
|
||||
|
||||
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
||||
let normalizedFrames = tailNormalizer.normalize(
|
||||
layoutFrames,
|
||||
content: content,
|
||||
href: item.href
|
||||
)
|
||||
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
|
||||
? [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
breakReason: .chapterEnd,
|
||||
blockRange: nil,
|
||||
attachmentRanges: [],
|
||||
attachmentKinds: [],
|
||||
blockKinds: [],
|
||||
semanticHints: [],
|
||||
attachmentPlacements: [],
|
||||
trailingFragmentID: nil,
|
||||
diagnostics: [
|
||||
"page break: chapterEnd",
|
||||
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
|
||||
]
|
||||
)
|
||||
]
|
||||
: normalizedFrames
|
||||
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||
}
|
||||
|
||||
let chapterAttributedContent = content.copy() as! NSAttributedString
|
||||
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
|
||||
let range = frame.contentRange
|
||||
return RDEPUBTextPage(
|
||||
absolutePageIndex: absolutePageStartIndex + localPageIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectiveFrames.count,
|
||||
chapterContent: chapterAttributedContent,
|
||||
content: content.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0),
|
||||
metadata: frame.metadata
|
||||
)
|
||||
}
|
||||
|
||||
let chapter = RDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
attributedContent: chapterAttributedContent,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageBreakReasons: pages.map(\.metadata.breakReason),
|
||||
pages: pages
|
||||
)
|
||||
let performanceSample = RDEPUBTextPerformanceSample(
|
||||
chapterHref: item.href,
|
||||
renderDuration: renderDuration,
|
||||
paginateDuration: paginateDuration,
|
||||
pageCount: effectiveFrames.count,
|
||||
attributedStringLength: content.length,
|
||||
cacheHit: isCacheHit
|
||||
)
|
||||
let diagnostic = diagnosticsReporter.chapterDiagnostic(
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
pages: pages
|
||||
)
|
||||
|
||||
return RDEPUBTextChapterBuildResult(
|
||||
chapter: chapter,
|
||||
resourceDiagnostics: rendered.resourceDiagnostics,
|
||||
paginationDiagnostic: diagnostic,
|
||||
performanceSample: performanceSample,
|
||||
cacheHit: isCacheHit
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 章节标题解析
|
||||
|
||||
/// 从目录表中查找章节标题,找不到则回退到 spine item 的 title 或 href
|
||||
|
||||
@ -21,6 +21,17 @@ public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
|
||||
public var sampleNotes: [String]
|
||||
}
|
||||
|
||||
// MARK: - 章节构建结果
|
||||
|
||||
/// 单章构建结果,用于大书快速进入阅读器和后台增量补齐。
|
||||
public struct RDEPUBTextChapterBuildResult {
|
||||
public var chapter: RDEPUBTextChapter
|
||||
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
|
||||
public var paginationDiagnostic: RDEPUBTextChapterPaginationDiagnostic
|
||||
public var performanceSample: RDEPUBTextPerformanceSample
|
||||
public var cacheHit: Bool
|
||||
}
|
||||
|
||||
// MARK: - 页面数据模型
|
||||
|
||||
/// 分页后的单页数据,包含全书绝对页码、所属章节、内容范围等。
|
||||
@ -180,4 +191,3 @@ public struct RDEPUBTextBook {
|
||||
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -20,9 +20,8 @@ struct RDEPUBChapterPageCounter {
|
||||
private let pageSize: CGSize
|
||||
private let config: RDEPUBTextLayoutConfig
|
||||
private let pageBreakPolicy: RDEPUBPageBreakPolicy
|
||||
|
||||
/// CoreText 帧设置器
|
||||
private let framesetter: CTFramesetter
|
||||
|
||||
/// DTCoreText 路径可直接消费的单矩形布局区域
|
||||
private let dtLayoutRect: CGRect
|
||||
|
||||
@ -59,27 +58,26 @@ struct RDEPUBChapterPageCounter {
|
||||
var frames: [RDEPUBTextLayoutFrame] = []
|
||||
var location = 0
|
||||
let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
|
||||
let usableWidth = resolvedSize.width - config.edgeInsets.left - config.edgeInsets.right
|
||||
let usableHeight = resolvedSize.height - config.edgeInsets.top - config.edgeInsets.bottom
|
||||
let contentRect = config.contentRect(fallback: resolvedSize)
|
||||
|
||||
guard usableWidth > 0, usableHeight > 0 else {
|
||||
guard contentRect.width > 0, contentRect.height > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
while location < attributedString.length {
|
||||
let framePath = CGMutablePath()
|
||||
let pageRect = CGRect(
|
||||
x: config.edgeInsets.left,
|
||||
y: config.edgeInsets.bottom,
|
||||
width: usableWidth,
|
||||
height: usableHeight
|
||||
let framePath = RDEPUBCoreTextPageFrameFactory.makeLayoutPath(
|
||||
pageSize: resolvedSize,
|
||||
config: config
|
||||
)
|
||||
framePath.addRect(pageRect)
|
||||
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), framePath, nil)
|
||||
let proposedRange = proposedRangeUsingWXReadPageCount(
|
||||
let frame = CTFramesetterCreateFrame(
|
||||
framesetter,
|
||||
CFRangeMake(location, 0),
|
||||
framePath,
|
||||
nil
|
||||
)
|
||||
let proposedRange = proposedVisibleRange(
|
||||
from: frame,
|
||||
start: location,
|
||||
usableHeight: usableHeight,
|
||||
totalLength: attributedString.length
|
||||
)
|
||||
guard proposedRange.length > 0 else {
|
||||
@ -87,12 +85,18 @@ struct RDEPUBChapterPageCounter {
|
||||
}
|
||||
|
||||
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
||||
let keepWithNextAdjusted = factory.trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
|
||||
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame)
|
||||
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
|
||||
proposed: keepWithNextAdjusted,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
|
||||
|
||||
let adjusted = pageBreakPolicy.adjustedRange(
|
||||
from: avoidAdjusted,
|
||||
from: widowOrphanAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges,
|
||||
lineRanges: effectiveLineRanges,
|
||||
factory: factory
|
||||
)
|
||||
let trailingFragmentID = factory.nearestTrailingFragmentID(
|
||||
@ -157,10 +161,15 @@ struct RDEPUBChapterPageCounter {
|
||||
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
|
||||
let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
|
||||
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame)
|
||||
let widowOrphanAdjusted = factory.trimmedRangeForWidowAndOrphanControl(
|
||||
proposed: lineAdjusted,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
|
||||
let adjusted = pageBreakPolicy.adjustedRange(
|
||||
from: lineAdjusted,
|
||||
from: widowOrphanAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges,
|
||||
lineRanges: effectiveLineRanges,
|
||||
factory: factory
|
||||
)
|
||||
let verifiedRange: NSRange
|
||||
@ -231,40 +240,29 @@ struct RDEPUBChapterPageCounter {
|
||||
|
||||
// MARK: - WXRead 分页对齐
|
||||
|
||||
/// 逐句对齐 WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString` 的分页循环。
|
||||
private func proposedRangeUsingWXReadPageCount(
|
||||
/// 读取 CoreText frame 当前可见的字符范围;多栏 path 下也能返回整页可见区。
|
||||
private func proposedVisibleRange(
|
||||
from frame: CTFrame,
|
||||
start location: Int,
|
||||
usableHeight: CGFloat,
|
||||
totalLength: Int
|
||||
) -> NSRange {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
guard !lines.isEmpty else {
|
||||
let visibleRange = CTFrameGetVisibleStringRange(frame)
|
||||
guard visibleRange.length > 0 else {
|
||||
return NSRange(location: location, length: 0)
|
||||
}
|
||||
|
||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
|
||||
|
||||
var pageCharCount = 0
|
||||
for (index, line) in lines.enumerated() {
|
||||
let lineRange = CTLineGetStringRange(line)
|
||||
let lineY = origins[index].y
|
||||
var ascent: CGFloat = 0
|
||||
var descent: CGFloat = 0
|
||||
CTLineGetTypographicBounds(line, &ascent, &descent, nil)
|
||||
|
||||
if lineY - ascent > usableHeight {
|
||||
break
|
||||
}
|
||||
|
||||
pageCharCount += lineRange.length
|
||||
let resolvedLocation = max(location, visibleRange.location)
|
||||
let visibleEnd = visibleRange.location + visibleRange.length
|
||||
let length = min(max(visibleEnd - resolvedLocation, 0), totalLength - resolvedLocation)
|
||||
guard length > 0 else {
|
||||
return NSRange(location: resolvedLocation, length: 0)
|
||||
}
|
||||
return NSRange(location: resolvedLocation, length: length)
|
||||
}
|
||||
|
||||
if pageCharCount == 0 {
|
||||
pageCharCount = 1
|
||||
private func lineRangesWithinRange(_ lineRanges: [NSRange], range: NSRange) -> [NSRange] {
|
||||
lineRanges.filter { lineRange in
|
||||
lineRange.location >= range.location && NSMaxRange(lineRange) <= NSMaxRange(range)
|
||||
}
|
||||
|
||||
return NSRange(location: location, length: min(pageCharCount, totalLength - location))
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,6 +183,38 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// 根据 avoidWidows / avoidOrphans 修正页尾断点,避免段首孤悬页尾或段末独悬下一页。
|
||||
func trimmedRangeForWidowAndOrphanControl(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange {
|
||||
guard !lineRanges.isEmpty else { return proposed }
|
||||
guard config.avoidWidows || config.avoidOrphans else { return proposed }
|
||||
|
||||
var adjusted = proposed
|
||||
var visibleLines = lineRanges
|
||||
|
||||
if config.avoidWidows,
|
||||
let widowAdjusted = trimmedRangeAvoidingWidow(
|
||||
proposed: adjusted,
|
||||
lineRanges: visibleLines
|
||||
),
|
||||
widowAdjusted != adjusted {
|
||||
adjusted = widowAdjusted
|
||||
visibleLines = Array(visibleLines.dropLast())
|
||||
}
|
||||
|
||||
if config.avoidOrphans,
|
||||
let orphanAdjusted = trimmedRangeAvoidingOrphan(
|
||||
proposed: adjusted,
|
||||
lineRanges: visibleLines
|
||||
) {
|
||||
adjusted = orphanAdjusted
|
||||
}
|
||||
|
||||
return adjusted
|
||||
}
|
||||
|
||||
// MARK: - 行信息提取
|
||||
|
||||
/// 获取 CTFrame 中所有行的字符范围
|
||||
@ -263,6 +295,101 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
}
|
||||
|
||||
// MARK: - Widow / Orphan 控制
|
||||
|
||||
private func trimmedRangeAvoidingWidow(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange? {
|
||||
guard lineRanges.count >= 2,
|
||||
let lastLine = lineRanges.last else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let paragraphRange = paragraphRange(containing: lastLine.location)
|
||||
let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
|
||||
let paragraphContinuesOnNextPage = NSMaxRange(proposed) < NSMaxRange(paragraphRange)
|
||||
|
||||
guard trailingLines == 1, paragraphContinuesOnNextPage else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let keptLine = lineRanges[lineRanges.count - 2]
|
||||
let adjustedLength = NSMaxRange(keptLine) - proposed.location
|
||||
guard adjustedLength > 0 else { return nil }
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
private func trimmedRangeAvoidingOrphan(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange? {
|
||||
guard lineRanges.count >= 2,
|
||||
let lastLine = lineRanges.last else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let paragraphRange = paragraphRange(containing: lastLine.location)
|
||||
let pageEnd = NSMaxRange(proposed)
|
||||
let paragraphEnd = NSMaxRange(paragraphRange)
|
||||
guard pageEnd < paragraphEnd else { return nil }
|
||||
|
||||
let remainingLineCount = estimatedRemainingLineCount(
|
||||
in: paragraphRange,
|
||||
startingAt: pageEnd
|
||||
)
|
||||
let trailingLines = trailingLineCount(in: lineRanges, paragraphRange: paragraphRange)
|
||||
guard remainingLineCount == 1,
|
||||
trailingLines >= 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let keptLine = lineRanges[lineRanges.count - 2]
|
||||
let adjustedLength = NSMaxRange(keptLine) - proposed.location
|
||||
guard adjustedLength > 0 else { return nil }
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
private func trailingLineCount(
|
||||
in lineRanges: [NSRange],
|
||||
paragraphRange: NSRange
|
||||
) -> Int {
|
||||
var count = 0
|
||||
for lineRange in lineRanges.reversed() {
|
||||
if NSIntersectionRange(lineRange, paragraphRange).length > 0 {
|
||||
count += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private func estimatedRemainingLineCount(
|
||||
in paragraphRange: NSRange,
|
||||
startingAt location: Int
|
||||
) -> Int {
|
||||
let paragraphEnd = NSMaxRange(paragraphRange)
|
||||
var currentLocation = max(location, paragraphRange.location)
|
||||
guard currentLocation < paragraphEnd else { return 0 }
|
||||
|
||||
let width = max(config.columnRects(fallback: pageSize).first?.width ?? config.contentRect(fallback: pageSize).width, 1)
|
||||
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
|
||||
var lineCount = 0
|
||||
|
||||
while currentLocation < paragraphEnd {
|
||||
let suggestedCount = CTTypesetterSuggestLineBreak(typesetter, currentLocation, Double(width))
|
||||
let lineLength = max(suggestedCount, 1)
|
||||
currentLocation += lineLength
|
||||
lineCount += 1
|
||||
if lineCount > 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return lineCount
|
||||
}
|
||||
|
||||
/// 获取指定范围内的附件类型列表(去重)
|
||||
func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
|
||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
||||
|
||||
@ -41,7 +41,11 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
let attributedString = NSMutableAttributedString(attributedString: rendered)
|
||||
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
|
||||
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(
|
||||
in: attributedString,
|
||||
style: request.style,
|
||||
layoutConfig: request.layoutConfig ?? .default
|
||||
)
|
||||
return RDEPUBRenderedChapterContent(
|
||||
attributedString: attributedString,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
@ -76,7 +80,11 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
|
||||
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
|
||||
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(
|
||||
in: attributedString,
|
||||
style: request.style,
|
||||
layoutConfig: request.layoutConfig ?? .default
|
||||
)
|
||||
return RDEPUBRenderedChapterContent(
|
||||
attributedString: attributedString,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
|
||||
@ -104,7 +104,11 @@ enum RDEPUBTextRendererSupport {
|
||||
// MARK: - 后渲染归一化(由 RDEPUBDTCoreTextRenderer 调用)
|
||||
|
||||
/// 规范化阅读属性:统一字体、行距、颜色,并注入分页语义属性。
|
||||
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
|
||||
static func normalizeReadingAttributes(
|
||||
in attributedString: NSMutableAttributedString,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
layoutConfig: RDEPUBTextLayoutConfig = .default
|
||||
) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
var blockIndex = 0
|
||||
let sourceText = attributedString.string as NSString
|
||||
@ -114,6 +118,7 @@ enum RDEPUBTextRendererSupport {
|
||||
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
|
||||
paragraph.lineSpacing = style.lineSpacing
|
||||
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
|
||||
paragraph.hyphenationFactor = layoutConfig.hyphenation ? 1.0 : 0.0
|
||||
|
||||
var updatedAttributes = attributes
|
||||
updatedAttributes[.font] = normalizedFont
|
||||
|
||||
@ -30,12 +30,15 @@ final class RDEPUBReaderChapterListController: UITableViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.accessibilityIdentifier = "epub.reader.toc.panel"
|
||||
tableView.accessibilityIdentifier = "epub.reader.toc.table"
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.backgroundColor = theme.contentBackgroundColor
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.toc.navbar"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
@ -62,4 +65,4 @@ final class RDEPUBReaderChapterListController: UITableViewController {
|
||||
guard let currentItem else { return false }
|
||||
return currentItem.href == item.href
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,6 +91,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
|
||||
/// 页码变化回调:清除选区、同步阅读状态、检测到达末尾
|
||||
public func pageNum(readerView: RDReaderView, pageNum: Int) {
|
||||
readerContext.markUserNavigationActivity()
|
||||
updateCurrentSelection(nil)
|
||||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
||||
|
||||
|
||||
@ -247,6 +247,8 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
|
||||
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||||
applyTheme()
|
||||
@ -286,6 +288,7 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
|
||||
@ -25,6 +25,12 @@ import UIKit
|
||||
/// navigationController?.pushViewController(controller, animated: true)
|
||||
/// ```
|
||||
public final class RDURLReaderController: UIViewController {
|
||||
private struct PendingDemoPageRequest {
|
||||
let pageNumber: Int
|
||||
let animated: Bool
|
||||
var attemptCount: Int
|
||||
}
|
||||
|
||||
/// 书籍文件 URL
|
||||
private let bookURL: URL
|
||||
/// EPUB 阅读器配置(字体、行距、主题等)
|
||||
@ -41,6 +47,10 @@ public final class RDURLReaderController: UIViewController {
|
||||
label.text = "reader=opening"
|
||||
return label
|
||||
}()
|
||||
private var pendingDemoPageRequest: PendingDemoPageRequest?
|
||||
private var isRetryingPendingDemoPage = false
|
||||
private let maxPendingDemoPageAttempts = 24
|
||||
private let pendingDemoPageRetryDelay: TimeInterval = 0.25
|
||||
|
||||
/// 初始化方法
|
||||
/// - Parameters:
|
||||
@ -88,9 +98,12 @@ public final class RDURLReaderController: UIViewController {
|
||||
/// - Returns: 是否成功跳转
|
||||
@discardableResult
|
||||
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
let moved = readerController?.go(toPageNumber: pageNumber, animated: animated) ?? false
|
||||
let moved = performDemoPageNavigation(pageNumber, animated: animated)
|
||||
if moved {
|
||||
emitDemoState(prefix: "page=\(pageNumber)")
|
||||
pendingDemoPageRequest = nil
|
||||
isRetryingPendingDemoPage = false
|
||||
} else {
|
||||
queuePendingDemoPageNavigation(pageNumber, animated: animated)
|
||||
}
|
||||
return moved
|
||||
}
|
||||
@ -187,6 +200,73 @@ public final class RDURLReaderController: UIViewController {
|
||||
embeddedController as? RDEPUBReaderController
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool {
|
||||
guard let readerController else { return false }
|
||||
guard pageNumber > 0 else { return false }
|
||||
|
||||
let totalPages = readerController.textBook?.pages.count ?? readerController.activePages.count
|
||||
let numPages = readerController.readerView.numberOfPages()
|
||||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): textBook.pages=\(totalPages), readerView.numberOfPages=\(numPages), currentPage=\(readerController.readerView.currentPage)")
|
||||
guard pageNumber <= totalPages else { return false }
|
||||
|
||||
readerController.readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): after transition, currentPage=\(readerController.readerView.currentPage)")
|
||||
emitDemoState(prefix: "page=\(pageNumber)")
|
||||
return true
|
||||
}
|
||||
|
||||
private func queuePendingDemoPageNavigation(_ pageNumber: Int, animated: Bool) {
|
||||
if let pendingDemoPageRequest,
|
||||
pendingDemoPageRequest.pageNumber == pageNumber,
|
||||
pendingDemoPageRequest.animated == animated {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDemoPageRequest = PendingDemoPageRequest(
|
||||
pageNumber: pageNumber,
|
||||
animated: animated,
|
||||
attemptCount: 0
|
||||
)
|
||||
retryPendingDemoPageIfNeeded()
|
||||
}
|
||||
|
||||
private func retryPendingDemoPageIfNeeded() {
|
||||
guard !isRetryingPendingDemoPage else { return }
|
||||
guard pendingDemoPageRequest != nil else { return }
|
||||
|
||||
isRetryingPendingDemoPage = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
|
||||
self?.attemptPendingDemoPageNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
private func attemptPendingDemoPageNavigation() {
|
||||
guard var pendingDemoPageRequest else {
|
||||
isRetryingPendingDemoPage = false
|
||||
return
|
||||
}
|
||||
|
||||
if performDemoPageNavigation(pendingDemoPageRequest.pageNumber, animated: pendingDemoPageRequest.animated) {
|
||||
self.pendingDemoPageRequest = nil
|
||||
isRetryingPendingDemoPage = false
|
||||
return
|
||||
}
|
||||
|
||||
pendingDemoPageRequest.attemptCount += 1
|
||||
if pendingDemoPageRequest.attemptCount >= maxPendingDemoPageAttempts {
|
||||
self.pendingDemoPageRequest = nil
|
||||
isRetryingPendingDemoPage = false
|
||||
print("[ReadViewDemo] automation page=\(pendingDemoPageRequest.pageNumber) failed after \(pendingDemoPageRequest.attemptCount) attempts")
|
||||
return
|
||||
}
|
||||
|
||||
self.pendingDemoPageRequest = pendingDemoPageRequest
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + pendingDemoPageRetryDelay) { [weak self] in
|
||||
self?.attemptPendingDemoPageNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出当前阅读器状态日志(Demo 调试用)
|
||||
private func logDemoState(prefix: String) {
|
||||
guard let readerController else { return }
|
||||
@ -259,7 +339,13 @@ public final class RDURLReaderController: UIViewController {
|
||||
}
|
||||
|
||||
extension RDURLReaderController: RDEPUBReaderDelegate {
|
||||
public func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {
|
||||
retryPendingDemoPageIfNeeded()
|
||||
emitDemoState()
|
||||
}
|
||||
|
||||
public func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {
|
||||
retryPendingDemoPageIfNeeded()
|
||||
emitDemoState()
|
||||
}
|
||||
|
||||
|
||||
@ -23,11 +23,13 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
|
||||
setupErrorLabel(controller.errorLabel, in: controller.view)
|
||||
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
|
||||
print("[ReadViewDemo] assembleInterface: dataSource=\(readerView.dataSource != nil ? "set" : "nil"), numberOfPages=\(readerView.numberOfPages())")
|
||||
}
|
||||
|
||||
/// 外部纯文本图书启动时,加载已保存的书签、高亮和阅读位置,完成分页收尾。
|
||||
func finishExternalTextBookLaunchIfNeeded() {
|
||||
guard let runtime = context.runtime,
|
||||
let controller = context.controller,
|
||||
context.isExternalTextBook else {
|
||||
return
|
||||
}
|
||||
@ -37,7 +39,14 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? []
|
||||
context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? []
|
||||
}
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
|
||||
if let textBook = controller.textBook {
|
||||
print("[ReadViewDemo] finishExternalTextBook: applying textBook with \(textBook.pages.count) pages")
|
||||
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
print("[ReadViewDemo] finishExternalTextBook: after applyTextBook, numberOfPages=\(context.readerView?.numberOfPages() ?? -1)")
|
||||
} else {
|
||||
runtime.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) {
|
||||
|
||||
@ -9,6 +9,9 @@ import UIKit
|
||||
/// - 便捷方法(renderStyle、layoutConfig 等)
|
||||
/// - 弱引用 controller(仅用于 UIKit 呈现操作)
|
||||
final class RDEPUBReaderContext {
|
||||
private let activityLock = NSLock()
|
||||
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
|
||||
|
||||
// MARK: - 引用
|
||||
|
||||
/// 弱引用阅读器控制器,用于 UIKit 呈现操作。
|
||||
@ -205,6 +208,22 @@ final class RDEPUBReaderContext {
|
||||
persistence?.saveLocation(location, for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 记录最近一次用户翻页/跳转行为,用于后台分页让路。
|
||||
func markUserNavigationActivity() {
|
||||
activityLock.lock()
|
||||
lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent()
|
||||
activityLock.unlock()
|
||||
}
|
||||
|
||||
/// 距离最近一次用户翻页/跳转已经过去的时间。
|
||||
func secondsSinceLastUserNavigation() -> CFAbsoluteTime {
|
||||
activityLock.lock()
|
||||
let timestamp = lastUserNavigationTimestamp
|
||||
activityLock.unlock()
|
||||
guard timestamp > 0 else { return .greatestFiniteMagnitude }
|
||||
return CFAbsoluteTimeGetCurrent() - timestamp
|
||||
}
|
||||
|
||||
/// 根据规范化 href 获取文本章节数据。
|
||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||
guard let textBook, let publication else { return nil }
|
||||
|
||||
@ -9,6 +9,52 @@ import Foundation
|
||||
/// - 刷新可见内容并保持位置
|
||||
/// - 重建外部纯文本图书
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
||||
private let backgroundChapterPause: TimeInterval = 0.04
|
||||
private let incrementalMergeChapterThreshold = 20
|
||||
private let stagedIncrementalApplyDelay: TimeInterval = 1.0
|
||||
private var stagedIncrementalApplyWorkItem: DispatchWorkItem?
|
||||
private let stagedBookRequestLock = NSLock()
|
||||
private var stagedBookRequest: StagedBookRequest?
|
||||
|
||||
private struct QuickBuildState {
|
||||
var quickWindow: [Int]
|
||||
var chapterStore: IncrementalChapterStore
|
||||
var book: RDEPUBTextBook
|
||||
}
|
||||
|
||||
private struct StagedBookRequest {
|
||||
var chapterStore: IncrementalChapterStore
|
||||
var orderedSpineIndices: [Int]
|
||||
var restoreLocation: RDEPUBLocation?
|
||||
var isComplete: Bool
|
||||
}
|
||||
|
||||
private final class IncrementalChapterStore {
|
||||
private let lock = NSLock()
|
||||
private var builtChapters: [Int: RDEPUBTextChapter] = [:]
|
||||
|
||||
func insert(_ chapter: RDEPUBTextChapter, for spineIndex: Int) {
|
||||
lock.lock()
|
||||
builtChapters[spineIndex] = chapter
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func contains(_ spineIndex: Int) -> Bool {
|
||||
lock.lock()
|
||||
let contains = builtChapters[spineIndex] != nil
|
||||
lock.unlock()
|
||||
return contains
|
||||
}
|
||||
|
||||
func snapshot(orderedBy spineIndices: [Int]) -> [Int: RDEPUBTextChapter] {
|
||||
lock.lock()
|
||||
let chapters = builtChapters
|
||||
lock.unlock()
|
||||
return chapters
|
||||
}
|
||||
}
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@ -29,38 +75,22 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
controller.showLoading()
|
||||
let token = UUID()
|
||||
context.paginationToken = token
|
||||
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
|
||||
|
||||
if publication.readingProfile == .textReflowable {
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
context.lastTextPaginationPageSize = pageSize
|
||||
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
let renderStyle = controller.currentTextRenderStyle()
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak controller] in
|
||||
guard let controller else { return }
|
||||
do {
|
||||
let textBook = try builder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: renderStyle
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.context.runtime?.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
print("[EPUB][Pagination] path=text-reflowable-fast-entry")
|
||||
paginateTextPublication(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
readingSession: readingSession,
|
||||
restoreLocation: restoreLocation,
|
||||
token: token
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if publication.layout == .fixed {
|
||||
print("[EPUB][Pagination] path=fixed-layout")
|
||||
let snapshot = readingSession.makePaginationSnapshot(
|
||||
pageCounts: Array(repeating: 1, count: publication.spine.count),
|
||||
preferences: controller.currentPreferences(),
|
||||
@ -71,6 +101,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
let paginator = context.makePaginator()
|
||||
print("[EPUB][Pagination] path=web-paginator")
|
||||
context.paginator = paginator
|
||||
paginator.calculate(
|
||||
parser: parser,
|
||||
@ -91,6 +122,9 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
/// 应用文本图书模型:生成分页快照并完成分页流程。
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller else { return }
|
||||
stagedIncrementalApplyWorkItem?.cancel()
|
||||
stagedIncrementalApplyWorkItem = nil
|
||||
clearStagedBookRequest()
|
||||
context.textBook = textBook
|
||||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
@ -108,7 +142,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
guard context.controller != nil else { return }
|
||||
context.textBook = nil
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
@ -168,4 +202,351 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本 EPUB 大书快速进入:先构建目标章节进入阅读器,再后台补齐完整 TextBook。
|
||||
private func paginateTextPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
readingSession: RDEPUBReadingSession,
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
token: UUID
|
||||
) {
|
||||
guard let controller = context.controller else { return }
|
||||
|
||||
let pageSize = controller.currentTextPageSize()
|
||||
context.lastTextPaginationPageSize = pageSize
|
||||
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
let renderStyle = controller.currentTextRenderStyle()
|
||||
let quickSpineCandidates = prioritizedBuildableSpineIndices(
|
||||
publication: publication,
|
||||
readingSession: readingSession,
|
||||
restoreLocation: restoreLocation
|
||||
)
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak controller] in
|
||||
guard controller != nil else { return }
|
||||
var didApplyQuickChapter = false
|
||||
|
||||
do {
|
||||
self.waitForReadingInteractionToSettle()
|
||||
let allBuildableIndices = self.allBuildableSpineIndices(in: publication)
|
||||
let quickBuildState = try self.buildQuickTextBook(
|
||||
builder: builder,
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: renderStyle,
|
||||
prioritizedCandidates: quickSpineCandidates
|
||||
)
|
||||
|
||||
if let quickBook = quickBuildState?.book {
|
||||
DispatchQueue.main.sync {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
didApplyQuickChapter = true
|
||||
self.context.runtime?.applyTextBook(quickBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
|
||||
let chapterStore = quickBuildState?.chapterStore ?? IncrementalChapterStore()
|
||||
var pendingIncrementalCount = 0
|
||||
let incrementalBuildOrder = self.incrementalBuildOrder(
|
||||
allBuildableIndices: allBuildableIndices,
|
||||
quickWindow: quickBuildState?.quickWindow ?? []
|
||||
)
|
||||
|
||||
for spineIndex in incrementalBuildOrder where !chapterStore.contains(spineIndex) {
|
||||
self.waitForReadingInteractionToSettle()
|
||||
guard let result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: renderStyle
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
chapterStore.insert(result.chapter, for: spineIndex)
|
||||
pendingIncrementalCount += 1
|
||||
Thread.sleep(forTimeInterval: self.backgroundChapterPause)
|
||||
|
||||
if pendingIncrementalCount >= self.incrementalMergeChapterThreshold {
|
||||
pendingIncrementalCount = 0
|
||||
self.stageBookRequest(
|
||||
chapterStore: chapterStore,
|
||||
orderedSpineIndices: allBuildableIndices,
|
||||
restoreLocation: restoreLocation,
|
||||
isComplete: false
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.scheduleStagedIncrementalTextBookApplication()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.stageBookRequest(
|
||||
chapterStore: chapterStore,
|
||||
orderedSpineIndices: allBuildableIndices,
|
||||
restoreLocation: restoreLocation,
|
||||
isComplete: true
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
self.scheduleStagedIncrementalTextBookApplication()
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
guard self.context.paginationToken == token else { return }
|
||||
if didApplyQuickChapter {
|
||||
self.context.isRepaginating = false
|
||||
self.context.hideLoading()
|
||||
} else {
|
||||
self.context.handle(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func buildQuickTextBook(
|
||||
builder: RDEPUBTextBookBuilder,
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
prioritizedCandidates: [Int]
|
||||
) throws -> QuickBuildState? {
|
||||
var anchorResult: RDEPUBTextChapterBuildResult?
|
||||
for spineIndex in prioritizedCandidates {
|
||||
guard let result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
), !result.chapter.pages.isEmpty else {
|
||||
continue
|
||||
}
|
||||
anchorResult = result
|
||||
break
|
||||
}
|
||||
|
||||
guard let anchorResult else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let quickWindow = quickWindowSpineIndices(
|
||||
around: anchorResult.chapter.spineIndex,
|
||||
in: publication
|
||||
)
|
||||
|
||||
let chapterStore = IncrementalChapterStore()
|
||||
for spineIndex in quickWindow {
|
||||
let result: RDEPUBTextChapterBuildResult?
|
||||
if spineIndex == anchorResult.chapter.spineIndex {
|
||||
result = anchorResult
|
||||
} else {
|
||||
result = try builder.buildChapter(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
)
|
||||
}
|
||||
|
||||
guard let chapter = result?.chapter,
|
||||
!chapter.pages.isEmpty else {
|
||||
continue
|
||||
}
|
||||
chapterStore.insert(chapter, for: spineIndex)
|
||||
}
|
||||
|
||||
guard let book = textBook(from: chapterStore.snapshot(orderedBy: quickWindow), orderedBy: quickWindow) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
print("[EPUB][Pagination] quick-book chapters=\(book.chapters.count) pages=\(book.pages.count) anchor=\(anchorResult.chapter.href)")
|
||||
return QuickBuildState(
|
||||
quickWindow: quickWindow,
|
||||
chapterStore: chapterStore,
|
||||
book: book
|
||||
)
|
||||
}
|
||||
|
||||
private func prioritizedBuildableSpineIndices(
|
||||
publication: RDEPUBPublication,
|
||||
readingSession: RDEPUBReadingSession,
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) -> [Int] {
|
||||
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
|
||||
return publication.spine.indices
|
||||
.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||||
.sorted { lhs, rhs in
|
||||
abs(lhs - preferred) < abs(rhs - preferred)
|
||||
}
|
||||
}
|
||||
|
||||
private func quickWindowSpineIndices(
|
||||
around anchorSpineIndex: Int,
|
||||
in publication: RDEPUBPublication,
|
||||
maxChapterCount: Int = 3
|
||||
) -> [Int] {
|
||||
let buildableIndices = allBuildableSpineIndices(in: publication)
|
||||
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
|
||||
return [anchorSpineIndex]
|
||||
}
|
||||
|
||||
var selected: [Int] = [anchorSpineIndex]
|
||||
var nextPosition = anchorPosition + 1
|
||||
var previousPosition = anchorPosition - 1
|
||||
|
||||
while selected.count < maxChapterCount,
|
||||
nextPosition < buildableIndices.count || previousPosition >= 0 {
|
||||
if nextPosition < buildableIndices.count {
|
||||
selected.append(buildableIndices[nextPosition])
|
||||
nextPosition += 1
|
||||
if selected.count == maxChapterCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if previousPosition >= 0 {
|
||||
selected.insert(buildableIndices[previousPosition], at: 0)
|
||||
previousPosition -= 1
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
|
||||
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
|
||||
}
|
||||
|
||||
func scheduleStagedIncrementalTextBookApplication() {
|
||||
stagedIncrementalApplyWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
self?.applyStagedIncrementalTextBookIfPossible()
|
||||
}
|
||||
stagedIncrementalApplyWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + stagedIncrementalApplyDelay, execute: workItem)
|
||||
}
|
||||
|
||||
private func applyStagedIncrementalTextBookIfPossible() {
|
||||
guard context.secondsSinceLastUserNavigation() >= backgroundInteractionCooldown,
|
||||
!context.isRepaginating,
|
||||
context.readingSession?.navigatorState == .idle else {
|
||||
scheduleStagedIncrementalTextBookApplication()
|
||||
return
|
||||
}
|
||||
|
||||
guard let staged = consumeStagedBookRequest() else {
|
||||
stagedIncrementalApplyWorkItem = nil
|
||||
return
|
||||
}
|
||||
|
||||
stagedIncrementalApplyWorkItem = nil
|
||||
guard let stagedBook = textBook(
|
||||
from: staged.chapterStore.snapshot(orderedBy: staged.orderedSpineIndices),
|
||||
orderedBy: staged.orderedSpineIndices
|
||||
) else {
|
||||
return
|
||||
}
|
||||
let restoreLocation = context.currentVisibleLocation() ?? staged.restoreLocation
|
||||
let logPrefix = staged.isComplete ? "full-book" : "applied-staged-book"
|
||||
print("[EPUB][Pagination] \(logPrefix) chapters=\(stagedBook.chapters.count) pages=\(stagedBook.pages.count)")
|
||||
context.runtime?.applyTextBook(stagedBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
private func waitForReadingInteractionToSettle() {
|
||||
while context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
|
||||
Thread.sleep(forTimeInterval: 0.08)
|
||||
}
|
||||
}
|
||||
|
||||
private func incrementalBuildOrder(
|
||||
allBuildableIndices: [Int],
|
||||
quickWindow: [Int]
|
||||
) -> [Int] {
|
||||
guard let firstWindowIndex = quickWindow.first,
|
||||
let lastWindowIndex = quickWindow.last else {
|
||||
return allBuildableIndices
|
||||
}
|
||||
|
||||
let forward = allBuildableIndices.filter { $0 > lastWindowIndex }
|
||||
let backward = allBuildableIndices.filter { $0 < firstWindowIndex }
|
||||
return quickWindow + forward + backward
|
||||
}
|
||||
|
||||
private func textBook(
|
||||
from builtChapters: [Int: RDEPUBTextChapter],
|
||||
orderedBy spineIndices: [Int]
|
||||
) -> RDEPUBTextBook? {
|
||||
var chapters: [RDEPUBTextChapter] = []
|
||||
var pages: [RDEPUBTextPage] = []
|
||||
|
||||
for spineIndex in spineIndices {
|
||||
guard var chapter = builtChapters[spineIndex],
|
||||
!chapter.pages.isEmpty else {
|
||||
continue
|
||||
}
|
||||
|
||||
chapter.chapterIndex = chapters.count
|
||||
let normalizedPages = chapter.pages.enumerated().map { localPageIndex, page -> RDEPUBTextPage in
|
||||
var page = page
|
||||
page.absolutePageIndex = pages.count + localPageIndex
|
||||
page.chapterIndex = chapters.count
|
||||
page.pageIndexInChapter = localPageIndex
|
||||
page.totalPagesInChapter = chapter.pages.count
|
||||
return page
|
||||
}
|
||||
chapter.pages = normalizedPages
|
||||
chapters.append(chapter)
|
||||
pages.append(contentsOf: normalizedPages)
|
||||
}
|
||||
|
||||
guard !pages.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return RDEPUBTextBook(chapters: chapters, pages: pages)
|
||||
}
|
||||
|
||||
private func stageBookRequest(
|
||||
chapterStore: IncrementalChapterStore,
|
||||
orderedSpineIndices: [Int],
|
||||
restoreLocation: RDEPUBLocation?,
|
||||
isComplete: Bool
|
||||
) {
|
||||
stagedBookRequestLock.lock()
|
||||
stagedBookRequest = StagedBookRequest(
|
||||
chapterStore: chapterStore,
|
||||
orderedSpineIndices: orderedSpineIndices,
|
||||
restoreLocation: restoreLocation,
|
||||
isComplete: isComplete
|
||||
)
|
||||
stagedBookRequestLock.unlock()
|
||||
}
|
||||
|
||||
private func consumeStagedBookRequest() -> StagedBookRequest? {
|
||||
stagedBookRequestLock.lock()
|
||||
defer { stagedBookRequestLock.unlock() }
|
||||
let request = stagedBookRequest
|
||||
stagedBookRequest = nil
|
||||
return request
|
||||
}
|
||||
|
||||
private func clearStagedBookRequest() {
|
||||
stagedBookRequestLock.lock()
|
||||
stagedBookRequest = nil
|
||||
stagedBookRequestLock.unlock()
|
||||
}
|
||||
|
||||
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
|
||||
guard publication.spine.indices.contains(index) else { return false }
|
||||
let item = publication.spine[index]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,11 +73,15 @@ final class RDEPUBReaderRuntime {
|
||||
return false
|
||||
}
|
||||
|
||||
if context.textBook != nil {
|
||||
guard let location = controller.resolvedTextLocation(forPageNumber: pageNumber) else {
|
||||
if let textBook = context.textBook {
|
||||
guard textBook.page(at: pageNumber) != nil else {
|
||||
return false
|
||||
}
|
||||
return locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
if let location = locationCoordinator.currentVisibleLocation() {
|
||||
context.persist(location: location)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
guard context.activePages.indices.contains(pageNumber - 1) else {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user