会议白板之控件转图片 WPF

科技   2024-08-16 08:03   北京  

 会议白板之控件转图片 WPF

本文经原作者授权以原创方式二次分享,欢迎转载、分享。

原文作者:唐宋元明清

原文地址:   https://www.cnblogs.com/kybs0/p/18324167

Windows应用开发有很多场景需要动态获取控件显示的图像,即控件转图片,用于其它界面的显示、传输图片数据流、保存为本地图片等用途。

RenderTargetBitmap

控件转图片BitmapImage/BitmapSource,在WPF中可以使用RenderTargetBitmap获取捕获控件的图像。

RenderTargetBitmap[1] 是用于将任何 Visual 元素内容渲染为位图的主要工具

下面我们展示下简单快速的获取控件图片:

private void CaptureButton_OnClick(object sender, RoutedEventArgs e)
    {
        var dpi = GetAppStartDpi();
        var bitmapSource = ToImageSource(Grid1, Grid1.RenderSize, dpi.X, dpi.Y);
        CaptureImage.Source = bitmapSource;
    }
    /// <summary>
    /// Visual转图片
    /// </summary>
    public static BitmapSource ToImageSource(Visual visual, Size size, double dpiX, double dpiY)
    {
        var validSize = size.Width > 0 && size.Height > 0;
        if (!validSize) throw new ArgumentException($"{nameof(size)}值无效:${size.Width},${size.Height}");
        if (Math.Abs(size.Width) > 0.0001 && Math.Abs(size.Height) > 0.0001)
        {
            RenderTargetBitmap bitmap = new RenderTargetBitmap((int)(size.Width * dpiX), (int)(size.Height * dpiY), dpiX * 96, dpiY * 96, PixelFormats.Pbgra32);
            bitmap.Render(visual);
            return bitmap;
        }
        return new BitmapImage();
    }

获取当前窗口所在屏幕DPI,使用控件已经渲染的尺寸,就可以捕获到指定控件的渲染图片。捕获到图片BitmapSource,即可以将位图分配给Image的Source属性来显示。DPI获取可以参考 C# 获取当前屏幕DPI - 唐宋元明清2188 - 博客园 (cnblogs.com)[2]

上面方法获取的是BitmapSource,BitmapSource是WPF位图的的抽象基类,继承自ImageSource,因此可以直接用作WPF控件如Image的图像源。RenderTargetBitmap以及BitmapImage均是BitmapSource的派生实现类

RenderTargetBitmap此处用于渲染Visual对象生成位图,RenderTargetBitmap它可以用于拼接、合并(上下层叠加)、缩放图像等。BitmapImage主要用于从文件、URL及流中加载位图。

而捕获返回的基类BitmapSource可以用于通用位图的一些操作(如渲染、转成流数据、保存),BitmapSource如果需要转成可以支持支持更高层次图像加载功能和延迟加载机制的BitmapImage,可以按如下操作:

/// <summary>
    /// WPF位图转换
    /// </summary>
    private static BitmapImage ToBitmapImage(BitmapSource bitmap,Size size,double dpiX,double dpiY)
    {
        MemoryStream memoryStream = new MemoryStream();
        BitmapEncoder encoder = new PngBitmapEncoder();
        encoder.Frames.Add(BitmapFrame.Create(bitmap));
        encoder.Save(memoryStream);
        memoryStream.Seek(0L, SeekOrigin.Begin);

        BitmapImage bitmapImage = new BitmapImage();
        bitmapImage.BeginInit();
        bitmapImage.DecodePixelWidth = (int)(size.Width * dpiX);
        bitmapImage.DecodePixelHeight = (int)(size.Height * dpiY);
        bitmapImage.StreamSource = memoryStream;
        bitmapImage.EndInit();
        bitmapImage.Freeze();
        return bitmapImage;
    }

这里选择了Png编码器,先将bitmapSource转换成图片流,然后再解码为BitmapImage。

