写点什么

桌面挂件不能承受之重——GIF

  • 2025-08-21
    广东
  • 本文字数:8355 字

    阅读完需:约 27 分钟

作者: vivo 互联网客户端团队- Zhang Qin

本文从桌面挂件开发过程中遇到的 GIF 图片难以加载的问题展开,分别介绍了现有的挂件中加载 GIF 图片的两种可行方案——ViewFlipper 和 AnimatedImageDrawable,同时阐述了两种的方案的优缺点。然后针对现有方案中的痛点,结合现有方案,提出通过网络下发 GIF 并通过逐帧解析得到帧图片,再采用 ViewFlipper 来实现加载的方案,解决痛点中的引入资源过多导致包体增大的问题,使挂件既能不增加包体又能展示 GIF。


1 分钟看图掌握核心观点👇


一、背景

众所周知,Android 原生的原子组件(AppWidget,又名桌面挂件)所能使用的 View 有限,仅能支持如下的:

layout(布局):

  • AdapterViewFlipper

  • FrameLayout

  • GridLayout

  • GridView

  • LinearLayout

  • ListView

  • RelativeLayout

  • StackView

  • ViewFlipper


widgets(小部件):

  • AnalogClock

  • Button

  • Chronometer

  • ImageButton

  • ImageView

  • ProgressBar

  • TextClock

  • TextView


API 31 开始,还支持如下的小部件和布局:

  • CheckBox

  • RadioButton

  • RadioGroup

  • Switch


需要注意一点,除了上述这些之外,其余所有的都不支持,包括继承自这些类的子类同样也不支持。因此我们能够看出,开发 AppWidget 的局限性比较大,只有限定的布局和小部件能够使用,且不能通过继承来实现自定义的炫酷效果。这里也解释了为什么笔者一开始不直接使用 Lottie、PAG 等来实现复杂的动画,完全是被限制了。


不仅如此,组件内由于使用的都是 Remoteviews,Remoteviews 可以在其它进程中进行显示,我们可以跨进程更新它的界面。Remoteviews 在 Android 中的主要应用是通知栏和桌面挂件。也正式挂件中使用的是 Remoteviews,所以我们不能像普通 Android 应用一样使用 findViewById 或者 viewbinding 来获取 View 的对象并通过 view 对象来设置相应的属性等。在挂件中只能使用 Remoteviews 中的一些方法,这些方法基本都是通过反射方式进行封装来实现的,比如设置 ImageView 的图片,Remoteviews 中只提供了如下四种方法

/** * Equivalent to calling {@link ImageView#setImageResource(int)} * * @param viewId The id of the view whose drawable should change * @param srcId The new resource id for the drawable */publicvoidsetImageViewResource(@IdResint viewId, @DrawableResint srcId){    setInt(viewId, "setImageResource", srcId);}  /** * Equivalent to calling {@link ImageView#setImageURI(Uri)} * * @param viewId The id of the view whose drawable should change * @param uri The Uri for the image */publicvoidsetImageViewUri(@IdResint viewId, Uri uri){    setUri(viewId, "setImageURI", uri);}  /** * Equivalent to calling {@link ImageView#setImageBitmap(Bitmap)} * * @param viewId The id of the view whose bitmap should change * @param bitmap The new Bitmap for the drawable */publicvoidsetImageViewBitmap(@IdResint viewId, Bitmap bitmap){    setBitmap(viewId, "setImageBitmap", bitmap);}  /** * Equivalent to calling {@link ImageView#setImageIcon(Icon)} * * @param viewId The id of the view whose bitmap should change * @param icon The new Icon for the ImageView */publicvoidsetImageViewIcon(@IdResint viewId, Icon icon){    setIcon(viewId, "setImageIcon", icon);}
复制代码

从源码中可以看到,setImageViewResource 方法只能传入 int 类型的资源,也就是在资源文件中的资源 ID,除此之外就是 Bitmap、Uri 和 Icon 类型,无法支持 Drawable 等类型。由此可见,组件中的 View 其实只能包含普通 View 的一部分功能,限制比较明显。


二、挂件加载 GIF 的可行方案

言归正传,首先,我们介绍下在组件中加载 GIF 的可行方案,主要有两种:

2.1 方案一:使用 ViewFlipper 来实现逐帧动画的效果

此方案是利用 Remoteviews 支持的 ViewFlipper 控件,配合多个 ImageView 来循环显示,达到类似逐帧动画的效果。布局内容如下:

<ViewFlipper    android:layout_width="40dp"    android:layout_height="40dp"    android:layout_gravity="end|center_vertical"    android:autoStart="true"    android:flipInterval="90">      <ImageView        android:layout_width="40dp"        android:layout_height="40dp"        android:src="@drawable/before_sign_in_anim0" />      <ImageView        android:layout_width="40dp"        android:layout_height="40dp"        android:src="@drawable/before_sign_in_anim15" />      <ImageView        android:layout_width="40dp"        android:layout_height="40dp"        android:src="@drawable/before_sign_in_anim28" />      <ImageView        android:layout_width="40dp"        android:layout_height="40dp"        android:src="@drawable/before_sign_in_anim43" />      <ImageView        android:layout_width="40dp"        android:layout_height="40dp"        android:src="@drawable/before_sign_in_anim57" />      <ImageView        android:layout_width="40dp"        android:layout_height="40dp"        android:src="@drawable/before_sign_in_anim71" />      <ImageView        android:layout_width="40dp"        android:layout_height="40dp"        android:src="@drawable/before_sign_in_anim85" />      <ImageView        android:layout_width="40dp"        android:layout_height="40dp"        android:src="@drawable/before_sign_in_anim100" /></ViewFlipper>
复制代码

ViewFlipper 中的一些常用方法如下:

  • setInAnimation:设置 View 或 ImageView 进入屏幕时使用的动画

  • setOutAnimation:设置 View 或 ImageView 退出屏幕时使用的动画

  • showNext:调用该方法来显示 ViewFlipper 里的下一个 View 或 ImageView

  • showPrevious:调用该方法来显示 ViewFlipper 的上一个 View 或 ImageView

  • setFilpInterval:设置 View 或 ImageView 之间切换的时间间隔

  • startFlipping:使用上面设置的时间间隔来开始切换所有的 View 或 ImageView,切换会循环进行

  • stopFlipping:停止 View 或 ImageView 切换

  • isAutoStart:是否自动开始播放


在作为动画设置时,需要在 xml 文件中设置 autoStart 属性为 true,保证动画能够自动播放。

优点:

  • 各版本兼容性好,ViewFlipper 是 API 11 时引入的,目前应该不会有比这低的了;

缺点:

  • ImageView 过多,代码也多,修改替换麻烦;

  • 在 Remoteviews 中,ViewFlipper 的很多方法无法使用,比如停止播放等。


2.2 方案二:使用 AnimatedImageDrawable 来显示 GIF 动画

Android 9.0 中引入了一个新的 Drawable 来显示 GIF 图片:AnimatedImageDrawable,对应的 xml 标签是<animated-image>,这样一来,我们可以直接将一个 GIF 图片 before_sign_in.gif 放到 drawable 目录中,然后新建一个 before_sign_in_anim.xml 来引用:

<?xml version="1.0" encoding="utf-8"?><animated-image xmlns:android="http://schemas.android.com/apk/res/android"    android:autoStart="true"    android:autoMirrored="true"    android:src="@drawable/ic_test_gif" />
复制代码


其中的 ic_test_gif 就是我们的.gif 文件。

我们可以看下 AnimatedImageDrawable 的属性:

<!-- Drawable used to draw animated images(gif). -->    <declare-styleable name="AnimatedImageDrawable">        <!-- Identifier of the image file. This attribute is mandatory.             It must be an image file with multiple frames, e.g. gif or webp -->        <attr name="src" />        <!-- Indicates if the drawable needs to be mirrored when its layout direction is             RTL(right-to-left). -->        <attr name="autoMirrored" />        <!-- Replace the loop count in the encoded data. A repeat count of 0 means that             the animation will play once, regardless of the number of times specified             in the encoded data. Setting this to infinite(-1) will result in the             animation repeating as long as it is displayed(once start() is called). -->        <attr name="repeatCount"/>        <!-- When true, automatically start animating. The default is false, meaning             that the animation will not start until start() is called. -->        <attr name="autoStart" />    </declare-styleable>
复制代码


从中我们可以发现,这里可以设置 repeatCount 循环次数,设置为 0 的话表示只播放一次。

此时,我们只需要将 drawable 设置给 ImageView 即可,在 Remoteviews 中,考虑到版本兼容问题,我们通过如下方式设置:

remoteViews.setImageViewResource(    R.id.abnormal_static_cat,    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {        R.drawable.before_sign_in_anim    } else {        R.drawable.before_sign_in_static    })
复制代码

优点:资源少,一个 GIF 只要一个 xml,且替换简单;

缺点:只有 Android9 以上的系统可以用。


2.3 现有方案的痛点

上述描述的两种方案中,都会引入很多资源文件,这必然会增加应用的包体,导致包体增大不少,因此可以考虑通过服务端下发的方式来实现,那么问题就来了:

1)如果通过方案一,那么客户端必须写定一个 xml,写定一定数量的 ImageView 来供下发的图片加载,当然了,可以动态的添加,但这里是组件,动态添加会比普通的 view 动态添加稍微麻烦些,这个我们后面再说。

2)如果通过方案二,那么就有问题了,前面已经提到了,组件里面的 ImageView 是不支持通过 Drawable 对象来设置内容的,这就导致了就算我们能够得到 AnimatedImageDrawable 对象,我们也没办法设置,况且要得到这样一个 Drawable,也比较困难(没有深究如何得到)。


戛然而止了,两个方案实现起来听着都不太靠谱,那么有没有什么好的方案呢?


三、可行方案探索

3.1 初探

想到这里,大家可能会问,为什么不使用 Glide 呢?这个强大的图片加载库总不会没有这样的方法吧?

确实,Glide 给 AppWidget 提供了专门的图片加载方式,其实现方式如下:

val appwidgetTarget = AppWidgetTarget(context, R.id.abnormal_static_cat, remoteViews, ComponentName(context, TestWidgetProvider::class.java))Glide.with(context)   .asBitmap()   .load(url)   .into(appwidgetTarget)
复制代码

但是从上面可以看出,这个只能加载 Bitmap,如果是 asGif,则在 into 时没有 target 这个选项,只能 into(ImageView)。因此这个方法也行不通。


3.2 思索与尝试

这里还要说一点,如果是将图片下载到手机本地,再去读取本地文件,还需要考虑存储权限的问题,而这里是原子组件,如果需要请求权限,那么就得找一个落地页去承载,且组件的卡片上最好也需要有这个说明,这样的话 UI 改动会比较大,且如果没有同意权限就会出现展示不了图片的情况,这也很不友好。


综上,只能在请求网络图片时就把 GIF 加载出来,这样既不需要上述的那些繁琐的权限授予过程,也不会增加包体的大小。

受到上面第一个方案的启发,我们可以把 GIF 图的逐帧图片取出来,然后通过方案一来展示,这样就能实现了。


3.2.1 获取网络 GIF 图片

首先是拿到网络的 GIF 图片,这里我们采用 Glide 来获取(Glide 还是好用啊),采用 Glide 还有一个好处是,Glide 会针对图片作缓存,这样我们重复加载同一张图不会重复消耗流量:

Glide.with(context)    .asGif()    .load(url)    .diskCacheStrategy(DiskCacheStrategy.ALL)    .submit(432, 432)    .get()
复制代码


3.2.2 得到 GIF 的逐帧图片

然后是将得到的 GIF 进行解析,得到逐帧的图片,这里我们引入一个工具库:implementation("pl.droidsonroids.gif:android-gif-drawable:1.2.24"),该库在 Vhub 上已有上传,可以直接使用:

@WorkerThreadfun getAllFrameBitmapByUrl(context: Context, url: String): MutableList<Bitmap> {    val frameBitmaps: MutableList<Bitmap> = ArrayList()    var gifDrawable: GifDrawable? = null    try {        val gif = Glide.with(context)            .asGif()            .load(url)            .diskCacheStrategy(DiskCacheStrategy.ALL)            .submit(432, 432)            .get()        gifDrawable = GifDrawable(gif.buffer)        val totalCount = gifDrawable.numberOfFrames        for(i in 0 until totalCount){            frameBitmaps.add(gifDrawable.seekToFrameAndGet(i))        }    } catch (t: Throwable) {        VLog.e(TAG, "getAllFrameBitmapByUrl Error.", t)    } finally {        gifDrawable?.stop()    }    return frameBitmaps}
复制代码

这样我们就得到了包含 GIF 所有帧图片的列表了(美滋滋~),接下来就可以根据方案一处理每一帧的图片了。


3.2.3 加载

然后,就报错了,lang.IllegalArgumentException: RemoteViews for widget update exceeds maximum bitmap memory usage (used: 236588800, max: 15396480)。由于 Remoteviews 是跨进程的传输,并不是传统意义上的 view,其内部是通过 Binder 来实现的,因此当 ImageView 去 setImageBitmap 的时候,需要注意设置进去的 bitmap 是否超过了大小限制。


最大的 Size 公式为:The total Bitmap memory used by the RemoteViews object cannot exceed that required to fill the screen 1.5 times, ie. (screen width x screen height x 4 x 1.5) bytes.也就是 RemoteViews 对象使用的总 Bitmap 内存不能超过填满屏幕 1.5 倍所需的内存,即 (屏幕宽度 x 屏幕高度 x 4 x 1.5) 字节。这个在 AppWidgetServiceImpl.java 中有相应的定义:

privatevoidcomputeMaximumWidgetBitmapMemory(){    Display display = mContext.getDisplayNoVerify();    Point size = new Point();    display.getRealSize(size);    // Cap memory usage at 1.5 times the size of the display    // 1.5 * 4 bytes/pixel * w * h ==> 6 * w * h    mMaxWidgetBitmapMemory = 6 * size.x * size.y;}
复制代码

而且,RemoteViews 源码内部维护了一个:BitmapCache mBitmapCache, 每次设置 bitmap 进来,都会被缓存起来,最终计算 RemoteViews 占用内存大小的话,也会把这块算进去。

/**     * Call a method taking one Bitmap on a view in the layout for this RemoteViews.     * @more     * <p class="note">The bitmap will be flattened into the parcel if this object is     * sent across processes, so it may end up using a lot of memory, and may be fairly slow.</p>     *     * @param viewId The id of the view on which to call the method.     * @param methodName The name of the method to call.     * @param value The value to pass to the method.     */    publicvoidsetBitmap(@IdResint viewId, String methodName, Bitmap value){        addAction(new BitmapReflectionAction(viewId, methodName, value));    }  ...      BitmapReflectionAction(@IdResint viewId, String methodName, Bitmap bitmap) {       this.bitmap = bitmap;       this.viewId = viewId;       this.methodName = methodName;       bitmapId = mBitmapCache.getBitmapId(bitmap);    }  ...      publicintgetBitmapId(Bitmap b){        if (b == null) {            return -1;        } else {            int hash = b.hashCode();            int hashId = mBitmapHashes.get(hash, -1);            if (hashId != -1) {                return hashId;            } else {                if (b.isMutable()) {                    b = b.asShared();                }                mBitmaps.add(b);                mBitmapHashes.put(mBitmaps.size() - 1, hash);                mBitmapMemory = -1;                return (mBitmaps.size() - 1);           }       }   }
复制代码

这里由于 GIF 解析出来的帧图片太多,如果每一张都设置的话,确实太多了,那么就需要采取采样的方式,目前设定的是每 5 张中取一张,然后设置了每一张图片的大小也不能超过阈值,另外总体也设置了一个阈值,防止超过报错。这里就会出现两个问题,一个是单张图片限制了大小阈值,必定会出现压缩、采样,导致单张图片质量下降,不像原先那么高清,第二个是帧图片太多,就算单张限制了阈值,总体也会超过总体的阈值,在超过总体前一帧时直接 return,这样就会导致最终的动画和 GIF 相比可能被截断。反复试验,找了个相对平衡的点,既保证单张图片的清晰度,也保证整体的完整性,但这个方案不够健壮,会随着 GIF 图的变化出现不同的问题。


下面介绍下上面说的这个方案,原理上基本清晰,就是通过 ViewFlipper,向其中动态添加 ImageView,每一个 ImageView 加载一帧图片,从而达到动画效果。

val viewFlipper = RemoteViews(context.packageName, R.layout.sign_in_view_flipper)var allSize = 0kotlin.run {    frameBitmaps.forEachIndexed { index, it ->        logger.d("allSize = $allSize, index = $index")        if (index % 5 != 0) {            return@forEachIndexed        }        val ivRemoteViews = RemoteViews(context.packageName, R.layout.sign_in_per_frame_bitmap_view)        var bitmapSize = GifDownloadUtils.getBitmapSize(it)        var bitmap = it        val matrix = Matrix()        var scale = 432f / bitmap.width        logger.d("start, bitmapSize = $bitmapSize")        matrix.setScale(scale, scale)        while (bitmapSize >= GifDownloadUtils.MAX_WIDGET_BITMAP_MEMORY) {            bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)            bitmapSize = GifDownloadUtils.getBitmapSize(bitmap)            logger.d("bitmapSize = $bitmapSize, scale = $scale")            scale /= 2f            matrix.setScale(scale, scale)        }        allSize += bitmapSize        logger.d("allSize = $allSize")        if (allSize >= GifDownloadUtils.maxTotalWidgetBitmapMemory()) {            return@run        }        ivRemoteViews.setImageViewBitmap(R.id.iv_per_frame, bitmap)        viewFlipper.addView(R.id.view_flipper, ivRemoteViews)    }}  logger.d("addView")// 这里是由于addView添加的View都会显示在最上面,所以这里通过在原卡片中添加相同id的view,先把原卡的移除,再把新建的添加进去,达到更新的效果,这样布局的层级就还是原先的层级。remoteViews.removeAllViews(R.id.view_flipper)remoteViews.addView(R.id.view_flipper, viewFlipper)
复制代码

其中 frameBitmaps 就是上面获得的所有图片。

到这里网络 GIF 图片的加载也基本完成了。


四、总结

上述提出的加载网络 GIF 的方案,虽然解决了现有方案中加载 GIF 需要引入很多图片资源或者 GIF 资源,导致包体大小增加的问题,但是如果 GIF 图片本身质量较高,通过新方案可能会降低 GIF 的质量。

上述三种方案的优缺点和适用场景总结如下:

总而言之,具体采用哪种方案需要根据实际开发的具体需要来实现,综合方案的优缺点和适用场景来选择。

发布于: 刚刚阅读数: 3
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020-07-10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
桌面挂件不能承受之重——GIF_android_vivo互联网技术_InfoQ写作社区