优化思路:
1、先了解工程结构。第三方库管理方式使用情况、本地资源文件使用情况
2、使用infer进行静态代码扫描
3、使用command+shift+b进行静态内存扫描
4、使用LSUnusedResources进行无用图片扫描,扫描后由开发检测这些图片,判断图片在项目中是否实际有用到
5、使用FindSelectorsUnrefs.py脚本检测无用类与方法
6、使用fdupes扫描重复文件
7、使用pmd扫描重复代码
8、使用智图工具压缩工程图片
9、检测工程+(void)load使用情况(搜索(void)load)
10、检测工程启动时间,查看工程启动流程代码
11、工程配置优化
一、使用infer进行静态代码扫描
静态扫描的目的是进行代码规范的执行检测,保障项目代码规范的一致性,增加代码的可阅读性、维护性。减少低级或隐藏错误
1、安装命令brew install infer
如遇报错:No such file or directory @ rb_sysopen - /Users/heyujia/Library/Caches/Homebrew/downloads/3f2311ffb10a2774d49c39dfca3e6fc69d0a951be4299c7c84383c5774481d62--sqlite-3.37.2.monterey.bottle.tar.gz
brew install sqlite-3.37.2.monterey.bottle.tar.gz
解决办法:brew update (更新homebrew 源,本人homebrew版本3.6.3)
如遇其他报错请检查HomeBrew的版本、安装路径、源路径等等。
2、使用以下命令进行扫描
# 第1步:编译工程生成编译日志xcodebuild.log文件
xcodebuild -workspace xxx.xcworkspace -scheme xxx -configuration Debug -sdk iphoneos COMPILER_INDEX_STORE_ENABLE=NO | tee xcodebuild.log
# 第2步:根据编译日志生成编译数据compile_commands.json文件
xcpretty -r json-compilation-database -o compile_commands.json < xcodebuild.log > /dev/null
# 第3步:基于编译数据compile_commands.json文件进行静态分析
infer run --skip-analysis-in-path Pods --no-xcpretty --keep-going --compilation-database-escaped compile_commands.json
3、查看report.txt文件,根据文件内容进行修改
扫描结果会产出在命令指定的工程目录下,根据扫描结果去进行修改。修改时根据项目和团队情况选择性修改,不必所有内容都修改。因为扫描规则是infer设置的,如果需要调整扫描规则需要修改infer。
二、command + shift + b 静态内存扫描
扫描的目的是从静态代码分析工程代码中有哪些明确的代码使用错误,减少内存占用,从而提升App性能。 一般只需要修复自身编写代码的错误,第三方库的不建议修改。
三、使用LSUnusedResources进行无用图片扫描
扫描的目的是减少无用资源,从而减少App体积以及启动时资源加载的耗时
1、下载LSUnusedResources
在官网下载工程后,用MyMac模拟器运行工程就会打开工具
2、开始扫描并删除无用图片
四、使用FindSelectorsUnrefs.py脚本检测无用类与方法
通过减少工程中无用类与未使用的方法的方式,可以减少App包的体积,也可以增加App启动时连接加载的耗时,从而提升App启动速度。
因为FindSelectorsUnrefs.py是python脚本,因此需要有python环境。因为本人使用的是python3,所以下面脚本是针对python3的
,如果是其他版本的python,语法会有所差异,差异的部分需要根据语法自行调整。
1、FindSelectorsUnrefs.py脚本内容
# coding:utf-8
import os
import re
import sys
import getopt
reserved_prefixs = ["-[", "+["]
# 获取入参参数
def input_parameter():
opts, args = getopt.getopt(sys.argv[1:], '-a:-p:-w:-b:',
['app_path=', 'project_path=', 'black_list_Str', 'white_list_str'])
black_list_str = ''
white_list_str = ''
white_list = []
black_list = []
# 入参判断
for opt_name, opt_value in opts:
if opt_name in ('-a', '--app_path'):
# .app文件路径
app_path = opt_value
if opt_name in ('-p', '--project_path'):
# 项目文件路径
project_path = opt_value
if opt_name in ('-b', '--black_list_Str'):
# 检测黑名单前缀,不检测谁
black_list_Str = opt_value
if opt_name in ('-w', '--white_list_str'):
# 检测白名单前缀,只检测谁
white_list_str = opt_value
if len(black_list_str) > 0:
black_list = black_list_str.split(",")
if len(white_list_str) > 0:
white_list = white_list_str.split(",")
if len(white_list) > 0 and len(black_list) > 0:
print("\033[0;31;40m白名单【-w】和黑名单【-b】不能同时存在\033[0m")
exit(1)
# 判断文件路径存不存在
if not os.path.exists(project_path):
print("\033[0;31;40m输入的项目文件路径【-p】不存在\033[0m")
exit(1)
app_path = verified_app_path(app_path)
if not app_path:
exit('输入的app路径不存在,停止运行')
return app_path, project_path, black_list, white_list
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
# 获取protocol中所有的方法
def header_protocol_selectors(file_path):
# 删除路径前后的空格
file_path = file_path.strip()
if not os.path.isfile(file_path):
return None
protocol_sels = set()
file = open(file_path, 'r',encoding='utf-8',errors='ignore')
is_protocol_area = False
# 开始遍历文件内容
for line in file.readlines():
# 删除注释信息
# delete description
line = re.sub('\".*\"', '', line)
# delete annotation
line = re.sub('//.*', '', line)
# 检测是否是 @protocol
# 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
# - (CGPoint)convertPoint:(CGPoint)point toCoordinateSpace:(id <UICoordinateSpace>)coordinateSpace
if ':' in line:
# match sel with parameters
# 【"convertPoint:","toCoordinateSpace:"]
sel_content_match_result = re.compile('\w+\s*:').findall(line)
else:
# - (void)invalidate;
# match sel without parameters
# invalidate;
sel_content_match_result = re.compile('\w+\s*;').findall(line)
if sel_content_match_result:
# 方法参数拼接
# convertPoint:toCoordinateSpace:
funcList = ''.join(sel_content_match_result).replace(';', '')
protocol_sels.add(funcList)
file.close()
return protocol_sels
# 获取所有protocol定义的方法
def protocol_selectors(path, project_path):
print('获取所有的protocol中的方法...')
header_files = set()
protocol_sels = set()
# 获取当前引用的系统库中的方法列表
system_base_dir = '/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk'
# get system librareis
lines = os.popen('otool -L ' + path).readlines()
for line in lines:
# 去除首尾空格
line = line.strip()
# /System/Library/Frameworks/MediaPlayer.framework/MediaPlayer (compatibility version 1.0.0, current version 1.0.0)
# /System/Library/Frameworks/MediaPlayer.framework/MediaPlayer
# delete description,
line = re.sub('\(.*\)', '', line).strip()
if line.startswith('/System/Library/'):
# [0:-1],获取数组的左起第一个,到倒数最后一个,不包含最后一个,[1,-1)左闭右开
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())
if not os.path.isdir(project_path):
exit('Error: project path error')
# 获取当前路径下面所有的.h文件路径
header_files = header_files.union(os.popen('find %s -name \"*.h\"' % project_path).readlines())
for header_path in header_files:
# 获取所有查找到的文件下面的protocol方法,这些方法,不能用来统计
header_protocol_sels = header_protocol_selectors(header_path)
if header_protocol_sels:
protocol_sels = protocol_sels.union(header_protocol_sels)
return protocol_sels
def imp_selectors(path):
print('获取所有的方法,除了setter and getter方法...')
# return struct: {'setupHeaderShadowView':['-[TTBaseViewController setupHeaderShadowView]']}
# imp 0x100001260 -[AppDelegate setWindow:] ==>> -[AppDelegate setWindow:],setWindow:
re_sel_imp = re.compile('\s*imp\s*0x\w+ ([+|-]\[.+\s(.+)\])')
re_properties_start = re.compile('\s*baseProperties 0x\w{9}')
re_properties_end = re.compile('\w{16} 0x\w{9} _OBJC_CLASS_\$_(.+)')
re_property = re.compile('\s*name\s*0x\w+ (.+)')
imp_sels = {}
is_properties_area = False
# “otool - ov”将输出Objective - C类结构及其定义的方法。
for line in os.popen('/usr/bin/otool -oV %s' % path).readlines():
results = re_sel_imp.findall(line)
if results:
# imp 0x100001260 -[AppDelegate setWindow:] ==>> [-[AppDelegate setWindow:],setWindow:]
(class_sel, sel) = results[0]
if sel in imp_sels:
imp_sels[sel].add(class_sel)
else:
imp_sels[sel] = set([class_sel])
else:
# delete setter and getter methods as ivar assignment will not trigger them
# 删除相关的set方法
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)
# 拼接set方法
setter = 'set' + property_name[0].upper() + property_name[1:] + ':'
# 干掉set方法
if setter in imp_sels:
imp_sels.pop(setter)
return imp_sels
def ref_selectors(path):
print('获取所有被调用的方法...')
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
def ignore_selectors(sel):
if sel == '.cxx_destruct':
return True
if sel == 'load':
return True
return False
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
def unref_selectors(path, project_path):
# 获取所有类的protocol的方法集合
protocol_sels = protocol_selectors(path, project_path)
# 获取项目所有的引用方法
ref_sels = ref_selectors(path)
if len(ref_sels) == 0:
exit('获取项目所有的引用方法为空....')
# 获取所有的方法,除了set方法
imp_sels = imp_selectors(path)
print("\n")
if len(imp_sels) == 0:
exit('Error: imp selectors count null')
unref_sels = set()
for sel in imp_sels:
# 所有的方法,忽略白名单
if ignore_selectors(sel):
continue
# 如果当前的方法不在protocol中,也不再引用的方法中,那么认为这个方法没有被用到
# 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
# 黑白名单过滤
def filtration_list(unref_sels, black_list, white_list):
# 黑名单过滤
temp_unref_sels = list(unref_sels)
if len(black_list) > 0:
# 如果黑名单存在,那么将在黑名单中的前缀都过滤掉
for unref_sel in temp_unref_sels:
for black_prefix in black_list:
class_method = "+[%s" % black_prefix
instance_method = "-[%s" % black_prefix
if (unref_sel.startswith(class_method) or unref_sel.startswith(
instance_method)) and unref_sel in unref_sels:
unref_sels.remove(unref_sel)
break
# 白名单过滤
temp_array = []
if len(white_list) > 0:
# 如果白名单存在,只留下白名单中的部分
for unref_sel in unref_sels:
for white_prefix in white_list:
class_method = "+[%s" % white_prefix
instance_method = "-[%s" % white_prefix
if unref_sel.startswith(class_method) or unref_sel.startswith(instance_method):
temp_array.append(unref_sel)
break
unref_sels = temp_array
return unref_sels
# 整理结果,写入文件
def write_to_file(unref_sels):
file_name = 'selector_unrefs.txt'
f = open(os.path.join(sys.path[0].strip(), file_name), 'w')
unref_sels_num_str = '查找到未被使用的方法: %d个\n' % len(unref_sels)
print(unref_sels_num_str)
f.write(unref_sels_num_str)
num = 1
for unref_sel in unref_sels:
unref_sels_str = '%d : %s' % (num, unref_sel)
print(unref_sels_str)
f.write(unref_sels_str + '\n')
num = num + 1
f.close()
print('\n项目中未使用方法检测完毕,相关结果存储到当前目录 %s 中' % file_name)
print('请在项目中进行二次确认后处理')
if __name__ == '__main__':
# 获取入参
app_path, project_path, black_list, white_list = input_parameter()
# 获取未使用方法
unref_sels = unref_selectors(app_path, project_path)
# 黑白名单过滤
unref_sels = filtration_list(unref_sels, black_list, white_list)
# 打印写入文件
write_to_file(unref_sels)
2、执行脚本开始扫描
扫描时需要cd到FindSelectorsUnrefs.py所在目录,扫描是基于工程编译产物.app进行的,因此需要保障工程可以正常的编译通过。然后找到工程的.app文件路径。扫描命令里第一个路径是填写.app文件路径。第二个路径是工程的根目录。其原理是通过编译产物的二进制文件里的符号表信息与工程文件做比较,分析哪些方法没有使用过。
//脚本命令说明
//-a Xcode运行之后的,项目Product路径
//-p 项目的地址
//-w 结果白名单处理,检测结果,只想要以什么开头的类的方法,多个用逗号隔开,比如JD,BD,AL
//-b 结果黑名单处理,检测结果,不想要以什么开头的类的方法,多个用逗号隔开,比如Pod,AF,SD
//-w 和 -b 不能共存,共存会报错
python FindSelectorsUnrefs.py -a /Users/heyujia/Library/Developer/Xcode/DerivedData/mPaasBestPracticeDemo-dwqqyiincpwuaocnzhmmhnqxnrmd/Build/Products/Debug-iphonesimulator/mPaasBestPracticeDemo.app -p /Users/heyujia/Desktop/MPaaS/mPaasBestPracticeDemo -b UI,AU,MP,AP,DT,SD,AF,mPaaS,MA,Ali
// /Users/heyujia/Library/Developer/Xcode/DerivedData/mPaasBestPracticeDemo-dwqqyiincpwuaocnzhmmhnqxnrmd/Build/Products/Debug-iphonesimulator/mPaasBestPracticeDemo.app
//这个地址是你工程编译产物.app地址
// /Users/heyujia/Desktop/MPaaS/mPaasBestPracticeDemo
//这个地址是你工程根目录地址
//UI,AU,MP,AP,DT,SD,AF,mPaaS,MA,Ali
//这个是你自己开发的类的前缀,用于白名单处理,让脚本只扫描你自己的类,避免扫描无法修改的类
如遇报错:zsh: command not found: python 可执行下面命令解决
echo "alias python=/usr/bin/python3" >> ~/.zshrc
source ~/.zshrc
echo "alias python=/usr/bin/python3" >> ~/.bash_profile
source ~/.bash_profile
3、检测扫描结果
扫描结果的内容一般会有很多,特别是一些第三方库,但第三方库的类一般都是有固定前缀,可以通过在执行命名后 “加参数 -b AU,MP,AP,DT,SD,AF,mPaaS,MA,Ali” 来忽略。最好的方式是自己工程的类,有固定的前缀,通过添加-w 白名单,只扫描自己的类。
检测扫描结果selector_unrefs.txt文件,根据文件内容进行删除
五、使用fdupes扫描重复文件
减少重复文件既可以减少App体积,又可以减少启动时连接加载类的耗时,提升启动速度。
1、安装fdupes
brew install fdupes
2、扫描重复文件
扫描命令的第一个路径是被扫工程的根路径,第二个路径是扫描结果fdupes.txt文件存放的路径
// /Users/heyujia/Desktop/MPaaS/mPaasBestPracticeDemo
//是你工程的根目录地址
// /Users/heyujia/Desktop/MPaaS/fdupes.txt
//是扫描结果输出的文件地址已经文件名称
fdupes -Sr /Users/heyujia/Desktop/MPaaS/mPaasBestPracticeDemo > /Users/heyujia/Desktop/MPaaS/fdupes.txt
3、删除重复文件
根据扫描结果文件和工程实际使用情况,删除重复文件
六、使用pmd扫描重复代码
现在大多数工程都是由多人开发,开发过程或测试过程会写很多重复的代码。这些代码会增加类的体积,增加编译运行的效率。因此上线前需要删除这些重复的代码。
重复代码的标准需要使用时自行根据经验去定义,具体多少个字符相同就算重复代码。这是一个经验值。需要根据项目和团队情况去定义。
1、查询当前有哪些php版本
安装pmd 先要安装php
brew search php
2、安装php
根据查询结果选择自己想要安装的php版本
brew install php@8.0
如遇报错:php@8.0: Failed to download resource "aspell"
解决:brew upgrade
3、配置并启动PHP服务
# 配置php环境变量
echo "export PATH="$(brew --prefix php@8.0)/bin:$PATH"" >> ~/.bash_profile
source ~/.bash_profile
# 创建php启动文件夹
mkdir -p ~/Library/LaunchAgents
# 加入launchctl启动控制
cp /usr/local/Cellar/php@8.0/8.0.23_1/homebrew.mxcl.php@8.0.plist ~/Library/LaunchAgents/
launchctl load -w ~/Library/LaunchAgents/homebrew.mxcl.php@8.0.plist
#启动php服务
brew services start php@8.0
4、安装 pmd
brew install pmd
5、在工程中添加pmd脚本
安装完成后需要在Xcode 的 Build Phases 中,我们增加一个新的 Run Script
添加如下脚本
#检测swift代码
#pmd cpd --files ${EXECUTABLE_NAME} --minimum-tokens 50 --language swift --encoding UTF-8 --format net.sourceforge.pmd.cpd.XMLRenderer > cpd-output.xml --failOnViolation true
#检测objective-c代码
pmd cpd --files ${EXECUTABLE_NAME} --minimum-tokens 20 --language objectivec --encoding UTF-8 --format net.sourceforge.pmd.cpd.XMLRenderer > cpd-output.xml --failOnViolation true
# Running script
php ./cpd_script.php -cpd-xml cpd-output.xml
脚本中--minimum-tokens:该值决定了多少个字符串长度相同即代表为重复。是一个经验值。一般20、50、100三个值都进行扫描一次。
6、分析结果
根据扫描结果,与团队成员探讨哪些需要删除哪些需要保留。每一次探讨都是一次经验的积累。从经验中能更好的找出一个适合自己团队的--minimum-tokens值。
如遇编译报错:line 10: php: command not found Command PhaseScriptExecution failed with a nonzero exit code
解决:
brew services restart php@8.0
echo 'export PATH="/usr/local/opt/php@8.0/bin:$PATH"' >> ~/.zshrc
echo 'export PATH="/usr/local/opt/php@8.0/sbin:$PATH"' >> ~/.zshrc
source ~/.zshrc
brew services restart php@8.0
brew link --overwrite --force php@8.0
七、使用智图软件对工程中的图片进行压缩
智图官网。如果对外部软件不放心或者对压缩后的图片质量不放心。可以找自己公司的UI大佬想办法尽量压缩图片的大小。这么多年的工作经验告诉我,靠人不如靠己。
八、检测+(void)load使用情况
在App的冷启动过程中,会去加载load方法,如果load方法内执行的代码过多或者比较耗时。会导致App启动速度变慢。因此需要全局搜索(void)load,把每一个自己写的load方法都看一遍。检测每一个load方法的耗时,能不放在load的就不放load中,如果必须方load有没有其他代替方案或者其他执行效率较高的方法来实现。
完成上面八个步骤,静态部分就是剩下最后对Mach-o的重排了,Mach-o部分留在文章的最后,因为这是上架前的最后一步。接下来讲讲动态部分思路,动态部分只讲思路,还有些许案例。因为这个算是经验学,每个公司、团队、项目的情况不一样。
九、AppDelegate优化
这是老生常谈的启动优化流程之一,检测一下你AppDelegate里的代码,把能延迟或异步执行的代码统统放在后面执行,不要对App启动流程造成任何一点点的阻塞。虽然原则就这一点,但这一点还是有难度的,因为大多数在App启动的时候会初始化各种SDK,做各种校验等等。最瑟瑟发抖的是大部分金融类型App都会有零信任服务或者国密SSL服务,需要在App启动的时候就初始化,保障能拦截App所有的网络请求,而这种SDK有些还是异步的,需要阻塞线程来等待它初始化完成在继续执行启动流程。这部分就非常考验人的能力,如何在满足需求的情况提示App启动速度。
一般开发能纯代码开发就不要用SB、XIB,后者的执行效率比纯代码要慢很多,并且可控性也没有代码来得那么直观。而且文件还大。
十、设计App的启动流程
现在App启动耗时记录的时间不在是以前的App执行完AppDelegate的耗时了,而是用户点击App启动图标到完整的看到App的首页这段时间。因此为了提升App的启动耗时,需要开发同学单独对App的启动流程进行开发详细设计,产出详设文档。因为产品同学一般提供的PRD都只关注业务流程,对于开发而言内容太粗,细节太少,并且产品同学不是非常了解App启动的逻辑流程。所以需要开发同学从更加全面细致的思维角度去细化完善启动流程,并从设计角度提升App的启动速度,让用户能快速的进入到App首页,提升用户体验。
App的启动耗时结束的标准是用户看到完整的首页界面,那么至少要在首页ViewController的viewDidLoad执行完成后才算结束。但首页数据往往都有网络请求,更加精准的应该是网络请求数据回来并渲染完成后,才算做启动时间结束
十一、工程配置优化
1、Generate Debug Symbols 设置为NO
该选项是是否生产调试符号,设置为NO就不会生成调试符号,也会导致不会在断点处停下。所以release模式下可以设置为NO。
2、舍弃arm7架构
armv7用于支持4s和4,目前大多数项目最低支持版本是iOS9.而4s最高支持iOS8.所以这个框架基本可以忽略。
3、去掉不必要的调试符号
Strip Debug Symbols During Copy release设置为YES
Symbols Hidden by Default release设置为YES
4、Asset Catalog Compiler编译设置优化
通过修改Optimization这个选项可以改变在构建Assets.car时的策略,修改为space
后会按照一定策略选取编码算法,对其中的 png 图片重新编码, 从而减少包大小。