埋點(diǎn)的探索,自動(dòng)注入的方案

1.需求導(dǎo)向

1.1.需求導(dǎo)向,背景描述

產(chǎn)品期望埋點(diǎn)需求,一般是頁面訪問統(tǒng)計(jì),使用時(shí)長, 某按鈕或模塊點(diǎn)擊事件統(tǒng)計(jì)或者是復(fù)雜行為統(tǒng)計(jì)??偟膩碚f產(chǎn)品期望看到的統(tǒng)計(jì)數(shù)據(jù)是豐富而且能夠盡量靈活滿足多變需求,但app 總會(huì)因?yàn)樽兏枨蠖枰掳姹?,這算是產(chǎn)品變化需求與本身開發(fā)設(shè)計(jì)的博弈。所以,我們?cè)O(shè)計(jì)埋點(diǎn)方案時(shí)候,就得歸納出產(chǎn)品常見需要統(tǒng)計(jì)的數(shù)據(jù)是哪些,常見的統(tǒng)計(jì)功能和報(bào)表,另外就是開發(fā)設(shè)計(jì)上的靈活。

1.2.常見統(tǒng)計(jì)需求

  • 頁面訪問次數(shù)
  • 頁面訪問人數(shù)
  • 頁面訪問時(shí)長
  • 頁面流向分布
  • 自定義事件統(tǒng)計(jì)

2.常見的埋點(diǎn)統(tǒng)計(jì)方案

目前常見的埋點(diǎn)統(tǒng)計(jì)方案一般是引入第三方庫,使用其平臺(tái)觀測(cè)數(shù)據(jù),如友盟統(tǒng)計(jì),能夠滿足絕大部分統(tǒng)計(jì)場景,還支持多渠道數(shù)據(jù)監(jiān)測(cè)。但是第三方統(tǒng)計(jì)的方式相對(duì)固定,所以未必能滿足自定義統(tǒng)計(jì)需求,缺點(diǎn)是局限于第三方統(tǒng)計(jì)到的數(shù)據(jù),而且不能將數(shù)據(jù)源導(dǎo)出到自身運(yùn)營統(tǒng)計(jì)平臺(tái)上。所以為了滿足產(chǎn)品的需求,我們得設(shè)計(jì)一套符合自己產(chǎn)品的埋點(diǎn)統(tǒng)計(jì)方案。

2.1.雛形方案

思路

2.1.1.頁面統(tǒng)計(jì)

定義BaseActivity,BaseFragment基類,在onResume ,onPause 方法處,設(shè)置埋點(diǎn)方法記錄頁面生命周期。因?yàn)閛nResume,onPause是對(duì)稱出現(xiàn)的(忽略其它因素導(dǎo)致onPause不執(zhí)行影響),所以可以根據(jù)它們統(tǒng)計(jì)出頁面使用時(shí)長。

    @Override
    protected void onResume() {
        super.onResume();
        DotComponent.getInstance().recordLifecycle(getClass().getName(),"onResume");
    }
    @Override
    protected void onPause() {
        super.onPause();
        DotComponent.getInstance().recordLifecycle(getClass().getName(),"onPause");
    }

2.1.2.點(diǎn)擊事件統(tǒng)計(jì)

在BaseActivity,BaseFragment 的onCreate方法中,加入hookView核心方法,遍歷需要 “關(guān)注的view" 。view的點(diǎn)擊事件是存放在ListenerInfo這個(gè)類里面,通過反射獲取OnClickListener 變量,最后通過動(dòng)態(tài)代理方式,創(chuàng)建帶有埋點(diǎn)功能的代理點(diǎn)擊事件,替換換原來OnClickListener。

//定義某個(gè)頁面 某個(gè)控件,需要埋點(diǎn)的事件
//ListenerProxyEnum 統(tǒng)計(jì)事件類型
 configList.put(MainActivity.class.getName(), new ArrayList<ViewProxyEvent>(){{
            add(new ViewProxyEvent(MainActivity.class.getName(),R.id.btn_test,"btn_test",ListenerProxyEnum.CLICK_PROXY));
        }});
 private  void hookView(View view,ViewProxyEvent event){
        try {
            ListenerProxyEnum proxyEnum = event.proxyEnum;
            Class viewClazz = Class.forName("android.view.View");
            //事件監(jiān)聽器都是這個(gè)實(shí)例保存的
            Method listenerInfoMethod = viewClazz.getDeclaredMethod("getListenerInfo");
            if (!listenerInfoMethod.isAccessible()) {
                listenerInfoMethod.setAccessible(true);
            }
            Object listenerInfoObj = listenerInfoMethod.invoke(view);
            Class listenerInfoClazz = Class.forName("android.view.View$ListenerInfo");
            //需要更換的目標(biāo)事件
            Field onClickListenerField = listenerInfoClazz.getDeclaredField(proxyEnum.listenName);
            if (!onClickListenerField.isAccessible()) {
                onClickListenerField.setAccessible(true);
            }
            Object mListener =   onClickListenerField.get(listenerInfoObj);
            //自定義代理事件監(jiān)聽器
            BaseListenerProxy proxy = getProxyInstance(proxyEnum.cls,mListener,event);
            //更換
            onClickListenerField.set(listenerInfoObj, proxy);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

 public BaseListenerProxy getProxyInstance(Class proxyClass, Object sourceEvent,ViewProxyEvent event{
     //點(diǎn)擊事件代理 OnClickListenerProxy 為加入埋點(diǎn)自定義事件
     if(proxyClass.getSimpleName().equals(OnClickListenerProxy.class.getSimpleName())){
            return new OnClickListenerProxy((View.OnClickListener) sourceEvent,event);
     }
     //其它事件
     //...
     return null;
}
//自定義代理事件
public class OnClickListenerProxy extends BaseListenerProxy<View.OnClickListener> implements View.OnClickListener {
    public OnClickListenerProxy(View.OnClickListener object, ViewProxyEvent event) {
        this.object = object;
        this.event = event;
    }
    @Override
    public void onClick(View v) {
            //執(zhí)行注入事件
            execute();
            if(object != null) {
                object.onClick(v);
            }
        }
    }
   @Override
   protected void execute() {
         DotComponent.getInstance().recordViewClick(event.className, event.viewIdName);
   }
}

總結(jié):

此方案雖然能夠滿足一般埋點(diǎn)要求,但是擴(kuò)展性和維護(hù)性并不高,默認(rèn)要求了相同展示頁面下,viewID不能相同,而且需要配置"關(guān)注view" 的事件,配置列表會(huì)越來越大。

3.最后研究的優(yōu)化方案

3.1 ASM引述

ASM 是一個(gè) Java 字節(jié)碼操控框架。它能被用來動(dòng)態(tài)生成類或者增強(qiáng)既有類的功能。ASM 可以直接產(chǎn)生二進(jìn)制 class 文件,也可以在類被加載入 Java 虛擬機(jī)之前動(dòng)態(tài)改變類行為。Java class 被存儲(chǔ)在嚴(yán)格格式定義的 .class 文件里,這些類文件擁有足夠的元數(shù)據(jù)來解析類中的所有元素:類名稱、方法、屬性以及 Java 字節(jié)碼(指令)。ASM 從類文件中讀入信息后,能夠改變類行為,分析類信息,甚至能夠根據(jù)用戶要求生成新類。說白了asm是直接通過字節(jié)碼來修改class文件。

3.2 思路

ASM 可以在編譯時(shí)候修改字節(jié)碼,也就是說,我們可以通過ASM 動(dòng)態(tài)注入 埋點(diǎn)代碼。對(duì)于原有項(xiàng)目入侵小,不需要額外增加基類,同時(shí)可以把埋點(diǎn) 業(yè)務(wù)邏輯抽離出來作為module單獨(dú)維護(hù)。

3.3 實(shí)現(xiàn)步驟

1.利用buildSrc 方式 構(gòu)建gradle 插件(方便實(shí)時(shí)修改調(diào)試)
2.再利用Plugin Transform 在編譯class 文件時(shí)候 注入代碼
以下是圖文介紹具體步驟
(1)創(chuàng)建以buildSrc 命名的 moudle ,刪除多余文件,新建groovy文件夾


img1.png

(2)在resources 文件夾下創(chuàng)建 xxxx.properties 文件并設(shè)置implementation-class ,properties 文件名為 插件對(duì)外引用名稱既主項(xiàng)目引用插件名,implementation-class 定義插件 主文件。

implementation-class=com.awarmisland.plugin.CusPlugin

(3) 設(shè)置buildSrc 的 build 文件,引入groovy ,和 gradle api 同步下項(xiàng)目

apply plugin: 'groovy'  //必須
apply plugin: 'maven'
dependencies {
    implementation gradleApi() //必須
    implementation localGroovy() //必須
    //如果要使用android的API,需要引用這個(gè),實(shí)現(xiàn)Transform的時(shí)候會(huì)用到
    implementation 'com.android.tools.build:gradle:3.1.3'
    implementation 'com.android.tools.build:gradle-api:3.1.3'
}
repositories {
    google()
    jcenter()
    mavenCentral() //必須
}

(4)主項(xiàng)目引入buildSrc插件

apply plugin: 'com.awarmisland.plugin'

(5) CusPlugin 繼承 PluginProject, 通過apply 添加需要執(zhí)行的task,Transform 就是我們需要編寫的 自定義編譯class task 可以引入多個(gè)task, 當(dāng)我們執(zhí)行build project時(shí)候,在AS build窗口會(huì)看到我們自定義的task。

def android = project.extensions.getByType(AppExtension)
 //注冊(cè)Transform
android.registerTransform(new ActivityLifecycleTransform(project),Collections.EMPTY_LIST)
android.registerTransform(new FragmentLifecycleTransform(project),Collections.EMPTY_LIST)
android.registerTransform(new RecordTransform(project),Collections.EMPTY_LIST)

(6)定義BaseTransform 主要設(shè)計(jì)目的是 為了抽離編譯過程的代碼,統(tǒng)籌分類處理。Transform task 任務(wù)是依次執(zhí)行,所以當(dāng)我們讀取了class 文件修改處理后,需要覆蓋原來文件,交給下一個(gè)task 執(zhí)行。p.s. 理論大概就是這樣,這里需要小心處理,不然很容易編譯不通過。

abstract class BaseTransform extends Transform implements TransformInterface{
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        def transformName = getName();
        println '--------------- '+transformName+' visit start --------------- '
        def startTime = System.currentTimeMillis()
        Collection<TransformInput> inputs = transformInvocation.inputs
        TransformOutputProvider outputProvider = transformInvocation.outputProvider
        //刪除之前的輸出
        if (outputProvider != null)
            outputProvider.deleteAll()
        //遍歷inputs
        inputs.each { TransformInput input ->
            //遍歷directoryInputs
            input.directoryInputs.each { DirectoryInput directoryInput ->
                //處理directoryInputs
                handleDirectoryInput(directoryInput, outputProvider)
            }

            //遍歷jarInputs
            input.jarInputs.each { JarInput jarInput ->
                //處理jarInputs
                handleJarInputs(jarInput, outputProvider)
            }
        }
        def cost = (System.currentTimeMillis() - startTime) / 1000
        println '--------------- '+transformName+' visit end --------------- '
        println transformName+" cost : $cost s"
}

 /**
     * 遍歷sdk 中的class
     * 處理Jar中的class文件
     */
     void handleJarInputs(JarInput jarInput, TransformOutputProvider outputProvider) {
        if (jarInput.file.getAbsolutePath().endsWith(".jar")) {
            //重名名輸出文件,因?yàn)榭赡芡?會(huì)覆蓋
            def jarName = jarInput.name
            def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
            if (jarName.endsWith(".jar")) {
                jarName = jarName.substring(0, jarName.length() - 4)
            }

            JarFile jarFile = new JarFile(jarInput.file)
            Enumeration enumeration = jarFile.entries()
            File tmpFile = new File(jarInput.file.getParent() + File.separator + "classes_temp.jar")
            //避免上次的緩存被重復(fù)插入
            if (tmpFile.exists()) {
                tmpFile.delete()
            }
            JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile))
            //用于保存
            while (enumeration.hasMoreElements()) {
                JarEntry jarEntry = (JarEntry) enumeration.nextElement()
                String entryName = jarEntry.getName()
                ZipEntry zipEntry = new ZipEntry(entryName)
                InputStream inputStream = jarFile.getInputStream(jarEntry)
//                println("className: "+entryName)
                jarOutputStream.putNextEntry(zipEntry)
                //處理 插樁class
                if(isModifyClass(entryName)&&entryName.endsWith(".class")){
                    byte[] code = modifyClass(entryName, IOUtils.toByteArray(inputStream))
                    if(code){
                        jarOutputStream.write(code)
                    }else{
                        jarOutputStream.write(IOUtils.toByteArray(inputStream))
                    }
                }else{
                    jarOutputStream.write(IOUtils.toByteArray(inputStream))
                }
                jarOutputStream.closeEntry()
            }
            //結(jié)束
            jarOutputStream.close()
            jarFile.close()

            def dest = outputProvider.getContentLocation(jarName + md5Name,
                    jarInput.contentTypes, jarInput.scopes, Format.JAR)
            FileUtils.copyFile(tmpFile, dest)
            tmpFile.delete()
        }
    }

