利用 Combine 產生自動停止的 timer
開發 iOS App 時我們時常使用定時觸發的 timer & 接收通知的 notification center, 不過它們卻暗藏可怕的陷阱,有可能讓 App 的記憶體愈用愈多,進而引發 App 因記憶體爆掉而閃退。
接下來讓我們先看看 SwiftUI 的 timer 問題,然後再利用 Combine 產生定時發送 value 的 timer publisher,它不只可以解決記憶體問題,還會在畫面關閉時自動停止。
SwiftUI 的 timer 問題
假設第一頁是 ContentView,點選 button somewhere in time 將顯示使用 timer 的 TimerView。
struct ContentView: View {
@State private var showTimerView = false
var body: some View {
Button(action: {
self.showTimerView.toggle()
}) {
Text("somewhere in time")
.font(.largeTitle)
}
.sheet(isPresented: $showTimerView) {
TimerView()
}
}
}
我們想測試 TimerView 是否因 timer 造成它無法死掉,然而 TimerView 是 struct,因此我們無法在 TimerView 裡定義 deinit 檢查。因此我們另外定義 class TestObject,然後在 TimerView 宣告 property testObject。到時候若 TimerView 能順利死掉,我們將看到 TestObject 的 deinit 印出。
class TestObject {
deinit {
print("deinit")
}
}struct TimerView: View {
let testObject = TestObject()
@State private var number = 0
var body: some View {
VStack {
Image("pic")
Text("\(number)")
.font(.largeTitle)
}
.onAppear {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (_) in
self.number += 1
print(self.number)
}
}
}
}
當我們從 TimerView 回到 ContentView 時,我們發現 TimerView 的 timer 繼續勤勞地列印訊息,而且 TestObject 的 deinit 沒有觸發,因此很明顯地 timer 害 TimerView 無法死掉。
解決此問題有很多方法,以下我們介紹兩種方法,一種是在離開畫面時停止 timer,一種是利用 Combine。
在離開畫面時停止 timer
將 timer 儲存在 property timer,在 onDisappear 呼叫停止 timer 的 function invalidate。
struct TimerView: View {
let testObject = TestObject()
@State private var number = 0
@State private var timer: Timer?var body: some View {
VStack {
Image("pic")
Text("\(number)")
.font(.largeTitle)
}
.onAppear {
self.timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (_) in
self.number += 1
print(self.number)
}
}
.onDisappear {
self.timer?.invalidate()
}
}
}
使用 Combine 提供的 timer publisher
剛剛的方法需要另外呼叫停止 timer 的 invalidate,很容易不小心忘記。讓我們看看另一個更方便的方法,使用 Combine 提供的 timer publisher 產生能夠自動停止的 timer。
import SwiftUI
import Combinestruct TimerView: View {
let testObject = TestObject()
@State private var number = 0
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()var body: some View {
VStack {
Image("pic")
Text("\(number)")
.font(.largeTitle)
}
.onReceive(timer, perform: { (_) in
self.number += 1
print(self.number)
})
}
}
說明
- 產生 timer 的 publisher
combine 擴充了 timer 的功能,我們可以直接產生 timer 的 publisher,讓它定時發送 value。在此我們設定它每秒觸發一次。
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
由於 timer 的 publisher 屬於必須先連接才能發送 value 的 publisher,所以我們呼叫 autoconnect,讓它可以自動連接,方便之後接收 timer 發送的 value。
- 呼叫 onReceive(_:perform:) 接收 timer 每秒發送的 value
在 view 上呼叫 onReceive(_:perform:),傳入 publisher timer,接收 timer 每秒發送的 value,perform 參數傳入 timer 定時觸發的 closure 程式。
.onReceive(timer, perform: { (_) in
self.number += 1
print(self.number)
})
使用 onReceive(_:perform:) 定義 timer 觸發的 closure 將享有特別的好處,當 SwiftUI view 關閉後,timer 也會自動停止,不用另外呼叫 invalidate。
ps: 若是使用 combine 的 sink 接收 timer 發送的 value 則要處理記憶體的問題。
timer publisher 發送的 value 是時間
final public class TimerPublisher : ConnectablePublisher {
public typealias Output = Date
timer publisher 的型別是 TimerPublisher,從它的定義我們發現它發送的 value 型別是 Date,因此以下例子印出的 value 即是 timer 觸發的時間。
.onReceive(timer, perform: { (value) in
print(value)
})
在 UIKit controller 使用 timer publisher
在 UIKit controller 使用 timer publisher 也可以讓 timer 的程式更簡單。當 TimerViewController 畫面關閉時,timer 將自動停止,不用再呼叫 invalidate。不過利用 sink 接收 timer 發送的 value 時,記得要加上 capture list 的 [weak self],否則會有 reference cycle 問題,造成不死的 TimerViewController 和永不停止的 timer。
import UIKit
import Combineclass TimerViewController: UIViewController {
var cancellable: AnyCancellable? override func viewDidLoad() {
super.viewDidLoad()
cancellable = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink {[weak self] (_) in
guard let self = self else { return }
self.view.backgroundColor = UIColor(red: CGFloat.random(in: 0...1), green: 0, blue: 0, alpha: 1)
}
}}
SwiftUI NotificationCenter 的記憶體問題
同樣的,NotificationCenter 也會有類似的問題。比方以下程式加了接收鍵盤出現和消失通知的 closure,在 closure 裡用到 self,因此造成不死的 NotificationView。
struct NotificationView: View {
let testObject = TestObject()
@State private var keyboardShow = false
@State private var name = ""var body: some View {
VStack {
TextField("name", text: $name)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
Text("keyboard show \(keyboardShow.description)")
.font(.largeTitle)
}
.onAppear {
NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidShowNotification, object: nil, queue: .main) { (_) in
self.keyboardShow = true
}
NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidHideNotification, object: nil, queue: .main) { (_) in
self.keyboardShow = false
}
}
}
}
使用 Combine 提供的 NotificationCenter publisher
NotificationCenter 跟 timer 一樣可以產生 publisher,因此我們可使用同樣的招式修正記憶體問題。
NotificationCenter 的 publisher 將在發送通知時傳送 value 給 receiver,我們利用 onReceive 接收 NotificationCenter 發送的 value,在 perform 參數傳入的 closure 定義收到 notification 時觸發的程式。
struct NotificationView: View {
let testObject = TestObject()
@State private var keyboardShow = false
@State private var name = ""
let keyboardDidShowNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)
let keyboardDidHideNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)
var body: some View {
VStack {
TextField("name", text: $name)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
Text("keyboard show \(keyboardShow.description)")
.font(.largeTitle)
}
.onReceive(keyboardDidShowNotification, perform: { (_) in
self.keyboardShow = true
})
.onReceive(keyboardDidHideNotification, perform: { (_) in
self.keyboardShow = false
})
}
}