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 SwiftUIimport Combinestruct 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 可以極為簡單處理一個單一資料的資料源,但如果需要一個多個資料的資料源的情境呢?或是更複雜的通知情境呢?
必須要自行定義 Model 並且 conform ObservedObject,同時必須要是 class,例如 CakeModel
在 state 發生變化時(willSet),需要利用 PassthroughSubject 物件,發動通知
宣告時使用 @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 { @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]) .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
參考書籍