From 52c4fc4e5321bdfd1f8c2f81e7a9c0bdf00b1b7c Mon Sep 17 00:00:00 2001 From: xiaoy312 Date: Thu, 5 Dec 2024 21:41:14 -0500 Subject: [PATCH] refactor: shape perf --- src/Uno.UI/FeatureConfiguration.cs | 24 +++++ src/Uno.UI/UI/Xaml/Shapes/Shape.wasm.cs | 88 +++++++++++++++--- src/Uno.UWP/Collections/LruCache.cs | 119 ++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 src/Uno.UWP/Collections/LruCache.cs diff --git a/src/Uno.UI/FeatureConfiguration.cs b/src/Uno.UI/FeatureConfiguration.cs index 77a6c8fcc8fa..b4b2d0cda563 100644 --- a/src/Uno.UI/FeatureConfiguration.cs +++ b/src/Uno.UI/FeatureConfiguration.cs @@ -877,5 +877,29 @@ internal static class DebugOptions public static bool WaitIndefinitelyInEventTester { get; set; } } + + public static class Shape + { +#if __WASM__ + /// + /// Gets or sets whether native svg attributes assignments can be postponed until the first arrange pass. + /// + /// This avoid double assignments(with js interop call) from both OnPropertyChanged and UpdateRender. + public static bool DelayUpdateUntilFirstArrange { get; set; } = true; + + /// + /// Gets or sets whether native getBBox() result will be cached. + /// + public static bool CacheBBoxCalculationResult { get; set; } = true; + /// + /// Gets or sets the size of getBBox cache. The default size is 500. + /// + public static int BBoxCacheSize + { + get => Microsoft.UI.Xaml.Shapes.Shape.BBoxCacheSize; + set => Microsoft.UI.Xaml.Shapes.Shape.BBoxCacheSize = value; + } +#endif + } } } diff --git a/src/Uno.UI/UI/Xaml/Shapes/Shape.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Shape.wasm.cs index dad06272de6d..96cac5bb1348 100644 --- a/src/Uno.UI/UI/Xaml/Shapes/Shape.wasm.cs +++ b/src/Uno.UI/UI/Xaml/Shapes/Shape.wasm.cs @@ -1,20 +1,35 @@ using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Linq; -using Uno; -using Uno.Disposables; -using Uno.Extensions; +using System.Numerics; using Windows.Foundation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Wasm; +using Uno; +using Uno.Collections; +using Uno.Disposables; +using Uno.Extensions; +using Uno.UI; +using Uno.UI.Xaml; using RadialGradientBrush = Microsoft/* UWP don't rename */.UI.Xaml.Media.RadialGradientBrush; -using System.Numerics; -using System.Diagnostics; -using Uno.UI.Xaml; namespace Microsoft.UI.Xaml.Shapes { + partial class Shape + + { + private static readonly LruCache _bboxCache = new(500); + + internal static int BBoxCacheSize + { + get => _bboxCache.Capacity; + set => _bboxCache.Capacity = value; + } + } + partial class Shape { private readonly SerialDisposable _fillBrushSubscription = new SerialDisposable(); @@ -22,6 +37,7 @@ partial class Shape private DefsSvgElement _defs; private protected readonly SvgElement _mainSvgElement; + private protected bool _shouldUpdateNative = !FeatureConfiguration.Shape.DelayUpdateUntilFirstArrange; // used to block updates to native element until 1st Arrange protected Shape() : base("svg", isSvg: true) { @@ -37,14 +53,30 @@ private protected Shape(string mainSvgElementTag) : base("svg", isSvg: true) private protected void UpdateRender() { - OnFillBrushChanged(); - OnStrokeBrushChanged(); - UpdateStrokeThickness(); - UpdateStrokeDashArray(); + // We can delay the setting of these property until first arrange pass, + // so to prevent double-updates from both OnPropertyChanged & ArrangeOverride. + // These updates are a little costly as we need to cross the cs-js bridge. + _shouldUpdateNative = true; + + // Regarding to caching of GetPathBoundingBox(js: getBBox) result: + // On shapes that depends on it, so: Line, Path, Polygon, Polyline + // all the properties below (at the time of written) has no effect on getBBox: + // > Note that the values of the opacity, visibility, fill, fill-opacity, fill-rule, stroke-dasharray and stroke-dashoffset properties on an element have no effect on the bounding box of an element. + // > -- https://svgwg.org/svg2-draft/coords.html#BoundingBoxes + // while not mentioned, stroke-width doesnt affect getBBox neither (for the 4 classes of shape mentioned above). + + // StrokeThickness can alter getBBox on Ellipse and Rectangle, but we dont use getBBox in these two. + + OnFillBrushChanged(); // fill + OnStrokeBrushChanged(); // stroke + UpdateStrokeThickness(); // stroke-width + UpdateStrokeDashArray(); // stroke-dasharray } private void OnFillBrushChanged() { + if (!_shouldUpdateNative) return; + // We don't request an update of the HitTest (UpdateHitTest()) since this element is never expected to be hit testable. // Note: We also enforce that the default hit test == false is not altered in the OnHitTestVisibilityChanged. @@ -111,6 +143,8 @@ private void OnFillBrushChanged() private void OnStrokeBrushChanged() { + if (!_shouldUpdateNative) return; + var svgElement = _mainSvgElement; var stroke = Stroke; @@ -159,6 +193,8 @@ private void OnStrokeBrushChanged() private void UpdateStrokeThickness() { + if (!_shouldUpdateNative) return; + var svgElement = _mainSvgElement; var strokeThickness = ActualStrokeThickness; @@ -174,6 +210,8 @@ private void UpdateStrokeThickness() private void UpdateStrokeDashArray() { + if (!_shouldUpdateNative) return; + var svgElement = _mainSvgElement; if (StrokeDashArray is not { } strokeDashArray) @@ -203,7 +241,35 @@ private UIElementCollection GetDefs() private static Rect GetPathBoundingBox(Shape shape) { - return shape._mainSvgElement.GetBBox(); + if (FeatureConfiguration.Shape.CacheBBoxCalculationResult) + { + var key = shape switch + { + // most other properties don't impact getBBox result, see also comments in UpdateRender. + Path path => $"path:{(path.Data as GeometryData)?.Data}", + Line line => $"line:{line.X1},{line.Y1}-{line.X2}{line.Y2}", + Polygon polygon => $"polygon:{polygon.Points?.ToCssString()}", + Polyline polyline => $"polyline:{polyline.Points?.ToCssString()}", + +#if DEBUG // we shouldn't hit these cases anyways + (Ellipse or Rectangle) => throw new InvalidOperationException(), +#endif + _ => null, + }; + + if (!string.IsNullOrEmpty(key)) + { + if (!_bboxCache.TryGetValue(key, out var rect)) + { + _bboxCache[key] = rect = shape._mainSvgElement.GetBBox(); + } + + return rect; + } + } + + var result = shape._mainSvgElement.GetBBox(); + return result; } private protected void Render(Shape shape, Size? size = null, double scaleX = 1d, double scaleY = 1d, double renderOriginX = 0d, double renderOriginY = 0d) diff --git a/src/Uno.UWP/Collections/LruCache.cs b/src/Uno.UWP/Collections/LruCache.cs new file mode 100644 index 000000000000..98669bb5b344 --- /dev/null +++ b/src/Uno.UWP/Collections/LruCache.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Cache; +using System.Text; +using System.Threading.Tasks; + +namespace Uno.Collections; + +public partial class LruCache +{ + // essentially, a KeyValuePair where the value is mutable + private class KeyedItem + { + public TKey Key { get; init; } + public TValue Value { get; set; } + + public KeyedItem(TKey key, TValue value) + { + this.Key = key; + this.Value = value; + } + } +} + +/// +/// A least-recently-used cache, keeping only the last n added/updated items. +/// +/// +/// +public partial class LruCache where TKey : notnull +{ + private readonly Dictionary> _map = new(); + private readonly LinkedList _list = new(); + private int _capacity; + + public int Count => _map.Count; + public int Capacity + { + get => _capacity; + set => UpdateCapacity(value); + } + + public TValue this[TKey key] + { + get => Get(key); + set => Put(key, value); + } + + public LruCache(int capacity) + { + if (capacity < 0) throw new ArgumentOutOfRangeException("capacity must be positive or zero."); + + this._capacity = capacity; + } + + public TValue Get(TKey key) + { + return TryGetValue(key, out var value) ? value : default; + } + public bool TryGetValue(TKey key, out TValue value) + { + if (_map.TryGetValue(key, out var node)) + { + _list.Remove(node); + _list.AddFirst(node); + + value = node.Value.Value; + return true; + } + else + { + value = default; + return false; + } + } + public void Put(TKey key, TValue value) + { + if (_capacity == 0) return; + + if (_map.TryGetValue(key, out var node)) + { + node.Value.Value = value; + + _list.Remove(node); + _list.AddFirst(node); + } + else + { + while (_map.Count >= _capacity) + { + _map.Remove(_list.Last.Value.Key); + _list.RemoveLast(); + } + + _map.Add(key, _list.AddFirst(new KeyedItem(key, value))); + } + } + + public void UpdateCapacity(int capacity) + { + if (capacity < 0) throw new ArgumentOutOfRangeException("capacity must be positive or zero."); + + _capacity = capacity; + if (_capacity == 0) + { + _map.Clear(); + _list.Clear(); + } + else + { + while (_map.Count > _capacity) + { + _map.Remove(_list.Last.Value.Key); + _list.RemoveLast(); + } + } + } +}