Skip to content

BodyRenderer is constructed per draw, so its private PathCache never hits #3

Description

@elmbg-sam

Summary

BodyRenderer holds its PathCache as an instance property:

// Sources/MuscleMap/Rendering/BodyRenderer.swift:62
private let pathCache = PathCache()

but a BodyRenderer is constructed fresh on every draw, so the cache is populated and thrown away each time and never serves a hit. Every render re-runs PathBuilder.buildPath over the full body — a few hundred SVG path strings — on the main thread.

Version: 1.6.4.

Where the renderer is rebuilt

BodyView.standardBody constructs one inside the Canvas draw closure:

// Sources/MuscleMap/Views/BodyView.swift
Canvas { context, size in
    makeRenderer(size: size).render(context: &context, size: size)
}

makeRenderer(size:) returns BodyRenderer(...), so each invalidation of that Canvas allocates a new renderer and a new empty PathCache. The same pattern appears in InteractiveBodyOverlay, BodyAccessibilityOverlay, MuscleTooltip and AnimatedBodyView, each with its own short-lived cache — so a single BodyView parses the body several times per frame.

Reproduction

struct ContentView: View {
    @State private var tick = 0

    var body: some View {
        VStack {
            // Any unrelated state change invalidates the Canvas and re-parses
            // the whole body.
            Text("\(tick)")
            BodyView(gender: .male, side: .front)
                .highlight(.chest, color: .red)
            Button("Redraw") { tick += 1 }
        }
    }
}

Add a counter to PathBuilder.buildPath (or a breakpoint) and it is hit the same number of times on every redraw, rather than only on the first.

The symptom in an app: a screen showing two bodies (front + back) is noticeably slow to appear in a Debug build and stutters while the surrounding view updates.

Suggestion

Make the cache outlive the renderer — a static let shared = PathCache(), or a cache owned by the view and passed in. The existing key already includes scale/offsetX/offsetY, so a shared instance stays correct across sizes; only eviction would need thought.

I worked around it downstream by wrapping the body in an EquatableView so it is only rebuilt when the highlight data actually changes, but that only reduces the frequency — the first render of each distinct state still re-parses everything.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions