背景
因为项目原有的图片加载框架已经不满足新的业务需求,而且改造成本较大,本人经过慎重的技术选型,将原有的图片加载框架整体迁移成Glide。在这个过程中我遇到了不少问题,跟大家分享一下。
1.如果ImageView不可见 或者 width为0 或者 height为0,glide是不会加载图像的。
项目中遇到ImageView并没有add到UI上,只是用来加载图像,获取drawing cache作为Bitmap使用。在没有layout的时候width和height都是0,结果图片是空的,因为glide没有加载图像。
解决方案:layout结束才加载图像。
2.不支持多磁盘缓存目录。
因为DiskCache的LRU机制,我们倾向于会把较小的头像和较大的会话图片放在不同的目录存储,以保证小头像有足够的磁盘缓存空间,小文件也不会因大文件的频繁变动而从缓存中移除。
但是Glide原生只支持一个磁盘缓存目录,很多人也提过这个issue。根据作者提供的思路,可以通过继承DiskLruCacheWrapper
,依据不同请求的signature来分发不同的磁盘缓存目录,但是需要一些反射的手段。
MultiFolderDiskLruCacheWrapper.java
/**
* 支持多个磁盘缓存目录
*/
public class MultiFolderDiskLruCacheWrapper extends DiskLruCacheWrapper {
private static final String TAG = "MultiFolderDiskLruCacheWrapper";
private static Field sFieldSignatureInResourceCacheKey;
private static Field sFieldSignatureInDataCacheKey;
private static MultiFolderDiskLruCacheWrapper wrapper;
static {
try {
Class> resourceCacheKeyClass = Class.forName("com.bumptech.glide.load.engine.ResourceCacheKey");
sFieldSignatureInResourceCacheKey = resourceCacheKeyClass.getDeclaredField("signature");
sFieldSignatureInResourceCacheKey.setAccessible(true);
Class> DataCacheKeyClass = Class.forName("com.bumptech.glide.load.engine.DataCacheKey");
sFieldSignatureInDataCacheKey = DataCacheKeyClass.getDeclaredField("signature");
sFieldSignatureInDataCacheKey.setAccessible(true);
} catch (ClassNotFoundException e) {
WwLog.log(Log.WARN, TAG, "find ResourceCacheKey failed", e);
} catch (NoSuchFieldException e) {
WwLog.log(Log.WARN, TAG, "reflect signature failed", e);
} catch (Error error) {
WwLog.log(Log.WARN, TAG, "reflect signature failed", error);
}
}
private DiskCache[] diskCaches = new DiskCache[CacheFolder.values().length];
public static synchronized DiskCache get() {
if (wrapper == null) {
wrapper = new MultiFolderDiskLruCacheWrapper(null, 0);
}
return wrapper;
}
protected MultiFolderDiskLruCacheWrapper(File directory, int maxSize) {
super(directory, maxSize);
}
@Override
public File get(Key key) {
return getDiskCacheBySignature(key).get(key);
}
@Override
public void put(Key key, Writer writer) {
getDiskCacheBySignature(key).put(key, writer);
}
@Override
public void delete(Key key) {
getDiskCacheBySignature(key).delete(key);
}
@Override
public synchronized void clear() {
for (DiskCache diskCache : diskCaches) {
if (diskCache != null) {
diskCache.clear();
}
}
}
/**
* 根据key选择不同的DiskCache实例,每个DiskCache实例对应不同缓存目录
* @param key
* @return
*/
private DiskCache getDiskCacheBySignature(Key key) {
Object signature = getSignature(key);
CacheFolder cacheFolder;
if (isContact(signature)) {
cacheFolder = CacheFolder.CONTACHT;
} else {
cacheFolder = CacheFolder.OTHER;
}
return getDiskCache(cacheFolder);
}
@Nullable
private Object getSignature(Key key) {
Object signature = null;
try { // 已有缓存的情况下一般是ResourceCacheKey,所以这种情况更常见
signature = sFieldSignatureInResourceCacheKey.get(key);
} catch (Exception e) {
WwLog.log(Log.INFO, TAG, "getSignature: " + e.getMessage());
}
if (signature != null) return signature;
try {
signature = sFieldSignatureInDataCacheKey.get(key);
} catch (Exception e) {
WwLog.log(Log.INFO, TAG, "getSignature", e);
}
return signature;
}
private boolean isContact(Object signature) {
return signature instanceof ContactSignature;
}
@NonNull
private DiskCache getDiskCache(CacheFolder cacheFolder) {
DiskCache diskCache = diskCaches[cacheFolder.index];
if (diskCache == null) {
String cachePath = FileUtil.getImageDiskCacheDirPath(cacheFolder.cacheDirName);
if (FileUtil.createDir(cachePath)) {
try {
diskCache = (DiskLruCacheWrapper) ReflecterHelper.newInstance(
"com.bumptech.glide.load.engine.cache.DiskLruCacheWrapper", new Object[]{new File(cachePath), cacheFolder.cacheSize});
diskCaches[cacheFolder.index] = diskCache;
} catch (Exception e) {
WwLog.log(Log.ERROR, TAG, "getDiskCache", e);
}
}
}
return diskCache;
}
}
CacheFolder.java
public enum CacheFolder {
CONTACHT(0, 1024 * 1024 * 50, "glidecontactphoto"),
IMAGE(1, 1024 * 1024 * 100, "glideimage"),
OTHER(2, 1024 * 1024 * 50, "glideother"),
;
public int index;
public int cacheSize;
public String cacheDirName;
CacheFolder(int index, int cacheSize, String cacheDirName) {
this.index = index;
this.cacheSize = cacheSize;
this.cacheDirName = cacheDirName;
}
}
3.使用setTag会报错”You must not call setTag() on a view Glide is targeting”。
先看下Glide中是怎样使用setTag的:
/**
* Returns any stored request using {@link android.view.View#getTag()}.
*
* For Glide to function correctly, Glide must be the only thing that calls {@link
* View#setTag(Object)}. If the tag is cleared or put to another object type, Glide will not be
* able to retrieve and cancel previous loads which will not only prevent Glide from reusing
* resource, but will also result in incorrect images being loaded and lots of flashing of images
* in lists. As a result, this will throw an {@link java.lang.IllegalArgumentException} if {@link
* android.view.View#getTag()}} returns a non null object that is not an {@link
* com.bumptech.glide.request.Request}.
*/
@Override
@Nullable
public Request getRequest() {
Object tag = getTag();
Request request = null;
if (tag != null) {
if (tag instanceof Request) {
request = (Request) tag;
} else {
throw new IllegalArgumentException(
"You must not call setTag() on a view Glide is targeting");
}
}
return request;
}
原来Glide把Request通过setTag的方式放在ImageView中,主要用于复用资源取消旧的加载请求 或者 在列表滑动图片时防止加载了错误的图片。
解决方案:改成使用setTag(int key, final Object tag)
就不会和Glide冲突了。
4.需要清除某个key对应的图像缓存。
解决方案:参考issue,使用作者建议的方式,通过更新signature来废弃原有的图像缓存。
还有另一种情况是,需要显示的图片只是一次性的临时图片,但是这个临时图片的路径是写死在代码中的,这时直接选择不使用磁盘缓存的方式就好了(DiskCacheStrategy.NONE
)。
5.需要图片落地加密,读磁盘缓存时解密。
由于我们项目对安全性要求较高,需要对所有的图片用aes加密后才落地,读缓存时要aes解密。那如何在Glide的下载图片和加载缓存的过程中插入加解密的流程呢?
一种直接的想法就是去修改Glide的源码,在加载流程中加入我们的加解密函数。但是侵入式修改源码的做法,不利于跟进官方对Glide的升级。
参考issue,其实根据Glide Resource Flow,Glide是可以通过插入Encoder,ResourceDecoder,ResourceEncoder
来自定义加载的流程。后来我看到另一个项目的需求,要在Glide的基础上支持对sharpP格式(腾讯音视频实验室推出的一种图片压缩技术)的支持,也是可以自定义ModelLoader
来实现。
强烈建议认真研读Glide Resource Flow这张图,对于我们理解Glide数据流的转换和理解源码的帮助很大。
为了实现对所有图片的加解密,我加入了如下的Decoder和Encoder
。其实源码里有大把的ResourceDecoder,基本上复制过来添加加解密的函数的就可以了。
@GlideModule(glideName = "WwGlide")
public class WwGllideMoudle extends AppGlideModule {
@Override
public void registerComponents(Context context, Glide glide, Registry registry) {
// 对于ModelLoader,Encoder,ResourceDecoder,ResourceEncoder的理解,参考
// https://docs.google.com/drawings/d/1KyOJkNd5Dlm8_awZpftzW7KtqgNR6GURvuF6RfB210g/edit
/* FileId */
registry.append(ImageFileId.class, InputStream.class, new FileIdLoader.Factory());
/* File */
registry.append(File.class, new FileEncoder())
.prepend(File.class, File.class, new FileDecoder());
/* 加解密相关 */
BitmapPool bitmapPool = glide.getBitmapPool();
ArrayPool arrayPool = glide.getArrayPool();
Downsampler downsampler = new Downsampler(glide.getRegistry().getImageHeaderParsers(),
glide.getContext().getResources().getDisplayMetrics(), bitmapPool, arrayPool);
registry.prepend(InputStream.class, new StreamEncryptEncoder(glide.getArrayPool()));
registry.prepend(Bitmap.class, new BitmapEncyptEncoder());
registry.prepend(BitmapDrawable.class, new BitmapDrawableEncryptEncoder(bitmapPool, new BitmapEncyptEncoder()));
registry.prepend(File.class, Bitmap.class, new BitmapDecryptDecoder(downsampler, arrayPool));
glide.getRegistry().replace(GlideUrl.class, InputStream.class, new HttpGlideUrlLoader.Factory());
}
...
}
在使用开源项目时,如果需要根据业务自定义开源项目中的某些流程,最优方案是寻找开源项目中,可以提供定制能力的接口或可供继承的类;次优方案是使用反射的手段hack掉开源项目中类;最后才是修改源码。因为如果和源码形成了不同的分支,后面原作者对项目的更新,我们就只能通过merge代码的方式跟进。
预热缓存
增加了图片加解密后,我们发现头像首次加载速度比之前慢了很多,原因是从磁盘中解密图片文件时耗费了很多时间。考虑到App启动后闪屏页大概要花费1.5s的时间,我在这段时间内预热头像的内存缓存,闪屏结束后首页加载头像就很快了。
private void preloadConversationContact() {
List conversationItems = ConversationEngine.getInstance().getSortedConversationList();
for (final ConversationItem conv : conversationItems) {
List photoUrlList = conv.getPhotoUrlList();
boolean isSinglePhoto = photoUrlList.size() == 1;
for (int i = 0; i < MultiPhotoImageView.MAX_PHOTO_COUNT && i < photoUrlList.size(); i++) {
if (!StringUtil.isHttpSchema(photoUrlList.get(i))) break;
((GlideImgLoader) WwImgLoaderManager.getInstance()).loadContactImage(WwUtil.APPLICATION_CONTEXT, photoUrlList.get(i),
GlideImgLoader.NO_DEFAULT_RES, isSinglePhoto ? ImageSizeType.TYPE_MIDDLE : ImageSizeType.TYPE_SMALL, null);
}
}
}
6.需要一个同步接口判断某个url的图片是否在磁盘缓存中。
Glide从磁盘缓存中加载图像的流程是放在异步IO线程中进行的,所以只提供了异步判断的接口。但是项目有几个地方是用了同步方式判断缓存是否存在,如果改成异步接口需要修改大量代码。通读了Glide中构造key加载图像的代码,发现如果是按原大小加载图像,也是可以通过反射出几个关键类判断的。
public static String getPathFromGlideCacheByFileId(String fileId) {
try {
Key sourceId = new ObjectKey( new ImageFileId(fileId));
Key originalKey = (Key) ReflecterHelper.newInstance(
"com.bumptech.glide.load.engine.DataCacheKey", new Object[]{sourceId, EmptySignature.obtain()}, new Class[]{Key.class, Key.class});
File cacheFile = MultiFolderDiskLruCacheWrapper.get().get(originalKey);
if (cacheFile != null) {
return cacheFile.getAbsolutePath();
}
} catch (Exception e) {
WwLog.log(Log.INFO, TAG, "getAttachPath", e);
}
return null;
}
}
但是如果按照实际显示大小加载图像,因为ResourceCacheKey
的构造涉及了实际大小等其他因素,就没什么办法了。
currentKey = new ResourceCacheKey(sourceId, helper.getSignature(), helper.getWidth(),
helper.getHeight(), transformation, resourceClass, helper.getOptions());
题后语
经过了这次对开源项目的技术选型、迁移旧代码,感触还是比较多的。
关于技术选型
对于某个业务需求,类似的开源项目很多,首先要选跟自身业务所需要的功能和接口都最契合的,否则迁移旧代码时就要做很多改造。我选择Glide主要是因为Glide可以支持gif和视频的加载,并且提供了扩展所支持的图片格式、定制加载流程的方式,可以满足我们丰富多样的业务需求。
其次是选社区比较活跃,作者积极发布新版本和处理issue的开源项目。我之前在Glide的项目中提了几个issue,基本上作者都会当天回复,并且也可以找到很多相似的案例。在使用开源项目遇到问题时,这样可以大大提高处理问题的速度。
关于迁移框架
迁移旧代码是一个漫长的过程,不能步子迈太大。我首先将头像加载部分改造成使用Glide,经过两个迭代的上线验证之后,再迁移主业务的图片加载接口。整个过程中旧框架和新框架并存,并可以自由切换,以防出现了严重问题需要切回原框架。