Browse Source

Merge pull request #10068 from MrJul/textlayout-wrapping-perf

Improved text wrapping performance
Benedikt Stebner 2 năm trước cách đây
mục cha
commit
d697522bc5

+ 5 - 11
src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs

@@ -15,9 +15,7 @@ namespace Avalonia.Media.TextFormatting
 
         public override void Justify(TextLine textLine)
         {
-            var lineImpl = textLine as TextLineImpl;
-
-            if(lineImpl is null)
+            if (textLine is not TextLineImpl lineImpl)
             {
                 return;
             }
@@ -34,14 +32,9 @@ namespace Avalonia.Media.TextFormatting
                 return;
             }
 
-            var textLineBreak = lineImpl.TextLineBreak;
-
-            if (textLineBreak is not null && textLineBreak.TextEndOfLine is not null)
+            if (lineImpl.TextLineBreak is { TextEndOfLine: not null, IsSplit: false })
             {
-                if (textLineBreak.RemainingRuns is null || textLineBreak.RemainingRuns.Count == 0)
-                {
-                    return;
-                }
+                return;
             }
 
             var breakOportunities = new Queue<int>();
@@ -107,7 +100,8 @@ namespace Avalonia.Media.TextFormatting
                         var glyphIndex = glyphRun.FindGlyphIndex(characterIndex);
                         var glyphInfo = shapedBuffer.GlyphInfos[glyphIndex];
 
-                        shapedBuffer.GlyphInfos[glyphIndex] = new GlyphInfo(glyphInfo.GlyphIndex, glyphInfo.GlyphCluster, glyphInfo.GlyphAdvance + spacing);
+                        shapedBuffer.GlyphInfos[glyphIndex] = new GlyphInfo(glyphInfo.GlyphIndex,
+                            glyphInfo.GlyphCluster, glyphInfo.GlyphAdvance + spacing);
                     }
 
                     glyphRun.GlyphInfos = shapedBuffer.GlyphInfos;

+ 66 - 54
src/Avalonia.Base/Media/TextFormatting/TextFormatterImpl.cs

@@ -2,7 +2,6 @@
 using System;
 using System.Buffers;
 using System.Collections.Generic;
-using System.Linq;
 using System.Runtime.InteropServices;
 using Avalonia.Media.TextFormatting.Unicode;
 using Avalonia.Utilities;
@@ -22,68 +21,55 @@ namespace Avalonia.Media.TextFormatting
         public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth,
             TextParagraphProperties paragraphProperties, TextLineBreak? previousLineBreak = null)
         {
-            var textWrapping = paragraphProperties.TextWrapping;
-            FlowDirection resolvedFlowDirection;
             TextLineBreak? nextLineBreak = null;
-            IReadOnlyList<TextRun>? textRuns;
             var objectPool = FormattingObjectPool.Instance;
             var fontManager = FontManager.Current;
 
-            var fetchedRuns = FetchTextRuns(textSource, firstTextSourceIndex, objectPool,
-                out var textEndOfLine, out var textSourceLength);
+            // we've wrapped the previous line and need to continue wrapping: ignore the textSource and do that instead
+            if (previousLineBreak is WrappingTextLineBreak wrappingTextLineBreak
+                && wrappingTextLineBreak.AcquireRemainingRuns() is { } remainingRuns
+                && paragraphProperties.TextWrapping != TextWrapping.NoWrap)
+            {
+                return PerformTextWrapping(remainingRuns, true, firstTextSourceIndex, paragraphWidth,
+                    paragraphProperties, previousLineBreak.FlowDirection, previousLineBreak, objectPool);
+            }
 
+            RentedList<TextRun>? fetchedRuns = null;
             RentedList<TextRun>? shapedTextRuns = null;
-
             try
             {
-                if (previousLineBreak?.RemainingRuns is { } remainingRuns)
-                {
-                    resolvedFlowDirection = previousLineBreak.FlowDirection;
-                    textRuns = remainingRuns;
-                    nextLineBreak = previousLineBreak;
-                    shapedTextRuns = null;
-                }
-                else
-                {
-                    shapedTextRuns = ShapeTextRuns(fetchedRuns, paragraphProperties, objectPool, fontManager,
-                        out resolvedFlowDirection);
-                    textRuns = shapedTextRuns;
+                fetchedRuns = FetchTextRuns(textSource, firstTextSourceIndex, objectPool, out var textEndOfLine,
+                    out var textSourceLength);
 
-                    if (nextLineBreak == null && textEndOfLine != null)
-                    {
-                        nextLineBreak = new TextLineBreak(textEndOfLine, resolvedFlowDirection);
-                    }
-                }
+                shapedTextRuns = ShapeTextRuns(fetchedRuns, paragraphProperties, objectPool, fontManager,
+                    out var resolvedFlowDirection);
 
-                TextLineImpl textLine;
+                if (nextLineBreak == null && textEndOfLine != null)
+                {
+                    nextLineBreak = new TextLineBreak(textEndOfLine, resolvedFlowDirection);
+                }
 
-                switch (textWrapping)
+                switch (paragraphProperties.TextWrapping)
                 {
                     case TextWrapping.NoWrap:
                     {
-                        // 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 = new TextLineImpl(textRunArray, firstTextSourceIndex, textSourceLength,
+                        var textLine = new TextLineImpl(shapedTextRuns.ToArray(), firstTextSourceIndex,
+                            textSourceLength,
                             paragraphWidth, paragraphProperties, resolvedFlowDirection, nextLineBreak);
 
                         textLine.FinalizeLine();
 
-                        break;
+                        return textLine;
                     }
                     case TextWrapping.WrapWithOverflow:
                     case TextWrapping.Wrap:
                     {
-                        textLine = PerformTextWrapping(textRuns, firstTextSourceIndex, paragraphWidth,
-                            paragraphProperties, resolvedFlowDirection, nextLineBreak, objectPool, fontManager);
-                        break;
+                        return PerformTextWrapping(shapedTextRuns, false, firstTextSourceIndex, paragraphWidth,
+                            paragraphProperties, resolvedFlowDirection, nextLineBreak, objectPool);
                     }
                     default:
-                        throw new ArgumentOutOfRangeException(nameof(textWrapping));
+                        throw new ArgumentOutOfRangeException(nameof(paragraphProperties.TextWrapping));
                 }
-
-                return textLine;
             }
             finally
             {
@@ -108,15 +94,16 @@ namespace Avalonia.Media.TextFormatting
             for (var i = 0; i < textRuns.Count; i++)
             {
                 var currentRun = textRuns[i];
+                var currentRunLength = currentRun.Length;
 
-                if (currentLength + currentRun.Length < length)
+                if (currentLength + currentRunLength < length)
                 {
-                    currentLength += currentRun.Length;
+                    currentLength += currentRunLength;
 
                     continue;
                 }
 
-                var firstCount = currentRun.Length >= 1 ? i + 1 : i;
+                var firstCount = currentRunLength >= 1 ? i + 1 : i;
 
                 if (firstCount > 1)
                 {
@@ -128,13 +115,13 @@ namespace Avalonia.Media.TextFormatting
 
                 var secondCount = textRuns.Count - firstCount;
 
-                if (currentLength + currentRun.Length == length)
+                if (currentLength + currentRunLength == length)
                 {
                     var second = secondCount > 0 ? objectPool.TextRunLists.Rent() : null;
 
                     if (second != null)
                     {
-                        var offset = currentRun.Length >= 1 ? 1 : 0;
+                        var offset = currentRunLength >= 1 ? 1 : 0;
 
                         for (var j = 0; j < secondCount; j++)
                         {
@@ -653,7 +640,7 @@ namespace Avalonia.Media.TextFormatting
         /// </summary>
         /// <returns>The empty text line.</returns>
         public static TextLineImpl CreateEmptyTextLine(int firstTextSourceIndex, double paragraphWidth,
-            TextParagraphProperties paragraphProperties, FontManager fontManager)
+            TextParagraphProperties paragraphProperties)
         {
             var flowDirection = paragraphProperties.FlowDirection;
             var properties = paragraphProperties.DefaultTextRunProperties;
@@ -675,21 +662,21 @@ namespace Avalonia.Media.TextFormatting
         /// Performs text wrapping returns a list of text lines.
         /// </summary>
         /// <param name="textRuns"></param>
+        /// <param name="canReuseTextRunList">Whether <see cref="textRuns"/> can be reused to store the split runs.</param>
         /// <param name="firstTextSourceIndex">The first text source index.</param>
         /// <param name="paragraphWidth">The paragraph width.</param>
         /// <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>
-        /// <param name="fontManager">The font manager to use.</param>
         /// <returns>The wrapped text line.</returns>
-        private static TextLineImpl PerformTextWrapping(IReadOnlyList<TextRun> textRuns, int firstTextSourceIndex,
-            double paragraphWidth, TextParagraphProperties paragraphProperties, FlowDirection resolvedFlowDirection,
-            TextLineBreak? currentLineBreak, FormattingObjectPool objectPool, FontManager fontManager)
+        private static TextLineImpl PerformTextWrapping(List<TextRun> textRuns, bool canReuseTextRunList,
+            int firstTextSourceIndex, double paragraphWidth, TextParagraphProperties paragraphProperties,
+            FlowDirection resolvedFlowDirection, TextLineBreak? currentLineBreak, FormattingObjectPool objectPool)
         {
             if (textRuns.Count == 0)
             {
-                return CreateEmptyTextLine(firstTextSourceIndex, paragraphWidth, paragraphProperties, fontManager);
+                return CreateEmptyTextLine(firstTextSourceIndex, paragraphWidth, paragraphProperties);
             }
 
             if (!TryMeasureLength(textRuns, paragraphWidth, out var measuredLength))
@@ -819,13 +806,37 @@ namespace Avalonia.Media.TextFormatting
 
             try
             {
-                var textLineBreak = postSplitRuns?.Count > 0 ?
-                    new TextLineBreak(null, resolvedFlowDirection, postSplitRuns.ToArray()) :
-                    null;
+                TextLineBreak? textLineBreak;
+                if (postSplitRuns?.Count > 0)
+                {
+                    List<TextRun> remainingRuns;
+
+                    // reuse the list as much as possible:
+                    // if canReuseTextRunList == true it's coming from previous remaining runs
+                    if (canReuseTextRunList)
+                    {
+                        remainingRuns = textRuns;
+                        remainingRuns.Clear();
+                    }
+                    else
+                    {
+                        remainingRuns = new List<TextRun>();
+                    }
 
-                if (textLineBreak is null && currentLineBreak?.TextEndOfLine != null)
+                    for (var i = 0; i < postSplitRuns.Count; ++i)
+                    {
+                        remainingRuns.Add(postSplitRuns[i]);
+                    }
+
+                    textLineBreak = new WrappingTextLineBreak(null, resolvedFlowDirection, remainingRuns);
+                }
+                else if (currentLineBreak?.TextEndOfLine is { } textEndOfLine)
                 {
-                    textLineBreak = new TextLineBreak(currentLineBreak.TextEndOfLine, resolvedFlowDirection);
+                    textLineBreak = new TextLineBreak(textEndOfLine, resolvedFlowDirection);
+                }
+                else
+                {
+                    textLineBreak = null;
                 }
 
                 var textLine = new TextLineImpl(preSplitRuns.ToArray(), firstTextSourceIndex, measuredLength,
@@ -833,6 +844,7 @@ namespace Avalonia.Media.TextFormatting
                     textLineBreak);
 
                 textLine.FinalizeLine();
+
                 return textLine;
             }
             finally

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

@@ -416,9 +416,11 @@ namespace Avalonia.Media.TextFormatting
                 width = lineWidth;
             }
 
-            if (left > textLine.Start)
+            var start = textLine.Start;
+
+            if (left > start)
             {
-                left = textLine.Start;
+                left = start;
             }
 
             height += textLine.Height;
@@ -427,12 +429,10 @@ namespace Avalonia.Media.TextFormatting
         private TextLine[] CreateTextLines()
         {
             var objectPool = FormattingObjectPool.Instance;
-            var fontManager = FontManager.Current;
 
             if (MathUtilities.IsZero(MaxWidth) || MathUtilities.IsZero(MaxHeight))
             {
-                var textLine = TextFormatterImpl.CreateEmptyTextLine(0, double.PositiveInfinity, _paragraphProperties,
-                    fontManager);
+                var textLine = TextFormatterImpl.CreateEmptyTextLine(0, double.PositiveInfinity, _paragraphProperties);
 
                 Bounds = new Rect(0, 0, 0, textLine.Height);
 
@@ -461,7 +461,7 @@ namespace Avalonia.Media.TextFormatting
                         if (previousLine != null && previousLine.NewLineLength > 0)
                         {
                             var emptyTextLine = TextFormatterImpl.CreateEmptyTextLine(_textSourceLength, MaxWidth,
-                                _paragraphProperties, fontManager);
+                                _paragraphProperties);
 
                             textLines.Add(emptyTextLine);
 
@@ -504,7 +504,7 @@ namespace Avalonia.Media.TextFormatting
                     //Fulfill max lines constraint
                     if (MaxLines > 0 && textLines.Count >= MaxLines)
                     {
-                        if (textLine.TextLineBreak?.RemainingRuns is not null)
+                        if (textLine.TextLineBreak is { IsSplit: true })
                         {
                             textLines[textLines.Count - 1] = textLine.Collapse(GetCollapsingProperties(width));
                         }
@@ -521,8 +521,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, fontManager);
+                    var textLine = TextFormatterImpl.CreateEmptyTextLine(0, MaxWidth, _paragraphProperties);
 
                     textLines.Add(textLine);
 

+ 7 - 8
src/Avalonia.Base/Media/TextFormatting/TextLineBreak.cs

@@ -1,15 +1,13 @@
-using System.Collections.Generic;
-
-namespace Avalonia.Media.TextFormatting
+namespace Avalonia.Media.TextFormatting
 {
     public class TextLineBreak
     {
-        public TextLineBreak(TextEndOfLine? textEndOfLine = null, FlowDirection flowDirection = FlowDirection.LeftToRight, 
-            IReadOnlyList<TextRun>? remainingRuns = null)
+        public TextLineBreak(TextEndOfLine? textEndOfLine = null,
+            FlowDirection flowDirection = FlowDirection.LeftToRight, bool isSplit = false)
         {
             TextEndOfLine = textEndOfLine;
             FlowDirection = flowDirection;
-            RemainingRuns = remainingRuns;
+            IsSplit = isSplit;
         }
 
         /// <summary>
@@ -23,8 +21,9 @@ namespace Avalonia.Media.TextFormatting
         public FlowDirection FlowDirection { get; }
         
         /// <summary>
-        /// Get the remaining runs that were split up by the <see cref="TextFormatter"/> during the formatting process.
+        /// Gets whether there were remaining runs after this line break,
+        /// that were split up by the <see cref="TextFormatter"/> during the formatting process.
         /// </summary>
-        public IReadOnlyList<TextRun>? RemainingRuns { get; }
+        public bool IsSplit { get; }
     }
 }

+ 3 - 5
src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs

@@ -1285,13 +1285,11 @@ namespace Avalonia.Media.TextFormatting
                 {
                     case ShapedTextRun textRun:
                         {
-                            var properties = textRun.Properties;
-                            var textMetrics =
-                                new TextMetrics(properties.CachedGlyphTypeface, properties.FontRenderingEmSize);
+                            var textMetrics = textRun.TextMetrics;
 
-                            if (fontRenderingEmSize < properties.FontRenderingEmSize)
+                            if (fontRenderingEmSize < textMetrics.FontRenderingEmSize)
                             {
-                                fontRenderingEmSize = properties.FontRenderingEmSize;
+                                fontRenderingEmSize = textMetrics.FontRenderingEmSize;
 
                                 if (ascent > textMetrics.Ascent)
                                 {

+ 30 - 0
src/Avalonia.Base/Media/TextFormatting/WrappingTextLineBreak.cs

@@ -0,0 +1,30 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+
+namespace Avalonia.Media.TextFormatting
+{
+    /// <summary>Represents a line break that occurred due to wrapping.</summary>
+    internal sealed class WrappingTextLineBreak : TextLineBreak
+    {
+        private List<TextRun>? _remainingRuns;
+
+        public WrappingTextLineBreak(TextEndOfLine? textEndOfLine, FlowDirection flowDirection,
+            List<TextRun> remainingRuns)
+            : base(textEndOfLine, flowDirection, isSplit: true)
+        {
+            Debug.Assert(remainingRuns.Count > 0);
+            _remainingRuns = remainingRuns;
+        }
+
+        /// <summary>
+        /// Gets the remaining runs from this line break, and clears them from this line break.
+        /// </summary>
+        /// <returns>A list of text runs.</returns>
+        public List<TextRun>? AcquireRemainingRuns()
+        {
+            var remainingRuns = _remainingRuns;
+            _remainingRuns = null;
+            return remainingRuns;
+        }
+    }
+}

+ 1 - 1
tests/Avalonia.Benchmarks/Text/HugeTextLayout.cs

@@ -15,7 +15,7 @@ namespace Avalonia.Benchmarks.Text;
 public class HugeTextLayout : IDisposable
 {
     private static readonly Random s_rand = new();
-    private static readonly bool s_useSkia = true;
+    private static readonly bool s_useSkia = false;
 
     private readonly IDisposable _app;
     private readonly string[] _manySmallStrings;

+ 4 - 1
tests/Avalonia.Skia.UnitTests/Media/TextFormatting/TextFormatterTests.cs

@@ -558,7 +558,10 @@ namespace Avalonia.Skia.UnitTests.Media.TextFormatting
                 var textLine =
                     formatter.FormatLine(textSource, 0, 33, paragraphProperties);
 
-                Assert.NotNull(textLine.TextLineBreak?.RemainingRuns);
+                var remainingRunsLineBreak = Assert.IsType<WrappingTextLineBreak>(textLine.TextLineBreak);
+                var remainingRuns = remainingRunsLineBreak.AcquireRemainingRuns();
+                Assert.NotNull(remainingRuns);
+                Assert.NotEmpty(remainingRuns);
             }
         }