萬字詳解 Google Play 上架應用標準包格式 AAB

語言: CN / TW / HK

應用出海繞不開 Google Play 這個核心渠道。關注【融雲全球網際網路通訊雲】瞭解更多

根據資料平臺 Statcounter 的資料,截至 2022 年 6 月,Android 在全球移動裝置作業系統的市場份額佔比高達 72.12%。而 Google Play 是絕對的 Android 流量巨頭,也是 Android 應用上架的第一選擇。

自 2021 年 8 月起,Google 要求在 Google Play 中釋出的應用使用 Android App Bundle(AAB)格式。

AAB 可以提供更小的 App 體積,提升使用者的下載轉化率並減少解除安裝量,其要求應用程式的大小不超過 150MB。

對於需要超過 150MB 的應用程式,App Bundles 引入了 Play Asset Delivery(PAD)功能,通過資源動態下發,實現更順暢的釋出且此後的更新會變快,因為更新包不會包含所有的內容。

本文以一個出海 App 為案例,分享其採用 Android App Bundle 格式及 Play Asset Delivery 方案“瘦身”上架的實踐過程。

Android App Bundle

AAB 的優勢

使用 AAB 進行釋出,將由 Google Play 負責 APK 的生成和簽名。並且包體積大小從 APK 的 100M 限制,變為了 AAB 的 150M 限制。

使用 App Bundle 會將 APK 的生成和簽名工作轉到 Google Play 上完成,使用者在下載時,Google Play 會根據使用者使用的裝置生成經過優化的 APK,其中僅包含裝置在執行時所需的程式碼和資源,可獲得更小、更有針對性的下載包。 微信圖片_20220726143215.png (使用 App Bundle 可獲得更小下載包)

AAB 包的構成

Android App Bundle 是一種新的釋出格式,其中包含了所有經過編譯的程式碼和資源。在 AAB 包中有三種類型的模組:

  1. 基本 APK(Base.apk):提供基本的功能,當用戶下載應用時,首先會下載並安裝該 APK。
  2. 配置 APK:針對不同的螢幕密度、CPU 架構或者語言會生成對應的 APK 檔案,當用戶下載應用時,只會下載並安裝對應配置的 APK。
  3. 功能模組 APK:我們可以將非基礎並相對獨立的功能打包成 APK,當用戶需要使用的時候再進行安裝。

微信圖片_20220726143221.png (AAB 包主要模組)

正如上圖所示:

base/ :此目錄包含了應用基本模組程式碼。

feature1/和feature2/ :此目錄包含了需要動態安裝的程式碼。

asset_pack_1/和 asset_pack_2/ :此目錄包含了需要動態載入的資源。

BUNDLE-METADATA/ :此目錄下包含了元資料檔案。

BundleConfig.pb:提供了有關 Bundle 本身的資訊,如用於構建 App Bundle 工具的版本。

manifest/ :每個模組的 AndroidManifest.xml 檔案單獨儲存在這個目錄下。

dex/ :存放每個模組的所有 dex 檔案。

如何使用 AAB ?

要生成一個 AAB 包非常的簡單,我們只需要在打包的時候從選擇 APK 改為選擇 Andriod App Bundle,剩下的流程跟生成 APK 檔案完全一致。 微信圖片_20220726143224.png

這裡要注意: 1. 使用 AAB 格式後,不再支援 APK 擴充套件檔案(*.obb)。 2. 使用 AAB 格式進行釋出,必須開啟 Google Play 的應用簽名功能。

Google Play 會使用此金鑰對經過優化的 APK 簽名。這裡推薦勾選 Export encrypted key for enrolling published apps in Google Play App Signing 將金鑰匯出並且上傳到 Google Play,如下圖所示。

圖片

AAB 資源配置

預設情況下,在構建 App Bundle 時,支援為每一組語言資源、螢幕密度資源和 ABI 庫生成配置 APK。如果你想停用對某種配置 APK 型別的支援,可以在基礎模組的 build.gradle 檔案中進行配置。

