讲讲android埋点那些事

起因是想系统的学习下asm相关的语法的,中间看到有asm全埋点实战这本书和神策开源的埋点项目,想着不如用一篇文章总结下我对于android埋点相关的一些理解,这篇文章的后半部分会详细的分析下神策开源的埋点项目的相关细节实现。

埋点的作用其实是有人想获得应用运行中的一些信息,这里的角色可能是开发,可能是产品,也可能是老板;这里的信息可能是应用运行的业务信息,性能信息等等;埋点信息需要经过一系列复杂的操作,转化为更易使用的信息类型,帮助我们进行技术或业务上的分析,在这个过程中,android客户端只是其中的一个小角色,但是我们也试图以小窥大,尝试从我们推导出整个数据分析的全流程版图。

其实数据分析的全流程可以划分为几个关键的节点:

  1. 数据采集。
  2. 数据传输。
  3. 数据清洗与存储。
  4. 数据可视化展示。
  5. 数据分析。

下面讲下我对每个节点的理解。

1.数据采集

首先是要先通过埋点产生数据,那么业界也有很多种埋点的技术手段,以应对不同的场景。

手动埋点

这个我们公司有用到友盟的产品,这个就很好理解了,就是在我们需要记录信息的地方,调用一个埋点api,把需要记录的信息存储起来,留到后面使用。 这个方案的好处是可以记录到最全的信息,而且对于一些关键信息,比如支付场景如订单金额,折扣情况,每个用户都不一样。而且这个方案也是最灵活的,想记哪里记哪里。

这个方案的坏处就是每次都需要加,而且只能开发加,成本就高了,而且还需要维护。

这个方案肯定是有其价值的,而且不仅客户端用,服务端更要大大的用,因为服务端可以记录的信息更多,比如数据库中的一些关键信息,服务端日志库也有许多成熟的,比如log4j等等,也有第三方的可以使用。

可视化埋点

这个方案也很好理解,就是把标记埋点的工作用开发转移到类似产品,运营去做。类似一些低代码,可视化组装页面等的思路。

这里说下基本的原理和过程:

  1. 客户端在页面加载后(如onCreate之后会初始化DecorView),通过解析控件树的方式将页面view传输到服务端。
  2. 服务端拿到数据后,前端将数据反解析,在web页面重新渲染出虚拟手机的界面。
  3. 产品或运营对界面上的元素进行手动标记,即埋点的过程。埋点数据会存储到服务端。
  4. 客户端会在下一次运行时读取埋点的配置信息,在用户点击相应控件时触发埋点事件上报。

对于可视化埋点的具体细节可以参考以下资料:

全埋点

全埋点也叫无埋点,这种方案是在应用运行的特定场景自动采集埋点,这种方式的自动化程度最高,无需进行手动埋点,但这也造成了这种方案的灵活性较低,只能采集一些简单的信息数据,无法采集到更细节的业务数据。

一般全埋点的使用场景有:

  1. 应用启动,退出(crash,主动退出,lmk强杀等)。
  2. 应用页面生命周期记录。
  3. 应用页面控件点击。
  4. 性能数据:cpu,内存,帧率,网络请求质量等。

我们后面重点分析的就是这个方案中的应用页面控件点击事件采集,详见后面神策埋点开源项目分析部分。

2.数据传输

埋点数据格式

通过前一步已经采集到了相应的数据埋点,这里简单讲下埋点的数据格式:

  • 默认字段:采集的一些通用字段,如设备id或cookie(用来标识唯一用户),设备的型号,操作系统的版本号等。
  • 事件字段:最开始是事件的key,后面是事件的信息字段,这里不同事件也可以抽取一些公共字段出来,方便后端进行统一的数据处理。

对于存储的数据格式,这里有几点需要注意:

  1. 埋点的数据格式对应于数据分析的Event+User模型,这样可以按照不同的用户和事件维度进行下钻。
  2. 对于不同端的同一用户,对应的用户id标识可能不同,这里我们采用业内通用的ID-mapping进行全端用户打通。

埋点数据存储

埋点数据的存储其实也是很有讲究,因为我们产生的数据量是非常大的,像我们安卓应用的数据每天产生约1.2亿条日志数据,需要对数据做一些优化,否则对于存储和传输压力都比较大。

埋点数据存储有两种方案:

  • 数据库(sqlite)。
    这种方案适合数据量比较小的情况,因为数据库存储的成本还是比较高的,比如友盟这种手动埋点的方案一般将数据存入数据库中。 存入数据库中的一个好处是数据的处理比较灵活,可以自由选择相关的策略进行上传,比如上传某一时间段的数据,上传失败后也可以针对失败的数据进行回滚。
  • 文件。 这种方案适合数据量比较大的情况,可以将日志数据进行压缩加密后按照二进制存储取得更高的压缩率。这里一般会采用一些高性能日志的方案,可以自己实现,也可以参考一些开源的方案。这里我们公司采用的是二次开发wechat的xlog。
    采用文件存储的一个坏处是上传可能不那么灵活,因为按文件维度要比按数据库维度要大,这里我们尽量将文件切小分片上传,这样失败后也方便进行重传。

埋点数据传输

埋点数据传输基本上采用https协议栈即可,在上传时注意尽量不要使用应用基础网络库组件,避免日志记录和上传循环调用。另外可以约定一些上传的策略,比如前后台上传的间隔可以不一样,尽量平衡性能和上传时效的平衡。

3. 数据清洗与存储。

这里主要是客户端上传数据后,服务端所做的一些工作,这一部分我没有过多的研究,在这里略述一二。

  • 数据接收:使用nginx等服务器接收。
  • 数据流处理:kafka等,作为数据接入和数据处理两个流程之间的缓冲。
  • 数据存储:使用hdfs或clickhouse等。
  • 数据查询:主要是使用sql进行查询,可以辅助开发一些前台看板来可视化查询,我们使用的是grafana。

4. 数据可视化展示。

可视化展示没有很固定的思路,主要就是画图,满足我们后面数据分析的需要或是满足可观测性,可以让我们自动化进行业务ops即可。

这里我使用应用全埋点相关的场景举几个例子,代码相关的解析详见后面神策埋点开源项目分析部分。

  • 应用控件点击采集。 将采集到的应用控件点击数据,以页面为维度进行聚类,简单的可以绘制页面控件点击百分比扇形图,复杂的可以参考前面可视化埋点的思路,绘制页面区域点击数热力图,这样产品或运行可以根据不同区域点击的占比调整关键业务的位置。

  • 应用页面生命周期事件采集。 可以将一个业务的页面流转绘制成桑基图形式展现,以目标事件为起点终点进行用户行为路径分析,可以对业务的转化进行分析改善。

5.数据分析

这里我觉得是埋点数据真正起作用的地方,只有对数据进行分析,才能对业务产生正向反馈,但这一部分也恰恰是我的短板,这里分享我在学习数据分析过程中的一些总结。

这里的数据分析既包含传统基于统计学的一些数据分析方法,也有基于用户画像的推荐,机器学习和ai的一些新型数据分析方法。

  • 行为事件分析模型
    这个模型是最贴近我们采集的原始数据的模型,因为我们采集的单条数据就是一个Event。
    行为事件分析一般有以下几个阶段。
  1. 事件定义与选择。
    采集信息包含:who(通过userId,设备id等标识唯一用户),when(记录事件发生的时间戳),where(事件发生的地点,这个可以是客户端将用户ip上报,服务端进行地址库解析),how(产生事件的来源,如端标识等),what(记录事件的key和事件数据)。
  2. 多维度下钻分析。
    这里我们的事件分析系统要支持任意下钻分析和精细化条件筛选。
  3. 解释与结论。
  • 漏斗分析模型
    该模型可以反映用户行为状态以及从起点到终点各阶段用户转化率情况。 可以通过对比不同条件下的转化率,以及进行a/b test进行线上的转化率调优测试。
  • 留存分析模型
  • 用户路径分析模型
    这个和漏斗分析模型比较像,只是它的分布是多对多的。
  • 分群分析模型
    该模型区别于传统数据分析模型,采用用户画像等算法对用户进行聚类,进行精细化的推送和召回等操作。

掌握了上述的事件分析模型,进行简单的数据分析时,可以直接使用系统进行可视化的查询,但在进行一些复杂的数据查询或绘制看板时,作为一个android客户端开发,也需要掌握一定的sql能力。

神策埋点开源项目分析

埋点sdk:github.com/sensorsdata...

埋点插件sdk:github.com/sensorsdata...

这里我们可能需要一些编写gradle插件的前置基础知识,大多数读者应该都已掌握。需要注意的是gradle在7.3之后将transform修改class的方式标记为过时,这里我们需要用gradle提供的新api,在复杂情况下,我们可能还需要使用task来辅助解决。

kotlin 复制代码
class V73Impl(project: Project, override val asmWrapperFactory: AsmCompatFactory) :
    AGPCompatInterface {
    
    init {
        val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java)
        V73AGPContextImpl.asmCompatFactory = asmWrapperFactory
        androidComponents.onVariants { variant: Variant ->
            variant.instrumentation.transformClassesWith(
                SensorsDataAsmClassVisitorFactory::class.java,
                InstrumentationScope.ALL
            ) {
                ...
            }
            variant.instrumentation
                .setAsmFramesComputationMode(FramesComputationMode.COPY_FRAMES)
        }
    }

}

可以看出来,新的api也是固定套路,先获取到AndroidComponentsExtension这个扩展,然后在每个变体上注册一个转换类,这里是SensorsDataAsmClassVisitorFactory,最后的lambda块里可以注入一些配置。

InstrumentationScope.ALL表示我们分析的代码是整个工程的,包含自己的工程和第三方的库。

这里我们可能还需要一些asm的基础知识,读者肯定也学习过了,那么接着往下看:

ClassVisitor:

kotlin 复制代码
abstract class SensorsDataAsmClassVisitorFactory :
    AsmClassVisitorFactory<ConfigInstrumentParams> {

    override fun createClassVisitor(
        classContext: ClassContext,
        nextClassVisitor: ClassVisitor
    ): ClassVisitor {
        V73AGPContextImpl.asmCompatFactory!!.onBeforeTransform()
        val classInheritance = object : ClassInheritance {
            override fun isAssignableFrom(subClass: String, superClass: String): Boolean {
                return classContext.loadClassData(subClass)?.let {
                    it.className == superClass || it.superClasses.contains(superClass) || it.interfaces.contains(superClass)
                } ?: false
            }

            override fun loadClass(className: String): ClassInfo? {
                return classContext.loadClassData(className)?.let {
                    ClassInfo(
                        it.className,
                        interfaces = it.interfaces,
                        superClasses = it.superClasses
                    )
                }
            }
        }

        return V73AGPContextImpl.asmCompatFactory!!.transform(
            nextClassVisitor, classInheritance
        )
    }

    override fun isInstrumentable(classData: ClassData): Boolean {
        return V73AGPContextImpl.asmCompatFactory!!.isInstrumentable(
            ClassInfo(
                classData.className,
                interfaces = classData.interfaces,
                superClasses = classData.superClasses
            )
        )
    }
}

实现AsmClassVisitorFactory接口即可,可以看到对于asm的兼容还是非常方便的,只需要实现具体埋点的ClassVisitor即可。对于该项目是SAPrimaryClassVisitor。

按顺序分析一下SAPrimaryClassVisitor。

访问类

kotlin 复制代码
override fun visit(
        version: Int,
        access: Int,
        name: String,
        signature: String?,
        superName: String?,
        interfaces: Array<out String>?
    ) {
        super.visit(version, access, name, signature, superName, interfaces)
        classNameAnalytics = ClassNameAnalytics(name, superName, interfaces?.asList())
        shouldReturnJSRAdapter = version <= Opcodes.V1_5
        configHookHelper.initConfigCellInClass(name)
    }

类相关的元信息存储在classNameAnalytics中。

SAConfigHookHelper是插件提供的可以通过配置删除一些方法调用的功能。

类中方法调用
kotlin 复制代码
override fun visitMethod(
        access: Int,
        name: String?,
        descriptor: String?,
        signature: String?,
        exceptions: Array<String>?
    ): MethodVisitor? { 
        ...
        //创建一系列MethodVisitor
    }
kotlin 复制代码
//check whether need to delete this method. if the method is deleted,
//a new method will be created at visitEnd()
if (configHookHelper.isConfigsMethod(name, descriptor)) {
    return null
}

命中配置,有需要删除的方法调用,记录到SAConfigHookHelper的mHookMethodCells里。

kotlin 复制代码
if (classNameAnalytics.superClass == "android/app/Activity"
    && name == "onNewIntent" && descriptor == "(Landroid/content/Intent;)V"
) {
    isFoundOnNewIntent = true
}

命中onNewIntent方法。

下面就是创建对应的MethodVisitor,这个稍后分析。

访问类结束
kotlin 复制代码
override fun visitEnd() {
        super.visitEnd()

        //给 Activity 添加 onNewIntent,满足 push 业务需求
        if (pluginManager.isModuleEnable(SAModule.PUSH)
            && !isFoundOnNewIntent
            && classNameAnalytics.superClass == "android/app/Activity"
        ) {
            SensorsPushInjected.addOnNewIntent(classVisitor)
        }

        //为 Fragment 添加方法,满足生命周期定义
        if (pluginManager.isModuleEnable(SAModule.AUTOTRACK)) {
            FragmentHookHelper.hookFragment(
                classVisitor,
                classNameAnalytics.superClass,
                visitedFragMethods
            )
        }

        //添加需要置空的方法
        configHookHelper.disableIdentifierMethod(classVisitor)
    }

这里做了三件事:

1.如果Activity没有实现onNewIntent方法,给 Activity 添加 onNewIntent方法。

kotlin 复制代码
 fun addOnNewIntent(classVisitor: ClassVisitor) {
        val mv = classVisitor.visitMethod(
            Opcodes.ACC_PROTECTED,
            "onNewIntent",
            "(Landroid/content/Intent;)V",
            null,
            null
        )
        mv.visitAnnotation("Lcom/sensorsdata/analytics/android/sdk/SensorsDataInstrumented;", false)
        mv.visitCode()
        mv.visitVarInsn(Opcodes.ALOAD, 0)
        mv.visitVarInsn(Opcodes.ALOAD, 1)
        mv.visitMethodInsn(
            Opcodes.INVOKESPECIAL,
            "android/app/Activity",
            "onNewIntent",
            "(Landroid/content/Intent;)V",
            false
        )
        mv.visitVarInsn(Opcodes.ALOAD, 0)
        mv.visitVarInsn(Opcodes.ALOAD, 1)
        mv.visitMethodInsn(
            Opcodes.INVOKESTATIC,
            PUSH_TRACK_OWNER,
            "onNewIntent",
            "(Ljava/lang/Object;Landroid/content/Intent;)V",
            false
        )
        mv.visitInsn(Opcodes.RETURN)
        mv.visitMaxs(2, 2)
        mv.visitEnd()
    }

通过这段逻辑可以学习怎样新增方法。

2.Fragment生命周期方法插桩

主要是在Fragment生命周期中插入FragmentTrackHelper的相关调用。

这里有一个小技巧是,框架将方法的调用封装了一下,就不用写很多模板指令方法了。

kotlin 复制代码
// call super
methodCell.visitMethod(mv, Opcodes.INVOKESPECIAL, superName!!)
// call injected method
methodCell.visitHookMethod(
                    mv,
                    Opcodes.INVOKESTATIC,
                    SensorsFragmentHookConfig.SENSORS_FRAGMENT_TRACK_HELPER_API
)

这里将调用super和插桩方法封装了起来。

3.清空记录到SAConfigHookHelper的mHookMethodCells里的方法体。

方法内插桩(点击事件插桩实现)

我们关注的点击事件插桩还是在方法体的访问中,还是回到visitMethod方法中,这里创建了一系列嵌套的MethodVisitor,我们从外向内分析。

MethodVisitor的调用顺序如下:

scss 复制代码
(visitParameter)*
[visitAnnotationDefault]
(visitAnnotation | visitAnnotableParameterCount | visitParameterAnnotation | visitTypeAnnotation | visitAttribute)*
[    visitCode    (        visitFrame |        visitXxxInsn |        visitLabel |        visitInsnAnnotation |        visitTryCatchBlock |        visitTryCatchAnnotation |        visitLocalVariable |        visitLocalVariableAnnotation |        visitLineNumber    )*    visitMaxs]
visitEnd
1.UpdateSDKPluginVersionMV
kotlin 复制代码
override fun visitFieldInsn(opcode: Int, owner: String, fieldName: String, descriptor: String) {
        if (mClassNameAnalytics.isSensorsDataAPI && "ANDROID_PLUGIN_VERSION" == fieldName && opcode == PUTSTATIC) {
            mMethodVisitor.visitLdcInsn(VersionConstant.VERSION)
        }
        super.visitFieldInsn(opcode, owner, fieldName, descriptor)
    }

这个类的作用是当应用设置SensorsDataAPI的ANDROID_PLUGIN_VERSION字段时,将当前版本号放在操作数栈顶,再执行该指令,即完成了替换。

2.SensorsAutoTrackMethodVisitor

这个类真正实现了点击事件插桩的功能,是我们重点分析的对象。

kotlin 复制代码
class SensorsAutoTrackMethodVisitor(
    mv: MethodVisitor,
    methodAccess: Int,
    methodName: String,
    var desc: String,
    private val classNameAnalytics: ClassNameAnalytics,
    private val visitedFragMethods: MutableSet<String>,
    lambdaMethodCells: MutableMap<String, SensorsAnalyticsMethodCell>,
    private val pluginManager: SAPluginManager
) : AdviceAdapter(
    pluginManager.getASMVersion(), mv,
    methodAccess,
    methodName,
    desc
)

可以看到这里继承的是AdviceAdapter,在前面说的调用顺序之外,还增加了两个方法:

kotlin 复制代码
public override fun onMethodEnter() {}
public override fun onMethodExit(opcode: Int) {}

这两个方法分别在方法调用的开头和结束调用,方便我们织入自己的自定义代码。

我们按照asm遍历的顺序去解析这个类。

(1)遍历注解
kotlin 复制代码
override fun visitAnnotation(s: String, b: Boolean): AnnotationVisitor {
        if (s == "Lcom/sensorsdata/analytics/android/sdk/SensorsDataTrackViewOnClick;") {
            isSensorsDataTrackViewOnClickAnnotation = true
        } else if (s == "Lcom/sensorsdata/analytics/android/sdk/SensorsDataIgnoreTrackOnClick;") {
            isSensorsDataIgnoreTrackOnClick = true
        } else if (s == "Lcom/sensorsdata/analytics/android/sdk/SensorsDataInstrumented;") {
            isHasInstrumented = true
        } else if (s == "Lcom/sensorsdata/analytics/android/sdk/SensorsDataTrackEvent;") {
            return object : AnnotationVisitor(pluginManager.getASMVersion()) {
                override fun visit(key: String, value: Any) {
                    super.visit(key, value)
                    if ("eventName" == key) {
                        eventName = value as String
                    } else if ("properties" == key) {
                        eventProperties = value.toString()
                    }
                }
            }
        }
        return super.visitAnnotation(s, b)
    }

由于我们识别点击的主要策略是查找调用setOnClickListener方法,而使用一些框架时,可能不叫这个名字,比如使用butterknife,databinding时,需要手动在这些点击方法上进行注解,这样才能被识别插桩。

这里将注解中的信息提取出来。

(2)方法入口点
kotlin 复制代码
public override fun onMethodEnter() {
        super.onMethodEnter()
        pubAndNoStaticAccess =
            SAUtils.isPublic(access) && !SAUtils.isStatic(
                access
            )
        protectedAndNotStaticAccess =
            SAUtils.isProtected(access) && !SAUtils.isStatic(
                access
            )
        if (pubAndNoStaticAccess) {
            if (nameDesc == "onClick(Landroid/view/View;)V") {
                isOnClickMethod = true
                variableID = newLocal(Type.getObjectType("java/lang/Integer"))
                mMethodVisitor.visitVarInsn(ALOAD, 1)
                mMethodVisitor.visitVarInsn(ASTORE, variableID)
            } else { ... }
        } else if (protectedAndNotStaticAccess) {
            if (nameDesc == "onListItemClick(Landroid/widget/ListView;Landroid/view/View;IJ)V") {
                localIds = ArrayList()
                val firstLocalId = newLocal(Type.getObjectType("java/lang/Object"))
                mMethodVisitor.visitVarInsn(ALOAD, 1)
                mMethodVisitor.visitVarInsn(ASTORE, firstLocalId)
                localIds!!.add(firstLocalId)
                val secondLocalId = newLocal(Type.getObjectType("android/view/View"))
                mMethodVisitor.visitVarInsn(ALOAD, 2)
                mMethodVisitor.visitVarInsn(ASTORE, secondLocalId)
                localIds!!.add(secondLocalId)
                val thirdLocalId = newLocal(Type.INT_TYPE)
                mMethodVisitor.visitVarInsn(ILOAD, 3)
                mMethodVisitor.visitVarInsn(ISTORE, thirdLocalId)
                localIds!!.add(thirdLocalId)
            }
        }

        ...
        if (pluginManager.isHookOnMethodEnter) {
            handleCode()
        }
    }

对方法调用中,和点击事件相关的进行处理,例如最普遍的定义一个点击事件:

java 复制代码
private void initButton() {
        findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {

            }
        });
    }

这里在public,nostatic分支中,找到onClick方法,这里插件将View参数存放到一个新开辟的局部变量表空间中。

kotlin 复制代码
isOnClickMethod = true
variableID = newLocal(Type.getObjectType("java/lang/Integer"))
mMethodVisitor.visitVarInsn(ALOAD, 1)
mMethodVisitor.visitVarInsn(ASTORE, variableID)

其他分支的处理都大同小异,类似匹配AdapterView的item点击事件。

因为在实际的项目中,多次遇到参数类型被优化的现象,所以采取的方式是在 onMethodEnter 的时候进行相关参数的保存,以便插入代码的时候正确读取使用。

这里需要重点关注一种情况,lambda对于方法插桩的影响。

D8/R8会对lambda语法进行脱糖处理,这里看一个java lambda表达式的例子:

原始代码:

java 复制代码
public class Java8 {

    interface Logger {
        void log(String s);
    }

    public static void main(String... args) {
        test(s -> System.out.println(s))
    }

    private static void test(Logger logger) {
        logger.log("hello")
    }

}

脱糖后的代码:

java 复制代码
public class Java8 {

    interface Logger {
        void log(String s);
    }

    public static void main(String... args) {
        test(s -> new Java8$1())
    }

    //方法体中的内容移到这里  
    static void lambda$main$0(String str) {
        System.out.println(str)
    }  

    private static void test(Logger logger) {
        logger.log("hello")
    }

}

public class Java8$1 implements Java8.Logger {
    public Java8$1() {}    

    @Override
    public void log(String s) {
            Java8.lambda$main$0(s);
    }

}

脱糖后,生成了一个实现该接口的类,类调用的方法体为lambda块内的代码。

可以看出,lambda <math xmlns="http://www.w3.org/1998/Math/MathML"> m a i n main </math>main0是一个在运行时生成的方法,在编译时是不存在的,对应的字节码是invokedynamic。

invokedynamic指令

invokedynamic指令在jdk7引入,用于实现动态类型语言功能。

和该指令相关的jdk类有:

  1. MethodType
java 复制代码
public static MethodType methodType(Class<?> rtype, Class<?>[] ptypes) {
    return makeImpl(rtype, ptypes, false);
}

MethodType代表一个方法所需的返回值类型和所有参数类型。

  1. MethodHandle

MethodHandle是方法句柄,MethodHandle根据类名,方法名,以及MethodType查找到特定方法并执行。

java 复制代码
@RequiresApi(api = Build.VERSION_CODES.O)
    public void foo(Context context) {
        try {
            MethodType methodType = MethodType.methodType(String.class, int.class);
            MethodHandles.Lookup lookup = MethodHandles.lookup();
            MethodHandle methodHandle = lookup.findStatic(String.class, "valueOf", methodType);
            String result = (String) methodHandle.invoke(99);
            ToastUtil.showLong(context, result);
        } catch (Throwable e) {
            e.printStackTrace();
        }
    }

这里提供一个通过MethodHandle动态执行String.valueOf方法的例子。

  1. CallSite

CallSite是方法调用点,调用点中包含了方法句柄信息,CallSite对MethodHandle进行链接,这可能有些抽象。

下面看一个invokedynamic的例子:

java 复制代码
import java.util.Date;
import java.util.function.Consumer;

public class TestLambda {

    public void test() {
        final Date date = new Date();
        Consumer<String> consumer = s -> {
            System.out.println(s+ date);
        };
    }

}

使用javap观察字节码:

bytecode 复制代码
public void test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #2                  // class java/util/Date
         3: dup
         4: invokespecial #3                  // Method java/util/Date."<init>":()V
         7: astore_1
         8: aload_1
         9: invokedynamic #4,  0              // InvokeDynamic #0:accept:(Ljava/util/Date;)Ljava/util/function/Consumer;
        14: astore_2
        15: return
      LineNumberTable:
        line 9: 0
        line 10: 8
        line 13: 15

这里调用了invokedynamic指令,0是预留字段,#4是常量池字段。

bytecode 复制代码
 #4 = InvokeDynamic      #0:#30         // #0:accept:(Ljava/util/Date;)Ljava/util/function/Consumer;

这里的#0表示是第一个引导方法,引导方法指向lambda脱糖后的代码,该方法是运行时动态生成的:

bytecode 复制代码
BootstrapMethods:
  0: #26 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #27 (Ljava/lang/Object;)V
      #28 invokestatic com/sensorsdata/sdk/demo/TestLambda.lambda$test$0:(Ljava/util/Date;Ljava/lang/String;)V
      #29 (Ljava/lang/String;)V

实际调用的是LambdaMetafactory.metafactory方法。

java 复制代码
public static CallSite metafactory(MethodHandles.Lookup caller,
                                       String invokedName,
                                       MethodType invokedType,
                                       MethodType samMethodType,
                                       MethodHandle implMethod,
                                       MethodType instantiatedMethodType)

这里重点看后三个参数,前三个是固定系统生成的:

bytecode 复制代码
    Method arguments:
      #27 (Ljava/lang/Object;)V
      #28 invokestatic com/sensorsdata/sdk/demo/TestLambda.lambda$test$0:(Ljava/util/Date;Ljava/lang/String;)V
      #29 (Ljava/lang/String;)V

samMethodType:函数式接口中抽象方法的签名信息,这里对应Consumer接口的accept方法,由于泛型参数擦除,这里是Object。

implMethod:脱糖后实际生成的静态方法,可以看到,这里生成的方法有两个参数,这是因为lambda引用了lambda块外的final变量date。

bytecode 复制代码
  private static void lambda$test$0(java.util.Date, java.lang.String);
    descriptor: (Ljava/util/Date;Ljava/lang/String;)V
    flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=3, locals=2, args_size=2
         0: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: new           #6                  // class java/lang/StringBuilder
         6: dup
         7: invokespecial #7                  // Method java/lang/StringBuilder."<init>":()V
        10: aload_1
        11: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        14: aload_0
        15: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
        18: invokevirtual #10                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        21: invokevirtual #11                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        24: return

instantiatedMethodType:samMethodType的实际类型,这里泛型参数还原为String。

invokedynamic插桩

有了上述的基础知识,我们能够对invokedynamic插桩进行分析。

使用一个简单的点击事件为样本进行分析:

java 复制代码
private void initLambdaButton() {
    Button button = (Button) findViewById(R.id.lambdaButton);
    button.setOnClickListener(v -> {

    });
}

框架预先增加了一个点击事件的lambda配置:

kotlin 复制代码
addLambdaMethod(
    SensorsAnalyticsMethodCell(
        "onClick",
        "(Landroid/view/View;)V",
        "Landroid/view/View$OnClickListener;",
        "trackViewOnClick",
        "(Landroid/view/View;)V",
        1, 1,
        listOf(Opcodes.ALOAD)
    )
)

之后对invokedynamic指令进行拦截:

kotlin 复制代码
override fun visitInvokeDynamicInsn(
    name1: String,
    desc1: String,
    bsm: Handle,
    vararg bsmArgs: Any
) {
    super.visitInvokeDynamicInsn(name1, desc1, bsm, *bsmArgs)
    if (!pluginManager.extension.lambdaEnabled) {
        return
    }
    try {
        val owner = bsm.owner
        if ("java/lang/invoke/LambdaMetafactory" != owner) {
            return
        }
        val desc2 = (bsmArgs[0] as Type).descriptor
        val sensorsAnalyticsMethodCell: SensorsAnalyticsMethodCell? =
            SensorsAnalyticsHookConfig.LAMBDA_METHODS.get(
                Type.getReturnType(desc1).descriptor + name1 + desc2
            )
        if (sensorsAnalyticsMethodCell != null) {
            val it = bsmArgs[1] as Handle
            mLambdaMethodCells[it.name + it.desc] = sensorsAnalyticsMethodCell
        }
    } catch (e: Exception) {
        warn("Some exception happened when call visitInvokeDynamicInsn: className: " + classNameAnalytics.className + ", error message: " + e.localizedMessage)
    }
}

