From meow God blog: onevcat.com/2021/12/tca…

preface

TCA (The Composable Architecture) is an architectural approach that seems to fit SwiftUI well in a few articles.

I wrote about structuring View Controllers with one-way data streams over four years ago, and since THERE is no mandatory View refresh process in UIKit, a lot of things have to be done yourself, including binding data, which poses a significant barrier to large-scale use. It took two years before SwiftUI’s release put the mechanic in a more appropriate place. I wrote a book about SwiftUI in its early days that used some of the same ideas, but it was far from perfect. Now, I want to go back and look at this architectural approach to see how it has evolved in recent times with the help of the community, and whether it could be a better choice now.

For those of you who haven’t been exposed to declarative or similar architectures in the past, some of these concepts and choices may not be easy to understand, such as why Side effects need extra correspondence, how to share state between views, how to gracefully handle page migration, and so on. In this series of articles, I will try to clarify some common questions as I understand them, hoping to help readers have a smoother starting experience.

To start, let’s take a quick look at some of the architectural weaknesses of SwfitUI today. Then use TCA to implement the simplest View possible.

SwiftUI is great, but…

IOS 15 went off with a bang, giving developers a brand new version of SwiftUI. It not only has a more rational asynchronous approach and new features, but also corrects a number of long-standing problems. Since iOS 14, SwiftUI has gradually become available. Recently, with iOS 13 being completely abandoned in the company’s project, I was finally able to use SwiftUI more officially at work.

Apple doesn’t have a “charter” architecture for SwiftUI the way they implement MVC in UIKit. While there are a lot of State management keywords and property wrappers in SwiftUI, such as @State and @Observedobject, you wouldn’t be able to say much about data transfer and State management in the official SwiftUI tutorial. Enough to guide developers in building stable and scalable apps. SwiftUI’s most basic state management mode achieves single source of Truth: All views are exported from states, but it also has many shortcomings. Here are a few:

  • You must at least remember the differences between @State, @observedobject, @StateObject, @binding, and @environmentobject in order to use them properly.

  • Much of the code that changes the state is embedded in view.body, or even intermixed with other View code in the body. The same state can be directly modified by multiple unrelated views (via Binding, for example), which can be difficult to track and locate and can be a nightmare in more complex apps.

  • Testing difficulties: This may be counter-intuitive, as the SwiftUI framework’s View is entirely state-determined, so in theory we only need to test the state (i.e., the Model layer), which should be easy. But if you stick to the basics of Apple’s official tutorials, there are a lot of private states in the app that are hard to mock, and even if you could, how to test changes to those states is a problem.

Of course, these limitations can be overcome, For example, memorizing the five properties of the wrapper, minimizing shared mutable states to avoid accidental modification, and preparing preview data as Recommended by Apple and opening the View file to check preview results one by one (although there are automated tools that help us to keep our eyes free, But seriously, don’t laugh, what Apple originally meant in this session was to check the render results!) .

We really needed an architecture to make SwiftUI easier to use.

Inspiration from Elm

I estimate that the front-end development community produces about 500 architectures a year. If we need a new architecture, we can probably go to the front and copy it. Combined with the characteristics of SwiftUI, Elm is a very good target for plagiarism.

To be honest, if you’re looking to learn a language right now, I’d recommend Elm. But while Elm is a general-purpose programming language, it’s safe to say that it actually serves only one thing, The Elm Architecture (TEA). The simplest counter in Elm looks like this:

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 "+"]]Copy the code

If I get the chance, I’ll write something on Elm or Haskell. In this case, I decided to translate the above code directly into pseudo 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) }
  }
}
Copy the code

TEA architecture components

The entire process looks like this (leaving out the Cmd part for brevity, which we’ll cover in a later article in this series) :

  1. User actions on the View (such as pressing a button) will be sent as messages. Some mechanism in Elm will catch this message.

  2. When a new message is detected, it is passed as input to the Update function, along with the current Model. This function is usually the part of the app developer that takes the most time, controlling the state of the entire app. As the core of the Elm architecture, it needs to calculate the new Model based on input messages and states.

  3. This new model replaces the original model and is ready to repeat the above process again to get the new state when the next MSG arrives.

  4. The Elm runtime is responsible for calling the View function when it gets the new Model, rendering the result (in the Elm context, a front-end HTML page). It allows the user to send a new message again, repeating the above loop.

Now, you have a basic understanding of TEA. Step 4 is already included in SwiftUI: When @State or @observedobject changes to @Published, SwiftUI automatically calls View.body to render the new interface for us. Therefore, to implement TEA in SwiftUI, all we need to do is implement 1 through 3. Or to put it another way, what we need is a set of rules to standardize the way the fragmented SwiftUI state is managed. TCA has made a lot of efforts in this area.

The first TCA app