(7)現(xiàn)在來看看實(shí)踐,我們需要在 Activity 生命周期中埋點(diǎn),記錄Activity 生命周期事件。這個(gè)時(shí)候我們就得在基類FragmentActivity 中 注入我們的埋點(diǎn)代碼。
????前面的BaseTransform 基類 已經(jīng)封裝好 遍歷class 的調(diào)度方法。我們繼承它 定義一個(gè)ActivityLifecycleTransform,isModifyClass 用于過濾需要修改的class 文件,modifyClass為主要處理 注入代碼邏輯方法。
????重點(diǎn)來了,如何實(shí)現(xiàn)代碼注入呢?代碼注入就是需要 修改class 文件,ASM 幫到你。(其實(shí)還有其它庫,比如Javassist)
????ASM是字節(jié)碼處理庫,常用處理元素ClassVisitor MethodVisitor 對(duì)應(yīng) 類訪問,方法訪問。在modifyClass 方法中,我們通過ClassReader 讀取 class 文件,再通過ClassWrite 授權(quán)修改class 文件。

class ActivityLifecycleTransform extends BaseTransform {

    ActivityLifecycleTransform(Project p) {
        super(p)
    }

    @Override
    String getName() {
        return "ActivityLifecycleTransform"
    }

    @Override
    boolean isModifyClass(String className) {
        if ("android/support/v4/app/FragmentActivity.class".equals(className)) {
            return true
        }
        return false
    }

    byte[] modifyClass(String className, byte[] classBytes) {
        println '----------- deal with "class" file <' + className + '> -----------'
        ClassReader classReader = new ClassReader(classBytes)
        ClassWriter classWriter = new ClassWriter(classReader,ClassWriter.COMPUTE_MAXS)
        ClassVisitor cv = new LifecycleClassVisitor(classWriter)
        classReader.accept(cv,EXPAND_FRAMES)
        return classWriter.toByteArray()
    }
}

(8)自定義LifecycleClassVisitor 集成ClassVisitor , ClassVisitor 對(duì)類 內(nèi)部元素讀取也是有規(guī)律的,我們暫時(shí)不研究,對(duì)方法的讀取回調(diào)在visitMethod ,我們可以獲取到 方法名name, 通過方法名過濾出需要埋點(diǎn)的方法。

public class LifecycleClassVisitor extends ClassVisitor {
    private String mClassName;

    public LifecycleClassVisitor(ClassVisitor cv) {
        super(Opcodes.ASM5,cv);
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
//        System.out.println("LifecycleClassVisitor:visit----->started"+name);
        this.mClassName = name;
        super.visit(version, access, name, signature, superName, interfaces);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { ;
        MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
        LifecycleMethodVisitor method  = new LifecycleMethodVisitor(mv);
        //匹配FragmentActivity
        if ("onResume".equals(name)
                ||"onStop".equals(name)) {
            //處理onCreate
            method.setLifecycleName(name);
            return method;
        }
        return mv;
    }
}

(9) 和ClassVistor 一樣,MethodVisitor 用于 訪問 method 中代碼,也是有其訪問規(guī)律,可以說是訪問的生命周期。 visitCode 開始訪問代碼,此時(shí),我們開始在這里注入字節(jié)代碼。mv.xxxx 6行代碼 其實(shí)代表著 DotComponent.getInstance().recordLifecycle(this.getClass().getName(), lifecycleName); 這一句埋點(diǎn) 執(zhí)行邏輯代碼。java在編譯成class 文件前,會(huì)先轉(zhuǎn)化成 機(jī)器可識(shí)別的字節(jié)碼 ,然后再編譯成二進(jìn)制碼。 現(xiàn)在我們就用ASM 語法手動(dòng)創(chuàng)建了 需要注入的邏輯代碼的字節(jié)碼。這個(gè)時(shí)候肯定有人問,那注入代碼 豈不是需要另外學(xué)習(xí)字節(jié)碼的語法規(guī)則? 其實(shí)總得來說,如果你需要深入定制,就有必要學(xué)習(xí)了,但是我們只是簡單使用的話,知道一點(diǎn)皮毛就ok ,而且我們是可以通過工具生成字節(jié)碼的。

public class LifecycleMethodVisitor extends MethodVisitor {

    private String lifecycleName;

    public LifecycleMethodVisitor(MethodVisitor mv){
        super(Opcodes.ASM5, mv);
    }

    @Override
    public void visitCode() {
        super.visitCode();
        //方法執(zhí)行后插
        //  DotComponent.getInstance().recordLifecycle(this.getClass().getName(), lifecycleName);
        mv.visitMethodInsn(Opcodes.INVOKESTATIC, DOT_PATH, "getInstance", "()L"+DOT_PATH+";", false);
        mv.visitVarInsn(Opcodes.ALOAD, 0);
        mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Object", "getClass", "()Ljava/lang/Class;", false);
        mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Class", "getName", "()Ljava/lang/String;", false);
        mv.visitLdcInsn(lifecycleName);
        mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, DOT_PATH, "recordLifecycle", "(Ljava/lang/String;Ljava/lang/String;)V", false);
    }
}

Plugins 搜索 ASM 找到ASM Bytecode Outline 安裝,然后就可以在需要 注入的java 文件 右鍵 生成字節(jié)碼,具體的方法可以找度娘,很多介紹的


img2.png

最后~ build project 就會(huì)將代碼注入到FragmentActivity onResume 方法中


img3.png

img4.jpg

4.Github地址

https://github.com/awarmisland/BuryingPoint

5.參考文章

http://www.woshipm.com/data-analysis/450268.html
https://blog.csdn.net/jiang547860818/article/details/64121698?utm_source=blogkpcl0
http://www.itdecent.cn/p/a1e6b3abd789
關(guān)于plugin debug 可參考這個(gè)文章
http://www.itdecent.cn/p/99c8e953654e

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容