green-code-initiative / ecoCode-challenge

Emboard in the hackhatons serie for improving ecoCode
3 stars 4 forks source link

[Hackathon 2024][Niobium][iOS] Avoid usage of AnyView inside a ForEach in SwiftUI #103

Open ThibaultFarnier opened 4 months ago

ThibaultFarnier commented 4 months ago

Rule title

Avoid usage of AnyView inside ForEach.

Language and platform

Swift 5.0+, iOS 13+

Rule description

In SwiftUI, when using ForEach blocks, AnyView should not directly be used as the first type of the ForEach block.

In order for SwiftUI to have best performances, it needs to know at compile-time the "tree" of Views it is going to display.

Using the type-erased AnyView prevents SwiftUI from knowing what's inside the AnyView, like a black-box, and this can lead to performance issues, especially when displaying lists of elements, even when lazily loaded inside a List.

To prevent from having this issue, AnyView should be avoided as returned elements in ForEach blocks, and @ViewBuilder along with some View should be preferred instead.

Bad code:

import SwiftUI

struct ContentView: View {
    var body: some View {
        List {
            ForEach(0...10_000, id: \.self) { _ in 
                cellView()
            }
        }
    }

    func cellView() -> AnyView {
        return AnyView(RandomColorView())
    }
}

struct RandomColorView: View {
    var body: some View {
        Color(uiColor: .random())
    }
}

extension UIColor {
    static func random() -> UIColor {
        [.yellow, .red, .blue, .black , .brown, .cyan, .green].randomElement()!
    }
}

Good code:

import SwiftUI

struct ContentView: View {
    var body: some View {
        List {
            ForEach(0...10_000, id: \.self) { _ in
                cellView()
            }
        }
    }

    @ViewBuilder
    func cellView() -> some View {
        RandomColorView()
    }
}

struct RandomColorView: View {
    var body: some View {
        Color(uiColor: .random())
    }
}

extension UIColor {
    static func random() -> UIColor {
        [.yellow, .red, .blue, .black , .brown, .cyan, .green].randomElement()!
    }
}

Rule short description

AnyView should be avoided as returned elements in ForEach blocks, and @ViewBuilder along with some View should be preferred instead.

Rule justification

Assuming the following existing code:

struct RandomColorView: View {
    var body: some View {
        Color(uiColor: .random())
    }
}

extension UIColor {
    static func random() -> UIColor {
        [.yellow, .red, .blue, .black , .brown, .cyan, .green].randomElement()!
    }
}

This is a mistake often made when starting to use SwiftUI and trying to create an intermediary func or var to provide a SwiftUI View, like in this situation:

struct ContentView: View {
    var body: some View {
        List {
            ForEach(0...10_000, id: \.self) { _ in 
                cellView()
            }
        }
    }

    // this does not compile, the compiler suggests using `any View`, which won't work anyway because when `cellView()` is called
    // the compiler needs to know which underlying type is sent by cellView
    func cellView() -> View {
        return RandomColorView()
    }
}

A quick fix might be:

struct ContentView: View {
    var body: some View {
        List {
            ForEach(0...10_000, id: \.self) { _ in 
                cellView()
            }
        }
    }

    func cellView() -> AnyView {
        return AnyView(RandomColorView())
    }
}

This will compile, but now SwiftUI only has a type-erased AnyView inside the ForEach block and won't be able to smartly detect it is always a RandomColorView that is used under the hood and won't make any optimisation when scrolling through the List.

As we can see in this video (performed on a real device iPhone 12 Pro):

https://github.com/green-code-initiative/ecoCode-challenge/assets/5621515/8281d4fc-595d-4bf6-a55d-e83eac8be7f6

It takes more than one minute and a half to scroll through the whole list.

This video was analyzed in parallel with Instruments to check CPU performances, here are the results below:

Screenshot 2024-05-29 at 16 33 25

We can see that often the CPU reaches 100% usage, and hangs several times.

If instead we use the following code which relies on @ViewBuilder and some View, which tells the IDE that the underlying type will be known at compile-time:

struct ContentView: View {
    var body: some View {
        List {
            ForEach(0...10_000, id: \.self) { _ in 
                cellView()
            }
        }
    }

    @ViewBuilder
    func cellView() -> some View {
        RandomColorView()
    }
}

We can see in the video below (same iPhone 12 Pro):

https://github.com/green-code-initiative/ecoCode-challenge/assets/5621515/0639906c-6f55-4b21-a08e-9020014e5ddb

that performances are dramatically better, as it takes only 10 seconds to reach the bottom of the 10k elements list.

This is confirmed by Instruments as well:

Screenshot 2024-05-29 at 16 37 42

as there are no hangs, and CPU sometimes reaches 100% but for very short amounts of time.

Similar investigation results can be found in this article: https://martinmitrevski.com/2024/01/02/anyviews-impact-on-swiftui-performance/

Instruments trace file: AnyView_CPUTest_OK.trace.zip

Severity / Remediation Cost

Severity: Major (depends of on the size of the list)

Remediation: Medium

Remediation consists in using @ViewBuilder and @some View in the function / var signature.

Implementation principle

In a ForEach loop, if a single View is returned, ensure the returned type in the block is NOT AnyView.

Valid triggering examples:

ForEach(/*item*/) { item in
    AnyView(AView())
}
ForEach(/*item*/) { item in
    viewFromFunc()
}

// ...

func viewFromFunc() -> AnyView {
    // something that returns AnyView
}
ForEach(/*item*/) { item in
    viewFromVar
}

// ...

var viewFromVar: AnyView {
    // something that returns AnyView
}