Administrator
发布于 2026-09-22 / 6,006 阅读
0
0

Android App Widget 新架构:Glance 小组件

前言

写 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 时代的对比

对比项

RemoteViews (旧)

Glance (新)

布局写法

XML 子集

Compose 声明式

点击事件

PendingIntent 手动拼

actionRunCallback 闭包式

列表

RemoteViewsService + Factory

LazyColumn

数据刷新

onUpdate 手动算

suspend provideGlance

动态背景

不支持

支持 ColorFilter

学习成本

高(一堆特殊 API)

低(会 Compose 就行)

小结

Glance 不是"能用 Compose 写 Widget"这么简单,它改掉了 Widget 的心智模型:从"RemoteViews 填数据"变成"提供一帧 Compose 内容"。

如果你已有 Widget 需要迁移,Glance 1.1 已经可以用于生产环境。新项目直接上 Glance,不用再碰 RemoteViews。

下篇讲 ART 运行时,AOT/JIT/PGO 是怎么配合的,GC 为什么不用 G1 而用 CC。


评论