iOS App瘦身

iOS App瘦身

关于app瘦身,你能想到什么?

  • 删除无用类
  • 删除无用方法
  • 代码相似度分析
  • 删除无用图片
  • 无损压缩图片等资源
    • 补充一句:图片格式的定义中有一部分字段是和图片质量没有关系的,比如图片中的位置、时间等信息,很多时候我们侥幸的认为我们在无损压缩,但事后被测试或者UI人员发现,有的非黑色图片竟然颜色变成黑色了,或者部分位置颜色变了。天哪,我们真的在进行无损压缩吗?
  • 资源查重,比如相同或者相似图片
  • 采用更有效的图片管理方式
  • 第三方库的选择性引入或者部分引入
  • 可执行文件瘦身(比如对于iPhone app,移除发布包中静态库的x86指令集)
  • 采用更好的实现方案
    • 对于点击量只有1/10000的个别说明页,引入各种超级大的导向图一定很好吗?

重要说明

  • 下面的描述,尤其是代码部分,有些地方是和笔者工程中的公共组件有关系,但并不影响大家使用,稍微改吧改吧就可以在自己的工程中使用。
  • 另外这仅仅是一个学习记录,如果不妥或者不对的地方,望大家见谅

无用图片扫描

  • 简单的想一想,人工分析一张图片有没有在用,我们该怎么办?
  • 图片名一copy,删除@2x、@3x.png后缀,工程全文搜索;什么?除了一个宏定义,别的地方都没出现。
  • copy一下宏,再次搜索,omygod,这么多地方在用啊;另一种结果可能是:竟然还有一另外一个宏映射到该宏上,继续copy新宏再搜索。。。
  • 看了一下,工程中共有200张图片,这时,我们很抓狂
  • 老大总会有解决办法的,写个程序来跑吧,简单粗暴有成效
  • 第一我要拿到所有的图片名,第二我要看图片名有没有再工程代码中有没有出现,如果仅仅在宏定义中出现,我再搜索宏有没有出现,想想都不想写,但真的有这么麻烦么?如果不麻烦,有多少人写过这段程序呢?
  • 好了,扯了这么多,不瞎扯了,其实不麻烦的,直接入正题

使用

假设我们已经存在一个脚本,我们可以直接像下面这样:

  • ⚠️注意替换project path为工程路径⚠️

    sh start.sh project path

    • 输出结果:result.html
    • 将该脚本搞成一个job,每次写完代码/或者发布前/或者发布后执行一次job,真的是简单高效
    • 温馨提示:每一段代码,每一个小程序,都尽可能的落实到位,这不只是一个习惯问题。

思路

  • 静态扫描工程中所有类型的图片,构成一个集合Set_all
  • 分析工程中使用的图片集合Set_used
    • 基于真正使用原则,找出所有涉及图片API参数
    • 如果参数是宏,则进行一次宏展开
  • Set_all - Set_used,生成html报表

start.sh 内容如下(记着替换MacroImagePath等)

grep -R "#define " $1 | grep -v "/Pods/" | grep "MacroImagePath" | grep "png" > define.txt
grep -R "\[UIImage image" $1 | grep -v "/Pods/" | grep -E "imageWith|imageNamed" > images.txt
python unusedimage.py $1 > result.html
rm define.txt
rm images.txt

unusedimage.py 内容如下

# -*- coding: utf-8 -*-
# 
# Usage demo: python sys.argv[0] ./ProjectRootDir

import os
import glob
import sys
import traceback
import re


# 以strRootDir为根目录,递归返回其下符合模式strPattern的文件。
def getFileList(strRootDir, strPattern):
    # 返回strDir目录下(不处理子目录)符合模式字符串的文件列表(只包含文件名)
    list_files_inRootDir = glob.glob(os.path.join(strRootDir, strPattern))
    
    # 处理子目录
    root = os.listdir(strRootDir)
    for item in root:
        item_as_root = os.path.join(strRootDir, item)
        if os.path.isdir(item_as_root):
            list_files_inRootDir.extend(getFileList(item_as_root, strPattern))
    return list_files_inRootDir

def getFiletList_ext(strRootDir, list_pattern):
    list_files = []
    for item in list_pattern:
        list_files.extend(getFileList(strRootDir, item))
    return list_files
    pass

def print2html(dict_param):
    str_unused_files = ''
    byte_size = 0
    for key, value in dict_param.items():
        str_row = '''<a href="{}">{}</a>'''.format(key, key)
        str_unused_files = str_unused_files + '''<br> ''' + str_row
        byte_size += os.path.getsize(key)
        pass
    print('''<html>''')
    print('''<h2> Unused images </h2>''')
    print('''<i> <b>Note:</b> This scans all the files for the images available. Please look for images carefully in the below list which are used in your project.<br>Only once macro define considered.<br>Known bugs existed in sacnning script, please check carefully one by one!</i>''')
    print('''<body>''')
    print('''<h3>''')
    print('''There are {} unused images (total size: {} kb)'''.format(len(dict_param.keys()), byte_size/1024))
    print('''</h3>''')
    print('''<pre>''')
    print(str_unused_files)
    print('''</pre>''')
    pass


class QOCMAnalier(object):
    """docstring for QOCMAnalier"""

    def __init__(self):
        super(QOCMAnalier, self).__init__()
        self.dict_define_one_macro_value = {} #dict
        self.list_used_images = None
        self.dict_statistics = None


    # 返回文件内容,类无关函数。
    def __load_file_content(self, file_name):
        if os.path.isfile(file_name):
            try:
                f = open(file_name)
                str_content = f.read()
                return str_content
            except Exception, e:
                print e
                print traceback.format_exc()
            finally:
                f.close()

    def parse_define(self):
        str_file_content = self.__load_file_content("define.txt")
        re_macro_value = re.compile('#define\s+(\w+)\s+(.*?)"\)', re.S)
        list_macros = re_macro_value.findall(str_file_content)
        for item in list_macros:
            str_macro_name = item[0]
            str_macro_value = item[1].split('"')[1]
            self.dict_define_one_macro_value[str_macro_name] = str_macro_value
        pass

    # 分析出一定在用的图片,其实大部分都是一个宏定义
    def  parse_used_images(self):
        str_file_content = self.__load_file_content("images.txt")
        # imageWithBundlePath
        re_image_with_bundle_path = re.compile('imageWithBundlePath:\s*(\w+)\s*]', re.S)
        list_images_one = re_image_with_bundle_path.findall(str_file_content)
        # imageNamed
        re_imageNamed = re.compile('imageNamed:\s*(\w+)\s*]', re.S)
        list_images_two = re_imageNamed.findall(str_file_content)
        self.list_used_images = list_images_one + list_images_two
        pass

    # 字段的key就是在用的图片名称
    def get_used_image_names(self):
        dict_statistics = {}
        for item in self.list_used_images:
            if self.dict_define_one_macro_value.has_key(item):
                str_new_key = self.dict_define_one_macro_value[item]
                if(dict_statistics.has_key(str_new_key)):
                    pass
                else:
                    dict_statistics[str_new_key] = {}
                    pass
            else:
                str_new_key = item
                if(dict_statistics.has_key(str_new_key)):
                    pass
                else:
                    dict_statistics[str_new_key] = {}
                    pass
            pass
        self.dict_statistics = dict_statistics
        pass

    def get_unused_images(self, list_all_images):
        dict_statistics_unused_images = {}
        for item in list_all_images:
            str_file_full_name = item
            str_file_name = os.path.split(item)[1]
            str_file_name_remove_at2_at3 = str_file_name.replace("@2x", "").replace("@3x", "")
            if(self.dict_statistics.has_key(str_file_name_remove_at2_at3)):
                pass
            else:
                dict_statistics_unused_images[str_file_full_name] = True
                pass
            pass
        return dict_statistics_unused_images
        pass

    def get_statistics(self, list_all_images):
        self.parse_define()
        self.parse_used_images()
        self.get_used_image_names()
        return self.get_unused_images(list_all_images)
        pass


if __name__ == '__main__':
    args = sys.argv[1:]
    rootDir = ''
    if len(args) == 0 :
        print r'''请指定待分析目录'''
        raise Exception('Usage demo: python ' + sys.argv[0] + ' ./ProjectRootDir')
    elif os.path.isdir(args[0]):
        rootDir = args[0]
    else:
        print args[0] + r''' 不是一个有效目录'''
        raise Exception('Usage demo: python ' + sys.argv[0] + ' ./ProjectRootDir')

    list_images = getFiletList_ext(rootDir, ['*.png', '*.jpg', '*.jpeg',"*.gif"])
    list_images = [elem for elem in list_images if elem.find("/Pods/") == -1 and elem.find("/image.xcassets/") == -1 and elem.find("/build/") == -1]

    ocm_analier = QOCMAnalier()
    dict_unused_images = ocm_analier.get_statistics(list_images)

    # print2html({"德玛西亚之力":"德玛"})
    print2html(dict_unused_images)


对扫描不友好的case(尽量避免)

上面的思路已经可以Scan到我们多数没有用到的图片,相对于我们第一版(只考虑图片名的出现与否),效果还是非常明显的,但这一版仍然有下面两个已知的缺陷(这一版不打算再处理,因为不严重)

  • 理论上,我们在调用UIImage API的时候是可以把图片名赋值给一个变量,然后真正用到图片的地方直接使用这个变量,这种case对与静态扫描来说几乎是不可能完全搞定的,因为变量的值多数是在运行时才知道的。当然可以用图片名称或者图片宏的出现与否解决多数问题,实现起来也不复杂,这里先不介绍了
  • 第二种case是给图片名进行宏定义,一层的宏定义我们已经考虑进去了,但不乏存在间接宏定义的case,这种case我们没有处理,虽然很简单

无用类的扫描

关于无用类的扫描,不废话,直接上代码(代码面前,一切都是公开的):

oc_unusedclasses.sh

#!/bin/sh

#-----------------------------------------------------------------------------------------------------
# Experimental util to find the classes( read from file $2) which are not used in the given directory.
#-----------------------------------------------------------------------------------------------------
# $1 project dir
# $2 类集合文件
# demo: sh oc_unusedclasses.sh ./ProjectRootDir classes_all.txt


let count=0;
unusedclasses="";
project=`find $1 -name '*.mm' -o -name '*.[mh]'`

i=1
while read myline
do
    # trick.
    # // is considered later.
    # /* */ is ignored.
    # case1: Classname * (alloc后分配给父类)
    mylinepinter=$myline"\s*\*"
    printf "progress:%d\r" $i
    if ! grep -q $mylinepinter $project; then
        
        # case2:[Classname alloc 
        # case3:[Classname postMessage (只调用静态方法)
        mylinealloc="\[\s*"$myline"\s+"
        if ! egrep -q $mylinealloc $project; then
            
            # case4: @interface WGetPriceDataCommand : Classname
            mylineinherit=":\s*"$myline"\s*"
            if ! grep -q $mylineinherit $project; then
                # case5: NSArray WParserArray(orderStatus, Classname)
                mylineinherit=",\s*"$myline"\s*)"
                if ! grep -q $mylineinherit $project; then
                    unusedclasses="$unusedclasses <br> $myline";
                    let "count += 1";
                fi
            fi
        fi
    fi
    i=$(($i+1))
done < $2

resultfile='result.html'
echo "<i> <b>Note:</b> This scans all source files (*.h, *.m, *.mm) references in the directory.</i> <br>" >$resultfile
echo "<h3>"                             >>$resultfile
echo "There are $count unused classes"  >>$resultfile
echo "</h3>"                            >>$resultfile
echo "<pre>"                            >>$resultfile
if [ $count -gt 0 ]; then
    echo $unusedclasses                 >>$resultfile
fi
echo "</pre>"                           >>$resultfile

oc_getClasses.py

# -*- coding: utf-8 -*-
# 
# Usage demo: python sys.argv[0] ./ProjectRootDir

import os
import glob
import sys
import traceback
import re


class QOCMAnalier(object):
    """docstring for QOCMAnalier"""

    def __init__(self):
        super(QOCMAnalier, self).__init__()
        self.file_name_h = None
        self.file_content_h = None
        self.list_class_fromH = {}


        self.file_name_m = None
        self.file_content_m = None

        self.re_class_declare = re.compile('@interface\s+(\w+)\s*.*?@end', re.S)
        self.re_class_implementation = re.compile('@implementation\s*(\w+)(.*?)@end', re.S)
        self.list_class_implementation_s = None
        self.dict_class_setMethods = {} #dict


    # 返回文件内容,类无关函数。
    def __load_file_content(self, file_name):
        if os.path.isfile(file_name):
            try:
                f = open(file_name)
                str_content = f.read()
                return str_content
            except Exception, e:
                print e
                print traceback.format_exc()
            finally:
                f.close()


    # 加载.h文件
    def load_h(self, file_name = None):
        if isinstance(file_name, basestring):
            self.file_name_h = file_name
            self.file_content_h = self.__load_file_content(self.file_name_h)
            pass

    def parse_h(self):
        # 匹配.h文件中的类
        self.list_class_fromH = self.re_class_declare.findall(self.file_content_h)
        pass


    # 加载.m文件
    def load_m(self, file_name = None):
        if isinstance(file_name, basestring):
            self.file_name_m = file_name
            self.file_content_m = self.__load_file_content(self.file_name_m)
            pass

    def parse_m(self):
        ''' This is a test for oc_m object. '''
        self.list_class_implementation_s = self.re_class_implementation.findall(self.file_content_m)
        for clsss_implementation in self.list_class_implementation_s:
            class_name = clsss_implementation[0]
            class_implementation = clsss_implementation[1]
            self.dict_class_setMethods[class_name] = self.parse_methods(class_implementation)
    def parse_methods(self, strMethods):
        # 正则分析:OC函数
        # 第一部分:无参函数
        # 第二部分:第一个参数(可选部分)
        # 第三部分:其余参数(可选部分)
        re_method_declare = re.compile('([-+]\s*\(\w+\)\s*\w+\s*' + '(:\s*\(\s*\w+\s*\*?\)\s*\w+\s*)*' + '(\w+\s*:\s*\(\s*\w+\s*\*?\)\s*\w+\s*)*)')
        list_methods = re_method_declare.findall(strMethods)

        # 只要方法名method[0]
        list_methods = [method[0] for method in list_methods]
        return set(list_methods)

    def get_classnamees_fromH(self):
        return self.list_class_fromH
        pass

    def get_classnames(self):
        list_classnames = []
        for class_name in self.dict_class_setMethods:
            list_classnames.append(class_name)
        return list_classnames

    # print class namees.
    def print_ocm_classes(self):
        for class_name in self.dict_class_setMethods:
            print class_name
        pass
        
    # print classes and methods
    def print_ocm(self):
        for class_name in self.dict_class_setMethods:
            print class_name, len(self.dict_class_setMethods[class_name]), 'methods'
            for method in self.dict_class_setMethods[class_name]:
                print '\t' + method.strip()
        pass


# 以strRootDir为根目录,递归返回其下符合模式strPattern的文件。
def getFileList(strRootDir, strPattern):
    # 返回strDir目录下(不处理子目录)符合模式字符串的文件列表(只包含文件名)
    list_files_inRootDir = glob.glob(os.path.join(strRootDir, strPattern))
    
    # 处理子目录
    root = os.listdir(strRootDir)
    for item in root:
        item_as_root = os.path.join(strRootDir, item)
        if os.path.isdir(item_as_root):
            list_files_inRootDir.extend(getFileList(item_as_root, strPattern))
    return list_files_inRootDir


if __name__ == '__main__':
    args = sys.argv[1:]
    rootDir = './'
    if len(args) == 0 :
        print r'''请指定待分析目录'''
        print 'Usage demo: python ' + sys.argv[0] + ' ./ProjectRootDir'
        exit()
    elif os.path.isdir(args[0]):
        rootDir = args[0]
    else:
        print args[0] + r''' 不是一个有效目录'''
        exit()

    # 用于存放所有的类名
    set_class_all = set()

    # 创建分析起对象
    ocm_analier = QOCMAnalier()
    
    # 遍历.m文件
    list_mfiles = getFileList(rootDir, '*.m')
    for mfile in list_mfiles:
        ocm_analier.load_m(mfile)
        ocm_analier.parse_m()
        set_class_all = set_class_all | set(ocm_analier.get_classnames())
        pass


    # 遍历.h文件
    list_mfiles = getFileList(rootDir, '*.h')
    for mfile in list_mfiles:
        ocm_analier.load_h(mfile)
        ocm_analier.parse_h()
        set_class_all = set_class_all | set(ocm_analier.get_classnamees_fromH())
        pass


    for class_name in set_class_all:
        print class_name
        pass

其它处理

  • 关于瘦身也好,工程潜在问题分析也好,分析办法很多,也没有我们想象的那么难,比如代码相似度分析有simain,infor可以发现工程中很多有效的bug(后期它的用武之地可能会更多),无用函数分析的脚本也不难写,这里就不逐一分析了。

关于app瘦身,看看别人怎么做的

俗话说,知己知彼,百战不殆,干我们这一行,用在这里,虽然不太合适,但了解别人已经开源的实现,可以避免我们自己再造轮子,浪费时间。以下是几个别人已经早好的轮子(思路大致就是在Mac App中调用shell来实现)供大家参考:

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

推荐阅读更多精彩内容