笔者在某厂开发工具类App期间,曾经负责实现过这样一个功能:统计用户手机上所有游戏的前台使用时间 ,并以当天 、本周 以及历史全部 三个维度进行展示。经过调研,Android 5.0(API 21)提供了
UsageStatsManager
负责此类功能,但实现过程中发现其有较大误差,事实上,早在2018年Google Issue Tracker上就有人提过这个问题,Android开发团队承认了这个bug,但至今仍然没有得到完善解决。
UsageStatsManager
是Android系统提供的获取应用使用统计信息的服务,通过它可以查询任意应用的前台使用时长、首次启动时间、最近一次启动时间、启动次数等信息。阅读本文后,你将了解到:
- 如何通过
UsageStatsManager
获取指定应用在指定时间区间内的前台使用时长 UsageStatsManager
的启动流程是怎样的- 系统如何记录应用的使用信息
- 如果用户篡改系统时间,是否会导致使用数据时长错乱
- 在查询使用信息时调用关系是怎样的
- 是否支持查询任意时间范围内的使用时长
- 默认的获取使用信息接口有何缺陷
- 自定义方法,实现精准计算应用前台时长
接下来让我们从一个简单的Demo开始。
获取应用使用时长的Demo
UsageStatsManager API用法
android.app.usage.UsageStatsManager.java
java
public List<UsageStats> queryUsageStats(int intervalType, long beginTime, long endTime)
其中参数intervalType
含义是统计的区间类型,可以分为5种:
INTERVAL_DAILY
INTERNAL_MONTHLY
INTERVAL_YEARLY
INTERNAL_BEST
前4个都好理解,唯独最后一个INTERNAL_BEST
语焉不详,后文的源码分析部分将揭开它神秘的面纱。
函数的返回值类型是List<UsageStats>
,是按照intervalType
分割而成的使用信息列表,如果我们想按天数查询,则传入INTERNAL_DAILY
,和想要查询的起止时间。返回List里每一个UsageStats
则记录了每一天的使用信息。
android.app.usage.UsageStats.java
java
...
public String mPackageName; // 包名
public long mBeginTimeStamp; // 该时间区间开始时间戳
public long mEndTimeStamp; // 该时间区间结束时间戳
public long mLastTimeUsed; // 该区间内最后一次使用时间戳
public int mLaunchCount; // 该区间内启动次数
public long mTotalTimeInForeground; // 该区间内前台时长
申请和判断PACKAGE_USAGE_STATS权限
使用UsageStatsManager.queryUsageStats
时需要有PACKAGE_USAGE_STATS
权限,在manifest文件中进行声明。
java
<uses-permission
android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions"/>
这个权限比较特殊,它的申请和验证都不同于常规写法。
需要在运行时动态申请,申请时无法通过系统弹窗,而是需要跳转到系统设置页。
java
val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
startActivity(intent)
不能通过Activity.checkSelfPermission()
检查是否拥有该权限,而是要通过以下方式。
java
private fun checkPermission(): Boolean {
//API > 25 版本的使用记录访问权限监测方式 适用于 Android 8.0 版本以上
val appOps = getSystemService(APP_OPS_SERVICE) as AppOpsManager
val mode = appOps.checkOpNoThrow("android:get_usage_stats", android.os.Process.myUid(),
packageName
)
val isGranted = mode == AppOpsManager.MODE_ALLOWED
return isGranted
}
写个Demo
解决完权限问题,我们来写个简单Demo验证下,这里我们以获取微信(com.tencent.mm
)使用信息为例。
java
/**
* 范例,查询单个应用一个月内按天分布的使用时长、使用次数,从1号到当天
*/
fun querySingleAppUsageInfo(pkgName: String): String {
val startTime = Calendar.getInstance().startTimeOf(Period.MONTH)
val endTime = Calendar.getInstance()
val usageStats = usm.queryUsageStats(UsageStatsManager.INTERVAL_DAILY, startTime.timeInMillis, endTime.timeInMillis)
val sb = StringBuilder()
sb.appendLine(pkgName)
usageStats.forEach {
if (it.packageName != pkgName) {
return@forEach
}
sb.append("${timestampToDate(it.firstTimeStamp)}: ")
try {
val launchCnt = it.javaClass.getDeclaredMethod("getAppLaunchCount").invoke(it)
sb.append("$launchCnt times,")
} catch (e: Exception) {}
sb.append(" ${it.totalTimeInForeground.toHHmm()}\n")
}
return sb.toString()
}
Demo中存在的问题
输出如上,统计结果并没能真实反映用户在微信上的使用时长和次数,存在两个问题
- 开始时间计算错误,返回值里beginTime是早上8点多,而通常意义上一天的统计起始时间是0点
- 启动次数计算有误,比实际的启动次(冷+热)数多
在开发业务需求时,我采用类似方法实现的统计游戏时长功能,经测试同学反馈存在同样的计时不准问题。
以下从源码角度分析UsageStatsManager
的启动流程、统计逻辑,以寻找问题发生的原因。
UsageStatsManager启动流程
首先看UsageStatsManager
是如何启动的。
手机开机时,linux内核首先加载init.rc
文件,其中启动zygote
进程,zygote
进程又fork出system_server
进程,其代码位于SystemServer.java
。通过startService(UsageStatsService.class)
启动了应用统计服务。
com.android.SystemServer.java
javat
public static void main(String[] args) {
new SystemServer().run();
}
private void run() {
...
startCoreServices(t);
...
}
private void startCoreServices(@NonNull TimingsTraceAndSlog t) {
...
mSystemServiceManager.startService(UsageStatsService.class);
mActivityManagerService.setUsageStatsManager(LocalServices.getService(UsageStatsManagerInternal.class));
...
}
通过startService()
启动的服务可以在后台长期运行, 接下来调用AMS的setUsageStatsManager()
函数,AMS代码无需关注,只不过是把启动的Service保存为成员变量。重点逻辑位于UsageStatsService
com.android.server.usage.UsageStatsService.java
java
public void onStart() {
mAppOps = (AppOpsManager) getContext().getSystemService(Context.APP_OPS_SERVICE); // 获取系统服务作为成员变量
mUserManager = (UserManager) getContext().getSystemService(Context.USER_SERVICE);
mPackageManager = getContext().getPackageManager();
mPackageManagerInternal = LocalServices.getService(PackageManagerInternal.class);
mHandler = new H(BackgroundThread.get().getLooper()); // 执行异步任务
...
mRealTimeSnapshot = SystemClock.elapsedRealtime(); // 开机时间
mSystemTimeSnapshot = System.currentTimeMillis(); // 系统时间
...
publishBinderServices();
mHandler.obtainMessage(MSG_ON_START).sendToTarget();
}
// 生成Binder对象
void publishBinderServices() {
publishBinderService(Context.USAGE_STATS_SERVICE, new BinderService());
}
// Handler
public void handleMessage(Message msg) {
...
case MSG_ON_START:
synchronized (mLock) {
loadGlobalComponentUsageLocked();
}
break;
...
}
// 加载本地文件中已保存的使用记录到内存
private void loadGlobalComponentUsageLocked() {
final File[] packageUsageFile = COMMON_USAGE_STATS_DE_DIR.listFiles(
(dir, name) -> TextUtils.equals(name, GLOBAL_COMPONENT_USAGE_FILE_NAME)); // /data/system/usagestats/globalcomponentusage
if (packageUsageFile == null || packageUsageFile.length == 0) {
return;
}
final AtomicFile af = new AtomicFile(packageUsageFile[0]);
final Map<String, Long> tmpUsage = new ArrayMap<>(); // ArrayMap比HashMap内存效率高
try {
try (FileInputStream in = af.openRead()) {
UsageStatsProtoV2.readGlobalComponentUsage(in, tmpUsage); // 读取最近一次使用时间戳
}
// only add to in memory map if the read was successful
final Map.Entry<String, Long>[] entries =
(Map.Entry<String, Long>[]) tmpUsage.entrySet().toArray();
final int size = entries.length;
for (int i = 0; i < size; ++i) {
// In memory data is usually the most up-to-date, so skip the packages which already
// have usage data.
mLastTimeComponentUsedGlobal.putIfAbsent(
entries[i].getKey(), entries[i].getValue()); // 更新到内存里
}
} catch (Exception e) {
// Most likely trying to read a corrupted file - log the failure
Slog.e(TAG, "Could not read " + packageUsageFile[0]);
}
}
在onStart()
过程中,UsageStatsService
首先拿到AppOpsManager
、UserManager
、PackageManager
、InternalPackageManager
并保存作为成员变量,随后初始化了一个用于执行异步任务的Handler 。将开机后系统运行的时间记录为mRealTimeSnapshot
,将当时的系统时间记录为mSystemTimeSnapshot
,如果后续用户手动设置系统时间,会通过这两个变量对历史数据进行校准。publishBinderServices()
则将UsageStatsService
自身发布到ServiceManager
,关于这部分知识可以参考之前对Binder原理的分析
在 onStart()
最后发送MSG_ON_START
消息给Handler ,它收到这个消息后,通过loadGlobalComponentUsageLocked()
函数,加载位于 /data/system/usagestats/globalcomponentusage
目录的xml文件列表,这些文件保存了手机里各个App的最后启动时间,读取完成后,将数据保存在mLastTimeComponentUsedGlobal
的ArrayMap结构里。
ArrayMap比HashMap的优点在于内存使用率更高,在不超过1000个元素的情况下,两者的插入/查找/删除没有明显的速度差异
记录用户使用时长
接下来分析UsageStatsService
如何记录并统计用户每次使用App的信息。当Activity的活动状态发生变化时(例如resumed <-> paused
),ActivityRecord.java
里会执行setState()
,关于AMS的源码分析请见我另一篇文章TODO 。(在更早的Android版本里,这部分逻辑位于ActivityStack.java
,现在这个类已废除)
com.android.server.am.ActivityRecord.java
java
void setState(ActivityState state, String reason) {
...
switch (state) {
case RESUMED:
mAtmService.updateBatteryStats(this, true);
mAtmService.updateActivityUsageStats(this, Event.ACTIVITY_RESUMED);
...
case PAUSED:
mAtmService.updateBatteryStats(this, false);
mAtmService.updateActivityUsageStats(this, Event.ACTIVITY_PAUSED);
break;
...
}
这里mAtmService
是ActivityTaskManagerService
对象。
com.android.server.wm.ActivityTaskManagerService.java
java
4548 void updateActivityUsageStats(ActivityRecord activity, int event) {
4549 ComponentName taskRoot = null;
4550 final Task task = activity.getTask();
4551 if (task != null) {
4552 final ActivityRecord rootActivity = task.getRootActivity();
4553 if (rootActivity != null) {
4554 taskRoot = rootActivity.mActivityComponent;
4555 }
4556 }
4557
4558 final Message m = PooledLambda.obtainMessage(
4559 ActivityManagerInternal::updateActivityUsageStats, mAmInternal,
4560 activity.mActivityComponent, activity.mUserId, event, activity.appToken, taskRoot);
4561 mH.sendMessage(m);
4562 }
其中ActivityManagerInternal::updateActivityUsageStats()
函数就是要执行的记录逻辑,它是一个抽象类,实现代码位于ActivityManagerService.LocalService
类里面。
android.app.ActivityManagerInternal.java
java
50 public abstract class ActivityManagerInternal {
...
299 public abstract void updateActivityUsageStats(
300 ComponentName activity, @UserIdInt int userId, int event, IBinder appToken,
301 ComponentName taskRoot);
android.server.am.ActivityManagerService.java
java
// 内部类LocalService
15663 public void updateActivityUsageStats(ComponentName activity, int userId, int event,
15664 IBinder appToken, ComponentName taskRoot) {
15665 ActivityManagerService.this.updateActivityUsageStats(activity, userId, event,
15666 appToken, taskRoot); // 调用AMS自身实现
15667 }
// AMS实现
2720 public void updateActivityUsageStats(ComponentName activity, int userId, int event,
2721 IBinder appToken, ComponentName taskRoot) {
...
2726 if (mUsageStatsService != null) {
2727 mUsageStatsService.reportEvent(activity, userId, event, appToken.hashCode(), taskRoot);
2728 if (event == Event.ACTIVITY_RESUMED) {
2729 // Report component usage as an activity is an app component
2730 mUsageStatsService.reportEvent(
2731 activity.getPackageName(), userId, Event.APP_COMPONENT_USED);
2732 } // 任务被分派给【用户时长统计类 UserUsageStatsService】
2733 }
...
2740 }
com.android.server.usage.UserUsageStatsService.java
java
273 void reportEvent(Event event) {
...
326 for (IntervalStats stats : mCurrentStats) { // 任务进一步下发给IntervalStats
327 switch (event.mEventType) {
...
352 default: {
353 stats.update(event.mPackage, event.getClassName(),
354 event.mTimeStamp, event.mEventType, event.mInstanceId);
355 if (incrementAppLaunch) {
356 stats.incrementAppLaunchCount(event.mPackage);
357 }
358 } break;
359 }
360 }
com.android.server.usage.IntervalStats.java
java
309 public void update(String packageName, String className, long timeStamp, int eventType,
310 int instanceId) {
311 if (eventType == DEVICE_SHUTDOWN
312 || eventType == FLUSH_TO_DISK) {
313 // DEVICE_SHUTDOWN and FLUSH_TO_DISK are sent to all packages.
314 final int size = packageStats.size();
315 for (int i = 0; i < size; i++) {
316 UsageStats usageStats = packageStats.valueAt(i);
317 usageStats.update(null, timeStamp, eventType, instanceId); // 还没完,把事件交给IntervalStats内部所有的UsageStats处理
318 }
319 } else {
320 UsageStats usageStats = getOrCreateUsageStats(packageName);
321 usageStats.update(className, timeStamp, eventType, instanceId);
322 }
323 if (timeStamp > endTime) { // 同时更新最晚使用时间戳
324 endTime = timeStamp;
325 }
326 }
android.app.usage.UsageStats.java
java
574 public void update(String className, long timeStamp, int eventType, int instanceId) {
575 switch(eventType) {
576 case ACTIVITY_RESUMED:
577 case ACTIVITY_PAUSED:
578 case ACTIVITY_STOPPED:
579 case ACTIVITY_DESTROYED:
580 updateActivity(className, timeStamp, eventType, instanceId);
581 break;
...
638 if (eventType == ACTIVITY_RESUMED) {
639 mLaunchCount += 1; // 启动次数+1,这里计算得并不精确,只是记录onResume次数
640 }
641 }
...
468 private void updateActivity(String className, long timeStamp, int eventType, int instanceId) {
469 if (eventType != ACTIVITY_RESUMED
470 && eventType != ACTIVITY_PAUSED
471 && eventType != ACTIVITY_STOPPED
472 && eventType != ACTIVITY_DESTROYED) {
473 return;
474 }
475
476 // update usage.
477 final int index = mActivities.indexOfKey(instanceId);
478 if (index >= 0) {
479 final int lastEvent = mActivities.valueAt(index);
480 switch (lastEvent) {
481 case ACTIVITY_RESUMED:
482 incrementTimeUsed(timeStamp);
483 incrementTimeVisible(timeStamp);
484 break;
485 case ACTIVITY_PAUSED:
486 incrementTimeVisible(timeStamp);
487 break;
488 default:
489 break;
490 }
491 }
...
440 private void incrementTimeVisible(long timeStamp) {
441 if (timeStamp > mLastTimeVisible) {
442 mTotalTimeVisible += timeStamp - mLastTimeVisible; // 这里采取计算前台时间、使用次数
443 mLastTimeVisible = timeStamp;
444 }
445 }
以上便是完整的用户时长记录调用流程,概括下,始于ActivityRecord
,将Activity设置为不同的state ,随后通知ActivityTaskManagerService
,而后者又调用ActivityManagerService
的updateActivityUsageStats()
函数,将任务分派给负责维护用户使用统计信息的UserUsageStatsService
。最后在UsageStats 对象里计算前台时间,并且累加mAppLaunchCount
启动次数。
时长信息在内存里的维护格式
以上我们看到了在Activity.onPause()
和Activity.onResume()
发生时,AMS记录使用时长、打开次数的调用链,分析到内存的这一步。接下来从源码角度探究数据的维护方法,这有助于更加深刻地理解系统内置的时长统计功能。
先从时长统计类型入手,系统内部将时长统计类型分为4种。
android.app.usage.UsageStatsManager.java
java
...
public static final int INTERVAL_DAILY = 0;
public static final int INTERVAL_WEEKLY = 1;
public static final int INTERVAL_MONTHLY = 2;
public static final int INTERVAL_YEARLY = 3;
public static final int INTERVAL_BEST = 4;
...
这里明明有5个常量,为什么说只有4种统计类型?因为系统里真正持久化保存下来的只有日周月年 4个,而BEST则是接口调用的时候实时进行计算,返回的intervalType
可能是日周月年里的任意一个,无法预测它的行为,导致使用价值很低。
作为Android工程师,我们通常使用UsageStatsManager
类来获取App的使用信息,在它内部实际上是使用了UsageStatsService
来处理查询请求(注意到只对开发者开放了查询的API,无法人工写入和修改)。而Android是一个支持多用户 的系统,不同用户对同一个App的使用信息明显应当分开处理,虽然日常生活里99.9%的场景都是单一用户使用。因此,UsageStatsService再把单一用户的核心统计逻辑放在UserUsageStatsService.java
中,接下来我们聚焦于此。
com.android.server.usage.UserUsageStatsService.java
java
class UserUsageStatsService {
...
private static final SimpleDateFormat sDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
private final Context mContext;
private final UsageStatsDatabase mDatabase;
private final IntervalStats[] mCurrentStats;
private final UnixCalendar mDailyExpiryDate;
// 构造函数
UserUsageStatsService(Context context, int userId, File usageStatsDir,
StatsUpdatedListener listener) {
mContext = context;
mDailyExpiryDate = new UnixCalendar(0);
mDatabase = new UsageStatsDatabase(usageStatsDir);
mCurrentStats = new IntervalStats[INTERVAL_COUNT];
mListener = listener;
mLogPrefix = "User[" + Integer.toString(userId) + "] ";
mUserId = userId;
mRealTimeSnapshot = SystemClock.elapsedRealtime();
mSystemTimeSnapshot = System.currentTimeMillis();
}
首先关注它的成员变量与构造函数。
- Context 对象来自于
UsageStatsService
,前者在创建UserUsageStatsService
时将自身传入构造函数 UsageStatsDatabase mDatabase
是以文件形式保存的持久化数据,UserUsageStatsService
负责在系统启动时加载历史数据到内存,以及定期将内存数据持久化为xml文件,文件路径在/data/system/usagestats
。xml内部使用二进制流保存了对象IntervalStats
mCurrentStats
是记录时长的数据结构,是长度为4 的数组,依次保存了日、周、月、年类型的统计信息
IntervalStats
IntervalStats 记录了当前最新的时间周期内,用户使用信息的统计数据。即对于INTERVAL_TYPE=DAILY时,只记录了从当天0:00:00至今的数据在内存中,如果调用方想要查询更早日期的数据,就需要通过mDatabase
在数据库(xml文件目录)里进行查询。
com.android.server.usage.IntervalStats.java
java
public class IntervalStats {
...
public final ArrayMap<String, UsageStats> packageStats = new ArrayMap<>();
...
public final EventList events = new EventList();
它内部持有packageName->UsageStats的一个ArrayMap,也就是说,管理了一个包名->数据统计的查找表。
而events却是不区分包名的,这里要特别注意!
UsageStats
表示一个App在特定时间段内的使用信息。
android.app.usage.UsageStats.java
java
public final class UsageStats implements Parcelable {
...
public String mPackageName;
public long mBeginTimeStamp;
public long mEndTimeStamp;
public long mLastTimeUsed;
public long mTotalTimeInForeground;
public long mTotalTimeVisible;
public int mAppLaunchCount;
...
public void add(UsageStats right) {
if (!mPackageName.equals(right.mPackageName)) {
throw new IllegalArgumentException("Can't merge UsageStats for package '" +
mPackageName + "' with UsageStats for package '" + right.mPackageName + "'.");
}
// We use the mBeginTimeStamp due to a bug where UsageStats files can overlap with
// regards to their mEndTimeStamp.
if (right.mBeginTimeStamp > mBeginTimeStamp) {
// Even though incoming UsageStat begins after this one, its last time used fields
// may somehow be empty or chronologically preceding the older UsageStat.
mergeEventMap(mActivities, right.mActivities);
mergeEventMap(mForegroundServices, right.mForegroundServices);
mLastTimeUsed = Math.max(mLastTimeUsed, right.mLastTimeUsed);
mLastTimeVisible = Math.max(mLastTimeVisible, right.mLastTimeVisible);
mLastTimeComponentUsed = Math.max(mLastTimeComponentUsed, right.mLastTimeComponentUsed);
mLastTimeForegroundServiceUsed = Math.max(mLastTimeForegroundServiceUsed,
right.mLastTimeForegroundServiceUsed);
}
mBeginTimeStamp = Math.min(mBeginTimeStamp, right.mBeginTimeStamp);
mEndTimeStamp = Math.max(mEndTimeStamp, right.mEndTimeStamp);
mTotalTimeInForeground += right.mTotalTimeInForeground;
mTotalTimeVisible += right.mTotalTimeVisible;
mTotalTimeForegroundServiceUsed += right.mTotalTimeForegroundServiceUsed;
mLaunchCount += right.mLaunchCount;
mAppLaunchCount += right.mAppLaunchCount;
...
首先关注成员变量,除了包名mPackageName
,UsageStats里面还记录了当前时间区间的起止时间戳,注意这个时间戳并不是0:00:00~23:59:59,而是真正有事件发生时的时间点 ,从上文的add()
函数可以看出,当合并两个UsageStats
对象时,保留最早的mBeginTimeStamp
和最晚的mEndTimeStamp/mLastTimeUsed
,同时累加mTotalTimeInForeground/mTotalTimeVisible/mLaunchCount
等。
mTotalTimeInForeground指的是resume~pause之间的时间,而mTotalTimeVisible指的是start~stop之间的时间
以上就是UsageStatsManager
在内存里维护的用于记录用户操作记录、统计信息的数据结构,它们对于理解UsageStatsManager、UsageStatsService
的原理必不可少。
时长信息的持久化流程
上面看完了内存中是如何管理使用数据的,注意内存里只是以数组形式保存了一天、一周、一个月、一年内的IntervalStats
,对于更久远的数据,UsageStatsService
是通过xml的形式保存在硬盘上的。
触发写磁盘操作的代码位于UserUsageStatsService.reportEvent()
函数。
java
void reportEvent(Event event) {
...
if (event.mTimeStamp >= mDailyExpiryDate.getTimeInMillis()) { // <--重点,达到过期时间触发写磁盘
// Need to rollover
rolloverStats(event.mTimeStamp);
}
...
}
void init(final long currentTimeMillis, HashMap<String, Long> installedPackages,
boolean deleteObsoleteData) {
...
updateRolloverDeadline();
...
}
private void updateRolloverDeadline() {
mDailyExpiryDate.setTimeInMillis(
mCurrentStats[INTERVAL_DAILY].beginTime);
mDailyExpiryDate.addDays(1);
Slog.i(TAG, mLogPrefix + "Rollover scheduled @ " +
sDateFormat.format(mDailyExpiryDate.getTimeInMillis()) + "(" +
mDailyExpiryDate.getTimeInMillis() + ")");
}
rollover 的英文含义是"续期 ",可以理解成过完一天后,把当天的使用数据持久化到磁盘,然后从0开始记录新一天。上述代码里在reportEvent()
时,根据传入的event时间戳 判断,如果超过了续期时间 ,则进行持久化。那么这个续期时间mDailyExpireDate
是如何计算得到的呢?Android源码在这里有一个巨大的坑,稍后进行讲解。先把数据持久化的过程走完。
java
private void rolloverStats(final long currentTimeMillis) {
...
persistActiveStats(); // <--重点1
mDatabase.prune(currentTimeMillis); // <--重点2,下文分析
loadActiveStats(currentTimeMillis); // <--重点3
...
}
// 重点1将数据持久化保存至xml文件
void persistActiveStats() {
if (mStatsChanged) {
Slog.i(TAG, mLogPrefix + "Flushing usage stats to disk");
try {
mDatabase.obfuscateCurrentStats(mCurrentStats);
mDatabase.writeMappingsLocked();
for (int i = 0; i < mCurrentStats.length; i++) {
mDatabase.putUsageStats(i, mCurrentStats[i]); // 代码位于UsageStatsDatabase,以二进制格式将UsageStats保存到xml文件
}
mStatsChanged = false;
} catch (IOException e) {
Slog.e(TAG, mLogPrefix + "Failed to persist active stats", e);
}
}
}
// 重点3,从数据库里重新加载最新的IntervalStats,如果取不到,则new一个出来
private void loadActiveStats(final long currentTimeMillis) {
for (int intervalType = 0; intervalType < mCurrentStats.length; intervalType++) {
final IntervalStats stats = mDatabase.getLatestUsageStats(intervalType);
if (stats != null
&& currentTimeMillis < stats.beginTime + INTERVAL_LENGTH[intervalType]) {
if (DEBUG) {
Slog.d(TAG, mLogPrefix + "Loading existing stats @ " +
sDateFormat.format(stats.beginTime) + "(" + stats.beginTime +
") for interval " + intervalType);
}
mCurrentStats[intervalType] = stats;
} else {
// No good fit remains.
if (DEBUG) {
Slog.d(TAG, "Creating new stats @ " +
sDateFormat.format(currentTimeMillis) + "(" +
currentTimeMillis + ") for interval " + intervalType);
}
mCurrentStats[intervalType] = new IntervalStats();
mCurrentStats[intervalType].beginTime = currentTimeMillis; // 重点4,注意!new的时候使用了当前时间戳作为beginTime
mCurrentStats[intervalType].endTime = currentTimeMillis + 1;
}
}
mStatsChanged = false;
updateRolloverDeadline();
// Tell the listener that the stats reloaded, which may have changed idle states.
mListener.onStatsReloaded();
}
UsageStatsDatabase如何清理历史文件
UsageStatsDatabase
是负责将IntervalStats
、UsageStats
持久化为二进制序列的类,文件是不可能只新增不清理的,因此在它内部也有清理过期文件的逻辑。清理时有两条规则同时起作用。
- 文件数量限制,日周月年的xml文件数量各有上限 (100,50,12,10) ,当文件超过上限时则删除最老的个
- 记录时间限制,每个Interval都有历史回溯期 (10,4,6,3) ,记录时间超过回溯期的文件会被清理
其实这里我是有些疑问,如果严格按照规则2清理的话,文件根本不会达到规则1里所设置的上限数量,规则1根本不会有生效的场景。希望有知道的朋友不吝啬分享指教。
com.android.server.usage.UsageStatsDatabase.java
java
public class UsageStatsDatabase {
...
// 日、周、月、天所保存的最大文件数量
static final int[] MAX_FILES_PER_INTERVAL_TYPE = new int[]{100, 50, 12, 10};
...
// 删除超过最大回溯期的文件,日周月天分别是10、4、6、3,日期时间戳是创建文件时记录在文件名里面的
public void prune(final long currentTimeMillis) {
synchronized (mLock) {
mCal.setTimeInMillis(currentTimeMillis);
mCal.addYears(-3);
pruneFilesOlderThan(mIntervalDirs[UsageStatsManager.INTERVAL_YEARLY],
mCal.getTimeInMillis());
mCal.setTimeInMillis(currentTimeMillis);
mCal.addMonths(-6);
pruneFilesOlderThan(mIntervalDirs[UsageStatsManager.INTERVAL_MONTHLY],
mCal.getTimeInMillis());
mCal.setTimeInMillis(currentTimeMillis);
mCal.addWeeks(-4);
pruneFilesOlderThan(mIntervalDirs[UsageStatsManager.INTERVAL_WEEKLY],
mCal.getTimeInMillis());
mCal.setTimeInMillis(currentTimeMillis);
mCal.addDays(-10);
pruneFilesOlderThan(mIntervalDirs[UsageStatsManager.INTERVAL_DAILY],
mCal.getTimeInMillis());
mCal.setTimeInMillis(currentTimeMillis);
mCal.addDays(-SELECTION_LOG_RETENTION_LEN);
for (int i = 0; i < mIntervalDirs.length; ++i) {
pruneChooserCountsOlderThan(mIntervalDirs[i], mCal.getTimeInMillis());
}
// We must re-index our file list or we will be trying to read
// deleted files.
indexFilesLocked();
}
}
}
UsageStatsService时间划分存在的问题
这里就不得不说官方API在此埋的一个巨大大大大坑! mDailyExpiryDate
的时间等于mCurrentStats[INTERVAL_DAILY].beginTime
,也就是数组第一个元素的beginTime
,然而,它并不是这一天的0:00:00:000时间戳,而是一个在这一天的范围内、无法预测的时间值 !其原因在于mCurrentStats
的生成逻辑,代码位于UserUsageStatsService.java
。
java
void init(final long currentTimeMillis, HashMap<String, Long> installedPackages,
boolean deleteObsoleteData) {
...
int nullCount = 0;
for (int i = 0; i < mCurrentStats.length; i++) {
mCurrentStats[i] = mDatabase.getLatestUsageStats(i);
if (mCurrentStats[i] == null) {
// Find out how many intervals we don't have data for.
// Ideally it should be all or none.
nullCount++;
}
}
if (nullCount > 0) {
if (nullCount != mCurrentStats.length) {
// This is weird, but we shouldn't fail if something like this
// happens.
Slog.w(TAG, mLogPrefix + "Some stats have no latest available");
} else {
// This must be first boot.
}
// By calling loadActiveStats, we will
// generate new stats for each bucket.
loadActiveStats(currentTimeMillis); // <--重点,生成新的UsageStats对象
} else {
// Set up the expiry date to be one day from the latest daily stat.
// This may actually be today and we will rollover on the first event
// that is reported.
updateRolloverDeadline(); // <--重点2,更新deadline
}
...
}
private void loadActiveStats(final long currentTimeMillis) {
for (int intervalType = 0; intervalType < mCurrentStats.length; intervalType++) {
final IntervalStats stats = mDatabase.getLatestUsageStats(intervalType);
if (stats != null
&& currentTimeMillis < stats.beginTime + INTERVAL_LENGTH[intervalType]) {
if (DEBUG) {
Slog.d(TAG, mLogPrefix + "Loading existing stats @ " +
sDateFormat.format(stats.beginTime) + "(" + stats.beginTime +
") for interval " + intervalType);
}
mCurrentStats[intervalType] = stats;
} else {
// No good fit remains.
if (DEBUG) {
Slog.d(TAG, "Creating new stats @ " +
sDateFormat.format(currentTimeMillis) + "(" +
currentTimeMillis + ") for interval " + intervalType);
}
mCurrentStats[intervalType] = new IntervalStats();
mCurrentStats[intervalType].beginTime = currentTimeMillis; <--重点!直接使用当前时间戳作为开始时间
mCurrentStats[intervalType].endTime = currentTimeMillis + 1;
}
}
mStatsChanged = false;
updateRolloverDeadline();
// Tell the listener that the stats reloaded, which may have changed idle states.
mListener.onStatsReloaded();
}
private void updateRolloverDeadline() {
mDailyExpiryDate.setTimeInMillis(
mCurrentStats[INTERVAL_DAILY].beginTime); // 直接使用上一个beginTime
mDailyExpiryDate.addDays(1);
Slog.i(TAG, mLogPrefix + "Rollover scheduled @ " +
sDateFormat.format(mDailyExpiryDate.getTimeInMillis()) + "(" +
mDailyExpiryDate.getTimeInMillis() + ")");
}
在init()
函数里,UserUsageStatsService
创建长度4 的数组用于记录日周月年的使用数据,并通过mDatabase.getLatestUsageStats(i)来获取对应Interval的历史数据。注意!这里取到的是最近一个日期的历史数据,而非当天!
rollover 的英文含义是"续期 ",可以理解成过完一天后,把当天的使用数据持久化到磁盘,然后从0开始记录新一天。上述代码里在reportEvent()
时,根据传入的event时间戳 判断,如果超过了续期时间 ,则进行持久化。那么这个续期时间mDailyExpireDate
是如何计算得到的呢?Android源码在这里有一个巨大的坑,稍后进行讲解。先把数据持久化的过程走完。
java
private void rolloverStats(final long currentTimeMillis) {
...
persistActiveStats(); // <--重点1
mDatabase.prune(currentTimeMillis); // <--重点2,下文分析
loadActiveStats(currentTimeMillis); // <--重点3
...
}
// 重点1将数据持久化保存至xml文件
void persistActiveStats() {
if (mStatsChanged) {
Slog.i(TAG, mLogPrefix + "Flushing usage stats to disk");
try {
mDatabase.obfuscateCurrentStats(mCurrentStats);
mDatabase.writeMappingsLocked();
for (int i = 0; i < mCurrentStats.length; i++) {
mDatabase.putUsageStats(i, mCurrentStats[i]); // 代码位于UsageStatsDatabase,以二进制格式将UsageStats保存到xml文件
}
mStatsChanged = false;
} catch (IOException e) {
Slog.e(TAG, mLogPrefix + "Failed to persist active stats", e);
}
}
}
// 重点3,从数据库里重新加载最新的IntervalStats,如果取不到,则new一个出来
private void loadActiveStats(final long currentTimeMillis) {
for (int intervalType = 0; intervalType < mCurrentStats.length; intervalType++) {
final IntervalStats stats = mDatabase.getLatestUsageStats(intervalType);
if (stats != null
&& currentTimeMillis < stats.beginTime + INTERVAL_LENGTH[intervalType]) {
if (DEBUG) {
Slog.d(TAG, mLogPrefix + "Loading existing stats @ " +
sDateFormat.format(stats.beginTime) + "(" + stats.beginTime +
") for interval " + intervalType);
}
mCurrentStats[intervalType] = stats;
} else {
// No good fit remains.
if (DEBUG) {
Slog.d(TAG, "Creating new stats @ " +
sDateFormat.format(currentTimeMillis) + "(" +
currentTimeMillis + ") for interval " + intervalType);
}
mCurrentStats[intervalType] = new IntervalStats();
mCurrentStats[intervalType].beginTime = currentTimeMillis; // 重点4,注意!new的时候使用了当前时间戳作为beginTime
mCurrentStats[intervalType].endTime = currentTimeMillis + 1;
}
}
mStatsChanged = false;
updateRolloverDeadline();
// Tell the listener that the stats reloaded, which may have changed idle states.
mListener.onStatsReloaded();
}
UsageStatsDatabase如何清理历史文件
UsageStatsDatabase
是负责将IntervalStats
、UsageStats
持久化为二进制序列的类,文件是不可能只新增不清理的,因此在它内部也有清理过期文件的逻辑。清理时有两条规则同时起作用。
- 文件数量限制,日周月年的xml文件数量各有上限 (100,50,12,10) ,当文件超过上限时则删除最老的个
- 记录时间限制,每个Interval都有历史回溯期 (10,4,6,3) ,记录时间超过回溯期的文件会被清理
其实这里我是有些疑问,如果严格按照规则2清理的话,文件根本不会达到规则1里所设置的上限数量,规则1根本不会有生效的场景。希望有知道的朋友不吝啬分享指教。
com.android.server.usage.UsageStatsDatabase.java
scss
public class UsageStatsDatabase {
...
// 日、周、月、天所保存的最大文件数量
static final int[] MAX_FILES_PER_INTERVAL_TYPE = new int[]{100, 50, 12, 10};
...
// 删除超过最大回溯期的文件,日周月天分别是10、4、6、3,日期时间戳是创建文件时记录在文件名里面的
public void prune(final long currentTimeMillis) {
synchronized (mLock) {
mCal.setTimeInMillis(currentTimeMillis);
mCal.addYears(-3);
pruneFilesOlderThan(mIntervalDirs[UsageStatsManager.INTERVAL_YEARLY],
mCal.getTimeInMillis());
mCal.setTimeInMillis(currentTimeMillis);
mCal.addMonths(-6);
pruneFilesOlderThan(mIntervalDirs[UsageStatsManager.INTERVAL_MONTHLY],
mCal.getTimeInMillis());
mCal.setTimeInMillis(currentTimeMillis);
mCal.addWeeks(-4);
pruneFilesOlderThan(mIntervalDirs[UsageStatsManager.INTERVAL_WEEKLY],
mCal.getTimeInMillis());
mCal.setTimeInMillis(currentTimeMillis);
mCal.addDays(-10);
pruneFilesOlderThan(mIntervalDirs[UsageStatsManager.INTERVAL_DAILY],
mCal.getTimeInMillis());
mCal.setTimeInMillis(currentTimeMillis);
mCal.addDays(-SELECTION_LOG_RETENTION_LEN);
for (int i = 0; i < mIntervalDirs.length; ++i) {
pruneChooserCountsOlderThan(mIntervalDirs[i], mCal.getTimeInMillis());
}
// We must re-index our file list or we will be trying to read
// deleted files.
indexFilesLocked();
}
}
}
UsageStatsService时间划分存在的问题
这里就不得不说官方API在此埋的一个巨大大大大坑! mDailyExpiryDate
的时间等于mCurrentStats[INTERVAL_DAILY].beginTime
,也就是数组第一个元素的beginTime
,然而,它并不是这一天的0:00:00:000时间戳,而是一个在这一天的范围内、无法预测的时间值 !其原因在于mCurrentStats
的生成逻辑,代码位于UserUsageStatsService.java
。
java
void init(final long currentTimeMillis, HashMap<String, Long> installedPackages,
boolean deleteObsoleteData) {
...
int nullCount = 0;
for (int i = 0; i < mCurrentStats.length; i++) {
mCurrentStats[i] = mDatabase.getLatestUsageStats(i);
if (mCurrentStats[i] == null) {
// Find out how many intervals we don't have data for.
// Ideally it should be all or none.
nullCount++;
}
}
if (nullCount > 0) {
if (nullCount != mCurrentStats.length) {
// This is weird, but we shouldn't fail if something like this
// happens.
Slog.w(TAG, mLogPrefix + "Some stats have no latest available");
} else {
// This must be first boot.
}
// By calling loadActiveStats, we will
// generate new stats for each bucket.
loadActiveStats(currentTimeMillis); // <--重点,生成新的UsageStats对象
} else {
// Set up the expiry date to be one day from the latest daily stat.
// This may actually be today and we will rollover on the first event
// that is reported.
updateRolloverDeadline(); // <--重点2,更新deadline
}
...
}
private void loadActiveStats(final long currentTimeMillis) {
for (int intervalType = 0; intervalType < mCurrentStats.length; intervalType++) {
final IntervalStats stats = mDatabase.getLatestUsageStats(intervalType);
if (stats != null
&& currentTimeMillis < stats.beginTime + INTERVAL_LENGTH[intervalType]) {
if (DEBUG) {
Slog.d(TAG, mLogPrefix + "Loading existing stats @ " +
sDateFormat.format(stats.beginTime) + "(" + stats.beginTime +
") for interval " + intervalType);
}
mCurrentStats[intervalType] = stats;
} else {
// No good fit remains.
if (DEBUG) {
Slog.d(TAG, "Creating new stats @ " +
sDateFormat.format(currentTimeMillis) + "(" +
currentTimeMillis + ") for interval " + intervalType);
}
mCurrentStats[intervalType] = new IntervalStats();
mCurrentStats[intervalType].beginTime = currentTimeMillis; <--重点!直接使用当前时间戳作为开始时间
mCurrentStats[intervalType].endTime = currentTimeMillis + 1;
}
}
mStatsChanged = false;
updateRolloverDeadline();
// Tell the listener that the stats reloaded, which may have changed idle states.
mListener.onStatsReloaded();
}
private void updateRolloverDeadline() {
mDailyExpiryDate.setTimeInMillis(
mCurrentStats[INTERVAL_DAILY].beginTime); // 直接使用上一个beginTime
mDailyExpiryDate.addDays(1);
Slog.i(TAG, mLogPrefix + "Rollover scheduled @ " +
sDateFormat.format(mDailyExpiryDate.getTimeInMillis()) + "(" +
mDailyExpiryDate.getTimeInMillis() + ")");
}
在init()
函数里,UserUsageStatsService
创建长度4 的数组用于记录日周月年的使用数据,并通过mDatabase.getLatestUsageStats(i)来获取对应Interval的历史数据。注意!这里取到的是最近一个日期的历史数据,而非当天!
举个例子,如果手机在3月1日14时之前一直开机使用,并且在14时的时间点关机,然后等到3月12日开机,getLatestUsageStats所返回的是3月1日的使用数据,而非3月12日!
- 假设
getLatestUsageStats()
返回了上一个自然日k(非今日)的数据,则在updateRolloverDeadline()
函数里将k.beginTime
加上一天 作为今天的beginTime
,意味着如果k.beginTime
没有对齐至0点,这个错误将延续到今天 - 假设
getLatestUsageStats()
没有返回上一个自然日的数据(如首次开机或者刷机),在loadActiveStats()
里直接使用当前传入的currentTimeMillis
作为beginTime
,同样没有对齐0点
综上,系统对于INTERVAL_DAILY的beginTime计算存在很大的问题,根本不是通常意义上的自然日起始时间。
统计启动次数
在前文已经分析过UsageStats类里面的前台时长的统计逻辑,这里复习一下。
在UsageStatsService 进行reportEvent()
时,会把事件交给UsageStats 处理,UsageStats 判断如果这是一个PAUSED、END_OF_DAY 事件,则用传入的timestamp 减去上次记录的mLastTimeVisible
,得到本次使用的前台时间,然后将其累加到mTotalTimeVisible
里面。
需要注意的是,由于分配每天时间区间时,系统对beginTime
的计算存在不精确、难以预测的问题,在实际业务中不会使用系统默认的统计方式。
android.app.usage.UsageStats.java
java
// 处理与前台使用时长有关的事件
public void update(String className, long timeStamp, int eventType, int instanceId) {
switch(eventType) {
case ACTIVITY_RESUMED:
case ACTIVITY_PAUSED:
case ACTIVITY_STOPPED:
case ACTIVITY_DESTROYED:
updateActivity(className, timeStamp, eventType, instanceId);
break;
case END_OF_DAY:
// END_OF_DAY updates all activities.
if (hasForegroundActivity()) {
incrementTimeUsed(timeStamp);
}
if (hasVisibleActivity()) {
incrementTimeVisible(timeStamp);
}
break;
...
}
private void updateActivity(String className, long timeStamp, int eventType, int instanceId) {
if (eventType != ACTIVITY_RESUMED
&& eventType != ACTIVITY_PAUSED
&& eventType != ACTIVITY_STOPPED
&& eventType != ACTIVITY_DESTROYED) {
return;
}
// update usage.
final int index = mActivities.indexOfKey(instanceId);
if (index >= 0) {
final int lastEvent = mActivities.valueAt(index);
switch (lastEvent) {
case ACTIVITY_RESUMED:
incrementTimeUsed(timeStamp);
incrementTimeVisible(timeStamp); // 累加前台使用时长
break;
case ACTIVITY_PAUSED:
incrementTimeVisible(timeStamp);
break;
default:
break;
}
}
// update current event.
switch(eventType) {
case ACTIVITY_RESUMED:
if (!hasVisibleActivity()) { // 更新mLastTimeUsed, mLastTimeVisible等变量
// this is the first visible activity.
mLastTimeUsed = timeStamp;
mLastTimeVisible = timeStamp;
} else if (!hasForegroundActivity()) {
// this is the first foreground activity.
mLastTimeUsed = timeStamp;
}
mActivities.put(instanceId, eventType);
break;
case ACTIVITY_PAUSED:
if (!hasVisibleActivity()) {
// this is the first visible activity.
mLastTimeVisible = timeStamp;
}
mActivities.put(instanceId, eventType);
break;
case ACTIVITY_STOPPED:
case ACTIVITY_DESTROYED:
// remove activity from the map.
mActivities.delete(instanceId);
break;
default:
break;
}
}
// 累加前台使用时长
private void incrementTimeUsed(long timeStamp) {
if (timeStamp > mLastTimeUsed) {
mTotalTimeInForeground += timeStamp - mLastTimeUsed;
mLastTimeUsed = timeStamp;
}
}
然后是App启动次数 的统计,在UsageStats中记录App启动次数的变量叫做mAppLaunchCount
,是在UserUsageStatsService中对它进行操作的。在PAUSE 的时候,记录此时被Pause的包名A,随后在收到RESUME 事件时,对新传入的包名B进行对比,如果两者包名不相等,则认为新传入的包名B是一次AppLaunch 事件。这里的Launch与我们通常意义理解上的App冷/热启动不一致,业务上几乎不会使用这种方式进行统计。
com.server.usage.UserUsageStatsService.java
java
void reportEvent(Event event) {
...
boolean incrementAppLaunch = false; // 判断是否增加启动次数
if (event.mEventType == Event.ACTIVITY_RESUMED) {
if (event.mPackage != null && !event.mPackage.equals(mLastBackgroundedPackage)) {
incrementAppLaunch = true;
}
} else if (event.mEventType == Event.ACTIVITY_PAUSED) {
if (event.mPackage != null) {
mLastBackgroundedPackage = event.mPackage;
}
}
...
if (incrementAppLaunch) {
stats.incrementAppLaunchCount(event.mPackage);
}
}
当用户修改系统时间后,如何保证历史统计的正确
在UserUsageStatsService
类里面,维护了两个long类型的时间相关的变量,在构造函数中对它们进行初始化。
mRealTimeSnapshot
,从系统启动到UserUsageStatsService初始化,中间所经历的时间mSystemTimeSnapshot
,系统启动时的物理世界时间戳
UserUsageStatsService 会定期调用自身的checkAndGetTimeLocked()
函数,检验时间差值是否超过阈值(2s ),若超过则调用onTimeChanged()
。
java
private long checkAndGetTimeLocked() {
final long actualSystemTime = System.currentTimeMillis();
if (!UsageStatsService.ENABLE_TIME_CHANGE_CORRECTION) {
return actualSystemTime;
}
final long actualRealtime = SystemClock.elapsedRealtime(); // 先计算系统启动时间的差值
final long expectedSystemTime = (actualRealtime - mRealTimeSnapshot) + mSystemTimeSnapshot; // 将差值加到物理世界时间上
final long diffSystemTime = actualSystemTime - expectedSystemTime;
if (Math.abs(diffSystemTime) > UsageStatsService.TIME_CHANGE_THRESHOLD_MILLIS) { // 时间差的阈值2s
// The time has changed. 若大于差值,则触发onTimeChanged(),同时更新两个时间变量
Slog.i(TAG, mLogPrefix + "Time changed in by " + (diffSystemTime / 1000) + " seconds");
onTimeChanged(expectedSystemTime, actualSystemTime);
mRealTimeSnapshot = actualRealtime;
mSystemTimeSnapshot = actualSystemTime;
}
return actualSystemTime;
}
在onTimeChanged()
里主要做了以下几件事:
- 将内存里的数据持久化至数据库
- 调用数据库的
onTimeChanged()
方法 - 从数据库里重新加载数据到内存
java
private void onTimeChanged(long oldTime, long newTime) {
mCachedEarlyEvents.clear();
persistActiveStats(); // 1
mDatabase.onTimeChanged(newTime - oldTime); // 2
loadActiveStats(newTime); // 3
}
UsageStatsDatabase.onTimeChanged()
函数入参是时间差值,对目录内每一个文件计算出其新的时间后,删除无效文件(时间<0),并且将旧文件更名为新文件名,但文件内容里的时间戳信息不会修改,只是修改文件名里的beginTime
。
查询过程
供开发者调用的查询时长接口位于UsageStatsManager ,它调用UsageStatsService 的queryUsageStats()
函数,后者则是把查询任务分配给当前user的UserUsageStatsService来执行。
android.app.usage.UsageStatsManager.java
java
public List<UsageStats> queryUsageStats(int intervalType, long beginTime, long endTime) {
try {
@SuppressWarnings("unchecked")
ParceledListSlice<UsageStats> slice = mService.queryUsageStats(intervalType, beginTime,
endTime, mContext.getOpPackageName(), mContext.getUserId());
if (slice != null) {
return slice.getList();
}
} catch (RemoteException e) {
// fallthrough and return the empty list.
}
return Collections.emptyList();
}
android.server.usage.UsageStatsService.java
java
List<UsageStats> queryUsageStats(int userId, int bucketType, long beginTime, long endTime,
boolean obfuscateInstantApps) {
synchronized (mLock) {
if (!mUserUnlockedStates.contains(userId)) {
Slog.w(TAG, "Failed to query usage stats for locked user " + userId);
return null;
}
final UserUsageStatsService service = getUserUsageStatsServiceLocked(userId);
if (service == null) {
return null; // user was stopped or removed
}
List<UsageStats> list = service.queryUsageStats(bucketType, beginTime, endTime);
...
UserUsageStatsService 首先校正时间 ,随后基于校正后的时间对传入的beginTime、endTime进行校验 ,校验比较简单,只判断beginTime要早于当前时间以及传入的endTime即可。
android.server.usage.UserUsageStatsService.java
java
List<UsageStats> queryUsageStats(int bucketType, long beginTime, long endTime) {
if (!validRange(checkAndGetTimeLocked(), beginTime, endTime)) {
return null;
}
return queryStats(bucketType, beginTime, endTime, sUsageStatsCombiner);
}
private static boolean validRange(long currentTime, long beginTime, long endTime) {
return beginTime <= currentTime && beginTime < endTime;
}
查询逻辑的核心位于queryStats()
函数,由于数据保存在内存和数据库两个位置,因此查询时也是分别在这两个地方进行统计。
- 数据库统计: 保留规则是找出数据库中那些
beginTime
位于入参 [beginTime, endTime) 的数据进行统计,无法做到精确匹配。存在的问题是多统计了头部数据,少统计了尾部数据 - 内存统计: 取出时间范围与传入 [beginTime, endTime] 有交集 的Interval。存在的问题是无法做到时间点精准匹配
android.server.usage.UserUsageStatsService.java
java
private <T> List<T> queryStats(int intervalType, final long beginTime, final long endTime,
...
final long truncatedEndTime = Math.min(currentStats.beginTime, endTime);
List<T> results = mDatabase.queryUsageStats(intervalType, beginTime, truncatedEndTime, combiner); // 数据库统计
...
// 只有内存数据完全位于入参时间范围内时,才将其累加
if (beginTime < currentStats.endTime && endTime > currentStats.beginTime) {
if (DEBUG) {
Slog.d(TAG, mLogPrefix + "Returning in-memory stats");
}
if (results == null) {
results = new ArrayList<>();
}
mDatabase.filterStats(currentStats); // 过滤掉已经删除的package
combiner.combine(currentStats, true, results);
}
...
return results;
}
如果传入的intervalType
是BEST ,系统会自动找出四个IntrevalStats 里最接近入参beginTime
的那个,"接近"的判断规则是该interval的beginTime
比参数的beginTime
早,且差值最小,如果没找到则返回-1,使用INTERVAL_DAILY
java
public int findBestFitBucket(long beginTimeStamp, long endTimeStamp) {
synchronized (mLock) {
int bestBucket = -1;
long smallestDiff = Long.MAX_VALUE;
for (int i = mSortedStatFiles.length - 1; i >= 0; i--) {
final int index = mSortedStatFiles[i].closestIndexOnOrBefore(beginTimeStamp);
int size = mSortedStatFiles[i].size();
if (index >= 0 && index < size) {
// We have some results here, check if they are better than our current match.
long diff = Math.abs(mSortedStatFiles[i].keyAt(index) - beginTimeStamp);
if (diff < smallestDiff) {
smallestDiff = diff;
bestBucket = i;
}
}
}
return bestBucket;
}
}
能否支持查询任意时间内的统计信息
回答本文开头提出的问题,如果不做云端存储的话,手机本地是难以支持对全部时长进行检索的,系统只会保留一定期限内的统计信息,超过期限的文件将直接删除。
时间区间 | 文件保留的范围 |
---|---|
日 | 10 |
周 | 4 |
月 | 6 |
年 | 3 |
基于Events实现精准统计,不使用默认的统计方式
由于系统提供的UsageStatsManager.queryUsageStats()
函数存在起始时间不明、启动次数判断不准确的问题,尝试使用自定义方法进行应用前台时长统计。
设计思路是通过事件events
判断,用相邻的PAUSE事件时间戳减去RESUME事件时间戳,得出应用在一次使用周期内的前台时长。对于相同包名的应用,将上述时间差值累加,便得到了指定时间周期的总时长。需要注意,这种方式实时计算得到的时长,仍然受限于系统统计的周期(10天、4周、6月、3年),无法计算得到更早的时间明细(如上个月按天统计的使用时长)
首先是JavaBean。
AppUsageInfo.kt
kotlin
data class AppUsageInfo (
val pkgName: String = "undefined",
val displayName: String = "undefined",
val icon: String = "undefined", // TODO
var dailyUseTime: Long = -1, // 当天游玩时间
var weeklyUseTime: Long = -1, // 本周,从周一开始计算
var monthlyUseTime: Long = -1, // 本月,从1日开始计算
var dailyLaunchCnt: Int = 0,
var weeklyLaunchCnt: Int = 0,
var monthlyLaunchCnt: Int = 0,
)
...
然后是统计逻辑。
取当前时间,计算出日、周、月的起始时间,分别调用UsageStatsManager.queryEvents()
得到相应时间周期内的全部事件,然后进行过滤。
第一步得到的事件列表是手机内所有在装App的,实际使用时在取得列表后应当对包名进行过滤。
UsageManager.kt
java
/**
* 基于UsageEvents计算前台时长
*/
fun queryUsageInfoV2(pkgNames: List<String>): List<AppUsageInfo> {
val today = Calendar.getInstance()
val dayStart = Calendar.getInstance().startTimeOf(Period.DAY)
val weekStart = Calendar.getInstance().startTimeOf(Period.WEEK)
val monthStart = Calendar.getInstance().startTimeOf(Period.MONTH)
val dailyEvents = usm.queryEvents(dayStart.timeInMillis, today.timeInMillis)
val dailyUsageMap = retrieveUsageFromEvents(dailyEvents, pkgNames, Period.DAY)
val weeklyEvents = usm.queryEvents(weekStart.timeInMillis, today.timeInMillis)
val weeklyUsageMap = retrieveUsageFromEvents(weeklyEvents, pkgNames, Period.WEEK)
val monthlyEvents = usm.queryEvents(monthStart.timeInMillis, today.timeInMillis)
val monthlyUsageMap = retrieveUsageFromEvents(monthlyEvents, pkgNames, Period.MONTH)
return combine(pkgNames, dailyUsageMap, weeklyUsageMap, monthlyUsageMap)
}
根据事件列表,求出resume to pause的时间,累加后得出前台总时长。
java
/**
* 从List<UsageEvents>中提取应用使用时长
*/
private fun retrieveUsageFromEvents(usageEvent: UsageEvents, pkgNames: List<String>, period: Period): Map<String, AppUsageInfo> {
// packageName - (Long) usage
val appUsageMap = HashMap<String, AppUsageInfo>().apply {
pkgNames.forEach {
put(it, AppUsageInfo(pkgName = it))
}
}
/**
* 遍历事件时,辅助计算用的临时集合
* - 若是进入前台事件,则记录
* - 若是进入后台事件,则取出map中的前台时间戳,记录本次使用时长
*/
val appUsageCalcMap = HashMap<String, Long>().apply {
val dayStart = Calendar.getInstance().startTimeOf(period)
pkgNames.forEach {
put(it, dayStart.timeInMillis)
}
}
// 辅助计算启动次数
val appLaunchCntMap = HashMap<String, Int>()
// 遍历events
while(usageEvent.hasNextEvent()) {
val event = UsageEvents.Event()
usageEvent.getNextEvent(event)
if (!appUsageCalcMap.containsKey(event.packageName)) {
continue
}
when (event.eventType) {
UsageEvents.Event.ACTIVITY_PAUSED -> {
val resumeTime = appUsageCalcMap[event.packageName] ?: 0
if (resumeTime == 0L) {
// 上一个RESUME事件已被消费掉,说明出现连续两个PAUSED事件,异常
LLOG("Error, duplicated PAUSED event!")
continue
} else {
// 计算时差并累加
val timeDiff = event.timeStamp - resumeTime
updateUsage(appUsageMap[event.packageName] ?: continue, timeDiff, period)
}
appUsageCalcMap[event.packageName] = 0
}
UsageEvents.Event.ACTIVITY_RESUMED -> {
appUsageCalcMap[event.packageName] = event.timeStamp
}
UsageEvents.Event.DEVICE_STARTUP -> {
appLaunchCntMap[event.packageName] = 1 + (appLaunchCntMap[event.packageName]?:0)
updateLaunchCnt(appUsageMap[event.packageName] ?: continue, appLaunchCntMap[event.packageName] ?: 0, period)
}
}
}
return appUsageMap
}
private fun updateUsage(appUsageInfo: AppUsageInfo, timeDiff: Long, period: Period) {
when (period) {
Period.DAY -> {
appUsageInfo.dailyUseTime = appUsageInfo.dailyUseTime + timeDiff
}
Period.WEEK -> {
appUsageInfo.weeklyUseTime = appUsageInfo.weeklyUseTime + timeDiff
}
Period.MONTH -> {
appUsageInfo.monthlyUseTime = appUsageInfo.monthlyUseTime + timeDiff
}
}
}
private fun updateLaunchCnt(appUsageInfo: AppUsageInfo, launchCnt: Int, period: Period) {
when (period) {
Period.DAY -> appUsageInfo.dailyLaunchCnt = launchCnt
Period.WEEK -> appUsageInfo.weeklyLaunchCnt = launchCnt
Period.MONTH -> appUsageInfo.monthlyLaunchCnt = launchCnt
}
}
完善自定义的统计功能
以上便可以相对精确地统计出近期的使用时长分布,基于这个方案,可以做的事情还有:
功能 | 优先级 | 说明 |
---|---|---|
扩大计算周期 | P0 | 由于系统接口查询events时仍然存在beginTime的问题,故需要扩大计算周期,将传入的beginTime提前一个单位(日周月年),以覆盖目标时间区间 |
跨天统计 | P0 | 如果应用跨天使用,应当计入这段使用时长。 事件特点是 - 只有PAUSE没有RESUME(当天开头使用) - 只有RESUME没有PAUSE(当天结尾使用) |
数据持久化 | P1 | 设计每日定期任务统计数据,并保存到数据库里;一方面加快查询效率,另一方面可以保留历史统计数据,防止系统清理掉超过时间范围的xml文件 |
数据可视化 | P2 | 更加直观地展示统计数据 |