Public number: byte array
Hope to help you 🤣🤣
Recently, it was planned to implement a double-click anti-shake function by means of bytecode pegs. In short, I want bytecode pegs to insert a piece of logic into all callback methods using OnClickListener throughout the project. This logic code will determine the time of two clicks and return if the time is less than a certain threshold
/ / before pile
view.setOnClickListener(object : View.OnClickListener {
override fun onClick(view: View) {
//TODO}})/ / after pile
view.setOnClickListener(object : View.OnClickListener {
override fun onClick(view: View) {
if(! ViewDoubleClickCheck.canClick(view)){return
}
//TODO}})Copy the code
After long iterations of a large project, OnClickListener can be set up in many different ways, either using multiple extension frameworks or using multiple implementations of the same code. At this point, consider at least the following scenarios:
- The OnClickListener is set directly to the View in the code
- The onClick attribute is declared for the View in the XML
- Level of support for third-party frameworks. For example, if ButterKnife is used in your project, take care of the method that declares the @onclick annotation. If use the BaseRecyclerViewAdapterHelper, to take care of for each Adapter onItemClickListener or onItemChildClickListener
The only difference is that the code to set the OnClickListener is not explicitly declared in our project. As long as we can solve the first scenario, we only need to change the scope and criteria of the hook for the other scenarios
The OnClickListener can be set directly to the View via the code. The implementation of the code can be divided into two ways according to whether or not a lambda expression is used:
view.setOnClickListener(object : View.OnClickListener {
override fun onClick(v: View) {
onClickView()
}
})
view.setOnClickListener {
onClickView()
}
Copy the code
In the process of implementing the double click anti-shake function through ASM, the biggest difficulty lies in dealing with lambda expressions, which cannot be processed directly through the conventional positioning package name full path and method signature information. It covers the differences between native Java and Android implementations of Java 8, as well as a little bit about the Android compilation process
This article is to introduce me in the process of implementing bytecode staking summed up some knowledge points, I hope to help you 🤣🤣
Java 8 Lambda
Let’s start with an example of the non-lambda form
Developers should know that if they want to get an instance of an interface or abstract class, they can declare the instance object as an anonymous inner class instead of explicitly declaring the implementation class
public class Demo {
void test(a) {
Runnable runnable = new Runnable() {
@Override
public void run(a) {
System.out.println("leavesC"); }}; runnable.run(); }}Copy the code
Compile Java files to bytecode
javac Demo.java
Copy the code
You end up generating two class files: demo. class and Demo$1.class
You can see that Demo$1 implements the Runnable interface, also known as the anonymous inner class. The Runnable variable declared in the above code points to the implementation class, and as you can see from line 11 of demo. class, the final object new points to Demo$1.class
Convert the code above to lambda form and see how its bytecode changes
public class Demo {
void test(a) {
Runnable runnable = () -> System.out.println("leavesC"); runnable.run(); }}Copy the code
All that will be generated is a demo.class file
The main differences between the two bytecode files are:
- In non-lambda mode, the declared Runnable variable ends up pointing to a concrete interface implementation class, also known as an anonymous inner class. You can also see in the bytecode that there is an explicit process for generating this anonymous inner class object. Corresponding to new, DUP, ALOAD_0, invokespecial and other commands. The code block to be executed by Runnable is in
Demo$1.class
In the run method of - In lambda mode, declaring a Runnable variable corresponds to invokedynamic, astore_1, and so on. The code block that Runnable executes is a private static method that is automatically generated
lambda$test$0()
ä¸
It follows from this that lambda expressions do not generate corresponding implementation classes at compile time, and the implementation mechanism of lambda syntax is different from previous anonymous inner classes
The key is the Invokedynamic instruction, which Java currently contains five bytecode invocation instructions
instruction | role |
---|---|
invokevirtual | Calling instance methods |
invokestatic | Calling static methods |
invokeinterface | Calling interface methods |
invokespecial | Call special instance methods, including instance initializer methods and parent class methods |
invokedynamic | Depending on the user-guided method, the runtime dynamically resolves the method referenced by the call point qualifier |
In the class file generated during compilation, the first four instructions have fixed the symbolic information of the target method through the Constant Pool, including the globally qualified names of classes and interfaces, field names and descriptors, method names and descriptors, etc. The runtime can rely on this symbol information to directly locate the specific method to call directly
Invokedynamic is a bytecode invocation instruction newly added in Java 7. As one of the improvements of Java to support dynamic typing language, invokeDynamic was applied in Java 8, and the underlying implementation of lambda expressions relies on this instruction. The Invokedynamic instruction does not contain the specific symbol information of its target method in the constant pool, but stores the information of BootstapMethod. At runtime, the owner and type of the method are determined dynamically through the bootmethod mechanism
Take a closer look at the detailed bytecode information for Demo.class
javap -verbose Demo.class
Copy the code
After executing the Invokedynamic instruction on line 17, the top value of the stack, known as this, is stored and used as an argument to call the run() method of the Runnable interface. This illustrates that a Runnable object can be retrieved using the InvokeDynamic instruction
0: invokedynamic #2.0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: aload_1
7: invokeinterface #3.1 // InterfaceMethod java/lang/Runnable.run:()V
12: return
Copy the code
InvokeDynamic points to the BootstapMethods field on line 42, where the static method metaFactory () of LambdaMetafactory is invoked via the Invokestatic instruction. At this point, the associated interface implementation class is generated in memory
In addition, three input parameters are indicated:
- (V). The original method generic erased method signature information, namely the RUN method. Since the run method does not contain generics, it has the same signature information as the third parameter
- invokestatic lambdademo/Demo.lambda
Zero: (V). Invokestatic expresses an operation that invokes a static method, followed by an automatically generated private static methodlambda$test$0()
That contains the block of code that the lambda expression is supposed to execute - (V). Method signature information before the original method generic erasure, that is, the RUN method
From InnerClassLambdaMetafactory can also see that used here in the ASM, the generated implementation class according to the ClassName + $$Lambda $+ integer index way named, The implementation class is responsible for calling the lambda$test$0() method
You can verify that the runnable object belongs to another class than DemoClass by printing its class information
public class Demo {
void test() {
Runnable runnable = () -> System.out.println("leavesC");
System.out.println(runnable.getClass().getSimpleName());
Runnable runnable2 = () -> System.out.println("leavesC");
System.out.println(runnable2.getClass().getSimpleName());
}
public static void main(String[] args) {
Demo demo = new Demo();
demo.test();
//Demo$$Lambda$1
//Demo$$Lambda$2}}Copy the code
So using a lambda expression does not mean that the corresponding implementation class will not be generated. Instead, the generation time is changed to runtime. When a lambda expression is first executed, the JVM dynamically generates the corresponding implementation class in memory and can reuse the class directly when the lambda expression is executed again
The Android Lambda
Now that we know how the native Java platform implements lambda expressions, let’s talk about how the Android platform supports lambda expressions because Android lambda and Java lambda are not the same
Most developers should know that Java-Bytecode (JVM Bytecode) does not run directly on Android, and needs to be converted to Android-Bytecode (Dalvik/ART Bytecode). Dalvik/ART does not support invokedynamic instructions, so Java 8 is not fully supported by the current lower versions of Android system. In addition, we cannot use some APIS, such as localDatetime.now ().
The Android Gradle plugin 3.0.0 and later supports all Java 7 language features, as well as some Java 8 language features (depending on platform version). When building applications using Android Gradle plugin 4.0.0 and later, you can use multiple Java 8 language apis without setting a minimum API level for your application
In order to support Java 8, the Android Gradle plugin currently works by inserting a bytecode conversion operation called desugar, or desugaring, into the D8/R8 compilation of a class file into a dex file
The desugar operation is used to restore some syntax sugars that are not currently supported by the Android system to simple basic syntax structures. For example, lambda expressions are converted to concrete implementation classes after passing through desugar, and the generated implementation classes are written directly to the DEX file, thus ensuring that lambda expressions can run properly on lower-version systems and thus eliminating compatibility issues
To verify this, you can write a simple Android application, write a random lambda expression, decompile the dex file, and see that the lambda expression already exists directly in the compiled dex file as an implementation class
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?). {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val runnable = Runnable { println("leavesC") }
runnable.run()
}
}
Copy the code
Parse Lambda instruction
With the above foundation in mind, we now know what effect desugar will bring to bytecode staking: Since the Transform is executed before desugar, the specific implementation classes of each lambda expression have not been generated at this time, so we cannot directly locate all implementation classes based on the signature information of view. OnClickListener
So, in the following code, the anonymous inner class hooks correctly in the first way, but not in the second, and you have to parse the Invokedynamic instruction to help you determine
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
}
});
view.setOnClickListener(v -> {
});
Copy the code
For the second approach, through the ASM framework, we can obtain information at the bytecode level:
- This method contains a lambda expression, which contains an InvokeDynamic instruction corresponding to the InvokeDynamicInsnNode in ASM
- The Invokedynamic directive contains the signature information of the interface instance to be generated, that is, the InvokeDynamic directive identifies an OnClickListener object and contains an onClick method. You can now find the InvokeDynamicInsnNode associated with the OnClickListener lambda expression by iterating through the InvokeDynamicInsnNode name and desc properties of the global InvokeDynamicInsnNode
- The Invokedynamic instruction points to the BootstapMethod field, which is already marked with three input parameters. The second parameter is a private static method that is automatically generated at compile time and contains the code block that the onClick method should execute. These three parameters correspond to the bsmArgs attribute of InvokeDynamicInsnNode, so bsmArgs gives you the signature information of the method that the onClick method will eventually call, and the hook is implemented by inserting the logic needed into the method
Take a look at the actual coding implementation
Declare two extension methods to filter the InvokeDynamicInsnNode directive, comparing the InvokeDynamicInsnNode name with the desc attribute
fun MethodNode.findHookPointLambda(config: DoubleClickConfig): List<InvokeDynamicInsnNode> {
val onClickListenerLambda = findLambda {
val nodeName = it.name
val nodeDesc = it.desc
val find = config.hookPointList.find { point ->
nodeName == point.methodName && nodeDesc.endsWith(point.interfaceSignSuffix)
}
return@findLambdafind ! =null
}
return onClickListenerLambda
}
private fun MethodNode.findLambda(
filter: (InvokeDynamicInsnNode) - >Boolean
): List<InvokeDynamicInsnNode> {
val handleList = mutableListOf<InvokeDynamicInsnNode>()
valinstructions = instructions? .iterator() ? :return handleList
while (instructions.hasNext()) {
val nextInstruction = instructions.next()
if (nextInstruction is InvokeDynamicInsnNode) {
if (filter(nextInstruction)) {
handleList.add(nextInstruction)
}
}
}
return handleList
}
Copy the code
DoubleClickConfig represents all the configuration information currently used to hook, and HookPoint is used to filter invokedynamic instructions
class DoubleClickConfig(
private val doubleCheckClass: String = "github.leavesc.asm.double_click.ViewDoubleClickCheck".val doubleCheckMethodName: String = "canClick".val doubleCheckMethodDescriptor: String = "(Landroid/view/View;) Z".val hookPointList: List<HookPoint> = extraHookPoints
) {
val formatDoubleCheckClass: String
get() = doubleCheckClass.replace("."."/")}data class HookPoint(
val interfaceName: String,
val methodName: String,
val methodSign: String,
) {
val interfaceSignSuffix = "L$interfaceName;"
}
private val extraHookPoints = listOf(
HookPoint(
interfaceName = "android/view/View\$OnClickListener",
methodName = "onClick",
methodSign = "onClick(Landroid/view/View;) V"))Copy the code
Each time a bytecode is obtained, all the methods are iterated to determine whether the method contains a lambda expression associated with OnClickListener, and if so, the signature information of the static method to which it points is saved. Once you have all the methods you need to hook, insert the ViewDoubleClickCheck anti-shake directive into them
class DoubleClickTransform(private val config: DoubleClickConfig) : BaseTransform() {
override fun modifyClass(byteArray: ByteArray): ByteArray {
val classReader = ClassReader(byteArray)
val classNode = ClassNode()
classReader.accept(classNode, ClassReader.EXPAND_FRAMES)
val methods = classNode.methods
if(! methods.isNullOrEmpty()) {val shouldHookMethodList = mutableSetOf<String>()
for (methodNode in methods) {
// Determine if there are lambda expressions inside the method that need to be processed
val invokeDynamicInsnNodes = methodNode.findHookPointLambda(config)
invokeDynamicInsnNodes.forEach {
val handle = it.bsmArgs[1] as? Handle
if(handle ! =null) {
shouldHookMethodList.add(handle.name + handle.desc)
}
}
}
if (shouldHookMethodList.isNotEmpty()) {
for (methodNode in methods) {
val methodNameWithDesc = methodNode.nameWithDesc
if (shouldHookMethodList.contains(methodNameWithDesc)) {
val argumentTypes = Type.getArgumentTypes(methodNode.desc)
valviewArgumentIndex = argumentTypes? .indexOfFirst { it.descriptor == ViewDescriptor } ?: -1
if (viewArgumentIndex >= 0) {
val instructions = methodNode.instructions
if(instructions ! =null && instructions.size() > 0) {
// Insert ViewDoubleClickCheck
val list = InsnList()
list.add(
VarInsnNode(
Opcodes.ALOAD, getVisitPosition(
argumentTypes,
viewArgumentIndex,
methodNode.isStatic
)
)
)
list.add(
MethodInsnNode(
Opcodes.INVOKESTATIC,
config.formatDoubleCheckClass,
config.doubleCheckMethodName,
config.doubleCheckMethodDescriptor
)
)
val labelNode = LabelNode()
list.add(JumpInsnNode(Opcodes.IFNE, labelNode))
list.add(InsnNode(Opcodes.RETURN))
list.add(labelNode)
instructions.insert(list)
}
}
}
}
val classWriter = ClassWriter(ClassWriter.COMPUTE_MAXS)
classNode.accept(classWriter)
return classWriter.toByteArray()
}
}
return byteArray
}
}
Copy the code
Anonymous inner class
If lambda expressions are not used, the OnClickListener interface implementation Class is generated at compile time, so when the Class object being iterated over implements the OnClickListener interface, the onClick method is obtained
The isHookPoint method is used to determine whether any interface implemented by ClassNode contains OnClickListener, and whether the current MethodNode signature is equal to “onClick(Landroid/view/ view;). V”, so this MethodNode is the target method
class DoubleClickTransform(private val config: DoubleClickConfig) : BaseTransform() {
override fun modifyClass(byteArray: ByteArray): ByteArray {
val classReader = ClassReader(byteArray)
val classNode = ClassNode()
classReader.accept(classNode, ClassReader.EXPAND_FRAMES)
val methods = classNode.methods
if(! methods.isNullOrEmpty()) {val shouldHookMethodList = mutableSetOf<String>()
for (methodNode in methods) {
val methodNameWithDesc = methodNode.nameWithDesc
// Determine whether the current methodNode meets the hook requirements
if (classNode.isHookPoint(config, methodNode)) {
shouldHookMethodList.add(methodNameWithDesc)
continue
}
// Determine if there are lambda expressions inside the method that need to be processed...}if (shouldHookMethodList.isNotEmpty()) {
// Insert ViewDoubleClickCheck...val classWriter = ClassWriter(ClassWriter.COMPUTE_MAXS)
classNode.accept(classWriter)
return classWriter.toByteArray()
}
}
return byteArray
}
}
fun ClassNode.isHookPoint(config: DoubleClickConfig, methodNode: MethodNode): Boolean {
val myInterfaces = interfaces
if (myInterfaces.isNullOrEmpty()) {
return false
}
val extraHookMethodList = config.hookPointList
extraHookMethodList.forEach {
if (myInterfaces.contains(it.interfaceName) && methodNode.nameWithDesc == it.methodSign) {
return true}}return false
}
Copy the code
XML onClick
The onClick attribute declared in the XML is read when the View class parses the AttributeSet, and the View class sets it up with a custom OnClickListener that reflects the handlerName method as a callback
We cannot hook the DeclaredOnClickListener because the Transform does not work on the system source code
The first option is to add a custom annotation to the XML-pointing onClick method, which indicates that the method needs to hook. This is the method used in this article. With this approach, the usage scenario is not limited to XML onClick, but can be applied to any method that matches the signature information
The first step is to declare a custom annotation to add to the method that needs to hook
package github.leavesc.asm.double_click
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class CheckViewOnClick
Copy the code
Then you need to verify that MethodNode’s method signature meets the requirements, that is, it requires an input parameter of type View and a method return value of type void. The target method meets the requirements and contains the CheckViewOnClick annotation
private const val OnClickViewMethodDescriptor = "(Landroid/view/View;) V"
val MethodNode.onlyOneViewParameter: Boolean
get() = desc == OnClickViewMethodDescriptor
private fun MethodNode.hasAnnotation(annotationDesc: String): Boolean {
returnvisibleAnnotations? .find { it.desc == annotationDesc } ! =null
}
fun MethodNode.hasCheckViewAnnotation(config: DoubleClickConfig): Boolean {
return hasAnnotation(config.formatCheckViewOnClickAnnotation)
}
val methodNameWithDesc = methodNode.nameWithDesc
if (methodNode.onlyOneViewParameter) {
if (methodNode.hasCheckViewAnnotation(config)) {
// Add the CheckViewOnClick annotation
shouldHookMethodList.add(methodNameWithDesc)
continue}}Copy the code
On the other hand, there are situations where you don’t want to hook a specific OnClickListener, such as when a business requires multiple quick clicks. You can also define a specific annotation as a whitelist, as in UncheckViewOnClick. See the source link at the end of this article for details
The second option is to hook the AppCompatViewInflater class in the AppCompat package. Currently, most of the activities we use inherit from AppCompatActivity, which can parse a View object through AppCompatViewInflater. Internally we will try to proxy the onClick property and set a custom OnClickListener for the View. Since AppCompatViewInflater is a dependent library, the Transform can scan for AppCompatViewInflater classes by extending the scope. So we hook either the checkOnClickListener method or the DeclaredOnClickListener class
ButterKnife
ButterKnife automatically generates a helper file for each class that uses the @onclick annotation, like the MainActivity_ViewBinding below, And ButterKnife an OnClickListener for each View are framework inside a custom implementation class DebouncingOnClickListener
public final class MainActivity_ViewBinding implements Unbinder {...@UiThread
public MainActivity_ViewBinding(final MainActivity target, View source) {
this.target = target;
···
view.setOnClickListener(new DebouncingOnClickListener() {
@Override
public void doClick(View p0) { target.onClickViewByButterKnife(p0); }}); }}public abstract class DebouncingOnClickListener implements View.OnClickListener {
private static final Runnable ENABLE_AGAIN = () -> enabled = true;
private static final Handler MAIN = new Handler(Looper.getMainLooper());
static boolean enabled = true;
@Override public final void onClick(View v) {
if (enabled) {
enabled = false; MAIN.post(ENABLE_AGAIN); doClick(v); }}public abstract void doClick(View v);
}
Copy the code
There are several options for hooking ButterKnife:
- Locate directly from the @onclick annotation and hook the method containing the annotation as soon as it is resolved
- According to an anonymous inner class DebouncingOnClickListener for positioning. ButterKnife in setting DebouncingOnClickListener without using lambda expressions for the View, so as long as the resolution to the implementation class and then to hook the doClick methods
- Expand the scope of Transform to all dependent libraries. Before two ways need only scan project their own code and code generated by APT, and if the Transform will extend to all dependent libraries, the Transform phase can see DebouncingOnClickListener class, This is the equivalent of dealing with anonymous inner classes
I took the first option, which is the simplest and has the least impact
val methodNameWithDesc = methodNode.nameWithDesc
if (methodNode.onlyOneViewParameter) {
if (methodNode.hasCheckViewAnnotation(config)) {
// Add the CheckViewOnClick annotation
shouldHookMethodList.add(methodNameWithDesc)
continue
} else if (methodNode.hasButterKnifeOnClickAnnotation()) {
// ButterKnife is used and the current method has an OnClick annotation
shouldHookMethodList.add(methodNameWithDesc)
continue}}Copy the code
BaseRecyclerViewAdapterHelper
BaseRecyclerViewAdapterHelper is an encapsulated RecyclerViewAdapter common operations of the three party libraries, OnItemClickListener and onItemChildClickListener can be easily set. It is reasonable to say that an application’s double click anti-shock function should not only apply to a single View, Also need to RecyclerView of each Item for processing
The onItemClickListener and onItemChildClickListener methods are also implemented internally by setting OnClickListener for the View, which can also be handled by extending the scope of the Transform. But to reduce the scope, I hook these two onItem methods
The point is that you also need to consider lambda expressions at this point, which means you need to deal with both types of code:
adapter.onItemClickListener = BaseQuickAdapter.OnItemClickListener { adapter, view, position ->
onClickView()
}
adapter.onItemClickListener = object :BaseQuickAdapter.OnItemClickListener{
override fun onItemClick(adapter: BaseQuickAdapter< * * >? , view:View? , position:Int){}}Copy the code
The overall flow of the two onItem methods, like OnClickListener, depends on whether a lambda expression is used, so simply add two more nodes to extraHookPoints that need to be hooked. Specific signature information is required to view the source code of the open source library
private val extraHookPoints = listOf(
HookPoint(
interfaceName = "android/view/View\$OnClickListener",
methodName = "onClick",
methodSign = "onClick(Landroid/view/View;) V"
),
HookPoint(
interfaceName = "com/chad/library/adapter/base/BaseQuickAdapter\$OnItemClickListener",
methodName = "onItemClick",
methodSign = "onItemClick(Lcom/chad/library/adapter/base/BaseQuickAdapter; Landroid/view/View; I)V"
),
HookPoint(
interfaceName = "com/chad/library/adapter/base/BaseQuickAdapter\$OnItemChildClickListener",
methodName = "onItemChildClick",
methodSign = "onItemChildClick(Lcom/chad/library/adapter/base/BaseQuickAdapter; Landroid/view/View; I)V")),Copy the code
At the end
Finally also give the complete source code: ASM_Transform
This should be a good example for readers to get started with bytecode staking, and readers can also introduce the double click anti-shake function into their own projects after simple modification according to the actual situation of the project