背景
事实上,一切都是类路径。你的狗?一个类路径,由吼叫、口水和便便组成。
javac -classpath barks.jar:drool.jar:poop.jar Dog.java
我们还将查看一些 Gradle 构建扫描,因为它们是可视化类路径的非常好的工具。
classpath 是什么
类路径是告诉 SDK 工具(如 java 或 javac)或包装 SDK 工具的构建工具(如 Ant、Maven 或 Gradle)在何处查找非扩展的第三方或用户定义的类的方式或核心 JDK 的一部分。
第三方类通常称为库或依赖项,通常以 jar 的形式提供。用户定义的类是由个人(即应用程序)编写的类。将此与核心 Java 平台类(如 java.lang.String)进行对比,后者是 JDK 的一部分,不需要在类路径中指定。 (可以将这些平台类视为位于引导类路径中。)
类路径只是 jar 文件和包含类文件的目录的有序列表。回顾上面的 Dog.java 示例,该编译操作的类路径实际上只是三个指定的 jar 文件,依次为:barks.jar、drool.jar、poop.jar。
作为现代 JVM 开发人员,我们很少直接与 javac(Java 编译器)等工具进行交互。我们使用“构建工具”来编排我们越来越复杂的构建。同理,我们一般不直接指定类路径;相反,我们“声明依赖关系”。
在 gradle 构建脚本中声明依赖项时,该工具会将其视为解决该依赖项的指令(通常涉及从 Internet 下载),最终结果是一个或多个 jar 文件代表“依赖”最终在类路径上。例如,如果声明以下内容:
dependencies {
implementation 'androidx.appcompat:appcompat:1.1.0'
}
那么最可能的结果是 Androidx appcompat lib v1.1.0(表示为 jar 文件)在编译时和运行时类路径上结束。此外,appcompat 的所有依赖项(以及它们的依赖项),也称为传递依赖项,将被添加到相同的类路径中。下面是构建扫描的一部分,显示了 appcompat 的顶级依赖项:
虽然构建扫描显示了组织成树结构的依赖关系,但当 gradle 实际调用相关的编译任务时,这棵树被展平为一个简单的列表。
声明依赖在功能上等同于向一个或多个类路径添加一个或多个 jar。
要了解类路径如何影响 Java 运行时,我们必须谈谈类加载。
类加载
Java 应用程序使用了三个内置的类加载器:
引导类加载器。用于加载 java.lang.String 和 java.util.ArrayList 等 JDK 类。
扩展类加载器。用于加载扩展类(超出本文范围)。
应用程序或系统类加载器。用于加载第三方和用户定义的类。这是使用用户定义的类路径配置的类加载器。
还可以在运行时定义自定义类加载器。
以下简单示例演示了第一种和第三种类型:
public class Main {
public static void main(String... args) {
System.out.println("Main class loader = " + Main.class.getClassLoader());
System.out.println("String class loader = " + String.class.getClassLoader());
}
}
Main class loader = sun.misc.Launcher$AppClassLoader@7852e922
String class loader = null
String 类加载器显示为 null,这表明它是引导类加载器。由于这个类加载器是用本机代码编写的,它没有作为 Java 类的表示。
这些类加载器按层次结构组织,引导类加载器充当扩展类加载器的父级,扩展类加载器本身就是应用程序类加载器的父级。当被要求加载某个类时,类加载器委托给它们的父级。这些父母委托给他们的父母,依此类推。如果父级无法加载类,则直接子级会尝试加载,依此类推,直到最终加载类或抛出 ClassNotFoundException 或 NoClassDefFoundError.
每个 Class 实例都有一个对加载它的 ClassLoader 的引用,该引用由 Class.getClassLoader() 方法检索。
定义的类路径用于配置应用程序类加载器,它使用该信息加载程序在运行时请求的类。
类路径是顺序敏感的并且可以容忍重复的条目
现在我们知道类路径如何影响 Java 或与 Java 相邻的应用程序:它指示应用程序类加载器如何查找 3rd-party 库和用户定义的类。因此,类路径在非常深的层次上影响 Java 运行时。
可以将类路径视为提供类文件和/或 jar 的元素的有序列表,它们是类和资源文件的集合。这些类文件用于编排构建、编译或运行项目等操作。
在大多数情况下,从类路径中删除一个元素只会导致给定的操作失败,但在其他情况下,您实际上可能会看到不同的行为!理解类路径是顺序敏感的也很重要。让我们再次考虑我们的狗示例。如果不是
javac -classpath barks.jar:drool.jar:poop.jar Dog.java
而是
javac -classpath drool.jar:barks.jar:poop.jar Dog.java
如果 drool.jar 和 barks.jar 有重叠的类文件(或者是因为错误打包,或者是更大的重构中的中间步骤,或者糟糕的架构等),那么在第一种情况下,我们将使用来自 barks.jar 的类文件,在第二种情况下,来自 drool.jar 的类文件,这些很可能是不同的。这意味着类路径也可以容忍重复条目:SDK 工具只是忽略与列表中较早出现的条目重复的每个条目。
Gradle 保证类路径顺序是确定性的。
我们所说的“构建”是什么意思?
现代 JVM 开发(至少是 Android 开发)很少涉及与 SDK 工具(如 javac 或 kotlinc)的直接交互。相反,我们使用“构建工具”“运行构建”。在底层,这些构建工具(例如 Gradle)调用了更基础的 SDK 工具。例如,Gradle 的 java 插件注册了一个任务,compileJava,类型为 JavaCompile;在执行期间,该任务将调用 Java 编译器 javac。
构建是对编译和运行软件应用程序的更基本的 SDK 工具操作的高级抽象。
构建类路径
必须编译 Gradle 构建中的构建脚本,然后必须运行它们才能执行您的构建。这个过程由 Gradle 自动化,无论您的脚本是用 Groovy (.gradle) 还是 Kotlin (.gradle.kts) 编写的。当您执行构建时(无论是单击 Android Studio 中的绿色箭头还是从命令行运行 ./gradlew app:assembleDebug),Gradle 将分两个不同的步骤执行该指令:首先编译您的构建脚本,然后运行你的构建。如果您熟悉 Gradle 构建生命周期,将第一步视为配置阶段,而将第二步视为执行阶段可能会有所帮助。
与 JVM 中的任何其他编译一样,编译脚本需要一个编译类路径,我们称之为 build compile classpath。
首先考虑一个非常简单的构建脚本并检查构建类路径,它位于构建扫描的构建依赖项部分。创建一个 build.gradle 并运行它!
// root build.gradle
println "project name = $name"
然后我们可以执行 gradle help --scan,我们将在其余的输出中看到以下内容:
> Configure project :
project name = classpaths-example
我们还将看到这个简单构建的构建扫描。这些是构建依赖项:
首先,我们可以从 println 语句起作用这一事实推断出 Groovy 开发工具包 (GDK) 位于类路径中,而且 JDK 也是如此。 (println 是 System.out.println() 的 Groovy 简写。)最后,name 变量是支持我们的 Gradle 脚本的 Project 实例上的一个属性,这意味着 Gradle API 也默认可用。所以,尽管我们的构建扫描,Gradle 在构建类路径上为我们提供了很多,默认情况下。
最后,还有一个隐藏的依赖项。我们对 --scan 选项的使用隐式地将 Gradle Enterprise (GE) 插件添加到我们的构建中。目前尚不清楚为什么在扫描中看不到它。如果您添加一个空的 settings.gradle 并再次运行该构建,您将获得新的扫描:
将插件添加到项目
// root build.gradle
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.21'
}
}
用 gradle help --scan 配置这个项目(就像我们喜欢做的那样),我们可以看到这对我们的构建类路径做了什么:
在根构建脚本的构建类路径中有我们的 Kotlin Gradle 插件(具有传递依赖项)。您会注意到我们此时甚至还没有应用该插件,更不用说以任何方式对其进行配置了。相反,我们使用古老的 buildscript 块将其显式添加到我们的构建类路径中。
plugins块
显式添加到构建类路径的替代方法是使用 plugins 块。
// root build.gradle
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.4.21'
}
这个块结合了添加 Kotlin Gradle Plugin (KGP) 和应用它,所以它的行为与上面的有点不同。
如果我们再次运行 gradle help --scan,我们可以检查我们的构建类路径:
正如我们所看到的,它与另一个示例非常相似。有三个重要的区别:
- 我们可以看到额外的嵌套级别,顶层依赖为 org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:1.4.21。这被称为插件标记工件,并且是 Gradle 如何管理将插件 ID 映射到其“物理”工件(jar 及其传递依赖项)的问题。
- 如果您在两次扫描中检查构建依赖项,您会看到,首先,它们都是从 repo.maven.apache.org/maven2/(又名 mavenCentral())解析的,而在其次,它们都是从 plugins.gradle.org/m2(又名 gradlePluginPortal())解决的。
- 插件也适用于插件。
实际上,可以在不应用插件的情况下使用 plugins
使用 plugins 自动将插件应用到构建中是默认行为,但可以告诉 Gradle 不要这样做:
// root build.gradle
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.4.21' apply false
}
出于某种原因,您只需要在构建类路径中使用 Kotlin 插件,可能是因为另一个插件需要它。其次,这是一种强制构建中的所有子项目使用给定插件的相同版本的方法。考虑一下:
// settings.gradle
include ':app'
// root build.gradle
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.4.21' apply false
}
// app/build.gradle
plugins {
id 'org.jetbrains.kotlin.jvm'
}
从这里我们可以看到构建类路径基本上没有改变(尽管我们现在可以看到 Gradle Enterprise 插件,因为我们添加了一个 settings.gradle)。这证实了 KGP 没有被添加到 app 子项目的构建类路径中:它只是在根构建脚本的类路径中。尽管如此,由于 Gradle 定义良好(尽管文档记录不佳)的类加载器层次结构使根类加载器成为所有子项目类加载器的父级,子项目可以访问它。这些子项目类加载器以正常方式委托给它们的父级.
Android Gradle Plugin
// settings.gradle
pluginManagement {
repositories {
google() // AGP
gradlePluginPortal() // KGP
}
}
include ':app'
// app/build.gradle
plugins {
id 'com.android.application' version '4.2.0-beta04'
id 'org.jetbrains.kotlin.android' version '1.4.21'
}
这个例子有两个有趣的特性:我们不再需要一个根 build.gradle,我们在我们的设置脚本中有这个新的 pluginManagement 块。pluginManagement {} 块只能出现在 settings.gradle 文件中,它必须是文件中的第一个块,或者出现在初始化脚本中。默认情况下,plugins {} DSL 解析来自公共 Gradle 插件门户的插件。许多构建作者还希望从私有 Maven 或 Ivy 存储库中解析插件,因为这些插件包含专有的实现细节,或者只是为了更好地控制哪些插件可用于他们的构建。要指定自定义插件存储库,请使用 pluginManagement {} 内的存储库 {} 块:
pluginManagement {
repositories {
maven(url = "./maven-repo")
gradlePluginPortal()
ivy(url = "./ivy-repo")
}
}
这告诉 Gradle 在解析插件时首先查看位于 ../maven-repo 的 Maven 存储库,然后如果在 Maven 存储库中找不到插件,则检查 Gradle 插件门户。如果您不希望搜索 Gradle 插件门户,请省略 gradlePluginPortal() 行。最后,将检查位于 ../ivy-repo 的 Ivy 存储库。
pluginManagement {} 内的 plugins {} 块允许在单个位置定义构建的所有插件版本。然后可以通过 plugins {} 块按 id 将插件应用到任何构建脚本。
以这种方式设置插件版本的一个好处是 pluginManagement.plugins {} 没有与构建脚本 plugins {} 块相同的约束语法。这允许从 gradle.properties 获取插件版本,或通过其他机制加载。
pluginManagement {
val helloPluginVersion: String by settings
plugins {
id("com.example.hello") version "${helloPluginVersion}"
}
}
plugins {
id("com.example.hello")
}
helloPluginVersion=1.0.0
插件版本从 gradle.properties 加载并在设置脚本中配置,允许将插件添加到任何项目而无需指定版本。
插件解析规则允许您修改在 plugins {} 块中发出的插件请求,例如更改请求的版本或明确指定实现工件坐标。
要添加解析规则,请使用 pluginManagement {} 块内的 resolutionStrategy {}:
pluginManagement {
resolutionStrategy {
eachPlugin {
if (requested.id.namespace == "com.example") {
useModule("com.example:sample-plugins:1.0.0")
}
}
}
repositories {
maven {
url = uri("./maven-repo")
}
gradlePluginPortal()
ivy {
url = uri("./ivy-repo")
}
}
}
这告诉 Gradle 使用指定的插件实现工件,而不是使用其内置的从插件 ID 到 Maven/Ivy 坐标的默认映射。
自定义 Maven 和 Ivy 插件存储库除了实际实现插件的工件外,还必须包含插件标记工件。
Gradle build script compilation
什么是构建?它与类路径有什么关系?在这篇文章中,从根本上讲,我们一直在讨论构建编译时。我们的目标可能是编译我们的项目,但在我们这样做之前,我们必须编译我们的构建脚本。我们在这篇文章中学到的一切都归结为影响构建类路径的不同方式,以便我们可以编译我们的构建脚本,然后最终编译我们的项目。它确实是类路径。
Gradle 的类加载器层次结构
// app/build.gradle
def printClassLoaders(classLoader) {
while (classLoader != null) {
println(classLoader)
classLoader = classLoader.parent
}
}
class A {
}
printClassLoaders(A.classLoader)
通过脚本可以获得:
1. VisitableURLClassLoader(groovy-script-/home/tony/workspace/temp/classloaders-simple/app/build.gradle-loader)
2. VisitableURLClassLoader(ClassLoaderScopeIdentifier.Id{coreAndPlugins:settings:/home/tony/workspace/temp/classloaders-simple/buildSrc:root-project(export)})
3. VisitableURLClassLoader(ClassLoaderScopeIdentifier.Id{coreAndPlugins:settings:/home/tony/workspace/temp/classloaders-simple/buildSrc(export)})
4. VisitableURLClassLoader(ClassLoaderScopeIdentifier.Id{coreAndPlugins:settings(export)})
5. CachingClassLoader(FilteringClassLoader(VisitableURLClassLoader(legacy-mixin-loader)))
6. FilteringClassLoader(VisitableURLClassLoader(legacy-mixin-loader))
7. VisitableURLClassLoader(legacy-mixin-loader)
8. VisitableURLClassLoader(ant-and-gradle-loader)
9. VisitableURLClassLoader(ant-loader)
10. jdk.internal.loader.ClassLoaders$PlatformClassLoader@cf73672
从我们的角度来看,这个层次结构的大部分实际上是不可变的(因此我们将忽略它),但前四个类加载器可能会受到我们的构建脚本的影响。第一个类加载器被 app/build.gradle 加载的任何类使用。它的直接父级是根 build.gradle。您可以(正确地)由此推断根 build.gradle 类加载器是所有子项目类加载器的父级。上面是 buildSrc 本身,它是 Gradleland 原始邪恶的历史根源。最后,在此之上,也是我们在典型 Gradle 构建中影响类路径的最后机会,是 settings.gradle。
使用 buildSrc 进行猴子修补
“buildSrc” 指的是项目根目录中该名称的目录,其中包含项目使用的构建逻辑和自定义插件。如上所示,放在 buildSrc 中的任何内容都会自动出现在构建中所有项目的类路径中。
如上所述,buildSrc 定义了类加载器,它是根脚本类加载器的父级,而根脚本类加载器又是所有子项目类加载器的父级。如果我们还记得类加载器总是首先委托给它们的父类,并且类路径是顺序敏感的,我们就可以理解我们即将探索的猴子修补技术。
想象一下,我们想要修补 AGP 中的一个问题。派生和发布 AGP 的自定义版本很复杂,因此在等待上游应用修复程序的同时在本地修补它更简单。考虑以下:
// buildSrc/build.gradle
dependencies {
implementation 'com.android.tools.build:gradle:4.1.0'
}
// app/build.gradle
plugins {
id 'com.android.application'
}
我们不需要直接在构建脚本的类路径上指定 AGP,甚至不需要在 plugins 块中提供版本(事实上,这样做会导致 Gradle 错误)。
现在让我们应用我们的补丁。我们将在我们的项目中添加一个新文件,其主要部分如下所示。
//buildSrc/src/main/java/com/android/build/gradle/tasks/factory/AndroidUnitTest.java
package com.android.build.gradle.tasks.factory;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.build.api.artifact.impl.ArtifactsImpl;
import com.android.build.api.component.TestComponentProperties;
import com.android.build.api.component.impl.ComponentPropertiesImpl;
import com.android.build.api.component.impl.UnitTestPropertiesImpl;
import com.android.build.api.variant.impl.VariantPropertiesImpl;
import com.android.build.gradle.BaseExtension;
import com.android.build.gradle.internal.SdkComponentsBuildService;
import com.android.build.gradle.internal.publishing.AndroidArtifacts;
import com.android.build.gradle.internal.scope.BootClasspathBuilder;
import com.android.build.gradle.internal.scope.GlobalScope;
import com.android.build.gradle.internal.scope.InternalArtifactType;
import com.android.build.gradle.internal.tasks.VariantAwareTask;
import com.android.build.gradle.internal.tasks.factory.VariantTaskCreationAction;
import com.android.build.gradle.options.BooleanOption;
import com.android.build.gradle.tasks.GenerateTestConfig;
import com.android.builder.core.VariantType;
import com.google.common.collect.ImmutableList;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.plugins.JavaBasePlugin;
import org.gradle.api.reporting.ConfigurableReport;
import org.gradle.api.tasks.Internal;
import org.gradle.api.tasks.Nested;
import org.gradle.api.tasks.Optional;
import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.testing.Test;
import org.gradle.api.tasks.testing.TestTaskReports;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.util.concurrent.Callable;
import static com.android.build.gradle.internal.publishing.AndroidArtifacts.ArtifactScope.ALL;
import static com.android.build.gradle.internal.publishing.AndroidArtifacts.ArtifactType.CLASSES_JAR;
import static com.android.build.gradle.internal.publishing.AndroidArtifacts.ConsumedConfigType.RUNTIME_CLASSPATH;
public abstract class AndroidUnitTest extends Test implements VariantAwareTask {
private String variantName;
@Nullable private GenerateTestConfig.TestConfigInputs testConfigInputs;
@Internal
@NotNull @Override public String getVariantName() {
return variantName;
}
@Override public void setVariantName(@NotNull String s) {
variantName = s;
}
@Nested
@Optional
public GenerateTestConfig.TestConfigInputs getTestConfigInputs() {
return testConfigInputs;
}
@Override
@TaskAction
public void executeTests() {
getLogger().quiet("Hello world!");
}
@SuppressWarnings("UnstableApiUsage") public static class CreationAction extends VariantTaskCreationAction<AndroidUnitTest, ComponentPropertiesImpl> {
@NonNull private final UnitTestPropertiesImpl unitTestProperties;
public CreationAction(@NonNull UnitTestPropertiesImpl unitTestProperties) {
super(unitTestProperties);
this.unitTestProperties = unitTestProperties;
}
@NotNull @Override public String getName() {
return computeTaskName(VariantType.UNIT_TEST_PREFIX);
}
@NotNull @Override public Class<AndroidUnitTest> getType() {
return AndroidUnitTest.class;
}
@Override
public void configure(@NonNull AndroidUnitTest task) {
super.configure(task);
GlobalScope globalScope = creationConfig.getGlobalScope();
BaseExtension extension = globalScope.getExtension();
VariantPropertiesImpl testedVariant =
(VariantPropertiesImpl)
((TestComponentProperties) creationConfig).getTestedVariant();
boolean includeAndroidResources =
extension.getTestOptions().getUnitTests().isIncludeAndroidResources();
boolean useRelativePathInTestConfig =
creationConfig
.getServices()
.getProjectOptions()
.get(BooleanOption.USE_RELATIVE_PATH_IN_TEST_CONFIG);
// we run by default in headless mode, so the forked JVM doesn't steal focus.
task.systemProperty("java.awt.headless", "true");
task.setGroup(JavaBasePlugin.VERIFICATION_GROUP);
task.setDescription("Run unit tests for the " + testedVariant.getName() + " build.");
task.setTestClassesDirs(creationConfig.getArtifacts().getAllClasses());
task.setClasspath(computeClasspath(creationConfig, includeAndroidResources));
if (includeAndroidResources) {
// When computing the classpath above, we made sure this task depends on the output
// of the GenerateTestConfig task. However, it is not enough. The GenerateTestConfig
// task has 2 types of inputs: direct inputs and indirect inputs. Only the direct
// inputs are registered with Gradle, whereas the indirect inputs are not (see that
// class for details).
// Since this task also depends on the indirect inputs to the GenerateTestConfig
// task, we also need to register those inputs with Gradle.
task.testConfigInputs = new GenerateTestConfig.TestConfigInputs(unitTestProperties);
}
// Put the variant name in the report path, so that different testing tasks don't
// overwrite each other's reports. For component model plugin, the report tasks are not
// yet configured. We get a hardcoded value matching Gradle's default. This will
// eventually be replaced with the new Java plugin.
TestTaskReports testTaskReports = task.getReports();
ConfigurableReport xmlReport = testTaskReports.getJunitXml();
xmlReport.setDestination(new File(globalScope.getTestResultsFolder(), task.getName()));
ConfigurableReport htmlReport = testTaskReports.getHtml();
htmlReport.setDestination(new File(globalScope.getTestReportFolder(), task.getName()));
extension.getTestOptions().getUnitTests().applyConfiguration(task);
// The task is not yet cacheable when includeAndroidResources=true and
// android.testConfig.useRelativePath=false (bug 115873047). We set it explicitly here
// so Gradle doesn't have to store cache entries that won't be reused.
task.getOutputs()
.doNotCacheIf(
"AndroidUnitTest task is not yet cacheable"
+ " when includeAndroidResources=true"
+ " and android.testConfig.useRelativePath=false",
(thisTask) -> includeAndroidResources && !useRelativePathInTestConfig);
}
@NonNull
private ConfigurableFileCollection computeClasspath(
ComponentPropertiesImpl component, boolean includeAndroidResources) {
GlobalScope globalScope = component.getGlobalScope();
ArtifactsImpl artifacts = component.getArtifacts();
ConfigurableFileCollection collection = component.getServices().fileCollection();
// the test classpath is made up of:
// 1. the config file
if (includeAndroidResources) {
collection.from(
artifacts.get(InternalArtifactType.UNIT_TEST_CONFIG_DIRECTORY.INSTANCE));
}
// 2. the test component classes and java_res
collection.from(component.getArtifacts().getAllClasses());
// TODO is this the right thing? this doesn't include the res merging via transform
// AFAIK
collection.from(artifacts.get(InternalArtifactType.JAVA_RES.INSTANCE));
// 3. the runtime dependencies for both CLASSES and JAVA_RES type
collection.from(
component
.getVariantDependencies()
.getArtifactFileCollection(RUNTIME_CLASSPATH, ALL, CLASSES_JAR));
collection.from(
component
.getVariantDependencies()
.getArtifactFileCollection(
RUNTIME_CLASSPATH, ALL, AndroidArtifacts.ArtifactType.JAVA_RES));
// 4. The separately compile R class, if applicable.
if (!globalScope.getExtension().getAaptOptions().getNamespaced()) {
collection.from(component.getVariantScope().getRJarForUnitTests());
}
// 5. Any additional or requested optional libraries
collection.from(getAdditionalAndRequestedOptionalLibraries(component.getGlobalScope()));
// 6. Mockable JAR is last, to make sure you can shadow the classes with
// dependencies.
collection.from(component.getGlobalScope().getMockableJarArtifact());
return collection;
}
@NonNull
private ConfigurableFileCollection getAdditionalAndRequestedOptionalLibraries(
GlobalScope globalScope) {
return creationConfig
.getServices()
.fileCollection(
(Callable)
() ->
BootClasspathBuilder.INSTANCE
.computeAdditionalAndRequestedOptionalLibraries(
globalScope.getProject(),
globalScope
.getSdkComponents()
.flatMap(
SdkComponentsBuildService
::getAdditionalLibrariesProvider)
.get(),
globalScope
.getSdkComponents()
.flatMap(
SdkComponentsBuildService
::getOptionalLibrariesProvider)
.get(),
false,
ImmutableList.copyOf(
globalScope
.getExtension()
.getLibraryRequests()),
creationConfig
.getServices()
.getIssueReporter()));
}
}
}
现在在项目中执行单元测试:
./gradlew app:testDebugUnitTest
在所有其他输出中,您应该看到:
> Task :app:testDebugUnitTest
Tests passed! (I think?)
为什么这行得通?撇开滥用 Java 的包可见性不谈,这样做的原因是因为类路径是依赖于顺序的。我们的 AndroidUnitTest 版本在与 AGP 打包的版本之前的构建类路径上(但它仍然可以编译,因为 AGP 确实在类路径上),这意味着 JVM 运行时在找到我们的类定义时停止查找构建执行。因此,我们的类被添加到这个根类加载器中,供我们所有的子项目使用; AGP 提供的类文件被有效地忽略了。
如果有问题的 jar (AGP) 位于父类加载器的类路径上,则此技术将不起作用,这要归功于 Java 的委托模型。它在这里工作是因为我们的自定义代码和 AGP 是由同一个类加载器加载的。
Gradle 运行时
到目前为止,我们一直专注于构建:我们已经学会了如何检查、影响甚至操纵构建类路径来实现我们的软件目标。
编译和运行我们的应用程序本身所涉及的类路径。这些与影响我们构建环境的类路径不同,对于理解我们的应用程序至关重要。了解它们的工作原理将使您成为更有效的 JVM/ART(Android 运行时)程序员;
基础
让我们从一个简单的 Kotlin 程序开始,当我们谈论编译时与运行时类路径时,我们将用它来探究一下我们的意思。
在这些示例中,我们将使用 kotlinc(用于编译 Kotlin 源代码)和 kotlin(用于运行 Kotlin 程序)。
// Main.kt
import lib.Library
fun main() {
Library().truth()
}
// lib/Library.kt
package lib
class Library {
fun truth() = println("All billionaires are bad.")
}
为了编译这个程序,我们需要确保所有涉及的类都在编译类路径中:Main 和我们的库 Library。如下所示:
$ cd lib && kotlinc Library.kt && cd ..
这会生成一个类文件 lib/Library.class(路径很重要)。下一步我们需要这个:
$ kotlinc -classpath lib Main.kt -d main.jar
这会编译 Main.kt。程序 kotlinc 将在 lib/Library.class 中查找类文件(根据 import 语句,在 Main.kt 中导入 lib.Library!)。包含 -classpath lib 可确保库类文件可用于编译 Main.kt。
最后,我们有 -d main.jar,它告诉 kotlinc 将其输出捆绑到具有该名称的 jar 中。检查此命令产生的 jar 是很有启发性的:
$ jar tf main.jar
META-INF/MANIFEST.MF
META-INF/main.kotlin_module
MainKt.class
我们看到了通常的 jar (manifest, kotlin_module);更重要的是,我们看到了我们编译的类 MainKt.class。请注意,我们看不到的一件事是 Library.class。我们针对它进行了编译,但它没有捆绑到我们的 jar 文件中。当我们运行我们的程序时,我们将不得不在运行时类路径上以另一种方式提供该类文件。让我们试着弄清楚如何做到这一点。我们会天真地开始:
$ kotlin MainKt
error: could not find or load main class MainKt
再试一次
$ kotlin -classpath main.jar MainKt
Exception in thread "main" java.lang.NoClassDefFoundError: lib/Library
...
最后一次
$ kotlin -classpath lib:main.jar MainKt
All billionaires are bad.
第一个命令不起作用,因为 MainKt 不在我们的 kotlin 调用的类路径中(kotlin 找不到我们告诉它运行的类 MainKt)。我们通过将 main.jar 告诉 kotlin 来解决这个问题,但该命令仍然失败,这次是针对 lib/Library 的 NoClassDefFoundError。然后,我们通过更新我们的类路径以包含包含库类文件的目录来修复这个问题(类文件和 jar 在 :-delimited 列表中指定)。最后一条命令终于成功了。
我们现在更好地掌握了编译时和运行时类路径之间的区别。特别是,我们知道指定编译时类路径(就像我们对 kotlinc 所做的那样)不足以同时运行我们的程序(使用 kotlin)。为此,我们需要提供一个单独的(但可能是相同的)运行时类路径。
运行时与编译时不同代表什么?
// Main.kt same as before
// lib/Library.kt same as before
// lib-alt/Library.kt
package lib
class Library {
fun truth() = println("Some billionaires are very fine people.")
}
我们已经编译了 Main.kt 和 lib/Library.kt。让我们编译 lib-alt/Library.kt。
$ cd lib-alt && kotlinc Library.kt && cd ..
现在再次运行我们的程序,但使用不同的运行时类路径!
$ kotlin -classpath lib-alt:main.jar MainKt
Some billionaires are very fine people.
我们没有重新编译 Main.kt。我们只是在另一个上下文中运行它——一个不同的类路径,它提供了 truth() 函数的不同实现(lib-alt/Library 和 lib/Library 是二进制兼容的)。
我想真正了解运行时的强大功能,所以让我们再举一个例子。我们将从更新我们的替代库开始:
// lib-alt/Library.kt
package lib
class Library {
fun truth() = println("Some billionaires are very fine people.")
fun lie() = println("Actually, billionaires earned their wealth.")
}
// Main.kt
import lib.Library
fun main(args: Array<String>) {
val lib = Library()
val arg = args.firstOrNull() ?: "truth"
lib.javaClass
.getDeclaredMethod(arg)
.invoke(lib)
}
请注意,我们必须重新编译 Main.kt,因为我们这次更改了它,这与我们之前的示例不同。我们还使用了一个新标志 -include-runtime,它将 Kotlin stdlib(“运行时”)捆绑到我们的 jar 中,因此我们可以使用 stdlib 函数 firstOrNull() ,而无需稍后手动将其放在类路径中。
$ kotlinc -classpath lib Main.kt -include-runtime -d main.jar
$ cd lib-alt && kotlinc Library.kt && cd ..
我们首先针对 lib/Library 编译 Main.kt,然后针对 lib-alt/Library 运行它。 Kotlin 编译器只需要某种方法来解析所有符号,在这种情况下,只需要导入 lib.Library。
$ kotlin -classpath lib-alt:main.jar MainKt lie
Actually, billionaires earned their wealth.
连接到 Gradle
由于我们通常不直接使用 SDK 工具,而是通过诸如 Gradle 之类的构建工具,所以让我们花点时间将上述内容翻译成 Gradle 术语。想象一个具有如下结构的项目:
.
├── app
│ ├── build.gradle
│ └── src
│ └── main
│ └── Main.kt
├── lib
│ ├── build.gradle
│ └── src
│ └── main
│ └── lib
│ └── Library.kt
├── lib-alt
│ ├── build.gradle
│ └── src
│ └── main
│ └── lib
│ └── Library.kt
└── settings.gradle
settings.gradle:
include ':app', ':lib', ':lib-alt'
app/build.gradle:
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.5.10'
}
dependencies {
// Two choices:
// 1. :lib is on the compile and runtime classpaths of :app
implementation project(':lib')
// 2. :lib is on the compile classpath, while :lib-alt is
// on the runtime classpath.
compileOnly project(':lib')
runtimeOnly project(':lib-alt')
}
Gradle 的文档有一个很好的图形描述了这里的各种“configurations”之间的关系。
- 绿色的配置是用户应该用来声明依赖关系的配置
- 粉红色的配置是组件编译或针对库运行时使用的配置
- 蓝色的配置是组件内部的,供自己使用
用例
编写最大兼容的 Gradle 插件
编写一个与一系列 Android Gradle 插件 (AGP) 版本(例如 4.2 到 7.0)兼容的 Gradle 插件。你至少应该做两件事:
- 根据打算支持的最低版本编译插件。在这种情况下,4.2.0。
- 将此依赖项声明为 compileOnly 依赖项,而不是 implementation 或 api。
dependencies {
// 4.2.0 is the minimum version we support
compileOnly 'com.android.tools.build:gradle-api:4.2.0'
}
这样做可以确保依赖项在编译时可用,并且它不会在运行时暴露给插件的用户。 并且由于在运行时不会自动提供依赖项,因此需要用户确保它位于他们的构建运行时类路径中。
神秘的运行时异常:Stub!
Caused by: java.lang.RuntimeException: Stub!
at android.os.Looper.getMainLooper(Looper.java:7)
at flow.Preconditions.getMainThread(Preconditions.java:55)
at flow.Preconditions.<clinit>(Preconditions.java:22)
我们最近在 'com.google.android:android:4.1.1.4' 上添加了一个 compileOnly (!) 依赖项——这些是“android.jar”的 maven 坐标,即 Android 运行时。我们想针对它们进行编译,因为一些代码触及 Parcelable 类,但实际上并没有使用它。因此,我们知道我们在运行时是安全的,因为我们永远不会尝试调用任何与 Parcelable 相关的东西——它只需要在编译时可用。
这在命令行中按预期工作,但是当从 Android Studio 调用测试时,它们总是会失败(至少它是确定性的)。原来原因是,我引用(强调我的)“在 IDE 中运行测试存在类路径问题,因为整个设置一团糟”,以及“单元测试在 [Arctic Fox] 之前有很多问题,这令人惊讶他们工作了。”基本上,Arctic Fox 之前的 Android Studio 使用了错误的运行时类路径,导致运行时失败。
首先,我们删除了 compileOnly 'com.google.android:android:4.1.1.4'。我们添加了一个新模块,该模块具有 Parcelable 和 Parcel 的手写 stubs,并且我们将该模块放在编译类路径中。现在我们所有的测试都通过了。
了解 NoClassDefFoundErrors
也许您在 Android 或 JVM 开发人员的职业生涯中看到过类似以下内容:
Instrumentation test failed due to uncaught exception in thread [main]:\
`java.lang.NoClassDefFoundError`: Failed resolution of: `Lcom/example/ThisClassShouldDefinitelyBeHere;`
发生的情况是运行时失败,因为 ART/JVM 试图解析一个类并且在任何地方都找不到它:它在运行时类路径上不可用。
“但是我的代码编译得很好!”你说。 “该类依赖于‘foo’,它是在‘implementation’配置中声明的。为什么它在运行时不可用?”
已经确定该类应该存在:您在正确的配置、实现中声明了它,并且您知道此类依赖项在编译时和运行时都 - 或应该 - 可用。因此,实际上,您发现的是您的一个构建工具中的一个错误。我最近在 AGP 4.2.0 中遇到了这样一个问题,它发生在 androidTestImplementation 上声明的项目依赖项中,并且包含混合的 Java/Kotlin 源代码。该错误已经修复,并将作为 4.2.2 发布,但我们在等待正式发布时已在本地对其进行了修补。