YYText 源码解析

YYText 是一个功能强大的 iOS 富文本编辑与显示框架(该项目是 YYKit 组件之一),是 ibireme 大神的作品之一。
我在项目中多次使用到 YYText,这是一个功能强大、接口完备、文档翔实、性能优秀的文本框架,不仅能很好的满足日常需求的开发,在性能优化时也能有所帮助。之前一直停留在使用的阶段,最近特地抽时间研究了 YYText 的底层实现,尝试找到其强大的秘密。

阅读本文需要一些 Core Text 的知识,可以参阅:
[1]. iOS 文字排版 (CoreText) 那些事
[2]. 基于 CoreText 的排版引擎:基础

工程结构

YYText 源码解析_第1张图片
工程结构

引用 YYText github 主页的工程结构图

  • YYLabel 是上层的控件类,类似于 UILabel,继承自 UIView,但是在 UILabel 的基础上提供了异步排版和渲染、图文混排、文本高亮、文本容器控制、竖排文字等高级特性
  • YYTextView 是上层的控件类,类似于 UITextView,继承自 UIScrollView,但是在 UITextView 的基础上提供了图文混排、文本高亮、文本容器控制、竖排文字、复制黏贴等高级特性
  • YYTextLayout 是存储了文本布局结果的只读类,这个类的方法是线程安全的。日常使用上层的 YYLabel 或者 YYTextView 就满足需求了,但是如果想要获得最高的性能,可以在后台线程用 YYTextLayout 进行预排版,之前的文章「iOS性能优化探讨」中就使用到了这一特性
  • YYTextContainer 定义了一个供文本展示的区域,可以是矩形区域(size + insets) 或者是非矩形区域(path),还可以定义排除路径(exclusion paths)实现文本环绕的效果,YYTextContainer 也是线程安全的
  • 底层依赖的是 CoreText

图文混排

YYText 支持设置 UIImageUIViewCALayer 等作为 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.mYYTextDrawAttachment 方法:

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 可以是 UIImageUIViewCALayer,根据这三种类型分别进行处理:

  • UIImage 类型的 attachment 直接调用 CoreGraphics 的 CGContextDrawImage 在预留的区域中进行图片绘制
  • UIView 类型的 attachment 则 addSubview 增加子视图到预留的区域中
  • CALayer 类型的 attachment 则 addSublayer 增加子 layer 到预留的区域中

点击高亮

点击高亮是通过 YYTextHighlight 类实现的,在指定的 range 存储 YYTextHighlightAttributeName : YYTextHighlight 的键值对,同时实现了 YYLabelYYTextView 的触摸事件回调,判断点击的位置在富文本中的具体位置中取出对应的 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 更新布局,当中涉及了很多坐标的转换计算,这里不展开叙述。

异步绘制

YYLabeldisplaysAsynchronously 设置为 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 会在自身内容需要被更新时请求一个异步的绘制任务。这里存在几个问题:

  • 自身内容需要被更新的合适时机是什么时候?
  • YYTextAsyncLayerYYLabel 之间如何交互,共同完成绘制任务?

合适的更新时机

先贴一下核心代码:

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 中监听了 kCFRunLoopBeforeWaitingkCFRunLoopExit 事件,分别对应 RunLoop 「即将进入休眠」和「即将退出Loop」事件,这个监听的优先级设置为了 0xFFFFFF,在 CATransaction 的优先级之后,在处理完系统的重要逻辑之后才进行异步绘制的操作,避免繁重的绘制任务阻塞了其他操作。

异步绘制的核心逻辑

首先需要重写 YYLabellayerClass 方法将内部的 layer 改成 YYTextAsyncLayerYYTextAsyncLayer 暴露了一个类型是 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 回调中调用了 YYTextLayoutdrawInContext 方法,而 drawInContext 方法则是根据配置去绘制边框、阴影、attachment、文本等,这些属性的绘制全是通过 CoreGraphics、CoreText 完成的,而这两个框架都是线程安全的,可以在派发到异步线程进行,这也就是 YYText 异步绘制的秘诀所在!

提前布局

在「iOS性能优化探讨」文章中我提到提前布局对于列表滑动而言是最为有效的性能优化手段。

提前布局可以说是最重要的优化点了。其实在从服务端拿到 JSON 数据的时候,关于视图的布局就已经确定了,包括每个控件的 frame、cell 的高度以及文本排版结果等等,在这个时候完全可以在后台线程计算并封装为对应的布局对象 XXXTableViewCellLayout,每个 cellLayout 的内存占用并不是很多,所以直接全部缓存到内存中。当列表滚动到某个 cell 的时候,直接拿到对应的 cellLayout 配置这个 cell 的对应属性即可。当然,该有的计算是免不了的,只是提前算好并缓存,免去了在滚动的时候计算和重复的计算。

使用提前布局特性的话需要将 YYLabelignoreCommonProperties 设置为 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 会重设 YYLabelinnerLayoutinnerTextinnerContainer,然后引起 YYLabel 的重绘更新成新的样式。根据 YYTextLayout 的初始化方法 + (nullable YYTextLayout *)layoutWithContainer:(YYTextContainer *)container text:(NSAttributedString *)text range:(NSRange)range 可以得出,在 YYTextLayout 初始化的时候就已经将布局和渲染用到的 textframeSettercontainertruncatedLineattachments 等所有信息都已经计算好并存储了下来,避免了在列表滑动的时候才去进行繁重且重复的计算,从而获得了极佳的性能表现。

结语

YYText 不愧为一个功能强大的 iOS 富文本编辑与显示框架,作者对 CoreGraphics 和 CoreText 的使用可谓是炉火纯青,接口的设计也是恰到好处,每一次阅读都有新的收获,是一个值得反复研究的优秀开源项目。

你可能感兴趣的:(YYText 源码解析)