使用 Jetpack WindowManager 支持折叠屏和双屏设备

1. 开始之前

本实践代码实验室将教你双屏和折叠屏设备开发的基础知识。完成后,应用程序可以支持 Pixel Fold、Microsoft Surface Duo、Samsung Galaxy Z Fold 5 等折叠屏设备。

先决条件

要完成此代码实验室,您需要

您将学习

创建一个简单的应用程序,该应用程序可以

  • 显示设备功能
  • 检测应用程序是否在折叠屏或双屏设备上运行
  • 确定设备状态
  • 使用 Jetpack WindowManager 处理新型态设备。

您需要准备

Android 模拟器 v30.0.6+ 包含带有虚拟铰链传感器和 3D 视图的折叠屏支持。您可以使用一些折叠屏模拟器,如下面的图片所示。

ca76200cc00b6ce6.png

2. 单屏设备与折叠屏设备

与之前的移动设备相比,折叠屏设备为用户提供了更大的屏幕和更通用的用户界面。折叠时,这些设备通常比普通尺寸的平板电脑更小,使其更便携且功能更强大。

在撰写本文时,有两种类型的折叠屏设备

  • 单屏折叠屏设备,只有一个可以折叠的屏幕。用户可以使用 多窗口 模式在同一屏幕上同时运行多个应用程序。
  • 双屏折叠屏设备,两个屏幕由铰链连接。这些设备也可以折叠,但它们有两个不同的逻辑显示区域。

9ff347a7c8483fed.png

与平板电脑和其他单屏移动设备一样,折叠屏设备可以

  • 在一个显示区域运行一个应用程序。
  • 并排运行两个应用程序,每个应用程序在一个不同的显示区域(使用 多窗口 模式)。

与单屏设备不同,折叠屏设备还支持不同的姿态。姿态可以用来以不同的方式显示内容。

bac1d8089687c0c2.png

当应用程序跨越整个显示区域(在双屏折叠屏设备上使用所有显示区域)时,折叠屏设备可以提供不同的跨度姿态。

折叠屏设备还可以提供折叠姿态,例如桌面模式,这样您可以在平坦的屏幕部分和朝向您的倾斜部分之间进行逻辑分割,以及帐篷模式,这样您可以将内容可视化,就像设备使用了支架装置一样。

3. Jetpack WindowManager

Jetpack WindowManager 库帮助应用程序开发者支持新型态设备,并为旧版和新版平台上的各种 WindowManager 功能提供通用的 API 接口。

主要功能

Jetpack WindowManager 版本 1.1.0 包含 FoldingFeature 类,该类描述了柔性显示屏中的折叠或两个物理显示面板之间的铰链。其 API 提供了访问与设备相关的关键信息的功能

使用 WindowInfoTracker 接口,您可以访问 windowLayoutInfo() 以收集 FlowWindowLayoutInfo,其中包含所有可用的 DisplayFeature

4. 设置

创建一个新项目并选择“空活动”模板

a5ce5c7fb033ec4c.png

将所有参数保留为默认值。

声明依赖项

为了使用 Jetpack WindowManager,请在应用程序或模块的 build.gradle 文件中添加依赖项

app/build.gradle

dependencies {
    ext.windowmanager_version = "1.1.0"

    implementation "androidx.window:window:$windowmanager_version"
    androidTestImplementation "androidx.window:window-testing:$windowmanager_version"

    // Needed to use lifecycleScope to collect the WindowLayoutInfo flow
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
}

使用 WindowManager

可以通过 WindowManager 的 WindowInfoTracker 接口访问窗口功能。

打开 MainActivity.kt 源文件并调用 WindowInfoTracker.getOrCreate(this@MainActivity) 来初始化与当前活动关联的 WindowInfoTracker 实例

MainActivity.kt

import androidx.window.layout.WindowInfoTracker

private lateinit var windowInfoTracker: WindowInfoTracker

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
     
        windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)
}

使用 WindowInfoTracker 实例,获取有关设备当前窗口状态的信息。

5. 设置应用程序 UI

从 Jetpack WindowManager 获取有关窗口度量、布局和显示配置的信息。在主活动布局中显示此信息,为每个信息使用一个 TextView

创建一个 ConstraintLayout,其中包含三个 TextView,居中显示在屏幕上。

打开 activity_main.xml 文件并粘贴以下内容

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:id="@+id/constraint_layout"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

    <TextView
        android:id="@+id/window_metrics"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="20dp"
        tools:text="Window metrics"
        android:textSize="20sp"
        app:layout_constraintBottom_toTopOf="@+id/layout_change"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed" />

    <TextView
        android:id="@+id/layout_change"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="20dp"
        tools:text="Layout change"
        android:textSize="20sp"
        app:layout_constrainedWidth="true"
        app:layout_constraintBottom_toTopOf="@+id/configuration_changed"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/window_metrics" />

    <TextView
        android:id="@+id/configuration_changed"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="20dp"
        tools:text="Using one logic/physical display - unspanned"
        android:textSize="20sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/layout_change" />

</androidx.constraintlayout.widget.ConstraintLayout>

我们现在将使用 视图绑定 在代码中连接这些 UI 元素。为此,我们首先在应用程序的 build.gradle 文件中启用它

app/build.gradle

android {
   // Other configurations

   buildFeatures {
      viewBinding true
   }
}

根据 Android Studio 的建议同步 gradle 项目,并在 MainActivity.kt 中使用以下代码使用视图绑定

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private lateinit var windowInfoTracker: WindowInfoTracker
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)
    }
}

6. 可视化 WindowMetrics 信息

MainActivityonCreate 方法中,调用一个函数来获取和显示 WindowMetrics 信息。在 onCreate 方法中添加 obtainWindowMetrics() 调用

MainActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)

   windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)

   obtainWindowMetrics()
}

实现 obtainWindowMetrics 方法

MainActivity.kt

import androidx.window.layout.WindowMetricsCalculator

private fun obtainWindowMetrics() {
   val wmc = WindowMetricsCalculator.getOrCreate()
   val currentWM = wmc.computeCurrentWindowMetrics(this).bounds.flattenToString()
   val maximumWM = wmc.computeMaximumWindowMetrics(this).bounds.flattenToString()
   binding.windowMetrics.text =
       "CurrentWindowMetrics: ${currentWM}\nMaximumWindowMetrics: ${maximumWM}"
}

通过其伴随函数 getOrCreate() 获取 WindowMetricsCalculator 的实例。

使用该 WindowMetricsCalculator 实例,将信息设置到 windowMetrics TextView 中。使用函数 computeCurrentWindowMetrics.boundscomputeMaximumWindowMetrics.bounds 返回的值。

这些值提供了有关窗口占据区域的度量信息的实用信息。

运行应用程序。在双屏模拟器(如下图所示)中,您将获得与模拟器镜像的 设备尺寸相符的 CurrentWindowMetrics。您还可以在应用程序以单屏模式运行时查看度量。

f6f0deff678fd722.png

当应用跨越多个显示屏时,窗口度量值会像下图一样发生变化,从而反映应用使用的更大窗口区域。

f1ce73d7198b4990.png

当前和最大窗口度量值相同,因为应用始终运行并占据整个可用显示区域,无论是在单屏还是双屏模式下。

在具有水平折叠的可折叠模拟器中,当应用跨越整个物理显示屏和应用在多窗口模式下运行时,值会有所不同。

d00e53154f32d7df.png

正如左侧图像所示,这两个度量值相同,因为正在运行的应用使用了当前和最大可用显示区域。

但在右侧图像中,应用在多窗口模式下运行,您可以看到当前度量值显示了应用在分屏模式的特定区域(顶部)中运行的区域的尺寸,并且您可以看到最大度量值显示了设备拥有的最大显示区域。

WindowMetricsCalculator提供的度量值对于确定应用正在使用或可以使用窗口区域非常有用。

7. 可视化FoldingFeature信息

现在注册以接收窗口布局变化以及模拟器或设备DisplayFeatures的特性和边界。

要从WindowInfoTracker#windowLayoutInfo()收集信息,请使用为每个Lifecycle对象定义的lifecycleScope。在此作用域中启动的任何协程都将在Lifecycle销毁时取消。您可以通过lifecycle.coroutineScopelifecycleOwner.lifecycleScope属性访问生命周期的协程作用域。

MainActivityonCreate方法中,调用一个函数来获取和显示WindowInfoTracker信息。首先在onCreate方法中添加onWindowLayoutInfoChange()调用。

MainActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)

   windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)

   obtainWindowMetrics()
   onWindowLayoutInfoChange()
}

使用该函数的实现来获取每当新的布局配置发生变化时信息。

定义函数签名和框架。

MainActivity.kt

private fun onWindowLayoutInfoChange() {
}

使用函数接收的参数,一个WindowInfoTracker,获取其WindowLayoutInfo数据。WindowLayoutInfo包含位于窗口内的DisplayFeature列表。例如,铰链或显示屏折叠可能会横跨窗口,在这种情况下,将视觉内容和交互元素分成两组(例如,列表详细信息或视图控件)可能是有意义的。

仅报告当前窗口边界内存在的特性。如果窗口在屏幕上移动或调整大小,它们的位置和大小可能会发生变化。

通过在lifecycle-runtime-ktx依赖项中定义的lifecycleScope,获取flowWindowLayoutInfo,其中包含所有显示功能的列表。添加onWindowLayoutInfoChange的主体。

MainActivity.kt

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch

private fun onWindowLayoutInfoChange() {
    lifecycleScope.launch(Dispatchers.Main) {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            windowInfoTracker.windowLayoutInfo(this@MainActivity)
                .collect { value ->
                    updateUI(value)
                }
        }
    }
}

updateUI函数正在从collect调用。实现此函数以显示和打印从flowWindowLayoutInfo接收到的信息。检查WindowLayoutInfo数据是否具有显示功能。如果是,则显示功能正在以某种方式与应用的UI交互。如果WindowLayoutInfo数据没有任何显示功能,则应用在单屏设备或模式或多窗口模式下运行。

MainActivity.kt

import androidx.window.layout.WindowLayoutInfo

private fun updateUI(newLayoutInfo: WindowLayoutInfo) {
    binding.layoutChange.text = newLayoutInfo.toString()
    if (newLayoutInfo.displayFeatures.isNotEmpty()) {
        binding.configurationChanged.text = "Spanned across displays"
    } else {
        binding.configurationChanged.text = "One logic/physical display - unspanned"
    }
}

运行应用程序。在双屏模拟器中,您有

a6f6452155742925.png

WindowLayoutInfo为空。它有一个空的List<DisplayFeature>。但是,如果您有一个中间带有铰链的模拟器,为什么您没有从WindowManager获取信息呢?

WindowManager(通过WindowInfoTracker)只会在应用跨越显示屏(物理的或非物理的)时提供WindowLayoutInfo数据(设备功能类型、设备功能边界和设备姿态)。因此,在上图中,当应用在单屏模式下运行时,WindowLayoutInfo为空。

拥有这些信息后,您可以知道应用在何种模式下运行(单屏模式或跨越多个显示屏),然后您可以在UI/UX中进行更改,提供更适合这些特定配置的更好的用户体验。

在没有两个物理显示屏的设备上(它们通常没有物理铰链),应用程序可以使用多窗口模式并排运行。在这些设备上,当应用在多窗口模式下运行时,应用的行为就像在单屏设备上的上一示例一样。当应用占据所有逻辑显示屏时,它的行为与应用跨越多个显示屏时相同。参见下图

eacdd758eefb6c3c.png

当应用在多窗口模式下运行时,WindowManager提供一个空的List<LayoutInfo>

总而言之,只有当应用占据所有逻辑显示屏并与设备功能(折叠或铰链)相交时,才会获取WindowLayoutInfo数据。在所有其他情况下,您都不会获得任何信息。32e4190913b452e4.png

当您跨越多个显示屏扩展应用时会发生什么?在双屏模拟器中,WindowLayoutInfo将具有一个FoldingFeature对象,该对象提供有关设备功能的数据:一个HINGE、该功能的边界(Rect (0, 0 - 1434, 1800))以及设备的姿态(状态)(FLAT)。

586f15def7d23ffd.png

让我们看看每个字段的含义

  • type = TYPE_HINGE:此双屏模拟器镜像具有物理铰链的真实Surface Duo设备,这就是WindowManager报告的内容。
  • Bounds [0, 0 - 1434, 1800]:表示应用程序窗口中窗口坐标空间内功能的边界矩形。如果您阅读Surface Duo设备的尺寸规格,您会看到铰链位于这些边界(左、上、右、下)报告的确切位置。
  • State:有两个不同的值代表设备的设备姿态(状态)。
  • HALF_OPENED:可折叠设备的铰链处于打开和关闭状态之间的中间位置,并且灵活屏幕的各个部分或物理屏幕面板之间存在非平面角度。
  • FLAT:可折叠设备完全打开,呈现给用户的屏幕空间是平坦的。

模拟器默认以180度打开,因此WindowManager返回的姿态为FLAT

如果您使用“虚拟传感器”选项将模拟器的姿态更改为“半开”姿态,WindowManager将通知您新的位置:HALF_OPENED

cba02ab39d6d346b.png

使用WindowManager调整您的UI/UX

如显示窗口布局信息的图所示,显示的信息被显示功能裁剪了,这里也是一样。

ff2caf93916f1682.png

这不是最佳的用户体验。您可以使用WindowManager提供的信息来调整您的UI/UX。

正如您之前所见,当您的应用跨越所有不同的显示区域时,也是您的应用与设备功能相交的时候,因此WindowManager提供窗口布局信息作为显示状态和显示边界。因此,在这里,当应用跨越多个显示屏时,您需要使用这些信息并调整您的UI/UX。

接下来要做的是,在应用跨越多个显示屏时,在运行时调整当前的UI/UX,这样就不会有重要的信息被显示功能裁剪或隐藏。您将创建一个镜像设备显示功能的视图,并将其用作约束被裁剪或隐藏的TextView的参考,这样就不会再有信息丢失了。

出于学习目的,将此新视图着色,以便可以轻松看出它位于与真实设备显示功能相同的位置,并且具有相同的尺寸。

activity_main.xml中添加您将用作设备功能参考的新视图。

activity_main.xml

<!-- It's not important where this view is placed by default, it will be positioned dynamically at runtime -->
<View
    android:id="@+id/folding_feature"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:background="@android:color/holo_red_dark"
    android:visibility="gone"
    tools:ignore="MissingConstraints" />

MainActivity.kt 文件中,找到你用来显示给定 WindowLayoutInfo 信息的 updateUI() 函数,并在包含显示功能的 if-else 语句块中添加一个新的函数调用。

MainActivity.kt

private fun updateUI(newLayoutInfo: WindowLayoutInfo) {
   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.isNotEmpty()) {
       binding.configurationChanged.text = "Spanned across displays"
       alignViewToFoldingFeatureBounds(newLayoutInfo)
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}

你已经添加了一个名为 alignViewToFoldingFeatureBounds 的函数,它接收 WindowLayoutInfo 作为参数。

创建这个函数。在函数内部,创建一个 ConstraintSet 对象,以便为你的视图应用新的约束。然后,使用 WindowLayoutInfo 获取显示功能的边界。由于 WindowLayoutInfo 返回的是一个 DisplayFeature 接口列表,你需要将其转换为 FoldingFeature 才能访问所有信息。

