YYText 是一个功能强大的 iOS 富文本编辑与显示框架(该项目是 YYKit 组件之一),是 ibireme 大神的作品之一。
我在项目中多次使用到 YYText,这是一个功能强大、接口完备、文档翔实、性能优秀的文本框架,不仅能很好的满足日常需求的开发,在性能优化时也能有所帮助。之前一直停留在使用的阶段,最近特地抽时间研究了 YYText 的底层实现,尝试找到其强大的秘密。
阅读本文需要一些 Core Text 的知识,可以参阅:
[1]. iOS 文字排版 (CoreText) 那些事
[2]. 基于 CoreText 的排版引擎:基础
工程结构
引用 YYText github 主页的工程结构图
-
YYLabel
是上层的控件类,类似于UILabel
,继承自UIView
,但是在UILabel
的基础上提供了异步排版和渲染、图文混排、文本高亮、文本容器控制、竖排文字等高级特性 -
YYTextView
是上层的控件类,类似于UITextView
,继承自UIScrollView
,但是在UITextView
的基础上提供了图文混排、文本高亮、文本容器控制、竖排文字、复制黏贴等高级特性 -
YYTextLayout
是存储了文本布局结果的只读类,这个类的方法是线程安全的。日常使用上层的YYLabel
或者YYTextView
就满足需求了,但是如果想要获得最高的性能,可以在后台线程用YYTextLayout
进行预排版,之前的文章「iOS性能优化探讨」中就使用到了这一特性 -
YYTextContainer
定义了一个供文本展示的区域,可以是矩形区域(size + insets) 或者是非矩形区域(path),还可以定义排除路径(exclusion paths)实现文本环绕的效果,YYTextContainer
也是线程安全的 - 底层依赖的是
CoreText
图文混排
YYText 支持设置 UIImage
、UIView
和 CALayer
等作为 attachment 实现图文混排效果,先看一下方法的具体实现:
+ (nonnull NSMutableAttributedString *)yy_attachmentStringWithContent:(nullable id)content
contentMode:(UIViewContentMode)contentMode
width:(CGFloat)width
ascent:(CGFloat)ascent
descent:(CGFloat)descent
{
NSMutableAttributedString *atr = [[NSMutableAttributedString alloc] initWithString:YYTextAttachmentToken];
...
YYTextRunDelegate *delegate = [YYTextRunDelegate new];
delegate.width = width;
delegate.ascent = ascent;
delegate.descent = descent;
CTRunDelegateRef delegateRef = delegate.CTRunDelegate;
[atr yy_setRunDelegate:delegateRef range:NSMakeRange(0, atr.length)];
if (delegate) CFRelease(delegateRef);
return atr;
}
CoreText 实际上并没有相应 API 直接将一个图片转换为 CTRun 并进行绘制,它所能做的只是为图片预留相应的空白区域,而真正的绘制则是交由 CoreGraphics 完成。(像 OSX 就方便很多,直接将图片打包进 NSTextAttachment 即可,根本无须操心绘制的事情,所以基于这个想法,M80AttributedLabel 的接口和实现也是使用了 attachment 这么个概念,图片或者 UIView 都是被当作文字段中的 attachment。) 在 CoreText 中提供了 CTRunDelegate 这么个 Core Foundation 类,顾名思义它可以对 CTRun 进行拓展:AttributedString 某个段设置 kCTRunDelegateAttributeName 属性之后,CoreText 使用它生成 CTRun 是通过当前 Delegate 的回调来获取自己的 ascent,descent 和 width,而不是根据字体信息。这样就给我们留下了可操作的空间:用一个空白字符作为图片的占位符,设好 Delegate,占好位置,然后用 CoreGraphics 进行图片的绘制。
如上所述,YYText 在指定的 range 中设置了 CTRunDelegateRef
,为 attachment 预留了相应的空白区域,用一个空白字符作为图片的占位符,设好 Delegate,占好位置,然后用 CoreGraphics 进行图片的绘制。接下来我们看下图片实际的绘制,图片的绘制在 YYTextLayout.m
的 YYTextDrawAttachment
方法:
static void YYTextDrawAttachment(YYTextLayout *layout, CGContextRef context, CGSize size, CGPoint point, UIView *targetView, CALayer *targetLayer, BOOL (^cancel)(void)) {
...
for (NSUInteger i = 0, max = layout.attachments.count; i < max; i++) {
...
UIImage *image = nil;
UIView *view = nil;
CALayer *layer = nil;
if ([a.content isKindOfClass:[UIImage class]]) {
image = a.content;
} else if ([a.content isKindOfClass:[UIView class]]) {
view = a.content;
} else if ([a.content isKindOfClass:[CALayer class]]) {
layer = a.content;
}
...
if (image) {
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);
}
} else if (view) {
view.frame = rect;
[targetView addSubview:view];
} else if (layer) {
layer.frame = rect;
[targetLayer addSublayer:layer];
}
}
}
attachment 可以是 UIImage
、UIView
和 CALayer
,根据这三种类型分别进行处理:
-
UIImage
类型的 attachment 直接调用 CoreGraphics 的CGContextDrawImage
在预留的区域中进行图片绘制 -
UIView
类型的 attachment 则addSubview
增加子视图到预留的区域中 -
CALayer
类型的 attachment 则addSublayer
增加子 layer 到预留的区域中
点击高亮
点击高亮是通过 YYTextHighlight
类实现的,在指定的 range 存储 YYTextHighlightAttributeName
: YYTextHighlight
的键值对,同时实现了 YYLabel
和 YYTextView
的触摸事件回调,判断点击的位置在富文本中的具体位置中取出对应的 YYTextHighlight
,如果设置了 tapAction
或者 longPressAction
则回调对应的触摸事件给上层。接下来看一下具体的实现:
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
[self _updateIfNeeded];
...
_highlight = [self _getHighlightAtPoint:point range:&_highlightRange];
_highlightLayout = nil;
...
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
[self _updateIfNeeded];
...
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
...
if (_highlight) {
if (!_state.touchMoved || [self _getHighlightAtPoint:point range:NULL] == _highlight) {
YYTextAction tapAction = _highlight.tapAction ? _highlight.tapAction : _highlightTapAction;
if (tapAction) {
...
tapAction(self, _innerText, _highlightRange, rect);
}
}
[self _removeHighlightAnimated:_fadeOnHighlight];
}
}
...
}
- (YYTextAsyncLayerDisplayTask *)newAsyncDisplayTask {
...
__block YYTextLayout *layout = (_state.showingHighlight && _highlightLayout) ? self._highlightLayout : self._innerLayout;
...
}
可以看到,newAsyncDisplayTask
方法中会判断如果处于点击高亮状态中则会设置为高亮状态对应的 YYTextLayout
,然后重绘。当手松开时,切换会常态下的 YYTextLayout
。
这就是点击高亮的实现原理,实际上就是替换 YYTextLayout
更新布局,当中涉及了很多坐标的转换计算,这里不展开叙述。
异步绘制
将 YYLabel
的 displaysAsynchronously
设置为 YES
可以将文本的布局与渲染都派发到后台线程异步地完成,这似乎违背了我们一贯以来的认知:在 iOS 中对视图的操作都应该放到主线程中,否则会引发不可知的错误甚至导致应用崩溃。那么 YYText
是怎么做到这一点的呢?
秘密就在于 YYTextAsyncLayer
。
The YYTextAsyncLayer class is a subclass of CALayer used for render contents asynchronously. When the layer need update it's contents, it will ask the delegate for a async display task to render the contents in a background queue.
简单理解就是 YYTextAsyncLayer
会在自身内容需要被更新时请求一个异步的绘制任务。这里存在几个问题:
- 自身内容需要被更新的合适时机是什么时候?
-
YYTextAsyncLayer
和YYLabel
之间如何交互,共同完成绘制任务?
合适的更新时机
先贴一下核心代码:
static void YYRunLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
if (transactionSet.count == 0) return;
NSSet *currentSet = transactionSet;
transactionSet = [NSMutableSet new];
[currentSet enumerateObjectsUsingBlock:^(YYTextTransaction *transaction, BOOL *stop) {
[transaction.target performSelector:transaction.selector];
}];
}
static void YYTextTransactionSetup() {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
transactionSet = [NSMutableSet new];
CFRunLoopRef runloop = CFRunLoopGetMain();
CFRunLoopObserverRef observer;
observer = CFRunLoopObserverCreate(CFAllocatorGetDefault(),
kCFRunLoopBeforeWaiting | kCFRunLoopExit,
true, // repeat
0xFFFFFF, // after CATransaction(2000000)
YYRunLoopObserverCallBack, NULL);
CFRunLoopAddObserver(runloop, observer, kCFRunLoopCommonModes);
CFRelease(observer);
});
}
YYTextAsyncLayer
在主线程的 RunLoop 中监听了 kCFRunLoopBeforeWaiting
和 kCFRunLoopExit
事件,分别对应 RunLoop 「即将进入休眠」和「即将退出Loop」事件,这个监听的优先级设置为了 0xFFFFFF
,在 CATransaction
的优先级之后,在处理完系统的重要逻辑之后才进行异步绘制的操作,避免繁重的绘制任务阻塞了其他操作。
异步绘制的核心逻辑
首先需要重写 YYLabel
的 layerClass
方法将内部的 layer 改成 YYTextAsyncLayer
,YYTextAsyncLayer
暴露了一个类型是 YYTextAsyncLayerDelegate
的代理给上层 YYLabel
,当 layer 的内容需要更新的时候会通过 newAsyncDisplayTask
向上层索要一个 YYTextAsyncLayerDisplayTask
绘制任务,由上层提供绘制的具体操作。看一下核心代码:
- (void)_displayAsync:(BOOL)async {
...
dispatch_async(YYAsyncLayerGetDisplayQueue(), ^{
UIGraphicsBeginImageContextWithOptions(size, opaque, scale);
CGContextRef context = UIGraphicsGetCurrentContext();
task.display(context, size, isCancelled);
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
dispatch_async(dispatch_get_main_queue(), ^{
self.contents = (__bridge id)(image.CGImage);
});
}];
...
}
可以看到,YYTextAsyncLayer
在异步线程创建一个位图上下文,通过这个上下文生成一个位图,最后在主队列将这个位图设置到 layer 的 content
属性,由 GPU 渲染过后提交到显示系统。
接下来看一下上层的 YYLabel
是如何提交这个绘制任务的:
#pragma mark - YYTextAsyncLayerDelegate
- (YYTextAsyncLayerDisplayTask *)newAsyncDisplayTask {
...
// create display task
YYTextAsyncLayerDisplayTask *task = [YYTextAsyncLayerDisplayTask new];
task.willDisplay = ^(CALayer *layer) {
...
};
task.display = ^(CGContextRef context, CGSize size, BOOL (^isCancelled)(void)) {
if (isCancelled()) return;
if (text.length == 0) return;
...
[drawLayout drawInContext:context size:size point:point view:nil layer:nil debug:debug cancel:isCancelled];
};
task.didDisplay = ^(CALayer *layer, BOOL finished) {
...
};
return task;
}
- (void)drawInContext:(CGContextRef)context
size:(CGSize)size
point:(CGPoint)point
view:(UIView *)view
layer:(CALayer *)layer
debug:(YYTextDebugOption *)debug
cancel:(BOOL (^)(void))cancel{
@autoreleasepool {
if (self.needDrawBlockBorder && context) {
if (cancel && cancel()) return;
YYTextDrawBlockBorder(self, context, size, point, cancel);
}
if (self.needDrawBackgroundBorder && context) {
if (cancel && cancel()) return;
YYTextDrawBorder(self, context, size, point, YYTextBorderTypeBackgound, cancel);
}
if (self.needDrawShadow && context) {
if (cancel && cancel()) return;
YYTextDrawShadow(self, context, size, point, cancel);
}
if (self.needDrawUnderline && context) {
if (cancel && cancel()) return;
YYTextDrawDecoration(self, context, size, point, YYTextDecorationTypeUnderline, cancel);
}
if (self.needDrawText && context) {
if (cancel && cancel()) return;
YYTextDrawText(self, context, size, point, cancel);
}
...
}
}
在 display
回调中调用了 YYTextLayout
的 drawInContext
方法,而 drawInContext
方法则是根据配置去绘制边框、阴影、attachment、文本等,这些属性的绘制全是通过 CoreGraphics、CoreText 完成的,而这两个框架都是线程安全的,可以在派发到异步线程进行,这也就是 YYText
异步绘制的秘诀所在!
提前布局
在「iOS性能优化探讨」文章中我提到提前布局对于列表滑动而言是最为有效的性能优化手段。
提前布局可以说是最重要的优化点了。其实在从服务端拿到 JSON 数据的时候,关于视图的布局就已经确定了,包括每个控件的 frame、cell 的高度以及文本排版结果等等,在这个时候完全可以在后台线程计算并封装为对应的布局对象 XXXTableViewCellLayout,每个 cellLayout 的内存占用并不是很多,所以直接全部缓存到内存中。当列表滚动到某个 cell 的时候,直接拿到对应的 cellLayout 配置这个 cell 的对应属性即可。当然,该有的计算是免不了的,只是提前算好并缓存,免去了在滚动的时候计算和重复的计算。
使用提前布局特性的话需要将 YYLabel
的 ignoreCommonProperties
设置为 YES
,这样的话 YYLabel
会忽略掉 text
/font
/textColor
等所有属性,而只会通过 textLayout
属性去获取这些配置,所以提前布局的实现取决于 YYTextLayout
中存储了文本布局与渲染所需要的所有信息。
- (void)setTextLayout:(YYTextLayout *)textLayout {
_innerLayout = textLayout;
_shrinkInnerLayout = nil;
if (_ignoreCommonProperties) {
_innerText = (NSMutableAttributedString *)textLayout.text;
_innerContainer = textLayout.container.copy;
}
...
if (_displaysAsynchronously && _clearContentsBeforeAsynchronouslyDisplay) {
[self _clearContents];
}
_state.layoutNeedUpdate = NO;
[self _setLayoutNeedRedraw];
[self _endTouch];
[self invalidateIntrinsicContentSize];
}
可以看到,设置 textLayout
属性,结合 ignoreCommonProperties
会重设 YYLabel
的 innerLayout
、innerText
和 innerContainer
,然后引起 YYLabel
的重绘更新成新的样式。根据 YYTextLayout
的初始化方法 + (nullable YYTextLayout *)layoutWithContainer:(YYTextContainer *)container text:(NSAttributedString *)text range:(NSRange)range
可以得出,在 YYTextLayout
初始化的时候就已经将布局和渲染用到的 text
、frameSetter
、container
、truncatedLine
、attachments
等所有信息都已经计算好并存储了下来,避免了在列表滑动的时候才去进行繁重且重复的计算,从而获得了极佳的性能表现。
结语
YYText
不愧为一个功能强大的 iOS 富文本编辑与显示框架,作者对 CoreGraphics 和 CoreText 的使用可谓是炉火纯青,接口的设计也是恰到好处,每一次阅读都有新的收获,是一个值得反复研究的优秀开源项目。