极品分享

C# WPF 使用BeginInvoke开启新线程读取载入数据等耗时操作,避免界面卡顿的方法!

话说接触.net一年有余,发觉身边许多用.net的人都不知道“线程”这回事,他们写的程序都是单线程的,从不考虑把一个耗时较多的操作放到一个工作线程中,所以一旦数据库操作长时间没反应,程序界面也就跟着卡死了……而线程对于有着多年Windows编程经验的我来说,再熟悉不过。

一般来说,程序的界面处理是用一个线程(通常同时作为主线程),而工作线程则可能有好几个,比较理想的情况下据说是跟CPU的个数(现在准确说是跟CPU的核心数)相同,工作线程负责一些比较耗时的处理,如量较大的IO读写操作,工作线程一般不会操作界面元素,如果需要操作,则是通过向界面线程发消息的方式,而不是直接控制界面元素。

我记得在Windows编程(C++)中,并没有一个硬性规定说工作线程一定不能操作界面元素,但我们通常确实不会那么干,因为这样的话实际操作起来会有一些不可预知的问题,如工作线程莫名其妙被卡死,界面失去响应或者不按预期刷新等,所以界面元素的处理(包括绘制和响应用户操作)都由一个线程来做,工作线程还是老老实实“干活”去,别越俎代庖,至于如何把工作的进度“汇报”到界面上去,那就只能通过“打报告”,即发送消息,而且只能用PostMessage,不可用SendMessage,因为SendMessage会阻塞线程等待返回。这不是唯一的做法,但却是最正统的做法。(SendMessage和PostMessage是Windows的两个原生API函数,可用C/C++直接调用)

到了.net(无论是Winform还是WPF),微软用了两个方法对PostMessage进行了封装,分别是Invoke和BeginInvoke,Invoke的行为类似于SendMessage(其实底层上还是用PostMessage来实现,只是调用完之后就直觉开始等待),而BeginInvoke的行为则类似PostMessage。先来看这么一个最简单的例子:界面上有一个进度条progressBarExecuting,有一个按钮buttonExecuteManually,点击一下按钮,进度条前进10,我们这么写:

        public void SetProgress(int iProgress)
        {
            progressBarExecuting.Value += iProgress;
        }        private void buttonExecuteManually_Click(object sender, RoutedEventArgs e)
        {
            SetProgress(10);
        }

BeginInvoke在单线程程序中的用法

上面的代码没有任何问题,但我现在假设SetProgress是个比较耗时的操作,我不希望我对Click事件的处理被卡在这个上面,我希望buttonExecuteManually_Click立即结束,不管SetProgress到底执行如何,这怎么办?这时候虽然没有涉及到多线程,但BeginInvoke就可以派上用场了。

 private delegate void SetProgressMethod(int iProgress);        public void SetProgress(int iProgress)
        {
            Debug.WriteLine("[{0}]SetProgress是个耗时的动作", DateTime.Now.TimeOfDay.TotalSeconds);
            Thread.Sleep(5000);
            progressBarExecuting.Value += iProgress;
            Debug.WriteLine("[{0}]SetProgress结束", DateTime.Now.TimeOfDay.TotalSeconds);
        }        private void buttonExecuteManually_Click(object sender, RoutedEventArgs e)
        {
            Dispatcher.BeginInvoke(new SetProgressMethod(SetProgress), 10);
            Debug.WriteLine("[{0}]buttonExecuteManually_Click结束", DateTime.Now.TimeOfDay.TotalSeconds);
        }

Debug输出结果:

[86295.9374504]buttonExecuteManually_Click结束
[86295.9384504]SetProgress是个耗时的动作
[86300.9394504]SetProgress结束

从这可以看出,Click事件的处理无需等待SetProgress,它直接结束掉了,这个在某些场合特别有用,如在处理一些需要及时返回的鼠标事件的时候,UI编程做多了自然能够体会到这点。

使用Timer更新界面

现在我换一种方式更新进度条,那就是使用Timer,点击按钮激活Timer,并让每100ms,进度条前进2。

 private Timer m_timerTest;        
        private void buttonExecuteByTimer_Click(object sender, RoutedEventArgs e)
        {            if (m_timerTest != null)
            {
                m_timerTest.Dispose();
            }
            progressBarExecuting.Value = 0;
            m_timerTest = new Timer(TestTimerCallback, null, 0, 100);
        }        
        public void SetProgress(int iProgress)
        {
            progressBarExecuting.Value += iProgress;
        }        public void TestTimerCallback(Object state)
        {
            SetProgress(2);
        }

