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, and the layout direction. 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. 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 now insets its inner clip by the border widths whatever its edge colors. It used to inset it only while an edge was visible, and nothing is drawn while none is. It also resolves its border widths with the rest of its geometry instead of on every draw, and reuses its single-edge path across draws. It stops building an outline path nothing reads, which was the only geometry setBorderWidth's widths fed. And it 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 into a bitmap, and compares the calls and the pixels with those of a freshly built drawable in the same state. Five of its cases draw a border of one width and one color, which BorderDrawable strokes along a single path. 17 of its 25 cases fail on main:

  • five 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;
  • four assert that a switch to RTL rebuilds the rounded geometry, which main does only at the next redraw request;
  • seven 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 eight pass on both and hold what the geometry must still follow:

  • bounds that grow from empty;
  • a border edge color that turns visible;
  • a rectangular border's widths and layout direction, which main resolves on every draw;
  • a border's width set through setBorderWidth.

Each trigger is load-bearing: removing one from a drawable — the stale mark in a setter, in onBoundsChange or in onLayoutDirectionChanged — fails exactly the cases that name it.

RecordingCanvas.kt is the file #59097 adds, byte for byte, so whichever of the two lands first carries it.

  • ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest -PenableWarningsAsErrors=true --tests … over the nine classes that reach these drawables (BackgroundAndBorderDrawableTest, BorderDrawableTest, CompositeBackgroundDrawableTest, BackgroundStyleApplicatorTest, ReactSwitchManagerTest, and the image, scroll view, text and text input property tests): 83 tests, 0 failures
  • ktfmt over the changed files, with the jar and flags of node ./scripts/format-kotlin.js --check: clean

RNTester release builds of main and of this change at c2207dbfbe7, 150 views whose background or border color changes on every frame, on a Galaxy S23 (Android 14, API 34). Draw recording drops from 1.49–1.53 to 0.54–0.55 ms per frame for a rounded 1px border and from 0.75–0.79 to 0.30–0.31 ms for a background with uneven radii, and the median window's frames over the 8.33 ms budget from 13–15 to 0–3 of 120. Settled frames are pixel-identical below the status bar. The numbers, the scene and how to rerun them are in the evidence comment.

I didn't run the rest of the ReactAndroid unit tests, 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 19, 2026
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor Author

Galaxy S23: 150 views changing their background or border color every frame

In each build's two rounds, for border-rounded, a 1px border with radius 8, draw recording drops from 1.49 and 1.53 ms per frame on main to 0.55 and 0.54 ms, the frame from 6.82 and 6.77 to 5.58 and 5.73 ms, and the frames over the 8.33 ms budget from 15 and 13 of 120 (7–21 across the windows) to 0 and 3 (0–5). For background-uneven, radii 8 and 2, draw recording drops from 0.75 and 0.79 to 0.31 and 0.30 ms. On the mean of each build's two rounds, the animation, GPU-issue and swap stages move by at most 0.05 ms on every variant. Settled frames are pixel-identical below the status bar.

The measured commit, c2207dbfbe7, is an earlier head of this PR. The head, 5452a3dc881, was not measured; it differs from c2207dbfbe7 only in BorderDrawable, where it drops a per-draw check of the four edge colors' alpha and a stale mark in setBorderWidth, which these scenes don't call.

variant, 150 views ms per frame main c2207dbfbe7 c2207dbfbe7 main
background draw recording 0.31 (0.29–0.32) 0.28 (0.25–0.28) 0.28 (0.27–0.30) 0.33 (0.31–0.33)
frame 3.58 (3.53–3.64) 3.46 (3.44–3.56) 3.54 (3.45–3.56) 3.60 (3.53–3.66)
background-rounded draw recording 0.39 (0.37–0.40) 0.31 (0.29–0.31) 0.31 (0.30–0.33) 0.41 (0.39–0.43)
frame 4.35 (4.23–4.48) 4.26 (4.22–4.29) 4.33 (4.10–4.36) 4.47 (4.41–4.49)
background-uneven draw recording 0.75 (0.72–0.78) 0.31 (0.30–0.33) 0.30 (0.29–0.32) 0.79 (0.74–0.79)
frame 4.82 (4.71–4.86) 4.29 (4.21–4.33) 4.34 (4.26–4.45) 4.87 (4.67–4.95)
border draw recording 0.83 (0.80–0.86) 0.72 (0.68–0.74) 0.69 (0.66–0.77) 0.86 (0.85–0.89)
frame 4.96 (4.77–4.98) 4.77 (4.66–4.84) 4.78 (4.71–5.01) 4.98 (4.92–5.03)
border-rounded draw recording 1.49 (1.48–1.60) 0.55 (0.53–0.60) 0.54 (0.52–0.58) 1.53 (1.51–1.57)
frame 6.82 (6.63–6.83) 5.58 (5.57–5.80) 5.73 (5.67–6.07) 6.77 (6.54–6.88)
frames over 8.33 ms, of 120 15 (13–21) 0 (0–2) 3 (0–5) 13 (7–18)

Release arm64-v8a RNTester builds (./gradlew :packages:rn-tester:android:app:assembleRelease -PreactNativeArchitectures=arm64-v8a) of main (6afe9f134ac) and c2207dbfbe7, each with the scene below added to ViewExample, on a Galaxy S23 (Android 14, API 34, Adreno 740, 120 Hz). The columns are the rounds, in run order. Each cell is the median of five windows, with their range in parentheses.

Each window runs adb shell am force-stop com.facebook.react.uiapp, waits until dumpsys thermalservice reports no throttling, every CPU cluster's scaling_max_freq is back at its cpuinfo_max_freq and the hottest /sys/class/thermal/thermal_zone*/temp other than Qualcomm's lmh zones is at most 44 °C, then runs adb shell am start -a android.intent.action.VIEW -d rntester://example/ViewExample/drawable-animated-<variant> -p com.facebook.react.uiapp, waits 2 s, runs adb shell dumpsys gfxinfo com.facebook.react.uiapp reset, waits 3 s and reads the 120 frames adb shell dumpsys gfxinfo com.facebook.react.uiapp framestats prints, none of which carried a non-zero Flags. A window counts only if every cluster's scaling_max_freq, sampled every 0.2 s from the example opening to that read, held at its cpuinfo_max_freq; all 100 windows (five variants, four rounds, five windows each) did. Draw recording is DrawStart to SyncQueued and the frame IntendedVsync to FrameCompleted; animation is AnimationStart to PerformTraversalsStart, GPU issue IssueDrawCommandsStart to SwapBuffers and swap SwapBuffers to FrameCompleted. The two main rounds, the noise floor, agree within 0.04 ms on draw recording and 0.12 ms on the frame.

Each variant's settled twin, drawable-static-<variant>, comes from a second pair of builds of the same commits and passes settles, which sets the color once and requests no further frame. Screenshots taken with adb exec-out screencap -p 3 s after it opens, from main, c2207dbfbe7 and main again, compared pixel by pixel, differ only in pixel rows 35–68, the status bar's clock and icons.

Scene
function AnimatedDrawableStressExample({variant, settles = false}) {
  const [phase, setPhase] = useState(0);
  useEffect(() => {
    let frame = requestAnimationFrame(function step(timestamp) {
      if (settles) {
        setPhase(1);
        return;
      }
      setPhase(0.5 + 0.5 * Math.cos((timestamp / 1000) * 2 * Math.PI));
      frame = requestAnimationFrame(step);
    });
    return () => cancelAnimationFrame(frame);
  }, [settles]);
  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>
  );
}

Each variant is one entry of ViewExample's examples, {title: 'Drawable Animated: <variant>', name: 'drawable-animated-<variant>', render: () => <AnimatedDrawableStressExample variant="<variant>" />}, and its twin {title: 'Drawable Settled: <variant>', name: 'drawable-static-<variant>', render: () => <AnimatedDrawableStressExample variant="<variant>" settles={true} />}.

