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 更加直观地展示统计数据

参考资料

相关推荐
Tans521 小时前
Androidx Fragment 源码阅读笔记(下)
android jetpack·源码阅读
Tans54 天前
Androidx Fragment 源码阅读笔记(上)
android jetpack·源码阅读
Tans58 天前
Androidx Lifecycle 源码阅读笔记
android·android jetpack·源码阅读
凡小烦8 天前
LeakCanary源码解析
源码阅读·leakcanary
程序猿阿越16 天前
Kafka源码(四)发送消息-服务端
java·后端·源码阅读
CYRUS_STUDIO19 天前
Android 源码如何导入 Android Studio?踩坑与解决方案详解
android·android studio·源码阅读
Code_Artist19 天前
[Java并发编程]6.并发集合类:ConcurrentHashMap、CopyOnWriteArrayList
java·后端·源码阅读
Joey_Chen1 个月前
【源码赏析】开源C++日志库spdlog
架构·源码阅读
顾林海1 个月前
Android MMKV 深度解析:原理、实践与源码剖析
android·面试·源码阅读
程序猿阿越1 个月前
Kafka源码(三)发送消息-客户端
java·后端·源码阅读