Android Glance 小组件(AppWidgets)开发

本篇文章主要介绍以几下个内容:

  • Glance 介绍
  • Glance 开发步骤
  • Glance 状态管理
  • Glance 项目实践
Android 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),并在 Manifestprovider 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.forceUpdateWidgetWidgetUtils.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
        }
    }
}

这个流程展示了如何利用 StateFlowPrefRepo 中管理状态,然后在 BaseAppWidget 中通过 collectAsState 监听这些状态,并根据状态组合决定最终传递给 UI Composable (LargeAppWidget) 的数据,从而驱动小组件在不同场景下(未登录、无设备、未选择设备、显示内容)展示不同的界面。

5.3 显示图表 (渲染到图片)

  • 难点: Glance 不允许在其 Composable 内部直接使用 Canvas 进行自定义绘制。

  • 解决方案: 实际项目中可通过以下取巧的方式处理:

    1. 数据获取:ChartManager(或后台任务)获取 24 小时数据 (ApiRepo.get24HourData)。
    2. Bitmap 生成:它使用非 Glance 的 ChartDrawer 类(操作 Android Canvas)将图表绘制到 Bitmap 上。
    3. 图片保存:使用 ImageUtils.saveImageToFile 将生成的 Bitmap 保存到内部存储。
    4. 状态更新:ChartManager 更新 PrefRepo 中的 chartStateFlow,以指示图表图片已就绪 (PrefRepo.updateChartState(nodeId, true))。
    5. Glance 显示:LargeContentView 收集 chartStateFlow。当特定 nodeId 的状态为 true 时,它使用 ImageUtils.loadBitmapFromFile 加载保存的 Bitmap,并使用 Glance Image 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:

    使用 StateFlowSharedFlow 在外部(例如在像 PrefRepo 这样的 Repository 中)管理小组件状态。

    这是触发更新的最有效方法。

  • 最小化 provideGlance 中的工作:

    让此函数专注于收集状态和组合 UI。

    将数据获取、图像处理和复杂逻辑卸载到后台线程、WorkManagerRepository 中。

  • 使用配置 Activity:

    对于需要设置的小组件,实现一个配置 Activity (WidgetSettingActivity) 以获得更好的用户体验。

  • 处理加载/错误状态:

    根据收集到的状态,在 Glance UI 中提供视觉反馈(占位符、加载指示器、错误消息)。

  • 后台工作:

    使用 WorkManager 进行定期更新或执行复杂的后台任务。

  • 测试:

    测试不同的小组件状态(未登录、无设备、已选择设备、错误状态、加载图表)。

  • 资源管理:

    注意 Bitmap 大小和文件 I/O,尤其是在使用渲染到图片的方法时。

7. 小结

Glance 相对于传统的 Android 小组件开发提供了显著的改进,主要是通过其声明式 UI 方法和使用 Flow 简化的状态管理。使用类似 Compose 的语法定义 UI 的能力使代码更清晰,并且与现代应用开发更加一致。

provideGlance 中收集的 StateFlow 更新驱动的响应式特性非常强大,并且通常简化了保持小组件与应用程序数据同步的过程。

挑战:

  • 局限性: 受到 RemoteViews 的限制。复杂的布局或自定义绘图需要采用变通的方法。
  • 异步性: 管理状态更新、后台任务(如获取数据和渲染图像)以及确保小组件正确更新需要仔细处理协程和状态流。
  • 学习曲线: 熟悉 Jetpack Compose 的人会觉得 Glance 很直观,但在特定于 Glance 的组件、修饰符以及小组件的状态处理模式方面仍然存在学习曲线。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

  • """1.个性化消息: 将用户的姓名存到一个变量中,并向该用户显示一条消息。显示的消息应非常简单,如“Hello ...
    她即我命阅读 8,566评论 0 5
  • 为了让我有一个更快速、更精彩、更辉煌的成长,我将开始这段刻骨铭心的自我蜕变之旅!从今天开始,我将每天坚持阅...
    李薇帆阅读 6,020评论 0 3
  • 似乎最近一直都在路上,每次出来走的时候感受都会很不一样。 1、感恩一直遇到好心人,很幸运。在路上总是...
    时间里的花Lily阅读 5,239评论 0 2
  • 1、expected an indented block 冒号后面是要写上一定的内容的(新手容易遗忘这一点); 缩...
    庵下桃花仙阅读 3,582评论 0 1
  • 一、工具箱(多种工具共用一个快捷键的可同时按【Shift】加此快捷键选取)矩形、椭圆选框工具 【M】移动工具 【V...
    墨雅丫阅读 3,567评论 0 0