小部件兴起
天气地图等 时钟提醒等
苹果的小部件的确不错,还挺好看,但是安卓的其实也不差,前段时间写了一个完全用 Compose 写的天气应用:
从零到一写一个完整的 Compose 版本的天气,想着苹果的天气小部件挺好用,给安卓也整一个吧!就有了今天的文章,来看看今天实现的最终效果吧:
今天实现的样式 可以上下滑动来查看一周的天气哦
是不是也很炫酷,哈哈哈!这就是基于之前编写的天气应用写的(文末也有Github地址)。
虽然安卓在很多年前就有了小部件,但小部件在安卓手机里的使用并不多,甚至可能说很少,最多也就是手机出厂的时候自带的时间小部件。。。其实很多咱们常用应用都有很多小部件,由于使用的确实不多,所以存在感很低(顺带吐槽下,常用的软件都太流氓了,每个应用都有一堆功能一样的小部件,比如:抖音有好几个、头条也有好几个、爱奇艺、优酷等就不说了。。。)
为什么安卓中的小部件很少人使用呢?主要还是样式太丑,还有就是像上面说的那样太流氓就不想用。Google 其实都快把小部件给忘记了,但去年让苹果给提了下醒,想起了安卓中还有小部件这个东西呢,于是痛定思痛,将小部件做了一些大的更新及升级。
安卓小部件之痛
其实不光使用者不喜欢用安卓的小部件,开发者也不想开发小部件,这是为什么呢?由于小部件是依附在桌面上的,所以并不属于原本应用的进程,而如果想要跨进程修改布局的话就需要使用到 RemoteViews ,但 RemoteViews 不能说是难用,那是相当难用,不仅不能使用自定义 View,连咱们常用的 RecyclerView 等控件都不能使用,只能使用官方固定的几种控件,
可以支持以下布局类:
- FrameLayout 、 LinearLayout 、 RelativeLayout 、GridLayout
- 以及以下控件:AnalogClock(模拟时钟)、Button 、Chronometer 、ImageButton 、ImageView 、ProgressBar 、TextView 、ViewFlipper 、ListView 、GridView 、StackView 、AdapterViewFlipper
注:这块的控件指的是 Android 12之前的,Android 12中新增了一些新的控件,在下面的部分中会有介绍。
扯皮就先扯到这里吧,开始干活吧!
Android 12 中小部件的更新
刚才也说过,Google 这次在 Android 12中对小部件更新很大,这块来说下吧!
用户可重新设置原有小部件
在之前,用户如果想要重新设置小部件的话只能删除了再重新添加,但是在 Android 12 中,用户将无需通过删除和重新添加 widget 来调整这些原有设定。
设置方法其实很简单,只需要添加一行配置:
<?xml version="1.0" encoding="utf-8"?> <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" android:configure="com.zj.weather.common.widget.WeatherWidgetConfigureActivity" android:widgetFeatures="reconfigurable" ... />
上面配置有两个,widgetFeatures 就是 Android 12中新增的可重新设置小部件的配置项,另外一个是配置小部件的 Activity,想要使 widgetFeatures 起作用的话必须要配置 Activity,这很好理解,如果都不知道去哪配置小部件何谈重新设置呢!
小部件的尺寸限制
在 Android 12之前,Android 中的小部件大小其实特别混乱,每个应用在小部件中标柱的大小基本都是错的,比如应用写的大小是 4 * 1 ,当你将页面布局调整之后应用大小就有可能发生变化,就不再是 4 * 1 的大小了。
Google 有可能也知道这种情况,所以在 Android 12 中增加了小部件的尺寸限制,除了现有的 minWidth、minHeigh、minResizeWidth 以及 minResizeHeight 以外,还新增了新的 maxResizeWidth 、 maxResizeHeight 、 targetCellWidth 和 targetCellHeight 属性,下面来具体说下新增的几个属性的含义。
- maxResizeWidth:定义用户所能够调整的小部件尺寸的最大宽度
- maxResizeHeight:定义用户所能够调整的小部件尺寸的最大高度
- targetCellWidth:定义设备主屏幕上的小部件默认宽度所占格数(即使不同型号的手机中也会占定义好的格数,但手机系统版本必须在 Android 12 及以上)
- targetCellHeight:定义设备主屏幕上的小部件默认高度所占格数
如果之前有 targetCellWidth 和 targetCellHeight 属性的话,小部件也不至于像现在这么乱而导致用户不想使用。
新的小部件控件
Android 12 使用以下现有控件新增了对有状态行为的支持:
- CheckBox
- Switch
- RadioButton
上面这几个控件大家应该非常熟悉了,但在 Android 12 之前在小部件中想要使用的话也是不可能的。
小部件UI更新
这块其实大家应该都看过了,就一带而过吧,就是为小部件默认添加了一个圆角,可以通过 system_app_widget_background_radius 和 system_app_widget_inner_radius 系统参数来设置微件圆角的半径。
这里来放一张官方文档中的图吧。
干活了干活了
上面叨叨了这么多,先是介绍了下小部件的前世今生,然后又说了下 Android 12中的更新内容,终于要准备干活了。
编写配置文件
在清单中声明小部件
如果想要在 Android 中添加一个小部件的话首先应该在 AndroidManifest.xml 中进行声明,因为小部件实际上也是一个 BroadcastReceiver,大家都知道四大组件想要使用的话都需要在 AndroidManifest.xml 中进行声明,所以咱们先来在清单中声明小部件。
<receiver android:name=".common.widget.WeatherWidget" 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/weather_widget_info" /> </receiver>
<receiver> 元素需要 android:name 属性,该属性指定小部件使用的 AppWidgetProvider(AppWidgetProvider的父类就是BroadcastReceiver)。
<intent-filter> 中的 <action> 元素指定小部件接受 ACTION_APPWIDGET_UPDATE 广播。这是必须明确声明的唯一一项广播,用以接收小部件的增删改等信息。
<meta-data> 元素指定小部件的资源,并且需要以下属性:
- android:name - 指定元数据名称。必须使用 android.appwidget.provider 将数据标识为 AppWidgetProviderInfo 描述符。
- android:resource - 指定 AppWidgetProviderInfo 资源位置。
编写小部件的配置文件
上面在清单文件中声明了小部件,下面来编写下小部件的配置文件,根据上面的代码可以看到这个配置文件放在了 xml 文件下,具体路径为:res -> xml,如果本地没有这个文件夹的话创建一个就好。
<?xml version="1.0" encoding="utf-8"?> <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" android:configure="com.zj.weather.common.widget.WeatherWidgetConfigureActivity" android:initialKeyguardLayout="@layout/weather_widget" android:initialLayout="@layout/weather_widget" android:minWidth="170dp" android:minHeight="90dp" android:previewImage="@mipmap/weather_widget" android:resizeMode="horizontal|vertical" android:targetCellWidth="3" android:targetCellHeight="2" android:updatePeriodMillis="86400000" android:widgetCategory="home_screen" android:widgetFeatures="reconfigurable" />
可以看到这里已经使用到了上面讲的 Android 12中的新的配置,并且设置了最小的宽高,还有预览图片等等,下面来详细看下每一项配置都是干啥的吧。
- minWidth 和 minHeight :指定小部件默认情况下占用的最小空间。
注意:为使小部件能够在设备间移植,小部件的最小大小不得超过 4 x 4 单元格。
- minResizeWidth和minResizeHeight:指定小部件的绝对最小大小。
- updatePeriodMillis:定义小部件框架通过调用 onUpdate() 回调方法来从 AppWidgetProvider 请求更新的频率应该是多大。
- initialLayout: 指向用于定义小部件布局的布局资源。
- configure: 定义要在用户添加小部件时启动以便用户配置小部件属性的 Activity。。
- previewImage: 指定预览来描绘小部件经过配置后是什么样子的,用户在选择小部件时会看到该预览。
- autoAdvanceViewId :指定应由小部件的托管应用自动跳转的小部件子视图的视图 ID。
- resizeMode :指定可以按什么规则来调整微件的大小,可选值为“horizontal|vertical”,一般默认设置横竖都可以进行调整。
- minResizeHeight :指定可将微件大小调整到的最小高度。
- minResizeWidth: 指定可将微件大小调整到的最小宽度。
- widgetCategory:声明小部件是否可以显示在主屏幕 (home_screen) 或锁定屏幕 (keyguard) 上。只有低于 5.0 的 Android 版本才支持锁定屏幕微件。对于 Android 5.0 及更高版本,只有 home_screen 有效,所以现在将这个值写为home_screen即可。
编写布局
根布局
配置文件写好了来编写下布局吧,来考虑下布局应该怎么写,通过文章开头的图可以知道这是一个 StackView ,那就先来写下根布局吧。
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@android:id/background" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#00000000" android:theme="@style/Theme.Design.NoActionBar"> <StackView android:id="@+id/stack_view" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:loopViews="true" /> </FrameLayout>
子布局
可以看到布局很简单,只放了一个 StackView,它继承自 AdapterViewAnimator ,同 ListView 和 GridView 一样,StackView 也需要子布局,那就来吧。
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/widget_ll_item"> <ImageView android:id="@+id/widget_iv_bg"/> <LinearLayout> <TextView android:id="@+id/widget_tv_city" /> <TextView android:id="@+id/widget_tv_date"/> <ImageView android:id="@+id/widget_iv_icon" /> <ImageView android:id="@+id/widget_iv_small_icon" /> <TextView android:id="@+id/widget_tv_temp" /> </LinearLayout> </FrameLayout>
由于篇幅原因将布局给简化了下,详细布局可以看文末提供的项目源码。
包含集合小部件的清单
由于咱们的布局中有 StackView ,包含集合的小部件除了上面中列出的要求之外,要使包含集合的小部件能够绑定到 RemoteViewsService,还必须在清单文件中使用 BIND_REMOTEVIEWS 权限来声明该服务。这样可防止其他应用自由访问小部件的数据。
<service android:name=".common.widget.WeatherWidgetService" android:exported="false" android:permission="android.permission.BIND_REMOTEVIEWS" />
包含集合小部件的 AppWidgetProvider 类
与常规小部件一样,AppWidgetProvider 子类中的大部分代码通常都在 onUpdate() 中。在创建包含集合的小部件时,必须调用 setRemoteAdapter() 来设置适配器,这样将告知集合视图要从何处获取其数据。然后,RemoteViewsService 可以返回 RemoteViewsFactory 实现,并且微件可以提供适当的数据。当调用此方法时,必须传递指向 RemoteViewsService 实现的 Intent,以及指定要更新的小部件的小部件 ID,来看看具体实现吧。
override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { appWidgetIds.forEach { appWidgetId-> updateAppWidget(context, appWidgetManager, appWidgetId) val cityInfo = loadTitlePref(context, appWidgetId) // 设置布局 val views = RemoteViews(context.packageName, R.layout.weather_widget) val intent = Intent(context, WeatherWidgetService::class.java).apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) } views.apply { // 设置 StackView 适配器 setRemoteAdapter(R.id.stack_view, intent) setEmptyView(R.id.stack_view, R.id.empty_view) } val toastPendingIntent: PendingIntent = Intent( context, WeatherWidget::class.java ).run { action = CLICK_ITEM_ACTION putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) PendingIntent.getBroadcast( context, 0, this, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } // 设置点击事件的模版 views.setPendingIntentTemplate(R.id.stack_view, toastPendingIntent) appWidgetManager.updateAppWidget(appWidgetId, views) } }
RemoteViewsService实现
上面说过,想要创建包含集合的小部件的话必须设置适配器,这里咱们就来实现下。
class WeatherWidgetService : RemoteViewsService() { override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { return WeatherRemoteViewsFactory(this.applicationContext, intent) } }
可以看到 WeatherWidgetService 继承自 RemoteViewsService ,并自己实现了 WeatherRemoteViewsFactory。
class WeatherRemoteViewsFactory(private val context: Context, intent: Intent) : RemoteViewsService.RemoteViewsFactory, CoroutineScope by MainScope() { private var cityInfo: CityInfo? = null init { intent.getStringExtra(CITY_INFO)?.apply { cityInfo = Gson().fromJson(this, CityInfo::class.java) } } override fun getViewAt(position: Int): RemoteViews { if (widgetItems.size != WEEK_COUNT) { return RemoteViews(context.packageName, R.layout.weather_widget_loading) } return RemoteViews(context.packageName, R.layout.widget_item).apply { val weather = widgetItems[position] setTextViewText(R.id.widget_tv_temp, "${weather.min}-${weather.max}℃") setTextViewText( R.id.widget_tv_city, "${cityInfo?.city ?: ""} ${cityInfo?.name ?: "北京"}" ) setImageViewBitmap( R.id.widget_iv_bg, fillet(context = context, bitmap = zoomImg(context, weather.icon), roundDp = 10) ) layoutAdapter(weather.icon) setTextViewText(R.id.widget_tv_date, weather.time) setImageViewResource( R.id.widget_iv_icon, IconUtils.getWeatherIcon(weather.icon) ) // 设置点击事件 val fillInIntent = Intent().apply { putExtra(EXTRA_ITEM, weather.time) } setOnClickFillInIntent(R.id.widget_ll_item, fillInIntent) } } override fun getLoadingView(): RemoteViews { // 加载数据时的布局 return RemoteViews(context.packageName, R.layout.weather_widget_loading) } }
上面编写了 RemoteViewsFactory 的实现,省略了一些不重要的方法,大家可以去源码中进行查看。
设置配置Activity
配置 Activity 在上面咱们已经说过如何添加到小部件的配置文件中,剩下的就和普通的 Activity 一样了。
由于小部件不支持 Compose ,所以上面咱们都是编写的 Layout ,但是在 Activity 中就可以使用 Compose 了!
@AndroidEntryPoint class WeatherWidgetConfigureActivity : BaseActivity() { private val viewModel by viewModels<CityListViewModel>() public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 刷新城市数据 viewModel.refreshCityList() setContent { PlayWeatherTheme { Surface(color = MaterialTheme.colors.background) { ConfigureWidget( viewModel, onCancelListener = { setResult(RESULT_CANCELED) finish() }) { cityInfo -> onConfirm(cityInfo) } } } } }
这样 Layout 布局咱们就不需要编写了,下面来看下 ConfigureWidget的实现吧。
@OptIn(ExperimentalPagerApi::class) @Composable private fun ConfigureWidget( viewModel: CityListViewModel, onCancelListener: () -> Unit, onConfirmListener: (CityInfo) -> Unit ) { val cityList by viewModel.cityInfoList.observeAsState(arrayListOf()) val buttonHeight = 45.dp val pagerState = rememberPagerState() Column(modifier = Modifier.fillMaxSize()) { Spacer(modifier = Modifier.height(80.dp)) Text( text = "小部件城市选择", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, fontSize = 26.sp, color = Color(red = 53, green = 128, blue = 186) ) Box(modifier = Modifier.weight(1f)) { HorizontalPager( state = pagerState, count = cityList.size, modifier = Modifier.fillMaxSize() ) { page -> Card( shape = RoundedCornerShape(10.dp), backgroundColor = MaterialTheme.colors.onSecondary, modifier = Modifier.size(300.dp) ) { val cityInfo = cityList[page] Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { Text(text = cityInfo.name, fontSize = 30.sp) } } } DrawIndicator(pagerState = pagerState) } Spacer(modifier = Modifier.height(50.dp)) Divider( modifier = Modifier .fillMaxWidth() .height(1.dp) ) Row { TextButton( modifier = Modifier .weight(1f) .height(buttonHeight), onClick = { onCancelListener() } ) { Text( text = stringResource(id = R.string.city_dialog_cancel), fontSize = 16.sp, color = Color(red = 53, green = 128, blue = 186) ) } Divider( modifier = Modifier .width(1.dp) .height(buttonHeight) ) TextButton( modifier = Modifier .weight(1f) .height(buttonHeight), onClick = { onConfirmListener(cityList[pagerState.currentPage]) } ) { Text( text = stringResource(id = R.string.city_dialog_confirm), fontSize = 16.sp, color = Color(red = 53, green = 128, blue = 186) ) } } } }
看着代码多,其实布局很简单,一个线性布局包裹着标题、城市ViewPager、确定和取消按钮,然后通过高阶函数的方式将确定按钮的点击事件回调出去。
遇到的坑
OK,到这里本篇文章基本就算结束了,上面的这些一般在别的博客中都能搜到,但是重点来了,有很多东西网上是搜不到的,包括在官方文档中写的也是很笼统,并没有实际的应用案例,下面就来详细说一说吧。
布局适配问题
在苹果中小部件的布局在添加的时候就固定好了,后面是不可以进行修改的,想要修改的话只能是删除掉然后重新进行添加,但是在安卓中小部件的大小是可以进行拉伸的,长按即可进行宽高的调整,所以就难免出现布局适配的问题。
Android 12 之前的解决方案
在 Android 12 之前如果想适配不同宽高下显示不同布局的话需要重写下 onAppWidgetOptionsChanged() 方法,然后从中获取到当前小部件的最小宽高,根据宽高的不同就可以进行布局适配了。
override fun onAppWidgetOptionsChanged( context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle ) { super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions) // See the dimensions and val options = appWidgetManager.getAppWidgetOptions(appWidgetId) // 获取小部件最小的宽高 val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT) // 计算小部件的占的格数 val rows: Int = getCellsForSize(minHeight) val columns: Int = getCellsForSize(minWidth) XLog.e("rows:$rows columns:$columns") updateAppWidget(context, appWidgetManager, appWidgetId, rows, columns) }
上面代码中提到了一个 getCellsForSize() 方法,这个方法是根据官方文档中写的计算小部件格数的方法进行定义的,来看下吧:
/** * 返回给定大小的小部件所需的单元格数。 * * @param size 以 dp 为单位的小部件大小。 * @return 单元格数量的大小。 */ fun getCellsForSize(size: Int): Int { var n = 2 while (70 * n - 30 < size) { ++n } return n - 1 }
注意!!! 这里所计算出的单元格数量不一定是正确的,在有的手机上可能没问题,但一些手机上就有可能出问题,大家一定要注意,这也是没办法的事,手机厂商太多了,每个桌面的实现方式也略有不同,这事是正常的。
Android 12 之后的解决方案
在 Android 12 之后,可以通过响应式布局来进行适配,首先需要创建一组不同尺寸的布局,然后调用 updateAppWidget() 函数,并传入一组布局,当小部件尺寸发生变化时,系统会自动更改布局。
val viewMapping = mapOf( SizeF(150f, 110f) to RemoteViews( context.packageName, 布局 ), SizeF(250f, 110f) to RemoteViews( context.packageName, 布局 ), ) // 指示小部件管理器更新小部件 appWidgetManager.updateAppWidget(appWidgetId, RemoteViews(viewMapping))
这样确实会简单一些,相当于是 RemoteViews 内部为我们做了处理,无需再重写 onAppWidgetOptionsChanged() 方法了,但这样的话只能在 Android 12 及之后的版本中进行使用,大家根据需求来使用吧。
StackView 数据刷新问题
这个问题是真的挺恶心,也有可能是我水平有限,官方给出的刷新是 notifyAppWidgetViewDataChanged() 方法,这块搞的时候差点给我搞疯。。。
也是我自己的问题,人家都告诉刷新的流程了还写的有问题。
我之前是将天气的数据请求放在 onCreate 方法中,然后通过 runBlocking() 方法将异步转为同步,获取到数据再执行下一步,但这样的话就会 anr。。
然后我又写了一个高阶函数:
/** * 获取之后一周的天气 * * @param context / * @param cityInfo 需要获取天气的城市 * @param onSuccessListener 获取成功的回调 */ fun getWeather7Day( context: Context, cityInfo: CityInfo?, onSuccessListener: (MutableList<WeekWeather>) -> kotlin.Unit ) { QWeather.getWeather7D(context, getLocation(cityInfo = cityInfo), getDefaultLocale(context), Unit.METRIC, object : QWeather.OnResultWeatherDailyListener { override fun onError(e: Throwable) { XLog.e("getWeather7Day1 onError: $e") showToast(context, e.message) } override fun onSuccess(weatherDailyBean: WeatherDailyBean?) { onSuccessListener(weatherDailyBean.daily) } }) }
获取到数据的时候进行回调,然后将数据进行赋值,但数据就是不刷新。。。
也是太傻了,数据赋值完刷新下不就好了。。。
private fun notifyWeatherWidget( context: Context, appWidgetId: Int ) { WeatherWidgetUtils.getWeather7Day(context = context, cityInfo = cityInfo) { items -> // 赋值 widgetItems = items val mgr = AppWidgetManager.getInstance(context) // 刷新 mgr.notifyAppWidgetViewDataChanged( appWidgetId, R.id.stack_view ) XLog.e(TAG, "init: $widgetItems") } }
这就可以了,再来放下官方的流程图吧。
桌面图片显示圆角
这块是为了展示天气背景而出的问题,小部件中不支持自定义 View,所以就只能通过图片本身了,需要将图片加上圆角,这很简单,网上一搜一大堆,但我设置完了之后并不是我想要的效果,我想要的是宽高一样,这也简单,加一行配置就行:
android:scaleType="centerCrop"
再次运行发现设置的圆角没了。。。好吧,被切了,那只能先自己切成想要的大小,然后再添加圆角了。。。
/** * 将普通Bitmap按照centerCrop的方式进行截取 */ fun zoomImg(bm: Bitmap): Bitmap { val w = bm.width // 得到图片的宽,高 val h = bm.height val retX: Int val retY: Int val wh = w.toDouble() / h.toDouble() val nwh = w.toDouble() / w.toDouble() if (wh > nwh) { retX = h * w / w retY = h } else { retX = w retY = w * w / w } val startX = if (w > retX) (w - retX) / 2 else 0 //基于原图,取正方形左上角x坐标 val startY = if (h > retY) (h - retY) / 2 else 0 val bit = Bitmap.createBitmap(bm, startX, startY, retX, retY, null, false) bm.recycle() return bit }
这样设置完再切圆角就没问题了,最后再将图片设置到 ImageView 中。
setImageViewBitmap( R.id.widget_iv_bg, fillet(context = context, bitmap = zoomImg(context, weather.icon), roundDp = 10) )
打完收工
如果你在学习或者想要学习关于 Compose 的话,亦或是想学习安卓的小部件,这个项目应该或多或少会对你有点帮助,如果对你有帮助的话,别忘记点个 Star,感激不尽。