深入 Android App Shortcuts 全链路:从 ShortcutManager 静态/动态配置到 Launcher 固定快捷方式的深度集成与 Compose 适配

做启动优化时发现了一个诡异问题:测试机上长按 App 图标弹出的快捷菜单时有时无。Logcat 里躺着一条「Shortcut exceeds max count」。排查后确认——我们注册了 6 个动态 + 4 个静态捷径,总计 10 个,而系统上限是 5。但问题只在 Android 8.0 上复现,高版本丝毫无恙。

这事促使我完整梳理了一遍 App Shortcuts 的注册、分发、固定全链路。

ShortcutManager 双通道:上限规则与优先级

捷径数量上限取决于 API 版本。ShortcutManager.getMaxShortcutCountPerActivity() 在 Android 8.0(API 26)上返回 5,静态和动态共享这个配额。从 Android 9 开始,上限提升到 15,但 Launcher 长按菜单只直接展示前 5 个,其余收进「更多」入口。

这就是那个 bug 的根因:8.0 总共只留 5 个位置,系统按 rank 值截断,低优先级直接丢弃。高版本换成 15 个容错空间大了不少,但部分定制 ROM(华为 EMUI、小米 MIUI)可能只渲染 4 个。一句话:捷径最终能展示多少个,裁量权在 Launcher,不在 ShortcutManager。

静态捷径在 XML 中声明:

<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
    <shortcut
        android:shortcutId="search"
        android:enabled="true"
        android:shortcutShortLabel="@string/search_short"
        android:icon="@drawable/ic_search_adaptive">
        <intent
            android:action="android.intent.action.VIEW"
            android:targetPackage="com.example.app"
            android:targetClass="com.example.app.SearchActivity" />
    </shortcut>
</shortcuts>

静态的优势是不需要冷启动,安装完立即出现在菜单里。硬伤是 icon 只能引用 drawable 资源,服务端下发的动态图标只能用动态捷径来承载。

动态捷径在运行时构建:

val shortcut = ShortcutInfo.Builder(this, "dynamic_search")
    .setShortLabel("搜索订单")
    .setLongLabel("搜索历史订单与运单")
    .setIcon(Icon.createWithAdaptiveBitmap(remoteBitmap))
    .setIntent(Intent(Intent.ACTION_VIEW, null, this, MainActivity::class.java))
    .setRank(0)
    .build()
shortcutManager.dynamicShortcuts = listOf(shortcut)

setRank() 控制排序,值越小越靠前。踩过的一个坑:多个业务方各自调用 addDynamicShortcuts(),rank 全设为 0,用户看到的顺序基本随机。后来统一由 ShortcutRepository 集中管理 rank 分配,按业务优先级 0、10、20 递增。

更新和替换的差别也容易搞混:addDynamicShortcuts() 追加,setDynamicShortcuts() 全量替换,updateShortcuts() 按 ID 增量更新。我的习惯是用 setDynamicShortcuts 每次覆盖,避免残留已下线的业务入口。

Intent 路由:双入口分发

捷径被点击后,Intent 送达路径取决于目标 Activity 的当前状态:

  • 在前台 → 走 onNewIntent()
  • 不在前台 → 走 onCreate()

这就意味着 handleIntent 的逻辑必须同时存在于两个位置:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        handleShortcutIntent(intent)
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        handleShortcutIntent(intent)
    }

    private fun handleShortcutIntent(intent: Intent?) {
        when (intent?.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)) {
            "search" -> navigateToSearch()
            "order_history" -> viewModel.loadOrderHistory()
        }
    }
}

自定义数据的传递有两个渠道:

  1. Intent.putExtra() —— 直接塞进 Intent extras,简单但会污染原始 Intent
  2. ShortcutInfo.Builder.setExtras(PersistableBundle) —— 数据持久化到系统服务,卸载时自动清除

我选第二种,配合 EXTRA_SHORTCUT_ID 做路由 key,PersistableBundle 里只放轻量参数,比如 {"tab": "pending"}

Pinned Shortcuts:用户主导的桌面固定

与静态/动态捷径不同,Pinned Shortcut 由用户主动操作——从捷径菜单拖拽到桌面,形成独立图标。捷径的 Intent 在此过程中被系统固化,后续内容变更需要显式更新。

触发固定需要两步:

if (ShortcutManagerCompat.isRequestPinShortcutSupported(this)) {
    val pinInfo = ShortcutInfoCompat.Builder(this, "pinned_order_9527")
        .setShortLabel("订单 #9527")
        .setIcon(IconCompat.createWithResource(this, R.drawable.ic_order))
        .setIntent(Intent(Intent.ACTION_VIEW, null, this, OrderActivity::class.java).apply {
            putExtra("order_id", "9527")
        })
        .build()
    ShortcutManagerCompat.requestPinShortcut(this, pinInfo, null)
}

系统会弹确认框,用户点击「添加」后桌面出现一个图标。这里有个关键行为:Intent 一旦固化,调用 updateShortcuts 时 Intent 保持不变——只有 label 和 icon 会被刷新。要修改跳转逻辑,唯一的办法是禁用旧捷径、重新创建。

还有个容易忽略的参数:requestPinShortcut 的第三个参数接受 IntentSender,用于接收成功/失败回调。不传的话 App 完全不知道用户点了确认还是取消——做产品埋点时我被这个空值坑过一次。

Pinned Shortcut 生命周期独立于动态捷径。即使调了 removeAllDynamicShortcuts(),桌面的固定图标依然有效。想清除必须调用 disableShortcuts(listOf("pinned_order_9527"))

Compose 场景的适配策略

Compose 不提供捷径管理的原生 API,底层只能走 ShortcutManager。真正需要适配的是两个点:图标生成和导航分发。

捷径图标在 Compose 中需要用 ImageBitmapBitmap

val imageBitmap = ImageBitmapConfig.Argb8888.toBitmap(
    painterResource(R.drawable.ic_shortcut), 96.dp, 96.dp
)
val icon = Icon.createWithAdaptiveBitmap(imageBitmap)

用 Coil 加载网络图片做动态图标技术上可行,但不建议频繁更新。每次 updateShortcuts 都会触发 Launcher 图标重绘,频率一高桌面滑动就掉帧。

导航分发的坑在于时机。handleIntent 中不能直接调 navController.navigate(),Compose 树可能尚未组合:

LaunchedEffect(Unit) {
    val shortcutId = activity.intent
        ?.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID)
    shortcutId?.let { viewModel.onShortcutTriggered(it) }
}

把导航指令交给 ViewModel,等 Compose 稳定后再消费,可以避免 IllegalStateException。另一个思路是把 navigation 封装成 Channel<Route>,由 LaunchedEffect 消费——适合需要按序导航的场景。


上线前我通常会做三件事:

  1. 跑一遍 adb shell dumpsys shortcut 导出所有捷径,逐条核对 id 和 rank 是否与预期一致
  2. 至少 3 台不同品牌的真机上验证 Launcher 行为,Pinned Shortcut 在不同 ROM 上的表现差异是重灾区
  3. 捷径图标用 Adaptive Icon 格式,核心区域 48dp × 48dp,否则部分 Launcher 会裁切边角内容

ShortcutManager 这套 API 六年来几乎没有大改,真正的变量一直是 Launcher 的碎片化实现。如果要做服务端下发的捷径配置,加个灰度开关比一步到位稳妥。