如何精确计算Android应用的使用时长

应用时长的计算友盟早期做法计算每个Activity的时长,然后全部相加就是App的使用时长。后来的做法是在客户端计算,如果应用离开小于30秒内又切回就将切走的时间也算入App的使用时长内。

本人觉得既然是计算时长就应该是应用的实际使用时长,对于不在App内的时长就不应该统计在内。猜测可能是友盟考虑到计算量的问题,要服务端来计算使用时长至少是浪费资源的,客户端相对来说是比较容易计算出使用时长的。iOS由于系统给AppDelegate提供了前后台切换的接口所以很容易计算,而Android的前后台却没有App级别的,只有针对Activity生命周期的回调。在整个计算方法的实现上还是经历一些波折,我把整个思路做了个梳理。(以下方案都针对ApiLevel14+, ApiLevel14以前的版本还是由服务端计算每个页面的使用时长相加毕竟这类设备已经很少了,很多应用都不支持14以前的版本了)

方案一:

通过onStart, onStop来统计前台Activity数量是否是0->1, 1->0来判断是否到前台或者后台。(网上大多采用这个方案)

    private int foregroundActivityCount = 0;

    @Override
    public void onActivityStarted(Activity activity) {
        if (foregroundActivityCount == 0) {
            Log.i(TAG, "switch to foreground");
        }
        foregroundActivityCount += 1;
    }

    @Override
    public void onActivityStopped(Activity activity) {
        foregroundActivityCount -= 1;
        if(foregroundActivityCount == 0){
            Log.i(TAG, "switch to background");
        }
    }

本方案基本解决了Activity之间切换以及一些常规状态的处理。不过当遇到在最上层Activity有重建逻辑(比如:横竖屏旋转)时会有问题,Activity走的流程onPause->onStop->onDestory->onStart->onResume。这过程中onStop时前台Activity数量为0的情况,所以会有无缘无故多了一次前后天切换的逻辑,解决方法看方案二。

方案二:

在方案一的基础上,在onStop时检查Activity是否在changingConfiguration来决定是否计入前台Activity数量。

    private int foregroundActivityCount = 0;
    private boolean isChangingConfigActivity = false;

    @Override
    public void onActivityStarted(Activity activity) {
        if (foregroundActivityCount == 0) {
            Log.i(TAG, "switch to foreground");
        }

        if(isChangingConfigActivity){
            isChangingConfigActivity = false;
            return;
        }

        foregroundActivityCount += 1;
    }

    @Override
    public void onActivityStopped(Activity activity) {
        if(activity.isChangingConfigurations()){
            isChangingConfigActivity = true;
            return;
        }

        foregroundActivityCount -= 1;
        if(foregroundActivityCount == 0){
            Log.i(TAG, "switch to background");
        }
    }

此方案基本就能解决屏幕旋转造成的误判,不过在进行锁屏测试时又发现了新的问题,对于竖屏状态下锁屏方案二没有什么问题。但是对于支持横竖屏旋转的Activity先转成横屏再进行锁屏这时候的Activity流程onPause->onStop->onStart->onResume->onPause,也就是Activity先进入后台,又重新创建进入前台,同时只到onPause没有再触发onStop。导致我们以为应用还在前台,这时候通过前台Activity的数量来判断是否真正在前台就不准确了。在分析这个流程的过程中发现onResume的时候屏幕已经关掉了。正常情况下onResume一定是在屏幕还亮着的情况下进行的根,据这点就有了方案三。

方案三:

通过onResume是否处在屏幕可操作来决定是否处在前台,之前方案的做法是在onStart的时候已经能判断App是否进入前台,而我们需要延时这个判断时机,不废话直接上代码。

    private int foregroundActivityCount = 0;
    private boolean isChangingConfigActivity = false;
    private boolean willSwitchToForeground = false;
    private boolean isForegroundNow = false;

    @Override
    public void onActivityStarted(Activity activity) {
        if (foregroundActivityCount == 0 || !isForegroundNow) {
            willSwitchToForeground = true;
        }

        if(isChangingConfigActivity){
            isChangingConfigActivity = false;
            return;
        }

        foregroundActivityCount += 1;
    }

    @Override
    public void onActivityResumed(Activity activity) {
        if (willSwitchToForeground && isInteractive(activity)) {
            isForegroundNow = true;
            Log.i("TAG", "switch to foreground");
        }

        if (isForegroundNow) {
            willSwitchToForeground = false;
        }
    }

    @Override
    public void onActivityStopped(Activity activity) {
        if(activity.isChangingConfigurations()){
            isChangingConfigActivity = true;
            return;
        }

        foregroundActivityCount -= 1;
        if(foregroundActivityCount == 0){
            isForegroundNow = false;
            Log.i(TAG, "switch to background");
        }
    }

    private boolean isInteractive(Context context) {
        PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
            return pm.isInteractive();
        } else {
            return pm.isScreenOn();
        }
    }

方案三通过willSwitchToForeground将判断是否进入前台的时机延后到onResume来做,同时添加一个当前状态isForegroundNow防止出现误判。这样App前后台切换基本就算ok了。在进行更多细节时发现部分手机比如oppo呼起语音助手、锤子的闪念胶囊都会只执行一个onPause而不会有后续的其他生命周期回调。而这种场景可能经常会出现,为了更精细的计算App的前台时间我们还是应该把这部分时长也去除,一开始想能否用方案三类似的手段将判断提前?这个逻辑上其实是不可行的,只能延后判断不能提前判断。如果无法做我们是否可以直接将这部分时间从总的App使用时长中减去呢?也就有了方案四。

方案四:

由于呼出系统应用后App可能会有两种生命周期onResume或者onStop我们根据时间间隔大于1秒(以误差为1秒计,本身页面切换需要时间),认为不在当前App中活跃。

    private int foregroundActivityCount = 0;
    private boolean isChangingConfigActivity = false;
    private boolean willSwitchToForeground = false;
    private boolean isForegroundNow = false;

    private String lastPausedActivityName;
    private int lastPausedActivityHashCode;
    private long lastPausedTime;
    private long appUseReduceTime = 0;

    @Override
    public void onActivityStarted(Activity activity) {
        if (foregroundActivityCount == 0 || !isForegroundNow) {
            willSwitchToForeground = true;
        }

        if (isChangingConfigActivity) {
            isChangingConfigActivity = false;
            return;
        }

        foregroundActivityCount += 1;
    }

    @Override
    public void onActivityResumed(Activity activity) {
        addAppUseReduceTimeIfNeeded(activity);

        if (willSwitchToForeground && isInteractive(activity)) {
            isForegroundNow = true;
            Log.i("TAG", "switch to foreground");
        }

        if (isForegroundNow) {
            willSwitchToForeground = false;
        }
    }

    @Override
    public void onActivityPaused(Activity activity) {
        lastPausedActivityName = getActivityName(activity);
        lastPausedActivityHashCode = activity.hashCode();
        lastPausedTime = System.currentTimeMillis();
    }

    @Override
    public void onActivityStopped(Activity activity) {
        addAppUseReduceTimeIfNeeded(activity);

        if (activity.isChangingConfigurations()) {
            isChangingConfigActivity = true;
            return;
        }

        foregroundActivityCount -= 1;
        if (foregroundActivityCount == 0) {
            isForegroundNow = false;
            Log.i(TAG, "switch to background (reduce time["+appUseReduceTime+"])");
        }
    }

    private void addAppUseReduceTimeIfNeeded(Activity activity) {
        if (getActivityName(activity).equals(lastPausedActivityName)
                && activity.hashCode() == lastPausedActivityHashCode) {
            long now = System.currentTimeMillis();
            if (now - lastPausedTime > 1000) {
                appUseReduceTime += now - lastPausedTime;
            }
        }

        lastPausedActivityHashCode = -1;
        lastPausedActivityName = null;
        lastPausedTime = 0;
    }

    private boolean isInteractive(Context context) {
        PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
            return pm.isInteractive();
        } else {
            return pm.isScreenOn();
        }
    }

    private String getActivityName(final Activity activity) {
        return activity.getClass().getCanonicalName();
    }

方案四基本上解决了语音助手等系统App的使用时长问题,对于正常App的时长统计基本上比较OK了。这时候我们还遇到了一个问题,那就是应用崩溃导致统计时长缺失,怎么计算这部分时长?首先崩溃是不可预知的,简单的方法就是使用心跳,每个固定时间检查应用是否在前台,并将时间戳记下,正常关闭时清除这个时间戳,下次打开时发现有这个时间戳,说明上一次是异常关闭。这样的方案本身没有问题,但是消耗手机资源。由于android的奔溃很多都是jvm层面的,于是我灵光一现想到只要在页面打开、关闭、崩溃catch时对当前时间进行记录不就可以了吗?

方案五:

优化应用异常退出造成的统计时长误差的问题。

    private AppLifecyclePersistentManager persistentMgr;

    private int foregroundActivityCount = 0;
    private boolean isChangingConfigActivity = false;
    private boolean willSwitchToForeground = false;
    private boolean isForegroundNow = false;

    private String lastPausedActivityName;
    private int lastPausedActivityHashCode;
    private long lastPausedTime;
    private long appUseReduceTime = 0;

    private long foregroundTs;

    @Override
    public void onActivityStarted(Activity activity) {
        if (foregroundActivityCount == 0 || !isForegroundNow) {
            willSwitchToForeground = true;
        }

        if (isChangingConfigActivity) {
            isChangingConfigActivity = false;
            return;
        }

        foregroundActivityCount += 1;
    }

    @Override
    public void onActivityResumed(Activity activity) {
        persistentMgr.saveActiveTs(System.currentTimeMillis());

        addAppUseReduceTimeIfNeeded(activity);

        if (willSwitchToForeground && isInteractive(activity)) {
            if(persistentMgr.isLastAppLifecycleAbnormal()){
                long activeTs = persistentMgr.findActiveTs();
                long reduceTime = persistentMgr.findReduceTs();
                long foregroundTs = persistentMgr.findForegroundTs();
                Log.i("TAG", "last switch to background abnormal terminal");
                persistentMgr.clearAll();
            }

            isForegroundNow = true;
            foregroundTs = System.currentTimeMillis();
            persistentMgr.saveForegroundTs(foregroundTs);

            Log.i("TAG", "switch to foreground[" + foregroundTs + "]");
        }

        if (isForegroundNow) {
            willSwitchToForeground = false;
        }
    }

    @Override
    public void onActivityPaused(Activity activity) {
        persistentMgr.saveActiveTs(System.currentTimeMillis());

        lastPausedActivityName = getActivityName(activity);
        lastPausedActivityHashCode = activity.hashCode();
        lastPausedTime = System.currentTimeMillis();
    }

    @Override
    public void onActivityStopped(Activity activity) {
        addAppUseReduceTimeIfNeeded(activity);

        if (activity.isChangingConfigurations()) {
            isChangingConfigActivity = true;
            return;
        }

        foregroundActivityCount -= 1;
        if (foregroundActivityCount == 0) {
            isForegroundNow = false;

            persistentMgr.clearAll();

            Log.i(TAG, "switch to background (reduce time[" + appUseReduceTime + "])");
        }
    }

    private void addAppUseReduceTimeIfNeeded(Activity activity) {
        if (getActivityName(activity).equals(lastPausedActivityName)
                && activity.hashCode() == lastPausedActivityHashCode) {
            long now = System.currentTimeMillis();
            if (now - lastPausedTime > 1000) {
                appUseReduceTime += now - lastPausedTime;
            }
        }

        lastPausedActivityHashCode = -1;
        lastPausedActivityName = null;
        lastPausedTime = 0;

        persistentMgr.saveReduceTs(appUseReduceTime);
    }

    private boolean isInteractive(Context context) {
        PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
            return pm.isInteractive();
        } else {
            return pm.isScreenOn();
        }
    }

    private String getActivityName(final Activity activity) {
        return activity.getClass().getCanonicalName();
    }
    private Thread.UncaughtExceptionHandler mDefaultHandler;

    public void register() {

        if (Thread.getDefaultUncaughtExceptionHandler() == this) {
            return;
        }

        mDefaultHandler = Thread.getDefaultUncaughtExceptionHandler();

        Thread.setDefaultUncaughtExceptionHandler(this);
    }

    @Override
    public void uncaughtException(Thread t, Throwable e) {
        AppLifecyclePersistentManager.getInstance().saveActiveTs(System.currentTimeMillis());

        if (mDefaultHandler != null && mDefaultHandler != Thread.getDefaultUncaughtExceptionHandler()) {
            mDefaultHandler.uncaughtException(t, e);
        }
    }

利用uncaughtException来记录最后活跃时间,这样一个相对完美的使用时长方案就诞生了。同时对于某些有特殊需求,需要知道应用何时切后台也是实现了。

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

推荐阅读更多精彩内容