利用 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 Combine
struct 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 Combine
class 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

})


}
}

--

--

彼得潘的 iOS App Neverland
彼得潘的 Swift iOS App 開發問題解答集

彼得潘的iOS App程式設計入門,文組生的iOS App程式設計入門講師,彼得潘的 Swift 程式設計入門,App程式設計入門作者,http://apppeterpan.strikingly.com