安卓打包流程一篇文章就够了

作为一个安卓开发,如果你对安卓打包流程还不熟悉的话?不妨看看这篇文章。本文会带你深入了解安卓打包流程的各个细节。更重要的是,熟悉安卓打包流程会让你对apk瘦身、参数化构建、资源文件处理有更深的理解。

流程步骤

安卓apk打包的流程总的来说分为七个步骤,分别是:

  1. aapt打包资源文件、Manifest xml布局文件、资源索引文件R.java
  2. aidl处理aidl文件,将aidl文件编译成.java文件
  3. javac编译器将所有的.java代码编译成.class字节码
  4. dex工具将所有的.class字节码文件(包括第三方库)编译成.dex文件(DVM和ArtVM识别的字节码文件)
  5. 生成APK文件
  6. 签名。开发版签名(系统自带)和正式版签名(自主配置)
  7. 文件对齐(release版本开启)

谷歌官方曾给出过一张打包流程图:

从图中可以看出,整个流程包含上述的7个核心操作。我们平时在简单的执行run任务时,其实AndroidStudio默默的会为我们执行一遍打包流程(不考虑instantrun等)。

下面我们来针对每一步操作,进行深入的剖析。

第一步:AAPT打包资源文件

什么是aapt?

它是一款打包工具,安卓SDK目录下提供了它的可执行文件aapt,具体目录位置为:sdk/build-tools/26.0.3/aapt(请忽略sdk版本目录,在每个版本下面都会有),事实上高版本上会使用aapt2
我们使用它的命令行版本信息指令aapt version来看下它的详情:

Android Asset Packaging Tool, v0.2-4420879


当然也可以看下aapt2的信息,指令是:aapt2 version

Android Asset Packaging Tool (aapt) 2:19

通过输出信息可以知道,aapt也就是:安卓资源打包工具。

前面提到过,他是以可执行文件的形式提供的,那么我们接下来先看看它都有哪些指令。通过直接执行指令aapt就可以看到。主要包括:

指令全称 缩写 指令作用
list l 输出压缩文件的所有内容
dump d 打印对应资源文件或者apk的指定信息
package p 打包安卓资源,主要包括assets和resources
remove r 从压缩文件中删除指定文件
add a 向压缩文件中添加指定文件
crunch c 对多个目录下的PNG文件进行预处理并存储到输出目录
singleCrunch s 对单个PNG文件进行预处理
version v 打印aap的版本信息

这些指令中,在打包安卓应用时,主要会使用到package指令。我们下面来看看该指令的一些用法:

扫描二维码关注公众号,回复: 11117193 查看本文章
 aapt p[ackage] [-d][-f][-m][-u][-v][-x][-z][-M AndroidManifest.xml] \
        [-0 extension [-0 extension ...]] [-g tolerance] [-j jarfile] \
        [--debug-mode] [--min-sdk-version VAL] [--target-sdk-version VAL] \
        [--app-version VAL] [--app-version-name TEXT] [--custom-package VAL] \
        [--rename-manifest-package PACKAGE] \
        [--rename-instrumentation-target-package PACKAGE] \
        [--utf16] [--auto-add-overlay] \
        [--max-res-version VAL] \
        [-I base-package [-I base-package ...]] \
        [-A asset-source-dir]  [-G class-list-file] [-P public-definitions-file] \
        [-D main-dex-class-list-file] \
        [-S resource-sources [-S resource-sources ...]] \
        [-F apk-file] [-J R-file-dir] \
        [--product product1,product2,...] \
        [-c CONFIGS] [--preferred-density DENSITY] \
        [--split CONFIGS [--split CONFIGS]] \
        [--feature-of package [--feature-after package]] \
        [raw-files-dir [raw-files-dir] ...] \
        [--output-text-symbols DIR]
        

在命令行窗口中,对于该命令有一个简短的描述:

   Package the android resources.  It will read assets and resources that are
   supplied with the -M -A -S or raw-files-dir arguments.  The -J -P -F and -R
   options control which files are output.
   

该描述对于该命令的核心功能和核心参数都做了概括,打包命令支持很多参数,其中比较重要的几个在上述描述中已经说明。下面我们来看看这几个重要的参数的具体意义。

