Apple 在 2019 年的 WWDC 發布 SwiftUI 和 Combine,這兩套新的開發方式給我們很多新想法和驚喜,也為 Swift 的生態系注入新的活力,最近會開始慢慢接觸它,這是第一篇文章,未來會再持續記錄下去。

前言

以前開發 App 是基於 Objective-C 編寫程式的方式,加上 Cocoa Design Patterns 例如 target/action、delegate/protocol、KVO、NotificationCenter,並且使用 AppKit 和 UIKit 的 UI 系統和 MVC 的程式架構,而 Swift 基本上也是將上述用法原封不動地封裝成自己的接口,發展出更簡化更安全的呼叫方式,由於近代程式語言的演進和趨勢,同時前端各種語言和框架的興起 ( React Native 和 Flutter … ),Swift 也進而演化出 SwiftUI 和 Combine。

這邊要先提及另一個名詞叫做”程式範式”,一般來說”程式範式”( Programming Paradigm ) 會直接影響寫程式的思考方式和邏輯,有些程式語言只為一種範式而設計、有些程式語言可以用多種範式去撰寫。

最簡單可以有兩種分類方式

  • 指令式程式設計 ( Imperative Programming ):
    • 利用運算符號 + 循環執行 + 條件判斷,一行一指令,不斷地往下執行來達到目標的功能 => 描述過程
    • 以義大利麵的案例來講,就像先滾水煮熟天使細麵、撈出麵條、與肉醬攪拌熱炒、放入盤中
    • 也就是以前我們撰寫 OC 和 Swift 所使用的方式
    • 其中還有更詳細的分類,例如 Procedural / Structured / Object-oriented programming …
  • 宣告式程式設計 ( Declarative Programming ):
    • 利用宣告的方式,在初始化就定義好資料和 View 的狀態和未來的走向 => 描述結果
    • 以義大利麵的案例來講,就像定義一個義大利肉醬天使細麵
    • 一般使用”函數式程式設計”或是”領域特定語言 ( DSL/Domain-Specific Language , ex. HTML、SQL ) 來實作宣告式程式設計
    • SwiftUI 就是這種思考方式

Combine 是基於響應式程式設計 ( Reactive Programming ) 來管理數據資料,也算是宣告式程式設計的其中一個分支。

因此在開發 SwiftUI 和 Combine 盡量要丟棄以往的思考方式,用全新的看法去開發會更容易理解。


SwiftUI

這裡不詳細說明 SwiftUI 的用法,僅僅列出範例,未來再詳細整理。
值得一提的是 SwiftUI 的 View 是一個 Protocol,實做出來的 View 例如 VStack、Spacer、Button 也都是 struct,而且連 Color 都是 Conform View 的 struct。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import SwiftUI
import Combine

struct CakeView : View {
var body: some View {
VStack(spacing: 12) {
Spacer()
Text("Cake List")
.font(.system(size: 32))
.foregroundColor(.gray)
.minimumScaleFactor(0.5)
.padding(.trailing, 24)
.frame(
minWidth: 0,
maxWidth: .infinity,
alignment: .trailing)
CakeButtonRow(row: [.strawberry, .cocolate, .cheese])
.padding(.bottom)
CakeButtonRow(row: [.cocolate, .cheese, .strawberry])
.padding(.bottom)
}
}
}

struct CakeView_Previews : PreviewProvider {
static var previews: some View {
Group {
CakeView()
CakeView().previewDevice("iPhone 13")
CakeView().previewDevice("iPad Air 2")
}
}
}

struct CakeButtonRow : View {
let row: [CakeItem]
var body: some View {
HStack {
ForEach(row, id: \.self) { item in
CakeButton(
title: item.title,
backgroundColor: item.backgroundColor)
{
print("Button: \(item.title)")
}
}
}
}
}

struct CakeButton : View {
let title: String
let backgroundColor: Color
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 16))
.foregroundColor(.white)
.frame(width: 120, height: 30)
.background(backgroundColor)
.cornerRadius(30/2)
}
}
}

enum CakeItem: String {

case strawberry
case cocolate
case cheese

var title: String {
switch self {
case .strawberry: return "Strawberry"
case .cocolate: return "Cocolate"
case .cheese: return "Cheese"
}
}

var backgroundColor: Color {
switch self {
case .strawberry: return .pink
case .cocolate: return .brown
case .cheese: return .yellow
}
}
}

extension CakeItem: Hashable {}

Combine

單向數據流的 Redux

在使用 Combine 之前,需要先理解一下單向數據流的 Redux 概念和 Flux,其思想來源為 Elm,後來也影響其他前台的開發框架 React Component 和 Flutter。

它需要以下東西驅動資料更動

  • Store: 專門存放多個 State
  • State: 可視為狀態或是資料
  • Reducer: 可用於更新 State
    • 特性是純函數設計,此方法只會依照參數決定回傳值,不會依賴任何系統狀態,也不應改變任何全域資料,因此沒有任何依賴關係。
  • Action: 用來驅動 Reducer
  • View: 不能直接操作 Status 而是利用 Action 發動改變 Store 中的 State
