65.9K
CodeProject 正在变化。 阅读更多。
Home

在Avalonia应用程序中嵌入原生(Windows和Linux)视图/控件/应用程序的简单示例

starIconstarIconstarIconstarIconstarIcon

5.00/5 (16投票s)

2022年11月28日

MIT

25分钟阅读

viewsIcon

27210

本文介绍了如何将原生的Windows和Linux控件嵌入到Avalonia应用程序中。

引言

请注意,本文和示例代码均已更新,以兼容最新版本的Avalonia - 11.0.6

什么是Avalonia?

Avalonia 是一个优秀的跨平台开源UI框架,用于开发

  • 可在Windows、Mac和Linux上运行的桌面解决方案
  • 在浏览器中运行的Web应用程序(通过WebAssembly)
  • 适用于Android、iOS和Tizen的移动应用程序。

Avalonia比UNO平台更强大、更简洁、更快速——UNO平台是其在跨平台XAML/C#世界中唯一的竞争对手。

要了解更多关于Avalonia的信息,请查阅Avalonia网站上的文档,并查看我在codeproject.com上发表的其他Avalonia文章,从使用AvaloniaUI进行跨平台UI编程的简单示例。第一部分 - AvaloniaUI构建块开始。

什么是将原生视图/控件/应用程序嵌入到Avalonia中?为什么我们需要它?

如果Avalonia应用程序由于某种原因缺少某些复杂的视觉元素(控件或视图),例如特定公司定制的,而这些视觉元素在Windows上作为WPF控件可用,那么可以将这些WPF视觉元素嵌入到Avalonia应用程序中,以便在Windows上运行。当然,它们只适用于提供这些视觉元素的平台。在没有视觉实现的其它平台上,开发者可以选择提供类似“此视图目前不适用于Linux。”的消息。

以下场景需要Avalonia托管原生视觉元素

  1. 当现有单平台应用程序(例如WPF应用程序)逐渐迁移到跨平台Avalonia时。

    在这种情况下,无需等待所有视图和控件都移植到Avalonia后才能向客户展示结果。您可以从移植Avalonia外壳和一些最重要的视图开始,其余视图可以逐个逐步移植。尚未移植的视图将仅在Windows上运行,而在其他平台上,它们可以显示例如“正在开发中”的消息。

  2. 有时,视图必须是原生的,因为将其多平台化会花费太多时间。在这种情况下,托管技术允许为每个平台显示适当的原生视图,例如,在Windows上运行应用程序将显示WPF视图,而在Linux上运行则会显示类似的原生Linux视图。

  3. 令人惊讶的是,Avalonia可以托管由其他进程创建的本机Windows,例如,如果运行多个WPF进程,每个进程都控制一个WPF窗口,我们可以将所有这些窗口显示在一个Avalonia应用程序中。从某种意义上说,Avalonia可以在视觉上将多个本机应用程序统一为一个Avalonia应用程序。我的最后一个示例将展示如何实现这一点。

本文示例所使用的平台

由于WSL2,在Windows 10和11上测试和调试Linux变得非常容易。因此,这里的大多数示例都是在Windows 11和Linux(Ubuntu 20.04)上构建和测试的。不幸的是,我无法在Mac上轻松测试我的代码,因此本文不包含任何Mac示例。

要查看使用WSL运行Avalonia应用程序,请参阅在Linux的Windows子系统 (WSL) 上运行和调试多平台.NET (.NET Core, .NET5 和 .NET6) GUI和控制台应用程序一文。

示例中使用的Avalonia、.NET和Visual Studio版本

在本文中,我使用了Avalonia 11 preview 4、.NET 6.0和Visual Studio 17.4.0 Preview 5.0。希望Avalonia很快能发布正式版11,届时,如果时间允许,我将把示例移植到该版本。

我使用Avalonia 11 preview 4的主要原因是,该分支与最新稳定分支0.10.18之间存在一些重大变化,我希望示例能够轻松转换为即将发布的Avalonia 11版本。

我遇到的Visual Studio 17.4.0 Preview 5.0问题

由于我们处理的是多个目标(Windows和Linux),我不得不将主项目文件从单目标修改为多目标,并使某些项目和包依赖项取决于当前目标。此类文件修改并非总是立即生效——有时,我必须重新启动Visual Studio。如果您正在重新处理本文中的示例,请记住这一点。

文章组织

  1. 首先,我将展示Avalonia窗口托管Windows和Linux简单原生视图的简单示例。
  2. 然后,我将提供一些关于将代码组织到单独项目、通过多平台视图模型重用通用功能以及使用IoC容器使主项目代码几乎与平台无关的架构建议。
  3. 最后,我提供了一个示例,展示如何将运行在自己进程中的原生WPF窗口嵌入到Avalonia应用程序中。目前,我还没有Linux的类似示例,但计划在未来某个时候添加它。

示例

示例位置

所有示例都位于NP.Avalonia.Demos存储库中的NP.Ava.Demos/HostingDemos文件夹下。

简单的Windows和Linux示例

简单的WinForms示例

运行示例

第一个示例展示了如何将WinForm嵌入到Avalonia中。该示例位于

HostingWinFormsDemo/HostingWinFormsDemo/HostingWinFormsDemo.sln

解决方案。

打开解决方案,将主项目HostingWinFormsDemo设为启动项目,编译并运行。您将看到

每次点击ClickMe按钮,按钮上方的点击次数就会增加。

请注意,嵌入的WinForm仅占据窗口垂直左半部分。这旨在说明开发人员可以自行决定其位置和分配空间——它不必占据整个窗口。事实上,Avalonia窗口的两个或更多部分可以被不同的原生嵌入控件占据。

源代码

查看定义WinForms控件的MyWinFormsControl项目。它由定义视觉控件的MyControl类和定义非视觉视图模型的ClickCounterViewModel类组成。(是的,即使在编写WinForms时,我们也使用视图-视图模型模式)。

ClickCounterViewModel类包含两个属性

  • NumberClicks - 指定按钮被点击的次数
  • NumberClicksStr - 要显示的最终字符串

两个属性都是可通知的,这意味着它们在更改时会触发INotifyPropertyChanged.PropertyChanged属性。

还有一个非常简单的方法

public void IncreaseNumberClicks()
{
    NumberClicks++;
}  

它会增加点击次数。

MyControl指定了一个按钮MyButton和一个放置在按钮上方的标签ClickCounter

控件中还定义了ClickCounterViewModel对象的一个实例

public partial class MyControl: UserControl
{
    // the view model.
    ClickCounterViewModel _viewModel = new ClickCounterViewModel();
    ...
}

这是带有注释的简单控件的完整代码

public partial class MyControl: UserControl
{
    // the view model
    ClickCounterViewModel _viewModel = new ClickCounterViewModel();

    public MyControl()
    {
        InitializeComponent();

        // call _viewModel.IncreaseNumberClicks();
        // on a button click
        MyButton.Click += MyButton_Click!;
            
        // set the initial value for the label
        SetLabel();

        // trigger the label change on NumberClicks change within the view model
        _viewModel.PropertyChanged += _viewModel_PropertyChanged!;
    }

    // calls SetLabel (to set the Label) when NumberClickStr property changes 
    // on the view model
    private void _viewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName == nameof(ClickCounterViewModel.NumberClicksStr))
        {
            SetLabel();
        }
    }

    // sets the ClickCounter label's text from the NumberClicksStr property
    private void SetLabel()
    {
        this.ClickCounter.Text = _viewModel.NumberClicksStr;
    }

    // button click handler that calls IncreaseNumberClicks on the view model
    private void MyButton_Click(object sender, EventArgs e)
    {
        _viewModel.IncreaseNumberClicks();
    }
}  

现在来看主项目HostingWinFormsDemo。这里最有趣的类是EmbedSample

public class EmbedSample : NativeControlHost
{
    protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            // on Windows, return the win32 handle to MyControl packed
            // as PlatformHandle object
            MyControl myControl = new MyControl();

            return new PlatformHandle(myControl.Handle, "Hndl");
        }

        // otherwise, return default
        return base.CreateNativeControlCore(parent);
    }

    protected override void DestroyNativeControlCore(IPlatformHandle control)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {

            WinApi.DestroyWindow(control.Handle);

            return;
        }

        base.DestroyNativeControlCore(control);
    }
}  

EmbedSample是一个Avalonia控件,它派生自NativeControlHost并重写其CreateNativeControlCore(...)方法以创建原生控件并返回其原生句柄(在我们的例子中,它是原生Win-Forms控件的Win32句柄)。

它还包含一个DestroyNativeControlCore(...)方法的重写,该方法在控件销毁时被调用以清除原生句柄(在我们的例子中,是win32句柄)。

EmbedSample控件通过MainWindow构造函数中的代码连接到窗口的视觉树

public MainWindow()
{
    InitializeComponent();

    EmbedSample embedSample = new EmbedSample();

    embedSample.HorizontalAlignment = HorizontalAlignment.Stretch;
    embedSample.VerticalAlignment = VerticalAlignment.Stretch;

    // connect the EmbedSample
    MyContentControl.Content = new EmbedSample();
}  

请注意,我们将embedSample控件的垂直和水平对齐方式设置为Stretch,否则当窗口调整大小时,控件将不会填充多余的空间。

Avalonia XAML代码位于MainWindow.xaml文件中

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        x:Class="HostingWindowsProcessDemo.MainWindow"
        Width="800"
        Height="300"
        Title="HostingWindowsProcessDemo">
    <Grid ColumnDefinitions="*,*">
         <ContentControl x:Name="WpfAppPlacementControl"/>
    </Grid>
</Window>  

ContentControl将占据窗口Grid面板的左半部分。

还有一个static unsafe class WinApi,它将Win32的DestroyWindow(...)方法导入到C#中,使其可用于其余功能。

public static unsafe class WinApi
{
    [DllImport("user32.dll", SetLastError = true)]
    public static extern bool DestroyWindow(IntPtr hwnd);
}  

此方法WinApi.DestroyWindow(IntPtr hwnd)用于EmbedSample.DestronNativeControlCore(...)重写中,以清理原生句柄

protected override void DestroyNativeControlCore(IPlatformHandle control)
{
    if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
    {
        // destroy win32 window
        WinApi.DestroyWindow(control.Handle);

        return;
    }

    base.DestroyNativeControlCore(control);
}  

查看主项目文件 HostingWinFormsDemo.csproj 的XML代码。有几个重要的点需要记住

  • 项目的TargetFramework属性设置为net6.0-windows(而不是多平台NET6.0)。这意味着生成的代码只适用于Windows。
  • 有一个UseWindowsForms属性设置为true<UseWindowsForms>true</UseWindowsForms>。这会自动将WinForms库添加到项目中。
  • 属性AllowUnsafeBlocks也设置为true<AllowUnsafeBlocks>true</AllowUnsafeBlocks>。这将允许不安全的类WinApi导入Windows代码以在项目中使用。

最后,注意app.manifest文件。它有一个重要行

  <!-- Windows 10 and 11-->
  <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />

如果这一行没有取消注释,嵌入功能将无法在Windows 10和11上运行。

关于架构的重要说明

为了简化与嵌入原生代码直接相关的解释,上面的示例在构建时包含本小节中描述的一些众所周知的架构问题。

要查看从架构角度接近完美的示例,请参阅下面的适用于Windows和Linux的多目标示例,架构接近最优部分。

