我正在参与「启航方案」

Android 开发者们对 Jetpack Compose 应该现已很熟悉了吧?我在项目中也现已大规模应用了 Jetpack Compose,极大地解放了开发者的心智负担

最近我想要来为项目做一些可以提高 应用功用 或者是 用户体验 的优化项,想了想就敲定了一个方针:经过字节码插桩的方法,来为项目中所有和 Jetpack Compose 相关的事务完成大局的双击防抖功用

在之前,我现已为 Android 的 View 系统完成过相同的功用了:ASM 字节码插桩:完成双击防抖 ,想着在 Jetpack Compose 中应该也差不多,不会太麻烦,可在编码过程中才发现这一个功用并不好做,遇到了一些不太好处理的问题,后面来一一进行讲解

一、基本思路

在 Jetpack Compose 中,咱们一般都是经过 Modifier 的 clickable 或者 combinedClickable 这两个扩展函数来为可组合函数的点击事情设置监听,办法均位于 compose.foundation 库的 Clickable 类中,一共有四个办法可供使用

clickablecombinedClickable 办法均包含了重载函数,差别只在于是否包含 interactionSourceindication 这两个入参参数,重载函数之间还是属于直接调用的联系,所以只需重视第二个和第四个办法即可

fun Modifier.clickable(
  enabled: Boolean = true,
  onClickLabel: String? = null,
  role: Role? = null,
  onClick: () -> Unit
) {
  Modifier.clickable(
    enabled = enabled,
    onClickLabel = onClickLabel,
    onClick = onClick,
    role = role,
    indication = LocalIndication.current,
    interactionSource = remember { MutableInteractionSource() }
   )
}
​
fun Modifier.clickable(
  interactionSource: MutableInteractionSource,
  indication: Indication?,
  enabled: Boolean = true,
  onClickLabel: String? = null,
  role: Role? = null,
  onClick: () -> Unit
) {
  //TODO
}
​
fun Modifier.combinedClickable(
  enabled: Boolean = true,
  onClickLabel: String? = null,
  role: Role? = null,
  onLongClickLabel: String? = null,
  onLongClick: (() -> Unit)? = null,
  onDoubleClick: (() -> Unit)? = null,
  onClick: () -> Unit
){
  Modifier.combinedClickable(
    enabled = enabled,
    onClickLabel = onClickLabel,
    onLongClickLabel = onLongClickLabel,
    onLongClick = onLongClick,
    onDoubleClick = onDoubleClick,
    onClick = onClick,
    role = role,
    indication = LocalIndication.current,
    interactionSource = remember { MutableInteractionSource() }
   )
}
​
fun Modifier.combinedClickable(
  interactionSource: MutableInteractionSource,
  indication: Indication?,
  enabled: Boolean = true,
  onClickLabel: String? = null,
  role: Role? = null,
  onLongClickLabel: String? = null,
  onLongClick: (() -> Unit)? = null,
  onDoubleClick: (() -> Unit)? = null,
  onClick: () -> Unit
){
  //TODO
}

为了完成双击防抖,咱们需求约束 onClick 被重复履行时的最小时刻间隔。此刻最直接的思路,便是引进一个包装类 OnClickWrap,将 onClick 均改为 OnClickWrap,然后在 OnClickWrap 中完成防抖逻辑,挑选性地履行 onClick 办法即可

class OnClickWrap(private val onClick: (() -> Unit)) : Function0<Unit> {
​
  companion object {
​
    private const val MIN_DURATION = 500Lprivate var lastClickTime = 0L
​
   }
​
  override fun invoke() {
    val currentTime = System.currentTimeMillis()
    if (currentTime - lastClickTime > MIN_DURATION) {
      lastClickTime = currentTime
      onClick()
      log("onClick isEnabled : true")
     } else {
      log("onClick isEnabled : false")
     }
   }
​
  private fun log(log: String) {
    Log.e(
      "OnClickWrap",
      "${System.identityHashCode(this)} ${System.identityHashCode(onClick)} $log"
     )
   }
​
}

也便是说,在插桩后,clickablecombinedClickable 这两个办法的伪代码应该如下所示

fun Modifier.clickable(
  interactionSource: MutableInteractionSource,
  indication: Indication?,
  enabled: Boolean = true,
  onClickLabel: String? = null,
  role: Role? = null,
  onClick: () -> Unit
) {
  onClick = OnClickWrap(onClick = onClick)
  //TODO
}
​
fun Modifier.combinedClickable(
  interactionSource: MutableInteractionSource,
  indication: Indication?,
  enabled: Boolean = true,
  onClickLabel: String? = null,
  role: Role? = null,
  onLongClickLabel: String? = null,
  onLongClick: (() -> Unit)? = null,
  onDoubleClick: (() -> Unit)? = null,
  onClick: () -> Unit
){
  onClick = OnClickWrap(onClick = onClick)
  //TODO
}

二、反编译 Clickable 类

有了基本思路后,咱们先经过 ClassVisitor 打印出 Clickable 类中所有办法的签名信息,这样在 Transform 阶段才能知道怎么识别到方针办法

private class ComposeDoubleClickClassVisitor(
  private val nextClassVisitor: ClassVisitor,
  private val classData: ClassData
) : ClassNode(Opcodes.ASM5) {
​
  private companion object {
​
    private const val ComposeClickClassName = "androidx.compose.foundation.ClickableKt"
​
   }
​
  override fun visitEnd() {
    super.visitEnd()
    val className = classData.className
    if (className == ComposeClickClassName) {
      methods.forEach { methodNode ->
        val methodName = methodNode.name
        val methodDesc = methodNode.desc
        LogPrint.log("methodName: $methodName \n methodDesc: $methodDesc")
       }
     }
    accept(nextClassVisitor)
   }
​
}

除去一些无关办法后,令我意外的是,最终输出的办法签名信息居然有八个:四个 clickable 办法、四个 combinedClickable 办法

