Skip to content

Commit 0230e7f

Browse files
dskuzadskuza
andcommitted
refactor(apple): load render context async (#11810) b311089408
Co-authored-by: David Skuza <david@rive.app>
1 parent dbbfc02 commit 0230e7f

File tree

13 files changed

+208
-212
lines changed

13 files changed

+208
-212
lines changed

.rive_head

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
dd441653dfa8ac2cf63eeaf68d5a047bc0fbed67
1+
b311089408d38ffe9a176298f962266c334a02e0
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//
2+
// SharedWorkerView.swift
3+
// RiveExample
4+
//
5+
// Created by David Skuza on 3/4/26.
6+
// Copyright © 2026 Rive. All rights reserved.
7+
//
8+
9+
import SwiftUI
10+
@_spi(RiveExperimental) import RiveRuntime
11+
12+
actor WorkerCache {
13+
static let shared = WorkerCache()
14+
15+
@MainActor
16+
private var cachedWorker: Worker?
17+
18+
@MainActor
19+
func worker() async throws -> Worker {
20+
if let cachedWorker {
21+
return cachedWorker
22+
}
23+
24+
let worker = try await Worker()
25+
cachedWorker = worker
26+
return worker
27+
}
28+
}
29+
30+
struct SharedWorkerView: View {
31+
var body: some View {
32+
ScrollView {
33+
ForEach(0..<4) { _ in
34+
AsyncRiveUIViewRepresentable {
35+
let worker = try await WorkerCache.shared.worker()
36+
let file = try await File(source: .local("marty_v2", .main), worker: worker)
37+
return try await Rive(file: file)
38+
}
39+
.frame(maxWidth: .infinity)
40+
.frame(height: 240)
41+
}
42+
}
43+
}
44+
}

Example-iOS/Source/ExamplesMaster.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ class ExamplesMasterTableViewController: UITableViewController {
6363
private let experimental: [(String, AnyView)] = [
6464
("Simple - Marty", AnyView(MartyView())),
6565
("Quick Start - Data Binding", AnyView(QuickStartView())),
66-
("Animation Player", AnyView(PlayerView()))
66+
("Animation Player", AnyView(PlayerView())),
67+
("Shared Worker", AnyView(SharedWorkerView()))
6768
]
6869
}
6970

Source/Experimental/Utilities/MetalDevice.swift

Lines changed: 11 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -9,80 +9,24 @@
99
import Foundation
1010
import Metal
1111

12-
/// Singleton that safely caches the system default Metal device.
13-
final class MetalDevice: @unchecked Sendable {
12+
actor MetalDevice {
1413
static let shared = MetalDevice()
15-
16-
private var device: UncheckedSendable<MTLDevice>?
17-
private var deviceTask: Task<UncheckedSendable<MTLDevice>?, Never>?
18-
private let lock = NSLock()
19-
20-
func defaultDevice() -> UncheckedSendable<MTLDevice>? {
21-
// Serialize access to the cached device.
22-
lock.withLock {
23-
if let device { return device }
24-
25-
// First access pays the system lookup cost once.
26-
let defaultDevice = MTLCreateSystemDefaultDevice().map(UncheckedSendable.init)
27-
28-
device = defaultDevice
29-
return defaultDevice
30-
}
31-
}
14+
private var defaultDevice: UncheckedSendable<MTLDevice>?
3215

3316
func defaultDevice() async -> UncheckedSendable<MTLDevice>? {
34-
// Fast-path: return cached device without spawning a task.
35-
if let cached = cachedDevice() {
36-
return cached
37-
}
38-
return await asyncDefaultDevice()
39-
}
40-
41-
private func cachedDevice() -> UncheckedSendable<MTLDevice>? {
42-
// Read the cached device under lock to avoid races.
43-
lock.withLock { device }
44-
}
45-
46-
private func asyncDefaultDevice() async -> UncheckedSendable<MTLDevice>? {
47-
// Ensure only one async task performs device creation.
48-
var existingDevice: UncheckedSendable<MTLDevice>?
49-
var existingTask: Task<UncheckedSendable<MTLDevice>?, Never>?
50-
var createdTask: Task<UncheckedSendable<MTLDevice>?, Never>?
51-
52-
lock.withLock {
53-
if let cachedDevice = self.device {
54-
existingDevice = cachedDevice
55-
return
56-
}
57-
if let deviceTask {
58-
existingTask = deviceTask
59-
return
60-
}
61-
let task = makeDeviceTask()
62-
deviceTask = task
63-
createdTask = task
64-
}
65-
66-
if let existingDevice {
67-
return existingDevice
17+
if let defaultDevice {
18+
return defaultDevice
6819
}
6920

70-
let task = existingTask ?? createdTask
71-
let resolvedDevice = await task?.value
72-
73-
if createdTask != nil {
74-
// Clear in-flight state after completion.
75-
lock.withLock {
76-
deviceTask = nil
21+
let device = await Task.detached { () -> UncheckedSendable<MTLDevice>? in
22+
guard let device = MTLCreateSystemDefaultDevice() else {
23+
return nil
7724
}
78-
}
7925

80-
return resolvedDevice
81-
}
26+
return UncheckedSendable(value: device)
27+
}.value
8228

83-
private func makeDeviceTask() -> Task<UncheckedSendable<MTLDevice>?, Never> {
84-
return Task.detached { [weak self] in
85-
self?.defaultDevice()
86-
}
29+
defaultDevice = device
30+
return device
8731
}
8832
}

Source/Experimental/Worker/Worker.swift

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,31 @@ public class Worker {
2424
private var images: [String: Image] = [:]
2525
private var fonts: [String: Font] = [:]
2626
private var audios: [String: Audio] = [:]
27-
28-
/// Creates a new worker that spawns a new background instance of Rive.
29-
///
30-
/// The worker will automatically start processing when initialized.
31-
@MainActor
32-
public convenience init() throws {
33-
guard let device = MetalDevice.shared.defaultDevice()?.value else {
34-
throw WorkerError.missingDevice
35-
}
36-
37-
self.init(device: device)
38-
}
3927

4028
@MainActor
4129
public convenience init() async throws {
42-
guard let device = await MetalDevice.shared.defaultDevice()?.value else {
30+
guard let device = await MetalDevice.shared.defaultDevice() else {
4331
throw WorkerError.missingDevice
4432
}
45-
self.init(device: device)
33+
34+
let renderContext = await Task.detached(priority: .userInitiated) { () -> UncheckedSendable<RiveRenderContext> in
35+
UncheckedSendable(value: RiveRenderContext(device: device.value))
36+
}.value
37+
38+
let commandQueue = CommandQueue()
39+
let commandServer = CommandServer(commandQueue: commandQueue, renderContext: renderContext.value)
40+
41+
self.init(
42+
dependencies: .init(
43+
workerService: .init(
44+
dependencies: .init(
45+
commandQueue: commandQueue,
46+
commandServer: commandServer,
47+
renderContext: renderContext.value
48+
)
49+
)
50+
)
51+
)
4652
}
4753

4854
@MainActor

Tests/Experimental/Artboard/ArtboardTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ class ArtboardTests: XCTestCase {
140140
let artboard = Artboard(dependencies: dependencies, artboardHandle: 1)
141141

142142
// Create a mock file with dependencies
143-
let (file, _, _, _) = File.mock(fileHandle: 123)
143+
let (file, _, _, _) = await File.mock(fileHandle: 123)
144144

145145
// Mock the command queue to trigger the onDefaultViewModelInfoReceived callback
146146
let expectation = expectation(description: "default view model info received")
@@ -175,7 +175,7 @@ class ArtboardTests: XCTestCase {
175175
let artboard = Artboard(dependencies: dependencies, artboardHandle: 42)
176176

177177
// Create a mock file with dependencies
178-
let (file, _, _, _) = File.mock(fileHandle: 456)
178+
let (file, _, _, _) = await File.mock(fileHandle: 456)
179179

180180
// Mock the command queue to verify correct handles are passed
181181
let expectation = expectation(description: "default view model info received")

0 commit comments

Comments
 (0)