Android增量編譯3~5秒的背後

wutongke發表於2016-12-10

前篇福利-Android增量編譯3~5秒介紹了增量編譯神器freeline的基本使用,這篇文章主要介紹freeline是如何實現快速增量編譯的。

Android 編譯打包流程

首先看一下android打包流程圖,圖片來源Android開發學習筆記(二)——編譯和執行原理

Android增量編譯3~5秒的背後
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中的資原始檔進行打包。其流程如圖(圖片來源

    Android增量編譯3~5秒的背後
    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增量工具打包出增量的資原始檔,然後客戶端將檔案放置在正確的位置,然後啟動應用後,就可以正確訪問應用資源了。
      Android增量編譯3~5秒的背後
      Paste_Image.png

freeline實現分析

freeline 在實現上借鑑了buck,layoutCast的思想,把整個過程構建成多個任務,多工併發,同時快取各個階段的生成檔案,以達到快速構建的目的。

  • 多工併發

    先來看一張圖(圖片來源

    Android增量編譯3~5秒的背後
    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,我們看一下其原始碼有哪些內容

Android增量編譯3~5秒的背後
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命令
      Android增量編譯3~5秒的背後
      Paste_Image.png

      這是其類繼承圖,可以看到最下邊兩個子類分別是gradleincbuilder和gradlecleanbuilder,分別用於增量編譯和全量編譯。
      #####command
      Android增量編譯3~5秒的背後
      Paste_Image.png

      利用build執行命令,可以組織多個command,在建立command時傳入builder,則可以執行不同的任務。
      #####task_engine
      task_engine定義了一個執行緒池,TaskEngine會根據task的依賴關係,多執行緒執行任務。
      #####task
      freeline中定義了多個task,分為完成不同的功能
      Android增量編譯3~5秒的背後
      Paste_Image.png

      #####gradle_tools
      定義了一些公有的方法:
      Android增量編譯3~5秒的背後
      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類似。

      Android增量編譯3~5秒的背後
      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…

相關文章