本篇以按钮点击防抖为例,使用gradle
插件在所有onClick()
方法体里面插入代码,大致步骤如下
- 新建一个
sdk module
,定义DoubleClickController.isDoubleClick()
判断方法 - 新建
gradle
插件module
,使用gradle
插件遍历所有onClick
方法 - 引入
ASM
,修改onClick
方法体,在onClick()
方法体第一行插入DoubleClickController.isDoubleClick()
判断
效果如下:
findViewById(R.id.btnTestSdk).setOnClickListener(view -> {
if (DoubleClickController.isDoubleClick()) return; // 要插入的代码
Toast.makeText(TestSdkActivity.this, "test sdk click", Toast.LENGTH_SHORT).show();
});
1. 定义DoubleClickController.isDoubleClick()
判断方法
该方法可以放到一个独立module
里面,最终可以和gradle plugin
一起提供给外部使用
public class DoubleClickController {
private static final long DOUBLE_CLICK_TIME_SPACE = 500; // 双击间隔
private static long lastClickTime = 0L; // 上次点击事件戳
public static boolean isDoubleClick() {
long curTime = System.currentTimeMillis();
if (curTime - lastClickTime < DOUBLE_CLICK_TIME_SPACE) {
return true;
}
lastClickTime = curTime;
return false;
}
}
findViewById(R.id.btnTestSdk).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
// if (DoubleClickController.isDoubleClick()) return; // 要插入的代码
Toast.makeText(TestSdkActivity.this, "test sdk click", Toast.LENGTH_SHORT).show();
}
});
2. gradle
插件
插件的创建与调试可以参考上一篇:Android Gradle学习(四)- gradle插件开发和调试
首先需要了解两个概念:
gradle plugin 7.0
之前,使用Transform
,本篇基于此改造。gradle plugin 7.0
开始,Transform
废弃,这个方案后面再说。class
分为主项目编译生成的class
和三方jar
里面的class
。
class
修改大致流程:
Transform
遍历所有class
(主项目和三方jar)InputStream
读取文件流ClassReader
读取并加载class
ClassVisitor
解析class
并筛选出要修改的方法体MethodVisitor
修改方法体ClassWriter
将修改后的字节码转换为class
字节数组OutputStream
将class
字节数组写入文件
gradle
插件遍历所有的资源方法基本一致,是可以拷贝的,以下是插件入口CommonPlugin.groovy
方法
package com.kongge.commonplugin
import com.android.build.gradle.AppExtension
import com.kongge.commonplugin.doubleclick.DoubleClickTransform
import org.gradle.api.Plugin
import org.gradle.api.Project
class CommonPlugin implements Plugin<Project> {
@Override
void apply(Project project) {
applyExtension(project)
println '----------CommonPlugin start----------'
doSomething(project)
println '----------CommonPlugin end----------'
}
private void applyExtension(Project project) {
// 扩展参数,此处可以忽略
project.extensions.create(CommonParam.EXT_NAME, CommonParam.class)
}
private void doSomething(Project project) {
// 扩展参数,此处可以忽略
AppExtension appExtension = project.extensions.findByType(AppExtension.class)
// 注册Transform
appExtension.registerTransform(new DoubleClickTransform(project))
project.afterEvaluate {
// 扩展参数,此处可以忽略
CommonParam commonParam = CommonParam.parseExt(project)
println "enable=${commonParam.enable}"
println "doubleClickTimeSpace=${commonParam.doubleClickTimeSpace}"
}
}
}
3. Transform
介绍
Transform
是 Android Gradle Plugin
1.5 就引入的特性,主要用于在 Android
构建过程中,在 Class
-> Dex
这个节点修改 Class
字节码。利用 Transform API
,我们可以拿到所有参与构建的 Class
文件,借助 Javassist
或 ASM
等字节码编辑工具进行修改,插入自定义逻辑。
具体可以参考其实 Gradle Transform 就是个纸老虎,此处不再赘述
DoubleClickTransform.groovy
package com.kongge.commonplugin.doubleclick
import com.android.build.api.transform.*
import com.android.build.gradle.internal.pipeline.TransformManager
import com.android.utils.FileUtils
import org.apache.commons.codec.digest.DigestUtils
import org.apache.commons.io.IOUtils
import org.gradle.api.Project
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassWriter
import org.objectweb.asm.Opcodes
import java.nio.file.Files
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
// 隐私处理类
public class DoubleClickTransform extends Transform {
Project project
DoubleClickTransform(Project project) {
this.project = project
}
@Override
String getName() {
return "DoubleClickTransform"
}
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}
@Override
Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}
@Override
boolean isIncremental() {
return false
}
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation)
printCopyright()
TransformOutputProvider outputProvider = transformInvocation.getOutputProvider()
//清理文件
if (!transformInvocation.isIncremental()) {
outputProvider.deleteAll()
}
transformInvocation.inputs.each { TransformInput input ->
input.jarInputs.each { JarInput jarInput ->
// 处理jar
processJarInput(jarInput, outputProvider)
}
input.directoryInputs.each { DirectoryInput directoryInput ->
// 处理源码文件
processDirectoryInputs(directoryInput, outputProvider)
}
}
}
private void printCopyright() {
println()
println("**************************************************************")
println("****** ******")
println("****** 欢迎使用DoubleClick编译插件 ******")
println("****** ******")
println("**************************************************************")
println()
}
private void processJarInput(JarInput jarInput, TransformOutputProvider outputProvider) {
//必须给jar重新命名,否则会冲突
String jarName = jarInput.name
String md5 = DigestUtils.md5Hex(jarInput.file.absolutePath)
if (jarName.endsWith(".jar")) {
jarName = jarName.substring(0, jarName.length() - 4)
}
File dest = outputProvider.getContentLocation(md5 + jarName, jarInput.contentTypes, jarInput.scopes, Format.JAR)
visitorJar(jarInput.getFile(), dest)
}
private void processDirectoryInputs(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {
directoryInput.getFile().eachFile { File file ->
findFiles(file)
}
File dest = outputProvider.getContentLocation(
directoryInput.getName(),
directoryInput.getContentTypes(),
directoryInput.getScopes(),
Format.DIRECTORY)
// 建立文件夹
FileUtils.mkdirs(dest)
// 将修改过的字节码copy到dest
FileUtils.copyDirectory(directoryInput.getFile(), dest)
}
private void findFiles(File file) {
if (file.isDirectory()) {
File[] files = file.listFiles()
files.each { File file1 ->
if (file1.isDirectory()) {
findFiles(file1)
} else {
visitorFile(file1)
}
}
} else {
visitorFile(file)
}
}
private void visitorJar(File src, File dest) {
try {
ZipFile inputZip = new ZipFile(src)
ZipOutputStream outputZip = new ZipOutputStream(new BufferedOutputStream(Files.newOutputStream(dest.toPath())))
Enumeration<? extends ZipEntry> inEntries = inputZip.entries()
while (inEntries.hasMoreElements()) {
ZipEntry entry = inEntries.nextElement()
InputStream originalFile = new BufferedInputStream(inputZip.getInputStream(entry))
String name = entry.getName()
println("-------------------entryname=" + name)
byte[] newEntryContent
// 仅对class文件做处理,使用AMS9修改字节码
if (name.endsWith(".class")) {
ClassReader classReader = new ClassReader(originalFile)
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS)
DoubleClickClassVisitor doubleClickVisitor = new DoubleClickClassVisitor(Opcodes.ASM9, classWriter)
classReader.accept(doubleClickVisitor, ClassReader.EXPAND_FRAMES)
newEntryContent = classWriter.toByteArray()
} else {
newEntryContent = IOUtils.toByteArray(originalFile)
}
originalFile.close()
ZipEntry outEntry = new ZipEntry(name)
outputZip.putNextEntry(outEntry)
outputZip.write(newEntryContent)
outputZip.closeEntry()
}
outputZip.flush()
outputZip.close()
} catch (Exception e) {
e.printStackTrace()
}
}
private void visitorFile(File file) {
try {
FileInputStream fileInputStream = new FileInputStream(file)
// 使用AMS9修改字节码
ClassReader classReader = new ClassReader(fileInputStream)
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES)
DoubleClickClassVisitor doubleClickVisitor = new DoubleClickClassVisitor(Opcodes.ASM9, classWriter)
classReader.accept(doubleClickVisitor, ClassReader.SKIP_DEBUG)
byte[] bytes = classWriter.toByteArray()
FileOutputStream fileOutputStream = new FileOutputStream(file.absolutePath)
if (file.exists()) {
file.delete()
}
fileOutputStream.write(bytes)
fileOutputStream.close()
fileInputStream.close()
} catch (Exception e) {
e.printStackTrace()
}
}
}
4. ASM
ASM
是一种基于java
字节码层面的代码分析和修改工具,ASM
的目标是生成,转换和分析已编译的java class
文件。对class
的修改基本分为3步
- 读取
class
文件 -ClassReader
- 扫描并修改
class
字节码 -ClassVisitor
- 输出
class
文件 -ClassWriter
集成AMS9
依赖
dependencies {
implementation gradleApi()
implementation localGroovy()
implementation 'com.android.tools.build:gradle:4.1.3'
// 一些文件操作工具
implementation 'commons-io:commons-io:2.4'
// asm
implementation 'org.ow2.asm:asm:9.2'
implementation 'org.ow2.asm:asm-util:9.2'
}
DoubleClickTransform.groovy
public class DoubleClickTransform extends Transform {
// ...
// 使用ASM对class文件进行处理
private void visitorFile(File file) {
try {
FileInputStream fileInputStream = new FileInputStream(file)
ClassReader classReader = new ClassReader(fileInputStream)
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES)
DoubleClickClassVisitor doubleClickVisitor = new DoubleClickClassVisitor(Opcodes.ASM9, classWriter)
classReader.accept(doubleClickVisitor, ClassReader.SKIP_DEBUG)
byte[] bytes = classWriter.toByteArray()
FileOutputStream fileOutputStream = new FileOutputStream(file.absolutePath)
println("visitorFile file.absolutePath=" + file.absolutePath)
if (file.exists()) {
file.delete()
}
fileOutputStream.write(bytes)
fileOutputStream.close()
fileInputStream.close()
} catch (Exception e) {
e.printStackTrace()
}
}
}
DoubleClickClassVisitor.groovy
: 用于遍历class
和匹配onClick
方法,之后交由MethodVisitor
去处理具体逻辑
package com.kongge.commonplugin.doubleclick
import org.objectweb.asm.ClassVisitor
import org.objectweb.asm.MethodVisitor
import org.objectweb.asm.Opcodes
class DoubleClickClassVisitor extends ClassVisitor {
private String[] mInterfaces
DoubleClickClassVisitor(int api) {
super(api)
}
DoubleClickClassVisitor(int api, ClassVisitor classVisitor) {
super(api, classVisitor)
}
@Override
void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces)
mInterfaces = interfaces
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
// 此处mv是外部传入的ClassWriter
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions)
String mInterfaceStr = ""
if(mInterfaces != null && mInterfaces.length > 0){
for(int i = 0 ; i < mInterfaces.length ; i++){
mInterfaceStr += mInterfaces[i]
}
}
println("-------start------")
println("mv != null is " + (mv != null))
println("name=" + name)
println("descriptor=" + descriptor)
println("mInterfaceStr=" + mInterfaceStr)
if (mv != null && name.contains("onClick")
&& mInterfaceStr.contains("android/view/View\$OnClickListener")
&& descriptor.contains("(Landroid/view/View;)V")) {
boolean isAbstractMethod = (access & Opcodes.ACC_ABSTRACT) != 0
boolean isNativeMethod = (access & Opcodes.ACC_NATIVE) != 0
if (!isAbstractMethod && !isNativeMethod) {
return new DoubleClickMethodVisitor(api, mv, access, name, descriptor)
}
}
println("------end-------")
return mv
}
}
DoubleClickMethodVisitor.groovy
:
package com.kongge.commonplugin.doubleclick
import org.objectweb.asm.Label
import org.objectweb.asm.MethodVisitor
import org.objectweb.asm.Type
import org.objectweb.asm.commons.AdviceAdapter
class DoubleClickMethodVisitor extends AdviceAdapter {
/**
* Constructs a new {@link AdviceAdapter}.
*
* @param api the ASM API version implemented by this visitor. Must be one of {@link
* Opcodes#ASM4}, {@link Opcodes#ASM5}, {@link Opcodes#ASM6} or {@link Opcodes#ASM7}.
* @param methodVisitor the method visitor to which this adapter delegates calls.
* @param access the method's access flags (see {@link Opcodes}).
* @param name the method's name.
* @param descriptor the method's descriptor (see {@link Type Type}).
*/
protected DoubleClickMethodVisitor(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {
super(api, methodVisitor, access, name, descriptor)
}
@Override
protected void onMethodEnter() {
super.onMethodEnter()
println("DoubleClickMethodVisitor onMethodEnter()")
visitMethodInsn(INVOKESTATIC, "com/kongge/commonsdk/doubleclick/DoubleClickController", "isDoubleClick", "()Z", false)
Label label0 = new Label()
visitJumpInsn(IFEQ, label0)
visitInsn(RETURN)
visitLabel(label0)
}
}
5.Java
字节码
参考:五分钟看懂Java字节码:极简手册,可以先简单了解下字节码指令集,这里推荐一个AndroidStudio
的插件,用于方便快捷查看class
字节码
AS
-> File
-> Settings
-> Plugins
搜索asm
,找到ASM Bytecode Viewer Support Kotlin
-> install
-> 重启AS
在AS
代码区右键
,选择ASM Bytecode Viewer
右侧可看到Bytecode tab
,可查看当前class
字节码,ASMified tab
可查看如何使用ASM
生成当前class
字节码。
如何使用ASM
生成插桩代码,有个小技巧:先使用插件查看源代码,将ASMified tab
所有内容复制到AS编辑代码区,然后将插桩代码加上,再使用插件查看,将ASMified tab
全复制并和之前的内容对比。此时会发现不一样的即ASM
插桩代码.
6.查看代码生成
app
|-build
|-intermediates
|-transforms
|-DoubleClickTransform
|-debug
|-[数字]
即可看到项目所有onClick的方法体内部都加上了点击防抖逻辑
7. jar
里面class
处理
jar
包的处理和主项目有点区别,其实质是一个压缩包,所以得使用ZipFile api
加载class
DoubleClickTransform.groovy
public class DoubleClickTransform extends Transform {
// ...
private void visitorJar(File src, File dest) {
try {
ZipFile inputZip = new ZipFile(src)
ZipOutputStream outputZip = new ZipOutputStream(new BufferedOutputStream(Files.newOutputStream(dest.toPath())))
Enumeration<? extends ZipEntry> inEntries = inputZip.entries()
while (inEntries.hasMoreElements()) {
ZipEntry entry = inEntries.nextElement()
InputStream originalFile = new BufferedInputStream(inputZip.getInputStream(entry))
String name = entry.getName()
println("-------------------entryname=" + name)
byte[] newEntryContent
// 仅对class文件做处理,使用AMS9修改字节码
if (name.endsWith(".class")) {
ClassReader classReader = new ClassReader(originalFile)
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS)
DoubleClickClassVisitor doubleClickVisitor = new DoubleClickClassVisitor(Opcodes.ASM9, classWriter)
classReader.accept(doubleClickVisitor, ClassReader.EXPAND_FRAMES)
newEntryContent = classWriter.toByteArray()
} else {
newEntryContent = IOUtils.toByteArray(originalFile)
}
originalFile.close()
ZipEntry outEntry = new ZipEntry(name)
outputZip.putNextEntry(outEntry)
outputZip.write(newEntryContent)
outputZip.closeEntry()
}
outputZip.flush()
outputZip.close()
} catch (Exception e) {
e.printStackTrace()
}
}
}
(AS
不支持直接查看Transform
目录下的jar
,可以安装File Expander
插件,安装成功后就可以展开jar
查看里面内容)
8. 注意事项
8.1 混淆问题
由于插件插入DoubleClickController.isDoubleClick()
是通过文本名称插入的,所以该类路径和方法名不能被混淆。可以在DoubleClickController sdk mudule
的consumer-rules.pro
中加入keep
,然后在build.gradle
中指定此混淆文件。
-keep class com.kongge.commonsdk.doubleclick.DoubleClickController {*;}
android {
defaultConfig {
// ...
consumerProguardFiles "consumer-rules.pro"
}
}
此时打出来的aar
便携带了proguard.txt
文件,外部就不需要再配置混淆文件了
8.2 Lambda
表达式问题
上述匹配onClick()
方法是通过OnClickListener
接口判断的,但是Lambda
表达式的写法没匹配上,后续再研究
findViewById(R.id.btnTestSdk).setOnClickListener(view -> {
// if (DoubleClickController.isDoubleClick()) return; // 要插入的代码
Toast.makeText(TestSdkActivity.this, "test sdk click", Toast.LENGTH_SHORT).show();
});
8.3 多个进程同时打包,有一定概率出现类丢失问题
由于插件处理jar
,会生成一个temp_xxx.jar
文件,如果temp_xxx.jar
是和源文件是同一个目录,此时多个进程同时打包,有一定概率同一时刻多进程读写同一个jar
导致出现脏数据。可以将生成的temp_xxx.jar
文件放到当前项目的build
目录下。
参考: