Skip to content

Stop rebuilding Android box-shadow geometry on every draw - #59081

Open
YevheniiKotyrlo wants to merge 3 commits into
react:mainfrom
YevheniiKotyrlo:perf/android-box-shadow-drawable-allocation
Open

YevheniiKotyrlo wants to merge 3 commits into
react:mainfrom
YevheniiKotyrlo:perf/android-box-shadow-drawable-allocation

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

InsetBoxShadowDrawable.draw() and OutsetBoxShadowDrawable.draw() derive their clip and shadow paths, rects and resolved radii every time they run, and draw() runs on every invalidation of the view's background. A background that animates therefore allocates a fresh set of Path, RectF, FloatArray and ComputedBorderRadius objects per shadow per frame, on the UI thread, while it records the display list.

Every one of those values depends only on the bounds, the border radius and insets, the layout direction, the display density, the RTL left/right swap preference and the constructor arguments. Both drawables now keep them in fields and rebuild them in draw() only after one of those changed: the borderRadius and borderInsets setters, onBoundsChange and onLayoutDirectionChanged mark them stale, and since nothing notifies a drawable of a density change or of swapLeftAndRightInRTL(), draw() compares the density and, for a shadow in RTL with a radius or insets to resolve, I18nUtil.doLeftAndRightSwapInRTL() with the values the geometry was built with. The setters see every radius change, because BackgroundStyleApplicator reassigns the shared BorderRadiusStyle to each shadow after mutating it. An alpha or color filter change redraws without rebuilding them, where BackgroundDrawable and BorderDrawable mark their paths stale on every invalidateSelf(). The cache holds two or three RectFs, one or two Paths and one or two radii arrays per shadow.

#59083 and #59097 build on this change, #59189 does the same for the background and border drawables, and #60008 tracks the bar an outset shadow paints when its spread empties its shape, which this change leaves as it is.

Changelog:

[ANDROID] [FIXED] - Box shadows no longer rebuild their paths and radii on every draw

Test Plan:

BoxShadowDrawableTest (new) draws each drawable twice through a recording canvas. Four cases assert that the second draw reuses the first draw's Path and RectF instances; they fail on main. Eleven compare a reused drawable with a freshly built one after a bounds change, a radius mutated in place and reassigned, new border insets, a switch to RTL, a density change, and the swap preference turned off in RTL. Four check that an alpha or color filter change, and a second draw in RTL, do not resolve the border radius again, and two that a shadow reads the swap preference only in RTL and only with a radius or insets to resolve.

  • ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest -x :packages:react-native:ReactAndroid:buildCodegenCLI -Preact.internal.useHermesStable=true --rerun --tests com.facebook.react.uimanager.drawable.BoxShadowDrawableTest -PenableWarningsAsErrors=true: 21 tests, 0 failures
  • java -jar node_modules/ktfmt/lib/ktfmt.jar --do-not-remove-unused-imports --dry-run --set-exit-if-changed on the changed files: clean

RNTester release builds of main and of this branch, 150 views whose background colour animates on the native driver, each with a 1px ring: the UI thread's draw recording drops from 4.4 to 2.6 ms per frame for inset 0 0 0 1px and from 5.5 to 2.7 ms for 0 0 0 1px on a Mi MIX 3 (Android 10, API 29), and from 2.2 to 1.2 ms and from 2.4 to 1.0 ms on a Galaxy S23 (Android 14, API 34); still frames are byte-identical. With density in RNTester's android:configChanges, through wm density 600 and I18nManager.swapLeftAndRightInRTL() on the S23, this branch draws what main draws. The numbers, the chart, the recordings and the scenes are in the evidence comment.

I didn't run the full Android build, the template and HelloWorld app builds, or the Android e2e jobs locally.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 14, 2026
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Device measurement — before / after

Draw recording per frame — the UI thread's DrawStart to SyncQueued in dumpsys gfxinfo framestats — for 150 views whose background colour animates on the native driver, each with a 1px ring: inset 0 0 0 1px (inset ring) or 0 0 0 1px (outset ring), or no shadow as the control.

ms, in run order main this PR this PR main
Mi MIX 3 — inset ring 4.38 (4.23–4.46) 2.65 (2.32–2.76) 2.62 (2.26–2.67) 4.50 (4.08–4.53)
Mi MIX 3 — outset ring 5.64 (3.72–5.73) 2.65 (2.20–2.73) 2.67 (2.15–2.75) 5.34 (4.20–5.70)
Mi MIX 3 — no shadow 3.06 (2.81–3.28) 3.65 (3.33–3.70) 3.21 (3.09–3.38) 3.34 (2.99–3.68)
Galaxy S23 — inset ring 2.22 (2.19–2.33) 1.28 (1.11–1.33) 1.20 (1.08–1.38) 2.28 (2.18–2.30)
Galaxy S23 — outset ring 2.39 (1.45–2.45) 0.95 (0.94–1.09) 1.09 (0.94–1.23) 2.45 (2.39–2.64)
Galaxy S23 — no shadow 0.86 (0.60–0.86) 0.87 (0.84–0.88) 0.85 (0.77–0.89) 0.87 (0.84–0.88)

Mi MIX 3 (Android 10, API 29, Adreno 630, 60 Hz) and Galaxy S23 (Android 14, API 34, Adreno 740), RNTester release builds of main and of this branch for arm64-v8a. A cell is the median of five framestats windows, with their range. The Mi MIX 3 ran five continuous 6 s windows per arm while MIUI capped its big cluster: at 95% of its rated clock in the first arm, at 83% from then on, and at 75% in the outset arm of the last main column; the chart plots its last two columns. On the S23 each window started from a cool device in a fresh process and counted only while every CPU cluster held its rated clock; 12 of 72 were discarded and taken again. A still frame of each ring is byte-identical between the builds on both phones.

Scene
function BoxShadowStress({boxShadow}) {
  const progress = React.useRef(new Animated.Value(0)).current;
  React.useEffect(() => {
    const loop = Animated.loop(
      Animated.timing(progress, {
        toValue: 1,
        duration: 1000,
        easing: Easing.linear,
        useNativeDriver: true,
      }),
    );
    loop.start();
    return () => loop.stop();
  }, [progress]);
  const backgroundColor = progress.interpolate({
    inputRange: [0, 0.5, 1],
    outputRange: ['#e5e7eb', '#ffffff', '#e5e7eb'],
  });
  return (
    <View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 12, padding: 8}}>
      {Array.from({length: 150}, (_, index) => (
        <Animated.View
          key={index}
          style={{width: 36, height: 36, borderRadius: 8, boxShadow, backgroundColor}}
        />
      ))}
    </View>
  );
}

boxShadow is 'inset 0 0 0 1px #7c3aed', '0 0 0 1px #7c3aed', or null for the control.

A density change and the RTL left/right swap

main, this PR before the commits that answer the review (cdf7b6b), and this PR (dcd1cb9), as release builds on a Galaxy S23 (Android 14, API 34), each in RTL with density, smallestScreenSize and screenLayout added to RNTester's android:configChanges, so the views survive adb shell wm density 600 (from 480) and wm density reset. I18nManager.swapLeftAndRightInRTL(false) and then (true) flip the preference. The card is sized in percentages of the window, so its bounds stay 820×656 px at both densities.

At density 600 and with the swap off, this PR's stills are byte-identical to main's. The scene alternates the card's and tiles' colours by one shade step every 100 ms to keep them redrawing; beyond that step, cdf7b6b's stills differ in 54,864 and 128,615 of the 1080×2195 pixels between the status and navigation bars, by up to 241: its shadows keep their 480 geometry and their sides from before the flip. Back at 480 with the swap on, all three draw alike. Stills at density 600: main · cdf7b6b · this PR; with the swap off: main · cdf7b6b · this PR; recordings: main · cdf7b6b · this PR.

Scene
function BoxShadowDensityAndSwap() {
  const [frame, setFrame] = React.useState(0);
  React.useEffect(() => {
    I18nManager.swapLeftAndRightInRTL(true);
    const redraw = setInterval(() => setFrame(value => value + 1), 100);
    const flips = [
      setTimeout(() => I18nManager.swapLeftAndRightInRTL(false), 14000),
      setTimeout(() => I18nManager.swapLeftAndRightInRTL(true), 18000),
    ];
    return () => {
      clearInterval(redraw);
      flips.forEach(clearTimeout);
    };
  }, []);
  const shade =
    frame % 2 === 0
      ? {backgroundColor: '#F1F5F9', borderColor: '#0F172A'}
      : {backgroundColor: '#F1F5FA', borderColor: '#0F172B'};
  return (
    <View style={{flex: 1, backgroundColor: '#FFFFFF'}}>
      <View style={{width: '100%', paddingHorizontal: '12%', paddingVertical: '10%'}}>
        <View
          style={[
            {
              width: '100%',
              aspectRatio: 1.25,
              borderTopLeftRadius: 64,
              borderLeftWidth: 14,
              boxShadow:
                'inset 18px 14px 0 10px rgba(124, 58, 237, 0.85), 16px 12px 0 6px rgba(14, 165, 233, 0.9)',
            },
            shade,
          ]}
        />
      </View>
    </View>
  );
}

Below the card, three 72×72 tiles with borderTopLeftRadius: 28 take the same shade and carry inset 0 0 0 6px #7C3AED with borderLeftWidth: 6, 0 0 0 6px #0EA5E9, and inset 0 4px 10px 2px rgba(124, 58, 237, 0.9) with borderLeftWidth: 6.

@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the perf/android-box-shadow-drawable-allocation branch from 4b6e7fa to ad615d5 Compare October 18, 2026 22:50
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 18, 2026 23:04
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 18, 2026
InsetBoxShadowDrawable and OutsetBoxShadowDrawable resolved the border
radii and built new RectF, Path and radii arrays inside draw(), so every
redraw of a view's background, every frame of an animation that touches
it, allocated them again and handed the canvas a new Path. Both drawables
now keep that geometry in fields and rebuild it only when its inputs
change: on a bounds or layout direction change, and when borderRadius or
borderInsets is assigned. An alpha or color filter change only redraws.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the perf/android-box-shadow-drawable-allocation branch from ad615d5 to cdf7b6b Compare November 11, 2026 18:05
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

@cortinico on a Mi MIX 3, this PR takes the draw recording of 150 views with an inset ring from 4.4 to 2.9 ms a frame, #59083 the frame of 150 blurred inset shadows from 395.6 to 19.5 ms, and #59097 their frame from 405 to 22.7 ms while their colour animates.

@cortinico cortinico left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do some testing + add evidence that this change is sound when handling Pixel density change and the RTL left/right-swap preference (specifically the swapLeftAndRightInRTL() method).

Ideally if you could test those + add videos would be ideal

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft November 13, 2026 17:21
The box-shadow drawables cache their geometry, which depends on the screen
density and, in RTL, on the left/right swap preference, but rebuilt it only
on a bounds or layout direction change and when borderRadius or
borderInsets was assigned. Neither the density, which changes in place for
an activity that handles density configuration changes, nor
I18nManager.swapLeftAndRightInRTL() notifies a drawable, so one drawn again
afterwards kept the old geometry. Each drawable now records the density and
swap it built its geometry with and rebuilds it when either differs.
The geometry check read the left/right swap preference on every RTL draw,
also for a shadow with no border radius and no border insets, whose
geometry never reads it. It now reads it only when the drawable has a
border radius, or border insets for an inset shadow, as resolving them
does.
@github-actions

Copy link
Copy Markdown

Visual regression

No visual differences at dcd1cb9edca6.

ios: no capture

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

The previous head kept the geometry it had built at the old density, or with the old swapLeftAndRightInRTL() preference in RTL, until the view's bounds, border or layout direction changed, since neither change notifies the drawable. 0a26210b916 and dcd1cb9edca compare both, on every draw, with the values the geometry was built with, and rebuild it when either differs; BoxShadowDrawableTest covers each for the inset and the outset drawable, and the evidence comment now has Galaxy S23 recordings of main, the previous head and this PR through adb shell wm density 600 and swapLeftAndRightInRTL(false) then (true), where this PR's stills match main's.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants