[精通Objective-C]运行时系统

[精通Objective-C]运行时系统

参考书籍:《精通Objective-C》【美】 Keith Lee

目录

运行时系统概述

Objective-C拥有相当多的动态特性,这些特性在运行程序时发挥作用,而不是在编译或链接代码时发挥作用。Objective-C运行时系统实现了这些特性,而这些功能为Objective-C语言提供了非常多的强大功能和灵活性。开发人员使用它们能够以实时方式促进程序的开发和更新,而无需重新编译和重新部署软件。

在运行时,Objective-C语言会执行其他语言在程序编译或链接时会执行的许多常规操作,如确定类型和方法解析。这些操作还可以提供API,使编写的程序能够执行额外的运行时操作,如动态内省和以动态方式创建和加载代码。

对象消息

在OPP术语中,消息传递是指一种在对象之间发送和接收消息的通信模式。在Objective-C中,消息传递用于调用类和对象的方法。关于消息传递机制,详见[精通Objective-C]对象和消息传递。下面以一个消息传递表达式为例:

[calculator sumAddend1:addend1 addend2:addend2];

calculator是消息的目的地(对象或类),而消息本身sumAddend1:addend1 addend2:addend2,由选择器和相应的输入参数构成。概括来说,Objective-C对象消息传递中具有下列关键元素:
消息:向对象/类发送的名称(选择器)和一系列参数。
方法:Objective-C中的类或实例方法,其声明中含有名称、输入参数、返回值和方法签名(即输入参数和返回值的数据类型)。
方法绑定:接收向指定接收器发送的消息并寻找和执行适当方法的处理过程。Objective-C运行时系统在调用方法时,会以动态绑定方式处理信息。

选择器

选择器是一种文本字符串,用于指明调用对象或类中的哪个(些)方法。选择器是一种分为多个段的文本字符串,每个段以冒号结尾并且后跟参数。下面是一些选择器实例:

description
description:
sumAddend1:addend2:
sumAddend1::

在Objective-C中,消息的选择器直接与一个或多个类/实例方法声明对应。例如:

@interface Calculator : NSObject
// 与选择器sumAddend1:addend2:对应
-(NSNumber *) sumAddend1:(NSNumber *)adder1 addend2:(NSNumber *)adder2;
// 与选择器sumAddend1::对应
-(NSNumber *) sumAddend1:(NSNumber *)adder1 :(NSNumber *)adder2;
@end

选择器类型(SEL)是一种特殊的Objective-C数据类型,是用于在编译源代码时替换选择器值的唯一标识符。所有具有相同选择器值的方法都拥有相同的SEL标识符。

下面是SEL类型变量的两种使用方式:

编译时创建选择器:

[calculator performSelector:@selector(sumAddend1::) withObject:addend1 withObject:addend2];

运行时创建选择器:

SEL selector = NSSelectorFromString(@"sumAddend1::");
[calculator performSelector:selector withObject:addend1 withObject:addend2];

方法签名

方法签名定义了方法输入参数的数据类型和方法的返回值(如果存在)。编译器会将[接收器 消息]形式的对象消息转换为声明中含有方法签名的C函数调用语句。为了生成正确的对象消息传递代码,编译器需要获得选择器值和方法签名。消息中可能含有输入参数,而且因为接收器和相应的方法是在程序运行时确定的,所以编译器无法知道使用怎样的数据类型才能与要调用的方法对应起来。为了确定正确的方法签名,编译器会根据已解析的方法声明进行猜测。如果它找不到方法签名,或者它从方法声明获得的方法签名与运行时实际执行的方法不匹配,就会出现方法签名不匹配的情况,会导致各种各样的警告或错误。

下面展示一个方法签名不匹配的情况,该程序使用了3个类,3个类的接口分别如下所示:

@interface Calculator1 : NSObject
-(int) sumAddend1:(int)adder1 addend2:(int)adder2;
@end
@interface Calculator2 : NSObject
-(float) sumAddend1:(float)adder1 addend2:(float)adder2;
@end
@interface Calculator3 : NSObject
-(NSInteger) sumAddend1:(NSInteger)adder1 addend2:(NSInteger)adder2;
@end

当接收器类型为id时,发送消息[接收器 sumAddend1:25 addend2:10]时,根据程序的接口和运行时确定的接收器类型进行判断(接收器类型可能为3个类中的任意一个),会出现方法签名不匹配的情况。要避免出现这种情况,最好确保拥有不同特征的方法也拥有不同的名称。

使用对象消息

下面是选择器和SEL类型变量的具体使用示例:

首先创建一个类Calculator,类中有两个方法,都是返回两个输入参数之和:

#import <Foundation/Foundation.h>

@interface Calculator : NSObject

-(NSNumber *) sumAddend1:(NSNumber *)adder1 addend2:(NSNumber *)adder2;
-(NSNumber *) sumAddend1:(NSNumber *)adder1 :(NSNumber *)adder2;
@end
#import "Calculator.h"

@implementation Calculator

-(NSNumber *) sumAddend1:(NSNumber *)adder1 addend2:(NSNumber *)adder2{

    // _cmd是一个类型为SEL的隐式参数,含有被发送消息中的选择器
    NSLog(@"Invoking method on %@ object with selector %@", [self className], NSStringFromSelector(_cmd));
    return [NSNumber numberWithInteger:[adder1 integerValue] + [adder2 integerValue]];
}

-(NSNumber *) sumAddend1:(NSNumber *)adder1 :(NSNumber *)adder2{
    NSLog(@"Invoking method on %@ object with selector %@", [self className], NSStringFromSelector(_cmd));
    return [NSNumber numberWithInteger:[adder1 integerValue] + [adder2 integerValue]];
}

@end

下面是在main.m中进行测试:

#import <Foundation/Foundation.h>
#import "Calculator.h"

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Calculator *calculator = [[Calculator alloc] init];
        NSNumber *addend1 = [NSNumber numberWithInteger:25];
        NSNumber *addend2 = [NSNumber numberWithInteger:10];

        // 直接使用选择器
        NSLog(@"Sum of %@ + %@ = %@", addend1, addend2, [calculator sumAddend1:addend1 addend2:addend2]);

// performSelector方法如果找不到与该选择器匹配的方法,那么方法就会抛出异常导致内存泄漏。于是编译器会发出警告。通过pragma指令可以消除该警告。
// 禁用指定的编译器警告功能,使用push和pop可以保存和恢复编译器当前的诊断设置
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        // 使用SEL类型变量创建选择器,再用performSelector方法调用
        SEL selector = NSSelectorFromString(@"sumAddend1::");
        NSLog(@"Sum of %@ + %@ = %@", addend1, addend2, [calculator performSelector:selector withObject:addend1 withObject:addend2]);
#pragma clang diagnostic pop
    }
    return 0;
}

运行结果:

2016-07-06 12:48:48.651 Calculator[7049:69121] Invoking method on Calculator object with selector sumAddend1:addend2:
2016-07-06 12:48:48.652 Calculator[7049:69121] Sum of 25 + 10 = 35
2016-07-06 12:48:48.652 Calculator[7049:69121] Invoking method on Calculator object with selector sumAddend1::
2016-07-06 12:48:48.652 Calculator[7049:69121] Sum of 25 + 10 = 35
Program ended with exit code: 0

动态类型

运行时系统通过动态类型功能可以在运行时程序时决定对象的类型,因而可以使运行时因素能够在程序中指定哪种对象。Objective-C通过id类型支持动态类型。id数据类型是一种Objective-C独有的数据类型。其变量可以存储任何数据类型的Objective-C对象,而不论该对象是哪种类的实例。以下是静态类型和动态类型的使用:

// 声明为静态类型
Atom *atom1 = [[Atom alloc] init];
// 声明为动态类型
id atom2 = [[Atom alloc] init];

由于Objective-C既支持静态类型又支持动态类型,所以可在方法声明中使用不同等级的类型信息:

// 输入参数可以接收任何类的实例
-(NSInteger) computeValue1:(id)parameter;
// 输入参数可以接收任何遵守Writer协议的对象
-(NSInteger) computeValue2:(id<Writer>)parameter;
// 输入参数可以接收任何类型为NSNumber的对象
-(NSInteger) computeValue3:(NSNumber *)parameter;
// 输入参数可以接收任何类型为NSNumber且遵守Writer协议的对象
-(NSInteger) computeValue4:(NSNumber<Writer> *)parameter;

动态绑定

动态绑定指在运行程序时(而不是在编译时)将消息与方法对应起来的处理过程。因为许多接收器对象可能会实现相同的方法,调用方法的方式会动态变化。因此,动态绑定实现了OPP的多态性,可以在不影响既有代码的情况下,将新对象和代码连接或添加到系统中,从而降低对象之间的耦合度。同时通过消除用于处理多选情景的条件逻辑,动态绑定还能够降低程序的复杂程度。以下面代码段为例(Hydrogen类为Atom类的子类,而logInfo方法定义在Atom类中):

id atom = [[Hydrogen alloc] initWithNeutrons:1];
[atom logInfo];

执行这段代码时,运行时系统会通过动态绑定确定变量atom的实际类型,然后使用消息选择器将该消息与接收器的实例方法对应起来。在本例中,atom的类型被设置为Hydrogen *,因此运行时系统会搜索Hydrogen类的实例方法logInfo,如果没有找到,就会在Hydrogen类的父类中寻找相应的实例方法。运行时系统会一直在类层次结果中寻找该实例方法,直到找到它为止。

动态绑定是Objective-C的一种继承特性,它不需要任何API。使用动态绑定甚至可以将消息选择器设置为在运行程序时确定的变量。

动态方法决议

使用动态方法决议能够以动态方式实现方法。使用Objective-C中的@dynamic指令,可以告知编译器与属性关联的方法会以动态方式实现。

NSObject类中含有resolveInstanceMethod:和resolveClassMethod:方法,它们能够以动态方式分别为指定的实例和类方法选择器提供实现代码。

下面是以动态方式实现方法,来展示动态方法决议:

首先在之前创建的Calculator.m文件中导入运行时系统库,并重写resolveInstanceMethod:方法:

#import <objc/runtime.h>
+(BOOL) resolveInstanceMethod:(SEL)sel{
    NSString *method = NSStringFromSelector(sel);
    if ([method hasPrefix:@"absoluteValue"]) {
        // 运行时系统API,动态方式将函数作为实例方法添加到类中
        // class_addMethod的4个参数分别添加方法的目标类、新方法的选择器、函数的地址(数据类型为IMP)和描述方法参数的数据类型的字符串(字符串里的内容分别为函数返回值类型和每个参数类型)
        class_addMethod([self class], sel, (IMP)absoluteValue, "@@:@");
        NSLog(@"Dynamically added instance method %@ to class %@", method, [self className]);
        return YES;
    }
    return [super resolveClassMethod:sel];
}

// 被添加为实例方法的函数
id absoluteValue(id self, SEL _cmd, id value){
    NSInteger intVal = [value integerValue];
    if (intVal < 0) {
        return [NSNumber numberWithInteger:(intVal * -1)];
    }
    return value;
}

最后在main.m中进行测试:

#import <Foundation/Foundation.h>
#import "Calculator.h"

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Calculator *calculator = [[Calculator alloc] init];
        NSNumber *addend1 = [NSNumber numberWithInteger:-25];

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        SEL selector = NSSelectorFromString(@"absoluteValue:");
        NSLog(@"Invoking instance method %@ on object of class %@", NSStringFromSelector(selector), [calculator className]);
        NSLog(@"Absolute value of %@ = %@", addend1, [calculator performSelector:selector withObject:addend1]);
#pragma clang diagnostic pop
    }
    return 0;
}

运行结果:

2016-07-06 14:26:24.324 Calculator[12102:117902] Invoking instance method absoluteValue on object of class Calculator
2016-07-06 14:26:24.325 Calculator[12102:117902] Dynamically added instance method absoluteValue to class Calculator
2016-07-06 14:26:24.325 Calculator[12102:117902] Absolute value of -25 = 25