``` android { // Instead, use the bundle block to control which types of configuration APKs     // you want your app bundle to support.     bundle {         language { // Specifies that the app bundle should not support             // configuration APKs for language resources. These             // resources are instead packaged with each base and             // feature APK.             enableSplit = true|false         }         density {             // This property is set to true by default.             enableSplit = true|false         }         abi {             // This property is set to true by default.             enableSplit = true|false         }     } } ````

使用 Bundletool 測試 AAB

在開發過程中,我們可能需要對 AAB 檔案進行分析與除錯。這時候就需要用到 Bundletool 工具。使用該工具,我們可以完成以下功能:

1. 將 AAB 轉換為 APKS

AAB 格式無法直接安裝在手機上,需要將 AAB 格式轉換為 APKS 檔案,再安裝對應的 APK。

在開發階段,我們可以使用 Build Bundle 來生成 AAB 檔案。 微信圖片_20220726143233.png

然後使用以下命令輸出 APKS 檔案:

java -jar bundletool-all-1.10.0.jar build-apks --bundle=app-debug.aab --output=app.apks --local-testing

使用 Zip 工具解壓生成的 APKS 檔案,可以看見在 splits 目錄下,針對不同的語言、解析度、ABI 生成了不同的 APK 檔案。 微信圖片_20220726143237.png (點選檢視大圖)

如果 language 的 enableSplit 設定為 false,則不會針對語言生成不同的 APK 檔案。 微信圖片_20220726143241.png

如果只對當前連線 PC 的裝置生成 APKS 檔案可以使用以下命令:

java -jar bundletool-all-1.10.0.jar build-apks --connected-device --bundle=app-debug.aab --output=app.apks

微信圖片_20220726143244.png

通過使用該命令,生成的 APKS 檔案中,就只包含針對於該裝置的 base APK + 配置 APK。

我們使用以下命令可以獲得當前連線裝置的配置 json 檔案:

java -jar bundletool-all-1.10.0.jar get-device-spec --output=device.json

{ "supportedAbis": ["arm64-v8a", "armeabi-v7a", "armeabi"], "supportedLocales": ["zh-CN", "ar-JO", "en-US"], "deviceFeatures": ["reqGlEsVersion\u003d0x30002", "android.hardware.audio.low_latency", "android.hardware.audio.output", "android.hardware.audio.pro", "android.hardware.bluetooth", "android.hardware.bluetooth_le", "android.hardware.camera", "android.hardware.camera.any", "android.hardware.camera.autofocus", "android.hardware.camera.capability.manual_post_processing", "android.hardware.camera.capability.manual_sensor", "android.hardware.camera.capability.raw", "android.hardware.camera.concurrent", "android.hardware.camera.flash", "android.hardware.camera.front", "android.hardware.camera.level.full", "android.hardware.context_hub", "android.hardware.device_unique_attestation", "android.hardware.faketouch", "android.hardware.fingerprint", "android.hardware.identity_credential\u003d202101", "android.hardware.location", "android.hardware.location.gps", "android.hardware.location.network", "android.hardware.microphone", "android.hardware.nfc", "android.hardware.nfc.any", "android.hardware.nfc.ese", "android.hardware.nfc.hce", "android.hardware.nfc.hcef", "android.hardware.nfc.uicc", "android.hardware.opengles.aep", "android.hardware.ram.normal", "android.hardware.reboot_escrow", "android.hardware.screen.landscape", "android.hardware.screen.portrait", "android.hardware.se.omapi.ese", "android.hardware.se.omapi.uicc", "android.hardware.security.model.compatible", "android.hardware.sensor.accelerometer", "android.hardware.sensor.barometer", "android.hardware.sensor.compass", "android.hardware.sensor.gyroscope", "android.hardware.sensor.hifi_sensors", "android.hardware.sensor.light", "android.hardware.sensor.proximity", "android.hardware.sensor.stepcounter", "android.hardware.sensor.stepdetector", "android.hardware.strongbox_keystore", "android.hardware.telephony", "android.hardware.telephony.carrierlock", "android.hardware.telephony.cdma", "android.hardware.telephony.euicc", "android.hardware.telephony.gsm", "android.hardware.telephony.ims", "android.hardware.touchscreen", "android.hardware.touchscreen.multitouch", "android.hardware.touchscreen.multitouch.distinct", "android.hardware.touchscreen.multitouch.jazzhand", "android.hardware.usb.accessory", "android.hardware.usb.host", "android.hardware.vulkan.compute", "android.hardware.vulkan.level\u003d1", "android.hardware.vulkan.version\u003d4198400", "android.hardware.wifi", "android.hardware.wifi.aware", "android.hardware.wifi.direct", "android.hardware.wifi.passpoint", "android.hardware.wifi.rtt", "android.software.activities_on_secondary_displays", "android.software.app_enumeration", "android.software.app_widgets", "android.software.autofill", "android.software.backup", "android.software.cant_save_state", "android.software.companion_device_setup", "android.software.connectionservice", "android.software.controls", "android.software.cts", "android.software.device_admin", "android.software.device_id_attestation", "android.software.file_based_encryption", "android.software.home_screen", "android.software.incremental_delivery\u003d2", "android.software.input_methods", "android.software.ipsec_tunnels", "android.software.live_wallpaper", "android.software.managed_users", "android.software.midi", "android.software.opengles.deqp.level\u003d132383489", "android.software.picture_in_picture", "android.software.print", "android.software.secure_lock_screen", "android.software.securely_removes_users", "android.software.sip", "android.software.sip.voip", "android.software.verified_boot", "android.software.voice_recognizers", "android.software.vulkan.deqp.level\u003d132383489", "android.software.webview", "com.google.android.apps.dialer.SUPPORTED", "com.google.android.feature.ADAPTIVE_CHARGING", "com.google.android.feature.AER_OPTIMIZED", "com.google.android.feature.D2D_CABLE_MIGRATION_FEATURE", "com.google.android.feature.DREAMLINER", "com.google.android.feature.EXCHANGE_6_2", "com.google.android.feature.GOOGLE_BUILD", "com.google.android.feature.GOOGLE_EXPERIENCE", "com.google.android.feature.GOOGLE_FI_BUNDLED", "com.google.android.feature.NEXT_GENERATION_ASSISTANT", "com.google.android.feature.PIXEL_2017_EXPERIENCE", "com.google.android.feature.PIXEL_2018_EXPERIENCE", "com.google.android.feature.PIXEL_2019_EXPERIENCE", "com.google.android.feature.PIXEL_2019_MIDYEAR_EXPERIENCE", "com.google.android.feature.PIXEL_2020_EXPERIENCE", "com.google.android.feature.PIXEL_2020_MIDYEAR_EXPERIENCE", "com.google.android.feature.PIXEL_EXPERIENCE", "com.google.android.feature.TURBO_PRELOAD", "com.google.android.feature.WELLBEING", "com.nxp.mifare", "com.verizon.hardware.telephony.ehrpd", "com.verizon.hardware.telephony.lte"], "glExtensions": ["GL_OES_EGL_image", "GL_OES_EGL_image_external", "GL_OES_EGL_sync", "GL_OES_vertex_half_float", "GL_OES_framebuffer_object", "GL_OES_rgb8_rgba8", "GL_OES_compressed_ETC1_RGB8_texture", "GL_AMD_compressed_ATC_texture", "GL_KHR_texture_compression_astc_ldr", "GL_KHR_texture_compression_astc_hdr", "GL_OES_texture_compression_astc", "GL_OES_texture_npot", "GL_EXT_texture_filter_anisotropic", "GL_EXT_texture_format_BGRA8888", "GL_EXT_read_format_bgra", "GL_OES_texture_3D", "GL_EXT_color_buffer_float", "GL_EXT_color_buffer_half_float", "GL_QCOM_alpha_test", "GL_OES_depth24", "GL_OES_packed_depth_stencil", "GL_OES_depth_texture", "GL_OES_depth_texture_cube_map", "GL_EXT_sRGB", "GL_OES_texture_float", "GL_OES_texture_float_linear", "GL_OES_texture_half_float", "GL_OES_texture_half_float_linear", "GL_EXT_texture_type_2_10_10_10_REV", "GL_EXT_texture_sRGB_decode", "GL_EXT_texture_format_sRGB_override", "GL_OES_element_index_uint", "GL_EXT_copy_image", "GL_EXT_geometry_shader", "GL_EXT_tessellation_shader", "GL_OES_texture_stencil8", "GL_EXT_shader_io_blocks", "GL_OES_shader_image_atomic", "GL_OES_sample_variables", "GL_EXT_texture_border_clamp", "GL_EXT_EGL_image_external_wrap_modes", "GL_EXT_multisampled_render_to_texture", "GL_EXT_multisampled_render_to_texture2", "GL_OES_shader_multisample_interpolation", "GL_EXT_texture_cube_map_array", "GL_EXT_draw_buffers_indexed", "GL_EXT_gpu_shader5", "GL_EXT_robustness", "GL_EXT_texture_buffer", "GL_EXT_shader_framebuffer_fetch", "GL_ARM_shader_framebuffer_fetch_depth_stencil", "GL_OES_texture_storage_multisample_2d_array", "GL_OES_sample_shading", "GL_OES_get_program_binary", "GL_EXT_debug_label", "GL_KHR_blend_equation_advanced", "GL_KHR_blend_equation_advanced_coherent", "GL_QCOM_tiled_rendering", "GL_ANDROID_extension_pack_es31a", "GL_EXT_primitive_bounding_box", "GL_OES_standard_derivatives", "GL_OES_vertex_array_object", "GL_EXT_disjoint_timer_query", "GL_KHR_debug", "GL_EXT_YUV_target", "GL_EXT_sRGB_write_control", "GL_EXT_texture_norm16", "GL_EXT_discard_framebuffer", "GL_OES_surfaceless_context", "GL_OVR_multiview", "GL_OVR_multiview2", "GL_EXT_texture_sRGB_R8", "GL_KHR_no_error", "GL_EXT_debug_marker", "GL_OES_EGL_image_external_essl3", "GL_OVR_multiview_multisampled_render_to_texture", "GL_EXT_buffer_storage", "GL_EXT_external_buffer", "GL_EXT_blit_framebuffer_params", "GL_EXT_clip_cull_distance", "GL_EXT_protected_textures", "GL_EXT_shader_non_constant_global_initializers", "GL_QCOM_texture_foveated", "GL_QCOM_texture_foveated_subsampled_layout", "GL_QCOM_shader_framebuffer_fetch_noncoherent", "GL_QCOM_shader_framebuffer_fetch_rate", "GL_EXT_memory_object", "GL_EXT_memory_object_fd", "GL_EXT_EGL_image_array", "GL_NV_shader_noperspective_interpolation", "GL_KHR_robust_buffer_access_behavior", "GL_EXT_EGL_image_storage", "GL_EXT_blend_func_extended", "GL_EXT_clip_control", "GL_OES_texture_view", "GL_EXT_fragment_invocation_density", "GL_QCOM_motion_estimation", "GL_QCOM_validate_shader_binary", "GL_QCOM_YUV_texture_gather"], "screenDensity": 440, "sdkVersion": 31 } 從生成的 json 檔案中可以看出,該裝置當前新增支援的地區語言為中文、阿語和英語。所以在之前生成的 APK 中,也包含這三種語言的 APK。

2. 將 APKS 部署到連線裝置

在生成 APKS 檔案以後,使用以下命令可以將 APKS  檔案部署到當前所連線的裝置上:

java -jar bundletool-all-1.10.0.jar install-apks --apks=app_release.apks

安裝成功後,我們可以使用 adb 命令來確認是否成功安裝配置 APK:

adb shell pm path "包名"

微信圖片_20220726143248.png

也可以使用以下命令達到同樣的目的:

adb shell dumpsys package 包名 | findstr split

微信圖片_20220726143252.png

這在除錯 AAB 包是否正確安裝時非常有用。

Play Asset Delivery

在使用 AAB 格式之後,我們發現案例應用最終輸出的 AAB 檔案依舊很大,而且上傳到 Google Play 依舊超過了上限。

這時候就要使用 Play Asset Delivey(PAD)功能,PAD 廣泛用於遊戲 App,它可以將遊戲資源(紋理、聲音等)單獨釋出,並且在 Google Play 上傳 AAB 包時,單獨計算大小。我們可以將 App 內佔用空間較大的資源放到單獨的 Asset Pack 中,繞過 Google Play 上傳 AAB 包 150M 的限制。

PAD 有三種分發模式:

1. install-time:Asset Pack 在使用者安裝應用時分發,也被稱為“預先”資源包,可以在應用啟動時立即使用。這些資源包會增加 Google Play 商店上列出的應用大小,且使用者無法修改或刪除。

2. fast-follow:Asset Pack 會在使用者安裝應用後立即自動下載。使用者無需開啟應用即可開始下載,並且不會阻塞使用者使用 App。這些資源包會增加 Google Play 商店上列出的應用大小。

3. on-demand:Asset Pack 會在應用執行的時候下載。

不同的分發模式,Asset Pack 的大小上限不同

  1. 每個 fast-follow 和 on-demand 模式的 Asset Pack 大小上限為 512MB。
  2. 所有 install-time 模式的 Asset Pack 總上限為 1GB。
  3. 一個 AAB 包中所有 Asset Pack 的總大小上限為 2GB。
  4. 一個 AAB 包中最多可以使用 50 個 Asset Pack。

資源動態載入

install-time 方案

第一步 建立一個新的 Module 來存放資源。 微信圖片_20220726143258.png

第二步 在 install_time_asset_pack Module 的 build.gradle 中程式碼修改為:

``` // In the asset pack’s build.gradle file: apply plugin: 'com.android.asset-pack'

assetPack { packName = "install_time_asset_pack" dynamicDelivery { //只能指定一種分發模式 deliveryType = "install-time" } } ```

第三步 在 App Module 的 build.gradle 中新增以下程式碼: 微信圖片_20220726143301.png

第四步 新增 install_time_asset_pack 模組到 setting.gradle 檔案中。

include ':install_time_asset_pack'

第五步 將佔用空間較大的資源放入 install_time_asset_pack Module。

這裡需要在 mian 目錄下面建立一個 assets 目錄,將資源放入該目錄下即可。

微信圖片_20220726143305.png

第六步 由於我們將資源放入了 Asset Pack 包中,所以需要將這些資源在原來的目錄刪除。

``` applicationVariants.all { variant -> variant.mergeAssetsProvider.configure { doLast { def file = fileTree(dir: outputDir, includes: ['model/ai_body.bundle', 'model/ai_face.bundle', 'model/ai_green.bundle', 'model/ai_human.bundle',
'graphics/body.bundle', 'graphics/controller.bundle', 'graphics/face.bundle', 'graphics/tongue.bundle'])

            delete(file)
        }
    }

} ```

第七步 編寫程式碼,將 Asset Pack 中的資源拷貝到專案的私有目錄並且返回路徑。在原有載入邏輯中修改為該路徑。

``` public String copyResource(String relativeAssetPath){ AssetFileDescriptor openFd = mAssetManager.openFd(relativeAssetPath); String filePath = mContext.getExternalFilesDir(null).getAbsolutePath() + File.separator + relativeAssetPath; File file = new File(filePath); if (file.exists()) { return filePath; } else { new File(file.getParent() + "/").mkdirs(); } copyFile(openFd.createInputStream(), filePath) return filePath; }

private void copyFile(FileInputStream fileInputStream, String outFilePath) throws IOException { if (fileInputStream != null) { FileOutputStream fos = null; try { fos = new FileOutputStream(outFilePath); byte[] bytes = new byte[1024]; int temp = 0; while ((temp = fileInputStream.read(bytes)) != -1) { fos.write(bytes, 0, temp); } } catch (Exception exception) { Log.e(TAG, "copyFile: e=" + exception.getMessage()); } finally { if (fos != null) { fos.close(); } } } } ```

fast-follow、on-demand Asset 方案

採用 fast-follow 或 on-demand Asset 方案,在 Asset Pack 的 build.gralde 中需要修改 deliveryType 屬性。

``` apply plugin: 'com.android.asset-pack'

assetPack { packName = "on_demand_asset" // Directory name for the asset pack dynamicDelivery { deliveryType = "fast-follow | on-demand" } } ```

然後需判斷資源包是否存在,如果不存在需要開啟下載並且監聽其下載狀態。

```

private void getAssetResource() { if (mAssetPackManager != null) { AssetPackLocation assetLocation = mAssetPackManager.getPackLocation(AssetPackName); if (assetLocation == null) { //跟蹤資源包的安裝進度 mAssetPackManager.registerListener(new AssetPackStateUpdateListener() { @Override public void onStateUpdate(@NonNull AssetPackState assetPackState) { switch (assetPackState.status()) { case AssetPackStatus.PENDING: break;

                    case AssetPackStatus.DOWNLOADING:
                        //這裡監控下載進度
                        break;

                    case AssetPackStatus.TRANSFERRING:
                        // 100% downloaded and assets are being transferred.
                        // Notify user to wait until transfer is complete.
                        break;

                    case AssetPackStatus.COMPLETED:
                        //下載成功後加載資料
                        loadData();
                        break;

                    case AssetPackStatus.FAILED:
                       //如果下載失敗了在這裡進行處理
                        break;

                    case AssetPackStatus.CANCELED:
                        // Request canceled. Notify user.
                        break;

                    case AssetPackStatus.WAITING_FOR_WIFI: 
                        if (!waitForWifiConfirmationShown) {
                            mAssetPackManager.showCellularDataConfirmation(MainActivity.this)
                                    .addOnSuccessListener(new OnSuccessListener<Integer>() {
                                        @Override
                                        public void onSuccess(Integer resultCode) {
                                            if (resultCode == RESULT_OK) {
                                                Log.d(TAG, "Confirmation dialog has been accepted.");
                                            } else if (resultCode == RESULT_CANCELED) {
                                                Log.d(TAG, "Confirmation dialog has been denied by the user.");
                                            }
                                        }
                                    });
                            waitForWifiConfirmationShown = true;
                        }
                        break;

                    case AssetPackStatus.NOT_INSTALLED:
                        // Asset pack is not downloaded yet.
                        break;
                    case AssetPackStatus.UNKNOWN:
                        break;
                }
            }
        });
        //下載資源包
        mAssetPackManager.fetch(Collections.singletonList(AssetPackName));
    } else {
        //資源如果已經下載,直接進行載入
        loadData();
    }
}

} ``` 在下載過程中,若下載內容超過 150M 且使用者未連線到 Wi-Fi,那在使用者明確同意使用行動網路下載之前,是不會下載的。

同樣,如果下載內容較大並且使用者中途 Wi-Fi 斷開,下載也會被暫停,需要使用者明確同意使用行動網路下載才會繼續。

這時候監聽到的狀態為 WAITING_FOR_WIFI。要觸發使用者使用行動網路下載的提示,需要呼叫 showCellularDataConfirmation()方法。

總結

微信圖片_20220726143309.png 使用 install-time 模式,Asset Pack 就緒後使用 AssetManager API 訪問資源即可。

使用 fast-follow 或 on-demand 模式,需先判斷 Asset Pack 是否已經下載。如果下載成功,直接獲取路徑使用。如果還未開始下載,則需要觸發下載資源包並且監聽其狀態,以便給使用者反饋。

SO 動態載入

SO 動態載入跟資源的動態載入稍微有一點不同。我們知道在 Android 中載入 SO 檔案一般有兩種方式: - System.load() - System.loadLibrary()

所以我們要動態載入 SO,就得知道這兩種載入 SO 的方式有什麼區別,根據他們的不同去尋找解決方案。

首先在方法使用上,System.load() 方法需要傳入一個完整的檔案路徑。而 System.loadLibrary() 只需要傳入庫檔名就行。其次 System.load() 方法需要先載入依賴庫。例如:LibA.so 依賴於 LibB.so 檔案,使用 load()方法載入 LibA.so 檔案時,就算 LibB.so 檔案跟 LibA.so 檔案在同一目錄,也會因為找不到 LibB.so 而載入失敗。

需要先用 load() 方法載入 LibB.so 再載入 LibA.so。這就要求我們在載入 SO 檔案時知道檔案的依賴關係。而使用 loadLibrary() 方法只需要將有依賴關係的 SO 放在同一目錄即可。

System.load() 方案

要使用 System.load() 方法來動態載入 SO,我們首先需要解決 SO 庫依賴的問題。SO 庫的依賴一定被定義在了某個地方,只要我們能找到這個地方,並且遞迴獲取依賴,我們就能得到完整的依賴路徑。

ELF 結構

我們知道 Android 是基於 Linux 作業系統,而 SO 檔案在 Linux 下是按照 ELF 格式進行儲存。所以我們只需要按 ELF 格式解析 SO 檔案,就能夠獲取到依賴。

要解析 ELF,我們需要知道 ELF 的結構。 微信圖片_20220726143314.jpg

ELF 中的資訊以 Segment(段) 的形式進行儲存,上圖中列出了比較常見的段:

.text:也就是我們所謂的的程式碼段,源程式編譯後的機器指令經常被存放在此處。

.data:資料段用於存放全域性變數和區域性靜態變數。

.bss:未初始化的全域性變數和區域性靜態變數一般存放在此,因為這些變數沒有初始化,它們的預設值為 0,放在資料段沒有必要,單獨在 .bss 段為它們預留位置,所以 .bss 段在 ELF 檔案中也不佔空間。

.got:全域性偏移表(Global Offset Table)是為了解決動態連結庫能夠被多個程序共享而設計的。每個應用程式將引用的動態庫符號收集起來,儲存到 got 表中,用這個表來記錄各個引用符號的地址,當程式中需要引用這些符號時,通過這個表查詢各符號的地址。這樣做的好處在於,記憶體中只需要載入一份動態庫,當不同程式執行時,只需要修改各自的 got 表,它們引用的符號都可以指向同一份動態庫,這樣就可以實現不同程式共享同一個動態庫的目的。

.plt: PLT 表是用來解決延遲繫結的。在程式開始執行時就將所有動態庫中的符號收集起來,儲存到 GOT 表中的做法是不可取的。為了實現延遲繫結,可以通過 PLT 表增加一層跳轉。 test@plt: jmp *(test@GOT) push n push moduleID jump _dl_runtime_resolve

上面虛擬碼中,test@plt 的第一條指令是跳轉到 test@GOT 表查詢 test() 方法的地址。

由於是第一次訪問 test() 方法,這時候 test@GOT 表中並沒有 test() 方法的真正地址,而是儲存的 test@plt 表中第二條指令 push n 的地址,所以會繼續跳轉到 test@plt 表中執行 push n 操作,這個數字 n 是 test這個符號引用在重定位表(.rel.plt)中的下標。

直到執行完_dl_runtime_resolve 指令後,會將 test() 方法的真正地址儲存到 test@GOT 表中,之後再次呼叫 test@plt 時,就會跳轉到 test() 方法的真正地址。

目前 Android Native Hook 方案中,其中一種就是基於 PLT/GOT 表進行 Hook。

.dynamic:動態段中儲存了動態連結器需要的基本資訊,包括動態連結符號表的位置、依賴於哪些共享物件、動態連結重定位表的位置等。可以通過 readelf -d 來檢視。

section header table: 段表描述了 ELF 各段的資訊,包括段名,段的長度,在檔案中的偏移等。我們可以使用 readelf -S 或者 objdump -h 來進行檢視。

program header table:程式頭表是一個數組,陣列中的每個元素稱為“程式頭”,每個程式頭都描述了一個 Segment(段)資訊。可以使用 readelf -h 來檢視。

string table:字串表中包含以 null 結尾的字串,這些字串可能是符號的名字或者 Segment 的名字,需要引用某個字串的時候,只需要提供該字串在字串表中的序號即可。 微信圖片_20220726143318.jpg

需要注意字串表中的第一個字串永遠是空串(null),由於每個字串都以 null 結尾,所以最後一個位元組也必然是 null。

symbol table:符號表中記錄了 ELF 檔案中用到的所有符號(函式、變數)。每個符號都有一個對應值,對變數和函式來說,這個值就是它們的地址。

獲取依賴資訊

對 ELF 檔案格式有了大概瞭解以後,我們知道在 .dynamic 段中存放了當前 SO 檔案的依賴資訊。我們只要能拿到這些資訊,就能遞迴的拿到完整的依賴。

要讀取 .dynamic 段中的內容,必須知道該段在檔案中的偏移。該段的偏移可以在 section header table 或 program header table 中找到。而 section header table 和 program header table 的地址我們又可以從 ELF 頭中得到。

整體思路如下:

微信圖片_20220726143323.jpg

  1. 首先讀取 ELF 頭資訊 ELF Header: Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 Class: ELF32 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: DYN (Shared object file) Machine: ARM Version: 0x1 Entry point address: 0x0 Start of program headers: 52 (bytes into file) Start of section headers: 12588 (bytes into file) Flags: 0x5000000, Version5 EABI Size of this header: 52 (bytes) Size of program headers: 32 (bytes) Number of program headers: 7 Size of section headers: 40 (bytes) Number of section headers: 20 Section header string table index: 19

從頭資訊中我們可以看到 ELF 魔數: 微信圖片_20220726143326.jpg

前 4 個位元組是所有 ELF 檔案都相同的標識碼,我們在解析 ELF 檔案的時候需要通過該標識碼來判斷當前是否解析的是 ELF 格式。

可以看到我們當前解析的 ELF 檔案是一個 32 位的 ELF 檔案,並且是小端序。這兩個資訊很重要,直接關係到我們在解析 ELF 檔案時的正確性。例如這裡使用了小端序,所以在判斷 ELF 的魔數時,應該與 0x464C457F 進行比較。

有了這些資訊後,我們在讀取 ELF 檔案頭時就有了依據,由於是 32 位的 ELF 檔案,我們就需要根據 32 位的資料結構來進行解析。

typedef struct { unsigned char e_ident[16]; //0x00-0x0f Elf32_Half e_type; //0x10-0x11 Elf32_Half e_machine; //0x12-0x13 Elf32_Word e_version; //0x14-0x17 Elf32_Addr e_entry; //0x18-0x1b Elf32_Off e_phoff; //0x1c-0x1f Elf32_Off e_shoff; //0x20-0x23 Elf32_Word e_flags; //0x24-0x27 Elf32_Half e_ehsize; //0x28-0x29 Elf32_Half e_phentsize; //0x2a-0x2b Elf32_Half e_phnum; //0x2c-0x2d Elf32_Half e_shentsize; //0x2e-0x2f Elf32_Half e_shnum; //0x30-0x31 Elf32_Half e_shstrndx; //0x32-0x33 } Elf32_Ehdr;

其中 Elf32_Half、ELF32_Word 等都是自定義型別,長度分別如下: 微信圖片_20220726143330.jpg

接下來我們需要獲取 ELF 頭中幾個重要欄位:

1.e_type:標記檔案屬於哪種型別。 微信圖片_20220726143333.jpg 表格中並沒有完全列出所有的值,在這裡我們只需要判斷當前是否為 ET_DYN 型別即可。

2.e_phoff:程式頭表偏移量,即程式頭表的地址,以位元組為單位。這裡對應:Start of program headers:  52 (bytes into file)

需要從 0x1C 處讀取 4 個位元組。

3.e_shoff:段頭表偏移量,即段頭表的地址,以位元組為單位。這裡對應:Start of section headers:  12588 (bytes into file)

需要從 0x20 處讀取 4 個位元組。

4.e_phentsize:程式頭表中每個 segment 的大小,以位元組為單位。這裡對應:Size of program headers:  32 (bytes)

需要從 0x2A 處讀取 2 個位元組。

5.e_phnum:程式頭表中 segment 的個數。這裡對應:Number of program headers:   7

需要從 0x2C 處讀取 2 個位元組。

6.e_shentsize:段頭表中每個 segment 的大小,以位元組為單位。這裡對應:Size of section headers:    40 (bytes)

需要從 0x2E 處讀取 2 個位元組。

7:e_shnum:段頭表中 segment 的個數。這裡對應:Number of section headers:   20

需要從0x30處讀取2個位元組

8.e_shstrndx:段頭表中字串表的索引。這裡對應:Section header string table index: 19

需要從 0x32 處讀取 2 個位元組。

拿到了程式頭表的偏移後,我們就可以對程式頭表進行遍歷,本例中有 7 個程式頭。

``` Elf file type is DYN (Shared object file) Entry point 0x0 There are 7 program headers, starting at offset 52

Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000034 0x00000034 0x00000034 0x000e0 0x000e0 R 0x4 LOAD 0x000000 0x00000000 0x00000000 0x02160 0x02160 R E 0x1000 LOAD 0x002eac 0x00003eac 0x00003eac 0x00158 0x00158 RW 0x1000 DYNAMIC 0x002eb8 0x00003eb8 0x00003eb8 0x00100 0x00100 RW 0x4 GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0 EXIDX 0x002088 0x00002088 0x00002088 0x000d8 0x000d8 R 0x4 GNU_RELRO 0x002eac 0x00003eac 0x00003eac 0x00154 0x00154 RW 0x4

Section to Segment mapping: Segment Sections... 00 01 .dynsym .dynstr .hash .rel.dyn .rel.plt .plt .text .ARM.extab .ARM.exidx 02 .fini_array .init_array .dynamic .got .data 03 .dynamic 04 05 .ARM.exidx 06 .fini_array .init_array .dynamic .got ```

我們從偏移位置為 0x52 處開始遍歷,每次增加步長為 32(e_phentsize)。同樣的,程式頭也有自己的資料結構:

typedef struct { Elf32_Word p_type;//0x52-0x55 Elf32_Off p_offset; //0x56-0x59 Elf32_Addr p_vaddr; //0x5a-0x5d Elf32_Addr p_paddr; //0x5e-0x62 Elf32_Word p_filesz; //0x63-0x66 Elf32_Word p_memsz; //0x67-0x6a Elf32_Word p_flags; //0x6b-0x6e Elf32_Word p_align; //0x6f-0x73 } Elf32_Phdr; 我們需要從程式頭中讀取以下資訊:

1.p_type:程式頭所描述的段的型別,這裡我們只需要找到 PT_DYNAMIC 型別即可。

2.p_offset:程式頭所描述的段的偏移量,相對於檔案開頭的偏移量 以位元組為單位。

3.p_vaddr:本段內容的開始位置在程序中的虛擬地址,以位元組為單位。

4.p_memsz:本段內容的大小,以位元組為單位。

從 readelf 打印出的內容中可見,我們其實要找的只是這行內容:

DYNAMIC        0x002eb8 0x00003eb8 0x00003eb8 0x00100 0x00100 RW  0x4

這裡的偏移地址就是 .dynamic 段的偏移地址。

有了 .dynamic 段的偏移地址後,我們就可以讀取到所依賴的 SO 檔案。

Dynamic section at offset 0x2eb8 contains 27 entries: Tag Type Name/Value 0x00000003 (PLTGOT) 0x3fd4 0x00000002 (PLTRELSZ) 64 (bytes) 0x00000017 (JMPREL) 0xb28 0x00000014 (PLTREL) REL 0x00000011 (REL) 0xae8 0x00000012 (RELSZ) 64 (bytes) 0x00000013 (RELENT) 8 (bytes) 0x6ffffffa (RELCOUNT) 6 0x00000006 (SYMTAB) 0x114 0x0000000b (SYMENT) 16 (bytes) 0x00000005 (STRTAB) 0x494 0x0000000a (STRSZ) 1238 (bytes) 0x00000004 (HASH) 0x96c 0x00000001 (NEEDED) Shared library: [libhello.so] 0x00000001 (NEEDED) Shared library: [libstdc++.so] 0x00000001 (NEEDED) Shared library: [libm.so] 0x00000001 (NEEDED) Shared library: [libc.so] 0x00000001 (NEEDED) Shared library: [libdl.so] 0x0000000e (SONAME) Library soname: [libhellojni.so] 0x0000001a (FINI_ARRAY) 0x3eac 0x0000001c (FINI_ARRAYSZ) 8 (bytes) 0x00000019 (INIT_ARRAY) 0x3eb4 0x0000001b (INIT_ARRAYSZ) 4 (bytes) 0x00000010 (SYMBOLIC) 0x0 0x0000001e (FLAGS) SYMBOLIC BIND_NOW 0x6ffffffb (FLAGS_1) Flags: NOW 0x00000000 (NULL) 0x0 .dynamic 段的資料結構比較簡單

typedef struct { Elf32_Word p_type;//0x52-0x55 Elf32_Off p_offset; //0x56-0x59 Elf32_Addr p_vaddr; //0x5a-0x5d Elf32_Addr p_paddr; //0x5e-0x62 Elf32_Word p_filesz; //0x63-0x66 Elf32_Word p_memsz; //0x67-0x6a Elf32_Word p_flags; //0x6b-0x6e Elf32_Word p_align; //0x6f-0x73 } Elf32_Phdr;

我們需要遍歷 .dynamic 段,找到 d_tag 為:NEEDED 和 STRTAB 的內容。

其中 NEEDED 指明瞭所依賴的庫,但是該元素本身並不是一個字串,它指向 STRTAB 表中的索引。所以我們也需要獲取到 STRTAB 的偏移量。

在本例中我們會獲取到 5 個 NEEDED:

0x00000001 (NEEDED)      Shared library: [libhello.so] 

0x00000001 (NEEDED)                      Shared library: [libstdc++.so] 

0x00000001 (NEEDED)                      Shared library: [libm.so] 

0x00000001 (NEEDED)                      Shared library: [libc.so] 

0x00000001 (NEEDED)                      Shared library: [libdl.so]

以及 STRTAB 的偏移:

0x00000005 (STRTAB)                      0x494

有了這些資訊,我們就可以獲取到所依賴的 SO 檔案,然後再遞迴去查詢這些 SO 的依賴,得到完整的依賴路徑。有了依賴路徑,我們就可以按照依賴的先後順序來載入 SO 檔案。

全域性替換 load 方法

由於是動態載入 SO 檔案,所以 SO 檔案地址跟之前有可能不一樣。在解決完 load() 方法的依賴問題後,我們需要修改成新的地址以及載入邏輯。

這時候可以將整個 SO 獲取依賴資訊以及載入的邏輯進行封裝,封裝好以後可以通過 ASM 在編譯期進行位元組碼修改,將呼叫系統 System.load() 方法指令全部替換成自己封裝的方法。如果不想自己封裝,也可以使用 ReLinker 或者 Facebook 開源的 SoLoader

System.loadLibrary() 方案

loadLibrary() 方案比 load() 簡單,它不需要我們去解析 ELF 檔案讀取依賴資訊。很多時候我們也都採用這個方法來載入 SO 庫。

我們先分析一下 loadLibrary() 方法是如何載入 SO 庫的。

```public static void loadLibrary(String libname) { Runtime.getRuntime().loadLibrary0(Reflection.getCallerClass(), libname); }

void loadLibrary0(Class<?> fromClass, String libname) { ClassLoader classLoader = ClassLoader.getClassLoader(fromClass); loadLibrary0(classLoader, fromClass, libname); }

private synchronized void loadLibrary0(ClassLoader loader, Class<?> callerClass, String libname) { if (libname.indexOf((int)File.separatorChar) != -1) { throw new UnsatisfiedLinkError( "Directory separator should not appear in library name: " + libname); } String libraryName = libname;

if (loader != null && !(loader instanceof BootClassLoader)) {
    String filename = loader.findLibrary(libraryName);
    if (filename == null &&
            (loader.getClass() == PathClassLoader.class ||
             loader.getClass() == DelegateLastClassLoader.class)) {

        filename = System.mapLibraryName(libraryName);
    }
    if (filename == null) {
        throw new UnsatisfiedLinkError(loader + " couldn't find \"" +
                                       System.mapLibraryName(libraryName) + "\"");
    }
    String error = nativeLoad(filename, loader);
    if (error != null) {
        throw new UnsatisfiedLinkError(error);
    }
    return;
}
getLibPaths();
String filename = System.mapLibraryName(libraryName);
String error = nativeLoad(filename, loader, callerClass);
if (error != null) {
    throw new UnsatisfiedLinkError(error);
}

} ```

以上是 loadLibrary 的呼叫順序,關鍵邏輯在 loadLibrary0() 方法中。在該方法中會使用 ClassLoader 的 findLibrary() 方法去獲取 SO 檔案。獲取成功後交給 native 方法 nativeLoad() 對 SO 檔案進行載入。

``` //BaseDexClassLoader public String findLibrary(String name) { return pathList.findLibrary(name); }

//DexPathList.java / List of native library path elements. / // Some applications rely on this field being an array or we'd use a final list here @UnsupportedAppUsage / package visible for testing */ NativeLibraryElement[] nativeLibraryPathElements;

/** List of application native library directories. */
@UnsupportedAppUsage
private final List<File> nativeLibraryDirectories;

/** List of system native library directories. */
@UnsupportedAppUsage
private final List<File> systemNativeLibraryDirectories;

public String findLibrary(String libraryName) { String fileName = System.mapLibraryName(libraryName);

    for (NativeLibraryElement element : nativeLibraryPathElements) {
        String path = element.findNativeLibrary(fileName);

        if (path != null) {
            return path;
        }
    }
    return null;
}

private static NativeLibraryElement[] makePathElements(List<File> files) {
    NativeLibraryElement[] elements = new NativeLibraryElement[files.size()];
    int elementsPos = 0;
    for (File file : files) {
        String path = file.getPath();

        if (path.contains(zipSeparator)) {
            String split[] = path.split(zipSeparator, 2);
            File zip = new File(split[0]);
            String dir = split[1];
            elements[elementsPos++] = new NativeLibraryElement(zip, dir);
        } else if (file.isDirectory()) {
            // We support directories for looking up native libraries.
            elements[elementsPos++] = new NativeLibraryElement(file);
        }
    }
    if (elementsPos != elements.length) {
        elements = Arrays.copyOf(elements, elementsPos);
    }
    return elements;
}

 DexPathList(ClassLoader definingContext, String dexPath,
        String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
    ...
    this.definingContext = definingContext;

    ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();

    this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                       suppressedExceptions, definingContext, isTrusted);

    this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);
    this.systemNativeLibraryDirectories =
            splitPaths(System.getProperty("java.library.path"), true);
    this.nativeLibraryPathElements = makePathElements(getAllNativeLibraryDirectories());

    if (suppressedExceptions.size() > 0) {
        this.dexElementsSuppressedExceptions =
            suppressedExceptions.toArray(new IOException[suppressedExceptions.size()]);
    } else {
        dexElementsSuppressedExceptions = null;
    }
}

 private List<File> getAllNativeLibraryDirectories() {
    List<File> allNativeLibraryDirectories = new ArrayList<>(nativeLibraryDirectories);
    allNativeLibraryDirectories.addAll(systemNativeLibraryDirectories);
    return allNativeLibraryDirectories;
}

``` 上面原始碼有點多,簡單總結就是:

1.從 java.library.pah 中獲取到系統 native 庫的目錄;

2.把應用程式的 native 庫的目錄一起放入一個 List 中,傳給 makePathElements() 方法;

3.makePathElements() 方法經過處理後返回一個  NativeLibraryElement[]  陣列給 nativeLibraryPathElements 變數;

4.查詢 SO 庫時,從 nativeLibraryPathElements 這個變數包含的目錄中進行查詢。

知道原理以後,要實現 loadlibrary() 動態載入 SO 就很簡單了。

只需要將動態載入的 SO 庫存放目錄通過反射新增到 nativeLibraryPathElements 陣列的第一個位置,這樣系統按照 nativeLibraryPathElements 中包含的目錄進行查詢時,就能找到我們的 SO 檔案。

``` private static void install(ClassLoader classLoader, File folder) throws Throwable { final Field pathListField = ReflectionUtils.findField(classLoader, PATH_LIST); final Object dexPathList = pathListField.get(classLoader);

final Field nativeLibraryDirectories = ReflectionUtils.findField(dexPathList, NATIVE_LIBRARY_DIRECTORIES);

List<File> origLibDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);
if (origLibDirs == null) {
    origLibDirs = new ArrayList<>(2);
}
//去重
final Iterator<File> libDirIt = origLibDirs.iterator();
while (libDirIt.hasNext()) {
    final File libDir = libDirIt.next();
    if (folder.equals(libDir)) {
        libDirIt.remove();
        break;
    }
}
origLibDirs.add(0, folder);

final Field systemNativeLibraryDirectories = ReflectionUtils.findField(dexPathList, SYSTEM_NATIVE_LIBRARY_DIRECTORIES);
List<File> origSystemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);
if (origSystemLibDirs == null) {
    origSystemLibDirs = new ArrayList<>(2);
}
//建立新的list,方式併發修改異常
final List<File> newLibDirs = new ArrayList<>(origLibDirs.size() + origSystemLibDirs.size() + 1);
newLibDirs.addAll(origLibDirs);
newLibDirs.addAll(origSystemLibDirs);

final Method makeElements = ReflectionUtils.findMethod(dexPathList, MAKE_PATH_ELEMENTS, List.class);

final Object[] elements = (Object[]) makeElements.invoke(dexPathList, newLibDirs);

final Field nativeLibraryPathElements = ReflectionUtils.findField(dexPathList, NATIVE_LIBRARY_PATH_ELEMENTS);
nativeLibraryPathElements.set(dexPathList, elements);

} ```

由於 Android 各版本的實現有稍許差異,所以我們需要對版本進行適配。具體可以參考騰訊 Tinker 的實現。

dlopen 問題

本來一切都很美好,直到 Android N(7.0)到來。

Android 平臺一直都是高度碎片化的,裝置製造商不願意將舊的裝置升級到新的 Android 平臺,因為需要很多工作量,這就迫使開發者需要在大量的裝置上去測試他們的應用程式。

為了解決這個問題,谷歌釋出了 Project Treble。

Treble 將 Android 平臺分為框架(Framework)和供應商(Vendor)兩部分,它們之間通過穩定的介面進行互動。因此通過 Treble 可以實現在保持供應商部分不變的情況下,升級 Android 框架。 微信圖片_20220726143337.png

但是這就導致了 Treble 引入了兩套本地庫:框架和供應商的。

在某些情況下,這兩部分的庫檔案中可能存在相同名稱,但不同的實現。由於庫的符號會暴露給一個程序的所有程式碼,所以會產生衝突。

為了解決這些問題,Android 動態連結器引入了基於名稱空間的動態連結(namespace based dynamic linking),它和 Java 類載入器隔離資源的方法類似。通過這種設計,每個庫都載入到一個特定的名稱空間 中,除非它們通過名稱空間連結 (namespace link)共享,否則不能訪問其他名稱空間中的庫。

我們知道不論是 load() 方法還是 loadLibrary() 方法,最終都是呼叫 dlopen() 方法來開啟 SO 庫的。dlopen() 方法最終會呼叫到 Linker.cpp 中的程式碼。

從 Android N 開始,Linker.cpp 的 loader_library() 方法進行了許可權的判斷。

``` static bool load_library(android_namespace_t ns, LoadTask task, LoadTaskList* load_tasks, int rtld_flags, const std::string& realpath, bool search_linked_namespaces) { ...

if ((fs_stat.f_type != TMPFS_MAGIC) && (!ns->is_accessible(realpath))) { ... return false; } ... return true; } ```

其中 is_accessible() 方法會判斷給定的絕對路徑是否在以下三個列表中: 1. ld_library_paths 2. default_library_paths 3. permitted_paths

``` bool android_namespace_t::is_accessible(const std::string& file) { if (!is_isolated_) { return true; }

if (!allowed_libs_.empty()) { const char *lib_name = basename(file.c_str()); if (std::find(allowed_libs_.begin(), allowed_libs_.end(), lib_name) == allowed_libs_.end()) { return false; } }

for (const auto& dir : ld_library_paths_) { if (file_is_in_dir(file, dir)) { return true; } }

for (const auto& dir : default_library_paths_) { if (file_is_in_dir(file, dir)) { return true; } }

for (const auto& dir : permitted_paths_) { if (file_is_under_dir(file, dir)) { return true; } }

return false; } ```

如果給定的路徑不在以上三個列表中,load_library() 方法就會返回 false,導致載入失敗。程式會報出以下異常:

java.lang.UnsatisfiedLinkError: dlopen failed: library "/storage/emulated/0/Android/data/org.zzy.nativetest/files/bundle/jni/arm64-v8a/libnativetest.so" needed or dlopened by "/apex/com.android.art/lib64/libnativeloader.so" is not accessible for the namespace "classloader-namespace" at java.lang.Runtime.loadLibrary0(Runtime.java:1077) at java.lang.Runtime.loadLibrary0(Runtime.java:998) at java.lang.System.loadLibrary(System.java:1656) at org.zzy.nativetest.so.SoTestActivity.onCreate(SoTestActivity.java:28) at android.app.Activity.performCreate(Activity.java:8051) at android.app.Activity.performCreate(Activity.java:8031) at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1329) at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3608) at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3792) at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:103) at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135) at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95) at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2210) at android.os.Handler.dispatchMessage(Handler.java:106) at android.os.Looper.loopOnce(Looper.java:201) at android.os.Looper.loop(Looper.java:288) at android.app.ActivityThread.main(ActivityThread.java:7838) at java.lang.reflect.Method.invoke(Native Method) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1003) 這會有什麼問題呢?我們之前通過反射的方式將 SO 庫存放的目錄新增到了 nativeLibraryPathElements 陣列中,但是從 Android N 以後,SO 庫的存放目錄如果不在以上三個列表中,就會導致 dlopen 開啟失敗。

好在天無絕人之路,在 Logcat 的日誌中,列印了  ld_library_paths,default_library_paths,permitted_paths 的值。

[name="classloader-namespace", ld_library_paths="", default_library_paths="/data/app/~~-ARvezkrvMHNPn30p76eTg==/org.zzy.nativetest-oouE9DDeRvsCdlkHBgca1g==/lib/arm64:/data/app/~~-ARvezkrvMHNPn30p76eTg==/org.zzy.nativetest-oouE9DDeRvsCdlkHBgca1g==/base.apk!/lib/arm64-v8a", permitted_paths="/data:/mnt/expand:/data/data/org.zzy.nativetest"]

我們可以發現 permitted_paths 中包含了應用的沙盒目錄。也就是說我們只要把需要動態載入的 SO 檔案放到應用的沙盒目錄下,就可以解決這個問題。

Asset Delivery 動態載入 SO

在瞭解完 SO 動態載入的方案之後,就可以開始使用 Asset Delivery 來動態載入 SO 庫。我們採用的方案還是 install-time 模式,在應用安裝時就進行分發。

首先我們還是要將原來的 SO 檔案從專案中去掉。可以在 App module 中的 build.gradle 檔案中使用以下方式去除:

packagingOptions { exclude 'META-INF/DEPENDENCIES' if (packAAB) { exclude 'lib/arm64-v8a/libxxxSDK.so' exclude 'lib/arm64-v8a/libxxx.so' exclude 'lib/arm64-v8a/libxxx_view.so' exclude 'lib/armeabi-v7a/libxxxSDK.so' exclude 'lib/armeabi-v7a/libxxx.so' exclude 'lib/armeabi-v7a/libxxx_view.so' } }

這裡使用了一個變數來控制是否生成 AAB 包,如果生成的是 APK 包,SO 檔案將不會被去除。

接著將 SO 庫放入到之前建立的 install_time_asset_pack Module 中。記得按 ABI 版本進行區分。在 Application 初始化的時候把 SO 庫拷貝到應用的沙盒目錄,這裡需要做一下檢查,如果已經存在了,就別再拷貝了,要不然每次應用啟動都拷貝一次挺耗效能的。

也需要判斷一下當前裝置是 64 位還是 32 位的,把相應 ABI 對應的 SO 檔案拷貝過去就行。最後再使用我們前面提到的方案,將 SO 檔案的存放目錄通過反射新增到 nativeLibraryPathElements 陣列中。

總結而言,對於使用 System.load() 方法來載入 SO 的,需要自己封裝 ELF 的解析以及 SO 的載入邏輯,並且在編譯期插樁來替換掉原來的載入邏輯。對於使用 System.loadLibrary() 方法來載入 SO 的,需要通過反射將 SO 的載入目錄注入到 nativeLibraryPathElements 變數。

不論是採用哪種方式動態載入 SO,SO 的存放路徑必須放在應用的沙盒目錄下。

每次 SO 檔案更新,Asset Pack 中的 SO 也需要相應的更新。

實踐結果

未使用 AAB 格式釋出之前,案例應用的 APK 大小達 227.49 MB,遠超於 Google Play 的限制。

在改為 AAB 格式進行釋出以後,Google Play 對於包大小的限制變為了:當用戶下載您的應用時,安裝應用所需的壓縮 APK(例如,基本 APK + 配置 APK)的總大小不得超過 150 MB。

在 Pixel 5 手機上,如果安裝案例 App,會獲取以下 APK: 微信圖片_20220726143342.png

通過計算,基本 APK+ 配置 APK 的總大小為:84M。資源包大小不計算在 Google Play 上傳限制之內。

參考資料:

1.http://developer.android.com/guide/playcore/asset-delivery?hl=zh-cn

2.http://jackwish.net/blog/2016/android-dynamic-linker.html

3.http://cloud.tencent.com/developer/article/1592672?from=article.detail.1751968

4.http://www.52pojie.cn/thread-948942-1-1.html