software-mansion / react-native-reanimated

React Native's Animated library reimplemented
https://docs.swmansion.com/react-native-reanimated/
MIT License
8.83k stars 1.29k forks source link

Huge performance issues when using multiple worklets (`useAnimatedStyle`, `useDerivedValue`) #1499

Closed mrousavy closed 3 years ago

mrousavy commented 3 years ago

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 ```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 ); } ```

Package versions

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.
mrousavy commented 3 years ago

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?

mrousavy commented 3 years ago

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.

github-actions[bot] commented 3 years ago

Issue validator - update # 3

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

terrysahaidak commented 3 years ago

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

karol-bisztyga commented 3 years ago

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.

mrousavy commented 3 years ago

@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 πŸ€—

karol-bisztyga commented 3 years ago

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).

mrousavy commented 3 years ago
  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.
karol-bisztyga commented 3 years ago

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 ( {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 ( {index} ); })} ); } ```

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

mrousavy commented 3 years ago

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.

karol-bisztyga commented 3 years ago

Great! Looking forward to hearing about the results, thx! πŸ™Œ

davidfarinha commented 3 years ago

I'm also seeing huge performance issues when using this useAnimatedStyles within FlatList items (about ~40 list items are rendered on the screen at once).

const animatedStyles = {
styleAnimated: useAnimatedStyle(() => {
    const rotate = interpolate(
        shared.translation.x.value,
        [-windowDimensions.width * 0.95, 0, windowDimensions.width * 0.95],
        [-10, 0, 10]
    ) as unknown as string;

    const opacity = interpolate( 
        shared.translation.x.value,
        [-(windowDimensions.width / 3), 0, windowDimensions.width / 3],
        [1, 0.45, 1]
    );

    return ({
        transform: [
            {
                translateX: shared.translation.x.value
            },
            {
                rotate: `${rotate}deg`
            }
        ],
        backgroundColor: (!shared.didSnapBackAnimationFinish.value ? !shared.isDragging.value : shared.translation.x.value === 0) ? 'transparent' : (shared.translation.x.value < 0 ? `rgba(0,0,0,${opacity})` : `rgba(0,64,159,${+opacity})`)
    });
})

The app runs very slow (<30fps), but when I comment out this useAnimatedStyle within the list item, it starts running smoothly again at 60fps. Is there any workaround to improve performance when using rendering many reanimated hooks?

mrousavy commented 3 years ago

Is there any workaround to improve performance when using rendering many reanimated hooks?

I played around with performance issues A LOT, and came to the following conclusion:

  1. Sharing the output of a single mapper across multiple views would be a huge improvement. (that means, using the style from useAnimatedStyle for multiple views. there is a PR open to support this here: #1470 ). Unfortunately this does not work in all cases, since sometimes we also have to add some sort of parallax and interpolate each view differently. (e.g. my original issue, and probably your's too.)
  2. optimize away if not visible. I found a solution to get this working for my case - I only mounted the views for the headers that were actually visible (that's the one on the current page and the one left and right to it) - that's a huge optimization since instead of running ~40 mappers (useAnimatedStyle) I'm now running 3. This does not work in all cases and sometimes there's more going on onscreen.
  3. Optimize your mappers. JavaScript isn't really that fast, and if you run a lot of logic in your animated style hook, that's blocking the UI thread.
  4. Optimizations done by Reanimated.
    1. Optimize memory allocations, heavily optimizing the mappers through the jsi runtime, optimizing style updates ("applying" the output from useAnimatedStyle), etc
    2. Smart mappers. Only run if inputs actually changed (already the case), don't update if output didn't change (already the case afaik), don't run updates and maybe even optimize view away if it is out of viewport/not onscreen (that's a complicated one)
mrousavy commented 3 years ago

as for point 4.1, @piaskowyk is doing some interesting work over at https://github.com/software-mansion/react-native-reanimated/pull/1879 ^^