暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

网易支付SDK包体积防劣化实践

技术对话 2023-10-17
107

背景

随着现在各大应用的不断更新,功能越来越多等情况下,应用体积也随之增大。支付SDK作为一个为宿主提供支付功能的基础模块,是直接集成到宿主工程当中,支付SDK的体积大小也影响着宿主工程的大小。支付SDK的体积在部分商户中的占比较高,需要降低SDK在宿主应用中的体积大小。SDK的大小在主要商户APP中占比超过10%,基于此需要对SDK进行包体积优化,降低在宿主中的占比。

基于以上背景,我们进行SDK包体积优化,其中对图片资源、无用文件类、二三方库等进行精简优化,为了防止后续业务开发过程中,导致无用资源文件增加,现增加防劣化监控,通过集成CI工具扫描相关代码资源进行检测和防护,保持SDK包体积健康度。

目标

杜绝SDK的包体积在日常迭代中出现突增风险,实时监测包体积变化。

介绍

防劣化:顾名思义,防止劣化,在本篇文章中就是对SDK的包体积防止劣化,不让包体积进一步恶化。由于之前已经做了包体积优化,所以需要一些防劣化的措施来保证包体积优化的成果。这里的防劣化措施也是基于优化措施而来。下面将介绍一下有哪些防劣化措施。

图片检测:图片资源一直都是包体的重要组成部分,所以需要对一些未使用图片、大图片等资源进行检测,检测到可以立即进行清理,防止线上出现这样的资源。

未使用类/方法检测:随着业务的不断迭代,SDK工程中会出现大量未使用的类或方法,这些类和方法已经不在最新的业务使用,是冗余的存在,清理掉不仅会减少文件,还能保证代码的清洁度。

二三方库监测:在开发过程中,或多或少的都会引入一些二方库或三方库,这里主要监测他们的版本变化或其依赖库的变化,减少不必要的依赖库引入。

文件大小监测:这里主要监测SDK工程中各个文件编译后的大小变化,对大小有变化的文件进行输出,对增量较大的文件建议进行逻辑拆分,避免单个文件过于臃肿。

包体积变化监测:对比Merge Request的源分支和目标分支的IPA包大小,获得包大小的增减量,对增量超过阈值的MR进行提示,让开发者检查代码并给出合理解释。

措施及实现

一、图片检测

1、图片集合

每一个工程里因业务需要都会有图片资源,我们可以将图片资源划分为以下几个集合:

  • 全量图片集合:全量图片资源是指代码仓库中存在的所有图片集合,具体后缀有.png、.jpg、.jpeg、.gif、.webp等。

  • 使用图片集合:指代码中使用到的图片,是代码直接或间接使用的图片集合。

  • 未使用图片集合:指全量图片集合和使用图片集合的差集。

  • 重复图片集合:指使用图片集合中,名称或hash值重复的图片,也可指相似度高的图片。

  • 大图片集合:指使用图片集合中,大小大于5KB(自定义)的图片集合。

有了上面的图片集合,就可以对不同集合中的图片进行清理、压缩、合并等操作。

2、方案

如上图所示,首先通过find命令查询到全量图片集合,然后从代码文件中查找使用的图片集合,两个集合取差价即可得到未使用图片集合;对使用图片集合进行名称及hash比较即可得到重复图片集合;对使用图片集合进行图片大小比较,大于自定义的阈值即可得到大图片集合。


3、实现

3.1、获取全量图片集合

func allResourceFiles() -> [String: Set<String>] {
          // /usr/bin/find
        let find = ExtensionFindProcess(path: projectPath, extensions: resourceExtensions, excluded: excludedPaths)
        guard let result = find?.execute() else {
            print("Resource finding failed.".red)
            return [:]
        }

        var files = [String: Set<String>]()
        fileLoop: for file in result {

            // 过滤忽略的文件夹和文件...

            let key = file.plainFileName(extensions: resourceExtensions)
            if let existing = files[key] {
                files[key] = existing.union([file])
            } else {
                files[key] = [file]
            }
        }
        return files
    }

  1. 通过路径、图片后缀等信息生成find . -name ".png" or -name ".jpg" 命令;

  2. execute执行该命令,查找项目中所有图片资源。

  3. 通过图片名称进行图片路径保存。

3.2、获取使用图片集合

func usedStringNames(at path: Path) -> Set<String> {
        guard let subPaths = try? path.children() else { return []}
        var result = [String]()
        for subPath in subPaths {
            if subPath.isDirectory {
                result.append(contentsOf: usedStringNames(at: subPath))
            } else {
                let fileExt = subPath.extension ?? ""
                guard searchInFileExtensions.contains(fileExt) else {
                    continue
                }
                let fileType = FileType(ext: fileExt)
                let searchRules = fileType?.searchRules(extensions: resourceExtensions) ??
                                  [PlainImageSearchRule(extensions: resourceExtensions)]
                let content = (try? subPath.read()) ?? ""
                result.append(contentsOf: searchRules.flatMap {
                    $0.search(in: content).map { name in
                        let p = Path(name)
                        guard let ext = p.extension else { return name }
                        return resourceExtensions.contains(ext) ? p.lastComponentWithoutExtension : name
                    }
                })
            }
        }
        return Set(result)
    }

  1. 获取文件夹子路径,递归查询本地代码文件;

  2. 通过不同的代码文件规则,对代码内容进行使用图片的筛查;

oc文件:"@\"(.?)\"", "\"(.?)\""

swift文件:"\"(.*?)\""

xib文件:"image name=\"(.?)\"", "image=\"(.?)\"", "value=\"(.*?)\""

plist文件:"(.*?)"

pbxproj文件:"ASSETCATALOG_COMPILER_APPICON_NAME = \"?(.?)\"?;", "ASSETCATALOG_COMPILER_COMPLICATION_NAME = \"?(.?)\"?;"

  • 将筛查结果保存,即可得到使用图片集合。

通过对两个集合的不同操作即可得到其他的图片集合,然后进行不同的处理操作即可。


二、未使用类/方法检测

要对未使用和方法进行检测,就必须对SDK的编译产物进行检测,下面将简单介绍一下MachO文件。

1、Mach-O

Mach-O为Mach Object文件格式的缩写,它是一种用于可执行档、目标代码、动态函式库、内核转储的档案格式。Mach-O提供了更强的扩展性,并提升了符号表中资讯的访问速度。

Header:文件的头部信息,包括CPU、文件类型、command的条数等;

Load Command:描述文件的加载信息,符号表的位置,字符串表的位置等;

__TEXT:存储静态字符串、代码指令、方法名等;

__DATA:主要存储数据、类关系、相关函数列表等;

Symbol:符号表信息;

String:字符串表。

在iOS中,通过xcodebuild命令直接触发该分支的编译打包,获取可执行文件。

最终就可以得到下面的可执行文件。

得到可执行文件后,就可以对其进行未使用类和方法的检测。

2、方案

2.1、未使用类检测方案

  1. 获取所有被引用的类集合和使用load方法的类的集合,合并之后得到使用类集合;

  2. 获取编译产物中所有类的集合;

  3. 将上面两个集合取差集,得到未使用类集合,通过类符号找到具体的类名;

  4. 去除未使用类中的父类及通过字符串引用的类名;

  5. 进行黑白名单过滤,即可得到最终的未使用类集合。

2.2、未使用方法检测方案

  1. 获取系统库和项目中的头文件目录,通过正则找到项目中使用到的代理方法,记为protocol_sels;

  2. 获取项目中所有被引用的方法,记为ref_sels;

  3. 获取项目中所有方法集合,去除setter、getter和load等方法,记为imp_sels;

  4. 遍历imp_sels,如果方法不在protocol_sels和ref_sels中,则说明该方法未使用,记为unuse_sels;

  5. 对unuse_sels进行黑白名单过滤,得到最终的未使用方法集合。

3、实现

3.1、未使用类检测实现

def class_unref_symbols(path):
  # example: Mach-O 64-bit executable arm64 or Mach-O 64-bit executable x86_64
  binary_file_arch = os.popen('file -b ' + path).read().split(' ')[-1].strip()
  # 获取未使用类 = 所有类 - (使用类 + 有load方法类)
  unref_pointers = class_pointers(path, binary_file_arch, ClassType.all) - (class_pointers(path, binary_file_arch, ClassType.ref) | class_pointers(path, binary_file_arch, ClassType.load))
  if len(unref_pointers) == 0:
    print('未找到未使用的类')
    sys.exit(0)
  symbols = class_symbols(path)
  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:
    sys.exit('class unref null')
  return unref_symbols

  • 通过file -b命令获取可执行文件的架构,通过class_pointers方法去分别获取所有方法集合、使用类集合、load方法集合,通过运算得到未使用类集合。

下面以x86架构(模拟器)的编译产物为例:

def class_pointers(path, binary_file_arch, class_type):
  list_pointers = set()
  script = ''
  if class_type == ClassType.all:
    '''
    x86:
    000000010117cd20    a0 80 2e 01 01 00 00 00 f0 80 2e 01 01 00 00 00 
    000000010117cd30    40 81 2e 01 01 00 00 00 90 81 2e 01 01 00 00 00

    arm64:
    00000001015be4f0    01722560 00000001 017225b0 00000001 
    00000001015be500    01722600 00000001 01722650 00000001
    '''

    script = f'/usr/bin/otool -v -s __DATA __objc_classlist {path}'
  elif class_type == ClassType.ref:
    '''
    00000001012d5888    00 00 00 00 00 00 00 00 88 bd 2e 01 01 00 00 00 
    00000001012d5898    00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
    '''

    script = f'/usr/bin/otool -v -s __DATA __objc_classrefs {path}'
  elif class_type == ClassType.load:
    '''
    000000010117ffd0    d8 1d 2f 01 01 00 00 00 48 91 2e 01 01 00 00 00 
    000000010117ffe0    a0 b3 2e 01 01 00 00 00 f8 b6 2e 01 01 00 00 00
    '''

    script = f'/usr/bin/otool -v -s __DATA __objc_nlclslist {path}'
  if not script:
    sys.exit('获取类命令为空,请确定类型是否正确')

  lines = os.popen(script).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 and (class_type == ClassType.all or class_type == ClassType.ref) :
    sys.exit(f'Error: {class_type} class pointer null')
  return list_pointers

  • 通过不同的ClassType调用不同的脚本命令获取对应的集合。

所有类集合:__objc_classlist

引用类集合:__objc_classrefs

load类集合:__objc_nlclslist

  • 获取到未使用集合中类的地址,再进行大小端替换,获取对应的类符号。

获取到类符号之后,通过nm命令获取产物的类符号和类名对应信息,具体实现如下:

def class_symbols(path):
  symbols = {}
  re_class_name = re.compile('(\w{16}) .* _OBJC_CLASS_\$_(.+)')
  lines = os.popen(f'nm -nm {path}').readlines()
  # line: 00000001012fe4e0 (__DATA,__objc_data) external _OBJC_CLASS_$_NEPPayViewController
  for line in lines:
    # result: [('00000001012fe4e0', 'NEPPayViewController')]
    result = re_class_name.findall(line)
    if result:
      (addr, symbol) = result[0]
      symbols[addr] = symbol
  # symbols: {'00000001012fe4e0': 'NEPPayViewController', '': '', .....}
  if len(symbols) == 0:
    sys.exit('Error: class symbols null')
  return symbols

  • 通过未使用类集合中的类符号,找到对应的类名并记录,进行输出。

至此,就可以找到对应的未使用类名,然后可以通过人工确认是否进行删除操作。

3.2、未使用方法检测实现

def unref_selectors(path, project_path):
  # 获取所有类的protocol的方法集合
  protocol_sels = protocol_selectors(path, project_path)
  # 获取项目所有的引用方法
  ref_sels = ref_selectors(path)
  if len(ref_sels) == 0:
    sys.exit('Error: ref selectors is empty')
  # 获取所有方法
  imp_sels = imp_selectors(path)
  if len(imp_sels) == 0:
    sys.exit('Error: imp selectors is empty')
  unuse_sels = set()
  for sel in imp_sels:
    # 1、遍历所有方法
    # 2、跳过忽略方法
    # 3、判断方法是否在协议方法 和 引用方法里
    if ignore_selectors(sel):
      continue
    if sel not in protocol_sels and sel not in ref_sels:
      unuse_sels = unuse_sels.union(filter_selectors(imp_sels[sel]))
  return unuse_sels

  • 获取所有类的代理方法集合和引用方法集合,再获取所有方法集合。

获取代理方法:找到/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk路径,通过otool -L命令去遍历系统的代理方法。

获取引用方法:通过otool -v -s __DATA __objc_selrefs命令获取引用方法集合。

所有方法:通过otool -oV命令获取所有方法的集合。

  • 通过遍历所有方法集合,去除忽略、协议、引用的方法后,得到未使用方法集合。

这里就可以输出未使用类的方法名,可以铜鼓人工确认是否需要删除等操作。

三、二三方库检测

1、方案

  1. 切换到目标分支上,执行pod install操作,获取目标分支上依赖的lock文件target_podfile_lock,需要备份,不然执行第二步时会消失;

  2. 再切换到源分支上,执行pod install操作,获取源分支上依赖的lock文件source_podfile_lock;

  3. 将两个lock文件进行diff操作,获取源分支上变化的库;

  4. 获取变化库的不同版本大小,输出版本变化和大小变化。

2、实现

下面简单介绍一下不同分支库版本对比变化。代码如下:

def get_change_list(pod_data_list_target, pod_data_list_source):
  """获取目标分支和源分支的改动列表"""
  add_lines = []
  for line1 in pod_data_list_source:
    if line1 not in pod_data_list_target:
      add_lines.append(line1)

  reduce_lines = []
  for line2 in pod_data_list_target:
    if line2 not in pod_data_list_source:
      reduce_lines.append(line2)

  if not add_lines and not reduce_lines:
    add_note('podfileLock检测结果: 无变化')
    sys.exit(0)

  index_dir = f'{project_path}/indexDir'
  create_folder(index_dir)
  index_html = f'{index_dir}/index.html'
  d = difflib.HtmlDiff()
  result = d.make_file(fromlines=reduce_lines, 
                         tolines=add_lines, 
                        fromdesc=target_branch_name, 
                          todesc=source_branch_name)
  with open(index_html, 'w'as f:
    f.writelines(result)
  upload_targz(index_dir)

  • 通过前面获取到的两个lock文件,进行逐行对比,将不同的部分进行记录,通过difflib库生成html再上传,可以直观的看出库的版本变化。

四、文件大小监测

顾名思义,就是对源分支上每个文件的大小进行检测,将检测到的大小同目标分钟进行对比,获取文件大小的增量或减量,可以明确的知道哪些文件在该次mr中迅速膨胀。

1、LinkMap

需要检测文件的大小,就需要用到xcode编译过程中产生的LinkMap文件,这里介绍一下LinkMap文件。

LinkMap文件:源码需要经过编译、链接,最终生成一个可执行文件。在编译阶段,每个类会生成对应的.o文件(目标文件)。在链接阶段,会把.o文件和动态库链接在一起。Link Map File就是这样一个记录链接相关信息的纯文本文件,里面记录了可执行文件的路径、CPU架构、目标文件、符号等信息。

而生成LinkMap文件则需要再xcode中手动开启配置。

开启后,通过编译就可以获得对应的LinkMap文件了。路径为xxx-LinkMap-normal-arm64.txt。

2、方案

对MR的源分支和目标分支分别通过命令进行编译,获取LinkMap文件,将两文件进行对比操作。

第一步:定位到工程目录,切换到MR的目标分支,执行编译操作,获取目标分支的LinkMap文件并备份;

第二步:将分支切回MR的源分支,执行编译操作,获取源分支的LinkMap文件;

第三步:将两个分支的LinkMap文件进行对比,获取大小变化的.o文件进行输出。

具体流程大致和二三方库监测相同,现方案实现分开叙述,具体开发阶段可将该两个操作合并一同执行,获取不同文件的对比结果。

3、实现

# 解析linkMap文件
def link_map_file_parser(link_map_file):
  '''
  1、解析linkMap文件
  # Object files:
  [606] /.../Library/Developer/Xcode/DerivedData/NEPaySDK-helpvbjvhvwtrfdimmfeffgxhfbv/Build/Products/Debug-iphonesimulator/xxxSDK-dev/libNEPaySDK-dev.a(xxxInfo.o)
  [607] /.../Library/Developer/Xcode/DerivedData/NEPaySDK-helpvbjvhvwtrfdimmfeffgxhfbv/Build/Products/Debug-iphonesimulator/xxxSDK-dev/libNEPaySDK-dev.a(xxxManager.o)

  # Sections:
  0x100002000    0x00E4268D  __TEXT  __text
  0x101090600    0x00092820  __DATA  __const

  # Symbols:
  0x100402F60    0x000000C0  [606] -[xxxInfo setAccountName:]
  0x100403020    0x000000A0  [606] -[xxxInfo setNameIdChecked:]

  顺序:Path、Object files、Sections、Symbols
  '''

  reach_files = 0
  reach_sections = 0
  reach_symbols = 0
  size_map = {}
  for line in link_map_file:
    if not line:
      continue
    if line.startswith('#'):
      if line.startswith('# Object files:'):
        # 找到Object files区域
        reach_files = 1
      if line.startswith('# Sections:'):
        # 找到Sections区域
        reach_sections = 1
      if line.startswith('# Symbols:'):
        # 找到Symbols区域
        reach_symbols = 1
    else:
      if reach_files == 1 and reach_sections == 0 and reach_symbols == 0:
        # Object files区域,获取所有文件
        index = line.find(']')
        if index != -1:
          symbol = {'file': line[index+2 : -1]}
          key = int(line[1 : index])
          size_map[key] = symbol
          # size_map = {'606': {'file': '.../libxxx-dev.a(NEPayInfo.o)'}, '607': {'file': '.../libxxx-dev.a(NEPayManager.o)'}, ...}
      elif reach_files == 1 and reach_sections == 1 and reach_symbols == 0:
        # Sections区域,暂无处理
        pass
      elif reach_files == 1 and reach_sections == 1 and reach_symbols == 1:
        # Symbols区域,获取类所有方法和属性大小
        # 以制表符分割,例:['0x100402F60', '0x000000C0', '[606] -[xxxInfo setAccountName:]']
        symbol_array = line.split('\t')
        if len(symbol_array) == 3:
          # 获取key和name,[331] -[xxxViewItem setItemFrame:highColor:']
          file_key_and_name = symbol_array[2]
          # 获取大小 16进制,0x000000C0
          size = int(symbol_array[1], 16)
          index = file_key_and_name.find(']')
          if index != -1:
            # 找到key,606
            key = int(file_key_and_name[1:index])
            # 取出前面存的symbol
            symbol = size_map[key]
            # 将相同key的大小进行叠加
            if symbol:
              if 'size' in symbol:
                symbol['size'] += size
              else:
                symbol['size'] = size
      else:
        pass
  return size_map

  • 遍历LinkMap文件,分别找到Object files、Sections、Symbols区域;

  • 在Object files区域找到所有文件,并记录在map中;

  • 在Symbols区域中获取类所有方法和属性大小;

  • 对通过类里的方法大小进行叠加求和,得到这个类的最终大小。

五、包体积变化监测

1、方案

方案实现比较简单,就是切换目标分支和源分支来执行打包脚本,记录两个分支的包体积大小进行比较,最后得出包体积变化的值。

2、实现

def ipa_package(project_path):
  '''
  打包脚本输出日志:
  ##AppVersion##
  Exported xxxDemo to: /var/folders/tr/crfnh05s32n8cd0b4nxrknz00000gn/T/xxxDemo-ReleaseTemp/projectDir/build
  ** EXPORT SUCCEEDED **
  ~~AppIPAPath~~
  /var/folders/tr/crfnh05s32n8cd0b4nxrknz00000gn/T/xxxDemo-ReleaseTemp/projectDir/build/xxxDemo_支付Demo.ipa
  ##AppIPAPath##

  通过判别关键字获取ipa包路径,再通过路径获取ipa文件大小。
  '''

  script = f'cd {project_path} && sh build.sh'
  build_ipa_content = os.popen(script).readlines()
  is_export_success = False
  ipa_path = ''
  for line in build_ipa_content:
    if 'EXPORT SUCCEEDED' in line:
      is_export_success = True
    if is_export_success:
      if '.ipa' in line:
        ipa_path = line.strip()
        break
  ipa_size = os.path.getsize(ipa_path)
  return ipa_size

  • cd到本地,执行打包脚本,计算包大小体积,输出大小变化。

六、终极调用

将这些工具都实现之后,需要在入口文件进行调用。安装工具的优先级一步一步调用,最后输出结果,具体调用如下:

if __name__ == '__main__':
  print('包体积防劣化检测开始')
  # 获取本地配置
  config_data = get_local_config()

  # 切换到目标分支
  checkout_branch(target_branch_name)
  # 获取目标分支图片情况
  target_img_module = img_check(config_data)
  # 对目标分支进行编译
  build_project()
  # 编译完成后备份目标分支LinkMap文件
  target_branch_link_map_backup_path = backup_file(derived_data_path + link_map_sub_path, temp_file_backup)
  # 对目标分支工程进行打包,并获取ipa包大小
  target_ipa_size = ipa_package.ipa_package(project_path)

  # 切换到源分支(现有开发分支)
  checkout_branch(source_branch_name)
  # 获取源分支图片情况
  source_img_module = img_check(config_data)
  # 对源分支进行编译
  build_project()
  # 编译完成后无用方法/类检测
  (unuse_classs, unuse_sels) = unuse_check(config_data)
  # 编译完成后获取源分支LinkMap文件路径
  source_branch_link_map_path = derived_data_path + link_map_sub_path
  # 对源分支工程进行打包,并获取ipa包大小
  source_ipa_size = ipa_package.ipa_package(project_path)

  # 图片结果筛选
  result_imgs(source_img_module, target_img_module)
  # 无用方法\类结果
  result_unuse_class_and_sel(unuse_classs, unuse_sels)
  # 文件大小检测和结果
  file_check(target_branch_link_map_backup_path, source_branch_link_map_path)
  # ipa包大小对比结果
  result_ipa_size(source_ipa_size - target_ipa_size)
  print('包体积防劣化检测结束')

结果

1、merge request

在mr创建后都会触发该检测工具,工具执行完成后通过评论输出到mr下面,可以清晰的看到工具执行的结果。

2、耗时

以上是CI工具的总耗时和防劣化工具的耗时对比,可以看出防劣化工具的耗时平均占比74.1%,耗时较久,后续将对其进行优化,降低耗时占比。

3、总结

风险

1、通过工具对资源/文件扫描的结果,不能保证100%的准确率,会存在误判的情况,因此必须对获取的结果进行多次或多人确认才能进行对应操作。

2、当因业务需要引进新的二三方库时,很容易导致包体积增量突破阈值,会导致防劣化的难度增加。

3、一套工具执行下来,耗时比较长,对于一些紧急的MR将无法做到有效监控。可考虑只对有业务修改的正常MR开启耗时检测工具。

后续

1、持续优化扫描工具,提高工具检测准确率,提前发现更多问题。

2、优化检测工具,降低执行时间,提高运行效率。

3、新增其他防劣化操作,增加劣化壁垒。


-- End --

点击下方的公众号入口,关注「技术对话」微信公众号,可查看历史文章,投稿请在公众号后台回复:投稿

文章转载自技术对话,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论