Txt引擎之切章重构之路

前言

由于诸多历史原因,QQ阅读iOS App目前的Txt引擎只能支持单个文件的加载、排版和渲染,所以在处理在线章节阅读的切换时,只能先把当前阅读的章节的引擎关闭掉,清理掉所有的业务数据,然后重新加载新的章节的引擎,重新拉取新章节的业务信息。在这种情况下,存在着2个严重的缺陷:

  • 在两个章节的临界点来回翻页的时候,需要不停地执行打开关闭操作,会付出很大的性能代价
  • 3D翻页的时候需要进行强制翻页,换句话说,没办法通过手势控制3D效果,没有回翻的机会.

因此需要重构Txt引擎多章节直接的切换流程,以最小的代价来支持多章节的无缝切换。
(实际上该重构工作的出发点是为了看看是否有可能去除RDM的top1 crash)

———— 所有的痛苦源于对历史缺乏足够的认知和敬畏

现状分析

整体架构流程

  • TextBaseViewController持有QRReadingDataSourceQRReadingDataSource负责章节数据以及各种业务数据的加载;
  • QRReadingDataSource持有ReaderRender,同时也被ReaderRender反持有,ReaderRender负责页面的绘制,包括各种业务数据对应的视图;
  • 所有和引擎相关的都是通过Hub来操作,底层是TextEngine来负责具体章节文件的加载、排版和渲染
  • 3D翻页、平滑翻页、覆盖翻页的各种VC都是直接弱持有TextBaseViewController,并且要调用里面的方法,因此TextBaseViewController不得已需要开各种接口供其调用

(各种持有和反持有操作,耦合的无法自拔)

打开文件流程

  • 打开文件调用层级太深,有的在OpenBookManager中,有的在TextBaseViewController中,逻辑有些分散,所以不得已采用通知的方式进行同步,这种到处弥漫的发散逻辑代码风格阅读起来十分费神;
  • 这里的章节加载中有预加载的逻辑,但是只涉及到章节的数据,对于付费预览页的信息没有做缓存处理,每次翻到新的章节,都要示意性的loading
  • 对于其它的业务逻辑,有的分布在TextBaseViewController,有的分布在QRReadingDataSource中,并且没有同步机制,每种业务数据都是单独请求,回来都可能不同程度地刷新阅读页面,增加了阅读页的刷新压力

Hub调用

Hub机制相当于将底层的引擎TextEngine做成了一个单例,所以到处都可以没有节制地加以调用,初步统计结果如下:

文件名 个数
QRReadingDataSource 3
ReaderRender 33
TextBaseViewController 47

重构之路

缓存模型的确立

为了在切到下一章的时候避免不必要的浪费,我们不能立刻将上一个章节的信息立刻关闭,而需要缓存下来,这样如果要回翻,就不用再次打开,而是直接利用缓存,同时也为了更好的翻页体验,因此缓存模型被提了出来

3引擎架构

一开始想到的是用3引擎架构,即生成一个引擎壳QRTextEngine,内部持有3个TextEngine实例,来实现数据的预加载和章节的切换功能

当前的章节快要翻到尾页的时候,例如进度是80%的时候,触发预加载逻辑,nextTextEngine在子线程异步加载下一章的数据,当真的要切到下一章的时候,直接将curTextEngine指向nextTextEngine就可以完成章节引擎的切换。

1
2
3
4
5
6
@interface QRTextEngine()<TextEngineDelegate>{
@property(nonatomic,strong) TextEngine *preEngine;
@property(nonatomic,strong) TextEngine *curEngine;
@property(nonatomic,strong) TextEngine *nextEngine;
...
@end
1
2
3
4
5
6
7
8
- (void)switchToNextEngine
{
[self syncPerformBlockOnEngineQueue:^{
self.preEngine = self.curEngine;
self.curEngine = self.nextEngine;
self.nextEngine = nil;
}];
}

然而实践中发现该缓存模型存在着一些问题:

  • 不是所有的章节都有章节数据,有的章节可能没有付费,自然不可能有文件,这个时候TextEngine是空的,切换就会失败;
  • 付费预览信息得不到缓存,每次都需要重新请求,极大的浪费了流量,loading的等待也令人极其不适
  • 有的情况下会出现这样的情况:[engine nil engine]、[nil engine nil],如果切换逻辑控制不好,会造成一定的错乱,所以不得已执行一个假文件的打开操作;
  • 缓存的不够彻底,章节的各种业务缓存逻辑还得在别的地方进行处理,一个统一的行为被割裂了…

3模型架构

发掘了3引擎架构的一些不足之后,经过考虑,引入了3模型架构:

模型不再仅仅是指引擎,它相当于是章节的一个模型QRTextReaderChapterModel,它不仅包括TextEngine,还可以包括付费预览等授权信息,甚至可以包括章节的各种业务数据(例如章评、段评等)

前面3引擎碰到的问题都迎刃而解:

  • 所有的章节都有了数据,已下载的是章节渲染数据,未下载的是付费预览数据,可以实现页面之间的随意切换;
  • 付费预览信息得到了缓存,可以根据需求在一定的时间内可以只请求1次;
  • 各种业务数据的缓存逻辑得到统一处理,只有全部的业务数据都回来之后,再回去刷新阅读页,减少了阅读页的刷新压力;

重构类图

确立了缓存模型之后,整个重构方案的架构基本上就确立下来了

高清图传送门

  • 1、针对Txt书籍,不再直接调用OpenBookManager处理打开书的逻辑,它现在仅仅负责生成VC,把BookInfo传给VC,然后进行push操作;
  • 2、剔除原先的QRReadingDataSourceReaderRender,由新的管理类QRTextReaderManager统一负责核心逻辑;
  • 3、各种翻页的VC不再直接弱持有TextBaseViewController,而是直接传递QRTextReaderManager,调用QRTextReaderManager的各种接口;
  • 4、3模型架构直接由QRTextReaderManager来管理,因此QRTextReaderManager负责这3个Model的生成、预加载、切换和销毁;
  • 5、QRTextReaderChapterModel
    (1)负责章节文件请求和加载,以及TextEngine的初始化;
    (2)负责付费预览信息的请求、缓存管理;
    (3)负责各种业务数据的请求和加载;
    (4)调用QRTextEngine,负责各种页面的排版和渲染;

QRTextReaderManager

Txt阅读逻辑的各种统筹调度

在最开始的设计中,采用的是“3引擎架构”,即QRTextEngine直接持有3个TxtEngine引擎,并且完成3个引擎的生成、预加载、切换和销毁,但是这不能很好地解决一些章节网络状态(付费、登录、下架)的处理,中间为了解决各种诡异问题,保证内部引擎切换的连续性,还一度引入了“打开一个假引擎”的概念,但是终究还是放弃了,因为这是整个结构设计上的缺陷,大方向就是有问题的,这种缝缝补补的后续措施只会越陷越深…

这样原来都是“3引擎架构”就变成了“3模型架构”。原先的“3引擎架构”是由QRTextEngine管理的,而现在的“3 Model架构”则直接由QRTextReaderManager来管理,因此QRTextReaderManager需要负责这3个Model的生成、预加载、切换和销毁。

原先在TextBaseViewController中存在着大量的字段,用于各种逻辑判断,但是因为现在QRTextReaderManager要承载各种逻辑,所以这部分字段需要进行下沉。

QRTextReaderChapterModel

Txt阅读逻辑的各种统筹调度的核心逻辑支撑

最后的方案:在QRTextEngine的基础上包装了一层QRTextReaderChapterModel
该类中既包含一个章节的网络状态信息authorizeInfo,也包含本地文件信息以及加载文件的engine引擎,当然这两者是互斥的,任何时刻只能存在一种,并且存在网络状态到本地状态的转换。

  • 如果是网络状态,QRTextReaderChapterModel利用authorizeInfo包含的信息生成一个View;
  • 如果是本地状态,QRTextReaderChapterModel利用engine引擎进行排版、渲染好页面图片,并添加页眉页脚,生成一个View;并且调用QRTextBusinessManager异步加载和管理各种业务数据信息,数据回来后统一刷新阅读页面;

注意:所有章节的加载,包括预加载都应该经过QRTextReaderChapterModel,因为只有这样,才能搞清楚每个章节的一个状态,才能不至于重复加载,避免造成资源的浪费。

当各种业务数据回来后,如何统一刷新呢?这里采用了一种简单的方案:
每一种业务数据都有一个专门的属性来标记它的状态,当它被标记为YES的时候,它会去尝试性地调用
handleBusinessDataIfNecessary方法,在这个方法里回去判定是否所有的业务数据都已经就绪,进而统一回调刷新。这个方法的前提是各种业务数据的回调都在主线程上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@weakify(self)
[self.businessMgr loadAllBusinessDataWithReadyCallback:^(NSError *error, NSDictionary* dic) {

@strongify(self)

// 每个章节的数据全部回来统一处理
[self handleTailPagePlaceholder];
[self handleChapterCommentResponse:dic];
[self handleExcellentParaComment];

// 引擎
if(self.loadPoint == QRChapterLoadPointType_End){
[self turnToFinalPage];
}

// 业务数据回来之后,清除缓存
[self flushPageCache];

// 业务数据全部回来之后,代理出去,看看阅读页是否需要刷新
NSDictionary* result = @{QROnlineReadingResultKeys.result:@(QRReadOnlineBookResult_Opened)};
[self.delegate chapterModel:self didFinishLoadWithResult:result];

}];

QRTextBusinessManager

承载Txt章节的所有业务逻辑,包括章评、段评、大神说QA、广告、投票、红包等,实际上它也是一层简单的封装,里面的每一个业务都有相应的类去做。

每个章节的信息(可能是网络状态信息,也可能是本地文件)就绪之后,就利用QRTextBusinessManager进行业务数据的加载,同样要在QRTextReaderChapterModel记录业务数据状态(未请求、请求中、请求结束)

