为Android图片加载添加百分比进度条(Picasso+Okhttp3)

  前言

我目前工作的项目使用的是Android 的第三方图片加载库Picasso,最近有需求要为图片添加下载进度条,并准确提示下载进度。然而Picasso原生并不支持下载进度的回调(Fresco原生支持),但是Picasso好在灵活性还可以,能够自由的指定Downloader,于是我在原来使用Okhttp3 Http请求库的基础上添加了下载进度的提示,和网上其他的Picasso添加进度的方案不同,网上的都是生成多个Picasso实例和Downloader实例,导致Lru缓存失效和内存溢出的问题,我在实现中使用了单例Picasoo和Downloader,通过给OkhttpClient类添加拦截器的方式实现下载进度的回调,通过url进行分发,实现在ListView等控件中多图、省内存、防OOM的加载。

注:要实现进度的获取需要图片服务器在图片的Http响应头添加”Content-Length“

  实现效果

为Android图片加载添加百分比进度条(Picasso+Okhttp3)_第1张图片     

Github项目地址:https://github.com/AlexZhuo/AlxPicassoProgress


  实现思路

1、关于进度获取:

这里获取进度百分比的原理是通过请求图片的URL,获得Http响应报文后,从Header中获取到Content-Length获得文件的总大小,然后通过Okhttp3 的client获得当前读取的字节数,通过当前读取字节数除以Content-Length获得百分比。

2、关于Okhttp3和Picasoo的耦合:

Picasso默认不会自动使用Picasso,我通过重写了Picasso的Downloader添加了Picasso对Okhttp3的支持,具体实现可以看我之前的一篇博客:使用okhttp3做Android图片框架Picasso的下载器和缓存器

在那篇博客里面,我已经添加了一个拦截器,用来在手机Flash存储上存储下载好的图片,并设置过期时间为7天,今天这个issue我又添加了一个拦截器,用于获取当前文件的Content-Length和下载字节数,然后通过一个ProgressListener接口实现下载进度的回调,回调后通过url判断是哪个图片,然后控制圆形进度条控件和TextView显示进度。

3、关于圆形进度条控件:

这里我使用的是Github上的一个项目,github上关于进度条的自定义控件有很多,可以去找找有没有你爱用的,然后修改我这个Demo的控制进度条的相关代码就好

4、关于Picasso和Okhttp3配合的bug(极为关键):

Picasso在网络请求上处理的并不好,比如一个子元素很多的listView,如果我在上半部分的图片没有加载完,我就使劲往下滑,然后再滑回最顶部,然后再迅速的滑到最底部,那么Picasso对一张照片就会调用好多线程去同时下载,意思就是Picasso已经抛弃的下载任务,Okhttp3还会继续下载,导致Okhttp3下载好了某张照片也是没有用的,因为Picasso抛弃之后,又开启了一次新下载,然后Okhttp3又要再下载一遍,这种情况往往要花两倍的时间和流量去下载一个照片,在我的Demo中,出现这种情况会输出Log,并且进度显示“99.11%”,由于是进行一次完整的第二次下载,所以“99.11%”这个进度可能保持的时间比较长才显示出图片,但是你如果上下划一划,重走getConvertView()方法,Picasoo会让Okhttp3读取下载成功的Flash缓存,这时就可以迅速显示

另外Picasso一次下载最多开4个线程,也就是说最多同时下载4个图片,如果有4张图片正在下载没有完成,那么你滑到listView的底部,底下的那些照片会等待那4张下载完后才开始下载,效率比较低。

5、关于ListView,RecyclerView,GridView中控件复用:众所周知ListView中相同item的控件是复用的,如果处理不好,那么进度就会到处乱闪,一会显示A图的进度,一会显示B图的进度,我的处理方式是将url和控件通过setTag进行绑定,在更新进度之前,首先检查要更新的url是否同View.getTag(id)一致,如果不一致则不去更新,每次执行ListView adapter的getConvertView()方法时给进度条setTag,这样在复用控件的环境中,就不会出现错乱的问题


  实现步骤

本方案为了能看的清晰,特地去昵图网找了几张大图当demo,但如果你的网速太快,那么进度条可能转瞬即逝

首先需要在项目中引用Picasso,Okhttp3,和一个圆形进度条的控件materialish-progress,如下

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    testCompile 'junit:junit:4.12'
    
    compile 'com.squareup.picasso:picasso:2.5.2'
    compile 'com.squareup.okhttp3:okhttp:3.1.1'
    compile 'com.pnikosis:materialish-progress:1.7'

}

为了让Picasso支持Okhttp3,需要自定义一个Okhttp3的downloader,并在这个Downloader中添加获取下载字节数的拦截器,代码如下:

注:我这里这个Downloader还添加了自动重定向的功能,就是如果你访问一个Url,但是并没有返回一个图片的字节流,而是一个字符串,那么Picasso会自动的去根据这个返回的字符串再去请求该字符串对应的地址,直到获得图片为止。并且根据这个Downloader打的log可以判断图片是从Okhttp3的Flash缓存中获取的还是联网下载的。

package vc.zz.qduxsh.picassoprogress;

import android.net.Uri;
import android.support.annotation.IntRange;
import android.support.annotation.WorkerThread;
import android.util.Log;

import com.squareup.picasso.Downloader;
import com.squareup.picasso.NetworkPolicy;

import java.io.IOException;

import okhttp3.Cache;
import okhttp3.CacheControl;
import okhttp3.Call;
import okhttp3.Interceptor;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.BufferedSource;
import okio.ForwardingSource;
import okio.Okio;
import okio.Source;

/**
 * Created by Alex on 2016/9/20.
 */

public final class AlxPicassoOk3Downloader implements Downloader {

    private final Call.Factory client;
    private final Cache cache;


    public AlxPicassoOk3Downloader(OkHttpClient client,final ProgressListener listener) {
        this.client = client.newBuilder().addNetworkInterceptor(new Interceptor() {
            @Override public okhttp3.Response intercept(Chain chain) throws IOException {
                okhttp3.Response originalResponse = chain.proceed(chain.request());
                return originalResponse.newBuilder().body(
                        new AlxPicassoOk3Downloader.ProgressResponseBody(originalResponse.body(),originalResponse.request().url().url().toString(), listener))
                        .build();
            }
        }).build();
        this.cache = ((OkHttpClient)this.client).cache();
    }

    public AlxPicassoOk3Downloader(Call.Factory client) {
        this.client = client;
        this.cache = null;
    }
    public AlxPicassoOk3Downloader(OkHttpClient client) {
        this.client = client;
        this.cache = client.cache();
    }

    /**
     * 如果根据url请求到的不是图片而是字符串的话,支持自动重定向
     * @param uri
     * @param networkPolicy
     * @return
     * @throws IOException
     */
    @Override public Response load(Uri uri, int networkPolicy) throws IOException {
        Log.i("AlexImage","准备从php拿url去cdn要图片的uri是->"+uri+"    缓存策略是"+networkPolicy);
        CacheControl cacheControl = null;
        if (networkPolicy != 0) {
            if (NetworkPolicy.isOfflineOnly(networkPolicy)) {
                cacheControl = CacheControl.FORCE_CACHE;
            } else {
                CacheControl.Builder builder = new CacheControl.Builder();
                if (!NetworkPolicy.shouldReadFromDiskCache(networkPolicy)) {
                    builder.noCache();
                }
                if (!NetworkPolicy.shouldWriteToDiskCache(networkPolicy)) {
                    builder.noStore();
                }
                cacheControl = builder.build();
            }
        }

        Request.Builder php_builder = new Request.Builder().url(uri.toString());
        if (cacheControl != null) {
            php_builder.cacheControl(cacheControl);
        }
        long startTime = System.currentTimeMillis();
        okhttp3.Response php_response = client.newCall(php_builder.build()).execute();
        int php_responseCode = php_response.code();
        Log.i("AlexImage","php响应码是"+php_responseCode);
        if (php_responseCode >= 300) {
            php_response.body().close();
            throw new ResponseException(php_responseCode + " " + php_response.message(), networkPolicy, php_responseCode);
        }
        boolean fromPhpCache = php_response.cacheResponse() != null;
        Log.i("AlexImage","php的响应是不是从缓存拿的呀?"+fromPhpCache);
        Log.i("AlexImage","全部的header是"+php_response.headers());
        if("text/html".equals(php_response.header("Content-Type")) || "text/plain".equals(php_response.header("Content-Type"))){//如果php发来的是cdn的图片url
            Log.i("AlexImage","现在是从php取得的url字符串而不是jpg");
            String cdnUrl = php_response.body().string();
            Log.i("AlexImage","php服务器响应时间"+(System.currentTimeMillis() - startTime));
            Log.i("AlexImage","CDN的imageUrl是->"+cdnUrl);
            Request.Builder cdn_builder = new Request.Builder().url(cdnUrl);
            if (cacheControl != null) {
                cdn_builder.cacheControl(cacheControl);
            }
            long cdnStartTime = System.currentTimeMillis();
            okhttp3.Response cdn_response = client.newCall(cdn_builder.build()).execute();
            int cdn_responseCode = cdn_response.code();
            Log.i("AlexImage","cdn的响应码是"+cdn_responseCode);
            if (cdn_responseCode >= 300) {
                cdn_response.body().close();
                throw new ResponseException(cdn_responseCode + " " + cdn_response.message(), networkPolicy,
                        cdn_responseCode);
            }
            Log.i("AlexImage","cdn响应时间"+(System.currentTimeMillis() - cdnStartTime));
            boolean fromCache = cdn_response.cacheResponse() != null;
            ResponseBody cdn_responseBody = cdn_response.body();
            Log.i("AlexImage","cdn的图片是不是从缓存拿的呀?fromCache = "+fromCache);
            return new Response(cdn_responseBody.byteStream(), fromCache, cdn_responseBody.contentLength());
        }else {//如果php发来的不是图片的URL,那就直接用php发来的图片
            Log.i("AlexImage","准备直接用PHP的图片!!!");
            boolean fromCache = php_response.cacheResponse() != null;
            ResponseBody responseBody = php_response.body();
            return new Response(responseBody.byteStream(), fromCache, responseBody.contentLength());
        }
    }

    @Override public void shutdown() {
        if (cache != null) {
            try {
                cache.close();
            } catch (IOException ignored) {
            }
        }
    }
    public interface ProgressListener {
        @WorkerThread
        void update(@IntRange(from = 0, to = 100) int percent,String url);
    }
    public static class ProgressResponseBody extends ResponseBody {

        private final ResponseBody responseBody;
        private final ProgressListener progressListener;
        private BufferedSource bufferedSource;
        private String url;

        public ProgressResponseBody(ResponseBody responseBody,String url, ProgressListener progressListener) {
            this.responseBody = responseBody;
            this.progressListener = progressListener;
            this.url = url;
            Log.i("Alex","当前图片是::"+url);
        }

        @Override
        public MediaType contentType() {
            Log.i("Alex","contentType是"+responseBody.contentType());
            return responseBody.contentType();
        }

        @Override
        public long contentLength() {
            Log.i("Alex","contentLength"+responseBody.contentLength());
            return responseBody.contentLength();
        }

        @Override
        public BufferedSource source() {
            if (bufferedSource == null) {
                bufferedSource = Okio.buffer(source(responseBody.source()));
            }
            return bufferedSource;
        }

        private Source source(Source source) {

            return new ForwardingSource(source) {
                long totalBytesRead = 0L;

                @Override
                public long read(Buffer sink, long byteCount) throws IOException {
                    long bytesRead = super.read(sink, byteCount);
                    // read() returns the number of bytes read, or -1 if this source is exhausted.
                    totalBytesRead += bytesRead != -1 ? bytesRead : 0;
                    if (progressListener != null) {
                        progressListener.update(
                                ((int) ((100 * totalBytesRead) / responseBody.contentLength())),url);
                    }
                    return bytesRead;
                }
            };
        }
    }
}

上面的Downloader需要传入一个OkhttpClient作为参数,这里我使用的是自定义OkhttpClient,添加了手机Flash缓存下载好的图片7天的功能,并规定了缓存文件的路径和缓存大小,方便管理和删除,由于Picasso本身不带缓存,所以缓存的工作就交给了Okhttp3,代码如下

package vc.zz.qduxsh.picassoprogress;

import android.content.Context;
import android.os.StatFs;

import java.io.File;
import java.io.IOException;

import okhttp3.Interceptor;
import okhttp3.OkHttpClient;

/**
 * Created by Administrator on 2016/9/20.
 */
public class AlxOk3ClientManager {
    private static final String PICASSO_CACHE = "picasso-cache";//缓存图片的存放文件夹名
    private static final int MIN_DISK_CACHE_SIZE = 5 * 1024 * 1024; // 5MB
    private static final int MAX_DISK_CACHE_SIZE = 50 * 1024 * 1024; // 50MB最大SD卡占用空间
    public static File createDefaultCacheDir(Context context) {
        File cache = new File(context.getApplicationContext().getCacheDir(), PICASSO_CACHE);
        if (!cache.exists()) {
            //noinspection ResultOfMethodCallIgnored
            cache.mkdirs();
        }
        return cache;
    }

    private static long calculateDiskCacheSize(File dir) {
        long size = MIN_DISK_CACHE_SIZE;

        try {
            StatFs statFs = new StatFs(dir.getAbsolutePath());
            long available = ((long) statFs.getBlockCount()) * statFs.getBlockSize();
            // Target 2% of the total space.
            size = available / 50;
        } catch (IllegalArgumentException ignored) {
        }

        // Bound inside min/max size for disk cache.
        return Math.max(Math.min(size, MAX_DISK_CACHE_SIZE), MIN_DISK_CACHE_SIZE);
    }

    private static OkHttpClient defaultOkHttpClient(File cacheDir, long maxSize) {
        Interceptor REWRITE_CACHE_CONTROL_INTERCEPTOR = new Interceptor() {
            @Override
            public okhttp3.Response intercept(Chain chain) throws IOException {
                okhttp3.Response originalResponse = chain.proceed(chain.request());
                return originalResponse.newBuilder()
                        .removeHeader("Pragma")
                        .header("Cache-Control", String.format("max-age=%d", 604800))//本地sd卡缓存7天
                        .build();
            }
        };

        return new OkHttpClient.Builder()
                .cache(new okhttp3.Cache(cacheDir, maxSize))
                .addNetworkInterceptor(REWRITE_CACHE_CONTROL_INTERCEPTOR)
                .build();
    }


    /**
     * Create new downloader that uses OkHttp. This will install an image cache into your application
     * cache directory.
     */
    public static OkHttpClient getDefaultClient(Context context) {
        return getDefaultClient(createDefaultCacheDir(context));
    }


    /**
     * Create new downloader that uses OkHttp. This will install an image cache into the specified
     * directory.
     *
     * @param cacheDir The directory in which the cache should be stored
     */
    public static OkHttpClient getDefaultClient(File cacheDir) {
        return getDefaultClient(cacheDir, calculateDiskCacheSize(cacheDir));
    }

    /**
     * Create new downloader that uses OkHttp. This will install an image cache into your application
     * cache directory.
     *
     * @param maxSize The size limit for the cache.
     */
    public static OkHttpClient getDefaultClient(final Context context, final long maxSize) {
        return getDefaultClient(createDefaultCacheDir(context), maxSize);
    }

    /**
     * Create new downloader that uses OkHttp. This will install an image cache into the specified
     * directory.
     *
     * @param cacheDir The directory in which the cache should be stored
     * @param maxSize The size limit for the cache.
     */
    public static OkHttpClient getDefaultClient(File cacheDir, long maxSize) {
        return defaultOkHttpClient(cacheDir, maxSize);
    }

}

