最近有朋友问我一道题:能不能像某些定制系统那样,把一部分应用藏起来,桌面上一看就像没装过一样,只有自己知道用什么暗号能把它们全调出来。我第一反应是“这不就是自定义Launcher嘛”,但真动手之后发现坑一个接一个:系统桌面的图标不是第三方应用能随便拿走的,要藏只能换桌面;暗码触发怎么做才顺手,也跟很多人设想的“拨号盘输入暗码”不是一回事。这篇就以我实际做过的方案为例,把从获取应用列表、管理隐藏名单、桌面渲染到暗码唤出的全链路拆开讲清楚,希望对准备做类似工具的场景有点帮助。
1. 项目整体设计与技术选型
1.1 需求拆解:隐藏应用有哪些常规路线
标题里可以看到两个核心诉求:一是把指定应用从桌面藏起来,二是通过暗码输入临时显示全部应用。拆开看其实就是三件事:应用列表的获取、隐藏名单的维护、以及一套动态刷新桌面的机制。
理论上,想在Android上实现“隐藏图标”效果,有几种思路:
走系统级API直接隐藏图标。Android并没有公开给第三方应用调用、去移除系统桌面上某个图标的能力,只有系统应用或具备系统签名的应用才能碰系统桌面。普通应用哪怕申请了一堆权限,也拿不到系统Launcher的图标控制权。
用DevicePolicyManager做设备所有者。这种方式在某些定制ROM上确实有隐藏包名或禁用应用的API,但它依赖设备管理员等强权限,普通用户根本不敢把设备管理权交给一个第三方工具,而且各厂商ROM对这个能力限制很严格,不是通用方案。
自研桌面Launcher。让用户把默认桌面切换成我们写的Launcher,由我们自己决定桌面上显示哪些应用、哪些应用被隐藏、暗码输入后是否临时显示。这个方案完全可行,不依赖系统私有权限,逻辑闭环也清晰。
三种方案对比如下:
| 方案 | 权限要求 | 通用性 | 实现成本 | 风险 |
|---|---|---|---|---|
| 系统级API隐藏图标 | 系统签名/系统应用 | 极低 | 极高 | 厂商限制多 |
| DevicePolicyManager | 设备管理员/Device Owner | 低 | 高 | 用户信任成本高 |
| 自研Launcher | 仅需QUERY_ALL_PACKAGES等常规权限 | 高 | 中 | 需要用户手动设为默认桌面 |
标题里的“桌面应用”这个词提示得很明白:目标场景就是要把“桌面”这个入口抓在自己手里,所以自研Launcher必然是第一选择。
1.2 为什么最终选择自研轻量级桌面
真正动手之前,我也犹豫过要不要做一个“隐藏应用抽屉”,也就是用户打开工具App,在里面输入暗码之后看到所有应用。但这个方案有个硬伤:系统桌面还挂在外面,被隐藏的应用照样在系统桌面显示,根本没有达到“藏起来像没装过”的效果。
所以最终方案锁定为:做一个轻量级桌面,用户按下Home键后选择我们作为默认桌面,我们通过过滤逻辑决定哪些应用能显示在桌面上,哪些不能。隐藏名单变更或暗码触发后,桌面立即刷新。
这样做还有一个隐藏的好处:桌面内的事件分发完全可控。比如长按空白处弹暗码输入框、长按应用图标快捷隐藏/取消隐藏,这些交互都不需要依赖外部应用,代码结构简单,也方便后续扩展。
1.3 暗码触发:拨号盘的坑与正确设计思路
“暗码输入显示所有应用”这个功能,第一反应可能是模仿早期安卓的拨号盘暗码,比如在拨号界面输入一串*#*#xxxx#*#*之后触发某种行为。但这里有个现实的限制:普通第三方应用监听不到系统拨号盘的按键输入,系统拨号盘不向外部应用开放按键事件接口,想监听只能自己做一个输入法或系统级拨号应用,工程量完全失控。
退一步讲,“暗码输入”本质上是“一个只有你知道的开关”,不一定非要做在系统拨号盘里。既然我们自己是桌面应用,完全可以在桌面内部做一个暗码入口:
- 在桌面空白处长按弹出数字键盘,输入预设暗码后触发临时显示。
- 在桌面上放置一个伪装图标,点进去后要求输入暗码。
- 在桌面顶部连点N次版本号,弹出暗码输入框。
这三种方式都能实现“暗码触发”,而且逻辑完全在自家应用内闭环。比起硬攻系统拨号盘,这条路又稳又简单。
2. 开发环境准备与工程搭建
2.1 Android Studio 环境与 SDK 版本的选择
写这个项目之前,先把环境踩了一遍。Android Studio 最新稳定版一般都会自带合适的 Gradle 和 AGP 版本,但很多人在下载安装阶段就卡住了,常见误区是 Gradle 版本、JDK 版本和 AGP 三方对不上。
我在环境配置上用的是现在比较稳的组合:
- Android Studio 最新稳定版(对应 AGP 8.x)
- Gradle 8.2 以上
- JDK 17
- compileSdk 34 (低于这个没法适配 Android 14 上的一些行为变更)
如果只想做个内部自用工具,compileSdk 33 也够用。但建议还是往新版本靠,因为 Android 14 对前台服务类型、隐式 Intent 的限制都更严格,旧版本编译出来的包在很多新设备上适配起来很麻烦。
2.2 创建项目并配置“桌面应用”声明
新建一个空 Activity 项目之后,最关键的工程配置是:让这个应用成为可选的桌面。
在AndroidManifest.xml中给主页 Activity 增加HOME和DEFAULT的 Intent Filter:
<activity android:name=".ui.HomeActivity" android:exported="true" android:launchMode="singleTask"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.HOME" /> <category android:name="android.intent.category.DEFAULT" /> </intent-filter> </activity>注意两个 Intent Filter 都要保留:带LAUNCHER的保证应用有入口图标,带HOME的保证系统在用户按 Home 键时能识别出“这是一个桌面”。
还有一个细节:启动模式最好设置成singleTask,否则桌面被用户频繁切换回来时会创建多个实例,导致状态混乱。
2.3 核心依赖与权限声明
这个项目不依赖重框架,用系统自带的 PackageManager、RecyclerView 就够了。依赖方面我精简到最少:
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'权限上有两个点要提前加上:
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />QUERY_ALL_PACKAGES是 Android 11 之后必须声明的。从 Android 11 开始,系统默认的“包可见性”机制会让应用查不到其他已安装应用,如果不加这个权限,queryIntentActivities返回的列表永远是空的。第二个权限是为了让桌面在后台长期存活,避免被省电策略回收,后面排查问题部分还会细说。
3. 应用列表获取与隐藏状态管理
3.1 用 PackageManager 读取所有可启动应用
读取应用列表是整个项目的地基。这里不能直接拿getInstalledApplications(),因为有些应用虽然安装了,但并没有桌面启动入口,比如纯后台服务型应用,把这种应用藏起来没有意义,用户在系统桌面上本来就看不到它。
正确的姿势是查询带MAIN和LAUNCHER的 Intent:
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) val resolveInfos = packageManager.queryIntentActivities(intent, 0) val apps = resolveInfos.map { resolveInfo -> val pkg = resolveInfo.activityInfo.packageName val name = resolveInfo.loadLabel(packageManager).toString() val icon = resolveInfo.loadIcon(packageManager) AppItem(packageName = pkg, name = name, icon = icon) }很多新手在这里掉坑:拿到的resolveInfo.activityInfo不是每个字段都能直接用。比如loadLabel和loadIcon这两个方法其实封装好了,直接用即可,不要自己去拼应用名。
此外记得过滤掉自己。不然用户把你这个桌面应用从隐藏名单里勾掉之后,入口直接消失,别人想打开设置都没地方点:
val selfPkg = context.packageName apps = apps.filterNot { it.packageName == selfPkg }3.2 隐藏名单的存储方案:SharedPreferences 还是 DataStore
隐藏名单的数据量很小,就是一组包名,我的做法是直接用SharedPreferences存Set<String>,操作简单、读取快、不需要额外依赖。
封装成一个轻量的HiddenRepository单例,所有页面共用:
object HiddenRepository { private const val PREFS_NAME = "hidden_config" private const val KEY_HIDDEN_PACKAGES = "hidden_packages" private const val KEY_ACCESS_CODE = "access_code" private const val DEFAULT_ACCESS_CODE = "1024" private val prefs: SharedPreferences by lazy { AppContext.get().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } fun getHiddenPackages(): Set<String> = prefs.getStringSet(KEY_HIDDEN_PACKAGES, emptySet()) ?: emptySet() fun setHiddenPackages(items: Set<String>) { prefs.edit().putStringSet(KEY_HIDDEN_PACKAGES, items).apply() } fun isHidden(pkg: String): Boolean = pkg in getHiddenPackages() fun getAccessCode(): String = prefs.getString(KEY_ACCESS_CODE, DEFAULT_ACCESS_CODE) ?: DEFAULT_ACCESS_CODE fun setAccessCode(code: String) { prefs.edit().putString(KEY_ACCESS_CODE, code).apply() } }StringSet在 SharedPreferences 中有个需要注意的点:它返回的是一份拷贝,修改后再写入时必须构建一个新的Set,不能直接修改原对象。因为它内部存的是引用,直接改会抛异常或者不生效。
3.3 设置页与隐藏列表管理
设置页需要一个完整应用列表,外加勾选框。我的做法是直接用上一步的queryIntentActivities拿到全量列表,底部放在一个AlertDialog或者单独 Activity 里展示,每一行显示图标、应用名和 Switch 开关。
保存逻辑要特别小心:用户勾选的语义是“隐藏”,而不是“显示”。也就是说,Switch 开 = 加入隐藏名单,关 = 从隐藏名单移除。这个方向不要搞反,否则桌面会瞬间清空。
保存完成后,需要一个刷新桌面的机制。我用的是 LiveData 事件:设置页保存后调用一次HiddenRepository,同时HomeActivity注册观察者,监听到隐藏名单变化后重新加载并刷新页面的应用列表。
val hiddenChanged = MutableLiveData<Boolean>() fun updateHiddenSettings(newSet: Set<String>) { HiddenRepository.setHiddenPackages(newSet) hiddenChanged.value = true }这种解耦方式比较干净,桌面自己不直接操作数据源,只响应变化事件。后续如果要在桌面里直接长按隐藏某个应用,也只要调同一套数据层逻辑。
4. 桌面主页的渲染与交互细节
4.1 主界面结构与应用图标加载
桌面主界面用 RecyclerView 网格布局分页展示即可。每个 item 就两个元素:图标和名称。点击是启动应用,长按是弹出操作菜单(隐藏/取消隐藏/查看应用详情)。
图标获取的时候有一个性能问题:如果在onBindViewHolder里同步调用packageManager.getApplicationIcon(pkg),滚动时会有明显卡顿,因为图标解析是耗时操作。我的做法是预先把图标缓存在AppItem的内存列表里,首次加载后再直接用内存数据。如果应用很多,还可以用 Glide 的本地加载能力做磁盘缓存,不过一般桌面应用数量就几十个,内存缓存已经足够。
4.2 过滤逻辑:怎么实现“指定应用显示”
桌面主页要展示的列表,由三个条件共同决定:
- 该应用不在隐藏名单中。
- 或者当前处于“暗码临时显示全部”状态。
- 或者用户明确取消了对该应用的隐藏。
显示列表的过滤函数写出来后非常直观:
fun getVisibleApps( allApps: List<AppItem>, hiddenSet: Set<String>, showAllTemporarily: Boolean ): List<AppItem> { return if (showAllTemporarily) { allApps } else { allApps.filterNot { it.packageName in hiddenSet } } }showAllTemporarily这个状态是核心。暗码触发时把它置为true,桌面重新刷新,隐藏应用全部显示;等临时显示超时或用户手动退出后,置回false,桌面恢复过滤后的列表。
这个设计有一个很大的好处:临时显示不修改隐藏名单数据源。用户看完了,超时后自动恢复隐藏状态,不会误操作导致隐藏名单被清空。
4.3 点击启动应用与常见 Intent 坑
点击应用图标的逻辑看起来简单,但实际上有几个坑。
fun launchApp(packageName: String) { val launchIntent = packageManager.getLaunchIntentForPackage(packageName) if (launchIntent != null) { launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(launchIntent) } }这里的FLAG_ACTIVITY_NEW_TASK是必须的。因为桌面 Activity 本身可能不在任务栈顶部,如果直接startActivity,应用可能不会正常进入前台。另外getLaunchIntentForPackage返回的 Intent 不一定永远有效,比如用户在系统设置里把某个应用的启动 Activity 禁用了,这时候就要做空判断,并且在 UI 上提示用户。
桌面的长按菜单我做了三个选项:设为隐藏、取消隐藏、应用信息。第三个可以直接调系统设置里的应用详情页:
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) intent.data = Uri.parse("package:$packageName") startActivity(intent)这个入口对排障特别有用,有时候隐藏列表里的包名在系统里已经被清理掉了,用户可以直接去设置页看实际情况。
5. 暗码输入与临时显示所有应用
5.1 暗码入口的实现方式
暗码入口放得太明显,就失去“暗码”的意义;放得太隐蔽,自己都找不到也不行。我的方案是:桌面空白处长按,弹出一个专属数字键盘的 Dialog。
为什么不用系统软键盘?因为系统软键盘会挡住 Dialog 的一部分,体验很怪;而且这个场景本质上是纯数字校验,自定义一个数字键盘布局反而更简洁、更安全,不会误触系统键盘的联想词。
弹窗打开时的校验逻辑:
fun showAccessCodeDialog() { val inputView = layoutInflater.inflate(R.layout.dialog_access_code, null) val codeInput = inputView.findViewById<TextView>(R.id.tv_code) val keyboardContainer = inputView.findViewById<LinearLayout>(R.id.keyboard_container) val dialog = AlertDialog.Builder(this) .setView(inputView) .create() // 数字键盘按钮绑定 keyboardContainer.setOnClickListener { v -> if (v is TextView) { val key = v.text.toString() codeInput.append(key) } } inputView.findViewById<TextView>(R.id.btn_confirm).setOnClickListener { val inputCode = codeInput.text.toString() if (inputCode == HiddenRepository.getAccessCode()) { enterShowAllMode() dialog.dismiss() } else { toast("暗码错误") } } dialog.show() }这里有个经验点:Dialog 的宽高要设置成贴近手机宽度,因为默认 Dialog 在宽屏下会显得很窄,数字键盘按钮挤压变形。给 Dialog 的自定义 View 根布局设置宽高match_parent且水平方向约束一下就可以了。
5.2 临时显示全部应用的逻辑实现
暗码输入正确后,进入“临时显示全部”模式。我用一个MutableStateFlow<Boolean>来跟踪这个状态,因为桌面可能同时有多个地方需要感知变化(列表刷新、UI 提示、倒计时自动退出)。
private val _showAllMode = MutableStateFlow(false) val showAllMode: StateFlow<Boolean> = _showAllMode fun enterShowAllMode() { viewModelScope.launch { _showAllMode.value = true delay(30_000) _showAllMode.value = false } }超时时间我设置成 30 秒。这个时长刚好够用户点开被隐藏的应用查看情况,又不会长到让别人在旁边翻一遍都能看到。临时显示期间,主页顶部会有一条“隐藏模式已解除,30秒后恢复”的提示条,用户也可以点击“立即恢复”按钮手动退出。
5.3 暗码修改与双重校验
暗码不能写死,否则用户过两天换个密码习惯就没法用了。我在设置页里放了一个“修改暗码”入口,需要先验证旧暗码,才能设置新暗码:
fun verifyAndChangeCode(oldCode: String, newCode: String): Boolean { if (oldCode != HiddenRepository.getAccessCode()) { return false } if (newCode.length < 4) { return false } HiddenRepository.setAccessCode(newCode) return true }新暗码长度至少 4 位,太短太容易被试出来。不要用纯生日、连续数字这类简单序列,虽然只是个本地工具,但既然是“暗码”,信任边界还是越清楚越好。
6. 常见问题与踩坑记录
6.1 Android 11 以上查不到应用列表
这是这个项目里最容易踩的坑。Android 11 开始,系统引入了包可见性策略,默认情况下第三方应用查不到大多数其他安装的应用。如果 manifest 里漏掉QUERY_ALL_PACKAGES,queryIntentActivities返回的结果基本是空的。
建议在开发调试阶段就把权限加好,同时注意:如果应用要上架 Google Play,QUERY_ALL_PACKAGES是敏感权限,必须填写明确的用途说明并经过审核,内部自用则无所谓。如果不想申请这个高权限,可以在 manifest 里用<queries>列出需要查询的包名,但本项目是动态桌面,列不全所有包名,所以还是老老实实用全量权限。
6.2 桌面被系统回收,Home 键回到系统桌面
作为桌面应用,被系统杀死的概率比普通应用低很多,但如果在最近任务里手动划掉,或者厂商 ROM 省电策略激进,还是可能被杀掉。
解决办法分两层。第一层在代码里,onTaskRemoved时主动发广播拉起自己:
override fun onTaskRemoved(rootIntent: Intent?) { val restartIntent = Intent(this, HomeActivity::class.java) restartIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(restartIntent) super.onTaskRemoved(rootIntent) }但注意:Android 10 之后后台弹 Activity 的限制越来越严,这个方法不是百分百生效。真正可靠的还是引导用户做两件事:第一,把应用从最近任务里锁定;第二,在系统设置的“电池优化”里选择“不优化”或“允许后台活动”。所以在设置页里最好放一个“设为默认桌面”和“保持后台运行”的引导入口。
6.3 预装应用和系统应用误隐藏
有些系统应用(比如搜索、联系人、电话)没有桌面图标,但它们确实在 query 结果里。如果用户把系统应用也勾选了隐藏,会导致部分系统功能入口消失,把锅甩到桌面上。
我的建议是在隐藏列表管理页做一个“仅显示第三方应用”的筛选开关。默认情况下,系统应用不参与隐藏列表。判断逻辑很简单:
val isSystemApp = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0注意有些厂商 ROM 会把淘宝、微信这类预装应用打成系统应用,这个判断并不能完美区分“用户能不能卸载”,只能作为一个筛选维度,功能设计上提醒用户“隐藏系统应用需谨慎”即可。
6.4 图标缓存和空界面问题
首次加载应用列表时,如果没有任何显示缓存,桌面会看到一个空白页。我加了一个轻量加载态,用内置的“正在初始化桌面”占位来填充,避免用户误以为应用卡死。具体做法是创建一个AppLoadingAdapter,在首次数据加载完成前显示一个带进度条的单行 item,数据到位后自动切换为真正的应用列表。
另外,桌面加载最好放到子线程。虽然queryIntentActivities在多数设备上很快,但在低端机上仍然可能出现几十毫秒到几百毫秒的阻塞。我这边用Dispatchers.IO加载数据,加载完成后切回主线程刷新 RecyclerView,能够明显提升桌面切换回来的速度。
写在最后的一点经验
把这个项目完整跑通后,我的结论是:“隐藏桌面应用”这件事,硬攻系统桌面是攻不下来的,最优雅的解法是让用户把默认桌面换成你写的桌面,再由你决定哪些图标出现、哪些消失。暗码触发也一样,别再纠结系统拨号盘能不能监听,在自家入口里做一套数字键盘校验,既满足需求又没额外风险,逻辑上还更可控。
如果只是想练手,这个项目非常适合入门。它用到的 PackageManager、Intent Filter、RecyclerView、LiveData/Flow 都是 Android 日常开发的核心技能,而且把“数据获取—状态管理—动态刷新—场景化交互”这条完整链路串起来了,做完之后对应用与应用之间的关系理解会提升一个档次。要是给产品加一点实用性,可以把桌面主页做成分页缩略图,或者加一个“最近安装”分区,把最近 7 天安装的新应用展示在面板顶部——这对一款自研桌面来说,体验感会拔高不少。