问题背景:
目前需求统计应用的当天使用情况,在 5.0 以上有权限 android.permission.PACKAGE_USAGE_STATS
,获取到该权限后可以通过 UsageStatsManager.queryUsageStats(int intervalType, long beginTime, long endTime)
方法查询到应用的使用情况。
问题描述:
第一天下午使用一些应用后,可以查询到相应应用的当天使用记录。第二天早上七点打开手机,在确定未使用这些应用的情况下,还是查询到了这些应用的当天使用记录。因为查询的是当天使用记录,所以在过去了一个自然日后,应该查询不到应用的使用记录才对。
问题分析:
queryUsageStats
有三个参数,分别是 intervalType
、beginTime
和 endTime
。先说说 beginTime
和 endTime
,它们分别表示待查询时间范围的起始时间和结束时间。而 intervalType
表示时间间隔的类型,它有5个值:
INTERVAL_DAILY
:天存储级别的数据;INTERVAL_WEEKLY
:星期存储级别的数据;INTERVAL_MONTHLY
:月存储级别的数据;INTERVAL_YEARLY
:年存储级别的数据;INTERVAL_BEST
:根据给定时间范围选取最佳时间间隔类型。
既然有了待查询时间范围,那么时间类型又有什么用呢,这就是导致上述问题发生的原因了。测试中查询的时间范围默认是当天零点到当前时间,而时间类型用的是 INTERVAL_DAILY
。在实际查询结果中发现查询到的使用记录并不是按给定时间范围来的,而是从前一天的某个时间点开始的。
我们先看下 queryUsageStats
返回的数据,它是一个 UsageStats
类型的数据集合,其中有几个关键字段:
mBeginTimeStamp
:查询范围的起始时间;mEndTimeStamp
:查询范围的起结束时间;mLastTimeUsed
:应用最后一次使用结束时的时间;mTotalTimeInForeground
:查询范围内应用在前台的累积时长;mLaunchCount
:查询范围内应用的打开次数。
进一步分析查询结果,发现其中的 mBeginTimeStamp
和 mEndTimeStamp
并不是传入的时间范围,而是和 intervalType
有关,比如在 INTERVAL_DAILY
情况下进行如下测试:
查询时间范围 2018-10-31 00:00:00
到 2018-10-31 18:00:00
,查询结果中 mBeginTimeStamp
和 mEndTimeStamp
分别为 2018-10-30 08:00:00
和 2018-10-31 07:59:59
。
查询时间范围 2018-10-30 10:00:00
到 2018-10-31 18:00:00
,查询结果中 mBeginTimeStamp
和 mEndTimeStamp
分别为 2018-10-30 08:00:00
和 2018-10-31 07:59:59
。
查询时间范围 2018-10-31 10:00:00
到 2018-10-31 18:00:00
,查询结果中 mBeginTimeStamp
和 mEndTimeStamp
分别为 2018-10-31 08:00:00
和 2018-10-31 19:17:32
(当前系统时间)。
从以上3次测试结果可以看出,beginTime
、endTime
与 mBeginTimeStamp
、mEndTimeStamp
没有显而易见的关系。我们再看一下 queryUsageStats
参数的注释:
通过注释可以理解 beginTime
会包含在查询结果的时间范围内,即:
beginTime >= mBeginTimeStamp && beginTime <= mEndTimeStamp
综上可以得出结论,queryUsageStats
的查询范围依赖 intervalType
而定,比如 INTERVAL_DAILY
类型会查询一天内的使用情况,并且根据传入的 beginTime
计算出该天的起始和结束时间。这里的天并非自然日的概念,而是受时区影响的一天,即北京时间(东八区)从 08:00:00 开始算一天,但在少数机型上也发现从 00:00:00 开始算一天。不过可以肯定的是 mBeginTimeStamp
会受到时区的影响,比如当前是东八区,修改到东十区,一天的起始时间会相应增加2小时。
解决方案:
因为是系统的 api,查询的时间段无法调整到更精确的范围,只能考虑增加对 mLastTimeUsed
的判断,这个字段是应用最后一次使用结束时的时间,可以判断 mLastTimeUsed
是不是在 beginTime
和 endTime
之间来确定用户当天是否有使用过应用。
但是如果想判断用户当天是否有使用应用达到一定时长,就不能很准确的判断了。因为 mTotalTimeInForeground
统计的是 mBeginTimeStamp
到 mEndTimeStamp
期间应用在前台的累计时长,所以即使结合 mLastTimeUsed
判断出用户当天有打开过应用,但还是无法准确得知当天打开应用后的使用时长。
拓展阅读:
在测试期间还发现一个问题:先使用某个应用一定时间,查询出当天的使用记录,然后修改手机系统时间到一星期后,查询发现当天仍有使用记录,且两次查询结果除日期变化外其余都相同。猜测使用记录是不是以一种相对时间的方式存储在系统中的,当查询时会根据当前时间计算出具体时间戳,所以进一步研究验证。
这里就不得不提一下 UsageStatsService
了,UsageStatsManager.queryUsageStats
实际上是通过 UsageStatsService.queryUsageStats
进行查询的。UsageStatsService
是一个系统服务,其主要通过 AMS 等来收集、聚合和保留应用程序使用数据,可以通过 AppOps 授予应用程序权限来查询此数据。
UsageStatsService
记录的应用行为事件全部定义在 UsageEvents
类中,在 UsageStats
中也有字段 mLastEvent
记录了应用的最后一次行为。这里说一下几个常见的事件:
-
MOVE_TO_FOREGROUND
:当 ActivityonResume
时,ActivityManagerService
会调用UsageStatsService.noteResumeComponent
方法记录下此事件,标记应用切换到前台。 -
MOVE_TO_BACKGROUND
:当 ActivityonPause
时,ActivityManagerService
会调用UsageStatsService.notePauseComponent
方法记录下此事件,标记应用切换到后台。
UsageStatsService
在接收到事件变化时会通过 writeStatsToFile
方法将数据持久化成文件,通过这里可以得知 UsageStatsService
是通过文件来存储应用的使用数据的。
那么文件又是如何被管理的呢?这里需要引出 UsageStatsDatabase
,通过它将数据持久化存储在了 XML 文件中,并提供从 XML 数据库查询 UsageStats
数据的接口,XML 文件存储在 /data/system/usagestats/
路径下。数据目录按 daily、monthly、weekly、yearly 四个文件夹存储。XML 的所有操作,例如读、写等,都被封装在 UsageStatsXmlV1
中,由 UsageStatsDatabase
进行调用。
关于 UsageStatsService
的数据存储,还有很重要的一点需要提到,那就是缓存。UsageStatsService
在每次启动时,都会先按照 user 生成各个 UserUsageStatsService
,其中每个对象都会先去各自的文件路径下读取数据到内存中。此后每次外界 reportEvent
时,都会先更新内存中的数据,相当于缓存。那么什么时候会把内存中的数据更新到文件中呢?主要有以下几种情况:
- 手机关机
- 系统时间跳变
- 一天结束时
从这里引出了我们一开始提到的问题,在修改系统时间后,使用记录为什么会跟随日期变化。
首先,手机系统时间会发生跳变,常见形式有人为修改时间或系统通过网络自动校准。比如,手机第一次使用,未联网校准时,手机时间是错误的,可能显示为1970年1月1日,这时候产生的应用使用数据会被记录为1970年1月1日。但手机联网后,时间通过网络校准为2018年11月1日。那么 UsageStatsService
中统计的时间会仍然为1970年1月1日吗?Google 早在设计之初就考虑到了这点,在 UsageStatsService
中有一个巧妙的机制来保证记录时间的准确性。
UsageStatsService
中有一个 checkAndGetTimeLocked
方法,此方法会在每次 reportEvent
或 queryUsageStats
时会去检查系统时间。
通过代码可以看出,checkAndGetTimeLocked
会计算出当前系统时间与预期系统时间之间的差值,当这个差值大于 TIME_CHANGE_THRESHOLD_MILLIS
(2s)时,UsageStatsService
会调用 onTimeChanged
方法,它会负责更新 UsageStatsService
记录的时间,以便它们能够跟随系统时间跳变而相应更新。这就是直接导致开始提到的修改手机系统时间后,查询发现当天仍有使用记录,且前后两次查询结果除日期变化外其余都相同的原因。
既然使用记录的时间会随系统时间修改而变动,那么它是否如猜测一般是以一种相对时间的形式存储的呢?首先,我们来看下 daily 文件夹:
从上图中可以看出 XML 文件是以当天日期起点的时间戳命名的。如 1541030400000 即 2018-11-01 08:00:00。其次,在 daily 文件夹下,XML 文件是以天级别存储的,每天会新建一个文件。这里也可以联想到之前分析的 UsageStatsManager.queryUsageStats
不会根据传入的时间范围精确查询,而是以天级别查询的,其实就是读取分析了当天的 XML 文件。且文件命名中的时间是受时区影响的,也就导致了 daily 级别的查询结果不是按自然日来的。接下来看一下 XML 文件的具体内容:
从文件中可以看出,使用记录是以毫秒级别的差值进行存储的,这个差值是以文件名中的时间点为锚定的。在查询使用记录时会结合两者计算出确切的时间,比如,应用的上次使用时间 = XML 文件名 + XML 中此应用的上次使用时间。
通过以上机制,当系统时间跳变时,UsageStatsService
会通过 onTimeChanged
方法更新 XML 文件名中的时间,而 XML 中的数据并不需要变动即可随着系统时间的跳变而保持准确值。
2018/12/28 更新
如何解决 UsageStatsManager.queryUsageStats
统计不准确问题
在研究统计应用前台活动时,发现 UsageStatsManager.queryEvents
方法查询的时间范围是准确的。其返回结果是 UsageEvents
类型,其中包含了该时间段内所有应用的所有活动事件,UsageEvents.Event
包含了单个事件的具体内容,通过 while (UsageEvents.getNextEvent(UsageEvents.Event))
方法可以遍历获取所有事件。
UsageEvents.Event
中主要提供了以下几个 API :
-
getPackageName()
:该事件来源应用的包名。 -
getTimeStamp()
:该事件发生的时间,毫秒级别时间戳。 -
getEventType()
:该事件类型。
其中,事件类型分为以下几种:
/**
* No event type.
*/
public static final int NONE = 0;
/**
* An event type denoting that a component moved to the foreground.
*/
public static final int MOVE_TO_FOREGROUND = 1;
/**
* An event type denoting that a component moved to the background.
*/
public static final int MOVE_TO_BACKGROUND = 2;
/**
* An event type denoting that a component was in the foreground when the stats
* rolled-over. This is effectively treated as a {@link #MOVE_TO_BACKGROUND}.
* {@hide}
*/
public static final int END_OF_DAY = 3;
/**
* An event type denoting that a component was in the foreground the previous day.
* This is effectively treated as a {@link #MOVE_TO_FOREGROUND}.
* {@hide}
*/
public static final int CONTINUE_PREVIOUS_DAY = 4;
/**
* An event type denoting that the device configuration has changed.
*/
public static final int CONFIGURATION_CHANGE = 5;
/**
* An event type denoting that a package was interacted with in some way by the system.
* @hide
*/
public static final int SYSTEM_INTERACTION = 6;
/**
* An event type denoting that a package was interacted with in some way by the user.
*/
public static final int USER_INTERACTION = 7;
/**
* An event type denoting that an action equivalent to a ShortcutInfo is taken by the user.
*
* @see android.content.pm.ShortcutManager#reportShortcutUsed(String)
*/
public static final int SHORTCUT_INVOCATION = 8;
通过记录每个应用 MOVE_TO_FOREGROUND
和 MOVE_TO_BACKGROUND
的时间点,可以计算出其在前台停留的时间。该方法可以弥补 UsageStatsManager.queryUsageStats
查询不准确的问题,但处理过程比较麻烦,相当于自己对 mTotalTimeInForeground
进行统计运算。