Description
Description
When repeatedly displaying and destroying a ContentView that contains custom View<T> handlers (specifically a ViewHandler<TVirtual, TPlatform> whose TPlatform is a custom LinearLayout subclass), the native Android View instances are never garbage-collected, even after:
- Recursive
MauiView.DisconnectHandlers() from the root
- Explicit
Java.Lang.Object.Dispose() on the platform view (deferred via Handler.MainLooper.PostDelayed to avoid layout-pass race conditions)
View.setBackground(null) to break Drawable callback cycles
- Multiple
GC.Collect() + GC.WaitForPendingFinalizers() cycles
- ART full GC via
adb shell am send-trim-memory <pkg> COMPLETE
Eclipse MAT analysis of the heap dump shows that each leaked native view has GC root: JNI Global, indicating the Mono runtime peer is never released — even when Dispose() was demonstrably called (verified via diagnostic Interlocked counters: scheduled == completed).
Reproduction steps
- Create a
ViewHandler<TElement, CustomLinearLayout> where CustomLinearLayout : LinearLayout is a custom Java type registered via [Register].
- In
ConnectHandler, inflate the platform view and obtain inner views via _root.GetChildAt(i) as ImageView.
- Open a
ContentView page that creates ~15-20 instances of this handler in a Grid.
- Close the page (via standard MAUI navigation), call
view.DisconnectHandlers() recursively, run GC.Collect().
- Repeat 10×.
- Force a full ART GC:
adb shell am send-trim-memory <pkg> COMPLETE.
- Take a heap dump:
adb shell am dumpheap <pkg> /data/local/tmp/heap.hprof.
Expected behavior
After DisconnectHandlers() + GC + Force ART GC, the native Java View instances should be eligible for collection. dumpsys meminfo Views count should return to baseline.
Actual behavior
After 10 cycles + Force GC:
dumpsys meminfo Views: 1098 (baseline ~200, so ~900 zombie native Views)
- Heap dump shows ~170 zombie
CustomLinearLayout instances with JNI Global GC root path
- Each zombie holds ~5-10 child views (
MaterialTextView, AppCompatImageView, etc.) via mChildren
- Per-cycle leak: ~86 native Views, +1.5 MB PSS
Diagnostic evidence
Live count of managed objects (Interlocked counters in ctor/finalizer)
After 10 cycles:
Form (ContentView): live oscillates 1↔2 ✓ correctly GC'd
EditableForm (ContentView): live oscillates 2↔4 ✓
- Custom toolbar
ContentView: live oscillates 0↔1 ✓
- All managed C# objects ARE finalized correctly.
Eclipse MAT — Path to GC Roots (excluding weak/soft/phantom refs) for one zombie CustomLinearLayout:
CustomLinearLayout @ 0x12d502e8 GC root: JNI Global
↓ mChildren View[]
└ MaterialTextView @ 0x...
└ MaterialTextView @ 0x...
└ AppCompatImageView @ 0x...
The only GC root path is JNI Global — meaning Mono runtime holds a global JNI reference that was never released.
Histogram of leaked instances after 10 cycles + Force GC
| Class |
Instance count |
android.graphics.RenderNode |
1414 |
android.view.ViewAnimationHostBridge |
1414 |
androidx.appcompat.widget.AppCompatBackgroundHelper |
626 |
androidx.appcompat.widget.AppCompatTextHelper |
455 |
androidx.emoji2.viewsintegration.EmojiTextViewHelper (5 variants) |
454 each |
com.google.android.material.textview.MaterialTextView |
342 |
crc6431bef388e7f7f9bd.CustomLinearLayout (custom platform view) |
171 |
mono.android.view.View_OnFocusChangeListenerImplementor |
280 |
Diagnostic counter proof that Dispose() was called
FSK - EnhancedBtn dispose scheduled, total=156
...
FSK - EnhancedBtn dispose completed, total=156
156 platform-view peers explicitly called Java.Lang.Object.Dispose(), yet 171 of them remain in the heap with JNI Global GC root.
Workarounds attempted (none reduced leak)
| Attempt |
Result |
view.DisconnectHandlers() recursively from root |
No effect on count |
_root.Background = null (break Drawable.callback cycle) |
No effect |
_image.SetImageDrawable(null) |
No effect |
_root.OnFocusChangeListener = null |
No effect |
_root.Dispose() synchronously in DisconnectHandler |
Crashes with JNI ERROR (deleted reference) race condition with pending layout pass |
_root.Dispose() via View.PostDelayed(200ms) |
Sometimes never executes (runnable cancelled when view detached from window) |
_root.Dispose() via Handler(Looper.MainLooper).PostDelayed(200ms) |
Confirmed runs (verified via counter) — but zombie count unchanged |
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); after disconnect |
No effect |
The fact that Dispose() runs successfully but the JNI Global Ref persists suggests Mono.Android.ManagedValueManager (or similar internal cache) holds a strong reference to the Mono peer outside the public Dispose() contract.
Environment
- .NET version: 10.0
- Microsoft.Maui.Controls: 10.0.11
- TargetFramework:
net10.0-android
- Android API: 34 (emulator + physical Pixel 6 Pro)
- SupportedOSPlatformVersion: 24.0
Heap dump
heap.hprof is attached (51 MB). Filter MAT histogram for crc6431bef388e7f7f9bd.CustomLinearLayout to see the leaked custom platform view; right-click → Path to GC Roots → exclude weak/soft/phantom shows JNI Global as the only GC root.
Impact
- Memory grows ~1.5 MB per page open/close cycle in our app.
- After ~100 cycles the app reaches risky memory pressure on low-end devices.
- Occasional
JNI ERROR (deleted reference) crashes occur during teardown when the JNI table is heavily fragmented.
How to capture the diagnostic files (for reproduction)
memory_watch.txt — per-second dumpsys meminfo
Run this PowerShell loop in parallel with the app:
$package = "com.package.xyz" # replace with your package
$out = "$env:USERPROFILE\Desktop\memory_watch.txt"
Remove-Item $out -ErrorAction SilentlyContinue
while ($true) {
"====================" | Tee-Object $out -Append
Get-Date | Tee-Object $out -Append
adb shell dumpsys meminfo $package |
Select-String "TOTAL PSS|TOTAL RSS|Native Heap|Dalvik Heap|Java Heap|Graphics|Views|Activities|AppContexts|Objects" |
ForEach-Object { $_.Line } |
Tee-Object $out -Append
Start-Sleep -Seconds 1
}
Stop the loop (Ctrl+C) once the 10-cycle reproduction is complete.
heap.hprof — Java heap dump after Force ART GC
$package = "com.package.xyz" # replace with your package
# Step 1: send the app to background so we can request COMPLETE-level trim
adb shell input keyevent KEYCODE_HOME
Start-Sleep -Seconds 2
# Step 2: force a full ART GC (frees everything reachable only via dead refs)
adb shell am send-trim-memory $package COMPLETE
Start-Sleep -Seconds 5
# Step 3: dump the Java heap and pull it locally
adb shell am dumpheap $package /data/local/tmp/heap.hprof
adb pull /data/local/tmp/heap.hprof
# Optional: confirm Views count after Force GC
adb shell dumpsys meminfo $package | Select-String "Views:"
The dump captured this way reflects the post-GC steady state — anything still in the heap is genuinely retained, not waiting for collection. Open heap.hprof in Eclipse MAT (older versions may need hprof-conv heap.hprof heap_std.hprof from Android SDK platform-tools first).
Attached diagnostic files
-
memory_watch.txt — output of a script that runs dumpsys meminfo <pkg> once per second over the whole reproduction window. Each block contains TOTAL PSS, Native Heap, Java Heap, and Views count. Shows the linear growth of Views (~86 per cycle) and PSS (~1.5 MB per cycle) across 10 open/close cycles, with no recovery after Force GC.
-
heap.zip — zipped Android heap.hprof (51 MB → ~12 MB compressed) captured after the 10-cycle reproduction + adb shell am send-trim-memory <pkg> COMPLETE (full ART GC). Open with Eclipse MAT (must run hprof-conv first if using older MAT versions). Histogram filter crc6431bef388e7f7f9bd.CustomLinearLayout shows the 186 leaked custom platform-view instances; right-click → Merge Shortest Paths to GC Roots → exclude weak/soft/phantom references reproduces the screenshot above.
Eclipse MAT — Path to GC Roots for CustomLinearLayout instances (excluding weak/soft/phantom refs).
All 186 zombie instances have JNI Global as the GC root, holding MaterialTextView and other child views via mChildren.
Minimal reproduction code
The following sanitized excerpts (extracted from our production app) are sufficient to reproduce the leak in a fresh dotnet new maui project. Drop these files into the appropriate folders, register the handler in MauiProgram.cs, then navigate back-and-forth between two pages.
1. Controls/EnhancedButton.cs (MAUI cross-platform control)
using Microsoft.Maui.Controls;
namespace JniLeakRepro.Controls;
// Custom Button subclass with extra bindable properties (icon image + text + description).
// Concrete properties trimmed for brevity.
public class EnhancedButton : Button
{
public static readonly BindableProperty ImageSourceNameProperty =
BindableProperty.Create(nameof(ImageSourceName), typeof(string), typeof(EnhancedButton), "");
public new string ImageSourceName
{
get => (string)GetValue(ImageSourceNameProperty);
set => SetValue(ImageSourceNameProperty, value);
}
}
2. Platforms/Android/CustomLinearLayout.cs (Java/native subclass)
using Android.Content;
using Android.Util;
using Android.Widget;
namespace JniLeakRepro.Platforms.Android;
// Java type generated as ACW: crc<hash>.CustomLinearLayout
// Hash prefix is what shows up in Eclipse MAT histogram.
public class CustomLinearLayout : LinearLayout
{
public CustomLinearLayout(Context? context) : base(context) { }
public CustomLinearLayout(Context? context, IAttributeSet attrs) : base(context, attrs) { }
}
3. Platforms/Android/Resources/layout/EnhancedButton.axml
<?xml version="1.0" encoding="utf-8"?>
<JniLeakRepro.Platforms.Android.CustomLinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/image"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/text"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/description"/>
</JniLeakRepro.Platforms.Android.CustomLinearLayout>
4. Platforms/Android/EnhancedButtonHandler.cs (the handler that leaks)
using Android.Content;
using Android.Views;
using Android.Widget;
using JniLeakRepro.Controls;
using JniLeakRepro.Platforms.Android;
using Microsoft.Maui.Handlers;
namespace JniLeakRepro.Platforms.Android;
public class EnhancedButtonHandler : ViewHandler<EnhancedButton, CustomLinearLayout>
{
private ImageView? _image;
private TextView? _text;
private TextView? _description;
private CustomLinearLayout? _root;
public static readonly IPropertyMapper<EnhancedButton, EnhancedButtonHandler> Mapper
= new PropertyMapper<EnhancedButton, EnhancedButtonHandler>(ViewMapper)
{
[nameof(EnhancedButton.Text)] = (h, b) => h.ApplyText(),
};
public EnhancedButtonHandler() : base(Mapper) { }
protected override CustomLinearLayout CreatePlatformView()
{
var inflater = Context.GetSystemService(Context.LayoutInflaterService) as LayoutInflater
?? throw new InvalidOperationException();
_root = (CustomLinearLayout)inflater.Inflate(Resource.Layout.EnhancedButton, null)!;
return _root;
}
protected override void ConnectHandler(CustomLinearLayout platformView)
{
base.ConnectHandler(platformView);
// Obtain inner views via GetChildAt - these are auto-wrapped by Mono peers.
_image = _root!.GetChildAt(0) as ImageView;
_text = _root!.GetChildAt(1) as TextView;
_description = _root!.GetChildAt(2) as TextView;
}
protected override void DisconnectHandler(CustomLinearLayout platformView)
{
base.DisconnectHandler(platformView);
// Per MAUI docs base.DisconnectHandler nulls handler.PlatformView,
// but Mono peer for `_root` keeps its JNI Global Ref.
// Adding `_root.Dispose()` here causes JNI race-crash with pending layout pass.
}
void ApplyText()
{
if (_text != null) _text.Text = VirtualView?.Text ?? "";
}
}
5. MauiProgram.cs (handler registration)
builder.ConfigureMauiHandlers(handlers =>
{
handlers.AddHandler<EnhancedButton, EnhancedButtonHandler>();
});
6. MainPage.xaml (host page with cyclic navigation)
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:c="clr-namespace:JniLeakRepro.Controls"
x:Class="JniLeakRepro.MainPage">
<Grid x:Name="Root" RowDefinitions="*,Auto" Padding="20">
<Grid x:Name="ButtonHost" Grid.Row="0" RowSpacing="4"
ColumnDefinitions="*,*,*,*,*"
RowDefinitions="*,*,*,*"/>
<Button Grid.Row="1" Text="Open then close 10x" Clicked="OnRunCycles"/>
</Grid>
</ContentPage>
7. MainPage.xaml.cs (reproduces the leak)
using Microsoft.Maui.Platform; // for DisconnectHandlers()
using JniLeakRepro.Controls;
namespace JniLeakRepro;
public partial class MainPage : ContentPage
{
public MainPage() => InitializeComponent();
async void OnRunCycles(object sender, EventArgs e)
{
for (int cycle = 0; cycle < 10; cycle++)
{
// Add 20 EnhancedButtons (simulating opening a "doklad" page)
for (int i = 0; i < 20; i++)
{
var btn = new EnhancedButton { Text = $"Btn {i}" };
Grid.SetRow(btn, i / 5);
Grid.SetColumn(btn, i % 5);
ButtonHost.Children.Add(btn);
}
await Task.Delay(300);
// Disconnect all handlers + clear (simulating closing the "doklad")
foreach (var child in ButtonHost.Children.OfType<View>().ToList())
child.DisconnectHandlers();
ButtonHost.Children.Clear();
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
await Task.Delay(300);
}
}
}
After running OnRunCycles once and then forcing ART full GC + heap dump (commands above), Eclipse MAT will show ~180+ leaked crc<hash>.CustomLinearLayout instances all rooted at JNI Global, with no path back to managed application code.
Suggested fix direction
Either:
Java.Lang.Object.Dispose() should reliably remove the peer from JniRuntime.JniValueManager.RegisteredInstances and call JNIEnv.DeleteGlobalRef.
MauiContext.Services (or whichever per-page IServiceProvider holds handler references) should be disposed when the page is destroyed, so its strong references to platform views are released.
ViewHandler.DisconnectHandler() should also dispose the _platformView peer, not only null the field.
Steps to Reproduce
No response
Link to public reproduction project repository
No response
Version with bug
10.0.20
Is this a regression from previous behavior?
Not sure, did not test other versions
Last version that worked well
Unknown/Other
Affected platforms
Android
Affected platform versions
All Android versions, that I tried
Did you find any workaround?
No response
Relevant log output
Description
Description
When repeatedly displaying and destroying a
ContentViewthat contains customView<T>handlers (specifically aViewHandler<TVirtual, TPlatform>whoseTPlatformis a customLinearLayoutsubclass), the native AndroidViewinstances are never garbage-collected, even after:MauiView.DisconnectHandlers()from the rootJava.Lang.Object.Dispose()on the platform view (deferred viaHandler.MainLooper.PostDelayedto avoid layout-pass race conditions)View.setBackground(null)to break Drawable callback cyclesGC.Collect()+GC.WaitForPendingFinalizers()cyclesadb shell am send-trim-memory <pkg> COMPLETEEclipse MAT analysis of the heap dump shows that each leaked native view has GC root: JNI Global, indicating the Mono runtime peer is never released — even when
Dispose()was demonstrably called (verified via diagnosticInterlockedcounters:scheduled == completed).Reproduction steps
ViewHandler<TElement, CustomLinearLayout>whereCustomLinearLayout : LinearLayoutis a custom Java type registered via[Register].ConnectHandler, inflate the platform view and obtain inner views via_root.GetChildAt(i) as ImageView.ContentViewpage that creates ~15-20 instances of this handler in aGrid.view.DisconnectHandlers()recursively, runGC.Collect().adb shell am send-trim-memory <pkg> COMPLETE.adb shell am dumpheap <pkg> /data/local/tmp/heap.hprof.Expected behavior
After
DisconnectHandlers()+ GC + Force ART GC, the native JavaViewinstances should be eligible for collection.dumpsys meminfoViews count should return to baseline.Actual behavior
After 10 cycles + Force GC:
dumpsys meminfo Views: 1098 (baseline ~200, so ~900 zombie native Views)CustomLinearLayoutinstances withJNI GlobalGC root pathMaterialTextView,AppCompatImageView, etc.) viamChildrenDiagnostic evidence
Live count of managed objects (Interlocked counters in ctor/finalizer)
After 10 cycles:
Form(ContentView): live oscillates 1↔2 ✓ correctly GC'dEditableForm(ContentView): live oscillates 2↔4 ✓ContentView: live oscillates 0↔1 ✓Eclipse MAT — Path to GC Roots (excluding weak/soft/phantom refs) for one zombie
CustomLinearLayout:The only GC root path is
JNI Global— meaning Mono runtime holds a global JNI reference that was never released.Histogram of leaked instances after 10 cycles + Force GC
android.graphics.RenderNodeandroid.view.ViewAnimationHostBridgeandroidx.appcompat.widget.AppCompatBackgroundHelperandroidx.appcompat.widget.AppCompatTextHelperandroidx.emoji2.viewsintegration.EmojiTextViewHelper(5 variants)com.google.android.material.textview.MaterialTextViewcrc6431bef388e7f7f9bd.CustomLinearLayout(custom platform view)mono.android.view.View_OnFocusChangeListenerImplementorDiagnostic counter proof that
Dispose()was called156 platform-view peers explicitly called
Java.Lang.Object.Dispose(), yet 171 of them remain in the heap withJNI GlobalGC root.Workarounds attempted (none reduced leak)
view.DisconnectHandlers()recursively from root_root.Background = null(break Drawable.callback cycle)_image.SetImageDrawable(null)_root.OnFocusChangeListener = null_root.Dispose()synchronously inDisconnectHandlerJNI ERROR (deleted reference)race condition with pending layout pass_root.Dispose()viaView.PostDelayed(200ms)_root.Dispose()viaHandler(Looper.MainLooper).PostDelayed(200ms)GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();after disconnectThe fact that
Dispose()runs successfully but the JNI Global Ref persists suggestsMono.Android.ManagedValueManager(or similar internal cache) holds a strong reference to the Mono peer outside the publicDispose()contract.Environment
net10.0-androidHeap dump
heap.hprofis attached (51 MB). Filter MAT histogram forcrc6431bef388e7f7f9bd.CustomLinearLayoutto see the leaked custom platform view; right-click → Path to GC Roots → exclude weak/soft/phantom showsJNI Globalas the only GC root.Impact
JNI ERROR (deleted reference)crashes occur during teardown when the JNI table is heavily fragmented.How to capture the diagnostic files (for reproduction)
memory_watch.txt— per-seconddumpsys meminfoRun this PowerShell loop in parallel with the app:
Stop the loop (Ctrl+C) once the 10-cycle reproduction is complete.
heap.hprof— Java heap dump after Force ART GCThe dump captured this way reflects the post-GC steady state — anything still in the heap is genuinely retained, not waiting for collection. Open
heap.hprofin Eclipse MAT (older versions may needhprof-conv heap.hprof heap_std.hproffrom Android SDK platform-tools first).Attached diagnostic files
memory_watch.txt — output of a script that runs
dumpsys meminfo <pkg>once per second over the whole reproduction window. Each block containsTOTAL PSS,Native Heap,Java Heap, andViewscount. Shows the linear growth ofViews(~86 per cycle) and PSS (~1.5 MB per cycle) across 10 open/close cycles, with no recovery after Force GC.heap.zip — zipped Android
heap.hprof(51 MB → ~12 MB compressed) captured after the 10-cycle reproduction +adb shell am send-trim-memory <pkg> COMPLETE(full ART GC). Open with Eclipse MAT (must runhprof-convfirst if using older MAT versions). Histogram filtercrc6431bef388e7f7f9bd.CustomLinearLayoutshows the 186 leaked custom platform-view instances; right-click → Merge Shortest Paths to GC Roots → exclude weak/soft/phantom references reproduces the screenshot above.Eclipse MAT — Path to GC Roots for
CustomLinearLayoutinstances (excluding weak/soft/phantom refs).All 186 zombie instances have
JNI Globalas the GC root, holdingMaterialTextViewand other child views viamChildren.Minimal reproduction code
The following sanitized excerpts (extracted from our production app) are sufficient to reproduce the leak in a fresh
dotnet new mauiproject. Drop these files into the appropriate folders, register the handler inMauiProgram.cs, then navigate back-and-forth between two pages.1.
Controls/EnhancedButton.cs(MAUI cross-platform control)2.
Platforms/Android/CustomLinearLayout.cs(Java/native subclass)3.
Platforms/Android/Resources/layout/EnhancedButton.axml4.
Platforms/Android/EnhancedButtonHandler.cs(the handler that leaks)5.
MauiProgram.cs(handler registration)6.
MainPage.xaml(host page with cyclic navigation)7.
MainPage.xaml.cs(reproduces the leak)After running
OnRunCyclesonce and then forcing ART full GC + heap dump (commands above), Eclipse MAT will show ~180+ leakedcrc<hash>.CustomLinearLayoutinstances all rooted atJNI Global, with no path back to managed application code.Suggested fix direction
Either:
Java.Lang.Object.Dispose()should reliably remove the peer fromJniRuntime.JniValueManager.RegisteredInstancesand callJNIEnv.DeleteGlobalRef.MauiContext.Services(or whichever per-page IServiceProvider holds handler references) should be disposed when the page is destroyed, so its strong references to platform views are released.ViewHandler.DisconnectHandler()should also dispose the_platformViewpeer, not only null the field.Steps to Reproduce
No response
Link to public reproduction project repository
No response
Version with bug
10.0.20
Is this a regression from previous behavior?
Not sure, did not test other versions
Last version that worked well
Unknown/Other
Affected platforms
Android
Affected platform versions
All Android versions, that I tried
Did you find any workaround?
No response
Relevant log output