应用情景
实际上,WPF在后台线程绘制控件是很 “脏” 的手法,网上的资料也不多,不是什么值得使用的方案。这里存粹是记录一下。
我遇到一个应用情景是,一个控件是由视频控件和上方的Grid控件组成的,该Grid控件能够根据视频控件的内容动态的显示一些效果,类似于视频滤镜。需求是能够将整个控件的变化导出到视频,而且要支持导出队列,就是多个控件能够同时导出多个视频,同一个控件也可以重复导出。
因为需要导出队列,所以无法使用wpf控件截图的方式,而Grid控件显示的样式又十分复杂,通过简单的线条是很难在后台画出来,因此想到了在后台线程直接画控件并渲染的方法。只要后台cs代码和xaml中的页面保存一致,那导出的视频就是界面显示的效果。
示例代码
点击Button1在独立的STA线程中初始化控件并保存到图片。
private void Button1_Click(object sender, RoutedEventArgs e)
{
// 在新线程中操作控件
Task.Factory.StartNew(ThreadFunc).Wait();
}
private void ThreadFunc()
{
try
{
Thread thread = new Thread(() =>
{
var control = new Grid();
Random random = new Random();
byte a = (byte)random.Next(64, 256);
byte r = (byte)random.Next(64, 256);
byte g = (byte)random.Next(64, 256);
byte b = (byte)random.Next(64, 256);
control.Background = new SolidColorBrush(Color.FromArgb(a, r, g, b));
TextBlock textBlock = new TextBlock();
textBlock.FontSize = 30;
textBlock.Text = $"A:{a} R:{r} G:{g} B:{b}";
textBlock.VerticalAlignment = VerticalAlignment.Center;
textBlock.HorizontalAlignment = HorizontalAlignment.Center;
control.Children.Add(textBlock);
// 将要绘制的画布大小传递到最外层控件
Rect renderRect = new Rect(0, 0, 500, 500);
control.Arrange(renderRect); // 必须在控件属性设置完之后,绘制到 RenderTargetBitmap 之前调用
// 渲染控件到 RenderTargetBitmap
var bitmapRender = new RenderTargetBitmap((int)renderRect.Width, (int)renderRect.Height, 96d, 96d, PixelFormats.Pbgra32);
bitmapRender.Render(control);
// RenderTargetBitmap 保存到图片
var bmpEncoder = new PngBitmapEncoder();
bmpEncoder.Frames.Add(BitmapFrame.Create(bitmapRender));
using (Stream fs = File.Create("output.png"))
{
bmpEncoder.Save(fs);
}
});
thread.SetApartmentState(ApartmentState.STA); // 控件必须在STA线程中构造
thread.IsBackground = true;
thread.Start();
thread.Join();
}
catch (Exception ex)
{
Trace.TraceError($"{ex}");
}
}
一段简单的示例展示了如何完全在一个STA线程中绘制控件,并保存到图片。
但是这段代码存在一个问题。
有时候我们要将初始化和更新分开,这样不必每次都要初始化重新构造控件。
所有的控件只能在线程中创建时初始化一次。如果我们把控件在外部通过控件的Dispatcher.Invoke访问会抛出异常。因为这样创建的Thread是没有消息循环的。
改进方案
为了让控件的初始化和更新属性分开,我们给STA线程手动添加消息循环。
注意ThreadFunc中的添加的Dispatcher.Run();
需要注意的是这个方法是阻塞的(消息循环里的while),添加之后会阻塞STA线程。因此移除了thread.Join()。
点击Button1初始化控件。
点击Button2更新控件背景颜色为随机色,并更新子控件的文本内容为对应argb的颜色值。
private void Button1_Click(object sender, RoutedEventArgs e)
{
// 在task中初始化控件
Task.Factory.StartNew(ThreadFunc).Wait();
}
private void Button2_Click(object sender, RoutedEventArgs e)
{
// 刷新控件
RefreshControl();
}
private Grid control;
private void ThreadFunc()
{
try
{
Thread thread = new Thread(() =>
{
control = new Grid();
TextBlock textBlock = new TextBlock();
textBlock.FontSize = 30;
textBlock.VerticalAlignment = VerticalAlignment.Center;
textBlock.HorizontalAlignment = HorizontalAlignment.Center;
control.Children.Add(textBlock);
Dispatcher.Run(); // 在当前线程运行消息循环,该方法会阻塞线程。
});
thread.SetApartmentState(ApartmentState.STA); // 控件必须在STA线程中构造
thread.IsBackground = true;
thread.Start();
}
catch (Exception ex)
{
Trace.TraceError($"{ex}");
}
}
private void RefreshControl()
{
if (control == null) return;
control.Dispatcher.Invoke(() =>
{
// 更新控件背景
Random random = new Random();
byte a = (byte)random.Next(64, 256);
byte r = (byte)random.Next(64, 256);
byte g = (byte)random.Next(64, 256);
byte b = (byte)random.Next(64, 256);
control.Background = new SolidColorBrush(Color.FromArgb(a, r, g, b));
// 更新子控件内容
TextBlock textBlock = control.Children[0] as TextBlock;
textBlock.Text = $"A:{a} R:{r} G:{g} B:{b}";
// 将要绘制的画布大小传递到最外层控件
Rect renderRect = new Rect(0, 0, 500, 500);
control.Arrange(renderRect); // 必须在绘制到 RenderTargetBitmap 之前调用
// 渲染控件到 RenderTargetBitmap
var bitmapRender = new RenderTargetBitmap((int)renderRect.Width, (int)renderRect.Height, 96d, 96d, PixelFormats.Pbgra32);
bitmapRender.Render(control);
// RenderTargetBitmap 保存到图片
var bmpEncoder = new PngBitmapEncoder();
bmpEncoder.Frames.Add(BitmapFrame.Create(bitmapRender));
using (Stream fs = File.Create("output.png"))
{
bmpEncoder.Save(fs);
}
});
}
总结
如果只是要在后台线程画线,矩形,椭圆等简单的几何形状,可以使用DrawingContext,没有必要大费周章去做这种事情。
虽然后台渲染WPF的UI控件怎么看都不是好方案,但是作为开发也要具备解决各种问题的能力。