第3章 Android經(jīng)典場(chǎng)景設(shè)計(jì)

同樣是使用Java語言,為什么做MobileAPI的開發(fā)人員寫不了Android程序,反之亦然。我想大概是各行有各行的規(guī)矩和做事法則,本章介紹的這幾種Android經(jīng)典場(chǎng)景就是如此,看似都是些平談無奇的UI,但其中卻蘊(yùn)藏著大智慧。

閑話少敘,且聽我一一道來。

3.1 App圖片緩存設(shè)計(jì)

App緩存分為兩部分,數(shù)據(jù)緩存和圖片緩存。我們?cè)诘?章的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。尤其是在列表頁,會(huì)加載大量網(wǎng)絡(luò)上的圖片,每當(dāng)快速劃動(dòng)列表的時(shí)候,都會(huì)很卡,甚至?xí)驗(yàn)閮?nèi)存溢出而崩潰。

這時(shí)就輪到ImageLoader上場(chǎng)表演了。ImageLoader的目的是為了實(shí)現(xiàn)異步的網(wǎng)絡(luò)圖片加載、緩存及顯示,支持多線程異步加載。

ImageLoader的工作原理是這樣的:在顯示圖片的時(shí)候,它會(huì)先在內(nèi)存中查找;如果沒有,就去本地查找;如果還沒有,就開一個(gè)新的線程去下載這張圖片,下載成功會(huì)把圖片同時(shí)緩存到內(nèi)存和本地。

基于這個(gè)原理,我們可以在每次退出一個(gè)頁面的時(shí)候,把ImageLoader內(nèi)存中的緩存全都清除,這樣就節(jié)省了大量?jī)?nèi)存,反正下次再用到的時(shí)候從本地再取出來就是了。

此外,由于ImageLoader對(duì)圖片是軟引用的形式,所以內(nèi)存中的圖片會(huì)在內(nèi)存不足的時(shí)候被系統(tǒng)回收(內(nèi)存足夠的時(shí)候不會(huì)對(duì)其進(jìn)行垃圾回收)。

3.1.2 ImageLoader的使用

ImageLoader由三大組件組成:

  • ImageLoaderConfiguration——對(duì)圖片緩存進(jìn)行總體配置,包括內(nèi)存緩存的大小、本地緩存的大小和位置、日志、下載策略(FIFO還是LIFO)等等。
  • ImageLoader——我們一般使用displayImage來把URL對(duì)應(yīng)的圖片顯示在ImageView上。
  • DisplayImageOptions——在每個(gè)頁面需要顯示圖片的地方,控制如何顯示的細(xì)節(jié),比如指定下載時(shí)的默認(rèn)圖(包括下載中、下載失敗、URL為空等),是否將緩存放到內(nèi)存或者本地磁盤。

借用博客園上陳哈哈的博文對(duì)三者關(guān)系的一個(gè)比喻,“他們有點(diǎn)像廚房規(guī)定、廚師、客戶個(gè)人口味之間的關(guān)系。Im-ageLoaderConfiguration就像是廚房里面的規(guī)定,每一個(gè)廚師要怎么著裝,要怎么保持廚房的干凈,這是針對(duì)每一個(gè)廚師都適用的規(guī)定,而且不允許個(gè)性化改變。ImageLoader就像是具體做菜的廚師,負(fù)責(zé)具體菜譜的制作。DisplayImageOptions就像每個(gè)客戶的偏好,根據(jù)客戶是重口味還是清淡,每一個(gè)ImageLoader根據(jù)DisplayImageOptions的要求具體執(zhí)行。”

下面我們介紹如何使用ImageView:

  1. 在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);
        }
    }
  1. 在使用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();
    }
  1. 在使用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。我列舉在上面的配置代碼是目前比較通用的,請(qǐng)大家參考。

3.1.3 ImageLoader優(yōu)化

盡管ImageLoader很強(qiáng)大,但一直把圖片緩存在內(nèi)存中,會(huì)導(dǎo)致內(nèi)存占用過高。雖然對(duì)圖片的引用是軟引用,軟引用在內(nèi)存不夠的時(shí)候會(huì)被GC,但我們還是希望減少GC的次數(shù),所以要經(jīng)常手動(dòng)清理ImageLoader中的緩存。

我們?cè)贏ppBaseActivity中的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)原理。有興趣的朋友可以參考下列文章,里面有很深入的研究:

  1. 簡(jiǎn)介ImageLoader。地址:http://blog.csdn.net/yueqinglkong/article/de-tails/27660107

  2. Android-Universal-Image-Loader圖片異步加載類庫的使用(超詳細(xì)配置)。地址:http://blog.csdn.net/vipzjyno1/article/de-tails/23206387

  3. 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會(huì)變得很卡。我們使用MAT分析內(nèi)存,發(fā)現(xiàn)讓內(nèi)存居高不下的罪魁禍?zhǔn)拙褪菆D片。于是我們把目光轉(zhuǎn)向Fresco,開始優(yōu)化App占用的內(nèi)存。

Fresco使用起來很簡(jiǎn)單,如下所示:

  • 在Application級(jí)別,對(duì)Fresco進(jìn)行初始化,如下所示:Fresco.initialize(getApplicationContext());
  • 與ImageLoader等傳統(tǒng)第三方圖片處理SDK不同,F(xiàn)resco是基于控件級(jí)別的,所以我們把程序中顯示網(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中找不到。

圖3-1 Image Pipeline的工作流

我們可以像配置ImageLoader那樣配置Fresco中的ImagePipeline,使用ImagePipelineConfig來做這個(gè)事情。

Fresco有3個(gè)線程池,其中3個(gè)線程用于網(wǎng)絡(luò)下載圖片,2個(gè)線程用于磁盤文件的讀寫,還有2個(gè)線程用于CPU相關(guān)操作,比如圖片解碼、轉(zhuǎn)換,以及放在后臺(tái)執(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)建和回收不會(huì)引發(fā)過多的GC,從而讓App運(yùn)行得更快。

當(dāng)App切換到后臺(tái)時(shí),Bitmap緩存會(huì)被清空。

第二層:內(nèi)存緩存

內(nèi)存緩存中存儲(chǔ)了圖片的原始?jí)嚎s格式。從內(nèi)存緩存中取出的圖片,在顯示前必須先解碼。當(dāng)App切換到后臺(tái)時(shí),內(nèi)存緩存也會(huì)被清空。

第三層:磁盤緩存

磁盤緩存,又名本地存儲(chǔ)。磁盤緩存中存儲(chǔ)的也是圖片的原始?jí)嚎s格式。在使用前也要先解碼。當(dāng)App切換到后臺(tái)時(shí),磁盤緩存不會(huì)丟失,即使關(guān)機(jī)也不會(huì)。

Fresco有很多高級(jí)的應(yīng)用,對(duì)于大部分App而言,基本還用不到。只要掌握上述簡(jiǎn)單的使用方法就能極大地節(jié)省內(nèi)存了。我做的App原先占用180MB的內(nèi)存,現(xiàn)在只會(huì)占據(jù)80MB左右的內(nèi)存了。這也是我為什么要在本書中增加這一部分內(nèi)容的原因。

關(guān)于Fresco的更多介紹請(qǐng)參見:

3.2 對(duì)網(wǎng)絡(luò)流量進(jìn)行優(yōu)化

對(duì)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í)”的對(duì)話框,就是我們開發(fā)人員必須要解決的問題了。

3.2.1 通信層面的優(yōu)化

