
有段时间我做一套政企定制设备客户提了个需求默认桌面只保留几个办公应用其他App全部隐藏如果运维人员临时想看隐藏的应用必须在拨号盘输入一个暗码才能恢复显示。这个需求听起来像是“系统自带的隐藏应用功能”但真做起来涉及好几个关键点——怎么让应用图标从桌面消失、怎么用暗码触发、怎么保证不同 Android 版本上行为一致。我整套方案做完之后把核心思路、关键代码和踩过的坑整理成这篇文章适合正在做 Android 应用隐藏、企业定制 ROM、或者单纯想给自己手机加一道隐私保险的朋友参考。1. 需求拆解与整体方案设计1.1 “隐藏桌面应用”到底在隐藏什么先明确一个基本概念Android 桌面上能显示哪些图标并不是桌面自己拍脑袋决定的。系统桌面对外表现就是一个 Launcher它会通过 PackageManager 查询所有带ACTION_MAINCATEGORY_LAUNCHER这两个 Intent Filter 的 Activity然后把查询结果渲染成应用图标。所以“隐藏应用”本质上只有两条路要么让 Launcher 查得到但故意不显示要么让 PackageManager 干脆查不到这个 Activity。大多数普通用户理解的“隐藏应用”其实是第一种——换一个支持隐藏功能的第三方桌面在显示层做过滤。但这种方式一旦用户切回系统自带的 Launcher隐藏立刻失效对定制场景来说基本没用。真正可靠的方案是让 PackageManager 层面发生变化。这里有一个容易被忽略的细节如果一个 package 的主 Launcher Activity 被设置为disabled或者整个 package 被系统标记为隐藏PackageManager 在解析桌面图标时会直接跳过它。不管用户切到哪个桌面图标都不会出现。这就解决了我当时遇到的“隐藏必须在全局生效”的问题。1.2 三种实现路径的对比针对“指定应用显示其他应用隐藏”这类需求我梳理过三种常用实现路径实现方案核心API权限要求适用场景Device Owner 设备所有者方案DevicePolicyManager.setApplicationHidden()通过ADB激活设备所有者免Root企业定制、个人隐私保护系统签名/Root 方案PackageManager.setApplicationEnabledSetting()系统签名或Root权限定制ROM、政企设备出厂预装Launcher 过滤方案桌面源码或自研桌面过滤无特殊权限只要求“本桌面”生效的场景我实际选的是第一种。原因很简单setApplicationHidden()这个 API 专门就是干这个事的隐藏应用之后系统会同时把它 force-stop图标从桌面消失后台也不会偷偷跑效果非常干净。而setApplicationEnabledSetting()虽然也常见但它对权限要求极其严格普通签名App根本调用不了后面我会专门讲这个坑。如果是给企业做量产设备我反而建议直接采用 Device Owner 方案因为它不仅可以隐藏应用还能顺便做设备锁定、信号广播、MAC地址查询等企业管控功能一套框架解决所有问题。1.3 暗码触发方式的选型“暗码触发”是这个项目里最有意思的部分。常见的实现方式有四种拨号盘暗码拦截用户打开系统拨号盘输入类似*#*#8899#*#*的暗码应用通过监听NEW_OUTGOING_CALL广播把这次拨号拦下来并执行恢复/隐藏逻辑。自定义暗码输入页在隐藏入口放一个输入框输入固定暗码后触发。实现简单不受厂商ROM限制但需要一个入口。无障碍服务监听通过 AccessibilityService 监听全局输入字符一旦匹配暗码就触发。隐蔽性强但开发量大。传感器/组合键触发比如快速摇晃手机、连续按音量键等通过传感器或按键事件触发。如果只让我推荐一种首选还是拨号盘暗码。因为它不需要任何可见入口普通人根本不会往拨号盘里输入一长串*#*#开头的符号隐蔽性非常强。但它也确实有个局限有些国产ROM的拨号盘不走系统的NEW_OUTGOING_CALL广播导致拦截失效。所以我在产品里做了双路设计——主路用拨号盘暗码备路在后台放一个输入框两种方式触发同一个 Handler。2. 环境准备与基础工程搭建2.1 项目结构与权限清单基础工程用 Android Studio 建一个普通空项目即可关键是权限和组件结构。工程结构大致如下MainActivity应用管理界面用于勾选隐藏/显示的应用并维护白名单。AdminReceiver设备管理器广播接收器负责激活 Device Owner。SecretReceiver拨号盘暗码监听广播接收器。HiddenManager核心逻辑封装负责隐藏、恢复、名单读取。res/xml/device_admin.xml设备管理器的策略声明。权限声明在AndroidManifest.xml中按需求列出uses-permission android:nameandroid.permission.CHANGE_COMPONENT_ENABLED_STATE / uses-permission android:nameandroid.permission.PROCESS_OUTGOING_CALLS / uses-permission android:nameandroid.permission.QUERY_ALL_PACKAGES / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE /注意PROCESS_OUTGOING_CALLS是危险权限Android 6.0 以上需要动态申请。如果目标设备是 Android 11 及以上QUERY_ALL_PACKAGES必须显式声明否则getInstalledApplications()只能拿到部分系统应用和你有交互的应用列表会不完整。2.2 激活 Device Owner免Root方案的关键步骤很多人以为要隐藏应用必须Root其实用 Device Owner 就可以做到。激活设备所有者需要两步先声明一个设备管理接收器再用 ADB 命令激活。AdminReceiver是一个最普通不过的DeviceAdminReceiverclass AdminReceiver : DeviceAdminReceiver() { override fun onEnabled(context: Context, intent: Intent) { super.onEnabled(context, intent) } override fun onDisabled(context: Context, intent: Intent) { super.onDisabled(context, intent) } }对应res/xml/device_admin.xmldevice-admin xmlns:androidhttp://schemas.android.com/apk/res/android uses-policies / /device-admin这里我刻意没有写任何具体策略因为我们的目的只是拿到设备所有者身份不是真的要搞设备管理。设备所有者身份下setApplicationHidden()才有权限调用。然后在AndroidManifest.xml里注册接收器receiver android:name.AdminReceiver android:exportedtrue android:permissionandroid.permission.BIND_DEVICE_ADMIN meta-data android:nameandroid.app.device_admin android:resourcexml/device_admin / intent-filter action android:nameandroid.app.action.DEVICE_ADMIN_ENABLED / /intent-filter /receiver最后用 ADB 激活adb shell dpm set-device-owner com.example.hideapp/.AdminReceiver这一步有几个前提条件需要注意激活设备所有者时设备上不能已经登录过账号不能已经有其他设备所有者部分厂商设备还要求先关闭锁屏密码否则会提示失败。我当时在一台已经登录了系统账号的平板上碰到过报错最终恢复出厂设置才解决所以建议在设备出厂阶段或者刚恢复出厂后统一执行激活。2.3 应用列表加载与名单维护隐藏功能的核心逻辑是“维护一份要隐藏的包名列表”或“维护一份允许显示的包名白名单”。这一步要做的是把已安装应用加载出来让操作人员在 UI 上勾选。val pm packageManager val intent Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) val resolveInfos pm.queryIntentActivities(intent, PackageManager.MATCH_ALL) val appList resolveInfos.map { resolveInfo - resolveInfo.activityInfo.packageName }.toSet()注意我这里用queryIntentActivities而不是getInstalledApplications因为自带桌面只关心“有桌面入口”的应用用这种方式过滤后列表里不会出现各种后台服务App用户看得也更清楚。名单持久化我直接用了SharedPreferences简单够用。如果后续要支持多设备同步再换成数据库或云配置。fun saveHiddenList(context: Context, hiddenList: SetString) { val sp context.getSharedPreferences(config, Context.MODE_PRIVATE) sp.edit().putStringSet(hidden_packages, hiddenList).apply() } fun readHiddenList(context: Context): MutableSetString { val sp context.getSharedPreferences(config, Context.MODE_PRIVATE) return sp.getStringSet(hidden_packages, mutableSetOf()) ?: mutableSetOf() }这里有个体验优化点应用列表界面里一定要把“设置”“桌面”“输入法”这几个关键应用排除掉否则用户误操作把自己桌面隐藏了设备会直接黑屏恢复起来非常麻烦。我会在后续的避坑部分详细展开。3. 核心逻辑实现隐藏/恢复与暗码触发3.1 用 DevicePolicyManager 隐藏指定应用隐藏功能的核心代码其实很少难的是权限和边界情况。通过 Device Owner 调用setApplicationHidden()是系统级的隐藏效果是全局的。class HiddenManager(private val context: Context) { private val dpm: DevicePolicyManager context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager private val admin ComponentName(context, AdminReceiver::class.java) fun setHidden(packageName: String, hidden: Boolean): Boolean { return try { dpm.setApplicationHidden(admin, packageName, hidden) } catch (e: SecurityException) { e.printStackTrace() false } } fun hidePackages(packages: ListString) { for (pkg in packages) { setHidden(pkg, true) } } fun restorePackages(packages: ListString) { for (pkg in packages) { setHidden(pkg, false) } } }调用前最好判断一下dpm.isDeviceOwnerApp(context.packageName)如果不是设备所有者后面必然会抛 SecurityException提前判断可以给用户更友好的提示。还有一个容易踩的坑setApplicationHidden()返回false并不一定代表失败逻辑有问题。比如目标应用本身就是一些受保护的系统组件或者当前用户不是系统用户都会返回false。我刚开始写的时候只关注了返回值后来调试发现部分预装应用即使返回值是true桌面图标也可能要等一会儿才消失这是 PackageManager 的广播刷新机制导致的等几秒或者重启 Launcher 就能看到效果。3.2 “指定应用显示”的两种语义回到标题里的“指定应用显示”这个表述其实有两种理解我在工程里给了两种模式隐藏名单模式用户勾选哪些App就隐藏哪些App其他全部正常显示。白名单模式用户勾选哪些App允许显示其他应用全部隐藏。这个模式等价于“桌面只保留指定应用”。两种模式在代码上只差一个名单转换逻辑。白名单模式下要先把所有应用枚举出来然后计算差集再逐一隐藏fun applyWhiteListMode(whiteList: SetString, allPackages: ListString) { val toHide allPackages.filterNot { it in whiteList || it context.packageName } restorePackages(whiteList.toList()) hidePackages(toHide) }这个差集计算要注意几点第一个是自己应用的包名必须排除否则把自己隐藏了就没法恢复第二个是系统关键应用比如com.android.settings、当前桌面、当前输入法尽量不要加入差集隐藏范围否则会出现设置打不开、桌面消失这些灾难性问题。我实际部署中通常建议使用白名单模式因为在政企场景里设备用途非常明确指定几个办公App即可比维护“要隐藏哪些App”更不容易出问题。3.3 暗码触发恢复显示完整代码分析暗码触发的核心是SecretReceiver。它监听ACTION_NEW_OUTGOING_CALL这个广播是有序广播可以在拨号之前被拦截掉。先看注册部分receiver android:name.SecretReceiver android:exportedtrue intent-filter android:priority999 action android:nameandroid.intent.action.NEW_OUTGOING_CALL / /intent-filter /receiver然后是实现类class SecretReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action ! Intent.ACTION_NEW_OUTGOING_CALL) return val number if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { intent.getStringExtra(Intent.EXTRA_PHONE_NUMBER) } else { resultData ?: intent.getStringExtra(Intent.EXTRA_PHONE_NUMBER) } if (number SECRET_CODE) { // 去掉广播阻止真实拨号 abortBroadcast() val mode getCurrentMode(context) val manager HiddenManager(context) if (mode MODE_HIDDEN) { // 显示所有应用 manager.restorePackages(getHiddenListFromPref(context).toList()) setCurrentMode(context, MODE_VISIBLE) } else { // 重新进入隐藏模式 manager.hidePackages(getHiddenListFromPref(context).toList()) setCurrentMode(context, MODE_HIDDEN) } Toast.makeText(context, 暗码生效, Toast.LENGTH_SHORT).show() } } companion object { const val SECRET_CODE *#*#8899#*#* } }abortBroadcast()是这里最关键的一行。因为它是有序广播调用这个方法后后续所有监听该广播的组件都不会收到这次广播系统拨号程序也就不会真的发起电话。所以用户输入暗码后只会看到 Toast 提示不会真的拨出一个电话。getCurrentMode和setCurrentMode用 SharedPreferences 存储当前状态。第一次输入暗码时从隐藏模式切到显示模式把所有隐藏应用恢复第二次输入时再切回隐藏模式重新隐藏应用。这样就实现了“暗码切换隐藏/显示”的完整闭环。3.4 兜底方案自定义暗码输入页面如果某些机型拨号盘不触发NEW_OUTGOING_CALL就要准备备用入口。最简单的办法是在 MainActivity 里做一个隐藏入口通过某种方式进入后弹一个密码输入框。class SecretInputActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_secret_input) val editText findViewByIdEditText(R.id.etSecret) val btnSubmit findViewByIdButton(R.id.btnSubmit) btnSubmit.setOnClickListener { if (editText.text.toString() SecretReceiver.SECRET_CODE) { toggleHiddenState() finish() } else { Toast.makeText(this, 暗码错误, Toast.LENGTH_SHORT).show() } } } }入口可以放在三个地方桌面Widget、通知栏常驻通知、或者一个没有图标的快捷设置。使用这个方案就不需要PROCESS_OUTGOING_CALLS权限兼容性也更好。但因为它需要一个可见入口隐蔽性会稍差一些所以我把拨号暗码作为主方案输入框作为备选方案。3.5 不使用 Device Owner 时的替代写法如果你的场景拿不到 Device Owner但有系统签名或者 Root可以改用PackageManager.setComponentEnabledSetting()。它同样能实现图标消失甚至更彻底——它是把目标应用的 Launcher Activity 组件直接禁用。fun disableLauncherActivity(packageName: String) { val pm packageManager val intent Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER).setPackage(packageName) val resolveInfo pm.resolveActivity(intent, 0) resolveInfo?.let { val componentName ComponentName(packageName, it.activityInfo.name) pm.setComponentEnabledSetting( componentName, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ) } }恢复时把COMPONENT_ENABLED_STATE_DISABLED改成COMPONENT_ENABLED_STATE_ENABLED即可。为什么强调非系统应用不能用这个方法因为这个 API 需要CHANGE_COMPONENT_ENABLED_STATE权限而该权限是 signature 级别的普通签名APK即使声明了也拿不到运行时一定会抛SecurityException: Permission Denial。网上很多教程不说明这一点直接贴代码导致读者在普通手机上跑通不了。这也是我选择 Device Owner 方案的根本原因免Root、权限路径清晰、行为可控。4. 桌面端Launcher过滤方案进阶4.1 不替换系统桌面的过滤思路如果你的需求不要求“全局隐藏”只要求“这个桌面上只显示指定应用”那最轻量化的方式就是自研一个简单的 Launcher或者修改开源的 Launcher3 源码。先说自研 Launcher 的思路。新建一个工程写一个 MainActivity在 AndroidManifest 里声明它为桌面intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.HOME / category android:nameandroid.intent.category.DEFAULT / /intent-filter然后在这个 Activity 里查询所有带桌面入口的应用根据自己的名单过滤后展示val intent Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) val resolveInfos packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL) val visibleApps resolveInfos.map { resolveInfo - val pkg resolveInfo.activityInfo.packageName if (whiteList.contains(pkg) || pkg packageName) resolveInfo else null }.filterNotNull()把visibleApps渲染到一个 GridView 或者 RecyclerView 里点击每一项时直接startActivity启动目标应用。这个方案不需要任何特殊权限也不需要处理系统级隐藏带来的各种边界问题。缺点也很明显用户只要在系统设置里切回原来的桌面隐藏就失效了。因此在个人隐私场景下这个方案只能算“轻量级迷惑”不能当真正安全措施用。4.2 修改 Launcher3 源码的过滤位置如果是定制 ROM修改 Launcher3 源码会更彻底因为系统默认桌面就是修改后的桌面用户根本不怎么会去切换桌面。关键位置在 Launcher3 的数据加载层。Launcher3 通过 LauncherAppState 加载应用信息最终会把所有 AppInfo 交给模型的AllAppsList。我改的时候直接在这个类的add方法里做了过滤public void add(AppInfo app) { if (mHiddenPackages.contains(app.componentName.getPackageName())) { return; } ... }同时还要处理桌面上已存在的快捷方式。因为即使应用被列表过滤了如果用户之前手动把它拖到了桌面工作区Launcher 启动时还是会从数据库恢复这个快捷方式。所以我还在 LauncherProvider 或 Shortcut 加载的地方做了一次包名剔除确保彻底消失。mHiddenPackages从哪里来可以直接从 Config 文件读取也可以做成本地数据库存一份让管理端通过广播或者 ContentProvider 下发。这一套对ROM开发者来说并不复杂主要精力都耗在数据处理时机上。4.3 三种方案怎么选我根据实际项目经验做了一个选择建议表项目情况推荐方案理由政企大货设备定制Device Owner DPM免Root、可批量下发、稳定个人低投入小范围使用Device Owner DPM一台手机一个App就能搞定ROM厂商出厂预装修改Launcher3或系统签名禁用组件无激活步骤用户无感知只想换桌面生效自研Launcher过滤开发量最小但不全局生效如果让我只给一个普适答案我会毫不犹豫推荐 Device Owner 方案。原因是用它调出来的系统行为最接近原生App隐藏后不只是图标消失连后台进程都会被强杀这是 Launcher 过滤方案永远做不到的。5. 兼容性、常见问题与避坑实录5.1 Android 11 包可见性限制从 Android 11 开始系统对应用可见性做了限制。普通应用直接调用getInstalledApplications()或queryIntentActivities()时只能看到自己、系统部分应用以及你通过queries声明过的包名。解决方案有两个一个是在 Manifest 里声明QUERY_ALL_PACKAGES权限拿到完整的应用列表另一个是在queries里逐个声明需要的包名。对于个人工具建议直接用前者简单粗暴如果打算上架 Google Play得注意这个权限属于敏感权限Google 会要求你说明用途企业工具倒是基本都能过审。5.2 隐藏系统关键应用会造成的连锁问题这是我踩过最大的坑。在做白名单模式时我一开始把所有“非白名单”应用全部隐藏结果设置、输入法、甚至当前 Launcher 都被隐藏了设备直接变成了PPT——屏幕上什么都点不了。后来我加了几条硬性规则所有关键系统包名必须始终在排除名单里。最少要排除当前的系统桌面Home Launcher当前的输入法com.android.settings设置自己这个管理App本身如果是定制设备还要排除设备IMEI查询、网络配置、远程监控等基础服务。隐藏输入法之后键盘不弹出来隐藏设置之后很多权限无从配置隐藏桌面之后更是直接变砖这些边界情况一旦触发恢复成本很高。5.3 系统应用被隐藏后返回 false 或恢复失败预装应用和部分系统级应用对setApplicationHidden()支持得并不好。有的应用被隐藏后调用恢复接口会返回false但在拨号盘再次输入暗码时又确实能恢复正常。这种情况一般跟系统的 DevicePolicyManager 对特定包名的策略有关没有统一的解决办法只能多测设备、多做异常兜底。我的做法是恢复时记录一份操作日志并且不依赖单次返回值而是恢复后都刷新一次状态。如果连续恢复失败就给用户弹Toast提示“该应用可能被系统保护无法通过DPM隐藏”。5.4 拨号暗码失效的排查路线这个问题的出现频率很高尤其是国产ROM。排查顺序从简单到复杂排查点操作是否授权电话权限到系统设置里确认 App 已获得“电话”权限Receiver 是否被系统回收设置里检查自启动权限把应用加入电池优化白名单暗码是否被拨号盘吃掉换一个以纯数字开头结尾的暗码比如#8899#广播是否被更高优先级拦截查看系统中其他App是否也注册了NEW_OUTGOING_CALL厂商ROM是否不支持改用自定义输入框或无障碍服务备路我项目里最终没有把宝押在拨号盘上主备双路并行实际交付后运维反馈稳定多了。5.5 常见问题速查表现象原因解决办法调setApplicationHidden抛 SecurityException不是 Device Owner检查isDeviceOwnerApp()重新激活设备所有者隐藏后图标还在桌面Launcher 缓存未刷新等待几秒或重启 Launcher恢复隐藏应用后无法打开应用被强制停止了正常点图标启动或通过startActivity拉起输入暗码后 Toast 不出现拨号广播没被接收到检查电话权限和自启动权限隐藏了设置/桌面导致设备不可用名单里没有过滤关键应用在 UI 层禁止勾选系统关键应用白名单模式下自己App也消失了差集计算里没排除自己把context.getPackageName()加入白名单最后一个隐藏问题如果设备已经激活了 Device Owner且 App 被卸载或者数据被清除原来被隐藏的应用不会自动恢复显示。因为系统里没有其他角色来“取消隐藏”。所以要在管理端做好备用入口最好在 Manager App 收到卸载广播之前主动恢复或者至少在文档里明确告知实施人员。我在实际部署这套方案的时候最深的体会是隐藏应用本身不是难点难的是你选定的权限模型能不能被目标设备接受。很多网上教程动不动就贴setComponentEnabledSetting但没告诉你它需要系统签名导致普通开发者调试半天全是 SecurityException。如果你的设备能做到 ADB 激活Device Owner 加setApplicationHidden()是目前性价比最高也最稳的路线。最后再分享一个实际经验暗码不要设置成太简单的数字比如#0000#、*#*#1234#*#*因为家里人误拨一次就可能触发隐藏/恢复切换后续排查还得花半天。暗码里插入一些不规则的符号或者使用较长组合比如*#*#889966#*#*这样既保持隐蔽性又能有效防止误触发。