前段时间刚做完公司无埋点数据采集项目,跟大家分享一下。
以下只有部分核心代码,完整源码及接入流程请移步
github:https://github.com/harvie1208/TracePoint
项目背景
当前手动代码埋点的方式,效率低、成本高、见效慢,故开发一套sdk自动采集pv、click等事件。
技术方案调研
无埋点主流方案有以下几种
1.View.AccessibilityDelegate
- 采用辅助功能事件实现无埋点,简单来讲,就是给View设置AccessibilityDelegate,当View产生了click,long_click等事件时,会在响应原有的Listener方法后,发送消息给AccessibilityDelegate,然后在sendAccessibilityEvent方法下搜集自动埋点事件。
- 设置代理的时机
实现Application.ActivityLifecycleCallbacks,用来监听Activity生命周期,当监听到某个Activity进入onResumed状态时,通过以下方式获取RootView:
mViewRoot = this.mActivity.getWindow().getDecorView().getRootView()
从RootView出发深度优先遍历控件树,为满足特定条件的View设置代理监听。
2.gradle插件字节码插装
插件实现也分两种,一种是将Button、TextView等替换成自定义View,另一种是修改字节码。这里选择第二种实现。
-
主流程概述:
通过自定义gradle插件拦截到view的onClick方法及Activity、fragment生命周期方法,插入自定义采集方法,从而监听pv、click事件。
-
关键概念简介(图片来源网易HubbleData)
通过上图可以看出,我们就是在class文件打包到dex文件的过程中增加transform任务,执行插入代码
无埋点技术实现(gradle插件方式)
1.编写gradle插件模块(groovy文件实现)
看到groovy文件不要慌,可以把它当做java写
- 1.工程下创建buildSrc模块(系统保留名称)
- 2.编写插件
import com.android.build.gradle.BaseExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
/**
* @author harvie
*/
class NoTracePointPlugin implements Plugin<Project>{
@Override
void apply(Project project) {
project.extensions.create(ClassModifyUtil.CONFIG_NAME,NoTracePointPluginParams)
registerTransform(project)
}
def static registerTransform(Project project){
BaseExtension extension = project.extensions.getByType(BaseExtension)
NoTracePointTransform transform = new NoTracePointTransform(project)
extension.registerTransform(transform)
}
}
其中apply方法中的project对象用于读取build.gradle文件中的一些配置信息
将自定义的transform类注册进去后,执行工程编译命令时就会执行自定义transform中的代码
- 3.编写transform
import com.android.build.api.transform.*
import com.android.build.gradle.BaseExtension
import com.android.build.gradle.internal.pipeline.TransformManager
import groovy.io.FileType
import org.gradle.api.Project
import java.util.jar.JarEntry
import java.util.jar.JarFile
/**
* @author harvie
*/
class NoTracePointTransform extends Transform{
private static Project project
private static BaseExtension android
//需要扫描的目标包名集合
private static Set<String> targetPackages = new HashSet<>()
NoTracePointTransform(Project project) {
this.project = project
this.android = project.extensions.getByType(BaseExtension)
ClassModifyUtil.project = project
ClassModifyUtil.noTracePointPluginParams = project.noTracePoint
}
@Override
String getName() {
//transform任务名称,随意
return "noTracePointTransform"
}
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
//输入类型 class文件
return TransformManager.CONTENT_CLASS
}
@Override
Set<? super QualifiedContent.Scope> getScopes() {
//作用域 全局工程
return TransformManager.SCOPE_FULL_PROJECT
}
@Override
boolean isIncremental() {
//是否增量构建
return true
}
@Override
void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
//核心操作
long t1 = System.currentTimeMillis()
HLog.i("transform start: "+t1)
// 取build.gradle中配置包名数组
HashSet<String> tempPackages = project.noTracePoint.targetPackages
//此处省略部分非核心代码
// 开始遍历全局jar包
inputs.each {TransformInput input->
input.jarInputs.each { JarInput jarInput->
/** 获得输出文件*/
File dest = outputProvider.getContentLocation(jarInput.file.absolutePath, jarInput.contentTypes, jarInput.scopes, Format.JAR)
File modifiedJar = null
modifiedJar = ClassModifyUtil.modifyJarFile(jarInput.file,context.getTemporaryDir(),android,targetPackages)
if (modifiedJar == null){
modifiedJar = jarInput.file
}
// 因为当前transform的输出文件会成为下一个任务的输入,故需要将修改的文件copy到输出目录
FileUtils.copyFile(modifiedJar,dest)
}
//遍历目录
input.directoryInputs.each { DirectoryInput directoryInput->
File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
File dirFile = directoryInput.file
if (dirFile){
HashMap modifyMap = new HashMap()
dirFile.traverse(type: FileType.FILES,nameFilter:~/.*\.class/){
File classFile ->
//此处省略部分非核心代码,与上面修改class类似
}
}
}
}
long t2 = System.currentTimeMillis()
HLog.i("transform end 耗时: "+(t2-t1)+"毫秒")
}
}
- 4.字节码修改
import org.objectweb.asm.ClassVisitor
import org.objectweb.asm.MethodVisitor
import org.objectweb.asm.Opcodes
/**
* @author harvie
* asm 字节码操作工具类
*/
class HClassVisitor extends ClassVisitor{
private String[] interfaces
private String superName
private String className
private ClassVisitor classVisitor
//记录已访问的fragment方法
private HashSet<String> methodName = new HashSet<>();
HClassVisitor(ClassVisitor cv){
super(Opcodes.ASM5,cv)
this.classVisitor = cv
}
/**
* 访问类头部信息
* @param version
* @param access
* @param name
* @param signature
* @param superName
* @param interfaces
*/
@Override
void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
this.interfaces = interfaces
this.superName = superName
this.className = name.contains('$')?name.substring(0,name.indexOf('$')):name
super.visit(version, access, name, signature, superName, interfaces)
}
/**
* 访问类方法
* @param access
* @param name
* @param desc
* @param signature
* @param exceptions
* @return
*/
@Override
MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor mv = cv.visitMethod( access, name, desc, signature, exceptions)
String nameDesc = name+desc
return new MethodVisitor(this.api, mv){
@Override
void visitCode() {
//点击事件
if (interfaces!=null && interfaces.length>0){
MethodCode methodCode = InterceptEventConfig.interfaceMethods.get(nameDesc)
if(methodCode!=null){
mv.visitVarInsn(Opcodes.ALOAD, 1)
mv.visitMethodInsn(Opcodes.INVOKESTATIC, methodCode.owner, methodCode.agentName, methodCode.agentDesc, false)
}
}
//activity生命周期hook
if (instanceOfActivity(superName)){
MethodCode methodCode = InterceptEventConfig.activityMethods.get(nameDesc)
if (methodCode!=null){
methodName.add(nameDesc)
mv.visitVarInsn(Opcodes.ALOAD, 0)
mv.visitMethodInsn(Opcodes.INVOKESTATIC, methodCode.owner, methodCode.agentName, methodCode.agentDesc, false)
}
}
super.visitCode()
}
@Override
void visitInsn(int opcode) {
//fragment 页面hook
if (instanceOfFragemnt(superName)) {
MethodCode methodCode = InterceptEventConfig.fragmentMethods.get(nameDesc)
if (methodCode != null) {
methodName.add(nameDesc)
if (opcode == Opcodes.RETURN) {
mv.visitVarInsn(Opcodes.ALOAD, 0)
mv.visitVarInsn(Opcodes.ILOAD, 1)
if (superName == 'android/app/Fragment'){
mv.visitMethodInsn(Opcodes.INVOKESTATIC, methodCode.owner, methodCode.agentName, '(Landroid/app/Fragment;Z)V', false)
}else {
mv.visitMethodInsn(Opcodes.INVOKESTATIC, methodCode.owner, methodCode.agentName, '(Landroid/support/v4/app/Fragment;Z)V', false)
}
}
}
}
super.visitInsn(opcode)
}
}
}
@Override
void visitEnd() {
if (instanceOfActivity(superName)){
//防止activity没有复写oncreate方法,再次检测添加
Iterator iterator = InterceptEventConfig.activityMethods.keySet().iterator()
while (iterator.hasNext()) {
String key = iterator.next()
MethodCode methodCell = InterceptEventConfig.activityMethods.get(key)
if (methodName.contains(key)) {
continue
}
//添加需要的生命周期方法
if (key == 'onCreate(Landroid/os/Bundle;)V' || key == 'onResume()V'){
MethodVisitor methodVisitor = classVisitor.visitMethod(Opcodes.ACC_PUBLIC, methodCell.name, methodCell.desc, null, null)
methodVisitor.visitCode()
methodVisitor.visitVarInsn(Opcodes.ALOAD, 0)
methodVisitor.visitVarInsn(Opcodes.ALOAD, 0)
if (key == 'onCreate(Landroid/os/Bundle;)V') {
methodVisitor.visitVarInsn(Opcodes.ALOAD, 1)
}
methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, superName, methodCell.name, methodCell.desc, false)
methodVisitor.visitMethodInsn(Opcodes.INVOKESTATIC, methodCell.owner, methodCell.agentName, methodCell.agentDesc, false)
methodVisitor.visitInsn(Opcodes.RETURN)
methodVisitor.visitMaxs(2, 2)
methodVisitor.visitEnd()
}
}
}else if (instanceOfFragemnt(superName)){
Iterator iterator = InterceptEventConfig.fragmentMethods.keySet().iterator()
while (iterator.hasNext()){
String key = iterator.next()
MethodCode methodCell = InterceptEventConfig.fragmentMethods.get(key)
if (methodName.contains(key)){
continue
}
//添加需要的生命周期方法
MethodVisitor methodVisitor = classVisitor.visitMethod(Opcodes.ACC_PUBLIC, methodCell.name, methodCell.desc, null, null)
methodVisitor.visitCode()
methodVisitor.visitVarInsn(Opcodes.ALOAD, 0)
methodVisitor.visitVarInsn(Opcodes.ILOAD, 1)
methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, superName, methodCell.name, methodCell.desc, false)
methodVisitor.visitVarInsn(Opcodes.ALOAD, 0)
methodVisitor.visitVarInsn(Opcodes.ILOAD, 1)
if (superName == 'android/app/Fragment'){
methodVisitor.visitMethodInsn(Opcodes.INVOKESTATIC, methodCell.owner, methodCell.agentName, '(Landroid/app/Fragment;Z)V', false)
}else {
methodVisitor.visitMethodInsn(Opcodes.INVOKESTATIC, methodCell.owner, methodCell.agentName, '(Landroid/support/v4/app/Fragment;Z)V', false)
}
methodVisitor.visitInsn(Opcodes.RETURN)
methodVisitor.visitMaxs(2, 2)
methodVisitor.visitEnd()
}
}
super.visitEnd()
}
}
下面是字节码操作后的代码示例:
public class MainActivity extends Activity {
public MainActivity() {
}
protected void onCreate(Bundle var1) {
//这一行就是通过插件植入的代码
ActivityHelper.onCreate(this);
super.onCreate(var1);
this.setContentView(2131296284);
((TextView)this.findViewById(2131165331)).setOnClickListener(new 0(this));
}
public void onResume() {
super.onResume();
//这一行就是通过插件植入的
ActivityHelper.onResume(this);
}
}
2.编写事件处理模块(java模块)
- 1.activity及fragment相关hook方法接收
import android.app.Activity;
/**
* @author harvie
* @date 2019/9/3
*/
public class ActivityHelper {
public static void onCreate(Activity activity){
if (activity!=null){
String pageName = activity.getClass().getName();
//业务代码
}
}
public static void onResume(Activity activity) {
//业务代码
//比如直接将此pv事件发送到后台
}
public static void onPause(Activity activity){
//业务代码
}
}
import android.app.Fragment;
/**
* @author harvie
* @date 2019/9/3
*/
public class FragmentHelper {
public static void setUserVisibleHint(Fragment fragment, boolean visiable){
if (visiable){
//业务代码
}
}
public static void onHiddenChanged(Fragment fragment,boolean hidden){
if (!hidden){
//业务代码
}
}
public static void setUserVisibleHint(android.support.v4.app.Fragment fragment,boolean visiable){
if (visiable){
//业务代码
}
}
public static void onHiddenChanged(android.support.v4.app.Fragment fragment,boolean hidden){
if (!hidden){
//业务代码
}
}
}
- 2.click事件接收
public class BuryPointHelper {
public static void onClick(View view){
try {
//根据view获取activity
Activity activity = VIewPathUtil.getActivity(view);
//获取view路径
String path = VIewPathUtil.getViewPath(activity,view);
//通过socket传递至服务器
HLog.i("onClick view:"+path);
YdlPushAgent.sendClickEvent(path);
}catch (Exception e){e.printStackTrace();}
}
}
- 3.view唯一路径生成
import android.app.Activity;
import android.content.Context;
import android.content.ContextWrapper;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
/**
* view唯一ID生成器
* @author harvie
* @date 2019/8/30
*/
class VIewPathUtil {
/**
* 获取view的页面唯一值
* @return
*/
public static String getViewPath(Activity activity,View view){
String pageName = activity.getClass().getName();
String vId = getViewId(view);
return pageName+"_"+MD5Util.md5(vId);
}
/**
* 获取页面名称
* @param view
* @return
*/
public static Activity getActivity(View view){
Context context = view.getContext();
while (context instanceof ContextWrapper){
if (context instanceof Activity){
return ((Activity)context);
}
context = ((ContextWrapper) context).getBaseContext();
}
return null;
}
/**
* 获取view唯一id,根据xml文件内容计算
* @param view1
* @return
*/
private static String getViewId(View view1){
StringBuilder sb = new StringBuilder();
//当前需要计算位置的view
View view = view1;
ViewParent viewParent = view.getParent();
while (viewParent!=null && viewParent instanceof ViewGroup){
ViewGroup tview = (ViewGroup) viewParent;
int index = getChildIndex(tview,view);
sb.insert(0,view.getClass().getSimpleName()+"["+(index==-1?"-":index)+"]");
viewParent = tview.getParent();
view = tview;
}
return sb.toString();
}
/**
* 计算当前 view在父容器中相对于同类型view的位置
*/
private static int getChildIndex(ViewGroup viewGroup,View view){
if (viewGroup ==null || view == null){
return -1;
}
String viewName = view.getClass().getName();
int index = 0;
for (int i = 0;i < viewGroup.getChildCount();i++){
View el = viewGroup.getChildAt(i);
String elName = el.getClass().getName();
if (elName.equals(viewName)){
//表示同类型的view
if (el == view){
return index;
}else {
index++;
}
}
}
return -1;
}
}
-
4.上传服务器
目前是通过socket长连接(断开自动重连)直接传输至服务器,遇到链接异常时会有少量数据丢失,后期加入本地数据库,进行失败重传。
-
5.构建maven库
build.gradle文件中加入uploadArchives任务
uploadArchives {
repositories {
mavenDeployer {
String repoUrl = '私服地址'
repository(url: repoUrl) {
authentication(userName: '用户名', password: '密码')
}
pom.version = '版本号'
pom.artifactId = '库名称'
pom.groupId = "组名称"
}
}
}
直接执行上面任务就可以打包上传至maven私服
-
6.接入App
以下是我制作好的库,可直接通过gradle引入使用,祥见:https://github.com/harvie1208/TracePoint
欢迎star、评论,下期继续优化
总结
-
优点
1.用户数据反馈及时
项目上线即可收集到相关数据,无需后期查补埋点。
2.节省人力成本
一次集成,后期无需再开发,也无需在和产品、运营沟通基础数据埋点相关问题
-
缺点
1.目前还无法采集业务数据
当前仅对click、pv等事件采集,业务数据需通过手动埋点,在考虑通过前后端的一些约定配置采集
2.当页面改版时,需要及时重新配置采集别名
当前事件标识符是根据view的布局路径产生的,我们直接将此路径md5加密上传至后台,路径的中文名是通过另一套可视化界面编辑上传至后台配置中心的。一旦页面发生变化,某个按钮的位置很有可能会变动,需要通过后端api给此路径重新命名
网友评论