Unwanted AnyView

I am experimenting with view caching. Note the use of type erased AnyView:

class ViewCache {
    static let shared = ViewCache()
    private init() {}
    
    private var cache: [AnyHashable: AnyView] = [:] //😢
    
    func view<V: View>(for key: AnyHashable, create: () -> V) -> AnyView { //😢
        if let existing = cache[key] {
            // TODO: set as MRU
            return existing
        }
        let newView = AnyView(create()) //😢
        cache[key] = newView
        print("cache size: \(cache.count)")
        // TODO: set as MRU
        // TODO: limit cache size by removing the LRU element
        return newView
    }
}
Infrastructure
protocol CacheableView: View {
    associatedtype Content: Hashable
    associatedtype BT: View
    associatedtype RT: View
    var content: Content { get }
    var body: BT { get }
    var realBody: RT { get }
}

extension CacheableView {
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.content == rhs.content
    }
    func hash(into hasher: inout Hasher) {
        hasher.combine(content)
    }
    var body: some View {
        ViewCache.shared.view(for: content) {
            realBody
        }
    }
}
Usage example
struct MyView : CacheableView {
    struct Content: Hashable {
        let param1: Int
        let param2: String
    }
    let content: Content
    
    // called if the view is not in cache
    var realBody: some View {
        print("realBody called")
        return Text(content.param2)
    }
}

struct ContentView: View {
    @State private var param1 = 0
    @State private var param2 = "Hello"

    var body: some View {
        VStack {
            MyView(content: MyView.Content(param1: param1, param2: param2)).padding()
            Button("change param1") { param1 += 1 }.padding()
            Button("change param2") { param2 += "+" }.padding()
        }
    }
}

@main struct iApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

I've read AnyView is unwise (e.g. here). Is it inevitable to use AnyView in this case? Or is it possible to change it to something like any CacheableView or a view builder instead?

Some form of type erasure is unavoidable, and I don't think that using AnyView vs any View vs any CacheableView matters much.

But why do you even need a view cache in the first place? Views are just structs, they should be cheap to construct. SwiftUI won't call body unless it thinks that some inputs to the body have changed. If you see body called too often, this might be indication of a different problem. Are you creating object instances (often conforming to ObservableObject, but not necessary) inside body? Maybe indirectly, in another function called from body? Including initialisers of child views.

Also worth to check for mistakes in custom Hashable conformance. But usually this would manifest itself other way around - view would not update when it should.

3 Likes

Creating a list with a large number of items (like 100K) can take quite a while. Example:

    var body: some View {
            List {
                ForEach(content.items, id: \.self) { item in
                    NavigationLink {
                        Text(item)
                    } label: {
                        Text(item)
                    }
                }
            }
    }

Showing this list takes seconds. Surprisingly, dismissing this list takes even longer.

Full example if you want to try
import SwiftUI

let theContent = TheView.Content(items: (0 ..< 100_000).map { _ in UUID().uuidString })

struct TheView: View {
    struct Content: Hashable {
        var items: [String]
    }
    let content: Content
    
    var body: some View {
        let start = Date()
        defer { print(Date().timeIntervalSince(start)) }
        return List {
                ForEach(content.items, id: \.self) { item in
                    NavigationLink {
                        Text(item)
                    } label: {
                        Text(item)
                    }
                }
        }
    }
}

struct ContentView: View {
    @State private var isPresented = false
    var body: some View {
        Button("Show") {
            self.isPresented = true
        }
        .sheet(isPresented: $isPresented) {
            NavigationView {
                TheView(content: theContent)
                    .toolbar {
                        ToolbarItem {
                            Button("Close") { isPresented = false }
                        }
                    }
            }
        }
    }
}

@main struct iApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

I rechecked this again, interestingly the slowdown in this particular case is not in the body call itself, it is inside the framework, so indeed caching body result won't help in this particular case.

Edit: removed unneeded NavigationView & VStack from TheView.

Hm. That's strange. List should be lazy, calling ForEach as you scroll. Are you running on iOS or macOS?

I checked on iOS initially. It's worse on mac: opening a list with 100K items beachballs and doesn't complete in a reasonable time (I gave it a minute and then gave up). 30K items works "better" (starts beachballing after about 10 seconds, but then finally completes in about yet another 10 seconds). Interestingly, compared to iOS: closing the list is very fast on mac. Release build, M1 Pro Mac.

Updated app with minor tuning for mac
import SwiftUI

let theContent = TheView.Content(items: (0 ..< 30_000).map { _ in UUID().uuidString })

struct TheView: View {
    struct Content: Hashable {
        var items: [String]
    }
    let content: Content
    
    var body: some View {
        let start = Date()
        defer { print(Date().timeIntervalSince(start)) }
        return List {
            ForEach(content.items, id: \.self) { item in
                NavigationLink {
                    Text(item)
                } label: {
                    Text(item)
                }
            }
        }.frame(width: 300, height: 300)
    }
}

struct ContentView: View {
    @State private var isPresented = false
    var body: some View {
        Button("Show") {
            self.isPresented = true
        }
        .frame(width: 300, height: 300)
        .sheet(isPresented: $isPresented) {
            NavigationView {
                TheView(content: theContent)
                    .toolbar {
                        ToolbarItem {
                            Button("Close") { isPresented = false }
                        }
                    }
            }
        }
    }
}

@main struct iApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

List hasn't properly scaled since the day it was introduced. There's no native SwiftUI solution to this problem. Most people revert to just wrapping a collection or table view, unfortunately.

2 Likes