深刻解讀Android新特性——App Bundles

摘要: 深刻剖析Android官方新模塊化方案——App Bundles,技術要點全方位挖掘。java

App Bundles是2018 Google I/O新引入的一個概念。不一樣於以往傳統的App是以一個完整的Apk做爲單位,而App Bundles有下面幾個技術特色:android

  1. 一個App被分割成一個基礎APK和多個模塊APK。
  2. 基礎APK在安裝後,能夠按需請求或者更新模塊APK。
  3. 模塊APK能夠劃分爲更細的粒度,根據當前運行的設備特徵來請求特定粒度的APK。
  4. 請求與安裝過程是經過與Google Play商店交互來進行的。
  5. Android Studio中經過開發者引入dynamic-feature模塊,來實現模塊APK的開發。
  6. 模塊APK的方式只在Android 5.0(API 21)及以上機型使用,在Android 4.4及如下機型Play商店仍會下發完整的APK。

一圖勝千言,shell

apk_splits_tree_2x

App Bundles能夠在運行時選取設備所需的APK組合運行,與傳統方式對好比下:api

compare_old

編譯期技術

須要上傳到Play商店的是一個Android App Bundle(.aab)文件,經過Android Studio中的Build > Build Bundle(s)/APK(s) > Build Bundle(s) 能夠在project-name/module-name/build/outputs/bundle/目錄下生成。它有以下結構:安全

aab_format_2x

在我本身構造demo中,有base和feature0模塊,在base中沒法直接引用feature0模塊的類,反過來是能夠的。session

能夠看到這個demo生成的aab文件內容是這樣的:app

Archive:  app/build/outputs/bundle/debug/bundle.aab
  Length     Date   Time    Name
 --------    ----   ----    ----
    67086  05-17-18 13:21   META-INF/MANIFEST.MF
    67248  05-17-18 13:21   META-INF/ANDROIDD.SF
      773  05-17-18 13:21   META-INF/ANDROIDD.RSA
     4056  05-17-18 13:21   base/manifest/AndroidManifest.xml
      541  05-17-18 13:21   base/res/anim/abc_fade_in.xml
      541  05-17-18 13:21   base/res/anim/abc_fade_out.xml
                            ... ...
   448151  05-17-18 13:21   base/resources.pb
  3121604  05-17-18 13:21   base/dex/classes.dex
        7  05-17-18 13:21   base/root/META-INF/com.android.support_appcompat-v7.version
        7  05-17-18 13:21   base/root/META-INF/com.android.support_support-core-utils.version
                            ... ...
   182504  05-17-18 13:21   base/lib/arm64-v8a/libnative-lib.so
   104160  05-17-18 13:21   base/lib/armeabi-v7a/libnative-lib.so
   177924  05-17-18 13:21   base/lib/x86/libnative-lib.so
   174712  05-17-18 13:21   base/lib/x86_64/libnative-lib.so
       85  05-17-18 13:21   base/native.pb
     1719  05-17-18 13:21   feature0/manifest/AndroidManifest.xml
      375  05-17-18 13:21   feature0/res/layout/feature0_activity.xml
     1272  05-17-18 13:21   feature0/res/layout/feature0_fragment.xml
      573  05-17-18 13:21   feature0/resources.pb
     4192  05-17-18 13:21   feature0/dex/classes.dex
    27292  05-17-18 13:21   feature0/dex/classes2.dex
        6  05-17-18 13:21   feature0/root/META-INF/android.arch.lifecycle_extensions.version
        6  05-17-18 13:21   feature0/root/META-INF/android.arch.lifecycle_livedata.version
      273  05-17-18 13:21   BundleConfig.pb
 --------                   -------
  4977479                   528 files

Play商店會根據這個文件自動生成基線APK與其餘模塊APK。async

另外Google也提供了bundletool調試工具,咱們經過執行如下命令,能夠生成一個.apks文件:編輯器

bundletool build-apks --bundle=app/build/outputs/bundle/debug/bundle.aab --output=bundle.apks

.apks文件的內容是這樣的:ide

Length     Date   Time    Name
 --------    ----   ----    ----
    56679  05-11-18 10:32   base-xhdpi.apk
    47738  05-11-18 10:32   base-ldpi.apk
    51733  05-11-18 10:32   base-hdpi.apk
    47350  05-11-18 10:32   base-mdpi.apk
    67465  05-11-18 10:32   base-xxhdpi.apk
    71691  05-11-18 10:32   base-xxxhdpi.apk
    85949  05-11-18 10:32   base-tvdpi.apk
    58942  05-11-18 10:32   base-armeabi_v7a.apk
    75591  05-11-18 10:32   base-arm64_v8a.apk
    79456  05-11-18 10:32   base-x86.apk
    76614  05-11-18 10:32   base-x86_64.apk
  1278608  05-11-18 10:32   base-master.apk
     6456  05-11-18 10:32   feature0-ldpi.apk
     6455  05-11-18 10:32   feature0-mdpi.apk
     6453  05-11-18 10:32   feature0-hdpi.apk
     6455  05-11-18 10:32   feature0-xhdpi.apk
     6458  05-11-18 10:32   feature0-xxhdpi.apk
     6459  05-11-18 10:32   feature0-xxxhdpi.apk
     6459  05-11-18 10:32   feature0-tvdpi.apk
    23840  05-11-18 10:32   feature0-master.apk
  1399736  05-11-18 10:32   standalone-arm64_v8a_mdpi.apk
  1404122  05-11-18 10:32   standalone-arm64_v8a_hdpi.apk
  1400150  05-11-18 10:32   standalone-arm64_v8a_ldpi.apk
  1409067  05-11-18 10:32   standalone-arm64_v8a_xhdpi.apk
  1419850  05-11-18 10:32   standalone-arm64_v8a_xxhdpi.apk
  1383502  05-11-18 10:32   standalone-armeabi_v7a_ldpi.apk
  1424094  05-11-18 10:32   standalone-arm64_v8a_xxxhdpi.apk
  1438398  05-11-18 10:32   standalone-arm64_v8a_tvdpi.apk
  1383096  05-11-18 10:32   standalone-armeabi_v7a_mdpi.apk
  1387471  05-11-18 10:32   standalone-armeabi_v7a_hdpi.apk
  1392416  05-11-18 10:32   standalone-armeabi_v7a_xhdpi.apk
  1403202  05-11-18 10:32   standalone-armeabi_v7a_xxhdpi.apk
  1407436  05-11-18 10:32   standalone-armeabi_v7a_xxxhdpi.apk
  1403612  05-11-18 10:32   standalone-x86_mdpi.apk
  1404026  05-11-18 10:32   standalone-x86_ldpi.apk
  1421745  05-11-18 10:32   standalone-armeabi_v7a_tvdpi.apk
  1407995  05-11-18 10:32   standalone-x86_hdpi.apk
  1412940  05-11-18 10:32   standalone-x86_xhdpi.apk
  1427968  05-11-18 10:32   standalone-x86_xxxhdpi.apk
  1423722  05-11-18 10:32   standalone-x86_xxhdpi.apk
  1401172  05-11-18 10:32   standalone-x86_64_ldpi.apk
  1442270  05-11-18 10:32   standalone-x86_tvdpi.apk
  1400762  05-11-18 10:32   standalone-x86_64_mdpi.apk
  1405145  05-11-18 10:32   standalone-x86_64_hdpi.apk
  1420877  05-11-18 10:32   standalone-x86_64_xxhdpi.apk
  1410090  05-11-18 10:32   standalone-x86_64_xhdpi.apk
  1425119  05-11-18 10:32   standalone-x86_64_xxxhdpi.apk
  1439423  05-11-18 10:32   standalone-x86_64_tvdpi.apk
     6367  05-11-18 10:32   toc.pb
 --------                   -------
 41572624                   49 files

能夠推測,這個就是上傳到商店以後最終生成的須要按需下發的APK產物。

運行期技術

由於衆所周知的緣由,國內的設備不會默認包含Google Play商店,所以App Bundles對於國內App是不適用的。因此咱們考慮,是否能夠不經由商店,在App自身開發一套相似的更新機制?

咱們要想自行實現這個功能,就得探清其原理,知其因此然。

在本地安裝以後,咱們發如今APK安裝路徑下存在兩個APK,

gemini:/ # ls -l  data/app/com.taobao.myappbundledemo-2/
total 4664
-rw-r--r-- 1 system system  2343434 2018-05-13 12:41 base.apk
drwxr-xr-x 3 system system     4096 2018-05-13 12:41 lib
drwxrwx--x 3 system install    4096 2018-05-13 12:41 oat
-rw-r--r-- 1 system system    25412 2018-05-13 12:41 split_feature0.apk

這裏確實有些違反常理,一般咱們一個App只有一個APK,而這裏咱們能夠看到,在一個App的安裝目錄下出現了多個APK。實際上,安裝目錄能夠安裝多Apk的特性是Android 5.0開始引入的,這也就解釋了爲什麼4.4如下機型只能安裝一個單獨的完整包。那麼,這種多個APK是以什麼方式安裝的?咱們是否能夠模仿這種安裝方式對App自身進行更新呢?

官方接口請求

咱們先看下官方SDK採用的方式。首先,若是想經過商店請求安裝新模塊,能夠經過下面的代碼進行:

// Creates an instance of SplitInstallManager.
SplitInstallManager splitInstallManager =
    SplitInstallManagerFactory.create(context);

// Creates a request to install a module.
SplitInstallRequest request =
    SplitInstallRequest
        .newBuilder()
        // You can download multiple on demand modules per
        // request by invoking the following method for each
        // module you want to install.
        .addModule("pictureMessages")
        .addModule("promotionalFilters")
        .build();

splitInstallManager
    // Submits the request to install the module through the
    // asynchronous startInstall() task. Your app needs to be
    // in the foreground to submit the request.
    .startInstall(request)
    // You should also be able to gracefully handle
    // request state changes and errors. To learn more, go to
    // the section about how to Monitor the request state.
    .addOnSuccessListener(sessionId -> { ... })
    .addOnFailureListener(errorCode -> { ... });

使用這個類須要引入依賴com.google.android.play:core:1.2.0

這裏的關鍵天然是startInstall,咱們就順藤摸瓜往下挖。

final class k {
    private static final b b = new b("SplitInstallService");
    private static final Intent c = new Intent("com.google.android.play.core.splitinstall.BIND_SPLIT_INSTALL_SERVICE").setPackage("com.android.vending");
    final com.google.android.play.core.a.b<a> a;
    private final Context d;
    private final String e;
    private final f f;
    
    private k(Context context, String str) {
        this.f = new j(this);
        this.d = context;
        this.e = str;
        this.a = new com.google.android.play.core.a.b(context.getApplicationContext(), b, "SplitInstallService", c, l.a, this.f);
    }

    public final Task<Integer> a(Collection<String> collection) {
        b.a("startInstall(%s)", collection);
        i iVar = new i();
        this.a.a(new m(this, iVar, collection, iVar));
        return iVar.a();
    }

... ...
public final class b<T extends IInterface> {
    private static final Map<String, Handler> a = Collections.synchronizedMap(new HashMap());
    private final Context b;
    private final com.google.android.play.core.splitcompat.b.b c;
    private final String d;
    private final List<a> e = new ArrayList();
    private boolean f;
    private final Intent g;
    private final g<T> h;
    private final WeakReference<f> i;
    private final DeathRecipient j = new c(this);
    private ServiceConnection k;
    private T l;

    public b(Context context, com.google.android.play.core.splitcompat.b.b bVar, String str, Intent intent, g<T> gVar, f fVar) {
        this.b = context;
        this.c = bVar;
        this.d = str;
        this.g = intent;
        this.h = gVar;
        this.i = new WeakReference(fVar);
    }

    private final void b(a aVar) {
        if (this.l == null && !this.f) {
            this.c.a("Initiate binding to the service.", new Object[0]);
            this.e.add(aVar);
            this.k = new h();
            this.f = true;
            if (!this.b.bindService(this.g, this.k, 1)) {
                this.c.a("Failed to bind to the service.", new Object[0]);
                this.f = false;
                for (a a : this.e) {
                    i a2 = a.a();
                    if (a2 != null) {
                        a2.a(new k());
                    }
                }
                this.e.clear();
            }
        } else if (this.f) {
            this.c.a("Waiting to bind to the service.", new Object[0]);
            this.e.add(aVar);
        } else {
            aVar.run();
        }
    }


... ...

能夠看到class k的a字段是一個class b,在這個class b的構造函數中,經過com.google.android.play.core.splitinstall.BIND_SPLIT_INSTALL_SERVICE綁定了一個Service,然而這個Service並不在App自身,而是屬於Play商店的一個Service.

咱們反編譯商店,發現確實存在com.google.android.play.core.splitinstall.BIND_SPLIT_INSTALL_SERVICE的filter。

<service android:name="com.google.android.finsky.splitinstallservice.SplitInstallService" android:enabled="true" android:exported="true" android:visibleToInstantApps="true">
    <meta-data android:name="instantapps.clients.allowed" android:value="true"/>
    <intent-filter>
        <action android:name="com.google.android.play.core.splitinstall.BIND_SPLIT_INSTALL_SERVICE"/>
    </intent-filter>
</service>

其對應的正是SplitInstallService,因爲這裏混淆比較嚴重,很難往下繼續跟了,不過咱們能夠知道的是,安裝APK的操做確實是經過請求Play商店的Service進行的。

使用bundletool安裝

接下來咱們轉換一下思路繼續探索。

使用bundletool調試工具能夠直接安裝apks,命令以下:

bundletool install-apks --apks=bundle.apks

經過跟進bundletool源碼,咱們最終找到了關鍵方法:

private String createMultiInstallSession(List<File> apkFiles, String pmOptions, long timeout, TimeUnit unit) throws TimeoutException, AdbCommandRejectedException, ShellCommandUnresponsiveException, IOException {
    long totalFileSize = 0;
    for (File apkFile : apkFiles) {
        totalFileSize += apkFile.length();
    }
    InstallCreateReceiver receiver = new InstallCreateReceiver();
    this.mDevice.executeShellCommand(String.format(this.mPrefix + " install-create %1$s -S %2$d", new Object[]{pmOptions, Long.valueOf(totalFileSize)}), receiver, timeout, unit);
    return receiver.getSessionId();
}


private boolean uploadApk(String sessionId, File fileToUpload, int uniqueId, long timeout, TimeUnit unit) {
    Throwable e;
    Throwable th;
    Log.d(sessionId, String.format("Uploading APK %1$s ", new Object[]{fileToUpload.getPath()}));
    if (!fileToUpload.exists()) {
        Log.e(sessionId, String.format("File not found: %1$s", new Object[]{fileToUpload.getPath()}));
        return false;
    } else if (fileToUpload.isDirectory()) {
        Log.e(sessionId, String.format("Directory upload not supported: %1$s", new Object[]{fileToUpload.getAbsolutePath()}));
        return false;
    } else {
        String baseName;
        if (fileToUpload.getName().lastIndexOf(46) != -1) {
            baseName = fileToUpload.getName().substring(0, fileToUpload.getName().lastIndexOf(46));
        } else {
            baseName = fileToUpload.getName();
        }
        baseName = UNSAFE_PM_INSTALL_SESSION_SPLIT_NAME_CHARS.replaceFrom(baseName, '_');
        Log.d(sessionId, String.format("Executing : %1$s", new Object[]{String.format(this.mPrefix + " install-write -S %d %s %d_%s -", new Object[]{Long.valueOf(fileToUpload.length()), sessionId, Integer.valueOf(uniqueId), baseName})}));
... ...        
    }
}


public void install(long timeout, TimeUnit unit) throws InstallException {
    try {
        String sessionId = createMultiInstallSession(this.mApks, this.mOptions, timeout, unit);
        if (sessionId == null) {
            Log.d(LOG_TAG, "Failed to establish session, quit installation");
            throw new InstallException("Failed to establish session");
        }
        int index = 0;
        boolean allUploadSucceeded = true;
        while (allUploadSucceeded) {
            if (index >= this.mApks.size()) {
                break;
            }
            int index2 = index + 1;
            allUploadSucceeded = uploadApk(sessionId, (File) this.mApks.get(index), index, timeout, unit);
            index = index2;
        }
        String command = this.mPrefix + " install-" + (allUploadSucceeded ? "commit " : "abandon ") + sessionId;
        InstallReceiver receiver = new InstallReceiver();
        this.mDevice.executeShellCommand(command, receiver, timeout, unit);
        if (receiver.getErrorMessage() != null) {
            String message = String.format("Failed to finalize session : %1$s", new Object[]{receiver.getErrorMessage()});
            Log.e(LOG_TAG, message);
            throw new InstallException(message);
        } else if (!allUploadSucceeded) {
            throw new InstallException("Failed to install all ");
        }
    } catch (InstallException e) {
        throw e;
    } catch (Throwable e2) {
        throw new InstallException(e2);
    }
}

可見其真正使用的命令是如下三條:

adb shell pm install-create ...
adb shell pm install-write ...
adb shell pm install-commit ...

咱們繼續看下Pm.java,具體看下這三個命令是如何實現安裝的。

frameworks-base-p-preview-1/cmds/pm/src/com/android/commands/pm/Pm.java
public int run(String[] args) throws RemoteException {
... ...
    mPm = IPackageManager.Stub.asInterface(ServiceManager.getService("package"));

    mInstaller = mPm.getPackageInstaller();
... ...
    
    if ("install-create".equals(op)) {
        return runInstallCreate();
    }

    if ("install-write".equals(op)) {
        return runInstallWrite();
    }

    if ("install-commit".equals(op)) {
        return runInstallCommit();
    }
... ...
}

private int runInstallCreate() throws RemoteException {
    final InstallParams installParams = makeInstallParams();
    final int sessionId = doCreateSession(installParams.sessionParams,
            installParams.installerPackageName, installParams.userId);

    // NOTE: adb depends on parsing this string
    System.out.println("Success: created install session [" + sessionId + "]");
    return PackageInstaller.STATUS_SUCCESS;
}

private int doCreateSession(SessionParams params, String installerPackageName, int userId)
        throws RemoteException {
    userId = translateUserId(userId, "runInstallCreate");
    if (userId == UserHandle.USER_ALL) {
        userId = UserHandle.USER_SYSTEM;
        params.installFlags |= PackageManager.INSTALL_ALL_USERS;
    }

    final int sessionId = mInstaller.createSession(params, installerPackageName, userId);
    return sessionId;
}
private int runInstallWrite() throws RemoteException {
    long sizeBytes = -1;

    String opt;
    while ((opt = nextOption()) != null) {
        if (opt.equals("-S")) {
            sizeBytes = Long.parseLong(nextArg());
        } else {
            throw new IllegalArgumentException("Unknown option: " + opt);
        }
    }

    final int sessionId = Integer.parseInt(nextArg());
    final String splitName = nextArg();
    final String path = nextArg();
    return doWriteSession(sessionId, path, sizeBytes, splitName, true /*logSuccess*/);
}

private int doWriteSession(int sessionId, String inPath, long sizeBytes, String splitName,
        boolean logSuccess) throws RemoteException {
    if (STDIN_PATH.equals(inPath)) {
        inPath = null;
    } else if (inPath != null) {
        final File file = new File(inPath);
        if (file.isFile()) {
            sizeBytes = file.length();
        }
    }

    final SessionInfo info = mInstaller.getSessionInfo(sessionId);

    PackageInstaller.Session session = null;
    InputStream in = null;
    OutputStream out = null;
    try {
        session = new PackageInstaller.Session(
                mInstaller.openSession(sessionId));

        if (inPath != null) {
            in = new FileInputStream(inPath);
        } else {
            in = new SizedInputStream(System.in, sizeBytes);
        }
        out = session.openWrite(splitName, 0, sizeBytes);

        int total = 0;
        byte[] buffer = new byte[65536];
        int c;
        while ((c = in.read(buffer)) != -1) {
            total += c;
            out.write(buffer, 0, c);

            if (info.sizeBytes > 0) {
                final float fraction = ((float) c / (float) info.sizeBytes);
                session.addProgress(fraction);
            }
        }
        session.fsync(out);

        if (logSuccess) {
            System.out.println("Success: streamed " + total + " bytes");
        }
        return PackageInstaller.STATUS_SUCCESS;
    } catch (IOException e) {
        System.err.println("Error: failed to write; " + e.getMessage());
        return PackageInstaller.STATUS_FAILURE;
    } finally {
        IoUtils.closeQuietly(out);
        IoUtils.closeQuietly(in);
        IoUtils.closeQuietly(session);
    }
}

由此可知,pm是經過PackageInstaller類來實現安裝的。

自行模擬安裝

知道了這個流程後,咱們就能夠本身實現這種安裝方法了:

PackageInstaller.SessionParams sessionParams = new PackageInstaller.SessionParams(
        PackageInstaller.SessionParams.MODE_INHERIT_EXISTING);

PackageInstaller installer = context.getPackageManager().getPackageInstaller();
int sessionId = installer.createSession(sessionParams);
PackageInstaller.Session session = installer.openSession(sessionId);

File apkFile = new File(getFilesDir(), "feature0-debug.apk");
in = new FileInputStream(apkFile.getPath());
out = session.openWrite("anything", 0, apkFile.length());
int total = 0;
byte[] buffer = new byte[65536];
int c;
while ((c = in.read(buffer)) != -1) {
    total += c;
    out.write(buffer, 0, c);
}
session.fsync(out);

IntentSender intentSender = createIntentSender(context, sessionId);
session.commit(intentSender);

session.close();

這裏的重點是,須要在建立SessionParams的時候指定模式爲MODE_INHERIT_EXISTING,才能覆蓋安裝模塊APK。模塊APK的名稱爲split_<module name>.apk。這裏的<module name>是模塊APK的AndroidManifest中指定的,<manifest ... ... package="com.taobao.myappbundledemo" split="feature0">,所以,系統會自動識別安裝位置並肯定安裝後的文件名稱,若是manifest中不帶split,其名稱就是base.apk

調用完上述代碼,並非很順利地直接裝上了,而是彈出了幾個界面:

容許未知安裝來源提示:

17be53b0023707d88d3a8fe44085df5410943aa0

容許該App進行安裝:

27db74d7fbc25d436bb99d985415c431fd36beaf

確認安裝:

6c03732ace689d762b987b80cde0d9ec758fc74e

容許未知來源只須要一次,確認安裝窗口在每次進行安裝操做的時候都會彈出。能夠看到,安裝操做執行完畢後,split_feature0.apk確實發生了更新,而不是以前舊的,由於能夠比較出與base.apk的安裝的時間不一樣:

angler:/ # ls -l data/app/com.taobao.myappbundledemo-6XP2HdNBRWLquWr3U15iJA\=\=/
total 2376
-rw-r--r-- 1 system system  2392626 2018-05-16 12:04 base.apk
drwxr-xr-x 3 system system     4096 2018-05-16 12:18 lib
drwxrwx--x 3 system install    4096 2018-05-16 12:18 oat
-rw-r--r-- 1 system system    25412 2018-05-16 12:18 split_feature0.apk

安裝後不殺死進程

然而還有個問題,在安裝執行後,App會立馬被殺死。而根據官方文檔,在Android 7.0及以上版本的設備是能夠直接請求安裝模塊後當即進行使用,若是直接殺死,那確定體驗很糟糕。所以應該能夠找到辦法,在安裝後不殺當前進程。

然而從PackageInstaller.SessionParams 接口中咱們並無發現相關設置,是否有隱藏的API能夠作到呢?咱們經過反編譯Play商店來尋找答案。

private final synchronized void a(String str, long j, String str2, Bitmap bitmap, int i, int i2, fk fkVar, int i3) {
        if (this.c.containsKey(str)) {
            FinskyLog.e("Creating session for %s when one already exists", str);
        } else {
            SessionParams sessionParams = new SessionParams(i3);
            if (i3 == 2 && android.support.v4.os.a.a()) {
                sessionParams.setDontKillApp(true);
            }
            if (bitmap != null) {
                sessionParams.setAppIcon(bitmap);
            }
            if (!TextUtils.isEmpty(str2)) {
                sessionParams.setAppLabel(str2);
            }
            sessionParams.setAppPackageName(str);
            sessionParams.setInstallLocation(i);
            if (j > 0) {
                sessionParams.setSize(j);
            }
            if (android.support.v4.os.a.b()) {
                sessionParams.setInstallReason(i2);
            }
... ...

sessionParams.setDontKillApp彷佛正是咱們須要的,它也的確不在公開API中,因此編輯器裏面沒法直接引用,咱們經過反射打開它:

sessionParams.getClass().getDeclaredMethod("setDontKillApp", boolean.class).invoke(sessionParams, true);

這樣果真實現了安裝後不殺進程。

還有一個疑問,雖然不殺死進程,但新安裝的APK是否能夠直接被如今的進程當即使用到呢?經過查看進程空間,咱們發現,

7265874000-7265876000 r--p 00000000 fd:00 1439142                        /data/app/com.taobao.myappbundledemo-I1BztqjLDnwA1LZ7mxP6kg==/oat/arm64/split_feature0.odex
7265876000-7265879000 rw-p 00000000 00:00 0                              [anon:.bss]
7265879000-726587a000 r--p 00002000 fd:00 1439142                        /data/app/com.taobao.myappbundledemo-I1BztqjLDnwA1LZ7mxP6kg==/oat/arm64/split_feature0.odex
726587a000-726587b000 rw-p 00003000 fd:00 1439142                        /data/app/com.taobao.myappbundledemo-I1BztqjLDnwA1LZ7mxP6kg==/oat/arm64/split_feature0.odex
... ...
7265ce1000-7265d0b000 r-xp 00000000 fd:00 1439025                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/lib/arm64/libnative-lib.so (deleted)
7265d0b000-7265d1b000 ---p 00000000 00:00 0
7265d1b000-7265d1d000 r--p 0002a000 fd:00 1439025                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/lib/arm64/libnative-lib.so (deleted)
7265d1d000-7265d1e000 rw-p 0002c000 fd:00 1439025                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/lib/arm64/libnative-lib.so (deleted)
7265d1e000-7265d30000 rw-p 00000000 00:00 0                              [anon:.bss]
7265d53000-7265d54000 ---p 00000000 00:00 0                              [anon:thread stack guard page]
7265d54000-7265d55000 ---p 00000000 00:00 0
7265d55000-7265e50000 rw-p 00000000 00:00 0                              [stack:20896]
7265e50000-7265e5e000 r--p 00000000 fd:00 1439058                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/oat/arm64/base.odex (deleted)
7265e5e000-7265e5f000 r-xp 0000e000 fd:00 1439058                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/oat/arm64/base.odex (deleted)
7265e5f000-7265e6d000 rw-p 00000000 00:00 0                              [anon:.bss]
7265e6d000-7265e6e000 r--p 0000f000 fd:00 1439058                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/oat/arm64/base.odex (deleted)
7265e6e000-7265e6f000 rw-p 00010000 fd:00 1439058                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/oat/arm64/base.odex (deleted)
7265eb1000-72661b4000 r--s 00000000 fd:00 1439062                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/oat/arm64/base.vdex (deleted)
... ...
72ff84f000-72ff850000 r--s 00004000 fd:00 1439097                        /data/app/com.taobao.myappbundledemo-I1BztqjLDnwA1LZ7mxP6kg==/split_feature0.apk

從中能夠看出,安裝後,split_feature0.apk確實被加載進了進程的內存空間。而且安裝路徑發生了變化——data/app/com.taobao.myappbundledemo-後面跟字母由4tyKF-aM9SLiSM28Z4YkkQ變爲I1BztqjLDnwA1LZ7mxP6kg而且base.odex文件顯示爲deleted

上述是feature0還未被請求的狀況,如今咱們來看下,若是本地已經安裝過了feature0模塊,再次進行覆蓋安裝的話,會是怎麼的狀況:

7265874000-7265876000 r--p 00000000 fd:00 1439142                        /data/app/com.taobao.myappbundledemo-I1BztqjLDnwA1LZ7mxP6kg==/oat/arm64/split_feature0.odex (deleted)
7265876000-7265879000 rw-p 00000000 00:00 0                              [anon:.bss]
7265879000-726587a000 r--p 00002000 fd:00 1439142                        /data/app/com.taobao.myappbundledemo-I1BztqjLDnwA1LZ7mxP6kg==/oat/arm64/split_feature0.odex (deleted)
726587a000-726587b000 rw-p 00003000 fd:00 1439142                        /data/app/com.taobao.myappbundledemo-I1BztqjLDnwA1LZ7mxP6kg==/oat/arm64/split_feature0.odex (deleted)
... ...
7265ce1000-7265d0b000 r-xp 00000000 fd:00 1439025                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/lib/arm64/libnative-lib.so (deleted)
7265d0b000-7265d1b000 ---p 00000000 00:00 0
7265d1b000-7265d1d000 r--p 0002a000 fd:00 1439025                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/lib/arm64/libnative-lib.so (deleted)
7265d1d000-7265d1e000 rw-p 0002c000 fd:00 1439025                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/lib/arm64/libnative-lib.so (deleted)
7265d1e000-7265d30000 rw-p 00000000 00:00 0                              [anon:.bss]
7265d53000-7265d54000 ---p 00000000 00:00 0                              [anon:thread stack guard page]
7265d54000-7265d55000 ---p 00000000 00:00 0
7265d55000-7265e50000 rw-p 00000000 00:00 0                              [stack:20896]
7265e50000-7265e5e000 r--p 00000000 fd:00 1439058                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/oat/arm64/base.odex (deleted)
7265e5e000-7265e5f000 r-xp 0000e000 fd:00 1439058                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/oat/arm64/base.odex (deleted)
7265e5f000-7265e6d000 rw-p 00000000 00:00 0                              [anon:.bss]
7265e6d000-7265e6e000 r--p 0000f000 fd:00 1439058                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/oat/arm64/base.odex (deleted)
7265e6e000-7265e6f000 rw-p 00010000 fd:00 1439058                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/oat/arm64/base.odex (deleted)
7265eb1000-72661b4000 r--s 00000000 fd:00 1439062                        /data/app/com.taobao.myappbundledemo-4tyKF-aM9SLiSM28Z4YkkQ==/oat/arm64/base.vdex (deleted)
...
72ff5a4000-72ff5a5000 r--s 00006000 fd:00 1439145                        /data/app/com.taobao.myappbundledemo-6XP2HdNBRWLquWr3U15iJA==/split_feature0.apk

能夠發現,因爲安裝後,安裝路徑發生變化,data/app/com.taobao.myappbundledemo-I1BztqjLDnwA1LZ7mxP6kg==/oat/arm64/split_feature0.odex已經顯示爲deleted,同時,未發現有新安裝路徑下的split_feature0.apk被加載進內存。由此能夠推斷:若是模塊已經被安裝,當他進行覆蓋安裝更新時,必須冷啓動App才能生效。

使用SpltCompat支持低版本

前面說過,Android 7.0及以上版本的設備能夠在請求到新模塊後直接使用。而對於6.0如下版本的機型,是沒法直接使用下載的新模塊的。不過Google也提供了一種兼容方式,使得低版本機型能夠即時使用新模塊,那就是採用SplitCompat

能夠用如下兩種方式來接入SplitCompat:
1、直接繼承SplitCompatApplication

public class MyApplication extends SplitCompatApplication {
    ...
}

2、在attachBaseContext中調用 SplitCompat.install

protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    // Emulates installation of future on demand modules using SplitCompat.
    SplitCompat.install(this);
}

SplitCompatApplication 最後也是走的SplitCompat.install(this),這跟MultiDex十分相似,咱們來看下SplitCompat.install(this)的實現。

public class SplitCompat {
    private static final AtomicReference<SplitCompat> a = new AtomicReference(null);
    private final Context b;
    private final c c;
    private final Set<String> d = new HashSet();

    public static boolean install(Context context) {
        return a(context, false);
    }

    private static boolean a(Context context, boolean z) {
        if (VERSION.SDK_INT >= 24) {
            return true;
        }
        if (VERSION.SDK_INT <= 19) {
            return false;
        }
        SplitCompat splitCompat = (SplitCompat) a.get();
        if (a.compareAndSet(null, new SplitCompat(context))) {
            com.google.android.play.core.splitinstall.a.a(new com.google.android.play.core.splitcompat.a.a(context, a.a(), new c(context, splitCompat.c, new com.google.android.play.core.splitcompat.c.b()), splitCompat.c));
            d.a(new l(splitCompat));
        }
        try {
            splitCompat.a(z);
            return true;
        } catch (Throwable e) {
            Log.e("SplitCompat", "Error installing additional splits", e);
            return false;
        }
    }

    private final synchronized void a(boolean z) {
        Iterator it;
        if (z) {
            this.c.a();
        } else {
            a.a().execute(new m(this));
        }
        Set<o> b = b(z);
        d dVar = new d(this.c);
        a a = b.a();
        ClassLoader classLoader = this.b.getClassLoader();
        if (z) {
            a.a(classLoader, dVar.a());
        } else {
            it = b.iterator();
            while (it.hasNext()) {
                Set a2 = dVar.a((o) it.next());
                if (a2 == null) {
                    it.remove();
                } else {
                    a.a(classLoader, a2);
                }
            }
        }
        Set<File> hashSet = new HashSet();
        for (o oVar : b) {
            if (a.a(classLoader, this.c.c(oVar.a()), oVar.b(), z)) {
                hashSet.add(oVar.b());
            }
        }
        AssetManager assets = this.b.getAssets();
        for (File path : hashSet) {
            int intValue = ((Integer) com.google.android.play.core.splitcompat.c.b.a(assets, "addAssetPath", Integer.class, String.class, path.getPath())).intValue();
            StringBuilder stringBuilder = new StringBuilder(39);
            stringBuilder.append("addAssetPath completed with ");
            stringBuilder.append(intValue);
            Log.d("SplitCompat", stringBuilder.toString());
        }
        for (o oVar2 : b) {
            String a3 = oVar2.a();
            StringBuilder stringBuilder2 = new StringBuilder(String.valueOf(a3).length() + 30);
            stringBuilder2.append("Split '");
            stringBuilder2.append(a3);
            stringBuilder2.append("' installation emulated");
            Log.d("SplitCompat", stringBuilder2.toString());
            this.d.add(oVar2.a());
        }
    }

addAssetPath這裏能夠明顯看到是在新增資源了,另外須要注意的是,模塊APK中的資源包id並不是傳統的0x7f,而是往下遞減的0x7e、0x7d、...

加載代碼和so的地方是在:

public static a a() {
    if (VERSION.SDK_INT == 21) {
        return new c();
    }
    if (VERSION.SDK_INT == 22) {
        return new f();
    }
    if (VERSION.SDK_INT == 23) {
        return new g();
    }
    throw new AssertionError();
}


final class c implements a {
    c() {
    }

    static Object a(ClassLoader classLoader) {
        return b.b(classLoader, "pathList", Object.class).a();
    }

    static boolean a(ClassLoader classLoader, File file, File file2, boolean z, e eVar) {
        Collection arrayList = new ArrayList();
        Object a = a(classLoader);
        a c = b.c(a, "dexElements", Object.class);
        List<Object> asList = Arrays.asList((Object[]) c.a());
        List arrayList2 = new ArrayList();
        for (Object b : asList) {
            arrayList2.add((File) b.b(b, "zip", File.class).a());
        }
        if (arrayList2.contains(file2)) {
            return true;
        }
        int i = 0;
        if (!z) {
            if (!new File((String) b.a(a.getClass(), "optimizedPathFor", String.class, File.class, file2, File.class, file)).exists()) {
                return false;
            }
        }
        c.a(eVar.a(a, new ArrayList(Collections.singleton(file2)), file, arrayList));
        if (arrayList.isEmpty()) {
            return true;
        }
        Throwable kVar = new k("DexPathList.makeDexElement failed");
        ArrayList arrayList3 = (ArrayList) arrayList;
        int size = arrayList3.size();
        while (i < size) {
            Object obj = arrayList3.get(i);
            i++;
            IOException iOException = (IOException) obj;
            Log.e("SplitCompat", "DexPathList.makeDexElement failed", iOException);
            com.google.a.a.a.a.a.a.a(kVar, iOException);
        }
        b.c(a, "dexElementsSuppressedExceptions", IOException.class).a(arrayList);
        throw kVar;
    }

    static void b(ClassLoader classLoader, Set<File> set) {
        if (!set.isEmpty()) {
            Collection hashSet = new HashSet();
            for (File file : set) {
                String str = "Splitcompat";
                String str2 = "Adding native library parent directory: ";
                String valueOf = String.valueOf(file.getParentFile().getAbsolutePath());
                Log.d(str, valueOf.length() != 0 ? str2.concat(valueOf) : new String(str2));
                hashSet.add(file.getParentFile());
            }
            a c = b.c(a(classLoader), "nativeLibraryDirectories", File.class);
            hashSet.removeAll(Arrays.asList((File[]) c.a()));
            int size = hashSet.size();
            StringBuilder stringBuilder = new StringBuilder(30);
            stringBuilder.append("Adding directories ");
            stringBuilder.append(size);
            Log.d("Splitcompat", stringBuilder.toString());
            c.a(hashSet);
        }
    }

    public final void a(ClassLoader classLoader, Set<File> set) {
        b(classLoader, set);
    }

    public final boolean a(ClassLoader classLoader, File file, File file2, boolean z) {
        return a(classLoader, file, file2, z, new d());
    }
}

這裏只列出了SDK = 21狀況下的c類,SDK = 22SDK = 23的狀況其實也大同小異。熟悉Instant Run或者熱修復的同窗都能一眼看出端倪了,這不就是新增DEX和so庫嗎?確實,SplitCompat的本質,就是相似冷啓動的熱修復的方式,插入新模塊包。

模塊間依賴

經過分析運行時各個模塊類中ClassLoader的狀況,咱們能夠大體看出模塊間的依賴關係。

咱們發現,全部模塊中的類其實都是用的同一個ClassLoader:

dalvik.system.PathClassLoader
[DexPathList[
    [zip file "/data/app/com.taobao.myappbundledemo-y4Ih9MutYd4z43hpON6LVg==/base.apk",
     zip file "/data/app/com.taobao.myappbundledemo-y4Ih9MutYd4z43hpON6LVg==/split_feature0.apk",
     zip file "/data/app/com.taobao.myappbundledemo-y4Ih9MutYd4z43hpON6LVg==/split_feature1.apk"],
    nativeLibraryDirectories=[
        /data/app/com.taobao.myappbundledemo-y4Ih9MutYd4z43hpON6LVg==/lib/arm64, 
        /data/app/com.taobao.myappbundledemo-y4Ih9MutYd4z43hpON6LVg==/base.apk!/lib/arm64-v8a, 
        /data/app/com.taobao.myappbundledemo-y4Ih9MutYd4z43hpON6LVg==/split_feature0.apk!/lib/arm64-v8a, 
        /data/app/com.taobao.myappbundledemo-y4Ih9MutYd4z43hpON6LVg==/split_feature1.apk!/lib/arm64-v8a, 
        /system/lib64, /vendor/lib64]
]]

而這個ClassLoader裏面包含了三個模塊全部的APK路徑與so路徑,可見模塊之間的類和so庫的訪問是徹底相通的。也就是說一個模塊中聲明的類,可以直接在另外一個模塊中毫無差異的使用,就像使用本身模塊中的類同樣。而若是兩個模塊引入了相同的依賴,兩個依賴會被分別打進兩個模塊的APK中,這就可能引起問題。因此,若是兩個不一樣的模塊依賴了相同的庫,須要先改成provided(gradle 3.0.0以上稱爲compileOnly)依賴,而在base模塊中引入compile(gradle 3.0.0以上稱爲api)依賴。

優點與侷限

App Bundles提供了一整套動態模塊化App的機制,依託Google官方的插件支持,開發者能夠直接進行模塊化開發,而再也不須要本身造輪子,也能夠避免Android官方插件不斷升級帶來的兼容性問題。Google Play商店自然承載了更新APK的使命,用戶能夠直接在商店上發佈新模塊APK,來實現靜默升級,因爲是直接安裝,於是不存在任何兼容性問題。按需獲取對應特徵APK,可以極大減少本地安裝的包大小。

然而它的侷限也是比較多的,

  • 首先,Goolge Play商店沒法在國內正常使用。
  • 其次,模塊更新只從Android 5.0開始支持,對於大量留存的老設備,都沒法享受這一福利。
  • manifest中新註冊的組件與系統顯示的資源都沒法即時使用。官方文檔說明
  • 對於已有模塊的更新,必須進行冷啓動。

固然,隨着時間的推移,App Bundles這一套模式對於海外應用仍具備很強的吸引力。

還須要注意的是,App Bundles沒有實現多模塊之間包依賴的自動管理。也就是說各個模塊的依賴是獨立的,所以各個模塊對於使用的相同的依賴都會各自引入,從而致使冗餘。而且,若多個模塊使用相同依賴包的不一樣版本,可能還會有兼容性問題。所以須要在各個模塊中進行provided依賴,而把公共依賴放入base模塊中。

引伸

回到咱們以前拋出的問題,咱們是否能夠直接利用Google爲咱們提供好的構建機制,模仿Play商店構造一個SDK對App自身進行更新呢?

經過前面的技術分析,咱們有兩種方案能夠實現自行更新:

  1. 直接利用PackageInstaller進行安裝,這種方式最接近App Bundles的在Google Play中的實際做用效果,可是,最大問題就是會對用戶形成極大的干擾,首先,不少用戶出於安全考慮,是不會輕易容許未知安裝來源的。另外,每次安裝都會彈出界面,也會打斷用戶正常的操做流程,使用戶體驗再也不流暢。而且若是須要經過國內各個廠商應用商店來更新,各大商店可能沒有這麼快支持APK分包功能。
  2. 模仿SplitCompat的方式,在全部機型版本上,本地下載後直接加載安裝包。這種方式和熱修復插件化這類技術比較相似,問題是也須要不斷對新機型進行維護,而且將來隨着Google對私有API的更進一步收緊,反射技術可能會受到更大限制,將來對機型兼容性的支持是比較大的挑戰。而且沒法像直接安裝那樣可以享受到JIT優化帶來的性能改進。

App Bundles的模塊化,主要基於APK直接安裝的方式,不須要用戶任何對系統組件的修改,就能自然得到原生優化能力(如JIT、DEX優化等),於是不存在任何兼容性問題。這個思路與傳統組件化/插件化方式大相徑庭,由於Google做爲Android官方主體,把控了Play商店這個惟一安裝渠道(國內除外),因此能夠採用這種玩法來作到史無前例的穩定性和動態能力。App Bundles也存在一個問題,就是沒法在運行過程當中直接新增四大組件,須要覆蓋安裝base模塊以後再重啓。然後面兩個方案因爲都是基於App Bundles的,於是有更大侷限性。

目前看來,期望Google迴歸恐怕遙遙無期,不過這或許能吸引國內各個廠商自帶應用市場的跟進,甚至可能像統一推送那樣協商出一套符合國情的標準,從而改善國內的App更新環境。不過,App Bunndles的一些思想,如細緻化配置,確實值得現有的模塊化方案進行借鑑和吸取。

參考資料

Android App Bundles

Build the new, modular Android App Bundle (Google I/O '18)

相關文章
相關標籤/搜索