  • QA(大神说问答|章节业务)
  • Advertisements(广告数据|章节业务)
  • InteractionBar(章节尾页底部(打赏、推荐票、月票、红包)|书的业务)
  • Comments(章评|章节业务)
  • ParagraphTailMark(段评|章节业务)

git分支merge之后,尾页新增了一些业务:例如广点通广告和卡牌。其中卡牌数据是用的chapterall协议,跟随者其它的业务数据一起回来的,然后广点通的广告是独立的协议,并且其业务比较复杂,其逻辑被单独放置到了QRChapterBusinessManager+GDTAd.h文件中,并且和其它的业务数据一样,在回调回来的时候做了统一处理

1
2
3
4
5
- (void)setAlreadyGetGDTTailAd:(BOOL)alreadyGetGDTTailAd
{
_alreadyGetGDTTailAd = alreadyGetGDTTailAd;
[self handleBusinessDataIfNecessary];
}

QRTextEngine封装

对TextEngine的简单封装,主要充当原有的EngineModule的功能,但是不再是单例

章节加载逻辑

所有章节的加载和预加载的逻辑都应该从QRTextReaderChapterModel开始,相当于一个起点,一旦开始加载,需要随时更新QRTextReaderChapterModel的状态

预加载的触发时机在最新的代码中只有1处:在章节id变化的时候或者onlineTag被修改的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
- (void)setOnlineTag:(OnlineTag *)onlineTag
{
if(!onlineTag) { return; }
_onlineTag = onlineTag;

_chapterModel = [self chapterModelWithOnlineTag:onlineTag];
if(_chapterModel.bookInfo != nil){
self.bookInfo = _chapterModel.bookInfo;
}

// 上报
[self reportOnlineBookWithTag:_onlineTag];

// 保存浏览历史。 lucas
if (self.onlineTag.onlineBookInfo) {
[[QRScanHistoryService sharedScanHistoryService] recordScanHistoryItem:self.onlineTag.onlineBookInfo];
}

NSArray *allKeys = [self.chapterModels allKeys];
NSInteger chapterId = [@(self.chapterModel.chapterId) integerValue];
for (NSNumber *key in allKeys){
NSInteger keyValue = [key integerValue];
if (ABS(keyValue - chapterId) > 1){
[self.chapterModels removeObjectForKey:key];
}
}

// 异步预加载逻辑
[self asyncPerformBlockOnChapterProloadQueue:^{

// 切章后,当前章节的前后章节最多只保留1页的缓存
QRTextReaderChapterModel* preModel = [self.chapterModels qr_safeObjectForKey:@(chapterId-1)];
[preModel removeAllPageCacheExceptTheLastOne];
QRTextReaderChapterModel* nextModel = [self.chapterModels qr_safeObjectForKey:@(chapterId+1)];
[nextModel removeAllPageCacheExceptTheFirstOne];


if(self.onlineTag.onlineBookInfo.chapterid < self.onlineTag.onlineBookInfo.totalChapter){
OnlineTag* nextOnlineTag = [self onlineTagWithTag:self.onlineTag direction:QRTurnDirection_Next];
[nextOnlineTag resetProgress];
nextOnlineTag.rightParameter = CHAPTER_CUR;
QRTextReaderChapterModel* nextModel = [self chapterModelWithOnlineTag:nextOnlineTag];
if(nil == nextModel){
[self proloadChapterModelWithOnlineTag:nextOnlineTag];
}
}

if(self.onlineTag.toReadingCharpterId > 0){
OnlineTag* prevOnlineTag = [self onlineTagWithTag:self.onlineTag direction:QRTurnDirection_Prev];
[prevOnlineTag resetProgress];
prevOnlineTag.rightParameter = CHAPTER_CUR;
QRTextReaderChapterModel* preModel = [self chapterModelWithOnlineTag:prevOnlineTag];
if(nil == preModel){
[self proloadChapterModelWithOnlineTag:prevOnlineTag];
}
}
}];
}

这里预加载的内容包括2个方面:

  • 章节的信息,可能是文件,也可能是付费预览等信息;
  • 章节相关的所有业务,当然业务种类还是比较复杂的,并且有些业务已经影响到页面的排版;
    QRTextReaderChapterModel会随时记录当前的预加载进度状态

打开文件流程重构


高清图传送门

1、去除一路走来的“通知”,通过回调来实现,聚合被通知分散到各处的逻辑;
2、去除QROnlineReadingModule中的preload逻辑(该逻辑负责免费章节的预下载),因为现在的预加载发起点都应该从QRTextReaderChapterModel开始,并经过QRTextReaderChapterModel记录章节的状态

理论上来说,QROnlineReadingModule不应该知道当前阅读章节的OnlineTag
应该发生的逻辑:给定一个OnlineTag,它只是负责该章节的缓存查询、启动下载,以及网络结果回调,而最后的结果都应该回调到QRTextReaderChapterModel

  • 1、如果当前章节是第0章,代表这是一个封面,直接返回,结束;
  • 2、如果当前章节已下载,将章节文件路径返回到QRTextReaderChapterModel中,结束;
  • 3、进行网络请求,请求当前章节信息,等待结果:
    • 3.1 如果结果是已下载的章节文件,走第2步骤;
    • 3.2 如果需要授权(付费、登录、下架),将结果返回QRTextReaderChapterModel中;

Hub去除

  • 导入书的逻辑,新增类QRTextEngineParser来处理解析逻辑;

  • 涉及到章节排版渲染逻辑,使用QRTextReaderChapterModel中的QRTextEngine类型成员engine来解决;原来的Hub相当于一个单例,它可以无限制地使用在工程的任意一个文件里,而engine是作为curChapterModel中的一个成员而存在的,所以需要在QRTextReaderManager里对引擎的操作做一层封装,让QRTextReaderManager负责对外的各种引擎操作,engine不再直接与外界打交道。

  • 统计逻辑。Hub的统计逻辑原来在StatisticModule中,为了彻底消除Hub,新增了QRStatisticManager类,将原来StatisticModule的大部分逻辑全部改写到QRStatisticManager中,剩下的小部分逻辑有的直接调用QRBookManager,有的直接调用AppPersist

注:在替换的过程中,需要注意Hub的传入参数inputMessage和传出参数outputMessage,有时候区分不是特别明显,inputMessage也可以当做传出参数,这里要小心

翻页

翻页前逻辑判定

不管是哪一种翻页方式,在翻页之前,都需要进行判断:

  • 当前的页面是否支持向前翻页,如果当前页面已经是封面或者第一页,那就不能继续往前翻页了;
  • 当前的页面是否支持向后翻页,如果当前的页面已经是最后一章的最后一页了,就不能继续往后翻页了;
  • 如果当前的页面正在加载中,会禁止翻页;
  • 翻页频率控制

具体的细节可以参考如下方法

1
2
// TextBaseViewController+PageViewController.h
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer

翻页逻辑

翻页的时候可能产生以下行为:
1、如果是章内翻页,引擎TextEngine切到对应的页码,然后进行实时渲染;
2、如果是章间翻页,分3种情况:
(1)如果章节数据已经就绪,那么引擎TextEngine切到对应的页码(也可能是提前加载好的付费预览等信息),然后进行实时渲染;
(2)如果章节尚未加载,先加载一个loading页,然后加载章节数据,并在完成后进行回调刷新页面;
(3)如果章节正在加载中,先加载一个loading页,设置需要刷新的标记,在加载完成后进行回刷;

这里以 切到下一章 为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
- (void)gotoNextChapter
{
if(!self.isOnlineRead) { return;}

@synchronized(self)
{
// 注意这里切换onlineTag,需要上锁
OnlineTag* nextOnlineTag = [self onlineTagWithTag:self.onlineTag direction:QRTurnDirection_Next];
[nextOnlineTag resetProgress];
nextOnlineTag.rightParameter = CHAPTER_CUR;

// 注意这里会触发预加载的逻辑
self.onlineTag = nextOnlineTag;

QRTextReaderChapterModel* chapterModel = [self chapterModelWithOnlineTag:self.onlineTag];
if(nil == chapterModel || chapterModel.type == QRChapterModelState_Failure){
if(chapterModel){
[self.chapterModels qr_safeRemoveObjectForKey:@(chapterModel.chapterId)];
}
// 生成ChapterModel
chapterModel = [self createChapterModelWithOnlineTag:self.onlineTag];
chapterModel.loadPoint = QRChapterLoadPointType_Start;

// 需要去请求网络数据
[self proloadChapterModelWithOnlineTag:self.onlineTag];
}
else
{
chapterModel.loadPoint = QRChapterLoadPointType_Start;
if(chapterModel.type == QRChapterModelState_Loading){
// 先去刷loading页
[self.delegate refreshReadingPageIfNecessary];
}
else
{
// ChpaterModel已经提前缓存就绪
if(chapterModel.type == QRChapterModelState_Rended){
// 存在章节文件,并且引擎已经准备就绪
if(![self.chapterModel isPageTop]){
[self.chapterModel turnToFirstPage];
}
}
}
}
}
}

3D翻页

(1)手势翻页
手势翻页的处理比较简单,无需关心用户的滑动区域,UIPageViewController会自动识别是前翻页还是后翻页,最终都会调到以下几个比较重要的代理方法里进行逻辑处理:

1
2
3
4
5
6
7
// UIPageViewControllerDelegate
- (void)pageViewController:(UIPageViewController *)pageViewController didFinishAnimating:(BOOL)finished previousViewControllers:(NSArray *)previousViewControllers transitionCompleted:(BOOL)completed

// UIPageViewControllerDataSource
- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerBeforeViewController:(UIViewController *)viewController;

- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController viewControllerAfterViewController:(UIViewController *)viewController;

手势3D翻页有一个回翻的动作:3D动画进行到一半,又可以退回去,但是在3D动画开始的时候翻页逻辑已经发生了,所以回翻回去需要校正页码,其原因是在3D动画的开始,数据源已经发生了变化,所以在回翻回去的时候需要将数据源进行还原。
在新的架构下,需要分2种情况来讨论:
(1)如果回翻的动作发生在章节内,则需要在章节内调整页码;
(2)如果回翻的动作发生在章节之间,就涉及到章节之间的切换;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 当preVC的章节Id和ReaderManager的章节Id不一致的时候,需要切换章节引擎,一致的时候切换章内页码即可
- (void)resumePageWithCurrentId:(NSInteger)currentId
previousId:(NSInteger)previousId
previousIndex:(NSInteger)previousIndex
{
if(previousId < currentId){
// 引擎层切换回原有章节,页码不需要切换
[self gotoLastChapter];
}
else if(previousId > currentId){
// 引擎层切换回原有章节,页码不需要切换
[self gotoNextChapter];
}
else{
// 章节内切换回原有页码
[self.chapterModel resumePage];
}
}

3D手势翻页还有1个问题,就是很有可能在3D动画的过程中,收到网络结果的回调刷新,造成一定的诡异现象,解决办法是:此时不直接刷,而是把需要刷新的逻辑用block方式保存起来,等到翻页动画完成再进行刷新

1
2
3
4
5
6
7
8
9
void (^refreshBlock)() = ^{...};

if(_pageViewControllerDelegate.pageAnimationFinished){
refreshBlock();
}
else{
// 如果正在3D翻页过程中,直接刷新可能会造成逻辑错误,需要在动画结束后进行
[_pageViewControllerDelegate.callbackBlocks addObject:[refreshBlock copy]];
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)pageViewController:(UIPageViewController *)pageViewController didFinishAnimating:(BOOL)finished previousViewControllers:(NSArray *)previousViewControllers transitionCompleted:(BOOL)completed
{
self.pageAnimationFinished = YES;
...
if(completed){
while(self.callbackBlocks.count > 0){
id obj = [self.callbackBlocks qr_safeObjectAtIndex:0];
void (^block)() = (void(^)())(obj);
block();
[self.callbackBlocks qr_safeRemoveObjectAtIndex:0];
[block release];// MRC
}
}
}

(2)点击翻页
点击翻页的逻辑比较复杂,首先需要了解下UIPageViewController对点击区域的处理:

如图,只有点击区域处于左右两边的天蓝色区域内,UIPageViewController才会进行和上面手势一样的逻辑处理,其它地方是没有响应的,所以其它的地方就需要自定义处理:根据需要,在合适的区域内进行响应,调用下面的方法来进行前翻页或后翻页

1
- (void)setViewControllers:(nullable NSArray<UIViewController *> *)viewControllers direction:(UIPageViewControllerNavigationDirection)direction animated:(BOOL)animated completion:(void (^ __nullable)(BOOL finished))completion;

这样就引入了 系统点击区域自定义点击区域 两个概念:

区域判定

具体的点击区域判定逻辑可以参考下面两个方法:

1
2
- (QRTurnDirection)system3DAreaDirectionWithRect:(CGRect)rect point:(CGPoint)point;
- (QRTurnDirection)customAreaDirectionWithRect:(CGRect)rect point:(CGPoint)point;

无动画翻页

无动画翻页的逻辑是在3D翻页的基础上去除了动画实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[self loadPageViewControllerWithStyle:UIPageViewControllerTransitionStylePageCurl
orientation:UIPageViewControllerNavigationOrientationHorizontal];
_pageViewAnimateNone = YES;

//无动画模式屏蔽滑动
for (UIGestureRecognizer *gestureRecognizer in _pageViewController.gestureRecognizers)
{
if ([gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) {
gestureRecognizer.enabled = NO;
}
}

// 添加左右翻页手势
UISwipeGestureRecognizer *swipeLeft = [[[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(swipeTurnNextPage)] autorelease];
swipeLeft.direction = UISwipeGestureRecognizerDirectionLeft;
swipeLeft.delegate = self;
[_pageViewController.view addGestureRecognizer:swipeLeft];

UISwipeGestureRecognizer *swipeRight = [[[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(swipeTurnPreviousPage)] autorelease];
swipeRight.direction = UISwipeGestureRecognizerDirectionRight;
swipeRight.delegate = self;
[_pageViewController.view addGestureRecognizer:swipeRight];

点击翻页区域如下图所示:

平滑翻页

平滑翻页主要用到了上下两个视图,并实时预加载前后两个页面,本次重构主要修改的是预加载的逻辑:

原先通过nextPageInfo获取下一页的信息也仅仅局限在本章之内的,是无法跨章节的,现在由于引擎测已经可以支持多个章节打开,所以这里考虑跨章节获取下一章的信息。下一章如果已经下载好了,那么通过已经打开的引擎,可以进行排版渲染,返回结果,否则进行预加载,等待回调刷新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
- (QRReadingPageInfo *)nextPageInfo
{
if([self.chapterModel hasNextPage])
{
QRReadingPageInfo *pageInfo = [self.chapterModel nextPageInfo];

@weakify(self)
[self checkIfHitNextPageInsertLogic:self.chapterModel.pageIndex
succBlock:^(NSInteger hitPageIndex) {
@strongify(self)
pageInfo.pageDrawResultItem = [self cachePageImageWithIndex:hitPageIndex];

}
failBlock:^(id _Nullable insertItem) {
@strongify(self)
if(insertItem) {
pageInfo.pageDrawResultItem = [self.chapterModel imageForCoverPage];
pageInfo.insertItem = insertItem;
}
}];
return pageInfo;
}
else
{
// 获取下一章的ChapterModel(这里保证必须取到,如果取不到,立刻加载)
QRTextReaderChapterModel* nextChapterModel = [self chapterModelWithChapterId:self.chapterModel.chapterId+1];
if(nextChapterModel)
{
if(![nextChapterModel isPageTop]){
[nextChapterModel turnToFirstPage];
}
return [nextChapterModel curPageInfo];
}
else
{
OnlineTag* nextOnlineTag = [self onlineTagWithTag:self.onlineTag direction:QRTurnDirection_Next];
[nextOnlineTag resetProgress];
nextOnlineTag.rightParameter = CHAPTER_CUR;
nextChapterModel = [self createChapterModelWithOnlineTag:nextOnlineTag];
nextChapterModel.loadPoint = QRChapterLoadPointType_Start;
[nextChapterModel proloadChapterData];
return [nextChapterModel curPageInfo];
}

}
}

和3D翻页不同的是:在章节之间翻页时,平滑翻页获取的下一页只是预加载的结果在动画的过程中并没有真正的切换到下一章,而3D翻页在动画过程中已经是切换章节了。(这也是3D翻页回翻回去需要重新调整页码,而平滑翻页不需要的原因)

平滑翻页的真正切换章节的逻辑是在动画完成后触发的回调中完成的:

1
2
3
4
5
6
7
8
9
10
11
12
- (void)dragAnimationFinished:(BOOL)popSuccess toNext:(BOOL)toNext
{
...
if (toNext) // 翻到下一页
{
[self.dataSource didTrunPageToNext];
}
else
{
[self.dataSource didTrunPageToPrev];
}
}

覆盖翻页

覆盖翻页的逻辑和平滑翻页的逻辑整体比较相似:动画过程中只是取了预加载数据,并没有执行真正的翻页,真正的翻页是在动画完成之后进行的:

1
2
3
4
5
- (void)didCoverFinish{
...

[self.dataSource didTrunPageToNext];
}

线上的覆盖翻页一直感觉有些视觉上的卡顿,为了提高用户体验,采取了小步快跑的策略:减小步伐,提高刷新频率
(左边为线上效果,右边为优化后的效果)



插页逻辑

git版本merge之后,新增了插页逻辑:在原来的阅读页之间经常会插入一些广告业。但是这些插页逻辑和原来的翻页逻辑是耦合在一起的,所以首先针对这个问题做了逻辑分离:即把翻页逻辑和插页逻辑分开

1
2
3
4
5
6
7
8
9
10
11
- (void)didTrunPageToPrev
{
[self invokePrevPageInsertLogic:self.chapterModel.pageIndex block:^{
if(self.isOnlineRead){
[self didTrunPageToPrevInOnlineReadMode];
}
else{
[self didTrunPageToPrevInLocalReadMode];
}
}];
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
- (void)invokePrevPageInsertLogic:(NSInteger)pageIndex block:(void(^)())block
{
if (self.insertTransitionInfo.previousPage) {
if (self.insertTransitionInfo.previousPage.integerValue == pageIndex - 1) {
QRSafelyDoBlock0(block);
[self cleanUpInsertPage];
self.accumulatePageIndex--;
[self.insertManager pageDirection:QRTurnPageDirectionToPrevious];
return;
}
if (self.insertTransitionInfo.previousPage.integerValue == pageIndex) {
[self cleanUpInsertPage];
[self.insertManager pageDirection:QRTurnPageDirectionToPrevious];
return;
}
}
self.insertTransitionInfo.nextPage = @(pageIndex);
self.insertTransitionInfo.previousPage = @(pageIndex - 1);

self.insertTransitionInfo.nextPageAd = @(self.accumulatePageIndex);
self.insertTransitionInfo.previousPageAd = @(self.accumulatePageIndex - 1);
self.insertTransitionInfo.turnPageDirection = QRTurnPageDirectionToPrevious;
if ([self canInsertAD] && [self.insertManager currentPageShouldInsertByTransitionInfo:self.insertTransitionInfo]) {
return;
}

QRSafelyDoBlock0(block);
[self cleanUpInsertPage];
}

这样插页逻辑可以以一种插件的方式植入进来,而原先的翻页逻辑以block方式递给了插页manager,以后插页内部逻辑的修改不会影响到翻页逻辑,反之亦然

关于更多的细节,可以参考这个文件:QRTextReaderManager+Insert.m
同时,插页逻辑涉及到的顺延逻辑,会和斯雨同学协同重新设计实现

页面缓存

全局缓存观

在切章的版本中,提到最多的就是预加载、缓存这些字眼,然而这些缓存不是一成不变的,有的情况下是需要更新的,例如修改了选项,需要更新当前页面,那么包括当前看到的页面,以及已经预加载好的其它页面的缓存都必须得到更新,因此要有一个
全局的缓存观念
全局的缓存观念
全局的缓存观念

接下来列举一些需要全局缓存观的地方:

  • 例如翻到了1个付费预览页,并且前一章和后一章都是付费预览页,这个时候点击了购买,那么需要把当前章节的model删掉,重新建立1个对应的model,重新加载,直到渲染完成。但是不巧的是,正好在点击购买的时候,打开了自动购买,那么这个时候其它的两个已经预加载好的付费预览页都应该处于无效状态,应该从缓存中清除;又比如说,购买的时候特意关闭了自动购买,那么其它的已经预加载好的付费预览页的自动购买标记也应该关掉

  • 例如3个连着的章节,并且都是渲染页面,这个时候,修改了字体大小,或者进行了转屏,那么除了当前章节,其它两个缓存的章节的model也应该首先清除已经绘制好的页面,并随后去修改自己引擎的相关设置,否则会出现问题;

模式变更

(1)以下这些信息的变更都需要调用relayoutReaderEngine方法刷新阅读页

  • 阅读模式变更(3D、平滑、无效果、覆盖翻页、TTS)
  • 转屏
  • 字体变化、字体大小变化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
- (void)relayoutReaderEngine    //切换阅读模式时,阅读引擎需要重新布局
{
[self.lock lock];
[self.chapterModels enumerateKeysAndObjectsUsingBlock:
^(NSNumber * _Nonnull key, QRTextReaderChapterModel * _Nonnull model, BOOL * _Nonnull stop) {

if(nil == model.engine) return;

QRTextEngine* engine = model.engine;
QRBookMarkInfo *bookMark = [engine getBookMark];

BOOL isLandscape = UIInterfaceOrientationIsLandscape(self.curOrientation);
CGRect rect = [self getBoundRect:isLandscape isFullScreen:NO];
CGRect fullRect = [self getBoundRect:isLandscape isFullScreen:YES];

[engine setOwnRect:&rect fullRect:&fullRect];// 更新绘制区域
[engine recalcPageSize];// 更新页面大小
[engine relayout];//重新排版
[engine goToBookMark:bookMark];// 重新定位

if(model.onlineTag && model.onlineTag.onlineBookInfo.chapterid > 0){
// 业务数据必须在relayout之后调用,否则会段落丢失
[model handleTailPagePlaceholder];
}
}];
[self.lock unlock];
}

这里需要格外注意的是:
业务数据的处理handleTailPagePlaceholder必须在relayout之后调用,否则会段落丢失,这是因为handleTailPagePlaceholder是已经在建立好的段落链表中插入了自定义了业务段落,而relayout把所有的段落都扔掉,以章节数据为准重新建立段落

排版设置变更

QRSetting是作为QRTextReadManager的一个属性setting而存在的,如果用户改变了排版设置,导致其发生变化时,则需要作出相应的处理:

(1)需要清理掉之前已排版好的页面缓存:

1
2
3
4
5
6
7
8
- (void)flushPageCache
{
[self.lock lock];
[self.chapterModels enumerateKeysAndObjectsUsingBlock:^(NSNumber * _Nonnull key, QRTextReaderChapterModel * _Nonnull obj, BOOL * _Nonnull stop) {
[obj flushPageCache];
}];
[self.lock unlock];
}

(2)更新所有已经加载好的TextEngine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)setSetting:(QRSetting *)setting
{
_setting = setting;
[self updateEngineSetting:setting];
}

- (void)updateEngineSetting:(QRSetting*)setting
{
[self.lock lock];
[self.chapterModels enumerateKeysAndObjectsUsingBlock:^(NSNumber * _Nonnull key, QRTextReaderChapterModel * _Nonnull obj, BOOL * _Nonnull stop) {
[obj flushPageCache];
[obj.engine setSetting:setting];
}];
[self.lock unlock];
}

屏幕旋转

转屏导致绘制区域发生变化,需要清理掉已渲染好的页面,重新绘制页面

ps:线上一直有一个问题,就是在转屏的时候,段评会丢失,究其原因是在转屏的时候,会重新排版。
解决方案是做一个往返运动

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)resumeEngineState
{
if(self.isPageTop){
[self scrollPageTop];
}
else if(self.isPageBottom){
[self scrollPageBottom];
}
else{
[self scrollPageUp:1];
[self scrollPageDown:1];
}
}

后来发现主线已经修复这个问题,所以这个修改可以撤销了

业务数据导致页面变化

(1)需要清理掉已渲染好的页面缓存;
(2)更新业务占位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 设置章节尾页占位符区域
//
- (void)handleTailPagePlaceholder
{
// 打赏
//
NSValue *placeholderForInteraction =
[self placeholderForInteraction:self.bookInfo
onlineTag:self.onlineTag];

// 大神说
//
NSValue *placeholderForQA =
[self placeholderForQA:self.bookInfo
onlineTag:self.onlineTag];

...

}

翻页清理缓存


(1)当前章节的页面缓存最多保留3个页面,一旦超过3个就会进行清理;
(2)预加载的前后章节最多保留1个页面(可能因为章节数据,所以引擎为nil,此时只有0个)

总结

Txt切章的重构工作断断续续已经进行了大半年,经历了2个版本。
第1个版本试验性第尝试开发,小打小闹,没有动核心架构,整体进度较快,但是也存在着各种问题;由于重构的不够彻底,遇到了一些性能瓶颈,任凭如何优化也改进不了这种设计上的缺陷,所以下狠心推翻了,开始了重构更加彻底的2.0版本,这中间和峰哥也讨论过很多次,尝试很多种方案,我自己也从中吸取了一些经验和教训。

2.0版本经历了40多天的“编译红”、刚开始运行的“页面黑”和跌跌撞撞的crash,目前的工作进度大概在70%~80%,剩下的工作自从主线7.0开发开始就搁置了

迁移到git之前,再次进行了代码合并,随后迁移到了git

备注

1.0版本的svn地址(52次代码提交)
http://bj-scm.tencent.com/mqq/mqq_book_rep/QQReader_iPhone_proj/branches/QQReader_TxtMultiChaptersLoad_1.0

2.0版本的svn代码地址:(22次代码提交)
http://bj-scm.tencent.com/mqq/mqq_book_rep/QQReader_iPhone_proj/branches/QQReader_TxtMultiChaptersLoad_3.0

git版本地址:(34次代码提交)
http://git.code.oa.com/qq_reader_mobile_tech_ios/QQReader_iPhone_Proj
branch: QQReaderTXTEngine

埋坑记

(1) 神想法排版错乱

通过定位,发现这里的代码逻辑有问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (TextPrgf_ptr_t)_paragraphWithIdentifier:(NSInteger)paraIdentifier
paraList:(NSArray<NSNumber *> *)paraList
{
if (paraList.count <= paraIdentifier ||
paraIdentifier < 0) {
return NULL;
}

// 找到paraIdentifier对应段落的偏移
NSInteger paraOffset = [paraList objectAtIndex:paraIdentifier].integerValue;

// 从_prgfGroup.head依次往后寻找
TextPrgf_ptr_t paragraph = _prgfGroup.head;
for (; paragraph != NULL;) {
if (paragraph->offset >= paraOffset &&
paraOffset <= paragraph->offset + paragraph->size) {
break;
}
paragraph = paragraph->next;
}
return paragraph;
}

这里的paraIdentifier代表段落序号,paraList从前往后有序地存储着当前章节每个段落的偏移值,但是如果打开书的时候从章节的中间进入,此时无法构建完整的段落链表,这意味着_prgfGroup.head从中间的某一个段落开始。按照上述代码的逻辑,如果paraOffset_prgfGroup.head还小的话,那么就说明最后找到的paragraph是一个错误的段落,这个错误的段落后面并没有给神想法预留排版位置,所以产生了位置重叠,排版错乱的现象。
找到了原因,修复就比较简单了:在找到paraIdentifier对应的段落偏移后,先和_prgfGroup.head的偏移值做一些比较,如果小于,直接返回NULL

1
2
3
4
5
6
7
8
// 找到paraIdentifier对应段落的偏移
NSInteger paraOffset = [paraList objectAtIndex:paraIdentifier].integerValue;

if(paraOffset < _prgfGroup.head->offset){
return NULL;
}

...

但是这样的代码在主线上为什么没有问题发生呢?通过定位,发现主要是主线上的代码在渲染当前页面的时候,同时会预渲染上一页,这就会导致构建当前锚点之前的段落链表,而由于在线书的章节文件普遍较小,_prgfGroup.head此时会被构建为章节的开始段落,就不会出现上述问题。

1
2
3
4
5
6
7
8
9
if (![self isTopPage])
{
// 在txtengine的parsePrgf:lineOffset:prgfSize:policy:prgfGroup:中有个潜规则
// 认为会往前缓存一页内容
// 故这里的缓存逻辑不能随意修改
// add by zxf at 15/11/2017 for paragraph comment
//
[self cachePageImageWithIndex:currentPageIndex - 1];
}

(2) 预加载假死问题

切章的时候,经常会间歇性地发生假死现象,通过打点调试,发现章节文件下载的 结束回调竟然跑到了 进度回调的前面:

1
2
2019-04-18 16:11:18.942489+0800 QQReaderUI[97099:5778241] end callback: 异常:0, 8192, <NSHTTPURLResponse: 0x600003c3ccc0>  { Status Code: 200, Headers {
2019-04-18 16:11:18.947396+0800 QQReaderUI[97099:5777779] progress callback: 进度 8192, 8192 , <NSMutableURLRequest: 0x600003f044f0>

此时数据尚未接受完毕便发生了回调进行刷新,所以产生了假死现象,而正常的章节下载应该是这样的流程:

1
2
2019-04-18 16:11:51.987733+0800 QQReaderUI[97099:5777779] progress callback: 进度 9216, 9216 , <NSMutableURLRequest: 0x600003f3ab10> 
2019-04-18 16:13:11.827501+0800 QQReaderUI[97099:5778430] end callback: 正常:9216, 9216, <NSHTTPURLResponse: 0x600003dc10c0> { Status Code: 200, Headers {

通过定位发现和QQHttpClient的回调有关

  • session实例可以设置completionQueue,回调block会在completionQueue队列执行。
  • 如不设置completionQueue,则:
  • 如调用[QQHttpClient enqueueRequestSession:session]线程为主线程,在主线程回调;
  • 如调用[QQHttpClient enqueueRequestSession:session]线程为子线程,在默认concurrentQueue队列执行
1
2
3
4
5
6
7
8
if (!session.completionQueue){
if ([NSThread isMainThread]){
session.completionQueue = dispatch_get_main_queue();
}
else{
session.completionQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
}
}

这里由于是预加载,所以网络发起是在子线程,事先没有设置completionQueue,所以回调默认也在子线程,然而QQHttpClientSession的进度回调却主动切到了主线程,从而导致进度回调发生在结束回调之后。

1
2
3
4
5
6
7
8
9
10
11
12
if (self.downloadProgress)
{
long long totalRead = self.totalBytesRead + _partialDownloadSize;
long long expectedLength = self.response.expectedContentLength + _partialDownloadSize;

// 这里主动切到了主线程
dispatch_async(dispatch_get_main_queue(), ^{
if (self.downloadProgress){
self.downloadProgress(length, totalRead, expectedLength);
}
});
}

理论上来说这里不应该自动切换到主线程,不管是主线程,还是子线程,应该让进度回调和结束回调保持在同一个线程上,然后由于代码历史悠久,不宜改动,所以这里解决方案就是统一设置章节下载的网络回调为主线程

1
2
// 设置回调队列是主队列
session.completionQueue = dispatch_get_main_queue();

(3) Heap corruption
最近的一段时间,阅读页经常发生崩溃现象,并且崩溃的时机和地点相当随机,每次都会输出下面的信息:

1
2
3
QQReaderUI(1728,0x10ab9eb80) malloc: Heap corruption detected, free list is damaged at 0x282ecaac0
*** Incorrect guard value: 10804049344
QQReaderUI(1728,0x10ab9eb80) malloc: *** set a breakpoint in malloc_error_break to debug

通过设置异常捕获,也于事无补,找不到任何有价值的思路
终于通过1个死循环的巧合,定位了问题所在:
TxtEngine引擎中的尾页逻辑包含着各种优先级,其中“作者的话”本来也是业务数据,但是它偏偏在章节数据中,每次章节排版完,都需要将解析出来的“作者的话”传递到外面的业务层,完成各种优先级排序,之后引擎会来要排好的数据。然而有的情况下,引擎需要重新排版,就会将之前递出去的“作者的话”释放掉,然而由于“作者的话”本身是一个C结构体指针,引擎释放的时候,外面的业务层完全无法感知到(终究是因为代码有漏洞),等下次引擎要数据的时候,外面的业务层将一个“已经被释放掉的非法指针”递给引擎,此时会非法操作内存,进而引发crash。

-------------本文结束 感谢您的阅读-------------

本文标题:Txt引擎之切章重构之路

文章作者:lingyun

发布时间:2019年01月01日 - 11:01

最后更新:2020年12月27日 - 12:12

原始链接:https://tsuijunxi.github.io/2019/01/01/Txt引擎之切章重构之路/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

坚持原创技术分享,您的支持将鼓励我继续创作!

本文标题:Txt引擎之切章重构之路

文章作者:lingyun

发布时间:2019年01月01日 - 11:01

最后更新:2020年12月27日 - 12:12

原始链接:https://tsuijunxi.github.io/2019/01/01/Txt引擎之切章重构之路/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。