Skip to content

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

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

YevheniiKotyrlo wants to merge 1 commit 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 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. BackgroundStyleApplicator mutates the shared BorderRadiusStyle in place and then reassigns it to each shadow, so the setters see every radius change. An alpha or color filter change only redraws, unlike BackgroundDrawable and BorderDrawable, which mark their paths stale on every invalidateSelf().

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; on main they fail with Expecting actual: RectF(...) and actual: RectF(...) to refer to the same object. Seven compare a reused drawable with a freshly built one after a bounds change, a radius mutated in place and reassigned, new border insets, and a switch to RTL. Two check that an alpha and a color filter change resolve the border radius no second time.

  • ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest -PenableWarningsAsErrors=true: 776 tests, 0 failures
  • node ./scripts/format-kotlin.js --check: clean

RNTester release builds of main and of this branch, 150 views with a native-driven background and a 1px ring, on a Mi MIX 3 (Android 10, API 29): draw recording drops from 4.4 to 2.9 ms per frame for inset 0 0 0 1px and from 5.2 to 3.0 ms for 0 0 0 1px, with render-thread time and a no-shadow control unchanged and the static frames byte-identical. The chart and the scene 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

main — every frame of an animated background rebuilds each shadow's paths, rects and radii: 4.4 ms (inset ring) and 5.2 ms (outset ring) of draw recording for 150 views.

This PR — 2.9 ms and 3.0 ms. The render thread, the no-shadow control and the rendered pixels are unchanged.

draw recording per frame, ms main this PR
Mi MIX 3 — inset ring 4.39 (3.06–4.50) 2.86 (2.00–3.00)
Mi MIX 3 — outset ring 5.21 (3.33–5.39) 2.96 (1.91–3.01)
Galaxy S23 — inset ring 2.50, 2.47, 2.51 1.57, 1.51, 1.48, 1.48
Galaxy S23 — outset ring 2.81, 2.82, 2.65 1.32, 1.29, 1.31, 1.33

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 Mi MIX 3 cell is the median of five 6 s dumpsys gfxinfo framestats windows with their range, the low end being each arm's first window; a Galaxy S23 cell lists the medians of separate sessions, run in the order main, this PR, this PR, main, this PR, main, this PR, so main against itself gives the noise floor. Frame totals on the S23 follow the CPU governor, which lowers the cluster clock once the UI thread has less to do.

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.

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

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.

1 participant