本篇文章主要介绍以几下个内容:
- Glance 介绍
- Glance 开发步骤
- Glance 状态管理
- Glance 项目实践

本文实现效果:

1. Glance 简介
Glance 是 Jetpack 提供的一个现代框架,用于使用类似 Compose 的声明式 UI 模式构建 Android 应用小组件(App Widgets)。
它利用 Jetpack Compose 运行时将 UI 渲染到 RemoteViews 中,与传统的基于 XML 的小组件相比,简化了开发过程。
下面将概述使用 Glance 开发小组件的关键概念、步骤和最佳实践,并结合实际项目中的示例进行说明。
Glance 的官方介绍:
https://developer.android.google.cn/develop/ui/compose/glance?hl=zh-cn
2. 为何使用 Glance?
Glance 与 传统小组件对比如下:
| 特性 | 传统小组件 (XML + RemoteViews) | Glance 小组件 | |
|---|---|---|---|
| UI 定义 | XML 布局文件 | 声明式的 @Composable 函数 |
|
| 状态管理 | 手动更新 (AppWidgetManager, Intent) |
响应式,状态驱动 (例如使用 StateFlow) |
|
| 代码结构 | 通常需要样板代码,手动视图映射 | 更简洁,基于组件,类似 Jetpack Compose | |
| 开发体验 | 可能复杂,易出错 (RemoteViews API) |
更直观,与现代 Android 开发保持一致 | |
| 底层技术 | 直接构建 RemoteViews
|
使用 Compose 运行时生成 RemoteViews
|
|
| 支持的视图 | 支持大多数标准 Android View 组件 | 支持 Compose 组件的一个子集,复杂视图可能受限 | |
| API 成熟度 | 非常成熟稳定 | 相对较新,API 可能仍在快速演进中 |
2.1 Glance 的优势
- 声明式 UI: 使用熟悉的 Compose 概念定义小组件 UI,代码更具可读性和可维护性。
-
简化的状态处理: 与 Kotlin Flow(
StateFlow,SharedFlow)等现代状态管理库无缝集成,实现响应式更新。 - 改进的开发体验: 减少样板代码,利用 Compose 运行时的强大功能。
- 一致性: 使小组件开发与现代 Android UI 实践 (Jetpack Compose) 保持一致。
- 未来趋势: Glance 是 Google 推荐的未来小组件开发方向。
2.2 Glance 的局限性
Glance 最终也需要将 UI 渲染成 RemoteViews,所以它无法支持所有 Jetpack Compose 的功能,特别是那些需要复杂自定义绘制或直接访问底层 View 的场景。
对于这些情况,笔者是采用如 渲染到 Bitmap 再显示 之类的取巧方法。
此外,作为一个较新的框架,其 API 和功能集仍在不断发展中。
3. Glance 小组件开发基本步骤
以下是以实际项目为例,概述 Glance 的开发步骤:
3.1 添加依赖
// 在 app/build.gradle 中
implementation("androidx.glance:glance-appwidget:...") // 使用最新版本
3.2 实现 GlanceAppWidget
Glance 小组件的核心类,定义小组件的内容。
在本示例中,BaseAppWidget 作为抽象基类,AppWidgetLarge 是具体实现。
// 核心代码示例(简化自 BaseAppWidget.kt / AppWidgetLarge.kt)
abstract class BaseAppWidget : GlanceAppWidget() {
@Composable
abstract fun Content(isLogin: Boolean, isEmpty: Boolean, deviceInfo: NodeDetail?)
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// 从 Flow 收集状态 (见状态管理部分)
val isLogin = ...
val isEmpty = ...
val deviceInfo = ...
Content(isLogin, isEmpty, deviceInfo)
}
}
}
class AppWidgetLarge : BaseAppWidget() {
@Composable
override fun Content(isLogin: Boolean, isEmpty: Boolean, deviceInfo: NodeDetail?) {
// 调用大型小组件 UI 的 Composable 函数
LargeAppWidget(isLogin, isEmpty, deviceInfo)
}
}
3.3 实现 GlanceAppWidgetReceiver
这是标准的 AppWidgetProvider,用于处理小组件生命周期事件。
// 核心代码示例(简化自 AppWidgetLarge.kt)
class AppWidgetLargeReceiver : GlanceAppWidgetReceiver() {
// 指定关联的 GlanceAppWidget
override val glanceAppWidget: GlanceAppWidget = AppWidgetLarge()
}
3.4 在 AndroidManifest.xml 中声明 Receiver
注册上面定义的 Receiver。
<!-- 核心代码示例(简化自 AndroidManifest.xml) -->
<receiver
android:name=".glance.AppWidgetLargeReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/app_widget_large_info" /> <!-- 指向 provider info 文件 -->
</receiver>
3.5 创建 App Widget Provider Info XML 文件
定义小组件元数据(尺寸、更新频率、配置 Activity 等)。
<!-- res/xml/app_widget_large_info.xml -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="180dp"
android:updatePeriodMillis="1800000"
android:previewImage="@drawable/widget_preview_large"
android:initialLayout="@layout/widget_initial_layout"
android:configure="com.evotech.easeair.glance.pages.WidgetSettingActivity"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen">
</appwidget-provider>
3.6 实现配置 Activity (可选)
如果小组件需要设置(例如选择设备),创建一个 Activity(在示例中是 WidgetSettingActivity),并在 Manifest 和 provider info 中声明。
此 Activity 应保存配置(例如使用 PrefRepo)并设置结果:
// 核心代码示例(简化自 WidgetSettingActivity.kt),仅作参考
class WidgetSettingActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// ...
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1)
setContent {
WidgetSettingPage(
onClickDevice = { deviceInfo ->
lifecycleScope.launch {
// 1. 保存选择 (例如用定义的 PrefRepo)
PrefRepo.setDeviceNodeId(appWidgetId, deviceInfo.nodeId)
// 2. 按需触发数据获取/更新
ChartManager.updateNodeChart(context, deviceInfo.nodeId)
// 3. 设置结果并结束 Activity
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId))
finish()
}
}
// ...
)
}
}
}
4. 关键概念与注意点
4.1 Composable UI
使用 @Composable 函数,就像在 Jetpack Compose 中一样 (如定义的 DeviceNameView, AqiView, LargeContentView)。
注意,Glance 中虽然 Composable 还是使用的 Compose 的,但里面的可组合项都是 Glance 中重写的(androidx.glance.xxx)。
4.2 状态管理 (核心)
Glance 小组件默认是无状态的。状态应在外部管理。
使用 Flow (推荐): 示例项目中在定义的
PrefRepo中使用了StateFlow(loginStateFlow,deviceDataFlow,deviceNodeMapFlow,chartStateFlow) 来持有小组件的状态。-
在
provideGlance中,使用collectAsState()收集这些 Flow 以获取当前状态,并在状态更改时触发重组。// 核心代码示例(简化自 BaseAppWidget.kt) override suspend fun provideGlance(context: Context, id: GlanceId) { provideContent { val repo = remember { PrefRepo } val loginState = repo.loginStateFlow.collectAsState() // 响应登录状态变化 val deviceData = repo.deviceDataFlow.collectAsState() // 响应设备数据变化 val chartStates = repo.chartStateFlow.collectAsState() // 响应图表就绪状态 // 使用 loginState.value, deviceData.value 等构建 UI // ... } }
4.3 更新小组件
基于 Flow (首选)
当PrefRepo中的状态(支撑StateFlow)更新时(例如由后台工作器、配置Activity或 Flutter 通道更新),provideGlance中的collectAsState()会自动收集到新的状态值,触发 Glance UI 的重组 (recomposition),从而更新小组件的界面。
这是最具响应性且推荐的方法。手动更新
使用GlanceAppWidget.update(context, glanceId)手动触发更新(如BaseAppWidget.forceUpdateWidget或WidgetUtils.refreshLargeWidget中所示)。
这对于特定场景(如配置后的初始设置或显式刷新操作)很有用,但如果使用 Flow,则不应作为主要的更新机制。Actions (点击事件)
使用GlanceModifier.clickable配合actionStartActivity,actionRunCallback等 actions。
(示例项目中使用AppLinksUtils来创建用于深度链接到主应用的 Intent。)
4.4 有限的组件集
Glance 不支持所有的 Jetpack Compose Composable(例如 ConstraintLayout,或直接在 Glance Composable 中进行自定义绘制)。
通常使用 Box, Row, Column, Text, Image, Button, LazyColumn 等。
5. 实际项目中的 Glance 实践
实际项目中一般会利用 Glance 构建了多种尺寸的小组件,这边示例中还实现了一些其他功能,如设备选择、数据显示、图表展示和与 Flutter 应用的通信等。
以下介绍一些关键实现点。
5.1 Glance 项目结构概览
为了更好地组织 Glance 小组件相关的代码,项目在 glance 目录下采用了以下结构(展示核心模块和代表性文件):
glance/
├── constant/ # 存放常量定义
├── data/ # 数据层: 数据获取和存储
│ ├── network/ # - 网络请求相关 (Retrofit)
│ │ └── ApiService.kt # (示例: API 接口定义)
│ └── repo/ # - 仓库层: 封装数据访问和状态管理
│ ├── ApiRepo.kt # (示例: 获取网络数据)
│ └── PrefRepo.kt # (示例: 缓存管理 & StateFlow 状态 - **核心**)
├── entity/ # 存放数据模型类 (Data Classes)
├── manager/ # 管理类: 封装复杂功能逻辑 (如图表管理)
├── pages/ # 存放与小组件相关的 Activity 页面 (如配置页)
├── theme/ # 存放 Glance UI 主题元素 (颜色等)
├── uitls/ # 存放工具类 (图片处理, 小组件工具, DeepLink等)
├── widgets/ # 构成小组件 UI 的 Composable 函数
│ ├── chart/ # - 图表绘制相关 (使用 Android Canvas)
│ ├── CommonWidgets.kt # - 可复用的通用 UI 组件
│ └── LargeAppWidget.kt # - 各尺寸小组件顶层 UI 实现 (以 Large 为例)
├── work/ # 后台任务 (WorkManager, 如数据同步)
├── AppWidgetLarge.kt # GlanceAppWidget 实现 (以 Large 为例)
├── AppWidgetLargeReceiver.kt # GlanceAppWidgetReceiver (以 Large 为例)
└── BaseAppWidget.kt # 所有小组件的基类, 封装共享逻辑 (状态收集等)
这种结构将数据处理、UI 定义、状态管理、后台任务和配置页面清晰地分离开来,提高了代码的可维护性和可读性。
5.2 处理登录状态 & 设备数据
BaseAppWidget 作为所有小组件的基类,负责统一处理登录状态和设备数据的逻辑。它通过 PrefRepo 提供的 StateFlow 来响应式地获取当前状态,并根据这些状态决定向具体的小组件实现(如 AppWidgetLarge)传递什么数据。
核心代码示例:
// 1. 在 PrefRepo 中定义和暴露 StateFlow
object PrefRepo {
// ... 其他代码 ...
private val _loginStateFlow = MutableStateFlow<Boolean>(/* 从 MMKV 读取初始值 */)
val loginStateFlow: StateFlow<Boolean> = _loginStateFlow.asStateFlow()
private val _deviceDataFlow = MutableStateFlow<NodesResponse?>(/* 从 MMKV 读取初始值 */)
val deviceDataFlow: StateFlow<NodesResponse?> = _deviceDataFlow.asStateFlow()
// 映射 AppWidget ID 到选择的设备 Node ID
private val _deviceNodeMapFlow = MutableStateFlow<Map<Int, String>>(/* 从 MMKV 读取初始值 */)
val deviceNodeMapFlow: StateFlow<Map<Int, String>> = _deviceNodeMapFlow.asStateFlow()
// 更新登录状态的方法 (例如 Flutter 通道调用)
fun setUserToken(token: String?) {
// ... 保存 token ...
_loginStateFlow.value = !token.isNullOrEmpty() // 更新 Flow
}
// 更新设备数据的方法 (例如后台同步任务调用)
fun setDeviceData(data: NodesResponse?) {
// ... 保存数据 ...
_deviceDataFlow.value = data // 更新 Flow
}
// 更新设备选择的方法 (例如配置 Activity 调用)
fun setDeviceNodeId(appWidgetId: Int, nodeId: String) {
// ... 保存映射 ...
_deviceNodeMapFlow.update { currentMap ->
currentMap.toMutableMap().apply { put(appWidgetId, nodeId) } // 更新 Flow
}
}
// ... 其他方法 ...
}
// 2. 在 BaseAppWidget 中收集状态并决定传递给 Content 的数据
abstract class BaseAppWidget : GlanceAppWidget() {
// 抽象方法,由具体小组件实现 UI
@Composable
abstract fun Content(isLogin: Boolean, isEmpty: Boolean, deviceInfo: NodeDetail?)
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// 获取当前小组件的 AppWidget ID
val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
// 注入/获取 PrefRepo 实例
val repo = remember { PrefRepo }
// 使用 collectAsState 监听状态变化
val loginState = repo.loginStateFlow.collectAsState()
val deviceData = repo.deviceDataFlow.collectAsState()
val deviceNodeMap = repo.deviceNodeMapFlow.collectAsState()
// 根据收集到的状态值进行逻辑判断
val isLogin = loginState.value
var isEmpty = true // 默认设备为空
var specificDeviceInfo: NodeDetail? = null
if (isLogin) {
val allDeviceDetails = deviceData.value?.nodesDetails
isEmpty = allDeviceDetails.isNullOrEmpty()
if (!isEmpty) {
// 从映射中获取当前小组件配置的 nodeId
val selectedNodeId = deviceNodeMap.value[appWidgetId]
// 如果找到了 nodeId,则从设备列表中查找对应的设备详情
specificDeviceInfo = selectedNodeId?.let { nodeId ->
allDeviceDetails?.find { it.nodeId == nodeId }
}
}
}
// 调用具体小组件的 Content 实现,传递计算出的状态
Content(isLogin, isEmpty, specificDeviceInfo)
}
}
}
// 3. 在具体的小组件实现中根据传递的状态渲染不同 UI
class AppWidgetLarge : BaseAppWidget() {
@Composable
override fun Content(isLogin: Boolean, isEmpty: Boolean, deviceInfo: NodeDetail?) {
// 调用顶层 Composable UI
LargeAppWidget(isLogin, isEmpty, deviceInfo)
}
}
@Composable
fun LargeAppWidget(isLogin: Boolean, isEmpty: Boolean, deviceInfo: NodeDetail? = null) {
Box {
GradientBackground() // 背景
when {
!isLogin -> LargeLoginView() // 未登录 UI
isEmpty -> LargeAddView() // 已登录但无设备 UI
deviceInfo == null -> LargeSelectView() // 已登录有设备但当前小组件未选择 UI
else -> LargeContentView(WidgetData.convertNodeDetail(deviceInfo)) // 显示设备内容 UI
}
}
}
这个流程展示了如何利用 StateFlow 在 PrefRepo 中管理状态,然后在 BaseAppWidget 中通过 collectAsState 监听这些状态,并根据状态组合决定最终传递给 UI Composable (LargeAppWidget) 的数据,从而驱动小组件在不同场景下(未登录、无设备、未选择设备、显示内容)展示不同的界面。
5.3 显示图表 (渲染到图片)
难点: Glance 不允许在其 Composable 内部直接使用 Canvas 进行自定义绘制。
-
解决方案: 实际项目中可通过以下取巧的方式处理:
- 数据获取:
ChartManager(或后台任务)获取 24 小时数据 (ApiRepo.get24HourData)。 - Bitmap 生成:它使用非 Glance 的
ChartDrawer类(操作 AndroidCanvas)将图表绘制到Bitmap上。 - 图片保存:使用
ImageUtils.saveImageToFile将生成的Bitmap保存到内部存储。 - 状态更新:
ChartManager更新PrefRepo中的chartStateFlow,以指示图表图片已就绪 (PrefRepo.updateChartState(nodeId, true))。 - Glance 显示:
LargeContentView收集chartStateFlow。当特定nodeId的状态为true时,它使用ImageUtils.loadBitmapFromFile加载保存的Bitmap,并使用 GlanceImageComposable 显示它。
// 核心代码示例(简化自 LargeContentView.kt) @Composable fun LargeContentView(deviceInfo: WidgetData) { // ... val repo = remember { PrefRepo } val chartStates = repo.chartStateFlow.collectAsState() // 收集图表状态 val isChartReady = chartStates.value[deviceInfo.nodeId] == true // 检查此设备图表是否就绪 // ... Box(...) { if (isChartReady) { // 仅当状态流确认就绪时才加载 bitmap val chartBitmap = ImageUtils.loadBitmapFromFile(context, ImageUtils.getFileName(deviceInfo.nodeId)) chartBitmap?.let { bitmap -> Image(provider = ImageProvider(bitmap), ...) // 显示图片 } } else { Text("图表加载中...") // 显示占位符 } } } - 数据获取:
6. Glance 开发技巧与最佳实践
-
拥抱 StateFlow:
使用
StateFlow或SharedFlow在外部(例如在像PrefRepo这样的Repository中)管理小组件状态。这是触发更新的最有效方法。
-
最小化
provideGlance中的工作:让此函数专注于收集状态和组合 UI。
将数据获取、图像处理和复杂逻辑卸载到后台线程、
WorkManager或Repository中。 -
使用配置 Activity:
对于需要设置的小组件,实现一个配置 Activity (
WidgetSettingActivity) 以获得更好的用户体验。 -
处理加载/错误状态:
根据收集到的状态,在 Glance UI 中提供视觉反馈(占位符、加载指示器、错误消息)。
-
后台工作:
使用
WorkManager进行定期更新或执行复杂的后台任务。 -
测试:
测试不同的小组件状态(未登录、无设备、已选择设备、错误状态、加载图表)。
-
资源管理:
注意 Bitmap 大小和文件 I/O,尤其是在使用渲染到图片的方法时。
7. 小结
Glance 相对于传统的 Android 小组件开发提供了显著的改进,主要是通过其声明式 UI 方法和使用 Flow 简化的状态管理。使用类似 Compose 的语法定义 UI 的能力使代码更清晰,并且与现代应用开发更加一致。
由 provideGlance 中收集的 StateFlow 更新驱动的响应式特性非常强大,并且通常简化了保持小组件与应用程序数据同步的过程。
挑战:
-
局限性: 受到
RemoteViews的限制。复杂的布局或自定义绘图需要采用变通的方法。 - 异步性: 管理状态更新、后台任务(如获取数据和渲染图像)以及确保小组件正确更新需要仔细处理协程和状态流。
- 学习曲线: 熟悉 Jetpack Compose 的人会觉得 Glance 很直观,但在特定于 Glance 的组件、修饰符以及小组件的状态处理模式方面仍然存在学习曲线。