Просмотр исходного кода

Layout performance improvements (#18315)

Julien Lebosquain 6 месяцев назад
Родитель
Сommit
aac83dcd53

+ 73 - 103
src/Avalonia.Base/Layout/LayoutHelper.cs

@@ -1,4 +1,6 @@
 using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
 using Avalonia.Utilities;
 using Avalonia.VisualTree;
 
@@ -25,21 +27,20 @@ namespace Avalonia.Layout
         /// <param name="constraints">The space available for the control.</param>
         /// <returns>The control's size.</returns>
         public static Size ApplyLayoutConstraints(Layoutable control, Size constraints)
-        {
-            var minmax = new MinMax(control);
+            => ApplyLayoutConstraints(new MinMax(control), constraints);
 
-            return new Size(
-                MathUtilities.Clamp(constraints.Width, minmax.MinWidth, minmax.MaxWidth),
-                MathUtilities.Clamp(constraints.Height, minmax.MinHeight, minmax.MaxHeight));
-        }
+        internal static Size ApplyLayoutConstraints(MinMax minMax, Size constraints)
+            => new(
+                MathUtilities.Clamp(constraints.Width, minMax.MinWidth, minMax.MaxWidth),
+                MathUtilities.Clamp(constraints.Height, minMax.MinHeight, minMax.MaxHeight));
 
         public static Size MeasureChild(Layoutable? control, Size availableSize, Thickness padding,
             Thickness borderThickness)
         {
             if (IsParentLayoutRounded(control, out double scale))
             {
-                padding = RoundLayoutThickness(padding, scale, scale);
-                borderThickness = RoundLayoutThickness(borderThickness, scale, scale);
+                padding = RoundLayoutThickness(padding, scale);
+                borderThickness = RoundLayoutThickness(borderThickness, scale);
             }
 
             if (control != null)
@@ -55,7 +56,7 @@ namespace Avalonia.Layout
         {
             if (IsParentLayoutRounded(control, out double scale))
             {
-                padding = RoundLayoutThickness(padding, scale, scale);
+                padding = RoundLayoutThickness(padding, scale);
             }
 
             if (control != null)
@@ -71,8 +72,8 @@ namespace Avalonia.Layout
         {
             if (IsParentLayoutRounded(child, out double scale))
             {
-                padding = RoundLayoutThickness(padding, scale, scale);
-                borderThickness = RoundLayoutThickness(borderThickness, scale, scale);
+                padding = RoundLayoutThickness(padding, scale);
+                borderThickness = RoundLayoutThickness(borderThickness, scale);
             }
 
             return ArrangeChildInternal(child, availableSize, padding + borderThickness);
@@ -81,7 +82,7 @@ namespace Avalonia.Layout
         public static Size ArrangeChild(Layoutable? child, Size availableSize, Thickness padding)
         {
             if(IsParentLayoutRounded(child, out double scale))
-                padding = RoundLayoutThickness(padding, scale, scale);
+                padding = RoundLayoutThickness(padding, scale);
 
             return ArrangeChildInternal(child, availableSize, padding);
         }
@@ -140,18 +141,7 @@ namespace Avalonia.Layout
         /// <param name="control">The control.</param>
         /// <exception cref="Exception">Thrown when control has no root or returned layout scaling is invalid.</exception>
         public static double GetLayoutScale(Layoutable control)
-        {
-            var visualRoot = (control as Visual)?.VisualRoot;
-            
-            var result = (visualRoot as ILayoutRoot)?.LayoutScaling ?? 1.0;
-
-            if (result == 0 || double.IsNaN(result) || double.IsInfinity(result))
-            {
-                throw new Exception($"Invalid LayoutScaling returned from {visualRoot!.GetType()}");
-            }
-
-            return result;
-        }
+            => control.VisualRoot is ILayoutRoot layoutRoot ? layoutRoot.LayoutScaling : 1.0;
 
         /// <summary>
         /// Rounds a size to integer values for layout purposes, compensating for high DPI screen
@@ -172,6 +162,20 @@ namespace Avalonia.Layout
             return new Size(RoundLayoutValueUp(size.Width, dpiScaleX), RoundLayoutValueUp(size.Height, dpiScaleY));
         }
 
+        [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "The DPI scale should have been normalized.")]
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        internal static Size RoundLayoutSizeUp(Size size, double dpiScale)
+        {
+            // If DPI == 1, don't use DPI-aware rounding.
+            return dpiScale == 1.0 ?
+                new Size(
+                    Math.Ceiling(size.Width),
+                    Math.Ceiling(size.Height)) :
+                new Size(
+                    Math.Ceiling(RoundTo8Digits(size.Width) * dpiScale) / dpiScale,
+                    Math.Ceiling(RoundTo8Digits(size.Height) * dpiScale) / dpiScale);
+        }
+
         /// <summary>
         /// Rounds a thickness to integer values for layout purposes, compensating for high DPI screen
         /// coordinates.
@@ -196,6 +200,38 @@ namespace Avalonia.Layout
             );
         }
 
+        [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "The DPI scale should have been normalized.")]
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        internal static Thickness RoundLayoutThickness(Thickness thickness, double dpiScale)
+        {
+            // If DPI == 1, don't use DPI-aware rounding.
+            return dpiScale == 1.0 ?
+                new Thickness(
+                    Math.Round(thickness.Left),
+                    Math.Round(thickness.Top),
+                    Math.Round(thickness.Right),
+                    Math.Round(thickness.Bottom)) :
+                new Thickness(
+                    Math.Round(thickness.Left * dpiScale) / dpiScale,
+                    Math.Round(thickness.Top * dpiScale) / dpiScale,
+                    Math.Round(thickness.Right * dpiScale) / dpiScale,
+                    Math.Round(thickness.Bottom * dpiScale) / dpiScale);
+        }
+
+        [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "The DPI scale should have been normalized.")]
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        internal static Point RoundLayoutPoint(Point point, double dpiScale)
+        {
+            // If DPI == 1, don't use DPI-aware rounding.
+            return dpiScale == 1.0 ?
+                new Point(
+                    Math.Round(point.X),
+                    Math.Round(point.Y)) :
+                new Point(
+                    Math.Round(point.X * dpiScale) / dpiScale,
+                    Math.Round(point.Y * dpiScale) / dpiScale);
+        }
+
         /// <summary>
         /// Calculates the value to be used for layout rounding at high DPI by rounding the value
         /// up or down to the nearest pixel.
@@ -211,28 +247,10 @@ namespace Avalonia.Layout
         /// </remarks>
         public static double RoundLayoutValue(double value, double dpiScale)
         {
-            double newValue;
-
             // If DPI == 1, don't use DPI-aware rounding.
-            if (!MathUtilities.IsOne(dpiScale))
-            {
-                newValue = Math.Round(value * dpiScale) / dpiScale;
-
-                // If rounding produces a value unacceptable to layout (NaN, Infinity or MaxValue),
-                // use the original value.
-                if (double.IsNaN(newValue) ||
-                    double.IsInfinity(newValue) ||
-                    MathUtilities.AreClose(newValue, double.MaxValue))
-                {
-                    newValue = value;
-                }
-            }
-            else
-            {
-                newValue = Math.Round(value);
-            }
-
-            return newValue;
+            return MathUtilities.IsOne(dpiScale) ?
+                Math.Round(value) :
+                Math.Round(value * dpiScale) / dpiScale;
         }
 
         /// <summary>
@@ -250,73 +268,25 @@ namespace Avalonia.Layout
         /// </remarks>
         public static double RoundLayoutValueUp(double value, double dpiScale)
         {
-            double newValue;
+            // If DPI == 1, don't use DPI-aware rounding.
+            return MathUtilities.IsOne(dpiScale) ?
+                Math.Ceiling(value) :
+                Math.Ceiling(RoundTo8Digits(value) * dpiScale) / dpiScale;
+        }
 
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        private static double RoundTo8Digits(double value)
+        {
             // Round the value to avoid FP errors. This is needed because if `value` has a floating
             // point precision error (e.g. 79.333333333333343) then when it's multiplied by
             // `dpiScale` and rounded up, it will be rounded up to a value one greater than it
             // should be.
 #if NET6_0_OR_GREATER
-            value = Math.Round(value, 8, MidpointRounding.ToZero);
+            return Math.Round(value, 8, MidpointRounding.ToZero);
 #else
             // MidpointRounding.ToZero isn't available in netstandard2.0.
-            value = Math.Truncate(value * 1e8) / 1e8;
+            return Math.Truncate(value * 1e8) / 1e8;
 #endif
-
-            // If DPI == 1, don't use DPI-aware rounding.
-            if (!MathUtilities.IsOne(dpiScale))
-            {
-                newValue = Math.Ceiling(value * dpiScale) / dpiScale;
-
-                // If rounding produces a value unacceptable to layout (NaN, Infinity or MaxValue),
-                // use the original value.
-                if (double.IsNaN(newValue) ||
-                    double.IsInfinity(newValue) ||
-                    MathUtilities.AreClose(newValue, double.MaxValue))
-                {
-                    newValue = value;
-                }
-            }
-            else
-            {
-                newValue = Math.Ceiling(value);
-            }
-
-            return newValue;
-        }
-
-        /// <summary>
-        /// Calculates the min and max height for a control. Ported from WPF.
-        /// </summary>
-        private readonly struct MinMax
-        {
-            public MinMax(Layoutable e)
-            {
-                MaxHeight = e.MaxHeight;
-                MinHeight = e.MinHeight;
-                double l = e.Height;
-
-                double height = (double.IsNaN(l) ? double.PositiveInfinity : l);
-                MaxHeight = Math.Max(Math.Min(height, MaxHeight), MinHeight);
-
-                height = (double.IsNaN(l) ? 0 : l);
-                MinHeight = Math.Max(Math.Min(MaxHeight, height), MinHeight);
-
-                MaxWidth = e.MaxWidth;
-                MinWidth = e.MinWidth;
-                l = e.Width;
-
-                double width = (double.IsNaN(l) ? double.PositiveInfinity : l);
-                MaxWidth = Math.Max(Math.Min(width, MaxWidth), MinWidth);
-
-                width = (double.IsNaN(l) ? 0 : l);
-                MinWidth = Math.Max(Math.Min(MaxWidth, width), MinWidth);
-            }
-
-            public double MinWidth { get; }
-            public double MaxWidth { get; }
-            public double MinHeight { get; }
-            public double MaxHeight { get; }
         }
     }
 }

+ 51 - 49
src/Avalonia.Base/Layout/Layoutable.cs

@@ -2,6 +2,7 @@ using System;
 using Avalonia.Diagnostics;
 using Avalonia.Logging;
 using Avalonia.Reactive;
+using Avalonia.Utilities;
 using Avalonia.VisualTree;
 
 #nullable enable
@@ -544,53 +545,43 @@ namespace Avalonia.Layout
                 if (useLayoutRounding)
                 {
                     scale = LayoutHelper.GetLayoutScale(this);
-                    margin = LayoutHelper.RoundLayoutThickness(margin, scale, scale);
+                    margin = LayoutHelper.RoundLayoutThickness(margin, scale);
                 }
 
                 ApplyStyling();
                 ApplyTemplate();
 
+                var minMax = new MinMax(this);
+
                 var constrained = LayoutHelper.ApplyLayoutConstraints(
-                    this,
+                    minMax,
                     availableSize.Deflate(margin));
                 var measured = MeasureOverride(constrained);
 
-                var width = measured.Width;
-                var height = measured.Height;
+                var width = MathUtilities.Clamp(measured.Width, minMax.MinWidth, minMax.MaxWidth);
+                var height = MathUtilities.Clamp(measured.Height, minMax.MinHeight, minMax.MaxHeight);
 
+                if (useLayoutRounding)
                 {
-                    double widthCache = Width;
-
-                    if (!double.IsNaN(widthCache))
-                    {
-                        width = widthCache;
-                    }
+                    (width, height) = LayoutHelper.RoundLayoutSizeUp(new Size(width, height), scale);
                 }
 
-                width = Math.Min(width, MaxWidth);
-                width = Math.Max(width, MinWidth);
+                if (width > availableSize.Width)
+                    width = availableSize.Width;
 
-                {
-                    double heightCache = Height;
+                if (height > availableSize.Height)
+                    height = availableSize.Height;
 
-                    if (!double.IsNaN(heightCache))
-                    {
-                        height = heightCache;
-                    }
-                }
+                width += margin.Left + margin.Right;
+                height += margin.Top + margin.Bottom;
 
-                height = Math.Min(height, MaxHeight);
-                height = Math.Max(height, MinHeight);
+                if (width < 0)
+                    width = 0;
 
-                if (useLayoutRounding)
-                {
-                    (width, height) = LayoutHelper.RoundLayoutSizeUp(new Size(width, height), scale, scale);
-                }
-
-                width = Math.Min(width, availableSize.Width);
-                height = Math.Min(height, availableSize.Height);
+                if (height < 0)
+                    height = 0;
 
-                return NonNegative(new Size(width, height).Inflate(margin));
+                return new Size(width, height);
             }
             else
             {
@@ -618,8 +609,13 @@ namespace Avalonia.Layout
                 if (visual is Layoutable layoutable)
                 {
                     layoutable.Measure(availableSize);
-                    width = Math.Max(width, layoutable.DesiredSize.Width);
-                    height = Math.Max(height, layoutable.DesiredSize.Height);
+                    var childSize = layoutable.DesiredSize;
+
+                    if (childSize.Width > width)
+                        width = childSize.Width;
+
+                    if (childSize.Height > height)
+                        height = childSize.Height;
                 }
             }
 
@@ -650,12 +646,19 @@ namespace Avalonia.Layout
                 // If the margin isn't pre-rounded some sizes will be offset by 1 pixel in certain scales.
                 if (useLayoutRounding)
                 {
-                    margin = LayoutHelper.RoundLayoutThickness(margin, scale, scale);
+                    margin = LayoutHelper.RoundLayoutThickness(margin, scale);
                 }
 
-                var availableSizeMinusMargins = new Size(
-                    Math.Max(0, finalRect.Width - margin.Left - margin.Right),
-                    Math.Max(0, finalRect.Height - margin.Top - margin.Bottom));
+
+                var availableWidthMinusMargins = finalRect.Width - margin.Left - margin.Right;
+                if (availableWidthMinusMargins < 0)
+                    availableWidthMinusMargins = 0;
+
+                var availableHeightMinusMargins = finalRect.Height - margin.Top - margin.Bottom;
+                if (availableHeightMinusMargins < 0)
+                    availableHeightMinusMargins = 0;
+
+                var availableSizeMinusMargins = new Size(availableWidthMinusMargins, availableHeightMinusMargins);
                 var horizontalAlignment = HorizontalAlignment;
                 var verticalAlignment = VerticalAlignment;
                 var size = availableSizeMinusMargins;
@@ -670,12 +673,12 @@ namespace Avalonia.Layout
                     size = size.WithHeight(Math.Min(size.Height, DesiredSize.Height - margin.Top - margin.Bottom));
                 }
 
-                size = LayoutHelper.ApplyLayoutConstraints(this, size);
+                size = LayoutHelper.ApplyLayoutConstraints(new MinMax(this), size);
 
                 if (useLayoutRounding)
                 {
-                    size = LayoutHelper.RoundLayoutSizeUp(size, scale, scale);
-                    availableSizeMinusMargins = LayoutHelper.RoundLayoutSizeUp(availableSizeMinusMargins, scale, scale);
+                    size = LayoutHelper.RoundLayoutSizeUp(size, scale);
+                    availableSizeMinusMargins = LayoutHelper.RoundLayoutSizeUp(availableSizeMinusMargins, scale);
                 }
 
                 size = ArrangeOverride(size).Constrain(size);
@@ -702,13 +705,14 @@ namespace Avalonia.Layout
                         break;
                 }
 
+                var origin = new Point(originX, originY);
+
                 if (useLayoutRounding)
                 {
-                    originX = LayoutHelper.RoundLayoutValue(originX, scale);
-                    originY = LayoutHelper.RoundLayoutValue(originY, scale);
+                    origin = LayoutHelper.RoundLayoutPoint(origin, scale);
                 }
 
-                Bounds = new Rect(originX, originY, size.Width, size.Height);
+                Bounds = new Rect(origin, size);
             }
         }
 
@@ -887,11 +891,10 @@ namespace Avalonia.Layout
         /// <returns>True if the rect is invalid; otherwise false.</returns>
         private static bool IsInvalidRect(Rect rect)
         {
-            return rect.Width < 0 || rect.Height < 0 ||
-                double.IsInfinity(rect.X) || double.IsInfinity(rect.Y) ||
-                double.IsInfinity(rect.Width) || double.IsInfinity(rect.Height) ||
-                double.IsNaN(rect.X) || double.IsNaN(rect.Y) ||
-                double.IsNaN(rect.Width) || double.IsNaN(rect.Height);
+            return MathUtilities.IsNegativeOrNonFinite(rect.Width) ||
+                MathUtilities.IsNegativeOrNonFinite(rect.Height) ||
+                !MathUtilities.IsFinite(rect.X) ||
+                !MathUtilities.IsFinite(rect.Y);
         }
 
         /// <summary>
@@ -902,9 +905,8 @@ namespace Avalonia.Layout
         /// <returns>True if the size is invalid; otherwise false.</returns>
         private static bool IsInvalidSize(Size size)
         {
-            return size.Width < 0 || size.Height < 0 ||
-                double.IsInfinity(size.Width) || double.IsInfinity(size.Height) ||
-                double.IsNaN(size.Width) || double.IsNaN(size.Height);
+            return MathUtilities.IsNegativeOrNonFinite(size.Width) ||
+                MathUtilities.IsNegativeOrNonFinite(size.Height);
         }
 
         /// <summary>

+ 51 - 0
src/Avalonia.Base/Layout/MinMax.cs

@@ -0,0 +1,51 @@
+using System.Runtime.CompilerServices;
+
+namespace Avalonia.Layout;
+
+internal struct MinMax
+{
+    public double MinWidth;
+    public double MaxWidth;
+    public double MinHeight;
+    public double MaxHeight;
+
+    public MinMax(Layoutable e)
+    {
+        (MinWidth, MaxWidth) = CalcMinMax(e.Width, e.MinWidth, e.MaxWidth);
+        (MinHeight, MaxHeight) = CalcMinMax(e.Height, e.MinHeight, e.MaxHeight);
+    }
+
+    private static (double Min, double Max) CalcMinMax(double value, double min, double max)
+    {
+        double v0, v1;
+
+        if (double.IsNaN(value))
+        {
+            v0 = 0.0;
+            v1 = double.PositiveInfinity;
+        }
+        else
+        {
+            v0 = v1 = value;
+        }
+
+        max = ClampUnchecked(v1, min, max);
+        min = ClampUnchecked(v0, min, max);
+
+        return (min, max);
+    }
+
+    // Don't use Math.Clamp, it's possible for min to be greater than max here
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private static double ClampUnchecked(double value, double min, double max)
+    {
+        if (value > max)
+            value = max;
+
+        if (value < min)
+            value = min;
+
+        return value;
+    }
+
+}

+ 12 - 3
src/Avalonia.Base/Size.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Globalization;
+using System.Runtime.CompilerServices;
 #if !BUILDTASK
 using Avalonia.Animation.Animators;
 #endif
@@ -187,11 +188,18 @@ namespace Avalonia
         /// <param name="thickness">The thickness.</param>
         /// <returns>The deflated size.</returns>
         /// <remarks>The deflated size cannot be less than 0.</remarks>
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
         public Size Deflate(Thickness thickness)
         {
-            return new Size(
-                Math.Max(0, _width - thickness.Left - thickness.Right),
-                Math.Max(0, _height - thickness.Top - thickness.Bottom));
+            var width = _width - thickness.Left - thickness.Right;
+            if (width < 0)
+                width = 0;
+
+            var height = _height - thickness.Top - thickness.Bottom;
+            if (height < 0)
+                height = 0;
+
+            return new Size(width, height);
         }
 
         /// <summary>
@@ -247,6 +255,7 @@ namespace Avalonia
         /// </summary>
         /// <param name="thickness">The thickness.</param>
         /// <returns>The inflated size.</returns>
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
         public Size Inflate(Thickness thickness)
         {
             return new Size(

+ 1 - 1
src/Avalonia.Base/StyledElement.cs

@@ -85,7 +85,7 @@ namespace Avalonia
         private string? _name;
         private Classes? _classes;
         private ILogicalRoot? _logicalRoot;
-        private IAvaloniaList<ILogical>? _logicalChildren;
+        private AvaloniaList<ILogical>? _logicalChildren;
         private IResourceDictionary? _resources;
         private Styles? _styles;
         private bool _stylesApplied;

+ 10 - 1
src/Avalonia.Base/Threading/Dispatcher.cs

@@ -44,9 +44,18 @@ public partial class Dispatcher : IDispatcher
         _exceptionFilterEventArgs = new DispatcherUnhandledExceptionFilterEventArgs(this);
     }
     
-    public static Dispatcher UIThread => s_uiThread ??= CreateUIThreadDispatcher();
+    public static Dispatcher UIThread
+    {
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        get
+        {
+            return s_uiThread ??= CreateUIThreadDispatcher();
+        }
+    }
+
     public bool SupportsRunLoops => _controlledImpl != null;
 
+    [MethodImpl(MethodImplOptions.NoInlining)]
     private static Dispatcher CreateUIThreadDispatcher()
     {
         var impl = AvaloniaLocator.Current.GetService<IDispatcherImpl>();

+ 24 - 1
src/Avalonia.Base/Utilities/MathUtilities.cs

@@ -208,6 +208,7 @@ namespace Avalonia.Utilities
         /// <param name="min">The minimum value.</param>
         /// <param name="max">The maximum value.</param>
         /// <returns>The clamped value.</returns>
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
         public static double Clamp(double val, double min, double max)
         {
             if (min > max)
@@ -363,6 +364,28 @@ namespace Avalonia.Utilities
         {
             return GetMinMax(initialValue, initialValue + delta);
         }
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        internal static bool IsNegativeOrNonFinite(double d)
+        {
+#if NET6_0_OR_GREATER
+            ulong bits = BitConverter.DoubleToUInt64Bits(d);
+            return bits >= 0x7FF0_0000_0000_0000;
+#else
+            return d < 0 || !IsFinite(d);
+#endif
+        }
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        internal static bool IsFinite(double d)
+        {
+#if NET6_0_OR_GREATER
+            return double.IsFinite(d);
+#else
+            long bits = BitConverter.DoubleToInt64Bits(d);
+            return (bits & 0x7FFF_FFFF_FFFF_FFFF) < 0x7FF0_0000_0000_0000;
+#endif
+        }
         
 #if !BUILDTASK
         internal static int WhichPolygonSideIntersects(
@@ -451,7 +474,7 @@ namespace Avalonia.Utilities
             return true;
         }
 #endif
-        
+
         private static void ThrowCannotBeGreaterThanException<T>(T min, T max)
         {
             throw new ArgumentException($"{min} cannot be greater than {max}.");

+ 7 - 9
src/Avalonia.Base/Visual.cs

@@ -148,6 +148,8 @@ namespace Avalonia
         /// </summary>
         public Visual()
         {
+            _visualRoot = this as IRenderRoot;
+
             // Disable transitions until we're added to the visual tree.
             DisableTransitions();
 
@@ -317,16 +319,12 @@ namespace Avalonia
         /// <summary>
         /// Gets the control's child visuals.
         /// </summary>
-        protected internal IAvaloniaList<Visual> VisualChildren
-        {
-            get;
-            private set;
-        }
+        protected internal IAvaloniaList<Visual> VisualChildren { get; }
 
         /// <summary>
         /// Gets the root of the visual tree, if the control is attached to a visual tree.
         /// </summary>
-        protected internal IRenderRoot? VisualRoot => _visualRoot ?? (this as IRenderRoot);
+        protected internal IRenderRoot? VisualRoot => _visualRoot;
 
         internal RenderOptions RenderOptions { get; set; }
 
@@ -544,7 +542,7 @@ namespace Avalonia
         {
             Logger.TryGet(LogEventLevel.Verbose, LogArea.Visual)?.Log(this, "Detached from visual tree");
 
-            _visualRoot = null;
+            _visualRoot = this as IRenderRoot;
             RootedVisualChildrenCount--;
 
             if (RenderTransform is IMutableTransform mutableTransform)
@@ -683,9 +681,9 @@ namespace Avalonia
             var old = _visualParent;
             _visualParent = value;
 
-            if (_visualRoot != null)
+            if (_visualRoot is not null && old is not null)
             {
-                var e = new VisualTreeAttachmentEventArgs(old!, _visualRoot);
+                var e = new VisualTreeAttachmentEventArgs(old, _visualRoot);
                 OnDetachedFromVisualTreeCore(e);
             }
 

+ 1 - 1
src/Avalonia.Controls/Border.cs

@@ -156,7 +156,7 @@ namespace Avalonia.Controls
                     var borderThickness = BorderThickness;
 
                     if (UseLayoutRounding)
-                        borderThickness = LayoutHelper.RoundLayoutThickness(borderThickness, _scale, _scale);
+                        borderThickness = LayoutHelper.RoundLayoutThickness(borderThickness, _scale);
 
                     _layoutThickness = borderThickness;
                 }

+ 9 - 9
src/Avalonia.Controls/Presenters/ContentPresenter.cs

@@ -525,7 +525,7 @@ namespace Avalonia.Controls.Presenters
                     var borderThickness = BorderThickness;
 
                     if (UseLayoutRounding)
-                        borderThickness = LayoutHelper.RoundLayoutThickness(borderThickness, _scale, _scale);
+                        borderThickness = LayoutHelper.RoundLayoutThickness(borderThickness, _scale);
 
                     _layoutThickness = borderThickness;
                 }
@@ -618,8 +618,8 @@ namespace Avalonia.Controls.Presenters
 
             if (useLayoutRounding)
             {
-                padding = LayoutHelper.RoundLayoutThickness(padding, scale, scale);
-                borderThickness = LayoutHelper.RoundLayoutThickness(borderThickness, scale, scale);
+                padding = LayoutHelper.RoundLayoutThickness(padding, scale);
+                borderThickness = LayoutHelper.RoundLayoutThickness(borderThickness, scale);
             }
 
             padding += borderThickness;
@@ -642,8 +642,8 @@ namespace Avalonia.Controls.Presenters
 
             if (useLayoutRounding)
             {
-                sizeForChild = LayoutHelper.RoundLayoutSizeUp(sizeForChild, scale, scale);
-                availableSize = LayoutHelper.RoundLayoutSizeUp(availableSize, scale, scale);
+                sizeForChild = LayoutHelper.RoundLayoutSizeUp(sizeForChild, scale);
+                availableSize = LayoutHelper.RoundLayoutSizeUp(availableSize, scale);
             }
 
             switch (horizontalContentAlignment)
@@ -666,14 +666,14 @@ namespace Avalonia.Controls.Presenters
                     break;
             }
 
+            var origin = new Point(originX, originY);
+
             if (useLayoutRounding)
             {
-                originX = LayoutHelper.RoundLayoutValue(originX, scale);
-                originY = LayoutHelper.RoundLayoutValue(originY, scale);
+                origin = LayoutHelper.RoundLayoutPoint(origin, scale);
             }
 
-            var boundsForChild =
-                new Rect(originX, originY, sizeForChild.Width, sizeForChild.Height).Deflate(padding);
+            var boundsForChild = new Rect(origin, sizeForChild).Deflate(padding);
 
             Child.Arrange(boundsForChild);
 

+ 1 - 1
src/Avalonia.Controls/Presenters/ScrollContentPresenter.cs

@@ -511,7 +511,7 @@ namespace Avalonia.Controls.Presenters
             if (Child.UseLayoutRounding)
             {
                 var scale = LayoutHelper.GetLayoutScale(Child);
-                childMargin = LayoutHelper.RoundLayoutThickness(childMargin, scale, scale);
+                childMargin = LayoutHelper.RoundLayoutThickness(childMargin, scale);
             }
 
             var extent = Child!.Bounds.Size.Inflate(childMargin);

+ 1 - 2
src/Avalonia.Controls/Primitives/TemplatedControl.cs

@@ -291,7 +291,6 @@ namespace Avalonia.Controls.Primitives
         public sealed override void ApplyTemplate()
         {
             var template = Template;
-            var logical = (ILogical)this;
 
             // Apply the template if it is not the same as the template already applied - except
             // for in the case that the template is null and we're not attached to the logical 
@@ -299,7 +298,7 @@ namespace Avalonia.Controls.Primitives
             // the template has been detached, so we want to wait until it's re-attached to the 
             // logical tree as if it's re-attached to the same tree the template will be the same
             // and we don't need to do anything.
-            if (_appliedTemplate != template && (template != null || logical.IsAttachedToLogicalTree))
+            if (_appliedTemplate != template && (template != null || ((ILogical)this).IsAttachedToLogicalTree))
             {
                 if (VisualChildren.Count > 0)
                 {

+ 4 - 4
src/Avalonia.Controls/TextBlock.cs

@@ -605,7 +605,7 @@ namespace Avalonia.Controls
             }
 
             var scale = LayoutHelper.GetLayoutScale(this);
-            var padding = LayoutHelper.RoundLayoutThickness(Padding, scale, scale);
+            var padding = LayoutHelper.RoundLayoutThickness(Padding, scale);
             var top = padding.Top;
             var textHeight = TextLayout.Height;
 
@@ -709,7 +709,7 @@ namespace Avalonia.Controls
         protected override Size MeasureOverride(Size availableSize)
         {
             var scale = LayoutHelper.GetLayoutScale(this);
-            var padding = LayoutHelper.RoundLayoutThickness(Padding, scale, scale);
+            var padding = LayoutHelper.RoundLayoutThickness(Padding, scale);
             var deflatedSize = availableSize.Deflate(padding);
 
             if (_constraint != deflatedSize)
@@ -740,7 +740,7 @@ namespace Avalonia.Controls
             //This implicitly recreated the TextLayout with a new constraint if we previously reset it.
             var textLayout = TextLayout;
 
-            var size = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.MinTextWidth, textLayout.Height).Inflate(padding), 1, 1);
+            var size = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.MinTextWidth, textLayout.Height).Inflate(padding), 1);
 
             return size;
         }
@@ -748,7 +748,7 @@ namespace Avalonia.Controls
         protected override Size ArrangeOverride(Size finalSize)
         {
             var scale = LayoutHelper.GetLayoutScale(this);
-            var padding = LayoutHelper.RoundLayoutThickness(Padding, scale, scale);
+            var padding = LayoutHelper.RoundLayoutThickness(Padding, scale);
 
             var availableSize = finalSize.Deflate(padding);
 

+ 24 - 3
src/Avalonia.Controls/TopLevel.cs

@@ -137,6 +137,7 @@ namespace Avalonia.Controls
         private readonly IDisposable? _pointerOverPreProcessorSubscription;
         private readonly IDisposable? _backGestureSubscription;
         private readonly Dictionary<AvaloniaProperty, Action> _platformImplBindings = new();
+        private double _scaling;
         private Size _clientSize;
         private Size? _frameSize;
         private WindowTransparencyLevel _actualTransparencyLevel;
@@ -218,6 +219,7 @@ namespace Avalonia.Controls
             PlatformImpl = impl ?? throw new InvalidOperationException(
                 "Could not create window implementation: maybe no windowing subsystem was initialized?");
 
+            _scaling = ValidateScaling(impl.RenderScaling);
             _actualTransparencyLevel = PlatformImpl.TransparencyLevel;
 
             dependencyResolver ??= AvaloniaLocator.Current;
@@ -555,11 +557,10 @@ namespace Avalonia.Controls
             return control.GetValue(AutoSafeAreaPaddingProperty);
         }
 
-        /// <inheritdoc/>
-        double ILayoutRoot.LayoutScaling => PlatformImpl?.RenderScaling ?? 1;
+        double ILayoutRoot.LayoutScaling => _scaling;
 
         /// <inheritdoc/>
-        public double RenderScaling => PlatformImpl?.RenderScaling ?? 1;
+        public double RenderScaling => _scaling;
 
         IStyleHost IStyleHost.StylingParent => _globalStyles!;
 
@@ -717,6 +718,7 @@ namespace Avalonia.Controls
             Debug.Assert(PlatformImpl != null);
             // The PlatformImpl is completely invalid at this point
             PlatformImpl = null;
+            _scaling = 1.0;
             
             if (_globalStyles is object)
             {
@@ -768,6 +770,7 @@ namespace Avalonia.Controls
         /// <param name="scaling">The window scaling.</param>
         private void HandleScalingChanged(double scaling)
         {
+            _scaling = ValidateScaling(scaling);
             LayoutHelper.InvalidateSelfAndChildrenMeasure(this);
             Dispatcher.UIThread.Send(_ => ScalingChanged?.Invoke(this, EventArgs.Empty));
         }
@@ -971,6 +974,24 @@ namespace Avalonia.Controls
 
         ITextInputMethodImpl? ITextInputMethodRoot.InputMethod => PlatformImpl?.TryGetFeature<ITextInputMethodImpl>();
 
+        private double ValidateScaling(double scaling)
+        {
+            if (MathUtilities.IsNegativeOrNonFinite(scaling) || MathUtilities.IsZero(scaling))
+            {
+                throw new InvalidOperationException(
+                    $"Invalid {nameof(ITopLevelImpl.RenderScaling)} value {scaling} returned from {PlatformImpl?.GetType()}");
+            }
+
+            if (MathUtilities.IsOne(scaling))
+            {
+                // Ensure we've got exactly 1.0 and not an approximation,
+                // so we don't have to use MathUtilities.IsOne in various layout hot paths.
+                return 1.0;
+            }
+
+            return scaling;
+        }
+
         /// <summary>
         /// Provides layout pass timing from the layout manager to the renderer, for diagnostics purposes.
         /// </summary>

+ 13 - 1
tests/Avalonia.Benchmarks/Layout/Measure.cs

@@ -1,6 +1,7 @@
 using System.Collections.Generic;
 using System.Runtime.CompilerServices;
 using Avalonia.Controls;
+using Avalonia.Layout;
 using Avalonia.UnitTests;
 using BenchmarkDotNet.Attributes;
 
@@ -31,12 +32,23 @@ namespace Avalonia.Benchmarks.Layout
         [Benchmark, MethodImpl(MethodImplOptions.NoInlining)]
         public void Remeasure()
         {
+            _root.InvalidateMeasure();
+
             foreach (var control in _controls)
             {
-                control.InvalidateMeasure();
+                // Use an unsafe accessor instead of InvalidateMeasure, otherwise a lot of time is spent invalidating
+                // controls, which we don't want: this benchmark is supposed to be focused on Measure/Arrange.
+                SetIsMeasureValid(control, false);
+                SetIsArrangeValid(control, false);
             }
 
             _root.LayoutManager.ExecuteLayoutPass();
         }
+
+        [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_" + nameof(Layoutable.IsMeasureValid))]
+        private static extern void SetIsMeasureValid(Layoutable layoutable, bool value);
+
+        [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_" + nameof(Layoutable.IsArrangeValid))]
+        private static extern void SetIsArrangeValid(Layoutable layoutable, bool value);
     }
 }

+ 4 - 1
tests/Avalonia.Controls.UnitTests/Presenters/ScrollContentPresenterTests.cs

@@ -301,7 +301,10 @@ namespace Avalonia.Controls.UnitTests.Presenters
             target.Measure(new Size(1000, 1000));
             target.Arrange(new Rect(0, 0, 1000, 1000));
 
-            Assert.Equal(new Size(176.00000000000003, 176.00000000000003), target.Child!.DesiredSize);
+            var nonRoundedVieViewport = target.Child!.Bounds.Size.Inflate(
+                LayoutHelper.RoundLayoutThickness(target.Child.Margin, root.LayoutScaling));
+
+            Assert.Equal(new Size(176.00000000000003, 176.00000000000003), nonRoundedVieViewport);
             Assert.Equal(new Size(176, 176), target.Viewport);
             Assert.Equal(new Size(176, 176), target.Extent);
         }

+ 3 - 3
tests/Avalonia.Controls.UnitTests/TextBlockTests.cs

@@ -66,7 +66,7 @@ namespace Avalonia.Controls.UnitTests
 
                 var textLayout = textBlock.TextLayout;
 
-                var constraint = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.MinTextWidth, textLayout.Height), 1, 1);
+                var constraint = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.MinTextWidth, textLayout.Height), 1);
 
                 Assert.Equal(textBlock.DesiredSize, constraint);
             }
