Android開発の神ジェイクウォートンは尋ねます:2で割り、1で右にシフトします。誰が良いですか?

AndroidXコレクションライブラリをKotlinマルチプラットフォーム移植して、バイナリの互換性、パフォーマンス、使いやすさ、さまざまなメモリモデルをテストしようとしています。クラスライブラリの一部のデータ構造は、配列に基づくバイナリツリーを使用して要素を格納します。Javaコードシフト操作には、2番目の電力分割の代わりに多くの場所がありますKotlinに移植されると、これらのコードはわずかにねじれたインフィックス演算子に変換され、コードの意図が多少混乱します。

シフト操作と乗算/除算のパフォーマンス向上について、私はいくつかの調査を行いました。ほとんどの人は「シフト操作の方がパフォーマンスが優れている」と聞いていますが、その信憑性にも疑問を持っています。コードがCPUに実行される前に、コンパイラがいくつかの最適化を行う可能性があると考える人もいます。

私の好奇心を満たし、Kotlinのインフィックスシフト演算子の使用を避けるために、誰が優れているか、およびいくつかの関連する質問に答えます。行こう!

誰がコードを最適化しましたか?

私たちのコードがCPUを実行する前に、そこにいくつかの重要なコンパイラは以下の通りですjavac/kotlincD8、R8 と ART 。

すべてのステップで最適化の機会がありますが、彼らはそれを実行しましたか?

class Example {
  static int multiply(int value) {
    return value * 2;
  }
  static int divide(int value) {
    return value / 2;
  }
  static int shiftLeft(int value) {
    return value << 1;
  }
  static int shiftRight(int value) {
    return value >> 1;
  }
}

上記のコードをJDK14でコンパイルしjavap 、バイトコードを 表示します。

$ javac Example.java
$ javap -c Example
Compiled from "Example.java"
class Example {
  static int multiply(int);
    Code:
       0: iload_0
       1: iconst_2
       2: imul
       3: ireturn

  static int divide(int);
    Code:
       0: iload_0
       1: iconst_2
       2: idiv
       3: ireturn

  static int shiftLeft(int);
    Code:
       0: iload_0
       1: iconst_1
       2: ishl
       3: ireturn

  static int shiftRight(int);
    Code:
       0: iload_0
       1: iconst_1
       2: ishr
       3: ireturn
}

各メソッドはコマンドで始まります。これは、最初のパラメーターをロードすることを意味します。乗算と除算は、リテラル2をロードするために使用される命令です。その後、実行されintタイプの分割を実行するように指示されましたシフト操作はリテラルにロードされてから、シフト操作を使用および命令します。 iload_0 iconst_2imul idiv ishlishr

ここには最適化はありませんが、javaについて何か知っていれば、驚くことはありません。javac最適化するのはコンパイラではありませんが、ほとんどの作業はランタイムコンパイラまたはJVM上のAOTに任されています。

kotlinc

fun multiply(value: Int) = value * 2
fun divide(value: Int) = value / 2
fun shiftLeft(value: Int) = value shl 1
fun shiftRight(value: Int) = value shr 1

Kotlin 1.4-M1バージョンでは、 KotlinをJavaバイトコードにコンパイルし、それを使用して 表示し ます。 kotlincjavap

$ kotlinc Example.kt
$ javap -c ExampleKt
Compiled from "Example.kt"
public final class ExampleKt {
  public static final int multiply(int);
    Code:
       0: iload_0
       1: iconst_2
       2: imul
       3: ireturn

  public static final int divide(int);
    Code:
       0: iload_0
       1: iconst_2
       2: idiv
       3: ireturn

  public static final int shiftLeft(int);
    Code:
       0: iload_0
       1: iconst_1
       2: ishl
       3: ireturn

  public static final int shiftRight(int);
    Code:
       0: iload_0
       1: iconst_1
       2: ishr
       3: ireturn
}

出力結果はJavaとまったく同じです。

これはKotlinの元のJVMバックエンドを使用していますが、今後のIRベースのバックエンド(-Xuse-irを介して)を使用しても同じ出力が生成されます。

理解できないので上の文を組み立ててください〜

D8

最新のD8コンパイラを使用して、上記の例のKotlinコードから変換されたバイトコードからDEXファイルを生成します。

$ java -jar $R8_HOME/build/libs/d8.jar \
      --release \
      --output . \
      ExampleKt.class
$ dexdump -d classes.dex
Opened 'classes.dex', DEX version '035'
Class #0            -
  Class descriptor  : 'LExampleKt;'
  Access flags      : 0x0011 (PUBLIC FINAL)
  Superclass        : 'Ljava/lang/Object;'
  Direct methods    -
    #0              : (in LExampleKt;)
      name          : 'divide'
      type          : '(I)I'
      access        : 0x0019 (PUBLIC STATIC FINAL)
      code          -
000118:                              |[000118] ExampleKt.divide:(I)I
000128: db00 0102                    |0000: div-int/lit8 v0, v1, #int 2 // #02
00012c: 0f00                         |0002: return v0
#1              : (in LExampleKt;)
  name          : 'multiply'
  type          : '(I)I'
  access        : 0x0019 (PUBLIC STATIC FINAL)
  code          -

000130:                              |[000130] ExampleKt.multiply:(I)I
000140: da00 0102                    |0000: mul-int/lit8 v0, v1, #int 2 // #02
000144: 0f00                         |0002: return v0
#2              : (in LExampleKt;)
  name          : 'shiftLeft'
  type          : '(I)I'
  access        : 0x0019 (PUBLIC STATIC FINAL)
  code          -
000148:                              |[000148] ExampleKt.shiftLeft:(I)I
000158: e000 0101                    |0000: shl-int/lit8 v0, v1, #int 1 // #01
00015c: 0f00                         |0002: return v0
#3              : (in LExampleKt;)
  name          : 'shiftRight'
  type          : '(I)I'
  access        : 0x0019 (PUBLIC STATIC FINAL)
  code          -

(わずかに最適化された出力結果)

Dalvikバイトコードはレジスタに基づいており、Javaバイトコードはスタックに基づいています。結局、各メソッドは実際には1つのバイトコードのみを使用して関連する整数演算を操作します。それらはすべてv1レジスタを使用して最初のメソッドパラメータを格納し、リテラル1または2も必要です。

したがって、変更は行われません。D8は最適化コンパイラではありません(ただし、メソッドローカル最適化は実行できます)。

R8

R8を実行するには、コードが削除されないように難読化ルールを構成する必要があります。

-keep,allowoptimization class ExampleKt {
  <methods>;
}

上記のルール--pg-conf はパラメータを介して渡され ます

$ java -jar $R8_HOME/build/libs/r8.jar \
      --lib $ANDROID_HOME/platforms/android-29/android.jar \
      --release \
      --pg-conf rules.txt \
      --output . \
      ExampleKt.class
$ dexdump -d classes.dex
Opened 'classes.dex', DEX version '035'
Class #0            -
  Class descriptor  : 'LExampleKt;'
  Access flags      : 0x0011 (PUBLIC FINAL)
  Superclass        : 'Ljava/lang/Object;'
  Direct methods    -
    #0              : (in LExampleKt;)
      name          : 'divide'
      type          : '(I)I'
      access        : 0x0019 (PUBLIC STATIC FINAL)
      code          -
000118:                              |[000118] ExampleKt.divide:(I)I
000128: db00 0102                    |0000: div-int/lit8 v0, v1, #int 2 // #02
00012c: 0f00                         |0002: return v0

    #1              : (in LExampleKt;)
      name          : 'multiply'
      type          : '(I)I'
      access        : 0x0019 (PUBLIC STATIC FINAL)
      code          -
000130:                              |[000130] ExampleKt.multiply:(I)I
000140: da00 0102                    |0000: mul-int/lit8 v0, v1, #int 2 // #02
000144: 0f00                         |0002: return v0

    #2              : (in LExampleKt;)
      name          : 'shiftLeft'
      type          : '(I)I'
      access        : 0x0019 (PUBLIC STATIC FINAL)
      code          -
000148:                              |[000148] ExampleKt.shiftLeft:(I)I
000158: e000 0101                    |0000: shl-int/lit8 v0, v1, #int 1 // #01
00015c: 0f00                         |0002: return v0

    #3              : (in LExampleKt;)
      name          : 'shiftRight'
      type          : '(I)I'
      access        : 0x0019 (PUBLIC STATIC FINAL)
      code          -
000160:                              |[000160] ExampleKt.shiftRight:(I)I
000170: e100 0101                    |0000: shr-int/lit8 v0, v1, #int 1 // #01
000174: 0f00                         |0002: return v0

出力はD8とまったく同じです。

アート

上記のR8から出力されたDalvikバイトコードをARTの入力として使用し、Android10のx86仮想マシンで実行します。

$ adb push classes.dex /sdcard/classes.dex
$ adb shell
generic_x86:/ $ su
generic_x86:/ # dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oat
generic_x86:/ # oatdump --oat-file=/sdcard/classes.oat
OatDexFile:
0: LExampleKt; (offset=0x000003c0) (type_idx=1) (Initialized) (OatClassAllCompiled)
  0: int ExampleKt.divide(int) (dex_method_idx=0)
    CODE: (code_offset=0x00001010 size_offset=0x0000100c size=15)...
      0x00001010:     89C8      mov eax, ecx
      0x00001012:   8D5001      lea edx, [eax + 1]
      0x00001015:     85C0      test eax, eax
      0x00001017:   0F4DD0      cmovnl/ge edx, eax
      0x0000101a:     D1FA      sar edx
      0x0000101c:     89D0      mov eax, edx
      0x0000101e:       C3      ret
  1: int ExampleKt.multiply(int) (dex_method_idx=1)
    CODE: (code_offset=0x00001030 size_offset=0x0000102c size=5)...
      0x00001030:     D1E1      shl ecx
      0x00001032:     89C8      mov eax, ecx
      0x00001034:       C3      ret
  2: int ExampleKt.shiftLeft(int) (dex_method_idx=2)
    CODE: (code_offset=0x00001030 size_offset=0x0000102c size=5)...
      0x00001030:     D1E1      shl ecx
      0x00001032:     89C8      mov eax, ecx
      0x00001034:       C3      ret
  3: int ExampleKt.shiftRight(int) (dex_method_idx=3)
    CODE: (code_offset=0x00001040 size_offset=0x0000103c size=5)...
      0x00001040:     D1F9      sar ecx
      0x00001042:     89C8      mov eax, ecx
      0x00001044:       C3      ret

(わずかに最適化された出力結果)

x86アセンブリコードは、ARTが数学演算に介入し、それらの一部をシフト演算に置き換えたことを示しています。

まず第一に、multiplyそしてshiftLeft今、私たちは同じ認識を持っています、彼らは両方ともshl左シフト操作に使用します。さらに、ファイルオフセット(左端の列)を見ると、まったく同じであることがわかります。ARTは、これら2つのメソッドが同じメソッド本体を持っていることを認識し、x86アセンブリコードにコンパイルするときに重複排除操作を実行します。

次に、divideおよびshiftRight実装は同じではありません、彼らは一般的に使用されていないsar右シフト演算を実行します。入力を処理するために使用される追加の4つの命令の前divideのメソッド呼び出しでsarは、負の数です。

Android 10 Pixel4デバイスで同じ手順を実行し、ARTがコードをARMアセンブリコードにコンパイルする方法を見てみましょう。

OatDexFile:
0: LExampleKt; (offset=0x000005a4) (type_idx=1) (Verified) (OatClassAllCompiled)
  0: int ExampleKt.divide(int) (dex_mmultiply and shiftLeft ethod_idx=0)
    CODE: (code_offset=0x00001009 size_offset=0x00001004 size=10)...
      0x00001008: 0fc8      lsrs r0, r1, #31
      0x0000100a: 1841      adds r1, r0, r1
      0x0000100c: 1049      asrs r1, #1
      0x0000100e: 4608      mov r0, r1
      0x00001010: 4770      bx lr
  1: int ExampleKt.multiply(int) (dex_method_idx=1)
    CODE: (code_offset=0x00001021 size_offset=0x0000101c size=4)...
      0x00001020: 0048      lsls r0, r1, #1
      0x00001022: 4770      bx lr
  2: int ExampleKt.shiftLeft(int) (dex_method_idx=2)
    CODE: (code_offset=0x00001021 size_offset=0x0000101c size=4)...
      0x00001020: 0048      lsls r0, r1, #1
      0x00001022: 4770      bx lr
  3: int ExampleKt.shiftRight(int) (dex_method_idx=3)
    CODE: (code_offset=0x00001031 size_offset=0x0000102c size=4)...
      0x00001030: 1048      asrs r0, r1, #1
      0x00001032: 4770      bx lr

同様に、multiplyそしてshiftLeft使用されるlsls左シフト操作を完了するために繰り返し、メソッド本体に加えています。右への命令の完了shiftRightによりasrs、右シフト命令でlsrsは、入力を処理するために使用される別の区分が負の数になります。

これまでのところ、確実に言うことができますが、代わり使用してもメリットはありませんvalue << 1value * 2算術演算でそのようなことをするのをやめ、ビット単位の演算が厳密に必要な場合にのみそれらを保持してください。

しかし、value / 2value >> 1はまだ別のアセンブリ命令を生成するので、さまざまなパフォーマンスがあるでしょう。幸い、value / 2一般的な分割操作は実行されず、シフト操作に基づいているため、パフォーマンスの違いは大きくない可能性があります。

シフトは分割よりも速いですか?

シフト操作と分割操作のどちらが速いかを判断するために、Jetpackベンチマークを使用  してテストしました。

class DivideOrShiftTest {
  @JvmField @Rule val benchmark = BenchmarkRule()

  @Test fun divide() {
    val value = "4".toInt() // Ensure not a constant.
    var result = 0
    benchmark.measureRepeated {
      result = value / 2
    }
    println(result) // Ensure D8 keeps computation.
  }

  @Test fun shift() {
    val value = "4".toInt() // Ensure not a constant.
    var result = 0
    benchmark.measureRepeated {
      result = value shr 1
    }
    println(result) // Ensure D8 keeps computation.
  }
}

x86デバイスを持っていないので、Android 10 Pixel3でテストしたところ、結果は次のようになりました。

android.studio.display.benchmark=4 ns DivideOrShiftTest.divide
count=4006
mean=4
median=4
min=4
standardDeviation=0

実際には、除算とシフトの使用に違いはありません。両者の違いはナノ秒です。負の数を使用しても、結果に違いはありません。

これまでのところ、確実に言うことができますが、代わり使用してもメリットはありませんvalue >> 1value / 2算術演算でそのようなことをするのをやめ、ビット単位の演算が厳密に必要な場合にのみそれらを保持してください。

D8 / R8はApkボリュームを減らすことができますか?

同じ操作に対して2つの式がある場合は、より良いパフォーマンスを選択する必要があります。パフォーマンスが同じである場合は、Apkボリュームを減らすことができるものを選択する必要があります。

今では、ARTで同じアセンブリコードを知っvalue * 2value << 1作成しています。したがって、Dalvikでより多くのスペースを節約できるのであれば、別の書き方ではなく、間違いなくそれを使用する必要があります。同じサイズのバイトコードも生成するD8の出力を見てみましょう。

    #1              : (in LExampleKt;)
      name          : 'multiply'
      ⋮
000140: da00 0102                    |0000: mul-int/lit8 v0, v1, #int 2 // #02
#2              : (in LExampleKt;)
  name          : 'shiftLeft'
  ⋮

乗算は、文字通りの量を格納するためにより多くのスペースを消費する可能性があります。と比較し value * 32_768 てください value << 15 。

    #1              : (in LExampleKt;)
      name          : 'multiply'
      ⋮
000128: 1400 0080 0000               |0000: const v0, #float 0.000000 // #00008000
00012e: 9201 0100                    |0003: mul-int v1, v1, v0
#2              : (in LExampleKt;)
  name          : 'shiftLeft'
  ⋮

この問題についてはD8 で説明しましたが、この発生の可能性は0であると強く疑っているので、それだけの価値はありません。D8とR8の出力は、Dalvik の場合、value / 2 合計 value >> 1のコストが同じであることも示しています。

    #0              : (in LExampleKt;)
      name          : 'divide'
      ⋮
000128: db00 0102                    |0000: div-int/lit8 v0, v1, #int 2 // #02
#2              : (in LExampleKt;)
  name          : 'shiftLeft'
  ⋮

サイズがリテラル32768達すると、上記はバイトコードのサイズを変更します。負の数のため、2の累乗の除算を無条件に置き換えるために右シフトを使用することは絶対に安全ではありません。負でない数を保証しながら、置換を行うことができます。

符号なしの数字の2乗への分割もシフトを使用しますか?

Javaバイトコードには符号なしの番号はありませんが、符号付きの番号を使用してシミュレートできます。Javaは、符号付きの数値を符号なしの数値に変換する静的メソッドを提供します。Kotlinは、UInt同じ機能を提供する符号なしタイプを提供しますが、Javaとは異なり、データタイプとして独立して抽象化されます。右シフト操作で確実に2つの累乗の除算を書き換えることができると考えられます。

Kotlinを使用して、次の2つの状況を示します。

fun javaLike(value: Int) = Integer.divideUnsigned(value, 2)
fun kotlinLike(value: UInt) = value / 2U

kotlinc コンパイルすることにより (Kotlin 1.4-M1)

$ kotlinc Example.kt
$ javap -c ExampleKt
Compiled from "Example.kt"
public final class ExampleKt {
  public static final int javaLike(int);
    Code:
       0: iload_0
       1: iconst_2
       2: invokestatic  #12       // Method java/lang/Integer.divideUnsigned:(II)I
       5: ireturn
public static final int kotlinLike-WZ4Q5Ns(int);
Code:
0: iload_0
1: istore_1
2: iconst_2
3: istore_2
4: iconst_0
5: istore_3
6: iload_1
7: iload_2
8: invokestatic  #20       // Method kotlin/UnsignedKt."uintDivide-J1ME1BU":(II)I
11: ireturn
}

Kotlinが2番目の電力分割器として認識されていないため、iushr代わりにシフト操作が行われます。このもJetbrainに提出しました

使用し-Xuse-iても変更はありません(ロード/ストアの一部を削除することに加えて)。ただし、Java8では異なります。

$ kotlinc -jvm-target 1.8 Example.kt
$ javap -c ExampleKt
Compiled from "Example.kt"
public final class ExampleKt {
  public static final int javaLike(int);
    Code:
       0: iload_0
       1: iconst_2
       2: invokestatic  #12       // Method java/lang/Integer.divideUnsigned:(II)I
       5: ireturn
public static final int kotlinLike-WZ4Q5Ns(int);
Code:
0: iload_0
1: iconst_2
2: invokestatic  #12       // Method java/lang/Integer.divideUnsigned:(II)I
5: ireturn
}

Integer.divideUnsignedこのメソッドは、Java8以降で使用できます。このように2つの関数本体は完全に同じであるため、比較のために古いバージョンに戻ります。

次はR8です。上記との明らかな違いは、Kotlin標準ライブラリを入力として使用し、最小のapiも指定すること--min-api 24です。API24以降でInteger.divideUnsignedのみ使用できるためです。

$ java -jar $R8_HOME/build/libs/r8.jar \
      --lib $ANDROID_HOME/platforms/android-29/android.jar \
      --min-api 24 \
      --release \
      --pg-conf rules.txt \
      --output . \
      ExampleKt.class kotlin-stdlib.jar
$ dexdump -d classes.dex
Opened 'classes.dex', DEX version '039'
Class #0            -
  Class descriptor  : 'LExampleKt;'
  Access flags      : 0x0011 (PUBLIC FINAL)
  Superclass        : 'Ljava/lang/Object;'
  Direct methods    -
    #0              : (in LExampleKt;)
      name          : 'javaLike'
      type          : '(I)I'
      access        : 0x0019 (PUBLIC STATIC FINAL)
      code          -
0000f8:                              |[0000f8] ExampleKt.javaLike:(I)I
000108: 1220                         |0000: const/4 v0, #int 2 // #2
00010a: 7120 0200 0100               |0001: invoke-static {v1, v0}, Ljava/lang/Integer;.divideUnsigned:(II)I // method@0002
000110: 0a01                         |0004: move-result v1
000112: 0f01                         |0005: return v1
#1              : (in LExampleKt;)
  name          : 'kotlinLike-WZ4Q5Ns'
  type          : '(I)I'
  access        : 0x0019 (PUBLIC STATIC FINAL)
  code          -

000114:                              |[000114] ExampleKt.kotlinLike-WZ4Q5Ns:(I)I
000124: 8160                         |0000: int-to-long v0, v6
000126: 1802 ffff ffff 0000 0000     |0001: const-wide v2, #double 0.000000 // #00000000ffffffff
000130: c020                         |0006: and-long/2addr v0, v2
000132: 1226                         |0007: const/4 v6, #int 2 // #2
000134: 8164                         |0008: int-to-long v4, v6
000136: c042                         |0009: and-long/2addr v2, v4
000138: be20                         |000a: div-long/2addr v0, v2
00013a: 8406                         |000b: long-to-int v6, v0
00013c: 0f06                         |000c: return v6

Kotlinには独自の符号なし整数実装があり、関数本体に直接インライン化されています。このように実装され、パラメータとリテラルはlongに変換され、longは分割され、最後にintに変換されます。When we eventually run them through ART they’re just translated to equivalent x86 so we’re going to leave this function behind. (这句没太懂)ここでは最適化の機会を逃しています。

Javaバージョンの場合、R8は代わりにシフト操作を使用しませんでしたdivideUnsigned追跡を継続するために問題送信しました

最後の最適化の機会はARTです。


 

$ adb push classes.dex /sdcard/classes.dex
$ adb shell
generic_x86:/ $ sugenzong
generic_x86:/ # dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oat
generic_x86:/ # oatdump --oat-file=/sdcard/classes.oat
OatDexFile:
0: LExampleKt; (offset=0x000003c0) (type_idx=1) (Initialized) (OatClassAllCompiled)
  0: int ExampleKt.javaLike(int) (dex_method_idx=0)
    CODE: (code_offset=0x00001010 size_offset=0x0000100c size=63)...
      0x00001010:         85842400E0FFFF             test eax, [esp + -8192]
        StackMap[0] (native_pc=0x1017, dex_pc=0x0, register_mask=0x0, stack_mask=0b)
      0x00001017:                     55             push ebp
      0x00001018:                 83EC18             sub esp, 24
      0x0000101b:                 890424             mov [esp], eax
      0x0000101e:     6466833D0000000000             cmpw fs:[0x0], 0  ; state_and_flags
      0x00001027:           0F8519000000             jnz/ne +25 (0x00001046)
      0x0000102d:             E800000000             call +0 (0x00001032)
      0x00001032:                     5D             pop ebp
      0x00001033:             BA02000000             mov edx, 2
      0x00001038:           8B85CE0F0000             mov eax, [ebp + 4046]
      0x0000103e:                 FF5018             call [eax + 24]
        StackMap[1] (native_pc=0x1041, dex_pc=0x1, register_mask=0x0, stack_mask=0b)
      0x00001041:                 83C418             add esp, 24
      0x00001044:                     5D             pop ebp
      0x00001045:                     C3             ret
      0x00001046:         64FF15E0020000             call fs:[0x2e0]  ; pTestSuspend
        StackMap[2] (native_pc=0x104d, dex_pc=0x0, register_mask=0x0, stack_mask=0b)
      0x0000104d:                   EBDE             jmp -34 (0x0000102d)
  1: int ExampleKt.kotlinLike-WZ4Q5Ns(int) (dex_method_idx=1)
    CODE: (code_offset=0x00001060 size_offset=0x0000105c size=67)...
      ⋮

ARTにはインライン呼び出しdivideUnsignedはなく、代わりに通常のメソッド呼び出しを使用します。私は追跡のためにこの問題を提出しました

やっと

それは長い旅でした、あなたがそれをしたことを祝福します(またはちょうど記事の最後に目を向けました)。要約しましょう。

  1. ARTは、左シフト/右シフトを使用して、2の累乗の乗算/除算を書き換えます(負の数を処理する場合は、追加の命令が追加されます)。

  2. 右シフトと2の累乗による除算の間に大きなパフォーマンスギャップはありません。

  3. シフト、乗算、除算のDalvikバイトコードサイズは同じです。

  4. 署名されていない分割を最適化した人は誰もいませんが(少なくともまだ)、おそらくそれも使用していません。

これらの事実を踏まえて、記事の冒頭にある質問に答えることができます。

Androidでは、2で割るか、1で右にシフトするかを選択しますか?

どちらでもない!シフト演算はビット単位の演算が実際に必要な場合にのみ使用し、他の数学演算には乗算と除算を使用してください。AndroidXコレクションのビット単位の操作を乗算と除算に切り替え始めます。

記事は毎週継続的に更新されます。WeChatで「ProgrammingApeDevelopment Center」を検索して初めて読んで更新し(ブログより1〜2記事前)、「公式アカウントのインタビュー/詳細情報をクリックして」直接無料で入手できます一次・二次インターネット企業のAndroid開発ポストへのインタビュー質問のまとめ(回答分析)②Androidアーキテクチャの知識ポイントのまとめpdf +③超クリアなAndroidアドバンストマインドマップ。

 

おすすめ

転載: blog.csdn.net/qq_39477770/article/details/108773252