Android应用时长统计 UsageStatsManager & UsageStatsService 源码全解析

笔者在某厂开发工具类App期间,曾经负责实现过这样一个功能:统计用户手机上所有游戏的前台使用时间 ,并以当天本周 以及历史全部 三个维度进行展示。经过调研,Android 5.0(API 21)提供了UsageStatsManager负责此类功能,但实现过程中发现其有较大误差,事实上,早在2018年Google Issue Tracker上就有人提过这个问题,Android开发团队承认了这个bug,但至今仍然没有得到完善解决。

UsageStatsManager是Android系统提供的获取应用使用统计信息的服务,通过它可以查询任意应用的前台使用时长、首次启动时间、最近一次启动时间、启动次数等信息。阅读本文后,你将了解到:

  1. 如何通过UsageStatsManager获取指定应用在指定时间区间内的前台使用时长
  2. UsageStatsManager的启动流程是怎样的
  3. 系统如何记录应用的使用信息
  4. 如果用户篡改系统时间,是否会导致使用数据时长错乱
  5. 在查询使用信息时调用关系是怎样的
  6. 是否支持查询任意时间范围内的使用时长
  7. 默认的获取使用信息接口有何缺陷
  8. 自定义方法,实现精准计算应用前台时长

接下来让我们从一个简单的Demo开始。


获取应用使用时长的Demo

UsageStatsManager API用法

官方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中存在的问题

输出如上,统计结果并没能真实反映用户在微信上的使用时长和次数,存在两个问题

  1. 开始时间计算错误,返回值里beginTime是早上8点多,而通常意义上一天的统计起始时间是0点
  2. 启动次数计算有误,比实际的启动次(冷+热)数多

在开发业务需求时,我采用类似方法实现的统计游戏时长功能,经测试同学反馈存在同样的计时不准问题。

以下从源码角度分析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首先拿到AppOpsManagerUserManagerPackageManagerInternalPackageManager并保存作为成员变量,随后初始化了一个用于执行异步任务的Handler 。将开机后系统运行的时间记录为mRealTimeSnapshot ,将当时的系统时间记录为mSystemTimeSnapshot,如果后续用户手动设置系统时间,会通过这两个变量对历史数据进行校准。publishBinderServices() 则将UsageStatsService自身发布到ServiceManager,关于这部分知识可以参考之前对Binder原理的分析

onStart()最后发送MSG_ON_START 消息给Handler ,它收到这个消息后,通过loadGlobalComponentUsageLocked()函数,加载位于 /data/system/usagestats/globalcomponentusage目录的xml文件列表,这些文件保存了手机里各个App的最后启动时间,读取完成后,将数据保存在mLastTimeComponentUsedGlobal的ArrayMap结构里。

ArrayMap比HashMap的优点在于内存使用率更高,在不超过1000个元素的情况下,两者的插入/查找/删除没有明显的速度差异


记录用户使用时长

AOSP版本12.0.0_r34

接下来分析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;
    ...
}

这里mAtmServiceActivityTaskManagerService对象。

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,而后者又调用ActivityManagerServiceupdateActivityUsageStats()函数,将任务分派给负责维护用户使用统计信息的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是负责将IntervalStatsUsageStats持久化为二进制序列的类,文件是不可能只新增不清理的,因此在它内部也有清理过期文件的逻辑。清理时有两条规则同时起作用。

  1. 文件数量限制,日周月年的xml文件数量各有上限 (100,50,12,10) ,当文件超过上限时则删除最老的个
  2. 记录时间限制,每个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是负责将IntervalStatsUsageStats持久化为二进制序列的类,文件是不可能只新增不清理的,因此在它内部也有清理过期文件的逻辑。清理时有两条规则同时起作用。

  1. 文件数量限制,日周月年的xml文件数量各有上限 (100,50,12,10) ,当文件超过上限时则删除最老的个
  2. 记录时间限制,每个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()里主要做了以下几件事:

  1. 将内存里的数据持久化至数据库
  2. 调用数据库的onTimeChanged()方法
  3. 从数据库里重新加载数据到内存
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 ,它调用UsageStatsServicequeryUsageStats()函数,后者则是把查询任务分配给当前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;
    }

如果传入的intervalTypeBEST ,系统会自动找出四个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 更加直观地展示统计数据

参考资料

相关推荐
Tans59 天前
LeakCanary 源码阅读笔记(四)
源码阅读·leakcanary
灵感__idea10 天前
Vuejs技术内幕:组件渲染
前端·vue.js·源码阅读
Sword9919 天前
【ThreeJs原理解析】第4期 | 向量
前端·three.js·源码阅读
biubiubiu王大锤25 天前
Nacos源码分析-永久实例健康检查机制
java·源码阅读
Sword991 个月前
【ThreeJs原理解析】第3期 | 射线检测Raycaster实现原理
前端·three.js·源码阅读
欧阳码农1 个月前
看不懂来打我!Vue3的watch是如何实现数据监听的
vue.js·源码·源码阅读
biubiubiu王大锤1 个月前
nacos源码分析-客户端启动与配置动态更新的实现细节
后端·源码阅读
Sword991 个月前
【ThreeJs原理解析】第2期 | 旋转、平移、缩放实现原理
前端·three.js·源码阅读
侠客行03172 个月前
Eureka Client的初始化
java·架构·源码阅读
web_code2 个月前
webpack源码快速分析
前端·webpack·源码阅读