应用时长的计算友盟早期做法计算每个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来记录最后活跃时间,这样一个相对完美的使用时长方案就诞生了。同时对于某些有特殊需求,需要知道应用何时切后台也是实现了。