From e787746bc0d074d11247bbb64c224407129c092e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Sat, 10 Oct 2026 19:28:05 +0200 Subject: [PATCH 1/4] fix(android): stop idle frame callbacks on the new backend The view's render loop and the CommandQueue poll both re-posted a Choreographer callback every vsync for the life of the process, so the app woke on every frame even when every view was paused, settled, unmounted or backgrounded (#413). The view now requests one frame at a time, only while it is visible and has something to advance or draw, like rive-android's Rive composable suspending while settled. It wakes on play, configure, rebind, surface changes, pointer input and visibility changes. Each wake re-arms rive-android's settle signal (internal, reached by reflection), which otherwise fires only once per state machine. Polling uses rive-android's CommandQueue.beginPolling bound to the process lifecycle, so it stops while the app is in the background. A view-model write from JS after the state machine settled was never applied, because nothing advanced it again; every write now wakes the live views. Covered by settled-property.harness.tsx, which fails without this change. --- android/build.gradle | 2 + .../nitro/rive/HybridRiveFileFactory.kt | 43 +++- .../rive/HybridViewModelArtboardProperty.kt | 2 + .../rive/HybridViewModelBooleanProperty.kt | 2 + .../rive/HybridViewModelColorProperty.kt | 2 + .../nitro/rive/HybridViewModelEnumProperty.kt | 2 + .../rive/HybridViewModelImageProperty.kt | 2 + .../nitro/rive/HybridViewModelListProperty.kt | 11 + .../rive/HybridViewModelNumberProperty.kt | 2 + .../rive/HybridViewModelStringProperty.kt | 2 + .../rive/HybridViewModelTriggerProperty.kt | 2 + .../new/java/com/rive/RiveReactNativeView.kt | 171 ++++++++----- .../new/java/com/rive/StateMachineSettling.kt | 37 +++ .../__tests__/settled-property.harness.tsx | 78 ++++++ .../assets/rive-src/settle_probe/rive.yaml | 6 + .../assets/rive-src/settle_probe/scene.rml | 121 ++++++++++ example/assets/rive/settle_probe.riv | Bin 0 -> 687 bytes example/assets/rive/settle_probe.riv.d.ts | 25 ++ .../src/reproducers/Issue413IdleFrames.tsx | 228 ++++++++++++++++++ 19 files changed, 671 insertions(+), 67 deletions(-) create mode 100644 android/src/new/java/com/rive/StateMachineSettling.kt create mode 100644 example/__tests__/settled-property.harness.tsx create mode 100644 example/assets/rive-src/settle_probe/rive.yaml create mode 100644 example/assets/rive-src/settle_probe/scene.rml create mode 100644 example/assets/rive/settle_probe.riv create mode 100644 example/assets/rive/settle_probe.riv.d.ts create mode 100644 example/src/reproducers/Issue413IdleFrames.tsx diff --git a/android/build.gradle b/android/build.gradle index 6c88ff8b..b397366f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -167,6 +167,8 @@ dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "app.rive:rive-android:${riveAndroidVersion}" + // Same version as the lifecycle libraries rive-android depends on. + implementation "androidx.lifecycle:lifecycle-process:2.9.4" implementation project(":react-native-nitro-modules") } diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridRiveFileFactory.kt b/android/src/new/java/com/margelo/nitro/rive/HybridRiveFileFactory.kt index f40a4203..694dedbc 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridRiveFileFactory.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridRiveFileFactory.kt @@ -1,11 +1,12 @@ package com.margelo.nitro.rive import android.annotation.SuppressLint -import android.os.Handler -import android.os.Looper import android.util.Log -import android.view.Choreographer import androidx.annotation.Keep +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ProcessLifecycleOwner import app.rive.RiveFile import app.rive.RiveFileSource import app.rive.core.CommandQueue @@ -13,7 +14,10 @@ import com.facebook.proguard.annotations.DoNotStrip import com.margelo.nitro.core.ArrayBuffer import com.margelo.nitro.core.Promise import com.rive.RiveWorkerConfig +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** @@ -99,26 +103,39 @@ class HybridRiveFileFactory : HybridRiveFileFactorySpec() { * The experimental Rive SDK's CommandQueue needs to be polled every frame * to process responses from the C++ command server. Without polling, * all suspend functions (like RiveFile.fromSource) hang indefinitely. + * Polls only while the app is resumed, like rive-android's own hosts, so a + * backgrounded app gets no frame callbacks (issue #413). */ private fun startPolling(worker: CommandQueue) { if (pollingStarted) return pollingStarted = true - Handler(Looper.getMainLooper()).post { - val callback = object : Choreographer.FrameCallback { - override fun doFrame(frameTimeNanos: Long) { - try { - worker.pollMessages() - } catch (e: Exception) { - Log.e(TAG, "pollMessages error", e) - } - Choreographer.getInstance().postFrameCallback(this) + CoroutineScope(Dispatchers.Main).launch { + val processLifecycle = ProcessLifecycleOwner.get().lifecycle + // INITIALIZED means the app disabled ProcessLifecycleOwner's + // initializer, so it would never report RESUMED. + val lifecycle = if (processLifecycle.currentState == Lifecycle.State.INITIALIZED) { + AlwaysResumed.lifecycle + } else { + processLifecycle + } + while (true) { + try { + worker.beginPolling(lifecycle) + return@launch + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "pollMessages error", e) } } - Choreographer.getInstance().postFrameCallback(callback) } } } + private object AlwaysResumed : LifecycleOwner { + override val lifecycle = LifecycleRegistry(this).apply { currentState = Lifecycle.State.RESUMED } + } + private suspend fun buildRiveFile( data: ByteArray, referencedAssets: ReferencedAssetsType? diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelArtboardProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelArtboardProperty.kt index e9232d72..f5b21ffd 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelArtboardProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelArtboardProperty.kt @@ -5,6 +5,7 @@ import androidx.annotation.Keep import app.rive.Artboard import app.rive.ViewModelInstance import com.facebook.proguard.annotations.DoNotStrip +import com.rive.RiveReactNativeView @Keep @DoNotStrip @@ -23,6 +24,7 @@ class HybridViewModelArtboardProperty( try { val newArtboard = Artboard.fromFile(sourceFile, hybridArtboard.artboardName) instance.setArtboard(path, newArtboard) + RiveReactNativeView.onViewModelChanged() } catch (e: Exception) { Log.e(TAG, "Failed to set artboard for path '$path'", e) } diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelBooleanProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelBooleanProperty.kt index 458fd94d..af507f8a 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelBooleanProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelBooleanProperty.kt @@ -6,6 +6,7 @@ import com.facebook.proguard.annotations.DoNotStrip import com.margelo.nitro.core.Promise import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import com.rive.RiveReactNativeView @Keep @DoNotStrip @@ -35,6 +36,7 @@ class HybridViewModelBooleanProperty( override fun set(value: Boolean) { instance.setBoolean(path, value) + RiveReactNativeView.onViewModelChanged() } override fun setValueAsync(value: Boolean): Promise { diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelColorProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelColorProperty.kt index abf79576..b8ce76c7 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelColorProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelColorProperty.kt @@ -6,6 +6,7 @@ import com.facebook.proguard.annotations.DoNotStrip import com.margelo.nitro.core.Promise import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import com.rive.RiveReactNativeView @Keep @DoNotStrip @@ -35,6 +36,7 @@ class HybridViewModelColorProperty( override fun set(value: Double) { instance.setColor(path, value.toLong().toInt()) + RiveReactNativeView.onViewModelChanged() } override fun setValueAsync(value: Double): Promise { diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelEnumProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelEnumProperty.kt index ef89352e..fb509da2 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelEnumProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelEnumProperty.kt @@ -6,6 +6,7 @@ import com.facebook.proguard.annotations.DoNotStrip import com.margelo.nitro.core.Promise import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import com.rive.RiveReactNativeView @Keep @DoNotStrip @@ -35,6 +36,7 @@ class HybridViewModelEnumProperty( override fun set(value: String) { instance.setEnum(path, value) + RiveReactNativeView.onViewModelChanged() } override fun setValueAsync(value: String): Promise { diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt index 487a4e99..eaf3cd2a 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt @@ -9,6 +9,7 @@ import com.facebook.proguard.annotations.DoNotStrip import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import com.rive.RiveReactNativeView @Keep @DoNotStrip @@ -31,6 +32,7 @@ class HybridViewModelImageProperty( val result = ImageAsset.fromBytes(riveWorker, hybridImage.rawData) if (result is app.rive.Result.Success) { instance.setImage(path, result.value) + RiveReactNativeView.onViewModelChanged() } else { Log.e(TAG, "Failed to decode image for path '$path'") } diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelListProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelListProperty.kt index 9ab9852a..73b29611 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelListProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelListProperty.kt @@ -8,6 +8,7 @@ import app.rive.core.CommandQueue import com.facebook.proguard.annotations.DoNotStrip import com.margelo.nitro.core.Promise import kotlinx.coroutines.runBlocking +import com.rive.RiveReactNativeView @Keep @DoNotStrip @@ -77,6 +78,7 @@ class HybridViewModelListProperty( DeprecationWarning.warn("ListProperty.addInstance", "addInstanceAsync") val hybridInstance = instance as? HybridViewModelInstance ?: return this.instance.appendToList(path, hybridInstance.viewModelInstance) + RiveReactNativeView.onViewModelChanged() } override fun addInstanceAt(instance: HybridViewModelInstanceSpec, index: Double): Boolean { @@ -84,6 +86,7 @@ class HybridViewModelListProperty( val hybridInstance = instance as? HybridViewModelInstance ?: return false return try { this.instance.insertToListAtIndex(path, index.toInt(), hybridInstance.viewModelInstance) + RiveReactNativeView.onViewModelChanged() true } catch (e: Exception) { Log.e(TAG, "addInstanceAt failed", e) @@ -95,17 +98,20 @@ class HybridViewModelListProperty( DeprecationWarning.warn("ListProperty.removeInstance", "removeInstanceAsync") val hybridInstance = instance as? HybridViewModelInstance ?: return this.instance.removeFromList(path, hybridInstance.viewModelInstance) + RiveReactNativeView.onViewModelChanged() } override fun removeInstanceAt(index: Double) { DeprecationWarning.warn("ListProperty.removeInstanceAt", "removeInstanceAtAsync") this.instance.removeFromListAtIndex(path, index.toInt()) + RiveReactNativeView.onViewModelChanged() } override fun swap(index1: Double, index2: Double): Boolean { DeprecationWarning.warn("ListProperty.swap", "swapAsync") return try { this.instance.swapListItems(path, index1.toInt(), index2.toInt()) + RiveReactNativeView.onViewModelChanged() true } catch (e: Exception) { Log.e(TAG, "swap failed", e) @@ -118,6 +124,7 @@ class HybridViewModelListProperty( ?: return Promise.rejected(RuntimeException("Expected HybridViewModelInstance")) return Promise.async { this.instance.appendToList(path, hybridInstance.viewModelInstance) + RiveReactNativeView.onViewModelChanged() } } @@ -127,6 +134,7 @@ class HybridViewModelListProperty( return Promise.async { requireIndexInBounds(index.toInt(), allowEnd = true) this.instance.insertToListAtIndex(path, index.toInt(), hybridInstance.viewModelInstance) + RiveReactNativeView.onViewModelChanged() } } @@ -135,6 +143,7 @@ class HybridViewModelListProperty( ?: return Promise.rejected(RuntimeException("Expected HybridViewModelInstance")) return Promise.async { this.instance.removeFromList(path, hybridInstance.viewModelInstance) + RiveReactNativeView.onViewModelChanged() } } @@ -142,6 +151,7 @@ class HybridViewModelListProperty( return Promise.async { requireIndexInBounds(index.toInt()) this.instance.removeFromListAtIndex(path, index.toInt()) + RiveReactNativeView.onViewModelChanged() } } @@ -150,6 +160,7 @@ class HybridViewModelListProperty( requireIndexInBounds(index1.toInt()) requireIndexInBounds(index2.toInt()) this.instance.swapListItems(path, index1.toInt(), index2.toInt()) + RiveReactNativeView.onViewModelChanged() } } diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelNumberProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelNumberProperty.kt index 76259346..76e5a72c 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelNumberProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelNumberProperty.kt @@ -6,6 +6,7 @@ import com.facebook.proguard.annotations.DoNotStrip import com.margelo.nitro.core.Promise import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import com.rive.RiveReactNativeView @Keep @DoNotStrip @@ -35,6 +36,7 @@ class HybridViewModelNumberProperty( override fun set(value: Double) { instance.setNumber(path, value.toFloat()) + RiveReactNativeView.onViewModelChanged() } override fun setValueAsync(value: Double): Promise { diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelStringProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelStringProperty.kt index bfaf262b..4a3b91ba 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelStringProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelStringProperty.kt @@ -6,6 +6,7 @@ import com.facebook.proguard.annotations.DoNotStrip import com.margelo.nitro.core.Promise import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import com.rive.RiveReactNativeView @Keep @DoNotStrip @@ -35,6 +36,7 @@ class HybridViewModelStringProperty( override fun set(value: String) { instance.setString(path, value) + RiveReactNativeView.onViewModelChanged() } override fun setValueAsync(value: String): Promise { diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelTriggerProperty.kt b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelTriggerProperty.kt index 52f1803b..32039a4e 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridViewModelTriggerProperty.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridViewModelTriggerProperty.kt @@ -3,6 +3,7 @@ package com.margelo.nitro.rive import androidx.annotation.Keep import app.rive.ViewModelInstance import com.facebook.proguard.annotations.DoNotStrip +import com.rive.RiveReactNativeView @Keep @DoNotStrip @@ -14,6 +15,7 @@ class HybridViewModelTriggerProperty( override fun trigger() { instance.fireTrigger(path) + RiveReactNativeView.onViewModelChanged() } override fun addListener(onChanged: () -> Unit): () -> Unit { diff --git a/android/src/new/java/com/rive/RiveReactNativeView.kt b/android/src/new/java/com/rive/RiveReactNativeView.kt index 36d0baf4..e8e5fd34 100644 --- a/android/src/new/java/com/rive/RiveReactNativeView.kt +++ b/android/src/new/java/com/rive/RiveReactNativeView.kt @@ -62,6 +62,16 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { // (e.g. every 4th frame at 120Hz for a 30fps cap) instead of drifting past // it and halving the effective rate. private const val CAP_TOLERANCE_NS = 4_000_000L + + // Main thread only. + private val liveViews = mutableSetOf() + + // A view-model write from JS only reaches the state machine when it + // advances, and a settled one no longer does, so every write wakes all + // live views; each settles again within a few frames. + fun onViewModelChanged() { + UiThreadUtil.runOnUiThread { liveViews.forEach { it.unsettle() } } + } } // Render at most this many frames per second; null = every vsync. @@ -75,11 +85,9 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { private var settledJob: Job? = null - // rive-runtime's command server emits a settle signal on every advance - // whose advanceAndApply returns false, not just on the settle edge — so - // once the state machine is at rest we simply stop advancing it (also - // saves CPU). Re-armed by whatever could actually move the state machine - // again: pointer input, resuming playback, or a data-binding change. + // Once the state machine is at rest we stop advancing it. Re-armed by + // whatever could move it again: pointer input, resuming playback, a + // data-binding change, or a view-model write from JS. @Volatile private var settled = false @@ -106,14 +114,13 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { private var surfaceWidth = 0 private var surfaceHeight = 0 - private var renderLoopRunning = false + private var frameScheduled = false private var disposed = false private var lastFrameTimeNs = 0L private var frameCount = 0L - // While paused the loop still ticks, but only draws when something changed - // (initial content, resize, rebinding) — otherwise a paused view would keep - // re-rendering identical frames at full refresh rate. + // Draw on the next frame even if the state machine doesn't advance: initial + // content, a new or resized surface, rebinding, or becoming visible again. private var needsRedraw = true @Volatile @@ -135,6 +142,8 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { this@RiveReactNativeView.riveSurface = worker.createRiveSurface(SurfaceTextureSurface(st, w, h)) Log.d(TAG, "onSurfaceTextureAvailable: surface created") resizeArtboardIfLayout() + this@RiveReactNativeView.needsRedraw = true + requestFrame() } } } @@ -155,6 +164,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { // RiveSurface.resize() is internal to the SDK, so only the artboard // is resized here (same behavior as before the 11.7.2 bump). resizeArtboardIfLayout() + requestFrame() } override fun onSurfaceTextureUpdated(st: SurfaceTexture) {} @@ -163,17 +173,17 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { init { addView(textureView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + liveViews.add(this) } + // Frames are requested one at a time and only while there is something to + // advance or draw, so a paused, settled or hidden view costs no frame + // callbacks (issue #413). private val renderCallback = object : Choreographer.FrameCallback { override fun doFrame(frameTimeNanos: Long) { - if (!renderLoopRunning || disposed) return - - if (paused && !needsRedraw) { - // Keep the timebase fresh so resuming advances by one frame, not by - // the whole pause span. - lastFrameTimeNs = frameTimeNanos - Choreographer.getInstance().postFrameCallback(this) + frameScheduled = false + if (disposed || !canRender() || !hasWork()) { + lastFrameTimeNs = 0L return } @@ -185,7 +195,16 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { ) { // Skip without touching lastFrameTimeNs: the eventual advance must // cover the full elapsed time so capped playback keeps wall-clock speed. - Choreographer.getInstance().postFrameCallback(this) + requestFrame() + return + } + + val worker = riveWorker + val art = artboardHandle + val sm = stateMachineHandle + val rs = riveSurface + if (worker == null || art == null || sm == null || rs == null) { + lastFrameTimeNs = 0L return } @@ -196,46 +215,87 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { } lastFrameTimeNs = frameTimeNanos - val worker = riveWorker - val art = artboardHandle - val sm = stateMachineHandle - val rs = riveSurface - - if (worker != null && art != null && sm != null && rs != null) { - try { - if (!paused && !settled) { - worker.advanceStateMachine(sm, deltaTime) - } - worker.draw(art, sm, rs, activeFit) - needsRedraw = false - frameCount++ - val isFirstFrame = frameCount == 1L - if (isFirstFrame) { - viewReadyDeferred.complete(true) - } - } catch (e: Exception) { - Log.e(TAG, "Render loop error", e) + try { + if (!paused && !settled) { + worker.advanceStateMachine(sm, deltaTime) } + worker.draw(art, sm, rs, activeFit) + needsRedraw = false + frameCount++ + val isFirstFrame = frameCount == 1L + if (isFirstFrame) { + viewReadyDeferred.complete(true) + } + } catch (e: Exception) { + Log.e(TAG, "Render loop error", e) } - if (!disposed) { - Choreographer.getInstance().postFrameCallback(this) + if (hasWork()) { + requestFrame() + } else { + lastFrameTimeNs = 0L } } } - private fun startRenderLoop() { - if (renderLoopRunning) return - renderLoopRunning = true - lastFrameTimeNs = 0L - updateFrameRateHint() + private fun hasWork() = needsRedraw || (!paused && !settled) + + private fun canRender() = isAttachedToWindow && windowVisibility == View.VISIBLE && isShown + + // Callers may be off-main (play() and settled updates run on coroutines). + private fun requestFrame() { + if (UiThreadUtil.isOnUiThread()) postFrame() else UiThreadUtil.runOnUiThread { postFrame() } + } + + private fun postFrame() { + if (disposed || frameScheduled || !canRender() || !hasWork()) return + frameScheduled = true Choreographer.getInstance().postFrameCallback(renderCallback) } - private fun stopRenderLoop() { - renderLoopRunning = false - updateFrameRateHint() - Choreographer.getInstance().removeFrameCallback(renderCallback) + private fun cancelFrame() { + if (frameScheduled) { + Choreographer.getInstance().removeFrameCallback(renderCallback) + frameScheduled = false + } + lastFrameTimeNs = 0L + } + + private fun onVisibilityMaybeChanged() { + if (canRender()) { + needsRedraw = true + requestFrame() + } else { + cancelFrame() + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + onVisibilityMaybeChanged() + } + + override fun onDetachedFromWindow() { + cancelFrame() + super.onDetachedFromWindow() + } + + override fun onVisibilityAggregated(isVisible: Boolean) { + super.onVisibilityAggregated(isVisible) + onVisibilityMaybeChanged() + } + + override fun onWindowVisibilityChanged(visibility: Int) { + super.onWindowVisibilityChanged(visibility) + onVisibilityMaybeChanged() + } + + private fun unsettle() { + val worker = riveWorker + val sm = stateMachineHandle + if (worker != null && sm != null) StateMachineSettling.unsettle(worker, sm) + settled = false + requestFrame() } // Advisory platform hint (upstream applies the same one inside its Compose @@ -245,7 +305,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { private fun updateFrameRateHint() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return UiThreadUtil.runOnUiThread { - val fps = frameRate?.takeIf { it > 0 && renderLoopRunning && !paused } + val fps = frameRate?.takeIf { it > 0 && !disposed && !paused } textureView.requestedFrameRate = fps?.toFloat() ?: View.REQUESTED_FRAME_RATE_CATEGORY_NO_PREFERENCE } @@ -300,7 +360,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { Log.d(TAG, "configure: artboard=${artboardHandle != null} sm=${stateMachineHandle != null} surface=${riveSurface != null}") paused = !config.autoPlay - startRenderLoop() + updateFrameRateHint() } resizeArtboardIfLayout() @@ -308,6 +368,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { if (dataBindingChanged || initialUpdate || reload) { applyDataBinding(config.bindData, config.riveFile) } + requestFrame() } private fun observeSettled(worker: CommandQueue, handle: StateMachineHandle) { @@ -381,7 +442,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { } // A pointer event may move the state machine off rest; resume advancing // so the render loop actually applies its effect. - settled = false + unsettle() } catch (e: Exception) { Log.e(TAG, "Pointer event failed", e) } @@ -462,7 +523,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { if (worker != null && smHandle != null) { worker.bindViewModelInstance(smHandle, instance.instanceHandle) needsRedraw = true - settled = false + unsettle() } else { Log.w(TAG, "Cannot bind VMI: worker or state machine handle not available") } @@ -470,8 +531,8 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { fun play() { paused = false - settled = false updateFrameRateHint() + unsettle() } fun pause() { @@ -486,8 +547,8 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { fun playIfNeeded() { paused = false - settled = false updateFrameRateHint() + unsettle() } fun setNumberInputValue(name: String, value: Double, path: String?) { @@ -528,7 +589,9 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { settledJob?.cancel() viewScope.cancel() RiveErrorLogger.removeListener(errorListener) - stopRenderLoop() + liveViews.remove(this) + cancelFrame() + updateFrameRateHint() // The command queue is FIFO, so deletes enqueued here run after any // still-pending draw commands that reference these handles. setBoundInstance(null, owns = false) diff --git a/android/src/new/java/com/rive/StateMachineSettling.kt b/android/src/new/java/com/rive/StateMachineSettling.kt new file mode 100644 index 00000000..94e97c65 --- /dev/null +++ b/android/src/new/java/com/rive/StateMachineSettling.kt @@ -0,0 +1,37 @@ +package com.rive + +import android.util.Log +import app.rive.core.CommandQueue +import app.rive.core.StateMachineHandle +import java.lang.reflect.InvocationTargetException + +/** + * `CommandQueue.settledFlow` reports a state machine as settled only once until + * `unsettleStateMachine` re-arms it, which also drops settle signals from + * advances made before the re-arm. That method is Kotlin-internal (upstream + * calls it from its Compose view), so reflection reaches it under its mangled + * JVM name. + */ +internal object StateMachineSettling { + private const val TAG = "StateMachineSettling" + + private val unsettleMethod by lazy { + val method = CommandQueue::class.java.declaredMethods.firstOrNull { + it.name.startsWith("unsettleStateMachine") && + it.parameterTypes.contentEquals(arrayOf(Long::class.javaPrimitiveType)) + } + if (method == null) Log.w(TAG, "unsettleStateMachine not found") + method + } + + /** Returns false when this rive-android has no unsettle entry point. */ + fun unsettle(worker: CommandQueue, handle: StateMachineHandle): Boolean { + val method = unsettleMethod ?: return false + return try { + method.invoke(worker, handle.handle) + true + } catch (e: InvocationTargetException) { + throw e.targetException + } + } +} diff --git a/example/__tests__/settled-property.harness.tsx b/example/__tests__/settled-property.harness.tsx new file mode 100644 index 00000000..a079f6e7 --- /dev/null +++ b/example/__tests__/settled-property.harness.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, render, cleanup } from 'react-native-harness'; +import { View } from 'react-native'; +import { + RiveView, + RiveFileFactory, + Fit, + type ViewModelInstance, +} from '@rive-app/react-native'; + +// Probe { input, output }: input 1 moves Idle -> Hit and 0 moves it back; their +// animations key a shape's x that is bound back into output (10 and 77). Both +// states rest on a held frame, so the state machine settles right after each. +// Source: example/assets/rive-src/settle_probe +const SETTLE_PROBE = require('../assets/rive/settle_probe.riv'); + +function expectDefined(value: T): asserts value is NonNullable { + expect(value).toBeDefined(); +} + +async function waitForNumber( + instance: ViewModelInstance, + name: string, + expected: number, + timeout = 5000 +) { + const prop = instance.numberProperty(name); + expectDefined(prop); + const deadline = Date.now() + timeout; + let actual = await prop.getValueAsync(); + while (actual !== expected && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + actual = await prop.getValueAsync(); + } + return actual; +} + +describe('Settled state machine', () => { + it('applies a view model property set from JS after it has settled', async () => { + // Legacy Android also drops writes made after the state machine settles. + if (RiveFileFactory.getBackend() !== 'experimental') { + return; + } + const file = await RiveFileFactory.fromSource(SETTLE_PROBE, undefined); + const vm = await file.viewModelByNameAsync('Probe'); + expectDefined(vm); + const instance = await vm.createDefaultInstanceAsync(); + expectDefined(instance); + + await render( + + + + ); + + expect(await waitForNumber(instance, 'output', 10)).toBe(10); + // Give the state machine time to report that it settled. + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const input = instance.numberProperty('input'); + expectDefined(input); + await input.setValueAsync(1); + + expect(await waitForNumber(instance, 'output', 77)).toBe(77); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + await input.setValueAsync(0); + + expect(await waitForNumber(instance, 'output', 10)).toBe(10); + + cleanup(); + }); +}); diff --git a/example/assets/rive-src/settle_probe/rive.yaml b/example/assets/rive-src/settle_probe/rive.yaml new file mode 100644 index 00000000..844689a3 --- /dev/null +++ b/example/assets/rive-src/settle_probe/rive.yaml @@ -0,0 +1,6 @@ +# Test fixture authored in RML. Rebuild with `yarn build:settle-probe` +# (needs the Rive CLI: https://rive.app/docs/cli/getting-started). +name: settle_probe +logs: + file: build/rive.log + problems: build/problems.log diff --git a/example/assets/rive-src/settle_probe/scene.rml b/example/assets/rive-src/settle_probe/scene.rml new file mode 100644 index 00000000..cdf0ba39 --- /dev/null +++ b/example/assets/rive-src/settle_probe/scene.rml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/assets/rive/settle_probe.riv b/example/assets/rive/settle_probe.riv new file mode 100644 index 0000000000000000000000000000000000000000..54f331390b15295b94557456c8fff39dbcaf4b24 GIT binary patch literal 687 zcmb7>K}#D!6vzKB^EQcvC`hWc&^8x8fY=bkYTP81gjNt0ud*hCSrT@`xA`~sKY*Y!8zTl4>KxwunR);B-uz~+u=p_|(4-66$tZ*jsQP>W;7Oo+sRhWS#L-Ox{Dp0)h4ArNPtwyRa8 z`iBpGE!~wh@NSG!Tqso~hDHTmCu4X!ts4awB5(U$=U+2Oruyc6%a@56t5~)jnHa&? z7DYZ=>(WztQqfJr{6Yc~I|Qv5X99o4?W}0=72{OkpHN?c&9u&=w&RIB+;O9O+&a|% z2VCP8oXEL;hZ?C#0&1+NU21BV=A*zq?VsNVHXceD^J`Mpy2v!l90lm!XLFSO@LWtc D`;DC; literal 0 HcmV?d00001 diff --git a/example/assets/rive/settle_probe.riv.d.ts b/example/assets/rive/settle_probe.riv.d.ts new file mode 100644 index 00000000..9d8dba14 --- /dev/null +++ b/example/assets/rive/settle_probe.riv.d.ts @@ -0,0 +1,25 @@ +// Generated by rive-gen-types — do not edit manually. @generated +/* eslint-disable */ +// Source: settle_probe.riv +import type { RiveFileSource } from '@rive-app/react-native'; +declare const asset: RiveFileSource<{ + schemaVersion: 1; + artboards: 'Probe' | 'Ticker'; + defaultArtboard: 'Probe'; + stateMachines: { + Probe: 'State Machine 1'; + Ticker: 'State Machine 1'; + }; + enums: {}; + viewModels: { + Probe: { + input: 'number'; + output: 'number'; + }; + Ticker: { + phase: 'number'; + }; + }; + referencedAssets: {}; +}>; +export default asset; diff --git a/example/src/reproducers/Issue413IdleFrames.tsx b/example/src/reproducers/Issue413IdleFrames.tsx new file mode 100644 index 00000000..69e1d7da --- /dev/null +++ b/example/src/reproducers/Issue413IdleFrames.tsx @@ -0,0 +1,228 @@ +import { useEffect, useRef, useState } from 'react'; +import { View, Text, StyleSheet, Pressable } from 'react-native'; +import { + RiveView, + useRiveFile, + useViewModelInstance, + Fit, + type RiveViewRef, + type ViewModelInstance, +} from '@rive-app/react-native'; +import { type Metadata } from '../shared/metadata'; + +/** + * Reproducer for issue #413: the new Android backend kept a Choreographer + * frame callback running every vsync forever, even paused, settled, + * unmounted or backgrounded. + * + * Ticker's `phase` changes on every state machine advance and is only + * delivered while the command queue is polled, so "phase updates/s" is a live + * count of advances: one per display frame while playing, 0 when paused, + * unmounted or in the background. Probe checks that a JS write still reaches + * a settled state machine: "Set input = 1" must turn output from 10 into 77. + * + * Frame callbacks themselves are counted with (app in background): + * adb shell atrace -t 10 -a rive.example view \ + * | grep -c "B|$(adb shell pidof rive.example)|Choreographer#doFrame" + * Broken: one per vsync once any Rive file has loaded. Fixed: 0. + */ + +const SETTLE_PROBE = require('../../assets/rive/settle_probe.riv'); + +function useListenerCount( + instance: ViewModelInstance | null | undefined, + name: string +) { + const count = useRef(0); + const latest = useRef(null); + useEffect(() => { + const prop = instance?.numberProperty(name); + if (!prop) return; + return prop.addListener((value) => { + count.current += 1; + latest.current = value; + }); + }, [instance, name]); + return { count, latest }; +} + +export default function Issue413IdleFrames() { + const { riveFile } = useRiveFile(SETTLE_PROBE); + const { instance: ticker } = useViewModelInstance(riveFile, { + async: true, + artboardName: 'Ticker', + }); + const { instance: probe } = useViewModelInstance(riveFile, { + async: true, + artboardName: 'Probe', + }); + + const [mounted, setMounted] = useState(true); + const [paused, setPaused] = useState(false); + const [sampling, setSampling] = useState(true); + const [rate, setRate] = useState(0); + const [output, setOutput] = useState(null); + const tickerRef = useRef(null); + + const phase = useListenerCount(ticker, 'phase'); + const probeOutput = useListenerCount(probe, 'output'); + + useEffect(() => { + if (!sampling) return; + let last = phase.count.current; + const id = setInterval(() => { + setRate(phase.count.current - last); + last = phase.count.current; + setOutput(probeOutput.latest.current); + }, 1000); + return () => clearInterval(id); + }, [sampling, phase.count, probeOutput.latest]); + + const togglePaused = () => { + if (paused) { + tickerRef.current?.play(); + } else { + tickerRef.current?.pause(); + } + setPaused(!paused); + }; + + const setInput = (value: number) => { + probe?.numberProperty('input')?.set(value); + }; + + return ( + + Idle frame callbacks (#413) + + Pause, unmount or background the app: updates/s should drop to 0. + + + + {paused ? 'Play' : 'Pause'} + + { + setPaused(false); + setMounted((m) => !m); + }} + > + {mounted ? 'Unmount' : 'Mount'} + + setSampling((s) => !s)}> + + {sampling ? 'Stop counter' : 'Start counter'} + + + + + Ticker phase updates/s: {sampling ? rate : '–'} + + + {mounted && riveFile && ticker ? ( + (tickerRef.current = ref) }} + style={styles.rive} + /> + ) : ( + + {mounted ? 'Loading…' : 'Unmounted'} + + )} + + + + setInput(1)}> + Set input = 1 + + setInput(0)}> + Set input = 0 + + + + Probe output: {output ?? '–'} (10 at rest, 77 after input = 1) + + + {mounted && riveFile && probe ? ( + + ) : null} + + + ); +} + +Issue413IdleFrames.metadata = { + name: 'Idle frame callbacks (#413)', + description: + 'Live advance counter for paused, settled, unmounted and backgrounded views', +} satisfies Metadata; + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16, + backgroundColor: '#fff', + }, + title: { + fontSize: 20, + fontWeight: 'bold', + textAlign: 'center', + }, + subtitle: { + fontSize: 14, + color: '#666', + textAlign: 'center', + marginBottom: 12, + }, + buttonRow: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + gap: 8, + marginVertical: 8, + }, + button: { + paddingHorizontal: 14, + paddingVertical: 10, + backgroundColor: '#eee', + borderRadius: 8, + }, + buttonText: { + color: '#333', + fontWeight: '600', + fontSize: 13, + }, + stat: { + textAlign: 'center', + fontSize: 15, + fontWeight: '600', + marginVertical: 4, + }, + riveContainer: { + flex: 1, + backgroundColor: '#f5f5f5', + borderRadius: 8, + overflow: 'hidden', + justifyContent: 'center', + }, + rive: { + flex: 1, + }, + loading: { + textAlign: 'center', + color: '#666', + }, +}); From 8ee92005010ce53fd8296d5ef5f31921a0d96a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 29 Oct 2026 06:43:22 +0100 Subject: [PATCH 2/4] fix(android): read settled state from the worker so stale settles can't cancel a wake --- .../new/java/com/rive/RiveReactNativeView.kt | 34 +++++------------- .../new/java/com/rive/StateMachineSettling.kt | 35 ++++++++++++++----- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/android/src/new/java/com/rive/RiveReactNativeView.kt b/android/src/new/java/com/rive/RiveReactNativeView.kt index e8e5fd34..255a318b 100644 --- a/android/src/new/java/com/rive/RiveReactNativeView.kt +++ b/android/src/new/java/com/rive/RiveReactNativeView.kt @@ -26,9 +26,9 @@ import com.margelo.nitro.rive.RiveLog import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration @@ -83,13 +83,14 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { var onError: ((String) -> Unit)? = null - private var settledJob: Job? = null - // Once the state machine is at rest we stop advancing it. Re-armed by // whatever could move it again: pointer input, resuming playback, a - // data-binding change, or a view-model write from JS. - @Volatile - private var settled = false + // data-binding change, or a view-model write from JS. The worker drops + // settle callbacks from advances made before the latest unsettle, so a + // stale one cannot cancel a newer wake. + private var settledState: StateFlow? = null + + private val settled get() = settledState?.value ?: false private val errorListener: (String) -> Unit = { msg -> onError?.invoke(msg) @@ -242,7 +243,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { private fun canRender() = isAttachedToWindow && windowVisibility == View.VISIBLE && isShown - // Callers may be off-main (play() and settled updates run on coroutines). + // Callers may be off-main (play() runs on a coroutine). private fun requestFrame() { if (UiThreadUtil.isOnUiThread()) postFrame() else UiThreadUtil.runOnUiThread { postFrame() } } @@ -294,7 +295,6 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { val worker = riveWorker val sm = stateMachineHandle if (worker != null && sm != null) StateMachineSettling.unsettle(worker, sm) - settled = false requestFrame() } @@ -349,7 +349,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { config.riveWorker.createDefaultStateMachine(newArtboard.artboardHandle) } stateMachineHandle = newStateMachineHandle - observeSettled(config.riveWorker, newStateMachineHandle) + settledState = StateMachineSettling.settledState(config.riveWorker, newStateMachineHandle) if (surfaceTexture != null && riveSurface == null) { riveSurface = config.riveWorker.createRiveSurface( @@ -371,21 +371,6 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { requestFrame() } - private fun observeSettled(worker: CommandQueue, handle: StateMachineHandle) { - settled = false - settledJob?.cancel() - settledJob = viewScope.launch { - // Deprecated in rive-android >= 11.8 (removal planned for 12.0), kept as a - // compatibility shim. Revisit when 12.0 defines the long-term settling surface. - @Suppress("DEPRECATION") - worker.settledFlow.collect { settledHandle -> - if (settledHandle == handle) { - settled = true - } - } - } - } - private fun resizeArtboardIfLayout() { val fit = activeFit if (fit is Fit.Layout) { @@ -586,7 +571,6 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { if (disposed) return disposed = true viewReadyDeferred.complete(false) - settledJob?.cancel() viewScope.cancel() RiveErrorLogger.removeListener(errorListener) liveViews.remove(this) diff --git a/android/src/new/java/com/rive/StateMachineSettling.kt b/android/src/new/java/com/rive/StateMachineSettling.kt index 94e97c65..c944e11c 100644 --- a/android/src/new/java/com/rive/StateMachineSettling.kt +++ b/android/src/new/java/com/rive/StateMachineSettling.kt @@ -3,25 +3,42 @@ package com.rive import android.util.Log import app.rive.core.CommandQueue import app.rive.core.StateMachineHandle +import kotlinx.coroutines.flow.StateFlow import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method /** - * `CommandQueue.settledFlow` reports a state machine as settled only once until - * `unsettleStateMachine` re-arms it, which also drops settle signals from - * advances made before the re-arm. That method is Kotlin-internal (upstream - * calls it from its Compose view), so reflection reaches it under its mangled - * JVM name. + * The worker keeps a per-machine settled state that `unsettleStateMachine` + * resets, which also drops settle signals from advances made before the + * reset. Both members are Kotlin-internal (upstream's Compose view uses them), + * so reflection reaches them under their mangled JVM names. */ internal object StateMachineSettling { private const val TAG = "StateMachineSettling" - private val unsettleMethod by lazy { + private val unsettleMethod by lazy { findHandleMethod("unsettleStateMachine") } + + private val settledMethod by lazy { findHandleMethod("stateMachineSettled") } + + private fun findHandleMethod(prefix: String): Method? { val method = CommandQueue::class.java.declaredMethods.firstOrNull { - it.name.startsWith("unsettleStateMachine") && + it.name.startsWith(prefix) && it.parameterTypes.contentEquals(arrayOf(Long::class.javaPrimitiveType)) } - if (method == null) Log.w(TAG, "unsettleStateMachine not found") - method + if (method == null) Log.w(TAG, "$prefix not found") + return method + } + + /** Returns null when this rive-android has no settled state, so the view never settles. */ + @Suppress("UNCHECKED_CAST") + fun settledState(worker: CommandQueue, handle: StateMachineHandle): StateFlow? { + val method = settledMethod ?: return null + return try { + method.invoke(worker, handle.handle) as? StateFlow + } catch (e: InvocationTargetException) { + Log.w(TAG, "stateMachineSettled failed", e.targetException) + null + } } /** Returns false when this rive-android has no unsettle entry point. */ From d6dc6a0a5e09e45deb5b6900d7fad3bdd98055ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 29 Oct 2026 06:54:36 +0100 Subject: [PATCH 3/4] fix(android): wake the state machine on the UI thread --- android/src/new/java/com/rive/RiveReactNativeView.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/android/src/new/java/com/rive/RiveReactNativeView.kt b/android/src/new/java/com/rive/RiveReactNativeView.kt index 255a318b..c35de822 100644 --- a/android/src/new/java/com/rive/RiveReactNativeView.kt +++ b/android/src/new/java/com/rive/RiveReactNativeView.kt @@ -243,7 +243,6 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { private fun canRender() = isAttachedToWindow && windowVisibility == View.VISIBLE && isShown - // Callers may be off-main (play() runs on a coroutine). private fun requestFrame() { if (UiThreadUtil.isOnUiThread()) postFrame() else UiThreadUtil.runOnUiThread { postFrame() } } @@ -291,11 +290,17 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { onVisibilityMaybeChanged() } + // On the UI thread so the handle can't be deleted by a reload or dispose mid-call. private fun unsettle() { + if (!UiThreadUtil.isOnUiThread()) { + UiThreadUtil.runOnUiThread { unsettle() } + return + } + if (disposed) return val worker = riveWorker val sm = stateMachineHandle if (worker != null && sm != null) StateMachineSettling.unsettle(worker, sm) - requestFrame() + postFrame() } // Advisory platform hint (upstream applies the same one inside its Compose From 3cb01d0788fb028cafc206fc2e232f08a0b1476f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 29 Oct 2026 07:02:40 +0100 Subject: [PATCH 4/4] fix(android): keep polling while a paused activity is still visible --- .../nitro/rive/HybridRiveFileFactory.kt | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridRiveFileFactory.kt b/android/src/new/java/com/margelo/nitro/rive/HybridRiveFileFactory.kt index 694dedbc..eb5020d8 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridRiveFileFactory.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridRiveFileFactory.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.util.Log import androidx.annotation.Keep import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.ProcessLifecycleOwner @@ -103,8 +104,8 @@ class HybridRiveFileFactory : HybridRiveFileFactorySpec() { * The experimental Rive SDK's CommandQueue needs to be polled every frame * to process responses from the C++ command server. Without polling, * all suspend functions (like RiveFile.fromSource) hang indefinitely. - * Polls only while the app is resumed, like rive-android's own hosts, so a - * backgrounded app gets no frame callbacks (issue #413). + * Polls only while the app is visible, so a backgrounded app gets no frame + * callbacks (issue #413). */ private fun startPolling(worker: CommandQueue) { if (pollingStarted) return @@ -116,7 +117,7 @@ class HybridRiveFileFactory : HybridRiveFileFactorySpec() { val lifecycle = if (processLifecycle.currentState == Lifecycle.State.INITIALIZED) { AlwaysResumed.lifecycle } else { - processLifecycle + VisibleProcess.follow(processLifecycle) } while (true) { try { @@ -136,6 +137,25 @@ class HybridRiveFileFactory : HybridRiveFileFactorySpec() { override val lifecycle = LifecycleRegistry(this).apply { currentState = Lifecycle.State.RESUMED } } + // beginPolling() only polls while RESUMED, but a paused activity can still be + // visible and rendering (picture-in-picture), so report STARTED as RESUMED. + private object VisibleProcess : LifecycleOwner { + override val lifecycle = LifecycleRegistry(this) + + fun follow(process: Lifecycle): Lifecycle { + process.addObserver( + LifecycleEventObserver { _, _ -> + lifecycle.currentState = if (process.currentState.isAtLeast(Lifecycle.State.STARTED)) { + Lifecycle.State.RESUMED + } else { + Lifecycle.State.CREATED + } + } + ) + return lifecycle + } + } + private suspend fun buildRiveFile( data: ByteArray, referencedAssets: ReferencedAssetsType?