sharpgl之文字绘制
前言
说起显示文字,估计大家都觉得很简单。Opengl作为一个专业的绘图技术,竟然没有显示文字的接口。后来经过详细研究,发现显示文字是一个非常高深的问题。Opengl作为一个底层API已经不适合提供对应的接口。
环境搭建
在开始之前,我们需要搭建开发环境。OpenGL是C++的接口,C#需要对其进行封装才可以调用。目前有不少对OpenGL的封装,我们选用了SharpGL作为我们的类库。具体步骤如下:
创建一个窗口程序。
NUGet 里面安装SharpGL和 SharpGL.WinForms.
NUGet 里面安装SharpGL和 SharpGL.WinForms.
3. 安装SharpFont.Dependencies和SharpFont.
这是FreeType的类库,注意在安装的时候,依赖项要选择忽略依赖项。
引入FreeType.dll, 下载,这是编译好的FreeFont.zip.
在Program中引入FreeFont.dll:
在Program中引入FreeFont.dll:
3. 窗口中添加SharpGL. OpenGLControl,由于改控件不能直接从工具箱拖入,所以需要手动修改.designer.cs
partial class Form1 { /// <summary> /// 必需的设计器变量。 /// </summary> private System.ComponentModel.IContainer components = null; /// <summary> /// 清理所有正在使用的资源。 /// </summary> /// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param> protected override void Dispose(bool disposing) { if (disposing && (components != null)) { components.Dispose(); } base.Dispose(disposing); } #region Windows 窗体设计器生成的代码 /// <summary> /// 设计器支持所需的方法 - 不要修改 /// 使用代码编辑器修改此方法的内容。 /// </summary> private void InitializeComponent() { this.openGLControl1 = new SharpGL.OpenGLControl(); ((System.ComponentModel.ISupportInitialize)(this.openGLControl1)).BeginInit(); this.SuspendLayout(); // // openGLControl1 // this.openGLControl1.Dock = System.Windows.Forms.DockStyle.Fill; this.openGLControl1.DrawFPS = true; this.openGLControl1.FrameRate = 50; this.openGLControl1.Location = new System.Drawing.Point(0, 0); this.openGLControl1.Name = "openGLControl1"; this.openGLControl1.OpenGLVersion = SharpGL.Version.OpenGLVersion.OpenGL2_1; this.openGLControl1.RenderContextType = SharpGL.RenderContextType.NativeWindow; this.openGLControl1.RenderTrigger = SharpGL.RenderTrigger.Manual; this.openGLControl1.Size = new System.Drawing.Size(563, 473); this.openGLControl1.TabIndex = 0; this.openGLControl1.OpenGLInitialized += new System.EventHandler(this.openGLControl1_OpenGLInitialized); this.openGLControl1.OpenGLDraw += new SharpGL.RenderEventHandler(this.openGLControl1_OpenGLDraw); this.openGLControl1.Resized += new System.EventHandler(this.openGLControl1_Resized); this.openGLControl1.MouseDown += new System.Windows.Forms.MouseEventHandler(this.openGLControl1_MouseDown); this.openGLControl1.MouseMove += new System.Windows.Forms.MouseEventHandler(this.openGLControl1_MouseMove); this.openGLControl1.MouseUp += new System.Windows.Forms.MouseEventHandler(this.openGLControl1_MouseUp); // // Form1 // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.ClientSize = new System.Drawing.Size(563, 473); this.Controls.Add(this.openGLControl1); this.Name = "Form1"; this.Text = "Form1"; ((System.ComponentModel.ISupportInitialize)(this.openGLControl1)).EndInit(); this.ResumeLayout(false); } #endregion private SharpGL.OpenGLControl openGLControl1; }
功能实现
可行性探讨
列表显示
OpenGL的列表显示是一个常见的方案,他可以将生成的字形保存在一个列表中,在需要的时候直接调用这个列表,而且效果很清晰。
上图的文字就是通过列表显示的。下面是具体的代码
private void drawCNString(string str, float x, float y, int fontSize, OpenGL gl) { int i; // Create the font based on the face name. var hFont = Win32.CreateFont(fontSize, 0, 0, 0, Win32.FW_DONTCARE, 0, 0, 0, Win32.DEFAULT_CHARSET, Win32.OUT_OUTLINE_PRECIS, Win32.CLIP_DEFAULT_PRECIS, Win32.CLEARTYPE_QUALITY, Win32.CLEARTYPE_NATURAL_QUALITY, "新宋体"); // Select the font handle. var hOldObject = Win32.SelectObject(gl.RenderContextProvider.DeviceContextHandle, hFont); // Create the list base. var list = gl.GenLists(1); gl.RasterPos(x, y); // 逐个输出字符 for (i = 0; i < str.Length; ++i) { bool result = Win32.wglUseFontBitmapsW(gl.RenderContextProvider.DeviceContextHandle, str[i], 1, list); gl.CallList(list); } // 回收所有临时资源 //free(wstring); gl.DeleteLists(list, 1); // Reselect the old font. Win32.SelectObject(gl.RenderContextProvider.DeviceContextHandle, hOldObject); // Free the font. Win32.DeleteObject(hFont); //glDeleteLists(list, 1); }
由于OpenGL存储的显示列表最大个数不超过256个,而操作系统提供的APIwglUseFontBitmap每次调用都会生成一个列表。显示英文字母没有关系,可以一次性生成所有的显示列表,但是中文就没法提前将所有的文字显示列表生成,替代方案是每生成一个汉字的列表,就马上显示他,这样的效率会大大降低。
另一个无法逾越的障碍是,这个显示列表是生成的结果,看起来还是像素级别的位图,而不是矢量图。导致他不能放大或者缩小。
总结:这种方法适合做游戏菜单之类的问题,例如显示血量什么的。但是不适合显示图纸上的文字。
位图字体还是矢量字体
在早期Opengl程序中,往往是通过位图来显示字体,这些年随着TrueType字体的发展,更多使用TrueType。然而,即使用的是TrueType字体,这些字体本身并不能直接显示在Opengl中,往往是先生成一个纹理,然后再通过纹理的方式显示到屏幕上。而纹理中还是位图,总而言之,显示字体还是离不开位图。
系统默认字体还是FreeType
生成文字位图,可以用系统默认的System.Texting.Font 也可以用FreeType类库。然而,前者需要本机已经安装过对应的字体才可以使用。后者可以直接加载字体文件,所以建议选择FreeType。
图形字体
另外,AutoCad还支持字形字体(.SHX文件),这种文件描述在一个标准尺寸中,一个文字从哪里下笔,然后往哪个方向划多长然后抬笔,不停重复,知道一个字写完,跟我们写字基本上一样。
这样的方式是真正的矢量字体,无论怎么放大缩小文字,都会很清晰。同样,一个图纸解析完毕后,永远不需要重新渲染这些字体。
然而他也有他的缺点,他的一个字往往需要写几十笔(一笔对应2个点),如果一个图纸有1万个字要写,光写字就需要占用上百万定点坐标。有显存有一定的压力。
第二个缺点是,由于他是通过线条来显示文字,无限放大图纸的时候,看见的就是一根一根的线条。
即便如此,图形字体也是一个比较好的备选方案。
纹理
而纹理则是一个比较靠谱的方案,纹理的意思就是将一个一个文字生成一个图片,然后将这些图片显示到对应的位置去。
通过测试1000个汉字,按照18像素生成位图,保存出来的png文件不到1M,而加载到显卡口,大概占用了十几M的显存。
遇到的问题
性能
考虑到一个图纸可能有上万条文本,估计几万文字,需要避免每次都重新生成问题,而是充分使用缓存,比较好的方式就是使用纹理、顶点缓冲、VBO VAO等技术来完成图纸渲染。
内存
虽然一次纹理用了十几M的显存,但是我们不能无限生成纹理,例如8像素的10 12 16 18 24 36 都生成一遍纹理。
缩放
文字显示的另外一个问题是缩放,放大的时候有毛刺,缩小就模模糊糊了。
闪烁
如果在缩放后重新生成纹理,不可避免的会造成卡顿或者闪烁。
最终的方案
FreeType
使用FreeType生成文字纹理,在c#可以使用SharpFont封装库来调用FreeType.
使用之前首先要创建Face对象
public void SetFont(string filename) { FontFace = new Face(lib, filename); SetSize(this.Size);} public static byte[] RenderString(Library library, Face face, string text,ref int w, ref int h) { var chars = new List<CharInfo>(); var poses = new List<CharTextture>(); float stringWidth = 0; // the measured width of the string float stringHeight = 0; // the measured height of the string // Bottom and top are both positive for simplicity. // Drawing in .Net has 0,0 at the top left corner, with positive X to the right // and positive Y downward. // Glyph metrics have an origin typically on the left side and at baseline // of the visual data, but can draw parts of the glyph in any quadrant, and // even move the origin (via kerning). float top = 0, bottom = 0; float x = 0, y = 0; int rowStart = 0; // Measure the size of the string before rendering it. We need to do this so // we can create the proper size of bitmap (canvas) to draw the characters on. for (int i = 0; i < text.Length; i++) { #region Load character char c = text[i]; // Look up the glyph index for this character. uint glyphIndex = face.GetCharIndex(c); // Load the glyph into the font's glyph slot. There is usually only one slot in the font. face.LoadGlyph(glyphIndex, LoadFlags.Default, LoadTarget.Normal); var lineHeight = face.Glyph.LinearVerticalAdvance.Value; // Refer to the diagram entitled "Glyph Metrics" at http://www.freetype.org/freetype2/docs/tutorial/step2.html. // There is also a glyph diagram included in this example (glyph-dims.svg). // The metrics below are for the glyph loaded in the slot. float gAdvanceX = (float)face.Glyph.Advance.X; // same as the advance in metrics float gBearingX = (float)face.Glyph.Metrics.HorizontalBearingX; float gWidth = face.Glyph.Metrics.Width.ToSingle(); float gHeight = face.Glyph.Metrics.Height.ToSingle(); var ci = new CharInfo { Char = c, X = x, Y = y, Width = gWidth, Left = gBearingX, Right = gBearingX + gWidth, AdvanceX = gAdvanceX, Top = (float)face.Glyph.Metrics.HorizontalBearingY, Bottom = (float)(gHeight - face.Glyph.Metrics.HorizontalBearingY) }; #endregion #region Top/Bottom // If this character goes higher or lower than any previous character, adjust // the overall height of the bitmap. float glyphTop = (float)face.Glyph.Metrics.HorizontalBearingY; float glyphBottom = (float)(face.Glyph.Metrics.Height - face.Glyph.Metrics.HorizontalBearingY); if (glyphTop > top) top = glyphTop; if (glyphBottom > bottom) bottom = glyphBottom; #endregion if (ci.X + ci.AdvanceX > MaxWidth) { for (var j = rowStart; j < i; j++) { chars[j].Y += top; chars[j].LineTop = top; chars[j].LineBottom = bottom; } y += top + bottom; x = 0; ci.X = x; ci.Y = y; stringHeight += top + bottom ; rowStart = i; } x += ci.AdvanceX; chars.Add(ci); stringWidth = Math.Max(stringWidth, x ); // Accumulate the distance between the origin of each character (simple width). } for (var j = rowStart; j < chars.Count; j++) { chars[j].Y += top; chars[j].LineTop = top; chars[j].LineBottom = bottom; } stringHeight += top + bottom ; // If any dimension is 0, we can't create a bitmap if (stringWidth == 0 || stringHeight == 0) return null; // Create a new bitmap that fits the string. w = (int)Math.Ceiling(stringWidth); int l = 2; while (l < w) { l *= 2; } w = l; h = (int)Math.Ceiling(stringHeight); l = 2; while (l < h) { l *= 2; } h = l; // w = 512; // h = 512; byte[] buff = new byte[w * h * 2]; Pen borderPen = Pens.Blue; Pen shapePen = Pens.Red; // Draw the string into the bitmap. // A lot of this is a repeat of the measuring steps, but this time we have // an actual bitmap to work with (both canvas and bitmaps in the glyph slot). for (int i = 0; i < chars.Count; i++) { var ci = chars[i]; #region Load character char c = ci.Char; ; // Same as when we were measuring, except RenderGlyph() causes the glyph data // to be converted to a bitmap. uint glyphIndex = face.GetCharIndex(c); face.LoadGlyph(glyphIndex, LoadFlags.Default, LoadTarget.Mono); face.Glyph.RenderGlyph(RenderMode.Normal); FTBitmap ftbmp = face.Glyph.Bitmap; #endregion #region Draw glyph // Whitespace characters sometimes have a bitmap of zero size, but a non-zero advance. // We can't draw a 0-size bitmap, but the pen position will still get advanced (below). if ((ftbmp.Width > 0 && ftbmp.Rows > 0)) { var count = ftbmp.Width * ftbmp.Rows ; if (ftbmp.PixelMode == PixelMode.Mono) { // StringBuilder sb = new StringBuilder(); int cw = ftbmp.Width / 8; if (cw * 8 < ftbmp.Width) { cw++; } for (var r = 0; r < ftbmp.Rows; r++) { for(var col = 0; col < ftbmp.Width; col++) { var bit = col % 8; var bite = (col - bit) / 8; byte pix = ftbmp.BufferData[r * cw + bite]; var toCheck = pix >> (7 - bit); bool hasClr = (toCheck & 1) == 1; var p = (int)Math.Ceiling((ci.Y - ci.Top + r) * w + ci.X + ci.Left + col); if (hasClr) { buff[p * 2] = 255; buff[p * 2+1] = 255; } } } // var shape = sb.ToString(); } else if( ftbmp.PixelMode== PixelMode.Gray) { for (var j = 0; j < ftbmp.Rows; j++) { for (var k = 0; k < ftbmp.Width; k++) { var pos = j * ftbmp.Width + k; var p = (int)Math.Ceiling((ci.Y - ci.Top + j) * w + ci.X + ci.Left + k); var g = (int)ftbmp.BufferData[pos]; if (g > 0) { if (g < 100) { g = (int)Math.Ceiling(g * 2f); } else { g = (int)Math.Ceiling(g * 1.5f); } if (g > 255) { g = 255; } buff[p * 2] = 255; buff[p * 2 + 1] = (byte)g; } } } } ftbmp.Dispose(); } poses.Add(new CharTextture { Char = c, Left = (float)ci.X /w, Top = (float)(ci.Y - ci.LineTop) / h, Right = (float)(ci.X + ci.AdvanceX) / w, Bottom = (float)(ci.Y + ci.LineBottom) / h, WidthScale = ci.AdvanceX / (ci.LineTop + ci.LineBottom) }); #endregion } LastPoses = poses; return buff; }
然后,通过LoadGlyph方法加载字形相关信息:
uint glyphIndex = face.GetCharIndex(c);
face.LoadGlyph(glyphIndex, LoadFlags.Default, LoadTarget.Normal);
var lineHeight = face.Glyph.LinearVerticalAdvance.Value;
face.Glyph.RenderGlyph(RenderMode.Normal);
FTBitmap ftbmp = face.Glyph.Bitmap;
通过ftBmp的bufferData可以直接获取到对应的字形信息,不过要注意的是,如果渲染模式为Mono的话,字形是按位存储的,其他的是按照字节存储的,读取字形信息的时候需要区别对待
if (ftbmp.PixelMode == PixelMode.Mono) { // StringBuilder sb = new StringBuilder(); int cw = ftbmp.Width / 8; if (cw * 8 < ftbmp.Width) { cw++; } for (var r = 0; r < ftbmp.Rows; r++) { for(var col = 0; col < ftbmp.Width; col++) { var bit = col % 8; var bite = (col - bit) / 8; byte pix = ftbmp.BufferData[r * cw + bite]; var toCheck = pix >> (7 - bit); bool hasClr = (toCheck & 1) == 1; var p = (int)Math.Ceiling((ci.Y - ci.Top + r) * w + ci.X + ci.Left + col); if (hasClr) { buff[p * 2] = 255; buff[p * 2+1] = 255; } } } // var shape = sb.ToString(); } else if( ftbmp.PixelMode== PixelMode.Gray) { for (var j = 0; j < ftbmp.Rows; j++) { for (var k = 0; k < ftbmp.Width; k++) { var pos = j * ftbmp.Width + k; var p = (int)Math.Ceiling((ci.Y - ci.Top + j) * w + ci.X + ci.Left + k); var g = (int)ftbmp.BufferData[pos]; if (g > 0) { if (g < 100) { g = (int)Math.Ceiling(g * 2f); } else { g = (int)Math.Ceiling(g * 1.5f); } if (g > 255) { g = 255; } buff[p * 2] = 255; buff[p * 2 + 1] = (byte)g; } } } }
另外,读取Alpha通道的时候,可以适当调高Alpha通道的值。
TrueType可以参照下面链接。
http://www.freetype.org/freetype2/docs/tutorial/step2.html.
纹理+MipMap
MipMap是纹理的一种取样方式,OpenGL 在加载纹理后,可以生成一系列较小的纹理,在渲染的时候,如果图纸缩小,则直接使用接近的小纹理,而不是直接使用原始纹理。
private void GetMipTextureViaBuff(OpenGL gl) { fontSizes = new int[] { 12 }; fontService.SetSize(12f); textures = new uint[fontSizes.Length]; int w = 0, h = 0; gl.GenTextures(fontSizes.Length, textures); var data = fontService.GetTextBuff(Chars, ref w, ref h); Coorses.Add(FontService.LastPoses); // Bind the texture. gl.BindTexture(OpenGL.GL_TEXTURE_2D, textures[0]); gl.TexParameter(OpenGL.GL_TEXTURE_2D, OpenGL.GL_TEXTURE_WRAP_S, OpenGL.GL_CLAMP_TO_BORDER); gl.TexParameter(OpenGL.GL_TEXTURE_2D, OpenGL.GL_TEXTURE_WRAP_T, OpenGL.GL_CLAMP_TO_BORDER); gl.TexParameter(OpenGL.GL_TEXTURE_2D, OpenGL.GL_TEXTURE_MIN_FILTER, OpenGL.GL_LINEAR_MIPMAP_LINEAR); gl.TexParameter(OpenGL.GL_TEXTURE_2D, OpenGL.GL_TEXTURE_MAG_FILTER, OpenGL.GL_LINEAR_MIPMAP_NEAREST); gl.TexEnv(OpenGL.GL_TEXTURE_ENV, OpenGL.GL_TEXTURE_ENV_MODE, OpenGL.GL_REPLACE); gl.TexParameter(OpenGL.GL_TEXTURE_2D, OpenGL.GL_TEXTURE_BASE_LEVEL, 0); gl.TexParameter(OpenGL.GL_TEXTURE_2D, OpenGL.GL_TEXTURE_MAX_LEVEL, 2); float[] max_antis = new float[1]; gl.GetFloat(GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, max_antis); //多重采样 gl.TexParameter(OpenGL.GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, max_antis[0] < 4 ? max_antis[0] : 4); //创建MipMap纹理 gluBuild2DMipmaps(OpenGL.GL_TEXTURE_2D, 2, w, h, OpenGL.GL_LUMINANCE_ALPHA, OpenGL.GL_UNSIGNED_BYTE, data); //int size = data .Length; //IntPtr buffer = Marshal.AllocHGlobal(size); //try //{ // Marshal.Copy(data, 0, buffer, size); // gl.Build2DMipmaps(OpenGL.GL_TEXTURE_2D, 2, w, h, OpenGL.GL_LUMINANCE_ALPHA, OpenGL.GL_UNSIGNED_BYTE, buffer); //} //finally //{ // Marshal.FreeHGlobal(buffer); //} }
使用Mipmap纹理可能会多占用大约1/3的显存,但是一次生成后,不用再次生成。缩放图纸也能较为顺畅的显示,效果如下:
着色器
使用MipMap纹理,我们需要处理一个问题,那就是颜色。默认着色器会将纹理本身的颜色显示在屏幕,而我们的需求是根据不同的要求显示不同的颜色且不用为每个颜色生成纹理。
通过着色器可以解决这个问题:
#region shader VertexShader vertexShader = new VertexShader(); vertexShader.CreateInContext(gl); vertexShader.SetSource(@"#version 130 varying vec2 texcoord;varying vec4 pass_color;void main(void) { gl_Position =gl_ProjectionMatrix * gl_ModelViewMatrix * vec4( gl_Vertex.xy,0,1);//ftransform();// texcoord =gl_Vertex.zw; pass_color=gl_Color; }"); // Create a fragment shader. FragmentShader fragmentShader = new FragmentShader(); fragmentShader.CreateInContext(gl); fragmentShader.SetSource(@"#version 130varying vec2 texcoord;varying vec4 pass_color;uniform sampler2D tex;uniform vec4 color;void main(void) { vec4 clr=texture2D(tex,texcoord );//texcoord gl_FragColor =vec4(1,1,1,clr.a)*pass_color; }"); // Compile them both. vertexShader.Compile(); string msg = GetShaderError(vertexShader.ShaderObject, gl); if (!string.IsNullOrEmpty(msg)) { MessageBox.Show(msg); } fragmentShader.Compile(); msg= GetShaderError(fragmentShader.ShaderObject,gl); if (!string.IsNullOrEmpty(msg)) { MessageBox.Show(msg); } // Build a program. program.CreateInContext(gl); // Attach the shaders. program.AttachShader(vertexShader); program.AttachShader(fragmentShader); program.Link(); int[] parm = new int[1]; gl.GetProgram(program.ProgramObject, OpenGL.GL_LINK_STATUS, parm); if (parm[0] == 0) { StringBuilder smsg = new StringBuilder(1024); IntPtr ptr = IntPtr.Zero; gl.GetProgramInfoLog(program.ProgramObject, 1024, ptr, smsg); msg = smsg.ToString(); if (!string.IsNullOrEmpty(msg)) { MessageBox.Show(msg); } } gl.ValidateProgram(program.ProgramObject); gl.GetProgram (program.ProgramObject, OpenGL.GL_VALIDATE_STATUS, parm); if (parm[0] == 0) { StringBuilder smsg = new StringBuilder(1024); IntPtr ptr = IntPtr.Zero; gl.GetProgramInfoLog(program.ProgramObject, 1024, ptr, smsg); msg = smsg.ToString(); if (!string.IsNullOrEmpty(msg)) { MessageBox.Show(msg); } } #endregion
最后显示文字的代码如下:
private void DrawText(string text, float x, float y, float h, float wscale, OpenGL gl,bool bUseShader) { float screenH =(float) Math.Round(h * scale); var x0 = x; var idx = fontSizes.Length - 1; for(var i = 0; i < fontSizes.Length-1; i++) { if (h * scale <= fontSizeTrigers[i]) { idx = i; break; } } this.Text = "fontsize:" + fontSizes[idx]+">"+h*scale; Coors = Coorses[idx]; int simpler2d =(int) textures[idx]; gl.ActiveTexture(textures[idx]); gl.BindTexture(OpenGL.GL_TEXTURE_2D, textures[idx]); gl.Enable(OpenGL.GL_TEXTURE_2D); gl.Begin(OpenGL.GL_QUADS); foreach (var ch in text) { var ci = Coors.Find(c => c.Char ==ch); var w = h * ci.WidthScale * wscale; if (bUseShader) { // gl.TexCoord(ci.Left, ci.Bottom); gl.Vertex4f(x, y, ci.Left, ci.Bottom); // Bottom Left Of The Texture and Quad // gl.TexCoord(ci.Right, ci.Bottom); gl.Vertex4f(x + w, y, ci.Right, ci.Bottom); // Bottom Right Of The Texture and Quad // gl.TexCoord(ci.Right, ci.Top); gl.Vertex4f(x + w, y + h, ci.Right, ci.Top); // Top Right Of The Texture and Quad // gl.TexCoord(ci.Left, ci.Top); gl.Vertex4f(x, y + h, ci.Left, ci.Top); // Top Left Of The Texture and Quad } else { gl.TexCoord(ci.Left, ci.Bottom); gl.Vertex(x, y, 0); // Bottom Left Of The Texture and Quad gl.TexCoord(ci.Right, ci.Bottom); gl.Vertex(x + w, y, 0); // Bottom Right Of The Texture and Quad gl.TexCoord(ci.Right, ci.Top); gl.Vertex(x + w, y + h, 0); // Top Right Of The Texture and Quad gl.TexCoord(ci.Left, ci.Top); gl.Vertex(x, y + h, 0); // Top Left Of The Texture and Quad } x += w; } gl.End(); gl.Disable(OpenGL.GL_TEXTURE_2D); }
进一步优化
VBO+纹理
本文只是解释了以立即绘制的方式来显示文字,而真正使用的时候,应该将纹理和VBO结合使用,以达到更高的性能。
最后 ,代码下载地址为 下载 (正在审批中,估计过2天就可以下载了)。
————————————————
版权声明:本文为CSDN博主「李世垚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/snakegod/article/details/81126568
————————————————
版权声明:本文为CSDN博主「李世垚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/snakegod/article/details/81126568
本文来自博客园,作者:NLazyo,转载请注明原文链接:https://www.cnblogs.com/bile/p/12183080.html