A few notes on what I've learned over the past week refactoring my app to improve UI performance.
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)
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.
I ended up with this architecture:
@Shared
model the root, passed to each feature. Often as a shared sub-state. ($parent.childState
)- Each feature stores its own local state from what it needs of the model (var isModified)
- Views never observe the model
- Actions update the shared model, and refresh any local state (and children)
- Parent features must tell any sibling state to refresh
- 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.
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
}
}
}