万能小组件
最近发现一些App,如Top Widgets,Colorful Widgets等等都做了非常酷炫的桌面小组件。
其中有可以开关门的电子卡柜,也有可以点击旋转的吧唧,还有一直在吹的电风扇。统称之为万能小组件。
万能小组件整体特点是:
- 更丰富的交互
- 动画效果
- 仅一个小组件却能展现不同效果的模版能力
虽然不知道商业化App是怎么做的,所以我就自己做了一些尝试。
技术方案
小组件基础
小组件在Android本身是一个广播,而这个广播里承载一个RemoteView作为渲染。
小组件的创建流程如下
- 创建appwidget-provider的xml资源文件
ini
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widgets"
android:minWidth="300dp"
android:minHeight="110dp"
android:updatePeriodMillis="1800000"
android:initialLayout="@layout/widget_default_medium"
android:previewImage="@drawable/preview_4x2"
android:widgetCategory="home_screen"
android:targetCellWidth="4"
android:targetCellHeight="2"/>
- 创建预览的 RemoteView的layout资源文件
ini
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/shape_bg_default_widgets"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/widget_default_iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:adjustViewBounds="true"
android:src="@drawable/preview_4x2" />
</FrameLayout>
- 创建Widget类,并继承自AppWidgetProvider,实现对应的生命周期的重写方法
kotlin
class MediumWidget4x2 : AppWidgetProvider()
- 最后在AndroidManifest中声明广播
ini
<receiver
android:label="@string/medium_size"
android:name=".appwidget.MediumWidget4x2"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/medium_default_widget" />
</receiver>
到此一个最基本的小组件就可以创建出来了。
小组件进阶
创建小组件并不难,在layout中也可以进行UI的绘制,但如果想要在小组件中进行通信、交互、动效,那么就会复杂起来,而且在进行进阶之前需要记住小组件的几个重要特点。
- 一个AppWidgetProvider可能有多个活跃的widget,每个widget有自己的appWidgetId
- AppWidgetProvider中最重要的生命周期方法是onReceive,几乎是小组件交互的唯一切入点
- remoteView不是View的继承
- remoteView不支持findViewById
- 使用RemoteViews.setImageViewResource() setBitmap() 等方法时,涉及到了和launcher的跨进程通信,数据在通信过程中需要序列化为Parcel,需要注意性能开销。
在介绍完上面的5个重要特点以后,小组件的进阶开发的痛点也就浮出水面,甚至是接踵而至了。
更新UI
万能小组件的三个特点交互、动画、模版比起普通小组件本质上都是在UI的刷新上下功夫。交互说白了就是用户点小组件,小组件发生响应和变化,动画就是小组件在刷新而模版其实是替换不同的布局和资源,本质上都是在更新UI。所以我们首先要搞明白的就是如何在小组件上刷新UI。
设置属性
如果是一个普通的Android View,要如何更新UI呢,传统的MVP模式Android提供了一系列的set方法来对指定的View进行设置,而如果是MVVM或者MVI,那就是给View一个可以响应的被观察者来进行变化。但是很遗憾这两点,小组件都做不到,或者说不能完全做到。
如果是更新一个Text内容,可以使用views.setTextViewText(R.id.your_txt, str) ,使用这种赋值方式还有一些其它的API,例如setViewLayoutWidth 、setViewLayoutWidth 、setViewVisibility 等,还可以通过方法签名去设置。
vbscript
// 设置文本
views.setCharSequence(R.id.textView, "setText", "Hello, Widget!")
// 设置文本颜色
views.setInt(R.id.textView, "setTextColor", Color.RED)
// 设置可见性
views.setInt(R.id.imageView, "setVisibility", View.VISIBLE)
// 设置图片资源
views.setInt(R.id.imageView, "setImageResource", R.drawable.my_image)
// 设置透明度
views.setFloat(R.id.someView, "setAlpha", 0.5f)
// 设置进度(对于 ProgressBar)
views.setInt(R.id.progressBar, "setProgress", 50)
// 设置启用/禁用状态
views.setBoolean(R.id.button, "setEnabled", false)
有了这些设置,我们确实可以设置一些属性,但是就像之前提到的,这种形式不是通过findViewById拿到的,所以无法对复杂的View或者View的复杂设置进行操作,例如setAnimation 这个方法就没有。
布局替换
用新的RemoteView更新旧的RemoteView可以做到布局的替换,不仅可以替换整个layout,还可以替换部分layout。
scss
val remoteViews2 = RemoteViews(context.packageName, R.layout.your_layout) remoteViews2.setImageViewResource(R.id.your_widget, R.mipmap.your_img)
val awm = AppWidgetManager.getInstance(context.applicationContext)
awm.updateAppWidget(appWidgetIds, remoteViews2)
scss
remoteViews.removeAllViews(R.id.widget_container)
val remoteViews2 = RemoteViews(context.packageName, R.layout.anim_layout)
remoteViews.addView(R.id.widget_container, remoteViews2)
更新的方法是AppWidgetManager的updateAppWidget,注意这个方法的第一个参数是一个数组,所以要注意自己到底要更新哪些小组件。
可以在updateAppWidget前先使用setImageViewResource,setTextSize等方法做模版内容的替换。
点击事件
交互的另一个部分便是点击事件。在小组件上,点击事件其实就是给view绑定一个pendingIntent
AppWidgetProvider 接收更新请求,通过广播机制跨进程进行通信。
ini
val views = RemoteViews(context.packageName, R.layout.widget_default_medium)
// 设置点击事件以触发动画
val animateIntent = Intent(context, WidgetAnimationReceiver::class.java).apply {
action = ACTION_ANIMATE
}
val flags = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val pendingIntent = PendingIntent.getBroadcast(context, 0, animateIntent, flags)
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
自己设置Intent的Action用来标识事件类别,绑定点击事件可以在AppWidgetProvider的onUpdate方法中
可以自己设置一个新的用于接受的广播来处理消息。注意这个广播也需要去AndroidManifest注册
kotlin
class WidgetAnimationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_ANIMATE) {
}
}
如果不希望注册另外一个广播,那么可以把广播发给自己。点击事件的绑定时机可以有很多,最简单的方式可以直接在onUpdate 方法绑定,当然这也会带来其它问题,不展开,欢迎踩坑。
小组件动画
在详细讲小组件动画前,我想先罗列一下目前我实现动画的Scope。
由于小组件没有办法实现属性动画(如果有,请指点!),目前实现动画的方式我将其分为两种,视图动画 和帧动画 。帧动画很常见,就是一张一张图的资源快速替换,达到动画效果,而视图动画某种程度上可以理解为属性动画的弱化。通过给layout设置layoutAnimation 来达成动画的效果。
首先我区分了两种动画行为:
- 交互: 跟随点击事件发生动画
- 循环: 不跟随点击事件,从加载开始就一直在执行动画
这两种动画都有特点,首先视图动画是没有中间状态的,例如一个在旋转的唱片,想让其停止在某一个瞬间是做不到的。然后帧动画除了众所周知的资源开销大以外,目前无法很好做到交互和循环同时存在。例如:一个电风扇,默认就在一直转,我点它一下它就执行关动画了,再点一下就又打开,理论上它需要停止在它动画的当前帧,但如果这个时候是非预期内的app退出或者其他异常导致无法记录到当前电风扇是开还是关,以及到底在第几帧,那么这个动画和状态就完全乱掉了,所以目前如果执行一个帧动画,那就让它执行完而不让在动画循环或者执行过程中做额外交互,而视图动画由于本身就没有中间态,一直在执行固定的代码,所以这个问题就不是什么大问题了。
视图动画
ini
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="3000"
android:fromDegrees="0"
android:toDegrees="360"
android:pivotX="50%"
android:pivotY="50%"
android:repeatCount="-1"
android:repeatMode="restart" />
上面是一个旋转,之前小火过一阵子的敲木鱼也可以通过视图动画的缩放来实现。
在之前讲过的布局替换 中,通过对点击事件绑定布局替换,将有动画和没动画的layout进行替换可以达成一些动画效果。
kotlin
/**
* 循环视图动画
*/
private fun doLayoutAnimation(context: Context, remoteViews: RemoteViews) {
remoteViews.removeAllViews(R.id.widget_container)
val remoteViews2 = RemoteViews(context.packageName, R.layout.anim_layout)
// val bitmap = BitmapFactory.decodeResource(context.resources, R.mipmap.baji)
// remoteViews2.setImageViewBitmap(R.id.widget_iv, bitmap)
remoteViews.addView(R.id.widget_container, remoteViews2)
val appWidgetIds = AppWidgetManager.getInstance(context)
.getAppWidgetIds(
ComponentName(
context,
SmallWidget2x2::class.java
)
)
val awm = AppWidgetManager.getInstance(context.applicationContext)
awm.updateAppWidget(appWidgetIds, remoteViews)
}
ini
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/widget_iv"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layoutAnimation="@anim/demo_anim">
<ImageView
android:id="@+id/widget_effect"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</FrameLayout>
这是我用来实现循环旋转动画的部分。敲木鱼也是类似的实现。
帧动画
一次性动画
直接看代码~
kotlin
private fun doDisposableAnimation(context: Context, appWidgetId: Int) {
val handler = Handler(Looper.getMainLooper())
val frameDuration = 3000 / 45
var currentFrame = 0
size[appWidgetId] = images[appWidgetId]?.size ?: 0
val frameRunnable = object : Runnable {
override fun run() {
if (currentFrame >= (size[appWidgetId] ?: 1) - 1) {
playingMap[appWidgetId] = false
currentFrame = 0
} else {
currentFrame++
handler.postDelayed(this, frameDuration.toLong())
}
updateAnim(context, currentFrame, appWidgetId)
}
}
handler.post(frameRunnable)
}
scss
@Synchronized
private fun updateAnim(context: Context, index: Int, appWidgetId: Int) {
val remoteViews = RemoteViews(context.packageName, R.layout.widget_default_small)
remoteViews.removeAllViews(R.id.widget_container)
val remoteViews2 = RemoteViews(context.packageName, R.layout.frame_anim_layout)
val directory = context.filesDir
images[appWidgetId]?.get(index)?.let {
val filePath = File(directory, it).absolutePath
val originBitmap = WidgetHelper.get(filePath)
originBitmap?.let { bmp ->
val bitmap = getRoundedCornerBitmap(bmp, dp2px(context, 20F).toFloat())
remoteViews2.setImageViewBitmap(R.id.widget_muyu_iv, bitmap)
remoteViews.addView(R.id.widget_container, remoteViews2)
val awm = AppWidgetManager.getInstance(context.applicationContext)
awm.updateAppWidget(intArrayOf(appWidgetId), remoteViews)
} ?: WidgetHelper.load(filePath) { updateAnim(context, index, appWidgetId) }
}
}
代码比较简单,主要有几个注意事项。
- 定义好动画的帧数来决定动画速率
- 判断动画是否结束,以及动画是否在播来决定动画行为
- 由于帧动画使用了bitmap来进行跨进程传递,加入lrumap进行缓存
卖个关子,现在的一次性动画是没有状态的,如果像卡柜这种有开和关不同动画,或者是电风扇这种有开和关的要怎么实现呢?其实这部分的难点不在于动画的实现,而在于状态的保存和记录,欢迎交流。
循环动画
kotlin
/**
* 循环动画
*/
private fun doAnimation(context: Context, appWidgetId: Int) {
playingMap[appWidgetId] = true
val handler = Handler(Looper.getMainLooper())
val frameDuration = 3000 / 45
var currentFrame = 0
size[appWidgetId] = images[appWidgetId]?.size ?: 0
val frameRunnable = object : Runnable {
override fun run() {
if (currentFrame >= (size[appWidgetId] ?: 1) - 1) {
currentFrame = 0
} else {
currentFrame++
}
handler.postDelayed(this, frameDuration.toLong())
updateAnim(context, currentFrame, appWidgetId)
}
}
handler.post(frameRunnable)
}
和一次性动画的差别不大,主要区别是恢复到第0帧。
问题及展望
模版化
我将模版化放在了问题的小标题下,因为我并没有把模版化没有做到完美,在模版化的投入是需要考量的,这里面的工作量较大也比较复杂,所以我只做了部分模版化。
在上面的动画代码中,这一行代码currentFrame >= (size[appWidgetId] ?: 1) - 1 ,其中size其实是一个map,因为不同的动画的总图片数可能是不一样的,如果直接使用固定size,一定会outofbounds导致app崩溃!当然还有playingMap[appWidgetId] 这个map标识的单个小组件的动画状态。这两个例子介绍了一下我们模版化的工作重点:
- 用一个小组件也就是AppWidgetProvider承载不同的素材
- AppWidgetProvider需要记录每个id和素材以及状态的关系
- 同一个小组件的click事件的处理可能是不一样的,有的是执行一次性动画,有的循环动画,有的视图动画,有的是卡柜、电风扇(我称之为复动画)
不卖关子,直接说结论,在每次添加小组件的时候维护id和单个小组件属性的映射,并存储。目前我是存的SP,使用sqlite也是个不错的选择。
熟悉小组件的同学可能认为小组件的添加方式是从桌面长按拖出,但这个路径目前问题非常多,且工作量很大,不展开,总之配置页我目前是没有做的。我们的路径是直接在app内帮用户添加到桌面上。在这个时候发送一个广播给AppWidgetProvider。
AppWidgetProvider内部维护了一个AppWidgetIds 的数组,这个数组的最后一个就是当前我们添加的小组件,同时通过sp的方式传递并存储。
scss
WIDGETX_ADDED -> {
Toast.makeText(context, "已安装成功,可以返回桌面查看哦", Toast.LENGTH_SHORT).show()
val id = AppWidgetManager.getInstance(context).getAppWidgetIds(
ComponentName(context, SmallWidget2x2::class.java)
).last()
val preference = getApplicationContext().getSharedPreferences("common_widget", Context.MODE_PRIVATE)
val imageSet = preference.getStringSet("IMAGE_LIST", emptySet())
val subType = preference.getString("SUB_TYPE", INTERACTION) ?: INTERACTION
val images = imageSet?.toList() ?: emptyList()
val jsonObject = JSONObject().apply {
put("id", id)
put("subType", subType)
put("folder", images[0].substringBefore("/"))
}
if (images.isNotEmpty()) {
// Establish a mapping between ID and images file
preference.edit().putString(id.toString(), jsonObject.toString()).apply()
}
setImages(ArrayList(images), context, id, subType)
}
kotlin
private fun setImages(list: ArrayList<String>, context: Context, appWidgetId: Int, subType: String) {
require(list.isNotEmpty()) { "List cannot be empty" }
images[appWidgetId]?.clear()
list.sort()
images[appWidgetId] = list
if (subType == LOOP && playingMap[appWidgetId] != true) {
doAnimation(context, appWidgetId)
} else {
updateAnim(context, 0, appWidgetId)
}
}
至此,我们就完成了一个小组件实现不同素材、不同交互、不同动画的模版化。
App退出后执行动画
尽管小组件是静态注册的广播,理论上可以脱离App进程接收通知,但在动画使用Handler在App退出后可能是无法正常运行的,使用WorkManager 是一条优化思路,但也不能完全解决该问题。而且受限于App本身是否是一个工具类App,很多比较hacker的行为是不可行的。
厂商适配问题
尽管代码已经写出来了,但是桌面特性在android不同厂商的魔改之下有了不同的表现,不同的版本不同的手机都可能会有奇奇怪怪的表现,这里的坑有些好解,有些无解,需要有心理预期。
踩坑Tips
有一些上面没有提到的踩坑记录share给同学们
- 一个AppWidgetProvider可以对应多个小组件,一定要记录好每个id的映射
- Android厂商千奇百怪的尺寸、系统版本、桌面网格比例、权限设置在小组件的适配是一个非常大的坑,推荐使用固定宽度,高度跟随比例的形式来定义小组件尺寸
- 小组件的圆角由系统切会因为不同的系统和厂商有各种问题,最好的方式是自己切bitmap的圆角。
- 在小组件的onUpdate方法写代码需要谨慎,onUpdate方法会因为用户进站或者是其他的奇怪行为被系统调用
- 尝试在小组件广播的extra里增加bundle来传递数据是不可行的,接收方只会收到null
- PendingIntent之所以是PendingIntent,就是因为不靠谱
最后,我做的万能小组件还有很多可以优化的空间,希望此文章可以帮助到你实现小组件,也希望有小组件经验的同学可以交流一下万能小组件的优化~