Qt和Symbian C++的混合编程

原文出处:http://www.developer.nokia.com/Community/Wiki/Qt%e5%92%8cSymbian_C++%e7%9a%84%e6%b7%b7%e5%90%88%e7%bc%96%e7%a8%8b

 

本文讲解如何使用PIMPL模式来清晰区隔Qt和Symbian C++代码。

此外,文章讲述了如何编写代码来安全地融合两种环境下的异常处理机制、编码样式和惯例、字符串、几何图形、容器、图像,及数据等。每小节都对某个特定任务在Symbian C++ 和Qt中如何完成,如何融合两种编码惯例,作了高度概括。文中也提供了对一些重要文档的链接,以对这些实现方法作更为详细的解释。

所附范例代码(File:Qtbluetoothdiscoveryexample.zip)实现了找到远程蓝牙设备的Qt API(及相关对话框)。范例使用PIMPL模式获取底层平台信息,同时说明了Qt和SymbianC++混合编程中如何安全地融合不同的异常处理机制、编码样式和字符串。

 

Symbian^1 (诺基亚5800), S60 3rd Edition, Feature Pack 1 (诺基亚N95)

简介

Symbian平台是针对移动终端的开源软件平台。目前上亿部的手机终端基于Symbian平台(被称为S60和Symbian OS)的一些早期版本,Symbian平台也被全球100多家网络运营商所接受。

图 1: Symbian平台上的Qt

Qt是跨平台的应用和用户界面框架,它能让开发伙伴们所编制的应用部署到桌面、移动,及嵌入式操作系统中,而无需重写源代码。自Qt 4.6起,Qt也将Symbian平台作为编译目标;Qt应用可以运行于Symbian平台终端及一些早期的S60终端(自S60 3rd Edition FP1)上。如图1所示,针对Symbian 平台的Qt架构于本地Symbian C++平台APIs及其标准C/C++兼容层(Open C及Open C++)之上。

虽然Qt拥有丰富的APIs和开发工具,但某些开发伙伴还是无可避免地需要用到一般Qt或标准C++ APIs都不提供的平台级功能。当目标设备为移动终端时更是如此,目前使用某些重要的移动设备功能(如照相、蓝牙、名片夹等)的APIs还不存在。新Qt APIs已开始应对一些通常意义上的移动用例,但某些开发伙伴还是需要或希望使用本地操作系统级的功能。

在Qt不能提供所需API的场合,建议使用平台特定功能,即编写一个通用跨平台的封装API,并为封装API提供特定的私有平台实现。这一方案使得应用更易于移植, 方案细节将在#平台特定实现一节中讨论。

对终端特定功能的调用并非就是使用大家熟悉的标准C++ APIs和语法去调用手机特定APIs这么简单。Symbian的本地编程语言是Symbian C++,这是C++的一个变体,经过演化后能应对资源受限设备的需求。Symbian C++使用一些编程语法和框架以促进代码的强壮性和高效性,有时候则会牺牲一些可用性。它拥有其自己的异常处理机制,并在创建时省略了一些用不到(或被认为过分重量级)的标准C和C++库。

来自C++不同变体的代码可以融合,但却要小心对待,同时对Symbian C++要有所了解。本文讲解了如何协调对不同的异常处理机制、字符串、几何图形、容器、图像、数据,及多任务方案。相关每一节都简要介绍了在不同平台上的做法(且给出一些重要的参考资源链接),接下来则是融合方面的一些范例和讨论。Qt和Symbian C++开发伙伴们应能很好地了解如何使用其现在所掌握的技术,也能了解在哪里获取进一步的信息。

开始讨论跨平台兼容机制之前,我们在下面小节中对一些范例代码作概要讲解,以展示一些要点。

 

蓝牙发现范例

[edit] 概述

这个范例代码包括BluetoothLibrary.dll,及调用该dll的一个测试应用testbluetoothdiscovery.exe

BluetoothLibrary.dll导出一个Qt API (BluetoothDiscovery) 用于发现附近的蓝牙设备。BluetoothLibrary.dll具有其私有的Symbian C++实现,也具有针对其它平台的stub实现。BluetoothLibrary.dll还会导出一个使用方便的Qt对话框,用户可从中搜寻并选择一个或多个设备。

这个测试应用是GUI主视窗应用,它显示一个空屏幕作为起步。它具有功能键菜单选项用于启动单选和多选对话框。

下图展示诺基亚5800上针对单个和多个设备的选择对话框,及显示各主要组件间关系的架构图。

[edit] API

图 5: 主类

BluetoothDiscovery提供了一些槽,用于启动及终止对附近设备的搜索,也提供了一些信号以便在启动或终止搜索时、当探测到某个新设备时,以及出错时,通知所连接的客户端。

搜寻过程一旦启动就会一直持续到底层平台确定不存在更多的设备;这时它会发出信号,表示发现过程已经终止。当出现错误事件时发现过程也会停止,这时一个本地定义的枚举出错码被作为信号发向客户端。

对话框QBluetoothRemoteDeviceDialog 类给出了两个静态方法用于启动对话并返回用户选择:第一个方法返回一个单选,而第二个方法则允许用户选择多台设备:

static QBluetoothAddress QBluetoothAddress::getRemoteDevice ( QWidget * parent = 0);
static QList<QBluetoothAddress> QBluetoothAddress::getRemoteDevices ( QWidget * parent = 0);

除了对话类,还创建了一些相关的Qt类,如用于设备地址的QBluetoothAddress,用于代表一个远程设备的QBluetoothRemoteDevice,及用于枚举定义可能的设备类型和相关服务的QBluetooth等。

图5是简化了的公共API图。请注意其中并没有出现QBluetoothAddressQBluetoothRemoteDevice类。

该范例的对话框是可扩展的;它并不允许过滤掉所出现的设备(基于设备类型、服务类型或服务发现协议SDP),也不允许过滤掉用于搜寻目的的本地设备规范,它忽略了传递给自己的构造函数的窗口标志,也不会显示表示设备类型的图标。

[edit] 范例的编译和运行

编译本范例前你应该已经按 Qt快速起步中的要求配置好了自己的开发环境和配置终端。

向集成开发环境中导入范例的最方便方法就是通过PRO文件:/QtBluetoothDiscoveryExample/qtbluetoothdiscoveryexample.pro,它指定范例的PRO文件和测试代码,也规定了编译构建顺序。本范例既构建于Symbian平台(模拟器和移动终端),也构建于Windows平台,但须注意的是:在Windows上并不能搜索终端,因为范例未提供Windows平台的蓝牙实现。

该范例DLL和测试exe文件通过测试代码安装文件(testbluetoothdiscovery.sisx) 向终端部署。通过选择应用文件夹中的testbluetoothdiscovery 图标在Symbian平台上运行可执行程序。

[edit] 文件清单

范例包中具有下列目录结构(位于/qtbluetoothdiscoveryexample/文件夹下):

文件夹 文件 说明
BluetoothLibrary/ bluetoothdiscovery (.cpp/.h)BluetoothDiscovery 公共API头文件和源文件
 bluetoothdiscovery_stub (.cpp/.h)针对非Symbian平台目标的私有实现(stub)头文件和源文件(BluetoothDiscoveryPrivate)
 bluetoothdiscovery_symbian (.cpp/.h)私有Symbian平台实现头文件和源文件(BluetoothDiscoveryPrivate)
 BluetoothLibrary.pro针对BluetoothLibrary.dll的工程项目文件
 globalbluetooth.h 全局 #defines,用于定义DLL导入/导出的exports开关
 qbluetooth.h QBluetoothQBluetooth头文件(被其它类使用的公共枚举值)
 qbluetoothaddress (.cpp/.h) QBluetoothAddress公共头文件和源文件
 qbluetoothaddressdata.cpp QBluetoothAddress数据实现(用于隐式共享)
 qbluetoothremotedevice (.cpp/.h) QBluetoothRemoteDevice公共头文件和源文件
 QBluetoothRemoteDeviceDialog.cpp QBluetoothRemoteDeviceDialog公共头文件和源文件
 QBluetoothRemoteDeviceDialog.ui QBluetoothRemoteDeviceDialog Qt designer文件
eabi/ bluetoothlibraryu.def DLL的def定义
testbluetoothdiscovery/ main.cpp 测试应用的主入口
 testbluetoothdiscovery (.cpp/.h) 测试代码公共源文件和头文件
 testbluetoothdiscovery.pro 测试代码项目工程文件
 testbluetoothdiscovery.ui 测试代码Qt designer文件

[edit] 已知问题

该范例中还存在着一些已知问题:

  • 本文编撰时标准Qt进度条不会动(Qt的bug)。
  • 在N95上,对话框的设备列表不会有焦点。

 

特定于平台的实现

虽然Qt提供了丰富的API集,当撰写本文时,Symbian开发伙伴们却还看不到调用蓝牙或红外的Qt APIs,看不到能访问照相机、位置传感器、加速度计、生物特征测量仪或其他传感器的Qt APIs,也没有能发送短信或彩信,或读写如日历或名片夹等用户数据的Qt APIs。Qt开发框架正致力于通过Qt Mobility项目为这些问题提供跨平台的APIs。但如果你现在希望使用这些功能,也许需要Qt和Symbian C++的混合编程。毫无疑问今后出现的其它功能也会出现同样问题。

对于Qt未能提供所需API的场合,建议创建一些具有特定私有平台独立实现的公共的Qt APIs,从而用于访问平台特定功能。这一方案使大家能比较方便地采用友好的Qt编程语法来编写大量的代码,同时仍然能方便地进行平台移植。事实上,这正是Qt用来对底层操作系统进行抽象的解决方案,它使开发者无需关心每一个平台的底层编程语法、APIs、安装机制、构造系统和其它各种限制。

有许多种设计模式可用于构建你的代码。在下一节中,我们将讨论Pimpl (pointer to implementation) 惯用法,其中特定于(私有)平台的一些实现被隐藏于公共API的某个指针身后。这项技术又有一些变化/替代名,包括:“Handle/Body”, “Compiler Firewall”, “the Bridge”, “Opaque Pointers”,及“Cheshire Cat”。

也可以使用其他一些模式或技术。例如,大部分的QPixmap API是以某种通用的源文件实现的,但QPixmap::grabWindow()却以一些平台特定的源文件实现。这个方案是可接受的,只要这种平台特定实现不“泄漏”到Qt API中。

此外,#平台特定方法小节也介绍了一些展露某些平台特定细节信息的用例。

[edit] “指针到实现”模式

[edit] 概述
图 6: PIMPL类图

