美文网首页iOS进阶O~1iOS渲染和动画
CoreText是如何绘制文本的

CoreText是如何绘制文本的

作者: 迷路的安然和无恙 | 来源:发表于2018-07-18 17:51 被阅读425次

    代码实现可参考封装完成的SDLabel
    由于绘制都是在Context上下文上进行的,所以先补充一些上下文的知识,上下文是什么?为什么要有上下文?上下文是怎么工作的?

    Context上下文
    上下文是什么:

    上下文定义了我们需要绘制的地方。

    为什么要有上下文:

    要盛水,就需要有容器;要画画,就需要画板。上下文就是一个画板,在英语中词根concom都有很多一起的意思,再看Context,就是很多文本在一起🙂,这就是上下文了。

    上下文是怎么工作的:

    UIKit维护着一个上下文堆栈,UIKit 方法总是绘制到最顶层的上下文中。你可以使用 UIGraphicsGetCurrentContext() 来得到最顶层的上下文。你可以使用 UIGraphicsPushContext()UIGraphicsPopContext()在 UIKit 的堆栈中推进或取出上下文。最为突出的是,UIKit 使用 UIGraphicsBeginImageContextWithOptions()UIGraphicsEndImageContext()方便的创建类似于 CGBitmapContextCreate()的位图上下文。混合调用 UIKitCore Graphics 非常简单:

    从绘制路径CGMutablePathRef开始
    • CGMutablePathRef是一个可变的路径,可通过CGPathCreateMutable()创建,该路径在被创建后,需要加到Context中。

    • CGPathAddRect就是将上面的路径添加到上下文中的方法。
      通常如下

    void CGPathAddRect(CGMutablePathRef path, const CGAffineTransform *m, CGRect rect);
    
    • *m是一个指向仿射变换矩阵的指针,如果不需要可以设置为NULL,如果指定了矩阵,Core Graphics将转换应用于矩形,然后将其添加到路径中。

    • rect就是需要将该矩阵添加到哪里。

    如何获得文本的frame信息?

    如果需要计算需要绘制内容的frame,以文本绘制为例。
    需要计算文本所需的frame,需要用到

    CTFrameRef CTFramesetterCreateFrame(CTFramesetterRef framesetter, CFRange stringRange, CGPathRef path, CFDictionaryRef frameAttributes);
    
    
    • framesetter用来创建frame
    • stringRange用来创建framesetter,在排版时确定每一行的frame。如果rangelength为0,framesetter会继续添加行,直到把所有文本全部绘制完成或把给定的rect的范围用完。
    • frameAttributesframe添加额外的特定属性。
    怎么从字符串获得frame?

    至于上面的参数framesetter从哪里来?作用是什么?
    先举个栗子,使用

    NSMutableAttributedString *attributedStr = [[NSMutableAttributedString alloc] initWithString:text attributes:attributes];
    CFAttributedStringRef attributedString = (__bridge CFAttributedStringRef)[self highlightText:attributedStr];
    //Draw the frame
    CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedString);
    
    // 从attributeString创建一个可变的framesetter
    CTFramesetterRef CTFramesetterCreateWithAttributedString(CFAttributedStringRef string);
    

    framesetter是用来创建和填充text的frame的。也就是从字符串到获取字符串的frame需要经过:
    NSString —— NSAttributedString—— CFAttributedStringRef ——CTFramesetterRef ——CTFrameRef

    如何知道字符串被分成了多少行?

    此时获取的CTFrame是总的文本绘制区域的frame,如果需要知道文本在给定的范围内,一共有多少行,需要用到CTFrameGetLines函数。该函数返回了一个元素类型为NSCFTypeCFArrayRef数组,如需获得具体行数再调用CFArrayGetCount即可,用法如下

    // 行数组
    CFArrayRef lines = CTFrameGetLines(frame);
    // 具体行数
    NSInteger numberOfLines = CFArrayGetCount(lines);
    
    如何获得字符串中每一行对应的坐标?
    void CTFrameGetLineOrigins(CTFrameRef frame, CFRange range, CGPoint *origins);
    
    • frame存储有所有行信息,传入的frame的所有行坐标点会被复制一份。
    • range是想要获取的行坐标的范围,如果rangelength是0,复制操作会重新从range的start位置继续,直到复制完成。
    • origins是一个CGPoint类型的数组指针,用来接收从frame中复制出来的行坐标。
      具体使用如下:
    // 行坐标存储在lineOrigins数组中
    CGPoint lineOrigins[numberOfLines];
    CTFrameGetLineOrigins(frame, CFRangeMake(0, numberOfLines), lineOrigins);
    
    如何将字符串绘制到指定的位置?

    绘制文本的主要方法是

    // 确定文本绘制的坐标,绘制是单行执行的,也就是绘制的时候,是一行一行绘制
    void CGContextSetTextPosition(CGContextRef c, CGFloat x, CGFloat y);
    
    • c是绘制的上下文
    • x坐标
    • y坐标

    所以要绘制所有文本,必须遍历所有行,然后一行一行的确定位置。

    for (CFIndex lineIndex = 0; lineIndex < numberOfLines; lineIndex++) {
            CGPoint lineOrigin = lineOrigins[lineIndex];
            lineOrigin = CGPointMake(CGFloat_ceil(lineOrigin.x), CGFloat_ceil(lineOrigin.y));
            CGContextSetTextPosition(c, lineOrigin.x, lineOrigin.y);
    }
    
    如何获得行信息?
    // 传入行数组和角标 返回对应角标的CTLine
    const void * CFArrayGetValueAtIndex(CFArrayRef theArray, CFIndex idx);
    
    // 如下
    CTLineRef line = CFArrayGetValueAtIndex(lines, lineIndex);
    
    行内包含哪些属性?

    这是一个行结构体CTLine内所包含的属性,可以在排版时设置相应的属性。

    image.png
    如需对单行的descentascentlineLeading进行设置,可通过调用下面的函数达到目的
    double CTLineGetTypographicBounds(CTLineRef line, CGFloat *ascent, CGFloat *descent, CGFloat *leading);
    // 用法如下
    CGFloat descent = 0.0f;
    CGFloat ascent = 0.0f;
    CGFloat lineLeading;
    CTLineGetTypographicBounds((CTLineRef)line, &ascent, &descent, &lineLeading);
    
    行内如何设置左对齐、中间对齐、右对齐?
    // 返回相对于Flush的偏移量
    double CTLineGetPenOffsetForFlush(CTLineRef line, CGFloat flushFactor, double flushWidth);
    
    • flushFactor决定了排印的类型,0表示左对齐,1表示右对齐,0到1之间的值,就表示中间对齐的程度,0.5表示完全中间对齐。
      // 用法如下:
    CGFloat penOffset = (CGFloat)CTLineGetPenOffsetForFlush(truncatedLine, flushFactor, rect.size.width);
    

    到这里,每一行的上间距、下间距、偏移量都有了,最重要的就是驱动核心绘图库开始绘制文本,渲染到屏幕上,才能被看到。

    文字最终被绘制和显示

    在上面的代码中,在不考虑上下间距等问题时,使用了

    // 计算绘制的初始位置
    CGContextSetTextPosition(c, lineOrigin.x, lineOrigin.y);
    
    // 如果设置了descent,ascent,对齐方式信息,需要重新计算该行的position 
    // 所以需要再次调用该函数
    CGContextSetTextPosition(c, penOffset, y);
    

    每行位置已经确定,只需要在调用最终绘制的方法即可

    // 绘制完整的一行
    void CTLineDraw(CTLineRef line, CGContextRef context);
    // 如
    CTLineDraw(line, c);
    

    因为最终是以单行的形式绘制的,所以也是为什么在绘制前拿到所有NSString的行数组了。
    在绘制每一行的时候,如果需要在最后一行,添加截断方式像UILabe那样在最后添加...,应该怎么处理?

    如何给字符串添加截断类型?

    先来认识一下截断的枚举类型CTLineTruncationType

    // 三种截断类型如下
    typedef CF_ENUM(uint32_t, CTLineTruncationType) {
        kCTLineTruncationStart  = 0,// ...text
        kCTLineTruncationEnd    = 1,// text...
        kCTLineTruncationMiddle = 2// te...xt
    };
    

    那何时给最后一行添加这个枚举类型呢?在最后一行调用CTLineDraw之前,通过计算截断字符串的位置、判断是否需要添加截断字符串、添加截断字符串等,将截断样式插入到当前Line,并返回一个新的Line。具体代码如下

    // 检查字符串最后一行的范围
    CFRange lastLineRange = CTLineGetStringRange(line);
    // 指定截断类型为末尾添加截断
    CTLineTruncationType truncationType = kCTLineTruncationEnd;
    // 获取截断字符串需要插入的位置
    CFIndex truncationAttributePosition = lastLineRange.location;
    // 截断字符串的类型为\u2026
    // \u2026 的意思是表示省略号,是unicode的16进制表示
    NSString *truncationTokenString = @"\u2026";
    // 获取字符串中位置属性信息
    NSDictionary *truncationTokenStringAttributes = [attributedString attributesAtIndex:(NSUInteger)truncationAttributePosition effectiveRange:NULL];
    

    这里说一下这个生涩的API

    - (NSDictionary<NSAttributedStringKey, id> *)attributesAtIndex:(NSUInteger)location effectiveRange:(NSRangePointer)range;
    
    • index索引,这个值可能越界。
    • aRange属性和值与索引相同的范围,这个范围不一定是字符串的最大范围,其程度取决于实现。如果需要最大范围,请使用attributesAtIndex:longestEffectiveRange:inRange:
      如果不需要这个值,则传递NULL。
      最终的返回值,就是对应范围或者index的属性信息,我依旧好奇这个返回的属性信息包括什么?又特地调试了一下,它是一个如下的NSAttributeDictionary
    {
        CTForegroundColor = "<CGColor 0x60000028c9e0> [<CGColorSpace 0x6000000bdb20> (kCGColorSpaceICCBased; kCGColorSpaceModelMonochrome; Generic Gray Gamma 2.2 Profile; extended range)] ( 0 1 )";
        NSFont = "<UICTFont: 0x7fddfbd00bb0> font-family: \"HelveticaNeue-Light\"; font-weight: normal; font-style: normal; font-size: 17.00pt";
        NSParagraphStyle = "<CTParagraphStyle: 0x6040000dbf20>{base writing direction = -1, alignment = 0, line break mode = 0, default tab interval = 0\nfirst line head indent = 0, head indent = 0, tail indent = 0\nline height multiple = 0, maximum line height = 17, minimum line height = 17\nline spacing adjustment = 0, paragraph spacing = 0, paragraph spacing before = 0\nmaximum line spacing = 5\nminimum line spacing = 5\ntabs = (\n    \"<CTTextTab: 0x60400028c4e0>{location = 28, alignment = 0, options = (null)}\",\n    \"<CTTextTab: 0x60400028c170>{location = 56, alignment = 0, options = (null)}\",\n    \"<CTTextTab: 0x60400028bfe0>{location = 84, alignment = 0, options = (null)}\",\n    \"<CTTextTab: 0x60400028c530>{location = 112, alignment = 0, options = (null)}\",\n    \"<CTTextTab: 0x60400028cb70>{location = 140, alignment = 0, options = (null)}\",\n    \"<CTTextTab: 0x604000289420>{location = 168, alignment = 0, options = (null)}\",\n    \"<CTTextTab: 0x6040002873a0>{location = 196, alignment = 0, options = (null)}\",\n    \"<CTTextTab: 0x604000285e10>{location = 224, alignment = 0, options = (null)}\",\n    \"<CTTextTab: 0x604000288a70>{location = 252, alignment = 0, options = (null)}\",\n    \"<CTTextTab: 0x604000289600>{location = 280, alignment = 0, options = (null)}\",\n    \"<CTTextTab: 0x60400028b4a0>{location = 308, alignment = 0, options = (null)}\",\n    \"<CTTextTab: 0x60400028c300>{location = 336, alignment = 0, options = (null)}\"\n)}";
    }
    

    可以看出包括字体大小、段落样式、字体颜色等设置。拿到对应范围的文字属性信息,是为了把...替换成对应的样式。如下

    // 设置好...的样式
    NSAttributedString *attributedTokenString = [[NSAttributedString alloc] initWithString:truncationTokenString attributes:truncationTokenStringAttributes];
    

    再创建新的纯截断字符串...的行CTLine

    CTLineRef truncationToken = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)attributedTokenString);
    

    此时其实才准备插入的操作,也就是说此时需要获取到最后一行的文本,然后判断文本字符中最后一个字符是否是一个特殊字符,比如是不是一个换行字符,通常对应Unicode值为U+000A ~U+000D,U+0085, U+2028, U+2029。判断代码如下:

    NSMutableAttributedString *truncationString = [[attributedString attributedSubstringFromRange:NSMakeRange((NSUInteger)lastLineRange.location, (NSUInteger)lastLineRange.length)] mutableCopy];
        if (lastLineRange.length > 0) {
        // Remove any newline at the end (we don't want newline space between the text and the truncation token). There can only be one, because the second would be on the next line.
            unichar lastCharacter = [[truncationString string] characterAtIndex:(NSUInteger)(lastLineRange.length - 1)];
            if ([[NSCharacterSet newlineCharacterSet] characterIsMember:lastCharacter]) {
            [truncationString deleteCharactersInRange:NSMakeRange((NSUInteger)(lastLineRange.length - 1), 1)];
            }
       }
    

    执行完以上判断,就可以拼接截断字符串和最后一行的内容了也就是attrinuteStr+...

    [truncationString appendAttributedString:attributedTokenString];
    // 拼好后生成一个带...的最后一行文本
    CTLineRef truncationLine = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)truncationString);
    

    到这里其实还没有结束,因为还差一个整合操作,就是上述步骤的拼接,生成的是最后添加有...的Line,但如果type是在中间的截断呢?所以还需要调用截断串的生成函数

    CTLineRef CTLineCreateTruncatedLine(CTLineRef line, double width, CTLineTruncationType truncationType, CTLineRef truncationToken);
    
    • width宽度,如果Line的宽度超过该宽度会显示对应的截断样式。其他几个参数就上上面介绍的了。
      再经过
    CGFloat penOffset = (CGFloat)CTLineGetPenOffsetForFlush(truncatedLine, flushFactor, rect.size.width);
    y = lineOrigin.y - descent - self.font.descender;
    CGContextSetTextPosition(c, penOffset, y);
    CTLineDraw(truncatedLine, c);
    

    到这里算是CoreText层的绘制结束了

    CTLineDraw后面执行的步骤是什么?

    在iOS的屏幕渲染中,CoreText是基于Core Graphics的,文本是怎么显示到屏幕上的了,需要知道一个像素是如何绘制到屏幕上去的?有很多种方式将一些东西映射到显示屏上,他们需要调用不同的框架、许多功能和方法的结合体。这里我们大概的看一下屏幕之后发生的事情。当你想要弄清楚什么时候、怎么去查明并解决问题时,我希望这篇文章能帮助你理解哪一个 API 可以更好的帮你解决问题。我们将聚焦于 iOS,然而我讨论的大多数问题也同样适用于 OS X。

    以下内容摘自objc中国原文链接

    图形堆栈

    当像素映射到屏幕上的时候,后台发生了很多事情。但一旦他们显示到屏幕上,每一个像素均由三个颜色组件构成:红,绿,蓝。三个独立的颜色单元会根据给定的颜色显示到一个像素上。在 iPhone5 的液晶显示器上有1,136×640=727,040个像素,因此有2,181,120个颜色单元。在15寸视网膜屏的 MacBook Pro 上,这一数字达到15.5百万以上。所有的图形堆栈一起工作以确保每次正确的显示。当你滚动整个屏幕的时候,数以百万计的颜色单元必须以每秒60次的速度刷新,这是一个很大的工作量。

    软件组成

    从简单的角度来看,软件堆栈看起来有点像这样:


    image.png

    Display 的上一层便是图形处理单元 GPU,GPU 是一个专门为图形高并发计算而量身定做的处理单元。这也是为什么它能同时更新所有的像素,并呈现到显示器上。它并发的本性让它能高效的将不同纹理合成起来。我们将有一小块内容来更详细的讨论图形合成。关键的是,GPU 是非常专业的,因此在某些工作上非常高效。比如,GPU 非常快,并且比 CPU 使用更少的电来完成工作。通常 CPU 都有一个普遍的目的,它可以做很多不同的事情,但是合成图像在 CPU 上却显得比较慢。

    GPU Driver 是直接和 GPU 交流的代码块。不同的GPU是不同的性能怪兽,但是驱动使他们在下一个层级上显示的更为统一,典型的下一层级有 OpenGL/OpenGL ES.

    OpenGL(Open Graphics Library) 是一个提供了 2D 和 3D 图形渲染的 API。GPU 是一块非常特殊的硬件,OpenGL 和 GPU 密切的工作以提高GPU的能力,并实现硬件加速渲染。对大多数人来说,OpenGL 看起来非常底层,但是当它在1992年第一次发布的时候(20多年前的事了)是第一个和图形硬件(GPU)交流的标准化方式,这是一个重大的飞跃,程序员不再需要为每个GPU重写他们的应用了。

    OpenGL 之上扩展出很多东西。在 iOS 上,几乎所有的东西都是通过 Core Animation 绘制出来,然而在 OS X 上,绕过 Core Animation 直接使用 Core Graphics 绘制的情况并不少见。对于一些专门的应用,尤其是游戏,程序可能直接和 OpenGL/OpenGL ES 交流。事情变得使人更加困惑,因为 Core Animation 使用 Core Graphics 来做一些渲染。像 AVFoundation,Core Image 框架,和其他一些混合的入口。

    要记住一件事情,GPU 是一个非常强大的图形硬件,并且在显示像素方面起着核心作用。它连接到 CPU。从硬件上讲两者之间存在某种类型的总线,并且有像 OpenGL,Core Animation 和 Core Graphics 这样的框架来在 GPU 和 CPU 之间精心安排数据的传输。为了将像素显示到屏幕上,一些处理将在 CPU 上进行。然后数据将会传送到 GPU,这也需要做一些相应的操作,最终像素显示到屏幕上。

    这个过程的每一部分都有各自的挑战,并且许多时候需要做出折中的选择。

    硬件参与者
    image.png

    正如上面这张简单的图片显示那些挑战:GPU 需要将每一个 frame 的纹理(位图)合成在一起(一秒60次)。每一个纹理会占用 VRAM(video RAM),所以需要给 GPU 同时保持纹理的数量做一个限制。GPU 在合成方面非常高效,但是某些合成任务却比其他更复杂,并且 GPU在 16.7ms(1/60s)内能做的工作也是有限的。

    下一个挑战就是将数据传输到 GPU 上。为了让 GPU 访问数据,需要将数据从 RAM 移动到 VRAM 上。这就是提及到的上传数据到 GPU。这看起来貌似微不足道,但是一些大型的纹理却会非常耗时。

    最终,CPU 开始运行你的程序。你可能会让 CPU 从 bundle 加载一张 PNG 的图片并且解压它。这所有的事情都在 CPU 上进行。然后当你需要显示解压缩后的图片时,它需要以某种方式上传到 GPU。一些看似平凡的,比如显示文本,对 CPU 来说却是一件非常复杂的事情,这会促使 Core Text 和 Core Graphics 框架更紧密的集成来根据文本生成一个位图。一旦准备好,它将会被作为一个纹理上传到 GPU 并准备显示出来。当你滚动或者在屏幕上移动文本时,不管怎么样,同样的纹理能够被复用,CPU 只需简单的告诉 GPU 新的位置就行了,所以 GPU 就可以重用存在的纹理了。CPU 并不需要重新渲染文本,并且位图也不需要重新上传到 GPU。

    这张图涉及到一些错综复杂的方面,我们将会把这些方面提取出来并深一步了解。

    合成

    在图形世界中,合成是一个描述不同位图如何放到一起来创建你最终在屏幕上看到图像的过程。在许多方面显得显而易见,而让人忘了背后错综复杂的计算。

    让我们忽略一些难懂的事例并且假定屏幕上一切事物皆纹理。一个纹理就是一个包含 RGBA 值的长方形,比如,每一个像素里面都包含红、绿、蓝和透明度的值。在 Core Animation 世界中这就相当于一个CALayer

    在这个简化的设置中,每一个layer是一个纹理,所有的纹理都以某种方式堆叠在彼此的顶部。对于屏幕上的每一个像素,GPU 需要算出怎么混合这些纹理来得到像素 RGB 的值。这就是合成大概的意思。

    如果我们所拥有的是一个和屏幕大小一样并且和屏幕像素对齐的单一纹理,那么屏幕上每一个像素相当于纹理中的一个像素,纹理的最后一个像素也就是屏幕的最后一个像素。

    如果我们有第二个纹理放在第一个纹理之上,然后GPU将会把第二个纹理合成到第一个纹理中。有很多种不同的合成方法,但是如果我们假定两个纹理的像素对齐,并且使用正常的混合模式,我们便可以用下面这个公式来计算每一个像素:

    R = S + D * ( 1 – Sa )
    

    结果的颜色是源色彩(顶端纹理)+目标颜色(低一层的纹理)*(1-源颜色的透明度)。在这个公式中所有的颜色都假定已经预先乘以了他们的透明度。

    显然相当多的事情在这发生了。让我们进行第二个假定,两个纹理都完全不透明,比如 alpha=1.如果目标纹理(低一层的纹理)是蓝色(RGB=0,0,1),并且源纹理(顶层的纹理)颜色是红色(RGB=1,0,0),因为 Sa 为1,所以结果为:

    R = S
    

    结果是源颜色的红色。这正是我们所期待的(红色覆盖了蓝色)。

    如果源颜色层为50%的透明,比如 alpha=0.5,既然 alpha 组成部分需要预先乘进 RGB 的值中,那么 S 的 RGB 值为(0.5, 0, 0),公式看起来便会像这样:

                           0.5   0               0.5
    R = S + D * (1 - Sa) = 0   + 0 * (1 - 0.5) = 0
                           0     1               0.5
    

    我们最终得到RGB值为(0.5, 0, 0.5),是一个紫色。这正是我们所期望将透明红色合成到蓝色背景上所得到的。

    记住我们刚刚只是将纹理中的一个像素合成到另一个纹理的像素上。当两个纹理覆盖在一起的时候,GPU需要为所有像素做这种操作。正如你所知道的一样,许多程序都有很多层,因此所有的纹理都需要合成到一起。尽管GPU是一块高度优化的硬件来做这种事情,但这还是会让它非常忙碌。
    ......

    相关文章

      网友评论

        本文标题:CoreText是如何绘制文本的

        本文链接:https://www.haomeiwen.com/subject/dohgpftx.html