如运行结果所示,当使用选择器absoluteValue:的消息通过performSelector方法被以动态方式调用时,Objective-C运行时系统会将新方法添加到Calculator类中。

动态加载

Objective-C程序通过动态加载功能可以根据需要加载可执行代码和源代码,而无需在启动程序时就加载程序的所有组件。该方式不仅降低了对系统内存的需求,还提高了程序的可扩展性,因为它能够使新软件在不更改已存在程序的情况下,以动态方式将新增代码添加到程序中。苹果公司提供了以动态方式加载软件的包bundle机制。

包是一种软件交付机制。它由具有标准层次结构的目录以及该目录中的可执行代码和源代码构成。包可以含有可执行代码、图像、音频文件、和其他类型的代码与资源整合。它还含有一个运行时配置文件,即信息属性列表info.plist。包可以分为3类:
1.应用程序包
2.框架包(如Foundation框架)
3.可选加载包(也称为插件,用于动态加载的自定义包)

可以使用Foundation框架中的NSBundle类管理包。一个NSBundle对象就代表文件系统中的一个存储位置,该位置存储着可在程序中使用的代码和数据资源。

下面是动态加载信息属性列表和框架对象的示例:

// 动态加载信息属性列表
NSBundle *bundle = [NSBundle mainBundle];
NSString *bundlePath = [bundle pathForResource:@"Info" ofType:@"plist"];

// 动态加载框架对象   
NSBundle *testBundle = [NSBundle bundleWithPath:@"/Test.bundle"];
id tester = [[[bundle classNamed:@"Tester"] alloc] init];

内省

Foundation框架中NSObject类的API含有非常多用于执行对象内省的方法,使用这些方法能够以动态方式在程序运行时查询与方法有关的信息和测试对象的继承性、行为和一致性的信息。

下面是一些内省的语句:

// 检测calculator是Calculator类的实例还是Calculator类子类的实例
BOOL isCalculator = [calculator isKindOfClass:[Calculator class]];
// 检测calculator是否会对选择器做出回应,即该对象是否实现或继承了能够对指定消息作出回应的方法
BOOL responds = [calculator respondsToSelector:@selector(sumAddend1::)];
// 检测calculator是否遵守指定的协议
BOOL conforms = [calculator conformsToProtocol:@protocol(Writer)];
// 为选择器提取方法签名
NSMethodSignature *signature = [calculator methodSignatureForSelector:@selector(sumAddend1::)];

运行时系统的组成部分

运行时系统由两个主要部分构成:编译器和运行时系统库。

编译器

编译器主要功能有两个:

1.生成对象消息传递代码
编译器会将源代码中所有消息传递表达式([接收器 消息]形式的),转换为调用运行时系统库函数objc_msgSend(…)的代码,并为这些调用代码提供源代码所提供的参数。

2.生成类和对象的代码
编译器解析含有类定义和对象的代码时,会生成相应的运行时数据结构:
Objective-C中的类与运行时系统库中的Class数据结构对应。Class数据类型是指向带objc_class表示符的不透明数据类型的指针:

typedef struct objc_class *Class;

Objective-C对象也拥有相应的运行时数据类型:

struct objc_object
{
   Class isa;
};

isa变量就是指向objc_class类型的指针。

Objective-C中id数据类型对应的运行时数据类型也是一种C语言结构,该结构被定义为指向objc_object的指针:

typedef struct objc_object
{
   Class isa;
}*id;

下面用一个例子来查看运行时系统的数据结构

#import <Foundation/Foundation.h>
#import <objc/runtime.h>

//创建测试类
@interface TestClass1 : NSObject{
@public int myInt;
}
@end

