[推荐2]GObject Tutorial step by step 中文

只是部分引用,建议直接看原文,引用自:

http://www.zooyoo.org/?p=10275

 

GObject Tutorial

GObject Tutorial
Ryan McDougall(2004)

目的

这篇文档可用于两个目的:一是作为一篇学习Glib的GObject类型系统的教程,二是用作一篇按步骤的使用GObject类型系统的入门文章。文章从 如何用C语言来设计一个面相对想的类型系统开始,使用GObject作为假设的解决方案。这种介绍的方式可以更好的解释这个开发库为何采用这种形式来设 计,以及使用它为什么需要这些步骤。入门文章被安排在教程之后,使用了一种按步骤的,实际的,没有过多解释的组织形式,这样对于某些实际的程序员会更有用 些。

读者

这篇教程假想的读者是那些熟悉面向对象概念,但是刚开始接触GObject或者GTK+的开发人员。我会认为您已经了解一门面向对象的语言,和一些C语言的基本命令。

动机

使用一种根本不支持面向对象的语言来编写一个面向对象的系统 ,这让人听上去有些疯狂。然而我们的确有一些很好的理由来做这样的事情。 但我不会试着去证明作者决定的正确性,并且我认为读者自己就有一些使用GLib的好理由,这里我将指出这个系统的一些重要特性:

  • C是一门可移植性很强的语言
  • 一个完全动态的系统,新的类型可以在运行时被添加上

这样系统的可扩展性要远强于一门标准的语言,所以新的特性也可以被很快的加入进来。
对面向对象语言来说,面向对象的特性和能力是用语法来定义的。然而因为C并不支持面向对象,所以GObject系统必须要手动的将面向对象的能力移植过 来。一般来说要实现这个目标需要一些乏味的工作或者偶尔使用某些神奇的手段。我需要做的是枚举出所有必要的步骤或”咒语”,来使得程序执行起来,也希望能 说明这些步骤对您的程序意味着什么。

1. 创建一个非继承的对象
设计


在面向对象领域,对象包含两种类型成员:数据和方法,它们处于同一个对象引用之下。有一种办法可以使用C来实现对象,那就是C的结构体(struct),这样普通的公用成员可以是数据,方法则可以被实现为指向函数的指针。

然而这样的实现却存在着一些严重的缺陷:别扭的语法,类型安全问题,缺少封装。更实际的问题是 – 空间浪费严重。每个实例化后的对象需要一个4字节的指针来指向其每一个成员方法,而这些方法在类封装的范围内是完全相同的,所以这是完全冗余的。例如我们 有一个类需要有4个成员方法,一个程序实例化了1000个这个类的对象,这样我们就浪费了接近16KB的空间。

很明显我们只保留一张包含这些指针的表,以供从这个类实例化出来的对象调用要好的多,这样会节省下不少内存资源。
这样的表被称作虚方法表(vtable),GObject系统为每个类在内存中保存了一份。当你想调用一个虚方法时,你必须先向系统请求查找这个对象所对应的虚方法表,这张表如上所述只包含了一个由函数指针组成的结构体。这样你就能复引用这个指针,通过它来调用方法了。

我们称这两种类型为“对象结构体”和“类结构体”,并且将这两种结构体的实例分别称为“对象实例”和“类实例“。这两种结构体合并在一起形成的一个概念上的单元,称作“类”,对这个“类”的实例称作“对象”。
为什么将这样的函数称作“虚函数”的原因是调用它需要在运行时查找合适的函数指针,这样就能允许继承自它的类覆盖这个方法(只需要简单的更改虚函数表中的 函数指针指向相应函数入口即可)。这样子类在向上转型(upcast)为父类时就会正常工作,正如我们所知道的C++里的虚方法一样。

尽管这样做可以节省内存和实现虚方法,但从语法上来看,将成员方法与对象用“点操作符”关联起来的能力就不具备了。(译者:因为点操作符关联的将是struct里的方法,而不是vtable里的)。因此我们将使用如下的命名约定来声明类的成员方法:
NAMESPACE_TYPE_METHOD (OBJECT*, PARAMETERS)

