在 WPF 里面,帶了基礎的文本庫功能,如 TextBlock 等。文本庫排版的重點是在文本的分行邏輯,也就是換行邏輯,如何計算當前的文本字符串到達哪個字符就需要換到下一行的邏輯就是文本布局的重點模塊。本文來簡單聊聊 WPF 的文本布局邏輯
先寫給不想閱讀細節的大佬們了解 WPF 文本模塊的布局邏輯: 文本的排版和渲染是分開的兩個模塊。 文本邏輯在排版里面,核心都會調用到 TextFormatterImp 里面,在這里將會通過 SimpleTextLine 嘗試進行布局排版,在 SimpleTextLine 里面將會判斷當前的文本字符串是否剛好一行能放下,如果可以放下,那么就使用當行方式顯示。這是最為簡單的,實現邏輯就是通過 Typeface 的 GlyphMetrics 的 AdvanceWidth 列表獲取每個字符的排版寬度,將排版寬度乘以渲染字號即可獲取每個字符占用的渲染布局寬度,將所有字符的占用布局框架之和 與可用行寬度進行比較,如果小於行寬度則進行單行布局
如果超過單行布局的能力,則進入 TextMetrics 的 FullTextLine 方法。此方法將使用到沒有開源的 PresentationNative.dll 提供的 LoCreateLine 方法進行文本排版邏輯。在 PresentationNative 里面將會調用系統多語言處理 (也許是叫 TFS 但如果叫錯了還請大佬們教教我)進行文本的復雜排版行為,包括進行合寫字如蒙文藏文的排版邏輯。這部分復雜排版是需要系統層多語言的支持的,包含了復雜的語言文化規則
下面就是細節部分的邏輯
在 TextBlock 等的底層也是用到了 TextFormatterImp 的文本排版功能進行排版,然后進行渲染。渲染部分本文就不聊了
如在 TextBlock 的 OnRender 或 MeasureOverride 方法里面,都會調用 CreateLine 方法創建 Line 對象,接着通過 Line 對象的 Format 方法層層調用到 TextFormatterImp 里面,大概代碼如下
[ContentProperty("Inlines")]
[Localizability(LocalizationCategory.Text)]
public class TextBlock : FrameworkElement, IContentHost, IAddChildInternal, IServiceProvider
{
protected sealed override Size MeasureOverride(Size constraint)
{
// 忽略邏輯
// Create and format lines until end of paragraph is reached.
// Since we are disposing line object, it can be reused to format following lines.
Line line = CreateLine(lineProperties);
while (!endOfParagraph)
{
using(line)
{
// Format line. Set showParagraphEllipsis flag to false because we do not know whether or not the line will have
// paragraph ellipsis at this time. Since TextBlock is auto-sized we do not know the RenderSize until we finish Measure
line.Format(dcp, contentSize.Width, GetLineProperties(dcp == 0, lineProperties), textLineBreakIn, _textBlockCache._textRunCache, /*Show paragraph ellipsis*/ false);
// 忽略其他邏輯
}
}
}
}
// ----------------------------------------------------------------------
// Text line formatter.
// ----------------------------------------------------------------------
internal abstract class Line : TextSource, IDisposable
{
// ------------------------------------------------------------------
// Create and format text line.
//
// lineStartIndex - index of the first character in the line
// width - wrapping width of the line
// lineProperties - properties of the line
// textRunCache - run cache used by text formatter
// showParagraphEllipsis - true if paragraph ellipsis is shown
// at the end of the line
// ------------------------------------------------------------------
internal void Format(int dcp, double width, TextParagraphProperties lineProperties, TextLineBreak textLineBreak, TextRunCache textRunCache, bool showParagraphEllipsis)
{
// 忽略代碼
_line = _owner.TextFormatter.FormatLine(this, dcp, width, lineProperties, textLineBreak, textRunCache);
}
}
internal sealed class TextFormatterImp : TextFormatter
{
public override TextLine FormatLine(
TextSource textSource,
int firstCharIndex,
double paragraphWidth,
TextParagraphProperties paragraphProperties,
TextLineBreak previousLineBreak,
TextRunCache textRunCache
)
{
return FormatLineInternal(
textSource,
firstCharIndex,
0, // lineLength
paragraphWidth,
paragraphProperties,
previousLineBreak,
textRunCache
);
}
/// <summary>
/// Format and produce a text line either with or without previously known
/// line break point.
/// </summary>
private TextLine FormatLineInternal(
TextSource textSource,
int firstCharIndex,
int lineLength,
double paragraphWidth,
TextParagraphProperties paragraphProperties,
TextLineBreak previousLineBreak,
TextRunCache textRunCache
)
{
// 忽略代碼
}
}
通過上面代碼可以看到在 WPF 框架,核心的文本排版邏輯是在 FormatLineInternal 方法里面
在 FormatLineInternal 里面將會先使用 SimpleTextLine 嘗試作為一行進行布局,假設文本一行能放下,也就不需要復雜的排版邏輯,可以提升很大的性能。如果一行放不下,那就通過 TextMetrics 的 FullTextLine 進行復雜的排版邏輯
/// <summary>
/// Format and produce a text line either with or without previously known
/// line break point.
/// </summary>
private TextLine FormatLineInternal(
TextSource textSource,
int firstCharIndex,
int lineLength,
double paragraphWidth,
TextParagraphProperties paragraphProperties,
TextLineBreak previousLineBreak,
TextRunCache textRunCache
)
{
// prepare formatting settings
FormatSettings settings = PrepareFormatSettings(/*忽略傳入參數*/);
TextLine textLine = null;
if ( /*可以進行單行排版的文本*/ )
{
// simple text line.
textLine = SimpleTextLine.Create(/*忽略傳入參數*/);
}
if (textLine == null)
{
// content is complex, creating complex line
textLine = new TextMetrics.FullTextLine(/*忽略傳入參數*/);
}
return textLine;
}
在文本進行復雜排版,就需要用到沒有開源的 PresentationNative.dll 提供的和系統層的多語言對接的功能。本文就僅來了解 SimpleTextLine 的實現
在 SimpleTextLine 里面,實現的邏輯是將當前的文本在傳入的寬度內進行一行布局,如果能在一行進行布局,那就返回值,否則返回空
文本里面有段落和行和 TextRun 的三個概念,在開始了解 WPF 的代碼之前,咱先定義這三個不同的概念。一個文本里面包含有多段,默認采用換行符作為分段。也就是說在一段里面是不會存在多個換行符的。一個段落里面將會因為文本框的寬度限制而存在多行。一行文本里面,將會因為文本屬性的不同將文本分為多個 TextRun 對象
也就是最簡單的文本就是一個字符,一個字符是一個 TextRun 放在一行里面,這一行放在一段里面
在 SimpleTextLine 的 Create 方法將層層調用進入到 CreateSimpleTextRun 方法里面,也就是說在一行里面將會一個個 TextRun 進行創建,創建的時候同時判斷當前的文本剩余寬度是否足夠
在 CreateSimpleTextRun 方法里面將會調用 Typeface.CheckFastPathNominalGlyphs 方法進行快速的創建,這個方法是沒有開放出來給開發者使用的,調用這個方法可以繞過很多判斷邏輯,性能很高
在 CheckFastPathNominalGlyphs 方法里面,將會使用 Typeface 的 TypefaceMetrics 屬性作為 GlyphTypeface 類型的對象。此對象依然可以使用到沒有開放給開發者使用的 GetGlyphMetricsOptimized 方法。如方法命名可以看到,這是一個有很多性能優化的方法。此方法將拿到文本字符串對應的 glyphIndices 和 glyphMetrics 兩個數組,分別表示的是字符對應在 Glyph 的序號以及 Glyph 的信息,代碼如下
ushort[] glyphIndices = BufferCache.GetUShorts(charBufferRange.Length);
MS.Internal.Text.TextInterface.GlyphMetrics[] glyphMetrics = ignoreWidths ? null : BufferCache.GetGlyphMetrics(charBufferRange.Length);
glyphTypeface.GetGlyphMetricsOptimized(charBufferRange,
emSize,
pixelsPerDip,
glyphIndices,
glyphMetrics,
textFormattingMode,
isSideways
);
以上的 glyphIndices
變量和 glyphMetrics
都是從 BufferCache 獲取的,大部分排版邏輯都需要額外申請內存。此方法對比開放給開發者使用的版本的優勢在於可以批量獲取,給開發者使用的版本只能一個個字符獲取,性能上遠遠不如調用此方法獲取。更多關於開發者使用文本排版,請看 WPF 簡單聊聊如何使用 DrawGlyphRun 繪制文本
在拿到以上兩個變量之后,即可進行計算每個字符的排版寬度,此計算方法將會讓計算出來的值和實際渲染尺寸有一些誤差。然而此排版方法只是計算是否在一行里面足夠放下文本,有一些誤差不會影響到結果。因為如果能一行進行排版,那就走以上的方法,是高性能模式。如果一行不能排版,那就通過系統層的語言文化進行排版,可以符合業務的需求
大概的計算邏輯如下
//
// This block will advance until one of:
// 1. The end of the charBufferRange is reached
// 2. The charFlags have some of the charFlagsMask values
// 3. Glyph index is 0 (unless symbol font)
// 4. totalWidth > widthMax
//
while(
i < charBufferRange.Length // charBufferRange 就是文本的 Char 列表
&& (ignoreWidths || totalWidth <= widthMax) // totalWidth 是當前文本已排版的字符的寬度之和
&& ((charFlags & charFlagsMask) == 0)
&& (glyph != 0 || symbolTypeface) // 在 glyph 是 0 時,表示的是當前沒有字符,相當於 \0 字符。但是符號字體不在此范圍
)
{
char ch = charBufferRange[i++];
if (ch == TextStore.CharLineFeed || ch == TextStore.CharCarriageReturn || (breakOnTabs && ch == TextStore.CharTab))
{
--i;
break;
}
else
{
int charClass = (int)Classification.GetUnicodeClassUTF16(ch);
charFlags = Classification.CharAttributeOf(charClass).Flags;
charFastTextCheck &= charFlags;
glyph = glyphIndices[i-1];
if (!ignoreWidths)
{
totalWidth += TextFormatterImp.RoundDip(glyphMetrics[i - 1].AdvanceWidth * designToEm, pixelsPerDip, textFormattingMode) * scalingFactor;
}
}
}
上面邏輯核心就是 totalWidth <= widthMax
判斷,判斷當前布局的字符寬度之和是否小於可以使用的寬度。如果大於那就表示這一行放不下此字符串
計算單個字符占用的寬度使用的是 glyphMetrics[i - 1].AdvanceWidth * designToEm
進行計算,而 RoundDip 只是加上 Dpi 的輔助計算而已。以上的 AdvanceWidth 將是字符的寬度比例,可以乘以 designToEm 設計時的字號計算出 WPF 單位的寬度
也就是文本的單行排版里面就是通過各個字符的設計時寬度計算是否可以在一行排列,如果可以那就采用此優化,不再進行復雜文本排版,進入渲染邏輯
更多渲染相關博客請看 渲染相關