7

I have a small List with around 20 elements in it. Each row in the list is a simple custom view with a few labels inside embedded in a NavigationLink.

struct RunList: View {
    var model: RunModel
    var body: some View {
        List {
            ForEach(model.runs) { run in
//                NavigationLink(destination: RunOverview(run: run)) {. ** seems to have biggest impact on performance. 
                    RunCell(run: run).frame(height: 100)
//                }
            }
        }
        .listStyle(CarouselListStyle())
        .navigationBarTitle(Text("Demo App"))
    }
}

Running this simple list on an Apple Watch using significant amounts of CPU when scrolling causing it to drop frames.

The performance seems to be significantly worse when each list item has a NavigationLink as the root view.Removing the navigation link reduces CPU usage by upto 50% and vastly improves performance on an Apple Watch Series 2 but we need the list rows to be clickable.

The app I am building is very similar in layout to PopQuiz demo app produced by Apple https://developer.apple.com/documentation/watchkit/creating_a_watchos_app_with_swiftui

Running the above sample code also exhibits the same issues.

I have profiled it in instruments and the bulk of the time seems to be in layout related code.

enter image description here

I appreciate the Apple Watch 2 is fairly old now but surely a basic list such as the above should be able to run performant. Other system apps on the device run well although it is unlikely they will be using swiftUI.

Are tips or gotchas I should be aware of?

bencallis
  • 3,478
  • 4
  • 32
  • 58
  • Do you observe this only on device or on simulator as well? – Asperi May 20 '20 at 03:50
  • Yeah there is a clear increase in CPU usage in the sim when having these navigationLinks around each cell. – bencallis May 20 '20 at 09:45
  • Which Xcode / watchOS versions do you use, because I actually do not observe what you described with 11.4 / 6.2 – Asperi May 20 '20 at 15:49
  • 11.4.1 and 6.2.5. The sample app PopQuiz - https://developer.apple.com/videos/play/wwdc2019/219/ exhibits similar performance issues. – bencallis May 20 '20 at 20:54
  • I think we need to know the content of `RunOverview` and `RunCell` as anything you do in those two views can affect the performance (using shadows etc.). When you use `NavigationLink` it increases the CPU usage as using `NavigationLink` renders the destination right away. I strongly suggest you to share what you do in those two views (at least roughly). – Cuneyt May 26 '20 at 10:52
  • Just want to confirm this is still an issue on ALL SwiftUI platforms (iOS/iPadOS, WatchOS, TVOS). Major non-performance in many aspects of SwiftUI 2. NavigationLinks being just one problem. – Peter Suwara Oct 13 '20 at 07:00

3 Answers3

5

What about using one NavigationLink that gets activated and its information from RunCell TapGesture?

struct RunList: View {
    var model: RunModel
    @State var activeRun: Run?
    @State var runIsActive = false
    var body: some View {
        List {
            if activeRun != nil {
                NavigationLink(destination: RunOverView(run: activeRun!, isActive: $runIsActive, label: {EmptyView()})
            }
            ForEach(model.runs) { run in
                RunCell(run: run)
                    .frame(height: 100)
                    .onTapGesture {
                        self.activeRun = run
                        self.runIsActive = true
                }
            }
        }
        .listStyle(CarouselListStyle())
        .navigationBarTitle(Text("Demo App"))
    }
}
TheLegend27
  • 741
  • 7
  • 8
5

Some ideas,

  1. Avoid unnecessary redrawing of RunCell. Make it conform to Equatable if it's not already.
struct RunCell: View, Equatable {

    static func == (lhs: RunCell, rhs: RunCell) -> Bool {
       lhs.run == rhs.run // or whatever is equal
    }
...
  1. Maybe fix the offered size of the List elements

RunCell(run: run).fixedSize(vertical: true).frame(height: 100)

  1. If all RunCell views look roughly the same in terms of properties, put the List in a compositingGroup.

There's probably something that can be done with the NavigationLink also, but not sure what. There's a SwiftUI component for Insturments, but I'm not sure it will give more insight than TimeProfiler here.

Cenk Bilgen
  • 1,330
  • 9
  • 8
  • Thanks for the suggestions. I will definitely look into these. They sound like they could help somewhat. – bencallis May 28 '20 at 11:58
3

It might depend on how heavy is RunOverview.init because all those navigation link views are constructed during this ForEach iteration (even thought not yet activated

You can try DeferView from this solution to defer real destination construction to the moment when corresponding link activated

ForEach(model.runs) { run in
    NavigationLink(destination: DeferView { RunOverview(run: run) }) {
        RunCell(run: run).frame(height: 100)
    }
}
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 2
    Thanks for the suggestion. I tried something very similar to this called LazyView from Objc.io https://www.objc.io/blog/2019/07/02/lazy-loading/ Sadly neither seem to help. The RunOver has nothing but a run property and a body. – bencallis May 17 '20 at 17:39