Android全链路无痕埋点实践

背景

对无线开发同学而言,不管是支持业务数据采集,还是构建自动化测试体系,无痕埋点都是非常关键的技术路径之一。

目前我碰到一个需求就是要做端到端UI自动化回归链路,其思路在用户进行ui交互的时候,上报这些用户行为,也就是录制。后续需要回归用户行为的时候,根据上报的信息在端侧完成回放。这块的需求分解到端上也就是要做到以下两件事情

  1. 为全链路的所有控件打上唯一标签
  2. 统一拦截用户的交互行为,并上报上面设置的标签内容

基础能力

看到上面的设计流程,肯定会想到几个问题

  1. 如何拿到全链路的所有控件
  2. 控件的唯一标签怎么确定
  3. 用户的交互行为如何拦截

页面控件遍历

对页面的控件进行遍历,其实也就是从根View开始,通过getChildAt()方法去拿到所有的子 View,所以这个问题的根源是页面的根View是什么,它要怎么去获取。

这张是Activity,PhoneWindow,DecorView之间的关系图,可以看到Activity它的顶层View就是DecorView,它可以通过getWindow().getDecorVIew()来获取。

为了在页面变化,比如点击添加了一个新的TextView的时候,我们能实时的了解页面控件的变化,我们可以给ViewGroup添加setOnHierarchyChangeListener的监听,这个listener会在ViewGroup发生变化的时候通知到监听者。

还有一些特殊的页面变化,比如打开Dialog或者PopupWindow,他们并不是在原有DecorView中去添加View,而是在PhoneWindow上新建了一个DecorView去覆盖原来的DecorView,这种情况我们就需要去获取Dialog和Popupwindow生成的DecorView,重复上述流程来做到它上面控件的遍历。

控件唯一标签

拿到所有的控件后,后续就是给控件添加唯一标识。控件唯一标识这个概念出来的一瞬间,我们第一个想到的肯定是id。

1. ViewId生成唯一标识

