前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >一文带你读懂自动注入

一文带你读懂自动注入

原创
作者头像
大发明家
发布2021-12-21 12:32:34
1.3K0
发布2021-12-21 12:32:34
举报
文章被收录于专栏:技术博客文章技术博客文章

1.1.需求导向,背景描述

产品期望埋点需求,一般是页面访问统计,使用时长,

某按钮或模块点击事件统计或者是复杂行为统计。总的来说产品期望看到的统计数据是丰富而且能够尽量灵活满足多变需求,但app

总会因为变更需求而需要更新版本,这算是产品变化需求与本身开发设计的博弈。所以,我们设计埋点方案时候,就得归纳出产品常见需要统计的数据是哪些,常见的统计功能和报表,另外就是开发设计上的灵活。

1.2.常见统计需求

  • 页面访问次数
  • 页面访问人数
  • 页面访问时长
  • 页面流向分布
  • 自定义事件统计

2.常见的埋点统计方案

目前常见的埋点统计方案一般是引入第三方库,使用其平台观测数据,如友盟统计,能够满足绝大部分统计场景,还支持多渠道数据监测。但是第三方统计的方式相对固定,所以未必能满足自定义统计需求,缺点是局限于第三方统计到的数据,而且不能将数据源导出到自身运营统计平台上。所以为了满足产品的需求,我们得设计一套符合自己产品的埋点统计方案。

2.1.雏形方案

思路

2.1.1.页面统计

定义BaseActivity,BaseFragment基类,在onResume ,onPause

方法处,设置埋点方法记录页面生命周期。因为onResume,onPause是对称出现的(忽略其它因素导致onPause不执行影响),所以可以根据它们统计出页面使用时长。

代码语言:txt
复制
    @Override
代码语言:txt
复制
    protected void onResume() {
代码语言:txt
复制
        super.onResume();
代码语言:txt
复制
        DotComponent.getInstance().recordLifecycle(getClass().getName(),"onResume");
代码语言:txt
复制
    }
代码语言:txt
复制
    @Override
代码语言:txt
复制
    protected void onPause() {
代码语言:txt
复制
        super.onPause();
代码语言:txt
复制
        DotComponent.getInstance().recordLifecycle(getClass().getName(),"onPause");
代码语言:txt
复制
    }

2.1.2.点击事件统计

在BaseActivity,BaseFragment 的onCreate方法中,加入hookView核心方法,遍历需要 “关注的view"

。view的点击事件是存放在ListenerInfo这个类里面,通过反射获取OnClickListener

变量,最后通过动态代理方式,创建带有埋点功能的代理点击事件,替换换原来OnClickListener。

代码语言:txt
复制
//定义某个页面 某个控件,需要埋点的事件
代码语言:txt
复制
//ListenerProxyEnum 统计事件类型
代码语言:txt
复制
 configList.put(MainActivity.class.getName(), new ArrayList<ViewProxyEvent>(){{
代码语言:txt
复制
            add(new ViewProxyEvent(MainActivity.class.getName(),R.id.btn_test,"btn_test",ListenerProxyEnum.CLICK_PROXY));
代码语言:txt
复制
        }});
代码语言:txt
复制
 private  void hookView(View view,ViewProxyEvent event){
代码语言:txt
复制
        try {
代码语言:txt
复制
            ListenerProxyEnum proxyEnum = event.proxyEnum;
代码语言:txt
复制
            Class viewClazz = Class.forName("android.view.View");
代码语言:txt
复制
            //事件监听器都是这个实例保存的
代码语言:txt
复制
            Method listenerInfoMethod = viewClazz.getDeclaredMethod("getListenerInfo");
代码语言:txt
复制
            if (!listenerInfoMethod.isAccessible()) {
代码语言:txt
复制
                listenerInfoMethod.setAccessible(true);
代码语言:txt
复制
            }
代码语言:txt
复制
            Object listenerInfoObj = listenerInfoMethod.invoke(view);
代码语言:txt
复制
            Class listenerInfoClazz = Class.forName("android.view.View$ListenerInfo");
代码语言:txt
复制
            //需要更换的目标事件
代码语言:txt
复制
            Field onClickListenerField = listenerInfoClazz.getDeclaredField(proxyEnum.listenName);
代码语言:txt
复制
            if (!onClickListenerField.isAccessible()) {
代码语言:txt
复制
                onClickListenerField.setAccessible(true);
代码语言:txt
复制
            }
代码语言:txt
复制
            Object mListener =   onClickListenerField.get(listenerInfoObj);
代码语言:txt
复制
            //自定义代理事件监听器
代码语言:txt
复制
            BaseListenerProxy proxy = getProxyInstance(proxyEnum.cls,mListener,event);
代码语言:txt
复制
            //更换
代码语言:txt
复制
            onClickListenerField.set(listenerInfoObj, proxy);
代码语言:txt
复制
        } catch (Exception e) {
代码语言:txt
复制
            e.printStackTrace();
代码语言:txt
复制
        }
代码语言:txt
复制
    }
代码语言:txt
复制
 public BaseListenerProxy getProxyInstance(Class proxyClass, Object sourceEvent,ViewProxyEvent event{
代码语言:txt
复制
     //点击事件代理 OnClickListenerProxy 为加入埋点自定义事件
代码语言:txt
复制
     if(proxyClass.getSimpleName().equals(OnClickListenerProxy.class.getSimpleName())){
代码语言:txt
复制
            return new OnClickListenerProxy((View.OnClickListener) sourceEvent,event);
代码语言:txt
复制
     }
代码语言:txt
复制
     //其它事件
代码语言:txt
复制
     //...
代码语言:txt
复制
     return null;
代码语言:txt
复制
}
代码语言:txt
复制
//自定义代理事件
代码语言:txt
复制
public class OnClickListenerProxy extends BaseListenerProxy<View.OnClickListener> implements View.OnClickListener {
代码语言:txt
复制
    public OnClickListenerProxy(View.OnClickListener object, ViewProxyEvent event) {
代码语言:txt
复制
        this.object = object;
代码语言:txt
复制
        this.event = event;
代码语言:txt
复制
    }
代码语言:txt
复制
    @Override
代码语言:txt
复制
    public void onClick(View v) {
代码语言:txt
复制
            //执行注入事件
代码语言:txt
复制
            execute();
代码语言:txt
复制
            if(object != null) {
代码语言:txt
复制
                object.onClick(v);
代码语言:txt
复制
            }
代码语言:txt
复制
        }
代码语言:txt
复制
    }
代码语言:txt
复制
   @Override
代码语言:txt
复制
   protected void execute() {
代码语言:txt
复制
         DotComponent.getInstance().recordViewClick(event.className, event.viewIdName);
代码语言:txt
复制
   }
代码语言:txt
复制
}

总结:

此方案虽然能够满足一般埋点要求,但是扩展性和维护性并不高,默认要求了相同展示页面下,viewID不能相同,而且需要配置"关注view"

的事件,配置列表会越来越大。

3.最后研究的优化方案

3.1 ASM引述

ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入

Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class

文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM

从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。说白了asm是直接通过字节码来修改class文件。

3.2 思路

ASM 可以在编译时候修改字节码,也就是说,我们可以通过ASM 动态注入 埋点代码。对于原有项目入侵小,不需要额外增加基类,同时可以把埋点

业务逻辑抽离出来作为module单独维护。

3.3 实现步骤

1.利用buildSrc 方式 构建gradle 插件(方便实时修改调试)

2.再利用Plugin Transform 在编译class 文件时候 注入代码

以下是图文介绍具体步骤

(1)创建以buildSrc 命名的 moudle ,删除多余文件,新建groovy文件夹

img1.png

(2)在resources 文件夹下创建 xxxx.properties 文件并设置implementation-class ,properties

文件名为 插件对外引用名称既主项目引用插件名,implementation-class 定义插件 主文件。

代码语言:txt
复制
implementation-class=com.awarmisland.plugin.CusPlugin

(3) 设置buildSrc 的 build 文件,引入groovy ,和 gradle api 同步下项目

代码语言:txt
复制
apply plugin: 'groovy'  //必须
代码语言:txt
复制
apply plugin: 'maven'
代码语言:txt
复制
dependencies {
代码语言:txt
复制
    implementation gradleApi() //必须
代码语言:txt
复制
    implementation localGroovy() //必须
代码语言:txt
复制
    //如果要使用android的API,需要引用这个,实现Transform的时候会用到
代码语言:txt
复制
    implementation 'com.android.tools.build:gradle:3.1.3'
代码语言:txt
复制
    implementation 'com.android.tools.build:gradle-api:3.1.3'
代码语言:txt
复制
}
代码语言:txt
复制
repositories {
代码语言:txt
复制
    google()
代码语言:txt
复制
    jcenter()
代码语言:txt
复制
    mavenCentral() //必须
代码语言:txt
复制
}

(4)主项目引入buildSrc插件

代码语言:txt
复制
apply plugin: 'com.awarmisland.plugin'

(5) CusPlugin 继承 PluginProject, 通过apply 添加需要执行的task,Transform 就是我们需要编写的

自定义编译class task 可以引入多个task, 当我们执行build project时候,在AS build窗口会看到我们自定义的task。

代码语言:txt
复制
def android = project.extensions.getByType(AppExtension)
代码语言:txt
复制
 //注册Transform
代码语言:txt
复制
android.registerTransform(new ActivityLifecycleTransform(project),Collections.EMPTY_LIST)
代码语言:txt
复制
android.registerTransform(new FragmentLifecycleTransform(project),Collections.EMPTY_LIST)
代码语言:txt
复制
android.registerTransform(new RecordTransform(project),Collections.EMPTY_LIST)

(6)定义BaseTransform 主要设计目的是 为了抽离编译过程的代码,统筹分类处理。Transform task

任务是依次执行,所以当我们读取了class 文件修改处理后,需要覆盖原来文件,交给下一个task 执行。p.s.

理论大概就是这样,这里需要小心处理,不然很容易编译不通过。

代码语言:txt
复制
abstract class BaseTransform extends Transform implements TransformInterface{
代码语言:txt
复制
@Override
代码语言:txt
复制
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
代码语言:txt
复制
        def transformName = getName();
代码语言:txt
复制
        println '--------------- '+transformName+' visit start --------------- '
代码语言:txt
复制
        def startTime = System.currentTimeMillis()
代码语言:txt
复制
        Collection<TransformInput> inputs = transformInvocation.inputs
代码语言:txt
复制
        TransformOutputProvider outputProvider = transformInvocation.outputProvider
代码语言:txt
复制
        //删除之前的输出
代码语言:txt
复制
        if (outputProvider != null)
代码语言:txt
复制
            outputProvider.deleteAll()
代码语言:txt
复制
        //遍历inputs
代码语言:txt
复制
        inputs.each { TransformInput input ->
代码语言:txt
复制
            //遍历directoryInputs
代码语言:txt
复制
            input.directoryInputs.each { DirectoryInput directoryInput ->
代码语言:txt
复制
                //处理directoryInputs
代码语言:txt
复制
                handleDirectoryInput(directoryInput, outputProvider)
代码语言:txt
复制
            }
代码语言:txt
复制
            //遍历jarInputs
代码语言:txt
复制
            input.jarInputs.each { JarInput jarInput ->
代码语言:txt
复制
                //处理jarInputs
代码语言:txt
复制
                handleJarInputs(jarInput, outputProvider)
代码语言:txt
复制
            }
代码语言:txt
复制
        }
代码语言:txt
复制
        def cost = (System.currentTimeMillis() - startTime) / 1000
代码语言:txt
复制
        println '--------------- '+transformName+' visit end --------------- '
代码语言:txt
复制
        println transformName+" cost : $cost s"
代码语言:txt
复制
}
代码语言:txt
复制
 /**
代码语言:txt
复制
     * 遍历sdk 中的class
     * 处理Jar中的class文件
     */
     void handleJarInputs(JarInput jarInput, TransformOutputProvider outputProvider) {
        if (jarInput.file.getAbsolutePath().endsWith(".jar")) {
            //重名名输出文件,因为可能同名,会覆盖
            def jarName = jarInput.name
            def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
            if (jarName.endsWith(".jar")) {
                jarName = jarName.substring(0, jarName.length() - 4)
            }
代码语言:txt
复制
            JarFile jarFile = new JarFile(jarInput.file)
代码语言:txt
复制
            Enumeration enumeration = jarFile.entries()
代码语言:txt
复制
            File tmpFile = new File(jarInput.file.getParent() + File.separator + "classes_temp.jar")
代码语言:txt
复制
            //避免上次的缓存被重复插入
代码语言:txt
复制
            if (tmpFile.exists()) {
代码语言:txt
复制
                tmpFile.delete()
代码语言:txt
复制
            }
代码语言:txt
复制
            JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile))
代码语言:txt
复制
            //用于保存
代码语言:txt
复制
            while (enumeration.hasMoreElements()) {
代码语言:txt
复制
                JarEntry jarEntry = (JarEntry) enumeration.nextElement()
代码语言:txt
复制
                String entryName = jarEntry.getName()
代码语言:txt
复制
                ZipEntry zipEntry = new ZipEntry(entryName)
代码语言:txt
复制
                InputStream inputStream = jarFile.getInputStream(jarEntry)
代码语言:txt
复制
//                println("className: "+entryName)
代码语言:txt
复制
                jarOutputStream.putNextEntry(zipEntry)
代码语言:txt
复制
                //处理 插桩class
代码语言:txt
复制
                if(isModifyClass(entryName)&&entryName.endsWith(".class")){
代码语言:txt
复制
                    byte[] code = modifyClass(entryName, IOUtils.toByteArray(inputStream))
代码语言:txt
复制
                    if(code){
代码语言:txt
复制
                        jarOutputStream.write(code)
代码语言:txt
复制
                    }else{
代码语言:txt
复制
                        jarOutputStream.write(IOUtils.toByteArray(inputStream))
代码语言:txt
复制
                    }
代码语言:txt
复制
                }else{
代码语言:txt
复制
                    jarOutputStream.write(IOUtils.toByteArray(inputStream))
代码语言:txt
复制
                }
代码语言:txt
复制
                jarOutputStream.closeEntry()
代码语言:txt
复制
            }
代码语言:txt
复制
            //结束
代码语言:txt
复制
            jarOutputStream.close()
代码语言:txt
复制
            jarFile.close()
代码语言:txt
复制
            def dest = outputProvider.getContentLocation(jarName + md5Name,
代码语言:txt
复制
                    jarInput.contentTypes, jarInput.scopes, Format.JAR)
代码语言:txt
复制
            FileUtils.copyFile(tmpFile, dest)
代码语言:txt
复制
            tmpFile.delete()
代码语言:txt
复制
        }
代码语言:txt
复制
    }

(7)现在来看看实践,我们需要在 Activity 生命周期中埋点,记录Activity

生命周期事件。这个时候我们就得在基类FragmentActivity 中 注入我们的埋点代码。

前面的BaseTransform 基类 已经封装好 遍历class 的调度方法。我们继承它

定义一个ActivityLifecycleTransform,isModifyClass 用于过滤需要修改的class

文件,modifyClass为主要处理 注入代码逻辑方法。

重点来了,如何实现代码注入呢?代码注入就是需要 修改class 文件,ASM 帮到你。(其实还有其它库,比如Javassist)

ASM是字节码处理库,常用处理元素ClassVisitor MethodVisitor 对应 类访问,方法访问。在modifyClass

方法中,我们通过ClassReader 读取 class 文件,再通过ClassWrite 授权修改class 文件。

代码语言:txt
复制
class ActivityLifecycleTransform extends BaseTransform {
代码语言:txt
复制
    ActivityLifecycleTransform(Project p) {
代码语言:txt
复制
        super(p)
代码语言:txt
复制
    }
代码语言:txt
复制
    @Override
代码语言:txt
复制
    String getName() {
代码语言:txt
复制
        return "ActivityLifecycleTransform"
代码语言:txt
复制
    }
代码语言:txt
复制
    @Override
代码语言:txt
复制
    boolean isModifyClass(String className) {
代码语言:txt
复制
        if ("android/support/v4/app/FragmentActivity.class".equals(className)) {
代码语言:txt
复制
            return true
代码语言:txt
复制
        }
代码语言:txt
复制
        return false
代码语言:txt
复制
    }
代码语言:txt
复制
    byte[] modifyClass(String className, byte[] classBytes) {
代码语言:txt
复制
        println '----------- deal with "class" file <' + className + '> -----------'
代码语言:txt
复制
        ClassReader classReader = new ClassReader(classBytes)
代码语言:txt
复制
        ClassWriter classWriter = new ClassWriter(classReader,ClassWriter.COMPUTE_MAXS)
代码语言:txt
复制
        ClassVisitor cv = new LifecycleClassVisitor(classWriter)
代码语言:txt
复制
        classReader.accept(cv,EXPAND_FRAMES)
代码语言:txt
复制
        return classWriter.toByteArray()
代码语言:txt
复制
    }
代码语言:txt
复制
}

(8)自定义LifecycleClassVisitor 集成ClassVisitor , ClassVisitor 对类

内部元素读取也是有规律的,我们暂时不研究,对方法的读取回调在visitMethod ,我们可以获取到 方法名name, 通过方法名过滤出需要埋点的方法。

代码语言:txt
复制
public class LifecycleClassVisitor extends ClassVisitor {
代码语言:txt
复制
    private String mClassName;
代码语言:txt
复制
    public LifecycleClassVisitor(ClassVisitor cv) {
代码语言:txt
复制
        super(Opcodes.ASM5,cv);
代码语言:txt
复制
    }
代码语言:txt
复制
    @Override
代码语言:txt
复制
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
代码语言:txt
复制
//        System.out.println("LifecycleClassVisitor:visit----->started"+name);
代码语言:txt
复制
        this.mClassName = name;
代码语言:txt
复制
        super.visit(version, access, name, signature, superName, interfaces);
代码语言:txt
复制
    }
代码语言:txt
复制
    @Override
代码语言:txt
复制
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { ;
代码语言:txt
复制
        MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
代码语言:txt
复制
        LifecycleMethodVisitor method  = new LifecycleMethodVisitor(mv);
代码语言:txt
复制
        //匹配FragmentActivity
代码语言:txt
复制
        if ("onResume".equals(name)
代码语言:txt
复制
                ||"onStop".equals(name)) {
代码语言:txt
复制
            //处理onCreate
代码语言:txt
复制
            method.setLifecycleName(name);
代码语言:txt
复制
            return method;
代码语言:txt
复制
        }
代码语言:txt
复制
        return mv;
代码语言:txt
复制
    }
代码语言:txt
复制
}

(9) 和ClassVistor 一样,MethodVisitor 用于 访问 method 中代码,也是有其访问规律,可以说是访问的生命周期。

visitCode 开始访问代码,此时,我们开始在这里注入字节代码。mv.xxxx 6行代码 其实代表着

DotComponent.getInstance().recordLifecycle(this.getClass().getName(),

lifecycleName); 这一句埋点 执行逻辑代码。java在编译成class 文件前,会先转化成 机器可识别的字节码 ,然后再编译成二进制码。

现在我们就用ASM 语法手动创建了 需要注入的逻辑代码的字节码。这个时候肯定有人问,那注入代码 岂不是需要另外学习字节码的语法规则?

其实总得来说,如果你需要深入定制,就有必要学习了,但是我们只是简单使用的话,知道一点皮毛就ok ,而且我们是可以通过工具生成字节码的。

代码语言:txt
复制
public class LifecycleMethodVisitor extends MethodVisitor {
代码语言:txt
复制
    private String lifecycleName;
代码语言:txt
复制
    public LifecycleMethodVisitor(MethodVisitor mv){
代码语言:txt
复制
        super(Opcodes.ASM5, mv);
代码语言:txt
复制
    }
代码语言:txt
复制
    @Override
代码语言:txt
复制
    public void visitCode() {
代码语言:txt
复制
        super.visitCode();
代码语言:txt
复制
        //方法执行后插
代码语言:txt
复制
        //  DotComponent.getInstance().recordLifecycle(this.getClass().getName(), lifecycleName);
代码语言:txt
复制
        mv.visitMethodInsn(Opcodes.INVOKESTATIC, DOT_PATH, "getInstance", "()L"+DOT_PATH+";", false);
代码语言:txt
复制
        mv.visitVarInsn(Opcodes.ALOAD, 0);
代码语言:txt
复制
        mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Object", "getClass", "()Ljava/lang/Class;", false);
代码语言:txt
复制
        mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Class", "getName", "()Ljava/lang/String;", false);
代码语言:txt
复制
        mv.visitLdcInsn(lifecycleName);
代码语言:txt
复制
        mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, DOT_PATH, "recordLifecycle", "(Ljava/lang/String;Ljava/lang/String;)V", false);
代码语言:txt
复制
    }
代码语言:txt
复制
}

Plugins 搜索 ASM 找到ASM Bytecode Outline 安装,然后就可以在需要 注入的java 文件 右键

生成字节码,具体的方法可以找度娘,很多介绍的

最后~ build project 就会将代码注入到FragmentActivity onResume 方法中

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
作者已关闭评论
0 条评论
热度
最新
推荐阅读
目录
  • 1.1.需求导向,背景描述
  • 1.2.常见统计需求
  • 2.常见的埋点统计方案
    • 2.1.雏形方案
      • 思路
      • 2.1.1.页面统计
      • 2.1.2.点击事件统计
      • 总结:
  • 3.最后研究的优化方案
    • 3.1 ASM引述
      • 3.2 思路
        • 3.3 实现步骤
        相关产品与服务
        腾讯云 BI
        腾讯云 BI(Business Intelligence,BI)提供从数据源接入、数据建模到数据可视化分析全流程的BI能力,帮助经营者快速获取决策数据依据。系统采用敏捷自助式设计,使用者仅需通过简单拖拽即可完成原本复杂的报表开发过程,并支持报表的分享、推送等企业协作场景。
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档