Activity 嵌入

Activity embedding 通过在两个 Activity 或同一 Activity 的两个实例之间拆分应用程序的任务窗口,来优化大屏设备上的应用显示效果。

图 1. 并排显示 Activity 的设置应用。

如果您的应用包含多个 Activity,Activity embedding 可让您在平板电脑、可折叠设备和 ChromeOS 设备上提供更佳的用户体验。

Activity embedding 无需重构代码。您可以通过创建 XML 配置文件或调用 Jetpack WindowManager API 来确定应用如何显示其 Activity(并排或堆叠)。

系统会自动维护对小屏幕的支持。当您的应用在小屏幕设备上运行时,Activity 会垂直堆叠;在大屏幕上,Activity 则会并排显示。系统会根据您创建的配置确定显示方式,无需编写分支逻辑。

Activity embedding 可以适应设备方向的变化,并在可折叠设备上无缝运行,随着设备的折叠和展开自动堆叠或取消堆叠 Activity。

大多数运行 Android 12L(API 级别 32)及更高版本的大屏设备都支持 Activity embedding。

拆分任务窗口

Activity embedding 将应用的任务窗口拆分为两个容器:主容器和次容器。这些容器用于容纳从主 Activity 或已在容器中的其他 Activity 启动的 Activity。

当 Activity 被启动时,它们会在次容器中堆叠。在小屏幕上,次容器会堆叠在主容器上方,因此 Activity 的堆叠和返回导航顺序与您应用中预设的逻辑保持一致。

Activity embedding 支持多种显示方式。您的应用可以通过同时并排或上下启动两个 Activity 来拆分任务窗口。

图 2. 两个 Activity 并排和上下排列的示例。

占据整个任务窗口的 Activity 可以通过在旁边启动新 Activity 来创建拆分。

图 3. Activity A 启动 Activity B 并排显示。

已经处于拆分状态并共享任务窗口的 Activity 可以通过以下方式启动其他 Activity:

  • 在另一个 Activity 之上并排显示

    图 4. Activity A 启动 Activity C,并排显示在 Activity B 之上。
  • 在旁边显示,并侧向移动拆分区域,隐藏之前的 Activity。

    图 23. Activity B 启动 Activity C,并排显示并侧向移动了拆分区域。
  • 在当前位置上方启动 Activity;即在同一个 Activity 堆栈中。

    图 6. Activity B 在不带额外 Intent 标志的情况下启动 Activity C。
  • 在同一任务中以全窗口形式启动 Activity。

    图 7. Activity A 或 B 启动 Activity C,使其充满整个任务窗口。

返回导航

根据 Activity 之间的依赖关系或用户触发返回事件的方式,不同类型的应用在拆分任务窗口状态下可能有不同的返回导航规则,例如:

  • 共同进退:如果 Activity 彼此相关且缺一不可,则可以将返回导航配置为同时关闭两者。
  • 各行其是:如果 Activity 完全独立,则一个 Activity 上的返回导航不会影响任务窗口中另一个 Activity 的状态。

使用按钮导航时,返回事件会发送到最后获得焦点的 Activity。

对于基于手势的导航

  • Android 14 (API 级别 34) 及更低版本 — 返回事件会发送到触发手势的 Activity。当用户从屏幕左侧滑动时,返回事件发送到拆分窗口左侧面板的 Activity。当用户从右侧滑动时,事件发送到右侧面板的 Activity。

  • Android 15 (API 级别 35) 及更高版本

    • 处理来自同一应用的多个 Activity 时,无论滑动方向如何,手势都会结束最顶层的 Activity,从而提供更统一的体验。

    • 在涉及来自不同应用的两个 Activity(覆盖)的情况下,返回事件会定向到最后获得焦点的 Activity,这与按钮导航的行为一致。

多面板布局

Jetpack WindowManager 允许您在运行 Android 12L (API 级别 32) 或更高版本的大屏设备以及某些具有较旧平台版本的设备上构建 Activity embedding 多面板布局。无需重构源代码,现有的基于多个 Activity(而非 Fragment 或 SlidingPaneLayout 等视图布局)的应用即可提供改进的大屏用户体验。

常见的例子是列表-详情拆分。为确保高质量的呈现,系统启动列表 Activity,随后应用立即启动详情 Activity。过渡系统会等待两个 Activity 都绘制完成,然后将它们一起显示。对用户而言,这两个 Activity 是同时启动的。

图 8. 在多面板布局中同时启动的两个 Activity。

拆分属性

您可以指定任务窗口在拆分容器之间的比例,以及容器之间如何布局。

对于在 XML 配置文件中定义的规则,请设置以下属性:

  • splitRatio:设置容器比例。该值是一个开区间 (0.0, 1.0) 内的浮点数。
  • splitLayoutDirection:指定拆分容器的相对布局方向。值包括:
    • ltr:从左到右
    • rtl:从右到左
    • locale:根据语言区域设置决定是 ltr 还是 rtl

示例请参阅 XML 配置部分。

对于使用 WindowManager API 创建的规则,请使用 SplitAttributes.Builder 创建 SplitAttributes 对象,并调用以下构建器方法:

示例请参阅 WindowManager API 部分。

图 9. 两个从左到右布局但拆分比例不同的 Activity 拆分。

拆分方向

显示屏的尺寸和宽高比决定了 Activity 在 Activity embedding 拆分中的位置。在大尺寸横屏显示器上,Activity 并排显示;在高尺寸竖屏显示器或处于桌面模式的可折叠设备上,它们则上下排列。

您可以使用 SplitController SplitAttributes 计算器来指定拆分方向。该计算器会为活动的 SplitRule 计算 SplitAttributes

利用该计算器可以在不同的设备状态下以不同的方向拆分父容器,例如:

Kotlin

if (WindowSdkExtensions.getInstance().extensionVersion >= 2) {
    SplitController.getInstance(this).setSplitAttributesCalculator { params ->
        val parentConfiguration = params.parentConfiguration
        val builder = SplitAttributes.Builder()
        return@setSplitAttributesCalculator if (parentConfiguration.screenWidthDp >= 840) {
            // Side-by-side dual-pane layout for wide displays.
            builder
                .setLayoutDirection(SplitAttributes.LayoutDirection.LOCALE)
                .build()
        } else if (parentConfiguration.screenHeightDp >= 600) {
            // Horizontal split for tall displays.
            builder
                .setLayoutDirection(SplitAttributes.LayoutDirection.BOTTOM_TO_TOP)
                .build()
        } else {
            // Fallback to expand the secondary container.
            builder
                .setSplitType(SPLIT_TYPE_EXPAND)
                .build()
        }
    }
}

Java

if (WindowSdkExtensions.getInstance().getExtensionVersion() >= 2) {
    SplitController.getInstance(this).setSplitAttributesCalculator(params -> {
        Configuration parentConfiguration = params.getParentConfiguration();
        SplitAttributes.Builder builder = new SplitAttributes.Builder();
        if (parentConfiguration.screenWidthDp >= 840) {
            // Side-by-side dual-pane layout for wide displays.
            return builder
                .setLayoutDirection(SplitAttributes.LayoutDirection.LOCALE)
                .build();
        } else if (parentConfiguration.screenHeightDp >= 600) {
            // Horizontal split for tall displays.
            return builder
                .setLayoutDirection(SplitAttributes.LayoutDirection.BOTTOM_TO_TOP)
                .build();
        } else {
            // Fallback to expand the secondary container.
            return builder
                .setSplitType(SplitType.SPLIT_TYPE_EXPAND)
                .build();
        }
    });
}

在可折叠设备上,如果设备处于横屏模式,您可以垂直拆分屏幕;如果是竖屏模式,则显示单个 Activity;如果处于桌面模式,则水平拆分屏幕。

Kotlin

if (WindowSdkExtensions.getInstance().extensionVersion >= 2) {
    SplitController.getInstance(this).setSplitAttributesCalculator { params ->
        val tag = params.splitRuleTag
        val parentWindowMetrics = params.parentWindowMetrics
        val parentConfiguration = params.parentConfiguration
        val foldingFeatures =
            params.parentWindowLayoutInfo.displayFeatures.filterIsInstance<FoldingFeature>()
        val feature = if (foldingFeatures.size == 1) foldingFeatures[0] else null
        val builder = SplitAttributes.Builder()
        builder.setSplitType(SPLIT_TYPE_HINGE)
        return@setSplitAttributesCalculator if (feature?.isSeparating == true) {
            // Horizontal split for tabletop posture.
            builder
                .setSplitType(SPLIT_TYPE_HINGE)
                .setLayoutDirection(
                    if (feature.orientation == FoldingFeature.Orientation.HORIZONTAL) {
                        SplitAttributes.LayoutDirection.BOTTOM_TO_TOP
                    } else {
                        SplitAttributes.LayoutDirection.LOCALE
                    }
                )
                .build()
        } else if (parentConfiguration.screenWidthDp >= 840) {
            // Side-by-side dual-pane layout for wide displays.
            builder
                .setLayoutDirection(SplitAttributes.LayoutDirection.LOCALE)
                .build()
        } else {
            // No split for tall displays.
            builder
                .setSplitType(SPLIT_TYPE_EXPAND)
                .build()
        }
    }
}

Java

if (WindowSdkExtensions.getInstance().getExtensionVersion() >= 2) {
    SplitController.getInstance(this).setSplitAttributesCalculator(params -> {
        String tag = params.getSplitRuleTag();
        WindowMetrics parentWindowMetrics = params.getParentWindowMetrics();
        Configuration parentConfiguration = params.getParentConfiguration();
        List<FoldingFeature> foldingFeatures =
            params.getParentWindowLayoutInfo().getDisplayFeatures().stream().filter(
                    item -> item instanceof FoldingFeature)
                .map(item -> (FoldingFeature) item)
                .collect(Collectors.toList());
        FoldingFeature feature = foldingFeatures.size() == 1 ? foldingFeatures.get(0) : null;
        SplitAttributes.Builder builder = new SplitAttributes.Builder();
        builder.setSplitType(SplitType.SPLIT_TYPE_HINGE);
        if (feature != null && feature.isSeparating()) {
            // Horizontal slit for tabletop posture.
            return builder
                .setSplitType(SplitType.SPLIT_TYPE_HINGE)
                .setLayoutDirection(
                    feature.getOrientation() == FoldingFeature.Orientation.HORIZONTAL
                        ? SplitAttributes.LayoutDirection.BOTTOM_TO_TOP
                        : SplitAttributes.LayoutDirection.LOCALE)
                .build();
        }
        else if (parentConfiguration.screenWidthDp >= 840) {
            // Side-by-side dual-pane layout for wide displays.
            return builder
                .setLayoutDirection(SplitAttributes.LayoutDirection.LOCALE)
                .build();
        } else {
            // No split for tall displays.
            return builder
                .setSplitType(SplitType.SPLIT_TYPE_EXPAND)
                .build();
        }
    });
}

占位符

占位符 Activity 是空的次级 Activity,占据 Activity 拆分的一个区域。它们最终旨在被包含内容的另一个 Activity 取代。例如,在列表-详情布局中,占位符 Activity 可以占据拆分的次级区域,直到选中列表中的项目,此时包含所选项详细信息的 Activity 会取代占位符。

默认情况下,系统仅在有足够空间进行 Activity 拆分时才显示占位符。当显示屏尺寸变化到过窄或过短而无法进行拆分时,占位符会自动结束。当空间恢复时,系统会以重置后的状态重新启动占位符。

图 10. 可折叠设备的折叠与展开。占位符 Activity 随着显示屏尺寸变化而结束并重新创建。

但是,SplitPlaceholderRulestickyPlaceholder 属性或 SplitPlaceholder.BuildersetSticky() 方法可以覆盖默认行为。当属性或方法指定值为 true 时,如果显示屏从双面板调整为单面板,系统会将占位符显示为任务窗口中最顶层的 Activity(示例请参见拆分配置)。

图 11. 可折叠设备的折叠与展开。占位符 Activity 具有粘性。

窗口尺寸变化

当设备配置变化导致任务窗口宽度不足以进行多面板布局时(例如,大屏可折叠设备从平板模式折叠为手机模式,或在多窗口模式下调整应用窗口大小),次级面板中非占位符的 Activity 会堆叠在主面板的 Activity 之上。

占位符 Activity 仅在有足够宽度时显示。在更小的屏幕上,占位符会自动被丢弃。当显示区域足够大时,占位符会再次创建。(参见占位符部分。)

Activity 堆叠之所以可能,是因为 WindowManager 会将次级面板中的 Activity 在 Z 轴上置于主面板 Activity 之上。

次级面板中的多个 Activity

Activity B 在不带额外 Intent 标志的情况下原地启动 Activity C

Activity split containing activities A, B, and C with C stacked on
          top of B.

从而导致同一任务中的 Activity 具有以下 Z 轴顺序:

Secondary activity stack containing activity C stacked on top of B.
          Secondary stack is stacked on top of prmary activity stack
          containing activity A.

因此,在较小的任务窗口中,应用会缩减为单个 Activity,C 位于堆栈顶部。

Small window showing only activity C.

在较小窗口中向后导航会穿过堆叠在一起的 Activity。

如果任务窗口配置恢复到足以容纳多个面板的大小时,Activity 会再次并排显示。

堆叠拆分

Activity B 在旁边启动 Activity C 并侧向移动拆分区域

Task window showing activities A and B, then activities B and C.

结果是同一任务中的 Activity 具有以下 Z 轴顺序:

Activities A, B, and C in a single stack. The activities are stacked
          in the following order from top to bottom: C, B, A.

在较小的任务窗口中,应用缩减为单个 Activity,且 C 位于顶部。

Small window showing only activity C.

固定竖屏方向

清单设置 android:screenOrientation 允许应用将 Activity 限制为竖屏或横屏方向。为了提升平板电脑和可折叠设备等大屏设备上的用户体验,设备制造商 (OEM) 可以忽略屏幕方向请求,并在横屏显示器上将竖屏应用添加黑边(或反之)。

图 12. 添加黑边的 Activity:横屏设备上的固定竖屏(左),竖屏设备上的固定横屏(右)。

同样,启用 Activity embedding 时,OEM 可以自定义设备,以便在大屏幕(宽度 ≥ 600dp)的横向模式下为固定竖屏的 Activity 添加黑边。当固定竖屏的 Activity 启动第二个 Activity 时,设备可以在双面板中并排显示这两个 Activity。

图 13. 固定竖屏的 Activity A 启动 Activity B 并排显示。

务必在应用清单文件中添加 android.window.PROPERTY_ACTIVITY_EMBEDDING_SPLITS_ENABLED 属性,以通知设备您的应用支持 Activity embedding(参见拆分配置部分)。这样 OEM 定制设备就可以确定是否对固定竖屏的 Activity 添加黑边。

拆分配置

拆分规则用于配置 Activity 拆分。您可以在 XML 配置文件中或通过调用 Jetpack WindowManager API 来定义这些规则。

在这两种情况下,您的应用都必须访问 WindowManager 库,并通知系统该应用已实现 Activity embedding。

执行以下操作:

  1. 将最新的 WindowManager 库依赖项添加到应用的模块级 build.gradle 文件中,例如:

    implementation 'androidx.window:window:1.1.0-beta02'

    WindowManager 库提供了 Activity embedding 所需的所有组件。

  2. 通知系统您的应用已实现 Activity embedding。

    android.window.PROPERTY_ACTIVITY_EMBEDDING_SPLITS_ENABLED 属性添加到应用清单文件的 <application> 元素中,并将其值设为 true,例如:

    <manifest xmlns:android="http://schemas.android.com/apk/res/android">
        <application>
            <property
                android:name="android.window.PROPERTY_ACTIVITY_EMBEDDING_SPLITS_ENABLED"
                android:value="true" />
        </application>
    </manifest>
    

    在 WindowManager 1.1.0-alpha06 及更高版本中,除非在清单中添加该属性并将其设为 true,否则 Activity embedding 拆分将被禁用。

    此外,设备制造商会利用此设置,为支持 Activity embedding 的应用启用自定义功能。例如,设备可以在横屏显示器上对仅支持竖屏的 Activity 添加黑边,以便在启动第二个 Activity 时调整 Activity 方向,以进行双面板布局(参见固定竖屏方向)。

XML 配置

要创建基于 XML 的 Activity embedding 实现,请完成以下步骤:

  1. 创建一个 XML 资源文件,用于:

    • 定义共享拆分的 Activity
    • 配置拆分选项
    • 当内容不可用时,为拆分的次容器创建占位符
    • 指定绝对不应参与拆分的 Activity

    例如

    <!-- main_split_config.xml -->
    
    <resources
        xmlns:window="http://schemas.android.com/apk/res-auto">
    
        <!-- Define a split for the named activities. -->
        <SplitPairRule
            window:splitRatio="0.33"
            window:splitLayoutDirection="locale"
            window:splitMinWidthDp="840"
            window:splitMaxAspectRatioInPortrait="alwaysAllow"
            window:finishPrimaryWithSecondary="never"
            window:finishSecondaryWithPrimary="always"
            window:clearTop="false">
            <SplitPairFilter
                window:primaryActivityName=".ListActivity"
                window:secondaryActivityName=".DetailActivity"/>
        </SplitPairRule>
    
        <!-- Specify a placeholder for the secondary container when content is
             not available. -->
        <SplitPlaceholderRule
            window:placeholderActivityName=".PlaceholderActivity"
            window:splitRatio="0.33"
            window:splitLayoutDirection="locale"
            window:splitMinWidthDp="840"
            window:splitMaxAspectRatioInPortrait="alwaysAllow"
            window:stickyPlaceholder="false">
            <ActivityFilter
                window:activityName=".ListActivity"/>
        </SplitPlaceholderRule>
    
        <!-- Define activities that should never be part of a split. Note: Takes
             precedence over other split rules for the activity named in the
             rule. -->
        <ActivityRule
            window:alwaysExpand="true">
            <ActivityFilter
                window:activityName=".ExpandedActivity"/>
        </ActivityRule>
    
    </resources>
    
  2. 创建一个初始化程序 (Initializer)。

    WindowManager 的 RuleController 组件会解析 XML 配置文件并向系统提供规则。Jetpack Startup 库的 Initializer 会在应用启动时使 XML 文件对 RuleController 可用,从而确保规则在任何 Activity 启动时生效。

    要创建初始化程序,请执行以下操作:

    1. 将最新的 Jetpack Startup 库依赖项添加到您的模块级 build.gradle 文件中,例如:

      implementation 'androidx.startup:startup-runtime:1.1.1'

    2. 创建一个实现 Initializer 接口的类。

      通过将 XML 配置文件(main_split_config.xml)的 ID 传递给 RuleController.parseRules() 方法,该初始化程序可将拆分规则提供给 RuleController

      Kotlin

      class SplitInitializer : Initializer<RuleController> {
      
          override fun create(context: Context): RuleController {
              return RuleController.getInstance(context).apply {
                  setRules(RuleController.parseRules(context, R.xml.main_split_config))
              }
          }
      
          override fun dependencies(): List<Class<out Initializer<*>>> {
              return emptyList()
          }
      }

      Java

      public class SplitInitializer implements Initializer<RuleController> {
      
          @NonNull
          @Override
          public RuleController create(@NonNull Context context) {
              RuleController ruleController = RuleController.getInstance(context);
              ruleController.setRules(
                  RuleController.parseRules(context, R.xml.main_split_config)
              );
               return ruleController;
           }
      
           @NonNull
           @Override
           public List<Class<? extends Initializer<?>>> dependencies() {
               return Collections.emptyList();
           }
      }

  3. 为规则定义创建一个内容提供程序 (Content Provider)。

    androidx.startup.InitializationProvider 作为 <provider> 添加到您的应用清单文件中。包含对您的 RuleController 初始化程序实现(即 SplitInitializer)的引用:

    <!-- AndroidManifest.xml -->
    
    <provider android:name="androidx.startup.InitializationProvider"
        android:authorities="${applicationId}.androidx-startup"
        android:exported="false"
        tools:node="merge">
        <!-- Make SplitInitializer discoverable by InitializationProvider. -->
        <meta-data android:name="${applicationId}.SplitInitializer"
            android:value="androidx.startup" />
    </provider>
    

    InitializationProvider 会在应用 onCreate() 方法调用之前发现并初始化 SplitInitializer。因此,当应用的主 Activity 启动时,拆分规则即已生效。

WindowManager API

您可以通过少数 API 调用以编程方式实现 Activity embedding。请在 Application 子类的 onCreate() 方法中进行这些调用,以确保规则在任何 Activity 启动前生效。

要以编程方式创建 Activity 拆分,请执行以下操作:

  1. 创建一个拆分规则。

    1. 创建一个用于标识共享拆分之 Activity 的 SplitPairFilter

      Kotlin

      val splitPairFilter = SplitPairFilter(
          ComponentName(this, ListActivity::class.java),
          ComponentName(this, DetailActivity::class.java),
          null
      )

      Java

      SplitPairFilter splitPairFilter = new SplitPairFilter(
         new ComponentName(this, ListActivity.class),
         new ComponentName(this, DetailActivity.class),
         null
      );

    2. 将过滤器添加到过滤器集合。

      Kotlin

      val filterSet = setOf(splitPairFilter)

      Java

      Set<SplitPairFilter> filterSet = new HashSet<>();
      filterSet.add(splitPairFilter);
      ```

    3. 为拆分创建布局属性。

      Kotlin

      val splitAttributes: SplitAttributes = SplitAttributes.Builder()
          .setSplitType(SplitAttributes.SplitType.ratio(0.33f))
          .setLayoutDirection(SplitAttributes.LayoutDirection.LEFT_TO_RIGHT)
          .build()

      Java

      SplitAttributes splitAttributes = new SplitAttributes.Builder()
            .setSplitType(SplitAttributes.SplitType.ratio(0.33f))
            .setLayoutDirection(SplitAttributes.LayoutDirection.LEFT_TO_RIGHT)
            .build();

      SplitAttributes.Builder 创建一个包含布局属性的对象。

      • setSplitType():定义可用显示区域如何分配给每个 Activity 容器。比率拆分类型指定了分配给主容器的显示区域比例;次容器占据剩余的可用显示区域。
      • setLayoutDirection():指定 Activity 容器如何相对于彼此布局(主容器优先)。
    4. 构建一个 SplitPairRule

      Kotlin

      val splitPairRule = SplitPairRule.Builder(filterSet)
          .setDefaultSplitAttributes(splitAttributes)
          .setMinWidthDp(840)
          .setMinSmallestWidthDp(600)
          .setMaxAspectRatioInPortrait(EmbeddingAspectRatio.ratio(1.5f))
          .setFinishPrimaryWithSecondary(SplitRule.FinishBehavior.NEVER)
          .setFinishSecondaryWithPrimary(SplitRule.FinishBehavior.ALWAYS)
          .setClearTop(false)
          .build()

      Java

      SplitPairRule splitPairRule = new SplitPairRule.Builder(filterSet)
          .setDefaultSplitAttributes(splitAttributes)
          .setMinWidthDp(840)
          .setMinSmallestWidthDp(600)
          .setMaxAspectRatioInPortrait(EmbeddingAspectRatio.ratio(1.5f))
          .setFinishPrimaryWithSecondary(SplitRule.FinishBehavior.NEVER)
          .setFinishSecondaryWithPrimary(SplitRule.FinishBehavior.ALWAYS)
          .setClearTop(false)
          .build();

      SplitPairRule.Builder 创建并配置规则:

      • filterSet:包含拆分对过滤器,这些过滤器通过识别共享拆分的 Activity 来确定何时应用规则。
      • setDefaultSplitAttributes():将布局属性应用于规则。
      • setMinWidthDp():设置启用拆分的最小显示宽度(以与密度无关的像素 dp 为单位)。
      • setMinSmallestWidthDp():设置无论设备方向如何,启用拆分所需的两个显示尺寸中较小者的最小值(以 dp 为单位)。
      • setMaxAspectRatioInPortrait():设置显示 Activity 拆分的竖屏模式下的最大显示宽高比(高:宽)。如果竖屏宽高比超过此最大比例,无论屏幕宽度如何,拆分都将禁用。注意: 默认值为 1.4,这会导致大多数平板电脑的竖屏模式下 Activity 占据整个任务窗口。另请参阅 SPLIT_MAX_ASPECT_RATIO_PORTRAIT_DEFAULTsetMaxAspectRatioInLandscape()。横屏的默认值为 ALWAYS_ALLOW
      • setFinishPrimaryWithSecondary():设置关闭次容器中所有 Activity 对主容器中 Activity 的影响。NEVER 指示当次容器中的所有 Activity 结束时,系统不应结束主容器中的 Activity(参见结束 Activity)。
      • setFinishSecondaryWithPrimary():设置关闭主容器中所有 Activity 对次容器中 Activity 的影响。ALWAYS 指示当主容器中的所有 Activity 结束时,系统应始终结束次容器中的 Activity(参见结束 Activity)。
      • setClearTop():指定当在容器中启动新 Activity 时,是否结束次容器中的所有 Activity。false 值指定新 Activity 堆叠在次容器中已有的 Activity 之上。
    5. 获取 WindowManager RuleController 的单例实例,并添加规则。

      Kotlin

      val ruleController = RuleController.getInstance(this)
      ruleController.addRule(splitPairRule)

      Java

      RuleController ruleController = RuleController.getInstance(this);
      ruleController.addRule(splitPairRule);

    6. 当内容不可用时,为次容器创建占位符

    7. 创建一个 ActivityFilter,标识占位符与其共享任务窗口拆分的 Activity。

      Kotlin

      val placeholderActivityFilter = ActivityFilter(
          ComponentName(this, ListActivity::class.java),
          null
      )

      Java

      ActivityFilter placeholderActivityFilter = new ActivityFilter(
          new ComponentName(this, ListActivity.class),
          null
      );

    8. 将过滤器添加到过滤器集合。

      Kotlin

      val placeholderActivityFilterSet = setOf(placeholderActivityFilter)

      Java

      Set<ActivityFilter> placeholderActivityFilterSet = new HashSet<>();
      placeholderActivityFilterSet.add(placeholderActivityFilter);

    9. 创建一个 SplitPlaceholderRule

      Kotlin

      val splitPlaceholderRule = SplitPlaceholderRule.Builder(
          placeholderActivityFilterSet,
          Intent(context, PlaceholderActivity::class.java)
      ).setDefaultSplitAttributes(splitAttributes)
          .setMinWidthDp(840)
          .setMinSmallestWidthDp(600)
          .setMaxAspectRatioInPortrait(EmbeddingAspectRatio.ratio(1.5f))
          .setFinishPrimaryWithPlaceholder(SplitRule.FinishBehavior.ALWAYS)
          .setSticky(false)
          .build()

      Java

      SplitPlaceholderRule splitPlaceholderRule = new SplitPlaceholderRule.Builder(
            placeholderActivityFilterSet,
            new Intent(this, PlaceholderActivity.class)
          ).setDefaultSplitAttributes(splitAttributes)
           .setMinWidthDp(840)
           .setMinSmallestWidthDp(600)
           .setMaxAspectRatioInPortrait(EmbeddingAspectRatio.ratio(1.5f))
           .setFinishPrimaryWithPlaceholder(SplitRule.FinishBehavior.ALWAYS)
           .setSticky(false)
           .build();

      SplitPlaceholderRule.Builder 创建并配置规则:

      • placeholderActivityFilterSet:包含 Activity 过滤器,这些过滤器通过识别与占位符 Activity 关联的 Activity 来确定何时应用规则。
      • Intent:指定占位符 Activity 的启动。
      • setDefaultSplitAttributes():将布局属性应用于规则。
      • setMinWidthDp():设置启用拆分的最小显示宽度(以 dp 为单位)。
      • setMinSmallestWidthDp():设置无论设备方向如何,启用拆分所需的两个显示尺寸中较小者的最小值(以 dp 为单位)。
      • setMaxAspectRatioInPortrait():设置显示 Activity 拆分的竖屏模式下的最大显示宽高比(高:宽)。注意: 默认值为 1.4,这会导致大多数平板电脑的竖屏模式下 Activity 占据任务窗口。另请参阅 SPLIT_MAX_ASPECT_RATIO_PORTRAIT_DEFAULTsetMaxAspectRatioInLandscape()。横屏的默认值为 ALWAYS_ALLOW
      • setFinishPrimaryWithPlaceholder():设置关闭占位符 Activity 对主容器中 Activity 的影响。ALWAYS 指示当占位符结束时,系统应始终结束主容器中的 Activity(参见结束 Activity)。
      • setSticky():确定在小屏幕上,占位符 Activity 在首次以足够最小宽度的拆分形式出现后,是否应出现在 Activity 堆栈顶部。
    10. 将规则添加到 WindowManager RuleController

      Kotlin

      ruleController.addRule(splitPlaceholderRule)

      Java

      ruleController.addRule(splitPlaceholderRule);

  2. 指定绝对不应参与拆分的 Activity。

    1. 创建一个 ActivityFilter,标识应始终占据整个任务显示区域的 Activity。

      Kotlin

      val expandedActivityFilter = ActivityFilter(
          ComponentName(this, ExpandedActivity::class.java),
          null
      )

      Java

      ActivityFilter expandedActivityFilter = new ActivityFilter(
          new ComponentName(this, ExpandedActivity.class),
          null
      );

    2. 将过滤器添加到过滤器集合。

      Kotlin

      val expandedActivityFilterSet = setOf(expandedActivityFilter)

      Java

      Set<ActivityFilter> expandedActivityFilterSet = new HashSet<>();
      expandedActivityFilterSet.add(expandedActivityFilter);

    3. 创建一个 ActivityRule

      Kotlin

      val activityRule = ActivityRule.Builder(expandedActivityFilterSet)
          .setAlwaysExpand(true)
          .build()

      Java

      ActivityRule activityRule = new ActivityRule.Builder(
          expandedActivityFilterSet
      ).setAlwaysExpand(true)
       .build();

      ActivityRule.Builder 创建并配置规则:

      • expandedActivityFilterSet:包含 Activity 过滤器,这些过滤器通过识别您想要从拆分中排除的 Activity 来确定何时应用规则。
      • setAlwaysExpand():指定 Activity 是否应充满整个任务窗口。
    4. 将规则添加到 WindowManager RuleController

      Kotlin

      ruleController.addRule(activityRule)

      Java

      ruleController.addRule(activityRule);

跨应用嵌入

在 Android 13 (API 级别 33) 及更高版本中,应用可以嵌入来自其他应用的 Activity。跨应用(或跨 UID)的 Activity embedding 实现了来自多个 Android 应用的 Activity 的视觉集成。系统会将宿主应用的 Activity 和来自另一个应用的嵌入 Activity 并排或上下显示,与单应用 Activity embedding 一样。

例如,设置应用可以嵌入 WallpaperPicker 应用的壁纸选择器 Activity。

图 14. 设置应用(左侧菜单)嵌入壁纸选择器作为嵌入 Activity(右侧)。

信任模型

嵌入来自其他应用的 Activity 的宿主进程能够重新定义嵌入 Activity 的呈现方式,包括尺寸、位置、裁剪和透明度。恶意宿主可能会利用此功能误导用户并进行点击劫持或其他 UI 伪装攻击。

为防止滥用跨应用 Activity embedding,Android 要求应用选择加入以允许嵌入其 Activity。应用可以将宿主指定为受信任或不受信任。

受信任的宿主

要允许其他应用嵌入并完全控制来自您应用的 Activity 的呈现,请在您的应用清单文件的 <activity><application> 元素的 android:knownActivityEmbeddingCerts 属性中指定宿主应用的 SHA-256 证书。

android:knownActivityEmbeddingCerts 的值设为字符串,

<activity
    android:name=".MyEmbeddableActivity"
    android:knownActivityEmbeddingCerts="@string/known_host_certificate_digest"
    ... />

或者若要指定多个证书,则设为字符串数组,

<activity
    android:name=".MyEmbeddableActivity"
    android:knownActivityEmbeddingCerts="@array/known_host_certificate_digests"
    ... />

引用如下资源:

<resources>
    <string-array name="known_host_certificate_digests">
      <item>cert1</item>
      <item>cert2</item>
      ...
    </string-array>
</resources>

应用所有者可以通过运行 Gradle signingReport 任务获取 SHA 证书摘要。证书摘要是不含分隔冒号的 SHA-256 指纹。有关更多信息,请参阅运行签名报告验证您的客户端

不受信任的宿主

要允许任何应用嵌入您应用的 Activity 并控制其呈现,请在应用清单的 <activity><application> 元素中指定 android:allowUntrustedActivityEmbedding 属性,例如:

<activity
    android:name=".MyEmbeddableActivity"
    android:allowUntrustedActivityEmbedding="true"
    ... />

该属性的默认值为 false,这会防止跨应用 Activity embedding。

自定义身份验证

为缓解不受信任的 Activity embedding 的风险,请创建一个自定义身份验证机制来验证宿主身份。如果您知道宿主证书,请使用 androidx.security.app.authenticator 库进行验证。如果宿主在嵌入您的 Activity 后通过了身份验证,您可以显示实际内容。否则,您可以通知用户该操作不被允许并阻止内容。

使用 Jetpack WindowManager 库中的 ActivityEmbeddingController#isActivityEmbedded() 方法检查宿主是否正在嵌入您的 Activity,例如:

Kotlin

fun isActivityEmbedded(activity: Activity): Boolean {
    return ActivityEmbeddingController.getInstance(this).isActivityEmbedded(activity)
}

Java

boolean isActivityEmbedded(Activity activity) {
    return ActivityEmbeddingController.getInstance(context).isActivityEmbedded(activity);
}

最小尺寸限制

Android 系统会对嵌入的 Activity 应用应用清单 <layout> 元素中指定的最小高度和宽度。如果应用未指定最小高度和宽度,则适用系统默认值 (sw220dp)。

如果宿主尝试将嵌入容器调整为小于最小尺寸的尺寸,嵌入容器会展开以占据整个任务边界。

<activity-alias>

为了使受信任或不受信任的 Activity embedding 与 <activity-alias> 元素一起工作,必须将 android:knownActivityEmbeddingCertsandroid:allowUntrustedActivityEmbedding 应用于目标 Activity,而不是别名。系统服务器上验证安全性的策略基于目标上设置的标志,而非别名。

宿主应用

宿主应用实现跨应用 Activity embedding 的方式与实现单应用 Activity embedding 的方式相同。SplitPairRuleSplitPairFilterActivityRuleActivityFilter 对象指定了嵌入的 Activity 和任务窗口拆分。拆分规则在 XML 中静态定义,或在运行时使用 Jetpack WindowManager API 调用。

如果宿主应用尝试嵌入一个未选择加入跨应用嵌入的 Activity,该 Activity 会占据整个任务边界。因此,宿主应用需要知道目标 Activity 是否允许跨应用嵌入。

如果嵌入的 Activity 在同一任务中启动了一个新 Activity,且该新 Activity 未选择加入跨应用嵌入,则该 Activity 会占据整个任务边界,而不是覆盖嵌入容器中的 Activity。

只要 Activity 在同一任务中启动,宿主应用就可以不受限制地嵌入自己的 Activity。

拆分示例

从全窗口拆分

图 15. Activity A 启动 Activity B 并排显示。

无需重构。您可以静态或在运行时定义拆分配置,然后调用 Context#startActivity(),无需任何额外参数。

<SplitPairRule>
    <SplitPairFilter
        window:primaryActivityName=".A"
        window:secondaryActivityName=".B"/>
</SplitPairRule>

默认拆分

当应用着陆页设计为在大屏幕上拆分为两个容器时,如果两个 Activity 同时创建并呈现,用户体验会最佳。但是,在用户与主容器中的 Activity 进行交互(例如从导航菜单中选择一个项目)之前,次容器可能没有内容。占位符 Activity 可以在内容显示在次容器中之前填补空白(参见占位符部分)。

图 16. 通过同时打开两个 Activity 创建的拆分。其中一个是占位符。

要创建带有占位符的拆分,请创建一个占位符并将其与主 Activity 关联。

<SplitPlaceholderRule
    window:placeholderActivityName=".PlaceholderActivity">
    <ActivityFilter
        window:activityName=".MainActivity"/>
</SplitPlaceholderRule>

当应用收到 Intent 时,目标 Activity 可以显示为 Activity 拆分的次要部分;例如,请求显示包含列表项详细信息的详情屏幕。在小屏幕上,详情显示在整个任务窗口中;在大设备上,显示在列表旁边。

图 17. 深层链接详情 Activity 在小屏幕上单独显示,但在大屏幕上与列表 Activity 一起显示。

启动请求应路由到主 Activity,目标详情 Activity 应在拆分中启动。系统根据可用的显示宽度自动选择正确的呈现方式——堆叠或并排。

Kotlin

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    RuleController.getInstance(this)
        .addRule(SplitPairRule.Builder(filterSet).build())
    startActivity(Intent(this, DetailActivity::class.java))
}

Java

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    RuleController.getInstance(this)
        .addRule(new SplitPairRule.Builder(filterSet).build());
    startActivity(new Intent(this, DetailActivity.class));
}

深层链接目标可能是用户在返回导航堆栈中唯一可用的 Activity,您可能希望避免关闭详情 Activity 而只留下主 Activity:

Large display with list activity and detail activity side by side.
          Back navigation unable to dismiss detail activity and leave list
          activity on screen.

Small display with detail activity only. Back navigation unable to
          dismiss detail activity and reveal list activity.

相反,您可以通过使用 finishPrimaryWithSecondary 属性同时关闭两个 Activity:

<SplitPairRule
    window:finishPrimaryWithSecondary="always">
    <SplitPairFilter
        window:primaryActivityName=".ListActivity"
        window:secondaryActivityName=".DetailActivity"/>
</SplitPairRule>

参见配置属性部分。

拆分容器中的多个 Activity

在拆分容器中堆叠多个 Activity 允许用户访问深度内容。例如,通过列表-详情拆分,用户可能需要进入子详情部分,但保留主 Activity 的位置:

图 18. 在任务窗口的次级面板中原地打开的 Activity。

Kotlin

class DetailActivity : AppCompatActivity() {
    fun onOpenSubdetail() {
        startActivity(Intent(this, SubdetailActivity::class.java))
    }
}

Java

public class DetailActivity  extends AppCompatActivity {
    void onOpenSubdetail() {
        startActivity(new Intent(this, SubdetailActivity.class));
    }
}

子详情 Activity 被放置在详情 Activity 之上,将其遮住。

用户随后可以通过堆栈向后导航,回到之前的详情级别。

图 19. 从堆栈顶部移除的 Activity。

当从同一次容器中的 Activity 启动 Activity 时,将 Activity 相互堆叠是默认行为。从活动拆分中的主容器启动的 Activity 也会最终出现在次容器的 Activity 堆栈顶部。

新任务中的 Activity

当拆分任务窗口中的 Activity 在新任务中启动 Activity 时,该新任务与包含拆分的任务是分离的,并以全窗口形式显示。“最近”屏幕显示两个任务:拆分中的任务和新任务。

图 20. 从 Activity B 在新任务中启动 Activity C。

Activity 替换

Activity 可以在次容器堆栈中被替换;例如,当主 Activity 用于顶级导航而次 Activity 是所选目的地时。顶级导航的每次选择都应启动次容器中的新 Activity,并移除之前存在的 Activity。

图 21. 主面板中的顶级导航 Activity 替换次面板中的目的地 Activity。

如果导航选择更改时应用没有结束次容器中的 Activity,当拆分折叠(设备折叠)时,返回导航可能会造成困惑。例如,如果您在主面板中有一个菜单,而在次面板中堆叠了屏幕 A 和 B,当用户折叠手机时,B 在 A 之上,A 在菜单之上。当用户从 B 向后导航时,出现的是 A 而不是菜单。

在此类情况下,必须从返回堆栈中移除屏幕 A。

在现有拆分之上的新容器中启动时的默认行为是将新的次容器放在顶部,并在返回堆栈中保留旧的容器。您可以配置拆分以使用 clearTop 清除之前的次容器并正常启动新 Activity。

<SplitPairRule
    window:clearTop="true">
    <SplitPairFilter
        window:primaryActivityName=".Menu"
        window:secondaryActivityName=".ScreenA"/>
    <SplitPairFilter
        window:primaryActivityName=".Menu"
        window:secondaryActivityName=".ScreenB"/>
</SplitPairRule>

Kotlin

inner class MenuActivity : AppCompatActivity() {
    fun onMenuItemSelected(selectedMenuItem: Int) {
        startActivity(Intent(this, classForItem(selectedMenuItem)))
    }
}

Java

public class MenuActivity extends AppCompatActivity{
    void onMenuItemSelected(int selectedMenuItem) {
        startActivity(new Intent(this, classForItem(selectedMenuItem)));
    }
}

或者,使用相同的次 Activity,并从主(菜单)Activity 发送解析为相同实例的新 Intent,但在次容器中触发状态或 UI 更新。

多个拆分

应用可以通过在旁边启动额外的 Activity 来提供多级深度导航。

当次容器中的 Activity 在旁边启动新 Activity 时,会在现有拆分之上创建一个新拆分。

图 22. Activity B 在旁边启动 Activity C。

返回堆栈包含之前打开的所有 Activity,因此用户可以在结束 C 后导航到 A/B 拆分。

Activities A, B, and C in a stack. The activities are stacked in
          the following order from top to bottom: C, B, A.

要创建新拆分,请从现有的次容器在旁边启动新 Activity。声明 A/B 和 B/C 拆分的配置,并正常从 B 启动 Activity C:

<SplitPairRule>
    <SplitPairFilter
        window:primaryActivityName=".A"
        window:secondaryActivityName=".B"/>
    <SplitPairFilter
        window:primaryActivityName=".B"
        window:secondaryActivityName=".C"/>
</SplitPairRule>

Kotlin

class B : AppCompatActivity() {
    fun onOpenC() {
        startActivity(Intent(this, C::class.java))
    }
}

Java

public class B extends AppCompatActivity{
    void onOpenC() {
        startActivity(new Intent(this, C.class));
    }
}

响应拆分状态更改

应用中不同的 Activity 可能具有执行相同功能的 UI 元素;例如,打开包含帐户设置窗口的控件。

图 23. 具有功能相同 UI 元素的不同 Activity。

如果两个具有公共 UI 元素的 Activity 处于拆分状态,在两个 Activity 中都显示该元素是冗余的,甚至可能造成困惑。

图 24. Activity 拆分中的重复 UI 元素。

要了解 Activity 何时处于拆分状态,请检查 SplitController.splitInfoList 流,或通过 SplitControllerCallbackAdapter 注册侦听器以获取拆分状态的更改。然后,相应地调整 UI:

Kotlin

val layout = layoutInflater.inflate(R.layout.activity_main, null)
val view = layout.findViewById<View>(R.id.infoButton)
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        splitController.splitInfoList(this@SplitDeviceActivity) // The activity instance.
            .collect { list ->
                view.visibility = if (list.isEmpty()) View.VISIBLE else View.GONE
            }
    }
}

Java

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    new SplitControllerCallbackAdapter(SplitController.getInstance(this))
        .addSplitListener(
            this,
            Runnable::run,
            splitInfoList -> {
                View layout = getLayoutInflater().inflate(R.layout.activity_main, null);
                layout.findViewById(R.id.infoButton).setVisibility(
                    splitInfoList.isEmpty() ? View.VISIBLE : View.GONE);
            });
}

协程可以在任何生命周期状态下启动,但通常在 STARTED 状态下启动以节省资源(参见将 Kotlin 协程与生命周期感知组件结合使用了解更多信息)。

回调可以在任何生命周期状态下进行,包括 Activity 停止时。侦听器通常应在 onStart() 中注册,并在 onStop() 中注销。