C#发现之旅第六讲 C#图形开发中级篇

C#发现之旅第六讲 C#图形开发中级篇

袁永福 2008-5-15

系列课程说明

    为了让大家更深入的了解和使用C#,我们将开始这一系列的主题为“C#发现之旅”的技术讲座。考虑到各位大多是进行WEB数据库开发的,而所谓发现就是发现我们所不熟悉的领域,因此本系列讲座内容将是C#在WEB数据库开发以外的应用。目前规划的主要内容是图形开发和XML开发,并计划编排了多个课程。在未来的C#发现之旅中,我们按照由浅入深,循序渐进的步骤,一起探索和发现C#的其他未知的领域,更深入的理解和掌握使用C#进行软件开发,拓宽我们的视野,增强我们的软件开发综合能力。

本系列课程配套的演示代码下载地址为 http://files.cnblogs.com/xdesigner/cs_discovery.zip 。其中的CellViewLib.zip 就是本课程的演示代码。

本系列课程已发布的文章有
C#发现之旅第一讲 C#-XML开发
C#发现之旅第二讲 C#-XSLT开发
C#发现之旅第三讲 使用C#开发基于XSLT的代码生成器
C#发现之旅第四讲 Windows图形开发入门
C#发现之旅第五讲 图形开发基础篇
C#发现之旅第六讲 C#图形开发中级篇
C#发现之旅第七讲 C#图形开发高级篇
C#发现之旅第八讲 ASP.NET图形开发带超链接的饼图
C#发现之旅第九讲 ASP.NET验证码技术
C#发现之旅第十讲 文档对象模型

课程说明

    在上一次课程中,我们一起研究了使用C#开发一个比较简单的椭圆形按钮的控件,初步接触了C#图形开发,在本次课程中我们将继续深入研究C#图形开发,尝试使用C#开发一个稍微复杂点的数据网格控件。

功能需求

现客户要求开发一个图形软件,其软件功能需求是

  1. 用一个网格式界面显示一个数据表的文本内容。
  2. 可以设置网格行的高度,单元格的宽度自动适应文本内容的大小。当显示的内容比较多时显示滚动条。
  3. 用户可以使用鼠标点击操作来选择一个单元格,也可以鼠标拖拽选择多个单元格。
  4. 可以复制选择的单元格的文本。

    最后开发的软件其用户界面如图所示

C#发现之旅第六讲 C#图形开发中级篇_第1张图片

软件设计

    根据软件功能需求,其界面类似DataGrid控件,这是一种界面比较复杂的图形软件组件,需要采用文档-视图的软件设计模式。

    所谓文档-视图模式,就是将用户界面中要显示的数据并不直接放置到控件代码中,而是按照逻辑层次关系建立一种文档对象模型,使用一个个对象来影射到要显示的数据中的某些部分。此时控件根据这个文档对象模型来显示用户界面,并响应用户界面事件来操作一个个文档对象。这种软件设计模式通常用于比较复杂的图形用户界面软件的设计中。

文档对象模型-单元格,表格行,表格

    我个人的软件开发风格是从底层到界面,当然也有人是喜欢从界面到底层的。要开发这个软件,首先是设计文档模型,此处文档结构比较简单,就是一个二维表格,因此很自然的设计出表格式的文档对象模型,无非就是单元格,表格行和表格三种对象,这三种对象构成一个具有三个层次的对象树状结构,这有点类型HTML文档中的表格对象结构。此处为了简化设计就没有定义表格列对象了。

视图控件

    本软件的关键是建立一个自定义用户控件,它能根据表格文档对象模型来绘制网格,并实现一些操作特性。该控件开发主要实现的功能有

    数据的加载,可以将一个二维表格数据结构设置到表格文档对象模型中供控件显示。此处测试时使用了一个DataTable对象填充表格文档对象。为了开发方便,事先查询数据库获得一个DataTable并序列化到一个文件中。测试程序将从这个序列化文件获得一个DataTable然后填充到控件中。

    控件成功加载数据后,还需要进行内容排版,计算表格中每一个单元格的位置和大小。由于客户要求单元格的宽度自动适应文本内容的宽度,因此首先计算所有单元格文本的显示所需的宽度,然后获得某个表格列所有单元格的最大宽度,然后设置该网格列的宽度为这个最大宽度。网格内容排版后还要获得整个网格的显示大小,并根据需要设置控件的滚动条状态。

    我们可以使用Graphics对象的MeasureString 方法来计算字符串的显示宽度。若文档视图比较大时用户控件还要显示滚动条来滚动显示整个文档视图。

    控件还需要鼠标点击选择一个单元格,或者鼠标拖拽选择多个单元格,因此还需要处理鼠标事件。当用户按下鼠标按键时,设置鼠标光标下面的单位格为选中状态,当用户鼠标拖拽时,将动态的形成一个从拖拽起点到当前鼠标光标位置的选择区域矩形,若单元格和选择矩形相交,则设置单元格为选中状态。

软件代码说明

    根据软件设计,开发出了这个网格数据显示软件,现按照软件的运行过程对代码进行详细说明。

    使用VS.NET2003打开这个工程,按下F5运行,可以看到主窗体上有一个“显示数据”按钮,按下该按钮,然后进行单步跟踪状态,可以看到程序首先从程序资源文件 DataTable.dat 中反序列化加载一个DataTable对象,这个DataTable.dat 文件是事先生成的,这样做是为了简化这个软件的代码,我们也可以连接数据库查询数据获得一个DataTable对象,其效果是一样的。

FillDataTable

    获得DataTable后程序调用控件的FillDataTable方法向控件填充数据。这个方法的代码如下

///
/// 根据一个DataTable 填充网格
///

/// 数据表对象
public void FillDataTable( DataTable table )
{
    if( table == null )
        throw new ArgumentNullException("table");
    myDocument.Clear();
    CellRow row = new CellRow();
    myDocument.Add( row );
    foreach( DataColumn col in table.Columns )
    {
        row.Add( col.ColumnName );
    }
    foreach( DataRow drow in table.Rows )
    {
        row = new CellRow();
        foreach( DataColumn col in table.Columns )
        {
            object v = drow[ col ] ;
            string txt = "";
            if( v == null || DBNull.Value.Equals( v ))
                txt = "[NULL]";
            else
                txt = Convert.ToString( v );
            row.Add( txt );
        }
        myDocument.Add( row );
    }
    using( System.Drawing.Graphics g = this.CreateGraphics())
    {
        this.RefreshSize( g );
        this.Invalidate();
    }

}

    进入FillDataTable方法可以看到程序是根据DataTable填充网格文档对象 myDocument 。程序中实现了由Cell , CellRow CellDocument 三种类型组成的网格文档对象。

    大家可以看看这三个类的代码,它们是相当简单的。Cell 类定义了网格单元格对象,包括单元格显示的文本,位置和大小等信息。CellRow类定义了表格行对象,它本身也是单元格列表,可以添加单元格对象。CellDocument定义了表格文档对象,它本身是表格行列表,可以添加表格行,还提供Cells 属性返回文档中所有的单元格对象组成的数组。

    我们回到FillDataTable 函数,首先是清空文档,然后遍历DataTable的标题栏信息,生成网格文档的第一行单元格,然后遍历DataTable所有的数据行对象,对每一个数据行新增一个表格行对象,然后添加到 myDocument 中。

RefreshSize

    程序使用一个DataTable填充网格文档后,需要调用 RefreshSize 进行内容事先排版,为显示文档内容做准备。这个方法的代码为

///
/// 计算单元格大小,进行内容排版
///

/// 计算文本大小使用的图形绘制对象
public void RefreshSize( System.Drawing.Graphics g )
{
    ArrayList cells = new ArrayList();
    System.Drawing.Size ViewSize = Size.Empty ;
    int LeftCount = 0 ;
    for( int iCount = 0 ; iCount < 1000 ; iCount ++ )
    {
        // 遍历所有的表格列,获得指定的列的单元格对象
        // 此处允许最大的表格列有1000列
        cells.Clear();
        for( int RowIndex = 0 ; RowIndex < myDocument.Count ; RowIndex ++ )
        {
            CellRow row = myDocument[ RowIndex ] ;
            if( iCount < row.Count )
            {
                Cell cell = row[ iCount ] ;
                // 设置单元格的位置
                cell.intLeft = LeftCount ;
                cell.intTop = RowIndex * this.RowHeight ;
                cells.Add( cell );
            }
        }
        if( cells.Count == 0 )
            break;
        // 计算当前列的单元格的最大宽度
        int MaxWidth = 40 ;
        foreach( Cell cell in cells )
        {
            string txt = cell.Text ;
            if( txt != null && txt.Length > 0 )
            {
                System.Drawing.SizeF size = g.MeasureString(
                    txt ,
                    this.Font ,
                    1000 ,
                    System.Drawing.StringFormat.GenericDefault  );
                if( MaxWidth < ( int ) size.Width )
                    MaxWidth = ( int ) size.Width ;
            }
        }
        MaxWidth += 10 ;
        // 设置单元格的大小
        foreach( Cell cell in cells )
        {
            cell.intWidth = MaxWidth ;
            cell.intHeight = this.RowHeight ;
            if( cell.Left + cell.Width > ViewSize.Width )
                ViewSize.Width = cell.Left + cell.Width ;
            if( cell.Top + cell.Height > ViewSize.Height )
                ViewSize.Height = cell.Top + cell.Height ;
        }
        LeftCount += MaxWidth ;
    }
    ViewSize.Width += 10 ;
    ViewSize.Height += 10 ;
    if( this.AutoScrollMinSize.Equals( ViewSize ) == false )
    {
        this.AutoScrollMinSize = ViewSize ;
        this.Invalidate();
    }

}//public void RefreshSize( System.Drawing.Graphics g )

    由于其中要计算单元格文本的显示宽度,需要使用Graphics对象,因此这里使用用户控件的 CreateGraphics 方法获得一个 Graphics对象。Graphics对象不能使用new 语句直接实例化,必须使用某个控件的CreateGraphics方法或从一个图片中创建Graphics 对象。现在我们随着代码的流程进入到RefreshSize 函数。

    在这里我们定义了一个LeftCount变量,该变量保存了当前表格列的左边缘位置。定义了ViewSize变量,用于保存整个文档的显示大小。首先我们需要计算各个表格列的宽度,由于我们没有定义表格列对象,因此采用遍历的手段来获得所有属于指定列号的单元格对象。并设置这些单元格的顶端位置和左端位置。

    然后遍历所有同一列的单元格,计算它们的文本显示宽度,并获得其最大值。则该最大值就是当前表格列的宽度,然后设置这些单元格的宽度为列宽。并修正整个文档的显示大小。

    处理了所有的单元格后,文档视图排版完毕,可以显示了。程序还计算了整个文档视图的大小,并根据需要设置控件的 AutoScrollMinSize 属性用来设置滚动状态。

    UserControl支持自动设置滚动状态。当设置用户控件的AutoScroll属性时,就启用自动滚动设置。此时我们可以设置AutoScrollMinSize属性来控制滚动状态,当用户控件的客户区ClientSize的宽度或高度小于这个值时就会自动显示横向或纵向滚动条,若客户区大小足够容纳这个AutoScrollMinSize时,就不会显示滚动条,当用户控件大小改变时会自动进行这样的判断。在此我们设置AutoScrollMinSize为文档视图的大小,因此程序也就自动维护滚动状态。

    当程序完成文档内容排版后,我们就调用Invalidate函数来通知系统重新绘制控件的用户界面。

OnPaint

    数据加载了,文档视图也完成的排版,接下来就是绘制用户界面了,我们就很自然的重写控件的OnPaint函数来绘制网格了。这个方法代码为

///
/// 绘制控件内容
///

