iOS Design Patterns | #3 實作 Combine
前言
在 iOS 上套用 MVVM design patterns 時,會需要使用 Combine 來進行 data binding,因此透過這次機會來仔細研究 Apple 的開發者文件,做一些重點整理和心得筆記,若有錯誤也請指正。
目錄
- 什麼是 Combine?
- Publisher
Future
Just - Foundation Publisher
Timer
NotificationCenter
URLSession - Custom Publisher
Published
AnyPublisher
PassthroughSubject
CurrentValueSubject - Operator
- Subscriber
sink(receiveValue:)
sink(receiveCompletion:receiveValue:)
assign(to:on:) - 實作
什麼是 Combine?
Combine 是 Apple 在 WWDC 2019 發表的 Functional Reactive Programming (FRP) 的函式庫,雖然 FRP 的概念在網頁開發上已經很普遍,但以前在 iOS 開發上處理非同步資料時,只能依賴第三方函式庫 RxSwift 或 ReactiveSwift。
在 Combine 的發表後,開發者又多一個選項可以處理非同步資料,加上 SwiftUI 大量的使用 Combine,因此現在來學習 Combine 會是一個很棒的機會。
The Combine framework provides a declarative Swift API for processing values over time. These values can represent many kinds of asynchronous events. Combine declares publishers to expose values that can change over time, and subscribers to receive those values from the publishers.
如同 Apple 官方文件說明,最有趣的地方是 values over time,我會解釋為數據流(value stream),或是說「隨著非同步事件發生變化的數值」。
Combine 提供 Swift API 來處理 target/action, notification center, URLSession, Key-Value Observing, Ad-hoc callbacks.. 等各種非同步事件產生的數據流(value stream),並透過 Publisher 和 Subscriber 來做 data binding,即時地更新資料。
Combine 分成三個主要的概念:發佈者(Publisher), 運算符(Operator), 訂閱者 (Subscriber)。
為了避免因 Apple 命名造成讀者混淆,以下若提及概念會以中文來說明,若針對資料型別說明會以英文來顯示。
發佈者是發佈資料的人。
訂閱者是接收資料的人。
運算符是先接收從發佈者過來的資料,進行一些資料處理,再將處理好的資料重新發佈給訂閱者,當然不一定要有運算符,可有可無。
Publisher
發佈者可以觸發事件來發佈資料給一個或數個訂閱者,而發佈的資料型別需要遵從 protocol Publisher
。
protocol Publisher<Output, Failure>
以下為比較常見的發佈資料型別。
Future
Future
會回傳資料型別 Future.Promise
,其實就是 Result<Success, Failure>
,會有兩種結果:
若 .success
,則回傳 Output
,可能是 Int, String, Bool, Array, Object..。
若 .failure
,則回傳 Failure
,可能是 Error, Never。
final class Future<Output, Failure> where Failure : Error
func generateAsyncRandomNumberFromFuture() -> Future <Int, Never> {
return Future() { promise in
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
let number = Int.random(in: 1...10)
promise(Result.success(number))
}
}
}
Just
只回傳一次結果或不可能失敗時,可以使用 Just。
struct Just<Output>
這裡只列出幾個,更多可以參考官方文件。
Foundation Publisher
Apple 中的 Timer
, NotificationCenter
和 URLSession
都能使用 Combine 發佈資料。
Timer
以下為相同功能的程式碼,差異在是否使用 Combine 框架。
// Without Combine
var timer: Timer?
override func viewDidLoad() {
super.viewDidLoad()
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.myDispatchQueue.async() {
self.myDataModel.lastUpdated = Date()
}
}
}
// With Combine
import Combine
var cancellable: Cancellable?
override func viewDidLoad() {
super.viewDidLoad()
cancellable = Timer.publish(every: 1, on: .main, in: .default)
.autoconnect()
.receive(on: myDispatchQueue)
.assign(to: \.lastUpdated, on: myDataModel)
}
更多說明可以參考官方文件。
NotificationCenter
以下為相同功能的程式碼,差異在是否使用 Combine 框架。
// Without Combine
var notificationToken: NSObjectProtocol?
override func viewDidLoad() {
super.viewDidLoad()
notificationToken = NotificationCenter.default
.addObserver(forName: UIDevice.orientationDidChangeNotification,
object: nil,
queue: nil) { _ in
if UIDevice.current.orientation == .portrait {
print ("Orientation changed to portrait.")
}
}
}
// With Combine
var cancellable: Cancellable?
override func viewDidLoad() {
super.viewDidLoad()
cancellable = NotificationCenter.default
.publisher(for: UIDevice.orientationDidChangeNotification)
.filter() { _ in UIDevice.current.orientation == .portrait }
.sink() { _ in print ("Orientation changed to portrait.") }
}
Custom Publisher
當然也可以使用 Combine 提供的型別,來自定義發佈者,例如 Published
, AnyPublisher
, PassthroughSubject
, CurrentValueSubject
。
Published
Published
可以將 property 轉換成發佈者,因此每當 property 的數值發生變化時,會發佈資料給訂閱者,且要注意的是,Published
只能在 class 的 property 使用。
class Weather {
@Published var temperature: Double
init(temperature: Double) {
self.temperature = temperature
}
}
let weather = Weather(temperature: 20)
cancellable = weather.$temperature
.sink() {
print ("Temperature now: \($0)")
}
weather.temperature = 25
// Prints:
// Temperature now: 20.0
// Temperature now: 25.0
AnyPublisher
AnyPublisher
實作 protocol Publisher
,可以包裝其他發佈者發布的資料,
class Weather {
@Published var temperature: Double = 0
@Published var humidity: Double = 0
var weatherMessage: AnyPublisher<String, Never> {
// Combine both temperature and humidity into a single AnyPublisher
return Publishers.combineLastest($temperature, $humidity)
.map({ "Today's temperature is \($0), humidity is \($1)"})
.eraseToAnyPublisher()
}
}
let weather = Weather(temperature: 20, humidity: 30.2)
cancellable = weather.weatherMessage
.sink { print($0) }
// Prints:
// Today's temperature is 20.0, humidity is 30.2"
PassthroughSubject
PassthroughSubject
遵從 protocol Subject
,可以發佈資料給訂閱者。
當訂閱者 PassthroughSubject
呼叫 sink()
後,因為宣告時不需要給初始值,所以不會立即得到資料,需要等 PassthroughSubject
呼叫 send()
後才會收到最新的資料。
let subject = PassthroughSubject<String, Never>()
subject.send("Hello")
subject.send("World")
let subscription = subject
.sink({ print($0) })
subject.send("AI")
subject.send("is coming")
/*
AI
is coming
*/
CurrentValueSubject
CurrentValueSubject
和 PassthroughSubject
相似,遵從 protocol Subject
,可以發佈資料給訂閱者。
當訂閱者 CurrentValueSubject
呼叫 sink()
後,因為宣告時需要給初始值,所以會立即得到資料,當 CurrentValueSubject
呼叫 send()
後會收到最新的資料。
let subject = CurrentValueSubject<String, Never>("I'm ready")
subject.send("Hello")
subject.send("World")
let subscription = subject
.sink({ print($0) })
subject.send("AI")
subject.send("is coming")
/*
World
AI
is coming
*/
Operator
運算符接收來自發佈者發佈的資料,並將資料更改成訂閱者想要的資料,再重新發佈給訂閱者,常見的是 map(_:)
, filter(_:)
。
Subscriber
訂閱者接收來自發佈者發佈的資料,而訂閱的資料型別需要遵從 protocol Subscriber
。
protocol Subscriber<Input, Failure> : CustomCombineIdentifierConvertible
常見的資料型別和 protocol Publisher
幾乎一樣,Publisher
的 Output
對應到 Subscriber
的 Input
。
以下為 Combine 提供的 API 讓訂閱者能接收遵從 protocol Publisher
的資料型別。
sink(receiveValue:)
receiveValue
在收到數值時執行的 closure。
let integers = (0...3)
integers.publisher
.sink { print("Received \($0)") }
// Prints:
// Received 0
// Received 1
// Received 2
// Received 3
sink(receiveCompletion:receiveValue:)
receiveCompletion
在完成時執行的 closure。receiveValue
在收到數值時執行的 closure。
let myRange = (0...3)
cancellable = myRange.publisher
.sink(receiveCompletion: { print ("completion: \($0)") },
receiveValue: { print ("value: \($0)") })
// Prints:
// value: 0
// value: 1
// value: 2
// value: 3
// completion: finished
assign(to:on:)
可以將每一個數值指派給物件的 property。
class MyClass {
var anInt: Int = 0 {
didSet {
print("anInt was set to: \(anInt)", terminator: "; ")
}
}
}
var myObject = MyClass()
let myRange = (0...2)
cancellable = myRange.publisher
.assign(to: \.anInt, on: myObject)
// Prints: "anInt was set to: 0; anInt was set to: 1; anInt was set to: 2"
實作
實作一個簡易的登入功能作為範例,有 username, password, repeatPassword 三個輸入匡和 loginButton 的按鍵。每次輸入文字時,檢查三個輸入匡都不為 nil 且 password 和 repeatPassword 為一樣的數值,若兩者都為 true,就啟用 loginButton 。
以下為 LoginViewController。
private var viewModel = LoginViewModel()
private var subscritions = Set<AnyCancellable>()
@IBOutlet weak var usernameTextField: UITextField!
@IBOutlet weak var passwordTextField: UITextField!
@IBOutlet weak var repeatPasswordTextField: UITextField!
@IBOutlet weak var loginButton: UIButton!
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupCombine()
}
private func setupUI() {
usernameTextField.text = ""
usernameTextField.delegate = self
passwordTextField.text = ""
passwordTextField.delegate = self
repeatPasswordTextField.text = ""
repeatPasswordTextField.delegate = self
loginButton.isEnabled = false
loginButton.addTarget(self, action: #selector(didTapLogin), for: .touchUpInside)
}
private func setupCombine() {
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: usernameTextField)
.compactMap({ ($0.object as? UITextField)?.text }) // Filter nil
.assign(to: \.username, on: viewModel) // Assign text from publisher to view model
.store(in: &subscritions) // Store subscription
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: passwordTextField)
.compactMap({ ($0.object as? UITextField)?.text }) // Filter nil
.assign(to: \.password, on: viewModel) // Assign text from publisher to view model
.store(in: &subscritions) // Store subscription
NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: repeatPasswordTextField)
.compactMap({ ($0.object as? UITextField)?.text }) // Filter nil
.assign(to: \.repeatPassword, on: viewModel) // Assign text from publisher to view model
.store(in: &subscritions) // Store subscription
viewModel.isAllowToLogin
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: loginButton)
.store(in: &subscritions)
}
以下為 LoginViewModel。
import Combine
class LoginViewModel {
@Published var username: String = ""
@Published var password: String = ""
@Published var repeatPassword: String = ""
// Check that password is equal to repeat password
var isPasswordValid: AnyPublisher<Bool, Never> {
return Publishers.CombineLatest($password, $repeatPassword)
.map({ password, repeatPassword in
return password != "" && repeatPassword != "" && password == repeatPassword
})
.eraseToAnyPublisher()
}
// Check that username and passwords are valid
var isAllowToLogin: AnyPublisher<Bool, Never> {
return Publishers.CombineLatest(isPasswordValid, $username)
.map({ isPasswordValid, username in
return isPasswordValid && !username.isEmpty
})
.eraseToAnyPublisher()
}
}
當使用者輸入文字時,發佈者 UITextField.textDidChangeNotification
將數據發佈出去,訂閱者使用 assign(to:on:)
將數據流指派給 viewModel 的 properties ,也就是 username, password, repeatPassword。
因為 viewModel 的 property 為 Published
的訂閱者,因此數據更新的同時也發布資料給發佈者 isPasswordValid ,isPasswordValid 呼叫 combineLastest(_:_:)
合併檢查輸入的密碼是否一致,再將資料發佈給發佈者 isAllowToLogin,isAllowToLogin 呼叫 combineLastest(_:_:)
合併檢查輸入是否合法,再將資料發佈出去並指派給 loginButton 的 isEnable。
更多程式碼可以參考以下連結。