请注意,为了示例的简单和清晰,我们将视图模型类ClickCounterViewModel放在了与WinForms控件相同的项目中。一般来说,在实际项目中应避免这样做。所有视图模型都应放置在各自的纯非视觉多平台.NET项目中。这将避免混合视觉和非视觉代码的多个问题,并且还允许我们例如为不同平台重用相同的视图模型。

另一个重要的点是,主项目HostingWinFormsDemo具有单一目标框架net6.0-windows(因此它只在Windows上运行)。当我们使用各种平台上的原生控件时,主项目通常会编写为具有多个目标框架——项目文件中使用<TagetFrameworks>元素而不是<TargetFramework>,例如

<TargetFrameworks>net6.0;net6.0-windows</TargetFrameworks>

示例中的EmbedSample控件直接创建了WinForms控件MyControl——请参阅以下代码

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
    // on Windows, return the win32 handle to MyControl packed
    // as PlatformHandle object
    MyControl myControl = new MyControl();

    return new PlatformHandle(myControl.Handle, "Hndl");
}  

实际上,我们应该用更通用的控件替换EmbedSample控件,使其不依赖于特定的嵌入视觉实现。

主项目HostingWinFormsDemo直接依赖于包含演示视图的WinForms MyWinFormsControl项目。通常,为了实现关注点分离,最好利用动态加载和IoC容器来加载视图,有时也用于加载视图模型,以便可以独立开发、调试和测试Shell和视图。

简单的WPF示例

我们的下一个示例演示了如何将一个简单的WPF控件嵌入到Avalonia中。

解决方案是HostingWpfControlDemo/HostingWpfControlDemo/HostingWpfControlDemo.sln

HostingWpfControlDemo设为解决方案的启动项目,编译并运行示例,您将看到

它的行为与上面的Winforms应用程序完全相同——它在按钮上方显示按钮点击次数。

在描述项目的代码和架构时,为了避免重复,我们将强调与WinForms示例的不同之处。

WPF视图是在WpfControl项目中构建的,借助MyWpfControl视图和ClickCounterViewModel视图模型类。

MyWpfControl.xaml文件使用绑定和行为(来自Microsoft.Xaml.Behaviors.Wpf包引用)将TextBlockText和按钮的Click操作绑定到视图模型上定义的相应属性和方法。

<UserControl x:Class="WpfControl.MyWpfUserControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:i="http://schemas.microsoft.com/xaml/behaviors">
    <Grid Background="LightGray">
        <StackPanel HorizontalAlignment="Center"
                    VerticalAlignment="Center">
            <TextBlock Text="{Binding Path=NumberClicksStr}" 
                       HorizontalAlignment="Center"
                       Margin="20"/>

            <Button HorizontalAlignment="Center"
                    Padding="10,5"
                    Content="ClickMe">
                <i:Interaction.Triggers>
                    <i:EventTrigger EventName="Click">
                        <!-- Call the method IncreaseNumberClicks() on the view model-->
                        <i:CallMethodAction TargetObject="{Binding}"
                                            MethodName="IncreaseNumberClicks" />
                    </i:EventTrigger>
                </i:Interaction.Triggers>
            </Button>
        </StackPanel>
    </Grid>
</UserControl>  

视图的DataContext属性在MyWpfUserControl.xaml.cs代码隐藏文件(在视图的构造函数中)中被分配给ClickCounterViewModel类型的对象

public partial class MyWpfUserControl : UserControl
{
    public MyWpfUserControl()
    {
        InitializeComponent();

        DataContext = new ClickCounterViewModel();
    }
}  

主项目HostWpfControlDemo与上一个示例的主要变化是EmbedSample的内容。WPF控件不是Win32对象,它们没有Win32句柄。因此,我们将WPF控件放置在WinForms的ElementHost控件中(它有一个Win32句柄)。以下是CreateNativeControlCore(...)方法的结果代码

protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
{
    if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
    {
        // create the WPF view
        MyWpfUserControl myControl = new MyWpfUserControl();

        // use ElementHost to produce a win32 Handle for embedding
        ElementHost elementHost = new ElementHost();

        elementHost.Child = myControl;

        return new PlatformHandle(elementHost.Handle, "Hndl");
    }
    return base.CreateNativeControlCore(parent);
}  

相应地,主csproj文件的UseWindowsFormsUseWPF两个标志都设置为true

<UseWindowsForms>true</UseWindowsForms>
<UseWPF>true</UseWPF>

简单的Linux示例

原始Linux示例位于HostingLinuxControlDemo\HostingLinuxControlDemo\HostingLinuxControlDemo.sln解决方案中。使用Microsoft的WSL,可以在Windows 10和11上运行和调试它。占位符详细描述了如何操作。在Windows 11上,您仍然需要从sourceforge.com安装VcSrv,并按照文章中描述的方式配置和启动它。

HostLinuxControlDemo项目设为解决方案中的启动项目。

之后,将调试目标切换到WSL

打开 launchSettings.json 文件,并更改其在WSL2下的 WSL/environmentVariable/DISPLAY 值,以匹配您机器的IP地址,具体操作请参阅 在Linux的Windows子系统 (WSL) 上运行和调试多平台.NET (.NET Core, .NET5 和 .NET6) GUI和控制台应用程序

构建并运行应用程序。以下应用程序将启动

点击按钮将增加按钮上方显示的点击次数。

我们的视图模型——ClickCounterViewModel与之前的示例完全相同。

LinuxView本身是使用GtkSharp构建的——一个围绕Linux gtk功能的C#面向对象包装器。代码非常简单,我将不详细解释,因为我们主要关注嵌入。

我们的主项目中有两个有趣的文件:EmbedSample.csGtkApi.cs

GtkApi类导入了我们需要的两个Linux方法

public static class GtkApi
{
    private const string GdkName = "libgdk-3.so.0";
    private const string GtkName = "libgtk-3.so.0";
    [DllImport(GdkName)]
    // return the X11 handle for the linux window
    public static extern IntPtr gdk_x11_window_get_xid(IntPtr window);

    [DllImport(GtkName)]
    // destroys the gtk window
    public static extern void gtk_widget_destroy(IntPtr gtkWidget);
}  

这两个方法由我们的EmbedSample类使用。gdk_x11_window_get_xid用于返回我们需要用于嵌入Linux视图的X11窗口句柄,而gtk_widget_destroy则在最后销毁Linux窗口。

这是EmbedSample代码

public class EmbedSample : NativeControlHost
{
    private IntPtr? WidgetHandleToDestroy { get; set; }

    protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {

            return GtkInteropHelper.RunOnGlibThread(() =>
            {
                // create the linux view
                LinuxView linuxView = new LinuxView();

                // store the widget handle for the window to destroy at the end
                WidgetHandleToDestroy = linuxView.Handle;

                // get Xid from Gdk window
                IntPtr xid = GtkApi.gdk_x11_window_get_xid(linuxView.Window.Handle);

                return new PlatformHandle(xid, "Xid");
            }).Result;
        }
        return base.CreateNativeControlCore(parent);
    }

    protected override void DestroyNativeControlCore(IPlatformHandle control)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            GtkInteropHelper.RunOnGlibThread(() =>
            {
                if (WidgetHandleToDestroy != null)
                {
                    // destroy the widget handle of the window
                    GtkApi.gtk_widget_destroy(WidgetHandleToDestroy.Value);
                    WidgetHandleToDestroy = null;
                }
                return 0;
            }).Wait();

            return;
        }

        base.DestroyNativeControlCore(control);
    }
}  

请注意,所有与Linux的交互都是在Avalonia的Avalonia.X11.Interop.GtkInterlopHelper.RunOnGlibThread(...)方法提供给我们的特殊线程中进行的。

另请注意,两个项目的TargetFramework均为net6.0(不像之前的示例中是net6.0-windows)。

Windows和Linux的多目标示例

此示例的代码位于HostingNativeDemo/HostingNativeDemo/HostingNativeDemo.sln解决方案下。

这个演示的目的是展示如何在一个解决方案中组合Linux和WPF视图。主解决方案相应地是多目标的——它对Linux使用net6.0,对Windows使用net6.0-windows

这个示例以最直接的方式创建——它的架构并未优化。在下一小节中,我们将展示一个以相同方式工作但具有更好架构、更好关注点分离和更少平台特定代码的演示。

首先,为Windows编译并运行主解决方案。为此,首先选择目标为“HostingNativeDemo”和框架net6.0-windows

HostingNativeDemo设为启动项目并重建,然后运行。您将看到熟悉的画面

现在通过选择WSL目标和net6.0框架切换到Linux

重建主项目。同时启动VcSrv服务器,并按照在Linux的Windows子系统 (WSL) 上运行和调试多平台.NET (.NET Core, .NET5 和 .NET6) GUI和控制台应用程序中的描述,在Properties/launchSettings.json文件中将DISPLAY变量设置为您当前的IP地址。

运行项目,您将看到一个带有Linux文本和按钮的Linux窗口

现在来看看代码。解决方案中有四个项目

  1. HostingNativeDemo - 主项目
  2. WpfControl - 托管WPF控件(视图)的项目
  3. LinuxView - 托管Linux视图的项目
  4. ViewModels - 托管WPF和Linux项目的视图模型的项目

我们针对原生WPF和原生Linux项目唯一新增的功能是,与之前的子部分不同,视图模型(我们已经熟悉的ClickCoutnerViewModel)被提取到自己的与平台无关的项目中,以便可以在Windows和Linux上重用。所有平台特定的代码与之前考虑的WPF和Linux示例中的完全相同。

需要解释的新代码只在主项目中。

查看HostingNativeDemo.csproj项目文件。您可以看到它有很多基于框架是net6.0还是net6.0-windows的条件语句,例如

<PropertyGroup Condition=" '$(TargetFramework)' == 'net6.0-windows' " >
    <UseWindowsForms>true</UseWindowsForms>
    <UseWPF>true</UseWPF>
</PropertyGroup>  

还有

<PackageReference Condition=" '$(TargetFramework)' != 'net6.0-windows' 
 " Include="GtkSharp" Version="3.24.24.38" /> 

<ProjectReference Condition=" '$(TargetFramework)' != 'net6.0-windows' 
 " Include="..\LinuxControl\LinuxControl.csproj" />
<ProjectReference Condition=" '$(TargetFramework)' == 'net6.0-windows' 
 " Include="..\WpfControl\WpfControl.csproj" />  

所有这些条件语句的目的是选择Windows(当目标框架是net6.0-windows时)和Linux(当目标框架是net6.0时)所需的依赖项。

文件 WinApi.csGtkApi.cs 包含的功能与先前Windows和Linux示例中同名文件类似,只是它们的内容被预处理器条件包裹,仅在为Windows或Linux编译时显示。以下是 WinApi.cs 文件的内容(仅在为Windows编译时显示)

namespace HostingNativeDemo
{
// Only compile the class when WINDOWS is defined.
#if WINDOWS
    public static unsafe class WinApi
    {
        [DllImport("user32.dll", SetLastError = true)]
        public static unsafe extern bool DestroyWindow(IntPtr hwnd);
    }
#endif
}  

以下是 GtkApi.cs 文件的内容(仅在为Linux编译时显示)

namespace HostingNativeDemo
{
// Only compile the class when WINDOWS is not defined.
#if !WINDOWS
    public static unsafe class GtkApi
    {
        private const string GdkName = "libgdk-3.so.0";
        private const string GtkName = "libgtk-3.so.0";
        [DllImport(GdkName)]
        // return the X11 handle for the linux window
        public static extern IntPtr gdk_x11_window_get_xid(IntPtr window);

