Repository navigation
Rebuild Android background and border geometry only when it changes - #59189
YevheniiKotyrlo wants to merge 1 commit into
Conversation
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.
Device measurement — before / after
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.
Galaxy S23 (Android 14, API 34, Adreno 740, 120 Hz), release Scenefunction 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>
);
} |
Summary:
BackgroundDrawable,BackgroundImageDrawableandBorderDrawableoverrideinvalidateSelf()to mark their geometry stale, so every redraw request rebuilds their paths, rects and resolved radii on the nextdraw(): 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
borderRadiusandborderInsetssetters, inonBoundsChangeand inonLayoutDirectionChanged, the same shape as #59081 for the box-shadow drawables, andBorderDrawablealso rebuilds when its edges' visibility changes.BackgroundStyleApplicatoralready reassigns the sharedBorderRadiusStyleandBorderInsetsafter 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.BorderDrawablealso 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 dropssetBorderRadius(property, radius), which nothing calls and which changed the sharedBorderRadiusStylein 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 onmain:TooManyActualInvocationsonmain), and one that a rectangular border hands the canvas the samePathon every draw;maindoes only at the next redraw request;mainrebuilds only on the redraw requestBackgroundStyleApplicatorsends after reassigning them, so these fail onmainalthough an app onmaindraws 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
mainresolves on every draw. Each trigger is load-bearing: removing one from a drawable — the stale mark in a setter, inonBoundsChangeor inonLayoutDirectionChanged, or the visibility check — fails exactly the cases that name it../gradlew :packages:react-native:ReactAndroid:testDebugUnitTest -PenableWarningsAsErrors=true: 784 tests, 0 failuresnode ./scripts/format-kotlin.js --check: cleanRNTester release builds of
mainand 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.