开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

Gradle 系列(8)其实 Gradle Transfor

发表于 2022-05-18

请点赞关注,你的支持对我意义重大。

🔥 Hi,我是小彭。本文已收录到 GitHub · AndroidFamily 中。这里有 Android 进阶成长知识体系,有志同道合的朋友,关注公众号 [彭旭锐] 带你建立核心竞争力。

前言

目前,使用 AGP Transform API 进行字节码插桩已经非常普遍了,例如 Booster、神策等框架中都有 Transform 的影子。Transform 听起来很高大上,其本质就是一个 Gradle Task。在这篇文章里,我将带你理解 Transform 的工作机制、使用方法和核心源码解析,并通过一个 Github · DemoHall · HelloTransform Demo 帮助你融会贯通。


这篇文章是 Gradle 系列文章第 8 篇,相关 Android 工程化专栏完整文章列表:

一、Gradle 基础:

  • 1、Gradle 基础 :Wrapper、Groovy、生命周期、Project、Task、增量
  • 2、Gradle 插件:Plugin、Extension 扩展、NamedDomainObjectContainer、调试
  • 3、Gradle 依赖管理
  • 4、Maven 发布:SHAPSHOT 快照、uploadArchives、Nexus、AAR
  • 5、Gradle 插件案例:EasyPrivacy、so 文件适配 64 位架构、ABI

二、AGP 插件:

  • 1、AGP 构建过程
  • 2、AGP 常用配置项:Manifest、BuildConfig、buildTypes、壳工程、环境切换
  • 3、APG Transform:AOP、TransformTask、增量、字节码、Dex
  • 4、AGP 代码混淆:ProGuard、R8、Optimize、Keep、组件化
  • 5、APK 签名:认证、完整性、v1、v2、v3、Zip、Wallet
  • 6、AGP 案例:多渠道打包

三、组件化开发:

  • 1、方案积累:有赞、蘑菇街、得到、携程、支付宝、手淘、爱奇艺、微信、美团
  • 2、组件化架构基础
  • 3、ARouter 源码分析
  • 4、组件化案例:通用方案
  • 5、组件化案例:组件化事件总线框架
  • 6、组件化案例:组件化 Key-Value 框架

四、AOP 面向切面编程:

  • 1、AOP 基础
  • 2、Java 注解
  • 3、Java 注解处理器:APT、javac
  • 4、Java 动态代理:代理模式、Proxy、字节码
  • 5、Java ServiceLoader:服务发现、SPI、META-INF
  • 6、AspectJ 框架:Transform
  • 7、Javassist 框架
  • 8、ASM 框架
  • 9、AspectJ 案例:限制按钮点击抖动

五、相关计算机基础

  • 1、Base64 编码
  • 2、安全传输:加密、摘要、签名、CA 证书、防窃听、完整性、认证

  1. 认识 Transform

1.1 什么是 Transform?

Transform API 是 Android Gradle Plugin 1.5 就引入的特性,主要用于在 Android 构建过程中,在 Class→Dex 这个节点修改 Class 字节码。利用 Transform API,我们可以拿到所有参与构建的 Class 文件,借助 Javassist 或 ASM 等字节码编辑工具进行修改,插入自定义逻辑。一般来说,这些自定义逻辑是与业务逻辑无关的。

使用 Transform 的常见的应用场景有:

  • 埋点统计: 在页面展现和退出等生命周期中插入埋点统计代码,以统计页面展现数据;
  • 耗时监控: 在指定方法的前后插入耗时计算,以观察方法执行时间;
  • 方法替换: 将方法调用替换为调用另一个方法。

1.2 Transform 的基本原理

先大概了解下 Transform 的工作机制:

  • 1、工作时机: Transform 工作在 Android 构建中由 Class → Dex 的节点;
  • 2、处理对象: 处理对象包括 Javac 编译后的 Class 文件、Java 标准 resource 资源、本地依赖和远程依赖的 JAR/AAR。Android 资源文件不属于 Transform 的操作范围,因为它们不是字节码;
  • 3、Transform Task: 每个 Transform 都对应一个 Task,Transform 的输入和输出可以理解为对应 Transform Task 的输入输出。每个 TransformTask 的输出都分别存储在 app/build/intermediates/transform/[Transform Name]/[Variant] 文件夹中;
  • 4、Transform 链: TaskManager 会将每个 TransformTask 串联起来,前一个 Transform 的输出会作为下一个 Transform 的输入。

1.3 Transform API

了解 Transform 的基本工作机制后,我们先来看 Transform 的核心 API。这里仅列举出 Transform 抽象类中最核心的方法,有几个次要的方法后面再说。

com.android.build.api.transform.java

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
typescript复制代码public abstract class Transform {

// 指定 Transform 的名称,该名称还会用于组成 Task 的名称
// 格式为 transform[InputTypes]With[name]For[Configuration]
   public abstract String getName();

   // (孵化中)用于过滤 Variant,返回 false 表示该 Variant 不执行 Transform
   public boolean applyToVariant(VariantInfo variant) {
       return true;
  }

   // 指定输入内容类型
   public abstract Set<ContentType> getInputTypes();

   // 指定输出内容类型,默认取 getInputTypes() 的值
   public Set<ContentType> getOutputTypes() {
       return getInputTypes();
  }

   // 指定消费型输入内容范畴
   public abstract Set<? super Scope> getScopes();

   // 指定引用型输入内容范畴
   public Set<? super Scope> getReferencedScopes() {
       return ImmutableSet.of();
  }

   // 指定是否支持增量编译
   public abstract boolean isIncremental();

   // 核心 API
   public void transform(TransformInvocation transformInvocation)
           throws TransformException, InterruptedException, IOException {
       // 分发到过时 API,以兼容旧版本的 Transform
       //noinspection deprecation
       transform(transformInvocation.getContext(), transformInvocation.getInputs(),
               transformInvocation.getReferencedInputs(),
               transformInvocation.getOutputProvider(),
               transformInvocation.isIncremental());
  }

   // 指定是否支持缓存
   public boolean isCacheable() {
       return false;
  }
}

1.4 ContentType 内容类型

ContentType 是一个枚举类接口,表示输入或输出内容的类型,在 AGP 中定义了 DefaultContentType 和 ExtendedContentType 两个枚举类。但是,我们在自定义 Transform 时只能使用 DefaultContentType 中定义的枚举,即 CLASSES 和 RESOURCES 两种类型,其它类型仅供 AGP 内置的 Transform 使用。

自定义 Transform 需要在两个位置定义内容类型:

  • 1、Set getInputTypes(): 指定输入内容类型,允许通过 Set 集合设置输入多种类型;
  • 2、Set getOutputTypes(): 指定输出内容类型,默认取 getInputTypes() 的值,允许通过 Set 集合设置输出多种类型。

ExtendedContentType.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
scss复制代码// 加强类型,自定义 Transform 无法使用
public enum ExtendedContentType implements ContentType {

   // DEX 文件
   DEX(0x1000),

   // Native 库
   NATIVE_LIBS(0x2000),

   // Instant Run 加强类
   CLASSES_ENHANCED(0x4000),

   // Data Binding 中间产物
   DATA_BINDING(0x10000),

   // Dex Archive
   DEX_ARCHIVE(0x40000),
;
}

QualifiedContent.java

1
2
3
4
5
6
7
8
scss复制代码enum DefaultContentType implements ContentType {

// Java 字节码,包括 Jar 文件和由源码编译产生的
   CLASSES(0x01),

   // Java 资源
   RESOURCES(0x02);
}

在 TransformManager 中,预定义了一部分内容类型集合,常用的是 CONTENT_CLASS 操作 Class。

TransformManager.java

1
2
3
swift复制代码public static final Set<ContentType> CONTENT_CLASS = ImmutableSet.of(CLASSES);
public static final Set<ContentType> CONTENT_JARS = ImmutableSet.of(CLASSES, RESOURCES);
public static final Set<ContentType> CONTENT_RESOURCES = ImmutableSet.of(RESOURCES);

1.5 ScopeType 作用域

ScopeType 也是一个枚举类接口,表示输入内容的范畴。在 AGP 中定义了 InternalScope 和 Scope 两个枚举类。但是,我们在自定义 Transform 只能使用 Scope 中定义的枚举,其它类型仅供 AGP 内置的 Transform 使用。

Transform 需要在两个位置定义输入内容范围:

  • 1、Set getScopes() 消费型输入内容范畴: 此范围的内容会被消费,因此当前 Transform 必须将修改后的内容复制到 Transform 的中间目录中,否则无法将内容传递到下一个 Transform 处理;
  • 2、Set getReferencedScopes() 指定引用型输入内容范畴: 默认是空集合,此范围的内容不会被消费,因此不需要复制传递到下一个 Transform,也不允许修改。

InternalScope.java

1
2
3
4
5
6
7
8
9
10
scss复制代码// 内部使用的作用域,自定义 Transform 无法使用
public enum InternalScope implements QualifiedContent.ScopeType {

   MAIN_SPLIT(0x10000),

   LOCAL_DEPS(0x20000),

   FEATURES(0x40000),
  ;
}

QualifiedContent.java

1
2
3
4
5
6
7
8
9
10
11
12
13
scss复制代码enum Scope implements ScopeType {

   // 当前模块
   PROJECT(0x01),
// 子模块
   SUB_PROJECTS(0x04),
// 外部依赖,包括当前模块和子模块本地依赖和远程依赖的 JAR/AAR
   EXTERNAL_LIBRARIES(0x10),
// 当前变体所测试的代码(包括依赖项)
   TESTED_CODE(0x20),
// 本地依赖和远程依赖的 JAR/AAR(provided-only)
   PROVIDED_ONLY(0x40),
}

在 TransformManager 中,预定义了一部分作用域集合,常用的是 SCOPE_FULL_PROJECT 所有模块。需要注意,Library 模块注册的 Transform 只能使用 Scope.PROJECT。

TransformManager.java

1
2
swift复制代码public static final Set<ScopeType> PROJECT_ONLY = ImmutableSet.of(Scope.PROJECT);
public static final Set<ScopeType> SCOPE_FULL_PROJECT = ImmutableSet.of(Scope.PROJECT, Scope.SUB_PROJECTS, Scope.EXTERNAL_LIBRARIES);

1.6 transform 方法

transform() 是实现 Transform 的核心方法,方法的参数是 TransformInvocation,它提供了所有与输入输出相关的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
csharp复制代码public interface TransformInvocation {

   Context getContext();

   // 消费型输入内容
   Collection<TransformInput> getInputs();

   // 引用型输入内容
   Collection<TransformInput> getReferencedInputs();

// 额外输入内容
   Collection<SecondaryInput> getSecondaryInputs();

   // 输出信息
   TransformOutputProvider getOutputProvider();

   // 是否增量构建
   boolean isIncremental();
}
  • isIncremental(): 当前 Transform 任务是否增量构建;
  • getInputs(): 获取 TransformInput 对象,它是消费型输入内容,对应于 Transform#getScopes() 定义的范围;
  • getReferencedInputs(): 获取 TransformInput 对象,它是引用型输入内容,对应于 Transform#getReferenceScope() 定义的内容范围;
  • getOutPutProvider(): TransformOutputProvider 是对输出文件的抽象。

输入内容 TransformInput 由两部分组成:

  • DirectoryInput 集合: 以源码方式参与构建的输入文件,包括完整的源码目录结构及其中的源码文件;
  • JarInput 集合: 以 Jar 和 aar 依赖方式参与构建的输入文件,包含本地依赖和远程依赖。

输入内容信息 TransformOutputProvider 有两个功能:

  • deleteAll(): 当 Transform 运行在非增量构建模式时,需要删除上一次构建产生的所有中间文件,可以直接调用 deleteAll() 完成;
  • getContentLocation(): 获得指定范围+类型的输出目标路径。

TransformOutputProvider.java

1
2
3
4
5
6
7
8
9
10
11
typescript复制代码public interface TransformOutputProvider {

// 删除所有中间文件
void deleteAll()

// 获取指定范围+类型的目标路径
File getContentLocation(String name,
Set<QualifiedContent.ContentType> types,
Set<? super QualifiedContent.Scope> scopes,
Format format);
}

获取输入内容对应的输出路径:

1
2
3
4
5
6
7
8
9
10
11
scss复制代码for (input in transformInvocation.inputs) {
for (jarInput in input.jarInputs) {
// 输出路径
val outputJar = outputProvider.getContentLocation(
jarInput.name,
jarInput.contentTypes,
jarInput.scopes,
Format.JAR
)
}
}

1.7 Transform 增量模式

任何构建系统都会尽量避免重复执行相同工作,Transform 也不例外。虽然增量构建并不是必须的,但作为一个合格的 Transform 实现应该具备增量能力。

1、增量模式标记位: Transform API 有两个增量标志位,不要混淆:

  • Transform#isIncremental(): Transform 增量构建的使能开关,返回 true 才有可能触发增量构建;
  • TransformInvocation#isIncremental(): 当次 TransformTask 是否增量执行,返回 true 表示正在增量模式。

2、Task 增量模式与 Transform 增量模式的区别: Task 增量模式与 Transform 增量模式的区别在于,Task 增量执行时会跳过整个 Task 的动作列表,而 Transform 增量执行依然会执行 TransformTask,但输入内容会增加变更内容信息。

3、增量模式的输入: 增量模式下的所有输入都是带状态的,需要根据这些状态来做不同的处理,不需要每次所有流程都重新来一遍。比如新增的输入就需要处理,而未修改的输入就不需要处理。Transform 定义了四个输入文件状态:

com.android.build.api.transform.Status.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
arduino复制代码public enum Status {

// 未修改,不需要处理,也不需要复制操作
NOTCHANGED,

// 新增,正常处理并复制给下一个任务
ADDED,

// 已修改,正常处理并复制给下一个任务
CHANGED,

// 已删除,需同步移除 OutputProvider 指定的目标文件
REMOVED;
}

1.8 注册 Transform

在 BaseExtension 中维护了一个 Transform 列表,自定义 Transform 需要注册才能生效,而且还支持额外设置 TransformTask 的依赖。

BaseExtension.kt

1
2
3
4
5
6
7
8
9
10
kotlin复制代码abstract class BaseExtension {
private val _transforms: MutableList<Transform> = mutableListOf()
private val _transformDependencies: MutableList<List<Any>> = mutableListOf()
...

fun registerTransform(transform: Transform, vararg dependencies: Any) {
_transforms.add(transform)
_transformDependencies.add(listOf(dependencies))
}
}

注册 Transform:

1
2
3
4
kotlin复制代码// 获取 Android 扩展
val androidExtension = project.extensions.getByType(BaseExtension::class.java)
// 注册 Transform,支持额外增加依赖
androidExtension.registerTransform(ToastTransform(project)/* 支持增加依赖*/)

提示: 为了提高编译效率,可以判断 Variant 为 release 类型才注册 Transform,也可以通过重写 Transform#applyToVariant() 来决定是否执行 Transform。


  1. Transform 核心源码分析

这一节我们来分析 Transform 相关核心源码,这里我们引用的是 Android Gradle Plugin 7.1.0 版本的源码。

2.1 Transform 与 Task 的关系

Project 的构建逻辑由一系列 Task 的组成,每个 Task 负责完成一个基本的工作,例如 Javac 编译 Task。Transform 也是依靠 Task 执行的,在配置阶段,Gradle 会为注册的 Transform 创建对应的 Task。

提示: 说 “创建” 可能不太严谨,TransformManager 使用 register 懒创建的方式注册 Task,其实还没有创建 Task 实例。我们不要复杂化了,就说创建吧。

而 Task 的依赖关系是通过 TransformTask 的输入输出关系隐式确定的,TransformManager 通过 TransformStream 链接各个 TransformTask 的输入输出,进而控制 Transform 的依赖关系顺序。

LibraryTaskManager.java

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
scss复制代码@Override
protected void doCreateTasksForVariant(ComponentInfo<LibraryVariantBuilderImpl, LibraryVariantImpl> variantInfo) {
...
// ----- External Transforms -----
// apply all the external transforms.
List<Transform> customTransforms = extension.getTransforms();
List<List<Object>> customTransformsDependencies = extension.getTransformsDependencies();

final IssueReporter issueReporter = libraryVariant.getServices().getIssueReporter();

for (int i = 0, count = customTransforms.size(); i < count; i++) {
Transform transform = customTransforms.get(i);

// Check the transform only applies to supported scopes for libraries:
// We cannot transform scopes that are not packaged in the library
// itself.
Sets.SetView<? super Scope> difference = Sets.difference(transform.getScopes(), TransformManager.PROJECT_ONLY);
if (!difference.isEmpty()) {
String scopes = difference.toString();
issueReporter.reportError(
Type.GENERIC,
String.format(
"Transforms with scopes '%s' cannot be applied to library projects.",
scopes));
}

List<Object> deps = customTransformsDependencies.get(i);
transformManager.addTransform(
taskFactory,
libraryVariant,
transform,
null,
task -> {
// (3.2节提到的额外依赖)
// 在注册 Transform 时,可以额外增加依赖
if (!deps.isEmpty()) {
task.dependsOn(deps);
}
},
taskProvider -> {
// if the task is a no-op then we make assemble task
// depend on it.
if (transform.getScopes().isEmpty()) {
TaskFactoryUtils.dependsOn(
libraryVariant.getTaskContainer().getAssembleTask(),
taskProvider);
}
});
}

// Create jar with library classes used for publishing to runtime elements.
taskFactory.register(new BundleLibraryClassesJar.CreationAction(
libraryVariant, AndroidArtifacts.PublishedConfigType.RUNTIME_ELEMENTS));
...
}

网上很多朋友提到 “自定义 Transform 的执行时机早于系统内置 Transform”,但从 AGP 7.1.0 源码看,并不存在系统 Transform。猜测是新版本 AGP 将这部分 “系统内置 Transform” 修改为由 Task 直接实现,毕竟 从 AGP 7.0 开始 Transform 标记为过时了。

2.2 Transform 的创建过程

  • 1、注册 Transform: 注册 Transform 仅是将对象注册到 BaseExtension 中的列表中。TransformManager 会通过 Task 的输入输出隐式建立 Transform 的依赖顺序,另外还支持在注册时添加额外的依赖。

BaseExtension.kt

1
2
3
4
5
6
7
8
9
10
kotlin复制代码abstract class BaseExtension {
private val _transforms: MutableList<Transform> = mutableListOf()
private val _transformDependencies: MutableList<List<Any>> = mutableListOf()
...

fun registerTransform(transform: Transform, vararg dependencies: Any) {
_transforms.add(transform)
_transformDependencies.add(listOf(dependencies))
}
}
  • 2、创建 TransformTask 的执行链: TransformTask 属于 Android 构建构成的一部分,所有 Android Task 的创建入口都从 BasePlugin#createAndroidTasks() 开始。其中会为所有 Variant 变体创建相关的 Task,经过一系列调用后,会通过抽象方法 TaskManager#doCreateTaskForVariant() 分派到 ApplicationTaskManager 和 LibraryTaskManager 两个子类中,以区分 App 模块和 Library 模块。

调用链概要:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
scss复制代码BasePlugin#createAndroidTasks()
-> TaskManager#createTasks()->遍历所有变体
-> for {
TaskManager#createTasksForVariant(variant)
-> abstract TaskManager#doCreateTasksForVariant(variant)
// App
-> ApplicationTaskManager#doCreateTasksForVariant(variant)
-> ApplicationTaskManager#createCommonTask(variant)
-> ApplicationTaskManager#createCompileTask(variant)
-> TaskManager#createPostCompilationTasks(config)
-> for { Transform#addTransform(transform) }
// Library
-> LibraryTaskManager#doCreateTasksForVariant(variant)
-> for { Transform#addTransform(transform) }
}

2.3 TransformTask 的命名格式

Transform#getName() 会用于构造 Task Name,命名格式为 transform[InputTypes]With[name]For[Configuration],例如 transformClassed。这块源码体现在 TransformManager 中创建 Task 的位置:

TransformManager.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
scss复制代码// 创建 Transform Task
public <T extends Transform> Optional<TaskProvider<TransformTask>> addTransform(...) {
...
// TaskName = 前缀 + Configuration
String taskName = creationConfig.computeTaskName(getTaskNamePrefix(transform), "");
...
}

// TaskName 前缀
static String getTaskNamePrefix(Transform transform) {
StringBuilder sb = new StringBuilder(100);
sb.append("transform");
sb.append(transform
.getInputTypes()
.stream()
.map(inputType -> CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.UPPER_CAMEL, inputType.name()))
.sorted() // Keep the order stable.
.collect(Collectors.joining("And")));
sb.append("With");
StringHelper.appendCapitalized(sb, transform.getName());
sb.append("For");

return sb.toString();
}

2.4 TransformTask 的输入输出

TransformTask 通过 @Input 和 @OutputDirectory 等注解,将 Transform API 关联到 Task 的输入输出上:

TransformTask.java

1
2
3
4
5
6
7
8
9
10
11
12
13
scala复制代码public abstract class TransformTask extends StreamBasedTask {

...

@Input
public Set<QualifiedContent.ContentType> getInputTypes() {
return transform.getInputTypes();
}

@OutputDirectory
@Optional
public abstract DirectoryProperty getOutputDirectory();
}

2.5 执行 transform() 方法

每个 Task 内部都保持了一个 Action 列表 actions,执行 Task 就是按顺序执行这个列表,对于自定义 Task,可以通过 @TaskAction 注解添加默认 Action。

TransformTask.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
less复制代码@TaskAction
void transform(final IncrementalTaskInputs incrementalTaskInputs) {
...
transform.transform(new TransformInvocationBuilder(context)
.addInputs(consumedInputs.getValue())
.addReferencedInputs(referencedInputs.getValue())
.addSecondaryInputs(changedSecondaryInputs.getValue())
.addOutputProvider(outputStream != null
? outputStream.asOutput()
: null)
.setIncrementalMode(isIncremental.getValue())
.build());
...
}

2.6 Library 模块限制

Library 模块仅只支持使用 Scope.PROJECT 作用域:

LibraryTaskManager.java

1
2
3
4
5
6
7
8
scss复制代码// Check the transform only applies to supported scopes for libraries:
// We cannot transform scopes that are not packaged in the library
// itself.
Sets.SetView<? super Scope> difference = Sets.difference(transform.getScopes(), TransformManager.PROJECT_ONLY);
if (!difference.isEmpty()) {
String scopes = difference.toString();
issueReporter.reportError(Type.GENERIC, String.format("Transforms with scopes '%s' cannot be applied to library projects.",scopes));
}

  1. 自定义 Transform 模板

上一节我们探讨了 Transform 的基本工作机制,第 3 节和第 4 节我们来实现一个 Transform Demo。Transform 的核心代码在 transform() 方法中,我们要做的就是遍历输入文件,再把修改后的文件复制到目标路径中,对于 JarInputs 还有一次解压和压缩。更进一步,再考虑增量编译的情况。

因此,整个 Transform 的核心过程是有固定套路,模板流程图如下:

—— 图片引用自 rebooters.github.io/2020/01/04/…

我们把整个流程图做成一个抽象模板类,子类需要重写 provideFunction() 方法,从输入流读取 Class 文件,修改完字节码后再写入到输出流。甚至不需要考虑 Trasform 的输入文件遍历、加解压、增量等,舒服!

BaseCustomTransform.kt

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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
kotlin复制代码abstract class BaseCustomTransform(private val debug: Boolean) : Transform() {

abstract fun provideFunction(): ((InputStream, OutputStream) -> Unit)?

open fun classFilter(className: String) = className.endsWith(SdkConstants.DOT_CLASS)

override fun isIncremental() = true

override fun transform(transformInvocation: TransformInvocation) {
super.transform(transformInvocation)

log("Transform start, isIncremental = ${transformInvocation.isIncremental}.")

val inputProvider = transformInvocation.inputs
val referenceProvider = transformInvocation.referencedInputs
val outputProvider = transformInvocation.outputProvider

// 1. Transform logic implemented by subclasses.
val function = provideFunction()

// 2. Delete all transform tmp files when not in incremental build.
if (!transformInvocation.isIncremental) {
log("All File deleted.")
outputProvider.deleteAll()
}

for (input in inputProvider) {
// 3. Transform jar input.
log("Transform jarInputs start.")
for (jarInput in input.jarInputs) {
val inputJar = jarInput.file
val outputJar = outputProvider.getContentLocation(jarInput.name, jarInput.contentTypes, jarInput.scopes, Format.JAR)
if (transformInvocation.isIncremental) {
// 3.1 Transform jar input in incremental build.
when (jarInput.status ?: Status.NOTCHANGED) {
Status.NOTCHANGED -> {
// Do nothing.
}
Status.ADDED, Status.CHANGED -> {
// Do transform.
transformJar(inputJar, outputJar, function)
}
Status.REMOVED -> {
// Delete.
FileUtils.delete(outputJar)
}
}
} else {
// 3.2 Transform jar input in full build.
transformJar(inputJar, outputJar, function)
}
}
// 4. Transform dir input.
log("Transform dirInput start.")
for (dirInput in input.directoryInputs) {
val inputDir = dirInput.file
val outputDir = outputProvider.getContentLocation(dirInput.name, dirInput.contentTypes, dirInput.scopes, Format.DIRECTORY)
if (transformInvocation.isIncremental) {
// 4.1 Transform dir input in incremental build.
for ((inputFile, status) in dirInput.changedFiles) {
val outputFile = concatOutputFilePath(outputDir, inputFile)
when (status ?: Status.NOTCHANGED) {
Status.NOTCHANGED -> {
// Do nothing.
}
Status.ADDED, Status.CHANGED -> {
// Do transform.
doTransformFile(inputFile, outputFile, function)
}
Status.REMOVED -> {
// Delete
FileUtils.delete(outputFile)
}
}
}
} else {
// 4.2 Transform dir input in full build.
// Traversal fileTree (depthFirstPreOrder).
for (inputFile in FileUtils.getAllFiles(inputDir)) {
val outputFile = concatOutputFilePath(outputDir, inputFile)
if (classFilter(inputFile.name)) {
doTransformFile(inputFile, outputFile, function)
} else {
// Copy.
Files.createParentDirs(outputFile)
FileUtils.copyFile(inputFile, outputFile)
}
}
}
}
}
log("Transform end.")
}

/**
* Do transform Jar.
*/
private fun transformJar(inputJar: File, outputJar: File, function: ((InputStream, OutputStream) -> Unit)?) {
// Create parent directories to hold outputJar file.
Files.createParentDirs(outputJar)
// Unzip.
FileInputStream(inputJar).use { fis ->
ZipInputStream(fis).use { zis ->
// Zip.
FileOutputStream(outputJar).use { fos ->
ZipOutputStream(fos).use { zos ->
var entry = zis.nextEntry
while (entry != null && isValidZipEntryName(entry)) {
if (!entry.isDirectory) {
zos.putNextEntry(ZipEntry(entry.name))
if (classFilter(entry.name)) {
// Apply transform function.
applyFunction(zis, zos, function)
} else {
// Copy.
zis.copyTo(zos)
}
}
entry = zis.nextEntry
}
}
}
}
}
}

/**
* Do transform file.
*/
private fun doTransformFile(inputFile: File, outputFile: File, function: ((InputStream, OutputStream) -> Unit)?) {
// Create parent directories to hold outputFile file.
Files.createParentDirs(outputFile)
FileInputStream(inputFile).use { fis ->
FileOutputStream(outputFile).use { fos ->
// Apply transform function.
applyFunction(fis, fos, function)
}
}
}

private fun concatOutputFilePath(outputDir: File, inputFile: File) = File(outputDir, inputFile.name)

private fun applyFunction(input: InputStream, output: OutputStream, function: ((InputStream, OutputStream) -> Unit)?) {
try {
if (null != function) {
function.invoke(input, output)
} else {
// Copy
input.copyTo(output)
}
} catch (e: UncheckedIOException) {
throw e.cause!!
}
}

private fun log(logStr: String) {
if (debug) {
println("$name - $logStr")
}
}
}

  1. Hello Transform 示例

现在,我手把手带你基于 BaseCustomTransform 实现一个 Transform Demo。示例代码我已经上传到 Github · DemoHall · HelloTransform。有用请给个免费的 Star 支持下。

Demo 效果很简单:

  • 实现一个 Transform,在编译时在 Activity#onCreate() 方法末尾织入一个 Toast 语句;
  • 仅通过自定义注解 @Hello 修饰的 Activity#onCreate() 方法会生效。

4.1 步骤 1:初始化代码框架

首先,我们先搭建工程的整体框架,再来编写核心的 Transform 逻辑。我们选择自定义 Gradle 插件来承载 Transform 的逻辑,可维护性更好。关于自定义 Gradle 插件的步骤具体见上一篇文章《手把手带你自定义 Gradle 插件》,此处不展开。

提示: 提醒一下,并不是说一定要由 Gradle 插件来承载,你直接在 .gradle 文件中实现也是 OK 的。

插件实现类如下:

ToastPlugin.kt

1
2
3
4
5
6
7
8
kotlin复制代码class ToastPlugin : Plugin<Project> {
override fun apply(project: Project) {
// 获取 Android 扩展
val androidExtension = project.extensions.getByType(BaseExtension::class.java)
// 注册 Transform,支持额外增加依赖
androidExtension.registerTransform(ToastTransform(project)/* 支持增加依赖*/)
}
}

4.2 步骤 2:拷贝 Transform 模板类

将我们实现的 BaseCustomTransform 模板类复制到工程下,再实现一个子类:

ToastTransform.kt

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
kotlin复制代码internal class ToastTransform(val project: Project) : BaseCustomTransform(true) {

// Transform 名
override fun getName() = "ToastTransform"

// 是否支持增量构建
override fun isIncremental() = true

/**
* 用于过滤 Variant,返回 false 表示该 Variant 不执行 Transform
*/
@Incubating
override fun applyToVariant(variant: VariantInfo?): Boolean {
return "debug" == variant?.buildTypeName
}

// 指定输入内容类型
override fun getInputTypes() = TransformManager.CONTENT_CLASS

// 指定消费型输入内容范畴
override fun getScopes() = TransformManager.SCOPE_FULL_PROJECT

// 转换方法
override fun provideFunction() = { ios: InputStream, zos: OutputStream ->
input.copyTo(output)
}
}

其中,provideFunction() 是模板代码,参数分别表示源 Class 文件的输入流和目标 Class 文件输出流。子类要做的事,就是从输入流读取 Class 信息,修改后写入到输出流。

4.3 步骤 3:使用 Javassist 修改字节码

使用 Javassist API 从输入流加载数据,在匹配到 onCreate() 方法后检查是否声明 @Hello 注解。是则在该方法末尾织入一句 Toast:Hello Transform。本文重点不是 Javassist,此处就不展开了。

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
kotlin复制代码override fun provideFunction() = { ios: InputStream, zos: OutputStream ->
val classPool = ClassPool.getDefault()
// 加入android.jar
classPool.appendClassPath((project.extensions.getByName("android") as BaseExtension).bootClasspath[0].toString())
classPool.importPackage("android.os.Bundle")
// Input
val ctClass = classPool.makeClass(ios)
try {
ctClass.getDeclaredMethod("onCreate").also {
println("onCreate found in ${ctClass.simpleName}")
val attribute = it.methodInfo.getAttribute(AnnotationsAttribute.invisibleTag) as? AnnotationsAttribute
if (null != attribute?.getAnnotation("com.pengxr.hellotransform.Hello")) {
println("Insert toast in ${ctClass.simpleName}")
it.insertAfter(
"""android.widget.Toast.makeText(this,"Hello Transform!",android.widget.Toast.LENGTH_SHORT).show();
"""
)
}
}
} catch (e: NotFoundException) {
// ignore
}
// Output
zos.write(ctClass.toBytecode())
ctClass.detach()
}

4.4 步骤 4:应用插件

sample 模块 build.gradle

1
arduino复制代码apply plugin: 'com.pengxr.toastplugin'

4.5 步骤 5:声明 @Hello 注解

HelloActivity.kt

1
2
3
4
5
6
7
8
kotlin复制代码class HelloActivity : AppCompatActivity() {

@Hello
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_hello)
}
}

4.6 步骤 6:运行

完成以上步骤后,编译运行程序。可以在 Build Output 看到以下输出,HelloActivity 启动时会弹出 Toast HelloTransform,说明织入成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ruby复制代码...
Task :sample:mergeDebugJavaResource

> Task :sample:transformClassesWithToastTransformForDebug
...
onCreate found in HelloActivity
Insert toast in HelloActivity
ToastTransform - Transform end.

> Task :sample:dexBuilderDebug
> Task :sample:mergeExtDexDebug
> Task :sample:mergeDexDebug
> Task :sample:packageDebug
> Task :sample:createDebugApkListingFileRedirect
> Task :sample:assembleDebug

BUILD SUCCESSFUL in 3m 18s
33 actionable tasks: 33 executed

Build Analyzer results available

  1. Transform 的未来

从 AGP 7.0 开始,Transform API 已经被废弃了。是的,就是卷,而且这次直接是降维打击。以前 Transform 是 AGP 的特性,现在 Gradle 也来整 Transform,不过换了个名字,叫 —— TransformAction。

那么,我们还有必要学 AGP Transform API 吗?如果你现在涉足字节码插桩这块,你建议你还是学以下:

  • 1、社区沉淀: AGP Transform API 发展多年,目前社区中已经沉淀下非常多优秀的开源组件和博客,这些资源对你非常有帮助。而 TransformAction 的社区沉淀还非常单薄;
  • 2、技术思维: 虽然换了一套 API,但背后的思路 / 套路是相似的。理解 AGP Transform 的工作机制,对你理解 Gradle TransformAction 有事半功倍的效果。

例如,以下是 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
less复制代码abstract class CountLoc implements TransformAction<TransformParameters.None> {

@Inject
abstract InputChanges getInputChanges()

@PathSensitive(PathSensitivity.RELATIVE)
@InputArtifact
abstract Provider<FileSystemLocation> getInput()

@Override
void transform(TransformOutputs outputs) {
def outputDir = outputs.dir("${input.get().asFile.name}.loc")
println("Running transform on ${input.get().asFile.name}, incremental: ${inputChanges.incremental}")
inputChanges.getFileChanges(input).forEach { change ->
def changedFile = change.file
if (change.fileType != FileType.FILE) {
return
}
def outputLocation = new File(outputDir, "${change.normalizedPath}.loc")
switch (change.changeType) {
case ADDED:
case MODIFIED:
println("Processing file ${changedFile.name}")
outputLocation.parentFile.mkdirs()

outputLocation.text = changedFile.readLines().size()

case REMOVED:
println("Removing leftover output file ${outputLocation.name}")
outputLocation.delete()

}
}
}
}
  1. 总结

本文的示例代码已上传到 github.com/pengxurui/D…,请 Star 支持。关注我,带你了解更多,我们下次见。


2022 年 8 月 15 号更新

经评论区 @奋斗的bigHead 反馈,BaseCustomTransform 存在缺陷:在 classFilter() 设置过滤部分 class 文件(例如 className.endsWith("Activity.class") )时,编译时会出现找不到类。这是因为 BaseCustomTransform 未透明复制被过滤掉的文件。该缺陷现已修复,模板代码和 GitHub Demo 已同步修改。

参考资料

  • Gradle Transform + ASM 探索 —— REBOOTERS 著
  • 深入理解 Transform —— toothpickTina 著
  • 现在准备好告别 Transform 了吗? —— 究极逮虾户 著
  • AGP Transform API 被废弃意味着什么? —— johnsonlee 著
  • Transforming dependency artifacts on resolution —— Gradle 官方文档

我是小彭,带你构建 Android 知识体系。技术和职场问题,请关注公众号 [彭旭锐] 私信我提问。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Gradle 构建工具

发表于 2022-05-17

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

Gradle 作为官方主推的构建系统,目前已经深度应用于 Android 的多个技术体系中,例如组件化开发、产物构建、单元测试等。可见,要成为 Android 高级工程师 Gradle 是必须掌握的知识点。

本文是 Gradle 构建工具系列的第 2 篇文章,完整文章目录请移步到文章末尾~

前言

Gradle 本质上是高度模块化的构建逻辑,便于重用并与他人分享。例如,我们熟悉的 Android 构建流程就是由 Android Gradle Plugin 引入的构建逻辑。在这篇文章里,我将带你探讨 Gradle 插件的使用方法、开发步骤和技巧总结。


  1. 认识 Gradle 插件

1.1 什么是 Gradle 插件

Gradle 和 Gradle 插件是两个完全不同的概念,Gradle 提供的是一套核心的构建机制,而 Gradle 插件则是运行在这套机制上的一些具体构建逻辑,本质上和 .gradle 文件是相同。例如,我们熟悉的编译 Java 代码的能力,都是由插件提供的。

1.2 Gradle 插件的优点

虽然 Gradle 插件与 .gradle 文件本质上没有区别,.gradle 文件也能实现 Gradle 插件类似的功能。但是,Gradle 插件使用了独立模块封装构建逻辑,无论是从开发开始使用来看,Gradle 插件的整体体验都更友好。

  • 逻辑复用: 将相同的逻辑提供给多个相似项目复用,减少重复维护类似逻辑开销。当然 .gradle 文件也能做到逻辑复用,但 Gradle 插件的封装性更好;
  • 组件发布: 可以将插件发布到 Maven 仓库进行管理,其他项目可以使用插件 ID 依赖。当然 .gradle 文件也可以放到一个远程路径被其他项目引用;
  • 构建配置: Gradle 插件可以声明插件扩展来暴露可配置的属性,提供定制化能力。当然 .gradle 文件也可以做到,但实现会麻烦些。

1.3 插件的两种实现形式

Gradle 插件的核心类是 Plugin,一般使用 Project 作为泛型实参。当使用方引入插件后,其实就是调用了 Plugin#apply() 方法,我们可以把 apply() 方法理解为插件的执行入口。例如:

MyCustomGradlePlugin.groovy

1
2
3
4
5
6
kotlin复制代码public class MyCustomGradlePlugin implements Plugin<Project> {
@Override
void apply(Project project) {
println "Hello."
}
}

如果根据实现形式分类(MyCustomGradlePlugin 的代码位置),可以把 Gradle 插件分为 2 类:

  • 1、脚本插件: 脚本插件就是一个普通的脚本文件,它可以被导入都其他构建脚本中。有的朋友说脚本插件也需要使用 Plugin 接口才算脚本插件,例如:

build.gradle

1
2
3
4
5
kotlin复制代码apply plugin: MyCustomGradlePlugin

class MyCustomGradlePlugin implements Plugin<Project> {
...
}
  • 2、二进制插件 / 对象插件: 在一个单独的插件模块中定义,其他模块通过 Plugin ID 应用插件。因为这种方式发布和复用更加友好,我们一般接触到的 Gradle 插件都是指二进制插件的形式。

1.4 应用插件的步骤

我们总结下使用二进制插件的步骤:

  • 1、将插件添加到 classpath: 将插件添加到构建脚本的 classpath 中,我们的 Gradle 构建脚本才能应用插件。这里区分本地依赖和远程依赖两种情况。

本地依赖: 指直接依赖本地插件源码,一般在调试插件的阶段是使用本地依赖的方式。例如:

项目 build.gradle

1
2
3
4
5
6
7
kotlin复制代码buildscript {
...
dependencies {
// For Debug
classpath project(":easyupload")
}
}

远程依赖: 指依赖已发布到 Maven 仓库的插件,一般我们都是用这种方式依赖官方或第三方实现的 Gradle 插件。例如:

项目 build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码buildscript {
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.3'
// 也可以使用另一种等价语法:
classpath group: 'com.android.tools.build ', name: 'gradle ', version: '3.5.3'
}
...
}
  • 2、使用 apply 应用插件: 在需要使用插件的 .gradle 脚本中使用 apply 应用插件,这将创建一个新的 Plugin 实例,并执行 Plugin#apply() 方法。例如:
1
2
3
4
5
6
7
8
kotlin复制代码apply plugin: 'com.android.application'

// 或者

plugins {
// id «plugin id» [version «plugin version»] [apply «false»]
id 'com.android.application'
}

注意: 不支持在一个 build.gradle 中同时使用这两种语法。

1.5 特殊的 buildSrc 模块

插件模块的名称是任意的,除非使用了一个特殊的名称 “buildSrc”,buildSrc 模块是 Gradle 默认的插件模块。buildSrc 模块本质上和普通的插件模块是一样的,有一些小区别:

  • 1、buildSrc 模块会被自动识别为参与构建的模块,因此不需要在 settings.gradle 中使用 include 引入,就算引入了也会编译出错:
1
2
kotlin复制代码Build OutPut:
'buildSrc' cannot be used as a project name as it is a reserved name
  • 2、buildSrc 模块会自动被添加到构建脚本的 classpath 中,不需要手动添加:
1
2
3
4
5
6
7
kotlin复制代码buildscript {
...
dependencies {
// 不需要手动添加
// classpath project(":buildSrc")
}
}
  • 3、buildSrc 模块的 build.gradle 执行时机早于其他 Project:
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
kotlin复制代码Executing tasks: [test] 

settings.gradle:This is executed during the initialization phase.

> Configure project :buildSrc
build.gradle:buildSrc.

> Task :buildSrc:compileJava NO-SOURCE
> Task :buildSrc:compileGroovy NO-SOURCE
> Task :buildSrc:pluginDescriptors UP-TO-DATE
> Task :buildSrc:processResources NO-SOURCE
> Task :buildSrc:classes UP-TO-DATE
> Task :buildSrc:jar UP-TO-DATE
> Task :buildSrc:assemble UP-TO-DATE
> Task :buildSrc:pluginUnderTestMetadata UP-TO-DATE
> Task :buildSrc:compileTestJava NO-SOURCE
> Task :buildSrc:compileTestGroovy NO-SOURCE
> Task :buildSrc:processTestResources NO-SOURCE
> Task :buildSrc:testClasses UP-TO-DATE
> Task :buildSrc:test NO-SOURCE
> Task :buildSrc:validatePlugins UP-TO-DATE
> Task :buildSrc:check UP-TO-DATE
> Task :buildSrc:build UP-TO-DATE
...
> Configure project :
...
> Task :test
...
BUILD SUCCESSFUL in 19s


  1. 自定义 Gradle 插件的步骤

这一节我们来讲实现 Gradle 插件的具体步骤,基本步骤分为 5 步:

  • 1、初始化插件目录结构
  • 2、创建插件实现类
  • 3、配置插件实现类
  • 4、发布插件
  • 5、使用插件

2.1 初始化插件目录结构

首先,我们在 Android Studio 新建一个 Java or Kotlin Library 模块,这里以非 buildSrc 模块的情况为例:

然后,将模块 build.gradle 文件替换为以下内容:

模块 build.gradle

1
2
3
4
5
kotlin复制代码plugins {
id 'groovy' // Groovy Language
id 'org.jetbrains.kotlin.jvm' // Kotlin
id 'java-gradle-plugin' // Java Gradle Plugin
}
  • groovy 插件: 使用 Groovy 语言开发必备;
  • org.jetbrains.kotlin.jvm 插件: 使用 Kotlin 语言开发必备;
  • java-gradle-plugin 插件: 用于帮助开发 Gradle 插件,会自动应用 Java Library 插件,并在 dependencies 中添加 implementation gradleApi()。

最后,根据你需要的开发语言补充对应的源码文件夹,不同语言有默认的源码文件夹,你也可以在 build.gradle 文件中重新指定:

模块 build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kotlin复制代码plugins {
id 'groovy' // Groovy Language
id 'org.jetbrains.kotlin.jvm' // Kotlin
id 'java-gradle-plugin' // Java Gradle Plugin
}

sourceSets {
main {
groovy {
srcDir 'src/main/groovy'
}

java {
srcDir 'src/main/java'
}

resources {
srcDir 'src/main/resources'
}
}
}

插件目录结构:

2.2 创建插件实现类

新建一个 Plugin 实现类,并重写 apply 方法中添加构建逻辑,例如:

com.pengxr.easyupload.EasyUpload.groovy

1
2
3
4
5
6
7
8
kotlin复制代码class EasyUpload implements Plugin<Project> {

@Override
void apply(Project project) {
// 构建逻辑
println "Hello."
}
}

2.3 配置插件实现类

在模块 build.gradle 文件中增加以下配置,gradlePlugin 定义了插件 ID 和插件实现类的映射关系:

1
2
3
4
5
6
7
8
9
10
kotlin复制代码gradlePlugin {
plugins {
modularPlugin {
// Plugin id.
id = 'com.pengxr.easyupload'
// Plugin implementation.
implementationClass = 'com.pengxr.easyupload.EasyUpload'
}
}
}

这其实是 Java Gradle Plugin 提供的一个简化 API,其背后会自动帮我们创建一个 [插件ID].properties 配置文件,Gradle 就是通过这个文件类进行匹配的。如果你不使用 gradlePlugin API,直接手动创建 [插件ID].properties 文件,作用是完全一样的。

要点:

  • 1、[插件ID].properties 文件名是插件 ID,用于应用插件
  • 2、[插件ID].properties 文件内容配置了插件实现类的映射,需要使用implementation-class来指定插件实习类的全限定类名
1
kotlin复制代码implementation-class=com.pengxr.easyupload.EasyUpload

2.4 发布插件

我们使用 maven 插件 来发布仓库,在模块 build.gradle 文件中增加配置:

模块 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
kotlin复制代码plugins {
id 'groovy' // Groovy Language
id 'org.jetbrains.kotlin.jvm' // Kotlin
id 'java-gradle-plugin' // Java Gradle Plugin
}

gradlePlugin {
plugins {
modularPlugin {
// Plugin id.
id = 'com.pengxr.easyupload'
// Plugin implementation.
implementationClass = 'com.pengxr.easyupload.EasyUpload'
}
}
}

uploadArchives {
repositories {
mavenDeployer {
repository(url: uri('../localMavenRepository/snapshot'))
pom.groupId = 'com.pengxr'
pom.artifactId = 'easyupload'
pom.version = '1.0.0'
}
}
}

执行 uploadArchives 任务,会发布插件到项目根目录中的 localMavenRepository 文件夹,实际项目中通常是发布到 Nexus 私库或 Github 公共库等。不熟悉组件发布的话可以回顾:Android工程化实践:组件化发布,此处不展开。

2.5 使用插件

在项目级 build.gradle 文件中将插件添加到 classpath:

项目 build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码buildscript {
repositories {
google()
jcenter()
maven { url "$rootDir/localMavenRepository/snapshot" }
maven { url "$rootDir/localMavenRepository/release" }
}
dependencies {
// For debug
// classpath project(":easyupload")
classpath "com.pengxr:easyupload:1.0.0"
}
...
}

在模块级 build.gradle 文件中 apply 插件:

模块 build.gradle

1
2
kotlin复制代码// '项目 build.gradle' 是在 gradlePlugin 中定义的插件 ID
apply plugin: 'com.pengxr.easyupload'

完成以上步骤并同步项目,从 Build Output 可以看到我们的插件生效了:

1
2
3
kotlin复制代码Build Output:

Hello.

到这里,自定义 Gradle 插件最基本的步骤就完成了,接下来就可以在 Plugin#apply 方法中开始你的表演。


  1. 插件扩展机制

Extension 扩展是插件为外部构建脚本提供的配置项,用于支持外部自定义插件的工作方式,其实就是一个对外开放的 Java Bean 或 Groovy Bean。例如,我们熟悉的 android{} 就是 Android Gradle Plugin 提供的扩展。

当你应用一个插件时,插件定义的扩展会以 扩展名-扩展对象 键值对的形式保存在 Project 中的 ExtensionContainer 容器中。插件内外部也是通过 ExtensionContainer 访问扩展对象的。

注意事项:

  • 扩展名: 不支持在同一个 Project 上添加重复的扩展名;
  • 映射关系: 添加扩展后,不支持重新设置扩展对象;
  • DSL: 支持用 扩展名 {} DSL 的形式访问扩展对象。

3.1 基本步骤

这一节我们来讲实现 Extension 扩展的具体步骤,基本步骤分为 5 步:

  • 1、定义扩展类: 定义一个扩展配置类:

Upload.groovy

1
2
3
kotlin复制代码class Upload {
String name
}

提示: 根据 ”约定优先于配置“ 原则,尽量为配置提供默认值,或者保证配置缺省时也能正常执行。

  • 2、创建并添加扩展对象: 在 Plugin#apply() 中,将扩展对象添加到 Project 的 ExtensionContainer 容器中:

EasyUpload.groovy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码class EasyUpload implements Plugin<Project> {

// 扩展名
public static final String UPLOAD_EXTENSION_NAME = "upload"

@Override
void apply(Project project) {
// 添加扩展
applyExtension(project)
// 添加 Maven 发布能力
applyMavenFeature(project)
}

private void applyExtension(Project project) {
// 创建扩展,并添加到 ExtensionContainer
project.extensions.create(UPLOAD_EXTENSION_NAME, Upload)
}

private void applyMavenFeature(Project project) {
// 构建逻辑
}
}
  • 3、配置扩展: 使用方应用插件后,使用 扩展名 {} DSL定制插件行为:

build.gradle

1
2
3
4
5
kotlin复制代码apply plugin: 'com.pengxr.easyupload'

upload {
name = "Peng"
}
  • 4、使用扩展: 在 Plugin#apply() 中,通过 Project 的 ExtensionContainer 容器获取扩展对象,获取的代码建议封装在扩展对象内部。例如:
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
kotlin复制代码class EasyUpload implements Plugin<Project> {

// 扩展名
public static final String UPLOAD_EXTENSION_NAME = "upload"

@Override
void apply(Project project) {
// 添加扩展
applyExtension(project)
// 添加 Maven 发布能力
applyMavenFeature(project)
}

private void applyExtension(Project project) {
// 创建扩展,并添加到 ExtensionContainer 容器
project.extensions.create(UPLOAD_EXTENSION_NAME, Upload)
}

private void applyMavenFeature(Project project) {
project.afterEvaluate {
// 1. Upload extension
Upload rootConfig = Upload.getConfig(project.rootProject)
// 构建逻辑 ...
}
}
}

Upload.groovy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
kotlin复制代码class Upload {

String name

// 将获取扩展对象的代码封装为静态方法
static Upload getConfig(Project project) {
// 从 ExtensionContainer 容器获取扩展对象
Upload extension = project.getExtensions().findByType(Upload.class)
// 配置缺省的时候,赋予默认值
if (null == extension) {
extension = new Upload()
}
return extension
}

/**
* 检查扩展配置是否有效
*
* @return true:valid
*/
boolean checkParams() {
return true
}
}

提示: ExtensionContainer#create() 支持变长参数,支持调用扩展类带参数的构造函数,例如:project.extensions.create(UPLOAD_EXTENSION_NAME, Upload,"Name") 将调用构造函数 Upload(String str)。

  • 5、构建逻辑: 到这里,实现插件扩展最基本的步骤就完成了,接下来就可以在 Plugin#apply 方法中继续完成你的表演。

3.2 project.afterEvaluate 的作用

使用插件扩展一定会用到 project.afterEvaluate() 生命周期监听,这里解释一下:因为扩展配置代码的执行时机晚于 Plugin#apply() 的执行时机,所以如果不使用 project.afterEvaluate(),则在插件内部将无法正确获取配置值。

project.afterEvaluate() 会在当前 Project 配置完成后回调,这个时机扩展配置代码已经执行,在插件内部就可以正确获取配置值。

1
2
3
4
5
6
kotlin复制代码apply plugin: 'com.pengxr.easyupload'

// 执行时机晚于 apply
upload {
name = "Peng"
}

3.3 嵌套扩展

在扩展类中组合另一个配置类的情况,我们称为嵌套扩展,例如我们熟悉的 defaultConfig{} 就是一个嵌套扩展:

1
2
3
4
5
6
7
8
9
kotlin复制代码android {
compileSdkVersion 30
buildToolsVersion "30.0.0"
defaultConfig {
minSdkVersion 21
...
}
...
}

默认下嵌套扩展是不支持使用闭包配置,我们需要在外部扩展类中定义闭包函数。例如:

Upload.groovy

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
kotlin复制代码class Upload {

// 嵌套扩展
Maven maven

// 嵌套扩展
Pom pom

// 嵌套扩展闭包函数,方法名为 maven(方法名不一定需要与属性名一致)
void maven(Action<Maven> action) {
action.execute(maven)
}

// 嵌套扩展闭包函数,方法名为 maven
void maven(Closure closure) {
ConfigureUtil.configure(closure, maven)
}

// 嵌套扩展闭包函数,方法名为 pom
void pom(Action<Pom> action) {
action.execute(pom)
}

// 嵌套扩展闭包函数,方法名为 pom
void pom(Closure closure) {
ConfigureUtil.configure(closure, pom)
}
}

使用时:

build.gradle

1
2
3
4
5
6
7
kotlin复制代码apply plugin: 'com.pengxr.easyupload'

upload {
maven {
...
}
}

3.4 NamedDomainObjectContainer 命名 DSL

在 Android 工程中,你一定在 build.gradle 文件中见过以下配置:

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码android {
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
...
}
// 支持任意命名
preview {
...
}
}
}

除了内置的 release 和 debug,我们可以在 buildType 中定义任意多个且任意名称的类型,这个是如果实现的呢?—— 这背后是因为 buildTypes 是 NamedDomainObjectContainer 类型,源码体现:

com.android.build.api.dsl.CommonExtension.kt

1
kotlin复制代码val buildTypes: NamedDomainObjectContainer<BuildType>

NamedDomainObjectContainer 的作用:

NamedDomainObjectContainer 直译是命名领域对象容器,是一个支持配置不固定数量配置的容器。主要功能分为 3 点:

  • Set 容器: 支持添加多个 T 类型对象,并且不允许命名重复;
  • 命名 DSL: 支持以 DSL 的方式配置 T 类型对象,这也要求 T 类型必须带有 String name 属性,且必须带有以 String name 为参数的 public 构造函数;
  • SortSet 容器: 容器将保证元素以 name 自然顺序排序。

那么,以上配置相当于以下伪代码:

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码val buildTypes : Collections<BuildType>

BuildType release = BuildType("release")
release.minifyEnabled = false
release.proguardFiles = getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'

BuildType debug = BuildType("debug")
...

BuildType preview = BuildType("preview")
...

buildTypes.add(release)
buildTypes.add(debug)
buildType.add(preview)

NamedDomainObjectContainer 的用法:

这里介绍一下具体用法,我们仅以你熟悉的 BuildType 为例,但不等于以下为源码。

  • 1、定义类型 T: 在类型 T 中必须带有以 String name 为参数的 public 构造函数。例如:

BuildType.groovy

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码class BuildType {
// 必须有 String name 属性,且不允许构造后修改
@Nonnull
public final String name

// 业务参数
boolean minifyEnabled

BuildType(String name) {
this.name = name
}
}
  • 2、定义 NamedDomainObjectContainer 属性: 在扩展类中定义一个 NamedDomainObjectContainer 类型属性。例如:

CommonExtension.grooyv

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
kotlin复制代码class CommonExtension {

NamedDomainObjectContainer<BuildType> buildTypes

CommonExtension(Project project) {
// 通过 project.container(...) 方法创建 NamedDomainObjectContainer
NamedDomainObjectContainer<BuildType> buildTypeObjs = project.container(BuildType)
buildTypes = buildTypeObjs
}

// 嵌套扩展闭包函数,方法名为 buildTypes
void buildTypes(Action<NamedDomainObjectContainer<BuildType>> action) {
action.execute(buildTypes)
}

void buildTypes(Closure closure) {
ConfigureUtil.configure(closure, buildTypes)
}
}
  • 3、创建 Extension: 按照 4.1 节介绍的步骤创建扩展。例如:
1
kotlin复制代码project.extensions.create("android", CommonExtension)

到这里,就可以按照 buildTypes {} 的方式配置 BuildType 列表了。然而,你会发现每个配置项必须使用 = 进行赋值。这就有点膈应人了,有懂的大佬指导一下。

1
2
3
4
5
6
7
8
9
kotlin复制代码android {
buildTypes {
release {
// 怎样才能省略 = 号呢?
minifyEnabled = false
proguardFiles = getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}

  1. 插件调试

4.1 两个调试方法

在开发插件的过程一定需要调试,除了通过日志调试,我们也有断点调试的需求。这里总结两个方法:方法 1 虽然只支持调试简单执行任务,但已经能满足大部分需求,而且相对简单。而方法 2 支持命令行添加参数。

方法 1(简单): 直接提供 Android Studio 中 Gradle 面板的调试功能,即可调试插件。如下图,我们选择与插件功能相关的 Task,并右键选择 Debug 执行。

方法 2: 通过配置 IDE Configuration 以支持调试命令行任务,具体步骤:

  • 1、创建 Remote 类型 Configuration:

  • 2、执行命令: ./gradlew Task -Dorg.gradle.debug=true --no-daemon (开启 Debug & 不使用守护进程),执行后命令行会进入等待状态:

  • 3、Attach Debug: 点击调试按钮,即可开始断点调试。

4.2 调试技巧

一些调试技巧:

  • 引用插件源码: 在开发阶段可以直接本地依赖插件源码,而不需要将插件发布到 Maven 仓库,只需要在 build.gradle 文件中修改配置:

项目 build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码buildscript {
repositories {
google()
jcenter()
}
dependencies {
// For Debug
classpath project(":easyupload")
// classpath "com.pengxr:easyupload:1.0.0"
}
...
}
  • 插件代码开关: 由于 Plugin#apply 中的代码在配置阶段执行,如果其中的代码有问题就会出现 Sync 报错。又因为编译插件代码需要先 Sync,只能先将工程中所有使用插件的代码注释掉,重新编译插件模块,再将注释修改回来。真麻烦!我们还是加一个开关吧,例如:

gradle.properties

1
kotlin复制代码ENABLED=true

模块 build.gradle

1
2
3
4
5
6
kotlin复制代码if (ENABLED.toBoolean()) {
apply plugin: 'com.pengxr.easyupload'
upload {
name = "123"
}
}

  1. 插件开发技巧总结

  • 判断是否当前是 App 模块还是 Library 模块: 当我们开发 Android 项目相关插件时,经常需要根据插件的使用环境区分不同逻辑。例如插件应用在 App 模块和 Library 模块会采用不同逻辑。此时,我们可以用在 Plugin#apply() 中采用以下判断:
1
2
3
4
5
6
kotlin复制代码project.afterEvaluate {
// 1. Check if apply the ‘com.android.application’ plugin
if (!project.getPluginManager().hasPlugin("com.android.application")) {
return
}
}
  • 插件开发语言: 最初,Groovy 是 Gradle 的首要语言,但随着 Java 和 Kotlin 语言的演进,这一现状有所改变。现在的趋势是:Gradle 脚本使用 Groovy 或 Kotlin 开发,而 Gradle 插件使用 Kotlin 开发。例如,我们可以发现 AGP 现在已经用 Kotlin 开发了。虽然趋势是往 Kotlin 靠,但目前存量的 Gradle 脚本 / 插件还是以 Groovy 为主。
    • Groovy 优势:社区沉淀、动态语言
    • Kotlin 优势:IDE 支持、趋势

原文: In general, a plugin implemented using Java or Kotlin, which are statically typed, will perform better than the same plugin implemented using Groovy.


  1. 总结

到这里,Gradle 插件的部分就讲完了,需要 Demo 的同学可以看下我们之前实现过的小插件: EasyPrivacy。在本系列后续的文章中,也会有新的插件 Demo。关注我,带你了解更多,我们下次见。

参考资料

  • 《实战 Gradle》—— [美] Benjamin Muschko 著,李建 朱本威 杨柳 译
  • 《Gradle for Android》—— [美] Kevin Pelgrims 著,余小乐 译
  • Groovy 参考文档 —— Groovy 官方文档
  • Gradle 说明文档 —— Gradle 官方文档
  • Gradle DSL 参考文档 —— Gradle 官方文档
  • Developing Custom Gradle Plugins —— Gradle 官方文档
  • Using Gradle Plugins —— Gradle 官方文档
  • 深入探索 Gradle 自动化构建技术(系列) —— jsonchao 著

推荐阅读

Gradle 构建工具完整目录如下(2023/07/12 更新):

  • #1 为什么说 Gradle 是 Android 进阶绕不去的坎
  • #2 手把手带你自定义 Gradle 插件
  • #3 Maven 发布插件使用攻略(以 Nexus / Jitpack 为例)
  • #4 来开源吧!发布开源组件到 MavenCentral 仓库超详细攻略
  • #5 又冲突了!如何理解依赖冲突与版本决议?

整理中…

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

沉思录 开篇 Kotlin的「丑与美」,以及「最佳实践」

发表于 2022-05-16

往期文章:《Kotlin Jetpack 实战:开篇》

你好,我是朱涛。

博客两年没怎么更新了,过去的两年里发生了很多事情:

  • 第一,我成为了一名父亲;
  • 第二,郭霖大佬鼓励了我,让我去申请了GDE,很荣幸,我通过了Google的筛选和面试,成为了Kotlin、Android的GDE。(希望疫情赶紧过去,我还欠大佬一顿饭。)
  • 第三,我从上家公司离职,花了半年写了一个技术专栏《Kotlin编程第一课》,体验了一次自由职业者的感觉。

在撰写技术专栏的过程中,我成长了很多,既有技术方面的成长,也有技术之外的成长。在过去的半年里,我做的最多的事情就是:深度思考。我要想尽一切办法,将自己脑子里的知识掏出来,然后,以最简单、最直观的方式呈现给我的读者。比如说,为了解释清楚Kotlin协程的“挂起和恢复”,我掉了很多头发才找到下面这个灵感:

Kotlin13-3-2.gif

说实话,我写专栏,根本不是为了钱,单纯就是因为喜欢做这样的事情。这半年里,我写作的态度比从前的工作还拼命;半年里挣的钱,还不如我一个月工资。但我乐在其中啊,哈哈!

对我来说,读者对课程的赞扬,就是我快乐的源泉。其实,很多互联网的大佬写博客、写书,也是同理。

关于「沉思录」

其实,在这半年里,我还积累了许多创作灵感,这些灵感,大都非常的琐碎,并不足以成为专栏系统课程的一部分。但这些灵感如果不写出来,就太可惜了。所以,我决定将其取名为:「沉思录」,以博客的形式发出来。

「沉思录」,大约分为三个板块:

  • Kotlin沉思录,记录我在Kotlin领域的思考,包括Kotlin JVM,协程,也包括KMM;
  • Android沉思录,记录我在Android、Jetpack领域的思考,两年前我写的《Kotlin Jetpack实战》还是太浅了;
  • Compose沉思录,自然就是Jetpack Compose领域的思考,它的编程理念,实现原理都非常有趣。

今天,我们主要看看Kotlin沉思录。

Kotlin 沉思录

初学Kotlin的时候,我更多的只是看到了Kotlin的「美」,但经过5年多的沉淀,我也渐渐发现了Kotlin「丑」的一面。

世界上不存在完美的语言,Kotlin也不例外。

其实,一直以来,总有读者问我这样问题:

“Kotlin的最佳实践是什么?”

对于这样的问题,我的回答总是非常的谨慎,说实话,我不敢仅凭我个人的经验就妄下定论。然而,最近,问我这个问题的人越来越多了,有专栏的读者,也有公司的同事。

接下来,我们就来聊聊具体的思路吧,关于Kotlin的「丑」与「美」。

「丑」与「美」

人们常说,穿衣服,要尽量遮住自己「丑」的部位,尽量凸显自己「美」的部分。我的思路也是类似的,简单来说,就是:扬长避短。

Kotlin的「弱点」

考虑到我GDE的身份,不能大肆宣扬Kotlin的缺点。因此,关于Kotlin「丑」的部分,我会以「弱点」、「坑」为标题。毕竟「丑」与「美」都是非常主观的一种判断,「弱点」则是一种更客观的描述。(没错,我就是比较怂。)

以下是部分主题:

  • 02 | 枚举类的弱点在哪?
  • 03 | Data Class的弱点在哪?
  • 06 | Lambda的弱点在哪?
  • 09 | 扩展函数的弱点在哪?
  • 15 | Channel的弱点在哪?
  • 16 | Flow的弱点在哪?

Kotlin之美

聊完Kotlin的「丑」之后,我们就知道Kotlin的「弱点」还有「坑」在哪了,在平时使用的时候,我们就可以很好的避开它的弱点了。

接下来,我们就可以来看看Kotlin的「美」了,欲扬先抑嘛。

以下是部分主题:

  • 17 | 如何理解Kotlin语法之美?
  • 18 | 如何理解sealed之美?
  • 21 | 如何理解Delegation之美?
  • 23 | 如何理解协程之美?
  • 26 | 如何理解Flow之美?
  • 30 | 如何理解inline之美?

**Kotlin的「最佳实践」到底是什么?**看完上面的这些系列以后,我相信每个读者都会有一个自己的答案吧!

Kotlin其实是一个非常宽泛的话题,除了语法特性层面的「丑」与「美」,其实还有许多能聊的东西,比如:编程范式、DSL设计、数据结构与算法、设计模式,KMM、Compose Multiplatform。这些内容,我都会尝试在这个系列的博客里去做一些涉猎。

关于深度

沉思录这个系列不是面向0基础读者的,这里主要会记录我平时的一些灵感和思考,它更像是不成体系的Android、Kotlin随笔。如果你是有一定经验的开发者,应该能让你有所启发。(不敢保证哈。)

如果你对Kotlin没有一个全面的认识,那我建议你先去看看Kotlin官方文档。如果你觉得官方文档枯燥乏味,也可以去看看我公众号里的历史文章,我自认为讲的还不错。

OK,闲聊结束,我们进入正题。

一个让人「又爱又恨」的特性

我们都知道,Kotlin会为成员属性自动生成getter、setter。不论是使用var、还是使用val,Kotlin编译器都会自动帮我们处理getter、setter的问题。

1
2
3
4
5
6
7
8
kotlin复制代码// 代码段1

class GetterSetterDeepDive {
// var
var name: String = "朱涛的自习室"
// val
val desc: String = "Kotlin, Android and more."
}

如果将上面的Kotlin代码反编译成Java的话,它大致会长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码// 代码段2

public final class GetterSetterDeepDive {

private String name = "朱涛的自习室";
private final String desc = "Kotlin, Android and more.";

public final String getName() {
return this.name;
}

public final void setName(String var1) {
this.name = var1;
}

public final String getDesc() {
return this.desc;
}
}

在我初学Kotlin的时候,我曾对着这个案例感叹:Kotlin 666!一行顶十行啊!用Kotlin开发,果然能大大提升效率啊!

说实话,现在回过头来看,当时的我就跟个“脑残粉”一样,大家不要学我。

好了,言归正传,有一说一,Kotlin自动生成getter、setter,它这样的行为,对比Java确实是一大进步,因为它可以避免外部的类直接访问Kotlin类当中的字段(Field)。毕竟,我们都知道,类似下面这样的Java代码是非常不合理的。

1
2
3
4
5
6
java复制代码// 代码段3

public class FieldJavaBean {
public String name = "朱涛的自习室";
public final String desc = "Kotlin, Android and more.";
}

上面的代码,既不符合开放封闭的原则,也难以维护。对比之下,Kotlin生成的代码段2的代码,则要顺眼很多。然而,Kotlin这样的策略其实也有它丑陋的一面。让我们来看看下面的例子。

Getter、Setter的第一个弱点

Kotlin的Getter、Setter主要有两个弱点,我们先来看它的第一个弱点,请看下面的代码。

1
2
3
4
5
6
7
8
9
10
kotlin复制代码// 代码段4

class GetterSetterDeepDive {
var name: String = "朱涛的自习室"
val desc: String = "Kotlin, Android and more."

fun printName() {
println("name = $name,desc = $desc")
}
}

请问,这段代码的问题在哪?抛开当前文章的语境,如果你是在面试当中遇到这样一个问题,你会给出怎样的答案?

你可以停下来思考一下,心里有了答案以后再继续往后看。

Kotlin最大的问题就在于,大部分的开发者很难意识到Kotlin编译器背后自动生成的那些getter、setter方法,从而导致这个特性被滥用。

让我们将上面的代码反编译来看看。

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
kotlin复制代码// 代码段5

public final class GetterSetterDeepDive {

private String name = "朱涛的自习室";
private final String desc = "Kotlin, Android and more.";
// 1
public final String getName() {
return this.name;
}
// 2
public final void setName(@NotNull String var1) {
this.name = var1;
}
// 3
public final String getDesc() {
return this.desc;
}

public final void printName() {
// 并不会用到getter、setter
String var1 = "name = " + this.name + ",desc = " + this.desc;
System.out.println(var1);
}
}

可以看到,注释1、2、3对应的这三个自动生成的getter、setter方法,它们根本就没被用到!这样的问题,如果是在一个非常小的规模,其实是无伤大雅的,但对于一个大的工程来说,当这样的问题积少成多,就会极大增加方法数,还有应用的包体积。这一点对Android应用尤为重要。

那么,以上的问题该怎么解决呢?答案其实也很简单:

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码// 代码段6

class GetterSetterDeepDive {
// 变化在这里
// ↓
private var name: String = "朱涛的自习室"
private val desc: String = "Kotlin, Android and more."

fun printName() {
println("name = $name,desc = $desc")
}
}

我们将其反编译成Java看看:

1
2
3
4
5
6
7
8
9
10
11
java复制代码// 代码段7

public final class GetterSetterDeepDive {
private String name = "朱涛的自习室";
private final String desc = "Kotlin, Android and more.";

public final void printName() {
String var1 = "name = " + this.name + ",desc = " + this.desc;
System.out.println(var1);
}
}

这样的 Java 代码看起来是不是干净了很多?这就对了!

反思

Kotlin编译器自动生成Getter、Setter的操作,它对比Java确实是存在优势的,因为它更符合「开放封闭」的原则。我们Kotlin开发者随手写出的var、val,它背后都会转换成Java当中的「最佳实践」,真的很方便。这个设计最精彩的地方在于:「简洁」,简单的var背后代表了:Field + Getter + Setter,这样的信息密度是Java所不能比拟的。不得不佩服Kotlin设计者的精妙构思。

然而,几乎所有的技术都是一种trade-off,Kotlin这样「方便」、「简洁」的设计,让它丢失了许多底层的信息。许多开发者只看到了var的方便,却很容易忽略它底层自动生成的Field + Getter + Setter。Kotlin官方其实也在想办法解决类似这样的问题,但这远远不够。

当我们写出有问题的代码时,IDE其实是会有警告的。可是,它只告诉了我们:desc可以变成private,并没有告诉我们具体的后果。当我们看到这样的提示时,我们脑子里很难将“private”与“Getter、Setter”建立对等的关系。

  • 对于var属性来说,加上private修饰,就意味着方法数减2;
  • 对于val属性来说,加上private修饰,就意味着方法数减1;

其实,许多事物发展到一定程度,最后都会开始拼细节。考试是如此、商业是如此、编程也是如此。

好,考虑到篇幅限制,我们之后再聊Getter、Setter的第二个弱点吧!我们下期再见!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Gradle 构建工具

发表于 2022-04-30

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

Gradle 作为官方主推的构建系统,目前已经深度应用于 Android 的多个技术体系中,例如组件化开发、产物构建、单元测试等。可见,要成为 Android 高级工程师 Gradle 是必须掌握的知识点。

本文是 Gradle 构建工具系列的第 1 篇文章,完整文章目录请移步到文章末尾~

前言

Gradle 作为官方主推的构建系统,目前已经深度应用于 Android 的多个技术体系中,例如组件化开发、产物构建、单元测试等。可见,要成为 Android 高级工程师 Gradle 是必须掌握的知识点。在这篇文章里,我将带你由浅入深建立 Gradle 的基本概念,涉及 Gradle 生命周期、Project、Task 等知识点,这些内容也是 Gradle 在面试八股文中容易遇见的问题。


  1. 认识 Gradle 构建工具

Gradle 并不仅仅是一个语言,而是一套构建工具。在早期,软件构建只有编译和打包等简单需求,但软件开发的发展,现在的构建变得更加复杂。而构建工具就是在这一背景下衍生出来的工具链,它能够帮助开发者可重复、自动化地生成目标产物。例如 Ant、Maven 和 ivy 也是历史演化过程中诞生的构建工具。

1.1 Gradle 的优缺点

相比于早期出现的构建工具,Gradle 能够脱颖而出主要是以下优点:

  • 表达性的 DSL: Gradle 构建脚本采用基于 Groovy 的 DSL 领域特定语言,而不是采用传统的 XML 文件,相比 Maven 等构建系统更加简洁;
  • 基于 Java 虚拟机: Groovy 语言基于 Java 虚拟机,这使得 Gradle 支持用 Java / Kotlin 代码编写构建脚本,我们完全可以只学习一小部分 Groovy 语法就能上手 Gradle 脚本,降低了 Gradle 的学习强度;
  • 约定优先于配置: Gradle 具有约定优先于配置的原则,即为属性提供默认值,相比 Ant 等构建系统更容易上手。我们在开发 Gradle 插件时也需要遵循这一原则。

Gradle 也有明显的缺点,例如:

  • 较弱的向后兼容性: Gradle 是一个快速发展的工具,新版本经常会打破向后兼容性,有经验的同学就知道,一个工程在低版本 Gradle 可以编译,但换了新版本 Gradle 可能就编译不通过了。

1.2 Gradle 工程的基本结构

在 Android Studio 中创建新项目时,会自动生成以下与 Gradle 相关文件。这些大家都很熟悉了,简单梳理下各个文件的作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码.
├── a-subproject
│ └── build.gradle
├── build.gradle
├── settings.gradle
├── gradle.properties
├── local.properties
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
└── gradlew.bat
  • settings.gradle 文件: 用于确定哪些模块参与构建;
  • 项目级 build.gradle 文件: 用于定义所有子模块公共的配置参数;
  • 模块级 build.gradle 文件: 用于定义子模块的配置参数,它可以覆盖项目级 build.gradle 文件中定义的配置;
  • gradle/warpper: 负责自动下载安装项目所需的 Gradle 环境的脚本;
  • gradle.properties: 用作项目级 Gradle 配置项,会覆盖全局的配置项;
  • local.properties: 用作项目的私有属性配置,例如 SDK 安装目录,一般不把 local.properties 加入版本控制。

1.3 Gradle 中的重要概念

  • Gradle: 提供核心构建流程,但不提供具体构建逻辑;
  • Gradle 插件: Gradle 提供的是一套核心的构建机制,而 Gradle 插件正是运行在这套机制上的一些具体构建逻辑,本质上和 .gradle 文件没有区别。例如,我们熟悉的 Android 构建流程就是由 Android Gradle Plugin 引入的构建逻辑;
  • Gradle Daemon: 用于提升构建速度的后台进程;
  • Gradle Wrapper: 对 Gradle 的封装,增加了自动下载安装 Gradle 环境的能力;
  • 环境变量 GRADLE: 用于定义 Gradle 的安装目录;
  • 环境变量 GRADLE_USER_HOME: 用于定义 Gradle 运行过程的文件存储目录,例如 Gradle Wrapper 自动安装的 Gradle 环境、构建缓存等;

1.4 Gradle Daemon

Gradle Daemon 是 Gradle 3.0 引入的构建优化策略,通过规避重复创建 JVM 和内存缓存的手段提升了构建速度。 Daemon 进程才是执行构建的进程,当构建结束后,Daemon 进程并不会立即销毁,而是保存在内存中等待承接下一次构建。根据官方文档说明,Gradle Daemon 能够降低 15-75% 的构建时间。

Daemon 的优化效果主要体现在 3 方面:

  • 1、缩短 JVM 虚拟机启动时间: 不需要重复创建;
  • 2、JIT 编译: Daemon 进程会执行 JIT 编译,有助于提升后续构建的字节码执行效率;
  • 3、构建缓存: 构建过程中加载的类、资源或者 Task 的输入和输出会保存在内存中,可以被后续构建复用。

相关的 Gradle 命令:

  • gradle —status: 查看存活的 Daemon 进程信息;
  • gradle —stop: 停止所有 Daemon 进程。

提示: 并不是所有的构建都会复用同一个 Daemon 进程,如果已存活的 Daemon 进程无法满足新构建的需求,则 Gradle 会新建一个新的 Daemon 进程。影响因素:

  • Gradle 版本: 不同 Gradle 版本的构建不会关联到同一个 Daemon 进程;
  • Gradle 虚拟机参数: 不满足的虚拟机参数不会关联到同一个 Daemon 进程。

1.5 Gradle Wrapper

Gradle Wrapper 本质是对 Gradle 的一层包装,会在执行 Gradle 构建之前自动下载安装 Gradle 环境。 在开始执行 Gradle 构建时,如果当前设备中还未安装所需版本的 Gradle 环境,Gradle Wrapper 会先帮你下载安装下来,将来其他需要这个 Gradle 版本的工程也可以直接复用。

Android Studio 默认使用 Gradle Wrapper 执行构建,你可以在设置中修改这一行为:

命令行也有区分:

  • gradle :使用系统环境变量定义的 Gradle 环境进行构建;
  • gradlew :使用 Gradle Wrapper 执行构建。

为什么 Gradle 官方从早期就专门推出一个自动安装环境工具呢,我认为原因有 2 个:

  • 确保 Gradle 版本正确性: 鉴于 Gradle 有较弱向后兼容性的特点,Gradle Wrapper 能够从项目工程级别固化项目所需要的 Gradle 版本,从而确保同一个工程移植到其他电脑后能够正确地、可重复地构建;
  • 减少了手动安装 Gradle 环境的工作量: 单单从 Gradle 4 到 Gradle 7 就有大大小小十几个版本,而且每个工程所需要的 Gradle 版本不尽相同,使用 Gradle Wrapper 能够减少手动安装环境的工作量;

简单说下 Gradle Wrapper 相关的文件,主要有 4 个:

  • gradlew & gradlew.bat: 在 Linux 或 Mac 上可用的 Shell 脚本,以及在 Window 上可用的 Batch 脚本,用于以 Gradle Wrapper 的方式执行构建。也就是说,在命令行使用 gradlew 才是基于 Gradle Wrapper 执行的,而使用 gradle 命令是直接基于系统安装的 Gradle 环境执行编译;
  • gradle-wrapper.jar: 负责下载安装 Gradle 环境的脚本;
  • gradle-wrapper.properties: Gradle Wrapper 的配置文件,主要作用是决定 Gradle 版本和安装目录:
    • distributionBase + distributionPath:指定 Gradle 环境安装路径;
    • zipStoreBase + zipStorePath:指定 Gradle 安装包的存储路径;
    • distributionUrl:指定版本 Gradle 的下载地址,通过这个参数可以配置项目工程所需要的 Gradle 版本。
1
2
3
4
5
kotlin复制代码distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.0.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

提示: GRADLE_USER_HOME 的默认值是 用户目录/.gradle,可以通过系统环境变量 GRADLE_USER_HOME 修改。

1.6 gradle.properties 构建环境配置

Gradle 是运行在 Java 虚拟机的,gradle.properties 文件可以配置 Gradle 构建的运行环境,并且会覆盖 Android Studio 设置中的全局配置,完整构建环境配置见官方文档:Build Enviroment。常用的配置项举例:

1
2
3
4
5
6
7
8
kotlin复制代码# Gradle Daemon 开关,默认 ture
org.gradle.daemon=true

# 虚拟机参数
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

# 多模块工程并行编译多个模块,会消耗更多内存
org.gradle.parallel=true

除了构建环境配置,其他配置也可以用类似的键值对方式放在 gradle.properties 中,并直接在 .gradle 文件中引用。


  1. Groovy 必知必会

Groovy 是从 Java 虚拟机衍生出来的语言,由于我们都具备一定的 Java 基础,所以我们没有必要完全从零开始学习 Groovy。梳理 Groovy 与 Java 之间有差异的地方,或许是更高效的学习方式:

2.1 一些小差异

  • 分号: 语句允许不以分号 ; 结尾;
  • public: 默认的访问修饰符为 public;
  • getter / setter: Groovy 会为每个 field 创建对应的 getter / setter 方法,在访问 obj.field / obj.field=”” 时,实际上是在访问 getField() 和 setField(””);
  • 支持静态类型和动态类型: Groovy 既支持 Java 的静态类型,也支持通过 def 关键字声明动态类型(静态类型和动态类型的关键区别在于 ”类型检查是否倾向于在编译时执行“。例如 Java 是静态类型语言,意味着类型检查主要由编译器在编译时完成);
  • 字符串: Groovy 支持三种格式定义字符串 —— 单引号、双引号和三引号
    • 单引号:纯粹的字符串,与 Java 的双引号字符串类似;
    • 双引号:支持在引号内通过 $ 关键字直接引用变量值;
    • 三引号:支持换行。

2.2 函数

  • 函数定义: Groovy 支持通过返回类型或 def 关键字定义函数。def 关键字定义的函数如果没有 return 关键字返回值,则默认会返回 null。例如:
1
2
3
4
5
6
7
8
kotlin复制代码// 使用 def 关键字
def methodName() {
// Method Code
}

String methodName() {
// Method Code
}
  • 参数名: Groovy 支持不指定参数类型。例如:
1
2
3
4
5
6
7
8
kotlin复制代码// 省略参数类型
def methodName(param1, param2) {
// Method Code
}

def methodName(String param1, String param2) {
// Method Code
}
  • 默认参数: Groovy 支持指定函数参数默认值,默认参数必须放在参数列表末尾。例如:
1
2
3
kotlin复制代码def methodName(param1, param2 = 1) {
// Method Code
}
  • 返回值: 可以省略 return,默认返回最后一行语句的值。例如:
1
2
3
4
5
6
7
kotlin复制代码def methodName() {
return "返回值"
}
等价于
def methodName() {
"返回值"
}
  • invokeMethod & methodMissing:
    • invokeMethod: 分派对象上所有方法调用,包括已定义和未定义的方法,需要实现 GroovyInterceptable 接口;
    • methodMissing: 分派对象上所有为定义方法的调用。
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
kotlin复制代码// 实现 GroovyInterceptable 接口,才会把方法调用分派到 invokeMethod。
class Student implements GroovyInterceptable{
def name;

def hello() {
println "Hello ${name}"
}

@Override
Object invokeMethod(String name, Object args) {
System.out.println "invokeMethod : $name"
}
}

def student = new Student(name: "Tom")

student.hello()
student.hello1()

输出:
invokeMethod : hello
invokeMethod : hello1

-------------------------------------------------------------

class Student {
def name;

def hello() {
println "Hello ${name}"
}

@Override
Object methodMissing(String name, Object args) {
System.out.println "methodMissing : $name"
}
}

def student = new Student(name: "Tom")

student.hello()
student.hello1()

输出:
Hello Tom
methodMissing hello1

2.3 集合

Groovy 支持通过 [] 关键字定义 List 列表或 Map 集合:

  • 列表: 例如 def list = [1, 2, 3, 4]
  • 集合: 例如 def map = [’name’:’Tom’, ‘age’:18],空集合 [:]
  • 范围: 例如 def range = 1 .. 10
  • 遍历:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码// 列表
def list = [10, 11, 12]
list.each { value ->
}
list.eachWIthIndex { value, index ->
}

// 集合
def map = [’name’:’Tom’, ‘age’:18]
map.each { key, value ->
}
map.eachWithIndex { entry, index ->
}
map.eachWithIndex { key, value, index ->
}

2.4 闭包

Groovy 闭包是一个匿名代码块,可以作为值传递给变量或函数参数,也可以接收参数和提供返回值,形式上与 Java / Kotlin 的 lambda 表达式类似。例如以下是有效的闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码{ 123 }                                          

{ -> 123 }

{ println it }

{ it -> println it }

{ name -> println name }

{ String x, int y ->
println "hey ${x} the value is ${y}"
}
  • 闭包类型: Groovy 将闭包定义为 groovy.lang.Closure 的实例,使得闭包可以像其他类型的值一样复制给变量。例如:
1
2
3
4
kotlin复制代码Closure c = { 123 }

// 当然也可以用 def 关键字
def c = { 123 }
  • 闭包调用: 闭包可以像方法一样被调用,可以通过 Closure#call() 完成,也可以直接通过变量完成。例如:
1
2
3
4
5
6
7
kotlin复制代码def c = { 123 }

// 通过 Closure#call() 调用
c.call()

// 直接通过变量名调用
c()
  • 隐式参数: 闭包默认至少有一个形式参数,如果闭包没有显式定义参数列表(使用 →),Groovy 总是带有隐式添加一个参数 it。如果调用者没有使用任何实参,则 it 为空。当你需要声明一个不接收任何参数的闭包,那么必须用显式的空参数列表声明。例如:
1
2
3
4
5
6
7
8
kotlin复制代码// 带隐式参数 it
def greeting = { "Hello, $it!" }
assert greeting('Patrick') == 'Hello, Patrick!'

// 不带隐式参数 it
def magicNumber = { -> 42 }
// error 不允许传递参数
magicNumber(11)
  • 闭包参数简化: 函数的最后一个参数是闭包类型的化,在调用时可以简化,省略圆括号:
1
2
3
4
5
6
7
8
kotlin复制代码def methodName(String param1, Closure closure) {
// Method Code
}

// 调用:
methodName("Hello") {
// Closure Code
}
  • this、owner、delegate: 闭包委托是 Groovy Closure 相比 Java Lambda 最大的区别,通过修改闭包的委托可以实现灵活多样的 DSL。先认识闭包中的三个变量:
    • this: 定义闭包的外部类,this 一定指向类对象;
    • owner: 定义闭包的外部对象,owner 可能是类对象,也可能是更外一层的闭包;
    • delegate: 默认情况 delegate 等同于 owner,this 和 owner 的语义无法修改,而 delegate 可以修改。
  • 闭包委托策略: 在闭包中,如果一个属性没有显式声明接收者对象,则会通过闭包代理解析策略寻找定义的对象,例如:
1
2
3
4
5
6
7
8
9
10
kotlin复制代码class Person {
String name
}
def p = new Person(name:'Igor')
def cl = {
// 相当于 delegate.name.toUpperCase()
name.toUpperCase()
}
cl.delegate = p
assert cl() == 'IGOR'

闭包定义了多种解析策略,可以通过 Closure#resolveStrategy=Closure.DELEGATE_FIRST 修改:

  • Closure.OWNER_FIRST(默认): 优先在 owner 对象中寻找,再去 delegate 对象中寻找;
  • Closure.DELEGATE_FIRST: 优先在 delegate 对象中寻找,再去 owner 对象中寻找;
  • Closure.OWNER_ONLY: 只在 owner 对象中寻找;
  • Closure.DELEGATE_ONLY: 只在 delegate 对象中寻找;
  • Closure.TO_SELF: 只在闭包本身寻找;

  1. Gradle 构建生命周期

Gradle 将构建划分为三个阶段: 初始化 - 配置 - 执行 。理解构建生命周期(Gradle Build Lifecycle)非常重要,否则你可能连脚本中的每个代码单元的执行时机都搞不清楚。

3.1 初始化阶段

由于 Gradle 支持单模块构建或多模块构建,因此在初始化阶段(Initialization Phase),Gradle 需要知道哪些模块将参与构建。主要包含 4 步:

  • 1、执行 Init 脚本: Initialization Scripts 会在构建最开始执行,一般用于设置全局属性、声明周期监听、日志打印等。Gradle 支持多种配置 Init 脚本的方法,以下方式配置的所有 Init 脚本都会被执行:
    • gradle 命令行指定的文件:gradle —init-script <file>
    • USER_HOME/.gradle/init.gradle 文件
    • USER_HOME/.gradle/init.d/ 文件夹下的 .gradle 文件
    • GRADLE_HOME/init.d/ 文件夹下的 .gradle 文件
  • 2、实例化 Settings 接口实例: 解析根目录下的 settings.gradle 文件,并实例化一个 Settings 接口实例;
  • 3、执行 settings.gradle 脚本: 在 settings.gradle 文件中的代码会在初始化阶段执行;
  • 4、实例化 Project 接口实例: Gradle 会解析 include 声明的模块,并为每个模块 build.gradle 文件实例化 Project 接口实例。Gradle 默认会在工程根目录下寻找 include 包含的项目,如果你想包含其他工程目录下的项目,可以这样配置:
1
2
3
4
5
6
kotlin复制代码// 引用当前工程目录下的模块
include ':app'

// 引用其他工程目录下的模块
include 'video' // 易错点:不要加’冒号 :‘
project(:video).projectDir = new File("..\\libs\\video")

提示: 模块 build.gradle 文件的执行顺序和 include 顺序没有关系。

3.2 配置阶段

配置阶段(Configuration Phase)将执行 build.gradle 中的构建逻辑,以完成 Project 的配置。主要包含 3 步:

  • 1、下载插件和依赖: Project 通常需要依赖其他插件或 Project 来完成工作,如果有需要先下载;
  • 2、执行脚本代码: 在 build.gradle 文件中的代码会在配置阶段执行;
  • 3、构造 Task DAG: 根据 Task 的依赖关系构造一个有向无环图,以便在执行阶段按照依赖关系执行 Task。

提示: 执行任何 Gradle 构建命令,都会先执行初始化阶段和配置阶段。

3.3 执行阶段

在配置阶段已经构造了 Task DAG,执行阶段(Execution Phase)就是按照依赖关系执行 Task。这里有两个容易理解错误的地方:

  • 1、Task 配置代码在配置阶段执行,而 Task 动作在执行阶段执行;
  • 2、即使执行一个 Task,整个工程的初始化阶段和所有 Project 的配置阶段也都会执行,这是为了支持执行过程中访问构建模型的任何部分。

原文: This means that when a single task, from a single project is requested, all projects of a multi-project build are configured first. The reason every project needs to be configured is to support the flexibility of accessing and changing any part of the Gradle project model.

介绍完三个生命周期阶段后,你可以通过以下 Demo 体会各个代码单元所处的执行阶段:

USER_HOME/.gradle/init.gradle

1
kotlin复制代码println 'init.gradle:This is executed during the initialization phase.'

settings.gradle

1
2
kotlin复制代码rootProject.name = 'basic'
println 'settings.gradle:This is executed during the initialization phase.'

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码println 'build.gradle:This is executed during the configuration phase.'

tasks.register('test') {
doFirst {
println 'build.gradle:This is executed first during the execution phase.'
}
doLast {
println 'build.gradle:This is executed last during the execution phase.'
}
// 易错点:这里在配置阶段执行
println 'build.gradle:This is executed during the configuration phase as well.'
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码Executing tasks: [test] in project /Users/pengxurui/workspace/public/EasyUpload

init.gradle:This is executed during the initialization phase.
settings.gradle:This is executed during the initialization phase.

> Configure project :
build.gradle:This is executed during the configuration phase.
build.gradle:This is executed during the configuration phase as well.

> Task :test
build.gradle:This is executed first during the execution phase.
build.gradle:This is executed last during the execution phase.

...

提示: Task 在执行阶段执行有一个特例,即通过 Project#defaultTasks 指定默认任务,会在配置阶段会执行,见 第 6.2 节 ,了解即可。

3.4 生命周期监听

Gradle 提供了一系列监听构建生命周期流程的接口,大部分的节点都有直接的 Hook 点,这里我总结一些常用的:

  • 1、监听初始化阶段

Gradle 接口提供了监听 Settings 初始化阶段的方法:

settings.gradle

1
2
3
4
5
6
7
8
9
kotlin复制代码// Settings 配置完毕
gradle.settingsEvaluated {
...
}

// 所有 Project 对象创建(注意:此时 build.gradle 中的配置代码还未执行)
gradle.projectsLoaded {
...
}
  • 2、监听配置阶段

Project 接口提供了监听当前 Project 配置阶段执行的方法,其中 afterEvaluate 常用于在 Project 配置完成后继续增加额外的配置,例如 Hook 构建过程中的 Task。

1
2
3
4
5
6
7
8
9
kotlin复制代码// 执行 build.gradle 前
project.beforeEvaluate {
...
}

// 执行 build.gradle 后
project.afterEvaluate {
...
}

除此之外,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
less复制代码// 执行 build.gradle 前
gradle.beforeProject { project ->
...
}

// 执行 build.gradle 后
gradle.afterProject { project ->
// 配置后,无论成功或失败
if (project.state.failure) {
println "Evaluation of $project FAILED"
} else {
println "Evaluation of $project succeeded"
}
}

// 与 project.beforeEvaluate 和 project.afterEvaluate 等价
gradle.addProjectEvaluationListener(new ProjectEvaluationListener() {
@Override
void beforeEvaluate(Project project) {
...
}

@Override
void afterEvaluate(Project project, ProjectState projectState) {
...
}
})

// 依赖关系解析完毕
gradle.addListener(new DependencyResolutionListener() {
@Override
void beforeResolve(ResolvableDependencies dependencies) {
....
}

@Override
void afterResolve(ResolvableDependencies dependencies) {
....
}
})

// Task DAG 构造完毕
gradle.taskGraph.whenReady {
}

// 与 gradle.taskGraph.whenReady 等价
gradle.addListener(new TaskExecutionGraphListener() {
@Override
void graphPopulated(TaskExecutionGraph graph) {
...
}
})

// 所有 Project 的 build.gradle 执行完毕
gradle.projectsEvaluated {
...
}
  • 3、监听执行阶段

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
kotlin复制代码gradle.addListener(new TaskExecutionListener() {

// 执行 Task 前
@Override
void beforeExecute(Task task) {
...
}

// 执行 Task 后
@Override
void afterExecute(Task task, TaskState state) {
...
}
})

gradle.addListener(new TaskActionListener() {

// 开始执行 Action 列表前,回调时机略晚于 TaskExecutionListener#beforeExecute
@Override
void beforeActions(Task task) {
...
}

// 执行 Action 列表完毕,回调时机略早于 TaskExecutionListener#afterExecute
@Override
void afterActions(Task task) {
...
}
})

// 执行 Task 前
gradle.taskGraph.beforeTask { Task task ->
}

// 执行 Task 后
gradle.taskGraph.afterTask { Task task, TaskState state ->
if (state.failure) {
println "FAILED"
}
else {
println "done"
}
}
  • 4、监听 Task 创建

TaskContainer 接口提供了监听 Task 添加的方法,可以在 Task 添加到 Project 时收到回调:

1
2
kotlin复制代码tasks.whenTaskAdded { task ->
}
  • 5、监听构建结束

当所有 Task 执行完毕,意味着构建结束:

1
2
3
kotlin复制代码gradle.buildFinished {
...
}

  1. Project 核心 API

Project 可以理解为模块的构建管理器,在初始化阶段,Gradle 会为每个模块的 build.gradle 文件实例化一个接口对象。在 .gradle 脚本中编写的代码,本质上可以理解为是在一个 Project 子类中编写的。

4.1 Project API

Project 提供了一系列操作 Project 对象的 API:

  • getProject(): 返回当前 Project;
  • getParent(): 返回父 Project,如果在工程 RootProject 中调用,则会返回 null;
  • getRootProject(): 返回工程 RootProject;
  • getAllprojects(): 返回一个 Project Set 集合,包含当前 Project 与所有子 Project;
  • getSubprojects(): 返回一个 Project Set 集合,包含所有子 Project;
  • project(String): 返回指定 Project,不存在时抛出 UnKnownProjectException;
  • findProject(String): 返回指定 Project,不存在时返回 null;
  • allprojects(Closure): 为当前 Project 以及所有子 Project 增加配置;
  • subprojects(Closure): 为所有子 Project 增加配置。

4.2 Project 属性 API

Project 提供了一系列操作属性的 API,通过属性 API 可以实现在 Project 之间共享配置参数:

  • hasProperty(String): 判断是否存在指定属性名;
  • property(Stirng): 获取属性值,如果属性不存在则抛出 MissingPropertyException;
  • findProperty(String): 获取属性值,如果属性不存在则返回 null;
  • setProperty(String, Object): 设置属性值,如果属性不存在则抛出 MissingPropertyException。

实际上,你不一定需要显示调用这些 API,当我们直接使用属性名时,Gradle 会帮我们隐式调用 property() 或 setProperty()。例如:

build.gradle

1
2
kotlin复制代码name => 相当于 project.getProperty("name")
project.name = "Peng" => 相当于 project.setProperty("name", "Peng")

4.2.1 属性匹配优先级

Project 属性的概念比我们理解的字段概念要复杂些,不仅仅是一个简单的键值对。Project 定义了 4 种命名空间(scopes)的属性 —— 自有属性、Extension 属性、ext 属性、Task。 当我们通过访问属性时,会按照这个优先级顺序搜索。

getProperty() 的搜索过程:

  • 1、自有属性: Project 对象自身持有的属性,例如 rootProject 属性;
  • 2、Extension 属性;
  • 3、ext 属性;
  • 4、Task: 添加到 Project 上的 Task 也支持通过属性 API 访问;
  • 5、父 Project 的 ext 属性: 会被子 Project 继承,因此当 1 ~ 5 未命中时,会继续从父 Project 搜索。需要注意: 从父 Project 继承的属性是只读的;
  • 6、以上未命中,抛出 MissingPropertyException 或返回 null。

setProperty() 的搜索路径(由于部分属性是只读的,搜索路径较短):

  • 1、自有属性
  • 2、ext 额外属性

提示: 其实还有 Convention 命名空间,不过已经过时了,我们不考虑。

4.2.2 Extension 扩展

Extension 扩展是插件为外部构建脚本提供的配置项,用于支持外部自定义插件的工作方式,其实就是一个对外开放的 Java Bean 或 Groovy Bean。例如,我们熟悉的 android{} 就是 Android Gradle Plugin 提供的扩展。

关于插件 Extension 扩展的更多内容,见下一篇文章。

4.2.3 ext 属性

Gradle 为 Project 和 Task 提供了 ext 命名空间,用于定义额外属性。如前所述,子 Project 会继承 父 Project 定义的 ext 属性,但是只读的。我们经常会在 Root Project 中定义 ext 属性,而在子 Project 中可以直接复用属性值,例如:

项目 build.gradle

1
2
3
kotlin复制代码ext {
kotlin_version = '1.4.31'
}

模块 build.gradle

1
2
kotlin复制代码// 如果子 Project 也定义了 kotlin_version 属性,则不会引用父 Project
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"

4.3 Project 文件 API

4.3.1 文件路径

  • getRootDir(): Project 的根目录(不是工程根目录)
  • getProjectDir(): 包含 build 文件夹的项目目录
  • getBuildDir(): build 文件夹目录

4.3.2 文件获取

  • File file(Object path): 获取单个文件,相对位置从当前 Project 目录开始
  • ConfigurableFileCollection files(Object… paths): 获取多个文件,相对位置从当前 Project 目录开始
1
2
3
4
kotlin复制代码def destFile = file('releases.xml')
if (destFile != null && !destFile.exists()) {
destFile.createNewFile()
}

4.3.3 文件拷贝

  • copy(Closure): 文件拷贝,参数闭包用于配置 CodeSpec 对象
1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码copy {
// 来源文件
from file("build/outputs/apk")
// 目标文件
into getRootProject().getBuildDir().path + "/apk/"
exclude {
// 排除不需要拷贝的文件
}
rename {
// 对拷贝过来的文件进行重命名
}
}

4.3.4 文件遍历

  • fileTree(Object baseDir): 将指定目录转化为文件树,再进行遍历操作
1
2
3
4
5
kotlin复制代码fileTree("build/outputs/apk") { FileTree fileTree ->
fileTree.visit { FileTreeElement fileTreeElement ->
// 文件操作
}
}

  1. Task 核心 API

Project 的构建逻辑由一系列 Task 的组成,每个 Task 负责完成一个基本的工作,例如 Javac 编译 Task、资源编译 Task、Lint 检查 Task,签名 Task等。在构建配置阶段,Gradle 会根据 Task 的依赖关系构造一个有向无环图,以便在执行阶段按照依赖关系执行 Task。

5.1 创建简单 Task

Gradle 支持两种创建简单 Task 的语法:

  • 1、通过 task 关键字:
1
2
3
4
kotlin复制代码// 创建名为 MyTask 的任务
task MyTask(group: "MyGroup") {
// Task 配置代码
}
  • 2、通过 TaskContainer 方法: 通过 Project 的 TaskContainer 属性,可以创建 Task,分为热创建和懒创建:
    • Task create(String, Closure) 热创建: 立即实例化 Task 对象;
    • TaskProvider register(String, Closure) 懒创建: 注册 Task 构造器,但不会实例化对象。创建 Task 操作会延迟到访问该 Task 时,例如通过 TaskProvider#get() 或 TaskContainer#getByName()。
1
2
3
4
kotlin复制代码// 创建名为 MyTask 的任务
project.tasks.create(name: "MyTask") {
// Task 配置代码
}

5.2 创建增强 Task(自定义 Task 类型)

除了简单创建 Task 的方式,我们还可以自定义 Task 类型,Gradle 将这类 Task 称为增强 Task。增强 Task 的可重用性更好,并且可以通过暴露属性的方式来定制 Task 的行为。

  • 1、DefaultTask: 自定义 Task 必须继承 DefaultTask。
1
2
3
4
kotlin复制代码class CustomTask extends DefaultTask {
final String message
final int number
}
  • 2、带参数创建 Task: 除了可以在创建 Task 后配置属性值,我们也可以在调用 TaskContainer#create() 时传递构造器参数。为了将值传递给任务构造函数,必须使用 @Inject 注解修饰构造器。
1
2
3
4
5
6
7
8
9
10
kotlin复制代码class CustomTask extends DefaultTask {
final String message
final int number

@Inject
CustomTask(String message, int number) {
this.message = message
this.number = number
}
}
1
2
kotlin复制代码// 第二个参数为 Task 类型
tasks.register('myTask', CustomTask, 'hello', 42)

5.3 获取已创建 Task

可以获取 TaskContainer 中已创建的任务,对于通过 register 注册的任务会在这个时机实例化。例如:

  • Task getByName(String): 获取 Task,如果 Task 不存在则抛出 UnKnownDomainObjectException;
  • Task findByName(String): 获取 Task,如果 Task 不存在则返回 null。
1
2
kotlin复制代码// 获取已创建的 Task
project.MyTask.name => 等同于 project.tasks.getByName("MyTask").name

5.4 设置 Task 属性

设置 Task 属性的语法主要有三种:

  • 1、在创建 Task 时设置
1
kotlin复制代码task MyTask(group: "MyGroup")
  • 2、通过 setter 方法设置
1
2
3
kotlin复制代码task MyTask {
group = "MyGroup" => 等同于 setGroup("MyGroup")
}
  • 3、通过 ext 额外属性设置: Task 也支持与 Project 类似的额外属性。例如:
1
2
3
4
5
6
7
8
9
kotlin复制代码task MyTask(group:"111") {
ext.goods = 2
}

ext.goods = 1

println MyTask.good

输出:2

Task 常用的自有属性如下:

属性 描述
name Task 标识符,在定义 Task 时指定
group Task 所属的组
description Task 的描述信息
type Task类型,默认为 DefaultTask
actions 动作列表
dependsOn 依赖列表

注意事项:

  • 严格避免使用带空格的 Task name,否则在一些版本的 Android Studio 中会被截断,导致不兼容;
  • Android Studio 的 Gradle 面板会按照 group 属性对 Task 进行分组显示。其中, Tasks 组为 Root Project 中的 Task,其他分组为各个 Project 中的 Task,未指定 group 的 Task 会分配在 other 中。

5.5 执行 Task

  • 1、命令行: gradlew :[模块名]:[任务名],例如:gradlew -q :app:dependencies
  • 2、IDE 工具: 通过 IDE 提供的用户界面工具执行,例如 Gradle 面板或绿色三角形,支持普通执行和调试执行;
  • 3、默认任务: 通过 Project#defaultTasks 可以指定 Project 配置阶段的默认任务,在配置阶段会执行(这说明 Task 是有可能在配置阶段执行的,了解即可,不用钻牛角尖)。

build.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码defaultTasks 'hello','hello2'

task hello {
println "defaultTasks hello"
}

task hello2 {
println "defaultTasks hello2"
}

输出:
> Configure project :easyupload
defaultTasks hello
defaultTasks hello2
--afterEvaluate--
--taskGraph.whenReady--

5.6 Task Action 动作

每个 Task 内部都保持了一个 Action 列表 actions,执行 Task 就是按顺序执行这个列表,Action 是比 Task 更细的代码单元。Task 支持添加多个动作,Task 提供了两个方法来添加 Action:

  • doFirst(Closure): 在 Action 列表头部添加一个 Action;
  • doLast(Closure): 在 Action 列表尾部添加一个 Action。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
kotlin复制代码task MyTask

MyTask.doFirst{
println "Action doFirst 1"
}

MyTask.doFirst{
println "Action doFirst 2"
}

MyTask.doLast{
println "Action doLast 1"
}

执行 MyTask 输出:

Action doFirst 2
Action doFirst 1
Action doLast 1

对于自定义 Task,还可以通过 @TaskAction 注解添加默认 Action。例如:

1
2
3
4
5
6
kotlin复制代码abstract class CustomTask extends DefaultTask {
@TaskAction
def greet() {
println 'hello from GreetingTask'
}
}

5.7 跳过 Task 的执行

并不是所有 Task 都会被执行,Gradle 提供了多个方法来控制跳过 Task 的执行:

  • 1、onlyIf{}: 闭包会在即将执行 Task 之前执行,闭包返回值决定了是否执行 Task;
  • 2、enabled 属性: Task 的 enabled 属性默认为 true,设置为 false 表示无效任务,不需要执行。

剩下两种方式允许在执行 Task 的过程中中断执行:

  • 3、Task 异常: Task 提供了两个异常,能够当 Action 执行过程中抛出以下异常,将跳过执行并继续后续的构建过程:
    • StopActionException: 中断当前 Action,并继续当前 Task 的下一个 Action;
    • StopExecutionException: 中断当前 Task,并继续 Task 依赖树上的下一个 Action。
  • 4、timeouts 属性: 当 Task 执行时间到达 timeouts 超时时间时,执行线程会收到一个中断信号,可以借此许控制 Task 的执行时间(前提是 Task 要响应中断信号)。

5.8 Task 依赖关系

通过建立 Task 的依赖关系可以构建完成的 Task 有向无环图:

  • dependsOn 强依赖: Task 通过 dependsOn 属性建立强依赖关系,可以直接通过 dependsOn 属性设置依赖列表,也可以通过 dependsOn() 方法添加一个依赖;
  • 输入输出隐式依赖: 通过建立 Task 之间的输入和输出关系,也会隐式建立依赖关系。例如 Transform Task 之间就是通过输入输出建立的依赖关系。
1
2
3
4
5
6
7
8
9
kotlin复制代码
// 通过属性设置依赖列表
task task3(dependsOn: [task1, task2]) {
}

// 添加依赖
task3.dependsOn(task1, task2)

依赖关系:task3 依赖于 [task1, task2],在执行 task3 前一定会执行 task1 和 task2

在某些情况下,控制两个任务的执行顺序非常有用,而不会在这些任务之间引入显式依赖关系,可以理解为弱依赖。 任务排序和任务依赖关系之间的主要区别在于,排序规则不影响将执行哪些任务,只影响任务的执行顺序。

  • mustRunAfter 强制顺序: 指定强制要求的任务执行顺序;
  • shouldRunAfter 非强制顺序: 指定非强制的任务执行顺序,在两种情况下会放弃此规则:1、该规则造成环形顺序;2、并行执行并且任务的所有依赖项都已经完成。
1
2
3
4
5
kotlin复制代码task3 mustRunAfter(task1, task2)
task3 shouldRunAfter(task1, task2)

依赖关系:无,在执行 task3 前不一定会执行 task1 和 task2
顺序关系:[task1, task2] 优先于 task3

5.9 Finalizer Task

给一个 Task 添加 Finalizer 终结器任务后,无论 Task 执行成功还是执行失败,都会执行终结器,这对于需要在 Task 执行完毕后清理资源的情况非常有用。

1
2
kotlin复制代码// taskY 是 taskX 的终结器
taskX finalizedBy taskY

  1. 增量构建

6.1 什么是增量构建?

任何构建工具都会尽量避免重复执行相同工作,这一特性称为 Incremental Build 增量构建,这一特性能够节省大量构建时间。例如编译过源文件后就不应该重复编译,除非发生了影响输出的更改(例如修改或删除源文件)。

Gradle 通过对比自从上一次构建之后,Task 的 inputs 和 outputs 是否变化,来决定是否跳过执行。如果相同,则 Gralde 认为 Task 是最新的,从而会跳过执行。在 Build Outputs 中看到 Task 名称旁边出现 UP-TO-DATE 标志,即说明该 Task 是被跳过的。例如:

1
2
3
4
5
6
7
kotlin复制代码> Task :easyupload:compileJava NO-SOURCE
> Task :easyupload:compileGroovy UP-TO-DATE
> Task :easyupload:pluginDescriptors UP-TO-DATE
> Task :easyupload:processResources UP-TO-DATE
> Task :easyupload:classes UP-TO-DATE
> Task :easyupload:jar UP-TO-DATE
> Task :easyupload:uploadArchives

那么,在定义 Task 的输入输出时,要遵循一个原则:如果 Task 的一个属性会影响输出,那么应该将该属性注册为输入,否则会影响 Task 执行;相反,如果 Task 的一个属性不会影响输出,那么不应该将该属性注册为输入,否则 Task 会在不必要时执行。

6.2 Task 输入输出

大多数情况下,Task 需要接收一些 input 输入,并生成一些 output 输出。例如编译任务,输入是源文件,而输出是 Class 文件。Task 使用 TaskInputs 和 TaskOutputs 管理输入输出:

  • Task#inputs: 返回 Task 的 TaskInputs 输入管理器;
  • Task#outputs: 返回 Task 的 TaskOutputs 输出管理器。

对于 Task 的输入输出,我们用面向对象的概念去理解是没问题的。如果我们把 Task 理解为一个函数,则 Task 的输入就是函数的参数,而 Task 的输出就是函数的返回值。在此理解的基础上,再记住 2 个关键点:

  • 1、隐式依赖: 如果一个 Task 的输入是另一个 Task 的输出,Gradle 会推断出两者之间的强依赖关系;
  • 2、在配置阶段声明: 由于 Task 的输入输出会用于构建依赖关系,那么我们应该确保在配置阶段定义输入输出,而不是在执行阶段定义。

Task 支持三种形式的输入:

  • 1、简单值: 包括数值、字符串和任何实现 Serializable 的类;
  • 2、文件: 包括单个文件或文件目录;
  • 3、嵌套对象: 不满足以上两种条件,但其字段声明为输入。
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
kotlin复制代码public abstract class ProcessTemplates extends DefaultTask {

@Input
public abstract Property<TemplateEngineType> getTemplateEngine();

@InputFiles
public abstract ConfigurableFileCollection getSourceFiles();

@Nested
public abstract TemplateData getTemplateData();

@OutputDirectory
public abstract DirectoryProperty getOutputDir();

@TaskAction
public void processTemplates() {
// ...
}
}

public abstract class TemplateData {

@Input
public abstract Property<String> getName();

@Input
public abstract MapProperty<String, String> getVariables();
}

6.3 Task 输入输出校验

通过注解方式注册输入输出时,Gradle 会在配置阶段会对属性值进行检查。如果属性值不满足条件,则 Gradle 会抛出 TaskValidationException 异常。特殊情况时,如果允许输入为 null 值,可以添加 @Optional 注解表示输入可空。

  • @InputFile: 验证该属性值不为 null,并且关联一个文件(而不是文件夹),且该文件存在;
  • @InputDirectory: 验证该属性值不为 null,并且关联一个文件夹(而不是文件),且该文件夹存在;
  • @OutputDirectory: 验证该属性值不为 null,并且关联一个文件夹(而不是文件),当该文件夹不存在时会创建该文件夹。

  1. 总结

到这里,Gradle 基础的部分就讲完了,下一篇文章我们来讨论 Gradle 插件。提个问题,你知道 Gradle 插件和 .gradle 文件有区别吗?关注我,带你了解更多。

参考资料

  • 《实战 Gradle》—— [美] Benjamin Muschko 著,李建 朱本威 杨柳 译
  • 《Gradle for Android》—— Kevin Pelgrims 著,余小乐 译
  • Groovy 参考文档 —— Groovy 官方文档
  • Gradle 说明文档 —— Gradle 官方文档
  • Gradle DSL 参考文档 —— Gradle 官方文档
  • 深入探索 Gradle 自动化构建技术(系列) —— jsonchao 著

推荐阅读

Gradle 构建工具完整目录如下(2023/07/12 更新):

  • #1 为什么说 Gradle 是 Android 进阶绕不去的坎
  • #2 手把手带你自定义 Gradle 插件
  • #3 Maven 发布插件使用攻略(以 Nexus / Jitpack 为例)
  • #4 来开源吧!发布开源组件到 MavenCentral 仓库超详细攻略
  • #5 又冲突了!如何理解依赖冲突与版本决议?

整理中…

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【若川视野 x 源码共读】第32期 yocto-queu

发表于 2022-04-26

源码共读前言

为了能帮助到更多对源码感兴趣、想学会看源码、提升自己写作和前端技术能力的同学。 帮助读者夯实基础,查漏补缺,开阔眼界,拓宽视野,知其然知其所以然。

我倾力组织了每周一起学200行左右的源码共读活动。我写有《学习源码整体架构系列》20余篇。

欢迎点此扫码加我微信 ruochuan02 交流,参与 源码共读 活动,每周大家一起学习200行左右的源码,共同进步。可以持续关注我@若川。

从易到难推荐学习顺序

活动介绍和顺序具体看这里从易到难推荐学习顺序

提交笔记

提交笔记方式
简言之:看任务,看辅助文章、看源码,交流讨论,在掘金写笔记,写好后提交到本文评论区。

为了给大家谋福利,另外给大家的文章带来更多阅读量,便于搜索,从2022年3月27日起,笔记可以直接发布在掘金,以《标题自取》标题不限,可以取个好标题,容易被掘金推荐。

笔记文章开头加两句话:

  • 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
  • 这是源码共读的第xx期,链接:xxx。

笔记文章最后,建议写上总结、收获、感受等。

  • 开头第一句作用是:方便每月统计评优,掘金赞助送小礼物。顺便帮忙宣传推广,让更多人参与进来,一起学习。
  • 开头第二句作用是:加上是多少期,当前任务说明的链接,方便读者知道这是什么活动。

笔记写完后,到当前期活动的文章评论区留言自己的文章和笔记特点。方便大家查阅学习交流讨论。

往期所有笔记存放在语雀讨论区。

任务发布时间

4月25日 - 5月1日。可以按照自己节奏学习,提交笔记即可(不一定要严格按照我规定的时间)。往期共读也可以及时复习,笔记未完成可以继续完成。

学习任务

  • github.com/sindresorhu…
  • github1s: github1s.com/sindresorhu…
  • yocto-queue 源码行数不多, 67 行
  • 学习 Symbol.iterator 的使用场景
  • 可以克隆代码下来,关注测试用例,自己多通过测试用例调试
  • 学习链表和数组的区别,时间复杂度

参考文章

  • 看文章,看源码,交流讨论,写笔记发布在掘金。再在这篇文章下评论放上提交笔记的链接。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【若川视野 x 源码共读】第30期 taro小程序代码上

发表于 2022-04-23

本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。

学习准备工作

阅读相关学习资料:

1
2
3
4
ruby复制代码微信小程序CI :https://developers.weixin.qq.com/miniprogram/dev/devtools/ci.html
taro CI: https://link.juejin.cn/?target=https%3A%2F%2Ftaro-docs.jd.com%2Ftaro%2Fdocs%2Fnext%2Fplugin-mini-ci%2F
coding自动构建微信小程序:https://help.coding.net/docs/ci/practice/quick/wechat-mini-program.html
小打卡小程序自动化构建:https://www.yuque.com/jinxuanzheng/gvhmm5/uy4qu9#mmmx7

clone源码:

源码路径如下:

1
bash复制代码https://github.com/NervJS/taro/tree/next/packages/taro-plugin-mini-ci

我是把taro整个clone下来的。

源码学习

小程序CI的整体流程

首先看index.js:

1
2
java复制代码module.exports = require('./dist/index.js').default
module.exports.default = module.exports

含义是引入dist文件夹下的index.js文件, 但是刚克隆下来的代码中并没有dist文件夹。很容易想到dist文件夹是打包后产生的,所以执行命令:

1
2
arduino复制代码npm i
npm run build

注意是在taro/packages/taro-plugin-mini-ci目录下执行 install和build命令:

build之后可以看到有了dist文件夹:

对应目录下也生成了index.js文件,生成的js文件和原来的ts文件也没差太多,再加上最近再自学ts,就看index.ts吧(代码有删减):

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
javascript复制代码import { IPluginContext } from '@tarojs/service'
import * as minimist from 'minimist'
import { CIOptions } from './BaseCi'
import WeappCI from './WeappCI'
import TTCI from './TTCI'
import AlipayCI from './AlipayCI'
import SwanCI from './SwanCI'

export { CIOptions } from './BaseCi'
export default (ctx: IPluginContext, pluginOpts: CIOptions) => {
const onBuildDone = ctx.onBuildComplete || ctx.onBuildFinish

ctx.addPluginOptsSchema((joi) => {
return joi
.object()
.keys({
/** 微信小程序上传配置 */
weapp: joi.object({
appid: joi.string().required(),
projectPath: joi.string(),
privateKeyPath: joi.string().required(),
type: joi.string().valid('miniProgram', 'miniProgramPlugin', 'miniGame', 'miniGamePlugin'),
ignores: joi.array().items(joi.string().required())
}),
/** 字节跳动小程序上传配置 */
/** 阿里小程序上传配置 */
/** 百度小程序上传配置 */
swan: joi.object({
token: joi.string().required(),
minSwanVersion: joi.string()
}),
version: joi.string(),
desc: joi.string()
})
.required()
})

onBuildDone(async () => {
const args = minimist(process.argv.slice(2), {
boolean: ['open', 'upload', 'preview']
})
const { printLog, processTypeEnum } = ctx.helper
const platform = ctx.runOpts.options.platform
let ci
switch (platform) {
case 'weapp':
ci = new WeappCI(ctx, pluginOpts)
break
case 'tt':
ci = new TTCI(ctx, pluginOpts)
break
case 'alipay':
case 'iot':
ci = new AlipayCI(ctx, pluginOpts)
break
case 'swan':
ci = new SwanCI(ctx, pluginOpts)
break
default:
break
}
if (!ci) {
printLog(processTypeEnum.WARNING, `"@tarojs/plugin-mini-ci" 插件暂时不支持 "${platform}" 平台`)
return
}
switch (true) {
case args.open:
ci.open()
break
case args.upload:
ci.upload()
break
case args.preview:
ci.preview()
break
default:
break
}
})
}

代码的整体流程比较简单,判断平台,创建CI实例, 执行对应的CI。

可以在启动Node.js 程序时直接指定命令行参数,例如:

1
ini复制代码node index.js --beep=boop -t -z 12 -n5 foo bar

Node.js 程序启动后可以直接从process.argv中读取到参数列表:

1
2
javascript复制代码console.log(process.argv);
// ['/bin/node', '/tmp/index.js', '--beep=boop', '-t', '-z', '12', '-n5', 'foo', 'bar']

从上述代码中可以看到,process.argv 变量是一个数组,数组前两项分别是 node 程序位置和js脚本位置,数组中随后的元素都是我们启动Node.js后的参数,这些参数以空格分隔成数组。

而minimist 是一个专门用于处理Node.js启动参数的库,可以将 process.argv 中的参数列表转换成更加易于使用的格式:

1
2
3
javascript复制代码const argv = require('minimist')(process.argv.slice(2));
console.dir(argv);
// { _: [ 'foo', 'bar' ], beep: 'boop', t: true, z: 12, n: 5 }

具体使用可以参考www.npmjs.com/package/min…, 使用的时候接收参数和配置对象。

CI抽象类:BaseCI

packages/taro-plugin-mini-ci/src/BaseCi.ts(代码有删减):

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
javascript复制代码import { IPluginContext } from '@tarojs/service'
import * as path from 'path'

export type ProjectType = 'miniProgram' | 'miniGame' | 'miniProgramPlugin' | 'miniGamePlugin';

/** 微信小程序配置 */

/** 头条小程序配置 */

/** 支付宝系列小程序配置 */

/** 百度小程序配置 */

export interface CIOptions {
/** 发布版本号,默认取 package.json 文件的 taroConfig.version 字段 */
version: string;
/** 版本发布描述, 默认取 package.json 文件的 taroConfig.desc 字段 */
desc: string;
/** 微信小程序CI配置, 官方文档地址:https://developers.weixin.qq.com/miniprogram/dev/devtools/ci.html */
weapp?: WeappConfig;
/** 头条小程序配置, 官方文档地址:https://microapp.bytedance.com/docs/zh-CN/mini-app/develop/developer-instrument/development-assistance/ide-order-instrument */
tt?: TTConfig;
/** 支付宝系列小程序配置,官方文档地址: https://opendocs.alipay.com/mini/miniu/api */
alipay?: AlipayConfig;
/** 百度小程序配置, 官方文档地址:https://smartprogram.baidu.com/docs/develop/devtools/commandtool/ */
swan?: SwanConfig;
}

export default abstract class BaseCI {
/** taro 插件上下文 */
protected ctx: IPluginContext

/** 传入的插件选项 */
protected pluginOpts: CIOptions

/** 当前要发布的版本号 */
protected version: string

/** 当前发布内容的描述 */
protected desc: string

constructor (ctx: IPluginContext, pluginOpts: CIOptions) {
this.ctx = ctx
this.pluginOpts = pluginOpts

const { appPath } = ctx.paths
const { fs } = ctx.helper
const packageInfo = JSON.parse(
fs.readFileSync(path.join(appPath, 'package.json'), {
encoding: 'utf8'
})
)
this.version = pluginOpts.version || packageInfo.taroConfig?.version || '1.0.0'
this.desc = pluginOpts.desc || packageInfo.taroConfig?.desc || `CI构建自动构建于${new Date().toLocaleTimeString()}`

this._init()
}

/** 初始化函数,会被构造函数调用 */
protected abstract _init():void;

/** 打开小程序项目 */
abstract open();

/** 上传小程序 */
abstract upload();

/** 预览小程序 */
abstract preview();
}

在抽象类中定义了一些属性是protected的,意味着可以在本类以及子类中访问;在constructor中对属性进行了初始化,并调用初始化函数。然后是定义了一些CI操作的抽象方法。

CI子类:AlipayCI

packages/taro-plugin-mini-ci/src/AlipayCI.ts

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
javascript复制代码/* eslint-disable no-console */
import * as miniu from 'miniu'
import * as path from 'path'
import BaseCI from './BaseCi'
import generateQrCode from './QRCode'

/** 文档地址: https://opendocs.alipay.com/mini/miniu/api */
export default class AlipayCI extends BaseCI {
protected _init (): void {
if (this.pluginOpts.alipay == null) {
throw new Error('请为"@tarojs/plugin-mini-ci"插件配置 "alipay" 选项')
}
const { appPath } = this.ctx.paths
const { fs } = this.ctx.helper
const { toolId, privateKeyPath: _privateKeyPath, proxy } = this.pluginOpts.alipay
const privateKeyPath = path.isAbsolute(_privateKeyPath) ? _privateKeyPath : path.join(appPath, _privateKeyPath)
if (!fs.pathExistsSync(privateKeyPath)) {
throw new Error(`"alipay.privateKeyPath"选项配置的路径不存在,本次上传终止:${privateKeyPath}`)
}

miniu.setConfig({
toolId,
privateKey: fs.readFileSync(privateKeyPath, 'utf-8'),
proxy
})
}

open () {
const { printLog, processTypeEnum } = this.ctx.helper
printLog(processTypeEnum.WARNING, '阿里小程序不支持 "--open" 参数打开开发者工具')
}

async upload () {
const { chalk, printLog, processTypeEnum } = this.ctx.helper
const clientType = this.pluginOpts.alipay!.clientType || 'alipay'
printLog(processTypeEnum.START, '上传代码到阿里小程序后台', clientType)
// 上传结果CI库本身有提示,故此不做异常处理
// TODO 阿里的CI库上传时不能设置“禁止压缩”,所以上传时被CI二次压缩代码,可能会造成报错,这块暂时无法处理; SDK上传不支持设置描述信息
const result = await miniu.miniUpload({
project: this.ctx.paths.outputPath,
appId: this.pluginOpts.alipay!.appId,
packageVersion: this.version,
clientType,
experience: true,
onProgressUpdate (info) {
const { status, data } = info
console.log(status, data)
}
})
if (result.packages) {
const allPackageInfo = result.packages.find(pkg => pkg.type === 'FULL')
const mainPackageInfo = result.packages.find((item) => item.type === 'MAIN')
const extInfo = `本次上传${allPackageInfo!.size} ${mainPackageInfo ? ',其中主包' + mainPackageInfo.size : ''}`
console.log(chalk.green(`上传成功 ${new Date().toLocaleString()} ${extInfo}`))
}
}

async preview () {
const previewResult = await miniu.miniPreview({
project: this.ctx.paths.outputPath,
appId: this.pluginOpts.alipay!.appId,
clientType: this.pluginOpts.alipay!.clientType || 'alipay',
qrcodeFormat: 'base64'
})
console.log('预览二维码地址:', previewResult.packageQrcode)
generateQrCode(previewResult.packageQrcode!)
}
}

支付宝小程序子类的_init()方法主要做参数的验证和设置;open,upload,preview实现了抽象类定义的方法,分别用于打开开发者工具,上传代码,预览二维码。核心功能的实现依赖于miniu。可以查看相应的资料。

这篇文章介绍了使用MiniU完成CI/CD: forum.alipay.com/mini-app/po…。

生成二维码调用了generateQrCode方法:

1
2
3
4
5
6
7
javascript复制代码 /**
* 生产二维码输出到控制台
* @param url 链接地址
*/
export default function generateQrCode (url: string) {
require('qrcode-terminal').generate(url, { small: true })
}

generateQrCode实际上是通过三方包qrcode-terminal来实现的。

CI子类:SwanCI

在SwanCI类中open方法和preview方法的实现与AlipayCI一样,upload实现有所不同:

1
2
3
4
5
6
7
8
9
10
11
12
javascript复制代码async upload () {
const { outputPath } = this.ctx.paths
const { chalk, printLog, processTypeEnum } = this.ctx.helper
printLog(processTypeEnum.START, '上传体验版代码到百度后台')
printLog(processTypeEnum.REMIND, `本次上传版本号为:"${this.version}",上传描述为:“${this.desc}”`)
shell.exec(`${this.swanBin} upload --project-path ${outputPath} --token ${this.pluginOpts.swan!.token} --release-version ${this.version} --min-swan-version ${this.pluginOpts.swan!.minSwanVersion || '3.350.6'} --desc ${this.desc} --json`, (_code, _stdout, stderr) => {
if (!stderr) {
// stdout = JSON.parse(stdout)
console.log(chalk.green(`上传成功 ${new Date().toLocaleString()}`))
}
})
}

上传的时候执行shell脚本,通过shelljs来实现的 。

CI子类:WeappCI

WeappCI主要是使用了miniprogram-ci ,具体看一下open, upload, preview方法:

open方法(代码有删减):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
javascript复制代码import * as cp from 'child_process'

async open () {
const { fs, printLog, processTypeEnum, getUserHomeDir } = this.ctx.helper
const { appPath } = this.ctx.paths
// 检查安装路径是否存在
/** 命令行工具所在路径 */
// 检查是否开启了命令行
cp.exec(`${cliPath} open --project ${appPath}`, (err) => {
if (err) {
printLog(processTypeEnum.ERROR, err.message)
}
})
}

open方法用于打开开发者工具,通过node.js child_process的exec执行命令。

upload方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
javascript复制代码import * as ci from 'miniprogram-ci'

async upload () {
const { chalk, printLog, processTypeEnum } = this.ctx.helper
try {
printLog(processTypeEnum.START, '上传体验版代码到微信后台')
printLog(processTypeEnum.REMIND, `本次上传版本号为:"${this.version}",上传描述为:“${this.desc}”`)
const uploadResult = await ci.upload({
project: this.instance,
version: this.version,
desc: this.desc,
onProgressUpdate: undefined
})

if (uploadResult.subPackageInfo) {
const allPackageInfo = uploadResult.subPackageInfo.find((item) => item.name === '__FULL__')
const mainPackageInfo = uploadResult.subPackageInfo.find((item) => item.name === '__APP__')
const extInfo = `本次上传${allPackageInfo!.size / 1024}kb ${mainPackageInfo ? ',其中主包' + mainPackageInfo.size + 'kb' : ''}`
console.log(chalk.green(`上传成功 ${new Date().toLocaleString()} ${extInfo}`))
}
} catch (error) {
console.log(chalk.red(`上传失败 ${new Date().toLocaleString()} \n${error.message}`))
}
}

上传代码的方法使用miniprogram-ci的upload方法,得到结果信息后根据分包信息来提示整体包大小和主包大小。

preview方法(代码有删减):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
javascript复制代码async preview () {
const { chalk, printLog, processTypeEnum } = this.ctx.helper
try {
printLog(processTypeEnum.START, '上传开发版代码到微信后台并预览')
const uploadResult = await ci.preview({
project: this.instance,
version: this.version,
desc: this.desc,
onProgressUpdate: undefined
})

if (uploadResult.subPackageInfo) {
const allPackageInfo = uploadResult.subPackageInfo.find((item) => item.name === '__FULL__')
const mainPackageInfo = uploadResult.subPackageInfo.find((item) => item.name === '__APP__')
const extInfo = `本次上传${allPackageInfo!.size / 1024}kb ${mainPackageInfo ? ',其中主包' + mainPackageInfo.size + 'kb' : ''}`
console.log(chalk.green(`上传成功 ${new Date().toLocaleString()} ${extInfo}`))
}
} catch (error) {
console.log(chalk.red(`上传失败 ${new Date().toLocaleString()} \n${error.message}`))
}
}

preview方法使用的是miniprogram-ci的preview方法, 得到结果信息后根据分包信息来提示整体包大小和主包大小。

CI子类:TTCI

TTCI使用tt-ide-cli来完成预览和上传,使用child_process的exec来完成打开开发者工具的功能。

open(代码有删除):

1
2
3
4
5
6
7
8
9
10
11
12
13
javascript复制代码open () {
if (fs.existsSync(projectPath)) {
console.log(chalk.green(`open projectPath: ${projectPath}`))
const openPath = `${openCmd}?path=${projectPath}`
cp.exec(openPath, (error) => {
if (!error) {
console.log('打开IDE成功', openPath)
} else {
console.log(chalk.red('打开IDE失败', error))
}
})
}
}

这里open方法也是通过node.js child_process的exec执行命令。

upload(代码有删除):

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码import * as tt from 'tt-ide-cli'
async upload () {
try {
await tt.upload({
entry: outputPath,
version: this.version,
changeLog: this.desc
})
} catch (error) {
}
}

上传代码使用tt-ide-cli的upload方法。

preview(代码有删除):

1
2
3
4
5
6
7
8
9
10
11
12
javascript复制代码import * as tt from 'tt-ide-cli'
async preview () {
try {
await tt.preview({
entry: outputPath,
force: true,
small: true
})
} catch (error) {
console.log(chalk.red(`上传失败 ${new Date().toLocaleString()} \n${error.message}`))
}
}

生成预览二维码使用了tt-ide-cli的upload方法。

总结

1.taro小程序ci的核心代码逻辑是:判断平台,创建CI实例, 执行对应的CI。

2.不同平台对应不同的CI类,但都继承了基础的CI抽象类,实现了抽象类声明的open,upload和preview方法。

3.实现具体的open,upload和preview方法时根据对应小程序是否提供了命令行工具,有用到miniu,tt-ide-cli,miniprogram-ci,还有的使用shellj,qrcode-terminal,以及child_process来执行命令。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【若川视野 x 源码共读】第31期 p-limit 限制

发表于 2022-04-17

源码共读前言

为了能帮助到更多对源码感兴趣、想学会看源码、提升自己写作和前端技术能力的同学。 帮助读者夯实基础,查漏补缺,开阔眼界,拓宽视野,知其然知其所以然。

我倾力组织了每周一起学200行左右的源码共读活动。我写有《学习源码整体架构系列》20余篇。

欢迎点此扫码加我微信 ruochuan02 交流,参与 源码共读 活动,每周大家一起学习200行左右的源码,共同进步。可以持续关注我@若川。

从易到难推荐学习顺序

活动介绍和顺序具体看这里从易到难推荐学习顺序

提交笔记

提交笔记方式
简言之:看任务,看辅助文章、看源码,交流讨论,在掘金写笔记,写好后提交到本文评论区。

为了给大家谋福利,另外给大家的文章带来更多阅读量,便于搜索,从2022年3月27日起,笔记可以直接发布在掘金,以《标题自取》标题不限,可以取个好标题,容易被掘金推荐。

笔记文章开头加两句话:

  • 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
  • 这是源码共读的第xx期,链接:xxx。

笔记文章最后,建议写上总结、收获、感受等。

  • 开头第一句作用是:方便每月统计评优,掘金赞助送小礼物。顺便帮忙宣传推广,让更多人参与进来,一起学习。
  • 开头第二句作用是:加上是多少期,当前任务说明的链接,方便读者知道这是什么活动。

笔记写完后,到当前期活动的文章评论区留言自己的文章和笔记特点。方便大家查阅学习交流讨论。

往期所有笔记存放在语雀讨论区。

任务发布时间

4月18日 - 4月24日。可以按照自己节奏学习,提交笔记即可(不一定要严格按照我规定的时间)。往期共读也可以及时复习,笔记未完成可以继续完成。

语雀本期任务说明链接

语雀有树形菜单,更方便查看,所以也放下语雀的这一期链接

学习任务

  • github.com/sindresorhu…
  • github1s: github1s.com/sindresorhu…
  • p-limit 源码行数不多, 68 行
  • 学习 p-limit 的使用场景
  • 可以克隆代码下来,关注测试用例,自己多调试

参考文章

Node.js 并发能力总结

  • 看文章,看源码,交流讨论,写笔记发布在掘金。再在这篇文章下评论放上提交笔记的链接。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【若川视野 x 源码共读】第25期 跟着undersco

发表于 2022-04-17

源码共读前言

为了能帮助到更多对源码感兴趣、想学会看源码、提升自己写作和前端技术能力的同学。 帮助读者夯实基础,查漏补缺,开阔眼界,拓宽视野,知其然知其所以然。

我倾力组织了每周一起学200行左右的源码共读活动。我写有《学习源码整体架构系列》20余篇。

欢迎点此扫码加我微信 ruochuan02 交流,参与 源码共读 活动,每周大家一起学习200行左右的源码,共同进步。可以持续关注我@若川。

从易到难推荐学习顺序

活动介绍和顺序具体看这里从易到难推荐学习顺序

提交笔记

提交笔记方式
简言之:看任务,看辅助文章、看源码,交流讨论,在掘金写笔记,写好后提交到本文评论区。

为了给大家谋福利,另外给大家的文章带来更多阅读量,便于搜索,从2022年3月27日起,笔记可以直接发布在掘金,以《标题自取》标题不限,可以取个好标题,容易被掘金推荐。
文章开头第一句加上:本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

第二句可以加上当前任务说明的链接:这是源码共读的第xx期,链接:xxx。第一句作用为了方便每月统计评优。第二句方便读者知道这是什么活动主题。

笔记写完后,到当前期活动的文章评论区留言自己的文章和笔记特点。方便大家查阅学习交流讨论。

往期所有笔记存放在语雀讨论区。

任务发布时间

2月28日-3月6日,为期一周。一般一周发布一期源码。可以按照自己节奏学习,提交笔记即可(不一定要严格按照我规定的时间)。往期共读也可以及时复习,笔记未完成可以继续完成。

学习目标

  1. 学会防抖的使用场景
  2. 学会防抖的原理
  3. 学会使用测试用例测试源码

学习任务

  • 可以克隆源码也可以直接引用cdn写demo
  • 参考文章 JavaScript专题之跟着underscore学防抖
  • underscore 源码
  • github.com/jashkenas/u…
  • 可以看测试用例学习 underscore debounce 测试用例
  • lodash 源码
  • lodash.com/docs/4.17.1…
  • github.com/lodash/loda…
  • 参考文章:深入浅出防抖函数 debounce
  • 看文章,看源码,交流讨论,写笔记发布在掘金。再在这篇文章下评论放上提交笔记的链接。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【若川视野 x 源码共读】第26期 classnames

发表于 2022-04-17

源码共读前言

为了能帮助到更多对源码感兴趣、想学会看源码、提升自己写作和前端技术能力的同学。 帮助读者夯实基础,查漏补缺,开阔眼界,拓宽视野,知其然知其所以然。

我倾力组织了每周一起学200行左右的源码共读活动。我写有《学习源码整体架构系列》20余篇。

欢迎(jia)我微信 ruochuan12 交流,参与 源码共读 活动,每周大家一起学习200行左右的源码,共同进步。可以持续关注我@若川。

从易到难推荐学习顺序

活动介绍和顺序具体看这里从易到难推荐学习顺序

提交笔记

提交笔记方式
简言之:看任务,看辅助文章、看源码,交流讨论,在掘金写笔记,写好后提交到本文评论区。

为了给大家谋福利,另外给大家的文章带来更多阅读量,便于搜索,从2022年3月27日起,笔记可以直接发布在掘金,以《标题自取》标题不限,可以取个好标题,容易被掘金推荐。

笔记文章开头加两句话:

  • 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
  • 这是源码共读的第xx期,链接:xxx。

笔记文章最后,建议写上总结、收获、感受等。

  • 开头第一句作用是:方便每月统计评优,掘金赞助送小礼物。顺便帮忙宣传推广,让更多人参与进来,一起学习。
  • 开头第二句作用是:加上是多少期,当前任务说明的链接,方便读者知道这是什么活动。

笔记写完后,到当前期活动的文章评论区留言自己的文章和笔记特点。方便大家查阅学习交流讨论。

往期所有笔记存放在语雀讨论区。

任务发布时间

3月7日-3月13日,为期一周。一般一周发布一期源码。可以按照自己节奏学习,提交笔记即可(不一定要严格按照我规定的时间)。往期共读也可以及时复习,笔记未完成可以继续完成。\

语雀本期任务说明链接

语雀有树形菜单,更方便查看,所以也放下语雀的这一期链接

学习目标

  1. 学会 classnames 的用法
  2. 学会 classnames 的原理
  3. 多关注测试用例

学习任务

  • 仓库地址:github.com/JedWatson/c…
  • 可以多关注测试用例
  • 看文章,看源码,交流讨论,写笔记发布在掘金。再在这篇文章下评论放上提交笔记的链接。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【若川视野 x 源码共读】第27期 read-pkg

发表于 2022-04-17

源码共读前言

为了能帮助到更多对源码感兴趣、想学会看源码、提升自己写作和前端技术能力的同学。 帮助读者夯实基础,查漏补缺,开阔眼界,拓宽视野,知其然知其所以然。

我倾力组织了每周一起学200行左右的源码共读活动。我写有《学习源码整体架构系列》20余篇。

欢迎(jia)我微信 ruochuan12 交流,参与 源码共读 活动,每周大家一起学习200行左右的源码,共同进步。可以持续关注我@若川。

从易到难推荐学习顺序

活动介绍和顺序具体看这里从易到难推荐学习顺序

提交笔记

提交笔记方式
简言之:看任务,看辅助文章、看源码,交流讨论,在掘金写笔记,写好后提交到本文评论区。

为了给大家谋福利,另外给大家的文章带来更多阅读量,便于搜索,从2022年3月27日起,笔记可以直接发布在掘金,以《标题自取》标题不限,可以取个好标题,容易被掘金推荐。

笔记文章开头加两句话:

  • 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
  • 这是源码共读的第xx期,链接:xxx。

笔记文章最后,建议写上总结、收获、感受等。

  • 开头第一句作用是:方便每月统计评优,掘金赞助送小礼物。顺便帮忙宣传推广,让更多人参与进来,一起学习。
  • 开头第二句作用是:加上是多少期,当前任务说明的链接,方便读者知道这是什么活动。

笔记写完后,到当前期活动的文章评论区留言自己的文章和笔记特点。方便大家查阅学习交流讨论。

往期所有笔记存放在语雀讨论区。

任务发布时间

3月14日-3月20日,为期一周。一般一周发布一期源码。可以按照自己节奏学习,提交笔记即可(不一定要严格按照我规定的时间)。往期共读也可以及时复习,笔记未完成可以继续完成。\

语雀本期任务说明链接

语雀有树形菜单,更方便查看,所以也放下语雀的这一期链接

学习目标

  1. 如何学习调试源码
  2. 学会如何获取 package.json
  3. 学到 import.meta
  4. 学到引入 json 文件的提案
  5. JSON.parse 更友好的错误提示
  6. 规范化 package 元数据
  7. 等等

学习任务

  • 参考文章:从 vue-cli 源码中,我发现27行读取 json 文件有趣的 npm 包
  • 仓库地址 github.com/sindresorhu…
  • 看文章看源码记笔记
  • 看文章,看源码,交流讨论,写笔记发布在掘金。再在这篇文章下评论放上提交笔记的链接。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…959697…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%