Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
31 changes: 29 additions & 2 deletions android/src/legacy/java/com/margelo/nitro/rive/HybridRiveImage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T> 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()
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -12,7 +13,13 @@ class HybridRiveImageFactory : HybridRiveImageFactorySpec() {
private fun loadFromDataSource(source: DataSource): Promise<HybridRiveImageSpec> = 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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit> {
Expand Down
25 changes: 18 additions & 7 deletions android/src/legacy/java/com/rive/RiveReactNativeView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
62 changes: 62 additions & 0 deletions example/__tests__/image-dispose.harness.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Binary file added example/assets/images/leak_a.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added example/assets/images/leak_b.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading