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来实现)供大家参考: