Android無埋點資料採集實戰(附原始碼,兩行程式碼即可接入)

harvie發表於2019-10-22

前段時間剛做完公司無埋點資料採集專案,跟大家分享一下。

以下只有部分核心程式碼,完整原始碼及接入流程請移步

github:github.com/harvie1208/…

專案背景

當前手動程式碼埋點的方式,效率低、成本高、見效慢,故開發一套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)

Android無埋點資料採集實戰(附原始碼,兩行程式碼即可接入)

通過上圖可以看出,我們就是在class檔案打包到dex檔案的過程中增加transform任務,執行插入程式碼
複製程式碼

無埋點技術實現(gradle外掛方式)

1.編寫gradle外掛模組(groovy檔案實現)

看到groovy檔案不要慌,可以把它當做java寫
複製程式碼
  • 1.工程下建立buildSrc模組(系統保留名稱)

Android無埋點資料採集實戰(附原始碼,兩行程式碼即可接入)

  • 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私服

  • 6.接入App

    以下是我製作好的庫,可直接通過gradle引入使用,祥見:github.com/harvie1208/…

    歡迎star、評論,下期繼續優化

總結

  • 優點

    1.使用者資料反饋及時

      專案上線即可收集到相關資料,無需後期查補埋點。
    複製程式碼

    2.節省人力成本

      一次整合,後期無需再開發,也無需在和產品、運營溝通基礎資料埋點相關問題
    複製程式碼
  • 缺點

    1.目前還無法採集業務資料

      當前僅對click、pv等事件採集,業務資料需通過手動埋點,在考慮通過前後端的一些約定配置採集
    複製程式碼

    2.當頁面改版時,需要及時重新配置採集別名

    當前事件識別符號是根據view的佈局路徑產生的,我們直接將此路徑md5加密上傳至後臺,路徑的中文名是通過另一套視覺化介面編輯上傳至後臺配置中心的。一旦頁面發生變化,某個按鈕的位置很有可能會變動,需要通過後端api給此路徑重新命名

相關文章