非虚方法将被实现在一个普通的C函数里,虚方法也是实现在普通的C函数中,但不同的是这个函数将调用虚函数表中某个合适的方法。私有成员将被实现成只存活在源文件中,而不被导出声明在头文件中。

面向对象通常使用信息隐藏来作为封装的一部分,但在C中却没有简单的办法能隐藏私有成员。一种办法是将私有成员放到一个独立的结构体中,该 结构体只定义在源文件中,再向你的公有对象结构体中添加一个指向这个私有类的指针。然而,在开放源代码的世界里,这种保护对于用户执意要做这件错误的事情 是微不足道的。大部分的开发者也只是简单的写上几句注释,标明这些成员他们希望被保护为私有的,并且希望用户能尊重这种区别。
到现在为止我们有了两种不同的结构体,但我们没有一个简单的办法来通过一个实例化后的对象找到合适的虚方法表。如我们在上面暗指到的,这应该是系统的职 责,系统只需要要求我们向它注册上新声明的类型,就应该能够处理这个问题。系统同时要求我们去向它注册(对象的和类的)结构体构造和析构函数(以及其他的 重要信息),这样系统才能正确的实例化我们的对象。系统会通过枚举化所有的向它注册的类型来记录新的对象类型,并且要求所有实例化对象的第一个成员是一个指向它自己类的虚函数表的指针,每个虚函数表的第一个成员是它在系统中保存的枚举类型的数字表示。
类型系统要求所有类型的对象结构体和类结构体的第一个成员是一个特殊结构体。在对象结构体中,该特殊结构体是一个指向其类型的对象。因为C语言保 证在结构体中声明的第一个成员也是在内存中保存的第一个数据,因此这个类对象可以很容易的通过将这个对象结构体转型而获得到。又因为类型系统要求我们将被 继承的父结构体指针声明为子结构体的第一个成员,这样我们只需要在父类中声明一次这个特殊的结构体(译者:即那个指向其类型的对象),我们总是能够通过一 次转型而找到虚函数表。
最后我们需要一些函数来定义如何管理对象的生命期:创建类对象的函数,创建实例对象的函数,销毁类对象的函数。但不需要销毁实例对象的函数,因为实例对象的内存管理是一个比较复杂的问题,我们将把这个工作留给更高层的代码来处理。

代码(头文件)

a. 使用struct关键字来创建实例对象和类对象,实现“C风格”的对象
我们向结构体名字前添加了一个下划线,然后又增加了一个前置的类型定义typedef,用来给我们的结构体一个合适的名字。这是因为C的语法不允许你声明 SomeObject指针在SomeObject中(这对声明链表之类的数据结构很有用)。向上面的约定一节所描述的一样,我们还可以创建一个命名域,称 其为“Some“。


01. /* 我们的“实例结构体”定义了所有的数据域,这使得对象将是唯一的 */
02. typedef struct _SomeObject SomeObject;
03. struct _SomeObject
04. {
05. GTypeInstance   gtype;
06.  
07. gint        m_a;
08. gchar*      m_b;
09. gfloat      m_c;
10. };
11.  
12. /* 我们的“类结构体”定义了所有的方法函数,这是被实例化出来的对象所共享的 */
13. typedef struct _SomeObjectClass SomeObjectClass;
14. struct _SomeObjectClass
15. {
16. GTypeClass  gtypeclass;
17.  
18. void         (*method1)  (SomeObject *self, gint);
19. void         (*method2)  (SomeObject *self, gchar*);
20. };

b. 声明一个函数,该函数可以在第一次被调用时向系统注册上对象的类型,在此后的调用时就会返回系统记录下的我们声明的那个类型所对应的唯一数字了。这个函数 被成为”get_type”,返回值是”GType”类型,该类型实际上是一个系统用来区别已注册类型的整型数字。由于这个函数是SomeObject类 型在设计和定义时专有的,我们替它在函数前加上“some_object_”。


1. /* 这个方法将返回我们新声明的对象类型所关联的GType类型 */
2. GType   some_object_get_type ( void );

c. 声明管理我们对象生命期的函数:初始化时创建对象的函数,结束时销毁对象的函数。


1. /* 类/实例的初始化/销毁函数。它们的标记在gtype.h中定义。 */
2. void     some_object_class_init      (gpointer g_class, gpointer class_data);
3. void     some_object_class_final     (gpointer g_class, gpointer class_data);
4. void     some_object_instance_init   (GTypeInstance *instance, gpointer g_class);

d. 用C函数的通用约定来定义我们的类方法。


1. /* 所有这些函数都是SomeObject的方法. */
2. void     some_object_method1 (SomeObject *self, gint);   /* virtual */
3. void     some_object_method2 (SomeObject *self, gchar*); /* virtual */
4. void     some_object_method3 (SomeObject *self, gfloat); /* non-virtual */

e. 创建一些样板式代码(boiler-plate code),来符合规范,让生活更简单。


1. /* 好用的宏定义 */
2. #define SOME_OBJECT_TYPE        (some_object_get_type ())
3. #define SOME_OBJECT(obj)        (G_TYPE_CHECK_INSTANCE_CAST ((obj), SOME_OBJECT_TYPE, SomeObject))
4. #define SOME_OBJECT_CLASS(c)        (G_TYPE_CHECK_CLASS_CAST ((c), SOME_OBJECT_TYPE, SomeObjectClass))
5. #define SOME_IS_OBJECT(obj)     (G_TYPE_CHECK_TYPE ((obj), SOME_OBJECT_TYPE))
6. #define SOME_IS_OBJECT_CLASS(c)     (G_TYPE_CHECK_CLASS_TYPE ((c), SOME_OBJECT_TYPE))
7. #define SOME_OBJECT_GET_CLASS(obj)  (G_TYPE_INSTANCE_GET_CLASS ((obj), SOME_OBJECT_TYPE, SomeObjectClass))

Code(源程序)

现在我们可以继续实现我们刚刚声明过的源文件了。
由于虚函数现在只是一些函数指针,我们还要创建一些正常的、保存在内存中的、可以寻址到的C函数(声明为以”_impl”结尾的,并且不在头文件中导出的),在虚函数中将指向这些函数。
以”some_object_”开头的函数都是对应于SomeObject的定义的,这通常是因为我们会显式的将不同的指针转型到SomeObject,或者会使用类的其它特性。(译者:not very clear)
a. 实现虚方法。


01. /* 虚函数的实现 */
02. void     some_object_method1_impl (SomeObject *self, gint a)
03. {
04. self->m_a = a;
05. g_print ( "Method1: %i\n" , self->m_a);
06. }
07.  
08. void     some_object_method2_impl (SomeObject *self, gchar* b)
09. {
10. self->m_b = b;
11. g_print ( "Method2: %s\n" , self->m_b);
12. }

b. 实现所有公有方法。实现虚方法时,我们必须使用“GET_CLASS”宏来从类型系统中获取到类对象,用以调用虚函数表中的虚方法。非虚方法时,直接写实现代码即可。


01. /* 公有方法 */
02. void     some_object_method1 (SomeObject *self, gint a)
03. {
04. SOME_OBJECT_GET_CLASS (self)->method1 (self, a);
05. }
06.  
07. void     some_object_method2 (SomeObject *self, gchar* b)
08. {
09. SOME_OBJECT_GET_CLASS (self)->method2 (self, b);
10. }
11.  
12. void     some_object_method3 (SomeObject *self, gfloat c)
13. {
14. self->m_c = c;
15. g_print ( "Method3: %f\n" , self->m_c);
16. }

c. 实现构造/析构方法。系统给我们的是泛型指针(我们也相信这个指针的确指向的是一个合适的对象),所以我们在使用它之前必须将其转型为合适的类型。


01. /* 该函数将在类对象创建时被调用 */
02. void     some_object_class_init      (gpointer g_class, gpointer class_data)
03. {
04. SomeObjectClass *this_class = SOME_OBJECT_CLASS (g_class);
05.  
06. /* fill in the class struct members (in this case just a vtable) */
07. this_class->method1 = &some_object_method1_impl;
08. this_class->method2 = &some_object_method2_impl;
09. }
10.  
11. /* 该函数在类对象不再被使用时调用 */
12. void     some_object_class_final     (gpointer g_class, gpointer class_data)
13. {
14. /* No class finalization needed since the class object holds no
15. pointers or references to any dynamic resources which would need
16. to be released when the class object is no longer in use. */
17. }
18.  
19. /* 该函数在实例对象被创建时调用。系统通过g_class实例的类来传递该实例的类。 */
20. void     some_object_instance_init   (GTypeInstance *instance, gpointer g_class)
21. {
22. SomeObject *this_object = SOME_OBJECT (instance);
23.  
24. /* fill in the instance struct members */
25. this_object->m_a = 42;
26. this_object->m_b = 3.14;
27. this_object->m_c = NULL;
28. }

d. 实现能够返回给调用者SomeObject的GType的函数。该函数在第一次运行时,它通过向系统注册SomeObject来获取到GType。该 GType将被保存在一个静态变量中,以后该函数再被调用时就无须注册可以直接返回该数值了。虽然使用一个独立的函数来注册该类型时可能的,但这样的实现 可以保证类在使用前是被注册了的,该函数通常在第一个对象被实例化的时候调用。


01. /* 因为该类没有基类,所以基类构造/析构函数是空的 */
02. GType   some_object_get_type ( void )
03. {
04. static GType type = 0;
05.  
06. if (type == 0)
07. {
08. /* 这是系统用来完整描述类型时如何被创建,构造和析构的结构体。 */
09. static const GTypeInfo type_info =
10. {
11. sizeof (SomeObjectClass),
12. NULL,               /* 基类构造函数 */
13. NULL,               /* 基类析构函数 */
14. some_object_class_init,     /* 类对象构造函数 */
15. some_object_class_final,    /* 类对象析构函数 */
16. NULL,               /* 类数据 */
17. sizeof (SomeObject),
18. 0,              /* 预分配的字节数 */
19. some_object_instance_init   /* 实例对象构造函数 */
20.              };
21.  
22. /* 因为我们的类没有父类,所以它将被认为是“基础类(fundamental)”,
23.                     所以我们必须要告诉系统,我们的类既是一个复合结构的类(与浮点型,整型,
24.                     或者指针不同),并且时可以被实例化的(系统可以创建实例对象,例如接口
25.                     或者抽象类不能被实例化 */
26. static const GTypeFundamentalInfo fundamental_info =
27. {
28. G_TYPE_FLAG_CLASSED | G_TYPE_FLAG_INSTANTIATABLE
29. };
30.  
31. type = g_type_register_fundamental
32. (
33. g_type_fundamental_next (), /* 下一个可用的GType数 */
34. "SomeObjectType" ,       /* 类型的名称 */
35. &type_info,         /* 上面定义的type_info */
36. &fundamental_info,      /* 上面定义的fundamental_info */
37. 0               /* 类型不是抽象的 */
38. );
39. }
40.  
41. return   type;
42. }
43.  
44. /* 让我们来编写一个测试用例吧! */
45.  
46. int main()
47. {
48. SomeObject  *testobj = NULL;
49.  
50. /* 类型系统初始化 */
51. g_type_init ();
52.  
53. /* 让系统创建实例对象 */
54. testobj = SOME_OBJECT (g_type_create_instance (some_object_get_type()));
55.  
56. /* 调用我们定义了的方法 */
57. if (testobj)
58. {
59. g_print ( "%d\n" , testobj->m_a);
60. some_object_method1 (testobj, 32);
61.  
62. g_print ( "%s\n" , testobj->m_b);
63. some_object_method2 (testobj, "New string." );
64.  
65. g_print ( "%f\n" , testobj->m_c);
66. some_object_method3 (testobj, 6.9);
67. }
68.  
69. return   0;
70. }

最后需要考虑的

我们已经用C实现了第一个对象,但是做了很多工作,并且这并不是真正的面向对象,因为我们故意没有提及任何关于“继承”的方法。在下一节我们将看到如何让工作更加轻松,利用别人的代码-使SomeObject继承与内建的类GObject。
尽管在下文中我们将重用上面讨论的思想和模型,但是尝试去创建一个基础类型,使得它能像其它的GTK+代码一样的工作是非常困难和深入的。因此建议您总是继承GObject来创建新的类型,因为它帮您做了大量背后的工作,使得您的类型能工作的与GTK+要求的保持一致。

二、使用内建的宏定义来自动生成代码
设计

您可能已经注意到了,我们上面所做的大部分工作基本上都是机械性的、模板化的工作。大多数的函数都不是通用的,每创建一次类型我们就需要重写一遍。很显然这就是为什么我们发明了计算机的原因 - 让这些工作自动化,让我们的生活更简单!
好的,其实我们很幸运,因为C的预处理器将允许我们编写宏定义来定义新的类型,这样在编译时这些宏定义会自动展开成为合适的C代码。而且使用宏定义还能帮助我们减少人为错误。
然而自动化将使我们丢失部分灵活性。在上面描述的步骤中,我们能有许多可能的变化,但一个宏定义只能实现一种展开。如果这个宏提供了一种轻量级的展开,但 我们想要的是一个完整的类型,这样我们仍然需要手写一大堆代码。如果宏提供了一个完整的展开,但我们需要的是一种轻量级的类型,我们将得到许多冗余的代 码,花许多时间来填写这些我们用不上的桩代码,或者只是一些普通的错误代码。事实上C预处理器并没有设计成能够自动发现我们感兴趣的代码生成方式,它只包 含有限的功能。

代码

创建一个新类型的代码非常简单:G_DEFINE_TYPE_EXTENDED (TypeName, function_prefix, PARENT_TYPE, GTypeFlags, CODE)。
第一个参数是类型的名称。第二个是函数名称的前缀,这样能够与我们的命名规则保持一致。第三个是我们希望继承自的基类的GType。第四个是添加到GTypeInfo结构体里的GTypeFlag。第五个是在类型被注册后应该立刻被执行的代码。
看看下面的代码将被展开成为什么样将会对我们有更多的启发。


1. G_DEFINE_TYPE_EXTENDED (SomeObject, some_object, 0, some_function())

实际展开后的代码将随着系统版本不同而不同。你应该总是检查一下展开后的结果而不是凭主观臆断。
展开后的代码(清理了空格):

 

01. static void some_object_init (SomeObject *self);
02. static void some_object_class_init (SomeObjectClass *klass);
03. static gpointer some_object_parent_class = (( void *)0);
04.  
05. static void some_object_class_intern_init (gpointer klass)
06. {
07. some_object_parent_class = g_type_class_peek_parent (klass);
08. some_object_class_init ((SomeObjectClass*) klass);
09. }
10.  
11. GType some_object_get_type ( void )
12. {
13. static GType g_define_type_id = 0;
14. if ((g_define_type_id == 0))
15. {
16. static const GTypeInfo g_define_type_info =
17. {
18. sizeof (SomeObjectClass),
19. (GBaseInitFunc) (( void *)0),
20. (GBaseFinalizeFunc) (( void *)0),
21. (GClassInitFunc) some_object_class_intern_init,
22. (GClassFinalizeFunc) (( void *)0),
23. (( void *)0),
24. sizeof (SomeObject),
25. 0,
26. (GInstanceInitFunc) some_object_init,
27. };
28.  
29. g_define_type_id = g_type_register_static
30. (
31. G_TYPE_OBJECT,
32. "SomeObject" ,
33. &g_define_type_info,
34. (GTypeFlags) 0
35. );
36.  
37. { some_function(); }
38. }
39.  
40. return g_define_type_id;
41. }

该宏定义了一个静态变量“
_parent_class”,它是一个指针,指向我们打算创建对象的基类。这在你想去找到虚方法继承自哪里时派上用场,并且这个基类不是由 GObject继承下来的基类(译者:not very clear),主要用于链式触发析构函数,这些函数也几乎总是虚的。我们接下来的代码将不再使用这个结构,因为有其它的函数能够不使用静态变量来做到这一 点。
你应该注意到了,这个宏没有生成基类的构造析构以及类对象析构函数,如果你需要这些函数,就要自己动手了。

3. 创建一个继承自GObject的对象
设计

