Boxing (一)
目錄
⦿ Boxing
⦿ 思考方向
⦿ Notification Center
⦿ KVO
⦿ KVC
⦿ KVO
⦿ 實作
⦿ bind
⦿ 使用
Boxing
這是一篇開箱文
(?) 相信大家都聽過薛丁格的思想實驗,箱子裡的貓,在還沒開箱前我們不會知道牠是死是活,牠處於又死又活的情況,等到開箱,波函數塌縮,不是死,便是活,一定要開箱才知道結果。
在 OOP(物件導向)程式語言設計中,封裝(encapsulation)是一個相當重要的概念,抽象(abstration)也是一個相當重要的概念,在物件導向中,我們脫離不了兩者。
我們知道 SwiftUI 與 UIKit 在設計上,一個是宣告式(declarative approach),一個是命令式(imperative approach),兩者理念不同,SwiftUI 透過將 Data 與 UI 的綁定,當 Data 更新後,UI 即刻反應(呈現),是謂 Data Binding。
在 SwiftUI 中,@State 的作用即是如此,在 UIKit 中,我們從哪裡看過 @ 呢?例如 IBOutlet、IBAction、IBInspectable、IBDesignable,這些 IB 透過 @ 將 Storyboard、Xib 與程式碼相連,我們可以想像它是一個介面橋接
的前綴。
UIKit 中,當你要將 UIButton addTarget 時,selector 需帶入 Objective-C 的方法,宣告時我們也是用 @objc func,事實上,雖然我們使用它,但我們並不知道 Swift 在這些框架內幹了什麼事,所以,我們可以將這個橋接的前綴看成既是封裝了一些東西(因為我們不會訪問到裡面的實現),又抽象地讓我們知道使用 @ 代表某種橋接。
在 Swift 中,有所謂的 Property Wrapper(屬性包裝器),屬性包裝器在對 class 或 struct 使用時,也是加上 @,而我們宣告新的屬性時則只要用包裝器來宣告,這時也是透過 @,比方如下:
@PropertyWrapper
struct AgeRange {
var age: Int
...
...實作 wrappedValue,假設限定在 20 - 40 才是公民並享有福利
...
}
struct Citizen {
@AgeRange var age: Int
}
let citizen = Citizen(age: 45)
let year = 40 - citizen.age
print("享有公民福利還剩幾年 \(year)歲")
如上所示,如果 @AgeRange 是一種 Computed Property(計算屬性) 的封裝,那麼,在 SwiftUI 中,@State 就是一種記錄狀態並更新 UI 的封裝。
在 UIKit 中,我們透過 URLSession 找回來的值是會延遲的,畢竟網路溝通需要時間,透過 TCP 連接做三向交握,從請求頭丟給伺服器,到伺服器回應給你的結果,比方三秒鐘好了,我們會將這些操作放到背景線程
處理,避免卡主線程
讓 UI 停住,但當你拿到結果,我們會切回主線程
更新 UI。
也就是在 UIKit 這 Imperative Approach 下,拿到新的 Data 後,我們會主動去寫更新 UI 的這段程式,然而在 SwiftUI 下,@State 就幫你做到了。
Boxing 的目的就是在 UIKit 中幫你做到這件事,接下來我們看看思考方向。
繼續閱讀|回目錄
思考方向
試想在 UIKit 裡有一個 Button,在前篇文章中,我們提到了 Button 裡有個屬性叫做 isSelected
,這個 isSelected 預設值為 false,但不論你按幾下,它都還是維持 false,所以我們可以很 trick 地將它作為 Button 的素材,每當按下 Button,isSelected 都 toggle() 去改變它的 Bool,那它就是一種 State。
接著,UIKit 裡面有一個元件叫做 UIStepper(步進器),我們通常會用它來改變數值,例如計步,如下:
@IBOutlet weak var myLabel: UILabel!
@IBAction func stepperTapped(_ sender: UIStepper) {
myLabel.text = "\(sender.value)"
}
在 Stepper 沒計次時,減號是不能按的,如果按下加號,我們就可透過 sender 的值去改變 Label 的值,這是兩個元件的相互關係,不過這種關聯就不能稱之為狀態
,所以沒有所謂狀態改變
,但是,Stepper 計次為零時,減號不能按,計次不為零時,減號可以按,這就是狀態
了。
我們可以想看看這個邏輯:
if sender.value == 0 {
minusItem.enabled = false
} else {
minusItem.enabled = true
}
當然這個邏輯也可以寫成:
minusItem.enabled = (sender.value == 0) ? false : true
然而 (sender.value == 0) 是 Bool,而 enabled 也是 Bool,可以進一步修正:
minusItem.enabled = (sender.value != 0)
當 sender.value == 0
時,上述 enabled = false
,即減號不能按。
繼續閱讀|回目錄
Notification Center
所以,我們必須要有一個東西來去記錄狀態,每當你狀態更新時,UI 也會跟著更新,所以必須要設置監聽。
咦?這不是跟 KVO
或 Notification Center
有點像嗎?是的。
我們先來看一下 NotificationCenter 會怎麼做:
private var isSelected = false
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(stateChanged), name: Notification.Name("isSelectedToggle"), object: nil)
}
@objc func stateChanged(noti: Notification) {
if isSelected {
bindingBtn.setTitle("選中", for: .normal)
} else {
bindingBtn.setTitle("未選", for: .normal)
}
}
@IBAction func bindingBtnTapped(_ sender: UIButton) {
isSelected.toggle()
NotificationCenter.default.post(name: Notification.Name("isSelectedToggle"), object: nil)
}
從 viewDidLoad 中加入監聽,監聽名稱為 isSelectedToggle,當 isSelected 改變狀態了,觸發 stateChanged 這個 Objective-C 方法,判斷特定按鈕是否被選中
,或未選
。結果如下:
KVO
KVO,即 KVC 下的 Observer Pattern
,當物件繼承 NSObject 時,都有一個能夠 setValue forKey 的方法能用,而這個 Key 就是你屬性的名稱,即是說當屬性確定下來時,Key 也確定下來了。
而關於 KVC、KVO 的傳值,可以參考下面的文章。
KVC
在這邊回到 Data Binding 上,若是 KVC 會這樣寫:
@objc private dynamic var isSelected: Bool = false {
didSet {
if isSelected {
bindingBtn.setTitle("選中", for: .normal)
} else {
bindingBtn.setTitle("未選", for: .normal)
}
}
}
@IBAction func bindingBtnTapped(_ sender: UIButton) {
let currentSelectedValue = value(forKey: "isSelected") as? Bool ?? false
setValue(!currentSelectedValue, forKey: "isSelected")
}
透過跟 Objective-C 的橋接,屬性會繼承 NSObject,便可以使用 KVC,當你從中找到 Key,它的 Value 若有值取值,無值取預設值,接著,將這個 Bool 變成非
,即是每次按下這個 Button 都改變它的狀態,在 didSet 也去做到即時地更新 UI 了。
KVO
那麼,在 KVO 其實我們就會加入 Observer,而不是利用屬性觀察器(didSet),所以調整如下:
var observation: NSKeyValueObservation?
@objc private dynamic var isSelected: Bool = false
observation = observe(\.isSelected, options: [.new], changeHandler: { (object, change) in
if let newValue = change.newValue {
print("isSelected changed to: \(newValue)")
if newValue {
self.bindingBtn.setTitle("選中", for: .normal)
} else {
self.bindingBtn.setTitle("未選", for: .normal)
}
}
})
當 Object 的值改變時,我們去改變 UI。
Cool!下面我來實作 Boxing。
繼續閱讀|回目錄
實作
在 KVC、KVO 的地方,我們看到 value(forKey: “isSelected”) as? Bool
,這個轉型的程式碼,所以我們這個封裝的類
(class)的想像勢必是泛型,因為你不會為每種型別都寫一個 Boxing,如下:
class Box<T> {
}
代表每種型別都可以透過這個 Box 達到我們想要的效果(Data Binding),而透過前述對 Property Wrapper 的回憶,我們知道裡面一定有個封裝值,並且這個封裝值要能夠初始化:
class Box<T> {
var value: T
init(value: T) {
self.value = value
}
}
這時候我們就可以宣告 var box = Box(value: 3)、var box = Box(value: “string”),value 初始值為 3 與 string 的兩種不同型別的實例,也可以把它印出來:
print(box.value)
但你想想看,value 是能夠被改變的 Data 沒錯,而在前面的 KVC 中,我們知道可以透過 didSet 的方式來更新 UI,可是現在我們並不知道是什麼 UI,這即是說,如果要更新這個 UI,除了是在 Data 更新時去更新,這個 UI 改變的動作也是在使用 Observer(觀察者) 時才動作,參考前段的 KVO,在 changeHandler 裡,即被觀察的物件
的值改變時,我們才有更新 UI 的邏輯。
好的,現在我們試著加入一個叫做 Listener 的閉包:
class Box<T> {
var value: T
var listener: ((T) -> Void)?
init(value: T) {
self.value = value
}
}
如果這個閉包是可選的,我們就不需要初始化它,否則會出現下面的警示:
但這樣並不夠,listener 只是 class 裡的一個屬性,跟我們要監聽物件的 value 並不相干,這時後 didSet 就可拿出來用了!
class Box<T> {
var value: T {
listener?(value)
}
var listener: ((T) -> Void)?
init(value: T) {
self.value = value
}
}
但這是什麼意思,我們實際來看看:
當你產生一個實例的同時,這個實例的屬性也跟著產生,是謂初始化。接著設定它的屬性,看到如下:
在初始化的時候,由於 listener 是 Optional,所以沒它的事,不過,當 value 改變時,會將 value 的值帶入 listener,看到如下:
var boxbox = BoxBox(value: 3)
boxbox.listener = { newValue in
print("value changed to: \(newValue)")
}
boxbox.value = 5 // value changed to: 5
boxbox.value = 6 // value changed to: 6
每當 value 改變時,我們透過 listener 就可觀察到值的變化,不過,仔細看,在初始化時,這個 listener 並不起作用,當你的值改變時,才會依序印出想要的結果。
這不對的,試想,如果你是要將 Data 與 UI Binding,那麼,在 Data 跟 UI 初始化時不需要 Binding 嗎?當然不是。
bind
為此,我們需加入一個叫做 bind
的 function 來作為初始化的綁定:
class Box<T> {
var value: T {
listener?(value)
}
var listener: ((T) -> Void)?
init(value: T) {
self.value = value
}
func bind(listener: @escaping (T) -> Void) {
self.listener = listener
listener(value)
}
}
還記得前段說,光靠 listener 是沒辦法在初始化的時候,綁定 Data 跟 UI,而是在 Value 改變時,才跟著改變 UI,如果我們希望藉由 bind 在初始化時也綁定 UI,首先,我們將相同型別的閉包傳進 bind 裡。
如此一來,在調用 bind 時,就會用這個閉包去賦值給本來的屬性(listener),傳入的閉包要能夠修改本來的屬性(value),那這個閉包必須要加上前綴 @escaping
,不過這還沒完。
這做法跟前面直接使用 listener 其實沒兩樣,我們希望在第一次調用時,將 value 帶入傳入的閉包中,所以還需加入 listener(value),我們重新 build,再看到 Debug 區:
var boxbox = BoxBox(value: 3)
boxbox.bind { intValue in
print("value changed to: \(intValue)")
self.myLabel.text = "\(intValue)"
}
// value changed t0: 3
boxbox.value = 5 // value changed to: 5
boxbox.value = 6 // value changed to: 6
成功了!
繼續閱讀|回目錄
使用
接著,我們使用 Box,初始化一個 Button 的 titleLabel,並讓另一個 Button 的 Action 按下後,去更改它的 titleLabel。
如果是 Imperative Approach,可以想見是主動地由一個 Button 去更改另個 Button 的 Label,不過這樣並不是很好,我們看看可以怎麼做:
private let boxedInt = Box(123)
override func viewDidLoad() {
super.viewDidLoad()
// Data Binding,Boxing
boxedInt.bind { _ in
self.bindingBtn.setTitle("\(self.boxedInt.value)", for: .normal)
}
}
@IBAction func anotherBindingBtnTapped(_ sender: UIButton) {
boxedInt.value = 456
}
宣告一個屬性,初值為 123 這個 Int,將它與 bindingBtn 綁定,也就是說,如果屬性的值改變, bindingBtn 的 title 也會跟著變,按下另個 Button,將 value 改為 456,成果如下:
是不是很棒呢?又或者回到最前面強調的狀態,我們希望 Button 的 isSelected 做為它狀態變化的素材,但採用 Box,我們可以另外新增一個用 Box 裝起來的 isSelected,如下:
// Boxing
private var isSelected = Box(false)
isSelected.bind { isSelected in
if isSelected {
self.bindingBtn.setTitle("選中", for: .normal)
} else {
self.bindingBtn.setTitle("未選", for: .normal)
}
}
@IBAction func bindingBtnTapped(_ sender: UIButton) {
isSelected.value.toggle()
}
這表示最初是未選
,按下後變成選擇
,再按又回到未選
狀態,成果如下:
太棒了!透過這種方式,我們成功將 Data 與 UI 綁定了。
不過,現在的 Box 還有改進的空間,比方說,我希望這個 Data 跟兩個 UIElement 綁定,這個 Box 並做不到,因為它只有一個 listener,所以當你使用 bind 時,傳了一個閉包進去監聽,為了綁定一個 UI,再使用 bind,傳另一個閉包進去監聽,想要綁定另一個 UI,然而,後面那個監聽會覆蓋前一個監聽,即是我們此刻並不能做到多個綁定,不過,這個問題我們留待下次解決。
這次就分享到這,感謝您的閱讀。
繼續閱讀|回目錄
附上 GitHub: