1. 项目概述:为什么我们需要统计应用使用时长?
在Android开发中,尤其是涉及数字健康、家长控制、应用分析或设备管理类的项目时,一个非常核心的需求就是:精确地知道用户在各个应用上花费了多少时间,以及打开了多少次。这听起来简单,但背后涉及到系统权限、数据聚合、后台服务等一系列复杂问题。你可能想做一个类似“屏幕时间”的功能,或者在你的App里加入一个“今日使用报告”的小组件。直接的想法可能是去监听应用的前后台切换,但这种方式既不准确(无法统计前台服务时间),也容易被系统优化掉。
这时,Android系统自带的UsageStatsManager服务就成了我们的“官方答案”。它就像一个系统级的“时间记录员”,由系统内核统一收集所有应用的活动数据,我们只需要通过合适的权限去查询它整理好的报告。相比于自己“造轮子”,使用UsageStatsManager不仅数据更权威、更省电,也避免了因滥用后台监听而被系统限制的风险。今天,我就结合自己多次在数字健康类App中的实战经验,带你彻底搞懂UsageStatsManager,从权限申请、数据查询到数据处理,手把手实现一个可靠的应用使用时长与次数统计模块。
2. 核心权限与配置:跨过第一道门槛
在开始写代码之前,我们必须先搞定权限和配置。这是很多新手最容易栽跟头的地方,因为相关的配置项比较隐蔽,且在不同Android版本上行为差异很大。
2.1 必不可少的PACKAGE_USAGE_STATS权限
UsageStatsManager查询的数据属于高敏感信息,因此Android系统要求应用必须拥有android.permission.PACKAGE_USAGE_STATS权限。这个权限比较特殊,它属于“系统签名或用户授权”权限。
这意味着什么?普通应用无法通过在AndroidManifest.xml里简单声明就自动获得此权限。用户必须主动进入系统的“设置” -> “安全与隐私” -> “特殊应用权限” -> “使用情况访问”(不同厂商手机路径可能略有不同,如“应用使用情况”)页面,手动为你的应用开启开关。
在代码中引导用户授权:我们不能假设用户已经打开了权限。因此,在尝试查询数据前,必须检查权限状态,并引导用户去设置页面。以下是标准的检查与引导流程:
fun checkUsageStatsPermission(context: Context): Boolean { val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager val mode = appOps.checkOpNoThrow( AppOpsManager.OPSTR_GET_USAGE_STATS, android.os.Process.myUid(), context.packageName ) return mode == AppOpsManager.MODE_ALLOWED } fun requestUsageStatsPermission(activity: Activity) { if (!checkUsageStatsPermission(activity)) { // 构建一个明确的提示对话框 AlertDialog.Builder(activity) .setTitle("需要「使用情况访问」权限") .setMessage("此功能需要获取应用使用统计信息,以准确计算使用时长。请点击「去开启」,然后在系统设置中找到本应用并打开开关。") .setPositiveButton("去开启") { _, _ -> // 跳转到系统设置页面 val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS) activity.startActivity(intent) } .setNegativeButton("取消", null) .show() } }注意:在Android 11(API 30)及以上版本,即使拥有
PACKAGE_USAGE_STATS权限,应用也只能查询到自身以及其他可见应用的使用情况。所谓“可见应用”,通常指那些在Launcher中有图标的应用。一些系统核心进程或完全隐藏的应用可能不会出现在查询结果中,这是出于隐私保护的设计,你的代码需要能处理这种数据不完整的情况。
2.2 AndroidManifest.xml 中的权限声明
尽管需要用户手动授权,但在AndroidManifest.xml文件中声明该权限仍然是必须的,否则系统根本不会把你的应用列入“使用情况访问”的可选列表里。
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.yourcompany.yourapp"> <uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" /> <application ...> ... </application> </manifest>实操心得:在实际项目中,我强烈建议将权限检查封装成一个独立的工具类,并在App主页面或需要使用该功能的页面入口处进行校验。弹窗提示的文案要尽可能清晰,直接告诉用户需要去“系统设置”里操作,因为很多用户并不清楚“使用情况访问”这个系统级权限入口在哪里。测试时,务必在真机上反复测试权限开启和关闭的流程,模拟用户可能遇到的所有情况。
3. UsageStatsManager 核心API详解
拿到权限后,我们就可以和UsageStatsManager打交道了。首先通过Context.getSystemService(Context.USAGE_STATS_SERVICE)获取它的实例。
UsageStatsManager提供了几种主要的查询方法,我们需要根据不同的场景选择使用。
3.1 查询指定时间区间内的使用统计:queryUsageStats
这是最常用、最核心的方法。它返回一个List<UsageStats>,包含了在给定时间区间内所有有活动的应用的数据快照。
val usageStatsManager = getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager // 定义查询的时间区间:查询过去24小时的数据 val calendar = Calendar.getInstance() val endTime = calendar.timeInMillis calendar.add(Calendar.DAY_OF_YEAR, -1) val startTime = calendar.timeInMillis // 执行查询 val stats: List<UsageStats> = usageStatsManager.queryUsageStats( UsageStatsManager.INTERVAL_DAILY, // 时间间隔类型,用于系统内部优化聚合 startTime, endTime )关键参数解析:
intervalType(时间间隔类型):INTERVAL_DAILY: 日级聚合。适合查询最近几天的数据,系统可能为此优化了存储。INTERVAL_WEEKLY: 周级聚合。INTERVAL_MONTHLY: 月级聚合。INTERVAL_YEARLY: 年级聚合。INTERVAL_BEST: 系统根据你提供的startTime和endTime自动选择最合适的聚合区间。在大多数情况下,推荐使用INTERVAL_BEST,让系统帮你做最优选择,避免因为区间类型不匹配导致查询不到数据或数据不准确。
startTime和endTime:查询的起止时间戳(毫秒)。需要注意的是,UsageStatsManager的数据保留时间是有限的。通常系统只会保留最近几天(例如7天)的详细使用记录,更早的数据可能已被聚合或清除。如果你查询一个非常早的时间段,返回的列表可能为空或数据不全。
返回的UsageStats对象包含哪些信息?这是数据处理的源头,务必理解每个字段的含义:
packageName: 应用包名,如com.tencent.mm。firstTimeStamp/lastTimeStamp: 该应用在查询区间内首次和末次被使用的时间戳。lastTimeUsed: 该应用最后一次被使用(切换到前台)的时间戳。totalTimeInForeground:核心字段。该应用在查询区间内处于前台的总时间(毫秒)。这就是我们计算“使用时长”的依据。mLaunchCount(通过getLaunchCount()方法获取):核心字段。该应用在查询区间内被启动(带到前台)的次数。这就是“使用次数”。
3.2 查询聚合后的使用情况:queryAndAggregateUsageStats
如果你不关心每个独立的时间片段,只想要整个查询区间内的汇总数据,这个方法更高效。它返回一个Map<String, UsageStats>,以包名为 Key,对应的UsageStats中的totalTimeInForeground和mLaunchCount已经是整个区间的累加值。
val aggregatedStats: Map<String, UsageStats> = usageStatsManager.queryAndAggregateUsageStats( startTime, endTime ) // 直接获取微信的总使用时间 val weChatStats = aggregatedStats["com.tencent.mm"] val weChatUsageTime = weChatStats?.totalTimeInForeground ?: 0L3.3 查询实时事件流:queryEvents
对于需要实时监控应用使用行为(如实现“应用使用限制”,一到时间就锁屏)的高级场景,可以使用queryEvents。它返回一个UsageEvents对象,其中包含了一系列UsageEvents.Event。每个事件代表一个状态变化,比如MOVE_TO_FOREGROUND(应用切换到前台) 和MOVE_TO_BACKGROUND(应用退到后台)。
val events: UsageEvents = usageStatsManager.queryEvents(startTime, endTime) val event = UsageEvents.Event() while (events.hasNextEvent()) { events.getNextEvent(event) when (event.eventType) { UsageEvents.Event.MOVE_TO_FOREGROUND -> { Log.d("Usage", "${event.packageName} 进入前台 at ${Date(event.timeStamp)}") } UsageEvents.Event.MOVE_TO_BACKGROUND -> { Log.d("Usage", "${event.packageName} 退到后台 at ${Date(event.timeStamp)}") } } }注意事项:queryEvents会产生更细粒度的数据,但数据量也大得多,处理起来更复杂,且对性能有一定影响。除非有实时响应的需求,否则对于简单的时长统计,使用queryUsageStats或queryAndAggregateUsageStats就足够了。
4. 从原始数据到业务数据:数据处理全流程
拿到List<UsageStats>只是第一步,里面的数据是原始的、按包名分列的。我们需要将其转换成用户可读的、按应用归集的今日/本周使用报告。这个过程涉及到数据过滤、聚合、排序和格式化。
4.1 数据清洗与过滤
查询返回的列表中可能包含一些我们不需要的条目:
- 系统组件或包名异常的应用:有些包名以
android、com.android、system开头,或者没有对应实际应用。 - 总使用时间为0的应用:这些应用可能只是被系统唤醒过,但用户并未主动使用。
- 自身的应用:如果你不想统计自己,可以过滤掉。
fun processUsageStats(rawStats: List<UsageStats>): List<UsageStats> { return rawStats.filter { stats -> // 过滤掉使用时间为0的项 stats.totalTimeInForeground > 0 }.filterNot { stats -> // 过滤掉一些常见的系统包(可根据需要扩充列表) stats.packageName.startsWith("android") || stats.packageName.startsWith("com.android") || stats.packageName.startsWith("system") || stats.packageName == packageName // 过滤自身 } }4.2 关键指标计算:时长、次数与占比
对于过滤后的列表,我们可以开始计算核心指标。
data class AppUsageInfo( val packageName: String, val appName: String, // 需要通过包名解析出应用名 val totalTimeMs: Long, // 总使用时长(毫秒) val launchCount: Int, // 启动次数 val percentage: Float // 占总使用时间的百分比 ) fun calculateUsageInfo(processedStats: List<UsageStats>, context: Context): List<AppUsageInfo> { // 1. 计算所有应用的总使用时间 val totalUsageTimeMs = processedStats.sumOf { it.totalTimeInForeground } // 2. 转换并计算百分比 return processedStats.map { stats -> val appName = resolveAppName(context, stats.packageName) val usageTimeMs = stats.totalTimeInForeground val percentage = if (totalUsageTimeMs > 0) { (usageTimeMs.toFloat() / totalUsageTimeMs.toFloat()) * 100 } else { 0f } AppUsageInfo( packageName = stats.packageName, appName = appName, totalTimeMs = usageTimeMs, launchCount = stats.getLaunchCount(), percentage = percentage ) }.sortedByDescending { it.totalTimeMs } // 按使用时长降序排序 } /** * 根据包名获取应用名称 */ private fun resolveAppName(context: Context, packageName: String): String { return try { val pm = context.packageManager val appInfo = pm.getApplicationInfo(packageName, 0) pm.getApplicationLabel(appInfo).toString() } catch (e: PackageManager.NameNotFoundException) { // 如果应用已卸载或找不到,则返回包名 packageName } }4.3 时间格式化与展示
用户看不懂毫秒,我们需要将其转换成“X小时Y分钟”或“X分Y秒”的友好格式。
fun formatDuration(milliseconds: Long): String { val seconds = milliseconds / 1000 val hours = seconds / 3600 val minutes = (seconds % 3600) / 60 val remainingSeconds = seconds % 60 return when { hours > 0 -> String.format("%d小时%d分钟", hours, minutes) minutes > 0 -> String.format("%d分钟%d秒", minutes, remainingSeconds) else -> String.format("%d秒", remainingSeconds) } } // 在UI中展示 val usageInfo: AppUsageInfo = ... textViewTime.text = formatDuration(usageInfo.totalTimeMs) textViewPercentage.text = String.format("%.1f%%", usageInfo.percentage)实操心得:数据处理部分最容易出性能问题的地方在于resolveAppName。如果列表中有几十个应用,每个都去查询PackageManager会阻塞主线程。务必在子线程(如使用viewModelScope.launch(Dispatchers.IO))中执行整个数据处理流程,然后将最终结果post到主线程更新UI。可以考虑增加一个简单的内存缓存(Map<String, String>),将包名与应用名的映射关系缓存起来,避免重复查询。
5. 构建一个完整的后台统计服务
对于需要持续记录、即使App退到后台也要工作的场景(如完整的屏幕时间统计),我们需要一个Service。但要注意,Android系统对后台服务的限制越来越严格。
5.1 使用前台服务 (Foreground Service)
从Android 8.0 (API 26) 开始,如果服务需要在后台长时间运行,必须启动为前台服务,并显示一个无法被清除的通知。
AndroidManifest.xml 中声明权限和服务:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <service android:name=".UsageStatsCollectorService" android:enabled="true" android:exported="false" android:foregroundServiceType="dataSync" /> <!-- 根据实际用途选择type,如 dataSync, location等 -->在Service中启动为前台服务:
class UsageStatsCollectorService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // 创建通知渠道(Android 8.0+ 必需) createNotificationChannel() // 构建通知 val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("使用情况统计中") .setContentText("正在后台收集应用使用数据") .setSmallIcon(R.drawable.ic_stat_icon) .setPriority(NotificationCompat.PRIORITY_LOW) .build() // 启动为前台服务 startForeground(NOTIFICATION_ID, notification) // 开始你的定时统计任务... startPeriodicCollection() return START_STICKY } private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, "使用统计服务", NotificationManager.IMPORTANCE_LOW ).apply { description = "用于后台收集应用使用时长数据" } val manager = getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) } } // ... 其他代码 }5.2 实现定时查询策略
在服务中,我们不能无限循环查询,这太耗电。通常采用以下两种策略之一:
WorkManager定时任务:这是最推荐的方式。WorkManager能保证任务被执行,同时会考虑系统的省电策略。你可以设置一个周期性任务,例如每15分钟或每小时执行一次数据查询和保存。// 在Application或主Activity中初始化一次性的周期性任务 val periodicWorkRequest = PeriodicWorkRequestBuilder<UsageStatsWorker>( repeatInterval = 15, // 间隔周期 TimeUnit.MINUTES ).setConstraints( Constraints.Builder() .setRequiredNetworkType(NetworkType.NOT_REQUIRED) .setRequiresBatteryNotLow(false) // 根据需求设置 .build() ).build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( "usage_stats_collection", ExistingPeriodicWorkPolicy.KEEP, // 如果已有任务,则保留旧的 periodicWorkRequest )UsageStatsWorker是一个继承自Worker的类,在doWork()方法中执行查询和保存数据的逻辑。AlarmManager+BroadcastReceiver:这是一种更传统、更“准时”但也更耗电的方式。可以设置一个精确的重复闹钟,在指定时间唤醒设备执行任务。在Android 6.0之后,为了省电,AlarmManager的定时可能不精确。除非对实时性要求极高,否则优先选择WorkManager。
数据存储:查询到的数据需要持久化。根据数据量大小,可以选择Room数据库(适合存储大量历史记录)、SharedPreferences(适合存储简单的聚合数据,如今日总计)或文件存储。建议设计一个数据表,包含字段:id,packageName,date(日期,如20231015),duration(时长),launchCount(次数)。
6. 常见问题、兼容性处理与避坑指南
在实际开发中,你会遇到各种各样的问题。下面是我踩过坑后总结出来的经验。
6.1 查询结果为空或数据不准确
- 问题:调用
queryUsageStats后返回的列表是空的,或者totalTimeInForeground为0。 - 排查步骤:
- 首先确认权限:再次用
checkUsageStatsPermission检查,确保用户真的已经授权。这是最常见的原因。 - 检查时间区间:确保
endTime大于startTime。查询一个未来的时间区间会返回空。 - 检查
intervalType:如果你查询过去7天的数据,却使用了INTERVAL_DAILY,可能无法获取完整数据。始终优先使用INTERVAL_BEST。 - 系统限制:在Android 11+上,你的应用可能无法看到某些应用的记录。这是正常行为,你的UI需要能优雅地处理“未知应用”或数据缺失的情况。
- 设备休眠与统计延迟:系统对使用情况的统计可能存在数分钟到数小时的延迟,特别是在设备长时间休眠后。对于“今日”数据的实时性不要期望达到秒级。
- 首先确认权限:再次用
6.2 不同Android版本的兼容性问题
| Android 版本 | 关键变化与注意事项 |
|---|---|
| 5.0 (API 21) | 引入了UsageStatsManager。基础功能可用。 |
| 7.0 (API 24) | 无重大API变化,但后台优化更严格。 |
| 8.0 (API 26) | 后台执行限制。如需长时间后台统计,必须使用前台服务。 |
| 9.0 (API 28) | 对queryEvents获取的数据增加了更多限制。 |
| 10.0 (API 29) | 引入了android:foregroundServiceType属性,启动前台服务时必须指定类型。 |
| 11.0 (API 30) | 重大变更:PACKAGE_USAGE_STATS权限变为“部分限制”。应用只能看到“可见应用”的使用情况。需要在AndroidManifest.xml中声明<queries>标签来指定你想查询的其他应用(如果知道包名),但这对于统计所有应用来说不现实。通常只能接受这个限制。 |
| 12.0+ (API 31+) | 前台服务启动限制更严格。精确的闹钟 (AlarmManager.setExact) 需要新的权限SCHEDULE_EXACT_ALARM。强烈建议迁移到WorkManager。 |
兼容性编码建议:对于Android 11+的可见性限制,我们在获取应用名时要做降级处理:
private fun resolveAppName(context: Context, packageName: String): String { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // Android 11+,尝试获取,可能失败 try { val pm = context.packageManager val appInfo = pm.getApplicationInfo(packageName, 0) pm.getApplicationLabel(appInfo).toString() } catch (e: Exception) { // 获取失败,可能是不可见应用,返回包名或一个默认名称 packageName } } else { // 旧版本正常获取 // ... 正常逻辑 } }6.3 电量与性能优化
频繁查询UsageStatsManager(比如每秒一次)是极其耗电且不必要的。遵循以下原则:
- 降低频率:对于数据展示类功能,每分钟甚至每5分钟查询一次都足够了。对于后台统计,使用
WorkManager设置合理的间隔(如15分钟)。 - 批量处理:一次性查询一天的数据,而不是分多次查询。
- 避免在主线程操作:所有查询和数据处理都必须在子线程进行。
- 及时注销监听器:如果你使用了
UsageStatsManager的事件回调(某些定制ROM可能有),在组件销毁时务必注销。
6.4 数据隐私与合规性
应用使用数据是高度敏感的个人信息。在你的应用中:
- 明确告知用户:在申请权限前和隐私政策中,清晰说明你收集哪些数据、为何收集、如何存储以及如何使用。
- 本地处理优先:尽量在用户设备本地完成数据计算和存储,避免不必要的网络上传。
- 提供数据清除选项:允许用户清除你收集的所有使用统计数据。
- 遵守相关法律法规:如GDPR、CCPA等,确保你的数据处理流程合规。
最后,测试环节至关重要。你需要在不同品牌、不同系统版本的手机上进行测试,特别是权限授予流程和后台数据收集的稳定性。华为、小米、OPPO、vivo等国内厂商的定制系统可能会对后台服务和自启动有额外的限制,需要在对应的“电池优化”、“自启动管理”设置中引导用户将你的应用加入白名单,否则定时任务可能无法正常运行。这部分引导逻辑虽然繁琐,但对于提升功能的可靠性至关重要。