尽管我们现在能够生成一个基本的对象,但事实上我们故意略过了类型系统的上下文:作为一个复杂库套件的基础 - 那就是图形库GTK+。GTK+的设计要求所有的类应该继承自一个根类。这样就至少能允许一些公共的基础功能能够被共享:如支持信号(让消息可以很容易的 从一个对象传递到另一个),通过引用计数来管理对象生命期,支持属性(针对对象的数据域生成简单的setting和getting函数),支持构造和析构 函数(用来设置信号、引用计数器、属性)。当我们让对象继承自GObject时,我们获得了上述的一切,并且当与其它基于GObject的库交互时会更加 容易。然而,在这章我们不讨论信号、引用计数和属性,或者任何其它专门的特性,这里我们将集中描述继承是在类型系统中如何工作的。
我们都知道,如果LuxuryCar继承自Car,那么LuxuryCar就是Car加上一些新的特性。那我们要如何让系统去实现这样的功能呢?我们可以 使用C语言里结构体的一个特性来实现:结构体定义里的第一个成员一定是在内存的最前面。如果我们要求所有的对象将它们的基类声明为它们自己结构体的第一个 成员的话,那么我们就能迅速的将指向某个对象的指针转型为指向它基类的指针!尽管这个技巧很好用,并且语法上非常干净,但这种转型的方式只适用于指针 - 你不能这样转型一个普通的结构体。
这种转型技巧是类型不安全的。虽然把一个对象转型为它的基类对象是完全合法的,但实际上非常的不明智(译者:not very clear)。这取决于程序员来保障他的转型是安全的。

创建类型的实例

了解了这个技术后,究竟类型系统是如何实例化对象的呢?第一次我们使用g_type_create_instance让系统创建一个实例对象时,它必须要 先创建一个类对象供实例来使用。如果该类结构体继承自其它类,系统则需要先创建和初始化这些基类。系统依靠我们指定的结构体(*_get_type函数中 的GTypeInfo结构体),来完成这个工作,这个结构体描述了每个对象的实例大小,类大小,构造函数和析构函数。
- 要用g_type_create_instance来实例化一个对象
如果它没有相关联的类对象
创建它并且将其加入到类的层次中
创建实例对象并且返回指向它的指针

当系统创建一个新的类对象时,它先会分配足够的内存来放置这个最终的类对象(译者:“最终的”意指这个新的类对象,相对于其继承的基类们)。然后从 最顶端的基类开始到最末端的子类对象,内存级别的用基类的成员域覆写掉这个最终类对象的成员域。这就是子类如何继承自基类的。当把基类的数据复制完后,系 统将会在当前状态的类对象中执行基类的“基类初始化“函数。这个覆写和执行“基类初始化”的工作将循环多次,直到这个继承链上的每个基类都被处理过后才结 束。接下来系统将在这个最终的类对象上执行最终子类的“基类初始化”和“类初始化”函数。函数“类初始化”有一个参数,该参数可以被认为是类对象构造函数 的参数,即上文所提到的“类数据”。
细心的读者可能会问,为什么我们已经有了一个完整的基类对象的拷贝还需要它的基类初始化函数?因为当完整拷贝无法为每个类重新创建出某些数据时,我们就需 要基类初始化函数。例如,一个类成员可能指向另外一个对象,并且我们想要每个类对象的成员都指向它自己的对象(内存的拷贝只是“浅拷贝”,我们也许需要一 次“深拷贝”)。有经验的GObject程序员告诉我基类初始化函数其实在实际中很少用到。
当系统创建一个新的实例对象时,它会先分配足够的内存来将这个实例对象放进去。在从最顶端的基类开始调用这个基类的“实例初始化”函数在当前的状态下,直到最终的子类。最后,系统在最终类对象上调用最终子类的“实例初始化”函数。
我来总结一下上面所描述到的算法:
- 实例化一个类对象
为最终对象分配内存
从基类到子类开始循环
复制对象内容以覆盖掉最终对象的内容
在最终对象上运行对象自己的基类初始化函数
在最终对象上运行最终对象的基类初始化函数
在最终对象上运行最终对象的类初始化函数(附带上类数据)

- 实例化一个实例对象
为最终对象分配内存
从基类刀子类开始循环
在最终对象上运行实例初始化函数
在最终对象上运行最终对象的实例初始化函数
此时初始化了的类对象和实例对象都已经被创建,系统将实例对象的类指针指向到类对象,这样实例对象就能找到类对象所包含的虚函数表。这就是系统如何实例化已注册类型的过程;其实GObject实现了自己的构造和析构语义正如我们上面所描述的那样!

创建GObject实例

...

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值