Skip to content

Commit

Permalink
[controls] fix memory leaks in Page & navigation
Browse files Browse the repository at this point in the history
Fixes: dotnet#13520
Context: https://github.com/danardelean/MauiTestFinalizer

In the above sample, you can see `Page` objects leaking while using
Shell navigation. I was able to reproduce this in a test with both
Shell & `NavigationPage` such as:

    [Fact(DisplayName = "NavigationPage Does Not Leak")]
    public async Task DoesNotLeak()
    {
        SetupBuilder();
        WeakReference pageReference = null;
        var navPage = new NavigationPage(new ContentPage { Title = "Page 1" });

        await CreateHandlerAndAddToWindow<WindowHandlerStub>(new Window(navPage), async (handler) =>
        {
            var page = new ContentPage { Title = "Page 2" };
            pageReference = new WeakReference(page);
            await navPage.Navigation.PushAsync(page);
            await navPage.Navigation.PopAsync();
        });

        await Task.Yield();
        GC.Collect();
        GC.WaitForPendingFinalizers();
        Assert.NotNull(pageReference);
        Assert.False(pageReference.IsAlive, "Page should not be alive!");
    }

To summarize:

* Customer's sample only leaks on iOS/Catalyst

* My Shell/NavigationPage tests leak on Android/iOS/Catalyst

Comparing various GC heap dumps in Visual Studio, I was able to find
the sources of these issues. I'll try to list them.

* Controls.Core
    * `Window.OnPageChanged()` should unset `_menuBarTracker.Target`
* Windows
    * `ContentViewHandler.DisconnectHandler` should unset
      `platformView.CrossPlatformMeasure` and
      `platformView.CrossPlatformArrange`.
* Android
    * `ContentViewHandler.DisconnectHandler` should unset
      `platformView.CrossPlatformMeasure` and
      `platformView.CrossPlatformArrange`.
    * `NavigationViewFragment.OnDestroy()` should unset `_currentView`
      and `_fragmentContainerView`
* iOS/Catalyst
    * `ContentViewHandler.DisconnectHandler` should unset
      `platformView.CrossPlatformMeasure` and
      `platformView.CrossPlatformArrange`.
    * `ContentViewHandler.DisconnectHandler` should call
      `RemoveFromSuperview()`

After these changes, my tests pass! They literally would not pass
without fixing all of the above... Each issue I found while reviewing
GC dumps.

Other test changes:

* `ControlsHandlerTestBase.Windows.cs` needed some `null` checks
* `ShellTests` needed an `&& !MACCATALYST` to build for MacCatalyst
  • Loading branch information
jonathanpeppers committed Mar 15, 2023
1 parent 83fea9c commit f8fe477
Show file tree
Hide file tree
Showing 12 changed files with 92 additions and 3 deletions.
1 change: 1 addition & 0 deletions src/Controls/src/Core/HandlerImpl/Window/Window.Impl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@ void OnPageChanged(Page? oldPage, Page? newPage)
{
if (oldPage != null)
{
_menuBarTracker.Target = null;
InternalChildren.Remove(oldPage);
oldPage.HandlerChanged -= OnPageHandlerChanged;
oldPage.HandlerChanging -= OnPageHandlerChanging;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ Task SetupWindowForTests<THandler>(IWindow window, Func<Task> runTests, IMauiCon
}
finally
{
window.Handler.DisconnectHandler();
window.Handler?.DisconnectHandler();
await Task.Delay(10);
newWindow?.Close();
appStub.Handler.DisconnectHandler();
appStub.Handler?.DisconnectHandler();
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,5 +287,28 @@ await CreateHandlerAndAddToWindow<WindowHandlerStub>(new Window(navPage), async
};
});
}

[Fact(DisplayName = "NavigationPage Does Not Leak")]
public async Task DoesNotLeak()
{
SetupBuilder();
WeakReference pageReference = null;
var navPage = new NavigationPage(new ContentPage { Title = "Page 1" });

await CreateHandlerAndAddToWindow<WindowHandlerStub>(new Window(navPage), async (handler) =>
{
var page = new ContentPage { Title = "Page 2" };
pageReference = new WeakReference(page);
await navPage.Navigation.PushAsync(page);
await navPage.Navigation.PopAsync();
});

await Task.Yield();
GC.Collect();
GC.WaitForPendingFinalizers();

Assert.NotNull(pageReference);
Assert.False(pageReference.IsAlive, "Page should not be alive!");
}
}
}
36 changes: 35 additions & 1 deletion src/Controls/tests/DeviceTests/Elements/Shell/ShellTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ await CreateHandlerAndAddToWindow<ShellHandler>(shell, async (handler) =>
});
}

#if !IOS
#if !IOS && !MACCATALYST
[Fact]
public async Task ChangingToNewMauiContextDoesntCrash()
{
Expand Down Expand Up @@ -917,6 +917,40 @@ await CreateHandlerAndAddToWindow<WindowHandlerStub>(new Window(navPage), async
}
#endif

[Fact(DisplayName = "Pages Do Not Leak")]
public async Task PagesDoNotLeak()
{
SetupBuilder();
var shell = await CreateShellAsync(shell =>
{
shell.CurrentItem = new ContentPage() { Title = "Page 1" };
});

WeakReference pageReference = null;

await CreateHandlerAndAddToWindow<ShellHandler>(shell, async (handler) =>
{
await OnLoadedAsync(shell.CurrentPage);
var page = new ContentPage { Title = "Page 2" };
pageReference = new WeakReference(page);
await shell.Navigation.PushAsync(page);
await shell.Navigation.PopAsync();
});

// 3 GCs were required in Android API 23, 2 worked otherwise
for (int i = 0; i < 3; i++)
{
await Task.Yield();
GC.Collect();
GC.WaitForPendingFinalizers();
}

Assert.NotNull(pageReference);
Assert.False(pageReference.IsAlive, "Page should not be alive!");
}

protected Task<Shell> CreateShellAsync(Action<Shell> action) =>
InvokeOnMainThreadAsync(() =>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ public static void MapContent(IContentViewHandler handler, IContentView page)
protected override void DisconnectHandler(ContentViewGroup platformView)
{
// If we're being disconnected from the xplat element, then we should no longer be managing its children
platformView.CrossPlatformMeasure = null;
platformView.CrossPlatformArrange = null;
platformView.RemoveAllViews();
base.DisconnectHandler(platformView);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,12 @@ public static void MapContent(IContentViewHandler handler, IContentView page)
{
UpdateContent(handler);
}

protected override void DisconnectHandler(ContentPanel platformView)
{
platformView.CrossPlatformMeasure = null;
platformView.CrossPlatformArrange = null;
base.DisconnectHandler(platformView);
}
}
}
10 changes: 10 additions & 0 deletions src/Core/src/Handlers/ContentView/ContentViewHandler.iOS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,15 @@ public static void MapContent(IContentViewHandler handler, IContentView page)
{
UpdateContent(handler);
}

protected override void DisconnectHandler(ContentView platformView)
{
platformView.CrossPlatformMeasure = null;
platformView.CrossPlatformArrange = null;
// TODO: this specific call fixes leaks in iOS/Catalyst
// But it doesn't feel quite right
platformView.RemoveFromSuperview();
base.DisconnectHandler(platformView);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ public override void OnResume()
base.OnResume();
}

public override void OnDestroy()
{
_currentView = null;
_fragmentContainerView = null;

base.OnDestroy();
}

public override Animation OnCreateAnimation(int transit, bool enter, int nextAnim)
{
int id = 0;
Expand Down
1 change: 1 addition & 0 deletions src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ override Microsoft.Maui.Handlers.EditorHandler.PlatformArrange(Microsoft.Maui.Gr
override Microsoft.Maui.Handlers.RadioButtonHandler.PlatformArrange(Microsoft.Maui.Graphics.Rect frame) -> void
override Microsoft.Maui.Layouts.FlexBasis.Equals(object? obj) -> bool
override Microsoft.Maui.Layouts.FlexBasis.GetHashCode() -> int
override Microsoft.Maui.Platform.NavigationViewFragment.OnDestroy() -> void
override Microsoft.Maui.SizeRequest.Equals(object? obj) -> bool
override Microsoft.Maui.SizeRequest.GetHashCode() -> int
static Microsoft.Maui.FontSize.operator !=(Microsoft.Maui.FontSize left, Microsoft.Maui.FontSize right) -> bool
Expand Down
1 change: 1 addition & 0 deletions src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Microsoft.Maui.Hosting.MauiApp.DisposeAsync() -> System.Threading.Tasks.ValueTas
Microsoft.Maui.Layouts.FlexBasis.Equals(Microsoft.Maui.Layouts.FlexBasis other) -> bool
Microsoft.Maui.LifecycleEvents.iOSLifecycle.PerformFetch
Microsoft.Maui.SizeRequest.Equals(Microsoft.Maui.SizeRequest other) -> bool
override Microsoft.Maui.Handlers.ContentViewHandler.DisconnectHandler(Microsoft.Maui.Platform.ContentView! platformView) -> void
override Microsoft.Maui.Handlers.SwipeItemButton.Frame.get -> CoreGraphics.CGRect
override Microsoft.Maui.Handlers.SwipeItemButton.Frame.set -> void
override Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.ConnectHandler(UIKit.UIButton! platformView) -> void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Microsoft.Maui.IApplication.UserAppTheme.get -> Microsoft.Maui.ApplicationModel.
Microsoft.Maui.Hosting.MauiApp.DisposeAsync() -> System.Threading.Tasks.ValueTask
Microsoft.Maui.Layouts.FlexBasis.Equals(Microsoft.Maui.Layouts.FlexBasis other) -> bool
Microsoft.Maui.SizeRequest.Equals(Microsoft.Maui.SizeRequest other) -> bool
override Microsoft.Maui.Handlers.ContentViewHandler.DisconnectHandler(Microsoft.Maui.Platform.ContentView! platformView) -> void
override Microsoft.Maui.Handlers.SwipeItemButton.Frame.get -> CoreGraphics.CGRect
override Microsoft.Maui.Handlers.SwipeItemButton.Frame.set -> void
override Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.ConnectHandler(UIKit.UIButton! platformView) -> void
Expand Down
1 change: 1 addition & 0 deletions src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Microsoft.Maui.Hosting.MauiApp.DisposeAsync() -> System.Threading.Tasks.ValueTas
Microsoft.Maui.Layouts.FlexBasis.Equals(Microsoft.Maui.Layouts.FlexBasis other) -> bool
Microsoft.Maui.Platform.MauiWebView.MauiWebView(Microsoft.Maui.Handlers.WebViewHandler! handler) -> void
Microsoft.Maui.SizeRequest.Equals(Microsoft.Maui.SizeRequest other) -> bool
override Microsoft.Maui.Handlers.ContentViewHandler.DisconnectHandler(Microsoft.Maui.Platform.ContentPanel! platformView) -> void
override Microsoft.Maui.Layouts.FlexBasis.Equals(object? obj) -> bool
override Microsoft.Maui.Layouts.FlexBasis.GetHashCode() -> int
override Microsoft.Maui.SizeRequest.Equals(object? obj) -> bool
Expand Down

0 comments on commit f8fe477

Please sign in to comment.