對於目前的狀況來說,移動終端的網絡狀況沒有PC網絡狀況那么理想。在一個Android應用中,如果需要接收來自服務器的大容量數據,那么就不得不考慮客戶的流量問題。本文根據筆者的一個項目實戰經驗出發,解決大容量數據的交互問題,解決數據大小會根據實際情況動態切換問題(服務器動態選擇是否要壓縮數據,客戶端動態解析數據是否是被壓縮的),還有數據交互的編碼問題。
解決數據過大的問題,最直觀的方法就是壓縮數據。服務器將需要傳遞的數據先進行壓縮,再發送給Android客戶端,Android客戶端接收到壓縮的數據,對其解壓,得到壓縮前的數據。
如果規定Android客戶端和服務器的交互數據必須是經過某種壓縮算法后的數據,那么這種“規定”失去了視具體情況而定的靈活性。筆者擬將Http協議進行封裝,將動態的選擇傳輸的數據是否要經過壓縮,客戶端也能動態的識別,整理並獲得服務器想要發送的數據。Android客戶端向服務器請求某個方面的數據,這個數據也許是經過壓縮后傳遞比較合適,又也許是將原生數據傳遞比較合適。也就是說,筆者想要設計一種協議,這種協議適用於傳輸數據的數據量會動態的切換,也許它會是一個小數據,也許它又會是一個數據量龐大的大數據(大數據需要經過壓縮)。
可能說的比較抽象,那么我用實際情況解釋一下。
我項目中的一個實際情況是這樣的:這個項目是做一個Android基金客戶端,Android客戶端向服務器請求某一個基金的歷史走勢信息,由於我的Android客戶端實現了本地緩存,這讓傳遞數據的大小浮動非常大。如果本地緩存的歷史走勢信息的最新日期是5月5日,服務器的歷史走勢信息的最新日期是5月7日,那么服務器就像發送5月6日和5月7日這兩天的走勢信息,這個數據很小,不需要壓縮(我使用的壓縮算法,對於數據量過小的數據壓縮並不理想,數據量過小的數據壓縮后的數據會比壓縮前的數據大)。然而,Android客戶端也可能對於某個基金沒有任何的緩存信息,那么服務器將發送的數據將是過去三四年間的歷史走勢信息,這個數據會有點大,就需要進行壓縮后傳遞。那么客戶端對於同一個請求得到的數據,如何判斷它是壓縮后的數據還是未曾壓縮的數據呢?
筆者使用的解決方案是把傳遞數據的第一個字節作為標識字節,將標識這個數據是否被壓縮了。也能標識傳遞數據的編碼問題。Android對於接收到的數據(字節數組),先判斷第一個字節的數據,就能根據它所代表的數據格式和編碼信息進行相應的操作。說了那么多,也許不如看實際的代碼理解的快。首先是壓縮算法,這里筆者用到的是jdk自帶的zip壓縮算法。
1 package com.chenjun.utils.compress; 2 3 import java.io.ByteArrayInputStream; 4 import java.io.ByteArrayOutputStream; 5 import java.io.InputStream; 6 import java.io.OutputStream; 7 import java.util.zip.GZIPInputStream; 8 import java.util.zip.GZIPOutputStream; 9 10 public class Compress { 11 private static final int BUFFER_LENGTH = 400; 12 13 14 //壓縮字節最小長度,小於這個長度的字節數組不適合壓縮,壓縮完會更大 15 public static final int BYTE_MIN_LENGTH = 50; 16 17 18 //字節數組是否壓縮標志位 19 public static final byte FLAG_GBK_STRING_UNCOMPRESSED_BYTEARRAY = 0; 20 public static final byte FLAG_GBK_STRING_COMPRESSED_BYTEARRAY = 1; 21 public static final byte FLAG_UTF8_STRING_COMPRESSED_BYTEARRAY = 2; 22 public static final byte FLAG_NO_UPDATE_INFO = 3; 23 24 /** 25 * 數據壓縮 26 * 27 * @param is 28 * @param os 29 * @throws Exception 30 */ 31 public static void compress(InputStream is, OutputStream os) 32 throws Exception { 33 34 GZIPOutputStream gos = new GZIPOutputStream(os); 35 36 int count; 37 byte data[] = new byte[BUFFER_LENGTH]; 38 while ((count = is.read(data, 0, BUFFER_LENGTH)) != -1) { 39 gos.write(data, 0, count); 40 } 41 42 gos.finish(); 43 44 gos.flush(); 45 gos.close(); 46 } 47 48 49 /** 50 * 數據解壓縮 51 * 52 * @param is 53 * @param os 54 * @throws Exception 55 */ 56 public static void decompress(InputStream is, OutputStream os) 57 throws Exception { 58 59 GZIPInputStream gis = new GZIPInputStream(is); 60 61 int count; 62 byte data[] = new byte[BUFFER_LENGTH]; 63 while ((count = gis.read(data, 0, BUFFER_LENGTH)) != -1) { 64 os.write(data, 0, count); 65 } 66 67 gis.close(); 68 } 69 70 /** 71 * 數據壓縮 72 * 73 * @param data 74 * @return 75 * @throws Exception 76 */ 77 public static byte[] byteCompress(byte[] data) throws Exception { 78 ByteArrayInputStream bais = new ByteArrayInputStream(data); 79 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 80 81 // 壓縮 82 compress(bais, baos); 83 84 byte[] output = baos.toByteArray(); 85 86 baos.flush(); 87 baos.close(); 88 89 bais.close(); 90 91 return output; 92 } 93 94 95 /** 96 * 數據解壓縮 97 * 98 * @param data 99 * @return 100 * @throws Exception 101 */ 102 public static byte[] byteDecompress(byte[] data) throws Exception { 103 ByteArrayInputStream bais = new ByteArrayInputStream(data); 104 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 105 106 // 解壓縮 107 108 decompress(bais, baos); 109 110 data = baos.toByteArray(); 111 112 baos.flush(); 113 baos.close(); 114 115 bais.close(); 116 117 return data; 118 } 119 }
這里供外部調用的方法是byteCompress()和byteDecompress(),都將接收一個byte數組,byteCompress是數據壓縮方法,將返回壓縮后的數組數據,byteDecompress是數據解壓方法,將返回解壓后的byte數組數據。FLAG_GBK_STRING_COMPRESSED_BYTEARRAY表示服務器傳遞的數據是GBK編碼的字符串經過壓縮后的字節數組。其它的常量也能根據其名字來理解。(這里多說一句,最好將編碼方式和是否壓縮的標識位分開,比如將標識字節的前四個位定義成標識編碼方式的位,將后面四個位標識為是否壓縮或者其它信息的標識位,通過位的與或者或方式來判斷標識位。筆者這里偷懶了,直接就這么寫了。)
下面是處理傳遞數據的方法(判斷是否要壓縮)。我這里用要的是Struts 1框架,在Action里組織數據,並作相應的處理(壓縮或者不壓縮),並發送。
public ActionForward execute(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) { JjjzForm jjjzForm = (JjjzForm) form; //基金凈值歷史走勢信息 ArrayList<Jjjz> jjjzs = null; //得到基金凈值歷史走勢的方法省略了 Gson gson = new Gson(); String jsonStr = gson.toJson(jjjzs, jjjzs.getClass()); byte[] resultOriginalByte = jsonStr.getBytes(); //組織最后返回數據的緩沖字節數組 ByteArrayOutputStream resultBuffer = new ByteArrayOutputStream(); OutputStream os = null; try { os = response.getOutputStream(); //如果要返回的結果字節數組小於50位,不將壓縮 if(resultOriginalByte.length < Compress.BYTE_MIN_LENGTH){ byte flagByte = Compress.FLAG_GBK_STRING_UNCOMPRESSED_BYTEARRAY; resultBuffer.write(flagByte); resultBuffer.write(resultOriginalByte); } else{ byte flagByte = Compress.FLAG_GBK_STRING_COMPRESSED_BYTEARRAY; resultBuffer.write(flagByte); resultBuffer.write(Compress.byteCompress(resultOriginalByte)); } resultBuffer.flush(); resultBuffer.close(); //將最后組織后的字節數組發送給客戶端 os.write(resultBuffer.toByteArray()); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (Exception e) { // TODO Auto-generated catch block e.printStackTrace(); } finally{ try { os.close(); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); } } return null; }
這里我預發送的數據是一個Json格式的字符串(GBK編碼),將判斷這個字符串的長度(判斷是否適合壓縮)。如果適合壓縮,就將緩沖字節數組(ByteArrayOutputStream resultBuffer)的第一個字節填充FLAG_GBK_STRING_COMPRESSED_BYTEARRAY,再將Json字符串的字節數組壓縮,並存入數據緩沖字節數組,最后向輸出流寫入緩沖字節數組,關閉流。如果不適合壓縮,將發送的數據的第一個字節填充為FLAG_GBK_STRING_UNCOMPRESSED_BYTEARRAY,再將Json字符串的字節數組直接存入數據緩沖字節數組,寫入輸出流,關閉流。
最后就是Android客戶端的解析了,將上述的Compress壓縮輔助類拷貝到Android項目中就行。下面是Http請求后得到的字節數組數據做解析工作。(Android客戶端如何使用Http向服務器請求數據請參考我前面的一篇博客)。
byte[] receivedByte = EntityUtils.toByteArray(httpResponse.getEntity()); String result = null; //判斷接收到的字節數組是否是壓縮過的 if (receivedByte[0] == Compress.FLAG_GBK_STRING_UNCOMPRESSED_BYTEARRAY) { result = new String(receivedByte, 1, receivedByte.length - 1, EXCHANGE_ENCODING); } else if (receivedByte[0] == Compress.FLAG_GBK_STRING_COMPRESSED_BYTEARRAY) { byte[] compressedByte = new byte[receivedByte.length - 1]; for (int i = 0; i < compressedByte.length; i++) { compressedByte[i] = receivedByte[i + 1]; } byte[] resultByte = Compress.byteDecompress(compressedByte); result = new String(resultByte, EXCHANGE_ENCODING); }
這里最后得到的result就是服務器實際要發送的內容。
缺陷反思:任何設計都是有缺陷的。我這樣做已經將Http協議做了進一層封裝。Http的數據部分的第一個字節並不是實際數據,而是標識字節。這樣,降低了這個接口的可重用性。統一發送Json字符串的Action能被網頁(Ajax)或者其他客戶端使用,經過封裝壓縮之后,只有能識別這個封裝(就是能進行解析)的客戶端能使用這個接口。網頁(Ajax)就不能解析,那么這個Action就不能被Ajax使用。
具體開發過程中要視具體情況而定,如果數據量小的話我還是建議使用標准的Http協議,也就是說直接發送字符串,不做任何的壓縮和封裝。如果數據量實在過於大的話,建議使用我上述的方法。
有博友問,對於Android應用來說,什么樣的數據才算是大數據。我想這個大數據的界限並不是固定的,並不是說10k以上,或者100k以上就算是大數據,這個界限是由許多方面的利弊來衡量的。首先我要說,我設計的這個協議是適用於大數據和小數據動態切換的情況。對於大小數據界限的划定,交給開發人員去衡量利弊。這個衡量標准我想應該包括以下幾部分內容:
第一,壓縮算法的有效臨界點。只有要壓縮的數據大於這個點,壓縮后的數據才會更小,反之,壓縮后的數據會更加的大。我使用的zip算法這個點應該是50字節左右,因此,在我應用中,將大數據定義成50字節以上的數據。
第二:壓縮和解壓的開銷。服務器要壓縮數據,客戶端要解壓數據,這個都是需要CPU開銷的,特別是服務器,如果請求量大的話,需要為每一個響應數據進行壓縮,勢必降低服務器的性能。我們可以設想這樣的一種情況,原生數據只有50字節,壓縮完會有40字節,那么我們就要思考是否有必要來消耗CPU來為我們這區區的10個字節來壓縮呢?
綜上,雖然這個協議適合大小數據動態切換的數據傳輸,但是合理的選擇大數據和小數據的分割點(定義多少大的數據要壓縮,定義多少以下的數據不需要壓縮)是需要好好權衡的。
PS:筆者正在求職中,如果有意向的可以聯系 answer1991.chen@gmail.com 。非誠勿擾。