使用 Jetpack WindowManager 在可折叠设备上优化相机应用

1. 在你开始之前

可折叠设备有什么特别之处?

可折叠设备是划时代的创新。它们提供独特的体验,并带来独特的机会,让你可以通过诸如桌面 UI(用于免提使用)等差异化功能来给用户带来惊喜。

先决条件

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

你将构建什么

在本代码实验室中,你将构建一个相机应用,该应用针对可折叠设备优化了布局。

c5e52933bcd81859.png

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

虽然相机应用是此 API 最方便的用例,但本代码实验室中学习的两个功能都可以应用于任何应用。

你将学到什么

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

你需要什么

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

2. 开始设置

获取起始代码

  1. 如果你安装了 Git,你可以简单地运行以下命令。要检查是否安装了 Git,请在终端或命令行中键入 git --version,并验证它是否可以正确执行。
git clone https://github.com/android/large-screen-codelabs.git
  1. 可选:如果你没有安装 Git,你可以点击以下按钮下载本代码实验室的所有代码:

打开第一个模块

  • 在 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(),但本代码实验室不会深入探讨这些信息。

从版本 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() 来收集一个包含所有可用信息的 Flow WindowLayoutInfo,其中包含 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. 恭喜!

在本代码实验室中,你了解了可折叠设备特有的功能,例如后置显示模式或桌面模式,以及如何使用 Jetpack WindowManager 解锁这些功能。

你现在可以为你的相机应用程序实现出色的用户体验了。

进一步阅读

参考资料