ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android应用锁实战:基于无障碍服务拦截Activity启动的完整方案

Android应用锁实战:基于无障碍服务拦截Activity启动的完整方案 第一次给公司做应用锁模块时我的方案被测试一分钟就打回来了锁屏弹出来了但按 Home 键回桌面再点微信图标微信直接进去了密码框消失得无影无踪。排查到最后发现问题压根不在密码校验逻辑而在我忽略了一个核心事实——应用锁防的根本不是用户有没有输密码而是被保护的那个 Activity 到底有没有被彻底挡住。这也是我后来理解拦截 Activity 启动这句话真正含义的过程应用锁的本质就是在目标应用的 Activity 还没来得及让你看清页面内容时抢先盖上一道密码验证的墙。这篇文章面向有 Android 基础、想自己写应用锁或类似前台拦截功能的人。你会搞清楚这套机制为什么能成立、为什么不用 root 也不用 Xposed完整代码如何落地以及我排过的 5 个高频事故。方案本身在 Android 8 到 Android 14 上都验证过不需要任何系统权限普通工程直接能跑。1. 选型为什么应用锁最后都走了拦截Activity启动这条路1.1 四种主流方案横向对比做应用锁第一件事不是写代码是选机制。很多新手上来就搜如何获取当前前台应用然后一头扎进 UsageStatsManager 里写完才发现各种坑。我先把主流方案摆出来后面所有代码都建立在这个对比上。方案核心机制实时性权限要求绕过风险维护成本AccessibilityService Activity 覆盖监听窗口切换事件发现锁定应用后立即启动验证 Activity 压上去事件驱动毫秒级用户在系统设置里开启无障碍服务低可针对 Home 键兜底低UsageStatsManager 轮询定时查询当前前台应用命中锁定名单后启动验证 Activity取决于轮询间隔一般 200~500ms需要用户授权使用情况访问权限中轮询间隙可能露出内容中悬浮窗全局覆盖检测到切换后弹一个系统悬浮窗盖住屏幕取决于检测手段叠加窗本身有延迟无障碍 悬浮窗权限高Android 12 对悬浮窗限制更多高Xposed / Riru 系列 Hook直接 Hook 系统进程拦截 Activity.startActivity最及时需要 root框架兼容差低高且不适合普通用户这里要注意UsageStatsManager 和悬浮窗方案看起来轻量但实际坑非常多。轮询方案的延迟永远存在用户可能会在锁屏出现前的一两帧里看到目标应用的敏感内容悬浮窗方案最头疼的是软键盘——系统 IME 的层级不一定压得住悬浮窗经常出现密码输入框被键盘顶到看不见的诡异问题。1.2 为什么弃用悬浮窗、弃用轮询悬浮窗方案曾是一段时间内应用锁的主流做法因为很多人觉得盖个系统级悬浮窗比启动一个 Activity更轻、更不容易被系统杀死。但我在实际项目里遇到的真实情况是第一TYPE_APPLICATION_OVERLAY悬浮窗在部分定制 ROM 上会被系统弹窗、钱包、安全键盘等东西互相顶掉稳定性不如普通 Activity。第二悬浮窗要正确处理键盘显示、旋转、分屏、折叠屏这些边界条件叠加起来工作量非常大。第三Android 12 上对悬浮窗的显示限制收得更紧用户要额外授予显示在其他应用上层权限很多人不理解为什么一个锁屏还要这个权限转化率会打折扣。选择 AccessibilityService Activity 覆盖本质上就是让系统自己来管窗口层级。无障碍服务负责看普通 Activity 负责挡。系统对前台 Activity 的窗口管理是天然的稳定键盘、旋转、分屏全都按 Activity 的规则走不需要自己处理恶心的窗口层级问题。这也是市面上大多数不 root 的应用锁最终采用的路线。1.3 这套方案适合什么场景如果你的目标是做一个面向 C 端用户的应用锁或者给企业内部 APP 做一个二次验证进入的模块这套方案都适用。它不依赖 root不需要刷机不需要厂商合作只要用户愿意在系统设置里开一下无障碍服务就行。当然如果目标用户连无障碍服务都不愿意开那产出物基本是个摆设这一点在需求评审阶段就要说清楚。需要提醒的是无障碍服务本身是敏感权限Google Play 对它的用途审核很严格。如果上线商店必须把用于应用锁的窗口切换监测这个用途说明写明白并遵守商店对辅助功能 API 的使用准则。国内应用市场相对宽松但在隐私政策里也要如实声明。2. 机制拆解一条事件从应用切到前台到弹出密码框经历了什么2.1 AccessibilityService 如何感知 Activity 切换要拦截 Activity 启动第一步是先知道目标应用的 Activity 起来了。系统不会主动广播某某应用要启动 Activity但对无障碍服务例外——每当顶层窗口发生变化系统会向已开启的无障碍服务发送TYPE_WINDOW_STATE_CHANGED事件事件的packageName字段就是当前顶层窗口所属的应用包名。对 Activity 来说窗口变化基本可以等同为 Activity 的显示变化。你从桌面点开微信微信第一个界面的窗口变成顶层窗口系统就会发一个事件包名是com.tencent.mm。这个事件从发生到送达服务的时延非常短比轮询方案快一个数量级这就是拦截能够成立的关键。这里要澄清一个概念无障碍服务做的不是真正的Hook 掉 startActivity而是系统通知你窗口切了你抢在这个窗口被用户看清内容之前启动自己的锁屏 Activity 盖上去。在用户视角里效果等同于 Activity 的启动被拦住了。2.2 锁屏状态机三种状态和两次判断整个应用锁的内部逻辑其实是一个简单的状态机只有三种状态状态含义进入条件IDLE无锁屏什么事情都没有启动后默认状态用户进入非锁定应用或桌面LOCKING锁屏 Activity 正在显示等待验证前台包名在锁定名单里且不在解锁会话内UNLOCKED目标应用处于临时放行状态用户输对密码锁屏 Activity 销毁状态机里最关键的是两次判断第一次判断收到窗口事件后当前前台包名是否在锁定名单里。不在名单里说明用户去了别的应用或桌面直接清空临时放行的会话记录。第二次判断在名单里的情况还要再判断是否处于解锁会话中——也就是说这个包名是不是刚刚验证过并且距离上次验证时间没超过设定的重新锁定间隔。如果是就放行如果不是说明用户是刚从别的地方切回来需要立即启动锁屏。这两个判断缺一不可。没有第一次判断你会把非锁定应用也锁上没有第二次判断用户刚解锁微信在微信里切换一个聊天窗口都会反复弹锁。2.3 被忽略的事件源过滤为什么必须屏蔽自身包名写这个功能的人第一次调试大概率会遇到一个诡异现象锁屏 Activity 刚弹出来又瞬间把自己弹没了甚至形成连环闪屏。原因很简单——LockActivity 本身也是一个窗口它显示的那一刻系统同样会给无障碍服务发一条TYPE_WINDOW_STATE_CHANGED事件而事件里的包名就是应用锁自己的包名。如果不加过滤状态机会认为当前前台应用是应用锁自己而应用锁自己不在锁定名单里于是走到清空解锁会话的分支同时因为锁屏状态还是 LOCKING按前面设计又会重新启动锁屏结果就是自己跟自己打架。处理方式就是在状态机入口直接丢弃自己包名的事件if (packageName.equals(appContext.getPackageName())) { // 锁屏 Activity / 应用锁自身的界面不能参与锁屏判断直接忽略 return; }同样要忽略的还有系统界面这个放到后面的踩坑部分详细说。3. 完整代码落地服务、状态机、密码页三件套3.1 工程骨架与 Manifest 里的敏感参数整个功能只涉及三个核心文件无障碍服务、锁屏状态管理器、锁屏 Activity。先看 Manifest 里的关键声明application activity android:name.ui.LockActivity android:excludeFromRecentstrue android:launchModesingleInstance android:noHistorytrue android:exportedfalse android:taskAffinitycom.example.applock.task.lock android:windowSoftInputModestateAlwaysVisible|adjustResize / service android:name.service.AppLockAccessibilityService android:exportedtrue android:labelstring/accessibility_service_label android:permissionandroid.permission.BIND_ACCESSIBILITY_SERVICE intent-filter action android:nameandroid.accessibilityservice.AccessibilityService / /intent-filter meta-data android:nameandroid.accessibilityservice android:resourcexml/accessibility_service_config / /service /application这里几个参数要单独解释。launchModesingleInstance和noHistorytrue配合保证 LockActivity 永远是独立的一个实例用户验证完返回时LockActivity 不会留在返回栈里。taskAffinity给锁屏 Activity 指定一个独立的 Task避免它和主界面的 Task 混在一起。excludeFromRecentstrue让它不出现在最近任务列表中防止用户通过多任务卡片把锁屏滑掉。无障碍服务的配置文件res/xml/accessibility_service_config.xmlaccessibility-service xmlns:androidhttp://schemas.android.com/apk/res/android android:accessibilityEventTypestypeWindowStateChanged android:accessibilityFeedbackTypefeedbackGeneric android:accessibilityFlagsflagDefault android:canRetrieveWindowContentfalse android:descriptionstring/accessibility_service_description android:notificationTimeout50 /canRetrieveWindowContentfalse必须显式关闭我们只需要包名不需要读取窗口内容开着反而会让系统认为你要访问完整页面信息增加审核压力。3.2 无障碍服务30 行核心轮子public class AppLockAccessibilityService extends AccessibilityService { private static AppLockAccessibilityService instance; public static boolean isRunning() { return instance ! null; } Override protected void onServiceConnected() { super.onServiceConnected(); instance this; } Override public void onAccessibilityEvent(AccessibilityEvent event) { if (event.getEventType() ! AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { return; } CharSequence packageName event.getPackageName(); if (packageName null || packageName.length() 0) { return; } AppLockManager.getInstance().onForegroundAppChanged(packageName.toString()); } Override public void onInterrupt() { } Override public void onDestroy() { instance null; super.onDestroy(); } }这个服务只做一件事把所有窗口切换事件转发给状态管理器。事件类型过滤放在onAccessibilityEvent里做不放进 XML 配置里也可以但建议两个地方都做——XML 过滤是为了减少系统层的无效回调代码过滤是为了逻辑可读性。3.3 状态机中枢 AppLockManagerpublic class AppLockManager { private static final long RE_LOCK_INTERVAL_MILLIS 5 * 60 * 1000L; private static final String EXEMPT_SYSTEM_UI com.android.systemui; private final Context appContext; private final SharedPreferences preferences; private String currentLockPkg ; private String unlockedPkg ; private long unlockedAt 0L; private boolean lockScreenVisible false; private static volatile AppLockManager instance; public static void init(Context context) { if (instance null) { synchronized (AppLockManager.class) { if (instance null) { instance new AppLockManager(context.getApplicationContext()); } } } } public static AppLockManager getInstance() { return instance; } private AppLockManager(Context context) { this.appContext context; this.preferences appContext.getSharedPreferences(app_lock, Context.MODE_PRIVATE); } public synchronized void onForegroundAppChanged(String packageName) { if (TextUtils.isEmpty(packageName)) { return; } // 忽略自己否则锁屏 Activity 一弹出就会进入死循环 if (packageName.equals(appContext.getPackageName())) { return; } // 忽略系统 UI避免下拉通知栏触发误判 if (EXEMPT_SYSTEM_UI.equals(packageName)) { return; } // 锁屏正在显示期间只要前台被非自身窗口抢占说明用户按了 Home 或切出去 // 必须把锁屏拉回来否则就形成了绕过 if (lockScreenVisible) { if (!packageName.equals(appContext.getPackageName())) { lockScreenVisible false; showLockScreen(currentLockPkg); } return; } if (isLockedApp(packageName)) { boolean inUnlockSession packageName.equals(unlockedPkg) System.currentTimeMillis() - unlockedAt RE_LOCK_INTERVAL_MILLIS; if (!inUnlockSession) { showLockScreen(packageName); } } else { // 用户切换到了非锁定应用或桌面解锁会话失效 unlockedPkg ; unlockedAt 0L; } } public synchronized void onPasswordVerified() { if (TextUtils.isEmpty(currentLockPkg)) { return; } unlockedPkg currentLockPkg; unlockedAt System.currentTimeMillis(); lockScreenVisible false; currentLockPkg ; } public synchronized void onScreenOff() { unlockedPkg ; unlockedAt 0L; } private boolean isLockedApp(String packageName) { SetString lockedApps preferences.getStringSet(locked_apps, Collections.emptySet()); return lockedApps ! null lockedApps.contains(packageName); } private void showLockScreen(String packageName) { if (TextUtils.isEmpty(packageName)) { return; } currentLockPkg packageName; lockScreenVisible true; Intent intent new Intent(appContext, LockActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); intent.putExtra(LockActivity.EXTRA_LOCK_PKG, packageName); appContext.startActivity(intent); } }这个类把状态机的全部逻辑集中在一处UI 层完全不需要关心什么时候该锁。onScreenOff是给息屏接收器用的——息屏后解锁会话必须立即失效不然手机放口袋再拿出来锁屏应用就直接敞开了。3.4 密码校验页 LockActivity 的完整实现public class LockActivity extends Activity { public static final String EXTRA_LOCK_PKG extra_lock_pkg; private String lockPkg ; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_lock); lockPkg getIntent().getStringExtra(EXTRA_LOCK_PKG); if (TextUtils.isEmpty(lockPkg)) { finish(); return; } // 确保锁屏 Activity 能覆盖在目标应用之上并且息屏时也能点亮屏幕 getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED); getWindow().addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); setFinishOnTouchOutside(false); EditText pwdInput findViewById(R.id.et_password); Button btnUnlock findViewById(R.id.btn_unlock); btnUnlock.setOnClickListener(v - { String input pwdInput.getText().toString().trim(); if (checkPassword(input)) { AppLockManager.getInstance().onPasswordVerified(); finish(); } else { Toast.makeText(this, 密码错误, Toast.LENGTH_SHORT).show(); pwdInput.setText(); } }); pwdInput.requestFocus(); } Override public void onBackPressed() { // 屏蔽返回键绕过只能回到桌面锁屏 Activity 保留在任务里 moveTaskToBack(true); } private boolean checkPassword(String input) { String savedHash getSharedPreferences(app_lock, MODE_PRIVATE) .getString(password_hash, ); if (TextUtils.isEmpty(savedHash)) { return true; } return hashPassword(input).equals(savedHash); } private String hashPassword(String input) { // 生产环境建议使用 PBKDF2 或 BCrypt这里只演示基本流程 String salt com.example.applock.fixed.salt; return Sha256Utils.sha256(input salt); } }布局文件里是一个居中显示的密码框和确认按钮。重点说两个容易被忽略的细节第一FLAG_SHOW_WHEN_LOCKED和FLAG_TURN_SCREEN_ON的目的是让锁屏 Activity 在系统锁屏之上也能正常显示。如果你只想锁应用、不想跟系统锁屏交互这两个 flag 也必须加否则部分机型息屏唤醒后锁屏 Activity 会被系统锁屏挡住。第二onBackPressed里不能直接finish()否则按返回键就等于绕过密码了。moveTaskToBack(true)是把它当成一个后台任务退到桌面等用户再次点目标应用图标时onForegroundAppChanged会再次触发锁屏形成闭环。4. 踩坑实录应用锁开发中最高频的 5 个事故现场4.1 下拉通知栏触发重锁这是我第一个线上反馈用户在微信里解锁后待了不到一分钟下拉一次通知栏再上滑返回微信锁屏又弹出来了。排查链路是这样的收到反馈后我先在真机上复现用adb shell dumpsys window确认了下拉通知栏时顶层窗口确实会切换再往无障碍服务的日志里加打印发现事件包名变成了com.android.systemui。此时状态机走到非锁定应用分支unlockedPkg被清空解锁会话直接失效。根因清楚了修法也简单把系统 UI 包名加入豁免名单。但要注意部分国产 ROM 的系统 UI 不叫com.android.systemui有的带厂商后缀比如小米的部分机型是com.miui.systemui。稳妥做法是收集常见系统包名再加一个系统界面包名不在锁定名单、也不是普通应用包名时忽略的降级判断。4.2 Home 键绕过测试当场打回我的那个 bug开头说的那次打回根因就在这。当时我写状态机时只处理了进入锁定应用 - 弹锁这条正向链路没处理锁屏显示期间用户按 Home 键的情况。按 Home 后LockActivity 退到后台桌面变成顶层窗口此时无障碍事件里是桌面包名属于非锁定应用状态机走进 else 分支把会话清空了同时lockScreenVisible还停在 true。等我再点微信图标微信恢复前台事件包名是微信但第一层lockScreenVisible true直接 return 了——锁屏没有重新拉起来于是微信的内容直接暴露。修复后的逻辑就是 AppLockManager 里那段锁屏显示期间只要前台不是自身窗口强制重新拉回锁屏的判断。这里要特别强调的是lockScreenVisible这个状态非常关键它和currentLockPkg一旦不同步就会出现各种绕过或死锁。我在代码注释里特意标注了必须先置 false 再 showLockScreen就是为了避免重入导致状态互相覆盖。4.3 同一个 App 内切换页面反复弹锁用户解锁淘宝后从首页点进商品详情页锁屏又弹出来了。这个坑的根因不在代码逻辑在于概念没理清应用锁锁的是包名不是页面。从首页切到详情页窗口事件还是同一个包名本不应该触发二次锁定。但为什么会弹排查发现是我把RE_LOCK_INTERVAL_MILLIS设得太短当时只有 30 秒用户看商品详情超过 30 秒后滑动到下一页解锁会话刚好过期于是触发重锁。这里有一个设计取舍如果你希望应用锁是每次从后台回来自动重新锁定的强安全风格那么这个行为是正常的如果你希望解锁后一段时间内自由切换就把间隔设长或者改成仅在应用退到后台超过 N 秒才重新锁定的会话型策略。我建议默认做成后者因为前者在用户连续使用场景下体验非常糟糕而且容易让用户误以为应用锁有 bug。4.4 Android 10 后台启动 Activity 被限制项目跑到 Android 10 测试机上出现了一种偶发情况锁屏没有弹出来但活动记录里能看到startActivity的调用确实执行了日志有后台启动限制相关的警告。Android 10 开始系统对后台启动 Activity 做了严格限制但有两个关键豁免一是应用有SYSTEM_ALERT_WINDOW权限二是应用有被系统绑定的无障碍服务并且该服务正在活动状态。我们的方案正好踩中第二个豁免所以大部分情况是正常的。真正的问题出在我调试时用了开发者选项 - 后台进程限制把进程设成了不允许后台进程无障碍服务虽然还在但进程被系统调度限制唤醒时机不定导致事件处理和 Activity 启动出现一段空窗期。这个坑严格说不算代码 bug但提醒我一点这类依赖无障碍服务的功能要特别关注进程被系统杀死后的恢复能力。建议在主界面加一个启动自检检测AppLockAccessibilityService.isRunning()没运行就引导用户去设置里重新开启。4.5 进程被杀后锁屏失联最后一个坑比较隐蔽。手机内存吃紧时系统会把应用锁进程整个回收掉。等用户再从桌面打开微信无障碍服务还没来得及重新绑定微信已经显示出来了而且因为进程刚刚重启状态机里什么都没有锁屏可能就永久失联了。为什么说可能因为如果用户在微信里停留期间恰好有任意一次窗口状态切换比如打开一个子页面无障碍服务仍然能收到事件状态机就会重新触发锁定。但如果用户一直停留在同一个页面不动那就永远不会弹锁。实话说这个场景没有完美的空进程解决方案。我能给的最实用的建议是把锁屏状态持久化到 SharedPreferences在onServiceConnected和AppLockManager.init里做一次恢复检查——如果存在未验证的currentLockPkg立即重新弹锁。同时配合前台服务保住进程优先级能显著降低被杀概率。记住应用锁这种安全类应用前台服务通知虽然丑但不可省。5. 进阶加固从能解锁到真正锁得住5.1 指纹识别接入密码输入只是基础功能现阶段的用户习惯已经很难接受每次解锁都敲六位数字。Android 6.0 以上可以直接用系统BiometricPromptBiometricPrompt biometricPrompt new BiometricPrompt(this, executor, new BiometricPrompt.AuthenticationCallback() { Override public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) { AppLockManager.getInstance().onPasswordVerified(); finish(); } }); biometricPrompt.authenticate(new BiometricPrompt.PromptInfo.Builder() .setTitle(验证指纹) .setNegativeButtonText(使用密码) .build());注意接入指纹后密码输入框仍然必须保留作为setNegativeButtonText的兜底通道。还要考虑指纹验证失败几次后系统会强制要求输入锁屏密码这属于系统行为不需要自己处理。5.2 防卸载与防停用应用锁最容易被人直接进设置把无障碍服务关掉或者干脆卸载。防卸载要靠 DeviceAdmin 权限实现这个方案比较重而且国内用户对设备管理器授权非常敏感是否开启要谨慎权衡。更轻量、收益更高的做法是服务存活自检。在主界面用一个定时任务检查无障碍服务是否还在运行不在就弹全屏提醒。同时监听ACTION_PACKAGE_REMOVED虽然拦截不了卸载但至少能在重启后引导用户重新配置。我见过一些应用锁在 Android 12 上被系统自动回收无障碍服务用户没动过设置所以这个自检不是针对恶意用户更多是帮普通用户遇到问题时能自己恢复。5.3 锁屏策略参数化与优雅降级最后聊一下扩展。不要把所有策略写死在常量里建议提供三档配置策略行为适用场景宽松解锁后 5 分钟内不重锁息屏后重置普通用户兼顾体验标准每次从后台切回都重锁但应用内页面切换不锁默认推荐严格每次窗口切换都要验证锁屏频繁金融类应用或企业管控严格模式在状态机里其实只是把RE_LOCK_INTERVAL_MILLIS改成 0再把应用内切换不锁的判断去掉。真正开发时建议把这套参数做成远程配置方便线上调整。还有一个容易被忽略的降级场景应用锁自己的主界面配置锁定应用列表的那个页面最好加一层手势保护不然任何人拿到手机都能打开应用锁主页把微信从锁定名单里删掉。很多应用锁产品对外宣传有多安全结果自己主界面连个 PIN 都没有这就说不过去了。做应用锁这段时间我最大的体会是安全类功能的难点从来不是能不能实现而是能不能在所有边界场景下都不失守。Home 键、任务切换、通知栏、息屏、进程被杀、系统回收每一个看起来微不足道的动作都可能成为绕过的通道。写完这篇文章里代码之后建议你也自己跑一遍这清单锁屏显示时按 Home、按最近任务、下拉通知栏、锁屏熄屏再唤醒、低内存下切换应用。每个动作都试一遍你对拦截 Activity 启动这件事的理解会远超看十篇文章的效果。
返回列表