YYText 源碼剖析:CoreText 與異步繪製

YYKit 系列源碼剖析文章:html

前言

YYText 是業界知名富文本框架,基於 CoreText 作了大量基礎設施而且實現了兩個上層視圖組件:YYLabel 和 YYTextView。同其它 YYKit 組件同樣,YYText 在性能方面表現優異,且功能出奇的強大,能夠說是業界巔峯之做。git

提起 YYText,都知道它的核心優化點:異步繪製,然而這只是冰山一角,YYText 中最爲複雜和篇幅最多的是基於 CoreText 的各類計算,不得不說,源碼中大量的計算很容易讓人眼花繚亂。github

若想深刻理解 YYText 或者看懂本文,必需要了解 CoreText 基礎知識而且有足夠的耐心。框架代碼量很是大,本文主要講解框架基於 CoreText 的底層基礎部分,不會過多的講解 YYLabel 和 YYTextView 的細節。數組

1、框架總覽

YYText GitHub緩存

iOS UI 組件大都必須在主線程繪製,當繪製壓力過大會形成界面卡頓,得益於多線程技術,咱們能夠在異步線程繪製圖形從而減輕主線程壓力。安全

YYText 核心思路:在異步線程建立圖形上下文,而後利用 CoreText 繪製富文本,利用 CoreGraphics 繪製圖片、陰影、邊框等,最後將繪製完成的位圖放到主線程顯示。性能優化

步驟看起來很簡單,源碼中涉及到 CoreText 和 CoreGraphics 的繪製時須要大量的代碼來計算位置,這也是本文的重點之一。爲了簡潔易懂,筆者會略過一些技術細節,好比縱向文本佈局邏輯,一些奇怪的 BUG 修復代碼。bash

但願讀者朋友優先了解 CoreText 基礎 (CoreText 官方介紹),這裏放上兩個結構圖便於理解(圖會有誤差):多線程

結構圖1

結構圖2

2、CoreText 相關工具類

一、YYTextRunDelegate

在富文本中插入 key 爲kCTRunDelegateAttributeNameCTRunDelegateRef實例能夠定製一段區域的大小,一般使用這個方式來預留出一段空白,後面能夠填充圖片來達到圖文混排的效果。而建立CTRunDelegateRef須要一系列的函數名,使用繁瑣,框架使用一個類來封裝以減少使用成本:app

@interface YYTextRunDelegate : NSObject <NSCopying, NSCoding>
...
@property (nonatomic) CGFloat ascent;
@property (nonatomic) CGFloat descent;
@property (nonatomic) CGFloat width;
@end
複製代碼
static void DeallocCallback(void *ref) {
    YYTextRunDelegate *self = (__bridge_transfer YYTextRunDelegate *)(ref);
    self = nil; // release
}
static CGFloat GetAscentCallback(void *ref) {
    YYTextRunDelegate *self = (__bridge YYTextRunDelegate *)(ref);
    return self.ascent;
}
...
@implementation YYTextRunDelegate
- (CTRunDelegateRef)CTRunDelegate CF_RETURNS_RETAINED {
    CTRunDelegateCallbacks callbacks;
    callbacks.dealloc = DeallocCallback;
    callbacks.getAscent = GetAscentCallback;
    ...
    return CTRunDelegateCreate(&callbacks, (__bridge_retained void *)(self.copy));
}
...
複製代碼

使用CTRunDelegateCreate()建立一個CTRunDelegateRef,同時使用__bridge_retained轉移內存管理,持有一個YYTextRunDelegate對象。在該類中有數個靜態函數做爲回調,好比當回調GetAscentCallback()函數時,將持有對象的ascent屬性做爲返回值。

**注意一:**這樣作彷佛存在內存管理問題,CTRunDelegateRef實例持有的YYTextRunDelegate對象如何釋放? 答案就在CTRunDelegateRef釋放時會走的DeallocCallback()回調中,將內存管理權限轉移給一個YYTextRunDelegate局部變量自動管理內存。

**注意二:**能夠看到CTRunDelegateCreate(&callbacks, (__bridge_retained void *)(self.copy))代碼對self作了一個copy操做 (該類的 copy 爲深拷貝) ,這樣作是爲了什麼呢? 可能第一反應是想到CTRunDelegateRef持有self的副本是爲了不循環引用,然而該方法並無讓self持有CTRunDelegateCreate()後的實例,因此也不存在循環引用問題。 實際上這裏應該只是建立一個副本,當該方法返回後保證配置數據的安全性 (避免被外部意外更改)。

二、YYTextLine

建立一個富文本,能夠拿到CTLineRefCTRunRef以及一些結構數據 (好比ascent descent等),CTRunRef包含的數據內容並非不少,因此框架沒有專門作一個類來包裝它。使用YYTextLine來包裝CTLineRef計算保存一些數據便於後面的計算,好比使用CTLineGetTypographicBounds(...);方法來拿到ascent descent leading等。

計算 line 位置和大小

_bounds = CGRectMake(_position.x, _position.y - _ascent, _lineWidth, _ascent + _descent);
_bounds.origin.x += _firstGlyphPos;
複製代碼

_position是指 line 的origin點位於context上下文的座標轉換爲UIKit座標系的值,那麼結合上面的結構圖2分析:_position.y - _ascent就是 line 的最小y值,_ascent + _descent就是 line 高度(沒有算上行間距 leading)。

這裏最小x值加了一個_firstGlyphPos,它是當前 line 第一個 run 相對於 line 的偏移,經過CTRunGetPositions(...);算出,可能有一種場景,line 的origin位置與第一個 run 的位置有偏移(筆者並無模擬出這種狀況)。

找出全部的佔位 run

實際上這就是找出以前說的CTRunDelegateRef,框架每個CTRunDelegateRef都對應了一個YYTextAttachment,它表示一個附件(圖片、UIView、CALayer),具體實現後面會單獨講。這裏只須要知道基本原理就是用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);
複製代碼

_position上面已經說明了意義,runPosition是當前 run 相對於當前 line origin的偏移,那麼runPosition.x + _position.x表示了 run 相對於圖形上下文的x方向位置,後面同理。

最終,將這個YYTextAttachment附件對象和 run 位置大小信息緩存起來(後面會專門分析實現邏輯)。

三、YYTextContainer

建立CTFrameRef使用CTFramesetterCreateFrame(...)方法,這個方法須要一個CGPathRef參數,爲了使用簡便,框架抽象了一個YYTextContainer類重點屬性以下:

@property CGSize size;
@property UIEdgeInsets insets;
@property (nullable, copy) UIBezierPath *path;
@property (nullable, copy) NSArray<UIBezierPath *> *exclusionPaths;
複製代碼

使用者能夠簡單的使用CGSize來制定富文本的大小,也能夠用內存自動管理功能強大的UIBezierPath來制定路徑,同時包含一個exclusionPaths排除路徑。

┌─────────────────────────────┐  <------- container
     │                             │
     │    asdfasdfasdfasdfasdfa   <------------ container insets
     │    asdfasdfa   asdfasdfa    │
     │    asdfas         asdasd    │
     │    asdfa        <----------------------- container exclusion path
     │    asdfas         adfasd    │
     │    asdfasdfa   asdfasdfa    │
     │    asdfasdfasdfasdfasdfa    │
     │                             │
     └─────────────────────────────┘
複製代碼

CoreText 是支持鏤空效果的,就是由這個 exclusion path 控制。該類的屬性訪問都是線程安全的,還作了一些精緻的容錯。

3、YYTextLayout 核心計算類

YYTextLayout包含了佈局一個富文本幾乎全部的信息,同時還將衆多的繪製相關 C 代碼放在了這個文件裏面,因此這個文件很是龐大。咱們先無論這些繪製代碼,YYTextLayout主要的做用是計算各類數據,爲後面的繪製作準備。

核心計算在+ (YYTextLayout *)layoutWithContainer:(YYTextContainer *)container text:(NSAttributedString *)text range:(NSRange)range;初始化方法中,這個方法爲後面的各類查詢計算打下了數據基礎,接下來就分析一下這個超過 500 行的初始化方法作了些什麼。

一、計算繪製路徑和路徑的位置矩形

基於YYTextContainer對象計算獲得CGPathRef是主要邏輯,爲了不矩陣屬性出現負值,使用CGRectStandardize(...)來矯正。因爲 UIKit 和 CoreText 座標系的差異,最終獲得的矩陣要先作一個座標系翻轉:

rect = CGRectApplyAffineTransform(rect, CGAffineTransformMakeScale(1, -1));
cgPath = CGPathCreateWithRect(rect, NULL);
複製代碼

或者

CGAffineTransform trans = CGAffineTransformMakeScale(1, -1);
CGMutablePathRef transPath = CGPathCreateMutableCopyByTransformingPath(path, &trans);
複製代碼

它們道理是同樣的,都是沿着 x 軸翻轉座標系 180°,可能有人有疑問,UIKit 轉換爲 CoreText 座標系不是除了翻轉 180°,還要移動一個繪製區域高度麼?確實這裏少作了一個操做,那是由於框架是使用CTRunDraw(...)遍歷繪製 run,在繪製 run 以前會用CGContextSetTextPosition(...)指定位置(這個位置是 line 相對於繪製區域計算的),因此這個地方的 y 座標是否正確已經沒有意義了。

繪製路徑的矩形大小位置pathBox的計算:


好比這種狀況, pathBox = (CGRect){50, 50, 100, 100},可想而知 pathBox指的就是真正繪製區域相對於繪製上下文的位置和大小,這個數據很是有用,意味着後面計算 line 和 run 的位置時,都要加上 cgPathBox.origin偏移,才能真正表示 line 和 run 相對於繪製上下文的位置(好比 line 的 origin是相對於繪製區域的一個點,而不是相對於繪製上下文)。

二、初始化 CTFramesetterRef 和 CTFrameRef

這一步很簡單,利用兩個函數就搞定:CTFramesetterCreateWithAttributedString(...) CTFramesetterCreateFrame(...)。值得注意的是框架支持了幾個 CTFrameRef 的屬性,好比kCTFramePathWidthAttributeName,這些屬性一樣是經過YYTextContainer配置的。

三、計算 line 總 frame 和行數

前面已經建立了一個富文本CTFrameRef,那麼這裏只須要遍歷全部的 line 作計算,能夠看到以下代碼獲取每個 line 的位置大小:

// CoreText coordinate system
CGPoint ctLineOrigin = lineOrigins[i]; 
// UIKit coordinate system
CGPoint position;
position.x = cgPathBox.origin.x + ctLineOrigin.x;
position.y = cgPathBox.size.height + cgPathBox.origin.y - ctLineOrigin.y;

YYTextLine *line = [YYTextLine lineWithCTLine:ctLine position:position vertical:isVerticalForm];
CGRect rect = line.bounds;
複製代碼

lineOrigins是經過CTFrameGetLineOrigins(...)獲得的,因此須要轉換爲 UIKit 座標系方便計算。能夠看到轉換時作了一個cgPathBox.origin的偏移,這就是以前計算的實際繪製矩形的偏移,以此獲得的position就是相對於圖形上下文的點了,而後利用這個點初始化YYTextLine,前面講了YYTextLine的內部實現,這裏就直接獲得了當前 line 的位置和大小:rect

而後,利用CGRectUnion(...)函數將每個 line 的rect合併起來,獲得一個包含全部 line 的最小位置矩形textBoundingRect

計算 line 的行數

並非一個 line 就佔有一行,當有排除路徑時,一行可能有兩個 line:

因此,須要計算每一個 line 所在的行,便於爲後續的不少計算提供基礎,好比最大行限制。

噹噹前 line 的高度大於 last line 的高度時,若當前 line 的 y0 在 baseline 以上,y1 在 baseline 如下,就說明沒有換行。

噹噹前 line 的高度小於 last line 的高度時,若 last line 的 y0 在 baseline 以上,y1 在 baseline 如下,就說明沒有換行。

四、獲取行上下邊界數組

typedef struct {
    CGFloat head;
    CGFloat foot;
} YYRowEdge;
複製代碼

聲明瞭一個YYRowEdge *lineRowsEdge = NULL;數組,YYRowEdge表示每一行的上下邊界。計算邏輯大體是這樣的: 遍歷全部 line,噹噹前 line 和 last line 爲同一行時,取 line 和 last line 共同的最大上下邊界:

lastHead = MIN(lastHead, rect.origin.y);
lastFoot = MAX(lastFoot, rect.origin.y + rect.size.height);
複製代碼

噹噹前 line 和 last line 爲不一樣行時,取當前 line 的上下邊界:

lastHead = rect.origin.y;
lastFoot = lastHead + rect.size.height;
複製代碼

最終的結果多是這樣的:

foot1head2之間會存在一個間隙,這個間隙就是行間距,框架的處理是將這個間隙均分:

五、計算繪製區域總大小

上面已經計算了繪製路徑的位置矩形pathBox,這只是實際繪製區域的大小,業務中若設置了YYTextContainer的線寬或者邊距,那麼實際業務須要的繪製區域總大小會更大:

圖中藍色填充區域即爲實際繪製區域pathBox,繪製區域總大小應該是藍色邊框所覆蓋的範圍(請忽略線與線之間的小縫隙)。藉助CGRectInset(...) UIEdgeInsetsInsetRect(...)等函數能輕易的計算出來,一樣的須要用CGRectStandardize(...)糾正負值。

六、line 截斷

當富文本超過限制時,可能須要對最後一行可顯示的行末尾作一個省略號:aaaa...

首先有一個NSAttributedString *truncationToken;,這個 token 能夠自定義,框架也有默認的,就是一個...省略號,而後將這個truncationToken拼接到最後一個line

NSMutableAttributedString *lastLineText = [text attributedSubstringFromRange:lastLine.range].mutableCopy;
[lastLineText appendAttributedString:truncationToken];
複製代碼

固然,這樣lastLineText確定會超過繪製區域的範圍,因此要使用系統提供的方法CTLineCreateTruncatedLine(...)來建立自動計算的截斷 line,該方法返回一個CTLineRef,這裏轉換爲YYTextLine而且做爲YYTextLayout的一個屬性truncatedLine

這也就意味着,YYText 的截斷老是在富文本最後的,且只有一個。

七、緩存各類 BOOL 值

遍歷富文本對象,緩存一系列的 BOOL 值:

void (^block)(NSDictionary *attrs, NSRange range, BOOL *stop) = ^(NSDictionary *attrs, NSRange range, BOOL *stop) {
    if (attrs[YYTextHighlightAttributeName]) layout.containsHighlight = YES;
    if (attrs[YYTextBlockBorderAttributeName]) layout.needDrawBlockBorder = YES;
    if (attrs[YYTextBackgroundBorderAttributeName]) layout.needDrawBackgroundBorder = YES;
    if (attrs[YYTextShadowAttributeName] || attrs[NSShadowAttributeName]) layout.needDrawShadow = YES;
...
};
[layout.text enumerateAttributesInRange:visibleRange options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired usingBlock:block];
複製代碼

能夠猜想,YYTextBlockBorderAttributeName等就是 YYText 定製的富文本屬性,在初始化YYTextLayout時就將富文本中是否包含自定義 key 緩存起來。

想象一下,若此處不使用這些 BOOL 值,那麼在繪製的時候框架也須要去遍歷查找是否有自定義的 key,如有再執行自定義的繪製邏輯。也就是說,這個遍歷是必需要作的,要麼在初始化時作,要麼是繪製的時候作。

按照框架的設定,初始化YYTextLayout和繪製均可以在主線程也能夠在異步繪製執行,因此這裏的目的主要不是爲了將這個遍歷邏輯放入異步線程,而是爲了緩存。

初始化YYTextLayout時緩存這些 BOOL 值事後,二次繪製就不須要再遍歷了,以此達到優化性能的目的。

八、合併全部的附件

前面有講到,YYTextLine初始化時會將全部的附件及其相關位置信息裝到數組裏面,那麼這裏遍歷全部的 line 將附件相關數組合併到一塊兒,那麼以後的繪製就不須要再去遍歷 line 獲取附件了。

九、小結

除開YYTextLayout初始化方法,還有在#pragma mark - Query標記下的一系列查詢方法,這些查詢方法都是基於上面的初始化計算數據。至於#pragma mark - Draw標記下的繪製相關方法後面再說。

YYTextLayout初始化方法很是的長,筆者試圖將這個方法分解一下,發現這樣會更復雜。緣由是這個初始化方法裏面包含了衆多的須要手動管理的內存,好比CGPathRef CTFramesetterRef CTFrameRef等。

可能有人會說,哪一個地方須要引用計數減一,手動release不就好了?

可是實際狀況更加複雜,由於整個初始化過程隨時可能會被中斷。好比calloc(...)開闢內存可能會失敗,CGPathCreateMutableCopy(...)建立路徑可能會失敗,因此,在任何狀況失敗須要中斷初始化時,大概會以下寫:

if (failed) {
    CFRelease(...);
    free(...);
    ...
    return nil;
}
複製代碼

並且這個地方你必需要將前面全部手動管理的內存釋放掉,當這個代碼過多的時候,可能會讓你瘋掉。

因此做者用了一個很巧的方法,使用goto

fail:
    if (cgPath) CFRelease(cgPath);
    if (lineOrigins) free(lineOrigins);
    ...
    return nil;
複製代碼

那麼,當某個環節失敗時,直接這麼寫:

if (failed) {
    goto fail;
}
複製代碼

這個場景下,goto的使用確實很是適合。

4、自定義富文本屬性

前面有提到 YYText 定製的富文本屬性,

咱們知道,NSMutableAttributedString對象使用addAttribute:value:range:等一系列方法能夠添加富文本效果,這些效果有三個要素:名字 (key)、值 (value)、範圍。YYText 也拓展了一些本身的名字 (YYTextAttribute 文件):

UIKIT_EXTERN NSString *const YYTextAttachmentAttributeName;
UIKIT_EXTERN NSString *const YYTextHighlightAttributeName;
...
複製代碼

固然爲這些 key 都建立了對應的 value (類),好比YYTextHighlightAttributeName對應YYTextHighlight。可是這些自定義的 key CoreText 是識別不了的,那麼框架內部是如何處理的呢?

NSDictionary *attrs = (id)CTRunGetAttributes(run);
id anyValue = attrs[anyKey];
if (anyValue) { ... }
複製代碼

很簡單,實際上就是遍歷富文本,經過上面這段代碼就能找到某個 run 是否包含自定義的 key,而後作相應的繪製邏輯。

一、圖文混排實現

YYText 大部分的自定義屬性都算是「裝飾」文本,因此只須要繪製的時候判斷有沒有包含對應的 key,若包含就作相應的繪製邏輯。可是有一個自定義屬性比較特殊:

YYTextAttachmentAttributeName : YYTextAttachment
複製代碼

由於這個是添加一個附件 (UIImage、UIView、CALayer),因此須要一個空位,那麼設置這個自定義屬性的時候還須要設置一個CTRunDelegateRef

NSMutableAttributedString *atr = [[NSMutableAttributedString alloc] initWithString:YYTextAttachmentToken];

YYTextAttachment *attach = [YYTextAttachment new];
attach.content = content; // UIImage、UIView、CALayer
...
[atr yy_setTextAttachment:attach range:NSMakeRange(0, atr.length)];

YYTextRunDelegate *delegate = [YYTextRunDelegate new];
...
CTRunDelegateRef delegateRef = delegate.CTRunDelegate;
[atr yy_setRunDelegate:delegateRef range:NSMakeRange(0, atr.length)];
複製代碼

(1) 對齊方式

圖文混排添加圖片時,業務中每每有不少對齊方式,如何來對齊經過調整CTRunDelegateRefascent descent來控制,框架對其方式有三種:居上,居下,居中。

居上:

讓佔位 run 的ascent始終等於文本的ascent (若佔位 run 過矮則貼着 baseline) 。

居下:

讓佔位 run 的descent始終等於文本的descent (若佔位 run 過矮則貼着 baseline) 。

居中:

居中的計算相對複雜,須要讓佔位 run 的中點和文本的中點對齊 (如圖),那麼圖中yOffset + (佔位 run 的 height) * 0.5 就等於佔位 run 的ascent (若佔位 run 過矮則貼着 baseline) 。

固然,上面圖中的圖片能夠爲UIView CALayer。到目前爲止,佔位 run 的位置已經肯定了,接下來就須要把 UIImage UIView CALayer繪製到相應的空位上了。

(2) 繪製附件

繪製的邏輯在YYTextLayout下的方法YYTextDrawAttachment(...),對於UIImage圖片的附件,還能設置UIViewContentMode,會根據一開始設置的佔位 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);
}
複製代碼

若附件的類型是UIView CALayer,那分別就須要額外的傳入父視圖、父 layer:targetView targetLayer,而後的操做就是簡單的將UIView添加到targetView上或者將CALayer添加到targetLayer上。

二、點擊高亮實現

YYTextHighlightAttributeName : YYTextHighlight
複製代碼

YYTextHighlight包含了單擊和長按的回調,還包括一些屬性配置。在YYLabel中,經過下列方法來寫觸發邏輯:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
複製代碼

涉及到判斷點擊的CGPoint點對應富文本中的具體位置,因此有不少複雜的計算,這裏不展開了。

當找到了應該觸發的YYTextHighlight,更換具體的YYTextLine爲高亮狀態的YYTextLine,而後重繪。當手鬆開時,切換會常態下的YYTextLine

這就是點擊高亮的實現原理,實際上就是替換YYTextLine更新佈局。

5、異步繪製

上面介紹了幾種特殊的自定義富文本屬性,對於其它的自定義屬性,基本上都是使用 CoreGraphics API 繪製,好比邊框、陰影等,固然 CoreText 自帶有不少效果,YYText 作了一些改良和拓展。

能夠看到繪製方法都會帶有一個是否取消的 Block,好比static void YYTextDrawShadow(YYTextLayout *layout, CGContextRef context, CGSize size, CGPoint point, BOOL (^cancel)(void));。這個cancel就是用來判斷是否須要取消本次繪製,這樣就能在一次繪製的任意位置中斷,及時的取消無用的繪製任務以提升效率。

YYText 富文本能夠異步繪製,也能夠在主線程繪製,建立佈局類及其相關計算能夠在任意線程,能夠根據業務需求選擇適合的策略。

具體實現有些複雜,因此關於異步繪製的具體原理能夠看筆者專門的一篇博客: YYAsyncLayer 源碼剖析:異步繪製 YYAsyncLayer 就是從 YYText 裏面提取出來的組件,核心就是一個支持異步繪製的CALayer子類,相信看完 YYAsyncLayer 的解析會對異步繪製有較深的認識。

後語

YYText 確實過於重量,本文只是對基礎部分取重點作了解析,除此以外還有很是多的計算和邏輯,感興趣能夠自行研究。

從代碼質量來看,YYText 幾乎無可挑剔,細節處理很是棒,邏輯代碼很精煉,筆者嘗試太重寫部分邏輯代碼,發現優化半天又回到了源碼的寫法 😂,不得不佩服做者的功底。

至此,筆者已經閱讀了 YYKit 大部分源碼,曾屢次被做者的代碼技巧所折服,幾乎每一句代碼都經得起推敲,筆者也更加深入的理解了性能優化,明白了優化要從細節作起。

忽然想起了筆者和一位好友的笑梗。每逢佳時:

「這確實是一個很是巧妙且使人興奮的技巧」。

相關文章
相關標籤/搜索