赞
踩
如果要在Android系统中找一个一直存在,但一直被人忽略,而且有十分好用的功能,那么Widget,一定算一个。这个从Android 1.x就已经存在的功能,经历了近10年的迭代,在遭到无数无视和白眼之后,又重新回到了大家的视线之内,当然,也有可能是App内部已经没东西好卷了,所以大家又把目光放到了App之外,但不管怎样,Widget在Android 12之后,都开始焕发一新,官网镇楼,让我们重新来了解下这个最熟悉的陌生人。
https://developer.android.com/develop/ui/views/appwidgets/overview
Widget使用的是RemoteView,这与Notification的使用如出一辙,RemoteView是继承自Parcelable的组件,可以跨进程使用。在Widget中,通过AppWidgetProvider来管理Widget的行为,通过RemoteView来对Widget进行布局,通过AppWidgetManager来对Widget进行刷新。基本的使用方式,我们可以通过一套模板代码来实现,在Android Studio中,直接New Widget即可。这样Android Studio就可以自动为你生成一个Widget的模板代码,详细代码我们就不贴了,我们来分析下代码的组成。
首先,每个Widget都包含一个AppWidgetProvider。这是Widget的逻辑管理类,它继承自BroadcastReceiver,然后,我们需要在清单中注册这个Receiver,并在meta-data中指定它的配置文件,它的配置文件是一个xml,这里描述的是添加Widget时展示的一些信息。
从这些地方来看,其实Widget的使用还是比较简单的,所以本文也不准备来讲解这些基础知识,下面我们针对开发中会遇到的一些实际需求来进行分析。
1
appwidget-provider配置文件
这个xml文件虽然简单,但还是有些有意思的东西的。
在这里我们可以为Widget配置尺寸信息,通过maxResizeWidth、maxResizeHeight和minWidth、minHeight,我们可以大致将Widget的尺寸控制在MxN的格子内,这也是Widget在桌面上的展示方式,它并不是通过指定的宽高来展示的,而是桌面所占据的格子数。
官方设计文档中,对格子数和尺寸的转换标准,有一个表格,如下所示。
我们在设计的时候,也应该尽量遵循这个尺寸约束,避免在桌面上展示异常。在Android12之后,描述文件中,还增加了targetCellWidth和targetCellHeight两个参数,他们可以直接指定Widget所占据的格子数,这样更加方便,但由于它仅支持Android12+,所以,通常这些属性会一起设置。
有意思的是这个尺寸标准并不适用于所有的设备,因为ROM的碎片化问题,各个厂商的桌面都不一样,所以。。。只能参考参考。
这个参数用于指定Widget的被动刷新频率,它由系统控制,所以具有很强的不定性,而且它也不能随意设置,官网上对这个属性的限制如下所示。
updatePeriodMillis只支持设置30分钟以上的间隔,即1800000milliseconds,这也是为了保证后台能耗,即使你设置了小于30分钟的updatePeriodMillis,它也不会生效。
对于Widget来说,updatePeriodMillis控制的是系统被动刷新Widget的频率,如果当前App是活着的,那么随时可以通过广播来修改Widget。
而且这个值很有可能因为不同ROM而不同,所以,这是一个不怎么稳定的刷新机制。
除了上面我们提到的一些属性,还有一些需要留意的。
resizeMode:拉伸的方向,可以设置为horizontal|vertical,表示两边都可以拉伸。
widgetCategory:对于现在的App来说,只能设置为home_screen了,5.0之前可以设置为锁屏,现在基本已经不用了。
widgetFeatures:这是Android12之后新加的属性,设置为reconfigurable之后,就可以直接调整Widget的尺寸,而不用像之前那样先删除旧的Widget再添加新的Widget了。
这个配置文件的主要作用,就是在添加Widget时,展示一个简要的描述信息,所以,一个App中是可以存在多个描述xml文件的,而且有几个描述文件,添加时,就会展示几个Widget的缩略图,通常我们会创建几个不同尺寸的Widget,例如2x2、4x2、4x1等,并创建多个xml面试文件,从而让用户可以选择添加哪一个Widget。
不过在Android12之后,设置一个Widget,通过拉动来改变尺寸,就可以动态改变Widget的不同展示效果了,但这仅限于Android12+,所以需要权衡使用利弊。
通过configure属性可以配置添加Widget时的Configure Activity,这个在创建默认的Widget项目时就已经可以选择创建了,所以不多讲了,实际上就是一个简单的Activity,你可以配置一些参数,写入SP,然后在Widget中进行读取,从而实现自定义配置。
2
应用内唤起Widget的添加页面
文档如下:
https://developer.android.com/reference/android/appwidget/AppWidgetManager#requestPinAppWidget(android.content.ComponentName,%20android.os.Bundle,%20android.app.PendingIntent)
代码如下所示。
- fun requestToPinWidget(context: Context) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val appWidgetManager: AppWidgetManager? = getSystemService(context, AppWidgetManager::class.java)
- appWidgetManager?.let {
- val myProvider = ComponentName(context, NewAppWidget::class.java)
- if (appWidgetManager.isRequestPinAppWidgetSupported) {
- val pinnedWidgetCallbackIntent = Intent(context, MainGroupActivity::class.java)
- val successCallback: PendingIntent = PendingIntent.getBroadcast(context, 0,
- pinnedWidgetCallbackIntent, PendingIntent.FLAG_UPDATE_CURRENT)
- appWidgetManager.requestPinAppWidget(myProvider, null, successCallback)
- }
- }
- }
- }
通过这种方式,就可以直接唤起Widget的添加入口,从而避免用户手动在桌面中进行添加。
3
应用内主动更新Widget
- val manager = AppWidgetManager.getInstance(this)
- val ids = manager.getAppWidgetIds(ComponentName(this, XXXWidget::class.java))
- val updateIntent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE)
- updateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
- sendBroadcast(updateIntent)
这种方式的本质就是发送更新的广播,除此之外,还可以使用AppWidgetManager来直接对Widget进行更新,代码如下。
- val remoteViews = RemoteViews(context.packageName, R.layout.xxx)
- val appWidgetManager = AppWidgetManager.getInstance(context)
- val componentName = ComponentName(context, XXXWidgetProvider::class.java)
- appWidgetManager.updateAppWidget(componentName, remoteViews)
这种方式就是通过AppWidgetManager来对指定的Widget进行修改,使用新的RemoteViews来更新当前Widget。
这两种方式一种是主动替换,一种是被动刷新,具体的使用场景可以根据业务的不同来使用不同的方式。
4
应用外被动更新Widget
前面我们提到了通过设置updatePeriodMillis来进行Widget的更新,但是这种方式存在一些使用限制,如果你需要完全自主的控制Widget的刷新,那么可以使用AlarmManager或者WorkManager,类似的代码如下所示。
- private fun scheduleUpdates(context: Context) {
- val activeWidgetIds = getActiveWidgetIds(context)
- if (activeWidgetIds.isNotEmpty()) {
- val nextUpdate = ZonedDateTime.now() + WIDGET_UPDATE_INTERVAL
- val pendingIntent = getUpdatePendingIntent(context)
- context.alarmManager.set(
- AlarmManager.RTC_WAKEUP,
- nextUpdate.toInstant().toEpochMilli(), // alarm time in millis since 1970-01-01 UTC
- pendingIntent
- )
- }
- }
当然,这种方式也同样会受到ROM的限制,所以说,不管是WorkManager还是AlarmManager,或者是updatePeriodMillis,都不是稳定可靠的,随它去吧,强扭的瓜不甜。
一般来说,使用updatePeriodMillis就够了,Widget的目的是为了引流,对内容的实时性其实并不是要求的那么严格,updatePeriodMillis在大部分场景下,都是够用的。
5
多布局动态适配
- internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, widgetData: AppWidgetData) {
- val views41 = RemoteViews(context.packageName, R.layout.new_app_widget41).also { updateView(it, context, appWidgetId, widgetData) }
- val views42 = RemoteViews(context.packageName, R.layout.new_app_widget42).also { updateView(it, context, appWidgetId, widgetData) }
- val views21 = RemoteViews(context.packageName, R.layout.new_app_widget21).also { updateView(it, context, appWidgetId, widgetData) }
- val viewMapping: Map<SizeF, RemoteViews> = mapOf(
- SizeF(180f, 110f) to views21,
- SizeF(270f, 110f) to views41,
- SizeF(270f, 280f) to views42
- )
- appWidgetManager.updateAppWidget(appWidgetId, RemoteViews(viewMapping))
- }
-
- private fun updateView(remoteViews: RemoteViews, context: Context, appWidgetId: Int, widgetData: AppWidgetData) {
- remoteViews.setTextViewText(R.id.xxx, widgetData.xxx)
- }
它的核心就是RemoteViews(viewMapping),通过这个就可以动态适配当前用户选择的尺寸。
那么如果是Android12之前呢?
我们需要重写onAppWidgetOptionsChanged回调来获取当前Widget的宽高,从而修改不同的布局,模板代码如下所示。
- override fun onAppWidgetOptionsChanged(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle) {
- super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
- 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 = getWidgetCellsM(minHeight)
- val columns: Int = getWidgetCellsN(minWidth)
- updateAppWidget(context, appWidgetManager, appWidgetId, rows, columns)
- }
-
- fun getWidgetCellsN(size: Int): Int {
- var n = 2
- while (73 * n - 16 < size) {
- ++n
- }
- return n - 1
- }
-
- fun getWidgetCellsM(size: Int): Int {
- var m = 2
- while (118 * m - 16 < size) {
- ++m
- }
- return m - 1
- }
其中的计算公式,n x m:(73n-16)x(118m-16)就是文档中提到的算法。
但是这种方案有一个致命的问题,那就是不同的ROM的计算方式完全不一样,有可能在Vivo上一个格子的高度只有80,但是在Pixel中,一个格子就是100,所以,在不同的设备上显示的n x m不一样,也是很正常的事。
也正是因为这样的问题,如果不是只在Android 12+的设备上使用,那么通常都是固定好Widget的大小,避免使用动态布局,这也是没办法的权衡之举。
6
RemoteViews行为
remoteViews.setTextViewText(R.id.title, widgetData.xxx)
再比如点击后刷新Widget,实际上就是创建一个PendingIntent。
- val intentUpdate = Intent(context, XXXAppWidget::class.java).also {
- it.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
- it.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId))
- }
- val pendingUpdate = PendingIntent.getBroadcast(
- context, appWidgetId, intentUpdate,
- PendingIntent.FLAG_UPDATE_CURRENT)
- views.setOnClickPendingIntent(R.id.btn, pendingUpdate)
原理
RemoteViews通常用在通知和Widget中,分别通过NotificationManager和AppWidgetManager来进行管理,它们则是通过Binder来和SystemServer进程中的NotificationManagerService以及AppWidgetService进行通信,所以,RemoteViews实际上是运行在SystemServer中的,我们在修改RemoteViews时,就需要进行跨进程通信了,而RemoteViews封装了一系列跨进程通信的方法,简化了我们的调用,这也是为什么RemoteViews不支持全部的View方法的原因,RemoteViews抽象了一系列的set方法,并将它们抽象为统一的Action接口,这样就可以提供跨进程通信的效率,同时精简核心的功能。
7
如何进行后台请求
Widget在后台进行更新时,通常会请求网络,然后根据返回数据来修改Widget的数据展示。
AppWidgetProvider本质是广播,所以它拥有和广播一致的生命周期,ROM通常会定制广播的生命周期时间,例如设置为5s、7s,如果超过这个时间,那么就会产生ANR或者其它异常。
所以,我们一般不会把网络请求直接写在AppWidgetProvider中,一个比较好的方式,就是通过Service来进行更新。
首先我们创建一个Service,用来进行后台请求。
- class AppWidgetRequestService : Service() {
-
- override fun onBind(intent: Intent): IBinder? {
- return null
- }
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- val appWidgetManager = AppWidgetManager.getInstance(this)
- val allWidgetIds = intent?.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS)
- if (allWidgetIds != null) {
- for (appWidgetId in allWidgetIds) {
- BackgroundRequest.getWidgetData {
- NewAppWidget.updateAppWidget(this, appWidgetManager, appWidgetId, AppWidgetData(book1Cover = it))
- }
- }
- }
- return super.onStartCommand(intent, flags, startId)
- }
- }
在onStartCommand中,我们创建一个协程,来进行真正的网络请求。
- object BackgroundRequest : CoroutineScope by MainScope() {
- fun getWidgetData(onSuccess: (result: String) -> Unit) {
- launch(Dispatchers.IO) {
- val response = RetrofitClient.getXXXApi().getXXXX()
- if (response.isSuccess) {
- onSuccess(response.data.toString())
- }
- }
- }
- }
所以,在AppWidgetProvider的update里面,就需要进行下修改,将原有逻辑改为对Service的启动。
- class NewAppWidget : AppWidgetProvider() {
- override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
- val intent = Intent(context.applicationContext, AppWidgetRequestService::class.java)
- intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
- context.startForegroundService(intent)
- }
- }
8
动画
有必要这么卷吗,Widget里面还要加动画。由于RemoteViews里面不能实现正常的View动画,所以,Widget里面的动画基本都是通过类似「帧动画」的方式来实现的,即将动画抽成一帧一帧的图,然后通过Animator来进行切换,从而实现动画效果,群友给出了一篇比较好的实践,大家可以参考参考,我就不卷了。
https://juejin.cn/post/7048623673892143140
Widget的使用场景主要还是以实用功能为主,只有让用户觉得有用,才能锦上添花给App带来更多的活跃,否则只能是鸡肋。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。