本篇文章主要介绍以几下个内容:
- 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
,并使用 GlanceImage
Composable 显示它。
// 核心代码示例(简化自 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 的组件、修饰符以及小组件的状态处理模式方面仍然存在学习曲线。