Scrolling performance of heavy children #3399
-
Hello community! We are heavily invested into TCA on our project, whole team loves it, but today we have encountered a performance issue with scrolling. Use case: Product grid with many items. Every product card has a lot of logic inside and used through all app, so there is no option to move logic to parent features (composable, but not so composable?) I have made a small snippet of code that represents the problem. I've tried running it on release mode, it still janks. Code snippet is not as heavy as production feature, but issue is still there. Main issue is when child views send .onAppear and .onDisappear signals There was discussion on this topic couple years ago and still the best solution to this to keep child Stores in State instead of scoping. Is there any tips on how to tackle this? I really would not like to have stores directly in state and use ForEach without scope, but now it seems to be the only solution. We have decided to go with TCA because it enables us to split big features into really small and easily testable ones, but it seems now theres is a limit to it? Note: In production code issue persists even when all children has loaded all data, no state changes occur, only .onAppear / disappear actions are being sent import SwiftUI
import ComposableArchitecture
import Combine
@main
struct TCAScrollPerfomanceApp: App {
var body: some Scene {
WindowGroup {
ContentView(store: .init(initialState: .init(), reducer: ParentFeature.init))
}
}
}
struct ContentView: View {
let store: StoreOf<ParentFeature>
var body: some View {
ScrollView {
LazyVGrid(
columns: [
GridItem(
.adaptive(minimum: 164),
spacing: 8
)
],
spacing: 8
) {
// This doesnt jank
// ForEach(store.reducers, content: ChildView.init)
// This janks hard
ForEach(store.scope(state: \.children, action: \.childAction), content: ChildView.init)
}
}
}
}
struct ChildView: View {
let store: StoreOf<ChildFeature>
var body: some View {
Color.clear.overlay {
Image(systemName: "pencil.circle")
.resizable()
.frame(width: 50, height: 50)
}
.frame(height: 100)
.onAppear {
store.send(.onAppear)
}
.onDisappear() {
store.send(.onDisappear)
}
}
}
struct ParentFeature: Reducer {
@ObservableState
struct State: Equatable, Sendable {
var children: IdentifiedArrayOf<ChildFeature.State> = .init(uniqueElements: randomStrings(count: 500).map { .init(id: $0) })
var reducers: IdentifiedArrayOf<StoreOf<ChildFeature>> {
.init(uniqueElements: children.map { .init(initialState: $0, reducer: ChildFeature.init) })
}
}
@CasePathable
enum Action {
case childAction(IdentifiedActionOf<ChildFeature>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
return .none
}
.forEach(
\.children,
action: \.childAction,
element: ChildFeature.init
)
}
}
struct ChildFeature: Reducer {
@ObservableState
struct State: Equatable, Sendable, Identifiable {
let id: String
}
@CasePathable
enum Action {
case onAppear
case onDisappear
case publisherSignal
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
return .publisher {
pusblihser.map { Action.publisherSignal }.receive(on: DispatchQueue.main)
}
.cancellable(id: state.id, cancelInFlight: true)
case .onDisappear:
return .cancel(id: state.id)
default: return .none
}
}
}
}
func randomStrings(count: Int) -> [String] {
var result = [String]()
for _ in 0..<count {
result.append(UUID().uuidString)
}
return result
}
let pusblihser = PassthroughSubject<Void, Never>().eraseToAnyPublisher() |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment 1 reply
-
Hi @andreymosin, there are been a few past discussions on this kind of thing you may want to search for (here's one in particular). In the end there is always a balance of how much you want to integrate your features together, and how much they should be disconnected. By integrating the list of child features into the parent feature, your parent features has complete insight into everything happening inside the list. This is really powerful, but of course comes at a cost. Each action sent from the row goes through the full system, and each layer in the system can inspect and tweak the action is it goes through. The 2nd approach you have shown, that of detached stores that are not connected to the root, is an approach to speed things up. You then no longer incur the cost of the domains being integrated, but of course you also lose the powers. We are currently working on tools that should codify how one can detach child stores from parent stores in situations where it makes sense. Hopefully we will have something to share soon. |
Beta Was this translation helpful? Give feedback.
Hi @andreymosin, there are been a few past discussions on this kind of thing you may want to search for (here's one in particular).
In the end there is always a balance of how much you want to integrate your features together, and how much they should be disconnected. By integrating the list of child features into the parent feature, your parent features has complete insight into everything happening inside the list. This is really powerful, but of course comes at a cost. Each action sent from the row goes through the full system, and each layer in the system can inspect and tweak the action is it goes through.
The 2nd approach you have shown, that of detached stores that are not connecte…