Skip to content

Instantly share code, notes, and snippets.

@rcarver
Last active October 20, 2024 19:55
Show Gist options
  • Save rcarver/a4ff9e3fdffe0a3e02e3bb6ab90f0063 to your computer and use it in GitHub Desktop.
Save rcarver/a4ff9e3fdffe0a3e02e3bb6ab90f0063 to your computer and use it in GitHub Desktop.
Notes on improving performance of `ObservableState` with TCA

TCA UI Performance Journey

A few notes on what I've learned over the past week refactoring my app to improve UI performance.

Context

The app manipulates a single large model struct. That struct gets fed into a renderer on all changes to generate an image. The UI is largely made up of sliders, which need to be very responsive. Any extra time on the main thread is noticeable (and I already throttle sliders from the view to the store at ~15fps)

Original Design

Originally, all features manipulated the model directly. This was very easy to build, but caused any change on any part of the model to trigger views to update via observation.

Updated Design

I ended up with this architecture:

  1. @Shared model the root, passed to each feature. Often as a shared sub-state. ($parent.childState)
  2. Each feature stores its own local state from what it needs of the model (var isModified)
  3. Views never observe the model
  4. Actions update the shared model, and refresh any local state (and children)
  5. Parent features must tell any sibling state to refresh
  6. If the model changes from upstream (paste, undo, global reset), then all features explicitly tell their children to refresh

One particular benefit is in (5) that parents can skip updating sibling state during slider movement for example.

Implementation

A lightweight framework keeps things organized, providing a few tools to avoid unnecessary observations.

import ComposableArchitecture

public protocol Refreshable {
    mutating func refreshSelf()
    mutating func refreshChildren()
}

extension Refreshable {
    public mutating func refreshSelf() {}
    public mutating func refreshChildren() {}
}

extension Refreshable {
    public mutating func refresh() {
        // Update children first.
        self.refreshChildren()
        // Then self, so all child state is correct if accessed.
        self.refreshSelf()
    }
}

extension Refreshable where Self: ObservableState {
    /// If the new value is the same as the existing value, skip the
    /// assignment which removes an observable observation.
    public mutating func removeDuplicates<T: Equatable>(
        _ keyPath: WritableKeyPath<Self, T>, _ value: T
    ) {
        if value != self[keyPath: keyPath] {
            self[keyPath: keyPath] = value
        }
    }
}

extension Refreshable where Self: ObservableState, Self: Equatable {
    /// In rare cases, a child may incur observation even when it makes no
    /// observable mutations. When that happens you can wrap the child's update
    /// in `removeDuplicates` to only assign the modified child if changes
    /// its equatable state.
    public mutating func removeDuplicates(operation: (inout Self) -> Void) {
        var result = self
        operation(&result)
        if self != result {
            self = result
        }
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment