在WinForm中,使用自带的System.Windows.Forms.DataVisualization.Charting图表控件绘图时,在极其偶然的情况下,由于一些数据或者参数不对,会导致图表绘图区出现一个大大的红色叉叉,同时会弹出报错窗体。这个问题在我司的一个程序中非常罕见,且不容易重现,最近在处理这个问题时,通过控制变量,使得这一问题比较容易重现,从而为找到问题打开了突破口,这里记录一下。

问题


这个程序涉及到绘制股票的K线图,这个功能是基于Winform中自带的图表控件来开发的,可以新建一个ChartArea,然后添加一个类型为Candlestick的Series来实现。部分代码如下:

PriceArea = new System.Windows.Forms.DataVisualization.Charting.ChartArea();
PriceArea.AxisX.LabelStyle.Enabled = false;
PriceArea.AxisX.LabelStyle.ForeColor = System.Drawing.Color.Red;
PriceArea.AxisX.LabelStyle.Format = "MM/dd HH:mm";
PriceArea.AxisX.LineColor = System.Drawing.Color.DarkRed;
PriceArea.AxisX.MajorGrid.Enabled = false;
PriceArea.AxisX.MajorTickMark.Enabled = false;
PriceArea.AxisX.ScaleView.MinSize = 10D;
PriceArea.AxisX.ScrollBar.Enabled = false;
PriceArea.AxisX.ScrollBar.Size = 5D;

System.Windows.Forms.DataVisualization.Charting.StripLine stripLine1 = new System.Windows.Forms.DataVisualization.Charting.StripLine();
stripLine1.BackColor = System.Drawing.Color.MidnightBlue;
stripLine1.Interval = 1D;
stripLine1.IntervalType = System.Windows.Forms.DataVisualization.Charting.DateTimeIntervalType.Days;
stripLine1.StripWidth = 30D;
stripLine1.StripWidthType = System.Windows.Forms.DataVisualization.Charting.DateTimeIntervalType.Seconds;
PriceArea.AxisX.StripLines.Add(stripLine1);

PriceArea.AxisY.IsStartedFromZero = false;
PriceArea.AxisY.LabelStyle.ForeColor = System.Drawing.Color.Red;
PriceArea.AxisY.LabelStyle.Format = "f2";
PriceArea.AxisY.LineColor = System.Drawing.Color.DarkRed;
PriceArea.AxisY.MajorGrid.LineColor = System.Drawing.Color.DarkRed;
PriceArea.AxisY.MajorGrid.LineDashStyle = System.Windows.Forms.DataVisualization.Charting.ChartDashStyle.Dot;
PriceArea.AxisY.MaximumAutoSize = 100F;
PriceArea.AxisY.ScrollBar.Enabled = false;
PriceArea.AxisY.ScrollBar.Size = 5D;

PriceArea.BackColor = System.Drawing.Color.Black;
PriceArea.BackSecondaryColor = System.Drawing.Color.White;
PriceArea.BorderColor = System.Drawing.Color.FromArgb(((int)(((byte)(64)))), ((int)(((byte)(64)))), ((int)(((byte)(64)))), ((int)(((byte)(64)))));
PriceArea.BorderDashStyle = System.Windows.Forms.DataVisualization.Charting.ChartDashStyle.Solid;
PriceArea.CursorX.IsUserEnabled = true;
PriceArea.CursorX.LineColor = System.Drawing.Color.WhiteSmoke;
PriceArea.CursorY.Interval = 0.01D;
PriceArea.CursorY.IntervalType = System.Windows.Forms.DataVisualization.Charting.DateTimeIntervalType.Number;
PriceArea.CursorY.IsUserEnabled = true;
PriceArea.CursorY.LineColor = System.Drawing.Color.WhiteSmoke;

PriceArea.Name = "PriceArea";
PriceArea.Position.Auto = false;
PriceArea.Position.Height = 79F;
PriceArea.Position.Width = 96F;
PriceArea.Position.Y = 2F;
PriceArea.ShadowColor = System.Drawing.Color.Transparent;
PriceArea.CursorX.IsUserSelectionEnabled = true;
PriceArea.CursorY.IsUserSelectionEnabled = true;
this.ChartAreas.Add(PriceArea);

.....................................................................................................................................................................................................................................

PriceSeries = new System.Windows.Forms.DataVisualization.Charting.Series();
PriceSeries.ChartArea = "PriceArea";
PriceSeries.ChartType = System.Windows.Forms.DataVisualization.Charting.SeriesChartType.Candlestick;
PriceSeries.CustomProperties = "PriceUpColor=Black, PriceDownColor=Cyan, PointWidth=0.6";
PriceSeries.IsXValueIndexed = true;
PriceSeries.Name = "Price";
PriceSeries.XValueType = System.Windows.Forms.DataVisualization.Charting.ChartValueType.DateTime;
PriceSeries.YValuesPerPoint = 4;
PriceSeries.YValueType = System.Windows.Forms.DataVisualization.Charting.ChartValueType.Double;
this.Series.Add(PriceSeries);

在某些时候,该图表会报错,现象如下,绘图区会出现一个大大的红色的叉叉,并且会弹出报错信息:

有关调用实时(JIT)调试而不是此对话框的详细信息,
请参见此消息的结尾。

************** 异常文本 **************
System.OverflowException: 溢出错误。
   在 System.Drawing.Graphics.CheckErrorStatus(Int32 status)
   在 System.Drawing.Graphics.FillRectangle(Brush brush, Single x, Single y, Single width, Single height)
   在 System.Drawing.Graphics.FillRectangle(Brush brush, RectangleF rect)
   在 System.Windows.Forms.DataVisualization.Charting.GdiGraphics.FillRectangle(Brush brush, RectangleF rect)
   在 System.Windows.Forms.DataVisualization.Charting.ChartGraphics.FillRectangleRel(RectangleF rectF, Color backColor, ChartHatchStyle backHatchStyle, String backImage, ChartImageWrapMode backImageWrapMode, Color backImageTransparentColor, ChartImageAlignmentStyle backImageAlign, GradientStyle backGradientStyle, Color backSecondaryColor, Color borderColor, Int32 borderWidth, ChartDashStyle borderDashStyle, Color shadowColor, Int32 shadowOffset, PenAlignment penAlignment, Boolean circular, Int32 circularSectorsCount, Boolean circle3D, BarDrawingStyle barDrawingStyle, Boolean isVertical)
   在 System.Windows.Forms.DataVisualization.Charting.ChartGraphics.FillRectangleRel(RectangleF rectF, Color backColor, ChartHatchStyle backHatchStyle, String backImage, ChartImageWrapMode backImageWrapMode, Color backImageTransparentColor, ChartImageAlignmentStyle backImageAlign, GradientStyle backGradientStyle, Color backSecondaryColor, Color borderColor, Int32 borderWidth, ChartDashStyle borderDashStyle, Color shadowColor, Int32 shadowOffset, PenAlignment penAlignment)
   在 System.Windows.Forms.DataVisualization.Charting.ChartTypes.StockChart.DrawOpenCloseMarks(ChartGraphics graph, ChartArea area, Series ser, DataPoint point, Single xPosition, Single width)
   在 System.Windows.Forms.DataVisualization.Charting.ChartTypes.StockChart.ProcessChartType(Boolean selection, ChartGraphics graph, CommonElements common, ChartArea area, Series seriesToDraw)
   在 System.Windows.Forms.DataVisualization.Charting.ChartTypes.StockChart.Paint(ChartGraphics graph, CommonElements common, ChartArea area, Series seriesToDraw)
   在 System.Windows.Forms.DataVisualization.Charting.ChartArea.Paint(ChartGraphics graph)
   在 System.Windows.Forms.DataVisualization.Charting.ChartPicture.Paint(Graphics graph, Boolean paintTopLevelElementOnly)
   在 System.Windows.Forms.DataVisualization.Charting.ChartPicture.PaintOffScreen()
   在 System.Windows.Forms.DataVisualization.Charting.Selection.HitTest(Int32 x, Int32 y, Boolean ignoreTransparent, ChartElementType[] requestedElementTypes)
   在 System.Windows.Forms.DataVisualization.Charting.Selection.HitTest(Int32 x, Int32 y, ChartElementType requestedElement)
   在 System.Windows.Forms.DataVisualization.Charting.AnnotationCollection.OnMouseMove(MouseEventArgs e)
   在 System.Windows.Forms.DataVisualization.Charting.Chart.OnChartMouseMove(MouseEventArgs e)
   在 System.Windows.Forms.Control.WmMouseMove(Message& m)
   在 System.Windows.Forms.Control.WndProc(Message& m)
   在 System.Windows.Forms.NativeWindow.Callback(IntPtr hWnd, Int32 msg, IntPtr wparam, IntPtr lparam)


************** 已加载的程序集 **************
mscorlib
    程序集版本:4.0.0.0
    Win32 版本:4.8.9167.0 built by: NET481REL1LAST_B
    基本代码:file:///C:/Windows/Microsoft.NET/Framework64/v4.0.30319/mscorlib.dll

▲ 正常情况下

▲ 异常情况下的红叉

这个错误在用户代码中用try catch是捕获不到的。使用如下的全局异常处理:

Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
Application.ThreadException += Application_ThreadException;
AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);

能够捕获得到报错信息如下:

[2023-08-25 17:01:23,674] ERROR - Fatal UI Error
[2023-08-25 17:01:23,677] ERROR - 溢出错误。System.OverflowException   在 System.Drawing.Graphics.CheckErrorStatus(Int32 status)
   在 System.Drawing.Graphics.FillRectangle(Brush brush, Single x, Single y, Single width, Single height)
   在 System.Drawing.Graphics.FillRectangle(Brush brush, RectangleF rect)
   在 System.Windows.Forms.DataVisualization.Charting.GdiGraphics.FillRectangle(Brush brush, RectangleF rect)
   在 System.Windows.Forms.DataVisualization.Charting.ChartGraphics.FillRectangleRel(RectangleF rectF, Color backColor, ChartHatchStyle backHatchStyle, String backImage, ChartImageWrapMode backImageWrapMode, Color backImageTransparentColor, ChartImageAlignmentStyle backImageAlign, GradientStyle backGradientStyle, Color backSecondaryColor, Color borderColor, Int32 borderWidth, ChartDashStyle borderDashStyle, Color shadowColor, Int32 shadowOffset, PenAlignment penAlignment, Boolean circular, Int32 circularSectorsCount, Boolean circle3D, BarDrawingStyle barDrawingStyle, Boolean isVertical)
   在 System.Windows.Forms.DataVisualization.Charting.ChartGraphics.FillRectangleRel(RectangleF rectF, Color backColor, ChartHatchStyle backHatchStyle, String backImage, ChartImageWrapMode backImageWrapMode, Color backImageTransparentColor, ChartImageAlignmentStyle backImageAlign, GradientStyle backGradientStyle, Color backSecondaryColor, Color borderColor, Int32 borderWidth, ChartDashStyle borderDashStyle, Color shadowColor, Int32 shadowOffset, PenAlignment penAlignment)
   在 System.Windows.Forms.DataVisualization.Charting.ChartTypes.StockChart.DrawOpenCloseMarks(ChartGraphics graph, ChartArea area, Series ser, DataPoint point, Single xPosition, Single width)
   在 System.Windows.Forms.DataVisualization.Charting.ChartTypes.StockChart.ProcessChartType(Boolean selection, ChartGraphics graph, CommonElements common, ChartArea area, Series seriesToDraw)
   在 System.Windows.Forms.DataVisualization.Charting.ChartTypes.StockChart.Paint(ChartGraphics graph, CommonElements common, ChartArea area, Series seriesToDraw)
   在 System.Windows.Forms.DataVisualization.Charting.ChartArea.Paint(ChartGraphics graph)
   在 System.Windows.Forms.DataVisualization.Charting.ChartPicture.Paint(Graphics graph, Boolean paintTopLevelElementOnly)
   在 System.Windows.Forms.DataVisualization.Charting.Chart.OnPaint(PaintEventArgs e)
   在 System.Windows.Forms.Control.PaintWithErrorHandling(PaintEventArgs e, Int16 layer)
   在 System.Windows.Forms.Control.WmPaint(Message& m)
   在 System.Windows.Forms.Control.WndProc(Message& m)
   在 System.Windows.Forms.NativeWindow.Callback(IntPtr hWnd, Int32 msg, IntPtr wparam, IntPtr lparam)

可以看到,这个报错信息就是弹出框里的那些信息,这些信息比较底层,往更上层用户代码上调用的一些堆栈信息并没有显示。比如没有显示到用户编写的代码行数等内容,这些错误对于调试或者找到问题出处帮助不大。

突破口


这个问题存在了很长的时间。最主要的难点在于,这个bug在极少情况下会出现,并且即使出现,也留不下有用的信息,且在开发环境下非常难以重现。没有办法重现,就没有办法调试,或者说验证是否修复。一个bug,如果能规律的重现,还是有很多种办法来解决的。如果没办法重现,那就比较难解决,那就有时候就要靠运气,或者靠长久的留意(就像有句俗语说的“念念不忘,必有回响”😊),比如我之前 记一次.NET程序内存暴涨分析 这个例子也是。

我们程序里的K线图绘制功能,分为有两个步骤,当用户首次输入某只股票时(之前没打开过),那么程序会去图形服务器获取当天的所有K线数据,获取完成之后,就在本地利用实时接收到行情数据(逐比成交数据)在后面继续绘制。也就是说除了第一次数据是从服务端获取,后面的数据都是在本地计算和绘制的。这个方案既解决了用户在盘中打开K线图查看不到今天历史数据的问题,也减轻了服务端和客户端通讯的消耗。

我在本地继续开发K线绘制功能时,一般会往本地发送历史行情数据。为了调试,就把这个图形服务器的地址故意配置错误,这样就获取不到历史的K线数据,然后本地收到历史的行情数据继续K线的绘制。巧的是,这样设置后,之前极端少见的图形控件报错显示红叉的情况变得异常频繁,这就带来了解决这个问题的有利条件。

因为MSChart给出的错误并没有多少有用的信息,所以在网上找mschart的overflow溢出错误,找到了这个MSChart Unhandled Overflow exception after zooming这个里面指出,在对MSChart进行缩放的时候,如果值设置的过小,就会报错

chartarea.AxisX.ScaleView.Zoom(chart.CursorX.SelectionStart, chartarea.CursorX.SelectionStart + 0.00000001); // crash
chartarea.AxisX.ScaleView.Zoom(chart.CursorX.SelectionStart, chartarea.CursorX.SelectionStart + 0.0000001);  // no crash

可以设置解决上述问题:

chartarea.AxisX.ScaleView.MinSize = 0.0001; // something bigger than 0.0000001 works for me

所以问题就出在ScaleView的Zoom方法这里,于是在代码中搜索所有的Zoom方法,在这些方法的上面添加try catch,发现即使绘图界面出现red cross,代码也根本不会触发catch。

解决方法


由于try catch不住,所以在相关的方法周围打印一些变量记录下来(LogInfo):

private void SetZoom()
{
    double max, min;
    int start = 0;
    if (!double.IsNaN(PriceArea.AxisX.ScaleView.Position))
    {
        start = (int)PriceArea.AxisX.ScaleView.Position;
    }
    int len = (int)PriceArea.AxisX.Maximum;
    if (!double.IsNaN(PriceArea.AxisX.ScaleView.Size))
    {
        len = (int)PriceArea.AxisX.ScaleView.Size;
    }
    if (FindMaxMin(PriceSeries, start, len, out max, out min))
    {
        if (null != linkedSymbol)
        {
            double linkmax, linkmin;
            FindMaxMin(LinkSymbolPriceSeries, start, len, out linkmax, out linkmin);
            max = Math.Max(max, linkmax);
            min = Math.Min(min, linkmin);
        }
        if (max > 0 && min > 0 && max >= min)
        {
            max *= 1.003;
            min *= 0.997;
            Logger.LogInfo($"{FullCode} AxisY_1 max:{max},min:{min},AxisY.Maximum:{PriceArea.AxisY.Maximum},AxisY.Minimum:{PriceArea.AxisY.Minimum}");
            max = Math.Min(max, PriceArea.AxisY.Maximum);
            min = Math.Max(min, PriceArea.AxisY.Minimum);
            PriceArea.AxisY.ScaleView.Zoom(min, max);
            Logger.LogInfo($"{FullCode} AxisY_1 min:{min},max:{max}");
        }
    }
    if (FindMaxMin(VolumeSeries, start, len, out max, out min))
    {
        if (max > 0)
        {
            max *= 1.003;
            max = Math.Min(max, VolumeArea.AxisY.Maximum);
            VolumeArea.AxisY.ScaleView.Zoom(0, max);
            Logger.LogInfo($"{FullCode} AxisY max:{max}");
        }
    }
}

上面的Logger.LogInfo会记录传到Zoom方法里面的两个值,然后观察异常和正常图像这些数值的差异,发现当Zoom方法里start和end相等的时候,会出现异常。

问题就在于min和max,在当PriceArea.AxisY.Maximum和PriceArea.AxisY.Minimum值相等是,会导致min和max的值相等,然后传到Zoom里,在某些情况下就会报错,所以解决方法就很简单。首先,在初始化PriceArea对象的时候,限制Y轴缩放的最小值,这是最根本的解决方法:

PriceArea.AxisY.ScaleView.MinSize = 0.0001;

然后在上述查找min和max的时候,添加限制,防止min和max相等,当然下面这不能一定保证不出问题,彻底的解决办法其实只需要上面这一句设置即可。

if (max > 0 && min > 0 && max >= min)
{
    max *= 1.003;
    min *= 0.997;
    if (PriceArea.AxisY.Maximum > PriceArea.AxisY.Minimum)
    {
        max = Math.Min(max, PriceArea.AxisY.Maximum);
        min = Math.Max(min, PriceArea.AxisY.Minimum);
    }
    PriceArea.AxisY.ScaleView.Zoom(min, max);
}

可能原因


Zoom的方法,可以看到其源代码:

/// <summary>
/// Sets a new axis data view/position based on the specified start and end values.
/// </summary>
/// <param name="viewStart">New start position for the axis scale view.</param>
/// <param name="viewEnd">New end position for the axis scale view.</param>
public void Zoom(double viewStart, double viewEnd)
{
    this.Zoom(viewStart, viewEnd - viewStart, DateTimeIntervalType.Number, false, false);
}

/// <summary>
/// Internal helper zooming method.
/// </summary>
/// <param name="viewPosition">New data scaleView start posiion.</param>
/// <param name="viewSize">New data scaleView size.</param>
/// <param name="viewSizeType">New data scaleView size units type.</param>
/// <param name="fireChangeEvents">Fire scaleView position events from this method.</param>
/// <param name="saveState">Indicates that current scaleView size/position must be save, so it can be restored later.</param>
/// <returns>True if zoom operation was made.</returns>
internal bool Zoom(
    double viewPosition,
    double viewSize,
    DateTimeIntervalType viewSizeType,
    bool fireChangeEvents,
    bool saveState)
{
    // Validate new scaleView position and size
    ValidateViewPositionSize(ref viewPosition, ref viewSize, ref viewSizeType);

    // Fire scaleView position/size changing events
    ViewEventArgs arguments = new ViewEventArgs(this.axis, viewPosition, viewSize, viewSizeType);
    if (fireChangeEvents && GetChartObject() != null)
    {
        GetChartObject().OnAxisViewChanging(arguments);
        viewPosition = arguments.NewPosition;
        viewSize = arguments.NewSize;
        viewSizeType = arguments.NewSizeType;
    }

    // Check if data scaleView position and size is different from current
    if (viewPosition == this.Position &&
        viewSize == this.Size &&
        viewSizeType == this.SizeType)
    {
        return false;
    }

    // Save current data scaleView state, so it can be restored
    if (saveState)
    {
        SaveDataViewState();
    }

    // Change scaleView position/size
    this._ignoreValidation = true;
    this.Position = viewPosition;
    this.Size = viewSize;
    this.SizeType = viewSizeType;
    this._ignoreValidation = false;

    // Reset current scrolling line size
    this._currentSmallScrollSize = double.NaN;

    // Invalidate chart
    axis.Invalidate();

    // Fire scaleView position/size changed events
    if (fireChangeEvents && GetChartObject() != null)
    {
        GetChartObject().OnAxisViewChanged(arguments);
    }

    return true;
}

可以看到,当ViewStart和ViewEnd相等时,会导致ViewSize为0,在某些情况下,会导致报错,当设置MinSize大于某个值,或者让ViewStart和ViewEnd不相等,可能能够解决问题。

 

参考: