美文网首页
两行代码接入Android无埋点sdk(奉上源码及详解)

两行代码接入Android无埋点sdk(奉上源码及详解)

作者: Harvie1208 | 来源:发表于2019-10-22 15:32 被阅读0次

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

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

    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)

    image
    通过上图可以看出,我们就是在class文件打包到dex文件的过程中增加transform任务,执行插入代码
    

    无埋点技术实现(gradle插件方式)

    1.编写gradle插件模块(groovy文件实现)

    看到groovy文件不要慌,可以把它当做java写
    
    • 1.工程下创建buildSrc模块(系统保留名称)
    image
    • 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私服

    总结

    • 优点

      1.用户数据反馈及时

        项目上线即可收集到相关数据,无需后期查补埋点。
      

      2.节省人力成本

        一次集成,后期无需再开发,也无需在和产品、运营沟通基础数据埋点相关问题
      
    • 缺点

      1.目前还无法采集业务数据

        当前仅对click、pv等事件采集,业务数据需通过手动埋点,在考虑通过前后端的一些约定配置采集
      

      2.当页面改版时,需要及时重新配置采集别名

      当前事件标识符是根据view的布局路径产生的,我们直接将此路径md5加密上传至后台,路径的中文名是通过另一套可视化界面编辑上传至后台配置中心的。一旦页面发生变化,某个按钮的位置很有可能会变动,需要通过后端api给此路径重新命名

    相关文章

      网友评论

          本文标题:两行代码接入Android无埋点sdk(奉上源码及详解)

          本文链接:https://www.haomeiwen.com/subject/vpbqvctx.html