/// 绘制图形参数
protected override void OnPaint(PaintEventArgs e)
{
    base.OnPaint (e);
    System.Drawing.Rectangle ClipRect = e.ClipRectangle ;
    ClipRect.Offset( - this.AutoScrollPosition.X , - this.AutoScrollPosition.Y );
    // 进行坐标转换
    e.Graphics.TranslateTransform( this.AutoScrollPosition.X , this.AutoScrollPosition.Y );
    // 绘制网格的画笔对象
    Pen GridPen = null;
    if( intGridColor.A != 0 )
    {
        GridPen = new Pen( intGridColor );
    }
    // 填充网格的画刷对象
    SolidBrush GridBrush = null;
    if( intGridBackColor.A != 0 )
    {
        GridBrush = new SolidBrush( intGridBackColor );
    }
    // 绘制文本的画刷对象
    SolidBrush TextBrush = new SolidBrush( this.ForeColor );
    // 输出文本使用的格式化对象
    StringFormat TextFormat = new StringFormat();
    TextFormat.Alignment = System.Drawing.StringAlignment.Near ;
    TextFormat.LineAlignment = System.Drawing.StringAlignment.Center ;
    TextFormat.FormatFlags = System.Drawing.StringFormatFlags.NoWrap ;
    try
    {
        foreach( CellRow row in myDocument )
        {
            foreach( Cell cell in row )
            {
                // 遍历所有表格行的单元格对象,对单元格进行逐个绘制
                Rectangle bounds = cell.Bounds ;
                // 若单元格和剪切矩形不相交,则单元格无需绘制,转而处理下一个单元格.
                if( ClipRect.IntersectsWith( bounds ) == false )
                {
                    continue ;
                }
                if( cell.Selected )
                {
                    // 若单元格处于选择状态则显示高亮度背景色
                    e.Graphics.FillRectangle( SystemBrushes.Highlight , bounds );
                }
                else
                {
                    // 绘制单元格背景
                    if( GridBrush != null )
                    {
                        e.Graphics.FillRectangle( GridBrush , bounds );
                    }
                }
                if( GridPen != null )
                {
                    // 绘制单元格边框
                    e.Graphics.DrawRectangle( GridPen , bounds );
                }
                if( cell.Text != null )
                {
                    // 绘制单元格文本
                    e.Graphics.DrawString(
                        cell.Text ,
                        this.Font ,
                        cell.Selected ? SystemBrushes.HighlightText : TextBrush ,
                        new RectangleF(
                        cell.Left ,
                        cell.Top ,
                        cell.Width ,
                        cell.Height ) ,
                        TextFormat );
                }
            }
        }
    }
    finally
    {
        if( GridPen != null )
            GridPen.Dispose();
        if( GridBrush != null )
            GridBrush.Dispose();
        TextBrush.Dispose();
        TextFormat.Dispose();
    }

}

    由于我们已经计算了所有单元格的位置和大小,因此绘制网格的过程不复杂,就是遍历所有的单元格,绘制一个矩形边框和单元格文本而已。

    由于这个用户界面是可能发生滚动的,形成一种折射效应,绘制时需要进行坐标转换,这就增加了一些复杂度。程序首先获得剪切矩形ClipRect,并进行移位,然后设置图形绘制对象e.Graphics进行坐标转换,然后遍历所有的单元格对象,针对每一个单元格,若剪切矩形和单元格的边框相交,则绘制单元格,否则不绘制该单元格。

    单元格对象有一个 Selected 属性,表示单元格是否处于选择状态,若单元格处于选择状态则使用高亮度背景画刷 SystemBrushes.Highlight绘制单元格背景,否则使用控件的网格背景色绘制单元格背景。然后使用控件的网格线颜色来绘制单元格的边框。绘制控件的边框后使用GraphicsDrawString成员来绘制单元格文本,而且当单元格处于选择状态则使用高系统定义的高亮度文本颜色,否则使用控件文本颜色。

    这里创建绘制网格的画笔对象和绘制背景的画刷对象时进行了一些判断,若颜色值的属性A0则不创建对象。在C#中使用类型System.Drawing.Color来表示颜色值,它有4个属性来表示颜色特性,也就是ARGB四个属性值,其中属性RGB分别表示颜色的红绿蓝的颜色分量,而属性A表示颜色透明度,若A等于255则表示纯色,不透明,若等于0则表示完全透明,此时绘制图形也就无意义了,若A的值在1254之间则表示半透明,这个值越小,颜色就越透明。

鼠标事件处理

    用于客户要求能用鼠标点击或拖拽操作来选择单元格,因此我们需要处理控件的鼠标事件来实现选择单元格效果。其代码为

///
/// 上一次鼠标按键按下时鼠标光标位置
///

private Point LastMousePosition = new Point( -1 , -1);
///
/// 处理鼠标按键按下事件
///

///
protected override void OnMouseDown(MouseEventArgs e)
{
    base.OnMouseDown (e);
    // 鼠标光标位置坐标转换
    Point p = new Point( e.X , e.Y );
    p.Offset( - this.AutoScrollPosition.X , - this.AutoScrollPosition.Y );
    LastMousePosition = p ;
    Cell[] cells = myDocument.Cells ;
    foreach( Cell cell in cells )
    {
        Rectangle bounds = cell.Bounds ;
        bool select = bounds.Contains( p );
        if( cell.Selected != select )
        {
            InvalidateCell( cell );
            cell.Selected = select ;
        }
    }
}
///
/// 处理鼠标移动事件
///

///
protected override void OnMouseMove(MouseEventArgs e)
{
    base.OnMouseMove (e);
    if( LastMousePosition.X >= 0 )
    {
        // 鼠标光标位置坐标转换
        Point p = new Point( e.X , e.Y );
        p.Offset( - this.AutoScrollPosition.X , - this.AutoScrollPosition.Y );
        // 根据 p 和  LastMousePosition 两点坐标获得一个矩形选择区域
        Rectangle SelectRect = Rectangle.Empty ;
        if( p.X > LastMousePosition.X )
        {
            SelectRect.X = LastMousePosition.X ;
            SelectRect.Width = p.X - LastMousePosition.X ;
        }
        else
        {
            SelectRect.X = p.X ;
            SelectRect.Width = LastMousePosition.X - p.X ;
        }
        if( p.Y > LastMousePosition.Y )
        {
            SelectRect.Y = LastMousePosition.Y ;
            SelectRect.Height = p.Y - LastMousePosition.Y ;
        }
        else
        {
            SelectRect.Y = p.Y ;
            SelectRect.Height = LastMousePosition.Y - p.Y ;
        }
        foreach( Cell cell in myDocument.Cells )
        {
            bool flag = SelectRect.IntersectsWith( cell.Bounds );
            if( cell.Selected != flag )
            {
                cell.Selected = flag ;
                this.InvalidateCell( cell );
            }
        }
    }
}
///
/// 处理鼠标按键松开事件
///

///
protected override void OnMouseUp(MouseEventArgs e)
{
    base.OnMouseUp (e);
    LastMousePosition = new Point( -1 , -1 );

}

    首先重写OnMouseDown方法,由于刚刚说到的折射效果,需要将鼠标光标位置从控件客户区坐标转换为文档视图坐标。然后设置LastMousePosition变量,该变量服务于鼠标拖拽操作。遍历表格文档的所有的单元格对象,判断单元格的边框是否包含鼠标光标所在位置,然后根据需要设置单元格的选择状态,若单元格的选择状态发生改变,则调用InvalidateCell方法声明该单元格样式无效,准备重新绘制该单元格。

    然后重写OnMouseMove方法,首先进行鼠标光标位置坐标转换,若LastMousePosition有效,则说明用户正在拖拽鼠标,然后根据LastMousePosition坐标和当前鼠标光标坐标获得一个选择区域矩形,然后遍历所有单元格,判断选择矩形和单元格边框是否相交,并设置单元格的选择状态,若单元格的选择状态发生改变则声明该单元格样式无效,准备重新绘制界面。

    重写OnMouseUp方法,设置LastMousePosition变量无效,结束鼠标拖拽操作。

复制数据

    程序主窗体上有一个复制按钮,按下该按钮可以进入到控件的Copy方法。代码为

///
/// 复制选择的单元格的文本
///

public void Copy()
{
    System.Text.StringBuilder myStr = new System.Text.StringBuilder();
    foreach( CellRow row in myDocument )
    {
        bool find = false;
        foreach( Cell cell in row )
        {
            if( cell.Selected )
            {
                myStr.Append( cell.Text );
                myStr.Append( " " );
                find = true ;
            }
        }
        if( find )
            myStr.Append( System.Environment.NewLine );
    }
    if( myStr.Length > 0 )
    {
        System.Windows.Forms.DataObject data = new DataObject();
        data.SetData( myStr.ToString());
        System.Windows.Forms.Clipboard.SetDataObject( data , true );
    }

}

    该方法也比较简单,遍历所有的单元格,若该单元格处于选择状态,则获得它的文本,然后将所有选择单元格的文本拼凑起来,并设置到Windows系统剪切板中。

    大量的程序使用Windows剪切板交流数据。在.NET中操作剪切板是比较方便的,当我们进行复制操作时,首先是创建一个DataObject对象,使用它的SetData方法设置数据,然后使用ClipBoardSetDataObject方法来向Windows剪切板设置数据。我们可以同时向DataObject设置多种格式的数据,比如可以同时设置纯文本数据,RTF文档或图片数据,其他应用程序会检索剪切板中的数据格式,从而利用可处理的数据。

    当我们进行粘贴操作时,使用System.Windows.Forms.ClipBoardGetDataObject方法获得一个System.Windows.Forms.IDataObject对象,然后使用IDataObjectGetFormats方法检索可用的数据格式,类型System.Windows.Forms.DataFormats的静态字段预定义了一些数据格式的名称,然后可以使用IDataObjectGetData 方法获得指定格式的数据,如此可以根据获得的数据继续进行操作。