        [DllImport(GtkName)]
        // destroys the gtk window or widget
        public static extern void gtk_widget_destroy(IntPtr gtkWidget);
    }
#endif
}  

其他重要的更改在文件 EmbedSample.cs 中。它到处都是预处理器条件。基本上,预处理器条件确保它在Windows下像WPF示例中的 EmbedSample 类一样工作,在Linux下像Linux示例中一样工作

#if WINDOWS
using System.Windows.Forms.Integration;
using ViewModels;
using WpfControl;
#else
using LinuxControl;
using Avalonia.X11.Interop;
#endif

...
  
public class EmbedSample : NativeControlHost
{
#if !WINDOWS
    private IntPtr? WidgetHandleToDestroy { get; set; }
#endif

    protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
    {
#if WINDOWS
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {

            MyWpfUserControl control = new MyWpfUserControl();
            control.DataContext = new ClickCounterViewModel();

            ElementHost host = new ElementHost{ Child = control };

            return new PlatformHandle(host.Handle, "Ctrl");

        }
#else
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            return GtkInteropHelper.RunOnGlibThread(() =>
            {
                // create the linux view
                LinuxView linuxView = new LinuxView();

                // store the widget handle for the window to destroy at the end
                WidgetHandleToDestroy = linuxView.Handle;

                // get Xid from Gdk window
                IntPtr xid = GtkApi.gdk_x11_window_get_xid(linuxView.Window.Handle);

                return new PlatformHandle(xid, "Xid");
            }).Result;

        }
#endif

        return base.CreateNativeControlCore(parent);
    }

    protected override void DestroyNativeControlCore(IPlatformHandle control)
    {
#if WINDOWS
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            // destroy the win32 window
            WinApi.DestroyWindow(control.Handle);

            return;
        }
#else
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
        {
            GtkInteropHelper.RunOnGlibThread(() =>
            {
                if (WidgetHandleToDestroy != null)
                {
                    // destroy the widget handle of the window
                    GtkApi.gtk_widget_destroy(WidgetHandleToDestroy.Value);
                    WidgetHandleToDestroy = null;
                }
                return 0;
            }).Wait();

            return;
        }
#endif

        base.DestroyNativeControlCore(control);
    }
}  

适用于Windows和Linux的架构接近最优的多目标示例

Shell和视图架构简介

前面示例(以及上面其他示例)的目的是突出Avalonia托管Windows和Linux原生控件(视图)相关的功能。为了实现嵌入的清晰性,我们牺牲了架构。

在本示例中,我们将实现与上一个示例(具有Windows和Linux目标的应用程序)相同的目标,但会以实际项目应有的方式优化其架构。特别是

  1. 我们的主项目(模拟Shell)将不依赖于原生视图。相反,主项目将使用IoC容器动态加载原生视图。
  2. 我们将使用NativeEmbeddingControlHandleBuilder类来替代特定于控件的EmbedSample(请记住它本质上是创建原生控件),这些类将完全与视图无关,并且可重用于不同的原生视图。
  3. 我们假设我们的原生视图是在一些其他应用程序使用的项目中提供的,不应该被修改。相应地,为了使它们的功能适应我们的IoC容器和动态加载,我们创建了两个非常简单的适配器项目——一个用于Linux,另一个用于Windows原生项目。
  4. 视图模型被提取到其自己的非视觉多平台项目中(这在之前的示例中已经完成)。
  5. 模仿Shell的主项目几乎没有目标框架条件代码。99%的此类代码位于可重用的PolyFills项目中。

示例代码结构概览

该示例位于HostingNativeWithIoCDemo/HostingNativeWithIoCDemo/HostingNativeWithIoCDemo.sln解决方案下。启动项目是HostingNativeWithIoCDemo.csproj

以下是解决方案资源管理器中显示的所有项目和解决方案文件夹

以下是对所有项目和文件夹的解释(为简化起见,我排除了对多平台Avalonia项目的引用)。

  1. HostingNativeWithIoCDemo是模拟Shell的启动项目。它依赖于Core文件夹中的可重用项目。它还引用了我非常简单但功能强大的IoCNP.IoCy。所有对IoCy的简单调用都将在下面解释。最重要的是,为了关注点分离,它不依赖于平台特定的视图。通常,Shell不应该对视图或视图模型(无论是平台特定的还是非平台特定的)有任何了解。

    它的net6.0-windows目标依赖于Microsoft.Xaml.Behavior.Wpf包(我用它代替ICommand来在按钮点击时调用视图模型方法)。我需要它是因为IoCy程序集解析器在不先将其加载到Shell中的情况下,仍然无法自动解析动态加载项目中的nuget包。此功能即将集成到IoCy中,届时Shell将完全与目标无关。
  2. Core文件夹包含两个(潜在的)可重用视觉项目
    1. PolyFills - 包含几乎所有平台相关代码的项目。
    2. Visuals - 包含可重用NativeEmbeddingControl的项目。此项目依赖于PolyFills项目。
  3. NativeControls文件夹包含两个具有原生视图的项目
    1. LinuxControls 包含 LinuxView
    2. WpfControls 包含 MyWpfUserControl
    这两个原生类与之前示例中描述的Linux和WPF原生控件(视图)完全相同。
  4. ViewModels项目包含视图模型(我们从之前的示例中已经熟悉的ClickCounterViewModel),该视图模型可在两个平台重用。该项目是100%非视觉和多平台的(因此对于每个平台都是100%可重用的)。
  5. NativeAdapters文件夹包含原生控件的IoCy适配器。适配器的目的是将原生视觉元素适配到IoC容器中。原生视图/控件通常被认为是不可修改的,因为它们可能用于不同的项目。理论上,将视图集成到Shell中的团队甚至可能没有它们的源代码,而只将其作为nuget包使用。

    这两个项目都依赖于一个微小但有用的NP.Utilities包,该包为IoCy提供属性(它们不需要完整的IoCy,只需要属性)。此外,这两个项目都依赖于PolyFills项目(允许它们创建PlatformHandle对象)。

    1. LinuxAdapters项目包含LinuxView类的适配器(一个带有IoCy属性的工厂方法)。它引用了LinuxControls项目。
    2. WindowsAdapters项目包含MyWpfUserControl类的适配器。它引用了WpfControls项目。
    Adapters代码的详细信息将在下面给出。

