1.Android签名作用和原理

2.多渠道打包原理

3.APK Signature Scheme v2介绍

签名的作用和原理

签名的作用

Android App在开发时都有一个唯一的标识,我们把它叫做包名,包名就像身份证号一样,是和每一个人一一对应的,在把App安装时机器也是通过包名来识别应用的,一个机器中不能存在两个包名一样的应用,这就是App在机器中的唯一性。我们在给App升级的时候,比如覆盖安装,就是通过包名来进行识别和对应的覆盖,这样才能保证App可以顺利升级而不会覆盖了其他的App,同样,别的App包名不同也无法覆盖我们的App。虽然Android提供了一套包名识别机制,但仅有包名就可以了吗?试想一下,如果别人用我们的包名新建一个App想要覆盖我们的App亦或是不法分子破解我们的App往里面添加一些自己的内容,比如内嵌广告来牟利,把篡改过的App再发出去让用户覆盖安装,我们可能会受到巨大的损失。当然这种事情是不会发生的
,Google为每一个App增加了一个签名机制,App正是通过这种机制保证了自身的唯一性和安全性。那么,签名是如何做到这些的呢?

签名的原理

对Apk签名有好几种工具,但原理都是大同小异的。在使用签名工具之前我们必须准备好签名要用的私钥和公钥,然后再用签名工具对apk签名,之后签名流程会在apk里新建META-INF文件夹并在里面生成三个文件,这三个文件就是签名的关键和验证的依据。

从图中可以看到三个文件分别是:MANIFEST.MF
CERT.RSA
CERT.SF
接下来说说这三个文件是怎么生成的。

1.MANIFEST.MF

先看看它的内容,可以看到是一些Name对应的SHA1的值,很容易就能知道这个文件保存的是每一个文件对应的一个唯一的值。MANIIFEST.MF文件的功能就如刚才说的一样,在对APK签名的时候,首先会对每一个文件内容做一次SHA1算法,就是计算出文件的摘要信息,然后用Base64进行编码,把这些内容都保存在一个新建的MANIFEST.MF文件中,并把这个文件放到META-INF目录下,就完成了第一个签名文件的生成。下面我们用工具来做个案例看看是不是这样。我们以AndroidManifest.xml文件为例,首先利用软件计算出SHA1值,然后再用Base64进行编码(在线计算Base64的网站),我们发现结果完全一致。

2.CERT.SF

在生成了一个MANIFEST.MF文件之后,就能记录下我们每一个文件的唯一值,从而保证文件不被篡改,这样虽然保证了MANIFEST.MF中记录文件的安全,但却无法保证自身的安全,别人照样可以修改原有文件之后生成对应的SHA1值然后再修改MANIFEST文件,所以我们还要对MANIFEST进行加固,从而保证安全性。在进行完第一个文件生成后,签名工具开始生成第二个文件了,这个文件就是CERT.SF。

先来看一下文件内容,这里的内容感觉和MANIFEST.MF的内容差不多,其实它主要做了两件事:

  1. 计算这个MANIFEST.MF文件的整体SHA1值,再经过BASE64编码后,记录在CERT.SF主属性块(在文件头上)的“SHA1-Digest-Manifest”属性值值下。
  2. 逐条计算MANIFEST.MF文件中每一个块的SHA1,并经过BASE64编码后,记录在CERT.SF中的同名块中,属性的名字是“SHA1-Digest”。

具体的相关源码大家可以参考下文提供的方式寻找。

注:1.利用新的Android Stuido(2.2以后)打包生成的CERT.SF文件中,已经不再做这样的处理了,如图所示。但是为什么依然可以兼容之前的版本呢?下文见。
3.CERT.RSA

这个文件都是二进制的,生成完CERT.SF文件之后,我们用私钥对CERT.SF进行加密计算得出签名,将得到的签名和公钥的数字证书的信息一起保存到CERT.RSA中,整个签名过程就结束了。

校验

下面再简单叙述一下Apk安装过程中的验证步骤,其实就是和生成签名文件的步骤类似:

  1. 使用证书文件(在META-INF目录下,以.DSA、.RSA或者.EC结尾的文件)检验签名文件(在META-INF目录下,和证书文件同名,但扩展名为.SF的文件)是否被修改过的。
  2. 使用签名文件CERT.SF,检验MANIFEST.MF文件中的内容是否被被篡改过。
  3. 查保证Apk文件中包含的所有文件,对应的摘要值是否与MANIFEST.MF文件中记录的一致(如果一个文件存在于Apk中,但是并未在MANIFEST.MF文件中列出,验证失败)。
  4. 校验当前安装Apk文件的签名是否和已经安装的同包名应用签名一致。

综上所述:

  • 如果你改变了apk包中的任何文件,那么在apk安装校验时,改变后的文件摘要信息与MANIFEST.MF的检验信息不同,于是验证失败,程序就不能成功安装。
  • 其次,如果你对更改的过的文件相应的算出新的摘要值,然后更改MANIFEST.MF文件里面对应的属性值,那么必定与CERT.SF文件中算出的摘要值不一样,照样验证失败。
  • 如果你还不死心,继续计算MANIFEST.MF的摘要值,相应的更改CERT.SF里面的值,那么数字签名值必定与CERT.RSA文件中记录的不一样,还是失败。
  • 那么能不能继续伪造数字签名呢?不可能,因为没有数字证书对应的私钥。

所以,从上面的分析可以得出,只要修改了Apk中的任何内容,就必须重新签名,不然会提示安装失败。如果重新签名那么必然会和已经安装在手机上的相同包名的应用的签名不一致,因此无法安装。

所以,签名的作用可以总结为签名只能保证别人在不修改应用签名的前提下篡改我们应用的资源文件,代码等,并不能保证一个未安装过正版应用的用户使用的应用一定是正版的。

注:

  1. 在校验的第二步中,如果MANIFEST.MF文件的摘要和CERT.SF中SHA1-Digest-Manifest保存的摘要相同,则不需要将每个条目的摘要信息和MANIFEST.MF文件中相同Name的摘要信息进行哈希值计算并编码比较。这也是为什么AS2.2之后两个文件的“SHA1-Digest”相同,但是可以通过验证的原因。(个人猜测之所以放弃计算MANIFEST.MF中每个条目的摘要并保存在CERT.SF中,可能因为AS2.2之后默认同时进行两种签名,为了兼顾效率因此放弃计算。如了解相关知识,欢迎告知,谢谢!)
  2. 相关签名校验源码可以参考尼古拉斯_赵四的相关博客或直接查看相关源码。

多渠道打包方式

根据以上签名方式可以发现v1签名存在的问题。

  1. 从来就没有对META-INF里的任何文件进行数字编码和加密,因为这个文件夹是签名时生成的。在生成第一个文件MANIFEST.MF时并没有对这个文件夹里的任何文件进行数字编码,因为这个文件夹一定是空的,第二个文件是基于第一个文件生成的,第三个文件又是基于第二个生成的,所以整个过程中,这个META-INF文件夹几乎是在控制范围之外的。我们可以在里面通过添加一些空文件从而躲过签名这个过程,同时利用这个空文件的名字标识渠道号等信息。
  2. 签名只针对解压后的所有文件,校验也是针对解压后的文件做校验,并未对APK压缩文件做任何的校验,因此可以通过在压缩文件最后添加注释的方式保存渠道号。Packer-ng使用的便是这种方式。我们可以将Apk文件修改为zip文件查看注释信息。

v2签名

APK签名方案v2是一个全文件签名方案,通过检测APK的受保护部分的任何更改,提高验证速度和加强完整性保证。

使用APK签名方案v2 签名会在APK中心目录部分之前的APK文件中插入一个APK签名块。在APK签名块中,v2签名和签名者身份信息存储在APK签名方案v2块中。

image

新的签名方式是根据zip文件结构来进行签名的,Apk文件本质上就是一个压缩文件,如图所示,在被签名前可以分为三块内容。

  • 压缩文件实体数据 包含所有的元素具体数据
  • 核心目录数据 包含所有元数据的相对偏移量
  • 目录结束标志 包含目录数据的偏移位置和相关目录详情记录

而签名之后增加了一个APK签名块,该签名块是根据未签名的ZIP文件三个模块的所有内容进行编码签名后产生的一个唯一的数据,这三块任何内容发生改变后所产生的这块的数据都是不一致的,所以在签名之后无法对Apk的任何内容进行修改。

以下内容是利用软件翻译的官方文档,如需查看原文请移步官方文档


APK签名块

为了保持与当前APK格式的向后兼容性,v2和新的APK签名方式存储在APK签名块中,该签名块是用于支持v2签名方案的新容器。在APK文件中,APK签名块位于ZIP中心目录之前,该目录位于文件末尾。

该块包含一个更容易在APK中定位块的方式的ID键值对。APK的v2签名存储在ID为0x7109871a的键值对中。

格式

APK签名块的格式如下(所有数字字段都是小字节序):

  • size of block 以字节为单位(不包括此字段)(uint64)

    • uint64长度前缀的ID值对的序列:
    • ID (uint32)
  • value (可变长度:对的长度为4字节)
  • size of block 以字节为单位 - 与第一个字段相同(uint64)
  • magic “APK Sig Block 42”(16字节)

通过首先查找ZIP中心目录的开始(通过在文件末尾找到中央目录的ZIP结束记录,然后从记录中读取中心目录的起始偏移)来解析APK。该 magic值提供了一种快速方法来确定中心目录之前可能是APK签名块。size of block然后该值有效地指向文件中块的开始。

在解释块时,应忽略具有未知ID的ID值对。

v2签名块

APK由一个或多个签名者/身份签名,每个由签名密钥表示。此信息存储为APK签名计划v2块。对于每个签名者,存储以下信息:

  • (签名算法,摘要,签名)元组。存储摘要以将签名验证与APK内容的完整性检查去耦合。
  • X.509证书链代表签名者的身份。
  • 其他属性作为键值对。

对于每个签名者,使用提供的列表中支持的签名来验证APK。忽略具有未知签名算法的签名。当遇到多个支持的签名时,由每个实现来选择使用哪个签名。这使得能够以向后兼容的方式在将来引入更强的签名方法。建议的方法是验证最强的签名。

格式

APK签名方案v2块存储在ID签名的APK签名块内 0x7109871a。

APK签名方案v2块的格式如下(所有数值都是小端字节,所有长度前缀字段使用uint32作为长度):

  • 长度前缀序列signer:

    • 长度前缀signed data:

      • 长度前缀序列digests:

        • signature algorithm ID (uint32)
        • (长度前缀)digest- 请参阅 完整性保护的内容
      • X.509的长度前缀序列certificates:

        • 长度前缀X.509 certificate(ASN.1 DER形式)
      • 长度前缀序列additional attributes:

        • ID (uint32)
        • value (可变长度:附加属性的长度 - 4字节)
    • 长度前缀序列signatures:

       + signature algorithm ID (uint32)
       + 长度前缀signature过signed data
    • 长度前缀public key(SubjectPublicKeyInfo,ASN.1 DER形式)

签名算法ID

  • 0x0101-RSASSA-PSS with SHA2-256 digest,SHA2-256 MGF1,32 bytes of salt,trailer:0xbc
  • 0x0102-RSASSA-PSS with SHA2-512 digest,SHA2-512 MGF1,64 bytes of salt,trailer:0xbc
  • 0x0103-RSASSA-PKCS1-v1_5与SHA2-256摘要。这是为了构建需要确定性签名的系统。
  • 0x0104-RSASSA-PKCS1-v1_5与SHA2-512摘要。这是为了构建需要确定性签名的系统。
  • 0x0201-ECDSA与SHA2-256摘要
  • 0x0202-ECDSA与SHA2-512摘要
  • 0x0301-DSA与SHA2-256摘要

所有上述签名算法都是由Android平台支持的。签名工具可以支持算法的子集。

完整性保护的内容

为了保护APK内容,APK由四个部分组成:

  1. ZIP条目的内容(从偏移0到APK签名块的开始)
  2. APK签名块
  3. ZIP中心目录
  4. 中央目录结束标志

图2.签名后的APK部分

APK Signature Scheme v2保护第1,3,4节以及 signed data第2节中包含的APK签名方案v2块的完整性。

部分1,3和4的完整性由存储在signed data块中的其内容的一个或多个摘要来保护,块又被一个或多个签名保护。

部分1,3和4的摘要如下计算,类似于两级Merkle树。每个部分被分割成连续的1MB(2 20字节)块。每个部分中的最后一个块可以更短。每个块的摘要是在字节0xa5,块的字节长度(little-endian uint32)和块的内容的连接上计算的。顶层摘要是根据字节0x5a,块的数量(little-endian uint32)以及块中摘要的级联来计算的,以块中的大小顺序显示在APK中。以分块方式计算摘要,以便能够通过并行化来加速计算。

图3. APK摘要

第4节(中央目录的ZIP结束)的保护因包含ZIP中心目录的偏移的部分而变得复杂。当APK签名块的大小更改时(例如,添加新签名时),该偏移量会更改。因此,当计算中央目录的ZIP结束时的摘要时,包含ZIP中心目录的偏移的字段必须被视为包含APK签名块的偏移量。

回滚保护

攻击者可能会尝试在支持验证v2签名APK的Android平台上将v2签名的APK验证为v1签名的APK。为了减轻这种攻击,v2签名的APK也是v1签名的APK必须在他们的META-INF/ .SF文件的主要部分包含一个X-Android-APK-Signed属性。属性的值是以逗号分隔的一组APK签名方案ID(此方案的ID为2)。在验证v1签名时,需要APK验证者拒绝不具有验证者偏好于此集合的APK签名方案(例如,v2方案)的签名的APK。该保护依赖于内容META-INF/ .SF文件由v1签名保护的事实。请参阅有关JAR签名APK验证的部分 。

攻击者可以尝试从APK签名方案v2块中剥离更强的签名。为了减轻这种攻击,将被签名的签名算法ID的列表存储在signed data 由每个签名保护的块中。

验证

在Android 7.0中,APK可以根据APK签名计划v2(v2计划)或JAR签名(v1计划)进行验证。较旧的平台忽略v2签名,只验证v1签名。

图4. APK签名验证过程(新步骤为红色)

APK签名计划v2验证

  1. 找到APK签名块,并验证:

    1. APK Signing Block的两个大小字段包含相同的值。
    2. ZIP中心目录后紧跟着中央目录记录的ZIP结束。
    3. 中央目录的ZIP End后面没有更多数据。
  2. 在APK签名块中找到第一个APK签名计划v2块。如果存在v2块,则继续执行步骤3.否则,回退到使用v1方案验证APK 。
  3. 对于signerAPK Signature Scheme v2 Block中的每个:

    1. 选择最强的支持signature algorithm ID从 signatures。强度排序取决于每个实现/平台版本。
    2. 验证相应的signature从 signatures反对signed data使用public key。(现在可以安全地解析signed data。)
    3. 验证签名算法ID在digests和signatures中的有序列表 是否相同。(这是为了防止签名剥离/添加。)
    4. 使用与签名算法所使用的摘要算法相同的摘要算法来计算APK内容的摘要。
    5. 验证所计算的摘要是等同于相应 digest从digests。
    6. 验证第一个certificate的 certificatesSubjectPublicKeyInfo是否相同public key。
  4. 如果signer找到至少一个并且每个找到的步骤3 成功,则验证成功signer。

    注意:如果第3步或第4步中发生失败,则不得使用v1方案验证APK。

ARouter是阿里巴巴开源的Android平台中对页面、服务提供路由功能的中间件,提倡的是简单且够用。

基本用法


1.添加依赖配置
android {
    defaultConfig {
    ...
    //此处每一个Module都需要配置,具体原因见下文
    javaCompileOptions {
        annotationProcessorOptions {
        arguments = [ moduleName : project.getName() ]
        }
    }
    }
}

dependencies {
    // 替换成最新版本, 需要注意的是api
    // 要与compiler匹配使用,均使用最新版可以保证兼容
    compile 'com.alibaba:arouter-api:x.x.x'
    annotationProcessor 'com.alibaba:arouter-compiler:x.x.x'
    ...
}
2.添加注解
// 在支持路由的页面上添加注解(必选)
// 这里的路径需要注意的是至少需要有两级,/xx/xx。具体原因见下文
@Route(path = "/test/activity")
public class YourActivity extend Activity {
    ...
}
3.初始化SDK
if (isDebug()) {
    ARouter.openLog();     // 打印日志
    ARouter.openDebug();   // 开启调试模式(如果在InstantRun模式下运行,必须开启调试模式!线上版本需要关闭,否则有安全风险)
}
ARouter.init(mApplication); // 尽可能早,推荐在Application中初始化
4.发起路由操作
// 1. 应用内简单的跳转(通过URL跳转在'进阶用法'中)
ARouter.getInstance().build("/test/activity").navigation();

// 2. 跳转并携带参数
ARouter.getInstance().build("/test/1")
        .withLong("key1", 666L)
        .withString("key3", "888")
        .withObject("key4", new Test("Jack", "Rose"))
        .navigation();
5.添加混淆
-keep public class com.alibaba.android.arouter.routes.**{*;}
-keep class * implements com.alibaba.android.arouter.facade.template.ISyringe{*;}

进阶用法


1.通过URL跳转
2.解析URL中的参数
3.声明拦截器(拦截跳转过程,面向切面变成)
4.处理跳转结果
5.为目标也声明更多信息
6.自定义全局降级策略

原理简析


1.编译期

首先我们为User模块中的UserDetailActivity和UserSettingActivity分别添加@Route(path = "/home/detail", group = "user")和@Route(path = "/setting/user")两个注解。编译之后会发现工具帮我们生成了这样四个文件

再来看看他们的具体内容。

public class ARouter$$Root$$user implements IRouteRoot {
  @Override
  public void loadInto(Map<String, Class<? extends IRouteGroup>> routes) {
    routes.put("setting", ARouter$$Group$$setting.class);
    routes.put("user", ARouter$$Group$$user.class);
  }
}
public class ARouter$$Group$$user implements IRouteGroup {
  @Override
  public void loadInto(Map<String, RouteMeta> atlas) {
    atlas.put("/home/detail", RouteMeta.build(RouteType.ACTIVITY, UserDetailActivity.class, "/home/detail", "user", null, -1, -2147483648));
  }
}
public class ARouter$$Group$$setting implements IRouteGroup {
  @Override
  public void loadInto(Map<String, RouteMeta> atlas) {
    atlas.put("/setting/user", RouteMeta.build(RouteType.ACTIVITY, UserSettingsActivity.class, "/setting/user", "setting", null, -1, -2147483648));
  }
}

上面这些文件是如何生成的,生成规则又是怎样的,我们可以查看{@link com.alibaba.android.arouter.compiler.processor.RouteProcessor#parseRoutes(Set)} )}一探究竟。源码比较长,感兴趣的可以自行阅读,简要概括便是:

  1. 以@Route中的group作为类名,生成ARoute$$Group$$groupName类保存组名相同的path和Activity之间的映射关系。
  2. 以moduleName作为类名生成ARouter$$Root$$moduleName类,保存同一个module中group和ARoute$$Group$$groupName.class之间的映射关系。
2.运行期
2.1 初始化

毫无疑问跟踪ARouter.init(this)方法,当然了我们只看核心逻辑{@link com.alibaba.android.arouter.core.LogisticsCenter#init(Context, ThreadPoolExecutor)}。

class Warehouse {
    static Map<String, Class<? extends IRouteGroup>> groupsIndex;
    static Map<String, RouteMeta> routes;
}
public synchronized static void init(Context context, ThreadPoolExecutor tpe) throws HandlerException {
        List<String> classFileNames = ClassUtils.getFileNameByPackageName(mContext, "com.alibaba.android.arouter.routes");

        for (String className : classFileNames) {
            if (className.startsWith("ARouter$$Root")) {
                ((IRouteRoot) (Class.forName(className).getConstructor().newInstance())).loadInto(Warehouse.groupsIndex);
            }

            //......

        }
    }

概括来讲就是将group和Arouter$$Group$$groupName之间的映射关系加载到内存中。

2.2 跳转

接下来继续跟踪ARouter.getInstance().build("")方法的调用,核心的处理逻辑在{@link com.alibaba.android.arouter.core.LogisticsCenter#completion(Postcard)}中,看一下具体的实现逻辑。

public synchronized static void completion(Postcard postcard) {
        RouteMeta routeMeta = Warehouse.routes.get(postcard.getPath());
        if (null == routeMeta) {    // Maybe its does't exist, or didn't load.
            Class<? extends IRouteGroup> groupMeta = Warehouse.groupsIndex.get(postcard.getGroup());  // Load route meta.
            if (null == groupMeta) {
                throw new NoRouteFoundException(TAG + "There is no route match the path [" + postcard.getPath() + "], in group [" + postcard.getGroup() + "]");
            } else {
                // Load route and cache it into memory, then delete from metas.
                try {
                    //如果我们想要跳转的路径所在的组存在,那么加载该组中所有的映射关系到内存中。
                    //这便是作者提到的分组管理按需加载。
                    IRouteGroup iGroupInstance = groupMeta.getConstructor().newInstance();
                    iGroupInstance.loadInto(Warehouse.routes);
                    Warehouse.groupsIndex.remove(postcard.getGroup());
                } catch (Exception e) {
                    throw new HandlerException(TAG + "Fatal exception when loading group meta. [" + e.getMessage() + "]");
                }

                completion(postcard);   // Reload
            }
        } else {
            postcard.setDestination(routeMeta.getDestination());
            postcard.setType(routeMeta.getType());
            postcard.setPriority(routeMeta.getPriority());
            postcard.setExtra(routeMeta.getExtra());

            //......

        }
    }

最后再看一下完成最终跳转的navigation()方法。该方法最终实现是在_ARouter类中。

protected Object navigation(final Context context, final Postcard postcard, final int requestCode, NavigationCallback callback) {
        try {
            LogisticsCenter.completion(postcard);
        } catch (NoRouteFoundException ex) {
            return null;
        }

        if (null != callback) {
            callback.onFound(postcard);
        }

        //为了提高效率我们可以配置一些类不需要经过拦截器处理
        if (!postcard.isGreenChannel()) {   // It must be run in async thread, maybe interceptor cost too mush time made ANR.
            interceptorService.doInterceptions(postcard, new InterceptorCallback() {
                @Override
                public void onContinue(Postcard postcard) {
                    _navigation(context, postcard, requestCode);
                }

                @Override
                public void onInterrupt(Throwable exception) {
                
                }
            });
        } else {
            return _navigation(context, postcard, requestCode);
        }
        return null;
    }
    private Object _navigation(final Context context, final Postcard postcard, final int requestCode) {
        switch (postcard.getType()) {
            case ACTIVITY:
                final Intent intent = new Intent(currentContext, postcard.getDestination());
                intent.putExtras(postcard.getExtras());

                int flags = postcard.getFlags();
                if (-1 != flags) {
                    intent.setFlags(flags);
                } else if (!(currentContext instanceof Activity)) {    // Non activity, need less one flag.
                    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                }

                // Navigation in main looper.
                new Handler(Looper.getMainLooper()).post(new Runnable() {
                    @Override
                    public void run() {
                        if (requestCode > 0) {  // Need start for result
                            ActivityCompat.startActivityForResult((Activity) currentContext, intent, requestCode, postcard.getOptionsBundle());
                        } else {
                            ActivityCompat.startActivity(currentContext, intent, postcard.getOptionsBundle());
                        }

                        if ((0 != postcard.getEnterAnim() || 0 != postcard.getExitAnim()) && currentContext instanceof Activity) {    // Old version.
                            ((Activity) currentContext).overridePendingTransition(postcard.getEnterAnim(), postcard.getExitAnim());
                        }
                    }
                });

                break;
        }

        return null;
    }

以上代码省略了太多不相干的东西,看的不爽就请移步Github查看源码实现,当然了后续我也会陆续补充的。

FAQ


1.为什么path一定要声明成两级目录

为了避免一次性将工程中所有的页面和path之间的映射关系加载到内存中,ARouter采用了分组加载的机制。只有当一个分组中的某一个path被加载到内存中时,该分组中所有的映射关系才会被加载到内存中。因此,要求path采用两级目录的原因就是,第一级目录会被截取作为组名。当然了如果你同时声明group的话就可以只使用一级目录,只不过这种方式已经被@Deprecated了。 *(源码参考:{@link com.alibaba.android.arouter.compiler.processor.RouteProcessor#routeVerify(RouteMeta)}
)*

2.为什么一定要在build.gradle中配置javaCompileOptions

其实仔细看,我们真正配置的是annotationProcessorOptions,这个呢其实又是annotationProcessor工具的配置参数,所以我们首先要了解annotationProcessor是什么(Java注解处理器)。它是Google在Android Gradle Plugin 2.2之后提供的一个处理注解的工具,通过它可以将源文件中的注解生成.java文件等。ARouter便是通过这种方式建立path和Activity之间的映射关系。不过具体为什么要配置该参数,主要是因为ARouter将每一个module中的path和Activity之间的映射关系保存在一个单独的类中,为了避免类重名,因此类名最后都是以module名结尾,所以为了获得module名,需要我们手动配置该参数。(源码参考:{@link com.alibaba.android.arouter.compiler.processor.RouteProcessor#init(ProcessingEnvironment)})

3.手动设置分组后,为什么跳转失败

虽然Github上作者说一旦主动指定分组之后,应用内路由需要使用ARouter.getInstance().build(path, group) 手动指定分组进行跳转,否则无法找到。不过在查看了源码实现以及测试之后发现,这么表述其实不太准确。如果你在手动指定分组的同时将path配置成/aa/bb的形式并且aa和组名一致的话,那么使用build("/aa/bb")跳转并不会有任何问题。通过刚刚的原理简析相信大家应该已经明白了为什么会出现这种情况。不懂的话可以继续阅读源码或者忽视好了,毕竟该方法已经被@Deprecated,没有特殊需求也应该避免在@Route中手动指明分组。

参考资料
  1. 开源最佳实践:Android平台页面路由框架ARouter
  2. Github源码

关于LayoutInflater的基本用法就不再累述了,本篇主要通过分析inflate()的源码搞清几个参数的作用。

首先来看一个Demo,这个Demo很简单就是通过调用LayoutInflater的inflate方法获取一个蓝色背景的TextView并以match_parent的形式添加到一个300dp*100dp的RelativeLayout上,我们传递不同的参数来看一下实现效果之间的差别。
先来看一下这两个布局文件

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
          android:layout_width="match_parent"
          android:layout_height="match_parent"
          android:background="@android:color/holo_blue_dark"
          android:gravity="center"
          android:text="Hello World"
          android:textColor="#fff"
          android:textSize="18sp">

</TextView>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <RelativeLayout
        android:id="@+id/content"
        android:layout_width="300dp"
        android:layout_height="100dp"
        android:layout_gravity="center_horizontal"
        android:orientation="vertical">

    </RelativeLayout>

    <TextView
        android:id="@+id/params"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:paddingLeft="12dp"
        android:paddingRight="12dp"
        android:textColor="#555"
        android:textSize="14sp"/>

</LinearLayout>

再来看一下实现代码和对应的实现效果,同时我们输出TextView的宽和高。

No.1

View textView = LayoutInflater.from(this).inflate(R.layout.textview, null);
content.addView(textView);

No.2

LayoutInflater.from(this).inflate(R.layout.textview, content);

No.3

View textView = LayoutInflater.from(this).inflate(R.layout.textview, content, false);
content.addView(textView);

可以看到只有第二和第三种方式实现了我们想要的效果,为什么第一种不可以呢?根据输出的TextView的宽和高我们应该能猜出一些端倪。那就是通过第二,第三种方式得到的TextView设置了宽高都为match_parent的LayoutParams,为什么会这样呢,让我们通过源码一探究竟。

注:

        /**
         * Special value for the height or width requested by a View.
         * MATCH_PARENT means that the view wants to be as big as its parent,
         * minus the parent's padding, if any. Introduced in API Level 8.
         */
        public static final int MATCH_PARENT = -1;

        /**
         * Special value for the height or width requested by a View.
         * WRAP_CONTENT means that the view wants to be just large enough to fit
         * its own internal content, taking its own padding into account.
         */
        public static final int WRAP_CONTENT = -2;

源码简析

首先对比一下几个重载方法,可以看到除了上面我们用到的两种还有两种。

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
        return inflate(resource, root, root != null);
}

public View inflate(XmlPullParser parser, @Nullable ViewGroup root) {
        return inflate(parser, root, root != null);
}

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
        final Resources res = getContext().getResources();

        final XmlResourceParser parser = res.getLayout(resource);
        try {
            return inflate(parser, root, attachToRoot);
        } finally {
            parser.close();
        }
}

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    ...
}

不过前三个最终调用的都是:

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
    ...
}

而且我们还可以发现,root != null时,attachToRoot默认为true,布局id会被通过调用getLayout方法生成一个XmlResourceParser对象。我们继续分析inflate方法

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
        synchronized (mConstructorArgs) {

            final Context inflaterContext = mContext;
            final AttributeSet attrs = Xml.asAttributeSet(parser);
            Context lastContext = (Context) mConstructorArgs[0];
            mConstructorArgs[0] = inflaterContext;
            // 首先注意result的初始值为root,也就是我们传进来的
            View result = root;

            try {
                // 尝试找到根节点
                int type;
                while ((type = parser.next()) != XmlPullParser.START_TAG &&
                        type != XmlPullParser.END_DOCUMENT) {
                }

                // 获取当前节点名称
                final String name = parser.getName();

                // 处理merge节点
                if (TAG_MERGE.equals(name)) {
                    if (root == null || !attachToRoot) {
                        throw new InflateException("<merge /> can be used only with a valid "
                                + "ViewGroup root and attachToRoot=true");
                    }
                    rInflate(parser, root, inflaterContext, attrs, false);
                } else {
                    // 根据获取到的节点名创建根View
                    final View temp = createViewFromTag(root, name, inflaterContext, attrs);

                    ViewGroup.LayoutParams params = null;

                    // 如果我们传递进来一个ViewGroup,那么就会根据我们传递进来的ViewGroup
                    // 生成LayouParams
                    if (root != null) {
                        params = root.generateLayoutParams(attrs);
                        // 如果attachToRoot为false,那么将LayoutParams添加到根View
                        // 否则会走下面的代码,直接将根View添加到我们传递进来的ViewGroup上
                        if (!attachToRoot) {
                            temp.setLayoutParams(params);
                        }
                    }

                    // 获取根节点下面所有的子View
                    rInflateChildren(parser, temp, attrs, true);

                    // 如果我们传递进来一个ViewGroup并且attachToRoot为ture
                    // 则将获取到的view添加到我们传递进来的ViewGroup上,同时布局
                    if (root != null && attachToRoot) {
                        root.addView(temp, params);
                    }

                    // 如果我们没有传递进来ViewGroup或者attachToRoot为false,则将生成的
                    // 根View返回,否则返回root,也就是我们传递进来的ViewGroup
                    if (root == null || !attachToRoot) {
                        result = temp;
                    }
                }
            } catch (XmlPullParserException e) {

            } catch (Exception e) {

            } finally {

            }
            return result;
        }
    }
根据以上分析我们发现这个方法主要有下面几个步骤:
  1. 首先查找根节点,如果整个xml文件解析完毕也没看到根节点,会抛出异常;
  2. 如果查找到的根节点名称是merge标签,会调用rInflate方法继续解析布局,最终返回root;
  3. 如果是其他标签(View、TextView等),会调用createViewFromTag生成布局根View,并调用rInflate递归解析余下的子View,添加至布局根View中,最后视root和attachToRoot参数的情况最终返回view或者root。
从这里我们可以理清root和attachToRoot参数的关系了:
  • root == null, attachToRoot无用

    当root为空时,attachToRoot是什么都没有意义,此时传进来的布局会被加载成为一个View并直接返回;
    布局根View的android:layout_xxx属性会被忽略。

  • root != null, attachToRoot == true:

    传进来的布局会被加载成为一个View并作为子View添加到root中,最终返回root;
    而且这个布局根节点的android:layout_xxx参数会被解析用来设置View的大小。

  • root != null, attachToRoot == false:

    传进来的布局会被加载成为一个View并直接返回。
    布局根View的android:layout_xxx属性会被解析成LayoutParams并保留。(root只用来参与生成布局根View的LayoutParams)

想必到这不用我说大家也很清楚为什么Demo中通过第一种方式加载布局无法实现我们想要的效果了。

总结

可能以前我们怎么也不明白这些参数的作用,可是今天通过简单的分析源码我们就可以发现其中的端倪,而且要比看别人的介绍印象更加深刻,因此以后遇到不懂不明白的,Read the fucking source code,没有比这更直接有效的了。

其实通过上面简析inflate方法源码的过程,我们对加载xml布局的原理也有了一些简单的了解。其实就是从根节点开始,递归解析xml的每个节点,根据到的节点名通过反射生成一个个View,同时解析该节点的属性作为View的属性,然后根据View的层级关系add到对应的父View(上层节点)中,最终返回一个包含了所有解析好的子View的布局根View。那么具体是不是这样的,且看下回分解。

利用Android Studio中的产品风味进行密封测试

为了更好地实现运行UT过程中Mock数据的功能,这里借鉴大牛们的另一种思路,请看原文

结合实际项目需求,为了不影响debug和release这里决定基于debug构建类型新增一个单独的buildType用来专门运行UT,理由如下:

  1. 如果按照文中的方式创建单独的productFlavor而不是buildType,则需要为每一个productFlavor在src下创建单独的包,并把需要mock的类拆分到不同的包下。但是对于目前的项目,buildType是有限的,而productFlavor却可以有很多,因此为每一个flavor创建一个单独的包不太切合实际。

修改步骤:

  1. 增加新的buildType
  2. build.gradle中新增testBuildType(具体说明请参看TestedExtension类中的注释或官方文档)指向新增的buildType
  3. 在src目录下新建对应的buildType包
  4. 将需要Mock的类拆分到每一个单独的buildType包下,注意避免Duplicate class error

FAQ

1. androidTest目录下的类抛红,找不到依赖?新增的buildType包下的文件夹无法被AS识别?

修改BuildVariant选项,建议在写UT时将BuildVariants修改为firDebugUT,具体操作如下:

Build -> Select Build Variant -> 新增的buildVariant(如:firDebugUT)

参考文档:

配置变体

更改测试构建类型