1
2
3
4
5
struct Reducer { 
static func reduce(state: CakeState, action: CakeAction) -> CakeState{
return state.apply(item: action)
}
}

資料處理方式

Swift 為了模擬出單向數據流的方式,利用屬性包裝 (Property Wrapper),賦予了屬性有各種特性。

@State

此屬性會自動內部生成 setter 和 getter,當屬性發生數值變化時,body 會自動進行刷新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
enum CakeState{
case unselect
case didselect(item: CakeItem)
var message: String{
switch self {
case .unselect:
return "未選擇"
case .didselect(let item):
return "已選擇 - \(item.title)"
}
}
}
struct CakeView : View {
@State private var cakeState: CakeState = .unselect
var body: some View {
// ...
CakeButtonRow(cakeState: $cakeState, row: [.cocolate, .cheese, .strawberry])
.padding(.bottom)
}
}

@Binding

如果用 @State 將父層的狀態傳到子層,但子層按鈕卻無法對最上層的 cakeState 進行改變,因此需要 @Binding,此屬性觀察的不是屬性本身,而是他的參考引用,並且傳遞時使用 $,這稱為投影属性(projection property)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct CakeButtonRow : View {
@Binding var cakeState: CakeState
let row: [CakeItem]
var body: some View {
HStack {
ForEach(row, id: \.self) { item in
CakeButton(
title: item.title,
backgroundColor: item.backgroundColor)
{
cakeState = .didselect(item: item)
print("Button: \(item.title)")
}
}
}
}
}

展示 @State + @Binding 的結果,點擊下方按鈕會更新最上面的文字


@ObservedObject

@State 可以極為簡單處理一個單一資料的資料源,但如果需要一個多個資料的資料源的情境呢?或是更複雜的通知情境呢?

  1. 必須要自行定義 Model 並且 conform ObservedObject,同時必須要是 class,例如 CakeModel
  2. 在 state 發生變化時(willSet),需要利用 PassthroughSubject 物件,發動通知
  3. 宣告時使用 @ObservedObject 即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class CakeModel: ObservableObject {
let objectWillChange = PassthroughSubject<Void, Never>()
var state: CakeState = .unselect {
willSet { objectWillChange.send() }
}
}
struct CakeView : View {
//@State private var cakeState: CakeState = .unselect
@ObservedObject var model = CakeModel()
var body: some View {
// ...
CakeButtonRow(cakeState: $model.state, row: [.cocolate, .cheese, .strawberry])
.padding(.bottom)
}
}
struct CakeButtonRow : View {
@ObservedObject var model: CakeModel
let row: [CakeItem]
var body: some View {
HStack {
ForEach(row, id: \.self) { item in
CakeButton(
title: item.title,
backgroundColor: item.backgroundColor)
{
model.state = .didselect(item: item)
print("Button: \(item.title)")
}
}
}
}
}

@Published

如果手動進行通知其實蠻麻煩的,可以使用 @Published,預設就會帶有通知效果。

1
2
3
class CakeModel: ObservableObject {
@Published var state: CakeState = .unselect
}

@EnvironmentObject

上面提到的 @ObservedObject 的範例必須一層一層往下傳遞,導致撰寫上的麻煩,
因此父層可以使用 @EnvironmentObject,被標記的屬性會進行自動地查詢,
子層初始化時,不用由父層傳值下來就可進行使用,
有時候甚至中間層是不需要 model,可以節省中間層的傳遞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct CakeView : View {
@EnvironmentObject var model: CakeModel
var body: some View {
VStack(spacing: 12) {
// ...
CakeButtonRow(row: [.cocolate, .cheese, .strawberry]) //<---初始化不用設定model
.padding(.bottom)
}
}
}
struct CakeButtonRow : View {
@EnvironmentObject var model: CakeModel
let row: [CakeItem]
var body: some View {
HStack {
ForEach(row, id: \.self) { item in
CakeButton(
title: item.title,
backgroundColor: item.backgroundColor)
{
model.state = .didselect(item: item)
print("Button: \(item.title)")
}
}
}
}
}

初始化最上層物件時原本是使用 init 設定參數,但改成 .environmentObject 的方式設定。

1
CakeView().environmentObject(CakeModel())

題外補充:Property Wrapper

可以利用属性包装 (Property Wrapper),對任何屬性進行 set 和 get 的封裝!

1
2
3
4
5
6
7
@propertyWrapper
public struct State<Value>: DynamicViewProperty, BindingConvertible{
public init(initialValue value: Value)
public var value: Value { get nonmutating set }
public var wrappedValue: Value { get nonmutating set }
public var projectedValue: Binding<Value> { get }
}
  • initialValue: init方法,只有一個參數時直接給值,當然也可以更多參數
  • value: 實際存放的數值
  • wrappedValue: 實際上外部進行賦值和取值時,觸發的邏輯
  • projectedValue: 使用 $ 所傳遞的數值,遵守 BindingConvertible,就可以傳遞參考引用

Demo


參考書籍