尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android自研桌面:用Launcher实现应用隐藏与暗码唤出全解析

Android自研桌面:用Launcher实现应用隐藏与暗码唤出全解析 最近有朋友问我一道题能不能像某些定制系统那样把一部分应用藏起来桌面上一看就像没装过一样只有自己知道用什么暗号能把它们全调出来。我第一反应是“这不就是自定义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.xGradle 8.2 以上JDK 17compileSdk 34 低于这个没法适配 Android 14 上的一些行为变更如果只想做个内部自用工具compileSdk 33 也够用。但建议还是往新版本靠因为 Android 14 对前台服务类型、隐式 Intent 的限制都更严格旧版本编译出来的包在很多新设备上适配起来很麻烦。2.2 创建项目并配置“桌面应用”声明新建一个空 Activity 项目之后最关键的工程配置是让这个应用成为可选的桌面。在AndroidManifest.xml中给主页 Activity 增加HOME和DEFAULT的 Intent Filteractivity android:name.ui.HomeActivity android:exportedtrue android:launchModesingleTask intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.HOME / category android:nameandroid.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:nameandroid.permission.QUERY_ALL_PACKAGES / uses-permission android:nameandroid.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS /QUERY_ALL_PACKAGES是 Android 11 之后必须声明的。从 Android 11 开始系统默认的“包可见性”机制会让应用查不到其他已安装应用如果不加这个权限queryIntentActivities返回的列表永远是空的。第二个权限是为了让桌面在后台长期存活避免被省电策略回收后面排查问题部分还会细说。3. 应用列表获取与隐藏状态管理3.1 用 PackageManager 读取所有可启动应用读取应用列表是整个项目的地基。这里不能直接拿getInstalledApplications()因为有些应用虽然安装了但并没有桌面启动入口比如纯后台服务型应用把这种应用藏起来没有意义用户在系统桌面上本来就看不到它。正确的姿势是查询带MAIN和LAUNCHER的 Intentval 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存SetString操作简单、读取快、不需要额外依赖。封装成一个轻量的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(): SetString prefs.getStringSet(KEY_HIDDEN_PACKAGES, emptySet()) ?: emptySet() fun setHiddenPackages(items: SetString) { 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 MutableLiveDataBoolean() fun updateHiddenSettings(newSet: SetString) { HiddenRepository.setHiddenPackages(newSet) hiddenChanged.value true }这种解耦方式比较干净桌面自己不直接操作数据源只响应变化事件。后续如果要在桌面里直接长按隐藏某个应用也只要调同一套数据层逻辑。4. 桌面主页的渲染与交互细节4.1 主界面结构与应用图标加载桌面主界面用 RecyclerView 网格布局分页展示即可。每个 item 就两个元素图标和名称。点击是启动应用长按是弹出操作菜单隐藏/取消隐藏/查看应用详情。图标获取的时候有一个性能问题如果在onBindViewHolder里同步调用packageManager.getApplicationIcon(pkg)滚动时会有明显卡顿因为图标解析是耗时操作。我的做法是预先把图标缓存在AppItem的内存列表里首次加载后再直接用内存数据。如果应用很多还可以用 Glide 的本地加载能力做磁盘缓存不过一般桌面应用数量就几十个内存缓存已经足够。4.2 过滤逻辑怎么实现“指定应用显示”桌面主页要展示的列表由三个条件共同决定该应用不在隐藏名单中。或者当前处于“暗码临时显示全部”状态。或者用户明确取消了对该应用的隐藏。显示列表的过滤函数写出来后非常直观fun getVisibleApps( allApps: ListAppItem, hiddenSet: SetString, showAllTemporarily: Boolean ): ListAppItem { 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.findViewByIdTextView(R.id.tv_code) val keyboardContainer inputView.findViewByIdLinearLayout(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.findViewByIdTextView(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 临时显示全部应用的逻辑实现暗码输入正确后进入“临时显示全部”模式。我用一个MutableStateFlowBoolean来跟踪这个状态因为桌面可能同时有多个地方需要感知变化列表刷新、UI 提示、倒计时自动退出。private val _showAllMode MutableStateFlow(false) val showAllMode: StateFlowBoolean _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_PACKAGESqueryIntentActivities返回的结果基本是空的。建议在开发调试阶段就把权限加好同时注意如果应用要上架 Google PlayQUERY_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 天安装的新应用展示在面板顶部——这对一款自研桌面来说体验感会拔高不少。
返回列表