-
-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Description
Problem Summary
In large apps, Swift runtime protocol conformance checks can significantly impact startup time and app responsiveness. This issue documents profiling results and proposes concrete fixes to reduce casting overhead in Lottie's NodeRenderSystem.
Background: Swift Protocol Casting Costs
The first conformance check for any given (type, protocol) pair is expensive because the runtime must scan all protocol conformance type descriptors. After a successful cast, Swift caches the protocol witness table for that (type, protocol) pair - but in apps that initialize Lottie animations at startup, repeated casting across many types can still add up to measurable overhead.
Profiling Results
Profiling was performed in our app on iOS Simulator and iPhone 13 (Release build).
| Environment | MainThreadAnimationLayer.init total | Time spent in protocol casts |
|---|---|---|
| iOS Simulator | ~25ms | 17ms (68%) |
| iPhone 13 | ~42ms | 31ms (74%) |
The majority of this time was spent inside GroupNode.init and initializeNodeTree, as shown below:
After applying proposed fixes all overhead from type-casting is gone
Root Cause
There are two categories of protocol casting happening inside the NodeRenderSystem:
-
Explicit casts in (ItemExtension.swift)
if let pathNode = nodeTree.rootNode as? PathNode { ... }if let renderNode = nodeTree.rootNode as? RenderNode { ... }
-
Implicit casts in (ShapeCompositionLayer.swift and GroupNode.swift)
childKeypaths.append(contentsOf: results.childrenNodes)
Proposed Fixes
Fix 1: Replace protocol casts with exhaustive type switches in ItemsExtension.swift
Since PathNode and RenderNode are internal protocols with a closed, known set of conforming types , we can replace runtime protocol casting with concrete type switches. This avoids conformance scanning entirely.
private func getPathNode(rootNode: AnimatorNode?) -> PathNode? {
guard let rootNode else { return nil }
switch rootNode {
case let node as EllipseNode: return node
case let node as PolygonNode: return node
case let node as ShapeNode: return node
case let node as StarNode: return node
case let node as RectangleNode: return node
default: return nil
}
}
private func getRenderNode(rootNode: AnimatorNode?) -> RenderNode? {
guard let rootNode else { return nil }
switch rootNode {
case let node as GradientFillNode: return node
case let node as StrokeNode: return node
case let node as FillNode: return node
case let node as GradientStrokeNode: return node
default: return nil
}
}And update initializeNodeTree to use these helpers:
if let pathNode = getPathNode(rootNode: nodeTree.rootNode) {
nodeTree.paths.append(pathNode.pathOutput)
}
if let renderNode = getRenderNode(rootNode: nodeTree.rootNode) {
nodeTree.renderContainers.append(ShapeRenderLayer(renderer: renderNode.renderer))
}Tradeoffs:
This approach violates the Open/Closed Principle. Any new type conforming to PathNode or RenderNode must also be added to these switch statements. A compile-time exhaustiveness check or a documentation comment on the protocols would help mitigate this risk.
Fix 2: Explicit element-wise boxing to avoid _arrayForceCast
Replacing implicit array covariance casts with an explicit .map { $0 as KeypathSearchable } causes the compiler to emit a direct existential box operation per element, completely bypassing _arrayForceCast and its per-element conformance checks.
// GroupNode.swift — before
var childKeypaths: [KeypathSearchable] = tree.childrenNodes
// GroupNode.swift — after
var childKeypaths: [KeypathSearchable] = tree.childrenNodes.map { $0 as KeypathSearchable }// ShapeCompositionLayer.swift — before
childKeypaths.append(contentsOf: results.childrenNodes)
// ShapeCompositionLayer.swift — after
childKeypaths.append(contentsOf: results.childrenNodes.map { $0 as KeypathSearchable })Why this works: The explicit as KeypathSearchable cast on a value already known to be a concrete conforming type is resolved at compile time. The .map introduces a small allocation, but this is far cheaper than repeated conformance table scanning.