背景
之前『为 Android 应用构建 Widget』一文介绍过在 Android 中 Widget 的开发流程,文中的示例是基于『Android Studio』提供的默认模版,也就是 RemoteViews 来构建的,当时的文章反响不错,也引发了不少读者的讨论,其中有不少读者期待在 Jetpack Compose 中使用更加优雅的解决方案,今天它来了。
Google 开发了一个构建在 Jetpack Compose Runtime 之上的框架,名为 Glance,帮助你用更少的代码快速构建响应式 Widget。
事先声明,得益于 Android 良好的兼容方案,之前的 RemoteViews 依然可用,不必为其是否需要迁移感到焦虑。
实践
基本配置
『Android Studio』的默认创建模版之前的文章中已经介绍过了,它与 Glance 的配置大相径庭,所以本次我们将手动完成配置。
首先确保当前项目的 Compose 环境已经配置完成,Compose 的配置流程本文不再赘述,读者可以自行搜索。
接下来添加依赖:
dependencies {
// For AppWidgets support
implementation "androidx.glance:glance-appwidget:1.1.1"
// For interop APIs with Material 3
implementation "androidx.glance:glance-material3:1.1.1"
// For interop APIs with Material 2
implementation "androidx.glance:glance-material:1.1.1"
}
按需添加即可,需要注意的是,Glance 的版本和当前 Compose 的版本也是有关联的,如果不兼容可能会编译出错,修改版本号为对应的版本即可。
然后编写一个继承自 GlanceAppWidget 的类,我们的 Widget 样式将在这里渲染:
class MyAppWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
GlanceTheme {
MyContent()
}
}
}
@Composable
private fun MyContent() {
...
}
}
实现 provideGlance() 方法,并在 provideContent() 内创建你的 Compose 视图。
有几点需要注意:
provideGlance()方法运行在主线程,所以耗时操作建议使用协程处理。- 虽然表面上看它是使用 Compose 编写 UI,但实际上 Glance 内部重新构建了一套与 Jetpack Compose 类似的组件库,注意不要导错包。
Glance 只支持 Box、Column、Row、Spacer、Text、Button、Image、LazyColumn、Scaffold 等,尽管随着版本更新还拓展了一些其他的组件,但仍与 RemoteViews 的限制大同小异,事实上,Glance 只是提供了 Compose-Style 的写法,它依然会翻译成 RemoteViews 可用的方案。
如果期望 Glance 能够突破组件限制的可以劝退了😂。
继续创建一个 GlanceAppWidgetReceiver 用于获取刚刚的 GlanceAppWidget:
class MyAppWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = MyAppWidget()
}
看到这个名字相信你已经察觉到了,我们之前传统的构建方式也需要注册一个广播,扒开它的神秘面纱瞅一眼:
abstract class GlanceAppWidgetReceiver : AppWidgetProvider() {
...
abstract val glanceAppWidget: GlanceAppWidget
}
好家伙,还是基于原来的 AppWidgetProvider 封装的。
Glance 注册还需要一个 AppWidgetProviderInfo 元数据,这也是老演员了,需要留意的是,Glance 没有默认的初始布局,你可以使用它内部定义的:
<appwidget-provider
...
android:initialLayout="@layout/glance_default_loading_layout" />
最后就是在 AndroidManifest.xml 中注册了,同之前一样:
<manifest ...>
<application ...>
...
<receiver
android:name=".widget.MyAppWidgetReceiver"
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/my_app_widget_info" />
</receiver>
</application>
</manifest>
更新 Widget
Glance 官方文档中关于更新 Widget 的内容我认为描述得不够详细,它只告诉你调用 updateAll() 方法即可:
object MyAppWidgetManager {
fun updateAll(context: Context) {
GlobalScope.launch {
MyAppWidget().updateAll(context)
}
}
}
又或者调用 update():
object MyAppWidgetManager {
fun updateAll(context: Context) {
GlobalScope.launch {
val manager = GlanceAppWidgetManager(context)
val widget = MyAppWidget()
val glanceIds = manager.getGlanceIds(widget.javaClass)
glanceIds.forEach { glanceId ->
widget.update(context, glanceId)
}
}
}
}
由于这两个方法都使用 suspend 修饰,所以建议在非主线程中调用。
如果使用传统开发 Widget 的思维,调用这个方法 Widget 便会执行刷新,但是实际效果却并不能正确执行,请不要认为在任何时候调用这个方法会触发 provideGlance()。
因为 GlanceAppWidget 的更新是通过状态管理的,比如官方的例子:
class DestinationAppWidget : GlanceAppWidget() {
...
@Composable
fun MyContent() {
val repository = remember { DestinationsRepository.getInstance() }
val destinations by repository.destinations.collectAsState(State.Loading)
when (destinations) {
is State.Loading -> {
// show loading content
}
is State.Error -> {
// show widget error content
}
is State.Completed -> {
// show the list of destinations
}
}
}
}
但我们野生开发却很少使用这种状态,比如直接从 SharedPreferences 里面读取值来展示:
class MyAppWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val foo = fetchFromPreferences()
GlanceTheme {
if (foo != null) {
MyContent(foo)
} else {
DefaultContent()
}
}
}
}
@Composable
private fun MyContent(foo: Foo) {
...
}
@Composable
private fun DefaultContent() {
...
}
}
这种写法会导致调用 update() 判断不到状态的变更,导致不会刷新,当然野生写法也可以手动给它搞一个状态:
object MyAppWidgetManager {
const val KEY_NOW = "now"
fun updateAll(context: Context) {
GlobalScope.launch {
val glanceIds = GlanceAppWidgetManager(context).getGlanceIds(MyAppWidget::class.java)
glanceIds.forEach { id ->
updateAppWidgetState(context, id) {
it[longPreferencesKey(KEY_NOW)] = System.currentTimeMillis() // 设置状态
}
MyAppWidget().update(context, id)
}
}
}
}
每次调用 update() 方法前,都更新一次 Widget 的状态,即可刷新:
class MyAppWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val now = currentState<Preferences>()[longPreferencesKey(MyAppWidgetManager.KEY_NOW)] // 读取状态
val foo = fetchFromPreferences()
GlanceTheme {
if (foo != null) {
MyContent(foo)
} else {
DefaultContent()
}
}
}
}
...
}
从 Widget 中启动应用
我们知道,Widget 运行在远程进程中,在之前的 RemoteViews 方案,我们通过 PendingIntents 来启动应用,而 Glance 提供了更优雅的实现方案。
class MyAppWidget : GlanceAppWidget() {
...
@Composable
private fun MyContent() {
Column {
Button(text = "Home", onClick = actionStartActivity<MainActivity>())
Text(text = "Settings", modifier = GlanceModifier.clickable(actionStartActivity<SettingsActivity>()))
}
}
}
Glance 通过 Action 来简化处理用户互动的流程,Action 是一个接口,在不同的启动场景中有不同的实现。
在需要启动 Activity 时,我们只需要调用 actionStartActivity() 方法指定目标 Activity 即可,该方法会返回一个 StartActivityAction,StartActivityAction 也是一个接口,传递不同的参数由 StartActivityClassAction、StartActivityComponentAction、StartActivityIntentAction 等具体实现。
Button 可以通过 onClick 接收 Action,其他 @Composable 也可以通过 GlanceModifier.clickable() 接收 Action。
启动的时候需要传递参数怎么办,我们看到 actionStartActivity() 方法有个 Bundle 参数,千万不要被迷惑了!
@ExperimentalGlanceApi
inline fun <reified T : Activity> actionStartActivity(
parameters: ActionParameters = actionParametersOf(),
activityOptions: Bundle? = null,
): Action = actionStartActivity(T::class.java, parameters, activityOptions)
@ExperimentalGlanceApi
fun <T : Activity> actionStartActivity(
activity: Class<T>,
parameters: ActionParameters = actionParametersOf(),
activityOptions: Bundle? = null,
): Action = StartActivityClassAction(activity, parameters, activityOptions)
启动 Activity 所需的参数并不是在这个 Bundle 中传递的,而是通过 ActionParameters 传递的。尽管 ActionParameters 和 Bundle 一样底层实现都是 Map,但不同于 Bundle 的键是 String 类型,ActionParameters 的键是再做了一层封装的 ActionParameters.Key,这意味着我们需要再做一层转换,好处是不怕传递到错误的类型。
private val destinationKey = ActionParameters.Key<String>(NavigationActivity.KEY_DESTINATION)
class MyAppWidget : GlanceAppWidget() {
...
@Composable
private fun MyContent() {
Button(
text = "Home",
onClick = actionStartActivity<NavigationActivity>(actionParametersOf(destinationKey to "home"))
)
}
}
ActionParameters.Key 中的泛型指定为要传递的值的类型,然后传入我们 Activity 中定义的键名构造实例。通过 actionParametersOf() 构造 ActionParameters 对象,它内部可以填充多组 Pair 对应我们要传递的参数。将构建好的 ActionParameters 对象传递给 actionStartActivity() 方法即可。
目标 Activity 无需做多余的修改,还是使用 getIntent() 读取:
class NavigationActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val destination = intent.getStringExtra(KEY_DESTINATION)
...
}
companion object {
const val KEY_DESTINATION = "destination"
}
}
其他的操作比如启动 Service 或者发送广播同样是通过 Action 来实现,自定义操作也是如此,大同小异,参照文档即可,这里不再赘述。
RemoteViews 兜底
在某些情况下,可能需要使用 XML 和 RemoteViews 来提供视图。比如已经在不使用 Glance 的情况下实现了某项功能,或者该功能在当前 Glance API 尚未提供或无法使用。对于这种情况 Glance 提供了 AndroidRemoteViews,这是一个互操作性 API。不难看出,它提供了与 Compose 中 AndroidView 类似的功能。
AndroidRemoteViews 允许将 RemoteViews 与其他 @Composable 搭配使用:
class MyAppWidget : GlanceAppWidget() {
...
@Composable
private fun MyContent() {
val packageName = LocalContext.current.packageName
Column(modifier = GlanceModifier.fillMaxSize()) {
Text("Isn't that cool?")
AndroidRemoteViews(RemoteViews(packageName, R.layout.example_layout))
}
}
}
将 @Composable 放在 AndroidRemoteViews 同样可行:
class MyAppWidget : GlanceAppWidget() {
...
@Composable
private fun MyContent() {
val packageName = LocalContext.current.packageName
AndroidRemoteViews(
remoteViews = RemoteViews(packageName, R.layout.my_container_view),
containerViewId = R.id.example_view
) {
Column(modifier = GlanceModifier.fillMaxSize()) {
Text("My title")
Text("Maybe a long content...")
}
}
}
}
要注意的是这里需要传递布局和容器 ID,意味着这个容器必须是 ViewGroup,同时该 ViewGroup 内的所有子 View 都将被移除,取而代之的是在此 AndroidRemoteViews 中填充的内容。不要忘记,提供的 ViewGroup 必须受 RemoteViews 支持。
写在最后
这篇文章虽然主要是介绍 Glance,但实际上是对『为 Android 应用构建 Widget』一文的补充,因为 Glance 仍是对传统实现 Widget 的封装,提供了 Compose-Style 的写法,并不会超脱原有 Widget 的限制。
Glance 是一个很新的库,目前还在实验阶段,但已基本可用,而且它和 Compose 的结合非常紧密,使用起来非常方便。如果你正在使用 Compose,那么不妨尝试一下 Glance,相信你会爱上它的。
不过我需要在这里打个预防针,经过我们实际在企业项目的验证,目前 Glance 在 vivo 设备上会出现一个 IllegalStateException。

堆栈信息如下:
Fatal Exception: java.lang.IllegalStateException: Broadcast already finished
at android.content.BroadcastReceiver$PendingResult.sendFinished(BroadcastReceiver.java:283)
at android.content.BroadcastReceiver$PendingResult$1.run(BroadcastReceiver.java:256)
at android.app.QueuedWork.processPendingWork(QueuedWork.java:265)
at android.app.QueuedWork.-$$Nest$smprocessPendingWork()
at android.app.QueuedWork$QueuedWorkHandler.handleMessage(QueuedWork.java:285)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loopOnce(Looper.java:223)
at android.os.Looper.loop(Looper.java:324)
at android.os.HandlerThread.run(HandlerThread.java:67)
我追踪到在 Google IssueTracker 上从 2022 年开始就有这个问题,并且至今仍未被修复,尚不明确到底是 Glance 的 Bug 还是 vivo OriginOS 的锅。

