Android-Widget重装上阵
点击上方蓝字关注我,知识会给你力量
如果要在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的使用还是比较简单的,所以本文也不准备来讲解这些基础知识,下面我们针对开发中会遇到的一些实际需求来进行分析。
appwidget-provider配置文件
这个xml文件虽然简单,但还是有些有意思的东西的。
尺寸
在这里我们可以为Widget配置尺寸信息,通过maxResizeWidth、maxResizeHeight和minWidth、minHeight,我们可以大致将Widget的尺寸控制在MxN的格子内,这也是Widget在桌面上的展示方式,它并不是通过指定的宽高来展示的,而是桌面所占据的格子数。
官方设计文档中,对格子数和尺寸的转换标准,有一个表格,如下所示。
我们在设计的时候,也应该尽量遵循这个尺寸约束,避免在桌面上展示异常。在Android12之后,描述文件中,还增加了targetCellWidth和targetCellHeight两个参数,他们可以直接指定Widget所占据的格子数,这样更加方便,但由于它仅支持Android12+,所以,通常这些属性会一起设置。
有意思的是这个尺寸标准并不适用于所有的设备,因为ROM的碎片化问题,各个厂商的桌面都不一样,所以。。。只能参考参考。
updatePeriodMillis
这个参数用于指定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
通过configure属性可以配置添加Widget时的Configure Activity,这个在创建默认的Widget项目时就已经可以选择创建了,所以不多讲了,实际上就是一个简单的Activity,你可以配置一些参数,写入SP,然后在Widget中进行读取,从而实现自定义配置。
应用内唤起Widget的添加页面
大部分时候,我们都是通过在桌面上长按的方式来添加Widget,但是在Android API 26之后,系统提供了一直新的方式来在应用内唤起——requestPinAppWidget。
文档如下。
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的添加入口,从而避免用户手动在桌面中进行添加。
应用内主动更新Widget
前面我们提到了,当App活着的时候,可以主动来更新Widget,而且有两种方式可以实现,一种是通过广播ACTION_APPWIDGET_UPDATE,触发Widget的update回调,从而进行更新,代码如下所示。
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。
这两种方式一种是主动替换,一种是被动刷新,具体的使用场景可以根据业务的不同来使用不同的方式。
应用外被动更新Widget
产品现在重新开始重视Widget的一个重要原因,实际上就是App内部卷不动了,Widget可以在不打开App的情况下,对App进行引流,所以,应用外的Widget更新,就是一个很重要的组成部分,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在大部分场景下,都是够用的。
多布局动态适配
由于在Android12之后,用户可以在单个Widget上进行修改,从而修改Widget当前的配置,所以,用户在拖动修改Widget的尺寸时,就需要动态去调整Widget的布局,以自动适应不同的尺寸。我们可以通过下面的方式,来进行修改。
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的大小,避免使用动态布局,这也是没办法的权衡之举。
RemoteViews行为
RemoteViews不像普通的View,所以我们不能像写普通布局的方式一样来操纵View,但RemoteViews提供了一些set方法来帮助我们对RemoteViews中的View进行修改,例如下面的代码。
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接口,这样就可以提供跨进程通信的效率,同时精简核心的功能。
如何进行后台请求
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)
}
}
动画?
有必要这么卷吗,Widget里面还要加动画。由于RemoteViews里面不能实现正常的View动画,所以,Widget里面的动画基本都是通过类似「帧动画」的方式来实现的,即将动画抽成一帧一帧的图,然后通过Animator来进行切换,从而实现动画效果,群友给出了一篇比较好的实践,大家可以参考参考,我就不卷了。
https://juejin.cn/post/7048623673892143140
Widget的使用场景主要还是以实用功能为主,只有让用户觉得有用,才能锦上添花给App带来更多的活跃,否则只能是鸡肋。
向大家推荐下我的网站 https://xuyisheng.top/ 点击原文一键直达
专注 Android-Kotlin-Flutter 欢迎大家访问
往期推荐
更文不易,点个“三连”支持一下👇