From 62c47e0bd57dcce38dd7e84271eb93f233da9498 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Wed, 24 Jul 2024 04:44:13 +0800 Subject: [PATCH] Fix memory leaks in MAUI views Fixes #2923 --- .../SkiaSharp.Views.Maui.Controls/SKGLView.cs | 22 +++- .../SKCanvasView/SKCanvasViewHandler.Apple.cs | 58 +++++----- .../SKGLView/SKGLViewHandler.MacCatalyst.cs | 94 ++++++++-------- .../Handlers/SKGLView/SKGLViewHandler.iOS.cs | 96 ++++++++--------- .../Platform/Apple/SKEventProxy.cs | 33 ++++++ .../Platform/Apple/SKTouchHandlerProxy.cs | 55 ++++++++++ tests/SkiaSharp.Tests.Devices.Mac.sln | 100 ++++++++++++++++++ tests/SkiaSharp.Tests.Devices/MauiProgram.cs | 2 + .../SkiaSharp.Tests.Devices.csproj | 15 +-- .../Tests/Maui/MauiExtensions.cs | 50 +++++++++ .../Tests/Maui/MemoryLeakTests.cs | 74 +++++++++++++ .../Tests/Maui/SKUITests.cs | 50 +++++++++ tests/Tests/Xunit/AssertEx.cs | 80 ++++++++++++++ 13 files changed, 596 insertions(+), 133 deletions(-) create mode 100644 source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Platform/Apple/SKEventProxy.cs create mode 100644 source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Platform/Apple/SKTouchHandlerProxy.cs create mode 100644 tests/SkiaSharp.Tests.Devices.Mac.sln create mode 100644 tests/SkiaSharp.Tests.Devices/Tests/Maui/MauiExtensions.cs create mode 100644 tests/SkiaSharp.Tests.Devices/Tests/Maui/MemoryLeakTests.cs create mode 100644 tests/SkiaSharp.Tests.Devices/Tests/Maui/SKUITests.cs create mode 100644 tests/Tests/Xunit/AssertEx.cs diff --git a/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Controls/SKGLView.cs b/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Controls/SKGLView.cs index 373f1a6ed2..5593700dee 100644 --- a/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Controls/SKGLView.cs +++ b/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Controls/SKGLView.cs @@ -1,7 +1,7 @@ #nullable enable using System; - +using System.ComponentModel; using Microsoft.Maui; using Microsoft.Maui.Controls; using Microsoft.Maui.Graphics; @@ -10,6 +10,9 @@ namespace SkiaSharp.Views.Maui.Controls { public partial class SKGLView : View, ISKGLView { + private static readonly BindableProperty ProxyWindowProperty = + BindableProperty.Create("ProxyWindow", typeof(Window), typeof(SKGLView), propertyChanged: OnWindowChanged); + public static readonly BindableProperty IgnorePixelScalingProperty = BindableProperty.Create(nameof(IgnorePixelScaling), typeof(bool), typeof(SKGLView), false); @@ -22,6 +25,12 @@ public partial class SKGLView : View, ISKGLView private SKSizeI lastCanvasSize; private GRContext? lastGRContext; + public SKGLView() + { + var binding = new Binding(nameof(Window), source: this); + SetBinding(ProxyWindowProperty, binding); + } + public bool IgnorePixelScaling { get => (bool)GetValue(IgnorePixelScalingProperty); @@ -63,6 +72,17 @@ protected virtual void OnTouch(SKTouchEventArgs e) Touch?.Invoke(this, e); } + private static void OnWindowChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is not SKGLView view) + return; + + view.Handler?.UpdateValue(nameof(HasRenderLoop)); + } + + bool ISKGLView.HasRenderLoop => + HasRenderLoop && Window is not null; + void ISKGLView.OnCanvasSizeChanged(SKSizeI size) => lastCanvasSize = size; diff --git a/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Handlers/SKCanvasView/SKCanvasViewHandler.Apple.cs b/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Handlers/SKCanvasView/SKCanvasViewHandler.Apple.cs index a40ac0b1a2..ce56e098a2 100644 --- a/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Handlers/SKCanvasView/SKCanvasViewHandler.Apple.cs +++ b/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Handlers/SKCanvasView/SKCanvasViewHandler.Apple.cs @@ -7,24 +7,27 @@ namespace SkiaSharp.Views.Maui.Handlers { public partial class SKCanvasViewHandler : ViewHandler { - private SKSizeI lastCanvasSize; - private SKTouchHandler? touchHandler; + private PaintSurfaceProxy? paintSurfaceProxy; + private SKTouchHandlerProxy? touchProxy; protected override SKCanvasView CreatePlatformView() => new SKCanvasView { BackgroundColor = UIColor.Clear }; protected override void ConnectHandler(SKCanvasView platformView) { - platformView.PaintSurface += OnPaintSurface; + paintSurfaceProxy = new(); + paintSurfaceProxy.Connect(VirtualView, platformView); + touchProxy = new(); + touchProxy.Connect(VirtualView, platformView); base.ConnectHandler(platformView); } protected override void DisconnectHandler(SKCanvasView platformView) { - touchHandler?.Detach(platformView); - touchHandler = null; - - platformView.PaintSurface -= OnPaintSurface; + paintSurfaceProxy?.Disconnect(platformView); + paintSurfaceProxy = null; + touchProxy?.Disconnect(platformView); + touchProxy = null; base.DisconnectHandler(platformView); } @@ -43,38 +46,35 @@ public static void MapIgnorePixelScaling(SKCanvasViewHandler handler, ISKCanvasV public static void MapEnableTouchEvents(SKCanvasViewHandler handler, ISKCanvasView canvasView) { - handler.touchHandler ??= new SKTouchHandler( - args => canvasView.OnTouch(args), - (x, y) => handler.OnGetScaledCoord(x, y)); - - handler.touchHandler?.SetEnabled(handler.PlatformView, canvasView.EnableTouchEvents); + handler.touchProxy?.UpdateEnableTouchEvents(handler.PlatformView, canvasView.EnableTouchEvents); } // helper methods - private void OnPaintSurface(object? sender, iOS.SKPaintSurfaceEventArgs e) + private class PaintSurfaceProxy : SKEventProxy { - var newCanvasSize = e.Info.Size; - if (lastCanvasSize != newCanvasSize) - { - lastCanvasSize = newCanvasSize; - VirtualView?.OnCanvasSizeChanged(newCanvasSize); - } + private SKSizeI lastCanvasSize; - VirtualView?.OnPaintSurface(new SKPaintSurfaceEventArgs(e.Surface, e.Info, e.RawInfo)); - } + protected override void OnConnect(ISKCanvasView virtualView, SKCanvasView platformView) => + platformView.PaintSurface += OnPaintSurface; - private SKPoint OnGetScaledCoord(double x, double y) - { - if (VirtualView?.IgnorePixelScaling == false && PlatformView != null) + protected override void OnDisconnect(SKCanvasView platformView) => + platformView.PaintSurface -= OnPaintSurface; + + private void OnPaintSurface(object? sender, iOS.SKPaintSurfaceEventArgs e) { - var scale = PlatformView.ContentScaleFactor; + if (VirtualView is not {} view) + return; - x *= scale; - y *= scale; - } + var newCanvasSize = e.Info.Size; + if (lastCanvasSize != newCanvasSize) + { + lastCanvasSize = newCanvasSize; + view.OnCanvasSizeChanged(newCanvasSize); + } - return new SKPoint((float)x, (float)y); + view.OnPaintSurface(new SKPaintSurfaceEventArgs(e.Surface, e.Info, e.RawInfo)); + } } } } diff --git a/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Handlers/SKGLView/SKGLViewHandler.MacCatalyst.cs b/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Handlers/SKGLView/SKGLViewHandler.MacCatalyst.cs index 8b1a067929..58307c2a29 100644 --- a/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Handlers/SKGLView/SKGLViewHandler.MacCatalyst.cs +++ b/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Handlers/SKGLView/SKGLViewHandler.MacCatalyst.cs @@ -8,9 +8,8 @@ namespace SkiaSharp.Views.Maui.Handlers { public partial class SKGLViewHandler : ViewHandler { - private SKSizeI lastCanvasSize; - private GRContext? lastGRContext; - private SKTouchHandler? touchHandler; + private PaintSurfaceProxy? paintSurfaceProxy; + private SKTouchHandlerProxy? touchProxy; protected override SKMetalView CreatePlatformView() => new MauiSKMetalView @@ -21,17 +20,20 @@ protected override SKMetalView CreatePlatformView() => protected override void ConnectHandler(SKMetalView platformView) { - platformView.PaintSurface += OnPaintSurface; + paintSurfaceProxy = new(); + paintSurfaceProxy.Connect(VirtualView, platformView); + touchProxy = new(); + touchProxy.Connect(VirtualView, platformView); base.ConnectHandler(platformView); } protected override void DisconnectHandler(SKMetalView platformView) { - touchHandler?.Detach(platformView); - touchHandler = null; - - platformView.PaintSurface -= OnPaintSurface; + paintSurfaceProxy?.Disconnect(platformView); + paintSurfaceProxy = null; + touchProxy?.Disconnect(platformView); + touchProxy = null; base.DisconnectHandler(platformView); } @@ -61,49 +63,11 @@ public static void MapHasRenderLoop(SKGLViewHandler handler, ISKGLView view) public static void MapEnableTouchEvents(SKGLViewHandler handler, ISKGLView view) { - handler.touchHandler ??= new SKTouchHandler( - args => view.OnTouch(args), - (x, y) => handler.OnGetScaledCoord(x, y)); - - handler.touchHandler?.SetEnabled(handler.PlatformView, view.EnableTouchEvents); + handler.touchProxy?.UpdateEnableTouchEvents(handler.PlatformView, view.EnableTouchEvents); } // helper methods - private void OnPaintSurface(object? sender, iOS.SKPaintMetalSurfaceEventArgs e) - { - var newCanvasSize = e.Info.Size; - if (lastCanvasSize != newCanvasSize) - { - lastCanvasSize = newCanvasSize; - VirtualView?.OnCanvasSizeChanged(newCanvasSize); - } - if (sender is SKMetalView platformView) - { - var newGRContext = platformView.GRContext; - if (lastGRContext != newGRContext) - { - lastGRContext = newGRContext; - VirtualView?.OnGRContextChanged(newGRContext); - } - } - - VirtualView?.OnPaintSurface(new SKPaintGLSurfaceEventArgs(e.Surface, e.BackendRenderTarget, e.Origin, e.Info, e.RawInfo)); - } - - private SKPoint OnGetScaledCoord(double x, double y) - { - if (VirtualView?.IgnorePixelScaling == false && PlatformView != null) - { - var scale = PlatformView.ContentScaleFactor; - - x *= scale; - y *= scale; - } - - return new SKPoint((float)x, (float)y); - } - private class MauiSKMetalView : SKMetalView { public bool IgnorePixelScaling { get; set; } @@ -123,5 +87,41 @@ protected override void OnPaintSurface(iOS.SKPaintMetalSurfaceEventArgs e) base.OnPaintSurface(e); } } + + private class PaintSurfaceProxy : SKEventProxy + { + private SKSizeI lastCanvasSize; + private GRContext? lastGRContext; + + protected override void OnConnect(ISKGLView virtualView, SKMetalView platformView) => + platformView.PaintSurface += OnPaintSurface; + + protected override void OnDisconnect(SKMetalView platformView) => + platformView.PaintSurface -= OnPaintSurface; + + private void OnPaintSurface(object? sender, iOS.SKPaintMetalSurfaceEventArgs e) + { + if (VirtualView is not {} view) + return; + + var newCanvasSize = e.Info.Size; + if (lastCanvasSize != newCanvasSize) + { + lastCanvasSize = newCanvasSize; + view.OnCanvasSizeChanged(newCanvasSize); + } + if (sender is SKMetalView platformView) + { + var newGRContext = platformView.GRContext; + if (lastGRContext != newGRContext) + { + lastGRContext = newGRContext; + view.OnGRContextChanged(newGRContext); + } + } + + view.OnPaintSurface(new SKPaintGLSurfaceEventArgs(e.Surface, e.BackendRenderTarget, e.Origin, e.Info, e.RawInfo)); + } + } } } diff --git a/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Handlers/SKGLView/SKGLViewHandler.iOS.cs b/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Handlers/SKGLView/SKGLViewHandler.iOS.cs index c19a2463a6..3bb8450343 100644 --- a/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Handlers/SKGLView/SKGLViewHandler.iOS.cs +++ b/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Handlers/SKGLView/SKGLViewHandler.iOS.cs @@ -16,9 +16,8 @@ namespace SkiaSharp.Views.Maui.Handlers [UnsupportedOSPlatform("macos")] public partial class SKGLViewHandler : ViewHandler { - private SKSizeI lastCanvasSize; - private GRContext? lastGRContext; - private SKTouchHandler? touchHandler; + private PaintSurfaceProxy? paintSurfaceProxy; + private SKTouchHandlerProxy? touchProxy; private RenderLoopManager? renderLoopManager; protected override SKGLView CreatePlatformView() => @@ -30,22 +29,23 @@ protected override SKGLView CreatePlatformView() => protected override void ConnectHandler(SKGLView platformView) { + paintSurfaceProxy = new(); + paintSurfaceProxy.Connect(VirtualView, platformView); + touchProxy = new(); + touchProxy.Connect(VirtualView, platformView); renderLoopManager = new RenderLoopManager(this); - platformView.PaintSurface += OnPaintSurface; - base.ConnectHandler(platformView); } protected override void DisconnectHandler(SKGLView platformView) { + paintSurfaceProxy?.Disconnect(platformView); + paintSurfaceProxy = null; + touchProxy?.Disconnect(platformView); + touchProxy = null; renderLoopManager?.StopRenderLoop(); - touchHandler?.Detach(platformView); - touchHandler = null; - - platformView.PaintSurface -= OnPaintSurface; - base.DisconnectHandler(platformView); } @@ -75,49 +75,11 @@ public static void MapHasRenderLoop(SKGLViewHandler handler, ISKGLView view) public static void MapEnableTouchEvents(SKGLViewHandler handler, ISKGLView view) { - handler.touchHandler ??= new SKTouchHandler( - args => view.OnTouch(args), - (x, y) => handler.OnGetScaledCoord(x, y)); - - handler.touchHandler?.SetEnabled(handler.PlatformView, view.EnableTouchEvents); + handler.touchProxy?.UpdateEnableTouchEvents(handler.PlatformView, view.EnableTouchEvents); } // helper methods - private void OnPaintSurface(object? sender, iOS.SKPaintGLSurfaceEventArgs e) - { - var newCanvasSize = e.Info.Size; - if (lastCanvasSize != newCanvasSize) - { - lastCanvasSize = newCanvasSize; - VirtualView?.OnCanvasSizeChanged(newCanvasSize); - } - if (sender is SKGLView platformView) - { - var newGRContext = platformView.GRContext; - if (lastGRContext != newGRContext) - { - lastGRContext = newGRContext; - VirtualView?.OnGRContextChanged(newGRContext); - } - } - - VirtualView?.OnPaintSurface(new SKPaintGLSurfaceEventArgs(e.Surface, e.BackendRenderTarget, e.Origin, e.Info, e.RawInfo)); - } - - private SKPoint OnGetScaledCoord(double x, double y) - { - if (VirtualView?.IgnorePixelScaling == false && PlatformView != null) - { - var scale = PlatformView.ContentScaleFactor; - - x *= scale; - y *= scale; - } - - return new SKPoint((float)x, (float)y); - } - private class MauiSKGLView : SKGLView { public bool IgnorePixelScaling { get; set; } @@ -215,5 +177,41 @@ public void StopRenderLoop() displayLink = null; } } + + private class PaintSurfaceProxy : SKEventProxy + { + private SKSizeI lastCanvasSize; + private GRContext? lastGRContext; + + protected override void OnConnect(ISKGLView virtualView, SKGLView platformView) => + platformView.PaintSurface += OnPaintSurface; + + protected override void OnDisconnect(SKGLView platformView) => + platformView.PaintSurface -= OnPaintSurface; + + private void OnPaintSurface(object? sender, iOS.SKPaintGLSurfaceEventArgs e) + { + if (VirtualView is not {} view) + return; + + var newCanvasSize = e.Info.Size; + if (lastCanvasSize != newCanvasSize) + { + lastCanvasSize = newCanvasSize; + view.OnCanvasSizeChanged(newCanvasSize); + } + if (sender is SKGLView platformView) + { + var newGRContext = platformView.GRContext; + if (lastGRContext != newGRContext) + { + lastGRContext = newGRContext; + view.OnGRContextChanged(newGRContext); + } + } + + view.OnPaintSurface(new SKPaintGLSurfaceEventArgs(e.Surface, e.BackendRenderTarget, e.Origin, e.Info, e.RawInfo)); + } + } } } diff --git a/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Platform/Apple/SKEventProxy.cs b/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Platform/Apple/SKEventProxy.cs new file mode 100644 index 0000000000..7e13131b69 --- /dev/null +++ b/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Platform/Apple/SKEventProxy.cs @@ -0,0 +1,33 @@ +using System; + +namespace SkiaSharp.Views.Maui.Platform; + +internal class SKEventProxy + where TVirtualView : class + where TPlatformView : class +{ + private WeakReference? virtualView; + + protected TVirtualView? VirtualView => + virtualView is not null && virtualView.TryGetTarget(out var v) ? v : null; + + public void Connect(TVirtualView virtualView, TPlatformView platformView) + { + this.virtualView = new(virtualView); + OnConnect(virtualView, platformView); + } + + protected virtual void OnConnect(TVirtualView virtualView, TPlatformView platformView) + { + } + + public void Disconnect(TPlatformView platformView) + { + virtualView = null; + OnDisconnect(platformView); + } + + protected virtual void OnDisconnect(TPlatformView platformView) + { + } +} diff --git a/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Platform/Apple/SKTouchHandlerProxy.cs b/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Platform/Apple/SKTouchHandlerProxy.cs new file mode 100644 index 0000000000..72f315f57e --- /dev/null +++ b/source/SkiaSharp.Views.Maui/SkiaSharp.Views.Maui.Core/Platform/Apple/SKTouchHandlerProxy.cs @@ -0,0 +1,55 @@ +using Microsoft.Maui; +using System; +using UIKit; + +namespace SkiaSharp.Views.Maui.Platform; + +internal class SKTouchHandlerProxy : SKEventProxy +{ + private SKTouchHandler? touchHandler; + + protected override void OnDisconnect(UIView platformView) + { + touchHandler?.Detach(platformView); + touchHandler = null; + } + + public void UpdateEnableTouchEvents(UIView platformView, bool enabled) + { + if (VirtualView is null) + return; + + touchHandler ??= new SKTouchHandler( + args => OnTouch(args), + (x, y) => OnGetScaledCoord(x, y)); + + touchHandler?.SetEnabled(platformView, enabled); + } + + private void OnTouch(SKTouchEventArgs e) + { + if (VirtualView is ISKCanvasView canvasView) + canvasView.OnTouch(e); + else if (VirtualView is ISKGLView glView) + glView.OnTouch(e); + } + + private SKPoint OnGetScaledCoord(double x, double y) + { + var ignore = false; + if (VirtualView is ISKCanvasView canvasView) + ignore = canvasView.IgnorePixelScaling; + else if (VirtualView is ISKGLView glView) + ignore = glView.IgnorePixelScaling; + + if (ignore == false && touchHandler?.View is {} platformView) + { + var scale = platformView.ContentScaleFactor; + + x *= scale; + y *= scale; + } + + return new SKPoint((float)x, (float)y); + } +} diff --git a/tests/SkiaSharp.Tests.Devices.Mac.sln b/tests/SkiaSharp.Tests.Devices.Mac.sln new file mode 100644 index 0000000000..7424b4085b --- /dev/null +++ b/tests/SkiaSharp.Tests.Devices.Mac.sln @@ -0,0 +1,100 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32515.10 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.Tests.Devices", "SkiaSharp.Tests.Devices\SkiaSharp.Tests.Devices.csproj", "{1675A562-6545-4FBE-8AF7-C07784D83944}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.Tests", "SkiaSharp.Tests\SkiaSharp.Tests.csproj", "{1C63B836-2628-4365-8237-08080E76117B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp", "..\binding\SkiaSharp\SkiaSharp.csproj", "{9D753C4C-D7FC-4D1B-ABF0-BF1C089B987A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HarfBuzzSharp", "..\binding\HarfBuzzSharp\HarfBuzzSharp.csproj", "{D48557C5-795D-4948-84EE-A7531DDD91DC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.SceneGraph", "..\binding\SkiaSharp.SceneGraph\SkiaSharp.SceneGraph.csproj", "{8CD906F8-B3E4-48E6-8B16-EAFC0C34EAE1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.Resources", "..\binding\SkiaSharp.Resources\SkiaSharp.Resources.csproj", "{AD2C6978-4F5E-E592-B565-26C357877B2C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.Skottie", "..\binding\SkiaSharp.Skottie\SkiaSharp.Skottie.csproj", "{915D1D57-B059-4301-9A35-2E5EB68DED99}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.HarfBuzz", "..\source\SkiaSharp.HarfBuzz\SkiaSharp.HarfBuzz\SkiaSharp.HarfBuzz.csproj", "{6F999CA5-B67F-46A3-9A94-9E99527060F6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.Views", "..\source\SkiaSharp.Views\SkiaSharp.Views\SkiaSharp.Views.csproj", "{398936B0-1B68-4F2D-B91C-6880CAC9F168}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.Views.Maui.Core", "..\source\SkiaSharp.Views.Maui\SkiaSharp.Views.Maui.Core\SkiaSharp.Views.Maui.Core.csproj", "{CB3FAE69-DE1F-47FF-A158-B0EF8F5F8AF6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.Views.Maui.Controls", "..\source\SkiaSharp.Views.Maui\SkiaSharp.Views.Maui.Controls\SkiaSharp.Views.Maui.Controls.csproj", "{72C22D09-AC66-4D5A-B503-D7CDA2AD6A3B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "source", "source", "{6779122B-72B0-42ED-A1E7-5029C1C0A78D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1675A562-6545-4FBE-8AF7-C07784D83944}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1675A562-6545-4FBE-8AF7-C07784D83944}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1675A562-6545-4FBE-8AF7-C07784D83944}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {1675A562-6545-4FBE-8AF7-C07784D83944}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1675A562-6545-4FBE-8AF7-C07784D83944}.Release|Any CPU.Build.0 = Release|Any CPU + {1675A562-6545-4FBE-8AF7-C07784D83944}.Release|Any CPU.Deploy.0 = Release|Any CPU + {1C63B836-2628-4365-8237-08080E76117B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C63B836-2628-4365-8237-08080E76117B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C63B836-2628-4365-8237-08080E76117B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C63B836-2628-4365-8237-08080E76117B}.Release|Any CPU.Build.0 = Release|Any CPU + {9D753C4C-D7FC-4D1B-ABF0-BF1C089B987A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D753C4C-D7FC-4D1B-ABF0-BF1C089B987A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D753C4C-D7FC-4D1B-ABF0-BF1C089B987A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D753C4C-D7FC-4D1B-ABF0-BF1C089B987A}.Release|Any CPU.Build.0 = Release|Any CPU + {D48557C5-795D-4948-84EE-A7531DDD91DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D48557C5-795D-4948-84EE-A7531DDD91DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D48557C5-795D-4948-84EE-A7531DDD91DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D48557C5-795D-4948-84EE-A7531DDD91DC}.Release|Any CPU.Build.0 = Release|Any CPU + {8CD906F8-B3E4-48E6-8B16-EAFC0C34EAE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CD906F8-B3E4-48E6-8B16-EAFC0C34EAE1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CD906F8-B3E4-48E6-8B16-EAFC0C34EAE1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8CD906F8-B3E4-48E6-8B16-EAFC0C34EAE1}.Release|Any CPU.Build.0 = Release|Any CPU + {AD2C6978-4F5E-E592-B565-26C357877B2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD2C6978-4F5E-E592-B565-26C357877B2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD2C6978-4F5E-E592-B565-26C357877B2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD2C6978-4F5E-E592-B565-26C357877B2C}.Release|Any CPU.Build.0 = Release|Any CPU + {915D1D57-B059-4301-9A35-2E5EB68DED99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {915D1D57-B059-4301-9A35-2E5EB68DED99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {915D1D57-B059-4301-9A35-2E5EB68DED99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {915D1D57-B059-4301-9A35-2E5EB68DED99}.Release|Any CPU.Build.0 = Release|Any CPU + {6F999CA5-B67F-46A3-9A94-9E99527060F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F999CA5-B67F-46A3-9A94-9E99527060F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F999CA5-B67F-46A3-9A94-9E99527060F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F999CA5-B67F-46A3-9A94-9E99527060F6}.Release|Any CPU.Build.0 = Release|Any CPU + {398936B0-1B68-4F2D-B91C-6880CAC9F168}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {398936B0-1B68-4F2D-B91C-6880CAC9F168}.Debug|Any CPU.Build.0 = Debug|Any CPU + {398936B0-1B68-4F2D-B91C-6880CAC9F168}.Release|Any CPU.ActiveCfg = Release|Any CPU + {398936B0-1B68-4F2D-B91C-6880CAC9F168}.Release|Any CPU.Build.0 = Release|Any CPU + {CB3FAE69-DE1F-47FF-A158-B0EF8F5F8AF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB3FAE69-DE1F-47FF-A158-B0EF8F5F8AF6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB3FAE69-DE1F-47FF-A158-B0EF8F5F8AF6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB3FAE69-DE1F-47FF-A158-B0EF8F5F8AF6}.Release|Any CPU.Build.0 = Release|Any CPU + {72C22D09-AC66-4D5A-B503-D7CDA2AD6A3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72C22D09-AC66-4D5A-B503-D7CDA2AD6A3B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72C22D09-AC66-4D5A-B503-D7CDA2AD6A3B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72C22D09-AC66-4D5A-B503-D7CDA2AD6A3B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {9D753C4C-D7FC-4D1B-ABF0-BF1C089B987A} = {6779122B-72B0-42ED-A1E7-5029C1C0A78D} + {D48557C5-795D-4948-84EE-A7531DDD91DC} = {6779122B-72B0-42ED-A1E7-5029C1C0A78D} + {8CD906F8-B3E4-48E6-8B16-EAFC0C34EAE1} = {6779122B-72B0-42ED-A1E7-5029C1C0A78D} + {AD2C6978-4F5E-E592-B565-26C357877B2C} = {6779122B-72B0-42ED-A1E7-5029C1C0A78D} + {915D1D57-B059-4301-9A35-2E5EB68DED99} = {6779122B-72B0-42ED-A1E7-5029C1C0A78D} + {6F999CA5-B67F-46A3-9A94-9E99527060F6} = {6779122B-72B0-42ED-A1E7-5029C1C0A78D} + {398936B0-1B68-4F2D-B91C-6880CAC9F168} = {6779122B-72B0-42ED-A1E7-5029C1C0A78D} + {CB3FAE69-DE1F-47FF-A158-B0EF8F5F8AF6} = {6779122B-72B0-42ED-A1E7-5029C1C0A78D} + {72C22D09-AC66-4D5A-B503-D7CDA2AD6A3B} = {6779122B-72B0-42ED-A1E7-5029C1C0A78D} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {34FFBC0E-9245-423A-9A91-687C9B7FDB8B} + EndGlobalSection +EndGlobal diff --git a/tests/SkiaSharp.Tests.Devices/MauiProgram.cs b/tests/SkiaSharp.Tests.Devices/MauiProgram.cs index 6c1946708c..7ad34246f4 100644 --- a/tests/SkiaSharp.Tests.Devices/MauiProgram.cs +++ b/tests/SkiaSharp.Tests.Devices/MauiProgram.cs @@ -3,6 +3,7 @@ using DeviceRunners.XHarness; using Microsoft.Extensions.Logging; using Microsoft.Maui.Hosting; +using SkiaSharp.Views.Maui.Controls.Hosting; namespace SkiaSharp.Tests { @@ -23,6 +24,7 @@ public static MauiApp CreateMauiApp() }; builder + .UseSkiaSharp() .ConfigureUITesting() .UseXHarnessTestRunner(conf => conf .AddTestAssemblies(testAssemblies) diff --git a/tests/SkiaSharp.Tests.Devices/SkiaSharp.Tests.Devices.csproj b/tests/SkiaSharp.Tests.Devices/SkiaSharp.Tests.Devices.csproj index 9eb1a63ee2..bd842bfff5 100644 --- a/tests/SkiaSharp.Tests.Devices/SkiaSharp.Tests.Devices.csproj +++ b/tests/SkiaSharp.Tests.Devices/SkiaSharp.Tests.Devices.csproj @@ -66,13 +66,14 @@ - - - - - - - + <_PlatformCompile Include="Tests\Apple\**\*.cs;Tests\iOS\**\*.cs" Condition="$(TargetFramework.Contains('-ios')) or $(TargetFramework.Contains('-maccatalyst')) or $(TargetFramework.Contains('-tvos'))" /> + <_PlatformCompile Include="Tests\Apple\**\*.cs;Tests\macOS\**\*.cs" Condition="$(TargetFramework.Contains('-macos'))" /> + <_PlatformCompile Include="Tests\Android\**\*.cs" Condition="$(TargetFramework.Contains('-android'))" /> + <_PlatformCompile Include="Tests\Tizen\**\*.cs" Condition="$(TargetFramework.Contains('-tizen'))" /> + <_PlatformCompile Include="Tests\Windows\**\*.cs" Condition="$(TargetFramework.Contains('-windows'))" /> + <_OtherCompile Include="Tests\Apple\**;Tests\iOS\**;Tests\macOS\**;Tests\Android\**;Tests\Tizen\**;Tests\Windows\**" Exclude="@(_PlatformCompile)" /> + + diff --git a/tests/SkiaSharp.Tests.Devices/Tests/Maui/MauiExtensions.cs b/tests/SkiaSharp.Tests.Devices/Tests/Maui/MauiExtensions.cs new file mode 100644 index 0000000000..6e4b3a06d4 --- /dev/null +++ b/tests/SkiaSharp.Tests.Devices/Tests/Maui/MauiExtensions.cs @@ -0,0 +1,50 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Graphics; +using Xunit; + +namespace SkiaSharp.Views.Maui.Controls.Tests; + +public static class MauiExtensions +{ + private static readonly Rect InitialFrame = new(0, 0, -1, -1); + + public static async Task WaitForLoaded( + this VisualElement element, + int timeout = 1000) + { + if (element.IsLoaded) + return; + + var tcs = new TaskCompletionSource(); + + element.Loaded += OnLoaded; + + await Task.WhenAny(tcs.Task, Task.Delay(timeout)); + + element.Loaded -= OnLoaded; + + Assert.True(element.IsLoaded); + + void OnLoaded(object? sender, EventArgs e) + { + element.Loaded -= OnLoaded; + tcs.SetResult(); + } + } + + public static Task WaitForLayout( + this View view, + Rect? initialFrame = default, + int timeout = 1000, + int interval = 100) + { + initialFrame ??= InitialFrame; + return AssertEx.Eventually( + () => view.Frame != initialFrame, + timeout, + interval); + } +} diff --git a/tests/SkiaSharp.Tests.Devices/Tests/Maui/MemoryLeakTests.cs b/tests/SkiaSharp.Tests.Devices/Tests/Maui/MemoryLeakTests.cs new file mode 100644 index 0000000000..6c8eaab3ec --- /dev/null +++ b/tests/SkiaSharp.Tests.Devices/Tests/Maui/MemoryLeakTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Maui; +using Microsoft.Maui.Controls; +using SkiaSharp.Tests; +using Xunit; + +namespace SkiaSharp.Views.Maui.Controls.Tests; + +public class MemoryLeakTests : SKUITests +{ + [UIFact] + public Task SKCanvasViewHandlerDoesNotLeak() => + AssertHandlerDoesNotLeak(() => + { + var view = new SKCanvasView(); + view.PaintSurface += (sender, e) => + { + e.Surface.Canvas.Clear(SKColors.Red); + }; + view.EnableTouchEvents = true; + view.Touch += (sender, e) => + { + view.InvalidateSurface(); + }; + return view; + }); + + [UIFact] + public Task SKGLViewHandlerDoesNotLeak() => + AssertHandlerDoesNotLeak(() => + { + var view = new SKGLView(); + view.PaintSurface += (sender, e) => + { + e.Surface.Canvas.Clear(SKColors.Red); + }; + view.EnableTouchEvents = true; + view.Touch += (sender, e) => + { + view.InvalidateSurface(); + }; + view.HasRenderLoop = true; + return view; + }); + + private async Task AssertHandlerDoesNotLeak(Func ctor) + { + async Task<(WeakReference, WeakReference, WeakReference)> RunTest() + { + var view = ctor(); + var page = new ContentPage + { + Content = view + }; + + await CurrentPage.Navigation.PushAsync(page); + + await view.WaitForLoaded(); + await view.WaitForLayout(); + + var viewReference = new WeakReference(view); + var handlerReference = new WeakReference(view.Handler); + var platformViewReference = new WeakReference(view.Handler.PlatformView); + + await page.Navigation.PopAsync(); + + return (viewReference, handlerReference, platformViewReference); + } + var (viewRef, handlerRef, platformRef) = await RunTest(); + + await AssertEx.EventuallyGC(viewRef, handlerRef, platformRef); + } +} diff --git a/tests/SkiaSharp.Tests.Devices/Tests/Maui/SKUITests.cs b/tests/SkiaSharp.Tests.Devices/Tests/Maui/SKUITests.cs new file mode 100644 index 0000000000..017942b8e0 --- /dev/null +++ b/tests/SkiaSharp.Tests.Devices/Tests/Maui/SKUITests.cs @@ -0,0 +1,50 @@ +#nullable enable +using System.Threading.Tasks; +using Microsoft.Maui; +using Microsoft.Maui.Controls; +using SkiaSharp.Tests; +using Xunit; + +namespace SkiaSharp.Views.Maui.Controls.Tests; + +[Collection("SKUITests")] +public abstract class SKUITests : SKTest, IAsyncLifetime +{ + protected ContentPage CurrentPage { get; private set; } = null!; + + protected IMauiContext MauiContext { get; private set; } = null!; + + public async Task InitializeAsync() + { + Routing.RegisterRoute("uitests", typeof(ContentPage)); + + await Shell.Current.GoToAsync("uitests"); + + CurrentPage = (ContentPage)Shell.Current.CurrentPage; + + await CurrentPage.WaitForLoaded(); + + MauiContext = CurrentPage.Handler!.MauiContext!; + } + + public async Task DisposeAsync() + { + // pop all modals + while (Shell.Current.CurrentPage.Navigation.ModalStack.Count > 0) + { + await Shell.Current.CurrentPage.Navigation.PopModalAsync(); + } + + // pop until we are back at our page + while (Shell.Current.CurrentPage != CurrentPage) + { + await Shell.Current.CurrentPage.Navigation.PopAsync(); + } + + CurrentPage = null!; + + await Shell.Current.GoToAsync(".."); + + Routing.UnRegisterRoute("uitests"); + } +} diff --git a/tests/Tests/Xunit/AssertEx.cs b/tests/Tests/Xunit/AssertEx.cs new file mode 100644 index 0000000000..ee0bb8c293 --- /dev/null +++ b/tests/Tests/Xunit/AssertEx.cs @@ -0,0 +1,80 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using Xunit.Sdk; + +namespace Xunit; + +public static class AssertEx +{ + public static async Task Eventually( + Func assertion, + int timeout = 1000, + int interval = 100, + string message = "Assertion timed out") + { + do + { + if (assertion()) + { + return; + } + + await Task.Delay(interval); + + timeout -= interval; + } + while (timeout >= 0); + + if (!assertion()) + { + throw new XunitException(message); + } + } + + public static async Task EventuallyGC(params WeakReference[] references) + { + Assert.NotEmpty(references); + + bool AreReferencesCollected() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + + foreach (var reference in references) + { + Assert.NotNull(reference); + if (reference.IsAlive) + { + return false; + } + } + + return true; + } + + try + { + await Eventually(AreReferencesCollected); + } + catch (XunitException ex) + { + throw new XunitException(ListLivingReferences(references), ex); + } + } + + private static string ListLivingReferences(WeakReference[] references) + { + var stringBuilder = new StringBuilder(); + + foreach (var weakReference in references) + { + if (weakReference.IsAlive && weakReference.Target is object x) + { + stringBuilder.Append($"Reference to {x} (type {x.GetType()} is still alive.\n"); + } + } + + return stringBuilder.ToString(); + } +}