Home Case 001 - AnyView kills Performance
Post
Cancel

Case 001 - AnyView kills Performance

📋 Case Detail

Recently, I was working on an application and looking for a solution to dynamically return a view from a method.

The scenario was that TabBar children could be returned dynamically from a method.

1
func viewForTabItem(_ coordinator: any ASTabItemView, isSelected: Bool) -> AnyView

Observe that the method takes attributes for tab items (image, text) and returns respective views.

There are various ways to achieve this:

  1. @ViewBuilder…some View.
  2. Return a Protocol containing a view.
  3. AnyView

While looking for the solution, I came up with multiple sources where it is mentioned that avoid AnyView.

Sources:

WWDC Notes

Developer Forum

Curious about why AnyView should be avoided, I landed on Apple Documentation:

An AnyView allows changing the view type used in a given hierarchy. Whenever the type of view used with an AnyView changes, the old hierarchy is destroyed and a new hierarchy is created for the new type.

This seems to be a reason for all the discussion going on and from the above definition, introducing AnyView in the hierarchy will have a heavy toll on UI rendering.

Then decided to investigate the case and evaluate AnyView.

🕵️‍♂️ Suspect Interrogation

We already identified the suspect based on the reasons:

  1. Apple Documentation.
  2. Open Discussion on various forums

Now to understand in detail, let’s compare the output of return type with & without AnyView.

Concrete Type:

1
2
3
4
5
func normalTextWithoutAnyView() -> VStack<Text> {
   VStack {
       Text("Normal Text")
   }
}

Output:

Return the type of “VStack with layout description and detail of Text” with storage.

Type Erased:

1
2
3
4
5
func wrapTextInAnyView() -> AnyView {
    AnyView(VStack {
      Text("Wrap Text In AnyView")
    })
}

Output:

Here return the type of “AnyView” containing storage of type AnyViewStorage<VStack>. Not much detail is printed.

Observation is at runtime there is extra effort to figure out what is hidden inside the AnyView. Also, it stops the compiler from resolving the view hierarchy at compile time.

Let’s try to record the data when AnyView attacks the performance using instruments.

🚧 Reconstruct Crime Scene

To evaluate performance first we should have the right kind of setup. In this case, I am creating a Grid View that will render the Type of Grid Child element.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct GridView<cell: GridChild>: View {
    var rowCount: Int
    var columnCount: Int
    var itemSize: CGSize
	let update: Binding<Bool>
    
    var body: some View {
        return GeometryReader { proxy in
            ZStack {
                Grid {
                    ForEach((1...rowCount), id: \.self) { rowNumber in
                        self.gridRow(rowNumber: rowNumber)
                    }
                }
            }
        }
    }
    
    @ViewBuilder
    func gridRow(rowNumber: Int) -> some View {
        GridRow {
            ForEach((1...columnCount), id: \.self) { columnNumber in
                let indexPath = IndexPath(row: rowNumber, section: columnNumber)
                cell(indexPath: indexPath, size: self.itemSize)
            }
        }
    }
}

This is a blueprint of the child of the Grid View.

1
2
3
4
5
6
protocol GridChild: View {
    var indexPath: IndexPath { get }
    var itemSize: CGSize { get }
    
    init(indexPath: IndexPath, size: CGSize)
}

To find out the proof, let’s analyze the impact of the following scenarios using the above Grid View:

  1. Static N Text with and without Type Erased
  2. Dynamic view changes with and without AnyView(1-level Hierarchy)
  3. Dynamic view changes with and without AnyView(n-level Hierarchy)

Static N Text with and without Type Erased

I chose to draw 210(row = 30, column = 7) cells in the Grid View that have only text.

1
2
3
VStack(spacing: 10) {
   GridView<TextChild>(rowCount: 30, columnCount: 7, itemSize: textSize, update:$contentUpdater.isChange)
}

Grid Cell is made up of a simple Text object in the first case.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct TextChild: GridChild {
    var indexPath: IndexPath
    var itemSize: CGSize
    
    init(indexPath: IndexPath, size: CGSize) {
        self.indexPath = indexPath
        self.itemSize = size
    }
    
    var body: some View {
        Text("cell\(indexPath.row)\(indexPath.section)").frame(width: itemSize.width, height: itemSize.height)
    }
}

In the second case, the Text used in the cell with Type Erased.

1
2
3
VStack(spacing: 10) {
    GridView<TypeErassedTextChild>(rowCount: 30, columnCount: 7, itemSize: textSize, update: $contentUpdater.isChange)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
struct TypeErassedTextChild: GridChild {
    var indexPath: IndexPath
    var itemSize: CGSize
    
    init(indexPath: IndexPath, size: CGSize) {
        self.indexPath = indexPath
        self.itemSize = size
    }
    
    var body: some View {
        AnyView(Text("cell\(indexPath.row)\(indexPath.section)").frame(width: itemSize.width, height: itemSize.height))
    }
}

Evidence Collected

To capture the performance correctly the Grid is updated after 1 second of application launch.

In the case of TypeErased(AnyView), one cycle of rendering is on the tiny higher side. If you observe the below images rendering with TypeErased has taken more microseconds.

Concrete
Type Erased

However, it is a minuscule difference. Let’s analyze the impact when content is changing frequently.

Dynamic view changes with and without AnyView(1-level Hierarchy)

SwiftUI will only update the view if any change in the state is observed, So a timer is used to trigger the state change.

1
2
3
4
5
6
7
8
9
10
11
12
class ContentUpdater: ObservableObject {
    private var timer: Timer?
    @Published var isChange: Bool = false
    init() {
        timer = Timer.scheduledTimer(
            withTimeInterval: 0.05,
            repeats: true
        ) { [weak self] _ in
            self?.isChange.toggle()
        }
    }
}

In the first case, Text or Image is the direct child of Grid View depending upon the state (contentUpdater.isChange).

1
2
3
VStack(spacing: 10) {
    GridView<ConditionTextNImageChild>(rowCount: 30, columnCount: 7, itemSize: textSize, update: $contentUpdater.isChange)
}

The grid cell is made up of either Text or image depending upon a bool value.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct ConditionTextNImageChild: GridChild {
    var indexPath: IndexPath
    var itemSize: CGSize
    let shouldUpdate: Binding<Bool>
    
    init(indexPath: IndexPath, size: CGSize, shouldUpdate: Binding<Bool>) {
        self.indexPath = indexPath
        self.itemSize = size
        self.shouldUpdate = shouldUpdate
    }
    
    
    var body: some View {
        if self.shouldUpdate.wrappedValue {
            Text("cell\(indexPath.row)\(indexPath.section)").frame(width: itemSize.width, height: itemSize.height)
        } else {
            Image(systemName: "heart.fill")
        }
    }
}

In the following case, AnyView is used to hide the Type.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct ConditionTypeErassedChild: GridChild {
    var indexPath: IndexPath
    var itemSize: CGSize
    let shouldUpdate: Binding<Bool>
    
    init(indexPath: IndexPath, size: CGSize, shouldUpdate: Binding<Bool>) {
        self.indexPath = indexPath
        self.itemSize = size
        self.shouldUpdate = shouldUpdate
    }
    
    
    var body: some View {
        if self.shouldUpdate.wrappedValue {
            AnyView(Text("cell\(indexPath.row)\(indexPath.section)").frame(width: itemSize.width, height: itemSize.height))
        } else {
            AnyView(Image(systemName: "heart.fill"))
        }
    }
}

Evidence Collected

The timer frequency to refresh the content is set to 50ms so that performance can be evaluated under high stress.

I recorded the SwitUI redraw behaviour for one minute and the results are the following:

Concrete
Type Erased

Again concrete type has marginally won over AnyView. Observe the number of redrawn content View 1201 is higher than 1189. The average duration to draw the view is also less in the case of concrete.

Now Let’s add more stress by adding hierarchy in Content View.

View changes with AnyView(n-level Hierarchy)

It’s the final stage of the investigation. Now Grid View is a child of HStack and Hstack is further than Zstack (3-level of hierarchy).

1
2
3
4
5
ZStack {
     HStack {
        GridView<ConditionTypeErassedChild>(rowCount: 30, columnCount: 7, itemSize: textSize, update: $contentUpdater.isChange)
      }
}

Grid Cell is the same as in the case of the 1-level hierarchy.

Evidence Collected

In terms of the frequency of content updates, it is the same 50ms. Here again, I record the SwitUI redraw behaviour for one minute.

In the case of Concrete Type, the average duration(11.22μs) to render the Content is better compared to TypeErased(11.67μs). Again concrete type is marginally win over AnyView.

Concrete
Type Erased

⚖️ Closing Statment

We observe that AnyView has a microscopic impact on the performance. This impact should not raise the fear of avoiding AnyView in the application. We also observed that the system needs to re-calculate the hierarchy in both cases with or without TypeErased.

Time for Judgment(My Opinion), AnyView does not commit any heinous crime here, so there is no reason to censure it from use.

The source code for the project can be found here.

This post is licensed under CC BY 4.0 by the author.

What's System Design

Namaste Swift Server

Comments powered by Disqus.