运行,出错了:

22205128-dfa0805c5aa046e69b1faf3673fa650b.png


很显然,Timer并不属于界面线程,如果直接在Timer的线程中处理界面元素的显示,就会出错。另外这是跟标准的Windows编程很不一样的地方,标准的Windows编程,Timer并不是一个线程,而是向系统注册一个Timer之后,由系统定时往线程消息队列中插入WM_TIMER消息来实现的,在.net中改作独立线程的原因我想是因为需要更少的界面干预吧,纯猜测。

那么正确的做法应该是怎样呢?很简单,稍微改一点点代码:

private delegate void SetProgressMethod(int iProgress);        
public void TestTimerCallback(Object state)
{
   Dispatcher.BeginInvoke(new SetProgressMethod(SetProgress), 2);
}

这样就OK了,这是WPF的情况,如果使用的是WinForm,那就调用对应Control的BeginInvoke。那,这里用Invoke行不行?当然行,但通常我们会用BeginInvoke,因为如前面所说,Invoke是阻塞的,其作用没BeginInvoke大。

使用一个独立线程更新界面(WinForm)

其实跟同Timer没什么差别,Timer是线程,线程更是线程,对不?

       private Thread m_threadTest;        private AutoResetEvent m_eventStop = new AutoResetEvent(false); 
        private delegate void SetProgressMethod(int iProgress);        
        public void TestThread()
        {            do
            {                if (m_eventStop.WaitOne(100))                    return;
                Dispatcher.BeginInvoke(new SetProgressMethod(SetProgress), 2);
            } while (true);
        }        private void buttonExecuteByThread_Click(object sender, RoutedEventArgs e)
        {
            progressBarExecuting.Value = 0;
            m_threadTest = new Thread(TestThread);
            m_threadTest.Start();
        }        
        private void Window_Closed(object sender, EventArgs e)
        {
            m_eventStop.Set();
        }

和Timer不同之处是这里用了一个AutoResetEvent,其初始是无信号的,在窗口关闭时候将其变为有信号,这样工作线程会收到这个信号,并“优雅地”return,而不是Terminate。

其它情况

有时候你还会不经意地使用了线程,但并非显式地创建Thread,比如有一次我写了一个监视某个文件夹的程序,当此文件夹的文件发生变化(增加,删除,修改等)时候,我的回调函数就被调用,底层上来看,这也是开一个线程来做的,所以我的回调函数不能直接操作界面元素,必须用BeginInvoke或者Invoke。



实战举例

例如,我在在WPF中,窗口有个DataGrid,在打开该窗体时去查询数据库读取所有数据。并将数据源设置为返回的数据。

此时,当数据量较大时,就有有延迟,是一个耗时操作!如果不开启单独的线程进行这个操作,就会阻塞界面更新主线程,导致看上去界面卡顿了一段

时间后才显示出来。用户体验非常差!

  1. 不使用新线程加载数据时的方法

        //窗口加载
        private void Window_Loaded(object sender, RoutedEventArgs e)
        {
            LoadData();
        }

这里我们就可使用BeginInvoke方法来开启一个新的线程来处理读取数据和更新DataGrid内容,最终的结果就是,当打开窗体时非常流畅顺利的打开和

显示了该窗体,并且DataGrid中内容是空的(因为耗时操作在新线程中还为完成,完成后才异步更新到DataGrid中显示)。


    2.使用BeginInvoke加载数据的方法

        //定义委托载入数据方法
        private delegate void LoadDataMethod();
        //窗口加载
        private void Window_Loaded(object sender, RoutedEventArgs e)
        {
            //开启新线程加载数据使用委托通知更新
            Dispatcher.BeginInvoke(new LoadDataMethod(LoadData));
        }


实际测试,在从SqlServer读取1000条数据(含照片二进制)时,

不使用新线程加载数据时,界面卡顿8秒左右进入窗体显示。

使用BeginInvoke开启新线程委托界面更新加载数据时,界面流畅无卡顿,进入界面DataGrid为空白,耗时3秒异步更新显示数据。

2017-03-28 0 /
NET学习
/
标签: 

评论回复

回到顶部