图片编码器有很多种用途,上面是将流转成内存流,也可以转成文件流保存本地文件:

     var encoder = new PngBitmapEncoder();
     encoder.Frames.Add(BitmapFrame.Create(bitmapSource));
     using Stream stream = File.Create(imagePath);
     encoder.Save(stream);

回到控件图片捕获,上方操作是在界面控件渲染后的场景。如果控件未加载,需要更新布局下:

     //未加载到视觉树的,按指定大小布局
     //按size显示,如果设计宽高大于size则按sie裁剪,如果设计宽度小于size则按size放大显示。
     element.Measure(size);
     element.Arrange(new Rect(size));

另外也存在场景:控件不确定它的具体尺寸,只是想单纯捕获图像,那代码整理后如下:

public BitmapSource ToImageSource(Visual visual, Size size = default)
    {
        if (!(visual is FrameworkElement element))
        {
            return null;
        }
        if (!element.IsLoaded)
        {
            if (size == default)
            {
                //计算元素的渲染尺寸
                element.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
                element.Arrange(new Rect(new Point(), element.DesiredSize));
                size = element.DesiredSize;
            }
            else
            {
                //未加载到视觉树的,按指定大小布局
                //按size显示,如果设计宽高大于size则按sie裁剪,如果设计宽度小于size则按size放大显示。
                element.Measure(size);
                element.Arrange(new Rect(size));
            }
        }
        else if (size == default)
        {
            Rect rect = VisualTreeHelper.GetDescendantBounds(visual);
            if (rect.Equals(Rect.Empty))
            {
                return null;
            }
            size = rect.Size;
        }

        var dpi = GetAppStartDpi();
        return ToImageSource(visual, size, dpi.X, dpi.Y);
    }

控件未加载时,可以使用DesiredSize来临时替代操作,这类方案获取的图片宽高比例可能不太准确。已加载完的控件,可以通过VisualTreeHelper.GetDescendantBounds获取视觉树子元素集的坐标矩形区域Bounds。kybs00/VisualImageDemo: RenderTargetBitmap获取控件图片 (github.com)[3]

VisualBrush

如果只是程序内其它界面同步展示此控件,就不需要RenderTargetBitmap了,可以直接使用VisualBrush

VisualBrush是非常强大的类,允许使用另一个Visual对象(界面显示控件最底层的UI元素基类)作为画刷的内容,并将其绘制在其它UI元素上(当然,不是直接挂到其它视觉树上,WPF也不支持元素同时存在于俩个视觉树的设计) 具体的可以看下官网VisualBrush 类 (System.Windows.Media) | Microsoft Learn[4],这里做一个简单的DEMO:

<Window x:Class="VisualBrushDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:VisualBrushDemo"
        mc:Ignorable="d" Title="MainWindow" Height="450" Width="800">

    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="10"/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <Canvas x:Name="Grid1" Background="BlueViolet">
            <TextBlock x:Name="TestTextBlock" Text="截图测试" VerticalAlignment="Center" HorizontalAlignment="Center"
                       Width="100" Height="30" Background="Red" TextAlignment="Center" LineHeight="30" Padding="0 6 0 0"
                       MouseDown="TestTextBlock_OnMouseDown"
                       MouseMove="TestTextBlock_OnMouseMove"
                       MouseUp="TestTextBlock_OnMouseUp"/>

        </Canvas>
        <Grid x:Name="Grid2" Grid.Column="2">
            <Grid.Background>
                <VisualBrush Stretch="UniformToFill"
                             AlignmentX="Center" AlignmentY="Center"
                             Visual="{Binding ElementName=Grid1}"/>

            </Grid.Background>
        </Grid>
    </Grid>
</Window>

CS代码:

