前言
写 App Widget 的工程师大概都有这个体验:RemoteViews、PendingIntent、 onUpdate 手动算时间,布局只能用 XML 子集,连 RecyclerView 都要用专版 RemoteViewsAdapter。
Google 终于给了 Compose 版的答案:Glance。用 Compose 风格的 API 写小组件,不用 XML,不用 RemoteViews。
一、Glance 是什么
Glance 不是 Jetpack Compose 的扩展,它是一套独立的 API,但用了和 Compose 类似的声明式写法。底层仍然走 RemoteViews + RemoteViewsService,但把这些封装掉了。
你的 Compose 风格代码 -> Glance 编译 -> RemoteViews -> 桌面 Launcher 渲染
所以它兼容所有 Android 版本(API 23+),不依赖桌面 Launcher 是否支持 Compose。
二、第一个小组件
加依赖
dependencies {
implementation("androidx.glance:glance-appwidget:1.1.1")
implementation("androidx.glance:glance-material3:1.1.1")
}
定义 Widget
class TodoWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
// 这里相当于 Compose 的 @Composable
provideContent {
// 获取数据
val todos = remember { getTodayTodos() }
TodoContent(todos)
}
}
}
@Composable
private fun TodoContent(todos: List<Todo>) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.padding(12.dp)
.background(GlanceColors.surface)
) {
Text(
text = "今日待办 (${todos.size})",
style = TextStyle(
color = GlanceColors.onSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
)
Spacer(modifier = GlanceModifier.height(8.dp))
todos.take(3).forEach { todo ->
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Text(
text = if (todo.done) "[x]" else "[ ]",
style = TextStyle(color = GlanceColors.primary)
)
Spacer(modifier = GlanceModifier.width(8.dp))
Text(
text = todo.title,
style = TextStyle(
color = if (todo.done) GlanceColors.onSurfaceVariant
else GlanceColors.onSurface,
textDecoration = if (todo.done) TextDecoration.LineThrough else null
)
)
}
}
}
}
注册 Receiver
<!-- AndroidManifest.xml -->
<receiver
android:name=".TodoWidgetReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/todo_widget_info" />
</receiver>
class TodoWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = TodoWidget()
}
<!-- res/xml/todo_widget_info.xml -->
<appwidget-provider
xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="180dp"
android:minHeight="110dp"
android:targetCellWidth="3"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />
三、交互:点击事件
Glance 的点击事件用 actionRunCallback:
@Composable
private fun TodoItem(todo: Todo) {
Row(
modifier = GlanceModifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(actionRunCallback<ToggleTodoAction>(
parameters = actionParametersOf(
TodoIdKey to todo.id
)
))
) {
// ... 前面的内容
}
}
class ToggleTodoAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
val todoId = parameters[TodoIdKey] ?: return
// 更新数据
TodoRepository.toggle(todoId)
// 刷新 Widget
TodoWidget().updateAll(context)
}
}
val TodoIdKey = ActionKey<Long>(type = Long::class.java, name = "todo_id")
四、动态数据更新
Widget 的更新方式跟 Activity 不同,它不在你的进程里。不能直接 setState 触发重组。
// 方式一:定时刷新(xml 里的 updatePeriodMillis,最短 30 分钟)
// 方式二:主动刷新(数据变化时)
class TodoWidget : GlanceAppWidget() {
companion object {
fun refresh(context: Context) {
// 从后台任务或数据变化处调用
GlanceAppWidgetManager(context).getGlanceIds(TodoWidget::class.java)
.forEach { id ->
// 触发 provideGlance 重新执行
TodoWidget().update(context, id)
}
}
}
}
// 方式三:监听数据源(推荐)
// 用 WorkManager + GlanceAppWidgetManager 组合
class TodoSyncWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
// 拉数据
TodoRepository.sync()
// 刷新 Widget
GlanceAppWidgetManager(applicationContext)
.getGlanceIds(TodoWidget::class.java)
.forEach { id -> TodoWidget().update(applicationContext, id) }
return Result.success()
}
}
五、尺寸适配
Widget 可以被用户拖拽改变大小,Glance 提供了 SizeMode:
class WeatherWidget : GlanceAppWidget() {
// EXACT: 只有一种尺寸,按固定布局渲染
// RESPECT: 每种尺寸各自跑一次 provideGlance(推荐)
// FLEXIBLE: 一种尺寸适配所有
override val sizeMode = SizeMode.Respect
override suspend fun provideGlance(context: Context, id: GlanceId) {
val size = getAppWidgetSize(context, id) // 当前实际像素尺寸
provideContent {
WeatherContent(
layout = when {
size.width < 180.dp -> WeatherLayout.Compact
size.height < 110.dp -> WeatherLayout.Wide
else -> WeatherLayout.Full
}
)
}
}
}
六、跟 RemoteViews 时代的对比
小结
Glance 不是"能用 Compose 写 Widget"这么简单,它改掉了 Widget 的心智模型:从"RemoteViews 填数据"变成"提供一帧 Compose 内容"。
如果你已有 Widget 需要迁移,Glance 1.1 已经可以用于生产环境。新项目直接上 Glance,不用再碰 RemoteViews。
下篇讲 ART 运行时,AOT/JIT/PGO 是怎么配合的,GC 为什么不用 G1 而用 CC。