EP 151 · Composable Architecture Performance · Jun 28, 2021 ·Members

Video #151: Composable Architecture Performance: View Stores and Scoping

smart_display

Loading stream…

Video #151: Composable Architecture Performance: View Stores and Scoping

Episode: Video #151 Date: Jun 28, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep151-composable-architecture-performance-view-stores-and-scoping

Episode thumbnail

Description

Did you know the Composable Architecture’s scope operation and ViewStore are performance tools? We’ll explore how to diagnose your app’s performance, how scope can help, and fix a few long-standing performance issues in the library itself.

Video

Cloudflare Stream video ID: f9b3130e102dad86b87229b43f7366bf Local file: video_151_composable-architecture-performance-view-stores-and-scoping.mp4 *(download with --video 151)*

Transcript

0:06

In our last series of episodes we discussed the idea of “ deriving behavior ”, which explored ways to break down large domains of application state and behavior into smaller domains. We showed ways of accomplishing this in vanilla SwiftUI and in the Composable Architecture. For the Composable Architecture in particular we saw some really cool applications of the .scope operation, which allows you to transform a store that holds onto a big blob of data and behavior into smaller and smaller stores. We even showed how we use this tool in our word game isowords to allow tiny, leaf node views to work on only the domain they care about, which means they can also be extracted to their own modules, while still allowing them to be plugged into the whole app.

0:50

Scoping is a really powerful idea in the Composable Architecture, and so we should be using it liberally in our applications to break them down into smaller and smaller pieces.

1:00

However, all is not sunshine and rainbows in the Composable Architecture world. When you start building long chains of scoping and observations you run the risk of introducing performance problems if not done in the right way. Some of this can be solved in user land by being more vigilant with what parts of state need observing and what parts do not, and other things can be solved by the library itself. It turns out that some of the code in the Composable Architecture that handles scoping is not as efficient as it could be, and we want to take a moment to fix those problems.

1:33

Let’s start by first seeing what tools the Composable Architecture ships with that allows us to fine-tune the performance of our applications. To explore this we are going to pick back up the application we built at the beginning of the last series of episodes. View stores and view state

1:48

Here we have the project opened from the last series of episodes. It’s a pretty simple toy app with a tab view at the root, and two tabs. In the first tab we have a counter with the ability to save numbers to a list of favorites, and a second tab to list all those favorites, along with the ability to remove any number.

2:47

The primary performance tool that the Composable Architecture gives us is the ViewStore . Any time we construct one of these, whether it be using the WithViewStore view helper: WithViewStore(self.store) { viewStore in … }

3:06

Or if we created it directly and held onto it as an @ObservedObject : @ObservedObject var viewStore: ViewStore<AppState, AppAction> init(store: Store<AppState, AppAction>) { self.store = store self.viewStore = ViewStore(self.store) }

3:14

We are giving ourselves an opportunity to improve the performance of the view by limiting how much state we observe.

3:21

The reason that this is a performance tool is because ViewStore can only be constructed with stores whose state is equatable, and then under the hood the ViewStore will prevent recomputing the body of a view until the state actually changes.

3:35

If we use this in conjunction with store scoping then we can chisel away the state that the view actually observes to just the bare minimum of what is necessary for the view to do its job: self.viewStore = ViewStore(self.store.scope(state: { … })

3:49

Currently our AppView actually wants access to all of AppState because it uses both the count and the favorites to update the tab items. However, we can show that there’s a potential problem lurking in the shadows here by adding some new state.

4:02

Let’s add a third tab to the application that holds a slider that can be used to change a double value we hold in state. We can start by modeling the domain of this third tab, which can just be a struct wrapping a double and an enum for the single action of setting the value of the double: struct SliderState: Equatable { var value = 0.0 } enum SliderAction { case setValue(Double) } struct SliderEnvironment {}

4:28

Then we can define the reducer to update the state when this action comes in: let sliderReducer = Reducer< SliderState, SliderAction, SliderEnvironment > { state, action, _ in switch action { case let .setValue(value): state.value = value return .none } }

4:41

And we can define a view to show the slider: struct SliderView: View { let store: Store<SliderState, SliderAction> var body: some View { WithViewStore(self.store) { viewStore in Slider( value: viewStore.binding( get: \.value, send: SliderAction.setValue ) ) } } }

5:00

We’ve now built a new feature and it is completely isolated. It doesn’t need to know anything about the greater application, and it could even be put into its own module.

5:08

Now let’s integrate this into the main application. We can add SliderState to AppState : struct AppState: Equatable { … var slider = SliderState() … }

5:23

We can also add the SliderAction s to the AppAction s: enum AppAction { … case slider(SliderAction) }

5:27

If the slider feature needed its own dependencies we would have to further add those to AppEnvironment , but we’re not worrying about dependencies for right now.

5:38

Next we need to combine the sliderReducer into the appReducer so that we can get all of its functionality: let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine( … sliderReducer.pullback( state: \.slider, action: /AppAction.slider, environment: { (_: AppEnvironment) in SliderEnvironment() } ) )

5:58

And finally we can add a new tab to the root tab view: TabView { … SliderView( store: self.store.scope(state: \.slider, action: AppAction.slider) ) .tabItem { Text("Slider") } }

6:09

And that’s all it takes to get the slider feature integrated into the rest of the app. If we run the preview we will see it works, but also it’s nothing too amazing.

6:23

The main reason we wanted to integrate this third feature is because as we drag the slider it fires an action into the store for every little movement, and those actions will cause the state of the application to be updated, which can cause some WithViewStore views to re-compute their bodies.

6:38

To see this, let’s print something to the logs every time a WithViewStore renders. We can do this easily by using a .debug() view helper defined on WithViewStore that will cause the view to print every time the body property is accessed.

6:52

We can throw it on the TcaAppView : struct TcaAppView: View { … var body: some View { WithViewStore(self.store) { viewStore in … } .debug("ContentView") } }

7:05

And on the TcaCounterView : struct TcaCounterView: View { … var body: some View { WithViewStore(self.store) { viewStore in … } .debug("CounterView") } }

7:11

On the TcaProfileView : struct TcaProfileView: View { … var body: some View { WithViewStore(self.store) { viewStore in … } .debug("ProfileView") } }

7:16

And finally the SliderView : struct SliderView: View { … var body: some View { WithViewStore(self.store) { viewStore in … } .debug("SliderView") } }

7:21

In order to see these logs we have to run the view in the simulator, not just a SwiftUI preview.

7:30

When we run this in the simulator we see 3 logs print: ContentView: Evaluating WithViewStore<ViewState, AppAction, …>.body SliderView: Evaluating WithViewStore<SliderState, SliderAction, …>.body CounterView: Evaluating WithViewStore<CounterState, CounterAction, …>.body

7:36

So apparently the main app view, the slider view and the counter view all rendered. Already this is a little strange. Somehow SwiftUI didn’t need to render the profile view. I would expect that either all three tabs are rendered, or only the first is rendered since the other two haven’t be navigated to yet. But oh well, such is the mysteries of SwiftUI.

7:58

If we tap on the profile tab we will finally see the profile body is computed: ProfileView: Evaluating WithViewStore<ProfileState, ProfileAction, …>.body

8:12

Now let’s go back to the first tab and perform an action, like say increment the count. We see the following logs printed: AppView: Evaluating WithViewStore<AppState, AppAction, …>.body SliderView: Evaluating WithViewStore<SliderState, SliderAction, …>.body ProfileView: Evaluating WithViewStore<ProfileState, ProfileAction, …>.body CounterView: Evaluating WithViewStore<CounterState, CounterAction, …>.body

8:19

That’s interesting. The mere fact of changing a single piece of state caused all of our views to be re-computed. Even saving a favorite number has the same behavior.

8:32

So that may seem odd, but it’s also to be expected. In the TcaAppView we are observing all state in AppState with this line here: WithViewStore(self.store) { viewStore … }

8:45

This means the view’s body will recompute when any state changes at all, which will in turn cause all of the tabs to also re-compute their bodies.

8:53

We can really see why this is a problem if we flip over the slider tab and start dragging around the slider.

9:00

Every single little movement we make with the slider causes every single view to re-compute its body: AppView: Evaluating WithViewStore<AppState, AppAction, …>.body SliderView: Evaluating WithViewStore<SliderState, SliderAction, …>.body ProfileView: Evaluating WithViewStore<ProfileState, ProfileAction, …>.body CounterView: Evaluating WithViewStore<CounterState, CounterAction, …>.body …

9:14

And this seems more egregious than when we were incrementing the count because none of the content view, profile view or counter view even use the data that we are changing from the slider feature. Every single one of those body re-computations is being done for no good reason.

9:30

Well, the Composable Architecture gives us a tool to chisel away state so that we observe only the bare minimum a view needs to do its job. For example, the app view only cares about the current count and the favorites array. And so we can define a little equatable struct that holds onto just that data, along with an initializer to turn AppState into ViewState : struct TcaAppView: View { … struct ViewState: Equatable { let count: Int let favorites: Set<Int> init(state: AppState) { self.count = state.count self.favorites = state.favorites } } … }

10:09

Then, instead of observing all state in the WithViewStore we can first scope the store to get one that holds onto the more minimal ViewState : WithViewStore(self.store.scope(state: ViewState.init)) { viewStore in … }

10:36

And everything compiles just as it did before because the field names we were accessing from AppState match what we have in ViewState , except now we don’t hold onto any superfluous state that isn’t needed.

10:48

We can witness this directly by running the application in the simulator again, switching over to the slider tab, and dragging the slider around. We will see that all the other views no longer re-compute. The only one being computed is the slider view. So that’s a pretty huge win.

11:05

But also, now that we have this ViewState in place, we can even start performing further optimizations that would not be possible otherwise. For example, right now we are de-duping re-renders by using the set of favorites, but equality checks on sets can be expensive.

11:21

However, we don’t actually need the full set of favorites in ViewState . All we care about is the count of favorites, and equality check of an integer is super fast, and so might as well hold that in our ViewState : struct TcaAppView: View { … struct ViewState: Equatable { let count: Int let favoritesCount: Int init(state: AppState) { self.count = state.count self.favoritesCount = state.favorites.count } } … }

11:40

This changed the structure of the ViewState and so we have to make a small change to how we use the viewStore for the tab bar item: .tabItem { Text("Profile \(viewStore.favoritesCount)") }

11:48

Now everything should work as before, but this formulation is perhaps even a little more performant than the last. View state in isowords

11:56

It’s worth mentioning that for this little toy app, and indeed for many apps, re-computing the bodies of views needlessly may not be that big of a deal. SwiftUI is supposed to be very good at figuring out what changed when a body updates and do the minimal amount of work necessary to get the changes on the screen.

12:14

However, that doesn’t mean you don’t ever need to worry about these things. For a large application where the root view holds onto the state for the entirety of the application you run the risk of having the root view, and really any view close the root, being thrashed with body re-computations that aren’t necessary.

12:32

And in fact this is something we have witnessed in our game isowords, which we open sourced a few months ago. The game has quite a lot of state, and there are some hot paths of the application that cause lots of actions to be sent into the store, such as when you are dragging around on the cube. If we didn’t do any work to curb the number of times views were re-computed we could potentially be creating hundreds of intermediate views tracing all the way back down to the root even though they don’t change at all.

12:58

Let’s hop over to the isowords application to take a quick look at how view state is used. We can search the project for the terms “ struct ViewState ” and we will find that currently we have 27 spots we use view state. The first result is in the AppView , which is the root-most view of the entire application, and we see that in order for this view to do its job it only needs access to two booleans: struct ViewState: Equatable { let isGameActive: Bool let isOnboardingPresented: Bool init(state: AppState) { self.isGameActive = state.game != nil self.isOnboardingPresented = state.onboarding != nil } }

13:22

This means that changes to any other part of the application’s state besides these two booleans will not cause the AppView to re-compute its body.

13:28

We could check out another one, like say the LeaderboardLinkView . It only needs a little bit of the home’s state for the view: struct ViewState: Equatable { var tag: HomeRoute.Tag? var weekInReview: FetchWeekInReviewResponse? init(state: HomeState) { self.tag = state.route?.tag self.weekInReview = state.weekInReview } }

13:39

So any changes made to home state outside of these fields will not cause this view to recompute. Library performance in isowords

13:44

So it’s great that the Composable Architecture gives us this tool for making our applications performant.

13:50

But there’s a problem. It turns out that due to how the .scope operation and ViewStore object are designed they do way more work than necessary.

14:01

Currently when you form long chains of scopes, the state transformations that you provide will get invoked way more often than is needed. And worse, the longer the chain, the more this problem is exacerbated. And if that wasn’t bad enough, if you observe state changes on the intermediate stores in the scope chain then the equality operator is invoked on state way more than necessary too. And this can actually be a performance bottleneck for very large states, such as if you had a large array of values. We’ve even witnessed these performance problems in our game isowords because we perform a lot of logic in order to compute the view state that is provided to the word cube for rendering.

14:41

Luckily for us we can fix these problems. But first let’s take a look at them as they exist in the Composable Architecture today and see how they can affect performance.

14:52

We’re going to start in the isowords code base. Let’s go back to the CalendarView , which is responsible for showing a little 30 day calendar to display your daily challenge ranks. This view is utilizing ViewState both because it doesn’t need all the state from the feature to render and because it wants to pre-compute some things so that the view can more easily compute its body: struct ViewState: Equatable { let currentChallenge: DailyChallenge.GameNumber? let isLoading: Bool let months: [Month] … }

15:34

The initializer for this view state does a decent amount of work. It groups all of the results into the month that the result was recorded, and then transforms that into an array of Month values so that it can be more easily rendered by the view.

15:47

As we saw before, this view is at the end of a long chain of views that trace back to the root of the application. We went through about 6 or so layers, and most of those layers further scoped on a store in order to hand off to a child view.

16:04

To see what effect this has on this ViewState struct, let’s put a print statement in the initializer: init(state: DailyChallengeResultsState) { print("CalendarView.ViewState") … }

16:18

Even better, let’s also keep track of a little mutable count variable that can be incremented each time the view state is initialized: private var count = 0 struct CalendarView: View { struct ViewState: Equatable { init(state: DailyChallengeResultsState) { count += 1 print("CalendarView.ViewState", count) … } } }

16:33

If we run the application and navigate back to the daily challenge leaderboards we will see something surprising: CalendarView.ViewState 1 CalendarView.ViewState 2 … CalendarView.ViewState 16 CalendarView.ViewState 17

17:05

For some reason this struct was initialized 17 times. If we open the calendar it gets created an additional 12 times: CalendarView.ViewState 18 CalendarView.ViewState 19 … CalendarView.ViewState 28 CalendarView.ViewState 29

17:10

This is very surprising. We expect that only a single ViewState struct should be created each time an action is sent into the store, but it appears that multiple are being created, which means all that logic is running many more times than necessary.

17:27

There’s one more place we want to show this problem, and that’s in some code that actually powers the logic for the word cube. Let’s hop over to CubeFaceNode.swift , which holds the code responsible for rendering a single face of the cube. There are 81 faces on the cube because there are 3 * 3 * 3 = 27 small cubes making up the whole cube, and each cube has 3 faces.

17:52

In the file we can see that CubeFaceNode is a class inheriting from something called SCNNode , which is a class from SceneKit that you subclass in order to facilitate drawing things in a 3D scene. We can also see that we are using ViewState to power this view: public class CubeFaceNode: SCNNode { public struct ViewState: Equatable { … } }

18:09

This may seem a little surprising, but the idea of view state is important outside of SwiftUI, including UIKit and even right here in SceneKit.

18:17

Now we’d hope that this little ViewState struct is initialized only once for each cube face, which would be 81 times. To see what happens let’s instrument the initializer with a counter like we did the calendar view: private var count = 0 public class CubeFaceNode: SCNNode { public struct ViewState: Equatable { public var cubeFace: CubeFace public var letterIsHidden: Bool public var status: Status public init( cubeFace: CubeFace, letterIsHidden: Bool = false, status: Status ) { count += 1 print("CubeFaceNode.ViewState", count) … } } }

18:44

If we run the app in the simulator and navigate to a new game we will see the following in our logs: CubeFaceNode.ViewState 1 CubeFaceNode.ViewState 2 … CubeFaceNode.ViewState 1297 CubeFaceNode.ViewState 1298

19:10

Wow, ok, so about 16 times more view state structs were created than we hoped for.

19:37

And if we tap on a single face, which sends exactly one action, we get: CubeFaceNode.ViewState.init 1299 CubeFaceNode.ViewState.init 1300 … CubeFaceNode.ViewState.init 1945 CubeFaceNode.ViewState.init 1946

19:46

This means a single action has caused the view state struct to be initialized 648 times, which is 8 times more than expected.

20:17

Luckily the creating of these structs isn’t doing anything too complicated, but if we ever did put a little bit of logic in here it would be called way more necessary. This could definitely lead to a performance bottleneck down the road if we’re not careful. Library performance: scoping

20:34

This clearly isn’t right, so let’s start to dig into why it’s happening and how to fix it. This is actually a problem we have been aware of for some time, but haven’t had the time to tackle it and we weren’t sure how big of a problem this could be in practice. We even have some tests in the Composable Architecture that demonstrate the problem. If we hop over to StoreTests.swift and navigate to testScopeCallCount2 we will see a test that scopes a store multiple times, and inside each scope closure it keeps track of how many times the closure was called. Then a few actions are sent into the store and we assert on how many times each scope closure was called.

21:31

By the time the 4th action is sent into the store we see that the scopes were called way more times than we would expect: viewStore4.send(()) XCTAssertEqual(numCalls1, 10) XCTAssertEqual(numCalls2, 14) XCTAssertEqual(numCalls3, 18)

21:45

We have another test that demonstrates a further problem of heavily scoped stores when used with view stores. The more heavily you scope stores and the more you observe intermediate stores the more you will incur equality checks amongst your values. The testEqualityChecks sets up some deeply scoped stores and creates view stores every step of the way, and by the time the 4th action is sent to the store we have somehow incurred 168 equality checks: viewStore4.send(()) XCTAssertEqual(168, equalityChecks) XCTAssertEqual(168, subEqualityChecks) This too can get expensive, especially if your state holds large pieces of data such as arrays.

22:48

So we can definitely see that there’s a serious problem here. The numbers in these tests should be much smaller, and hopefully once we apply a fix we will see the numbers go down.

22:59

Let’s take a look at how the .scope method is currently written in the library: public func scope<LocalState, LocalAction>( state toLocalState: @escaping (State) -> LocalState, action fromLocalAction: @escaping (LocalAction) -> Action ) -> Store<LocalState, LocalAction> { let localStore = Store<LocalState, LocalAction>( initialState: toLocalState(self.state.value), reducer: .init { localState, localAction, _ in self.send(fromLocalAction(localAction)) localState = toLocalState(self.state.value) return .none }, environment: () ) localStore.parentCancellable = self.state .sink { [weak localStore] newValue in localStore?.state.value = toLocalState(newValue) } return localStore }

23:08

This is pretty much exactly what we wrote from scratch when we developed this concept on Point-Free over a year ago. So, much hasn’t changed since its inception. Let’s quickly remind ourselves how this method works.

23:20

Its job is to construct and return a Store that works on local state and local actions. To provide the initial value to this initializer we can just apply the toLocalState transformation to the store’s current state. To provide a reducer we can just invoke the parent’s logic by embedding the local action into a global action, via fromLocalAction , and then .send that to the parent. The parent will take care of running all the logic, and once that is done we overwrite our localState with the new state from the parent store transformed into local state. Overwriting the localState is necessary in order for any view stores that are observing this local store to get updates when local actions are sent. We also don’t need to provide an environment or return any effects, since that is already taken care of in the reducer passed to the root store this local one is derived from.

24:35

You’ll also notice that in the construction of the child store we are strongly retaining the parent from the child. This makes it so that the child store owns the parent and will make sure that the parent stays around for as long as the child is alive.

24:55

But sending local actions and synchronizing the local state is only half the story. It’s still possible for actions to be sent into the system that are not local actions and that do not go through this local store we are constructing. Some far off distant view could send actions that ultimately cause mutations to the state in this local domain, and this child store would not be notified of those changes if we stopped at the construction of the child domain. We have do more work.

25:23

We need to further listen for changes in the parent state and replay them to the child, which is exactly what these lines do: localStore.parentCancellable = self.state .sink { [weak localStore] newValue in localStore?.state.value = toLocalState(newValue) }

25:42

You’ll notice here we are weakly capturing the localStore in this closure. This makes it so that the parent store does not own the child store, thus breaking the potential retain cycle that could have occurred since the child store owns the parent store.

25:56

One thing you may notice here is that this scoping construction is very similar to what we tried to accomplish with integrating multiple observable objects into a single observable object when dealing with vanilla SwiftUI in our last series of episodes. We’re listening for changes in the parent and child in order to synchronize them. However, we don’t have the infinite loop problem because there’s a bit of an asymmetry in how state changes in the child and parent. We listen to parent changes by subscribing to the firehose of changes from its publisher, but we listen for child changes only when an action is sent directly to the store. This means we don’t run the risk of a change in the child leading to a change in the parent, which then leads to a change in the child, and on and on.

26:52

However, it’s pretty easy to see that this method causes more work to be performed than necessary. For one thing, when constructing the localStore we invoke the toLocalState to compute the initial state, but then also we subscribe to self.state , which is a CurrentValueSubject and emits immediately, which means we just turn right around to apply toLocalState again and pipe it into localStore ’s current value subject. There is no net effect to performing this extra work. We just took data that was already available in the local store and then overwrote it with the same value immediately after.

27:32

Well, luckily it’s easy enough to fix that. Since self.state is a publisher we can just chain on .dropFirst() to skip that first unneeded emission: localStore.parentCancellable = self.state .dropFirst() .sink { [weak localStore] newValue in localStore?.state.value = toLocalState(newValue) }

27:39

If we run tests we will see we get a bunch of failures, but it’s only because a bunch of counts we were asserting against are now one less than they used to be: XCTAssertEqual failed: (“10”) is not equal to (“9”) XCTAssertEqual failed: (“14”) is not equal to (“13”) XCTAssertEqual failed: (“18”) is not equal to (“17”)

28:05

It’s not a huge win, but it’s something. Let’s quickly fix these assertions to reflect the new reality.

28:19

There’s a more significant win we can squeeze out of this code, but it’s a bit more subtle. It turns out that although our synchronization method doesn’t suffer the same infinite loop problem that we had in the vanilla SwiftUI world, it is still doing more work than necessary.

28:35

When an action is sent into the localStore this little local reducer will be run: reducer: .init { localState, localAction, _ in self.send(fromLocalAction(localAction)) localState = toLocalState(self.state.value) return .none },

28:41

This passes the action back up to the parent to process, which eventually causes self.state to change. But once self.state changes it causes the .sink down below to fire: localStore.parentCancellable = self.state .dropFirst() .sink { [weak localStore] newValue in localStore?.state.value = toLocalState(newValue) }

28:51

Which then updates the localStore ‘s state with the new one handed to us from the subscription. None of this work needs to happen since the localStore already has the newest state, after all it’s the one that processed the new action. In fact, the only time we should care about self.state emitting is if it happened when a local action was not sent because the point of the synchronization is to replay state changes from the outside to the local domain.

29:20

So, sounds like we need to do some work that was similar to what we did for synchronizing vanilla SwiftUI observable objects. We can introduce a local mutable boolean that keeps track of when we are in the process of sending an action so that we can skip performing unnecessary work: public func scope<LocalState, LocalAction>( state toLocalState: @escaping (State) -> LocalState, action fromLocalAction: @escaping (LocalAction) -> Action ) -> Store<LocalState, LocalAction> { var isSending = false let localStore = Store<LocalState, LocalAction>( initialState: toLocalState(self.state.value), reducer: .init { localState, localAction, _ in isSending = true defer { isSending = false } self.send(fromLocalAction(localAction)) localState = toLocalState(self.state.value) return .none }, environment: () ) localStore.parentCancellable = self.state .dropFirst() .sink { [weak localStore] newValue in guard !isSending else { return } localStore?.state.value = toLocalState(newValue) } return localStore }

30:24

Now when we run tests we get even more failures, which at first glance looks good, because this time the counts are down even more! XCTAssertEqual failed: (“9”) is not equal to (“5”) XCTAssertEqual failed: (“13”) is not equal to (“5”) XCTAssertEqual failed: (“17”) is not equal to (“5”)

30:46

Now it doesn’t seem to matter how many times we scope, we always get the same count. We get exactly one call of the scope closure for each action sent to the store.

31:08

There are also massive improvements over in the ViewStoreTests.swift file: XCTAssertEqual failed: (“168”) is not equal to (“48”)

31:23

This is counting the number of times the equality check is made, and it’s gone down by a ton. Let’s quickly update all of these numbers to properly record the current reality. Library performance: view stores

31:33

OK, I think this is about as good as we can do for improving the performance of scope . But there’s more work to be done. Although the numbers for equality checks have gone down tremendously they still seem to be a little high. Each time we send an action we seem to incur 12 equality checks even though we’ve only got 8 subscriptions. We’ve created four view stores, we subscribed to the state changes from each, and we also subscribed to the state changes for a piece of substate in the view store. We would hope this incurs only 8 equality checks since there’s only 8 subscriptions.

32:26

Well, if we check how view stores are constructed we will find this initializer: public init( _ store: Store<State, Action>, removeDuplicates isDuplicate: @escaping (State, State) -> Bool ) { let publisher = store.state.removeDuplicates(by: isDuplicate) self.publisher = StorePublisher(publisher) self.state = store.state.value self._send = store.send self.viewCancellable = publisher.sink { [weak self] in self?.state = $0 } }

32:31

This looks a little different from what we developed in our episodes, but that’s because it’s doing a bit more. We have this thing called a StorePublisher , which is a custom publisher whose whole purpose is to leverage dynamic member lookup so that you can do things like: viewStore.publisher.substate

33:15

in order to automatically derive publishers from substate.

33:23

Before diving into the StorePublisher it already looks like we could make a quick win similar to the one we saw in the Store . When we construct a ViewStore , we immediately subscribe to the parent Store ‘s publisher in order to update state and ping the observable object’s objectWillChange : init(…) { … self.viewCancellable = publisher .sink { [weak self] in self?.state = $0 } } … public private(set) var state: State { willSet { self.objectWillChange.send() } }

33:44

But remember that publisher is a CurrentValueSubject , which immediately emits with the current state, which should be the same as the one we assigned on construction, so we can do another dropFirst to avoid this extra work. self.viewCancellable = publisher .dropFirst() .sink { [weak self] in self?.state = $0 }

34:01

Let’s now try to tackle something a bit more difficult. If we look in the StorePublisher we will see it is a Combine publisher that wraps around another publisher. @dynamicMemberLookup public struct StorePublisher<State>: Publisher { … public let upstream: AnyPublisher<State, Never> public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { self.upstream.subscribe(subscriber) } init<P>(_ upstream: P) where P: Publisher, Failure == P.Failure, Output == P.Output { self.upstream = upstream.eraseToAnyPublisher() }

34:27

This ““upstream publisher has already had duplicatesRemoved() called on it when it was passed along to the initializer. This means that whenever we access a dynamic member on a store publisher, like in the test that accesses sub-state, we still incur an equality check on all of the store’s state in this publisher. viewStore1.publisher.substate.sink { _ in } .store(in: &self.cancellables) viewStore2.publisher.substate.sink { _ in } .store(in: &self.cancellables) viewStore3.publisher.substate.sink { _ in } .store(in: &self.cancellables) viewStore4.publisher.substate.sink { _ in } .store(in: &self.cancellables)

34:39

Which means we get 2 equality checks, one on the .publisher level, and one on the .substate . But that’s more work than we need to be doing. We’re sinking on just the sub-state, so we should only incur that single equality check.

34:55

The work we do in the publisher we pass along is a little too eager: let publisher = store.state.removeDuplicates(by: isDuplicate)

35:00

Instead we could delay this work, by passing the original publisher along when constructing the store publisher: self.publisher = StorePublisher(store.state)

35:10

And somehow move it into the StorePublisher . We can actually apply the .removeDuplicates operator at the exact moment a subscriber is attached to the publisher by hooking into the receive method, which is a requirement from the Publisher protocol: public func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { self.upstream.removeDuplicates().subscribe(subscriber) } Referencing instance method ‘removeDuplicates()’ on ‘Publisher’ requires that ‘State’ conform to ‘Equatable’

35:23

However this doesn’t compile because the State generic is not necessarily Equatable . We don’t want to require this conformance, after all even the ViewStore type doesn’t require State to be equatable as long as one can provide their own notion of equatability depending on the situation. So, what we need to do instead is pass in a function that can be used to check for equality: private let isDuplicate: (State, State) -> Bool

36:26

And then that can be passed along: self.upstream.removeDuplicates(by: isDuplicate).subscribe(subscriber)

36:30

But the initializer needs to be updated to assign this property: init<P>( _ upstream: P, removeDuplicates isDuplicate: @escaping (State, State) -> Bool ) where P: Publisher, Failure == P.Failure, Output == P.Output { self.upstream = upstream.eraseToAnyPublisher() self.isDuplicate = isDuplicate }

36:50

The dynamic member lookup subscript has to also be updated, but because it requires LocalState to be Equatable we can just provide == to it: public subscript<LocalState>( dynamicMember keyPath: KeyPath<State, LocalState> ) -> StorePublisher<LocalState> where LocalState: Equatable { .init(self.upstream.map(keyPath), removeDuplicates: ==) }

37:20

And finally the initializer for the ViewStore needs to be fixed because the isDuplicate function now needs to be passed to the StorePublisher : self.publisher = StorePublisher( store.state, removeDuplicates: isDuplicate )

37:36

Before moving on, let’s do a little cleanup, because we are creating a local let publisher that’s now only used once in the initializer. We can just inline this work instead. // let publisher = store.state.removeDuplicates(isDuplicate) … self.viewCancellable = publisher .dropFirst() .removeDuplicates() .sink { [weak self] in self?.state = $0 }

38:01

With that the code is compiling, and if we run tests we get some more failures. This time just the equality counts for the state have decreased: XCTAssertEqual failed: (“12”) is not equal to (“4”) XCTAssertEqual failed: (“24”) is not equal to (“20”)

38:10

Now each action sent to the store incurs only 4 initial equality checks, and that’s exactly because there are 4 view stores, and then 8 more checks with each additional action. This is because after the initial dropFirst , we have 2 removeDuplicates being called, one on each publisher. If we could somehow share that work we could potentially further reduce equality checks to the bare minimum, which would be 4 in this test. Performance gains in isowords

40:02

Still, these performance improvements are seeming pretty promising, but let’s make sure it’s doing what we think it is by loading these changes in isowords and see if the counts we saw earlier have improved.

41:00

We can run the app and drill down to the daily challenge leaderboards and check the console: CalendarView.ViewState 1 CalendarView.ViewState 2 … CalendarView.ViewState 6 CalendarView.ViewState 7

41:22

Already we have more than halved the number of times we construct CalendarView.ViewState . The number now reflects the number of actions sent on the screen, and not the number of scopes.

41:42

If we open the calendar: CalendarView.ViewState 8 CalendarView.ViewState 9 CalendarView.ViewState 10 CalendarView.ViewState 11

41:47

We get 4 view state constructions, whereas previously we got 12, so we’ve cut things down to 1/3 what they were previously.

42:05

So how about the cube? We were seeing some pretty giant numbers earlier when we loaded a game. And now we see: CubeFaceNode.ViewState 1 CubeFaceNode.ViewState 2 … CubeFaceNode.ViewState 649 CubeFaceNode.ViewState 650

42:18

Again we have halved the number of view state creations! Previously we were seeing a number in the 1300s. Let’s tap a cube face: CubeFaceNode.ViewState 651 CubeFaceNode.ViewState 652 … CubeFaceNode.ViewState 811 CubeFaceNode.ViewState 812

42:49

We’ve only gone up to 812, which if we do the math, is 162, which is twice the 81 cube faces that are rendered on the screen. While it may seem like we’re still doing twice as much work as we should be, tapping actually sends 2 actions to the store: 1 on touch-down, and another on release, so this number is exactly what we would hope for. Conclusion

43:35

When we brought the performance improvements we made to the Composable Architecture over to isowords, we saw instant benefits: our view state structs are now being constructed far fewer times, just the bare minimum needed for SwiftUI to do its job. Where scoping heavily throughout the application previously did much more work, we can now scope freely without worrying about a penalty.

44:02

So this is pretty great. In the last series of episodes we saw that scoping is a super power of the Composable Architecture and that we should be using it a lot. Unfortunately, scoping had a pretty big performance problem. Each time you .scope a store you incur a little bit of extra work, both in terms of number of times the scope transformations are called and the number of times the equality operator is invoked.

44:30

But luckily it doesn’t have to be this way. The internal implementations of the store and view store were just a little suboptimal and we were able to fix the problems easily so that now the scope transformations and equality operators are invoked the minimal number of times possible.

44:46

That is it for this episode. It’s not often we do a single, standalone episode, but we thought our viewers would be interested in seeing the guts of the Composable Architecture and following along with us as we fix a performance problem of the library.

45:11

Until next time! Downloads Sample code 0151-tca-performance Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .