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

前篇福利-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命令,完成了部分初始化任务。

  1. 关键模块说明

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
  1. 命令分发

在代码入口出可以发现对命令进行了解析,之后在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,接下来在这里进行全量编译和增量编译。

  1. 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.更换调试手机或同一调试手机安装了与开发环境不一致的安装包。

  1. 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这里比全量编译更复杂一些。

  1. 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

  1. 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等,可能之后会再继续写文分析。
本人才疏学浅,如果有分析错误的地方,请指出。

参考

https://github.com/alibaba/freeline
https://yq.aliyun.com/articles/59122?spm=5176.8091938.0.0.1Bw3mU
http://www.cnblogs.com/Pickuper/archive/2011/06/14/2078969.html
http://blog.csdn.net/luoshengyang/article/details/8744683?spm=5176.100239.blogcont59122.10.pdZfgL

Other

欢迎关注公众号wutongke,每天推送移动开发前沿技术文章:

wutongke

推荐阅读:

神兵利器-Android性能调优工具Hugo

神兵利器-内存调试插件

炫酷的悬浮操作栏-谷歌出品

ViewPager倒计时播放

Android保存私密信息-强大的keyStore(译)

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

推荐阅读更多精彩内容

  • 参考:https://github.com/alibaba/freeline/blob/master/freeli...
    才兄说阅读 6,354评论 1 9
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,517评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,142评论 25 707
  • 说起我童年时候的那只老花猫,其实它到我家时,还只是幼儿的年纪。 邻居家的哥哥在外面玩,不知从哪里把它捉了回来。 那...
    kyran阅读 1,227评论 4 8
  • 真实的生活无法归于口头表达或书面写出的言语,谁都做不到,从来做不到。真实的生活开始于我们独处之时,独自思考、独自感...
    滟新阅读 443评论 9 1