Qt/.NET——在.NET WPF应用程序中使用QML

Qt/.NET — Using QML in a .NET WPF application

Qt/.NET——在.NET WPF应用程序中使用QML

August 30, 2024 by Miguel Costa | Comments

​2024年8月30日 米格尔·科斯塔发表|评论

Qt/.NET is a proposed toolkit for interoperability between C++ and .NET, including a Qt-based custom native host for managed assemblies, as well as a native-to-managed adapter module that provides higher-level interoperability services such as object life-cycle management, instance method invocation and event notification.

​Qt/.NET是一个为C++和.NET之间的互操作性而提出的工具包。包括用于托管程序集的基于Qt的自定义本机主机,以及提供更高级别互操作性服务(如对象生命周期管理、实例方法调用和事件通知)的托管适配器本机模块。

In a previous post, we demonstrated how Qt/.NET can be used to create QObject-based wrapper classes for managed types, including the possibility to access properties of .NET objects as QObject properties, and also to convert .NET events to QObject signals. We showed how a Qt application can seamlessly integrate with assets in managed assemblies, and presented an example of an application that provides a QML user interface for a C# backend module.

​在上一篇文章中,我们演示了Qt/.NET可用于为托管类型创建基于QObject的包装器类,包括访问.NET对象作为QObject属性的可能性,也可以将.NET事件转换为QObject信号。我们展示了Qt应用程序如何与托管程序集中的资产无缝集成,并展示了一个为C#后端模块提供QML用户界面的应用程序示例。

qtdotnet_02_010

[Object Model] Using a Ping object from the .NET framework through a QObject-based wrapper.

[对象模型].NET框架通过基于QObject的包装器使用Ping对象。

In this post we will continue describing our proposal for Qt and .NET interoperability, including how to implement C# interfaces in C++ and how to define .NET types that extend Qt classes. We will conclude with an example of how to use these and other features of Qt/.NET to embed a QML UI into a .NET WPF application.

在这篇文章中,我们将继续描述我们对Qt和.NET互操作性的提议,包括如何在C++中实现C#接口以及如何定义.NET类型扩展Qt类。最后,我们将举一个例子来说明如何使用Qt/.NET的将QML UI嵌入到.NET WPF应用程序。

Implementing C# interfaces in C++

用C++实现C#接口

An interface in C# is a specification of a design contract composed of names and signatures of member functions. Types that are declared as implementations of a given interface are required to provide a publicly accessible implementation for each of the members of that interface. C# interfaces thus provide a standard mechanism for decoupling the use of abstract types from their implementations.

​C#中的接口是由成员函数的名称和签名组成的设计契约的规范。声明为给定接口实现的类型需要为该接口的每个成员提供可公开访问的实现。因此,C#接口提供了一种标准机制,用于将抽象类型的使用与其实现解耦。

public interface IStringTransformation
{
    string Transform(string s);
}

[C# Code] Example of C# interface definition.

[C#代码]C#接口定义示例。

Qt/.NET allows C# interfaces to be implemented by C++ classes, further extending the decoupling of interface and implementation to the context of native interoperability. A native implementation class for a C# interface must: (1) extend QDotNetInterface, (2) specify the fully qualified name of the target interface, and (3) provide implementations for all the interface members. The implementations of interface members must be registered as callbacks and associated to the corresponding names and signatures.

Qt/.NET允许C#接口由C++类实现,进一步将接口和实现的解耦扩展到本机互操作性的上下文中。C#接口的本机实现类必须:(1)扩展QDotNetInterface,(2)指定目标接口的完全限定名,以及(3)为所有接口成员提供实现。接口成员的实现必须注册为回调,并与相应的名称和签名相关联。

struct ToUpper : public QDotNetInterface
{
    ToUpper() : QDotNetInterface("FooLib.IStringTransformation, FooLib")
    {
        setCallback<QString, QString>("Transform",
            [](void *, const QString &bar) { return bar.toUpper(); });
    }
};

[C++ Code] Implementing a C# interface.

[C++代码]实现C#接口。

Native object encapsulation

本机对象封装

By extending QDotNetInterface, C++ objects can thus become accessible to .NET as implementations of C# interfaces. The Qt/.NET adapter achieves this by providing a managed object that will serve as a proxy of the native implementation. This proxy is created by the QDotNetInterface constructor, and contains the list of callbacks that are provided as interface member implementations. From the perspective of managed code, it's the proxy that implements the interface and whose members are invoked by other .NET objects.

通过扩展QDotNetInterface,C++对象可以访问.NET作为C#接口的实现。Qt/.NET适配器通过提供一个托管对象来实现这一点,该对象将作为本机实现的代理。此代理由QDotNetInterface构造函数创建,包含作为接口成员实现提供的回调列表。从托管代码的角度来看,是代理实现了接口,其成员由其他人调用.NET对象。

The definition of the interface proxy as a collection of callbacks to native code works well in the case where the class extending QDotNetInterface is also the one providing the actual interface implementation. But  it is no longer adequate if the goal is to expose an existing native type (e.g. from the Qt API) to .NET code. In that case, the QDotNetNativeInterface<T> must be used instead as the base class for interface implementations. This generic class encapsulates a pointer to an instance of some type T, which is the one actually being exposed.

将接口代理定义为对本机代码的回调集合,在扩展QDotNetInterface的类也是提供实际接口实现的类的情况下效果很好。但是,如果目标是向公开现有的本机类型(例如,从Qt API)到.NET代码,则这就不再够了。在这种情况下,必须使用QDotNetNativeInterface<T>作为接口实现的基类。这个泛型类封装了一个指向某种类型T实例的指针,这是实际公开的实例。

As an example, let's assume the goal is to expose the QModelIndex class to managed code, such that instances of that class can be manipulated in C# in the same way as they are in C++. The first step would be to define a C# interface for QModelIndex.

​例如,我们假设目标是将QModelIndex类暴露给托管代码,以便在C#中可以像在C++中一样操纵该类的实例。第一步是为QModelIndex定义一个C#接口。

public interface IQModelIndex
{
    bool IsValid();
    int Row();
    int Column();
}

[C# Code] Interface definition for QModelIndex (excerpt).

[C#代码]QModelIndex的接口定义(摘录)。

We would then need to provide a native implementation of the C# interface. In this case, that would be a C++ class extending QDotNetNativeInterface<QModelIndex>. The constructor for this base class, apart from the fully qualified name of the interface, will take as arguments a pointer to the instance of QModelIndex to encapsulate, as well as a bool value. This last value will determine how the finalizer for the proxy object handles disposal of the native pointer: if the value is true, the pointer will be disposed by a callback that invokes the destructor for the target type T – in this case, QModelIndex; if the value is false, no disposal action will be taken during the proxy's finalizer.

​然后,我们需要提供C#接口的本机实现。在这种情况下,这将是一个扩展QDotNetNativeInterface<QModelIndex>的C++类。除了接口的完全限定名外,此基类的构造函数还将接受指向要封装的QModelIndex实例的指针以及bool值作为参数。最后一个值将决定代理对象的终结器如何处理本机指针的处置:如果该值为true,则指针将由一个回调函数处置,该回调函数将调用目标类型T的析构函数——在本例中为QModelIndex;如果该值为false,则在代理的终结器期间不会采取任何处置操作。

struct IQModelIndex : public QDotNetNativeInterface<QModelIndex>
{
    IQModelIndex(const QModelIndex &idx) : QDotNetNativeInterface<QModelIndex>(
    	"Qt.DotNet.IQModelIndex, Qt.DotNet.Adapter", new QModelIndex(idx), true)
    {
        setCallback<bool>("IsValid", [this](void *data)
            { return reinterpret_cast<QModelIndex *>(data)->isValid(); });
        setCallback<int>("Column", [this](void *data)
            { return reinterpret_cast<QModelIndex *>(data)->column(); });
        setCallback<int>("Row", [this](void *data)
            { return reinterpret_cast<QModelIndex *>(data)->row(); });
    }
};

[C++ Code] Implementing the C# interface for QModelIndex (excerpt).

[C++代码]实现QModelIndex的C#接口(摘录)。

An instance of the target type T can now be encapsulated in the the interface implementation and exposed to managed code. The constructor for the native implementation will trigger the creation of the managed proxy, which will contain the list of implementation callbacks, as well as a pointer to the encapsulated native object. When an interface member is invoked on the proxy it will in turn invoke the associated callback on the native implementation, passing as argument the native pointer. The callback will cast the pointer to the appropriate target type T and call the corresponding function on the encapsulated object.

目标类型T的实例现在可以封装在接口实现中,并暴露给托管代码。本机实现的构造函数将触发托管代理的创建,该代理将包含实现回调列表以及指向封装的本机对象的指针。当在代理上调用接口成员时,它将依次调用本机实现上的相关回调,并将本机指针作为参数传递。回调将把指针转换为适当的目标类型T,并调用封装对象上的相应函数。

qtdotnet_02_021

[Object Model] QModelIndex object exposed to C# code.

[对象模型]暴露给C#代码的QModelIndex对象。

Extending Qt classes in .NET

在.NET中扩展Qt类

Using the Qt API, in certain scenarios, requires that calling code provide extensions to abstract Qt classes. This is the case,  for example, with classes that adhere to the model/view design pattern. An application that uses a Qt view class will need to provide an implementation of an abstract model class, such as QAbstractItemModel or one of its specializations, like QAbstractListModel. In the case of a .NET application, that means having a C# class that extends one of these Qt C++ classes. Qt/.NET makes this possible by combining a QObject-based wrapper with the implementation of a C# interface.

​在某些情况下,使用Qt API需要调用代码提供抽象Qt类的扩展。例如,对于遵循模型/视图设计模式的类就是这种情况。使用Qt视图类的应用程序需要提供抽象模型类的实现,如QAbstractItemModel或其一个专门化,如QAabstractListModel。在一个.NET应用程序的情况下,这意味着有一个扩展Qt C++类之一的C#类。Qt/.NET通过将基于QObject的包装器与C#接口的实现相结合,实现了这一点。

As an example, let's suppose that we want to use a Qt view to display a list of items in a .NET application. To that end, we will need to define a new model that extends the QAbstractListModel class. This new model will provide its own C# methods to override some of the C++ member functions of QAbstractListModel. However, it will also need to invoke the base implementations when needed. For that purpose, we will define an IQAbstractListModel C# interface to represent the base implementation, alongside an abstract class that can be overridden by the C# model class. For brevity's sake, we'll assume that the QModelIndex and QVariant Qt classes are already accessible in C# as implementations of the IQModelIndex and IQVariant interfaces. 

​例如,假设我们想使用Qt视图在.NET应用程序中显示项目列表。为此,我们需要定义一个扩展QAbstractListModel类的新模型。这个新模型将提供自己的C#方法来覆盖QAbstractListModel的一些C++成员函数。但是,它还需要在需要时调用基础实现。为此,我们将定义一个IQAbstractListModel C#接口来表示基本实现,以及一个可以被C#模型类覆盖的抽象类。为了简洁起见,我们假设QModelIndex和QVariant Qt类在C#中已经可以作为IQModelIndex和IQVariant接口的实现访问。 

public interface IQAbstractListModel
{
    int Flags(IQModelIndex index);
    IQModelIndex CreateIndex(int arow, int acolumn, IntPtr adata);
    void EmitDataChanged(IQModelIndex topLeft, IQModelIndex bottomRight, int[] roles);
}

public abstract class QAbstractListModel
{
    public IQAbstractListModel Base { get; protected set; }
    public virtual int Flags(IQModelIndex index) => Base.Flags(index);
    public abstract int RowCount(IQModelIndex parent);
    public abstract IQVariant Data(IQModelIndex index, int role);
}

[C# Code] Base definitions to override QAbstractListModel in C#.

[C#代码]在C#中重写QAbstractListModel的基本定义。

The native counterpart to the C# definitions of interface and abstract class is a new C++ class that extends QAbstractListModel from the Qt API, and that will function as both native implementation for the IQAbstractListModel C# interface as well as native wrapper for C# models. As such, the new C++ class must also extend both QDotNetInterface and QDotNetObject. Again for the sake of brevity we will assume that implementations for the IQModelIndex and IQVariant C# interfaces are already provided by C++ classes with the same name..

接口和抽象类的C#定义的本机对应是一个新的C++类,它从Qt API扩展了QAbstractListModel,并将同时用作IQAbstractListModel C#接口的本机实现和C#模型的本机包装器。因此,新的C++类还必须扩展QDotNetInterface和QDotNetObject。同样为了简洁起见,我们将假设IQModelIndex和IQVariant C#接口的实现已经由同名的C++类提供。

class QDotNetAbstractListModel
    : public QDotNetObject
    , public QDotNetInterface
    , public QAbstractListModel
{
public:
    QDotNetAbstractListModel()
    {
        setCallback<int, IQModelIndex>("Flags",
            [this](void *, IQModelIndex index)
            {
            	return QAbstractListModel::flags(index);
            });
        setCallback<IQModelIndex, int, int, void *>("CreateIndex",
            [this](void *, int row, int col, void *ptr)
            {
                return IQModelIndex(QAbstractListModel::createIndex(row, col, ptr));
            });
        setCallback<void, IQModelIndex, IQModelIndex, QDotNetArray<int>>("EmitDataChanged",
            [this](void *, IQModelIndex idx0, IQModelIndex idx1, QDotNetArray<int> roles)
            {
                emit QAbstractListModel::dataChanged(idx0, idx1, roles);
            }
    }
    Qt::ItemFlags flags(const QModelIndex &index) const override
    {
        return Qt::ItemFlags::fromInt(method("Flags", fnFlags).invoke(*this, index));
    }
    int rowCount(const QModelIndex &parent = QModelIndex()) const override
    {
        return method("RowCount", fnRowCount).invoke(*this, parent);
    }
    QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override
    {
        return method("Data", fnData).invoke(*this, index, role);
    }
private:
    mutable QDotNetFunction<int, IQModelIndex> fnFlags = nullptr;
    mutable QDotNetFunction<int, IQModelIndex> fnRowCount = nullptr;
    mutable QDotNetFunction<IQVariant, IQModelIndex, int> fnData = nullptr;
};

[C++ Code] Native definitions required to override QAbstractListModel in C#.

[C++代码]在C#中重写QAbstractListModel所需的本机定义。

With all the necessary base definitions in place, both managed and native, we can now define the C# class that extends QAbstractListModel and provides the actual model for the application.

有了所有必要的基础定义,无论是托管的还是本机的,我们现在可以定义扩展QAbstractListModel并为应用程序提供实际模型的C#类。

public class FooListModel : QAbstractListModel
{
    private string[] Values = ["Foo", "Bar", "Foobar"];
    public override int RowCount(IQModelIndex parent = null)
    {
        if (parent?.IsValid() == true)
            return 0;
        return Values.Length;
    }
    public override int Flags(IQModelIndex index = null)
    {
    	if (index == null)
            return Base.Flags(index);
    	int row = index.Row();
        if (row < 0 || row >= Values.Length)
            return Base.Flags(index);
        return (int)(ItemFlag.ItemIsEnabled | ItemFlag.ItemNeverHasChildren);
    }
    public override IQVariant Data(IQModelIndex index, int role = 0)
    {
    	if (index == null)
            return null;
    	int row = index.Row();
        if (row < 0 || row >= Values.Length)
            return null;
        if ((ItemDataRole)role != ItemDataRole.DisplayRole)
            return null;
        return Values[row];
    }
    public void SetFoo(string foo)
    {
    	Values[0] = foo;
        Values[2] = foo + Values[1].ToLower();
        var idx0 = Base.CreateIndex(0, 0, IntPtr.Zero);
        var idx1 = Base.CreateIndex(2, 0, IntPtr.Zero);
        Base.EmitDataChanged(idx0, idx1, [(int)ItemDataRole.DisplayRole]);
    }
}

[C# Code] Example C# class that extends QAbstractListModel.

[C#Code]扩展QAbstractListModel的C#类示例。

In summary, the new model will be composed by the following triple:

总之,新模型将由以下三部分组成:

1.Overriding object (C#, in the example above, an instance of FooListModel);

1.覆盖对象(C#,在上面的例子中,是FooListModel的实例);

2.Base interface proxy (C#, Proxy_IQAbstractListModel);

2.基础接口代理(C#,proxy_IQAbstractListModel);

3.Native wrapper and base interface implementation (C++, QDotNetAbstractListModel).

3.本机包装器和基础接口实现(C++、QDotNetAbstractListModel)。

These three objects will have a synchronized life-cycle, and will cooperate to achieve the conceptual goal of extending the native Qt class QAbstractListModel, in the perspective of both native and managed code. The three objects will be created at the same time, triggered by the constructor of the overriding object (not shown in the example code). C# objects will "see" the model as the overriding object, whereas C++ objects will "see" it as the native wrapper. After garbage collection and finalization, all objects will be adequately disposed, including the native implementation/wrapper through the appropriate destructor.

这三个对象将具有同步的生命周期,并将合作实现从本机和托管代码的角度扩展本机Qt类QAbstractListModel的概念目标。这三个对象将同时创建,由覆盖对象的构造函数触发(示例代码中未显示)。C#对象将“看到”模型作为覆盖对象,而C++对象将“看”它作为本机包装器。在垃圾收集和完成之后,所有对象都将被充分处理,包括通过适当的析构函数处理本机实现/包装器。

qtdotnet_02_052

[Object Model] QAbstractListModel extended by C# class.

【对象模型】C#类扩展的QAbstractListModel。

A word about tooling

关于工具的一句话

Much of the code required to set up native wrappers and interface implementations, as described above, is boilerplate code that can be automatically generated. The on-going work on the Qt/.NET project includes the development of code-generation tools for such boilerplate native code. This topic is still a work in progress, and we'll discuss it in further detail in subsequent posts.

如上所述,设置本机包装器和接口实现所需的大部分代码都是可以自动生成的样板代码。Qt/.NET项目包括为这种样板本机代码开发代码生成工具,是正在进行的工作。这个主题仍在进行中,我们将在后续文章中更详细地讨论它。

Using QML in a WPF application

在WPF应用程序中使用QML

Making use of all the interop features discussed so far, we'll now demonstrate how to use Qt/.NET to add a QML user interface to a WPF application. The QML UI will consist of a View3D that will display a 3D animation. An overlaid ListView will show a list of technical properties related to camera placement. The contents of this list is managed by the C# backend, and provided to the QML UI through a list model implemented in C#. The WPF specification of the main window will contain the following elements:

​利用到目前为止讨论的所有互操作功能,我们现在将演示如何使用Qt/.NET为WPF应用程序添加QML用户界面。QML UI将由一个View3D组成,该View3D将显示3D动画。叠加的ListView将显示与相机放置相关的技术属性列表。此列表的内容由C#后端管理,并通过C#中实现的列表模型提供给QML UI。主窗口的WPF规范将包含以下元素:

  • Placeholder for the QML UI;
  • QML UI的占位符;
  • Group of slider controls to manipulate camera position;
  • 一组用于操纵相机位置的滑块控件;
  • Frame rate indicator (progress bar with 100% corresponding to 60 fps).
  • 帧率指示器(进度条,100%对应60 fps)。

qtdotnet_02_013

[Mockup] WPF + QML demo application design.

【实体模型】WPF+QML演示应用程序设计。

The sliders will be bound to properties in the C# backend (e.g. CameraPositionXCameraRotationY, etc.), and will trigger PropertyChanged events when moved. These events will be converted to property notification signals that will allow the bound properties on the View3D to be updated accordingly.

滑块将绑定到C#后端中的属性(例如CameraPositionX、CameraRotationY等),并在移动时触发Changed事件。这些事件将被转换为属性通知信号,从而允许View3D上的绑定属性相应地更新。

View3D {
    id: view
    anchors.fill: parent
    PerspectiveCamera {
        position: Qt.vector3d(
            mainWindow.cameraPositionX,
            mainWindow.cameraPositionY + 200,
            mainWindow.cameraPositionZ + 300)
        eulerRotation.x: (mainWindow.cameraRotationX - 30) % 360
        eulerRotation.y: mainWindow.cameraRotationY
        eulerRotation.z: mainWindow.cameraRotationZ
    }
}

[QML UI] View3D properties (excerpt) bound to backend (mainWindow) properties.

[QML UI]绑定到后端(主窗口)属性的View3D属性(摘录)。

The C# backend will include an instance of a Camera model class that extends QAbstractListModel. Changes to the camera control sliders will also trigger updates to the Camera model, which will in turn result in an update to the ListView contents.

C#后端将包含一个扩展QAbstractListModel的Camera模型类的实例。对摄影机控制滑块的更改也将触发对摄影机模型的更新,进而导致ListView内容的更新。

public class Camera : QAbstractListModel
{
    private static string[] Names = ["Truck", "Pedestal", "Zoom", "Tilt", "Pan", "Roll"];
    private double[] Values { get; set; } = new double[Names.Length];
    private IQModelIndex[] Index { get; } = new IQModelIndex[Names.Length];

    private IQModelIndex IndexOf(Settings setting)
    {
        if (Index[(int)setting] is not { } idx)
            idx = Index[(int)setting] = Base.CreateIndex((int)setting, 0, 0);
        return idx;
    }

    public double this[Settings setting]
    {
        get { return Values[(int)setting]; }
        set
        {
            Values[(int)setting] = value;
            var idx = IndexOf(setting);
            Base.EmitDataChanged(idx, idx, [(int)ItemDataRole.DisplayRole]);
        }
    }

    public override int RowCount(IQModelIndex parent = null)
    {
        if (parent?.IsValid() == true)
            return 0;
        return Values.Length;
    }

    public override int Flags(IQModelIndex index = null)
    {
        return (int)(ItemFlag.ItemIsEnabled | ItemFlag.ItemNeverHasChildren);
    }

    public override IQVariant Data(IQModelIndex index, int role = 0)
    {
        if ((ItemDataRole)role != ItemDataRole.DisplayRole)
            return null;
        var row = index.Row();
        if (row is < 0 or > 5)
            return null;
        return $@"{Names[row]}: {Values[row]:0.00}";
    }
}

[C# Code] Camera model.

[C#Code]相机型号。

Embedding a QQuickView in a WPF window

在WPF窗口中嵌入QQuickView

To show the QML UI inside of the WPF window we will use a WindowsFormsHost element in the XAML specification for the app's main window. This element allows embedding a Windows Forms control in WPF, which, unlike WPF controls, can be accessed through a HWND handle. We can then obtain a QWindow by calling the fromWinId() static function, passing as argument the handle to the embedded control. As the content of the embedded QWindow, we will use a QQuickView that, together with a QQmlEngine, will be responsible for rendering the QML contents. As a final note: the use of the WindowsFormsHost element is only a makeshift solution, exclusively for the purposes of this demo app. When officially released, Qt/.NET will include its own QWindow-based WPF element.

​为了在WPF窗口中显示QML UI,我们将在XAML规范中为应用程序的主窗口使用WindowsFormsHost元素。此元素允许在WPF中嵌入Windows窗体控件,与WPF控件不同,可以通过HWND句柄访问该控件。然后,我们可以通过调用fromWinId()静态函数来获得一个QWindow,并将句柄作为参数传递给嵌入式控件。作为嵌入式QWindow的内容,我们将使用QQuickView,它与QQmlEngine一起负责呈现QML内容。最后一点:WindowsFormsHost元素的使用只是一个临时解决方案,仅用于此演示应用程序。正式发布时,Qt/.NET将包含自己的基于QWindow的WPF元素。

<Window x:Class="WpfApp.MainWindow" Title="WPF + QML Embedded Window"
  ...
  xmlns:wf="clr-namespace:System.Windows.Forms;assembly=System.Windows.Forms">
  <Grid>
    ...
    <WindowsFormsHost Name="EmbeddedAppHost" Grid.Column="1">
      <wf:Panel Name="HwndHost" BackColor="#AAAAAA" />
    </WindowsFormsHost>
  </Grid>
</Window>

[XAML Markup] WPF main window (excerpt) with host element for the QML UI.

〔XAML标记〕WPF主窗口(摘录),带有QML UI的host元素。

void EmbeddedWindow::show()
{
    embeddedWindow = QWindow::fromWinId((WId)mainWindow->hwndHost.handle());
    quickView = new QQuickView(qmlEngine, embeddedWindow);
    quickView->setSource(QUrl(QStringLiteral("qrc:/main.qml")));
    quickView->show();
}

[C++ Code] Showing a QQuickView inside of the WPF host element.

[C++代码]在WPF宿主元素中显示QQuickView。

The full source code of the demo application is available in the latest patch submitted to the Qt/.NET repository.

​演示应用程序的完整源代码可以在提交给Qt/.NET存储库的最新补丁中找到。

https://www.qt.io/hs-fs/hubfs/qtdotnet_02_app.gif?quality=high&width=640&height=483&name=qtdotnet_02_app.gif

[App Screenshot] WPF + QML demo running.

[应用程序截图]WPF+QML演示正在运行。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值