UI长时间更新造成界面假死
有个例子,点击按钮就加载10000个数据到RichTextBox上
private void button1_Click(object sender, EventArgs e)
{
for(int i = 0; i < 10000; i++)
{
richTextBox.AppendText(i.ToString() + "\n");
}
}
上面的代码编译没有任何问题,运行后点击button,然后就发现界面卡死了。可是为什么会卡死呢?这个就要先看看消息机制了。我们点击按钮,我们移动窗体,其实都是在向系统发送消息。点击按钮就发送点击按钮的消息,移动窗体就会发送移动窗体的消息。在win32编程的时候都会有下面这段代码
while(GetMessage(&msg,NULL,0,0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
这个意思就是说GetMessage不断从消息队列中取出消息,然后将得到的消息转换成系统可识别的方式,然后将消息分发给服务函数,例如,我们点击按钮就会发送点击消息,然后通过循环查询GetMessage得到点击消息,然后TranslateMessage将点击事件的消息转换成系统识别的代码,最后DispatchMessage分发到服务函数,但是服务函数在哪里呢?就是下面这段程序
LRESULT CALLBACK WndProc(HWND hwnd,UINT message,WPARAM wParam,LPARAM lParam)
{
switch(message)
{
case WM_CREATE:
//处理窗体创建事件
return 0;
case WM_PAINT:
//处理窗体绘制事件
return 0;
//更多事件,比如按钮点击等
}
}
我们的服务函数就是写在这个switch中,看着可是真够丑陋的,我们在C#中编程只要添加事件就可以了,而且函数也是VS给我们直接生成的,但是在WIN32中却要这种方式,虽然复杂,但是我们对原理更加了解了。其实C#也是这样的,只是C#中都已经给我们封装好了,我们看着才会觉得C#的编程好像更简单。既然了解了C#的消息分发机制,我们就好解释为什么界面会卡死了
我们发现原来事件服务函数是在UI线程中执行的,那么如果服务函数中有一个耗时的操作,自然就会阻塞UI线程中其他的操作。比如在鼠标点击事件中有一个耗时操作,那么UI线程中下一个移动窗体的操作就要等到鼠标点击事件执行完才可以继续执行,然后一次循环执行后面的操作(点击,移动,重绘。。。。。窗体上的操作都是在UI线程中运行的)。
界面卡死可是一个很不好的事情,那么该如何解决呢,第一个想到就是多线程,在其他线程中完成更新界面,UI线程继续响应操作。当时这么想觉得很对,可是一分析,根本不是这么回事。
来看看下面这段代码
private void button1_Click(object sender, EventArgs e)
{
new Thread(new ThreadStart(() =>
{
for (int i = 0; i < 10000; i++)
{
richTextBox.AppendText(i.ToString() + "\n");
}
})).Start();
}
我们运行,然后就会抛出异常。
从不是创建控件richTextBox的线程中访问它。这到底是什么意思?创建richTextBox的线程毫无疑问是UI线程,但是我们在新线程中调用了UI线程创建的控件,所以就会造成线程间的操作。那么该怎么办呢?看看下面这段
private void button1_Click(object sender, EventArgs e)
{
new Thread(new ThreadStart(() =>
{
for (int i = 0; i < 10000; i++)
{
//无参数,但是返回值为bool类型
this.Invoke((Action)(() =>
{
richTextBox.AppendText(i.ToString() + "\n");
}));
}
})).Start();
}
使用this.Invoke的方式来解决这问题。this毫无疑问是指创建的窗体,那么Invoke呢?我觉得这里的意思是线程间的切换,界面的更新都是在UI线程中执行的,即通过Invoke将新线程切换到UI线程,然后执行界面更新的操作,之后再切换回来,继续新线程。这样就不会存在跨线程操作了。上面的代码的确不会影响界面的响应,可是我却产生了一个疑问,明明最后的界面更新还是在UI线程中的,为什么多线程中循环添加就不会阻塞界面(明明最后还是切换到了UI线程,这不就相当于直接在UI线程中循环添加吗)呢?后来我想了想觉得这种方式根本不能解决本质问题,因为所有更新肯定是在UI线程中的,但是通过多线程却在两次添加中插入了时间片,这样我们就可以在执行其他线程时我们就可以对窗体进行操作,相当于把更新界面整片时间切割成一小块一小块,比如,如果我们更新界面要10s,如果一次更新我们就要等10s才能继续操作,但是如果我们把10s的时间分成10000份,每次更新就花10ms,这样我们就看不出界面卡死了,但是这样更新界面会话更多的时间,因为别的线程会占用两次更新操作间的时间片。
虽然花费了时间,但是用户体验很好,其实直接一个新线程还是会一卡一卡的,看看下面的
private void button1_Click(object sender, EventArgs e)
{
new Thread(new ThreadStart(() =>
{
for (int i = 0; i < 10000; i++)
{
Thread.Sleep(1000);
this.Invoke((Action)(() =>
{
richTextBox.AppendText(i.ToString() + "\n");
}));
}
})).Start();
}
哈哈,更新完全成了龟速,但是窗体操作一点压力都没有了。
长时间等待造成界面假死
我们经常会长时间等待一个响应,然后等待返回结果,比如我们访问网站,当网络阻塞的时候就会要等很久才行。下面来模拟一下,我们要在长时间等待后将返回的结果添加到richTextBox,长时间等待用延时来实现
public string LongTimeWait()
{
Thread.Sleep(10 * 1000);
return "abc";
}
private void button1_Click(object sender, EventArgs e)
{
string result = LongTimeWait();
richTextBox.AppendText(result);
}
上面的代码一看就知道界面会卡死,但是我们又不能不等待?这里我们可以用异步回调。先说一下什么事异步。比如我们打电话,A给B打电话让B帮忙办点事情,A可以一直监听电话知道B将事情办完,也可以挂了电话,让B在办完事情后给自己打电话,告诉自己办完了。当然,只要是正常的人都会选择这么做,因为这样我们就可以忙自己的事情了,而不用等待。
道理我们都懂了,那么该如何实现呢?这里我们可以用委托来实现异步回调,在委托中有delegate.BeginInvoke和delegate.EndInvoke,这两个函数就是用来实现异步的。当我们调用delegate.BeginInvoke其实就是在线程池中取出一条线程去监控等待工作,然后等执行完之后返回结果,这是我们可以调用delegate.EndInvoke来获得返回的结果,并且执行之后的操作。
public string LongTimeWait()
{
Thread.Sleep(1000);
return "abc";
}
public void GetResult(IAsyncResult result)
{
AsyncResult ar = result as AsyncResult;
Func<string> wait = ar.AsyncDelegate as Func<string>;
string rst = wait.EndInvoke(result);
this.Invoke((Action)(() => richTextBox.AppendText(rst)));
}
private void button1_Click(object sender, EventArgs e)
{
Func<string> wait = LongTimeWait;
wait.BeginInvoke(new AsyncCallback(GetResult), null);
}
简化一下
private void button1_Click(object sender, EventArgs e)
{
Func<string> wait = ()=> {
Thread.Sleep(1000);
return "abc";
};
wait.BeginInvoke(new AsyncCallback(result=>
{
string rst = wait.EndInvoke(result);
this.Invoke((Action)(() => richTextBox.AppendText(rst)));
}), null);
}