Why I quit using the ObservableObject in SwiftUI

Alexey Naumov
Jan 3 · 6 min read

“Single source of truth” has become a buzzword in the iOS community after WWDC 2019 Data Flow-Through SwiftUI session.

SwiftUI framework is designed to encourage building the apps in the single-source-of-truth style, but this doesn’t mean the app has to have only one central state for the entire app, you actually can break it up.

But in any case, you’re very likely to end up using either @ObservedObject or @EnvironmentObject for the state management of the views.

And here is where the problem lies.

As I’ve been exploring how SwiftUI performs under the high load, I’ve discovered that the performance of the SwiftUI refresh degrades dramatically the more views are subscribed on the state update:

You may have a couple of thousands of views on the screen with just one being subscribed — and the update will be rendered lightning-fast, even for a view deep inside the hierarchy.

But it’s sufficient to have just a few hundreds of views subscribed on the same update and only one being factually affected — and you’ll notice a significant performance drawdown.

This means if we’re building a large SwiftUI app based on a Redux-like centralized state, we’re likely to be in big trouble!

Wrapping every view in EquatableView

At first glance, EquatableView looks like the perfect candidate for solving this problem.

It allows for writing a custom diffing strategy for the views, specifically, comparing the state instead of comparing the body.

But even if the mystical undocumented behavior of EquatableView is addressed someday in the future, we still won't be able to compare views that reference mutating state objects, such as EnvironmentObject or ObservedObject.

Let me explain why. Consider this simple example:

We’re opting out of the default SwiftUI diffing strategy by conforming to Equatable:

extension CustomView: Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.appState.value == rhs.appState.value
}
}

…and wrapping the view in EquatableView:

CustomView().equatable().environmentObject(AppState())

Now everything should be good, right?

You run the code and see that things got only worse: now the view freezes in its initial state and never gets redrawn.

So, what’s going on?

While lhs and rhs are two distinct instances of the CustomView struct, both copies are referencing the same shared object.

Because AppState is a reference type, SwiftUI won't copy it upon mutation, so you're basically comparing object instance to itself.

The == func always returns true, telling SwiftUI that our view does not require re-calculation. Never.

Snapshotting the state in the view

Ok, since we cannot rely on comparing references to the ObservableObjects, how about storing the snapshot of the previous state in the view when it receives an update?

Something like this:

In the == func we are comparing the prevValue to the updated appState.value, so this should work fine...

But it doesn’t. The reason — this simply won’t compile. body is immutable, so we're not allowed to set prevValue in it.

There is a workaround to this problem — we can create a reference type wrapper for storing the prevValue, but this all starts to be really cumbersome and smelly. In addition to that, the == func is not always called, making this approach worthless.

How about something more elegant?

Filtering the state updates

Prior to the release of SwiftUI and Combine frameworks I had a chance to try Redux state management on a large-scale UIKit app, and the combination of ReSwift with RxSwift worked really well.

The problem of massive state updates was not so apparent, but I still used filtering of the updates in the pipeline to reduce the load:

BehaviorRelay(value: AppState()) // produces all state updates
.map { $0.value1 } // removing all unused state values
.distinctUntilChanged() // remove duplicated "value1"
.bind(to: ...)

There is a function distinctUntilChanged is RxSwift (aka skipRepeats in ReactiveSwift and removeDuplicates in Combine) that allows for dropping the unnecessary update events when neither of the values used in the specific view got changed.

This approach would work for SwiftUI as well, but data bindings produced by @State, @ObservedObject and @EnvironmentObject don't have this filtering functionality.

To me, it was surprising that Publisher from Combine is incompatible with Bindingfrom SwiftUI, and there are literally just a couple of weird ways to connect them.

Wrapping the ObservableObject in ObservableObject

If we want to stick with the ObservableObject, there is literally no way to skip an event from the inner objectWillChange publisher, because SwiftUI subscribes to it directly.

What we can do is to wrap the ObservableObject in another ObservableObjectthat does the filtering under the hood.

We can make this wrapper generic and highly reusable. @dynamicMemberLookupattribute allows the client code to interact with the wrapper just like with the genuine object.

You can find the implementation of Deduplicated on Github gist, but here is the conceptual part:

It is observing the objectWillChange of the original object, takes the snapshot of AppState by only including the values used in the specific view and then removes the duplicated values. In the end, if the snapshot is different than the previous one, it triggers objectWillChange on the wrapper object used by the view.

On the consumer’s side, what we had:

…can be reworked in this manner:

And that’s it! Now AppState can generate tons of updates, but only the ones containing different .value will be forwarded to the view.

I should note two downsides of this approach:

  1. State update is delivered asynchronously due to the .delay call. This could be a gate for numerous bugs related to race conditions, but we're not in UIKit. If another update comes out of the blue, the view will just get re-calculated twice and end up reflecting the most recent state values.
  2. The Snapshot type must be unique for each screen consuming Deduplicated as an @EnvironmentObject. This is required because otherwise there might be a conflict when injecting multiple objects with .environmentObject(_:) modifiers.

Using Publisher instead of the ObservableObject

You know what, I’ve had enough of ObservableObject!

Seriously, the currently available API for Binding provides absolutely no control over the values flow.

You constantly need to bridge between it and Publishers from Combine, which are naturally used for networking and other asynchronous business logic.

I cannot see any use case where @ObservedObject or @EnvironmentObject would outpace the solution I'm about to propose when dealing with a centralized app state.

Let’s dive in:

This approach has many benefits:

  1. We’re not only filtering the updates but also limiting the access to the values defined in the local ViewState struct.
  2. We still benefit from using the native dependency injection with @Environment being used instead of @EnvironmentObject.
  3. The same injected container can be extended for injecting services.
  4. The updates are delivered synchronously.
  5. The code is more concise and clear than before.
  6. Easily scalable. No need to update the root dependency injection when adding a new view.

Just to complete the example with the full code, here is the AppState and its injection:

I’ve tried several ways to implement the reversed data flow from the standard SwiftUI views back to the AppState. The one that finally worked well was wrapping the Bindingsubmitted to the SwiftUI's view into the middleware that forwards the values to the AppState:

There are some ways how this whole solution can be improved syntactically (most notably by using keyPaths), but conceptually it is a more performant alternative to both @EnvironmentObject and @ObservedObject.

For the latter, you’d be injecting CurrentValueSubject as the init parameter of the view, in place of the object.

I’ve already migrated my Clean Architecture for SwiftUI sample project to use this approach, and updated my SwiftUI Unit Testing framework for better supporting it.

Did you know you can press and hold 👏? Try it :)

Follow me on Twitter to stay tuned about the coming posts!


Flawless iOS

🍏 Community around iOS development, mobile design, and marketing

Alexey Naumov

Written by

iOS developer since 2011

Flawless iOS

🍏 Community around iOS development, mobile design, and marketing

More From Medium

More from Flawless iOS

More from Flawless iOS

More from Flawless iOS

Improving your Swinject routine

Tim Kuzmin
Feb 7 · 4 min read

890

More from Flawless iOS

More from Flawless iOS

Tuples in Swift

736

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade