dotnet / dotnet/maui

[Android] Animating Opacity on a view with a Shadow leaks a Java peer per frame via WrapperView.ScheduleInvalidate, exhausting the GREF budget

Open
#38,003 0 comments 1 reaction 0 assignees View on GitHub
area-controls-border platform/android
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.