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

1. 准备工作

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

可折叠设备是一项卓越的创新。它们提供独特的体验,并带来独特的机会,通过可免触摸使用的桌面界面等差异化功能满足用户的需求。

前提条件

  • 具备开发 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 下的第一个模块。

Android Studio 的屏幕截图,其中显示了与此 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,它提供了关于在设备显示屏或显示区域之间移动窗口的信息和行为。

该 API 可让您查询当前可与之互动的 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,该函数将关闭会话(如果 capability 已处于活动状态);或者调用 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

Rear Display 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. 选择“Switch screens now”,即可看到内容移到了外屏!

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() 来收集 WindowLayoutInfoFlow(其中包含 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 解锁这些功能。

您已掌握相关知识,可以随时为相机应用实现出色的用户体验了。

深入阅读

参考文档