查看原文
其他

Android Fresco调优实践|得物技术

GavinX 得物技术
2024-12-05

目录

一、背景

二、图片拉伸优化

    1. 本地图拉伸

    2. .9图拉伸

    3. 网络图FIT_CENTER拉伸

    4. 网络图CENTER_CROP拉伸

三、本地相册加载优化

四、动图缓存、闪烁优化

五、未来展望

六、总结

背景

图片的加载体验对于交易、社区为核心的社交电商类应用来说可以说是生命线,好的图片加载体验决定了用户分享欲、购买欲。

得物的图片库方案采用的是android主流图片库-FaceBook开源库Fresco,我们基于其视图数据分离、链式调用高可拓展性,来实现二次封装,拓展支持如图片预加载、heif&apng&svg图片格式解码、CDN短边分级裁剪、自定义Processor处理器、白屏监控等能力。
近期收到了一些关于图片体验问题的反馈,主要为部分场景加载耗时高、加载图出现拉伸、锯齿、黑线、闪烁异常以及无法成功加载等问题,我们对相关问题进行了针对性的跟进治理,反馈的体验问题也基本都处理完毕。
本文核心介绍下由于Fresco开源库的部分历史实现缺陷导致的体验问题。

图片拉伸优化

优先来聊一聊图片拉伸问题,首先来看一看问题现场。


以上按问题的时序、严重程度列了三种场景:
图一:头像兜底图的边缘像素拉伸,网络图正常,且仅在2k的手机上出现。
图二:个别已拥有商品展示页出现黑灰线的上下拉伸
图三:仅在小米2k的超高分辨率手机出现,社区双列流、个人头像的网络图展示全部拉伸。(线上反馈用户原本是正常的,突然某天就开始出现拉伸,且无法恢复。)
图四:动态.9图加载padding时效,左侧红包icon上下出现拉伸。

本地图拉伸

发生拉伸问题的图片是一张普通的.png本地图,Fresco框架中对于不同类型、来源的图片有不同加载责任链配置,问题图片会经过系统硬解转换成Bitmap,通过BitmapDrawable进行承载加载,并最终绘制到DuImageLoaderView上。具体的层级结构如下图所示:

对于圆角图来说,Fresco实现了RoundedBitmapDrawable类,其中Bitmap的绘制实现是通过设置了BitmapShader的Paint完成,且设置的模式为Shader.TileMode.CLAMP。
private void updatePaint() { if (mLastBitmap == null || mLastBitmap.get() != mBitmap) { mLastBitmap = new WeakReference<>(mBitmap); mPaint.setShader(new BitmapShader(mBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)); mIsShaderTransformDirty = true; } if (mIsShaderTransformDirty) { mPaint.getShader().setLocalMatrix(mTransform); mIsShaderTransformDirty = false; } mPaint.setFilterBitmap(getPaintFilterBitmap());}
BitmapShader为android原生的Bitmap着色器,支持三个参数构造,分别为加载Bitmap,X轴的加载模式,Y轴的加载模式。
BitmapShader支持以下4种加载模式,其中着色器的的绘制区域,可以理解为Drawable的绘制Bounds,而原始边界可以理解为Bitmap的原始大小区域。
那么拉伸的原因就很好理解了,Fresco的圆角实现在绘制区域较大时,采用的Shader.TileMode.CLAMP会对边缘像素拉伸,很符合我们问题UI表现,同时在Fresco官方文档和issue也查到了相关的解释,详见:
https://frescolib.org/docs/rounded-corners-and-circles.html
https://github.com/facebook/fresco/issues/2308
Fresco开发人员给到的解释为Bitmap原生着色器的限制,圆角问题并没有完美的实现方案。
为什么只在2k分辨率手机上出现?
对于同一张Bitmap图片来说,Bitmap宽高实际上是相对固定的,那么重点就放在Fresco计算Drawable的Bitmap绘制区域的逻辑上。熟悉Canvas UI绘制的同学都知道,paint绘制是根据指定的path区域来进行的,查看Fresco圆角实现源码可以看到mPath的绘制区域取决于RootBounds的大小,rootBounds的设置又取决于ScaleTypeDrawable的持有的代理drawable宽高,即BitmapDrawable的测量宽高。
核心代码片段:
//设置path的核心代码if (mIsCircle) { mPath.addCircle( mRootBounds.centerX(), mRootBounds.centerY(), Math.min(mRootBounds.width(), mRootBounds.height()) / 2, Path.Direction.CW);} else if (mScaleDownInsideBorders) { if (mInsideBorderRadii == null) { mInsideBorderRadii = new float[8]; } for (int i = 0; i < mBorderRadii.length; i++) { mInsideBorderRadii[i] = mCornerRadii[i] - mBorderWidth; } mPath.addRoundRect(mRootBounds, mInsideBorderRadii, Path.Direction.CW);} else { mPath.addRoundRect(mRootBounds, mCornerRadii, Path.Direction.CW);}


//设置RootBounds的核心代码if (mTransformCallback != null) { mTransformCallback.getTransform(mParentTransform); mTransformCallback.getRootBounds(mRootBounds);} else { mParentTransform.reset(); mRootBounds.set(getBounds());}
在android源码中,计算出的IntrinsicWidth取决于density像素密度的计算,其逻辑为((size * tdensity) + (sdensity >> 1)) / sdensity。
以我的荣耀magic 5设备为例,scaleType为centerCrop,存放xxhdpi的本地png图片,核心加载信息如下:

targetDensity = 手机设备  √(水平像素数^2 + 垂直像素数^2)/ 屏幕尺寸(以英寸为单位) = 560

sdensity = 本地图存放的drawable文件夹对应的density,xxhdpi = 480


android DisplayMetrics源码

mdpi --> DisplayMetrics.DENSITY_MEDIUM --> 160

hdpi --> DisplayMetrics.DENSITY_HIGH --> 240

xhdpi --> DisplayMetrics.DENSITY_XHIGH --> 320

xxhdpi --> DisplayMetrics.DENSITY_XXHIGH --> 480

xxxhdpi --> DisplayMetrics.DENSITY_XXXHIGH --> 640

根据计算逻辑,targetDensity越大,sdensity越小,越容易发生Drawable的 IntrinsicWidth 大于 实际Bitmap尺寸的情况,最终导致越界区域无法被Bitmap完整覆盖,出现图片边缘拉伸的情况。
而仅出现2k分辨率的手机上的原因也就呼出欲出了,因为手机分辨率越高,targetDensity就越大,刚好触发了Drawable的测量宽高大于Bitmap尺寸的临界条件。
对于单一设备来说,本地图的targetDensity是固定的,我们一般无法调整。故我们只能通过打破另外一个条件,将Bitmap mDensity增大,对于本地图来说可以新增一张xxxhdpi的高分辨率图片,最终本地图的拉伸问题得到了解决。

.9图拉伸

.9图为android系统上一种支持特定区域拉伸的png格式图片,Fresco本身并没有支持.9图的网络图加载,仍旧采用的是系统NinePatchDrawable本地图加载能力。在包体积优化的背景下,得物图片库对.9图加载进行了自定义实现,核心逻辑为将原本android打包流程中对本地.9图aapt resource处理服务化,将aapt处理之后的文件上传cdn,实现资源远端加载。

先来看下历史核心加载代码:
@JvmStatic fun loadNinePatchBackground(image: ImageView, url: String) { //获取.9图的cdn下载文件 loadImageAsFile(url, { //通过BitmapFactory进行解码 获取bitmap val bitmap = BitmapFactory.decodeFile(it.path) ?: return@loadImageAsFile val chunk = bitmap.ninePatchChunk image.resources?.apply { //将bitmap数据封装到NinePatchDrawable中 val defaultDrawable = if (NinePatch.isNinePatchChunk(chunk)) { NinePatchDrawable(this, bitmap, chunk, Rect(), null) } else { BitmapDrawable(this, bitmap) } //设置核心图片背景 image.background = defaultDrawable } })}
查看NinePatchDrawable的构造方法后,可以知道Rect参数为.9图的padding信息,但我们当前传入为new Rect()的新对象,并没有进行padding设置,自然会导致padding失效。
那么padding信息我们该如何获取呢?在分析.9图的图片数据格式时,我们发现在android源码中有其定义。
计算机中8字节代表1位,那么padding信息按顺序分别bytes字节流的第12、16、20、24位,且isNinePatchChunk的native实现中已经校验了bytes大小,我们无需担心java层的数组越界等问题。
但解决了padding问题之后,我们发现一张md5相同的.9图远端加载和本地加载表现仍旧有较大差异,且在不同手机上表现不同。
有了本地图加载拉伸的排查经验,我们猜测是NinePatchDrawable输出Bitmap的测量宽高时产生了差异。查阅了NinePatchDrawable的源码,图片会进行targetDensity与sourceDensity的比例进行Bitmap Scale。在未主动设置density的情况下,targetDensity取值为DisplayMetrics.DENSITY_DEFAULT,即设备的默认density,sourceDensity取决于Bitmap density。

问题.9图仍旧为xxhdpi的标准图片BitmapDensity为480。

网络图取决于手机实际分辨率情况,问题设备上为640。

根据数据可以发现,一张259*72的Bitmap图片想要填充到345*96空间内势必会进行Scale放缩,也就导致了.9图需要进行适当的上下或者左右拉伸。
还有一个很奇怪的现象,问题手机DisplayMetrics.DENSITY_DEVICE_STABLE和 实际手机分辨率并不相同。其实是部分手机支持分辨率切换能力,DisplayMetrics.DENSITY_DEVICE_STABLE作为一个常量来说是写死的,而实际的手机分辨率则是会动态变化的,在高分辨率模式下为3200*1440,默认的分辨率模式下为2400*1080,该比例刚好等于分辨率的变化比例,即3200/2400 = 640/480 ≈ 1.33。
在了解了问题原因后,我们可以BitmapFactory的参数配置进行调整,其解码参数的中支持了inDensity & inTargetDensity配置,代表输入的位图的像素密度 & 目标位图的像素密度,将Bitmap对应scale放缩变化。通过主动设置density配置,来进一步调整实际输出Bitmap的大小,最终.9图加载优化代码如下:

对于问题图片来说,inDensity为480,而inTargetDensity则为 DisplayMetrics.DENSITY_DEVICE_STABLE的值。

* @param density 对应本地图的匹配density,默认为0,不进行inDensity配置。对于部分可变分辨率手机,可能会存在.9拉伸的问题,需要指定输入输出density。* 参数说明:* mdpi--> DisplayMetrics.DENSITY_MEDIUM* hdpi --> DisplayMetrics.DENSITY_HIGH* xhdpi -->DisplayMetrics.DENSITY_XHIGH* xxhdpi -->DisplayMetrics.DENSITY_XXHIGH* xxxhdpi -->DisplayMetrics.DENSITY_XXXHIGH
@JvmStatic fun loadNinePatchBackgroundForView(view: View, url: String, density: Int = 0) { loadImageAsFile(url, { var options: BitmapFactory.Options? = null options = BitmapFactory.Options() options.inDensity = density options.inTargetDensity = DisplayMetrics.DENSITY_DEVICE_STABLE BitmapFactory.decodeFile(it.path) val bitmap = BitmapFactory.decodeFile(it.path, options) ?: return@loadImageAsFile val chunk = bitmap.ninePatchChunk view.resources?.apply { val defaultDrawable = if (NinePatch.isNinePatchChunk(chunk)) { val rect = if (DuImageGlobalConfig.enableNewNinePatchLoad) { Rect(chunk[12].toInt(), chunk[20].toInt(), chunk[16].toInt(), chunk[24].toInt()) } NinePatchDrawable(this, bitmap, chunk, rect, null) } else { BitmapDrawable(this, bitmap) } view.background = defaultDrawable } })}

网络图FIT_CENTER拉伸

图片为ScaleType为Fit_Center的圆角图加载,查看了问题的图片信息后,我们发现经过CDN短边裁剪后,两张图片的图片Bitmap实际大小均为338*216,在未设置图片指定大小的情况下,会以图片的measuredWidth、measuredHeight为resize的目标,在我的测试机上均为178*178。
为了保证图片内存的可控性,得物图片库设置了兜底的resize裁剪,resize对于heif图片来说为整数倍,具体的计算逻辑在SampleSize裁剪计算逻辑在Fresco的DownsampleUtil类中,大家感兴趣的可以自行阅读。对于当前问题场景来说,sampleSize为2,即将宽高缩小2倍,最终输出的Bitmap大小为169*108。
Fit_center的scale并不会拉伸Drawable绘制区域,当图片小于加载区域时,保持Bitmap比例居中。那么Bitmap由于无法铺满Drawable区域,又触发了Bitmap的大小无法铺满Drawable的IntrinsicWidth的临界条件,且问题图片由于上下的边缘像素并非为白色,最终导致拉伸问题暴露。
Fresco官方本身也有很多类似issue反馈,后来在查阅Fresco高版本源码后,官方采用裁剪绘制区域clipRect来进行修复,具体的提交为https://github.com/facebook/fresco/commit/dae962dd36e71439de9915d89aa8ed8bea835152。
核心思路为mapRect将bitmapBounds(Bitmap的绘制区域)与canvas主绘制区域(RootBounds)进行ClipRect(交集裁剪),将Drawable的绘制区域限制在Bitmap阈值内,这样就规避了区域超出的条件,我们同样引入高版本代码,可以临时解决Fit_Center图片的加载拉伸问题。
但Fresco的官方方案存在一个遗留问题,在CenterCrop场景下,由于长宽都要进行拉伸,并以短边拉伸满为准,会存在长边超出交集区域,导致长边被裁剪的问题,最终图片展示为下图红色、紫色的交集不规则区域。


网络图CENTER_CROP拉伸

有了以上本地图、.9图、Fit_center网络图的拉伸问题分析铺垫,我们来看最后一种拉伸场景。用户从某天开始在2k分辨率手机上突然出现社区推荐流全部拉伸的情况,并且重启、卸载重装均无法恢复,开发小伙伴的小米ultra手机也出现类似情况,说明并不是个例。
根据截图的拉伸现象,我们知道此类问题也一定也是2k分辨率场景触发了Drawable宽高大于Bitmap宽高的临界条件导致的拉伸问题。社区推荐流采用的是CenterCrop的scaleType模式,故Fit_Center的交集裁剪方案并无法解决此问题。
Fresco官方给到的建议为BITMAP_ONLY与OVERLAY_COLOR模式结合使用处理圆角问题。
如何理解此两种模式呢?
BITMAP_ONLY模式为用上文说过android着色器BitmapShader进行绘制圆角,实际Bitmap区域本身就包含圆角。
OVERLAY_COLOR模式为Bitmap并没有进行圆角绘制,而是基于Fresco的视图展示 为多个Drawable的叠加原理,在其上面覆盖一层纯色的圆角背景,来实现圆角。
故我们为了快速修复问题,采用了视图渲染裁剪+OVERLAY_COLOR模式绘制透明背景的方式实现圆角,核心代码如下:
对于圆形图,采用ViewOutlineProvider方式进行圆角区域的限制。
对于不规则圆角图,通过GradientDrawable corner参数来支持四个边缘不规则圆角参数的配置。
对于边缘区域,我们设置为Color.TRANSPARENT,保证在页面视图叠加的时候,不覆盖底层view的展示。
最终通过view的clipToOutline配置进行交集区域的显示裁剪,叠加出圆形效果。


上述实现可以解决绝大多数的圆角展示问题,但对于buildDrawingCache、pixelCopy等本地View的Bitmap保存到相册的场景是存在缺陷的,会导致最终保存到相册的圆角丢失,如下图所示。
核心原因在buildDrawingCache等软件层实现对于使用硬件渲染特性的轮廓、阴影等场景来说是无法支持的。当view的构建缓存中缺失了我们用来圆角实现的轮廓信息后,自然会导致圆角失效。
问题的最终又回到了,我们能否基于BITMAP_ONLY模式彻底解决圆角图拉伸的问题呢?核心还是需要改变Bitmap的宽高或BitmapDrawable的测量宽高,以避免触发BitmapDrawable的测量宽高大于Bitmap的宽高的条件。
  • 改变Bitmap宽高的方式,即类似.9图的优化加载处理,通过BitmapFactory等解码density配置,增大输出Bitmap的宽高,但大面积使用场景会导致整体app使用内存上升,甚至增加线上OOM crash的发生概率,这无疑是不可能接受的。网上也有类似处理方案https://github.com/JessYanCoding/AndroidAutoSize/issues/209,故我们并没有从Bitmap角度持续做文章。
  • 那么改变BitmapDrawable的测量宽高呢?其实思考过后,我们是可以实现的,即对继承于android源码BitmapDrawable进行方法重写,当Bitmap存在时,优先获取Bitmap的宽高作为Drawable的测量宽高。
//重写测量宽高的实现@Overridepublic int getIntrinsicHeight() { Bitmap bitmap = this.mBitmap; if (bitmap != null) { return bitmap.getHeight(); } return super.getIntrinsicHeight();}

@Overridepublic int getIntrinsicWidth() { Bitmap bitmap = this.mBitmap; if (bitmap != null) { return bitmap.getWidth(); } return super.getIntrinsicWidth();}
重写后,Drawable测量宽高即等于Bitmap的宽高,打破了图片像素拉伸的条件,便不会在出现图片拉伸的情况。而效果也如我们预想的一样,采用重写测量宽高的方案后,线上反馈用户和出问题的开发小伙伴都表示已恢复正常。
至此,图片的边缘拉伸问题算是较为圆满的解决了,但是还遗留了两个思考的问题。
  • 为什么用户之前在2k分辨率下网络图都是正常,会突然出现拉伸的情况?
    我猜测为类似.9图中提到的部分设备支持分辨率切换的能力,屏幕分辨率已经被切换为高dpi,历史DisplayMetrics.DENSITY_DEVICE_STABLE仍旧保持低分辨率,但是厂商OTA或者特殊场景覆写,这一错误被纠正了。当targetDensity变大后,会导致测量宽高增大,最终导致拉伸情况产生。
  • 为什么android的源码实现不直接将测量宽高优先以Bitmap宽高为准?
    因为单用Bitmap的宽高作为测量宽高,其实是无法实现单个图片尺寸在不同的屏幕密度上均表现正常的。这也是困扰我们较久的原因,重写圆角图下android源码实现会不会带来什么新问题。梳理了整体view-多级drawable的关系后,其实图片库逻辑中会根据view的测量宽高对Bitmap进行兜底裁剪,由于View的宽高计算逻辑我们并没有改动,仍旧是基于density进行动态调整的。Drawable绘制时填充满View区域,故最终整体方案是可行的。
最终灰度上线后,线上证实并没有用户的极端机型场景下的图片展示异常反馈,线上OOM情况也保持稳定,整体图片加载体验肯定是正向提升的。

本地相册加载优化

得物原有相册缩略图加载采用的是复用本地图加载实现,整体相册页的链路耗时较高,整体用户加载体验存在优化空间。
在对比其他图片加载框架后,发现实际上是支持直接使用系统缩略图的能力,由于去除了复杂的EncodeImage封装、自定义解码等实现,实测加载耗时比现有流程快很多。于是乎,我们考虑是否可以系统缩略图接入到整体的Fresco加载流程中,简化本地缩略图的加载流程。
核心逻辑:新增本地缩略图SourceType,新增一个ThumbnailProducer接管原有Uri获取、旋转信息、解码等实现。

缩略图获取

左图为android Q及以上,右图为android Q及以下

熟悉了android源码缩略图api后,我们实现了自定义的ThumbnailProducer直接获取Bitmap的方案接入,通过借鉴其他Fresco Producer的 StatefulProducerRunnable实现,统一支持异常捕获、加载取消、成功回调等,对整体producer责任链框架并不会造成任何影响。

旋转参数兼容

线下测试时发现新方案中android 9的部分手机出现了缩略图的旋转问题。当即怀疑是旋转参数在android 9以上丢失了,同时也在google的官方issue找到了论证了我们的猜想,android 10以上缩略图会保证正常的缩略图实现,android 9以下暂未支持。
既然官方没有支持,那么我们只有自己适配旋转了。在android系统源码中ExifInterface包含了各种图片元数据,当然也包含旋转信息,只要知道了旋转角度与方向,fresco CloseableStaticBitmap自身是支持了旋转信息的配置的,以下为具体实现代码片段:
//cursor 获取具体的pathNamefinal String pathname = cursor.getString(cursor.getColumnIndex(MediaStore.Images.ImageColumns.DATA));if (pathname != null) { try { //通过ExifInterface获取图片的旋转角度 ExifInterface exif = new ExifInterface(pathname); orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); rotationAngle = JfifUtil.getAutoRotateAngleFromOrientation(orientation); } catch (IOException ioe) { FLog.e(PRODUCER_NAME, ioe, "Unable to retrieve thumbnail rotation for %s", pathname); }}..... //此处省略部分代码实现
//构造CloseableStaticBitmap时,传入旋转的角度与方向CloseableStaticBitmap closeableStaticBitmap =new CloseableStaticBitmap( loadThumbnail, SimpleBitmapReleaser.getInstance(), ImmutableQualityInfo.FULL_QUALITY, rotationAngle, orientation)

异常读取兜底

在测试过程中,还发现存在一张实际为jpeg图顶用heif后缀,导致在缩略图新流程下的加载失败的情况。根因为相册Media数据库在读取图片类型时,是依据后缀进行区分的,导致jpeg被误判为heif格式,而heif、avif等类视频格式的系统缩略图实现,需要通过MediaMetaDataRetriever进行关键帧获取,不同于jpeg等常规格式采用BitmapFactory解码。

故我们对此类情况做了兼容处理,通过兜底指定异常,读取文件流的头部字节(ImageHeader)判断图片类型进行类型校正。
try { loadThumbnail = mContentResolver.loadThumbnail(imageRequest.getSourceUri(), new Size(imageRequest.getPreferredWidth(), imageRequest.getPreferredHeight()), cancellationSignal);} catch (IOException exception) { if (exception.getMessage() != null && exception.getMessage().contains("Failed to create thumbnail")) { if (imageRequest.getMimeType() != null && HEIF_MIME_TYPE.contains(imageRequest.getMimeType())) { String path = getRealPathFromUri(mContentResolver, imageRequest.getSourceUri()); if (path != null) { File file = new File(path); FileInputStream fileInputStream = new FileInputStream(file); //读取头文件 做实际的判断 String type = ImageFormatChecker.getInstance().determineImageFormat(fileInputStream).getFileExtension(); //暂时只判断heif, avif格式的误判 暂时没做头文件 format检测 if (type != null && !type.equals(HEIF_FORMAT_EXTENSION)) { loadThumbnail = createImageThumbnail(file, new Size(imageRequest.getPreferredWidth(), imageRequest.getPreferredHeight()), cancellationSignal);
if (loadThumbnail != null) { //补充异常兜底事件抛到业务层 producerContext.getProducerListener().onProducerEvent(producerContext, PRODUCER_NAME, THUMBNAIL_FALL_BACK_EVENT); } } } } else { //此处抛出异常 会被onProducerFailed 处理 上层能监控到 throw exception; } }}

视频缩略图兼容 & 大图压缩

视频缩略图的实现则是直接在原有的LocalVideoThumbnailProducer内进行改造,同时端侧基于hook Bitmap创建的大图监控发现存在部分高清视频的关键帧截取存在超过20mb的大图,我们通过Bitmap scale api,根据上层配置ResizeOption信息补充了裁剪实现。
视频缩略图:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { loadThumbnail = mContentResolver.loadThumbnail(imageRequest.getSourceUri(), new Size(imageRequest.getPreferredWidth(), imageRequest.getPreferredHeight()), null);} else { long videoId = -1; try { videoId = ContentUris.parseId(imageRequest.getSourceUri()); } catch (Exception e) { FLog.w(PRODUCER_NAME, imageRequest.getSourceUri().toString() + " find exception " + e.getLocalizedMessage()); } if (videoId > 0) { loadThumbnail = MediaStore.Video.Thumbnails.getThumbnail( mContentResolver, videoId, calculateKind(imageRequest), null ); }}
缩略图scale裁剪:
if (imageRequest.isResizingThumbnail() && imageRequest.getResizeOptions() != null) { if (bitmap.getHeight() <= 0 || bitmap.getWidth() <= 0) return; ResizeOptions resizeOptions = imageRequest.getResizeOptions(); int resizeWidth = resizeOptions.width; int resizeHeight = resizeOptions.height; if (resizeWidth <= 0 || resizeHeight <= 0 ) return; //计算缩略图的宽高比 float radio = (float) bitmap.getWidth() / bitmap.getHeight();
float tempWidth = resizeWidth; float tempHeight = resizeHeight; // 如果 resize的宽高比 大于 实际原图的话,那就需要对图片做调整 if (tempWidth / tempHeight > radio) { resizeHeight = (int) (tempWidth / radio); } else { resizeWidth = (int) (tempHeight * radio); } bitmap = Bitmap.createScaledBitmap(bitmap, resizeWidth, resizeHeight, true);}
业务侧在540接入新版的相册加载流程上线后,首帧加载指标有了显著提升。基于线上80分位数据来看:
  • 冷启动链路耗时:
    • 从跳转发布工具相册首页-相册缩略图全部展示:1994.8 -> 991
    • 从开始加载相册缩略图-相册缩略图全部展示:1141 -> 359
  • 热启动链路耗时:
    • 从跳转发布工具相册首页-相册缩略图全部展示:1022.8 -> 573.2
    • 从开始加载相册缩略图-相册缩略图全部展示:560.39 -> 222

动图缓存、闪烁优化

Fresco多帧动图播放实现基于子线程的方法循环,过往得物图片库对Fresco动图的实现进行了全量重写,采用handler消息模型替代子线程多帧Bitmap循环,方便对单帧消息进行干预,支持动图停留在任意帧、超大动图强制转为静图、动图多帧并发解码等能力。
得物历史的动图闪烁优化采用禁用GenericDraweeHierarchy的reset操作,可以理解为离屏时不触发drawble的清理操作,方便回屏时进行复用。但此逻辑会导致回屏时指定帧的Bitmap丢失引用被GC回收,引起onDraw执行时找不到Bitmap抛出use a recycled Bitmap异常。线上我们做了try catch兜底处理,但仍旧有接近万次的异常上报。
故我们考虑继续排查闪烁问题的根因,发现动图缓存在多次上离屏过程中内存缓存中存在多份动图Bitmap对象引用。以下图为例,1张15帧动图,通过FaceBook Flipper工具可以看到第一次加载会将15帧动图缓存全部写入内存缓存,第三次加载时内存缓存居然存在3份同样的Bitmap缓存。
这对于内存极度敏感的图片库来说,无疑是不可接受的,不但会大量挤占静图内存缓存空间,还由于二次加载动图缓存无法复用,引起动图加载耗时升高,加大闪烁、卡顿概率。
以下为过往得物侧动图单帧渲染核心逻辑:
private void prepareFrame(final int frameNumber) { // 先从缓存中取 BitmapFrameCache mBitmapFrameCache = mAnimationBackend.mBitmapFrameCache; CloseableReference<Bitmap> bitmapReference = mBitmapFrameCache.getCachedFrame(frameNumber); if (bitmapReference == null || !bitmapReference.isValid()) { // 缓存中没有,new Bitmap并保存 try { bitmapReference = mAnimationBackend.mPlatformBitmapFactory.createBitmap(mAnimationBackend.getIntrinsicWidth(), mAnimationBackend.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); } catch (OutOfMemoryError e) { //埋点上报逻辑 } // 新建出来的对象也不可用 丢弃当前帧 if (bitmapReference == null || !bitmapReference.isValid()) { return; } Bitmap bitmap = bitmapReference.get(); if (bitmap.isRecycled()) { return; } mAnimationBackend.mAnimatedImageCompositor.renderFrame(frameNumber, bitmap); // 动图不再写入内存缓存 mBitmapFrameCache.onFrameRendered(frameNumber, bitmapReference, BitmapAnimationBackend.FRAME_TYPE_CACHED); } mAnimationBackend.mCurrentFrame.put(frameNumber, bitmapReference);}

动图cacheKey重写

从上面代码可以发现,核心内存缓存获取逻辑为mBitmapFrameCache.getCachedFrame方法,最终调用到mBackingCache方法,而其正是我们熟知的BitmapMemoeryCache-内存缓存,其内部实现了基于LinkedHashMap结构的LRU对象。
断点查看其内部保存的实际数据后,其键值为FrameKey对象,对于同一张动图的第15帧,由于对象不同导致的内存保存了多份,那么问题一定出在了动图cacheKey的实现逻辑上。
我们查看Fresco相关源码果然发现了问题,众所周知HashMap的get方法判断是否存在已有缓存对象逻辑为同时满足对象的hashCode相等与equal方法返回true。但现有Fresco实现没有做任何处理,新创建的对象由于hashCode与内存地址等相关,无法保证相等,是无法被内存缓存判断复用的。
明确了问题点后,优化手段在考虑后主要分为两点
  • 重写AnimationFrameCacheKey的hashCode与equals方法,保证LruCountingMemoryCache的key对象一致。
@Overridepublic boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null || getClass() != obj.getClass()) { return false; } AnimationFrameCacheKey that = (AnimationFrameCacheKey) obj; //equals 仅对比mAnimationUriString return mAnimationUriString.equals(that.mAnimationUriString);}
@Overridepublic int hashCode() { //hashCode 仅对比mAnimationUriString的hashCode 即 anim:// + 图片URI hashcode return mAnimationUriString.hashCode();}
  • 取消原有根据AnimatedImageResult对象的hashCode作为imageId逻辑,参考静图逻辑,改用图片URIString的hashCode,对于相等的URIString内容来说,hashCode保持固定。
private BitmapFrameCache createBitmapFrameCache(AnimatedImageResult animatedImageResult) { int cacheKeyHash; if (DuImageGlobalConfig.isEnableAnimatedCompareOnlyUri() && animatedImageResult.getSourceUri() != null && !animatedImageResult.getSourceUri().isEmpty()) { //动图cacheKeyHash 仅采用SourceUri cacheKeyHash = animatedImageResult.getSourceUri().hashCode(); } else { //原有逻辑 cacheKeyHash = animatedImageResult.hashCode(); } return new FrescoFrameCache(new AnimatedFrameCache(new AnimationFrameCacheKey(cacheKeyHash, DuImageGlobalConfig.isEnableNewAnimatedCache()), mBackingCache), DuImageGlobalConfig.isEnableNewAnimatedCache());}

支持重用帧配置

同时我们还发现Fresco框架支持帧对象重用的配置,但我们并没有合理使用。
当内存缓存无法获取到可用的Bitmap引用,支持LruMemoryCache.reused尝试获取仍在内存缓存中,但引用丢失的Bitmap数据,并在获取重新写入内存缓存,对齐Fresco原有的动图实现。
// 先从内存缓存中取 BitmapFrameCache mBitmapFrameCache = mAnimationBackend.mBitmapFrameCache; CloseableReference<Bitmap> bitmapReference = mBitmapFrameCache.getCachedFrame(frameNumber); if (DuImageGlobalConfig.isEnableNewAnimatedCache() && (bitmapReference == null || !bitmapReference.isValid())) { //从无引用的缓存中在尝试取一次 bitmapReference = mBitmapFrameCache.getBitmapToReuseForFrame(frameNumber, mAnimationBackend.getIntrinsicWidth(), mAnimationBackend.getIntrinsicHeight()); //获取到后重新写入内存缓存 mBitmapFrameCache.onFramePrepared(frameNumber, bitmapReference, BitmapAnimationBackend.FRAME_TYPE_CACHED);}
至此,动图完成了更好的复用能力,加载耗时也得到了有效降低,在多帧多线程预渲染的情况下,从17ms降低至1ms。

优化前

优化后

绑定view加载Tag

动图缓存的加载耗时降低后,仍旧低概率的出现闪烁概率,最终发现业务侧存在同url前后多次调用的情况,导致view绑定的AbstractDraweeController进行了重置。此时想到了是否可以通过view绑定加载url的方式,当url未发生变化时,不进行重复加载,参考代码如下:
//当前view的tag 与 加载url不相等if(view.tag != image.url){ view.loadwith(image.url).apply view.tag=image.url}
由于业务可能会对图片加载参数进行动态变更,故现有此逻辑暂时没有整体收口到图片库中,需要制定一套更合理的唯一id的实现,也算是后续的一个优化方向。但总体而言,经过耗时优化与解决重复加载后,图片闪烁问题已经得到了较好的解决。

未来展望

基于Fresco开源框架,得物侧也建立了图片的黑白屏、解码卡死等异常加载监控能力,并支持数量可控的预加载生产消费、图片高低优网络队列改造、解码库监控升级优化等,之后有机会再进行对应的介绍。
整体图片加载覆盖了系统多图片格式支持、编解码、网络库、cdn质量、view绘制、监控体系等全过程,其实我们还有较多值得持续深挖的点,例如:
  • 网络库精细化监控
  • 图片动、大图缓存独立、磁盘全局锁优化、解码流程监控、全局图片质量压缩、全链路监控平台建设
  • cdn异常备份降级、弱网离屏场景断点续下
  • ...

总结

通过以上问题的排查我们可以发现图片问题重在于对问题的理解,在熟悉Fresco的架构设计原理以及android视图绘制流程的基础上,敢于对源码提出质疑,不盲目相信开源库的实现就是最优解,解决手段来说一般也都是直接了当的。Fresco核心基础逻辑实现的背后,其实从性能、体验上都有其优劣势,值得好好分析。

往期回顾


1. 来自快手、得物等无线优化技术话题已集结|干货
2. 非标类型导致Dubbo接口出入参异常的本质 | 得物技术
3. 模型量化与量化在LLM中的应用 | 得物技术
4. 如何做配置链接的质量保障?看这篇就对了 | 得物技术
5. 基于Java代码模型生成质量平台自动化用例方案与实践 | 得物技术


文 / GavinX


关注得物技术,每周一、三、五更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

扫码添加小助手微信

如有任何疑问,或想要了解更多技术资讯,请添加小助手微信:




线下活动推荐

快快点击下方图片报名吧!


继续滑动看下一个
得物技术
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存