給Retrofit新增離線快取,支援Post請求

weixin_34087301發表於2016-12-04

需要實現的需求:

有網路的時候使用網路獲取資料,網路不可用的情況下使用本地快取。
Retrofit本身並沒有可以設定快取的api,它的底層網路請求使用Okhttp,所以新增快取也得從Okhttp入手。

一.Okhttp自帶的快取支援:

首先設定快取目錄,Okhttp的快取用到了DiskLruCache這個類。

        OkHttpClient.Builder builder = new OkHttpClient.Builder();
        File cacheDir = new File(context.getCacheDir(), "response");
        //快取的最大尺寸10m
        Cache cache = new Cache(cacheDir, 1024 * 1024 * 10);
        builder.cache(cache);

Okhttp快取攔截器:

public class CacheInterceptor implements Interceptor {

    @Override
    public okhttp3.Response intercept(Chain chain) throws IOException {
        Request request = chain.request();
        boolean netAvailable = NetWorkUtil.isNetAvailable(AppIml.appContext);

        if (netAvailable) {
            request = request.newBuilder()
                    //網路可用 強制從網路獲取資料
                    .cacheControl(CacheControl.FORCE_NETWORK)
                    .build();
        } else {
            request = request.newBuilder()
                    //網路不可用 從快取獲取
                    .cacheControl(CacheControl.FORCE_CACHE)
                    .build();
        }
        Response response = chain.proceed(request);
        if (netAvailable) {
            response = response.newBuilder()
                    .removeHeader("Pragma")
                    // 有網路時 設定快取超時時間1個小時
                    .header("Cache-Control", "public, max-age=" + 60 * 60)
                    .build();
        } else {
            response = response.newBuilder()
                    .removeHeader("Pragma")
                    // 無網路時,設定超時為1周
                    .header("Cache-Control", "public, only-if-cached, max-stale=" + 7 * 24 * 60 * 60)
                    .build();
        }
        return response;
    }
}

給OkHttpClient 設定攔截器,並用我們建立的OkHttpClient 替代Retrofit 預設的OkHttpClient:

    builder.addInterceptor(new CacheInterceptor());

    OkHttpClient client = builder.build();
    Retrofit retrofit = new Retrofit.Builder()
            .client(client)
            .baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build();

到這裡Okhttp的快取就配置完成了,實現了開頭所提出的需求。
但是這裡有個問題,Okhttp是隻支援Get請求的,如果我們使用其他方式請求比如Post,請求的能夠回撥onResponse方法,但是通過 response.body()來獲取請求的資料會得到null, response.code()得到的是504。
我還目前沒有找到能夠讓Okhttp的快取支援Post方式的方法,所以我只能自己去實現快取機制。

二.自己手動新增快取支援:

首先將 DiskLruCache.java 新增進來,我們和Okhttp一樣使用它來實現磁碟快取策略。
關於DiskLruCache的原始碼分析:Android DiskLruCache完全解析,硬碟快取的最佳方案
寫一個工具類 來設定和獲取快取:

public final class CacheManager {

    public static final String TAG = "CacheManager";

    //max cache size 10mb
    private static final long DISK_CACHE_SIZE = 1024 * 1024 * 10;

    private static final int DISK_CACHE_INDEX = 0;

    private static final String CACHE_DIR = "responses";

    private DiskLruCache mDiskLruCache;

    private volatile static CacheManager mCacheManager;

    public static CacheManager getInstance() {
        if (mCacheManager == null) {
            synchronized (CacheManager.class) {
                if (mCacheManager == null) {
                    mCacheManager = new CacheManager();
                }
            }
        }
        return mCacheManager;
    }

    private CacheManager() {
        File diskCacheDir = getDiskCacheDir(AppIml.appContext, CACHE_DIR);
        if (!diskCacheDir.exists()) {
            boolean b = diskCacheDir.mkdirs();
            Log.d(TAG, "!diskCacheDir.exists() --- diskCacheDir.mkdirs()=" + b);
        }
        if (diskCacheDir.getUsableSpace() > DISK_CACHE_SIZE) {
            try {
                mDiskLruCache = DiskLruCache.open(diskCacheDir,
                        getAppVersion(AppIml.appContext), 1/*一個key對應多少個檔案*/, DISK_CACHE_SIZE);
                Log.d(TAG, "mDiskLruCache created");
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 同步設定快取
     */
    public void putCache(String key, String value) {
        if (mDiskLruCache == null) return;
        OutputStream os = null;
        try {
            DiskLruCache.Editor editor = mDiskLruCache.edit(encryptMD5(key));
            os = editor.newOutputStream(DISK_CACHE_INDEX);
            os.write(value.getBytes());
            os.flush();
            editor.commit();
            mDiskLruCache.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (os != null) {
                try {
                    os.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    /**
     * 非同步設定快取
     */
    public void setCache(final String key, final String value) {
        new Thread() {
            @Override
            public void run() {
                putCache(key, value);
            }
        }.start();
    }

    /**
     * 同步獲取快取
     */
    public String getCache(String key) {
        if (mDiskLruCache == null) {
            return null;
        }
        FileInputStream fis = null;
        ByteArrayOutputStream bos = null;
        try {
            DiskLruCache.Snapshot snapshot = mDiskLruCache.get(encryptMD5(key));
            if (snapshot != null) {
                fis = (FileInputStream) snapshot.getInputStream(DISK_CACHE_INDEX);
                bos = new ByteArrayOutputStream();
                byte[] buf = new byte[1024];
                int len;
                while ((len = fis.read(buf)) != -1) {
                    bos.write(buf, 0, len);
                }
                byte[] data = bos.toByteArray();
                return new String(data);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (fis != null) {
                try {
                    fis.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (bos != null) {
                try {
                    bos.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return null;
    }

    /**
     * 非同步獲取快取
     */
    public void getCache(final String key, final CacheCallback callback) {
        new Thread() {
            @Override
            public void run() {
                String cache = getCache(key);
                callback.onGetCache(cache);
            }
        }.start();
    }

    /**
     * 移除快取
     */
    public boolean removeCache(String key) {
        if (mDiskLruCache != null) {
            try {
                return mDiskLruCache.remove(encryptMD5(key));
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return false;
    }

    /**
     * 獲取快取目錄
     */
    private File getDiskCacheDir(Context context, String uniqueName) {
        String cachePath = context.getCacheDir().getPath();
        return new File(cachePath + File.separator + uniqueName);
    }

    /**
     * 對字串進行MD5編碼
     */
    public static String encryptMD5(String string) {
        try {
            byte[] hash = MessageDigest.getInstance("MD5").digest(
                    string.getBytes("UTF-8"));
            StringBuilder hex = new StringBuilder(hash.length * 2);
            for (byte b : hash) {
                if ((b & 0xFF) < 0x10) {
                    hex.append("0");
                }
                hex.append(Integer.toHexString(b & 0xFF));
            }
            return hex.toString();
        } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return string;
    }

    /**
     * 獲取APP版本號
     */
    private int getAppVersion(Context context) {
        PackageManager pm = context.getPackageManager();
        try {
            PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 0);
            return pi == null ? 0 : pi.versionCode;
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }
        return 0;
    }
}

然後我們還是給Okhttp新增攔截器,將請求的Request和請求結果Response以Key Value的形式快取的磁碟。這裡的重點是判斷請求的方式,如果是Post請求這將請求的body轉成String然後新增到url的後面作為磁碟快取的key。

public class EnhancedCacheInterceptor implements Interceptor {
    @Override
    public Response intercept(Chain chain) throws IOException {
        Request request = chain.request();
        Response response = chain.proceed(request);

        String url = request.url().toString();
        RequestBody requestBody = request.body();
        Charset charset = Charset.forName("UTF-8");
        StringBuilder sb = new StringBuilder();
        sb.append(url);
        if (request.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();
            }
            sb.append(buffer.readString(charset));
            buffer.close();
        }
        Log.d(CacheManager.TAG, "EnhancedCacheInterceptor -> key:" + sb.toString());

        ResponseBody responseBody = response.body();
        MediaType contentType = responseBody.contentType();

        BufferedSource source = responseBody.source();
        source.request(Long.MAX_VALUE);
        Buffer buffer = source.buffer();

        if (contentType != null) {
            charset = contentType.charset(Charset.forName("UTF-8"));
        }
        String key = sb.toString();
        //伺服器返回的json原始資料 
        String json = buffer.clone().readString(charset);

        CacheManager.getInstance().putCache(key, json);
        Log.d(CacheManager.TAG, "put cache-> key:" + key + "-> json:" + json);
        return chain.proceed(request);
    }
}

建立OkHttpClient並新增快取攔截器,初始化Retrofit;

        OkHttpClient.Builder builder = new OkHttpClient.Builder();
        builder.addInterceptor(new EnhancedCacheInterceptor());

        OkHttpClient client = builder.build();
        Retrofit retrofit = new Retrofit.Builder()
                .client(client)
                .baseUrl(BASE_URL)
                .addConverterFactory(GsonConverterFactory.create())
                .build();

到這裡我們已經實現了請求網路新增快取,下一步是在網路不可用的時候獲取磁碟上的快取,這裡我通過改造Call和Callback來實現:
EnhancedCall使用裝飾模式裝飾Retrofit的Call,EnhancedCallback比Retrofit的Callback介面多一個onGetCache方法。
網路不可用的時候會回撥onFailure方法,我們攔截onFailure,並根據請求Request去獲取快取,獲取到快取走EnhancedCallback的onGetCache方法,如果沒有獲取到快取或者請求不需要使用快取再呼叫onFailure方法,

public class EnhancedCall<T> {
    private Call<T> mCall;
    private Class dataClassName;
    // 是否使用快取 預設開啟
    private boolean mUseCache = true;

    public EnhancedCall(Call<T> call) {
        this.mCall = call;
    }

    /**
     * Gson反序列化快取時 需要獲取到泛型的class型別
     */
    public EnhancedCall<T> dataClassName(Class className) {
        dataClassName = className;
        return this;
    }

    /**
     * 是否使用快取 預設使用
     */
    public EnhancedCall<T> useCache(boolean useCache) {
        mUseCache = useCache;
        return this;
    }

    public void enqueue(final EnhancedCallback<T> enhancedCallback) {
        mCall.enqueue(new Callback<T>() {
            @Override
            public void onResponse(Call<T> call, Response<T> response) {
                enhancedCallback.onResponse(call, response);
            }

            @Override
            public void onFailure(Call<T> call, Throwable t) {
                if (!mUseCache || NetWorkUtil.isNetAvailable(AppIml.appContext)) {
                    //不使用快取 或者網路可用 的情況下直接回撥onFailure
                    enhancedCallback.onFailure(call, t);
                    return;
                }

                Request request = call.request();
                String url = request.url().toString();
                RequestBody requestBody = request.body();
                Charset charset = Charset.forName("UTF-8");
                StringBuilder sb = new StringBuilder();
                sb.append(url);
                if (request.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();
                    }
                    sb.append(buffer.readString(charset));
                    buffer.close();
                }

                String cache = CacheManager.getInstance().getCache(sb.toString());
                Log.d(CacheManager.TAG, "get cache->" + cache);

                if (!TextUtils.isEmpty(cache) && dataClassName != null) {
                    Object obj = new Gson().fromJson(cache, dataClassName);
                    if (obj != null) {
                        enhancedCallback.onGetCache((T) obj);
                        return;
                    }
                }
                enhancedCallback.onFailure(call, t);
                Log.d(CacheManager.TAG, "onFailure->" + t.getMessage());
            }
        });
    }
}
public interface EnhancedCallback<T> {
    void onResponse(Call<T> call, Response<T> response);

    void onFailure(Call<T> call, Throwable t);

    void onGetCache(T t);
}

到這裡已經實現了最開始的我的需求,也可以支援Post請求的快取。最後看看使用的方式:

    public void getRequest(View view) {
        ApiService service = getApiService();
        Call<UserList> call = service.getUserList();
        //使用我們自己的EnhancedCall 替換Retrofit的Call
        EnhancedCall<UserList> enhancedCall = new EnhancedCall<>(call);
        enhancedCall.useCache(true)/*預設支援快取 可不設定*/
                .dataClassName(UserList.class)
                .enqueue(new EnhancedCallback<UserList>() {
                    @Override
                    public void onResponse(Call<UserList> call, Response<UserList> response) {
                        UserList userlist = response.body();
                        if (userlist != null) {
                            Log.d(TAG, "onResponse->" + userlist.toString());
                        }
                    }

                    @Override
                    public void onFailure(Call<UserList> call, Throwable t) {
                        Log.d(TAG, "onFailure->" + t.getMessage());
                    }

                    @Override
                    public void onGetCache(UserList userlist) {
                        Log.d(TAG, "onGetCache" + userlist.toString());
                    }
                });
    }

全部程式碼地址Github:https://github.com/wangyiwy/CacheUtil4Retrofit)

相關文章