使用 Jetpack WindowManager 优化折叠屏设备上的相机应用

1. 开始之前

折叠屏设备的特别之处?

折叠屏设备是具有划时代意义的创新。它们提供了独特的体验,并带来了独特的机会,让您可以通过诸如桌面 UI 等差异化功能来为用户带来惊喜,例如免提使用。

先决条件

  • Android 应用开发基础知识
  • Hilt 依赖注入框架基础知识

您将构建什么

在本 Codelab 中,您将构建一个针对折叠屏设备进行了布局优化的相机应用。

c5e52933bcd81859.png

您将从一个基本的相机应用开始,该应用不会对任何设备姿态做出反应,也不会利用更好的后置摄像头来增强自拍效果。您将更新源代码,以便在设备展开时将预览移动到较小的显示屏,并对手机处于桌面模式做出反应。

虽然相机应用是此 API 最方便的用例,但您在本 Codelab 中学习的这两个功能可以应用于任何应用。

您将学到什么

  • 如何使用 Jetpack Window Manager 对姿态变化做出反应
  • 如何将您的应用移动到折叠屏设备的较小显示屏

您需要什么

  • 最新版本的 Android Studio
  • 折叠屏设备或折叠屏模拟器

2. 设置

获取初始代码

  1. 如果您已安装 Git,则只需运行以下命令即可。要检查是否已安装 Git,请在终端或命令行中键入 git --version 并验证其是否可以正确执行。
git clone https://github.com/android/large-screen-codelabs.git
  1. 可选:如果您没有安装 Git,可以点击以下按钮下载此 Codelab 的所有代码:

打开第一个模块

  • 在 Android Studio 中,打开 /step1 下的第一个模块。

Screenshot of Android Studio showing the code related to this codelab

如果系统提示您使用最新版本的 Gradle,请继续更新。

3. 运行和观察

  1. step1 模块上运行代码。

如您所见,这是一个简单的相机应用。您可以切换前后摄像头,还可以调整纵横比。但是,最左侧的第一个按钮目前没有任何作用,但它将成为“后置自拍”模式的入口点。

149e3f9841af7726.png

  1. 现在,尝试将设备置于半展开位置,即铰链既不完全平坦也不完全闭合,而是形成 90 度角。

如您所见,应用不会对不同的设备姿态做出响应,因此布局不会改变,导致铰链位于取景器中间。

4. 了解 Jetpack WindowManager

Jetpack WindowManager 库可帮助应用开发者为折叠屏设备构建优化的体验。它包含 FoldingFeature 类,该类描述了柔性显示屏中的折叠或两个物理显示面板之间的铰链。其 API 提供了与设备相关的关键信息

FoldingFeature 类包含其他信息,例如 occlusionType()isSeparating(),但本 Codelab 不会深入探讨这些信息。

从版本 1.2.0-beta01 开始,该库使用 WindowAreaController,这是一个 API,它使后置显示模式能够将当前窗口移动到与后置摄像头对齐的显示屏,这非常适合使用后置摄像头自拍以及许多其他用例!

添加依赖项

  • 为了在应用中使用 Jetpack WindowManager,您需要将以下依赖项添加到模块级 build.gradle 文件中

step1/build.gradle

def work_version = '1.2.0'
implementation "androidx.window:window:$work_version"
implementation "androidx.window:window-java:$work_version"
implementation "androidx.window:window-core:$work_version"

现在,您可以在应用中访问 FoldingFeatureWindowAreaController 类。您可以使用它们来构建极致的折叠屏相机体验!

5. 实现后置自拍模式

从后置显示模式开始。

允许此模式的 API 是 WindowAreaController,它提供有关在设备上的显示屏或显示屏区域之间移动窗口的信息和行为。

它允许您查询当前可交互的 WindowAreaInfo 列表。

使用 WindowAreaInfo,您可以访问 WindowAreaSession,它是一个接口,用于表示活动的窗口区域功能以及特定 WindowAreaCapability 的可用性状态。

  1. 在您的 MainActivity 中声明这些变量

step1/MainActivity.kt

private lateinit var windowAreaController: WindowAreaController
private lateinit var displayExecutor: Executor
private var rearDisplaySession: WindowAreaSession? = null
private var rearDisplayWindowAreaInfo: WindowAreaInfo? = null
private var rearDisplayStatus: WindowAreaCapability.Status =
    WindowAreaCapability.Status.WINDOW_AREA_STATUS_UNSUPPORTED
private val rearDisplayOperation = WindowAreaCapability.Operation.OPERATION_TRANSFER_ACTIVITY_TO_AREA
  1. 并在 onCreate() 方法中初始化它们

step1/MainActivity.kt

displayExecutor = ContextCompat.getMainExecutor(this)
windowAreaController = WindowAreaController.getOrCreate()

lifecycleScope.launch(Dispatchers.Main) {
  lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
    windowAreaController.windowAreaInfos
      .map{info->info.firstOrNull{it.type==WindowAreaInfo.Type.TYPE_REAR_FACING}}
      .onEach { info -> rearDisplayWindowAreaInfo = info }
      .map{it?.getCapability(rearDisplayOperation)?.status?:  WindowAreaCapability.Status.WINDOW_AREA_STATUS_UNSUPPORTED }
      .distinctUntilChanged()
      .collect {
           rearDisplayStatus = it
           updateUI()
      }
  }
}
  1. 现在实现 updateUI() 函数以启用或禁用后置自拍按钮,具体取决于当前状态

step1/MainActivity.kt

private fun updateUI() {
    if(rearDisplaySession != null) {
        binding.rearDisplay.isEnabled = true
        // A session is already active, clicking on the button will disable it
    } else {
        when(rearDisplayStatus) {
            WindowAreaCapability.Status.WINDOW_AREA_STATUS_UNSUPPORTED -> {
                binding.rearDisplay.isEnabled = false
                // RearDisplay Mode is not supported on this device"
            }
            WindowAreaCapability.Status.WINDOW_AREA_STATUS_UNAVAILABLE -> {
                binding.rearDisplay.isEnabled = false
                // RearDisplay Mode is not currently available
            }
            WindowAreaCapability.Status.WINDOW_AREA_STATUS_AVAILABLE -> {
                binding.rearDisplay.isEnabled = true
                // You can enable RearDisplay Mode
            }
            WindowAreaCapability.Status.WINDOW_AREA_STATUS_ACTIVE -> {
                binding.rearDisplay.isEnabled = true
                // You can disable RearDisplay Mode
            }
            else -> {
                binding.rearDisplay.isEnabled = false
                // RearDisplay status is unknown
            }
        }
    }
}

最后一步是可选的,但了解 WindowAreaCapability 的所有可能状态非常有用。

  1. 现在实现函数 toggleRearDisplayMode,如果功能已激活,则将关闭会话,或者调用 transferActivityToWindowArea 函数

step1/CameraViewModel.kt

private fun toggleRearDisplayMode() {
    if(rearDisplayStatus == WindowAreaCapability.Status.WINDOW_AREA_STATUS_ACTIVE) {
        if(rearDisplaySession == null) {
            rearDisplaySession = rearDisplayWindowAreaInfo?.getActiveSession(rearDisplayOperation)
        }
        rearDisplaySession?.close()
    } else {
        rearDisplayWindowAreaInfo?.token?.let { token ->
            windowAreaController.transferActivityToWindowArea(
                token = token,
                activity = this,
                executor = displayExecutor,
                windowAreaSessionCallback = this
            )
        }
    }
}

请注意 MainActivity 作为 WindowAreaSessionCallback 的用法。

后置显示 API 使用监听器方法:当您请求将内容移动到另一个显示屏时,您将启动一个会话,该会话将通过监听器的 onSessionStarted() 方法返回。当您改为想要返回到内部(且更大的)显示屏时,您将关闭会话,并在 onSessionEnded() 方法中获得确认。要创建这样的监听器,您需要实现 WindowAreaSessionCallback 接口。

  1. 修改 MainActivity 声明,使其实现 WindowAreaSessionCallback 接口

step1/MainActivity.kt

class MainActivity : AppCompatActivity(), WindowAreaSessionCallback

现在,在 MainActivity 中实现 onSessionStartedonSessionEnded 方法。这些回调方法对于获取会话状态的通知并相应地更新应用非常有用。

但这次,为简单起见,只需在函数体中检查是否存在任何错误并记录状态。

step1/MainActivity.kt

override fun onSessionEnded(t: Throwable?) {
    if(t != null) {
        Log.d("Something was broken: ${t.message}")
    }else{
        Log.d("rear session ended")
    }
}

override fun onSessionStarted(session: WindowAreaSession) {
    Log.d("rear session started [session=$session]")
}
  1. 构建并运行应用。然后,如果您展开设备并点击后置显示按钮,系统将提示您显示如下消息

ba878f120b7c8d58.png

  1. 选择“立即切换屏幕”以查看您的内容已移动到外部显示屏!

6. 实现桌面模式

现在是时候让您的应用能够感知折叠状态了:您将根据折叠的方向将内容移动到设备铰链的侧面或上方。为此,您将在 FoldingStateActor 中执行操作,以便您的代码与 Activity 解耦,从而提高可读性。

此 API 的核心部分在于 WindowInfoTracker 接口,该接口通过一个静态方法创建,该方法需要一个 Activity

step1/CameraCodelabDependencies.kt

@Provides
fun provideWindowInfoTracker(activity: Activity) =
        WindowInfoTracker.getOrCreate(activity)

您无需编写此代码,因为它已经存在,但了解如何构建 WindowInfoTracker 非常有用。

  1. 要监听任何窗口更改,请在 ActivityonResume() 方法中监听这些更改

step1/MainActivity.kt

lifecycleScope.launch {
    foldingStateActor.checkFoldingState(
         this@MainActivity, 
         binding.viewFinder
    )
}
  1. 现在,打开 FoldingStateActor 文件,因为现在是时候填充 checkFoldingState() 方法了。

如您所见,它在 ActivityRESUMED 阶段运行,并且利用 WindowInfoTracker 来监听任何布局更改。

step1/FoldingStateActor.kt

windowInfoTracker.windowLayoutInfo(activity)
      .collect { newLayoutInfo ->
         activeWindowLayoutInfo = newLayoutInfo
         updateLayoutByFoldingState(cameraViewfinder)
      }

通过使用 WindowInfoTracker 接口,您可以调用 windowLayoutInfo() 以收集 FlowWindowLayoutInfo,其中包含 DisplayFeature 中的所有可用信息。

最后一步是对这些变化做出反应,并相应地移动内容。您可以在 updateLayoutByFoldingState() 方法中一步一步地完成此操作。

  1. 确保 activityLayoutInfo 包含一些 DisplayFeature 属性,并且其中至少有一个是 FoldingFeature,否则您无需执行任何操作。

step1/FoldingStateActor.kt

val foldingFeature = activeWindowLayoutInfo?.displayFeatures
            ?.firstOrNull { it is FoldingFeature } as FoldingFeature?
            ?: return
  1. 计算折叠的位置,以确保设备位置正在影响您的布局,并且不在您的层级结构的边界之外。

step1/FoldingStateActor.kt

val foldPosition = FoldableUtils.getFeaturePositionInViewRect(
            foldingFeature,
            cameraViewfinder.parent as View
        ) ?: return

现在,您可以确定您有一个影响布局的 FoldingFeature,因此您需要移动您的内容。

  1. 检查 FoldingFeature 是否为 HALF_OPEN,否则您只需恢复内容的位置。如果它是 HALF_OPEN,则需要运行另一个检查,并根据折叠的方向采取不同的操作。

step1/FoldingStateActor.kt

if (foldingFeature.state == FoldingFeature.State.HALF_OPENED) {
    when (foldingFeature.orientation) {
        FoldingFeature.Orientation.VERTICAL -> {
            cameraViewfinder.moveToRightOf(foldPosition)
        }
        FoldingFeature.Orientation.HORIZONTAL -> {
            cameraViewfinder.moveToTopOf(foldPosition)
        }
    }
} else {
    cameraViewfinder.restore()
}

如果折叠是 VERTICAL,则将内容移动到右侧,否则将其移动到折叠位置的顶部。

  1. 构建并运行您的应用,然后展开您的设备并将其置于桌面模式,以查看内容相应地移动!

7. 恭喜!

在本 Codelab 中,您了解了一些可折叠设备独有的功能,例如后置显示模式或桌面模式,以及如何使用 Jetpack WindowManager 来解锁这些功能。

您已准备好为您的相机应用实现出色的用户体验。

进一步阅读

参考