探索 Java 隱藏的開銷

Jake Wharton發表於2016-10-22

隨著 Android 引入 Java 8 的一些功能,請記住每一個標準庫的 API 和語言特性都會帶來一些相關的開銷,這很重要。雖然裝置越來越快而且記憶體越來越多,程式碼大小和效能優化之間仍然是有著緊密關聯的。這篇 360AnDev 的演講會探索一些 Java 功能的隱藏開銷。我們會關注對庫開發者和應用開發者都有關係的優化和能夠衡量它們影響的工具。

介紹 (0:00)

在這篇演講裡面,我將討論我近六個月以來一直在探索的事情,而且我想披露一些資訊。隨著你的深入瞭解,你可能得不到一些明確的能夠應用在你的應用程式上的東西。但是,到結束的時候,我會有一些具體的技巧來展示如何避免我今天講的這些開銷。我也會展示許多我使用的命令列工具,這些資源的連結都在文章結束的地方。

Dex 檔案 (1:14)

我們將從一個多項選擇的問題開始。下面這段程式碼有多少個方法?沒有,一個或者兩個?

class Example {
}

你可能馬上就有直覺的反應了。也許沒有,也許一個,也許兩個。讓我們看看我們是不是能回答這個問題。首先,類裡面沒有方法。我在原始檔裡面沒有任何方法,所以看起來可以這麼說。當然,這樣的答案真的沒有什麼意思。讓我們開始把我們的類在 Android 裡編譯一下,看看會發生什麼:

$ echo "class Example {
}" > Example.java

$ javac Example.java

$ javap Example.class
class Example {
Example();
}

我們把內容寫到一個檔案裡面,然後用 Java 編譯器編譯原始碼然後把它變成 class 檔案。我們可以使用其他的非 Java 開發套件的工具,它叫做 javap。這使得我們能夠深入瞭解編譯出來的 class 檔案。如果在我們編譯的 class 檔案上執行它,我們能看到我們的例子的 class 裡面有一個建構函式。我們沒有在原始檔裡面編寫它,但是 Java C 決定自動增加一個那樣的建構函式。這意味著原始檔裡面沒有方法,但是 class 檔案裡面有一個。但這不是 Android 編譯停止的地方:

$ dx --dex --output=example.dex Example.class

$ dexdump -f example.dex

在 Android SDK 中,有一個工具叫做 dx,它完成 dexing,這使得 Java Class 檔案變成 Android Dalvik 二進位制碼。我們通過 dex 執行我們的例子,Android SDK 裡面還有另外一個工具叫做 dexdump,這個工具會給我們一些關於 dex 檔案內部的資訊。你執行它,它會列印一串東西。它們是檔案的偏移量和計數器還有各種表。如果我們詳細點看看,一個明顯的事情是,dex 檔案裡面有一個函式列表:

method_ids_size : 2

它說我們的 class 裡面有兩個方法。這說不通。不幸的是,dexdump 並沒有給我一個簡單的方法來了解這兩個方法是什麼。因為如此,我寫了一個小工具來輸出 dex 檔案裡面的方法:

$ dex-method-list example.dex
Example <init>()
java.lang.Object <init>()

如果我們這樣做,我就能看到它返回了兩個方法。它返回了我們的建構函式,我們知道它是 Java 編譯器建立的,雖然我們沒有去寫它。但是它還說有一個物件建構函式。當然,我們的程式碼沒有四處呼叫 new 物件,所以這個方法是哪裡產生的呢,然後又在 dex 檔案裡面引用的呢?如果我們回到能列印 class 檔案資訊的 javap 工具,你能通過一些額外的標誌來找到 class 裡面的深度資訊。我將使用 -c,這會把二進位制程式碼反編譯成可讀的資訊。

$ javap -c Example.class
class Example {
    Example();
        Code:
            0: aload_0
            1: invokespecial #1 //java/lang/Object."<init>":()V
            4: return
}

在索引 1 處,是我們的物件建構函式,它被父類的建構函式呼叫。這是因為,即使我們不宣告它,Example 也是繼承於 Object 的。每一個建構函式都會呼叫它的父類的建構函式。它是自動插入的。這意味著我們的 class 流中有兩個方法。

所有這些關於我的初始問題的答案都是對的。區別就是術語不同。這是真實的情況。我們沒有定義任何方法。但是隻有人類關心它。作為人類,我們讀寫這些原始檔。我們是唯一關心它們內部構造的人。另外兩個方法更重要,方法的個數實際上是編譯進 class 檔案裡面了。無論是否宣告,這些方法都在 class 的內部。

這兩個方法是引用方法的數目。它和我們自己編寫的方法的計數是類似的,和所有其他在函式裡引用以及 Android logger 函式的呼叫也差不多。我這裡引用的 Log.d 函式和這個引用方法計數不一樣,因為這是我們在 dex 檔案裡面的計數。這也是人們經常在 Android 裡面討論方法計數時的常用方法,因為 dex 有著聲名狼藉的對於引用方法的個數的限制。

我們看到一個沒有宣告的建構函式被建立了,所以讓我們看看其他自動生成的,我們可能不知道的隱藏開銷。巢狀類是一個有用的例子:

// Outer.java
public class Outer {
    private class Example {
    }
}

Java 1.0 不支援這樣做。它們是在晚些的版本里才出現的。當你在一個檢視或者展示層裡面定義介面卡的時候,你能看到這樣的東西。

// ItemsView.java
public class ItemsView {
    private class ItemsAdapter {
    }
}

$ javac ItemsView.java

$ ls
ItemsView.class
ItemsView.java
ItemsView$ItemsAdapter.class

如果我們編譯這個 class,這是一個有兩個 class 的檔案。一個巢狀在另一個裡面。如果我們編譯它,我們能在檔案系統中看到兩個獨立的 class 檔案。如果 Java 真的有內嵌類,我們就應該只能看到一個 class 檔案。我們會得到ItemsView.class。但是這裡 Java 沒有真正的巢狀,那麼這些類檔案裡面是什麼呢?在這個 ItemsView 裡面,外層類,我們有的只是建構函式。這裡沒有引用,沒有內嵌類的任何跡象:

$ javap 'ItemsView$ItemsAdapter'
class ItemsView$ItemsAdapter {
    ItemsView$ItemsAdapter();
}

如果我們看看巢狀類的內容,你可以看到它有隱式的建構函式,而且你知道它在外部類的裡面,因為它的名字被擾亂了。另外一個重要的事情是如果我返回去,我能看到這個 ItemsView 類是公共的,這和我們在原始檔裡面定義的一樣。但是內部類,內嵌類,雖然它定義為私有的,在類檔案裡面它不是私有的。它是包作用範圍的。這是對的,因為我們在同一個包中有兩個生成的類檔案。重申一次,這進一步證明了在 Java 裡面沒有真正的內嵌類。

// ItemsView.java

public class ItemsView {
}

// ItemsAdapter.java

class ItemsAdapter {
}

雖然你內嵌了兩個類的定義,你可以有效地建立兩個類檔案,它們在同一個包裡緊鄰著對方。如果你想這樣做的話,你可以實現。你可以作為兩個獨立的檔案使用命名規則:

// ItemsView.java

public class ItemsView {
}

// ItemsView$ItemsAdapter.java

class ItemsView$ItemsAdapter {
}

美元符在 Java 裡面是名字的有效字元。對方法或者附加名字也有效:

// ItemsView.java
public class ItemsView {
    private static String displayText(String item) {
        return ""; // TODO
    }
private class ItemsAdapter {
    }
}

然而,這是真正有意思的地方,因為我知道我能夠做一些事情在外部類裡找到一個 private static 方法,而且我能在內部類裡面引用那個私有的方法:

// ItemsView.java

public class ItemsView {
    private static String displayText(String item) {
        return ""; // TODO
    }
}

// ItemsView$ItemsAdapter.java

class ItemsView$ItemsAdapter {
    void bindItem(TextView tv, String item) {
        tv.setText(ItemsView.displayText(item));
    }
}

現在我們知道沒有真正的內嵌,但是,這在我們假設的獨立系統裡面是如何工作的呢,這裡我們的 ItemsAdapter類需要引用 ItemsView 的私有方法?這沒有編譯,而且它們會被編譯:

// ItemsView.java

public class ItemsView {
    private static String displayText(String item) {
        return ""; // TODO
    }

    private class ItemsAdapter {
        void bindItem(TextView tv, String item) {
            tv.setText(ItemsView.displayText(item));
        }
    }
}

發生了什麼?當你回到我們的工具的時候,我們能再次使用 javac 。

$ javac -bootclasspath android-sdk/platforms/android-24/android.jar \
    ItemsView.java

$ javap -c 'ItemsView$ItemsAdapter'
class ItemsView$ItemAdapter {
    void bindItem(android.widget.TextView, java.lang.String);
    Code:
        0: aload_1
        1: aload_2
        2: invokestatic #3 // Method ItemsView.access$000:…
        5: invokevirtual #4 // Method TextView.setText:…
        8: return
}

我在引用 TextView,這樣我才能在 Java 裡面增加 Android APIs。現在我將列印出內嵌類的內容,來看看哪個函式被呼叫了。如果你看看索引 2,它沒有呼叫 displayText 方法。它呼叫的是 access$000,我們沒有定義它。它在 ItemsView 類裡面嗎?

$ javap -p ItemsView123

class ItemsView {
    ItemsView();
    private static java.lang.String displayText(…);
    static java.lang.String access$000(…);
}

如果我們仔細看看,是的,它在。我們看到我們的 private static 方法仍然在那,但是我們現在需要這個我們沒有編寫的額外方法自動加入。

$ javap -p -c ItemsView123

class ItemsView {
    ItemsView();
        Code: <removed>

private static java.lang.String displayText(…);
    Code: <removed>

static java.lang.String access$000(…);
    Code:
        0: aload_0
        1: invokestatic #1 // Method displayText:…
        4: areturn
}

如果我們看看這個函式的內容,它做的事情就是呼叫我們原來的 displayText 方法。這有意義,因為我們需要一個從包的作用域到類裡呼叫它的私有方法的途徑。Java 會合成一個包作用域的方法來幫助實現這個函式呼叫。

// ItemsView.java
public class ItemsView {
    private static String displayText(String item) {
        return ""; // TODO
    }

    static String access$000(String item) {
        return displayText(item);
    }
}

// ItemsView$ItemsAdapter.java
class ItemsView$ItemsAdapter {
    void bindItem(TextView tv, String item) {
        tv.setText(ItemsView.access$000(item));
    }
}

如果我們回到我們兩個類檔案的例子,我們手工的例子,我們能讓編譯器按照同樣的方法工作。我們能夠增加方法,我們能更新另一個類,然後引用它。dex 檔案有方法的限制,所以當你有這些因為你編寫原始檔的方式的不同,而導致的必須要新增新的的方法加的話,這些函式的個數都是計算在內的。理解這點是很重要的,因為我們嘗試在某處訪問一個私有成員是不可能的。

Dex 進階 (10:52)

所以你可能會說,”好吧,你只做了 Java C。也許 dex 工具能看到這些,而且自動地為我們去除這些函式。”

$ dx --dex --output=example.dex *.class

$ dex-method-list example.dex

ItemsView <init>()
ItemsView access$000(String) → String
ItemsView displayText(String) → String
ItemsView$ItemsAdapter <init>(ItemsView)
ItemsView$ItemsAdapter bindItem(TextView, String)
android.widget.TextView setText(CharSequence)
java.lang.Object <init>()

如果我們編譯這兩個生成的類,然後顯示它們,你可以看到實際情況不是這樣。dex 工具編譯它就好像它是個任意的其他方法一樣。在你的 dex 檔案裡面就這樣結束了。

你會說,”好吧,我聽說過這個新的 Jack 編譯器。而且 Jack 編譯器直接編譯原始檔,然後直接產生 dex 檔案,所以也許它做了些什麼事情使得它不需要產生額外的方法。” 這樣肯定沒有 access 方法。但是,有一個 -wrap0 方法,它實際上做的是同樣的事情:

$ java -jar android-sdk/build-tools/24.0.1/jack.jar \
        -cp android-sdk/platforms/android-24/android.jar \
        --output-dex . \
        ItemsView.java

$ dex-method-list classes.dex

ItemsView -wrap0(String) → String
ItemsView <init>()
ItemsView displayText(String) → String
ItemsView$ItemsAdapter <init>(ItemsView)
ItemsView$ItemsAdapter bindItem(TextView, String)
android.widget.TextView setText(CharSequence)
java.lang.Object <init>()

還有一個工具叫做 ProGuard,許多人都用它。你可能會說,”好吧,ProGuard 應該會處理這些事情,對吧?” 我可以寫一個快速的 ProGuard key。我能在我的類檔案上執行 ProGuard,然後列印這些方法。這裡是我得到的東西:

$ echo "-dontobfuscate
-keep class ItemsView$ItemsAdapter { void bindItem(...); }
" > rules.txt

$ java -jar proguard-base-5.2.1.jar \
    -include rules.txt \
    -injars . \
    -outjars example-proguard.jar \
    -libraryjars android-sdk/platforms/android-24/android.jar

$ dex-method-list example-proguard.jar

ItemsView access$000(String) → String
ItemsView$ItemsAdapter bindItem(TextView, String)
android.widget.TextView setText(CharSequence)

建構函式被移出了,因為它們沒有被使用。我將把它們加回來因為正常情況下它們是在的:

$ dex-method-list example-proguard.jar

ItemsView <init>()
ItemsView access$000(String) → String
ItemsView$ItemsAdapter <init>(ItemsView)
ItemsView$ItemsAdapter bindItem(TextView, String)
android.widget.TextView setText(CharSequence)
java.lang.Object <init>()

你能看到 access 函式還在那裡。但是如果你仔細看看,你保持了 access 方法,但是 displayText 消失了。這裡發生了什麼呢?你可以解壓縮 ProGuard 產生的 jar 包,然後回到我們的 javap 工具,看看 ProGuarded 類檔案裡面到底有些什麼:

$ unzip example-proguard.jar

$ javap -c ItemsView

public final class ItemsView {
    static java.lang.String access$000(java.lang.String);
        Code:
            0: ldc #1 // String ""
            2: areturn
}

如果我們看看 access 函式,它不再呼叫 displayText。ProGuard 把 displayText 的內容抽取出來,然後移到 access 函式裡面並且刪除了 displayText 函式。這個 access 函式是我們唯一引用的私有函式,所以它會變成 inline 函式,因為沒有其他人使用它了。是的,ProGuard 在某種程度上能夠幫得上忙。但是它不保證能夠有用。我們很幸運,因為這是一個小例子,但是優化也是不能保證的。你可能會想,”好吧,我真的沒有使用那麼多的內嵌類,也許有一組。如果我只是會得到一組額外的函式,這關係不大,對嗎?”

匿名類 (13:06)

讓我向你介紹一些我們的朋友,匿名類:

class MyActivity extends Activity {
    @Override protected void onCreate(Bundle state) {
        super.onCreate(state);

        setContentView(R.layout.whatever);
        findViewById(R.id.button).setOnClickListener(
            new OnClickListener() {
                @Override public void onClick(View view) {
                    // Hello!
                }
            });
    }
}

匿名類的行為幾乎和內嵌類完全一樣。它們本質上是一樣的東西。它是一個內嵌類,但是沒有名字。如果在這些監聽者裡面,這是我們常用的方法,你引用一個類裡面的私有方法,這樣就會產生一個合成的 access 函式。

class MyActivity extends Activity {
    @Override protected void onCreate(Bundle state) {
        super.onCreate(state);

        setContentView(R.layout.whatever);
        findViewById(R.id.button).setOnClickListener(
            new OnClickListener() {
                @Override public void onClick(View view) {
                    doSomething();
                }
            });
    }

    private void doSomething() {
        // ...
    }
}

對於成員來說,事實也是這樣:

class MyActivity extends Activity {
    private int count;

    @Override protected void onCreate(Bundle state) {
        super.onCreate(state);

        setContentView(R.layout.whatever);
        findViewById(R.id.button).setOnClickListener(
            new OnClickListener() {
                @Override public void onClick(View view) {
                    count = 0;
                    ++count;
                    --count;
                    count++;
                    count--;
                    Log.d("Count", "= " + count);
                }
            });
    }
}

我認為這是一個有許多共性的例子。我們在外部類裡面有這些成員,我們修改狀態的這些 activity 的成員在這些監聽者裡面。我們做了一個完整的美妙實現,但是我們做的是設定一個值。我使用了前置加加,前置減減,後置加加和後置減減,然而日誌訊息需要從成員中讀取數值。我們在這裡的函式有多少個呢?也許只有兩個。也許一個讀,一個寫,然後加加和減減變成讀加和寫。如果這是事實的話:

$ javac -bootclasspath android-sdk/platforms/android-24/android.jar \
MyActivity.java

$ javap MyActivity
class MyActivity extends android.app.Activity {
    MyActivity();
    protected void onCreate(android.os.Bundle);
    static int access$002(MyActivity, int); // count = 0    write
    static int access$004(MyActivity);        // ++count         preinc
    static int access$006(MyActivity);        // --count         predec
    static int access$008(MyActivity);        // count++         postinc
    static int access$010(MyActivity);        // count--         postdec
    static int access$000(MyActivity);        // count         read
}

我們編譯它,然後就為每一個型別都產生了一個函式。所以如果你覺得在一個 activity 或者 fragment 或者其它什麼東西,你有四到五個監聽者,和大概 10 個在外部類裡的私有成員。你就會有一個很棒的 access 方法爆炸。你也許還沒有被說服這是個問題。你會說,”好吧,也許是 50,也許是 100.這真的有關係嗎?” 我們下面來看看。事實證明一切。

現實情況 (15:03)

你可以看到在現實中這是多麼的普遍。這些命令可以幫你拿出手機上所有的 APK。每一個你安裝的應用,都是一個第三方的應用:

$ adb shell mkdir /mnt/sdcard/apks

$ adb shell cmd package list packages -3 -f \
| cut -c 9- \
| sed 's|=| /mnt/sdcard/apks/|' \
| xargs -t -L1 adb shell cp

$ adb pull /mnt/sdkcard/apks

我們可以寫一個指令碼來使用這些 dex 函式列表,然後 greps 所有的不同數值:

#!/bin/bash                                                 accessors.sh
set -e

METHODS=$(dex-method-list $1 | \grep 'access\$')
ACCESSORS=$(echo "$METHODS" | wc -l | xargs)
METHOD_AND_READ=$(echo "$METHODS" | egrep 'access\$\d+00\(' | wc -l | xargs)
WRITE=$(echo "$METHODS" | egrep 'access\$\d+02\(' | wc -l | xargs)
PREINC=$(echo "$METHODS" | egrep 'access\$\d+04\(' | wc -l | xargs)
PREDEC=$(echo "$METHODS" | egrep 'access\$\d+06\(' | wc -l | xargs)
POSTINC=$(echo "$METHODS" | egrep 'access\$\d+08\(' | wc -l | xargs)
POSTDEC=$(echo "$METHODS" | egrep 'access\$\d+10\(' | wc -l | xargs)
OTHER=$(($ACCESSORS - $METHOD_AND_READ - $WRITE - $PREINC - $PREDEC - $POSTINC - $POSTDEC))

NAME=$(basename $1)

echo -e "$NAME\t$ACCESSORS\t$READ\t$WRITE\t$PREINC\t$PREDEC\t$POSTINC\t$POSTDEC\t$OTHER"

然後我們執行這個瘋狂的命令,它會遍歷每一個從手機裡面獲取的 APK,執行這個指令碼,然後你得到一個漂亮的報表:

$ column -t -s $'\t' \
<(echo -e "NAME\tTOTAL\tMETHOD/READ\tWRITE\tPREINC\tPREDEC\tPOSTINC\tPOSTDEC\tOTHER" \
&& find apks -type f | \
xargs -L1 ./accessors.sh | \
sort -k2,2nr)

你能在 77 頁上看到這個表,它把使用 accessor 函式的包排了個序。在我的手機裡面,我有幾千個。Amazon 佔據了前六名中的五個。前幾名有 5000 個合成的 accessor 函式。5000 個函式,那是一整個庫了。這好像一個 apk 的 pad。你有一整個 apk 的 pad, 裡面都是無用的函式,它們的存在只是為了跳轉到其它的函式去。

同樣的,因為我們使用了 ProGuard,混淆會使得這些數值變得更難以確認。初始化會搞砸這些資料。不要認為它們是準確的資料。它只是給了你一個大約的數值,讓你明白你創造了多少個函式。你的應用裡面會有多少函式是比這些無用的 access 函式有用的?順便說一句,Twitter 在列表的末尾?它們有 1000 個。他們 ProGuarding 了他們的應用,所以可能實際情況更糟。但是我想這很有趣,因為它們有最多的函式,但是報告了最少的 access 函式數量。他們有 171,000 個函式,但是隻用了 1,000 個合成的 accessors。這很令人吃驚。

我們能改變這個情況。這是很容易的。我們不需要把一些東西作為私有成員。當我們跨邊界引用它們的時候,我們需要讓它們成為包作用域級別。 IntelliGate 提供了這樣的檢查。它預設不起作用,但是你可以進去然後搜尋一個私有方法。搜尋是一件有意思的事情,如果採用我們的例子,它會將結果標記為黃色高亮。你可以 選擇性進入,它會讓你跳轉到你訪問的私有成員那裡,然後把它設為包作用域的。

當你考慮這些內嵌類的時候,試著把它們想象成為兄弟姐妹,而不是父子關係。你不能從外部類裡面訪問一個私有成員。你需要讓它成為包級別的。這才不會出問題,因為即使你在編寫一個庫,這些人也不會在同樣的包裡面放入類檔案,然後訪問這些你設為更容易訪問的內容。我會在功能需求裡面放上一個這方面的 link 檢查。希望在未來,你在構建的時候,如果你做了類似的事情,構建就會失敗。

我遍歷過許多開源庫,而且修改過這些可見性的問題,這樣這些庫本身就不需要在 impose 成百上千的外部函式了。這對於生成程式碼的庫來說尤為重要。我們能在我們的應用裡面減少 2700 個函式,只需要改變一個我們程式碼生成的步驟。只需要把一些東西從私有作用域改到包作用域,就能很輕鬆地減少 2700 個函式了。

合成方法 (18:45)

這些合成方法之所以叫做合成方法是因為你沒有編寫它們。

// Callbacks.java

interface Callback<T> {
    void call(T value);
}

class StringCallback implements Callback<String> {
    @Override public void call(String value) {
        System.out.println(value);
    }
}

它們是為你自動生成的。這些 accessor 函式是唯一自動生成的函式。Generics 是另一個在 Java 1.0 之後才出現的東西,而且它必須重新翻譯成 Java 的工作方式。我們在庫裡面,甚至在我們的應用裡面能看到許多這樣的東西。我們使用這些泛型介面,因為它們非常方便,而且它們讓我們能保持型別正確。

$ javac Callbacks.java

$ javap StringCallback
class StringCallback implements Callback<java.lang.String> {
    StringCallback();
    public void call(java.lang.String);
    public void call(java.lang.Object);
}

如果我們有一個函式接收一個泛型值,當你編譯它的時候,你會發現所有接收泛型引數的函式最終會變成兩個。一個接收串,不論你的泛型是什麼,另一個接收物件。這和 erasure 很像。你聽到許多人都談論 erasure,你也許不理解到底發生了些什麼。這更像一個 erasure 的說明。我們必須產生使用物件的程式碼,因為當你訪問這個泛型函式的時候,這就是你最終呼叫的函式。

$ javap -c StringCallback
class StringCallback implements Callback<java.lang.String> {
    StringCallback();
        Code: <removed>

    public void call(java.lang.String);
        Code: <removed>

    public void call(java.lang.Object);
        Code:
        0: aload_0
        1: aload_1
        2: checkcast #4 // class java/lang/String
        5: invokevirtual #5 // Method call:(Ljava/lang/String;)V
        8: return
}

如果我們看看那個額外的函式裡面發生了什麼,它只有一個轉換。它強轉成了 year 型別然後它呼叫了接收泛型的真實實現。任何呼叫這個函式的人都會使用這個物件函式。呼叫程式碼會傳入它們有的任何物件,然後這部分程式碼就完成強制轉換,呼叫真正的實現。每一個使用泛型的函式,最後都會成為兩個函式。

// Providers.java

interface Provider<T> {
    T get(Context context);
}

class ViewProvider implements Provider<View> {
    @Override public View get(Context context) {
        return new View(context);
    }
}

返回值也是一樣。如果你有一個返回泛型的函式,你將會看到一樣的事情。

$ javac -bootclasspath android-sdk/platforms/android-24/android.jar \
    Example.java

$ javap -c ViewProvider
class ViewProvider implements Provider<android.view.View> {
    ViewProvider();
        Code: <removed>

    public android.view.View get(android.content.Context);
        Code: <removed>

    public java.lang.Object get(android.content.Context);
        Code:
            0: aload_0
            1: aload_1
            2: invokevirtual #4 // Method get:(…)Landroid/view/View;
            5: areturn
}

產生了兩個函式。一個作為返回值。在這種情況下,是我們的檢視。然後在底部,返回了物件。這個函式非常簡單因為它不用完成任何事情,它僅僅是接收 view 然後把它變成物件。

// Providers.java

interface Provider<T> {
    T get(Context context);
}

class ViewProvider implements Provider<View> {
    @Override public View get(Context context) {
        return new View(context);
    }
}

另一個需要指出的並且許多人都沒有意識到的問題是,這是 Java 語言的功能。

class ViewProvider implements Provider<View> {
    @Override public View get(Context context) {
        return new View(context);
    }
}

class TextViewProvider extends ViewProvider {
    @Override public TextView get(Context context) {
        return new TextView(context);
    }
}

如果你有需要過載的函式,你可以改變它的返回值成為更具體的型別。這叫做協變返回型別。不一定和我們這個例子裡面的一樣,我們在實現介面。基類不需要是一個介面或者其它的東西。你可以從基類過載一個方法。你可以把它的返回型別變成其他更具體的型別。

你這樣做的原因是如果你在我們第二個類裡面有其他方法,它又要呼叫這個 get 函式的話,你就會這樣做了,因為它們需要實現的型別。不需要更寬泛的型別了,在這個例子裡面,是 View 型別。它們完全可以只做對於TextView 的定製化,因為他們已經在那個類裡面了。

