设备上的应用可能很多,但自定义 Launcher 的桌面只需要出现几个业务 App。要完成这个功能,核心不是"隐藏系统应用",也不是维护一份安装包列表,而是控制 Launcher 自己渲染哪些可启动入口。
本文只实现一条完整链路:
text
LauncherApps 获取桌面入口
↓
按 packageName / ComponentName 白名单匹配
↓
网格只渲染命中的入口
↓
startMainActivity 精确启动
↓
安装、卸载、启停变化后自动刷新
示例使用 Kotlin、Jetpack Compose 和 Android 公开 API;其中核心 LauncherApps 接口从 Android 5.0(API 21)可用,项目的实际 minSdk 还应满足所选 Compose 版本。最终效果很明确:白名单以外的 App 即使已经安装,也不会出现在这个 Launcher 的桌面上。
一、先确定过滤对象:不是安装包,而是桌面入口
一个常见错误是通过 PackageManager.getInstalledApplications() 取得所有安装包,再根据包名绘制图标。安装包与桌面入口并不是一回事:
- 一个包可能没有
MAIN + LAUNCHERActivity; - 一个包可能暴露多个桌面入口;
- Activity 可能被禁用,而 Application 仍然处于安装状态;
- 工作资料等不同用户空间中的入口还携带各自的
UserHandle。
自定义 Launcher 真正需要处理的是 LauncherActivityInfo。Android 提供的 LauncherApps 已经封装了 Launcher 场景需要的查询、图标、用户空间和启动能力:
kotlin
val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE)
as LauncherApps
val currentUser = Process.myUserHandle()
val activities: List<LauncherActivityInfo> =
launcherApps.getActivityList(null, currentUser)
getActivityList(null, currentUser) 返回当前用户空间中可作为桌面入口的 Activity。传入具体包名可以只查一个包,但白名单通常包含多个包,逐包查询会产生重复逻辑;一次取得候选集合,再在内存中匹配规则更直接。
白名单应同时支持两种粒度:
| 规则 | 含义 | 适用场景 |
|---|---|---|
只配置 packageName |
显示该包下全部有效桌面入口 | 一个包只有一个入口,或确实需要全部显示 |
配置 packageName + activityName |
只显示指定组件 | 一个包有多个入口,但桌面只允许其中一个 |
因此,规则模型不能只有一个 Set<String>。直接把组件粒度保留下来:
kotlin
data class AllowedTarget(
val packageName: String,
val activityName: String? = null,
val order: Int = 0
) {
fun matches(component: ComponentName): Boolean {
if (component.packageName != packageName) return false
if (activityName == null) return true
val normalizedName = if (activityName.startsWith(".")) {
packageName + activityName
} else {
activityName
}
return component.className == normalizedName
}
}
例如下面的规则表示:订单 App 允许其包下所有桌面入口,巡检 App 只允许 InspectionActivity,即使巡检包还声明了调试入口,也不会显示出来。
kotlin
val allowedTargets = listOf(
AllowedTarget(
packageName = "com.example.order",
order = 10
),
AllowedTarget(
packageName = "com.example.inspection",
activityName = ".InspectionActivity",
order = 20
)
)
这里的 order 是桌面顺序,不应该依赖系统查询结果的返回顺序。系统返回顺序没有业务语义,同一份白名单在不同设备上也不应随机换位。
二、把 Activity 声明为 HOME 候选项
桌面 Activity 只需要声明 MAIN + HOME + DEFAULT。同时,为 Android 11 及以上的包可见性过滤声明 MAIN + LAUNCHER 查询意图;它只让真正提供桌面入口的应用对查询可见,不需要申请 QUERY_ALL_PACKAGES。Android 12 及以上要求带 Intent Filter 的组件显式设置 android:exported:
xml
<manifest ...>
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application ...>
<activity
android:name=".ManagedLauncherActivity"
android:exported="true"
android:launchMode="singleTask"
android:stateNotNeeded="true">
<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>
</application>
</manifest>
安装后由用户在系统界面中选择这款应用作为默认桌面,按 Home 键才会回到 ManagedLauncherActivity。这段 Manifest 只负责让 Activity 成为 HOME 候选项;"桌面显示什么"仍完全由后面的查询和过滤代码决定。
如果应用还需要一个能从普通桌面打开的配置入口,建议另外声明一个设置 Activity,并给它配置 MAIN + LAUNCHER。不要为了提供设置入口,给 HOME Activity 混入另一组用途不同的 Intent Filter。
三、查询、过滤并生成唯一桌面条目
桌面最终消费的数据至少要包含精确组件、用户空间、标题、图标和排序值:
kotlin
data class LauncherEntry(
val component: ComponentName,
val user: UserHandle,
val label: String,
val icon: Drawable,
val order: Int
)
下面的 Repository 完成四件事:取得当前用户的桌面候选入口、查找对应白名单、校验组件当前是否真实可用、生成稳定排序后的不可变列表。
kotlin
class ManagedLauncherRepository(
context: Context,
private val rules: List<AllowedTarget>
) {
private val launcherApps =
context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
private val packageManager = context.packageManager
private val currentUser = Process.myUserHandle()
private val refreshMutex = Mutex()
private val _entries = MutableStateFlow<List<LauncherEntry>>(emptyList())
val entries: StateFlow<List<LauncherEntry>> = _entries.asStateFlow()
suspend fun refresh() = refreshMutex.withLock {
val newEntries = withContext(Dispatchers.IO) {
queryAllowedEntries()
}
_entries.value = newEntries
}
private fun queryAllowedEntries(): List<LauncherEntry> {
val candidates = runCatching {
launcherApps.getActivityList(null, currentUser)
}.getOrDefault(emptyList())
return candidates.mapNotNull { activity ->
val component = activity.componentName
val rule = rules.firstOrNull { it.matches(component) }
?: return@mapNotNull null
if (!isRealLauncherActivity(component)) return@mapNotNull null
LauncherEntry(
component = component,
user = activity.user,
label = activity.label?.toString()
?.takeIf { it.isNotBlank() }
?: rule.packageName,
icon = activity.getBadgedIcon(0),
order = rule.order
)
}
.distinctBy { it.component }
.sortedWith(
compareBy<LauncherEntry> { it.order }
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.label }
.thenBy { it.component.flattenToString() }
)
}
private fun isRealLauncherActivity(component: ComponentName): Boolean {
val launcherIntent = Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setPackage(component.packageName)
val resolveInfos = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.queryIntentActivities(
launcherIntent,
PackageManager.ResolveInfoFlags.of(0)
)
} else {
@Suppress("DEPRECATION")
packageManager.queryIntentActivities(launcherIntent, 0)
}
}.getOrDefault(emptyList())
val activityInfo = resolveInfos
.map { it.activityInfo }
.firstOrNull {
ComponentName(it.packageName, it.name) == component
} ?: return false
return activityInfo.enabled &&
activityInfo.applicationInfo.enabled &&
activityInfo.exported
}
}
这里有几个不能省略的细节。
第一,必须使用 ComponentName 去重,不能按应用标题去重。两个不同 App 可以使用相同标题,同一个包也可以存在多个标题相同的入口;标题只是展示文案,不是身份。
第二,包级规则和组件级规则的含义必须明确。activityName == null 时,同一个包的两个 Launcher Activity 会生成两个图标。这不是查询重复,而是规则明确允许整个包。如果产品只想保留一个图标,应把规则收紧到具体 Activity。
第三,匹配白名单之后仍然以 MAIN + LAUNCHER Intent 反查并校验 ActivityInfo。组件可能在白名单生成后被禁用,应用也可能正在更新或刚被卸载。过滤阶段将不可用入口剔除,点击阶段还会再次检查,避免界面快照与实时包状态之间出现竞态。
第四,Android 官方文档说明,从 Android 10 开始,系统在特定情况下可能为没有真实 Launcher Activity 的应用合成一个指向系统详情页的入口。上面的反查只接受确实声明了 MAIN + LAUNCHER 的同名组件,因此不会把合成详情页当成业务入口。组件级规则仍然是多入口 App 最严格的配置方式。
Mutex 的作用也很具体:安装、卸载和组件启停可能连续触发多个回调。如果多个 refresh() 并发执行,较早开始的查询反而可能最后写回,导致桌面短暂恢复旧列表。串行刷新可以消除这类覆盖。
四、Compose 网格只渲染过滤后的列表
UI 层不再接触安装包列表,也不再执行第二遍白名单判断。它只接受 Repository 输出的 LauncherEntry。这样可以保证"可见"和"可点击"使用同一份结果。
kotlin
@Composable
fun LauncherGrid(
entries: List<LauncherEntry>,
onEntryClick: (LauncherEntry) -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 88.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(
items = entries,
key = { it.component.flattenToString() }
) { entry ->
val bitmap = remember(entry.component, entry.icon) {
entry.icon.toBitmap().asImageBitmap()
}
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { onEntryClick(entry) }
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = bitmap,
contentDescription = entry.label,
modifier = Modifier.size(56.dp)
)
Spacer(Modifier.height(8.dp))
Text(
text = entry.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
图标使用 LauncherActivityInfo.getBadgedIcon() 获取,而不是通过包名重新调用 getApplicationIcon()。前者与具体桌面入口和用户空间对应,在存在工作资料等场景时也能保留系统角标语义。
items 的 key 同样使用组件名。若只使用列表下标,某个 App 卸载后,Compose 可能把后一个条目的已有状态复用到前一个位置;稳定组件 key 可以让条目增删与真实身份一致。
五、点击时精确启动白名单组件
查询使用 LauncherApps,启动也应继续使用 LauncherApps.startMainActivity(),不要再根据包名调用 getLaunchIntentForPackage()。后者只返回系统选择的一个默认入口,无法保证与用户点击的图标是同一个 Activity。
kotlin
private fun openEntry(entry: LauncherEntry) {
val enabled = runCatching {
launcherApps.isActivityEnabled(entry.component, entry.user)
}.getOrDefault(false)
if (!enabled) {
lifecycleScope.launch { repository.refresh() }
Toast.makeText(this, "应用入口已不可用", Toast.LENGTH_SHORT).show()
return
}
runCatching {
launcherApps.startMainActivity(
entry.component,
entry.user,
null,
null
)
}.onFailure {
lifecycleScope.launch { repository.refresh() }
Toast.makeText(this, "应用暂时无法打开", Toast.LENGTH_SHORT).show()
}
}
启动前的 isActivityEnabled() 不能替代过滤阶段的校验。列表生成后到用户点击前,目标应用可能被更新、卸载或禁用;点击前复查解决的是这段时间差。即使复查成功,启动动作仍可能因包状态继续变化而失败,所以 startMainActivity() 仍要处理异常并刷新桌面。
Activity 中只需要收集列表并把点击事件交给上面的函数:
kotlin
class ManagedLauncherActivity : ComponentActivity() {
private val allowedTargets = listOf(
AllowedTarget("com.example.order", order = 10),
AllowedTarget(
"com.example.inspection",
".InspectionActivity",
order = 20
)
)
private val repository by lazy {
ManagedLauncherRepository(this, allowedTargets)
}
private val launcherApps by lazy {
getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
}
private val packageObserver by lazy {
LauncherPackageObserver(launcherApps) {
lifecycleScope.launch { repository.refresh() }
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val entries by repository.entries.collectAsState()
LauncherGrid(entries, ::openEntry)
}
}
override fun onStart() {
super.onStart()
packageObserver.register()
lifecycleScope.launch { repository.refresh() }
}
override fun onStop() {
packageObserver.unregister()
super.onStop()
}
private fun openEntry(entry: LauncherEntry) {
// 使用上一段的启动实现
}
}
首次进入和每次回到前台都主动刷新一次。这样即使应用变化发生在 Launcher 停止监听期间,恢复显示时也不会继续使用旧列表。
六、监听安装、卸载和组件变化
如果只在 onCreate() 查询一次,安装新白名单 App 后必须重启 Launcher 才能看到图标,卸载 App 后旧图标也可能暂时残留。LauncherApps.Callback 正好提供 Launcher 所需的包变化通知。
kotlin
class LauncherPackageObserver(
private val launcherApps: LauncherApps,
private val onCurrentUserChanged: () -> Unit
) : LauncherApps.Callback() {
private val currentUser = Process.myUserHandle()
private val mainHandler = Handler(Looper.getMainLooper())
private var registered = false
fun register() {
if (registered) return
launcherApps.registerCallback(this, mainHandler)
registered = true
}
fun unregister() {
if (!registered) return
launcherApps.unregisterCallback(this)
registered = false
}
private fun refreshIfCurrentUser(user: UserHandle) {
if (user == currentUser) onCurrentUserChanged()
}
override fun onPackageAdded(packageName: String, user: UserHandle) {
refreshIfCurrentUser(user)
}
override fun onPackageRemoved(packageName: String, user: UserHandle) {
refreshIfCurrentUser(user)
}
override fun onPackageChanged(packageName: String, user: UserHandle) {
refreshIfCurrentUser(user)
}
override fun onPackagesAvailable(
packageNames: Array<out String>,
user: UserHandle,
replacing: Boolean
) {
refreshIfCurrentUser(user)
}
override fun onPackagesUnavailable(
packageNames: Array<out String>,
user: UserHandle,
replacing: Boolean
) {
refreshIfCurrentUser(user)
}
}
注册和反注册必须成对执行。示例选择 onStart() 与 onStop(),因为桌面不可见时没有必要持续重绘;重新进入 onStart() 后又会主动查询,期间的变化不会丢失。
回调中不需要根据 packageName 手工增删现有列表。包更新可能同时改变 Activity 数量、启用状态、名称和图标,局部修改很容易留下旧数据。回调只发出"重新生成快照"的信号,由 Repository 从系统状态完整重建列表,逻辑更可靠。
如果业务只处理当前用户空间,就像示例一样检查 UserHandle。不要收到工作资料的回调后,错误地刷新或启动主用户空间中的同名组件。
七、验收只围绕"显示"和"启动"
这项功能不需要一张覆盖所有设备管控能力的大测试表。围绕白名单显示闭环,至少检查以下情况:
| 场景 | 预期结果 |
|---|---|
| 已安装 App 不在白名单 | 桌面不显示 |
| 白名单包尚未安装 | 桌面不生成空图标 |
| 包级规则命中一个单入口 App | 显示一个图标,点击进入该入口 |
| 包级规则命中一个多入口 App | 显示全部有效入口 |
| 组件级规则命中多入口 App 中的一个 Activity | 只显示指定 Activity |
| 白名单 App 安装完成 | 回调后自动出现 |
| 白名单 App 被卸载或组件被禁用 | 回调或前台刷新后消失 |
| App 在显示后、点击前被卸载 | 不崩溃,提示不可用并刷新列表 |
| 两个 App 使用相同标题 | 两个图标都保留,点击进入各自组件 |
| 白名单顺序固定 | 重启 Launcher、更新 App 后顺序仍一致 |
测试时还要特别准备一个包含两个 MAIN + LAUNCHER Activity 的样例包。很多实现只在"一个包只有一个入口"的环境中验证,看不出包级白名单与组件级白名单的区别,一旦真实业务包增加第二个入口,桌面就会多出不应展示的图标。
结论
自定义 Launcher 只显示指定 App,完整实现只有五个关键点:
- 使用
LauncherApps.getActivityList()获取真正的桌面入口,而不是遍历安装包; - 白名单同时支持包名和组件名,明确"允许整个包"与"只允许一个入口"的差异;
- 过滤后生成以
ComponentName为唯一身份的稳定列表,UI 只渲染这份列表; - 点击时通过
LauncherApps.startMainActivity()启动原始组件,并在启动前复查状态; - 使用
LauncherApps.Callback监听应用变化,重新生成桌面快照。
这条链路闭合以后,设备安装了多少其他 App 不重要:只要入口没有命中白名单,就不会出现在这个自定义 Launcher 的桌面上。