最近在工作中需要做一个小工具给其他部门的同事使用,遂决定用WinForms,.NET5.0平台框架来开发,由于好久没用WinForms了,于是就简单了解了下.net framework和.net core平台下WinForms的区别,发现还是有不少区别的,印象最深的就是.net core下可以将运行环境和程序一起打包发布,不需要在额外安装运行时环境支持的,但.net core 下也取消了不少.net framework下的功能,比如不支持BeginInvoke。
在做这个小工具的时候碰到了不少问题,主要都是围绕子线程更新主线程UI这个问题展开的,在这里记录下碰到问题的过程和解决的方法,好让自己以后遗忘时可以再回顾下,也给碰到相关问题的朋友一点帮助。如果有错误不当的地方还望指正。
项目开发环境:Visual Studio 2019 , .NET5.0 , WinForms
该项目使用MVVM模式开发,先上简单代码
// MainFormViewModel.cs
public partial class MainFormViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private string _log1;
private string _log2;
public string Log1{
get { return _log1; }
set {
if (_log1 != value) {
_log1 = value;
NotifyPropertyChanged();
}
}
}
public string Log2{
get { return _log2; }
set{
if (_log2 != value){
_log2 = value;
NotifyPropertyChanged();
}
}
}
private void NotifyPropertyChanged([CallerMemberName] string propertyName = ""){
if(PropertyChanged!=null)PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
// MainFormViewModel.Action.cs
public partial class MainFormViewModel
{
public void ShowLog1() {
SetLog1("log1 info");
}
public void ShowLog2() {
SetLog2("log2 info");
}
private void SetLog1(string message) {
message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
this.Log1 = message + this.Log1;
}
private void SetLog2(string message) {
message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
this.Log2 = message + this.Log2;
}
}
// MainForm.cs
public partial class MainForm : Form
{
private MainFormViewModel _mainFormViewModel;
public MainForm(){
InitializeComponent();
Init();
}
private void Init() {
_mainFormViewModel = new MainFormViewModel();
//进行数据绑定
this.textBox_Log1.DataBindings.Add(new Binding(nameof(textBox_Log1.Text), _mainFormViewModel, nameof(_mainFormViewModel.Log1))) ;
this.textBox_Log2.DataBindings.Add(new Binding(nameof(textBox_Log2.Text), _mainFormViewModel, nameof(_mainFormViewModel.Log2)));
}
// button 写log1
private void button_log1_Click(object sender, EventArgs e){
_mainFormViewModel.ShowLog1();
}
// button 写log2
private void button_log2_Click(object sender, EventArgs e){
_mainFormViewModel.ShowLog2();
}
}
这是界面设计
点击按钮“写Log1”和“写Log2”则对应TextBox会显示对应的内容
到此,一个简单的MVVM模式搭建完成,下一步,我有一个很耗时的任务,我需要新开一个线程来执行,并在结束后输出log日志,修改代码
// MainFormViewModel.Action.cs
// 使用Task.Run()执行耗时任务,并在执行完输入日志(在执行任务线程中)
public partial class MainFormViewModel
{
public void ShowLog1() {
Task.Run(()=> {
//模拟耗时任务
System.Threading.Thread.Sleep(2000);
//任务完成,输入日志
SetLog1("log1 任务完成");
});
}
public void ShowLog2() {
SetLog2("log2 info");
}
private void SetLog1(string message) {
message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
this.Log1 = message + this.Log1;
}
private void SetLog2(string message) {
message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
this.Log2 = message + this.Log2;
}
}
编译执行代码,不出所料报如下错误:
想了下,既然用了Task,那就用下异步编程吧async ,await。修改代码
public partial class MainFormViewModel
{
public async void ShowLog1() {
SetLog1("log1 开始任务");
await Task.Run(()=> {
//模拟耗时任务
System.Threading.Thread.Sleep(2000);
});
//任务完成,输入日志
SetLog1("log1 任务完成");
}
public void ShowLog2() {
SetLog2("log2 info");
}
private void SetLog1(string message) {
message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
this.Log1 = message + this.Log1;
}
private void SetLog2(string message) {
message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
this.Log2 = message + this.Log2;
}
}
编译执行,成功在异步任务执行完之后写入log
但这个必须在异步任务完全结束后才能执行,那么现在我需要再异步任务执行过程中就需要输出一些日志,比如执行进度百分比等等信息了?
修改代码
public partial class MainFormViewModel
{
public async void ShowLog1() {
SetLog1("log1 开始任务");
await Task.Run(()=> {
int i = 0;
while (i++ < 5) {
//模拟耗时任务
System.Threading.Thread.Sleep(2000);
//需要在此输出执行进度
//代码该如何写? SetLog1($"log1 任务进度{i}/5");
}
});
//任务完成,输入日志
SetLog1("log1 任务完成");
}
private void SetLog1(string message) {
message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
this.Log1 = message + this.Log1;
}
//省略其他不相关方法
}
翻阅了下微软的文档(https://docs.microsoft.com/zh-cn/dotnet/desktop/winforms/controls/how-to-make-thread-safe-calls?view=netdesktop-6.0),此处说有两种方式在工作线程调用UI线程的控件,我们尝试第一种,将System.Windows.Forms.Control引入到该类中
修改代码如下
// MainFormViewModel.cs
public partial class MainFormViewModel : INotifyPropertyChanged
{
private System.Windows.Forms.Control _uiControl;
public MainFormViewModel(System.Windows.Forms.Control uiControl) {
_uiControl = uiControl;
}
}
// MainForm.cs
private void Init() {
_mainFormViewModel = new MainFormViewModel(this);
// 其他代码省略
}
// MainFormViewModel.Action.cs
public async void ShowLog1() {
SetLog1("log1 开始任务");
await Task.Run(()=> {
int i = 0;
while (i++ < 5) {
//模拟耗时任务
System.Threading.Thread.Sleep(2000);
//需要在此输出执行进度
_uiControl.Invoke((Action)delegate
{
SetLog1($"{i}/5");
});
}
});
//任务完成,输入日志
SetLog1("log1 任务完成");
}
编译代码执行
可以看到日志顺利打印出来,但是在ViewModel中依赖System.Windows.Forms.Control并不是一个好的方法,所以我们改变一下,修改代码如下
//MainFormViewModel.cs
public partial class MainFormViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private readonly Action<Action> _uiInvoker;
public MainFormViewModel(Action<Action> uiInvoker) {
_uiInvoker = uiInvoker;
}
private string _log1;
private string _log2;
public string Log1{
get { return _log1; }
set {
if (_log1 != value) {
_log1 = value;
_uiInvoker(()=> NotifyPropertyChanged()) ;
}
}
}
public string Log2{
get { return _log2; }
set{
if (_log2 != value){
_log2 = value;
_uiInvoker(() => NotifyPropertyChanged());
}
}
}
private void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
{
if(PropertyChanged != null)PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
// MainFormViewModel.Action.cs
public async void ShowLog1() {
SetLog1("log1 开始任务");
await Task.Run(()=> {
int i = 0;
while (i++ < 5) {
//模拟耗时任务
System.Threading.Thread.Sleep(2000);
//需要在此输出执行进度
SetLog1($"{i}/5");
}
});
//任务完成,输入日志
SetLog1("log1 任务完成");
}
// MainForm.cs
private void Init() {
_mainFormViewModel = new MainFormViewModel(a=> this.Invoke(a));
//其他代码省略...
}
编译代码执行
到这里,在工作线程更新UI线程控件的功能就完成了,但是又遇到了下一个问题,当有两个工作线程在执行耗时任务,并更新UI线程的控件,却发生了界面卡死的问题,代码如下(在这里先注释了几行代码,下面会说为什么)
// MainFormViewModel.Action.ca
public async void ShowLog1() {
//SetLog1("log1 开始任务"); //先注释掉工作线程之外的UI更新,具体原因下面会解释
await Task.Run(()=> {
int i = 0;
while (i++ < 10000) {
//需要在此输出执行进度
SetLog1($"{i}/5");
}
});
//任务完成,输入日志
//SetLog1("log1 任务完成"); //先注释掉工作线程之外的UI更新,具体原因下面会解释
}
public async void ShowLog2() {
//SetLog2("log2 开始任务"); //先注释掉工作线程之外的UI更新,具体原因下面会解释
await Task.Run(() => {
int i = 0;
while (i++ < 10000){
//需要在此输出执行进度
SetLog2($"{i}/5");
}
});
//任务完成,输入日志
//SetLog2("log2 任务完成"); //先注释掉工作线程之外的UI更新,具体原因下面会解释
}
这里为什么会发生界面卡死,我用vs分析了好久,由于能力有限没有找到最明显最有利的证据来证明问题所在,但经过分析后可以确定问题点是在这个地方
// MainForm.cs
private void Init() {
_mainFormViewModel = new MainFormViewModel(a=> this.Invoke(a)); //问题就在 this.Invoke(a)
//其他代码省略...
}
查了下微软文档Control.Invoke()是线程安全的,没明白为什么会有这个问题,但大概知道了是由两个工作线程同时访问所造成的,于是修改代码
// MainForm.cs
private static object lockObj = new object();
private void Init() {
_mainFormViewModel = new MainFormViewModel(a=> {
lock(lockObj ){
this.Invoke(a)
}
});
//其他代码省略...
}
编译运行代码,可以正常运行程序,所以此处大胆猜测问题根源:工作线程1将委托Invoke到UI线程执行,并阻塞等待结果,而此时工作线程2也将委托Invoke到UI线程执行,并阻塞等待结果,而此时在哪里发生了死锁(还请明白真相的朋友不吝赐教)。
到这里就要说下上面的代码为什么有几行要先注释掉,我们将注释掉的代码放开,编译运行,发现程序依然会死锁,这是为什么了? 此处只比之前的代码多了几行在UI线程更新控件的方法。所以大胆猜测原因:工作线程1将委托Invoke到UI线程执行,并阻塞等待结果,而此时点击另一个按钮运行另个一方法,先执行的是UI线程的操作,在UI线程也调用了Invoke,从而导致了死锁,于是决定修改代码,工作线程走Invoke,UI线程的直接运行
// MainForm.cs
private static object lockObj = new object();
private void Init() {
_mainFormViewModel = new MainFormViewModel(a=> {
lock (lockObj){
if (this.InvokeRequired){ //判断是否需要invoke执行,只有在工作线程才需要,
this.Invoke(a);
}
else{ //在UI线程可以执行操作
a();
}
}
});
//其他代码省略...
}
编译运行,发现程序依然死锁,经测试发现应该是this.InvokeRequired并不是原子操作,导致UI线程到这里也返回true,依然执行的是Invoke,(后来想想这里可能分析的不对,这里的死锁可能是由lock引起的,工作线程到此获得锁,并invoke到UI线程执行并阻塞等待返回,而此时另一个操作是在UI线程执行到此发现被加锁,于是阻塞等待锁的释放,结果形成了死锁。)再修改代码,采用双判断
// MainForm.cs
private static object lockObj = new object();
private void Init() {
_mainFormViewModel = new MainFormViewModel(a=> {
if(this.InvokeRequired){
lock (lockObj){
if (this.InvokeRequired){ //判断是否需要invoke执行,只有在工作线程才需要,
this.Invoke(a);
}else{ //在UI线程可以执行操作
a();
}
}
}else{
a();
}
});
//其他代码省略...
}
编译运行,程序运行正常,满心欢喜,感觉到这终于结束了,于是关闭程序,不成想又报错了,不能访问已释放的对象
这个问题应该是工作线程没有及时释放调用了已释放资源的原因,修改代码如下
// MainFormViewModel.Action.cs
public partial class MainFormViewModel : IDisposable
{
public CancellationTokenSource cts = new CancellationTokenSource();
public async void ShowLog1() {
SetLog1("log1 开始任务");
await Task.Run(()=> {
try{
int i = 0;
while (i++ < 10000){
cts.Token.ThrowIfCancellationRequested();
SetLog1($"{i}/5");
}
}
catch (Exception err) { }
},cts.Token);
//任务完成,输入日志
SetLog1("log1 任务完成");
}
public async void ShowLog2() {
SetLog2("log2 开始任务");
await Task.Run(() => {
try{
int i = 0;
while (i++ < 10000){
cts.Token.ThrowIfCancellationRequested();
SetLog2($"{i}/5");
}
}
catch (Exception err) { }
},cts.Token);
//任务完成,输入日志
SetLog2("log2 任务完成");
}
public void Dispose(){
cts.Cancel();
}
//忽略一些没有修改的代码
}
编译运行,这次总算是运行正常,关闭正常,一切正常了。。。
补:不过在之前的调试时,关闭窗口有报此异常
只需增加一行代码即可解决,代码如下
// MainForm.cs
_mainFormViewModel = new MainFormViewModel(a=> {
if (!this.IsHandleCreated) return; //增加此行代码
if (this.InvokeRequired){
lock (lockObj){
if (this.InvokeRequired){
this.Invoke(a);
}else{
a();
}
}
}else {
a();
}
});
总结:虽然问题解决了,但是其中还有好多问题没有彻底弄明白,还须更进一步!