1. 前言

在运营分析中,DAU(Daily Active User)、UV(Unique Visitor)和用户使用时长是最常见的三个指标。对于一个 App 来说,三个指标的含义如下:

  • DAU:日活跃用户数;
  • UV:独立访客;
  • 用户使用时长:App 使用时长。

根据上面的描述可知,DAU 和 UV 的统计分析与 App 启动事件息息相关,用户使用时长则需要通过 App 退出事件进行分析。

在神策分析中,统计上述三个指标的方式如下:

  • DAU:通过查询 App 每日启动的独立用户数来统计;
  • UV:通过查询 App 启动总的用户数来统计;
  • 用户使用时长:通过查询 App 退出事件平均时长来统计。

神策 Android SDK 已经开源四年多了,这段时间内已经陆续发布了近 160 个版本,从最初的仅支持代码埋点到现在支持全埋点、可视化全埋点等功能。其中,全埋点的启动事件和退出事件采集也经过了不断地演进,作者作为方案设计的参与者整理了整个过程。下面逐步向大家进行介绍,希望对大家有所帮助,更希望得到一些指导意见。

2. 基本原理

因为在 Android 系统中没有开放的 API 接口监听 App 的启动与退出,所以无法直接从系统层面精确地判断 App 的启动与退出。在 Android 中 App 的页面承载是基于 Activity,因此可以尝试从 Activity 启动个数的维度来判断 App 的启动和退出。总的来说,当监测到第一个 Activity 打开的时候标记为 App 启动;监测到最后一个页面关闭的时候标记为 App 退出。

在 Android 系统中,可以通过在 Application 中注册 Application.ActivityLifecycleCallbacks 回调来统计 Activity 的切换,从而达到对 App 启动和退出事件的埋点采集,代码如下:

ActivityLifecycleCallbacks
public interface ActivityLifecycleCallbacks {
    void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState);
    void onActivityStarted(@NonNull Activity activity);
    void onActivityResumed(@NonNull Activity activity);
    void onActivityPaused(@NonNull Activity activity);
    void onActivityStopped(@NonNull Activity activity);
    void onActivityDestroyed(@NonNull Activity activity);
}

代码释义如下:

  • onActivityCreated:当 Activity 调用 super.onCreate() 时触发;
  • onActivityStarted:当 Activity 调用 super.onStart() 时触发;
  • onActivityResumed:当 Activity 调用 super.onResume() 时触发;
  • onActivityPaused:当 Activity 调用 super.onPause() 时触发;
  • onActivityStopped:当 Activity 调用 super.onStop() 时触发;
  • onActivityDestroyed:当 Activity 调用 super.onDestroy() 时触发。

3. 方案演进

3.1. 初期版本 1.0

3.1.1. 原理简介

在初期版本中,通过监测 Activity 的 onStart 和 onStop 生命周期函数来判断 App 启动和退出:

  1. 在 SDK 初始化时注册 Application.ActivityLifecycleCallbacks 监听,同时内部维护一个 Activity 的计数器;
  2. 当触发 onActivityStarted 回调时,如果 Activity 计数器个数为 0,则表示 App 打开的第一个页面(即 App 启动)。此时,触发 App 启动事件,同时计数器加 1;
  3. 当触发 onActivityStopped 回调时,表示一个 Activity 页面不可见,则 Activity 计数器减 1。如果 Activity 计数器个数为 0,则表示 App 最后一个页面不可见(即 App 退出)。此时,触发 App 退出事件。

核心流程如图 3-1 所示:

图 3-1 初期版本的流程图

3.1.2. 具体实现

针对初期版本的启动退出事件采集的核心逻辑,代码示例如下:

核心逻辑实现
public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        registerActivityLifecycleCallbacks(new SensorsDataActivityLifecycleCallbacks());
    }
    static class SensorsDataActivityLifecycleCallbacks implements Application.ActivityLifecycleCallbacks {
        private static final String TAG = "SA.LifecycleCallbacks";
        private int mActivityCount;
        @Override
        public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {}
        @Override
        public void onActivityStarted(@NonNull Activity activity) {
            if (mActivityCount == 0) {
                Log.d(TAG, "App 启动");
            }
            mActivityCount++;
        }
        @Override
        public void onActivityResumed(@NonNull Activity activity) {}
        @Override
        public void onActivityPaused(@NonNull Activity activity) {}
        @Override
        public void onActivityStopped(@NonNull Activity activity) {
            mActivityCount--;
            if (mActivityCount == 0) {
                Log.d(TAG, "App 退出");
            }
        }
        @Override
        public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {}
        @Override
        public void onActivityDestroyed(@NonNull Activity activity) {}
    }
}

3.1.3. 优缺点

优点:

  1. 初期版本的实现较为简单;
  2. 较好地满足了早期的客户需求,能够比较准确地采集 App 启动事件和 App 退出事件。

缺点:

  1. 当 App 内出现多进程页面时,如果进行跨进程的页面切换会触发 App 退出,造成用户行为序列中断,对用户行为序列分析造成很大的困扰。

3.2. 中期版本 2.0

3.2.1. 原理简介

随着客户的增多,App 使用的场景越来越复杂,初期版本已经无法满足一些场景的需求,影响分析功能的体验。主要面临以下场景:

场景 1:App 前后台切换

在初期版本中,当 Activity 进入后台后,计数器个数为 0。此时,会触发 App 退出事件,再次进入 App 时则会触发 App 启动事件。如果频繁进行前后台切换,则会采集很多 App 启动事件和退出事件,这种场景下的 App 启动事件对 DAU 的实际分析没有意义。例如:用户在某个页面需要输入验证码,暂时切换 App 到后台查看短信中的验证码,再次打开 App 时会触发一对 App 启动和退出事件,这种场景下触发 App 启动和退出事件会将用户的逻辑行为序列中断,不利于实际的用户行为序列分析。

场景 2:App 跳转到第三方页面再返回

在一些场景下,App 启动和退出会将用户的实际行为序列进行中断,不利于实际的用户行为序列分析。例如:在一些商家服务的 App 中,用户完成订单后,点击支付按钮跳转到第三方的支付平台进行支付,支付完成后点击返回键重新进入 App 中的场景。初期版本的处理会触发一对 App 启动和退出事件,这种场景下的启动和退出就将用户的实际行为序列进行了中断。

场景 3:App 内部存在多进程的页面

当 App 内出现一个 Activity 页面在单独的进程时,由于初期版本中的页面计数器是不支持多进程间共享,导致同一个 App 内的多进程页面跳转,会触发 App 启动和退出事件,造成错误的 App 启动和退出事件采集。

场景 4:App 异常崩溃或强杀

App 中的异常崩溃或强杀是很常见的一种场景,但是在初期版本中是无法处理的,导致在这种场景下会无法采集到 App 的退出事件。

针对上述四种场景,我们给出了下面的解决方案:

  1. 针对场景 1 和场景 2,我们引入 Session 时长的概念。以默认的 Session 时长间隔 30s 来说,对于一个 App 而言,当它的一个页面退出了,如果在 30s 之内没有新的页面打开,我们就认为 App 进入后台了,此时会触发 App 退出事件;当它的一个页面显示出来了,如果与上一个页面退出时间的间隔超过了 30s,我们就认为这个应用程序重新处于前台了,此时会触发 App 启动事件;
  2. 针对场景 3,我们引入一个标记位,用于标识是否触发 App 退出事件。当 App 退到后台 30s 后,如果 App 退出事件标记位为 false,则触发 App 退出事件,同时重置 App 退出事件标记为 true。对于跨进程标记位的读取,我们采用 Android 系统的 “ContentProvider + SharedPreferences” 的方式来进行跨进程的数据共享,保证不同的进程间读取的 App 退出事件标记位的准确性;
  3. 针对场景 4,对于 App 的异常崩溃,通过引入 Thread.UncaughtExceptionHandler 自定义异常处理器来监听,在自定义的 uncaughtException 方法中完成对 App 退出的采集。因为无法捕捉 Native 端的一些崩溃或强杀场景,所以采用定时打点的功能进行 App 退出事件的信息保存。当下次打开 App 时,如果检测到 App 标记位为 false,则进行补发 App 退出事件,同时重置 App 退出事件标记为 true。

核心流程如图 3-2 所示:

图 3-2 中期版本的流程图

通过中期版本的流程图可以看到,针对 App 启动事件和退出事件的采集原理有较大的改动,明显比初期版本复杂很多。同时,涉及到更多的细节处理。例如:标记位存储失败、事件触发的时间戳保存等。

3.2.2. 具体实现

针对中期版本采集的核心逻辑,代码实现如下:

核心逻辑实现
package com.sensorsdata.analytics.android.demo;
import android.app.Activity;
import android.app.Application;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.CountDownTimer;
import java.lang.ref.WeakReference;
public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        registerActivityLifecycleCallbacks(new SensorsDataActivityLifecycleCallbacks());
    }
    static class SensorsDataActivityLifecycleCallbacks implements Application.ActivityLifecycleCallbacks {
        private static SensorsDatabaseHelper mDatabaseHelper;
        private static CountDownTimer countDownTimer;
        private final static int SESSION_INTERVAL_TIME = 30 1000;
        @Override
        public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
        }
        @Override
        public void onActivityStarted(@NonNull Activity activity) {
            mDatabaseHelper.commitAppStart(true);
            double timeDiff = System.currentTimeMillis() - mDatabaseHelper.getAppPausedTime();
            if (timeDiff > SESSION_INTERVAL_TIME) {
                if (!mDatabaseHelper.getAppEndEventState()) {
                    trackAppEnd();
                }
            }
            if (mDatabaseHelper.getAppEndEventState()) {
                mDatabaseHelper.commitAppEndEventState(false);
                trackAppStart();
            }
        }
        @Override
        public void onActivityResumed(@NonNull Activity activity) {
            //此处要开启定时打点,用于记录 App 退出事件信息
            timer();
        }
        @Override
        public void onActivityPaused(@NonNull Activity activity) {
            countDownTimer.start();
            cancelTimer();
            mDatabaseHelper.commitAppPausedTime(System.currentTimeMillis());
        }
        @Override
        public void onActivityStopped(@NonNull Activity activity) {
        }
        @Override
        public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {
        }
        @Override
        public void onActivityDestroyed(@NonNull Activity activity) {
        }
        // 触发 App 启动事件
        private void trackAppStart() {
        }
        // 触发 App 退出事件
        private void trackAppEnd() {
        }
        
        // 定时打点记录 App 退出信息
        private void timer() {
        }
        
        // 取消定时打点
        private void cancelTimer() {
        }
    }
}

3.2.3. 优缺点

优点:

  1. 实现了复杂场景中采集 App 启动和退出事件;
  2. App 启动和退出事件的采集更加准确。

缺点:

  1. 为了解决更多的场景,实现原理上也更加复杂;
  2. 对于频繁的存储标记位以及定时打点,这些业务处理都有很多的性能消耗。

3.3. 稳定版本 3.0

3.3.1. 原理简介

中期版本的核心目的是为了解决更复杂的场景,用来弥补初期版本的不足。随着用户的不断增多,对于 SDK 的性能提出了日益严格的要求,因此针对中期版本需要做一些优化。

中期版本中暴露的性能消耗点:

  • onActivityStarted 触发时,执行启动时间戳 AppStartTime 的频繁读取、App 退出事件标记位的频繁读取、Session 时长的频繁判断;
  • onActivityResumed 触发时,执行打点定时器的重新开启;
  • onActivityPaused 触发时,执行打点定时器的关闭。

在一个 App 中,当涉及到频繁的 Activity 切换时,会多次执行上面的生命周期函数。内部频繁的存取时间戳,会造成较大的资源消耗。那如何才能避免频繁的读取呢?顺着这个问题的排查思路,最终采用初期版本的计数器结合中期版本的跨进程通信来解决频繁的性能消耗。总的来说,就是 “只在第一个页面时,才执行 App 启动的逻辑判断。只在最后一个页面时,才执行 App 退出的逻辑判断”。

核心流程如图 3-3 所示:

图 3-3 稳定版本的流程图

在稳定版本中 App 退出事件会存在一个尝试补发的流程:

  • 如果本次启动时间戳与上次退出时间戳之差超过 session 时长时,则尝试补发;
  • 如果读取本地缓存的 App 退出信息为空则不进行补发,如果不为空则补发。

正常流程下,App 退出事件触发后会清除本地打点 App 退出信息。如果 App 退出事件已经触发,尝试补发时会读取到为空的 App 退出信息,就不会触发 App 退出事件。因此,该过程称之为尝试补发。通过稳定版本的流程图可以看到,相比较中期版本流程上有了很大的优化,也更容易理解,性能消耗也比中期版本低很多。

3.3.2. 具体实现

针对稳定版本的核心逻辑,代码实现如下:

核心逻辑实现
public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        registerActivityLifecycleCallbacks(new SensorsDataActivityLifecycleCallbacks());
    }
    static class SensorsDataActivityLifecycleCallbacks implements Application.ActivityLifecycleCallbacks {
        private static SensorsDatabaseHelper mDatabaseHelper;
        private int startActivityCount;
        private int startTimerCount;
        private final static int SESSION_INTERVAL_TIME = 30 1000;
        @Override
        public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
        }
        @Override
        public void onActivityStarted(@NonNull Activity activity) {
            // 读取 Activity 启动个数
            startActivityCount = mDatabaseHelper.getActivityCount();
            mDatabaseHelper.commitActivityCount(++startActivityCount);
            // 如果是第一个页面
            if (startActivityCount == 1) {
                boolean sessionTimeOut = isSessionTimeOut();
                if (sessionTimeOut) {// 如果超过 session 时长,尝试补发
                    trackAppEnd();
                }
                if (sessionTimeOut) {
                    // 触发 App 启动事件
                    trackAppStart();
                }
            }
            // 如果是第一个页面
            if (startTimerCount++ == 0) {
                /*
                 * 在启动的时候开启打点,退出时停止打点,在此处可以防止两点:
                 *  1. App 在 onResume 之前 Crash,导致只有启动没有退出;
                 *  2. 多进程的情况下只会开启一个打点器;
                 */
                timer();
            }
        }
        @Override
        public void onActivityResumed(@NonNull Activity activity) {
        }
        @Override
        public void onActivityPaused(@NonNull Activity activity) {
        }
        @Override
        public void onActivityStopped(@NonNull Activity activity) {
            // 停止计时器,针对跨进程的情况,要停止当前进程的打点器
            startTimerCount--;
            if (startTimerCount == 0) {
                cancelTimer();
            }
            startActivityCount = mDatabaseHelper.getActivityCount();
            startActivityCount = startActivityCount > 0 ? --startActivityCount : 0;
            mDatabaseHelper.commitActivityCount(startActivityCount);
            if (startActivityCount <= 0) {// 如果是最后一个页面,则发起倒计时触发 App 退出
                //TODO 倒计时的逻辑,倒计时结束后触发 App 退出
                trackAppEnd();
            }
        }
        @Override
        public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {
        }
        @Override
        public void onActivityDestroyed(@NonNull Activity activity) {
        }
        // 触发 App 启动事件
        private void trackAppStart() {
        }
        // 触发 App 退出事件
        private void trackAppEnd() {
        }
        // 定时打点记录 App 退出信息
        private void timer() {
        }
        // 取消定时打点
        private void cancelTimer() {
        }
        private boolean isSessionTimeOut() {
            long currentTime = Math.max(System.currentTimeMillis(), 946656000000L);
            return Math.abs(currentTime - mDatabaseHelper.getAppEndTime()) > SESSION_INTERVAL_TIME;
        }
    }
}

上面示例代码只是对核心逻辑的大致实现,更详细实现可参考神策 Android SDK

3.3.3. 优缺点

优点:

  1. 降低了频繁的标记位存取,提高了效率;
  2. App 启动和退出事件的采集更加准确。

缺点:

  1. 仍然有部分业务在主线程中实现;
  2. 虽然对于方案进行了优化,但是实现原理还是较为复杂。

4. 总结

神策 Android SDK 实现的 App 启动和退出的采集方案,是随着对实际场景的理解加深而不断发生改变。目前使用的是稳定版本,经过大量客户的验证后已经趋于稳定,关于 App 启动和退出的相关问题也比较少。后续我们对于稳定版本的优化是将 App 启动和退出的相关逻辑从主线程中剥离出来,进一步降低对主线程的影响。

Android 系统中对于 App 启动和退出的精确检测是一个值得研究的课题,欢迎大家参与进来一起交流。