股票APP桌面小组件新探索:基于Jetpack Glance的简易实现方案

865 阅读4分钟

背景

最近几年,我发现Android手机随着CPU性能,屏幕越来越好,不只是手机上给人的感觉越来越流畅了,Android平板也是相比2018年那会体验好了不少。屏幕和性能的提升,还有跨屏互联的便捷性提高,让喜欢尝鲜的人开始使用平板进行一些简单的办公。

在系统层面,Android 12 之后的各版本中,各大手机厂商的OS对大屏幕的适配也做的越来越好。分屏功能和小组件的玩法日益丰富多样,为用户带来了更多个性化和高效的操作体验。

Android桌面小组件我记得这个东西并非新生事物,早在 Android 4.4 版本甚至更早便已出现了 。不过当时的小组件界面较为简陋,功能也不够完善,可真是丑陋的小废物。而且因为大家的电池也不怎么耐用,怎敢轻易在桌面上挂个小组件,怕它不断在后台运行,造成电量浪费。

后来估计是谷歌公司看到iOS的小组件做的不错,同时以华为为代表的平板销量增多,对大屏幕的适配需求也在渐渐增多,于是谷歌在 Android 12L 版本以后对小组件进行了重新设计,使得小组件在功能和外观上都有了显著提升,重新焕发生机。

实现效果

这个小组件具备更换股票和换肤的功能,同时也支持直接在APP内把小组件一键添加到用户桌面。

g21.gif

一键添加到用户桌面 g22.gif

小组件的特点

  1. 便捷信息展示:小组件作为桌面上的信息入口,无需打开应用即可快速获取用户所关心的数据,极大地提升了用户效率。如果我们所关心的数据,需要在打开APP后点个三级页面以上才能看到的话,这小组件的优势就体现出来了。
  2. 能充分利用大屏幕:现在的平板11寸,13寸的,多开几个窗口,桌面挂几个小组件,妥妥的生产力。
  3. 目前只能使用原生的方式来开发: 我看过现在有些公司新开发的APP,直接是所有UI页面能用跨平台的Flutter或者ReactNative,就用跨平台的,主打一个跨平台First,给公司降本增效。可是遇到开发小组件,还是得由熟悉这个系统的开发者来做(原生开发者最后的自留地吗?此刻想大喊那句,你不要过来啊。),

小组件的应用需求

当你打开手机APP桌面的小组件添加功能时,会发现做了小组件的 APP 多数集中在视频、新闻以及财经类领域。这是因为新闻资讯和股票信息,都是用户日常经常打开手机查看的内容。用户希望能够在不打开应用的情况下,快速获取这些关键信息。买过股票的人都知道盯盘的成瘾性吧,股票分时图上下起伏,我们的心率也跟随上下起伏,可谓盈亏不重要,玩的就是心跳。

今天我们就来实现一个简易的Android小组件, 以前学习写Android小组件,是用RemoteViews来实现的,需要写XML布局,BroadcastReceiver更新需要发送PendingIntent等等一系列操作才能完成。既然是现代Android开发,咱们就用用Jetpack Compose的兄弟项目Jetpack Glance来开发桌面小组件,Compose家族成员也是越来越多。developer.android.com/jetpack/and…

图片.png

下面如果我们使用Jetpack Glance来实现的话,看看是怎么个事?

实现步骤

  1. 先定义一个继承自GlanceAppWidget的自定义MyAppWidget组件类,方法provideGlance中有一个DSL provideContent 和Activity中的setContent方法一样,都是用于加载Compose方法组件UI的入口。
class MyAppWidget : GlanceAppWidget() {

    override suspend fun provideGlance(context: Context, id: GlanceId) {

        // In this method, load data needed to render the AppWidget.
        // Use `withContext` to switch to another thread for long running
        // operations.
        provideContent {
            // create your AppWidget here
            val repository = remember {
                StockRepository
            }
//            // Retrieve the cache data everytime the content is refreshed
           val stockState=repository.stockInfoState.collectAsState()
           val skinState=repository.skinState.collectAsState()
            val skinValue =skinState.value
            val stockValue = stockState.value
            Log.i("MyAppWidget", "provideGlance: skinValue:$skinValue")
            GlanceTheme(
                colors = if (skinValue) {
                    colorSkinDark
                } else {
                    colorSkinLight
                }
            ) {
                StockWidgetContent(stockValue, skinValue)
            }
        }
    }

}
  1. 接着我们定义一个MyAppWidgetReceiver来实现GlanceAppWidgetReceiver,在这个类中实现glanceAppWidget类,并给它赋值为我们刚才写的自定义MyAppWidget类,其实GlanceAppWidgetReceiver最后继承自BroadcastReceiver。
```kotlin
class MyAppWidgetReceiver : GlanceAppWidgetReceiver() {
    override val glanceAppWidget: GlanceAppWidget = MyAppWidget()}

3. 然后就是我们需要把这个MyAppWidgetReceiver 在AndroidManifest.xml清单文件中声明一下。

<application
    android:allowBackup="true"
    android:dataExtractionRules="@xml/data_extraction_rules"
    android:fullBackupContent="@xml/backup_rules"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/Theme.StockGlance">
    <receiver
        android:name=".glance.MyAppWidgetReceiver"
        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/my_app_widget_info" />
    </receiver>
  
</application>

传统View小组件开发也有这一步,因为本质上Jetpack Glance是对原来RemoteViews那一套用Compose的声明式开发思想来封装了一层。因为这个原因Glance写的组件和Compose的组件都是不同包名下的Text,Column,Row,二者UI方法组件无法通用,这就导致了Glance组件方法目前无法预览,不过官方已经把Glance Widget Preview这个功能加入到了Roadmap中:developer.android.com/jetpack/and…

图片.png

  1. 接着在我们要在res/xml目录下创建一个my_app_widget_info.xml文件,这是用来告诉系统这个小组件应该如何显示的配置信息,这个文件在前面的AndroidManifest.xml中的metal-data节点中有做配置。
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/app_widget_description"
    android:initialLayout="@layout/glance_default_loading_layout"
    android:minWidth="100dp"
    android:minHeight="80dp"
    android:resizeMode="horizontal|vertical"
    android:targetCellWidth="2"
    android:targetCellHeight="1"
    android:updatePeriodMillis="0"
    android:widgetCategory="home_screen" />

appwidget-provider中的属性说明:

  • android:description="@string/app_widget_description" 用于无障碍的说明
  • android:initialLayout 指定小组件的初始布局文件,在小组件第一次加载或正在更新时显示的内容。可以自定义但是只支持xml的布局文件
  • android:minWidthandroid:minHeight 定义小组件的最小宽度和高度,以 dp 为单位。这些值决定了小组件在桌面上的默认显示大小。
  • android:resizeMode="horizontal|vertical" 表示支持双向调整。
  • android:targetCellWidth="2" 表示目标宽度占用 2 个单元格。
  • android:targetCellHeight="1" 表示目标高度占用 1 个单元格。
  • android:updatePeriodMillis="0" 定义小组件的自动更新周期,以毫秒为单位。如果设置为 0,表示不自动更新,更新需要通过手动触发(如通过广播)
  • android:widgetCategory="home_screen" 表示该小组件仅限主屏幕使用。可选值:home_screen:表示小组件只能放置在主屏幕上。 keyguard:表示小组件只能放置在锁屏界面上home_screen|keyguard:表示小组件可以同时放置在主屏幕和锁屏界面。

至此运行APP后,我们就可以去桌面设置里找到这个APP下所属的小组件添加到桌面了,添加成功后就是上面的效果。

StockWidgetContent组件里是下面这样写的,发现和Compose 方法组件有一些的不同,Modifier在Glance中叫GlanceModifier,Text的颜色和Image的图片加载也有所区别,估计这都是因为这是对之前传统的RemoteViews那一套封装导致的差异。

把小组件添加到桌面后,我们修改Glance中Compose 组件里的内容,也是可以不用重复再添加一次小组件就能看到修改后的效果的,也还是方便UI开发的,不是盲人摸黑走路。

@Composable
fun StockWidgetContent(stock: StockInfo, isNight: Boolean) {
    Log.i("StockWidgetContent", "StockWidgetContent: stock ${stock.name} isNight $isNight")
    Box(
        modifier = GlanceModifier.fillMaxSize()
            .cornerRadius(12.dp),
    ) {
        Column(
            modifier = GlanceModifier.fillMaxSize()
                .background(getBgColor(isNight))
                .padding(16.dp),
            verticalAlignment = Alignment.Top,
        ) {
            // 顶部栏
            Row(
                horizontalAlignment = Alignment.Start,
                modifier = GlanceModifier.fillMaxWidth()
            ) {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Image(
                        provider = ImageProvider(stock.icon),
                        contentDescription = "market",
                        modifier = GlanceModifier.size(24.dp)
                    )
                    Spacer(GlanceModifier.width(8.dp))
                    Text(
                        stock.market, style = TextStyle(
                            color = getBgColor(!isNight),
                            fontSize = 18.sp,
                            fontWeight = FontWeight.Bold
                        )
                    )
                    Spacer(GlanceModifier.width(48.dp))
                    Image(
                        provider = ImageProvider(R.drawable.setting),
                        contentDescription = "Settings",
                        modifier = GlanceModifier.size(20.dp)
                            .clickable(actionStartActivity<MainActivity>())
                    )
                }
            }
            Spacer(GlanceModifier.height(12.dp))
            // 股票信息
            Column {
                Text(
                    stock.name,
                    style = TextStyle(
                        color = getStockNameColor(isNight),
                        fontSize = 20.sp,
                        fontWeight = FontWeight.Bold
                    )
                )
                Spacer(GlanceModifier.height(3.dp))
                Text(
                    stock.code,
                    style = TextStyle(color = ColorProvider(Color.Gray), fontSize = 14.sp)
                )

                Spacer(GlanceModifier.height(10.dp))
                Row {
                    Text(
                        stock.price,
                        style = TextStyle(
                            color = getColorByChange(stock.change),
                            fontSize = 28.sp,
                            fontWeight = FontWeight.Bold
                        )
                    )
                }
                // 涨跌信息
                Row {
                    Text(
                        stock.change,
                        style = TextStyle(
                            color = getColorByChange(stock.change),
                            fontSize = 14.sp
                        )
                    )
                    Spacer(GlanceModifier.width(4.dp))
                    Text(
                        stock.changePercent,
                        style = TextStyle(
                            color = getColorByChange(stock.change),
                            fontSize = 14.sp
                        )
                    )
                }

            }
        }
    }
}

一键添加小组件到桌面

发现现在微博APP里有个一键添加小组件到桌面的功能,启用该功能时,它会先提示用户开启桌面快捷菜单权限,获得授权后,小组件便能一键添加到桌面。这一设计大大提升了小组件的使用效率,毕竟现在大家手机里少说也安装了几十个 APP,要在众多应用中找到目标APP 的小组件并添加到桌面,着实麻烦些。

微信图片_20250112103109.jpg 下面我们来看看该怎么实现:

 fun requestAddWidget(activity: Activity, requestCode: Int) {
    val appWidgetManager = AppWidgetManager.getInstance(activity)

    // 检查是否支持固定小组件功能
    if (appWidgetManager.isRequestPinAppWidgetSupported) {
        // 获取小组件提供者的 ComponentName
        val myWidgetProvider = ComponentName(activity, MyAppWidgetReceiver::class.java)

        // 创建 PinAppWidgetRequest
        val pinnedWidgetCallback = PendingIntent.getActivity(
            activity,
            requestCode,
            Intent(activity, MainActivity::class.java),
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        appWidgetManager.requestPinAppWidget(myWidgetProvider, null, pinnedWidgetCallback)
    } else {
        Toast.makeText(activity, "设备不支持动态添加小组件", Toast.LENGTH_SHORT).show()
    }
}

这个方法的功能是通过AppWidgetManagerAPP小组件管理器,请求将我们小组件的MyAppWidgetReceiver添加到设备的主屏幕(桌面)。

appWidgetManager.requestPinAppWidget(myWidgetProvider, null, pinnedWidgetCallback)

这个requestPinAppWidget方法需要通过传组件名和一个PendingIntentsuccessCallback。 PendingIntent 里的flags 标志我们传入:

PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE

  • FLAG_UPDATE_CURRENT: 表示如果这个 PendingIntent 已经存在,则更新其内容。

  • FLAG_IMMUTABLE: 确保 PendingIntent 是不可修改的(Android 12 及以上需要设置)。

通过appWidgetManager.requestPinAppWidget(myWidgetProvider, null, pinnedWidgetCallback)方法我们就可以把小组件,添加至用户的桌面啦。

注意 Android 版本兼容性

  • requestPinAppWidget 在 API 26(Android 8.0)及以上才支持;
  • 各大手机OS可能存在兼容性的问题;

小组件落地设置页

点击小组件的设置页面,要进入到APP中的小组件设置页面,这个页面也是使用Compose写的,贴出来给大家参考一下:

@Composable
fun AppWidgetSettingScreen(
    selectCode: String,
    isDarkSkin: Boolean,
    onClickStock: (StockInfo) -> Unit,
    onClickSkin: (Boolean) -> Unit,
    onClickSetWidget: () -> Unit={}
) {
    //3个股票市场的股票 美股
    val stockMarketList = arrayListOf<StockInfo>(
        txStock,
        mtStock,
        appleStock
    )
    val skinList = arrayListOf<Boolean>(
        false,
        true,
    )
    LazyColumn() {
        item {
            ListItem(
                headlineContent = { Text("股票", fontSize = 20.sp, fontWeight = FontWeight.Bold) },
            )
            HorizontalDivider()
        }

        items(stockMarketList) {
            ListItem(
                modifier = Modifier.clickable {
                    onClickStock(it)
                },
                headlineContent = { Text(it.name + "(${it.code})") },
                trailingContent = {
                    if (selectCode == it.code) {
                        Icon(
                            Icons.Default.Check,
                            contentDescription = "",
                            tint = Color.Blue
                        )
                    }
                },
            )
            HorizontalDivider()
        }
        item {
            ListItem(
                headlineContent = { Text("皮肤", fontSize = 20.sp, fontWeight = FontWeight.Bold) },
            )
            HorizontalDivider()
        }
        items(skinList) {
            ListItem(
                modifier = Modifier.clickable {
                    onClickSkin(it)
                },
                headlineContent = { Text(if (it) "深色" else "浅色") },
                trailingContent = {
                    if (isDarkSkin == it) {
                        Icon(
                            Icons.Default.Check,
                            contentDescription = "",
                            tint = Color.Blue
                        )
                    }
                },
            )
            HorizontalDivider()
        }
        item {
            ListItem(
                headlineContent = {
                    Text(
                        "设置小组件",
                        fontSize = 20.sp,
                        fontWeight = FontWeight.Bold
                    )
                },
            )
            HorizontalDivider()
            Column(
                modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
                horizontalAlignment = Alignment.CenterHorizontally,
            ) {
                Image(painter = painterResource(id = R.drawable.widget), contentDescription = "")
                Button(onClick = { onClickSetWidget()}) {
                    Text("一键添加至桌面")
                }
            }
        }
    }
}

总结

基于Jetpack Glance来实现Android小组件,真心很有趣,我们完全可以用这个为自己开发一个提醒类的便签或者todolist之类的工具。用自己开发的小组件来激励自己,提醒自己,今天辛苦啦,明天也要继续加油努力哦!

图片描述

最后附上源码地址,给有兴趣的朋友,提供一些实现思路:StockGlanceWidget