背景
最近几年,我发现Android手机随着CPU性能,屏幕越来越好,不只是手机上给人的感觉越来越流畅了,Android平板也是相比2018年那会体验好了不少。屏幕和性能的提升,还有跨屏互联的便捷性提高,让喜欢尝鲜的人开始使用平板进行一些简单的办公。
在系统层面,Android 12 之后的各版本中,各大手机厂商的OS对大屏幕的适配也做的越来越好。分屏功能和小组件的玩法日益丰富多样,为用户带来了更多个性化和高效的操作体验。
Android桌面小组件我记得这个东西并非新生事物,早在 Android 4.4 版本甚至更早便已出现了 。不过当时的小组件界面较为简陋,功能也不够完善,可真是丑陋的小废物。而且因为大家的电池也不怎么耐用,怎敢轻易在桌面上挂个小组件,怕它不断在后台运行,造成电量浪费。
后来估计是谷歌公司看到iOS的小组件做的不错,同时以华为为代表的平板销量增多,对大屏幕的适配需求也在渐渐增多,于是谷歌在 Android 12L 版本以后对小组件进行了重新设计,使得小组件在功能和外观上都有了显著提升,重新焕发生机。
实现效果
这个小组件具备更换股票和换肤的功能,同时也支持直接在APP内把小组件一键添加到用户桌面。
一键添加到用户桌面
小组件的特点
- 便捷信息展示:小组件作为桌面上的信息入口,无需打开应用即可快速获取用户所关心的数据,极大地提升了用户效率。如果我们所关心的数据,需要在打开APP后点个三级页面以上才能看到的话,这小组件的优势就体现出来了。
- 能充分利用大屏幕:现在的平板11寸,13寸的,多开几个窗口,桌面挂几个小组件,妥妥的生产力。
- 目前只能使用原生的方式来开发: 我看过现在有些公司新开发的APP,直接是所有UI页面能用跨平台的Flutter或者ReactNative,就用跨平台的,主打一个跨平台First,给公司降本增效。可是遇到开发小组件,还是得由熟悉这个系统的开发者来做(原生开发者最后的自留地吗?此刻想大喊那句,你不要过来啊。),
小组件的应用需求
当你打开手机APP桌面的小组件添加功能时,会发现做了小组件的 APP 多数集中在视频、新闻以及财经类领域。这是因为新闻资讯和股票信息,都是用户日常经常打开手机查看的内容。用户希望能够在不打开应用的情况下,快速获取这些关键信息。买过股票的人都知道盯盘的成瘾性吧,股票分时图上下起伏,我们的心率也跟随上下起伏,可谓盈亏不重要,玩的就是心跳。
今天我们就来实现一个简易的Android小组件,
以前学习写Android小组件,是用RemoteViews来实现的,需要写XML布局,BroadcastReceiver
更新需要发送PendingIntent
等等一系列操作才能完成。既然是现代Android开发,咱们就用用Jetpack Compose的兄弟项目Jetpack Glance来开发桌面小组件,Compose家族成员也是越来越多。developer.android.com/jetpack/and…
下面如果我们使用Jetpack Glance来实现的话,看看是怎么个事?
实现步骤
- 先定义一个继承自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)
}
}
}
}
- 接着我们定义一个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…
- 接着在我们要在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:minWidth
和android: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 的小组件并添加到桌面,着实麻烦些。
下面我们来看看该怎么实现:
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()
}
}
这个方法的功能是通过AppWidgetManager
APP小组件管理器,请求将我们小组件的MyAppWidgetReceiver添加到设备的主屏幕(桌面)。
appWidgetManager.requestPinAppWidget(myWidgetProvider, null, pinnedWidgetCallback)
这个requestPinAppWidget
方法需要通过传组件名和一个PendingIntent
的successCallback
。
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