ObservableObject Research - Expressing Love is Not Easy

Published on

Get weekly handpicked updates on Swift and SwiftUI!

This article mainly explores in SwiftUI, using a Single Source of Truth development pattern, whether ObservableObject is the best choice. It examines if it’s possible to try new approaches without significantly altering the existing design philosophy to improve response efficiency. Finally, a method is provided that still uses the single source of truth design concept but completely abandons the use of ObservableObject.

Please do not be misled by the title of this article. It was written in the second year after the advent of SwiftUI, and its main purpose is to explore how to reduce unnecessary view updates caused by ObservableObject under the circumstances at that time. I also created two other articles related to optimization: How to Avoid Repeating SwiftUI View Updates, A Deep Dive Into Observation: A New Way to Boost SwiftUI Performance

Single Source of Truth

I first came across the concept of a Single Source of Truth last year when reading Wang Wei’s “SwiftUI and Combine Programming”.

  • Treat the app as a state machine where the state determines the user interface.
  • These states are all stored in a Store object, referred to as State.
  • Views cannot directly manipulate State but can only indirectly change the State stored in the Store by sending Actions.
  • Reducers take the existing State and the incoming Action to generate a new State.
  • Replace the old State in the Store with the new State to drive the update of the interface.

Redux Architecture

In this book, combined with the author’s previous experience with Redux, RxSwift, and other development experiences, a SwiftUI-ized example program is provided. Afterwards, I continued to study some related materials, and by reading many open-source examples on GitHub, I basically mastered this method and applied it in Health Notes. Generally speaking, under the SwiftUI framework, the main differences in implementation methods are reflected in the details, and the overall direction, pattern, and composition of the code are more or less the same:

  • The Store object conforms to the ObservableObject protocol.
  • State is stored in the Store object and wrapped with @Published to notify the Store when the State changes.
  • The Store object establishes a dependency with the View through @ObservedObject or @EnvironmentObject.
  • The Store object uses the objectWillChange’s Publisher to notify the View that has established a dependency relationship to refresh when the State changes.
  • View sends Action -> Reducer (State, Action) -> newState in a cycle.
  • Due to SwiftUI’s two-way binding mechanism, the data flow is not completely unidirectional.
  • In some views, SwiftUI’s other wrapper properties, such as @FetchRequest, can be combined to localize the state.

The last two items use SwiftUI’s features and can be omitted, adopting a completely unidirectional data flow approach.

Based on the above methods, single-source-of-truth development in SwiftUI is very convenient, and in most cases, execution efficiency and response speed are basically guaranteed. However, as mentioned in my previous article @State Research in SwiftUI, when the dynamic data volume increases and the number of Views that maintain a dependency relationship with the Store reaches a certain level, the overall response efficiency of the app will rapidly deteriorate.

The main reasons for this deterioration are:

  1. Timing of dependency injection for objects following the ObservableObject protocol.
  2. Refinement of Views.
  3. The uniqueness of the dependency notification interface. Any single element change in the State (a collection of states) will notify all Views dependent on the Store to redraw.

I will analyze these points one by one.

Timing of Dependency Injection for ObservableObject Objects

In the When is the dependency established? section of @State Research in SwiftUI, we used a piece of code to speculate about the timing of dependency injection for @State and @ObservedObject. The result is that dependency injection via @ObservedObject or @EnvironmentObject does not allow the compiler to make more precise judgments based on the specific content of the current View. As long as your View declares it, the dependency relationship is established.

Swift
struct MainView: View {
    @ObservedObject var store = AppStore()

    var body: some View {
        print("mainView")
        return Form {
            SubView(date: $store.date)
            Button("Modify Date") {
                self.store.date = Date().description
            }
        }
    }
}

struct SubView: View {
    @Binding var date

: String
    var body: some View {
        print("subView")
        return Text(date)
    }
}

class AppStore:ObservableObject{
    @Published var date:String = Date().description
}

After execution, the output is as follows:

Swift
mainView
subView
mainView
subView
...

For more detailed analysis, please refer to @State Research in SwiftUI.

Even if you only send an action in the View without displaying the data in the State or using it for judgment, that View will still be forced to refresh. Even if you, like me, forgot to remove the declaration in the View, the View will still be updated.

If there are many similar Views, your app will experience a lot of ineffective updates.

Refinement of Views

Here, the View referred to is the structure that you build following the View protocol.

In SwiftUI development, both subjectively and objectively, you need to refine your View descriptions, using more sub-Views to compose your final view, rather than trying to write all the code in the same View.

Subjective Aspects

  • Lower coupling.
  • Stronger reusability.

Objective Aspects

Limitations of ViewBuilder’s Design

FunctionBuilder, as a significant new feature of Swift 5.1, has become the foundation of SwiftUI’s declarative programming. It has greatly facilitated the implementation of DSL in Swift code. However, as a new product, its current capabilities are not very strong. Currently, it only provides very limited logical statements.

Swift
if {} else {}
if {}
? :
ForEach // the only loop capability, with many limitations

SwiftUI 2 has provided more convenient judgment statements such as switch if let.

In coding, to implement more logic and rich UI, we must disperse the code across various Views and then ultimately combine them. Otherwise, you will often encounter errors about using too much logic, etc.

Optimization Mechanism Based on Body

SwiftUI has done a lot of work to reduce the redrawing of Views. It optimizes deeply based on the body of each View (the body is the unique entry point of each View; using func -> some View in View does not enjoy optimization, only independent Views can). SwiftUI compiles all Views into a View tree at compile time, and it redraws only the Views that must respond to state changes as much as possible (@State perfectly supports this). Users can also further optimize the View redraw strategy by setting their own Equatable comparison conditions.

Limitations of Xcode’s Real-Time Code Parsing Capability

If you write too much code in the same View, Xcode’s code suggestion function will almost become unusable. I guess it’s because the workload of parsing DSL itself is very large. The seemingly not much descriptive statements we write in the View body actually correspond to a lot of specific code. Xcode’s code suggestions always exceed its reasonable calculation time and thus fail. In this case, simply splitting the View into several Views, even if still in the same file, will immediately make Xcode’s work normal.

Reliability Limitations of Preview

The new preview feature would greatly enhance layout and debugging efficiency, but due to its imperfect support for complex code, splitting the View and using appropriate Preview control statements can efficiently and error-freely preview each sub-View independently.

From the above points, it is quite appropriate to have more refined View descriptions from any angle.

But in the case of a single source of truth, we will have more Views establishing dependencies with the Store. The numerous dependencies will prevent us from enjoying the View update optimization mechanism provided by SwiftUI.

For issues related to View optimization, you can refer to the View update mechanism introduction in the book “Thinking in SwiftUI”, and there is also an article discussing Equality on swiftui-lab.

Uniqueness of Dependency Notification Interface

Any single element’s change in State (the collection of states) will notify all Views dependent on the Store to redraw.

State is wrapped with @Published. When the value of State changes, it sends a notification through the Store (ObservableObject protocol) via the ObjectWillChangePublisher, prompting all dependent Views to refresh.

Swift
class AppStore: ObservableObject {
    @Published var state = State()
    ...
}

struct State {
    var userName: String = ""
    var login: Bool = false
    var selection: Int = 0
}

For a not so complex State, although there are still some inefficient actions, the overall efficiency impact is not significant. However, if your app’s State contains a lot of content, updates frequently, and the pressure on View updates increases sharply. Especially in the State, many data changes are not high, and a lot of Views only need low-change data. But as soon as any change occurs in State, they are forced to redraw.

How to Improve

After identifying the above problems, I began to gradually explore solutions.

First Step: Reduce Dependency Injection

In response to the issue where declaring a dependency automatically forms it, my first thought was to reduce dependency injection. First, do not add unnecessary dependency declarations in the code; for those Views that only need to send Actions but do not use State, define the store as a global variable, which can be used directly without injection.

Swift
// In AppDelegate
lazy var store = Store()

//
let store = (UIApplication.shared.delegate as! AppDelegate).store
struct ContentView: View {
    var body: some View {
        Button("Directly Use Action") {
            store.dispatch(.test)
        }
    }
}

// For other Views that need dependency injection, use them normally
struct OtherView: View {
    @EnvironmentObject var store: Store
    var body: some View {
        Text(store.state.name)
    }
}

Second Step: Localize Unnecessary States

This might seem contrary to the single source of truth philosophy, but in reality, many states in an app are only relevant to the current View or a small range of Views. If designed properly, these state informations can be well managed and maintained within their own small areas, without needing to be aggregated into State.

To create and maintain a small state within a regional scope, the following methods can be used:

  • Make Good Use of @State

    In the article @State Research in SwiftUI, we discussed SwiftUI’s optimization of @State. If designed properly, we can store information that is not crucial to the overall situation in a local View. Through a secondary wrapping of @State, we can also achieve the necessary side effects. If a child View of that View uses @Binding, it will only affect the local View tree.

    Another option is to wrap commonly used View modifiers through ViewModifier. ViewModifier can maintain its own @State and manage the state independently.

  • Create Your Own @EnvironmentKey or PreferenceKey, Injecting Only into Needed View Tree Branches

    Similar to EnviromentObject, injecting a dependency with EnviromentKey is explicit.

    Swift
      @Environment(\.myKey) var currentPage

    We can change the value of this EnvironmentKey as follows, but the scope is limited to the subtree of Views under the current View.

    Swift
    Button("Change Value") {
        self.currentPage = 3
      }
      SubView()
          .environment(\.myKey, currentPage)

    EnvironmentObject can also be injected into any particular branch, but since it is a reference type, any branch’s changes will still affect other users in the entire View tree.

    Similarly, we can use PreferenceKey to inject data only into the layer above the current View.

    Value types are always more controllable than reference types.

  • Use Other Wrapper Properties Provided by SwiftUI in the Current View

    My most frequently used SwiftUI wrapper property is now @FetchRequest. Besides essential data placed in State, most of my CoreData data requirements are fulfilled using this property wrapper. @FetchRequest currently has some shortcomings, such as the inability to specify more detailed batch specifications, define lazy states, or set retrieval limits. However, given the convenience it brings, I find it acceptable. FetchRequest can fully implement programmatic Request settings just like other CoreData NSFetchRequests, and combined with the above methods, it allows placing Request generators in the Store without affecting the current View.

    Swift
      struct SideView: View {
          // Implementation details
      }
    
      private struct InsideListView: View {
          // Implementation details
      }

    I believe that SwiftUI will provide more wrappers in the next step to directly control the state at the local level.

Step Three: Saying Goodbye to ObservedObject

As long as our View still depends on the State from a single source of truth, all our efforts so far would be in vain. However, adhering to the single source of truth design philosophy is very clear. Since any change in state can only notify dependent Views through ObservedObject’s ObjectWillChangePublisher, if we want to solve this problem, we have to abandon using ObservedObject. Instead, we create our own dependencies between the View and each independent element in the State to achieve our optimization goals.

Combine is naturally the first choice. The effect I want to achieve is as follows:

  • State is still stored in the Store in its current form, and the structure of the entire program is basically the same as when using ObservedObject.
  • Each element in State can notify its dependent Views independently without going through @Published.
  • Each View can establish a dependency relationship with elements in State as needed, and irrelevant changes in State will not force it to refresh.
  • Data in State still supports operations like Binding, and can support various forms of structural settings.

Based on these points, I finally adopted the following solution:

  1. The Store remains unchanged, just without ObservedObject.
Swift
class Store {
    var state = AppState()
    ...
}
  1. The AppState structure is as follows:
Swift
struct AppState {
    var name = CurrentValueSubject<String, Never>("Elbow")
    var age = CurrentValueSubject<Int, Never>(100)
}

Using CurrentValueSubject to create a Publisher of the specified type.

  1. Injected as follows:
Swift
// The current View only needs to display name
struct ContentView: View {
    @State var name: String = ""
    var body: some View {
        Form {
            Text(name)
        }
        .onReceive(store.state.name) { name in
            self.name = name
        }
    }
}

We need to explicitly obtain and save each dependent element in every View using .onReceive.

  1. Modifying the value in State:
Swift
// Based on the View->Action mechanism to modify State
extension Store {
    // Example does not strictly follow action, but it calls Store, you get the idea
    func test() {
        state.name.value = "Big Elbow"
    }
}

// Add to the ContentView above
Button("Change Name") {
    store.test()
}
  1. Supporting Binding:
Swift
extension CurrentValueSubject {
    var binding: Binding<Output> {
        Binding<Output>(get: {self.value}, set: {self.value = $0})
    }
}
// Using binding

TextField("Name", text: store.state.name.binding)
  1. Supporting Binding for Structs:
Swift
struct Student {
    var name = "fat"
    var age = 18
}

struct AppState {
    var student = CurrentValueSubject<Student, Never>(Student())
}

extension CurrentValueSubject {
    func binding<Value>(for keyPath: WritableKeyPath<Output, Value>) -> Binding<Value> {
        Binding<Value>(get: {self.value[keyPath: keyPath]}, 
                       set: { self.value[keyPath: keyPath] = $0})
    }
}

// Using Binding
TextField("studentName:", text: store.state.student.binding(for: \.name))
  1. Supporting Binding for more complex State elements. If you need to create a format in State that the above Binding methods do not support, you can use the enhanced @MyState created in my other article @State Research in SwiftUI to meet special needs. Any changes you make to studentAge locally will automatically reflect in State:
Swift
struct ContentView: View {
    @MyState<String>(wrappedValue: String(store.state.student.value.age), toAction: {
        store.state.student.value.age = Int($0) ?? 0
    }) var studentAge
    var body: some View {
        TextField("student age:", text: $studentAge)   
    }
}

Thus, we have achieved all the goals we set earlier.

  • Only minor adjustments to the original program structure.
  • Each element in State independently notifies of its changes.
  • Each View can create dependencies only with the relevant elements in State.
  • Perfect support for Binding.

Additional: Reducing Code Volume

In actual use, the above solution leads to an increase in code volume for each View. Especially when you forget to write .onReceive, the program won’t report an error, but data will not respond in real time, making it harder to troubleshoot. By using property wrappers, we can combine Publisher subscription and variable declaration, further optimizing the solution.

Swift
@propertyWrapper
struct ObservedPublisher<P: Publisher>: DynamicProperty where P.Failure == Never {
    // Implementation details
}

// The code comes from the open

-source project SwiftUIX, with minor modifications to adapt to CurrentValueSubject

Usage:

@ObservedPublisher(publisher: store.state.title, initial: "") var title

Thus, we further reduce the code volume and basically eliminate problems that may arise from missing .onReceive.

The above code is available on Github.

Conclusion

The reason for this discussion is that my app was experiencing response stickiness (and there was a gap with the silkiness I envisioned for the iOS platform). The process of studying and learning also gave me a deeper understanding of SwiftUI. Whether my approach is correct or not, at least the entire process has been very beneficial to me.

In the process of my study, I also discovered another friend who proposed similar views and presented his solution. With his previous experience in developing large projects with RxSwift, his solution used the concept of SnapShot. The injection method uses EnvironmetKey, and it filters out unnecessary modifications to State elements well (such as changes that are the same as the original value). You can view his blog at his blog.

Finally, I hope Apple can provide more native ways to solve these problems in the future.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed