【说在前面的话】
“为什么要使用C语言来实现面向对象开发?” “直接用C++不就好了么?”
想必很多人在第一次面对 OOPC(Object-Oriented-Programming-with-ANSI-C)的时候,都会情不自禁的发出类似的疑问。其实,任何针对上述问题的讨论,其本身都是充满争议的——换句话说,无论我给出怎样的答案,都无法令所有人满意——正因如此,本文也无意去趟这摊浑水。 我写这篇文章的目的是为那些长期在MDK环境下从事C语言开发的朋友介绍一种方法:帮助大家在偶尔需要用到“面向对象”概念的时候,能简便快捷的使用C语言“搞定”面向对象开发。 在开始后续内容之前,我们需要约定和强调一些基本原则:
-
“零消耗”原则:即,我们所要实现的所有面向对象的特性都应该是“零资源消耗”或至少是“极小资源消耗”。这里的原理是:能在编译时刻(Compiletime)搞定的事情,绝不拖到运行时刻(Runtime)。
-
务实原则:即,我们不在形式上追求与C++类似,除非它的代价是零或者非常小。
-
“按需实现”原则:即,对任何类的实现来说,我们并不追求把所有的OO特性都实现出来——这完全没有必要——我们仅根据实际应用的需求来实现最小的、必要的面向对象技术。
-
“傻瓜化”原则:即,类的建立和使用都必须足够傻瓜化。最好所见即所得。
首先,我们要下载 PLOOC的 CMSIS-Pack,具体链接如下:
https://raw.githubusercontent.com/GorgonMeducer/PLOOC/master/cmsis-pack/GorgonMeducer.PLOOC.4.6.0.pack
当然,如果你因为某些原因无法访问Github,也可以在关注【裸机思维】公众号后发送关键字 “PLOOC” 来获取网盘链接。 下载成功后,直接双击安装包即可。
一般来说,部署会非常顺利,但如果出现了安装错误,比如下面这种:
则很可能是您所使用的MDK版本太低导致的——是时候更新下MDK啦。关注【裸机思维】公众号后发送关键字"MDK",即可获得最新的MDK网盘链接。
PLOOC 是 Protected-Low-overhead-Object-Oriented-programming-with-ansi-C 的缩写,顾名思义,是一个强调地资源消耗且为私有类成员提供保护的一个面向对象模板。 它是一个开源项目,如果你喜欢,还请多多Star哦! https://github.com/GorgonMeducer/PLOOC
【如何快速尝鲜】
为了简化用户对 OOC 的学习成本,PLOOC提供了一个无需任何硬件就可以直接仿真执行的例子工程。该例子工程以队列类为例子,展示了:
-
类的定义方式
-
如何实现类的方法(Method)
-
如何为类定义接口(Interface)
-
如何定义派生类
-
如何重载接口
-
如何在派生类中访问基类受保护的成员(Protected Member)
-
……
很多时候千言万语敌不过代码几行——学习OOC确是如此。 例子工程的获取非常简单。首先打开 Pack-Installer,在Device列表中找到Arm,选择任意一款Cortex-M内核(比如 Arm Cortex-M3)。在列表中选择ARMCMx(比如下图中的ARMCM3)。
此时,在右边的Example选项卡中,就可以看到最底部出现了一个名为 plooc_example (uVision Simulator)的例子工程。单击Copy,在弹出窗口中选择一个目录位置来保存工程:
单击OK后将打开自动打开如下所示的 MDK 界面:
直接单击编译,如果一切顺利,应该没有任何编译错误:
此时,我们可以直接进入调试模式:
可以看到,调试指针停在了 main() 函数的起始位置。我们先不着急开始全速运行。通过菜单打开 "Debug (printf) Viewer" 窗口:
一开始该窗口会出现在屏幕下方的窗体中,通过拖动的方式,我们可以将其挪到醒目的位置。此时,全速运行就可以看到例子工程所要展示的效果了:
该例子只展示了C99模式下使用PLOOC所构建的队列类(enhanced_byte_queue_t)的效果:
其输出为:static enhanced_byte_queue_t s_tQueue;
printf("Hello PLOOC! ");
do {
static uint8_t s_chQueueBuffer[QUEUE_BUFFER_SIZE];
const enhanced_byte_queue_cfg_t tCFG = {
s_chQueueBuffer,
sizeof(s_chQueueBuffer),
};
ENHANCED_BYTE_QUEUE.Init(&s_tQueue, (enhanced_byte_queue_cfg_t *)&tCFG);
} while(0);
//! you can enqueue
ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'p');
ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'L');
ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'O');
ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'O');
ENHANCED_BYTE_QUEUE.Enqueue(&s_tQueue, 'C');
ENHANCED_BYTE_QUEUE.use_as__i_byte_queue_t.Enqueue(&s_tQueue.use_as__byte_queue_t, '.');
ENHANCED_BYTE_QUEUE.use_as__i_byte_queue_t.Enqueue(&s_tQueue.use_as__byte_queue_t, '.');
ENHANCED_BYTE_QUEUE.use_as__i_byte_queue_t.Enqueue(&s_tQueue.use_as__byte_queue_t, '.');
//! you can dequeue
do {
uint_fast16_t n = ENHANCED_BYTE_QUEUE.Count(&s_tQueue);
uint8_t chByte;
printf("There are %d byte in the queue! ", n);
printf("let's peek! ");
while(ENHANCED_BYTE_QUEUE.Peek.PeekByte(&s_tQueue, &chByte)) {
printf("%c ", chByte);
}
printf("There are %d byte(s) in the queue! ",
ENHANCED_BYTE_QUEUE.Count(&s_tQueue));
printf("Let's remove all peeked byte(s) from queue... ");
ENHANCED_BYTE_QUEUE.Peek.GetAllPeeked(&s_tQueue);
printf("Now there are %d byte(s) in the queue! ",
ENHANCED_BYTE_QUEUE.Count(&s_tQueue));
} while(0);
类 enhanced_byte_queue_t 实际上是从基类 byte_queue_t 基础上派生出来的,并添加了一个非常有用的功能:可以连续的偷看(Peek)队列里的内容,并可以在需要的时候,要么1)将已经偷看的内容实际都取出来;要么2)从头开始偷看——上述代码就展示了这一功能。 PLOOC 相较普通的OOC模板来说,除了可以隐藏类的私有成员(private member)以外,还能够以零运行时成本实现多肽(Polymorphism)——用通俗的话说就是:PLOOC允许拥有不同参数数量、不同参数类型的多个函数拥有相同的名字。 要获得这样的功能,就要打开 C11(最好是GNU11)的支持。当我们打开工程配置,在“C/C++”选项卡中将 Language C 设置为 c11(最好是gnu11):
重新编译后,进入调试模式,将在输出窗口中看到额外的信息:
这些信息实际上对应如下的代码:
你看,同一个函数 LOG_OUT() 当我们给它不同数量和类型的参数时,居然可以实现不同的输出效果,是不是特别神奇——这就是面向对象开发中多态的魅力所在。请记住:LOG_OUT(" -[Demo of overload]------------------------------ ");
LOG_OUT((uint32_t) 0x12345678);
LOG_OUT(" ");
LOG_OUT(0x12345678);
LOG_OUT(" ");
LOG_OUT("PI is ");
LOG_OUT(3.1415926f);
LOG_OUT(" ");
LOG_OUT(" Show BYTE Array: ");
LOG_OUT((uint8_t *)main, 100);
LOG_OUT(" Show Half-WORD Array: ");
LOG_OUT((uint16_t *)(((intptr_t)&main) & ~0x1), 100/sizeof(uint16_t));
LOG_OUT(" Show WORD Array: ");
LOG_OUT((uint32_t *)(((intptr_t)&main) & ~0x3), 100/sizeof(uint32_t));
-
此时我们仍然使用的是C语言,而不是C++;
-
在C99下,我们可以实现拥有不同参数个数的函数共享同一个名字;
-
在C11下,我们可以实现拥有相同参数个数但类型不同的函数共享同一个名字;
-
我们在运行时刻的开销是0,一切在编译时刻就已经尘埃落定了。我们并没有为这项特性牺牲任何代码空间。
PLOOC 模板其实是一套头文件,既没有库(lib)也没有C语言源代码,更别提汇编了。 在任意的MDK工程中,只要你已经安装了此前我们提到过的CMSIS-Pack,就可以通过下述工具栏中标注的按钮,打开RTE配置界面:
找到 Language Extension选项,将其展开后勾选PLOOC,单击OK关闭窗口。
此时,我们就可以在工程管理器中看到一个新的列表项“Language Extension”:
它是不可展开的,别担心,这就足够了。打开工程配置,如果你使用的是 Arm Compiler 6(armclang):
则我们需要在 C/C++选项中:
-
将Language C设置为 gnu11(或者最低c99):
-
(推荐,而不是必须)在Misc Controls中添加对微软扩展的支持,并在 Define中添加一个宏定义 _MSC_VER。
-fms-extensions
如果你使用的是 Arm Compiler 5(armcc):
则需要在 C/C++ 选项卡中开启对 GNU Extension 和 C99的支持:
遗憾的是作为一款已经停止更新的编译器,Arm Compiler 5 既不支持C11,也不支持微软扩展(-fms-extensions),这意味着PLOOC中的多态特性无法发挥最大潜能,着实有点遗憾(但拥有不同参数数量的函数还是允许共享同一个名称的)。 至此,我们就完成了PLOOC在一个工程中的部署。是不是特别简单? 也许文章到了一半我才问,已经有点迟了——大家都熟悉基本的面向对象概念吧?比如:
-
类(class)
-
私有成员(private member)
-
公共成员(public member)
-
保护成员(protected member)
-
构造函数(constructor)
-
析构函数(destructor)
-
类的方法(method)
-
……
假设我们要创造一个新的类,叫做 my_class1
第一步:引入模板
在工程管理器中,添加一个新的group,命名为 my_class1:
右键单击 my_class1,并在弹出的菜单中选择 "Add New Item to Group my_class1":
在弹出的对话框中选择 User Code Template:
展开 Language Extension,可以看到有两个 PLOOC模板,分别对应:
- 基类和普通类(Base Class Template)
- 派生类(Derived Class Template)
由于我们要创建的是一个普通类(未来也可以作为基类),因此选择“Base Class Template”。单击Location右边的 "..." 按钮,选择一个保存代码文件的路径后,单击“Add”。
此时我们可以看到,class_name.c 被添加到了 my_class1中,且MDK自动在编辑器中为我们打开了两个模板文件:class_name.h和class_name.c。
第二步:格式化
在编辑器中打开或者选中 class_name.c。通过快捷键CTRL+H打开 替换窗口:
- 在Look in中选择Current Document
- 去掉Find Opitons属性框中的 Match whold word前的勾选(这一步骤很重要)
接下来,依次:
-
将小写的
-
将大写的
替换为 MY_CLASS1
-
将小写的
-
将大写的
替换为 MY_CLASS1
在工程管理器中展开 my_class1,并将其中的 class_name.c 删除:
打开class_name.c 所在文件目录:
找到我们刚刚编辑好的两个文件 class_name.c 和 class_name.h:
用我们的类为这两个文件命名:my_class1.c 和 my_class1.h
在MDK工程管理器中,将这两个文件加入 my_class1 下:
如果此前你的工程就是可以正常编译的话,在加入了上述文件后,应该依然可以正常编译:
第四步:如何设计你的类成员变量
打开 my_class1.h,找到 def_class 所在的代码片断:
//! ame class my_class1_t
//! @{
declare_class(my_class1_t)
def_class(my_class1_t,
public_member(
//!< place your public members here
)
private_member(
//!< place your private members here
)
protected_member(
//!< place your private members here
)
)
end_def_class(my_class1_t) /* do not remove this for forward compatibility */
//! @}
很容易注意到:
-
类所对应的类型会自动在尾部添加 "_t" 以表示这是一个自定义类型,当然这不是强制的,当你熟悉模板后,如果确实看它不顺眼,可以改成任何自己喜欢的类型名称。这里,由于我们的类叫做 my_class1,因此对应的类型就是 my_class1_t。
-
declare_class(或者也可以写成 dcl_class)用于类型的“前置声明”,它的本质就是
typedef struct my_class1_t my_class1_t;
因此并没有什么特别神秘的地方。
-
def_class用于定义类的成员。其中 public_member用于存放公共可见的成员变量;private_member用于存放私有成员;protected_member用于存放当前类以及派生类可见的成员。这三者的顺序任意,可以缺失,也可以存在多个——非常灵活。
第四步:如何设计构造函数
找到 typedef struct my_class1_cfg_t 对应的代码块:
typedef struct my_class1_cfg_t {
//! put your configuration members here
} my_class1_cfg_t;
可以看到,这是个平平无奇的结构体。它用于向我们的构造函数传递初始化类时所需的参数。在类的头文件中,你很容易找到构造函数的函数原型:
/*! rief the constructor of the class: my_class1 */
extern
my_class1_t * my_class1_init(my_class1_t *ptObj, my_class1_cfg_t *ptCFG);
可以看到,其第一个参数是指向类实例的指针,而第二个参数就是我们的配置结构体。在类的C源代码文件中,可以找到构造函数的实体:
/*! rief the constructor of the class: my_class1 */
my_class1_t * my_class1_init(my_class1_t *ptObj, my_class1_cfg_t *ptCFG)
{
/* initialise "this" (i.e. ptThis) to access class members */
class_internal(ptObj, ptThis, my_class1_t);
ASSERT(NULL != ptObj && NULL != ptCFG);
return ptObj;
}
此时,在构造函数中,我们可以通过 this.xxxx 的方式来访问类的成员,以便根据配置结构体中传进来的内容对类进行初始化。
也许你已经注意到了,我们的模板中并没有任何为类申请空间的代码。这是有意为之。原因如下:
-
面向对象并非一定要使用动态内存分配,这是一种偏见
-
我们只提供构造函数,而类的用户可以自由的决定如何为类的实例分配存储空间。
-
由于我们创造的类(比如 my_class1_t)本质上是一个完整的结构体类型,因此可以由用户像普通结构体那样:
-
进行静态分配:即定义静态变量,或是全局变量
-
使用池分配:直接为目标类构建一个专用池,有效避免碎片化。
-
进行堆分配:使用普通的malloc()进行分配,类的大小可以通过sizeof() 获得,比如:
-
my_class1_cfg_t tCFG = {
...
};
my_class1_t *ptNewItem = my_class1_init(
(my_class1_t *)malloc(sizeof(my_class1_t),
&tCFG);
if (NULL == ptNewItem) {
printf("Failed to new my_class1_t ");
}
...
free(ptNewItem);
当然,如果你说我就是要那种形式主义,那你完全可以定义一个宏:
({__name
__VA_ARGS__
};
__name
(__name
&tCFG);})
这可不就是一个根正苗红的 new()方法么,比如:
my_class1_t *ptItem = new_class(my_class, <构造用的参数列表>);
if (NULL == ptItem) {
printf("Failed to new my_class1_t ");
}
...
free(ptItem);
怎么样,是这个味道吧?析构函数类似,比如my_class1_depose()函数,同样不负责资源的释放——决定权还是在用户的手里,当然你也可以做完一套:
do {
__name
free(__obj);
} while(0)
形成组合拳,从分配资源、构造、析构到最后释放资源一气呵成:
my_class1_t *ptItem = new_class(my_class, <构造用的参数列表>);
if (NULL == ptItem) {
printf("Failed to new my_class1_t ");
}
...
free_class(my_class, ptItem);
第五步:如何设计构类的方法(method)
我们开篇说过,实践面向对象最重要的是功能,而非形式主义。假设有一个类的方法叫做 method1,理想中,大家一定觉得如下的使用方式是最“正统”的:在C语言中,我们完全可以实现类似的效果——只要你在类的定义中加入函数指针就行了——其实很多OOC的模板都是这么做的(比如lw_oopc)。但你仔细思考一下,在类的结构体中加入函数指针究竟有何利弊: 先来说好处:my_class1_t *ptItem = new_class(my_class, <构造用的参数列表>);
if (NULL == ptItem) {
printf("Failed to new my_class1_t ");
}
ptItem.method1(<实参列表>);
free_class(my_class, ptItem);
-
可以用“优雅”的方式来完成方法的调用;
-
支持运行时刻的重载(Override);
再来说缺点:
-
在嵌入式应用中,大部分类的方法都不需要重载,更别说是运行时刻的重载了;
-
函数指针会占用4个字节;
-
通过函数指针来实现的间接调用,其效率低于普通的函数直接调用。
这里,my_class1_method1() 是 my_class1.h 提供声明、my_class1.c 提供实现的一个函数。前缀 my_class1_ 用于防止命名空间污染。 另外一个值得注意的细节是,OOPC中,任何类的方法,其函数的第一个参数一定是指向类实例的指针——也就是我们常说的 this 指针。以 my_class1_method1() 为例,它的形式为:my_class1_t *ptItem = new_class(my_class, <构造用的参数列表>);
if (NULL == ptItem) {
printf("Failed to new my_class1_t ");
}
my_class1_method1(ptItem,<实参列表>);
free_class(my_class, ptItem);
这里,class_internal() 用于将 ptObj转变成我们所需的 this指针(这里的ptThis),借助宏的帮助,我们就可以实现 this.xxxx 这样无成本的形式主义了。 第六步:如何设计类的接口(Interface)#undef this
#define this (*ptThis)
void my_class1_method(my_class1_t *ptObj, <形参列表>)
{
/* initialise "this" (i.e. ptThis) to access class members */
class_internal(ptObj, ptThis, my_class1_t);
...
}
我们的模板还为每个类都提供了一个接口,并默认将构造和析构函数都包含在内,比如,我们可以较为优雅的对类进行构造和析构:
在 my_class1.h 中,我们可以找到这样的结构:static my_class1_t s_tMyClass;
...
MY_CLASS.Init(&s_tMyClass, ...);
...
MY_CLASS.Depose(&s_tMyClass);
假设我们要加入一个新的方法,则只需要在 i_my_class1_t 的接口定义中添加对应的函数指针即可,比如://! ame interface i_my_class1_t
//! @{
def_interface(i_my_class1_t)
my_class1_t * (*Init) (my_class1_t *ptObj, my_class1_cfg_t *ptCFG);
void (*Depose) (my_class1_t *ptObj);
/* other methods */
end_def_interface(i_my_class1_t) /*do not remove this for forward compatibility */
//! @}
接下来,我们要在 my_class1.h 中添加对应方法的函数声明://! ame interface i_my_class1_t
//! @{
def_interface(i_my_class1_t)
my_class1_t * (*Init) (my_class1_t *ptObj, my_class1_cfg_t *ptCFG);
void (*Depose) (my_class1_t *ptObj);
/* other methods */
void (*Method1) (my_class1_t *ptObj, <形参列表>);
end_def_interface(i_my_class1_t) /*do not remove this for forward compatibility */
//! @}
这里,值得注意的是,习惯上函数的命名上与接口除大小写歪,还有一个简单的对应关系:即,所有的"."直接替换成"_",比如,使用上:extern
void my_class1_method1(my_class1_t *ptObj, <形参列表>);
MY_CLASS1.Method1()
就对应为:
my_class1_method1()
与此同时,我们需要在 my_class1.c 中添加 my_class1_method1() 函数的实体:并找到名为 MY_CLASS1 的接口实例:void my_class1_method1(my_class1_t *ptObj, <形参列表>)
{
class_internal(ptObj, ptThis, my_class1_t);
...
}
在其中初始化我们的新方法(新函数指针) Method1:const i_my_class1_t MY_CLASS1 = {
.Init = &my_class1_init,
.Depose = &my_class1_depose,
/* other methods */
};
至此,我们就完成了类方法的添加和初始化。以后,在任何地方,都可以通过const i_my_class1_t MY_CLASS1 = {
.Init = &my_class1_init,
.Depose = &my_class1_depose,
/* other methods */
.Method1 = &my_class1_method1,
};
<类名大写>.<接口中方法名>()
的形式来访问类的操作函数了——这也算某种程度上的优雅了吧。
第六步:如何设计派生类(Derived Class)派生类的创建在基本步骤上与普通类基本一致,除了在模板选择阶段使用对应的模板外,还需要在“格式化”阶段额外添加以下两个替换步骤:
-
将
替换为 基类的大写名称; -
将
替换为基类的小写名称;
在类的定义阶段,我们注意到:
派生类在原有的类定义基础上多出了的结构,以"," 与类的类型名隔开://! ame class
_t //! @{
declare_class(
_t) def_class(
_t, which(implement( _t)) ...
)
end_def_class(
_t) /* do not remove this for forward compatibility */ //! @}
which(implement(<base_class_name>_t))
这里,which() 其实是一个列表,它允许我们实现多重继承。假设我们有多个基类,或是要继承多个接口,则可以写成如下的形式:需要注意的是,如果基类或是接口中存在名称冲突(重名)的成员,则可以将 implement() 替换为 inherit() 来避免这种冲突。比如which(
implement(
_t) implement(
_t) implement(
_t) implement(
_t) )
就像这里所展示的那样,PLOOC支持多继承,这是C++和C#都不曾支持的——这也是 使用C语言来实现OO的魅力之一,具体方法,大家可以自行摸索,这里就不再赘述。which(
inherit(
_t) implement(
_t) implement(
_t) implement(
_t) )
大家都知道,在面向对象中,有一类成员只有当前类和派生类能够访问——我们称之为受保护成员(protected member)。在类的定义中,可以通过 protected_member() 将这些成员囊括起来,比如:
这里,hwHead、hwTail和hwCount 都只有当前类和派生类能访问。 对于那些只允许派生类访问的方法(函数)来说,我们一般会使用预编译宏的形式将其有条件的保护起来://! ame class byte_queue_t
//! @{
declare_class(byte_queue_t)
def_class(byte_queue_t,
private_member(
implement(mem_t) //!< queue buffer
void *pTarget; //!< user target
)
protected_member(
uint16_t hwHead; //!< head pointer
uint16_t hwTail; //!< tail pointer
uint16_t hwCount; //!< byte count
)
)
end_def_class(byte_queue_t) /* do not remove this for forward compatibility */
//! @}
这里,受到宏 __BYTE_QUEUE_CLASS_IMPLEMENT 和 __BYTE_QUEUE_CLASS_INHERIT 的保护,函数 byte_queue_buffer_get() 仅能够允许类 byte_queue_t 自身极其派生类才能访问了。 在我们前面创建的 my_class1.h 中我们也有一个类似的例子:extern mem_t byte_queue_buffer_get(byte_queue_t *ptObj);
函数 my_class1_protected_method_example() 就是一个仅供 my_class1 极其派生类访问的 受保护的方法。 在派生类中,如果要访问基类的受保护成员,则可以借助 protected_internal() 的帮助,例如:/*! rief a method only visible for current class and derived class */
extern void my_class1_protected_method_example(my_class1_t *ptObj);
这里,派生类借助 this.use_as__byte_queue_t 获得了对基类的“引用”,并借助 protected_internal() 将其转化为了名为 ptBase 的指针。在 base 宏的帮助下,我们得以通过 base.xxxx 来访问基类的成员。在例子中,我们看到,base.hwTail 和 base.hwCount 正是前面所展示过的 byte_queue_t 的受保护成员。 【说在后面的话】
void enhanced_byte_queue_peek_reset(enhanced_byte_queue_t *ptObj)
{
/* initialise "this" (i.e. ptThis) to access class members */
class_internal(ptObj, ptThis, enhanced_byte_queue_t);
/* initialise "base" (i.e. ptBase) to access protected members */
protected_internal(&this.use_as__byte_queue_t, ptBase, byte_queue_t);
ASSERT(NULL != ptObj);
/* ------------------atomicity sensitive start---------------- */
this.hwPeek = base.hwTail;
this.hwPeekCount = base.hwCount;
/* ------------------atomicity sensitive end---------------- */
}
无论使用何种模板,OOPC来发的一个核心理念应该是“务实”,即:以最小的成本(最好是零成本),占最大的便宜(来自OO所带来的好处)。
此前,我曾经在文章《真刀真枪模块化(2.5)—— 君子协定》详细介绍过PLOOC的原理和手动部署技术。借助CMSIS-Pack和MDK中RTE的帮助,原本繁琐的手动部署和类的创建过程得到了空前的简化,使用OOPC进行开发从未如此简单过——几乎与直接使用C++相差无几了。
不知不觉间,从2年前第一次将其公开算起,PLOOC已经斩获了一百多个Star——算是我仓库中的明星工程了。从日志上来看,PLOOC相当稳定。距离我上一次“觉得其有必要更新”还是整整一年多前的事情,而加入CMSIS-Pack只是一件锦上添花的事情。
最后,感谢大家的支持——是你们的Star支撑着我一路对项目的持续更新。谢谢!
评论
查看更多