private bool _isDown;
    private Point _relativeToBlockPosition;
    private void TestTextBlock_OnMouseDown(object sender, MouseButtonEventArgs e)
    {
        _isDown = true;
        _relativeToBlockPosition = e.MouseDevice.GetPosition(TestTextBlock);
        TestTextBlock.CaptureMouse();
    }

    private void TestTextBlock_OnMouseMove(object sender, MouseEventArgs e)
    {
        if (_isDown)
        {
            var position = e.MouseDevice.GetPosition(Grid1);
            Canvas.SetTop(TestTextBlock, position.Y - _relativeToBlockPosition.Y);
            Canvas.SetLeft(TestTextBlock, position.X - _relativeToBlockPosition.X);
        }
    }

    private void TestTextBlock_OnMouseUp(object sender, MouseButtonEventArgs e)
    {
        TestTextBlock.ReleaseMouseCapture();
        _isDown = false;
    }

左侧操作一个控件移动,右侧区域动态同步显示左侧视觉。VisualBrush.Visual可以直接绑定指定控件,一次绑定、后续同步界面变更,延时超低

同步界面变更是如何操作的?下面是部分代码,我们看到,VisualBrush内有监听元素的内容变更,内容变更后VisualBrush也会自动同步DoLayout(element)一次:

// We need 2 ways of initiating layout on the VisualBrush root.
    // 1. We add a handler such that when the layout is done for the
    // main tree and LayoutUpdated is fired, then we do layout for the
    // VisualBrush tree.
    // However, this can fail in the case where the main tree is composed
    // of just Visuals and never does layout nor fires LayoutUpdated. So
    // we also need the following approach.
    // 2. We do a BeginInvoke to start layout on the Visual. This approach
    // alone, also falls short in the scenario where if we are already in
    // MediaContext.DoWork() then we will do layout (for main tree), then look
    // at Loaded callbacks, then render, and then finally the Dispather will
    // fire us for layout. So during loaded callbacks we would not have done
    // layout on the VisualBrush tree.
    //
    // Depending upon which of the two layout passes comes first, we cancel
    // the other layout pass.
    element.LayoutUpdated += OnLayoutUpdated;
    _DispatcherLayoutResult = Dispatcher.BeginInvoke(
        DispatcherPriority.Normal,
        new DispatcherOperationCallback(LayoutCallback),
        element);
    _pendingLayout = true;

而显示绑定元素,VisualBrush内部是通过元素Visual.Render方法将图像给到渲染上下文:

     RenderContext rc = new RenderContext();
     rc.Initialize(channel, DUCE.ResourceHandle.Null);
     vVisual.Render(rc, 0);

其内部是将Visual的快照拿来显示输出。VisualBrush基于这种渲染快照的机制,不会影响原始视觉元素在原来视觉树的位置,所以并不会导致不同视觉树之间的冲突。

此类VisualBrush方案,适合制作预览显示,比如打印预览、PPT页面预览列表等。

下面是我们团队开发的会议白板-页面列表预览效果:

参考文章:WPF Handedness with Popups[5]

参考资料
[1]

RenderTargetBitmap: https://learn.microsoft.com/zh-cn/dotnet/api/system.windows.media.imaging.rendertargetbitmap?view=windowsdesktop-8.0&viewFallbackFrom=net-8.0

[2]

C# 获取当前屏幕DPI - 唐宋元明清2188 - 博客园 (cnblogs.com): https://www.cnblogs.com/kybs0/p/7429282.html

[3]

kybs00/VisualImageDemo: RenderTargetBitmap获取控件图片 (github.com): https://github.com/kybs00/VisualImageDemo

[4]

VisualBrush 类 (System.Windows.Media) | Microsoft Learn: https://learn.microsoft.com/zh-cn/dotnet/api/system.windows.media.visualbrush?view=windowsdesktop-8.0

[5]

参考文章:WPF Handedness with Popups: https://stackoverflow.com/questions/18113597/wpf-handedness-with-popups


WPF开发者
「WPF开发者」现役微软MVP,专注 WPF 和 Avalonia 技术分享与传播。
 最新文章