YYText学习过程(YYAsyncLayer异步渲染学习记录)

前言

YYTextDemo

本文旨在学习YYText的布局&&调用渲染的过程,由于作者使用了CoreText接口和CoreGraphic的知识较多,学习过程中补充了两个库的知识!文章将从基本开始,最后切入YYText的布局和使用离线渲染的原理。离线渲染搭配SDWebImage的使用,能非常有效的处理掉帧问题(屏幕卡顿), 是一个非常值得学习的知识!特别是在没有TableView实现不了的界面的时代!

学习过程

1.CoreText 和 CoreGraphic 的学习总结

2.CoreText的高度计算与普遍的高度计算

3.CoreText文字渲染过程

5.YYText的学习记录

6.YYAsynLayer的学习总结

7.总结

内容

1.CoreText 和 CoreGraphic 的学习总结

两个库的位置

CoreText是一个负责管理文本布局的库,细化到每一个字每一个位置的尺寸,甚至自定义字体。

CoreGraphics是负责渲染的库,是Quartz 2D的一个高级绘图引擎,文本显示就需要它来实现显示

CoreText负责布局调用绘制接口,CoreGraphic负责绘制,下面针对两个库做简单描述,库中函数都是C接口

CoreText

Core Text关键类

富文本,使用NSAttributedString(CFAttributedStringRef)
- (instancetype)initWithString:(NSString *)str attributes:(nullable NSDictionary<NSString *, id> *)attrs
第二个参数attributes:包含了详细的文字排版信息。详细的信息可以在<CoreText/CTStringAttributes.h>或者#<Foundation/NSAttributedString.h>文件中看到。

根据AttributedString可以创建CTFramesetter(CoreText布局类)

CTFramesetterRef CTFramesetterCreateWithAttributedString(
    CFAttributedStringRef attrString ) CT_AVAILABLE(macos(10.5), ios(3.2), watchos(2.0), tvos(9.0));
CTFramesetter

通过CFAttributedString(NSAttributedString)创建,作用相当于一个生产CTFrame的工厂。

CTFrame

每个CTFrame可以看做一个段落 每个CTFrame由多个CTLine组成。可以使用它进行绘制

CTLine

代表一个line 一行文字中可能有多个CTLine组成,单每一个CTLine一定在同一行内。 一个CTLine有多个CTRun组成。也可以使用它进行绘制。

CTRun

一个连续的有着相同的attributes和direction的字形(glyph)的集合,是最小的字形绘制单元。

最后附上字体描述图

CoreGraphic

绘制过程分为CPU阶段,OpenGL ES阶段,GPU阶段。 布局和CoreGraphic的处理属于CPU阶段。这里重点描述。

OpenGL ES是对图层进行取色,采样,生成纹理,绑定数据,生成前后帧缓存。

GPU用来采集图片和形状,运行变换,应用文理和混合,最终把它们输送到屏幕上

Core Graphics绘制
如果对视图实现了drawRect:drawLayer:inContext:方法,或者 CALayerDelegate 的 方法,那么在绘制任何东 西之前都会产生一个巨大的性能开销。为了支持对图层内容的任意绘制,Core Animation必须创建一个内存中等大小的寄宿图片。然后一旦绘制结束之后, 必须把图片数据通过IPC传到渲染服务器。在此基础上,Core Graphics绘制就会变得十分缓慢,所以在一个对性能十分挑剔的场景下这样做十分不好。

2.CoreText的高度计算与普遍的高度计算

普通的高度计算代码

 NSDictionary *attribute = @{NSFontAttributeName:[UIFont systemFontOfSize:20]};
    
    CGSize retSize = [str boundingRectWithSize:CGSizeMake(width,100000000) options:NSStringDrawingUsesLineFragmentOrigin attributes:attribute context:nil].size;
    
    return retSize.height;

CoreText高度计算代码

if (!string && string.length == 0) {
          return 0;
      }
      CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)string);
      CGRect drawingRect = CGRectMake(0, 0, width, 100000);
      drawingRect = UIEdgeInsetsInsetRect(drawingRect, UIEdgeInsetsMake(50, 2, 2, 2));
    
      CGMutablePathRef path = CGPathCreateMutable();
      CGPathAddRect(path, NULL, drawingRect);
      CTFrameRef textFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
      CGPathRelease(path);
      CFRelease(framesetter);
      _linesArray = (NSArray *)CTFrameGetLines(textFrame);
      CGPoint origins[[_linesArray count]];
      CTFrameGetLineOrigins(textFrame, CFRangeMake(0, 0), origins);
      int lastLineRow = (int)_linesArray.count - 1;
      CGFloat line_y = (CGFloat)origins[lastLineRow].y;  //最后一行line的原点y坐标
      CGFloat ascent;
      CGFloat descent;
      CGFloat leading;
      CTLineRef lastLine = (__bridge CTLineRef)[_linesArray objectAtIndex:lastLineRow];
      CTLineGetTypographicBounds(lastLine, &ascent, &descent, &leading);
      CGFloat totalHeight = 100000-line_y + ( ascent + descent);
      return totalHeight;

两种高度计算实际测试,400个长度的字符串,系统字体20号,普通高度计算使用时间为13-18ms,而coreText使用2-4ms。从性能上来说,coretext更快!可是有一点需要注意的是,使用CoreText计算的高度比普通计算的高度小,差异在行间距&&字间距,所以,CoreText计算高度前,属性字符串需要先设置行间距和字间距。

3.CoreText文字渲染过程

绘制的过程需要一个CGContextRef对象,我们俗称的画布,画布可以在drawRect:方法通过UIGraphicsGetCurrentContext()获取当前画布进行绘制,而YYText是在YYAsynLayer中触发display(YYLabel调用self.layer setNeedsDisplay触发对应Layer的display方法)创建并获取当前画布。

UIGraphicsBeginImageContextWithOptions(self.bounds.size, self.opaque, self.contentsScale);
CGContextRef context = UIGraphicsGetCurrentContext();

绘制代码如下

CGContextSaveGState(context); {
        
        CGContextTranslateCTM(context, point.x, point.y);
        CGContextTranslateCTM(context, 0, size.height);
        CGContextScaleCTM(context, 1, -1);
        
        BOOL isVertical = layout.container.verticalForm;
        CGFloat verticalOffset = isVertical ? (size.width - layout.container.size.width) : 0;
        
        NSArray *lines = layout.lines;
        for (NSUInteger l = 0, lMax = lines.count; l < lMax; l++) {
            YYTextLine *line = lines[l];
            if (layout.truncatedLine && layout.truncatedLine.index == line.index) line = layout.truncatedLine;
            NSArray *lineRunRanges = line.verticalRotateRange;
            CGFloat posX = line.position.x + verticalOffset;
            CGFloat posY = size.height - line.position.y;
            CFArrayRef runs = CTLineGetGlyphRuns(line.CTLine);
            for (NSUInteger r = 0, rMax = CFArrayGetCount(runs); r < rMax; r++) {
                CTRunRef run = CFArrayGetValueAtIndex(runs, r);
                CGContextSetTextMatrix(context, CGAffineTransformIdentity);
                CGContextSetTextPosition(context, posX, posY);
                YYTextDrawRun(line, run, context, size, isVertical, lineRunRanges[r], verticalOffset);
            }
            if (cancel && cancel()) break;
        }
        
        // Use this to draw frame for test/debug.
        // CGContextTranslateCTM(context, verticalOffset, size.height);
        // CTFrameDraw(layout.frame, context);
        
    } CGContextRestoreGState(context);

YYTextDrawRun方法里面对Run的属性进行了处理,方法内部还有一段处理过程,但是最终绘制的方法是CoreText开放的绘制函数,函数名称如下:

 CTFrameDraw(textFrame, c);
CTLineDraw(line, c);
 CTRunDraw(run, c, CFRangeMake(0, 0));
 CTFontDrawGlyphs(runFont, glyphs + g, &zeroPoint, 1, context);

代码中每个对象是有关联的,对应上面CoreText关键类的图示,CTLine从CTFrame获取,CTRun从CTLine获取,以此类推。当然绘制前是需要设置位置的CGContextSetTextPosition。最终所有需要绘制的内容能精确地跟计算的高度一直。在这个过程中,使用创建Context方式,需要将layer的content设置一下,这也是YYText的实现方式,

UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
self.contents = (__bridge id)(image.CGImage);

上面截图中的self是CALayer或它的子类对象,这几行代码对应创建的代码,和addSubView有相似的地方。也是CoreGraphic 绘制描述中的缩略图,注意代码里,有一个EndImageContext是对应BeginImageContext的,有开就要有关!

到这里,CoreText的编辑&&调用绘制接口和运用CoreGraphic显示到屏幕上,大致的过程描述已经描述完毕,下面我们先使用以上的知识构建一个简单的CoreText绘制过程

上面是简单的绘制过程

1.在ViewController使用CoreText计算字符串的高度,并且添加一个自定义的Label,设置对应的高度。

2.重写自定义Label的+ (Class)layerClass方法,将Label的Layer指向自定义的Layer,自定义的layer在Label添加,或者触发layer setNeedsDisplay方法会触发display方法,从而创建一个ContextRef回调给ViewController。

3.ViewController使用回调的ContextRef,使用CoreText的绘制方法,在CoreText绘制,绘制完成后,Layer获取UIGraphicsGetImageFromCurrentImageContext();将layer的content赋值image,从而完成绘制,显示内容。

以上直接&&简单的一个绘制过程,但是一点都不优雅也不可能让别人可以方便运用,因为在ViewController有太多的代码,计算高度,绘制等!下面我们要学习YYText的设计思想。

5.YYText的学习记录

我认为,主体上,YYText的主要的实现过程与我们的描述是一样的(当然YYText还包含很多功能,包括处理了附件,边框绘制,阴影,属性字符串列别,队列池,异步渲染等,里面的编写风格也是值得学习的)

NSAttributedString提供内容,TextContainer设置排布空间,关系代码如下:

 YYTextContainer *container = [YYTextContainer containerWithSize:CGSizeMake(kScreenWidth, kCellHeight)];
 YYTextLayout *layout = [YYTextLayout layoutWithContainer:container text:text];

上面代码Layout包含了内容和布局信息,如果赋值给YYLabel的textLayout属性,将不需要再计算文字区域,而直接异步绘制,并且显示!

YYTextLayout对象管理着NSAttributedString和YYTextContainer对象,根据属性字符串的设置和Container的条件通过CoreText的方法计算出了所有的字符串数据,数据包含有很多信息,都是只读属性,有YYLine,以及对应的间距等信息!而YYLine内有CTLine 和其它间距信息!多层关联!

在绘制阶段,绘制的方法也放在了TextLayout内,都是C函数,函数名如下

在我们使用YYText的时候,我们可以从这里做切入口,将不用的代码,属性&&方法进行删减优化,提高项目维护的效率!

下图是YYText中YYLabel的描绘过程

过程中主要工作是TextLayout来完成,但是触发回调的是通过了YYLayer中调用DisplayTask的block回调来完成计算高度和调用渲染接口,YYLabel拥有TextLayout和AttributedString,对外YYLabel只需要开放NSAttributedString即可,这样,别人用起来就方便了,其中YYLayer还有离线渲染的设计,下个话题会描述!

YYText中围绕NSAttributedString+YYText做了很强大的设置字符串样式的简便方法(如阴影,加粗,空心,附件等),应用时,只要针对输入源对应设置即可!基本能满足90%以上的文本绘制!

具体请看YYKit的Demo。

6.YYAsynLayer的学习记录

YYKit中Demo中的Async Display中可查看效果,效果非常明显,未开启异步时,FPS会卡到15左右,离我们60的目标非常远,卡顿也非常明显,体验很差!开启异步绘制后,基本上FPS都稳定在60! 如此巨大的表现,究竟区别在哪里?

区别在哪里?

我认为主要有2个区别:

区别点非异步异步
绘制线程主线程子线程(多串行队列并发(例子中设置最大16个队列))
绘制任务过程启动渲染就要直到结束内容更新上一个任务就取消

首先我们看看YYAsyncLayer对应Demo的执行过程图,然后针对这两点展开学习。

上图流程上与YYLabel绘制的图(文章中上一张蓝色图)其实是一致的,只是Demo中先对属性字符进行了一轮计算赋值,在Demo中,作者有描述,这些计算最好放在子线程!这部分是可以优化的!异步绘制的差异点主要表现在YYAsyncLayerDisplayTask的 3个协议方法里面,分别是 willdisplay, display, 和 didplay, 其中willdisplay和diddisplay是在主线程完成的, 而display的绘制逻辑是在子线程完成,最后在主线程完成Layer的content赋值 self.contents = (__bridge id)(image.CGImage);完成绘制显示!

从上面的描述,我们可以知道,CoreText调用绘制接口的代码放在了子线程,tableView在滚动的时候就不用等待每个Label的绘制代码都完成了才可以滚动,优化了滚动的失帧情况!

这时,还有一个问题,TableView的Cell是一个复用的机制,那么Cell上面的Label也是复用,如果用户剧烈滚动,Cell上的Label对象快速设置内容,虽然绘制发生在子线程,还是会耗损性能,YYText的处理方案是以下代码:

BOOL (^isCancelled)(void) = ^BOOL() {
            return value != sentinel.value;
        };

sentinel是一个计数器,代码设置每次Layer重绘的时候(触发display方法)都会加1,只要改变绘制内容就加1,但是value在上一个绘制还没完成时还是上一次的值,代码中每次回调前都会调用判断,出现value 和当前已加1的sentinel的value不一样时取消上一次的绘制,回收性能!如下图:

task.display(context, size, isCancelled);
            if (isCancelled()) {
                UIGraphicsEndImageContext();
                dispatch_async(dispatch_get_main_queue(), ^{
                    if (task.didDisplay) task.didDisplay(self, NO);
                });
                return;
            }
            UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
            UIGraphicsEndImageContext();
            if (isCancelled()) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    if (task.didDisplay) task.didDisplay(self, NO);
                });
                return;
            }
            dispatch_async(dispatch_get_main_queue(), ^{
                if (isCancelled()) {
                    if (task.didDisplay) task.didDisplay(self, NO);
                } else {
                    self.contents = (__bridge id)(image.CGImage);
                    if (task.didDisplay) task.didDisplay(self, YES);
                }
            });

以上就是异步绘制的理解!

7.总结

通过这次学习TText框架的过程,整理了CoreText和CoreGraphic的认识,巩固了框架设计的方式,认识了C函数在OC内的使用过程!回顾了autoreleasepool和drawrect绘画的知识!学习了如isCancelled判断技巧,创建队列池,增加属性字符串内容,函数可先声明变量等技巧!后面将合理使用离线渲染和YYText强大的属性字符串显示功能!

YYTextView流程纪要(比YYLabel多了丰富的输入交互)

发表评论

电子邮件地址不会被公开。 必填项已用*标注