同樣是使用Java語言凯肋,為什么做MobileAPI的開發(fā)人員寫不了Android程序,反之亦然汽馋。我想大概是各行有各行的規(guī)矩和做事法則侮东,本章介紹的這幾種Android經(jīng)典場景就是如此,看似都是些平談無奇的UI惭蟋,但其中卻蘊(yùn)藏著大智慧苗桂。
閑話少敘,且聽我一一道來告组。
3.1 App圖片緩存設(shè)計(jì)
App緩存分為兩部分煤伟,數(shù)據(jù)緩存和圖片緩存。我們在第2章的2.2節(jié)介紹了App數(shù)據(jù)緩存木缝,從而把從Mo-bileAPI獲取到的數(shù)據(jù)緩存到本地便锨,減少了調(diào)用MobileAPI的次數(shù)。本節(jié)將介紹圖片緩存策略我碟。
3.1.1 ImageLoader設(shè)計(jì)原理
Android上最讓人頭疼的莫過于從網(wǎng)絡(luò)獲取圖片放案、顯示、回收矫俺,任何一個(gè)環(huán)節(jié)有問題都可能直接OOM吱殉。尤其是在列表頁,會加載大量網(wǎng)絡(luò)上的圖片厘托,每當(dāng)快速劃動(dòng)列表的時(shí)候友雳,都會很卡,甚至?xí)驗(yàn)閮?nèi)存溢出而崩潰铅匹。
這時(shí)就輪到ImageLoader上場表演了押赊。ImageLoader的目的是為了實(shí)現(xiàn)異步的網(wǎng)絡(luò)圖片加載、緩存及顯示包斑,支持多線程異步加載流礁。
ImageLoader的工作原理是這樣的:在顯示圖片的時(shí)候,它會先在內(nèi)存中查找罗丰;如果沒有神帅,就去本地查找;如果還沒有萌抵,就開一個(gè)新的線程去下載這張圖片茎活,下載成功會把圖片同時(shí)緩存到內(nèi)存和本地。
基于這個(gè)原理棋恼,我們可以在每次退出一個(gè)頁面的時(shí)候,把ImageLoader內(nèi)存中的緩存全都清除凹联,這樣就節(jié)省了大量內(nèi)存,反正下次再用到的時(shí)候從本地再取出來就是了哆档。
此外蔽挠,由于ImageLoader對圖片是軟引用的形式,所以內(nèi)存中的圖片會在內(nèi)存不足的時(shí)候被系統(tǒng)回收(內(nèi)存足夠的時(shí)候不會對其進(jìn)行垃圾回收)瓜浸。
3.1.2 ImageLoader的使用
ImageLoader由三大組件組成:
- ImageLoaderConfiguration——對圖片緩存進(jìn)行總體配置澳淑,包括內(nèi)存緩存的大小、本地緩存的大小和位置插佛、日志杠巡、下載策略(FIFO還是LIFO)等等。
- ImageLoader——我們一般使用displayImage來把URL對應(yīng)的圖片顯示在ImageView上雇寇。
- DisplayImageOptions——在每個(gè)頁面需要顯示圖片的地方氢拥,控制如何顯示的細(xì)節(jié),比如指定下載時(shí)的默認(rèn)圖(包括下載中锨侯、下載失敗嫩海、URL為空等),是否將緩存放到內(nèi)存或者本地磁盤囚痴。
借用博客園上陳哈哈的博文對三者關(guān)系的一個(gè)比喻叁怪,“他們有點(diǎn)像廚房規(guī)定、廚師深滚、客戶個(gè)人口味之間的關(guān)系奕谭。Im-ageLoaderConfiguration就像是廚房里面的規(guī)定,每一個(gè)廚師要怎么著裝痴荐,要怎么保持廚房的干凈展箱,這是針對每一個(gè)廚師都適用的規(guī)定,而且不允許個(gè)性化改變蹬昌。ImageLoader就像是具體做菜的廚師,負(fù)責(zé)具體菜譜的制作攀隔。DisplayImageOptions就像每個(gè)客戶的偏好皂贩,根據(jù)客戶是重口味還是清淡,每一個(gè)ImageLoader根據(jù)DisplayImageOptions的要求具體執(zhí)行昆汹∶魉ⅲ”
下面我們介紹如何使用ImageView:
- 在YoungHeartApplication中總體配置ImageLoader:
public class YoungHeartApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
CacheManager.getInstance().initCacheDir();
ImageLoaderConfiguration config = new ImageLoaderConfiguration.
Builder(getApplicationContext()).
threadPriority(Thread.NORM_PRIORITY - 2).
memoryCacheExtraOptions(480, 480).
memoryCacheSize(2 * 1024 * 1024).
denyCacheImageMultipleSizesInMemory().
discCacheFileNameGenerator(new Md5FileNameGenerator()).
tasksProcessingOrder(QueueProcessingType.LIFO).
memoryCache(new WeakMemoryCache()).build();
ImageLoader.getInstance().init(config);
}
}
- 在使用ImageView加載圖片的地方,配置當(dāng)前頁面的ImageLoader選項(xiàng)满粗。有可能是Activity辈末,也有可能是Adapter:
public CinemaAdapter(ArrayList<CinemaBean> cinemaList, AppBaseActivity context) {
this.cinemaList = cinemaList;
this.context = context;
options = new DisplayImageOptions.Builder().
showStubImage(R.drawable.ic_launcher).
showImageForEmptyUri(R.drawable.ic_launcher).
cacheInMemory().cacheOnDisc().
build();
}
- 在使用ImageView加載圖片的地方,使用ImageLoader,代碼片段節(jié)選自CinemaAdapter:
context.imageLoader.displayImage(cinemaList.get(position)
.getCinemaPhotoUrl(), holder.imgPhoto);
其中displayImage方法的第一個(gè)參數(shù)是圖片的URL挤聘,第二個(gè)參數(shù)是ImageView控件轰枝。
一般來說,ImageLoader性能如果有問題组去,就和這里的配置有關(guān)鞍陨,尤其是ImageLoader-Configuration。我列舉在上面的配置代碼是目前比較通用的从隆,請大家參考诚撵。
3.1.3 ImageLoader優(yōu)化
盡管ImageLoader很強(qiáng)大,但一直把圖片緩存在內(nèi)存中键闺,會導(dǎo)致內(nèi)存占用過高寿烟。雖然對圖片的引用是軟引用,軟引用在內(nèi)存不夠的時(shí)候會被GC辛燥,但我們還是希望減少GC的次數(shù)筛武,所以要經(jīng)常手動(dòng)清理ImageLoader中的緩存。
我們在AppBaseActivity中的onDestroy方法中购桑,執(zhí)行Im-ageLoader的clearMemoryCache方法畅铭,以確保頁面銷毀時(shí),把為了顯示這個(gè)頁面而增加的內(nèi)存緩存清除勃蜘。這樣硕噩,即使到了下個(gè)頁面要復(fù)用之前加載過的圖片,雖然內(nèi)存中沒有了缭贡,根據(jù)Im-ageLoader的緩存策略炉擅,還是可以在本地磁盤上找到:
public abstract class AppBaseActivity extends BaseActivity {
protected boolean needCallback;
protected ProgressDialog dlg;
public ImageLoader imageLoader = ImageLoader.getInstance();
protected void onDestroy() {// 回收該頁面緩存在內(nèi)存的圖片
imageLoader.clearMemoryCache();
super.onDestroy();
}
}
本章沒有過多討論ImageLoader的代碼實(shí)現(xiàn),只是描述了它的實(shí)現(xiàn)原理阳惹。有興趣的朋友可以參考下列文章谍失,里面有很深入的研究:
簡介ImageLoader。地址:http://blog.csdn.net/yueqinglkong/article/de-tails/27660107
Android-Universal-Image-Loader圖片異步加載類庫的使用(超詳細(xì)配置)莹汤。地址:http://blog.csdn.net/vipzjyno1/article/de-tails/23206387
Android開源框架Universal-Image-Loader完全解析快鱼。地址:http://blog.csdn.net/xiaanming/article/de-tails/39057201
3.1.4 圖片加載利器Fresco
就在本書寫作期間,F(xiàn)acebook開源了它的Android圖片加載組件Fresco纲岭。
我之所以關(guān)注這個(gè)Fresco組件抹竹,是因?yàn)槲邑?fù)責(zé)的App用一段時(shí)間后就占據(jù)了180M左右的內(nèi)存,App會變得很卡止潮。我們使用MAT分析內(nèi)存窃判,發(fā)現(xiàn)讓內(nèi)存居高不下的罪魁禍?zhǔn)拙褪菆D片。于是我們把目光轉(zhuǎn)向Fresco喇闸,開始優(yōu)化App占用的內(nèi)存袄琳。
Fresco使用起來很簡單询件,如下所示:
- 在Application級別,對Fresco進(jìn)行初始化唆樊,如下所示:Fresco.initialize(getApplicationContext());
- 與ImageLoader等傳統(tǒng)第三方圖片處理SDK不同宛琅,F(xiàn)resco是基于控件級別的,所以我們把程序中顯示網(wǎng)絡(luò)圖片的Im-ageView都替換為SimpleDraweeView即可窗轩,并在Im-ageView所在的布局文件中添加fresco命名空間夯秃,如下所示:
- 在Activity中為這個(gè)圖片控件指定要顯示的網(wǎng)絡(luò)圖片:
Uri uri = Uri.parse("http:// www.bb.com/a.png");draweeView.setImageURI(uri);
Fresco的原理是,設(shè)計(jì)了一個(gè)Image Pipeline的概念痢艺,它負(fù)責(zé)先后檢查內(nèi)存仓洼、磁盤文件(Disk),如果都沒有再老老實(shí)實(shí)從網(wǎng)絡(luò)下載圖片堤舒,如圖3-1所示色建,箭頭上標(biāo)記了jpg或bmp格式的,表示Cache中有圖片舌缤,直接取出箕戳;沒有標(biāo)記,則表示Cache中找不到国撵。
我們可以像配置ImageLoader那樣配置Fresco中的ImagePipeline陵吸,使用ImagePipelineConfig來做這個(gè)事情。
Fresco有3個(gè)線程池介牙,其中3個(gè)線程用于網(wǎng)絡(luò)下載圖片壮虫,2個(gè)線程用于磁盤文件的讀寫,還有2個(gè)線程用于CPU相關(guān)操作环础,比如圖片解碼囚似、轉(zhuǎn)換,以及放在后臺執(zhí)行的一些費(fèi)時(shí)操作线得。
接下來介紹Fresco三層緩存的概念饶唤。這才是Fresco最核心的技術(shù),它比其他圖片SDK吃內(nèi)存小贯钩,就在于這個(gè)全新的緩存設(shè)計(jì)募狂。
第一層:Bitmap緩存
- 在Android 5.0系統(tǒng)中,考慮到內(nèi)存管理有了很大改進(jìn)角雷,所以Bitmap緩存位于Java的堆(heap)中祸穷。
- 而在Android 4.x和更低的系統(tǒng),Bitmap緩存位于ash-mem中谓罗,而不是位于Java的堆(heap)中。這意味著圖片的創(chuàng)建和回收不會引發(fā)過多的GC季二,從而讓App運(yùn)行得更快檩咱。
當(dāng)App切換到后臺時(shí)揭措,Bitmap緩存會被清空。
第二層:內(nèi)存緩存
內(nèi)存緩存中存儲了圖片的原始壓縮格式刻蚯。從內(nèi)存緩存中取出的圖片绊含,在顯示前必須先解碼。當(dāng)App切換到后臺時(shí)炊汹,內(nèi)存緩存也會被清空躬充。
第三層:磁盤緩存
磁盤緩存,又名本地存儲讨便。磁盤緩存中存儲的也是圖片的原始壓縮格式充甚。在使用前也要先解碼。當(dāng)App切換到后臺時(shí)霸褒,磁盤緩存不會丟失伴找,即使關(guān)機(jī)也不會。
Fresco有很多高級的應(yīng)用废菱,對于大部分App而言技矮,基本還用不到。只要掌握上述簡單的使用方法就能極大地節(jié)省內(nèi)存了殊轴。我做的App原先占用180MB的內(nèi)存衰倦,現(xiàn)在只會占據(jù)80MB左右的內(nèi)存了。這也是我為什么要在本書中增加這一部分內(nèi)容的原因旁理。
關(guān)于Fresco的更多介紹請參見:
- Fresco在GitHub上的源碼:https://github.com/mkottman/AndroLua
- Fresco官方文檔:http://fresco-cn.org/docs/index.html
3.2 對網(wǎng)絡(luò)流量進(jìn)行優(yōu)化
對App的最低容忍限度是樊零,在2G、3G和4G網(wǎng)絡(luò)環(huán)境下韧拒,每個(gè)頁面都能打開淹接,都能正常跳轉(zhuǎn)到其他頁面。要能夠完成一次完整的支付流程叛溢。
慢點(diǎn)兒沒關(guān)系塑悼,尤其是2G網(wǎng)絡(luò)。但是動(dòng)不動(dòng)就彈出“無法連接到網(wǎng)絡(luò)”或者“網(wǎng)絡(luò)連接超時(shí)”的對話框楷掉,就是我們開發(fā)人員必須要解決的問題了厢蒜。
3.2.1 通信層面的優(yōu)化
讓我們先從MobileAPI層面進(jìn)行優(yōu)化:
MobileAPI接口返回的數(shù)據(jù),要使用gzip進(jìn)行壓縮烹植。注意:大于1KB才進(jìn)行壓縮斑鸦,否則得不償失。經(jīng)過gzip壓縮后草雕,返回的數(shù)據(jù)量大幅減少巷屿。
App與MobileAPI之間的數(shù)據(jù)傳遞,通常是遵守JSON協(xié)議的墩虹。JSON因?yàn)槭莤ml格式的嘱巾,并且是以字符存在的憨琳,在數(shù)據(jù)量上還有可以壓縮的空間。我這里推薦一種新的數(shù)據(jù)傳輸協(xié)議旬昭,那就是ProtoBuffer篙螟。這種協(xié)議是二進(jìn)制格式的,所以在表示大數(shù)據(jù)時(shí)问拘,空間比JSON小很多遍略。
接下來要解決的是頻繁調(diào)用MobileAPI的問題。我們知道骤坐,發(fā)起一次網(wǎng)絡(luò)請求绪杏,服務(wù)器處理的速度是很快的,主要花費(fèi)的時(shí)間在數(shù)據(jù)傳輸上或油,也就是這一來一回走路的時(shí)間上寞忿。
走路時(shí)間的長度,網(wǎng)絡(luò)運(yùn)維人員會去負(fù)責(zé)解決顶岸。移動(dòng)開發(fā)人員需要關(guān)注的是腔彰,減少網(wǎng)絡(luò)訪問次數(shù),能調(diào)用一次MobileAPI接口就能取到數(shù)據(jù)的辖佣,就不要調(diào)用兩次霹抛。我們知道,傳統(tǒng)的MobileAPI使用的是HTTP無狀態(tài)短連接卷谈。使用HTTP協(xié)議的速度遠(yuǎn)不如使用TCP協(xié)議杯拐,因?yàn)楹笳呤情L連接。所以我們可以使用TCP長連接世蔗,以提高訪問的速度端逼。缺點(diǎn)是一臺服務(wù)器能支持的長連接個(gè)數(shù)不多,所以需要更多的服務(wù)器集成污淋。
要建立取消網(wǎng)絡(luò)請求的機(jī)制顶滩。一個(gè)頁面如果沒有請求完網(wǎng)絡(luò)數(shù)據(jù),在跳轉(zhuǎn)到另一個(gè)頁面之前寸爆,要把之前的網(wǎng)絡(luò)請求都取消礁鲁,不再等待,也不再接收數(shù)據(jù)赁豆。
我遇到過一個(gè)真實(shí)的例子仅醇,首頁要在后臺調(diào)用十幾個(gè)MobileAPI接口,用戶一旦進(jìn)入二級頁面魔种,在二級頁面獲取列表數(shù)據(jù)時(shí)析二,經(jīng)常會取不到數(shù)據(jù),并彈出“網(wǎng)絡(luò)請求超時(shí)”的提示节预。我們通過在App輸出log的方式發(fā)現(xiàn)叶摄,二級頁面還在調(diào)用首頁沒有完成的那些MobileAPI接口漆改,App網(wǎng)絡(luò)底層的請求隊(duì)列已經(jīng)被阻塞了,原因是在進(jìn)入下一個(gè)頁面時(shí)准谚,首頁發(fā)起的網(wǎng)絡(luò)請求仍然存在于網(wǎng)絡(luò)請求隊(duì)列中,并沒有移除掉去扣。
無論是iOS還是Android柱衔,都應(yīng)該在基類(BaseViewCon-troller或者BaseActivity)中提供一個(gè)cancelRequest的方法,用以在離開當(dāng)前頁面時(shí)清空網(wǎng)絡(luò)請求隊(duì)列愉棱。增加重試機(jī)制唆铐。如果MobileAPI是嚴(yán)格的RESTful風(fēng)格,那么我們一般將獲取數(shù)據(jù)的請求接口都定義為get奔滑;而把操作數(shù)據(jù)的請求接口都定義為post艾岂。
這樣的話,我們就可以為所有的get請求配置重試機(jī)制朋其,比如get請求失敗后重試3次王浴。
有人會問post請求失敗后,是否需要重試呢梅猿?我們舉個(gè)例子吧氓辣,比如說下單接口是個(gè)post請求,如果請求失敗那么就會重試3次袱蚓,直到下單成功钞啸。但是有時(shí)候post請求并沒有失敗,而是超時(shí)了喇潘,超時(shí)時(shí)間是30秒体斩,但是卻31秒返回了,如果因此而重新發(fā)起下單請求颖低,那么就會連續(xù)下單兩次絮吵。所以post請求是不建議有重試機(jī)制的。此外枫甲,對所有的post請求源武,都要增加防止用戶1分鐘內(nèi)頻繁發(fā)起相同請求的機(jī)制,這樣就能有效防止重復(fù)下單想幻、重復(fù)發(fā)表評論粱栖、重復(fù)注冊等操作。
如果post請求具有防重機(jī)制脏毯,那么倒是可以增加重試機(jī)制闹究。但是要可以在服務(wù)器端靈活配置重試的次數(shù),可以是0次食店,意味著不會重試渣淤。在App啟動(dòng)的時(shí)候赏寇,告訴App所有的MobileAPI接口的重試次數(shù)。
3.2.2 圖片策略優(yōu)化
首先价认,我們從圖片層面進(jìn)行優(yōu)化嗅定,這里說的圖片,是根據(jù)MobileAPI返回的圖片URL地址新啟一個(gè)線程下載到App本地并顯示的用踩。很多App崩潰的原因就是圖片的問題沒處理好渠退。
1. 要確保下載的每張圖,都符合ImageView控件的大小
這對于Android是有難度的脐彩,因?yàn)槭謾C(jī)分辨率千奇百怪碎乃,所以App中的圖片,我們大多做成自適應(yīng)的惠奸,有時(shí)是等比拉伸或縮放圖片的寬和高梅誓,有時(shí)則固定高度而動(dòng)態(tài)伸縮寬度,反之亦然佛南。
于是我們要求運(yùn)營人員要事先準(zhǔn)備很多套不同分辨率的圖片梗掰。我們每次根據(jù)URL請求圖片時(shí),都要額外在URL上加上兩個(gè)參數(shù)嗅回,width和height愧怜,從而要求服務(wù)器返回其中某一張圖,URL如下所示:http://www.aaa.com/a.png?width=100&height=50妈拌。
如果認(rèn)為每次準(zhǔn)備很多套圖片是件很浪費(fèi)人力的事情拥坛,我還有另一種解決方案,這種方案只需要一張圖尘分。但我們需要事先準(zhǔn)備一臺服務(wù)器猜惋,稱為ImageServer。具體流程是這樣的:
- 首先培愁,App每次加載圖片著摔,都會把URL地址以及width和height參數(shù)所組成的字符串進(jìn)行encode,然后發(fā)送給Image-Server定续,新的URL如下所示:
http://www.ImageServer.com/getImage?param=(encodevalue) - 然后谍咆,ImageServer收到這個(gè)請求,會把param的值de-code私股,得到原始圖片的URL摹察,以及App想要顯示的這張圖片的width和height。ImageServer會根據(jù)URL獲取到這張?jiān)紙D片倡鲸,然后根據(jù)width和height供嚎,重新進(jìn)行繪制,保存到Image-Server上,并返回給App克滴。
- 最后逼争,App請求到的是一張符合其顯示大小的圖片。
接下來收到同樣的請求劝赔,直接返回ImageServer上保存的那種圖片即可誓焦。但是要每天清一次硬盤,不然過不了幾天硬盤就滿了着帽。
如果width和height的比例與原圖的寬高比不一致呢罩阵?我們需要再加一個(gè)參數(shù)imagetype,以下是定義:
- 1表示等比縮放后启摄,裁減掉多余的寬或者高。
- 2表示等比縮放后幽钢,不足的寬或者高填充白色歉备。
當(dāng)然你也可以定義0表示不進(jìn)行縮放,直接返回匪燕。
這種方案的缺點(diǎn)就是蕾羊,ImageServer頻繁地寫硬盤,硬盤堅(jiān)持不到兩周就壞掉帽驯。所以龟再,我們在損失了幾塊硬盤后,決定事先規(guī)定幾套width和height尼变,App必須嚴(yán)格遵守利凑,比如說100×50,200×100嫌术,那么就不允許向服務(wù)器發(fā)送類似99×51這樣的圖片尺寸哀澈。
但這樣規(guī)定,并不能防止App開發(fā)人員犯錯(cuò)度气,他在UI上就是不小心為某個(gè)ImageView控件指定了99×51這樣的尺寸割按,那么ImageServer還是會生成這樣的圖片。
唯一的辦法就是在出口加以控制磷籍,也就是向ImageServer發(fā)起請求的時(shí)候适荣。我們會拿99×51這個(gè)實(shí)際的圖片尺寸,去輪詢我們事先規(guī)定好的那幾個(gè)尺寸100×50和200×100院领,看更接近哪個(gè)弛矛,比如說99×51更接近100×50,那么就向ImageServer請求100×50這種尺寸的圖片比然。
找最接近圖片尺寸的辦法是面積法:
S = (w1-a) × (w1-w) + (h1-h) × (h1-h)
w和h是實(shí)際的圖片寬和高汪诉,w1和h1是事先規(guī)定的某個(gè)尺寸的寬和高。S最小的那個(gè),就是最接近的扒寄。
2. 低流量模式
在2G和3G網(wǎng)絡(luò)環(huán)境下鱼鼓,我們應(yīng)該適當(dāng)降低圖片的質(zhì)量。降低圖片質(zhì)量该编,相應(yīng)的圖片大小也會降低迄本,我們稱為低流量模式。
還記得我們前面提到的ImageServer嗎课竣?我們可以在URL中再增加一個(gè)參數(shù)quality嘉赎,2G網(wǎng)絡(luò)下這個(gè)值為50%,3G網(wǎng)絡(luò)下這個(gè)值為70%于樟,我們把這個(gè)參數(shù)傳遞給ImageServer路捧,從而Im-ageServer在繪制圖片時(shí)凡伊,就會將jpg圖片質(zhì)量降低為50%或70%见剩,這樣返回給App的數(shù)據(jù)量就大大減少了歉铝。
在列表頁宴霸,這種效果最為明顯,能極大的節(jié)省用戶流量岸裙。
3. 極速模式
我們后來發(fā)現(xiàn),在2G和3G網(wǎng)絡(luò)環(huán)境下速缆,用戶大多對圖片不感興趣降允,他們可能就是想快速下單并支付,我們需要額外設(shè)計(jì)一些頁面艺糜,區(qū)別于正常模式下圖文并茂的頁面,我們將這些只有文字的頁面稱為極速模式。
比如米同,首頁往往圖片占據(jù)多數(shù)乎赴,而且這些圖片大多數(shù)從網(wǎng)絡(luò)動(dòng)態(tài)下載的,在2G網(wǎng)絡(luò)下真慢,這些圖片是很浪費(fèi)流量的毅臊。所以在極速模式下,我們需要設(shè)計(jì)一個(gè)只有純文字的首頁黑界。
在每次開啟App進(jìn)入首頁前會先進(jìn)行預(yù)判管嬉,如果發(fā)現(xiàn)當(dāng)前網(wǎng)絡(luò)環(huán)境為2G、3G或4G朗鸠,但是當(dāng)前模式為正常模式蚯撩,就會彈出一個(gè)對話框詢問用戶,是否要進(jìn)入極速模式以節(jié)省流量烛占。如果是WiFi網(wǎng)絡(luò)環(huán)境胎挎,但當(dāng)前模式是極速模式,也會提示用戶是否要切換回正常模式,以看到最炫的效果犹菇。
僅在開啟App時(shí)提示用戶極速模式是不夠的德迹,我們在設(shè)置頁也要提供這個(gè)開關(guān),供用戶手動(dòng)切換项栏。
3.3 城市列表的設(shè)計(jì)
很多App都有城市列表這一功能浦辨。看似簡單沼沈,但就像登錄功能一樣流酬,做好它并不容易。
一份城市列表的數(shù)據(jù)包括以下幾個(gè)字典:
- cityId:城市Id列另。
- cityName:城市名稱芽腾。
- pinyin:城市全拼。
- jianpin:城市簡拼页衙。
其中摊滔,全拼和簡拼是用來在App本地做字母表排序和關(guān)鍵字檢索的。
我曾經(jīng)經(jīng)歷過把城市列表數(shù)據(jù)寫死在本地文件的做法店乐,日積月累艰躺,就會產(chǎn)生兩個(gè)問題:
- Android和iOS維護(hù)的數(shù)據(jù),差異會越來越大眨八。
- 一千多個(gè)城市腺兴,每次從本地加載都要很長時(shí)間。
針對問題1的解決辦法是廉侧,寫一個(gè)文本分析工具页响,找出An-droid和iOS各自維護(hù)文件的不同數(shù)據(jù)。
iOS開發(fā)人員喜歡使用plist文件作為數(shù)據(jù)存儲的載體段誊,最好能和Android統(tǒng)一使用一份xml文件闰蚕,這樣便于管理類似城市列表這樣的數(shù)據(jù)。
針對問題2的解決方案是连舍,對于一千多個(gè)城市没陡,意味著每次都要解析xml城市數(shù)據(jù)文件,既然每次讀取數(shù)據(jù)都很慢索赏,那么我們干脆就把序列化過的城市列表直接保存到本地文件诗鸭,跟隨App一起發(fā)布。這樣参滴,每次讀取這個(gè)文件時(shí)强岸,就直接進(jìn)行反序列化即可,速度得到很大提升砾赔。
把城市列表數(shù)據(jù)保存在本地蝌箍,有個(gè)很煩的事情青灼,就是每次增加新的城市,都要等下次發(fā)版妓盲,因?yàn)閿?shù)據(jù)是寫死在App本地的杂拨。于是,我們把城市列表數(shù)據(jù)做成一個(gè)MobileAPI接口悯衬,由MobileAPI去后臺采集數(shù)據(jù)弹沽,這樣數(shù)據(jù)是最新最準(zhǔn)的。
但是這樣做的問題是筋粗,這個(gè)MobileAPI接口返回的數(shù)據(jù)量會很大策橘,上千筆數(shù)據(jù),還包括那么多字段娜亿,即使打開了gzip壓縮丽已,也會有100k的樣子。于是我們又增加了版本號字段version的概念买决,這個(gè)MobileAPI接口的定義和返回的JSON格式是這樣的:
- 入?yún)⑴嬗ぁersion,本地存儲的城市列表數(shù)據(jù)對應(yīng)的版本號督赤。
- 返回值嘁灯。如果傳入?yún)?shù)version和線上最新版本號一致,則返回以下固定格式:
{
"isMatch": false,
"version": 1,
"cities": [
{
},
]
}
如果傳入?yún)?shù)version和線上最新版本號不一致躲舌,則返回以下格式:
{
"isMatch":false,
"version":1,
"cities": [
{
"cityId":1,
"cityName":"北京",
"pinyin":"beijing",
"jianpin":"bj"
},
{
"cityId":2,
"cityName":"上海",
"pinyin":"shanghai",
"jianpin":"sh"
},
{
"cityId":3,
"cityName":"平頂山",
"pinyin":"pingdingshan",
"jianpin":"pds"
}
]
}
version這個(gè)字段由MobileAPI進(jìn)行更新丑婿,每當(dāng)有城市數(shù)據(jù)更新時(shí),version可以立即自增+1孽糖,也可以積累到一定數(shù)據(jù)后自增+1枯冈。具體策略由MobileAPI來決定毅贮。
基于此办悟,App的策略可以是這樣的:
- 本地仍然保存一份線上最新的城市列表數(shù)據(jù)(序列化后的)以及對應(yīng)的版本號。我們要求每次發(fā)版前做一次城市數(shù)據(jù)同步的事情滩褥。
- 每次進(jìn)入到城市列表這個(gè)頁面時(shí)病蛉,將本地城市列表數(shù)據(jù)對應(yīng)的版本號version傳入到MobileAPI接口,根據(jù)返回的is-Match值來決定是否版本號一致瑰煎。如果一致铺然,則直接從本地文件中加載城市列表數(shù)據(jù);否則酒甸,就解析MobileAPI接口返回的數(shù)據(jù)魄健,在顯示列表的同時(shí),記得要把最新的城市列表數(shù)據(jù)和版本號保存到本地插勤。
- 如果MobileAPI接口沒有調(diào)用成功沽瘦,也是直接從本地文件中加載城市列表數(shù)據(jù)革骨,以確保主流程是暢通的。
- 每次調(diào)用MobileAPI時(shí)析恋,會獲取到大量的數(shù)據(jù)良哲,一般我們會打開gzip對數(shù)據(jù)進(jìn)行壓縮,以確保傳輸?shù)臄?shù)據(jù)量最小助隧。
3.3.2 城市列表數(shù)據(jù)的增量更新機(jī)制
上節(jié)中我們談到筑凫,每當(dāng)有城市數(shù)據(jù)更新時(shí),version可以立即自增+1并村。我的問題是巍实,如何判斷有城市數(shù)據(jù)更新?一種解決方案是橘霎,在服務(wù)器建立一個(gè)Timer蔫浆,每十分鐘跑一次,檢查10分鐘前后的數(shù)據(jù)是否有改動(dòng)姐叁,如果有瓦盛,version就自增+1,并返回這些有改動(dòng)的數(shù)據(jù)(新增外潜、刪除和修改)原环。這樣就保證了10分鐘內(nèi),從A改成B又改回A处窥,這時(shí)候我們認(rèn)為是沒有改動(dòng)的嘱吗,版本號不需要自增+1。
那么問題來了滔驾,對于1000筆城市數(shù)據(jù)谒麦,每次只改動(dòng)其中的幾筆,返回?cái)?shù)據(jù)中包括那些沒有改動(dòng)過的數(shù)據(jù)是沒有意義的哆致,是否可以只返回這些改動(dòng)的數(shù)據(jù)绕德?
分析1.0和2.0版本的城市列表數(shù)據(jù),每筆數(shù)據(jù)都有cityId和其他一些字段摊阀,比如說城市名稱耻蛇、簡拼、全拼等胞此。我畫了一個(gè)表臣咖,如圖3-2所示,試圖展示出1.0和2.0這兩個(gè)版本的城市數(shù)據(jù)之間的異同漱牵。
我來解釋一下圖3-2夺蛇,以cityId作為唯一標(biāo)識,只在1.0中出現(xiàn)的cityId是要?jiǎng)h除的數(shù)據(jù)酣胀,只在2.0中出現(xiàn)的cityId是要增加的數(shù)據(jù)刁赦,二者的交集則是cityId相同的數(shù)據(jù)愿卸,這又分為兩種情況,所有字段都相同的數(shù)據(jù)是不變的數(shù)據(jù)截型;cityId相同但某個(gè)字段不相同趴荸,則是修改的數(shù)據(jù)。
增量更新的數(shù)據(jù)宦焦,就由增发钝、刪、改這3部分?jǐn)?shù)據(jù)構(gòu)成波闹。于是酝豪,我們可以重新定義城市列表的JSON格式,在每筆增量數(shù)據(jù)中增加一個(gè)字段type精堕,用來區(qū)別是增(c)孵淘、刪(d)、改(u)中的哪種情況歹篓,如下所示:
{
"isMatch":false,
"version":1,
"cities": [
{
"cityId":1,
"cityName":"北京",
"pinyin":"beijing",
"jianpin":"bj",
"type":"d"
},
{
"cityId":2,
"cityName":"上海",
"pinyin":"shanghai",
"jianpin":"sh",
"type":"c"
},
{
"cityId":3,
"cityName":"平頂山",
"pinyin":"pingdingshan",
"jianpin":"pds",
"type":"u"
}
]
}
客戶端在收到上述格式JSON數(shù)據(jù)后瘫证,會根據(jù)type值來處理存放在本地的數(shù)據(jù)。因?yàn)椴皇侨扛伦椋蕴幚砥饋砗芸毂嘲啤_@種增量更新城市數(shù)據(jù)的策略,會使得App的邏輯很簡單洞斯,但是服務(wù)器的邏輯很復(fù)雜毡庆。這樣做是劃算的,我們要想盡辦法確保App的輕量烙如,把復(fù)雜的業(yè)務(wù)邏輯放在后端么抗。
3.4 App與HTML5的交互
App與HTML5的交互,是一個(gè)可以大做文章的話題亚铁。有的團(tuán)隊(duì)直接使用PhoneGap來實(shí)現(xiàn)交互的功能蝇刀,而我則認(rèn)為PhoneGap太重了。我們完全可以把這些交互操作在底層封裝好刀闷,然后給開發(fā)人員使用熊泵。
為了開發(fā)人員方便仰迁,我們要準(zhǔn)備一臺測試用的PC服務(wù)器甸昏,在上面搭建一個(gè)IIS,這樣可以快速搭建自己的Demo徐许,對于App開發(fā)人員而言施蜜,不需要等待HTML5團(tuán)隊(duì)就可以自行開發(fā)并測試了。他們只需知道一些基本的Html和JavaScript語法雌隅,而相應(yīng)的培訓(xùn)非常簡單翻默。
3.4.1 App操作HTML5頁面的方法
為了演示方便缸沃,我在assets中內(nèi)置了一個(gè)HTML5頁面。現(xiàn)實(shí)中修械,這個(gè)HTML5頁面是放在遠(yuǎn)程服務(wù)器上的趾牧。
首先要定好通信協(xié)議,也就是App要調(diào)用的HTML5頁面中JavaScript的方法名稱肯污。
例如翘单,App要調(diào)用HTML5頁面的changeColor(color)方法,改變HTML5頁面的背景顏色蹦渣。
- HTML5
<script type="text/javascript">
function changeColor (color) {
document.body.style.backgroundColor = color;
}
</script>
- Android
wvAds.getSettings().setJavaScriptEnabled(true);
wvAds.loadUrl("file:// /android_asset/104.html");
btnShowAlert.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick (View v) {
String color = "#00ee00";
wvAds.loadUrl("javascript: changeColor ('" + color + "');");
}
});
3.4.2 HTML5頁面操作App頁面的方法
仍然是先定義通信協(xié)議哄芜,這次定義的是JavaScript要調(diào)用的Android中方法名稱。
例如柬唯,點(diǎn)擊HTML5的文字认臊,回調(diào)Java中的callAndroid-Method方法:
- HTML5
<a onclick="baobao.callAndroidMethod(100,100,'ccc',true)">
CallAndroidMethod</a>
- Android
新創(chuàng)建一個(gè)JSInterface1類,包括callAndroidMethod方法的實(shí)現(xiàn):
class JSInteface1 {
public void callAndroidMethod(int a, float b, String c, boolean d) {
if (d) {
String strMessage = "-" + (a + 1) + "-" + (b + 1) + "-" + c + "-" + d;
new AlertDialog.Builder(MainActivity.this).setTitle("title").setMessage(strMessage).show();
}
}
}
同時(shí)锄奢,需要注冊baobao和JSInterface1的對應(yīng)關(guān)系:
wvAds.addJavascriptInterface(new JSInteface1(), "baobao");
調(diào)試期間我發(fā)現(xiàn)對于小米3系統(tǒng)失晴,要在方法前增加@JavascriptInterface,否則拘央,就不能觸發(fā)JavaScript方法师坎。
3.4.3 App和HTML5之間定義跳轉(zhuǎn)協(xié)議
根據(jù)上面的例子,運(yùn)營團(tuán)隊(duì)就找到了在App中搞活動(dòng)的解決方案堪滨。不必等到App每次發(fā)新版才能看到新的活動(dòng)頁面胯陋,而是每次做一個(gè)HTML5的活動(dòng)頁面,然后通過MobileAPI把這個(gè)HTML5頁面的地址告訴App袱箱,由App加載這個(gè)HTML5頁面即可遏乔。
在這個(gè)HTML5頁面中,我們可以定義各種JavaScript點(diǎn)擊事件发笔,從而跳轉(zhuǎn)回App的任意Native頁面盟萨。
為此,HTML5團(tuán)隊(duì)需要事先和App團(tuán)隊(duì)約定好一個(gè)格式了讨,例如:
gotoPersonCenter
gotoMovieDetail:movieId=100
gotoNewsList:cityId=1&cityName=北京
gotoUrl:http://www.sina.com
這個(gè)協(xié)議具體在HTML5頁面中是這樣的捻激,以gotoNewsList為例:
<a onclick="baobao.gotoAnyWhere(
'gotoNewsList:cityId=(int)12&cityName=北京')">
gotoAnyWhere</a>
其中,有些協(xié)議是不需要參數(shù)的前计,比如說gotoPersonCen-ter胞谭,也就是個(gè)人中心;有些則需要跳轉(zhuǎn)到具體的電影詳情頁男杈,我們需要知道m(xù)ovieId丈屹;有時(shí)候1個(gè)參數(shù)不夠用,我們需要更多的參數(shù)伶棒,才能準(zhǔn)確獲取到我們想要的數(shù)據(jù)旺垒,比如說gotoNewsList彩库,我們想要跳轉(zhuǎn)到2014年12月31號北京的所有新聞信息,就不得不需要cityId和createdTime兩個(gè)參數(shù)先蒋,處理協(xié)議的代碼如下所示:
public void gotoAnyWhere(String url) {
if (url != null) {
if (url.startsWith("gotoMovieDetail:")) {
String strMovieId = url.substring(24);
int movieId = Integer.valueOf(strMovieId);
Intent intent = new Intent(MainActivity.this, MovieDetailActivity.class);
intent.putExtra("movieId", movieId);
startActivity(intent);
} else if (url.startsWith("gotoNewsList:")) { // as above
} else if (url.startsWith("gotoPersonCenter")) {
Intent intent = new Intent(MainActivity.this, PersonCenterActivity.class);
startActivity(intent);
} else if (url.startsWith("gotoUrl:")) {
String strUrl = url.substring(8);
wvAds.loadUrl(strUrl);
}
}
}
這里的if分支邏輯太多骇钦,我們要想辦法將其進(jìn)行抽象,參見后面3.4.6節(jié)介紹的頁面分發(fā)器竞漾。
3.4.4 在App中內(nèi)置HTML5頁面
什么時(shí)候在App中內(nèi)置HTML5頁面司忱?根據(jù)我的經(jīng)驗(yàn),當(dāng)有些UI不太容易在App中使用原生語言實(shí)現(xiàn)時(shí)畴蹭,比如畫一個(gè)奇形怪狀的表格坦仍,這是HTML5所擅長的領(lǐng)域,只要調(diào)整好屏幕適配叨襟,就可以很好地應(yīng)用在App中繁扎。
下面詳細(xì)介紹如何在頁面中顯示一個(gè)表格,表格里的數(shù)據(jù)都是動(dòng)態(tài)填充的糊闽。
1. 首先定義兩個(gè)HTML5文件梳玫,放在assets目錄下。
其中右犹,102.html是靜態(tài)頁:
<html>
<head>
</head>
<body>
<table>
<data1DefinedByBaobao>
</table>
</body>
</html>
而data1_template.html是一個(gè)數(shù)據(jù)模板提澎,它負(fù)責(zé)提供表格中一行的樣式:
<tr>
<td>
<name>
</td>
<td>
<price>
</td>
</tr>
像<name>、<price>和<data1DefinedByBaobao>都是占位符念链,我們接下來會使用真實(shí)的數(shù)據(jù)來替換這些占位符盼忌。
2. 在MovieDetailActivity中,通過遍歷movieList這個(gè)集合掂墓,我們把數(shù)據(jù)填充到sbContent中谦纱,最終,把拼接好的字符串替換<data1DefinedByBaobao>標(biāo)簽:
String template = getFromAssets("data1_template.html");
StringBuilder sbContent = new StringBuilder();
ArrayList<MovieInfo> movieList = organizeMovieList();
for(MovieInfo movie :movieList) {
String rowData;
rowData = template.replace("<name>", movie.getName());
rowData = rowData.replace("<price>", movie.getPrice());
sbContent.append(rowData);
}
String realData = getFromAssets("102.html");
realData =realData.replace("<data1DefinedByBaobao>",sbContent.toString());
wvAds.loadData(realData,"text/html","utf-8");
3.4.5 靈活切換Native和HTML5頁面的策略
對于經(jīng)常需要改動(dòng)的頁面君编,我們會把它做成HTML5頁面跨嘉,在App中以WebView的形式加載。這樣就避免了Native頁面每次修改吃嘿,都要等一次迭代上線后才能看到——周期太長了祠乃,這不是產(chǎn)品經(jīng)理所希望的。
此外兑燥,HTML5的另一個(gè)好處是亮瓷,開發(fā)周期短——相比App開發(fā)而言。但是HTML5的缺點(diǎn)是慢贪嫂。
我們來看一下HTML5頁面生成的步驟:
- 從服務(wù)器端動(dòng)態(tài)獲取數(shù)據(jù)并拼接成一個(gè)HTML寺庄。
- 返回給客戶端WebView艾蓝。
- 在WebView中解析并生成這個(gè)HTML力崇。
相對于Native原生頁面加載JSON這種短小精悍的數(shù)據(jù)并展現(xiàn)在客戶端而言斗塘,HTML5肯定是慢了很多。魚和熊掌不可兼得亮靴,于是我們只能在靈活性和性能上作出取舍馍盟。
但是我們可以換一個(gè)思路來解決這個(gè)問題。我同時(shí)做兩套頁面茧吊,Native一套贞岭,HTML5一套,然后在App中設(shè)置一個(gè)變量搓侄,來判斷該頁面將顯示Native還是HTML5的瞄桨。
這個(gè)變量可以從MobileAPI獲取,這樣的話讶踪,正常情況下芯侥,是Native頁面,如果有類似雙十一或雙十二的促銷活動(dòng)乳讥,我們可以修改這個(gè)變量柱查,讓頁面以HTML5的形式展現(xiàn)。這樣云石,我們只要做個(gè)HTML5的頁面發(fā)布到線上就行了唉工。等活動(dòng)結(jié)束后再撤回到Native頁面。
以此類推汹忠,App中所有的頁面淋硝,都可以做成上述這種形式,為此宽菜,我們需要改變之前做App的思路奖地,比如:
- 需要做一個(gè)后臺,根據(jù)版本進(jìn)行配置每個(gè)頁面是使用Na-tive頁面還是HTML5頁面赋焕。
- 在App啟動(dòng)的時(shí)候参歹,從MobileAPI獲取到每個(gè)頁面是Native還是HTML5。
- 在App的代碼層面隆判,頁面之間要實(shí)現(xiàn)松耦合犬庇。為此,我們要設(shè)計(jì)一個(gè)導(dǎo)航器Navigator侨嘀,由它來控制該跳轉(zhuǎn)到Native頁面還是HTML5頁面臭挽。最大的挑戰(zhàn)是頁面間參數(shù)傳遞,字典是一種比較好的形式咬腕,消除了不同頁面對參數(shù)類型的不同要求欢峰。
接下來,就是App運(yùn)營人員和產(chǎn)品經(jīng)理隨心所欲的進(jìn)行配置了。
在實(shí)際的操作中纽帖,一定要注意宠漩,HTML5頁面只是權(quán)宜之計(jì),可以快速上一個(gè)活動(dòng)懊直,比如類似于雙十一的節(jié)假日扒吁,從而以迅雷不及掩耳之勢打擊競爭對手。隨著HTML5和Native的不同步室囊,當(dāng)一個(gè)頁面再從HTML5切換回Native時(shí)雕崩,我們會發(fā)現(xiàn),它們的邏輯已經(jīng)差了很多了融撞,切回來就會有很多bug盼铁,而我們又只能是在App發(fā)布后才發(fā)現(xiàn)這樣的問題。
唯一的解決方案是尝偎,把App和HTML5劃歸到一個(gè)團(tuán)隊(duì)捉貌,由產(chǎn)品經(jīng)理整理二者的差異性,要做到二者盡量同步冬念,一言以蔽之趁窃,App要時(shí)刻追趕HTML5的邏輯,追趕上了就切換回Native急前。
3.4.6 頁面分發(fā)器
我們知道醒陆,跳轉(zhuǎn)到一個(gè)Activity,需要傳遞一些參數(shù)裆针。這些參數(shù)的類型簡單如int和String刨摩,復(fù)雜的則是列表數(shù)據(jù)或者可序列化的自定義實(shí)體。
但是世吨,如果從HTML5頁面跳轉(zhuǎn)到Native頁面澡刹,是不大可能傳遞復(fù)雜類型的實(shí)體的,只能傳遞簡單類型耘婚。所以罢浇,并不是每個(gè)Native頁面都可以替換為HTML5。
接下來要討論的是沐祷,對于那些來自HTML5頁面嚷闭、傳遞簡單類型的頁面跳轉(zhuǎn)請求,我們將其抽象為一個(gè)分發(fā)器赖临,放到BaseActivity中胞锰。還記得我們在3.4.3節(jié)定義的協(xié)議嗎,以gotoMovieDetail為例:
<a onclick="baobao.gotoAnyWhere('gotoMovieDetail:movieId=12')">
gotoAnyWhere</a>
我們將其改寫為:
<a onclick="baobao.gotoAnyWhere(
'com.example.youngheart.MovieDetailActivity,
iOS.MovieDetailViewController:movieId=(int)123')">
gotoAnyWhere</a>
我們看到兢榨,協(xié)議的內(nèi)容分成3段嗅榕,第一段是Android要跳轉(zhuǎn)到的Activity的名稱顺饮。第二段是iOS要跳轉(zhuǎn)到的ViewController的名稱,第三段是需要傳遞的參數(shù)凌那,以key-value的形式進(jìn)行組裝兼雄。
我們接下來要做的就是從協(xié)議URL中取出第1段,將其反射為一個(gè)Activity對象案怯,取出第3段君旦,將其解析為key-value的形式澎办,然后從當(dāng)前頁面跳轉(zhuǎn)到目標(biāo)頁面并配以正確的參數(shù)嘲碱。其中,寫一個(gè)輔助函數(shù)getAndroidPageName局蚀,用來獲取Activity名稱:
public class BaseActivity extends Activity {
private String getAndroidPageName(String key) {
String pageName = null;
int pos = key.indexOf(",");
if (pos == -1) {
pageName = key;
} else {
pageName = key.substring(0, pos);
}
return pageName;
}
public void gotoAnyWhere(String url) {
if (url == null) return;
String pageName = getAndroidPageName(url);
if (pageName == null || pageName.trim() == "") return;
Intent intent = new Intent();
int pos = url.indexOf(":");
if (pos > 0) {
String strParams = url.substring(pos);
String[] pairs = strParams.split("&");
for (String strKeyAndValue : pairs) {
String[] arr = strKeyAndValue.split("=");
String key = arr[0];
String value = arr[1];
if (value.startsWith("(int)")) {
intent.putExtra(key, Integer.valueOf(value.substring(5)));
} else if (value.startsWith("(Double)")) {
intent.putExtra(key, Double.valueOf(value.substring(8)));
} else {
intent.putExtra(key, value);
}
}
}
try {
intent.setClass(this, Class.forName(pageName));
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
startActivity(intent);
}
}
注意麦锯,在協(xié)議中定義這些簡單數(shù)據(jù)類型的時(shí)候,String是不需要指定類型的琅绅,這是使用最廣泛的類型扶欣。對于int、Double等簡單類型千扶,我們要在值前面加上類似(int)這樣的約定料祠,這樣才能在解析時(shí)不出問題。
3.5 消滅全局變量
本節(jié)我們要討論的是一個(gè)深刻的話題澎羞。相信很多人都遇到過App莫名其妙就崩潰的情況髓绽,尤其是一些配置很低的手機(jī),重現(xiàn)場景就是在App切換到后臺妆绞,閑置了一段時(shí)間后再繼續(xù)使用時(shí)顺呕,就會崩潰。
3.5.1 問題的發(fā)現(xiàn)
導(dǎo)致上述崩潰發(fā)生的罪魁禍?zhǔn)拙褪侨肿兞坷ㄈ摹O率龃a就是在生成一個(gè)全局變量:
public class GlobalVariables {
public static UserBean User;
}
在內(nèi)存不足的時(shí)候株茶,系統(tǒng)會回收一部分閑置的資源,由于App被切換到后臺图焰,所以之前存放的全局變量很容易被回收启盛,這時(shí)再切換到前臺繼續(xù)使用,在使用某個(gè)全局變量的時(shí)候技羔,就會因?yàn)槿肿兞康闹禐榭斩罎⒊刍病_@不是個(gè)例。我經(jīng)歷過最糟糕的App竟然使用了200多個(gè)全局變量堕阔,任何頁面從后臺切換回前臺都有崩潰的可能棍厂。
想徹底解決這個(gè)問題,就一定要使用序列化技術(shù)超陆。
3.5.2 把數(shù)據(jù)作為Intent的參數(shù)傳遞
想一勞永逸地解決上述問題就是不使用全局變量牺弹,使用Intent來進(jìn)行頁面間數(shù)據(jù)的傳遞浦马。因?yàn)椋词鼓繕?biāo)Activity被系統(tǒng)銷毀了张漂,Intent上的數(shù)據(jù)仍然存在晶默,所以Intent是保存數(shù)據(jù)的一個(gè)很好的地方,比本地文件靠譜航攒。但是Intent能傳遞的數(shù)據(jù)類型也必須支持序列化磺陡,像JSONObject這樣的數(shù)據(jù)類型,是傳遞不過去的漠畜。對于一個(gè)有200多個(gè)全局變量的App而言币他,重構(gòu)的工作量很大,風(fēng)險(xiǎn)也很大憔狞。
另外蝴悉,如果Intent上攜帶的數(shù)據(jù)量過大,也會發(fā)生崩潰瘾敢。第7章會對此有詳細(xì)的介紹拍冠。
3.5.3 把全局變量序列化到本地
另一個(gè)比較穩(wěn)妥的解決方案是,我們?nèi)匀皇褂萌肿兞看氐郑诿看涡薷娜肿兞康闹档臅r(shí)候庆杜,都要把值序列化到本地文件中,這樣的話碟摆,即使內(nèi)存中的全局變量被回收,本地還保存有最新的值焦履,當(dāng)我們再次使用全局變量時(shí)拓劝,就從本地文件中再反序列化到內(nèi)存中。
這樣就解了燃眉之急嘉裤,數(shù)據(jù)不再丟失郑临。但長遠(yuǎn)之計(jì)還是要一個(gè)模塊一個(gè)模塊地將全局變量轉(zhuǎn)換為Intent上可序列化的實(shí)體數(shù)據(jù)。但這是后話屑宠,眼前厢洞,我們先要把全局變量序列化到本地文件,如下所示典奉,我們對全局GlobalsVariables變量進(jìn)行改造:
public class GlobalVariables implements Serializable, Cloneable {
/**
* @Fields: serialVersionUID
*/
private static final long serialVersionUID = 1L;
private static GlobalVariables instance;
private GlobalVariables() {
}
public static GlobalVariables getInstance() {
if (instance == null) {
Object object = Utils.restoreObject(AppConstants.CACHEDIR + TAG);
if (object == null) { // App首次啟動(dòng)躺翻,文件不存在則新建之
object = new GlobalVariables();
Utils.saveObject(AppConstants.CACHEDIR + TAG, object);
}
instance = (GlobalVariables) object;
}
return instance;
}
public final static String TAG = "GlobalVariables";
private UserBean user;
public UserBean getUser() {
return user;
}
public void setUser(UserBean user) {
this.user = user;
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
// — — — — —以下3個(gè)方法用于序列化— — — — — — — —
public GlobalVariables readResolve() throws ObjectStreamException, CloneNotSupportedException {
instance = (GlobalVariables) this.clone();
return instance;
}
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject();
}
public Object Clone() throws CloneNotSupportedException {
return super.clone();
}
public void reset() {
user = null;
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
}
就是這短短的六十多行代碼,解決了全局變量GlobalsVari-ables被回收的問題卫玖。我們對其進(jìn)行詳細(xì)分析:
1. 首先公你,這是一個(gè)單例,我們只能以如下方式來讀寫user數(shù)據(jù):
UserBean user = GlobalsVariables.getInstance().getUser();
GlobalsVariables.getInstance().setUser(user);
同時(shí)假瞬,GlobalsVariables還必須實(shí)現(xiàn)Serializable接口陕靠,以支持序列化自身到本地迂尝。然而,為了使一個(gè)單例類變成可序列化的剪芥,僅僅在聲明中添加“implements Serializable”是不夠的垄开。因?yàn)橐粋€(gè)序列化的對象在每次反序列化的時(shí)候,都會創(chuàng)建一個(gè)新的對象税肪,而不僅僅是一個(gè)對原有對象的引用瓢姻。為了防止這種情況续誉,需要在單例類中加入readResolve方法和readObject方法局嘁,并實(shí)現(xiàn)Cloneable接口讹开。
2. 我們仔細(xì)看GlobalsVariables這個(gè)類的構(gòu)造函數(shù)蹦狂。這和一般的單例模式寫的不太一樣厚满。我們的邏輯是施逾,先判斷instance是否為空钉答,不為空邦鲫,證明全局變量沒有被回收灸叼,可以繼續(xù)使用;為空庆捺,要么是第一次啟動(dòng)App古今,本地文件都不存在,更不要說序列化到本地了滔以;要么是全局變量被回收了捉腥,于是我們需要從本地文件中將其還原回來。
為此你画,我們在Utils類中編寫了restoreObject和saveObject兩個(gè)方法抵碟,分別用于把全局變量序列化到本地和從本地文件反序列化到內(nèi)存,如下所示:
public static final void saveObject(String path, Object saveObject) {
FileOutputStream fos = null;
ObjectOutputStream oos = null;
File f = new File(path);
try {
fos = new FileOutputStream(f);
oos = new ObjectOutputStream(fos);
oos.writeObject(saveObject);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (oos != null) {
oos.close();
}
if (fos != null) {
fos.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static final Object restoreObject(String path) {
FileInputStream fis = null;
ObjectInputStream ois = null;
Object object = null;
File f = new File(path);
if (!f.exists()) {
return null;
}
try {
fis = new FileInputStream(f);
ois = new ObjectInputStream(fis);
object = ois.readObject();
return object;
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
} finally {
try {
if (ois != null) {
ois.close();
}
if (fis != null) {
fis.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
return object;
}
3. 全局變量的User屬性坏匪,具有g(shù)etUser和SetUser這兩個(gè)方法拟逮。我們就看這個(gè)setUser方法,它會在每次設(shè)置一個(gè)新值后适滓,執(zhí)行一次Utils類的saveObject方法敦迄,把新數(shù)據(jù)序列化到本地。
值得注意的是凭迹,如果全局變量中有一個(gè)自定義實(shí)體的屬性罚屋,那么我們也要將這個(gè)自定義實(shí)體也聲明為可序列化的,UserBean實(shí)體就是一個(gè)很好的例子嗅绸。它作為全局變量的一個(gè)屬性脾猛,其自身也必須實(shí)現(xiàn)Serializable接口。
接下來我們看如何使用全局變量鱼鸠。
- 在來源頁:
private void gotoLoginActivity() {
UserBean user = new UserBean();
user.setUserName("Jianqiang");
user.setCountry("Beijing");
user.setAge(32);
Intent intent = new Intent(LoginNew2Activity.this, PersonCenterActivity.class);
GlobalVariables.getInstance().setUser(user);
startActivity(intent);
}
- 在目標(biāo)頁P(yáng)ersonCenterActivity:
protected void initVariables() {
UserBean user = GlobalVariables.getInstance().getUser();
int age = user.getAge();
}
- 在App啟動(dòng)的時(shí)候猛拴,我們要清空存儲在本地文件的全局變量喉刘,因?yàn)檫@些全局變量的生命周期都應(yīng)該伴隨著App的關(guān)閉而消亡,但是我們來不及在App關(guān)閉的時(shí)候做漆弄,所以只好在App啟動(dòng)的時(shí)候第一件事情就是清除這些臨時(shí)數(shù)據(jù):
GlobalVariables.getInstance().reset();
為此睦裳,需要在GlobalVariables這個(gè)全局變量類中增加一個(gè)reset方法,用于清空數(shù)據(jù)后把空值強(qiáng)制保存到本地撼唾。
public void reset() {
user = null;
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
3.5.4 序列化的缺點(diǎn)
再次強(qiáng)調(diào)廉邑,把全局變量序列化到本地的方案,只是一種過渡型解決方案倒谷,它有幾個(gè)硬傷:
1. 每次設(shè)置全局變量的值都要強(qiáng)制執(zhí)行一次序列化的操作蛛蒙,容易造成ANR。
我們看一個(gè)例子渤愁,寫一個(gè)新的全局變量GlobalVariables3牵祟,它有3個(gè)屬性,如下所示:
private String userName;
private String nickName;
private String country;
public void reset() {
userName = null;
nickName = null;
country = null;
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
public String getNickName() {
return nickName;
}
public void setNickName(String nickName) {
this.nickName = nickName;
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
public String getCountry() {
return country;
}
public void setCountry(String country) {
this.country = country;
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
那么在給GlobalVariables3設(shè)值的時(shí)候抖格,如下所示:
private void simulateANR() {
GlobalVariables3.getInstance().setUserName("jianqiang.bao");
GlobalVariables3.getInstance().setNickName("包包");
GlobalVariables3.getInstance().setCountry("China");
}
我們會發(fā)現(xiàn)诺苹,每次設(shè)置值的時(shí)候,都要將GlobalVariables3強(qiáng)制序列化到本地一次雹拄。性能會很差收奔,如果屬性多了,強(qiáng)制序列化的次數(shù)也會變多滓玖,因?yàn)樽x寫文件的次數(shù)多了坪哄,就會造成ANR。
相應(yīng)的解決方案很丑陋势篡,如下所示:
public void setUserName(String userName, boolean needSave) {
this.userName = userName;
if (needSave) {
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
}
public void setNickName(String nickName, boolean needSave) {
this.nickName = nickName;
if (needSave) {
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
}
public void setCountry(String country, boolean needSave) {
this.country = country;
if (needSave) {
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
}
也就是說翩肌,為每個(gè)set方法多加一個(gè)boolean參數(shù),來控制是否要在改動(dòng)后做序列化禁悠。同時(shí)在GlobalVariables3中提供一個(gè)save方法念祭,就是做序列化的操作。
這樣改動(dòng)之后绷蹲,我們再給GlobalVariables3設(shè)值的時(shí)候就要這樣寫了:
private void simulateANR2() {
GlobalVariables3.getInstance().setUserName("bao", false);
GlobalVariables3.getInstance().setNickName("包包", false);
GlobalVariables3.getInstance().setCountry("China", false);
GlobalVariables3.getInstance().save();
}
也就是說棒卷,每次set后不做序列化,都設(shè)置完后祝钢,一次性序列化到本地比规。這么寫代碼很惡心,但我之前說過拦英,這只是權(quán)宜之計(jì)蜒什,相當(dāng)于打補(bǔ)丁,是臨時(shí)的解決方案疤估。
2. 序列化生成的文件灾常,會因?yàn)閮?nèi)存不夠而丟失霎冯。
這個(gè)問題也是在把全局變量都序列化到本地后發(fā)現(xiàn)的,究其原因钞瀑,就是因?yàn)槲覀儗⑿蛄谢谋镜匚募旁诹藘?nèi)存/data/data/com.youngheart/cache/這個(gè)目錄下沈撞。內(nèi)存空間十分有限,因而顯得可貴雕什,一旦內(nèi)存空間耗盡缠俺,手機(jī)也就無法使用了。因?yàn)槲覀兊娜肿兞糠浅6啻叮詢?nèi)部空間會耗盡壹士,這個(gè)序列化文件會被清除。其實(shí)SharedPreferences和SQLite數(shù)據(jù)庫也都是存儲在內(nèi)存空間上偿警,所以這個(gè)文件如果太大躏救,也會引發(fā)數(shù)據(jù)丟失的問題。
有人問我為什么不存在SD卡上螟蒸,嗯盒使,SD卡確實(shí)空間大得很,但是不穩(wěn)定尿庐,不是所有的手機(jī)ROM對其都有完好的支持忠怖,我不能相信它呢堰。
臨時(shí)解決方案是抄瑟,每次使用完一個(gè)全局變量,就要將其清空枉疼,然后強(qiáng)制序列化到本地皮假,以確保本地文件體積減小。
3. Android提供的數(shù)據(jù)類型并不全都支持序列化骂维。
我們要確保全局變量的每個(gè)屬性都可以序列化惹资。然而,并不是所有的數(shù)據(jù)類型都可以序列化的航闺。那么褪测,哪些數(shù)據(jù)可以序列化呢?表3-1是我經(jīng)過測試得到的結(jié)果潦刃。
這就從另一方面證明了侮措,我們盡量不要使用不能序列化的數(shù)據(jù)類型,包括JSONObject乖杠、JSONArray分扎、HashMap<String,Ob-ject>、ArrayList<HashMap<String,Object>>胧洒。
新項(xiàng)目可以盡量規(guī)避這些數(shù)據(jù)類型畏吓,但是老項(xiàng)目可就棘手了墨状。好在天無絕人之路,我經(jīng)過大量實(shí)踐菲饼,得到一些解決方案肾砂,如下所示。
- JSONObject和JSONArray
雖然JSONObject不支持序列化宏悦,但是可以在設(shè)置的時(shí)候?qū)⑵滢D(zhuǎn)換為字符串通今,然后序列化到本地文件。在需要讀取的時(shí)候肛根,就從本地文件反序列化處理這個(gè)字符串辫塌,然后再把字符串轉(zhuǎn)換為JSONObject對象,如下所示:
private String strCinema;
public JSONObject getCinema() {
if (strCinema == null) return null;
try {
return new JSONObject(strCinema);
} catch (JSONException e) {
return null;
}
}
public void setCinema(JSONObject cinema) {
if (cinema == null) {
this.strCinema = null;
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
return;
}
this.strCinema = cinema.toString();
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
JSONArray如法炮制派哲。只需要把上述代碼中的JSONObject替換為JSONArray即可臼氨。
- HashMap<String,Object>和ArrayList<HashMap<String,Object>>
因?yàn)镺bject可以是各種類型,有可能是JSONObject和JSONArray芭届,所以以上兩種類型不一定支持序列化储矩。
首選的解決方案是,如果HashMap中所有的對象都不是JSONObject和JSONArray褂乍,那么以上兩種類型就是支持序列化的持隧。建議將Object全都改為String類型的。
private HashMap<String, String> rules;
public HashMap<String, String> getRules() {
return rules;
}
public void setRules(HashMap<String, String> rules) {
this.rules = rules;
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
其次逃片,如果HashMap中存放有JSONObject或JSONAr-ray屡拨,那么我們就要在set方法中,遍歷HashMap中存放的每個(gè)Object褥实,將其轉(zhuǎn)換為字符串呀狼。
以下是代碼實(shí)現(xiàn),你會看到算法超級繁瑣损离,效率也非常差:
HashMap<String, Object> guides;
public HashMap<String, Object> getGuides() {
return guides;
}
public void setGuides(HashMap<String, Object> guides) {
if (guides == null) {
this.guides = new HashMap<String, Object>();
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
return;
}
this.guides = new HashMap<String, Object>();
Set set = guides.entrySet();
java.util.Iterator it = guides.entrySet().iterator();
while (it.hasNext()) {
java.util.Map.Entry entry = (java.util.Map.Entry) it.next();
Object value = entry.getValue();
String key = String.valueOf(entry.getKey());
this.guides.put(key, String.valueOf(value));
}
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
對于HashMap<String,Object>類型哥艇,無論是get方法還是set方法,都非常慢僻澎,因?yàn)橐闅vHashMap中存放的所有對象貌踏。ArrayList<HashMap<String,Object>>是HashMap<String,Object>的集合,所以對其進(jìn)行遍歷窟勃,會更加慢祖乳。在遇到了N多次以上解決方案導(dǎo)致的ANR之后,我決定將這兩種超級復(fù)雜的數(shù)據(jù)結(jié)構(gòu)拳恋,全部改造為可序列化的實(shí)體凡资。好在這樣的數(shù)據(jù)類型在App中不太多,重構(gòu)的成本不是很大。
3.5.5 如果Activity也被銷毀了呢
如果內(nèi)存不足導(dǎo)致當(dāng)前Activity也被銷毀了呢隙赁?比如說旋轉(zhuǎn)屏幕從豎屏到橫屏垦藏。
即使Activity被銷毀了,傳遞到這個(gè)Activity的Intent并不會丟失伞访,在重新執(zhí)行Activity的onCreate方法時(shí)掂骏,Intent攜帶的bundle參數(shù)還是在的。所以厚掷,我們的解決方案是重新執(zhí)行當(dāng)前Activity的onCreate方法弟灼,這樣做最安全。
但是另一個(gè)問題就又浮出水面了:Activity需要保存頁面狀態(tài)嗎冒黑?
想必各位親們都看過Android SDK中的貪食蛇游戲田绑,它講的就是在Activity被銷毀后保存貪食蛇的位置,這樣的話抡爹,恢復(fù)該頁面時(shí)就能根據(jù)之前保存的貪食蛇的位置繼續(xù)游戲掩驱。
這個(gè)Demo用到了Activity的以下2個(gè)方法:
- onSaveInstanceState()
- onRestoreInstanceState()
網(wǎng)上關(guān)于以上兩個(gè)方法的介紹和討論不勝枚舉,下面只是分享我的使用心得冬竟。
對于游戲以及視頻播放器而言欧穴,保存頁面上每個(gè)控件的狀態(tài)是必須的,因?yàn)槊慨?dāng)Activity被銷毀泵殴,用戶都希望能恢復(fù)銷毀之前的狀態(tài)涮帘,比如游戲進(jìn)行到哪個(gè)程度了,視頻播放到哪個(gè)時(shí)間點(diǎn)了笑诅。
但是對于社交類或者電商類App而言调缨,頁面繁多,多于100個(gè)頁面的App比比皆是苟鸯。如果每個(gè)頁面都保存所有控件的狀態(tài)同蜻,工作量就會很大,要知道這樣的App早处,每個(gè)頁面都有大量的控件和交互行為,需要記錄的狀態(tài)會很多瘫析。
所以砌梆,不記錄狀態(tài),直接讓頁面重新執(zhí)行一遍onCreate方法贬循,是一種比較穩(wěn)妥的方法咸包。丟失的數(shù)據(jù),是頁面加載完成之后的用戶行為杖虾,讓用戶重新操作一遍就是了烂瘫。
額外說一句,想保存頁面狀態(tài),是件很難的事情坟比。這一點(diǎn)WindowsPhone做得很好芦鳍,因?yàn)樗腔贛VVM的編程模型,它把業(yè)務(wù)邏輯ViewModel和頁面View徹底分開葛账,同時(shí)柠衅,View中的每個(gè)控件的狀態(tài),都與ViewModel中的屬性進(jìn)行了綁定籍琳,這樣的話菲宴,View中控件狀態(tài)變化,ViewModel中的屬性也會相應(yīng)變化趋急,反之亦然喝峦。所以把ViewModel序列化到本地,即使View被銷毀了呜达,重新創(chuàng)建View愈犹,并把保存到本地的ViewModel與之綁定,就可以重現(xiàn)View被銷毀之前的狀態(tài)——我們稱為墓碑機(jī)制闻丑。
不得不說漩怎,微軟的墓碑機(jī)制確實(shí)做得很好,它吸取了iOS和Android的經(jīng)驗(yàn)嗦嗡,讓恢復(fù)頁面狀態(tài)變得容易很多勋锤。
3.5.6 如何看待SharedPreferences
在我們決定禁止使用全局變量后,曾經(jīng)一段時(shí)間確實(shí)有了很好的效果侥祭,但是我后來仔細(xì)一看項(xiàng)目叁执,新的全局變量倒是真的不再有了,大家都改為存取SharedPreferences的方式了矮冬。
在我看來谈宛,SharedPreferences是全局變量序列化到本地的另一種形式。SharedPreferences中也是可以存取任何支持序列化的數(shù)據(jù)類型的胎署。
我們應(yīng)該嚴(yán)格控制SharedPreferences中存放的變量的數(shù)量吆录。有些數(shù)據(jù)存在SharedPreferences中是合理的,比如說當(dāng)前所在城市名稱琼牧、設(shè)置頁面的那些開關(guān)的狀態(tài)等等恢筝。但不要把頁面跳轉(zhuǎn)時(shí)要傳遞的數(shù)據(jù)放在SharedPreferences中。這時(shí)候巨坊,要優(yōu)先考慮使用Intent來傳遞數(shù)據(jù)撬槽。
3.5.7 User是唯一例外的全局變量
依我看來,App中只有一個(gè)全局變量的存在是合理的趾撵,那就是User類侄柔。我們在任何地方都有可能使用到User這個(gè)全局變量,比如獲取用戶名、用戶昵稱暂题、身份證號碼等等移剪。
User這個(gè)全局變量的實(shí)現(xiàn),可以參考本章講解的例子敢靡。
每次登錄挂滓,都要把登錄成功后獲取到的用戶信息保存到User類。以后啸胧,每當(dāng)User的屬性有變動(dòng)時(shí)赶站,我們都要把User保存一次。退出登錄纺念,就把User類的信息進(jìn)行清空贝椿。與之前我們所設(shè)計(jì)的全局變量不同,App啟動(dòng)時(shí)不需要清空User類的數(shù)據(jù)陷谱。因?yàn)槲覀兿M鸄pp記住上次用戶的登錄狀態(tài)以及用戶信息烙博。再講下去就涉及用戶Cookie的機(jī)制了。
3.6 本章小結(jié)
本章討論了App中的集中幾種場景的設(shè)計(jì)烟逊,其中包括:如何設(shè)計(jì)App圖片緩存渣窜,如何優(yōu)化網(wǎng)絡(luò)流量,對城市列表的重新思考宪躯,如何讓HTML5在App中發(fā)揮更大的作用乔宿,如何解決全局變量過多導(dǎo)致的內(nèi)存回收問題,等等访雪。
下一章详瑞,我將介紹Android的編碼規(guī)范和命名規(guī)范。