Android实现无痕埋点具体实现-采用代码插桩的方式

手动无埋点的方式,效率低,成本高,见效慢,故开发一套sdk自动采集pv,click等事件;

实现无埋点主流方案有几下几种:

1.View.AccessibilityDelegate

1.1先看下为什么采用View.AccessibilityDelegate方式,通过View源码如何执行点击事件:

public boolean performClick() {
        final boolean result;
        final ListenerInfo li = mListenerInfo;
        if (li != null && li.mOnClickListener != null) {
            playSoundEffect(SoundEffectConstants.CLICK);
            li.mOnClickListener.onClick(this);
            result = true;
        } else {
            result = false;
        }
 
        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
        return result;
    }

第一步:调用View设置的点击事件实例onClick()回调方法,View接受到点击事件回调执行登录,注册,播放视频等操作;

ListenerInfo.mOnClickListener.onClick(this);

第二步:发送给定类型的可访问性事件,AccessibilityEvent定义了可访问的事件类型例如:点击事件-TYPE_VIEW_CLICKED,长按事件-TYPE_VIEW_LONG_CLICKED等;

sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);

public void sendAccessibilityEvent(int eventType) {
        if (mAccessibilityDelegate != null) {
            mAccessibilityDelegate.sendAccessibilityEvent(this, eventType);
        } else {
            sendAccessibilityEventInternal(eventType);
        }
    }

通过如上代码我们发现最后调用AccessibilityDelegate实例对象方法sendAccessibilityEvent(this, eventType),那么是否可以重写AccessibilityDelegate类和sendAccessibilityEvent(this, eventType)来完成埋点操作呢?

 public void setAccessibilityDelegate(@Nullable AccessibilityDelegate delegate) {
        mAccessibilityDelegate = delegate;
    }

我们发现可以自己手动设置AccessibilityDelegate实例替换View中现有AccessibilityDelegate实例对象,在自定义AccessibilityDelegate类sendAccessibilityEvent(this, eventType)完成埋点操作;

1.2完成替换View中AccessibilityDelegate类,实现sendAccessibilityEvent(this, eventType)方法

在Application提供了监听Activity生命周期的方法registerActivityLifecycleCallbacks(),在生命周期回调onActivityResumed()方法中为RootView下的所有视图设置自己实现AccessibilityDelegate类进行埋点;

public void initActivityLifeCycle(){
        registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
            @Override
            public void onActivityResumed(Activity activity) {
                sendLog(activity, "onActivityResumed");
                ViewGroup viewGroup = (ViewGroup) activity.getWindow().getDecorView().findViewById(android.R.id.content);
            mOnGlobalLayoutListener = new MOnGlobalLayoutListener(viewGroup); 
               viewGroup.getViewTreeObserver().addOnGlobalLayoutListener(mOnGlobalLayoutListener);             
 
            }
 
 
            @Override
            public void onActivityPaused(Activity activity) {
                sendLog(activity, "onActivityPaused");
                ViewGroup viewGroup = (ViewGroup) activity.getWindow().getDecorView().findViewById(android.R.id.content);
                viewGroup.getViewTreeObserver().removeGlobalOnLayoutListener(mOnGlobalLayoutListener);
                mOnGlobalLayoutListener = null;
            }
 
        });
    }
 
 
 
//遍历设置AccessibilityDelegate实现类
    public void setAccessibilityDelegate(ViewGroup viewGroup){
        int childCount = viewGroup.getChildCount();
        for(int i=0; i<childCount; i++){
            if(viewGroup.getChildAt(i) instanceof ViewGroup){
                setAccessibilityDelegate((ViewGroup) viewGroup.getChildAt(i));
            }else{
                 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) {
                if(viewGroup.getChildAt(i).hasOnClickListeners()){
                    BuryingPointAccessibilityDelegate accessibilityDelegate = new BuryingPointAccessibilityDelegate();
                    viewGroup.getChildAt(i).setAccessibilityDelegate(accessibilityDelegate);
                }
              }
            }
        }
    }
 
    private class MOnGlobalLayoutListener implements ViewTreeObserver.OnGlobalLayoutListener{
        private ViewGroup viewGroup;
 
        public MOnGlobalLayoutListener(ViewGroup viewGroup){
            this.viewGroup = viewGroup;
        }
 
        @Override
        public void onGlobalLayout() {
            setAccessibilityDelegate(viewGroup);
        }
    }
 
    private MOnGlobalLayoutListener mOnGlobalLayoutListener;

埋点实现:

/**
 * AccessibilityDelegate实现类,完成埋点操作
 */
 
public class BuryingPointAccessibilityDelegate extends View.AccessibilityDelegate {
    @Override
    public void sendAccessibilityEvent(View host, int eventType) {
        super.sendAccessibilityEvent(host, eventType);
        //埋点
        sendLog(host, eventType);
    }
 
    private void sendLog(View host, int eventType) {
        if(eventType == AccessibilityEvent.TYPE_VIEW_CLICKED){
            Log.d(ViewPathUtil.getViewPath(host), "AccessibilityEvent.TYPE_VIEW_CLICKED");
        }
        //...
    }
}

MainActivity[OneFragment]:LinearLayout/FrameLayout[0]/LinearLayout[0]/AppCompatTextView[0] 

AccessibilityEvent.TYPE_VIEW_CLICKED

注意事项:

a.跟视图是指的android.R.id.content,activity.getWindow().getDecorView().findViewById(android.R.id.content);

b.需要为跟视图添加OnGlobalLayoutListener全部布局变化监听器,方便视图变化时重新设置实现AccessibilityDelegate类进行埋点,viewGroup.getViewTreeObserver().addOnGlobalLayoutListener(mOnGlobalLayoutListener); 在onActivityPaused()方法要记得移除OnGlobalLayoutListener;

