WPF中Dispatcher的初步探讨
今天要专门讲一下Dispatcher,原因是WPF中经常碰到多线程下软件界面控件的更新问题。相信很多初步接触WPF的界面开发的朋友,为了保持界面不卡,在一个自己创建的线程中去更新或者读取一个控件时都会遇到了一个很奇怪的Exception异常,显示如下: 这个异常是告诉我们,不好意思您非法操作了。 这个问题我个人认为估计99.9%的人都碰到过。因此,很多人觉得微软的WPF真不好用,就简单更新下界面咋就这么费劲,那怕仅仅是读取下TexBox的Text属性就立马崩溃。个人也觉得这是微软底层架构的问题导致使用当中不太方便,但是架构咱们改变不了,只能硬着头皮学习怎么用。 相信很多人都也都通过无所不能的度娘找到解决办法了,就是加一段类似下面的语句:this.Dispatcher.Invoke(()=> { // 你的访问空间或者改变控件代码}); 加了上面的this.Dispatcher.Invoke()就解决了异常问题,问题解决了,解决的办法是完全正确无误的,但总感觉硌得慌,干嘛这么费劲? 我也觉得不直接、可读性差!!!但咱们也没办法,因为微软就是这么任性。 好了,言归正传,绕了这么一圈,我们就需要回过头来看看Dispatcher是个啥玩意? 先简单说几句啰嗦的话,希望能够了解,能大概记住是最好的: 1)官方说,WPF一般来说启动后会有两个线程,一个是责呈现,一个负责UI界面管理。官方对什么是负责呈现和什么是负责UI界面管理简单的介绍了下,在此不做赘述。不过需要记住的就是UI界面管理这个线程; 2)负责UI界面管理的线程,我们就简称为UI线程。UI线程内有个Dispatcher对象。Dispatcher对象内则包含这个UI线程的众多工作内容(官方叫work item)的队列。UI线程就是靠Dispatcher负责控件相关的这些事件的处理。废话那么多,直接来个图比较粗暴: 3)只有创建了UI控件的UI Thread才有权限控制控件的访问和更新!!! 4)其他线程(非直接创建你要访问和控制UI控件的线程)要访问和更新某个控件,必须通过创建这个控件的线程(一般就是UI线程)所关联的Dispatcher来访问和更新这个控件。这也是为什么经常会有this.Dispatcher.Invoke()的原因。 说这么多做一些试验来验证以上的理论知识。 先做一个下面这样子的WPF界面:XAML非常简单,代码如下: <Grid> <Button VerticalAlignment="Center" HorizontalAlignment="Center" Width="100" Name="myBtn">Click Me</Button> </Grid>C#后台代码如下: public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); Thread trd = new Thread(myFun);// 创建一个新线程 trd.Start();// 启动一个新线程 } public void myFun() { myBtn.Content = "My Love"; } } 运行,你会发现在新线程运行myFun()的时候出现InvalidOperationException,确实到了新线程里直接更新控件会出错。 因此,我们myFun将代码改为: public void myFun() { this.Dispatcher.Invoke(()=> { myBtn.Content = "My Love"; }); }重新运行,线程正确无误的修改了Button控件上的信息。 需要注意的是系统在MainWinow起来之后就给当前UI线程分配好了Dispatcher,这个Dispatcher属于MainWindow这个实例的,原因在于MainWindow继承自DispatcherObject类,而DispatcherObject包含了一个公共属性Dispatcher。实际上不仅仅是Window类,其他控件也都继承自DispatcherObject,因此他们在初始化时都自动赋值了Dispatcher属性,并且都指向同一个UI线程所拥有的Dispatcher对象。对于当前这例子,MainWindow和Button都被UI线程创建,并且MainWindow和Button的Dispatcher属性都指向UI线程拥有的Dispatcher,因此,我们还可以将上面的例子改为下面的方式(用myBtn.Dispatcher.Invoke()来实现按钮的更新,运行结果完全一样): public void myFun() { myBtn.Dispatcher.Invoke(()=> { myBtn.Content = "My Love"; }); } 从代码上看起来,myFun函数内访问this.Dispatcher貌似访问的是运行myFun线程中的Dispatcher,看起来是有点古怪,不过你只要知道this指的是MainWindow实例对象,那么MainWindow这个类实例对象的Dispatcher是UI线程拥有的对象,因此没有错误,也就不古怪了。 假如整个代码改为下面的方式: public MainWindow() { InitializeComponent(); myFun(); } public void myFun() { myBtn.Content = "My Love"; } 我们发现,运行也没问题,没有任何古怪的地方,加不加this.Dispatcher.Invoke()都可以运行ok。这是由于运行myFunc的环境是在UI线程之下。为了更容易发现线程的变换,我们加入更多的测试代码,整个工程改为如下: public MainWindow() { InitializeComponent(); Thread.CurrentThread.Name = "Main Thread";// 设置当前线程名称 Thread trd = new Thread(myFun); // 创建一个新线程 trd.Name = "New Thread"; trd.Start(); // 启动一个新线程 } public void myFun() { this.Dispatcher.Invoke(() => { myBtn.Content = Thread.CurrentThread.Name;// 将当前线程名称输出到Button上 }); } 我们发现,代码运行到myFun()时的线程已经变成了trd所创建的线程(通过Thread.CurrentThread.Name来获知当前线程名称是个好办法),新线程的名称也成功输出到按钮上: 由此,我们应当树立一个观点: 同一个类下的方法根据你调用的方式不同,并不一定都运行于同一个线程下。即使调用其他类的函数,也可能存在两种情况,要么运行在一个线程里,要么运行在不同的线程里。实际上是否是一个线程里完全跟如何调度相关,跟是否属于哪个类没有任何关系。 如果实在不清楚某个线程下是否可以直接更新或访问控件,一方面你可以一股脑的都加上this.Dispatcher.Invoke()(实际上除了这个方法,还有BeginInvoke方法),另一方面可以通过控件的CheckAccess()方法或者VerifyAccess()方法来判断该控件是否允许在当前线程下被访问被更新。因此,myFun函数可以改为下面的形式: public void myFun() { // myBtn.VerifyAccess(); //该方法在不可访问的情况下,直接抛出InvalidOperationException异常 if(!myBtn.CheckAccess()) { this.Dispatcher.Invoke(() => { myBtn.Content = Thread.CurrentThread.Name;// 将当前线程名称输出到Button上 }); } else { myBtn.Content = Thread.CurrentThread.Name;// 将当前线程名称输出到Button上 } } } 有深究精神的童鞋,可能会问,既然某个线程拥有Dispatcher,那么是否可以通过线程直接访问Dispatcher呢?这个很简单,可以直接查找Thread线程类的资料,惊奇的发现,Thread根本不存在一个可以访问自身所拥有的Dispatcher对象的属性或者方法,搞得我也一头雾水,反正有一种"我拥有的还不能直接获得"的莫名其妙感觉。那有没有能获得的办法了呢?答案是肯定的。 可以通过Dispatcher类本身的static方法FromThread(Thread trd)来获得某个线程所拥有的Dispatcher。下面将c#后台代码改为下面的试验代码: public MainWindow() { InitializeComponent(); Dispatcher dsp = Dispatcher.FromThread(Thread.CurrentThread);//结果:非null bool r = dsp.Equals(this.Dispatcher);//结果:true Thread.CurrentThread.Name = "Main Thread";// 设置当前线程名称 Thread trd = new Thread(myFun); // 创建一个新线程 trd.Name = "New Thread"; trd.Start(); // 启动一个新线程 } public void myFun() { Dispatcher dsp = Dispatcher.FromThread(Thread.CurrentThread);//结果:获得的dsp值为null if (!myBtn.CheckAccess()) { this.Dispatcher.Invoke(() => { myBtn.Content = Thread.CurrentThread.Name;// 将当前线程名称输出到Button上 }); } else { myBtn.Content = Thread.CurrentThread.Name;// 将当前线程名称输出到Button上 } }用debug调试方式,你会发现在MainWindow构造函数下的函数中获得的dsp非空,并且通过Equals()方法发现dsp就是this.Dispatcher。myFun()函数运行的线程下,dsp的结果是空,明显就不等于this.Dispatcher。那么怎么给这个新的线程赋一个Dispatcher?可能我看的资料还没全,至今我还只发现一种办法,并且是一种让人感觉很莫名其妙或者说很隐晦的方法,直接上代码: public MainWindow() { InitializeComponent(); Dispatcher dsp = Dispatcher.FromThread(Thread.CurrentThread);//结果:非null bool r = dsp.Equals(this.Dispatcher);//结果:true Dispatcher newdsp = Dispatcher.CurrentDispatcher; // 获取当前的Dispatcher r = this.Dispatcher.Equals(newdsp); //结果,仍然是true,说明已经有Dispatcher的情况下不会赋新Dispatcher Thread.CurrentThread.Name = "Main Thread";// 设置当前线程名称 Thread trd = new Thread(myFun); // 创建一个新线程 trd.Name = "New Thread"; trd.Start(); // 启动一个新线程 } public void myFun() { Dispatcher dsp = Dispatcher.FromThread(Thread.CurrentThread);//结果:获得的dsp值为null Dispatcher newdsp = Dispatcher.CurrentDispatcher; // 默默的为当前新线程赋了一个Dispatcher dsp = Dispatcher.FromThread(Thread.CurrentThread); // 重新获取当前线程的Dispatcher bool r = newdsp.Equals(dsp);// 结果是true if (!myBtn.CheckAccess()) { this.Dispatcher.Invoke(() => { myBtn.Content = Thread.CurrentThread.Name;// 将当前线程名称输出到Button上 }); } else { myBtn.Content = Thread.CurrentThread.Name;// 将当前线程名称输出到Button上 } } 仔细看上面的代码及注释。 官网透露的资料里,告诉我们既可以通过Dispatcher.CurrentDispatcher获得当前线程的Dispatcher对象,也可以通过访问Dispatcher.CurrentDispatcher(CurrentDispatcher只可读!!!)给一个没有Dispatcher的线程自动赋一个Dispatcher,自动给一个无Dispatcher的线程赋一个Dispatcher对象这个功能显得比较古怪,但是微软就是这么任性。 根据官网资料,一旦一个线程获得了一个Dispatcher,那么这个Dispatcher不可更改,即使被关闭。 不过,个人还不是很清楚给一个不带界面的Thread赋一个Dispatcher的作用,貌似没什么作用。要么就是鄙人还没掌握所有资料。 今天的故事就先讲到这里了。