Jetpack Glance 的 App Widget,Compose 写桌面组件
「Jetpack Glance 的 App Widget,Compose 写桌面组件」
去年把手里一个天气应用的老旧 Widget 从 RemoteViews 往 Glance 迁移时,我原以为这会是一次轻松的语法替换。毕竟官方文档里的示例看起来极其直白:继承一个 GlanceAppWidget,覆写 Content(),返回一个类似 Compose 的 UI 树,再用 GlanceAppWidgetReceiver 换掉原来的 AppWidgetProvider。整套流程读下来不超过五分钟。但实际把 androidx.glance:glance-appwidget:1.0.0 拉进项目后,才发现 Glance 不是简单的“Compose 语法写 Widget”,它更像是一层精心裁剪过的中间表示,底层仍然卡在 RemoteViews 的能力边界里。这篇文章记录我踩过的几个硬坑,以及 Glance 在真实项目里到底能走多远。
`Content()` 的糖衣:这不是真 Compose
刚上手时最容易产生的错觉,就是以为 Glance 直接把 Jetpack Compose 的 runtime 搬到了 App Widget 里。我一开始也是这么想的,甚至在 Content() 里随手写了个 remember { mutableStateOf(false) },编译器立刻报了错。Glance 提供的是 GlanceModifier,不是 Modifier;布局容器叫 Column 和 Row,但它们位于 androidx.glance.layout 包下,接受的是 GlanceModifier.padding() 而不是 Modifier.padding()。这说明整个 UI 描述层是被重新实现的,只是 API 设计向 Compose 看齐。
GlanceAppWidget 的核心是一个无参的 @Composable fun Content(),但这里Composable的含义被大幅收窄。你不能用 LaunchedEffect、DisposableEffect、rememberSaveable,也没有 SideEffect。背后的原因很简单:Widget 的进程边界由 AppWidgetService 控制,你的 UI 最终要被序列化成一组 RemoteViews 指令发给 Launcher。任何需要持续挂载的副作用在 Widget 生命周期里都不成立。我花了不少时间才戒掉在 Content() 里写标准 Compose 代码的习惯,转而用 remember { } 搭配 GlanceStateDefinition 来管理本地状态。
一个具体的代码片段能说明这种割裂。假设你要做一个带刷新按钮的时钟 Widget,旧 RemoteViews 的做法是在 onUpdate 里构造 RemoteViews 实例并设置 setOnClickPendingIntent。而在 Glance 里,按钮的点击要路由到 ActionCallback:
class RefreshAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
// 这里不是主线程
val newData = fetchWeather()
updateAppWidgetState(context, glanceId) { state ->
state[weatherKey] = newData
}
MyAppWidget().update(context, glanceId)
}
}注意 onAction 是 suspend 函数。这意味着你在回调里可以直接挂起做网络请求,看似比旧时代的 PendingIntent 转 BroadcastReceiver 再启 Service 要优雅得多。但这里埋着一个版本陷阱:如果你的目标设备跑在 Android 12(API 31)以上,后台启动限制会让 updateAppWidgetState 的调用时机变得很微妙。当用户点击按钮时,系统允许你的 Receiver 短暂运行,但这个窗口期并不长。如果 fetchWeather() 耗时超过几秒,后续的状态更新可能不会被系统渲染到屏幕上。我在 Pixel 7(Android 14)上测试时发现,超过约 5 秒的网络响应后,MyAppWidget().update() 虽然执行了,但 Launcher 端的 Widget 并未刷新,logcat 里也没有明显报错。这不是 Glance 独有的问题,而是整个 App Widget 后台执行模型收紧后的结果,但 Glance 的 suspend 语法太容易让人误以为有充裕的执行时间。
ActionCallback 的上下文陷阱
ActionCallback 的 onAction 接收的 context 参数类型是 Context,但它并不是你在 Activity 或 Fragment 里熟悉的那个 Activity 上下文,而是一个经过包装的 Context,更接近 ApplicationContext 的行为。我最初想在里面弹一个 Toast 做调试,直接调了 Toast.makeText(context, ...),结果在部分国产 ROM 上 Toast 根本没出现。后来才意识到这里不能做任何与 UI 层级强耦合的事,因为 Widget 的点击事件处理发生在你的应用进程里,却不在任何可见的 Activity 任务栈中。
另一个让我头疼的问题是参数传递。Glance 提供了 ActionParameters 来解耦点击事件与数据,它的内部实现依赖 Bundle。如果你试图传一个自定义 Parcelable 对象进去,编译期不会拦你,运行时直接抛 IllegalArgumentException,因为底层的 RemoteViews 不支持非基础类型的 Action 参数。我在一个音乐播放器的 Widget 里想把当前歌曲的 ID 传给播放控制回调,最初用了 ActionParameters.Key<SongId>,结果 crash 堆栈指向了 RemoteViews$ActionException。最后只能退回到传 String 类型的歌曲 ID,再到回调里查数据库。这个限制在官方文档里写得很隐晦,没真正栽进去很难注意到。
尺寸适配:Responsive 与 Exact 的Launcher博弈
Android 12 引入了更灵活的 Widget 尺寸配置,要求开发者在 appwidget-provider 的 XML 里用 targetCellWidth 和 targetCellHeight 替代旧的 minWidth/minHeight。Glance 通过 SizeMode 来响应这种变化。官方推荐的是 SizeMode.Responsive,允许你声明一组尺寸断点,Glance 内部会为每个尺寸生成一帧 RemoteViews,由系统在 Widget 被拉伸时自动切换。
class MyAppWidget : GlanceAppWidget() {
override val sizeMode: SizeMode
get() = SizeMode.Responsive(
setOf(
DpSize(110.dp, 110.dp), // 2x2
DpSize(250.dp, 110.dp), // 4x2
DpSize(250.dp, 250.dp) // 4x4
)
)
}这看起来是自动化的完美方案,直到我在不同 Launcher 上测试才发现,SizeMode.Responsive 的切换完全取决于 Launcher 是否规范地调用了 AppWidgetManager.updateAppWidgetOptions 并传入正确的 OPTION_APPWIDGET_MIN_WIDTH/MAX_WIDTH。Pixel Launcher 表现良好,但在某国产系统(基于 Android 13)的默认桌面里,Widget 被拉伸到 4x4 后,Glance 仍然渲染着 2x2 的布局。原因是该 Launcher 在 resize 后没有触发 onAppWidgetOptionsChanged,Glance 收不到新的尺寸约束。
我尝试过退回到 SizeMode.Exact,让 Glance 为每一个可能的像素组合生成 RemoteViews。但这会导致 AppWidgetService 端的内存占用暴涨,因为每一帧都是一份独立的 RemoteViews 树。在 4x4 的复杂布局下,Profiler 里看到 RemoteViews 对象的 retained size 能达到几百 KB。对于常驻桌面的 Widget,这种开销累积起来不可忽视。最终的折中方案是在 Content() 里手动读 LocalSize.current,配合 BoxWithConstraints 的逻辑做条件渲染,虽然代码啰嗦一点,但至少对不发送 resize 事件的 Launcher 有兜底的判断。
主题与动态颜色:Material You 的半吊子支持
如果你的应用已经全面拥抱 Material3,自然会期望 Widget 也能直接复用 MaterialTheme.colorScheme。Glance 确实提供了 GlanceTheme,但它和 Jetpack Compose 的 Material 主题并不互通。androidx.glance:glance-material3 库里的颜色系统是一套独立的适配层,在 Glance 1.0.0 时期甚至只支持静态色值映射。
Android 12 以上系统支持动态取色,但 Glance Widget 要拿到 context.obtainStyledAttributes(android.R.styleable.ColorScheme) 里的动态颜色,需要一些额外的桥接。我最初在 GlanceTheme 里直接传了 MaterialTheme.colorScheme.primary,编译报错类型不匹配。查源码才知道 Glance 的 ColorProviders 需要显式声明 day = ..., night = ...,它不会自动跟随系统动态壁纸提取的色调。
直到 Glance 1.1.0 版本,官方才提供了对 Material3 动态颜色的更好的封装,但仍然要求你在 Content() 的最外层包一个 GlanceTheme(colors = ColorProviders(...))。更麻烦的是,Widget 预览图(Android 12 要求的 android:previewLayout)无法直接使用 Glance 的动态颜色,因为预览渲染发生在系统进程,读不到你应用的主题配置。最后我的做法是:给预览单独写了一套写死的 RemoteViews XML,而运行时走 Glance。这种“运行时一套,预览一套”的双轨维护,抵消了 Glance 原本承诺的部分开发效率。
状态管理:没有 LaunchedEffect 的冷启动
Compose 里最顺手的状态副作用模式在 Glance 里全军覆没。Widget 的更新不是由你的应用主动驱动的,而是系统根据 updatePeriodMillis 或者用户交互来触发。Glance 提供了 AppWidgetState 和 PreferencesGlanceStateDefinition,本质上是把状态序列化到应用私有的 SharedPreferences 或 DataStore 里。
我的天气 Widget 需要每小时拉一次新数据。旧实现是在 onUpdate 里启一个 WorkManager 任务,等数据回来再 appWidgetManager.updateAppWidget。换成 Glance 后,我以为可以在 Content() 里用某种副作用自动触发数据加载,结果发现根本没有入口。正确的姿势是保持传统:在 GlanceAppWidgetReceiver 的 onUpdate 里调度 WorkManager,Worker 完成后再调用 MyAppWidget().update() 或 updateAppWidgetState。
class WeatherWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = WeatherGlanceWidget()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
// 不能在这里等网络,必须委托给 WorkManager
val work = PeriodicWorkRequestBuilder<WeatherWorker>(1, TimeUnit.HOURS).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"weather_update",
ExistingPeriodicWorkPolicy.UPDATE,
work
)
}
}这里有个隐性成本:Glance 的 updateAppWidgetState 会触发整个 Content() 的重组,哪怕你只改了一个布尔值。对于复杂布局,重组的开销比直接 mutate 一个 RemoteViews 的局部 view 要高。我用 Macrobenchmark 跑过一轮 Trace,Glance Widget 从 update() 调用到 SurfaceFlinger 最终渲染,中位耗时在 35ms 左右,而手写的 RemoteViews 局部更新能压到 12ms 以内。这 35ms 里大部分是 Glance 把你的 Composable 树翻译成 RemoteViews 指令的时间。对于静态内容为主、更新不频繁的 Widget,这个差距可以忽略;但如果你做的是秒级刷新的计步器或秒表,Glance 的翻译税会累积成明显的卡顿。
交互深度的天花板
RemoteViews 支持的 View 类型极其有限,Glance 也没有突破这个边界。它提供的最复杂组件大概就是 LazyColumn,而且内部实现是对 RemoteViews 的 setRemoteAdapter 封装,底层仍然是一个 ListView 语义的远程集合。我试过在 Widget 里做一个可横向滑动的周视图天气,Glance 并没有 LazyRow。Row 组件是支持的,但它会把所有子项一次性展开成 RemoteViews 节点。当我把 7 天的天气卡片放进 Row 后,在 Android Studio 的 Layout Inspector 里看到 Launcher 进程中的 View 节点数直接飙到了 200+,滑动时掉帧严重。
更深层的问题是事件反馈。Compose 里习以为常的 clickable { } 搭配重组做瞬时状态变化(比如按钮按下变色),在 Glance 里走不通。因为点击事件是一次 IPC,等