前言:
加载并显示gif是App常见的一个功能,像加载普通图片一样,大体应该包含以下几项功能:
1、自动下载GIF到本地文件作为缓存,第二次加载同一个url的图片不需要下载第二遍
2、由于GIF往往较大,要显示圆形的进度条提示下载进度
3、在GIF完全下载完之前,先显示GIF的第一帧图像进行占位,完全下载完毕之后自动播放动画。
4、两个不同的页面加载同一张GIF,两个页面的加载进度应该一致
5、支持ViewPager同时加载多个GIF动图
效果演示:
实现思路:
1、关于下载和磁盘缓存:
我这里使用HttpConnection根据url进行下载,在下载之前先将url字符串使用16位MD5进行转换,让下载的文件名为url的MD5码,然后以4096字节为单位,使用ByteStremBuffer进行边读边写,防止下载过程中内存溢出,而且不时的向磁盘写入还可以帮助实现GIF第一帧占位的效果。
2、关于进度指示:
我这里使用了一个圆形的第三方Progress Bar和一个TextView实现,由于在下载过程中以4096为缓冲,所以每下载4096字节就会更新一次进度UI。文件总大小由http返回报文的头部的Content-length返回,通过已下载大小除以这个length得出下载百分比。
3、关于不同页面的下载同步:
用户在首页会看到一个gif,这时候点击图片可以跳进大图页继续这个gif的下载,用户在首页的下载进度到带到大图页来,不能让用户下载两遍,也不能在大图页打开一个才下载了一半的图像。
首先在下载开始之前,建立一个MD5.tmp的文件用来存储下载内容,在下载完毕之后将.tmp文件名后缀去掉,这样通过文件系统检索一个GIF是否已被下载的时候,没有下载完成的图片就不会被检索出来。
如果有一个url已经开始了一次下载,这时候又有一个下载请求同一个url,此时会将请求的imageView,textView和progressBar使用一个WeakReference引用起来,防止内存泄漏,然后把这三个空间添加到一个HashMap里去,这个HashMap的key是url,value就是这些控件的弱引用组成的list。当下载线程更新进度或完成的时候,会从这个HashMap中根据url取出所有和这张gif有关的控件,然后把这些控件统一的更新状态,这样就可以保证不同页面的控件的进度相同,也避免了一个文件下载多次的情况。
4、关于使用GIF的第一帧进行下载占位:
GIF的显示使用了github上的开源项目:android-gif-drawable,地址:https://github.com/koral--/android-gif-drawable。是一个非常优秀的框架,其内部使用c语言编写了一些效率非常高的执行代码。
这个框架的可以直接根据输入流进行加载,也就是说不用等gif文件完全下载完毕就可以显示已经下载完毕的内容,甚至可以向浏览器那样一行像素一行像素的进行加载,十分好用。
根据框架的这个特性,只需要将还没有下载好的文件直接传到Drawable里,让道gifImageView中显示即可,并且在这之前要判断能否拿到第一帧,然后设置播放选项为暂停。
5、关于VIewPager的使用
在ViewPager的Adapter使用的时候遇到了很多麻烦,主要是由于ViewPager的缓存机制引起的,会引起显示重复,无控件显示等等问题,要解决在ViewPager中的使用,并让GifImageView和普通ImageView一起在ViewPager中和平共处,需要先研究好ViewPager的缓存机制。在这里我是先根据所有图片数量生成同等多的imageView放在一个数组里,然后ViewPager切换到哪张就从数组里拿出哪张放到ViewPager的Container里。GIfImageVIew也是这样,不过是放在另一个数组里,根据position取得相应的GIFImageView,然后用container来add,这里对于add过一遍的GIfImageView会报异常,通过catch解决。
关于如何在项目中引入android-gif-drawable这个库,请看我的另一篇博文《Android移植NDK子项目--以android-gif-drawable为例》
具体代码:
加载工具类:
import android.os.Handler; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.FileOutputStream; import java.io.OutputStream; import java.lang.ref.WeakReference; import java.net.HttpURLConnection; import java.net.URL; import java.security.MessageDigest; import com.imaginato.qravedconsumer.task.AlxMultiTask; import com.lidroid.xutils.HttpUtils; import com.pnikosis.materialishprogress.ProgressWheel; import com.qraved.app.R; import java.io.File; import java.io.IOException; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.concurrent.ConcurrentHashMap; import pl.droidsonroids.gif.GifDrawable; import pl.droidsonroids.gif.GifImageView; /** * Created by Alex on 2016/6/16. */ public class AlxGifHelper { public static class ProgressViews{ public ProgressViews(WeakReference<GifImageView> gifImageViewWeakReference, WeakReference<ProgressWheel> progressWheelWeakReference, WeakReference<TextView> textViewWeakReference,int displayWidth) { this.gifImageViewWeakReference = gifImageViewWeakReference; this.progressWheelWeakReference = progressWheelWeakReference; this.textViewWeakReference = textViewWeakReference; this.displayWidth = displayWidth; } public WeakReference<GifImageView> gifImageViewWeakReference;//gif显示控件 public WeakReference<ProgressWheel> progressWheelWeakReference;//用来装饰的圆形进度条 public WeakReference<TextView> textViewWeakReference;//用来显示当前进度的文本框 public int displayWidth;//imageView的控件宽度 } public static ConcurrentHashMap<String,ArrayList<ProgressViews>> memoryCache;//防止同一个gif文件建立多个下载线程,url和imageView是一对多的关系,如果一个imageView建立了一次下载,那么其他请求这个url的imageView不需要重新开启一次新的下载,这几个imageView同时回调 //为了防止内存泄漏,这个一对多的关系均使用LRU缓存 /** * 通过本地缓存或联网加载一张GIF图片 * @param url * @param gifView */ public static void displayImage(final String url, GifImageView gifView, ProgressWheel progressBar , TextView tvProgress, int displayWidth){ //首先查询一下这个gif是否已被缓存 String md5Url = getMd5(url); String path = gifView.getContext().getCacheDir().getAbsolutePath()+"/"+md5Url;//带.tmp后缀的是没有下载完成的,用于加载第一帧,不带tmp后缀是下载完成的, //这样做的目的是为了防止一个图片正在下载的时候,另一个请求相同url的imageView使用未下载完毕的文件显示一半图像 JLogUtils.i("AlexGIF","gif图片的缓存路径是"+path); final File cacheFile = new File(path); if(cacheFile.exists()){//如果本地已经有了这个gif的缓存 JLogUtils.i("AlexGIF","本图片有缓存"); if(displayImage(cacheFile,gifView,displayWidth)) {//如果本地缓存读取失败就重新联网下载 if (progressBar != null) progressBar.setVisibility(View.GONE); if (tvProgress!=null)tvProgress.setVisibility(View.GONE); return; } } //为了防止activity被finish了但是还有很多gif还没有加载完成,导致activity没有及时被内存回收导致内存泄漏,这里使用弱引用 final WeakReference<GifImageView> imageViewWait= new WeakReference<GifImageView>(gifView); final WeakReference<ProgressWheel> progressBarWait= new WeakReference<ProgressWheel>(progressBar); final WeakReference<TextView> textViewWait= new WeakReference<TextView>(tvProgress); if(gifView.getId()!= R.id.gif_photo_view)gifView.setImageResource(R.drawable.qraved_bg_default);//设置没有下载完成前的默认图片 if(memoryCache!=null && memoryCache.get(url)!=null){//如果以前有别的imageView加载过 JLogUtils.i("AlexGIF","以前有别的ImageView申请加载过该gif"+url); //可以借用以前的下载进度,不需要新建一个下载线程了 memoryCache.get(url).add(new ProgressViews(imageViewWait,progressBarWait,textViewWait,displayWidth)); return; } if(memoryCache==null)memoryCache = new ConcurrentHashMap<>(); if(memoryCache.get(url)==null)memoryCache.put(url,new ArrayList<ProgressViews>()); //将现在申请加载的这个imageView放到缓存里,防止重复加载 memoryCache.get(url).add(new ProgressViews(imageViewWait,progressBarWait,textViewWait,displayWidth)); final HttpUtils http = new HttpUtils(); // 下载图片 startDownLoad(url, new File(cacheFile.getAbsolutePath()+".tmp"), new DownLoadTask() { @Override public void onStart() { JLogUtils.i("AlexGIF","下载GIF开始"); ProgressWheel progressBar = progressBarWait.get(); TextView tvProgress = textViewWait.get(); if(progressBar!=null){ progressBar.setVisibility(View.VISIBLE); progressBar.setProgress(0); if(tvProgress==null)return; tvProgress.setVisibility(View.VISIBLE); tvProgress.setText("1%"); } } @Override public void onLoading(long total, long current) { int progress = 0; //得到要下载文件的大小,是通过http报文的header的Content-Length获得的,如果获取不到就是-1 if(total>0)progress = (int)(current*100/total); JLogUtils.i("AlexGIF","下载gif的进度是"+progress+"%"+" 现在大小"+current+" 总大小"+total); ArrayList<ProgressViews> viewses = memoryCache.get(url); if(viewses ==null)return; JLogUtils.i("AlexGIF","该gif的请求数量是"+viewses.size()); for(ProgressViews vs : viewses){//遍历所有的进度条,修改同一个url请求的进度显示 ProgressWheel progressBar = vs.progressWheelWeakReference.get(); if(progressBar!=null){ progressBar.setProgress((float)progress/100f); if(total==-1)progressBar.setProgress(20);//如果获取不到大小,就让进度条一直转 } TextView tvProgress = vs.textViewWeakReference.get(); if(tvProgress != null)tvProgress.setText(progress+"%"); } //显示第一帧直到全部下载完之后开始动画 getFirstPicOfGIF(new File(cacheFile.getAbsolutePath()+".tmp"),vs.gifImageViewWeakReference.get()); } public void onSuccess(File file) { if(file==null)return; String path = file.getAbsolutePath(); if(path==null || path.length()<5)return; File downloadFile = new File(path); File renameFile = new File(path.substring(0,path.length()-4)); if(path.endsWith(".tmp"))downloadFile.renameTo(renameFile);//将.tmp后缀去掉 Log.i("AlexGIF","下载GIf成功,文件路径是"+path+" 重命名之后是"+renameFile.getAbsolutePath()); if(memoryCache==null)return; ArrayList<ProgressViews> viewArr = memoryCache.get(url); if(viewArr==null || viewArr.size()==0)return; for(ProgressViews ws:viewArr){//遍历所有的进度条和imageView,同时修改所有请求同一个url的进度 //显示imageView GifImageView gifImageView = ws.gifImageViewWeakReference.get(); if (gifImageView!=null)displayImage(renameFile,gifImageView,ws.displayWidth); //修改进度条 TextView tvProgress = ws.textViewWeakReference.get(); ProgressWheel progressBar = ws.progressWheelWeakReference.get(); if(progressBar!=null)progressBar.setVisibility(View.GONE); if(tvProgress!=null)tvProgress.setVisibility(View.GONE); } JLogUtils.i("AlexGIF",url+"的imageView已经全部加载完毕,共有"+viewArr.size()+"个"); memoryCache.remove(url);//这个url的全部关联imageView都已经显示完毕,清除缓存记录 } @Override public void onFailure(Throwable e) { Log.i("Alex","下载gif图片出现异常",e); TextView tvProgress = textViewWait.get(); ProgressWheel progressBar = progressBarWait.get(); if(progressBar!=null)progressBar.setVisibility(View.GONE); if(tvProgress!=null)tvProgress.setText("image download failed"); if(memoryCache!=null)memoryCache.remove(url);//下载失败移除所有的弱引用 } }); } /** * 通过本地文件显示GIF文件 * @param localFile 本地的文件指针 * @param gifImageView * displayWidth imageView控件的宽度,用于根据gif的实际高度重设控件的高度来保证完整显示,传0表示不缩放gif的大小,显示原始尺寸 */ public static boolean displayImage(File localFile,GifImageView gifImageView,int displayWidth){ if(localFile==null || gifImageView==null)return false; JLogUtils.i("AlexGIF","准备加载gif"+localFile.getAbsolutePath()+"显示宽度为"+displayWidth); GifDrawable gifFrom; try { gifFrom = new GifDrawable(localFile); int raw_height = gifFrom.getIntrinsicHeight(); int raw_width = gifFrom.getIntrinsicWidth(); JLogUtils.i("AlexGIF","图片原始height是"+raw_height+" 图片原始宽是:"+raw_width); if(gifImageView.getScaleType() != ImageView.ScaleType.CENTER_CROP && gifImageView.getScaleType()!= ImageView.ScaleType.FIT_XY){ //如果大小应该自适应的话进入该方法(也就是wrap content),不然高度不会自动变化 if(raw_width<1 || raw_height<1)return false; int imageViewWidth = displayWidth; if(imageViewWidth < 1)imageViewWidth = raw_width;//当传来的控件宽度不大对的时候,就显示gif的原始大小 int imageViewHeight = imageViewWidth*raw_height/raw_width; JLogUtils.i("AlexGIF","缩放完的gif是"+imageViewWidth+" X "+imageViewHeight); ViewGroup.LayoutParams params = gifImageView.getLayoutParams(); if(params!=null){ params.height = imageViewHeight; params.width = imageViewWidth; } }else { JLogUtils.i("AlexGIF","按照固定大小进行显示"); } gifImageView.setImageDrawable(gifFrom); return true; } catch (IOException e) { JLogUtils.i("AlexGIF","显示gif出现异常",e); return false; } } /** * 用于获取一个String的md5值 * @param str * @return */ public static String getMd5(String str) { if(str==null || str.length()<1)return "no_image.gif"; MessageDigest md5 = null; try { md5 = MessageDigest.getInstance("MD5"); byte[] bs = md5.digest(str.getBytes()); StringBuilder sb = new StringBuilder(40); for(byte x:bs) { if((x & 0xff)>>4 == 0) { sb.append("0").append(Integer.toHexString(x & 0xff)); } else { sb.append(Integer.toHexString(x & 0xff)); } } if(sb.length()<24)return sb.toString(); return sb.toString().substring(8,24);//为了提高磁盘的查找文件速度,让文件名为16位 } catch (NoSuchAlgorithmException e) { JLogUtils.i("Alex","MD5加密失败"); return "no_image.gif"; } } public static abstract class DownLoadTask{ abstract void onStart(); abstract void onLoading(long total, long current); abstract void onSuccess(File target); abstract void onFailure(Throwable e); boolean isCanceled; } /** * 开启下载任务到线程池里,防止多并发线程过多 * @param uri * @param targetFile * @param task */ public static void startDownLoad(final String uri, final File targetFile, final DownLoadTask task){ final Handler handler = new Handler(); new AlxMultiTask<Void,Void,Void>(){//开启一个多线程池,大小为cpu数量+1 @Override protected Void doInBackground(Void... params) { task.onStart(); downloadToStream(uri,targetFile,task,handler); return null; } }.executeDependSDK(); } /** * 通过httpconnection下载一个文件,使用普通的IO接口进行读写 * @param uri * @param targetFile * @param task * @return */ public static long downloadToStream(String uri, final File targetFile, final DownLoadTask task, Handler handler) { if (task == null || task.isCanceled) return -1; HttpURLConnection httpURLConnection = null; BufferedInputStream bis = null; OutputStream outputStream = null; long result = -1; long fileLen = 0; long currCount = 0; try { try { final URL url = new URL(uri); outputStream = new FileOutputStream(targetFile); httpURLConnection = (HttpURLConnection) url.openConnection(); httpURLConnection.setConnectTimeout(20000); httpURLConnection.setReadTimeout(10000); final int responseCode = httpURLConnection.getResponseCode(); if (HttpURLConnection.HTTP_OK == responseCode) { bis = new BufferedInputStream(httpURLConnection.getInputStream()); result = httpURLConnection.getExpiration(); result = result < System.currentTimeMillis() ? System.currentTimeMillis() + 40000 : result; fileLen = httpURLConnection.getContentLength();//这里通过http报文的header Content-Length来获取gif的总大小,需要服务器提前把header写好 } else { Log.e("Alex","downloadToStream -> responseCode ==> " + responseCode); return -1; } } catch (final Exception ex) { handler.post(new Runnable() { @Override public void run() { task.onFailure(ex); } }); return -1; } if (task.isCanceled) return -1; byte[] buffer = new byte[4096];//每4k更新进度一次 int len = 0; BufferedOutputStream out = new BufferedOutputStream(outputStream); while ((len = bis.read(buffer)) != -1) { out.write(buffer, 0, len); currCount += len; if (task.isCanceled) return -1; final long finalFileLen = fileLen; final long finalCurrCount = currCount; handler.post(new Runnable() { @Override public void run() { task.onLoading(finalFileLen, finalCurrCount); } }); } out.flush(); handler.post(new Runnable() { @Override public void run() { task.onSuccess(targetFile); } }); } catch (Throwable e) { result = -1; task.onFailure(e); } finally { if (bis != null) { try { bis.close(); } catch (final Throwable e) { handler.post(new Runnable() { @Override public void run() { task.onFailure(e); } }); } } } return result; } /** * 加载gif的第一帧图像,用于下载完成前占位 * @param gifFile * @param imageView */ public static void getFirstPicOfGIF(File gifFile,GifImageView imageView){ if(imageView==null)return; if(imageView.getTag(R.style.AppTheme) instanceof Integer)return;//之前已经显示过第一帧了,就不用再显示了 try { GifDrawable gifFromFile = new GifDrawable(gifFile); boolean canSeekForward = gifFromFile.canSeekForward(); if(!canSeekForward)return; JLogUtils.i("AlexGIF","是否能显示第一帧图片"+canSeekForward); //下面是一些其他有用的信息 // int frames = gifFromFile.getNumberOfFrames(); // JLogUtils.i("AlexGIF","已经下载完多少帧"+frames); // int bytecount = gifFromFile.getFrameByteCount(); // JLogUtils.i("AlexGIF","一帧至少多少字节"+bytecount); // long memoryCost = gifFromFile.getAllocationByteCount(); // JLogUtils.i("AlexGIF","内存开销是"+memoryCost); gifFromFile.seekToFrame(0); gifFromFile.pause();//静止在该帧 imageView.setImageDrawable(gifFromFile); imageView.setTag(R.style.AppTheme,1);//标记该imageView已经显示过第一帧了 } catch (IOException e) { JLogUtils.i("AlexGIF","获取gif信息出现异常",e); } } }
线程池:
import android.os.AsyncTask; import android.os.Build; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * Created by Alex on 2016/4/19. * 用于替换系统自带的AsynTask,使用自己的多线程池,执行一些比较复杂的工作,比如select photos,这里用的是缓存线程池,也可以用和cpu数相等的定长线程池以提高性能 */ public abstract class AlxMultiTask<Params, Progress, Result> extends AsyncTask<Params, Progress, Result> { private static ExecutorService photosThreadPool;//用于加载大图的线程池 private final int CPU_COUNT = Runtime.getRuntime().availableProcessors(); private final int CORE_POOL_SIZE = CPU_COUNT + 1; public void executeDependSDK(Params...params){ if(photosThreadPool==null)photosThreadPool = Executors.newFixedThreadPool(CORE_POOL_SIZE); if(Build.VERSION.SDK_INT<11) super.execute(params); else super.executeOnExecutor(photosThreadPool,params); } }
ViewPager Adpater的写法(截取)
public class PhotoImageViewPageAdapter extends PagerAdapter { @Override public Object instantiateItem(ViewGroup container, int position) { String imageUrl = "http://xxx.com/sdf/xxx.gif"; JLogUtils.i("AlexGIF","当前图片->"+imageUrl); if(imageUrl.endsWith(".gif")){//如果是gif动图 JLogUtils.i("AlexGIF","现在是gif大图"); View rl_gif = LayoutInflater.from(activity).inflate(R.layout.layout_photo_loading_gif_imageview, null);//这种方式容易导致内存泄漏 GifImageView gifImageView = (GifImageView) rl_gif.findViewById(R.id.gif_photo_view); ProgressWheel progressWheel = (ProgressWheel) rl_gif.findViewById(R.id.progress_wheel); CustomTextView tv_progress = (CustomTextView) rl_gif.findViewById(R.id.tv_progress); AlxGifHelper.displayImage(imageUrl,gifImageView,progressWheel,tv_progress,0);//最后一个参数传0表示不缩放gif的大小,显示原始尺寸 try { container.addView(rl_gif);//这里要注意由于container是一个复用的控件,所以频繁的addView会导致多张相同的图片重叠,必须予以处置 }catch (Exception e){ JLogUtils.i("AlexGIF","父控件重复!!!!,这里出现异常很正常",e); } return rl_gif;//这里有个大坑,千万不能return container,但是在return之前必须addView } } return container; } }
布局文件
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:wheel="http://schemas.android.com/apk/res-auto" android:id="@+id/rl_gif" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <pl.droidsonroids.gif.GifImageView android:id="@+id/gif_photo_view" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_centerVertical="true" android:layout_centerHorizontal="true" /> <TextView android:id="@+id/tv_progress" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:layout_centerHorizontal="true" android:layout_centerVertical="true" android:textColor="@color/white" android:text="2%" /> <com.pnikosis.materialishprogress.ProgressWheel android:id="@+id/progress_wheel" android:layout_width="60dp" android:layout_height="60dp" android:layout_centerHorizontal="true" android:layout_centerVertical="true" android:layout_gravity="center" wheel:matProg_barColor="#5097DA" wheel:matProg_progressIndeterminate="true" /> </RelativeLayout>
中间的ProgressBar使用了一个第三方库
dependencies { compile 'com.pnikosis:materialish-progress:1.7' }