Repository navigation
Stop rebuilding Android box-shadow geometry on every draw - #59081
YevheniiKotyrlo wants to merge 3 commits into
Conversation
Device measurement — before / afterDraw recording per frame — the UI thread's
Mi MIX 3 (Android 10, API 29, Adreno 630, 60 Hz) and Galaxy S23 (Android 14, API 34, Adreno 740), RNTester release builds of Scenefunction 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>
);
}
A density change and the RTL left/right swap
At density 600 and with the swap off, this PR's stills are byte-identical to Scenefunction 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 |
4b6e7fa to
ad615d5
Compare
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.
ad615d5 to
cdf7b6b
Compare
|
@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
left a comment
There was a problem hiding this comment.
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
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.
Visual regressionNo visual differences at ios: no capture |
|
The previous head kept the geometry it had built at the old density, or with the old |



Summary:
InsetBoxShadowDrawable.draw()andOutsetBoxShadowDrawable.draw()derive their clip and shadow paths, rects and resolved radii every time they run, anddraw()runs on every invalidation of the view's background. A background that animates therefore allocates a fresh set ofPath,RectF,FloatArrayandComputedBorderRadiusobjects 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: theborderRadiusandborderInsetssetters,onBoundsChangeandonLayoutDirectionChangedmark them stale, and since nothing notifies a drawable of a density change or ofswapLeftAndRightInRTL(),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, becauseBackgroundStyleApplicatorreassigns the sharedBorderRadiusStyleto each shadow after mutating it. An alpha or color filter change redraws without rebuilding them, whereBackgroundDrawableandBorderDrawablemark their paths stale on everyinvalidateSelf(). The cache holds two or threeRectFs, one or twoPaths 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'sPathandRectFinstances; they fail onmain. 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 failuresjava -jar node_modules/ktfmt/lib/ktfmt.jar --do-not-remove-unused-imports --dry-run --set-exit-if-changedon the changed files: cleanRNTester release builds of
mainand 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 forinset 0 0 0 1pxand from 5.5 to 2.7 ms for0 0 0 1pxon 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. Withdensityin RNTester'sandroid:configChanges, throughwm density 600andI18nManager.swapLeftAndRightInRTL()on the S23, this branch draws whatmaindraws. 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.