Android Bundle包全过程详解

1.认识Bundle

官方文档:https://developer.android.com/guide/app-bundle

定义

Android App Bundle 是一种发布格式,其中包含您应用的所有经过编译的代码和资源,它会将 APK 生成及签名交由 Google Play 来完成。通俗理解就是,bundle是多个apk集合特殊格式,该集合内的apk根据用户需要安装对应apk,每个apk代表特殊的功能模块。

作用

Google Play 会使用您的 App Bundle 针对每种设备配置生成并提供经过优化的 APK,因此只会下载特定设备所需的代码和资源来运行您的应用。即用户只需下载最基础的apk,剩余根据情景,按需下载,减少下载量,以此提高下载和迭代速度,提高用户体验。

Apk的分类

bundle是一种文件形式,通常后缀为".aab",通过bundle工具就能解压成不同模块的apk包集合,apk包主要分为资源包和Dynamic Feature(自适应功能包)。由于bundle本质是让用户根据需要,下最少的资源包,因此衍生出对bundle资源分包和自适应包的具体实现。

image-20210507094849989.png

1.Base APK,可以认为是基础版apk,即集成了基本功能,并且每个用户都需要拥有的代码模块。

2.Configuration APK,大致分为以下三类,对应图中底部三种APK包,分别是像素分辨率资源、cpu内核分类、国际化语言。无论是Base APK还是Dynamic Feature APK,他们都拥有自身的Configuration APK,bundle包会根据设备的像素分辨率、cpu、当前使用语言,提供适配设备的精简包。

3.Dynamic Feature APK,自适应功能APK包,简单理解为在基础包功能的基础上,根据不同设备型号和不同用户手中所需的设备,按需下载对应的APK包,避免全部设备类型包都下载。所以,Feature包是满足base所有功能基础前提的一种用于细分业务的拓展。

Bundle分包配置

场景:我需要bundle打包,但我希望所有语言包在安装时就安装好,以便我切换语言。

当用户通过Google Play Store下载应用时,如果上架的是bundle包,那么就会根据当前手机配置过的语言,动态下载语言包,例如我本地手机配置过英语、中文。可我应用支持德语,用户在安装完包以后,通过系统切换到德语,那由于bundle在安装的时候并没有下载包,此时也不会动态去下载,因此无法切换成功德语的多语言适配。所以,我们需要在bundle生成时,在app/build.gradle 中进行配置,来实现我们所需的效果

android{
    bundle {
        language {
          //是否开启语言分包,当为true在这里可以添加inclue ‘ch-ZH’,配置预设语言
          enableSplit = false
        }
        //分辨率分包
        density {
          enableSplit = true
        }
        //cpu内核分包
        abi {
          enableSplit = true
          // include 'armeabi', 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
        }
        }
}

2.Bundle工具的配置和使用

提问:如何通过bundle工具打出,包含我想要的资源包或Dynamic Feature包的aab呢?

新建所需的Module并配置其清单文件,利用play core核心库辅助配置,然后使用bundle工具执行bundle命令,生成aab包,最终通过压缩工具校验包内的apk是否和期望一致。

官网下载地址:https://github.com/google/bundletool/releases

mac电脑安装步骤

1.通过官网下载最新的对应的jar包,并把jar包修改为bundletool.jar

2.通过command+shift+.查看隐藏文件,找到Android Studio中的sdk目录,并且在目录下创建bundle-tool文件夹,将jar放入其中

3.在命令行中执行chmod +x “你的jar包绝对路径”获取权限

4.通过Finder查看器,到个人用户/usr文件下,找到“.bash.profile”,用文本打开,并写入export PATH=PATH:$ANDROID_HOME/bundle-tool/,通过source .bash_profile保存

5.通过android studio的Terminal,首先先cd到你所打的bundle包目录,随后通过bundle命令测试,其中app-debug.abb,即我们cd目录下的bundle包,最终会在该目录生成apks包。bundle命令如果生效,则代表安装完成,否则会提示找不到bundle工具包。

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

bundle指令

官方官网指令地址:https://developer.android.google.cn/studio/command-line/bundletool?hl=zh-cn

举例:

//bundletool build-apks 指令名称
//--bundle=path bundle包输入地址
//--output=path apks包输出地址
bundletool build-apks --bundle=/MyApp/my_app.aab --output=/MyApp/my_app.apks

//--ks=path 密钥库地址
--ks=/MyApp/keystore.jks
//密钥库密码
--ks-pass=pass:password
//--ks-key-alia= 签名密钥别名
--ks-key-alias=MyKeyAlias
//密钥别名密码
--key-pass=pass:password

1.Bundle指令标识表

--bundle=path 包输入地址
--output=path apks包输出地址
--overwrite 如果已经在目录下有输出文件,output会提示以存在文件,需要要用此命令重写
--aapt2=path 指定 AAPT2 的自定义路径。 默认情况下,bundletool 包含自己的 AAPT2 版本。
--ks=path 指定用于为 APK 签名的部署密钥库的路径。此标记是可选的。如果您不添加此标记,bundletool 会尝试使用调试签名密钥为您的 APK 签名。
--ks-pass=pass:password--ks-pass=file:/path/to/file 指定密钥库的密码。如果您指定纯文本格式的密码,请使用 pass: 限定该密码。如果您要传递包含该密码的文件的路径,请使用 file: 限定该路径。如果您使用 --ks 标记指定密钥库,而未指定 --ks-pass,那么 bundletool 会提示您从命令行输入密码。
--ks-key-alias=alias 指定要使用的签名密钥的别名。
--key-pass=pass:password--key-pass=file:/path/to/file 指定签名密钥的密码。如果您指定纯文本格式的密码,请使用 pass: 限定该密码。如果您要传递包含该密码的文件的路径,请使用 file: 限定该路径。如果此密码与密钥库的密码相同,您可以省略此标记。
--connected-device 根据连接设备区分,把bundle包安装到不同设备
--device-id=serial-number 如果您有多个已连接的设备,请使用此标记指定要部署应用的设备的序列 ID。
--device-spec=spec_json 使用此标记提供 .json 文件的路径,该文件指定了您要针对其生成 APK 的设备配置。
--mode=universal 如果您希望 bundletool 只构建一个包含应用的所有代码和资源的 APK,以使该 APK 与应用支持的所有设备配置兼容,请将模式设置为 universal注意bundletool 仅包含功能模块,这些模块在通用 APK 中的对应清单中指定 。如需了解详情,请参阅功能模块清单。请注意,这些 APK 要比针对特定设备配置优化过的 APK 更大。但是,这些 APK 更便于与内部测试人员共享,例如想在多种设备配置上测试您的应用的测试人员。
--local-testing 使用此标志启用 app bundle 进行本地测试。 在本地测试时,由于无需上传到 Google Play 服务器,因此能够实现快速的迭代测试周期。 有关如何使用 --local-testing 标记测试模块安装的示例,请参阅在本地测试模块的安装。

2.Bundle功能指令

1.部署apks到设备中

bundletool install-apks --apks=/MyApp/my_app.apks

2.为当前连接设备生成自适应的一组apk包

//--connected-device标记功能
bundletool build-apks --connected-device 
//多设备连接需要指定设备id
--device-id=serial-id
--bundle=/MyApp/my_app.aab --output=/MyApp/my_app.apks

3.获取设备json文件以及使用json文件

//1.当没有json文件,想通过设备获取其适配的json
bundletool get-device-spec --output=/tmp/device-spec.json

//2.已有json文件,想让该apks遵循json文件规则
bundletool build-apks --device-spec=/MyApp/pixel2.json
--bundle=/MyApp/my_app.aab --output=/MyApp/my_app.apks

json文件规范

{
  //设备支持的cpu类型
  "supportedAbis": ["arm64-v8a", "armeabi-v7a"],
  //设备支持的语言类型
  "supportedLocales": ["en", "fr"],
  //设备的像素分辨率
  "screenDensity": 640,
  //设备的sdk版本
  "sdkVersion": 27
}

4.从已有的apks包中,提取一部分特定设备的apk

bundletool extract-apks
//当前完整的apks
--apks=/MyApp/my_existing_APK_set.apks
//不希望从bundle包再去打特定设备,直接从现有的apks抽取部分形成特定设备的apks包
--output-dir=/MyApp/my_pixel2_APK_set.apks
--device-spec=/MyApp/bundletool/pixel2.json

5.估算apks的大小

bundletool get-size total --apks=/MyApp/my_app.apks

实现本地测试

情景:希望本地就能测试apks包功能,不希望上架google play测试

为了实现这种测试情况,需要有以下前提

1.集成Google Play Core库

官方地址:https://developer.android.google.cn/guide/playcore?hl=zh-cn#include_playcore

1.app/build.gradle配置

dependencies {
    // This dependency is downloaded from the Google’s Maven repository.
    // So, make sure you also include that repository in your project's build.gradle file.
    implementation 'com.google.android.play:core:1.10.0'

    // For Kotlin users also add the Kotlin extensions library for Play Core:
    implementation 'com.google.android.play:core-ktx:1.8.1'
    ...
}

2.开发环境配置要求

a.Android Studio4.0或更高版本

b.sdk playform版本29或更高

c.sdk管理器中的CMake和NDK版本下载

d.play-core-native-sdk-1.10.0.zip下载,https://dl.google.com/games/play/core/play-core-native-sdk-1.10.0.zip?hl=zh-cn,若果maven过了可忽略

e.app/build.gradle补充

apply plugin: 'com.android.application'

// Define a path to the extracted Play Core SDK files.
// If using a relative path, wrap it with file() since CMake requires absolute paths.
//如果使用sdk相对路径要用file,否则直接填写绝对路径
def playcoreDir = file('../path/to/playcore-native-sdk')

android {
    defaultConfig {
        ...
        externalNativeBuild {
          //cmake使用
            cmake {
                // Define the PLAYCORE_LOCATION directive.
                arguments "-DANDROID_STL=c++_static",
                          "-DPLAYCORE_LOCATION=$playcoreDir"
            }
        }
      指定ndk支持的cpu类型
        ndk {
            // Skip deprecated ABIs. Only required when using NDK 16 or earlier.
            abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
        }
    }
    buildTypes {
        release {
            // Include Play Core Library proguard config files to strip unused code while retaining the Java symbols needed for JNI.
            //加上混淆规则,防止playcore核心库代码被混淆找不到指定文件
            proguardFile "$playcoreDir/proguard/common.pgcfg"
            proguardFile "$playcoreDir/proguard/per-feature-proguard-files"
            ...
        }
        debug {
            ...
        }
    }
    externalNativeBuild {
        cmake {
          //放在项目main目录的CMakeLists.txt文件
            path 'src/main/CMakeLists.txt'
        }
    }
}

dependencies {
    // Use the Play Core AAR included with the SDK.
    //下载的aar包,可替换成maven库
    implementation files("$playcoreDir/playcore.aar")
    ...
}

CMakeLists.txt文件内容

cmake_minimum_required(VERSION 3.6)

...

# Add a static library called “playcore” built with the c++_static STL.
include(${PLAYCORE_LOCATION}/playcore.cmake)
add_playcore_static_library()

// In this example “main” is your native code library, i.e. libmain.so.
add_library(main SHARED
        ...)

target_include_directories(main PRIVATE
        ${PLAYCORE_LOCATION}/include
        ...)

target_link_libraries(main
        android
        playcore
        ...)

2.使用--local-testing标记

//--local-testing标记,申明本地
bundletool build-apks --local-testing
  --bundle my_app.aab
  --output my_app.apks
//直接安装的包就是支持本地测试的包了
bundletool install-apks --apks my_app.apks

3.模拟play store的网络错误情况

当通过--local-testing标记,并将其部署到测试设备后,可以在应用中调用play core库中的FakeSplitInstallManager类,来模拟网络请求连接错误。

示例:

// 通过FakeSplitInstallManagerFactory工厂类,传入上下文,获取到fakeSplitInstallManager
val fakeSplitInstallManager = FakeSplitInstallManagerFactory.create(context)
//告诉核心库,我要模拟网络请求连接错误的情况
fakeSplitInstallManager.setShouldNetworkError(true)

3.Dynamic Feature APK

提问:上述描述的只是将bundle包,根据不同设备资源所需,生成的apks包,即开始描述的资源包,那Dynamic Feature APK(动态分发包)如何跟实际业务逻辑结合实现呢?

首先,在了解bundle时,提出来Dynamic Feature APK是在base APK基础上实现的,也就是所有Dynamic Feature module都是implementation project(":app")。

其次,bundel通过在 清单文件中使用dist: XML这种命名空间形式,来定义不同属性,这些行为称之有对应的功能清单属性,依据属性说明配置。

最后,自定义 Feature Delivery,是用于处理不同需求场景下的分发,例如安装时下载、使用时下载等情景。

1.Dynamic Feature module创建

新建流程

  1. 如需打开 New Module 对话框,请从菜单栏中依次选择 File > New > New Module
  2. 在 New Module 对话框中,选择 Dynamic Feature Module,然后点击 Next
  3. 像往常一样配置模块,然后点击 Next

现在来查看module中已创建的文件

android{
   dynamicFeatures = [':dynamicfeature']
}

//dynamicfeature/build.gradle
//plugins 在这里等同与apply plugin: 'com.android.dynamic-feature'
plugins {
    //  申明说我时一个Dynamic Feature Module
    id 'com.android.dynamic-feature'
}
android{
   defaultConfig {
      // 这里是你的模块应用id,跟清单文件中的package对应,dynamicfeature为模块名
      applicationId "com.example.dynamicfeature"
   }
}
dependencies {
    // 自动新增此依赖,因为所有的Dynamic Feature Module都是基于base module的
    implementation project(":app")
}
  
        
    
    
  
    
        
        
            
            
            
              
                  
                
                
                  
                  
                
                
                    
                
                
                            
                    
            
        
        
    

dynamicFeatures清单文件表

属性 说明
xmlns:dist="http://schemas.
android.com/apk/distribution"
指定一个新的 dist: XML 命名空间,如下所述。
split="split_name" 模块名,通常在清单文件中的package结尾处
`android:isFeatureSplit="true false">` 当 Android Studio 构建 App Bundle 时,会包含该属性。因此,您不应手动添加或修改此属性。指定此模块为功能模块。 基本模块和配置 APK 中的清单要么省略该属性,要么将其设置为 false
这一新的 XML 元素定义了一些属性,这些属性可确定如何打包模块并作为 APK 分发。
`dist:instant="true false"` 指定是否应通过 Google Play 免安装体验为模块启用免安装体验。如果应用包含一个或多个启用免安装体验的功能模块,您也必须为基本模块启用免安装体验。如果您使用的是 Android Studio 3.5 或更高版本,当您创建启用免安装体验的功能模块时,IDE 会为您完成此操作。在设置 时,不能将此 XML 元素设置为 true。不过,根据免安装体验的运作方式,您仍可使用 Google Play Core 库按需下载启用免安装体验的功能模块。当用户下载并安装您的应用时,设备会默认下载并安装启用免安装体验的功能模块以及基本 APK。
dist:title="@string/feature_name" 为模块指定一个面向用户的名称。例如,当设备请求确认下载时,便可能会显示该名称。您需要将此名称的字符串资源包含在基本模块的 module_root/src/source_set/res/values/strings.xml 文件中。
`false" />` 指定是否在面向搭载 Android 4.4(API 级别 20)及更低版本的设备的 multi-APK 中包含此模块。此外,当您使用 bundletool 从 App Bundle 生成 APK 时,只有将此属性设置为 true 的功能模块才会包含在通用 APK 中。通用 APK 是一个单体式 APK,其中包含了应用所支持的所有设备配置的代码和资源。
封装自定义模块分发的选项,如下所示:请注意,每个功能模块必须只配置这些自定义分发选项中的一种类型。
指定模块应在安装时可用。对于未指定自定义分发选项的其他类型的功能模块,这是默认行为。如需详细了解安装时下载,请参阅配置安装时分发。此节点还可以指定条件,用于限定要下载模块的设备所需满足的某些要求,例如设备功能,用户所在国家/地区或最低 API 级别。如需了解详情,请参阅配置按条件分发。
`false" />` 当未设置或设置为 false 时,bundletool 会在根据 bundle 生成拆分 APK 时将安装时模块整合到基本模块中。 由于整合会使拆分 APK 的数量减少,因此此设置可以提升应用的性能。当 removable 设置为 true 时:安装时模块将不会整合到基本模块中。如果您想要在将来卸载这些模块,请将其设置为 true。 不过,配置过多可移除的模块可能会导致应用的安装时间增加。默认为 false。只有当您想要针对某个功能模块停用整合功能时,才需要在清单中设置此值。注意:只有在使用 Android Gradle 插件 4.2 或从命令行使用 bundletool v1.0 时,才能使用此功能。
指定模块应支持按需下载。也就是说,模块在安装时不会下载,但应用可以稍后请求下载。如需详细了解按需下载,请参阅配置按需分发。
` false">...` 如果功能模块没有生成 DEX 文件(也就是说,它不包含之后编译成 DEX 文件格式的代码),您必须执行以下操作(否则,您可能会遇到运行时错误):在功能模块的清单中将 android:hasCode 设置为 "false"。将以下内容添加到基本模块的清单中: ...

2.自定义 Feature Delivery

官方技术网址:https://developer.android.google.cn/guide/playcore/dynamic-delivery?hl=zh-cn#kotlin

分发选项 行为 示例用例 使用入门
安装时分发 默认情况下,未配置上述任何分发选项的功能模块会在安装应用时下载。 如果应用包含特定的指导 Activity(比如关于如何在购物平台上买卖商品的交互式指南),可以配置为在应用安装时默认包含该功能。但是,为了减小应用的安装大小,应用可在用户完成该指导后请求删除该功能。 清单文件中加上
按需分发 允许您的应用按需请求和下载功能模块。 如果当前应用支持的设备类型只有20%,那只需要先适配这20%的设备,之后按需增量下载。如果某些设备被淘汰,并且已无人使用,可以删除旧功能支持包,缩减安装包大小。 自己判断条件通过manager.startInstall(request)添加
按条件分发 允许您指定的用户,按需请求和下载功能模块。 如果购物平台应用的用户遍布全球,您可能需要支持仅在特定地区使用的支付方式。为了减小应用的初始下载大小,您可以创建单独的功能模块处理特定类型的支付方式,并将这些模块根据用户的注册区域视条件安装在用户设备上。 创建功能模块并配置按条件分发。
免安装分发 Google Play 免安装体验让用户无需在设备上安装 APK 即可与应用互动。用户可以通过 Google Play 商店中的“立即体验”按钮或您创建的网址体验您的应用。 假设有一款游戏,游戏的前几个关卡包含在轻量级功能模块中。您可以启用该模块的免安装体验,这样用户就可以通过网址或“立即体验”按钮体验游戏,而无需安装应用。 创建功能模块并配置免安装分发。然后,应用就可以使用 Google Play Core 库请求按需下载该模块。请注意,使用功能模块以模块化处理应用功能只是第一步。如需支持 Google Play 免安装体验,应用基本模块的下载大小和给定的启用免安装体验的功能必须满足严格的大小限制。如需了解详情,请阅读通过减少应用或游戏大小启用免安装体验。

前提:模块配置都需要用到play core(核心库),所以需要下载核心库arr包或maven

建议:在理解之前可以先结合下个内容 [3.APK包校验] 中的例子,来辅助理解。

按需模块

需求场景:假设某个具有按需模块的应用可使用设备的相机拍摄和发送图片消息,并且此按需模块在其清单中指定了 split="pictureMessages"

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

// 现在需要将pictureMessages和promotionalFilters按需添加,要先生成一个请求request
val 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
        //在应用处于前台时开启一个异步线程,用来执行startInstall()任务
    // 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)
        //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 { exception ->  ... }

延迟安装按需模块

需求场景:某些功能,例如数据统计功能,由于功能模块较大,为例不影响用户初次安装使用,首次不获取,在首次使用过程中通过后台去下载延迟安装对应模块。

//promotionalFilters,代表要被延迟加载的模块名称
splitInstallManager.deferredInstall(listOf("promotionalFilters"))

监听异步安装模块

需求场景:我希望在安装成功某些模块时,触发回调处理一些逻辑业务

// Initializes a variable to later track the session ID for a given request.
//某个安装请求的id编号,用于回调校验
var mySessionId = 0

// Creates a listener for request status updates.
// 创建我们的更新回调监听对象
val listener = SplitInstallStateUpdatedListener { state ->
    if (state.sessionId() == mySessionId) {
      // Read the status of the request to handle the state update.
    }
}

// Registers the listener.
//注册
splitInstallManager.registerListener(listener)

// When your app no longer requires further updates, unregister the listener.
//解注册
splitInstallManager.unregisterListener(listener)
...

//执行request安装请求
splitInstallManager
    .startInstall(request)
    // When the platform accepts your request to download
    // an on demand module, it binds it to the following session ID.
    // You use this ID to track further status updates for the request.
    .addOnSuccessListener { sessionId -> mySessionId = sessionId }
    // You should also add the following listener to handle any errors
    // processing the request.
    .addOnFailureListener { exception ->
        // Handle request errors.
    }

处理请求错误

需求场景:由于存在可能模块安装失败的问题,所以需要对这些错误进行处理

splitInstallManager
    .startInstall(request)
    .addOnFailureListener { exception ->
        when ((exception as SplitInstallException).errorCode) {
            // 没有网络连接
            SplitInstallErrorCode.NETWORK_ERROR -> {
                // Display a message that requests the user to establish a
                // network connection.
            }
            //请求被拒绝,当前有其他请求正在下载中
            SplitInstallErrorCode.ACTIVE_SESSIONS_LIMIT_EXCEEDED -> checkForActiveDownloads()
            ...
        }
    }

fun checkForActiveDownloads() {
    splitInstallManager
        // Returns a SplitInstallSessionState object for each active session as a List.
            //以列表形式为每个活动会话返回一个SplitInstallSessionState对象
        .sessionStates
        .addOnCompleteListener { task ->
            if (task.isSuccessful) {
                // Check for active sessions.
                for (state in task.result) {
                    if (state.status() == SplitInstallSessionStatus.DOWNLOADING) {
                        // Cancel the request, or request a deferred installation.
                        //如果当前状态是在下载中,代表其他请求正在下载,需要取消当前
                        //或者延迟安装当前的请求
                    }
                }
            }
        }
}

错误码表

错误代码 说明 建议采取的措施
ACTIVE_SESSIONS
_LIMIT_EXCEEDED
请求遭到拒绝,因为当前至少有一个请求正在下载。 检查是否有任何仍在下载的请求,如上例所示。
MODULE_UNAVAILABLE Google Play 无法根据当前安装的应用版本、设备和用户的 Google Play 帐号找到所请求的模块。 如果用户无权访问该模块,请通知他们。
INVALID_REQUEST Google Play 已收到请求,但该请求无效。 验证请求中包含的信息是否完整准确。
SESSION_NOT_FOUND 找不到指定会话 ID 对应的会话。 如果您尝试通过会话 ID 监控请求的状态,请确保会话 ID 正确无误。
API_NOT_AVAILABLE 当前设备不支持 Play Core 库。也就是说,该设备无法按需下载和安装功能。 对于搭载 Android 4.4(API 级别 20)或更低版本的设备,您应在安装时使用 dist:fusing 清单属性添加功能模块。如需了解详情,请参阅功能模块清单。
ACCESS_DENIED 由于权限不足,应用无法注册该请求。 通常,当应用在后台运行时,会出现这种情况。在应用返回到前台时尝试请求。
NETWORK_ERROR 由于出现网络连接错误,请求失败。 提示用户建立网络连接或更改为其他网络。
INCOMPATIBLE_WITH
_EXISTING_SESSION
该请求包含一个或多个已请求但尚未安装的模块。 创建一个新请求,该请求不包含应用已请求的模块,或等待所有当前已请求的模块完成安装,然后再重试请求。请注意,请求已安装的模块无法解决错误。
SERVICE_DIED 负责处理请求的服务已终止。 请重试请求。此错误代码会作为对 SplitInstallStateUpdatedListener(其状态为 FAILED,会话 ID 为 -1)的更新提供。

处理状态更新

需求场景:当更新模块时,需要对模块进度信息进行反馈,那么在之前监听的基础上,根据不同的安装状态,来进行信息反馈。

SplitInstallStateUpdatedListener中的onStateUpdate

override fun onStateUpdate(state : SplitInstallSessionState) {
    if (state.status() == SplitInstallSessionStatus.FAILED
        && state.errorCode() == SplitInstallErrorCode.SERVICE_DIES) {
       // Retry the request.
       // 安装失败重试
       return
    }
    if (state.sessionId() == mySessionId) {
        when (state.status()) {
            SplitInstallSessionStatus.DOWNLOADING -> {
              val totalBytes = state.totalBytesToDownload()
              val progress = state.bytesDownloaded()
              // Update progress bar.
              //下载中更新进度条
            }
            SplitInstallSessionStatus.INSTALLED -> {
                            //在此处,你可以调用你即将跳转的Activity界面,并且可以访问安装后模块
              //的所有资源,如果你设置来demand模式按需安装,在8.0或之上的系统,需要
              //使用SplitInstallHelper的api更新上下文context
              
              // After a module is installed, you can start accessing its content or
              // fire an intent to start an activity in the installed module.
              // For other use cases, see access code and resources from installed modules.

              // If the request is an on demand module for an Android Instant App
              // running on Android 8.0 (API level 26) or higher, you need to
              // update the app context using the SplitInstallHelper API.
            }
        }
    }
}

模块安装状态表

请求状态 说明 建议采取的措施
PENDING 已接受该请求,即将开始下载。 初始化界面组件(例如进度栏),向用户提供关于下载的反馈。
REQUIRES_USER
_CONFIRMATION
下载需要用户确认。这很可能是由于下载内容大小超过 10 MB。 提示用户接受下载请求。如需了解详情,请转到有关如何获取用户确认的部分。
DOWNLOADING 下载正在进行中。 如果您为下载提供了进度条,请使用 SplitInstallSessionState.bytesDownloaded()SplitInstallSessionState.totalBytesToDownload() 方法更新界面(请参见此表上方的代码示例)。
DOWNLOADED 设备已下载模块,但尚未开始安装。 应用应启用 SplitCompat,以便访问已下载的模块并避免出现此状态。必须执行此操作才能访问功能模块的代码和资源。
INSTALLING 设备当前正在安装该模块。 更新进度条。此状态通常较短。
INSTALLED 该模块已安装在设备上。 访问模块中的代码和资源以继续用户操作流程。如果该模块针对的是在 Android 8.0(API 级别 26)或更高版本设备上运行的 Android 免安装应用,您需要使用 splitInstallHelper 才能利用新模块更新应用组件。
FAILED 在模块安装到设备上之前,请求已失败。 提示用户重试请求或取消请求。
CANCELING 设备正在取消请求。 如需了解详情,请转到有关如何取消安装请求的部分。
CANCELED 请求已取消。

获取用户确认

需求场景:用户当前在app上使用移动数据流量,由于新功能模块包需要流量数据,要经用户同意后才允许下载。

override fun onSessionStateUpdate(state: SplitInstallSessionState) {
    if (state.status() == SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION) {
        // Displays a dialog for the user to either “Download”
        // or “Cancel” the request.
        // 显示选择对话框
        splitInstallManager.startConfirmationDialogForResult(
          state,
          /* activity = */ this,
          // You use this request code to later retrieve the user's decision.
          /* requestCode = */ MY_REQUEST_CODE)
    }
    ...
 }

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
  if (requestCode == MY_REQUEST_CODE) {
    // Handle the user's decision. For example, if the user selects "Cancel",
    // you may want to disable certain functionality that depends on the module.
    //在此处处理回调
  }
}

请求的状态会根据用户响应进行更新:

  • 如果用户选择“下载”,请求状态会更改为 PENDING 并继续下载。
  • 如果用户选择“取消”,请求状态会更改为 CANCELED
  • 如果用户在对话框被销毁之前未做出选择,请求状态会保持为 REQUIRES_USER_CONFIRMATION。您的应用可能会再次提示用户完成请求。

访问模块

需求场景:如需在下载后从已下载的模块访问代码和资源,您的应用需要为应用和应用下载的功能模块中的每个 Activity 启用 SplitCompat 库。例如,下载的模块b中存在我要启动的ActivityB,为了访问到ActivityB,我需要启动SplitCompat库。

1.启动SplitCompat库:

方式1.如需启用 SplitCompat,最简单的方法是在您的应用清单中将 SplitCompatApplication 声明



方式2.在运行时调用SplitCompat

class MyApplication : SplitCompatApplication() {
    override fun attachBaseContext(base: Context) {
    super.attachBaseContext(base)
    // 判断模块是否安装,如果您的按需模块可同时与免安装应用和安装式应用兼容
    if (!InstantApps.isInstantApp(this)) {
        // Emulates installation of future on demand modules using SplitCompat.
            // 在此处调用,以便获取正确的context
        SplitCompat.install(this)
    }
    }
}

2.为Activity启用SplitCompact

//ActivityB中
override fun attachBaseContext(base: Context) {
    super.attachBaseContext(base)
    // Emulates installation of on demand modules using SplitCompat.
    SplitCompat.installActivity(this)
}

访问模块代码和资源

需求场景1:对应的模块已经安装完成,我需要访问模块内的资源,例如ActivityB,只需要获取到最新的上下文就可以跳转访问

override fun onStateUpdate(state: SplitInstallSessionState ) {
    if (state.sessionId() == mySessionId) {
        when (state.status()) {
            ...
            SplitInstallSessionStatus.INSTALLED -> {
                // 使用createPackageContext获取新的上下文
                val newContext = context.createPackageContext(context.packageName, 0)
                //newContext :Context就是最新的可用上下文
                val am = newContext.assets
            }
        }
    }
}

需求场景2 :Android 8.0 及更高版本上的 Android 免安装应用

override fun onStateUpdate(state: SplitInstallSessionState ) {
    if (state.sessionId() == mySessionId) {
        when (state.status()) {
            ...
            SplitInstallSessionStatus.INSTALLED -> {
                                //版本大于8.0
                if (BuildCompat.isAtLeastO()) {
                                        //由于时面安装应用,所以不能使用createPackageContext
                    //所以需要使用updateAppInfo来实现上下文的更新
                    SplitInstallHelper.updateAppInfo(context)
                    Handler().post {
                        // Loads contents from the module using AssetManager
                        val am = context.assets
                        ...
                    }
                    //使用免安装c库
                    SplitInstallHelper.loadLibrary(newContext, “my-cpp-lib”)
                }
            }
        }
    }
}

管理已安装模块

需求场景1:当前想知道设备已安装的功能模块

val installedModules: Set = splitInstallManager.installedModules

需求场景2: 想要卸载某些模块

//pictureMessages即模块名,申明在清单文件的 package 中的最后一个单词
splitInstallManager.deferredUninstall(listOf("pictureMessages", "promotionalFilters"))

管理语言安装包

需求场景1: 下载某些语言资源

sharedPrefs.edit().putString(LANGUAGE_SELECTION, "zh").apply()

// 创建请求,添加语言,包含“zh-CN、zh-TW”的所有“zh”资源
val request = SplitInstallRequest.newBuilder()
 .addLanguage(Locale.forLanguageTag(sharedPrefs.getString(LANGUAGE_SELECTION)))
        .build()

// Submits the request to install the additional language resources.
// 执行语言请求安装包
splitInstallManager.startInstall(request)

需求场景2: 访问已下载的语言资源

//1.Activity中
override fun attachBaseContext(base: Context) {
  super.attachBaseContext(base)
  SplitCompat.installActivity(this)
}

//2.application中
override fun attachBaseContext(base: Context) {
  val configuration = Configuration()
  configuration.setLocale(Locale.forLanguageTag(sharedPrefs.getString(LANGUAGE_SELECTION)))
  val context = base.createConfigurationContext(configuration)
  super.attachBaseContext(context)
  SplitCompat.install(this)
}

//3.使语言生效
when (state.status()) {
  SplitInstallSessionStatus.INSTALLED -> {
      // Recreates the activity to load resources for the new language
      // preference.
        // 需要重新加载Activity
      activity.recreate()
  }
  ...
}

需有场景3: 卸载语言资源

//1.查看已安装语言
val installedLanguages: Set = splitInstallManager.installedLanguages
//2.卸载指定语言
splitInstallManager.deferredLanguageUninstall(
    Locale.forLanguageTag(sharedPrefs.getString(LANGUAGE_SELECTION)))

3. APK包校验

全量apks

1.生成全量apks集合包

//生成bundle包
bundletool build-apks --bundle=/MyApp/my_app.aab --output=/MyApp/my_app.apks

2.解压apks包,查看apk的完整性

image-20210507184501204.png
image-20210507184903071.png

解压以后会有俩个文件夹instant和splits,如果sdk版本支持低于21,即还会有另外一个standlones文件,该文件不支持按需加载。instant目录代表支持免安装的apk资源,而splits,则是按需加载的apk资源,其中我们的自适应功能包的apk会在其中。

所以,我们需要检查是否对应的Dynamic Feature module中是否有与之对应的apk包。

生成设备所需apks

//1.cd到指定目录
cd /Users/qiushujie/AndroidStudioProjects/app-bundle-samples-master/DynamicFeatures/app/build/outputs/bundle/debug
//2.利用bundle指令--connected-device 模拟手机通过play store安装的apks包
bundletool build-apks --connected-device --bundle=app-debug.aab --output=app-debug.apks 

//3.自动会提示你当前用的是debug.keystore,正式key参照其余指令,此时已生成apks
INFO: The APKs will be signed with the debug keystore found at '/Users/qiushujie/.android/debug.keystore'.
  
//4.将apks安装到手机中,前提:adb命令通畅,通过adb version校验
bundletool install-apks --apks=app-debug.apks
The APKs have been extracted in the directory: /var/folders/53/x84c5smn67v0_gbpvmy1dv3w0000gn/T/2817755201777769660

//5.通过命令查看当前目录下apks的大小,可以明显看到少了将近16MB
ls -l
total 25864
-rw-------  1 qiushujie  staff  7401480 May  8 12:17 app-debug.aab
-rw-------  1 qiushujie  staff  5835855 May  8 15:25 app-debug.apks

了解apks和module的关联

image-20210508161627475.png

以官方提供的demo为例,instant目录下,由于根据设备生成,所以生成了master、xxhdpi、zh对应base module。另外,底下的split和url module,代表着这俩个模块允许下载免安装使用,接下里我们看下它们清单文件中的配置信息。


        
    
    
        
        
            
        
    

image-20210508162712842.png

接下来看下splits目录,splits代表着该目录底下都是对应的dynamic-feature类型的module,但有一点特殊的地方,红框内生成的base模块下的master、xxhdpi、zh,跟instant中大小都是一致的,除此之外的其余包,都可以认为是dynamic-feature类型的module生成的apk包。




    
    
        
    

清楚了包的生成和命名,接下来就是master和xxhdpi的理解,每个module都有自己的master包,xxhdpi时根据设备分辨率生成的包,这里有一个需要注意的,当涉及到ndk时,还有一个arm64_v8a包,现今手机基本都是arm系列64位的,所以这个包也是最常见的包。

场景:当我们需要调用native module中的c库方法,那就需要额外添加代码

SplitInstallHelper.loadLibrary(this, "hello-jni")

分析官方Demo

官方Demo地址:https://github.com/android/app-bundle-samples/tree/master/DynamicFeatures

1.生成bundle包,并打本地测试包

在打测试包之前,需要通过顶部菜单Build>>Build Bundle/APK(s)>>Build Bundle(s)生成bundle包。

//与之前打apks的命令一致,新增了--local-testing
bundletool build-apks --connected-device --local-testing --bundle=app-debug.aab --output=app-debug.apks 
//安装
bundletool install-apks --apks=app-debug.apks
2.打开app,查询当前已安装模块
private lateinit var manager: SplitInstallManager
//还是通过工厂类获取manager
manager = SplitInstallManagerFactory.create(this)

private fun getCurInstallModule(){
    //manager.installedModules是获取当前安装module的方法,返回set
    Log.e(TAG, "getCurInstallModule: ${manager.installedModules}")
}

执行完上述代码后,我们能知道默认已安装的module为,[initialInstall, split, url]

在了解apks和module关联时,有提到instant目录拥有split、url,而initialInstall是在splits目录下,仔细查询了下,在主入口时并没有进行安装处理,但在initialInstall的清单文件中发现了些端倪。


    
    
    
        
    

3.访问未安装模块kotlin
// 判断是否已安装,安装执行跳转
if (manager.installedModules.contains(name)) {
  jumpKotlinAcitivity()
  return
}
// 未安装新建请求
val request =
    SplitInstallRequest
        .newBuilder()
        .addModule("kotlin")
        .build()
//执行安装请求
private lateinit var manager: SplitInstallManager
manager.startInstall(request)

//参照Feature Delivery 监听异步安装模块
private val listener = SplitInstallStateUpdatedListener { state ->
   //判断state.status(),通常为1 pengding > 2 INSTALLING > 5 INSTALLED
    when (state.status()) { 
        SplitInstallSessionStatus.INSTALLED -> {
                    //判断是否为语言安装
          if (langsInstall) {
            onSuccessfulLanguageLoad(names)
          } else {
            //回调跳转, jumpKotlinAcitivity()
            onSuccessfulLoad(names, launch = !multiInstall)
          }
        }
    }                                                      
}
manager.registerListener(listener)

执行完上述代码后,我们能知道默认已安装的module为,[initialInstall, split, url, kotlin]

4.切换新语言
// 是否存在对应语言包
if (manager.installedLanguages.contains(lang)) {
    //安装语言包成,执行recreate()重初始化界面
    onSuccessfulLanguageLoad(lang)
    return
}

//执行下载语言包请求
val request = SplitInstallRequest.newBuilder()
        .addLanguage(Locale.forLanguageTag(lang))
        .build()
manager.startInstall(request)

//langsInstall为true,走之前的SplitInstallStateUpdatedListener

执行完之后,在installedLanguages中,就能打印出对应“lang”的module了

5.访问其他Module的资源
// 1.与访问kotlin模块一样的安装方式,访问assets模块
// 2.成功时回调displayAssets
private fun displayAssets() {
        // 通过重新获取context,并且启动SplitCompat库
    val assetManager = createPackageContext(packageName, 0).also {
        SplitCompat.install(it)
    }.assets
    
    // 当前assetManager此时时通过context.getAssets()获取的
    // 如果获取资源,可以用context.getResources()获取res文件资源
  
    //读取assets/assets.text文本
    val assetsStream = assetManager.open("assets.txt")
    val assetContent = assetsStream.bufferedReader()
            .use {
                it.readText()
            }
    
    //将assets.text的文本以弹窗显示
    AlertDialog.Builder(this)
            .setTitle(getString(R.string.asset_content))
            .setMessage(assetContent)
            .show()
}
// 纯资源无java生成的dex文件,可加上此标识

6.为特定的sdk版本新增模块

    
    
        
            
                
            
        
    

与安装其他模块一致,只有当手机版本大于等于23,即6.0时才会安装此module的apk包,可用于版本特殊处理。

4.组件化应用

Bundle是一种很好的打包方式,为了利用好该方式,对于模块的组件化,有更高要求,那么我们就需要思考,如何更好的将组件化和bundle的Dynamic-Feature模块结合好。

模块划分

1.App module

该模块用于最基础的apk打包,即集成了应用的基本功能。由于组件化+arouter(阿里路由框架),需要一个空壳模块来符合组件化的设计理念,并且大量的基本功能业务逻辑,也不允许我们将app作为一个单独的module开发。所以app模块在我们设计中,应该是一个空壳,该空壳会去持有俩类基本module,第一个是定制的UI module,第二个是拥有的Basic Feature module。

2.UI module

指的是涉及到我们公共UI的模块,例如主入口界面、通用的Dialog、Fragment界面等。由于涉及界面交互时,通畅有网络请求、数据处理、工具类或自定义View的使用、拓展方法等,所以UI module需要持有一层common module,即公用的模块,这些模块大体可以分为Network(网络请求和网络请求涉及的bean类)、Utils(工具类、kotlin拓展方法)、Common(base Activity等UI基础类、动态通用弹窗、自定义View)、Resource(颜色表、公用资源、风格资源)

3.Basic Feature module

指的是基础功能模块,例如Pay module、Bluetooth module,都可以作为摸个单纯的功能模块。在设计这些模块的时候,需要注意的是外部调用,我们需要把支付流程或蓝牙的一切行为逻辑,抽取对应的接口或抽象类出来,通过传入其实现类,实现某个功能的黑盒操作,例如蓝牙自动打开、扫描、配对、回调可以通信,这一切流程都在bluetooth中完成。

4.Network module

网络模块,主要涉及接口的定义修改,该模块只含UI module中基础所需的网络请求,如果涉及到Feature module的网络请求,则可以在该module中单独开network包实现,避免随着功能module的网络请求需求,而频繁修改Network module。

5.Utils module

该模块涉及所有可以公用的工具类、kotlin可访问到的bean类的拓展类,特殊bean类的拓展,在其功能模块中单独持有utils包。

6.Common module

该module中持有BaseActivity、BaseFragment等一系列基础类、以及它们的部分子类,同时持有各种自定义View、通用弹窗等。该模块可以默认持有Resource module,由于Common module大概率需要被其他module所持有,所以可以让其与Resource module绑定一起。

7.Dynamic-Feature module

自适应功能模块默认是要持有app的,本意是在app 的基础上做一定的功能定制化,每个功能效果不一,并且支持bundle打包之后能做到增量下载。所以,当存在某个需求,例如蓝牙设备的支持,由于每个蓝牙型号其交互协议和交互逻辑可能存在不同,针对公用的逻辑我们可以在app module中的bluetooth module定义,而需要定制逻辑处理时,那就需要利用拓展类实现,这些拓展类就是Dynamic-Feature module中涵盖的内容。

模块通信

1.路由通信

interface RouterPath {
    companion object {
        //mobile
        const val MOBILE_HOME = "/mobile/home_activity"
        //tablet
        const val TABLET_HOME = "/tablet/home_activity"
                //pay
        const val PAY_ACTIVITY = "/pay/activity"
    }
}

fun open(path: String, requestCode: Int = 0, action: Postcard.() -> Unit = {}) {
        val postcard = ARouter.getInstance().build(path)
        postcard.action()
        postcard.navigation(this, requestCode)
}

定义路径名,然后在对应的类中新增注解@Route(path = RouterPath.MOBILE_HOME),之后通过Arouter的navigation()方法跳转到不同模块的界面,从而实现跨模块的跳转

2.Dynamic-Feature module使用须知

a.引用app module资源时,不能直接使用R.drawdble 需要使用 [base moudle packagename].R.drawdble的方式

b.app module无法访问Dynamic中的资源id,原因俩个模块相同id,会在arssc中生成不一样的值。

c.当加载完毕Dynamic-Feature module,需要启动SplitCompat库之后,才能访问跳转module中的页面或资源

d.当加载的Dynamic-Feature module apk大于10MB时,需要使用用户确认功能才能进行加载。

e.如果Dynamic-Feature module持有module A,moduleA中拥有ActivityA,那app module中不能访问到ActivityA。

f.要清楚的知道几种安装方式,免安装instant = true,安装包时安装dist:install-time,按需安装dist:on-demand,dist:fusing dist:include="true"支持19-21sdk版本

你可能感兴趣的:(Android Bundle包全过程详解)