Android 增量编译 3~5 秒的背后

663 阅读9分钟
原文链接: www.jianshu.com

前篇福利-Android增量编译3~5秒介绍了增量编译神器freeline的基本使用,这篇文章主要介绍freeline是如何实现快速增量编译的。

Android 编译打包流程

首先看一下android打包流程图,图片来源Android开发学习笔记(二)——编译和运行原理


Paste_Image.png
  • R文件的生成
    R文件记录了每个资源的ID,之后要参与到java的编译过程,R文件是由aapt(Android Asset Package Tool)生成。
  • java编译
    我们知道有时app开发中会跨进程通信,这时可以通过aidl的方式定义接口,aidl工具可以根据aidl文件生成对应的java文件。
    之后R文件、aidl相关java文件、src中的java文件通过编译生成 .class文件
  • dex生成
    编译后的.class会又由dex工具打包成dex文件,freeline中用到了Buck中提取的dex工具,freeline给出的数据是比原生的dex工具快了40%
  • 资源文件编译
    aapt(Android Asset Package Tool)工具对app中的资源文件进行打包。其流程如图(图片来源


    Paste_Image.png


    Android应用程序资源的编译和打包过程分析罗升阳老师的文章非常清晰地分析了应用资源的打包过程。

  • apk文件生成与签名
    apkbuild工具把编译后的资源文件和dex文件打包成为dex文件。jarsigner完成apk的签名,当然Android7.0之后可以通过apksigner工具进行签名。了解Android Studio 2.2中的APK打包中有介绍。

增量编译原理

Android增量编译分为代码增量和资源增量,资源增量是freeline的一个亮点,instant-run开启时其实在资源上并不是增量的,而是把整个应用的资源打成资源包,推送至手机的。

  • 代码增量

    谷歌在支持multidex之后,当方法数超过65535时,android打包后会存在多个dex文件,运行时加载类时,会从一个dexList依次查找,找到则返回,利用这个原理可以把增量的代码打包成dex文件,插入到dexList的前边,这样就可以完成类的替换
    这里有一个问题是在非art的手机上存在兼容性问题,这也是instant-run只支持android5.0以上的原因,freeline在这里使用之前安卓App热补丁动态修复技术介绍中提出的插桩方案做了兼容处理,这样在非art手机上也可以进行增量编译。
  • 资源增量

    资源增量是freeline的一个亮点,在第一部分我们知道是通过aapt工具对应用资源文件进行打包的,freeline开发了自己的incrementAapt工具(目前并没有开源)。我们知道aapt进行资源编译时,会生成R文件和resources.arsc文件,R文件是资源名称和资源id的一个对应表,用于java文件中对资源的引用,而resources.arsc文件描述了每个资源id对应的配置信息,也就是描述了如何根据一个资源id找到对应的资源。
    • pulbic.xml 和ids.xml文件
      aapt进行资源编译时,如果两次编译之间资源文件进行了增删操作,则编译出的R文件即使资源名称没有变化,资源id值却可能发生变化,这样如果进行资源增量编译,则app在进行资源引用时可能发生资源引用错乱的情况。因此第二次编译时最好根据第一次编译的结果进行,public.xml和ids.xml文件就是完成这件事情的,freeline开发了id-gen-tool利用第一次编译的R文件来生成public.xml 和ids.xml,用于第二次的编译。
    • 客户端的处理
      freeline 利用incrementAapt增量工具打包出增量的资源文件,然后客户端将文件放置在正确的位置,然后启动应用后,就可以正确访问应用资源了。

      Paste_Image.png

freeline实现分析

freeline 在实现上借鉴了buck,layoutCast的思想,把整个过程构建成多个任务,多任务并发,同时缓存各个阶段的生成文件,以达到快速构建的目的。

  • 多任务并发

    先来看一张图(图片来源


    Paste_Image.png


    freeline这里借鉴了buck的思想,如果工程中有多个module,freeline会建立好各个工程构建的任务依赖。在build过程中同时可能会有多个module在构建,之后在合适的时间把构建后的文件进行合并。

  • 缓存

    我们在debug时可能会进行多次代码修改,并运行程序看修改效果,也就是要进行多次的增量编译,freeline对每次对编译过程进行了缓存。比如我们进行了三次增量编译,freeline每次编译都是针对本次修改的文件,对比LayoutCast 和instant-run每次增量编译都是编译第一次全量编译之后的更改的文件,freeline速度快了很多,根据freeline官方给的数据,快了3~4倍,但是这样freeline进行增量编译时的复杂性增加了不少。
    另外freeline增量编译后可调试,这点相对于instant-run 和LayoutCast来说,优势很大。freeline官方介绍中提到的懒加载,个人认为只是锦上添花的作用,在实际中可能并没有太大作用。

    代码分析

    终于到了代码分析的环节,还是先贴一下freeline的github地址:freeline,我们看一下其源码有哪些内容


Paste_Image.png

android-studio-plugin是android中的freeline插件源码
databinding-cli顾名思义是对dababinding的支持
freeline_core是我们今天分析的重点
gradle 是对gradle中freeline配置的支持
release-tools中是编译过程中用到的工具,如aapt工具等
runtime是增量编译后客户端处理的逻辑
sample是给出的demo

如果想编译调试freeline增量编译的源码,可以先clone下freeline的源码,然后导入sample工程,注意sample中其实就包含了freeline_core的源码,我这里用的ide是Pycharm。

freeline对于android的编译分为两个过程:全量编译和增量编译,我们先来看全量编译。

  • 全量编译

    1. 代码入口

      代码入口当然是freeline.py,

      if sys.version_info > (3, 0):
         print 'Freeline only support Python 2.7+ now. Please use the correct version of Python for freeline.'
         exit()
      parser = get_parser()
      args = parser.parse_args()
      freeline = Freeline()
      freeline.call(args=args)

      首先判断是否是python2.7,freeline是基于python2.7的,然后对命令进行解析:

      parser.add_argument('-v', '--version', action='store_true', help='show version')
      parser.add_argument('-f', '--cleanBuild', action='store_true', help='force to execute a clean build')
      parser.add_argument('-w', '--wait', action='store_true', help='make application wait for debugger')
      parser.add_argument('-a', '--all', action='store_true',
                         help="together with '-f', freeline will force to clean build all projects.")
      parser.add_argument('-c', '--clean', action='store_true', help='clean cache directory and workspace')
      parser.add_argument('-d', '--debug', action='store_true', help='enable debug mode')
      parser.add_argument('-i', '--init', action='store_true', help='init freeline project')

      之后创建了Freeline对象

      def __init__(self):
         self.dispatcher = Dispatcher()
      
      def call(self, args=None):
         if 'init' in args and args.init:
             print('init freeline project...')
             init()
             exit()
      
         self.dispatcher.call_command(args)

      freeline中创建了dispatcher,从名字可以就可以看出是进行命令分发的,就是在dispatcher中执行不同的编译过程。在dispatcher执行call方法之前,init方法中执行了checkBeforeCleanBuild命令,完成了部分初始化任务。

    2. 关键模块说明

      dispatcher
      分发命令,根据freeline.py 中命令解析的结果执行不同的命令
      builder
      执行各种build命令

      Paste_Image.png

      这是其类继承图,可以看到最下边两个子类分别是gradleincbuilder和gradlecleanbuilder,分别用于增量编译和全量编译。
      command

      Paste_Image.png

      利用build执行命令,可以组织多个command,在创建command时传入builder,则可以执行不同的任务。
      task_engine
      task_engine定义了一个线程池,TaskEngine会根据task的依赖关系,多线程执行任务。
      task
      freeline中定义了多个task,分为完成不同的功能

      Paste_Image.png
      gradle_tools
      定义了一些公有的方法:

      Paste_Image.png
    3. 命令分发

      在代码入口出可以发现对命令进行了解析,之后在dispatcher中对解析结果进行命令分发:
         if 'cleanBuild' in args and args.cleanBuild:
             is_build_all_projects = args.all
             wait_for_debugger = args.wait
             self._setup_clean_build_command(is_build_all_projects, wait_for_debugger)
         elif 'version' in args and args.version:
             version()
         elif 'clean' in args and args.clean:
             self._command = CleanAllCacheCommand(self._config['build_cache_dir'])
         else:
             from freeline_build import FreelineBuildCommand
             self._command = FreelineBuildCommand(self._config, task_engine=self._task_engine)
      我们重点关注最后一行,在这里创建了FreelineBuildCommand,接下来在这里进行全量编译和增量编译。
    4. FreelineBuildCommand

      首先需要判断时增量编译还是全量编译,全量编译则执行CleanBuildCommand,增量编译则执行IncrementalBuildCommand

         if self._dispatch_policy.is_need_clean_build(self._config, file_changed_dict):
             self._setup_clean_builder(file_changed_dict)
             from build_commands import CleanBuildCommand
             self._build_command = CleanBuildCommand(self._builder)
         else:
             # only flush changed list when your project need a incremental build.
             Logger.debug('file changed list:')
             Logger.debug(file_changed_dict)
             self._setup_inc_builder(file_changed_dict)
             from build_commands import IncrementalBuildCommand
             self._build_command = IncrementalBuildCommand(self._builder)
      
         self._build_command.execute()

      我们看一下is_need_clean_build方法

      def is_need_clean_build(self, config, file_changed_dict):
         last_apk_build_time = file_changed_dict['build_info']['last_clean_build_time']
      
         if last_apk_build_time == 0:
             Logger.debug('final apk not found, need a clean build.')
             return True
      
         if file_changed_dict['build_info']['is_root_config_changed']:
             Logger.debug('find root build.gradle changed, need a clean build.')
             return True
      
         file_count = 0
         need_clean_build_projects = set()
      
         for dir_name, bundle_dict in file_changed_dict['projects'].iteritems():
             count = len(bundle_dict['src'])
             Logger.debug('find {} has {} java files modified.'.format(dir_name, count))
             file_count += count
      
             if len(bundle_dict['config']) > 0 or len(bundle_dict['manifest']) > 0:
                 need_clean_build_projects.add(dir_name)
                 Logger.debug('find {} has build.gradle or manifest file modified.'.format(dir_name))
      
         is_need_clean_build = file_count > 20 or len(need_clean_build_projects) > 0
      
         if is_need_clean_build:
             if file_count > 20:
                 Logger.debug(
                     'project has {}(>20) java files modified so that it need a clean build.'.format(file_count))
             else:
                 Logger.debug('project need a clean build.')
         else:
             Logger.debug('project just need a incremental build.')
      
         return is_need_clean_build

      freelined的策略如下,如果有策略需求,可以通过更改这部分的代码来实现。

      1.在git pull 或 一次性修改大量
      2.无法依赖增量实现的修改:修改AndroidManifest.xml,更改第三方jar引用,依赖编译期切面,注解或其他代码预处理插件实现的功能等。
      3.更换调试手机或同一调试手机安装了与开发环境不一致的安装包。

    5. CleanBuildCommand

         self.add_command(CheckBulidEnvironmentCommand(self._builder))
         self.add_command(FindDependenciesOfTasksCommand(self._builder))
         self.add_command(GenerateSortedBuildTasksCommand(self._builder))
         self.add_command(UpdateApkCreatedTimeCommand(self._builder))
         self.add_command(ExecuteCleanBuildCommand(self._builder))

      可以看到,全量编译时实际时执行了如上几条command,我们重点看一下GenerateSortedBuildTasksCommand,这里创建了多条存在依赖关系的task,在task_engine启动按照依赖关系执行,其它command类似。


      Paste_Image.png


      其依赖关系是通过childTask的关系进行确认,可参考gradle_clean_build模块中的generate_sorted_build_tasks方法:

         build_task.add_child_task(clean_all_cache_task)
         build_task.add_child_task(install_task)
         clean_all_cache_task.add_child_task(build_base_resource_task)
         clean_all_cache_task.add_child_task(generate_project_info_task)
         clean_all_cache_task.add_child_task(append_stat_task)
         clean_all_cache_task.add_child_task(generate_apt_file_stat_task)
         read_project_info_task.add_child_task(build_task)

      最后在ExecuteCleanBuildCommand中启动task_engine

      self._task_engine.add_root_task(self._root_task)
      self._task_engine.start()
  • 增量编译

    增量编译与全量编译之前的步骤相同,在FreelineBuildCommand中创建了IncrementalBuildCommand

    1. IncrementalBuildCommand

      self.add_command(CheckBulidEnvironmentCommand(self._builder))
      self.add_command(GenerateSortedBuildTasksCommand(self._builder))
      self.add_command(ExecuteIncrementalBuildCommand(self._builder))
      创建了三个command,我们重点看一下GenerateSortedBuildTasksCommand这里比全量编译更复杂一些。
    2. GenerateSortedBuildTasksCommand

      def generate_sorted_build_tasks(self):
         """
         sort build tasks according to the module's dependency
         :return: None
         """
         for module in self._all_modules:
             task = android_tools.AndroidIncrementalBuildTask(module, self.__setup_inc_command(module))
             self._tasks_dictionary[module] = task
      
         for module in self._all_modules:
             task = self._tasks_dictionary[module]
             for dep in self._module_dependencies[module]:
                 task.add_parent_task(self._tasks_dictionary[dep])

      可以看到首先遍历每个module创建AndroidIncrementalBuildTask,之后遍历mudle创建任务依赖关系。创建AndroidIncrementalBuildTask时传入了GradleCompileCommand

    3. GradleCompileCommand

      self.add_command(GradleIncJavacCommand(self._module, self._invoker))
      self.add_command(GradleIncDexCommand(self._module, self._invoker))

      查看一下GradleIncJavacCommand

         self._invoker.append_r_file()
         self._invoker.fill_classpaths()
         self._invoker.fill_extra_javac_args()
         self._invoker.clean_dex_cache()
         self._invoker.run_apt_only()
         self._invoker.run_javac_task()
         self._invoker.run_retrolambda()

      执行了以上几个函数,具体的内容可以查看源码。
      以下简单说一下task_engine时如何解决task的依赖关系,这里根据task中的 parent_task列表定义了每个task的depth:

      def calculate_task_depth(task):
         depth = []
         parent_task_queue = Queue.Queue()
         parent_task_queue.put(task)
         while not parent_task_queue.empty():
             parent_task = parent_task_queue.get()
      
             if parent_task.name not in depth:
                 depth.append(parent_task.name)
      
             for parent in parent_task.parent_tasks:
                 if parent.name not in depth:
                     parent_task_queue.put(parent)
      
         return len(depth)

      在具体执行时根据depth对task进行了排序

         depth_array.sort()
      
         for depth in depth_array:
             tasks = self.tasks_depth_dict[depth]
             for task in tasks:
                 self.debug("depth: {}, task: {}".format(depth, task))
                 self.sorted_tasks.append(task)
      
         self._logger.set_sorted_tasks(self.sorted_tasks)
      
         for task in self.sorted_tasks:
             self.pool.add_task(ExecutableTask(task, self))

      然后每个task执行时会判断parent是否执行完成

      while not self.task.is_all_parent_finished():   
         # self.debug('{} waiting...'.format(self.task.name))    
         self.task.wait()

      只有parent任务执行完成后,task才可以开始执行。

      总结

      本文从增量编译的原理和代码角度简单分析了freeline的实现,其中原理部分主要参考了中文原理说明,代码部分主要分析了大体框架,没有深入到每一个细节,如freeline如何支持apt、lambda等,可能之后会再继续写文分析。
      本人才疏学浅,如果有分析错误的地方,请指出。

参考

github.com/alibaba/fre…
yq.aliyun.com/articles/59…
www.cnblogs.com/Pickuper/ar…
blog.csdn.net/luoshengyan…