目录
一、背景
二、图片拉伸优化
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.htmlhttps://github.com/facebook/fresco/issues/2308Fresco开发人员给到的解释为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
//将bitmap数据封装到NinePatchDrawable中
image.resources?.apply {
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的测量宽高。
//重写测量宽高的实现
@Override public int getIntrinsicHeight() {
Bitmap bitmap = this.mBitmap;
if (bitmap != null) {
return bitmap.getHeight();
}
return super.getIntrinsicHeight();
}
@Override public 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 获取具体的pathName
final 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时,传入旋转的角度与方向CloseableStatic
Bitmap 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对象一致。
@Override public 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异常备份降级、弱网离屏场景断点续下
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.dandroid.cn/archives/22766,转载请注明出处。
评论0