react-native-skia icon indicating copy to clipboard operation
react-native-skia copied to clipboard

[iOS] Image re-rendering with potential memory leak

Open DImuthuUpe opened this issue 11 months ago • 21 comments

Description

I am trying to build a pdf document viewer using react native skia and reanimated. This requires rendering bitmap tiles of a pdf page at different resolution levels when we peform pinch zoom on canvas. As a result of that, I have to regenerat the Skia Image multiple times and assign to the same image component. Please have a look at following simplified code sample. The issue I am struggling is, when I zoom in and generate images of 3000 x 3000 pixels, memory usage goes up as expected but when I zoom out and generate 300x300 bitmaps, memory does not go back down. I initially though that this is related to delayed garbage collection but it seems like not the case as the memory is occupied permanantly for a long period of time. I lookd at the memory profile and there is a lot of objects with stuck at SkData::MakeWithCopy and I think those are the images I created on demad when the pinch zoom ended. Can you help me to figure out why this memory leak is happening and what could be the potential resolution for this?

import React, { useEffect, useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { Canvas, Skia, AlphaType, ColorType, Fill, Image, SkImage } from '@shopify/react-native-skia';
import { GestureDetector, Gesture, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useDerivedValue,
  runOnJS,
} from 'react-native-reanimated';

function App(): React.JSX.Element {

  const tileSize = 512;
  const scale = useSharedValue(1);
  const translationX = useSharedValue(0);
  const translationY = useSharedValue(0);
  const [scaleEnd, setScaleEnd] = useState(1);
  const [skiaImage, setSkiaImage] = useState<SkImage>();

  const createTile = (tileSize: number) => {
      const pixels = new Uint8Array(tileSize * tileSize * 4);
      pixels.fill(255);
      let i = 0;
      for (let x = 0; x < tileSize; x++) {
        for (let y = 0; y < tileSize; y++) {
          pixels[i++] = (x * y) % 255;
        }
      }
      const data = Skia.Data.fromBytes(pixels);
      const img = Skia.Image.MakeImage(
        {
          width: tileSize,
          height: tileSize,
          alphaType: AlphaType.Opaque,
          colorType: ColorType.RGBA_8888,
        },
        data,
        tileSize * 4
      );
  
      return img;
    }


  if (skiaImage === null) {
    return <View style={styles.container} />;
  }

  const panGesture = Gesture.Pan().onChange((e) => {
    translationX.value += e.changeX;
    translationY.value += e.changeY;
  });
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   
  const scaleGesture = Gesture.Pinch().onChange((e) => {
    scale.value +=  e.scaleChange - 1;
  }).onEnd(() => { 
    runOnJS(() => {
      setScaleEnd(scale.value);
    })();
  }).runOnJS(true);

  useEffect(() => { 
    const skImg = createTile(tileSize * scale.value);
    console.log("Skia image calculating for tile size " + tileSize * scale.value);
    if (skImg !== null) {
      setSkiaImage(skImg);
    } 
  }, [scaleEnd]);

  return (
    <GestureHandlerRootView>
      <View style={{ flex: 1 }}>
        <Canvas style={{ flex: 1 }}>
          <Fill color="pink" />
          <Image image={skiaImage} x={translationX} y={translationY} width={useDerivedValue(() => tileSize * scale.value)} height={useDerivedValue(() => tileSize * scale.value)} />
        </Canvas>
        <GestureDetector gesture={Gesture.Race(panGesture, scaleGesture)}>
          <Animated.View style={StyleSheet.absoluteFill} />
        </GestureDetector>
      </View>
    </GestureHandlerRootView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "pink",
  },
  canvasContainer: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
  },
  canvas: {
    flex: 1,
  },
  animatedCanvas: {
    flex: 1,
  },
});

export default App;

React Native Skia Version

1.9.0

React Native Version

0.76.5

Using New Architecture

  • [x] Enabled

Steps to Reproduce

Run the attached code and try multiple pinch zoom on iOS

Snack, Code Example, Screenshot, or Link to Repository

Image Image

DImuthuUpe avatar Jan 23 '25 14:01 DImuthuUpe

Currently we are not using setExternalMemory pressure from Hermes and therefore this skiaImage gets garbage collected eventually but at a much later time than expected. calling skiaImage.dispose() when setting a new image should help. I would be curious to see the behavior you are experiencing. In my case we can log that all objects are properly deleted from the garbage collector but I still see a very high memory pressure, not sure why. I asked the questions on the hermes github: https://github.com/facebook/hermes/issues/1518

On Thu, Jan 23, 2025 at 3:22 PM Dimuthu Wannipurage @.***> wrote:

Description

I am trying to build a pdf document viewer using react native skia and reanimated. This requires rendering bitmap tiles of a pdf page at different resolution levels when we peform pinch zoom on canvas. As a result of that, I have to regenerat the Skia Image multiple times and assign to the same image component. Please have a look at following simplified code sample. The issue I am struggling is, when I zoom in and generate images of 3000 x 3000 pixels, memory usage goes up as expected but when I zoom out and generate 300x300 bitmaps, memory does not go back down. I initially though that this is related to delayed garbage collection but it seems like not the case as the memory is occupied permanantly for a long period of time. I lookd at the memory profile and there is a lot of objects with stuck at SkData::MakeWithCopy and I think those are the images I created on demad when the pinch zoom ended. Can you help me to figure out why this memory leak is happening and what could be the potential resolution for this?

import React, { useEffect, useState } from 'react'; import { View, StyleSheet } from 'react-native'; import { Canvas, Skia, AlphaType, ColorType, Fill, Image, SkImage } from @.***/react-native-skia'; import { GestureDetector, Gesture, GestureHandlerRootView } from 'react-native-gesture-handler'; import Animated, { useSharedValue, useDerivedValue, runOnJS, } from 'react-native-reanimated';

function App(): React.JSX.Element {

const tileSize = 512; const scale = useSharedValue(1); const translationX = useSharedValue(0); const translationY = useSharedValue(0); const [scaleEnd, setScaleEnd] = useState(1); const [skiaImage, setSkiaImage] = useState<SkImage>();

const createTile = (tileSize: number) => { const pixels = new Uint8Array(tileSize * tileSize * 4); pixels.fill(255); let i = 0; for (let x = 0; x < tileSize; x++) { for (let y = 0; y < tileSize; y++) { pixels[i++] = (x * y) % 255; } } const data = Skia.Data.fromBytes(pixels); const img = Skia.Image.MakeImage( { width: tileSize, height: tileSize, alphaType: AlphaType.Opaque, colorType: ColorType.RGBA_8888, }, data, tileSize * 4 );

  return img;
}

if (skiaImage === null) { return <View style={styles.container} />; }

const panGesture = Gesture.Pan().onChange((e) => { translationX.value += e.changeX; translationY.value += e.changeY; });

const scaleGesture = Gesture.Pinch().onChange((e) => { scale.value += e.scaleChange - 1; }).onEnd(() => { runOnJS(() => { setScaleEnd(scale.value); })(); }).runOnJS(true);

useEffect(() => { const skImg = createTile(tileSize * scale.value); console.log("Skia image calculating for tile size " + tileSize * scale.value); if (skImg !== null) { setSkiaImage(skImg); } }, [scaleEnd]);

return ( <GestureHandlerRootView> <View style={{ flex: 1 }}> <Canvas style={{ flex: 1 }}> <Fill color="pink" /> <Image image={skiaImage} x={translationX} y={translationY} width={useDerivedValue(() => tileSize * scale.value)} height={useDerivedValue(() => tileSize * scale.value)} /> </Canvas> <GestureDetector gesture={Gesture.Race(panGesture, scaleGesture)}> <Animated.View style={StyleSheet.absoluteFill} /> </GestureDetector> </View> </GestureHandlerRootView> ); };

const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "pink", }, canvasContainer: { flex: 1, justifyContent: "center", alignItems: "center", }, canvas: { flex: 1, }, animatedCanvas: { flex: 1, }, });

export default App;

React Native Skia Version

1.9.0

React Native Version

0.76.5

Using New Architecture

Enabled

Steps to Reproduce

Run the attached code and try multiple pinch zoom on iOS

Snack, Code Example, Screenshot, or Link to Repository

Screenshot.2025-01-23.at.9.04.32.AM.png (view on web) Screenshot.2025-01-23.at.9.18.32.AM.png (view on web)

— Reply to this email directly, view it on GitHub or unsubscribe. You are receiving this email because you are subscribed to this thread.

Triage notifications on the go with GitHub Mobile for iOS or Android.

wcandillon avatar Jan 23 '25 20:01 wcandillon

@wcandillon I tried to dispose the old image but that raised some EXEC_BAD_ACCESS errors on C++. Please let me whether following way is correct?

useEffect(() => { 
    const skImg = createTile(tileSize * scale.value);
    console.log("Skia image calculating for tile size " + tileSize * scale.value);
    if (skImg !== null) {
      const oldSkiaImage = skiaImage;
      setSkiaImage(skImg);
      if (oldSkiaImage) {
        oldSkiaImage.dispose();
      }
    } 
  }, [scaleEnd]);

Image

DImuthuUpe avatar Jan 23 '25 23:01 DImuthuUpe

Had a similiar issue only on iOS when re-rendering the skia image from a base64 png it would cause a crash. Can also provide some logs if it helps.

ronickg avatar Feb 05 '25 21:02 ronickg

yes any log/reproduction would be extremely helpful Ronald, did get a user report that hinted at a similar problem.

On Wed, Feb 5, 2025 at 10:56 PM Ronald Goedeke @.***> wrote:

Had a similiar issue only on iOS when re-rendering the skia image from a base64 png it would cause a crash. Can also provide some logs if it helps.

— Reply to this email directly, view it on GitHub or unsubscribe. You are receiving this email because you commented on the thread.

Triage notifications on the go with GitHub Mobile for iOS or Android.

wcandillon avatar Feb 05 '25 22:02 wcandillon

@wcandillon

Skia version: "@shopify/react-native-skia": "^1.11.4" RN version: "react-native": "0.76.6"

Image

mobilizeV2(57863,0x16c2bf000) malloc: *** error for object 0xc: pointer being freed was not allocated
mobilizeV2(57863,0x16c2bf000) malloc: *** set a breakpoint in malloc_error_break to debug

Issue occurs when creating an image like this:

 const skiaImage = Skia.Image.MakeImageFromEncoded(
    Skia.Data.fromBase64(
      "base64 string is in file below"
    )
  );

Base64 string of the image image.txt

ronickg avatar Feb 06 '25 11:02 ronickg

@ronickg thanks a lot, this error seems consistent with some other reports we've seen. Just to make sure, it's not as simple as running the snippet you kindly sent to reproduce the error right? I have to assume that it used in another runtime context (reanimated worklet)? Let me know if you have a strong lead on how to reproduce the crash.

wcandillon avatar Feb 06 '25 11:02 wcandillon

@wcandillon It should be as simple as just running the code without any worklets. To reproduce the error I just created a screen using expo router and just navigated to it. When wrapping it in a memo without any dependencies it works fine as it doesn't re-render, as the issue happens as soon as it needs to run the code a second time. And only had this issue on iOS, on android it works.

Image

ronickg avatar Feb 06 '25 11:02 ronickg

I'll make a new project repo to mimic the error which should make debugging easier.

ronickg avatar Feb 06 '25 11:02 ronickg

Thanks a lot 🫶🏻

wcandillon avatar Feb 06 '25 11:02 wcandillon

Okay, i wasn't able to reproduce the issue in a new repo, so I went back to testing my initial one. And initially I though it was crashing even with just the simple code snippet above, but I think i may have not correctly copied the base64 string. So from all that yes I do think its due to the worklets. I am using the latest react native worklets core and set the base64 string like so:


  const saveImagesAndStop = Worklets.createRunOnJS(
    async (imagesString: string) => {
     setSnapShotBase64(imagesString);
    }
  );

This function is called from inside the frameProcessor in vision camera. Which I am sure is somehow causing the crash.

So I don't think there is anything wrong on the skia side. Maybe this helps someone else who has the same issue. Thx

ronickg avatar Feb 06 '25 13:02 ronickg

@DImuthuUpe any chance you could update your example at the top of the issue to use .dipose (with the crash, and then I can investigate it?)

On Thu, Feb 6, 2025 at 2:05 PM Ronald Goedeke @.***> wrote:

Okay, i wasn't able to reproduce the issue in a new repo, so I went back to testing my initial one. And initially I though it was crashing even with just the simple code snippet above, but I think i may have not correctly copied the base64 string. So from all that yes I do think its due to the worklets. I am using the latest react native worklets core and set the base64 string like so:

const saveImagesAndStop = Worklets.createRunOnJS( async (imagesString: string) => { setSnapShotBase64(imagesString); } );

This function is called from inside the frameProcessor in vision camera. Which I am sure is somehow causing the crash.

So I don't think there is anything wrong on the skia side. Maybe this helps someone else who has the same issue. Thx

— Reply to this email directly, view it on GitHub or unsubscribe. You are receiving this email because you commented on the thread.

Triage notifications on the go with GitHub Mobile for iOS or Android.

wcandillon avatar Feb 06 '25 16:02 wcandillon

@wcandillon here it is. For some reasosn, this does not crash for me now but still the memory does not come down when I zoom out. Try to zoom in until you see memory usage goes in beyond 1 gb and try to zoom out. You might notice that the memory does not scale back to 500MBs

import React, { useEffect, useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { Canvas, Skia, AlphaType, ColorType, Fill, Image, SkImage } from '@shopify/react-native-skia';
import { GestureDetector, Gesture, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useDerivedValue,
  runOnJS,
} from 'react-native-reanimated';

function App(): React.JSX.Element {

  const tileSize = 512;
  const scale = useSharedValue(1);
  const translationX = useSharedValue(0);
  const translationY = useSharedValue(0);
  const [scaleEnd, setScaleEnd] = useState(1);
  const [skiaImage, setSkiaImage] = useState<SkImage>();

  const createTile = (tileSize: number) => {
      const pixels = new Uint8Array(tileSize * tileSize * 4);
      pixels.fill(255);
      let i = 0;
      for (let x = 0; x < tileSize; x++) {
        for (let y = 0; y < tileSize; y++) {
          pixels[i++] = (x * y) % 255;
        }
      }
      const data = Skia.Data.fromBytes(pixels);
      const img = Skia.Image.MakeImage(
        {
          width: tileSize,
          height: tileSize,
          alphaType: AlphaType.Opaque,
          colorType: ColorType.RGBA_8888,
        },
        data,
        tileSize * 4
      );
  
      return img;
    }


  if (skiaImage === null) {
    return <View style={styles.container} />;
  }

  const panGesture = Gesture.Pan().onChange((e) => {
    translationX.value += e.changeX;
    translationY.value += e.changeY;
  });
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   
  const scaleGesture = Gesture.Pinch().onChange((e) => {
    scale.value +=  e.scaleChange - 1;
  }).onEnd(() => { 
    runOnJS(() => {
      setScaleEnd(scale.value);
    })();
  }).runOnJS(true);

  useEffect(() => { 
    const skImg = createTile(tileSize * scale.value);
    console.log("Skia image calculating for tile size " + tileSize * scale.value);
    if (skImg !== null) {
      const oldSkiaImage = skiaImage;
      setSkiaImage(skImg);
      if (oldSkiaImage) {
        oldSkiaImage.dispose();
      }
    } 
  }, [scaleEnd]);
  

  return (
    <GestureHandlerRootView>
      <View style={{ flex: 1 }}>
        <Canvas style={{ flex: 1 }}>
          <Fill color="pink" />
          <Image image={skiaImage} x={translationX} y={translationY} width={useDerivedValue(() => tileSize * scale.value)} height={useDerivedValue(() => tileSize * scale.value)} />
        </Canvas>
        <GestureDetector gesture={Gesture.Race(panGesture, scaleGesture)}>
          <Animated.View style={StyleSheet.absoluteFill} />
        </GestureDetector>
      </View>
    </GestureHandlerRootView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "pink",
  },
  canvasContainer: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
  },
  canvas: {
    flex: 1,
  },
  animatedCanvas: {
    flex: 1,
  },
});

export default App;

DImuthuUpe avatar Feb 08 '25 17:02 DImuthuUpe

The topic of the memory not coming down is one I need to be more educated on. We've ran some benchmarks where we could measure that all objects were deleted (and the gc running at regular intervals), running some drawings for hours. However the allocated memory we would see on the iOS perf monitor of the app would never come down.

I would like to learn more about Hermes behaviours there and understand better this setup and I asked the question at https://github.com/facebook/hermes/issues/1518 but didn't get any feedback on it yet. Any information that would help me understand this topic better would be useful.

wcandillon avatar Feb 11 '25 09:02 wcandillon

I did some customizations to Skia C++ code to monitor the reference count of SKData shared pointer and in some cases, that does not go beyond 1 which causes the memory leak in my case (even if I call dispose of SkData and SkImage). I changed the logic to manually delete the pointer SkData when I call dispose from JS side and then the memory came down but this is causing crashes on long run. I am trying to come up with the minimal code to repreoduce this without gesture handlers. I think for this particular problem, I can clearly see a memory leak and SkData objects are not getting deleted when I did profiling on Xcode

DImuthuUpe avatar Feb 11 '25 14:02 DImuthuUpe

I seem to have memory leak issues with skia as well lately. Can't tell if it's related at all, not sure how I can debug this. When I activate skiaFrameProcessor, you can see the memory going up until app crashes. video

all the flag does is activate

    const frameProcessor = useSkiaFrameProcessor(frame => {
        'worklet'
            frame.render(defaultPaint)
    }, [paint, defaultPaint, negativeColors])

where: const defaultPaint = Skia.Paint()

Image

Update: Able to fix the issue be downgrading version of: skia from 1.11.5 -> 1.10.2 worklet-core from 1.5.0 -> 1.3.3

maxximee avatar Feb 13 '25 08:02 maxximee

@wcandillon This is the smallest possible code I have to show that SkData resources are not GCed. I am debugging the C++ code further to figure out why the SKData pointer is not getting released. Please let me know if you have any clue why this happens

import React, { useEffect, useRef, useState } from 'react';
import { View, StyleSheet, Button } from 'react-native';
import { Canvas, Skia, AlphaType, ColorType, Fill, Image, SkImage, SkData } from '@shopify/react-native-skia';

const tileSize = 512;
const pixels = new Uint8Array(tileSize * tileSize * 4);
pixels.fill(255);
let i = 0;
for (let x = 0; x < tileSize; x++) {
  for (let y = 0; y < tileSize; y++) {
    pixels[i++] = (x * y) % 255;
  }
}

function App(): React.JSX.Element {

  const [skiaImage, setSkiaImage] = useState<SkImage>();

  const createTile = (tileSize: number) => {
      "worklet";
      const data = Skia.Data.fromBytes(pixels);
      const img = Skia.Image.MakeImage(
        {
          width: tileSize,
          height: tileSize,
          alphaType: AlphaType.Opaque,
          colorType: ColorType.RGBA_8888,
        },
        data,
        tileSize * 4
      );
      return img;
    }

  useEffect(() => {
    setSkiaImage(createTile(tileSize) as SkImage);
  }
  , []);

  const regenerateImage = () => {
    "worklet";
    for (let i = 0; i < 1000; i++) {
      skiaImage?.dispose();
      setSkiaImage(createTile(tileSize) as SkImage);
    }
  }

  return (
    <View style={{ flex: 1 }}>
      <Canvas style={{ flex: 1 }}>
      <Fill color="pink" />
      <Image image={skiaImage as SkImage} x={0} y={0} width={tileSize} height={tileSize} />
    </Canvas>
    <Button title="Regenerate Image" onPress={regenerateImage} />
    </View>
  );
};

export default App;
Image

DImuthuUpe avatar Feb 20 '25 04:02 DImuthuUpe

@DImuthuUpe Thanks a lot, I want to look into it. Does using data.dispose help? I do want to want to consolidate the situation here, I think the minimum here would be for SkData to use setExternalMemoryPressure so the gc knows that this is a big object. I'm also not sure if the data is copied from fromBytes, I need to check this as well.

wcandillon avatar Feb 20 '25 04:02 wcandillon

@wcandillon fromBytes copies the data from source buffer to a new one. This is the C++ fragment which does that

auto data =
        SkData::MakeWithCopy(buffer.data(runtime), buffer.size(runtime));

However, I further simplified the code to a level where there is NO memory leak based on your suggestion. You can use this and above code as two extreme situations. In order to all smart pointers to clear out, both img.dispose() and data.dispose(); have to be invoked in the right order. (Tweaking C++ code further to find out where the leak is)

import React, { useState } from 'react';
import { View, Button } from 'react-native';
import { AlphaType, ColorType, Skia, } from '@shopify/react-native-skia';

const tileSize = 512;
const pixels = new Uint8Array(tileSize * tileSize * 4);
pixels.fill(255);
let i = 0;
for (let x = 0; x < tileSize; x++) {
  for (let y = 0; y < tileSize; y++) {
    pixels[i++] = (x * y) % 255;
  }
}

function App(): React.JSX.Element {

  const regenerateImage = () => {
    for (let i = 0; i < 1000; i++) {
      const data = Skia.Data.fromBytes(pixels);
      const img = Skia.Image.MakeImage(
        {
          width: tileSize,
          height: tileSize,
          alphaType: AlphaType.Opaque,
          colorType: ColorType.RGBA_8888,
        },
        data,
        tileSize * 4
      );
      img.dispose();
      data.dispose();
    }
  }

  return (
    <View style={{ flex: 1 }}>
    <Button title="Regenerate Image" onPress={regenerateImage} />
    </View>
  );
};

export default App;

DImuthuUpe avatar Feb 20 '25 05:02 DImuthuUpe

@wcandillon Finally found the issue. It seems like useState is a pretty bad lifcycle hook to keep track of the SkData and SkImage objects. I had to store the references const dictionaries and alternatively assign the SkImage to a useState when I wanted to render. Then there is no memory leak. This actually is not a rn-skia issue. Please refer to following corrected code. Now the memory is within 300MB range with 100K iterations and I can see some delayed GCs (as you have observed) to clean up memory allocations. I will leave it out to you to give a technical reason for this but for now, we have an alternative mechanism to move forwared with the project

import React, { useEffect, useRef, useState } from 'react';
import { View, StyleSheet, Button } from 'react-native';
import { Canvas, Skia, AlphaType, ColorType, Fill, Image, SkImage, SkData } from '@shopify/react-native-skia';

const tileSize = 512;
const pixels = new Uint8Array(tileSize * tileSize * 4);
pixels.fill(255);
let i = 0;
for (let x = 0; x < tileSize; x++) {
  for (let y = 0; y < tileSize; y++) {
    pixels[i++] = (x * y) % 255;
  }
}

function App(): React.JSX.Element {

  const imageCache = {};
  const dataCache = {};

  const [skiaImage, setSkiaImage] = useState<SkImage>();

  const createTile = (tileSize: number) => {
      const data = Skia.Data.fromBytes(pixels);
      const img = Skia.Image.MakeImage(
        {
          width: tileSize,
          height: tileSize,
          alphaType: AlphaType.Opaque,
          colorType: ColorType.RGBA_8888,
        },
        data,
        tileSize * 4
      );

      imageCache[0] = img;
      dataCache[0] = data;
    }

  useEffect(() => {
    createTile(tileSize);
    setSkiaImage(imageCache[0] as SkImage);
  }
  , []);

  const regenerateImage = () => {
    for (let i = 0; i < 1000; i++) {
      imageCache[0]?.dispose();
      dataCache[0]?.dispose();
      createTile(tileSize);
      setSkiaImage(imageCache[0] as SkImage);
    }
  }

  return (
    <View style={{ flex: 1 }}>
      <Canvas style={{ flex: 1 }}>
      <Fill color="pink" />
      <Imageimage={skiaImage as SkImage} x={0} y={0} width={tileSize} height={tileSize} />
    </Canvas>
    <Button title="Regenerate Image" onPress={regenerateImage} />
    </View>
  );
};

export default App;
Image

DImuthuUpe avatar Feb 20 '25 05:02 DImuthuUpe

Thank you for looking into this deeper, I definitely want to consolidate the situation there.

wcandillon avatar Feb 20 '25 06:02 wcandillon

I was also having some weird memory leaks with large images in my iOS app. Thank you for your discoveries DImuthuUpe! This led me to believe something weird is going on with the garbage collection

I switched over to objects to store my images instead of arrays & invoked global.gc() in a few different places where it made sense. Interestingly, I think multiple invocations helped over just invoking once. This seems to have fixed my memory leaks

angeljruiz avatar Feb 25 '25 08:02 angeljruiz

Hey everyone!

First, I would like to thank everyone for every piece of usefull information we have in this thread.

Inspired on @DImuthuUpe 's last post, I created this component.

const VideoCanvas = ({
  data,
  width,
  height
}: {
  data: Uint8Array | null
  width: number
  height: number
}) => {
  const cache = {}
  const [skiaImage, setSkiaImage] = useState<SkImage>()

  useEffect(() => {
    cache[IMAGE_KEY]?.dispose()
    cache[DATA_KEY]?.dispose()

    const skiaData = Skia.Data.fromBytes(data)
    const image = Skia.Image.MakeImage(
      {
        width,
        height,
        alphaType: AlphaType.Opaque,
        colorType: ColorType.RGBA_8888
      },
      skiaData,
      width * 4
    )

    cache[IMAGE_KEY] = image
    cache[DATA_KEY] = skiaData
    setSkiaImage(cache[0])
  }, [data])

  if (!data || !data.length) {
    return (
      <View
        style={{
          width,
          height,
          backgroundColor: 'red',
          justifyContent: 'center',
          alignItems: 'center'
        }}
      >
        <ThemedText style={{ color: 'white' }}>No data</ThemedText>
      </View>
    )
  }

  if (!skiaImage) {
    return (
      <View
        style={{
          width,
          height,
          backgroundColor: 'red',
          justifyContent: 'center',
          alignItems: 'center'
        }}
      >
        <ThemedText style={{ color: 'white' }}>
          Image creation failed
        </ThemedText>
      </View>
    )
  }

  return (
    <Canvas style={{ width, height, backgroundColor: 'red' }}>
      <Image
        image={skiaImage}
        fit='cover'
        x={0}
        y={0}
        width={width}
        height={height}
      />
    </Canvas>
  )
}

export default VideoCanvas

I observed two things so far:

  • It is really stable for something like 10 seconds, I have something like ~230/250mb, but after that it continues to grow until reaching 500mb (in almost 10 seconds)
  • When I leave/destroy the view responsible for rendering the video using Skia, the memory used so far still remains!

I'll continue some experiments, and try to grab much info.

I am using version 2.2.1.

tony-go avatar Aug 06 '25 10:08 tony-go

We're releasing a new version of Skia right now that dramatically improves memory usage and fixes some memory errors (including the memory pressure patterns seen in this issue). I've test some very compelling examples on this and will tests the ones from this issue as well

wcandillon avatar Sep 09 '25 01:09 wcandillon

Awesome news. Thanks @wcandillon

DImuthuUpe avatar Sep 09 '25 11:09 DImuthuUpe

:tada: This issue has been resolved in version 2.2.15 :tada:

The release is available on:

Your semantic-release bot :package::rocket:

github-actions[bot] avatar Sep 15 '25 20:09 github-actions[bot]

Still memory leak leading to app crashing on iOS with useSkiaFrameProcessor

maxximee avatar Oct 06 '25 04:10 maxximee