ViewId是指控件在编写xml时设置的id,通过view.getResources().getResourceEntryName(view.getId()获取,该id在同一个布局文件中可以保证唯一。

  • 优点:

设置简单,生成的埋点信息可读性很高,方便后续人工读取定位问题

  • 缺点:

  • 需要人为的给每个控件设置id,拿到的就是null

  • id只保证在xml中唯一,不保证整个页面的唯一性

    标识不唯一,而且对业务的使用有前置条件,这显然是不符合我们的要求的,这时候我们又会想到一个思路,每个控件他的页面布局层级肯定是唯一的。

2. ViewPath生成唯一标识

ViewPath是指基于ViewTree,给每个view定义一个坐标,提取出一个view的构建路径。

比如这里的全部按钮通过Android Studio的Layout Inspector可以看到它的页面层级信息如下

其布局结构就可以被描述为ViewRootImpl/DecorView[0]/LinearLayout[0]/FrameLayout[0]/ActionBarOverlayLayout[0]/ContentFrameLayout[0]/FrameLayout[0]/FloorContainerView[0]/CoordinatorLayout[0]/LinearLayout[1]/ConstraintLayout[1]/AppCompatCheckBox[0]

  • FrameLayout,``FloorContainerView,``AppCompatCheckBox这些定义了控件的类型
  • /斜杠用来表示层级,DecorView的deep为1,FrameLayout为deep为4,AppCompatCheckBox的deep为12
  • [1] 这个中括号的值就是表示该View处在同层级中的位置来,也可以理解为View在parent中的index。为了减少插入新的View导致的ViewPath变化的问题,在星环中已经优化为View在同层级中相同类型View的第几个。

优点:

所有的只要在ViewTree中控件都可以通过ViewPath进行定义

缺点:

  • 埋点信息的可读性不高,人工定位麻烦
  • 只能保证用户行为的瞬间ViewPath唯一,页面控件发生变化的时候ViewPath可能会发生变化
  • 出现复用的View时,viewPath会出现重复,比如RecycleView包含20个item,每一页只能显示5个,你会看到第20个item的层级位置不是20,而是你当前在页面中显示的位置。

前两个问题在我们当前的场景中不是问题,在做页面回放的时候,只需要保持当前录制或者回放时候每个控件的ViewPath保持不变,就可以保证回放的可靠。

第三个问题我们就要针对这种控件复用的情况做特殊的处理,比如下面的这个View,我们就在后面给他打一个position的标签,表明他是一个复用的View,并且是复用的第几个

其布局结构就可以被描述为 ViewRootImpl/DecorView[0]/LinearLayout[0]/FrameLayout[0]/ActionBarOverlayLayout[0]/ContentFrameLayout[0]/LinearLayout[0]/RecyclerView[0]/LinearLayout[position5]/AppCompatTextView[0]

Android插桩技术

对于用户交互行为的拦截,其实就是在用户行为操作的调用方法上,去添加埋点的逻辑。这个就需要引入Android的插桩技术。Android插桩的思路大致有两种

1. hook

hook简单来说就是在运行时,通过动态或者静态代理的方式,给目标对象的目标变量添加埋点逻辑。

比如说给VIew的点击事件添加打log日志的需求,根据源码我们可以知道,他其实就是在setListener的过程中,给View添加了一个mListener的对象,后续只要有onClick的触发,都会调用listener的onClick方法。所以我们只要代理这个listener,在listener的onClick触发时去添加一段埋点逻辑就可以了

public class HookSetOnClickListenerHelper {
    public static void hook(Context context, final View v) {//
        try {
            // 步骤1:获取到View原理注册的listener变量
            // 反射执行View类的getListenerInfo()方法,拿到v的mListenerInfo对象,这个对象就是点击事件的持有者
            Method method = View.class.getDeclaredMethod("getListenerInfo");
            //由于getListenerInfo()方法并不是public的,所以要加这个代码来保证访问权限
            method.setAccessible(true);
            //这里拿到的就是mListenerInfo对象,也就是点击事件的持有者
            Object mListenerInfo = method.invoke(v);
            //要从这里面拿到当前的点击事件对象
            Class<?> listenerInfoClz = Class.forName("android.view.View$ListenerInfo");
            Field field = listenerInfoClz.getDeclaredField("mOnClickListener");
            final View.OnClickListener onClickListenerInstance = (View.OnClickListener) field.get(mListenerInfo);
            //步骤2: 新建一个代理类,该代理类执行onClick的时候会在处理被代理类这个方法的同时去执行我们的逻辑
            //步骤3:把新建的代理类放回到View中
            field.set(mListenerInfo, new ProxyOnClickListener(onClickListenerInstance));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    // 可以通过动态或者静态代理,这里采用的是静态代理
    static class ProxyOnClickListener implements View.OnClickListener {
        View.OnClickListener oriLis;
        public ProxyOnClickListener(View.OnClickListener oriLis) {
            this.oriLis = oriLis;
        }
        @Override
        public void onClick(View v) {
            Log.d("HookSetOnClickListener", "点击事件被hook到了");
            if (oriLis != null) {
                oriLis.onClick(v);
            }
        }
    }
}
复制代码
  • 优点:

作为功能模块,接入成本低,不需要引入三方库

  • 缺点:

  • hook需要通过反射,效率较低

  • hook完成后如果外部又重新设置了listener,hookListener就会被覆盖,导致逻辑没有被执行

  • hook只能处理这种变量,给变量添加方法逻辑,没法直接给比如Activity的onCreate这种方法添加逻辑

2. Aop

AOP表示面向切面编程,在Android中的表现形式主要有Aspectj和ASM。

Aspectj的介绍和基本使用就不在这里说明了,主要采用的是gradle_plugin_android_aspectjx插 件,详细的使用方法可以看Android AspectJ详解,其原理就是在编译时,把逻辑织入目标方法中。

比如说给VIew的点击监听事件添加打日志这个需求。本质已经在上面描述的,接下来我们就是找到通过aspectj找到对应的方法,给他后面添加上这块日志。

    @After("execution(* android.view.View.OnClickListener+.onClick(..))")
    public void afterOnClick(JoinPoint joinPoint) {
        Log.e(TAG, "点击事件被hook到了");
    }
复制代码
  • 优点:

  • 目标精准,代码量很少

  • 可以给method添加逻辑

  • 缺点:

  • 需要导入aspectj的库,在编译时可能会出现一些编译的问题

  • 由于其在编译时进行字符码的插入,会导致编译时间变长

实现方案

Aspectj方案实现

1. 页面控件打标

通过Aspectj拦截到Activity的onCreate,Dialog的show和popupwindow的showAsDropDown,通过window拿到对应的DecorView,遍历所有的子控件并给他们打标

@Aspect
public class TrackerAspect {
    @After("execution(* android.app.Activity+.onCreate(..))")
    public void onActivityResume(JoinPoint joinPoint) {
        Tracker.startViewTracker((Activity) joinPoint.getThis());
    }
    @After("call(* android.app.Dialog+.show(..))")
    public void onDialogShow(JoinPoint joinPoint) {
        Dialog dialog = (Dialog) joinPoint.getTarget();
        Tracker.startViewTracker(dialog.getWindow().getDecorView());
    }

    @After("call(* android.widget.PopupWindow+.showAsDropDown(..))")
    public void onPopupWindowShow(JoinPoint joinPoint) {
        PopupWindow popupWindow = (PopupWindow) joinPoint.getTarget();
        try {
            Field field = SuperClassReflectionUtils.getDeclaredField(popupWindow, "mDecorView");
            field.setAccessible(true);
            FrameLayout popupDecorView = (FrameLayout) field.get(popupWindow);
            Tracker.startViewTracker(popupDecorView);
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }
}
复制代码

其中对于复用型的View比如RecycleView就可以监听他的onBindViewHolder,然后通过RecycleView和下面的itemView,按照上面的理论来构建他的ViewPath

    @After("execution(* androidx.recyclerview.widget.RecyclerView.Adapter.onBindViewHolder(..))")
    public void afterRecycleAdapterOnBind(JoinPoint joinPoint) {
        Object[] args = joinPoint.getArgs();
        RecyclerView.ViewHolder viewHolder = (RecyclerView.ViewHolder) args[0];
        int position = (int) args[1];
        try {
            // 获取listView对象
            Field recyclerViewField = SuperClassReflectionUtils.getDeclaredField(viewHolder, "mOwnerRecyclerView");
            recyclerViewField.setAccessible(true);
            RecyclerView recyclerView = (RecyclerView) recyclerViewField.get(viewHolder);
            // 获取listView对象
            Tracker.setViewTracker(viewHolder.itemView, ViewPath.getPath(viewHolder.itemView, recyclerView, position));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
复制代码

2. 交互行为拦截

交互行为的拦截其实和上面的思路是一样的,找到监听触发的方法,插入埋点的代码。

    /**
     * 监控OnClick点击事件
     */
    @After("execution(* android.view.View.OnClickListener+.onClick(..))")
    public void afterOnClickMethodCall(JoinPoint joinPoint) {
    }
   /**
     * 监控OnLongClick点击事件
     */
    @After("execution(* android.view.View.OnLongClickListener+.onLongClick(..))")
    public void afterOnLongClickMethodCall(JoinPoint joinPoint) {
    }

    /**
     * 监控OnCheckChange点击事件
     */
    @After("execution(* android.widget.CompoundButton.OnCheckedChangeListener+.onCheckedChanged(..))")
    public void afterOnCheckChangeMethodCall(JoinPoint joinPoint) {
    }

    /**
     * 监控文本输入事件
     */
    @After("execution(* android.text.TextWatcher.onTextChanged(..))")
    public void afterOnTextChangedMethodCall(JoinPoint joinPoint) {
    }

    int scrollerState = 0;
    int scrollerX = 0;
    int scrollerY = 0;

    /**
     * 监控RecyclerView的滑动事件
     *
     * @param joinPoint
     */
    @Pointcut
    @After("call(* android.support.v7.widget.RecyclerView.onScrollStateChanged(..))")
    public void afterRecycleViewOnScrollStateChangedMethodCall1(JoinPoint joinPoint) {
        // 方法的几个参数
        Object[] args = joinPoint.getArgs();
        scrollerState = (int) args[0];
        switch (scrollerState) {
            case RecyclerView.SCROLL_STATE_DRAGGING:
                break;
            case RecyclerView.SCROLL_STATE_IDLE:
                // 当页面滚动停止的时候进行数据上报,并且置零参数1
                Log.d(TAG, "afterRecycleViewOnScrollStateChangedMethodCall1: " + scrollerX);
                Log.d(TAG, "afterRecycleViewOnScrollStateChangedMethodCall1: " + scrollerY);
                Log.d(TAG, "afterRecycleViewOnScrollStateChangedMethodCall getThis->" + ((RecyclerView) joinPoint.getThis()).getContentDescription()); //切面代码运行所在的类对象
                scrollerX = 0;
                scrollerY = 0;
                break;
            default:
        }
    }

    /**
     * 监控RecycleView的滑动事件
     */
    @Pointcut
    @After("call(* android.support.v7.widget.RecyclerView.onScrolled(..))")
    public void afterRecycleViewOnScrolledMethodCall1(JoinPoint joinPoint) {
        // 方法的几个参数
        Object[] args = joinPoint.getArgs();
        // 当滚动状态为1或者2的时候,移动距离进行叠加。
        if (scrollerState == RecyclerView.SCROLL_STATE_DRAGGING || scrollerState == RecyclerView.SCROLL_STATE_SETTLING) {
            scrollerX += (int) args[0];
            scrollerY += (int) args[1];
        }
    }
复制代码

Native方案实现

上面的Aspectj方案可以看到十分的简单,但是需要额外导入三方库,这就带来一些可能的兼容问题。而hook的方案会有后续被覆盖的危险,那Android有没有提供什么原生能解决这个问题的方案呢。

1. 页面控件打标

页面的初始化显示我们没法通过Aspectj来拦截,只能手动的在Activity的onCreate的时候去Tracker.startViewTracker(getWindow().getDecorView())

2. 交互行为拦截

1) 点击输入事件拦截

通过看View点击的源码可以发现,View的点击事件被执行时,有一个sendAccessibilityEvent被执行,只要为View设置了mAccessibilityDelegate,其点击事件会走到sendAccessibilityEvent的回调中。

public boolean performClick() {
    /.../
    // 发送click事件
    sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
    notifyEnterOrExitForAutoFillIfNeeded(true);
    return result;
}
public void sendAccessibilityEvent(int eventType) {
    if (mAccessibilityDelegate != null) {
        mAccessibilityDelegate.sendAccessibilityEvent(this, eventType);
    } else {
        sendAccessibilityEventInternal(eventType);
    }
}
复制代码

通过重写这个监听,这个我们就可以监听View的点击以及输入事件,这个监听当然也有可能被替换,但是替换的可能性远远会小于onClickListener。

HookOnClickListener.hook(view);
public class HookOnClickListener {
    private static final String TAG = "HookOnClickListener";

    public static void hook(View view) {
        view.setAccessibilityDelegate(new View.AccessibilityDelegate() {
            @Override
            public void sendAccessibilityEvent(View host, int eventType) {
                super.sendAccessibilityEvent(host, eventType);
                switch (eventType) {
                    // 页面点击
                    case AccessibilityEvent.TYPE_VIEW_CLICKED:
                        Log.d(TAG, "sendAccessibilityEvent: " + host.getContentDescription());
                        break;
                    // 页面输入
//                    case AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED:
//                        TrackEvent.postUTSendKey(host.getContentDescription().toString(), ((TextView) host).getText().toString());
//                        Log.d(TAG, "postUTSendKey: " + host.getContentDescription() + "|value:" + ((TextView) host).getText().toString());
//                    break;
                    // 其他
                    default:
                        break;
                }
            }
        });
    }
}
复制代码

2) 列表滑动事件拦截

recyclerView可以通过addOnScrollListener添加多个滑动的监听,这样我们就可以自定义一个滑动的监听而不需要担心被其他的监听覆盖

public class HookAddOnScrollListenerHelper {

    private static final String TAG = "HookAddOnScrollListener";

    public static void hook(final RecyclerView recyclerView) {//
        List<RecyclerView.OnScrollListener> mScrollListeners = (List<RecyclerView.OnScrollListener>) SuperClassReflectionUtils.getFieldValue(recyclerView, "mScrollListeners");
        if (mScrollListeners != null && !mScrollListeners.isEmpty()) {
            for (RecyclerView.OnScrollListener listener : mScrollListeners) {
                // 去掉重复的滑动监听
                if (listener instanceof HookOnScrollListener) {
                    return;
                }
            }
        }
        recyclerView.addOnScrollListener(new HookOnScrollListener());
    }

    static class HookOnScrollListener extends RecyclerView.OnScrollListener {
        private static final String TAG = "OnScrollListener";
        int scrollerState = 0;
        int scrollerX = 0;
        int scrollerY = 0;

        @Override
        public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
            super.onScrollStateChanged(recyclerView, newState);
            scrollerState = newState;
            switch (scrollerState) {
                case RecyclerView.SCROLL_STATE_DRAGGING:
                    break;
                case RecyclerView.SCROLL_STATE_IDLE:
                    // 当页面滚动停止的时候进行数据上报,并且置零参数1
                    Log.d(TAG, "afterRecycleViewOnScrollStateChangedMethodCall1: " + scrollerX);
                    Log.d(TAG, "afterRecycleViewOnScrollStateChangedMethodCall1: " + scrollerY);
                    Log.d(TAG, "afterRecycleViewOnScrollStateChangedMethodCall getThis->" + recyclerView.getContentDescription()); //切面代码运行所在的类对象
                    scrollerX = 0;
                    scrollerY = 0;
                    break;
                default:
            }

        }

        @Override
        public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
            super.onScrolled(recyclerView, dx, dy);
            if (scrollerState == RecyclerView.SCROLL_STATE_DRAGGING || scrollerState == RecyclerView.SCROLL_STATE_SETTLING) {
                scrollerX += dx;
                scrollerY += dy;
            }
        }
    }
}
复制代码

最后的方案可以根据项目的实际情况进行选择,两种方案各有各的好处。

作者:悠二
链接:https://juejin.im/post/6868148850302844941

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,612评论 5 471
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,345评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,625评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,022评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,974评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,227评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,688评论 3 392
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,358评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,490评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,402评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,446评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,126评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,721评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,802评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,013评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,504评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,080评论 2 341