@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the perf/android-background-border-drawable-invalidate branch 2 times, most recently from 1c6d94e to e0615e8 Compare November 11, 2026 17:31
YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-1 that referenced this pull request Nov 11, 2026
RecordingCanvas.kt is the file react#59189 adds for the background and border
drawable tests, byte for byte, so whichever of the two lands first
carries it. Those drawables also call translate, drawRect(left, top,
right, bottom) and drawRoundRect, and draw borders with stroke paints,
so the canvas records those calls and each paint's style and width.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the perf/android-background-border-drawable-invalidate branch from e0615e8 to f9cfc71 Compare November 11, 2026 20:18
YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-1 that referenced this pull request Nov 11, 2026
RecordingCanvas.kt is the file react#59189 adds for the background and border
drawable tests, byte for byte, so whichever of the two lands first
carries it. Those drawables also call translate, drawRect(left, top,
right, bottom) and drawRoundRect, and draw borders with stroke paints,
so the canvas records those calls and each paint's style and width.
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. The drawables now mark it stale in their
borderRadius and borderInsets setters, in onBoundsChange and in
onLayoutDirectionChanged. A layout direction change now rebuilds the
geometry instead of waiting for the next redraw request.

BorderDrawable now insets its inner clip by the border widths whatever
its edge colors. It used to inset it only while an edge was visible,
and nothing is drawn while none is. It also resolves its border widths
with the rest of its geometry instead of on every draw, reuses its
single-edge path across draws, and stops building an outline path
nothing reads, the only geometry setBorderWidth's widths fed. It drops
setBorderRadius(property, radius), which nothing calls and which changed
the shared BorderRadiusStyle in place without reassigning it.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the perf/android-background-border-drawable-invalidate branch from f9cfc71 to 5452a3d Compare November 11, 2026 23:30
YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-1 that referenced this pull request Nov 11, 2026
RecordingCanvas.kt is the file react#59189 adds for the background and border
drawable tests, byte for byte, so whichever of the two lands first
carries it. Those drawables also call translate, drawRect(left, top,
right, bottom) and drawRoundRect, and draw borders with stroke paints,
so the canvas records those calls and each paint's style and width.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review November 12, 2026 06:35
@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 Nov 12, 2026
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft November 13, 2026 17:51
YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-1 that referenced this pull request Nov 13, 2026
An animated box shadow hands BackgroundStyleApplicator.setBoxShadow a
list of the same shape with new values on every frame, and setBoxShadow
answered each one with new shadow drawables, a new
CompositeBackgroundDrawable and a new view background, where
setOutlineColor sets the color on the existing OutlineDrawable.

Both shadow drawables now take their color, offsets, blur radius and
spread as properties that invalidate only on a change, and setBoxShadow
hands the existing drawables their new values when the list keeps the
same number of inset and outset shadows. A color change keeps the cached
geometry, so a blurred inset shadow keeps Skia's cached mask while its
color animates. The mask filter is rebuilt when the blur radius or the
screen density changes, as a new drawable would build it.
CompositeBackgroundDrawable types its shadow lists by kind, so the
applicator updates them without casts.

The tests draw through RecordingCanvas, the file react#59189 adds for the
background and border drawable tests, byte for byte, so whichever of the
two lands first carries it.
YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-1 that referenced this pull request Nov 13, 2026
An animated box shadow hands BackgroundStyleApplicator.setBoxShadow a
list of the same shape with new values on every frame, and setBoxShadow
answered each one with new shadow drawables, a new
CompositeBackgroundDrawable and a new view background, where
setOutlineColor sets the color on the existing OutlineDrawable.

Both shadow drawables now take their color, offsets, blur radius and
spread together through setShadowValues, which invalidates the drawable
once, and only when one of them changes, and setBoxShadow hands the
existing drawables their new values when the list keeps the same number
of inset and outset shadows. A color change keeps the cached geometry,
so a blurred inset shadow keeps Skia's cached mask while its color
animates. The mask filter is rebuilt when the blur radius or the screen
density changes, as a new drawable would build it.
CompositeBackgroundDrawable types its shadow lists by kind, so the
applicator updates them without casts.

The tests draw through RecordingCanvas, the file react#59189 adds for the
background and border drawable tests, byte for byte, so whichever of the
two lands first carries it.

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