React-native-reanimated: Huge performance issues when using multiple worklets (`useAnimatedStyle`, `useDerivedValue`)

Created on 1 Dec 2020  路  11Comments  路  Source: software-mansion/react-native-reanimated

Description

I've created a view which is essentially a horizontal ScrollView that can only be swiped one item at a time. Each item (= category) has a header showing the category's name. To somehow reveal to the user what the next page/previous page has to offer, I decided to show a tiny bit of the headers flowing into the screen.

Without header parallax With header parallax

Demo

Without header parallax With header parallax

While you can't _really_ see the difference in a compressed, 320 pixel wide, 25 FPS GIF, it is extremely noticeable in the app, since you can swipe/scroll normally without the parallax, and once you enable the header parallax it looks like a powerpoint presentation. I'd say without parallax it's around 60 FPS, with parallax about 5 FPS.

Attempts to solve

I've tried multiple approaches to solve those performance issues, here's what I tried:

  1. Checked my re-renders. Maybe something is unnecessarily re-rendering? Nope, everything's happening on the UI thread.
  2. Tried to optimize the worklets inside the Header (put all useDerivedValues into a single useAnimatedStyle) so that there's a few less worklets executing at once - didn't make a difference at all.
  3. I've tried (and I'm still using) the removeClippedSubviews property on the container to remove all views that are offscreen, so not rendering them if they're overflowing. That requires overflow: 'hidden' to be set, which I did, so now only everything that's on screen should be rendered. This doesn't affect worklets though, since still every single useDerivedValue and useAnimatedStyle worklet is executing per frame on drag. For 30 Stories, that's 1 useAnimatedStyle and 1 useAnimatedGestureHandler for the "list" container, and 30 useDerivedValues and 30 useAnimatedStyles for the Headers. Can we ignore them if RCTView removes that view due to clipping?
  4. I tried to unmount everything if it's offscreen. So only mounting current header, header to the right and header to the left. Weirdly enough, this still had terrible performance. It looked a bit better, but still absolutely unusable, like below 20 FPS.

Steps To Reproduce

  1. Create horizontal Swiper using <PanGestureHandler> and translate those views using the translateX (plus some offsetX)
  2. Now pass each item the translateX value and it's index in the Swiper
  3. Each item can now have a custom useAnimatedStyle which interpolates the current Swiper's translateX to parallax-like translate the header towards the screen corners so they get revealed.

Expected behavior

I expect it to run smoothly

Actual behavior

It runs terribly stuttery.

Snack or minimal code example


Header.tsx

function StoryHeader({
  story,
  style: _style,
  indexInSwiper,
  swiperTranslateX,
  isSwipeMode,
  onPress,
  ...passThroughProps
}: StoryHeaderProps): React.ReactElement {
  const [width, setWidth] = useState(0);

  const onLayout = useCallback(
    ({
      nativeEvent: {
        layout: { width: newWidth },
      },
    }: LayoutChangeEvent) => {
      setWidth(newWidth);
    },
    [setWidth],
  );

  const thisTranslateX = useMemo(() => -(SCREEN_WIDTH * indexInSwiper), [indexInSwiper]);
  const offsetX = useDerivedValue(() => {
    if (isSwipeMode) {
      return withTiming(width / 2, {
        duration: 200,
        easing: Easing.back(1),
      });
    } else {
      return withTiming(width / 2 - HEADER_PREVIEW_OVERFLOW, {
        duration: 300,
        easing: Easing.elastic(1),
      });
    }
  }, [isSwipeMode, width]);
  const animatedStyle = useAnimatedStyle(() => {
    const translateX = interpolate(
      swiperTranslateX.value,
      [
        thisTranslateX - SCREEN_WIDTH - SCREEN_WIDTH,
        thisTranslateX - SCREEN_WIDTH,
        thisTranslateX, //
        thisTranslateX + SCREEN_WIDTH,
        thisTranslateX + SCREEN_WIDTH + SCREEN_WIDTH,
      ],
      [
        SCREEN_WIDTH / 2,
        SCREEN_WIDTH / 2 - offsetX.value, // Swiper is at Next slide
        0, // Swiper is at current slide
        -(SCREEN_WIDTH / 2) + offsetX.value, // Swiper is at Previous slide
        -(SCREEN_WIDTH / 2),
      ],
      Extrapolate.CLAMP,
    );
    const isCurrentHeader = between(translateX, -10, 10);
    return {
      // opacity: withTiming(isSwipeMode ? (isCurrentHeader ? 1 : 0) : 1, { duration: 300 }),
      opacity: 1,
      transform: [{ translateX: translateX }, { scale: withTiming(isSwipeMode ? (isCurrentHeader ? 1.2 : 1) : 1, { duration: 150 }) }],
    };
  }, [isSwipeMode, offsetX, swiperTranslateX, thisTranslateX]);
  const style = useMemo(() => [_style, { left: SCREEN_WIDTH * indexInSwiper + (SCREEN_WIDTH / 2 - width / 2) }], [_style, indexInSwiper, width]);

  return (
    // TODO: DEBUG IF renderToHardwareTextureAndroid={true} shouldRasterizeIOS={true} ARE A GOOD IDEA
    <Reanimated.View
      onLayout={onLayout}
      style={[style, animatedStyle]}
      renderToHardwareTextureAndroid={true}
      shouldRasterizeIOS={true}
      {...passThroughProps}>
      <StoryHeaderContent story={story} onPress={onPress} />
    </Reanimated.View>
  );
}