Let’s actually do something like this Counter up here. Create a SwiftUI project. Remember to check “Include Tests” because we’re going to cover a lot of testing. Add TCA to your project’s Package Dependencies:

In the TCA version of this writing (0.29.0), the TCA framework cannot be compiled using Xcode 13.2. You can use Xcode 13.1 for now, or wait for Workaround to fix it.

Replace the contents of contentView.swift with

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) }
      }
    }
  }
}
Copy the code

Basically, there are some substitutions for the fake SwiftUI code translated by Elm above: Reducer -> Counter, Msg -> CounterAction, update(Msg: Model 🙂 -> counterReducer, view(Model 🙂 -> contentView.body

Reducer.Store 和 WithViewStoreIs a type in TCA:

  • Reducer is a common concept in functional programming. Reducer merges multiple contents and returns a single result.

  • So inside the ContentView, we’re not going to do Counter directly, we’re going to put it in a Store. The Store connects the Counter (State) to the Action.

  • CounterEnvironment gave us the opportunity to provide a custom runtime environment to inject some dependencies into the Reducer. We’ll explain that later.

1 to 3 in the above code exactly correspond to the corresponding parts of the TEA component:

  1. Send a message instead of changing the status directly

We express any user Action by sending an Action to the viewStore. Here, when the user presses the “-” or “+” button, we send the corresponding CounterAction. The choice to define Action as enum allows for a clearer expression of intent. But not only that, it can also bring many convenient features when merging reducer, and we will cover related topics in the following articles. It is not mandatory, but if there is no special reason, it is best to follow this practice and express actions in enums.

  1. Change the state only in the Reducer

As we have said, Reducer is the core part of the logic. It is also the most flexible part of TCA, and most of our work should be focused on creating an appropriate Reducer. For state changes, this should and should only be done in Reducer: its initialization method accepts a function of type:

(inout State.Action.Environment) - >Effect<Action.Never>
Copy the code

Inout’s State allows us to change State “in place” without explicitly returning it. The return value of this function is an Effect that represents any side effects that should not have been done in the Reducer, such as API requests, fetching the current time, etc. We’ll look at this in the next article.

  1. Update state and trigger render

It is legal to change the state in the Reducer closure, and the new state will be used by the TCA to trigger the view rendering and saved until the next Action arrives. In SwiftUI, TCA uses the ViewStore (itself an ObservableObject) to trigger a UI refresh via @obServedobject.

With this, the entire module is closed. Create a Store by passing in an initial Model instance and reducer in the Preview section:

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    CounterView(
      store: Store(
        initialState: Counter(),
        reducer: counterReducer,
        environment: CounterEnvironment()}}Copy the code

Finally, replace the @main content with a Store CounterView in the entry of the App, and the whole program will run:

@main
struct CounterDemoApp: App {
  var body: some Scene {
    WindowGroup {
      CounterView(
        store: Store(
          initialState: Counter(),
          reducer: counterReducer,
          environment: CounterEnvironment())}}}Copy the code

The Debug and Test

An important prerequisite for this mechanism to work is that the rendering of the View through the Model is correct. In other words, we need to trust that the State -> View process in SwiftUI is correct (in fact, even if it is not correct, as users of SwiftUI framework, there is a limit to what we can do). Under this premise, we only need to check whether the Action is sent correctly and whether the changes to State in the Reducer are correct.

There is a very convenient debug() method on Reducer in TCA that turns on the console debugging output for this Reducer, printing out the received actions and the State changes in them. Add this call to the counterReducer:

let counterReducer = Reducer<Counter.CounterAction.CounterEnvironment> {
  // ...
}.debug()
Copy the code

At this point, clicking the button gives us output like this, where the change in State is printed out as diff:

.debug() will only print under compilation conditions for #if debug, which means it doesn’t really matter at Release time. Alternatively, when we have more and more complex reducers, we can choose to call.debug() on just one or a few reducers to help with debugging. In TCA, a set of associated State/Reducer/ actions (and Environment) collectively are called a Feature. We can always combine the features of the widgets together to form a larger Feature or add them to other features to form a group of larger features. This combinatorial approach allows us to keep small features testable and available. This combination is exactly what Composable in The Composable Architecture represents.

Now we only have the Counter Feature. As apps get more complex, we’ll see more features later and how they can be put together using the tools provided by TCA.

Using.debug() allows us to actually see how the state changes on the console, but it would be more efficient and meaningful to ensure these changes with unit tests. In Unit Test, we add a Test to verify that.increment is sent:

func testCounterIncrement(a) throws {
  let store = TestStore(
    initialState: Counter(count: Int.random(in: -100.100)),
    reducer: counterReducer,
    environment: CounterEnvironment()
  )
  store.send(.increment) { state in
    state.count + = 1}}Copy the code

TestStore is a TCA Store specifically designed to handle tests. It accepts actions sent via Send with an assertion inside. If the new Model state generated upon receipt of the Action does not match the supplied Model state, the test fails. In the above example, the State change corresponding to store.send(.increment) should increase count by one, so in the closure provided by the send method, we correctly updated the State as the final State.

When we initialize Counter to provide initialState, we pass a random value. By using the “Repeat test” feature provided by Xcode 13 (right click on the icon to the left of the corresponding test), we can repeat the test, which allows us to cover more cases by providing different initial states. In this simple example it may seem like a “mountain out of a molehill,” but in more complex scenarios it can help uncover hidden problems.

If the test fails, TCA also prints a nice DIff result with dump to make the error clear:

In addition to its own assertions, TestStore has other uses, such as for time-sensitive tests. In addition, by configuring the proper Environment, we can provide stable effects as mocks. These are all issues that we encounter with other architectures as well, and in some cases can be difficult to deal with. At such times, the choice is often “if writing tests is too much trouble, maybe not”. With TCA’s easy-to-use test suite, it’s probably hard to avoid testing with this excuse. Most of the time, writing tests become fun, which is a great way to improve and ensure project quality.

Store and ViewStore

Shred stores to avoid unnecessary view updates

One important part of this simple example that I decided to highlight at the end of this article is the design of the Store and ViewStore. The Store acts as the State holder and is also responsible for connecting State and Action at run time. Single source of Truth is one of the most fundamental principles of state-driven UI, and because of this requirement, we wanted only one character to hold state. So a very common choice is to have only one Store for the entire app. The UI observes the Store (for example, by setting it to @observedobject), grabs the state they need, and responds to changes in state.

Typically, there are a lot of states in a Store like this. But a specific view generally requires only a small subset of them. For example, in the figure above, View 1 only relies on State 1 and doesn’t care about State 2 at all.

If you let the View View the entire Store directly, SwiftUI will require all UI updates for Store observation when one of the states changes, which will cause all views to re-evaluate the body, which is very wasteful. For example, in the following figure, State 2 changes, but it doesn’t depend on View 1 and View 1-1 of State 2, just because of the Store observation, and because of @observedobject, the body is reevaluated:

In order to avoid this problem, TCA split the functions of the traditional Store and invented the concept of ViewStore:

The Store is still the de facto manager and holder of state, representing a pure data-layer representation of app state. From the TCA user’s point of view, the most important function of Store is the partitioning of State, such as State and Store in the diagram:

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: ()
)
Copy the code

When passing stores to different pages, we can use.scope to “shard” them out:

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
      )
    )
  }
}
Copy the code

