资讯动态

Android智能UI锁屏:无障碍采集、悬浮窗覆盖与规则判定实战

发布时间:2026/10/30 3:33:08 来源:尧图企业网站定制
简介这份压缩包面向 Android 中高级开发者与 UI 学习者围绕智能锁屏这一高频系统组件提供可运行的锁屏界面实现参考。内容从界面设计、交互逻辑、安全性、自定义选项、性能优化到 API 与框架集成等维度展开涉及滑动、图案、指纹等解锁方式以及动态壁纸、音乐控制、通知预览与敏感信息隐藏等场景。包内共 57 个文件以 java 源码、class 字节码、xml 布局与资源、png 图片素材为主另有 jar 依赖包与编译产物 apk压缩包约 722KB导入工程即可对照阅读。已有 249 人学习下载可用于梳理 WallpaperService、NotificationManager 等系统服务的集成方式理解第三方动画与手势库的引入位置并观察从源码到打包的完整目录结构作为课程设计或锁屏功能选型的参考。1. 智能UI锁屏的真相难点不在“盖住屏幕”在“读懂这一屏值不值得盖”去年我帮一个团队收尾一个专注类应用第一版做得很直接检测到目标应用在前台就在上面盖一层全屏半透明黑配一句“先歇会儿”。上线一周卸载率翻了一倍。用户的反馈很一致——“我正看着看着突然黑了退也退不出去。”这让我彻底改了对 Android 智能UI锁屏的理解真正难的从来不是WindowManager.addView真正难的是判断这一屏该不该盖、盖多少、留什么出口。智能UI锁屏指的是应用借助无障碍服务、悬浮窗这些能力实时解析当前界面的包名、控件结构和内容特征再按策略决定是否叠加一层可交互的遮罩界面。它跟系统锁屏Keyguard没有替代关系更多是用在专注模式、儿童模式、展厅终端、无障碍辅助、老设备改造这类场景。适合两类读者一是手上已经有类似需求、正在做技术选型的 Android 开发二是做了个 Demo 发现真机上到处翻车、想搞清楚边界在哪的人。下面按“怎么读屏 → 怎么盖上去 → 怎么判定 → 怎么排错”这条线走每一步都给出能跑的最小代码。2. 用无障碍服务采集界面节点从 rootInActiveWindow 到一份能判定、够轻、不越界的快照采集是所有智能判断的输入。这一章只解决一件事怎么把“当前屏幕上有什么”变成一份结构化、脱敏、开销可控的数据而且不碰用户隐私红线。2.1 AccessibilityService 与 UsageStatsManager 的选型对比很多人的第一反应是用UsageStatsManager查前台应用简单、不用无障碍权限。但它只能给包名拿不到界面内容而且采样有延迟——轮询间隔设短了耗电设长了错过瞬间场景。真正要做“智能”判定无障碍服务是绕不开的。维度UsageStatsManagerAccessibilityService能拿到的信息前台包名、时间戳窗口树、控件 id、类名、文本、是否密码框触发方式主动轮询事件驱动权限门槛需用户在设置里授予使用情况访问需在无障碍设置里手动开启实时性秒级延迟毫秒级主要风险耗电、数据滞后事件风暴、隐私敏感、被系统回收我的做法是两个都用UsageStatsManager作为保底处理无障碍被关时至少知道用户开了什么AccessibilityService作为主输入。别指望单一数据源这是实机上最容易翻车的地方。2.2 一份能跑起来的最小无障碍配置先写配置注意三个参数直接决定了你的采集质量!-- res/xml/ui_reader_config.xml -- accessibility-service xmlns:androidhttp://schemas.android.com/apk/res/android android:accessibilityEventTypestypeWindowStateChanged|typeWindowContentChanged|typeWindowsChanged android:accessibilityFeedbackTypefeedbackGeneric android:accessibilityFlagsflagReportViewIds|flagRetrieveInteractiveWindows android:canRetrieveWindowContenttrue android:notificationTimeout100 android:descriptionstring/accessibility_desc /flagReportViewIds必须开viewIdResourceName是后面判定规则里最有价值的特征比文本稳定得多。flagRetrieveInteractiveWindows让你能拿到多窗口场景分屏、画中画。flagIncludeNotImportantViews我默认不开——它会显著放大节点数量在信息流类页面上节点数能翻三五倍采样耗时和 GC 压力都上来了除非判定确实需要否则别碰。配置里notificationTimeout100是事件合并窗口设太小会收到大量重复事件设太大会漏掉快速切换。100ms 是个能用的起点。2.3 事件订阅与快照生成服务本身的逻辑要极简事件来了就采样采完就丢绝不持有AccessibilityNodeInfo引用。class UiReaderService : AccessibilityService() { private val sampler NodeSampler(maxNodes 400, maxDepth 12) private var lastContentTs 0L override fun onAccessibilityEvent(event: AccessibilityEvent?) { event ?: return when (event.eventType) { AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, AccessibilityEvent.TYPE_WINDOWS_CHANGED - sampleNow() AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED - { // 内容变化事件极其密集250ms 节流是必须的 val now SystemClock.elapsedRealtime() if (now - lastContentTs 250) return lastContentTs now sampleNow() } else - Unit } } private fun sampleNow() { val root rootInActiveWindow ?: return val pkg root.packageName?.toString() ?: return val snapshot sampler.sample(root, pkg) UiBus.publish(snapshot) // 只往下游发纯数据不持有节点引用 } override fun onInterrupt() Unit }这里有个血泪经验AccessibilityNodeInfo是系统池化对象你在异步线程里拿着它用很容易拿到已经被回收或内容错乱的对象。所以sample()必须同步跑完、立刻转成自己的 data class后面所有逻辑都基于这份不可变快照。这一步做对了后面 90% 的“偶发判定错误”都不会发生。2.4 节点树裁剪只留判定需要的字段采样器是整个方案里最需要克制的部分。全量遍历、全量存文本在长列表页面上能让你的服务直接卡死。data class NodeSnapshot( val pkg: String, val className: String?, val viewId: String?, // 如 com.xxx:id/feed_list val textLen: Int, // 只留长度不留原文 val hasPassword: Boolean, val depth: Int, val bounds: Rect ) class NodeSampler(private val maxNodes: Int, private val maxDepth: Int) { fun sample(root: AccessibilityNodeInfo, pkg: String): ListNodeSnapshot { val out ArrayListNodeSnapshot(64) val queue ArrayDequePairAccessibilityNodeInfo, Int() queue.add(root to 0) val deadline SystemClock.elapsedRealtime() 60 // 单次采样硬超时 while (queue.isNotEmpty() out.size maxNodes) { if (SystemClock.elapsedRealtime() deadline) break // 超时直接截断 val (node, depth) queue.removeFirst() if (depth maxDepth) continue val pwd node.isPassword val len if (pwd) 0 else node.text?.length ?: 0 val rect Rect().also { node.getBoundsInScreen(it) } out.add( NodeSnapshot( pkg node.packageName?.toString() ?: pkg, className node.className?.toString(), viewId node.viewIdResourceName, textLen len, hasPassword pwd, depth depth, bounds rect ) ) for (i in 0 until node.childCount) { node.getChild(i)?.let { queue.add(it to depth 1) } } } return out } }四个参数值得说清楚。maxNodes 400是经验值超过这个数量判定收益急剧下降而耗时线性上升如果目标场景是长信息流可以放到来 600但一定配合超时。maxDepth 12是因为绝大多数业务控件的层级不会超过这个深度再往下基本是文本容器。deadline 60ms是硬约束宁可截断也不要卡主线程——无障碍服务的回调跑在主线程卡住会直接触发系统的服务超时回收。至于textLen只留长度不留原文这是隐私设计的底线判定只需要知道“这里有一段中等长度的文本”不需要知道它写了什么。注意isPassword为 true 的节点一律不取内容连长度也不取。用户在登录、支付、填表单时被你的服务读过内容这在任何合规审查里都是硬伤。2.5 采集不到内容的四类界面这是新手最容易忽略的现实无障碍树在相当多界面上是空的。界面类型表现应对WebView 页面只给一个空节点或极浅的树退化为包名 页面标题判定Flutter / 自绘引擎节点结构扁平viewId 全空依赖 className 与 bounds 面积分布游戏 SurfaceView几乎无子节点按包名走白名单策略系统权限弹窗独立窗口包名是系统直接判定为“不锁”绝不打扰遇到这些界面正确的做法是降级而不是硬猜。我一般的处理是当采样结果的节点数少于 5 个就把score直接置 0交给包名白名单做最后决定。强行从空树里推断内容得到的结论只会比随机更差。3. 悬浮窗的层级与输入锁屏层怎么盖、盖在哪、什么时候必须放行采集解决“看”这一章解决“盖”。智能UI锁屏的遮罩层要同时满足三个条件能盖住目标应用、不抢走用户的操作能力、能被干净地撤掉。三者互相牵制是最容易做出“用户想摔手机”效果的地方。3.1 三种窗口类型的取舍窗口类型选错后面所有努力都白费。类型需要权限层级位置适用TYPE_APPLICATION_OVERLAYSYSTEM_ALERT_WINDOW用户手动授予应用之上、系统关键界面之下通用遮罩无需无障碍TYPE_ACCESSIBILITY_OVERLAY无障碍服务已开启即可类似上者由无障碍服务上下文添加智能UI锁屏首选TYPE_KEYGUARD_DIALOG系统签名锁屏之上普通应用不可用做智能UI锁屏我优先用TYPE_ACCESSIBILITY_OVERLAY既然已经要开无障碍就不要再让用户多授一个悬浮窗权限减少一次流失。代价是这个窗口必须由AccessibilityService的 context 添加活动生命周期跟服务绑定。如果产品形态允许“只锁特定应用、不需要读界面内容”那就退回TYPE_APPLICATION_OVERLAY权限模型更简单。3.2 一个能全屏覆盖又不抢焦点的 LayoutParamsprivate fun buildLockParams(): WindowManager.LayoutParams { val type if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY } else { WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } return WindowManager.LayoutParams( WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT, type, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or // 不抢输入焦点 WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or // 覆盖状态栏区域 WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or // 越过屏幕边界 WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, PixelFormat.TRANSLUCENT ).apply { gravity Gravity.TOP or Gravity.START x 0 y 0 // 不要加 FLAG_NOT_TOUCHABLE否则拦不住点击遮罩形同虚设 // 不要加 FLAG_SECURE用户截不了图会直接投诉 } }FLAG_NOT_FOCUSABLE是关键它让遮罩不接收按键焦点用户按返回键、音量键的行为仍然交给下层系统处理。少了这个标志你的遮罩会变成一个“黑洞”用户按什么都没反应——这是最典型的一星评价来源。FLAG_LAYOUT_NO_LIMITS让窗口能覆盖状态栏和导航栏区域但在部分系统版本上无障碍浮层对全屏覆盖和触摸拦截还有额外限制所以实机验证永远优先于文档推断。3.3 输入策略全拦截、局部放行、只做提示遮罩到底要不要吃触摸取决于产品意图没有统一答案。全拦截最简单不加FLAG_NOT_TOUCHABLE遮罩自己处理所有 touch用户点哪儿都只会在遮罩上产生事件。适合“必须离开当前应用”的强干预场景但一定要在遮罩上留一个明确的“退出”按钮否则就是软禁。局部放行复杂一些遮罩整体拦截但在某个矩形区域把触摸透传给下层。Android 没有直接的“挖洞”API常见做法是在遮罩上用dispatchTouchEvent判断坐标命中放行区域时手动调用finish或者干脆把这块区域从遮罩布局里挖掉用带透明区域的背景 PorterDuff.Mode.CLEAR绘制。我更推荐后者视觉上就是个洞逻辑上不参与触摸分发行为可预期。第三种是“只做提示”加FLAG_NOT_TOUCHABLE遮罩只显示提示条所有操作照常。这种最不容易挨骂但干预效果也最弱。我的经验是——第一次触达用提示型用户连续触发三次再升级到拦截型这个渐进策略能把卸载率压下来一大截。提示无论哪种策略都要保证有一条“立即解除”的路径并且这条路径在 1 秒内可达。用户被锁住时的容忍度以秒计。3.4 反复 addView / removeView 带来的闪烁与内存问题新手最常写的就是“判定要锁 → addView判定不锁 → removeView”事件一密集就变成疯狂增删屏幕上闪成一片。正确做法是把 View 预加载好只切换可见性private var lockView: View? null private fun showLock() { val wm getSystemService(WINDOW_SERVICE) as WindowManager val v lockView ?: LayoutInflater.from(this) .inflate(R.layout.view_lock_overlay, null) .also { lockView it } if (v.isAttachedToWindow) return // 已在屏幕上直接返回 v.visibility View.VISIBLE wm.addView(v, buildLockParams()) } private fun hideLock() { val v lockView ?: return if (!v.isAttachedToWindow) return (getSystemService(WINDOW_SERVICE) as WindowManager).removeView(v) }isAttachedToWindow这个判断能挡掉绝大部分重复 add。另外如果只是想改遮罩内容比如换个提示语、改透明度不要 remove 再 add直接改属性或者用WindowManager.updateViewLayout重建窗口的开销远大于改属性而且重建过程天然带一帧闪烁。内存方面浮层 View 一旦持有 Activity 引用就会泄漏。我一般把浮层做成纯代码构建、不依赖任何 Activity 的布局或者用ContextThemeWrapper包一层服务 context。这点小改动能让长时间运行的终端设备少掉很多莫名其妙的 OOM。4. 判定模块的参数账包名白名单、关键词权重与那个 60 分阈值前面两章解决了“能看”和“能盖”这一章是“该不该盖”。智能UI锁屏的“智能”二字八成落在这里。4.1 把判定变成一个分数我倾向把判定输出设计成 0–100 的分数而不是布尔值原因很实际布尔值没法调分数可以。分数还能直接用于灰度、用于解释“为什么锁了”、用于日志回放。输入特征就那么几类包名、viewId 与 className 的关键词、节点数量、页面里是否存在密码框、当前时间、当前应用已连续使用时长。输出是分数加一个reason字符串。reason别省线上排查全靠它。4.2 规则引擎第一版白名单 关键词 时间加权data class LockDecision(val score: Int, val reason: String) object RuleEngine { // 命中即直接锁不参与打分这类页面没有讨论空间 private val hardLock setOf(com.demo.reader, com.demo.video) // 关键词权重在 viewId / className 里出现即加分 private val signals mapOf( feed to 30, recycler to 15, player to 20, comment to 25, search to 10 ) fun evaluate(snap: ListNodeSnapshot, minuteOfDay: Int, usedMinutes: Int): LockDecision { if (snap.size 5) return LockDecision(0, empty-tree) // 采集失败不锁 var score 0 val hits mutableListOfString() for (n in snap) { // 出现密码框说明用户可能正在登录/支付绝不打扰 if (n.hasPassword) return LockDecision(0, password-field) val key (n.viewId ?: ) / (n.className ?: ) for ((k, w) in signals) { if (key.contains(k, ignoreCase true)) { score w hits k } } } if (snap.first().pkg in hardLock) score 60 if (minuteOfDay in 22 * 60..23 * 60 59) score 20 // 夜间加权 if (usedMinutes 30) score 15 // 连续使用加成 return LockDecision(score.coerceIn(0, 100), hits.joinToString()) } fun shouldLock(d: LockDecision, threshold: Int 60) d.score threshold }参数说明逐个过一遍。signals的权重不是拍出来的是按“该特征出现时误锁的代价”排的comment评论区权重高是因为一旦进评论区基本是长时间停留锁了收益最大search权重低是因为用户可能正在搜关键内容锁了最容易挨骂。hardLock里的包名要人工维护宁可少放也不要乱放误锁一个工具类应用的口碑损失远大于多锁十个娱乐应用。时间加权和时长加成都是可选增强先关掉跑通主流程再逐个打开看指标变化。snap.size 5那条提前返回很重要。采集失败的页面如果按正常规则打分很可能因为残留节点凑出高分造成莫名其妙的误锁。4.3 阈值怎么定用日志做一次离线回放阈值 60 不是标准答案它取决于你的场景容忍度。定阈值的唯一可靠方法是拿真实日志回放。阈值误锁率估漏锁率估适用场景40高低强干预、可快速解除60中中通用起点80低高只锁最确定的场景90极低极高只做白名单直锁先把阈值调到 90观察一周把每次“用户手动解除”的日志捞出来看reason如果某个 reason 反复出现说明对应特征权重给高了往下调反过来把用户主动关闭功能前的最后几条记录挑出来看是不是该锁没锁。这一轮调整通常能把误锁率降一半比加任何模型都划算。4.4 什么时候才值得上模型很多人上来就想训个文本分类模型。我的判断标准很朴素当规则引擎在你的日志上覆盖率低于 70%即reason empty-tree或reason为空的比例超过三成再考虑模型。真要做输入也不该是文本原文而是viewId和className的哈希特征加节点数量、bounds 分布。这些东西不涉及用户内容模型体积极小可以直接用端侧推理跑在毫秒级。但模型带来的是版本管理、冷启动、不可解释三块成本而锁屏这种“错了就被卸载”的功能可解释性的价值往往高于几个点的准确率。我自己的项目里规则引擎 人工维护白名单撑了两年到最后也没到非上模型不可的地步。5. 排查锁屏不生效、闪烁、被系统回收的三类翻车现场这一章是我踩过的坑的记录按“现象 → 原因 → 解决”写遇到对得上的直接抄。5.1 无障碍服务显示已开启但收不到任何事件现象设置里服务状态是“已开启”日志里一条onAccessibilityEvent都没有。原因通常有两个。一是配置 XML 里accessibilityEventTypes没包含你需要的类型尤其是只写typeWindowStateChanged时很多页面内部切换不会触发。二是服务被系统杀了又自动重启onServiceConnected重连后rootInActiveWindow需要等一次真实事件才会更新这段时间你的采样全是 null。解决事件类型至少加typeWindowsChanged在onServiceConnected里做一次延时 500ms 的主动补采日志里打印event.packageName和event.eventType先确认事件到底有没有来别在业务逻辑里瞎猜。5.2 遮罩盖不住状态栏、通知栏或者系统弹窗现象应用内容被盖住了但下拉通知栏、状态栏图标仍然可见可点。原因窗口的覆盖范围受FLAG_LAYOUT_NO_LIMITS和窗口类型共同约束而通知栏属于系统层优先级天然高于应用浮层。这不是 bug是设计。解决接受这个边界。遮罩不需要盖住状态栏也不应该盖住系统弹窗——用户需要能下拉通知栏看到消息需要能处理系统授权请求。真正要做的是在视觉上把状态栏区域的背景也画成遮罩色让它“看起来”连成一片而不是真的去抢占系统层。强抢的后果是在某些系统版本上直接被拒绝添加窗口。5.3 遮罩一闪一闪或者出现两个叠在一起现象快速切换页面时遮罩反复出现消失或者同时出现两层。原因一是addView/removeView被并发调用事件线程和判定线程各来一次二是没有做“已添加”状态判断同一份 View 被添加两次。解决所有窗口操作收敛到主线程单一入口用前面isAttachedToWindow的方式判重判定结果加 200ms 防抖连续相同结论不重复执行addView包 try-catch 捕获IllegalStateException捕获到就说明状态判断有漏打日志而不是崩溃。5.4 后台跑一会儿就没了日志干干净净现象设备放着不动半小时服务消失没有任何异常堆栈。这就是典型的黑匣子场景。原因系统对后台服务的回收、部分定制 ROM 的额外清理策略、以及电池优化白名单没进三者叠加。解决用前台服务托管核心循环通知渠道设为低打扰引导用户把应用加入电池优化白名单ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS注意这需要在应用内明确说明用途在onDestroy里记录退出原因配合ActivityManager.getRunningServices之类的信号还原现场。别指望一套配置通吃所有设备能做的只有把存活率从 60% 提到 90%剩下 10% 靠用户手动保活。5.5 用户开了无障碍用了两天又关掉现象功能本身没报错但无障碍开关被用户手动关闭功能失效。原因多数是权限使用不透明。用户看到“此应用可以观察你的屏幕内容”这种提示第一反应是害怕。解决在引导页把“我们读什么、不读什么、数据去哪”写清楚尤其是“不读密码框、不上传原文”这两条要前置首次开启后立刻给一个可见的正向反馈比如显示“已开始识别当前页面无需打扰”让用户知道它真的在工作而不是在后台偷看。这一条本质上是产品问题但技术侧能做的也不少——比如在应用内提供一个“查看采集日志”的开关把判断依据摊开给用户看信任度提升非常明显。6. 进阶用脱敏日志离线回放把阈值从“拍脑袋”变成可复现的灰度结论调阈值最容易陷入的循环是改个数字 → 自己手机上试两下 → 感觉差不多 → 发版 → 用户投诉。要跳出这个循环只需要在本地搭一条离线回放。关键是先有日志。判定每次执行时落一行 JSONL分数、reason、包名、节点数、时间戳以及一个label字段——这个字段只有灰度用户能看到靠一个隐藏的“这次锁得对吗”二选一按钮收集。量不用大几百条就能看出趋势。import json def evaluate(row, thr60): return row[score] thr rows [json.loads(line) for line in open(decision_log.jsonl, encodingutf-8) if line.strip()] tp fp fn tn 0 for r in rows: pred evaluate(r, thr60) label (r.get(label) lock) tp pred and label fp pred and not label fn (not pred) and label tn (not pred) and not label precision tp / max(tp fp, 1) recall tp / max(tp fn, 1) print(fP{precision:.3f} R{recall:.3f} 误锁率{fp / max(fp tn, 1):.3f})这段脚本的价值在于它把“调参”变成了可重复实验。改一次权重、跑一次脚本、看三个指标怎么变十分钟一轮比线上灰度一周还准。几个使用要点label缺失的行要单独统计缺失率高说明采集入口埋得不够样本要按包名分层看整体 P 值好看不代表每个应用都好看阈值不要一次跳太多每次改 10 分观察 P/R 的交叉点。配套的灰度分桶也简单按设备 ID 的哈希取模分成 10 桶新策略先放 1 桶观察 48 小时的手动解除率。下面这四个指标我认为缺一不可。指标含义警戒线误锁率用户明确反馈“不该锁”的比例 3% 立即回滚漏锁率白名单应用未被锁的比例 15% 检查采集手动解除率遮罩出现后被主动解除的比例 25% 说明遮罩太重单次会话时长遮罩停留时长中位数 3 秒说明用户直接懵了最后说个我自己的习惯每次上线新策略前我会把reason字段的分布打出来看一遍。如果某个 reason 突然占了六成以上基本可以确定是某个权重给高了或者某类页面被误判成目标页——这个信号比任何指标都来得快。智能UI锁屏这件事做到最后你会发现真正花时间的不是写代码而是维护那份“什么该锁、什么绝不能锁”的判断力。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