富文本框架里YYText在性能方面的表现很出色,它基于 CoreText 做了大量基础处理并实现了两个上层视图组件:YYLabel 和 YYTextView。在了解富文本处理之前,我们还需要对 CoreText 基础知识做一些了解。本篇主要梳理 YYText 中 CoreText 的底层基础部分处理。

框架概述

iOS 中我们常会在主线程中进行 UI 的绘制,但当绘制压力过大时会造成页面卡顿情况的出现。一种解决思路是,通过多线程在异步线程进行图形的绘制,以减轻主线程的压力。

YYText 框架的实现思路也是这样的。

  • 创建自定义绘制线程
  • 在该线程中创建图形上下文
  • 通过 CoreText 绘制富文本,通过 CoreGraphics 绘制图片、阴影、边框等
  • 最后将绘制完成得到的位图,回到主线程展示

CoreText 工具类

关于 CoreText 的结构图:

YYTextRunDelegate

富文本中为定制一段区域的大小,可以在富文本中插入 key 为 kCTRunDelegateAttributeNameCTRunDelegateRef 实例。通过这种方式来预留空白,以用来填充图片,进行图文的混排。作者可能考虑到 CFRunDelegateRef 本身的使用会比较繁琐,为了简易使用,进行了封装。也就是这里的 YYTextRunDelegate。

内部实现的思路:

  • 通过 CTRunDelegateCreate() 创建一 CTRunDelegateRef
  • 通过 __bridge_retained 转移内存管理,持有一个 YYTextRunDelegate 对象

一些细节处理:

  • 内存管理问题。CTRunDelegateRef 实例持有 YYTextRunDelegate,当 CTRunDelegateRef 实例释放时,会调用 DeallocCallback(),将内存管理权限转移给 YYTextRunDelegate 局部变量的 ARC。
  • CTRunDelegateCreate() 里做了 copy 操作。这里是深拷贝,目的是为了创建一个副本,以保证配置数据的安全性,不会被篡改。

YYTextLine

创建一个富文本,拿到 CTRunRef、CTLineRef 及一些结构数据(如 ascent、descent)。

line 位置及大小的计算

// 不考虑竖排版
_bounds = CGRectMake(_position.x, _position.y - _ascent, _lineWidth, _ascent + _descent);
_bounds.origin.x += _firstGlyphPos;
  • _position 指 line 的 origin 点位于 context 上下文的坐标转换为 UIKit 坐标系的值。
  • _position.y - _ascent 表示 y 起始位置
  • _ascent + _descent 表示 line 高度
  • _firstGlyphPos 表示第一个 run 相对 line 的偏移

找出占位 run

基本原理是通过 CTRunDelegateRef 占位,再用 YYTextAttachment 填充。当遍历 line 里的 run 时,若该 run 内有 YYTextAttachment,说明是占位 run,计算这个 run 的位置和大小,便于后面填充。

runPosition.x += _position.x;
runPosition.y = _position.y - runPosition.y;
runTypoBounds = CGRectMake(runPosition.x, runPosition.y - ascent, runWidth, ascent + descent);
  • runPosition 表示相对 line 的 x/y 偏移量
  • 最后,缓存 YYTextAttachment 和 run 位置大小信息

YYTextContainer

CTFrameRef 需要通过 CTFramesetterCreateFrame() 创建,该方法需要 CGPathRef 作为参数,作者封装了 YYTextContainer 类来简化使用。

/*
Example:

┌─────────────────────────────┐ <------- container
│ │
│ asdfasdfasdfasdfasdfa <------------ container insets
│ asdfasdfa asdfasdfa │
│ asdfas asdasd │
│ asdfa <----------------------- container exclusion path
│ asdfas adfasd │
│ asdfasdfa asdfasdfa │
│ asdfasdfasdfasdfasdfa │
│ │
└─────────────────────────────┘
*/

开发者可以通过 CGSize 来设定富文本大小,也可以通过 UIBezierPath 定制路径。同时,CoreText 还支持镂空效果,通过 exclusionPaths 控制。

YYTextLayout

包含了布局一富文本所有的信息,这个文件里也包含很多绘制相关的 C 代码。YYTextLayout 负责计算各种数据,为后面的绘制做准备。

核心计算方法:

+ (YYTextLayout *)layoutWithContainer:(YYTextContainer *)container text:(NSAttributedString *)text range:(NSRange)range;

计算绘制路径和路径的位置矩形

