WPF:使用 GlyphRun 实现超快速简单的字符串绘制






4.98/5 (10投票s)
WPF:如何在 GUI 上以精确的像素位置、最高效率和干净的代码书写文本。
引言
郑重警告:本文是对 WPF 的技术深入探讨,对于普通读者来说可能比较枯燥。但是,如果您想改进您的 WPF 代码及其效率,那么本文适合您。
在 WPF 中向用户显示字符串最简单的方法是使用 TextBox
。然而,TextBox
带有每个 FrameworkElement
都具有的开销:参与视觉树、支持鼠标、大量的属性和事件、模板支持等等,但有时我们只是想在没有所有这些开销的情况下写入一些内容。特别是当您知道 FrameworkElement
中的像素位置时,您只需将字符串写入该 FrameworkElement
的 DrawingContext
。使用 TextBox
写入精确位置有点困难。
注意:此代码适用于继承自 UIElement
的任何内容,除了 Window
。似乎 Window
会将其 Background
绘制在 OnRender()
中写入的所有内容之上。
在重写的 OnRender()
方法中使用其 DrawingContext
写入字符串效率更高。将字符串写入 DrawingContext
有两种方式
DrawingContext.DrawText(FormattedText, Point)
这需要一个 FormattedText
,它很复杂并且有很多属性。实现很复杂,因为它必须适应各种复杂的格式。它将 FormattedText
转换为 GlyphRun
并在内部调用 DrawingContext.DrawGlyphRun()
。
DrawingContext.DrawGlyphRun(Brush, GlyphRun)
一个字形定义了一个字符的形状,以及宽度和高度,这取决于字体属性,例如
- FontFamily
- FontStyle
- FontWeight
- FontStretch
- PixelsPerDip
注意:FontSize 不属于这些属性。从当前字形到下一个字形的实际距离的计算公式是:GlyphWidth * FontSize
一个 GlyphRun
包含上面列出的字体属性。它们用于绘制每个字形,这些字形也存储在 GlyphRun
中,以及当前字形与下一个字形之间的距离(=AdvanceWidth
)。我们可以分散字符,因为我们指定了每个字形使用的宽度。
创建自己的 GlyphRun
有点让人头疼。例如,您如何知道两个字形之间的距离?
为此,您需要构建一个 GlyphTypeface
。您可以这样构建它
var typeface = new Typeface(fontFamily, fontStyle, fontWeight, fontStretch);
if (!typeface.TryGetGlyphTypeface(out glyphTypeface))
throw new InvalidOperationException("No GlyphTypeface found")
//convert the character '?' to glyph index
var glyphIndex = glyphTypeface.CharacterToGlyphMap[(int)'?'];
var distanceToNextGlyph = glyphTypeface.AdvanceWidths[glyphIndex] * FontSize;
DrawingContext.DrawText()
正在做所有这些以及更多。那么为什么不只使用 DrawingContext.DrawText()
呢?因为它效率低下,并且会导致糟糕的代码。只写两个字符串看起来像这样
FormattedText formattedText = new FormattedText("some string",
CultureInfo.GetCultureInfo("en-us"), FlowDirection.LeftToRight,
new Typeface("Verdana"), 32, Brushes.Black);
DrawingContext.DrawText(formattedText, new Point(10, 10));
FormattedText formattedText = new FormattedText("another string",
CultureInfo.GetCultureInfo("en-us"), FlowDirection.LeftToRight,
new Typeface("Verdana"), 36, Brushes.Gray);
DrawingContext.DrawText(formattedText, new Point(10, 50));
如果您使用相同的字体属性写入多个字符串,您必须为每个字符串重复该代码,最糟糕的是,DrawText()
会一次又一次地创建相同的 GlyphTypeface
,这是一个巨大的对象,它包含数千个字形的计算。
如果你能像这样编写代码并且不会导致 DrawText()
的低效率,那不是更好吗?
var glyphDrawer = new GlyphDrawer(FontFamily, FontStyle, FontWeight,
FontStretch, VisualTreeHelper.GetDpi(this).PixelsPerDip);
glyphDrawer.Write(drawingContext, new Point(10, 10), "some string", 32, Brushes.Black);
glyphDrawer.Write(drawingContext, new Point(10, 50), "another string", 36, Brushes.Gray);
GlyphDrawer
存储 GlyphTypeface
并为每次写入重复使用它。本文解释了如何使用我的 GlyphDrawer
快速高效地将字符串绘制到 GUI。
如何使用 GlyphDrawer
如上所示,创建一个 GlyphDrawer
很容易。您可以使用 FontFamily
、FontStyle
、FontWeight
、FontStretch
,它们是您将字符串写入的 Control
的属性。或者您可以使用其他值,例如 FontWeights.Bold
。
GlyphDrawer glyphDrawer= new GlyphDrawer(FontFamily, FontStyle, FontWeights.Bold,
FontStretch, VisualTreeHelper.GetDpi(this).PixelsPerDip);
protected override void OnRender(DrawingContext drawingContext) {
glyphDrawer.Write(drawingContext, new Point(10, 10), "some string", 32, Brushes.Black);
glyphDrawer.Write(drawingContext, new Point(10, 50), "another string", 36, Brushes.Gray);
}
GlyphDrawer.Write()
实际上看起来是这样的
public void Write(
DrawingContext drawingContext,
Point origin,
string text,
double size, //font size
Brush brush,
bool isRightAligned = false,
bool isSideways = false,
double angle = 0) //text can be rotated, in degrees, clockwise
一些参数的含义很明显,而另一些则更容易通过查看结果来理解。
使用同一个 GlyphDrawer
,您可以写入不同字体大小、颜色、左右对齐、横向或不横向(见上文)和旋转角度的字符串。但是,如果您想写入一些普通文本和一些粗体文本,则需要使用两个 GlyphDrawer
实例,一个使用 FontWeight.Normal
创建,另一个使用 FontWeight.Bold
创建。如果您需要不同的 FontFamily、FontStyles
(斜体)或 FontStretches
,则同样适用。
如果您在 Control
的构造函数中创建 GlyphDrawer
,而不是在每次调用 OnRender()
时重新创建它,您的代码将执行得更快。
一旦你创建了 GlyphDrawer
,你就可以用它来通过 GlyphDrawer.Write()
写入字符串。origin
参数指示文本应该写入的位置。如果字符串是左对齐的,origin.X
指向字符串应该开始的最左侧位置。对于右对齐的字符串(数字),origin.X
指向最右侧位置。origin.Y
指向最低字形像素被写入的位置(基线)。请注意,有些字符如“g”会在基线以下绘制一些像素。
GlyphDrawer.Write()
返回一个点,可用于写入下一个字符串。当文本旋转且 x 和 y 计算复杂时,这尤其方便。
var nextPoint = glyphDrawer.Write(drawingContext, new Point(x, y), "Test String", 12,
Foreground, angle: 30);
nextPoint = glyphDrawer.Write(drawingContext, nextPoint," Another String", 15,
Foreground, angle: 60);
使用 TextBoxes
来实现这一点会非常困难,而且使用 DrawingContext.DrawText()
也并非易事。此外,使用 GlyphDrawer.Write()
的执行速度比 DrawingContext.DrawText()
快约 5 倍,即 0.04 毫秒对 0.2 毫秒。
GlyphDrawer 的几个限制
绘制 Unicode 可能非常复杂,例如在同一个字符串中混合从左到右(英语)和从右到左(阿拉伯语)。.Net 框架中对此有支持,但 GlyphDrawer
不包含它,因为我处理的所有文本都是英文的。Unicode 中可能还有一些我没有遇到过的其他异国情调的功能。
获取代码
这是仅适用于 GlyphDrawer
的代码
您也可以从 Github 获取它,作为 CustomControlBaseLib
的一部分,其中包括对 GlyphDrawer
类的广泛测试以及一些用于编写您自己的 WPF 控件的强大功能
https://github.com/PeterHuberSg/CustomControlBaseLib
推荐阅读
恭喜您读到这里。我写了一些WPF文章,获得了高分评价。我强烈建议您阅读我的文章(CodeProject 2022年2月最佳文章,二等奖),以便更好地理解WPF中的布局工作原理,
如果你编写自己的 WPF 控件,你必须阅读下面的文章,它将使你很容易地可视化测试你的控件在不同的容器(Grid、ScrollViewer、Canvas 等)中,使用不同的对齐、大小、边距、边框、填充、字体等设置时是否正确显示。
我在 CodeProject 上写的其他高分 WPF 文章
- 使用绑定进行 WPF DataGrid 格式化的指南
- WPF DataGrid:解决排序、滚动到视图、刷新和焦点问题
- WPF 颜色、颜色空间、颜色选择器和为普通人创建自己颜色的权威指南
- 用于数据输入的基类 WPF 窗口功能
辛苦了这么多,不如玩玩WPF游戏吧?我10多年前写的,每天玩一个小时。它很有趣,因为每次玩都完全不同。你的对手是模拟玩家(=机器人),这带来了有趣的挑战。最棒的是,你可以编写自己的机器人
我的 Github 项目可能会让您感兴趣
- CustomControlBaseLib: 方便编写您自己的 WPF 控件,并提供开箱即用的调整大小、边距、填充、不同字体等支持。GlyphDrawer 是其中的一部分。
- WpfWindowsLib: 用于数据输入、检测是否缺少所需数据或数据已更改的 WPF 控件
- TracerLib: 部分用于
WpfControlTestbench
。内存中异常、错误和信息的快速追踪,一些条目可以通过后台线程写入文件。非常适合记录异常发生之前发生的情况。 - StorageLib: 仅限 C# 的库,为单用户应用程序提供 RAM 中的快速面向对象数据存储和本地硬盘上的长期存储。无需数据库。
- MasterGrab: MasterGrab 是一款 WPF 游戏,人类玩家与多个电脑玩家(=机器人)对战。您可以用 C# 编写自己的机器人。六年来,我每天都玩它。大约只需 10 分钟。非常适合在开始编程前热身大脑。