Android-Widget重装上阵

292dfbee96d06b953c68f63857ac660d.png

点击上方蓝字关注我,知识会给你力量

f6fff83562675f037004e221ea276be2.png

如果要在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在桌面上的展示方式,它并不是通过指定的宽高来展示的,而是桌面所占据的格子数。

官方设计文档中,对格子数和尺寸的转换标准,有一个表格,如下所示。

adaddd5b0d98266938a4798b466b0603.png

我们在设计的时候,也应该尽量遵循这个尺寸约束,避免在桌面上展示异常。在Android12之后,描述文件中,还增加了targetCellWidth和targetCellHeight两个参数,他们可以直接指定Widget所占据的格子数,这样更加方便,但由于它仅支持Android12+,所以,通常这些属性会一起设置。

有意思的是这个尺寸标准并不适用于所有的设备,因为ROM的碎片化问题,各个厂商的桌面都不一样,所以。。。只能参考参考。

updatePeriodMillis

这个参数用于指定Widget的被动刷新频率,它由系统控制,所以具有很强的不定性,而且它也不能随意设置,官网上对这个属性的限制如下所示。

2086638a5df68a5071eeb0cb1ba43e7b.png
image-20220823152941549

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 欢迎大家访问

往期推荐

本文原创公众号:群英传,授权转载请联系微信(Tomcat_xu),授权后,请在原创发表24小时后转载。

< END >

作者:徐宜生

更文不易,点个“三连”支持一下👇

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Borland公司在1981年推出C语言程序设计集成开发环境“Turbo C 2.0”,由于其实用稳定的性能,一直长期工作应用在社会不同领域,得到社会各界的广泛认同。Borland公司为了解决该软件的一些Bug,又于1989年推出了升级版“Turbo C v2.01”,紧接着在1992年,由于功能更为强大的C++面向对象程序设计集成开发环境“Turbo C++ 3.0”的面世,导致“Turbo C v2.01”未能得到有效推广。由于C语言程序设计课程一直采用TC2.0作为教学实验环境,为了减少其中的Bug,让同学们更方便的使用,AiY.CN重新找到尘封已久的“Turbo C v2.01”,重新包装上阵,焕发生机! “Turbo C v2.01 重装上阵版”是〖 AiY.CN ☆ 爱游中国 〗为了照顾不熟悉DOS环境软件操作的广大初学者,让大家能在Windows环境下方便地安装Turbo C 2.01,而为其完善了Windows下的运行环境,经过重新包装后推荐给学习《C语言程序设计》的广大师生。使用者并不需要熟悉DOS知识,只需要在安装时将 Turbo C v2.01 指定安装在任意文件夹,再不需要手动更改任何选项,就能够正常编译程序。 “Turbo C v2.01 重装上阵版”的主要功能 1、为了照顾不熟悉DOS环境软件操作的广大初学者,让大家能在Windows环境下方便地安装Turbo C v2.01,本安装程序可以将 Turbo C v2.01 安装在任意指定目录,无需修改 Options 菜单里的 Directory 选项,即可正常编译标准 C 程序! 2、安装程序将在开始菜单上建立“〖 程序设计 〗\Turbo C v2.01”菜单,并建立启动 Turbo C 的快捷方式和配置卸载工具,还有其它相关的一些快捷方式; 4、您建立的C程序文件将保存在“安装目录\TC2.01\Project”目录下,开始菜单上会建立“我的 Project 目录”的快捷方式,令您保存更方便; 5、程序输出的.OBJ和.EXE文件将保存在“安装目录\TC2.01\Output”目录下,开始菜单上会建立“输出文件 Output 目录”的快捷方式,令您管理更方便; 6、附带了方便实用的Word/IE选词搜索工具,在Word文档或网页浏览器中碰到某些词汇,只需要鼠标一划——即可轻松搜索!再不用新开浏览页面和复制、粘贴,实在是简单快捷;

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值