限制apk使用时长第一篇-统计apk使用时长
文章目录
前言
- 看到手机端有应用限时使用;
- 之前有客户提到过教育软件限制使用时长的客需,后面负责这个客需的同事没有搞定,没有去实现,这个客需点废弃掉了
那么自己就私下里去实现这样的一个功能。
一、技术点实现
自己通过分析,这里其实就是两方面的技术点,如下。 这也是为什么分两篇文章来实现的原因。
- 设置应用使用时长;实时监听、统计应用时长
- 时限到大后,针对客户使用apk 进行拦截、提醒、限制使用。
二、实际手机端效果
应用设置使用时长
应用使用实时显示
应用可用时长达到上限
延长应用可用时长
三、实现思路
上面提供的手机端效果目的就是我们在客制化产品的时候,参考这样的效果。 在手机端只要应用设置的时限到了,那么不管是哪一个方法进入 设置使用实现的应用,都会弹出可用时限已达,限制了应用的使用。
分析
我最开始的想法是 这个需求应该分为三部走:
- 设置里面显示当前设置了哪些 时限限制的应用-并在设置里面进行设置,保存时限应用关联的限时数据
- 拦截的界面,显示当前 限时应用时限已达,应该是一个apk 来实现的。实际发现它确实就是一个apk 实现的,弹出来显示。
- 对于如何拦截进入时限已达的应用,拦截逻辑肯定在Framework层实现。 那么就要分析Framework 层相关逻辑。无论打开时限应用方式怎样:adb/最近历史任务/杀死进程后重新打开/其它应用跳转等
验证手机端实现方式-oppo手机为例
对于实现方式,参考手机端,虽然已经分析了实现思路,但是实际还是要看看手机端如何实现的,反向看看自己思路是否正确。
限时应用顶层入口分析
查看上面这个界面的Activity 是什么? 如下:
com.android.settings/.Settings
这是我们再熟悉不过的原生设置界面
限时应用关联二级界面入口及相关分析
查看上面三个界面的Activity 是什么? 如下:
com.coloros.digitalwellbeing/.ui.activity.PhoneUseTimeActivity
com.coloros.digitalwellbeing/.ui.activity.ScreenLimitActivity
com.coloros.digitalwellbeing/.ui.activity.AppLimitSettingActivity
小结
这里从UI 对应的Activity分析如下:
- 设置就是一个入口,提供了管理模块的入口。
- 数字健康与家庭守护 这个模块实现的功能就是跳转App,限时应用的设置、界面显示设置的内容、当前应用使用时长和剩余时长其实就是一个应用内实现的。 个人理解,如果这些模块功能全部加在设置应用里面,确实太耦合臃肿了。
这样其实得出结论:在系统设置里面提供一个入口,跳转应用时限限时设置、限时应用展示。
备注: 对于开发人员而言,如何设置和修改限时 不麻烦,重点看看如何统计应用使用时长吧。
四、参考资料
Android获取应用使用时长和次数-UsageStatsManager使用
Android应用时长统计 UsageStatsManager & UsageStatsService 源码全解析
Android开发 UsageStatsManager应用使用统计管理
【技术支持】安卓开发中queryUsageStats不准确的问题
分析:其实这个系统里面有对应的API和服务,搞清楚:UsageStatsManager的使用方法和关联知识点即可实现。
五、实现方案
源码参考
我自己在一个服务里面实现,下面直接给出源码参考:
package com.fs.interfacetest.service
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.app.usage.UsageStats
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
import com.fish.togetsn.R
import java.util.Calendar
import java.util.concurrent.TimeUnit
class AppDailyUsageService : Service() {
private lateinit var usageStatsManager: UsageStatsManager
private val handler = Handler(Looper.getMainLooper())
private lateinit var monitorRunnable: Runnable
// 配置参数
private val monitorInterval = 1L // 分钟
private val targetPackageNames = listOf(
"tv.danmaku.bilibilihd",
"cn.kuwo.player",
"com.android.chrome"
)
var startTime=0L
private lateinit var sharedPrefs: SharedPreferences
private val calendar = Calendar.getInstance()
companion object {
private const val TAG = "AppDailyUsageService"
private const val NOTIFICATION_ID = 123
private const val CHANNEL_ID = "daily_usage_channel"
fun startService(context: Context) {
val intent = Intent(context, AppDailyUsageService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Service onCreate")
startTime = getStartOfDayTime()
usageStatsManager = getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
sharedPrefs = getSharedPreferences("DailyAppUsage", Context.MODE_PRIVATE)
startForegroundService()
startDailyMonitoring()
checkNewDay()
}
@SuppressLint("ForegroundServiceType")
private fun startForegroundService() {
createNotificationChannel()
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("应用使用统计")
.setContentText("正在记录当日应用使用时长")
.setSmallIcon(R.mipmap.icon_camera_police_switchon)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
startForeground(NOTIFICATION_ID, notification)
}
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)
}
}
private fun startDailyMonitoring() {
monitorRunnable = object : Runnable {
override fun run() {
checkAppUsage()
checkNewDay() // 每天检查是否是新的一天
handler.postDelayed(this, TimeUnit.MINUTES.toMillis(monitorInterval))
}
}
handler.post(monitorRunnable)
}
private fun checkAppUsage() {
val currentTime = System.currentTimeMillis()
Log.d(TAG,"checkAppUsage currentTime:${currentTime} startTime:${startTime} ")
targetPackageNames.forEach { packageName ->
val usageTime = getAppUsageTime(packageName, startTime, currentTime)
Log.d(TAG, "今日 $packageName 使用时长: ${formatTime(usageTime)}")
// 保存当日数据
saveDailyUsage(packageName, usageTime)
}
}
private fun getAppUsageTime(packageName: String, startTime: Long, endTime: Long): Long {
// 第一种方案 时间不准问题
val usageStats = usageStatsManager.queryUsageStats(
// UsageStatsManager.INTERVAL_DAILY,
UsageStatsManager.INTERVAL_BEST, // 最精确的间隔
startTime,
endTime
)
var totalTime = 0L
usageStats?.forEach { stats ->
if (stats.packageName == packageName) {
totalTime += stats.totalTimeInForeground
}
}
// 第二种方案
//第二种方式
/* val statsList2: Map<String, UsageStats> = usageStatsManager.queryAndAggregateUsageStats(
startTime,
endTime
)
var totalTime = 0L
for ((packageNameT, stats) in statsList2) {
if (packageNameT == packageName) {
// 打印日志
val totalTimeInForeground: Long = stats.getTotalTimeInForeground() // 前台总时长(毫秒)
// 转换为可读时间格式
// val formattedTime: String = formatMillisToTime(totalTimeInForeground)
// Log.d("UsageStats", "第二种: $formattedTime")
totalTime=totalTimeInForeground
}
}*/
return totalTime
}
fun formatMillisToTime(millis: Long): String {
val totalSeconds = millis / 1000
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return String.format("%02d:%02d:%02d", hours, minutes, seconds)
}
private fun saveDailyUsage(packageName: String, usageTime: Long) {
val todayKey = "${packageName}_${getTodayDateString()}"
sharedPrefs.edit().putLong(todayKey, usageTime).apply()
}
private fun checkNewDay() {
val today = getTodayDateString()
val lastRecordedDay = sharedPrefs.getString("last_recorded_day", "")
if (today != lastRecordedDay) {
// 新的一天,可以在这里执行重置操作
sharedPrefs.edit().putString("last_recorded_day", today).apply()
Log.d(TAG, "新的一天开始: $today")
}
}
private fun getStartOfDayTime(): Long {
calendar.timeInMillis = System.currentTimeMillis()
calendar.set(Calendar.HOUR_OF_DAY, 0)
calendar.set(Calendar.MINUTE, 0)
calendar.set(Calendar.SECOND, 0)
calendar.set(Calendar.MILLISECOND, 0)
return calendar.timeInMillis
}
private fun getTodayDateString(): String {
calendar.timeInMillis = System.currentTimeMillis()
return "${calendar.get(Calendar.YEAR)}-${calendar.get(Calendar.MONTH) + 1}-${
calendar.get(
Calendar.DAY_OF_MONTH
)
}"
}
private fun formatTime(millis: Long): String {
val hours = TimeUnit.MILLISECONDS.toHours(millis)
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60
return "${hours}小时${minutes}分钟"
}
override fun onDestroy() {
super.onDestroy()
handler.removeCallbacks(monitorRunnable)
Log.d(TAG, "Service onDestroy")
}
}
实现效果
备注: 自己实际验证阶段,我个人想法本身是在后台每隔一分钟统计一次使用时长,实时统计的效果。发现 在后台 获取的数据每次都是一样的,按照网上说法 可能延时,但是在MTK上面验证发现不是延时问题,时隔20分钟数据依然是第一次获取的数据。但是每次应用重新打开获取的时间又是对的。 想一想 对实现需求效果貌似不影响! 进去的时候获取一次 不就已经满足需求了嘛?
避坑指南
我用的是 MTK 板卡验证,自己写了一个Service 如上粘贴代码,发现定时 Runbale 无论如何 每隔一分钟的时间都是固定的值,但是我实际测试用的是 谷歌App和酷狗App,没有使用的情况下。 我也为自己程序代码问题或者MTK 平台限制,并用IntentService 验证 后来发现:
- 对于浏览器这样的,你不使用它,静态放在那里,统计时间会延时很久。 使用起来 使用时长每次获取是实时的,比如一直操作使用当前App。
- 对于动态页面不断刷新的应用,统计时长比较及时,特别涉及到交互的应用【不断刷抖音、操作界面】 时间统计比较及时的。
六、拓展-UsageStatsManager 使用指南
UsageStatsManager 是 Android 系统提供的一个 API,用于访问设备上应用程序的使用统计信息。它可以帮助开发者了解用户如何使用他们的应用以及其他应用的使用情况。
基本介绍
UsageStatsManager 提供以下主要功能:
-
查询应用程序的使用统计数据
-
获取应用使用事件的详细日志
-
监控设备使用模式
获取 UsageStatsManager 实例
UsageStatsManager usageStatsManager = (UsageStatsManager)
context.getSystemService(Context.USAGE_STATS_SERVICE);
主要方法
查询使用统计
// 获取指定时间范围内的使用统计
Calendar calendar = Calendar.getInstance();
long endTime = calendar.getTimeInMillis();
calendar.add(Calendar.DAY_OF_YEAR, -1);
long startTime = calendar.getTimeInMillis();
List<UsageStats> stats = usageStatsManager.queryUsageStats(
UsageStatsManager.INTERVAL_DAILY, startTime, endTime);
时间间隔常量:
-
INTERVAL_DAILY - 每日统计
-
INTERVAL_WEEKLY - 每周统计
-
INTERVAL_MONTHLY - 每月统计
-
INTERVAL_YEARLY - 每年统计
-
INTERVAL_BEST - 根据时间范围自动选择最佳间隔
查询事件
// 获取指定时间范围内的事件
UsageEvents events = usageStatsManager.queryEvents(startTime, endTime);
while (events.hasNextEvent()) {
UsageEvents.Event event = new UsageEvents.Event();
events.getNextEvent(event);
// 处理事件
String packageName = event.getPackageName();
int eventType = event.getEventType();
long timeStamp = event.getTimeStamp();
}
事件类型:
-
MOVE_TO_FOREGROUND (1) - 应用进入前台
-
MOVE_TO_BACKGROUND (2) - 应用进入后台
-
USER_INTERACTION (7) - 用户与应用交互
-
其他系统事件类型
获取应用使用时间
// 获取指定时间范围内应用的总使用时间
long totalTime = usageStatsManager.getTotalTimeInForeground(packageName, startTime, endTime);
使用示例
示例1:获取当天最常用的应用
public String getMostUsedAppToday(Context context) {
UsageStatsManager usageStatsManager = (UsageStatsManager)
context.getSystemService(Context.USAGE_STATS_SERVICE);
Calendar calendar = Calendar.getInstance();
long endTime = calendar.getTimeInMillis();
calendar.add(Calendar.DAY_OF_YEAR, -1);
long startTime = calendar.getTimeInMillis();
List<UsageStats> stats = usageStatsManager.queryUsageStats(
UsageStatsManager.INTERVAL_DAILY, startTime, endTime);
if (stats != null) {
UsageStats maxStats = null;
for (UsageStats usageStats : stats) {
if (maxStats == null ||
usageStats.getTotalTimeInForeground() > maxStats.getTotalTimeInForeground()) {
maxStats = usageStats;
}
}
return maxStats != null ? maxStats.getPackageName() : null;
}
return null;
}
示例2:监控应用使用事件
public void logAppUsageEvents(Context context) {
UsageStatsManager usageStatsManager = (UsageStatsManager)
context.getSystemService(Context.USAGE_STATS_SERVICE);
long startTime = System.currentTimeMillis() - 1000 * 60 * 60; // 过去一小时
long endTime = System.currentTimeMillis();
UsageEvents events = usageStatsManager.queryEvents(startTime, endTime);
while (events.hasNextEvent()) {
UsageEvents.Event event = new UsageEvents.Event();
events.getNextEvent(event);
String eventTypeStr = "";
switch (event.getEventType()) {
case UsageEvents.Event.MOVE_TO_FOREGROUND:
eventTypeStr = "进入前台";
break;
case UsageEvents.Event.MOVE_TO_BACKGROUND:
eventTypeStr = "进入后台";
break;
case UsageEvents.Event.USER_INTERACTION:
eventTypeStr = "用户交互";
break;
default:
eventTypeStr = "其他事件";
}
Log.d("AppUsage", "应用: " + event.getPackageName() +
", 事件: " + eventTypeStr +
", 时间: " + new Date(event.getTimeStamp()));
}
}
注意事项
-
需要用户明确授权才能使用此API
-
返回的数据可能有几分钟的延迟
-
不同Android版本可能有不同的行为
-
某些设备制造商可能修改了实现
-
大量查询可能影响性能
最佳实践
-
仅在需要时查询使用统计,避免频繁查询
-
缓存查询结果以减少系统调用
-
向用户清楚地解释为什么需要此权限
-
处理用户拒绝授予权限的情况
-
通过合理使用 UsageStatsManager,开发者可以更好地了解用户行为,优化应用体验。
总结
- 分析了需求,对 统计apk使用时长 部分做了一定的分析和实验
- 了解了 UsageStatsManager 使用,也要注意最佳实践里面有一定的针对性的说明
- 深入了解 UsageStatsManager 还需要实验中不断总结
- UsageStatsManager 还是可以做很多事情的,统计时长、应用使用次数、检测应用前台还是后台【避免使用,频率问题】