Matrix Code Rain及對Core Graphics繪制的優化
前情提要
在9月25號看到Kevin Chou的 這篇 介紹他開源的組件庫 PNChart 受到歡迎的文章時,我突然想到:對啊,這是個把自己喜好與技術積累結合起來的好途徑!之前總覺得往開源社區貢獻代碼需要超強的底層代碼功力,又不想仿寫已有的組件重復造輪子,這時我才剛剛意識到,上層的UI層面同樣需要優秀的貢獻——某種程度上講更加稀缺,畢竟同時對設計和代碼都有研究的程序員比較少。
恰好,一個做前端的朋友Fanta發來了一份他業余時間用HTML+JS寫著玩做的《黑客帝國》代碼雨效果的demo:
我覺得這個還挺有意思,搜了一下GitHub上還沒有做過的,于是便開始了編碼工作。
架構及軌跡生成
這是一個很簡單的小組件,所以基本架構也很簡單:
我們約定將每一條下落的軌跡都稱為一個 Track ,由一個 Generator 實例專門來生成,每隔指定的時間(顯然,隨機亦可)就新生成一條,加到 DataSource 中,并創建其對應的 CALayer 子類 CodeRainLayer 加到最底層的 UIView 上。
下落及軌跡清理
如何產生動畫呢?最開始自然想到用 CAAnimation 來做。
因為代碼太簡單,就不在這里寫了。
但是寫完個大概之后,運行起來卻發現不對勁:總感覺沒有電影里面酷。
問題出在哪里呢?我又從移動硬盤里翻出了那三部曲仔細地研究了一下,經過一幀一幀地探究,我找到了原因:
電影里面的代碼并不是在“下落”,如果你盯著一個字母看,會發現它根本就沒移動過位置(除去鏡頭本身的移動)。換句話說,整個空間是一個已經排列好的字母矩陣,而我們看到的表象是 一陣脈沖流過 而已。
所以最后改成的方案是由每個 Track 實例自帶的 Timer 負責驅動控制自身的下落(為表述方便我們依然沿用這個詞),當需要刷新時,通知其對應的 CodeRainLayer 實例( -setNeedsDisplay )進行重繪。至于如何重繪,由每個 CodeRainLayer 自行負責。
而當整條軌跡掉出屏幕的時候, Track 會檢測出邊界條件,然后把對應的 CALayer 執行 removeFromSuperlayer ,最后把自身從 DataSource 中清除。
階段性成果
OK, so far so good. 我們成功實現了整個的動畫效果,看起來也確實蠻酷的:
封裝
在把它傳到GitHub之前,還需要進行一些封裝。這里主要有兩方面的工作,一個是增加控制關鍵字來限制外界能接觸到的內部類和方法,另一個是將可調節的參數向外界暴露出來。
Access Control
在Swift 3中特地新加了 fileprivate 這個訪問權限,正好在這里可以用到。我們把不希望暴露給外界的類都加上這個限定關鍵字。
順便,Swift 3中的訪問權限依次是:
open,public,internal,fileprivate,private.
Configurable Parameters
在之前,組件中用到的所有參數都定義在了一個 struct 里:
fileprivate struct JSMatrixConstants {
static let maxGlowLength: Int = 3 // Characters
static let minTrackLength: Int = 8 // Characters
static let maxTrackLength: Int = 40 // Characters
static let charactersSpacing: CGFloat = 0.0 // pixel
static let characterChangeRate = 0.9
static let firstDropShowTime = 2.0 // Time between the First drop and the later
// Configurable
static let speed: TimeInterval = 0.15 // Seconds that new character pop up
static let newTrackComingLap: TimeInterval = 0.4
static let tracksSpacing: Int = 5
}</code></pre>
為了暴露其中的一些參數,我們在 CodeRainView 那里增加幾個變量:
var trackSpacing: Int
var newTrackComingLap: CGFloat
var speed: CGFloat
那么如果用戶不設置的時候呢?我們應該用回默認值。比如這樣:
var speed: CGFloat = CGFloat(JSMatrixConstants.speed){
didSet{
datasource.speed = TimeInterval(speed)
}
}
var newTrackComingLap: CGFloat = CGFloat(JSMatrixConstants.newTrackComingLap){
didSet{
datasource.newTrackComingLap = TimeInterval(newTrackComingLap)
}
}
var trackSpacing: Int = JSMatrixConstants.tracksSpacing{
didSet{
datasource.trackSpacing = trackSpacing
}
}
而一個2016年的UI組件應當是Interface Builder-Friendly的——尤其是,要做到這點只需舉手之勞:將上面的參數聲明為 @IBInspectable 。
最后在IB中看到的效果是:

優化性能
在我的iPhone6s上測試時,整個組件的表現沒什么大問題;但在比較老的iPhone5s上測試時,就有點吃力了。雖然畫面依然比較流暢,在CPU監測中能明顯看出占用:

而在我后面想結合一些 CoreMotion 的回調實現 視角縮放 效果時,在5s上的畫面終于卡了起來。
之所以會卡很容易理解,整個組件在主線程中進行了大量的繪制工作,擱你你也卡。
繪制UIView最快的方法就是把它當成imageview,我們把需要用Core Graphic繪制的代碼放到另一個線程中去繪制,生成image后直接賦值給view,達到異步繪制的目的。
我試了一下,差不多是這樣:
let track = self.track
DispatchQueue.global().async {
let size = self.bounds.size
UIGraphicsBeginImageContext(size)
context.saveGState()
... // Calculate positions, etc.
context.restoreGState()
self.render(in: context)
let resultImage = UIGraphicsGetImageFromCurrentImageContext();
DispatchQueue.main.async {
if let image = resultImage{
self.contents = image.cgImage
}
}
UIGraphicsEndImageContext()
}</code></pre>
但這樣做有問題:在每一次更新的時候,這個Layer需要在空白的背景下進行繪制,而直接調用 self.render(in: context) 方法,繪制的內容會疊加在當前顯示的內容之上,出來的效果是不可用的。(截圖過于殘暴,從略)
那么怎么解決這個問題呢?一個直接的想法是,如果能在一個新的context上繪制就好了。
帶著這個目標去搜索,在 這個文章 里面介紹了創建context的方法,于是上面的代碼變成了:
let track = self.track
DispatchQueue.global().async {
let size = self.bounds.size
UIGraphicsBeginImageContext(size)
/* Create drawing context */
let colorSpace = CGColorSpaceCreateDeviceRGB()
let createdContext = CGContext(data: nil, width: Int(size.width), height: Int(size.height), bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
if let context = createdContext{
context.saveGState()
... // calc positions, etc.
context.restoreGState()
self.render(in: context)
let resultImage = UIGraphicsGetImageFromCurrentImageContext();
DispatchQueue.main.async {
if let image = resultImage{
self.contents = image.cgImage
}
}
}
UIGraphicsEndImageContext()
}</code></pre>
優化結果
搞定了這些之后興沖沖地在5s上跑了一下,發現除了線程多了一些之外,差別幾乎不可見:

細想一下也可以理解,我們并沒有減少任何繪制的工作量,只不過是把它們移到了后臺線程而已。
那么接下來的問題是,在為主線程減了這么多負之后,程序的響應性能有提高嗎?因為要是再沒什么變化的話,我要為前面這些花出的時間哭幾秒。
接下來我搜到了一篇講述如何測量程序響應性的 文章 ,還附了源碼的截圖,非常良心。
fileprivate class PingThread: Thread{
var pingTaskIsRunning = false
var semaphore = DispatchSemaphore(value: 0)
override func main(){
while !self.isCancelled{
pingTaskIsRunning = true
DispatchQueue.main.async {
self.pingTaskIsRunning = false
self.semaphore.signal()
}
Thread.sleep(forTimeInterval: 1/30.0)
if pingTaskIsRunning {
NSLog("Delayed!")
}
_ = semaphore.wait(timeout: DispatchTime.distantFuture)
}
}
}
核心思想是,每隔一定的時間就在主線程給該線程的信號量發消息,要是主線程因為卡頓耽擱了,該線程就會輸出警告信息。
我把時間設為1/30秒,因為這是一個流暢的動畫所應當達到的幀率。
這下終于有了喜人的對比結果:
之前:

之后:

直到啟動20多秒后收到內存警告,都沒有一次卡頓出現!
雖然我不是一個使用meme表情控,但看國外的blog看多了之后,總覺得在這種情況下需要出現一個表情……
就是下面這個:

最后的話
通過這個項目,我學到的東西包括:
- Core Graphic的一些深入內容
- 一些之前用不到的封裝策略
- 一個優化繪制性能的方法
- 一個測量程序響應性能的方法
接下來又想到一個比較有趣的項目,不知道什么時候能填坑。
感謝觀賞。
來自:http://www.jianshu.com/p/b38ef781e2c5