无痕埋点实现方案的初步尝试

本文主要讨论无痕埋点的无痕操作,不涉及埋点存储,上传等问题,比较适合项目中使用了友盟以及类似方案的小伙伴,项目地址

需求和解决方案分析

在做一个项目时,正常情况下,都会有埋点记录用户行为的需求,大多公司都会使用友盟或类似的方案进行代码埋点,在需要埋点的地方,调用相关的方法记录一下,在合适的时候进行上报,具体如何记录,如何上报由SDK决定,我们只需要关心如何在合适的地方加入类型如下的代码:

public static void onEvent(Context context, String eventID);
public static void onEvent(Context context, String eventID, String label);

比较简单的方法就是在代码的各处手动加入埋点的操作,手动埋点虽然比较灵活简单,但就会存在如下几个问题:

  1. 埋点代码和业务代码耦合太严重,可能所有点击的地方都需要加入埋点,需要大量重复操作,不够优雅。
  2. 埋点容易出错且难以维护,每个版本都可能存在增删改埋点,一般都会面对一个Excel表格,一改改半天,过两天要发布了,产品又发来一个表格,埋点需要轻微的修改。
  3. 埋点一旦上线后就无法增减或修改。

针对上面几个问题,我们逐一寻找解决方案:

  1. 代码耦合问题:

    • 通过设置View.AccessibilityDelegate或者直接反射替换点击事件。
    • 通过AOP的方式,针对需要埋点的事件、方法等,插入埋点相关的代码。
    • 将控件直接替换为我们自己的控件,在控件内部进行埋点
  2. 维护问题:

    我们针对需要埋点的控件,生成一个唯一标识,如:Activity+层层布局ClassName+id/index。通过id与和埋点内容建立一套映射关系,当需要触发埋点时,根据标识获取到内容,进行埋点。

  3. 动态修改:

    这就比较简单了,在2的方案下,可以直接下发映射关系到APP中,可以加入版本的概念,毕竟埋点本身并不会经常修改。

代码耦合问题的具体实现

  1. View.AccessibilityDelegate

    Android系统提供了View.AccessibilityDelegate来帮助视力,肢体残疾等用户来使用Android设备。

    可以查看View.performClick()源码:

        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);
    
            notifyEnterOrExitForAutoFillIfNeeded(true);
    
            return result;
        }
    

    在点击之后,会调用 sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);

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

    最后会调用AccessibilityDelegate.sendAccessibilityEvent(View host, int eventType)方法。我们自定义AccessibilityDelegate,在相关的方法中埋点。

    我们可以在Application的onCreate()方法中,通过application.registerActivityLifecycleCallback监听所有Activity的生命周期,遍历所有控件,设置AccessibilityDelegate,并给RootView注册 ViewTreeObserver.OnGlobalLayoutListener 监听器,在布局发生变化时,重新遍历控件设置AccessibilityDelegate。

    这个方案存在两个缺陷,1、无法给Dialog和PopupWindow埋点,2、需要多次遍历控件,影响 性能

  2. 反射

    我们查看View的源码可以发现,源码中有这样一个类ListenerInfo,所有的事件都是存放在这个类中,我们可以写一个View.OnClickListener代理类,通过View.hasOnClickListeners()判断是否有点击事件,直接替换点击事件。具体代码如下:

        // hook一个View的监听器,替换成上面的代理监听器
        public void hookListener(View view) {  
            // 1. 反射调用View的getListenerInfo方法(API>=14),获得mListenerInfo对象
            Class viewClazz = Class.forName("android.view.View");
            Method getListenerInfoMethod = viewClazz.getDeclaredMethod("getListenerInfo");
            if (!getListenerInfoMethod.isAccessible()) {
                getListenerInfoMethod.setAccessible(true);
            }
            Object mListenerInfo = listenerInfoMethod.invoke(view);
    
            // 2. 然后从mListenerInfo中反射获取mOnClickListener对象
            Class listenerInfoClazz = Class.forName("android.view.View$ListenerInfo");
            Field onClickListenerField = listenerInfoClazz.getDeclaredField("mOnClickListener");
            if (!onClickListenerField.isAccessible()) {
                onClickListenerField.setAccessible(true);
            }
            View.OnClickListener mOnClickListener = (View.OnClickListener) onClickListenerField.get(mListenerInfo);
    
            // 3. 创建代理的点击监听器对象
            View.OnClickListener mOnClickListenerProxy = new OnClickListenerProxy(mOnClickListener);
    
            // 4. 把mListenerInfo的mOnClickListener设成新的onClickListenerProxy
            onClickListenerField.set(mListenerInfo, mOnClickListenerProxy);
            // 用这个似乎也可以:view.setOnClickListener(mOnClickListenerProxy);     
        }
    

    参考代码来自点我达的一篇博客,这种方式也存在很明显的缺点,第一仍然需要循环所有控件,第二,何时进行替换,存在这样的情况:点击事件是网络请求之后再设置,那么这时候可能就会无法替换到,第三,存在一些控件通过performClick()来触发点击事件,而非OnClickListener,比如TabLayout。

  3. 通过AOP的方式插入埋点代码,这里主要介绍Aspectj在Android中的使用

    首先需要进行配置,原本想要使用AspectJ,需要大量的配置。当然也有简单的方法,这样可以直接使用沪江的AspectJX,具体的配置方法见项目介绍。

    AspectJ具体的使用,可以查看网络上的一些资料,这篇文章写的挺好。

    Aspectj有很多高深的写法,主要还是靠自己多写多尝试。这里我写了一个点击事件的埋点

        @Aspect
        public class ViewCore extends BaseCore {
    
            /**
             * 这是自定义注解的切点,如果在方法上加入了{@link Event},就认定是一个切点
             */
            @Pointcut("execution(@com.warm.someaop.annotation.Event * *(..))")
            public void method() {
    
            }
    
            /**
             * {@link android.view.View.OnClickListener#onClick(View)}的切点
          * 第二段为lambda的写法,
             * @param view
             */
            @Pointcut(value = "(execution(* android.view.View.OnClickListener.onClick(android.view.View))&&args(view))||(execution(void *..lambda*(android.view.View))&&args(view))")
            public void onClick(View view) {
    
            }
    
            /**
             * 具体的通知方法,当Pointcut中的方法被调用之后,触发该方法对一些信息进行拦截
             * @param joinPoint
             * @param view
             * @param obj
             * @throws Throwable
             */
            @After("onClick(view)&&!method()&&this(obj)")
            public void injectOnClick(JoinPoint joinPoint, View view,Object obj) throws Throwable {
                Trace trace = Data.getEvent(getName(joinPoint, view));
                if (trace != null) {
                    track(trace.getId(), trace.getValue());
                }
            }
    
    
            private String getName(JoinPoint joinPoint, View view) {
    
                StringBuilder sb = new StringBuilder();
    
                sb.append(getViewName(view))
                        .append("$")
                        .append(getClassName(joinPoint.getTarget().getClass()))
                        .append("$")
                        .append(getClassName(view.getContext().getClass()));
    
                String md5 = Utils.toMD5(sb.toString());
    
                if (BuildConfig.DEBUG) {
                    Log.d(TAG, "getName: " + sb.toString() + ",MD5: " + md5);
                }
    
                return md5;
            }
        }
    

    我们定义一个数据管理类,本项目中的Data,简单模拟了数据的存入和获取,在Application类中存入所有的埋点,实际项目中应该由网络直接下发。当我们拦截到点击事件后,从数据管理类中获取到埋点信息。这个方案也存在的一些问题:主要针对一些lambda表达式,并不能很好的拦截,比如:this::onClick这样的写法,就没办法统一拦截,只能单独写切点,需要我们在写代码时能统一一些格式,或者我们直接拦截setOnClickListener,将点击事件替换成我们自己的代理类,大致代码如下:

    
        @Pointcut("call(* android.view.View.setOnClickListener(android.view.View.OnClickListener))&&args(clickListener)")
        public void setOnClickListener(View.OnClickListener clickListener) {
    
        }
    
        @Around("setOnClickListener(clickListener)")
        public Object injectSetOnClickListener(ProceedingJoinPoint joinPoint, View.OnClickListener clickListener) throws Throwable {
            return joinPoint.proceed(new Object[]{new OnClickListenerProxy(clickListener)});
        }
    
  4. 通过Gradle插件,将控件的父类直接修改为我们自己的控件

    该方案的思路来源于美团的一篇博客,我进行了实践和补充,这是我认为比较完善的方案。

    我们可以自定义一些常用控件,将所有的控件都直接继承自定义控件,在我们自己的控件中进行埋点操作,这样中,可以拦截到更多的事件,稳定且可靠,比如如下的TTextView:

        public class TTextView extends TextView {
            public TTextView(Context context) {
                super(context);
            }
    
            public TTextView(Context context, AttributeSet attrs) {
                super(context, attrs);
            }
    
            public TTextView(Context context, AttributeSet attrs, int defStyleAttr) {
                super(context, attrs, defStyleAttr);
            }
    
            @Override
            public boolean performClick() {
                boolean click = super.performClick();
                Track.getTrack().getViewTracker().performClick(this);
                return click;
            }
        }
    

    我们在performClick()中进行埋点,任何点击事件,都必然会走这个方法,我们不必担心OnClickListener问题

    我们目前的开发基本都会添加Support包,并继承AppCompatActivity,我们在XML中使用的控件都会自动换成Appcompat**控件,V7包通过AppCompatViewInflater来进行转换,我们可以关注一下AppCompatDelegateImpl#createView方法:

        @Override
        public View createView(View parent, final String name, @NonNull Context context,
                @NonNull AttributeSet attrs) {
            if (mAppCompatViewInflater == null) {
                TypedArray a = mContext.obtainStyledAttributes(R.styleable.AppCompatTheme);
                String viewInflaterClassName =
                        a.getString(R.styleable.AppCompatTheme_viewInflaterClass);
                if ((viewInflaterClassName == null)
                        || AppCompatViewInflater.class.getName().equals(viewInflaterClassName)) {
                    // Either default class name or set explicitly to null. In both cases
                    // create the base inflater (no reflection)
                    mAppCompatViewInflater = new AppCompatViewInflater();
                } else {
                    try {
                        Class viewInflaterClass = Class.forName(viewInflaterClassName);
                        mAppCompatViewInflater =
                                (AppCompatViewInflater) viewInflaterClass.getDeclaredConstructor()
                                        .newInstance();
                    } catch (Throwable t) {
                        Log.i(TAG, "Failed to instantiate custom view inflater "
                                + viewInflaterClassName + ". Falling back to default.", t);
                        mAppCompatViewInflater = new AppCompatViewInflater();
                    }
                }
            }
    
            boolean inheritContext = false;
            if (IS_PRE_LOLLIPOP) {
                inheritContext = (attrs instanceof XmlPullParser)
                        // If we have a XmlPullParser, we can detect where we are in the layout
                        ? ((XmlPullParser) attrs).getDepth() > 1
                        // Otherwise we have to use the old heuristic
                        : shouldInheritContext((ViewParent) parent);
            }
    
            return mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
                    IS_PRE_LOLLIPOP, /* Only read android:theme pre-L (L+ handles this anyway) */
                    true, /* Read read app:theme as a fallback at all times for legacy reasons */
                    VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
            );
        }
    

    通过该方法我们得知,可以再Theme中设置AppCompatViewInflater,我们可以自定义TAppCompatViewInflater,替换成我们自己的控件。但是第三方库中的控件,我们自定义的控件,就会失效,所以,我们需要写一个插件,在编译时,将会打印,转换后的jar包路径,

    转换路径

将控件的父类修改成我们自定义的控件。我们可以查看修改后的代码,生成的路径为:app/build/intermediates/transforms/TrackTransform/debug,使用JD-GUI打开,我们可以看到:

转换后的代码

我给插件写了一个extension,可以排除不需要转换的包:

track {
    excludes = [
            "retrofit2","com/umeng/"
    ]
}

通过这样的方式,可以将所有的控件转换成自定义的控件,但是仍然还有一些缺陷,比如我们很多时候需要在代码中动态添加控件,可以调用addView()的方法,传入需要加入的控件,我们可能会存在new LinearLayout()或new Button()这种情况,我们还是不能转换,但是我们可以结合第一种方案,在ViewGroup.addView时,设置View.AccessibilityDelegate,这样既不就覆盖了所有的情况

    @Override
    public void onViewAdded(View child) {
        super.onViewAdded(child);
        AccessibilityDelegateHelper.onViewAdded(child);
    }

唯一标识和映射关系的具体实现

唯一标识的规则基本都是设计一个ViewPath,类似Activity+层层布局id这种,可以看看51信用卡这一篇文章网易HubbleData ,文中讲的也很清楚,主要是id和idEntryName的选择,index的优化以及特殊控件的优化,网上的文章基本千篇一律,大家的方案都是类似的,本项目也是如此。但是在生成ViewPath这个问题上,我们需要根据项目的实际情况,来生成符合项目ViewPath,下面介绍两种常见情况:

  1. 针对使用了类似友盟的埋点上传机制<font size=2>(本文针对的主要情况)</font>,生成的ViewPath都需要后台配置下发到APP中。针对这种情况,布局index一般是不需要加入到ViewPath中的,因为该方案都是针对固定的点位,那些需要动态添加的view或者ListView、RecyclerView中的点位,具体item是多少个,其实后台也是不知道的,我们不可能配置100个1000个埋点,我这边考虑父布局需要给子控件添加index的手动添加一下tag或者再建立一套映射关系专门映射父布局用以区分。当然,我们也可以针对特殊的控件进行特殊处理,这需要根据项目的实际情况。

  2. 还有一些埋点上传方案是将所有的控件点击事件都上传后台,由后台来捞有效的埋点,这种方案加入index还是有必要的,毕竟后台可以控制哪些需要index,哪些不需要,APP只管上传点位信息就行,也就是全埋点的方案,这样的方案缺点主要是浪费流量,针对性不强。

不论什么样的方案,无痕埋点都需要多维护点位,每次页面发生修改,可能都需要后台维护一下埋点,这样的工作可以分配给产品、测试、运营他们。

最后

无痕埋点不论如何”无痕“,针对的都是用户简单的固定的事件,这类事件可能占比高达6、7成,而与业务耦合严重的埋点,都还是需要进行手动代码埋点。在埋点技术的选择上,也没有必要为了无痕而无痕,更应关注项目的实际情况,选择合适的方案。

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

推荐阅读更多精彩内容