应用出海绕不开 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,其中仅包含设备在运行时所需的代码和资源,可获得更小、更有针对性的下载包。
(使用 App Bundle 可获得更小下载包)
AAB 包的构成
Android App Bundle 是一种新的发布格式,其中包含了所有经过编译的代码和资源。在 AAB 包中有三种类型的模块:
基本 APK(Base.apk):提供基本的功能,当用户下载应用时,首先会下载并安装该 APK。
配置 APK:针对不同的屏幕密度、CPU 架构或者语言会生成对应的 APK 文件,当用户下载应用时,只会下载并安装对应配置的 APK。
功能模块 APK:我们可以将非基础并相对独立的功能打包成 APK,当用户需要使用的时候再进行安装。
(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 文件完全一致。
这里要注意:
使用 AAB 格式后,不再支持 APK 扩展文件(*.obb)。
使用 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 工具。使用该工具,我们可以完成以下功能:
将 AAB 转换为 APKS
AAB 格式无法直接安装在手机上,需要将 AAB 格式转换为 APKS 文件,再安装对应的 APK。
在开发阶段,我们可以使用 Build Bundle 来生成 AAB 文件。
图片然后使用以下命令输出 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 文件。
如果 language 的 enableSplit 设置为 false,则不会针对语言生成不同的 APK 文件。
如果只对当前连接 PC 的设备生成 APKS 文件可以使用以下命令:
java -jar bundletool-all-1.10.0.jar build-apks --connected-device --bundle=app-debug.aab --output=app.apks
通过使用该命令,生成的 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。
将 APKS 部署到连接设备
在生成 APKS 文件以后,使用以下命令可以将 APKS 文件部署到当前所连接的设备上:
java -jar bundletool-all-1.10.0.jar install-apks --apks=app_release.apks
安装成功后,我们可以使用 adb 命令来确认是否成功安装配置 APK:
adb shell pm path "包名"
也可以使用以下命令达到同样的目的:
adb shell dumpsys package 包名 | findstr split
这在调试 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 有三种分发模式:
install-time:Asset Pack 在用户安装应用时分发,也被称为“预先”资源包,可以在应用启动时立即使用。这些资源包会增加 Google Play 商店上列出的应用大小,且用户无法修改或删除。
fast-follow:Asset Pack 会在用户安装应用后立即自动下载。用户无需打开应用即可开始下载,并且不会阻塞用户使用 App。这些资源包会增加 Google Play 商店上列出的应用大小。
on-demand:Asset Pack 会在应用运行的时候下载。
不同的分发模式,Asset Pack 的大小上限不同:
每个 fast-follow 和 on-demand 模式的 Asset Pack 大小上限为 512MB。
所有 install-time 模式的 Asset Pack 总上限为 1GB。
3.一个 AAB 包中所有 Asset Pack 的总大小上限为 2GB。
4.一个 AAB 包中最多可以使用 50 个 Asset Pack。
资源动态加载
install-time 方案
第一步 创建一个新的 Module 来存放资源。
第二步 在 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 中添加以下代码:
第四步 添加 install_time_asset_pack 模块到 setting.gradle 文件中。
include ':install_time_asset_pack'
第五步 将占用空间较大的资源放入 install_time_asset_pack Module。
这里需要在 mian 目录下面创建一个 assets 目录,将资源放入该目录下即可。
第六步 由于我们将资源放入了 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()方法。
总结
使用 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 的结构。
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 的名字,需要引用某个字符串的时候,只需要提供该字符串在字符串表中的序号即可。
需要注意字符串表中的第一个字符串永远是空串(null),由于每个字符串都以 null 结尾,所以最后一个字节也必然是 null。
symbol table:符号表中记录了 ELF 文件中用到的所有符号(函数、变量)。每个符号都有一个对应值,对变量和函数来说,这个值就是它们的地址。
获取依赖信息
对 ELF 文件格式有了大概了解以后,我们知道在 .dynamic 段中存放了当前 SO 文件的依赖信息。我们只要能拿到这些信息,就能递归的拿到完整的依赖。要读取 .dynamic 段中的内容,必须知道该段在文件中的偏移。该段的偏移可以在 section header table 或 program header table 中找到。而 section header table 和 program header table 的地址我们又可以从 ELF 头中得到。
整体思路如下:
首先读取 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 魔数:
前 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 等都是自定义类型,长度分别如下:
接下来我们需要获取 ELF 头中几个重要字段:
1.e_type:标记文件属于哪种类型。
表格中并没有完全列出所有的值,在这里我们只需要判断当前是否为 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 框架。
但是这就导致了 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() 方法会判断给定的绝对路径是否在以下三个列表中:
ld_library_paths
default_library_paths
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:
通过计算,基本 APK+ 配置 APK 的总大小为:84M。资源包大小不计算在 Google Play 上传限制之内。
参考资料:
1.https://developer.android.com/guide/playcore/asset-delivery?hl=zh-cn
2.https://jackwish.net/blog/2016/android-dynamic-linker.html
3.https://cloud.tencent.com/developer/article/1592672?from=article.detail.1751968
4.https://www.52pojie.cn/thread-948942-1-1.html
评论