📋 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:
- @ViewBuilder…some View.
- Return a Protocol containing a view.
- AnyView
While looking for the solution, I came up with multiple sources where it is mentioned that avoid AnyView.
Sources:
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:
- Apple Documentation.
- 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")
}
}
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")
})
}
Here return the type of “AnyView” containing storage of type AnyViewStorage<VStack
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:
- Static N Text with and without Type Erased
- Dynamic view changes with and without AnyView(1-level Hierarchy)
- 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.
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:
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.
⚖️ 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.
Comments powered by Disqus.