diff --git a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveFile.kt b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveFile.kt index 7f91d70a..38d6afa4 100644 --- a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveFile.kt +++ b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveFile.kt @@ -165,8 +165,11 @@ class HybridRiveFile : HybridRiveFileSpec() { weakViews.clear() assetLoader?.dispose() assetLoader = null - riveFile?.release() + // Release on main, where the asset loader sets decoded assets, so the File + // isn't freed under a set already in progress. + val file = riveFile riveFile = null + riveMainScope.launch { file?.release() } referencedAssetCache?.clear() referencedAssetCache = null } diff --git a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveImage.kt b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveImage.kt index 8a4d73e5..1f3e8c26 100644 --- a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveImage.kt +++ b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveImage.kt @@ -7,9 +7,36 @@ import com.facebook.proguard.annotations.DoNotStrip @Keep @DoNotStrip class HybridRiveImage( - val renderImage: RiveRenderImage, - private val dataSize: Int + renderImage: RiveRenderImage, + private val dataSize: Int, + private val decodedSize: Long ) : HybridRiveImageSpec() { + // Consumers (view model image properties, image assets) take their own + // native ref, so releasing ours only frees the image once nothing shows it. + internal var renderImage: RiveRenderImage? = renderImage + private set + override val byteSize: Double get() = dataSize.toDouble() + + // The decoded pixels live outside the JS heap; without this hint Hermes + // collects dropped images too rarely to bound native/GPU memory. + override val memorySize: Long + get() = decodedSize + + /** Runs [block] with the image held so a concurrent [dispose] can't release it mid-use. */ + internal fun withRenderImage(block: (RiveRenderImage) -> T): T? = + synchronized(this) { renderImage?.let(block) } + + override fun dispose() { + synchronized(this) { + renderImage?.release() + renderImage = null + } + super.dispose() + } + + protected fun finalize() { + dispose() + } } diff --git a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveImageFactory.kt b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveImageFactory.kt index 5fc94b05..6f7e9fcf 100644 --- a/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveImageFactory.kt +++ b/android/src/legacy/java/com/margelo/nitro/rive/HybridRiveImageFactory.kt @@ -1,5 +1,6 @@ package com.margelo.nitro.rive +import android.graphics.BitmapFactory import androidx.annotation.Keep import app.rive.runtime.kotlin.core.RiveRenderImage import com.facebook.proguard.annotations.DoNotStrip @@ -12,7 +13,13 @@ class HybridRiveImageFactory : HybridRiveImageFactorySpec() { private fun loadFromDataSource(source: DataSource): Promise = Promise.async { val imageData = source.createLoader().load(source) val renderImage = RiveRenderImage.fromEncoded(imageData) - HybridRiveImage(renderImage, imageData.size) + HybridRiveImage(renderImage, imageData.size, decodedByteSize(imageData)) + } + + private fun decodedByteSize(imageData: ByteArray): Long { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(imageData, 0, imageData.size, bounds) + return maxOf(bounds.outWidth.toLong() * bounds.outHeight * 4, imageData.size.toLong()) } override fun loadFromURLAsync(url: String) = loadFromDataSource(DataSource.fromURL(url)) diff --git a/android/src/legacy/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt b/android/src/legacy/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt index 3af01fbf..91cca82b 100644 --- a/android/src/legacy/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt +++ b/android/src/legacy/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt @@ -19,7 +19,13 @@ class HybridViewModelImageProperty(private val viewModelImage: ViewModelImagePro get() = VIEW_MODEL_HYBRID_MEMORY_SIZE override fun set(image: HybridRiveImageSpec?) { - viewModelImage.set((image as? HybridRiveImage)?.renderImage) + val img = image as? HybridRiveImage + if (img == null) { + viewModelImage.set(null) + return + } + img.withRenderImage { viewModelImage.set(it) } + ?: throw IllegalStateException("RiveImage has been disposed") } override fun addListener(onChanged: () -> Unit): () -> Unit { diff --git a/android/src/legacy/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt b/android/src/legacy/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt index a6eca014..b79ab704 100644 --- a/android/src/legacy/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt +++ b/android/src/legacy/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt @@ -15,17 +15,16 @@ class ReferencedAssetLoader { } private fun processAssetBytes(bytes: ByteArray, asset: FileAsset) { - when (asset) { - is ImageAsset -> asset.image = RiveRenderImage.fromEncoded(bytes) - is FontAsset -> asset.font = RiveFont.make(bytes) - is AudioAsset -> asset.audio = RiveAudio.make(bytes) + // decode() hands the asset the only ref; assigning a RiveRenderImage / + // RiveFont / RiveAudio here would leak the wrapper's own ref. + if (!asset.decode(bytes)) { + logError("Failed to decode asset: ${asset.name}") } } private fun handlePreloadedImage(image: HybridRiveImageSpec, asset: FileAsset) { - if (asset is ImageAsset && image is HybridRiveImage) { - asset.image = image.renderImage - } + if (asset !is ImageAsset || image !is HybridRiveImage) return + image.withRenderImage { asset.image = it } } private fun loadAsset(assetData: ResolvedReferencedAsset, asset: FileAsset): Deferred { diff --git a/android/src/legacy/java/com/rive/RiveReactNativeView.kt b/android/src/legacy/java/com/rive/RiveReactNativeView.kt index c07af64e..0491f18d 100644 --- a/android/src/legacy/java/com/rive/RiveReactNativeView.kt +++ b/android/src/legacy/java/com/rive/RiveReactNativeView.kt @@ -9,6 +9,7 @@ import app.rive.runtime.kotlin.RiveAnimationView import app.rive.runtime.kotlin.RiveViewLifecycleObserver import app.rive.runtime.kotlin.controllers.RiveFileController import app.rive.runtime.kotlin.core.RefCount +import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.uimanager.ThemedReactContext import app.rive.runtime.kotlin.core.Alignment import app.rive.runtime.kotlin.core.File @@ -97,6 +98,7 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { private var _activeStateMachineName: String? = null private var configuredStateMachineName: String? = null private var willDispose = false + private var released = false init { riveAnimationView = ReactNativeRiveAnimationView(context) @@ -105,19 +107,28 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { fun dispose() { willDispose = true + // Fabric can detach the view before dispose() arrives; onDetachedFromWindow + // won't run again then, and the controller would pin its File forever. + UiThreadUtil.runOnUiThread { + if (!isAttachedToWindow) releaseResources() + } } override fun onDetachedFromWindow() { - if (willDispose) { - riveAnimationView?.dispose() - removeEventListeners() - // Post-dispose reads must resolve null, not the retained (released) - // instance; also stops pinning it for the view's remaining lifetime. - lastKnownViewModelInstance = null - } + if (willDispose) releaseResources() super.onDetachedFromWindow() } + private fun releaseResources() { + if (released) return + released = true + riveAnimationView?.dispose() + removeEventListeners() + // Post-dispose reads must resolve null, not the retained (released) + // instance; also stops pinning it for the view's remaining lifetime. + lastKnownViewModelInstance = null + } + //region Public Methods (API) suspend fun awaitViewReady(): Boolean { return viewReadyDeferred.await() diff --git a/example/__tests__/image-dispose.harness.ts b/example/__tests__/image-dispose.harness.ts new file mode 100644 index 00000000..1621a76a --- /dev/null +++ b/example/__tests__/image-dispose.harness.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'react-native-harness'; +import { Platform } from 'react-native'; +import { RiveFileFactory, RiveImages } from '@rive-app/react-native'; + +const DATABINDING_IMAGES = require('../assets/rive/databinding_images.riv'); + +// 1x1 red PNG +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC'; + +function pngBytes(): ArrayBuffer { + const binary = atob(PNG_BASE64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; +} + +async function loadImageProperty() { + const file = await RiveFileFactory.fromSource(DATABINDING_IMAGES, undefined); + const instance = file + .viewModelByName('MyViewModel') + ?.createInstanceByIndex(0); + const prop = instance?.imageProperty('bound_image'); + expect(prop).toBeDefined(); + return { file, instance: instance!, prop: prop! }; +} + +describe('RiveImage disposal', () => { + it('disposing a bound image leaves the property usable', async () => { + const { file, instance, prop } = await loadImageProperty(); + const first = await RiveImages.loadFromBytesAsync(pngBytes()); + prop.set(first); + first.dispose(); + + const second = await RiveImages.loadFromBytesAsync(pngBytes()); + prop.set(second); + second.dispose(); + // The new iOS runtime can't clear image properties yet (#360) + if ( + Platform.OS !== 'ios' || + RiveFileFactory.getBackend() !== 'experimental' + ) { + prop.set(undefined); + } + + prop.dispose(); + instance.dispose(); + file.dispose(); + }); + + it('swapping many images with dispose does not throw', async () => { + const { file, instance, prop } = await loadImageProperty(); + for (let i = 0; i < 20; i++) { + const image = await RiveImages.loadFromBytesAsync(pngBytes()); + prop.set(image); + image.dispose(); + } + prop.dispose(); + instance.dispose(); + file.dispose(); + }); +}); diff --git a/example/assets/images/leak_a.png b/example/assets/images/leak_a.png new file mode 100644 index 00000000..244cdfaf Binary files /dev/null and b/example/assets/images/leak_a.png differ diff --git a/example/assets/images/leak_b.png b/example/assets/images/leak_b.png new file mode 100644 index 00000000..8d660337 Binary files /dev/null and b/example/assets/images/leak_b.png differ diff --git a/example/src/reproducers/AndroidMemoryLeak.tsx b/example/src/reproducers/AndroidMemoryLeak.tsx new file mode 100644 index 00000000..3ba84aae --- /dev/null +++ b/example/src/reproducers/AndroidMemoryLeak.tsx @@ -0,0 +1,370 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + View, + Text, + StyleSheet, + Pressable, + Image, + ScrollView, +} from 'react-native'; +import { + RiveView, + RiveImages, + useRiveFile, + useViewModelInstance, + Fit, + type RiveFile, + type RiveImage, + type RiveViewRef, + type ViewModelImageProperty, +} from '@rive-app/react-native'; +import { type Metadata } from '../shared/metadata'; + +/** + * Reproducer for the Android memory growth reported against 0.4.11 with + * rive-android 11.12.1: + * (a) mounting/unmounting a RiveView never gives a chunk of memory back + * (b) swapping a data-bound image never frees the previous image + * + * The test images are 2048x2048, so each leaked image is ~21 MB of texture + * memory. On a device that shows up in `adb shell dumpsys meminfo rive.example` + * (Graphics / GL mtrack); on an emulator the textures live in the host process, + * so sample `footprint -p ` (IOAccelerator) instead. + * Broken: memory grows with the counters. Fixed: it plateaus. + */ + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const IMAGE_URIS = [ + Image.resolveAssetSource(require('../../assets/images/leak_a.png')).uri, + Image.resolveAssetSource(require('../../assets/images/leak_b.png')).uri, +]; + +const SCENARIOS = [ + 'mount', + 'mount-file', + 'mount-image', + 'mount-image-noview', + 'mount-image-clear', + 'mount-loadonly', + 'swap-fresh', + 'swap-reuse', +] as const; +type Scenario = (typeof SCENARIOS)[number]; +const isMountScenario = (s: Scenario) => s.startsWith('mount'); + +declare const HermesInternal: + | { getInstrumentedStats?: () => Record } + | undefined; + +function jsHeapMB(): string { + const stats = HermesInternal?.getInstrumentedStats?.(); + const bytes = stats?.js_heapSize ?? stats?.js_allocatedBytes; + return bytes ? (bytes / 1024 / 1024).toFixed(1) : 'n/a'; +} + +function forceGC() { + (globalThis as { gc?: () => void }).gc?.(); +} + +export default function AndroidMemoryLeak() { + const { riveFile } = useRiveFile( + require('../../assets/rive/databinding_images.riv') + ); + const [scenario, setScenario] = useState('mount'); + const [running, setRunning] = useState(false); + const [disposeOld, setDisposeOld] = useState(true); + const [mounted, setMounted] = useState(true); + const [mountCycles, setMountCycles] = useState(0); + const [swaps, setSwaps] = useState(0); + const [heap, setHeap] = useState(jsHeapMB()); + const runningRef = useRef(false); + const onSwap = useCallback(() => setSwaps((c) => c + 1), []); + + useEffect(() => { + runningRef.current = running; + }, [running]); + + useEffect(() => { + const id = setInterval(() => setHeap(jsHeapMB()), 1000); + return () => clearInterval(id); + }, []); + + useEffect(() => { + if (!running || !isMountScenario(scenario)) return; + let cancelled = false; + (async () => { + while (runningRef.current && !cancelled) { + setMounted(false); + await sleep(150); + setMounted(true); + setMountCycles((c) => c + 1); + await sleep(250); + if (Math.random() < 0.1) forceGC(); + } + })(); + return () => { + cancelled = true; + setMounted(true); + }; + }, [running, scenario]); + + return ( + + Android Memory Leak + + (a) RiveView mount/unmount cycles, (b) data-bound image swaps. Sample + `dumpsys meminfo rive.example` while running. + + + {SCENARIOS.map((s) => ( + { + setRunning(false); + setScenario(s); + }} + > + {s} + + ))} + + + setRunning((r) => !r)} + > + {running ? 'Stop' : 'Start'} + + setDisposeOld((d) => !d)} + > + + dispose old image: {disposeOld ? 'on' : 'off'} + + + + GC + + + + + {running ? 'running' : 'stopped'} | scenario: {scenario} | mount + cycles: {mountCycles} | image swaps: {swaps} | JS heap: {heap} MB + + + + {riveFile && mounted && scenario === 'mount' && ( + + )} + {mounted && scenario !== 'mount' && isMountScenario(scenario) && ( + + )} + {riveFile && !isMountScenario(scenario) && ( + + )} + + + ); +} + +// Owns everything a typical screen would: its own file, view model instance +// and (optionally) a freshly loaded image, all torn down on unmount. +function MountedScene({ + withImage, + bindImage, + withView, + clearOnUnmount, +}: { + withImage: boolean; + bindImage: boolean; + withView: boolean; + clearOnUnmount: boolean; +}) { + const { riveFile } = useRiveFile( + require('../../assets/rive/databinding_images.riv') + ); + const { instance } = useViewModelInstance(riveFile, { async: true }); + + useEffect(() => { + if (!instance || !withImage) return; + let cancelled = false; + let image: RiveImage | undefined; + const prop = instance.imageProperty('bound_image'); + RiveImages.loadFromURLAsync(IMAGE_URIS[0]!).then((img) => { + image = img; + if (!cancelled && bindImage) prop?.set(img); + }); + return () => { + cancelled = true; + if (clearOnUnmount) prop?.set(undefined); + prop?.dispose(); + image?.dispose(); + }; + }, [instance, withImage, bindImage, clearOnUnmount]); + + if (!riveFile || !instance || !withView) return null; + return ( + + ); +} + +function ImageSwapper({ + file, + running, + reuse, + disposeOld, + onSwap, +}: { + file: RiveFile; + running: boolean; + reuse: boolean; + disposeOld: boolean; + onSwap: () => void; +}) { + const { instance } = useViewModelInstance(file, { async: true }); + const propRef = useRef(undefined); + const viewRef = useRef(null); + + useEffect(() => { + if (!instance) return; + const prop = instance.imageProperty('bound_image'); + propRef.current = prop; + return () => { + propRef.current = undefined; + prop?.dispose(); + }; + }, [instance]); + + useEffect(() => { + if (!running || !instance) return; + let cancelled = false; + (async () => { + const preloaded = reuse + ? await Promise.all( + IMAGE_URIS.map((u) => RiveImages.loadFromURLAsync(u)) + ) + : []; + let previous: RiveImage | undefined; + let i = 0; + while (!cancelled) { + const next = reuse + ? preloaded[i % 2]! + : await RiveImages.loadFromURLAsync(IMAGE_URIS[i % 2]!); + if (cancelled) break; + propRef.current?.set(next); + // A settled state machine is not woken by an image change on the new + // backend, so nudge it to render the swap. + viewRef.current?.playIfNeeded(); + if (!reuse && disposeOld) previous?.dispose(); + previous = next; + i++; + onSwap(); + await sleep(200); + if (i % 10 === 0) forceGC(); + } + })(); + return () => { + cancelled = true; + }; + }, [running, instance, reuse, disposeOld, onSwap]); + + if (!instance) return null; + return ( + (viewRef.current = ref) }} + fit={Fit.Contain} + style={styles.rive} + /> + ); +} + +AndroidMemoryLeak.metadata = { + name: 'Android Memory Leak', + description: + 'RiveView mount/unmount and data-bound image swaps must not grow native memory', +} satisfies Metadata; + +const styles = StyleSheet.create({ + container: { + padding: 16, + backgroundColor: '#fff', + }, + title: { + fontSize: 20, + fontWeight: 'bold', + textAlign: 'center', + }, + subtitle: { + fontSize: 14, + color: '#666', + textAlign: 'center', + marginBottom: 16, + }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + gap: 8, + marginBottom: 12, + }, + button: { + paddingHorizontal: 12, + paddingVertical: 10, + backgroundColor: '#8E8E93', + borderRadius: 8, + }, + buttonActive: { + backgroundColor: '#007AFF', + }, + startButton: { + backgroundColor: '#34C759', + }, + buttonText: { + color: '#fff', + fontWeight: '600', + fontSize: 14, + }, + statusBox: { + backgroundColor: '#f0f0f0', + padding: 12, + borderRadius: 8, + marginBottom: 16, + }, + statusText: { + fontSize: 13, + color: '#333', + }, + riveContainer: { + height: 360, + backgroundColor: '#f5f5f5', + borderRadius: 8, + overflow: 'hidden', + }, + rive: { + flex: 1, + }, +});