methodName: clickable-XHw0xAI
methodDesc: (Landroidx/compose/ui/Modifier;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Lkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; 
​
methodName: clickable-XHw0xAI$default 
methodDesc: (Landroidx/compose/ui/Modifier;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; 
​
methodName: clickable-O2vRcR0 
methodDesc: (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/foundation/Indication;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Lkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; 
​
methodName: clickable-O2vRcR0$default 
methodDesc: (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/foundation/Indication;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; 
​
methodName: combinedClickable-cJG_KMw 
methodDesc: (Landroidx/compose/ui/Modifier;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; 
​
methodName: combinedClickable-cJG_KMw$default 
methodDesc: (Landroidx/compose/ui/Modifier;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; 
​
methodName: combinedClickable-XVZzFYc 
methodDesc: (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/foundation/Indication;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; 
​
methodName: combinedClickable-XVZzFYc$default 
methodDesc: (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/foundation/Indication;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; 

将开头的两个办法签名信息转换成易于了解的 Java 代码,便利读者了解

public static final Modifier clickable-XHw0xAI(Modifier modifier, boolean enabled, String onClickLabel,
                        Role role, Function0<Unit> onClick) {
​
}
​
public static Modifier clickable-XHw0xAI$default(Modifier modifier, boolean enabled, String onClickLabel, 
                         Role role, Function0 onClick, int flag, Object object) {
​
}

结合日志信息和伪代码,就可以很容易看出一些小细节了:

  • 第一个办法对应的是 Clickable 类中仅包含四个参数的 clickable 办法。多出一个 Modifier 参数很正常,因为 Kotlin 的扩展函数本质上就相当于 Java 中的静态办法,扩展目标会成为该静态办法的第一个入参参数,大多数开发者应该都知道这一点
  • 八个办法的命名方法显着是带有某种规律:一个 methodName 对应一个 methodName$default。例如:clickable-XHw0xAI 办法就对应着 clickable-XHw0xAI$default 办法
  • 名字带有 $default 的办法,其入参列表的结束都会多出两个参数:ILjava/lang/Object;,也即 Int 类型 和 Object 类型

打印出的日志信息显着和 Clickable 类差别很大,我以为是哪里出问题了,就再尝试着反编译打包进 Apk 中的 Clickable 类的源代码,最终代码总不会哄人

最终,我发现反编译出的源代码中也是有八个相同签名信息的办法,办法的命名规矩和上述彻底一致,而且多出来的 Int 类型参数在办法内部还进行了一系列 & 运算,好像还起到了决议其它入参参数值的作用?

ASM 字节码插桩:Jetpack Compose 实现双击防抖

三、剖析原因

Clickable 类中只声明了四个相关办法,为何这儿找到的又会是八个?多出来的 Int 类型和 Object 类型的入参参数的作用是什么?参数值又依据什么来决议的呢?

先说定论:之所以会多出两个 Int 类型 和 Object 类型的入参参数,是因为扩展函数存在默许参数值导致的,扩展函数需求依托该 Int 值来决议是否应该使用默许参数值

这个定论听着很笼统,不太好了解,这儿来举个小例子

为了便利了解,这儿来模拟 clickable 办法的定义规矩,声明 Modifier 和 Role 两个接口,以及相应的扩展函数

interface Modifier {
​
  companion object {
​
    val INSTANCE = object : Modifier {
​
     }
​
   }
​
}
​
interface Rolefun Modifier.clickable(
  enabled: Boolean = true,
  onClickLabel: String? = null,
  role: Role? = null,
  onClick: () -> Unit
) {
​
}
​
fun clickableDemo() {
  val modifier = Modifier.INSTANCE
  modifier.clickable(enabled = true, onClickLabel = "clickableDemo", role = null) {
​
   }
  modifier.clickable(enabled = true) {
​
   }
  modifier.clickable(onClickLabel = "clickableDemo") {
​
   }
}

将以上代码反编译为 Java 代码,格式化后大致如下所示

public static final void clickable(@NotNull Modifier modifier,
                  boolean enabled,
                  @Nullable String onClickLabel,
                  @Nullable Role role,
                  @NotNull Function0 onClick) {
  Intrinsics.checkNotNullParameter(modifier, "modifier");
  Intrinsics.checkNotNullParameter(onClick, "onClick");
}
​
public static void clickable$default(Modifier modifier,
                   boolean enabled,
                   String onClickLabel,
                   Role role,
                   Function0 onClick,
                   int flag,
                   Object object) {
  if ((flag & 1) != 0) {
    enabled = true;
   }
  if ((flag & 2) != 0) {
    onClickLabel = (String) null;
   }
  if ((flag & 4) != 0) {
    role = (Role) null;
   }
  clickable(modifier, enabled, onClickLabel, role, onClick);
}
​
public static final void clickableDemo() {
  Modifier modifier = Modifier.Companion.getINSTANCE();
  clickable(modifier, true, "clickableDemo", (Role) null, (Function0) null.INSTANCE);
  clickable$default(modifier, true, (String) null, (Role) null, (Function0) null.INSTANCE, 6, (Object) null);
  clickable$default(modifier, false, "clickableDemo", (Role) null, (Function0) null.INSTANCE, 5, (Object) null);
}

以上就可以发现一些和 Clickable 类一致的当地了:

  • 扩展函数最终会对应 clickableclickable$default 这两个静态办法。命名规矩和上面讲的相同
  • clickable 办法比较原扩展函数会多出一个 modifier 参数,clickable$default 办法会多出 modifier、flag、object 三个参数。办法签名信息也和上面讲的相同
  • clickableDemo 办法中,只要后两个办法调用的是 clickable$default 办法,传入的 flag 值别离是 6 和 5,object 值均为 null

多出一个 modifier 参数的原因上面现已讲了,object 值没有使用到,这儿也不理会

那 flag 值的作用是什么呢?值的生成规矩又是什么呢?

其实,flag 值起到的是一个标识作用:因为扩展函数的 enabledonClickLabelrole 这三个参数都包含默许值,所以 Kotlin 就需求有一个标识符用于标识开发者到底有没有自动传入这三个参数值,没有的话就需求去使用默许值

Kotlin 会依据一个二进制数来标识开发者是否有自动传入办法的入参参数:假设这三个参数都有传入,就对应二进制 000;假设只传入了 enabled,就对应二进制 110。也便是说,参数在办法的参数列表中越靠前,在二进制中的方位就越靠后,有传入值的话就用 0 表明,没传入值就用 1 表明

对应上述的三个办法:

  • 第一个办法我传入了所有参数,此刻所有入参参数都不必使用到默许值,因而调用的是 Java 代码中的 clickable 办法
  • 第二个和第三个办法我别离只传入了 enabledonClickLabel,对应的二进制便是 110 和 101,得到的十进制值就别离是 6 和 5 了。clickable$default 办法内部就会依据 & 运算,来判别对应方位的二进制位是否为 1,是 1 的话就说明开发者没有自动传入该参数,此刻就需求将该入参赋值为默许值了

所以说,尽管 Clickable 类只定义了四个和点击事情相关的扩展函数,但因为每个办法均包含默许参数值,所以在编译往后就会变成八个办法,多出来的办法也会多包含 flag 和 object 两个参数,这都是由 Kotlin 扩展函数的完成原理决议的

四、着手插桩

知道了 Kotlin 扩展函数的完成原理以及会带来的影响后,咱们就可以理解 ClassVisitor 输出的办法签名信息并没有错,这些办法对应的便是源码中的那四个扩展函数,咱们仅需求处理那两个不包含 flag 和 object 两个参数的办法即可,后面就可以来进行实际编码了

回忆一开始的思路:将 clickablecombinedClickable 这两个办法的入参参数 onClick 均重新赋值为 OnClickWrap 实例,在 OnClickWrap 中完成防抖逻辑,然后挑选性地履行 onClick 办法

package github.leavesczy.asm.doubleClick.compose
​
class OnClickWrap(private val onClick: (() -> Unit)) : Function0<Unit> {
​
  companion object {
​
    private const val MIN_DURATION = 500Lprivate var lastClickTime = 0L
​
   }
​
  override fun invoke() {
    val currentTime = System.currentTimeMillis()
    if (currentTime - lastClickTime > MIN_DURATION) {
      lastClickTime = currentTime
      onClick()
      log("onClick isEnabled : true")
     } else {
      log("onClick isEnabled : false")
     }
   }
​
  private fun log(log: String) {
    Log.e(
      "OnClickWrap",
      "${System.identityHashCode(this)} ${System.identityHashCode(onClick)} $log"
     )
   }
​
}

关于 ClassVisitor 来说,Class 文件中的每一个办法均会对应一个 MethodNode,咱们可以经过对比 MethodNode 的签名信息 desc 来识别到 clickablecombinedClickable 这两个办法。在编译往后,这两个办法的 onClick 参数在所有入参参数中的索引别离是 6 和 9,咱们经过该索引就可以拿到入参值并将之重新赋值为 OnClickWrap 实例

对应以下代码:

methods.forEach { methodNode ->
  val methodName = methodNode.name
  val methodDesc = methodNode.desc
  LogPrint.log("methodName: $methodName \n methodDesc: $methodDesc")
  val onClickArgumentIndex = when (methodDesc) {
    ClickableMethodDesc -> {
      6
     }
    CombinedClickableMethodDesc -> {
      9
     }
    else -> {
      -1
     }
   }
  if (onClickArgumentIndex > 0) {
    val instructions = methodNode.instructions
    val input = InsnList()
    input.add(
      TypeInsnNode(
        Opcodes.NEW,
        "github/leavesczy/asm/doubleClick/compose/OnClickWrap"
       )
     )
    input.add(InsnNode(Opcodes.DUP))
    input.add(VarInsnNode(Opcodes.ALOAD, onClickArgumentIndex))
    input.add(
      MethodInsnNode(
        Opcodes.INVOKESPECIAL,
        "github/leavesczy/asm/doubleClick/compose/OnClickWrap",
        "<init>",
        "(Lkotlin/jvm/functions/Function0;)V",
        false
       )
     )
    input.add(VarInsnNode(Opcodes.ASTORE, onClickArgumentIndex))
    instructions.insert(input)
   }
}

还有个问题需求重视:因为上述代码是直接对 Clickable 类进行插桩,所以咱们项目中所有使用到 clickablecombinedClickable 这两个办法的可组合函数都会遭到影响,但未必所有当地都需求完成防抖,为了灵活性考虑,咱们需求完成一个白名单机制,在白名单内的点击事情则不进行检查

这儿我经过判别 onClickLabel 的值是否是 noCheck 来决议是否要启用双击防抖功用,伪代码如下所示

fun Modifier.clickable(
  interactionSource: MutableInteractionSource,
  indication: Indication?,
  enabled: Boolean = true,
  onClickLabel: String? = null,
  role: Role? = null,
  onClick: () -> Unit
) {
  if (onClickLabel != "noCheck") {
    onClick = OnClickWrap(onClick)
   }
  //TODO
}

对应的插桩代码:

if (onClickArgumentIndex > 0) {
  val onClickLabelArgumentIndex = 4
  val input = InsnList()
  input.add(LdcInsnNode("noCheck"))
  input.add(VarInsnNode(Opcodes.ALOAD, onClickLabelArgumentIndex))
  input.add(
    MethodInsnNode(
      Opcodes.INVOKEVIRTUAL,
      "java/lang/String",
      "equals",
      "(Ljava/lang/Object;)Z",
      false
     )
   )
  val label = LabelNode()
  input.add(JumpInsnNode(Opcodes.IFNE, label))
  input.add(
    TypeInsnNode(
      Opcodes.NEW,
      "github/leavesczy/asm/doubleClick/compose/OnClickWrap"
     )
   )
  input.add(InsnNode(Opcodes.DUP))
  input.add(VarInsnNode(Opcodes.ALOAD, onClickArgumentIndex))
  input.add(
    MethodInsnNode(
      Opcodes.INVOKESPECIAL,
      "github/leavesczy/asm/doubleClick/compose/OnClickWrap",
      "<init>",
      "(Lkotlin/jvm/functions/Function0;)V",
      false
     )
   )
  input.add(VarInsnNode(Opcodes.ASTORE, onClickArgumentIndex))
  input.add(label)
  methodNode.instructions.insert(input)
}

最终的防抖作用就可以很显着的看出来,当快速点击有启用双击防抖功用的控件时,index 值的递加速度显着慢于不防抖的控件

ASM 字节码插桩:Jetpack Compose 实现双击防抖

五、还留传的问题

以上的作用看着还可以,但其实并不完善,还留传着几个问题

作用域不可控

因为我是直接修正 Clickable 类,因而防抖逻辑除了会作用于项目主体外,还包含其它任何使用了 Clickable 类的依赖库,然后导致咱们无法自在控制防抖逻辑的作用域

白名单机制不完善

假设咱们在代码中都是经过直接调用clickablecombinedClickable 这两个办法来监听点击事情的话,那经过判别 onClickLabel 的值来完成白名单功用是彻底可行的,但关于 Jetpack Compose 提供的一些封装了点击事情的控件就不适用了。例如 Button 和 TextButton 内部都封装了 clickable 办法,但没有开放设置 onClickLabel 值的进口,所以这类控件就无法加入白名单了

防抖逻辑会相相互关

进行双击防抖的时分,防抖逻辑可以分为两种:

  • 每个控件之间的防抖逻辑是相相互关的。也便是所,假设别离快速点击两个不同的控件,那么只要第一个点击事情会收效
  • 每个控件之间的防抖逻辑是相互独立的。也便是说,假设别离快速点击两个不同的控件,那么这两个点击事情均会收效

在上文中躲藏了一个小细节:OnClickWrap 中定义的 lastClickTime 是静态变量。这就会导致不同的点击事情源会同享 lastClickTime 值且同时遭到该值的约束,因而本文完成的作用是第一种。假设声明为非静态变量,吗对应的便是第二种逻辑

而我之所以不将lastClickTime 声明为非静态变量,是因为被可组合函数的 重组 约束了:假设点击事情会造成对应的可组合函数重组,那么将 onClick 重置为 OnClickWrap 实例的代码将被再一次履行,此刻声明为非静态变量的 lastClickTime 就又变成了默许值 0,然后也就无法完成双击防抖的作用了

六、结束

想要处理以上留传的问题,也许需求将思路改为从调用方进行插桩才能处理,但插桩逻辑就会变得很复杂,我现在还不知道该怎么完成。期望本文能抛砖引玉,读者看完后能提出更好的完成方法

相关的代码我都上传到了 Github:ASM_Transform

期望对你有所协助