作者:小白也坚强_177 | 来源:互联网 | 2023-09-24 13:49
1. 项目背景
注解源自于java,是在 JDK5 时引入的新特性,注解(也被称为元数据)为我们在代码中添加信息提供了一种形式化的方法,使我们可以在稍后某个时刻非常方便地使用这些数据。注解类型定义指定了一种新的类型,一种特殊的接口类型。 在关键词 interface 前加 @ 符号也就是用 @interface 来区分注解的定义和普通的接口声明。
注解的好处:
I.减少重复代码的书写,相同逻辑统一处理,降低出错率
II.复杂逻辑清晰化
III.降低代码耦合
但是在iOS中并没有注解的概念,鉴于注解的这些好处,就有了自定义iOS注解的想法。
2. 注解方案的实现思路
要模拟注解的过程,需要解决:
1. 不影响以前有的业务。
2. 在被注解的源代码实现里面能方便的获取注解内容,可以理解为被注解的代码,在编译期间能自动生成一段代码在被注解类里面,或者我们需要建立一个“被注解者”与“注解代码”的对应关系。
2.1 方案1
基于正则匹配,然后生成对应框架代码,加上自己OC实现的自定义规则,配合扫描结果关系表,来模拟注解的过程;
阿里的OCAnnotation(仓库地址:GitHub - alibaba/OCAnnotation: A light-weighted framework empowering Objective-C with annotation feature.)就是以方这个方案实现的,工程包括看一套ruby脚本,需要让其嵌入到我们的目标工程的build script里面。在我们编译期间将执行该脚本,该脚本将会扫码我们所有的源代码,并按规则生成对应的模板OC文件,该文件为一个配置对应关系,可理解为一个hashmap字典。
说白了就是,通过在编译期间,调用正则匹配脚本,扫码并获取注解与目标对象之间的关系(类,方法,属性)。并且把这个对应关系保存到一个字典里面去,这个字典以头文件,是ruby脚本扫码结束后自动创建的OC文件。当我们把这OC文件导入进去目标工程,在启动后马上加载进入内存,作为全局可访问数据,然后我们就可以使用该全局数据【配置表】和 我们自己定义的规则,来达到运行期间的注解校验。当然该工程有很大缺陷是,每次编译都要扫码源代码,虽然作者做了缓存,还有就是不支持framework.在组件化遍地开花的今天,这也很尴尬!
备注:为了解决“被注解者”与“注解代码”的桥梁问题,还有一种办法是生成注解对象的类别,如当前目标是被注解者,那么久生成改其类别扩展,并导入工程预编译中。这样的“类代码插庄”,也能间接的让我们获取到类外的注解内容。当然这个也一样存在编译期间扫描代码的问题,而且如果注解多,还会增加代码量。
2.2 方案2
基于类似FB的编译可配置来模拟的,用到__attribute((used, section("__DATA,"#sectname" “)))
目前beehive组件化框架也使用了此类方案[GitHub - alibaba/BeeHive: BeeHive is a solution for iOS Application module programs, it absorbed the Spring Framework API service concept to avoid coupling between modules.]
通过参考beehive组件化框架,我们最终选择编译期配置的方案,该方案可拆分为三个部分实现:
第一步:__attribute__机制在编译期插桩
第二步,运行时 从Mach-o的section data段 取出数据
第三步,针对性解析处理
3. 项目使用指南
以NeedLogin注解为例
以宏定义形式,仅需在需要使用注解的方法前面添加
@NeedLoginOCAnnotation(GlobalModuleRouter, jumpToAccreditViewController)
swift类使用
// 检测报告 关注按钮
@NeedLoginAnnotation(CheckReportViewController, collectAction, CheckReportModule)
第一个参数为类名,第二个参数为方法名,第三个参数为模块名(swift类),OC类传OC
比如首页中跳转录入车源方法:
如上图所示,原有代码仅可支持是否登录判断,未登录拉起登录页,已登录直接跳转录入车源。添加注解后,登录判断逻辑就不需要了,不仅支持是否登录判断,还支持未登录用户登录成功后自动跳转录入车源。
4. 实现原理
第一步:__attribute__机制在编译期插桩
__attribute__是在C, C++, Objective-C语言中使用的编译指令,一般以__attribute__(xxx)的形式出现在代码中,方便开发者向编译器表达某种要求,参与控制如Static Analyzer、Name Mangling、Code Generation等过程。
关于Attribute的语法描述见官方文档 Attribute Syntax:Attribute Syntax (Using the GNU Compiler Collection (GCC))
used
Used的作用是告诉编译器,我声明的这个符号是需要保留的。被used修饰以后,意味着即使函数没有被引用,在Release下也不会被优化。如果不加这个修饰,那么Release环境链接器会去掉没有被引用的段。具体的描述可以看gun的官方文档。
section
通常情况下,编译器会将对象放置于DATA段的data或者bss节中。但是,有时我们需要将数据放置于特殊的节中,此时section可以达到目的。例如,BeeHive中就把module注册数据存在__DATA数据段里面的"BeehiveMods"section中。
section通常用于修饰全局变量。
__attribute__的更多使用示例可参考FBTweak
编译器提供了我们一种__attribute__((section("xxx段,xxx节")的方式让我们将一个指定的数据储存到我们需要的节当中。
第二步,读取section中的值
现在来了解如何将存储在特殊section中的数据读出。
其中void initProphet()使用了__attribute__((constructor))修饰,
constructor / destructor
顾名思义,构造器和析构器,加上这两个属性的函数会在分别在可执行文件(或 shared library)load 和 unload 时被调用,可以理解为在 main() 函数调用前和 return 后执行:
constructor 和 +load 都是在 main 函数执行前调用,但 +load 比 constructor 更加早一丢丢,因为 dyld(动态链接器,程序的最初起点)在加载 image(可以理解成 Mach-O 文件)时会先通知 objc runtime 去加载其中所有的类,每加载一个类时,它的 +load 随之调用,全部加载完成后,dyld 才会调用这个 image 中所有的 constructor 方法
所以 constructor 是一个干坏事的绝佳时机:
所有 Class 都已经加载完成,main 函数还未执行
无需像 +load 还得挂载在一个 Class 中
若有多个 constructor 且想控制优先级的话,可以写成 attribute((constructor(101))),里面的数字越小优先级越高,1 ~ 100 为系统保留。
void initProphet()函数的实现体里使用了_dyld_register_func_for_add_image函数,现在看看该函数的作用。
_dyld_register_func_for_add_image:这个函数是用来注册回调,当dyld链接符号时,调用此回调函数。在dyld加载镜像时,会执行注册过的回调函数;当然,我们也可以使用下面的方法注册自定义的回调函数,同时也会为所有已经加载的镜像执行回调:
通过调用TTPReadConfiguration函数,我们就可以拿到之前注册到TTPNeedLogin特殊段里面的字典参数,该函数返回字符串的数组示例:[“{\”cls\":\""#cls"\",\"sel\":\""#sel"\",\"module\":\""#module"\"}, …]。
5. 遇到问题
- 常量名唯一性。编译期通过attribute机制插桩是通过定义全局c++变量实现的,那么就有可能出现重名的问题,原来的想法是以类名+方法名作为变量名,但是带参的方法名有冒号(:),变量名不能存在冒号,后将类名+行号+计数 拼接成变量名, 可保持唯一性。
- Swift类中不能使用宏定义
I. 再写一套Swift注解,属性包装器结合单例 判断是否aspects已hook。\\因 定义时 不执行初始化方法 放弃该方案
II. OC类中添加swift类方法的注解 \\有问题,使用需谨慎
1、类名 需加模块名.
2、方法名 与swift中定义的名称不同,需使用生成的OC方法名,所以需要添加@objc 3、.target-action 可以被hook,自定义的其他方法hook会失败,但经销商项目中使用时暂未发现该问题
III. 读取plist文件 \\较麻烦
IV. 单例强行处理 \\太low
结合上述方案,我们选择在OC类中添加swift类方法的注解 - aspects hook 有缺陷
1、不能多次hook 在基类中被继承的方法
2、hook 本方法 在block中不支持延时处理 非前插后插 (已解决)
利用NSInvocation系统类,通过原方法的方法签名、参数、调用对象,在block中再次调用原方法。
3、仅支持hook实例方法 (已解决)
根据对象方法的调用机制,我们知道类方法存放在其元类中,类对象的ISA指针指向元类,就可通过类对象获取其元类,通过去hook元类的实例方法的方式,实现了对类方法的hook。
4、 类方法与实例方法重名只能被hook一个
5、swift target-action 可以被hook,自定义的其他方法hook有可能会失败,但暂未在经销商项目中复现。
注解中,虽可以实现对类方法和实例方法的区分,但是考虑到类方法与实例方法重名只能被hook一个 ,所以注解库中优先hook实例方法,若为实现实例方法,再去hook类方法,所以使用时需注意。
注:翻阅了很多文章,具体链接也不记得了,仅以记录