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

79 篇文章 2 订阅

目录

介绍

什么是Avalonia?

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

用于本文中示例的平台

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

我在Visual Studio 17.4.0 Preview 5.0中遇到的问题

文章组织

示例

示例位置

简单的Windows和Linux示例

简单的WinForms示例

运行示例

源代码

关于体系结构的重要说明

简单的WPF示例

简单的Linux示例

适用于Windows和Linux的多目标示例

适用于Windows和Linux的多目标示例,体系结构接近最佳

使用shell和视图构建简介

示例的代码结构概述

有关生成和运行示例的说明

关于守则的血腥细节

IoCy相关代码

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

显示作为Avalonia中的本机进程运行的Windows本机应用程序

结论


如果需要将本机应用程序移植到多平台Avalonia,或者没有一些本机构建的复杂Avalonia控件,则将本机WindowsLinux控件嵌入到Avalonia应用程序中非常重要。我还介绍了通过IoC容器和动态加载在Avalonia中使用本机视图的最佳架构。最后,本文介绍如何将不同进程运行的本机窗口转换为Avalonia应用程序的可视部分。

介绍

什么是Avalonia

请注意,本文和示例代码都已更新,可以使用最新版本的Avalonia - 11.0.6。

Avalonia是一个伟大的新开源包,它与WPF非常相似,但与WPFUWP不同,它适用于大多数平台——WindowsMacOS和各种风格的Linux,并且在许多方面比WPF更强大。

要了解有关Avalonia的更多信息,请查看Avalonia网站上的文档,并查看我的其他Avalonia文章,从简单示例中的AvaloniaUI多平台UI编码开始。第1部分——AvaloniaUI构建基块

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

如果Avalonia应用程序由于某种原因缺少一些复杂的视觉对象(控件或视图),这些视觉对象可能由特定公司自定义,而这些视觉对象可用,例如在Windows上作为WPF控件,则可以将这些WPF视觉对象嵌入到Avalonia应用程序中以在Windows上运行。当然,它们仅适用于提供此类视觉效果的平台。在没有可视化实现的其他平台上,开发人员可以选择例如提供类似The view is not available yet for Linux."

以下方案需要托管Avalonia的本机视觉效果:

1、当现有的单平台应用程序(例如,WPF应用程序)逐渐移动到多平台Avalonia时。

在这种情况下,不必等待每个视图和每个控件移植到Avalonia即可将结果呈现给客户端。您可以从移植Avalonia shell和一些最重要的视图开始,其余视图可以逐个移植。尚未移植的视图仅适用于窗口和其他平台,它们可以显示例如正在进行中消息。

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

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

用于本文中示例的平台

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

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

用于示例的Avalonia.NETVisual Studio版本

在本文中,我使用了Avalonia 11预览版4.NET 6.0Visual Studio 17.4.0预览版5.0。希望很快,Avalonia将成为完整版本11,然后,如果时间允许,我会将示例移植到它。

我使用Avalonia 11预览版4的主要原因是因为该分支与最新的稳定分支0.10.18之间存在一些深刻的变化,我希望示例易于转换为即将推出的Avalonia 11版本。

我在Visual Studio 17.4.0 Preview 5.0中遇到的问题

由于我们正在处理多个目标(WindowsLinux),我不得不将主项目文件从单个目标修改为多个目标,并让一些项目和包依赖项以当前目标为条件。对文件的这种修改并不总是立即生效——但有时,我不得不重新启动Visual Studio。如果您要重新处理本文中的示例,请记住这一点。

文章组织

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

示例

示例位置

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

简单的WindowsLinux示例

简单的WinForms示例

运行示例

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

HostingWinFormsDemo/HostingWinFormsDemo/HostingWinFormsDemo.sln

解决方案。

打开解决方案,将主项目HostingWinFormsDemo设为启动项目,编译并运行。以下是您将看到的内容:

每次单击ClickMe按钮时,按钮上方的单击次数都会增加。

请注意,嵌入的winform仅垂直占据窗口的左半部分。这是为了表明由开发人员决定在哪里放置它以及分配给它多少空间——它不必占用整个窗口。事实上,Avalonia窗口的两个或多个部分可以被不同的本机嵌入式控件占用。

源代码

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

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 classWinApi还有将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.csprojXML代码。关于它,有几个要点需要记住:

  • 项目的TargetFramework属性设置为net6.0-windows
  • (而不是多平台NET6.0)。这意味着生成的代码仅适用于窗口。
  • 有一个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 1011上运行。

关于体系结构的重要说明

为了简化与嵌入本机代码直接相关的说明,上面的示例旨在包含本小节中描述的一些众所周知的体系结构问题。

有关从体系结构角度来看接近完美的示例,请查看下面的体系结构接近最佳架构的WindowsLinux的多目标示例部分。

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

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

<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容器来加载视图,有时也用于加载视图模型,以便可以独立开发、调试和测试外壳和视图。

简单的WPF示例

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

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

设置HostingWpfControlDemo为解决方案的启动项目,编译并运行示例,如下所示:

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

在描述项目的代码和体系结构时,我们将强调WinForms示例的差异,以避免重复。

WPF视图是在MyWpfControl视图和ClickCounterViewModel视图模型类的帮助下在WpfControl项目中生成的。

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 解决方案中。使用微软的WSL,可以在Windows 1011上运行和调试它。占位符详细描述了如何执行此操作。在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用于将X11句柄返回到我们需要用于嵌入Linux视图的窗口,而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的所有交互都是在AvaloniaAvalonia.X11.Interop.GtkInterlopHelper.RunOnGlibThread(...)方法允许我们访问的特殊线程中进行的。

另请注意,这两个项目的TargetFrameworknet6.0(与前面的示例中net6.0-windows不同)。

适用于WindowsLinux的多目标示例

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

本演示的目的是展示如何在同一解决方案中组合LinuxWPF视图。主要解决方案是相应的多目标——它将net6.0用于Linux和将net6.0-windows用于Windows

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

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

HostingNativeDemo成为启动项目并重建它,然后运行它。你会看到一个熟悉的画面:

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

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

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

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

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

对于本机WPF和本机Linux项目,我们唯一的新东西是,与前面的小节不同,视图模型(我们已经熟悉ClickCoutnerViewModel)在其自己的平台不可知的项目中被分解出来,以便它可以在WindowsLinux中重用。所有特定于平台的代码都与前面讨论的WPFLinux示例中的代码完全相同。

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

看看 HostingNativeDemo.csproj 项目文件。你可以看到它有很多语句,条件是框架是net6.0net6.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包含的功能类似于以前的WindowsLinux示例中的相同命名文件,只是它们的内容包装在预处理器条件中,仅在针对WindowsLinux编译时显示。以下是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示例中的类和Linux下的Linux示例中的EmbedSample类完全相同:

#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);
    }
}  

适用于WindowsLinux的多目标示例,体系结构接近最佳

使用shell和视图构建简介

前面的示例(与上面的其他示例相同)的目的是突出显示与Avalonia托管WindowsLinux本机控件(视图)相关的功能。我们牺牲了架构来实现嵌入的清晰度。

在此示例中,我们将实现与上一个示例(具有WindowsLinux目标的应用程序)相同的目标,但以实际项目中应有的方式优化其体系结构。特别:

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

示例的代码结构概述

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

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

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

1、HostingNativeWithIoCDemo是模仿命令行管理程序的启动项目。这取决于Core文件夹中的可重用项目。它还引用了我非常简单但功能强大的IoC包 NP.IoCy。下面将解释所有简单的IoCy调用。最重要的是,对于关注点分离,它不依赖于特定于平台的视图。通常,命令行管理程序不应了解视图或视图模型(特定于或不特定于平台)。

它的net6.0-windows目标取决于Microsoft.Xaml.Behavior.Wpf包(我使用ICommand而不是在单击按钮时调用视图模型方法)。我需要它,因为IoCy程序集解析程序仍然无法在不先将其加载到Shell中的情况下自动解析动态加载项目中的nuget包。此功能即将进入IoCy,然后shell将完全与目标无关。

2、核心文件夹包含两个(可能)可重用的视觉项目:

  1. PolyFills——包含几乎所有平台相关代码的项目。
  2. Visuals——包含可重用的NativeEmbeddingControl项目。此项目取决于PolyFills项目。

3、NativeControls 文件夹包含两个具有本机视图的项目:

  1. LinuxControls包含类LinuxView
  2. WpfControls包含类MyWpfUserControl

这两个本机类与前面示例中介绍的LinuxWPF本机控件(视图)完全相同。

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包有一个更薄的框架:

有关生成和运行示例的说明

启动项目和本机控件(及其适配器)之间没有直接依赖关系。因此,您必须独立于启动项目HostingNativeWithIoCDemo构建(或更好地重新构建)NativeAdapters文件夹。每个适配器项目的生成后事件会将其发布(DLLPDF)文件复制到与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

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

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

生成的应用程序将显示与上一示例中完全相同的布局和行为。

关于守则的血腥细节

本机控件和视图模型的代码与前面示例中的代码完全相同。因此,我们将主要关注以下两个主题:

  1. IoCy用于存储、创建、动态加载和使用视图作为容器创建、依赖项注入、动态加载对象的相关代码。
  2. 可重用的代码,用于创建和销毁要作为本机对象嵌入到Avalonia可视化树中的IPlatformHandle对象。

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]属性标记的类中搜索工厂方法。

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

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

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

参数resolutionKey可以是(与type属性一起)唯一标识容器内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());来创建并为LinuxView返回IPlatformHandle对象。我们使用不同方法的原因是,LinuxView()构造函数以及所有相关操作都需要在GtkInteropHelper.RunOnGlibThread(...)HandleBuilder.BuildObjAndHandle(...)提供的特殊Avalonia线程中完成。 确保所有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事件复制所有本机适配器发布文件的位置。

创建句柄并使用句柄生成可视化树的代码位于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

视觉项目仅包含一个控件NativeEmbeddingControl——该控件继承自AvaloniaNativeControlHost。它有一个AvaloniaStyledProperty Handle(类似于WPFDependencyProperty)。它还覆盖了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项目吸收了在WindowsLinux实现之间进行选择的复杂性。它的类WinApiGtkApi导入创建和销毁本机IPlatformHandle对象所需的本机WindowsLinux方法。它们与前面示例的相同命名类完全相同。

最复杂的类是HandleBuilder。它都充斥着预处理器#if , #else#endif方向。它包含用于从WinFormWPFGtkSharp控件构建(和销毁)IPlatformHandle对象的实现。

HandleBuilder大多数代码与前面的示例相同。唯一的区别是仅为Linux创建的ControlWrapper类。它实现了IPlatformHandle接口+ Destroy()方法的INativeControlHostDestroyableControlHandle。原因是在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应用程序并将WpfAppWindow的窗口放在其主窗口的左半部分:

仍在单独进程中运行的单独应用程序实例 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);
    }
}

结论

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

然后,我提供了两个示例,演示如何使同一应用程序在Windows上嵌入WPF控件,并在Linux上嵌入类似的Linux控件。第一个示例非常简单地突出了本机嵌入功能,而第二个示例演示了嵌入具有接近最佳体系结构的本机视图(视图和主项目是独立的,视图由IoC容器创建)。

最后一个示例演示如何将来自不同WPF进程的窗口嵌入到Avalonia应用程序中。最后一个示例仅针对Windows1011)构建,因为我在弄清楚如何从Linux上的进程句柄获取窗口X11 ID时遇到问题。如果时间允许,我会弄清楚,然后我将添加另一个部分来描述如何从不同的进程嵌入本机Linux窗口。

https://www.codeproject.com/Articles/5348155/Embedding-Native-Windows-and-Linux-Views-Controls

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Avalonia是一种用于构建跨平台用户界面的开源框架。导航控件Avalonia的一种重要控件,用于在不同页面之间进行导航操作。 Avalonia的导航控件允许我们在应用程序创建多个页面,并且能够在这些页面之间进行平滑的切换。导航控件提供了一种结构化的方式来组织和管理应用程序的不同界面。通过导航控件,我们可以通过简单的命令或代码逻辑来实现页面的导航和跳转。 导航控件通常由两个主要组件组成:导航器和页面容器。导航器负责维护当前页面的状态,并提供导航操作的方法和事件。页面容器用于显示和管理不同的页面。当我们进行页面导航时,导航器会负责加载、显示和销毁页面。 对于使用Avalonia导航控件应用程序,我们可以在页面间使用导航器提供的方法来切换页面,这样可以实现应用程序的整体流程控制。例如,我们可以使用导航控件在登录页面和主页面之间进行导航,或者在主页面的不同子页面之间进行导航。 此外,Avalonia导航控件还可以与其他控件一起使用,以实现更复杂的用户界面。例如,我们可以将导航控件和菜单控件结合使用,以创建具有导航功能的应用程序菜单。我们也可以将导航控件和数据绑定一起使用,以实现基于数据驱动的页面导航。 总而言之,Avalonia导航控件是一种实用的工具,可以帮助我们在Avalonia应用程序有效地管理和导航页面,提供良好的用户体验。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值