讓我們先從MobileAPI層面進(jìn)行優(yōu)化:

  1. MobileAPI接口返回的數(shù)據(jù),要使用gzip進(jìn)行壓縮。注意:大于1KB才進(jìn)行壓縮,否則得不償失。經(jīng)過gzip壓縮后,返回的數(shù)據(jù)量大幅減少。

  2. App與MobileAPI之間的數(shù)據(jù)傳遞,通常是遵守JSON協(xié)議的。JSON因?yàn)槭莤ml格式的,并且是以字符存在的,在數(shù)據(jù)量上還有可以壓縮的空間。我這里推薦一種新的數(shù)據(jù)傳輸協(xié)議,那就是ProtoBuffer。這種協(xié)議是二進(jìn)制格式的,所以在表示大數(shù)據(jù)時(shí),空間比JSON小很多。

  3. 接下來要解決的是頻繁調(diào)用MobileAPI的問題。我們知道,發(fā)起一次網(wǎng)絡(luò)請(qǐng)求,服務(wù)器處理的速度是很快的,主要花費(fèi)的時(shí)間在數(shù)據(jù)傳輸上,也就是這一來一回走路的時(shí)間上。
    走路時(shí)間的長(zhǎng)度,網(wǎng)絡(luò)運(yùn)維人員會(huì)去負(fù)責(zé)解決。移動(dòng)開發(fā)人員需要關(guān)注的是,減少網(wǎng)絡(luò)訪問次數(shù),能調(diào)用一次MobileAPI接口就能取到數(shù)據(jù)的,就不要調(diào)用兩次。

  4. 我們知道,傳統(tǒng)的MobileAPI使用的是HTTP無狀態(tài)短連接。使用HTTP協(xié)議的速度遠(yuǎn)不如使用TCP協(xié)議,因?yàn)楹笳呤情L(zhǎng)連接。所以我們可以使用TCP長(zhǎng)連接,以提高訪問的速度。缺點(diǎn)是一臺(tái)服務(wù)器能支持的長(zhǎng)連接個(gè)數(shù)不多,所以需要更多的服務(wù)器集成。

  5. 要建立取消網(wǎng)絡(luò)請(qǐng)求的機(jī)制。一個(gè)頁面如果沒有請(qǐng)求完網(wǎng)絡(luò)數(shù)據(jù),在跳轉(zhuǎn)到另一個(gè)頁面之前,要把之前的網(wǎng)絡(luò)請(qǐng)求都取消,不再等待,也不再接收數(shù)據(jù)。
    我遇到過一個(gè)真實(shí)的例子,首頁要在后臺(tái)調(diào)用十幾個(gè)MobileAPI接口,用戶一旦進(jìn)入二級(jí)頁面,在二級(jí)頁面獲取列表數(shù)據(jù)時(shí),經(jīng)常會(huì)取不到數(shù)據(jù),并彈出“網(wǎng)絡(luò)請(qǐng)求超時(shí)”的提示。我們通過在App輸出log的方式發(fā)現(xiàn),二級(jí)頁面還在調(diào)用首頁沒有完成的那些MobileAPI接口,App網(wǎng)絡(luò)底層的請(qǐng)求隊(duì)列已經(jīng)被阻塞了,原因是在進(jìn)入下一個(gè)頁面時(shí),首頁發(fā)起的網(wǎng)絡(luò)請(qǐng)求仍然存在于網(wǎng)絡(luò)請(qǐng)求隊(duì)列中,并沒有移除掉。
    無論是iOS還是Android,都應(yīng)該在基類(BaseViewCon-troller或者BaseActivity)中提供一個(gè)cancelRequest的方法,用以在離開當(dāng)前頁面時(shí)清空網(wǎng)絡(luò)請(qǐng)求隊(duì)列。

  6. 增加重試機(jī)制。如果MobileAPI是嚴(yán)格的RESTful風(fēng)格,那么我們一般將獲取數(shù)據(jù)的請(qǐng)求接口都定義為get;而把操作數(shù)據(jù)的請(qǐng)求接口都定義為post。

這樣的話,我們就可以為所有的get請(qǐng)求配置重試機(jī)制,比如get請(qǐng)求失敗后重試3次。

有人會(huì)問post請(qǐng)求失敗后,是否需要重試呢?我們舉個(gè)例子吧,比如說下單接口是個(gè)post請(qǐng)求,如果請(qǐng)求失敗那么就會(huì)重試3次,直到下單成功。但是有時(shí)候post請(qǐng)求并沒有失敗,而是超時(shí)了,超時(shí)時(shí)間是30秒,但是卻31秒返回了,如果因此而重新發(fā)起下單請(qǐng)求,那么就會(huì)連續(xù)下單兩次。所以post請(qǐng)求是不建議有重試機(jī)制的。此外,對(duì)所有的post請(qǐng)求,都要增加防止用戶1分鐘內(nèi)頻繁發(fā)起相同請(qǐng)求的機(jī)制,這樣就能有效防止重復(fù)下單、重復(fù)發(fā)表評(píng)論、重復(fù)注冊(cè)等操作。

如果post請(qǐng)求具有防重機(jī)制,那么倒是可以增加重試機(jī)制。但是要可以在服務(wù)器端靈活配置重試的次數(shù),可以是0次,意味著不會(huì)重試。在App啟動(dòng)的時(shí)候,告訴App所有的MobileAPI接口的重試次數(shù)。

3.2.2 圖片策略優(yōu)化

首先,我們從圖片層面進(jìn)行優(yōu)化,這里說的圖片,是根據(jù)MobileAPI返回的圖片URL地址新啟一個(gè)線程下載到App本地并顯示的。很多App崩潰的原因就是圖片的問題沒處理好。

1. 要確保下載的每張圖,都符合ImageView控件的大小

這對(duì)于Android是有難度的,因?yàn)槭謾C(jī)分辨率千奇百怪,所以App中的圖片,我們大多做成自適應(yīng)的,有時(shí)是等比拉伸或縮放圖片的寬和高,有時(shí)則固定高度而動(dòng)態(tài)伸縮寬度,反之亦然。

于是我們要求運(yùn)營(yíng)人員要事先準(zhǔn)備很多套不同分辨率的圖片。我們每次根據(jù)URL請(qǐng)求圖片時(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)備一臺(tái)服務(wù)器,稱為ImageServer。具體流程是這樣的:

  • 首先,App每次加載圖片,都會(huì)把URL地址以及width和height參數(shù)所組成的字符串進(jìn)行encode,然后發(fā)送給Image-Server,新的URL如下所示:
    http://www.ImageServer.com/getImage?param=(encodevalue)
  • 然后,ImageServer收到這個(gè)請(qǐng)求,會(huì)把param的值de-code,得到原始圖片的URL,以及App想要顯示的這張圖片的width和height。ImageServer會(huì)根據(jù)URL獲取到這張?jiān)紙D片,然后根據(jù)width和height,重新進(jìn)行繪制,保存到Image-Server上,并返回給App。
  • 最后,App請(qǐng)求到的是一張符合其顯示大小的圖片。

接下來收到同樣的請(qǐng)求,直接返回ImageServer上保存的那種圖片即可。但是要每天清一次硬盤,不然過不了幾天硬盤就滿了。

如果width和height的比例與原圖的寬高比不一致呢?我們需要再加一個(gè)參數(shù)imagetype,以下是定義:

  • 1表示等比縮放后,裁減掉多余的寬或者高。
  • 2表示等比縮放后,不足的寬或者高填充白色。

當(dāng)然你也可以定義0表示不進(jìn)行縮放,直接返回。

這種方案的缺點(diǎn)就是,ImageServer頻繁地寫硬盤,硬盤堅(jiān)持不到兩周就壞掉。所以,我們?cè)趽p失了幾塊硬盤后,決定事先規(guī)定幾套width和height,App必須嚴(yán)格遵守,比如說100×50,200×100,那么就不允許向服務(wù)器發(fā)送類似99×51這樣的圖片尺寸。

但這樣規(guī)定,并不能防止App開發(fā)人員犯錯(cuò),他在UI上就是不小心為某個(gè)ImageView控件指定了99×51這樣的尺寸,那么ImageServer還是會(huì)生成這樣的圖片。

唯一的辦法就是在出口加以控制,也就是向ImageServer發(fā)起請(qǐng)求的時(shí)候。我們會(huì)拿99×51這個(gè)實(shí)際的圖片尺寸,去輪詢我們事先規(guī)定好的那幾個(gè)尺寸100×50和200×100,看更接近哪個(gè),比如說99×51更接近100×50,那么就向ImageServer請(qǐng)求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)的圖片大小也會(huì)降低,我們稱為低流量模式。

還記得我們前面提到的ImageServer嗎?我們可以在URL中再增加一個(gè)參數(shù)quality,2G網(wǎng)絡(luò)下這個(gè)值為50%,3G網(wǎng)絡(luò)下這個(gè)值為70%,我們把這個(gè)參數(shù)傳遞給ImageServer,從而Im-ageServer在繪制圖片時(shí),就會(huì)將jpg圖片質(zhì)量降低為50%或70%,這樣返回給App的數(shù)據(jù)量就大大減少了。

在列表頁,這種效果最為明顯,能極大的節(jié)省用戶流量。

3. 極速模式

我們后來發(fā)現(xiàn),在2G和3G網(wǎng)絡(luò)環(huán)境下,用戶大多對(duì)圖片不感興趣,他們可能就是想快速下單并支付,我們需要額外設(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)入首頁前會(huì)先進(jìn)行預(yù)判,如果發(fā)現(xiàn)當(dāng)前網(wǎng)絡(luò)環(huán)境為2G、3G或4G,但是當(dāng)前模式為正常模式,就會(huì)彈出一個(gè)對(duì)話框詢問用戶,是否要進(jìn)入極速模式以節(jié)省流量。如果是WiFi網(wǎng)絡(luò)環(huán)境,但當(dāng)前模式是極速模式,也會(huì)提示用戶是否要切換回正常模式,以看到最炫的效果。

僅在開啟App時(shí)提示用戶極速模式是不夠的,我們?cè)谠O(shè)置頁也要提供這個(gè)開關(guān),供用戶手動(dòng)切換。

3.3 城市列表的設(shè)計(jì)

很多App都有城市列表這一功能??此坪?jiǎn)單,但就像登錄功能一樣,做好它并不容易。

一份城市列表的數(shù)據(jù)包括以下幾個(gè)字典:

  • cityId:城市Id。
  • cityName:城市名稱。
  • pinyin:城市全拼。
  • jianpin:城市簡(jiǎn)拼。

其中,全拼和簡(jiǎn)拼是用來在App本地做字母表排序和關(guān)鍵字檢索的。

我曾經(jīng)經(jīng)歷過把城市列表數(shù)據(jù)寫死在本地文件的做法,日積月累,就會(huì)產(chǎn)生兩個(gè)問題:

  • Android和iOS維護(hù)的數(shù)據(jù),差異會(huì)越來越大。
  • 一千多個(gè)城市,每次從本地加載都要很長(zhǎng)時(shí)間。

針對(duì)問題1的解決辦法是,寫一個(gè)文本分析工具,找出An-droid和iOS各自維護(hù)文件的不同數(shù)據(jù)。

iOS開發(fā)人員喜歡使用plist文件作為數(shù)據(jù)存儲(chǔ)的載體,最好能和Android統(tǒng)一使用一份xml文件,這樣便于管理類似城市列表這樣的數(shù)據(jù)。

針對(duì)問題2的解決方案是,對(duì)于一千多個(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去后臺(tái)采集數(shù)據(jù),這樣數(shù)據(jù)是最新最準(zhǔn)的。

但是這樣做的問題是,這個(gè)MobileAPI接口返回的數(shù)據(jù)量會(huì)很大,上千筆數(shù)據(jù),還包括那么多字段,即使打開了gzip壓縮,也會(huì)有100k的樣子。于是我們又增加了版本號(hào)字段version的概念,這個(gè)MobileAPI接口的定義和返回的JSON格式是這樣的:

  1. 入?yún)ⅰersion,本地存儲(chǔ)的城市列表數(shù)據(jù)對(duì)應(yīng)的版本號(hào)。
  2. 返回值。如果傳入?yún)?shù)version和線上最新版本號(hào)一致,則返回以下固定格式:
{
        "isMatch": false,
            "version": 1,
            "cities": [
                    {
                    },
        ]
    }

如果傳入?yún)?shù)version和線上最新版本號(hào)不一致,則返回以下格式:

{
        "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ù)(序列化后的)以及對(duì)應(yīng)的版本號(hào)。我們要求每次發(fā)版前做一次城市數(shù)據(jù)同步的事情。
  • 每次進(jìn)入到城市列表這個(gè)頁面時(shí),將本地城市列表數(shù)據(jù)對(duì)應(yīng)的版本號(hào)version傳入到MobileAPI接口,根據(jù)返回的is-Match值來決定是否版本號(hào)一致。如果一致,則直接從本地文件中加載城市列表數(shù)據(jù);否則,就解析MobileAPI接口返回的數(shù)據(jù),在顯示列表的同時(shí),記得要把最新的城市列表數(shù)據(jù)和版本號(hào)保存到本地。
  • 如果MobileAPI接口沒有調(diào)用成功,也是直接從本地文件中加載城市列表數(shù)據(jù),以確保主流程是暢通的。
  • 每次調(diào)用MobileAPI時(shí),會(huì)獲取到大量的數(shù)據(jù),一般我們會(huì)打開gzip對(duì)數(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)的,版本號(hào)不需要自增+1。

那么問題來了,對(duì)于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和其他一些字段,比如說城市名稱、簡(jiǎn)拼、全拼等。我畫了一個(gè)表,如圖3-2所示,試圖展示出1.0和2.0這兩個(gè)版本的城市數(shù)據(jù)之間的異同。

圖3-2 比較兩個(gè)版本城市數(shù)據(jù)間的異同

我來解釋一下圖3-2,以cityId作為唯一標(biāo)識(shí),只在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ù)后,會(huì)根據(jù)type值來處理存放在本地的數(shù)據(jù)。因?yàn)椴皇侨扛?,所以處理起來很快。這種增量更新城市數(shù)據(jù)的策略,會(huì)使得App的邏輯很簡(jiǎn)單,但是服務(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)備一臺(tái)測(cè)試用的PC服務(wù)器,在上面搭建一個(gè)IIS,這樣可以快速搭建自己的Demo,對(duì)于App開發(fā)人員而言,不需要等待HTML5團(tuán)隊(duì)就可以自行開發(fā)并測(cè)試了。他們只需知道一些基本的Html和JavaScript語法,而相應(yīng)的培訓(xùn)非常簡(jiǎ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頁面的背景顏色。

  1. HTML5
<script type="text/javascript">    
      function changeColor (color) {        
            document.body.style.backgroundColor = color;    
      }
</script>
  1. 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方法:

  1. HTML5
<a onclick="baobao.callAndroidMethod(100,100,'ccc',true)">    
CallAndroidMethod</a>
  1. 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í),需要注冊(cè)baobao和JSInterface1的對(duì)應(yīng)關(guān)系:

wvAds.addJavascriptInterface(new JSInteface1(), "baobao");

調(diào)試期間我發(fā)現(xiàn)對(duì)于小米3系統(tǒng),要在方法前增加@JavascriptInterface,否則,就不能觸發(fā)JavaScript方法。

3.4.3 App和HTML5之間定義跳轉(zhuǎn)協(xié)議

根據(jù)上面的例子,運(yùn)營(yíng)團(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號(hào)北京的所有新聞信息,就不得不需要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所擅長(zhǎng)的領(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>都是占位符,我們接下來會(huì)使用真實(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頁面的策略

對(duì)于經(jīng)常需要改動(dòng)的頁面,我們會(huì)把它做成HTML5頁面,在App中以WebView的形式加載。這樣就避免了Native頁面每次修改,都要等一次迭代上線后才能看到——周期太長(zhǎng)了,這不是產(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。

相對(duì)于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è)后臺(tái),根據(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ù)傳遞,字典是一種比較好的形式,消除了不同頁面對(duì)參數(shù)類型的不同要求。

接下來,就是App運(yùn)營(yíng)人員和產(chǎn)品經(jīng)理隨心所欲的進(jìn)行配置了。

在實(shí)際的操作中,一定要注意,HTML5頁面只是權(quán)宜之計(jì),可以快速上一個(gè)活動(dòng),比如類似于雙十一的節(jié)假日,從而以迅雷不及掩耳之勢(shì)打擊競(jìng)爭(zhēng)對(duì)手。隨著HTML5和Native的不同步,當(dāng)一個(gè)頁面再從HTML5切換回Native時(shí),我們會(huì)發(fā)現(xiàn),它們的邏輯已經(jīng)差了很多了,切回來就會(huì)有很多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ù)的類型簡(jiǎn)單如int和String,復(fù)雜的則是列表數(shù)據(jù)或者可序列化的自定義實(shí)體。

但是,如果從HTML5頁面跳轉(zhuǎn)到Native頁面,是不大可能傳遞復(fù)雜類型的實(shí)體的,只能傳遞簡(jiǎn)單類型。所以,并不是每個(gè)Native頁面都可以替換為HTML5。

接下來要討論的是,對(duì)于那些來自HTML5頁面、傳遞簡(jiǎn)單類型的頁面跳轉(zhuǎn)請(qǐng)求,我們將其抽象為一個(gè)分發(fā)器,放到BaseActivity中。還記得我們?cè)?.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對(duì)象,取出第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é)議中定義這些簡(jiǎn)單數(shù)據(jù)類型的時(shí)候,String是不需要指定類型的,這是使用最廣泛的類型。對(duì)于int、Double等簡(jiǎn)單類型,我們要在值前面加上類似(int)這樣的約定,這樣才能在解析時(shí)不出問題。

3.5 消滅全局變量

本節(jié)我們要討論的是一個(gè)深刻的話題。相信很多人都遇到過App莫名其妙就崩潰的情況,尤其是一些配置很低的手機(jī),重現(xiàn)場(chǎng)景就是在App切換到后臺(tái),閑置了一段時(shí)間后再繼續(xù)使用時(shí),就會(huì)崩潰。

3.5.1 問題的發(fā)現(xiàn)

導(dǎo)致上述崩潰發(fā)生的罪魁禍?zhǔn)拙褪侨肿兞?。下述代碼就是在生成一個(gè)全局變量:

public class GlobalVariables {    
  public static UserBean User;
}

在內(nèi)存不足的時(shí)候,系統(tǒng)會(huì)回收一部分閑置的資源,由于App被切換到后臺(tái),所以之前存放的全局變量很容易被回收,這時(shí)再切換到前臺(tái)繼續(xù)使用,在使用某個(gè)全局變量的時(shí)候,就會(huì)因?yàn)槿肿兞康闹禐榭斩罎?。這不是個(gè)例。我經(jīng)歷過最糟糕的App竟然使用了200多個(gè)全局變量,任何頁面從后臺(tái)切換回前臺(tái)都有崩潰的可能。

想徹底解決這個(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ù)類型,是傳遞不過去的。對(duì)于一個(gè)有200多個(gè)全局變量的App而言,重構(gòu)的工作量很大,風(fēng)險(xiǎn)也很大。

另外,如果Intent上攜帶的數(shù)據(jù)量過大,也會(huì)發(fā)生崩潰。第7章會(huì)對(duì)此有詳細(xì)的介紹。

3.5.3 把全局變量序列化到本地

另一個(gè)比較穩(wěn)妥的解決方案是,我們?nèi)匀皇褂萌肿兞?,在每次修改全局變量的值的時(shí)候,都要把值序列化到本地文件中,這樣的話,即使內(nèi)存中的全局變量被回收,本地還保存有最新的值,當(dāng)我們?cè)俅问褂萌肿兞繒r(shí),就從本地文件中再反序列化到內(nèi)存中。

這樣就解了燃眉之急,數(shù)據(jù)不再丟失。但長(zhǎng)遠(yuǎn)之計(jì)還是要一個(gè)模塊一個(gè)模塊地將全局變量轉(zhuǎn)換為Intent上可序列化的實(shí)體數(shù)據(jù)。但這是后話,眼前,我們先要把全局變量序列化到本地文件,如下所示,我們對(duì)全局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被回收的問題。我們對(duì)其進(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è)序列化的對(duì)象在每次反序列化的時(shí)候,都會(huì)創(chuàng)建一個(gè)新的對(duì)象,而不僅僅是一個(gè)對(duì)原有對(duì)象的引用。為了防止這種情況,需要在單例類中加入readResolve方法和readObject方法,并實(shí)現(xiàn)Cloneable接口。

2. 我們仔細(xì)看GlobalsVariables這個(gè)類的構(gòu)造函數(shù)。這和一般的單例模式寫的不太一樣。我們的邏輯是,先判斷instance是否為空,不為空,證明全局變量沒有被回收,可以繼續(xù)使用;為空,要么是第一次啟動(dòng)App,本地文件都不存在,更不要說序列化到本地了;要么是全局變量被回收了,于是我們需要從本地文件中將其還原回來。

為此,我們?cè)赨tils類中編寫了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方法,它會(huì)在每次設(shè)置一個(gè)新值后,執(zhí)行一次Utils類的saveObject方法,把新數(shù)據(jù)序列化到本地。

值得注意的是,如果全局變量中有一個(gè)自定義實(shí)體的屬性,那么我們也要將這個(gè)自定義實(shí)體也聲明為可序列化的,UserBean實(shí)體就是一個(gè)很好的例子。它作為全局變量的一個(gè)屬性,其自身也必須實(shí)現(xiàn)Serializable接口。

接下來我們看如何使用全局變量。

  1. 在來源頁:
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);
    }
  1. 在目標(biāo)頁P(yáng)ersonCenterActivity:
protected void initVariables() {    
        UserBean user = GlobalVariables.getInstance().getUser();     
        int age = user.getAge();
}
  1. 在App啟動(dòng)的時(shí)候,我們要清空存儲(chǔ)在本地文件的全局變量,因?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");
}

我們會(huì)發(fā)現(xiàn),每次設(shè)置值的時(shí)候,都要將GlobalVariables3強(qiáng)制序列化到本地一次。性能會(huì)很差,如果屬性多了,強(qiáng)制序列化的次數(shù)也會(huì)變多,因?yàn)樽x寫文件的次數(shù)多了,就會(huì)造成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)之后,我們?cè)俳oGlobalVariables3設(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. 序列化生成的文件,會(huì)因?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)部空間會(huì)耗盡,這個(gè)序列化文件會(huì)被清除。其實(shí)SharedPreferences和SQLite數(shù)據(jù)庫也都是存儲(chǔ)在內(nèi)存空間上,所以這個(gè)文件如果太大,也會(huì)引發(fā)數(shù)據(jù)丟失的問題。

有人問我為什么不存在SD卡上,嗯,SD卡確實(shí)空間大得很,但是不穩(wěn)定,不是所有的手機(jī)ROM對(duì)其都有完好的支持,我不能相信它。

臨時(shí)解決方案是,每次使用完一個(gè)全局變量,就要將其清空,然后強(qiáng)制序列化到本地,以確保本地文件體積減小。

3. Android提供的數(shù)據(jù)類型并不全都支持序列化。

我們要確保全局變量的每個(gè)屬性都可以序列化。然而,并不是所有的數(shù)據(jù)類型都可以序列化的。那么,哪些數(shù)據(jù)可以序列化呢?表3-1是我經(jīng)過測(cè)試得到的結(jié)果。

表3-1 各種類型數(shù)據(jù)對(duì)序列化的支持程度

這就從另一方面證明了,我們盡量不要使用不能序列化的數(shù)據(jù)類型,包括JSONObject、JSONArray、HashMap<String,Ob-ject>、ArrayList<HashMap<String,Object>>。

新項(xiàng)目可以盡量規(guī)避這些數(shù)據(jù)類型,但是老項(xiàng)目可就棘手了。好在天無絕人之路,我經(jīng)過大量實(shí)踐,得到一些解決方案,如下所示。

  1. JSONObject和JSONArray
    雖然JSONObject不支持序列化,但是可以在設(shè)置的時(shí)候?qū)⑵滢D(zhuǎn)換為字符串,然后序列化到本地文件。在需要讀取的時(shí)候,就從本地文件反序列化處理這個(gè)字符串,然后再把字符串轉(zhuǎn)換為JSONObject對(duì)象,如下所示:
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即可。

  1. HashMap<String,Object>和ArrayList<HashMap<String,Object>>
    因?yàn)镺bject可以是各種類型,有可能是JSONObject和JSONArray,所以以上兩種類型不一定支持序列化。
    首選的解決方案是,如果HashMap中所有的對(duì)象都不是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),你會(huì)看到算法超級(jí)繁瑣,效率也非常差:

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);
    }

對(duì)于HashMap<String,Object>類型,無論是get方法還是set方法,都非常慢,因?yàn)橐闅vHashMap中存放的所有對(duì)象。ArrayList<HashMap<String,Object>>是HashMap<String,Object>的集合,所以對(duì)其進(jìn)行遍歷,會(huì)更加慢。在遇到了N多次以上解決方案導(dǎo)致的ANR之后,我決定將這兩種超級(jí)復(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并不會(huì)丟失,在重新執(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è)方法的介紹和討論不勝枚舉,下面只是分享我的使用心得。

對(duì)于游戲以及視頻播放器而言,保存頁面上每個(gè)控件的狀態(tài)是必須的,因?yàn)槊慨?dāng)Activity被銷毀,用戶都希望能恢復(fù)銷毀之前的狀態(tài),比如游戲進(jìn)行到哪個(gè)程度了,視頻播放到哪個(gè)時(shí)間點(diǎn)了。

但是對(duì)于社交類或者電商類App而言,頁面繁多,多于100個(gè)頁面的App比比皆是。如果每個(gè)頁面都保存所有控件的狀態(tài),工作量就會(huì)很大,要知道這樣的App,每個(gè)頁面都有大量的控件和交互行為,需要記錄的狀態(tài)會(huì)很多。

所以,不記錄狀態(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中的屬性也會(huì)相應(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類。我們?cè)谌魏蔚胤蕉加锌赡苁褂玫経ser這個(gè)全局變量,比如獲取用戶名、用戶昵稱、身份證號(hào)碼等等。

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中的集中幾種場(chǎng)景的設(shè)計(jì),其中包括:如何設(shè)計(jì)App圖片緩存,如何優(yōu)化網(wǎng)絡(luò)流量,對(duì)城市列表的重新思考,如何讓HTML5在App中發(fā)揮更大的作用,如何解決全局變量過多導(dǎo)致的內(nèi)存回收問題,等等。

下一章,我將介紹Android的編碼規(guī)范和命名規(guī)范。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容