探索 Java 隐藏的开销

澳门新葡亰手机版 1

随着 Android 引入 Java 8 的一些功能,请记住每一个标准库的 API
和语言特性都会带来一些相关的开销,这很重要。虽然设备越来越快而且内存越来越多,代码大小和性能优化之间仍然是有着紧密关联的。这篇 360AnDev 的演讲会探索一些
Java
功能的隐藏开销。我们会关注对库开发者和应用开发者都有关系的优化和能够衡量它们影响的工具。

原文

2016年, Jake
Wharton发表了一系列有趣的言论关于Java的隐性成本。同一时期他也开始拥护使用Kotlin开发Android,但是几乎不提Kotlin的隐性成本除了推荐使用内联函数。由于Kotlin被Google官方推荐。通过研究它产生的字节码,我认为写有关这门语言方面的东西将是一个好主意。同Java相比,Kotlin是一门提供了大量语法糖的现代编程语言。同样的,也有更多的“黑魔法”在幕后进行着,其中一部分是不可忽视的成本,特别是对于那些年代久远和低配的设备。这不是反对Kotlin:我非常喜欢这门语言并且它能提高生产率,但我也相信一个好的程序员需要了解语言内部的特性为了更明智的使用它们。Kotlin
很强大,古语有云:能力越大责任越大。这些文章将单单聚焦于在JVM/Android 上
Kotlin1.1的实现,而不是Javascript的实现。

选择这个工具是为了搞清Kotlin的代码如何转化成字节码。在装有Kotlin插件的Android
Studio上,选择”Show Kotlin
ByteCode”去展示当前类的字节码。接着可以点击“Decompile”按钮去看等效的Java代码

澳门新葡亰手机版 1Paste_Image.png

实际上,我将会着重提及的Kotlin特性有:

  • 分配短生命周期的对象的原始类型装箱
  • 在代码中不直接可见的额外实例化的对象
  • 额外生成的方法(如你所知,在android应用中,单一的dex文件中、方法数是受限的,超过限制需要配置multidex,这也带来了限制和性能损耗,特别是在Lollipo5.0之前的Android版本中)

Kotlin支持为变量赋值函数并且作为参数传给其它函数。函数接受其他函数作为参数称作高阶函数。Kotlin函数能够被引用通过它的名字和::前缀或者作为匿名类直接声明。再或者使用最简洁描述函数的方式-lambda表达式。在Java6/7
JVMS和Android平台上,Kotlin是提供lambdas支持的最好的方式。参看以下应用函数:在数据库事务中执行任意操作并且返回受影响的行。

fun transaction(db: Database, body:  -> Int): Int { db.beginTransaction() try { val result = body db.setTransactionSuccessful() return result } finally { db.endTransaction() }}

我们可以调用这个函数通过传入一个lambda表达式作为最后一个参数,使用语法同Groovy相似:

val deletedRows = transaction { it.delete("Customers", null, null)}

但是Java6 JVMs
不直接支持lambda表达式。所以他们如何被转化为字节码?正如你可能期望的,lambdas和匿名函数被转化为函数对象。

下面的Java代码代表上面的lambda表达式被编译后

class MyClass$myMethod$1 implements Function1 { // $FF: synthetic method // $FF: bridge method public Object invoke(Object var1) { return Integer.valueOf(this.invoke(var1)); } public final int invoke(@NotNull Database it) { Intrinsics.checkParameterIsNotNull; return db.delete("Customers", null, null); }}

在你的Android
dex文件中,每个lambda表达式被编译成一个函数并将添加3-4个方法。好消息是这些函数对象的新实例仅仅在被需要的时候被创建。实际上也意味着:

  • 为了捕获表达式,每次lambda被当做参数传递将会生成一个新的函数实例并在执行完成后回收。
  • 对于非捕获函数,一个单例函数实例将被创建并在下次调用中重用。

由于我们的例子代码使用了非捕获lambda,它被编译成了一个单例并且非内部类。

this.transaction(db, (Function1)MyClass$myMethod$1.INSTANCE);

为了减少gc压力,避免重复调用高阶函数如果他们正在执行捕获lambdas。

与Java8相反,Kotlin有43种不同的特殊接口为了尽可能的避免装箱和拆箱,Kotlin编译的函数对象只实现了完全通用的接口,有效地使用了任何输入或输出值的对象类型。

/** A function that takes 1 argument. */public interface Function1<in P1, out R> : Function<R> { /** Invokes the function with the specified argument. */ public operator fun invoke: R}

这意味调用一个传入高阶函数的参数的函数实际上将涉及到系统的装箱和拆箱,当函数涉及到原始类型(如int或者long)作为输入值和返回值。这可能在性能上有负面影响,特别是在android上。在上面编译的lambda表达式中,我们可以看到结果被装入到Integer对象中。调用者代码接着将会为它拆箱。

当写一个标准的涉及到一个函数参数使用原始类型作为输入输出的高阶函数时需要小心。由于装箱和拆箱,重复的调用这个函数参数将会给gc带来更多压力。

值得欣慰的是,Kotlin中有一个很好的技巧避免造成任何损耗当使用lambda表达式:声明高阶函数为内联。这将使编译器内联函数体到调用者代码,避免彻底的调用。对于高阶函数的好处是更大的。因为作为参数的lambda表达式体也会被内联。实际效果如下:

  • 没有函数对象将会被实例化当lambda被声明。
  • 澳门新葡亰手机版,目标输入输出原始类型的装箱和拆箱将不会应用到lambda中
  • 没有方法将被增加
  • 没有实际的函数调用将被执行。占用CPU时间较多的代码将可以显著提高性能。

在我们声明transation()功能为内联之后,表示我们调用代码的java代码为:

db.beginTransaction();int var5;try { int result$iv = db.delete("Customers", null, null); db.setTransactionSuccessful(); var5 = result$iv;} finally { db.endTransaction();}

这个杀手锏特性有一些需要注意的地方:

  • 内联函数不能直接被另一个内联函数调用
  • 一个类中声明的内联函数只能只能访问这个类的公共函数和字段。
  • 代码将会变多。内联一个引用多次的长函数将会使生成的代码明显的变大,如果这个长函数本身引用了其他的长内嵌函数

如果可能,声明高阶函数为内联并使它们短。如果需要的话移动大块代码到非内联函数,你可以内联那些性能关键部分被调用的代码。

我们将会讨论其他内联函数的性能好处在未来的文章当中。

Kotlin类没有静态字段和函数。反而,非实例相关的字段和函数能被声明到类中的伴随实体中。

介绍 (0:00)

在这篇演讲里面,我将讨论我近六个月以来一直在探索的事情,而且我想披露一些信息。随着你的深入了解,你可能得不到一些明确的能够应用在你的应用程序上的东西。但是,到结束的时候,我会有一些具体的技巧来展示如何避免我今天讲的这些开销。我也会展示许多我使用的命令行工具,这些资源的链接都在文章结束的地方。

Accessing private class fields from its companion object(从它的伴随实体中访问私有字段)

参考如下例子:

class MyClass private constructor() { private var hello = 0 companion object { fun newInstance() = MyClass() }}

当被编译,一个伴随实体被实现为一个单例对象。正如那些私有字段需要被外部类访问的java类。访问一个私有字段或者构造函数需要生成额外的getter和setter方法。每次读或写访问一个类字段将造成静态方法被调用在伴随实体中。

ALOAD 1INVOKESTATIC be/myapplication/MyClass.access$getHello$p (Lbe/myapplication/MyClass;)IISTORE 2

对于这些字段,在Java中我们使用包可见性为了避免生成这些方法。然而对于Kotlin,这里没有包可见性。使用公共或者内部可见性替代将会造成Kotlin生成默认的getter和setter方法去使这些字段可以被外部世界访问。调用实例的方法理论上要比静态方法更耗费时间。所以不要厌烦去改变这些字段的可见性为了优化的理由。

如果你需要重复的读或者写去访问一个来自于伴随对像的类字段,你最好缓存它的值到一个局部变量为了避免重复的读写操作。

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
文件有方法的限制,所以当你有这些因为你编写源文件的方式的不同,而导致的必须要添加新的的方法加的话,这些函数的个数都是计算在内的。理解这点是很重要的,因为我们尝试在某处访问一个私有成员是不可能的。

Accessing constants declared in a companion object(访问伴随体中的常量)

在Kotlin中我们一般声明为“static”在伴随体的类中使用的常量。

class MyClass { companion object { private val TAG = "TAG" } fun helloWorld() { println }}

这些代码看起来整洁和干净,但是背后的实现确实相当丑陋的。由于上面提出的相同原因,访问一个在伴随体重声明的私有常量实际上将会生成额外的getter方法。

GETSTATIC be/myapplication/MyClass.Companion : Lbe/myapplication/MyClass$Companion;INVOKESTATIC be/myapplication/MyClass$Companion.access$getTAG$p (Lbe/myapplication/MyClass$Companion;)Ljava/lang/String;ASTORE 1

但更糟糕的是,这些合成的方法实际上不返回值,它调用一个被Kotlin生成的实例方法。

ALOAD 0INVOKESPECIAL be/myapplication/MyClass$Companion.getTAG ()Ljava/lang/String;ARETURN

当常量被声明为public而不是private,这个getter方法是public并且能够被直接调用,所以之前合成方法的那一步是不需要的。但是Kotlin仍然需要调用一个getter方法去读取一个常量。那么,这样就完了么?不!。事实上为了存储常量,Kotlin在主级别的类上生成一个实际的private
static final
字段而不是在伴随体中。但是,由于字段被声明为静态在类中,从伴随体中访问这个类需要另一个合成方法。

INVOKESTATIC be/myapplication/MyClass.access$getTAG$cp ()Ljava/lang/String;ARETURN

并且这个合成方法读取实际的值,最后:

GETSTATIC be/myapplication/MyClass.TAG : Ljava/lang/String;ARETURN

换句话说,当访问一个伴随体中的的常量字段时,不像java那样直接读取一个静态字段,你的代码实际上将会:

  • 调用一个伴随体中的静态方法
  • 接着调用伴随体中的实例方法
  • 接着调用类中的静态方法
  • 最后读取静态字段并返回它的值

下面是相等的Java代码:

public final class MyClass { private static final String TAG = "TAG"; public static final Companion companion = new Companion(); // synthetic public static final String access$getTAG$cp() { return TAG; } public static final class Companion { private final String getTAG() { return MyClass.access$getTAG$cp(); } // synthetic public static final String access$getTAG$p(Companion c) { return c.getTAG(); } } public final void helloWorld() { System.out.println(Companion.access$getTAG$p(companion)); }}

那么我们能够获得更轻量级的字节码么?可以,但不是所有的情况下。首先,使用const
关键字完全避免任何方法被声明为编译时常量是可能的。这将会有效的内联为调用方法中直接访问的值,但是你只能对原始类型和字符串这样用。

class MyClass { companion object { private const val TAG = "TAG" } fun helloWorld() { println }}

第二,你可以使用@JvmField注解在一个伴随体的公共字段上去通知编译器不要生成任何getter和setter并且将其作为类中的静态字段公开,就像纯粹的java常量。事实上,这个注解过去被创建单是为了Java的兼容性。我绝不会推荐用模糊的交互注释杂乱你美丽的Kotlin代码如果你不需要你的常量被Java代码访问。还有,它只能被公共字段使用。在Android开发的Context中,你可能使用这个注解实现Parcelable
对象:

class MyClass() : Parcelable { companion object { @JvmField val CREATOR = creator { MyClass } } private constructor(parcel: Parcel) : this() override fun writeToParcel(dest: Parcel, flags: Int) {} override fun describeContents() = 0}

最后,你也可以使用ProGuard工具去优化字节码并且希望它能把同时调用的方法合并一起,但是不能绝对保证它能行。与Java相比,读取一个伴随体中的静态常量将增加2-3个额外的方法。并且这些方法会为每个这类字段生成。对于这篇文章来说,这是所有内容。希望这能让你更好的理解使用这些Kotlin特性的隐喻。记住这点:为了写出更聪明的代码不要牺牲可读性和性能继续阅读请前往第二篇:局部函数,空指针安全和可变参数

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 "$NAMEt$ACCESSORSt$READt$WRITEt$PREINCt$PREDECt$POSTINCt$POSTDECt$OTHER"

然后我们运行这个疯狂的命令,它会遍历每一个从手机里面获取的
APK,运行这个脚本,然后你得到一个漂亮的报表:

$ column -t -s $'t' 
<(echo -e "NAMEtTOTALtMETHOD/READtWRITEtPREINCtPREDECtPOSTINCtPOSTDECtOTHER" 
&& 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 个函数了。