Android新特性——App Bundles

在您的项目中添加 Play 核心库

在开始使用 Play 核心库之前,您需要先将其作为 Gradle 依赖项导入应用模块,如下所示:

    // In your app’s build.gradle file:
    ...
    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.6.4'
        ...
    }
    

First, open app/build.gradle and add the following inside the android {} block:

bundle {
   language {
       enableSplit = true
   }
   density {
       enableSplit = true
   }
   abi {
       enableSplit = true
   }
}

This ensures that language, density, and abi configuration splits are all enabled.

请求按需模块

当您的应用需要使用动态功能模块时,它可以通过 SplitInstallManager 类在前台进行请求。在发起请求时,您的应用需要指定由目标模块清单中的 split 元素所定义的模块名称。当您使用 Android Studio 创建动态功能模块时,编译系统会使用您提供的模块名称,在编译时将该属性注入模块清单中。如需了解详情,请参阅动态功能模块清单

例如,假设某个具有按需模块的应用可使用设备的相机拍摄和发送图片消息,并且此按需模块在其清单中指定了 split="pictureMessages"。以下示例使用 SplitInstallManager 来请求 pictureMessages 模块(以及用于一些宣传过滤器的其他模块):

KOTLINJAVA

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

    // Creates a request to install a module.
    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
        // Submits the request to install the module through the
        // asynchronous startInstall() task. Your app needs to be
        // in the foreground to submit the request.
        .startInstall(request)
        // You should also be able to gracefully handle
        // request state changes and errors. To learn more, go to
        // the section about how to Monitor the request state.
        .addOnSuccessListener { sessionId -> ... }
        .addOnFailureListener { exception ->  ... }
    

当您的应用请求按需模块时,Play 核心库会采用“即发即弃”策略。也就是说,它会发送请求以将该模块下载到平台,但不会监控安装是否成功。要在安装后继续用户操作流程或妥善处理错误,请务必监控请求状态

注意:您可以请求已安装在设备上的动态功能模块。如果检测到该模块已安装,则 API 会立即将该请求视为已完成。此外,安装模块后,Google Play 会自动使其保持最新状态。也就是说,当您上传新版 App Bundle 时,平台会更新所有属于您应用的已安装 APK。如需了解详情,请参阅管理应用更新

要立即访问模块的代码和资源,您的应用需要启用 SplitCompat。请注意,Android 免安装应用不需要使用 SplitCompat,因为他们可以立即访问功能模块。

延迟安装按需模块

如果您不需要应用立即下载并安装按需模块,可以延迟到应用在后台运行时再安装该模块。例如,您想要预先加载一些宣传材料并在之后启动应用时使用这些材料。

您可以使用 deferredInstall() 方法指定之后要下载的模块,如下所示。而且,与 SplitInstallManager.startInstall() 不同,您的应用无需在前台就可发起延迟安装请求。

KOTLINJAVA

    // Requests an on demand module to be downloaded when the app enters
    // the background. You can specify more than one module at a time.
    splitInstallManager.deferredInstall(listOf("promotionalFilters"))
    

收到延迟安装请求后,系统将尽力而为,您无法跟踪其进度。因此,在尝试访问您已指定为延迟安装的模块之前,请检查该模块是否已安装。如果您需要立即使用该模块,请改为使用 SplitInstallManager.startInstall() 进行请求,如上一部分中所示。

监控请求状态

为了能够更新进度条,在安装后触发 Intent 或者妥善处理请求错误,您需要监听来自异步 SplitInstallManager.startInstall() 任务的状态更新。要开始接收安装请求更新,请先注册监听器并获取该请求的会话 ID,如下所示。

KOTLINJAVA

    // Initializes a variable to later track the session ID for a given request.
    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)

    ...

    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.
        }

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

处理请求错误

您应使用 addOnFailureListener() 妥善处理下载或安装模块时出现的失败,如下所示:

KOTLINJAVA

    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.
            .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 核心库。 也就是说,该设备无法按需下载和安装功能。 对于搭载 Android 4.4(API 级别 20)或更低版本的设备,您应在安装时使用 dist:fusing 清单属性添加动态功能模块。要了解详情,请参阅动态功能模块清单
ACCESS_DENIED 由于权限不足,应用无法注册该请求。 当应用在后台运行时,会出现这种情况。在应用返回到前台时尝试请求。
NETWORK_ERROR 由于出现网络连接错误,请求失败。 提示用户建立网络连接或更改为其他网络。
INCOMPATIBLE_WITH_EXISTING_SESSION 该请求包含一个或多个已请求但尚未安装的模块。 创建一个新请求,该请求不包含应用已请求的模块,或等待所有当前已请求的模块完成安装,然后再重试请求。

请注意,请求已安装的模块无法解决错误。

SERVICE_DIED 负责处理请求的服务已终止。 请重试请求。

此错误代码会作为对 SplitInstallStateUpdatedListener(其状态为 FAILED,会话 ID 为 -1)的更新提供。

如果用户请求下载按需模块并出现错误,请考虑显示一个对话框并为用户提供如下两个选项:重试(再次尝试该请求)和取消(放弃该请求)。如需其他支持,您还应该提供帮助链接,引导用户访问 Google Play 帮助中心

注意:测试应用时,若您看到 onError(-2),则可能是因为您尚未将应用上传到 Google Play。要测试应用的按需功能,您必须通过网址分享应用或者设置开放式测试、封闭式测试或内部测试

处理状态更新

注册监听器并记录请求的会话 ID 后,请使用 StateUpdatedListener.onStateUpdate() 处理状态变更,如下所示。

KOTLINJAVA

    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 -> {

                  // 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,以便立即访问已下载的模块并避免出现此状态。否则,下载将转换到 INSTALLED,并且您的应用只能在它进入后台运行后的某个时间点访问其代码和资源。
INSTALLING 设备当前正在安装该模块。 更新进度条。此状态通常较短。
INSTALLED 该模块已安装在设备上。 访问模块中的代码和资源以继续用户操作流程。

如果该模块针对的是在 Android 8.0(API 级别 26)或更高版本设备上运行的 Android 免安装应用,则需要使用 splitInstallHelper,以利用新模块更新应用组件

FAILED 请求在模块安装到设备上之前失败。 提示用户重试请求或取消请求。
CANCELING 设备正在取消请求。 要了解详情,请转到有关如何取消安装请求的部分。
CANCELED 请求已取消。  

获取用户确认

在某些情况下,Google Play 在满足下载请求之前可能需要用户确认。例如,在请求需要下载大量内容,而设备使用的是移动数据网络的情况下。在这种情况下,请求的状态会报告 REQUIRES_USER_CONFIRMATION,您的应用需要先获得用户确认,然后设备才能下载并安装请求的模块。要获得确认,您的应用应按以下方式提示用户:

KOTLINJAVA

    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)
        }
        ...
     }
    

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

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

要通过用户的响应接收回调,请使用 onActivityResult(),如下所示。

KOTLINJAVA

    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.
      }
    }
    

取消安装请求

如果您的应用需要在安装之前取消请求,它可以使用请求的会话 ID 调用 cancelInstall() 方法,如下所示。

KOTLINJAVA

    SplitInstallManager
        // Cancels the request for the given session ID.
        .cancelInstall(mySessionId)
    

立即访问模块

要立即从已下载的模块访问代码和资源(即应用重启之前),您的应用需要为应用和其下载的动态功能模块中的每项 Activity 启用 SplitCompat 库

不过请注意,在重启应用前,该平台在访问模块内容时会受到以下限制:

  • 平台无法应用模块引入的任何新的清单条目。
  • 平台无法访问系统界面组件(如通知)的模块资源。如果您需要立即使用此类资源,请考虑将这些资源添加到应用的基本模块中。

启用 SplitCompat

要让您的应用立即从已下载模块访问代码和资源,您需要使用以下任一方法启用 SplitCompat。

为应用启用 SplitCompat 后,您还需要为动态功能模块中,您希望应用可以立即访问的每个 Activity 启用 SplitCompat

在清单中声明 SplitCompatApplication

要启用 SplitCompat,最简单的方法是在您的应用清单中将 SplitCompatApplication 声明为 Application 子类,如下所示:

<application
        ...
        android:name="com.google.android.play.core.splitcompat.SplitCompatApplication">
    </application>
    

应用安装在设备上后,您可以自动从已下载的动态功能模块访问代码和资源。

在运行时调用 SplitCompat

您还可以在运行时在特定 Activity 或服务中启用 SplitCompat。要以这种方式启用 SplitCompat,您需要在安装模块后立即启动功能模块中所包含的 Activity。为此,请替换如下所示的 attachBaseContext

如果您有自定义 Application 类,则使其改为扩展 SplitCompatApplication,以便为您的应用启用 SplitCompat,如下所示:

KOTLINJAVA

    class MyApplication : SplitCompatApplication() {
        ...
    }
    

SplitCompatApplication 仅会替换 ContextWrapper.attachBaseContext() 以包含 SplitCompat.install(Context applicationContext)。如果您不想 Application 类扩展 SplitCompatApplication,则可以手动替换 attachBaseContext() 方法,如下所示:

KOTLINJAVA

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

如果您的按需模块可同时与免安装应用和已安装的应用兼容,您可以根据具体情况调用 SplitCompat,如下所示:

KOTLINJAVA

    override fun attachBaseContext(base: Context) {
        super.attachBaseContext(base)
        if (!InstantApps.isInstantApp(this)) {
            SplitCompat.install(this)
        }
    }
    

为模块 Activity 启用 SplitCompat

为基本应用启用 SplitCompat 后,您需要为应用在动态功能模块中下载的每项 Activity 启用 SplitCompat。为此,请使用 SplitCompat.installActivity() 方法,如下所示:

KOTLINJAVA

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

从已安装的模块访问代码和资源

只要您为基本应用上下文和动态功能模块中的 Activity 启用 SplitCompat,在将按需模块的请求报告为 INSTALLED 后,您就可以开始使用其代码和资源,就像它是基本 APK 的一部分一样。

如果您想从应用的其他已安装模块访问新安装模块中存在的资源,则必须使用应用上下文执行此操作。尝试访问资源的组件上下文此时还不会更新。或者,您可以在安装动态功能模块之后重新创建该组件或在其上安装 SplitCompat。

此外,请不要在您的应用中缓存 Android ApplicationInfo 对象、该对象的内容和包含这些对象的对象。您应根据需要从应用上下文中提取这些对象。在较新版本的 Android 上安装 Split 时,缓存此类对象可能会导致应用崩溃。

访问已安装的 Android 免安装应用

当 Android 免安装应用模块报告为 INSTALLED 后,您可以使用刷新后的应用上下文来访问其代码和资源。您的应用在安装模块之前创建的上下文(例如,已存储在变量中的上下文)不包含新模块的内容。不过,新的上下文包含此类内容,您可以使用 createPackageContext 获取新的上下文。

KOTLINJAVA

    // Generate a new context as soon as a request for a new module
    // reports as INSTALLED.
    override fun onStateUpdate(state: SplitInstallSessionState ) {
        if (state.sessionId() == mySessionId) {
            when (state.status()) {
                ...
                SplitInstallSessionStatus.INSTALLED -> {
                    val newContext = context.createPackageContext(context.packageName, 0)
                    // If you use AssetManager to access your app’s raw asset files, you’ll need
                    // to generate a new AssetManager instance from the updated context.
                    val am = newContext.assets
                }
            }
        }
    }
    

Android 8.0 及更高版本上的 Android 免安装应用

在 Android 8.0(API 级别 26)或更高版本的设备上请求 Android 免安装应用的按需模块时,在安装请求报告为 INSTALLED 后,您需要通过调用 SplitInstallHelper.updateAppInfo(Context context),使用新模块的上下文来更新该应用。否则,应用不会知道该模块的代码和资源。更新应用的元数据后,您应通过调用新的 Handler,在下一个主线程事件期间加载模块内容,如下所示:

KOTLINJAVA

    override fun onStateUpdate(state: SplitInstallSessionState ) {
        if (state.sessionId() == mySessionId) {
            when (state.status()) {
                ...
                SplitInstallSessionStatus.INSTALLED -> {
                    // You need to perform the following only for Android Instant Apps
                    // running on Android 8.0 (API level 26) and higher.
                    if (BuildCompat.isAtLeastO()) {
                        // Updates the app’s context with the code and resources of the
                        // installed module.
                        SplitInstallHelper.updateAppInfo(context)
                        Handler().post {
                            // Loads contents from the module using AssetManager
                            val am = context.assets
                            ...
                        }
                    }
                }
            }
        }
    }
    

加载 C/C++ 库

如果您要从设备已下载的模块加载 C/C++ 库,请使用 SplitInstallHelper.loadLibrary(Context context, String libName),如下所示:

KOTLINJAVA

    override fun onStateUpdate(state: SplitInstallSessionState) {
        if (state.sessionId() == mySessionId) {
            when (state.status()) {
                SplitInstallSessionStatus.INSTALLED -> {
                    // Updates the app’s context as soon as a module is installed.
                    val newContext = context.createPackageContext(context.packageName, 0)
                    // To load C/C++ libraries from an installed module, use the following API
                    // instead of System.load().
                    SplitInstallHelper.loadLibrary(newContext, “my-cpp-lib”)
                    ...
                }
            }
        }
    }
    

管理已安装模块

要检查设备上当前已安装的动态功能模块,您可以调用 SplitInstallManager.getInstalledModules(),它会返回已安装模块名称的 Set<String>,如下所示。

注意:如果您在开发 Android 免安装应用,则此部分不适用于您。

KOTLINJAVA

    val installedModules: Set<String> = splitInstallManager.installedModules
    

卸载模块

您可以通过调用 SplitInstallManager.deferredUninstall(List<String> moduleNames) 请求设备卸载模块,如下所示。

KOTLINJAVA

    // Specifies two dynamic feature modules for deferred uninstall.
    splitInstallManager.deferredUninstall(listOf("pictureMessages", "promotionalFilters"))
    

模块卸载不会立即发生。也就是说,设备会根据需要在后台卸载它们,以节省存储空间。您可以通过调用 SplitInstallManager.getInstalledModules() 并检查结果来确认设备是否已删除模块,如上一部分中所述。

下载其他语言资源

通过 Dynamic Delivery,设备只会下载运行应用所需的代码和资源。因此,对于语言资源,用户的设备只会下载与设备设置中当前所选的一种或多种语言相符的应用语言资源。

如果您希望应用能够访问其他语言资源(例如,实现一个应用内语言选择器),则可以使用 Play 核心库根据需要下载这些资源。该流程与下载动态功能模块的流程相似,如下所示。

KOTLINJAVA

    // Captures the user’s preferred language and persists it
    // through the app’s SharedPreferences.
    sharedPrefs.edit().putString(LANGUAGE_SELECTION, "fr").apply()
    ...

    // Creates a request to download and install additional language resources.
    val request = SplitInstallRequest.newBuilder()
            // Uses the addLanguage() method to include French language resources in the request.
            // Note that country codes are ignored. That is, if your app
            // includes resources for “fr-FR” and “fr-CA”, resources for both
            // country codes are downloaded when requesting resources for "fr".
            .addLanguage(Locale.forLanguageTag(sharedPrefs.getString(LANGUAGE_SELECTION)))
            .build()

    // Submits the request to install the additional language resources.
    splitInstallManager.startInstall(request)
    

该请求的处理方式与动态功能模块请求的处理方式相同。也就是说,您可以像平常一样监控请求状态

如果您的应用不需要立即使用其他语言资源,您可以延迟到应用在后台运行时再进行安装,如下所示。

KOTLINJAVA

    splitInstallManager.deferredLanguageInstall(
        Locale.forLanguageTag(sharedPrefs.getString(LANGUAGE_SELECTION)))
    

访问已下载的语言资源

要立即访问已下载的语言资源,您的应用需要在需要访问这些资源的每项 Activity 的 attachBaseContext() 方法内运行 SplitCompat.installActivity() 方法,如下所示。

KOTLINJAVA

    override fun attachBaseContext(base: Context) {
      super.attachBaseContext(base)
      SplitCompat.installActivity(this)
    }
    

对于您想要使用应用下载的语言资源的每项 Activity,请更新基本上下文并通过其 Configuration 设置新的语言区域:

KOTLINJAVA

    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)
    }
    

为使这些更改生效,您必须在新语言安装完毕且可供使用后重新创建 Activity。您可以使用 Activity#recreate() 方法。

KOTLINJAVA

    when (state.status()) {
      SplitInstallSessionStatus.INSTALLED -> {
          // Recreates the activity to load resources for the new language
          // preference.
          activity.recreate()
      }
      ...
    }
    

卸载其他语言资源

与动态功能模块类似,您可以随时卸载其他资源。在请求卸载之前,您可能需要先确定当前安装的语言,如下所示。

KOTLINJAVA

    val installedLanguages: Set<String> = splitInstallManager.installedLanguages
    

然后,您可以使用 deferredLanguageUninstall() 方法确定要卸载的语言,如下所示。

KOTLINJAVA

    splitInstallManager.deferredLanguageUninstall(
        Locale.forLanguageTag(sharedPrefs.getString(LANGUAGE_SELECTION)))
    

build_tool 命令:

Android设备屏幕分辨率,可以采用最快捷的方式,使用ADB命令获取
adb shell dumpsys window displays  
打印简单方式: 
adb shell wm size


java -jar C:\bundletool\bundletool-all-0.10.2.jar  build-apks --connected-device --adb=C:\Users\Admin\AppData\Local\Android\Sdk\platform-tools\adb  --bundle=C:\bundletool\aab\app-release.aab   --output=C:\bundletool\aab\app.apks
java -jar C:\bundletool\bundletool-all-0.10.2.jar  build-apks --bundle=C:\bundletool\aab\app-release.aab        --output=C:\bundletool\aab\app.apks

java -jar C:\bundletool\bundletool-all-0.10.2.jar  build-apks --connected-device --bundle=C:\bundletool\aab\app-release.aab   --output=C:\bundletool\aab\app.apks  --ks=C:\newjks\a050.jks  --ks-pass=pass:123456  --ks-key-alias=jsover  --key-pass=pass:123456

java -jar C:\bundletool\bundletool-all-0.10.2.jar   install-apks --apks=C:\bundletool\aab\app.apks
--ks=C:\newjks\test007.jks
--ks-pass=123456
--ks-key-alias=jsover
--key-pass=123456

https://developer.android.com/studio/projects/dynamic-delivery#dynamic_feature_manifest

https://blog.csdn.net/qq_42154484/article/details/80653420

https://developer.android.com/studio/command-line/bundletool

https://blog.csdn.net/qq_33404903/article/details/88052867

https://developer.android.com/topic/google-play-instant/overview(Google play  免安装)

https://codelabs.developers.google.com/codelabs/on-demand-dynamic-delivery/index.html#6

发布了74 篇原创文章 · 获赞 36 · 访问量 25万+

猜你喜欢

转载自blog.csdn.net/kdsde/article/details/104002500