小说阅读器的设计和实现

前言

19年做了一个小说阅读器,特此介绍阅读器设计,还有实现过程中的一些坑。

正文

一、阅读器整体设计

阅读器的基本功能是文字展示、翻页滚动,以及目录展示、进度切换、调整字号和主题切换等,扩展功能包括文本选择和复制,可能还会有第三方分享的定制化界面等。

通过整理以上功能,我们可以把整个阅读器的功能分为几个方面:
1、数据处理:将原书籍数据进行处理,得到能够展示的文本以及相应的目录数据;
2、文本展示:用CoreText处理文本,将其划分为多页数据,进行展示处理;
3、交互响应:翻页逻辑、目录操作、字号调整、背景切换等交互处理;

在设计以上功能的时候,需要考虑后续的图文混排、文本选中等变化,选择较为灵活的方案。

围绕左右滑动和分页展示、数据加载,简易的流程图如下

总共会有四个层级:

  • 交互层:处理左右滑动的事件以及正常的用户操作响应;(VC处理,view在渲染层)
  • 逻辑层:网络数据请求、数据格式转换和布局排版的计算;
  • 数据层:对数据进行封装,主要包括业务数据、用户设置数据、排版数据;
  • 渲染层:目录展示、各种交互view的显示、根据排版结果进行渲染;

SSLayoutManager + SSConfigData + SSChapterData = SSPageData
布局管理器 + 用户设置数据 + 章节数据 = 分页后的每页排版结果
整个结构图如下

二、CoreText相关问题

CTFramesetter是NSAttributedString的CF对象,可以直接强转;
CTFrame是排版数据,由CTFramesetter生成;
NSAttributedString是常用的富文本字符串类;
CTLine是CTFrame中的一行文本、CTRun是CTLine中有相同属性的连续字形;

阅读器的排版基于CoreText,通过章节文本数据SSChapterData和用户设置SSConfigData,可以生成带格式的富文本NSAttributeString;通过CoreText将富文本转化成多个SSLayoutPageData,每个对象中都有一个CTFrameRef,代表一页的排版结果;最终SSPageView将其CTFrameRef渲染到到屏幕上。

1、CTLine

CTFrameRef是我们生成的排版数据,通过CTFrameGetLines这个函数可以拿到NSArray数组,第0个元素是第1行,根据行数可以获取到CTLineRef;
CTFrameGetLineOrigins这个函数可以直接获取对应line的位置;

  CGPoint insertPoint;
  CTFrameGetLineOrigins(frameRef, CFRangeMake(insertLineIndex + 1, 1), &insertPoint);

获取的行位置信息有2个注意事项:
1、CoreText的坐标系是左下角原点,所以对于点(0, 100)是距离底部100的位置;
2、行的起始点不是行真实的起点,而是下图的Origin位置;

从上图可以看到,origin(原点)的位置是在descent上面,也即是我们通过CoreText指定大小的时候。

非常重要的三个属性:ascent、descent、width

static CGFloat ascentCallback(void * refCon){
    SSEmptyLayoutData *data = (__bridge SSEmptyLayoutData *)refCon;
    return data.size.height;
}

static CGFloat descentCallback(void * refCon){
    return 50;
}

static CGFloat widthCallback(void * refCon){
    SSEmptyLayoutData *data = (__bridge SSEmptyLayoutData *)refCon;
    return data.size.width;
}

对照到下图,绿色是原点,ascent、width、desent分别如图所示。

2、图文混排

图文混排的过程中,CoreText会回调我们某个字符的宽高,但是如果不注意代码会出现异常:

问题代码

打出crash堆栈如下:

(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x7a0090020)
    frame #0: 0x0000000111350faa libobjc.A.dylib`objc_retain + 10
  * frame #1: 0x000000010a4fc566 TTReading`ascentCallback(ref=0x0000600003592e40) at SSLayoutManager.m:14
    frame #2: 0x000000010e5551a6 CoreText`TDelegateRun::TDelegateRun(CTRun const*) + 102
    frame #3: 0x000000010e4a03b6 CoreText`TGlyphEncoder::EncodeChars(CFRange, TAttributes const&, TGlyphEncoder::Fallbacks) + 518
    frame #4: 0x000000010e4b8b2a CoreText`TTypesetterAttrString::Initialize(__CFAttributedString const*) + 238
    frame #5: 0x000000010e4b8a2e CoreText`TTypesetterAttrString::TTypesetterAttrString(__CFAttributedString const*, __CFDictionary const*) + 176
    frame #6: 0x000000010e4b4422 CoreText`CTFramesetterCreateWithAttributedString + 91

出现问题的代码如下:

CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, (__bridge void *)(dict));   // Crash

通过堆栈可以发现,是在ascentCallback函数访问参数时出现的内存异常;
经过分析和多次尝试,发现以下这段代码是正常的:

CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, (__bridge void *)(@"height"));   // OK

再回过头来分析,应该是dict变量在函数执行过后被释放,导致ascentCallback回调时发生异常;

此处记起ARC相关,加深关于__bridge的理解和记忆。

3、格式转换

网上的小说很多是html格式的文本,如下:


HTML的字符串可以通过系统API转成NSAttributedString,再通过其string属性,可以访问到NSString;

/**
 *  html字符串转富文本
 */
- (NSAttributedString *)htmlStrConvertToAttributeStr:(NSString *)htmlStr {
    return [[NSAttributedString alloc] initWithData:[htmlStr dataUsingEncoding:NSUnicodeStringEncoding]
                                            options:@{NSDocumentTypeDocumentAttribute:NSHTMLTextDocumentType}
                                 documentAttributes:nil
                                              error:nil];
}

这里的代码配合UIPageViewController会有偶现的Crash,但是出现的概率是千分之几;如果想完全避免这个crash可以换用其他解析库。

4、分页计算

分页计算的核心是拿到NSAttributedString和pageSize,按照页面大小进行排版,分别得到每页的字符串范围,最终以NSRange的方式返回,举例:

(
    "NSRange: {0, 34}",
    "NSRange: {34, 36}",
    "NSRange: {70, 40}",
    "NSRange: {110, 39}",
    "NSRange: {149, 35}",
    "NSRange: {184, 40}",
    "NSRange: {224, 37}",
    "NSRange: {261, 38}",
    "NSRange: {299, 3}"
)

以下这段代码可以是具体的分割逻辑:

- (NSArray *)pagingContentWithAttributeStr:(NSAttributedString *)attributeStr pageSize:(CGSize)pageSize {
    NSMutableArray<NSValue *> *resultRange = [NSMutableArray array]; // 返回结果数组
    CGRect rect = CGRectMake(0, 0, pageSize.width, pageSize.height); // 每页的显示区域大小
    NSUInteger curIndex = 0; // 分页起点,初始为第0个字符
    while (curIndex < attributeStr.length) { // 没有超过最后的字符串,表明至少剩余一个字符
        NSUInteger maxLength = MIN(1000, attributeStr.length - curIndex); // 1000为最小字体的每页最大数量,减少计算量
        NSAttributedString * subString = [attributeStr attributedSubstringFromRange:NSMakeRange(curIndex, maxLength)]; // 截取字符串
        CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef) subString); // 根据富文本创建排版类CTFramesetterRef
        UIBezierPath * bezierPath = [UIBezierPath bezierPathWithRect:rect];
        CTFrameRef frameRef = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), bezierPath.CGPath, NULL); // 创建排版数据,第个参数的range.length=0表示放字符直到区域填满
        CFRange visiableRange = CTFrameGetVisibleStringRange(frameRef); // 获取当前可见的字符串区域
        NSRange realRange = {curIndex, visiableRange.length}; // 当页在原始字符串中的区域
        [resultRange addObject:[NSValue valueWithRange:realRange]]; // 记录当页结果
        curIndex += realRange.length; //增加索引
        CFRelease(frameRef);
        CFRelease(frameSetter);
    };
    return resultRange;
}
5、跨页首行缩进异常

设置了首行缩进后,每段文字的第一行会空出两个字符左右的大小;
但是在某段文字被分在两个页时,第二页因为是新起的一页,会识别为新的一段!



解决方案1、换行替换为换行+空格,然后取消首行缩进;
解决方案2、每页在开始时,判断上页最后一个字符是否为换行符,再决定是否取消首行缩进;

if (curIndex > 0 && [attributeStr.string characterAtIndex:curIndex - 1] != '\n') {
    NSMutableParagraphStyle *style = [attributeStr attribute:NSParagraphStyleAttributeName atIndex:curIndex effectiveRange:NULL];
    NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init];
    paragraphStyle.firstLineHeadIndent = 0;
    paragraphStyle.lineBreakMode = NSLineBreakByCharWrapping;
    paragraphStyle.lineSpacing = style.lineSpacing;
    paragraphStyle.paragraphSpacing = style.paragraphSpacing;
    paragraphStyle.alignment = NSTextAlignmentJustified;
    [attributeStr addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:NSMakeRange(curIndex, 1)];
}

6、最后一行排版异常
排版过程中往文字最后插入了一个特殊空白字符,结果排版如下:

排版异常

排版的规则是两端对齐(最后一行会自然靠左),因为插入了特殊字符,“年当然也是明白”这段字被识别为倒数第二行,触发了两端对齐的逻辑;

那么可以在末尾的时候补齐一个'\n'符号;

                CFRange range = CTLineGetStringRange(line);
                NSUInteger insertIndex = curIndex + range.location + range.length;
                if (insertIndex >= attributeStr.length) { // 避免最后一行的特殊情况处理
                    [attributeStr insertAttributedString:[[NSAttributedString alloc] initWithString:@"\n"] atIndex:insertIndex];
                    insertIndex = attributeStr.length;
                }

三、UIPageViewController相关问题

1、ViewController相关

UIPageViewController 在手动设置vc的时候,非常容易crash;
以loadingVC为例,在展示vc后,会同步去加载数据;
当数据会回调后,此时无法使用新的vc去替换;
所以总体的设计中,vc在赋值给UIPageViewController之后,就不应该修改;

延伸出来的翻页逻辑优化
UIPageVC在使用过程中(动画过程中),不可调用这个方法,否则滑动的手势会取消,出现闪动的效果。

- (void)pageViewController:(UIPageViewController *)pageViewController didFinishAnimating:(BOOL)finished previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers transitionCompleted:(BOOL)completed {
    if (!completed && previousViewControllers && [previousViewControllers[0] isKindOfClass:[SSReadingBasePageViewController class]]) {
        SSPageControllData *lastData = [(SSReadingBasePageViewController *)previousViewControllers[0] pageControllData];
        SSPageControllData *pageData = [self.pageControllManager onLoadingReadyWithChapterId:loadingVC.pageControllData.loadingData.loadingChapterId loadingData:loadingVC.pageControllData.loadingData];
        [self setPageVCWithPageControllData:pageData isNext:YES];
    }

UIPageViewController另外的问题是无法监听当前状态,判断当前是否处于翻页过程,这对很多扩展逻辑进行了限制。

2、偶现Crash -Invalid parameter not satisfying: [views count] == 3'

该问题为偶现Crash,由stackoverflow上面的某回答建议:

  1. set dataSource before calling setViewControllers method
  2. use setViewControllers method without animation (animated: false)
  3. set dataSource to nil for single page mode

可以减少这种情况的出现,但是无法杜绝。
简书上另外一个开发者的介绍,UIPageViewController存在多个容易出现的Crash,UIPageViewController好用但是不太稳定。

3、翻页数据异常

UIPageViewController在翻页的时候会请求下一页数据,我们通过UIViewController封装好对应的数据和视图,直接回传一个VC;
但是当用户频繁滑动并在滑动动画未完成就触发点击进入下一页的逻辑时,会出现数据展示错误的情况。

对翻页逻辑进行整理,有滑动和点击两种方式。点击的时候会同步更新当前数据源为下一页,所以即使点击很快,也不会出现数据源异常的情况。
问题在于滑动切换时,何时把数据源更新为下一页?
由于UIPageViewController的局限,较好的一种方案是在开始滑动时就把数据源更新,最后如果用户取消翻页,则将数据源更新为原来的页面。

4、UIPageViewControllerTransitionStylePageCurl翻页模式下Crash

当UIPageViewController需要背面的VC时,会向delegate请求,此时需要返回对应的BackVC,否则出现数据展示异常;
通过setViewControllers方法手动切换界面时,如果设置animated为YES,则必须传入两个vc否则会出现Crash。

5、手势冲突

UIPageViewController是一个容器,上面会放置真正用于显示的VC,需要注意VC不能存在全屏的view,否则手势无法传到UIPageViewController,会出现无法左右滑动的情况;

总结

19年花了很多时间在这上面,文章介绍了大部分遇到的问题和解决方案,写了一个简单的demo,地址见GitHub
篇幅和时间所限,如果有具体的问题可以联系交流。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容

  • 我有了新生活 身无病痛,心有余力 可如今,我再也写不出诗 我再也没有时间读书 甚至没有时间来思考和想你 我离想要的...
    迷惘之乡阅读 187评论 0 0
  • 2017年8月20日 1.感恩爸妈的养育之恩。 2.感恩儿子让我享受到幸福,觉察到与他沟通时的负面语言较多,要积极...
    冯梓源阅读 156评论 0 0
  • 时光悄然旋转,一不经意间2018就快走到了尾声,不管这一年是奋力拼搏或是随意挥霍,岁月的年轮已经轧下了一道重重的痕...
    不服输的大叔阅读 261评论 0 0
  • String类概述 String 类被final修饰,无子类,不可被复写,对String类的任何改变,都会返回一个...
    哎呀啊噢阅读 384评论 0 0