MainActivity.kt

import androidx.constraintlayout.widget.ConstraintSet
import androidx.window.layout.FoldingFeature

private fun alignViewToFoldingFeatureBounds(newLayoutInfo: WindowLayoutInfo) {
   val constraintLayout = binding.constraintLayout
   val set = ConstraintSet()
   set.clone(constraintLayout)

   // Get and translate the feature bounds to the View's coordinate space and current
   // position in the window.
   val foldingFeature = newLayoutInfo.displayFeatures[0] as FoldingFeature
   val bounds = getFeatureBoundsInWindow(foldingFeature, binding.root)


   // Rest of the code to be added in the following steps
}

定义一个 getFeatureBoundsInWindow() 函数,用于将显示功能的边界转换为视图的坐标空间和窗口中的当前位置。

MainActivity.kt

import android.graphics.Rect
import android.view.View
import androidx.window.layout.DisplayFeature

/**
 * Get the bounds of the display feature translated to the View's coordinate space and current
 * position in the window. This will also include view padding in the calculations.
 */
private fun getFeatureBoundsInWindow(
    displayFeature: DisplayFeature,
    view: View,
    includePadding: Boolean = true
): Rect? {
    // Adjust the location of the view in the window to be in the same coordinate space as the feature.
    val viewLocationInWindow = IntArray(2)
    view.getLocationInWindow(viewLocationInWindow)

    // Intersect the feature rectangle in window with view rectangle to clip the bounds.
    val viewRect = Rect(
        viewLocationInWindow[0], viewLocationInWindow[1],
        viewLocationInWindow[0] + view.width, viewLocationInWindow[1] + view.height
    )

    // Include padding if needed
    if (includePadding) {
        viewRect.left += view.paddingLeft
        viewRect.top += view.paddingTop
        viewRect.right -= view.paddingRight
        viewRect.bottom -= view.paddingBottom
    }

    val featureRectInView = Rect(displayFeature.bounds)
    val intersects = featureRectInView.intersect(viewRect)

    // Checks to see if the display feature overlaps with our view at all
    if ((featureRectInView.width() == 0 && featureRectInView.height() == 0) ||
        !intersects
    ) {
        return null
    }

    // Offset the feature coordinates to view coordinate space start point
    featureRectInView.offset(-viewLocationInWindow[0], -viewLocationInWindow[1])

    return featureRectInView
}

有了显示功能边界的相关信息后,你可以使用它来设置参考视图的正确高度,并相应地移动它。

alignViewToFoldingFeatureBounds 函数的完整代码如下所示:

MainActivity.kt - alignViewToFoldingFeatureBounds

private fun alignViewToFoldingFeatureBounds(newLayoutInfo: WindowLayoutInfo) {
    val constraintLayout = binding.constraintLayout
    val set = ConstraintSet()
    set.clone(constraintLayout)

    // Get and Translate the feature bounds to the View's coordinate space and current
    // position in the window.
    val foldingFeature = newLayoutInfo.displayFeatures[0] as FoldingFeature
    val bounds = getFeatureBoundsInWindow(foldingFeature, binding.root)

    bounds?.let { rect ->
        // Some devices have a 0px width folding feature. We set a minimum of 1px so we
        // can show the view that mirrors the folding feature in the UI and use it as reference.
        val horizontalFoldingFeatureHeight = (rect.bottom - rect.top).coerceAtLeast(1)
        val verticalFoldingFeatureWidth = (rect.right - rect.left).coerceAtLeast(1)

        // Sets the view to match the height and width of the folding feature
        set.constrainHeight(
            R.id.folding_feature,
            horizontalFoldingFeatureHeight
        )
        set.constrainWidth(
            R.id.folding_feature,
            verticalFoldingFeatureWidth
        )

        set.connect(
            R.id.folding_feature, ConstraintSet.START,
            ConstraintSet.PARENT_ID, ConstraintSet.START, 0
        )
        set.connect(
            R.id.folding_feature, ConstraintSet.TOP,
            ConstraintSet.PARENT_ID, ConstraintSet.TOP, 0
        )

        if (foldingFeature.orientation == FoldingFeature.Orientation.VERTICAL) {
            set.setMargin(R.id.folding_feature, ConstraintSet.START, rect.left)
            set.connect(
                R.id.layout_change, ConstraintSet.END,
                R.id.folding_feature, ConstraintSet.START, 0
            )
        } else {
            // FoldingFeature is Horizontal
            set.setMargin(
                R.id.folding_feature, ConstraintSet.TOP,
                rect.top
            )
            set.connect(
                R.id.layout_change, ConstraintSet.TOP,
                R.id.folding_feature, ConstraintSet.BOTTOM, 0
            )
        }

        // Set the view to visible and apply constraints
        set.setVisibility(R.id.folding_feature, View.VISIBLE)
        set.applyTo(constraintLayout)
    }
}

现在,与设备显示功能冲突的 TextView 会考虑功能的位置,因此其内容不会被截断或隐藏。

67b41810704d0011.png

在双屏模拟器(上图,左)中,你可以看到跨屏幕显示内容并被铰链截断的 TextView 现在不再被截断,因此没有丢失信息。

在折叠式模拟器(上图,右)中,你会看到一条浅红色的线,表示折叠式显示功能的位置,并且 TextView 现在已放置在该功能下方。因此,当设备折叠时(例如,以笔记本电脑姿势折叠 90 度),没有任何信息受到该功能的影响。

如果你想知道双屏模拟器上的显示功能在哪里(因为它是一种铰链式设备),表示该功能的视图被铰链隐藏了。但是,如果应用从跨度状态更改为非跨度状态,你将看到它位于与功能相同的位置,并具有正确的宽度和高度。

1a309ab775c49a6a.png

8. 其他 Jetpack WindowManager 构件

除了主要的 WindowManager 构件之外,WindowManager 还提供其他有用的构件,这些构件可以帮助你以不同的方式与组件交互,同时考虑到构建应用时使用的当前环境。

Java 构件

如果你使用的是 Java 编程语言而不是 Kotlin,或者如果通过回调监听事件更适合你的架构,那么 WindowManager 的 Java 构件非常有用,因为它提供了一个 Java 友好的 API 来注册和注销通过回调监听事件。

RxJava 构件

如果你已经在使用 RxJava(版本 23),你可以使用特定的构件来帮助你保持代码的一致性,无论你使用的是 Observables 还是 Flowables

9. 使用 Jetpack WindowManager 进行测试

在任何模拟器或设备上测试折叠姿态对于测试如何在 FoldingFeature 周围放置 UI 元素非常有用。

为此,WindowManager 提供了一个非常有用的构件用于 instrumentation 测试。

让我们看看如何使用它。

除了主要的 WindowManager 依赖项之外,我们还在应用的 build.gradle 文件中添加了测试构件:androidx.window:window-testing

window-testing 构件提供了一个有用的新 TestRule,称为 WindowLayoutInfoPublisherRule,它将有助于测试使用 WindowLayoutInfo 值流。 WindowLayoutInfoPublisherRule 允许你根据需要推送不同的 WindowLayoutInfo 值。

为了使用它,并由此创建一个示例来帮助你使用这个新的构件更新来测试你的 UI,请更新 Android Studio 模板创建的测试类。用以下代码替换 ExampleInstrumentedTest 类中的所有代码。

ExampleInstrumentedTest.kt

import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.window.testing.layout.WindowLayoutInfoPublisherRule
import org.junit.Rule
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class MainActivityTest {
    private val activityRule = ActivityScenarioRule(MainActivity::class.java)
    private val publisherRule = WindowLayoutInfoPublisherRule()

    @get:Rule
    val testRule: TestRule

    init {
        testRule = RuleChain.outerRule(publisherRule).around(activityRule)
    }
}

该规则已与 ActvityScenarioRule 链接。

为了模拟 FoldingFeature,新的构件提供了一些非常有用的函数来实现这一点 这是提供一些默认值的最简单的函数。

MainActivity 中,TextView 与折叠功能的左侧对齐。创建一个测试来检查是否正确实现了这一点。

创建一个名为 testText_is_left_of_Vertical_FoldingFeature 的测试。

ExampleInstrumentedTest.kt

import androidx.window.layout.FoldingFeature.Orientation.Companion.VERTICAL
import androidx.window.layout.FoldingFeature.State.Companion.FLAT
import androidx.window.testing.layout.FoldingFeature
import androidx.window.testing.layout.TestWindowLayoutInfo
import org.junit.Test

@Test
fun testText_is_left_of_Vertical_FoldingFeature() {
   activityRule.scenario.onActivity { activity ->
       val hinge = FoldingFeature(
           activity = activity,
           state = FLAT,
           orientation = VERTICAL,
           size = 2
       )

       val expected = TestWindowLayoutInfo(listOf(hinge))
       publisherRule.overrideWindowLayoutInfo(expected)
   }

   // Add Assertion with EspressoMatcher here

}

测试 FoldingFeature 处于 FLAT 状态,其方向为 VERTICAL。我们定义了一个特定的大小,因为我们希望在我们的测试中在 UI 中显示虚假的 FoldingFeature,以便我们能够看到它在设备上的位置。

我们使用之前实例化的 WindowLayoutInfoPublishRule 来发布虚假的 FoldingFeaure,这样我们就可以像使用真实的 WindowLayoutInfo 数据一样获取它。

最后一步是测试我们的 UI 元素是否位于它们应该对齐的位置,以避免 FoldingFeature。为此,我们只需使用 EspressoMatchers,在刚刚创建的测试末尾添加断言。

ExampleInstrumentedTest.kt

import androidx.test.espresso.assertion.PositionAssertions
import androidx.test.espresso.matcher.ViewMatchers.withId

onView(withId(R.id.layout_change)).check(
    PositionAssertions.isCompletelyLeftOf(withId(R.id.folding_feature))
)

完整的测试如下所示:

ExampleInstrumentedTest.kt

@Test
fun testText_is_left_of_Vertical_FoldingFeature() {
    activityRule.scenario.onActivity { activity ->
        val hinge = FoldingFeature(
            activity = activity,
            state = FoldingFeature.State.FLAT,
            orientation = FoldingFeature.Orientation.VERTICAL,
            size = 2
        )
        val expected = TestWindowLayoutInfo(listOf(hinge))
        publisherRule.overrideWindowLayoutInfo(expected)
    }
    onView(withId(R.id.layout_change)).check(
        PositionAssertions.isCompletelyLeftOf(withId(R.id.folding_feature))
    )
}
val horizontal_hinge = FoldingFeature(
   activity = activity,
   state = FLAT,
   orientation = HORIZONTAL,
   size = 2
)

你现在可以在设备或模拟器上运行你的测试,以检查应用程序的行为是否符合预期。请注意,你不需要折叠式设备或模拟器来运行此测试。

10. 恭喜你!

Jetpack WindowManager 帮助开发者处理折叠式等新型态设备。

WindowManager 提供的信息对于使 Android 应用适应折叠式设备以提供更好的用户体验非常有帮助。

总而言之,在这个 codelab 中,你学习了:

  • 什么是折叠式设备
  • 不同折叠式设备之间的区别
  • 折叠式设备、单屏幕设备和平板电脑之间的区别
  • Jetpack WindowManager API
  • 使用 Jetpack WindowManager 并使我们的应用适应新的设备形态
  • 使用 Jetpack WindowManager 进行测试

了解更多