[Android] Animating Opacity on a view with a Shadow leaks a Java peer per frame via WrapperView.ScheduleInvalidate, exhausting the GREF budget
- Dominant language
- C#
- Stars
- 23.3k
- Forks
- 2k
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 290
Description
### Description
`ViewExtensions.UpdateOpacity` on Android calls `WrapperView.ScheduleInvalidate()` whenever the view has a `Shadow`:
```csharp
// src/Core/src/Platform/Android/ViewExtensions.cs
internal static void UpdateOpacity(this AView platformView, double opacity)
{
platformView.Alpha = (float)opacity;
if (platformView is WrapperView wrapperView && wrapperView.Shadow != null && wrapperView.IsLoaded())
{
// Post invalidation to ensure shadow redraws correctly after opacity changes.
wrapperView.ScheduleInvalidate();
}
}
```
```csharp
// src/Core/src/Platform/Android/WrapperView.cs
internal void ScheduleInvalidate()
{
Post(() => Invalidate());
}
```
`View.Post(Action)` allocates a `Java.Lang.Thread+RunnableImplementor`, and each one holds a **JNI global reference (GREF)** until it runs and disposes. There is no coalescing guard, so **every single opacity change allocates a new Java peer**.
Animating `Opacity` on a shadowed view therefore burns a GREF per animation frame. Because the default GREF budget is 51,200 (warning threshold 46,080), a continuous opacity animation on a shadowed view will exhaust the budget. Once it does, the runtime forces a full GC on every subsequent global-ref creation, which starves the main looper, which in turn stops the posted runnables from draining — so the situation is self-reinforcing rather than self-correcting.
### Impact
On a mid-size app screen with a single looping fade on one shadowed `Border`, I measured a single `WrapperView` instance accumulate **42,386 live `Java.Lang.Thread+RunnableImplementor` peers in roughly 18 seconds**, after which the count pinned at the GREF ceiling and never recovered. The app became effectively unusable: constant forced GCs, a UI thread that could no longer keep up, and background work missing its deadlines.
Note this is not ordinary short-lived churn. The peers are still alive many sample windows later, all attributable to one view.
### Scope
The trigger is narrow to state but broad to hit: **per-frame writes to `IView.Opacity` on a view whose platform view is a `WrapperView` with a non-null `Shadow`.** Two things make that far more common than it first appears.
**`Frame` opts into this by default.** `Frame.HasShadow` defaults to `true`:
```csharp
// src/Controls/src/Core/Frame/Frame.cs
public static readonly BindableProperty HasShadowProperty =
BindableProperty.Create(nameof(HasShadow), typeof(bool), typeof(Frame), BooleanBoxes.TrueBox);
```
A developer animating opacity on a `Frame` therefore triggers this without ever writing `Shadow=` or knowing a shadow is involved. The same applies to any style that sets `Shadow` as part of a shared card or panel look.
**Skeleton and shimmer placeholders are the pattern most likely to hit it.** The canonical hand-rolled loading placeholder is a forever-looping fade:
```csharp
while (_loading)
{
await placeholder.FadeToAsync(0.3, 800, Easing.SinInOut);
await placeholder.FadeToAsync(1.0, 800, Easing.SinInOut);
}
```
That is worse than the single-view repro below in two ways: placeholders are usually rendered as a **list** of rows or cards, so N shadowed views each allocate a peer per frame and the budget is consumed N times faster; and loading states are entered repeatedly over a session, each entry starting fresh loops. A list of 20 shadowed skeleton rows at 60fps is on the order of 1,200 Java peers per second, reaching the 46,080 threshold in well under a minute.
For triage: shimmer implementations that animate a **drawable** rather than view opacity are unaffected. Only per-frame `IView.Opacity` writes reach this code path.
### Steps to Reproduce
1. Create a new .NET MAUI app.
2. Put a `Border` with a `Shadow` on a page:
```xml
```
3. Give it a shadow and loop an opacity animation on the **border itself**:
```csharp
protected override async void OnAppearing()
{
base.OnAppearing();
ShadowedBorder.Shadow = new Shadow
{
Brush = Brush.CornflowerBlue,
Offset = new Point(0, 0),
Radius = 18,
Opacity = 0.7f
};
while (true)
{
await ShadowedBorder.FadeToAsync(0.78, 425, Easing.CubicInOut);
await ShadowedBorder.FadeToAsync(1.0, 425, Easing.CubicInOut);
}
}
```
4. Deploy to an Android device or emulator and leave the page open.
5. Watch logcat.
### Expected Behavior
Animating `Opacity` on a view with a `Shadow` redraws the shadow correctly without allocating an unbounded number of Java peers. Invalidation should be coalesced, or performed with a primitive that requires no managed peer at all.
### Actual Behavior
logcat fills with GREF pressure warnings:
```
JNI GlobalReferenceTable ... 46080 entries
... outstanding GREFs ...
```
and the live `RunnableImplementor` peer count climbs until the budget is exhausted, then pins there permanently.
You can confirm the attribution with a debug-only probe that histograms the surfaced peers and reflects the wrapped delegate out of each `RunnableImplementor`:
```csharp
foreach (var surfaced in JniRuntime.CurrentRuntime.ValueManager.GetSurfacedPeers())
{
if (!surfaced.SurfacedPeer.TryGetTarget(out var peer) || peer is null)
continue;
var type = peer.GetType();
if (type.FullName?.Contains("RunnableImplementor") != true)
continue;
// Reflect the private Action field to name the originating call site.
// Reports: Microsoft.Maui.Platform.WrapperView.b__19_0 [WrapperView#]
}
```
In my run this reported a single target instance:
```
RUNNABLE ORIGINS | 42386x Microsoft.Maui.Platform.WrapperView.b__19_0 [WrapperView#55330618]
```
The repeated single `[WrapperView#55330618]` identity is what distinguishes one runaway view from diffuse allocation churn.
### Workaround
Do not animate `Opacity` on a view that has a `Shadow`. Moving the opacity animation onto an inner child (leaving the shadowed `Border` to animate only `Scale`) avoids `UpdateOpacity` reaching the `WrapperView`, and in my measurements took GREF warnings from 519 to 0 over an identical 3-minute run, with live peers flat at ~3,400 instead of ~45,700.
### Suggested Fix
Either:
1. Use `PostInvalidateOnAnimation()` (or `PostInvalidate()`) instead of `Post(() => Invalidate())`. These are native, already coalescing, and allocate no managed `Runnable` peer — this seems like the right primitive here. Or
2. Guard `ScheduleInvalidate()` with a flag cleared inside the posted action, so at most one invalidation is ever in flight:
```csharp
bool _invalidateScheduled;
internal void ScheduleInvalidate()
{
if (_invalidateScheduled)
return;
_invalidateScheduled = true;
Post(() =>
{
_invalidateScheduled = false;
Invalidate();
});
}
```
### Regression
Introduced by #30379 (merged 2026-03-21), which fixed #29764 (shadows disappearing after opacity is set to 0). That fix is correct in intent; the problem is that the invalidation is posted unconditionally and allocates a peer each time.
Possibly related: #18757, `View.Post(Action)` leaking the action and its `RunnableImplementor`.
### Version with bug
.NET 11 preview (SDK `11.0.100-preview.7.26381.103`), `Microsoft.Android.Ref.36` 36.1.69. The affected code is still present on `main`.
### Affected platforms
Android
### Did you find any workaround?
Yes — see the Workaround section above.
Contributor guide
Research direction
Start with src/Core/src/Platform/Android/ViewExtensions.cs and src/Core/src/Platform/Android/WrapperView.cs, then reproduce the shadowed Border opacity animation on Android while watching GREF warnings. Compare the invalidation approaches described in the issue and verify that repeated opacity updates no longer create unbounded RunnableImplementor peers while the shadow still redraws correctly.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, csharp
- Domain
- mobile-dev, performance
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100