Back to Home
MVVM Architecture in SwiftUI: From ObservableObject to @Observable - Part 2

MVVM Architecture in SwiftUI: From ObservableObject to @Observable - Part 2

Part 2: Behind the Scene of ObservableObject and MVVM Implementation

Behind the Scene of ObservableObject

Construction and Internal Mechanism

ObservableObject is a protocol in the Combine framework that allows reference types to work with SwiftUI's reactive data binding system. When a class conforms to ObservableObject, Combine automatically creates an objectWillChange publisher. This publisher notifies subscribers just before any property marked with @Published changes. The publisher, called ObservableObjectPublisher, is a specialized version of PassthroughSubject that emits events without any data.

SwiftUI ObservableObject Internal Mechanism and Data Flow SwiftUI ObservableObject Internal Mechanism and Data Flow

This is achieved through runtime introspection, where the Combine framework inspects the class to find @Published properties. For each of these properties, Combine connects the objectWillChange publisher, linking property changes to notifications. The @Published property wrapper itself is a computed property that calls objectWillChange.send() in its setter before updating the stored value.

The timing of these notifications is "will change" rather than "did change," which is done for performance reasons. Early SwiftUI betas used a "did change" model, but this was changed to "will change" to allow for better coalescing of updates. As discussed in a community forum, this change lets SwiftUI group multiple property changes into a single view update, improving rendering performance.

Property Wrapper Integration and Lifecycle

The way ObservableObject works with SwiftUI property wrappers like @ObservedObject and @StateObject involves complex lifecycle management. @ObservedObject creates a subscription to the ObservableObject's objectWillChange publisher, but it doesn't control the object's lifecycle. It assumes that something else is keeping a strong reference to the object.

A flowchart illustrating when to use @ObservedObject versus @StateObject in SwiftUI views. A flowchart illustrating when to use @ObservedObject versus @StateObject in SwiftUI views.

@StateObject, on the other hand, both manages the subscription and owns the object's lifecycle. When a view uses @StateObject, SwiftUI allocates memory for the object and keeps it alive for as long as the view exists. This ensures that the same object instance is used even when the view is recreated, preventing state loss.

Both @ObservedObject and @StateObject conform to the DynamicProperty protocol, which is the foundation for SwiftUI property wrappers involved in the view update cycle. This protocol requires an update() method that SwiftUI calls before re-rendering a view's body, allowing property wrappers to refresh their state.

The subscription system uses Combine's cancellation features to manage memory. When a view is deallocated, SwiftUI automatically cancels its subscriptions to prevent memory leaks. However, this only works if the property wrapper itself is deallocated correctly, which depends on proper usage.

MVVM Implementation with ObservableObject

Lifecycle Management Challenges

Using ObservableObject for MVVM in SwiftUI brings up some lifecycle management issues. These arise from the fact that SwiftUI views are value types (structs) that can be recreated often, while ObservableObject instances are reference types (classes) that should persist.

Understanding how different property wrappers manage memory is key to avoiding lost state. With @StateObject, SwiftUI takes ownership of the object's lifecycle, creating it once when the view first appears and keeping it until the view is gone.

struct CounterView: View {
    @StateObject private var viewModel = CounterViewModel()
    
    var body: some View {
        VStack {
            Text("Count: \(viewModel.count)")
            Button("Increment") {
                viewModel.incrementCounter()
            }
            Text("View recreated: \(UUID())")
        }
    }
}

class CounterViewModel: ObservableObject {
    @Published var count = 0
    
    init() {
        print("ViewModel created: \(Date())")
    }
    
    func incrementCounter() {
        count += 1
    }
}

In contrast, @ObservedObject does not manage the object's lifecycle. It assumes that the object is being kept alive by some other part of the application. This can be a problem if developers mistakenly think @ObservedObject works like @StateObject. If a parent view updates and recreates a child view that uses @ObservedObject, the child view might lose its connection to the original object if it isn't being retained elsewhere.

A common mistake is creating an ObservableObject inline inside a view's initializer or as a computed property when using @ObservedObject.

// Problematic pattern - object will be recreated on every view update
struct ProblematicView: View {
    @ObservedObject var viewModel = CounterViewModel() // Wrong usage
    
    var body: some View {
        VStack {
            Text("Count: \(viewModel.count)")
            Button("Increment") {
                viewModel.incrementCounter()
            }
        }
    }
}

This leads to the CounterViewModel being recreated every time the parent view updates, which resets its state and can cause confusing behavior.

Property Wrapper Selection and Best Practices

The differences between @StateObject and @ObservedObject come down to their design and intended use. @StateObject uses SwiftUI's internal storage to cache the object instance, ensuring that even when the view struct is recreated, the same object reference is maintained.

Behind the scenes, @StateObject uses SwiftUI's storage system to keep object references across view updates. When a view is first created, @StateObject allocates memory and initializes the object once. On subsequent view recreations, it retrieves the cached object, ensuring state consistency.

struct ParentView: View {
    @State private var toggleState = false
    
    var body: some View {
        VStack {
            // This view will be recreated when toggleState changes
            // but @StateObject ensures viewModel persists
            ChildViewWithStateObject()
            
            Button("Toggle Parent State") {
                toggleState.toggle()
            }
            
            Text("Parent state: \(toggleState)")
        }
    }
}

struct ChildViewWithStateObject: View {
    @StateObject private var viewModel = PersistentViewModel()
    
    var body: some View {
        VStack {
            Text("Value: \(viewModel.value)")
            Button("Increment") {
                viewModel.increment()
            }
        }
        .onAppear {
            print("Child view appeared with viewModel: \(viewModel.id)")
        }
    }
}

class PersistentViewModel: ObservableObject {
    @Published var value = 0
    let id = UUID()
    
    func increment() {
        value += 1
    }
}

@ObservedObject, however, doesn't provide storage management. It simply observes an existing object. It's designed for cases where the object's lifecycle is managed externally, such as by a parent view or a dependency injection container. The key is that @ObservedObject expects the object to remain valid while it's being observed.

This distinction is important because of how SwiftUI updates views. When a view needs to be redrawn, SwiftUI creates a new instance of the view struct. At this point, @StateObject retrieves the cached object from its managed storage, while @ObservedObject expects the same object to be passed in from an external source.

struct DataFlowExample: View {
    @StateObject private var dataSource = DataSource()
    
    var body: some View {
        VStack {
            // Correct: Pass the StateObject-managed instance to children
            DisplayView(dataSource: dataSource)
            ModificationView(dataSource: dataSource)
        }
    }
}

struct DisplayView: View {
    @ObservedObject var dataSource: DataSource
    
    var body: some View {
        Text("Current data: \(dataSource.currentValue)")
    }
}

struct ModificationView: View {
    @ObservedObject var dataSource: DataSource
    
    var body: some View {
        Button("Update Data") {
            dataSource.updateValue()
        }
    }
}

class DataSource: ObservableObject {
    @Published var currentValue = "Initial"
    
    func updateValue() {
        currentValue = "Updated at \(Date().timeIntervalSince1970)"
    }
}

This pattern ensures that the DataSource instance is created and owned by the parent view using @StateObject, and child views observe that same instance using @ObservedObject.

State Persistence and Memory Management

As ObservableObject is implemented by reference type, it requires developers to be mindful of potential retain cycles, especially when using closures or delegates.

class NetworkService: ObservableObject {
    @Published var isLoading = false
    @Published var data: String = ""
    
    private var cancellables = Set<AnyCancellable>()
    
    func fetchData() {
        isLoading = true
        
        // Potential retain cycle if not handled properly
        URLSession.shared.dataTaskPublisher(for: URL(string: "https://api.example.com")!)
            .map { $0.data }
            .sink(
                receiveCompletion: { [weak self] _ in
                    self?.isLoading = false
                },
                receiveValue: { [weak self] data in
                    self?.data = String(data: data, encoding: .utf8) ?? ""
                }
            )
            .store(in: &cancellables)
    }
    
    deinit {
        cancellables.removeAll()
    }
}

The use of [weak self] in closures prevents retain cycles that could keep the NetworkService instance alive even after its view is gone.

That's all about this part. We've seen that the traditional implementation of MVVM in SwiftUI relies on the ObservableObject protocol, which uses Combine's objectWillChange publisher to notify views of state changes from @Published properties. Effective state management requires using @StateObject to create and own the ViewModel, ensuring its persistence throughout the view's lifecycle, while child views use @ObservedObject to simply observe it. Misusing these wrappers often leads to state loss, a common pitfall for developers. Additionally, because ObservableObjects are reference types, developers are responsible for manually preventing memory leaks and retain cycles, typically by using [weak self] in closures.

In the next part, we will discover the @Observable macro and its benefits.