如何編寫高效的Android程式碼

想念你的Android發表於2018-12-14

現代的手持裝置,與其說是電話,更像一臺拿在手中的電腦。但是,即使是“最快”的手持裝置,其效能也趕不上一臺普通的臺式電腦。

這就是為什麼我們在書寫Android應用程式的時候要格外關注效率。這些裝置並沒有那麼快,並且受電池電量的制約。這意味著,裝置沒有更多的能力,我們必須把程式寫的儘量有效。

本文討論了很多能讓開發者使他們的程式執行更有效的方法,遵照這些方法,你可以使你的程式發揮最大的效力。

簡介

對於佔用資源的系統,有兩條基本原則

不要做不必要的事
不要分配不必要的記憶體

所有下面的內容都遵照這兩個原則。

有些人可能馬上會跳出來,把本節的大部分內容歸於“草率的優化”(xing:參見[The Root of All Evil]),不可否認微優化(micro-optimization。xing:程式碼優化,相對於結構優化)的確會帶來很多問題,諸如無法使用更有效的資料結構和演算法。但是在手持裝置上,你別無選擇。假如你認為Android虛擬機器的效能與桌上型電腦相當,你的程式很有可能一開始就佔用了系統的全部記憶體(xing:記憶體很小),這會讓你的程式慢得像蝸牛一樣,更遑論做其他的操作了。

Android的成功依賴於你的程式提供的使用者體驗。而這種使用者體驗,部分依賴於你的程式是響應快速而靈活的,還是響應緩慢而僵化的。因為所有的程式都執行在同一個裝置之上,都在一起,這就如果在同一條路上行駛的汽車。而這篇文件就相當於你在取得駕照之前必須要學習的交通規則。如果大家都按照這些規則去做,駕駛就會很順暢,但是如果你不這樣做,你可能會車毀人亡。這就是為什麼這些原則十分重要。

當我們開門見山、直擊主題之前,還必須要提醒大家一點:不管VM是否支援實時(JIT)編譯器(xing:它允許實時地將Java解釋型程式自動編譯成本機機器語言,以使程式執行的速度更快。有些JVM包含JIT編譯器。),下面提到的這些原則都是成立的。假如我們有目標完全相同的兩個方法,在解釋執行時foo()比bar()快,那麼編譯之後,foo()依然會比bar()快。所以不要寄希望於編譯器可以拯救你的程式。

Android高階開發資料、高階UI、效能優化、架構師課程、NDK、混合式開發(ReactNative+Weex)等相關資料詳情請看主頁

避免建立物件

世界上沒有免費的物件。雖然GC為每個執行緒都建立了臨時物件池,可以使建立物件的代價變得小一些,但是分配記憶體永遠都比不分配記憶體的代價大。

如果你在使用者介面迴圈中分配物件記憶體,就會引發週期性的垃圾回收,使用者就會覺得介面像打嗝一樣一頓一頓的。

所以,除非必要,應儘量避免盡力物件的例項。下面的例子將幫助你理解這條原則:

當你從使用者輸入的資料中擷取一段字串時,儘量使用substring函式取得原始資料的一個子串,而不是為子串另外建立一份拷貝。這樣你就有一個新的String物件,它與原始資料共享一個char陣列。
如果你有一個函式返回一個String物件,而你確切的知道這個字串會被附加到一個StringBuffer,那麼,請改變這個函式的引數和實現方式,直接把結果附加到StringBuffer中,而不要再建立一個短命的臨時物件。
一個更極端的例子是,把多維陣列分成多個一維陣列。

int陣列比Integer陣列好,這也概括了一個基本事實,兩個平行的int陣列比(int,int)物件陣列效能要好很多。同理,這試用於所有基本型別的組合。
如果你想用一種容器儲存(Foo,Bar)元組,嘗試使用兩個單獨的Foo[]陣列和Bar[]陣列,一定比(Foo,Bar)陣列效率更高。(也有例外的情況,就是當你建立一個API,讓別人呼叫它的時候。這時候你要注重對API藉口的設計而犧牲一點兒速度。當然在API的內部,你仍要儘可能的提高程式碼的效率)

總體來說,就是避免建立短命的臨時物件。減少物件的建立就能減少垃圾收集,進而減少對使用者體驗的影響。

使用本地方法

當你在處理字串的時候,不要吝惜使用String.indexOf(), String.lastIndexOf()等特殊實現的方法(specialty methods)。這些方法都是使用C/C++實現的,比起Java迴圈快10到100倍。

使用實類比介面好

假設你有一個HashMap物件,你可以將它宣告為HashMap或者Map:

Map myMap1 = new HashMap();
HashMap myMap2 = new HashMap();複製程式碼

哪個更好呢?

按照傳統的觀點Map會更好些,因為這樣你可以改變他的具體實現類,只要這個類繼承自Map介面。傳統的觀點對於傳統的程式是正確的,但是它並不適合嵌入式系統。呼叫一個介面的引用會比呼叫實體類的引用多花費一倍的時間。

