美文网首页performanceOptistic
iOS 包体积优化方案

iOS 包体积优化方案

作者: coder_feng | 来源:发表于2021-07-14 09:19 被阅读0次

    一、背景

    由于APP目前包体积过大,收到苹果的提醒邮件,在部分设备上面会对蜂窝网络数据下载进行限制,只能使用WiFi下载,也就是说可能会减少用户的下载意愿,不利于APP的推广。

    另外下载包体积太大,会占用更多的设备存储空间,对于低存储的设备的用户也会有一定的影响,可能成为磁盘不够时的首选。

    包体优化1.png 包体优化2.png 包体优化3.png

    虽然苹果官方一直在提高最大的可执行文件大小,在 iOS 13 还取消了强制的 OTA 限制,但是超过 200 MB 会默认请求用户下载许可(可在 设置 - iTunes Store与App Store - App下载 中配置),并且iOS 13 以下的超过 200 MB 无法使用 OTA,影响了整体更新率。

    优化前APP的基本信息
    此次基于版本 5.0.2

    优化前APP相关包文件大小如下:

    appStore 上的大小 130.8MB(iphone 12)

    包体积优化4.png
    工程文件大小 1.73GB
    包体积优化5.png
    archive后的 .xcarchive 的大小 568.2MB
    包体积优化6.png
    导出adhoc 的 ipa包的大小161.2MB
    包体积优化7.png
    优化后的APP相关信息
    经过优化后,APP包体积从130.8M 减小到103.6M,减小包体积为27.2M,比例为:21%,达成了此次优化目标。

    优化后的app下载信息如下,appStore上的下载大小为103.6M,下载安装后的大小为103M,


    包体积优化9.png

    二、优化项目

    对于包体积优化,分多期进行优化,本期主要对如下项进行优化:

    1、无用/重复资源删除

    2、无用类/第三方库删除

    3、重复类封装抽离-创建公共库

    4、删除SVN相关的东西

    5、启动图片压缩

    2.1 无用/重复资源删除

    1、先删除项目中的SVN相关的文件

    项目中未使用SVN进行版本管理,所以可以先删除,命令如下:

    HLLCourseLive git:(test) ✗ find . -type d -name ".svn" |xargs rm -rvf
    
    2、查找并删除无用图片资源

    使用LSUnusedResources-master开源项目,可以根据项目实际情况定义查找文件的正则表达式。另外建议勾选 Ignore similar name ,避免扫描出图片组,地址:https://github.com/tinymind/LSUnusedResources

    执行项目,我这没有勾选 Ignore similar name,,导出后选择手动确认,效果如下,查找到未使用的图片资源,进行导出
    需要注意的是,这些图片不一定是真的没有使用到,很多都是图片组形式,为防止误删,需要我们手动进行确认,对多余的图片进行删除,这是一个体力活。

    对于项目中还有一些@1x的图片,目前没有适配iPhone4以下的机型了,可以手动找出进行删除。

    3、查找重复文件

    通过校验所有资源的 MD5,筛选出项目中的重复资源,推荐使用 fdupes 工具进行重复文件扫描,fdupes 是 Linux 平台的一个开源工具,由 C 语言编写 ,文件比较顺序是大小对比 > 部分 MD5 签名对比 > 完整 MD5 签名对比 > 逐字节对比。

    通过 homebrew 安装 fdupes

    brew install fdupes
    

    查看重复文件,将文件导出到fdupes.txt中

    fdupes -Sr /Users/61mac/Git/HLLCourseLive > /Users/61mac/Desktop/fdupes.txt
    

    通过这条命令导出的文件,会将虽然文件名不同但内容相同的文件找出来,所以会将一些复制的图片、代码文件都扫描出来,很强大,还会把重复的资源大小占用字节输出,对于这些资源只需要保存一份就可以了,可以减少大量的文件

    通过如上三步的处理,项目工程文件从1.73GB → 1.69GB , .xcarchive 包大小从 571MB → 553.6MB , IPA包从 161.2MB → 140.2MB

    2.2 删除未用到的类

    查找未使用的类文件使用的是Python脚本工具,脚本地址是: https://github.com/yan998/FindClassUnRefs,参考文档:https://www.jianshu.com/p/de03ea15f399

    脚本代码如下:

    # coding:utf-8
    # 查找iOS项目无用类脚本
     
    import os
    import re
    import sys
    import getopt
    import FindAllClassIvars
     
    # 获取入参参数
    def getInputParm():
        opts, args = getopt.getopt(sys.argv[1:], '-p:-b:-w:', ['path=', 'blackListStr', 'whiteListStr'])
     
        blackListStr = ''
        whiteListStr = ''
        whiteList = []
        blackList = []
        # 入参判断
        for opt_name, opt_value in opts:
            if opt_name in ('-p', '--path'):
                # 文件路径
                path = opt_value
            if opt_name in ('-b', '--blackListStr'):
                # 检测黑名单前缀,不检测谁
                blackListStr = opt_value
            if opt_name in ('-w', '--whiteListStr'):
                # 检测白名单前缀,只检测谁
                whiteListStr = opt_value
     
        if len(blackListStr) > 0:
            blackList = blackListStr.split(",")
     
        if len(whiteListStr) > 0:
            whiteList = whiteListStr.split(",")
     
        if len(whiteList) > 0 and len(blackList) > 0:
            print("\033[0;31;40m白名单【-w】和黑名单【-b】不能同时存在\033[0m")
            exit(1)
     
        # 判断文件路径存不存在
        if not os.path.exists(path):
            print("\033[0;31;40m输入的文件路径不存在\033[0m")
            exit(1)
     
        return path, blackList, whiteList
     
     
    def verified_app_path(path):
        if path.endswith('.app'):
            appname = path.split('/')[-1].split('.')[0]
            path = os.path.join(path, appname)
            if appname.endswith('-iPad'):
                path = path.replace(appname, appname[:-5])
        if not os.path.isfile(path):
            return None
        if not os.popen('file -b ' + path).read().startswith('Mach-O'):
            return None
        return path
     
     
    def pointers_from_binary(line, binary_file_arch):
        if len(line) < 16:
            return None
        line = line[16:].strip().split(' ')
        pointers = set()
        if binary_file_arch == 'x86_64':
            # untreated line example:00000001030cec80   d8 75 15 03 01 00 00 00 68 77 15 03 01 00 00 00
            if len(line) >= 8:
                pointers.add(''.join(line[4:8][::-1] + line[0:4][::-1]))
            if len(line) >= 16:
                pointers.add(''.join(line[12:16][::-1] + line[8:12][::-1]))
            return pointers
        # arm64 confirmed,armv7 arm7s unconfirmed
        if binary_file_arch.startswith('arm'):
            # untreated line example:00000001030bcd20   03138580 00000001 03138878 00000001
            if len(line) >= 2:
                pointers.add(line[1] + line[0])
            if len(line) >= 4:
                pointers.add(line[3] + line[2])
            return pointers
        return None
     
     
    def class_ref_pointers(path, binary_file_arch):
        print('获取项目中所有被引用的类...')
        ref_pointers = set()
        lines = os.popen('/usr/bin/otool -v -s __DATA __objc_classrefs %s' % path).readlines()
        for line in lines:
            pointers = pointers_from_binary(line, binary_file_arch)
            if not pointers:
                continue
            ref_pointers = ref_pointers.union(pointers)
        if len(ref_pointers) == 0:
            exit('Error:class ref pointers null')
        return ref_pointers
     
     
    def class_list_pointers(path, binary_file_arch):
        print('获取项目中所有的类...')
        list_pointers = set()
        lines = os.popen('/usr/bin/otool -v -s __DATA __objc_classlist %s' % path).readlines()
        for line in lines:
            pointers = pointers_from_binary(line, binary_file_arch)
            if not pointers:
                continue
            list_pointers = list_pointers.union(pointers)
        if len(list_pointers) == 0:
            exit('Error:class list pointers null')
        return list_pointers
     
     
    def filter_use_load_class(path, binary_file_arch):
        print('获取项目中所有使用load方法的类...')
        list_load_class = set()
        lines = os.popen('/usr/bin/otool -v -s __DATA __objc_nlclslist %s' % path).readlines()
        for line in lines:
            pointers = pointers_from_binary(line, binary_file_arch)
            if not pointers:
                continue
            list_load_class = list_load_class.union(pointers)
        return list_load_class
     
     
    # 通过符号表中的符号,找到对应的类名
    def class_symbols(path):
        print('通过符号表中的符号,获取类名...')
        symbols = {}
        # class symbol format from nm: 0000000103113f68 (__DATA,__objc_data) external _OBJC_CLASS_$_TTEpisodeStatusDetailItemView
        re_class_name = re.compile('(\w{16}) .* _OBJC_CLASS_\$_(.+)')
        lines = os.popen('nm -nm %s' % path).readlines()
        for line in lines:
            result = re_class_name.findall(line)
            if result:
                (address, symbol) = result[0]
                # print(result)
                symbols[address] = symbol
        if len(symbols) == 0:
            exit('Error:class symbols null')
        return symbols
     
     
    def filter_super_class(unref_symbols):
        re_subclass_name = re.compile("\w{16} 0x\w{9} _OBJC_CLASS_\$_(.+)")
        re_superclass_name = re.compile("\s*superclass 0x\w* _OBJC_CLASS_\$_(.+)")
        # subclass example: 0000000102bd8070 0x103113f68 _OBJC_CLASS_$_TTEpisodeStatusDetailItemView
        # superclass example: superclass 0x10313bb80 _OBJC_CLASS_$_TTBaseControl
        lines = os.popen("/usr/bin/otool -oV %s" % path).readlines()
        subclass_name = ""
        superclass_name = ""
        for line in lines:
            subclass_match_result = re_subclass_name.findall(line)
            if subclass_match_result:
                subclass_name = subclass_match_result[0]
                superclass_name = ''
            superclass_match_result = re_superclass_name.findall(line)
            if superclass_match_result:
                superclass_name = superclass_match_result[0]
     
     
            # 查看所有类的父类子类关系
            # if len(subclass_name) > 0 and len(superclass_name) > 0:
            #     # print("当前找到了superclass == " + line)
            #     print("superclass:%s  subClass:%s" % (superclass_name, subclass_name))
     
            if len(subclass_name) > 0 and len(superclass_name) > 0:
                if superclass_name in unref_symbols and subclass_name not in unref_symbols:
                    # print("删除的父类 -- %s   %s" % (superclass_name, subclass_name))
                    unref_symbols.remove(superclass_name)
                superclass_name = ''
                subclass_name = ''
        return unref_symbols
     
     
    def class_unref_symbols(path):
        # binary_file_arch: distinguish Big-Endian and Little-Endian
        # file -b output example: Mach-O 64-bit executable arm64
        binary_file_arch = os.popen('file -b ' + path).read().split(' ')[-1].strip()
     
        print("*****" + binary_file_arch)
     
        # 被使用的类和有load方法的类取合集,然后和所有的类的集合取差集
        unref_pointers = class_list_pointers(path, binary_file_arch) - (
                class_ref_pointers(path, binary_file_arch) | filter_use_load_class(path, binary_file_arch))
     
        if len(unref_pointers) == 0:
            exit('木有找到未使用的类')
        # 通过符号找类名
        symbols = class_symbols(path)
     
        # ###### 测试 ######
        # print("所有的类列表")
        # all_class_list = find_class_list(class_list_pointers(path, binary_file_arch), symbols)
        # print(all_class_list)
        #
        # print("\n所有的被引用的类列表")
        # all_class_ref_list = find_class_list(class_ref_pointers(path, binary_file_arch), symbols)
        # print(all_class_ref_list)
        #
        # print("\n所有的有load方法的类的列表")
        # all_class_load_list = find_class_list(filter_use_load_class(path, binary_file_arch), symbols)
        # print(all_class_load_list)
        # ###### 测试 ######
     
        unref_symbols = set()
        for unref_pointer in unref_pointers:
            if unref_pointer in symbols:
                unref_symbol = symbols[unref_pointer]
                unref_symbols.add(unref_symbol)
        if len(unref_symbols) == 0:
            exit('Finish:class unref null')
     
        return unref_symbols
     
     
    def find_class_list(unref_pointers, symbols):
        unref_symbols = set()
        for unref_pointer in unref_pointers:
            if unref_pointer in symbols:
                unref_symbol = symbols[unref_pointer]
                unref_symbols.add(unref_symbol)
        if len(unref_symbols) == 0:
            exit('Finish:class unref null')
     
        return unref_symbols
     
     
    # 检测通过runtime的形式,类使用字符串的形式进行调用,如果查到,可以认为用过
    def filter_use_string_class(path, unref_symbols):
        str_class_name = re.compile("\w{16}  (.+)")
        # 获取项目中所有的字符串 @"JRClass"
        lines = os.popen('/usr/bin/otool -v -s __TEXT __cstring %s' % path).readlines()
     
        for line in lines:
     
            stringArray = str_class_name.findall(line)
            if len(stringArray) > 0:
                tempStr = stringArray[0]
                if tempStr in unref_symbols:
                    unref_symbols.remove(tempStr)
                    continue
        return unref_symbols
     
     
    # 查找所有的未使用到的类,是否出现在了相关类的属性中
    # 自己作为自己的属性不算
    def find_ivars_is_unuse_class(path, unref_sels):
        # {'MyTableViewCell':
        # [{'ivar_name': 'superModel', 'ivar_type': 'SuperModel'}, {'ivar_name': 'showViewA', 'ivar_type': 'ShowViewA'}, {'ivar_name': 'dataSource111', 'ivar_type': 'NSArray'}],
        # 'AppDelegate': [{'ivar_name': 'window', 'ivar_type': 'UIWindow'}]}
        imp_ivars_info = FindAllClassIvars.get_all_class_ivars(path)
        temp_list = list(unref_sels)
        find_ivars_class_list = []
        for unuse_class in temp_list:
            for key in imp_ivars_info.keys():
                # 当前类包含自己类型的属性不做校验
                if key == unuse_class:
                    continue
                else:
                    ivars_list = imp_ivars_info[key]
                    is_find = 0
                    for ivar in ivars_list:
                        if unuse_class == ivar["ivar_type"]:
                            unref_symbols.remove(unuse_class)
                            find_ivars_class_list.append(unuse_class)
                            is_find = 1
                            break
                    if is_find == 1:
                        break
     
        return unref_symbols, find_ivars_class_list
     
     
    def filter_category_use_load_class(path, unref_symbols):
        re_load_category_class = re.compile("\s*imp\s*0x\w*\s*[+|-]\[(.+)\(\w*\) load\]")
        lines = os.popen("/usr/bin/otool -oV %s" % path).readlines()
     
        for line in lines:
            load_category_match_result = re_load_category_class.findall(line)
            if len(load_category_match_result) > 0:
                re_load_category_class_name = load_category_match_result[0]
                if re_load_category_class_name in unref_symbols:
                    unref_symbols.remove(re_load_category_class_name)
        return unref_symbols
     
    # 黑白名单过滤
    def filtration_list(unref_symbols, blackList, whiteList):
        # 数组拷贝
        temp_unref_symbols = list(unref_symbols)
        if len(blackList) > 0:
            # 如果黑名单存在,那么将在黑名单中的前缀都过滤掉
            for unrefSymbol in temp_unref_symbols:
                for blackPrefix in blackList:
                    if unrefSymbol.startswith(blackPrefix) and unrefSymbol in unref_symbols:
                        unref_symbols.remove(unrefSymbol)
                        break
     
        # 数组拷贝
        temp_array = []
        if len(whiteList) > 0:
            # 如果白名单存在,只留下白名单中的部分
            for unrefSymbol in unref_symbols:
                for whitePrefix in whiteList:
                    if unrefSymbol.startswith(whitePrefix):
                        temp_array.append(unrefSymbol)
                        break
            unref_symbols = temp_array
     
        return unref_symbols
     
     
    def write_to_file(unref_symbols, find_ivars_class_list):
        script_path = sys.path[0].strip()
        file_name = 'find_class_unRefs.txt'
        f = open(script_path + '/' + file_name, 'w')
        f.write('查找到未使用的类: %d个,【请在项目中二次确认无误后再进行相关操作】\n' % len(unref_symbols))
     
        num = 1
        if len(find_ivars_class_list):
            show_title = "\n查找结果:\n只作为其他类的成员变量,不确定有没有真正被使用,请在项目中查看 --------"
            print(show_title)
            f.write(show_title + "\n")
            for name in find_ivars_class_list:
                find_ivars_class_str = ("%d : %s" % (num, name))
                print(find_ivars_class_str)
                f.write(find_ivars_class_str + "\n")
                num = num + 1
     
        num = 1
        print("\n未使用的类 --------")
        for unref_symbol in unref_symbols:
            showStr = ('%d : %s' % (num, unref_symbol))
            print(showStr)
            f.write(showStr + "\n")
            num = num + 1
        f.close()
     
        print('未使用到的类查询完毕,结果已保存在了%s中,【请在项目中二次确认无误后再进行相关操作】' % file_name)
     
     
    if __name__ == '__main__':
     
        path, blackList, whiteList = getInputParm()
     
        path = verified_app_path(path)
        if not path:
            sys.exit('Error:invalid app path')
     
        # 查找未使用类结果
        unref_symbols = class_unref_symbols(path)
     
        # 检测通过runtime的形式,类使用字符串的形式进行调用,如果查到,可以认为用过
        unref_symbols = filter_use_string_class(path, unref_symbols)
     
        # 查找当前未被引用的子类
        unref_symbols = filter_super_class(unref_symbols)
     
        # 检测当前类的分类中是否有load方法,如果有,认为是被引用的类
        unref_symbols = filter_category_use_load_class(path, unref_symbols)
     
        # 黑白名单过滤
        unref_symbols = filtration_list(unref_symbols, blackList, whiteList)
     
        # 过滤属性,看当前查找到的未使用类,是否在使用的类的属性中
        unref_symbols, find_ivars_class_list = find_ivars_is_unuse_class(path, unref_symbols)
     
        # 整理结果,写入文件
        write_to_file(unref_symbols, find_ivars_class_list);
    

    执行脚本,查找DL和HLL为前缀未使用到的,需要cd到脚本目录下,需要传入一个.app的参数

    python FindClassUnRefs.py -p /Users/61mac/Library/Developer/Xcode/DerivedData/HLLCourseLive-aybrtkgzldnamaclvsljvcewoxam/Build/Products/Debug-iphonesimulator/HLLCourseLive_test.app -w DL,HLL
    
    

    会自动在脚本目录生成一个.txt文件,内容如下:


    包体积优化10.png

    需要注意的是,这些类也是需要确认的,有的可能是通过NSClassFromString()引入的,所以也是一个体力活,需要一个一个搜索确认

    删除掉一些以后,需要多重复几次,因为可能有一些类是通过删除的文件引入的,所以需要多试几次,防止遗漏。

    由于这个脚本对于查找NS开头的文件是不行的,所以对于项目中一些引入的 UIKit和Foundation 框架的多余的category,需要手动搜索删除。

    对多余的文件进行删除之后,项目工程文件从1.69GB->1.69GB , .xcarchive 包大小从 539.3MB→ 532.8MB , IPA包从 126MB → 125.3MB

    三、参考文档

    深入探索 iOS 包体积优化

    iOS 优化IPA包体积(今日头条)

    正经分析iOS包大小优化

    工具汇总

    相关文章

      网友评论

        本文标题:iOS 包体积优化方案

        本文链接:https://www.haomeiwen.com/subject/oapspltx.html