基于 YYTextContainer 对象计算得到 CGPathRef。UIKit 转为 CoreText 坐标,需要先进行坐标处理。得到 pathBox,pathBox 是真正的绘制区域相对于绘制上下文的位置和大小。在后面计算 line 和 run 位置时,都需要这里的 cgPathBox.origin

初始化 CTFramesetterRef 和 CTFrameRef

计算 line 总 frame

  • 前面 TextContainer 得到 CTFrameRef
  • 接着遍历所有的 line,结合 cgPathBox.origin 计算得到每个 line 的位置和大小
  • 最后将每个 line 的 rect 合并,得到包含所有 line 的最小位置矩形 textBoundingRect

计算 line 的行数


在有排除路径的情况下,一行可能有多个 line。所以需要计算每个 line 所在的行。


当 line 高度大于 lastline 高度时。若 lastline 的 baseline 在 line 的 y0~y1 之间,说明未换行。


当 line 高度小于 lastline 高度时。若 line 的 baseline 在 lastline 的 y0~y1 之间,说明未换行。

确定了 line 的换行规则,可以计算得到 line 的行数。

获取行上下边界的数组

上下边界结构体:

typedef struct {
CGFloat head;
CGFloat foot;
} YYRowEdge;

YYRowEdge 表示每一行的上下边界。遍历所有 line,当当前 line 和 last line 为同一行时,取 line 和 last line 最大上下边界;当当前 line 和 last line 不同行时,取当前 line 的上下边界。

结果如图示:

中间的间隙为行间距,YYText 将行间距进行了均分,如图示:

计算绘制区域的总大小

前面通过 YYTextContainer 计算得到绘制路径的位置矩形 pathBox(上图蓝色区域)。但这是实际绘制区域的大小,但应用场景中还会有 inset、borderWidth 之类的情况。所以实际业务需要的绘制区域会更大。

line 截断

当富文本超过限制时,通常会看到文本最后有点省略号:text...。YYText 支持自定义后缀,即 truncationToken

YYTextLine 总是在富文本最后,当 lastLineText 超出绘制范围,通过 CTLineCreateTruncatedLine(...) 创建自动计算的截断 line,会返回一个 CTLineRef,框架将其转化为 YYTextLine 作为 YYTextLayout 的一个属性 truncatedLine

自定义富文本属性

原理是遍历富文本,找到某个 run 是否包含自定义的 key,接着做对应的绘制逻辑。

图文混排的实现

前面提到过,如果想要在富文本中添加 UIImage、UIView 之类的附件,需要先设置一个占位符 CTRunDelegateRef,具体看下占位的逻辑。

对齐方式

图文混排添加附件有三种对齐方式:居上对齐、居中对齐、局下对齐。通过 ascent、descent、baseline 控制。

  1. 居上对齐

run 的 ascent 对齐文本的 ascent。

  1. 居下对齐

run 的 descent 对齐文本的 descent。若 run 太矮,则居上对齐文本的 baseline。

  1. 居中对齐

居中相对会复杂些,run 的 center 和文本的 center 对齐。

center = (ascent + descent) / 2

若 run 太矮,则贴着 baseline。

绘制附件

绘制逻辑在 YYTextLayoutYYTextDrawAttachment(...) 方法中。

  1. 若附件为 UIImage,会根据占位 run 的位置和大小,通过 CoreGraphics API 绘制图片:
CGImageRef ref = image.CGImage;
if (ref) {
CGContextSaveGState(context);
CGContextTranslateCTM(context, 0, CGRectGetMaxY(rect) + CGRectGetMinY(rect));
CGContextScaleCTM(context, 1, -1);
CGContextDrawImage(context, rect, ref);
CGContextRestoreGState(context);
}
  1. 若附件为 UIView、CALayer,需要传入额外的 superView、superLayer。再将绘制的 UIView、CALayer 添加到 superView、superLayer。

点击高亮的实现

YYTextHighlight 包含单击和长按的回调、及一些显示的属性配置。

如 YYLabel 中的触发逻辑。先判断点击的 CGPoint 对应的富文本位置,检测文本是否有对应的手势处理。若有对应的 YYTextHighlight 处理,则更换 YYTextLine 为高亮 YYTextLine,重绘。松手时,恢复 YYTextLine。

简而言之,通过检测点击 CGPoint 是否有对应的手势处理,若有替换对应的 YYTextLine,重新绘制。

总结

本篇主要是对 YYText 中 CoreText 处理的原理做了梳理,从 CTFrameRef 到 CTLineRef 到 CTRunRef。接着基于 line 和 run 做具体的展开,如:

  • line 的行数确定
  • line 的换行规则
  • line 的截断
  • run 的对齐
  • 富文本附件的添加原理

评论