这个方法的作用是生成mLambdaMethodCells这个map,它的key是脱糖后生成方法的name+desc,value是我们上面提前预埋的lambda配置方法SensorsAnalyticsMethodCell。

为了看懂这个方法,需要用到上面提到的lambda及invokedynamic指令的知识,这个方法的name1和desc1指代invokedynamic指令的第一个参数,bsm是引导方法metafactory相关的实例,bsmArgs是引导方法相关参数,即我们前面分析的samMethodType,implMethod和instantiatedMethodType。

(3)方法出口点

在方法出口点,进行真正的插桩功能实现。

kotlin 复制代码
public override fun onMethodExit(opcode: Int) {
    super.onMethodExit(opcode)
    if (!pluginManager.isHookOnMethodEnter) {
        handleCode()
    }
}

可以看到,主要的逻辑在handleCode中。

功能1:对Fragment相关方法调用插桩。

在ClassVisitor中,对于Fragment没有实现的方法进行了生成和插桩,如果该类已经实现了Fragment的相关方法,只需要插入埋点调用即可。

kotlin 复制代码
if (SAPackageManager.isInstanceOfFragment(classNameAnalytics.superClass)) {
            val sensorsAnalyticsMethodCell: SensorsAnalyticsMethodCell? =
                SensorsFragmentHookConfig.FRAGMENT_METHODS[nameDesc]
            if (sensorsAnalyticsMethodCell != null) {
                visitedFragMethods.add(nameDesc)
//                mMethodVisitor.visitVarInsn(ALOAD, 0)
                for (i in 0 until sensorsAnalyticsMethodCell.paramsCount) {
                    mMethodVisitor.visitVarInsn(
                        sensorsAnalyticsMethodCell.opcodes[i],
                        localIds!![i]
                    )
                }
                mMethodVisitor.visitMethodInsn(
                    INVOKESTATIC,
                    SensorsFragmentHookConfig.SENSORS_FRAGMENT_TRACK_HELPER_API,
                    sensorsAnalyticsMethodCell.agentName,
                    sensorsAnalyticsMethodCell.agentDesc,
                    false
                )
                isHasTracked = true
                return
            }
        }

这里我们插入FragmentTrackHelper的对应方法。

功能2:对lambda调用进行处理。

前面对于lambda表达式的处理讲了一大篇,终于到了收获的时候了。

kotlin 复制代码
val lambdaMethodCell: SensorsAnalyticsMethodCell? = mLambdaMethodCells[nameDesc]

开头的代码看上去比较懵逼,前面存入mLambdaMethodCells的不是生成脱糖方法的namedesc吗,这里取的时候怎么又用当前方法的nameDesc去取了?

其实是我们分析的维度错了,这里已经来到脱糖方法的methodVisitor中的,而mLambdaMethodCells是在ClassVisitor维度共享的。前面做了那么多铺垫,原来是为了拦截到真正脱糖方法的调用,否则脱糖方法的名字是虚拟机生成的,我们无法知道哪个方法承载了lambda的点击调用。

kotlin 复制代码
for (i in paramStart until paramStart + lambdaMethodCell.paramsCount) {
    mMethodVisitor.visitVarInsn(
        lambdaMethodCell.opcodes.get(i - paramStart),
        localIds!![i - paramStart]
    )
}

先将插桩方法需要用到的变量从局部变量表加载到操作数栈。

kotlin 复制代码
mMethodVisitor.visitMethodInsn(
    INVOKESTATIC,
    SensorsAnalyticsHookConfig.SENSORS_ANALYTICS_API,
    lambdaMethodCell.agentName,
    lambdaMethodCell.agentDesc,
    false
)

加载了参数之后,调用SensorsDataAutoTrackHelper的trackViewOnClick方法。

功能3:对于Android Tv的特殊处理。

kotlin 复制代码
if (isAndroidTv && SAPackageManager.isInstanceOfActivity(classNameAnalytics.superClass) && nameDesc == "dispatchKeyEvent(Landroid/view/KeyEvent;)Z") {
    mMethodVisitor.visitVarInsn(ALOAD, 0)
    mMethodVisitor.visitVarInsn(ALOAD, 1)
    mMethodVisitor.visitMethodInsn(
        INVOKESTATIC,
        SensorsAnalyticsHookConfig.SENSORS_ANALYTICS_API,
        "trackViewOnClick",
        "(Landroid/app/Activity;Landroid/view/KeyEvent;)V",
        false
    )
    isHasTracked = true
    return
}

由于android电视具有实体按键,因此需要额外拦截dispatchKeyEvent事件并插桩。

功能4:对点击事件进行处理。

这里终于来到了我最初的本意,学习点击事件的插桩方法。

kotlin 复制代码
if (isOnClickMethod && classNameAnalytics.className == "android/databinding/generated/callback/OnClickListener") {
    trackViewOnClick(mMethodVisitor, 1)
    isHasTracked = true
    return
}

对databinding的点击事件进行插桩埋点。

kotlin 复制代码
if (isSensorsDataTrackViewOnClickAnnotation && desc == "(Landroid/view/View;)V") {
    trackViewOnClick(mMethodVisitor, 1)
    isHasTracked = true
    return
}

对使用第三方框架,使用注解标注的方法进行插桩埋点。

kotlin 复制代码
if (isOnClickMethod) {
    trackViewOnClick(mMethodVisitor, variableID)
    isHasTracked = true
}

使用最常规匿名内部类click调用方式的插桩埋点。

kotlin 复制代码
private fun trackViewOnClick(mv: MethodVisitor, index: Int) {
    mv.visitVarInsn(ALOAD, index)
    mv.visitMethodInsn(
        INVOKESTATIC,
        SensorsAnalyticsHookConfig.SENSORS_ANALYTICS_API,
        "trackViewOnClick",
        "(Landroid/view/View;)V",
        false
    )
}

学了这么长时间的asm,非常简单,不解释了。

(4)方法遍历结束

kotlin 复制代码
override fun visitEnd() {
    super.visitEnd()
    if (isHasTracked) {
        if (pluginManager.extension.lambdaEnabled) {
            mLambdaMethodCells.remove(nameDesc)
        }
        visitAnnotation(
            "Lcom/sensorsdata/analytics/android/sdk/SensorsDataInstrumented;",
            false
        )
    }
}

做一些回收工作。

3. SensorsAnalyticsPushMethodVisitor

对push进行支持,这里主要是注入PushAutoTrackHelper相关的api,由于篇幅原因这里不过多赘述。

4. SensorsAnalyticsWebViewMethodVisitor

对WebView的特殊处理,这里主要是将h5和app进行打通。这里的打通是指,客户端内嵌的h5页面数据统一转发到native侧,由客户端统一进行埋点上报。打通可以统一使用客户端数据存储和传输能力,降低埋点数据的丢失率,另外也是我们ID-mapping功能的一部分,实现统一的用户id标识。

这个MethodVisitor的作用是自动进行JsBridge的注入。

kotlin 复制代码
//将局部变量表中的数据压入操作数栈中触发我们需要插入的方法
positionList.reversed().forEach { tmp ->
    loadLocal(tmp)
}
val newDesc = SAUtils.appendDescBeforeGiven(desc, VIEW_DESC)
mv.visitMethodInsn(INVOKESTATIC, JS_BRIDGE_API, name, newDesc, false)

将WebView的相关调用替换为JSHookAop的相关调用。 比如WebView的loadUrl方法被替换为JSHookAop的loadUrl方法。

loadUrl方法调用setupH5Bridge方法。

java 复制代码
private static void setupH5Bridge(View webView) {
    if (isSupportJellyBean() && SensorsDataAPI.getConfigOptions() != null &&
            SensorsDataAPI.getConfigOptions().isAutoTrackWebView()) {
        setupWebView(webView);
    }
    if (isSupportJellyBean()) {
        SAModuleManager.getInstance().invokeModuleFunction(Modules.Visual.MODULE_NAME, Modules.Visual.METHOD_ADD_VISUAL_JAVASCRIPTINTERFACE, webView);
    }
}

进行jsBridge的注入:

java 复制代码
private static void setupWebView(View webView) {
    if (webView != null && webView.getTag(com.sensorsdata.analytics.android.sdk.R.id.sensors_analytics_tag_view_webview) == null) {
        webView.setTag(com.sensorsdata.analytics.android.sdk.R.id.sensors_analytics_tag_view_webview, new Object());
        H5Helper.addJavascriptInterface(webView, new AppWebViewInterface(webView.getContext().getApplicationContext(), null, false, webView), "SensorsData_APP_New_H5_Bridge");
    }
}

总结:

到这里,整个埋点插件就全分析完了,可以看到不仅仅有我们最初想了解的点击事件插桩,还有对于lambda的兼容,fragment生命周期事件插桩等诸多功能,一个完善的sdk还是要比实现一个demo复杂的多。

相关推荐
曹绍华17 小时前
kotlin扩展函数是如何实现的
android·开发语言·kotlin
LSL666_1 天前
5 Repository 层接口
android·运维·elasticsearch·jenkins·repository
alexhilton1 天前
在Jetpack Compose中创建CRT屏幕效果
android·kotlin·android jetpack
2501_940094021 天前
emu系列模拟器最新汉化版 安卓版 怀旧游戏模拟器全集附可运行游戏ROM
android·游戏·安卓·模拟器
下位子1 天前
『OpenGL学习滤镜相机』- Day9: CameraX 基础集成
android·opengl
参宿四南河三1 天前
Android Compose SideEffect(副作用)实例加倍详解
android·app
火柴就是我1 天前
mmkv的 mmap 的理解
android
没有了遇见1 天前
Android之直播宽高比和相机宽高比不支持后动态获取所支持的宽高比
android
shenshizhong1 天前
揭开 kotlin 中协程的神秘面纱
android·kotlin
vivo高启强1 天前
如何简单 hack agp 执行过程中的某个类
android