下图显示了项目依赖关系。图中的箭头从referenceD项目指向引用它的项目。源代码项目有粗体边框,而nuget包有较细的边框

关于构建和运行示例的注意事项

启动项目和原生控件(及其适配器)之间没有直接依赖关系。因此,您必须将NativeAdapters文件夹与启动项目HostingNativeWithIoCDemo分开构建(或更好地重建)。每个适配器项目的构建后事件将将其发布(DLL和PDF)文件复制到与Adapter项目同名的目录下

<main-proj-output-folder>\Plugins\Views    

假设根文件夹是包含HostingNativeWithIoCDemo.sln解决方案文件的文件夹(同一文件夹也包含HostingNativeWithIoCDemo.csproj项目文件),那么LinuxAdapters项目的发布内容将被复制到新创建的

<root-folder>\bin\Debug\net6.0\Plugins\Views\LinuxAdapters  

文件夹中,而WindowsAdapters的发布内容将被复制到

<root-folder>\bin\Debug\net6.0-windows\Plugins\Views\WindowsAdapters

文件夹。在尝试运行启动项目之前,请确保这些文件夹已填充并保持最新。

除了上述说明,运行此项目(对于Windows和Linux)应该与运行上一个示例中的项目完全相同——Windows和Linux的多目标示例。不要忘记运行VcSrv并在Linux环境中更新Properties/launchSettings.json文件中的DISPLAY变量。

最终应用程序将显示与上一个示例完全相同的布局和行为。

关于代码的详细信息

原生控件和视图模型与之前的示例具有完全相同的代码。因此,我们将主要关注以下两个主题

  1. IoCy相关的代码,用于存储、创建、动态加载和消费作为容器创建、依赖注入、动态加载对象的视图。
  2. 可重用的代码,用于创建和销毁IPlatformHandle对象,以便作为原生对象嵌入到Avalonia视觉树中。

IoCy相关代码

查看NativeAdapters/WindowsAdapters项目下的WindowsControlsIoCFactory类。项目名称的复数形式(WindowsAdapters,而不是WindowsAdapter)暗示那里可以放置多个Windows原生控件(或视图)适配器(尽管这里我们只使用一个)。这是代码

[HasRegisterMethods]
public static class WindowsControlsIoCFactory
{
    [RegisterMethod(typeof(IPlatformHandle), resolutionKey: "ThePlatformHandle")]
    public static IPlatformHandle? CreateView()
    {
        // create the Windows native WPF control
        MyWpfUserControl control = new MyWpfUserControl();

        // assign its data context to our view model
        control.DataContext = new ViewModels.ClickCounterViewModel();

        // use the method from PolyFill project to create 
        return HandleBuilder.BuildHandle(control);
    }
}  

属性[HasRegisterMethods][RegisterMethod(...)]来自对NP.DependencyInjection包的引用。

[HasRegisterMethods]类属性意味着该类包含一些用于创建Container对象的IoCy工厂方法。这将使其更容易在注入的程序集(DLL)中搜索此类——而不是检查每个public类中的每个方法,我们首先检查public类,然后只在标有[HasRegisterMethods]属性的类中搜索工厂方法。

现在来看看CreateView()方法返回IPlatformHandle?对象的RegisterMethod(...)属性

[RegisterMethod(typeof(IPlatformHandle), resolutionKey: "ThePlatformHandle")]
public static IPlatformHandle? CreateView()
{
   ...
}

属性的第一个参数——typeof(IPlatformHandle)使容器验证该对象确实是该类型。

参数resolutionKey可以是任何对象,它(连同类型属性)唯一标识容器内的IoC对象创建单元。最好(但并非必需)使用在所有容器对象(不仅仅是相同创建类型的对象)中唯一的名称或枚举值。我们将我们的对象称为“ClickCounterView”。

请注意,我们还有一个未使用的重要参数——isSingleton——通过将其设置为true,您可以创建一个单例对象。我们不需要它(因为无论如何每个视图在视觉树中只使用一次,并且不能在视觉树中的两个不同位置使用)。

现在来看NativeAdapters/LinuxAdapters项目中的LinuxControlsIoCFactory

[HasRegisterMethods]
public static class LinuxControlsIoCFactory
{
    [RegisterMethod(typeof(IPlatformHandle),  resolutionKey: "ClickCounterView")]
    public static IPlatformHandle? CreateView()
    {
        // HandleBuilder.BuildObjAndHandle will run the LinuxView 
        // and IPlatformHandle creation code 
        return HandleBuilder.BuildObjAndHandle(() => new LinuxView());
    }
} 

代码非常相似,只是我们调用return HandleBuilder.BuildObjAndHandle(() => new LinuxView());来创建并返回LinuxViewIPlatformHandle对象。我们使用不同方法的原因是,LinuxView()构造函数以及所有相关操作都需要在由GtkInteropHelper.RunOnGlibThread(...)提供的特殊Avalonia线程中完成。HandleBuilder.BuildObjAndHandle(...)确保所有LinuxView相关操作都在该线程中完成。

现在我将描述如何将包含视图的程序集注入到IoCy容器中,以及主项目如何检索View对象。

查看HostingNativeWithIoCDemo项目下的App.axaml.cs文件。我们将IoCContainer定义为一个static属性,并且程序集注入和容器创建在其OnFrameworkInitializationCompleted()方法中完成

public partial class App : Application
{
    ...

    public static IDependencyInjectionContainer Container { get; }

    public override void OnFrameworkInitializationCompleted()
    {
         var containerBuilder = new ContainerBuilder();

        // Assembly injection
        containerBuilder.InjectPluginsFromSubFolders($"Plugins{Path.DirectorySeparatorChar}Views");

        // build the container. 
        Container = containerBuilder.Build();

        ...
    }
}  

请注意,我们正在从主项目可执行目录下的“Plugins/Views”文件夹中的所有子文件夹注入代码。这是PostBuild事件复制所有Native Adaptor发布文件的地方。

使用句柄创建句柄和构建视觉树的代码位于MainWindow.axaml.cs文件中的MainWindow()构造函数中

public MainWindow()
{
    InitializeComponent();

    // create the embedSample control 
    NativeEmbeddingControl embedSample = new NativeEmbeddingControl();

    // create the platform handle from the container. 
    IPlatformHandle? platformHandle = 
            App.Container.Resolve<IPlatformHandle?>("ClickCounterView");

    // assign the embedSample handle to platformHandle
    embedSample.Handle = platformHandle;

    // set the Content of MyContentControl to be embedSample object. 
    MyContentControl.Content = embedSample;
}  

创建和销毁IPlatformHandle对象的可重用代码

此代码位于Core文件夹下的两个项目PolyFillsVisuals中。

Visuals项目只包含一个控件——NativeEmbeddingControl,它继承自Avalonia的NativeControlHost。它有一个Avalonia StyledProperty Handle(类似于WPF的DependencyProperty)。它还重写了两个NativeControlHost的方法:CreateNativeControlCore(...)DestroyNativeControlCore(...)

public class NativeEmbeddingControl : NativeControlHost
{
   ...

    protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle? parent)
    {
        if (Handle != null)
        {
            // if handle property is not null, return it
            return Handle;
        }

        // otherwise call method of the base class
        return base.CreateNativeControlCore(parent!);
    }

    protected override void DestroyNativeControlCore(IPlatformHandle? handle)
    {
        // call extension method HandleBuilder.DestroyHandle() of PolyFill project
        handle.DestroyHandle();
    }
}  

PolyFill项目吸收了在Windows和Linux实现之间进行选择的复杂性。它的WinApiGtkApi类导入了创建和销毁原生IPlatformHandle对象所需的原生Windows和Linux方法。它们与之前示例中同名类完全相同。

最复杂的类是HandleBuilder。它被预处理器#if#else#endif指令大量填充。它包含用于从WinForm、WPF或GtkSharp控件构建(和销毁)IPlatformHandle对象的实现。

HandleBuilder的大部分代码与之前的示例相同。唯一的区别是仅为Linux创建的ControlWrapper类。它实现了INativeControlHostDestroyableControlHandle,这是一个IPlatformHandle接口 + Destroy()方法。之所以如此,是因为在Linux小部件的情况下,返回的IPlatformHandle接口需要具有X11窗口句柄,而在销毁窗口时,应在窗口的Gtk句柄上调用gtk_widget_destroy(...)。因此,我们必须保留两个句柄——Gtk窗口句柄和X11窗口句柄。在上面的示例中,我们将private IntPtr? WidgetHandleToDestroy { get; set; }添加到EmbedSample类作为第二个句柄。在这里,我们希望保持我们的NativeEmbeddingControl整洁、干净且平台无关;因此,我们创建了一个更复杂的ControlWrapper类型的IPlatformHandle对象,它保留了这两个句柄并在需要时销毁正确的句柄(类似于Avalonia示例中的做法)。

在Avalonia中显示作为原生进程运行的Windows原生应用程序

最有趣的例子留到了最后。打开HostingWindowsProcessDemo/HostingWindowsProcessDemo/HostingWindowsProcessDemo.sln解决方案。它包含两个项目——主项目HostingWindowsProcessDemo和另一个项目WpfApp。有趣的是,WpfApp是一个独立的WPF应用程序,而不是一个DLL。您可以将其设为启动项目并在没有主项目的情况下运行。它将显示我们已经熟悉的点击计数器视图

现在将HostingWindowsProcessDemo项目设为解决方案中的启动项目。重建WpfApp项目;它的构建后事件将把它复制到

<HostingWindowsProcessDemo-localtion>/bin/Debug/net6.0-windows/AppsToHost/WpfApp

文件夹。

现在构建并运行HostingWindowsProcessDemo项目。它将启动自己的MainWindow,然后它还将启动WpfApp应用程序并将WpfApp的窗口放置在其主Window的左半部分

一个独立的应用程序实例WpfApp.exe仍然在单独的进程中运行,在视觉上成为了主窗口的一部分!

实现此功能的代码集中在HostingWindowsProcessDemo项目的两个文件——MainWindow.axaml.csEmbeddedProcessWindow.cs文件中。

MainWindow.axaml.cs文件定义了以下功能

public partial class MainWindow : Window
{
    // path to WpfApp.exe
    public const string WpfAppProcessPath = @"AppsToHost\WpfApp\WpfApp.exe";

    public MainWindow()
    {
        InitializeComponent();

        // handle Opened event for the window
        this.Opened += MainWindow_Opened;
    }

    private async void MainWindow_Opened(object? sender, System.EventArgs e)
    {
        // create EmbeddedProcessWindow object passing the path to it
        var wpfAppEmbeddedProcessWindow = 
            new EmbeddedProcessWindow(WpfAppProcessPath);

        // start the process and wait for the process' 
        // MainWindowHandle to get populated
        await wpfAppEmbeddedProcessWindow.StartProcess();

        // assign the wpfAppEmbeddedProcessWindow to the 
        // content control in the left half of the MainWindow
        WpfAppPlacementControl.Content = wpfAppEmbeddedProcessWindow;
    }
}  

这是EmbeddedProcessWindow类的代码

public class EmbeddedProcessWindow : NativeControlHost
{
    public string ProcessPath;
    private Process _p;

    public IntPtr ProcessWindowHandle { get; private set; }

    public EmbeddedProcessWindow(string processPath)
    {
        ProcessPath = processPath;
    }

    public async Task StartProcess()
    {
        // start the process
        Process p = Process.Start(ProcessPath);

        _p = p;

        _p.Exited += _p_Exited;

        // wait until p.MainWindowHandle is non-zero
        while (true)
        {
            await Task.Delay(200);

            if (p.MainWindowHandle != (IntPtr)0)
                break;
        }

        // set ProcessWindowHandle to the MainWindowHandle of the process
        ProcessWindowHandle = p.MainWindowHandle;
    }

    protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
    {
        // set the parent of the ProcessWindowHandle to be the main window's handle
        WinApi.SetParent(ProcessWindowHandle, 
               ((Window) e.Root).PlatformImpl.Handle.Handle);

        // modify the style of the child window

        // get the old style of the child window
        long style = WinApi.GetWindowLongPtr(ProcessWindowHandle, -16);

        // modify the style of the ChildWindow - remove the embedded window's 
        // frame and other attributes of a stand alone window. 
        // Add child flag
        style &= ~0x00010000;
        style &= ~0x00800000;
        style &= ~0x80000000;
        style &= ~0x00400000;
        style &= ~0x00080000;
        style &= ~0x00020000;
        style &= ~0x00040000;
        style |= 0x40000000; // child

        HandleRef handleRef =
            new HandleRef(null, ProcessWindowHandle);

        // set the new style of the schild window
        WinApi.SetWindowLongPtr(handleRef, -16, (IntPtr)style);

        base.OnAttachedToVisualTree(e);
    }

    protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            // return the ProcessWindowHandle
            return new PlatformHandle(ProcessWindowHandle, "ProcWinHandle");
        }
        else
        {
            return base.CreateNativeControlCore(parent);
        }
    }

    private void _p_Exited(object? sender, System.EventArgs e)
    {

    }
}  

EmbeddedProcessWindow继承自NativeControlHost。它在其构造函数中接收可执行文件的路径

public string ProcessPath { get; }

...

public EmbeddedProcessWindow(string processPath)
{
    ProcessPath = processPath;
}

然后在它的async void StartProcess()中,它等待直到ProcessMainWindowHandle属性接收到进程主窗口的Handle(变得非零),并将其分配给ProcessWindowHandle属性

public async Task StartProcess()
{
    // start the process
    Process p = Process.Start(ProcessPath);

    _p = p;

    _p.Exited += _p_Exited;

    // wait until p.MainWindowHandle is non-zero
    while (true)
    {
        await Task.Delay(200);

        if (p.MainWindowHandle != (IntPtr)0)
            break;
    }

    // set ProcessWindowHandle to the MainWindowHandle of the process
    ProcessWindowHandle = p.MainWindowHandle;
}  

然后,在EmbeddedProcessWindow控件附加到主窗口的视觉树后,它修改其窗口样式(以移除窗口框架和按钮,使窗口成为子窗口等),并通过调用WinApi.SetParent(...)方法将其父级设置为主窗口。

protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
    // modify the style of the child window

    // get the old style of the child window
    long style = WinApi.GetWindowLongPtr(ProcessWindowHandle, -16);

    // modify the style of the ChildWindow - remove the embedded window's frame, 
    // buttons, etc. and other attributes of a stand alone window. 
    // Add child flag
    style &= ~0x00010000;
    style &= ~0x00800000;
    style &= ~0x80000000;
    style &= ~0x00400000;
    style &= ~0x00080000;
    style &= ~0x00020000;
    style &= ~0x00040000;
    style |= 0x40000000; // child

    HandleRef handleRef =
        new HandleRef(null, ProcessWindowHandle);

    // set the new style of the schild window
    WinApi.SetWindowLongPtr(handleRef, -16, (IntPtr)style);

    // set the parent of the ProcessWindowHandle to be the main window's handle
    WinApi.SetParent(ProcessWindowHandle, 
                    ((Window)e.Root).PlatformImpl.Handle.Handle);

    base.OnAttachedToVisualTree(e);
}  

最后,NativeControlHost.CreateNativeControlCore(...)的重写将返回new PlatformHandle(ProcessWindowHandle, "ProcWinHandle");ProcessWindowHandle已在OnAttachedToVisualTree(...)中设置)。

protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent)
{
    if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
    {
        // return the ProcessWindowHandle
        return new PlatformHandle(ProcessWindowHandle, "ProcWinHandle");
    }
    else
    {
        return base.CreateNativeControlCore(parent);
    }
}

结论

在本文中,我提供了Avalonia应用程序嵌入Windows和Linux视图/控件的简单而详细的示例。它从演示如何嵌入WinForms、WPF和GtkSharp应用程序的简单应用程序开始。

然后,我提供了两个示例,演示如何使同一个应用程序在Windows上嵌入WPF控件,在Linux上嵌入类似的Linux控件。第一个示例非常直接,以突出原生嵌入功能,而第二个示例则演示了如何以接近最佳架构嵌入原生视图(视图和主项目是独立的,并且视图由IoC容器创建)。

最后一个示例演示了如何将来自不同WPF进程的窗口嵌入到Avalonia应用程序中。这个最后一个示例仅针对Windows(10和11)构建,因为我在Linux上无法弄清楚如何从进程句柄获取X11窗口ID。如果时间允许,我将解决这个问题,然后我将添加另一部分,描述如何从不同的进程嵌入原生Linux窗口。

致谢

感谢Avalonia团队的Nikita Tsukanov在一些Linux示例方面提供的帮助。

历史

  • 2022年11月28日:初始版本
  • 将示例和文章升级到Avalonia 11.0.6
© . All rights reserved.