胡 坤, 特日根
(长光卫星技术有限公司 a. 数据中心; b. 吉林省卫星遥感应用技术重点实验室, 长春 130000)
iOS的编程语言objective-C是对C语言的扩展, 加入了面向对象和消息传递机制, 其函数的调用过程是动态的[1], 在编译时并不能决定真正调用的函数, 只有在真正运行时才会根据函数的名称找到对应的函数实体进行调用。而这个消息传递机制的核心是由C语言和汇编语言编写的Runtime库完成的[2], 它是objective-C语言面向对象和动态机制的核心[3]。
Runtime的核心是消息传递, 高级编程语言想要成为可执行文件需要先编译为汇编语言再汇编为机器语言。机器语言也是计算机能识别的唯一语言, 但objective-C并不能直接编译为汇编语言, 而是要先转写为纯C语言再进行编译和汇编操作, 从objective-C到C语言的过渡就是由Runtime实现的[4]。然而C语言是面向过程的编程语言, 而使用objective-C进行面向对象开发时, 就需要将面向对象的类转变为面向过程的结构体实现[5]。
笔者针对实际编程开发中无法修改系统方法的问题, 首先研究Runtime的主要API(Application Programming Interface)接口以及消息传递的本质, 然后使用恰当的接口函数对系统方法动态的修改和替换, 从而提高了开发效率, 降低程序运行故障率。
在objective-C语言中, 实例对象在调用方法时, 编译器在编译阶段不知道调用方法的具体位置, 只有在运行时, 才会向实例对象发送消息, 并通过遍历实例对象进行方法选择。这种机制称为消息机制。Runtime的特性主要是消息传递, 若消息在对象中找不到, 则进行消息转发。
对一个对象的普通方法[object func], 编译器会转成消息并发送objc_msgSend(object,func), 其中objc_msgSend方法定义如下。
定义1 OBJC_EXPORT id objc_msgSend(id self,SEL op,...)
Runtime消息的传递过程为:
1) 系统首先找到消息的接收对象, 然后通过对象的isa指针找到它的类;
2) 在它的类中遍历method_list, 寻找func方法;
3) 若没有func方法则查找父类的method_list;
4) 找到对应的method, 执行其IMP;
5) 转发IMP的return值。
以下6个小节为消息传递中的概念。
1.1.1类对象(objc_class)
Objective-C类是由Class类型表示的, 它实际上是一个指向objc_class结构体的指针。其中objc_class方法定义如下。
定义2 typedef struct objc_class*Class;
struct objc_class {
Class _Nonnull isa;
#if!__OBJC2__
Class_Nullable super_class;
const char*_Nonnull name;
long version;
long info;
long instance_size;
struct objc_ivar_list*_Nullable ivars;
struct objc_method_list*_Nullable*_Nullable methodLists;
struct objc_cache*_Nonnull cache;
struct objc_protocol_list*_Nullable protocols;
#endif
}
该结构体的第1个成员变量也是isa指针, 这说明了Class本身其实也是一个对象, 因此称之为类对象。类对象在编译期产生用于创建实例的对象, 在全局中只有一个, 这个唯一的实例, 称之为单例。
1.1.2 实例(objc_object)
类对象中存储了诸多信息, 这些信息描述如何创建一个实例。类对象和类方法应该从isa指针指向的结构体创建, 类对象的isa指针的指向对象称之为元类(metaclass), 元类中保存了创建类对象以及类方法的所有信息。其中实例objc_object的方法定义如下。
定义3 struct objc_object {
Class isa OBJC_ISA_AVAILABILITY;
};
ypedef struct objc_object *id;
1.1.3方法(objc_method)
方法(Method)和人们平时理解的函数是一致的, 即表示能独立完成一个功能的一段代码。方法(Method)定义如下。
定义4 typedef struct objc_method *Method;
struct objc_method {
SEL method_name
char *method_types
IMP method_imp
}
在这个结构体中, SEL和IMP都是Method的属性。
1.1.4 SEL(objc_selector)
方法选择器SEL定义如下。
定义5 typedef struct objc_selector *SEL;
定义1中提及的消息发送函数objc_msgSend的第2个参数类型为SEL, 它是selector在Objective-C中的表示类型。selector是方法选择器, 可以理解为区分方法的ID, 而这个ID的数据结构是SEL, SEL定义为: @property SEL selector。可以看到selector是SEL的一个实例。
1.1.5 IMP函数实现
函数实现IMP定义如下。
定义6 typedef id (*IMP)(id,SEL,...);
#endif
IMP就是指向程序内存地址的指针。在iOS的Runtime中, Method通过selector和IMP两个属性, 实现了快速查询方法及实现, 相对提高了性能, 又保持了灵活性。
1.1.6 Category(objc_category)
Category是表示一个指向分类结构体的指针, 其定义如下。
定义7 struct category_t {
const char*name;
classref_t cls;
struct method_list_t*instanceMethods;
struct method_list_t*classMethods;
struct protocol_list_t*protocols;
struct property_list_t*instanceProperties;
};
从上面的category_t结构体中可以看出, 分类中可以添加实例方法、 类方法, 甚至可以实现协议, 添加属性, 但不可以添加成员变量。
当有消息发送时, 系统会在相关类对象的方法列表中搜索所需方法, 若找不到则会沿着继承树向上搜索, 若搜索到继承树根部(通常为NSObject)时仍未找到, 则此消息转发失败, 并通过执行“doesNotRecognizeSelector:” 方法向系统报错, 其错误提示为“unrecognized selector”。
1) 动态方法解析(resolveInstanceMethod)。首先, objective-C运行时会调用“+resolveInstanceMethod:”, 使之有机会提供一个函数实现。若添加了函数并返回YES, 系统就会重新启动一次消息发送过程。
2) 备用接收者(备用receiver)。若方法“+resolveInstanceMethod:”返回为NO, 而且目标对象实现了“-forwardingTargetForSelector:”方法, 此时Runtime就会调用这个方法, 并把这个消息转发给备用接收者receiver。
3) 完整消息转发。若在上一步还不能处理未知消息, 则唯一能做的就是启用完整的消息转发机制。首先它会发送“-methodSignatureForSelector:”消息获得函数的参数和返回值类型。若“-methodSignatureForSelector:”返回“nil” , Runtime则会发出“-doesNotRecognizeSelector:”消息, 此时程序执行完毕。若返回了一个函数签名(函数签名就是函数的声明信息, 包括参数、 返回值、 调用约定), Runtime就会创建一个NSInvocation对象并发送“-forwardInvocation:”消息给目标对象。
图1 消息转发流程Fig.1 Message forwarding process
完整转发的代码实现以及运行打印结果如图2所示。
图2 消息转发代码及打印Fig.2 Message forwarding code and printing
KVO(Key-Value Observing)是苹果提供的一套事件通知机制。允许对象监听另一个对象特定属性的改变, 并在改变时接收到事件。在经典的MVC设计模式中, 经常用于在Model和Controller之间进行通讯。
KVO实现依赖于Objective-C 的Runtime机制, 当观察某对象A时, Runtime为对象A动态创建一个子类, 并为这个新的子类重写了被观察属性keyPath的setter方法。setter 方法随后负责通知观察对象属性的改变状况。
Apple使用了isa-swizzling技术实现KVO 。当观察对象A时, Runtime创建一个名为“NSKVONotifying_A”的新类, 该类继承自对象A, 且Runtime为NSKVONotifying_A重写观察属性的setter方法, NSKVONotifying_A的setter方法会负责在调用对象A的setter方法前后, 观察对象A属性值的更改情况[6-7]。
在笔者参与的某项目中大量使用KVO监听变量属性的改变, 其中监听键盘改变的代码如下。
//注册键盘出现通知
[[NSNotificationCenter defaultCenter] addObserver:selfselector:@selector(keyboardWillShow:) name:UIKeyboardWillShowNotification object:nil];
//注册键盘消失通知
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardWillHide:)
name:UIKeyboardWillHideNotification object:nil];
由于分类无法添加成员属性, 但可通过Runtime的关联对象进行实现。Runtime的关联对象提供了下面几个接口。
1) 关联对象接口:
void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy)
2)获取关联对象接口:
id objc_getAssociatedObject(id object, const void *key)
3) 移除关联对象接口:
void objc_removeAssociatedObjects(id object)
下例使用Runtime为UIView的分类动态添加“defaultColor”的属性。
图3 分类添加属性代码及打印Fig.3 Add attribute code and print for classification
该替换方案是通过“class_getInstanceMethod”函数获取实例方法(实例方法是类的实例才能调用的方法)的实现, 通过“class_getClassMethod”获取类方法(类方法只能类本身进行调用)实现, 并最终通过“method_exchangeImplementations”方法对两个函数的实现进行调换[8]。
2.3.1 适配旧项目的高系统版本图片问题
在项目开发中, 针对不同的iOS版本, 显示的图片常常不同。若项目中图片的获取方法为[UIImage imageNamed:@“test.png”], 而区分不用版本所需图片的方法是在图片的名称后面加上后缀“_系统版本号”, 则项目中有n处图片需要做版本适配, 同时再有m个版本需要做适配, 则需要新增n×m个图片选择的代码[9-10]。通过Runtime的Method Swizzling方式将自己的方法和系统的“[UIImage imageNamed:]”方法进行替换, 进而减少代码量, 降低代码维护成本[11]。其代码如下。
#import〈objc/runtime.h〉
+(void)load {
Class class=[self class];
//获取想要替换方法实现
Method originalMethod=
class_getInstanceMethod(class,imageNamed:);
//获取自定义方法hkImageNamed实现
Method swizzledMethod=
class_getInstanceMethod(class,hkImageNamed:);
//通过method_exchangeImplementations函数进行IMP替换
method_exchangeImplementations(originalMethod,
swizzledMethod);
}
2.3.2 NSMutableArray中插入空串报错问题
在NSMutableArray数组中, 经常有插入空串报错情况, 编译器报错为“[__NSSetM addObject:] object cannot be nil”。这个错误是因为调用了NSMutableArray的“[arrayM addObject:nil]”方法造成的[12]。若依次在报错位置加上if-else语句进行判断, 将会增加代码维护成本。针对该问题, 同样可以用Runtime的Method Swizzling进行方法替换, 代码如下。
#import 〈objc/runtime.h〉
+(void)load {
Class class=[self class];
//获取想要替换方法实现
Method originalMethod=
class_getInstanceMethod(class,addObject:);
//获取自定义方法hkAddObject:实现
Method swizzledMethod=
class_getInstanceMethod(class,hkAddObject:);
//通过method_exchangeImplementations函数进行IMP替换
method_exchangeImplementations(originalMethod, swizzledMethod);
}
2.3.3 数组越界问题
同样, 数组越界问题也是项目中常见问题[13]。对数组[self.arrays objectAtIndex:int]和self.arrays[int], 若int的值大于arrays元素数量self.arrays.count时, 则会报iOS “reason: ***-[__NSArrayM objectAtIndex:]: index int beyond bounds [0..self.arrays.count-1]”错误[14-15]。针对这样问题, 同样可以用Runtime的Method Swizzling进行方法替换, 代码如下。
#import 〈objc/runtime.h〉
+(void)load {
Class class=[self class];
//获取想要替换方法实现
Method originalMethod=
class_getInstanceMethod(class,objectAtIndex:);
//获取自定义方法hkObjectAtIndex:实现
Method swizzledMethod=
class_getInstanceMethod(class,hkObjectAtIndex:);
//通过method_exchangeImplementations函数进行IMP替换
method_exchangeImplementations(originalMethod, swizzledMethod);
}
笔者首先对objective-C语言的运行时机制Runtime进行了系统剖析, 阐述了Runtime是一套基于C语言的底层API库的集合, objective-C是消息机制, 其方法最终都转化成消息的转发过程, 并通过消息转发流程图, 完整地阐述了消息转发全过程。
然后, 通过对KVO机制分析, 给分类(category)增加属性方法, 验证了Runtime在代码运行过程中起到的作用。并针对项目中常见问题, 提出了基于Runtime方式的解决方案, 使代码更加精简, 降低了代码编码和维护成本, 并证明了该方法可解决系统自带功能不足的问题。