This limits the state that each page can access and keeps it clear.

Finally, let’s look at the simplest TCA code:

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) }
      }
    }
  }
}
Copy the code

TCA uses WithViewStore to convert a Store representing pure data into SwiftUI observable data. Not surprisingly, when the closure accepted by WithViewStore satisfies the View protocol, it satisfies the View itself, which is why we can use it directly to build a View in the Body of a CounterView. The WithViewStore view, internally, holds a ViewStore type, which further holds a reference to store. As a View, it looks at the ViewStore via @observedobject and responds to its changes. Therefore, if our View holds only the shard Store, changes to other parts of the original Store will not affect the slice of the current Store, ensuring that state changes unrelated to the current UI will not cause a refresh of the current UI.

When passing data from View to View, try to keep the Store subdivided so that the modules don’t interfere with each other. However, in practice in TCA projects, more often than not we build from a smaller module (which contains its own set of features) and then “add” this local content to its parent. So Store shards will become natural. Now you may be skeptical of this part, but in the next few articles, you’ll see more examples of feature partitioning and organization.

Use across UI frameworks

On the other hand, the separation of Store and ViewStore frees TCA from its dependence on the UI framework. In SwiftUI, the refresh of the body is a feature provided by the SwiftUI runtime via the @observedobject attribute wrapper. And now this is included in the WithViewStore. But the Store and ViewStore themselves don’t depend on any particular UI framework. In other words, we can use the same SET of apis to use TCA in UIKit or AppKit apps. Although this requires us to bind the View and Model ourselves, it will be a bit troublesome, but if you want to try TCA as soon as possible but can’t use SwiftUI, you can also learn in UIKit. The lessons you gain can easily be transferred to other UI platforms (or even web apps).

practice

In order to strengthen, I also prepared some exercises. The finished project will be used as the starting code for the next article. But if you really don’t want to do these exercises, or if you’re not sure if you’re doing them correctly, each article also provides the initial code for reference, so don’t worry. If you did not follow the code section through the example, you can find the initial code for this exercise here. The reference implementation can be found here.

Add color to the data text

To better see the numbers, color them: green for positive numbers, red for negative numbers.

Add a Reset button

In addition to add and subtract, add a reset button that restores the number to 0 when pressed.

Complete all tests for Counter

The test now only contains the case of.increment. Please add tests for the minus sign and reset button.