一、 问题描述
当一个Form非常复杂,里面的控件嵌套层次很深时,我们发现在改变Form大小的时候,处于最内层的控件会绘制错误。当我们设置了相应Layout之后,通常内层的控件在外层控件的大小改变时应该也随之改变。当问题出现时,我们期待的内层控件没有变化。
二、 问题重现
1. 新建一个Winforms工程;
2. 在Form上添加一个Button,一个Label和一个Panel;
3. 在把panel1的Anchor属性设为Top|Bottom|Left|Right;
4. 在类Form1中添加如下代码:
public Form1()
{
InitializeComponent();
UpdateLevel();
}
int level = 1;
private void UpdateLevel()
{
label1.Text = "Level: " + level.ToString();
}
Panel lastPanel;
private void button1_Click(object sender, EventArgs e)
{
Panel panel = new Panel();
Random random = new Random((int)DateTime.Now.ToBinary());
panel.BackColor = Color.FromArgb(random.Next(256), random.Next(256), random.Next(256));
panel.Padding = new Padding(5, 5, 5, 5);
panel.Location = new Point(5, 5);
panel.Size = new Size(Math.Max(lastPanel.Width - 10, 0), Math.Max(lastPanel.Height - 10, 0));
panel.Dock = DockStyle.Fill;
lastPanel.Controls.Add(panel);
lastPanel = panel;
level++;
UpdateLevel();
}
上述代码主要功能是嵌套添加Panel;
5. 编译运行;
6. 反复点击Button,同时改变Form的大小,观察绘制的结果。当嵌套的深度到达一定程度时(我的电脑是28),最内层的Panel绘制出现问题。
三、 原因分析
当我们改变Form的大小的时候,会调用Form的OnLayout方法,在Form的OnLayout的方法里,会调用最外层控件的OnLayout的方法,外层控件的OnLayout函数会调用内层OnLayout方法。因此,Control.OnLayout是一个递归调用的函数。当我们控件嵌套层次太深的时候,这个调用栈会很深。
在OnLayout里,我们会调用Windows一个API:SetWindowPos。通常情况下,我们调用SetWindowPos去改变一个Windows窗口的位置和大小的时候,Windows会给该窗口发送WM_WINDOWPOSCHANGED。Winforms在该消息的处理函数里,会去调整窗口的大小并重新绘制。
当调用栈超过一定限度的时候,我们发现Windows并没有向窗口发送消息WM_WINDOWPOSCHANGED,于是Winforms就不能在其处理函数里图调整窗口的大小并重新绘制。由于消息是由Windows发送出来的,我们站在Winforms的角度不能知道该消息没有发出来的真正原因。
四、 解决办法
由于问题的真正原因是Windows没有发出WM_WINDOWPOSCHANGED消息,因此我们不能从根本上解决这个问题。但我们可以想办法去绕过这个问题。
正如前面分析所提到的,这个问题的出现与调用栈太深有关。如果我们能缩短递归调用栈的深度,也就能绕过这个问题。
因此我们的第一个建议是减少Form上的控件嵌套层次。经过大量实验,我们发现出现这个问题时,在32位操作系统上嵌套层次超过25层,在64位机器上嵌套层次超过15层(不同机器数据略有不同)。当控件的嵌套层次超过15层时,这个Form会非常复杂。因此我们可以考虑简化Form的设计,减少控件的嵌套层次。
如果我们确实需要很深的嵌套层次,我们可以尝试另外一个办法:是用异步调用的办法来减少调用栈的深度。我们在一个函数里异步调用另一个函数时,原函数会马上继续,而不用等待被调用函数返回,因此也就减少了调用栈的深度。
我们可以选择一个控件里面只有少数(最好只有一个)子控件,不设置它子空间的Anchor和Dock属性,而在该控件的Layout事件处理器里自己处理子控件的布局,也就是显式调整子控件的位置和大小。由于我们需要用异步调用的方式去缩短栈的深度,因此我们可以用Control.BeginInvoke来设置Control.Size。下面是一段参考代码:
1. 添加如下代码:
private delegate void SetControlSizeDelegate(Control control, Size size);
private void SetControlSize(Control control, Size size)
{
control.Size = size;
}
private void panel_Layout(object sender, LayoutEventArgs e)
{
Panel panel = sender as Panel;
if (panel != null && panel.IsHandleCreated && panel.Controls.Count == 1)
{
Control control = panel.Controls[0];
Size size = new Size(Math.Max(panel.Width - control.Padding.Left - control.Padding.Right, 0),
Math.Max(panel.Height - control.Padding.Top - control.Padding.Bottom, 0));
SetControlSizeDelegate myDelegate = new SetControlSizeDelegate(SetControlSize);
this.BeginInvoke(myDelegate, new object[] { control, size });
}
}
这段代码的主要功能就是在Layout的事件处理器中用BeginInvoke异步修改子控件的大小。
2. 在Form1的构造函数里添加代码:
panel1.Layout += panel_Layout;
3. 在button1_Click里删除对Panel的Dock设置。我们将在Panel的Layout事件处理器里调整子控件的布局
4. 在button1_Click里添加代码:
panel.Layout += panel_Layout;