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..eb5020d8 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,13 @@ 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.LifecycleEventObserver +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 +15,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 +104,58 @@ 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 visible, 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 { + VisibleProcess.follow(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 } + } + + // 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? 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..c35de822 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 @@ -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. @@ -73,15 +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. 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 - // 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. - @Volatile - private var settled = false + private val settled get() = settledState?.value ?: false private val errorListener: (String) -> Unit = { msg -> onError?.invoke(msg) @@ -106,14 +115,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 +143,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 +165,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 +174,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 +196,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 +216,91 @@ 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 + + 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() + } + + // 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) + postFrame() } // Advisory platform hint (upstream applies the same one inside its Compose @@ -245,7 +310,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 } @@ -289,7 +354,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( @@ -300,7 +365,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,21 +373,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { if (dataBindingChanged || initialUpdate || reload) { applyDataBinding(config.bindData, config.riveFile) } - } - - 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 - } - } - } + requestFrame() } private fun resizeArtboardIfLayout() { @@ -381,7 +432,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 +513,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 +521,8 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { fun play() { paused = false - settled = false updateFrameRateHint() + unsettle() } fun pause() { @@ -486,8 +537,8 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { fun playIfNeeded() { paused = false - settled = false updateFrameRateHint() + unsettle() } fun setNumberInputValue(name: String, value: Double, path: String?) { @@ -525,10 +576,11 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { if (disposed) return disposed = true viewReadyDeferred.complete(false) - 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..c944e11c --- /dev/null +++ b/android/src/new/java/com/rive/StateMachineSettling.kt @@ -0,0 +1,54 @@ +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 + +/** + * 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 { 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(prefix) && + it.parameterTypes.contentEquals(arrayOf(Long::class.javaPrimitiveType)) + } + 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. */ + 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 00000000..54f33139 Binary files /dev/null and b/example/assets/rive/settle_probe.riv differ 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', + }, +});