iOS Design Patterns | #3 實作 Combine

黃暉德 Wade Huang
23 min readMay 14, 2024

--

前言

在 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, NotificationCenterURLSession 都能使用 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

CurrentValueSubjectPassthroughSubject 相似,遵從 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 幾乎一樣,PublisherOutput 對應到 SubscriberInput

以下為 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。

更多程式碼可以參考以下連結。

--

--