在.NET中,绘制图形和文本用的是GDI+。
在实际的应用中,绘制多行文本是比较常见的,而且有时还要求在绘制多行文本时能指定文本的行间距。如下图:
注:由于图太大,只截了左边部分的图,右边有一小部分没有截图。
上面这个示意图。一共18行文字,每行52个文字,行间距为1.5字符。
有关的GDI+的知识这里不再详细的介绍了。下面讲的是如何实现上面这个图的效果,给出三种实现方法。并比较他们的实现效率。
由于GDI+中没有文本行间距的概念,所以,本文的三种方法都是自己实现行间距。
准备工作:自定义一个类clsDraw
有这几个方法:
New(P as Control) 构造函数,根据P来构造文本绘制环境
Clear() 用背景色清楚画布
DrawText(Text as String,P as Point) 在指定位置P,绘制文本Text,这个文本一般是单行文本
DrawText1(Text as String,P as Point) 在指定位置P,绘制文本Text,这个文本是带有换行符的多行文本
Draw1(Text as String) 用方法一绘制文本在默认位置。
Draw2(Text as String) 用方法二绘制文本在默认位置。
Draw3(Text as String) 用方法三绘制文本在默认位置。
Refresh(G as Graphics) 刷新本画布的内容到指定的控件上
有这几个属性
mG Graphics 本画布的Graphics对象。
mFont Font 本画布的Font对象
mForeColor Color 本画布的前景色
mBackColor Color 本画布的背景色
mBmp Bitmap 本画布对应的Bitmap对象
mCP Point 画布当前的绘制点
mTextHeight Integer Font对应的文字高度
mLineHeight Integer 行高,在本文中,是文本高度的1.5倍
下面详细介绍三种方法的实现:
方法一:将文本截断成多个文本,然后依次调用DrawText方法,将文本绘制到画布。模拟出自定义行间距的效果。
Public Sub Draw1(ByVal Text As String)
Clear()
Dim i As Integer, j As Integer, tS() As String
j = Int(Text.Length / 52)
ReDim tS(j - 1)
For i = 0 To j - 1
tS(i) = Text.Substring(i * 52, 52)
Next
If Text.Length - j * 52 <> 0 Then
ReDim Preserve tS(j+1)
tS(j+1) = Text.Substring(j * 52)
End If
For i = 0 To tS.GetUpperBound(0)
DrawText(tS(i), New Point(3, 3 + i * mLineHeight))
Next
End Sub
Public Sub DrawText(ByVal Text As String, ByVal P As Point)
mCP = P
RenderText(Text)
End Sub
Private Sub RenderText(ByVal Text As String)
TextRenderer.DrawText(mG, Text, mFont, mCP, mForeColor, TextFormatFlags.NoPadding)
End Sub
几点说明:
1、绘制文本采用TextRender类,这个类对GDI进行了封装。在绘制文本的时候效率比Graphics的DrawString的效率高。
2、这个方法也是大家都能想到的。不过效率不敢恭维。原因有二,一是在绘制文本前,要拆分文本,会产生大量的临时字符串。二是,每调用一次DrawText,CLR其实做了大量的PInvoke的工作,而这些工作很多是重复的。去看看它的Reflector后的代码,每次调用,都要将mG、mFont、mForeColor对象转化为GDI,绘制文本,然后销毁GDI对象。而多次绘制,其实这三个对象是不变的,而多次的生成和销毁自然影响了效率。
方法二:既然方法一的瓶颈在多次调用DrawText而产生的。那如果只调用一次该方法,是不是就能提升效率呢?答案是肯定的。本方法就是将文本拆分成多个字符串后,再用换行符(VbNewLine)串联起来。这样,调用一次DrawText就能绘制多行文本,不过这个多行文本是没有行间距效果的,下一行文本紧挨着上一行文本。采用的办法是将绘制好的文本再逐行下移到指定位置,产生行间距的效果。本方法过程分两步,先在备用的画布上一次绘制所有文本,将备用画布上的文本再依次绘到画布上的指定位置。产生行间距的效果。
Public Sub Draw2(ByVal Text As String)
Clear()
Dim i As Integer, j As Integer, tS() As String, tS1 As String
j = Int(Text.Length / 52)
ReDim tS(j - 1)
For i = 0 To j - 1
tS(i) = Text.Substring(i * 52, 52)
Next
If Text.Length - j * 52 <> 0 Then
ReDim Preserve tS(j+1)
tS(j+1) = Text.Substring(j * 52)
End If
tS1 = Join(tS, vbNewLine)
DrawText1(tS1, New Point(3, 3))
End Sub
Public Sub DrawText1(ByVal Text As String, ByVal P As Point)
mCP = P
RenderText1(Text)
End Sub
Private Sub RenderText1(ByVal Text As String)
Dim i As Integer, tR As Rectangle, tR1 As Rectangle
TextRenderer.DrawText(mG1, Text, mFont, mCP, mForeColor, TextFormatFlags.NoPadding)
tR.X = 3
tR.Height = mTextHeight
tR.Width = mBmp1.Width
tR1.X = 3
tR1.Height = mTextHeight
tR1.Width = mBmp1.Width
For i = 0 To MaxLines - 1
tR.Y = 3 + i * mLineHeight
tR1.Y = 3 + i * mTextHeight
mG.DrawImage(mBmp1, tR, tR1, GraphicsUnit.Pixel)
Next
End Sub
Public ReadOnly Property MaxLines() As Integer
Get
Return Int((mBmp.Height + mLineHeight - mTextHeight) / mLineHeight)
End Get
End Property
几点说明:
1、本方法比方法一效率有所提高,约有20%的提高。
2、不过还是存在两个问题。一是要拆分字符串,会产生大量的临时字符串。二是将原来的多次调用DrawText的方法改为多次调用DrawImage的方法,效率有一定的提高,但还是多次PInvoke,大量的对象生成和销毁,效率还是有问题。
方法三:利用GdipDrawDriverString函数。我们所有的GDI+对象其实都是封装了Gdiplus.dll中的函数,只不过有的函数没有封装而已。GdipDrawDriverString就是其中一个。它的VB2005声明为
<DllImport("Gdiplus.dll", CharSet:=CharSet.Unicode)> _
Friend Shared Function GdipDrawDriverString(ByVal graphics As IntPtr, _
ByVal text As String, _
ByVal length As Integer, _
ByVal font As IntPtr, _
ByVal brush As IntPtr, _
ByVal positions() As PointF, _
ByVal flags As Integer, _
ByVal matrix As IntPtr) As Integer
End Function
由于这个函数不能直接调用Graphics、Font、SolidBrush等对象。因此,在调用前还得自己先封装一下:
Private Shared Sub DrawDriverString(ByVal graphics As Graphics, _
ByVal text As String, ByVal font As Font, _
ByVal brush As Brush, ByVal positions() As PointF)
DrawDriverString(graphics, text, font, brush, positions, Nothing)
End Sub
Private Shared Sub DrawDriverString(ByVal G As Graphics, _
ByVal T As String, ByVal F As Font, _
ByVal B As Brush, ByVal P() As PointF, ByVal M As Matrix)
If (G Is Nothing) Then Throw New ArgumentNullException("graphics")
If (T Is Nothing) Then Throw New ArgumentNullException("text")
If (F Is Nothing) Then Throw New ArgumentNullException("font")
If (B Is Nothing) Then Throw New ArgumentNullException("brush")
If (P Is Nothing) Then Throw New ArgumentNullException("positions")
Dim Field As FieldInfo
Field = GetType(Graphics).GetField("nativeGraphics", BindingFlags.Instance Or BindingFlags.NonPublic)
Dim hGraphics As IntPtr = Field.GetValue(G)
Field = GetType(Font).GetField("nativeFont", BindingFlags.Instance Or BindingFlags.NonPublic)
Dim hFont As IntPtr = Field.GetValue(F)
Field = GetType(Brush).GetField("nativeBrush", BindingFlags.Instance Or BindingFlags.NonPublic)
Dim hBrush As IntPtr = Field.GetValue(B)
Dim hMatrix As IntPtr = IntPtr.Zero
If (Not M Is Nothing) Then
Field = GetType(Matrix).GetField("nativeMatrix", BindingFlags.Instance Or BindingFlags.NonPublic)
hMatrix = Field.GetValue(M)
End If
Dim result As Integer = GdipDrawDriverString(hGraphics, T, T.Length, hFont, hBrush, P, DriverStringOptions.CmapLookup, hMatrix)
End Sub
Private Enum DriverStringOptions
CmapLookup = 1
Vertical = 2
Advance = 4
LimitSubpixel = 8
End Enum
上面这段代码是我移植网上的一段C#的代码。期间也碰到过陷阱,看看“使用GDI+绘制有间距的文本”“充满魅惑的GetType(VB2005)”这两篇文章就知道我指陷阱是什么了。
这个函数在调用的时候要传递一个PointF的数组,指明每个字符的绘制位置。而且这个位置是指的是字符的左下角位置。那么我在调用的时候就不需要拆分字符串,而是计算每个字符的位置就可以了。
Public Sub Draw3(ByVal Text As String)
Clear()
Dim i As Integer, tP() As PointF
ReDim tP(Text.Length - 1)
For i = 0 To Text.Length - 1
tP(i).X = (i Mod 52) * 16 + 3
tP(i).Y = 3 + Int(i / 52) * mLineHeight + 12
Next
DrawDriverString(mG, Text, mFont, New SolidBrush(mForeColor), tP)
End Sub
写了一段测试代码,分别测试三个方法的效率。代码如下:
Private Sub Button1_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Button1.Click
Dim mGDI As New clsDraw(Panel1)
Dim tS1 As String = My.Computer.FileSystem.ReadAllText("t1.txt", System.Text.Encoding.Default)
Dim t1 As Integer, t2 As Integer
t1 = Environment.TickCount
mGDI.Draw1(tS1)
t2 = Environment.TickCount
Debug.Print(t2 - t1)
t1 = Environment.TickCount
mGDI.Draw2(tS1)
t2 = Environment.TickCount
Debug.Print(t2 - t1)
t1 = Environment.TickCount
mGDI.Draw3(tS1)
t2 = Environment.TickCount
Debug.Print(t2 - t1)
Panel1.Invalidate()
End Sub
在绘制象示意图中的文本的效果,测试了十次,三种方法的耗费的时间如下(单位是毫秒):
第一次:方法一:125;方法二:94;方法三:15
第二次:方法一:125;方法二:78;方法三:16
第三次:方法一:125;方法二:79;方法三:15
第四次:方法一:110;方法二:93;方法三:16
第五次:方法一:125;方法二:78;方法三:16
第六次:方法一:125;方法二:78:方法三:16
第七次:方法一:125;方法二:78;方法三:15
第八次:方法一:125;方法二:78;方法三:16
第九次:方法一:110;方法二:94;方法三:15
第十次:方法一:109;方法二:94;方法三:15
可以看出,方法一和方法二由于要拆分字符串和反复调用GDI+的方法,所以效率有点低下,方法二由于采用DrawImage的方法,效率略有提升。而方法三不拆分字符串和只调用一次GDI+的方法,效率高得惊人。把前两种方法远远甩在后面。
如果各位网友还有什么好的方法,欢迎交流,大家互相学习。