iOS代码瘦身实践:删除无用的方法

7,008 阅读4分钟

本文将提供一种静态分析的方式,用于查找可执行文件中未使用的方法,源码链接:xuezhulian/selectorsunref

核心思路

分析Mach-o文件中的__DATA __objc_selrefs段得到使用到的方法,通过otool找出实现的所有方法。取差集得到未使用的方法。然后过滤setter和getter,过滤协议方法,再加上一些其它的过滤规则得到最终的结果。

def unref_selectors(path):
    ref_sels = ref_selectors(path)
    imp_sels = imp_selectors(path)
    protocol_sels = protocol_selectors(path)
    unref_sels = set()
    for sel in imp_sels:
        if ignore_selectors(sel):
            continue
        #protocol sels will not apppear in selrefs section
        if sel not in ref_sels and sel not in protocol_sels:
            unref_sels = unref_sels.union(filter_selectors(imp_sels[sel]))
    return unref_sels

使用到的方法

使用otool -v -s输出__DATA __objc_selrefs段的信息:

def ref_selectors(path):
    re_selrefs = re.compile('__TEXT:__objc_methname:(.+)')
    ref_sels = set()
    lines = os.popen('/usr/bin/otool -v -s __DATA __objc_selrefs %s' % path).readlines()
    for line in lines:
        results = re_selrefs.findall(line)
        if results:
            ref_sels.add(results[0])
    return ref_sels

输出示例:

00000001030f7ce8  __TEXT:__objc_methname:getMessageRequestFromQQ:
00000001030f7cf0  __TEXT:__objc_methname:SendMessageToQQRequest:
00000001030f7cf8  __TEXT:__objc_methname:responseToGetMessageFromQQ:
00000001030f7d00  __TEXT:__objc_methname:responseToShowMessageFromQQ:

匹配__TEXT:__objc_methname:(.+)得到使用到的方法。

实现的所有方法

使用otool -oV输出可执行文件的详细信息, 在__DATA,__objc_classlist这个段里面记录了类实现的方法的相关信息:

Contents of (__DATA,__objc_classlist) section
0000000102bdc190 0x103117798 _OBJC_CLASS_$_EpisodeDetailStatusCell
           isa 0x103117770 _OBJC_METACLASS_$_EpisodeDetailStatusCell
    superclass 0x103152988 _OBJC_CLASS_$_TableViewCell
         cache 0x0 __objc_empty_cache
        vtable 0x0
          data 0x102be84c0 (struct class_ro_t *)
                    flags 0x184 RO_HAS_CXX_STRUCTORS
            instanceStart 8
             instanceSize 16
                 reserved 0x0
               ivarLayout 0x102a2a78f
                layout map: 0x01 
                     name 0x102a2a775 TTEpisodeDetailStatusCell
              baseMethods 0x102be83d0 (struct method_list_t *)
		   entsize 24
		     count 7
		      name 0x1028606b7 setupConstraintsAdditional
		     types 0x102a489fe v16@0:8
		       imp 0x10000c1a8 -[TTEpisodeDetailStatusCell setupConstraintsAdditional]
		      name 0x1028606d2 setupUpdateConstraintsAdditional
		     types 0x102a489fe v16@0:8
		       imp 0x10000c7b8 -[TTEpisodeDetailStatusCell setupUpdateConstraintsAdditional]
		      name 0x1028606f3 bindDataWithEpisode:replayInfo:
		     types 0x102a48a20 v32@0:8@16@24
		       imp 0x10000d014 -[TTEpisodeDetailStatusCell bindDataWithEpisode:replayInfo:]
		       ... ...

通过匹配\s*imp 0x\w+ ([+|-]\[.+\s(.+)\])得到实现的方法,存储的数据结构{sel:set("-[class sel]","-[class sel]")}

for line in os.popen('/usr/bin/otool -oV %s' % path).xreadlines():
    results = re_sel_imp.findall(line)
    if results:
        (class_sel, sel) = results[0]
        if sel in imp_sels:
            imp_sels[sel].add(class_sel)
        else:
            imp_sels[sel] = set([class_sel])

过滤setter和getter

直接对ivar赋值,不会触发propertysettergetter,这些方法即使不被调用,也不能够删除。 otool -oV可以输出类的protertieslist

   baseProperties 0x102be84a8
            entsize 16
              count 1
	     name 0x10293aaa5 pinkPointView
	attributes 0x10293aab3 T@"UIView",&,N,V_pinkPointView

匹配baseProperties区间,通过\s*name 0x\w+ (.+)匹配类的属性,此时也就得到了对应的setter和getter方法。

#delete setter and getter methods as ivar assignment will not trigger them
if re_properties_start.findall(line):
    is_properties_area = True
if re_properties_end.findall(line):
    is_properties_area = False
if is_properties_area:
    property_result = re_property.findall(line)
    if property_result:
        property_name = property_result[0]
        if property_name and property_name in imp_sels:
            #properties layout in mach-o is after func imp
            imp_sels.pop(property_name)
            setter = 'set' + property_name[0].upper() + property_name[1:] + ':'
            if setter in imp_sels:
                imp_sels.pop(setter)

过滤protocol方法

协议调用的方法不会出现在__DATA __objc_selrefs这个段里面,过滤协议方法采用的策略是找到相应的.h文件,正则匹配文件中包含的协议方法。

def header_protocol_selectors(file_path):
    protocol_sels = set()
    file = open(file_path, 'r')
    is_protocol_area = False
    for line in file.readlines():
        #delete description
        line = re.sub('\".*\"', '', line)
        #delete annotation
        line = re.sub('//.*', '', line)
        #match @protocol
        if re.compile('\s*@protocol\s*\w+').findall(line):
            is_protocol_area = True
        #match @end
        if re.compile('\s*@end').findall(line):
            is_protocol_area = False
        #match sel
        if is_protocol_area and re.compile('\s*[-|+]\s*\(').findall(line):
            sel_content_match_result = None
            if ':' in line:
                #match sel with parameters
                sel_content_match_result = re.compile('\w+\s*:').findall(line)
            else:
                #match sel without parameters
                sel_content_match_result = re.compile('\w+\s*;').findall(line)
            if sel_content_match_result:
                protocol_sels.add(''.join(sel_content_match_result).replace(';', ''))
    file.close()
    return protocol_sels

系统.h文件

otool -L可以打印可执行文件引用到的library,加上公共前缀/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk',得到绝对路径。使用find命令递归查找该目录下所有的.h文件。

#get system librareis
lines = os.popen('otool -L ' + path).readlines()
for line in lines:
    line = line.strip()
    #delete description
    line = re.sub('\(.*\)', '', line).strip()
    if line.startswith('/System/Library/'):
        library_dir = system_base_dir + '/'.join(line.split('/')[0:-1])
        if os.path.isdir(library_dir):
            header_files = header_files.union(os.popen('find %s -name \"*.h\"' % library_dir).readlines())

自定义.h文件

otool -oV的输出来看,baseProtocols会包含协议的方法,但是一些pod仓库通过.a文件导入到宿主工程,这个时候拿不到方法的符号。最终过滤自定义协议方法的时候采用的策略和系统协议方法相同。递归遍历工程目录(脚本需要输入的第二个参数)下的.h文件,匹配协议方法。

header_files = header_files.union(os.popen('find %s -name \"*.h\"' % project_dir).readlines())
for header_path in header_files:
    header_protocol_sels = header_protocol_selectors(header_path)
    if header_protocol_sels:
        protocol_sels = protocol_sels.union(header_protocol_sels)

其他的过滤规则

根据输出的结果,对一些系统方法进行了过滤。

def ignore_selectors(sel):
    if sel == '.cxx_destruct':
        return True
    if sel == 'load':
        return True
    return False

为了过滤第三方库的方法,只保留了带有某些前缀的类的方法,这里需要根据实际情况自行修改reserved_prefixs

def filter_selectors(sels):
    filter_sels = set()
    for sel in sels:
        for prefix in reserved_prefixs:
            if sel.startswith(prefix):    
                filter_sels.add(sel)
    return filter_sels

最终结果保存在脚本路径下的selectorunref.txt文件中。和之前整理过的iOS代码瘦身实践:删除无用的类 一样,这个方式只能做静态分析,对动态调用无效,最终是否需要删除,还需要手动确认。