0%

应用沙盒

概述

Android 平台利用基于用户的 Linux 保护机制来识别和隔离应用资源,可将不同的应用分离开,并保护应用和系统免受恶意应用的攻击。为此,Android 会为每个 Android 应用分配一个独一无二的用户 ID (UID),并在自己的进程中运行。

Android 会使用此 UID 设置一个内核级应用沙盒。内核会在进程级别利用标准的 Linux 机制(例如,分配给应用的用户 ID 和组 ID)实现应用和系统之间的安全防护。 默认情况下,应用不能彼此交互,而且对操作系统的访问权限会受到限制。例如,如果应用 A(一个单独的应用)尝试执行恶意操作,例如在没有权限的情况下读取应用 B 的数据或拨打电话,操作系统会阻止此类行为,因为应用 A 没有适当的用户权限。这一沙盒机制非常简单,可审核,并且基于已有数十年历史的 UNIX 风格的进程用户隔离和文件权限机制。

由于应用沙盒位于内核层面,因此该安全模型的保护范围扩展到了原生代码和操作系统应用。位于更高层面的所有软件(例如,操作系统库、应用框架、应用运行时环境和所有应用)都会在应用沙盒中运行。在某些平台上,为了执行安全防护机制,会限制开发者只能使用特定的开发框架、API 或语言。在 Android 上,并没有为此而限制开发者必须如何编写应用;在这方面,原生代码与解释型代码一样进行沙盒化。

保护机制

通常,要在经过适当配置的设备上攻破应用沙盒这道防线,必须要先攻破 Linux 内核的安全功能。但是,与其他安全功能类似,强制执行应用沙盒的各种保护机制并非无懈可击,因此深度防御对于防止通过单个漏洞入侵操作系统或其他应用非常重要。

Android 依靠许多保护机制来强制执行应用沙盒。 这些强制措施是随着时间的推移不断引入的,并且显著增强了基于 UID 的原始自主访问控制 (DAC) 沙盒的安全性。 以前的 Android 版本包括以下保护机制:

  • 在 Android 5.0 中,SELinux 提供了强制访问控制 (MAC) 来将系统和应用分离开。但是,所有第三方应用都在相同的 SELinux 环境中运行,因此应用间的隔离主要由 UID DAC 强制执行。
  • 在 Android 6.0 中,SELinux 沙盒经过扩展,可以跨各个物理用户边界隔离应用。此外,Android 还为应用数据设置了更安全的默认设置:对于 targetSdkVersion >= 24 的应用,应用主目录上的默认 DAC 权限从 751 更改为 700。这为私有应用数据提供了更安全的默认设置(但应用可能会替换这些默认设置)。
  • 在 Android 8.0 中,所有应用都设为使用 seccomp-bpf 过滤器运行,该过滤器可限制允许应用使用的系统调用,从而增强应用/内核边界的安全性。
  • 在 Android 9 中,targetSdkVersion >= 28 的所有非特权应用都必须在不同的 SELinux 沙盒中运行,并针对各个应用提供 MAC。这种保护机制可以提升应用隔离效果,防止替换安全默认设置,并且(最重要的是)防止应用的数据可让所有人访问。

共享文件指南

将应用数据设为可供所有人访问从安全方面来讲是一种不好的做法,因为这会为所有人授予访问权限,并且无法限定只让目标受众访问这些数据。这种做法会导致信息披露泄露,让代理漏洞变得混乱,并会成为针对包含敏感数据的应用(例如电子邮件客户端)的恶意软件的首选目标。在 Android 9 及更高版本中,targetSdkVersion>=28 的应用明确禁止以这种方式共享文件。

在共享文件时,请遵循以下指南,而不是让应用数据可供所有人访问:

如果您的应用需要与其他应用共享文件,请使用内容提供程序外部存储设备上的共享位置。内容提供程序会以适当的粒度共享数据,并且不会出现使用所有人都可访问的 UNIX 权限会带来的诸多问题(如需了解详情,请参阅内容提供程序基础知识)。
如果您的应用包含确实应让所有人访问的文件(例如照片),请使用外部存储设备。如需帮助,请参阅将文件保存至公共目录

应用签名

在 Android 上,应用签名是将应用放入其应用沙盒的第一步。已签名的应用证书定义了哪个用户 ID 与哪个应用相关联;不同的应用要以不同的用户 ID 运行。应用签名可确保一个应用无法访问任何其他应用的数据,通过明确定义的 IPC 进行访问时除外。

当应用(APK 文件)安装到 Android 设备上时,软件包管理器会验证 APK 是否已经过适当签名(已使用 APK 中包含的证书签名)。如果该证书(或更准确地说,证书中的公钥)与设备上的任何其他 APK 使用的签名密钥一致,那么这个新 APK 就可以选择在清单中指定它将与其他以类似方式签名的 APK 共用一个 UID。

应用可以由第三方(OEM、运营商、其他应用市场)签名,也可以自行签名。Android 提供了使用自签名证书进行代码签名的功能,而开发者无需外部协助或许可即可生成自签名证书。应用并非必须由核心机构签名。Android 目前不对应用证书进行 CA 认证。

应用还可以在“签名”保护级别声明安全权限,以便仅限使用同一个密钥签名的应用访问它们,同时维持单独的 UID 和应用沙盒。通过共用 UID 功能,可以与共用的应用沙盒建立更紧密的联系,这是因为借助该功能,使用同一个开发者密钥签名的两个或更多应用可以在其清单中声明共用的 UID。

Android 支持以下三种应用签名方案:

  • v1 方案:基于 JAR 签名。
  • v2 方案:APK 签名方案 v2(在 Android 7.0 中引入)。
  • v3 方案:APK 签名方案 v3(在 Android 9 中引入)。

为了最大限度地提高兼容性,请按照 v1、v2、v3 的先后顺序采用所有方案对应用进行签名。与只通过 v1 方案签名的应用相比,还通过 v2+ 方案签名的应用能够更快速地安装到 Android 7.0 及更高版本的设备上。更低版本的 Android 平台会忽略 v2+ 签名,这就需要应用包含 v1 签名。

v1方案(jarsinger)

签名机制

v1 签名不保护 APK 的某些部分,例如 ZIP 元数据。APK 验证程序需要处理大量不可信(尚未经过验证)的数据结构,然后会舍弃不受签名保护的数据。这会导致相当大的受攻击面。此外,APK 验证程序必须解压所有已压缩的条目,而这需要花费更多时间和内存。为了解决这些问题,Android 7.0 中引入了 APK 签名方案 v2。

APK文件本质上是一个ZIP压缩包,而ZIP格式是固定的,主要由三部分构成:

  • 第一部分是内容块,所有的压缩文件都在这部分。每个压缩文件都有一个local file header,主要记录了文件名、压缩算法、压缩前后的文件大小、修改时间、CRC32值等。
  • 第二部分称为中央目录,包含了多个central directory file header(和第一部分的local file header一一对应),每个中央目录文件头主要记录了压缩算法、注释信息、对应local file header的偏移量等,方便快速定位数据。
  • 最后一部分是EOCD,主要记录了中央目录大小、偏移量和ZIP注释信息等

V1签名只会检验第一部分的所有压缩文件,而不理会后两部分内容。

解压APK后,在META-INF目录下,可以看到三个文件:MANIFEST.MF、CERT.SF、CERT.RSA。它们都是V1签名的产物。

其中,MANIFEST.MF文件内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Manifest-Version: 1.0   // 用来定义manifest文件的版本
Built-By: Generated-by-ADT // 声明该文件的构建者
Created-By: Android Gradle 3.4.2 // 声明该文件的生成者

Name: AndroidManifest.xml
SHA-256-Digest: 2ukv+j6Xu2UEEelZaKeZ+63IpYQ5FqCbIfA6QKk7fgM=

Name: META-INF/androidx.appcompat_appcompat.version
SHA-256-Digest: n9KGQtOsoZHlx/wjg8/W+rsqrIdD8Cnau4mJrFhOMbw=

Name: META-INF/androidx.arch.core_core-runtime.version
SHA-256-Digest: wo/MpTY3vIjhJK8XJd8Ty5jGne3v1i+zzb4c22t2BiQ=

// ...

Name: res/drawable-nodpi-v4/filter_48.jpg
SHA-256-Digest: FfpvAStgPFXWDjVvs/N2H+XULiIwwwfqGtp1fPg0YYo=

// ...

Name: res/xml/file_paths.xml
SHA-256-Digest: 9s07yeSRLkE3+iYROBVOBmGlmApgGVDUeGsZQ9HYPfo=

Name: resources.arsc
SHA-256-Digest: uGEAbvopf67Kasj8mPZE7R1NpI9jzuaL26usC1mXVsE=

它记录了APK中所有原始文件的数据摘要的Base64编码,而数据摘要算法就是SHA256(或SHA1)。

CERT.SF文件内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Signature-Version: 1.0
Created-By: 1.0 (Android)
SHA-256-Digest-Manifest: qXt5WBCgJuQFdkZK1ZpHd5KnGllF0FNY/yx++rgsdDc=
X-Android-APK-Signed: 2

Name: AndroidManifest.xml
SHA-256-Digest: 9SF8sMBwuyZm25vpoYk4VZImaK37o4rXpIGKPWWDj9M=

Name: META-INF/androidx.appcompat_appcompat.version
SHA-256-Digest: ABbgKP0s08CVeuJ5ZMlIZx/AvJtb1QhNA0ffeXfCaHk=

Name: META-INF/androidx.arch.core_core-runtime.version
SHA-256-Digest: PjygIQMN5T6nIKT/hi5PFaxVcEB+W20fr4f0g2n7jrg=

//...

Name: res/xml/file_paths.xml
SHA-256-Digest: aDSRY140t8cYqDFleCi9Gc4NTFo0EfbEY/Mr3vlfx1o=

Name: resources.arsc
SHA-256-Digest: KNGXNZ4K5DU+sdJIblO/zwEug24++hvcQxp7fbaf6Gk=

SHA-256-Digest-Manifest记录了整个MANIFEST.MF文件的数据摘要的Base64编码。其余的普通属性则和MANIFEST.MF中的属性一一对应,分别记录了对应数据块的数据摘要的Base64编码。这里要注意的是:最后一行的换行符是必不可少,需要参与计算的。

CERT.RSA文件包含了对CERT.SF文件的数字签名和开发者的数字证书。RSA就是计算数字签名使用的非对称加密算法。

整个签名机制的最终产物就是MANIFEST.MF、CERT.SF、CERT.RSA三个文件。

除了CERT.RSA文件,其余两个签名文件其实跟keystore没什么关系,主要是文件自身的摘要及二次摘要,用不同的keystore进行签名,生成的MANIFEST.MF与CERT.SF都是一样的,不同的只有CERT.RSA签名文件。也就是说前两者主要保证各个文件的完整性,CERT.RSA从整体上保证APK的来源及完整性,不过META_INF中的文件不在校验范围中,这也是V1的一个缺点。

签名流程可查看源码:SignApk.java

校验流程

V1签名是怎么保证APK文件不被篡改的?

  • 首先,如果破坏者修改了APK中的任何文件,那么被篡改文件的数据摘要的Base64编码就和MANIFEST.MF文件的记录值不一致,导致校验失败。
  • 其次,如果破坏者同时修改了对应文件在MANIFEST.MF文件中的Base64值,那么MANIFEST.MF中对应数据块的Base64值就和CERT.SF文件中的记录值不一致,导致校验失败。
  • 最后,如果破坏者更进一步,同时修改了对应文件在CERT.SF文件中的Base64值,那么CERT.SF的数字签名就和CERT.RSA记录的签名不一致,也会校验失败。
  • 理论上不可能继续伪造CERT.SF的数字签名,因为破坏者没有开发者的私钥,但是可以重新签名。

v2方案(apksigner)

签名机制

APK 签名方案 v2 是一种全文件签名方案,该方案能够发现对 APK 的受保护部分进行的所有更改,从而有助于加快验证速度并增强完整性保证。为了保持与 v1 APK 格式向后兼容,v2 及更高版本的 APK 签名会存储在“APK 签名分块”内,该分块是为了支持 APK 签名方案 v2 而引入的一个新容器。在 APK 文件中,“APK 签名分块”位于“ZIP 中央目录”(位于文件末尾)之前并紧邻该部分。

APK 签名方案 v2 是在 Android 7.0 (Nougat) 中引入的。为了使 APK 可在 Android 6.0 (Marshmallow) 及更低版本的设备上安装,应先使用 JAR 签名功能对 APK 进行签名,然后再使用 v2 方案对其进行签名。

为了保护 APK 内容,APK 包含以下 4 个部分:

  1. ZIP 条目的内容(从偏移量 0 处开始一直到“APK 签名分块”的起始位置)
  2. APK 签名分块
  3. ZIP 中央目录
  4. ZIP 中央目录结尾

V2签名同时修改了EOCD中的中央目录的偏移量,使签名后的APK还符合ZIP结构。

V2签名块的生成可参考ApkSignerV2

  1. 首先,根据多个签名算法,计算出整个APK的数据摘要,组成APK数据摘要集;
  2. 接着,把数据摘要、数字证书和额外属性组装起来,形成类似于V1签名的“MF”文件;
  3. 其次,再用相同的私钥,不同的签名算法,计算出“MF”文件的数字签名,形成类似于V1签名的“SF”文件;
  4. 然后,把第二列的类似MF文件、类似SF文件和开发者公钥一起组装成通过单个keystore签名后的v2签名块;
  5. 最后,把多个keystore签名后的签名块组装起来,就是完整的V2签名块了(Android中允许使用多个keystore对apk进行签名)。

验证流程

APK 签名方案 v2 负责保护第 1、3、4 部分的完整性,以及第 2 部分包含的“APK 签名方案 v2 分块”中的 signed data 分块的完整性。第 1、3 和 4 部分的完整性通过其内容的一个或多个摘要来保护,这些摘要存储在 signed data 分块中,而这些分块则通过一个或多个签名来保护。

V1签名是怎么保证APK文件不被篡改的?

  • 首先,如果破坏者修改了APK文件的任何部分(签名块本身除外),那么APK的数据摘要就和“MF”数据块中记录的数据摘要不一致,导致校验失败。
  • 其次,如果破坏者同时修改了“MF”数据块中的数据摘要,那么“MF”数据块的数字签名就和“SF”数据块中记录的数字签名不一致,导致校验失败。
  • 然后,如果破坏者使用自己的私钥去加密生成“SF”数据块,那么使用开发者的公钥去解密“SF”数据块中的数字签名就会失败。
  • 最后,更进一步,若破坏者甚至替换了开发者公钥,那么使用数字证书中的公钥校验签名块中的公钥就会失败,这也正是数字证书的作用。

v3方案(apksigner)

Android 9 新增了对 APK Signature Scheme v3 的支持。该架构提供的选择可以在其签名块中为每个签名证书加入一条轮转证据记录。 利用此功能,应用可以通过将 APK 文件过去的签名证书链接到现在签署应用时使用的证书,从而使用新签名证书来签署应用。

Android 9 支持 APK 密钥轮转,这使应用能够在 APK 更新过程中更改其签名密钥。为了实现轮转,APK 必须指示新旧签名密钥之间的信任级别。为了支持密钥轮转,我们将 APK 签名方案从 v2 更新为 v3,以允许使用新旧密钥。v3 在 APK 签名分块中添加了有关受支持的 SDK 版本和 proof-of-rotation 结构的信息。

为了保持与 v1 APK 格式向后兼容,v2 和 v3 APK 签名存储在“APK 签名分块”内紧邻 ZIP Central Directory 前面。v3 APK 签名分块的格式与 v2 相同。

配置调试的Debug包apk可安装

Android Studio 3.0会在debug apk的配置文件application标签里自动添加 android:testOnly=”true”属性,导致IDE中run跑出的apk无法安装,只能用于as测试安装。

解决办法:在gradle.properties(项目根目录或者gradle全局配置目录 ~/.gradle/)文件中添加android.injected.testOnly=false 之后就可以安装了。

证书和密钥库

Keystore 称为密钥库,一个keystore里面可以放多组秘钥,每组密钥都有有效期、地址、公司等信息,可以通过别名来进行区分拿取。开发者将录入自己信息的秘钥(而非秘钥库Keystore)存入APP中,以认证此APP为自己开发。

Eclipse或Android Studio在Debug时,对App签名都会使用一个默认的密钥库:~/.android/debug.keystore

  • 密钥库名: debug.keystore
  • 密钥别名: androiddebugkey
  • 密钥库密码: android

由于调试证书是由构建工具创建并且设计上不安全,因此大多数应用商店(包括 Google Play 商店)都不接受使用调试证书签署发布的 APK 或应用软件包。

数据摘要、数字签名和数字证书

数据摘要

数据摘要算法是一种能产生特定输出格式的算法,其原理是根据一定的运算规则对原始数据进行某种形式的信息提取,被提取出的信息就是原始数据的消息摘要,也称为数据指纹。 一般情况下,数据摘要算法具有以下特点:

  1. 无论输入数据有多大(长),计算出来的数据摘要的长度总是固定的。例如:MD5算法计算出的数据摘要有128Bit。
  2. 一般情况下(不考虑碰撞的情况下),只要原始数据不同,那么其对应的数据摘要就不会相同。同时,只要原始数据有任何改动,那么其数据摘要也会完全不同。即:相同的原始数据必有相同的数据摘要,不同的原始数据,其数据摘要也必然不同。
  3. 不可逆性,即只能正向提取原始数据的数据摘要,而无法从数据摘要中恢复出原始数据。

著名的摘要算法有RSA公司的MD5算法和SHA系列算法。

数字签名和数字证书

数字签名和数字证书是成对出现的,两者不可分离(数字签名主要用来校验数据的完整性,数字证书主要用来确保公钥的安全发放)。

要明白数字签名的概念,必须要了解数据的加密、传输和校验流程。一般情况下,要实现数据的可靠通信,需要解决以下两个问题:

  1. 确定数据的来源是其真正的发送者。
  2. 确保数据在传输过程中,没有被篡改,或者若被篡改了,可以及时发现。

而数字签名,就是为了解决这两个问题而诞生的。 首先,数据的发送者需要先申请一对公私钥对,并将公钥交给数据接收者。 然后,若数据发送者需要发送数据给接收者,则首先要根据原始数据,生成一份数字签名,然后把原始数据和数字签名一起发送给接收者。 数字签名由以下两步计算得来:

  1. 计算发送数据的数据摘要
  2. 用私钥对提取的数据摘要进行加密

这样,数据接收者拿到的消息就包含了两块内容:

  1. 原始数据内容
  2. 附加的数字签名

接下来,接收者就会通过以下几步,校验数据的真实性:

  1. 用相同的摘要算法计算出原始数据的数据摘要。
  2. 用预先得到的公钥解密数字签名。
  3. 对比签名得到的数据是否一致,如果一致,则说明数据没有被篡改,否则数据就是脏数据了。

因为私钥只有发送者才有,所以其他人无法伪造数字签名。这样通过数字签名就确保了数据的可靠传输。 综上所述,数字签名就是只有发送者才能产生的别人无法伪造的一段数字串,这段数字串同时也是对发送者发送数据真实性的一个有效证明。

想法虽好,但是上面的整个流程,有一个前提,就是数据接收者能够正确拿到发送者的公钥。如果接收者拿到的公钥被篡改了,那么坏人就会被当成好人,而真正的数据发送者发送的数据则会被视作脏数据。那怎么才能保证公钥的安全性那?这就要靠数字证书来解决了。

数字证书是由有公信力的证书中心(CA)颁发给申请者的证书,主要包含了:证书的发布机构、证书的有效期、申请者的公钥、申请者信息、数字签名使用的算法,以及证书内容的数字签名。

可见,数字证书也用到了数字签名技术。只不过签名的内容是数据发送方的公钥,以及一些其它证书信息。这样数据发送者发送的消息就包含了三部分内容:

  1. 原始数据内容
  2. 附加的数字签名
  3. 申请的数字证书。

接收者拿到数据后,首先会根据CA的公钥,解码出发送者的公钥。然后就与上面的校验流程完全相同了。

所以,数字证书主要解决了公钥的安全发放问题。

密钥管理

如果准备自行创建密钥和密钥库,请确保先为密钥库选择一个强密码,然后为密钥库中存储的每个私钥选择一个单独的强密码,且必须为密钥库存放在一个可靠的地方。

签名步骤

1. 生成密钥对

  1. 生成密钥对

    1
    2
    3
    4
    5
    6
    7
    8
    9
    keytool -genkeypair -keystore 密钥库名 -alias 密钥别名 -validity 天数 -keyalg RSA

    参数:
    -genkeypair 生成一条密钥对(由私钥和公钥组成)
    -keystore 密钥库名字以及存储位置(默认当前目录)
    -alias 密钥对的别名(密钥库可以存在多个密钥对,用于区分不同密钥对)
    -validity 密钥对的有效期(单位: 天)
    -keyalg 生成密钥对的算法(常用RSA/DSA,DSA只用于签名,默认采用DSA)
    -delete 删除一条密钥

    提示: 可重复使用此条命令,在同一密钥库中创建多条密钥对,例如,在debug.keystore中新增一对密钥,别名是release:

    1
    keytool -genkeypair -keystore debug.keystore -alias release -validity 30000
  2. 查看密钥库

    1
    2
    3
    4
    5
    keytool -list -v -keystore 密钥库名

    参数:
    -list 查看密钥列表
    -v 查看密钥详情

2. 签名

zipalign

位于Android SDK/build-tools/SDK版本/下,zipalign是对zip包对齐的工具,使APK包内未压缩的数据有序排列对齐,从而减少APP运行时内存消耗。

1
2
zipalign -v 4 in.apk out.apk   # 4字节对齐优化
zipalign -c -v 4 in.apk # 检查APK是否对齐

zipalign可以在V1签名后执行,但zipalign不能在V2签名后执行,只能在V2签名之前执行。

jarsigner

1
jarsigner -keystore 密钥库名 xxx.apk 密钥别名

从JDK7开始, jarsigner默认算法是SHA256, 但Android 4.2以下不支持该算法,所以需要修改算法, 添加参数-digestalg SHA1 -sigalg SHA1withRSA

1
2
3
4
5
jarsigner -keystore 密钥库名 -digestalg SHA1 -sigalg SHA1withRSA xxx.apk 密钥别名

参数:
-digestalg 摘要算法
-sigalg 签名算法

apksigner

1
2
3
4
5
6
7
8
9
10
11
12
apksigner sign --ks 密钥库名 --ks-key-alias 密钥别名 xxx.apk

# 若密钥库中有多个密钥对,则必须指定密钥别名
apksigner sign --ks 密钥库名 --ks-key-alias 密钥别名 xxx.apk

# 禁用V2签名
apksigner sign --v2-signing-enabled false --ks 密钥库名 xxx.apk

参数:
--ks-key-alias 密钥别名,若密钥库有一个密钥对,则可省略,反之必选
--v1-signing-enabled 是否开启V1签名,默认开启
--v2-signing-enabled 是否开启V2签名,默认开启

3. 签名验证

keytool,只支持V1签名校验

1
2
3
4
5
keytool -printcert -jarfile MyApp.apk (显示签名证书信息)

参数:
-printcert 打印证书内容
-jarfile <filename> 已签名的jar文件 或apk文件

apksigner,支持V1和V2签名校验

1
2
3
4
5
apksigner verify -v --print-certs xxx.apk

参数:
-v, --verbose 显示详情(显示是否使用V1和V2签名)
--print-certs 显示签名证书信息

Gradle签名发布

可以使用Android Studio工具配置KeyStore相关的配置,会自动在build.gradle文件中生成配置。

  1. 通过keytool生成签名文件

  2. 配置build.gradle

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    android {
    signingConfigs {
    release {
    storeFile file("path/release.keystore")
    storePassword "123456"
    keyAlias "release.keystore"
    keyPassword "123456"
    }
    debug {
    storeFile file("path/test.keystore")
    storePassword "123456"
    keyAlias "test.keystore"
    keyPassword "123456"
    }
    }
    buildTypes {
    release {
    minifyEnabled enableProguardInReleaseBuilds
    proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
    signingConfig signingConfigs.release

    android.applicationVariants.all { variant ->
    variant.outputs.all {
    if (variant.buildType.name.equals('release')) {
    outputFileName = "SecureMail${defaultConfig.versionName}-${releaseTime()}.apk"
    variant.getPackageApplication().outputDirectory = new File(
    project.rootDir.absolutePath + "/app/release")
    }
    }
    }
    }
    }

    flavorDimensions "version"
    productFlavors {
    hearing {
    dimension "version"
    signingConfig signingConfigs.release
    }
    full {
    dimension "version"
    }
    }
    }

    static def releaseTime() {
    return new Date().format("yyyy-MM-dd", TimeZone.getTimeZone("UTC"))
    }
  3. 执行打包命令

    1
    ./gradlew assemblerelease

注意:可以在根目录的gradle.properties文件中配置签名相关的变量,在build.gradle中直接引入即可。

多渠道打包方案

Android Gradle Plugin

Gradle Plugin本身提供了多渠道的打包策略: 首先,在AndroidManifest.xml中添加渠道信息占位符:

1
<meta-data android:name="InstallChannel" android:value="${InstallChannel}" />

然后,通过Gradle Plugin提供的productFlavors标签,添加渠道信息:

1
2
3
4
5
6
7
8
productFlavors{
"YingYongBao"{
manifestPlaceholders = [InstallChannel : "YingYongBao"]
}
"360"{
manifestPlaceholders = [InstallChannel : "360"]
}
}

这样,Gradle编译生成多渠道包时,会用不同的渠道信息替换AndroidManifest.xml中的占位符。我们在代码中,也就可以直接读取AndroidManifest.xml中的渠道信息了。

但是,这种方式存在一些缺点:

  1. 每生成一个渠道包,都要重新执行一遍构建流程,效率太低,只适用于渠道较少的场景。
  2. Gradle会为每个渠道包生成一个不同的BuildConfig.java类,记录渠道信息,导致每个渠道包的DEX的CRC值都不同。一般情况下,这是没有影响的。但是如果你使用了微信的Tinker热补丁方案,那么就需要为不同的渠道包打不同的补丁,这完全是不可以接受的。(因为Tinker是通过对比基础包APK和新包APK生成差分补丁,然后再把补丁和基础包APK一起合成新包APK。这就要求用于生成差分补丁的基础包DEX和用于合成新包的基础包DEX是完全一致的,即:每一个基础渠道包的DEX文件是完全一致的,不然就会合成失败)

ApkTool

ApkTool是一个逆向分析工具,可以把APK解开,添加代码后,重新打包成APK。因此,基于ApkTool的多渠道打包方案分为以下几步:

  1. 复制一份新的APK
  2. 通过ApkTool工具,解压APK(apktool d origin.apk)
  3. 删除已有签名信息
  4. 添加渠道信息(可以在APK的任何文件添加渠道信息)
  5. 通过ApkTool工具,重新打包生成新APK(apktool b newApkDir)
  6. 重新签名

优点: 不需要重新构建新渠道包,仅需要复制修改就可以了。并且因为是重新签名,所以同时支持V1和V2签名。

缺点:

  • ApkTool工具不稳定,曾经遇到过升级Gradle Plugin版本后,低版本ApkTool解压APK失败的情况。
  • 生成新渠道包时,需要重新解包、打包和签名,而这几步操作又是相对比较耗时的。经过测试:生成企鹅电竞10个渠道包需要16分钟左右,虽然比Gradle Plugin方案减少很多耗时。但是若需要同时生成上百个渠道包,则需要几个小时,显然不适合渠道非常多的业务场景。

VasDolly

VasDolly实现原理

概述

众所周知,因为国内Android应用分发市场的现状,我们在发布APP时,一般需要生成多个渠道包,上传到不同的应用市场。这些渠道包需要包含不同的渠道信息,在APP和后台交互或者数据上报时,会带上各自的渠道信息。这样,我们就能统计到每个分发市场的下载数、用户数等关键数据。

项目地址:VasDolly

VasDolly是一种快速多渠道打包工具,同时支持基于V1签名和V2签名进行多渠道打包。插件本身会自动检测Apk使用的签名类别,并选择合适的多渠道打包方式,对使用者来说完全透明。

基于V1签名的多渠道打包方案

根据之前的V1签名和校验机制可知,V1签名只会检验第一部分的所有压缩文件,而不理会后两部分内容。因此,只要把渠道信息写入到后两块内容就可以通过V1校验,而EOCD的注释字段无疑是最好的选择。

在APK文件的注释字段,添加渠道信息。 整个方案包括以下几步:

  1. 复制APK
  2. 找到EOCD数据块
  3. 修改注释长度
  4. 添加渠道信息
  5. 添加渠道信息长度
  6. 添加魔数:方便从后向前读取数据,定位渠道信息。

该方案的最大优点就是:不需要解压缩APK,不需要重新签名,只需要复制APK,在注释字段添加渠道信息。每个渠道包仅需几秒的耗时,非常适合渠道较多的APK。

基于V2签名的多渠道打包方案

V2签名块中的数据摘要是针对APK的文件内容块、中央目录和EOCD三块内容计算的。但是在写入签名块后,修改了EOCD中的中央目录偏移量,那么在进行V2签名校验时,理论上在“数据摘要校验”这步应该会校验失败。

Android系统在校验APK的数据摘要时,首先会把EOCD的中央目录偏移量替换成签名块的偏移量,然后再计算数据摘要。而签名块的偏移量就是v2签名之前的中央目录偏移量,因此,这样计算出的数据摘要就和“MF”数据块中的数据摘要完全一致了。

Android系统只会关注ID为0x7109871a的V2签名块,并且忽略其他的ID-Value,同时V2签名只会保护APK本身,不包含签名块。因此,基于V2签名的多渠道打包方案就应运而生:在APK签名块中添加一个ID-Value,存储渠道信息。整个方案包括以下几步:

  1. 找到APK的EOCD块
  2. 找到APK签名块
  3. 获取已有的ID-Value Pair
  4. 添加包含渠道信息的ID-Value
  5. 基于所有的ID-Value生成新的签名块
  6. 修改EOCD的中央目录的偏移量(上面已介绍过:修改EOCD的中央目录偏移量,不会导致数据摘要校验失败)
  7. 用新的签名块替代旧的签名块,生成带有渠道信息的APK

多渠道包的强校验

那么如何保证通过这些方案生成的渠道包,能够在所有Android平台上正确安装那?

Google提供了一个同时支持V1和V2签名和校验的工具:apksig。它包括一个apksigner命令行和一个apksig类库。其中前者就是Android SDK build-tools下面的命令行工具。正是借助后面的apksig来进行渠道包强校验,它可以保证渠道包在apk Minsdk ~ 最高版本之间都校验通过。

概述

构建流程

典型 Android 应用模块的构建流程通常依循下列步骤:

  1. 编译器将您的源代码转换成 DEX(Dalvik Executable) 文件(其中包括 Android 设备上运行的字节码),将所有其他内容转换成已编译资源。
  2. APK 打包器将 DEX 文件和已编译资源合并成单个 APK。 不过,必须先签署 APK,才能将应用安装并部署到 Android 设备上。
  3. APK 打包器使用调试或发布密钥库签署您的 APK:
  4. 在生成最终 APK 之前,打包器会使用 zipalign 工具对应用进行优化,减少其在设备上运行时占用的内存。

简要步骤:

  • Merged Manifest, Merged Resource, Merged Assets --> aapt --> R.java, Compiled Resource
  • R.java, Source Code --> Java Compiler --> .class files --> proguard --> proguarded.jar file --> dex --> .dex files
  • Compiled Resource, .dex files, .so files... --> apkbuilder --> .apk file --> sign --> sign.apk file --> zipalign

构建配置文件

Android Plugin for Gradle 引入了许多 DSL 元素,具体可参考:DSL 参考文档

settings.gradle 文件位于项目根目录,用于指示 Gradle 在构建应用时应将哪些模块包括在内。对大多数项目而言,该文件很简单,只包括以下内容:

1
include ':app'

顶级 build.gradle 文件位于项目根目录,用于定义适用于项目中所有模块的构建配置。 默认情况下,此顶级构建文件使用 buildscript 代码块来定义项目中所有模块共用的 Gradle 存储区和依赖项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* The buildscript block is where you configure the repositories and
* dependencies for Gradle itself—meaning, you should not include dependencies
* for your modules here. For example, this block includes the Android plugin for
* Gradle as a dependency because it provides the additional instructions Gradle
* needs to build Android app modules.
*/

buildscript {

/**
* The repositories block configures the repositories Gradle uses to
* search or download the dependencies. Gradle pre-configures support for remote
* repositories such as JCenter, Maven Central, and Ivy. You can also use local
* repositories or define your own remote repositories. The code below defines
* JCenter as the repository Gradle should use to look for its dependencies.
*
* New projects created using Android Studio 3.0 and higher also include
* Google's Maven repository.
*/

repositories {
google()
jcenter()
}

/**
* The dependencies block configures the dependencies Gradle needs to use
* to build your project. The following line adds Android plugin for Gradle
* version 3.4.2 as a classpath dependency.
*/

dependencies {
classpath 'com.android.tools.build:gradle:3.4.2'
}
}

/**
* The allprojects block is where you configure the repositories and
* dependencies used by all modules in your project, such as third-party plugins
* or libraries. However, you should configure module-specific dependencies in
* each module-level build.gradle file. For new projects, Android Studio
* includes JCenter and Google's Maven repository by default, but it does not
* configure any dependencies (unless you select a template that requires some).
*/

allprojects {
repositories {
google()
jcenter()
}
}

对于包含多个模块的 Android 项目,在项目级别定义某些属性,并在所有模块间共享这些属性可能会非常有用。为此,可以将额外属性添加到顶级 build.gradle 文件的 ext 代码块中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
buildscript {...}

allprojects {...}

// This block encapsulates custom properties and makes them available to all
// modules in the project.
ext {
// The following are only a few examples of the types of properties you can define.
compileSdkVersion = 28
// You can also create properties to specify versions for dependencies.
// Having consistent versions between modules can avoid conflicts with behavior.
supportLibVersion = "28.0.0"
...
}

要从相同项目中的模块访问这些属性,在模块的 build.gradle 文件中使用以下语法(虽然 Gradle 可在模块级别定义项目范围的属性,但应避免这样做,因为这样会导致共享这些属性的模块进行耦合)。

1
2
3
4
5
6
7
8
9
10
11
android {
// Use the following syntax to access properties you defined at the project level:
// rootProject.ext.property_name
compileSdkVersion rootProject.ext.compileSdkVersion
...
}
...
dependencies {
implementation "com.android.support:appcompat-v7:${rootProject.ext.supportLibVersion}"
...
}

模块级 build.gradle 文件位于各 project/module/ 目录中,用于配置适用于其所在模块的构建设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
/**
* The first line in the build configuration applies the Android plugin for
* Gradle to this build and makes the android block available to specify
* Android-specific build options.
*/

apply plugin: 'com.android.application'

/**
* The android block is where you configure all your Android-specific
* build options.
*/

android {

/**
* compileSdkVersion specifies the Android API level Gradle should use to
* compile your app. This means your app can use the API features included in
* this API level and lower.
*/

compileSdkVersion 28

/**
* buildToolsVersion specifies the version of the SDK build tools, command-line
* utilities, and compiler that Gradle should use to build your app. You need to
* download the build tools using the SDK Manager.
*
* This property is optional because the plugin uses a recommended version of
* the build tools by default.
*/

buildToolsVersion "29.0.0"

/**
* The defaultConfig block encapsulates default settings and entries for all
* build variants, and can override some attributes in main/AndroidManifest.xml
* dynamically from the build system. You can configure product flavors to override
* these values for different versions of your app.
*/

defaultConfig {

/**
* applicationId uniquely identifies the package for publishing.
* However, your source code should still reference the package name
* defined by the package attribute in the main/AndroidManifest.xml file.
*/

applicationId 'com.example.myapp'

// Defines the minimum API level required to run the app.
minSdkVersion 15

// Specifies the API level used to test the app.
targetSdkVersion 28

// Defines the version number of your app.
versionCode 1

// Defines a user-friendly version name for your app.
versionName "1.0"
}

/**
* The buildTypes block is where you can configure multiple build types.
* By default, the build system defines two build types: debug and release. The
* debug build type is not explicitly shown in the default build configuration,
* but it includes debugging tools and is signed with the debug key. The release
* build type applies Proguard settings and is not signed by default.
*/

buildTypes {

/**
* By default, Android Studio configures the release build type to enable code
* shrinking, using minifyEnabled, and specifies the Proguard settings file.
*/

release {
minifyEnabled true // Enables code shrinking for the release build type.
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}

/**
* The productFlavors block is where you can configure multiple product flavors.
* This allows you to create different versions of your app that can
* override the defaultConfig block with their own settings. Product flavors
* are optional, and the build system does not create them by default.
*
* This example creates a free and paid product flavor. Each product flavor
* then specifies its own application ID, so that they can exist on the Google
* Play Store, or an Android device, simultaneously.
*
* If you declare product flavors, you must also declare flavor dimensions
* and assign each flavor to a flavor dimension.
*/

flavorDimensions "tier"
productFlavors {
free {
dimension "tier"
applicationId 'com.example.myapp.free'
}

paid {
dimension "tier"
applicationId 'com.example.myapp.paid'
}
}

/**
* The splits block is where you can configure different APK builds that
* each contain only code and resources for a supported screen density or
* ABI. You'll also need to configure your build so that each APK has a
* different versionCode.
*/

splits {
// Settings to build multiple APKs based on screen density.
density {

// Enable or disable building multiple APKs.
enable false

// Exclude these densities when building multiple APKs.
exclude "ldpi", "tvdpi", "xxxhdpi", "400dpi", "560dpi"
}
}
}

/**
* The dependencies block in the module-level build configuration file
* specifies dependencies required to build only the module itself.
* To learn more, go to Add build dependencies.
*/

dependencies {
implementation project(":lib")
implementation 'com.android.support:appcompat-v7:28.0.0'
implementation fileTree(dir: 'libs', include: ['*.jar'])
}

Gradle 还包括两个属性文件,均位于项目根目录中,可用于指定适用于 Gradle 构建工具包本身的设置:

  • gradle.properties:可以在其中配置项目范围 Gradle 设置,例如 Gradle 后台进程的最大堆大小。 如需了解详细信息,请参阅构建环境。
  • local.properties:为构建系统配置本地环境属性,例如 SDK 安装路径。由于该文件的内容由 Android Studio 自动生成并且专用于本地开发者环境,因此不应手动修改该文件,或将其纳入版本控制系统。

Android Studio 按逻辑关系将每个模块的源代码和资源分组为源集。模块的 main/ 源集包括其所有构建变体使用的代码和资源。其他源集目录为可选项,在配置新的构建变体时,Android Studio 不会自动为您创建这些目录。不过,创建类似于 main/ 的源集有助于让 Gradle 仅在构建特定应用版本时才应使用的文件和资源井然有序:

  • src/main/:此源集包括所有构建变体共用的代码和资源。
  • src/buildType/:创建此源集可加入特定构建类型专用的代码和资源。
  • src/productFlavor/:创建此源集可加入特定产品风格专用的代码和资源。(注:如果配置构建以组合多个产品风格,则可为风格维度间产品风格的各个组合创建源集目录: src/productFlavor1ProductFlavor2/)
  • src/productFlavorBuildType/:创建此源集可加入特定构建变体专用的代码和资源。

例如,要生成应用的“完整调试”版本,构建系统需要合并来自以下源集的代码、设置和资源:

  • src/fullDebug/(构建变体源集)
  • src/debug/(构建类型源集)
  • src/full/(产品风格源集)
  • src/main/(主源集)

注:当在 Android Studio 中使用 File > New 菜单选项新建文件或目录时,可以针对特定源集进行创建。 可供您选择的源集取决于您的构建配置,如果所需目录尚不存在,Android Studio 会自动创建。

如果不同源集包含同一文件的不同版本,Gradle 将按以下优先顺序决定使用哪一个文件(左侧源集替换右侧源集的文件和设置):

  • 构建变体 > 构建类型 > 产品风格 > 主源集 > 库依赖项

这样一来,Gradle 便可使用专用于您试图构建的构建变体的文件,同时对与其他应用版本共用的 Activity、应用逻辑和资源加以重复利用。 在合并多个清单时,Gradle 使用同一优先顺序,这样每个构建变体都能在最终清单中定义不同的组件或权限。

android对象为我们提供了3个属性:

  • applicationVariants (仅仅适用于Android应用Gradle插件)
  • libraryVariants (仅仅适用于Android库Gradle插件)
  • testVariants (以上两种Gradle插件都使用)

SDK Version

compileSdkVersion

compileSdkVersion仅仅是告诉Gradle使用哪个版本的SDK编译应用,不会被包含到apk中,完全不影响应用的运行结果,关注compileSdkVersion版本的原因:

  • 应用想兼容新版本、使用了新版本API,此时就必须使用新版本及以上版本编译,否则就会编译报错;
  • 如果使用了新版本的Support Library,此时也必须使用新版本及以上版本编译;
  • 推荐使用最新版本编译,用新的编译检查,可以看到很多新版本相关的警告,提前预研新版本开发;

minSdkVersion

  1. minSdkVersion表明此应用兼容的最低版本,在低于该版本的手机上安装时会报错,无法安装;
  2. 如果最低版本设置为19,在代码中使用了API 23中的API,就会有警告。使用运行时检查系统版本的方式可解决;
  3. 如果使用的某个Support Library的最低版本为7,那minSdkVersion就必须大于等于7了,否则该Support Library在低于7的手机中就要报错了。

targetSdkVersion

  1. 如果targetSdkVersion为19(对应为Android4.4),应用运行时,最高只能使用API 19的新特性。即使代码中使用了API 23的新特性,实际运行时,也不会使用该新特性;
  2. 同样的API,比如AlarmManger的set()和get()方法,在API 19和之前的效果是不一样的,如果targetSdkVersion为18,无论运行手机是什么版本,都是旧效果;如果targetSdkVersion为19,那么在4.4以上的手机上运行时,就是新效果了。

总结

综上所诉,compileSdkVersion决定了编译期间能否使用新版本的API。targetSDKVersion决定了运行期间使用哪种特性。建议用较低的minSdkVersion来覆盖最大的人群,用最新的compileSdkVersion和targetSDKVersion来获得最好的外观和行为。即:maxSdkVersion >= buildToolsVersion >= compileSdkVersion>= targetSdkVersion >= minSdkVersion

Set the Application ID

设置Application ID

应用 ID 通过模块的 build.gradle 文件中的 applicationId 属性定义,如下所示:

1
2
3
4
5
6
7
8
9
10
android {
defaultConfig {
applicationId "com.example.myapp"
minSdkVersion 15
targetSdkVersion 24
versionCode 1
versionName "1.0"
}
...
}

应用 ID 和软件包名称彼此无关,可以更改代码的软件包名称(代码命名空间),这不会影响应用 ID,反之亦然。Application ID的命名规则的限制:

  • 必须至少包含两段(一个或多个圆点)。
  • 每段必须以字母开头。
  • 所有字符必须为字母数字或下划线 [a-zA-Z0-9_]。

注意:

  • 应用 ID 过去直接关联到代码的软件包名称;所以,有些 Android API 会在其方法名称和参数名称中使用“package name”一词,但这实际上是Application ID。例如,Context.getPackageName() 方法会返回您的应用 ID。
  • 使用 WebView的话,Application ID 中应将软件包名称用作前缀;否则,可能会遇到如问题 211768 中所述的问题。

更改用于编译变体的Application ID

每个编译变体应定义为单独的产品特性。对于 productFlavors 块中的每个类型,可以重新定义 applicationId 属性,也可以使用 applicationIdSuffix 在默认的应用 ID 上追加一段,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
android {
defaultConfig {
applicationId "com.example.myapp"
}
productFlavors {
free {
applicationIdSuffix ".free"
}
pro {
applicationIdSuffix ".pro"
}
}
}

也可以根据自己的版本类型使用 applicationIdSuffix 追加一段,如下所示:

1
2
3
4
5
6
7
8
android {
...
buildTypes {
debug {
applicationIdSuffix ".debug"
}
}
}

由于 Gradle 会在产品特性后面应用版本类型配置,因此“free debug”编译变体的应用 ID 现在是“com.example.myapp.free.debug”。

注意:

  • 为了与以前的 SDK 工具兼容,如果未在 build.gradle 文件中定义 applicationId 属性,构建工具会将 AndroidManifest.xml 文件中的软件包名称用作应用 ID。在这种情况下,重构软件包名称也会更改您的应用 ID。
  • 如果需要在清单文件中引用应用 ID,可以在任何清单属性中使用 ${applicationId} 占位符。在编译期间,Gradle 会将此标记替换为实际的应用 ID。

更改用于测试的应用 ID

默认情况下,构建工具会将应用 ID 应用到您的测试 APK,该 APK 将应用 ID 用于给定的编译变体,同时追加 .test。例如,com.example.myapp.free 编译变体的测试 APK 的应用 ID 为 com.example.myapp.free.test。

可以通过在 defaultConfig 或 productFlavor 块中定义 testApplicationId 属性来更改应用 ID,不过应该没有必要这样做。

注意:为了避免与受测应用发生名称冲突,构建工具会为您的测试 APK 生成 R 类,其命名空间基于测试应用 ID,而不是清单文件中定义的软件包名称。

更改软件包名称

默认情况下,项目的软件包名称与应用 ID 匹配,但您可以更改软件包名称。不过,如果您要更改软件包名称,需要注意的是,软件包名称(由项目目录结构定义)应始终与 AndroidManifest.xml 文件中的 package 属性匹配,如下所示:

1
2
3
4
5
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp"
android:versionCode="1"
android:versionName="1.0" >

Android 构建工具使用 package 属性来发挥两种作用:

  • 它会将此名称用作应用生成的 R.java 类的命名空间。示例:对于上面的清单,R 类将为 com.example.myapp.R。
  • 它会使用此名称解析清单文件中声明的任何相关类名。示例:对于上面的清单,声明为 <activity android:name=".MainActivity"> 的 Activity 将解析为 com.example.myapp.MainActivity。

因此,package 属性中的名称应始终与项目的基础软件包名称匹配,基础软件包中保存着您的 Activity 及其他应用代码。当然,您的项目中可以包含子软件包,但是这些文件必须从 package 属性导入使用命名空间的 R.java 类,而且清单中声明的任何应用组件都必须添加缺失的子软件包名称(或者使用完全限定软件包名称)。

如果您要完全重构您的软件包名称,请确保也更新 package 属性。只要您使用 Android Studio 的工具重命名和重构您的软件包,那么这些就会自动保持同步。(如果它们未保持同步,您的应用代码将无法解析 R 类,因为它不再位于同一软件包中,并且清单无法识别您的 Activity 或其他组件。)

您必须始终在项目的主 AndroidManifest.xml 文件中指定 package 属性。如果您有其他清单文件(如产品特性或版本类型的清单文件),请注意,优先级最高的清单文件提供的软件包名称始终用于最终合并的清单。

还有一点需要了解:虽然清单 package 和 Gradle applicationId 可以具有不同的名称,但构建工具会在编译结束时将应用 ID 复制到 APK 的最终清单文件中。所以,如果您在编译后检查 AndroidManifest.xml 文件,发现 package 属性发生更改就不足为奇了。实际上,Google Play 商店和 Android 平台会查看 package 属性来识别您的应用。所以,编译系统利用原始值(设置 R 类的命名空间并解析清单类名称)后,它会舍弃该值并将其替换为应用 ID。

Add the dependencies

指定依赖项时,不应使用动态版本号,比如 'com.android.tools.build:gradle:3.+'。 使用此功能,可能会导致意外版本更新和难以解析版本差异。

依赖项类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apply plugin: 'com.android.application'

android { ... }

dependencies {
// Dependency on a local library module
implementation project(":mylibrary")

// Dependency on local binaries
implementation fileTree(dir: 'libs', include: ['*.jar'])

// Dependency on a remote binary
implementation 'com.example.android:app-magic:12.3'
}

本地库模块依赖项

1
implementation project(':mylibrary')

这段代码声明名为“mylibrary”的 Android 库模块的依赖项(该名称必须匹配使用 settings.gradle 文件中的 include: 定义的库名称)。在构建应用时,构建系统会编译库模块,并将生成的编译内容打包到 APK中。

本地二进制文件依赖项

1
implementation fileTree(dir: 'libs', include: ['*.jar'])

Gradle 声明项目 module_name/libs/ 目录中 JAR 文件的依赖项(因为 Gradle 会读取 build.gradle 文件的相对路径)。或者,也可以像下面这样指定单独的文件:

1
implementation files('libs/foo.jar', 'libs/bar.jar')

远程二进制文件依赖项

1
implementation 'com.example.android:app-magic:12.3'

以上代码实际上是下列代码的缩写形式:

1
implementation group: 'com.example.android', name: 'app-magic', version: '12.3'

这段代码声明com.example.android命名空间组内“app-magic”库 12.3 版本的依赖项。

注:与此类似的远程依赖项要求您声明相应的远程代码库,Gradle 应在其中寻找该库。如果本地尚不存在该库,Gradle 会在构建需要它时(例如,当您点击 Sync Project with Gradle Files 或当您运行构建时)从远程站点获取该库。

依赖项配置

新配置 已弃用配置 行为
implementation compile Gradle 会将依赖项添加到编译类路径,并将依赖项打包到构建输出。但是,当您的模块配置 implementation 依赖项时,会告知 Gradle 您不想模块在编译时将依赖项泄露给其他模块。也就是说,依赖项只能在运行时供其他模块使用。使用此依赖项配置而不是api 或 compile(已弃用),可以显著缩短构建时间,因为它可以减少构建系统需要重新编译的模块数量。例如,如果 implementation 依赖项更改了其 API,Gradle 只会重新编译该依赖项和直接依赖它的模块。大多数应用和测试模块都应使用此配置。
api compile Gradle 会将依赖项添加到编译类路径,并构建输出。当模块包括 api 依赖项时,会告知 Gradle 模块想将该依赖项间接导出至其他模块,以使这些模块在运行时和编译时均可使用该依赖项。此配置的行为类似于 compile (现已弃用),但您应仅对需要间接导出至其他上游消费者的依赖项慎重使用它。 这是因为,如果 api 依赖项更改了其外部 API,Gradle 会重新编译可以在编译时访问该依赖项的所有模块。 因此,拥有大量 api 依赖项会显著增加构建时间。 如果不想向不同的模块公开依赖项的 API,库模块应改用 implementation 依赖项。
compileOnly provided Gradle 只会将依赖项添加到编译类路径(即不会将其添加到构建输出)。如果是创建 Android 模块且在编译期间需要使用该依赖项,在运行时可选择呈现该依赖项,则此配置会很有用。如果使用此配置,则您的库模块必须包含运行时条件,以便检查是否提供该依赖项,然后妥善更改其行为,以便模块在未提供依赖项的情况下仍可正常工作。这样做不会添加不重要的瞬时依赖项,有助于缩减最终 APK 的大小。 此配置的行为类似于 provided (现已弃用)。
runtimeOnly apk Gradle 只会将依赖项添加到构建输出,供运行时使用。也就是说,不会将其添加到编译类路径。 此配置的行为类似于 apk(现已弃用)。
annotationProcessor compile 要在库中添加注解处理器依赖项,则必须使用 annotationProcessor 配置将其添加到注解处理器类路径。这是因为使用此配置可分离编译类路径与注解处理器类路径,从而提升构建性能。如果 Gradle 在编译类路径上找到注解处理器,则会停用 避免编译功能,这样会增加构建时间(Gradle 5.0 和更高版本会忽略编译类路径上的注解处理器)。如果 JAR 文件包含以下文件,则 Android Gradle Plugin 会假定依赖项是注解处理器:META-INF/services/javax.annotation.processing.Processor。如果插件检测到编译类路径上包含注解处理器,则会生成构建错误。

以上配置适用于您的项目的主源集,该源集应用于所有构建不同类型。 如果您改为只想为特定构建不同类型源集或测试源集声明依赖项,则必须大写配置名称并在其前面加上构建不同类型或测试源集的名称作为前缀。

例如,要仅将 implementation 依赖项添加到您的“free”产品风格(使用远程二进制文件依赖项),需要使用下面这样的代码:

1
2
3
dependencies {
freeImplementation 'com.google.firebase:firebase-ads:9.8.0'
}

但如果想要为组合产品风格和构建类型的变体添加依赖项,则必须在 configurations 代码块中初始化配置名称。 以下示例向您的“freeDebug”构建变体添加 runtimeOnly 依赖项(使用本地二进制文件依赖项):

1
2
3
4
5
6
7
8
9
configurations {
// Initializes a placeholder for the freeDebugRuntimeOnly dependency
// configuration.
freeDebugRuntimeOnly {}
}

dependencies {
freeDebugRuntimeOnly fileTree(dir: 'libs', include: ['*.jar'])
}

要为您的本地测试和设备化测试添加 implementation 依赖项,需要使用下面这样的代码:

1
2
3
4
5
6
7
dependencies {
// Adds a remote binary dependency only for local tests.
testImplementation 'junit:junit:4.12'

// Adds a remote binary dependency only for the instrumented test APK.
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

但某些配置在这种情况下没有意义。 例如,由于其他模块无法依赖 androidTest,因此如果使用 androidTestApi 配置,则会收到以下警告:

1
WARNING: Configuration 'androidTestApi' is obsolete and has been replaced with 'androidTestImplementation'.

添加注解处理器

如果将注解处理器添加到您的编译类路径,您将看到一条与以下消息类似的错误消息:

1
Error: Annotation processors must be explicitly declared now.

要解决此错误问题,请使用 annotationProcessor 配置您的依赖项,以在您的项目中添加注解处理器,如下所示:

1
2
3
4
5
6
dependencies {
// Adds libraries defining annotations to only the compile classpath.
compileOnly 'com.google.dagger:dagger:version-number'
// Adds the annotation processor dependency to the annotation processor classpath.
annotationProcessor 'com.google.dagger:dagger-compiler:version-number'
}

如果需要向注解处理器传递参数,您可以在您的模块构建配置中使用 AnnotationProcessorOptions 代码块。 例如,如果要以键值对形式传递原始数据类型,则可使用 argument 属性,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
android {
...
defaultConfig {
...
javaCompileOptions {
annotationProcessorOptions {
argument "key1", "value1"
argument "key2", "value2"
}
}
}
}

但在使用 Android Gradle Plugin 3.2.0 和更高版本时,您需要使用 Gradle CommandLineArgumentProvider 接口传递表示文件或目录的处理器参数。使用 CommandLineArgumentProvider 可让您或注解处理器作者将增量构建属性类型注解应用于每个参数,从而提高增量构建和缓存干净构建的正确性和性能。

例如,下面的类可实现 CommandLineArgumentProvider 并注解处理器的每个参数。 此外,此示例也使用 Groovy 语言语法,且直接包含在模块的 build.gradle 文件中。

注:通常,注解处理器作者会提供此类或有关如何编写这种类的说明。这是因为每个参数均需指定正确的构建属性类型注解,才能按预期运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class MyArgsProvider implements CommandLineArgumentProvider {

// Annotates each directory as either an input or output for the
// annotation processor.
@InputFiles
// Using this annotation helps Gradle determine which part of the file path
// should be considered during up-to-date checks.
@PathSensitive(PathSensitivity.RELATIVE)
FileCollection inputDir

@OutputDirectory
File outputDir

// The class constructor sets the paths for the input and output directories.
MyArgsProvider(FileCollection input, File output) {
inputDir = input
outputDir = output
}

// Specifies each directory as a command line argument for the processor.
// The Android plugin uses this method to pass the arguments to the
// annotation processor.
@Override
Iterable<String> asArguments() {
// Use the form '-Akey[=value]' to pass your options to the Java compiler.
["-AinputDir=${inputDir.singleFile.absolutePath}",
"-AoutputDir=${outputDir.absolutePath}"]
}
}

android {...}

在创建实现 CommandLineArgumentProvider 的类后,您需要使用 annotationProcessorOptions.compilerArgumentProvider 属性初始化并将其传递至 Android 插件,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// This is in your module's build.gradle file.
android {
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
// Creates a new MyArgsProvider object, specifies the input and
// output paths for the constructor, and passes the object
// to the Android plugin.
compilerArgumentProvider new MyArgsProvider(files("input/path"),
new File("output/path"))
}
}
}
}

如果编译类路径中的依赖项包含您不需要的注解处理器,您可以将以下代码添加到 build.gradle 文件中,停用错误检查。 请记住,您添加到编译类路径中的注解处理器仍不会添加到处理器类路径中。

1
2
3
4
5
6
7
8
9
10
11
android {
...
defaultConfig {
...
javaCompileOptions {
annotationProcessorOptions {
includeCompileClasspath false
}
}
}
}

如果您将项目的注解处理器迁移到处理器类路径后遇到问题,可通过将 includeCompileClasspath 设置为 true,允许编译类路径上包含注解处理器。 但是,我们不建议将此属性设置为 true,并且我们将在以后的 Android plugin 更新版本中移除这种操作的相关选项。

排除传递依赖项

随着应用范围的扩大,其中可包含许多依赖项,包括直接依赖项和传递依赖项(应用的导入库所依赖的库)。 要排除不再需要的传递依赖项,您可以使用 exclude 关键字,如下所示:

1
2
3
4
5
dependencies {
implementation('some-library') {
exclude group: 'com.example.imgtools', module: 'native'
}
}

如果需要从您的测试中排除某些传递依赖项,上文所示的代码示例可能无法按预期发挥作用。 这是因为测试配置(例如 androidTestImplementation)扩展了模块的 implementation 配置。 也就是说,在 Gradle 解析配置时其中始终包含 implementation 依赖项。

因此,要从测试中排除传递依赖项,必须在执行代码时执行此操作,如下所示:

1
2
3
4
android.testVariants.all { variant ->
variant.getCompileConfiguration().exclude group: 'com.jakewharton.threetenabp', module: 'threetenabp'
variant.getRuntimeConfiguration().exclude group: 'com.jakewharton.threetenabp', module: 'threetenabp'
}

使用 variant-aware 依赖项管理

Android 插件 3.0.0 及更高版本包含一项新的依赖项机制,这种机制可以在消费库时自动匹配不同类型。 也就是说,应用的 debug 不同类型将自动消费库的 debug 不同类型,依此类推。 这种机制也适用于使用风格的情况—应用的 freeDebug 变体将使用库的 freeDebug 变体。

要让插件准确匹配变体,您需要为无法直接匹配的情况提供匹配回退。 假设您的应用配置一个名为“staging”的构建类型,但其库依赖项之一没有进行相应配置。 在插件尝试构建您的“staging”版本的应用时,它将无法了解库要使用哪一个版本,您将看到一条类似以下消息的错误消息:

1
2
3
Error:Failed to resolve: Could not resolve project :mylibrary.
Required by:
project :app

远程代码库

如果您的依赖项并非本地库或文件树,Gradle 会在您的 build.gradle 文件 repositories 程序块中指定的任何一个在线代码库中寻找文件。 列出各代码库的顺序决定了 Gradle 在这些代码库中搜索各项目依赖项的顺序。 例如,如果代码库 A 和 B 都提供某依赖项,而您先列出代码库 A,则 Gradle 会从代码库 A 下载此依赖项。

默认情况下,Android Studio 新项目会在项目的顶级 build.gradle 文件中指定 Google 的 Maven 代码库和 JCenter 作为代码库位置,如下所示:

1
2
3
4
5
6
allprojects {
repositories {
google()
jcenter()
}
}

如果您需要的内容来自 Maven 中央代码库,则添加 mavenCentral();如果来自本地代码库,则使用 mavenLocal(),或者也可像下面这样声明特定 Maven 或 Ivy 代码库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
allprojects {
repositories {
google()
jcenter()
mavenCentral()
mavenLocal()
maven {
url "https://repo.example.com/maven2"
}
maven {
url "file://local/repo/"
}
ivy {
url "https://repo.example.com/ivy"
}
}
}

依赖项顺序

例如,如果您的项目声明以下内容:

  • LIB_A 和 LIB_B 上的依赖项(按照该顺序)
  • 并且 LIB_A 依赖 LIB_C 和 LIB_D (按照该顺序)
  • 并且 LIB_B 还依赖 LIB_C

然后,扁平型依赖项顺序将如下所示:

  • LIB_A
  • LIB_D
  • LIB_B
  • LIB_C

查看依赖项树

运行gradle androidDependencies即可查看依赖项树。

修复依赖项解析错误

修复重复类错误

如果某类多次出现在运行时类路径中,您会收到一条与以下内容相似的错误:

1
Program type already present com.example.MyClass

该错误通常是下列其中一种情况所致:

  • 二进制文件依赖项包括您的应用同时作为直接依赖项包括的库。 例如,您的应用在库 A 和库 B 上声明了直接依赖项,但库 A 的二进制文件中已包括库 B:要解决此问题,请取消将库 B 作为直接依赖项。
  • 您的应用在同一库上具有本地二进制文件依赖项和远程二进制文件依赖项:要解决此问题,请移除其中一个二进制文件依赖项。

解决类路径之间的冲突问题

当 Gradle 解析编译类路径时,会先解析运行时类路径,然后使用此结果确定应添加到编译类路径的依赖项版本。 换言之,运行时类路径决定下游类路径的相同依赖项所需的版本号。

应用的运行时类路径还决定 Gradle 匹配运行类路径中应用测试 APK 的依赖项所需要的版本号:

如果相同依赖项的冲突版本出现在多个类路径中,您可能会看到与以下内容相似的错误:

1
2
Conflict with dependency 'com.example.library:some-lib:2.0' in project 'my-library'.
Resolved versions for runtime classpath (1.0) and compile classpath (2.0) differ.

例如,当您的应用使用 implementation 依赖项配置加入某依赖项版本,并且库模块使用 runtimeOnly 配置加入此依赖项的不同版本时,可能会发生该冲突。 要解决此问题,请执行以下其中一项操作:

  • 将所需版本的依赖项作为 api 依赖项加入您的库模块。 也就是说,仅库模块声明此依赖项,但应用模块也可间接访问其 API。
  • 或者,您也可以同时在两个模块中声明此依赖项,但应确保每个模块使用的版本相同。 请考虑配置项目范围的属性,以确保各依赖项的多个版本在整个项目中都保持一致。

应用自定义构建逻辑

本节介绍的内容在您想要扩展 Android Gradle Plugin 或编写自己的插件时很有用。

为自定义逻辑发布变体依赖项

库可以包含其他项目或子项目可能要使用的功能。 发布库是为其消费者提供库的流程。 库可以控制其消费者在编译时和运行时可访问的依赖项。现有两种不同的配置,其中包含消费者为使用库而必须使用的各类路径的传递依赖项,如下所述:

1
2
variant_nameApiElements:此配置包含编译时消费者可使用的传递依赖项。
variant_nameRuntimeElements:此配置包含运行时消费者可使用的传递依赖项。

自定义依赖项解析策略

项目包含的依赖项可能包含在相同库的两个不同版本中,这样会导致依赖项冲突。例如,如果您的项目依赖于模块 A 的版本 1 和模块 B 的版本 2,模块 A 间接依赖于模块 B 的版本 3,则会出现依赖项版本冲突。

要解决此冲突问题,Android Gradle Plugin 需使用以下依赖项解析策略:当插件检测到依赖图中包含相同模块的不同版本时,会默认选择版本最高的模块。但此策略可能无法按预期发挥作用。 要自定义依赖项解析策略,请使用以下配置解析您任务所需变体的特定依赖项:

1
2
variant_nameCompileClasspath:此配置包含适用于给定变体编译类路径的解析策略。
variant_nameRuntimeClasspath:此配置包含适用于给定变体运行时类路径的解析策略。

Android Gradle Plugin 包含可用于访问各变体配置对象的 getter。 因此,您可以使用变体 API 查询依赖项解析策略,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
android {
applicationVariants.all { variant ->
// Return compile configuration objects of a variant.
variant.getCompileConfiguration().resolutionStrategy {
// Use Gradle's ResolutionStrategy API
// to customize how this variant resolves dependencies.
...
}
// Return runtime configuration objects of a variant.
variant.getRuntimeConfiguration().resolutionStrategy {
...
}
// Return annotation processor configuration of a variant.
variant.getAnnotationProcessorConfiguration().resolutionStrategy {
...
}
}
}

配置编译变体

配置版本类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
android {
defaultConfig {
manifestPlaceholders = [hostName:"www.example.com"]
...
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}

debug {
applicationIdSuffix ".debug"
debuggable true
}

/**
* The `initWith` property allows you to copy configurations from other build types,
* then configure only the settings you want to change. This one copies the debug build
* type, and then changes the manifest placeholder and application ID.
*/
staging {
initWith debug
manifestPlaceholders = [hostName:"internal.example.com"]
applicationIdSuffix ".debugStaging"
}
}
}

配置产品特性

配置产品特性

创建产品特性与创建版本类型类似:将其添加到编译配置中的 productFlavors 代码块并添加所需的设置。产品特性支持与 defaultConfig 相同的属性,这是因为 defaultConfig 实际上属于 ProductFlavor 类。这意味着,您可以在 defaultConfig 代码块中为所有类型提供基本配置,并且每个类型都可以更改其中任何默认值.

所有类型都必须属于一个指定的类型维度,即一个产品特性组。即使您打算只使用一个维度,也必须将类型分配到类型维度.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
android {
...
defaultConfig {...}
buildTypes {
debug{...}
release{...}
}
// Specifies one flavor dimension.
flavorDimensions "version"
productFlavors {
demo {
// Assigns this product flavor to the "version" flavor dimension.
// This property is optional if you are using only one dimension.
dimension "version"
applicationIdSuffix ".demo"
versionNameSuffix "-demo"
}
full {
dimension "version"
applicationIdSuffix ".full"
versionNameSuffix "-full"
}
}
}

将多个产品特性与类型维度结合使用

在编译应用时,Gradle 会结合使用您定义的每个类型维度的产品特性配置以及版本类型配置,以创建最终的编译变体。Gradle 不会将属于同一类型维度的产品特性组合在一起。

以下代码示例使用 flavorDimensions 属性来创建“mode”类型维度和“api”类型维度,前者用于将“full”和“demo”产品特性进行分组,后者用于根据 API 级别对产品特性配置进行分组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
android {
...
buildTypes {
debug {...}
release {...}
}

// Specifies the flavor dimensions you want to use. The order in which you
// list each dimension determines its priority, from highest to lowest,
// when Gradle merges variant sources and configurations. You must assign
// each product flavor you configure to one of the flavor dimensions.
flavorDimensions "api", "mode"

productFlavors {
demo {
// Assigns this product flavor to the "mode" flavor dimension.
dimension "mode"
...
}

full {
dimension "mode"
...
}

// Configurations in the "api" product flavors override those in "mode"
// flavors and the defaultConfig block. Gradle determines the priority
// between flavor dimensions based on the order in which they appear next
// to the flavorDimensions property above--the first dimension has a higher
// priority than the second, and so on.
minApi24 {
dimension "api"
minSdkVersion 24
// To ensure the target device receives the version of the app with
// the highest compatible API level, assign version codes in increasing
// value with API level. To learn more about assigning version codes to
// support app updates and uploading to Google Play, read Multiple APK Support
versionCode 30000 + android.defaultConfig.versionCode
versionNameSuffix "-minApi24"
...
}

minApi23 {
dimension "api"
minSdkVersion 23
versionCode 20000 + android.defaultConfig.versionCode
versionNameSuffix "-minApi23"
...
}

minApi21 {
dimension "api"
minSdkVersion 21
versionCode 10000 + android.defaultConfig.versionCode
versionNameSuffix "-minApi21"
...
}
}
}

以上面的编译配置为例,Gradle 使用以下命名方案创建了总共 12 个编译变体:

  • 编译变体:[minApi24, minApi23, minApi21][Demo, Full][Debug, Release]
  • 对应的 APK:app-[minApi24, minApi23, minApi21]-[demo, full]-[debug, release].apk

除了可以为各个产品特性和编译变体创建源集目录外,您还可以为每个产品特性组合创建源集目录。例如,您可以创建 Java 源文件并将其添加到 src/demoMinApi24/java/ 目录中,这样 Gradle 就只会在编译同时对应这两种产品特性的变体时才使用这些源文件。您为产品特性组合创建的源集的优先级高于属于各个产品特性的源集。

过滤变体

Gradle 会为您配置的产品特性和版本类型的每种可能组合创建编译变体。但是,某些编译变体可能并不是您需要的,或者在项目上下文中没有意义。您可以通过在模块级 build.gradle 文件中创建变体过滤器来移除某些编译变体配置。

以上一部分中的编译配置为例,假设您打算让“demo”版应用仅支持 API 级别 23 及更高级别。您可以使用 variantFilter 代码块过滤掉所有将“minApi21”和“demo”产品特性组合在一起的编译变体配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
android {
...
buildTypes {...}

flavorDimensions "api", "mode"
productFlavors {
demo {...}
full {...}
minApi24 {...}
minApi23 {...}
minApi21 {...}
}

variantFilter { variant ->
def names = variant.flavors*.name
// To check for a certain build type, use variant.buildType.name == "<buildType>"
if (names.contains("minApi21") && names.contains("demo")) {
// Gradle ignores any variants that satisfy the conditions above.
setIgnore(true)
}
}
}

维度回退

情况1:app中有某个build type但module中没有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// In the app's build.gradle file.
android {
buildTypes {
debug {}
release {}
staging {
// 下面[]中的qa、debug、release是module中配置的buildType,必须含有其中一个或更多,
// 若module中buildType没有staging,gradle会根据matchingFallbacks的配置,
// 依次按顺序去匹配
// 注意:module与module之间存在依赖关系的话,也要在特定的build types中指定匹配关系
matchingFallbacks = ['qa', 'debug', 'release']
}
}
}

注意:module中有但app中没有的build type是不会报错的,因为gradle插件根本不会去module中请求build type。

情况2:在app和它的module中都有同一个维度(比如:flavorDimensions ‘tier’),但你的app有的flavors在module中没有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
flavorDimensions 'tier'
productFlavors {
paid {
// 因为依赖app的module在'tier'维度下也有'paid'这个flavor,所以你不用去管,
// gradle会自动为你匹配
dimension 'tier'
}
free {
// 因为module在'tier'维度下没有'free'这个flavor,所以需要指定matchingFallbacks
// 让gradle知道怎么去匹配
// 像下面这样配置,gradle会按顺序依次去匹配module中'tier'维度下的flavor,
// 直到匹配到,否则会报错
matchingFallbacks = ['demo', 'trial']
}
}

注意:对于在同一个维度下,module中有的flavors但app中没有是不会报错的,因为gradle插件根本不会去module中请求flavors。

情况3:module中有某个dimension维度,但app中没有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// In the app's build.gradle file.
android {
defaultConfig{
// 下面这句话告诉gradle,当遇到一个module中有个app中没有的'minApi'维度时,
// 它应该按照下面这个顺序去匹配module中这个维度的flavors
missingDimensionStrategy 'minApi', 'minApi18', 'minApi23'
// 若其他module中还有更多app中没有的维度,你必须为所有的维度定义回退策略
missingDimensionStrategy 'abi', 'x86', 'arm64'
}
flavorDimensions 'tier'
productFlavors {
free {
dimension 'tier'
// 你可以在一个特定的flavor中覆盖defaultConfig的配置
missingDimensionStrategy 'minApi', 'minApi23', 'minApi18'
}
paid { }
}
}

注意:当一个维度app中有但module中没有的时候是不会报错,因为gradle插件只会匹配已经在module中存在的维度,比如module中没有abi这个维度,当app为freeX86Debug时,你的module就用freeDebug。

情况4:若module中没有某个dimension,则app不需要在这个dimension下做任何处理。

创建源集

创建源集

默认情况下,Android Studio 会为您希望在所有编译变体之间共享的所有内容创建 main/ 源集和目录。但是,您可以创建新的源集来精确控制 Gradle 为特定版本类型、产品特性(以及使用类型维度时的产品特性组合)和编译变体编译和打包的文件。例如,您可以在 main/ 源集中定义基本功能,并使用产品特性源集来为不同客户端更改应用的品牌,或仅为使用“debug”版本类型的编译变体添加特殊权限和日志记录功能。

Gradle 要求您以某种类似于 main/ 源集的方式组织源集文件和目录。例如,Gradle 要求将“debug”版本类型特有的 Java 类文件放在 src/debug/java/ 目录中。

可通过gradle sourceSets查看不同变体期望的源集路径;可通过Android Studio自带功能创建源集。

更改默认源集配置

可以使用sourceSets修改默认源集路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
android {
...
sourceSets {
// Encapsulates configurations for the main source set.
main {
// Changes the directory for Java sources. The default directory is
// 'src/main/java'.
java.srcDirs = ['other/java']

// If you list multiple directories, Gradle uses all of them to collect
// sources. Because Gradle gives these directories equal priority, if
// you define the same resource in more than one directory, you get an
// error when merging resources. The default directory is 'src/main/res'.
res.srcDirs = ['other/res1', 'other/res2']

// Note: You should avoid specifying a directory which is a parent to one
// or more other directories you specify. For example, avoid the following:
// res.srcDirs = ['other/res1', 'other/res1/layouts', 'other/res1/strings']
// You should specify either only the root 'other/res1' directory, or only the
// nested 'other/res1/layouts' and 'other/res1/strings' directories.

// For each source set, you can specify only one Android manifest.
// By default, Android Studio creates a manifest for your main source
// set in the src/main/ directory.
manifest.srcFile 'other/AndroidManifest.xml'
...
}

// Create additional blocks to configure other source sets.
androidTest {

// If all the files for a source set are located under a single root
// directory, you can specify that directory using the setRoot property.
// When gathering sources for the source set, Gradle looks only in locations
// relative to the root directory you specify. For example, after applying the
// configuration below for the androidTest source set, Gradle looks for Java
// sources only in the src/tests/java/ directory.
setRoot 'src/tests'
...
}
}
}

使用源集编译

可以使用源集目录来添加只希望与某些配置打包在一起的代码和资源。例如,如果您要编译“demoDebug”这个变体(“demo”产品特性和“debug”版本类型的混合产物),则 Gradle 会查看这些目录,并为它们指定以下优先级:

  1. src/demoDebug/(编译变体源集)
  2. src/debug/(版本类型源集)
  3. src/demo/(产品特性源集)
  4. src/main/(主源集)

注意:如果您结合使用多个产品特性,那么这些产品特性的优先级由它们所属的类型维度决定。使用 android.flavorDimensions 属性列出类型维度时,属于您列出的第一个类型维度的产品特性的优先级高于属于第二个类型维度的产品特性,依此类推。此外,您为产品特性组合创建的源集的优先级高于属于各个产品特性的源集。

上面列出的顺序决定了 Gradle 组合代码和资源时哪个源集的优先级更高。由于 demoDebug/ 源集目录可能包含该编译变体特有的文件,因此,如果 demoDebug/ 包含在 debug/ 中也定义了的文件,则 Gradle 会使用 demoDebug/ 源集中的文件。类似地,Gradle 会为版本类型和产品特性源集中的文件提供比 main/ 中的相同文件更高的优先级。在应用以下编译规则时,Gradle 会考虑这种优先顺序:

  • java/ 目录中的所有源代码将一起编译以生成单个输出。
    注意:对于给定的编译变体,如果 Gradle 遇到两个或更多个源集目录定义了同一个 Java 类的情况,则会抛出编译错误。例如,在编译调试 APK 时,您不能同时定义 src/debug/Utility.java 和 src/main/Utility.java。这是因为 Gradle 在编译过程中会查看这两个目录并抛出“重复类”错误。如果您要为不同的版本类型使用不同版本的 Utility.java,则可以让每个版本类型定义各自的文件版本,而不是将其包含在 main/ 源集中。
  • 所有清单都将合并为一个清单,优先级将按照上面列出的顺序提供。也就是说,版本类型的清单设置会替换产品特性的清单设置,依此类推。
  • 同样,values/ 目录中的文件也会合并在一起。如果两个文件(如两个 strings.xml 文件)的名称相同,将按照上面列表中的顺序指定优先级。也就是说,在版本类型源集的文件中定义的值会重写在产品特性的同一文件中定义的值,依此类推。
  • res/ 和 asset/ 目录中的资源会打包在一起。如果在两个或更多个源集中定义了同名的资源,将按照上面列表中的顺序指定优先级。
  • 最后,在编译 APK 时,Gradle 会为库模块依赖项随附的资源和清单指定最低优先级。

声明依赖项

可以为特定编译变体或测试源集配置依赖项,方法是在 Implementation 关键字前面加上编译变体或测试源集的名称作为前缀,如以下示例所示。

1
2
3
4
5
6
7
8
9
10
dependencies {
// Adds the local "mylibrary" module as a dependency to the "free" flavor.
freeImplementation project(":mylibrary")

// Adds a remote binary dependency only for local tests.
testImplementation 'junit:junit:4.12'

// Adds a remote binary dependency only for the instrumented test APK.
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

配置签名设置

除非您明确定义此版本的签名配置,否则 Gradle 不会为该版本的 APK 签名。您可以轻松创建发布密钥并使用 Android Studio 为发布版本类型签名。要使用 Gradle 编译配置为您的发布版本类型手动配置签名,请执行以下操作:

  1. 创建一个密钥库。密钥库是一个包含一组私钥的二进制文件。您必须将密钥库保存在安全可靠的地方。
  2. 创建一个私钥。私钥代表将通过应用识别的实体,如个人或公司。
  3. 将签名配置添加到模块级 build.gradle 文件中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
android {
...
defaultConfig {...}
signingConfigs {
release {
storeFile file("myreleasekey.keystore")
storePassword "password"
keyAlias "MyReleaseKey"
keyPassword "password"
}
}
buildTypes {
release {
...
signingConfig signingConfigs.release
}
}
}

注意:在编译文件中添加发布密钥和密钥存储区的密码并不是一种好的安全做法。作为替代方案,您可以配置编译文件以从环境变量获取这些密码,或让编译流程提示您输入这些密码。

要从环境变量获取这些密码,请编写以下代码:

1
2
storePassword System.getenv("KSTOREPWD")
keyPassword System.getenv("KEYPWD")

要让编译流程在您要从命令行调用此编译时提示您输入这些密码,请编写以下代码:

1
2
storePassword System.console().readLine("\nKeystore password: ")
keyPassword System.console().readLine("\nKey password: ")

警告:请将密钥库和私钥保存在安全可靠的地方,并确保您为其创建了安全的备份。如果您将应用发布到 Google Play,随后丢失了用于为应用签名的密钥,那么您将无法向您的应用发布任何更新,因为您必须始终使用相同的密钥为应用的所有版本签名。

注意:当buildType.debug中没有指定signingConfig时,即使productFlavors中提供了签名配置,也会默认使用Android Studio提供的签名,因此如果要使debug下的flavor签名生效,需要指定debug的signingConfig为null,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def flavorSigns = [
"base" : readSigningConfig(file('../signing1.properties')),
"flavorA": readSigningConfig(file('../signing2.properties')),
]

android {
signingConfigs {
flavorSigns.forEach { key, value ->
"$key" {
keyAlias value.keyAlias
keyPassword value.keyPassword
storeFile value.storeFile
storePassword value.storePassword
}
}
}
defaultConfig {
signingConfig signingConfigs.base
}
buildTypes {
release {
// ...
}
debug {
// ...
// Android Studio adds a default signingConfig for debug builds and we can remove it by passing null.
// By doing this, I delegate the signingConfig to the product flavors.
signingConfig null
}
}
flavorDimensions "version", "channel"

productFlavors {
stable {
dimension "version"
}
alpha {
dimension "version"
}
base {
dimension "channel"
}
flavorA {
dimension "channel"
signingConfig signingConfigs.flavorA
}
}
}

构建多应用

构建多应用

屏幕密度

以下为compatibleScreens列出的每个屏幕密度生成单独的APK,但ldpi,xxhdpi和xxxhdpi除外:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
android {
// ...
splits {
// Configures multiple APKs based on screen density.
density {
// Configures multiple APKs based on screen density.
enable true
// Specifies a list of screen densities Gradle should not create multiple APKs for.
exclude "ldpi", "xxhdpi", "xxxhdpi"
// Specifies a list of compatible screen size settings for the manifest.
compatibleScreens 'small', 'normal', 'large', 'xlarge'
}
}
}
  • enable:如果将此元素设置为true,Gradle会根据您定义的屏幕密度生成多个APK。默认值为false。

  • exclude:指定以逗号分隔的密度列表,Gradle不应为其生成单独的APK。

  • reset():清除默认的屏幕密度列表,仅在与include元素组合时使用, 以指定要添加的密度。

  • include:指定Gradle应为其生成APK的密度列表。只能结合使用reset()来指定密度的确切列表。

  • compatibleScreens:指定兼容屏幕尺寸的逗号分隔列表,这会为每个APK在manifest中注入一个匹配的 <compatible-screens>节点。此设置提供了在同一build.gradle中管理屏幕密度和屏幕大小的便捷方法。但是,使用<compatible-screens>限制了应用程序可以使用的设备类型。

    1
    2
    reset()  // Clears the default list from all densities to no densities.
    include "ldpi", "xxhdpi" // Specifies the two densities we want to generate APKs for.

因为基于屏幕密度的每个APK都包含<compatible-screens>标记,其中包含有关APK支持的屏幕类型的特定限制,即使您发布了多个APK,某些新设备也无法匹配您的多个APK过滤器。因此,Gradle始终会生成一个额外的通用APK,其中包含所有屏幕密度的资源,并且不包含<compatible-screens>标记。您应该发布此通用APK以及每个密度的APK,以便为与APK兼容的设备提供后备兼容的<compatible-screens>标记。

ABI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
android {
// ...
splits {

// Configures multiple APKs based on ABI.
abi {

// Enables building multiple APKs per ABI.
enable true

// By default all ABIs are included, so use reset() and include to specify that we only
// want APKs for x86 and x86_64.

// Resets the list of ABIs that Gradle should create APKs for to none.
reset()

// Specifies a list of ABIs that Gradle should create APKs for.
include "x86", "x86_64"

// Specifies that we do not want to also generate a universal APK that includes all ABIs.
universalApk false
}
}
}

ABI 包含以下信息:

  • 机器代码应使用的 CPU 指令集。
  • 运行时内存存储和加载的字节顺序。
  • 可执行二进制文件(例如程序和共享库)的格式,以及它们支持的内容类型。
  • 用于解析内容与系统之间数据的各种约定。这些约定包括对齐限制,以及系统如何使用堆栈和在调用函数时注册。
  • 运行时可用于机器代码的函数符号列表 - 通常来自非常具体的库集。
  • enable:如果您将此元素设置为true,Gradle会根据您定义的ABI生成多个APK。默认值是false
  • exclude:指定用逗号分隔的ABI的名单不生成单独的APK。
  • reset:清除ABI的默认列表。仅在与include元素结合使用时才使用, 以指定要添加的ABI。
  • include:指定Gradle应为其生成APK的ABI的逗号分隔列表。只能结合使用reset()来指定ABI的确切列表。
  • universalApk:如果true,除了per-ABI APK,Gradle还生成通用APK。通用APK包含单个APK中所有ABI的代码和资源。默认值是false。请注意,该选项仅在该splits.abi块中可用。当根据屏幕密度构建多个APK时,Gradle始终会生成一个通用APK,其中包含用于所有屏幕密度的代码和资源。

在Gradle 3.1.0及更高版本中不再默认生成支持mips, mips64, 和armeabi的apk,因为 NDK r17 及更高版本不再支持这些abi。因此如果使用Gradle版本低于3.1.0,NDK高于r17,则会报错。

配置版本

默认生成的多个apk的版本信息是一样的,但是GP不允许同一应用的多apk拥有相同的版本信息,因此需要为其生成不同的版本。

如果您的构建包含通用APK,则应为其分配一个低于任何其他APK的版本代码。 由于Google Play商店会安装与目标设备兼容且版本编号最高的应用版本,因此将较低版本的代码分配给通用APK可确保Google Play商店尝试安装其中一个APK,然后再回到通用版本APK。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
android {
...
defaultConfig {
...
versionCode 4
}
splits {
...
}
}

// Map for the version code that gives each ABI a value.
ext.abiCodes = ['armeabi-v7a':1, 'x86':2, 'x86_64':3]

// For per-density APKs, create a similar map like this:
// ext.densityCodes = ['mdpi': 1, 'hdpi': 2, 'xhdpi': 3]

import com.android.build.OutputFile

// For each APK output variant, override versionCode with a combination of
// ext.abiCodes * 1000 + variant.versionCode. In this example, variant.versionCode
// is equal to defaultConfig.versionCode. If you configure product flavors that
// define their own versionCode, variant.versionCode uses that value instead.
android.applicationVariants.all { variant ->

// Assigns a different version code for each output APK
// other than the universal APK.
variant.outputs.each { output ->

// Stores the value of ext.abiCodes that is associated with the ABI for this variant.
def baseAbiVersionCode =
// Determines the ABI for this variant and returns the mapped value.
project.ext.abiCodes.get(output.getFilter(OutputFile.ABI))

// Because abiCodes.get() returns null for ABIs that are not mapped by ext.abiCodes,
// the following code does not override the version code for universal APKs.
// However, because we want universal APKs to have the lowest version code,
// this outcome is desirable.
if (baseAbiVersionCode != null) {

// Assigns the new version code to versionCodeOverride, which changes the version code
// for only the output APK, not for the variant itself. Skipping this step simply
// causes Gradle to use the value of variant.versionCode for the APK.
output.versionCodeOverride =
baseAbiVersionCode * 1000 + variant.versionCode
}
}
}

格式化apk名

1
2
3
4
5
6
7
android {
applicationVariants.all { variant ->
variant.outputs.all {
outputFileName = "${variant.applicationId}-${variant.name}-${variant.versionName}.apk"
}
}
}

Manifest合并

合并优先级

合并工具会根据每个清单文件的优先级按顺序合并,将所有清单文件组合到一个文件中。

有三种基本的清单文件可以互相合并,它们的合并优先级如下(按优先级由高到低的顺序):

  1. 编译变体的清单文件
    如果您的变体有多个源集,则其清单优先级如下:

    • 编译变体清单(如 src/demoDebug/)
    • 版本类型清单(如 src/debug/)
    • 产品类型清单(如 src/demo/)
      如果您使用的是类型维度,则清单优先级将与每个维度在 flavorDimensions 属性中的列示顺序(按优先级由高到低的顺序)对应。
  2. 应用模块的主清单文件

  3. 所包含的库中的清单文件
    如果您有多个库,则其清单优先级与依赖顺序(库出现在 Gradle dependencies 块中的顺序)匹配。例如,先将库清单合并到主清单中,然后再将主清单合并到编译变体清单中。

注:build.gradle 文件中的编译配置将替换合并后的清单文件中的所有对应属性。例如,build.gradle 文件中的 minSdkVersion 将替换 <uses-sdk> 清单元素中的匹配属性。为了避免混淆,您只需省去 <uses-sdk> 元素并在 build.gradle 文件中定义这些属性。

合并冲突启发式算法

合并工具可以在逻辑上将一个清单中的每个 XML 元素与另一个清单中的对应元素相匹配。如果优先级较低的清单中的某个元素与优先级较高的清单中的任何元素都不匹配,则会将该元素添加到合并后的清单。不过,如果有匹配的元素,则合并工具会尝试将每个元素的所有属性组合到同一元素中。如果该工具发现两个清单包含相同的属性,但值不同,则会发生合并冲突。

高优先级属性 低优先级属性 属性的合并结果
没有值 没有值 没有值(使用默认值)
没有值 值 B 值 B
值 A 没有值 值 A
值 A 值 A 值 A
值 A 值 B 冲突错误 - 您必须添加合并规则标记

不过,在某些情况下,合并工具会采取其他行为方式以避免合并冲突:

  • <manifest> 元素中的属性绝不会合并在一起 - 仅使用优先级最高的清单中的属性。
  • <uses-feature><uses-library> 元素中的 android:required 属性使用 OR 合并,这样一来,如果发生冲突,系统将应用 “true” 并始终包含某个清单所需的功能或库。
  • <uses-sdk> 元素中的属性始终使用优先级较高的清单中的值,但以下情况除外:
    • 如果优先级较低的清单的 minSdkVersion 值较高,除非您应用 overrideLibrary 合并规则,否则会发生错误。
    • 如果优先级较低的清单的 targetSdkVersion 值较低,合并工具将使用优先级较高的清单中的值,但也会添加所有必要的系统权限,以确保所导入的库继续正常工作(适用于较高的 Android 版本具有更多权限限制的情况)。
  • 绝不会在清单之间匹配 <intent-filter> 元素。每个该元素都被视为唯一的元素,并添加到合并后的清单中共同的父元素。

对于属性之间的其他所有冲突,您将收到一条错误,并且必须通过在优先级较高的清单文件中添加一个特殊属性来指示合并工具如何解决此错误(请参阅有关合并规则标记的下一部分)。

不依赖于默认属性值:由于所有唯一属性都组合到同一元素中,因此如果优先级较高的清单实际上依赖于某个属性的默认值而不声明该属性,则可能会导致意外结果。例如,如果优先级较高的清单不声明 android:launchMode 属性,则会使用默认值 “standard”;但如果优先级较低的清单声明此属性具有其他值,则该值将应用于合并后的清单(替换默认值)。因此,您应该按期望明确定义每个属性。(清单参考文档中介绍了每个属性的默认值。)

合并规则标记

合并规则标记是一个 XML 属性,可用于表达您对如何解决合并冲突或移除不需要的元素和属性的偏好。您可以对整个元素应用标记,也可以只对元素中的特定属性应用标记。

合并两个清单文件时,合并工具会在优先级较高的清单文件中查找这些标记。

所有标记都属于 Android tools 命名空间,因此您必须先在 <manifest> 元素中声明此命名空间,如下所示:

1
2
3
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp"
xmlns:tools="http://schemas.android.com/tools">

节点标记

向整个 XML 元素(给定清单元素中的所有属性及其所有子标记)应用合并规则:

  1. tools:node="merge":如果使用合并冲突启发式算法时没有冲突,则合并此标记中的所有属性以及所有嵌套元素。这是元素的默认行为。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // 低优先级
    <activity android:name="com.example.ActivityOne"
    android:windowSoftInputMode="stateUnchanged">
    <intent-filter>
    <action android:name="android.intent.action.SEND" />
    <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>
    </activity>

    // 高优先级
    <activity android:name="com.example.ActivityOne"
    android:screenOrientation="portrait"
    tools:node="merge">
    </activity>

    // 合并后
    <activity android:name="com.example.ActivityOne"
    android:screenOrientation="portrait"
    android:windowSoftInputMode="stateUnchanged">
    <intent-filter>
    <action android:name="android.intent.action.SEND" />
    <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>
    </activity>
  2. tools:node="merge-only-attributes":仅合并此标记中的属性,不合并嵌套元素。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 低优先级
    <activity android:name="com.example.ActivityOne"
    android:windowSoftInputMode="stateUnchanged">
    <intent-filter>
    <action android:name="android.intent.action.SEND" />
    <data android:type="image/*" />
    <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>
    </activity>

    // 高优先级
    <activity android:name="com.example.ActivityOne"
    android:screenOrientation="portrait"
    tools:node="merge-only-attributes">
    </activity>

    // 合并后
    <activity android:name="com.example.ActivityOne"
    android:screenOrientation="portrait"
    android:windowSoftInputMode="stateUnchanged">
    </activity>
  3. tools:node="remove":从合并后的清单中移除此元素。虽然您似乎应该只删除此元素,但如果您发现合并后的清单中有不需要的元素,而且该元素是由不受您控制的优先级较低的清单文件(如导入的库)提供的,则必须使用此属性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 低优先级
    <activity-alias android:name="com.example.alias">
    <meta-data android:name="cow"
    android:value="@string/moo"/>
    <meta-data android:name="duck"
    android:value="@string/quack"/>
    </activity-alias>

    // 高优先级
    <activity-alias android:name="com.example.alias">
    <meta-data android:name="cow"
    tools:node="remove"/>
    </activity-alias>

    // 合并后
    <activity-alias android:name="com.example.alias">
    <meta-data android:name="duck"
    android:value="@string/quack"/>
    </activity-alias>
  4. tools:node="removeAll":与 tools:node=”remove” 类似,但它会移除与此元素类型匹配的所有元素(同一父元素内)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 低优先级
    <activity-alias android:name="com.example.alias">
    <meta-data android:name="cow"
    android:value="@string/moo"/>
    <meta-data android:name="duck"
    android:value="@string/quack"/>
    </activity-alias>

    // 高优先级
    <activity-alias android:name="com.example.alias">
    <meta-data tools:node="removeAll"/>
    </activity-alias>

    // 合并后
    <activity-alias android:name="com.example.alias">
    </activity-alias>
  5. tools:node="replace":完全替换优先级较低的元素。也就是说,如果优先级较低的清单中有匹配的元素,会将其忽略并完全按照此元素在此清单中显示的样子来使用它。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // 低优先级
    <activity-alias android:name="com.example.alias">
    <meta-data android:name="cow"
    android:value="@string/moo"/>
    <meta-data android:name="duck"
    android:value="@string/quack"/>
    </activity-alias>

    // 高优先级
    <activity-alias android:name="com.example.alias"
    tools:node="replace">
    <meta-data android:name="fox"
    android:value="@string/dingeringeding"/>
    </activity-alias>

    // 合并后
    <activity-alias android:name="com.example.alias">
    <meta-data android:name="fox"
    android:value="@string/dingeringeding"/>
    </activity-alias>
  6. tools:node="strict":每当此元素在优先级较低的清单中与在优先级较高的清单中不完全匹配时,都会导致编译失败(除非已通过其他合并规则标记解决)。这将替换合并冲突启发式算法。例如,如果优先级较低的清单只是包含一个额外的属性,则编译将会失败(而默认行为会将该额外属性添加到合并后的清单)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 低优先级
    <activity android:name="com.example.ActivityOne"
    android:windowSoftInputMode="stateUnchanged">
    <intent-filter>
    <action android:name="android.intent.action.SEND" />
    <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>
    </activity>

    // 高优先级
    <activity android:name="com.example.ActivityOne"
    android:screenOrientation="portrait"
    tools:node="strict">
    </activity>

    这会生成清单合并错误。 这两个清单元素在严格模式下完全无法区分。因此,您必须应用其他合并规则标记来解决这些差异。(通常,这两个元素会很好地合并在一起,如上面的 tools:node=”merge” 示例中所示。)

属性标记

  1. tools:remove="attr, ...":从合并后的清单中移除指定属性。虽然您似乎可以只删除这些属性,但如果优先级较低的清单文件不包含这些属性,而且您希望确保不将它们纳入合并后的清单,则必须使用此属性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 低优先级
    <activity android:name="com.example.ActivityOne"
    android:windowSoftInputMode="stateUnchanged">

    // 高优先级
    <activity android:name="com.example.ActivityOne"
    android:screenOrientation="portrait"
    tools:remove="android:windowSoftInputMode">

    //合并后
    <activity android:name="com.example.ActivityOne"
    android:screenOrientation="portrait">
  2. tools:replace="attr, ...":将优先级较低的清单中的指定属性替换为此清单中的属性。换句话说,始终保留优先级较高的清单的值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 低优先级
    <activity android:name="com.example.ActivityOne"
    android:theme="@oldtheme"
    android:exported="false"
    android:windowSoftInputMode="stateUnchanged">

    // 高优先级
    <activity android:name="com.example.ActivityOne"
    android:theme="@newtheme"
    android:exported="true"
    android:screenOrientation="portrait"
    tools:replace="android:theme,android:exported">

    //合并后
    <activity android:name="com.example.ActivityOne"
    android:theme="@newtheme"
    android:exported="true"
    android:screenOrientation="portrait"
    android:windowSoftInputMode="stateUnchanged">
  3. tools:strict="attr, ...":每当这些属性在优先级较低的清单中与在优先级较高的清单中不完全匹配时,都会导致编译失败。这是所有属性的默认行为,但具有特殊行为的属性除外,如合并冲突启发式算法中所述。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 低优先级
    <activity android:name="com.example.ActivityOne"
    android:screenOrientation="landscape">
    </activity>

    // 高优先级
    <activity android:name="com.example.ActivityOne"
    android:screenOrientation="portrait"
    tools:strict="android:screenOrientation">
    </activity>

    这会生成清单合并错误。 您必须应用其他合并规则标记来解决冲突。(切记:这是默认行为,因此如果您移除 tools:strict=”screenOrientation”,上面的示例将具有相同的结果。)

  4. 也可以对一个元素应用多个标记

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 低优先级
    <activity android:name="com.example.ActivityOne"
    android:theme="@oldtheme"
    android:exported="false"
    android:allowTaskReparenting="true"
    android:windowSoftInputMode="stateUnchanged">

    // 高优先级
    <activity android:name="com.example.ActivityOne"
    android:theme="@newtheme"
    android:exported="true"
    android:screenOrientation="portrait"
    tools:replace="android:theme,android:exported"
    tools:remove="android:windowSoftInputMode">

    //合并后
    <activity android:name="com.example.ActivityOne"
    android:theme="@newtheme"
    android:exported="true"
    android:allowTaskReparenting="true"
    android:screenOrientation="portrait">

标记选择器

如果要仅对导入的特定库应用合并规则标记,请添加带有库软件包名称的 tools:selector 属性。例如,对于下面的清单,只有在优先级较低的清单文件来自 com.example.lib1 库时,才会应用 remove 合并规则。

1
2
3
<permission android:name="permissionOne"
tools:node="remove"
tools:selector="com.example.lib1">

如果优先级较低的清单来自其他任何来源,系统将会忽略 remove 合并规则。

注意:如果将此属性与某个属性标记一起使用,则它会应用于该标记中指定的所有属性。

替换导入的库的 <uses-sdk>

默认情况下,导入 minSdkVersion 值高于主清单文件的库时会出错,而且无法导入该库。要使合并工具忽略此冲突并导入库,同时保留应用的较低 minSdkVersion 值,请将 overrideLibrary 属性添加到 <uses-sdk> 标记。属性值可以是一个或多个库软件包名称(用英文逗号分隔),指明可以替换主清单的 minSdkVersion 的库。

例如,如果应用的主清单按如下方式应用 overrideLibrary:

1
2
3
4
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.app"
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="com.example.lib1, com.example.lib2"/>

则以下清单可以合并,而不会出现与 <uses-sdk> 标记相关的错误,合并后的清单将保留应用清单中的 minSdkVersion=”2”。

1
2
3
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.lib1">
<uses-sdk android:minSdkVersion="4" />

隐式系统权限

一些曾经可由应用自由访问的 Android API 在最新的 Android 版本中受到了系统权限的限制。为了避免中断预期会访问这些 API 的应用,最新的 Android 版本允许应用在无权限的情况下继续访问这些 API,前提是它们已将 targetSdkVersion 设为低于添加限制的版本的值。此行为会有效地向应用授予隐式权限,以允许访问这些 API。因此,这可能会对具有不同 targetSdkVersion 值的合并后的清单产生以下影响。

如果优先级较低的清单文件具有较低的 targetSdkVersion 值,因而为其提供了一项隐式权限,但优先级较高的清单不具备相同的隐式权限(因为它的 targetSdkVersion 等于或高于添加限制的版本),则合并工具会向合并后的清单明确添加相应的系统权限。

例如,如果您的应用将 targetSdkVersion 设为 4 或更高的值,但导入的某个库将 targetSdkVersion 设为 3 或更低的值,则合并工具会向合并后的清单添加 WRITE_EXTERNAL_STORAGE 权限。

注意:如果您已将应用的 targetSdkVersion 设为 23 或更高的值,那么当应用试图访问受任何危险权限保护的 API 时,您必须对这些权限执行运行时权限请求。

优先级较低的清单声明 向合并后的清单添加的权限
targetSdkVersion 为 3 或更低的值 WRITE_EXTERNAL_STORAGE、READ_PHONE_STATE
targetSdkVersion 为 15 或更低的值,并且使用 READ_CONTACTS READ_CALL_LOG
targetSdkVersion 为 15 或更低的值,并且使用 WRITE_CONTACTS WRITE_CALL_LOG

检查合并后的清单并查找冲突

可以通过Android Studio提供的工具查看合并后的Manifest文件。

合并策略

清单合并工具可以在逻辑上将一个清单文件中的每个 XML 元素与另一个文件中的对应元素匹配。合并工具会使用“匹配键”来匹配每个元素,匹配键可以是唯一的属性值(如 android:name),也可以是标记本身的自然唯一性(例如,只能有一个 <supports-screen> 元素)。如果两个清单具有相同的 XML 元素,则该工具会采用三种合并策略中的一种,将这两个元素合并在一起:

  • 合并:将所有非冲突属性组合到同一标记中,并按各自的合并策略合并子元素。如果任何属性相互冲突,使用合并规则标记将它们合并在一起。
  • 仅合并子元素:不组合或合并属性(仅保留优先级最高的清单文件提供的属性),并按各自的合并策略合并子元素。
  • 保留:将元素“按原样”保留,并将其添加到合并后的文件中的共同父元素。只有在可接受同一元素的多个声明时,才会采用此策略。

将构建变量注入Manifest

如果需要将build.gradle中的变量注入到Manifest中,可以使用manifestPlaceholders属性:

1
2
3
4
5
6
android {
defaultConfig {
manifestPlaceholders = [hostName:"www.example.com"]
}
...
}

然后在Manifest中:

1
2
3
4
<intent-filter ... >
<data android:scheme="http" android:host="${hostName}" ... />
...
</intent-filter>

默认情况下,构建工具还会在${applicationId}占位符中提供应用程序的应用程序ID,该值始终与当前构建的最终应用程序ID匹配(包括构建变体的更改)。

例如,如果build.gradle中如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
android {
defaultConfig {
applicationId "com.example.myapp"
}
productFlavors {
free {
applicationIdSuffix ".free"
}
pro {
applicationIdSuffix ".pro"
}
}
}

在Manifest中可以如下配置:

1
2
3
4
<intent-filter ... >
<action android:name="${applicationId}.TRANSMOGRIFY" />
...
</intent-filter>

自定义BuildConfig

1
2
3
4
5
6
7
8
android {
buildTypes {
release {
buildConfigField("String", "PLUGIN_NAME", "\"${plugin_name}\"")
buildConfigField("int", "PLUGIN_VERSION", "${plugin_version}")
}
}
}

压缩代码和资源

代码压缩通过 ProGuard 提供,ProGuard 会检测和移除封装应用中未使用的类、字段、方法和属性,包括自带代码库中的未使用项(这使其成为以变通方式解决 64k 引用限制的有用工具)。ProGuard 还可优化字节码,移除未使用的代码指令,以及用短名称混淆其余的类、字段和方法。混淆过的代码可令您的 APK 难以被逆向工程,这在应用使用许可验证等安全敏感性功能时特别有用。

压缩代码

压缩代码

要通过 ProGuard 启用代码压缩,请在 build.gradle 文件内相应的构建类型中添加 minifyEnabled true。请注意,代码压缩会拖慢构建速度,因此您应该尽可能避免在调试构建中使用。

1
2
3
4
5
6
7
8
9
10
android {
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
}
...
}
  • getDefaultProguardFile(‘proguard-android.txt’) 方法可从 Android SDK tools/proguard/ 文件夹获取默认的 ProGuard 设置。提示:要想做进一步的代码压缩,请尝试使用位于同一位置的 proguard-android-optimize.txt 文件。它包括相同的 ProGuard 规则,但还包括其他在字节码一级(方法内和方法间)执行分析的优化,以进一步减小 APK 大小和帮助提高其运行速度。
  • proguard-rules.pro 文件用于添加自定义 ProGuard 规则。默认情况下,该文件位于模块根目录(build.gradle 文件旁)。

要添加更多各构建变体专用的 ProGuard 规则,请在相应的 productFlavor 代码块中再添加一个 proguardFiles 属性。例如,以下 Gradle 文件会向 flavor2 产品定制添加 flavor2-rules.pro。现在 flavor2 使用所有三个 ProGuard 规则,因为还应用了来自 release 代码块的规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
android {
...
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
}
productFlavors {
flavor1 {
}
flavor2 {
proguardFile 'flavor2-rules.pro'
}
}
}

每次构建时 ProGuard 都会输出下列文件:

  • dump.txt:说明 APK 中所有类文件的内部结构。
  • mapping.txt:提供原始与混淆过的类、方法和字段名称之间的转换。
  • seeds.txt:列出未进行混淆的类和成员。
  • usage.txt:列出从 APK 移除的代码。

这些文件保存在 <module-name>/build/outputs/mapping/release/ 中。

自定义要保留的代码

对于某些情况,默认 ProGuard 配置文件 (proguard-android.txt) 足以满足需要,ProGuard 会移除所有(并且只会移除)未使用的代码。不过,ProGuard 难以对许多情况进行正确分析,可能会移除应用真正需要的代码。举例来说,它可能错误移除代码的情况包括:

  • 当应用引用的类只来自 AndroidManifest.xml 文件时
  • 当应用调用的方法来自 Java 原生接口 (JNI) 时
  • 当应用在运行时(例如使用反射或自检)操作代码时

测试应用应该能够发现因不当移除的代码而导致的错误,但您也可以通过查看 <module-name>/build/outputs/mapping/release/ 中保存的 usage.txt 输出文件来检查移除了哪些代码。

要修正错误并强制 ProGuard 保留特定代码,请在 ProGuard 配置文件中添加一行 -keep 代码。例如:

1
-keep public class MyClass

或者,您可以向您想保留的代码添加 @Keep 注解。在类上添加 @Keep 可原样保留整个类。在方法或字段上添加它可完整保留方法/字段(及其名称)以及类名称。请注意,只有在使用注解支持库时,才能使用此注解。

在使用 -keep 选项时,有许多事项需要考虑;如需了解有关自定义配置文件的详细信息,请阅读 ProGuard 手册问题排查一章概述了您可能会在混淆代码时遇到的其他常见问题。

解码混淆后的代码

ProGuard 每次运行时都会创建一个 mapping.txt 文件,其中显示了与混淆过的名称对应的原始类名称、方法名称和字段名称。ProGuard 将该文件保存在应用的 <module-name>/build/outputs/mapping/release/ 目录中。

要自行将混淆过的堆栈追踪转换成可读的堆栈追踪,请使用 retrace 脚本(在 Windows 上为 retrace.bat;在 Mac/Linux 上为 retrace.sh)。它位于 <sdk-root>/tools/proguard/ 目录中。该脚本利用 mapping.txt 文件和您的堆叠追踪生成新的可读堆叠追踪。使用 retrace 工具的语法如下:

1
retrace.bat|retrace.sh [-verbose] mapping.txt [<stacktrace_file>]

例如:

1
retrace.bat -verbose mapping.txt obfuscated_trace.txt

如果您不指定堆栈追踪文件,retrace 工具会从标准输入读取。

通过 Instant Run 启用代码压缩

Android 插件压缩器不会对您的代码进行混淆处理或优化,它只会删除未使用的代码。因此,您应该仅将其用于调试构建,并为发布构建启用 ProGuard,以便对发布 APK 的代码进行混淆处理和优化。

要启用 Android 插件压缩器,只需在 “debug” 构建类型中将 useProguard 设置为 false(并保留 minifyEnabled 设置 true):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
android {
buildTypes {
debug {
minifyEnabled true
useProguard false
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
}
}

压缩资源

压缩资源

资源压缩只与代码压缩协同工作。代码压缩器移除所有未使用的代码后,资源压缩器便可确定应用仍然使用的资源。这在您添加包含资源的代码库时体现得尤为明显 - 您必须移除未使用的库代码,使库资源变为未引用资源,才能通过资源压缩器将它们移除。

要启用资源压缩,请在 build.gradle 文件中将 shrinkResources 属性设置为 true(在用于代码压缩的 minifyEnabled 旁边)。例如:

1
2
3
4
5
6
7
8
9
10
11
android {
...
buildTypes {
release {
shrinkResources true
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
}
}

如果您尚未使用代码压缩用途的 minifyEnabled 构建应用,请先尝试使用它,然后再启用 shrinkResources,因为您可能需要编辑 proguard-rules.pro 文件以保留动态创建或调用的类或方法,然后再开始移除资源。

注:资源压缩器目前不会移除 values/ 文件夹中定义的资源(例如字符串、尺寸、样式和颜色)。这是因为 Android 资源打包工具 (AAPT) 不允许 Gradle 插件为资源指定预定义版本。

自定义要保留的资源

如果您有想要保留或舍弃的特定资源,请在您的项目中创建一个包含 <resources> 标记的 XML 文件,并在 tools:keep 属性中指定每个要保留的资源,在 tools:discard 属性中指定每个要舍弃的资源。这两个属性都接受逗号分隔的资源名称列表。您可以使用星号字符作为通配符。

1
2
3
4
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@layout/l_used*_c,@layout/l_used_a,@layout/l_used_b*"
tools:discard="@layout/unused2" />

将该文件保存在项目资源中,例如,保存在 res/raw/keep.xml。构建不会将该文件打包到 APK 之中。

指定要舍弃的资源可能看似愚蠢,因为您本可将它们删除,但在使用构建变体时,这样做可能很有用。例如,如果您明知给定资源表面上会在代码中使用(并因此不会被压缩器移除),但实际不会用于给定构建变体,就可以将所有资源放入公用项目目录,然后为每个构建变体创建一个不同的 keep.xml 文件。构建工具也可能无法根据需要正确识别资源,这是因为编译器会添加内联资源 ID,而资源分析器可能不知道真正引用的资源和恰巧具有相同值的代码中的整数值之间的差别。

启用严格引用检查

正常情况下,资源压缩器可准确判定系统是否使用了资源。不过,如果您的代码调用 Resources.getIdentifier()(或您的任何库进行了这一调用 - AppCompat 库会执行该调用),这就表示您的代码将根据动态生成的字符串查询资源名称。当您执行这一调用时,默认情况下资源压缩器会采取防御性行为,将所有具有匹配名称格式的资源标记为可能已使用,无法移除。

例如,以下代码会使所有带 img_ 前缀的资源标记为已使用。

1
2
String name = String.format("img_%1d", angle + 1);
res = getResources().getIdentifier(name, "drawable", getPackageName());

资源压缩器还会浏览代码以及各种 res/raw/ 资源中的所有字符串常量,寻找格式类似于 file:///android_res/drawable//ic_plus_anim_016.png 的资源网址。如果它找到与其类似的字符串,或找到其他看似可用来构建与其类似的网址的字符串,则不会将它们移除。

这些是默认情况下启用的安全压缩模式的示例。但您可以停用这一“有备无患”处理方式,并指定资源压缩器只保留其确定已使用的资源。要执行此操作,请在 keep.xml 文件中将 shrinkMode 设置为 strict,如下所示:

1
2
3
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:shrinkMode="strict" />

如果您确已启用严格压缩模式,并且代码也引用了包含动态生成字符串的资源(如上所示),则必须利用 tools:keep 属性手动保留这些资源。

移除未使用的备用资源

Gradle 资源压缩器只会移除未被您的应用代码引用的资源,这意味着它不会移除用于不同设备配置的备用资源。必要时,您可以使用 Android Gradle 插件的 resConfigs 属性来移除您的应用不需要的备用资源文件。

例如,如果您使用的库包含语言资源(例如使用的是 AppCompat 或 Google Play 服务),则 APK 将包括这些库中消息的所有已翻译语言字符串,无论应用的其余部分是否翻译为同一语言。如果您想只保留应用正式支持的语言,则可以利用 resConfig 属性指定这些语言。系统会移除未指定语言的所有资源。

下面这段代码展示了如何将语言资源限定为仅支持英语和法语:

1
2
3
4
5
6
android {
defaultConfig {
...
resConfigs "en", "fr"
}
}

同理,您也可以利用 APK 拆分为不同设备构建不同的 APK,自定义在 APK 中包括的屏幕密度或 ABI 资源。

合并重复资源

默认情况下,Gradle 还会合并同名资源,例如可能位于不同资源文件夹中的同名可绘制对象。这一行为不受 shrinkResources 属性控制,也无法停用,因为在有多个资源匹配代码查询的名称时,有必要利用这一行为来避免错误。

只有在两个或更多个文件具有完全相同的资源名称、类型和限定符时,才会进行资源合并。Gradle 会在重复项中选择其视为最佳选择的文件(根据下述优先顺序),并只将这一个资源传递给 AAPT,以供在 APK 文件中分发。

Gradle 会在下列位置寻找重复资源:

  • 与主源集关联的主资源,一般位于 src/main/res/ 中。

  • 变体叠加,来自构建类型和构建风味。

  • 库项目依赖项。
    G
    radle 会按以下级联优先顺序合并重复资源:

  • 依赖项 → 主资源 → 构建风格 → 构建类型

例如,如果某个重复资源同时出现在主资源和构建风味中,Gradle 会选择构建风格中的重复资源。

如果完全相同的资源出现在同一源集中,Gradle 无法合并它们,并且会发出资源合并错误。如果您在 build.gradle 文件的 sourceSet 属性中定义了多个源集,则可能会发生这种情况,例如,如果 src/main/res/ 和 src/main/res2/ 包含完全相同的资源,就可能会发生这种情况。

排查资源压缩问题

当您压缩资源时,Gradle Console 会显示它从应用软件包中移除的资源的摘要。例如:

1
2
3
:android:shrinkDebugResources
Removed unused resources: Binary resource data reduced from 2570KB to 1711KB: Removed 33%
:android:validateDebugSigning

Gradle 还会在 <module-name>/build/outputs/mapping/release/(ProGuard 输出文件所在的文件夹)中创建一个名为 resources.txt 的诊断文件。该文件包括诸如哪些资源引用了其他资源以及使用或移除了哪些资源等详情。

例如,要了解您的 APK 为何仍包含 @drawable/ic_plus_anim_016,请打开 resources.txt 文件并搜索该文件名。您可能会发现,有其他资源引用了它,如下所示:

1
2
16:25:48.005 [QUIET] [system.out] @drawable/add_schedule_fab_icon_anim : reachable=true
16:25:48.009 [QUIET] [system.out] @drawable/ic_plus_anim_016

现在您需要了解为何 @drawable/add_schedule_fab_icon_anim 可以访问 - 如果您向上搜索,就会发现“The root reachable resources are:”之下列有该资源。这意味着存在对 add_schedule_fab_icon_anim 的代码引用(即在可访问代码中找到了其 R.drawable ID)。

如果您使用的不是严格检查,则存在看似可用于为动态加载资源构建资源名称的字符串常量时,可将资源 ID 标记为可访问。在这种情况下,如果您在构建输出中搜索资源名称,可能会找到类似下面这样的消息:

1
2
10:32:50.590 [QUIET] [system.out] Marking drawable:ic_plus_anim_016:2130837506
used because it format-string matches string pool constant ic_plus_anim_%1$d.

如果您看到一个这样的字符串,并且您能确定该字符串未用于动态加载给定资源,就可以按照有关如何自定义要保留的资源部分中所述利用 tools:discard 属性通知构建系统将它移除。

多DEX文件

关于“64K 引用限制”

Android 应用 (APK) 文件包含 Dalvik Executable (DEX) 文件形式的可执行字节码文件,这些文件包含用来运行应用的已编译代码。Dalvik Executable 规范将可在单个 DEX 文件内引用的方法总数限制为 65,536,其中包括 Android 框架方法、库方法以及您自己的代码中的方法。这一限制称为“64K 引用限制”。

Android 5.0(API 级别 21)之前的平台版本使用 Dalvik 运行时来执行应用代码。默认情况下,Dalvik 将应用限制为每个 APK 只能使用一个 classes.dex 字节码文件。要绕过这一限制,您可以在您的项目中添加多 dex 文件支持库:

1
2
3
dependencies {
implementation 'androidx.multidex:multidex:2.0.1'
}

如果您不使用 AndroidX,请改为添加以下支持库依赖项:

1
2
3
dependencies {
implementation 'com.android.support:multidex:1.0.3'
}

此库会成为应用的主要 DEX 文件的一部分,然后管理对其他 DEX 文件及其所包含代码的访问。

Android 5.0(API 级别 21)及更高版本使用名为 ART 的运行时,它本身支持从 APK 文件加载多个 DEX 文件。ART 在应用安装时执行预编译,扫描 classesN.dex 文件,并将它们编译成单个 .oat 文件,以供 Android 设备执行。因此,如果您的 minSdkVersion 为 21 或更高的值,则不需要多 dex 文件支持库。

多DEX配置

将您的应用项目设为使用多 dex 文件配置要求您对应用项目进行以下修改,具体取决于应用支持的最低 Android 版本。

如果您的 minSdkVersion 设为 21 或更高的值,您只需在模块级 build.gradle 文件中将 multiDexEnabled 设为 true,如下所示:

1
2
3
4
5
6
7
8
9
android {
defaultConfig {
...
minSdkVersion 21
targetSdkVersion 28
multiDexEnabled true
}
...
}

不过,如果您的 minSdkVersion 设为 20 或更低的值,则您必须使用多 dex 文件支持库,具体操作步骤如下:

  1. 修改模块级 build.gradle 文件以启用多 dex 文件,并将多 dex 文件库添加为依赖项,如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    android {
    defaultConfig {
    ...
    minSdkVersion 15
    targetSdkVersion 28
    multiDexEnabled true
    }
    ...
    }

    dependencies {
    compile 'com.android.support:multidex:1.0.3'
    }
  2. 根据是否替换 Application 类,执行以下某项操作:

    • 如果您不替换 Application 类,请修改清单文件以设置 <application> 标记中的 android:name,如下所示:

      1
      2
      3
      4
      5
      6
      7
      8
      <?xml version="1.0" encoding="utf-8"?>
      <manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="com.example.myapp">
      <application
      android:name="android.support.multidex.MultiDexApplication" >
      ...
      </application>
      </manifest>
    • 如果您替换 Application 类,请对其进行更改以扩展 MultiDexApplication(如果可能),如下所示:

      1
      public class MyApplication extends MultiDexApplication { ... }
    • 或者,如果您替换 Application 类,但无法更改基类,则可以改为替换 attachBaseContext() 方法并调用 MultiDex.install(this) 来启用多 dex 文件:

      1
      2
      3
      4
      5
      6
      7
      public class MyApplication extends SomeOtherApplication {
      @Override
      protected void attachBaseContext(Context base) {
      super.attachBaseContext(base);
      MultiDex.install(this);
      }
      }

      注意:在 MultiDex.install() 完成之前,不要通过反射或 JNI 执行 MultiDex.install() 或其他任何代码。多 dex 文件跟踪功能不会追踪这些调用,从而导致出现 ClassNotFoundException,或因 DEX 文件之间的类分区错误而导致验证错误。

现在,当您编译应用时,Android 编译工具会根据需要构造主要 DEX 文件 (classes.dex) 和辅助 DEX 文件(classes2.dex 和 classes3.dex 等)。然后,编译系统会将所有 DEX 文件打包到您的 APK 中。

在运行时,多 dex 文件 API 使用特殊的类加载器来搜索适用于您的方法的所有 DEX 文件(而不是只在主 classes.dex 文件中搜索)。

多 dex 文件支持库具有一些已知的局限性,将其纳入您的应用编译配置时,您应注意这些局限性并进行针对性的测试:

  • 启动期间在设备的数据分区上安装 DEX 文件的过程相当复杂,如果辅助 DEX 文件较大,可能会导致应用无响应 (ANR) 错误。在这种情况下,您应通过 ProGuard 应用代码压缩,以尽量减小 DEX 文件的大小,并移除未使用的那部分代码。
  • 当运行的版本低于 Android 5.0(API 级别 21)时,使用多 dex 文件不足以避开 linearalloc 限制(问题 78035)。此上限在 Android 4.0(API 级别 14)中有所提高,但这并未完全解决该问题。在低于 Android 4.0 的版本中,您可能会在达到 DEX 索引限制之前达到 linearalloc 限制。因此,如果您的目标 API 级别低于 14,请在这些版本的平台上进行全面测试,因为您的应用可能会在启动时或加载特定类组时出现问题。

声明主要 DEX 文件中必需的类

为多 dex 文件应用编译每个 DEX 文件时,编译工具会执行复杂的决策制定来确定主要 DEX 文件中需要的类,以便您的应用能够成功启动。如果主要 DEX 文件中未提供启动期间需要的任何类,则您的应用会崩溃并出现 java.lang.NoClassDefFoundError 错误。

对于直接从您的应用代码访问的代码,不应发生这种情况,因为编译工具可以识别这些代码路径。但是,当代码路径的可见性较低时(例如,当您使用的库具有复杂的依赖项时),可能会发生这种情况。例如,如果代码使用自检机制或从原生代码调用 Java 方法,那么可能不会将这些类识别为主要 DEX 文件中的必需类。

因此,如果您收到 java.lang.NoClassDefFoundError,则必须使用版本类型中的 multiDexKeepFile 或 multiDexKeepProguard 属性声明这些其他类,以手动将这些类指定为主要 DEX 文件中的必需类。如果某个类在 multiDexKeepFile 或 multiDexKeepProguard 文件中匹配到,则会将该类添加到主要 DEX 文件。

multiDexKeepFile 属性

您在 multiDexKeepFile 中指定的文件应该每行包含一个类,并且类采用 com/example/MyClass.class 格式。例如,您可以创建一个名为 multidex-config.txt 的文件,如下所示:

1
2
com/example/MyClass.class
com/example/MyOtherClass.class

然后,您可以针对版本类型声明该文件,如下所示:

1
2
3
4
5
6
7
8
android {
buildTypes {
release {
multiDexKeepFile file('multidex-config.txt')
...
}
}
}

请注意,Gradle 会读取相对于 build.gradle 文件的路径,因此如果 multidex-config.txt 与 build.gradle 文件在同一目录中,以上示例将有效。

multiDexKeepProguard 属性

multiDexKeepProguard 文件使用与 Proguard 相同的格式,并且支持全部 Proguard 语法。如需详细了解 Proguard 格式和语法,请参阅 Proguard 手册中的 Keep 选项 一节。

您在 multiDexKeepProguard 中指定的文件应该在任何有效的 ProGuard 语法中包含 -keep 选项。例如,-keep com.example.MyClass.class。您可以创建一个名为 multidex-config.pro 的文件,如下所示:

1
2
-keep class com.example.MyClass
-keep class com.example.MyClassToo

如果您要指定软件包中的所有类,文件将如下所示:

1
-keep class com.example.** { *; } // All classes in the com.example package

然后,您可以针对版本类型声明该文件,如下所示:

1
2
3
4
5
6
7
8
android {
buildTypes {
release {
multiDexKeepProguard file('multidex-config.pro')
...
}
}
}

在开发编译中优化多 dex 文件

多 dex 文件配置会大幅增加编译处理时间,因为编译系统必须就哪些类必须包含在主要 DEX 文件中以及哪些类可以包含在辅助 DEX 文件中做出复杂的决策。这意味着,使用多 dex 文件的增量编译通常耗时较长,可能会拖慢您的开发进度。

要缩短较长的增量编译时间,您应使用 dex 预处理在编译之间重用多 dex 文件输出。dex 预处理依赖于一种只在 Android 5.0(API 级别 21)及更高版本中提供的 ART 格式。如果您使用的是 Android Studio 2.3 及更高版本,那么在将您的应用部署到搭载 Android 5.0(API 级别 21)或更高版本的设备上时,IDE 会自动使用此功能。

提示:Android Plugin for Gradle 3.0.0 及更高版本得到了进一步改进来优化编译速度,如每个类的 dex 处理(这样,只有您修改的类会重新进行 dex 处理)。一般来说,为了获得最佳开发体验,您应始终升级到最新版 Android Studio 和 Android 插件。

不过,如果您是从命令行运行 Gradle 编译,则需要将 minSdkVersion 设为 21 或更高的值以启用 dex 预处理。要保留正式版的设置,一种有用的策略是使用产品类型(一个开发类型和一个发布类型,它们具有不同的 minSdkVersion 值)来创建两个应用版本,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
android {
defaultConfig {
...
multiDexEnabled true
// The default minimum API level you want to support.
minSdkVersion 15
}
productFlavors {
// Includes settings you want to keep only while developing your app.
dev {
// Enables pre-dexing for command line builds. When using
// Android Studio 2.3 or higher, the IDE enables pre-dexing
// when deploying your app to a device running Android 5.0
// (API level 21) or higher—regardless of what you set for
// minSdkVersion.
minSdkVersion 21
}
prod {
// If you've configured the defaultConfig block for the production version of
// your app, you can leave this block empty and Gradle uses configurations in
// the defaultConfig block instead. You still need to include this flavor.
// Otherwise, all variants use the "dev" flavor configurations.
}
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
}
}
dependencies {
compile 'com.android.support:multidex:1.0.3'
}

提示:由于您有满足不同多 dex 文件需求的不同编译变体,因此也可以为不同的变体提供不同的清单文件(这样,只有适用于 API 级别 20 及更低级别的清单文件会更改 <application> 标记名称),或者为每个变体创建不同的 Application 子类(这样,只有适用于 API 级别 20 及更低级别的子类会扩展 MultiDexApplication 类或调用 MultiDex.install(this))。

测试多 dex 文件应用

编写多 dex 文件应用的插桩测试时,如果使用 MonitoringInstrumentation(或 AndroidJUnitRunner)插桩测试,则不需要额外的配置。如果使用其他 Instrumentation,则必须将其 onCreate() 方法替换为以下代码:

1
2
3
4
5
public void onCreate(Bundle arguments) {
MultiDex.install(getTargetContext());
super.onCreate(arguments);
...
}

注意:

  • 请勿使用已弃用的 MultiDexTestRunner,请改用 AndroidJUnitRunner。
  • 目前不支持使用多 dex 文件创建测试 APK。

aapt

资源

Android 天生为兼容各种各样不同的设备做了相当多的工作,比如屏幕大小、国际化、键盘、像素密度等等,我们能为各种各样特定的场景下使用特定的资源做兼容而不用改动一行代码,假设我们为各种各样不同的场景适配了不同的资源,如何能快速的应用上这些资源呢?Android 为我们提供了 R 这个类,指定了一个资源的索引(id),然后我们只需要告诉系统在不同的业务场景下,使用对应的资源就好了,至于具体是指定资源里面的哪一个具体文件,由系统根据开发者的配置决定。

在这种场景下,假设我们给定的 id 是 x 值,那么当下业务需要使用这个资源的时候,手机的状态就是 y 值,有了(x,y),在一个表里面就能迅速的定位到资源文件的具体路径了。这个表就是 resources.arsc,它是从 aapt 编译出来的。

其实二进制的资源(比如图片)是不需要编译的,只不过这个“编译”的行为,是为了生成 resources.arsc 以及对 xml 文件进行二进制化等操作,resources.arsc 是上面说的表,xml 的二进制化是为了系统读取上性能更好。AssetManager 在我们调用 R 相关的 id 的时候,就会在这个表里面找到对应的文件,读取出来。

Gradle 在编译资源的过程中,就是调用的这些aapt2命令,传的参数也在这个文档里都介绍了,只不过对开发者隐藏起了调用细节。

aapt2 主要分两步,一步叫 compile,一步叫 link。创建一个空工程:只写了两个 xml,分别是 AndroidManifest.xml 和 activity_main.xml。

Compile

1
2
$ mkdir compiled
$ aapt2 compile src/main/res/layout/activity_main.xml -o compiled/

在 compiled 文件夹中,生成了 layout_activity_main.xml.flat 这个文件,它是 aapt2 特有的,aapt 没有,aapt2 用它能进行增量编译。如果我们有很多的文件的话,需要依次调用 compile 才行,其实这里也可以使用 –dir 参数,只不过这个参数就没有增量编译的效果了。也就是说,当传递整个目录时,即使只有一个资源发生了变化,AAPT2也会重新编译目录中的所有文件。

link 的工作量比 compile 要多一点,此处的输入是多个 flat 的文件 和 AndroidManifest.xml,外部资源,输出是只包含资源的 apk 和 R.java。命令如下:

1
2
3
4
5
aapt2 link -o out.apk \
-I $ANDROID_HOME/platforms/android-28/android.jar \
compiled/layout_activity_main.xml.flat \
--java src/main/java \
--manifest src/main/AndroidManifest.xml
  • 第二行 -I 是 import 外部资源,此处主要是 android 命名空间下定义的一些属性,我们平常使用的@android:xxx都是放在这个 jar 里面,其实我们也可以提供自己的资源供别人链接
  • 第三行是输入的 flat 文件,如果有多个,直接在后面拼接即可
  • 第四行是 R.java 生成的目录
  • 第五行是指定 AndroidManifest.xml

Link完成后会生成out.apk和R.java,out.apk中包含了一个resources.arsc文件。只带资源文件的可以用后缀名.ap_

查看编译后的资源

除了是用 Android Studio 去查看 resources.arsc,还可以直接使用 aapt2 dump apk 信息的方式来查看资源相关的 ID 和状态:

1
aapt2 dump out.apk

输出的结果如下:

1
2
3
4
5
Binary APK
Package name=com.geminiwen.hello id=7f
type layout id=01 entryCount=1
resource 0x7f010000 layout/activity_main
() (file) res/layout/activity_main.xml type=XML

可以看到 layout/activity_main 对应的 ID 是 0x7f010000。顺便看下一个用 Android Studio 新建出来的 apk(为了简单,暂时去除了 support library):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Binary APK
Package name=com.gemini.app.properties id=7f
type color id=01 entryCount=3
resource 0x7f010000 color/colorAccent
() #ffd81b60
resource 0x7f010001 color/colorPrimary
() #ff008577
resource 0x7f010002 color/colorPrimaryDark
() #ff00574b
type drawable id=02 entryCount=3
resource 0x7f020000 drawable/$ic_launcher_foreground__0
(v24) (file) res/drawable-v24/$ic_launcher_foreground__0.xml type=XML
resource 0x7f020001 drawable/ic_launcher_background
() (file) res/drawable/ic_launcher_background.xml type=XML
resource 0x7f020002 drawable/ic_launcher_foreground
(v24) (file) res/drawable-v24/ic_launcher_foreground.xml type=XML
type layout id=03 entryCount=1
resource 0x7f030000 layout/activity_main
() (file) res/layout/activity_main.xml type=XML
type mipmap id=04 entryCount=2
resource 0x7f040000 mipmap/ic_launcher
(mdpi) (file) res/mipmap-mdpi-v4/ic_launcher.png type=PNG
(hdpi) (file) res/mipmap-hdpi-v4/ic_launcher.png type=PNG
(xhdpi) (file) res/mipmap-xhdpi-v4/ic_launcher.png type=PNG
(xxhdpi) (file) res/mipmap-xxhdpi-v4/ic_launcher.png type=PNG
(xxxhdpi) (file) res/mipmap-xxxhdpi-v4/ic_launcher.png type=PNG
(anydpi-v26) (file) res/mipmap-anydpi-v26/ic_launcher.xml type=XML
resource 0x7f040001 mipmap/ic_launcher_round
(mdpi) (file) res/mipmap-mdpi-v4/ic_launcher_round.png type=PNG
(hdpi) (file) res/mipmap-hdpi-v4/ic_launcher_round.png type=PNG
(xhdpi) (file) res/mipmap-xhdpi-v4/ic_launcher_round.png type=PNG
(xxhdpi) (file) res/mipmap-xxhdpi-v4/ic_launcher_round.png type=PNG
(xxxhdpi) (file) res/mipmap-xxxhdpi-v4/ic_launcher_round.png type=PNG
(anydpi-v26) (file) res/mipmap-anydpi-v26/ic_launcher_round.xml type=XML
type string id=05 entryCount=1
resource 0x7f050000 string/app_name
() "Gemini"

资源共享

android.jar 只是一个编译用的桩,真正执行的时候,Android OS 提供了一个运行时的库(framework.jar)。android.jar很像一个 apk,只不过它存在的是 class 文件,然后存在一个 AndroidManifest.xml 和 resources.arsc。这就意味着我们也可以对它用aapt2 dump,执行如下命令:

1
aapt2 dump $ANDROID_HOME/platforms/android-28/android.jar > test.out

得到很多类似如下的输出:

1
2
3
4
5
6
7
8
resource 0x010a0000 anim/fade_in PUBLIC
() (file) res/anim/fade_in.xml type=XML
resource 0x010a0001 anim/fade_out PUBLIC
() (file) res/anim/fade_out.xml type=XML
resource 0x010a0002 anim/slide_in_left PUBLIC
() (file) res/anim/slide_in_left.xml type=XML
resource 0x010a0003 anim/slide_out_right PUBLIC
() (file) res/anim/slide_out_right.xml type=XML

它多了一些PUBLIC的字段,一个 apk 文件里面的资源,如果被加上这个标记的话,就能被其他 apk 所引用,引用方式是@包名:类型/名字,例如:@android:color/red

如果我们想要提供我们的资源,那么首先为我们的资源打上 PUBLIC 的标记,然后在 xml 中引用你的包名,比如:@com.gemini.app:color/red 就能引用到你定义的 color/red 了,如果你不指定包名,默认是自己。

至于 AAPT2 如何生成 PUBLIC,感兴趣的可以接着研究。

Apk相关

直接运行 Dex

在 Android OS 上跑的虚拟机曾经叫 dalvik,现在叫 ART (Android Runtime)。

编译.java文件

1
2
3
4
5
public class Main {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}

通过javac -cp . Main.java可得到.class文件。

编译.class文件

使用dx工具编译,在build-tools下不同的版本中都有dx工具。命令:

1
$ ~/Software/android_sdk/build-tools/29.0.1/dx --dex --output=classes.dex Main.class

运行.dex文件

将生成的classes.dex文件push进手机,运行命令即可得到输出:

1
2
$ dalvikvm -cp classes.dex Main                                                                       
Hello World!

如果输错类名,得到输出:

1
2
3
4
5
6
7
8
9
Unable to locate class 'Mai'
java.lang.ClassNotFoundException: Didn't find class "Mai" on path: DexPathList[[dex file "classes.dex"],nativeLibraryDirectories=[/system/lib64, /vendor/lib64, /system/lib64, /vendor/lib64]]
at dalvik.system.BaseDexClassLoader.findClass(BaseDexClassLoader.java:134)
at java.lang.ClassLoader.loadClass(ClassLoader.java:379)
at java.lang.ClassLoader.loadClass(ClassLoader.java:312)
Exception in thread "main" java.lang.ClassNotFoundException: Didn't find class "Mai" on path: DexPathList[[dex file "classes.dex"],nativeLibraryDirectories=[/system/lib64, /vendor/lib64, /system/lib64, /vendor/lib64]]
at dalvik.system.BaseDexClassLoader.findClass(BaseDexClassLoader.java:134)
at java.lang.ClassLoader.loadClass(ClassLoader.java:379)
at java.lang.ClassLoader.loadClass(ClassLoader.java:312)

可以看到,此处的类加载器是 DexClassLoader,里面存在一个 DexPathList。

dalvikvm 除了能接受一个裸露的 dex 文件以外,还能接受一个 zip 格式的文件,只要求里面的 dex 文件名必须是 classes.dex 就行。比如我们传一个 zip/apk/jar 都能接受,毕竟他们的本质都是 zip。

Dex 热修复与 Classpath

热修复与 Classloader

参照腾讯开源的 Tinker 和阿里的 DexPatch 的原理,我们知道对于现在对于 java 代码的热修复主要从 DexClassLoader 里面的 dexPathList 入手,这里应用的原理就是 classloader 双亲委派里对于加载后的类的缓存机制。

  • 如果一个类在一个类加载器中加载过,就不会从其他类加载器中装载了。

Android 提供的 DexClassloader 是按提供的 dex 顺序找的,因此对于 java 代码的热修复变得很简单:只要把想要被修复的 Dex 放到最前面,加载相关的类就好了,Tinker 和 DexPatch 当然还做了更多的事情,比如对 dex 进行 merge 之类的工作。

构造有问题的 Dex

首先要构造有问题的 Dex,写两个类,分别为Test.java,和HelloWorld.java,这里的HelloWorld类作为主入口,Test 类内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
public class Test {
public void run() {
System.out.println("Bug!");
}
}

public class HelloWorld {
public static void main(String[] args) {
Test test = new Test();
test.run();
}
}

编译运行:

1
2
3
4
5
6
7
$ javac Test.java HelloWorld.java
$ dx --dex -output=classes.dex Test.class HelloWorld.class
$ adb push classes.dex /sdcard/
$ adb shell
$ cd /sdcard/
$ dalvikvm -cp classes.dex HelloWorld
Bug!

构造修复后的 Dex

在 Test.java 中:

1
2
3
4
5
public class Test {
public void run() {
System.out.println("Fixed!");
}
}

构造一个新的new.dex:

1
2
$ ~/javac Test.java HelloWorld.java
$ ~/dx --dex --output=new.dex Test.class

应用热修复

1
2
3
4
$ dalvikvm -cp new.dex:classes.dex HelloWorld
Fixed!
$ dalvikvm -cp classes.dex HelloWorld
Bug!

手动创建可安装Apk

可以在脱离Gradle的情况下使用sdk工具手工创建Android项目,然后生成Apk并打包。

新建工程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Hello
├── build
├── compiled
└── src
└── main
├── AndroidManifest.xml
├── java
│   └── com
│   └── test
│   ├── MainActivity.java
└── res
├── drawable
│   └── ic_launcher.png
└── layout
└── activity_main.xml

AndroidManifest.xml内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.test">

<uses-sdk
android:minSdkVersion="21"
android:targetSdkVersion="29"/>

<application
android:icon="@drawable/ic_launcher"
android:label="坤坤">

<activity
android:name="com.test.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>

MainActivity.java内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.test;

import android.app.Activity;
import android.os.Bundle;

public class MainActivity extends Activity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
}

activity_main.xml内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">

<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="Test"/>
</LinearLayout>

编译资源

1
2
$ aapt2 compile src/main/res/layout/activity_main.xml -o compiled/
$ aapt2 compile src/main/res/drawable/ic_launcher.png -o compiled/

链接资源

1
$ aapt2 link -o resources.ap_ -I $ANDROID_HOME/platforms/android-29/android.jar compiled/drawable_ic_launcher.png.flat compiled/layout_activity_main.xml.flat --java src/main/java --manifest src/main/AndroidManifest.xml

编译class

java工具链中是没有android sdk的,所以需要在编译的时候导入classpath。

1
$ javac -d build -cp $ANDROID_HOME/platforms/android-29/android.jar src/main/java/**/*.java

其中-d表示输出目录,-cp表示 classpath,后面跟着输入文件,src/main/java 目录下面所有的 java 文件。

生成dex

1
$ dx --dex --output=classes.dex build

把前面的编译结果合起来,首先将ap_文件,复制一份,重命名成 apk 文件

1
cp resources.ap_ app-debug.apk

拿到了一个 apk(其实是zip文件),然后把 classes.dex 加进去。

1
zip -ur app-debug.apk classes.dex

签名

1
2
# 密码为android
$ apksigner sign -ks ~/.android/debug.keystore app-debug.apk

生成结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
app
├── app-debug.apk
├── build
│   └── com
│   └── test
│   ├── MainActivity.class
│   ├── R.class
│   ├── R$drawable.class
│   └── R$layout.class
├── classes.dex
├── compiled
│   ├── drawable_ic_launcher.png.flat
│   └── layout_activity_main.xml.flat
├── resources.ap_
└── src
└── main
├── AndroidManifest.xml
├── java
│   └── com
│   └── test
│   ├── MainActivity.java
│   └── R.java
└── res
├── drawable
│   └── ic_launcher.png
└── layout
└── activity_main.xml

其他

配置调试的Debug包apk可安装

Android Studio 3.0会在debug apk的配置文件application标签里自动添加 android:testOnly=”true”属性,导致IDE中run跑出的apk无法安装,只能用于as测试安装。

解决办法:在gradle.properties(项目根目录或者gradle全局配置目录 ~/.gradle/)文件中添加android.injected.testOnly=false 之后就可以安装了。

概述

  • OpenGL(Open Graphics Library)是一个用于渲染2D、3D矢量图形的跨语言、跨平台的应用程序编程接口(API)。
  • OpenGl一般用于在图形工作站,PC端使用,由于性能各方面原因,在移动端基本带不动OpenGl。为此,KhronosGroup 为 OpenGl 提供了一个子集,OpenGl ES(OpenGl for Embedded System)。
  • OpenGl ES是免费的跨平台的功能完善的2D/3D图形库接口的API,是OpenGL的一个子集。
  • 移动端使用到的基本上都是OpenGl ES,当然Android开发下还专门为OpenGl提供了android.opengl包,并且提供了GlSurfaceView, GLU, GlUtils等工具类。

Android 支持多版 OpenGL ES API:

  • OpenGL ES 1.0 和 1.1 - 此 API 规范受 Android 1.0 及更高版本的支持。
  • OpenGL ES 2.0 - 此 API 规范受 Android 2.2(API 级别 8)及更高版本的支持。
  • OpenGL ES 3.0 - 此 API 规范受 Android 4.3(API 级别 18)及更高版本的支持。
  • OpenGL ES 3.1 - 此 API 规范受 Android 5.0(API 级别 21)及更高版本的支持。

Android 通过其 Framework API 和 Native开发套件(NDK) 来支持 OpenGL,本文主要讲解 Framework API。Android Framework 中有两个基本类,用于通过 OpenGL ES API 来创建和操控图形:GLSurfaceView 和 GLSurfaceView.Renderer。

参考:Android-OpenGl-ES官方文档

EGL 是 OpenGL ES 渲染 API 和本地窗口系统(native platform window system)之间的一个中间接口层,它主要由系统制造商实现。为了让 OpenGL ES能够绘制在当前设备上,需要 EGL 作为 OpenGL ES 与设备的桥梁。

GlSurfaceView

GlSurfaceView是一个 View,它继承自SurfaceView,并增加了Renderer,它的作用就是专门为 OpenGl 显示渲染使用的。使用方法:

1.创建一个GlSurfaceView
2.为这个GlSurfaceView设置Renderer
3.在GlSurfaceView.renderer中绘制处理显示数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
GLSurfaceView glSurfaceView = new GLSurfaceView(this);
glSurfaceView.setRenderer(new GLSurfaceView.Renderer() {
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {

}

@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {

}

@Override
public void onDrawFrame(GL10 gl) {

}
});
setContentView(glSurfaceView);
}

GlSurfaceView.Renderer

  • onSurfaceCreated(): 系统会在创建 GLSurfaceView 时调用一次此方法。使用此方法可执行仅需发生一次的操作,例如设置 OpenGL 环境参数或初始化 OpenGL 图形对象。
  • onDrawFrame(): 系统会在每次重新绘制 GLSurfaceView 时调用此方法。请将此方法作为绘制(和重新绘制)图形对象的主要执行点。
  • onSurfaceChanged(): 系统会在 GLSurfaceView 几何图形发生变化(包括 GLSurfaceView 大小发生变化或设备屏幕方向发生变化)时调用此方法。使用此方法可响应 GLSurfaceView 容器中的更改。

声明OpenGL

如果应用使用的 OpenGL 功能不一定在所有设备上可用,则必须在 AndroidManifest.xml 文件中包含这些要求。以下是最常见的 OpenGL 清单声明:

  • OpenGL ES 版本要求 - 如果应用需要特定版本的 OpenGL ES,则必须通过将以下设置添加到 Manifest 中来声明该要求,如下所示。

    1
    2
    3
    4
    5
    6
    <!-- Tell the system this app requires OpenGL ES 2.0. -->
    <uses-feature android:glEsVersion="0x00020000" android:required="true" />
    <!-- Tell the system this app requires OpenGL ES 3.0. -->
    <uses-feature android:glEsVersion="0x00030000" android:required="true" />
    <!-- Tell the system this app requires OpenGL ES 3.1. -->
    <uses-feature android:glEsVersion="0x00030001" android:required="true" />
  • 纹理压缩要求 - 如果应用使用了纹理压缩格式,则必须使用 <supports-gl-texture> 在 Manifest 文件中声明应用支持的格式,如需详细了解可用的纹理
    压缩格式,可参阅纹理压缩支持

简单使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 使用 GLSurfaceView 作为主要视图的 Activity 的最少实现
class OpenGLES20Activity : Activity() {
private lateinit var gLView: GLSurfaceView

public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
gLView = MyGLSurfaceView(this)
setContentView(gLView)
}

class MyGLSurfaceView(context: Context) : GLSurfaceView(context) {
private val renderer: MyGLRenderer

init {
// Create an OpenGL ES 2.0 context
setEGLContextClientVersion(2)
renderer = MyGLRenderer()
setRenderer(renderer)
// 该设置可防止系统在调用 requestRender() 之前重新绘制 GLSurfaceView 帧,更为高效。
// Render the view only when there is a change in the drawing data
renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
}
}

class MyGLRenderer : GLSurfaceView.Renderer {

override fun onSurfaceCreated(unused: GL10, config: EGLConfig) {
// Set the background frame color
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f)
}

override fun onDrawFrame(unused: GL10) {
// Redraw background color
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
}

override fun onSurfaceChanged(unused: GL10, width: Int, height: Int) {
GLES20.glViewport(0, 0, width, height)
}
}
}

定义形状

默认情况下,OpenGL ES 会假定一个坐标系,其中 [0,0,0] (X,Y,Z) 指定 GLSurfaceView 帧的中心,[1,1,0] 指定帧的右上角,而 [-1,-1,0] 指定帧的左下角,此形状的坐标是按照逆时针顺序定义的。

定义三角形

通过 OpenGL ES 可以使用三维空间中的坐标定义绘制的对象,在 OpenGL 中,执行此操作的典型方式是为坐标定义浮点数的顶点数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// number of coordinates per vertex in this array
const val COORDS_PER_VERTEX = 3
var triangleCoords = floatArrayOf( // in counterclockwise order:
0.0f, 0.622008459f, 0.0f, // top
-0.5f, -0.311004243f, 0.0f, // bottom left
0.5f, -0.311004243f, 0.0f // bottom right
)

class Triangle {

// Set color with red, green, blue and alpha (opacity) values
val color = floatArrayOf(0.63671875f, 0.76953125f, 0.22265625f, 1.0f)

// (number of coordinate values * 4 bytes per float)
private var vertexBuffer: FloatBuffer = ByteBuffer.allocateDirect(triangleCoords.size * 4).run {
// use the device hardware's native byte order
order(ByteOrder.nativeOrder())

// create a floating point buffer from the ByteBuffer
asFloatBuffer().apply {
// add the coordinates to the FloatBuffer
put(triangleCoords)
// set the buffer to read the first coordinate
position(0)
}
}
}

定义方形

有多种方式可以定义方形,但在 OpenGL ES 中绘制此类形状的典型方式是使用两个绘制在一起的三角形:

定义方形

对于表示该形状的两个三角形,应按逆时针顺序定义顶点,并将这些值放入 ByteBuffer 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// number of coordinates per vertex in this array
const val COORDS_PER_VERTEX = 3
var squareCoords = floatArrayOf(
-0.5f, 0.5f, 0.0f, // top left
-0.5f, -0.5f, 0.0f, // bottom left
0.5f, -0.5f, 0.0f, // bottom right
0.5f, 0.5f, 0.0f // top right
)

class Square2 {

private val drawOrder = shortArrayOf(0, 1, 2, 0, 2, 3) // order to draw vertices

// initialize vertex byte buffer for shape coordinates
// (# of coordinate values * 4 bytes per float)
private val vertexBuffer: FloatBuffer = ByteBuffer.allocateDirect(squareCoords.size * 4).run {
order(ByteOrder.nativeOrder())
asFloatBuffer().apply {
put(squareCoords)
position(0)
}
}

// initialize byte buffer for the draw list
// (# of coordinate values * 2 bytes per short)
private val drawListBuffer: ShortBuffer = ByteBuffer.allocateDirect(drawOrder.size * 2).run {
order(ByteOrder.nativeOrder())
asShortBuffer().apply {
put(drawOrder)
position(0)
}
}
}

绘制形状

初始化形状

在进行任何绘制之前必须初始化并加载打算绘制的形状,除非在程序中使用的形状结构(原始坐标)在执行过程中发生变化,否则应该在渲染程序的 onSurfaceCreated() 方法中对它们进行初始化,以提高内存和处理效率。

1
2
3
4
5
6
7
8
9
10
11
class MyGLRenderer : GLSurfaceView.Renderer {
private lateinit var mTriangle: Triangle
private lateinit var mSquare: Square

override fun onSurfaceCreated(unused: GL10, config: EGLConfig) {
// initialize a triangle
mTriangle = Triangle()
// initialize a square
mSquare = Square()
}
}

绘制形状

使用 OpenGL ES 2.0 绘制定义的形状需要大量代码,因为必须向图形渲染管道提供大量信息,具体来说需要定义以下内容:

  • 顶点(Vertex)着色程序 - 用于渲染形状的顶点的 OpenGL ES 图形代码。
  • 片段(Fragment)着色程序 - 用于使用颜色或纹理渲染形状面的 OpenGL ES 代码。
  • 程序 - 包含希望用于绘制一个或多个形状的着色程序的 OpenGL ES 对象。

至少需要一个顶点着色程序绘制形状,以及一个 Fragment 着色程序为该形状着色,还必须对这些着色程序进行编译,然后将其添加到之后用于绘制形状的 OpenGL ES 程序中。以下示例展示了如何定义可用于绘制 Triangle 类中的形状的基本着色程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Triangle {
private val vertexShaderCode =
"attribute vec4 vPosition;" +
"void main() {" +
" gl_Position = vPosition;" +
"}"

private val fragmentShaderCode =
"precision mediump float;" +
"uniform vec4 vColor;" +
"void main() {" +
" gl_FragColor = vColor;" +
"}"
}

着色程序包含 OpenGL 着色语言 (GLSL) 代码,必须先对其进行编译,然后才能在 OpenGL ES 环境中使用。要编译此代码,可以在渲染程序类中创建一个实用程序方法:

1
2
3
4
5
6
7
8
9
10
11
fun loadShader(type: Int, shaderCode: String): Int {

// create a vertex shader type (GLES20.GL_VERTEX_SHADER)
// or a fragment shader type (GLES20.GL_FRAGMENT_SHADER)
return GLES20.glCreateShader(type).also { shader ->

// add the source code to the shader and compile it
GLES20.glShaderSource(shader, shaderCode)
GLES20.glCompileShader(shader)
}
}

要绘制形状,必须编译着色程序代码,将它们添加到 OpenGL ES 程序对象中,然后关联该程序。该操作需要在绘制对象的构造函数中完成,因此只需执行一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Triangle {
private var mProgram: Int

init {
val vertexShader: Int = loadShader(GLES20.GL_VERTEX_SHADER, vertexShaderCode)
val fragmentShader: Int = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode)

// create empty OpenGL ES Program
mProgram = GLES20.glCreateProgram().also {

// add the vertex shader to program
GLES20.glAttachShader(it, vertexShader)

// add the fragment shader to program
GLES20.glAttachShader(it, fragmentShader)

// creates OpenGL ES program executables
GLES20.glLinkProgram(it)
}
}
}

此时可以添加绘制形状的实际调用,使用 OpenGL ES 绘制形状时需要指定多个参数,以告知渲染管道要绘制的形状以及如何进行绘制。由于绘制选项因形状而异,因此最好使形状类包含自身的绘制逻辑。

创建用于绘制形状的 draw() 方法,此代码将位置和颜色值设置为形状的顶点着色程序和 Fragment 着色程序,然后执行绘制功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private var positionHandle: Int = 0
private var mColorHandle: Int = 0

private val vertexCount: Int = triangleCoords.size / COORDS_PER_VERTEX
private val vertexStride: Int = COORDS_PER_VERTEX * 4 // 4 bytes per vertex

fun draw() {
// Add program to OpenGL ES environment
GLES20.glUseProgram(mProgram)

// get handle to vertex shader's vPosition member
positionHandle = GLES20.glGetAttribLocation(mProgram, "vPosition").also {

// Enable a handle to the triangle vertices
GLES20.glEnableVertexAttribArray(it)

// Prepare the triangle coordinate data
GLES20.glVertexAttribPointer(
it,
COORDS_PER_VERTEX,
GLES20.GL_FLOAT,
false,
vertexStride,
vertexBuffer
)

// get handle to fragment shader's vColor member
mColorHandle = GLES20.glGetUniformLocation(mProgram, "vColor").also { colorHandle ->

// Set color for drawing the triangle
GLES20.glUniform4fv(colorHandle, 1, color, 0)
}

// Draw the triangle
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, vertexCount)

// Disable vertex array
GLES20.glDisableVertexAttribArray(it)
}
}

将所有的代码编译完成之后,只需从渲染程序的 onDrawFrame() 方法中调用 draw() 方法即可绘制此对象:

1
2
3
override fun onDrawFrame(unused: GL10) {
mTriangle.draw()
}

在横屏手机上绘制结果如下:

三角形-无转换

形状偏斜的原因在于,对象的顶点尚未针对显示 GLSurfaceView 的屏幕区域的比例进行校正。

投影和相机视图

在 Android 设备上显示图形时,一个基本问题在于屏幕的尺寸和形状各不相同,OpenGL 假设屏幕采用均匀的方形坐标系。

OpenGL-Android坐标系

上图左侧显示了针对 OpenGL 假定的均匀坐标系,右侧显示了坐标实际上如何映射到右侧屏幕方向为横向的设备屏幕上。要解决此问题,可以通过应用 OpenGL 投影模式和相机视图来转换坐标,这样,图形对象在任何屏幕上都具有正确的比例。

  • 投影:根据显示绘制对象的 GLSurfaceView 的宽度和高度调整绘制对象的坐标。通常只有在渲染程序的 onSurfaceChanged() 方法中确定或更改 OpenGL 视图的比例时,才需要计算投影转换。
  • 相机视图:根据虚拟相机的位置调整绘制对象的坐标。相机视图转换可能在确定 GLSurfaceView 时计算一次,也可能会根据用户操作或应用的功能动态变化。

定义投影

用于投影转换的数据使用 GLSurfaceView.Renderer 的 onSurfaceChanged() 方法计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// vPMatrix is an abbreviation for "Model View Projection Matrix"--模型视图投影矩阵
private val vPMatrix = FloatArray(16)
private val projectionMatrix = FloatArray(16)
private val viewMatrix = FloatArray(16)

override fun onSurfaceChanged(unused: GL10, width: Int, height: Int) {
GLES20.glViewport(0, 0, width, height)

val ratio: Float = width.toFloat() / height.toFloat()

// this projection matrix is applied to object coordinates
// in the onDrawFrame() method
Matrix.frustumM(projectionMatrix, 0, -ratio, ratio, -1f, 1f, 3f, 7f)
}

定义相机视图

通过在渲染程序中添加相机视图转换作为绘制流程的一部分,完成绘制对象的转换流程。在以下示例代码中,相机视图转换使用 Matrix.setLookAtM() 方法进行计算,然后与之前计算的投影矩阵合并。之后,系统会将合并后的转换矩阵传递到绘制的形状。

1
2
3
4
5
6
7
8
9
10
override fun onDrawFrame(unused: GL10) {
// Set the camera position (View matrix)
Matrix.setLookAtM(viewMatrix, 0, 0f, 0f, -3f, 0f, 0f, 0f, 0f, 1.0f, 0.0f)

// Calculate the projection and view transformation
Matrix.multiplyMM(vPMatrix, 0, projectionMatrix, 0, viewMatrix, 0)

// Draw shape
mTriangle.draw(vPMatrix)
}

应用投影和相机转换

为了使用预览部分中显示的合并后的投影和相机视图转换矩阵,请先将矩阵变体添加到之前在 Triangle 类中定义的顶点着色程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Triangle {
private val vertexShaderCode =
// This matrix member variable provides a hook to manipulate
// the coordinates of the objects that use this vertex shader
"uniform mat4 uMVPMatrix;" +
"attribute vec4 vPosition;" +
"void main() {" +
// the matrix must be included as a modifier of gl_Position
// Note that the uMVPMatrix factor *must be first* in order
// for the matrix multiplication product to be correct.
" gl_Position = uMVPMatrix * vPosition;" +
"}"

// Use to access and set the view transformation
private var vPMatrixHandle: Int = 0
}

接下来,修改图形对象的 draw() 方法以接受合并后的转换矩阵,并将其应用于形状:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
fun draw(mvpMatrix: FloatArray) {
// Add program to OpenGL ES environment
GLES20.glUseProgram(mProgram)

// get handle to vertex shader's vPosition member
positionHandle = GLES20.glGetAttribLocation(mProgram, "vPosition").also {

// Enable a handle to the triangle vertices
GLES20.glEnableVertexAttribArray(it)

// Prepare the triangle coordinate data
GLES20.glVertexAttribPointer(
it,
COORDS_PER_VERTEX,
GLES20.GL_FLOAT,
false,
vertexStride,
vertexBuffer
)

// get handle to fragment shader's vColor member
mColorHandle = GLES20.glGetUniformLocation(mProgram, "vColor").also { colorHandle ->

// Set color for drawing the triangle
GLES20.glUniform4fv(colorHandle, 1, color, 0)
}

// get handle to shape's transformation matrix
vPMatrixHandle = GLES20.glGetUniformLocation(mProgram, "uMVPMatrix")

// Pass the projection and view transformation to the shader
GLES20.glUniformMatrix4fv(vPMatrixHandle, 1, false, mvpMatrix, 0)

// Draw the triangle
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, vertexCount)

// Disable vertex array
GLES20.glDisableVertexAttribArray(positionHandle)
}
}

这样就能按照正确的比例显示形状了。

添加动画

旋转形状

使用 OpenGL ES 2.0 旋转绘制的对象相对比较简单。在渲染程序中,再创建一个转换矩阵(旋转矩阵),然后将其与投影和相机视图转换矩阵相结合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private val rotationMatrix = FloatArray(16)

override fun onDrawFrame(gl: GL10) {
val scratch = FloatArray(16)

// ...

// Create a rotation transformation for the triangle
val time = SystemClock.uptimeMillis() % 4000L
val angle = 0.090f * time.toInt()
Matrix.setRotateM(rotationMatrix, 0, angle, 0f, 0f, -1.0f)

// Combine the rotation matrix with the projection and camera view
// Note that the vPMatrix factor *must be first* in order
// for the matrix multiplication product to be correct.
Matrix.multiplyMM(scratch, 0, vPMatrix, 0, rotationMatrix, 0)

// Draw triangle
mTriangle.draw(scratch)
}

如果三角形在进行这些更改后没有旋转,请确保已对 GLSurfaceView.RENDERMODE_WHEN_DIRTY 设置取消备注。

启用连续渲染

请确保对将渲染模式设置为仅在脏时才进行绘制的行取消备注,否则 OpenGL 仅按一个增量旋转形状,然后就等待从 GLSurfaceView 容器调用 requestRender():

1
2
3
4
5
6
7
class MyGLSurfaceView(context: Context) : GLSurfaceView(context) {
init {
// Render the view only when there is a change in the drawing data.
// To allow the triangle to rotate automatically, this line is commented out:
//renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
}
}

除非在没有任何用户互动的情况下更改对象,否则通常最好启用此标记。

响应触摸事件

设置触摸监听器

为了使 OpenGL ES 应用响应触摸事件,需要在 GLSurfaceView 类中实现 onTouchEvent() 方法。以下示例实现展示了如何监听 MotionEvent.ACTION_MOVE 事件,并将其平移到某个形状的旋转角度。这里需要取消上面renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY的注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private const val TOUCH_SCALE_FACTOR: Float = 180.0f / 320f
...
private var previousX: Float = 0f
private var previousY: Float = 0f

override fun onTouchEvent(e: MotionEvent): Boolean {
// MotionEvent reports input details from the touch screen
// and other input controls. In this case, you are only
// interested in events where the touch position changed.

val x: Float = e.x
val y: Float = e.y

when (e.action) {
MotionEvent.ACTION_MOVE -> {

var dx: Float = x - previousX
var dy: Float = y - previousY

// reverse direction of rotation above the mid-line
if (y > height / 2) {
dx *= -1
}

// reverse direction of rotation to left of the mid-line
if (x < width / 2) {
dy *= -1
}

renderer.angle += (dx + dy) * TOUCH_SCALE_FACTOR
requestRender()
}
}

previousX = x
previousY = y
return true
}

线程安全

上面的代码需要公开 angle 成员,由于渲染程序代码在独立于应用的主界面线程的线程上运行,因此必须将此公开变量声明为 volatile。

1
2
3
4
class MyGLRenderer : GLSurfaceView.Renderer {
@Volatile
var angle: Float = 0f
}

应用旋转

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
override fun onDrawFrame(gl: GL10) {
// ...
val scratch = FloatArray(16)

// Create a rotation for the triangle
// long time = SystemClock.uptimeMillis() % 4000L;
// float angle = 0.090f * ((int) time);
Matrix.setRotateM(rotationMatrix, 0, angle, 0f, 0f, -1.0f)

// Combine the rotation matrix with the projection and camera view
// Note that the mvpMatrix factor *must be first* in order
// for the matrix multiplication product to be correct.
Matrix.multiplyMM(scratch, 0, mvpMatrix, 0, rotationMatrix, 0)

// Draw triangle
triangle.draw(scratch)
}

这样就可以实现拖动旋转三角形了。

概述

Gradle是一个基于 JVM 的富有突破性构建工具,Gradle 的核心在于基于 Groovy 的丰富而可扩展的域描述语言(DSL)。

Gradle最重要的是Project和Task:

  • 任何一个Gradle项目都是由一个或多个projects组成的,projects其实就是Idea、AndroidStudio中的Module;
  • tasks是任务,它是Gradle中的原子性操作,如编译、打包、生成javadoc等,一个project中会有多个tasks;

可以通过gradle tasks查看当前可执行的tasks。

需要注意的一些地方:

  • Gradle设计之初就是一个通用的构建工具,它允许你用它来构建任何应用,唯一的限制是Gradle的远程依赖管理目前仅支持Maven和Ivy的仓库;
  • Gradle的构建模块是基于task的,Gradle要做的就是按照task之间的依赖关系来组织task按照合适的顺序运行;
  • Gradle评估(evaluate)和指定构建脚本时有三个固定步骤:
    1. 初始化(Initialization): 初始化构建所需的运行环境,并检查哪些projects参与构建
    2. 配置(Configuration): 将tasks组织起来,决定它们按何种顺序执行
    3. 执行(Execution): 执行tasks
  • Gradle提供了集中方式以扩展:
    • 自定义task types
    • 自定义task actions
    • 在projects和tasks中指定额外的属性
    • 自定义conventions
    • custon model

可以通过在命令行运行gradle命令来执行构建,gradle 命令会从当前目录下寻找build.gradle文件来执行构建。称 build.gradle 文件为构建脚本,严格来说这其实是一个构建配置脚本。

1
2
3
4
5
task hello {
doLast {
println 'Hello world!'
}
}

执行 gradle -q hello-q 参数的目的是:用来控制 gradle 的日志级别,可以保证只输出我们需要的内容)

构建基础

创建工程

使用gradle init可创建一个新工程。

Gradle 图形用户界面

可以通过gradle –gui 参数来启动GUI界面,通过gradle –gui& 让它作为后台任务运行。

Conventions

Conventions:声明式,约定式。Gradle吸收了Maven的声明式的特点,所谓声明式直接的体现就是我们将特定的文件(如代码、资源文件)放在特定的目录下,Gradle会自动地在相应的目录下找到对应的文件,减少了需要自定义的构建脚本。

项目创建完成之后,目录结构如下:

1
2
3
4
5
6
7
8
9
10
.gradle/    # 存放Gradle的缓存,缓存可用于加快构建速度
.idea/ # Idea生成的,与Gradle无关
gradle/ # gradle文件夹中只含有wrapper文件夹,用于存放GradleWraper的jar包以及配置文件
module1/ # module
module2/ # module
build.gradle # 根目录下的build.gradle文件,是项目的构建脚本
gradle.properties # 配置文件
gradlew # Gradle Wrapper的执行脚本
gradlew.bat # Gradle Wrapper的执行脚本
settings.gradle # 项目的设置文件,最重要的作用是用于设置Multi-Project构建时哪些project参与构建

Project API

在 Gradle 中构建脚本定义了一个项目(project)。在构建的每一个项目中,Gradle 创建了一个 Project 类型的实例,并在构建脚本中关联此 Project 对象。当构建脚本执行时,它会配置此 Project 对象:

  • 在构建脚本中,你所调用的任何一个方法,如果在构建脚本中未定义,它将被委托给 Project 对象。
  • 在构建脚本中,你所访问的任何一个属性,如果在构建脚本里未定义,它也会被委托给 Project 对象。
1
2
3
4
5
6
println name
println project.name

> gradle -q check
projectApi
projectApi

这两个 println 语句打印出相同的属性。在生成脚本中未定义的属性,第一次使用时自动委托到 Project 对象。其他语句使用了在任何构建脚本中可以访问的 project 属性,则返回关联的 Project 对象。只有当定义的属性或方法是 Project 对象的一个成员相同名字时,才需要使用 project 属性。

Project对象提供了一些在构建脚本中可用的标准的属性。下表列出了常用的几个属性:

名称 类型 默认值
project Project Project实例
name String 项目目录的名称。
path String 项目的绝对路径。
description String 项目的描述。
projectDir File 包含生成脚本的目录。
buildDir File projectDir/build
group Object 未指定
version Object 未指定
ant AntBuilder AntBuilder实例

属性配置

Gradle提供了许多种配置属性的方式:

  1. Gradle安装目录下的gradle.properties文件
  2. 项目根目录下的gradle.properties文件
  3. 环境变量GRADLE_USER_HOME所指向目录的gradle.properties文件
  4. 通过命令行设定的系统属性,从Gradle启动的JVM,可以使用-D命令行选项向它传入一个系统属性。

除了在声明时可以定义属性之外,Gradle还提供了一个叫 “额外属性” 的东西来添加一些自定义属性,在Groovy中使用ext命名空间来声明额外属性。

1
2
3
4
5
6
task hello << {
println 'Hello world!'
}
hello.doLast {
println "Greetings from the $hello.name task."
}

也可以为一个任务添加额外的属性。例如,新增一个叫做 myProperty 的属性,用 ext.myProperty 的方式给他一个初始值。这样便增加了一个自定义属性。

1
2
3
4
5
6
7
task myTask {
ext.myProperty = "myValue"
}

task printTaskProperties << {
println myTask.myProperty
}

如果构建脚本依赖于一些可选属性,而这些属性用户可能在比如 gradle.properties 文件中设置,这时可以通过使用方法hasProperty('propertyName')来进行检查,它返回true或false。

比如可以在gradle.properties中配置代理:

  • 配置 HTTP 代理服务器

    1
    2
    3
    4
    5
    systemProp.http.proxyHost=www.somehost.org
    systemProp.http.proxyPort=8080
    systemProp.http.proxyUser=userid
    systemProp.http.proxyPassword=password
    systemProp.http.nonProxyHosts=*.nonproxyrepos.com|localhost
  • 配置 HTTPS 代理服务器

    1
    2
    3
    4
    5
    systemProp.https.proxyHost=www.somehost.org
    systemProp.https.proxyPort=8080
    systemProp.https.proxyUser=userid
    systemProp.https.proxyPassword=password
    systemProp.https.nonProxyHosts=*.nonproxyrepos.com|localhost

配置与执行阶段

Gradle运行构建脚本时有配置(Configuration)阶段和执行(Execution)阶段,先配置后执行。

当配置阶段执行完了之后,Gradle就知道哪些tasks将要被执行,Gradle给我们提供了一个hook的能力,在两个阶段之间执行一些操作。

下面的demo将根据release这个task是否将会被执行做出不同操作,这个demo具有实际意义,代表我们在开发时debug和release的两种情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//build.gradle
task distribution {
doLast {
println "We build the zip with version=$version"
}
}
task release {
dependsOn 'distribution'
doLast {
println 'We release now'
}
}
gradle.taskGraph.whenReady { taskGraph ->
if (taskGraph.hasTask(":release")) {
version = '1.0'
} else {
version = '1.0-SNAPSHOT'
}
}

> gradle -q distribution
We build the zip with version=1.0-SNAPSHOT
> gradle -q release
We build the zip with version=1.0
We release now

外部依赖

如果你的构建脚本需要使用一些外部的依赖,比如说需要一些开源库来执行某些操作时,可以将它们的classpath添加至脚本中,使用buildscript方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//build.gradle
import org.apache.commons.codec.binary.Base64

buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath group: 'commons-codec', name: 'commons-codec', version:'1.2'
}
}

task encode {
doLast {
def byte[] encodedString = new Base64().encode('hello world\n').getBytes()
println new String(encodedString)
}
}

> gradle -q encode
aGVsbG8gd29ybGQK

注意:implementation和api等都是Java插件提供的!

外部构建脚本

可以使用外部构建脚本来配置当前项目,Gradle构建语言的所有内容在外部脚本中也可以使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// other.gradle
android {

buildTypes {
release {
test()
}
}
}

void test() {
println "other: method"
}

task other {
doLast {
println "other: task"
}
}

可以在build.gradle中引用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// build.gradle
apply from: 'other.gradle'

android {
buildTypes {
release {
minifyEnabled true // Enables code shrinking for the release build type.
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}

other.doLast {
println "build: task"
}

通过这种方式引用了other.gradle后,可以在build.gradle中使用other task,通过其release的buildTypes还会执行test()函数。

守护进程(Daemon)

Gradle运行在JVM上,它会一些额外的类库,但这些类库在初始化时会花费一些时间,这会导致在某些时候,Gradle在启动的时候有些慢。解决办法是使用Gradle守护进程:它是一个长期在后台运行的进程,可以更快速的执行一些构建任务。

它的实现方式是避免昂贵的启动过程和使用缓存,将项目的相关数据保存在内存当中。使用守护进程运行构建和以普通方式运行构建没有区别,只需要简单的配置,所有这些操作对于使用者来是透明的。

Gradle不使用已经运行的守护进程而创建一个新的守护进程有几个原因。基本规则是:如果没有可用的空闲或兼容的守护进程,Gradle将启动一个新的守护进程。Gradle会杀死已经闲置3个小时以上的守护进程,所以你不必担心手动清理它们。

从Gradle 3.0开始,默认情况下启用守护程序,但如果您使用的是旧版本,则应该在本地开发人员计算机上启用它。在旧版本上启动守护进程的方式:

  1. 通过命令传递参数:-Dorg.gradle.daemon=true
  2. 在gradle配置文件(GRADLE_USER_HOME/gradle.properties)中配置:org.gradle.daemon=true

守护进程是运行在后台的进程,如果连续3个小时,守护进程都没有被激活(运行Gradle的任务),那么守护进程就会停掉。当然,如果你想要手动关掉守护进程,可以执行:gradle --stop

默认情况下,Gradle将为您的构建保留1GB的堆空间,这对于大多数项目来说是足够的,一些非常大的构建可能需要更多内存来保存Gradle的模型和缓存。如果是这种情况,您可以在gradle.properties文件中检入更大的内存要求:

1
2
// gradle.properties
org.gradle.jvmargs=-Xmx2048M

Task

创建task

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 下面三种定义也一模一样
task myTask {
doLast {
println "after execute myTask"
}
}

project.task('myTask').doLast {
println "after execute myTask"
}

project.tasks.create('myTask').doLast {
println "after execute myTask"
}

还有一种创建Task的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MyTask extends DefaultTask {

@TaskAction
void action() {
println "action1+++++"
}
}

//创建 hello3 task
task hello3 (type: MyTask) {
doLast {
println "action2+++++"
}
}

->gradle hello3
action1+++++
action2+++++

Action

一个项目可以有多个Project、每个Project中又有多个Task,Task是原子性的操作,一个Task又由多个Action组成,多个Action组成一个Action List,按顺序执行。

doLast函数就是往Task的Action List的末端插入一个Action,相应的还有doFirst函数——往Action List的前端插入一个Action。doLast、doFirst都可以被调用多次。

创建Action的相关API:

1
2
3
4
5
6
7
8
9
10
11
12
13
//在Action 队列头部添加Action
Task doFirst(Action<? super Task> action);
Task doFirst(Closure action);

//在Action 队列尾部添加Action
Task doLast(Action<? super Task> action);
Task doLast(Closure action);

//已经过时了,建议用 doLast 代替
Task leftShift(Closure action);

//删除所有的Action
Task deleteAllActions();

Groovy与Kotlin

Gradle的构建脚本完全支持Groovy和Kotlin两种语言,当用Groovy书写构建脚本时,文件名为build.gradle;用kotlin书写时,文件名为build.gradle.kts。

以下是两个例子,分别用两种语言实现同一个功能:

1
2
3
4
5
6
7
8
//build.gradle
task upper {
doLast {
String someString = 'mY_nAmE'
println "Original: $someString"
println "Upper case: ${someString.toUpperCase()}"
}
}
1
2
3
4
5
6
7
8
//build.gradle.kts
tasks.register("upper") {
doLast {
val someString = "mY_nAmE"
println("Original: $someString")
println("Upper case: ${someString.toUpperCase()}")
}
}

参数

参数 含义 默认值
name task的名字 不能为空,必须指定
type task的“父类” DefaultTask
overwrite 是否替换已经存在的task false
dependsOn task依赖的task的集合 []
group task属于哪个组 null
description task的描述 null

task依赖

有些任务之间可能会有先后关系,这时候就可以用tasks之间的依赖关系来,依赖关系使用dependsOn方法来表示,比如下面的demo就表示taskX的运行依赖于taskY的运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//build.gradle
task taskX {
dependsOn 'taskY'
doLast {
println 'taskX'
}
}

task taskY {
doLast {
println 'taskY'
}
}

> gradle -q taskX
taskY
taskX

动态创建task

1
2
3
4
5
6
7
8
9
10
11
//build.gradle
4.times { counter ->
task "task$counter" {
doLast {
println "I'm task number $counter"
}
}
}

> gradle -q task1
I'm task number 1

Task操纵

一旦任务被创建后,任务之间可以通过 API 进行相互访问。

增加依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
4.times { counter ->
task "task$counter" << {
println "I'm task number $counter"
}
}

task0.dependsOn task2, task3

->output
Output of gradle -q task0
\> gradle -q task0
I'm task number 2
I'm task number 3
I'm task number 0

增加任务行为

doFirst 和 doLast 可以进行多次调用。他们分别被添加在任务的开头和结尾。当任务开始执行时这些动作会按照既定顺序进行。其中 << 操作符 是 doLast 的简写方式(弃用)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
task hello << {
println 'Hello Earth'
}
hello.doFirst {
println 'Hello Venus'
}
hello.doLast {
println 'Hello Mars'
}
hello << {
println 'Hello Jupiter'
}

task hello1

hello1.doLast {
println 'Hello world!'
}

hello1.doFirst {
println("first")
}

> gradle -q hello
Hello Venus
Hello Earth
Hello Mars
Hello Jupiter

定义默认任务

Gradle允许添加默认Task,当执行gradle命令而不指定task时就会执行这些默认的Tasks,当gradle命令指定了task时,默认的Task除非被依赖,否则不会执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//build.gradle
defaultTasks 'clean', 'run'
task clean {
doLast {
println 'Default Cleaning!'
}
}
task run {
doLast {
println 'Default Running!'
}
}
task other {
doLast {
println "I'm not a default task!"
}
}

> gradle -q
Default Cleaning!
Default Running!
> gradle -q other
I'm not a default task!

执行gradle -q 与直接调用gradle clean run效果是一样的。在多项目构建中,每个子项目都可以指定单独的默认任务。如果子项目未进行指定将会调用父项目指定的的默认任务。

Extension

在Gradle插件中可以通过自定义的Extension,实现在build脚本中增加类似于Android插件中android{}命名空间的配置,gradle可以读取这些配置,然后在自定义的插件中做处理。

ExtensionContainer

这个类与 TaskContainer 命名有点类似,TaskContainer 是用来创建并管理 Task 的,而 ExtensionContainer 则是用来创建并管理 Extension 的。通过 Project 的以下 API 可以获取到 ExtensionContainer 对象:

1
ExtensionContainer getExtensions​()

简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//先定义一个普通的java类,包含2个属性
class Foo {
int age
String username

String toString() {
return "name = ${username}, age = ${age}"
}
}

//创建一个名为 foo 的Extension
getExtensions().create("foo", Foo)

//配置Extension
foo {
age = 30
username = "hjy"
}

task testExt << {
//能直接通过 project 获取到自定义的 Extension
println project.foo
}

创建Extension的方法:

1
2
// Extension名称, 实现类, 参数
<T> T create(String var1, Class<T> var2, Object... var3);

前面的 create() 方法会创建并返回一个 Extension 对象,与之相似的还有一个 add() 方法,唯一的差别是它并不会返回一个 Extension 对象。

查找Extension的方法:

1
2
3
4
Object findByName(String name)
<T> T findByType(Class<T> type)
Object getByName(String name)
<T> T getByType(Class<T> type)

创建嵌套Extension:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class OuterExt {
String outerName
String msg
InnerExt innerExt = new InnerExt()

void outerName(String name) {
outerName = name
}

void msg(String msg) {
this.msg = msg
}

//创建内部Extension,名称为方法名 inner
void inner(Action<InnerExt> action) {
action.execute(inner)
}

//创建内部Extension,名称为方法名 inner
void inner(Closure c) {
org.gradle.util.ConfigureUtil.configure(c, innerExt)
}

String toString() {
return "OuterExt[ name = ${outerName}, msg = ${msg}] " + innerExt
}
}

class InnerExt {

String innerName
String msg

void innerName(String name) {
innerName = name
}

void msg(String msg) {
this.msg = msg
}

String toString() {
return "InnerExt[ name = ${innerName}, msg = ${msg}]"
}
}

def outExt = getExtensions().create("outer", OuterExt)

outer {
outerName "outer"
msg "this is a outer message."
inner {
innerName "inner"
msg "This is a inner message."
}
}

task testExt << {
println outExt
}

这里的关键点在于下面这2个方法的定义,只需要定义任意一个即可:

1
2
void inner(Action<InnerExt> action)
void inner(Closure c)

NamedDomainObjectContainer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
android {

buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.hmiou
}

debug {
signingConfig signingConfigs.hmiou
}
}
}

上述的release和debug可以修改成任意名字,且可以新增其它name,这是通过NamedDomainObjectContainer实现的。

创建NamedDomainObjectContainer:

1
2
3
4
// Project.container
<T> NamedDomainObjectContainer<T> container​(Class<T> type)
<T> NamedDomainObjectContainer<T> container​(Class<T> type, NamedDomainObjectFactory<T> factory)
<T> NamedDomainObjectContainer<T> container​(java.lang.Class<T> type, Closure factoryClosure

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
//这是领域对象类型定义
class TestDomainObj {

//必须定义一个 name 属性,并且这个属性值初始化以后不要修改
String name

String msg

//构造函数必须有一个 name 参数
public TestDomainObj(String name) {
this.name = name
}

void msg(String msg) {
this.msg = msg
}

String toString() {
return "name = ${name}, msg = ${msg}"
}
}

//创建一个扩展
class TestExtension {

//定义一个 NamedDomainObjectContainer 属性
NamedDomainObjectContainer<TestDomainObj> testDomains

public TestExtension(Project project) {
//通过 project.container(...) 方法创建 NamedDomainObjectContainer
NamedDomainObjectContainer<TestDomainObj> domainObjs = project.container(TestDomainObj)
testDomains = domainObjs
}

//让其支持 Gradle DSL 语法
void testDomain(Action<NamedDomainObjectContainer<TestDomainObj>> action) {
action.execute(testDomains)
}

void test() {
//遍历命名领域对象容器,打印出所有的领域对象值
testDomains.all { data ->
println data
}
}
}

//创建一个名为 test 的 Extension
def testExt = getExtensions().create("test", TestExtension, project)

test {
testDomain {
domain2 {
msg "This is domain2"
}
domain1 {
msg "This is domain1"
}
domain3 {
msg "This is domain3"
}
}
}

task myTask << {
testExt.test()
}

查找和遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//遍历
void all(Closure action)
//查找
<T> T getByName(String name)
//查找
<T> T findByName(String name)

//通过名字查找
TestDomainObj testData = testDomains.getByName("domain2")
println "getByName: ${testData}"

//遍历命名领域对象容器,打印出所有的领域对象值
testDomains.all { data ->
println data
}

需要注意的是,Gradle 中有很多容器类的迭代遍历方法有 each(Closure action)、all(Closure action),但是一般我们都会用 all(…) 来进行容器的迭代。all(…) 迭代方法的特别之处是,不管是容器内已存在的元素,还是后续任何时刻加进去的元素,都会进行遍历。

多项目构建

概述

项目结构如下:

  • mult_demo/
    • part1
    • part2

定义一个多项目构建工程需要在根目录创建一个setting 配置文件来指明构建包含哪些项目。并且这个文件必需叫 settings.gradle。本例的配置文件如下:

1
2
3
rootProject.name = 'mult_demo'
include 'part1'
include 'part2'

公共配置

根项目就像一个容器,子项目会迭代访问它的配置并注入到自己的配置中。这样我们就可以简单的为所有工程定义主配置单了。

allprojects和subprojects的区别:allprojects是对所有project的配置,包括Root Project。而subprojects是对所有Child Project的配置。

新建一个工程test,其下有两个submodule:app和lib。

1
2
// settings.gradle
include ':app',':lib'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// test: build.gradle
allprojects {
tasks.create('hello') {
doLast { task ->
print "project name is $task.project.name \n"
}
}
}

subprojects {
hello << {
print "here is subprojects \n"
}
}
->output
$ gradle -q hello
project name is test_gradle

project name is app
here is subprojects

project name is lib
here is subprojects

在rootProject下的build.gradle中:buildscript的repositories和allprojects的repositories的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//build.gradle
buildscript {
repositories {
jcenter()
google()
maven {
url 'https://maven.google.com/'
name 'Google'
}
}
dependencies {
classpath 'com.android.tools.build:gradle:2.3.3'
}
}
allprojects {
repositories {
jcenter()
google()
maven {
url "http://maven.xxxxxxxx/xxxxx"
}
}
}
  1. buildscript里是gradle脚本执行所需依赖,分别是对应的maven库和插件
  2. allprojects里是项目本身需要的依赖,比如代码中某个类是打包到maven私有库中的,那么在allprojects—>repositories中需要配置maven私有库,而不是buildscript中,不然找不到。

工程依赖

Gradle在构建api之前总是会先构建shared工程:

1
2
3
dependencies {
implementation project(':shared')
}

仓库

一个项目可以采用多个库。Gradle 会按照顺序从各个库里寻找所需的依赖文件,并且一旦找到第一个便停止搜索。

Maven中央仓库:

1
2
3
repositories {
mavenCentral()
}

Maven远程仓库:

1
2
3
4
5
repositories {
maven {
url "http://repo.mycompany.com/maven2"
}
}

远程Ivy仓库:

1
2
3
4
5
repositories {
ivy {
url "http://repo.mycompany.com/repo"
}
}

本地Ivy目录:

1
2
3
4
5
6
7
8
9
10
repositories {
ivy {
url "/home/hearing/WorkSpace/gradle/repo"
}
mavenCentral()
}

dependencies {
compile "com.hearing:part1:1.0-SNAPSHOT"
}

打包发布

发布到Ivy仓库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
uploadArchives {
repositories {
ivy {
credentials {
username "username"
password "pw"
}
url "http://repo.mycompany.com"
}
}
}

uploadArchives {
repositories {
ivy {
url "/home/hearing/WorkSpace/gradle/repo"
}
}
}

执行 gradle uploadArchives,Gradle 便会构建并上传你的 jar 包,同时会生成一个 ivy.xml 一起上传到目标仓库。

发布到Maven仓库:

1
2
3
4
5
6
7
8
apply plugin: 'maven'
uploadArchives {
repositories {
mavenDeployer {
repository(url: "file://localhost/tmp/myRepo/")
}
}
}

发布到Jcenter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
apply plugin: 'com.novoda.bintray-release'
apply plugin: 'maven'

Properties properties = new Properties()
properties.load(project.rootProject.file('local.properties')
.newDataInputStream())

// publish to bintray
publish {
userOrg = 'ljd1997'
groupId = 'com.hearing.gradle'
artifactId = 'ipcbridge'
uploadName = 'com.hearing.gradle:ipcbridge'
publishVersion = '1.0.0'
desc = 'IpcBridge for Android'
website = 'https://github.com/ljd1996/IpcBridge'
bintrayUser = "${properties.get('bintray.user')}"
bintrayKey = "${properties.get('bintray.apikey')}"
dryRun = false
}

// publish to local
uploadArchives {
repositories {
mavenDeployer {
pom.groupId = 'com.hearing.gradle'
pom.artifactId = 'ipcbridge'
pom.version = '1.0.0'
repository(url: uri('../maven'))
}
}
}

tasks.withType(JavaCompile) {
options.encoding = "UTF-8"
}

具体使用见:bintray-release-wiki

Gradle生命周期

概述

生命周期阶段:

  1. 初始化阶段
  2. 配置阶段
  3. 执行阶段

初始化阶段,Gradle 根据 settings.gradle 文件的配置为项目创建了 Project 实例。在给定的构建脚本中只定义了一个项目。在多项目构建中,这个构建阶段变得更加重要。根据你正在执行的项目,Gradle 找出哪些项目需要参与到构建中。实质为执行 settings.gradle 脚本。注意,在这个阶段当前已有的构建脚本代码都不会被执行。

用户可以在 settings.gradle 文件中调用 Settings 类的各种方法配置项目,最常用的就是 include 方法,它可以将用户新建的module加入项目中。

配置阶段,Gradle 构造了一个模型来表示任务,并参与到构建中来。增量式构建特性决定了模型中的 task 是否需要运行。配置阶段完成后,整个 build 的 project 以及内部的 Task 关系就确定了。这个阶段非常适合于为项目或指定 task 设置所需的配置。配置阶段的实质为解析每个被加入构建项目的 build.gradle 脚本,比如通过 apply 方法引入插件,为插件扩展属性进行的配置等等。

注意,项目的每一次构建的任何配置代码都可以被执行–即使你只执行 gradle tasks。

1
2
3
4
5
6
7
8
9
$ ./gradlew testPluginTask1
> Configure project :app
** Test This is my first gradle plugin **
## hello
before apply CustomPlugin
** This is my first gradle plugin. msg = null
after apply CustomPlugin
> Task :app:testPluginTask1
## This is my first gradle plugin in testPlugin task. msg = testMSG

比如这里是执行 task,但是仍然经历了配置阶段。

执行阶段,所有的 task 都应该以正确的顺序被执行。执行顺序时由它们的依赖决定的。如果任务被认为没有被修改过,将被跳过。

Gradle 的增量式的构建特性紧紧地与生命周期相结合。

生命周期监听

有两种方式可以编写回调声明周期事件:在闭包中,或者是通过 Gradle API 所提供的监听器接口实现。

Projet 提供的一些生命周期回调方法:

  • afterEvaluate(closure),afterEvaluate(action)
  • beforeEvaluate(closure),beforeEvaluate(action)

Gradle 提供的一些生命周期回调方法:

  • afterProject(closure),afterProject(action)
  • beforeProject(closure),beforeProject(action)
  • buildFinished(closure),buildFinished(action)
  • projectsEvaluated(closure),projectsEvaluated(action)
  • projectsLoaded(closure),projectsLoaded(action)
  • settingsEvaluated(closure),settingsEvaluated(action)
  • addBuildListener(buildListener)
  • addListener(listener)
  • addProjectEvaluationListener(listener)

可以看到,每个方法都有两个不同参数的方法,一个接收闭包作为回调,另外一个接受 Action 作为回调。

注意:一些声明周期事件只有在适当的位置上声明才会发生。

beforeEvaluate

beforeEvaluate()是在 project 开始配置前调用,当前的 project 作为参数传递给闭包。

这个方法很容易误用,你要是直接当前子模块的 build.gradle 中使用是肯定不会调用到的,因为Project都没配置好所以也就没它什么事情,这个代码块的添加只能放在父工程的 build.gradle 中,如此才可以调用的到。

1
2
3
4
5
this.project.subprojects { sub ->
sub.beforeEvaluate { project
println "#### Evaluate before of "+project.path
}
}

Action 作为参数的方法:

1
2
3
4
5
6
7
8
this.project.subprojects { sub ->
sub.beforeEvaluate(new Action<Project>() {
@Override
void execute(Project project) {
println "#### Evaluate before of "+project.path
}
})
}

afterEvaluate

afterEvaluate 是一般比较常见的一个配置参数的回调方式,只要 project 配置成功均会调用,不论是在父模块还是子模块。参数类型以及写法与afterEvaluate相同:

1
2
3
project.afterEvaluate { pro ->
println("#### Evaluate after of " + pro.path)
}

afterProject

设置一个 project 配置完毕后立即执行的闭包或者回调方法。

afterProject 在配置参数失败后会传入两个参数,前者是当前 project,后者显示失败信息。

1
2
3
4
5
6
7
this.getGradle().afterProject { project,projectState ->
if(projectState.failure){
println "Evaluation afterProject of "+project+" FAILED"
} else {
println "Evaluation afterProject of "+project+" succeeded"
}
}

beforeProject

设置一个 project 配置前执行的闭包或者回调方法,当前 project 作为参数传递给闭包。

子模块的该方法声明在 root project 中回调才会执行,root project 的该方法声明在 settings.gradle 中才会执行。

1
2
3
gradle.beforeProject { p ->
println("Evaluation beforeProject"+p)
}

buildFinished

构建结束时的回调,此时所有的任务都已经执行,一个构建结果的对象 BuildResult 作为参数传递给闭包。

1
2
3
gradle.buildFinished { r ->
println("buildFinished "+r.failure)
}

projectsEvaluated

所有的 project 都配置完成后的回调,此时,所有的project都已经配置完毕,准备开始生成 task 图。gradle 对象会作为参数传递给闭包。

1
2
3
gradle.projectsEvaluated {gradle ->
println("projectsEvaluated")
}

projectsLoaded

当 setting 中的所有project 都创建好时执行闭包回调。gradle 对象会作为参数传递给闭包。

这个方法也比较特殊,只有声明在适当的位置上才会发生,如果将这个声明周期挂接闭包声明在 build.gradle 文件中,那么将不会发生这个事件,因为项目创建发生在初始化阶段。

放在 settings.gradle 中是可以执行的。

1
2
3
gradle.projectsLoaded {gradle ->
println("@@@@@@@ projectsLoaded")
}

settingsEvaluated

当 settings.gradle 加载并配置完毕后执行闭包回调,setting对象已经配置好并且准备开始加载构建 project。

这个回调在 build.gradle 中声明也是不起作用的,在 settings.gradle 中声明是可以的。

1
2
3
gradle.settingsEvaluated {
println("@@@@@@@ settingsEvaluated")
}

前面我们说过,设置监听回调还有另外一种方法,通过设置接口监听添加回调来实现。作用的对象均是所有的 project 实现。

addProjectEvaluationListener

1
2
3
4
5
6
7
8
9
10
gradle.addProjectEvaluationListener(new ProjectEvaluationListener() {
@Override
void beforeEvaluate(Project project) {
println " add project evaluation lister beforeEvaluate,project path is: "+project
}
@Override
void afterEvaluate(Project project, ProjectState state) {
println " add project evaluation lister afterProject,project path is:"+project
}
})

addListener

添加一个实现来 listener 接口的对象到 build。

addBuildListener

添加一个 BuildListener 对象到 Build 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
gradle.addBuildListener(new BuildListener() {
@Override
void buildStarted(Gradle gradle) {
println("### buildStarted")
}
@Override
void settingsEvaluated(Settings settings) {
println("### settingsEvaluated")
}
@Override
void projectsLoaded(Gradle gradle) {
println("### projectsLoaded")
}
@Override
void projectsEvaluated(Gradle gradle) {
println("### projectsEvaluated")
}
@Override
void buildFinished(BuildResult result) {
println("### buildFinished")
}
})

TaskExecutionGraph

在配置时,Gradle 决定了在执行阶段要运行的 task 的顺序,他们的依赖关系的内部结构被建模为一个有向无环图,我们可以称之为 taks 执行图,它可以用 TaskExecutionGraph 来表示。可以通过 gradle.taskGraph 来获取。

在 TaskExecutionGraph 中也可以设置一些 Task 生命周期的回调:

1
2
3
4
5
addTaskExecutionGraphListener(TaskExecutionGraphListener listener)
addTaskExecutionListener(TaskExecutionListener listener)
afterTask(Action action),afterTask(Closure closure)
beforeTask(Action action),beforeTask(Closure closure)
whenReady(Action action),whenReady(Closure closure)

addTaskExecutionGraphListener

添加 task 执行图的监听器,当执行图配置好会执行通知。

1
2
3
4
5
6
gradle.taskGraph.addTaskExecutionGraphListener(new TaskExecutionGraphListener() {
@Override
void graphPopulated(TaskExecutionGraph graph) {
println("@@@ gradle.taskGraph.graphPopulated ")
}
})

addTaskExecutionListener

添加 task 执行监听器,当 task 执行前或者执行完毕会执行回调发出通知。

1
2
3
4
5
6
7
8
9
10
gradle.taskGraph.addTaskExecutionListener(new TaskExecutionListener() {
@Override
void beforeExecute(Task task) {
println("@@@ gradle.taskGraph.beforeTask "+task)
}
@Override
void afterExecute(Task task, TaskState state) {
println("@@@ gradle.taskGraph.afterTask "+task)
}
})

afterTask

设置一个 task 执行完毕的闭包或者回调方法。该 task 作为参数传递给闭包。

1
2
3
gradle.taskGraph.afterTask { task ->
println("### gradle.taskGraph.afterTask "+task)
}

beforeTask

设置一个 task 执行前的闭包或者回调方法。该 task 作为参数传递给闭包。

1
2
3
gradle.taskGraph.beforeTask { task ->
println("### gradle.taskGraph.beforeTask "+task)
}

whenReady

设置一个 task 执行图准备好后的闭包或者回调方法。该 taskGrahp 作为参数传递给闭包。

1
2
3
gradle.taskGraph.whenReady { taskGrahp ->
println("@@@ gradle.taskGraph.whenReady ")
}

生命周期顺序

我们通过在生命周期回调中添加打印的方法来看一下他们的执行顺序。为了看一下配置 task 的时机,我们在 app 模块中创建来一个 taks:

1
2
3
4
5
6
7
8
9
task hello {
doFirst {
println '*** task hello doFirst'
}
doLast {
println '*** task hello doLast'
}
println '*** config task hello'
}

为了保证生命周期的各个回调方法都被执行,我们在 settings.gradle 中添加各个回调方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
gradle.addBuildListener(new BuildListener() {
@Override
void buildStarted(Gradle gradle) {
println("### gradle.buildStarted")
}
@Override
void settingsEvaluated(Settings settings) {
println("### gradle.settingsEvaluated")
}
@Override
void projectsLoaded(Gradle gradle) {
println("### gradle.projectsLoaded")
}
@Override
void projectsEvaluated(Gradle gradle) {
println("### gradle.projectsEvaluated")
}
@Override
void buildFinished(BuildResult result) {
println("### gradle.buildFinished")
}
})
gradle.afterProject { project,projectState ->
if(projectState.failure){
println "### gradld.afterProject "+project+" FAILED"
} else {
println "### gradle.afterProject "+project+" succeeded"
}
}
gradle.beforeProject { p ->
println("### gradle.beforeProject "+p)
}
gradle.allprojects(new Action<Project>() {
@Override
void execute(Project project) {
project.beforeEvaluate { project
println "### project.beforeEvaluate "+project
}
project.afterEvaluate { pro ->
println("### project.afterEvaluate " + pro)
}
}
})
gradle.taskGraph.addTaskExecutionListener(new TaskExecutionListener() {
@Override
void beforeExecute(Task task) {
if (task.name.equals("hello")){
println("@@@ gradle.taskGraph.beforeTask "+task)
}
}
@Override
void afterExecute(Task task, TaskState state) {
if (task.name.equals("hello")){
println("@@@ gradle.taskGraph.afterTask "+task)
}
}
})
gradle.taskGraph.addTaskExecutionGraphListener(new TaskExecutionGraphListener() {
@Override
void graphPopulated(TaskExecutionGraph graph) {
println("@@@ gradle.taskGraph.graphPopulated ")
}
})
gradle.taskGraph.whenReady { taskGrahp ->
println("@@@ gradle.taskGraph.whenReady ")
}

执行 task hello:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
./gradlew hello
### gradle.settingsEvaluated
### gradle.projectsLoaded
> Configure project :
### gradle.beforeProject root project 'TestSomething'
### project.beforeEvaluate root project 'TestSomething'
### gradle.afterProject root project 'TestSomething' succeeded
### project.afterEvaluate root project 'TestSomething'
> Configure project :app
### gradle.beforeProject project ':app'
### project.beforeEvaluate project ':app'
*** config task hello
### gradle.afterProject project ':app' succeeded
### project.afterEvaluate project ':app'
> Configure project :common
### gradle.beforeProject project ':common'
### project.beforeEvaluate project ':common'
### gradle.afterProject project ':common' succeeded
### project.afterEvaluate project ':common'
### gradle.projectsEvaluated
@@@ gradle.taskGraph.graphPopulated
@@@ gradle.taskGraph.whenReady
> Task :app:hello
@@@ gradle.taskGraph.beforeTask task ':app:hello'
*** task hello doFirst
*** task hello doLast
@@@ gradle.taskGraph.afterTask task ':app:hello'
BUILD SUCCESSFUL in 1s
1 actionable task: 1 executed
### gradle.buildFinished

因此,生命周期回调的执行顺序是:

1
2
3
4
5
6
7
8
9
10
gradle.settingsEvaluated->
gradle.projectsLoaded->
gradle.beforeProject->
project.beforeEvaluate->
gradle.afterProject->
project.afterEvaluate->
gradle.projectsEvaluated->
gradle.taskGraph.graphPopulated->
gradle.taskGraph.whenReady->
gradle.buildFinished

Gradle命令行

多任务调用

可以以列表的形式在命令行中一次调用多个任务。例如 gradle compile test 命令会依次调用,并且每个任务仅会被调用一次。compile 和 test 任务以及它们的依赖任务,无论它们是否被包含在脚本中:即无论是以命令行的形式定义的任务还是依赖于其它任务都会被调用执行。

下面定义了四个任务。dist 和 test 都依赖于 compile,只用当 compile 被调用之后才会调用 gradle dist test 任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
task compile << {
println 'compiling source'
}
task compileTest(dependsOn: compile) << {
println 'compiling unit tests'
}
task test(dependsOn: [compile, compileTest]) << {
println 'running unit tests'
}
task dist(dependsOn: [compile, test]) << {
println 'building the distribution'
}

\> gradle dist test
:compile
compiling source
:compileTest
compiling unit tests
:test
running unit tests
:dist
building the distribution

由于每个任务仅会被调用一次,所以调用 gradle test test 与调用 gradle test 效果是相同的。

使用gradle dist -x test可排除test任务。

默认情况下只要有任务调用失败 Gradle 就是中断执行。这可能会使调用过程更快,但那些后面隐藏的错误不会被发现。所以你可以使用–continue 在一次调用中尽可能多的发现所有问题。采用了–continue 选项,Gralde会调用每一个任务以及它们依赖的任务。而不是一旦出现错误就会中断执行。所有错误信息都会在最后被列出来。

简化任务名

当试图调用某个任务的时候,无需输入任务的全名。只需提供足够的可以唯一区分出该任务的字符即可。例如,上面的例子也可以这么写。用 gradle di 来直接调用 dist 任务。

也可以用驼峰命名的任务中每个单词的首字母进行调用。例如,可以执行 gradle compTest或gradle cT 来调用 compileTest 任务。

简化后仍然可以使用 -x 参数。

选择构建位置

调用 gradle 时,默认情况下总是会构建当前目录下的文件,可以使用-b 参数选择构建的文件,当使用此参数时settings.gradle 将不会生效

1
2
3
4
5
6
7
8
// subdir/myproject.gradle

task hello << {
println "using build file '$buildFile.name' in '$buildFile.parentFile.name'."
}

\> gradle -q -b subdir/myproject.gradle hello
using build file 'myproject.gradle' in 'subdir'.

另外,可以使用 -p 参数来指定构建的目录,例如在多项目构建中可以用 -p 来替代 -b 参数。

1
2
\> gradle -q -p subdir hello
using build file 'build.gradle' in 'subdir'.

获取项目信息

  • 执行 gradle projects 会列出子项目名称列表。
  • 在项目中可以用description属性来指定这些描述信息:
  • 执行 gradle tasks 会列出项目中所有任务,这会显示项目中所有的默认任务以及每个任务的描述。默认情况下,这只会显示那些被分组的任务。可以通过为任务设置 group 属性和 description 来把这些信息展示到结果中。
  • 可以用–all 参数来收集更多任务信息。这会列出项目中所有任务以及任务之间的依赖关系。

获取依赖列表

执行 gradle dependencies 会列出项目的依赖列表,所有依赖会根据任务区分,以树型结构展示出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
\> gradle -q dependencies api:dependencies webapp:dependencies
\------------------------------------------------------------
Root project
\------------------------------------------------------------
No configurations
\------------------------------------------------------------
Project :api - The shared API for the application
\------------------------------------------------------------
compile
\--- org.codehaus.groovy:groovy-all:2.2.0
testCompile
\--- junit:junit:4.11
\--- org.hamcrest:hamcrest-core:1.3
\------------------------------------------------------------
Project :webapp - The Web application implementation
\------------------------------------------------------------
compile
+--- project :api
| \--- org.codehaus.groovy:groovy-all:2.2.0
\--- commons-io:commons-io:1.2
testCompile
No dependencies

过滤依赖信息

可以通过–configuration 参数来查看指定构建任务的依赖情况。

1
2
3
4
5
6
7
\> gradle -q api:dependencies --configuration testCompile
\------------------------------------------------------------
Project :api - The shared API for the application
\------------------------------------------------------------
testCompile
\--- junit:junit:4.11
\--- org.hamcrest:hamcrest-core:1.3

查看特定依赖

执行 Running gradle dependencyInsight 可以查看指定的依赖情况。如下例。

1
2
3
4
\> gradle -q webapp:dependencyInsight --dependency groovy --configuration compile
org.codehaus.groovy:groovy-all:2.2.0
\--- project :api
\--- compile

dependencyInsight 任务是’Help’任务组中的一个。这项任务需要进行配置才可以。如果用了 Java 相关的插件,那么 dependencyInsight 任务已经预先被配置到’Compile’下了。只需要通过’–dependency’参数来制定所需查看的依赖即可。如果不想用默认配置的参数项可以通过 ‘–configuration’ 参数来进行指定。

获取项目属性列表

执行 gradle properties 可以获取项目所有属性列表。

1
2
3
4
5
6
7
8
9
10
11
\> gradle -q api:properties
\------------------------------------------------------------
Project :api - The shared API for the application
\------------------------------------------------------------
allprojects: [project ':api']
ant: org.gradle.api.internal.project.DefaultAntBuilder@12345
antBuilderFactory: org.gradle.api.internal.project.DefaultAntBuilderFactory@12345
artifacts: org.gradle.api.internal.artifacts.dsl.DefaultArtifactHandler@12345
asDynamicObject: org.gradle.api.internal.ExtensibleDynamicObject@12345
buildDir: /home/user/gradle/samples/userguide/tutorial/projectReports/api/build
buildFile: /home/user/gradle/samples/userguide/tutorial/projectReports/api/build.gradle

构建日志

–profile 参数可以收集一些构建期间的信息并保存到 build/reports/profile 目录下并且以构建时间命名这些文件。

如果采用了 buildSrc,那么在 buildSrc/build 下同时也会生成一份日志记录记录。

Try Run

有时可能只想知道某个任务在一个任务集中按顺序执行的结果,但并不想实际执行这些任务。那么可以用-m 参数。例如 gradle -m clean compile 会调用 clean 和 compile,这与 tasks 可以形成互补,让你知道哪些任务可以用于执行。

插件

概述

Gradle本身只是一个框架,它的核心部分在构建过程中起的作用实际上很小。真正起作用的步骤来自于插件,比如编译Java代码的功能就是由“java”插件提供。

分类

Gradle的插件分为两种类型:脚本插件(script plugins)和二进制插件(binary plugins)。

  • 脚本插件就是额外的构建脚本,脚本插件通常用来对构建过程进行深度配置,同样遵循声明式的思想。脚本插件常常作为另一个脚本文件(即*.gradle)文件被放置在项目目录中,以本地文件的形式应用插件。虽然脚本插件也可以放置在云端,比如说共享仓库jcenter,但不常用,一般共享的插件都是二进制插件。
  • 二进制插件就是实现了Plugin接口的类,可以用java、kotlin和groovy编写,更容易进行测试,还可以被打包成jar包共享出去。

一个插件项目最开始写的时候通常都是以脚本插件的形式,因为它们更容易编写,当项目变得更有价值之后再被迁移成二进制插件,这样更容易测试以及共享。

1
2
3
4
5
// 脚本插件
apply from: 'other.gradle'

// 二进制插件
apply plugin: pluginName

Gradle的插件根据是否内置又分为核心插件和社区插件,核心插件是Gradle必要的插件(如java插件),核心插件随着Gradle安装已经解析好了,只需要应用即可;社区插件是共享在社区上的插件,在需要时才被解析到本地。

实现方式

  1. 脚本插件:可以直接在build.gradle中编写一个实现了org.gradle.api.plugins接口的类,这个类就是一个插件
  2. buildSrc:Gradle提供了一种在现有项目中编写二进制插件的方法,依旧遵循声明式的思想。二进制插件可以用Java、Kotlin、Groovy多种语言编写,例如使用的是Groovy语言,那么就创建目录rootProjectDir/buildSrc/src/main/groovy,如果是kotlin则为.../kotlin。Gradle会自动编译这些目录下的插件,并且将它们解析,使得在项目的所有构建脚本中都可以应用这些目录下的插件。
  3. 独立项目:可以为插件单独建立一个项目,这个项目可以被打包成一个jar包,可以实现更好的复用,还可以分享到在线仓库成为社区插件,给别的项目使用。

apply

传统apply语法

每个插件都有一个id,可以通过指定插件的id的方式来应用插件:

1
2
//build.gradle
apply plugin: 'java'

也可以直接指定插件的实现类的类名:

1
2
//build.gradle
apply plugin: JavaPlugin

对于内置插件,我们可以直接通过上述的apply语法应用插件,指定类名时也不需要前缀(org.gradle.api.plugins)以及.class后缀。

但对于社区插件,我们还需要先解析插件。解析插件使用的是buildscript{}语法块,语法规则如下:

1
2
3
4
5
6
7
8
9
10
11
//build.gradle
buildscript {
repositories {
jcenter()
}
dependencies {
classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:0.4.1'
}
}

apply plugin: 'com.jfrog.bintray'
  • repositories{}语法块,用于指定仓库,有以下常用选项:
  • dependencies{}语法块,用于指定要使用的插件,由classpath关键字指定,格式为:classpath ‘group:name:version’

plugins DSL语法

核心插件只需要指定插件名:

1
2
3
4
5
6
7
8
9
//build.gradle
plugins {
id 'java'
}

//build.gradle.kts
plugins {
java
}

社区插件还需要指定版本:

1
2
3
4
5
6
7
8
9
//build.gradle
plugins {
id 'com.jfrog.bintray' version '0.4.1'
}

//build.gradle.kts
plugins {
id("com.jfrog.bintray") version "0.4.1"
}

pluginManagement

pluginManagement语法块是专门用于管理整个项目插件的,只能出现在settings.gradle文件或”初始化脚本“中,并且在settings.gradle文件中pluginManagement必须是文件中的第一个块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//build.gradle
pluginManagement {
// 用于统一指定整个项目使用的某个插件的版本
plugins {
id 'org.gradle.sample.hello' version "${helloPluginVersion}"
}
resolutionStrategy {
}
repositories {
maven {
url '../maven-repo'
}
gradlePluginPortal()
ivy {
url '../ivy-repo'
}
}
}

//init.gradle
settingsEvaluated { settings ->
settings.pluginManagement {
plugins {
}
resolutionStrategy {
}
repositories {
}
}
}

在根目录下的settings.gradle指定了插件的版本后,在build.gradle中只需要指定插件的id即可,插件版本配置在gradle.properties中。

自定义插件

方法一:build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//使用插件
apply plugin: CustomPlugin

//自定义插件:实现Plugin类接口,重写apply方法
class CustomPlugin implements Plugin<Project> {
@Override
void apply(Project project) {
project.task('CustomPluginTask') {
doLast {
println "大家好,我是一个自定义插件,在这里写下你的具体功能"
}
}
}
}

方法二:buildSrc

在现有项目下新建buildSrc目录,其中目录结构如下:

1
2
3
4
src/
main/
groovy/
build.gradle

在groovy/目录下新建一个包(com.hearing.plugin),包内新建一个groovy文件,这里起名为PluginImpl.groovy,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// PluginImpl.groovy
package com.hearing.plugin

import org.gradle.api.Plugin
import org.gradle.api.Project

class PluginImpl implements Plugin<Project> {
void apply(Project project) {
project.task('testTask') << {
println "Hello gradle plugin"
}
}
}

build.gradle内容如下:

1
2
3
4
5
6
7
8
plugins {
id 'groovy'
}

dependencies {
implementation gradleApi()
implementation localGroovy()
}

使用apply plugin: com.hearing.plugin.PluginImpl即可引用该插件。

方法三:独立项目

如果想要分享给其他人或者自己用,可以在一个独立的项目中编写插件,这个项目会生成一个包含插件类的JAR文件。

新建一个项目,在项目内部新建一个module(module_plugin),其目录结构如下:

1
2
3
4
5
src/
main/
groovy/
resources/
build.gradle
  • 在groovy/目录下新建一个包(com.hearing.plugin),包内新建一个groovy文件,这里起名为PluginImpl.groovy。
  • 在resources目录下新建一个META-INF文件夹,在META-INF文件夹下新建一个gradle-plugins文件夹,在gradle-plugins里创建一个.properties文件,文件名是上步骤里的那个包名com.hearing.plugin.properties(也可以取其它名,该properties文件的文件名是就是插件名):
1
2
// com.hearing.plugin.properties
implementation-class=com.hearing.plugin.PluginImpl

build.gradle内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apply plugin: 'groovy'
apply plugin: 'maven'

dependencies {
implementation gradleApi()
implementation localGroovy()
}

repositories {
mavenCentral()
}

uploadArchives {
repositories {
mavenDeployer {
//设置插件的GAV参数
pom.groupId = 'com.hearing.plugin' //你的包名
pom.artifactId = 'myPlugin'
pom.version = '1.1.9' //版本号
repository(url: uri('../repo'))
}
}
}

然后执行uploadArchives可以得到jar包,在其它项目里可以引用它:

  1. 项目build.gradle文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    buildscript {
    ...
    repositories {
    ...
    maven {//本地Maven仓库地址
    url uri('../repo')
    }
    }
    dependencies {
    ...
    classpath 'com.hearing.plugin:myPlugin:1.1.9'
    }
    }
  2. module的build.gradle文件

    1
    apply plugin: 'com.hearing.plugin'

Groovy插件

添加groovy插件后可以在项目中同时包含java和groovy源文件,源码包含在src/main/groovy目录下。

1
2
3
4
5
6
7
8
9
apply plugin: 'groovy'

repositories {
mavenCentral()
}

dependencies {
implementtation 'org.codehaus.groovy:groovy-all:2.2.0'
}

运行 gradle build 将会对项目进行编译,测试和打成 jar 包。

Java插件

1
apply plugin: 'java'

项目布局

默认项目布局:

目录 意义
src/main/java 产品的Java源代码
src/main/resources 产品的资源
src/test/java Java 测试源代码
src/test/resources 测试资源
sourceSet/java 给定的源集的Java源代码
sourceSet/resources 给定的源集的资源

更改项目布局:

1
2
3
4
5
6
7
8
9
10
sourceSets {
main {
java {
srcDir 'src/java'
}
resources {
srcDir 'src/resources'
}
}
}

Tasks

  • build:当执行 gradle build 时,Gralde 会编译并执行单元测试,并且将 src/main/* 下面 class 和资源文件打包。
  • clean:删除 build 目录以及所有构建完成的文件。
  • assemble:编译并打包 jar 文件,但不会执行单元测试。一些其他插件可能会增强这个任务的功能。例如,如果采用了 War 插件,这个任务便会为你的项目打出 War 包。
  • check:编译并测试代码。一些其他插件也可能会增强这个任务的功能。例如,如果采用了 Code-quality 插件,这个任务会额外执行 Checkstyle。

外部依赖

  • implementation:会添加依赖到编译路径,并且会将依赖打包到输出(aar或apk等),但是在编译时不会将依赖的实现暴露给其他module,也就是只有在运行时其他module才能访问这个依赖中的实现。使用这个配置,可以显著提升构建时间,因为它可以减少重新编译的module的数量。
  • api:会添加依赖到编译路径,并且会将依赖打包到输出(aar或apk),与implementation不同,这个依赖可以传递,其他module无论在编译时和运行时都可以访问这个依赖的实现。
  • compileOnly:Gradle把依赖加到编译路径,编译时使用,不会打包到输出(aar或apk)。
  • runtimeOnly:gradle添加依赖只打包到APK/Jar/aar,运行时使用,但不会添加到编译路径。
  • annotationProcessor:用于注解处理器的依赖配置。
  • testImplementation
  • debugImplementation
  • releaseImplementation

classpath、implementation、api 的区别:

  • classpath:一般是添加buildscript本身需要运行的东西,buildScript是用来加载gradle脚本自身需要使用的资源,可以声明的资源包括依赖项、第三方插件、maven仓库地址等。某种意义上来说,classpath声明的依赖,不会编译到最终的apk里面。
  • implementation、api :在模块中的build.gradle中,给dependencies中添加的使应用程序所需要的依赖包,也就是项目运行所需要的东西。

用法:

1
2
3
4
5
6
7
8
9
apply plugin: 'java'

repositories {
mavenCentral()
}

dependencies {
implementation group: 'org.hibernate', name: 'hibernate-core', version: '3.6.7.Final'
}

源集

可以使用sourceSets属性访问项目的源集,另外还有一个sourceSets{}的脚本块,可以传入一个闭包来配置源集容器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Various ways to access the main source set
println sourceSets.main.output.classesDir
println sourceSets['main'].output.classesDir

sourceSets {
println main.output.classesDir
}

sourceSets {
main {
println output.classesDir
}
}

// Iterate over the source sets
sourceSets.all {
println name
}

可以创建一个名为api的source set来存放程序中的接口类:

1
2
3
sourceSets {
api
}

当然,以上配置也可以与main放在一起。在默认情况下,该api所对应的Java源文件目录被Gradle设置为${path-to-project}/src/api/java,而资源文件目录则被设置成了${path-to-project}/src/api/resources

源集中的属性如下图:

源集属性

项目打包

jar

缺省时jar.

1
2
3
4
jar {
baseName = ‘myProjectName’
version = ‘2.0
}

war

1
2
3
4
5
apply plugin: "war"

war {

}

可执行jar

1
2
3
4
5
6
7
8
apply plugin: 'java'

jar {
manifest {
attributes 'Main-Class': 'com.hearing.Hello'
attributes 'Class-Path': 'my-lib.jar'
}
}

这种打包方式不会将依赖的jar包打包到一起.

完整构建脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
apply plugin: 'java'

sourceCompatibility = 1.5
version = '1.0'

jar {
manifest {
attributes 'Implementation-Title': 'Gradle Quickstart', 'Implementation-Version': version
}
}

repositories {
mavenCentral()
}

dependencies {
implementation group: 'commons-collections', name: 'commons-collections', version: '3.2'
}

test {
systemProperties 'property': 'value'
}

uploadArchives {
repositories {
flatDir {
dirs 'repos'
}
}
}

概述

Maven 是基于项目对象模型(POM),可以通过一小段描述信息来管理项目的构建,
报告和文档的软件项目管理工具。

maven项目结构

1
2
3
4
5
6
7
└─src
| ├─main
| │ ├─java
| │ └─resources
| └─test
| └─java
└─pom.xml

maven的坐标与仓库

maven中任何依赖、插件、项目输出等够成为构件,构件通过坐标唯一标识;
镜像仓库,镜像仓库的修改在maven的conf目录下setting.xml中的mirros节点

1
2
3
4
5
6
7
8
<mirror>
<id>mirrorId</id>
《mirror of标识为那个仓库添加镜像,这里也可以使用 * 代表所有仓库,一旦使用了镜像仓库,所有对原仓库的访问都将转为对镜像仓库的访问》
<mirrorOf>repositoryId</mirrorOf>
<name>Human Readable Name for this Mirror.</name>
<url>http://my.repository.com/repo/path</url>
</mirror>
</mirrors>

默认情况下,maven的仓库在C:\Users\Administrator.m2\repostory,一般不将仓库放在C盘,若需要修改位置,修改maven的conf中的settings.xml中

1
<localRepository>D:/SoftWare/ThirdPart4Java/Maven350/Responsitory</localRepository>

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<!--指定当前版本-->
<modelVersion>4.0.0</modelVersion>
<!--当前项目的坐标-->
<groupId>公司网址反写+项目名</groupId>
<!--maven的项目和实际项目并不对应,maven项目对应实际项目的一个模块,即一个实际项目会有多个maven项目-->
<artifactId>项目名-模块名</artifactId>
<!--0大版本号.0分支版本号.1小版本号-->
<!--
snapshot 快照版本
alpha 内部测试版
beta 公测版本
release 稳定版本
GA 正式发布版本
-->
<version>0.0.1-SNAPSHOT</version>
<!--项目打包方式,默认jar,也可以为war/zip/pom等-->
<packaging>jar</packaging>

<!--项目描述名,用于生成相关文档-->
<name>hi</name>
<!--项目地址-->
<url>http://maven.apache.org</url>
<!--项目描述-->
<description></description>
<!--项目开发者-->
<developers></developers>
<!--许可证信息-->
<license></license>
<!--组织信息-->
<organization></organization>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<!--依赖列表-->
<dependencies>
<!--每个dependency都表示一个依赖项-->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<!--scope表示作用范围,Junit仅用于test
http://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html
-->
<scope>test</scope>
<!--依赖项是否可选,默认false,即必须强制引入-->
<optional>true/false</optional>
<!--依赖排除列表,和dependecies类似,内含多个exclusion,注意exclusions在dependency标签内-->
<exclusions></exclusions>
</dependency>
</dependencies>

<!--dependencyManagement模块和dependencices类似,不同之处在于他并不会执行
一般在父模块中定义,用于子模块继承
-->
<dependencyManagement></dependencyManagement>

<build>
<!--插件列表-->
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<!--用在子模块中,用于对父模块pom的继承-->
<parent></parent>
<!--聚合多个maven项目,将各模块合并-->
<modules></modules>
</project>

maven项目生命周期

一个完成的项目周期包括:清理clean、编译compile、测试test、打包package、集成测试、验证test、部署deploy

maven的三套生命周期

  1. clean,清理项目

    • pre-clean,执行清理前的工作
    • clean,清理上一次生成的所有文件
    • post-clean, 执行清理后的文件
  2. default,构建项目

    • compile
    • test
    • package
    • install
  3. site,生成项目站点

    • pre-site,生成站点前需要完成的工作
    • site,生成项目的站点文档
    • post-site,生成站点后需要完成的工作
    • site-deploy,发不生成的站点到服务器

mvn基本命令

  1. mvn compile,表示编译项目
  2. mvn test,运行test
  3. mvn package,为项目生成jar或war
  4. mvn clean,删除target文件
  5. mvn install,安装本地jar到目录
  6. mvn archetype:generate,自动创建符合maven要求的目录结构
  7. mvn archetype:generate
    -DgroupId=xxx(这里一般就是组织名,例如公司网址反写+项目名)
    -DartifactId=sss(这里一般是项目在公司的唯一标识,例如项目名-模块名)
    -Dversion=11111 指定版本号
    -Dpackage=dddddd代码所在的包
  8. mvn site, 在target目录生成项目站点文档

maven模板创建Java项目

mvn archetype:generate -DgroupId={project-packaging} -DartifactId={project-name}-DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false

这告诉 Maven 来从 maven-archetype-quickstart 模板创建 Java 项目。

安装和引用jar包

安装现有jar到本地maven仓库

mvn install:install-file -Dfile=xxx-version.jar -DgroupId=groupId -DartifactId=artifactId -Dversion={version} -Dpackaging=jar

在pom.xml中引用本地仓库包

1
2
3
4
5
<dependency>
<groupId>com.google.code</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3</version>
</dependency>

运行jar包

用mvn package导出的包中,如果没有在pom文件中将依赖包打进去,是没有依赖包。

  1. 打包时指定了主类,可以直接用java -jar xxx.jar。
  2. 打包是没有指定主类,可以用java -cp xxx.jar 主类名称(绝对路径)。
  3. 要引用其他的jar包,可以用java -cp $CLASSPATH:xxxx.jar 主类名称(绝对路径)。

生成项目站点文档

mvn site

maven 依赖冲突

例如A/B分别依赖两个不同版本的构建,这里就会存在冲突,maven采用如下的原则

  1. 短路优先,

    • A -> B -> C -> X(jar);

    • A -> D -> X(jar);

      这里,maven将采用第二个路径

  2. 路径相同,则先声明先依赖

maven聚合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.hongxing</groupId>
<artifactId>hongxing.aggreation</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>pom</packaging>

<name>hongxing.aggreation</name>
<url>http://maven.apache.org</url>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<modules>
<module>../hongxing-bege</module>
<module>../hongxing-nange</module>
<module>../hongxing-shanji</module>
</modules>
</project>

maven继承

新建一个 maven 项目(dependencyManagement 标签中的 dependency 并不会运行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.hongxing</groupId>
<artifactId>hongxing-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<!--这里package定义为pom-->
<packaging>pom</packaging>

<name>hongxing-parent</name>
<url>http://maven.apache.org</url>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!--这里定义Junit的版本-->
<junit.version>3.8.1</junit.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<!--因为在property中定义了该属性,这里就可以向SpEL那样引用-->
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>

另一个项目中

1
2
3
4
5
<parent>
<groupId>com.hongxing</groupId>
<artifactId>hongxing-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>

概述

Groovy是一种基于JVM的面向对象语言,如果不声明public/private等访问权限的话,Groovy中类及其变量默认都是public的。

Groovy中有以下特点:

  • 同时支持静态和动态类型
  • 支持运算符重载
  • 对正则表达式的本地支持
  • 可以使用现有的Java库及Java语法
  • 不需要分号
  • 可省略return关键字,默认用最后一个表达式为返回
  • 使用命名的参数初始化beans和默认的构造器:new Server(name: "Obelix", cluster: aCluster)
  • Groovy里的is()方法等同于Java里的==,Groovy中的==是更智能的equals()
  • 在同一个bean中使用with()来重复某一个操作:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // Java
    server.name = application.name
    server.status = status
    server.sessionCount = 3
    server.start()
    server.stop()

    // Groovy
    server.with {
    name = application.name
    status = status
    sessionCount = 3
    start()
    stop()
    }

基本语法

1
2
3
4
5
class Test {
static void main(String[] args) {
println('Hello World.')
}
}

在Groovy中下列包是默认导入的:

1
2
3
4
5
6
7
8
9
10
import java.lang.* 
import java.util.*
import java.io.*
import java.net.*

import groovy.lang.*
import groovy.util.*

import java.math.BigInteger
import java.math.BigDecimal

Groovy中使用def来定义变量。

数据类型

内置数据类型

  • byte -这是用来表示字节值。例如2。
  • short -这是用来表示一个短整型。例如10。
  • int -这是用来表示整数。例如1234。
  • long -这是用来表示一个长整型。例如10000090。
  • float -这是用来表示32位浮点数。例如12.34。
  • double -这是用来表示64位浮点数,这些数字是有时可能需要的更长的十进制数表示。例如12.3456565。
  • char -这定义了单个字符文字。例如“A”。
  • Boolean -这表示一个布尔值,可以是true或false。
  • String -这些是以字符串的形式表示的文本。

Groovy提供了多种表示String字面量的方法。 Groovy中的字符串可以用单引号(’),双引号(“)或三引号(”“”)括起来。此外,由三重引号括起来的Groovy字符串可以跨越多行。

Groovy中的字符串是字符的有序序列,字符串索引从零开始,还允许负索引从字符串的末尾开始计数。

1
2
3
4
5
6
7
8
9
10
11
class Example { 
static void main(String[] args) {
String sample = "Hello world";
println(sample[4]); // Print the 5 character in the string

//Print the 1st character in the string starting from the back
println(sample[-1]);
println(sample[1..2]);//Prints a string starting from Index 1 to 2
println(sample[4..2]);//Prints a string starting from Index 4 back to 2
}
}

对象类型(包装器类型)

  • java.lang.Byte
  • java.lang.Short
  • java.lang.Integer
  • java.lang.Long
  • java.lang.Float
  • java.lang.Double
  • 父类为Number

Groovy中的变量可以通过两种方式定义: 使用数据类型的语法,或者使用def关键字。对于变量定义,必须明确提供类型名称或在替换中使用“def”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Example { 
static void main(String[] args) {
//Example of a int datatype
int x = 5;

//Example of a long datatype
long y = 100L;

//Example of a floating point datatype
float a = 10.56f;

//Example of a double datatype
double b = 10.5e40;

//Example of a BigInteger datatype
BigInteger bi = 30g;

//Example of a BigDecimal datatype
BigDecimal bd = 3.5g;

println(x);
println(y);
println(a);
println(b);
println(bi);
println(bd);
}
}

运算符

Groovy语言支持正常的算术运算符任何语言。

范围运算符:

1
2
def range = 5..10;
println(range.get(3))

范围由序列中的第一个和最后一个值表示,Range可以是包含或排除。包含范围包括从第一个到最后一个的所有值,而独占范围包括除最后一个之外的所有值。

  • 1..10 - 包含范围的示例
  • 1..<10 - 独占范围的示例
  • ‘a’..’x’ - 范围也可以由字符组成
  • 10..1 - 范围也可以按降序排列
  • ‘x’..’a’ - 范围也可以由字符组成并按降序排列。

可通过list[index]方式访问。

方法 用法
contains() 检查范围是否包含特定值
get() 返回此范围中指定位置处的元素。
getFrom() 获得此范围的下限值。
getTo() 获得此范围的上限值。
isReverse() 这是一个反向的范围,反向迭代
size() 返回此范围的元素数。
subList() 返回此指定的fromIndex(包括)和toIndex(排除)之间的此范围部分的视图

循环

语句 描述
while 首先通过计算条件表达式(布尔值)来执行,如果结果为真,则执行while循环中的语句。
for 用于遍历一组值。
for-in 用于遍历一组值。
break 用于改变循环和switch语句内的控制流。
continue 补充了break语句。它的使用仅限于while和for循环。

方法

Groovy中的方法是使用返回类型或使用def关键字定义的。方法可以接收任意数量的参数。定义参数时,不必显式定义类型,可以给参数使用初始值。可以添加修饰符,如public,private和protected。默认情况下,如果未提供可见性修饰符,则该方法为public。

最简单的方法是没有参数的方法,如下所示:

1
2
3
4
5
6
7
8
9
10
class Example {
static def DisplayName() {
println("This is how methods work in groovy");
println("This is an example of a simple method");
}

static void main(String[] args) {
DisplayName();
}
}

以下是使用参数的简单方法的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Example {
static void sum(int a,int b) {
int c = a+b;
println(c);
}
// 或
static void sum(a, b) {
int c = a + b;
println(c);
}

static void main(String[] args) {
sum(10,5);
}
}

Groovy中还有一个规定来指定方法中的参数的默认值。 如果没有值传递给参数的方法,则使用缺省值。 如果使用非默认和默认参数,则必须注意,默认参数应在参数列表的末尾定义。

以下是使用参数的简单方法的示例:

1
2
3
4
5
6
7
8
9
10
class Example { 
static void sum(int a, int b = 5) {
int c = a + b;
println(c);
}

static void main(String[] args) {
sum(6);
}
}

I/O

Groovy在使用I/O时提供了许多辅助方法,同时也可以使用Java原生IO类。

读取文件

以下示例将输出Groovy中的文本文件的所有行。方法eachLine内置在Groovy中的File类中,目的是确保文本文件的每一行都被读取。

1
2
3
4
5
6
7
8
9
import java.io.File

class Example {
static void main(String[] args) {
new File("E:/Example.txt").eachLine {
line -> println "line : $line";
}
}
}

如果要将文件的整个内容作为字符串获取,可以使用文件类的text属性,即:String s = file.text

写入文件

如果想写入文件,则需要使用Write类输出文本到一个文件中:

1
2
3
4
5
6
7
8
import java.io.File 
class Example {
static void main(String[] args) {
new File('E:/','Example.txt').withWriter('utf-8') {
writer -> writer.writeLine 'Hello World'
}
}
}

获取文件的大小

如果要获取文件的大小,可以使用文件类的length属性来获取:

1
2
3
4
5
6
class Example {
static void main(String[] args) {
File file = new File("E:/Example.txt")
println "The file ${file.absolutePath} has ${file.length()} bytes"
}
}

文件是否是目录

如果要查看路径是文件还是目录,可以使用File类的isFile和isDirectory选项:

1
2
3
4
5
6
7
class Example { 
static void main(String[] args) {
def file = new File('E:/')
println "File? ${file.isFile()}"
println "Directory? ${file.isDirectory()}"
}
}

创建目录

如果要创建一个新目录,可以使用File类的mkdir函数:

1
2
3
4
5
6
class Example {
static void main(String[] args) {
def file = new File('E:/Directory')
file.mkdir()
}
}

删除文件

如果要删除文件,可以使用File类的delete功能:

1
2
3
4
5
6
class Example {
static void main(String[] args) {
def file = new File('E:/Example.txt')
file.delete()
}
}

复制文件

Groovy还提供将内容从一个文件复制到另一个文件的功能:

1
2
3
4
5
6
7
class Example {
static void main(String[] args) {
def src = new File("E:/Example.txt")
def dst = new File("E:/Example1.txt")
dst << src.text
}
}

获取目录内容

Groovy还提供了列出驱动器中的驱动器和文件的功能,以下示例显示如何使用File类的listRoots函数显示机器上的驱动器:

1
2
3
4
5
6
7
8
class Example { 
static void main(String[] args) {
def rootFiles = new File("test").listRoots()
rootFiles.each {
file -> println file.absolutePath
}
}
}

以下示例显示如何使用File类的eachFile函数列出特定目录中的文件:

1
2
3
4
5
6
7
class Example {
static void main(String[] args) {
new File("E:/Temp").eachFile() {
file->println file.getAbsolutePath()
}
}
}

如果要递归显示目录及其子目录中的所有文件,则可以使用File类的eachFileRecurse函数:

1
2
3
4
5
6
7
class Example { 
static void main(String[] args) {
new File("E:/temp").eachFileRecurse() {
file -> println file.getAbsolutePath()
}
}
}

List

  • [11,12,13,14] - 整数值列表
  • [‘Angular’,’Groovy’,’Java’] - 字符串列表
  • [1,2,[3,4],5] - 嵌套列表
  • [‘Groovy’,21,2.11] - 异构的对象引用列表
  • [] - 空列表

可通过list[index]方式访问。

方法 用法
add() 将新值附加到此列表的末尾。
contains() 如果此列表包含指定的值,则返回true。
get() 返回此列表中指定位置的元素。
isEmpty() 如果此列表不包含元素,则返回true
minus() 创建一个由原始元素组成的新列表,而不是集合中指定的元素。
plus() 创建由原始元素和集合中指定的元素组成的新列表。
pop() 从此列表中删除最后一个项目
remove() 删除此列表中指定位置的元素。
reverse() 创建与原始列表的元素相反的新列表
size() 获取此列表中的元素数。
sort() 返回原始列表的排序副本。

多重赋值和多返回值:

1
2
3
4
5
6
7
// def(var1, var2, var3) = [value1, value2, value3]
def (a, b, c) = fun()

def fun() {
// ...
return [f1, f2, f3]
}

Map

  • [‘TopicName’: ‘Lists’, ‘TopicName’: ‘Maps’] - 具有TopicName作为键的键值对的集合及其相应的值。
  • [:] - 空映射。
方法 用法
containsKey() 此映射是否包含此键
get() 查找此Map中的键并返回相应的值。如果此映射中没有键的条目,则返回null。
keySet() 获取此映射中的一组键。
put() 将指定的值与此映射中的指定键相关联。如果此映射先前包含此键的映射,则旧值将替换为指定的值。
size() 返回此地图中的键值映射的数量。
values() 返回此地图中包含的值的集合视图。

键可以是任意类型:

1
2
def m = [1: 2, 2: 3]
println(m.get(2))

Date

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Example { 
static void main(String[] args) {
Date date = new Date();
// 分配一个Date对象并初始化它,以便它表示分配的时间,以最近的毫秒为单位。
System.out.println(date.toString());
}
}
-> output:
Thu Dec 10 21:31:15 GST 2015

class Example {
static void main(String[] args) {
Date date = new Date(100);
// 分配一个Date对象并将其初始化以表示自标准基准时间(称为“该历元”,即1970年1月1日,00:00:00 GMT)起指定的毫秒数。
System.out.println(date.toString());
}
}
->output:
Thu Jan 01 04:00:00 GST 1970

下面是一些常用方法:

方法 作用
after() 测试此日期是否在指定日期之后。
equals() 比较两个日期的相等性。当且仅当参数不为null时,结果为true,并且是表示与该对象时间相同的时间点(毫秒)的Date对象。
compareTo() 比较两个日期的顺序。
toString() 将此Date对象转换为字符串
before() 测试此日期是否在指定日期之前。
getTime() 返回自此Date对象表示的1970年1月1日,00:00:00 GMT以来的毫秒数。
setTime() 设置此Date对象以表示一个时间点,即1970年1月1日00:00:00 GMT之后的时间毫秒。

regex

Groovy使用〜“regex”表达式本地支持正则表达式,引号中包含的文本表示用于比较的表达式:

1
def regex = ~'Groovy'
字符 说明
\ 将下一字符标记为特殊字符、文本、反向引用或八进制转义符。例如,”n”匹配字符”n”。”\n”匹配换行符。序列”\\“匹配”\“,”\(“匹配”(“。
^ 匹配输入字符串开始的位置。如果设置了 RegExp 对象的 Multiline 属性,^ 还会与”\n”或”\r”之后的位置匹配。
$ 匹配输入字符串结尾的位置。如果设置了 RegExp 对象的 Multiline 属性,$ 还会与”\n”或”\r”之前的位置匹配。
* 零次或多次匹配前面的字符或子表达式。例如,zo* 匹配”z”和”zoo”。* 等效于 {0,}。
+ 一次或多次匹配前面的字符或子表达式。例如,”zo+”与”zo”和”zoo”匹配,但与”z”不匹配。+ 等效于 {1,}。
? 零次或一次匹配前面的字符或子表达式。例如,”do(es)?”匹配”do”或”does”中的”do”。? 等效于 {0,1}。
{n} n 是非负整数。正好匹配 n 次。例如,”o{2}”与”Bob”中的”o”不匹配,但与”food”中的两个”o”匹配。
{n,} n 是非负整数。至少匹配 n 次。例如,”o{2,}”不匹配”Bob”中的”o”,而匹配”foooood”中的所有 o。”o{1,}”等效于”o+”。”o{0,}”等效于”o*”。
{n,m} M 和 n 是非负整数,其中 n <= m。匹配至少 n 次,至多 m 次。例如,”o{1,3}”匹配”fooooood”中的头三个 o。’o{0,1}’ 等效于 ‘o?’。注意:您不能将空格插入逗号和数字之间。
? 当此字符紧随任何其他限定符(*、+、?、{n}、{n,}、{n,m})之后时,匹配模式是”非贪心的”。”非贪心的”模式匹配搜索到的、尽可能短的字符串,而默认的”贪心的”模式匹配搜索到的、尽可能长的字符串。例如,在字符串”oooo”中,”o+?”只匹配单个”o”,而”o+”匹配所有”o”。
. 匹配除”\r\n”之外的任何单个字符。若要匹配包括”\r\n”在内的任意字符,请使用诸如”[\s\S]”之类的模式。
x|y 匹配 x 或 y。例如,’z
[xyz] 字符集。匹配包含的任一字符。例如,”[abc]”匹配”plain”中的”a”。
[^xyz] 反向字符集。匹配未包含的任何字符。例如,”[^abc]”匹配”plain”中”p”,”l”, “i”,”n”。
[a-z] 字符范围。匹配指定范围内的任何字符。例如,”[a-z]”匹配”a”到”z”范围内的任意小写字母。
[^a-z] 反向范围字符。匹配不在指定的范围内的任何字符。例如,”[^a-z]”匹配任何不在”a”到”z”范围内的任何字符。
\b 匹配一个字边界,即字与空格间的位置。例如,”er\b”匹配”never”中的”er”,不匹配”verb”中的”er”。
\B 非字边界匹配。”er\B”匹配”verb”中的”er”,但不匹配”never”中的”er”。
\cx 匹配 x 指示的控制字符。例如,\cM 匹配 Control-M 或回车符。x 的值必须在A-Z 或 a-z 之间。如果不是这样,则假定 c 就是”c”字符本身。
\d 数字字符匹配。等效于 [0-9]。
\D 非数字字符匹配。等效于 [^0-9]。
\f 换页符匹配。等效于 \x0c 和 \cL。
\n 换行符匹配。等效于 \x0a 和 \cJ。
\r 匹配一个回车符。等效于 \x0d 和 \cM。
\s 匹配任何空白字符,包括空格、制表符、换页符等。与 [ \f\n\r\t\v] 等效。
\S 匹配任何非空白字符。与 [^ \f\n\r\t\v] 等效。
\t 制表符匹配。与 \x09 和 \cI 等效。
\v 垂直制表符匹配。与 \x0b 和 \cK 等效。
\w 匹配任何字类字符,包括下划线。与”[A-Za-z0-9_]”等效。
\W 与任何非单词字符匹配。与”[^A-Za-z0-9_]”等效。

面向对象

Groovy中的面向对象与Java很类似。

特征

特征是语言的结构构造,允许:

  • 行为的组成。
  • 接口的运行时实现。
  • 与静态类型检查/编译的兼容性

它们可以被看作是承载默认实现和状态的接口,使用trait关键字定义trait。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Test {
static void main(String[] args) {
Student s = new Student()
s.id = 20
s.age = 10
println(s.id)
println(s.age)
s.total()
s.test1()
s.test2()
s.test3()
}
}

interface Total {
void total()
}

trait Marks implements Total {
void test1() {
println('Marks')
}

void total() {
println('total')
}
}

trait Marks1 extends Marks {
int age;

void test2() {
println('Marks1 = ' + age)
}
}

trait Marks2 {
void test3() {
println('Marks2')
}
}

class Student implements Marks1, Marks2 {
int id
}

闭包

概述

闭包是一个短的匿名代码块。它通常跨越几行代码。一个方法甚至可以将代码块作为参数。它们是匿名的。

1
2
3
4
5
static void main(String[] args) {
def clos = {println "Hello World"};
clos.call();
clos();
}

在Groovy中,每个闭包都是groovy.lang.Closure的实例,执行闭包对象有两种,一是直接用括号+参数,二是调用call方法+参数。

闭包的形参

1
2
3
4
5
6
7
8
9
static void main(String[] args) {
def clos = {param -> println "Hello ${param}"};
clos.call("World");
}

static void main(String[] args) {
def clos = {println "Hello ${it}"};
clos.call("World");
}

闭包和变量

闭包可以在定义闭包时引用变量。

1
2
3
4
5
6
7
8
9
10
11
12
static void main(String[] args) {
def str1 = "Hello";
def clos = {param -> println "${str1} ${param}"}
clos.call("World");

// We are now changing the value of the String str1 which is referenced in the closure
str1 = "Welcome";
clos.call("World");
}
->output:
Hello World
Welcome World

在方法中使用闭包

以下示例显示如何将闭包作为参数发送到方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Example {
def static Display(clo) {
// This time the $param parameter gets replaced by the string "Inner"
clo.call("Inner");
}

static void main(String[] args) {
def str1 = "Hello";
def clos = { param -> println "${str1} ${param}" }
clos.call("World");

// We are now changing the value of the String str1 which is referenced in the closure
str1 = "Welcome";
clos.call("World");

// Passing our closure to a method
Example.Display(clos);
}
}
->output:
Hello World
Welcome World
Welcome Inner

def filter(array, block) {
for (val in array) {
block(val)
}
}

iarray = [1, 2, 3, 4, 5, 6, 7, 8, 9]
total = []
filter(iarray, { if (it % 2 == 0) total << it })
println total // [2, 4, 6, 8]

total = []
filter(iarray, { if (it > 5) total << it })
println total // [6, 7, 8, 9]

当闭包作为最后一个参数时,可以有其它写法

1
filter(iarray) { if (it % 2 == 0) total << it }

集合和字符串中的闭包

List,Map和String方法接受一个闭包作为参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Example {
static void main(String[] args) {
def lst = [11, 12, 13, 14];
lst.each {println it}
}
}

class Example {
static void main(String[] args) {
def mp = ["TopicName" : "Maps", "TopicDescription" : "Methods in Maps"]
mp.each {println it}
mp.each {println "${it.key} maps to: ${it.value}"}
}
}

class Example {
static void main(String[] args) {
def lst = [1,2,3,4];
lst.each {println it}
println("The list will only display those numbers which are divisible by 2")
lst.each{num -> if(num % 2 == 0) println num}
}
}

方法

方法 用法
find() find方法查找集合中与某个条件匹配的第一个值。
findAll() 它找到接收对象中与闭合条件匹配的所有值。
any() & every() 方法any迭代集合的每个元素,检查布尔谓词是否对至少一个元素有效。
collect() 该方法通过集合收集迭代,使用闭包作为变换器将每个元素转换为新值。

闭包委托

在闭包内部,有三个内置对象this,owner,delegate,我们可以直接this,owner,delegate调用,或者用get方法:

  • getThisObject() 等于 this

  • getOwner() 等于 owner

  • getDelegate() 等于delegate

  • this 该属性指向定义闭包的类的实例对象

  • owner 该属性和 this 类似,但是闭包中也可以定义闭包的,如果闭包 A 内定义了闭包 B,那么闭包 B 的 owner 指向的是其外部的闭包 A

  • delegate 该值初始化时是和 owner 相同的,但是该值可以通过接口将其它对象赋值给 delegate,来实现方法的委托功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Hello {

static void main(String[] args) {
def p = new Person(name: "hearing")
p.clo()
}
}

class Person {
String name
def clo = {
println(name)
// 都有.name属性
println(this)
println(owner)
println(delegate)

def clo1 = {
println(name)
println(this)
// 没有.name属性
println(owner)
println(delegate)
}
clo1.delegate = Person
clo1()
}
}
->output:
hearing
Person@35d176f7
Person@35d176f7
Person@35d176f7
hearing
Person@35d176f7
Person$_closure1@6ebc05a6
class Person

设置delegate的意义就是将闭包和一个具体的对象关联起来,在闭包中可以访问被代理对象的属性和方法,如果闭包所在的类或闭包中和被代理的类中有相同名称的方法,那么有几种代理策略:

  • Closure.OWNER_FIRST是默认策略。优先在owner寻找,owner没有再delegate
  • Closure.DELEGATE_FIRST:优先在delegate寻找,delegate没有再owner
  • Closure.OWNER_ONLY:只在owner中寻找
  • Closure.DELEGATE_ONLY:只在delegate中寻找
  • Closure.TO_SELF:

用法实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# Person.groovy
class Person {
String name
int age

void eat(String food) {
println("你喂的${food}真难吃")
}

@Override
String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}'
}
}

# Main.groovy
class Main {
void eat(String food){
println "我根本不会吃,不要喂我${food}"
}
def cc = {
name = "hanmeimei"
age = 26
eat("油条")
}

static void main(String... args) {
Main main = new Main()
Person person = new Person(name: "lilei", age: 14)
println person.toString()

main.cc.delegate = person
// main.cc.setResolveStrategy(Closure.DELEGATE_FIRST)
main.cc.setResolveStrategy(Closure.OWNER_FIRST)
main.cc.call()
println person.toString()
}
}

DSL(领域定义语言)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Gradle {
String type
String version
String result

def type(type) {
this.type = type
}

def version(version) {
this.version = version
}

def result(str) {
result = version + type
println str + result
}

static def build(closure) {
Gradle gradle = new Gradle()
closure.delegate = gradle
closure.call()
}
}

Gradle.build {
type 'compile'
version '1.11 '
result 'build: '
}

-> output
// build: 1.11 compile

Groovy XML

概述

Groovy语言提供了对XML语言的丰富支持,其两个最基本的XML类是:

  • XML生成器:groovy.xml.MarkupBuilder
  • XML解析器:groovy.util.XmlParser

MarkupBuilder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import groovy.xml.MarkupBuilder 

class Example {
static void main(String[] args) {
def mp = [1 : ['Enemy Behind', 'War, Thriller','DVD','2003',
'PG', '10','Talk about a US-Japan war'],
2 : ['Transformers','Anime, Science Fiction','DVD','1989',
'R', '8','A scientific fiction'],
3 : ['Trigun','Anime, Action','DVD','1986',
'PG', '10','Vash the Stam pede'],
4 : ['Ishtar','Comedy','VHS','1987', 'PG',
'2','Viewable boredom ']]

def mB = new MarkupBuilder()

// Compose the builder
def MOVIEDB = mB.collection('shelf': 'New Arrivals') {
mp.each { sd ->
mB.movie('title': sd.value[0]) {
type(sd.value[1])
format(sd.value[2])
year(sd.value[3])
rating(sd.value[4])
stars(sd.value[4])
description(sd.value[5])
}
}
}
}
}

运行上面代码会生成如下XML内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<collection shelf='New Arrivals'>
<movie title='Enemy Behind'>
<type>War, Thriller</type>
<format>DVD</format>
<year>2003</year>
<rating>PG</rating>
<stars>PG</stars>
<description>10</description>
</movie>
<movie title='Transformers'>
<type>Anime, Science Fiction</type>
<format>DVD</format>
<year>1989</year>
<rating>R</rating>
<stars>R</stars>
<description>8</description>
</movie>
<movie title='Trigun'>
<type>Anime, Action</type>
<format>DVD</format>
<year>1986</year>
<rating>PG</rating>
<stars>PG</stars>
<description>10</description>
</movie>
<movie title='Ishtar'>
<type>Comedy</type>
<format>VHS</format>
<year>1987</year>
<rating>PG</rating>
<stars>PG</stars>
<description>2</description>
</movie>
</collection>

XmlParser

对于上面的XML,可以使用下述方法解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import groovy.xml.MarkupBuilder 
import groovy.util.*

class Example {

static void main(String[] args) {

def parser = new XmlParser()
def doc = parser.parse("Movies.xml");

doc.movie.each{ bk->
print("Movie Name:")
println "${bk['@title']}"

print("Movie Type:")
println "${bk.type[0].text()}"

print("Movie Format:")
println "${bk.format[0].text()}"

print("Movie year:")
println "${bk.year[0].text()}"

print("Movie rating:")
println "${bk.rating[0].text()}"

print("Movie stars:")
println "${bk.stars[0].text()}"

print("Movie description:")
println "${bk.description[0].text()}"
println("*******************************")
}
}
}

运行上面的程序可以得到以下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Movie Name:Enemy Behind 
Movie Type:War, Thriller
Movie Format:DVD
Movie year:2003
Movie rating:PG
Movie stars:10
Movie description:Talk about a US-Japan war
*******************************
Movie Name:Transformers
Movie Type:Anime, Science Fiction
Movie Format:DVD
Movie year:1989
Movie rating:R
Movie stars:8
Movie description:A schientific fiction
*******************************
Movie Name:Trigun
Movie Type:Anime, Action
Movie Format:DVD
Movie year:1986
Movie rating:PG
Movie stars:10
Movie description:Vash the Stam pede!
*******************************
Movie Name:Ishtar
Movie Type:Comedy
Movie Format:VHS
Movie year:1987
Movie rating:PG
Movie stars:2
Movie description:Viewable boredom

Groovy JSON

概述

Groovy语言提供了两个类来处理JSON格式的数据:

  • JSON生成器:groovy.json.JsonOutput
  • JSON解析器:groovy.json.JsonSlurper

JsonOutput

  • 用法:Static string JsonOutput.toJson(datatype obj)
  • 参数:可以是数据类型的对象,数字,布尔,字符,字符串,日期,地图,闭包等。
  • 返回类型:一个JSON字符串。
1
2
3
4
5
6
7
8
import groovy.json.JsonOutput

class Example {
static void main(String[] args) {
def output = JsonOutput.toJson([name: 'John', ID: 1])
println(output);
}
}

以上程序的输出如下:

1
{"name":"John","ID":1}

JsonOutput也可以用于普通的Groovy对象:

1
2
3
4
5
6
7
8
9
10
11
12
import groovy.json.JsonOutput  
class Example {
static void main(String[] args) {
def output = JsonOutput.toJson([ new Student(name: 'John', ID: 1), new Student(name: 'Mark', ID: 2)])
println(output);
}
}

class Student {
String name
int ID;
}

以上程序的输出如下:

1
[{"name":"John","ID":1},{"name":"Mark","ID":2}]

JsonSlurper

JsonSlurper是一个将JSON文本或阅读器内容解析为Groovy数据结构的类,如Map,列表和原始类型,如Integer,Double,Boolean和String等。

  • 用法:def slurper = new JsonSlurper()

JsonSlurper类自带了一些用于解析器实现的变体,例如当读取从Web服务器的响应返回的JSON时使用解析器JsonParserLax变量是有益的,此parser允许在JSON文本中存在注释以及没有引号字符串等。要指定此类型的解析器需要在定义JsonSlurper的对象时使用JsonParserType.LAX解析器类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
http.request( GET, TEXT ) {
headers.Accept = 'application/json'
headers.'User-Agent' = USER_AGENT

response.success = {
res, rd ->
def jsonText = rd.text

//Setting the parser type to JsonParserLax
def parser = new JsonSlurper().setType(JsonParserType.LAX)
def jsonResp = parser.parseText(jsonText)
}
}

类似地,以下附加的解析器类型在Groovy中可用:

  • JsonParserCharArray解析器基本上采用一个JSON字符串并对底层字符数组进行操作。在值转换期间,它复制字符子数组(称为“斩波”的机制)并单独操作它们。
  • JsonFastParser是JsonParserCharArray的一个特殊变体,是最快的解析器。JsonFastParser也称为索引覆盖解析器,在解析给定的JSON字符串期间,它尽可能努力地避免创建新的字符数组或String实例。它只保留指向底层原始字符数组的指针。此外,它会尽可能晚地推迟对象创建。
  • JsonParserUsingCharacterSource是一个非常大的文件的特殊解析器。它使用一种称为“字符窗口化”的技术来解析具有恒定性能特征的大型JSON文件(大型意味着超过2MB大小的文件)。

文本解析:

1
2
3
4
5
6
7
8
9
10
11
import groovy.json.JsonSlurper 

class Example {
static void main(String[] args) {
def jsonSlurper = new JsonSlurper()
def object = jsonSlurper.parseText('{ "name": "John", "ID" : "1"}')

println(object.name);
println(object.ID);
}
}

以上程序的输出如下:

1
2
John 
1

解析整数列表:

我们可以使用每个的List方法,并传递一个闭包。

1
2
3
4
5
6
7
8
9
import groovy.json.JsonSlurper

class Example {
static void main(String[] args) {
def jsonSlurper = new JsonSlurper()
Object lst = jsonSlurper.parseText('{ "List": [2, 3, 4, 5] }')
lst.each { println it }
}
}

以上程序的输出如下:

1
List=[2, 3, 4, 5, 23, 42]

解析基本数据类型列表:

JSON解析器还支持字符串,数字,对象,true,false和null的原始数据类型。JsonSlurper类将这些JSON类型转换为相应的Groovy类型。

1
2
3
4
5
6
7
8
9
10
11
12
import groovy.json.JsonSlurper

class Example {
static void main(String[] args) {
def jsonSlurper = new JsonSlurper()
def obj = jsonSlurper.parseText ''' {"Integer": 12, "fraction": 12.55, "double": 12e13}'''

println(obj.Integer);
println(obj.fraction);
println(obj.double);
}
}

以上程序的输出如下:

1
2
3
12
12.55
1.2E+14

概述

Termux-app是一个Android上的Linux虚拟机工具,它拥有自己的包管理工具和软件源,可以实现很多Linux上的功能。源码地址:https://github.com/termux/termux-app

Termux在初始化安装时会从远端下载一个对应系统架构的bootstraps-arch.zip文件,它是一个Linux的基本环境,其目录结构如下:

1
2
3
4
5
6
7
8
9
10
bootstraps-arch/
├── bin
├── etc
├── include
├── lib
├── libexec
├── share
├── SYMLINKS.txt
├── tmp
└── var

bootstraps文件跟app的包名所绑定,如果需要将该功能集成到自己的Android项目中,则需要修改包名参数为自己的app包名,指定自定义的软件仓库,然后重新编译bootstraps.zip文件,在Termux-app项目中替换引入。

Compiling packages(termux-packages)

需要在机器上安装docker环境,由于编译termux-packages需要一些系统环境,因此使用docker来完成这个编译过程无疑是一个比较省时间的做法。docker的镜像名为:termux/package-builder

  1. 获取https://github.com/termux/termux-packages仓库

  2. 修改termux-packages/scripts/build/termux_step_setup_variables.sh(最好修改所有的package_name):

    1
    2
    "${TERMUX_PREFIX:="/data/data/$package_name/files/usr"}"
    "${TERMUX_ANDROID_HOME:="/data/data/$package_name/files/home"}"
  3. 运行docker脚本进入docker容器:./scripts/run-docker.sh

  4. 在容器中开始编译过程:./build-package.sh -a arch $lib_name,会在debs目录下生成对应的deb包。

    我在编译时使用的是-a参数值是all,必需的package可以在编译脚本中看到(下文会提到)。在build的过程中可能会出现依赖包下载失败的问题,可以在网上查找对应的依赖包下载之后放置到某个地方,然后修改对应packages的build.sh脚本参数,使之从自己定义的url出下载依赖包,build.sh的位置在:termux-packages/packages/$package/build.sh

Create apt repository

概述

由于termux软件源中的deb包是与包名绑定的,因此如果需要使用软件源则需要自己修改包名后重新编译对应的package包,然后将生成的deb包上传到自己的软件仓库上,本节主要介绍如何搭建一个apt repository。具体的指引可以参考https://wiki.debian.org/DebianRepository/Setup中的文档。

以下是我搭建的软件源目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
hearing/
├── bootstraps
│   ├── bootstrap-aarch64
│   ├── bootstrap-aarch64.zip
│   ├── bootstrap-arm.zip
│   ├── bootstrap-i686.zip
│   └── bootstrap-x86_64.zip
├── repository
│   ├── dists
│ | └── stable
│ | ├── InRelease
│ | ├── main
│ | │   ├── binary-aarch64
│ | │   │   └── Packages
│ | │   ├── binary-all
│ | │   │   └── Packages
│ | │   ├── binary-arm
│ | │   │   └── Packages
│ | │   ├── binary-i686
│ | │   │   └── Packages
│ | │   └── binary-x86_64
│ | │   └── Packages
│ | ├── Release
| | ├── Release.gpg
| | └── repo.asc
│   └── pool
│   ├── aarch64
│    | ├── xxx.deb
│   ├── all
│    | ├── xxx.deb
│   ├── arm
│    | ├── xxx.deb
│   ├── i686
│    | ├── xxx.deb
│   └── x86_64
│    ├── xxx.deb
└── tmp
├── xxx.tar.gz
  • bootstraps目录下放的是最终编译成功的zip文件
  • repository是自己制作的软件仓库
  • tmp放置的是上一步编译中下载失败的一些依赖

引用

根据https://wiki.debian.org/DebianRepository/Setup中的说法,仓库分为两种,一种比较简单的是trivial archive,而另外一种复杂的仓库称为official archive。在一个official archive中,典型特征是顶层有个 dists 目录和 pool 目录。这样的好处是:

  • 将所有类型CPU的包列表(Packages或者Packages.gz文件)放在一个文件里面,这样每个机器要获取的包列表就比较小。
  • 不同套件/不同CPU可共用的deb包(主要是那些 _all.deb)和源代码包,也只在 pool/all目录下存放一份。
  • 源代码包(.dsc,orig.tar.xz)有路径存放,这样 dget / apt source 可以取到源代码包。

对应的/etc/apt/sources.list配置如下:

1
deb http://192.168.56.47:80/hearing/repository stable main

配置软件源的格式为:

1
deb|deb-src uri distribution [component1] [component2] [...]

生成Packages

Packages文件包括每个deb包的位置,描述,版本等信息,生成命令:

1
2
dpkg-scanpackages all/ /dev/null| gzip -9c > dists/stable/main/binary-all/Packages.gz
dpkg-scanpackages all/ /dev/null| > dists/stable/main/binary-all/Packages

生成Release

Release文件里面包含了 Packages 等文件的大小和校验和(包含MD5/SHA1/SHA256/SHA512 多种值),如果这个文件里面所描述的 Packages 大小与校验和与实际读取到的文件不一致,apt 会拒绝这个仓库。生成命令:

1
apt-ftparchive release $dir > Release

生成Release.gpg 和 InRelease 文件

Release.gpg 是一个签名文件,随同 Release 一起出现的,比较老的客户端只认这两个文件,而 InRelease 是内嵌签名的(也就是说,将原来 Release 的内容和 Release.gpg 的内容揉到一起了,注意这里不是简单地拼到一起),新的客户端才支持这个这个文件,观察一下 Debian 和 Ubuntu 的仓库 ( http://mirrors.ustc.edu.cn/debian/dists/jessie/, http://mirrors.ustc.edu.cn/ubuntu/dists/xenial/ ) ,可以看到 Debian 的仓库只有 Release 和 Release.gpg 这两个文件,而 Ubuntu 仓库里面这三个文件都有。

如何生成这两个文件:

  • 生成自己的gpg key: gpg --list-keys || gpg --gen-key
  • 生成Release.gpg: gpg --armor --detach-sign --sign -o Release.gpg Release
  • 生成InRelease: gpg --clearsign -o InRelease Release

导入公钥

当运行apt update的时候,会出现警告:The following signatures couldn't be verified because the public key is not available: NO_PUBKEY 722D2AFAD8BAD548

也就是说 InRelease / Release.gpg 虽然签名了,但由于这个签名所用的公钥没有被接受,因此还是不能正常使用,有三种解决方法:

  1. 服务端将公钥导出,然后提供给客户端导入:

    1
    2
    3
    4
    5
    # 导出
    gpg --export --armor 722D2AFAD8BAD548 -o my-repo.gpg-key.asc

    # 导入
    sudo apt-key add my-repo.gpg-key.asc
  2. 客户端在执行apt update的时候,添加--allow-insecure-repositories选项;在执行apt install pkg的时候,添加--allow-unauthenticated选项。

  3. 用户修改仓库的配置,改为deb [trusted=yes] http://192.168.56.47:80/hearing/repository stable main

Getting bootstraps(termux-packaging)

  1. 将获得的deb文件上传到一个apt repository
  2. 获取https://github.com/termux/termux-packaging仓库
  3. 在scripts目录下运行:./generate-bootstraps.sh -p /data/data/$package_name/files/usr -r http://localhost/hearing/repository
  4. 上传得到的bootstraps.zip文件(可能需要修改bootstraps.zip中的source.list中的软件源)

generate-bootstraps.sh脚本中可以看到需要的依赖包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# Package manager.
pull_package apt
pull_package game-repo
pull_package science-repo

# Core utilities.
pull_package bash
pull_package bzip2
pull_package command-not-found
pull_package coreutils
pull_package curl
pull_package dash
pull_package diffutils
pull_package findutils
pull_package gawk
pull_package grep
pull_package gzip
pull_package less
pull_package procps
pull_package psmisc
pull_package sed
pull_package tar
pull_package termux-exec
pull_package termux-tools
pull_package xz-utils

# Additional.
pull_package busybox
pull_package ed
pull_package dos2unix
pull_package inetutils
pull_package net-tools
pull_package patch
pull_package unzip
pull_package util-linux

Termux-app

将Termux-app源码中的bootstraps.zip的url替换成我们自己的url。

附录

Java自动将deb文件分目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.io.File;
import java.io.IOException;

public class Main {
public static void main(String[] args) throws IOException {
File file = new File("dir/debs");
File[] debs = file.listFiles();
for (File f : debs) {
if (f.getName().endsWith("aarch64.deb")) {
Runtime.getRuntime().exec("cp "+f.getPath() + " diraarch64");
} else if (f.getName().endsWith("all.deb")) {
Runtime.getRuntime().exec("cp "+f.getPath() + " dirall");
} else if (f.getName().endsWith("arm.deb")) {
Runtime.getRuntime().exec("cp "+f.getPath() + " dirarm");
} else if (f.getName().endsWith("i686.deb")) {
Runtime.getRuntime().exec("cp "+f.getPath() + " diri686");
} else if (f.getName().endsWith("x86_64.deb")) {
Runtime.getRuntime().exec("cp "+f.getPath() + " dirx86_64");
}
}
}
}

考虑使用静态工厂方法替代构造方法

1
2
3
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}

使用Builder模式替代参数过多的构造器

当类构造器的参数过多时,容易导致程序由于参数传递顺序等问题产生一些难以调试的Bug,因此有一种解决方法是JavaBeans模式,这种模式通过提供一个无参的构造器,然后通过一系列的setter方法去设置对象的属性,这种模式有两个缺点:

  • 代码冗长
  • 由于构造方法在多次调用中被分割,所以在构造过程中 JavaBean 可能处于不一致的状态。一个相关的缺点是,JavaBeans 模式排除了让类不可变的可能性,并且需要增加工作以确保线程安全。

另一种解决方式是Builder模式,通过在类中提供一个通常是static类型的Builder类,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// Builder Pattern
public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;

public static class Builder {
// Required parameters
private final int servingSize;
private final int servings;

// Optional parameters - initialized to default values
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;

public Builder(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}

public Builder calories(int val) {
calories = val;
return this;
}

public Builder fat(int val) {
fat = val;
return this;
}

public Builder sodium(int val) {
sodium = val;
return this;
}

public Builder carbohydrate(int val) {
carbohydrate = val;
return this;
}

public NutritionFacts build() {
return new NutritionFacts(this);
}
}

private NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}

NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
.calories(100).sodium(35).carbohydrate(27).build();

总而言之,当设计类的构造方法或静态工厂的参数超过几个时,Builder 模式是一个不错的选择,特别是如果许多参数是可选的或相同类型的。客户端代码比使用伸缩构造方法(telescoping constructors)更容易读写,并且 builder 比 JavaBeans 更安全。

使用私有构造方法执行非实例化

对于一些只包含静态方法和静态属性的类,可以采用私有构造方法防止该类被继承,从而执行非实例化。

消除过期的对象引用

  • 内存泄漏原因之一:集合类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    // Can you spot the "memory leak"?
    public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
    elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(Object e) {
    ensureCapacity();
    elements[size++] = e;
    }

    // 内存泄漏
    public Object pop() {
    if (size == 0)
    throw new EmptyStackException();
    return elements[--size];
    }

    // 修改版本
    public Object pop() {
    if (size == 0)
    throw new EmptyStackException();
    Object result = elements[--size];
    elements[size] = null; // Eliminate obsolete reference
    return result;
    }

    /**
    * Ensure space for at least one more element, roughly
    * doubling the capacity each time the array needs to grow.
    */
    private void ensureCapacity() {
    if (elements.length == size)
    elements = Arrays.copyOf(elements, 2 * size + 1);
    }
    }
  • 内存泄漏原因之二:缓存。可以使用WeakHashMap来避免。

  • 内存泄漏原因之三:监听器等回调方法。

避免使用Finalizer和Cleaner(Java 9)机制

使用 try-with-resources 语句替代 try-finally

1
2
3
4
5
try {
throw new Exception();
} finally {
throw new NullPointerException();
}

如上语句中,只会捕获finally中的异常。为了防止该问题的出现可以使用try-with-resources,这是Java 7的一个特性,Java 类库和第三方类库中的许多类和接口现在都实现或继承了AutoCloseable接口。如果你编写的类表示必须关闭的资源,那么这个类也应该实现 AutoCloseable 接口。

1
2
3
public interface AutoCloseable {
void close() throws Exception;
}

因此建议使用如下方式关闭资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// try-with-resources - the the best way to close resources!
static String firstLineOfFile(String path) throws IOException {
try (BufferedReader br = new BufferedReader(
new FileReader(path))) {
return br.readLine();
}
}

// try-with-resources on multiple resources - short and sweet
static void copy(String src, String dst) throws IOException {
try (InputStream in = new FileInputStream(src);
OutputStream out = new FileOutputStream(dst)) {
byte[] buf = new byte[BUFFER_SIZE];
int n;
while ((n = in.read(buf)) >= 0)
out.write(buf, 0, n);
}
}

如果调用 readLine 和(不可见)close 方法都抛出异常,则后一个异常将被抑制(suppressed),而不是前者。 事实上,为了保留你真正想看到的异常,可能会抑制多个异常。 这些抑制的异常没有被抛弃, 而是打印在堆栈跟踪中,并标注为被抑制了。 你也可以使用 getSuppressed 方法以编程方式访问它们,该方法在 Java 7 中已添加到的 Throwable 中。

可以在 try-with-resources 语句中添加 catch 子句,就像在常规的 try-finally 语句中一样。这允许你处理异常,而不会在另一层嵌套中污染代码:

1
2
3
4
5
6
7
8
9
// try-with-resources with a catch clause
static String firstLineOfFile(String path, String defaultVal) {
try (BufferedReader br = new BufferedReader(
new FileReader(path))) {
return br.readLine();
} catch (IOException e) {
return defaultVal;
}
}

重写equals方法

重写equals方法必须遵守相应的规则:

  • 自反性: 对于任何非空引用 x,x.equals(x) 必须返回 true。
  • 对称性: 对于任何非空引用 x 和 y,如果且仅当 y.equals(x) 返回 true 时 x.equals(y) 必须返回 true。
  • 传递性: 对于任何非空引用 x、y、z,如果 x.equals(y) 返回 true,y.equals(z) 返回 true,则 x.equals(z) 必须返回 true。
  • 一致性: 对于任何非空引用 x 和 y,如果在 equals 比较中使用的信息没有修改,则 x.equals(y) 的多次调用必须始终返回 true 或始终返回 false。
  • 对于任何非空引用 x,x.equals(null) 必须返回 false。
  • 重写equals方法一定要重写hashCode方法。

equals方法与hashCode方法有几个约定:

  • 当在一个应用程序执行过程中,如果在 equals 方法比较中没有修改任何信息,在一个对象上重复调用 hashCode 方法时,它必须始终返回相同的值。从一个应用程序到另一个应用程序的每一次执行返回的值可以是不一致的。
  • 如果两个对象根据 equals(Object) 方法比较是相等的,那么在两个对象上调用 hashCode 就必须产生的结果是相同的整数。
  • 如果两个对象根据 equals(Object) 方法比较并不相等,则不要求在每个对象上调用 hashCode 都必须产生不同的结果。 但是,程序员应该意识到,为不相等的对象生成不同的结果可能会提高散列表(hash tables)的性能。

当不重写 hashCode 时,所违反第二个关键条款是:相等的对象必须具有相等的哈希码( hash codes)。

始终重写toString方法

谨慎地重写clone方法

考虑实现Comparable接口

使类和成员的可访问性最小化

  • private —— 该成员只能在声明它的顶级类内访问。
  • package-private —— 成员可以从被声明的包中的任何类中访问。从技术上讲,如果没有指定访问修饰符(接口成员除外,它默认是公共的),这是默认访问级别。
  • protected —— 成员可以从被声明的类的子类中访问(会受一些限制 [JLS, 6.6.2]),以及它声明的包中的任何类。
  • public —— 该成员可以从任何地方被访问。

最小化可变性

不可变类简单来说是它的实例不能被修改的类。包含在每个实例中的所有信息在对象的生命周期中是固定的,因此不会观察到任何变化。Java平台类库包含许多不可变的类,包括 String 类,基本类型包装类以及 BigInteger 类和 BigDecimal 类。

不可变类比可变类更容易设计,实现和使用。他们不太容易出错,更安全。除非有充分的理由使类成为可变类,否则类应该是不可变的。要使一个类不可变,需要遵循以下五条规则:

  • 不要提供修改对象状态的方法。
  • 确保这个类不能被继承。
  • 把所有属性设置为 final。
  • 把所有的属性设置为 private。
  • 确保对任何可变组件的互斥访问。如果你的类有任何引用可变对象的属性,请确保该类的客户端无法获得对这些对象的引用。切勿将这样的属性初始化为客户端提供的对象引用,或从访问方法返回属性。在构造方法,访问方法和 readObject 方法中进行防御性拷贝。

不可变的特点:

  • 不可变对象本质上是线程安全的; 它们不需要同步
  • 不仅可以共享不可变的对象,而且可以共享内部信息
  • 不可变对象为其他对象提供了很好的构件
  • 不可变对象提供了免费的原子失败机制
  • 不可变类的主要缺点是对于每个不同的值都需要一个单独的对象

为了保证不变性,一个类不得允许子类化,这可以通过使类用 final 修饰,但是还有另外一个更灵活的选择:可以使其所有的构造方法私有或包级私有,并添加公共静态工厂,而不是公共构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Immutable class with static factories instead of constructors
public class Complex {

private final double re;
private final double im;

private Complex(double re, double im) {
this.re = re;
this.im = im;
}

public static Complex valueOf(double re, double im) {
return new Complex(re, im);
}

... // Remainder unchanged
}

总而言之,坚决不要为每个属性编写一个 get 方法后再编写一个对应的 set 方法。 除非有充分的理由使类成为可变类,否则类应该是不可变的。 不可变类提供了许多优点,唯一的缺点是在某些情况下可能会出现性能问题。

组合优于继承(装饰器模式)

继承打破了封装,换句话说,一个子类依赖于其父类的实现细节来保证其正确的功能。 父类的实现可能会从发布版本不断变化,如果是这样,子类可能会被破坏,即使它的代码没有任何改变。 因此,一个子类必须与其超类一起更新而变化,除非父类的作者为了继承的目的而专门设计它,并对应有文档的说明。

为了具体说明,假设有一个使用 HashSet 的程序,为了调整程序的性能,需要查询 HashSet ,从创建它之后已经添加了多少个元素,为了提供这个功能,编写了一个 HashSet 变体,它保留了尝试元素插入的数量,并导出了这个插入数量的一个访问方法,HashSet 类包含两个添加元素的方法,分别是 add 和 addAll,所以我们重写这两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Broken - Inappropriate use of inheritance!
public class InstrumentedHashSet<E> extends HashSet<E> {
// The number of attempted element insertions
private int addCount = 0;

public InstrumentedHashSet() {
}

public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}

此时的addAll方法会出现一个问题,因为在其内又调用了add方法,因此计数器叠加了两次。

为了解决这个问题,可以使用组合替代继承。给新类增加一个私有属性,该属性是现有类的实例引用,这种设计被称为组合。因此可以这样修改上述功能代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public class ForwardingSet<E> implements Set<E> {

private final Set<E> s;

public ForwardingSet(Set<E> s) {
this.s = s;
}

public void clear() {
s.clear();
}

public boolean contains(Object o) {
return s.contains(o);
}

public boolean isEmpty() {
return s.isEmpty();
}

public int size() {
return s.size();
}

public Iterator<E> iterator() {
return s.iterator();
}

public boolean add(E e) {
return s.add(e);
}

public boolean remove(Object o) {
return s.remove(o);
}

public boolean containsAll(Collection<?> c) {
return s.containsAll(c);
}

public boolean addAll(Collection<? extends E> c) {
return s.addAll(c);
}

public boolean removeAll(Collection<?> c) {
return s.removeAll(c);
}

public boolean retainAll(Collection<?> c) {
return s.retainAll(c);
}

public Object[] toArray() {
return s.toArray();
}

public <T> T[] toArray(T[] a) {
return s.toArray(a);
}

@Override
public boolean equals(Object o) {
return s.equals(o);
}

@Override
public int hashCode() {
return s.hashCode();
}

@Override
public String toString() {
return s.toString();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class InstrumentedSet<E> extends ForwardingSet<E> {

private int addCount = 0;

public InstrumentedSet(Set<E> s) {
super(s);
}

@Override public boolean add(E e) {
addCount++;
return super.add(e);
}

@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}

public int getAddCount() {
return addCount;
}
}

InstrumentedSet 类被称为包装类,因为每个 InstrumentedSet 实例都包含(“包装”)另一个 Set 实例。 这也被称为装饰器模式,因为 InstrumentedSet 类通过添加计数功能来“装饰”一个集合。

总之,继承是强大的,但它是有问题的,因为它违反封装。 只有在子类和父类之间存在真正的子类型关系时才适用。 即使如此,如果子类与父类不在同一个包中,并且父类不是为继承而设计的,继承可能会导致脆弱性。 为了避免这种脆弱性,使用合成和转发代替继承,特别是如果存在一个合适的接口来实现包装类。 包装类不仅比子类更健壮,而且更强大。

接口优于抽象类

接口通过包装类模式确保安全的,强大的功能增强成为可能。如果使用抽象类来定义类型,那么只能继承,生成的类比包装类更脆弱。

接口仅用来定义类型

使用静态成员类而不是非静态类

如果声明了一个不需要访问宿主实例的成员类,应该使它成为一个静态成员类,而不是非静态的成员类,因为非静态内部类的每个实例都会有一个隐藏的外部引用给它的宿主实例,如前所述,存储这个引用需要占用时间和空间;更严重的是可能会导致即使宿主类在满足垃圾回收的条件时却仍然驻留在内存中,由此产生的内存泄漏可能是灾难性的,由于引用是不可见的,所以通常难以检测到。

将源文件限制为单个顶级类

虽然 Java 编译器允许在单个源文件中定义多个顶级类,但这样做没有任何好处,并且存在重大风险。风险源于在源文件中定义多个顶级类使得为类提供多个定义成为可能,使用哪个定义会受到源文件传递给编译器的顺序的影响。

不要使用原始类型

术语 中文含义 举例
Parameterized type 参数化类型 List<String>
Actual type parameter 实际类型参数 String
Generic type 泛型类型 List<E>
Formal type parameter 形式类型参数 E
Unbounded wildcard type 无限制通配符类型 List<?>
Raw type 原始类型 List
Bounded type parameter 限制类型参数 <E extends Number>
Recursive type bound 递归类型限制 <T extends Comparable<T>>
Bounded wildcard type 限制通配符类型 List<? extends Number>
Generic method 泛型方法 static <E> List<E> asList(E[] a)
Type token 类型令牌 String.class

列表优于数组

数组是协变的(covariant),这意味着如果 Sub 是 Super 的子类型,则数组类型 Sub[] 是数组类型 Super[] 的子类型;相比之下,泛型是不变的(invariant),对于任何两种不同的类型 Type1 和 Type2,List 既不是 List 的子类型也不是父类型。

1
2
3
4
5
6
7
// Fails at runtime!
Object[] objectArray = new Long[1];
objectArray[0] = "I don't fit in"; // Throws ArrayStoreException

// Won't compile!
List<Object> ol = new ArrayList<Long>(); // Incompatible types
ol.add("I don't fit in");

优先考虑泛型

使用限定通配符来增加API的灵活性

使用枚举类型替代整型常量

注解优于命名模式

如下实例:

1
2
3
4
5
6
// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});

可以修改为:

1
2
// Lambda expression as function object (replaces anonymous class)
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

另一个实例,在枚举中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Enum type with constant-specific class bodies & data 
public enum Operation {
PLUS("+") {
public double apply(double x, double y) { return x + y; }
},

MINUS("-") {
public double apply(double x, double y) { return x - y; }
},

TIMES("*") {
public double apply(double x, double y) { return x * y; }
},

DIVIDE("/") {
public double apply(double x, double y) { return x / y; }
};

private final String symbol;

Operation(String symbol) { this.symbol = symbol; }

@Override
public String toString() { return symbol; }

public abstract double apply(double x, double y);
}

DoubleBinaryOperator 接口是 java.util.function 中许多预定义的函数接口之一,它表示一个函数,它接受两个 double 类型参数并返回 double 类型的结果。因此可以修改上述枚举如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public enum Operation {
PLUS ("+", (x, y) -> x + y),
MINUS ("-", (x, y) -> x - y),
TIMES ("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);

private final String symbol;
private final DoubleBinaryOperator op;

Operation(String symbol, DoubleBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}

@Override
public String toString() { return symbol; }

public double apply(double x, double y) {
return op.applyAsDouble(x, y);
}
}

lambda 没有名称和文档; 如果计算不是自解释的,或者超过几行,则不要将其放入 lambda 表达式中。 一行代码对于 lambda 说是理想的,三行代码是合理的最大值。

Lambda 仅限于函数式接口,它不能获取对自身的引用。

通过接口引用对象

明智地进行优化

1
2
不要去计较效率上的一些小小的得失,在 97% 的情况下,不成熟的优化才是一切问题的根源。
—Donald E. Knuth [Knuth74]

对可恢复的情况使用受检异常,对编程错误使用运行时异常

对于可恢复的情况,要抛出受检异常;对于程序错误,就要抛出运行时异常。不确定是否可恢复,就抛出为受检异常。不要定义任何既不是受检异常也不是运行异常的抛出类型。要在受检异常上提供方法,以便协助程序恢复。

但是要避免不必要的使用受检异常。

抛出与抽象对应的异常

为了避免方法抛出的异常和它所执行的任务没有任何关系,我们可以在程序中进行异常转译:更高层的实现应该捕获低层的异常,同时抛出可以按照高层抽象进行解释的异常,如下所示:

1
2
3
4
5
6
7
8
public E get(int index) {
ListIterator<E> i = listIterator(index);
try {
return(i.next() );
} catch (NoSuchElementException e) {
throw new IndexOutOfBoundsException("Index: " + index);
}
}

如果不能阻止或者处理来自更低层的异常,一般的做法是使用异常转译,只有在低层方法的规范碰巧可以保证“它所抛出的所有异常对于更高层也是合适的”情况下,才可以将异常从低层传播到高层。异常链对高层和低层异常都提供了最佳的功能:它允许抛出适当的高层异常,同时又能捕获低层的原因进行失败分析。

保持失败原子性

当对象抛出异常之后,通常我们期望这个对象仍然保持在一种定义良好的可用状态之中,即使失败是发生在执行某个操作的过程中间。对于受检异常而言,这尤为重要,因为调用者期望能从这种异常中进行恢复。一般而言,失败的方法调用应该使对象保持在被调用之前的状态,具有这种属性的方法被称为具有失败原子性 (failure atomic)。

有几种途径可以实现这种效果:

  1. 设计不可变的对象。如果一个操作失败了,它可能会阻止创建新的对象,但是永远也不会使已有的对象保持在不一致的状态之中,因为当每个对象被创建之后它就处于一致的状态之中,以后也不会再发生变化。

  2. 在可变对象上执行操作时,获得失败原子性最常见的办法是,在执行操作之前检查参数的有效性,这可以使得在对象的状态被修改之前,先抛出适当的异常。如下代码所示:

    1
    2
    3
    4
    5
    6
    7
    public Object pop() {
    if ( size == 0 )
    throw new EmptyStackException();
    Object result = elements[--size];
    elements[size] = null; /* Eliminate obsolete reference */
    return(result);
    }

    另一种类似的方式是:调整计算处理过程的顺序,使得任何可能会失败的计算部分都在对象状态被修改之前发生。

  3. 在对象的一份临时拷贝上执行操作,当操作完成之后再用临时拷贝中的结果代替对象的内容。

  4. 编写一段恢复代码 (recovery code),由它来拦截操作过程中发生的失败,以及便对象回滚到操作开始之前的状态上,这种办法主要用于永久性的(基于磁盘的)数据结构。

总而言之,作为方法规范的一部分,它产生的任何异常都应该让对象保持在调用该方法之前的状态。如果违反这条规则, API 文档就应该清楚地指明对象将会处于什么样的状态。遗憾的是,大量现有的 API 文档都未能做到这一点。

不要忽略异常

如果选择忽略异常,catch 块中应该包含一条注释,说明为什么可以这么做。

谨慎地使用延迟初始化

考虑使用自定义的序列化形式

移位运算比乘除法快

  • 从效率上看,使用移位指令有更高的效率,因为移位指令占2个机器周期,而乘除法指令占4个机器周期。
  • 从硬件上看,移位对硬件而言更容易实现。

概述

ASP.NET Core ASP.NET 4.x
针对 Windows、macOS 或 Linux 进行生成 针对 Windows 进行生成
Razor 页面 是在 ASP.NET Core 2.x 及更高版本中创建 Web UI 时建议使用的方法。 使用 Web 窗体、SignalR、MVC、Web API、WebHooks 或网页
每个计算机多个版本 每个计算机一个版本
使用 C# 或 F# 通过 Visual Studio、Visual Studio for Mac 或 Visual Studio Code 进行开发 使用 C#、VB 或 F# 通过 Visual Studio 进行开发
比 ASP.NET 4.x 性能更高 良好的性能
选择 .NET Framework 或 .NET Core 运行时 使用 .NET Framework 运行时

基础知识

依赖注入

概述

  • 使用接口抽象化依赖关系实现。
  • 注册服务容器中的依赖关系,ASP.NET Core 提供了一个内置的服务容器 IServiceProvider,服务已在应用的 Startup.ConfigureServices 方法中注册。
  • 将服务注入到使用它的类的构造函数中,框架负责创建依赖关系的实例,并在不再需要时对其进行处理。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public interface IMyDependency
{
Task WriteMessage(string message);
}

public class MyDependency : IMyDependency
{
private readonly ILogger<MyDependency> _logger;

public MyDependency(ILogger<MyDependency> logger)
{
_logger = logger;
}

public Task WriteMessage(string message)
{
_logger.LogInformation(
"MyDependency.WriteMessage called. Message: {MESSAGE}",
message);

return Task.FromResult(0);
}
}

// 注册
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

services.AddScoped<IMyDependency, MyDependency>();
services.AddTransient<IOperationTransient, Operation>();
services.AddScoped<IOperationScoped, Operation>();
services.AddSingleton<IOperationSingleton, Operation>();
services.AddSingleton<IOperationSingletonInstance>(new Operation(Guid.Empty));

// OperationService depends on each of the other Operation types.
services.AddTransient<OperationService, OperationService>();
}

MyDependency 在其构造函数中请求 ILogger,以链式方式使用依赖关系注入并不罕见,每个请求的依赖关系相应地请求其自己的依赖关系,容器解析图中的依赖关系并返回完全解析的服务。

服务生存期

暂时

暂时生存期服务是每次从服务容器进行请求时创建的,这种生存期适合轻量级、无状态的服务。

作用域(Scoped)

作用域生存期服务以每个客户端请求(连接)一次的方式创建。

警告:在中间件内使用有作用域的服务时,请将该服务注入至 Invoke 或 InvokeAsync 方法,请不要通过构造函数注入进行注入,因为它会强制服务的行为与单一实例类似。

单例

单一实例生存期服务是在第一次请求时(或者在运行 ConfigureServices 并且使用服务注册指定实例时)创建的,每个后续请求都使用相同的实例,如果应用需要单一实例行为,建议允许服务容器管理服务的生存期,不要实现单一实例设计模式并提供用户代码来管理对象在类中的生存期。

警告:从单一实例解析有作用域的服务很危险,当处理后续请求时,它可能会导致服务处于不正确的状态。

Razor页面

入门

1
2
3
4
5
6
7
8
9
10
11
# 新建Razor页面项目
$ dotnet new webapp -o RazorPagesMovie
# 本地部署
$ dotnet run
# 输出
: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[0]
User profile is available. Using '/home/hearing/.aspnet/DataProtection-Keys' as key repository; keys will not be encrypted at rest.
Hosting environment: Development
Content root path: /home/hearing/WorkSpace/asp/hello
Now listening on: https://localhost:5001
Now listening on: http://localhost:5000

项目文件:

  • Pages 文件夹:包含 Razor 页面和支持文件。 每个 Razor 页面都是一对文件:
    • 一个 .cshtml 文件,其中包含使用 Razor 语法的 C# 代码的 HTML 标记。
    • 一个 .cshtml.cs 文件,其中包含处理页面事件的 C# 代码。
    • 支持文件的名称以下划线开头。例如,_Layout.cshtml 文件可配置所有页面通用的 UI 元素。 此文件设置页面顶部的导航菜单和页面底部的版权声明。
  • wwwroot文件夹:包含静态文件,如 HTML 文件、JavaScript 文件和 CSS 文件。
  • appsettings.json:包含配置数据,如连接字符串。
  • Program.cs:包含程序的入口点。
  • Startup.cs:包含配置应用行为的代码,例如,是否需要同意 cookie。 有关更多信息,请参见ASP.NET Core 中的应用启动。

MVC

入门

1
2
3
4
5
6
7
8
9
10
11
# 新建Razor页面项目
$ dotnet new mvc -o MvcHello
# 本地部署
$ dotnet run
# 输出
: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[0]
User profile is available. Using '/home/hearing/.aspnet/DataProtection-Keys' as key repository; keys will not be encrypted at rest.
Hosting environment: Development
Content root path: /home/hearing/WorkSpace/asp/MvcHello
Now listening on: https://localhost:5001
Now listening on: http://localhost:5000

目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
MvcHello
├── appsettings.Development.json
├── appsettings.json
├── bin
│   └── Debug
│   └── netcoreapp2.2
├── Controllers
│   ├── HelloWorldController.cs
│   └── HomeController.cs
├── Models
│   └── ErrorViewModel.cs
├── MvcHello.csproj
├── obj
│   ├── Debug
│   │   └── netcoreapp2.2
│   ├── MvcHello.csproj.nuget.cache
│   ├── MvcHello.csproj.nuget.dgspec.json
│   ├── MvcHello.csproj.nuget.g.props
│   ├── MvcHello.csproj.nuget.g.targets
│   └── project.assets.json
├── Program.cs
├── Properties
│   └── launchSettings.json
├── Startup.cs
├── Views
│   ├── Home
│   │   ├── Index.cshtml
│   │   └── Privacy.cshtml
│   ├── Shared
│   │   ├── _CookieConsentPartial.cshtml
│   │   ├── Error.cshtml
│   │   ├── _Layout.cshtml
│   │   └── _ValidationScriptsPartial.cshtml
│   ├── _ViewImports.cshtml
│   └── _ViewStart.cshtml
└── wwwroot
├── css
│   └── site.css
├── favicon.ico
├── js
│   └── site.js
└── lib
├── bootstrap
├── jquery
├── jquery-validation
└── jquery-validation-unobtrusive

Route

在 Startup类 的Configure 方法中,可能会看到与下面类似的代码:

1
2
3
app.UseMvc(routes => {
routes.MapRoute("default", "{controller=Home}/{action=Index}/{id?}");
});

在对 UseMvc 的调用中,MapRoute 用于创建单个路由,亦称 default 路由。大多数 MVC 应用使用带有模板的路由,与 default 路由类似。路由模板:

  • {controller=Home} 将 Home 定义为默认 controller
  • {action=Index} 将 Index 定义为默认 action
  • {id?} 将 id 定义为可选参数

路由模板 “{controller=Home}/{action=Index}/{id?}” 可以匹配诸如 /Products/Details/5 之类的 URL 路径,并通过对路径进行标记来提取路由值 { controller = Products, action = Details, id = 5 }。 MVC 将尝试查找名为 ProductsController 的控制器并运行 Details 操作:

1
2
3
public class ProductsController : Controller {
public IActionResult Details(int id) { ... }
}

依赖关系注入

控制器

构造函数注入

服务作为构造函数参数添加,并且运行时从服务容器中解析服务,通常使用接口来定义服务。例如,考虑需要当前时间的应用,以下接口公开 IDateTime 服务:

1
2
3
4
5
6
7
8
9
10
11
12
public interface IDateTime
{
DateTime Now { get; }
}

public class SystemDateTime : IDateTime
{
public DateTime Now
{
get { return DateTime.Now; }
}
}

注册:

1
2
3
4
5
6
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IDateTime, SystemDateTime>();

services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class HomeController : Controller
{
private readonly IDateTime _dateTime;

public HomeController(IDateTime dateTime)
{
_dateTime = dateTime;
}

public IActionResult Index()
{
var serverTime = _dateTime.Now;
if (serverTime.Hour < 12)
{
ViewData["Message"] = "It's morning here - Good Morning!";
}
else if (serverTime.Hour < 17)
{
ViewData["Message"] = "It's afternoon here - Good Afternoon!";
}
else
{
ViewData["Message"] = "It's evening here - Good Evening!";
}
return View();
}
}

FromServices注入

FromServicesAttribute 允许将服务直接注入到操作方法,而无需使用构造函数注入:

1
2
3
4
5
6
public IActionResult About([FromServices] IDateTime dateTime)
{
ViewData["Message"] = $"Current server time: {dateTime.Now}";

return View();
}

视图

ASP.NET Core 支持将依赖关系注入到视图。 这对于视图特定服务很有用,例如仅为填充视图元素所需的本地化或数据。 应尽量在控制器和视图之间保持问题分离。 视图显示的大部分数据应该从控制器传入。

简单示例

可使用 @inject 指令将服务注入到视图中,可将 @inject 看作向视图添加属性,并用 DI 填充该属性。@inject 的语法:@inject <type> <name>,示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@using System.Threading.Tasks
@using ViewInjectSample.Model
@using ViewInjectSample.Model.Services
@model IEnumerable<ToDoItem>
@inject StatisticsService StatsService
<!DOCTYPE html>
<html>
<head>
<title>To Do Items</title>
</head>
<body>
<div>
<h1>To Do Items</h1>
<ul>
<li>Total Items: @StatsService.GetCount()</li>
<li>Completed: @StatsService.GetCompletedCount()</li>
<li>Avg. Priority: @StatsService.GetAveragePriority()</li>
</ul>
<table>
<tr>
<th>Name</th>
<th>Priority</th>
<th>Is Done?</th>
</tr>
@foreach (var item in Model)
{
<tr>
<td>@item.Name</td>
<td>@item.Priority</td>
<td>@item.IsDone</td>
</tr>
}
</table>
</div>
</body>
</html>

在 Startup.cs 的 ConfigureServices 中为依赖关系注入注册此服务:

1
2
3
4
5
6
7
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();

services.AddTransient<IToDoItemRepository, ToDoItemRepository>();
services.AddTransient<StatisticsService>();
services.AddTransient<ProfileOptionsService>();

填充查找数据

视图注入可用于填充 UI 元素(如下拉列表)中的选项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
using Microsoft.AspNetCore.Mvc;
using ViewInjectSample.Model;

namespace ViewInjectSample.Controllers
{
public class ProfileController : Controller
{
[Route("Profile")]
public IActionResult Index()
{
// TODO: look up profile based on logged-in user
var profile = new Profile()
{
Name = "Steve",
FavColor = "Blue",
Gender = "Male",
State = new State("Ohio","OH")
};
return View(profile);
}
}
}

using System.Collections.Generic;

namespace ViewInjectSample.Model.Services
{
public class ProfileOptionsService
{
public List<string> ListGenders()
{
// keeping this simple
return new List<string>() {"Female", "Male"};
}

public List<State> ListStates()
{
// a few states from USA
return new List<State>()
{
new State("Alabama", "AL"),
new State("Alaska", "AK"),
new State("Ohio", "OH")
};
}

public List<string> ListColors()
{
return new List<string>() { "Blue","Green","Red","Yellow" };
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@using System.Threading.Tasks
@using ViewInjectSample.Model.Services
@model ViewInjectSample.Model.Profile
@inject ProfileOptionsService Options
<!DOCTYPE html>
<html>
<head>
<title>Update Profile</title>
</head>
<body>
<div>
<h1>Update Profile</h1>
Name: @Html.TextBoxFor(m => m.Name)
<br/>
Gender: @Html.DropDownList("Gender",
Options.ListGenders().Select(g =>
new SelectListItem() { Text = g, Value = g }))
<br/>

State: @Html.DropDownListFor(m => m.State.Code,
Options.ListStates().Select(s =>
new SelectListItem() { Text = s.Name, Value = s.Code}))
<br />

Fav. Color: @Html.DropDownList("FavColor",
Options.ListColors().Select(c =>
new SelectListItem() { Text = c, Value = c }))
</div>
</body>
</html>

Controller

  • 驻留在项目的根级别“Controllers”文件夹中
  • 继承自 Microsoft.AspNetCore.Mvc.Controller

控制器是一个可实例化的类,其中下列条件至少某一个为 true:

  • 类名称带有“Controller”后缀
  • 该类继承自带有“Controller”后缀的类
  • 使用 [Controller] 属性修饰该类

控制器上的公共方法(除了那些使用 [NonAction] 属性装饰的方法)均为操作。操作上的参数会绑定到请求数据,并使用模型绑定进行验证。所有模型绑定的内容都会执行模型验证。 ModelState.IsValid 属性值指示模型绑定和验证是否成功。

操作方法应包含用于将请求映射到某个业务关注点的逻辑,业务关注点通常应当表示为控制器通过依赖关系注入来访问的服务,然后,操作将业务操作的结果映射到应用程序状态。

操作可以返回任何内容,但是经常返回生成响应的 IActionResult(或异步方法的 Task)的实例,操作方法负责选择响应的类型,操作结果会做出响应。

添加控制器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using Microsoft.AspNetCore.Mvc;
using System.Text.Encodings.Web;

namespace MvcHello.Controllers {
public class HelloWorldController : Controller {

// GET: /HelloWorld/
public string Index() {
return "This is my default action...";
}

// GET: /HelloWorld/Welcome/
public string Welcome() {
return "This is the Welcome action method...";
}
}
}

View

创建视图

在 Views / [ControllerName] 文件夹中创建特定于控制器的视图。 控制器之间共享的视图都将置于 Views/Shared 文件夹。 要创建一个视图,请添加新文件,并将其命名为与 .cshtml 文件扩展名相关联的控制器操作的相同名称。

Razor 标记以 @ 符号开头。 通过将 C# 代码放置在用大括号 ({ … }) 括住的 Razor 代码块内,运行 C# 语句。如下所示:

1
2
3
4
5
6
7
@{
ViewData["Title"] = "About";
}
<h2>@ViewData["Title"].</h2>
<h3>@ViewData["Message"]</h3>

<p>Use this area to provide additional information.</p>

添加视图

在 HelloWorldController 类中,将 Index 方法替换为以下代码:

1
2
3
public IActionResult Index() {
return View();
}

为 HelloWorldController 添加 Index 视图。

  1. 添加一个名为“Views/HelloWorld”的新文件夹。
  2. 向 Views/HelloWorld 文件夹添加名为“Index.cshtml”的新文件,并添加内容。

更改标题和页脚等

修改Views/Shared/_Layout.cshtml 文件中的相关内容,并确认 Views/_ViewStart.cshtml 文件:

1
2
3
@{
Layout = "_Layout";
}

Views/_ViewStart.cshtml 文件将 Views/Shared/_Layout.cshtml 文件引入到每个视图中。 可以使用 Layout 属性设置不同的布局视图,或将它设置为 null,这样将不会使用任何布局文件。

控制器传递数据

强类型数据 (ViewModel)

使用 viewmodel 将数据传递给视图可让视图充分利用强类型检查。 强类型化(或强类型)意味着每个变量和常量都有明确定义的类型(例如 string、int 或 DateTime)。 在编译时检查视图中使用的类型是否有效。

使用 @model 指令指定模型。 使用带有 @Model 的模型:

1
2
3
4
5
6
7
8
@model WebApplication1.ViewModels.Address

<h2>Contact</h2>
<address>
@Model.Street<br>
@Model.City, @Model.State @Model.PostalCode<br>
<abbr title="Phone">P:</abbr> 425.555.0100
</address>

为了将模型提供给视图,控制器将其作为参数进行传递:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public IActionResult Contact()
{
ViewData["Message"] = "Your contact page.";

var viewModel = new Address()
{
Name = "Microsoft",
Street = "One Microsoft Way",
City = "Redmond",
State = "WA",
PostalCode = "98052-6399"
};

return View(viewModel);
}

弱类型数据(ViewData、ViewData 属性和 ViewBag)

与强类型不同,弱类型(或松散类型)意味着不显式声明要使用的数据类型,可以使用弱类型数据集合将少量数据传入及传出控制器和视图。ViewBag 在 Razor 页中不可用。

ViewData 是通过 string 键访问的 ViewDataDictionary 对象。 字符串数据可以直接存储和使用,而不需要强制转换,但是在提取其他 ViewData 对象值时必须将其强制转换为特定类型。可以让控制器将视图模板所需的动态数据(参数)放置在视图模板稍后可以访问的 ViewData 字典中,将Welcome方法改为如下内容:

1
2
3
4
5
6
7
public IActionResult Welcome(string name, int numTimes = 1) {
ViewData["Message"] = "Hello " + name;
ViewData["NumTimes"] = numTimes;

// return View("Views/HelloWorld/Welcome.cshtml");
return View();
}

创建一个名为 Views/HelloWorld/Welcome.cshtml 的 Welcome 视图模板,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
@{
ViewData["Title"] = "Welcome";
}

<h2>Welcome</h2>

<ul>
@for (int i = 0; i < (int)ViewData["NumTimes"]; i++)
{
<li>@ViewData["Message"]</li>
}
</ul>

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public IActionResult SomeAction()
{
ViewData["Greeting"] = "Hello";
ViewData["Address"] = new Address()
{
Name = "Steve",
Street = "123 Main St",
City = "Hudson",
State = "OH",
PostalCode = "44236"
};

return View();
}

在视图中处理数据:

1
2
3
4
5
6
7
8
9
10
11
12
@{
// Since Address isn't a string, it requires a cast.
var address = ViewData["Address"] as Address;
}

@ViewData["Greeting"] World!

<address>
@address.Name<br>
@address.Street<br>
@address.City, @address.State @address.PostalCode
</address>

Model

可以结合 Entity Framework Core (EF Core) 使用来处理数据库,EF Core 是对象关系映射 (ORM) 框架,可以简化需要编写的数据访问代码。要创建的模型类称为 POCO 类(源自“简单传统 CLR 对象”),因为它们与 EF Core 没有任何依赖关系,它们只定义将存储在数据库中的数据的属性。

建表

在数据库中建立表,需要与实体类对应。

安装相关包

通过Nuget包管理器安装MySql.Data.EntityFrameworkCore包:

1
$ dotnet add package  MySql.Data.EntityFrameworkCore

添加实体类

在“Models”文件夹下添加“User.cs”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System;
using System.ComponentModel.DataAnnotations;

namespace MvcHello.Models
{
public class User
{
//用户Id
[Key]
public int Id { get; set; }
//用户名
public string Name { get; set; }
//用户密码
public string Password { get; set; }
}
}

User 类可包含:

  • 数据库需要 Id 字段以获取主键。
  • [DataType(DataType.Date)]:DataType 属性指定数据的类型 (Date)。 通过此特性:
    • 用户无需在数据字段中输入时间信息。
    • 仅显示日期,而非时间信息。

添加数据库上下文类

在根目录下加上 DataAccess 目录做为数据库操作目录,在该目录下加上 Base 目录做数据库上下文目录,在Base目录下添加上下文类,继承 DbContext 类,通过构造函数注入数据库连接,添加 DbSet 实体属性,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

namespace MvcHello.DataAccess.Base
{
public class UserDbContext : DbContext
{
public UserDbContext (DbContextOptions<UserDbContext> options): base(options)
{
}

public DbSet<MvcHello.Models.User> User { get; set; }
}
}

添加数据库连接配置(MySQL)

将连接字符串添加到 appsettings.json 文件:

1
2
3
4
5
6
7
8
9
10
11
{
"Logging": {
"LogLevel": {
"Default": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"UserDbContext": "server=localhost;user id=root;password=123456;database=asp;charset=utf8;sslMode=None"
}
}

添加数据库操作服务类

在 DataAccess 目录下新建 Interface 目录,用于保存数据库操作的接口,在该目录下新建 IUserDao 接口,在接口里增加相关数据库添、删、改、查接口,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace MvcHello.DataAccess.Interface
{
public interface IUserDao
{
//插入数据
bool CreateUser(User user);

//取全部记录
IEnumerable<User> GetUsers();

//取某id记录
User GetUserByID(int id);

//根据id更新整条记录
bool UpdateUser(User user);

//根据id更新名称
bool UpdateNameByID(int id, string name);

//根据id删掉记录
bool DeleteUserByID(int id);
}
}

在 DataAccess 目录下新建 Implement 目录,用于保存数据库操作接口的实现,在该目录下新建 UserDao 类,继承 IUserDao 接口,实现接口里的数据库操作方法,在构造函数注入 UserDbContext 数据库上下文,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
namespace MvcHello.DataAccess.Implement
{
public class UserDao: IUserDao
{
public UserDbContext Context;

public UserDao(UserDbContext context)
{
Context = context;
}

//插入数据
public bool CreateUser(User user)
{
Context.User.Add(user);
return Context.SaveChanges() > 0;
}

//取全部记录
public IEnumerable<User> GetUsers()
{
return Context.User.ToList();
}

//取某id记录
public User GetUserByID(int id)
{
return Context.User.SingleOrDefault(s => s.Id == id);
}

//根据id更新整条记录
public bool UpdateUser(User user)
{
Context.User.Update(user);
return Context.SaveChanges() > 0;
}

//根据id更新名称
public bool UpdateNameByID(int id, string name)
{
var state = false;
var user = Context.User.SingleOrDefault(s => s.Id == id);

if (user != null)
{
user.Name = name;
state = Context.SaveChanges() > 0;
}

return state;
}

//根据id删掉记录
public bool DeleteUserByID(int id)
{
var user = Context.User.SingleOrDefault(s => s.Id == id);
Context.User.Remove(user);
return Context.SaveChanges() > 0;
}
}
}

控制器中注入使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
 //插入数据
public ActionResult<string> Create(string name, string password)
{
if (string.IsNullOrEmpty(name.Trim()))
{
return "姓名不能为空";
}
if (string.IsNullOrEmpty(password.Trim()))
{
return "密码不能为空";
}

var user = new User() {
Name = name,
Password = password
};

var result = iHelloDao.CreateUser(user);

if (result)
{
return "学生插入成功";
}
else
{
return "学生插入失败";
}
}

//取全部记录
public ActionResult<string> Gets()
{
var names = "没有数据";
var users = iHelloDao.GetUsers();

if (users != null)
{
names = "";
foreach (var s in users)
{
names += $"{s.Name} \r\n";
}

}

return names;
}

在Starup.cs中注册数据库服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void ConfigureServices(IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
{
// This lambda determines whether user consent for non-essential cookies is needed for a given request.
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
});

services.AddDbContext<UserDbContext>(d => d.UseMySQL(Configuration.GetConnectionString("UserDbContext")));
services.AddScoped<IUserDao, UserDao>();

services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}

环境

在Linux上编译运行C#需要安装mono(毕竟微软家的呵),安装方式见官网。

程序结构

一个 C# 程序主要包括以下部分:

  • 命名空间声明(Namespace declaration)
  • 一个 Class
  • Class 方法
  • Class 属性
  • 一个 Main 方法
  • 语句(Statements)& 表达式(Expressions)
  • 注释

实例:

1
2
3
4
5
6
7
8
9
10
using System;
namespace HelloWorldApplication {
class HelloWorld {
static void Main(string[] args) {
/* 我的第一个 C# 程序*/
Console.WriteLine("Hello World");
Console.ReadKey();
}
}
}

编译运行:

1
2
3
4
// 编译生成.exe文件
mcs test.cs
// 运行.exe文件
mono test.exe

基本语法

using关键字

using 关键字用于在程序中包含命名空间。一个程序可以包含多个 using 语句。

class关键字

class 关键字用于声明一个类。

C# 关键字

关键字是 C# 编译器预定义的保留字,如果想使用这些关键字作为标识符,可以在关键字前面加上 @ 字符作为前缀。

在 C# 中,有些关键字在代码的上下文中有特殊的意义,如 get 和 set,这些被称为上下文关键字(contextual keywords)。下表列出了 C# 中的保留关键字(Reserved Keywords)和上下文关键字(Contextual Keywords):

数据类型

在 C# 中,变量分为以下几种类型:

  • 值类型(Value types)
  • 引用类型(Reference types)
  • 指针类型(Pointer types)

值类型(Value types)

值类型变量可以直接分配给一个值。它们是从类 System.ValueType 中派生的(结构体和枚举都是值类型)。

表达式 sizeof(type) 产生以字节为单位存储对象或类型的存储尺寸。

struct

1
2
3
4
5
6
struct Books {
public string title;
public string author;
public string subject;
public int book_id;
};

C# 中的结构有以下特点:

  • 结构可带有方法、字段、索引、属性、运算符方法和事件。
  • 结构可定义构造函数,但不能定义析构函数。但是,您不能为结构定义默认的构造函数。默认的构造函数是自动定义的,且不能被改变。
  • 与类不同,结构不能继承其他的结构或类。
  • 结构不能作为其他结构或类的基础结构。
  • 结构可实现一个或多个接口。
  • 结构成员不能指定为 abstract、virtual 或 protected。
  • 当使用 New 操作符创建一个结构对象时,会调用适当的构造函数来创建结构。与类不同,结构可以不使用 New 操作符即可被实例化。
  • 如果不使用 New 操作符,只有在所有的字段都被初始化之后,字段才被赋值,对象才被使用。
  • 类是引用类型,结构是值类型。
  • 结构不支持继承。
  • 结构不能声明默认的构造函数。

enum

1
2
3
enum <enum_name> {
enumeration list
};
  • enum_name 指定枚举的类型名称。
  • enumeration list 是一个用逗号分隔的标识符列表。

枚举列表中的每个符号代表一个整数值,一个比它前面的符号大的整数值。默认情况下,第一个枚举符号的值是 0.例如:

1
2
3
4
5
6
7
8
9
10
11
12
using System;

public class EnumTest {
enum Day { Sun, Mon, Tue, Wed, Thu, Fri, Sat };

static void Main() {
int x = (int)Day.Sun;
int y = (int)Day.Fri;
Console.WriteLine("Sun = {0}", x);
Console.WriteLine("Fri = {0}", y);
}
}

引用类型(Reference types)

引用类型不包含存储在变量中的实际数据,但它们包含对变量的引用。换句话说,它们指的是一个内存位置。使用多个变量时,引用类型可以指向一个内存位置。如果内存位置的数据是由一个变量改变的,其他变量会自动反映这种值的变化。内置的 引用类型有:object、dynamic 和 string。

对象(Object)类型

对象(Object)类型 是 C# 通用类型系统(Common Type System - CTS)中所有数据类型的终极基类。Object 是 System.Object 类的别名。所以对象(Object)类型可以被分配任何其他类型(值类型、引用类型、预定义类型或用户自定义类型)的值。但是,在分配值之前,需要先进行类型转换。

当一个值类型转换为对象类型时,则被称为 装箱;另一方面,当一个对象类型转换为值类型时,则被称为 拆箱。

1
2
object obj;
obj = 100; // 这是装箱

动态(Dynamic)类型

可以存储任何类型的值在动态数据类型变量中。这些变量的类型检查是在运行时发生的。声明动态类型的语法:

1
2
3
4
dynamic <variable_name> = value;

// 例如:
dynamic d = 20;

动态类型与对象类型相似,但是对象类型变量的类型检查是在编译时发生的,而动态类型变量的类型检查是在运行时发生的。

字符串(String)类型

字符串(String)类型允许您给变量分配任何字符串值。字符串(String)类型是 System.String 类的别名。它是从对象(Object)类型派生的。字符串(String)类型的值可以通过两种形式进行分配:引号和@引号(称作”逐字字符串”,将转义字符\当作普通字符对待)。

1
2
3
4
5
6
7
8
9
10
string str = @"C:\Windows";

// 等价于:
string str = "C:\\Windows";

// @ 字符串中可以任意换行,换行符及缩进空格都计算在字符串长度之内。
string str = @"<script type=""text/javascript"">
<!--
-->
</script>";

指针类型(Pointer types)

指针类型变量存储另一种类型的内存地址。C# 中的指针与 C 或 C++ 中的指针有相同的功能。声明指针类型的语法:

1
2
3
4
type* identifier;
// 例如:
char* cptr;
int* iptr;

类型转换

  • 隐式类型转换:这些转换是 C# 默认的以安全方式进行的转换, 不会导致数据丢失。例如,从小的整数类型转换为大的整数类型,从派生类转换为基类。
  • 显式类型转换:显式类型转换(方法:(type)value),即强制类型转换。显式转换需要强制转换运算符,而且强制转换会造成数据丢失。

C#提供了下列内置的类型转换方法:

方法 描述
ToBoolean 如果可能的话,把类型转换为布尔型。
ToByte 把类型转换为字节类型。
ToChar 如果可能的话,把类型转换为单个 Unicode 字符类型。
ToDateTime 把类型(整数或字符串类型)转换为 日期-时间 结构。
ToDecimal 把浮点型或整数类型转换为十进制类型。
ToDouble 把类型转换为双精度浮点型。
ToInt16 把类型转换为 16 位整数类型。
ToInt32 把类型转换为 32 位整数类型。
ToInt64 把类型转换为 64 位整数类型。
ToSbyte 把类型转换为有符号字节类型。
ToSingle 把类型转换为小浮点数类型。
ToString 把类型转换为字符串类型。
ToType 把类型转换为指定类型。
ToUInt16 把类型转换为 16 位无符号整数类型。
ToUInt32 把类型转换为 32 位无符号整数类型。
ToUInt64 把类型转换为 64 位无符号整数类型。

变量和常量

变量

变量定义的语法:

1
<data_type> <variable_list>;

常量

整数常量

整数常量可以是十进制、八进制或十六进制的常量。

  • 前缀:指定基数,0x 或 0X 表示十六进制,0 表示八进制,没有前缀则表示十进制。
  • 后缀:可以是 U 和 L 的组合,其中,U 和 L 分别表示 unsigned 和 long。后缀可以是大写或者小写,多个后缀以任意顺序进行组合。
1
2
3
4
5
6
7
8
9
10
11
12
13
212         /* 合法 */
215u /* 合法 */
0xFeeL /* 合法 */
078 /* 非法:8 不是一个八进制数字 */
032UU /* 非法:不能重复后缀 */

85 /* 十进制 */
0213 /* 八进制 */
0x4b /* 十六进制 */
30 /* int */
30u /* 无符号 int */
30l /* long */
30ul /* 无符号 long */

浮点常量

一个浮点常量是由整数部分、小数点、小数部分和指数部分组成。可以使用小数形式或者指数形式来表示浮点常量。

1
2
3
4
5
3.14159       /* 合法 */
314159E-5L /* 合法 */
510E /* 非法:不完全指数 */
210f /* 非法:没有小数或指数 */
.e55 /* 非法:缺少整数或小数 */

使用小数形式表示时,必须包含小数点、指数或同时包含两者。使用指数形式表示时,必须包含整数部分、小数部分或同时包含两者。有符号的指数是用 e 或 E 表示的。

字符常量

字符常量括在单引号里.

字符串常量

字符串常量是括在双引号 “” 里,或者是括在 @”” 里。字符串常量包含的字符与字符常量相似,可以是:普通字符、转义序列和通用字符.

定义常量

常量是使用 const 关键字来定义的 。定义一个常量的语法如下:

1
const <data_type> <constant_name> = value;

Console

System 命名空间中的 Console 类提供了一个函数 ReadLine(),用于接收来自用户的输入,并把它存储到一个变量中。提供了WriteLine()方法用于输出至控制台。

1
2
3
int num;
num = Convert.ToInt32(Console.ReadLine());
Console.WriteLine("test {0}", num);

函数 Convert.ToInt32() 把用户输入的数据转换为 int 数据类型,因为 Console.ReadLine() 只接受字符串格式的数据。

集合(Collection)

描述和用法
动态数组(ArrayList) 它代表了可被单独索引的对象的有序集合。
它基本上可以替代一个数组。但是,与数组不同的是,您可以使用索引在指定的位置添加和移除项目,动态数组会自动重新调整它的大小。它也允许在列表中进行动态内存分配、增加、搜索、排序各项。
哈希表(Hashtable) 它使用键来访问集合中的元素。
当使用键访问元素时,则使用哈希表,而且您可以识别一个有用的键值。哈希表中的每一项都有一个键/值对。键用于访问集合中的项目。
排序列表(SortedList) 它可以使用键和索引来访问列表中的项。
排序列表是数组和哈希表的组合。它包含一个可使用键或索引访问各项的列表。如果您使用索引访问各项,则它是一个动态数组(ArrayList),如果您使用键访问各项,则它是一个哈希表(Hashtable)。集合中的各项总是按键值排序。
堆栈(Stack) 它代表了一个后进先出的对象集合。
当需要对各项进行后进先出的访问时,则使用堆栈。当您在列表中添加一项,称为推入元素,当您从列表中移除一项时,称为弹出元素。
队列(Queue) 它代表了一个先进先出的对象集合。
当需要对各项进行先进先出的访问时,则使用队列。当您在列表中添加一项,称为入队,当您从列表中移除一项时,称为出队。
点阵列(BitArray) 它代表了一个使用值 1 和 0 来表示的二进制数组。
当需要存储位,但是事先不知道位数时,则使用点阵列。您可以使用整型索引从点阵列集合中访问各项,索引从零开始。

运算符

运算符大多和Java类似,以下举出几个不一样的:

运算符 描述 实例
sizeof() 返回数据类型的大小。 sizeof(int),将返回 4.
typeof() 返回 class 的类型。 typeof(StreamReader);
& 返回变量的地址。 &a; 将得到变量的实际地址。
* 变量的指针。 *a; 将指向一个变量。
is 判断对象是否为某一类型。 if( Ford is Car) // 检查 Ford 是否是 Car 类的一个对象。
as 强制转换,即使转换失败也不会抛出异常。 Object obj = new StringReader(“Hello”);
StringReader r = obj as StringReader;

流程控制

语句 描述
if 语句 一个 if 语句 由一个布尔表达式后跟一个或多个语句组成。
if…else 语句 一个 if 语句 后可跟一个可选的 else 语句,else 语句在布尔表达式为假时执行。
switch 语句 一个 switch 语句允许测试一个变量等于多个值时的情况。
? : 三元判断运算符
while 循环 当给定条件为真时,重复语句或语句组。它会在执行循环主体之前测试条件。
for/foreach 循环 多次执行一个语句序列,简化管理循环变量的代码。(如:foreach (int i in array))
do…while 循环 除了它是在循环主体结尾测试条件外,其他与 while 语句类似。

可空类型(Nullable)

C# 提供了一个特殊的数据类型: Nullable 类型(可空类型),可空类型可以表示其基础值类型正常范围内的值,再加上一个 null 值。

在处理数据库和其他包含可能未赋值的元素的数据类型时,将 null 赋值给数值类型或布尔型的功能特别有用。例如,数据库中的布尔型字段可以存储值 true 或 false,或者,该字段也可以未定义。声明一个 nullable 类型(可空类型)的语法如下:

1
< data_type> ? <variable_name> = null;

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using System;
namespace CalculatorApplication {
class NullablesAtShow {
static void Main(string[] args) {
int? num1 = null;
int? num2 = 45;
double? num3 = new double?();
double? num4 = 3.14157;

bool? boolval = new bool?();

// 显示值

Console.WriteLine("显示可空类型的值: {0}, {1}, {2}, {3}",
num1, num2, num3, num4);
Console.WriteLine("一个可空的布尔值: {0}", boolval);
Console.ReadLine();

}
}
}

Null 合并运算符(??)

Null 合并运算符用于定义可空类型和引用类型的默认值。Null 合并运算符为类型转换定义了一个预设值,以防可空类型的值为 Null。Null 合并运算符把操作数类型隐式转换为另一个可空(或不可空)的值类型的操作数的类型。如果第一个操作数的值为 null,则运算符返回第二个操作数的值,否则返回第一个操作数的值。下面的实例演示了这点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using System;
namespace CalculatorApplication {
class NullablesAtShow {

static void Main(string[] args) {

double? num1 = null;
double? num2 = 3.14157;
double num3;
num3 = num1 ?? 5.34; // num1 如果为空值则返回 5.34
Console.WriteLine("num3 的值: {0}", num3);
num3 = num2 ?? 5.34;
Console.WriteLine("num3 的值: {0}", num3);
Console.ReadLine();

}
}
}

访问修饰符

一个 访问修饰符 定义了一个类成员的范围和可见性。C# 支持的访问修饰符如下所示:

  • public:所有对象都可以访问;
  • private:对象本身在对象内部可以访问;
  • protected:只有该类对象及其子类对象可以访问
  • internal:同一个程序集的对象可以访问;
  • protected internal:访问限于当前程序集或派生自包含类的类型。

类的默认访问标识符是 internal,成员的默认访问标识符是 private。

public 访问修饰符

public 访问修饰符允许一个类将其成员变量和成员函数暴露给其他的函数和对象。任何公有成员可以被外部的类访问。

private 访问修饰符

private 访问修饰符允许一个类将其成员变量和成员函数对其他的函数和对象进行隐藏。只有同一个类中的函数可以访问它的私有成员。即使是类的实例也不能访问它的私有成员。

protected 访问修饰符

protected 访问修饰符允许子类访问它的基类的成员变量和成员函数。这样有助于实现继承。

internal 访问修饰符

internal 访问修饰符允许一个类将其成员变量和成员函数暴露给当前程序中的其他函数和对象。换句话说,带有 internal 访问修饰符的任何成员可以被定义在该成员所定义的应用程序内的任何类或方法访问。

protected internal 访问修饰符

protected internal 访问修饰符允许在本类,派生类或者包含该类的程序集中访问。这也被用于实现继承。

class

析构函数不能继承或重载。

继承

C#不支持多重继承,同Java一样也需要接口来实现,继承语法:

1
2
3
4
5
6
<访问修饰符符> class <基类> {
// ...
}
class <派生类> : <基类> {
// ...
}

父类对象应在子类对象创建之前被创建,可以在成员初始化列表中进行父类的初始化(base类似于Java中的super):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Rectangle {
// 成员变量
protected double length;
protected double width;
public Rectangle(double l, double w) {
length = l;
width = w;
}

public void Display() {
// ...
}
}

class Tabletop : Rectangle {
private double cost;
public Tabletop(double l, double w) : base(l, w) {
}

public void Display() {
base.Display();
Console.WriteLine("成本: {0}", GetCost());
}
}

class ExecuteRectangle {
static void Main(string[] args) {
Tabletop t = new Tabletop(4.5, 7.5);
t.Display();
Console.ReadLine();
}
}

多态

在面向对象编程范式中,多态性往往表现为”一个接口,多个功能“。

  • 静态多态性中,函数的响应是在编译时发生的;
  • 动态多态性中,函数的响应是在运行时发生的。

静态多态性

在编译时,函数和对象的连接机制被称为早期绑定,也被称为静态绑定。C# 提供了两种技术来实现静态多态性。分别为:

  • 函数重载
  • 运算符重载

动态多态性

动态多态性是通过 抽象类 和 虚方法 实现的。

C# 允许使用关键字 abstract 创建抽象类,用于提供接口的部分类的实现。当一个派生类继承自该抽象类时,实现即完成。抽象类包含抽象方法,抽象方法可被派生类实现。派生类具有更专业的功能。下面是有关抽象类的一些规则:

  • 不能创建一个抽象类的实例。
  • 不能在一个抽象类外部声明一个抽象方法。
  • 通过在类定义前面放置关键字 sealed,可以将类声明为密封类。当一个类被声明为 sealed 时,它不能被继承。抽象类不能被声明为 sealed。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using System;
namespace PolymorphismApplication {
abstract class Shape {
abstract public int area();
}

class Rectangle: Shape {
private int length;
private int width;

public Rectangle( int a=0, int b=0) {
length = a;
width = b;
}

public override int area () {
Console.WriteLine("Rectangle 类的面积:");
return (width * length);
}
}

class RectangleTester {
static void Main(string[] args) {
Rectangle r = new Rectangle(10, 7);
double a = r.area();
Console.WriteLine("面积: {0}",a);
Console.ReadKey();
}
}
}

当有一个定义在类中的函数需要在继承类中实现时,可以使用虚方法。虚方法是使用关键字 virtual 声明的。虚方法可以在不同的继承类中有不同的实现。对虚方法的调用是在运行时发生的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
using System;
namespace PolymorphismApplication {
class Shape {
protected int width, height;

public Shape( int a=0, int b=0) {
width = a;
height = b;
}

public virtual int area() {
Console.WriteLine("父类的面积:");
return 0;
}
}

class Rectangle: Shape {
public Rectangle( int a=0, int b=0): base(a, b) {

}

public override int area () {
Console.WriteLine("Rectangle 类的面积:");
return (width * height);
}
}

class Triangle: Shape {
public Triangle(int a = 0, int b = 0): base(a, b) {

}

public override int area() {
Console.WriteLine("Triangle 类的面积:");
return (width * height / 2);
}
}

class Caller {
public void CallArea(Shape sh) {
int a;
a = sh.area();
Console.WriteLine("面积: {0}", a);
}
}

class Tester {

static void Main(string[] args) {
Caller c = new Caller();
Rectangle r = new Rectangle(10, 7);
Triangle t = new Triangle(10, 5);
c.CallArea(r);
c.CallArea(t);
Console.ReadKey();
}
}
}

注意:override重写的属性必须是virtual、abstract或override

interface

类似于Java。

namespace

在一个命名空间中声明的类的名称与另一个命名空间中声明的相同的类的名称不冲突。

1
2
3
namespace namespace_name {
// 代码声明
}

可通过namespace_name.item_name方式调用命名空间中的item。使用 using 命名空间指令,这样在使用的时候就不用在前面加上命名空间名称。

预处理器指令

预处理器指令指导编译器在实际编译开始之前对信息进行预处理。

所有的预处理器指令都是以 # 开始。且在一行上,只有空白字符可以出现在预处理器指令之前。预处理器指令不是语句,所以它们不以分号(;)结束。

C# 编译器没有一个单独的预处理器,但是,指令被处理时就像是有一个单独的预处理器一样。在 C# 中,预处理器指令用于在条件编译中起作用。与 C 和 C++ 不同的是,它们不是用来创建宏。一个预处理器指令必须是该行上的唯一指令。

预处理器指令 描述
#define 它用于定义一系列成为符号的字符。
#undef 它用于取消定义符号。
#if 它用于测试符号是否为真。
#else 它用于创建复合条件指令,与 #if 一起使用。
#elif 它用于创建复合条件指令。
#endif 指定一个条件指令的结束。
#line 它可以让您修改编译器的行数以及(可选地)输出错误和警告的文件名。
#error 它允许从代码的指定位置生成一个错误。
#warning 它允许从代码的指定位置生成一级警告。
#region 它可以让您在使用 Visual Studio Code Editor 的大纲特性时,指定一个可展开或折叠的代码块。
#endregion 它标识着 #region 块的结束。

异常处理

  • try:一个 try 块标识了一个将被激活的特定的异常的代码块。后跟一个或多个 catch 块。
  • catch:程序通过异常处理程序捕获异常。catch 关键字表示异常的捕获。
  • finally:finally 块用于执行给定的语句,不管异常是否被抛出都会执行。例如,如果您打开一个文件,不管是否出现异常文件都要被关闭。
  • throw:当问题出现时,程序抛出一个异常。使用 throw 关键字来完成。

C# 中的异常类主要是直接或间接地派生于 System.Exception 类。System.ApplicationException 和 System.SystemException 类是派生于 System.Exception 类的异常类。

  • System.ApplicationException 类支持由应用程序生成的异常。所以程序员定义的异常都应派生自该类。
  • System.SystemException 类是所有预定义的系统异常的基类。

反射(Reflection)

委托(Delegate)

委托是存有对某个方法的引用的一种引用类型变量。引用可在运行时被改变。委托特别用于实现事件和回调方法。所有的委托都派生自 System.Delegate 类。

声明委托

1
2
3
4
delegate <return type> <delegate-name> <parameter list>

// 下面的委托可被用于引用任何一个带有一个单一的 string 参数的方法,并返回一个 int 类型变量
public delegate int MyDelegate (string s);

实例化委托

1
2
3
4
public delegate void printString(string s);
...
printString ps1 = new printString(WriteToScreen);
printString ps2 = new printString(WriteToFile);

委托的多播

委托对象可使用 “+” 运算符进行合并。一个合并委托调用它所合并的两个委托。只有相同类型的委托可被合并。”-“ 运算符可用于从合并的委托中移除组件委托。

使用委托的这个有用的特点,可以创建一个委托被调用时要调用的方法的调用列表。这被称为委托的 多播(multicasting),也叫组播。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using System;

delegate int NumberChanger(int n);
namespace DelegateAppl {
class TestDelegate {
static int num = 10;
public static int AddNum(int p) {
num += p;
return num;
}

public static int MultNum(int q) {
num *= q;
return num;
}

public static int getNum() {
return num;
}

static void Main(string[] args) {
// 创建委托实例
NumberChanger nc;
NumberChanger nc1 = new NumberChanger(AddNum);
NumberChanger nc2 = new NumberChanger(MultNum);
nc = nc1;
nc += nc2;
// 调用多播
nc(5);
Console.WriteLine("Value of Num: {0}", getNum()); // 75
Console.ReadKey();
}
}
}

事件(Event)

  • 发布器(publisher) 是一个包含事件和委托定义的对象。事件和委托之间的联系也定义在这个对象中。发布器(publisher)类的对象调用这个事件,并通知其他的对象。
  • 订阅器(subscriber) 是一个接受事件并提供事件处理程序的对象。在发布器(publisher)类中的委托调用订阅器(subscriber)类中的方法(事件处理程序)。

声明事件

在类的内部声明事件,首先必须声明该事件的委托类型。例如:

1
public delegate void BoilerLogHandler(string status);

然后,声明事件本身,使用 event 关键字:

1
2
// 基于上面的委托定义事件
public event BoilerLogHandler BoilerEventLog;

上面的代码定义了一个名为 BoilerLogHandler 的委托和一个名为 BoilerEventLog 的事件,该事件在生成的时候会调用委托。

实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
using System;

namespace SimpleEvent {
using System;

/***********发布器类***********/
public class EventTest
{
private int value;

public delegate void NumManipulationHandler();
public event NumManipulationHandler ChangeNum;

protected virtual void OnNumChanged() {
if (ChangeNum != null) {
ChangeNum(); /* 事件被触发 */
}else {
Console.WriteLine( "event not fire" );
Console.ReadKey(); /* 回车继续 */
}
}

public EventTest() {
int n = 5;
SetValue(n);
}

public void SetValue(int n) {
if (value != n) {
value = n;
OnNumChanged();
}
}
}

/***********订阅器类***********/
public class subscribEvent {
public void printf() {
Console.WriteLine( "event fire" );
Console.ReadKey(); /* 回车继续 */
}
}

/***********触发***********/
public class MainClass {
public static void Main() {
EventTest e = new EventTest(); /* 实例化对象,第一次没有触发事件 */
subscribEvent v = new subscribEvent(); /* 实例化对象 */
e.ChangeNum += new EventTest.NumManipulationHandler(v.printf); /* 注册 */
e.SetValue(7);
e.SetValue(11);
}
}
}

// 当上面的代码被编译和执行时,它会产生下列结果:
event not fire
event fire
event fire

泛型(Generic)

使用泛型是一种增强程序功能的技术,具体表现在以下几个方面:

  • 它有助于最大限度地重用代码、保护类型的安全以及提高性能。
  • 可以创建泛型集合类。.NET 框架类库在 System.Collections.Generic 命名空间中包含了一些新的泛型集合类。可以使用这些泛型集合类来替代 System.Collections 中的集合类。
  • 可以创建自己的泛型接口、泛型类、泛型方法、泛型事件和泛型委托。
  • 可以对泛型类进行约束以访问特定数据类型的方法。
  • 关于泛型数据类型中使用的类型的信息可在运行时通过使用反射获取。

泛型类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
using System;
using System.Collections.Generic;

namespace GenericApplication {
public class MyGenericArray<T> {
private T[] array;

public MyGenericArray(int size) {
array = new T[size + 1];
}

public T getItem(int index) {
return array[index];
}

public void setItem(int index, T value) {
array[index] = value;
}
}

class Tester {
static void Main(string[] args) {
// 声明一个整型数组
MyGenericArray<int> intArray = new MyGenericArray<int>(5);
// 设置值
for (int c = 0; c < 5; c++) {
intArray.setItem(c, c*5);
}
// 获取值
for (int c = 0; c < 5; c++) {
Console.Write(intArray.getItem(c) + " ");
}
Console.WriteLine();
// 声明一个字符数组
MyGenericArray<char> charArray = new MyGenericArray<char>(5);
// 设置值
for (int c = 0; c < 5; c++) {
charArray.setItem(c, (char)(c+97));
}
// 获取值
for (int c = 0; c < 5; c++) {
Console.Write(charArray.getItem(c) + " ");
}
Console.WriteLine();
Console.ReadKey();
}
}
}

泛型方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using System;
using System.Collections.Generic;

namespace GenericMethodAppl {
class Program {
static void Swap<T>(ref T lhs, ref T rhs) {
T temp;
temp = lhs;
lhs = rhs;
rhs = temp;
}

static void Main(string[] args) {
int a, b;
char c, d;
a = 10;
b = 20;
c = 'I';
d = 'V';

// 在交换之前显示值
Console.WriteLine("Int values before calling swap:");
Console.WriteLine("a = {0}, b = {1}", a, b);
Console.WriteLine("Char values before calling swap:");
Console.WriteLine("c = {0}, d = {1}", c, d);

// 调用 swap
Swap<int>(ref a, ref b);
Swap<char>(ref c, ref d);

// 在交换之后显示值
Console.WriteLine("Int values after calling swap:");
Console.WriteLine("a = {0}, b = {1}", a, b);
Console.WriteLine("Char values after calling swap:");
Console.WriteLine("c = {0}, d = {1}", c, d);
Console.ReadKey();
}
}
}

泛型委托

可以通过类型参数定义泛型委托。例如:

1
delegate T NumberChanger<T>(T n);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using System;
using System.Collections.Generic;

delegate T NumberChanger<T>(T n);
namespace GenericDelegateAppl {
class TestDelegate {
static int num = 10;
public static int AddNum(int p) {
num += p;
return num;
}

public static int MultNum(int q) {
num *= q;
return num;
}

public static int getNum() {
return num;
}

static void Main(string[] args) {
// 创建委托实例
NumberChanger<int> nc1 = new NumberChanger<int>(AddNum);
NumberChanger<int> nc2 = new NumberChanger<int>(MultNum);
// 使用委托对象调用方法
nc1(25);
Console.WriteLine("Value of Num: {0}", getNum());
nc2(5);
Console.WriteLine("Value of Num: {0}", getNum());
Console.ReadKey();
}
}
}

Lambda表达式

1、表达式Lambda

表达式位于 => 运算符右侧的 lambda 表达式称为“表达式 lambda”。 表达式 lambda 会返回表达式的结果,并采用以下基本形式:

1
(input parameters) => expression

仅当 lambda 只有一个输入参数时,括号才是可选的;否则括号是必需的。括号内的两个或更多输入参数使用逗号加以分隔;有时,编译器难以或无法推断输入类型,如果出现这种情况,可以按以下示例中所示方式显式指定类型:

1
(int x, string s) => s.Length > x

使用空括号指定零个输入参数:

1
() => SomeMethod()

2、语句Lambda

当lambda表达式中,有多个语句时,写成如下形式:

1
(input parameters) => {statement;}

例如:

1
2
3
4
5
6
7
delegate void TestDelegate(string s);
// ...
TestDelegate myDel = n => {
string s = n + " " + "World";
Console.WriteLine(s);
};
myDel("Hello");

线程

Thread类

Thread类的常用属性:

属性 描述
CurrentContext 获取线程正在其中执行的当前上下文。
CurrentCulture 获取或设置当前线程的区域性。
CurrentPrinciple 获取或设置线程的当前负责人(对基于角色的安全性而言)。
CurrentThread 获取当前正在运行的线程。
CurrentUICulture 获取或设置资源管理器使用的当前区域性以便在运行时查找区域性特定的资源。
ExecutionContext 获取一个 ExecutionContext 对象,该对象包含有关当前线程的各种上下文的信息。
IsAlive 获取一个值,该值指示当前线程的执行状态。
IsBackground 获取或设置一个值,该值指示某个线程是否为后台线程。
IsThreadPoolThread 获取一个值,该值指示线程是否属于托管线程池。
ManagedThreadId 获取当前托管线程的唯一标识符。
Name 获取或设置线程的名称。
Priority 获取或设置一个值,该值指示线程的调度优先级。
ThreadState 获取一个值,该值包含当前线程的状态。

Thread类的常用方法:

序号 方法名 & 描述
1 public void Abort()
在调用此方法的线程上引发 ThreadAbortException,以开始终止此线程的过程。调用此方法通常会终止线程。
2 public static LocalDataStoreSlot AllocateDataSlot()
在所有的线程上分配未命名的数据槽。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段。
3 public static LocalDataStoreSlot AllocateNamedDataSlot( string name)
在所有线程上分配已命名的数据槽。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段。
4 public static void BeginCriticalRegion()
通知主机执行将要进入一个代码区域,在该代码区域内线程中止或未经处理的异常的影响可能会危害应用程序域中的其他任务。
5 public static void BeginThreadAffinity()
通知主机托管代码将要执行依赖于当前物理操作系统线程的标识的指令。
6 public static void EndCriticalRegion()
通知主机执行将要进入一个代码区域,在该代码区域内线程中止或未经处理的异常仅影响当前任务。
7 public static void EndThreadAffinity()
通知主机托管代码已执行完依赖于当前物理操作系统线程的标识的指令。
8 public static void FreeNamedDataSlot(string name)
为进程中的所有线程消除名称与槽之间的关联。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段。
9 public static Object GetData( LocalDataStoreSlot slot )
在当前线程的当前域中从当前线程上指定的槽中检索值。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段。
10 public static AppDomain GetDomain()
返回当前线程正在其中运行的当前域。
11 public static AppDomain GetDomainID()
返回唯一的应用程序域标识符。
12 public static LocalDataStoreSlot GetNamedDataSlot( string name )
查找已命名的数据槽。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段。
13 public void Interrupt()
中断处于 WaitSleepJoin 线程状态的线程。
14 public void Join()
在继续执行标准的 COM 和 SendMessage 消息泵处理期间,阻塞调用线程,直到某个线程终止为止。此方法有不同的重载形式。
15 public static void MemoryBarrier()
按如下方式同步内存存取:执行当前线程的处理器在对指令重新排序时,不能采用先执行 MemoryBarrier 调用之后的内存存取,再执行 MemoryBarrier 调用之前的内存存取的方式。
16 public static void ResetAbort()
取消为当前线程请求的 Abort。
17 public static void SetData( LocalDataStoreSlot slot, Object data )
在当前正在运行的线程上为此线程的当前域在指定槽中设置数据。为了获得更好的性能,请改用以 ThreadStaticAttribute 属性标记的字段。
18 public void Start()
开始一个线程。
19 public static void Sleep( int millisecondsTimeout )
让线程暂停一段时间。
20 public static void SpinWait( int iterations )
导致线程等待由 iterations 参数定义的时间量。
21 public static byte VolatileRead( ref byte address )
public static double VolatileRead( ref double address )
public static int VolatileRead( ref int address )
public static Object VolatileRead( ref Object address )
读取字段值。无论处理器的数目或处理器缓存的状态如何,该值都是由计算机的任何处理器写入的最新值。此方法有不同的重载形式。这里只给出了一些形式。
22 public static void VolatileWrite( ref byte address, byte value )
public static void VolatileWrite( ref double address, double value )
public static void VolatileWrite( ref int address, int value )
public static void VolatileWrite( ref Object address, Object value )
立即向字段写入一个值,以使该值对计算机中的所有处理器都可见。此方法有不同的重载形式。这里只给出了一些形式。
23 public static bool Yield()
导致调用线程执行准备好在当前处理器上运行的另一个线程。由操作系统选择要执行的线程。

创建线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using System;
using System.Threading;

namespace MultithreadingApplication {
class ThreadCreationProgram {
public static void CallToChildThread() {
Console.WriteLine("Child thread starts");
}

static void Main(string[] args) {
ThreadStart childref = new ThreadStart(CallToChildThread);
Console.WriteLine("In Main: Creating the Child thread");
Thread childThread = new Thread(childref);
childThread.Start();
Console.ReadKey();
}
}
}

// 结果:
In Main: Creating the Child thread
Child thread starts

管理线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using System;
using System.Threading;

namespace MultithreadingApplication {
class ThreadCreationProgram {
public static void CallToChildThread() {
Console.WriteLine("Child thread starts");
// 线程暂停 5000 毫秒
int sleepfor = 5000;
Console.WriteLine("Child Thread Paused for {0} seconds",
sleepfor / 1000);
Thread.Sleep(sleepfor);
Console.WriteLine("Child thread resumes");
}

static void Main(string[] args) {
ThreadStart childref = new ThreadStart(CallToChildThread);
Console.WriteLine("In Main: Creating the Child thread");
Thread childThread = new Thread(childref);
childThread.Start();
Console.ReadKey();
}
}
}

// 结果:
In Main: Creating the Child thread
Child thread starts
Child Thread Paused for 5 seconds
Child thread resumes

销毁线程

Abort() 方法用于销毁线程。通过抛出 ThreadAbortException 在运行时中止线程。这个异常不能被捕获,如果有 finally 块,控制会被送至 finally 块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
using System;
using System.Threading;

namespace MultithreadingApplication {
class ThreadCreationProgram {
public static void CallToChildThread() {
try {
Console.WriteLine("Child thread starts");
// 计数到 10
for (int counter = 0; counter <= 10; counter++) {
Thread.Sleep(500);
Console.WriteLine(counter);
}
Console.WriteLine("Child Thread Completed");
} catch (ThreadAbortException e) {
Console.WriteLine("Thread Abort Exception");
} finally {
Console.WriteLine("Couldn't catch the Thread Exception");
}
}

static void Main(string[] args) {
ThreadStart childref = new ThreadStart(CallToChildThread);
Console.WriteLine("In Main: Creating the Child thread");
Thread childThread = new Thread(childref);
childThread.Start();
// 停止主线程一段时间
Thread.Sleep(2000);
// 现在中止子线程
Console.WriteLine("In Main: Aborting the Child thread");
childThread.Abort();
Console.ReadKey();
}
}
}

// 结果:
In Main: Creating the Child thread
Child thread starts
0
1
2
In Main: Aborting the Child thread
Thread Abort Exception
Couldn't catch the Thread Exception