@@ -83,7 +83,7 @@ namespace Avalonia.Controls.UnitTests
 
                 var textLayout = textBlock.TextLayout;
 
-                var constraint = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.WidthIncludingTrailingWhitespace, textLayout.Height), 1, 1);
+                var constraint = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.WidthIncludingTrailingWhitespace, textLayout.Height), 1);
 
                 textBlock.Arrange(new Rect(constraint));
 
@@ -118,7 +118,7 @@ namespace Avalonia.Controls.UnitTests
 
                 var textLayout = textBlock.TextLayout;
 
-                var constraint = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.WidthIncludingTrailingWhitespace, textLayout.Height), 1, 1);
+                var constraint = LayoutHelper.RoundLayoutSizeUp(new Size(textLayout.WidthIncludingTrailingWhitespace, textLayout.Height), 1);
 
                 Assert.Equal(constraint, textBlock.DesiredSize);
             }

+ 2 - 9
tests/Avalonia.Controls.UnitTests/WindowBaseTests.cs

@@ -239,6 +239,7 @@ namespace Avalonia.Controls.UnitTests
             var renderer = new Mock<IWindowBaseImpl>();
             if (setupAllProperties)
                 renderer.SetupAllProperties();
+            renderer.Setup(x => x.RenderScaling).Returns(1.0);
             renderer.Setup(x => x.Compositor).Returns(RendererMocks.CreateDummyCompositor());
             return renderer;
         }
@@ -248,18 +249,10 @@ namespace Avalonia.Controls.UnitTests
             public bool IsClosed { get; private set; }
 
             public TestWindowBase()
-                : base(CreateWindowsBaseImplMock())
+                : base(CreateMockWindowBaseImpl().Object)
             {
             }
 
-            private static IWindowBaseImpl CreateWindowsBaseImplMock()
-            {
-                var compositor = RendererMocks.CreateDummyCompositor();
-                return Mock.Of<IWindowBaseImpl>(x =>
-                    x.RenderScaling == 1 &&
-                    x.Compositor == compositor);
-            }
-
             public TestWindowBase(IWindowBaseImpl impl)
                 : base(impl)
             {

+ 1 - 0
tests/Avalonia.Controls.UnitTests/WindowTests.cs

@@ -18,6 +18,7 @@ namespace Avalonia.Controls.UnitTests
         public void Setting_Title_Should_Set_Impl_Title()
         {
             var windowImpl = new Mock<IWindowImpl>();
+            windowImpl.Setup(r => r.RenderScaling).Returns(1.0);
             windowImpl.Setup(r => r.Compositor).Returns(RendererMocks.CreateDummyCompositor());
             var windowingPlatform = new MockWindowingPlatform(() => windowImpl.Object);