Skip to content

fix(android): stop idle frame callbacks on the new backend - #416

Open
mfazekas wants to merge 4 commits into
mainfrom
fix/android-idle-frame-loops
Open

mfazekas wants to merge 4 commits into
mainfrom
fix/android-idle-frame-loops

Conversation

@mfazekas

@mfazekas mfazekas commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #413. On the new Android 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 kept waking on every frame even when views were paused, settled, unmounted or the app was backgrounded.

  1. The view now requests one frame at a time, only while it is visible and has something to advance or draw, mirroring how rive-android's Rive composable suspends while settled. Play, configure, rebind, surface changes, pointer input and visibility changes wake it. Each wake re-arms rive-android's settle signal (unsettleStateMachine is internal, so it is reached by reflection, like DeferredRiveWorker), which otherwise fires only once per state machine.
  2. Polling uses rive-android's CommandQueue.beginPolling bound to ProcessLifecycleOwner, so it stops while the app is in the background. A JS call made while backgrounded resolves when the app resumes.
  3. 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. Upstream's composable uses ViewModelInstance.dirtyFlow for this, but that only covers the bound instance, not nested (viewModel(path)) or list item instances, so the setters wake the views instead. settled-property.harness.tsx covers it and fails without this change; legacy Android has the same bug, so the test is skipped there.

Measured on an emulator release build with the app backgrounded: Choreographer#doFrame went from ~600 per 10 s (with a Rive view mounted, or after one had been) to 0. A settled view renders no frames, also after JS writes to it. The example app has a new "Idle frame callbacks (#413)" page with pause/unmount controls and a live update counter.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A stale settlement notification can overwrite a newer JS wake, causing hasWork() to return false and leave writes or triggers unapplied.

1 open issue
What changed in this PR

Addresses #413 by reducing idle frame callbacks in the new Android backend and waking settled views when JavaScript changes view-model data.

Changes:

  • Schedules rendering only when visible views have work, with support for re-arming settlement detection.
  • Ties command-queue polling to the app lifecycle and wakes views after property writes.
  • Adds a regression harness, Rive fixture sources, and an interactive idle-frame reproducer.
File Description
example/​src/​reproducers/​Issue413IdleFrames.tsx Adds pause/unmount controls and live update counters.
example/​assets/​rive/​settle_probe.riv.d.ts Defines generated types for the probe fixture.
example/​assets/​rive-src/​settle_probe/​scene.rml Defines settled-probe and continuously animated artboards.
example/​assets/​rive-src/​settle_probe/​rive.yaml Configures the fixture build.
example/​__tests__/​settled-property.harness.tsx Tests JS property writes after settlement.
android/​src/​new/​java/​com/​rive/​StateMachineSettling.kt Adds reflective access to the SDK's re-arm method.
android/​src/​new/​java/​com/​rive/​RiveReactNativeView.kt Replaces continuous rendering with work-driven frame requests.
android/​src/​new/​java/​com/​margelo/​nitro/​rive/​HybridViewModelTriggerProperty.kt Wakes views after trigger events.
android/​src/​new/​java/​com/​margelo/​nitro/​rive/​HybridViewModelStringProperty.kt Wakes views after string writes.
android/​src/​new/​java/​com/​margelo/​nitro/​rive/​HybridViewModelNumberProperty.kt Wakes views after number writes.
android/​src/​new/​java/​com/​margelo/​nitro/​rive/​HybridViewModelListProperty.kt Wakes views after list mutations.
android/​src/​new/​java/​com/​margelo/​nitro/​rive/​HybridViewModelImageProperty.kt Wakes views after image assignment.
android/​src/​new/​java/​com/​margelo/​nitro/​rive/​HybridViewModelEnumProperty.kt Wakes views after enum writes.
android/​src/​new/​java/​com/​margelo/​nitro/​rive/​HybridViewModelColorProperty.kt Wakes views after color writes.
android/​src/​new/​java/​com/​margelo/​nitro/​rive/​HybridViewModelBooleanProperty.kt Wakes views after boolean writes.
android/​src/​new/​java/​com/​margelo/​nitro/​rive/​HybridViewModelArtboardProperty.kt Wakes views after artboard assignment.
android/​src/​new/​java/​com/​margelo/​nitro/​rive/​HybridRiveFileFactory.kt Makes queue polling lifecycle-aware.
android/​build.gradle Adds the process-lifecycle dependency.

Review effort: Balanced

Comment thread android/src/new/java/com/rive/RiveReactNativeView.kt Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A critical state-machine wake race can use a deleted handle, and mismatched rendering and polling lifecycle boundaries can leave views drawing without callbacks.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

Medium severity Align rendering with polling lifecycle boundary

android/​src/​new/​java/​com/​margelo/​nitro/​rive/​HybridRiveFileFactory.kt:123

beginPolling() stops outside RESUMED, but canRender() still allows a visible, paused activity to render, such as one behind another app's translucent activity. This leaves views advancing and drawing without delivery of property listeners, async-read results or settlement notifications. Align rendering and polling to the same lifecycle boundary, and wake views when that lifecycle becomes active again.

1 resolved since last review

Review effort: Balanced

Comment thread android/src/new/java/com/rive/RiveReactNativeView.kt

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Rendering can continue in a paused but visible activity after lifecycle-based polling stops, leaving settlement signals and JS responses unconsumed and preventing the view from settling.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Gate rendering to the lifecycle used by the poller

android/​src/​new/​java/​com/​rive/​RiveReactNativeView.kt:244

A paused activity can remain visible in picture-in-picture or split-screen, so this check allows rendering after the ProcessLifecycleOwner-based poller in HybridRiveFileFactory.kt:123 stops. Advances and draws continue, but settlement signals and JS responses are not consumed, preventing the view from settling. Gate rendering on the same lifecycle as polling and wake it on resume, or keep polling active while a visible host is rendering.

1 resolved since last review

Review effort: Balanced

@mfazekas

Copy link
Copy Markdown
Collaborator Author

Re the previously missed issue in Copilot's last review (polling gated on RESUMED while a paused-but-visible activity keeps rendering): fixed in 3cb01d0. The poller now follows a lifecycle that reports RESUMED whenever the process is at least STARTED, so polling continues for picture-in-picture hosts and still stops once the app is backgrounded. Checked on an emulator: the app gets 0 frame callbacks while backgrounded, and polling and JS writes work again after it returns to the foreground.

@mfazekas
mfazekas requested a balanced review from Copilot October 29, 2026 06:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed to determine how the missing fixture-build command affects reproducibility of the new regression test and reproducer.

No open issues 🎉

Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rive drains battery in the background on Android

2 participants