参数 说明
-f 覆盖现有的文件命令,加上后编译生成直接覆盖目前已经存在的R.java;
-m 使生成的包的目录放在-J参数指定的目录;
-v 详细输出,加上此命令会在控制台输出每一个资源文件信息,R.java生成后还有注释。
-M AndroidManifest.xml的路径
-I 指定的SDK版本中android.jar的路径
-A assert文件夹的路径
-P 指定的输出公共资源,可以指定一个文件 让资源ID输出到那上面
-D 在多dex构建模式下,主构建dex的设计的类文件列表
-S 指定资源目录 一般是 res
-F 指定把资源输出到 apk文件中
-J 指定R.java输出的路径
–product 指定想要选择的打包变体(product-flavor)
–min-sdk-versopm VAL 最小SDK版本 如是7以上 则默认编译资源的格式是 utf-8
–target-sdk-version VAL 在androidMainfest中的目标编译SDK版本
–app-version VAL 应用程序版本号
–app-version-name TEXT 应该程序版本名字
–custom-package VAL 生成R.java到一个不同的包
–rename-mainifest-package PACKAGE 修改APK包名的选项
raw-file-dir 附加打包进APK的文件

接下来,我们通过一个小例子来看下package命令的使用和输出。

在作者本地有一个安卓项目。项目结构为:

我们通过以下命令来打包资源文件

 aapt package -f -S /Users/lotty/android/lotty/component/src/main/res -M /Users/lotty/android/lotty/component/src/main/AndroidManifest.xml -A /Users/lotty/android/lotty/component/src/main/assets -I /Users/lotty/Library/Android/sdk/platforms/android-29/android.jar -m -J /Users/lotty/Desktop/out/ -v -F /Users/lotty/Desktop/out/test.apk

输出路径out下面有两个文件:

解压输出文件test.apk的详情为:

对应的R.java为:

package com.github.component;
public final class R {
    public static final class attr {
    }
    public static final class drawable {
        public static final int p1=0x7f020000;
        public static final int p2=0x7f02000b;
        public static final int ptr_header_down=0x7f020036;
        public static final int ptr_header_loading=0x7f020037;
        public static final int ptr_header_up=0x7f020038;
    }
    public static final class id {
        public static final int content=0x7f050000;
        public static final int ptr_header_content=0x7f050001;
        public static final int ptr_header_hint=0x7f050002;
    }
    public static final class layout {
        public static final int body_item_layout=0x7f030000;
        public static final int default_header_layout=0x7f030001;
    }
    public static final class string {
        public static final int app_name=0x7f040000;
    }
}


可以看到,应用内自定义的资源几乎都是以0x7f开头的。这是由系统在生成R文件的策略决定的,这种表示方法的分为三层,具体表示为:

第一个字节表示packageID7f表明是当前应用程序的资源,系统的资源是以01开头

第二个字节表示TypeId:资源的类型animator、anim、color、drawable、layout、menu、raw、string和xml等等若干种,每一种都会被赋予一个ID

最后四个字节是编号:表示每一个资源在其对应的TypID中出现的顺序

当然,感兴趣的同学可以看下aapt的源码,它是C++实现的,入口是/frameworks/base/tools/aapt/Main.cpp
事实上,该压缩文件中除了没有.java代码,就是完整的安卓工程。并且输出了一个resources.arsc文件以及项目的R.java文件。这两个文件也是参与到APK构建流程的不可或缺的组成部分。

第二步:AIDL转化成Java文件

什么是AIDL?

AIDL是Android Interface Definition Language的缩写,即Android接口定义语言。它是Android的进程间通信比较常用的一种方式。

当然,我们这里主要探讨aidl文件转化为java文件的过程。
在打包过程中,编译器是通过AIDL工具将.aidl文件转化为对应的.java文件的。我们看下该工具支持的命令:

usage: aidl OPTIONS INPUT [OUTPUT]
       aidl --preprocess OUTPUT INPUT...

OPTIONS:
   -I<DIR>    search path for import statements.
   -d<FILE>   generate dependency file.
   -a         generate dependency file next to the output file with the name based on the input file.
   -ninja     generate dependency file in a format ninja understands.
   -p<FILE>   file created by --preprocess to import.
   -o<FOLDER> base output folder for generated files.
   -b         fail when trying to compile a parcelable.

INPUT:
   An aidl interface file.

OUTPUT:
   The generated interface files.
   If omitted and the -o option is not used, the input filename is used, with the .aidl extension changed to a .java extension.
   If the -o option is used, the generated files will be placed in the base output folder, under their package folder

可以看到主要的一个指令是输入文件,也就是要转化的.aidl文件,如果不指定输出文件状态时,就是简单的将aidl扩展名替换为.java扩展名。接下里,我们来用一个简单的aidl文件测试下。
在我们的项目下,我们新建了aidl文件:

// TestAidl.aidl
package com.github.lotty.demo;

// Declare any non-default types here with import statements

interface TestAidl {
    /**
     * Demonstrates some basic types that you can use as parameters
     * and return values in AIDL.
     */
    void basicTypes(int anInt, long aLong, boolean aBoolean, float aFloat,
            double aDouble, String aString);
}

我们通过下面的命令来转化

这里有一点需要注意的是,aidl文件必须是在其声明的包路径下才可以进行转换操作,否则会因为包路径的识别而抛出如下异常,当然,如果aidl文件格式不正确,同样会抛出异常。

TestAidl.aidl:6 interface TestAidl should be declared in a file called com/github/lotty/demo/TestAidl.aidl.

我们看下转换后的java文件内容(比较长哦~)

/*
 * This file is auto-generated.  DO NOT MODIFY.
 * Original file: TestAidl.aidl
 */
package com.github.lotty.demo;
// Declare any non-default types here with import statements

public interface TestAidl extends android.os.IInterface {
  /**
   * Demonstrates some basic types that you can use as parameters
   * and return values in AIDL.
   */
  public void basicTypes(int anInt, long aLong, boolean aBoolean, float aFloat, double aDouble,
      java.lang.String aString) throws android.os.RemoteException;

  /** Local-side IPC implementation stub class. */
  public static abstract class Stub extends android.os.Binder
      implements com.github.lotty.demo.TestAidl {
    static final int TRANSACTION_basicTypes = (android.os.IBinder.FIRST_CALL_TRANSACTION + 0);
    private static final java.lang.String DESCRIPTOR = "com.github.lotty.demo.TestAidl";

    /** Construct the stub at attach it to the interface. */
    public Stub() {
      this.attachInterface(this, DESCRIPTOR);
    }

    public static com.github.lotty.demo.TestAidl asInterface(android.os.IBinder obj) {
      if ((obj == null)) {
        return null;
      }
      android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
      if (((iin != null) && (iin instanceof com.github.lotty.demo.TestAidl))) {
        return ((com.github.lotty.demo.TestAidl) iin);
      }
      return new com.github.lotty.demo.TestAidl.Stub.Proxy(obj);
    }

    @Override public android.os.IBinder asBinder() {
      return this;
    }

    @Override
    public boolean onTransact(int code, android.os.Parcel data, android.os.Parcel reply, int flags)
        throws android.os.RemoteException {
        // 省略....
      }
      return super.onTransact(code, data, reply, flags);
    }

    private static class Proxy implements com.github.lotty.demo.TestAidl {
      private android.os.IBinder mRemote;

      Proxy(android.os.IBinder remote) {
        mRemote = remote;
      }

      @Override public android.os.IBinder asBinder() {
        return mRemote;
      }

      public java.lang.String getInterfaceDescriptor() {
        return DESCRIPTOR;
      }

      @Override public void basicTypes(int anInt, long aLong, boolean aBoolean, float aFloat,
          double aDouble, java.lang.String aString) throws android.os.RemoteException {
		// 省略....
      }
    }
  }
}

可以看到,生成的java文件跟我们平时在开发工程中由IDE在构建过程中生成的是一致的,具备了stub以及Proxy代理类。

可以看出aidl文件的转换流程相对来说,比较简单。

第三步:javac编译所有的java文件到字节码文件

通过javac将java文件编译成class文件是jdk提供的功能。java文件是开发者编写项目时的文件格式,是一种可读的文件格式,而字节码文件是java文件被编译后的产物,他是一种紧凑的16进制字符表示的文件。

在命令行模式下,它的命令也很简单:

javac <options> <source files>

在JDK中,其入口类文件为:com.sun.tools.javac.main.JavaCompiler,javac的大致流程可以参照下图:

用流程来描述的话,可以分为8个小步骤。

预处理

主要对注解进行配置和准备,涉及到的方法为:initProcessAnnotation

词法分析

主要是将java的源文件分解为一个一个的token,分析的主要实现(包括词法分析和语法分析)在JavaCompiler中的parse方法中,而词法分析是由com.sun.tools.javac.parser.Scanner的实现来完成的

语法分析

根据词法分析生成的token流以及程序的语法结构生成一颗抽象的语法树。语法分析的实现主要是由com.sun.tools.javac.parser.Parser来实现的

填充符号表

一组符号地址和符号信息构成的表格,主要用于语义分析阶段,由com.sun.tools.javac.comp.Enter来实现的

标注检查

是语义分析前的预检查步骤,主要是检查变量是否声明,数据类型是否匹配,常量折叠等。由com.sun.tools.javac.comp.AttrCheck实现

控制流分析

主要是检查是不是方法的每条路径都有返回值,是不是所有受检查异常都被处理了等等。由com.sun.tools.javac.comp.Flow实现

解语法糖

主要目的是将java中的语法糖还原,比如自动封拆箱,泛型,变长参数,for循环等等。由com.sun.tools.javac.comp.TransTypesLower实现

字节码生成

将前面所有步骤生成的信息写入class文件中,还包括对class的一些修改和优化。由com.sun.tools.javac.jvm.Gen来实现

生成的字节码是一种格式固定并且演进的文件,其格式标准可以参照下图,其中u1、u2、u4、u8 分别代表1个字节、2个字节、4个字节和8个字节的无符号数

其中前8个字节是程序无关项,表示固定的魔数、java语言最大版本和最小版本,后面按照常量、访问控制标记、类层次信息、接口信息、成员变量信息、方法信息、属性信息的顺序构成。

关于字节码文件的详细分析,有兴趣的同学可以自行研究,目前很多字节码注入技术就是利用了字节码这种严苛的数据格式来实现的。

第四步:编译所有的字节码文件到dex文件

这一步是通过dx工具来实现的。MacOS系统中它位于Library/Android/sdk/build-tools/具体版本/路径下。
字节码文件时JVM可以识别的一种文件格式,但是安卓虚拟机(DVM或者ART)支持文件格式是dex文件,所以最终打到APK中的源码文件是dex文件,dex文件的设计思想跟字节码文件设计思想类似。

dex文件也是一种格式严格的紧凑型文件,它的定义在/dalvik/libdex/DexFile.h中,其定义为:

struct DexFile {
    const DexHeader*    pHeader;
    const DexStringId*  pStringIds;
    const DexTypeId*    pTypeIds;
    const DexFieldId*   pFieldIds;
    const DexMethodId*  pMethodIds;
    const DexProtoId*   pProtoIds;
    const DexClassDef*  pClassDefs;
    const DexLink*      pLinkData;
}

也即是说,dex是有头部信心、字符信息、类型信息、成员信息、方法信息、协议信息、类定义、连接数据组成的,其中头部信息同样包含魔数、校验和、签名、其他字段的长度和偏移量等。每个字节码文件都是一个单独的个体,但是dex文件会把所有参与打包的字节码文件进行对应类型数据的合并处理,举个例子,他会把所有字节码文件对于常量字符的信息都放到一起。

从字节码文件到dex文件,dx一般会对字节码文件进行重构和压缩等优化性测试。

我们来看下它的命令行支持

命令 说明
–dex 打包dex文件
–annottool 注解
–dump 分析dex文件(反编译)
–find-usages 查找引用和声明
-J 传递虚拟机参数
–version 打印版本信息

其中dex指令是用来打包dex文件的。它支持的参数比较多,涉及到优化选项、严苛模式、混淆、SDK版本、多DEX等。

官方对其主要功能描述为

    Convert a set of classfiles into a dex file, optionally embedded in a
    jar/zip. Output name must end with one of: .dex .jar .zip .apk or be a
    directory.

经过dx的处理,参与打包的所有字节码文件会打包成一个.dex文件。

第五步:生成APK文件

在经过前四部的处理后,apkbuilder会将之前生成的dex文件、所有的资源文件(编译过的以及没有编译过的)、Maniffest文件打包到最终的apk文件中。

需要指出的是apkbuilder这个工具在新版本中已经被移除了,不过影响不大,因为它的入口类为android-sdk/tools/lib/sdklib.jar中的com.android.sdklib.build.ApkbuilderMain,在这里面又调用了com.android.sdklib.build.Apkbuilder。我们简单看下这两个类的功能。

	//ApkbuilderMain
      File outApk = new File(args[0]);
      File dexFile = null;
      ArrayList<File> zipArchives = new ArrayList();
      ArrayList<File> sourceFolders = new ArrayList();
      ArrayList<File> jarFiles = new ArrayList();
      ArrayList<File> nativeFolders = new ArrayList();

在入口类中,首先创建了需要输出的apk文件,以及用来存储输入文件的文件列表对象,输入文件主要包括:被压缩的资源文件、未被压缩的资源文件、字节码文件、native的so文件。

处理完上述这些文件后,会创建ApkBuilder对象来对所有文件进行打包处理。

ApkBuilder builder = new ApkBuilder(outApk, (File)zipArchives.get(0), dexFile, signed ? ApkBuilder.getDebugKeystore() : null, verbose ? System.out : null);

接下来遍历输入文件,并添加到ApkBuilder中。

      while(true) {
        while(i$.hasNext()) {
          jarFile = (File)i$.next();
          if (jarFile.isDirectory()) {
            String[] filenames = jarFile.list(new FilenameFilter() {
              public boolean accept(File dir, String name) {
                return ApkBuilderMain.PATTERN_JAR_EXT.matcher(name).matches();
              }
            });
            String[] arr$ = filenames;
            int len$ = filenames.length;

            for(int i$ = 0; i$ < len$; ++i$) {
              String filename = arr$[i$];
              builder.addResourcesFromJar(new File(jarFile, filename));
            }
          } else {
            builder.addResourcesFromJar(jarFile);
          }
        }

        i$ = nativeFolders.iterator();

        while(i$.hasNext()) {
          jarFile = (File)i$.next();
          builder.addNativeLibraries(jarFile);
        }

        builder.sealApk();
        break;
      }
      

经过这些处理之后,输出的outApk文件就已经被填充了参与打包的所有文件。

第六步:签名

签名流程是通过apksigner来实现的,在开发过程中,我们可能没有配置debug的签名信息,是因为AS默认有一个debug的签名。
安卓中,有两种签名方式,一种是jar签名,一种是apk签名(v2签名)

jar签名是对apk内所有的文件依次进行摘要签名,当资源较多时,比较耗时

apk签名是对apk整体文件进行签名,速度较快

我们来看下apksigner工具的支持的命令

EXAMPLE:
       apksigner sign --ks release.jks app.apk
       apksigner verify --verbose app.apk

apksigner is a tool for signing Android APK files and for checking whether
signatures of APK files will verify on Android devices.

        COMMANDS

sign                  Sign the provided APK

verify                Check whether the provided APK is expected to verify on Android

可以看到,整个使用过程比较简单,只支持签名和校验两个功能。签名时提供秘钥和apk文件即可。
签名后,apk文件就相当于具备了一种身份标识。这种身份标识在OS中会被用来校验权限、文件合法性检测等。

第七步:文件对齐

文件对齐是针对签名后的apk文件来说的,是通过zipalign工具来实现的,主要是对齐apk文件中所有资源文件基于起始地址的偏移量,降低应用运行过程中的内存消耗。

我们先看下zipalign工具的命令行支持

Usage: zipalign [-f] [-p] [-v] [-z] <align> infile.zip outfile.zip
       zipalign -c [-p] [-v] <align> infile.zip

  <align>: alignment in bytes, e.g. '4' provides 32-bit alignment
  -c: check alignment only (does not modify file)
  -f: overwrite existing outfile.zip
  -p: memory page alignment for stored shared object files
  -v: verbose output
  -z: recompress using Zopfli
  

使用也很简单,指定输入输出文件即可。需要注意的是对齐的字节数是可以配置的。默认情况下是4字节,也就是说调整对应文件基于起始文件地址的偏移量为4字节的整数倍,这样可以提高系统在检索文件时的效率。

在对apk文件进行对齐处理后,整个打包流程就结束了,也就输出了我们的最终的apk文件。

总结

android打包流程是可以通过gralde构建系统来查看并研究的。整个打包流程其实并不复杂,每一步都可以在AOP源码中找到对应的实现,其中有一部分是C实现的,需要一定的C语言阅读能力,不过这对我们研究整个流程来说,影响不大。在分析过程中,会涉及到字节码个dex文件格式的问题,在这里,我们可以利用字节码注入工具(建议ASM)来对辅助学习。最后,如果遇到问题,请在源码中寻找答案。

发布了46 篇原创文章 · 获赞 21 · 访问量 7065

猜你喜欢

转载自blog.csdn.net/lotty_wh/article/details/105736833