深入 Android Glance AppWidget 全链路:从 RemoteViews 渲染桥接到 Compose 声明式小组件的架构演进与更新策略

去年接手一个桌面小组件需求时,我打开项目一看——800 行的 RemoteViews 构建代码,findViewById 式的字符串 ID 满天飞,布局改一行要脑补渲染结果。小组件本应是轻量入口,结果维护成本比主 App 还高。这就是 Glance 要解决的核心问题。

RemoteViews 的本质约束

要理解 Glance 做了什么,得先看 RemoteViews 是什么。

RemoteViews 是一个 Parcelable 对象,它不承载真实 View 实例,而是携带一组 View 操作指令。Widget 进程把这些指令序列化后跨进程传给 Launcher,Launcher 端反序列化后在 AppWidgetHostView 里按指令构建出真实的 View 树。

val views = RemoteViews(packageName, R.layout.widget)
views.setTextViewText(R.id.title, "今日待办")
views.setImageViewResource(R.id.icon, R.drawable.ic_task)
views.setOnClickPendingIntent(R.id.root, pendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)

这套机制的痛点在于:UI 是声明式的,但代码是命令式的。你用 Java/Kotlin 一行行「设置」属性,而真实 UI 结构藏在 XML 文件里,阅读时两个文件来回对照。RemoteViews 只支持少数 View 类型和有限的操作方法——想加个圆角得绕到 setInt 反射改属性。

Glance 的设计目标就是在这套底层约束之上,提供一套 Compose 风格的声明式 API。

Glance 的翻译引擎

Glance 不是把 Compose 的整个运行时搬过来了,而是实现了一个受限的翻译层。你写的 @Composable 函数在 Glance 里走的不是 Compose 编译器,而是 Glance 自己的节点构建器。

核心流程:

Composable 函数 → GlanceNode 树 → RemoteViews 操作序列 → AppWidgetManager

每一个 Glance 专属的 Composable 对应一种 RemoteViews 布局元素:

Glance 组件映射的 RemoteViews View支持的关键 Modifier
BoxFrameLayoutpadding, background, size
RowLinearLayout(HORIZONTAL)padding, defaultWeight
ColumnLinearLayout(VERTICAL)padding, defaultWeight
TextTextViewtextSize, textColor, maxLines
ImageImageViewsize, cornerRadius
LazyColumnListView + RemoteViewsService

Modifier 是翻译引擎设计上最值得关注的部分。GlanceModifier.background(Color.Red) 最终会翻译成 RemoteViews.setInt(viewId, "setBackgroundColor", color),通过反射调用来设置属性。这套反射机制正是 RemoteViews 扩展性的来源——Glance 把 Compose 修饰符链编译为一组反射操作,保持与 RemoteViews 协议的兼容。

class NoteWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            Column(
                modifier = GlanceModifier
                    .fillMaxWidth()
                    .padding(12.dp)
                    .background(Color.White)
                    .cornerRadius(8.dp)
            ) {
                Text(
                    text = "待办事项",
                    style = TextStyle(fontWeight = FontWeight.Bold)
                )
                // 列表项省略
            }
        }
    }
}

编译期你能用的组件和 Modifier 是白名单控制的。用了标准 Compose 的 Modifier 或者 LazyRow,IDE 不会报错但运行时返回的节点树会缺失对应内容。这个坑我踩过一次——在 Row 里用了 Modifier.weight(1f) 而不是 GlanceModifier.defaultWeight(),编译通过但 Launcher 上布局完全不对。

Actions:点击交互的翻译难题

RemoteViews 的事件处理全靠 setOnClickPendingIntent,每个可点击区域绑定一个 PendingIntent,携带一个 FillInIntent 来区分点击目标。

Glance 把这一层封装成了 Action 接口:

class RefreshAction : Action {
    override suspend fun onRun(
        context: Context,
        glanceId: GlanceId,
        parameters: ActionParameters
    ) {
        // 在这里执行更新逻辑,运行在 App 进程
        updateNoteWidget(context, glanceId)
    }
}

// 在 Composable 中绑定
Text(
    text = "刷新",
    modifier = GlanceModifier.clickable(
        action = Action(RefreshAction::class)
    )
)

翻译引擎对 Actions 做了两件事:第一,为每个 Action 注册一个唯一的 BroadcastReceiver;第二,把 Action 信息塞进 FillInIntent。用户点击时,Launcher 发送广播,Glance 的 ActionReceiver 拦截后通过反射实例化你的 Action 并调用 onRun

这套机制的代价是每次点击都要走广播 → 进程唤醒 → 反射调用,延迟在 100-300ms 之间。高频交互的小组件(比如秒表)不适合用 Glance Actions。

跨进程更新的完整链路

一次 update() 调用背后,数据要跨越至少三个进程:

App 进程                    System Server               Launcher 进程
   │                            │                           │
   │ 1. provideGlance() 构建    │                           │
   │    GlanceNode 树           │                           │
   │                            │                           │
   │ 2. 翻译为 RemoteViews      │                           │
   │                            │                           │
   │ 3. AppWidgetManager ──────►│                           │
   │    .updateAppWidget()      │ 4. Binder 传输            │
   │                           │    RemoteViews (Parcel)    │
   │                           │──────────────────────────►│
   │                           │                           │ 5. AppWidgetHost
   │                           │                           │    .updateAppWidget()
   │                           │                           │
   │                           │                           │ 6. apply RemoteViews
   │                           │                           │    → 真实 View 树

RemoteViews 的序列化大小是性能关键。一个包含 10+ 个文本、图片的复杂组件,RemoteViews 序列化后约 5-15KB。在 Binder 层传输这个量级通常不是瓶颈,真正的延迟在步骤 1 和 2。

provideGlance 在 Glance 线程池中作为 suspend 函数执行,内部可以调用网络请求或数据库查询。组件的刷新间隔由 updatePeriodMillis 或 WorkManager 控制,系统对 Widget 更新频率有硬性限制——最短 30 分钟(Android 12+)。如果需要更频繁更新,只能用 AlarmManager 搭配前台 Service,但会额外耗电。

何时用 Glance,何时退回去

Glance 把 Widget 开发体验拉到了和主 App UI 接近的水平,但它不是银弹。我的判断标准:

适合 Glance:

  • 信息展示型小组件(天气、日程、股价)
  • 布局包含列表(LazyColumn + RemoteViewsService 比手写 ListView 轻松太多)
  • 团队已熟悉 Compose,降低 Widget 维护成本

不适合 Glance:

  • 需要 Canvas 绘制或自定义动画——RemoteViews 不支持,Glance 也给不了
  • Widget 有大量可交互按钮(每个 Action 走广播链路,延迟累积明显)
  • 目标 minSdk < 23——Glance 最低要求 API 23,低版本只能用传统 RemoteViews
// Glance 和传统 RemoteViews 可以混用
class HybridWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            // Glance 管理的部分
            Column { /* ... */ }
        }
    }
}

// 某些场景直接操作 RemoteViews
fun updateLegacyPart(context: Context, appWidgetId: Int) {
    val views = RemoteViews(context.packageName, R.layout.widget_legacy)
    views.setProgressBar(R.id.progress, 100, 50, false)
    AppWidgetManager.getInstance(context).partiallyUpdateAppWidget(appWidgetId, views)
}

partiallyUpdateAppWidget 可以和 Glance 共存——Glance 管静态内容区,传统 RemoteViews 管进度条这类 Glance 不支持的部分。

更新策略的选择

Glance 提供了三层更新粒度:

  1. 全局更新 updateAll(context):更新某个 Widget 类型的所有实例,适合数据变更后统一刷新
  2. 单实例更新 update(context, glanceId):只更新指定 Widget 实例,用户点击了某个 Widget 上的按钮时用
  3. 周期性更新:通过 updatePeriodMillis 在 Manifest 里声明,或接入 WorkManager

我更倾向于把更新逻辑放进 Repository 层,Widget 和主 App 共享同一份数据源:

class WeatherRepository {
    // 数据变化时同时驱动主 App UI 和 Widget
    val weatherFlow: Flow<WeatherData> = dataSource.observe()

    suspend fun refresh() {
        val data = api.fetch()
        dataSource.save(data)
        // Widget 更新由 WorkManager 统一调度
        WeatherWidgetUpdateWorker.enqueue()
    }
}

WorkManager 在这里比 updatePeriodMillis 灵活得多——你可以加约束(网络可用时才更新)、设置退避策略、甚至链式执行。updatePeriodMillis 强依赖于系统的 AlarmManager,在 Doze 模式下行为不可控。


Widget 的本质是 App 在主屏幕上的一扇远程视图窗口。Glance 没有改变这个本质,它做的是把构建方式从「手工拼接指令」变成了「声明式描述」。写 Glance 时脑子里始终要有这层映射关系——每行 Composable 最终会变成一段 RemoteViews 反射操作。建立这个心智模型之后,那些编译通过但渲染异常的问题就都说得通了。

延伸阅读