Skip to content

Commit 7cc8317

Browse files
ErikUggeldahlErikUggeldahl
andcommitted
fix(Android): VMI read/write lock (#12811) 9f34ff6291
We're seeing a crash in Android legacy in `updateDataBinds`, which we believe to be due to concurrent read and write operations on data binds, with writes happening on the main thread in client code and reads happening from advances on the worker thread. The solution is to make that impossible from the high level runtime by protecting them under mutexes. Contains a rename commit to make the "domain" of the lock more obvious. The test confirmed a similar stack trace before the patch. There's some nuance around updating the lock in the event of a file being used under a new file, since VMIs can be transferred to a different file context. There's also nuance around properties, nested VMIs, and list item VMIs, which all need to inherit the lock as well. But once that's sorted, it's a straight forward matter of putting those operations under synchronized blocks to avoid concurrent mutation. chore(runtime): add computed root values test (#12830) d17e462c1a fix(metal): exempt prepareToFlush from thread-safety analysis (#12734) 3a7bd9bfdf * fix(metal): exempt prepareToFlush from thread-safety analysis RenderContextMetalImpl::prepareToFlush acquires a buffer-ring lock that is released asymmetrically in a GPU completion handler scheduled by postFlush. Clang's -Wthread-safety analysis cannot follow a lock handed off into that block and flags the unbalanced lock(). The newer Clang in Xcode 26.4.1 turns this into a hard error for downstream builds that compile with -Wthread-safety -Werror. Add a portable RIVE_NO_THREAD_SAFETY_ANALYSIS macro and apply it to the prepareToFlush definition to exempt this intentional, correct locking pattern from the analysis. feature: fit text content by font size (#12792) c623003932 chore(tests): add test to catch missing BrowserStack gm images (#12806) 65383d26e5 Nothing in our CI was previously testing to make sure that the browserstack_gms images contained all of the images needed (when a new gm was introduced), now there is a test to do so. fix(ore/vk): MSAA resolve corruption on Xclipse 920 (resolve targets missed the post-pass layout hand-off) (#12810) bd3b3f3b75 * fix(ore/vk): hand off MSAA resolve target layout to Rive after the render pass * ci: un-skip ore_msaa_resolve on galaxy-s22 vk jobs, TEMP gate CI to s22 only * clang-format * rebaseline galaxy-s22 vk ore_msaa_resolve goldens * ci: restore full CI, revert temporary s22-only gating * review: ResolveTarget struct instead of parallel arrays, std::exchange in move ctor, drop stale glmsaa comment feat(Android): Capped FPS (#12789) 4461206065 Adds an often requested feature to cap FPS. This could save tremendously on battery, especially on high refresh displays. Implemented as a coordination between the Rive composable and the new `RiveFramePacer` helper, which tracks the state for when then next frame should run. Additionally, we hint to the View the requested frame rate, but it only really works for API 35+, so it's a nice to have rather than truly functional. Includes a sample and unit tests. Co-authored-by: Erik <erik@rive.app>
1 parent 52faff2 commit 7cc8317

25 files changed

Lines changed: 1141 additions & 185 deletions

File tree

.rive_head

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
c8bb275d99abd793a9147836394462ba218f6d3c
1+
9f34ff6291fd0a0383c2c369514083a53b6606c6

app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
<activity
4848
android:name=".ComposeAudioActivity"
4949
android:exported="true" />
50+
<activity
51+
android:name=".ComposeCappedFPSActivity"
52+
android:exported="true" />
5053
<activity
5154
android:name=".ComposeTouchPassThroughActivity"
5255
android:exported="true" />
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package app.rive.runtime.example
2+
3+
import android.os.Bundle
4+
import androidx.activity.ComponentActivity
5+
import androidx.activity.SystemBarStyle
6+
import androidx.activity.compose.setContent
7+
import androidx.activity.enableEdgeToEdge
8+
import androidx.compose.foundation.layout.Box
9+
import androidx.compose.foundation.layout.Column
10+
import androidx.compose.foundation.layout.Arrangement
11+
import androidx.compose.foundation.layout.Row
12+
import androidx.compose.foundation.layout.WindowInsets
13+
import androidx.compose.foundation.layout.fillMaxSize
14+
import androidx.compose.foundation.layout.fillMaxWidth
15+
import androidx.compose.foundation.layout.padding
16+
import androidx.compose.foundation.layout.safeGestures
17+
import androidx.compose.foundation.layout.windowInsetsPadding
18+
import androidx.compose.material3.Button
19+
import androidx.compose.material3.MaterialTheme
20+
import androidx.compose.material3.Scaffold
21+
import androidx.compose.material3.Slider
22+
import androidx.compose.material3.Text
23+
import androidx.compose.runtime.getValue
24+
import androidx.compose.runtime.mutableFloatStateOf
25+
import androidx.compose.runtime.mutableStateOf
26+
import androidx.compose.runtime.saveable.rememberSaveable
27+
import androidx.compose.runtime.setValue
28+
import androidx.compose.ui.Alignment
29+
import androidx.compose.ui.Modifier
30+
import androidx.compose.ui.graphics.Color
31+
import androidx.compose.ui.semantics.contentDescription
32+
import androidx.compose.ui.semantics.semantics
33+
import androidx.compose.ui.unit.dp
34+
import app.rive.Fit
35+
import app.rive.Result
36+
import app.rive.Rive
37+
import app.rive.RiveFileSource
38+
import app.rive.RiveFrameRate
39+
import app.rive.RiveLog
40+
import app.rive.rememberRiveFile
41+
import app.rive.rememberRiveWorker
42+
import java.util.Locale
43+
import android.graphics.Color as AndroidColor
44+
45+
private val FPS_PRESETS = listOf(12f, 24f, 30f, 60f, 90f, 120f, 240f)
46+
private val FPS_PRESET_ROWS = listOf(
47+
FPS_PRESETS.take(4),
48+
FPS_PRESETS.drop(4)
49+
)
50+
51+
class ComposeCappedFPSActivity : ComponentActivity() {
52+
override fun onCreate(savedInstanceState: Bundle?) {
53+
super.onCreate(savedInstanceState)
54+
enableEdgeToEdge(
55+
statusBarStyle = SystemBarStyle.dark(AndroidColor.BLACK),
56+
navigationBarStyle = SystemBarStyle.dark(AndroidColor.BLACK)
57+
)
58+
RiveLog.logger = RiveLog.LogcatLogger()
59+
60+
setContent {
61+
val riveWorker = rememberRiveWorker()
62+
val riveFile = rememberRiveFile(RiveFileSource.RawRes.from(R.raw.marty), riveWorker)
63+
var framesPerSecond by rememberSaveable { mutableFloatStateOf(30f) }
64+
var isUncapped by rememberSaveable { mutableStateOf(false) }
65+
val frameRate = if (isUncapped) {
66+
RiveFrameRate.Unbounded
67+
} else {
68+
RiveFrameRate.Capped(framesPerSecond)
69+
}
70+
71+
Scaffold(containerColor = Color.Black) { innerPadding ->
72+
Column(
73+
modifier = Modifier
74+
.fillMaxSize()
75+
.padding(innerPadding)
76+
) {
77+
Box(
78+
modifier = Modifier
79+
.weight(1f)
80+
.fillMaxWidth()
81+
) {
82+
when (riveFile) {
83+
is Result.Loading -> LoadingIndicator()
84+
is Result.Error -> ErrorMessage(riveFile.throwable)
85+
is Result.Success -> {
86+
Rive(
87+
file = riveFile.value,
88+
fit = Fit.Contain(),
89+
frameRate = frameRate,
90+
modifier = Modifier.fillMaxSize()
91+
)
92+
}
93+
}
94+
}
95+
96+
Column(
97+
modifier = Modifier
98+
.fillMaxWidth()
99+
.windowInsetsPadding(WindowInsets.safeGestures)
100+
.padding(16.dp),
101+
horizontalAlignment = Alignment.CenterHorizontally
102+
) {
103+
Text(
104+
text = if (isUncapped) {
105+
"FPS Cap: Uncapped"
106+
} else {
107+
String.format(Locale.US, "FPS Cap: %.1f", framesPerSecond)
108+
},
109+
style = MaterialTheme.typography.bodyLarge,
110+
color = Color.White,
111+
modifier = Modifier.padding(bottom = 8.dp)
112+
)
113+
Slider(
114+
value = framesPerSecond,
115+
onValueChange = {
116+
framesPerSecond = it
117+
isUncapped = false
118+
},
119+
valueRange = 1f..240f,
120+
modifier = Modifier.fillMaxWidth()
121+
)
122+
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
123+
FPS_PRESET_ROWS.forEachIndexed { index, presets ->
124+
Row(
125+
modifier = Modifier.fillMaxWidth(),
126+
horizontalArrangement = Arrangement.spacedBy(8.dp)
127+
) {
128+
presets.forEach { preset ->
129+
Button(
130+
onClick = {
131+
framesPerSecond = preset
132+
isUncapped = false
133+
},
134+
modifier = Modifier.weight(1f)
135+
) {
136+
Text(preset.toInt().toString())
137+
}
138+
}
139+
if (index == FPS_PRESET_ROWS.lastIndex) {
140+
Button(
141+
onClick = { isUncapped = true },
142+
modifier = Modifier
143+
.weight(1f)
144+
.semantics { contentDescription = "Uncapped" }
145+
) {
146+
Text("🚀")
147+
}
148+
}
149+
}
150+
}
151+
}
152+
}
153+
}
154+
}
155+
}
156+
}
157+
}

app/src/main/java/app/rive/runtime/example/MainActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class MainActivity : ComponentActivity() {
1919
Pair(R.id.go_compose_data_binding_lists, ComposeListActivity::class.java),
2020
Pair(R.id.go_compose_layout, ComposeLayoutActivity::class.java),
2121
Pair(R.id.go_compose_audio, ComposeAudioActivity::class.java),
22+
Pair(R.id.go_compose_capped_fps, ComposeCappedFPSActivity::class.java),
2223
Pair(R.id.go_compose_touch_pass_through, ComposeTouchPassThroughActivity::class.java),
2324
Pair(R.id.go_compose_scrolling, ComposeScrollActivity::class.java),
2425
Pair(R.id.go_simple, SimpleActivity::class.java),

app/src/main/res/layout/main.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@
8888
android:text="Compose Audio"
8989
android:textColor="@color/textColorPrimary" />
9090

91+
<Button
92+
android:id="@+id/go_compose_capped_fps"
93+
style="@style/Widget.Material3.Button"
94+
android:layout_width="match_parent"
95+
android:layout_height="wrap_content"
96+
android:layout_marginVertical="4dp"
97+
android:text="Compose Capped FPS"
98+
android:textColor="@color/textColorPrimary" />
99+
91100
<Button
92101
android:id="@+id/go_compose_touch_pass_through"
93102
style="@style/Widget.Material3.Button"

app/src/main/res/raw/marty.riv

122 Bytes
Binary file not shown.

kotlin/src/androidTest/kotlin/app/rive/CommandQueueComposeTest.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.compose.runtime.remember
77
import androidx.compose.ui.test.junit4.createAndroidComposeRule
88
import androidx.test.ext.junit.runners.AndroidJUnit4
99
import app.rive.core.CommandQueue
10+
import app.rive.runtime.kotlin.test.R
1011
import org.junit.Rule
1112
import org.junit.runner.RunWith
1213
import kotlin.test.BeforeTest
@@ -49,4 +50,60 @@ class CommandQueueComposeTest : RiveAndroidTest() {
4950
assertEquals(0, queue.refCount)
5051
assertTrue(queue.isDisposed)
5152
}
53+
54+
@Test
55+
fun rememberRiveFile_reloads_when_worker_changes() {
56+
val firstWorker = CommandQueue()
57+
val secondWorker = CommandQueue()
58+
lateinit var activeWorker: MutableState<CommandQueue>
59+
var showContent: MutableState<Boolean>? = null
60+
var fileResult: Result<RiveFile> = Result.Loading
61+
62+
composeRule.mainClock.autoAdvance = true
63+
64+
fun waitForFileLoadedBy(worker: CommandQueue) {
65+
composeRule.waitUntil(timeoutMillis = 5_000) {
66+
worker.pollMessages()
67+
(fileResult as? Result.Success)?.value?.riveWorker === worker
68+
}
69+
}
70+
71+
try {
72+
val source = RiveFileSource.RawRes(R.raw.empty, context.resources)
73+
74+
composeRule.setContent {
75+
activeWorker = remember { mutableStateOf(firstWorker) }
76+
val activeShowContent = remember { mutableStateOf(true) }
77+
showContent = activeShowContent
78+
if (activeShowContent.value) {
79+
fileResult = rememberRiveFile(source, activeWorker.value)
80+
}
81+
}
82+
83+
waitForFileLoadedBy(firstWorker)
84+
85+
composeRule.runOnUiThread {
86+
activeWorker.value = secondWorker
87+
}
88+
89+
waitForFileLoadedBy(secondWorker)
90+
91+
assertEquals(1, firstWorker.refCount)
92+
assertEquals(2, secondWorker.refCount)
93+
} finally {
94+
showContent?.let { activeShowContent ->
95+
composeRule.runOnUiThread {
96+
activeShowContent.value = false
97+
}
98+
}
99+
composeRule.waitForIdle()
100+
101+
if (!firstWorker.isDisposed) {
102+
firstWorker.release(javaClass.simpleName, "Test cleanup")
103+
}
104+
if (!secondWorker.isDisposed) {
105+
secondWorker.release(javaClass.simpleName, "Test cleanup")
106+
}
107+
}
108+
}
52109
}

kotlin/src/androidTest/kotlin/app/rive/runtime/kotlin/core/RiveArtboardRendererTest.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ class RiveArtboardRendererTest {
7272
val afterDeleteLatch = CountDownLatch(1)
7373

7474
// The renderer needs a valid artboard to proceed to accessing the cppPointer
75-
val dummyArtboard = object : Artboard(unsafeCppPointer = 1L, lock = ReentrantLock()) {
75+
val dummyArtboard = object : Artboard(unsafeCppPointer = 1L, fileLock = ReentrantLock()) {
7676
// Override draw to do nothing, avoiding the thread affinity checks in the real draw().
7777
override fun draw(
7878
rendererAddress: Long,
@@ -138,12 +138,12 @@ class RiveArtboardRendererTest {
138138
// Signals that the main thread has released the artboard, and draw() can continue.
139139
val afterRelease = CountDownLatch(1)
140140

141-
// A lock we can reference, unlike the default private Artboard lock.
141+
// A lock we can reference, unlike the default private Artboard file lock.
142142
val artboardLock = ReentrantLock()
143143

144144
// An artboard that synchronizes with the main thread to simulate being released while
145145
// being drawn.
146-
val latchingArtboard = object : Artboard(unsafeCppPointer = 1L, lock = artboardLock) {
146+
val latchingArtboard = object : Artboard(unsafeCppPointer = 1L, fileLock = artboardLock) {
147147
override fun draw(
148148
rendererAddress: Long,
149149
fit: Fit,
@@ -209,12 +209,12 @@ class RiveArtboardRendererTest {
209209
// Signals that the main thread has released the artboard, and draw() can continue.
210210
val afterRelease = CountDownLatch(1)
211211

212-
// A lock we can reference, unlike the default private Artboard lock.
212+
// A lock we can reference, unlike the default private Artboard file lock.
213213
val artboardLock = ReentrantLock()
214214

215215
// An artboard that synchronizes with the main thread to simulate being released right
216216
// before being drawn.
217-
val latchingArtboard = object : Artboard(unsafeCppPointer = 1L, lock = artboardLock) {
217+
val latchingArtboard = object : Artboard(unsafeCppPointer = 1L, fileLock = artboardLock) {
218218
override fun draw(
219219
rendererAddress: Long,
220220
fit: Fit,

kotlin/src/androidTest/kotlin/app/rive/runtime/kotlin/core/RiveControllerTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,8 @@ class RiveControllerTest {
182182
// No assertions. The intent is to run without throwing a NoSuchElementException.
183183
}
184184

185-
private class ArtboardSpy(unsafeCppPointer: Long, lock: ReentrantLock) :
186-
Artboard(unsafeCppPointer, lock) {
185+
private class ArtboardSpy(unsafeCppPointer: Long, fileLock: ReentrantLock) :
186+
Artboard(unsafeCppPointer, fileLock) {
187187
final var advanceCount = 0
188188
private set
189189

@@ -204,7 +204,7 @@ class RiveControllerTest {
204204
if (artboardPointer == NULL_POINTER) {
205205
throw ArtboardException("No Artboard found at index $index.")
206206
}
207-
val ab = ArtboardSpy(artboardPointer, lock) // Instantiate the spy
207+
val ab = ArtboardSpy(artboardPointer, fileLock) // Instantiate the spy
208208
dependencies.add(ab)
209209
return ab
210210
}

0 commit comments

Comments
 (0)