Android 设备管控开发实战:自定义 Launcher 如何只显示指定 App?

设备上的应用可能很多,但自定义 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 + LAUNCHER Activity;
  • 一个包可能暴露多个桌面入口;
  • 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,完整实现只有五个关键点:

  1. 使用 LauncherApps.getActivityList() 获取真正的桌面入口,而不是遍历安装包;
  2. 白名单同时支持包名和组件名,明确"允许整个包"与"只允许一个入口"的差异;
  3. 过滤后生成以 ComponentName 为唯一身份的稳定列表,UI 只渲染这份列表;
  4. 点击时通过 LauncherApps.startMainActivity() 启动原始组件,并在启动前复查状态;
  5. 使用 LauncherApps.Callback 监听应用变化,重新生成桌面快照。

这条链路闭合以后,设备安装了多少其他 App 不重要:只要入口没有命中白名单,就不会出现在这个自定义 Launcher 的桌面上。

参考资料

相关推荐
Dovis(誓平步青云)34 分钟前
模拟器横评:电脑上看小说、追短剧用什么模拟器?MuMu、雷电、腾讯手游助手实测
android·java·服务器·前端·javascript·电脑
非凡ghost1 小时前
用 Kotlin 和 Jetpack Compose 重写的安卓视频播放器,原生体验有多流畅?
android·java·kotlin·音视频·软件需求
峥嵘life1 小时前
Android16 系统 APEX 模块调试总结
android·大数据·开发语言
亘元有量-流量变现1 小时前
2026安卓ASO增量攻略:摆脱iOS思维依赖,吃透多商店自然免费流量
android·aso优化·亘元有量·方糖试玩
一航jason2 小时前
安卓车机端 AIOS 技术生态全景
android·人工智能·ai·ai编程·ai-native
深念Y2 小时前
ZTE B860AV1.1-T2 机顶盒改造记录:从 Android 到 Linux 服务器
android·linux·运维·服务器·boot·cma·机顶盒
淡淡的香烟3 小时前
Android中tcp的通信
android·物联网·tcp/ip
Escalating_xu4 小时前
【C++入门基础(下)】默认参数、函数重载、引用、inline 与 nullptr
android·c++·redis
wen_zhufeng15 小时前
用 vLLM 加速 TTS 推理:通用改造指南
android·vllm