Android小组件动画技术分享

万能小组件

最近发现一些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,就是因为不靠谱

最后,我做的万能小组件还有很多可以优化的空间,希望此文章可以帮助到你实现小组件,也希望有小组件经验的同学可以交流一下万能小组件的优化~

相关推荐
B.-1 小时前
减少 Flutter 应用体积的常用方法
学习·flutter·android studio·xcode
筒栗子2 小时前
复习打卡MySQL篇03
android·数据库·mysql
教我数理化_jwslh3 小时前
ImageView在setImageResource后发生了什么
android·性能优化
guoruijun_2012_44 小时前
在 ThinkPHP中 post 请求中 执行 异步 command ,该 command 创建一个命令行脚本 执行 curl请求 并设置其执行时间无限制
android
shankss4 小时前
网页跳转App,Universal Links(iOS)和 App Links(Android) 如何设置
android·flutter·ios
鲤籽鲲5 小时前
C# 异常处理 详解
android·java·c#
Billy_Zuo5 小时前
Android Room 数据库使用详解
android·数据库·room
火柴就是我5 小时前
autox.js 实现遍历微信聊天列表 并发送固定消息
android
Gredingd7 小时前
单步调试Android Framework——App冷启动
android