如何避免界面冻结失去响应

我们开发的程序总是不可避免地会进行一些很耗时的运算,对于有用户界面的程序而言,如何避免界面冻结就成了一个在日常开发中必须要认真考虑的问题。下面就总结一下我平时用到的几种避免界面冻结的方法。
1. 拆分耗时运算,接管消息循环
我们碰到的耗时运算在很多情况下都是可以拆分成一系列运算量可以容忍的小任务的,并且可能由于历史原因,该运算模块会存在多处与界面或者其他模块的依赖,很难甚至无法改写成多线程模式。这种情况下我倾向于拆分耗时运算,在完成每个小任务后强制处理消息队列从而确保界面反应流畅。
以在C# Windows Form中处理一个耗时30秒的任务为例:

 1         private void UpdateMsgInRunningHeavyJob()
 2         {
 3             int jobTime = 30000;
 4             DateTime start = DateTime.Now;
 5             do
 6             {
 7                 // 模拟一系列小任务
 8                 Thread.Sleep(50);
 9                 // 处理消息队列
10                 Application.DoEvents();
11             } while (start.AddMilliseconds(jobTime) > DateTime.Now);
12         }
13 
14         private void button1_Click(object sender, EventArgs e)
15         {
16             button1.Enabled = false;
17             UpdateMsgInRunningHeavyJob();
18             button1.Enabled = true;
19         }

这种方法可以说是渊源流长。早在Windows 95之前,Windows操作系统是“协同多任务”的,根本不支持多线程,如果单个程序占据的时间过长,整个系统就会被冻结。 所以程序员在开发时都必须考虑自身程序对系统的影响,自觉地及时交出“控制权”,让其他程序有被系统"服务"的机会。即使回到十多年前用MFC写界面的时代,我们很多人还是习惯用以下的几行Win32 API来实现一个强制处理消息队列的Update方法,从而在进行耗时运算时能够及时地响应界面操作。

1         void Update()
2         {
3             MSG msg;
4             while(PeekMessage(&msg,(HWND)NULL,0,0,PM_REMOVE))
5             {
6                 TranslateMessage(&msg);
7                 DispatchMessage(&msg);
8             }
9         }

Windows 不断升级,但是基于事件驱动的消息队列依然经典如旧。这个貌似老旧的Update()方法,仍然是.Net Application.DoEvents()的精神内核。

2. 启用子线程进行耗时运算
上文也提到了,不是所有场景下的耗时运算都可以拆分,为了避免拖累主界面线程,创建子线程进行耗时运算势在必行,好在.Net的多线程库超级方便,利用System.Threading.ThreadPool线程池库启动子线程,几行代码就可以搞定:

 1         private void button2_Click(object sender, EventArgs e)
 2         {
 3             button2.Enabled = false;
 4             //启动子线程执行耗时任务
 5             ThreadPool.QueueUserWorkItem(delegate
 6             {
 7                 RunHeavyJob();
 8                 //同步主线程更新界面
 9                 button1.BeginInvoke((Action) delegate
10                 {
11                     button2.Enabled = true;
12                 });
13             });
14         }

这里必须强调的是,子线程不可以直接更新界面控件,任何界面操作都必须调用control.BeginInvoke方法,同步到创建控件的线程也即主线程中进行。
很多人看到这里,可能会觉得子线程里的代码不够“干净”,有什么方法可以避免子线程访问主界面线程的控件呢?问题的关键在于主线程如何才能得知子进程何时结束。使用经典的多线程同步机制可以解决这个问题,但是多线程同步的复杂性却让很多人望而却步。顺着第一种方法的思路,我们不禁会想,插入消息处理函数的方式相当于把一个方法分成了很多块,时不时地交出运行控制权;那利用二分法的思想简化问题,我们能不能用某种方式,把需要等待任务结束的方法一分两半,先执行方法的前一半到开始等待任务时就“退出”交出控制权, 在任务结束后再自动”切回“执行剩下的另一半方法呢? 这种需求如此具有普遍性,如果编程语言或者运行框架能给出一个统一的解决方案就好了。
3. 使用.Net异步Task进行耗时运算
答案是肯定的。很多现代的编程语言都直接支持异步编程模式,上述思路也就是“协程”coroutine的基本思想。.Net framework很早就引入了异步编程库,并且在.Net 4.5中进一步引入了async/await关键字,让基于TPL库(Task Parallel Library)的编程变得更容易更直观。
回到一开始的问题,我们看看如何利用.Net异步Task来克服上一个方法中的“不完美”:

1         private async void button3_Click(object sender, EventArgs e)
2         {
3             button3.Enabled = false;
4             await Task.Run((Action)RunHeavyJob);
5             button3.Enabled = true;
6         }

Oh, my God!撇开第一行插入的async关键字,真正的异步代码就一行!时代在发展,编程思想和框架库也在不断演化,程序员真得终身学习呀!

原文地址:https://www.cnblogs.com/egoechog/p/AvoidFreezingGUI.html