PIMPL是Handle-Body模式的一个变形,在其中的公共API中含有一个指向其私有实现类的指针。这个指向私有实现的指针在头文件中被前向声明(而非#included)因此对该公共API为非透明。Pimpl一词由Herb Sutter在Pimpls - Beauty Marks You Can Depend OnThe Joy of Pimpls (或, More About the Compiler-Firewall Idiom)中提出。

如果这个私有类需要调用位于公共类中的方法,我们就把对该公共类的一个指针/引用传递给私有类的构造函数。如果它需要调用公共类的某些私有方法(如为了发出信号)我们也可将其做成公共类的类。图6中的类图展示了它们之间的关系,说明了拥有一个私有实现类QMyClassPrivate的公共类QMyClass

该公共类的实现负责构造(并销毁)那个私有实现。为达此目的它需要知道QMyClassPrivate的定义,所以它基于平台定义来#include针对当前平台的头文件,如Q_OS_SYMBIAN(出自qglobal.h):

//qmyclass.cpp
...
#ifdef Q_OS_SYMBIAN
#include "qmyclass_symbian.h" //Symbian definition of private class
#else
#include "qmyclass_stub.h" //Stub for all other platforms
#endif
...

注意:在项目工程文件中的平台特定代码段,相关私有类头文件和源文件需要明确指定,请见下面的#工程文件一节。

私有类的定义和实现几乎完全由开发者自己决定。唯一的硬性限制就是:所有平台都必须使用同样的名字(否则我们就需要在公共头文件中为每一个平台前向定义或友元声明私有类)。典型地,私有类还具有一些相同的函数,这使我们能实现非平台绑定的公共类:

// Implementation of a public class slot
void QMyClass::mySlots()
{
d_ptr->mySlots();
}

私有类可以具有任意的继承关系。

{{Note|虽然各种私有类常常继承自QObject ,但并非一定必需(如果你希望你的实现具备信号和槽,这也是有用的)。要注意的是,Qt代码行经常会将QObjectPrivate用于私有实现。由于这并非公共API的一部分,第三方应尽力避免。

关于Symbian私有实现有两种基本的类设计,分别如图7和图8所示。在第一种方案中,私有实现类是一个Symbian类,继承自{{Icode|CBase。而在第二种方案中,私有类使用了一个或多个能回调该私有类的Symbian类。

如用第一种机制,开发者投入的精力会较少些,因为你无需额外间接创建/调用Symbian类,也无需获取回调类的完成通知。然而这种方案却产生了比较复杂的对象结构(理由请见下文) ,而且更难以在概念级别上对Qt和Symbian C++代码进行分离。下面几节中讲解的范例代码就用到了这种方案。

使用第二种方案往往更好。它导致两种编程语法的清晰分离。针对私有类实现需要使用一些Symbian类的情况,这同样也是较好的解决方案。#回调APIs 一节详细讲解了第二种方法。

[edit] 蓝牙范例公共API
图 9: BluetoothDiscovery 类图

公共BluetoothDiscovery类如下所示。它具有一个指向私有实现友元类BluetoothDiscoveryPrivate的指针。

类图(图9) 展示了既针对Symbian实现的私有类,也展示了针对其他平台stub实现的私有类。这些私有类共享同样的名字,并具有该公共类中槽和方法的超集。请注意,它们并不重新实现公共类中的信号,因为这些都由工具链解释实现,私有类要做的是:通过其构造时所获得的指针来调用公共类的emit

//Forward declarations
class BluetoothDiscoveryPrivate;

class BluetoothDiscovery: public QObject
{
Q_OBJECT
public: //enums
enum BluetoothDiscoveryErrors
{ BluetoothNotSupported, BluetoothInUse, BluetoothAlreadyStopped,
BluetoothNotReady, DiscoveryCancelled, UnknownError };
public:
BluetoothDiscovery(QObject *parent = 0);
virtual ~BluetoothDiscovery();

public slots:
void startSearch();
void stopSearch();

signals:
void newDevice(const QBluetoothRemoteDevice remoteDevice);
void discoveryStopped();
void discoveryStarted();
void error(BluetoothDiscovery::BluetoothDiscoveryErrors error);

private: // Data
BluetoothDiscoveryPrivate *d_ptr; //private implementation

private: // Friend class definitions
friend class BluetoothDiscoveryPrivate;
};
[edit] 工程文件

源文件针对平台的特定编译通过工程文件得到控制。

公共类头文件和源文件都在一般描述段中指定。平台特定头文件/源文件则在平台特定代码块中指定,如下所示。

...
HEADERS += qbluetoothaddressdata.h # public header
SOURCES += bluetoothdiscovery.cpp # public class implementation
...

symbian {
...
HEADERS += bluetoothdiscovery_symbian_p.h # Symbian private class header
SOURCES += bluetoothdiscovery_symbian_p.cpp # Symbian private class source code
LIBS += -lesock /
-lbluetooth
TARGET.CAPABILITY = LocalServices /
NetworkServices /
ReadUserData /
UserEnvironment /
WriteUserData
}
else {
HEADERS += bluetoothdiscovery_stub_p.h //private class declaration for other platforms
SOURCES += bluetoothdiscovery_stub_p.cpp //private class source for other platforms
}

平台特定代码块列出了代码所要链接的那些平台库,在本例中就是esock.dllbluetooth.dll

各种Symbian实现必须规定可执行代码所需要的capabilities(更多信息请参阅:平台安全(Symbian C++基础)一节)。本例中我们仅指定能授予自签名应用的那些capabilities。

[edit] 公共类实现

公共类实现需要私有类头文件,以便构造私有类。我们根据当前平台有条件地导入头文件:

//bluetoothdiscovery.cpp
...
#ifdef Q_OS_SYMBIAN
#include "bluetoothdiscovery_symbian_p.h" //Symbian definition of BluetoothDiscoveryPrivate
#else
#include "bluetoothdiscovery_stub_p.h" //Stub for all other platforms
#endif
[edit] 构造

公共类在其构造器中创建私有实现类的一个实例,如下所示:

BluetoothDiscovery::BluetoothDiscovery(QObject *parent)
: QObject(parent)
{
#ifdef Q_OS_SYMBIAN //Symbian specific compilation
QT_TRAP_THROWING(d_ptr = BluetoothDiscoveryPrivate::NewL(this));
#else
d_ptr = new BluetoothDiscoveryPrivate(this);
#endif
}

请注意,我们在公共类实现中已选择使用平台特定构造方案,这是因为私有类是一个继承自Symbian CBase的类。这是可以的,因为Symbian类实现在这个公共API中不可见。然而,一般说来,我们还是推荐将尽可能多的平台特定实现放入私有类中。

NewL()是标准的Symbian静态工厂类,用于创建BluetoothDiscoveryPrivate 型对象,它确保该对象被成功分配创建,或者当出现异常时能被适当地清除掉。由于它会出现异常,我们将其封装到一个QT_TRAP_THROWING中以便将Symbian异常转换成一个Qt异常抛出。#异常和出错处理一节中讲解了如何处理Symbian和Qt代码间的异常。

如果你用一个继承自CBase的类来实现这个私有类,那么构造过程就比较简单,而且可以具有一个通用的公共类实现。

//Public class
BluetoothDiscovery::BluetoothDiscovery(QObject *parent)
: QObject(parent)
{
d_ptr = new BluetoothDiscoveryPrivate(this);
}

//Private class
BluetoothDiscoveryPrivate::BluetoothDiscoveryPrivate(QObject *parent)
: d_ptr(parent)
{
QT_TRAP_THROWING(symbianMember = CBluetoothDiscovery::NewL(this));
}

也可以用其他方法来分配创建Symbian对象。不管使用哪种方法,重要的是记住:

  1. Symbian C类重载了继承自CBase的new方法,new方法并不会抛出异常。因此如果采用new方法构造,就需要使用q_check_ptr来检查这个指针(并当其值为Null时抛出)。
  2. 确保当构造失败时该对象能被恰当地清除。

如果某个继承自CBase的对象在使用前无需初始化,上述第一点适用。在这种情况下,你可以使用new构造,但是你必须检查指针并当其值为NULL时异常抛出。

第二点很重要,因为它并不总是显而易见的,特别当使用Qt和Symbian C++进行混合编程时,需要考虑是否能恰当地删除对象。请看下列代码:

BluetoothDiscovery::BluetoothDiscovery(QObject *parent)
: QObject(parent)
{
d_ptr = q_check_ptr(new BluetoothDiscoveryPrivate(this)); //from Qt 4.6
#ifdef Q_OS_SYMBIAN
QT_TRAP_THROWING(d_ptr->ConstructL());
#endif
}

如果ConstructL()出现异常,QT_TRAP_THROWING这个宏就会抛出异常。按照一般C++的规则,BluetoothDiscovery分配的内存被释放,但是不会调用其析构函数。因而,BluetoothDiscoveryPrivate 中任何被部分分配资源的对象都会造成内存泄漏。为此,应将d_ptr 设置成一个智能指针( QScopedPointer)。

该公共类必须在其析构函数中删除掉这个私有实现对象。

[edit] 方法

我们在私有类中复制了公共API接口PI。

一些公共方法通过这个指针到实现调用私有类的对等方法,即:

// Called to start searching for new devices
void BluetoothDiscovery::startSearch()
{
d_ptr->startSearch();
}

请注意,我们并未隔离Qt和Symbian C++异常处理机制。这是因为,我们从私有类实现中了解到,startSearch()并不会出现Leave异常。这个方法会抛出Qt异常,这是可接受的,因为从来不会从私有类的Symbian代码中调用它。

[edit] 私有平台实现
[edit] 类声明

下面是Symbian平台的BluetoothDiscoveryPrivate实现。这是一个Symbian活动对象。由于这是一个Symbian C++类,遵循#Symbian 编码标准就特别重要:

  • 使用Symbian机制构造
  • 如果使用多重继承,首先从继承自CBase 的类继承,唯一例外则是继承Mixin(接口)类。
Warning.png
Warning: 切勿同时继承 CBaseQObject ,因为用于构造的 new并不确定。
class BluetoothDiscoveryPrivate : public CActive
{
public:
//static factory "constructor" function
static BluetoothDiscoveryPrivate* NewL(BluetoothDiscovery *aPublicAPI = 0);

~BluetoothDiscoveryPrivate(); //Destructor

public:
void startSearch(); //Called to start searching for new devices
void stopSearch(); //Called to stop searching

public: //From CActive
virtual void DoCancel(); //Implements cancellation of an outstanding request.
void RunL(); //Handles an active object's request completion event.

private:
BluetoothDiscoveryPrivate(BluetoothDiscovery *aPublicAPI = 0); //constructor
void ConstructL(); //Second phase constructor - connects to socket server and finds protocol

//Error translator - converts global errors into local format then emits to parent object
void ErrorConvertToLocalL(int err);

private: // Data

RSocketServ iSocketServ; //Socket server connection
RHostResolver iHostResolver; //Host resolver
TNameEntry iCurrentDeviceEntry; //The entry of the device just returned.
TInquirySockAddr iInqSockAddr;
TProtocolDesc iProtocolInfo;

BluetoothDiscovery *iPublicBluetoothDiscovery; //pointer to parent object (from constructor).
};

这个类拥有指向其父类BluetoothDiscovery 的指针,当活动对象完成或出错时,该指针会向Qt客户端发送信号。

这个私有类复制了公共类的API,减少了公共类实现中的状态编码。另外,它还重新实现了CActive类的虚拟方法RunL()DoCancel() ,用于处理该活动对象操作的完成和取消。

这个类具有一些私有数据成员,用来从Symbian的Bluetooth.dll获取远程蓝牙设备的名称和id。iHostResolver 就是包含异步请求函数的对象。

[edit] 构造

因为这个私有实现是一个Symbian类,如前所述,我们使用两步构造的方法。

公共静态NewL()工厂函数使用一个会leave的构造函数来创建这个对象,将其推入清除堆栈,并使用会抛出异常的第二阶段构造函数对其作初始化,最后将其从清除堆栈中弹出并将返回给用户。

BluetoothDiscoveryPrivate* BluetoothDiscoveryPrivate::NewL(BluetoothDiscovery *wrapper)
{
BluetoothDiscoveryPrivate* self = new (ELeave) BluetoothDiscoveryPrivate(wrapper);
// push onto cleanup stack in case self->ConstructL leaves
CleanupStack::PushL(self);
// complete construction with second phase constructor
self->ConstructL();
CleanupStack::Pop(self);
return self;
}

作为构造的一部分,我们也向这个私有对象提供一个指向公共类对象的句柄 – 此处即一个指针,但也可以是一个引用。该指针可用于直接调用公共类的一些方法,或发出一些公共类信号。

[edit] 方法

私有类方法的实现依赖于所需实现的功能。总体上,最需关注的重要事情是:平台异常处理系统间的正确交互,如异常处理节所述。

也请注意void ErrorConvertToLocalL(int err);的使用,它将Symiban的全局出错信息转换为针对Qt的本地出错信息。

#将活动对象转换为信号和槽一节以实例讨论了RunL()DoCancel()方法。

[edit] 平台特定方法

在一切可能场合,公共APIs都应避免平台特定成员和函数。这一规则极少会有例外!

偶尔,平台特定的帮助器(helper)函数会被设为公共的,不然的话开发伙伴就需要有其自己的方法。比如,QPixmap就提供了能对CFbsBitmap做双向转换的帮助器函数:

与之相似的是,有时候必须公开一些平台特定类型,并公开地包含一些平台头文件,以便将它们映射到通用的typedefs(类型定义)中。你可以参阅QProcess中的相关内容,在此,Q_PIDtypedef为Symbian平台上的TProcessId

在所有情况下,平台特定的相关声明都必须被定义为仅在特定平台内可见。比如,在QPixmap声明中:

#if defined(Q_OS_SYMBIAN)
CFbsBitmap *toSymbianCFbsBitmap() const;
static QPixmap fromSymbianCFbsBitmap(CFbsBitmap *bitmap);
#endif

[edit] 文件和类的命名惯例

通常,如果公共类名为QMyClass ,那么:

  • 私有类就被定义为QMyClassPrivate
  • 公共类的源文件和头文件共享公共类名:qmyclass.h,qmyclass.cpp
  • 私有类的头文件和源文件名以_p 结尾(比如qmyclass_p.h),除非该文件是一个平台特定实现。
  • 平台特定实现的头文件和源文件名中包括平台名 – 如qmyclass_symbian.cpp (不必在结尾处添加_p ,因为已经暗示)。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值