前言
我目前工作的项目使用的是Android 的第三方图片加载库Picasso,最近有需求要为图片添加下载进度条,并准确提示下载进度。然而Picasso原生并不支持下载进度的回调(Fresco原生支持),但是Picasso好在灵活性还可以,能够自由的指定Downloader,于是我在原来使用Okhttp3 Http请求库的基础上添加了下载进度的提示,和网上其他的Picasso添加进度的方案不同,网上的都是生成多个Picasso实例和Downloader实例,导致Lru缓存失效和内存溢出的问题,我在实现中使用了单例Picasoo和Downloader,通过给OkhttpClient类添加拦截器的方式实现下载进度的回调,通过url进行分发,实现在ListView等控件中多图、省内存、防OOM的加载。
注:要实现进度的获取需要图片服务器在图片的Http响应头添加”Content-Length“
实现效果
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'
}
注:我这里这个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;
}
};
}
}
}
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);
}
}
OkHttpClient client = AlxOk3ClientManager.getDefaultClient(context);
AlxPicassoOk3Downloader downloader = new AlxPicassoOk3Downloader(client,listener);
if(picasso == null) picasso = new Picasso.Builder(context).downloader(downloader).build();
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上一直更新这个项目