有了这两个以后,就可以让Picasso和Okhttp耦合起来,代码如下

OkHttpClient client = AlxOk3ClientManager.getDefaultClient(context);
        AlxPicassoOk3Downloader downloader = new AlxPicassoOk3Downloader(client,listener);
        if(picasso == null) picasso = new Picasso.Builder(context).downloader(downloader).build();

然后我使用弱引用维护一些ListView中进度条控件和TextView防止OOM,通过url获得相应的控件,并使用view.setTag()方法将该控件所指示的图片的url与控件绑定起来,防止由于ListView控件复用导致的进度错乱,大体代码如下:

private static final WeakHashMap progressWheelHashMap = new WeakHashMap<>();//用于管理进度条的map,使用弱引用可以防止OOM
private static final WeakHashMap textViewHashMap = new WeakHashMap<>();//用于管理进度条的map,使用弱引用可以防止OOM
private static final ConcurrentHashMap progressHashMap = new ConcurrentHashMap<>();//用于记录某个url的下载进度
 public void update(@IntRange(from = 0, to = 100) final int percent, final String url) {
                if(percent > 100 || percent <1)return;
                final ProgressWheel progressWheel = progressWheelHashMap.get(url);
                final TextView textView = textViewHashMap.get(url);
                if(textView == null || progressWheel == null)return;
                final int oldPregress = progressHashMap.get(url);
                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        Log.i("Alex","当前下载的百分比是=="+percent+"   url::"+url);
                        //防止listView的控件复用
                        if(!url.equals(progressWheel.getTag(R.id.progress_wheel)) ||! url.equals(textView.getTag(R.id.tv1))){
                            Log.i("Alex","两张不同图片的进度冲突了");
                            return;
                        }
                        //如果百分比突然不正常了,说明重新下载了一遍,这时候之前那一遍就应该干掉
                        if(oldPregress > percent){//正常情况下,每次的percent都应该比上次大
                            Log.i("Alex","注意::图片被下载了两次!!!!!!  "+url);
                            if(oldPregress == 100) {
                                Log.i("Alex","由于上下滑动太快,导致Picasoo重复下载!!!"+" 本次进度"+percent);
                                textView.setVisibility(View.GONE);
                                progressWheel.setVisibility(View.GONE);
                                if(progressWheel.getVisibility() == View.GONE)progressWheel.setVisibility(View.VISIBLE);
                                if(textView.getVisibility() == View.GONE)textView.setVisibility(View.VISIBLE);
                                textView.setText("99.11%");
                                progressWheel.setProgress(0.99f);//设置进度条的进度
                            }
                            return;
                        }
                        //两个线程同时下载,其中有一个没用的旧线程可能已经下载完,但是已经被Picasso抛弃
                        if(oldPregress == 100){
                            Log.i("Alex","奇怪,以前不是成功了么?");
                        }
                        if(progressWheel.getVisibility() == View.GONE)progressWheel.setVisibility(View.VISIBLE);
                        if(textView.getVisibility() == View.GONE)textView.setVisibility(View.VISIBLE);
                        progressHashMap.put(url,percent);
                        if(percent == 100){
//                            textView.setVisibility(View.GONE);
//                            progressWheel.setVisibility(View.GONE);
                            progressHashMap.put(url,100);
                            textView.setText("99.5%");//当前是即将成功
                            progressWheel.setProgress(0.99f);//设置进度条的进度
                            return;
                        }
                        textView.setText(percent+"%");
                        progressWheel.setProgress(percent/100f);//设置进度条的进度
                    }
                });
            }
最后调用Picasso加载图片只需一个函数,并把相应的URL,ImageView,显示进度的TextView和圆形进度条控件传入即可

AlxPicassoUtils.displayImageProgress(url,imageView,progressWheel,textView);

最后再说一句,如果进度长时间保持在“99.11%上”,说明Picasoo对同一个资源下载了两次以上,本例中已经做了很多处理

我会在github上一直更新这个项目




你可能感兴趣的:(为Android图片加载添加百分比进度条(Picasso+Okhttp3))