天天看點

産品設計:Android應用-開發技術【資料緩存】

最近真夠忙的,瞎忙!好久沒寫部落格。不知道寫什麼,就寫些最近對使用者體驗這塊的一個小的見解吧。

無論大型或小型應用,靈活的緩存可以說不僅大大減輕了伺服器的壓力,而且因為更快速的使用者體驗而友善了使用者。從事Android開發工作以來,個人認為Android應用可以說是作為小型應用,隻是其中很多的開發時間花費在細節之上(UI互動方式、響應速度、效果、字型、顔色等等),其中90%乃至99的應用并不是需要實時更新的(即時通訊類的除外:QQ),而且诟病于蝸牛般的移動網速,3G也是(至少中國目前是這樣的),與伺服器的資料互動是能少則少,這樣使用者體驗才更好。

産品設計:Android應用-開發技術【資料緩存】

這是我公司産品(感興趣的朋友可以到這裡下載下傳,提有用建議有回報的哦http://www.hulutan.net/ 目前仍在不斷完善,後邊還有驚喜)的截圖,其實這個清單基本上可以全部用緩存資料來處理,即便你有新遊戲釋出,我不信你一個上百個應用出世。 

采用緩存,可以進一步大大緩解資料互動的壓力,又能提供一定的離線浏覽。下邊我簡略列舉一下緩存管理的适用環境:

1. 提供網絡服務的應用

2. 資料更新不需要實時更新,哪怕是3-5分鐘的延遲也是可以采用緩存機制。

3. 緩存的過期時間是可以接受的(類似網易的新聞閱讀,支援離線離線閱讀)

這樣所帶來的好處:

1. 減小伺服器的壓力

2. 提高用戶端的響應速度(本地資料提取嘛)

3. 一定程度上支援離線浏覽(可以參考網易的那個新聞應用,個人感覺離線閱讀做得非常棒。)

一、緩存管理的方法

緩存管理的原理很簡:通過時間的設定來判斷是否讀取緩存還是重新下載下傳;斷網下就沒什麼好說的,直接去緩存即可。如圖:

産品設計:Android應用-開發技術【資料緩存】

裡面會有一些細節的處理,後面會詳細闡述。基于這個原理,目前個人用過的兩種比較常見的緩存管理方法是:資料庫和檔案(txt)。

二、資料庫(SQLite)緩存方式

這種方法是在下載下傳完資料檔案後,把檔案的相關資訊如url,路經,下載下傳時間,過期時間等存放到資料庫,當然我個人建議把url作為唯一的辨別。下次下載下傳的時候根據url先從資料庫中查詢,如果查詢到目前時間并未過期,就根據路徑讀取本地檔案,進而實作緩存的效果。

從實作上我們可以看到這種方法可以靈活存放檔案的屬性,進而提供了很大的擴充性,可以為其它的功能提供一定的支援。

從操作上需要建立資料庫,每次查詢資料庫,如果過期還需要更新資料庫,清理緩存的時候還需要删除資料庫資料,稍顯麻煩,而資料庫操作不當又容易出現一系列的性能,ANR問題,指針錯誤問題,實作的時候要謹慎,具體作的話,但也隻是增加一個工具類或方法的事情。

還有一個問題,緩存的資料庫是存放在/data/data/<package>/databases/目錄下,是占用記憶體空間的,如果緩存累計,容易浪費記憶體,需要及時清理緩存。

當然這種方法從目前一些應用的實用上看,我沒有發現什麼問題,估計使用的量還比較少吧。

本文本人不太喜歡資料庫,原因操作麻煩,尤其是要自己寫建表那些語句,你懂的。我側重檔案緩存方式。

三、檔案緩存方式

這種方法,使用File.lastModified()方法得到檔案的最後修改時間,與目前時間判斷是否過期,進而實作緩存效果。

實作上隻能使用這一個屬性,沒有為其它的功能提供技術支援的可能。操作上倒是簡單,比較時間即可,而且取的資料也就是檔案裡的JSON資料而已。本身處理也不容易帶來其它問題,代價低廉。

四、檔案法緩存方式的兩點說明

1. 不同類型的檔案的緩存時間不一樣。

籠統的說,不變檔案的緩存時間是永久,變化檔案的緩存時間是最大忍受不變時間。說白點,圖檔檔案内容是不變的,一般存在SD卡上直到被清理,我們是可以永遠讀取緩存的。配置檔案内容是可能更新的,需要設定一個可接受的緩存時間。

2. 不同環境下的緩存時間标準不一樣。

無網絡環境下,我們隻能讀取緩存檔案,為了應用有東西顯示,沒有什麼過期之說了。

WiFi網絡環境下,緩存時間可以設定短一點,一是網速較快,而是流量不要錢。

3G流量環境下,緩存時間可以設定長一點,節省流量,就是節省金錢,而且使用者體驗也更好。

GPS就别說更新什麼的,已經夠慢的了。緩存時間能多長就多長把。

當然,作為一款好的應用,不會死定一種情況,針對于不同網絡變換不同形式的緩存功能是必須有的。而且這個時間根據自己的實際情況來設定:資料的更新頻率,資料的重要性等。

五、何時重新整理

        開發者一方面希望盡量讀取緩存,使用者一方面希望實時重新整理,但是響應速度越快越好,流量消耗越少越好(關于這塊,的确開發中我沒怎麼想到,畢竟接口就是這麼多,現在公司的産品幾乎點一下就通路一下,而且還有些雞肋多餘的功能。慢慢修改哈哈),是一個沖突。

其實何時重新整理我也不知道,這裡我提供兩點建議:

1. 資料的最長多長時間不變,對應用無大的影響。

        比如,你的資料更新時間為4小時,則緩存時間設定為1~2小時比較合适。也就是更新時間/緩存時間=2,但使用者個人修改、網站編輯人員等一些人為的更新就另說。一天使用者總會看到更新,即便有延遲也好,視你産品的用途了;如果你覺得你是資訊類應用,再減少,2~4小時,如果你覺得資料比較重要或者比較受歡迎,使用者會經常把玩,再減少,1~2小時,依次類推。

産品設計:Android應用-開發技術【資料緩存】

        當然類似這個界面的資料我認為更新時間能多長就多長了,盡可能長。如果你拿後邊那個有多少資料會變動來搪塞。我會告訴你:這個隻是一個引導性的界面,你有多少款遊戲跟使用者半毛錢關系都沒有,10億也跟他沒關,他隻要确定這裡能找到他要找的 湯姆貓 就行。否則你又失去了一個使用者。

2. 提供重新整理按鈕。

        必要時候或最保險的方法使在相關界面提供一個重新整理按鈕,或者當下流行的下拉清單重新整理方式。為緩存,為加載失敗提供一次重新來過的機會。畢竟喝骨頭湯的時候,我也不介意碗旁多雙筷子。

總而言之,一切使用者至上,為了更好的使用者體驗,方法也會層出不窮。期待更好的辦法

關鍵代碼:

package com.hulutan.gamestore.cache;

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

import android.os.Environment;
import android.util.Log;

import com.hulutan.gamestore.Constants;
import com.hulutan.gamestore.GameStoreApplication;
import com.hulutan.gamestore.util.EncryptUtils;
import com.hulutan.gamestore.util.FileUtils;
import com.hulutan.gamestore.util.NetworkUtils;
import com.hulutan.gamestore.util.NetworkUtils.NetworkState;
import com.hulutan.gamestore.util.StringUtils;

/**
 * 緩存工具類
 * @author naibo-liao
 * @時間: 2013-1-4下午02:30:52
 */
public class ConfigCacheUtil {

    private static final String TAG=ConfigCacheUtil.class.getName();

    /** 1秒逾時時間 */
    public static final int CONFIG_CACHE_SHORT_TIMEOUT=1000 * 60 * 5; // 5 分鐘

    /** 5分鐘逾時時間 */
    public static final int CONFIG_CACHE_MEDIUM_TIMEOUT=1000 * 3600 * 2; // 2小時

    /** 中長緩存時間 */
    public static final int CONFIG_CACHE_ML_TIMEOUT=1000 * 60 * 60 * 24 * 1; // 1天

    /** 最大緩存時間 */
    public static final int CONFIG_CACHE_MAX_TIMEOUT=1000 * 60 * 60 * 24 * 7; // 7天

    /**
     * CONFIG_CACHE_MODEL_LONG : 長時間(7天)緩存模式 <br>
     * CONFIG_CACHE_MODEL_ML : 中長時間(12小時)緩存模式<br>
     * CONFIG_CACHE_MODEL_MEDIUM: 中等時間(2小時)緩存模式 <br>
     * CONFIG_CACHE_MODEL_SHORT : 短時間(5分鐘)緩存模式
     */
    public enum ConfigCacheModel {
        CONFIG_CACHE_MODEL_SHORT, CONFIG_CACHE_MODEL_MEDIUM, CONFIG_CACHE_MODEL_ML, CONFIG_CACHE_MODEL_LONG;
    }

    /**
     * 擷取緩存
     * @param url 通路網絡的URL
     * @return 緩存資料
     */
    public static String getUrlCache(String url, ConfigCacheModel model) {
        if(url == null) {
            return null;
        }

        String result=null;
        String path=Constants.ENVIROMENT_DIR_CACHE + StringUtils.replaceUrlWithPlus(EncryptUtils.encryptToMD5(url));
        File file=new File(path);
        if(file.exists() && file.isFile()) {
            long expiredTime=System.currentTimeMillis() - file.lastModified();
            Log.d(TAG, file.getAbsolutePath() + " expiredTime:" + expiredTime / 60000 + "min");
            // 1。如果系統時間是不正确的
            // 2。當網絡是無效的,你隻能讀緩存
            if(NetworkUtils.getNetworkState(GameStoreApplication.getInstance().getContext()) != NetworkState.NETWORN_NONE) {
                if(expiredTime < 0) {
                    return null;
                }
                if(model == ConfigCacheModel.CONFIG_CACHE_MODEL_SHORT) {
                    if(expiredTime > CONFIG_CACHE_SHORT_TIMEOUT) {
                        return null;
                    }
                } else if(model == ConfigCacheModel.CONFIG_CACHE_MODEL_MEDIUM) {
                    if(expiredTime > CONFIG_CACHE_MEDIUM_TIMEOUT) {
                        return null;
                    }
                } else if(model == ConfigCacheModel.CONFIG_CACHE_MODEL_ML) {
                    if(expiredTime > CONFIG_CACHE_ML_TIMEOUT) {
                        return null;
                    }
                } else if(model == ConfigCacheModel.CONFIG_CACHE_MODEL_LONG) {
                    if(expiredTime > CONFIG_CACHE_MEDIUM_TIMEOUT) {
                        return null;
                    }
                } else {
                    if(expiredTime > CONFIG_CACHE_MAX_TIMEOUT) {
                        return null;
                    }
                }
            }

            try {
                result=FileUtils.readTextFile(file);
            } catch(IOException e) {
                e.printStackTrace();
            }
        }
        return result;
    }

    /**
     * 設定緩存
     * @param data
     * @param url
     */
    public static void setUrlCache(String data, String url) {
        if(Constants.ENVIROMENT_DIR_CACHE == null) {
            return;
        }
        File dir=new File(Constants.ENVIROMENT_DIR_CACHE);
        if(!dir.exists() && Environment.getExternalStorageState().equals(android.os.Environment.MEDIA_MOUNTED)) {
            dir.mkdirs();
        }
        File file=new File(Constants.ENVIROMENT_DIR_CACHE + StringUtils.replaceUrlWithPlus(EncryptUtils.encryptToMD5(url)));
        try {
            // 建立緩存資料到磁盤,就是建立檔案
            FileUtils.writeTextFile(file, data);
        } catch(IOException e) {
            Log.d(TAG, "write " + file.getAbsolutePath() + " data failed!");
            e.printStackTrace();
        } catch(Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 删除曆史緩存檔案
     * @param cacheFile
     */
    public static void clearCache(File cacheFile) {
        if(cacheFile == null) {
            if(Environment.getExternalStorageState().equals(android.os.Environment.MEDIA_MOUNTED)) {
                try {
                    File cacheDir=new File(Environment.getExternalStorageDirectory().getPath() + "/hulutan/cache/");
                    if(cacheDir.exists()) {
                        clearCache(cacheDir);
                    }
                } catch(Exception e) {
                    e.printStackTrace();
                }
            }
        } else if(cacheFile.isFile()) {
            cacheFile.delete();
        } else if(cacheFile.isDirectory()) {
            File[] childFiles=cacheFile.listFiles();
            for(int i=0; i < childFiles.length; i++) {
                clearCache(childFiles[i]);
            }
        }
    }
}
           

擷取緩存:

String cacheConfigString=ConfigCacheUtil.getUrlCache(Net.API_HELP, ConfigCacheModel.CONFIG_CACHE_MODEL_LONG);
        if(cacheConfigString != null) {
               //do something
        }
           

設定緩存:

ConfigCacheUtil.setUrlCache(data, Net.API_HELP);
           

補充  FileUtils 檔案

/**
 * 檔案處理工具類
 * 
 * @author naibo-liao
 * @時間: 2013-1-4下午03:13:08
 */
public class FileUtils {

	public static final long B = 1;
	public static final long KB = B * 1024;
	public static final long MB = KB * 1024;
	public static final long GB = MB * 1024;
	private static final int BUFFER = 8192;
	/**
	 * 格式化檔案大小<b> 帶有機關
	 * 
	 * @param size
	 * @return
	 */
	public static String formatFileSize(long size) {
		StringBuilder sb = new StringBuilder();
		String u = null;
		double tmpSize = 0;
		if (size < KB) {
			sb.append(size).append("B");
			return sb.toString();
		} else if (size < MB) {
			tmpSize = getSize(size, KB);
			u = "KB";
		} else if (size < GB) {
			tmpSize = getSize(size, MB);
			u = "MB";
		} else {
			tmpSize = getSize(size, GB);
			u = "GB";
		}
		return sb.append(twodot(tmpSize)).append(u).toString();
	}

	/**
	 * 保留兩位小數
	 * 
	 * @param d
	 * @return
	 */
	public static String twodot(double d) {
		return String.format("%.2f", d);
	}

	public static double getSize(long size, long u) {
		return (double) size / (double) u;
	}

	/**
	 * sd卡挂載且可用
	 * 
	 * @return
	 */
	public static boolean isSdCardMounted() {
		return android.os.Environment.getExternalStorageState().equals(
				android.os.Environment.MEDIA_MOUNTED);
	}

	/**
	 * 遞歸建立檔案目錄
	 * 
	 * @param path
	 * */
	public static void CreateDir(String path) {
		if (!isSdCardMounted())
			return;
		File file = new File(path);
		if (!file.exists()) {
			try {
				file.mkdirs();
			} catch (Exception e) {
				Log.e("hulutan", "error on creat dirs:" + e.getStackTrace());
			}
		}
	}

	/**
	 * 讀取檔案
	 * 
	 * @param file
	 * @return
	 * @throws IOException
	 */
	public static String readTextFile(File file) throws IOException {
		String text = null;
		InputStream is = null;
		try {
			is = new FileInputStream(file);
			text = readTextInputStream(is);;
		} finally {
			if (is != null) {
				is.close();
			}
		}
		return text;
	}

	/**
	 * 從流中讀取檔案
	 * 
	 * @param is
	 * @return
	 * @throws IOException
	 */
	public static String readTextInputStream(InputStream is) throws IOException {
		StringBuffer strbuffer = new StringBuffer();
		String line;
		BufferedReader reader = null;
		try {
			reader = new BufferedReader(new InputStreamReader(is));
			while ((line = reader.readLine()) != null) {
				strbuffer.append(line).append("\r\n");
			}
		} finally {
			if (reader != null) {
				reader.close();
			}
		}
		return strbuffer.toString();
	}

	/**
	 * 将文本内容寫入檔案
	 * 
	 * @param file
	 * @param str
	 * @throws IOException
	 */
	public static void writeTextFile(File file, String str) throws IOException {
		DataOutputStream out = null;
		try {
			out = new DataOutputStream(new FileOutputStream(file));
			out.write(str.getBytes());
		} finally {
			if (out != null) {
				out.close();
			}
		}
	}

	/**
	 * 将Bitmap儲存本地JPG圖檔
	 * @param url
	 * @return
	 * @throws IOException
	 */
	public static String saveBitmap2File(String url) throws IOException {

		BufferedInputStream inBuff = null;
		BufferedOutputStream outBuff = null;

		SimpleDateFormat sf = new SimpleDateFormat("yyyy_MM_dd_HH_mm_ss");
		String timeStamp = sf.format(new Date());
		File targetFile = new File(Constants.ENVIROMENT_DIR_SAVE, timeStamp
				+ ".jpg");
		File oldfile = ImageLoader.getInstance().getDiscCache().get(url);
		try {

			inBuff = new BufferedInputStream(new FileInputStream(oldfile));
			outBuff = new BufferedOutputStream(new FileOutputStream(targetFile));
			byte[] buffer = new byte[BUFFER];
			int length;
			while ((length = inBuff.read(buffer)) != -1) {
				outBuff.write(buffer, 0, length);
			}
			outBuff.flush();
			return targetFile.getPath();
		} catch (Exception e) {

		} finally {
			if (inBuff != null) {
				inBuff.close();
			}
			if (outBuff != null) {
				outBuff.close();
			}
		}
		return targetFile.getPath();
	}

	/**
	 * 讀取表情配置檔案
	 * 
	 * @param context
	 * @return
	 */
	public static List<String> getEmojiFile(Context context) {
		try {
			List<String> list = new ArrayList<String>();
			InputStream in = context.getResources().getAssets().open("emoji");// 檔案名字為rose.txt
			BufferedReader br = new BufferedReader(new InputStreamReader(in,
					"UTF-8"));
			String str = null;
			while ((str = br.readLine()) != null) {
				list.add(str);
			}

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

	/**
	 * 擷取一個檔案夾大小
	 * 
	 * @param f
	 * @return
	 * @throws Exception
	 */
	public static long getFileSize(File f) {
		long size = 0;
		File flist[] = f.listFiles();
		for (int i = 0; i < flist.length; i++) {
			if (flist[i].isDirectory()) {
				size = size + getFileSize(flist[i]);
			} else {
				size = size + flist[i].length();
			}
		}
		return size;
	}

	/**
	 * 删除檔案
	 * 
	 * @param file
	 */
	public static void deleteFile(File file) {

		if (file.exists()) { // 判斷檔案是否存在
			if (file.isFile()) { // 判斷是否是檔案
				file.delete(); // delete()方法 你應該知道 是删除的意思;
			} else if (file.isDirectory()) { // 否則如果它是一個目錄
				File files[] = file.listFiles(); // 聲明目錄下所有的檔案 files[];
				for (int i = 0; i < files.length; i++) { // 周遊目錄下所有的檔案
					deleteFile(files[i]); // 把每個檔案 用這個方法進行疊代
				}
			}
			file.delete();
		}
	}
}