利用 capture list 解決 closure 可能產生的記憶體問題
開發 iOS App 時,我們時常使用強大的 closure 語法。然而就像 spider man,能力愈大,責任愈大,強大的 closure 有時會產生麻煩的記憶體問題。
接下來就讓我們瞧瞧 closure 如何產生記憶體問題吧。
增加物件 reference count 的 closure
- Dog 類別
import Foundation
import os
let logger = Logger()
class Dog {
func run() {
logger.log("追追追")
}
deinit {
logger.log("主人,我即將死去,謝謝你愛過我")
}
}
為了顯示訊息列印的時間,我們利用 Logger 列印訊息。
- ViewController 類別
import UIKit
class ViewController: UIViewController {
func play() {
let cuteDog = Dog()
logger.log("小狗出生")
Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { timer in
cuteDog.run()
}
logger.log("function play 結束")
}
override func viewDidLoad() {
super.viewDidLoad()
play()
}
}
呼叫 play,在 play 裡產生 Dog,然後設定 timer 5 秒後觸發。
結果。
記得要勾選 Timestamp 才能看到時間。
從印出的結果,我們發現 viewDidLoad 執行完時是 22:11:24,然而小狗死亡的訊息卻在 5 秒後(22:11:29),timer 的 closure 程式執行完,小狗跑步印出”追追追”後才出現。看來小狗會等到 closure 執行完才死去,這和我們的直覺相反。照理說指到小狗的 cuteDog 在 function play 裡宣告,在 play 執行完 cuteDog 被清掉時,小狗就該死去呀。
小狗,為什麼你還不死呢 ~
小狗能繼續活著的救命恩人正是 closure,因為我們在 closure 裡使用 cuteDog,所以它的 reference count 將被增加,確保到時 closure 執行時,cuteDog 還活著,等到 timer 的 closure 5 秒後跑完時小狗才會死。
Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { timer in
cuteDog.run()
}
因此當我們在 closure 裡使用物件時,它將對物件產生兩個重要的影響:
- 增加物件的 reference count。
- 當 closure 的程式執行完,不會再執行時,物件的 reference count 才會減少。
相反的,若是 timer 的 closure 裡沒有使用 cuteDog,將不會增加小狗的 reference count,因此小狗將在 play 執行完時死去。
func play() {
let cuteDog = Dog()
logger.log("小狗出生")
Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { timer in
logger.log("timer 結束")
}
logger.log("function play 結束")
}
結果
2021-01-08 02:14:00.163748+0800 Demo2[12009:715842] 小狗出生
2021-01-08 02:14:00.163984+0800 Demo2[12009:715842] function play 結束
2021-01-08 02:14:00.164083+0800 Demo2[12009:715842] 主人,我即將死去,謝謝你愛過我
2021-01-08 02:14:05.165119+0800 Demo2[12009:715842] timer 結束
Swift 如此貼心的舉動,我們很感激。但這不一定是我們要的,如果你希望 closure 不要增加 cuteDog 的 reference count,capture list 將是你的救星。利用 capture list,我們可以告訴 closure,某某變數(常數)不關心(在乎)物件,請不要增加物件的 reference count。
利用 capture list 阻止 closure 增加物件的 reference count
capture list 的表達方式很簡單,在 closure 的 { } 裡,一開頭加上 [ ],在其中指定不關心物件的變數(常數),並搭配 unowned 或 weak,若有多個變數(常數),只要以逗號分隔即可。
因此剛剛的例子,只要加上 [weak cuteDog]
,closure 將不再增加 cuteDog 的 reference count。
func play() {
let cuteDog = Dog()
logger.log("小狗出生")
Timer.scheduledTimer(withTimeInterval: 5, repeats: false) {[weak cuteDog] timer in
cuteDog?.run()
}
logger.log("function play 結束")
}
結果:
2021-01-07 23:40:10.226145+0800 Demo2[10909:559761] 小狗出生2021-01-07 23:40:10.226305+0800 Demo2[10909:559761] function play 結束2021-01-07 23:40:10.226394+0800 Demo2[10909:559761] 主人,我即將死去,謝謝你愛過我
加了 capture list [weak cuteDog]
後,function play 執行結束時,小狗也跟著死去,因此 closure 5 秒後執行時,cuteDog 已變成 nil(因為 weak 的關係),不會執行 function run,追追追
也不會印出。
搭配 guard let
當我們在 capture list 使用 weak 時,closure 執行時物件可能已經死去,因此以下例子的 run,eat & sleep 可能都不會執行,而且呼叫時還要搭配 optional chaining。(因為 weak 將讓 cuteDog 變成 optional)
func play() {
let cuteDog = Dog()
logger.log("小狗出生")
Timer.scheduledTimer(withTimeInterval: 5, repeats: false) {[weak cuteDog] timer in
cuteDog?.run()
cuteDog?.eat()
cuteDog?.sleep()
}
logger.log("function play 結束")
}
如果想在發現物件死掉時提早離開 closure,我們也可以搭配 guard let,例如以下例子:
func play() {
let cuteDog = Dog()
logger.log("小狗出生")
Timer.scheduledTimer(withTimeInterval: 5, repeats: false) {[weak cuteDog] timer in
guard let cuteDog else { return }
cuteDog.run()
cuteDog.eat()
cuteDog.sleep()
}
logger.log("function play 結束")
}
closure 不一定會產生 strong reference cycle & memory leak
在剛剛的例子,我們見證了沒有 capture list 時,closure 會增加物件的 reference count。不過這不代表 closure 一定會產生 strong reference cycle & memory leak。
就像世上有壞人,的確有產生 reference cycle 的 closure。不過這個世界還是好人較多,所以大部分的 closure 並不會產生 strong reference cycle & memory leak,讓我們看看以下 view controller 的例子。
假設 storyboard 畫面的設計如下,第二頁是 ChangeColorViewController。
ChangeColorViewController 的程式如下,它將在出現後 5 秒左右改變顏色。
import UIKit
import os
class ChangeColorViewController: UIViewController {
let logger = Logger()
deinit {
logger.log("deinit")
}
override func viewDidLoad() {
super.viewDidLoad()
Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { timer in
self.changeColor()
}
logger.log("viewDidLoad 結束")
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
logger.log("viewDidDisappear 結束")
}
func changeColor() {
view.backgroundColor = .blue
logger.log("changeColor")
}
}
實驗1:
進入第二頁的 ChangeColorViewController,看到它變色後再欣賞幾秒才返回第一頁。
2021-01-08 00:20:53.541904+0800 Demo2[11221:596157] viewDidLoad 結束2021-01-08 00:20:58.541254+0800 Demo2[11221:596157] changeColor2021-01-08 00:21:03.227026+0800 Demo2[11221:596157] viewDidDisappear 結束2021-01-08 00:21:03.227557+0800 Demo2[11221:596157] deinit
返回第一頁時 ChangeColorViewController 順利死去,在印出 viewDidDisappear 後印出 deinit。
實驗2:
進入第二頁的 ChangeColorViewController,還沒看到它變色就返回第一頁。
2021-01-08 00:28:38.189568+0800 Demo2[11430:614019] viewDidLoad 結束
2021-01-08 00:28:39.818943+0800 Demo2[11430:614019] viewDidDisappear 結束
2021-01-08 00:28:43.190050+0800 Demo2[11430:614019] changeColor
2021-01-08 00:28:43.190391+0800 Demo2[11430:614019] deinit
當我們返回第一頁時(viewDidDisappear 觸發),ChangeColorViewController 還沒死去,因為我們在 timer 的 closure 裡使用 self
,也就是 ChangeColorViewController,增加了它的 reference count。
override func viewDidLoad() {
super.viewDidLoad()
Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { timer in
self.changeColor()
}
logger.log("viewDidLoad 結束")
}
當 timer 在 5 秒後觸發時,ChangeColorViewController 的畫面已看不到,但 ChangeColorViewController 還活得好好的,所以它會盡責地呼叫 changeColor,完成 timer closure 的程式後才安心地死去。
從以上的實驗,我們發現 closure 雖然會增加 controller 的 reference count,但它並沒有產生 reference cycle & memory leak,因此沒有加上 [weak self]
也是 ok 的。
若要雞蛋裡挑骨頭,剛剛的程式的確有缺點,closure 延長了 ChangeColorViewController 存活的時間。若是覺得返回第一頁時,ChangeColorViewController 已經沒有利用價值,反正都看不到了,不需要改變顏色。那麼我們可以加上 [weak self],在返回第一頁時 ChangeColorViewController 將馬上死去。當 timer 在 5 秒後觸發 closure 時,self 將變成 nil,不會執行 changeColor。
override func viewDidLoad() {
super.viewDidLoad()
Timer.scheduledTimer(withTimeInterval: 5, repeats: false) {[weak self] timer in
self?.changeColor()
}
logger.log("viewDidLoad 結束")
}
結果
2021-01-08 01:00:56.581679+0800 Demo2[11635:647801] viewDidLoad 結束2021-01-08 01:00:58.056172+0800 Demo2[11635:647801] viewDidDisappear 結束2021-01-08 01:00:58.056705+0800 Demo2[11635:647801] deinit
網路抓資料是另一個常見的 closure 搭配 [weak self]
的例子,接下來就讓我們實驗看看吧。
URLSession 網路抓資料延長了物件的生命,沒抓到前不準死
storyboard 的畫面如下,第二頁的 PhotoViewController 將抓取網路上的圖片。
為了容易看出實驗的效果,我們特別抓取 size 6.7 MB 的極光美圖,增加抓圖所需的時間。
import UIKit
import os
class PhotoViewController: UIViewController {
@IBOutlet weak var imageView: UIImageView!
let logger = Logger()
deinit {
logger.log("deinit")
}
override func viewDidLoad() {
super.viewDidLoad()
let url = URL(string: "https://images.pexels.com/photos/3617500/pexels-photo-3617500.jpeg?crop=entropy&cs=srgb&dl=pexels-benjamin-suter-3617500.jpg&fit=crop&fm=jpg&h=6583&w=5266")!
URLSession.shared.dataTask(with: url) { data, response, error in
if let data,
let image = UIImage(data: data) {
DispatchQueue.main.async {
self.imageView.image = image
self.logger.log("抓到圖後顯示")
}
}
}.resume()
logger.log("viewDidLoad 結束")
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
logger.log("viewDidDisappear 結束")
}
}
實驗:
進入第二頁 PhotoViewController 時,我們沒有耐心等待,圖還沒抓到就返回前一頁。
2021-01-08 01:13:34.671160+0800 Demo2[11713:662517] viewDidLoad 結束2021-01-08 01:13:34.839266+0800 Demo2[11713:662638] [] nw_protocol_get_quic_image_block_invoke dlopen libquic failed2021-01-08 01:13:36.471529+0800 Demo2[11713:662517] viewDidDisappear 結束2021-01-08 01:13:49.697862+0800 Demo2[11713:662517] 抓到圖後顯示2021-01-08 01:13:49.698021+0800 Demo2[11713:662517] deinit
我們在 1:13:36 返回第一頁,然而 PhotoViewController 要等到圖片抓到後的 01:13:49 才死去,因為 function dataTask 的 closure 裡使用了 self,增加了 PhotoViewController 的 reference count。
加上 [weak self] 讓 controller 提早死去
當圖片抓到或抓取失敗,closure 執行完時,PhotoViewController 終會死去。不過由於 controller 時常佔據許多記憶體,若想早點回收它的記憶體,而且覺得它都已經看不到了,繼續等待抓到圖後更新 image view 毫無意義,也可以加上 [weak self]
,如此返回第一頁時 PhotoViewController 將立即死去。若在抓到圖前返回第一頁,PhotoViewController 將立即死去,因此 closure 執行時將發現 self 為 nil,在 guard else 提早離開 closure。
override func viewDidLoad() {
super.viewDidLoad()
let url = URL(string: "https://images.pexels.com/photos/3617500/pexels-photo-3617500.jpeg?crop=entropy&cs=srgb&dl=pexels-benjamin-suter-3617500.jpg&fit=crop&fm=jpg&h=6583&w=5266")!
URLSession.shared.dataTask(with: url) {[weak self] data, response, error in
guard let self else { return }
if let data,
let image = UIImage(data: data) {
DispatchQueue.main.async {
imageView.image = image
logger.log("抓到圖後顯示")
}
}
}.resume()
logger.log("viewDidLoad 結束")
}
ps: 當 weak self 已經被 unwrapped 後,我們可以在接下來的程式省略 self。相關說明可參考以下連結。
結果
2021-01-08 01:22:37.242643+0800 Demo2[11755:671780] viewDidLoad 結束
2021-01-08 01:22:37.370116+0800 Demo2[11755:671875] [] nw_protocol_get_quic_image_block_invoke dlopen libquic failed
2021-01-08 01:22:38.891981+0800 Demo2[11755:671780] viewDidDisappear 結束
2021-01-08 01:22:38.892531+0800 Demo2[11755:671780] deinit
解決 Timer 重覆執行造成的記憶體問題
剛剛我們看到的 timer 例子並不會產生記憶體問題,因為它的參數 repeat repeats 為 false,只會執行一次。然而當 repeats 為 true,timer 會重覆執行時,將可能產生記憶體問題,甚至讓 controller 永遠不死。
此問題的解法關鍵在於 timer 的 invalidate,詳情可參考以下連結。