点我达Android无痕埋点实现详解

陈尔展

前言

埋点是数据采集的一种重要方法,无论是运营策略还是产品迭代,都需要有详细的数据来支撑。有了数据分析可以得到用户画像、用户的行为路径,不用盲目的去做大量低效的用户调研和无根据的分析,大大降低了我们的试错成本。

就目前而言,客户端埋点最常见的方式还是以代码埋点为主。代码埋点的方式虽然灵活多变,可以准确的获取各种数据,但是也存在不少痛点:

  • 业务需求总是多变的,漏埋点或者错埋点总是无法完全避免的,这时就只能等待下个版本迭代的时候补全了。
  • 增加开发与测试的工作量,不规范的埋点代码可能造成App Crash。
  • 埋点代码侵入业务代码中,埋点数量的不断增加,也给后续的版本迭代与代码维护增加难度。

产品、运营在版本发布前并不能完全预知自己需要收集的数据,等到版本发布之后才发现一些重要的埋点并没有采集,只能等待下个版本补充,可能为时已晚了。这时候我们就要引入无痕埋点的方案了,接下来我将详细讲解一下点我达商家Android端在无痕埋点方面的具体实现。

结合实际的开发成本、兼容性以及可扩展性来考虑,我们选后最后一种【全局Hook App的View】这种方式来实现我们无痕埋点功能。

1、如何准确识别每个View?

在开始无痕埋点全我们得先搞清楚一个问题,就是怎么为App中的每个View设置一个具有唯一性可用于识别View身份的ID呢?

可能我们首先想到的就是用view.getId()这个值,但是这个ID值其实是不可靠的,如果在布局文件没有制定id、或者直接在代码中new出来的情况下,view.getId()的结果都是NO_ID;并且不同版本的aapt编译出来的ID值可能是不一致的。

所以这么看来,我们只能另找出路来,我们尝试一下自己根据某种特征构建View ID了。所以View有什么靠谱特征可以满足唯一性和稳定性呢?本着面向google编程的原则,我们过来快速确定了一种方案,就是通过Page + ViewTree的方式。

1.1 提取ViewTree的ViewPath

首先我们先了解一下什么是ViewTree,话不多说直接上图: 直接用Android Studio--Tools--Layout Inspector就可以提取你App当前页面的View Tree了。

通过这棵ViewTree,我们可以给每个View提取一个坐标:

  • deep:View相对于rootView位于第几层级;
  • index:View相对于同层级的view来说排在第几个;

我们把ViewTree抽象成这个简单的图来分析。按照我们对坐标对定义,RelativeLayout2的deep为1,index为1。TextView2的deep为2,index为1。这样我们就可以提取出一个View的路径,我们把它称做ViewPath,它的表达式为:

TextView2: Root/RelativeLayout2[1]/TextView2[1]  

这个ViewPath可以描述为:

  • Root、RelativeLayout2、TextView2这些定义表示View类名。
  • / 斜杠就是用来表示层级的,Root的deep为0,RelativeLayout2为deep为1,TextView2的deep为2
  • [1] 这个综扩号的值就是表示该View处在同层级中的位置来,也可以理解为View在parent中的index。

至于Root,因为相当于是根结点,我们已经不关心它的index了,所以我们可以不用写它的[index]。

1.2 优化ViewPath
1.2.1 index优化

虽然我们上面提取的ViewPath已经基本可以用来识别一个View了。但是还是存在一些显而易见的问题,比如说在代码中动态插入一个View,那么这个View后面的index就全部会发生变化,这就会大大降低View的可识别性。 如图所示,如果在代码中动态插入一个FrameLayout1,那么后面的RelativeLayout2、LinearLayout3它们的index都会相对之前的+1。所以我们得想办法来优化一下。

我们把index的定义修改一下,我们将index的定义修改为:

View相对于同层级的相同类型的view来说排在第几个;  

这时候我们再来看一下上图各个View的ViewPath:

LinearLayout1:Root/LinearLayout1[0]  
RelativeLayout2:Root/RelativeLayout2[0]  
LinearLayout3:Root/LinearLayout3[1]  
FrameLayout1:Root/FrameLayout1[0]  

这样定义之后,正常插入一个不同类型的View其它的View的index都不会发生变化。

1.2.2 ListView、RecyclerView、ViewPager等可复用View优化

对于ListView、RecyclerView、ViewPager之类的可复用的View,我们以RecyclerView为例,一个屏幕完整只能显示5个itemView,那么这个RecyclerView实际上只包含有5个child,而如果此时我们有50个item数据要显示,那么我们这5个itemView与50个item数据是无法一一对应上的。对于埋点来说,我们肯定是希望能区分每个itemView的,那么有什么办法呢?

我们来分析一下这些可复用的View是否有用来区分自己itemView位置的属性嘛?答案肯定是显而易见的,这些可复用的View都可以通过获取itemView的position属性来区分每个itemView的位置。所以我们针对可复用的View的index可以做一下优化:

index:该itemView在其parent所处的position。  

具体各个常用的可复用View获取position的方式:

ListView:ListView.getPositionForView(itemView)  
RecyclerView:RecyclerView.getChildAdapterPosition(itemView)  
ViewPager:ViewPager.getCurrentItem()  
1.2.3 RootView优化

关于Root的划定,我们还得商榷一下。我们先将DecorView作为Root,那么以上面ViewTree图中任意一个View为例,一个View的ViewPath为:

DecorView/LinearLayout[0]/FrameLayout[0]/ActionBarOverlayLayout[0]/ContentFrameLayout[0]/RelativeLayout[0]/AppCompatTextView[0]  

这么一长串的ViewPath,DecorView/LinearLayout[0]/FrameLayout[0]/FitWindowsLinearLayout[0]/ContentFrameLayout[0]都是系统的layout,而RelativeLayout[0]/AppCompatTextView[0]则是我们开发过程中通过setContentView()设置的页面layout。

而DecorView/LinearLayout[0]/FrameLayout[0]/FitWindowsLinearLayout[0]/ContentFrameLayout[0]这一串系统layout,会因为不同的系统版本,不同的定制Rom而发生变化,所以如果我们的ViewPath如果把这一串包含进去,就会影响我们识别最终的目标View,所以我们的ViewPath可以优化为:

Page/RelativeLayout[0]/AppCompatTextView[0]

把系统那部分的layout给移除,值保留自己设置的contentView。而这个Page可是是你Activity的名称、或者根据某种条件让用户自己命名。

2、页面事件采集

对于无痕埋点,我们要采集的不止是View事件埋点,我们还需要采集用户的浏览数据。针对页面采集我们将Activity和Fragment区分开来分别采集。

2.1 Activity页面采集

针对Activity我们通过注册Application的registerActivityLifecycleCallbacks方法来监听拦截每个页面的生命周期,然后在各个生命周期的回调方法中上报页面事件埋点:

app.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {  
            @Override
            public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
                registerFragmentLifeCycle(activity);
                sendLog(activity, Page.ACTIVITY.ON_CREATE);
            }

            @Override
            public void onActivityStarted(Activity activity) {
                sendLog(activity, Page.ACTIVITY.ON_START);
            }

            @Override
            public void onActivityResumed(Activity activity) {

                sendLog(activity, Page.ACTIVITY.ON_RESUME);
            }

            @Override
            public void onActivityPaused(Activity activity) {
                sendLog(activity, Page.ACTIVITY.ON_PAUSE);
            }

            @Override
            public void onActivityStopped(Activity activity) {
                sendLog(activity, Page.ACTIVITY.ON_STOP);
            }

            @Override
            public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
                sendLog(activity, Page.ACTIVITY.ON_SAVE_INSTANCE_STATE);
            }

            @Override
            public void onActivityDestroyed(Activity activity) {
                sendLog(activity, Page.ACTIVITY.ON_DESTROY);
            }
        });

这种方式比较简单、而且稳定,但是这个注册方法支持Android4.0系统,所以针对4.0以下的系统我们得额外去Hook Instrumentation实例,去重写里面callActivityOnCreate、callActivityOnStart、callActivityOnResume等生命周期方法,由于我们等App最低从4.1开始适配,所以这个Hook方式就不详细展开说明来。

2.2 Fragment页面采集

Android的开发中其实存在两种Fragment:

android/support/v4/app/Fragment  
android/app/Fragment  

v4的Fragment比较容易,我们通过getSupportFragmentManager()方法可以拿到FragmentManager,然后在FragmentManager调用registerFragmentLifecycleCallbacks()来监听每个v4的Fragment的生命周期方法回调:

private void registerFragmentLifeCycle(Activity activity) {  
        if (!(activity instanceof FragmentActivity)) {
            return;
        }
        FragmentManager fm = ((FragmentActivity) activity).getSupportFragmentManager();
        if (fm == null) {
            return;
        }
        fm.registerFragmentLifecycleCallbacks(new FragmentManager.FragmentLifecycleCallbacks() {
            @Override
            public void onFragmentPreAttached(@NonNull FragmentManager fm, @NonNull Fragment f, @NonNull Context context) {
                super.onFragmentPreAttached(fm, f, context);
                sendLog(f, Page.FRAGMENT.ON_PRE_ATTACH);
            }

            @Override
            public void onFragmentAttached(@NonNull FragmentManager fm, @NonNull Fragment f, @NonNull Context context) {
                super.onFragmentAttached(fm, f, context);
                sendLog(f, Page.FRAGMENT.ON_ATTACH);
            }

            @Override
            public void onFragmentPreCreated(@NonNull FragmentManager fm, @NonNull Fragment f, @Nullable Bundle savedInstanceState) {
                super.onFragmentPreCreated(fm, f, savedInstanceState);
                sendLog(f, Page.FRAGMENT.ON_PRE_CREATE);
            }

            @Override
            public void onFragmentCreated(@NonNull FragmentManager fm, @NonNull Fragment f, @Nullable Bundle savedInstanceState) {
                super.onFragmentCreated(fm, f, savedInstanceState);
                sendLog(f, Page.FRAGMENT.ON_CREATE);
            }

            @Override
            public void onFragmentActivityCreated(@NonNull FragmentManager fm, @NonNull Fragment f, @Nullable Bundle savedInstanceState) {
                super.onFragmentActivityCreated(fm, f, savedInstanceState);
                sendLog(f, Page.FRAGMENT.ON_ACTIVITY_CREATE);
            }

            @Override
            public void onFragmentViewCreated(@NonNull FragmentManager fm, @NonNull Fragment f, @NonNull View v, @Nullable Bundle savedInstanceState) {
                super.onFragmentViewCreated(fm, f, v, savedInstanceState);
                sendLog(f, Page.FRAGMENT.ON_VIEW_CREATE);
            }

            @Override
            public void onFragmentStarted(@NonNull FragmentManager fm, @NonNull Fragment f) {
                super.onFragmentStarted(fm, f);
                sendLog(f, Page.FRAGMENT.ON_START);
            }

            @Override
            public void onFragmentResumed(@NonNull FragmentManager fm, @NonNull Fragment f) {
                super.onFragmentResumed(fm, f);
                sendLog(f, Page.FRAGMENT.ON_RESUME);
            }

            @Override
            public void onFragmentPaused(@NonNull FragmentManager fm, @NonNull Fragment f) {
                super.onFragmentPaused(fm, f);
                sendLog(f, Page.FRAGMENT.ON_PAUSE);
            }

            @Override
            public void onFragmentStopped(@NonNull FragmentManager fm, @NonNull Fragment f) {
                super.onFragmentStopped(fm, f);
                sendLog(f, Page.FRAGMENT.ON_STOP);
            }

            @Override
            public void onFragmentSaveInstanceState(@NonNull FragmentManager fm, @NonNull Fragment f, @NonNull Bundle outState) {
                super.onFragmentSaveInstanceState(fm, f, outState);
                sendLog(f, Page.FRAGMENT.ON_SAVE_INSTANCE_STATE);
            }

            @Override
            public void onFragmentViewDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) {
                super.onFragmentViewDestroyed(fm, f);
                sendLog(f, Page.FRAGMENT.ON_VIEW_DESTROY);
            }

            @Override
            public void onFragmentDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) {
                super.onFragmentDestroyed(fm, f);
                sendLog(f, Page.FRAGMENT.ON_DESTROY);
            }

            @Override
            public void onFragmentDetached(@NonNull FragmentManager fm, @NonNull Fragment f) {
                super.onFragmentDetached(fm, f);
                sendLog(f, Page.FRAGMENT.ON_DETACH);
            }
        }, true);
    }

而对于android/app/Fragment方式就比较麻烦了,并没有提供监听关于生命周期回调的监听方法,这里就只能用插桩大法了,自定义一个Plugin,利用Gradle在编译期间用ASM等库进行字节插入操作,扫描所有的android/app/Fragment方法,在onCreateView、onViewCreated、onResume等方法中插入自己的埋点代码。

总结

目前我们的无痕埋点虽然已经基本可以实现所有页面、View事件等数据的采集,但是还存在不少问题:

  • 关于业务数据的精准采集还比较困难,还不能准到跟手动代码埋点一样精确自如。
  • 目前我们的无痕埋点数据收集方式是通过全量采集后通过后台进行筛选,这就产生了大量的冗余数据,一定程度上浪费了一些终端与服务器的资源。
  • 版本迭代可能会造成的不同版本之前页面事件、View事件不一致,目前还没想到一个很简单的方式去管理。

当然后续我们持续去优化这个无痕埋点方案:

  • 完善配置化方案,通过后台的配置下发,精准打捞目标埋点,减少冗余数据,节省系统资源。
  • 开发圈选工具,使用圈选工具产品可以自己提取想要统计的View的ViewId、PageId等Key值。
  • 逐步增加无痕埋点覆盖面、改进兼容性。