设备管控经常需要知道"当前正在使用哪个 App"。如果直接把 AccessibilityEvent.packageName 保存为前台包名,输入法、通知栏、权限弹窗和桌面中转都可能造成误判。
这篇文章只完成一件事:把无障碍事件转换成稳定的前台 App StateFlow。不讲白名单、自动拉回、使用时长、策略、保活、手势或截屏。
适用于设备所有者或使用者明确知情并开启无障碍服务的受控终端。本文的"前台 App"指可持续消费的活动应用窗口;短暂覆盖窗口不替换最近稳定值。
1. 先区分事件来源与稳定前台 App
event.packageName 表示事件由哪个包产生,不保证它就是业务要记录的前台应用。
| 操作 | 事件包名可能是 | 稳定状态 |
|---|---|---|
| App A 切换到 App B | App B | 提交 App B |
| App A 打开软键盘 | 输入法 | 保留 App A |
| App A 下拉通知栏 | SystemUI | 保留 App A |
| App A 弹出权限窗口 | Permission Controller | 保留 App A |
| App A 经桌面进入 App B | Launcher,随后 App B | 短暂停留不提交 Launcher |
因此识别链路必须包含五步:事件快照、事件筛选、候选包名、短暂窗口分流、稳定状态提交。

2. 只采集能改变窗口归属的信号
前台识别只处理 TYPE_WINDOW_STATE_CHANGED 和 TYPE_WINDOWS_CHANGED。TYPE_WINDOW_CONTENT_CHANGED、点击和滚动事件可以继续供页面自动化使用,但不能推动前台包名切换。
xml
<accessibility-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowsChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:notificationTimeout="30" />
在回调中同步复制字段,不要把 AccessibilityEvent 对象带进异步协程。事件包名反应快,活动窗口和根节点用于佐证;三者都可能短暂为空或延迟,所以不能单独当绝对真值。
kotlin
data class ForegroundSnapshot(
val type: Int,
val eventPackage: String?,
val eventClass: String?,
val activePackage: String?,
val rootPackage: String?,
val observedAt: Long
)
private fun CharSequence?.packageOrNull(): String? =
this?.toString()?.trim()?.takeIf {
it.isNotEmpty() && it != "null"
}
private fun AccessibilityService.activeAppPackage(): String? =
windows.asSequence()
.filter {
it.isActive &&
it.type == AccessibilityWindowInfo.TYPE_APPLICATION
}
.mapNotNull { it.root?.packageName.packageOrNull() }
.firstOrNull()
fun AccessibilityService.snapshot(
event: AccessibilityEvent
) = ForegroundSnapshot(
type = event.eventType,
eventPackage = event.packageName.packageOrNull(),
eventClass = event.className?.toString(),
activePackage = activeAppPackage(),
rootPackage = rootInActiveWindow
?.packageName
.packageOrNull(),
observedAt = event.eventTime.takeIf { it > 0L }
?: SystemClock.uptimeMillis()
)
3. 短暂包先过滤,事件与窗口一致才立即确认
输入法包名从当前启用的输入法列表读取,Launcher 通过 HOME Intent 解析。普通三方 APK 没有一个公开 API 能稳定返回所有 ROM 的权限控制器包名,所以保留常见基础包,并允许 OEM 适配层追加。
管控端自身不能整包忽略:用户真正打开管控设置页时,它就是前台 App。只忽略明确登记的透明中转 Activity。
kotlin
data class ForegroundRules(
val ownPackage: String,
val launcherPackage: String?,
val transientPackages: Set<String>,
val ownTransientActivities: Set<String>
) {
fun isTransient(pkg: String, cls: String?): Boolean =
pkg in transientPackages ||
(pkg == ownPackage && cls in ownTransientActivities)
}
fun buildForegroundRules(
context: Context,
ownTransientActivities: Set<String>,
oemTransientPackages: Set<String> = emptySet()
): ForegroundRules {
val imePackages = context
.getSystemService(InputMethodManager::class.java)
.enabledInputMethodList
.map { it.packageName }
.toSet()
val home = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
}
val launcher = context.packageManager.resolveActivity(
home,
PackageManager.MATCH_DEFAULT_ONLY
)?.activityInfo?.packageName
return ForegroundRules(
ownPackage = context.packageName,
launcherPackage = launcher,
transientPackages = setOf(
"android",
"com.android.systemui",
"com.android.permissioncontroller",
"com.google.android.permissioncontroller",
"com.android.packageinstaller"
) + imePackages + oemTransientPackages,
ownTransientActivities = ownTransientActivities
)
}
data class ForegroundCandidate(
val packageName: String,
val confirmed: Boolean,
val observedAt: Long
)
class CandidateRouter(
private val rules: ForegroundRules
) {
fun route(s: ForegroundSnapshot): ForegroundCandidate? {
if (s.type != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED &&
s.type != AccessibilityEvent.TYPE_WINDOWS_CHANGED) {
return null
}
fun usable(pkg: String?): String? = pkg?.takeUnless {
rules.isTransient(it, s.eventClass)
}
val eventPackage = usable(s.eventPackage)
val windowPackage = usable(s.activePackage)
?: usable(s.rootPackage)
// 冲突时优先采用活动窗口作为弱候选,避免晚到事件回滚状态。
val selected = when {
eventPackage != null && eventPackage == windowPackage ->
eventPackage
windowPackage != null -> windowPackage
else -> eventPackage
} ?: return null
val confirmed = eventPackage != null &&
eventPackage == windowPackage &&
selected != rules.launcherPackage
return ForegroundCandidate(
packageName = selected,
confirmed = confirmed,
observedAt = s.observedAt
)
}
}
结果只有三种:SystemUI、输入法和权限窗口没有可用候选时返回 null;事件包名与活动窗口一致时立即确认;只有单一来源、来源冲突或候选是 Launcher 时进入短暂确认。
4. 候选经过短暂确认,再发布 StateFlow
已确认候选立即提交;弱候选等待 180ms。等待期间出现新候选,旧候选自动取消。先按"包名 + 是否确认"去重,避免重复弱事件不断重置等待时间;最终再按包名去重。
180ms 只是起始值,不是系统常量。量产时应根据目标机型的事件日志在 150~250ms 内调整。
kotlin
data class ForegroundAppState(
val packageName: String?,
val changedAt: Long
) {
companion object {
val Unknown = ForegroundAppState(null, 0L)
}
}
class StableForegroundSource(
scope: CoroutineScope,
private val settleMillis: Long = 180L
) {
private val candidates = MutableSharedFlow<ForegroundCandidate>(
replay = 1,
extraBufferCapacity = 32,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val state: StateFlow<ForegroundAppState> = candidates
.distinctUntilChangedBy {
it.packageName to it.confirmed
}
.transformLatest { candidate ->
if (!candidate.confirmed) delay(settleMillis)
emit(
ForegroundAppState(
candidate.packageName,
candidate.observedAt
)
)
}
.distinctUntilChangedBy { it.packageName }
.stateIn(
scope,
SharingStarted.Eagerly,
ForegroundAppState.Unknown
)
fun accept(candidate: ForegroundCandidate) {
candidates.tryEmit(candidate)
}
}
短暂包不会进入候选流,所以键盘、通知栏和权限窗口出现时,state 保留最近稳定 App。快速经过 Launcher 后进入 App B 时,App B 会取消 Launcher 候选;用户确实停留桌面超过确认时间,Launcher 才被提交。
5. 服务只生产状态,业务只订阅状态
AccessibilityService 负责快照、路由和播种当前窗口。白名单判断、自动跳转或时长统计属于下游,不应写回识别器。
kotlin
class ForegroundMonitorService : AccessibilityService() {
private val job = SupervisorJob()
private val scope = CoroutineScope(job + Dispatchers.Default)
private val source = StableForegroundSource(scope)
private val rules by lazy {
buildForegroundRules(
this,
ownTransientActivities = setOf(
"com.example.control.PermissionBridgeActivity"
)
)
}
private val router by lazy { CandidateRouter(rules) }
override fun onAccessibilityEvent(event: AccessibilityEvent) {
router.route(snapshot(event))?.let(source::accept)
}
override fun onServiceConnected() {
super.onServiceConnected()
val pkg = activeAppPackage()
?: rootInActiveWindow?.packageName.packageOrNull()
if (pkg != null && !rules.isTransient(pkg, null)) {
source.accept(
ForegroundCandidate(
packageName = pkg,
confirmed = false,
observedAt = SystemClock.uptimeMillis()
)
)
}
}
override fun onInterrupt() = Unit
override fun onDestroy() {
job.cancel()
super.onDestroy()
}
}
真机验收只看最终 StateFlow,不要以"收到了事件"作为成功标准:
| 操作 | 期望状态 |
|---|---|
| App A 切换到 App B | 只提交一次 App B |
| App A 打开、关闭键盘 | 始终保持 App A |
| App A 下拉、收回通知栏 | 始终保持 App A |
| App A 弹出、关闭权限窗口 | 始终保持 App A |
| App A 经桌面快速进入 App B | 不发布闪现的 Launcher |
| 返回桌面并停留 | 确认时间后提交 Launcher |
到这里,业务拿到的是经过事件筛选、窗口校验、短暂包过滤、确认和去重后的前台 App,而不是"最近一条无障碍事件的包名"。
参考资料
- Android Developers:AccessibilityEvent
- Android Developers:AccessibilityService
- Android Developers:AccessibilityWindowInfo
- Kotlin Coroutines:StateFlow
本文根据真实设备管控项目中的事件筛选、SystemUI 回退、输入法过滤和前台包名发布链路重新抽象并脱敏。候选确认与 StateFlow 是面向本文目标重新设计的清洁实现。