19年做了一个小说阅读器,特此详情阅读器设计,还有实现过程中的少量坑。
阅读器的基本功能是文字展现、翻页滚动,以及目录展现、进度切换、调整字号和主题切换等,扩展功能包括文本选择和复制,可能还会有第三方分享的定制化界面等。
通过整理以上功能,我们可以把整个阅读器的功能分为几个方面:
1、数据解决:将原书籍数据进行解决,得到能够展现的文本以及相应的目录数据;
2、文本展现:用CoreText解决文本,将其划分为多页数据,进行展现解决;
3、交互响应:翻页逻辑、目录操作、字号调整、背景切换等交互解决;
在设计以上功能的时候,需要考虑后续的图文混排、文本选中等变化,选择较为灵活的方案。
围绕左右滑动和分页展现、数据加载,简易的流程图如下
总共会有四个层级:
SSLayoutManager + SSConfigData + SSChapterData = SSPageData
布局管理器 + 客户设置数据 + 章节数据 = 分页后的每页排版结果
整个结构图如下
CTFramesetter是NSAttributedString的CF对象,可以直接强转;
CTFrame是排版数据,由CTFramesetter生成;
NSAttributedString是常用的富文本字符串类;
CTLine是CTFrame中的一行文本、CTRun是CTLine中有相同属性的连续字形;
阅读器的排版基于CoreText,通过章节文本数据SSChapterData和客户设置SSConfigData,可以生成带格式的富文本NSAttributeString;通过CoreText将富文本转化成多个SSLayoutPageData,每个对象中都有一个CTFrameRef,代表一页的排版结果;最终SSPageView将其CTFrameRef渲染到到屏幕上。
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分别如图所示。
图文混排的过程中,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的了解和记忆。
网上的小说很多是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可以换用其余解析库。
分页计算的核心是拿到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;}
设置了首行缩进后,每段文字的第一行会空出两个字符左右的大小;
但是在某段文字被分在两个页时,第二页由于是新起的一页,会识别为新的一段!
处理方案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 在手动设置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另外的问题是无法监听当前状态,判断当前能否处于翻页过程,这对很多扩展逻辑进行了限制。
Invalid parameter not satisfying: [views count] == 3'
该问题为偶现Crash,由stackoverflow上面的某答复建议:
可以减少这种情况的出现,但是无法杜绝。
从简书上另外一个开发者的详情,UIPageViewController存在多个容易出现的Crash,UIPageViewController好用但是不太稳固。
UIPageViewController在翻页的时候会请求下一页数据,我们通过UIViewController封装好对应的数据和视图,直接回传一个VC;
但是当客户频繁滑动并在滑动动画未完成就触发点击进入下一页的逻辑时,会出现数据展现错误的情况。
对翻页逻辑进行整理,有滑动和点击两种方式。点击的时候会同步升级当前数据源为下一页,所以即便点击很快,也不会出现数据源异常的情况。
问题在于滑动切换时,何时把数据源升级为下一页?
因为UIPageViewController的局限,较好的一种方案是在开始滑动时就把数据源升级,最后假如客户取消翻页,则将数据源升级为原来的页面。
当UIPageViewController需要背面的VC时,会向delegate请求,此时需要返回对应的BackVC,否则出现数据展现异常;
通过setViewControllers
方法手动切换界面时,假如设置animated为YES,则必需传入两个vc否则会出现Crash。
UIPageViewController是一个容器,上面会放置真正用于显示的VC,需要注意VC不能存在全屏的view,否则手势无法传到UIPageViewController,会出现无法左右滑动的情况;
19年花了很多时间在这上面,文章详情了大部分遇到的问题和处理方案,写了一个简单的demo,地址见GitHub。
篇幅和时间所限,假如有具体的问题可以联络交流。