65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (10投票s)

2024年8月2日

CPOL

6分钟阅读

viewsIcon

20965

downloadIcon

370

WPF:如何在 GUI 上以精确的像素位置、最高效率和干净的代码书写文本。

引言

郑重警告:本文是对 WPF 的技术深入探讨,对于普通读者来说可能比较枯燥。但是,如果您想改进您的 WPF 代码及其效率,那么本文适合您。

在 WPF 中向用户显示字符串最简单的方法是使用 TextBox。然而,TextBox 带有每个 FrameworkElement 都具有的开销:参与视觉树、支持鼠标、大量的属性和事件、模板支持等等,但有时我们只是想在没有所有这些开销的情况下写入一些内容。特别是当您知道 FrameworkElement 中的像素位置时,您只需将字符串写入该 FrameworkElementDrawingContext。使用 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 很容易。您可以使用 FontFamilyFontStyleFontWeightFontStretch,它们是您将字符串写入的 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 的代码

下载 GlyphDrawer.zip - 2.6 KB

您也可以从 Github 获取它,作为 CustomControlBaseLib 的一部分,其中包括对 GlyphDrawer 类的广泛测试以及一些用于编写您自己的 WPF 控件的强大功能

https://github.com/PeterHuberSg/CustomControlBaseLib

推荐阅读

恭喜您读到这里。我写了一些WPF文章,获得了高分评价。我强烈建议您阅读我的文章(CodeProject 2022年2月最佳文章,二等奖),以便更好地理解WPF中的布局工作原理,

如果你编写自己的 WPF 控件,你必须阅读下面的文章,它将使你很容易地可视化测试你的控件在不同的容器(Grid、ScrollViewer、Canvas 等)中,使用不同的对齐、大小、边距、边框、填充、字体等设置时是否正确显示。

我在 CodeProject 上写的其他高分 WPF 文章

辛苦了这么多,不如玩玩WPF游戏吧?我10多年前写的,每天玩一个小时。它很有趣,因为每次玩都完全不同。你的对手是模拟玩家(=机器人),这带来了有趣的挑战。最棒的是,你可以编写自己的机器人

我的 Github 项目可能会让您感兴趣

  • CustomControlBaseLib: 方便编写您自己的 WPF 控件,并提供开箱即用的调整大小、边距、填充、不同字体等支持。GlyphDrawer 是其中的一部分。
  • WpfWindowsLib: 用于数据输入、检测是否缺少所需数据或数据已更改的 WPF 控件
  • TracerLib: 部分用于 WpfControlTestbench。内存中异常、错误和信息的快速追踪,一些条目可以通过后台线程写入文件。非常适合记录异常发生之前发生的情况。
  • StorageLib: 仅限 C# 的库,为单用户应用程序提供 RAM 中的快速面向对象数据存储和本地硬盘上的长期存储。无需数据库。
  • MasterGrab: MasterGrab 是一款 WPF 游戏,人类玩家与多个电脑玩家(=机器人)对战。您可以用 C# 编写自己的机器人。六年来,我每天都玩它。大约只需 10 分钟。非常适合在开始编程前热身大脑。
© . All rights reserved.