Android 设备管控开发实战:无障碍如何准确识别当前前台 App?

设备管控经常需要知道"当前正在使用哪个 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_CHANGEDTYPE_WINDOWS_CHANGEDTYPE_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,而不是"最近一条无障碍事件的包名"。

参考资料

  1. Android Developers:AccessibilityEvent
  2. Android Developers:AccessibilityService
  3. Android Developers:AccessibilityWindowInfo
  4. Kotlin Coroutines:StateFlow

本文根据真实设备管控项目中的事件筛选、SystemUI 回退、输入法过滤和前台包名发布链路重新抽象并脱敏。候选确认与 StateFlow 是面向本文目标重新设计的清洁实现。

相关推荐
chjif1 小时前
Android 设备管控开发实战:企业受控终端的 DNS 域名策略实现
android·华为·harmonyos
c238563 小时前
MySQL 基础用法(下):查询进阶与核心特性
android·c语言·c++·mysql
math_hongfan3 小时前
鸿蒙Agent高级技能自定义开发:自定义意图服务/能力注册/Agent技能编排/服务插件开发实战
android·学习·华为·harmonyos·鸿蒙
2501_915106324 小时前
iOS 证书类型及作用说明 账号证书与签名配置的完整解读
android·ios·小程序·https·uni-app·iphone·webview
九皇叔叔5 小时前
RHEL9 安装 MySQL 8.4.11.LTS 版本
android·mysql·adb
海兰5 小时前
【数据库】tdsql(MySQL )的事务隔离级别
android·数据库·mysql
哆啦A梦z5 小时前
Android BLE 实战:从小米手环 7 踩坑到完整连接框架
android
IKUN家族5 小时前
KMP全栈开发:从Android到AI Agent的技术演进与实践
android·人工智能
笑一下蒜了.6 小时前
Linux 存储多路径访问手册|multipath.conf 各配置段详解
android·linux·运维