缺点:

a.获取的是Activity跟视图重新设置了AccessibilityDelegate类进行埋点,无法为游离Activity上的Dialog,PopWindow进行埋点;

b.viewGroup.getChildAt(i).hasOnClickListeners(),hasOnClickListeners()需要api版本为15;

c.每次视图有变化遍历所有的视图,重新设置AccessibilityDelegate类进行埋点,耗费性能;

处理替换AccessibilityDelegate方式,也可以采用Hook方式用OnClickListener代理类替换View下ListenerInfo持有的OnClickListener实例方式实现埋点:

参考:https://blog.csdn.net/ahou2468/article/details/106503190

2.gradle插件字节码插装

通过修改字节码的方式进行埋点效果如下:

Activity
protected void onCreate(@Nullable Bundle var1) {
        InterceptEventHanlder.activityOnCreate(this);
        super.onCreate(var1);
    }
 
public void onClick(View var1) {
        InterceptEventHanlder.onClick(var1);
        switch(var1.getId()) {}
    }

通过自定义gradle插件拦截View的onClick方法及Activity,fragment生命周期方法,插入自定义的采集方法,从而监听pv,click事件;

2.1Android下apk编译流程

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

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

自定义插件参考:Android开发自定义Gradle插件_没有uploadarchives-CSDN博客

groovy文件编写可以当Java写;

2.2.1工程下新建buildSrc模块(系统保留名称)

2.2.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(例如:模块module的app)文件的一些配置信息,将自定义的transform类注册进去后,执行工程编译命令时就会执行自定义的transform中的代码;

2.2.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)+"毫秒")
    }
}

2.2.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.3编写事件处理模块(Java)

2.3.1Activity及Fragment相关hook接受,点击事件接受
public class InterceptEventHanlder {
    private static String TAG = "tracepoint";
 
    //------------------- activity 事件接收
    public static void activityOnCreate(Activity activity){
        Log.e(TAG,activity.getClass().getName());
    }
 
    public static void activityOnResume(Activity activity) {
        Log.e(TAG,activity.getClass().getName());
    }
    //------------------- fragment 事件接收
    public static void setUserVisibleHint(Fragment fragment, boolean visiable){
        if (visiable){
            Log.e(TAG,"pv:"+fragment.getClass().getName());
        }
    }
 
    public static void onHiddenChanged(Fragment fragment,boolean hidden){
 
        if (!hidden){
            Log.e(TAG,"pv:"+fragment.getClass().getName());
        }
    }
 
    public static void setUserVisibleHint(android.support.v4.app.Fragment fragment,boolean visiable){
 
        if (visiable){
            Log.e(TAG,"pv:"+fragment.getClass().getName());
        }
    }
 
    public static void onHiddenChanged(android.support.v4.app.Fragment fragment,boolean hidden){
 
        if (!hidden){
            Log.e(TAG,"pv:"+fragment.getClass().getName());
        }
    }
 
    //------------------- click 事件接收
 
    public static void onClick(View view){
 
        try {
            //以下ViewPath工具类可从源码app模块中获取
            //Activity activity = ViewPathUtil.getActivity(view);
            String path = ViewPathUtil.getViewPath(view);
 
            Log.e(TAG,"viewPath:"+path);
        }catch (Exception e){e.printStackTrace();}
    }
}

3.保证View的唯一路径

package fan.fragmentdemo.notracepoint;
 
import android.app.Activity;
import android.content.Context;
import android.content.ContextWrapper;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
 
/**
 * view唯一ID生成器
 */
public class ViewPathUtil {
    //设置Fragment下View的Tag对应的key
    public static final int FRAGMENT_NAME_TAG = 0xff000001;
 
    /**
     * 获取view的页面唯一值
     * @return
     */
    public static String getViewPath(View view){
        Activity activity = getActivity(view);
        String pageName = (String)view.getTag(FRAGMENT_NAME_TAG);
        if(TextUtils.isEmpty(pageName)){
            pageName = activity.getClass().getSimpleName();
        }else{
            pageName = activity.getClass().getSimpleName()+"["+pageName+"]";
        }
 
        String vId = getViewId(view);
        return pageName+":"+ vId;//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 currentView
     * @return
     */
    private static String getViewId(View currentView){
 
        StringBuilder sb = new StringBuilder();
 
        //当前需要计算位置的view
        View view = currentView;
        ViewParent viewParent =  view.getParent();
 
        while (viewParent!=null && viewParent instanceof ViewGroup){
 
            ViewGroup tview = (ViewGroup) viewParent;
            if(((View)view.getParent()).getId() == android.R.id.content){
                sb.insert(0,view.getClass().getSimpleName());
                break;
            }else{
                int index = getChildIndex(tview,view);
                sb.insert(0,"/"+view.getClass().getSimpleName()+"["+(index==-1?"-":index)+"]");
            }
 
            viewParent = tview.getParent();
            view = tview;
        }
        Log.e("Path", sb.toString());
        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;
    }
}

以上埋点的基本流程,参考代码:https://github.com/harvie1208/TracePoint

4.总结

a.gradle插件话埋点无需要手动埋点,采用字节码埋点,大大减少手动埋点的工作量;

b.gradle插件话埋点不需要哦后期查补埋点;

c.一些业务数据需要手动埋点;

d.旧的页面项目升级时页面结构发生变化可能导致View的唯一路径发生变化,后台需要重新为此View命名;

参考:

https://juejin.im/post/5dae95c4f265da5bb7466357#heading-2

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值