Ver código fonte

Added FormattingObjectPool to reduce temp allocs during text layout

Julien Lebosquain 2 anos atrás
pai
commit
91f89c5176

+ 5 - 0
src/Avalonia.Base/Media/TextFormatting/BidiReorderer.cs

@@ -10,9 +10,14 @@ namespace Avalonia.Media.TextFormatting
     /// <remarks>To avoid allocations, this class is designed to be reused.</remarks>
     internal sealed class BidiReorderer
     {
+        [ThreadStatic] private static BidiReorderer? t_instance;
+
         private ArrayBuilder<OrderedBidiRun> _runs;
         private ArrayBuilder<BidiRange> _ranges;
 
+        public static BidiReorderer Instance
+            => t_instance ??= new();
+
         public void BidiReorder(Span<TextRun> textRuns, FlowDirection flowDirection)
         {
             Debug.Assert(_runs.Length == 0);

+ 135 - 0
src/Avalonia.Base/Media/TextFormatting/FormattingObjectPool.cs

@@ -0,0 +1,135 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+
+namespace Avalonia.Media.TextFormatting
+{
+    /// <summary>
+    /// <para>Contains various list pools that are commonly used during text layout.</para>
+    /// <para>
+    /// This class provides an instance per thread.
+    /// In most applications, there'll be only one instance: on the UI thread, which is responsible for layout.
+    /// </para>
+    /// </summary>
+    /// <seealso cref="RentedList{T}"/>
+    internal sealed class FormattingObjectPool
+    {
+        [ThreadStatic] private static FormattingObjectPool? t_instance;
+
+        /// <summary>
+        /// Gets an instance of this class for the current thread.
+        /// </summary>
+        /// <remarks>
+        /// Since this is backed by a thread static field which is slower than a normal static field,
+        /// prefer passing the instance around when possible instead of calling this property each time.
+        /// </remarks>
+        public static FormattingObjectPool Instance
+            => t_instance ??= new();
+
+        public ListPool<TextRun> TextRunLists { get; } = new();
+
+        public ListPool<UnshapedTextRun> UnshapedTextRunLists { get; } = new();
+
+        public ListPool<TextLine> TextLines { get; } = new();
+
+        [Conditional("DEBUG")]
+        public void VerifyAllReturned()
+        {
+            TextRunLists.VerifyAllReturned();
+            UnshapedTextRunLists.VerifyAllReturned();
+            TextLines.VerifyAllReturned();
+        }
+
+        internal sealed class ListPool<T>
+        {
+            // we don't need a big number here, these are for temporary usages only which should quickly be returned
+            private const int MaxSize = 16;
+
+            private readonly RentedList<T>[] _lists = new RentedList<T>[MaxSize];
+            private int _size;
+            private int _pendingReturnCount;
+
+            /// <summary>
+            /// Rents a list.
+            /// See <see cref="RentedList{T}"/> for the intended usages.
+            /// </summary>
+            /// <returns>A rented list instance that must be returned to the pool.</returns>
+            /// <seealso cref="RentedList{T}"/>
+            public RentedList<T> Rent()
+            {
+                var list = _size > 0 ? _lists[--_size] : new RentedList<T>();
+
+                Debug.Assert(list.Count == 0, "A RentedList has been used after being returned!");
+
+                ++_pendingReturnCount;
+                return list;
+            }
+
+            /// <summary>
+            /// Returns a rented list to the pool.
+            /// </summary>
+            /// <param name="rentedList">
+            /// On input, the list to return.
+            /// On output, the reference is set to null to avoid misuse.
+            /// </param>
+            public void Return(ref RentedList<T>? rentedList)
+            {
+                if (rentedList is null)
+                {
+                    return;
+                }
+
+                --_pendingReturnCount;
+                rentedList.Clear();
+
+                if (_size < MaxSize)
+                {
+                    _lists[_size++] = rentedList;
+                }
+
+                rentedList = null;
+            }
+
+            [Conditional("DEBUG")]
+            public void VerifyAllReturned()
+            {
+                if (_pendingReturnCount > 0)
+                {
+                    throw new InvalidOperationException(
+                        $"{_pendingReturnCount} RentedList<{typeof(T).Name} haven't been returned to the pool!");
+                }
+
+                if (_pendingReturnCount < 0)
+                {
+                    throw new InvalidOperationException(
+                        $"{-_pendingReturnCount} RentedList<{typeof(T).Name} extra lists have been returned to the pool!");
+                }
+            }
+        }
+
+        /// <summary>
+        /// <para>Represents a list that has been rented through <see cref="FormattingObjectPool"/>.</para>
+        /// <para>
+        /// This class can be used when a temporary list is needed to store some items during text layout.
+        /// It can also be used as a reusable array builder by calling <see cref="List{T}.ToArray"/> when done.
+        /// </para>
+        /// <list type="bullet">
+        ///   <item>NEVER use an instance of this type after it's been returned to the pool.</item>
+        ///   <item>AVOID storing an instance of this type into a field or property.</item>
+        ///   <item>AVOID casting an instance of this type to another type.</item>
+        ///   <item>
+        ///     AVOID passing an instance of this type as an argument to a method expecting a standard list,
+        ///     unless you're absolutely sure it won't store it.
+        ///   </item>
+        ///   <item>
+        ///     If you call a method returning an instance of this type,
+        ///     you're now responsible for returning it to the pool.
+        ///   </item>
+        /// </list>
+        /// </summary>
+        /// <typeparam name="T">The type of elements in the list.</typeparam>
+        internal sealed class RentedList<T> : List<T>
+        {
+        }
+    }
+}

+ 2 - 1
src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs

@@ -48,8 +48,9 @@ namespace Avalonia.Media.TextFormatting
 
             var currentPosition = textLine.FirstTextSourceIndex;
 
-            foreach (var textRun in lineImpl.TextRuns)
+            for (var i = 0; i < lineImpl.TextRuns.Count; ++i)
             {
+                var textRun = lineImpl.TextRuns[i];
                 var text = textRun.Text;
 
                 if (text.IsEmpty)

+ 2 - 2
src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs

@@ -1,6 +1,6 @@
 using System;
-using System.Collections.Generic;
 using Avalonia.Media.TextFormatting.Unicode;
+using static Avalonia.Media.TextFormatting.FormattingObjectPool;
 
 namespace Avalonia.Media.TextFormatting
 {
@@ -47,7 +47,7 @@ namespace Avalonia.Media.TextFormatting
         /// </summary>
         /// <returns>The shapeable text characters.</returns>
         internal void GetShapeableCharacters(ReadOnlyMemory<char> text, sbyte biDiLevel,
-            ref TextRunProperties? previousProperties, List<TextRun> results)
+            ref TextRunProperties? previousProperties, RentedList<TextRun> results)
         {
             var properties = Properties;
 

+ 6 - 11
src/Avalonia.Base/Media/TextFormatting/TextEllipsisHelper.cs

@@ -1,8 +1,6 @@
 using System;
 using System.Collections.Generic;
-using System.Linq;
 using Avalonia.Media.TextFormatting.Unicode;
-using Avalonia.Utilities;
 
 namespace Avalonia.Media.TextFormatting
 {
@@ -111,20 +109,17 @@ namespace Avalonia.Media.TextFormatting
                 return new[] { shapedSymbol };
             }
 
-            // perf note: the runs are very likely to come from TextLineImpl
-            // which already uses an array: ToArray() won't ever be called in this case
-            var textRunArray = textRuns as TextRun[] ?? textRuns.ToArray();
+            var objectPool = FormattingObjectPool.Instance;
 
-            var (preSplitRuns, _) = TextFormatterImpl.SplitTextRuns(textRunArray, collapsedLength);
+            var (preSplitRuns, postSplitRuns) = TextFormatterImpl.SplitTextRuns(textRuns, collapsedLength, objectPool);
 
             var collapsedRuns = new TextRun[preSplitRuns.Count + 1];
+            preSplitRuns.CopyTo(collapsedRuns);
+            collapsedRuns[collapsedRuns.Length - 1] = shapedSymbol;
 
-            for (var i = 0; i < preSplitRuns.Count; ++i)
-            {
-                collapsedRuns[i] = preSplitRuns[i];
-            }
+            objectPool.TextRunLists.Return(ref preSplitRuns);
+            objectPool.TextRunLists.Return(ref postSplitRuns);
 
-            collapsedRuns[collapsedRuns.Length - 1] = shapedSymbol;
             return collapsedRuns;
         }
     }

+ 93 - 56
src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs

@@ -1,14 +1,16 @@
-using System;
+// ReSharper disable ForCanBeConvertedToForeach
+using System;
 using System.Buffers;
 using System.Collections.Generic;
 using System.Linq;
 using System.Runtime.InteropServices;
 using Avalonia.Media.TextFormatting.Unicode;
 using Avalonia.Utilities;
+using static Avalonia.Media.TextFormatting.FormattingObjectPool;
 
 namespace Avalonia.Media.TextFormatting
 {
-    internal class TextFormatterImpl : TextFormatter
+    internal sealed class TextFormatterImpl : TextFormatter
     {
         private static readonly char[] s_empty = { ' ' };
         private static readonly char[] s_defaultText = new char[TextRun.DefaultTextSourceLength];
@@ -23,20 +25,25 @@ namespace Avalonia.Media.TextFormatting
             var textWrapping = paragraphProperties.TextWrapping;
             FlowDirection resolvedFlowDirection;
             TextLineBreak? nextLineBreak = null;
-            IReadOnlyList<TextRun> textRuns;
+            IReadOnlyList<TextRun>? textRuns;
+            var objectPool = FormattingObjectPool.Instance;
 
-            var fetchedRuns = FetchTextRuns(textSource, firstTextSourceIndex,
+            var fetchedRuns = FetchTextRuns(textSource, firstTextSourceIndex, objectPool,
                 out var textEndOfLine, out var textSourceLength);
 
+            RentedList<TextRun>? shapedTextRuns;
+
             if (previousLineBreak?.RemainingRuns is { } remainingRuns)
             {
                 resolvedFlowDirection = previousLineBreak.FlowDirection;
                 textRuns = remainingRuns;
                 nextLineBreak = previousLineBreak;
+                shapedTextRuns = null;
             }
             else
             {
-                textRuns = ShapeTextRuns(fetchedRuns, paragraphProperties, out resolvedFlowDirection);
+                shapedTextRuns = ShapeTextRuns(fetchedRuns, paragraphProperties, objectPool, out resolvedFlowDirection);
+                textRuns = shapedTextRuns;
 
                 if (nextLineBreak == null && textEndOfLine != null)
                 {
@@ -49,25 +56,32 @@ namespace Avalonia.Media.TextFormatting
             switch (textWrapping)
             {
                 case TextWrapping.NoWrap:
-                    {
-                        textLine = new TextLineImpl(textRuns.ToArray(), firstTextSourceIndex, textSourceLength,
-                            paragraphWidth, paragraphProperties, resolvedFlowDirection, nextLineBreak);
+                {
+                    // perf note: if textRuns comes from remainingRuns above, it's very likely coming from this class
+                    // which already uses an array: ToArray() won't ever be called in this case
+                    var textRunArray = textRuns as TextRun[] ?? textRuns.ToArray();
 
-                        textLine.FinalizeLine();
+                    textLine = new TextLineImpl(textRunArray, firstTextSourceIndex, textSourceLength,
+                        paragraphWidth, paragraphProperties, resolvedFlowDirection, nextLineBreak);
 
-                        break;
-                    }
+                    textLine.FinalizeLine();
+
+                    break;
+                }
                 case TextWrapping.WrapWithOverflow:
                 case TextWrapping.Wrap:
-                    {
-                        textLine = PerformTextWrapping(textRuns, firstTextSourceIndex, paragraphWidth, paragraphProperties,
-                            resolvedFlowDirection, nextLineBreak);
-                        break;
-                    }
+                {
+                    textLine = PerformTextWrapping(textRuns, firstTextSourceIndex, paragraphWidth,
+                        paragraphProperties, resolvedFlowDirection, nextLineBreak, objectPool);
+                    break;
+                }
                 default:
                     throw new ArgumentOutOfRangeException(nameof(textWrapping));
             }
 
+            objectPool.TextRunLists.Return(ref shapedTextRuns);
+            objectPool.TextRunLists.Return(ref fetchedRuns);
+
             return textLine;
         }
 
@@ -76,9 +90,12 @@ namespace Avalonia.Media.TextFormatting
         /// </summary>
         /// <param name="textRuns">The text run's.</param>
         /// <param name="length">The length to split at.</param>
+        /// <param name="objectPool">A pool used to get reusable formatting objects.</param>
         /// <returns>The split text runs.</returns>
-        internal static SplitResult<IReadOnlyList<TextRun>> SplitTextRuns(IReadOnlyList<TextRun> textRuns, int length)
+        internal static SplitResult<RentedList<TextRun>> SplitTextRuns(IReadOnlyList<TextRun> textRuns, int length,
+            FormattingObjectPool objectPool)
         {
+            var first = objectPool.TextRunLists.Rent();
             var currentLength = 0;
 
             for (var i = 0; i < textRuns.Count; i++)
@@ -94,8 +111,6 @@ namespace Avalonia.Media.TextFormatting
 
                 var firstCount = currentRun.Length >= 1 ? i + 1 : i;
 
-                var first = new List<TextRun>(firstCount);
-
                 if (firstCount > 1)
                 {
                     for (var j = 0; j < i; j++)
@@ -108,7 +123,7 @@ namespace Avalonia.Media.TextFormatting
 
                 if (currentLength + currentRun.Length == length)
                 {
-                    var second = secondCount > 0 ? new List<TextRun>(secondCount) : null;
+                    var second = secondCount > 0 ? objectPool.TextRunLists.Rent() : null;
 
                     if (second != null)
                     {
@@ -122,13 +137,13 @@ namespace Avalonia.Media.TextFormatting
 
                     first.Add(currentRun);
 
-                    return new SplitResult<IReadOnlyList<TextRun>>(first, second);
+                    return new SplitResult<RentedList<TextRun>>(first, second);
                 }
                 else
                 {
                     secondCount++;
 
-                    var second = new List<TextRun>(secondCount);
+                    var second = objectPool.TextRunLists.Rent();
 
                     if (currentRun is ShapedTextRun shapedTextCharacters)
                     {
@@ -144,11 +159,16 @@ namespace Avalonia.Media.TextFormatting
                         second.Add(textRuns[i + j]);
                     }
 
-                    return new SplitResult<IReadOnlyList<TextRun>>(first, second);
+                    return new SplitResult<RentedList<TextRun>>(first, second);
                 }
             }
 
-            return new SplitResult<IReadOnlyList<TextRun>>(textRuns, null);
+            for (var i = 0; i < textRuns.Count; i++)
+            {
+                first.Add(textRuns[i]);
+            }
+
+            return new SplitResult<RentedList<TextRun>>(first, null);
         }
 
         /// <summary>
@@ -157,14 +177,16 @@ namespace Avalonia.Media.TextFormatting
         /// <param name="textRuns">The text runs to shape.</param>
         /// <param name="paragraphProperties">The default paragraph properties.</param>
         /// <param name="resolvedFlowDirection">The resolved flow direction.</param>
+        /// <param name="objectPool">A pool used to get reusable formatting objects.</param>
         /// <returns>
         /// A list of shaped text characters.
         /// </returns>
-        private static List<TextRun> ShapeTextRuns(List<TextRun> textRuns, TextParagraphProperties paragraphProperties,
+        private static RentedList<TextRun> ShapeTextRuns(IReadOnlyList<TextRun> textRuns,
+            TextParagraphProperties paragraphProperties, FormattingObjectPool objectPool,
             out FlowDirection resolvedFlowDirection)
         {
             var flowDirection = paragraphProperties.FlowDirection;
-            var shapedRuns = new List<TextRun>();
+            var shapedRuns = objectPool.TextRunLists.Rent();
 
             if (textRuns.Count == 0)
             {
@@ -172,13 +194,14 @@ namespace Avalonia.Media.TextFormatting
                 return shapedRuns;
             }
 
-
-            var bidiData = t_bidiData ??= new BidiData();
+            var bidiData = t_bidiData ??= new();
             bidiData.Reset();
             bidiData.ParagraphEmbeddingLevel = (sbyte)flowDirection;
 
-            foreach (var textRun in textRuns)
+            for (var i = 0; i < textRuns.Count; ++i)
             {
+                var textRun = textRuns[i];
+
                 ReadOnlySpan<char> text;
                 if (!textRun.Text.IsEmpty)
                     text = textRun.Text.Span;
@@ -190,8 +213,7 @@ namespace Avalonia.Media.TextFormatting
                 bidiData.Append(text);
             }
 
-            var bidiAlgorithm = t_bidiAlgorithm ??= new BidiAlgorithm();
-
+            var bidiAlgorithm = t_bidiAlgorithm ??= new();
             bidiAlgorithm.Process(bidiData);
 
             var resolvedEmbeddingLevel = bidiAlgorithm.ResolveEmbeddingLevel(bidiData.Classes);
@@ -199,9 +221,11 @@ namespace Avalonia.Media.TextFormatting
             resolvedFlowDirection =
                 (resolvedEmbeddingLevel & 1) == 0 ? FlowDirection.LeftToRight : FlowDirection.RightToLeft;
 
-            var processedRuns = new List<TextRun>(textRuns.Count);
+            var processedRuns = objectPool.TextRunLists.Rent();
+
+            CoalesceLevels(textRuns, bidiAlgorithm.ResolvedLevels.Span, processedRuns);
 
-            CoalesceLevels(textRuns, bidiAlgorithm.ResolvedLevels, processedRuns);
+            var groupedRuns = objectPool.UnshapedTextRunLists.Rent();
 
             for (var index = 0; index < processedRuns.Count; index++)
             {
@@ -210,8 +234,9 @@ namespace Avalonia.Media.TextFormatting
                 switch (currentRun)
                 {
                     case UnshapedTextRun shapeableRun:
-                        {
-                            var groupedRuns = new List<UnshapedTextRun>(2) { shapeableRun };
+                    {
+                            groupedRuns.Clear();
+                            groupedRuns.Add(shapeableRun);
                             var text = shapeableRun.Text;
 
                             while (index + 1 < processedRuns.Count)
@@ -253,6 +278,9 @@ namespace Avalonia.Media.TextFormatting
                 }
             }
 
+            objectPool.TextRunLists.Return(ref processedRuns);
+            objectPool.UnshapedTextRunLists.Return(ref groupedRuns);
+
             return shapedRuns;
         }
 
@@ -319,14 +347,13 @@ namespace Avalonia.Media.TextFormatting
             }
         }
 
-
         private static bool CanShapeTogether(TextRunProperties x, TextRunProperties y)
             => MathUtilities.AreClose(x.FontRenderingEmSize, y.FontRenderingEmSize)
                && x.Typeface == y.Typeface
                && x.BaselineAlignment == y.BaselineAlignment;
 
         private static void ShapeTogether(IReadOnlyList<UnshapedTextRun> textRuns, ReadOnlyMemory<char> text,
-            TextShaperOptions options, List<TextRun> results)
+            TextShaperOptions options, RentedList<TextRun> results)
         {
             var shapedBuffer = TextShaper.Current.ShapeText(text, options);
 
@@ -349,8 +376,8 @@ namespace Avalonia.Media.TextFormatting
         /// <param name="levels">The bidi levels.</param>
         /// <param name="processedRuns">A list that will be filled with the processed runs.</param>
         /// <returns></returns>
-        private static void CoalesceLevels(IReadOnlyList<TextRun> textCharacters, ArraySlice<sbyte> levels,
-            List<TextRun> processedRuns)
+        private static void CoalesceLevels(IReadOnlyList<TextRun> textCharacters, ReadOnlySpan<sbyte> levels,
+            RentedList<TextRun> processedRuns)
         {
             if (levels.Length == 0)
             {
@@ -437,19 +464,20 @@ namespace Avalonia.Media.TextFormatting
         /// </summary>
         /// <param name="textSource">The text source.</param>
         /// <param name="firstTextSourceIndex">The first text source index.</param>
+        /// <param name="objectPool">A pool used to get reusable formatting objects.</param>
         /// <param name="endOfLine">On return, the end of line, if any.</param>
         /// <param name="textSourceLength">On return, the processed text source length.</param>
         /// <returns>
         /// The formatted text runs.
         /// </returns>
-        private static List<TextRun> FetchTextRuns(ITextSource textSource, int firstTextSourceIndex,
-            out TextEndOfLine? endOfLine, out int textSourceLength)
+        private static RentedList<TextRun> FetchTextRuns(ITextSource textSource, int firstTextSourceIndex,
+            FormattingObjectPool objectPool, out TextEndOfLine? endOfLine, out int textSourceLength)
         {
             textSourceLength = 0;
 
             endOfLine = null;
 
-            var textRuns = new List<TextRun>();
+            var textRuns = objectPool.TextRunLists.Rent();
 
             var textRunEnumerator = new TextRunEnumerator(textSource, firstTextSourceIndex);
 
@@ -543,8 +571,10 @@ namespace Avalonia.Media.TextFormatting
             measuredLength = 0;
             var currentWidth = 0.0;
 
-            foreach (var currentRun in textRuns)
+            for (var i = 0; i < textRuns.Count; ++i)
             {
+                var currentRun = textRuns[i];
+
                 switch (currentRun)
                 {
                     case ShapedTextRun shapedTextCharacters:
@@ -554,15 +584,15 @@ namespace Avalonia.Media.TextFormatting
                                 var firstCluster = shapedTextCharacters.ShapedBuffer.GlyphInfos[0].GlyphCluster;
                                 var lastCluster = firstCluster;
 
-                                for (var i = 0; i < shapedTextCharacters.ShapedBuffer.Length; i++)
+                                for (var j = 0; j < shapedTextCharacters.ShapedBuffer.Length; j++)
                                 {
-                                    var glyphInfo = shapedTextCharacters.ShapedBuffer[i];
+                                    var glyphInfo = shapedTextCharacters.ShapedBuffer[j];
 
                                     if (currentWidth + glyphInfo.GlyphAdvance > paragraphWidth)
                                     {
                                         measuredLength += Math.Max(0, lastCluster - firstCluster);
 
-                                        goto found;
+                                        return measuredLength != 0;
                                     }
 
                                     lastCluster = glyphInfo.GlyphCluster;
@@ -579,7 +609,7 @@ namespace Avalonia.Media.TextFormatting
                         {
                             if (currentWidth + drawableTextRun.Size.Width >= paragraphWidth)
                             {
-                                goto found;
+                                return measuredLength != 0;
                             }
 
                             measuredLength += currentRun.Length;
@@ -596,8 +626,6 @@ namespace Avalonia.Media.TextFormatting
                 }
             }
 
-        found:
-
             return measuredLength != 0;
         }
 
@@ -605,7 +633,8 @@ namespace Avalonia.Media.TextFormatting
         /// Creates an empty text line.
         /// </summary>
         /// <returns>The empty text line.</returns>
-        public static TextLineImpl CreateEmptyTextLine(int firstTextSourceIndex, double paragraphWidth, TextParagraphProperties paragraphProperties)
+        public static TextLineImpl CreateEmptyTextLine(int firstTextSourceIndex, double paragraphWidth,
+            TextParagraphProperties paragraphProperties, FormattingObjectPool objectPool)
         {
             var flowDirection = paragraphProperties.FlowDirection;
             var properties = paragraphProperties.DefaultTextRunProperties;
@@ -618,7 +647,9 @@ namespace Avalonia.Media.TextFormatting
 
             var textRuns = new TextRun[] { new ShapedTextRun(shapedBuffer, properties) };
 
-            return new TextLineImpl(textRuns, firstTextSourceIndex, 0, paragraphWidth, paragraphProperties, flowDirection).FinalizeLine();
+            var line = new TextLineImpl(textRuns, firstTextSourceIndex, 0, paragraphWidth, paragraphProperties, flowDirection);
+            line.FinalizeLine();
+            return line;
         }
 
         /// <summary>
@@ -630,14 +661,15 @@ namespace Avalonia.Media.TextFormatting
         /// <param name="paragraphProperties">The text paragraph properties.</param>
         /// <param name="resolvedFlowDirection"></param>
         /// <param name="currentLineBreak">The current line break if the line was explicitly broken.</param>
+        /// <param name="objectPool">A pool used to get reusable formatting objects.</param>
         /// <returns>The wrapped text line.</returns>
         private static TextLineImpl PerformTextWrapping(IReadOnlyList<TextRun> textRuns, int firstTextSourceIndex,
             double paragraphWidth, TextParagraphProperties paragraphProperties, FlowDirection resolvedFlowDirection,
-            TextLineBreak? currentLineBreak)
+            TextLineBreak? currentLineBreak, FormattingObjectPool objectPool)
         {
             if (textRuns.Count == 0)
             {
-                return CreateEmptyTextLine(firstTextSourceIndex, paragraphWidth, paragraphProperties);
+                return CreateEmptyTextLine(firstTextSourceIndex, paragraphWidth, paragraphProperties, objectPool);
             }
 
             if (!TryMeasureLength(textRuns, paragraphWidth, out var measuredLength))
@@ -763,10 +795,10 @@ namespace Avalonia.Media.TextFormatting
                 break;
             }
 
-            var (preSplitRuns, postSplitRuns) = SplitTextRuns(textRuns, measuredLength);
+            var (preSplitRuns, postSplitRuns) = SplitTextRuns(textRuns, measuredLength, objectPool);
 
             var lineBreak = postSplitRuns?.Count > 0 ?
-                new TextLineBreak(null, resolvedFlowDirection, postSplitRuns) :
+                new TextLineBreak(null, resolvedFlowDirection, postSplitRuns.ToArray()) :
                 null;
 
             if (lineBreak is null && currentLineBreak?.TextEndOfLine != null)
@@ -778,7 +810,12 @@ namespace Avalonia.Media.TextFormatting
                 paragraphWidth, paragraphProperties, resolvedFlowDirection,
                 lineBreak);
 
-            return textLine.FinalizeLine();
+            textLine.FinalizeLine();
+
+            objectPool.TextRunLists.Return(ref preSplitRuns);
+            objectPool.TextRunLists.Return(ref postSplitRuns);
+
+            return textLine;
         }
 
         private struct TextRunEnumerator

+ 17 - 8
src/Avalonia.Base/Media/TextFormatting/TextLayout.cs

@@ -426,16 +426,19 @@ namespace Avalonia.Media.TextFormatting
 
         private TextLine[] CreateTextLines()
         {
+            var objectPool = FormattingObjectPool.Instance;
+
             if (MathUtilities.IsZero(MaxWidth) || MathUtilities.IsZero(MaxHeight))
             {
-                var textLine = TextFormatterImpl.CreateEmptyTextLine(0, double.PositiveInfinity, _paragraphProperties);
+                var textLine = TextFormatterImpl.CreateEmptyTextLine(0, double.PositiveInfinity, _paragraphProperties,
+                    FormattingObjectPool.Instance);
 
                 Bounds = new Rect(0, 0, 0, textLine.Height);
 
                 return new TextLine[] { textLine };
             }
 
-            var textLines = new List<TextLine>();
+            var textLines = objectPool.TextLines.Rent();
 
             double left = double.PositiveInfinity, width = 0.0, height = 0.0;
 
@@ -447,14 +450,15 @@ namespace Avalonia.Media.TextFormatting
 
             while (true)
             {
-                var textLine = textFormatter.FormatLine(_textSource, _textSourceLength, MaxWidth,
-                    _paragraphProperties, previousLine?.TextLineBreak);
+                var textLine = textFormatter.FormatLine(_textSource, _textSourceLength, MaxWidth, _paragraphProperties,
+                    previousLine?.TextLineBreak);
 
                 if (textLine.Length == 0)
                 {
                     if (previousLine != null && previousLine.NewLineLength > 0)
                     {
-                        var emptyTextLine = TextFormatterImpl.CreateEmptyTextLine(_textSourceLength, MaxWidth, _paragraphProperties);
+                        var emptyTextLine = TextFormatterImpl.CreateEmptyTextLine(_textSourceLength, MaxWidth,
+                            _paragraphProperties, objectPool);
 
                         textLines.Add(emptyTextLine);
 
@@ -496,7 +500,7 @@ namespace Avalonia.Media.TextFormatting
                 //Fulfill max lines constraint
                 if (MaxLines > 0 && textLines.Count >= MaxLines)
                 {
-                    if(textLine.TextLineBreak is TextLineBreak lineBreak && lineBreak.RemainingRuns != null)
+                    if(textLine.TextLineBreak?.RemainingRuns is not null)
                     {
                         textLines[textLines.Count - 1] = textLine.Collapse(GetCollapsingProperties(width));
                     }
@@ -513,7 +517,7 @@ namespace Avalonia.Media.TextFormatting
             //Make sure the TextLayout always contains at least on empty line
             if (textLines.Count == 0)
             {
-                var textLine = TextFormatterImpl.CreateEmptyTextLine(0, MaxWidth, _paragraphProperties);
+                var textLine = TextFormatterImpl.CreateEmptyTextLine(0, MaxWidth, _paragraphProperties, objectPool);
 
                 textLines.Add(textLine);
 
@@ -552,7 +556,12 @@ namespace Avalonia.Media.TextFormatting
                 }
             }
 
-            return textLines.ToArray();
+            var result = textLines.ToArray();
+
+            objectPool.TextLines.Return(ref textLines);
+            objectPool.VerifyAllReturned();
+
+            return result;
         }
 
         /// <summary>

+ 51 - 39
src/Avalonia.Base/Media/TextFormatting/TextLeadingPrefixCharacterEllipsis.cs

@@ -1,7 +1,7 @@
 // ReSharper disable ForCanBeConvertedToForeach
 using System;
 using System.Collections.Generic;
-using System.Linq;
+using static Avalonia.Media.TextFormatting.FormattingObjectPool;
 
 namespace Avalonia.Media.TextFormatting
 {
@@ -80,60 +80,64 @@ namespace Avalonia.Media.TextFormatting
 
                                 if (measuredLength > 0)
                                 {
-                                    var collapsedRuns = new List<TextRun>(textRuns.Count + 1);
+                                    var objectPool = FormattingObjectPool.Instance;
 
-                                    // perf note: the runs are very likely to come from TextLineImpl,
-                                    // which already uses an array: ToArray() won't ever be called in this case
-                                    var textRunArray = textRuns as TextRun[] ?? textRuns.ToArray();
+                                    var collapsedRuns = objectPool.TextRunLists.Rent();
 
-                                    IReadOnlyList<TextRun>? preSplitRuns;
-                                    IReadOnlyList<TextRun>? postSplitRuns;
+                                    RentedList<TextRun>? rentedPreSplitRuns = null;
+                                    RentedList<TextRun>? rentedPostSplitRuns = null;
+                                    TextRun[]? results;
 
-                                    if (_prefixLength > 0)
+                                    try
                                     {
-                                        (preSplitRuns, postSplitRuns) = TextFormatterImpl.SplitTextRuns(
-                                            textRunArray, Math.Min(_prefixLength, measuredLength));
+                                        IReadOnlyList<TextRun>? effectivePostSplitRuns;
 
-                                        for (var i = 0; i < preSplitRuns.Count; i++)
+                                        if (_prefixLength > 0)
                                         {
-                                            var preSplitRun = preSplitRuns[i];
-                                            collapsedRuns.Add(preSplitRun);
+                                            (rentedPreSplitRuns, rentedPostSplitRuns) = TextFormatterImpl.SplitTextRuns(
+                                                textRuns, Math.Min(_prefixLength, measuredLength), objectPool);
+
+                                            effectivePostSplitRuns = rentedPostSplitRuns;
+
+                                            foreach (var preSplitRun in rentedPreSplitRuns)
+                                            {
+                                                collapsedRuns.Add(preSplitRun);
+                                            }
+                                        }
+                                        else
+                                        {
+                                            effectivePostSplitRuns = textRuns;
                                         }
-                                    }
-                                    else
-                                    {
-                                        preSplitRuns = null;
-                                        postSplitRuns = textRunArray;
-                                    }
 
-                                    collapsedRuns.Add(shapedSymbol);
+                                        collapsedRuns.Add(shapedSymbol);
 
-                                    if (measuredLength <= _prefixLength || postSplitRuns is null)
-                                    {
-                                        return collapsedRuns.ToArray();
-                                    }
+                                        if (measuredLength <= _prefixLength || effectivePostSplitRuns is null)
+                                        {
+                                            results = collapsedRuns.ToArray();
+                                            objectPool.TextRunLists.Return(ref collapsedRuns);
+                                            return results;
+                                        }
 
-                                    var availableSuffixWidth = availableWidth;
+                                        var availableSuffixWidth = availableWidth;
 
-                                    if (preSplitRuns is not null)
-                                    {
-                                        for (var i = 0; i < preSplitRuns.Count; i++)
+                                        if (rentedPreSplitRuns is not null)
                                         {
-                                            var run = preSplitRuns[i];
-                                            if (run is DrawableTextRun drawableTextRun)
+                                            foreach (var run in rentedPreSplitRuns)
                                             {
-                                                availableSuffixWidth -= drawableTextRun.Size.Width;
+                                                if (run is DrawableTextRun drawableTextRun)
+                                                {
+                                                    availableSuffixWidth -= drawableTextRun.Size.Width;
+                                                }
                                             }
                                         }
-                                    }
-
-                                    for (var i = postSplitRuns.Count - 1; i >= 0; i--)
-                                    {
-                                        var run = postSplitRuns[i];
 
-                                        switch (run)
+                                        for (var i = effectivePostSplitRuns.Count - 1; i >= 0; i--)
                                         {
-                                            case ShapedTextRun endShapedRun:
+                                            var run = effectivePostSplitRuns[i];
+
+                                            switch (run)
+                                            {
+                                                case ShapedTextRun endShapedRun:
                                                 {
                                                     if (endShapedRun.TryMeasureCharactersBackwards(availableSuffixWidth,
                                                             out var suffixCount, out var suffixWidth))
@@ -151,10 +155,18 @@ namespace Avalonia.Media.TextFormatting
 
                                                     break;
                                                 }
+                                            }
                                         }
                                     }
+                                    finally
+                                    {
+                                        objectPool.TextRunLists.Return(ref rentedPreSplitRuns);
+                                        objectPool.TextRunLists.Return(ref rentedPostSplitRuns);
+                                    }
 
-                                    return collapsedRuns.ToArray();
+                                    results = collapsedRuns.ToArray();
+                                    objectPool.TextRunLists.Return(ref collapsedRuns);
+                                    return results;
                                 }
 
                                 return new TextRun[] { shapedSymbol };

+ 10 - 16
src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs

@@ -1,14 +1,11 @@
 using System;
 using System.Collections.Generic;
-using System.Threading;
 using Avalonia.Utilities;
 
 namespace Avalonia.Media.TextFormatting
 {
     internal sealed class TextLineImpl : TextLine
     {
-        private static readonly ThreadLocal<BidiReorderer> s_bidiReorderer = new(() => new BidiReorderer());
-
         private readonly TextRun[] _textRuns;
         private readonly double _paragraphWidth;
         private readonly TextParagraphProperties _paragraphProperties;
@@ -570,7 +567,7 @@ namespace Avalonia.Media.TextFormatting
         {
             var characterIndex = firstTextSourceIndex + textLength;
 
-            var result = new List<TextBounds>(TextRuns.Count);
+            var result = new List<TextBounds>(_textRuns.Length);
             var lastDirection = FlowDirection.LeftToRight;
             var currentDirection = lastDirection;
 
@@ -583,9 +580,9 @@ namespace Avalonia.Media.TextFormatting
 
             TextRunBounds lastRunBounds = default;
 
-            for (var index = 0; index < TextRuns.Count; index++)
+            for (var index = 0; index < _textRuns.Length; index++)
             {
-                if (TextRuns[index] is not DrawableTextRun currentRun)
+                if (_textRuns[index] is not DrawableTextRun currentRun)
                 {
                     continue;
                 }
@@ -671,12 +668,12 @@ namespace Avalonia.Media.TextFormatting
 
                         for (int i = rightToLeftIndex - 1; i >= index; i--)
                         {
-                            if (TextRuns[i] is not ShapedTextRun)
+                            if (_textRuns[i] is not ShapedTextRun shapedRun)
                             {
                                 continue;
                             }
 
-                            currentShapedRun = (ShapedTextRun)TextRuns[i];
+                            currentShapedRun = shapedRun;
 
                             currentRunBounds = GetRightToLeftTextRunBounds(currentShapedRun, startX, firstTextSourceIndex, characterIndex, currentPosition, remainingLength);
 
@@ -786,7 +783,7 @@ namespace Avalonia.Media.TextFormatting
         {
             var characterIndex = firstTextSourceIndex + textLength;
 
-            var result = new List<TextBounds>(TextRuns.Count);
+            var result = new List<TextBounds>(_textRuns.Length);
             var lastDirection = FlowDirection.LeftToRight;
             var currentDirection = lastDirection;
 
@@ -797,9 +794,9 @@ namespace Avalonia.Media.TextFormatting
             double currentWidth = 0;
             var currentRect = default(Rect);
 
-            for (var index = TextRuns.Count - 1; index >= 0; index--)
+            for (var index = _textRuns.Length - 1; index >= 0; index--)
             {
-                if (TextRuns[index] is not DrawableTextRun currentRun)
+                if (_textRuns[index] is not DrawableTextRun currentRun)
                 {
                     continue;
                 }
@@ -992,14 +989,11 @@ namespace Avalonia.Media.TextFormatting
             }
         }
 
-        public TextLineImpl FinalizeLine()
+        public void FinalizeLine()
         {
             _textLineMetrics = CreateLineMetrics();
 
-            var bidiReorderer = s_bidiReorderer.Value!;
-            bidiReorderer.BidiReorder(_textRuns, _resolvedFlowDirection);
-
-            return this;
+            BidiReorderer.Instance.BidiReorder(_textRuns, _resolvedFlowDirection);
         }
 
         /// <summary>

+ 34 - 2
src/Avalonia.Base/Utilities/ArrayBuilder.cs

@@ -12,7 +12,6 @@ namespace Avalonia.Utilities
     /// </summary>
     /// <typeparam name="T">The type of item contained in the array.</typeparam>
     internal struct ArrayBuilder<T>
-        where T : struct
     {
         private const int DefaultCapacity = 4;
         private const int MaxCoreClrArrayLength = 0x7FeFFFFF;
@@ -48,6 +47,12 @@ namespace Avalonia.Utilities
             }
         }
 
+        /// <summary>
+        /// Gets the current capacity of the array.
+        /// </summary>
+        public int Capacity
+            => _data?.Length ?? 0;
+
         /// <summary>
         /// Returns a reference to specified element of the array.
         /// </summary>
@@ -131,8 +136,28 @@ namespace Avalonia.Utilities
         /// </summary>
         public void Clear()
         {
-            // No need to actually clear since we're not allowing reference types.
+#if NET6_0_OR_GREATER
+            if (RuntimeHelpers.IsReferenceOrContainsReferences<T>())
+            {
+                ClearArray();
+            }
+            else
+            {
+                _size = 0;
+            }
+#else
+            ClearArray();
+#endif
+        }
+
+        private void ClearArray()
+        {
+            var size = _size;
             _size = 0;
+            if (size > 0)
+            {
+                Array.Clear(_data!, 0, size);
+            }
         }
 
         private void EnsureCapacity(int min)
@@ -190,5 +215,12 @@ namespace Avalonia.Utilities
         /// <returns>The <see cref="ArraySlice{T}"/>.</returns>
         [MethodImpl(MethodImplOptions.AggressiveInlining)]
         public ArraySlice<T> AsSlice(int start, int length) => new ArraySlice<T>(_data!, start, length);
+
+        /// <summary>
+        /// Returns the current state of the array as a span.
+        /// </summary>
+        /// <returns>The <see cref="Span{T}"/>.</returns>
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public Span<T> AsSpan() => _data.AsSpan(0, _size);
     }
 }

+ 0 - 1
src/Avalonia.Base/Utilities/ArraySlice.cs

@@ -17,7 +17,6 @@ namespace Avalonia.Utilities
     /// </summary>
     /// <typeparam name="T">The type of item contained in the slice.</typeparam>
     internal readonly struct ArraySlice<T> : IReadOnlyList<T>
-        where T : struct
     {
         /// <summary>
         /// Gets an empty <see cref="ArraySlice{T}"/>

+ 4 - 1
tests/Avalonia.UnitTests/MockTextShaperImpl.cs

@@ -24,7 +24,10 @@ namespace Avalonia.UnitTests
 
                 var glyphIndex = typeface.GetGlyph(codepoint);
 
-                shapedBuffer[i] = new GlyphInfo(glyphIndex, glyphCluster, 10);
+                for (var j = 0; j < count; ++j)
+                {
+                    shapedBuffer[i + j] = new GlyphInfo(glyphIndex, glyphCluster, 10);
+                }
 
                 i += count;
             }