@implementation TestClass1
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // 为测试类创建两个实例并显示其数据
        TestClass1 *tc1A = [[TestClass1 alloc] init];
        tc1A->myInt = 0xa5a5a5a5;
        TestClass1 *tc1B = [[TestClass1 alloc] init];
        tc1B->myInt = 0xc3c3c3c3;
        long tc1Size = class_getInstanceSize([TestClass1 class]);
        NSData *obj1Data = [NSData dataWithBytes:(__bridge const void *)(tc1A) length:tc1Size];
        NSData *obj2Data = [NSData dataWithBytes:(__bridge const void *)(tc1B) length:tc1Size];
        NSLog(@"TestClass1 object tc1 contains %@", obj1Data);
        NSLog(@"TestClass1 object tc2 contains %@", obj2Data);
        NSLog(@"TestClass1 memory address = %p", [TestClass1 class]);

        // 获取并显示TestClass1类的数据
        id testClz = objc_getClass("TestClass1");
        long tcSize = class_getInstanceSize([testClz class]);
        NSData *tcData = [NSData dataWithBytes:(__bridge const void *)(testClz) length:tcSize];
        NSLog(@"TestClass1 class contains %@", tcData);
        NSLog(@"TestClass1 superclass memory address = %p", [TestClass1 superclass]);
    }
    return 0;
}

运行结果:

2016-07-06 15:55:11.030 Runspector[16745:165936] TestClass1 object tc1 contains <21120000 01801d00 a5a5a5a5 00000000>
2016-07-06 15:55:11.031 Runspector[16745:165936] TestClass1 object tc2 contains <21120000 01801d00 c3c3c3c3 00000000>
2016-07-06 15:55:11.031 Runspector[16745:165936] TestClass1 memory address = 0x100001220
2016-07-06 15:55:11.031 Runspector[16745:165936] TestClass1 class contains <f9110000 01801d00 f0103377 ff7f0000>
2016-07-06 15:55:11.031 Runspector[16745:165936] TestClass1 superclass memory address = 0x7fff773310f0

下面对这些数据进行分析。当编译器解析对象时,就会生成objc_object类型的实例,该实例由一个isa指针和对象实例变量的值构成,就可以查明tc1对象含有两项内容:一个isa指针(21120000 01801d00)和该对象实例变量的值(a5a5a5a5 00000000),类似地,tc2对象的isa指针(21120000 01801d00)和实例变量值(c3c3c3c3 00000000)。对象objc_object的数据结构中的第一项就是其isa指针,两个对象的isa指针都是相同的,因为它们都是同一个类的实例,都指向该类的内存地址。

而之后的一行中显示的TestClass1地址与前面的isa指针值却不相同,这是因为,Mac计算机使用的是低字节序,它们会使用反转的字节顺序存储数据(8位为一个字节,由2位16进制数表示)。将isa指针值翻转后得到地址为0x1d80100001221,而后面输出的TestClass1地址为0x100001220。在书上这两个地址是完全一致的,但博主做了多次验证发现类地址总是为isa地址去掉高位后减1,这可能是编译环境或操作系统版本导致的(而后面的例子又没有此问题)。

下一行输出的是TestClass1中的内容,包含两个指针,isa指针(f9110000 01801d00)和指向父类的指针(f0103377 ff7f0000),最后一行输出的是TestClass1父类的地址0x7fff773310f0,与TestClass1中指向父类的指针按字节翻转后完全一致。

运行时系统库

下面是运行时系统库API的简单应用:

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import <objc/message.h>

NSString *greeting(id self, SEL _cmd){
    return [NSString stringWithFormat:@"Hello, World!"];
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // 以动态方式创建一个类
        Class dynaClass = objc_allocateClassPair([NSObject class], "DynaClass", 0);

        // 以动态方式添加一个方法,使用已有方法(NSObject类中的description方法,两个方法的参数和返回值类型完全相同)获取特征
        Method description = class_getInstanceMethod([NSObject class], @selector(description));
        const char *types = method_getTypeEncoding(description);
        class_addMethod(dynaClass, @selector(greeting), (IMP)greeting, types);

        // 注册这个类
        objc_registerClassPair(dynaClass);

        // 使用该类创建一个实例并向其发送一条消息
        id dynaObj = [[dynaClass alloc] init];
        NSLog(@"%@",objc_msgSend(dynaObj,NSSelectorFromString(@"greeting")));
    }
    return 0;
}

注意:在目前版本的Xcode中,需要将 Apple LLVM X.X(版本号) - Preprocessing 中的 Enable Strict Checking of objc_msgSend Calls 设置为No,不然编译会失败。

运行时系统库含有可用于访问下列信息的函数

信息函数
对象的类定义objc_getClass
对象的元类定义objc_getMetaClass
类的父类class_getSuperClass
类的名称class_getName
类的版本信息class_getVersion
以字节为单位的类尺寸class_getInstanceSize
类的实例变量列表class_copyIvarList
类的方法列表class_copyMethodList
类的协议列表class_copyProtocalList
类的属性列表class_copyProperyList

运行时系统中的方法数据类型

struct objc_method
{
    // 描述方法的名称
    SEL method_name;
    // 描述方法参数的数据类型
    char * method_types;
    // 方法的地址
    IMP method_imp;
};
typedef objc_method Method;

通过虚函数表查找方法的流程:

Created with Raphaël 2.1.0 开始查找 通过对象的isa指针,获取该对象所属的类 通过搜索类方法缓存,查找方法的IMP指针 是否找到了方法? 跳转到存储方法代码的地址并执行方法 通过对象的isa指针,获取该对象所属的类 是否找到了方法? 跳转到存储方法代码的地址并执行方法 依次尝试使用动态方法决议,快速转发,标准转发的方式尝试找到可以处理消息的方法 是否解决 运行时系统发送一条doseNotRecongniseSelector:消息 yes no yes no yes no

元类

实际上Objective-C中的类也是对象,因此它们也能接受消息。运行时系统是通过元类的实现这个功能的,元类是一种特殊的类对象,运行时系统使用其中含有的信息能够找到并调用类方法。每个类都拥有一个独一无二的元类。运行时系统API提供可访问元类的函数,以下是对元类的操作示例:

        id metaClass = objc_getMetaClass("TestClass1");
        long mclzSize = class_getInstanceSize([metaClass class]);
        NSData *mclzData = [NSData dataWithBytes:(__bridge const void *)(metaClass) length:mclzSize];
        NSLog(@"TestClass1 class contains %@", mclzData);
        class_isMetaClass(metaClass) ? NSLog(@"Class %s is a metaclass",class_getName(metaClass)) : NSLog(@"Class %s is  not a metaclass",class_getName(metaClass));

运行结果:

2016-07-06 17:09:13.177 Runspector[20513:203884] TestClass1 class contains <19113377 ffff1d00 18113377 ff7f0000 00592000 01000000 07000000 01000000 d0006000 01000000>
2016-07-06 17:09:13.177 Runspector[20513:203884] Class TestClass1 is a metaclass

元类中含有isa指针,父指针和附加信息,TestClass1的父类是NSObject。因为这个类中没有自定义的类方法,所以它的isa指针和父指针都指向NSObject类。这里两个指针的值也跟之前有同样的问题(19113377 ffff1d00和18113377 ff7f0000按字节翻转后去掉高位后相差1,原书中运行结果是完全相同的。)

与运行时系统交互

Objective-C程序通过与运行时系统交互实现动态特性,这些交互操作分为3个等级:Objective-C源代码,Foundation框架中的NSObject类的方法,运行时系统库API。

之前介绍了编译器和运行时系统库的作用,接下来展示NSObject类的运行时特性:

#import <Foundation/Foundation.h>

// 创建一个测试类
@interface Greeter : NSObject
@property(readwrite, strong) NSString *salutation;
-(NSString *)greeting:(NSString *) recipient;
@end
@implementation Greeter
-(NSString *)greeting:(NSString *)recipient{
    return [NSString stringWithFormat:@"%@,%@",[self salutation],recipient];
}
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Greeter *greeter = [[Greeter alloc] init];
        [greeter setSalutation:@"Hello"];

        // 使用NSObject类的对象内省,逐步测试对象是否能响应方法和遵循协议
        if ([greeter respondsToSelector:@selector(greeting:)] && [greeter conformsToProtocol:@protocol(NSObject)]) {
             // 使用运行时方法performSelector发送消息
            id result = [greeter performSelector:@selector(greeting:) withObject:@"Monster!"];
            NSLog(@"%@",result);
        }
    }
    return 0;
}
  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值