前言在上一篇关于TCA的文章中,我们概述了TCA中的Feature是如何工作的,并尝试实现了一个最小的Feature及其测试。在本文中,我们将继续研究TCA中Binding的处理,并使用Environment将依赖项与reducer解耦。如果你想继续,你可以在完成上一篇文章的练习后直接使用最后的状态,或者从这里[1]获取起始代码。关于绑定绑定和普通状态的区别上一篇我们实现了“点击按钮”->“发送Action”->“更新状态”->“触发UI更新”的流程,解决了“状态驱动”的问题用户界面”主题。不过SwiftUI除了简单的“通过状态更新UI”之外,还支持反向使用@Binding将某个State绑定到控件上,这样UI就可以不用我们的代码改变某个状态。在SwiftUI中,我们几乎可以在所有既表示状态又接受输入的控件中找到这种模式,比如TextField接受String的Binding,Toggle接受Bool的Binding等。当我们通过Binding将某个状态传递给其他视图时,这个视图有能力改变直接改变状态。事实上,这违反了只能在减速器中更改状态的TCA规定。对于绑定,TCA为ViewStore添加了一种将状态转换为“特殊绑定关系”的方法。让我们尝试将Counter示例中显示数字的Text更改为可以接受直接输入的TextField。在TCA中实现单一绑定首先,为CounterAction和counterReducer添加相应的接受字符串值的能力来设置计数:,_inswitchaction{//...+case.setCount(lettext):+ifletvalue=Int(text){+state.count=value+}+return.none//...}.debug()下,替换原来的正文中的文本具有以下TextField:varbody:someView{WithViewStore(store){viewStorein//...-Text("\(viewStore.count)")+TextField(+String(viewStore.count),+text:viewStore.binding(+get:{String($0.count)},+send:{CounterAction.setCount($0)}+)+)+.frame(width:40)+.multilineTextAlignment(.center)。foregroundColor(colorOfCount(viewStore.count))}}viewStore.binding方法接受get和send参数,这两个参数都是与当前ViewStore和绑定的视图类型相关的泛型函数。特化后(在此上下文中将泛型类型转换为具体类型):get:(Counter)->String负责为对象View(此处为TextField)提供数据。send:(String)->CounterAction负责将View新发送的值转换成ViewStore可以理解的action,发送给触发counterReducer。在counterReducer接收到绑定给出的setCount事件后,我们又回到了使用reducer更新状态和驱动UI的标准TCA循环。在传统的SwiftUI中,当我们通过$符号获取一个状态的Binding时,我们实际上调用的是它的projectedValue。在内部,viewStore.binding将ViewStore本身包装成一个ObservedObject,然后使用自定义的projectedValue来设置输入get并发送给Binding以供使用。在内部,它通过内部存储来维护状态并隐藏这个细节;在外部,它通过动作发送状态变化。捕获这个变化,相应地更新它,最后通过get再次给绑定设置新的状态,是开发者需要保证的。简化代码,做一点重构:现在binding的get是$0.count生成的String,reducer中state.count的设置也需要先从String转换成Int。我们把Mode和View表示相关的部分提取出来,放入Counter的扩展中,作为ViewModel使用:extensionCounter{varcountString:String{get{String(count)}set{count=Int(newValue)??count}}}将reducer中的String转换部分替换成countString:letcounterReducer=Reducer{state,action,_inswitchaction{//...case.setCount(lettext):-ifletvalue=Int(text){-state.count=value-}+state.countString=textreturn.none//...}.debug()在Swift5.2中,KeyPath已经可以作为函数使用了,所以我们可以使用\Counter的类型.countString被视为(Counter)->String。同时,Swift5.3中的enumcase也可以作为函数使用[2],可以认为CounterAction.setCount的类型为(String)->CounterAction。两者正好满足绑定的两个参数的要求,所以创建绑定的部分可以进一步简化://...TextField(String(viewStore.count),text:viewStore.binding(-get:{String($0.count)},+get:\.countString,-send:{CounterAction.setCount($0)}+send:CounterAction.setCount))//...最后,不要忘记为.setCount添加测试!Multiplebindings固定值如果一个Feature中有多个bindingvalues,使用例子中的方法,我们需要每次都添加一个action,然后在binding中发送。这是相同的模板代码,@BindableState和BindableAction在TCA中被设计为使编写多个绑定更容易。具体分为三步:在State中需要绑定UI的变量上加上@BindableState。将Action声明为BindableAction,然后添加一个“特殊”casebinding(BindingAction)。在Reducer中处理此.binding并添加.binding()调用。直接使用代码说明会更快://1structMyState:Equatable{+@BindableStatevarfoo:Bool=false+@BindableStatevarbar:String=""}//2-enumMyAction{+enumMyAction:BindableAction{+casebinding(BindingAction)}//3letmyReducer=//...//...+case.binding:+return.none}+.binding()经过这样的操作,我们可以在View中使用类似于标准SwiftUI的方法,使用$获取绑定的预计值:structMyView:View{letstore:Storevarbody:someView{WithViewStore(store){viewStorein+Toggle("Toggle!",isOn:viewStore.binding(\.$foo))+TextField("TextField!",text:viewStore.binding(\.$bar))}}}这样即使有多个绑定值,我们也只需要用一个.binding动作来对应即可。这段代码之所以有效,是因为BindableAction需要一个名为binding且签名为BindingAction->Self的函数:代码可以变得非常简洁和优雅。环境值猜数字游戏回到计数器的例子。既然已经有了输入数字的方法,那我们就来做一个猜数字的小游戏吧!猜数字:程序在-100到100之间随机选择一个数字,用户输入一个数字,程序判断这个数字是否是随机选择的数字。如果不是,返回“太大”或“太小”作为反馈,并要求用户继续尝试猜测下一个数字。最简单的方法是在Counter中添加一个属性来保存这个随机数:structCounter:Equatable{varcount:Int=0+letsecret=Int.random(in:-100...100)}检查count和Secret的关系,返回答案:extensionCounter{enumCheckResult{caselower,equal,higher}varcheckResult:CheckResult{ifcountsecret{return.higher}return.equal}}有了这个模型,我们可以通过使用checkResult来显示一个Label代表结果在视图中:(.decrement)}//...}funccheckLabel(withcheckResult:Counter.CheckResult)->someView{switchcheckResult{case.lower:returnLabel("Lower",systemImage:"lessthan.circle").foregroundColor(.red)案例.higher:returnLabel("Higher",systemImage:"greaterthan.circle").foregroundColor(.red)case.equal:returnLabel("Correct",systemImage:"checkmark.circle").foregroundColor(.green)}}}最终,我们可以得到这样一个UI:externaldependencies当我们使用这个UI来“盲”答题时,虽然Reset按钮可以将猜测归零,但是无法重启游戏我们,这当然有点无聊。让我们尝试将重置按钮更改为新游戏按钮。在UI和CounterAction中,我们定义了.reset行为,并做了一些重命名工作:enumCounterAction{//...-casereset+caseplayNext}structCounterView:View{//...varbody:someView{//...-Button("Reset"){viewStore.send(.reset)}+Button("Next"){viewStore.send(.playNext)}}}然后在counterReducer中处理这种情况,structCounter:Equatable{varcount:Int=0-letsecret=Int.random(in:-100...100)+varsecret=Int.random(in:-100...100)}letcounterReducer=Reducer{//...-case.reset:+case.playNext:state.count=0+state.secret=Int.random(in:-100...100)return.none//...}.debug()运行app,看reducerdebug()的输出,可以看到一切正常!伟大的。Cmd+U随时运行测试是每个人都应该养成的习惯。这时我们可以发现测试编译失败了。最后的任务是修复原来的.reset测试,也很简单:functestReset()throws{-store.send(.reset){statein+store.send(.playNext){stateinstate.count=0}}然而,测试运行的结果极有可能会失败!这是因为.playNext现在不仅会重置计数,还会随机生成一个新的秘密。而TestStore会将send闭包结束时的状态与reducer操作的真实状态进行比较,并断言:前者没有设置适当的secret,因此它们不相等,因此测试失败。我们需要一种稳定的方式来保证测试的成功。Resolvingdependenciesusingenvironmentvalues在TCA中,为了可测试,reducer必须是纯函数:也就是说,相同的输入组合(状态、动作和环境)必须给出相同的输入(在这种情况下输出是状态和效果,我们会在后面的文章中触及效果作用)。letcounterReducer=//...{state,action,_in//...case.playNext:state.count=0state.secret=Int.random(in:-100...100)return.none//..当.}.debug()处理.playNext时,Int.random显然不能保证每次调用时都给出相同的结果,这就是reducer变得不可测试的原因。TCA中Environment的概念就是对应这样的外部依赖。如果reducer中存在依赖于外部状态的情况(比如这里的Int.random使用了自动选择随机种子的SystemRandomNumberGenerator),我们可以通过Environment注入这个状态,这样实际的app和unit测试可以使用不同的环境。首先,更新CounterEnvironment以添加一个属性,该属性包含随机生成Int的方法。structCounterEnvironment{+vargenerateRandom:(ClosedRange)->Int}现在编译器需要我们将generateRandom设置添加到原始的CounterEnvironment()。我们可以在生成的时候直接使用Int.random创建一个CounterEnvironment::$0)}+)))一种更常用和简洁的做法是为CounterEnvironment定义一组环境,然后传递到相应的地方:structCounterEnvironment{vargenerateRandom:(ClosedRange)->Int+staticletlive=CounterEnvironment(+generateRandom:Int.random+)}CounterView(store:Store(initialState:Counter(),reducer:counterReducer,-environment:CounterEnvironment()+environment:.live))现在,在reducer中,你可以使用注入环境实现等效结果的值:letcounterReducer=//...{-state,action,_in+state,action,environmentin//...case.playNext:state.count=0-state。secret=Int.random(in:-100...100)+state.secret=environment.generateRandom(-100...100)return.none//...}.debug()万事俱备,回最初的目的——保证考试能够顺利通过。在测试目标中,以类似的方式创建一个.test环境:extensionCounterEnvironment{staticlettest=CounterEnvironment(generateRandom:{_in5})}现在,在生成TestStore时,使用.test,然后生成适当的Counter作为新状态,测试将成功通过:store=TestStore(initialState:Counter(count:Int.random(in:-100...100)),reducer:counterReducer,-environment:CounterEnvironment()+environment:.test)store.send(.playNext){statein-state.count=0+state=Counter(count:0,secret:5)}在store.send的闭包中,我们现在直接为state设置一个新的Counter,并指定所有预期的属性.这里也可以分开两行写state.count=0和state.secret=5,测试就可以通过了。两种方式都可以选择,但是当遇到复杂情况时,你会倾向于选择一个完整的赋值:在测试中,我们希望通过断言来比较预期状态和实际状态的差异,而不是重新实现它减速器中的逻辑。这会带来混淆,因为当一个测试失败时,你需要排查问题是reducer本身的问题,还是测试代码中的运行状态导致的问题。除了random系列等其他常见的依赖,任何随着调用环境的变化(包括时间、位置、各种外部状态等)而破坏reducer纯函数特性的外部依赖都应该归入Environment类。常见的有UUID的生成、当前Date的获取、运行队列(比如主队列)的获取、使用CoreLocation获取当前位置信息、负责发送网络请求的网络框架,等等。有些可以同步完成,比如例子中的Int.random;其中一些需要一定的时间才能得到结果,例如获取位置信息和发送网络请求。对于后者,我们倾向于将其转化为Effect。我们将在下一篇文章中讨论Effects。练习如果您没有按照本文更新您的代码,您可以在此处找到以下练习的起始代码[3]。可以在此处找到参考实现[4]。添加一个Slider以使用键盘和加号和减号控制Counter会很好,但添加一个Slider会更有趣。请给CounterView添加一个Slider,与TextField和"+""-"Button一起用来控制我们的猜数游戏。预期的UI大致是这样的:不要忘记编写测试!改进计数器并记录更多信息。为了后期功能的开发,我们需要更新Counter模型。首先给每个拼图添加一些元信息,比如拼图ID:给Counter添加如下属性,然后使其满足Identifiable:-structCounter:Equatable{+structCounter:Equatable,Identifiable{varcount:Int=0varsecret=Int.random(in:-100...100)+varid:UUID=UUID()}开始新一轮游戏时,记得更新id。另外,不要忘记编写测试!