協變返回型別 (21:58)

協變返回型別。我肯定你們能猜到這裡面發生了什麼。

$ javap TextViewProvider

class TextViewProvider extends ViewProvider {
    TextViewProvider();
    public android.widget.TextView get(android.content.Context);
    public android.view.View get(android.content.Context);
    public java.lang.Object get(android.content.Context);
}

我們有了另一個函式。在這個例子裡面,它既是泛型而且還有協變返回型別。我們把一個函式變成了三個基本上不做任何事情的函式。這是一個深入內部的 python 指令碼:

#!/usr/bin/python

import os
import subprocess
import sys

list = subprocess.check_output(["dex-method-list", sys.argv[1]])

class_info_by_name = {}

for item in list.split('\n'):
    first_space = item.find(' ')
    open_paren = item.find('(')
    close_paren = item.find(')')
    last_space = item.rfind(' ')

    class_name = item[0:first_space]
    method_name = item[first_space + 1:open_paren]
    params = [param for param in item[open_paren + 1:close_paren].split(', ') if len(param) > 0]
    return_type = item[last_space + 1:]
    if last_space < close_paren:
        return_type = 'void'

    # print class_name, method_name, params, return_type

    if class_name not in class_info_by_name:
        class_info_by_name[class_name] = {}
    class_info = class_info_by_name[class_name]

    if method_name not in class_info:
        class_info[method_name] = []
    method_info_by_name = class_info[method_name]

    method_info_by_name.append({
        'params': params,
        'return': return_type
    })

count = 0
for class_name, class_info in class_info_by_name.items():
    for method_name, method_info_by_name in class_info.items():
        for method_info in method_info_by_name:
            for other_method_info in method_info_by_name:
                if method_info == other_method_info:
                    continue # Do not compare against self.
                params = method_info['params']
                other_params = other_method_info['params']
                if len(params) != len(other_params):
                    continue # Do not compare different numbered parameter lists.

                match = True
                erased = False
                for idx, param in enumerate(params):
                    other_param = other_params[idx]
                    if param != 'Object' and not param[0].islower() and other_param == 'Object':
                        erased = True
                    elif param != other_param:
                        match = False

                return_type = method_info['return']
                other_return_type = other_method_info['return']
                if return_type != 'Object' and other_return_type == 'Object':
                    erased = True
                elif return_type != other_return_type:
                    match = False

                if match and erased:
                    count += 1
                    # print "FOUND! %s %s %s %s" % (class_name, method_name, params, return_type)
                    # print " %s %s %s %s" % (class_name, method_name, other_params, other_return_type)

print os.path.basename(sys.argv[1]) + '\t' + str(count)

這得花了很長時間才能弄明白,但是我想知道這在應用裡面有多流行。我可以採用同樣的流程,然後對我裝置上安裝的所有應用都執行一遍。

$ column -t -s $'\t' \
    <(echo -e "NAME\tERASED" \
        && find apks -type f | \
            xargs -L1 ./erased.py | \
            sort -k2,2nr)

有幾千個。你做的不全在這。我說過 ProGuard 在某種程度上能幫上忙。好處是如果 ProGuard 能夠發現沒有任何人引用這個接收一個物件然後返回物件的泛型函式的話。它就能消滅它,所以你能看到 ProGuard 消滅了成千上萬個函式。但是有的函式不能被移除因為你在介面那裡用抽象的方式呼叫了這些方法。

最後一個我想討論的例子,對於 Android 來說是新的,並且即將出現。這就是 Java 8 語言特性。

class Greeter {
    void sayHi() {
        System.out.println("Hi!");
    }
}

class Example {
    public static void main(String... args) {
        Executor executor = Executors.newSingleThreadExecutor();
        final Greeter greeter = new Greeter();
        executor.execute(new Runnable() {
            @Override public void run() {
                greeter.sayHi();
            }
        });
    }
}

我們有 retro-lamina 一段時間了。但是現在 Jack compiler 在同樣的精神指導下實現了這些功能,這樣會讓它們向後相容。但是新語言也會有相關的開銷嗎?

我有一些簡單的類,它們在呼叫它的函式的時候會列印 Hi。我想做的事情是讓我的 Greeter 在 Executor 的時候列印 hello。Executor 有一個函式叫做 run,它接收 Runnable。在當前的情況下,我們設定建立者型別為final。然後我們就能建立一個新的 runnable 來直接呼叫函式了。

class Greeter {
    void sayHi() {
        System.out.println("Hi!");
    }
}

class Example {
    public static void main(String... args) {
        Executor executor = Executors.newSingleThreadExecutor();
        Greeter greeter = new Greeter();
        executor.execute(() -> greeter.sayHi());
    }
}

在 Lambda 的世界裡,這變得特別簡潔了。它也會建立一個 Runnable,但是它是隱式建立的。你不需要指定型別。你不需要真正的函式名和引數型別。

最後一個是函式引用。這有些意思,因為這是一個不返回任何東西而且不接受任何引數的函式,我可以把它自動地變成 Runnable,因為我知道我需要做的事情就是呼叫這個函式。

class Greeter {
    void sayHi() {
        System.out.println("Hi!");
    }
}

class Example {
    public static void main(String... args) {
        Executor executor = Executors.newSingleThreadExecutor();
        Greeter greeter = new Greeter();
        executor.execute(greeter::sayHi);
    }
}

這些開銷有多大?(24:45)

這很有趣,但是這些開銷到底有多大?採用這些語言特性的開銷到底多大?我準備了一個 Retrolambda 工具鏈和一個使用 Jack 的工具鏈。

Retrolambda toolchain

$ javac *.java

$ java -Dretrolambda.inputDir=. -Dretrolambda.classpath=. \
    -jar retrolambda.jar

$ dx --dex --output=example.dex *.class

$ dex-method-list example.dex

Jack toolchain 

$ java -jar android-sdk/build-tools/24.0.1/jack.jar \
        -cp android-sdk/platforms/android-24/android.jar \
        --output-dex . *.java

$ dex-method-list classes.dex

這兩個都沒有使用 ProGuard,而且最簡化了 Jack,因為它不影響結果。在匿名類的例子中,對於我們到目前為止常常使用的例子而言,它總共有兩個方法。

Example$1 <init>(Greeter)
Example$1 run()

$ javap -c 'Example$1'
final class Example$1 implements java.lang.Runnable {
    Example$1(Greeter);
        Code: <removed>

    public void run();
        Code:
        0: aload_0
        1: getfield #1 // Field val$greeter:LGreeter;
        4: invokevirtual #3 // Method Greeter.sayHi:()V
        7: return
}

如果我們編譯我們的 example,我們可以看到這就是我們的匿名類,它給附上了簡單升序的數字。我們看看建構函式。建構函式為我們接收了 Greeter 類,然後它有一個 run 函式,如果我們反編譯它,它所做的事情就是呼叫函式。這就是我們期望的事情,非常直接。

當我們使用 lambda 的時候,如果你使用的是一個老版本的 retrolambda,開銷就很大了。簡單的一小行程式碼能變成六個或者七個函式來完成功能。謝天謝地,現在最小的版本是 4。 而且 Jack 和版本 3 工作的很好,所以只會比匿名類多出一個函式。但是區別在哪裡呢?為什麼會有多餘的函式呢?

我們知道如何弄清楚。這是 retrolambda,它有兩個額外的函式:

Example lambda$main$0(Greeter)
Example$$Lambda$1 <init>(Greeter)
Example$$Lambda$1 lambdaFactory$(Greeter) → Runnable
Example$$Lambda$1 run()

$ javap -c 'Example$$Lambda$1'

final class Example$$Lambda$1 implements java.lang.Runnable {
    public void run();
        Code:
            0: aload_0
            1: getfield #15 // Field arg$1:LGreeter;
            4: invokestatic #21 // Method Example.lambda$main$0:
            7: return
}

開始的那個函式是新的。這裡發生的事情是:你在 lambda 裡面定義了一段程式碼,這段程式碼需要被封裝到某個地方。它不在定義這個 lamda 的函式裡面,因為如果這樣就會很怪。它不屬於那個函式。它需要在其他的某個地方定義,然後在你需要的時候傳入。

這就是開始的那個函式。它只是拷貝貼上了你在同一個類裡面的 lambda 的內容。這裡你可以看到實現方法。它做的所有事情就是代理 sayHi 函式。和我們的 runnable 實現非常類似。我們仍然有建構函式。除了 run 函式的修改有點不同外。它會回到原來的類然後呼叫 lambda 函式,而不是直接呼叫 Greeter。這就是那個額外的函式。接下來看看 retrolambda 是如何工作的。

Example lambda$main$0(Greeter)
Example$$Lambda$1 <init>(Greeter)
Example$$Lambda$1 lambdaFactory$(Greeter) → Runnable
Example$$Lambda$1 run()

它生成了另外一個函式,這是一個靜態工廠方法,它呼叫了建構函式,而不是直接呼叫建構函式來建立生成類。Jack 做的事情也很類似,除了額外的靜態方法。

Example -Example_lambda$1(Greeter)
Example <init>()
Example main(String[])
Example run(Runnable)
Example$-void_main_java_lang_String__args_LambdaImpl0 <init>(Greeter)
Example$-void_main_java_lang_String__args_LambdaImpl0 run()
Greeter <init>()
Greeter sayHi()
java.io.PrintStream println(String)
java.lang.Object <init>()
java.lang.Runnable run()

我們仍然有 lambda one,雖然它的名字很瘋狂,而且生成類的命名也很有創造性,它在名字裡使用了整個型別簽名。如上所示。三個方法。Lambda 產生了開始的額外方法。這就是另外一個額外方法開銷的原因。

函式引用也很有趣。Retrolambda 和 Jack 本質上是繫結的。Retrolambda 有時候需要產生一個額外的方法,原因是你可能需要引用一個私有方法,所以這不是 Retrolambda 產生的。Java 產生了這些合成 accessor 函式中的一個,因為你給另外一個類傳遞了一個私有方法的引用,它做不到。這是第四個函式產生的原因。

Jack,非常有趣,為每一個單獨的函式引用產生了三個函式。除了私有類,它應該為每一個函式產生兩個。這種情況下,它應該產生三個函式。現在,它為每一個函式引用都產生了一個 accessor 函式。這看起來像一個 bug,所以希望我們能看到 Jack 減少到兩個函式。這很重要,因為在函式引用上,這和匿名類一樣了。那麼從匿名類轉到函式引用的抽象就沒有開銷了,這會很棒。

Lambda 不幸的是,不太可能減少到同樣的數量。原因是你還是可能在 lambda 裡面引用私有函式或者私有成員。它不可能被拷貝到生成的 runnable 裡面。因為,它沒有任何訪問這些東西的方法。我們也只能這麼指望了。

現實中的 Lambdas (30:05)

讓我們看看現實中有多少 lambda 正被使用著。同樣的方法。順便說一句,這花費的時間很長。

#!/bin/bash             lambdas.sh
set -e

ALL=$(dex-method-list $1)

RL=$(echo "$ALL" | \grep ' lambda\$' | wc -l | xargs)
JACK=$(echo "$ALL" | \grep '_lambda\$' | wc -l | xargs)

NAME=$(basename $1)

echo -e "$NAME\t$RL\t$JACK"
$ column -t -s $'\t' \
    <(echo -e "NAME\tRETROLAMBDA\tJACK" \
        && find apks -type f | \
            xargs -L1 ./lambdas.sh | \
            sort -k2,2nr)

NAME                         RETROLAMBDA     JACK
com.squareup.cash             826             0
com.robinhood.android         680             0
com.imdb.mobile             306             0
com.stackexchange.marvin     174             0
com.eventbrite.attendee     53                 0
com.untappdllc.app             53                 0

差不多 10 分鐘。這也取決於你最後獲取的應用有多少。如果你需要這樣做,請耐心一點。它需要一些時間,但是最後會有結果的。

不幸的是,顯然沒有多少人使用 lambda,我很興奮,因為我們使用的最多。我們有 826 個 lambda。這是 lambda 的個數而不是函式的個數。我們的函式個數是 lambda 的個數 826 乘以三或者四。

沒有人使用 Jack,或者至少我安裝的應用沒有用,沒有人同時使用 Jack 和 Lambda。他們可能用了 Jack 而沒有使用 lambda,這很奇怪。或者,他們使用了 ProGuarding。

所以,再強調一遍,ProGuard 完全隱藏了 lambda 類和函式名。如果你是一個使用 lambda 的流行應用,然後你使用了 ProGuard,這可能就是你不在這個列表上的原因。或者是因為我不喜歡你的應用。這是關於函式的一切。

我研究這個的原因是為了突破 65K 的限制。但是這些函式還有執行時候的開銷。載入額外的位元組流是有開銷的。執行的時候如果你需要遍歷它們,那麼還有額外的開銷。私有變數是我最喜歡的部分,因為許多時候你都能看到這些匿名的監聽者裡面發生了什麼。主執行緒上的 UI 互動往往會導致這些結果。

你不想要的正是這個在主執行緒上執行的計算開銷昂貴的程式碼,不論它是動畫,計算大小或者其它的任何事情。每一次你應用那些私有變數的時候,你都不想要這些開銷。你都不想跳轉到那個額外的函式去。查詢一個變數是很快的。呼叫一個函式,然後查詢一個變數也很快,但是肯定比查詢一個變數要慢些。因為這些 accessor 函式的存在,你沒有直接立即作用,而是引入了這些間接的途徑。但是它們是些無用的方法,它們能做的事情就是增大你的 APK ,然後拖慢你的應用。

集合 (33:21)

我想對齒輪做些修改,然後討論些更加關注執行時情況的事情。這和集合有關。

HashMap<K, V>                ArrayMap<K, V>
HashSet<K, V>                ArraySet<V>
HashMap<Integer, V>            SparseArray<V>
HashMap<Integer, Boolean>    SparseBooleanArray
HashMap<Integer, Integer>    SparseIntArray
HashMap<Integer, Long>        SparseLongArray
HashMap<Long, V>            LongSparseArray<V>

如果你的應用裡面有類似的事情,你可能浪費了比你需要的要多的資源。

Android 有這些專門的集合,我想大多數人現在都很瞭解。實現各式各樣,但是它們是專門為非常普遍的情況定製的。例如,當你需要一個 map 中整型的索引來引用某些值的時候。就有一個專門的集合可以使用。

許多時候人們都談論著 autoboxing 的內容。Autoboxing 是什麼?如果你不知道的話。當我們有一個 HashMap 的時候,這個 HasMap 接受整型的 key,這樣你有一個整型的值,然後你想把這個值放進 Map 裡面。 或者相反的,你在遍歷整個條目,你想根據 key 得到一個值,這個轉換不是直接能實現的。它需要一個額外的步驟叫做 aotoboxing,那裡它先獲得主要的值,然後轉換成它的類版本,這個例子裡面是整型。

最後的動作開銷不是很大。它是封裝一個型別。開始的例子開銷就大了。小數字還好,因為有快取,但是如果你有一個隨機的變化劇烈的整形,每一次你呼叫一個方法的時候都會分配空間。這很普遍,而且接受大的整型。這是大部分人認為是優點的地方。這是個很明顯的優點,但是它還有兩個我們從未談及的其他優點。

第一個是資料間接性。如果你看看 HashMap 是如何實現的,它有一個這些節點的陣列,這個陣列有自己的大小。當你插入一個數值或者查詢一個數值的時候,它就會跳到這個陣列去。這就是 hash 的步驟。找到這個 hash 值是有時空開銷的,然後它返回陣列的偏移。然而這是一個節點陣列。節點的型別有 key 和 value。它也有 hash,它有一個指向額外節點的指標。我們回到那個陣列,找到節點的引用,然後我們需要跳轉到那個節點了。如果我們需要值,我們就需要訪問這個節點。我們獲得節點的引用,然後跳轉過去。我們需要遍歷這些間接引用。它們在記憶體裡面是不同的大小。你需要跳過這些記憶體來得到 key 的值。或者在一個 key 上賦一個值。這會更糟。

這就是 hash 碰撞問題,當兩個 hash 碰到同一個 bucket 的時候,HashMap 在那個 bucket 裡面會有一個連結串列。如果我們碰巧碰到這種情況,我們就需要遍歷連結串列來獲取合適的 hash 值。我將討論一下 sparse 陣列。 sparse 陣列就是替代解決方案。我們馬上就會討論它,但是在這之前,另外一個優點就是開銷。這些集合的記憶體開銷。這裡我們有兩個類。

$ java -jar jol-cli-0.5-full.jar internals java.util.HashMap
# Running 64-bit HotSpot VM.
# Objects are 8 bytes aligned.
java.util.HashMap object internals:
OFFSET     SIZE     TYPE DESCRIPTION                 VALUE
0         4         (object header)                 01 00 00 00
4         4         (object header)                 00 00 00 00
8         4         (object header)                 9f 37 00 f8
12         4         Set AbstractMap.keySet             null
16         4         Collection AbstractMap.values     null
20         4         int HashMap.size                 0
24         4         int HashMap.modCount             0
28         4         int HashMap.threshold             0
32         4         float HashMap.loadFactor         0.75
36         4         Node[] HashMap.table             null
40         4         Set HashMap.entrySet             null
44         4         (loss due to the next object alignment)

Instance size: 48 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

我們可以使用一個叫做 Java 物件佈局的工具,它會告訴你建立一個這樣的物件在記憶體裡面的開銷。對 HashMap 執行它,它就會列印一堆東西。它會顯示你的每一個欄位的開銷。這裡重要的數字在最底下,每一個 HashMap, 只是 HashMap, 不是節點,不是 key 和 value 或者其它什麼東西。就是 HashMap 物件本身是 48 個位元組。這不差,這很小。

$ java -jar jol-cli-0.5-full.jar internals 'java.util.HashMap$Node'
# Running 64-bit HotSpot VM.
# Objects are 8 bytes aligned.

java.util.HashMap$Node object internals:
OFFSET     SIZE     TYPE DESCRIPTION     VALUE
0         12         (object header)     N/A
12         4         int Node.hash         N/A
16         4         Object Node.key     N/A
20         4         Object Node.value     N/A
24         4         Node Node.next         N/A
28         4         (loss due to the next object alignment)

Instance size: 32 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

我們對節點物件執行它。一樣的東西。我們看到我們的四個欄位,這個是 32 個位元組。每一個 map 裡面的節點都是 32 個位元組。Map 裡面的每一個成員,每一個鍵值對都會在這個節點裡。它是 32 乘以條目的個數。

當我們開始插入值的時候,我們可以使用這樣類似的公式來計算執行時一個物件的大小是多少。這不全是正確的。你還需要算上陣列的開銷。還有一個陣列持有這些數值,所以我們需要加上這些數值佔用的空間。這很複雜。它是 4,這是一個單獨整形的大小,它也是對每個數值的引用,乘以陣列的大小。

問題是,HashMap 有一個叫做 load 因子的東西。末尾的 8 位元組是每個陣列都有的開銷。但是 load 因子從來都不是飽和的。它會維繫某種程度的飽和,所以當它達到那個飽和因子的時候,它就會像一個陣列列表一樣增長。HashMap 也會持續增長這樣它就可以維繫一些空的空間。

這樣做的原因是,如果不這樣做的話,你會有許多的碰撞和效能損失。這就差不多是一個 HashMap 不論有多少個條目,也不論 load 因子是多大的開銷了。我們能算出來它需要佔用多少記憶體。順便說一句,預設的 load 因子是 75%。HashMap 只會達到 3/4 的容量。

Sparse 陣列是你替代這樣的 HashMap 的東西。讓我們看看 HashMap 的兩個例子,間接引用,間接引用的級別和記憶體的大小。Sparse 儲存了兩個差不多的陣列。一個是 key, 另一個是 value。如果我們尋找 map 裡面的某個值,或者插入某個值,我們要做的第一件事就是訪問這個整型陣列,不像 HashMap,不是固定時間開銷。它需要對陣列做二叉樹查詢。然後我們能訪問到這個數值,在這個例子裡,二叉樹查詢給了我們查詢值的特定單元。因為這個陣列是值,我們能直接返回,直接跳到引用,然會返回。

關於記憶體,這裡的間接引用就少了很多。整型陣列是連續的,它不是連結串列。我們能直接地在陣列內部跳轉。它沒有我們需要解析的節點物件,然後才能獲得值的過程。間接引用少了許多。然而,非固定時間有時會慢很多。這就是為什麼你想盡可能的保證陣列小的原因。說到小,意味著成百上千的條目。如果你有上萬個條目,效能就會很糟,相比較而言,這個時候 HashMap 的記憶體開銷反而更有吸引力一些。

$ javac SparseArray.java

$ java -cp .:jol-cli-0.5-full.jar org.openjdk.jol.Main \
internals android.util.SparseArray

# Running 64-bit HotSpot VM.
# Objects are 8 bytes aligned.

android.util.SparseArray object internals:
OFFSET     SIZE     TYPE DESCRIPTION                 VALUE
0         4         (object header)                 01 00 00 00
4         4         (object header)                 00 00 00 00
8         4         (object header)                 1a 69 01 f8
12         4         int SparseArray.mSize             0
16         1         boolean SparseArray.mGarbage     false
17         3         (alignment/padding gap)         N/A
20         4         int[] SparseArray.mKeys         [0, 0, 0, 0, 0, 0, …]
24         4         Object[] SparseArray.mValues     [null, null, null, …]
28         4         (loss due to the next object alignment)

Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total

在這個例子裡面,因為類都是差不多的,而且是 JVM 64 bit, Android 現在也是 64 bit 了,數字是一樣的。如果你不習慣,把它們看作是個近似值,然後增加 20% 的變數。這肯定比弄清楚在 Android 裡物件的大小要容易得多。Android 裡面幾乎不可能弄清楚,這個方法會容易許多。

Sparse 陣列的物件本身稍微小一點,32 位元組。這個大小沒有太大關係。真正有影響的是你知道它們不可能只有 32 位元組。我們仍然有條目增長問題。我們需要計算整個陣列的大小,那是所有整形 key,那是 4 乘以條目的個數然後加上 8. 同時還有 value 陣列的大小,是同樣的計算方式,4 乘以條目的個數然後加上 8.

Sparse 陣列這裡有一點不同,就是它沒有 load 因子。但是它也隱含著因子的概念,因為這些陣列是樹,它們是二叉樹。它們不是全部填滿的。也不是連續的。它們中間有些空間是沒有被佔用的。我們需要用另外一個捏造的因子來說明這個問題,這個因子和 load 因子很像。在這個例子裡面,這完全取決於你往 map 裡面插入什麼元素。我使用同樣的值。我假設這些陣列會 75% 的滿載,這是一個非常安全的假設。

SparseArray<V>
32 + (4 * entries + 4 * entries) / 0.75

HashMap<Integer, V>
48 + 32 * entries + 4 * (entries / loadFactor) + 8

現在我們能直接比較這些內容了。我們知道我們能計算間接跳轉。那很容易。現在我們能用這些公式來計算這些例項真正佔用多少記憶體了。

SparseArray<V>
32 + (4 * 50 + 4 * 50) / 0.75 = 656

HashMap<Integer, V>
48 + 32 * 50 + 4 * (50 / 0.75) + 8 = 1922

對於 HashMap 我將使用預設的 .75 因子。我們可以假設一個條目的個數。在這個例子裡,我選擇了 50。也許這是一個美國各州的 Map。然後我們能進行這些計算。你能看到 Sparese 陣列大概是 HashMap 的 1/3。你需要記住,這裡會有一些效能的損失,因為每一個操作都不再是瞬時的時間了。這裡是 50 個元素,所以二叉樹的搜尋是非常快的。

結論 (44:18)

講了很多東西。都是些非常瑣碎的需要去做的事情,這樣做才能避免函式編譯時的開銷,執行時不同物件的開銷,還有間接引用的開銷。

第一個我已經展示了。開啟私有成員檢查然後注意它。不要忽視它們。你不需要為每一個型別都這樣做。你不需要都看一遍,然後全部一次解決。這是你在開發應用時能同時完成的事情。相反的,對於庫的開發者來說,這會更重要一點。

作為一個庫,你希望最小化你對 APK 大小的影響和執行時效能的影響。或者 deck 大小和執行時的效能。如果你在開發一個庫,你可能需要遍歷一遍然後找出所有的問題。合成的生成函式在庫裡面沒有任何意義。它只是浪費了 dex 檔案的空間。也浪費了執行的時間,把它們當作 Bug 一樣看待。

如果你在使用 retrolambda,請千萬確認你已經升級到最新的版本了,因為你可能浪費了成千上萬個函式。如果你在編寫一個開源庫,請注意處理好你的匿名類。也許這沒有太大的影響。但是,在強調一遍,你是追求對使用你庫的應用開發者造成最小的影響的,這樣做你的庫就不會有問題了。

試試 Jack。它很好用。它還是快速的開發中。它現在還缺少許多東西來使得每一個人都能使用。但是肯定的是,總有些不太重要的應用是不希望在編譯的時候做些不一樣的事情的。

不要忽略 bug。不要回避,找到它,並且解決它。”哦,不管怎麼樣,我都用了 2 年了。” 你可以這麼說,在你轉回 Java C 索引的時候上報這些 bug。這是未來的趨勢,你不可能閉關鎖國,因為它在發生著。現在接受這些事情比未來要容易些。然後是開啟 ProGuard。ProGuard 也有作用。無論你是不是在用 ProGuard 或者是其他增量縮減工具,而且 instant run 也在一起配合的很好,想必這也是 Jack 未來會使用的東西。

不要死板地套用規則。如果你在 ProGuard 的規則檔案裡面看見 * *,那肯定是 100% 錯誤的。這一定不是你希望使用的方式,因為這樣做你不會從 ProGuard 裡面得到任何好處。你可能說 “是的,我在使用 OKHttp,但是我不想使用 HTTP/2 的所有方法,我也從來沒有用過。我不想它們被移除,我希望保持它們以備未來使用” 這並不聰明。如果你在開源庫裡看到這些,這是一個 bug。上報這個 bug。如果你的應用裡面有這些東西,嘗試著理解為什麼它們會被加入。把它們拿出來,然後想想,看看 ProGuard 報的錯誤,然後看看你能對你的規則做些什麼特別的修改。如果這些東西引起你的注意了,而且你感興趣,這裡還有其他的一些演講你可以作為參考。

相關文章