系统预定义颜色

    类型SystemBrushes定义了一些系统颜色的画刷对象,系统颜色是指Windows操作系统预先定义的标准颜色,包括桌面背景色,窗体颜色,菜单控件文本颜色,3D边框中的亮边框颜色,暗边框颜色,提示文本颜色和背景色,高亮度选择状态的文本颜色和背景色等等。打开操作系统桌面属性,可以进入这些系统颜色定义对话框,该对话框样式如图所示。

C#发现之旅第六讲 C#图形开发中级篇_第2张图片

    在进行图形开发时有时候需要使用这种系统预定义颜色,这样使得应用系统的颜色风格和Windows操作系统的整体风格保持一致,这样可以获得和操作系统一致的用户体验。

    在.NET中,类型SystemBrushes的一些静态属性提供了具有这种系统预定义颜色的画刷对象,类似的SystemPens的静态属性提供了具有系统预定义颜色的画笔对象,而SystemColors则提供了这些预定义颜色值。

折射效应

    由于该控件可能存在滚动,这就造成一种折射效应。这加大了程序的复杂度。

    在空气中,光线是直线传播的,因此手迎着光线直线移动必然能接触到物体。但若一个物体在水中,由于折射作用,手迎着光线直线移动也不一定能接触到物体,因此人的动作要根据折射的因素进行修正,才能准确的抓住物体。

    当用户界面发生滚动时也会有类似的折射效应。控件客户区中显示了一个图形,由于发生了滚动,则该图形在文档视图中的位置不等于在控件客户区中的位置,两者存在一个偏移量,这个偏移量就是控件的滚动量。

    在绘图图形时,需要将图形在文档视图中的坐标转换为控件客户区中的坐标来模拟这种折射效果,在OnPaint方法的开头,就调用GraphicsTranslateTrnasform方法进行坐标转换,这样就整体实现了文档视图坐标向控件客户区坐标的转换。

    在本控件的处理鼠标事件时,需要判断鼠标光标下的单元格对象,事件参数提供的鼠标光标坐标是在控件客户区中的坐标,若直接根据这个控件客户区坐标位置查找单元格对象,当控件发生滚动时,这样的操作过程是错误的。因此需要将鼠标的控件客户区坐标转换为视图坐标,转换后再搜索单元格才是正确的。

    控件中定义了一个InvalidateCell方法,参数是Cell类型,该方法的功能是声明某个单元格样式无效,需要重新绘制。由于声明控件部分界面无效的方法Invalidate的参数是采用控件客户区坐标的,而单元格位置是采用文档视图坐标的,因此需要进行坐标转换。

    折射效果在图形开发中是会经常遇到的,此处的折射效果是比较简单的,只是简单的整体移位。在一些复杂的图形用户界面中还可能发生图形的缩放和旋转,文档视图的不同的部分发生了不同的折射效应,此时程序处理折射效应是比较复杂的。

C#发现之旅第六讲 C#图形开发中级篇_第3张图片

完成开发

    为了开发方便,我们设置该程序为WinForm应用程序模式,编译生成一个EXE文件,我们可以修改工程类型为类库,编译生成一个DLL文件,我们就可以把这个DLL提交给客户使用了。

小结

    在本课程中,我们一起研究了一个稍微复杂的C#开发的图形软件,相对于上一个演示软件,这个软件展示了更多的C#图形编程技术,包括图形文档的排版,使用剪切矩形优化图形绘制,理解了用户界面的折射效应。相信大家认真学习后能身体力行,开始能编写一些自己的图形软件了。

    在下一个课程中,我们将探索更为复杂的C#图形开发,开始学习高级图形软件所用到的一些开发技术。使得大家能在C#图形开发的世界中更自在的探索研究。

你可能感兴趣的:(C#发现之旅第六讲 C#图形开发中级篇)