YYKit 系列原始碼剖析文章:
- YYText 原始碼剖析:CoreText 與非同步繪製
- YYModel 原始碼剖析:關注效能
- YYCache 原始碼剖析:一覽亮點
- YYImage 原始碼剖析:圖片處理技巧
- YYAsyncLayer 原始碼剖析:非同步繪製
- YYWebImage 原始碼剖析:執行緒處理與快取策略
前言
YYText 是業界知名富文字框架,基於 CoreText 做了大量基礎設施並且實現了兩個上層檢視元件:YYLabel 和 YYTextView。同其它 YYKit 元件一樣,YYText 在效能方面表現優異,且功能出奇的強大,可以說是業界巔峰之作。
提起 YYText,都知道它的核心優化點:非同步繪製,然而這只是冰山一角,YYText 中最為複雜和篇幅最多的是基於 CoreText 的各種計算,不得不說,原始碼中大量的計算很容易讓人眼花繚亂。
若想深入理解 YYText 或者看懂本文,必須要了解 CoreText 基礎知識並且有足夠的耐心。框架程式碼量非常大,本文主要講解框架基於 CoreText 的底層基礎部分,不會過多的講解 YYLabel 和 YYTextView 的細節。
一、框架總覽
iOS UI 元件大都必須在主執行緒繪製,當繪製壓力過大會造成介面卡頓,得益於多執行緒技術,我們可以在非同步執行緒繪製圖形從而減輕主執行緒壓力。
YYText 核心思路:在非同步執行緒建立圖形上下文,然後利用 CoreText 繪製富文字,利用 CoreGraphics 繪製圖片、陰影、邊框等,最後將繪製完成的點陣圖放到主執行緒顯示。
步驟看起來很簡單,原始碼中涉及到 CoreText 和 CoreGraphics 的繪製時需要大量的程式碼來計算位置,這也是本文的重點之一。為了簡潔易懂,筆者會略過一些技術細節,比如縱向文字佈局邏輯,一些奇怪的 BUG 修復程式碼。
希望讀者朋友優先了解 CoreText 基礎 (CoreText 官方介紹),這裡放上兩個結構圖便於理解(圖會有偏差):
二、CoreText 相關工具類
1、YYTextRunDelegate
在富文字中插入 key 為kCTRunDelegateAttributeName
的CTRunDelegateRef
例項可以定製一段區域的大小,通常使用這個方式來預留出一段空白,後面可以填充圖片來達到圖文混排的效果。而建立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
建立一個富文字,可以拿到CTLineRef
和CTRunRef
以及一些結構資料 (比如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
的計算:
比如這種情況,
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:
所以,需要計算每個 line 所在的行,便於為後續的很多計算提供基礎,比如最大行限制。
噹噹前 line 的高度大於 last line 的高度時,若當前 line 的 y0 在 baseline 以上,y1 在 baseline 以下,就說明沒有換行。
噹噹前 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;
複製程式碼
最終的結果可能是這樣的:
foot1
和head2
之間會存在一個間隙,這個間隙就是行間距,框架的處理是將這個間隙均分:
5、計算繪製區域總大小
上面已經計算了繪製路徑的位置矩形pathBox
,這只是實際繪製區域的大小,業務中若設定了YYTextContainer
的線寬或者邊距,那麼實際業務需要的繪製區域總大小會更大:
圖中藍色填充區域即為實際繪製區域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) 對齊方式
圖文混排新增圖片時,業務中往往有很多對齊方式,如何來對齊通過調整CTRunDelegateRef
的ascent 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
上。
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 大部分原始碼,曾多次被作者的程式碼技巧所折服,幾乎每一句程式碼都經得起推敲,筆者也更加深刻的理解了效能優化,明白了優化要從細節做起。
突然想起了筆者和一位好友的笑梗。每逢佳時:
“這確實是一個非常巧妙且令人興奮的技巧”。