YYText 源码解析

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 支持设置 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 的使用可谓是炉火纯青,接口的设计也是恰到好处,每一次阅读都有新的收获,是一个值得反复研究的优秀开源项目。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343