Package versions

  • React: 17
  • React Native: 0.64.rc1 (but also reproduced on 0.63)
  • React Native Reanimated: master (but also reproduced on alpha 9 or 2.0-rc0)
  • Engine: Hermes (but also reproduced on JSC)
  • NodeJS: v14.15.1

I'm really not sure how I can make this more efficient or if I'm doing something wrong, so any tips would be greatly appreciated!

EDIT

Here's everything I've noted:

  1. In the header's useAnimatedStyle hook, that gets executed a lot (30 times for 30 different headers per on drag event), using withTiming (or withSpring) is a bad idea. Instead, extracting that into a useSharedValue and _imperatively_ animating that when the isSwipeMode or isCurrentHeader props changes works a lot better. I am guessing it just takes a long time to set up the withTiming (or withSpring) animation, even though the inputs haven't changed an it already is at the desired position.
  2. Splitting all my dependencies up into useDerivedValues doesn't seem to really do anything, it feels a tiny bit smoother I guess
  3. Animations are run even if views are removed by RCTView's performance strategy: removeClippedSubviews. (overflow hidden)
  4. I've unmounted everything except 3 headers (current, left and right), and it still had bad performance. Even removed all other stuff, e.g. the background you're seeing in the demo. Now I know that this is a very complex animation, but with REA v1 I just passed different interpolated nodes into this and everything worked fine and smoothly, I assume "everything working as good as REA v1 or even better" is also the goal here.
  5. If you want to see another demo of this, and even have a REA v1 vs REA v2 comparison, take a look at William's Jelly Scroll video (https://www.youtube.com/watch?v=Xnj6uoW2PJM). I've tried to implement this in REA v2, and it had terrible performance because of the many useAnimatedStyle hooks.
馃彔 Reanimated2 馃悶 Bug

Most helpful comment

Issue validator - update # 3

Hello!
Congratulations! Your issue passed the validator! Thank you!

All 11 comments

Also, are there fundamental differences between useAnimatedStyle and useDerivedValue in terms of dealing with results that are shallow-equal? E.g.: When a useDerivedValue executes and it's result stays the same, other useDerivedValues and useAnimatedStyles do not re-run since the input is the same (afaik).
Is this also the case for useAnimatedStyle? Does it forcefully re-trigger a native re-render/re-draw even if the output' style (return value of useAnimatedStyle) is still the same?

Actually, I've just tried to disable random parts of the animation and noticed that the scale transform:

{ scale: withTiming(isSwipeMode ? (isCurrentHeader ? 1.2 : 1) : 1, { duration: 150 })

in the useAnimatedStyle hook is causing a lot of performance issues, even though the inputs haven't changed. I'm trying to run scale animations imeratively using useEffect hooks now, that works _a bit_ better. It's still under 30 FPS.

Issue validator - update # 3

Hello!
Congratulations! Your issue passed the validator! Thank you!

It seems to be related to the performance issues I had and we talked about @karol-bisztyga

Basically, it subscribes for too many mappers (animated style callback) and for some reason it had hard time executing all of them during the single frame update

Hello!
First of all thanks to @mrousavy for posting such a well-prepared issue! You must've taken your time to make it look like this, I really appreciate that 馃檶 !

I got a little bit confused about whether your components are inside ScrollView or PanGestureHandler that tries to imitate a ScrollView(you started the post by mentioning the first one but in repro, there is the other one). Could you clarify? Still, both options should work smoothly but just wanted to understand it better. The code snippet you posted(Header.tsx - I understand it's rendered multiple times in some kind of loop(Array.map in a parent component, something like that?), right?). Maybe it's just me and I don't get it right, then I'm sorry.

I'll be looking into it, I wouldn't be surprised if it turned out that there are several distinct problems which together cause those big lags.

Also, are there fundamental differences between useAnimatedStyle and useDerivedValue in terms of dealing with results that are shallow-equal? E.g.: When a useDerivedValue executes and it's result stays the same, other useDerivedValues and useAnimatedStyles do not re-run since the input is the same (afaik).
Is this also the case for useAnimatedStyle? Does it forcefully re-trigger a native re-render/re-draw even if the output' style (return value of useAnimatedStyle) is still the same?

I think it should work that way, however, it doesn't(I've reproduced such a case already) and that could be the first problem here.

@karol-bisztyga Thanks for responding.

  1. My components are inside a PanGestureHandler, since a ScrollView isn't as customizeable. The PanGestureHandler View is build like this:
<PanGestureHandler
  onGestureEvent={onGestureEvent}
  maxPointers={1}
  activeOffsetX={GESTURE_HANDLER_RANGE}
  failOffsetY={GESTURE_HANDLER_FAIL_RANGE}>
  <Reanimated.View style={styles.sliderContainer}> {/* <-- That's just width: SCREEN_WIDTH, height: SCREEN_HEIGHT */}
    <Reanimated.View style={[styles.slider, sliderStyle, sliderAnimatedStyle]}> {/* <-- That's flexDirection: 'row', width: SCREEN_WIDTH * headers.length, transform: [{ translateX: translateX.value }] */}
      {swipers}
      {headers} {/* Those are the individual headers, memoized so I can return null ("unmount them") for those that aren't in the viewport, story headers have a custom style of position: absolute and their left: inset. Also, they interpolate their own translateX on the translateX value from the PanGestureHandler parent, see the code in my original issue/post */}
    </Reanimated.View>
  </Reanimated.View>
</PanGestureHandler>
  1. wdym there is a ScrollView in the repro? The video from William shows a ScrollView, yep.
  2. Yes, the StoryHeader.tsx is rendered multiple times. For completeness, here's that:
const headers = useMemo(
  () =>
    stories.map((story, i) => {
      if (Math.abs(index - i) > 1) {
        return null; // it's out of viewport bounds
      } else {
        return (
          <StoryHeader
            key={`${story.id}.header`}
            style={styles.storyHeader}
            story={story}
            indexInSwiper={i}
            swiperTranslateX={translateX}
            onPress={() => onHeaderPressed(i)}
          />
        );
      }
    }),
  [index, isExperimental, isSwipeMode, onHeaderPressed, stories, translateX],
);

If you need any more details, please let me know! Thanks for looking into this 馃

Ok so there are two major problems here:

  1. If you set a shared value to the same value multiple times and this shared value is an input for some mappers it's going to trigger those mappers anyway. Something like this:
const sv = useSharedValue(50)
const uas = useAnimatedStyle(() => {
    return { width: sv.value }
})
for (let i = 0; i < 1000; ++i) {
    sv.value = 100;
}

In that case, the mapper inside of the uas is going to be triggered 1000 times(plus initial updater run but I mean 1000 extra times that shouldn't happen).

As for this problem, I'm going to push a solution soon.

  1. The mappers and (props)style updaters are run even for the components which aren't visible. This is a bit more complicated as the calculations inside of those mappers determine whether those components are visible or not. I think we could think of something like conditional mapper execution(the calculations would still be run on UI but there would be no overhead coming from prop updating for instance). Still, we don't want to modify API too much I guess so just have to figure out a proper way of doing this.

In the meantime will also hunt for more issues here(maybe there are some).

  1. Seems like a good idea to shallow-equality-compare the inputs, nice catch 馃憤
  2. Yeah, I understand that there's not a simple solution for this problem, we'd have to know what exactly is performing so badly, if it's the worklet execution itself, or the updating of style/props afterwards. I currently see two solutions:

    1. Add shallow-equality-comparisons for inputs and outputs of worklets, if inputs are the same don't run the worklet, if outputs are the same don't update style/props. This enables me to use Extrapolate.clamp for my translateX interpolation, and if the view is out of bounds, the header translateX value will get clamped, so it doesn't re-run the animated style hook (since the inputs are the same).

    2. Unmount my components that are out of bounds. This is really complex, since the components are stateful. I'd lose any progress the user has made in a view once he swipes away.

I've done some testing and my fix in #1502 seems to reduce 90%+ of the lag. BUT there has to be a proper approach applied. The main idea is to have that one useDerivedValue per item in the render loop(Array.map) which would be some kind of 'entry point' for all the other mappers for that item. Then every other worklet there would depend on that value. Something like this(pseudo-code):

items.map((item) => {
    const udv = useDerivedValue(() => {
        // ... calculate something
        return ... // e.g. null/undefined when the item wouldn't be visible, original number when it would
    })
    const st1 = useAnimatedStyle(() => {
        return { transform: [{ translateX: udv.value === null ? -100 : udv.value }] }
    })
    const st2 = useAnimatedStyle(() => {
        return { scale: withTiming(udv.value === null ? 1 : 0.7) }
    })
    return <...>
})

I wouldn't treat it as some kind of workaround but rather a good practice. This happens only when there is a lot of mappers spawned which recalculate a lot. Maybe there's some way we could improve the process more on the reanimated 2 side but keep in mind that the application's logic matters a lot too.

Below I'm pasting the code that I used for testing/reproducing. It's a standalone component so just feel free to paste it and launch it.

I've run it on iPhone 11 and without the fix from #1502 it would drop to extremely low FPS values(both JS and UI, often <10, sometimes even 0, lol) but with the fix applied it stays at 60FPS(both) and drops to 50+ when there are more components involved(meaning when you swipe so the new ones appear)(also, if you remove console.log's that little drop only appears on UI, on JS it remains at 60). I realize that's still a frame drop there and I will look deeper, but just wanted to point out a direction for solving the problem(also the calculations in this particular example could be not very precise as I didn't pay that much attention to this).


the code

import Animated, {
  useSharedValue,
  withTiming,
  useAnimatedStyle,
  useDerivedValue,
  useAnimatedGestureHandler,
} from 'react-native-reanimated';
import { View, Dimensions, Text } from 'react-native';
import React from 'react';
import { PanGestureHandler } from 'react-native-gesture-handler';

const WIDTH = Dimensions.get('window').width;

export default function App() {
  const items = Array(50)
    .fill()
    .map((_, i) => i);
  const itemWidth = 150;
  const itemMargin = 10;
  const posX = useSharedValue(0);

  const handler = useAnimatedGestureHandler({
    onActive: (e, ctx) => {
      let newPosX = posX.value;
      newPosX += e.velocityX;
      newPosX = Math.min(newPosX, 0);
      const minval = (-(itemWidth + itemMargin) * items.length + WIDTH) * 50;
      newPosX = Math.max(newPosX, minval);
      console.log('here new pos', newPosX);
      posX.value = newPosX;
    },
  });

  return (
    <View>
      <PanGestureHandler onGestureEvent={handler}>
        <Animated.View
          style={{
            alignSelf: 'stretch',
            height: 300,
            backgroundColor: 'purple',
          }}>
          {items.map((index) => {

            // this determines whether the styles should calculate
            const udv = useDerivedValue(() => {
              console.log('here mapper #1', index);
              const dist = Math.abs((itemWidth + 10) * index + posX.value / 50);
              return (dist > 500) ? null : posX.value ;
            })

            const style = useAnimatedStyle(() => {
              console.log('here mapper #2', index, udv.value);
              const x =
                udv.value === null
                  ? -itemWidth
                  : (itemWidth + itemMargin) * index + udv.value / 50;
              return {
                transform: [
                  {
                    translateX: x,
                  },
                ],
              };
            });

            const style2 = useAnimatedStyle(() => {
              const dist = (udv.value === 2000) ? null : (itemWidth + 10) * index + udv.value / 50;
              console.log('here mapper #3', index, dist);
              return {
                transform: [{ scale: withTiming(dist === null ? 1 : 0.7) }],
              };
            });

            return (
              <Animated.View
                style={[
                  {
                    position: 'absolute',
                    width: 100,
                    height: 250,
                    backgroundColor: 'orange',
                    marginTop: 25,
                  },
                  style,
                ]}>
                <Text style={{ fontSize: 20 }}>{index}</Text>
                <Animated.View
                  style={[
                    {
                      width: itemWidth / 2,
                      height: itemWidth / 2,
                      backgroundColor: 'green',
                    },
                    style2,
                  ]}
                />
              </Animated.View>
            );
          })}
        </Animated.View>
      </PanGestureHandler>
    </View>
  );
}

@mrousavy what do you think? I'm probably going to try to also refactor your code a little bit in such a manner.

That's awesome! That's essentially what I was referring to with "clamping" the outputs, so they stay the same and won't re-trigger dependant worklets (useAnimatedStyle). I'll test that out in my app sometime this week to see how big of an impact it's going to have, but it looks promising. Thanks again for looking into this.

Great! Looking forward to hearing about the results, thx! 馃檶

Was this page helpful?
0 / 5 - 0 ratings