TCA-SwiftUI 的救星之一
本文轉(zhuǎn)載自微信公眾號「Swift社區(qū)」,作者王巍 (onevcat)。轉(zhuǎn)載本文請聯(lián)系Swift社區(qū)公眾號。
前言
打算用幾篇文章介紹一下 TCA (The Composable Architecture[1]),這是一種看起來非常契合 SwiftUI 的架構(gòu)方式。
四年多前我寫過一篇關(guān)于使用單向數(shù)據(jù)流來架構(gòu) View Controller[2] 的文章,因為 UIKit 中并沒有強制的 view 刷新流程,所以包括綁定數(shù)據(jù)在內(nèi)的很多事情都需要自己動手,這為大規(guī)模使用造成了不小的障礙。而自那時過了兩年后, SwiftUI 的發(fā)布才讓這套機制有了更加合適的舞臺。在 SwiftUI 發(fā)布初期,我也寫過一本相關(guān)的書籍[3],里面使用了一些類似的想法,但是很不完善?,F(xiàn)在,我想要回頭再看看這樣的架構(gòu)方式,來看看最近一段時間在社區(qū)幫助下的進化,以及它是否能成為現(xiàn)下更好的選擇。
對于以前很少接觸聲明式或者類似架構(gòu)的朋友來說,其中有一些概念和選擇可能不太容易理解,比如為什么 Side Effect 需要額外對應(yīng),如何在不同 View 之間共享狀態(tài),頁面遷移的時候如何優(yōu)雅處理等等。在這一系列文章里,我會盡量按照自己的理解,嘗試闡明一些常見的問題,希望能幫助讀者有一個更加平滑的入門體驗。
作為開篇,我們先來簡單看一看現(xiàn)在 SwfitUI 在架構(gòu)上存在的一些不足。然后使用 TCA 實現(xiàn)一個最簡單的 View。
SwiftUI 很贊,但是…
iOS 15 一聲炮響,給開發(fā)們送來了全新版本的 SwiftUI。它不僅有更加合理的異步方法和全新特性,更是修正了諸多頑疾。可以說,從 iOS 14 開始,SwiftUI 才算逐漸進入了可用的狀態(tài)。而最近隨著公司的項目徹底拋棄 iOS 13,我也終于可以更多地正式在工作中用上 SwiftUI 了。
Apple 并沒有像在 UIKit 中貫徹 MVC 那樣,為 SwiftUI ”欽定“ 一個架構(gòu)。雖然 SwiftUI 中提供了諸多狀態(tài)管理的關(guān)鍵字或?qū)傩园b (property wrapper),比如 @State、@ObservedObject 等,但是你很難說官方 SwiftUI 教程里關(guān)于數(shù)據(jù)傳遞[4]和狀態(tài)管理[5]的部分,足夠指導(dǎo)開發(fā)者構(gòu)建出穩(wěn)定和可擴展的 app。SwiftUI 最基礎(chǔ)的狀態(tài)管理模式,做到了 single source of truth:所有的 view 都是由狀態(tài)導(dǎo)出的,但是它同時也存在了很多不足。簡單就可以列舉一些:
復(fù)雜的狀態(tài)修飾,想要”正常“使用,你至少必須要記住 @State,@ObservedObject,@StateObject,@Binding,@EnvironmentObject 各自的特點和區(qū)別。
很多修改狀態(tài)的代碼內(nèi)嵌在 View.body 中,甚至只能在 body 中和其他 view 代碼混雜在一起。同一個狀態(tài)可能被多個不相關(guān)的 View 直接修改 (比如通過 Binding),這些修改難以被追蹤和定位,在 app 更復(fù)雜的情況下會是噩夢。
測試困難: 這可能和直覺相反,因為 SwiftUI 框架的 view 完全是由狀態(tài)決定的,所以理論上來說我們只需要測試狀態(tài) (也就是 model 層) 就行,這本應(yīng)是很容易的。但是如果嚴格按照 Apple 官方教程的基本做法,app 中會存在大量私有狀態(tài),這些狀態(tài)難以 mock,而且就算可以,如何測試對這些狀態(tài)的修改也是問題。
當(dāng)然,這些不足都可以克服,比如死記硬背下五種屬性包裝的寫法、盡可能減少共享可變狀態(tài)來避免被意外修改、以及按照 Apple 的推薦[6]準備一組 preview 的數(shù)據(jù)然后打開 View 文件去挨個檢查 Preview 的結(jié)果 (雖然有一些自動化工具[7]幫我們解放雙眼,但嚴肅點兒,別笑,Apple 在這個 session 里原本的意思就是讓我們?nèi)ゲ殇秩窘Y(jié)果!)。
我們真的需要一種架構(gòu),來讓 SwiftUI 的使用更加輕松一些。
從 Elm 獲得的啟示
我估摸著前端開發(fā)的圈子一年能大約能誕生 500 多種架構(gòu)[8]。如果我們需要一種新架構(gòu),那去前端那邊抄一下大抵是不會錯的。結(jié)合 SwiftUI 的特點,Elm[9] 就是非常優(yōu)秀的”抄襲“對象。
說實話,要是你現(xiàn)在正好想要學(xué)習(xí)一門語言,那我想推薦的就是 Elm。不過雖然 Elm 是一門通用編程語言[10],但可以說這門語言實際上只為一件事服務(wù),那就是 Elm 架構(gòu) ( The Elm Architecture, TEA)。一個最簡單的 counter 在 Elm 中長成這個樣子:
- type Msg = Increment | Decrement
- update : Msg -> Model -> ( Model, Cmd Msg )
- update msg model =
- case msg of
- Increment ->
- ( model + 1, Cmd.none )
- Decrement ->
- ( model - 1, Cmd.none )
- view model =
- div []
- [ button [ onClick Decrement ] [ text "-" ]
- , div [] [ text (String.fromInt model) ]
- , button [ onClick Increment ] [ text "+" ]
- ]
如果有機會,我再寫一些 Elm 或者 Haskell 的東西。在這里,我決定直接把上面這段代碼”翻譯“成偽 SwiftUI:
- enum Msg {
- case increment
- case decrement
- }
- typealias Model = Int
- func update(msg: Msg, model: Model) -> (Model, Cmd<Msg>) {
- switch msg {
- case .increment:
- return (model + 1, .none)
- case .decrement:
- return (model - 1, .none)
- }
- }
- func view(model: Model) -> some View {
- HStack {
- Button("-") { sendMsg(.decrement) }
- Text("\(model)")
- Button("+") { sendMsg(.increment) }
- }
- }
TEA 架構(gòu)組成部件
整個過程如圖所示 (為了簡潔,先省去了 Cmd 的部分,我們會在系列后面的文章再談到這個內(nèi)容):
- 用戶在 view 上的操作 (比如按下某個按鈕),將會以消息的方式進行發(fā)送。Elm 中的某種機制將捕獲到這個消息。
- 在檢測到新消息到來時,它會和當(dāng)前的 Model 一并,作為輸入傳遞給 update 函數(shù)。這個函數(shù)通常是 app 開發(fā)者所需要花費時間最長的部分,它控制了整個 app 狀態(tài)的變化。作為 Elm 架構(gòu)的核心,它需要根據(jù)輸入的消息和狀態(tài),演算出新的 Model。
- 這個新的 model 將替換掉原有的 model,并準備在下一個 msg 到來時,再次重復(fù)上面的過程,去獲取新的狀態(tài)。
- Elm 運行時負責(zé)在得到新 Model 后調(diào)用 view 函數(shù),渲染出結(jié)果 (在 Elm 的語境下,就是一個前端 HTML 頁面)。用戶可以通過它再次發(fā)送新的消息,重復(fù)上面的循環(huán)。
現(xiàn)在,你已經(jīng)對 TEA 有了基本的了解了。我們類比一下這些步驟在 SwiftUI 中的實現(xiàn),可以發(fā)現(xiàn)步驟 4 其實已經(jīng)包含在 SwiftUI 中了:當(dāng) @State 或 @ObservedObject 的 @Published 發(fā)生變化時,SwiftUI 會自動調(diào)用 View.body 為我們渲染新的界面。因此,想要在 SwiftUI 中實現(xiàn) TEA,我們需要做的是實現(xiàn) 1 至 3?;蛘邠Q句話說,我們需要的是一套規(guī)則,來把零散的 SwiftUI 狀態(tài)管理的方式進行規(guī)范。TCA 正是在這方面做出了非常多的努力。
第一個 TCA app
來實際做一點東西吧,比如上面的這個 Counter。新建一個 SwiftUI 項目。因為我們會涉及到大量測試的話題,所以記得把 “Include Tests” 勾選上。然后在項目的 Package Dependencies 里把 TCA 加入到依賴中:
在本文寫作的 TCA 版本 (0.29.0) 中,使用 Xcode 13.2 的話將無法編譯 TCA 框架。暫時可以使用 Xcode 13.1,或者等待 workaround 修正。
把 ContentView.swift 的內(nèi)容替換為
- struct Counter: Equatable {
- var count: Int = 0
- }
- enum CounterAction {
- case increment
- case decrement
- }
- struct CounterEnvironment { }
- // 2
- let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
- state, action, _ in
- switch action {
- case .increment:
- // 3
- state.count += 1
- return .none
- case .decrement:
- // 3
- state.count -= 1
- return .none
- }
- }
- struct CounterView: View {
- let store: Store<Counter, CounterAction>
- var body: some View {
- WithViewStore(store) { viewStore in
- HStack {
- // 1
- Button("-") { viewStore.send(.decrement) }
- Text("\(viewStore.count)")
- Button("+") { viewStore.send(.increment) }
- }
- }
- }
- }
基本上就是對上面 Elm 翻譯的偽 SwiftUI 代碼進行了一些替換:Model -> Counter,Msg -> CounterAction,update(msg:model:) -> counterReducer,view(model:) -> ContentView.body。
Reducer,Store 和 WithViewStore 是 TCA 中的類型:
- Reducer 是函數(shù)式編程中的常見概念,顧名思意,它將多項內(nèi)容進行合并,最后返回單個結(jié)果。
- ContentView 中,我們不直接操作 Counter,而是將它放在一個 Store 中。這個 Store 負責(zé)把 Counter (State) 和 Action 連接起來。
- CounterEnvironment 讓我們有機會為 reducer 提供自定義的運行環(huán)境,用來注入一些依賴。我們會把相關(guān)內(nèi)容放到后面再解釋。
上面的代碼中 1 至 3,恰好就對應(yīng)了 TEA 組成部件中對應(yīng)的部分:
1.發(fā)送消息,而非直接改變狀態(tài)
任何用戶操作,我們都通過向 viewStore 發(fā)送一個 Action 來表達。在這里,當(dāng)用戶按下 “-“ 或 “+” 按鈕時,我們發(fā)送對應(yīng)的 CounterAction。選擇將 Action 定義為 enum,可以帶來更清晰地表達意圖。但不僅如此,它還能在合并 reducer 時帶來很多便利的特性,在后續(xù)文章中我們會涉及相關(guān)話題。雖然并不是強制,但是如果沒有特殊理由,我們最好跟隨這一實踐,用 enum 來表達 Action。
2.只在 Reducer 中改變狀態(tài)
我們已經(jīng)說過,Reducer 是邏輯的核心部分。它同時也是 TCA 中最為靈活的部分,我們的大部分工作應(yīng)該都是圍繞打造合適的 Reducer 來展開的。對于狀態(tài)的改變,應(yīng)且僅應(yīng)在 Reducer 中完成:它的初始化方法接受一個函數(shù),其類型為:
- (inout State, Action, Environment) -> Effect<Action, Never>
inout 的 State 讓我們可以“原地”對 state 進行變更,而不需要明確地返回它。這個函數(shù)的返回值是一個 Effect,它代表不應(yīng)該在 reducer 中進行的副作用,比如 API 請求,獲取當(dāng)前時間等。我們會在下一篇文章中看到這部分內(nèi)容。
3.更新狀態(tài)并觸發(fā)渲染 在 Reducer 閉包中改變狀態(tài)是合法的,新的狀態(tài)將被 TCA 用來觸發(fā) view 的渲染,并保存下來等待下一次 Action 到來。在 SwiftUI 中,TCA 使用 ViewStore (它本身是一個 ObservableObject) 來通過 @ObservedObject 觸發(fā) UI 刷新。
有了這些內(nèi)容,整個模塊的運行就閉合了。在 Preview 的部分傳入初始的 model 實例和 reducer 來創(chuàng)建 Store:
- struct ContentView_Previews: PreviewProvider {
- static var previews: some View {
- CounterView(
- store: Store(
- initialState: Counter(),
- reducer: counterReducer,
- environment: CounterEnvironment()
- )
- }
- }
最后,在 App 的入口將 @main 的內(nèi)容也替換成帶有 store 的 CounterView,整個程序就可以運行了:
- @main
- struct CounterDemoApp: App {
- var body: some Scene {
- WindowGroup {
- CounterView(
- store: Store(
- initialState: Counter(),
- reducer: counterReducer,
- environment: CounterEnvironment())
- )
- }
- }
- }
Debug 和 Test
這一套機制能正常運行的一個重要前提,是通過 model 對 view 進行渲染的部分是正確的。也就是說,我們需要相信 SwiftUI 中 State -> View 的過程是正確的 (實際上就算不正確,作為 SwiftUI 這個框架的使用者來說,我們能做的事情其實有限)。在這個前提下,我們只需要檢查 Action 的發(fā)送是否正確,以及 Reducer 中對 State 的變更是否正確就行了。
TCA 中 Reducer 上有一個非常方便的 debug() 方法,它會為這個 Reducer 開啟控制臺的調(diào)試輸出,打印出接收到的 Action 以及其中 State 的變化。為 counterReducer 加上這個調(diào)用:
- let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
- // ...
- }.debug()
這時,點擊按鈕會給我們這樣的輸出,State 的變化被以 diff 的方式打印出來:
.debug() 只會在 #if DEBUG 的編譯條件下打印,也就是說在 Release 時其實并不產(chǎn)生影響。另外,當(dāng)我們有更多更復(fù)雜的 Reducer 時,我們也可以選擇只在某個或某幾個 Reducer 上調(diào)用 .debug() 來幫助調(diào)試。在 TCA 中,一組關(guān)聯(lián)的 State/Reducer/Action (以及 Environment) 統(tǒng)合起來稱為一個 Feature。我們總是可以通過把小部件的 Feature 整體一起,組合形成更大的 Feature 或是添加到其他 Feature 上去,形成一組更大的功能。這種依靠組合的開發(fā)方式,可以讓我們保持小 Feature 的可測試和可用性。而這種組合,也正是 The Composable Architecture 中 Composable 所代表的意涵。
現(xiàn)在我們還只有 Counter 這一個 Feature。隨著 app 越來越復(fù)雜,在后面我們會看到更多的 Feature,以及如何通過 TCA 提供的工具,將它們組合到一起。
使用 .debug() 可以讓我們在控制臺實際看到狀態(tài)變化的方式,但如果能用單元測試確保這些變化,會更加高效和有意義。在 Unit Test 里,我們添加一個測試,來驗證發(fā)送 .increment 時的情況:
- func testCounterIncrement() throws {
- let store = TestStore(
- initialState: Counter(count: Int.random(in: -100...100)),
- reducer: counterReducer,
- environment: CounterEnvironment()
- )
- store.send(.increment) { state in
- state.count += 1
- }
- }
TestStore 是 TCA 中專門用來處理測試的一種 Store。它在接受通過 send 發(fā)送的 Action 的同時,還在內(nèi)部帶有斷言。如果接收到 Action 后產(chǎn)生的新的 model 狀態(tài)和提供的 model 狀態(tài)不符,那么測試失敗。上例中,store.send(.increment) 所對應(yīng)的 State 變更,應(yīng)該是 count 增加一,因此在 send 方法提供的閉包部分,我們正確更新了 state 作為最終狀態(tài)。
在初始化 Counter 提供 initialState 時,我們傳遞了一個隨機值。通過使用 Xcode 13 提供的“重復(fù)測試”功能 (右鍵點擊對應(yīng)測試左側(cè)的圖標),我們可以重復(fù)這個測試,這可以讓我們通過提供不同的初始狀態(tài),來覆蓋更多的情況。在這個簡單的例子中可能顯得“小題大作”,但是在更加復(fù)雜的場景里,這有助于我們發(fā)現(xiàn)一些潛藏的問題。
如果測試失敗,TCA 也會通過 dump 打印出非常漂亮的 diff 結(jié)果,讓錯誤一目了然:
除了自帶斷言,TestStore 還有其他一些用法,比如用來對應(yīng)時序敏感的測試。另外,通過配置合適的 Environment,我們可以提供穩(wěn)定的 Effect 作為 mock。這些課題其實在我們使用其他架構(gòu)時,也都會遇到,在有些情況下會很難處理。這種時候,開發(fā)者們的選擇往往是“如果寫測試太麻煩,那要不就算了吧”。在 TCA 這一套易用的測試套件的幫助下,我們大概很難再用這個借口逃避測試。大多數(shù)時候,書寫測試反而變成一種樂趣,這對項目質(zhì)量的提升和保障可謂厥功至偉。
Store 和 ViewStore
切分 Store 避免不必要的 view 更新
在這個簡單的例子中,有一個很重要的部分,我決定放到本文最后進行強調(diào),那就是 Store 和 ViewStore 的設(shè)計。Store扮演的是狀態(tài)持有者,同時也負責(zé)在運行的時候連接 State 和 Action。Single source of truth 是狀態(tài)驅(qū)動 UI 的最基本原則之一,由于這個要求,我們希望持有狀態(tài)的角色只有一個。因此很常見的選擇是,整個 app 只有一個 Store。UI 對這個 Store 進行觀察 (比如通過將它設(shè)置為 @ObservedObject),攫取它們所需要的狀態(tài),并對狀態(tài)的變化作出響應(yīng)。
通常情況下,一個這樣的 Store 中會存在非常多的狀態(tài)。但是具體的 view 一般只需要一來其中一個很小的子集。比如上圖中 View 1 只需要依賴 State 1,而完全不關(guān)心 State 2。
如果讓 View 直接觀察整個 Store,在其中某個狀態(tài)發(fā)生變化時,SwiftUI 將會要求所有對 Store 進行觀察的 UI 更新,這會造成所有的 view 都對 body 進行重新求值,是非常大的浪費。比如下圖中,State 2 發(fā)生了變化,但是并不依賴 State 2 的 View 1 和 View 1-1 只是因為觀察了 Store,也會由于 @ObservedObject 的特性,重新對 body 進行求值:
TCA 中為了避免這個問題,把傳統(tǒng)意義的 Store 的功能進行了拆分,發(fā)明了 ViewStore 的概念:
Store 依然是狀態(tài)的實際管理者和持有者,它代表了 app 狀態(tài)的純數(shù)據(jù)層的表示。在 TCA 的使用者來看,Store 最重要的功能,是對狀態(tài)進行切分,比如對于圖示中的 State 和 Store:
- struct State1 {
- struct State1_1 {
- var foo: Int
- }
- var childState: State1_1
- var bar: Int
- }
- struct State2 {
- var baz: Int
- }
- struct AppState {
- var state1: State1
- var state2: State2
- }
- let store = Store(
- initialState: AppState( /* */ ),
- reducer: appReducer,
- environment: ()
- )
在將 Store 傳遞給不同頁面時,可以使用 .scope 將其”切分“出來:
- let store: Store<AppState, AppAction>
- var body: some View {
- TabView {
- View1(
- store: store.scope(
- state: \.state1, action: AppAction.action1
- )
- )
- View2(
- store: store.scope(
- state: \.state2, action: AppAction.action2
- )
- )
- }
- }
這樣可以限制每個頁面所能夠訪問到的狀態(tài),保持清晰。
最后,再來看這一段最簡單的 TCA 架構(gòu)下的代碼:
- struct CounterView: View {
- let store: Store<Counter, CounterAction>
- var body: some View {
- WithViewStore(store) { viewStore in
- HStack {
- Button("-") { viewStore.send(.decrement) }
- Text("\(viewStore.count)")
- Button("+") { viewStore.send(.increment) }
- }
- }
- }
- }
TCA 通過 WithViewStore 來把一個代表純數(shù)據(jù)的 Store 轉(zhuǎn)換為 SwiftUI 可觀測的數(shù)據(jù)。不出意外,當(dāng) WithViewStore 接受的閉包滿足 View 協(xié)議時,它本身也將滿足 View,這也是為什么我們能在 CounterView 的 body 直接用它來構(gòu)建一個 View 的原因。WithViewStore 這個 view,在內(nèi)部持有一個 ViewStore 類型,它進一步保持了對于 store 的引用。作為 View,它通過 @ObservedObject 對這個 ViewStore 進行觀察,并響應(yīng)它的變更。因此,如果我們的 View 持有的只是切分后的 Store,那么原始 Store 其他部分的變更,就不會影響到當(dāng)前這個 Store 的切片,從而保證那些和當(dāng)前 UI 不相關(guān)的狀態(tài)改變,不會導(dǎo)致當(dāng)前 UI 的刷新。
當(dāng)我們在 View 之間自上向下傳遞數(shù)據(jù)時,盡量保證把 Store 進行細分,就能保證模塊之間互不干擾。但是,實際上在使用 TCA 做項目時,更多的情景時我們從更小的模塊進行構(gòu)建 (它會包含自己的一套 Feature),然后再把這些本地內(nèi)容”添加“到它的上級。所以 Store 的切分將會變得自然而然?,F(xiàn)在你可能對這部分內(nèi)容還有懷疑,但是在后面的幾篇文章中,會逐步深入 feature 劃分和組織,在那里你可以看到更多的例子。
跨 UI 框架的使用
另一方面,Store 和 ViewStore 的分離,讓 TCA 可以擺脫對 UI 框架的依賴。在 SwiftUI 中,body 的刷新是 SwiftUI 運行時通過 @ObservedObject 屬性包裝所提供的特性?,F(xiàn)在這部分內(nèi)容被包含在了 WithViewStore 中。但是 Store 和 ViewStore 本身并不依賴于任何特定的 UI 框架。也就是說,我們也可以在 UIKit 或者 AppKit 的 app 中用同一套 API 來使用 TCA。雖然這需要我們自己去將 View 和 Model 綁定起來,會有些麻煩,但是如果你想要盡快嘗試 TCA,卻又不能使用 SwiftUI,也可以在 UIKit 中進行學(xué)習(xí)。你得到的經(jīng)驗可以很容易遷移到其他的 UI 平臺 (甚至 web app) 中去。
練習(xí)
為了鞏固,我也準備了一些練習(xí)。完成后的項目將會作為下一篇文章的起始代碼使用。不過如果你實在不想進行這些練習(xí),或者不確定是否正確完成,每一篇文章也提供了初始代碼以供參考,所以不必擔(dān)心。如果你沒有跟隨代碼部分完成這個示例,你可以在這里[11]找到這次練習(xí)的初始代碼。參考實現(xiàn)可以在這里[12]找到。
為數(shù)據(jù)文本添加顏色
為了更好地看清數(shù)字的正負,請為數(shù)字加上顏色[13]:正數(shù)時用綠色顯示,負數(shù)時用紅色顯示。
添加一個 Reset 按鈕
除了加和減以外,添加一個重置按鈕,按下后將數(shù)字復(fù)原為 0。
為 Counter 補全所有測試
現(xiàn)在測試中只包含了 .increment 的情況。請?zhí)砑訙p號和重置按鈕的相關(guān)測試。
參考資料
[1]The Composable Architecture: https://github.com/pointfreeco/swift-composable-architecture
[2]使用單向數(shù)據(jù)流來架構(gòu) View Controller: https://onevcat.com/2017/07/state-based-viewcontroller/
[3]相關(guān)的書籍: https://objccn.io/products/swift-ui
[4]數(shù)據(jù)傳遞: https://developer.apple.com/tutorials/app-dev-training/passing-data-with-bindings
[5]狀態(tài)管理: https://developer.apple.com/tutorials/app-dev-training/managing-state-and-life-cycle
[6]推薦: https://developer.apple.com/videos/play/wwdc2019/233/
[7]自動化工具: https://www.raywenderlich.com/24426963-snapshot-testing-tutorial-for-swiftui-getting-started
[8]誕生 500 多種架構(gòu): https://www.zhihu.com/question/314536318
[9]Elm: https://elm-lang.org/
[10]通用編程語言: https://zh.wikipedia.org/wiki/%E9%80%9A%E7%94%A8%E7%BC%96%E7%A8%8B%E8%AF%AD%E8%A8%80
[11]練習(xí)初始代碼: https://github.com/onevcat/CounterDemo/releases/tag/part-1-start
[12]參考實現(xiàn): https://github.com/onevcat/CounterDemo/releases/tag/part-1-finish
[13]加上顏色: https://developer.apple.com/documentation/swiftui/view/foregroundcolor(_:)