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

TCA-SwiftUI 的救星之二

開發(fā) 后端
當我們把某個狀態(tài)通過 Binding 交給其他 view 時,這個 view 就有能力改變?nèi)ブ苯痈淖儬顟B(tài)了,實際上這是違反了 TCA 中關(guān)于只能在 reducer 中更改狀態(tài)的規(guī)定的。

[[440914]]

前言

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

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

關(guān)于綁定

綁定和普通狀態(tài)的區(qū)別

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

當我們把某個狀態(tài)通過 Binding 交給其他 view 時,這個 view 就有能力改變?nèi)ブ苯痈淖儬顟B(tài)了,實際上這是違反了 TCA 中關(guān)于只能在 reducer 中更改狀態(tài)的規(guī)定的。對于綁定,TCA 中為 View Store 添加了將狀態(tài)轉(zhuǎn)換為一種“特殊綁定關(guān)系”的方法。我們來試試看把 Counter 例子中的顯示數(shù)字的 Text 改成可以接受直接輸入的 TextField。

在 TCA 中實現(xiàn)單個綁定

首先,為 CounterAction 和 counterReducer 添加對應(yīng)的接受一個字符串值來設(shè)定 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 兩個參數(shù),它們都是和當前 View Store 及綁定 view 類型相關(guān)的泛型函數(shù)。在特化 (將泛型在這個上下文中轉(zhuǎn)換為具體類型) 后:

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

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

簡化代碼

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

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

把 reducer 中轉(zhuǎn)換 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 已經(jīng)可以被當作函數(shù)使用了,因此我們可以把 \Counter.countString 的類型看作 (Counter) -> String。同時,Swift 5.3 中 enum case 也可以當作函數(shù)[2],可以認為 CounterAction.setCount 具有類型 (String) -> CounterAction。兩者恰好滿足 binding 的兩個參數(shù)的要求,所以可以進一步將創(chuàng)建綁定的部分簡化:

  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 中設(shè)計了 @BindableState 和 BindableAction,讓多個綁定的寫法簡單一些。具體來說,分三步:

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

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

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

直接用代碼說明會更快:

  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 就能對應(yīng)了。這段代碼能夠工作,是因為 BindableAction 要求一個簽名為 BindingAction -> Self 且名為 binding 的函數(shù):

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

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

環(huán)境值

猜數(shù)字游戲

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

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

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

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

檢查 count 和 secret 的關(guān)系,返回答案:

  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 中顯示一個代表結(jié)果的 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 里我們已經(jīng)定義了 .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 運行測試是大家都應(yīng)該養(yǎng)成的習(xí)慣,這時候我們可以發(fā)現(xiàn)測試編譯失敗了。最后的任務(wù)就是修正原來的 .reset 測試,這也很簡單:

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

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

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

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

使用環(huán)境值解決依賴

在 TCA 中,為了保證可測試性,reducer 必須是純函數(shù):也就是說,相同的輸入 (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 顯然無法保證每次調(diào)用都給出同樣結(jié)果,它也是導(dǎo)致 reducer 變得無法測試的原因。TCA 中環(huán)境 (Environment) 的概念,就是為了對應(yīng)這類外部依賴的情況。如果在 reducer 內(nèi)部出現(xiàn)了依賴外部狀態(tài)的情況 (比如說這里的 Int.random,使用的是自動選擇隨機種子的 SystemRandomNumberGenerator),我們可以把這個狀態(tài)通過 Environment 進行注入,讓實際 app 和單元測試能使用不同的環(huán)境。

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

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

現(xiàn)在編譯器需要我們?yōu)樵瓉?CounterEnvironment() 的地方加上 generateRandom 的設(shè)定。我們可以直接在生成時用 Int.random 來創(chuàng)建一個 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 定義一組環(huán)境,然后把它們傳到相應(yīng)的地方:

  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.   ) 

現(xiàn)在,在 reducer 中,就可以使用注入的環(huán)境值來達到和原來等效的結(jié)果了:

  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 中,用類似的方法創(chuàng)建一個 .test 環(huán)境:

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

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

其他常見依賴

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

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

練習(xí)

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

添加一個 Slider

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

期望的 UI 大概是這樣:

別忘了寫測試!

完善 Counter,記錄更多信息

為了后面功能的開發(fā),我們需要更新一下 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社區(qū)
相關(guān)推薦

2021-12-15 08:26:03

TCASwiftUIUIKit

2009-03-20 08:54:16

Windows 7微軟

2019-11-08 08:16:12

區(qū)塊鏈數(shù)據(jù)存儲去中心化

2021-10-18 08:28:03

Kafka架構(gòu)主從架構(gòu)

2021-01-26 14:31:04

IPv6物聯(lián)網(wǎng)IOT

2017-10-26 10:25:07

數(shù)據(jù)恢復(fù)服務(wù)

2022-05-09 11:52:38

Java卡片服務(wù)卡片

2022-03-04 15:43:36

文件管理模塊Harmony鴻蒙

2021-12-01 07:02:16

虛擬化LinuxCPU

2021-10-11 11:58:41

Channel原理recvq

2010-10-28 11:25:34

應(yīng)聘

2023-06-29 08:32:41

Bean作用域

2012-03-15 16:27:13

JavaHashMap

2021-10-28 19:27:08

C++指針內(nèi)存

2018-04-19 14:11:50

2021-06-29 08:28:12

算法順序表數(shù)據(jù)

2021-01-18 05:33:08

機器學(xué)習(xí)前端算法

2021-02-15 15:36:20

Vue框架數(shù)組

2020-10-15 14:10:51

網(wǎng)絡(luò)攻擊溯源

2012-01-16 11:05:22

紅帽PaaS 開源
點贊
收藏

51CTO技術(shù)棧公眾號

主站蜘蛛池模板: 国产小视频精品 | 91精品久久久久久久久中文字幕 | 日韩成人免费在线视频 | 欧美日韩久久久 | av天天干 | 看真人视频一级毛片 | 精品久草 | 成年人黄色免费视频 | 国产精品视频一区二区三区 | 天堂久| 中文字幕一级 | 精品欧美一区二区三区久久久 | 99视频免费在线 | 老妇激情毛片免费 | 欧美成人a∨高清免费观看 欧美日韩中 | 亚洲成人一二区 | 自拍偷拍在线视频 | 中文字幕乱码一区二区三区 | 二区三区视频 | 亚洲综合首页 | 亚洲精品国产a久久久久久 中文字幕一区二区三区四区五区 | 精品自拍视频在线观看 | 亚洲成人www | 精品视频一区二区三区四区 | 91av免费看 | 国产成人综合久久 | 91就要激情| 久久久亚洲 | 亚洲国产专区 | 亚洲免费久久久 | 亚洲天堂一区 | 美女久久久久 | 一区二区三区视频在线免费观看 | 99re视频在线 | 久热精品视频 | 欧美日韩久久 | 亚洲福利免费 | 欧美一区二区免费 | 亚洲国产高清在线 | 成人av在线播放 | 在线观看中文视频 |