小说阅读器的设计和实现

前言

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 *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 *)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。
篇幅和时间所限,如果有具体的问题可以联系交流。

你可能感兴趣的:(小说阅读器的设计和实现)