同樣是使用Java語言,為什么做MobileAPI的開發人員寫不了Android程序,反之亦然。我想大概是各行有各行的規矩和做事法則,本章介紹的這幾種Android經典場景就是如此,看似都是些平談無奇的UI,但其中卻蘊藏著大智慧。
閑話少敘,且聽我一一道來。
3.1 App圖片緩存設計
App緩存分為兩部分,數據緩存和圖片緩存。我們在第2章的2.2節介紹了App數據緩存,從而把從Mo-bileAPI獲取到的數據緩存到本地,減少了調用MobileAPI的次數。本節將介紹圖片緩存策略。
3.1.1 ImageLoader設計原理
Android上最讓人頭疼的莫過于從網絡獲取圖片、顯示、回收,任何一個環節有問題都可能直接OOM。尤其是在列表頁,會加載大量網絡上的圖片,每當快速劃動列表的時候,都會很卡,甚至會因為內存溢出而崩潰。
這時就輪到ImageLoader上場表演了。ImageLoader的目的是為了實現異步的網絡圖片加載、緩存及顯示,支持多線程異步加載。
ImageLoader的工作原理是這樣的:在顯示圖片的時候,它會先在內存中查找;如果沒有,就去本地查找;如果還沒有,就開一個新的線程去下載這張圖片,下載成功會把圖片同時緩存到內存和本地。
基于這個原理,我們可以在每次退出一個頁面的時候,把ImageLoader內存中的緩存全都清除,這樣就節省了大量內存,反正下次再用到的時候從本地再取出來就是了。
此外,由于ImageLoader對圖片是軟引用的形式,所以內存中的圖片會在內存不足的時候被系統回收(內存足夠的時候不會對其進行垃圾回收)。
3.1.2 ImageLoader的使用
ImageLoader由三大組件組成:
- ImageLoaderConfiguration——對圖片緩存進行總體配置,包括內存緩存的大小、本地緩存的大小和位置、日志、下載策略(FIFO還是LIFO)等等。
- ImageLoader——我們一般使用displayImage來把URL對應的圖片顯示在ImageView上。
- DisplayImageOptions——在每個頁面需要顯示圖片的地方,控制如何顯示的細節,比如指定下載時的默認圖(包括下載中、下載失敗、URL為空等),是否將緩存放到內存或者本地磁盤。
借用博客園上陳哈哈的博文對三者關系的一個比喻,“他們有點像廚房規定、廚師、客戶個人口味之間的關系。Im-ageLoaderConfiguration就像是廚房里面的規定,每一個廚師要怎么著裝,要怎么保持廚房的干凈,這是針對每一個廚師都適用的規定,而且不允許個性化改變。ImageLoader就像是具體做菜的廚師,負責具體菜譜的制作。DisplayImageOptions就像每個客戶的偏好,根據客戶是重口味還是清淡,每一個ImageLoader根據DisplayImageOptions的要求具體執行。”
下面我們介紹如何使用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加載圖片的地方,配置當前頁面的ImageLoader選項。有可能是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,代碼片段節選自CinemaAdapter:
context.imageLoader.displayImage(cinemaList.get(position)
.getCinemaPhotoUrl(), holder.imgPhoto);
其中displayImage方法的第一個參數是圖片的URL,第二個參數是ImageView控件。
一般來說,ImageLoader性能如果有問題,就和這里的配置有關,尤其是ImageLoader-Configuration。我列舉在上面的配置代碼是目前比較通用的,請大家參考。
3.1.3 ImageLoader優化
盡管ImageLoader很強大,但一直把圖片緩存在內存中,會導致內存占用過高。雖然對圖片的引用是軟引用,軟引用在內存不夠的時候會被GC,但我們還是希望減少GC的次數,所以要經常手動清理ImageLoader中的緩存。
我們在AppBaseActivity中的onDestroy方法中,執行Im-ageLoader的clearMemoryCache方法,以確保頁面銷毀時,把為了顯示這個頁面而增加的內存緩存清除。這樣,即使到了下個頁面要復用之前加載過的圖片,雖然內存中沒有了,根據Im-ageLoader的緩存策略,還是可以在本地磁盤上找到:
public abstract class AppBaseActivity extends BaseActivity {
protected boolean needCallback;
protected ProgressDialog dlg;
public ImageLoader imageLoader = ImageLoader.getInstance();
protected void onDestroy() {// 回收該頁面緩存在內存的圖片
imageLoader.clearMemoryCache();
super.onDestroy();
}
}
本章沒有過多討論ImageLoader的代碼實現,只是描述了它的實現原理。有興趣的朋友可以參考下列文章,里面有很深入的研究:
簡介ImageLoader。地址:http://blog.csdn.net/yueqinglkong/article/de-tails/27660107
Android-Universal-Image-Loader圖片異步加載類庫的使用(超詳細配置)。地址: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
就在本書寫作期間,Facebook開源了它的Android圖片加載組件Fresco。
我之所以關注這個Fresco組件,是因為我負責的App用一段時間后就占據了180M左右的內存,App會變得很卡。我們使用MAT分析內存,發現讓內存居高不下的罪魁禍首就是圖片。于是我們把目光轉向Fresco,開始優化App占用的內存。
Fresco使用起來很簡單,如下所示:
- 在Application級別,對Fresco進行初始化,如下所示:Fresco.initialize(getApplicationContext());
- 與ImageLoader等傳統第三方圖片處理SDK不同,Fresco是基于控件級別的,所以我們把程序中顯示網絡圖片的Im-ageView都替換為SimpleDraweeView即可,并在Im-ageView所在的布局文件中添加fresco命名空間,如下所示:
- 在Activity中為這個圖片控件指定要顯示的網絡圖片:
Uri uri = Uri.parse("http:// www.bb.com/a.png");draweeView.setImageURI(uri);
Fresco的原理是,設計了一個Image Pipeline的概念,它負責先后檢查內存、磁盤文件(Disk),如果都沒有再老老實實從網絡下載圖片,如圖3-1所示,箭頭上標記了jpg或bmp格式的,表示Cache中有圖片,直接取出;沒有標記,則表示Cache中找不到。
我們可以像配置ImageLoader那樣配置Fresco中的ImagePipeline,使用ImagePipelineConfig來做這個事情。
Fresco有3個線程池,其中3個線程用于網絡下載圖片,2個線程用于磁盤文件的讀寫,還有2個線程用于CPU相關操作,比如圖片解碼、轉換,以及放在后臺執行的一些費時操作。
接下來介紹Fresco三層緩存的概念。這才是Fresco最核心的技術,它比其他圖片SDK吃內存小,就在于這個全新的緩存設計。
第一層:Bitmap緩存
- 在Android 5.0系統中,考慮到內存管理有了很大改進,所以Bitmap緩存位于Java的堆(heap)中。
- 而在Android 4.x和更低的系統,Bitmap緩存位于ash-mem中,而不是位于Java的堆(heap)中。這意味著圖片的創建和回收不會引發過多的GC,從而讓App運行得更快。
當App切換到后臺時,Bitmap緩存會被清空。
第二層:內存緩存
內存緩存中存儲了圖片的原始壓縮格式。從內存緩存中取出的圖片,在顯示前必須先解碼。當App切換到后臺時,內存緩存也會被清空。
第三層:磁盤緩存
磁盤緩存,又名本地存儲。磁盤緩存中存儲的也是圖片的原始壓縮格式。在使用前也要先解碼。當App切換到后臺時,磁盤緩存不會丟失,即使關機也不會。
Fresco有很多高級的應用,對于大部分App而言,基本還用不到。只要掌握上述簡單的使用方法就能極大地節省內存了。我做的App原先占用180MB的內存,現在只會占據80MB左右的內存了。這也是我為什么要在本書中增加這一部分內容的原因。
關于Fresco的更多介紹請參見:
- Fresco在GitHub上的源碼:https://github.com/mkottman/AndroLua
- Fresco官方文檔:http://fresco-cn.org/docs/index.html
3.2 對網絡流量進行優化
對App的最低容忍限度是,在2G、3G和4G網絡環境下,每個頁面都能打開,都能正常跳轉到其他頁面。要能夠完成一次完整的支付流程。
慢點兒沒關系,尤其是2G網絡。但是動不動就彈出“無法連接到網絡”或者“網絡連接超時”的對話框,就是我們開發人員必須要解決的問題了。
3.2.1 通信層面的優化
讓我們先從MobileAPI層面進行優化:
MobileAPI接口返回的數據,要使用gzip進行壓縮。注意:大于1KB才進行壓縮,否則得不償失。經過gzip壓縮后,返回的數據量大幅減少。
App與MobileAPI之間的數據傳遞,通常是遵守JSON協議的。JSON因為是xml格式的,并且是以字符存在的,在數據量上還有可以壓縮的空間。我這里推薦一種新的數據傳輸協議,那就是ProtoBuffer。這種協議是二進制格式的,所以在表示大數據時,空間比JSON小很多。
接下來要解決的是頻繁調用MobileAPI的問題。我們知道,發起一次網絡請求,服務器處理的速度是很快的,主要花費的時間在數據傳輸上,也就是這一來一回走路的時間上。
走路時間的長度,網絡運維人員會去負責解決。移動開發人員需要關注的是,減少網絡訪問次數,能調用一次MobileAPI接口就能取到數據的,就不要調用兩次。我們知道,傳統的MobileAPI使用的是HTTP無狀態短連接。使用HTTP協議的速度遠不如使用TCP協議,因為后者是長連接。所以我們可以使用TCP長連接,以提高訪問的速度。缺點是一臺服務器能支持的長連接個數不多,所以需要更多的服務器集成。
要建立取消網絡請求的機制。一個頁面如果沒有請求完網絡數據,在跳轉到另一個頁面之前,要把之前的網絡請求都取消,不再等待,也不再接收數據。
我遇到過一個真實的例子,首頁要在后臺調用十幾個MobileAPI接口,用戶一旦進入二級頁面,在二級頁面獲取列表數據時,經常會取不到數據,并彈出“網絡請求超時”的提示。我們通過在App輸出log的方式發現,二級頁面還在調用首頁沒有完成的那些MobileAPI接口,App網絡底層的請求隊列已經被阻塞了,原因是在進入下一個頁面時,首頁發起的網絡請求仍然存在于網絡請求隊列中,并沒有移除掉。
無論是iOS還是Android,都應該在基類(BaseViewCon-troller或者BaseActivity)中提供一個cancelRequest的方法,用以在離開當前頁面時清空網絡請求隊列。增加重試機制。如果MobileAPI是嚴格的RESTful風格,那么我們一般將獲取數據的請求接口都定義為get;而把操作數據的請求接口都定義為post。
這樣的話,我們就可以為所有的get請求配置重試機制,比如get請求失敗后重試3次。
有人會問post請求失敗后,是否需要重試呢?我們舉個例子吧,比如說下單接口是個post請求,如果請求失敗那么就會重試3次,直到下單成功。但是有時候post請求并沒有失敗,而是超時了,超時時間是30秒,但是卻31秒返回了,如果因此而重新發起下單請求,那么就會連續下單兩次。所以post請求是不建議有重試機制的。此外,對所有的post請求,都要增加防止用戶1分鐘內頻繁發起相同請求的機制,這樣就能有效防止重復下單、重復發表評論、重復注冊等操作。
如果post請求具有防重機制,那么倒是可以增加重試機制。但是要可以在服務器端靈活配置重試的次數,可以是0次,意味著不會重試。在App啟動的時候,告訴App所有的MobileAPI接口的重試次數。
3.2.2 圖片策略優化
首先,我們從圖片層面進行優化,這里說的圖片,是根據MobileAPI返回的圖片URL地址新啟一個線程下載到App本地并顯示的。很多App崩潰的原因就是圖片的問題沒處理好。
1. 要確保下載的每張圖,都符合ImageView控件的大小
這對于Android是有難度的,因為手機分辨率千奇百怪,所以App中的圖片,我們大多做成自適應的,有時是等比拉伸或縮放圖片的寬和高,有時則固定高度而動態伸縮寬度,反之亦然。
于是我們要求運營人員要事先準備很多套不同分辨率的圖片。我們每次根據URL請求圖片時,都要額外在URL上加上兩個參數,width和height,從而要求服務器返回其中某一張圖,URL如下所示:http://www.aaa.com/a.png?width=100&height=50。
如果認為每次準備很多套圖片是件很浪費人力的事情,我還有另一種解決方案,這種方案只需要一張圖。但我們需要事先準備一臺服務器,稱為ImageServer。具體流程是這樣的:
- 首先,App每次加載圖片,都會把URL地址以及width和height參數所組成的字符串進行encode,然后發送給Image-Server,新的URL如下所示:
http://www.ImageServer.com/getImage?param=(encodevalue) - 然后,ImageServer收到這個請求,會把param的值de-code,得到原始圖片的URL,以及App想要顯示的這張圖片的width和height。ImageServer會根據URL獲取到這張原始圖片,然后根據width和height,重新進行繪制,保存到Image-Server上,并返回給App。
- 最后,App請求到的是一張符合其顯示大小的圖片。
接下來收到同樣的請求,直接返回ImageServer上保存的那種圖片即可。但是要每天清一次硬盤,不然過不了幾天硬盤就滿了。
如果width和height的比例與原圖的寬高比不一致呢?我們需要再加一個參數imagetype,以下是定義:
- 1表示等比縮放后,裁減掉多余的寬或者高。
- 2表示等比縮放后,不足的寬或者高填充白色。
當然你也可以定義0表示不進行縮放,直接返回。
這種方案的缺點就是,ImageServer頻繁地寫硬盤,硬盤堅持不到兩周就壞掉。所以,我們在損失了幾塊硬盤后,決定事先規定幾套width和height,App必須嚴格遵守,比如說100×50,200×100,那么就不允許向服務器發送類似99×51這樣的圖片尺寸。
但這樣規定,并不能防止App開發人員犯錯,他在UI上就是不小心為某個ImageView控件指定了99×51這樣的尺寸,那么ImageServer還是會生成這樣的圖片。
唯一的辦法就是在出口加以控制,也就是向ImageServer發起請求的時候。我們會拿99×51這個實際的圖片尺寸,去輪詢我們事先規定好的那幾個尺寸100×50和200×100,看更接近哪個,比如說99×51更接近100×50,那么就向ImageServer請求100×50這種尺寸的圖片。
找最接近圖片尺寸的辦法是面積法:
S = (w1-a) × (w1-w) + (h1-h) × (h1-h)
w和h是實際的圖片寬和高,w1和h1是事先規定的某個尺寸的寬和高。S最小的那個,就是最接近的。
2. 低流量模式
在2G和3G網絡環境下,我們應該適當降低圖片的質量。降低圖片質量,相應的圖片大小也會降低,我們稱為低流量模式。
還記得我們前面提到的ImageServer嗎?我們可以在URL中再增加一個參數quality,2G網絡下這個值為50%,3G網絡下這個值為70%,我們把這個參數傳遞給ImageServer,從而Im-ageServer在繪制圖片時,就會將jpg圖片質量降低為50%或70%,這樣返回給App的數據量就大大減少了。
在列表頁,這種效果最為明顯,能極大的節省用戶流量。
3. 極速模式
我們后來發現,在2G和3G網絡環境下,用戶大多對圖片不感興趣,他們可能就是想快速下單并支付,我們需要額外設計一些頁面,區別于正常模式下圖文并茂的頁面,我們將這些只有文字的頁面稱為極速模式。
比如,首頁往往圖片占據多數,而且這些圖片大多數從網絡動態下載的,在2G網絡下,這些圖片是很浪費流量的。所以在極速模式下,我們需要設計一個只有純文字的首頁。
在每次開啟App進入首頁前會先進行預判,如果發現當前網絡環境為2G、3G或4G,但是當前模式為正常模式,就會彈出一個對話框詢問用戶,是否要進入極速模式以節省流量。如果是WiFi網絡環境,但當前模式是極速模式,也會提示用戶是否要切換回正常模式,以看到最炫的效果。
僅在開啟App時提示用戶極速模式是不夠的,我們在設置頁也要提供這個開關,供用戶手動切換。
3.3 城市列表的設計
很多App都有城市列表這一功能。看似簡單,但就像登錄功能一樣,做好它并不容易。
一份城市列表的數據包括以下幾個字典:
- cityId:城市Id。
- cityName:城市名稱。
- pinyin:城市全拼。
- jianpin:城市簡拼。
其中,全拼和簡拼是用來在App本地做字母表排序和關鍵字檢索的。
我曾經經歷過把城市列表數據寫死在本地文件的做法,日積月累,就會產生兩個問題:
- Android和iOS維護的數據,差異會越來越大。
- 一千多個城市,每次從本地加載都要很長時間。
針對問題1的解決辦法是,寫一個文本分析工具,找出An-droid和iOS各自維護文件的不同數據。
iOS開發人員喜歡使用plist文件作為數據存儲的載體,最好能和Android統一使用一份xml文件,這樣便于管理類似城市列表這樣的數據。
針對問題2的解決方案是,對于一千多個城市,意味著每次都要解析xml城市數據文件,既然每次讀取數據都很慢,那么我們干脆就把序列化過的城市列表直接保存到本地文件,跟隨App一起發布。這樣,每次讀取這個文件時,就直接進行反序列化即可,速度得到很大提升。
把城市列表數據保存在本地,有個很煩的事情,就是每次增加新的城市,都要等下次發版,因為數據是寫死在App本地的。于是,我們把城市列表數據做成一個MobileAPI接口,由MobileAPI去后臺采集數據,這樣數據是最新最準的。
但是這樣做的問題是,這個MobileAPI接口返回的數據量會很大,上千筆數據,還包括那么多字段,即使打開了gzip壓縮,也會有100k的樣子。于是我們又增加了版本號字段version的概念,這個MobileAPI接口的定義和返回的JSON格式是這樣的:
- 入參。version,本地存儲的城市列表數據對應的版本號。
- 返回值。如果傳入參數version和線上最新版本號一致,則返回以下固定格式:
{
"isMatch": false,
"version": 1,
"cities": [
{
},
]
}
如果傳入參數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這個字段由MobileAPI進行更新,每當有城市數據更新時,version可以立即自增+1,也可以積累到一定數據后自增+1。具體策略由MobileAPI來決定。
基于此,App的策略可以是這樣的:
- 本地仍然保存一份線上最新的城市列表數據(序列化后的)以及對應的版本號。我們要求每次發版前做一次城市數據同步的事情。
- 每次進入到城市列表這個頁面時,將本地城市列表數據對應的版本號version傳入到MobileAPI接口,根據返回的is-Match值來決定是否版本號一致。如果一致,則直接從本地文件中加載城市列表數據;否則,就解析MobileAPI接口返回的數據,在顯示列表的同時,記得要把最新的城市列表數據和版本號保存到本地。
- 如果MobileAPI接口沒有調用成功,也是直接從本地文件中加載城市列表數據,以確保主流程是暢通的。
- 每次調用MobileAPI時,會獲取到大量的數據,一般我們會打開gzip對數據進行壓縮,以確保傳輸的數據量最小。
3.3.2 城市列表數據的增量更新機制
上節中我們談到,每當有城市數據更新時,version可以立即自增+1。我的問題是,如何判斷有城市數據更新?一種解決方案是,在服務器建立一個Timer,每十分鐘跑一次,檢查10分鐘前后的數據是否有改動,如果有,version就自增+1,并返回這些有改動的數據(新增、刪除和修改)。這樣就保證了10分鐘內,從A改成B又改回A,這時候我們認為是沒有改動的,版本號不需要自增+1。
那么問題來了,對于1000筆城市數據,每次只改動其中的幾筆,返回數據中包括那些沒有改動過的數據是沒有意義的,是否可以只返回這些改動的數據?
分析1.0和2.0版本的城市列表數據,每筆數據都有cityId和其他一些字段,比如說城市名稱、簡拼、全拼等。我畫了一個表,如圖3-2所示,試圖展示出1.0和2.0這兩個版本的城市數據之間的異同。
我來解釋一下圖3-2,以cityId作為唯一標識,只在1.0中出現的cityId是要刪除的數據,只在2.0中出現的cityId是要增加的數據,二者的交集則是cityId相同的數據,這又分為兩種情況,所有字段都相同的數據是不變的數據;cityId相同但某個字段不相同,則是修改的數據。
增量更新的數據,就由增、刪、改這3部分數據構成。于是,我們可以重新定義城市列表的JSON格式,在每筆增量數據中增加一個字段type,用來區別是增(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數據后,會根據type值來處理存放在本地的數據。因為不是全量更新,所以處理起來很快。這種增量更新城市數據的策略,會使得App的邏輯很簡單,但是服務器的邏輯很復雜。這樣做是劃算的,我們要想盡辦法確保App的輕量,把復雜的業務邏輯放在后端。
3.4 App與HTML5的交互
App與HTML5的交互,是一個可以大做文章的話題。有的團隊直接使用PhoneGap來實現交互的功能,而我則認為PhoneGap太重了。我們完全可以把這些交互操作在底層封裝好,然后給開發人員使用。
為了開發人員方便,我們要準備一臺測試用的PC服務器,在上面搭建一個IIS,這樣可以快速搭建自己的Demo,對于App開發人員而言,不需要等待HTML5團隊就可以自行開發并測試了。他們只需知道一些基本的Html和JavaScript語法,而相應的培訓非常簡單。
3.4.1 App操作HTML5頁面的方法
為了演示方便,我在assets中內置了一個HTML5頁面。現實中,這個HTML5頁面是放在遠程服務器上的。
首先要定好通信協議,也就是App要調用的HTML5頁面中JavaScript的方法名稱。
例如,App要調用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頁面的方法
仍然是先定義通信協議,這次定義的是JavaScript要調用的Android中方法名稱。
例如,點擊HTML5的文字,回調Java中的callAndroid-Method方法:
- HTML5
<a onclick="baobao.callAndroidMethod(100,100,'ccc',true)">
CallAndroidMethod</a>
- Android
新創建一個JSInterface1類,包括callAndroidMethod方法的實現:
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();
}
}
}
同時,需要注冊baobao和JSInterface1的對應關系:
wvAds.addJavascriptInterface(new JSInteface1(), "baobao");
調試期間我發現對于小米3系統,要在方法前增加@JavascriptInterface,否則,就不能觸發JavaScript方法。
3.4.3 App和HTML5之間定義跳轉協議
根據上面的例子,運營團隊就找到了在App中搞活動的解決方案。不必等到App每次發新版才能看到新的活動頁面,而是每次做一個HTML5的活動頁面,然后通過MobileAPI把這個HTML5頁面的地址告訴App,由App加載這個HTML5頁面即可。
在這個HTML5頁面中,我們可以定義各種JavaScript點擊事件,從而跳轉回App的任意Native頁面。
為此,HTML5團隊需要事先和App團隊約定好一個格式,例如:
gotoPersonCenter
gotoMovieDetail:movieId=100
gotoNewsList:cityId=1&cityName=北京
gotoUrl:http://www.sina.com
這個協議具體在HTML5頁面中是這樣的,以gotoNewsList為例:
<a onclick="baobao.gotoAnyWhere(
'gotoNewsList:cityId=(int)12&cityName=北京')">
gotoAnyWhere</a>
其中,有些協議是不需要參數的,比如說gotoPersonCen-ter,也就是個人中心;有些則需要跳轉到具體的電影詳情頁,我們需要知道movieId;有時候1個參數不夠用,我們需要更多的參數,才能準確獲取到我們想要的數據,比如說gotoNewsList,我們想要跳轉到2014年12月31號北京的所有新聞信息,就不得不需要cityId和createdTime兩個參數,處理協議的代碼如下所示:
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分支邏輯太多,我們要想辦法將其進行抽象,參見后面3.4.6節介紹的頁面分發器。
3.4.4 在App中內置HTML5頁面
什么時候在App中內置HTML5頁面?根據我的經驗,當有些UI不太容易在App中使用原生語言實現時,比如畫一個奇形怪狀的表格,這是HTML5所擅長的領域,只要調整好屏幕適配,就可以很好地應用在App中。
下面詳細介紹如何在頁面中顯示一個表格,表格里的數據都是動態填充的。
1. 首先定義兩個HTML5文件,放在assets目錄下。
其中,102.html是靜態頁:
<html>
<head>
</head>
<body>
<table>
<data1DefinedByBaobao>
</table>
</body>
</html>
而data1_template.html是一個數據模板,它負責提供表格中一行的樣式:
<tr>
<td>
<name>
</td>
<td>
<price>
</td>
</tr>
像<name>、<price>和<data1DefinedByBaobao>都是占位符,我們接下來會使用真實的數據來替換這些占位符。
2. 在MovieDetailActivity中,通過遍歷movieList這個集合,我們把數據填充到sbContent中,最終,把拼接好的字符串替換<data1DefinedByBaobao>標簽:
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頁面的策略
對于經常需要改動的頁面,我們會把它做成HTML5頁面,在App中以WebView的形式加載。這樣就避免了Native頁面每次修改,都要等一次迭代上線后才能看到——周期太長了,這不是產品經理所希望的。
此外,HTML5的另一個好處是,開發周期短——相比App開發而言。但是HTML5的缺點是慢。
我們來看一下HTML5頁面生成的步驟:
- 從服務器端動態獲取數據并拼接成一個HTML。
- 返回給客戶端WebView。
- 在WebView中解析并生成這個HTML。
相對于Native原生頁面加載JSON這種短小精悍的數據并展現在客戶端而言,HTML5肯定是慢了很多。魚和熊掌不可兼得,于是我們只能在靈活性和性能上作出取舍。
但是我們可以換一個思路來解決這個問題。我同時做兩套頁面,Native一套,HTML5一套,然后在App中設置一個變量,來判斷該頁面將顯示Native還是HTML5的。
這個變量可以從MobileAPI獲取,這樣的話,正常情況下,是Native頁面,如果有類似雙十一或雙十二的促銷活動,我們可以修改這個變量,讓頁面以HTML5的形式展現。這樣,我們只要做個HTML5的頁面發布到線上就行了。等活動結束后再撤回到Native頁面。
以此類推,App中所有的頁面,都可以做成上述這種形式,為此,我們需要改變之前做App的思路,比如:
- 需要做一個后臺,根據版本進行配置每個頁面是使用Na-tive頁面還是HTML5頁面。
- 在App啟動的時候,從MobileAPI獲取到每個頁面是Native還是HTML5。
- 在App的代碼層面,頁面之間要實現松耦合。為此,我們要設計一個導航器Navigator,由它來控制該跳轉到Native頁面還是HTML5頁面。最大的挑戰是頁面間參數傳遞,字典是一種比較好的形式,消除了不同頁面對參數類型的不同要求。
接下來,就是App運營人員和產品經理隨心所欲的進行配置了。
在實際的操作中,一定要注意,HTML5頁面只是權宜之計,可以快速上一個活動,比如類似于雙十一的節假日,從而以迅雷不及掩耳之勢打擊競爭對手。隨著HTML5和Native的不同步,當一個頁面再從HTML5切換回Native時,我們會發現,它們的邏輯已經差了很多了,切回來就會有很多bug,而我們又只能是在App發布后才發現這樣的問題。
唯一的解決方案是,把App和HTML5劃歸到一個團隊,由產品經理整理二者的差異性,要做到二者盡量同步,一言以蔽之,App要時刻追趕HTML5的邏輯,追趕上了就切換回Native。
3.4.6 頁面分發器
我們知道,跳轉到一個Activity,需要傳遞一些參數。這些參數的類型簡單如int和String,復雜的則是列表數據或者可序列化的自定義實體。
但是,如果從HTML5頁面跳轉到Native頁面,是不大可能傳遞復雜類型的實體的,只能傳遞簡單類型。所以,并不是每個Native頁面都可以替換為HTML5。
接下來要討論的是,對于那些來自HTML5頁面、傳遞簡單類型的頁面跳轉請求,我們將其抽象為一個分發器,放到BaseActivity中。還記得我們在3.4.3節定義的協議嗎,以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>
我們看到,協議的內容分成3段,第一段是Android要跳轉到的Activity的名稱。第二段是iOS要跳轉到的ViewController的名稱,第三段是需要傳遞的參數,以key-value的形式進行組裝。
我們接下來要做的就是從協議URL中取出第1段,將其反射為一個Activity對象,取出第3段,將其解析為key-value的形式,然后從當前頁面跳轉到目標頁面并配以正確的參數。其中,寫一個輔助函數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);
}
}
注意,在協議中定義這些簡單數據類型的時候,String是不需要指定類型的,這是使用最廣泛的類型。對于int、Double等簡單類型,我們要在值前面加上類似(int)這樣的約定,這樣才能在解析時不出問題。
3.5 消滅全局變量
本節我們要討論的是一個深刻的話題。相信很多人都遇到過App莫名其妙就崩潰的情況,尤其是一些配置很低的手機,重現場景就是在App切換到后臺,閑置了一段時間后再繼續使用時,就會崩潰。
3.5.1 問題的發現
導致上述崩潰發生的罪魁禍首就是全局變量。下述代碼就是在生成一個全局變量:
public class GlobalVariables {
public static UserBean User;
}
在內存不足的時候,系統會回收一部分閑置的資源,由于App被切換到后臺,所以之前存放的全局變量很容易被回收,這時再切換到前臺繼續使用,在使用某個全局變量的時候,就會因為全局變量的值為空而崩潰。這不是個例。我經歷過最糟糕的App竟然使用了200多個全局變量,任何頁面從后臺切換回前臺都有崩潰的可能。
想徹底解決這個問題,就一定要使用序列化技術。
3.5.2 把數據作為Intent的參數傳遞
想一勞永逸地解決上述問題就是不使用全局變量,使用Intent來進行頁面間數據的傳遞。因為,即使目標Activity被系統銷毀了,Intent上的數據仍然存在,所以Intent是保存數據的一個很好的地方,比本地文件靠譜。但是Intent能傳遞的數據類型也必須支持序列化,像JSONObject這樣的數據類型,是傳遞不過去的。對于一個有200多個全局變量的App而言,重構的工作量很大,風險也很大。
另外,如果Intent上攜帶的數據量過大,也會發生崩潰。第7章會對此有詳細的介紹。
3.5.3 把全局變量序列化到本地
另一個比較穩妥的解決方案是,我們仍然使用全局變量,在每次修改全局變量的值的時候,都要把值序列化到本地文件中,這樣的話,即使內存中的全局變量被回收,本地還保存有最新的值,當我們再次使用全局變量時,就從本地文件中再反序列化到內存中。
這樣就解了燃眉之急,數據不再丟失。但長遠之計還是要一個模塊一個模塊地將全局變量轉換為Intent上可序列化的實體數據。但這是后話,眼前,我們先要把全局變量序列化到本地文件,如下所示,我們對全局GlobalsVariables變量進行改造:
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首次啟動,文件不存在則新建之
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個方法用于序列化— — — — — — — —
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被回收的問題。我們對其進行詳細分析:
1. 首先,這是一個單例,我們只能以如下方式來讀寫user數據:
UserBean user = GlobalsVariables.getInstance().getUser();
GlobalsVariables.getInstance().setUser(user);
同時,GlobalsVariables還必須實現Serializable接口,以支持序列化自身到本地。然而,為了使一個單例類變成可序列化的,僅僅在聲明中添加“implements Serializable”是不夠的。因為一個序列化的對象在每次反序列化的時候,都會創建一個新的對象,而不僅僅是一個對原有對象的引用。為了防止這種情況,需要在單例類中加入readResolve方法和readObject方法,并實現Cloneable接口。
2. 我們仔細看GlobalsVariables這個類的構造函數。這和一般的單例模式寫的不太一樣。我們的邏輯是,先判斷instance是否為空,不為空,證明全局變量沒有被回收,可以繼續使用;為空,要么是第一次啟動App,本地文件都不存在,更不要說序列化到本地了;要么是全局變量被回收了,于是我們需要從本地文件中將其還原回來。
為此,我們在Utils類中編寫了restoreObject和saveObject兩個方法,分別用于把全局變量序列化到本地和從本地文件反序列化到內存,如下所示:
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屬性,具有getUser和SetUser這兩個方法。我們就看這個setUser方法,它會在每次設置一個新值后,執行一次Utils類的saveObject方法,把新數據序列化到本地。
值得注意的是,如果全局變量中有一個自定義實體的屬性,那么我們也要將這個自定義實體也聲明為可序列化的,UserBean實體就是一個很好的例子。它作為全局變量的一個屬性,其自身也必須實現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);
}
- 在目標頁PersonCenterActivity:
protected void initVariables() {
UserBean user = GlobalVariables.getInstance().getUser();
int age = user.getAge();
}
- 在App啟動的時候,我們要清空存儲在本地文件的全局變量,因為這些全局變量的生命周期都應該伴隨著App的關閉而消亡,但是我們來不及在App關閉的時候做,所以只好在App啟動的時候第一件事情就是清除這些臨時數據:
GlobalVariables.getInstance().reset();
為此,需要在GlobalVariables這個全局變量類中增加一個reset方法,用于清空數據后把空值強制保存到本地。
public void reset() {
user = null;
Utils.saveObject(AppConstants.CACHEDIR + TAG, this);
}
3.5.4 序列化的缺點
再次強調,把全局變量序列化到本地的方案,只是一種過渡型解決方案,它有幾個硬傷:
1. 每次設置全局變量的值都要強制執行一次序列化的操作,容易造成ANR。
我們看一個例子,寫一個新的全局變量GlobalVariables3,它有3個屬性,如下所示:
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設值的時候,如下所示:
private void simulateANR() {
GlobalVariables3.getInstance().setUserName("jianqiang.bao");
GlobalVariables3.getInstance().setNickName("包包");
GlobalVariables3.getInstance().setCountry("China");
}
我們會發現,每次設置值的時候,都要將GlobalVariables3強制序列化到本地一次。性能會很差,如果屬性多了,強制序列化的次數也會變多,因為讀寫文件的次數多了,就會造成ANR。
相應的解決方案很丑陋,如下所示:
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);
}
}
也就是說,為每個set方法多加一個boolean參數,來控制是否要在改動后做序列化。同時在GlobalVariables3中提供一個save方法,就是做序列化的操作。
這樣改動之后,我們再給GlobalVariables3設值的時候就要這樣寫了:
private void simulateANR2() {
GlobalVariables3.getInstance().setUserName("bao", false);
GlobalVariables3.getInstance().setNickName("包包", false);
GlobalVariables3.getInstance().setCountry("China", false);
GlobalVariables3.getInstance().save();
}
也就是說,每次set后不做序列化,都設置完后,一次性序列化到本地。這么寫代碼很惡心,但我之前說過,這只是權宜之計,相當于打補丁,是臨時的解決方案。
2. 序列化生成的文件,會因為內存不夠而丟失。
這個問題也是在把全局變量都序列化到本地后發現的,究其原因,就是因為我們將序列化的本地文件放在了內存/data/data/com.youngheart/cache/這個目錄下。內存空間十分有限,因而顯得可貴,一旦內存空間耗盡,手機也就無法使用了。因為我們的全局變量非常多,所以內部空間會耗盡,這個序列化文件會被清除。其實SharedPreferences和SQLite數據庫也都是存儲在內存空間上,所以這個文件如果太大,也會引發數據丟失的問題。
有人問我為什么不存在SD卡上,嗯,SD卡確實空間大得很,但是不穩定,不是所有的手機ROM對其都有完好的支持,我不能相信它。
臨時解決方案是,每次使用完一個全局變量,就要將其清空,然后強制序列化到本地,以確保本地文件體積減小。
3. Android提供的數據類型并不全都支持序列化。
我們要確保全局變量的每個屬性都可以序列化。然而,并不是所有的數據類型都可以序列化的。那么,哪些數據可以序列化呢?表3-1是我經過測試得到的結果。
這就從另一方面證明了,我們盡量不要使用不能序列化的數據類型,包括JSONObject、JSONArray、HashMap<String,Ob-ject>、ArrayList<HashMap<String,Object>>。
新項目可以盡量規避這些數據類型,但是老項目可就棘手了。好在天無絕人之路,我經過大量實踐,得到一些解決方案,如下所示。
- JSONObject和JSONArray
雖然JSONObject不支持序列化,但是可以在設置的時候將其轉換為字符串,然后序列化到本地文件。在需要讀取的時候,就從本地文件反序列化處理這個字符串,然后再把字符串轉換為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>>
因為Object可以是各種類型,有可能是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中存放的每個Object,將其轉換為字符串。
以下是代碼實現,你會看到算法超級繁瑣,效率也非常差:
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方法,都非常慢,因為要遍歷HashMap中存放的所有對象。ArrayList<HashMap<String,Object>>是HashMap<String,Object>的集合,所以對其進行遍歷,會更加慢。在遇到了N多次以上解決方案導致的ANR之后,我決定將這兩種超級復雜的數據結構,全部改造為可序列化的實體。好在這樣的數據類型在App中不太多,重構的成本不是很大。
3.5.5 如果Activity也被銷毀了呢
如果內存不足導致當前Activity也被銷毀了呢?比如說旋轉屏幕從豎屏到橫屏。
即使Activity被銷毀了,傳遞到這個Activity的Intent并不會丟失,在重新執行Activity的onCreate方法時,Intent攜帶的bundle參數還是在的。所以,我們的解決方案是重新執行當前Activity的onCreate方法,這樣做最安全。
但是另一個問題就又浮出水面了:Activity需要保存頁面狀態嗎?
想必各位親們都看過Android SDK中的貪食蛇游戲,它講的就是在Activity被銷毀后保存貪食蛇的位置,這樣的話,恢復該頁面時就能根據之前保存的貪食蛇的位置繼續游戲。
這個Demo用到了Activity的以下2個方法:
- onSaveInstanceState()
- onRestoreInstanceState()
網上關于以上兩個方法的介紹和討論不勝枚舉,下面只是分享我的使用心得。
對于游戲以及視頻播放器而言,保存頁面上每個控件的狀態是必須的,因為每當Activity被銷毀,用戶都希望能恢復銷毀之前的狀態,比如游戲進行到哪個程度了,視頻播放到哪個時間點了。
但是對于社交類或者電商類App而言,頁面繁多,多于100個頁面的App比比皆是。如果每個頁面都保存所有控件的狀態,工作量就會很大,要知道這樣的App,每個頁面都有大量的控件和交互行為,需要記錄的狀態會很多。
所以,不記錄狀態,直接讓頁面重新執行一遍onCreate方法,是一種比較穩妥的方法。丟失的數據,是頁面加載完成之后的用戶行為,讓用戶重新操作一遍就是了。
額外說一句,想保存頁面狀態,是件很難的事情。這一點WindowsPhone做得很好,因為它是基于MVVM的編程模型,它把業務邏輯ViewModel和頁面View徹底分開,同時,View中的每個控件的狀態,都與ViewModel中的屬性進行了綁定,這樣的話,View中控件狀態變化,ViewModel中的屬性也會相應變化,反之亦然。所以把ViewModel序列化到本地,即使View被銷毀了,重新創建View,并把保存到本地的ViewModel與之綁定,就可以重現View被銷毀之前的狀態——我們稱為墓碑機制。
不得不說,微軟的墓碑機制確實做得很好,它吸取了iOS和Android的經驗,讓恢復頁面狀態變得容易很多。
3.5.6 如何看待SharedPreferences
在我們決定禁止使用全局變量后,曾經一段時間確實有了很好的效果,但是我后來仔細一看項目,新的全局變量倒是真的不再有了,大家都改為存取SharedPreferences的方式了。
在我看來,SharedPreferences是全局變量序列化到本地的另一種形式。SharedPreferences中也是可以存取任何支持序列化的數據類型的。
我們應該嚴格控制SharedPreferences中存放的變量的數量。有些數據存在SharedPreferences中是合理的,比如說當前所在城市名稱、設置頁面的那些開關的狀態等等。但不要把頁面跳轉時要傳遞的數據放在SharedPreferences中。這時候,要優先考慮使用Intent來傳遞數據。
3.5.7 User是唯一例外的全局變量
依我看來,App中只有一個全局變量的存在是合理的,那就是User類。我們在任何地方都有可能使用到User這個全局變量,比如獲取用戶名、用戶昵稱、身份證號碼等等。
User這個全局變量的實現,可以參考本章講解的例子。
每次登錄,都要把登錄成功后獲取到的用戶信息保存到User類。以后,每當User的屬性有變動時,我們都要把User保存一次。退出登錄,就把User類的信息進行清空。與之前我們所設計的全局變量不同,App啟動時不需要清空User類的數據。因為我們希望App記住上次用戶的登錄狀態以及用戶信息。再講下去就涉及用戶Cookie的機制了。
3.6 本章小結
本章討論了App中的集中幾種場景的設計,其中包括:如何設計App圖片緩存,如何優化網絡流量,對城市列表的重新思考,如何讓HTML5在App中發揮更大的作用,如何解決全局變量過多導致的內存回收問題,等等。
下一章,我將介紹Android的編碼規范和命名規范。