首页 >iOS开发

iOS源码解析:runtime<一> isa,class底层结构窥探

2018-09-12 15:13 编辑: yyuuzhu 分类:iOS开发 来源:雪山飞狐

isa详解

要想学习runtime,首先要了解它底层的一些常用的数据结构,比如isa指针。
在arm64架构之前,isa就是一个普通的指针,存储着Class,Meta-Class对象的内存地址
从arm64架构开始,对isa进行了优化,变成了一个共用体(union)结构,还使用位域来存储更多的信息。
我们打开runtime的源码,搜索objc_object {,我们在objc-private.h文件中找到了objc_object的结构:

struct objc_object {
private:
    isa_t isa;

我们看到,这个isa并不是一个简单的指针,它现在是isa_t类型的。我们点进这个isa_t看看

union isa_t 
{
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }

    Class cls;
    uintptr_t bits;


#   define ISA_MASK        0x0000000ffffffff8ULL
#   define ISA_MAGIC_MASK  0x000003f000000001ULL
#   define ISA_MAGIC_VALUE 0x000001a000000001ULL
    struct {
        uintptr_t nonpointer        : 1;
        uintptr_t has_assoc         : 1;
        uintptr_t has_cxx_dtor      : 1;
        uintptr_t shiftcls          : 33// MACH_VM_MAX_ADDRESS 0x1000000000
        uintptr_t magic             : 6;
        uintptr_t weakly_referenced : 1;
        uintptr_t deallocating      : 1;
        uintptr_t has_sidetable_rc  : 1;
        uintptr_t extra_rc          : 19;
#       define RC_ONE   (1ULL<<45)
#       define RC_HALF  (1ULL<<18)
    };
};

我们可以看到,在arm64的结构下,isa的结构变得非常的复杂,首先我们要知道的是这是一个共用体。nonpointer,has_assoc等后面的数字表示它们占用的位数。

下面我们从侧面去学习一下isa的这个结构。

Person类有三个BOOL类型的属性tall,rich,handsome。

@interface Person : NSObject

@property (nonatomicassigngetter=isTall)BOOL tall;
@property (nonatomicassigngetter=isRich)BOOL rich;
@property (nonatomicassigngetter=isHandsome)BOOL handsome;
@end

可以像下面这样读取:

Person *person = [[Person alloc] init];
        person.tall = NO;
        person.rich = YES;
        person.handsome = YES;

        NSLog(@"%d, %d, %d", person.isTall, person.isRich, person.isHandsome);

我们还可以打印一下看person对象需要分配的内存空间:

NSLog(@"%zd", class_getInstanceSize([person class]));

打印出来的结果是16

16是怎么得来的呢?isa指针占8个字节,然后三个BOOL类型的成员变量总共占三个字节,又由于内存对齐,所以总共占16个字节。
但是有一个问题,BOOL值本来只有YES或者NO两种可能,其实用一个字节去表示都有些浪费,用一位去表示都可以了,那么接下来我们就试着用一位去表示这些BOOL类型的成员变量。
既然每个BOOL变量都只用一位去表示,那就不能声明属性了,因为声明属性了,就会有成员变量,每个变量占用的内存不可能是一位。

我们就要自己去实现每个属性的set和get方法。基本的思想是创建一个char类型的成员变量_tallRichHandsome,既然是char类型,那么这个成员变量就占一个字节,即8位,我们用最后一位来表示rich这个属性,用倒数第二位来表示tall这个属性,用倒数第三位来表示handsome这个属性。当这一位是0时表示属性值为NO,为1时表示属性值为YES。

取值时,我们用_tallRichHandsome的值和0b0000 0001也就是1相与,可以取到其最后一位,和0b0000 0010也就是2相与,可以取到倒数第二位,和0b0000 0100即4相与,可以取到倒数第三位。

设值时,如果要把属性值设为YES,也即是把_tallRichHandsome相对应的位置为1,那么把0b0000 0001和_tallRichHandsome相或,就可以把_tallRichHandsome的最后一位置为1,把0b0000 0010与之相或,可把倒数第二位置为1,把0b0000 0100与之相或,可把倒数第三位置为1。而如果要把属性值设为NO,也即是把_tallRichHandsome的相应的位置为0,这个时候可以把0b0000 0001取反,然后和_tallRichHandsome相与,这样就把_tallRichHandsome的最后一位置为0。

//Person.m
#define RichMask (1 << 0)    //也就是把0b0000 0001左移0位,还是0b0000 0001
#define TallMask (1 << 1)    //也就是把0b0000 0001左移1位,得到0b0000 0010
#define HandsomeMask (1 << 2)//也就是把0b0000 0001左移2位,得到0b0000 0100

@interface Person(){

    char _tallRichHandsome; //0b0000 0011
}

@end

@implementation Person

- (instancetype)init{

    if(self = [super init]){

        _tallRichHandsome = 0b00000011;
    }
    return self;
}


- (void)setRich:(BOOL)rich{

    if (rich) {
        _tallRichHandsome |= RichMask;
    }else{
        _tallRichHandsome &= ~RichMask;
    }
}

- (void)setTall:(BOOL)tall{

    if (tall) {
        _tallRichHandsome |= TallMask;
    }else{
        _tallRichHandsome &= ~TallMask;
    }
}

- (void)setHandsome:(BOOL)handsome{

    if (handsome) {
        _tallRichHandsome |= HandsomeMask;
    }else{
        _tallRichHandsome &= ~HandsomeMask;
    }
}

- (BOOL)isRich{

    return !!(_tallRichHandsome & RichMask);
}

- (BOOL)isTall{

    return !!(_tallRichHandsome & TallMask);
}

- (BOOL)isHandsome{

    return !!(_tallRichHandsome & HandsomeMask);
}
@end

当我们要设值和取值的时候可以:

Person *person = [[Person alloc] init];
        person.rich = YES;
        person.tall = NO;
        person.handsome = YES;
        NSLog(@"tall: %d,rich: %d,handsome: %d", person.isRich, person.isTall, person.isHandsome);

这样我们就完成了使用一个字节的数据来表示三个布尔类型的属性值。

使用结构体代替成员变量

但是我们能发现,这种方式还是非常的复杂冗长,那么有没有简洁一些的方法呢?这个时候我们想到了用结构体来表示_tallRichHandsome这个char类型的成员变量,然后设置三个char类型的成员变量,分别是rich,tall,handsome,然后我们使用位域规定每个成员变量只占1位

struct {
        char rich : 1;
        char tall : 1;
        char handsome : 1;

    } _tallRichHandsome;   //0b0000 0011 成员变量会根据先后顺序 rich在最右边一位 tall在倒数第二位,handsome在倒数第三位

这样_tallRichHandsome整个结构体就只需要占用一个字节,并且读取和设值非常简单,以rich属性为例:

- (void)setRich:(BOOL)rich{

    _tallRichHandsome.rich = rich;
}

- (BOOL)isRich{

    return _tallRichHandsome.rich;
}

然后就能顺利的取值和设值了。

使用共同体
    union Person{

        int age;
        int height;
        char name;
    };

这是一个名为Person的共同体,共同体和结构体非常相似,它们之间的不同之处在于,共同体中几个成员变量是共享同一块内存空间,什么意思呢?比如说这个共同体有三个成员变量age,height,name,如果这是一个机构体,那么这个结构体就占9个字节,这三个成员变量之间的内存时互不干涉的。而如果这是共同体,则由于三个成员变量中占内存最大的是占四个字节,所以这个共同体是占四个字节,age,height,name都是存储在这四个字节中,如果先设置了age的值,然后取height的值,那么取到的就是刚刚设置的age的值。

解释了共同体的概念之后我们再来看看下面这个共同体:

union {
        char bits;
        struct {
            char rich : 1;
            char tall : 1;
            char handsome : 1;
        } ;   
    }_tallRichHandsome;

这个共同体中有两个成员变量,一个是char类型的bits,一个是一个结构体。那么这个共同体需要内存分配多少空间呢?共同体占多少内存空间取决于占内存空间最大的那个成员变量,这个bits和这个结构体都是占一个字节,所以结构体是占一个字节。
然后我们就可以利用这个共同体进行取值和赋值:

- (void)setRich:(BOOL)rich{

    if (rich) {
        _tallRichHandsome.bits |= RichMask;
    }else{
        _tallRichHandsome.bits &= ~RichMask;
    }
}

- (void)setTall:(BOOL)tall{

    if (tall) {
        _tallRichHandsome.bits |= TallMask;
    }else{
        _tallRichHandsome.bits &= ~TallMask;
    }
}

我们可以看到,这其实和之前使用成员变量_tallRichHandsome几乎是一样的。共同体中的结构体基本是不起作用,我们在取值和赋值的时候都没有用到,它唯一的作用就是其解释作用,让我们知道最右边那位代表rich,倒数第二位代表tall,倒数第三位代表handsome。我们甚至可以把这个结构体删除都不影响。

再看isa的结构
union isa_t 
{
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }

    Class cls;
    uintptr_t bits;


#   define ISA_MASK        0x0000000ffffffff8ULL
#   define ISA_MAGIC_MASK  0x000003f000000001ULL
#   define ISA_MAGIC_VALUE 0x000001a000000001ULL
    struct {
        uintptr_t nonpointer        : 1;
        uintptr_t has_assoc         : 1;
        uintptr_t has_cxx_dtor      : 1;
        uintptr_t shiftcls          : 33// MACH_VM_MAX_ADDRESS 0x1000000000
        uintptr_t magic             : 6;
        uintptr_t weakly_referenced : 1;
        uintptr_t deallocating      : 1;
        uintptr_t has_sidetable_rc  : 1;
        uintptr_t extra_rc          : 19;
#       define RC_ONE   (1ULL<<45)
#       define RC_HALF  (1ULL<<18)
    };
};

再看到这个结构应该要熟悉很多了。现在我们就知道,这个共同体的所有信息都存储在bits中。ISA_MASK,ISA_MAGIC_MASK,ISA_MAGIC_VALUE都是掩码,用bits和这些掩码相与就可以得到nonpointer,has_assoc,shiftcls等信息。nonpointer,has_assoc,shiftcls也都采用了位域,它们占用的位数都在后面标出了。

  • shiftcls
    存储着Class,Meta-Class对象的内存地址

  • nonpointer
    0,代表普通的指针,存储着Class,Meta-Class对象的内存地址
    1,代表优化过,使用位域存储更多的信息

  • has_assoc
    是否有设置过关联对象,如果没有,释放时会更快

  • magic
    用于在调试时分辨对象是否未完成初始化

  • weakly_referenced
    是否有被弱引用指向过,如果没有,释放时会更快

  • deallocating
    对象是否正在释放

  • extra_rc
    里面存储的值是引用计数器减1

这里面比较重要的就是shiftcls这个成员变量,这个成员变量里面存放的就是Class,Meta-Class的地址值,要取得这个值,需要bits & ISA_MASK,我们把ISA_MASK展开:0b0000000000000000000000000000111111111111111111111111111111111000,这里面有33个1,也就是取了bits中的33位,但是这样一来就有个问题,它们相与后的最后三位是一定为0,那么我们打印一下对象的地址来验证一下。

NSLog(@"%p\n %p\n", [Person class], object_getClass([Person class]));

打印结果:

0x10313cf58
0x10313cf80

一个结尾是8,一个结尾是0,由于都是16进制,所以后面都是音3个0结尾。

class详解

05A81BEA-1E16-4E40-BF03-395BB6DDAA7C.png

这个图是实例对象,类对象,元类对象的一个结构图,从这个图中我们可以看出,类对象和元类对象的结构其实非常相似,不过是在方法里面,类对象存储的是对象方法,元类对象存储的是类方法,其实我们可以把元类对象看成是特殊的类对象。

class_rw_t结构

我们在runtime的源码中搜索objc_class,然后在obj-runtime-new.h这找到了class的结构:

struct objc_class : objc_object {
    // Class ISA;
    Class superclass;
    cache_t cache;             // 方法缓存
    class_data_bits_t bits;    // 用于获取类的具体信息

    class_rw_t *data() { 
        return bits.data();
    }
};

通过对isa的学习,现在我们队bits应该有了更多的认识,它包含着很多的信息。通过bits & FAST_DATA_MASK就可以得到class_rw_t这个结构体。rw的意思是readwrite,即可读可写。类的主要信息都是存储在class_rw_t中:

struct class_rw_t {
    // Be warned that Symbolication knows the layout of this structure.
    uint32_t flags;
    uint32_t version;

    const class_ro_t *ro;

    method_array_t methods;    //方法列表
    property_array_t properties; //属性列表
    protocol_array_t protocols;   //协议列表

    Class firstSubclass;
    Class nextSiblingClass;

    char *demangledName;
};

在class_rw_t中可以存放方法列表,属性列表,协议列表等等。还有一个指向class_ro_t这个结构体的指针ro。再看一下class_ro_t的结构:

struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize;
#ifdef __LP64__
    uint32_t reserved;
#endif

    const uint8_t * ivarLayout;

    const char * name;   //类名
    method_list_t * baseMethodList;
    protocol_list_t * baseProtocols;
    const ivar_list_t * ivars;    //成员变量列表

    const uint8_t * weakIvarLayout;
    property_list_t *baseProperties;

    method_list_t *baseMethods() const {
        return baseMethodList;
    }
};

ro的意思其实就是readonly,也就是只读。
所以总结一下,class的结构就是这样:

BFBF37E8-B460-4A57-854D-F26A38BDFA08.png

那这里就有一个疑问了,在class_rw_t中已经有了各种列表来装方法,属性和协议那为什么在class_ro_t中还有列表来装方法协议成员变量等等呢?原因就是在_class_ro_t中存放的是类初始化时本身的方法,协议,成员变量等,是不包括分类的,因此这些是不能改变的,只读的。而class_rw_t中的方法列表,属性列表,协议列表等包含的是该类本身加上分类的所有的方法,属性,协议,因此是可变的,即可写。

然后我们再看一下class_rw_t中的方法列表:

    method_array_t methods;
    property_array_t properties;
    protocol_array_t protocols;

然后我们点进method_array_methods看看:

class method_array_t : 
    public list_array_tt<method_tmethod_list_t
{
    typedef list_array_tt<method_tmethod_list_t> Super;

通过这个结构我们能看到method_array_t是一个二维数组,也就是第一层也是一个数组,每个数组元素是method_list_t,这又是一个一维数组,这个一维数组中存放的每个元素是method_t。
property_array_t,protocol_array_t都是这种结构,是二维数组。
也即是下图这种结构:

D1B11435-F41F-4E17-8221-4C4D829CACA3.png

我们再看一下class_ro_t中的方法类表:

    method_list_t * baseMethodList;
    protocol_list_t * baseProtocols;
    const ivar_list_t * ivars;

我们知道,method_list_t就是一个一维数组,而这个一维数组里面装的就是method_t,即下图这种结构:

A52B5508-89C6-4159-A10A-2E1CF2E7B4DA.png
下面我们分析一下class_ro_t和class_rw_t中数组类型不同的道理:
我们知道,class_ro_t中存放的方法属性协议等属于类本身的,那这样我只需要一个一维数组来存放类的方法和属性,一个一维数组来存放协议,完全没必要用一个二维数组。但是class_rw_t就不一样了,它会存放分类的方法,协议吗,属性,创建一个二维数组,将每个分类的方法放在一个数组中,最后一个数组中放类本身的方法,这样就非常科学方便。

528CC59C-2538-4E7E-90C1-5BBCDDCF0C2C.png
上面这段代码可以说明class_rw__t和class_ro_t的先后顺序。
总结起来就是cls->data()先指向class_ro_t,这个时候还没有class_rw_t,然后再给class_rw_t分配内存,让其ro指针指向class_ro_t,最后让cls->data()指向class_rw_t。**

method_t结构

不管是class_rw_t还是class_ro_t的方法列表,最小的单位一定是method_t这个结构,下面我们就一起来看一下这个结构:

struct method_t {
    SEL name;//就是方法名
    const char *types;  //编码 (返回值类型,参数类型)
    IMP imp; //指向函数的指针 (函数地址)
};

method_t结构体中第一个成员是SEL类型的name,这个name就是方法名,比如说方法是- (void)test;,那name就是test,如果方法是- (void)test:(int)age,那name就是test:

SEL代表方法/函数名,一般叫做选择器,底层结构和char指针类似
可以通过@selector()和sel_registerName()获得
可以通过sel_getName()和NSStringFromSelector()将SEL转成字符串
不同类中相同名字的方法,所对应的方法选择器是相同的。

//可以使用@selector()和sel_registerName()获取方法选择器
        SEL sel1 = @selector(init);
        SEL sel2 = sel_registerName("init");
        NSLog(@"%s %s",sel1, sel2);

这样打印的结果是:

init init

这也说明了方法选择器其实就是方法名。

//使用sel_getName()将SEL转化为char类型,使用NSStringFromSelector()转化为字符串
        SEL sel1 = @selector(init);
        SEL sel2 = sel_registerName("init");
        char *str1 = sel_getName(sel1);
        NSString *str2 = NSStringFromSelector(sel2);
        NSLog(@"%s %@",str1, str2);

使用sel_getName()将SEL转化为char类型,使用NSStringFromSelector()转化为字符串。

第二个成员变量是char类型的指针types,它包含的信息是函数的返回值类型和参数类型。我们先看一下这个types长什么样。
我们在Person类中写了一个方法:

//Person.m
@implementation Person

- (int)testHeight:(float)height age:(int)age{

    return 0;
}

@end

然后我们将其转化为c++的源码,在源码中国我们直接拖到最下面,找到_class_ro_t这个结构,刚刚讲过,在这个结构中存着类本身的一些方法等信息:

static struct _class_ro_t _OBJC_CLASS_RO_$_Person __attribute__ ((used, section ("__DATA,__objc_const"))) = {
    0sizeof(struct Person_IMPL), sizeof(struct Person_IMPL), 
    (unsigned int)0
    0
    "Person",
    (const struct _method_list_t *)&_OBJC_$_INSTANCE_METHODS_Person,
    0
    0
    0
    0
};

这个比较简单,我们通过名称就可以知道_OBJC_$_INSTANCE_METHODS_Person这个结构体中国应该存放的是实例方法,然后我们找到这个结构体:

static struct /*_method_list_t*/ {
    unsigned int entsize;  // sizeof(struct _objc_method)
    unsigned int method_count;
    struct _objc_method method_list[1];
} _OBJC_$_INSTANCE_METHODS_Person __attribute__ ((used, section ("__DATA,__objc_const"))) = {
    sizeof(_objc_method),
    1,
    {{(struct objc_selector *)"testHeight:age:""i24@0:8f16i20", (void *)_I_Person_testHeight_age_}}
};

由于整个类中只有一个方法,所以这个结构体中也只会有一个方法的描述:

{(struct objc_selector *)"testHeight:age:""i24@0:8f16i20", (void *)_I_Person_testHeight_age_}

第一个参数testHeight:age:是方法选择器,也即是方法名,这第二个参数"i24@0:8f16i20"那就是types了,第三个参数(void *)_I_Person_testHeight_age_这就是方法实现的地址。
"i24@0:8f16i20"这个看起来应该是一脸懵逼,这是因为这个字符串使用了iOS中的字符串编码,具体来说就是:iOS提供了一个叫做@encode的指令,可以将具体的类型表示成字符串编码,部分编码如下:

CodeMeaning
cchar
iint
sshort
llong
qlong long
Cunsigned char
Iunsigned int
Sunsigned short
Lunsigned long
Qunsigned long long
ffloat
ddouble
vvoid
*char *
@id
#Class
:SEL

再看types字符串:"i24@0:8f16i20"
这里面i是int类型,@是id类型,:是SEL类型,f是float类型,i是int类型。还有一个需要搞明白的是,我们这个函数到底有几个参数?答案是四个,我们之前讲过,不管这个函数自己有没有带参数,它本身会带有self和_cmd这两个参数,self是id类型,_cmd是SEL类型。所以这四个参数分别是id类型的self,SEL类型的_cmd,float类型的height,int类型的int。
我们把类型和数字分开:"i 24 @ 0 : 8 f 16 i 20"
i值的是返回值是int型,24值这所有参数占24字节,@指第一个参数self,0指self参数是从第0字节开始,:指第二个参数为_cmd,8指第二个参数从第8字节开始,因为第一个参数self是指针占8字节,f指第三个参数类型是f,16指第三个参数从第16字节开始,因为第二个参数也是指针类型,占8字节,指第四个参数是int型,20指第四个参数从第20字节开始,因为第三个参数占四个字节。

用cache_t进行方法缓存

在class结构中有一个成员变量cache_t类型的cache,这个成员变量是用来进行方法缓存的。

image.png

如果没有这个缓存,我们可以想象一下方法调用的过程,如果是调用对象方法,那么首先通过对象方法的isa指针找到类对象,然后去类对象的class_rw_t结构体的methods数组中去查找方法,这个methods数组是一个二维数组,我们只能通过循环去遍历数组来查找方法,如果类对象中找不到该方法,就要通过superclass指针找到其父类的类对象,然后去父类中去查看,这样一级一级查看,这样使非常繁琐和浪费时间的。
使用缓存后,我们首先就会去缓存中去查找这个方法,缓存中没有再去类对象中查找,然后将其加入缓存中方便下去调用。
下面我们看一下cache_t的结构:

struct cache_t {
    struct bucket_t *_buckets;   //散列表
    mask_t _mask;        //散列表的长度减1
    mask_t _occupied;  //已经缓存的方法数量
};

第一个成员_buckets是一个散列表结构,缓存的方法的各种信息都是存放在这个散列表中。我们查看一下bucket_t这个结构体的结构:

struct bucket_t {
    cache_key_t _key;    //用方法选择器SEL作为key
    IMP _imp;                //函数的内存地址
};

通过这个结构体我们可以看到bucket_t这个结构中保存着两个内容,一个是_key也即是缓存的方法的选择器SEL,还有一个就是_imp也即是缓存的方法的地址。
散列表的结构大概是下表的样子:

0bucket_t (_key, _imp)
1bucket_t (_key, _imp)
2bucket_t (_key, _imp)
3bucket_t (_key, _imp)
4bucket_t (_key, _imp)
........

那么当我们想要调用一个方法,拿到一个方法的SEL后,怎么在上表中找到对应的方法地址也就是_impl呢?一个比较笨的方法就是拿着要调用方法的SEL去这个表中一个一个比对,找到了匹配的key就将其中对应的_imp取出来实现调用。这样确实能够实现,但是这就和普通的数组没有任何区别了。

在cache_t的结构中有一个_mask,这个是散列表的长度,首先我们要知道的是散列表的长度在一开始初始化的时候就给定了,所以_mask也是一个固定值。当我们要缓存一个方法时,比如这个方法叫test,那么就用@seledctor & _mask来产生一个索引,比如这个索引是3,那就把这个方法缓存在散列表的3这个位置。

当我们要去缓存中拿到这个函数的_imp时,就用这个函数的SEL:@selector(test) & _mask,这样来获取函数的位置,这样获取了函数的位置是3,那我们就从这个位置取出函数的_imp,从而调用函数。

0NULL
1NULL
2NULL
3bucket_t (_key = @selector(test), _imp)
4NULL
........

这里我们也能看出一些问题,就是方法在内存中不是连续存储的,可能有很多格子空着,这就是用内存空间换区时间。另外,@seledctor(test) & _mask是一定不超过_mask的,所以不用担心超过散列表的最大长度。

但是这样做其实也有一个问题,就是两个不同的方法,可能@seledctor(test) & _mask@seledctor(test2) & _mask得出的索引都是4,那么它们的存储到缓存中和从缓存中取出都会有问题。这个问题怎么办呢?我们找到bucket_t * cache_t::find(cache_key_t k, id receiver)这个方法:

C9FF692E-1ADA-4C3D-A0EE-0AC59B8037F9.png

cache_next函数:

static inline mask_t cache_next(mask_t i, mask_t mask) {
    return i ? i-1 : mask;
}

这里的意思就是当i不为0的时候就去索引这个i-1,当i为0那就去索引mask也就是最后一个。
总结一下,处理办法就是:如果@seledctor(test) & _mask@seledctor(test2) & _mask得出的索引都是4,且test2方法是后缓存的,那么这时散列表中4这个位置已经被占了,这时要缓存test2,就去找3这个位置,3不行再去找2,一直到0,再不行就回去找最后一个。这时缓存的时候,调用的时候,如果@seledctor(test2) & _mask的结果是4,且@selector(test2)和4这个位置key不匹配,那么就去找3这个位置的key看匹不匹配,依次找到0这个位置,若是还不匹配那就回过来找最后一个位置。

作者:雪山飞狐 投稿
链接:https://www.jianshu.com/p/bbc33b55d4b7

搜索CocoaChina微信公众号:CocoaChina
微信扫一扫
订阅每日移动开发及APP推广热点资讯
公众号:
CocoaChina
我要投稿   收藏文章
上一篇:iOS:一用就上瘾的刮刮乐视图
我来说两句
发表评论
您还没有登录!请登录注册
所有评论(0

综合评论

相关帖子

sina weixin mail 回到顶部