-
Notifications
You must be signed in to change notification settings - Fork 43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Performance issue using Observation in v0.10.1 #69
Comments
I suspect the issue is related to how the new Observation is handled. In this Pointfree episode, Brandon explains how easy it is to observe too much state. Since this library uses |
Do you have a demo app that we can look at? For context, TCA provides special logic to avoid observing child state from parents when it comes to Here are examples: Now there is a generalization that should minimize observation of any collection of observable state: So as long as If TCA coordinators uses its own data structure for routing, it could also define its own |
I took the example from this repository and just added |
Here are demo apps showcasing the issue. TCACoordinators
|
I am in the same situation. So I'm still keep the previous version. |
I have created a more general sample based on @mirkobraic zip samples. It is a package based project with two targets Observable and Unobservable. public var body: some View {
if Screen.self is ObservableState.Type {
WithPerceptionTracking {
Router(
$store[],
buildView: { screen, index in
WithPerceptionTracking {
screenContent(scopedStore(index: index, screen: screen))
}
}
)
}
} else {
UnobservedTCARouter(store: store, identifier: identifier, screenContent: screenContent)
}
} So we can use latest version of TCACoordinators but without ObservableState in app implementation to avoid performance issues for now. |
Great resource, thank you @sdkdimon! |
It seems to me that the root cause of the issue lies in the FlowStacks library. The problem is that the objects within FlowStacks are not Observable. For instance, here is a snippet from public struct Router<Screen, ScreenView: View, Modifier: ViewModifier>: View {
@Binding var routes: [Route<Screen>]
@ViewBuilder var buildView: (Binding<Screen>, Int) -> ScreenView
let navigationViewModifier: Modifier
// ...
} The I think we should update FlowStacks lib with the new observation framework. Here is a link to a slack conversation that I had with @rhysm94. Since it doesn't make sense for FlowStacks to depend on the ComposableArchitecture, it might be better to manually add all of the FlowStacks code directly to the |
FWIW, FlowStacks wouldn’t need to import TCA, but instead Perception. |
That sounds good to me. Given that we don't support the latest version of FlowStacks and probably never will, I don't see a significant downside to this approach. @johnpatrickmorgan, do you have any thoughts on this? |
Sounds good, i mean to embed Perception modified FlowStacks into the TCACoordinators. Now i am trying to integrate Perception into the FlowStacks. @Perceptible
class ScreenState<Screen> {
var routes: [Route<Screen>]
init(routes: [Route<Screen>]) {
self.routes = routes
}
}
public struct Router<Screen, ScreenView: View, Modifier: ViewModifier>: View {
@Perception.Bindable var routes: ScreenState<Screen>
// ..
} |
No, I’m almost certain that doesn’t need pushing out into a separate class. It should work just fine as a |
Edit: Here is a link to another slack thread on this topic that could be useful. |
Ah i see the |
I think so. But the more I'm looking into this the more questions I have. Since Edit: The issue could be in |
To ignore things in |
@jshier yes. But currently I think the issue is not related to the |
It’s still really difficult to tell what’s causing the redraws when the underlying state is not changing. The only thing I can think might be an issue is that |
Thanks everyone for your efforts and insights so far. I agree it would make sense to remove this library's dependency on |
Okay, I think I got something. If we expand extension Route: ComposableArchitecture.ObservableState, Observation.Observable {
public var _$id: ComposableArchitecture.ObservableStateID {
switch self {
case let .push(state):
return ._$id(for: state)._$tag(0)
case .sheet:
return ObservableStateID()._$tag(1)
case .cover:
return ObservableStateID()._$tag(2)
}
}
public mutating func _$willModify() {
switch self {
case var .push(state):
ComposableArchitecture._$willModify(&state)
self = .push(state)
case .sheet:
break
case .cover:
break
}
}
} This is incorrect and requires manual implementation of the extension. Fortunately, we can achieve this without modifying the FlowStacks code, which means we might not need to inline the entire library. extension Route: ComposableArchitecture.ObservableState, Observation.Observable {
public var _$id: ComposableArchitecture.ObservableStateID {
switch self {
case let .push(state):
return ._$id(for: state)._$tag(0)
case let .sheet(state, _, _):
return ._$id(for: state)._$tag(1)
case let .cover(state, _, _):
return ._$id(for: state)._$tag(2)
}
}
public mutating func _$willModify() {
switch self {
case var .push(state):
ComposableArchitecture._$willModify(&state)
self = .push(state)
case .sheet(var state, let embedInNavigationView, let onDismiss):
ComposableArchitecture._$willModify(&state)
self = .sheet(state, embedInNavigationView: embedInNavigationView, onDismiss: onDismiss)
case .cover(var state, let embedInNavigationView, let onDismiss):
ComposableArchitecture._$willModify(&state)
self = .sheet(state, embedInNavigationView: embedInNavigationView, onDismiss: onDismiss)
}
}
} For a test, let's take a look at the example project provided by @sdkdimon. By adding this extension to the ObservableState module, we can see that when incrementing the value on the last screen, only that screen gets re-rendered. Previously, the entire stack would be recomputed. Thanks @rhysm94 for keeping the focus on that enum. Our next step is to figure out why the whole stack is recomputed on push/pop actions. I'll investigate whether we can fix this by providing custom |
Very nice stuff! Just wondering - does that break the non- |
I wonder if the recomputing the whole stack issue is related to this? swiftlang/swift#71122 There’s a Swift forum post about it here, as well, detailing more of the problem. https://forums.swift.org/t/observable-pessimizes-arrays/69508/28 It seems like the initial version of the Observation framework didn’t add a (I could very well be completely wrong and off base here!) |
- Fixed whole navigation stack recompute on single feature change in the navigation stack - Custom Route Observable confirming (johnpatrickmorgan#69 (comment)).
If we try access to the route element inside TCARouter.swift store as following let route = self.store[1] the compiler gives an error - @ObservableState
public struct State: Equatable {
var routes: [Route<Screen.State>]
}
//Here we constructing router with Store that holds non Observable array ('[Route<Screen>]')
TCARouter(store.scope(state: \.routes, action: \.router)) { screen in
//....
} It's just my guess i could be completely wrong here |
Guess i got some result. In my playground I've used Router view from FlowStacks directly without TCARouter, and pass an Observable binding of [Route], also inside Router view I wrapped stuff with WithPerceptionTracking view. |
I’ve been quite busy lately and haven't had time to work on this, apologies. We've successfully incorporated v0.10.1 and observation into our project using the extension I shared earlier. However, we’ve noticed an odd issue. When fully replacing the state in the coordinator, the navigation would completely stop working. For some reason the observation got "disconnected". I’ll investigate this tomorrow and share more details with you then. |
So here what i got. Let have focus on our previously created sample example project, here is modified AppCoordinatorView: public struct AppCoordinatorView: View {
@Perception.Bindable public var store: StoreOf<AppCoordinator>
public init(store: StoreOf<AppCoordinator>) {
self.store = store
}
public var body: some View {
// var storeBindable: _StoreBindable_Perception<AppCoordinator.State, AppCoordinator.Action, [Route<Screen.State>]> = $store.routes
// var routesBinding: Binding<[Route<Screen.State>]> = storeBindable.sending(\.router.updateRoutes)
WithPerceptionTracking {
// Here i passing an observable binding of [Route<Screen.State>]
Router($store.routes.sending(\.router.updateRoutes)) { screenState, index in
WithPerceptionTracking {
let store = self.store.scopedStore(state: screenState, index: index)
switch store.case {
case let .landing(store):
LandingFeatureView(store: store)
case let .step1(store):
Step1FeatureView(store: store)
case let .step2(store):
Step2FeatureView(store: store)
}
}
}
}
}
}
// Also we need to do store scoping for router result
// Here is an extension to our store
extension Store where State: ObservableState, State == AppCoordinator.State, Action == AppCoordinator.Action {
func scopedStore(state: Screen.State, index: Int) -> StoreOf<Screen> {
let stateKeyPath: KeyPath<AppCoordinator.State, Route<Screen.State>?> = \.routes[safe: index]
let actionCaseKeyPath: CaseKeyPath<AppCoordinator.Action, IndexedRouterActionOf<Screen>> = \.router
let storeId:ScopeID<AppCoordinator.State, AppCoordinator.Action> = self.id(state: stateKeyPath, action: actionCaseKeyPath)
let toState: ToState<AppCoordinator.State, Screen.State> =
ToState { rootState in
// If we uncomment this the whole stack will start recomputing again
// return rootState[keyPath: stateKeyPath]?.screen ?? state
//If just return a state then stack will NOT be recomputed BUT the actions from views STOP's modifying own state
// only AppCoordinator.State modified
return state
}
return self.scope(id: storeId, state: toState) { ch in
.router(.routeAction(id: index, action: ch))
} isInvalid: {
$0[keyPath: stateKeyPath] == nil
}
}
}
All changes available in example project, also i've embedded TCACoordinators + FlowStacks as package to the sample so you can easely modify all sources to play around it. |
Another thing that i am figured out that even such keypath call: ToState { rootState in
let childState = rootState[keyPath: stateKeyPath]?.screen // --> this call will cause redrawing whole stack on it changes
//State from func arg
return state
} |
Hi @sdkdimon, here are my thoughts.
It's a bit unclear to me what this line should do and what does it prove. For example, if we write @ObservableState
struct ObservableStruct { } We can then set up a similar store in let test: Store<[ObservableStruct], RouterAction<ID, Screen, ScreenAction>>
let _ = test[1] // error: Referencing subscript 'subscript(dynamicMember:)' on 'Store' requires that '[ObservableStruct]' conform to 'ObservableState' Despite this, I don't believe it will lead to any issues with observing the array incorrectly, as I have used arrays of
That's correct. But to be more precise,
I'm not entirely sure, but my guess is that without this, the screen won't be added to the observationRegistrar and thus won't be observed at all. However, once the screen is added to the registrar, it starts over-observing, meaning we still did not fix our "observation chain". That's why I'm curious how would this behave if we added |
The sample that i provided above contains your Observable Router extension implementation, and it has same behaviour. More of all i am even tried to split FlowStacks into composable features with own state and issue still there, so seems to be we are missing something. |
Seems the whole stack recalculation issue on it change is not related to the TCACoordinators and FlowStacks SwiftUI implementation part. @Reducer
public struct ForEachScreenCoordinator {
@ObservableState
public struct State: Equatable {
public static let initialState = State(routes: [.landing(.init())])
var routes: IdentifiedArrayOf<Screen.State>
}
public enum Action {
case router(IdentifiedActionOf<Screen>)
}
public init() {}
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .router(.element(id: _, action: .landing(.nextStepTapped))):
state.routes.append(.step1(Step1Feature.State()))
return .none
case .router(.element(id: _, action: .step1(.nextStepTapped))):
state.routes.append(.step2(Step2Feature.State()))
return .none
default:
return .none
}
}
.forEach(\.routes, action: \.router, element: {
Screen.State.StateReducer.body
})
}
}
public struct ForEachCoordinatorView: View {
public let store: StoreOf<ForEachScreenCoordinator>
public init(store: StoreOf<ForEachScreenCoordinator>) {
self.store = store
}
public var body: some View {
WithPerceptionTracking {
VStack {
ForEach(store.scope(state: \.routes, action: \.router)) { childStore in
WithPerceptionTracking {
switch childStore.case {
case let .landing(store):
LandingFeatureView(store: store)
case let .step1(store):
Step1FeatureView(store: store)
case let .step2(store):
Step2FeatureView(store: store)
}
}
}
}
}
}
} |
Also i am noticed that TCA library used special types for Navigation to hold screen feature it StackState and StackAction for actions handling. I'll try to check whether our issue is reproduced with the native TCA navigation. Maybe we need to look at the implementation of StackState and look how the Observation is implemented there. |
I am checked the whole stack recalculation issue with TCA "native" navigation and seems it is there, maybe it is not an issue at all. Here the sample code, it also available here. @Reducer
struct RootFeature {
@Reducer(state: .equatable)
enum Path {
case landing(LandingFeature)
case step1(Step1Feature)
case step2(Step2Feature)
}
@ObservableState
struct State: Equatable {
var path = StackState<Path.State>()
}
enum Action {
case path(StackActionOf<Path>)
case pushLanding
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .pushLanding:
state.path.append(.landing(.init()))
return .none
case let .path(action):
switch action {
case .element(id: _, action: .landing(.nextStepTapped)):
state.path.append(.step1(.init()))
case .element(id: _, action: .step1(.nextStepTapped)):
state.path.append(.step2(.init()))
return .none
default:
return .none
}
}
return .none
}
.forEach(\.path, action: \.path)
}
}
struct RootView: View {
@Perception.Bindable var store: StoreOf<RootFeature>
var body: some View {
WithPerceptionTracking {
NavigationStack(
path: $store.scope(state: \.path, action: \.path)
) {
WithPerceptionTracking {
Button {
store.send(.pushLanding)
} label: {
Text("Push Landing Page")
}
}
} destination: { store in
WithPerceptionTracking {
switch store.case {
case .landing(let store):
LandingFeatureView(store: store)
case .step1(let store):
Step1FeatureView(store: store)
case .step2(let store):
Step2FeatureView(store: store)
}
}
}
}
}
} |
After upgrading from
v0.9.0
tov0.10.1
, I am experiencing severe performance issues. ThewithPerceptionTracking
closure is being called much more frequently than expected, causing the app to become unresponsive and laggy.To ensure that the issue wasn't due to a mistake on my part, I checked the behavior of the example project and found that it is also affected. To test the issue, I compared the v0.9.0 and v0.10.1 example projects. Specifically, I used the
FormAppCoordinator
tab and placedSelf._printChanges()
inside every view (Step1View, Step2View, Step3View, and FinalScreen).This is the result for
v0.9.0
:Screen.Recording.2024-07-05.at.19.12.26.mov
And this is the result for
v0.10.1
Screen.Recording.2024-07-05.at.19.18.03.mov
We can see that with the latest version of the library, views are being recomputed much more frequently than before.
Note: I added
Self._printChanges()
inside ofWithViewStore
andwithPerceptionTracking
.I have also profiled both cases to obtain a more detailed performance review. Here is the compressed folder containing the result: profiling.zip.
I attempted to determine the cause of this issue but was unable to find a solution. Any assistance would be greatly appreciated.
The text was updated successfully, but these errors were encountered: