YYText 原始碼剖析:CoreText 與非同步繪製

波兒菜發表於2019-07-30

YYKit 系列原始碼剖析文章:

前言

YYText 是業界知名富文字框架,基於 CoreText 做了大量基礎設施並且實現了兩個上層檢視元件:YYLabel 和 YYTextView。同其它 YYKit 元件一樣,YYText 在效能方面表現優異,且功能出奇的強大,可以說是業界巔峰之作。

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

若想深入理解 YYText 或者看懂本文,必須要了解 CoreText 基礎知識並且有足夠的耐心。框架程式碼量非常大,本文主要講解框架基於 CoreText 的底層基礎部分,不會過多的講解 YYLabel 和 YYTextView 的細節。

一、框架總覽

YYText GitHub

iOS UI 元件大都必須在主執行緒繪製,當繪製壓力過大會造成介面卡頓,得益於多執行緒技術,我們可以在非同步執行緒繪製圖形從而減輕主執行緒壓力。

YYText 核心思路:在非同步執行緒建立圖形上下文,然後利用 CoreText 繪製富文字,利用 CoreGraphics 繪製圖片、陰影、邊框等,最後將繪製完成的點陣圖放到主執行緒顯示。

YYText 原始碼剖析:CoreText 與非同步繪製

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

希望讀者朋友優先了解 CoreText 基礎 (CoreText 官方介紹),這裡放上兩個結構圖便於理解(圖會有偏差):

結構圖1

結構圖2

二、CoreText 相關工具類

1、YYTextRunDelegate

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

@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()後的例項,所以也不存在迴圈引用問題。 實際上這裡應該只是建立一個副本,當該方法返回後保證配置資料的安全性 (避免被外部意外更改)。

2、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 位置大小資訊快取起來(後面會專門分析實現邏輯)。

3、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 控制。該類的屬性訪問都是執行緒安全的,還做了一些精緻的容錯。

三、YYTextLayout 核心計算類

YYTextLayout包含了佈局一個富文字幾乎所有的資訊,同時還將眾多的繪製相關 C 程式碼放在了這個檔案裡面,所以這個檔案非常龐大。我們先不管這些繪製程式碼,YYTextLayout主要的作用是計算各種資料,為後面的繪製做準備。

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

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

基於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的計算:

YYText 原始碼剖析:CoreText 與非同步繪製

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

2、初始化 CTFramesetterRef 和 CTFrameRef

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

3、計算 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:

YYText 原始碼剖析:CoreText 與非同步繪製

所以,需要計算每個 line 所在的行,便於為後續的很多計算提供基礎,比如最大行限制。

YYText 原始碼剖析:CoreText 與非同步繪製

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

YYText 原始碼剖析:CoreText 與非同步繪製

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

4、獲取行上下邊界陣列

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;
複製程式碼

最終的結果可能是這樣的:

YYText 原始碼剖析:CoreText 與非同步繪製

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

YYText 原始碼剖析:CoreText 與非同步繪製

5、計算繪製區域總大小

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

YYText 原始碼剖析:CoreText 與非同步繪製

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

6、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 的截斷總是在富文字最後的,且只有一個。

7、快取各種 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 值過後,二次繪製就不需要再遍歷了,以此達到優化效能的目的。

8、合併所有的附件

前面有講到,YYTextLine初始化時會將所有的附件及其相關位置資訊裝到陣列裡面,那麼這裡遍歷所有的 line 將附件相關陣列合併到一起,那麼之後的繪製就不需要再去遍歷 line 獲取附件了。

9、小結

除開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的使用確實非常適合。

四、自定義富文字屬性

我們知道,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,然後做相應的繪製邏輯。

1、圖文混排實現

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來控制,框架對其方式有三種:居上,居下,居中。

居上:

YYText 原始碼剖析:CoreText 與非同步繪製

讓佔位 run 的ascent始終等於文字的ascent (若佔位 run 太矮則貼著 baseline) 。

居下:

YYText 原始碼剖析:CoreText 與非同步繪製

讓佔位 run 的descent始終等於文字的descent (若佔位 run 太矮則貼著 baseline) 。

居中:

YYText 原始碼剖析:CoreText 與非同步繪製

居中的計算相對複雜,需要讓佔位 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上。

2、點選高亮實現

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更新佈局。

五、非同步繪製

上面介紹了幾種特殊的自定義富文字屬性,對於其它的自定義屬性,基本上都是使用 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 大部分原始碼,曾多次被作者的程式碼技巧所折服,幾乎每一句程式碼都經得起推敲,筆者也更加深刻的理解了效能優化,明白了優化要從細節做起。

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

“這確實是一個非常巧妙且令人興奮的技巧”。

相關文章