iOS底层原理 | 揭开 Objective-C Runtime 的面纱

写在前面

还记得初学 Objective-C 时,书中介绍 Objective-C 是面向对象语言,是 C 语言的超集,是一门动态语言。当时不知道“动态”所谓何意,直到理解了 Runtime 这一概念才明白动态的意思。本文试图对 Runtime 相关概念进行梳理并介绍其应用,来真正发现 Objective-C 这门语言的魅力。

Objective-C 的简介及其工作原理(Runtime)

C 语言是面向过程语言,Objective-C 是面向对象语言。前者以方法为核心,后者是以数据(对象)为核心。“对象”(object)就是基本构造单元,我们通过对象来存储和传递数据。对象之间专递数据并执行任务的过程就叫做“消息传递”(Messaging)。所以想要真正了解 Objective-C 这门语言,就要理解“对象”(object)和“消息传递”(Messaging)这两个概念。稍后会重点介绍这两个概念。

相对于其他面向对象语言,Objective-C 的动态性是如何实现的呢?先用 C++ 举例,C++ 语言是硬编码,编译后就知道的哪些对象调用哪些方法。而 Objective-C 对象类型并非在编译器就绑定好了,而是要在运行期查找。这里说的运行时就是 runtime,它是一套用 C 和汇编实现的底层库,是一套 API <objc/runtime.h>,它提供了一系列方法使得 Objective-C 具有动态性,方便程序员们可以利用这些方法实现强大的操作。常见的比如:关联对象(Associated Object)、方法调配(Method Swizzling)和消息转发机制,这些技术稍后也会做出详细介绍并给出 Demo。

研究方法

Tip:学习 runtime 有两条途径,一是分析 runtime 源码,二是分析 NSObject 类对象。第一种是底层到应用层自底向上的研究方法,第二种反之。这里借助一条命令,将 Objective-C 源代码变换为 C++ 的源代码。

clang -rewrite-objc 源代码文件名

理解“类对象”

Objective-C 中几乎所有的对象都继承自 NSObject,Objective-C 对象的本质是指向某块内存数据的指针,其包含了一个Class结构体类型的isa成员变量。

@interface NSObject <NSObject> {
    Class isa;
}

点开Classtypedef struct objc_class *Class中可以看到,Class是一个objc_class类型的结构体。看一下 objc_class 的结构:

struct objc_class {
    Class isa;
    Class super_class;
    const char *name;
    long version;
    long info;
    long instance_size;
    struct objc_ivar_list *ivars;
    struct objc_method_list *methodLists;
    struct objc_cache *cache;
    struct objc_protocol_list *protocols;
} 

1.isa 元类(metaclass);
2.super_class 父类;
3.name 类名;
4.verson 类的版本信息,默认为0,可以通过runtime函数class_setVersion或者class_getVersion进行修改、读取;
5.info 类信息,供运行时期使用的一些位标识,如CLS_CLASS (0x1L) 表示该类为普通 class,其中包含实例方法和变量;CLS_META (0x2L) 表示该类为 metaclass,其中包含类方法;
6.instance_size 该类的实例变量大小(包括从父类继承下来的实例变量);
7.ivars 该类的成员变量地址列表;
8.methodLists 方法地址列表,与 info 的一些标志位有关,如CLS_CLASS (0x1L),则存储实例方法,如CLS_META (0x2L),则存储类方法;
9.cache 缓存最近使用的方法地址,用于提升效率;
10.protocols 存储该类声明遵守的协议的列表。

此结构体存放类的“元数据” (metadata),例如类的的实例实现了几个方法,具有多少个实例变量等信息。此结构体的首个变量也是 isa 指针,这说明 Class 本身也是 Objective-C 对象。结构体里还有一个变量叫做 super_class,它定义了本类的超类。类对象所属的类型(也就是 isa 指针所指向的类型)是另外一个类,叫做“元类” (metaclass), 用来表述类对象本身所具备的元数据。“类方法”就定义于此处,因为这些方法可以理解成类对象的实例方法。每个类仅有一个“类对象”,而每个“类对象”仅有一个与之相关的“元类”。

objective-runtime-1.png

super_class 指针确立了继承关系,而 isa 指针描述了实例所属的类。通过这张布局关系图即可执行“类型信息查询”。我们可以查出对象是否能响应某个方法,是否遵从某项协议,并且能看出此对象位于“类继承体系”(class hierarchy)的哪一部分。

理解消息传递 objc_msgSend 方法

创建工程添加一个 Person 类,在 main 中初始化并向 Person 类的实例发送消息,如下:

int main(int argc, const char * argv[]) {
    @autoreleasepool {

        Person *xiaoming = [Person new];
        [xiaoming sayHello];

    }
    return 0;
}

下面用之前提到的 clang -rewrite-objc main.m 命令对 main.m 文件进行转换,运行命令结束生成了一个 main.cpp 文件。打开该文件,定位到关键代码,上面源代码转换后如下:

int main(int argc, const char * argv[]) {
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; 

        Person *xiaoming = ((Person *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("Person"), sel_registerName("new"));
        ((void (*)(id, SEL))(void *)objc_msgSend)((id)xiaoming, sel_registerName("sayHello"));

    }
    return 0;
}

在对象上调用方法是 Objective-C 中最常用的功能,用 Objective-C 的术语来说,这叫做“传递消息”(pass a message)。消息有“名称”(name)或“选择子”(selector),可以接受参数,可能还有返回值。上文说了 Objective-C 是一门动态语言,因为向某个对象传递消息,会用到“动态绑定”(dynamic binding)机制来决定需要调用的方法。在底层,所有方法都是普通的 C 语言函数,然而对象收到消息之后,究竟该调用哪个方法完全于运行期决定,甚至可以在程序运行时改变。下面来看这句代码:

[xiaoming sayHello];

在本例中,xiaoming 叫做“接收者”(receiver),sayHello 叫做“选择子”(selector)。选择子与参数合起来称为“消息”(message)。编译器看到此消息后,将其转换为一条标准的 C 语言调用,所用函数是消息传递机制中的核心函数,叫做 objc_msgSend,其原型如下:

void objc_msgSend(id target, SEL cmd, ...)

这是个“可变参数函数”,能接受两个或两个以上参数。第一个参数代表接收者,第二个参数代表选择子,后续参数即消息中的那些参数,顺序不变。因此,编译器会把上个例子转换如下函数:

objc_msgSend(xiaoming, @selector(sayHello));

objc_msgSend 函数会根据接收者和选择子调用适当的方法。该函数会在接收者所属类中查询其“方法列表”(list of methods),如果能找到与选择子相符的方法,就跳转至其实现代码。若是找不到,就沿着继承体系向上查找,找到合适的方法之后跳转。如果最终还是找不到相符的方法,那就执行“消息转发”(message forwarding)操作。如下图:

messaging1.gif

具体步骤说明:

  1. 检测当前 selector 是不是要忽略的。比如 Mac OS X 开发,有了垃圾回收就不理会 retain, release 这些函数了。
  2. 检测当前 target 是不是 nil 对象。ObjC 的特性是允许对一个 nil 对象执行任何一个方法不会 Crash,因为会被忽略掉。
  3. 如果上面两个都过了,那就开始查找这个类的 IMP,先从 cache 里面找,完了找得到就跳到对应的函数去执行。
  4. 如果 cache 找不到就找一下方法分发表。
  5. 如果分发表找不到就到超类的分发表去找,一直找,直到找到 NSObject 类为止。
  6. 如果还找不到就要开始执行消息转发了,后面会提到。

补充:前面讲的是部分调用过程,还有其他“边界情况”(edge case)则需要 runtime 中其他函数来处理:
objc_msgSend_stret 如果待发送的消息要返回结构体,可交由此函数处理。
objc_msgSend_fpret 如果消息返回浮点数,可交由此函数处理。
objc_msgSendSuper 如果要给超类发送消息,可交由此函数处理。

消息转发

利用上述工程,为 Person 类添加一个 coding 方法,但是不实现该方法。运行程序,结果程序报错,控制台提示错误原因: reason: '-[Person coding]: unrecognized selector sent to instance 0x100448ad0'

若想令类能理解某条消息,我们必须实现对应方法。但是,在编译期向类发送了其无法理解的消息并不会报错,因为运行期可以继续向类中添加方法,所以编译器在编译时还无法确定类中到底会不会有某个方法实现。当对象接收到无法理解的消息后,就会启动“消息转发”(message forwarding)机制。

其实我们已经见过消息转发流程所处理的消息了。例如向一个 NSNumber 对象发送 NSString 对象方法,控制台中输出的如下信息:

-[_NSCFNumber lowercaseString]: unrecognized selector sent to instance 0x1005002d0

上面这段异常信息是由 NSObject 的“doesNotRecognizeSelector:”方法所抛出的,此异常表明:消息接收者的类型是 Person,接收者无法理解的选择子名称为 coding

消息转发分为两个阶段。第一阶段先征询接受者所属的类,看其是否能动态添加方法,以处理当前这个“未知选择子”(unknown selector),这叫做“动态方法解析”(dynamic method resolution)。第二个阶段涉及“完整的转发机制”(full forwarding mechanism)。如果运行系统已经把第一个阶段执行完了,那么接收者自己就无法再以动态新增方法的手段来响应包含该选择子的消息了。此时,运行期系统会请求接收者一其他手段来处理与消息相关的方法调用。这又分为两步。首先,请接收者看看有没有其他对象能处理这条消息。若有,则运行期系统会把消息转给那个对象,于是消息转发过程结束,一切如常。若没有“备援的接收者”(replacement receiver),则启动完整的消息转发机制,运行期系统会把与消息有关的全部细节都封装到 NSInvocation 对象中,再给接收者最后一次机会,令其解决当前还未处理的这条消息。

objective-runtime-6.png
  • 第一步:通过 resolveInstanceMethod: 方法决定是否动态添加方法以处理当前这个“未知选择子”(unknown selector),这叫做“动态方法解析”(dynamic method resolution)。如果返回 Yes 则通过 class_addMethod 动态添加方法,消息得到处理,结束;如果返回 No,则进入下一步;
  • 第二步:这步会进入 forwardingTargetForSelector: 方法,用于指定备选对象响应这个 selector ,不能指定为 self 。如果返回某个对象则会调用对象的方法,结束。如果返回nil,则进入第三步;
  • 第三步:这步我们要通过 methodSignatureForSelector: 方法签名,如果返回 nil ,则消息无法处理。如果返回 methodSignature ,则进入下一步;
  • 第四步:这步调用 forwardInvocation: 方法,我们可以通过 anInvocation 对象做很多处理,比如修改实现方法,修改响应对象等,如果方法调用成功,则结束。如果失败,则进入 doesNotRecognizeSelector 方法,若我们没有实现这个方法,那么就会crash。

关联对象(Associated Object)

当我们想对一个类进行拓展,可以通过继承,在子类中添加方法和属性。有开发经验的知道,从设计模式角度看,继承虽然提供了便利但不是一种友好的设计方式,因为它破坏了封装。设计模式里有一个原则,开闭原则:对扩展开放,对修改关闭。所以推荐使用组合少用继承。Objective-C 中还有另一个方法是在分类(category)中添加方法,但是分类中不能添加成员变量。如果想在分类中添加成员变量就用到了关联对象(Associated Object)技术。

关联对象用到下面三个方法:

void objc_setAssociatedObject(id object, void* key, id value, objc_AssociationPolicy policy)

此方法以给定的键和策略为某对象设置关联对象值。

id objc_getAssociatedObject(id object, void* key)

此方法根据给定的键从某对象中获取相应的关联对象值。

void objc_removeAssociatedObjects(id object)

此方法移除指定对象的全部关联对象。

举例:之前做一个项目为了实现某个需求,需要给 UIButton 添加一个 NSIndexPath 类型的属性,代码实现如下:

#import <UIKit/UIKit.h>

@interface UIButton (IndexPath)

@property (nonatomic, strong) NSIndexPath *indexPath;

@end
#import "UIButton+IndexPath.h"
#import <objc/runtime.h>

@implementation UIButton (IndexPath)

- (void)setIndexPath:(NSIndexPath *)indexPath
{
    objc_setAssociatedObject(self, @selector(indexPath), indexPath, OBJC_ASSOCIATION_COPY);
}

- (NSIndexPath *)indexPath
{
    return objc_getAssociatedObject(self, _cmd);
}

@end

注意:使用关联对象添加属性时,需要指明“存储策略”(storage policy),用以维护相应的“内存管理语义”。存储策略由 objc_AssociationPolicy 的枚举所定义。

typedef OBJC_ENUM(uintptr_t, objc_AssociationPolicy) {
    OBJC_ASSOCIATION_ASSIGN = 0,           /**< Specifies a weak reference to the associated object. */
    OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1, /**< Specifies a strong reference to the associated object. 
                                            *   The association is not made atomically. */
    OBJC_ASSOCIATION_COPY_NONATOMIC = 3,   /**< Specifies that the associated object is copied. 
                                            *   The association is not made atomically. */
    OBJC_ASSOCIATION_RETAIN = 01401,       /**< Specifies a strong reference to the associated object.
                                            *   The association is made atomically. */
    OBJC_ASSOCIATION_COPY = 01403          /**< Specifies that the associated object is copied.
                                            *   The association is made atomically. */
};

关联类型对应的属性内存管理语义:

关联类型 等效的 @property 属性
OBJC_ASSOCIATION_ASSIGN assign
OBJC_ASSOCIATION_RETAIN_NONATOMIC nonatomic,retain
OBJC_ASSOCIATION_COPY_NONATOMIC nonatomic,copy
OBJC_ASSOCIATION_RETAIN retain
OBJC_ASSOCIATION_COPY copy

Method Swizzling

上文提过 Objective-C 对象收到消息后,究竟调用哪个方法需要在运行时才能解析出来。那么,与给定的选择子名称对应的方法是不是也可以在运行期改变呢?没错,就是这样。因此,我们既不需要源代码,也不需要继承子类重写方法就能改变这个类的功能。Method Swizzling 就是改变一个 selector 的实际实现的技术。通过这一技术,我们可以在运行时通过修改类的分发表中 selector 对应的函数,来修改方法的实现。

每个类都维护着一张选择子表(selector table),其实就是方法表。这张表包括一个或多个选择子 SEL ,且 SELIMP 是一一映射关系。

这里涉及两个概念:SELIMP

SELselector 在 Objective-C 中的表示类型。selector 可以理解为区别方法的ID。

typedef struct objc_selector *SEL;

objc_selector 的定义如下:

struct objc_selector {
    char *name;                       OBJC2_UNAVAILABLE;// 名称
    char *types;                      OBJC2_UNAVAILABLE;// 类型
}; // name和types都是char类型。

IMP 是“implementation”的缩写,它是由编译器生成的一个函数指针。当你发起一个消息后(下文介绍),这个函数指针决定了最终执行哪段代码。

typedef id (*IMP)(id, SEL, ...);

例如:NSString 类可以响应 lowercaseStringuppercaseStringcapitalizedString 等选择子。这些选择子都映射到不同的 IMP 上。

屏幕快照 2018-05-24 下午2.01.28.png

接下来介绍如何实现交换两个方法的实现。通过此操作可以为已有方法添加新功能。在讲解添加新功能之前,先来介绍两个函数:

void method_exchangeImplementations(Method m1, Method m2)

此函数可以交换两个方法实现,而方法的实现可以通过下面这个函数获得:

Method class_getInstanceMethod(Class aClass, SEL aSelector)

此函数分局给定的选择从类中取出与之对应的方法。

例如,我们想跟踪在程序中每一个view controller 展示给用户的次数:当然,我们可以在每个 view controller 的 viewDidAppear 中添加跟踪代码;但是这太过麻烦,需要在每个view controller中写重复的代码。创建一个子类可能是一种实现方式,但需要同时创建UIViewController, UITableViewController, UINavigationController 及其它UIKit中view controller的子类,这同样会产生许多重复的代码。

这种情况下,我们就可以使用 Method Swizzling,如在代码所示:

#import <objc/runtime.h>
 
@implementation UIViewController (Tracking)
 
+ (void)load {
        static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];         
        // When swizzling a class method, use the following:
                    // Class class = object_getClass((id)self);
 
        SEL originalSelector = @selector(viewWillAppear:);
                    SEL swizzledSelector = @selector(xxx_viewWillAppear:);
 
        Method originalMethod = class_getInstanceMethod(class, originalSelector);
                    Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
 
        BOOL didAddMethod =
                        class_addMethod(class,
                originalSelector,
                method_getImplementation(swizzledMethod),
                method_getTypeEncoding(swizzledMethod));
 
        if (didAddMethod) {
                        class_replaceMethod(class,
                swizzledSelector,
                method_getImplementation(originalMethod),
                method_getTypeEncoding(originalMethod));
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}
 
#pragma mark - Method Swizzling
 
- (void)xxx_viewWillAppear:(BOOL)animated {
        [self xxx_viewWillAppear:animated];
    NSLog(@"viewWillAppear: %@", self);
}
 
@end

在这里,我们通过Method Swizzling修改了UIViewController@selector(viewWillAppear:) 对应的函数指针,使其实现指向了我们自定义的 xxx_viewWillAppear 的实现。这样,当 UIViewController 及其子类的对象调用 viewWillAppear 时,都会打印一条日志信息。

我们回过头来看看前面新的方法的实现代码:

- (void)xxx_viewWillAppear:(BOOL)animated {
    [self xxx_viewWillAppear:animated];
    NSLog(@"viewWillAppear: %@", NSStringFromClass([self class]));
}

咋看上去是会导致无限循环的。但令人惊奇的是,并没有出现这种情况。在 swizzling 的过程中,方法中的 [self xxx_viewWillAppear:animated] 已经被重新指定到 UIViewController 类的 -viewWillAppear: 中。在这种情况下,不会产生无限循环。不过如果我们调用的是 [self viewWillAppear:animated],则会产生无限循环,因为这个方法的实现在运行时已经被重新指定为 xxx_viewWillAppear: 了。

上面的例子很好地展示了使用 Method Swizzling 来一个类中注入一些我们新的操作。当然,还有许多场景可以使用Method Swizzling,在此不多举例。在此我们说说使用 Method Swizzling 需要注意的一些问题:

Swizzling 应该总是在 +load 中执行

在 Objective-C 中,运行时会自动调用每个类的两个方法。+load 会在类初始加载时调用,+initialize 会在第一次调用类的类方法或实例方法之前被调用。这两个方法是可选的,且只有在实现了它们时才会被调用。由于Method Swizzling 会影响到类的全局状态,因此要尽量避免在并发处理中出现竞争的情况。+load 能保证在类的初始化过程中被加载,并保证这种改变应用级别的行为的一致性。相比之下,+initialize 在其执行时不提供这种保证—事实上,如果在应用中没为给这个类发送消息,则它可能永远不会被调用。

Swizzling 应该总是在 dispatch_once 中执行

与上面相同,因为 swizzling 会改变全局状态,所以我们需要在运行时采取一些预防措施。原子性就是这样一种措施,它确保代码只被执行一次,不管有多少个线程。GCD 的 dispatch_once 可以确保这种行为,我们应该将其作为 Method Swizzling 的最佳实践。

Method Swizzling 注意事项总结

  1. 方法交换应该保证唯一性和原子性
    唯一性:应该尽可能在+load 方法中实现,这样可以保证方法一定会调用且不会出现异常。
    原子性:使用 dispatch_once 来执行方法交换,这样可以保证只运行一次。
  2. 一定要调用原始实现
    由于 iOS 的内部实现对我们来说是不可见的,使用方法交换可能会导致其代码结构改变,而对系统产生其他影响,因此应该调用原始实现来保证内部操作的正常运行。
  3. 方法名必须不能产生冲突
    这个是常识,避免跟其他库产生冲突。
  4. 做好记录
    记录好被影响过的方法,不然时间长了或者其他人 debug 代码时候可能会对一些输出信息感到困惑。
  5. 如果非迫不得已,尽量少用方法交换
    虽然方法交换可以让我们高效地解决问题,但是如果处理不好,可能会导致一些莫名其妙的 bug。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,001评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,210评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,874评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,001评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,022评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,005评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,929评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,742评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,193评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,427评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,583评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,305评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,911评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,564评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,731评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,581评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,478评论 2 352

推荐阅读更多精彩内容

  • 本文详细整理了 Cocoa 的 Runtime 系统的知识,它使得 Objective-C 如虎添翼,具备了灵活的...
    lylaut阅读 798评论 0 4
  • 我们常常会听说 Objective-C 是一门动态语言,那么这个「动态」表现在哪呢?我想最主要的表现就是 Obje...
    Ethan_Struggle阅读 2,192评论 0 7
  • 文中的实验代码我放在了这个项目中。 以下内容是我通过整理[这篇博客] (http://yulingtianxia....
    茗涙阅读 921评论 0 6
  • 本文转自:杨萧玉博客 本文详细整理了 Cocoa 的 Runtime 系统的知识,它使得 Objective-C ...
    oneofai阅读 207评论 0 0
  • 大家好! 如果之前在小红书上有做过的推广的或者在小红书站外已经有不错的口碑的品牌,推荐您直接做kol达人种草引流。...
    c7d0504ddbe7阅读 129评论 0 1