Scroll Performance with ForEachStore

I've been wrestling with this all day. I'm still fairly new to TCA in general, so maybe I'm misunderstanding how things are supposed to piece together here.

I have a FileManagerView with a ForEachStore inside a List like so:

List {
  ForEachStore(
    self.store.scope(
      state: \.shownFiles,
      action: FileManagerAction.fileListItem(id:action:)
    ),
    content: FileListItem.init(store:)
  )
}

shownFiles is defined in FileManagerState as an IdentifiedArrayOf<FileListItemState>. The list renders the items as I would expect, but each item has some data that I want to fetch lazily as the user scrolls to reveal the file info. Things like on-disk size, attaching to the file representation's objectWillChange publisher, etc.

In FileListItem, I fire these initial setup things in its Reducer by sending the onAppear action in SwiftUI's onAppear() callback.

This is where things get hairy. By adding that onAppear hook, scroll performance is out the window! And I know it's not the effects that the Reducer fires off; the scroll jitters even when case .onAppear returns .none. It seems that the simple act of sending an action to the view store has a significant performance cost when many such calls are made in a row.

Instruments tells me that the heaviest callstacks bottom out in extractHelp in extract(case:from:), in a call to String(describing:), I guess while building something to do with a CasePath. I have no idea what it's doing, or why it's taking so long there in this case. I'm still new to TCA in general...

I've tried a few work-arounds:

  • Set a variable in the view state to check from the reducer, and optionally return .none. This was no good, as the performance hit is on the way to the reducer, not in the effects fired after.
  • Set a State variable on the FileListItem view to track whether we've already sent the onAppear action, so as to only fire it once. This is all well and good when scrolling back up the list, but scrolling down and firing those actions the first time still hurts. Not ideal.
  • Don't use ForEachStore. Surprisingly (to me at least), when I bypass the combined reducer and instead create a new Store for each item inside a regular ForEach block, scroll performance is excellent! My onAppear actions fire smoothly, one after the other, and my data starts to load right away. This isn't ideal either, because it means that I'm passing in a fresh environment, rather than using pullback or forEach in FileManagerView's reducer.
  • Go for a walk to clear my head. This actually was helpful, and led me to the last two workarounds above. Didn't help with scroll performance, though.

Any help or advice would be very much appreciated.

1 Like

I'm running into similar performance issues with onAppear store actions. Were you able to find a way around this issue? I have a ForEachStore with a few more stores inside such as IfLetStore or WithViewStore. I'm considering the use of the lifecycle reducer instead of sending an action in onAppear but not sure if that will help much.

Unfortunately I haven't put much more time into this issue. The only workaround I found so far is to not use ForEachStore, and just create new Stores from the data in a regular ForEach. The biggest drawback here aside from verbosity is that I need to create a new environment for each object, since that is not accessible from the local view store. (Come to think of it, I have been experiencing some odd stability issues in my project, and this workaround might be part of the cause.)

I'll post back here if I find another solution.

I worked around this performance bottleneck by sending my onAppear actions from a different call site. (earlier in the order of execution - I used the onAppear action to preload some data for my view)

It appears that ForEachStore definitely has some performance issues. We had a very nice demo of the problem submitted here: Performance issue when triggering child actions · Issue #381 · pointfreeco/swift-composable-architecture · GitHub

@mbrandonw and I spent a lil time debugging it and have a PR with some improvements here: Improve performance of ForEachStore by stephencelis · Pull Request #386 · pointfreeco/swift-composable-architecture · GitHub

It's not clear if this will improve every deficiency, but if you've encountered any issues we'd love for you to give this branch a try and report back! Please note that the IdentifiedArray-based ForEachStore is more performant (and correct), so please switch to using it if you're using a plain ole array-based ForEachStore.

2 Likes

@stephencelis thank you for looking into this issue, I can't wait to test the performance in new release. I will also test performance using the IdentifiedArray as this sounds more promising.

To be honest I think that the real bottleneck in TCA is the store scoping. You can see it comes up on the stack trace above as well. Specifically, my list started to jitter after I needed to add an IfLetStore in following view hierarchy:

ForEachStore(store.scope(state: \.messages, action: ChatAction.message(index:action:)), content: { scopedStore in
    WithViewStore(scopedStore) { scopedViewStore in
        // passing in scoped view store to prevent yet another scoped view store inisde MessageView
        MessageView(store: scopedStore, viewStore: scopedViewStore) { // contents of message view:
            ViewBuilder.buildBlock(
                // MARK: - Text Message
                IfLetStore(scopedStore.scope(state: \.text, action: MessageAction.text), then: { textStore in
                    TextMessageView(store: textStore) { // contents of text message view:
                        WithViewStore(store) // uses scoped TextMessageState and actions
                    }
                },
                // MARK: - Audio Message
                IfLetStore(store.scope(state: \.audio, action: MessageAction.audio), then: { audioStore in
                    /* same as text view store */
                },
                /* other message types (Image, Video, File) ... */
        }
        // use scopedViewStore to apply conditional layout...
    }
}

I'm scoping from [MessageState] > MessageState (shared) > {MsgType}MessageState and require 4 scopes in total. I need to distinguish message states and actions. If I remove my IfLetStore the list is back to normal. So about 3 scopes in non-IdentifiableArray is the maximum that you get before shit hits the fan.

PS: I'd love to learn how I can reduce the amount of scopes I'm using here.

Edit: Using IdentifiedArray did not improve the performance in my case, I still get occasional screen tear.

Edit2: The scroll experience does seem to be smoother when using IdentifiedArray on the latest master branch.

that's sound good! lets i am also looking for the same help!

Update on my situation: Recent updates after some discussion on this topic have helped dramatically! I spent some time yesterday and today porting my ForEach and Store(...) shims to ForEachStore, and scroll performance appears unaffected! Now my environments can pass data down the view model hierarchy instead of instantiating them in place, saving memory and reducing crash frequency. Great stuff!