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,就是因为不靠谱

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

相关推荐
踢球的打工仔4 小时前
PHP面向对象(7)
android·开发语言·php
安卓理事人4 小时前
安卓socket
android
安卓理事人10 小时前
安卓LinkedBlockingQueue消息队列
android
万能的小裴同学11 小时前
Android M3U8视频播放器
android·音视频
q***577412 小时前
MySql的慢查询(慢日志)
android·mysql·adb
JavaNoober12 小时前
Android 前台服务 "Bad Notification" 崩溃机制分析文档
android
王六岁13 小时前
UIAutomatorViewer 安装指南 (macOS m3pro 芯片)
android studio
城东米粉儿13 小时前
关于ObjectAnimator
android
zhangphil14 小时前
Android渲染线程Render Thread的RenderNode与DisplayList,引用Bitmap及Open GL纹理上传GPU
android
火柴就是我15 小时前
从头写一个自己的app
android·前端·flutter