零. 前言
在OC的开发过程中,要尽量避免分类方法同名,否则会发生什么线上问题也很难被察觉出来(一个踩过坑的菜鸡流泪打下这句话),很多开发的同学即使知道这个原理,但在实际开发过程中还是难免会产生脑子一抽写下分类同名方法的情况。
特别是对于大文件的重构,一不小心就在多个分类上面都写下了系统方法,什么dealloc
,viewDidAppear
,轻则影响到了原有的逻辑,重则因为dealloc
等被重写,没有及时释放对象,引发系统崩溃。
所以写个自动检测脚本还是有必要的,起码能降低影响产品功能的不确定因素= =,这个脚本将会在编译时自动调用,如果检测到分类有同名方法的话,就会编译时报错,将bug扼杀在开发阶段中。
一. 思路
思路很简单,无非就是:
读取工程文件.m、.mm的内容和名字 => 取出所有文件的所有方法名 => 以基础文件名(分类则会去除+xxx)
为key、该文件下所有方法名的数组
为Value存进字典中 => 如果分类的方法名已经在字典中,则视为同名方法,报错
二. 匹配出工程的所有方法
1. 匹配单个文件的多个实现
一个文件内可以有多个implementation,不同的implementation可以有相同名字的方法,所以即便读取到单个文件的内容,也有取出多个实现:
# 一个文件内可能有多个实现方法
imple_regex = r'@implementation(.*?)@end'
for imple_string in re.findall(imple_regex, file_string, re.S):
imple_name = imple_string.split('\n')[0]
find_implementation_method_replaced(file, imple_name, imple_string)
对于一些情况,还需过滤一下,以确保获得@implementation xxx (Category)
中的xxx
,还要去除一下空格:
# @implementation xxx { ... }
if imple_name.find('{') != -1:
imple_name = imple_name[:imple_name.find('{')]
# @implementation xxx (Category)
if imple_name.find('(') != -1:
imple_name = imple_name[:imple_name.find('(')]
# 去除空格
imple_name = imple_name.strip()
imple_name = imple_name.rstrip()
2. 匹配出完整方法
有些内容比较简单就不说了,关键在于用Python如何取出所有文件的所有方法名:
对于OC来说,方法名的情况有以下几种:
- (void)func1 {
}
- (NSString *)func2 {
return nil;
}
- (void) func3:(NSString *)params3 {
}
- (void)func4:(NSString *)param4 func4:(NSString *)param4 {
}
- (void) func5:(NSString *)param5
func5:(NSString *)param5 {
}
- (void)func6:(NSString *)param6
func6:(void(^)(NSString *))param6 {
}
- (void(^)(NSString *))func7:(NSString *)param7
func7:(void(^)(NSString *))param7
func7:(void(^)(NSString *))param7
func7:(void(^)(NSString *))param7
func7:(void(^)(NSString *))param7
func7:(void(^)(NSString *))param7 {
return nil;
}
即 -(xxx)func:(xxx)param func:(xxx)param ... {}
,中间可能会穿插若干个空格或者回车键,而且还有可能有block等带括号的变量,如何准确地忽略空格、回车、block里面的括号等干扰因素,多个参数时准确取出参数方法名,还有一些把+ -
当作减号加号的情况,是解决这个问题的关键所在。
另外,对于OC的方法,我们只需关心几个重点:
类方法(+)还是实例方法(-)
方法名字是啥(- func1:func2)
而对于参数名、返回类型,我们无需关心,因为对于有且仅有一个名字的类方法/实例方法。
这时候,正则表达式这个大神器又得祭出来了!真是一个一行顶百行的好东西!(虽然这一行可能要想一天= =)
首先匹配到所有的类方法或者实例方法,以+或者-
开头,且以{
结束,中间可能有若干个括号、空格、换行的若干情况的一段文本:
(\+|\-)\s*\([^;<>=\+\-]*?\)\s*([^;<>=\+\-]*?)\s*\{
上面的正则即可匹配到-(xxx)func:(xxx)param func:(xxx)param ... {}
类似的格式,且把一些可能引起混淆的运算字符踢了出去,以避免加减号干扰到正则了。(注意:+ load方法要排除在外)
3. 对方法进行拆分
方法可能没有参数,也可能有多个参数,这时候我们需要匹配出所有带参数的前缀:
用下面的正则表达式,如果不匹配,则说明这个方法没有参数,如果匹配了,则取每一个匹配的参数进行拼接即可。
(\w*?)\s*:\s*\(.*?\)
这样我们就可以得到所有的方法了
-func1
-func2
-func3:
-func4:func4:
-func5:func5:
-func6:func6:
-func7:func7:func7:func7:func7:func7:
最后以imple_name
为key,该实现下所有func
的数组为value,即可检查是否有分类重名方法。
三. 匹配出库文件的所有方法
虽然在上面的步骤中,我们提取到了所有.m和.mm文件的方法,并进行了一次检查,但是在工程中还有一些需要检查的地方:.a文件和.framework文件,他们是外部提供的不暴露源代码、只暴露部分头文件的库文件,有些是外部提供的SDK,有些则是自身为提高编译速度和维护性而生成的模块化文件。
这里就有难点了:既然我们获取不到里面的源代码,那我们怎么知道工程中是否有重写库文件的方法呢?幸好,Mac平台提供了一个命令,可以让我们查看一个文件的符号表信息,而符号表信息中就包含了该二进制文件里面的所有方法,所以只要我们执行这个命令,再对生成的符号表进行筛选,即可获取到该库的所有方法。
这个指令就是nm
指令,具体介绍可以参考这篇文章
1. 对.a文件的方法提取
当我们对微信的SDK执行这个指令时:
nm xxx/Libs/WeChat/libWeChatSDK.a
就可以得到以下的符号表,这里截取其中一小段讲解
.......
xxx/Libs/WeChat/libWeChatSDK.a(WXLogUtil.o):
---------------- t +[WXLogUtil sharedInstance]
---------------- t -[WXLogUtil .cxx_destruct]
---------------- t -[WXLogUtil configLogBlock:level:]
---------------- t -[WXLogUtil configLogDelegate:level:]
---------------- t -[WXLogUtil logBlock]
---------------- t -[WXLogUtil logDelegate]
---------------- t -[WXLogUtil logLevel]
---------------- t -[WXLogUtil printLog:level:]
---------------- t -[WXLogUtil removeLog]
---------------- t -[WXLogUtil setLogBlock:]
---------------- t -[WXLogUtil setLogDelegate:]
---------------- t -[WXLogUtil setLogLevel:]
U _OBJC_CLASS_$_NSObject
---------------- D _OBJC_CLASS_$_WXLogUtil
---------------- S _OBJC_IVAR_$_WXLogUtil._logBlock
---------------- S _OBJC_IVAR_$_WXLogUtil._logDelegate
---------------- S _OBJC_IVAR_$_WXLogUtil._logLevel
U _OBJC_METACLASS_$_NSObject
---------------- D _OBJC_METACLASS_$_WXLogUtil
U __NSConcreteStackBlock
---------------- t ___27+[WXLogUtil sharedInstance]_block_invoke
U ___CFConstantStringClassReference
---------------- W ___block_descriptor_40_e8__e5_v8?0l
---------------- W ___copy_helper_block_e8_
---------------- W ___destroy_helper_block_e8_
U __objc_empty_cache
U _dispatch_once
---------------- d _instance
U _objc_getProperty
U _objc_msgSend
U _objc_setProperty
......
上面的格式是“符号值 符号类型 符号名”,符号类型的意义在刚刚提到的那篇文章也讲得很清楚了,在这里我就直接照抄了。
1)U,未定义符号
表示这个符号没有在本文件中定义,需要解析别的文件从而找出对应符号的定义。
例如,当前文件调用另一个文件中定义的函数或者全局变量,这个被调用的函数或全局变量在当前文件中就是未定义的。(但是,在定义它的文件中,如果是函数则对应的类型是T,而如果是全局变量则其符号类型为C)。
2)A,绝对符号
表示该符号的值是绝对的,在以后的链接过程中,不允许进行改变。这种类型的符号常常出现在中断向量表中,例如用符号来表示各个中断向量函数在中断向量表中的位置。
3)T,定义在__TEXT段__text区(代码区)中的符号
表示该符号位于代码区中,其值表示该符号在整个文件当中的所处的位置。
有点奇怪的是符号“__mh_execute_header”竟然类型也为T,算作在代码区定义的符号。
4)D,定义在__DATA段__data区中的符号
表明该符号位于初始化数据区中,其值表示该符号在整个文件当中的所处的位置。
5)B,定义在__DATA段__bss区中的符号
表明该符号位于非初始化数据区中,其值表示该符号在bss段中的偏移。
6)C,所谓的普通(Common)符号,定义在__DATA段__common区中的符号
普通符号是定义在一个未初始化数据段内的符号。该符号没有包含于一个普通的区中,只有在链接过程中才进行分配,符号的值表示该符号需要的字节数。例如在一个C文件中,定义int test,并且该符号在别的地方会被引用,则该符号类型即为C,否则其类型为B。
7)I,间接符号
说明这个符号是仅仅是对另一个符号的间接引用。
8)S,其它符号
定义在除前所述其它地方的符号,例如出现在__TEXT段__const区中的符号。
但其实这些在这次的需求中都不重要,我们只需关注第三列:符号名,相信在上面的符号表中我们也能看出点端倪出来了,那么,怎么排除其他干扰因素,只要第三列呢?我们只需加个参数就可以了:
nm -j xxx/Libs/WeChat/libWeChatSDK.a
于是乎,我们就得到了纯符号名,可以获取到库的符号了!
/xxx/Libs/WeChat/libWeChatSDK.a(WXLogUtil.o):
+[WXLogUtil sharedInstance]
-[WXLogUtil .cxx_destruct]
-[WXLogUtil configLogBlock:level:]
-[WXLogUtil configLogDelegate:level:]
-[WXLogUtil logBlock]
-[WXLogUtil logDelegate]
-[WXLogUtil logLevel]
-[WXLogUtil printLog:level:]
-[WXLogUtil removeLog]
-[WXLogUtil setLogBlock:]
-[WXLogUtil setLogDelegate:]
-[WXLogUtil setLogLevel:]
_OBJC_CLASS_$_NSObject
_OBJC_CLASS_$_WXLogUtil
_OBJC_IVAR_$_WXLogUtil._logBlock
_OBJC_IVAR_$_WXLogUtil._logDelegate
_OBJC_IVAR_$_WXLogUtil._logLevel
_OBJC_METACLASS_$_NSObject
_OBJC_METACLASS_$_WXLogUtil
__NSConcreteStackBlock
___27+[WXLogUtil sharedInstance]_block_invoke
___CFConstantStringClassReference
___block_descriptor_40_e8__e5_v8?0l
___copy_helper_block_e8_
___destroy_helper_block_e8_
__objc_empty_cache
_dispatch_once
_instance
_objc_getProperty
_objc_msgSend
_objc_setProperty
_sharedInstance.onceToken
再观察一下,纯方法的格式无非就是以+/-
开头,[Class method]
形式的文本(在这里要注意一下,一定是本行以+/-
开头,因为___27+[WXLogUtil sharedInstance]_block_invoke
虽然也符合符号表的格式,但他代表的是该方法被调用的回调,并不是写在代码里面的方法。
好了,这就是很简单的一个文本处理了:读取每一行,看到+/-
开头的再提取,且需要符合[Class method]
形式。
另外需要注意的一个方法是.cxx_destruct
方法,有些符号表会有如-[WXLogUtil .cxx_destruct]
这样的方法,这个并不是我们代码文件写出来的,而是ARC下编译器插入的.cxx_desctruct
方法,目的是为了自动释放ARC下对象的成员变量。所以这个需要我们去加入方法的白名单,让检查器不去检查这个方法,如果有兴趣研究的可以看看Sunny大神的这篇详解。
2. 对.framework文件的方法提取
现在我们提取到了.a文件的所有方法,但如果我们需要对.framework文件进行这个指令的话,应该怎么办呢?你可能想到同样直接nm -j
完事:
nm -j xxx/xxxLib.framework
那么就会有报错产生:
error: xxx/xxxLib.framework: Is a directory.
系统提示:.framework文件是个文件夹来的,报错了。
之所以.a可以直接用这个指令,是因为.a 是单纯的二进制文件,.framework是二进制文件+资源文件。
其中.a 不能直接使用,需要 .h文件配合,而.framework则可以直接使用。
.framework = .a + .h + sourceFile(资源文件)
右键.framework文件=》显示包内容,可以看到下面的结构:
其中,xxLib和xx.framework同名,他就是我们想要的二进制文件,Headers是这个库要暴露的头文件,.bundle是一些资源文件(如图片文件、链接的一些其他库等等)。
现在,我们就可以知道怎么获得.framework文件的符号表了,就是在.framework文件后面加个同名的路径就可以了:
nm -j xxx/xxxLib.framework/xxxLib
至此,我们就可以读取到库里面的所有方法了,再也不用担心覆盖了库文件的方法了!
def find_file(rootDir):
for lists in os.listdir(rootDir):
if (lists == "Third-Party"):
continue
path = os.path.join(rootDir, lists)
file_name = path.split('/')[-1]
file_type = file_name.split('.')[-1]
if file_type == 'm' or file_type == 'mm':
handle_file(path)
elif file_type == 'framework':
handle_framework(path, file_name)
elif file_type == 'a':
handle_lib(path)
if os.path.isdir(path):
find_file(path)
# .framework文件
def handle_framework(path, framework_name):
framework_path = path + '/' + framework_name.split('.')[0]
handle_lib(framework_path)
# .a文件或者是.framework文件
def handle_lib(path):
cmd = 'nm -j ' + path
output_string = commands.getoutput(cmd)
for line in output_string.split('\n'):
find_lib_method_replaced(path, line)
def find_lib_method_replaced(path, line):
if len(line) < 6:
#-[A b]
return
if line[0] == '-' or line[0] == '+':
if line[1] == '[' and line[-1] == ']':
space_index = line.find(' ')
if space_index != -1:
imple_name = line[2 : space_index]
method_name = line[space_index + 1 : -1]
method_name = line[0] + method_name
handle_method(path, imple_name, method_name)
四. 编译时自动运行
其他步骤因为比较简单就不多说了,最后讲一下怎么在编译的时候自动跑这个脚本
我们在Build Phase
加一个.sh脚本,这个.sh脚本用于运行.py脚本,如果python脚本有输出,则会终止编译
# find_error_property.py
ret=$(python "${SRCROOT}/find_category_method_replaced.py")
if [ -n "$ret" ] ; then
echo "error: property declaration" >&2
echo "${ret}"
exit 1
fi
最后把上面提到的几个方法加在分类上面试试:
成功啦!
五. 拓展
如果你实在想在不同分类上面实现同样的方法(比如都想用到dealloc
等方法搞事情),不妨看看这篇文章,大概原理就是自动在同名方法前加个前缀来实现。
六. 成果
通过这个脚本,成功找到了工程中10处被覆盖的分类方法,16个迁移时未被及时移除的工程文件,以及6个已经失效的无用文件。
七. 注意事项
该脚本对系统控件的分类方法覆盖检测无效,不过如果重写了系统的方法会有警告,这个需要留意一下。