前段时间刚做完公司无埋点数据采集项目,跟大家分享一下。

以下只有部分核心代码,完整源码及接入流程请移步

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>{@Overridevoid 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.Projectimport java.util.jar.JarEntry
import java.util.jar.JarFile/*** @author harvie*/
class NoTracePointTransform extends Transform{private static Project projectprivate static BaseExtension android//需要扫描的目标包名集合private static Set<String> targetPackages = new HashSet<>()NoTracePointTransform(Project project) {this.project = projectthis.android = project.extensions.getByType(BaseExtension)ClassModifyUtil.project = projectClassModifyUtil.noTracePointPluginParams = project.noTracePoint}@OverrideString getName() {//transform任务名称,随意return "noTracePointTransform"}@OverrideSet<QualifiedContent.ContentType> getInputTypes() {//输入类型 class文件return TransformManager.CONTENT_CLASS}@OverrideSet<? super QualifiedContent.Scope> getScopes() {//作用域 全局工程return TransformManager.SCOPE_FULL_PROJECT}@Overrideboolean isIncremental() {//是否增量构建return true}@Overridevoid 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 = nullmodifiedJar = 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.fileif (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[] interfacesprivate String superNameprivate String classNameprivate 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*/@Overridevoid visit(int version, int access, String name, String signature, String superName, String[] interfaces) {this.interfaces = interfacesthis.superName = superNamethis.className = name.contains('$')?name.substring(0,name.indexOf('$')):namesuper.visit(version, access, name, signature, superName, interfaces)}/*** 访问类方法* @param access* @param name* @param desc* @param signature* @param exceptions* @return*/@OverrideMethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {MethodVisitor mv = cv.visitMethod( access,  name,  desc,  signature, exceptions)String nameDesc = name+descreturn new MethodVisitor(this.api, mv){@Overridevoid 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生命周期hookif (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()}@Overridevoid visitInsn(int opcode) {//fragment 页面hookif (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)}}}@Overridevoid 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获取activityActivity 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();//当前需要计算位置的viewView 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)){//表示同类型的viewif (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给此路径重新命名

人人都会的Android无埋点数据采集技术相关推荐

  1. 揭秘数极客Android无埋点数据采集原理

    采集数据柯林斯基本分为代码埋点状语从句:无埋点.近年来无埋点的数据采集方案越来越普及,而无埋点的实现方案也有多种,我们今天讨论的问题是数据采集的一种方案,是无需开发人员重复进行采集事件的代码埋点就能达 ...

  2. Android无埋点的技术选型之路

    数极客是国内新一代用户行为分析平台,支持无埋点采集,前端代码埋点采集,后端代码埋点采集等多种混合数据采集方式,支持30多种数据可视化效果,是增长***必的备大数据分析工具,支持APP分析数据网站分析及 ...

  3. 产品随记-无埋点数据采集

    今天收到一封售前的邮件,关于H省网运营商要做手机APP数据分析的需求.客户对要分析的数据内容没有很清楚的想法,只给了百度移动统计的页面.这个需求本身不复杂,只是涉及的厂商较多:APP由我方提供,但其中 ...

  4. 微信小程序无埋点数据采集方案

    相信业务团队对这样的场景不会太陌生: 打点需求:每新上一个功能,数据产品便会同步加上打点需求,当数据打点上线后一段时间,数据产品/业务产品便会针对数据的转化率分析和对业务需求的调整: 打点正确性验证: ...

  5. android 无埋点 简书,无埋点README

    无埋点编码规范 无埋点方案基于窗口回调(Window.Callback)机制.BaseActivity中集成了自动打点相关逻辑.但由于dialog和activity实现机制不一样.为了dialog同样 ...

  6. 代码埋点、可视化埋点、无埋点几种数据埋点方案的分析报告

    目录 数据采集的核心问题 一.埋点是什么 二.为什么要埋点 三.埋点有哪些方式 四.各埋点方式优劣对比 五.其他 在这篇文章里面,我们会对数据采集的一些基本概念进行阐述,然后,会针对目前市面上新增的一 ...

  7. 无埋点数据收集和adb monkey测试屏蔽通知栏

    简单记录百度移动统计android无埋点sdk使用和monkey测试屏蔽通知栏的问题 1.无埋点sdk使用 很简单,下载完sdk后导入到项目中 , 参考sdk文档进行就可以了,个人觉得比友盟还简单,几 ...

  8. 深入浅出:移动端(Android 和 iOS)数据采集埋点 SDK

    随着大数据时代的到来,越来越多公司注意到数据带来的价值,开始自建或购买一些第三方的数据平台.从数据流的角度看,平台对于数据的处理,一般有以下几个步骤: 其中,数据采集工作是后面几个步骤的基础,数据采集 ...

  9. 数据采集及埋点、无埋点

    随着移动互联网时代的兴起和数据量的大规模爆发,越来越多的互联网企业开始重视数据的质量,用户对数据的需求已经不仅仅局限于简单的 PV.UV,而是更加重视用户使用行为数据的相关分析.在数据分析的道路上,数 ...

最新文章

  1. 字节跳动只剩下小米这一个朋友了
  2. 牛客小白月赛16练习
  3. redis源码剖析(十一)—— Redis字符串相关函数实现
  4. 7招,实现安全高效的流水线管理
  5. 阿尔伯塔大学2019计算机科学 cs,[阿尔伯塔大学]计算机/计算机工程专业
  6. 压缩版styleGAN(Mobile StyleGAN)参数更少、计算复杂度更低
  7. box2dweb 学习笔记--sample讲解
  8. css3盒子模型微课_CSS3 盒子模型
  9. Windows核心编程_获取鼠标指定位置的RGB颜色值
  10. [BZOJ4897][Thu Summer Camp2016]成绩单
  11. mysql实现自动更新时间戳
  12. 网上看到了一个关于黑客的练习方式
  13. 【深度学习-数据加载优化-训练速度提升一倍】
  14. 2023二建建筑施工备考第二天Day03
  15. GitHub 上一款全能高速下载工具!堪比某度的会员
  16. 拓嘉启远电商:拼多多直通车烧钱太多的原因
  17. 20句黑客经典语录,一个黑客的内心独白
  18. 卫星图在线浏览地址大全
  19. 第四章 :JavaEE项目之“谷粒商城” ----快速开发
  20. 关于找不到mfc120u.dll文件错误与0xc000007b错误的解决方案

热门文章

  1. 阿里云商标注册教程:新手自助申请步骤
  2. pdf转word文档
  3. 基于LSTM的语音分类(原理加代码)
  4. 京东金融布局:8.8%+白条+网银钱包
  5. 玩客云刷home assistant(2023-01-19亲测)
  6. 如何在SATA驱动装WinPE
  7. Spring源码阅读(一)——整体结构
  8. python服务端编程_Python WebSocket服务端编程代码完成gtalk机器人
  9. Retrofit examples
  10. 分享一款非常好用的免费画图软件