From fabfc55bec1727c5be95c12cb69fa463f7440e04 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 10 Nov 2023 08:52:03 -0600 Subject: [PATCH] [ios/android] fix memory leak in `ImageButton` (#18602) * [ios] fix memory leak in `ImageButton` Context: https://github.com/dotnet/maui/issues/18365 Adding a parameter to the test: [Theory("Handler Does Not Leak")] [InlineData(typeof(ImageButton))] public async Task HandlerDoesNotLeak(Type type) Shows a memory leak in `ImageButton`, caused by the cycle * `ImageButtonHandler` -> * `UIButton` events like `TouchUpInside` -> * `ImageButtonHandler` I could solve this problem by creating a `ImageButtonProxy` class -- the same pattern I've used in other PRs to avoid cycles. This makes an intermediate type to handle the events and breaks the cycle. Still thinking if the analyzer could have caught this, issue filed at: https://github.com/jonathanpeppers/memory-analyzers/issues/12 * [android] fix memory leak in `ImageButton` Context: a270ebdcc478578605d0a04fcf01c50eab7a3861 Context: 1bbe79de61f241217f88207b1272179ff66e6733 Context: https://github.com/material-components/material-components-android/issues/2063 Reviewing a GC dump of the device tests, I noticed a `System.Action` keeping the `ImageButton` alive: Microsoft.Maui.Controls.ImageButton System.Action Java.Lang.Thread.RunnableImplementor So next, I looked for `System.Action` and found the path on the `ReferencedTypes` tab: System.Action Microsoft.Maui.Platform.ImageButtonExtensions.[]c__DisplayClass4_0 Google.Android.Material.ImageView.ShapeableImageView Microsoft.Maui.Controls.ImageButton Which led me to the code: public static async void UpdatePadding(this ShapeableImageView platformButton, IImageButton imageButton) { platformButton.SetContentPadding(imageButton); platformButton.Post(() => { platformButton.SetContentPadding(imageButton); }); platformButton.SetContentPadding(imageButton); } ?!? Why is this code calling `SetContentPadding` three times? Reviewing the commit history: * a270ebdcc478578605d0a04fcf01c50eab7a3861 * 1bbe79de61f241217f88207b1272179ff66e6733 * https://github.com/material-components/material-components-android/issues/2063 I could comment out the code and the leak is solved, but I found I could also change the code to use `await Task.Yield()` for the same result. --- .../tests/DeviceTests/Memory/MemoryTests.cs | 2 + .../ImageButton/ImageButtonHandler.iOS.cs | 68 +++++++++++++------ .../Platform/Android/ImageButtonExtensions.cs | 12 ++-- 3 files changed, 54 insertions(+), 28 deletions(-) diff --git a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs index cee079cb9023..13aea7d39d48 100644 --- a/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs +++ b/src/Controls/tests/DeviceTests/Memory/MemoryTests.cs @@ -37,6 +37,7 @@ void SetupBuilder() handlers.AddHandler(); handlers.AddHandler(); handlers.AddHandler(); + handlers.AddHandler(); handlers.AddHandler(); handlers.AddHandler(); handlers.AddHandler(); @@ -60,6 +61,7 @@ void SetupBuilder() [InlineData(typeof(Editor))] [InlineData(typeof(GraphicsView))] [InlineData(typeof(Image))] + [InlineData(typeof(ImageButton))] [InlineData(typeof(IndicatorView))] [InlineData(typeof(Label))] [InlineData(typeof(Picker))] diff --git a/src/Core/src/Handlers/ImageButton/ImageButtonHandler.iOS.cs b/src/Core/src/Handlers/ImageButton/ImageButtonHandler.iOS.cs index 8d60d498220d..f25a54291d9b 100644 --- a/src/Core/src/Handlers/ImageButton/ImageButtonHandler.iOS.cs +++ b/src/Core/src/Handlers/ImageButton/ImageButtonHandler.iOS.cs @@ -5,6 +5,8 @@ namespace Microsoft.Maui.Handlers { public partial class ImageButtonHandler : ViewHandler { + readonly ImageButtonProxy _proxy = new(); + protected override UIButton CreatePlatformView() { var platformView = new UIButton(UIButtonType.System) @@ -17,18 +19,14 @@ protected override UIButton CreatePlatformView() protected override void ConnectHandler(UIButton platformView) { - platformView.TouchUpInside += OnButtonTouchUpInside; - platformView.TouchUpOutside += OnButtonTouchUpOutside; - platformView.TouchDown += OnButtonTouchDown; + _proxy.Connect(VirtualView, platformView); base.ConnectHandler(platformView); } protected override void DisconnectHandler(UIButton platformView) { - platformView.TouchUpInside -= OnButtonTouchUpInside; - platformView.TouchUpOutside -= OnButtonTouchUpOutside; - platformView.TouchDown -= OnButtonTouchDown; + _proxy.Disconnect(platformView); base.DisconnectHandler(platformView); @@ -55,22 +53,6 @@ public static void MapPadding(IImageButtonHandler handler, IImageButton imageBut (handler.PlatformView as UIButton)?.UpdatePadding(imageButton); } - void OnButtonTouchUpInside(object? sender, EventArgs e) - { - VirtualView?.Released(); - VirtualView?.Clicked(); - } - - void OnButtonTouchUpOutside(object? sender, EventArgs e) - { - VirtualView?.Released(); - } - - void OnButtonTouchDown(object? sender, EventArgs e) - { - VirtualView?.Pressed(); - } - partial class ImageButtonImageSourcePartSetter { public override void SetImageSource(UIImage? platformImage) @@ -85,5 +67,47 @@ public override void SetImageSource(UIImage? platformImage) button.VerticalAlignment = UIControlContentVerticalAlignment.Fill; } } + + class ImageButtonProxy + { + WeakReference? _virtualView; + + IImageButton? VirtualView => _virtualView is not null && _virtualView.TryGetTarget(out var v) ? v : null; + + public void Connect(IImageButton virtualView, UIButton platformView) + { + _virtualView = new(virtualView); + + platformView.TouchUpInside += OnButtonTouchUpInside; + platformView.TouchUpOutside += OnButtonTouchUpOutside; + platformView.TouchDown += OnButtonTouchDown; + } + + public void Disconnect(UIButton platformView) + { + platformView.TouchUpInside -= OnButtonTouchUpInside; + platformView.TouchUpOutside -= OnButtonTouchUpOutside; + platformView.TouchDown -= OnButtonTouchDown; + } + + void OnButtonTouchUpInside(object? sender, EventArgs e) + { + if (VirtualView is IImageButton imageButton) + { + imageButton.Released(); + imageButton.Clicked(); + } + } + + void OnButtonTouchUpOutside(object? sender, EventArgs e) + { + VirtualView?.Released(); + } + + void OnButtonTouchDown(object? sender, EventArgs e) + { + VirtualView?.Pressed(); + } + } } } diff --git a/src/Core/src/Platform/Android/ImageButtonExtensions.cs b/src/Core/src/Platform/Android/ImageButtonExtensions.cs index 1f9912081e44..d8d53f768a97 100644 --- a/src/Core/src/Platform/Android/ImageButtonExtensions.cs +++ b/src/Core/src/Platform/Android/ImageButtonExtensions.cs @@ -1,4 +1,5 @@ -using Android.Graphics.Drawables; +using System.Threading.Tasks; +using Android.Graphics.Drawables; using Android.Widget; using Google.Android.Material.ImageView; using Google.Android.Material.Shape; @@ -41,13 +42,12 @@ public static void UpdateCornerRadius(this ShapeableImageView platformButton, IB .Build(); } - public static void UpdatePadding(this ShapeableImageView platformButton, IImageButton imageButton) + public static async void UpdatePadding(this ShapeableImageView platformButton, IImageButton imageButton) { platformButton.SetContentPadding(imageButton); - platformButton.Post(() => - { - platformButton.SetContentPadding(imageButton); - }); + + // see: https://github.com/material-components/material-components-android/issues/2063 + await Task.Yield(); platformButton.SetContentPadding(imageButton); }