Skip to content

Rebuild Android background and border geometry only when it changes - #59189

Draft
YevheniiKotyrlo wants to merge 1 commit into
react:mainfrom
YevheniiKotyrlo:perf/android-background-border-drawable-invalidate
Draft

YevheniiKotyrlo wants to merge 1 commit into
react:mainfrom
YevheniiKotyrlo:perf/android-background-border-drawable-invalidate

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

BackgroundDrawable, BackgroundImageDrawable and BorderDrawable override invalidateSelf() to mark their geometry stale, so every redraw request rebuilds their paths, rects and resolved radii on the next draw(): a background or border color change, an alpha change, a border style change, a new background image layer. A color animation pays that rebuild for every view on every frame, on the UI thread, while the display list is recorded.

The geometry depends only on the bounds, the border radius and insets, the layout direction and, for a border, on whether any edge's color is visible, since only then is its inner clip inset by the border widths. The three drawables now mark it stale in their borderRadius and borderInsets setters, in onBoundsChange and in onLayoutDirectionChanged, the same shape as #59081 for the box-shadow drawables, and BorderDrawable also rebuilds when its edges' visibility changes. BackgroundStyleApplicator already reassigns the shared BorderRadiusStyle and BorderInsets after changing them, so it needs no change. A layout direction change now rebuilds the geometry; before, the backgrounds and a rounded border kept the previous direction's start and end corners and widths until their next redraw request.

BorderDrawable also resolves its border widths with the rest of its geometry instead of on every draw, stops building an outline path nothing reads, reuses its single-edge path across draws, and drops setBorderRadius(property, radius), which nothing calls and which changed the shared BorderRadiusStyle in place without reassigning it.

Changelog:

[ANDROID] [FIXED] - Background and border drawables no longer rebuild their geometry when only a color, an alpha or a style changes

Test Plan:

BackgroundAndBorderDrawableTest (new, Robolectric with native graphics) draws each drawable through a recording canvas and compares the calls with those of a freshly built drawable in the same state. 14 of its 20 cases fail on main:

  • four assert that a color, alpha, style or layer change resolves the border radius or widths no second time (TooManyActualInvocations on main), and one that a rectangular border hands the canvas the same Path on every draw;
  • three assert that a switch to RTL rebuilds the rounded geometry, which main does only at the next redraw request;
  • six assert that the geometry follows a radius or widths changed in place and reassigned. main rebuilds only on the redraw request BackgroundStyleApplicator sends after reassigning them, so these fail on main although an app on main draws correctly.

The other six pass on both and hold what the geometry must still follow: bounds that grow from empty, a border edge color that turns visible, and a rectangular border's widths and layout direction, which main resolves on every draw. Each trigger is load-bearing: removing one from a drawable — the stale mark in a setter, in onBoundsChange or in onLayoutDirectionChanged, or the visibility check — fails exactly the cases that name it.

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

RNTester release builds of main and of this branch, 150 views whose background or border color changes on every frame, on a Galaxy S23 (Android 14, API 34): draw recording drops from 1.5 to 0.55 ms per frame for a rounded 1px border and from 0.77 to 0.31 ms for a background with uneven radii, and frames over the 8.33 ms budget from 13–15 to 0–3 of every 120. Settled frames are pixel-identical below the status bar. The numbers 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.

BackgroundDrawable, BackgroundImageDrawable and BorderDrawable marked
their paths stale in invalidateSelf(), so every redraw request rebuilt
their geometry on the next draw: a background or border color change,
an alpha change, a border style change, a new background image layer.
A color animation paid that rebuild for every view on every frame.

The geometry depends only on the bounds, the border radius and insets
and the layout direction, and for a border on whether any edge's color
is visible, which decides whether its inner clip is inset. The drawables
now mark it stale in their borderRadius and borderInsets setters, in
onBoundsChange and in onLayoutDirectionChanged, and BorderDrawable also
when its edges' visibility changes. A layout direction change now
rebuilds the geometry instead of waiting for the next redraw request.

BorderDrawable also resolves its border widths with the rest of its
geometry instead of on every draw, stops building an outline path
nothing reads, reuses its single-edge path across draws, and drops
setBorderRadius(property, radius), which nothing calls and which changed
the shared BorderRadiusStyle in place without reassigning it.
@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 19, 2026
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Device measurement — before / after

main — a color change rebuilds each drawable's geometry on the next draw, so with 150 views changing their border color every frame, a rounded 1px border spends 1.5 ms of every frame recording draws, and 13 to 15 of every 120 frames miss the 8.33 ms budget.

This PR — the geometry is kept: the same border records its draws in 0.55 ms and at most 3 frames of 120 miss the budget, and a background with uneven radii drops from 0.77 to 0.31 ms. The animation stage and GPU time don't move, and settled frames are pixel-identical below the status bar.

150 views, color changing every frame ms per frame main this PR this PR main
background draw recording 0.31 0.28 0.28 0.33
frame 3.58 3.46 3.54 3.60
background, radius 8 draw recording 0.39 0.31 0.31 0.41
frame 4.35 4.26 4.33 4.47
background, radii 8 and 2 draw recording 0.75 0.31 0.30 0.79
frame 4.82 4.29 4.34 4.87
1px border draw recording 0.83 0.72 0.69 0.86
frame 4.96 4.77 4.78 4.98
1px border, radius 8 draw recording 1.49 0.55 0.54 1.53
frame 6.82 5.58 5.73 6.77
frames over 8.33 ms, of 120 15 0 3 13

Galaxy S23 (Android 14, API 34, Adreno 740, 120 Hz), release arm64-v8a RNTester builds of main (6afe9f134ac) and this branch, in the order shown. Each value is the median over five 120-frame dumpsys gfxinfo framestats windows, each taken in a fresh process from a cool device with every CPU cluster at its rated maximum; the two main rounds agree within 0.04 ms on draw recording and 0.12 ms on the frame. The settled frames come from a twin of each arm whose views change color once and hold, captured from main, this branch and main again.

Scene
function AnimatedDrawableStressExample({variant}) {
  const [phase, setPhase] = useState(0);
  useEffect(() => {
    let frame = requestAnimationFrame(function step(timestamp) {
      setPhase(0.5 + 0.5 * Math.cos((timestamp / 1000) * 2 * Math.PI));
      frame = requestAnimationFrame(step);
    });
    return () => cancelAnimationFrame(frame);
  }, []);
  const color = `rgb(${Math.round(124 + 100 * phase)}, 58, 237)`;
  const animatedStyle =
    variant === 'background'
      ? {backgroundColor: color}
      : variant === 'background-rounded'
        ? {borderRadius: 8, backgroundColor: color}
        : variant === 'background-uneven'
          ? {borderRadius: 8, borderBottomRightRadius: 2, backgroundColor: color}
          : variant === 'border'
            ? {backgroundColor: '#e5e7eb', borderWidth: 1, borderColor: color}
            : {borderRadius: 8, backgroundColor: '#e5e7eb', borderWidth: 1, borderColor: color};
  return (
    <View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 12, padding: 8}}>
      {Array.from({length: 150}, (_, index) => (
        <View key={index} style={[{width: 36, height: 36}, animatedStyle]} />
      ))}
    </View>
  );
}

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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant