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

作者: 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上已有上传,可以直接使用:

复制代码
@WorkerThread
fun 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 = 0
kotlin.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的质量。

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

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

相关推荐
踢球的打工仔2 小时前
PHP面向对象(7)
android·开发语言·php
安卓理事人2 小时前
安卓socket
android
安卓理事人8 小时前
安卓LinkedBlockingQueue消息队列
android
万能的小裴同学9 小时前
Android M3U8视频播放器
android·音视频
q***577410 小时前
MySql的慢查询(慢日志)
android·mysql·adb
JavaNoober10 小时前
Android 前台服务 "Bad Notification" 崩溃机制分析文档
android
城东米粉儿11 小时前
关于ObjectAnimator
android
zhangphil12 小时前
Android渲染线程Render Thread的RenderNode与DisplayList,引用Bitmap及Open GL纹理上传GPU
android
火柴就是我13 小时前
从头写一个自己的app
android·前端·flutter
lichong95114 小时前
XLog debug 开启打印日志,release 关闭打印日志
android·java·前端