记录一下今天用IEnumerable解决一个窗体更新的问题。这个技术也应该是Unity来实现Delay的方法吧,印象中Unity的WaitForXX系列延时函数就是返回的IEnumerable,当时还觉得这是个蛇皮操作,没想到,自己在开发中用上了… 这种灵光乍现的感觉还挺好的。
剧透:这篇文章居然又扯到了 多线程 / 协程 / 异步,做前端还真就是离不开这些概念。
既然是前端,那么我们先来一个经典Windows Form窗体:
using System.Windows.Forms;
namespace Example
{
class Program
{
static void Main(string[] args)
{
var form = new Form();
var btn = new Button() { Text = "Click", Dock = DockStyle.Top };
btn.Click += (o, e) => MessageBox.Show("Clicked!");
form.Controls.Add(btn);
Application.Run(form);
}
}
}
直接运行,很简单,就是一个窗体上有个按钮,按完之后弹个窗,仅此而已。
经典的模拟繁重任务Thread.Sleep()
,绑定到一个新的button
上面,这里就不重复介绍了。除此之外,再来点好玩的,每次都往Form
里加点元素:
static void Main(string[] args)
{
Form form = new Form();
Button btn = new Button() { Text = "Click", Dock = DockStyle.Top };
btn.Click += (o, e) => MessageBox.Show("Clicked!");
form.Controls.Add(btn);
Button yabtn = new Button() { Text = "Heavy!", Dock = DockStyle.Top };
yabtn.Click += (o, e) => TimeConsumingEventList(form);
form.Controls.Add(yabtn);
Application.Run(form);
}
static void TimeConsumingEventList(Form form)
{
form.Controls.Add(new Label() { Text = "Job 1 finished.", Dock = DockStyle.Top });
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 2 finished.", Dock = DockStyle.Top });
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 3 finished.", Dock = DockStyle.Top });
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 4 finished.", Dock = DockStyle.Top });
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 5 finished.", Dock = DockStyle.Top });
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 6 finished.", Dock = DockStyle.Top });
Thread.Sleep(1000);
}
经典阻塞执行代码,相信读者肯定意识到了,程序运行起来之后,按下 “Heavy” 这个按钮之后,由于触发的函数TimeConsumingEventList()
为阻塞执行,主程序会失去响应大致 6 秒钟(因为我们一共有6个Thread.Sleep(1000)
),然后才能恢复。事实也的确如此:
而且这里还出现了很奇怪的现象,看上去好像添加了许多“Click”的按钮一样,而且新增的Label
也没有立刻显示出来,而是执行完方法后啪一下很快地一次展示。
其实这个是新增加的Label
控件占用了位置,但是其本身却还没有刷新出来,所以才会感觉很奇怪。
await
way前端与后端逻辑最不相同的地方就是需要时刻关注维护UI线程,尽量做到不要让繁重任务阻塞该UI线程,因为阻塞该线程的后果就是UI失去响应,用户体验极差。此外,这种未响应的状态还会让用户误以为程序已经死机了,可能直接就打开任务管理器把我们的程序干掉了…
怎么改善呢?C#作为前端王者之一,早就考虑到了这一点,引入了async/await这对关键词来解决这个问题。将TimeConsumingEventList()
函数引入async
关键词就可以很方便地解决这个问题。我们改写这个函数:
static async void TimeConsumingEventList(Form form)
{
await Task.Run(() =>
{
form.Controls.Add(new Label() { Text = "Job 1 finished.", Dock = DockStyle.Top });
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 2 finished.", Dock = DockStyle.Top });
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 3 finished.", Dock = DockStyle.Top });
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 4 finished.", Dock = DockStyle.Top });
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 5 finished.", Dock = DockStyle.Top });
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 6 finished.", Dock = DockStyle.Top });
Thread.Sleep(1000);
});
}
那我们直接运行试试看呢?…
这个错误是因为 试图在非UI线程来更新UI元素。
由于我们使用了await
关键词来等待一个任务,而这个任务是由Task.Run()
封装,在另一个线程中运行。在这个线程中执行form.Controls.Add(new Label...)
就直接报错了,因为form不属于这个线程,必须要在form所属的主线程来执行这段代码。
于是,为了能够在form自己所属的线程中运行向其添加元素的代码我们需要用到Invoke()
。
改造后的代码(如果是WPF框架的Window
,则需要使用Dispacher
):
static async void TimeConsumingEventList(Form form)
{
await Task.Run(() =>
{
form.Invoke(new EventHandler((o, e)
=> form.Controls.Add(new Label() { Text = "Job 1 finished.", Dock = DockStyle.Top })));
Thread.Sleep(1000);
form.Invoke(new EventHandler((o, e)
=> form.Controls.Add(new Label() { Text = "Job 2 finished.", Dock = DockStyle.Top })));
Thread.Sleep(1000);
form.Invoke(new EventHandler((o, e)
=> form.Controls.Add(new Label() { Text = "Job 3 finished.", Dock = DockStyle.Top })));
Thread.Sleep(1000);
form.Invoke(new EventHandler((o, e)
=> form.Controls.Add(new Label() { Text = "Job 4 finished.", Dock = DockStyle.Top })));
Thread.Sleep(1000);
form.Invoke(new EventHandler((o, e)
=> form.Controls.Add(new Label() { Text = "Job 5 finished.", Dock = DockStyle.Top })));
Thread.Sleep(1000);
form.Invoke(new EventHandler((o, e)
=> form.Controls.Add(new Label() { Text = "Job 6 finished.", Dock = DockStyle.Top })));
Thread.Sleep(1000);
});
}
终于,我们拥有了一个我们设想中的不阻塞主UI线程,并且可以在后台更新UI元素的一个例子。
但是这个代码似乎不是很优美啊,又是Invoke
又是EventHandler
,还有一大堆的new
,看着不舒适,能不能有更好的办法解决这个需求,并且减少代码量呢?
答案就是 —— IEnumerable
IEnumerable
way我们都知道,UI失去响应的根本原因在于用于 维护UI的线程在执行别的任务(非UI主循环)。在上面的例子中,它被我们安排去执行了一个耗时的TimeConsumingEventList()
方法,它在忙着做事情,当然就不会对用户的鼠标点击啊、移动之类的做响应。
一般而言,对于这种事情我们直接把耗时方法直接安排在后台线程上即可,即新起一个线程来执行耗时任务。此时此刻如果我们想要在后台线程更新UI,则需要用到Inoke
把我们的“更新UI”操作放到UI线程上来执行。
有没有什么方法可以让TimeConsumingEventList()
方法在 执行到一半的时候返回到主UI线程,更新一下UI,然后在继续回到方法上次执行的地方继续执行?
—— 等等,这听上去有点熟悉,这不就是 协程 吗??
而C#里的协程……就是 IEnumerable !哼哼,上代码:
static IEnumerable TimeConsumingEventList(Form form)
{
form.Controls.Add(new Label() { Text = "Job 1 finished.", Dock = DockStyle.Top });
yield return null;
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 2 finished.", Dock = DockStyle.Top });
yield return null;
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 3 finished.", Dock = DockStyle.Top });
yield return null;
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 4 finished.", Dock = DockStyle.Top });
yield return null;
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 5 finished.", Dock = DockStyle.Top });
yield return null;
Thread.Sleep(1000);
form.Controls.Add(new Label() { Text = "Job 6 finished.", Dock = DockStyle.Top });
yield return null;
Thread.Sleep(1000);
}
我们直接在每次对Form
元素更改之后,直接使用yield return null
来返回主线程,更新完UI之后,等下次该方法被执行的时候,就会从上次yield return
的地方开始执行,这不就实现了在一个方法的中间“返回主UI循环更新UI元素”并且可以在下次执行的时候从函数体中间开始继续执行上次未执行完的剩下的代码?
当然,因为这方法改成了协程,我们调用它的方法也要改一改:
Button yabtn = new Button() { Text = "Heavy!", Dock = DockStyle.Top };
yabtn.Click += (o, e) =>
{
var en = TimeConsumingEventList(form).GetEnumerator();
while (en.MoveNext())
{
Application.DoEvents();
}
};
form.Controls.Add(yabtn);
关键就在于这个MoveNext()
,由它负责反复调用TimeConsumingEventList()
方法。GetEnumerator()
相当于获取了这个协程函数的一个“方法执行接口”,而MoveNext()
则是“执行协程函数”。
Application.DoEvents()
是UI线程的主循环,我们每次从TimeConsumingEventList()
返回时,执行该函数用以更新UI、相应事件等。
让我们看看运行结果:
太妙了!原来这玩意儿还能这么用!每次MoveNext()
的执行,都会从上一次的yield return
位置开始,直到遇到下一个yield return
或者方法执行完毕(此时MoveNext()
返回值为false
。当我们发现从MoveNext()
的返回值是false
时,也就意味着这个方法真正地被执行完毕了;返回值是true
时,说明这个方法还有未执行完毕的代码)。
最实用的就是用来更新一个ProgressBar
的状态。ProgressBar
作为UI元素,在后台线程如果想要更新其值是需要做代理函数的,现在,直接IEnumerable就搞定了:
static void Main(string[] args)
{
Form form = new Form();
ProgressBar prgs = new ProgressBar() { Dock = DockStyle.Bottom };
form.Controls.Add(prgs);
Button btn = new Button() { Text = "Click", Dock = DockStyle.Top };
btn.Click += (o, e) => MessageBox.Show("Clicked!");
form.Controls.Add(btn);
Button yabtn = new Button() { Text = "Heavy!", Dock = DockStyle.Top };
yabtn.Click += (o, e) =>
{
// 简单的函数搞定 PrograssBar 的更新
IEnumerable Func(Form fm)
{
for (int i = 1; i < 11; ++i)
{
// 更新UI元素
fm.Controls.Add(new Label() { Text = $"Job{i} finished.", Dock = DockStyle.Top });
fm.Controls.OfType<ProgressBar>().FirstOrDefault().Value = i * 10;
// 返回主线程刷新
yield return null;
// 由主线程返回继续干事儿…
Thread.Sleep(1000);
}
}
var en = Func(form).GetEnumerator();
while (en.MoveNext())
{
Application.DoEvents();
}
};
form.Controls.Add(yabtn);
Application.Run(form);
}
值得注意的是,由于IEnumerable
并未引入新线程 (协程并不会创造线程,不同于await必须依赖Task创造新线程来实现线程异步),Thread.Sleep()
这个函数 仍然会在主UI线程上执行,所以在更新UI的一瞬间可以实现鼠标/键盘/事件的响应,但是转瞬之间UI线程又跑去执行Thread.Sleep()
了,给人的感觉就是一顿一顿的,但是至少比完全失去响应要好上那么一丢丢丢吧。
比起await
需要创造线程,Invoke
线程间发送事件,IEnumerable
带来的额外开销几乎可以忽略不计,对于超级轻量级的任务的列表来说(比如要执行1000个操作,每个操作都想要ProgressBar
更新一下的情况),个人感觉用IEnumerable
方便了许多。
不过真的是任何密集型任务,无论是I/O密集型还是计算密集型,还是开个新线程交由新的线程去操作把,毕竟这类事情,UI线程还是做不来的……