成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

TCA-SwiftUI 的救星之二

開發 后端
當我們把某個狀態通過 Binding 交給其他 view 時,這個 view 就有能力改變去直接改變狀態了,實際上這是違反了 TCA 中關于只能在 reducer 中更改狀態的規定的。

[[440914]]

前言

上一篇關于 TCA 的文章中,我們通過總覽的方式看到了 TCA 中一個 Feature 的運作方式,并嘗試實現了一個最小的 Feature 和它的測試。在這篇文章中,我們會繼續深入,看看 TCA 中對 Binding 的處理,以及使用 Environment 來把依賴從 reducer 中解耦的方法。

如果你想要跟做,可以直接使用上一篇文章完成練習后最后的狀態,或者從這里[1]獲取到起始代碼。

關于綁定

綁定和普通狀態的區別

在上一篇文章中,我們實現了“點擊按鈕” -> “發送 Action” -> “更新 State” -> “觸發 UI 更新” 的流程,這解決了“狀態驅動 UI”這一課題。不過,除了單純的“通過狀態來更新 UI” 以外,SwiftUI 同時也支持在反方向使用 @Binding 的方式把某個 State 綁定給控件,讓 UI 能夠不經由我們的代碼,來更改某個狀態。在 SwiftUI 中,我們幾乎可以在所有既表示狀態,又能接受輸入的控件上找到這種模式,比如 TextField 接受 String 的綁定 Binding,Toggle 接受 Bool 的綁定 Binding 等。

當我們把某個狀態通過 Binding 交給其他 view 時,這個 view 就有能力改變去直接改變狀態了,實際上這是違反了 TCA 中關于只能在 reducer 中更改狀態的規定的。對于綁定,TCA 中為 View Store 添加了將狀態轉換為一種“特殊綁定關系”的方法。我們來試試看把 Counter 例子中的顯示數字的 Text 改成可以接受直接輸入的 TextField。

在 TCA 中實現單個綁定

首先,為 CounterAction 和 counterReducer 添加對應的接受一個字符串值來設定 count 的能力:

  1. enum CounterAction { 
  2.   case increment 
  3.   case decrement 
  4. case setCount(String) 
  5.   case reset 
  6.  
  7. let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> { 
  8.   state, action, _ in 
  9.   switch action { 
  10.   // ... 
  11. case .setCount(let text): 
  12. +   if let value = Int(text) { 
  13. +     state.count = value 
  14. +   } 
  15. +   return .none 
  16.   // ... 
  17. }.debug() 

接下來,把 body 中原來的 Text 替換為下面的 TextField:

  1. var body: some View { 
  2.   WithViewStore(store) { viewStore in 
  3.     // ... 
  4. -   Text("\(viewStore.count)"
  5. +   TextField( 
  6. +     String(viewStore.count), 
  7. +     text: viewStore.binding( 
  8. +       get: { String($0.count) }, 
  9. +       send: { CounterAction.setCount($0) } 
  10. +     ) 
  11. +   ) 
  12. +     .frame(width: 40) 
  13. +     .multilineTextAlignment(.center) 
  14.       .foregroundColor(colorOfCount(viewStore.count)) 
  15.   } 

viewStore.binding 方法接受 get 和 send 兩個參數,它們都是和當前 View Store 及綁定 view 類型相關的泛型函數。在特化 (將泛型在這個上下文中轉換為具體類型) 后:

  • get: (Counter) -> String 負責為對象 View (這里的 TextField) 提供數據。
  • send: (String) -> CounterAction 負責將 View 新發送的值轉換為 View Store 可以理解的 action,并發送它來觸發 counterReducer。 在 counterReducer 接到 binding 給出的 setCount 事件后,我們就回到使用 reducer 進行狀態更新,并驅動 UI 的標準 TCA 循環中了。

傳統的 SwiftUI 中,我們在通過 $ 符號獲取一個狀態的 Binding 時,實際上是調用了它的 projectedValue。而 viewStore.binding 在內部通過將 View Store 自己包裝到一個 ObservedObject 里,然后通過自定義的 projectedValue 來把輸入的 get 和 send 設置給 Binding 使用中。對內,它通過內部存儲維持了狀態,并把這個細節隱藏起來;對外,它通過 action 來把狀態的改變發送出去。捕獲這個改變,并對應地更新它,最后再把新的狀態再次通過 get 設置給 binding,是開發者需要保證的事情。

簡化代碼

做一點重構:現在 binding 的 get 是從 $0.count 生成的 String,reducer 中對 state.count 的設定也需要先從 String 轉換為 Int。我們把這部分 Mode 和 View 表現形式相關的部分抽取出來,放到 Counter 的一個 extension 中,作為 View Model 使用:

  1. extension Counter { 
  2.   var countString: String { 
  3.     get { String(count) } 
  4.     set { count = Int(newValue) ?? count } 
  5.   } 

把 reducer 中轉換 String 的部分替換成 countString:

  1. let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> { 
  2.   state, action, _ in 
  3.   switch action { 
  4.   // ... 
  5.   case .setCount(let text): 
  6. -   if let value = Int(text) { 
  7. -     state.count = value 
  8. -   } 
  9. +   state.countString = text 
  10.     return .none 
  11.   // ... 
  12. }.debug() 

在 Swift 5.2 中,KeyPath 已經可以被當作函數使用了,因此我們可以把 \Counter.countString 的類型看作 (Counter) -> String。同時,Swift 5.3 中 enum case 也可以當作函數[2],可以認為 CounterAction.setCount 具有類型 (String) -> CounterAction。兩者恰好滿足 binding 的兩個參數的要求,所以可以進一步將創建綁定的部分簡化:

  1. // ... 
  2.   TextField( 
  3.     String(viewStore.count), 
  4.     text: viewStore.binding( 
  5. -     get: { String($0.count) }, 
  6. +     get: \.countString, 
  7. -     send: { CounterAction.setCount($0) } 
  8. +     send: CounterAction.setCount 
  9.     ) 
  10.   ) 
  11. // ... 

最后,別忘了為 .setCount 添加測試!

多個綁定值 如果在一個 Feature 中,有多個綁定值的話,使用例子中這樣的方式,每次我們都會需要添加一個 action,然后在 binding 中 send 它。這是千篇一律的模板代碼,TCA 中設計了 @BindableState 和 BindableAction,讓多個綁定的寫法簡單一些。具體來說,分三步:

為 State 中的需要和 UI 綁定的變量添加 @BindableState。

將 Action 聲明為 BindableAction,然后添加一個“特殊”的 case binding(BindingAction) 。

在 Reducer 中處理這個 .binding,并添加 .binding() 調用。

直接用代碼說明會更快:

  1. // 1 
  2. struct MyState: Equatable { 
  3. + @BindableState var foo: Bool = false 
  4. + @BindableState var bar: String = "" 
  5.  
  6. // 2 
  7. - enum MyAction { 
  8. + enum MyAction: BindableAction { 
  9. +   case binding(BindingAction<MyState>) 
  10.  
  11. // 3 
  12. let myReducer = //... 
  13.   // ... 
  14. case .binding: 
  15. +   return .none 
  16. + .binding() 

這樣一番操作后,我們就可以在 View 里用類似標準 SwiftUI 的做法,使用 $ 取 projected value 來進行 Binding 了:

  1. struct MyView: View { 
  2.   let store: Store<MyState, MyAction> 
  3.   var body: some View { 
  4.     WithViewStore(store) { viewStore in 
  5. +     Toggle("Toggle!", isOn: viewStore.binding(\.$foo)) 
  6. +     TextField("Text Field!", text: viewStore.binding(\.$bar)) 
  7.     } 
  8.   } 

這樣一來,即使有多個 binding 值,我們也只需要用一個 .binding action 就能對應了。這段代碼能夠工作,是因為 BindableAction 要求一個簽名為 BindingAction -> Self 且名為 binding 的函數:

  1. public protocol BindableAction { 
  2.   static func binding(_ action: BindingAction<State>) -> Self 

再一次,利用了將 enum case 作為函數使用的 Swift 新特性,代碼可以變得非常簡單優雅。

環境值

猜數字游戲

回到 Counter 的例子來。既然已經有輸入數字的方式了,那不如來做一個猜數字的小游戲吧!

猜數字:程序隨機選擇 -100 到 100 之間的數字,用戶輸入一個數字,程序判斷這個數字是否就是隨機選擇的數字。如果不是,返回“太大”或者“太小”作為反饋,并要求用戶繼續嘗試輸入下一個數字進行猜測。

最簡單的方法,是在 Counter 中添加一個屬性,用來持有這個隨機數:

  1. struct Counter: Equatable { 
  2.   var countInt = 0 
  3. + let secret = Int.random(in: -100 ... 100) 

檢查 count 和 secret 的關系,返回答案:

  1. extension Counter { 
  2.   enum CheckResult { 
  3.     case lower, equal, higher 
  4.   } 
  5.    
  6.   var checkResult: CheckResult { 
  7.     if count < secret { return .lower } 
  8.     if count > secret { return .higher } 
  9.     return .equal 
  10.   } 

有了這個模型,我們就可以通過使用 checkResult 來在 view 中顯示一個代表結果的 Label 了:

  1. struct CounterView: View { 
  2.   let store: Store<Counter, CounterAction> 
  3.   var body: some View { 
  4.     WithViewStore(store) { viewStore in 
  5.       VStack { 
  6. +       checkLabel(with: viewStore.checkResult) 
  7.         HStack { 
  8.           Button("-") { viewStore.send(.decrement) } 
  9.           // ... 
  10.   } 
  11.    
  12.   func checkLabel(with checkResult: Counter.CheckResult) -> some View { 
  13.     switch checkResult { 
  14.     case .lower
  15.       return Label("Lower", systemImage: "lessthan.circle"
  16.         .foregroundColor(.red) 
  17.     case .higher: 
  18.       return Label("Higher", systemImage: "greaterthan.circle"
  19.         .foregroundColor(.red) 
  20.     case .equal: 
  21.       return Label("Correct", systemImage: "checkmark.circle"
  22.         .foregroundColor(.green) 
  23.     } 
  24.   } 

最終,我們可以得到這樣的 UI:

外部依賴

當我們用這個 UI “蒙對”答案后,Reset 按鈕雖然可以把猜測歸零,但它并不能為我們重開一局,這當然有點無聊。我們來試試看把 Reset 按鈕改成 New Game 按鈕。

在 UI 和 CounterAction 里我們已經定義了 .reset 行為了,進行一些重命名的工作:

  1. enum CounterAction { 
  2.   // ... 
  3. case reset 
  4. case playNext 
  5.  
  6. struct CounterView: View { 
  7.   // ... 
  8.   var body: some View { 
  9.     // ... 
  10. -   Button("Reset") { viewStore.send(.reset) } 
  11. +   Button("Next") { viewStore.send(.playNext) } 
  12.   } 

然后在 counterReducer 里處理這個情況,

  1. struct Counter: Equatable { 
  2.   var countInt = 0 
  3. - let secret = Int.random(in: -100 ... 100) 
  4. + var secret = Int.random(in: -100 ... 100) 
  5.  
  6. let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> { 
  7.   // ... 
  8. case .reset: 
  9. case .playNext: 
  10.     state.count = 0 
  11. +   state.secret = Int.random(in: -100 ... 100) 
  12.     return .none 
  13.   // ... 
  14. }.debug() 

運行 app,觀察 reducer debug() 的輸出,可以看到一切正常!太好了。

隨時 Cmd + U 運行測試是大家都應該養成的習慣,這時候我們可以發現測試編譯失敗了。最后的任務就是修正原來的 .reset 測試,這也很簡單:

  1. func testReset() throws { 
  2. - store.send(.reset) { state in 
  3. + store.send(.playNext) { state in 
  4.     state.count = 0 
  5.   } 

但是,測試的運行結果大概率會失敗!

這是因為 .playNext 現在不僅重置 count,也會隨機生成新的 secret。而 TestStore 會把 send 閉包結束時的 state 和真正的由 reducer 操作的 state 進行比較并斷言:前者沒有設置合適的 secret,導致它們并不相等,所以測試失敗了。

我們需要一種穩定的方式,來保證測試成功。

使用環境值解決依賴

在 TCA 中,為了保證可測試性,reducer 必須是純函數:也就是說,相同的輸入 (state, action 和 environment) 的組合,必須能給出相同的輸入 (在這里輸出是 state 和 effect,我們會在后面的文章再接觸 effect 角色)。

  1. let counterReducer = // ... { 
  2.  
  3.   state, action, _ in  
  4.   // ... 
  5.   case .playNext: 
  6.     state.count = 0 
  7.     state.secret = Int.random(in: -100 ... 100) 
  8.     return .none 
  9.   //... 
  10. }.debug() 

在處理 .playNext 時,Int.random 顯然無法保證每次調用都給出同樣結果,它也是導致 reducer 變得無法測試的原因。TCA 中環境 (Environment) 的概念,就是為了對應這類外部依賴的情況。如果在 reducer 內部出現了依賴外部狀態的情況 (比如說這里的 Int.random,使用的是自動選擇隨機種子的 SystemRandomNumberGenerator),我們可以把這個狀態通過 Environment 進行注入,讓實際 app 和單元測試能使用不同的環境。

首先,更新 CounterEnvironment,加入一個屬性,用它來持有隨機生成 Int 的方法。

  1. struct CounterEnvironment { 
  2. + var generateRandom: (ClosedRange<Int>) -> Int 

現在編譯器需要我們為原來 CounterEnvironment() 的地方加上 generateRandom 的設定。我們可以直接在生成時用 Int.random 來創建一個 CounterEnvironment:

  1. CounterView( 
  2.   store: Store( 
  3.     initialState: Counter(), 
  4.     reducer: counterReducer, 
  5. -   environment: CounterEnvironment() 
  6. +   environment: CounterEnvironment( 
  7. +     generateRandom: { Int.random(in: $0) } 
  8. +   ) 
  9.   ) 

一種更加常見和簡潔的做法,是為 CounterEnvironment 定義一組環境,然后把它們傳到相應的地方:

  1. struct CounterEnvironment { 
  2.   var generateRandom: (ClosedRange<Int>) -> Int 
  3.    
  4. static let live = CounterEnvironment( 
  5. +   generateRandom: Int.random 
  6. + ) 
  7.  
  8. CounterView( 
  9.   store: Store( 
  10.     initialState: Counter(), 
  11.     reducer: counterReducer, 
  12. -   environment: CounterEnvironment() 
  13. +   environment: .live 
  14.   ) 

現在,在 reducer 中,就可以使用注入的環境值來達到和原來等效的結果了:

  1. let counterReducer = // ... { 
  2. - state, action, _ in 
  3. + state, action, environment in 
  4.   // ... 
  5.   case .playNext: 
  6.     state.count = 0 
  7. -   state.secret = Int.random(in: -100 ... 100) 
  8. +   state.secret = environment.generateRandom(-100 ... 100) 
  9.     return .none 
  10.   // ... 
  11. }.debug() 

萬事俱備,回到最開始的目的 - 保證測試能順利通過。在 test target 中,用類似的方法創建一個 .test 環境:

  1. extension CounterEnvironment { 
  2.   static let test = CounterEnvironment(generateRandom: { _ in 5 }) 

現在,在生成 TestStore 的時候,使用 .test,然后在斷言時生成合適的 Counter 作為新的 state,測試就能順利通過了:

  1. store = TestStore( 
  2.   initialState: Counter(countInt.random(in: -100...100)), 
  3.   reducer: counterReducer, 
  4. - environment: CounterEnvironment() 
  5. + environment: .test 
  6.  
  7. store.send(.playNext) { state in 
  8. - state.count = 0 
  9. + state = Counter(count: 0, secret: 5) 

在 store.send 的閉包里,我們現在直接為 state 設置了一個新的 Counter,并明確了所有期望的屬性。這里也可以分開兩行,寫成 state.count = 0 以及 state.secret = 5,測試也可以通過。選擇哪種方式都可以,但在涉及到復雜的情況下,會傾向于選擇完整的賦值:在測試中,我們希望的是通過斷言來比較期望 state 和實際 state 的差別,而不是重新去實現一次 reducer 中的邏輯。這可能引入混亂,因為在測試失敗時你需要去排查到底是 reducer 本身的問題,還是測試代碼中操作狀態造成的問題。

其他常見依賴

除了像是 random 系列以外,凡是會隨著調用環境的變化 (包括時間,地點,各種外部狀態等等) 而打破 reducer 純函數特性的外部依賴,都應該被納入 Environment 的范疇。常見的像是 UUID 的生成,當前 Date 的獲取,獲取某個運行隊列 (比如 main queue),使用 Core Location 獲取現在的位置信息,負責發送網絡請求的網絡框架等等。

它們之中有一些是可以同步完成的,比如例子中的 Int.random;有一些則是需要一定時間才能得到結果,比如獲取位置信息和發送網絡請求。對于后者,我們往往會把它轉換為一個 Effect。我們會在下一篇文章中再討論 Effect。

練習

如果你沒有跟隨本文更新代碼,你可以在這里[3]找到下面練習的起始代碼。參考實現可以在這里[4]找到。

添加一個 Slider

用鍵盤和加減號來控制 Counter 已經不錯了,但是添加一個 Slider 會更有趣。請為 CounterView 添加一個 Slider,用來來和 TextField 以及 “+” “-“ Button 一起,控制我們的猜數字游戲。

期望的 UI 大概是這樣:

別忘了寫測試!

完善 Counter,記錄更多信息

為了后面功能的開發,我們需要更新一下 Counter 模型。首先,每個謎題添加一些元信息,比如謎題 ID:

在 Counter 中加上下面的屬性,然后讓它滿足 Identifiable:

  1. - struct Counter: Equatable { 
  2. + struct Counter: Equatable, Identifiable { 
  3.     var countInt = 0 
  4.     var secret = Int.random(in: -100 ... 100) 
  5.    
  6. +   var id: UUID = UUID() 
  7.   } 

 

在開始新一輪游戲的時候,記得更新 id。還有,別忘了寫測試!

 

責任編輯:武曉燕 來源: Swift社區
相關推薦

2021-12-15 08:26:03

TCASwiftUIUIKit

2009-03-20 08:54:16

Windows 7微軟

2021-01-26 14:31:04

IPv6物聯網IOT

2017-10-26 10:25:07

數據恢復服務

2019-11-08 08:16:12

區塊鏈數據存儲去中心化

2021-10-18 08:28:03

Kafka架構主從架構

2021-10-11 11:58:41

Channel原理recvq

2021-12-01 07:02:16

虛擬化LinuxCPU

2022-05-09 11:52:38

Java卡片服務卡片

2022-03-04 15:43:36

文件管理模塊Harmony鴻蒙

2010-10-28 11:25:34

應聘

2023-06-29 08:32:41

Bean作用域

2012-01-16 11:05:22

紅帽PaaS 開源

2013-10-24 10:12:12

Windows XP虛擬化

2016-11-03 19:04:02

磁盤數據TestDisk

2022-04-14 11:35:01

HarmonyOS手表Demo操作系統

2011-11-28 12:55:37

JavaJVM

2012-03-15 16:27:13

JavaHashMap

2021-10-28 19:27:08

C++指針內存

2018-04-19 14:11:50

點贊
收藏

51CTO技術棧公眾號

主站蜘蛛池模板: 岛国av一区二区 | 免费三级黄| 国产精品二区三区在线观看 | 精品欧美一区二区三区久久久 | jav成人av免费播放 | 波多野结衣精品 | www.亚洲成人网 | 日韩精品免费 | 成人精品一区二区三区 | 国产精品日韩欧美一区二区三区 | 亚洲综合色视频在线观看 | 欧美日韩精品一区二区三区四区 | 久久精品日产第一区二区三区 | 久久久国产亚洲精品 | 精品九九九 | 国产欧美日韩一区二区三区 | 日本成人二区 | 草草网| 在线色网 | 久久黄视频| 久久成人18免费网站 | 久久精品国产99国产精品亚洲 | 久久精品国产99国产精品 | 日韩毛片免费看 | 人人干人人干人人干 | 午夜影院在线免费观看视频 | 国产一区免费 | 国产又色又爽又黄又免费 | 亚洲成av人影片在线观看 | www.成人.com | 国产一区二区在线观看视频 | 国产成人aⅴ | 色综合99 | 成人一区在线观看 | 一区二区三区国产精品 | 午夜免费观看网站 | 91精品在线播放 | 伊人精品一区二区三区 | 黑色丝袜三级在线播放 | 在线国产一区 | 成人在线小视频 |