iOS分类 同名方法自动检测脚本

零. 前言

在OC的开发过程中,要尽量避免分类方法同名,否则会发生什么线上问题也很难被察觉出来(一个踩过坑的菜鸡流泪打下这句话),很多开发的同学即使知道这个原理,但在实际开发过程中还是难免会产生脑子一抽写下分类同名方法的情况。

特别是对于大文件的重构,一不小心就在多个分类上面都写下了系统方法,什么deallocviewDidAppear,轻则影响到了原有的逻辑,重则因为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个已经失效的无用文件。

七. 注意事项

该脚本对系统控件的分类方法覆盖检测无效,不过如果重写了系统的方法会有警告,这个需要留意一下。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容

  • 1、随机数 不需要随机数种子 arc4random()%N + begin:产生begin~begin+N的随机数...
    我是小胡胡分胡阅读 4,136评论 0 2
  • 官网 中文版本 好的网站 Content-type: text/htmlBASH Section: User ...
    不排版阅读 4,365评论 0 5
  • 写在前面的话 代码中的# > 表示的是输出结果 输入 使用input()函数 用法 注意input函数输出的均是字...
    FlyingLittlePG阅读 2,730评论 0 8
  • 这篇文章是手册的中文译版整理而来(英文看着太慢了,感谢前人铺路Orz...),vim的markdown插件和实时预...
    Himryang阅读 6,915评论 0 20
  • 2014年的苹果全球开发者大会(WWDC),当Craig Federighi向全世界宣布“We have new ...
    yeshenlong520阅读 2,263评论 0 9