本文和接下來的幾篇文章為閱讀郭霖先生所著《第一行代碼:Android(篇第2版)》的學習筆記,按照書中的內容順序進行記錄,書中的Demo本人全部都做過了。 每一章節本人都做了詳細的記錄,以下是我學習記錄(包含大量書中內容的整理和自己在學習中遇到的各種bug及解決方案),方便以後閱讀和查閱。最後,非常 ...
本文和接下來的幾篇文章為閱讀郭霖先生所著《第一行代碼:Android(篇第2版)》的學習筆記,按照書中的內容順序進行記錄,書中的Demo本人全部都做過了。
每一章節本人都做了詳細的記錄,以下是我學習記錄(包含大量書中內容的整理和自己在學習中遇到的各種bug及解決方案),方便以後閱讀和查閱。最後,非常感激郭霖先生提供這麼好的書籍。
第13章 繼續進階——你還應該掌握的高級技巧
13.1 全局獲取Context的技巧
回想這麼久以來我們所學的內容,你會發現有很多地方都需要用到Context,彈出Toast的時候需要,啟動活動的時候需要,發送廣播的時候需要,操作資料庫的時候需要,使用通知的時候需要,等等等等。
或許目前你還沒有為得不到Context而發愁過,因為我們很多的操作都是在活動中進行的,而活動本身就是一個Context對象。但是,當應用程式的架構逐漸開始複雜起來的時候,很多的邏輯代碼都將脫離Activity類,但此時你又恰恰需要使用Context,也許這個時候你就會感到有些傷腦筋了。
舉個例子來說吧,在第9章的最佳實踐環節,我們編寫了一個HttpUtil類,在這裡將一些通用的網路操作封裝了起來,代碼如下所示:
package com.zhouzhou.networktest;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
public class HttpUtil {
public static void sendHttpRequest(final String address,final HttpCallbackListener listener) {
new Thread(new Runnable() {
@Override
public void run() {
HttpURLConnection connection = null;
try {
URL url = new URL(address);
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(8000);
connection.setReadTimeout(8000);
connection.setDoInput(true);
connection.setDoOutput(true);
InputStream inputStream = connection.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
StringBuilder response = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
if (listener != null) {
// 回調onFinish()方法
listener.onFinish(response.toString());
}
} catch (Exception e) {
e.printStackTrace();
// 回調onError()方法
listener.onError(e);
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
}).start();
}
}
這裡使用sendHttpRequest()方法來發送HTTP請求顯然是沒有問題的,並且我們還可以在回調方法中處理伺服器返回的數據。但現在我們想對sendHttpRequest()方法進行一些優化,當檢測到網路不存在的時候就給用戶一個Toast提示,並且不再執行後面的代碼。看似一個挺簡單的功能,可是卻存在一個讓人頭疼的問題,彈出Toast提示需要一個Context參數,而我們在HttpUtil類中顯然是獲取不到Context對象的,這該怎麼辦呢?
其實要想快速解決這個問題也很簡單,大不了在sendHttpRequest()方法中添加一個Context參數就行了嘛,於是可以將HttpUtil中的代碼進行如下修改:
public class HttpUtil {
public static void sendHttpRequest(final Context context,final String address,final HttpCallbackListener listener) {
if (! isNetworkAvailable()) {
Toast.makeText(context,"network is unavailable",Toast.LENGT SHORT).show();
return;
}
new Thread(new Runnable() {
@Override
public void run() {
...
}
}).start();
}
private static boolean isNetworkAvailable() {
...
}
}
可以看到,這裡在方法中添加了一個Context參數,並且假設有一個isNetworkAvailable()方法用於判斷當前網路是否可用,如果網路不可用的話就彈出Toast提示,並將方法return掉。
雖說這也確實是一種解決方案,但是卻有點推卸責任的嫌疑,因為我們將獲取Context的任務轉移給了sendHttpRequest()方法的調用方,至於調用方能不能得到Context對象,那就不是我們需要考慮的問題了。
由此可以看出,在某些情況下,獲取Context並非是那麼容易的一件事,有時候還是挺傷腦筋的。不過別擔心,下麵我們就來學習一種技巧,讓你在項目的任何地方都能夠輕鬆獲取到Context。
Android提供了一個Application類,每當應用程式啟動的時候,系統就會自動將這個類進行初始化。而我們可以定製一個自己的Application類,以便於管理程式內一些全局的狀態信息,比如說全局Context。
定製一個自己的Application其實並不複雜,首先我們需要創建一個MyApplication類繼承自Application,代碼如下所示:
public class MyApplication extends Application {
private static Context context;
@Override
public void onCreate() {
context = getApplicationContext();
}
public static Context getContext() {
return context;
}
}
這裡我們重寫了父類的onCreate()方法,並通過調用getApplicationContext()方法得到了一個應用程式級別的Context,然後又提供了一個靜態的getContext()方法,在這裡將剛纔獲取到的Context進行返回。
接下來我們需要告知系統,當程式啟動的時候應該初始化MyApplication類,而不是預設的Application類。這一步也很簡單,在AndroidManifest.xml文件的<application>
標簽下進行指定就可以了,代碼如下所示:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.zhouzhou.networktest"
android:versionCode="1"
android:versionName="1.0">
...
<application
android:name="com.zhouhzou.networktest.MyApplication"
...>
...
</application>
</manifest>
註意,這裡在指定MyApplication的時候一定要加上完整的包名,不然系統將無法找到這個類。
這樣我們就已經實現了一種全局獲取Context的機制,之後不管你想在項目的任何地方使用Context,只需要調用一下MyApplication.getContext()就可以了。那麼接下來我們再對sendHttpRequest()方法進行優化,代碼如下所示:
public static void sendHttpRequest(final String address,final HttpCallbackListener listener) {
if (! isNetworkAvailable()) {
Toast.makeText(MyApplication.getContext(),"network is unavailable",Toast.LENGTH_SHORT).show();
return;
}
...
}
可以看到,sendHttpRequest()方法不需要再通過傳參的方式來得到Context對象,而是調用一下MyApplication.getContext()方法就可以了。
有了這個技巧,你再也不用為得不到Context對象而發愁了。然後我們再回顧一下6.5.2小節學過的內容,當時為了讓LitePal可以正常工作,要求必須在AndroidManifest.xml中配置如下內容:
<application
android:name="org.litepal.LitePalApplication"
...>
...
</application>
其實道理也是一樣的,因為經過這樣的配置之後,LitePal就能在內部自動獲取到Context了。
不過這裡你可能又會產生疑問,如果我們已經配置過了自己的Application怎麼辦?這樣豈不是和LitePalApplication衝突了?沒錯,任何一個項目都只能配置一個Application,對於這種情況,LitePal提供了很簡單的解決方案,那就是在我們自己的Application中去調用LitePal的初始化方法就可以了,如下所示:
public class MyApplication extends Application {
private static Context context;
@Override
public void onCreate() {
context = getApplicationContext();
LitePal.initialize(context);
}
public static Context getContext() {
return context;
}
}
使用這種寫法,就相當於我們把全局的Context對象通過參數傳遞給了LitePal,效果和在AndroidManifest.xml中配置LitePalApplication是一模一樣的。
13.2 使用Intent傳遞對象
Intent的用法:可以藉助它來啟動活動、發送廣播、啟動服務等。在進行上述操作的時候,我們還可以在Intent中添加一些附加數據,以達到傳值的效果,比如在FirstActivity中添加如下代碼:
Intent intent = new Intent(FirstActivity.this,SecondActivity.class);
intent.putExtra("string_data","hello");
intent.putExtra("int_data",100);
startActivity(intent);
這裡調用了Intent的putExtra()方法來添加要傳遞的數據,之後在SecondActivity中就可以得到這些值了,代碼如下所示:
getIntent().getStringExtra("string_data");
getIntent().getIntExtra("int_data",0);
不知道你有沒有發現,putExtra()方法中所支持的數據類型是有限的,雖然常用的一些數據類型它都會支持,但是當你想去傳遞一些自定義對象的時候,就會發現無從下手。不用擔心,下麵我們就學習一下使用Intent來傳遞對象的技巧。
13.2.1 Serializable方式
使用Intent來傳遞對象通常有兩種實現方式:Serializable和Parcelable,本小節中學習第一種實現方式:
Serializable是序列化的意思,表示將一個對象轉換成可存儲或可傳輸的狀態。序列化後的對象可以在網路上進行傳輸,也可以存儲到本地。至於序列化的方法也很簡單,只需要讓一個類去實現Serializable這個介面就可以了。比如說有一個Person類,其中包含了name和age這兩個欄位,想要將它序列化就可以這樣寫:
public class Person implements Serializable {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
其中,get、set方法都是用於賦值和讀取欄位數據的,最重要的部分是在第一行。這裡讓Person類去實現了Serializable介面,這樣所有的Person對象就都是可序列化的了。接下來在FirstActivity中的寫法非常簡單:
Person person = new Person();
person.setName("Tom");
person.setAge(20);
Intent intent = new Intent(FirstActivity.this,SecondActivity.class);
intent.putExtra("person_data",person);
startActivity(intent);
可以看到,這裡我們創建了一個Person的實例,然後就直接將它傳入到putExtra()方法中了。由於Person類實現了Serializable介面,所以才可以這樣寫。接下來在SecondActivity中獲取這個對象也很簡單,寫法如下:
Person person = (Persion) getIntent.getSerializableExtra("person_data");
這裡調用了getSerializableExtra()方法來獲取通過參數傳遞過來的序列化對象,接著再將它向下轉型成Person對象,這樣我們就成功實現了使用Intent來傳遞對象的功能了。
13.2.2 Parcelable方式
除了Serializable之外,使用Parcelable也可以實現相同的效果,不過不同於將對象進行序列化,Parcelable方式的實現原理是將一個完整的對象進行分解,而分解後的每一部分都是Intent所支持的數據類型,這樣也就實現傳遞對象的功能了。下麵我們來看一下Parcelable的實現方式,修改Person中的代碼,如下所示:
public class Person implements Parcelable {
private String name;
private int age;
...
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest,int flags) {
dest.writeString(name);//寫出name
dest.writeInt(age);//寫出age
}
public static final Parcelable.Creator<Person> CREATOR = new Parcelable.Creator<Person>() {
@Override
public Person createFromParcel(Parcel source) {
Person person = new Person();
person.name = source.readString();//讀取name
person.age = source.readInt();//讀取age
return person;
}
@Override
public Person[] newArray(int size) {
return new Person[size]
}
}
}
Parcelable的實現方式要稍微複雜一些。可以看到,首先我們讓Person類去實現了Parcelable介面,這樣就必須重寫describeContents()和writeToParcel()這兩個方法。其中describeContents()方法直接返回0就可以了,而writeToParcel()方法中我們需要調用Parcel的writeXxx()方法,將Person類中的欄位一一寫出。註意,字元串型數據就調用writeString()方法,整型數據就調用writeInt()方法,以此類推。
除此之外,我們還必須在Person類中提供一個名為CREATOR的常量,這裡創建了Parcelable.Creator介面的一個實現,並將泛型指定為Person。接著需要重寫createFromParcel()和newArray()這兩個方法,在createFromParcel()方法中我們要去讀取剛纔寫出的name和age欄位,並創建一個Person對象進行返回,其中name和age都是調用Parcel的readXxx()方法讀取到的,註意這裡讀取的順序一定要和剛纔寫出的順序完全相同。而newArray()方法中的實現就簡單多了,只需要new出一個Person數組,並使用方法中傳入的size作為數組大小就可以了。
接下來,在FirstActivity中我們仍然可以使用相同的代碼來傳遞Person對象,只不過在SecondActivity中獲取對象的時候需要稍加改動,如下所示:
Person person = (Person) getIntent().getParcelableExtra("person_data");
註意,這裡不再是調用getSerializableExtra()方法,而是調用getParcelableExtra()方法來獲取傳遞過來的對象了,其他的地方都完全相同。
對比一下,Serializable的方式較為簡單,但由於會把整個對象進行序列化,因此效率會比Parcelable方式低一些,所以在通常情況下還是更加推薦使用Parcelable的方式來實現Intent傳遞對象的功能。
13.3 定製自己的日誌工具
雖然Android中自帶的日誌工具功能非常強大,但也不能說是完全沒有缺點,例如在列印日誌的控制方面就做得不夠好。
打個比方,你正在編寫一個比較龐大的項目,期間為了方便調試,在代碼的很多地方都列印了大量的日誌。最近項目已經基本完成了,但是卻有一個非常讓人頭疼的問題,之前用於調試的那些日誌,在項目正式上線之後仍然會照常列印,這樣不僅會降低程式的運行效率,還有可能將一些機密性的數據泄露出去。
那該怎麼辦呢?難道要一行一行地把所有列印日誌的代碼都刪掉?顯然這不是什麼好點子,不僅費時費力,而且以後你繼續維護這個項目的時候可能還會需要這些日誌。因此,最理想的情況是能夠自由地控制日誌的列印,當程式處於開發階段時就讓日誌列印出來,當程式上線了之後就把日誌屏蔽掉。看起來好像是挺高級的一個功能,其實並不複雜,我們只需要定製一個自己的日誌工具就可以輕鬆完成了。比如新建一個LogUtil類,代碼如下所示:
public class LogUtil {
public static final int VERBOSE = 1;
public static final int DEBUG =2;
public static final int INFO = 3;
public static final int WARN = 4;
public static final int ERROR = 5;
public static final int NOTHING = 6;
public static int level = VERBOSE;
public static void v (String tag,String msg) {
if (level <= VERBOSE) {
Log.v (tag,msg);
}
}
public static void d (String tag,String msg) {
if (level <= DEBUG) {
Log.d (tag,msg);
}
}
public static void i (String tag,String msg) {
if (level <= INFO) {
Log.i (tag,msg);
}
}
public static void w (String tag,String msg) {
if (level <=WARN) {
Log.w (tag,msg);
}
}
public static void e(String tag,String msg) {
if (level <= ERROR) {
Log.e (tag,msg);
}
}
}
可以看到,我們在LogUtil中先是定義了VERBOSE、DEBUG、INFO、WARN、ERROR、NOTHING這6個整型常量,並且它們對應的值都是遞增的。然後又定義了一個靜態變數level,可以將它的值指定為上面6個常量中的任意一個。
接下來我們提供了v()、d()、i()、w()、e()這5個自定義的日誌方法,在其內部分別調用了Log.v()、Log.d()、Log.i()、Log.w()、Log.e()這5個方法來列印日誌,只不過在這些自定義的方法中我們都加入了一個if判斷,只有當level的值小於或等於對應日誌級別值的時候,才會將日誌列印出來。
這樣就把一個自定義的日誌工具創建好了,之後在項目里我們可以像使用普通的日誌工具一樣使用LogUtil,比如列印一行DEBUG級別的日誌就可以這樣寫:
LogUtil.d("TAG","debug log");
列印一行WARN級別的日誌就可以這樣寫:
LogUtil.w("TAG","warn log");
然後,只需要修改level變數的值,就可以自由地控制日誌的列印行為了。比如讓level等於VERBOSE就可以把所有的日誌都列印出來,讓level等於WARN就可以只列印警告以上級別的日誌,讓level等於NOTHING就可以把所有日誌都屏蔽掉。
使用了這種方法之後,剛纔所說的那個問題就不復存在了,你只需要在開發階段將level指定成VERBOSE,當項目正式上線的時候將level指定成NOTHING就可以了。
13.4 調試Android程式
當開發過程中遇到一些奇怪的bug,但又遲遲定位不出來原因是什麼的時候,最好的解決辦法就是調試了。調試允許我們逐行地執行代碼,並可以實時觀察記憶體中的數據,從而能夠比較輕易地查出問題的原因。
那麼本節中我們就來學習一下使用Android Studio來調試Android程式的技巧。(以第5章的最佳實踐環節中,程式中有一個登錄功能,比如說現在登錄出現了問題,我們就可以通過調試來定位問題的原因。)
調試工作的第一步肯定是添加斷點,添加斷點的方法,只需要在相應代碼行的左邊點擊一下就可以了,如圖:
如果想要取消這個斷點,對著它再次點擊就可以了。添加好了斷點,接下來就可以對程式進行調試了,點擊Android Studio頂部工具欄中的Debug按鈕,就會使用調試模式來啟動程式:
等到程式運行起來的時候,首先會看到一個提示框,如圖:
這個框很快就會自動消失,然後在輸入框里輸入賬號和密碼,並點擊Login按鈕,這時Android Studio就會自動打開Debug視窗,如圖:
接下來每按一次F8健,代碼就會向下執行一行,並且通過Variables視圖還可以看到記憶體中的數據,如圖:
可以看到,我們從輸入框里獲取到的賬號密碼分別是abc和123,而程式里要求正確的賬號密碼是admin和123456,所以登錄才會出現問題。這樣我們就通過調試的方式輕鬆地把問題定位出來了,調試完成之後點擊Debug視窗中的Stop按鈕來結束調試即可:
這種調試方式雖然完全可以正常工作,但在調試模式下,程式的運行效率將會大大地降低,如果你的斷點加在一個比較靠後的位置,需要執行很多的操作才能運行到這個斷點,那麼前面這些操作就都會有一些卡頓的感覺。
沒關係,Android還提供了另外一種調試的方式,可以讓程式隨時進入到調試模式,下麵我們就來嘗試一下。
這次不需要選擇調試模式來啟動程式了,就使用正常的方式來啟動程式。由於現在不是在調試模式下,程式的運行速度比較快,可以先把賬號和密碼輸入好。然後點擊Android Studio頂部工具欄的Attach debugger to Androidprocess按鈕:
此時會彈出一個進程選擇提示框,如圖:
這裡目前只列出了一個進程,也就是我們當前程式的進程。選中這個進程,然後點擊OK按鈕,就會讓這個進程進入到調試模式了。
接下來在程式中點擊Login按鈕,Android Studio同樣也會自動打開Debug視窗,之後的流程就都是相同的了。相比起來,第二種調試方式會比第一種更加靈活,也更加常用。
13.5 創建定時任務
Android中的定時任務一般有兩種實現方式:
-
一種是使用Java API里提供的Timer類;
Timer有一個明顯的短板,它並不太適用於那些需要長期在後臺運行的定時任務。我們都知道,為了能讓電池更加耐用,每種手機都會有自己的休眠策略,Android手機就會在長時間不操作的情況下自動讓CPU進入到睡眠狀態,這就有可能導致Timer中的定時任務無法正常運行。
-
一種是使用Android的Alarm機制;
Alarm則具有喚醒CPU的功能,它可以保證在大多數情況下需要執行定時任務的時候CPU都能正常工作。需要註意,這裡喚醒CPU和喚醒屏幕完全不是一個概念,千萬不要產生混淆。
13.5.1 Alarm機制
那麼首先我們來看一下Alarm機制的用法,主要就是藉助了AlarmManager類來實現的。這個類和NotificationManager有點類似,都是通過調用Context的getSystemService()方法來獲取實例的,只是這裡需要傳入的參數是Context.ALARM_ SERVICE。因此,獲取一個AlarmManager的實例就可以寫成:
AlarmManager manager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
接下來調用AlarmManager的set()方法就可以設置一個定時任務了,比如說想要設定一個任務在10秒鐘後執行,就可以寫成:
Long triggerAtTime = SystemClock.elapsedRealtime() + 10 * 1000;
manager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP,triggerAtTime,pendingIntent);
上面的兩行代碼,因為set()方法中需要傳入的3個參數:
- 第一個參數是一個整型參數,用於指定AlarmManager的工作類型,有4種值可選,分別是ELAPSED_REALTIME、ELAPSED_REALTIME_WAKEUP、RTC和RTC_WAKEUP。
- ELAPSED_REALTIME表示讓定時任務的觸發時間從系統開機開始算起,但不會喚醒CPU。
- ELAPSED_REALTIME_WAKEUP同樣表示讓定時任務的觸發時間從系統開機開始算起,但會喚醒CPU。-
- RTC表示讓定時任務的觸發時間從1970年1月1日0點開始算起,但不會喚醒CPU。
- RTC_WAKEUP同樣表示讓定時任務的觸發時間從1970年1月1日0點開始算起,但會喚醒CPU。
使用SystemClock.elapsedRealtime()方法可以獲取到系統開機至今所經歷時間的毫秒數,使用System.currentTimeMillis()方法可以獲取到1970年1月1日0點至今所經歷時間的毫秒數。
- 第二個參數就是定時任務觸發的時間,以毫秒為單位。
如果第一個參數使用的是ELAPSED_REALTIME或ELAPSED_REALTIME_WAKEUP,則這裡傳入開機至今的時間再加上延遲執行的時間。如果第一個參數使用的是RTC或RTC_WAKEUP,則這裡傳入1970年1月1日0點至今的時間再加上延遲執行的時間。
- 第三個參數是一個PendingIntent。
這裡我們一般會調用getService()方法或者getBroadcast()方法來獲取一個能夠執行服務或廣播的Pending-Intent。這樣當定時任務被觸發的時候,服務的onStartCommand()方法或廣播接收器的onReceive()方法就可以得到執行。瞭解了set()方法的每個參數之後,你應該能想到,設定一個任務在10秒鐘後執行也可以寫成:
Long triggerAtTime = System.currentTimeMillis() + 10 * 1000;
manager.set (AlarmManager.RTC_WAKEUP,triggerAtTime,pendingIntent);
那麼,如果我們要實現一個長時間在後臺定時運行的服務該怎麼做呢?其實很簡單,首先新建一個普通的服務,比如把它起名叫LongRunningService,然後將觸發定時任務的代碼寫到onStartCommand()方法中,如下所示:
public class LongRunningService extends Service {
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent,int flags,int startId) {
new Thread(new Runnable() {
@Override
public void run() {
//這裡執行具體的邏輯操作
}
}).start();
AlarmManager manager = (AlarmManager) getSystemService(ALARM_SERVICE);
int anHour = 60 * 60 * 1000;
Long triggerAtTime = SystemClock.elapsedRealtime() + anHour;
Intent i = new Intent(this,LongRunningService.class);
pendingIntent pi = pendingIntent.getService(this,0,i,0);
manager.set (AlarmManager.ELAPSED_REALTIME_WAKEUP,triggerAtTime,pi);
return super.onStartCommand(intent,flags,startId);
}
}
可以看到,我們先是在onStartCommand()方法中開啟了一個子線程,這樣就可以在這裡執行具體的邏輯操作了。之所以要在子線程里執行邏輯操作,是因為邏輯操作也是需要耗時的,如果放在主線程里執行可能會對定時任務的準確性造成輕微的影響。
創建線程之後的代碼就是我們剛剛講解的Alarm機制的用法了,先是獲取到了AlarmManager的實例,然後定義任務的觸發時間為一小時後,再使用PendingIntent指定處理定時任務的服務為LongRunningService,最後調用set()方法完成設定。
這樣我們就將一個長時間在後臺定時運行的服務成功實現了。因為一旦啟動了LongRunningService,就會在onStartCommand()方法里設定一個定時任務,這樣一小時後將會再次啟動LongRunningService,從而也就形成了一個永久的迴圈,保證LongRunningService的onStartCommand()方法可以每隔一小時就執行一次。
最後,只需要在你想要啟動定時服務的時候調用如下代碼即可:
Intent intent = new Intent(context,LongRunningService.class);
context.startService(intent);
另外需要註意的是,從Android 4.4系統開始,Alarm任務的觸發時間將會變得不准確,有可能會延遲一段時間後任務才能得到執行。這並不是個bug,而是系統在耗電性方面進行的優化。系統會自動檢測目前有多少Alarm任務存在,然後將觸發時間相近的幾個任務放在一起執行,這就可以大幅度地減少CPU被喚醒的次數,從而有效延長電池的使用時間。
當然,如果你要求Alarm任務的執行時間必須準確無誤,Android仍然提供瞭解決方案。使用AlarmManager的setExact()方法來替代set()方法,就基本上可以保證任務能夠準時執行了。
13.5.2 Doze模式
雖然Android的每個系統版本都在手機電量方面努力進行優化,不過一直沒能解決後臺服務泛濫、手機電量消耗過快的問題。於是在Android 6.0系統中,谷歌加入了一個全新的Doze模式,從而可以極大幅度地延長電池的使用壽命。
到底什麼是Doze模式。當用戶的設備是Android 6.0或以上系統時,如果該設備未插接電源,處於靜止狀態(Android 7.0中刪除了這一條件),且屏幕關閉了一段時間之後,就會進入到Doze模式。在Doze模式下,系統會對CPU、網路、Alarm等活動進行限制,從而延長了電池的使用壽命。
當然,系統並不會一直處於Doze模式,而是會間歇性地退出Doze模式一小段時間,在這段時間中,應用就可以去完成它們的同步操作、Alarm任務,等等。完整描述了Doze模式的工作過程,如下圖:
可以看到,隨著設備進入Doze模式的時間越長,間歇性地退出Doze模式的時間間隔也會越長。因為如果設備長時間不使用的話,是沒必要頻繁退出Doze模式來執行同步等操作的,Android在這些細節上的把控使得電池壽命進一步得到了延長。
具體看一看在Doze模式下有哪些功能會受到限制:
❑ 網路訪問被禁止。
❑ 系統忽略喚醒CPU或者屏幕操作。
❑ 系統不再執行WIFI掃描。
❑ 系統不再執行同步服務。
❑ Alarm任務將會在下次退出Doze模式的時候執行。
註意:其中的最後一條,也就是說,在Doze模式下,我們的Alarm任務將會變得不准時。當然,這在大多數情況下都是合理的,因為只有當用戶長時間不使用手機的時候才會進入Doze模式,通常在這種情況下對Alarm任務的準時性要求並沒有那麼高。
不過,如果你真的有非常特殊的需求,要求Alarm任務即使在Doze模式下也必須正常執行,Android還是提供瞭解決方案。調用AlarmManager的setAndAllowWhileIdle()或setExactAndAllowWhileIdle()方法就能讓定時任務即使在Doze模式下也能正常執行了,這兩個方法之間的區別和set()、setExact()方法之間的區別是一樣的。
13.6 多視窗模式編程
(以下為記錄書上的內容,我手上沒有Android 7.0及以上的手機。前面學習的內容,我測試用的都是6.0的)
由於手機屏幕大小的限制,傳統情況下一個手機只能同時打開一個應用程式,無論是Android、iOS還是Windows Phone都是如此。而Android 7.0系統中卻引入了一個非常有特色的功能——多視窗模式,它允許我們在同一個屏幕中同時打開兩個應用程式。
13.6.1 進入多視窗模式
首先你需要知道,我們不用編寫任何額外的代碼來讓應用程式支持多視窗模式。事實上,本書中所編寫的所有項目都是支持多視窗模式的。但是這並不意味著我們就不需要對多視窗模式進行學習,因為系統化地瞭解這些知識點才能編寫出在多視窗模式下相容性更好的程式。
那麼先來看一下如何才能進入到多視窗模式。手機的導航欄上面一共有3個按鈕,如圖:
其中左邊的Back按鈕和中間的Home按鈕,右邊的Overview按鈕,這個按鈕的作用是打開一個最近訪問過的活動或任務的列表界面,從而能夠方便地在多個應用程式之間進行切換,如圖:
可以通過以下兩種方式進入多視窗模式。
❑ 在Overview列表界面長按任意一個活動的標題,將該活動拖動到屏幕突出顯示的區域,則可以進入多視窗模式。
❑ 打開任意一個程式,長按Overview按鈕,也可以進入多視窗模式。比如說我們首先打開了MaterialTest程式,然後長按Overview按鈕,效果如圖:
可以看到,現在整個屏幕被分成了上下兩個部分,MaterialTest程式占據了上半屏,下半屏仍然還是一個Overview列表界面,另外Overview按鈕的樣式也有了變化。現在我們可以從Overview列表中選擇任意一個其他程式,比如說這裡點擊LBSTest,效果如圖:
還可以將模擬器旋轉至水平方向,這樣上下分屏的多視窗模式會自動切換成左右分屏的多視窗模式,如圖:
多視窗模式的用法大概就是這個樣子了,我們可以將任意兩個應用同時打開,這樣就能組合出許多更為豐富的使用場景。
可以看出,在多視窗模式下,整個應用的界面會縮小很多,那麼編寫程式時就應該多考慮使用match_parent屬性、RecyclerView、ListView、ScrollView等控制項,來讓應用的界面能夠更好地適配各種不同尺寸的屏幕,儘量不要出現屏幕尺寸變化過大時界面就無法正常顯示的情況。
13.6.2 多視窗模式下的生命周期
多視窗模式下的生命周期。其實多視窗模式並不會改變活動原有的生命周期,只是會將用戶最近交互過的那個活動設置為運行狀態,而將多視窗模式下另外一個可見的活動設置為暫停狀態。如果這時用戶又去和暫停的活動進行交互,那麼該活動就變成運行狀態,之前處於運行狀態的活動變成暫停狀態。
下麵我們還是通過一個例子來更加直觀地理解多視窗模式下活動的生命周期。首先打開MaterialTest項目,修改MainActivity中的代碼,如下所示:
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
...
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Log.d(TAG,"onCreate");
...
}
@Override
protected void onStart(Bundle savedInstanceState) {
super.onStart(savedInstanceState);
Log.d(TAG,"onStart");
}
@Override
protected void onResume(Bundle savedInstanceState) {
super.onResume(savedInstanceState);
Log.d(TAG,"onResume");
}
@Override
protected void onPause(Bundle savedInstanceState) {
super.onPause(savedInstanceState);
Log.d(TAG,"onPause");
}
@Override
protected void onStop(Bundle savedInstanceState) {
super.onStop(savedInstanceState);
Log.d(TAG,"onSop");
}
@Override
protected void onDestroy(Bundle savedInstanceState) {
super.onDestroy(savedInstanceState);
Log.d(TAG,"onDestroy");
}
@Override
protected void onRestart(Bundle savedInstanceState) {
super.onRestart(savedInstanceState);
Log.d(TAG,"onRestart");
}
...
}
這裡我們在Activity的7個生命周期回調方法中分別列印了一句日誌。然後點擊Android Studio導航欄上的File→Open Recent→LBSTest,重新打開LBSTest項目。修改MainActivity的代碼,如下所示:
public class MainActivity extends AppCompatActivity {
private static final String TAG = "LBSTest";
...
@Override
protected void onCreate() {
super.onCreate();
Log.d(TAG,"onCreate");
...
}
...
@Override
protected void onStart() {
super.onStart();
Log.d(TAG,"onStart");
}
@Override
protected void onResume() {
super.onResume();
Log.d(TAG,"onResume");
mapView.onResume();
}
@Override
protected void onPause() {
super.onPause();
Log.d(TAG,"onPause");
mapView.onPause();
}
@Override
protected void onStop() {
super.onStop();
Log.d(TAG,"onSop");
}
@Override
protected void onDestroy() {
super.onDestroy();
Log.d(TAG,"onDestroy");
mLocationClient.stop();
baiduMap.setMyLocationEnabled(false);
}
@Override
protected void onRestart() {
super.onRestart();
Log.d(TAG,"onRestart");
}
...
}
這裡同樣也是在Activity的7個生命周期回調方法中分別列印了一句日誌。註意這兩處日誌的TAG是不一樣的,方便我們進行區分。
現在,先將MaterialTest和LBSTest這兩個項目的最新代碼都運行到模擬器上,然後啟動MaterialTest程式。這時觀察logcat中的列印日誌(註意要將logcat的過濾器選擇為No Filters),如圖:
可以看到,onCreate()、onStart()和onResume()方法會依次得到執行,這個也是在我們意料之中的。然後長按Overview按鈕,進入多視窗模式,此時的列印信息如圖:
會發現,MaterialTest中的MainActivity經歷了一個重新創建的過程。其實這個是正常現象,因為進入多視窗模式後活動的大小發生了比較大的變化,此時預設是會重新創建活動的。
除此之外,像橫豎屏切換也是會重新創建活動的。進入多視窗模式後,MaterialTest變成了暫停狀態。接著在Overview列表界面選中LBSTest程式,列印信息如圖:
可以看到,現在LBSTest的onCreate()、onStart()和onResume()方法依次得到了執行,說明現在LBSTest變成了運行狀態。接下來我們可以隨意操作一下MaterialTest程式,然後觀察logcat中的列印日誌,如圖:
現在LBSTest的onPause()方法得到了執行,而MaterialTest的onResume()方法得到了執行,說明LBSTest變成了暫停狀態,MaterialTest則變成了運行狀態,這和我們在本小節開頭所分析的生命周期行為是一致的。
瞭解了多視窗模式下活動的生命周期規則,那麼我們在編寫程式的時候,就可以將一些關鍵性的點考慮進去了。
比如說,在多視窗模式下,用戶仍然可以看到處於暫停狀態的應用,那麼像視頻播放器之類的應用在此時就應該能繼續播放視頻才對。因此,我們最好不要在活動的onPause()方法中去處理視頻播放器的暫停邏輯,而是應該在onStop()方法中去處理,並且在onStart()方法恢復視頻的播放。
另外,針對於進入多視窗模式時活動會被重新創建,如果你想改變這一預設行為,可以在AndroidManifest.xml中對活動進行如下配置:
<activity
android:name=".MainActivity"
android:lable="Fruits"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout">
...
</activity>
加入了這行配置之後,不管是進入多視窗模式,還是橫豎屏切換,活動都不會被重新創建,而是會將屏幕發生變化的事件通知到Activity的onConfigurationChanged()方法當中。因此,如果你想在屏幕發生變化的時候進行相應的邏輯處理,那麼在活動中重寫onConfiguration-Changed()方法即可。
13.6.3 禁用多視窗模式
多視窗模式雖然功能非常強大,但是未必就適用於所有的程式。比如說,手機游戲就非常不適合在多視窗模式下運行,很難想象我們如何一邊玩著游戲,一邊又操作著其他應用。因此,Android還是給我們提供了禁用多視窗模式的選項,如果你非常不希望自己的應用能夠在多視窗模式下運行,那麼就可以將這個功能關閉掉。
禁用多視窗模式的方法非常簡單,只需要在AndroidManifest.xml的<application>
或<activity>
標簽中加入如下屬性即可:
android:resizeableActivity=["true" | "false"]
其中,true表示應用支持多視窗模式,false表示應用不支持多視窗模式,如果不配置這個屬性,那麼預設值為true。現在我們將MaterialTest程式設置為不支持多視窗模式,如下所示:
<application
...
android:resizeableActivity="false">
...
</application>
重新運行程式,然後長按Overview按鈕,結果如圖:
可以看到,現在是無法進入到多視窗模式的,而且屏幕下方還會彈出一個Toast提示來告知用戶,當前應用不支持多視窗模式。
雖說android:resizeableActivity
這個屬性的用法很簡單,但是它還存在著一個問題,就是這個屬性只有當項目的targetSdkVersion指定成24或者更高的時候才會有用,否則這個屬性是無效的。那麼比如說我們將項目的targetSdkVersion指定成23,這個時候嘗試進入多視窗模式,結果如圖:
可以看到,雖說界面上彈出了一個提示,告知我們此應用在多視窗模式下可能無法正常工作,但還是進入了多視窗模式。
針對這種情況,還有一種解決方案。Android規定,如果項目指定的targetSdkVersion低於24,並且活動是不允許橫豎屏切換的,那麼該應用也將不支持多視窗模式。
預設情況下,我們的應用都是可以隨著手機的旋轉自由地橫豎屏切換的,如果想要讓應用不允許橫豎屏切換,那麼就需要在AndroidManifest.xml的<activity>
標簽中加入如下配置:
android:screenOrientation=["portrait"|"landscape"]
其中,portrait表示活動只支持豎屏,landscape表示活動只支持橫屏。當然android:screenOrientation屬性中還有很多其他可選值,不過最常用的就是portrait和landscape了。現在我們將MaterialTest的MainActivity設置為只支持豎屏,如下所示:
<activity
android:name=".MainActivity"
android:lable="Fruits"
android:screenOrientation="portrait">
...
</activity>
重新運行程式之後你會發現MaterialTest現在不支持橫豎屏切換了,此時長按Overview按鈕會彈出禁用多視窗模式的提示。
13.7 Lambda表達式
Java 8中著實引入了一些非常有特色的功能,如Lambda表達式、streamAPI、介面預設實現,等等。
雖然剛纔已經提到了幾個Java 8中的新特性,不過現在能夠立即應用到項目當中的也就只有Lambda表達式而已,因為stream API和介面預設實現等特性都只支持Android 7.0及以上的系統,我們顯然不可能為了使用這些新特性而放棄相容眾多低版本的Android手機。而Lambda表達式卻最低相容到Android2.3系統,基本上可以算是覆蓋所有的Android手機了。
Lambda表達式本質上是一種匿名方法,它既沒有方法名,也即沒有訪問修飾符和返回值類型,使用它來編寫代碼將會更加簡潔,也更加易讀。
如果想要在Android項目中使用Lambda表達式或者Java 8的其他新特性,首先我們需要在app/build.gradle中添加如下配置:
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
註意,不要加入:
jackOptions {
enabled true
}
https://developer.android.com/studio/write/java8-support?utm_source=android-studio 這裡提到Jack已不被支持。
之後就可以開始使用Lambda表達式來編寫代碼了,比如說傳統情況下開啟一個子線程的寫法如下:
new Thread(new Runnable() {
@Override
public void run() {
//處理具體邏輯
}
}).start();
而使用Lambda表達式則可以這樣寫:
new Thread(() -> {
//處理具體邏輯
}).start();
不管是從代碼行數上還是縮進結構上來看,Lambda表達式的寫法明顯要更加精簡。
那麼為什麼我們可以使用這麼神奇的寫法呢?這是因為Thread類的構造函數接收的參數是一個Runnable介面,並且該介面中只有一個待實現方法。我們查看一下Runnable介面的源碼,如下所示:
public interface Runnable {
/**
* Start executing the active part of the class' code. This method is
* called when a thread is started that has been created with a class which
* implements {@code Runnable}
*/
public void run();
}
凡是這種只有一個待實現方法的介面,都可以使用Lambda表達式的寫法。比如說,通常創建一個類似於上述介面的匿名類實現需要這樣寫:
Runnable runnable = new Runnable() {
@Override
public void run() {
//添加具體邏輯
}
};
//而有了Lambda表達式之後,我們就可以這樣寫了:
Runnable runnable = () -> {
//添加具體邏輯
};
瞭解了Lambda表達式的基本寫法,接下來我們嘗試自定義一個介面,然後再使用Lambda表達式的方式進行實現。新建一個MyListener介面,代碼如下所示:
public interface MyListener {
String doSomething(String a,int b);
}
MyListener介面中也只有一個待實現方法,這和Runnable介面的結構是基本一致的。唯一不同的是,MyListener中的doSomething()方法是有參數並且有返回值的,那麼我們就來看一看這種情況下該如何使用Lambda表達式進行實現。其實寫法也是比較相似的,使用Lambda表達式創建MyListener介面的匿名實現寫法如下:
MyListener listener = (String a,int b) -> {
String result = a + b;
return result;
};
可以看到,doSomething()方法的參數直接寫在括弧裡面就可以了,而返回值則仍然像往常一樣,寫在具體實現的最後一行即可。另外,Java還可以根據上下文自動推斷出Lambda表達式中的參數類型,因此上面的代碼也可以簡化成如下寫法:
MyListener listener = (a ,b) -> {
String result = a + b;
return result;
}
Java將會自動推斷出參數a是String類型,參數b是int類型,從而使得我們的代碼變得更加精簡了。接下來舉個具體的例子,比如說現在有一個方法是接收MyListener參數的,如下所示:
public void hello (MyListener listener) {
String a = "Hello Lambda";
int b = 1024;
Stirng result = listener.doSomething(a,b);
Log.d("TAG",result);
}
在調用hello()這個方法的時候就可以這樣寫:
hello((a,b) -> {
String result a + b;
return result;
})
那麼doSomething()方法就會將a和b兩個參數進行相加,從而最終的列印結果就會是“Hello Lambda1024”。
接下來我們看一看在Android當中有哪些常用的功能是可以使用Lambda表達式進行替換的。
其實只要是符合介面中只有一個待實現方法這個規則的功能,都是可以使用Lambda表達式來編寫的。除了剛纔舉例說明的開啟子線程之外,還有像設置點擊事件之類的功能也是非常適合使用Lambda表達式的。
傳統情況下,我們給一個按鈕設置點擊事件需要這樣寫:
Button button = (Button) findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 處理點擊事件
}
});
而使用Lambda表達式之後,就可以將代碼簡化成這個樣子了:
Button button = (Button) findViewById(R.id.button);
button.setOnClickListener((v) -> {
// 處理點擊事件
});
另外,當介面的待實現方法有且只有一個參數的時候,我們還可以進一步簡化,將參數外面的括弧去掉,如下所示:
Button button = (Button) findViewById(R.id.button);
button.setOnClickListener(v -> {
// 處理點擊事件
});
當然,有些人可能並不喜歡Lambda表達式這種極簡主義的寫法。不管你喜歡與否,Java 8對於哪一種寫法都是完全支持的,至於到底要不要使用Lambda表達式其實全憑個人。
13.8 總結
這13章的內容不算很多,但卻已經把Android中絕大部分比較重要的知識點都覆蓋到了。從搭建開發環境開始學起,後面逐步學習了四大組件、UI、碎片、數據存儲、多媒體、網路、定位服務、Material Design等內容,本章中又學習瞭如全局獲取Context、定製日誌工具、調試程式、多視窗模式編程、Lambda表達式等高級技巧。