如果HashMap完全適合你的程式,那麼使用Map就沒有什麼價值。如果有些地方你不能確定,先避免使用Map,剩下的交給IDE提供的重構功能好了。(當然公共API是一個例外:一個好的API常常會犧牲一些效能)

用靜態方法比虛方法好

如果你不需要訪問一個物件的成員變數,那麼請把方法宣告成static。虛方法執行的更快,因為它可以被直接呼叫而不需要一個虛擬函式表。另外你也可以通過宣告體現出這個函式的呼叫不會改變物件的狀態。

不用getter和setter

在很多本地語言如C++中,都會使用getter(比如:i = getCount())來避免直接訪問成員變數(i = mCount)。在C++中這是一個非常好的習慣,因為編譯器能夠內聯訪問,如果你需要約束或除錯變數,你可以在任何時候新增程式碼。

在Android上,這就不是個好主意了。虛方法的開銷比直接訪問成員變數大得多。在通用的介面定義中,可以依照OO的方式定義getters和setters,但是在一般的類中,你應該直接訪問變數。

將成員變數快取到本地

訪問成員變數比訪問本地變數慢得多,下面一段程式碼:

for (int i = 0; i < this.mCount; i++)dumpItem(this.mItems[i]);複製程式碼

最好改成這樣:

int count = this.mCount;
Item[] items = this.mItems;
for (int i = 0; i < count; i++)dumpItems(items[i]);複製程式碼

(使用"this"是為了表明這些是成員變數)

另一個相似的原則是:永遠不要在for的第二個條件中呼叫任何方法protected void drawHorizontalScrollBar(Canvas canvas, int width, int height) {

    if (isHorizontalScrollBarEnabled()) {
        int size = mScrollBar.getSize(false);
        if (size <= 0) {
         size = mScrollBarSize;
        }
        mScrollBar.setBounds(0, height - size, width, height);
        mScrollBar.setParams(computeHorizontalScrollRange(),computeHorizontalScrollOffset(),computeHorizontalScrollExtent(), false);
        mScrollBar.draw(canvas);
    }
}複製程式碼

這裡有4次訪問成員變數mScrollBar,如果將它快取到本地,4次成員變數訪問就會變成4次效率更高的棧變數訪問。

另外就是方法的引數與本地變數的效率相同。

使用常量

讓我們來看看這兩段在類前面的宣告:

static int intVal = 42;
static String strVal = "Hello, world!";複製程式碼

必以其會生成一個叫做的初始化類的方法,當類第一次被使用的時候這個方法會被執行。方法會將42賦給intVal,然後把一個指向類中常量表的引用賦給strVal。當以後要用到這些值的時候,會在成員變數表中查詢到他們。

下面我們做些改進,使用“final"關鍵字:

static final int intVal = 42;
static final String strVal = "Hello, world!";複製程式碼

現在,類不再需要<clinit>方法,因為在成員變數初始化的時候,會將常量直接儲存到類檔案中。用到intVal的程式碼被直接替換成42,而使用strVal的會指向一個字串常量,而不是使用成員變數。

將一個方法或類宣告為"final"不會帶來效能的提升,但是會幫助編譯器優化程式碼。舉例說,如果編譯器知道一個"getter"方法不會被過載,那麼編譯器會對其採用內聯呼叫。

你也可以將本地變數宣告為"final",同樣,這也不會帶來效能的提升。使用"final"只能使本地變數看起來更清晰些(但是也有些時候這是必須的,比如在使用匿名內部類的時候)(xing:原文是 or you have to, e.g. for use in an anonymous inner class)

謹慎使用foreach

foreach可以用在實現了Iterable介面的集合型別上。foreach會給這些物件分配一個iterator,然後呼叫 hasNext()和next()方法。你最好使用foreach處理ArrayList物件,但是對其他集合物件,foreach相當於使用 iterator。

下面展示了foreach一種可接受的用法:

public class Foo {
    int mSplat;
    static Foo mArray[] = new Foo[27];
    public static void zero() {
        int sum = 0;
        for (int i = 0; i < mArray.length; i++) {
            sum += mArray[i].mSplat;
        }
    }
    public static void one() {
        int sum = 0;
        Foo[] localArray = mArray;
        int len = localArray.length;
        for (int i = 0; i < len; i++) {
            sum += localArray[i].mSplat;
        }
    }
    public static void two() {
        int sum = 0;
        for (Foo a: mArray) {sum += a.mSplat;}
    }
}複製程式碼

在zero()中,每次迴圈都會訪問兩次靜態成員變數,取得一次陣列的長度。

retrieves the static field twice and gets the array length once for every iteration through the loop.

在one()中,將所有成員變數儲存到本地變數。 pulls everything out into local variables, avoiding the lookups.

two()使用了在java1.5中引入的foreach語法。編譯器會將對陣列的引用和陣列的長度儲存到本地變數中,這對訪問陣列元素非常好。但是編譯器還會在每次迴圈中產生一個額外的對本地變數的儲存操作(對變數a的存取)這樣會比one()多出4個位元組,速度要稍微慢一些。

綜上所述:foreach語法在運用於array時效能很好,但是運用於其他集合物件時要小心,因為它會產生額外的物件。

避免使用列舉

列舉變數非常方便,但不幸的是它會犧牲執行的速度和並大幅增加檔案體積。例如:

public class Foo {public enum Shrubbery { GROUND, CRAWLING, HANGING }}

會產生一個900位元組的.class檔案(Foo$Shubbery.class)。在它被首次呼叫時,這個類會呼叫初始化方法來準備每個列舉變數。每個列舉項都會被宣告成一個靜態變數,並被賦值。然後將這些靜態變數放在一個名為"$VALUES"的靜態陣列變數中。而這麼一大堆程式碼,僅僅是為了使用三個整數。

這樣:

Shrubbery shrub = Shrubbery.GROUND;會引起一個對靜態變數的引用,如果這個靜態變數是final int,那麼編譯器會直接內聯這個常數。

一方面說,使用列舉變數可以讓你的API更出色,並能提供編譯時的檢查。所以在通常的時候你毫無疑問應該為公共API選擇列舉變數。但是當效能方面有所限制的時候,你就應該避免這種做法了。

有些情況下,使用ordinal()方法獲取列舉變數的整數值會更好一些,舉例來說,將:

for (int n = 0; n < list.size(); n++) {
    if (list.items[n].e == MyEnum.VAL_X) {
        // do stuff 1
    }
    else if (list.items[n].e == MyEnum.VAL_Y) {
        // do stuff 2
    }
}複製程式碼

替換為:

int valX = MyEnum.VAL_X.ordinal();
int valY = MyEnum.VAL_Y.ordinal();
int count = list.size();
MyItem items = list.items();
for (int n = 0; n < count; n++) {
    int valItem = items[n].e.ordinal();
    if (valItem == valX) {
     // do stuff 1
    }
    else if (valItem == valY) {
     // do stuff 2
    }
}複製程式碼

會使效能得到一些改善,但這並不是最終的解決之道。

將與內部類一同使用的變數宣告在包範圍內

請看下面的類定義:

public class Foo {
    private int mValue;
    public void run() {
        Inner in = new Inner();
        mValue = 27;
        in.stuff();
    }
    private void doStuff(int value) {
     System.out.println("Value is " + value);
    }
    private class Inner {void stuff() {
     Foo.this.doStuff(Foo.this.mValue);
    }
}複製程式碼

這其中的關鍵是,我們定義了一個內部類(Foo$Inner),它需要訪問外部類的私有域變數和函式。這是合法的,並且會列印出我們希望的結果"Value is 27"。

問題是在技術上來講(在幕後)Foo$Inner是一個完全獨立的類,它要直接訪問Foo的私有成員是非法的。要跨越這個鴻溝,編譯器需要生成一組方法:

static int Foo.access$100(Foo foo) {
 return foo.mValue;
}
static void Foo.access$200(Foo foo, int value) {
 foo.doStuff(value);
}複製程式碼

內部類在每次訪問"mValue"和"doStuff"方法時,都會呼叫這些靜態方法。就是說,上面的程式碼說明了一個問題,你是在通過介面方法訪問這些成員變數和函式而不是直接呼叫它們。在前面我們已經說過,使用介面方法(getter、setter)比直接訪問速度要慢。所以這個例子就是在特定語法下面產生的一個“隱性的”效能障礙。

通過將內部類訪問的變數和函式宣告由私有範圍改為包範圍,我們可以避免這個問題。這樣做可以讓程式碼執行更快,並且避免產生額外的靜態方法。(遺憾的是,這些域和方法可以被同一個包內的其他類直接訪問,這與經典的OO原則相違背。因此當你設計公共API的時候應該謹慎使用這條優化原則)

避免使用浮點數

在奔騰CPU出現之前,遊戲設計者做得最多的就是整數運算。隨著奔騰的到來,浮點運算處理器成為了CPU內建的特性,浮點和整數配合使用,能夠讓你的遊戲執行得更順暢。通常在桌面電腦上,你可以隨意的使用浮點運算。

但是非常遺憾,嵌入式處理器通常沒有支援浮點運算的硬體,所有對"float"和"double"的運算都是通過軟體實現的。一些基本的浮點運算,甚至需要毫秒級的時間才能完成。

甚至是整數,一些晶片有對乘法的硬體支援而缺少對除法的支援。這種情況下,整數的除法和取模運算也是有軟體來完成的。所以當你在使用雜湊表或者做大量數學運算時一定要小心謹慎。

Android高階開發資料、高階UI、效能優化、架構師課程、NDK、混合式開發(ReactNative+Weex)等相關資料詳情請看主頁


相關文章