乐趣区

关于前端:神策-Android-全埋点插件介绍

一、前言

埋点是数据采集畛域的一个术语,它是指针对特定用户行为或事件进行捕捉、解决、上报的过程。埋点技术本质就是在适合的机会去采集行为数据,同时获取必要的上下文信息,最初将行为数据上报到指定的服务端。埋点获取到的业务数据能够为产品后续的迭代方向和评判营销价值提供无力、牢靠的数据撑持。

常见的埋点形式次要包含全埋点和代码埋点(又称自定义埋点)。其中,全埋点能够满足 UV、PV、点击量等常见指标统计的需要,实用于以较小的埋点代价收集尽可能多的用户行为数据的场景。

上面将首先针对神策 Android SDK 全埋点性能作简要的介绍,而后重点解说 Android 全埋点插件的作用和实现原理。

二、全埋点介绍

2.1 基本概念

全埋点,也叫无埋点、无码埋点、无痕埋点、主动埋点等。全埋点,是指无需利用程序开发工程师写代码或者只写大量的代码,即可事后主动收集用户的所有或者绝大部分的行为数据,而后再依据理论的业务剖析需要从中筛选出所需行为数据并进行剖析。神策 Android SDK 全埋点采集的事件目前包含以下四种(事件名称后面的 $ 符号,是指该事件是预置事件):

$AppStart 事件
是指应用程序启动事件,包含冷启动和热启动场景。冷启动是指在零碎中没有该利用的过程时启动应用程序,热启动是指在零碎中已有该过程时启动应用程序,热启动也能够了解为从后盾关上 App。

$AppEnd 事件
是指应用程序退出事件,常见的退出场景包含应用程序的失常退出、进入后盾、应用程序被强杀、应用程序解体等场景。这里须要留神的是神策 Android SDK 为了应答多过程、强杀等场景,退出了 30 秒的 session 机制,即用户退出 App 到后盾 30 秒的时候才会触发退出事件。

$AppViewScreen 事件
是指应用程序页面浏览事件,对于 Android 应用程序来说,就是指切换 Activity 或 Fragment。

$AppClick 事件
是指应用程序控件(View)点击事件,例如:点击 Button、ImageView 等。

2.2 实现原理

实现 App 启动、退出和页面浏览(Activity)的全埋点较为简单,事件的采集围绕 Activity 生命周期开展即可。Android 官网在 Android 4.0 及以上版本提供了 Application.ActivityLifecycleCallbacks 接口,调用 Application.registerActivityLifecycleCallbacks 办法并传入 Application.ActivityLifecycleCallbacks 接口的实现类,就能够在实现类中获取到之后所有 Activity 生命周期的回调。这样,咱们只须要做一些简略判断就能够实现以上全埋点事件的采集。

实现 App 浏览页面(Fragment)和点击的全埋点则要简单的多,尽管这两个事件要埋点的地位很分明(例如:Button 的 OnClickListener.onClick 办法触发就能够视为 Button 的点击),然而并没有一个像 Application.ActivityLifecycleCallbacks 一样全局托管的接口。因而,咱们须要利用一些技术在原解决逻辑中“插入”咱们想要的埋点代码,从而实现主动埋点的成果。神策的 Android 全埋点插件正是为了解决这个问题而推出的。

三、全埋点插件的实现原理

想要主动在咱们规定的地位插入特定的埋点代码,须要先理解 Android 的构建流程,如图 3-1 所示:

图 3-1 Android Apk 构建流程图(图片来源于 Android 开发者官网)
通过上图能够晓得,Compilers 会将源码转化成 DEX 文件,其余内容转化成编译后的资源。实际上,Compilers 到 DEX 文件这一步会先将源码编译成字节码文件,再通过 dex 命令将字节码文件解决成 classes.dex。而咱们须要做的就是:在转化成 dex 之前对字节码文件做解决,遍历所有的字节码文件并在特定的逻辑处进行插码。进一步细化思路能够分为两个步骤:

(1)在转化成 dex 之前获取到全量、可解决的字节码文件流;

(2)辨认字节码文件中的特定逻辑并插入自定义的埋点代码。

留神:对于上述的第二步,如果你写过 Xposed 插件或者理解过 Spring 框架原理的话就会感觉十分相熟,这里应用到了面向切面编程的思维,即 AOP。依照 AOP 的思维,咱们能够把要插入代码的中央形象成切入点,而后在切入点处增加埋点代码就能够了。

神策在实现这个性能的时候用到了以下关键技术:

(1)Gradle 插件:Gradle 是一个十分优良的我的项目构建工具,它的 DSL(畛域特定语言)基于 Groovy 实现。Gradle 构建的大部分性能通过插件的形式来实现,并且反对自定义 Gradle 插件。把插件利用到我的项目中,插件会扩大我的项目的性能,帮忙你在我的项目的构建过程中做很多事件,例如:测试、编译、打包等;

(2)Transform API:是一组封装好的类,通过 Transform API 容许第三方以插件(plugin)的模式,在 Android 应用程序打包成 .dex 文件之前的编译过程中操作字节码文件;

(3)ASM:是一个通用的 Java 字节码操纵框架,它能被用来动静生成类或者加强既有类的性能。

3.1 Transform API

Google 从 Android Gradle 1.5.0 开始,提供了 Transform API,容许第三方插件在 Android App 打包成 .dex 文件之前的编译过程中操作字节码文件。咱们只有实现一套 Transform,遍历字节码文件的所有办法之后进行批改,最终替换原文件即可达到插入代码的目标。

咱们先理解一下 Transform 的两个概念:

(1)TransformInput:是指输出文件的形象,它蕴含 DirectoryInput 汇合(代表以源码形式参加我的项目编译的所有目录构造及其目录下的源码文件)与 JarInput 汇合(以 jar 包形式参加我的项目编译的所有本地 jar 包和近程 jar 包)两局部;

(2)TransformOutputProvider:是指 Transform 的输入,通过它能够获取输入门路。

上面咱们再理解一下 Transform 类的定义,作为一个抽象类它次要蕴含以下的局部:

class Transform {

public abstract Set<ContentType> getInputTypes();

public abstract Set<? super Scope> getScopes();

public void transform(@NonNull TransformInvocation transformInvocation)
        throws TransformException, InterruptedException, IOException {}


}
(1)getInputTypes:指定 Transform 要解决的数据类型,例如:解决编译后的字节码或者规范的 Java 资源;

(2)getScopes:指定 Transform 的作用域,例如:只解决以后我的项目、只解决子项目等;

(3)transform:解决字节码文件流的切入点,通过入参 transformInvocation 咱们就能够拿到下面概念中提到的 TransformInput 和 TransformOutputProvider。

能够看到 Transform API 的构造是非常简单、清晰的,它的应用能够概括为:先从 TransformInput 接管咱们须要的流,再从 TransformOutputProvider 输入咱们解决过的流,具体的解决逻辑须要实现自定义的 Transform 类。getInputTypes、getScopes 等办法用于粗粒度的束缚、筛选输出流,transform 实现接管流、解决流、输入流的过程。transform 的办法逻辑是大同小异的,因为真正解决流的过程是通过 ASM 去实现,如果对 Transform API 这部分有趣味的话能够间接参考神策 Android 全埋点插件的源码。

3.2 ASM

ASM 是一个通用的 Java 字节码操作和剖析框架。它能够用来批改现有的类,或者间接以二进制模式动静生成类。ASM 提供了一些常见的字节码转换和剖析算法,能够依据这些算法构建定制的简单转换和代码剖析工具。ASM 提供了与其余 Java 字节码框架相似的性能,并且效率更高。不过效率高的前提是该库的语法更靠近字节码层面,因而学习老本更大。

咱们先来理解下 ASM 框架的两个外围类:

(1)ClassVisitor:次要负责“visit”类的成员信息,包含标记在类上的注解、构造方法、属性、办法、动态代码块。在这里咱们能够通过自定义 ClassVisitor 解决 Transform API 提供的字节码文件流,例如:如果咱们须要采集 Button 的点击事件,那么就只须要对继承了 View.OnClickListener 接口的类进行解决。

(2)MethodVisitor:次要负责“visit”办法的信息,用来进行具体的字节码操作。“插入”代码的过程便是通过 MethodVisitor 的办法来实现。

上面咱们再来看一下 ClassVisitor 类的定义,ClassVisitor 类的构造如图 3-2 所示:

图 3-2 ClassVisitor 类构造
能够看到 ClassVisitor 中定义了很多 visit* 的办法,用于解决一个字节码文件的不同内容。其中,最罕用到的是 visit 与 visitMethod 这两个办法:

void visit(int version, int access, String name, String signature, String superName, String[] interfaces)
该办法是扫描类时的第一个“visit”的办法,次要用于类申明应用,其中参数的释义如下:

(1)version:标识类版本,例如:51,示意以后字节码文件的版本是 JDK 1.7;

(2)access:类的修饰符,例如:ACC_PUBLIC(对应 public);

(3)name:类的全限定名,通常类的名称是由包名加类名形成并以‘.’隔开,全限定名就须要把名称里的‘.’替换成‘/’,例如:“java/lang/String”;

(4)signature:泛型信息;

(5)superName:父类的名称,java 是单根构造,即所有的类都继承自“java.lang.Object”。即便你申明一个没有 extends 任何类的类,JDK 在编译的时候会为你加上“extends Object”;

(6)interfaces:类实现的接口,一个 java 类能够实现多个接口,因而是一个数组。

MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions)
该办法是当扫描器扫描到类的办法时进行调用,其中参数的释义如下:

(1)access:办法的修饰符,例如:ACC_PRIVATE(对应 private);

(2)name:办法名,在 ASM 中“visitMethod”办法会解决构造方法、动态代码块、公有办法、受爱护的办法、私有办法、native 类型的办法。在这些办法中,构造方法的办法名为“<init>”,动态代码块的办法名为“<clinit>”;

(3)desc:办法签名,要理解办法签名须要先理解描述符相干的常识,上面会具体介绍;

(4)signature:泛型信息;

(5)exceptions:异样信息,示意办法可能抛出的异样,一个 java 办法能够抛出多个异样,因而是一个数组。

这里的 access、name、signature、exceptions 都很好了解,只有 desc(办法签名)不是很常见。其实办法签名的内容就是形容办法的返回值和入参类型列表,例如:void fun(int a,String b),它的 desc 就是“(iLjava/lang/String;)V”。这里 desc 的组成规定是:“(入参类型描述符)返回值类型描述符”。类型与描述符的对照关系如表 3-1 所示:


表 3-1 类型描述符对照表
开发者通过自定义 ClassVisitor 的子类,重写对应的 visit 办法就能达到批改字节码的目标,在更底层的地位 Visitor 会依据 ASM 实现的算法去遍历类的各个角落。因为是在编译期间对字节码进行解决,ASM 即实现了实现管制类的工作且无显著性能代价,非常适合咱们的需要。

3.3 运行流程

为了不便用户应用,神策 Android 全埋点插件提供了一些可配置项。插件会在开始时读取这些配置以决定具体的运行模式。除此以外就是 Transform API 和 ASM 一起实现动静插入埋点代码的性能,插件的整体运行流程如图 3-3 所示:

图 3-3 神策 Android 全埋点插件运行流程图

四、全埋点插件的具体实现

4.1 SensorsAnalyticsTransform

SensorsAnalyticsTransform 是 3.1 节中提到的 Transform API 的实现类,SensorsAnalyticsTransform 的作用很明确:先把 TransformInput 中蕴含的每一个字节码文件遍历并交给 ASM 框架解决,再把每一个 ASM 框架解决后的字节码文件输入到 TransformOutputProvider,供下一个 Gradle Task 应用。上面咱们来看一下 SensorsAnalyticsTransform 的运行流程,如图 4-1 所示:

图 4-1 SensorsAnalyticsTransform 运行流程图
图中的办法名和源码是统一的,整体流程先是通过 Transform 类的重写办法 transform(),之后别离开始 DirectoryInput 汇合和 JarInput 汇合的遍历,最终在 modifyClass() 办法中将遍历所得的字节码文件应用 ASM 框架进行解决。Transform API 是反对多线程编译以及增量编译的,神策 Android 全埋点插件也实现了这一部分的性能,因而能够看到在遍历 DirectoryInput 汇合和 JarInput 汇合的时候有多层的嵌套解决。ASM 框架解决后的字节码文件还须要输入给 TransformOutputProvider,这部分的逻辑没有在图 4-1 中体现,不过也是在遍历的办法中去实现的。

从 transform() 到 modifyClass() 之间实现遍历性能的逻辑是绝对固定的,没有太多业务含意,能够间接复用这部分代码。因而,这里就不深入分析源码了。咱们来看下最终的 modifyClass() 办法的实现逻辑:

/**

  • 真正批改类中办法字节码
    */

private byte[] modifyClass(byte[] srcClass, ClassNameAnalytics classNameAnalytics) {

ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS)
ClassVisitor classVisitor = new SensorsAnalyticsClassVisitor(classWriter, classNameAnalytics, transformHelper)
ClassReader cr = new ClassReader(srcClass)
cr.accept(classVisitor, ClassReader.EXPAND_FRAMES + ClassReader.SKIP_FRAMES)
return classWriter.toByteArray()

}
ClassReader 类次要用来解析编译过的字节码文件,ClassWriter 类用来从新构建编译后的类,比方批改类名、属性以及办法。这两个类同样是 ASM 框架的外围类,不过在咱们的需要中并不需要自定义实现,因而不再赘述。SensorsAnalyticsClassVisitor 类中实现了自定义“插入”代码的逻辑,下节中咱们通过示例讲述如何实现。

4.2 SensorsAnalyticsClassVisitor

SensorsAnalyticsClassVisitor 是 3.2 节中提到的 ASM 框架 ClassVisitor 类的子类,它也是神策 Android 全埋点插件最外围的类,在这个类中咱们实现了 Fragment 的页面浏览以及 App 点击的全埋点事件采集性能。因为要实现的业务逻辑多且简单,本节不会间接剖析 SensorsAnalyticsClassVisitor 的源码,而是通过示例(如何实现 Button 的点击事件采集)一窥到底。

回顾第三节中提到的基本思路:在转化成 dex 之前对字节码文件做解决,遍历所有的字节码文件并在特定的逻辑处进行插码。其中,后半局部“并在特定的逻辑处进行插码”就是咱们要实现的逻辑,这种思路对应了面向切面编程思维(AOP)。

AOP 有如下三个次要概念:

(1)切面(Aspect):切入点 + 告诉;

(2)切入点(Pointcut):指具体被加强的类或者办法;

(3)告诉(Advice):指在切入点执行的加强解决。

咱们的实现过程就围绕着定义切入点和插入埋点逻辑。

4.2.1 定义切入点

要给一个 Button 对象设置点击事件,最一般的做法是调用 Button 对象的 setOnClickListener 办法,传入本人实现的 View.OnClickListener 对象,大抵逻辑如下所示:

findViewById(R.id.btn).setOnClickListener(new View.OnClickListener() {

@Override
public void onClick(View v) {// 触发该办法示意 Button 被点击}

});
咱们想要实现 Button 的点击事件采集的话,就须要在 OnClick 办法中插入咱们的自定义代码。从图中代码能够推断出,咱们须要的切入点规定是:

(1)该办法所在的类须要实现 View.onClickListener 接口;

(2)办法名为 onClick;

(3)办法签名是一个 View 类型的入参和 void 的办法返回值,也就是“(Landroid/view/View;)V”。

依据 3.2 节对于 ASM 框架的介绍中咱们能够得悉:从 visit 办法的 interfaces 参数能够获取到以后类实现的接口数组;从 visitMethod 的 name 和 desc 能够别离获取到办法名和办法签名。

4.2.2 加强切入点

加强切入点也就是插入咱们自定义的埋点逻辑,这里以插入一行日志为例:

class MyClassVisitor extends ClassVisitor {

private String[] interfaces
private String className;
private String superName;

MyClassVisitor(ClassVisitor cv) {super(ASM6,cv)
}

@Override
void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
    this.interfaces = interfaces
    this.className = name
    this.superName = superName
    super.visit(version,access,name,signature,superName,interfaces)
}

@Override
MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {def methodVisitor = super.visitMethod(access, name, desc, signature, exceptions);
    if(name.equals('onClick') && desc.equals('(Landroid/view/View;)V') && interfaces.contains('android/view/View$OnClickListener')){
        println className + "" + superName +" "+ access +" "+ name +" " + desc
        methodVisitor.visitLdcInsn('HookLog')
        methodVisitor.visitVarInsn(ALOAD, 1)
        methodVisitor.visitMethodInsn(INVOKEVIRTUAL, 'android/view/View', 'toString', '()Ljava/lang/String;', false)
        methodVisitor.visitMethodInsn(INVOKESTATIC, 'android/util/Log', 'e','(Ljava/lang/String;Ljava/lang/String;)I', false)
    }
    return methodVisitor
}

}
这里重写办法 visitMethod 中的 if 条件语句就对应了切入点规定,并且通过 MethodVisitor 在点击事件里插入了一行代码“Log.e(“HookLog”, view.toString() );”,view 对象就是 onClick 办法的入参。

MethodVisitor 的应用须要波及很多 Java 虚拟机和字节码指令相干的常识,不在本文探讨的范畴,这里就不再开展了。为了疾速上手 ASM,举荐一个插件 ASM Bytecode Viewer,它能够帮你疾速把 Java 代码转化成对应的 ASM 代码。

五、总结

本文首先介绍了神策 Android 全埋点的基本概念和实现逻辑,而后重点讲述了 Android 全埋点插件的作用和实现原理。Transform API + ASM 的性能十分弱小,往往能够起到意想不到的作用,值得大家深入研究。

最初,心愿大家通过这篇文章能够对神策的 Android 全埋点插件有一个较为全面的理解。

注:参考文献

Android Apk 构建流程图:https://developer.android.com…

文章起源:公众号神策技术社区

退出移动版