手动缓存Retrofit+OkHttp响应体,不再局限于Get请求缓存

Posted iGoach

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了手动缓存Retrofit+OkHttp响应体,不再局限于Get请求缓存相关的知识,希望对你有一定的参考价值。

转载请标明出处:
http://blog.csdn.net/iamzgx/article/details/51764848
本文出自:【iGoach的博客】

概括

这篇博客是接着上一篇博客学会Retrofit+OkHttp+RxAndroid三剑客的使用,让自己紧跟Android潮流的步伐,没看过的,建议看完上一篇再来看这篇。在上一篇博客中仅仅是简单的讲解了OkHttp的缓存问题,主要是通过http协议里面的control-cache控制缓存,而且是仅仅只能是Get请求才能缓存,如果Post请求OkHttp会让response返回null,同时报504错误,也就是没缓存。okhttp为什么要这样做呢?通过查看缓存的文件,我们可以发现,OkHttp缓存的是整个http请求的信息,所以这就和http协议有关系了。在RESTful API里面,我们把Get请求理解为从服务端查询数据,Post请求理解为更新服务端数据,而http协议里面缓存通常只适用于idempotent request,也就是Get请求,为什么只适应Get请求?我们都知道Get请求url结合提交参数是唯一标示,而Post请求的参数是在http的body体里面,是可变的,无法成为唯一的标示。但是,我们在项目中基本上每一个接口都要提交基本参数,一般用的都是Post请求。Get请求还不太安全,请求的路径大小还有限制。既然OkHttp有限制。那么我们可以自己手动缓存。

android的缓存处理

既然要手动缓存,那么我们就要来看看android里面手动缓存有哪些。主要有两种方式,一种是sqlite缓存,一种是文件缓存。

  • sqlite缓存
    目前有很多第三方sqlite框架,比如可以结合GreenDao来做缓存,一个缓存对应一个表。把url路经,下载时间,过期时间等信息都存放到数据库。然后把url做为请求的唯一标示,在有网的情况下,判断当前请求url缓存是否存在,存在就要移除数据库里面的缓存,然后缓存新的缓存,在没有网络的情况下,判断缓存是否过期,然后进行数据库操作。从这里我们可以看出,数据库操作还是比较频繁的,一不留神,就会出现应用性能问题,ANR问题,指针问题。而且android数据库是放在/data/data/<包名>/databases/目录下,它会占用应用内存的,一但缓存很多的话,就要及时去清理缓存,很麻烦。

  • 文件缓存
    为什么说文件缓存更好呢?如果SD存在的话,我们可以把缓存放在SD的/data/data/<包名>/cache目录下,不存在SD的话,再放在/data/data/<包名>下面。即使内存再多,也不会影响应用的内置应用空间。文件缓存一般都会通过DiskLruCache实现,DiskLruCache是硬盘缓存,即使应用进程结束了,缓存还是存在的。当应用卸载时,改目录的数据也会清除掉,不会留下残余数据。DiskLruCache缓存,没有什么过期时间之说,只要它存在文件里面,我们就可以随时去读取它。下面我们就用DiskLruCache对Retrofit+OkHttp的响应体进行缓存。这里我们只缓存json数据。

DiskLruCache的使用方法

获取DiskLruCache对象

public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)

不能直接通过new的方法创建,要通过调用DiskLruCache.open()这个方法获取,有四个参数,File指的是缓存的存储路径,一般优先存储于SD卡的 /sdcard/Android/data/<包名>/cache 路径下,如果SD卡不存在,再存在/data/data/<包名>/cache 这个路径下,判断代码如下

   private File getDiskCacheDir(Context context, String uniqueName)
    {
        String cachePath;

        if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())
                || !Environment.isExternalStorageRemovable())
        {
            //如果SD卡存在通过getExternalCacheDir()获取路径,
            cachePath = context.getExternalCacheDir().getPath();
        } else
        { 
            //如果SD卡不存在通过getCacheDir()获取路径,
            cachePath = context.getCacheDir().getPath();
        }
        //放在路径 /.../data/<application package>/cache/uniqueName
        return new File(cachePath + File.separator + uniqueName);
    }

appVersion指的是版本号,可以指应用的版本号,valueCount指的就是一个key对应多少个文件,一般我们指定1个文件,一对一使得后面更好获取。maxSize指的是缓存的最大大小,一般传入5M或者10M就够了。

写入缓存

首先我们先获取一个DiskLruCache.Editor对象,代码如下

    public DiskLruCache.Editor editor(String key)
    {
        try
        {
            key = Utils.hashKeyForDisk(key);
            //wirte DIRTY
            DiskLruCache.Editor edit = mDiskLruCache.edit(key);
            //edit maybe null :the entry is editing
            if (edit == null)
            {
                Log.w(TAG, "the entry spcified key:" + key + " is editing by other . ");
            }
            return edit;
        } catch (IOException e)
        {
            e.printStackTrace();
        }

        return null;
    }

首先进行的是Utils.hashKeyForDisk(key),也就是通过MD5生成唯一的请求标示,这样就可以通过key来获取DiskLruCache.Editor实例。获取到实例后就可以获取到OutputStream,然后通过BufferedWriter写入,如下代码

    public void put(String key, String value)
    {
        DiskLruCache.Editor edit = null;
        BufferedWriter bw = null;
        try
        {
            edit = editor(key);
            if (edit == null) return;
            OutputStream os = edit.newOutputStream(0);
            bw = new BufferedWriter(new OutputStreamWriter(os));
            bw.write(value);
            edit.commit();//write CLEAN
        } catch (IOException e)
        {
            e.printStackTrace();
            try
            {
                //s
                edit.abort();//write REMOVE
            } catch (IOException e1)
            {
                e1.printStackTrace();
            }
        } finally
        {
            try
            {
                if (bw != null)
                    bw.close();
            } catch (IOException e)
            {
                e.printStackTrace();
            }
        }
    }

读取缓存

首先是通过key获取DiskLruCache.Snapshot实例,然后得到InputStream,如下代码

    public InputStream get(String key)
    {
        try
        {
            DiskLruCache.Snapshot snapshot = mDiskLruCache.get(Utils.hashKeyForDisk(key));
            if (snapshot == null) //not find entry , or entry.readable = false
            {
                Log.e(TAG, "not find entry , or entry.readable = false");
                return null;
            }
            //write READ
            return snapshot.getInputStream(0);

        } catch (IOException e)
        {
            e.printStackTrace();
            return null;
        }

    }

然后就是InputStreamReader读取,如下代码

    public String getAsString(String key) {
        InputStream inputStream = null;
        inputStream = get(key);
        if (inputStream == null) return null;
        String str = null;
        try {
            str = Util.readFully(new InputStreamReader(inputStream, Util.UTF_8));
        } catch (IOException e) {
            e.printStackTrace();
            try {
                inputStream.close();
            } catch (IOException e1) {
                e1.printStackTrace();
            }
        }
        return str;
    }

    static String readFully(Reader reader) throws IOException
    {
        try
        {
            StringWriter writer = new StringWriter();
            char[] buffer = new char[1024];
            int count;
            while ((count = reader.read(buffer)) != -1)
            {
                writer.write(buffer, 0, count);
            }
            return writer.toString();
        } finally
        {
            reader.close();
        }
    }

然后就是删除操作

    public boolean remove(String key)
    {
        try
        {
            key = Utils.hashKeyForDisk(key);
            return mDiskLruCache.remove(key);
        } catch (IOException e)
        {
            e.printStackTrace();
        }
        return false;
    }

直接remove掉就ok了。

DiskLruCache的封装

从Github里面搜索DiskLruCache,可以看到鸿洋大神的base-diskcache框架,它主要是把diskcache封装成和AsimpleCache框架一样,挺好用的。
使用方法如下(来源于base-diskcache框架

存

put(String key, Bitmap bitmap)

put(String key, byte[] value)

put(String key, String value)

put(String key, JSONObject jsonObject)

put(String key, JSONArray jsonArray)

put(String key, Serializable value)

put(String key, Drawable value)

editor(String key).newOutputStream(0);//原有的方式String getAsString(String key);

JSONObject getAsJson(String key)

JSONArray getAsJSONArray(String key)

<T> T getAsSerializable(String key)

Bitmap getAsBitmap(String key)

byte[] getAsBytes(String key)

Drawable getAsDrawable(String key)

InputStream get(String key);//原有的用法

这里我只是保存响应的json,只用到

put(String key, String value)

String getAsString(String key);

两个方法,至于key使用请求参数生成的MD5做为唯一的标示。

下面就使用这个DiskLruCache封装进行手动缓存,DiskLruCache的源码和封装代码可以去鸿洋的github上下载

HRetrofitNetHelper代码的修改

基于上一篇博客的HRetrofitNetHelper对象。进行代码修改,修改点如下

  • 去除OkHttp的cache缓存配置
  • 去除mUrlInterceptor的拦截器
  • 改在call的onresponse里面进行操作
  • enqueueCall方法配置成链式编程配置

然后再贴上全部的代码,注意几个修改点就好了。

public class HRetrofitNetHelper implements HttpLoggingInterceptor.Logger {
    public static HRetrofitNetHelper mInstance;
    public Retrofit mRetrofit;
    public OkHttpClient mOkHttpClient;
    public HttpLoggingInterceptor mHttpLogInterceptor;
    private BasicParamsInterceptor mBaseParamsInterceptor;
    private  Context mContext;
    public  Gson mGson;
    //DiskLruCache封装的帮助类,
    private  DiskLruCacheHelper diskLruCacheHelper;
    public static final String BASE_URL = "http://192.168.1.102:8080/GoachWeb/";
    private Action1<String> onNextAction;
    private HRetrofitNetHelper(Context context){
        this.mContext = context ;
        createSubscriberByAction();
        mGson = new GsonBuilder()
                .setDateFormat("yyyy-MM-dd HH:mm:ss")
                .create();
        mHttpLogInterceptor = new HttpLoggingInterceptor(this);
        mHttpLogInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
        Map<String,String> tempParams = getBaseParams();
        mBaseParamsInterceptor = new BasicParamsInterceptor.Builder()
                .addParamsMap(tempParams)
                .build();
        try {
        //创建DiskLruCacheHelper 对象
            diskLruCacheHelper = new DiskLruCacheHelper(mContext);
        } catch (IOException e) {
            e.printStackTrace();
        }
        //这里去除了缓存配置和mUrlInterceptor的配置
        mOkHttpClient = new OkHttpClient.Builder()
                .connectTimeout(12, TimeUnit.SECONDS)
                .writeTimeout(20, TimeUnit.SECONDS)
                .readTimeout(20, TimeUnit.SECONDS)
                .retryOnConnectionFailure(true)
                .addInterceptor(mHttpLogInterceptor)
                .addInterceptor(mBaseParamsInterceptor)
                .build();
        mRetrofit = new Retrofit.Builder()
                .baseUrl(BASE_URL)
                .addConverterFactory(GsonConverterFactory.create(mGson))
                .client(mOkHttpClient)
                .build();
    }
    public static HRetrofitNetHelper getInstance(Context context){
        if(mInstance==null){
            synchronized (HRetrofitNetHelper.class){
                if(mInstance==null){
                    mInstance =  new HRetrofitNetHelper(context);
                }
            }
        }
        return mInstance;
    }
    public <T> T getAPIService(Class<T> service) {
        return mRetrofit.create(service);
    }
    /*这里改成链式编程,默认是不缓存。在不缓存的情况下,只需配置Call<BaseResp<D>>实例,也就是调用上面getAPIService方法获取的实例。然后就是retrofitCallBack回调接口,如果需要缓存的情况,那么就要再配置isCache为true,然后配置Type(主要是Gson解析泛型会报错Java.lang.ClassCastException: com.google.gson.internal.LinkedTreeMap cannot be cast to,所以需再传递这个参数进行解析),最后调用start方法进行请求*/
    public static final class enqueueCall{
        boolean isCache;
        Type clazz;
        Call call;
        RetrofitCallBack retrofitCallBack;
        HRetrofitNetHelper mRetrofitNetHelper;
        private Context mContext;
        public  Gson mGson;
        private DiskLruCacheHelper diskLruCacheHelper;
        public enqueueCall(HRetrofitNetHelper retrofitNetHelper){
            isCache = false;
            this.mRetrofitNetHelper = retrofitNetHelper;
            this.mContext = retrofitNetHelper.mContext;
            this.mGson = retrofitNetHelper.mGson;
            this.diskLruCacheHelper = retrofitNetHelper.diskLruCacheHelper;
        }
        public <D> enqueueCall call(Call<BaseResp<D>> call){
            this.call = call ;
            return this;
        }
        public enqueueCall clazz(Type clazz){
            this.clazz = clazz ;
            return this;
        }
        public <D> enqueueCall retrofitCallBack(RetrofitCallBack<D> retrofitCallBack){
            this.retrofitCallBack = retrofitCallBack ;
            return this;
        }
        public enqueueCall isCache(boolean isCache){
            this.isCache = isCache ;
            return this;
        }
        public <D> enqueueCall start(){
            call.enqueue(new Callback<BaseResp<D>>() {
                @Override
                public void onResponse(Call<BaseResp<D>> call, Response<BaseResp<D>> response) {
                    //获取请求Request 
                    Request request = call.request();
                    //获取请求的url
                    String requestUrl = call.request().url().toString();
                    //去获取返回数据
                    BaseResp<D> resp = response.body() ;
                    //去获取RequestBody
                    RequestBody requestBody = request.body();
                    //缓存格式为utf-8
                    Charset charset = Charset.forName("UTF-8");
                    //去获取要保存的key
                     String key="";
                     //如果是Post请求,要通过Buffer去读取body体里面的参数
                    if(method.equals("POST")){
                        MediaType contentType = requestBody.contentType();
                        if (contentType != null) {
                            charset = contentType.charset(Charset.forName("UTF-8"));
                        }
                        Buffer buffer = new Buffer();
                        try {
                            requestBody.writeTo(buffer);
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                        key = buffer.readString(charset);

                        buffer.close();
                    }else{
                    //如果不是Post请求,比如Get请求,那么久通过url做为唯一标识
                        key = requestUrl;
                    }
                    Log.d("zgx","response==========key"+key);
                    //处理特殊接口,如果是登录接口进行弹框提示
                    if(!TextUtils.isEmpty(requestUrl)){
                        if(requestUrl.contains("LoginDataServlet")) {
                            if (Looper.myLooper() == null) {
                                Looper.prepare();
                            }
                            mRetrofitNetHelper.createObservable("现在请求的是登录接口");
                        }
                    }
                    //分为有网和没网的情况下
                    //如果有网
                    if(NetUtil.checkNetwork(mContext)!=NetUtil.NO_NETWORK){
                        //如果返回数据为null
                        if(resp==null){
                        //回调失败接口
                            if(retrofitCallBack!=null)
                                retrofitCallBack.onFailure("暂无数据");
                        }else{
                        //如果是接口返回2000或者2001或者2002,进行弹框提示
                            if (resp.getResultCode() == 2000 || resp.getResultCode() == 2001 || resp.getResultCode() == 2002) {
                                Toast.makeText(mContext,"code====="+resp.getResultCode(),Toast.LENGTH_SHORT).show();
                            }
                            //如果接口返回200,并且http请求code返回200,说明请求成功
                            if (resp.getResultCode() == 200&&response.code()==200) {
                                if(retrofitCallBack!=null){
                                    //需要缓存数据
                                    String cacheResponse = mGson.toJson(resp);
 //判断下当前是否存在key缓存的数据,如果存在移除掉,                                   if(!TextUtils.isEmpty(key)&&!TextUtils.isEmpty(diskLruCacheHelper.getAsString(key)))
                                        diskLruCacheHelper.remove(key);
 //当需要缓存的数据不为空的时候,并且需要缓存的时候,通过diskLruCacheHelper进行缓存                                   if(!TextUtils.isEmpty(key)&&!TextUtils.isEmpty(cacheResponse)&&isCache){
                                        Log.d("zgx","response========cacheResponse"+cacheResponse);
                                        diskLruCacheHelper.put(key,cacheResponse);
                                    }
                                    //然后就是回调成功接口
                                    retrofitCallBack.onSuccess(resp);
                                }
                            } else {
                            //这个是请求失败,那么就回调失败接口
                                // ToastMaker.makeToast(mContext, resp.errMsg, Toast.LENGTH_SHORT);
                                if(retrofitCallBack!=null)
                                    retrofitCallBack.onFailure(resp.getErrMsg());
                            }
                        }
                        return;
                    }
                    //没有网络的情况下,去获取key对应的缓存
                    String json = diskLruCacheHelper.getAsString(key);
                    //如果缓存不存在,那么久回调失败接口
                    if(json==null){
                        Toast.makeText(mContext, "没有缓存!", Toast.LENGTH_SHORT).show();
                        if(retrofitCallBack!=null){
                            retrofitCallBack.onFailure("没有缓存!");
                        }
                    }else{
                    //判断是否配置clazz,一定要先配置,要不然Gson解析出错
                        if(clazz==null){
                            throw new IllegalArgumentException("请先配置clazz");
                        }
                        //解析缓存数据,然后进行回调成功接口
                        resp = mGson.fromJson(json,clazz);
                        if(retrofitCallBack!=null){
                            retrofitCallBack.onSuccess(resp);
                        }
                    }
                }

                @Override
                public void onFailure(Call<BaseResp<D>> call, Throwable t) {
                    //   ToastMaker.makeToast(mContext, "网络错误,请重试!", Toast.LENGTH_SHORT);
                    if(retrofitCallBack!=null){
                        retrofitCallBack.onFailure(t.toString());
                    }
                }
            });
            return this;
        }
    }
    //.....省略,和上篇博客代码一样
    //这里我们改成通过diskLruCacheHelper封装的类进行删除缓存
    public void clearCache() throws IOException {
        diskLruCacheHelper.delete();
    }
}

主要修改的地方,上面基本上都注释到了,这里没有做缓存的过期时间,有网的情况下,还是保持数据的实时性,没网的情况下才会去读取缓存。

API修改为Post请求

ILoginService.class

public interface ILoginService {
    @FormUrlEncoded
    @POST("LoginDataServlet")
    Call<BaseResp<RegisterBean>> userLogin(@Field("username") String username, @Field("password") String password);
}

INewsService.class

public interface INewsService {
    @FormUrlEncoded
    @POST("NewsDataServlet")
    Call<BaseResp<News<NewItem>>> userNews(@Field("userId") String userId);
}

这里主要是测试这两个接口

请求修改为链式编程

登录请求修改代码如下

首先实现回调接口

//传入成功回调的BaseResp<T>的泛型T为RegisterBean
implements HRetrofitNetHelper.RetrofitCallBack<RegisterBean>

然后是Call请求配置

final Call<BaseResp<RegisterBean>> repos = loginService.userLogin(username,password);
      new HRetrofitNetHelper
       .enqueueCall(HRetrofitNetHelper.getInstance(this))
       .call(repos)//repos指的是retrofitNetHelper.getAPIService返回的API
       .retrofitCallBack(this)//配置回调接口
       .isCache(true)//设置需要缓存
       .clazz(new TypeToken<BaseResp<RegisterBean>>(){}.getType())//Gson解析缓存需要
       .start();//真正开始发起请求

然后实现两个回调方法

     @Override
    public void onSuccess(BaseResp<RegisterBean> baseResp) {
        Date date = baseResp.getResponseTime();
        if(baseResp.getData().getErrorCode()==1){
            Toast.makeText(getBaseContext(),"登录成功",Toast.LENGTH_SHORT).show();
        }else {
            Toast.makeText(getBaseContext(),"用户不存在",Toast.LENGTH_SHORT).show();
        }
        mDialog.dismiss();
    }

    @Override
    public void onFailure(String error) {
        Log.d("zgx","onFailure======"+error);
        mDialog.dismiss();
    }

如果新闻页也要缓存,那么代码同理修改如下。

    private void loadData(){
        INewsService newService = retrofitNetHelper.getAPIService(INewsService.class);
        Log.d("zgx","mUserId====="+mUserId);
        final Call<BaseResp<News<NewItem>>> repos = newService.userNews(mUserId);
        new HRetrofitNetHelper.enqueueCall(HRetrofitNetHelper.getInstance(this))
                .call(repos)
                .retrofitCallBack(this)
                .isCache(true)
                .clazz(new TypeToken<BaseResp<News<NewItem>>>(){}.getType())
                .start();
    }

这样就缓存了登录接口的数据和新闻页面的数据。
下面就来测试下,只缓存登录接口。测试结果为有网的情况下,根据上面代码知道登录成功会弹出登录成功的Toast,并且会生成缓存文件,没有网络的情况下会去读取缓存,并且还是会弹出Toast提示,登录失败不弹。效果如下

这里写图片描述

接下来我们再看下没有缓存的效果,代码只要修改不配置

HRetrofitNetHelper.enqueueCall(HRetrofitNetHelper.getInstance(this))
                            .call(repos)
                            .retrofitCallBack(this)
                            .start();

然后就来看效果,有网的情况下应该为登录成功,没网的情况下,提示没有缓存,效果如下

这里写图片描述

Get请求效果同理。同样可以得到这样的效果,感兴趣的可以去试下。

最后配置3个权限

 <uses-permission android:name="android.permission.INTERNET" />
 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
 <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

总体感觉Retrofit+OkHttp框架用起来还是很方便的。特别是响应式编程,用的特别爽。还有就是Retrofit的源码设计的特别完美。不过在这里,用RxAndroid用的还是比较少,相信以后会用的越来越多,而且现在谷歌的agera响应式编程也出来了。

以上是关于手动缓存Retrofit+OkHttp响应体,不再局限于Get请求缓存的主要内容,如果未能解决你的问题,请参考以下文章

使用OkHttp拦截器和Retrofit进行缓存

使用OkHttp拦截器和Retrofit进行缓存

改造:来自 gson 转换体的原始响应体

Retrofit2 OkHttp3 响应正文空错误

如何使用 Retrofit 和 OKHttp 在下一次请求时使缓存路由无效/强制更新?

Android 使用Retrofit2.0+OkHttp3.0实现缓存处理+Cookie持久化第三方库