为任意屏幕尺寸构建 Android 界面
△ 为任意屏幕尺寸构建 Android 界面
Bilibili 视频链接
https://www.bilibili.com/video/BV1eq4y1y7NR/
用户参与度
在 Android 开发者峰会举办后的几个月,Play 商店推出新的激励措施,包括会按照设备类型对应用进行评级等举措,鼓励开发者将更多目光放到大屏上去。所以目前正是迎接这些变化的绝佳时机,不仅能够迎合之后的市场变化,还能就此解决因为没有适配大屏而造成用户的使用体验欠缺的尴尬。
窗口大小类和 Reference Devices
class WindowMetrics {
class WindowSizeClass(val name: String) {
companion object {
val COMPACT = WindowSizeClass(“COMPACT”)
val MEDIUM = WindowSizeClass(“MEDIUM”)
val EXPANDED = WindowSizeClass(“EXPANDED”)
}
}
val widthClass: WindowSizeClass
get() {...}
val heightClass: WindowSizeClass
get() {...}
}
有一点比较重要的是,从 Android 12 开始,将允许应用任意调整尺寸,且允许所有应用都以多窗口模式运行。以 Samsung Galaxy Fold 系列来看,其提供的分屏模式使得屏幕利用率提高了 7 倍,而分屏允许用户根据自己的偏好对尺寸进行调整,这也进一步突出了构建可动态调整尺寸界面的重要性。
从设备和配置的角度来对布局进行考量,我们让每个窗口大小类都代表了一些典型设备的配置 (如下图所示),当您考虑基于断点对布局进行设计时,这将会是一个很有用的参考。其中,较小型代表了竖屏模式下手机的典型模式,中等型代表了大部分平板电脑和更大的可折叠设备的尺寸,展开型则代表了平板电脑或更大的可折叠设备,或是桌面设备在横屏模式下的显示情况。
除了以上三种基于宽度的断点外,我们还引入了具有相同类别名称的基于高度的断点,以便适用于更高级别的布局场景,并赋予更多的灵活性。假设我们需要使用较小的高度断点来对横屏手机界面进行布局优化,虽然这听起来很复杂,但是别担心,根据我们同许多 Android 开发者进行深谈后,大部分情况下只需要根据宽度进行布局适配就可以了。
△ 基于高度的窗口大小类的表示
△ 四种 Reference Devices
在本文对大屏幕适配的介绍中,若您只想快速知晓要注意的点,那请记住以下几点:
为了确保应用在不同设备尺寸上都能够正确展示,请优先针对较小和展开型宽度大小类来优化布局; 在所有的 Reference Devices 上都测试一遍您的应用,优先采用在中等型下的最佳布局; 为了提供更好的用户体验,请添加对应用有意义的功能,如支持可折叠设备的折叠状态或针对键盘、鼠标和触控笔输入支持进行优化;
如需了解更多有关窗口大小类的详细信息,请查阅窗口大小类:
如需查看关于窗口大小类的实际应用示例,请参阅 JetNews Compose 示例:
https://github.com/android/compose-samples/tree/main/JetNews
适配大屏
△ 更改之前的 Trackr 样式
上图是我们进行更改之前的 Trackr 样式,您会发现不管在什么设备或屏幕下,都会有一个单窗口任务列表以及用于导航到归档或设置页面的底部应用栏。Trackr 有几个主要界页,包括任务列表、任务详情、任务创建或编辑页面。接下来,就让我们对 Trackr 进行大屏优化。
NavigationRailView
我们正在 Android Studio Chipmunk 中开发一个新的工具 Visual Linting。可以通过它在 Layout Validation 中对界面进行检查,并显示一些警告和相关建议。我们使用 Visual Linting 对 Trackr 的布局进行检查,来通过工具找出一些潜在的大屏幕显示的相关问题。我们可以打开 main_activity 布局,然后打开 Layout Validation 工具 (还可以通过 View - Tools Window 路径找到该选项)。
△ Layout Validation 中对界面进行检查
在 Layout Validation 界面,您会发现有一个新的 Reference Devices 的类别,通过它您可以在 Android Studio 中使用新的 Reference Devices 功能。在 Layout Validation 右上角可以发现一个警告图标,单击此图标可以打开警告窗口,点击每个警告会显示哪些设备会受到影响。如上图所示,我们会发现两个跟大屏显示相关的警告: 底部应用栏只推荐用于较小屏幕以及 MaterialTextView 的部分行包含超过 120 个字符。
△ 警告窗口
展开警告可以查看到 Android Studio 是否提供了修改建议,这里关于底部应用栏警告的修改建议就是使用 Navigation Rail、抽屉式导航栏,或使用顶部应用栏代替。对于 Trackr,我认为使用导航路由更有建设性。而针对 MaterialTextView 的修改建议是要么减少 TextView 的宽度,要么考虑使用多列布局,这里使用多列布局更适合我们的应用。对于 Trackr,我们将会使用典型的列表加详情窗口的样式来解决这些警告,针对有着中等或较大宽度的设备,我们将使用 NavRail,而非底部应用栏,对于展开型宽度的设备我们将使用双窗口布局来展示任务和相关详情。
我们先来进行第一项优化,使用 NavRail 而非底部应用栏,首先我们要考虑的是导航模型,所幸我们不会更改很多具体的视图,仅仅只会更改导航方式,因为 NavRail 会一直存在于整个视图体系中,可以通过它导航到任何其他视图。为了实现这一模式,我们可以将 Navigation Rail View 添加到 main_activity 布局中,如下代码所示:
// main_activity.xml
<androidX.coordinatorlayout.widget.CorrdinatorLayout
…>
<com.google.android.material.navigationrail.NavigationRailView
android:id="@+id/navigation_rail"
android :layout_width="wrap_content"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app: layout_constraintTop_toTopOf="parent"
app:headerLayout="@layout/navigation_rail_header"
app:labelVisibilityMode="unlabeled"
app:menu="@menu/navigation_rail" />
</androidX.coordinatorlayout.widget.CorrdinatorLayout
在此之前,main_activity 仅由 FragmentContainerView 和 CoordinatorLayout 组成,并通过 NavHostFragment 来托管其他 Fragment。而将 NavigationRailView 放置在 main_activity 布局级别后,它将在所有视图中持久存在。尽管如此,我只想要 NavigationRail 用于宽度为 600dp 或者更大的屏幕尺寸,要实现这一点,一个简单的方法是添加资源限定 (resource-qualified) 的 main_activity 布局,并在包含 NavHostFragment 的 FragmentContainerView 的同一级别上添加 NavigationRailView:
// w600dp/tasks_fragment.xml
<layout...>
<data.../>
<androidx.coordinatorlayout.widget.CoordinatorLayout...>
<com.google.android.material.appbar.AppBarLayout.../>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tasks_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingLeft="@dimen/pane_margin"
android:paddingRight="@dimen/pane_margin"
tools:ignore="SpeakableTextPresentCheck"
tools:listitem="@layout/task_summary"/>
<com.google.android.material.bottomappbar.BottomAppBar.../>
<com.google.android.material.floatingactionbutton.FloatingActionButton.../>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
<!=-NavRail Menu -->
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item>
android:id="@+id/nav_tasks"
android:icon="@drawable/ic_task" .../>
<item
android:id="@+id/nav_archives"
android:icon="@drawable/ic_archive" .../>
</menu>
// MainActivity NavController
class MainActivity: AppCompatActivity() {
...
override func onCreate(saveInstanceState: Bundle?) {
...
binding.navigationRail?.apply{
setupWithNavController(navController)
setOnItemReselectedListener()
headerView?.setOnClickListener {
navController.navigate(R.id.nav_task_edit_graph)
}
}
}
}
这样就完成了,可以在 Android Studio 查看显示是否一切正常,通过在各种 Reference Devices 中来回切换查看布局是否按照我们的预期进行。当查看 Phone Reference Device 时,依然能够看到底部应用栏,而切换到更大的屏幕后,我们发现它开始使用 NavRail 了,一切按照我们的预期进行。
△ Phone Reference Device 下的效果
△ Tablet Reference Device 下的效果
SlidingPanelLayout
接下来让我们继续基于展开型宽度设备来实现双窗口视图布局。支持这一布局方式的一个简单方法是使用 SlidingPaneLayout,它的优势在于可以轻松复用现有的布局代码,以下是目前更新后的导航图:
我们可以通过 NavigationRailView 导航到应用任意一个顶层布局,但仍然可以通过选择界面中某个单项任务而导航到详情页面的 Fragment。这种模式在实现 SlidingPanelLayout 时会发生一些变化,我们将添加一个新布局 TwoPaneTasks 来包含 SlidingPaneLayout,此布局将同时包含任务列表和详情的 Fragment。通过这种方式更新应用导航,无论屏幕尺寸如何都能够拥有相同的导航图,这意味着调整屏幕尺寸不会产生导航的变化,从而让用户感到困惑。
由于任务和详情都呈现在 SlidingPaneLayout 中的同一个新的 Fragment 中,因此我们为该 Fragment 的导航交互专门添加一个新的子导航层次结构。这样,当我选择一项任务并且应用从双窗口变成单窗口时,该项目将位于导航栈的顶部,并是可见的状态。
简单说,我们将使用 SlidingPaneLayout 和 FragmentContainerView 来添加一个新 Fragment 来托管任务和详情窗格,这样不必对现有代码进行大的重构。
// tasks_two_pane_fragment.xml
<layout...>
<androidx.slidingpanelayout.widget.SlidingPaneLayout
android:id="@+id/sliding_pane_layout"...>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/list_pane"
android:name="com.example.android.trackr.ui.tasks.TasksFragment"
android:layout_width="@dimen/list_pane_width"
android:layout_height="match_parent"
android:layout_weight="@dimen/list_pane_weight"
tools:layout="@layout/tasks_fragment" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/detail_pane"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="@dimen/detail_pane_width"
android:layout_height="match_parent"
android:layout_weight="@dimen/detail_pane_weight"
app:navGraph="@navigation/task_detail"
tools:layout="@layout/task_detail_fragment" />
</androidx.slidingpanelayout.widget.SlidingPaneLayout>
</layout>
然后,继续更新应用的顶层导航层次结构,使新的双窗口 Fragment 成为应用的起始目的页面,并从应用的导航图中移除详情目的页面。
<!-- 顶层导航图 -->
<navigation app: startDestination="@+id/nav_tasks"...>
<fragment
android:id="@+id/nav_tasks"
android:name="..trackr.ui.tasks.TasksTwoPaneFragment" ...>
<action
android:id="@+id/to_task_edit"
app: destination="@id/nav_task_edit_graph' />
</fragment>
<!--Remove the 'details' destination-->
...
</navigation>
<!-SlidingPaneLayout 导航图-->
<navigation...
app:startDestination="@id/nav_task_detail_placeholder">
<action
android:id="@+id/to_task_detail"
app:destination="@id/nav_task_detail"
app:popUpTo="@id/nav_task_detail"
app :popUpToInclusive="true"
<fragment
android:id="@+id/nav_task_detail"
android:name="..trackr.ui..TaskDetailFragment"...>
<argument
android:name="taskId"
app:argType="long" />
</fragment>
<!-- 为其实目的页面使用一个 placeholder-->
<fragment
android:id="@+id/nav_task_detail_placeholder"
android:name="..trackr.ui.PlaceholderFragment"
tools:layout="@layout/placeholder_fragment"/>
</navigation>
最后,再为 SlidingPaneLayout 专门添加一个新导航图,并在 TasksTwoPaneFragment Kotlin 代码中处理 SlidingPaneLayoutNavController 配置逻辑。通过这两项更改应用在不同设备不同外形下的布局会更加合理。完成这些后,我们再次通过在 Android Studio 中的 Reference Devices 工具,就能看到新的布局在所有的设备屏幕中都能够完美布局了。而为了在应用运行时进行测试,Android Studio Chipmunk 提供了可支持尺寸调整的模拟器,通过它可以在相同的 Reference Devices 之间切换,来快速验证应用布局是否正确。
另外,SlidingPaneLayout 提供了另一个重要特性是它不仅适用于大屏幕设备,而且适用于多屏幕设备。Microsoft 最近为 SlidingPaneLayout 提供了一个支持铰链检测的功能,让其自动能够支持跨屏幕拆分窗口,而无需更改任何代码。这意味着应用的新列表/详情布局将适用于所有设备,包括多屏幕设备。
虽然上述提到的方法对于优化大屏显示非常有用,但是许多开发者的应用都基于多个 Activity,对于这些应用,12L 中发布的新 Activity Embedding API 将使支持双窗口视图等新界面范式变得容易,敬请期待。
Jetpack Compose
△ JetNews 的主界面展示
前文中已经介绍了 WindowManager API,目前我们正在将其集成到 Compose 中去,以便更轻松地从 Compose 中访问这些信息。在此期间,我们可以创建一个 composable 函数来处理与 WindowManager 的集成,然后轻松将当前 Activity 的窗口信息转换为最终的窗口大小类,代码如下所示:
@Composable
fun Activity.rememberWindowSizeClass(): WindowSize {
val configuration = LocalConfiguration.current
val windowMetrics = remember (configuration) {
WindowMetricsCalculator.getOrCreate()
.computeCurrentWindowMetrics(this)
}
val windowDpSize = with (LocalDensity.current) {
windowMetrics.bounds.toComposeRect().size.toDpSize()
}
when {
windowDpSize.width < 600.dp -> WindowSize.Compact
windowDpSize.width < 840.dp
else -> WindowSize.Expanded
}
}
WindowManager 库很快就会推出直接使用这些类的 API,Compose 也会很快支持更方便的功能来完成此项工作,敬请期待。目前,您可暂时借用这一代码来完成这一功能上的需要。
回到 JetNews,我们可以看到在大屏状态下,侧边的抽屉导航栏会以模态的方式出现,但它会延伸到整个屏幕而出现大量空白区域。根据前文中提到的修改建议,是使用 Navigation Rail,而 Compose 则直接支持,我们仅需要对其进行设置并将内容传入即可。
NavigationRail(
header = {
JetnewsIcon()
}
) {
Column(verticalArrangement = Arrangement.Center) {
Icon(
icon = Icons.Filled.Home,
action = navigateToHome
)
Icon(
icon = Icons.Filled.ListAlt,
action = navigateToInterests
)
}
}
标题图标和两个导航项图标,一个用于主页面,一个用于 Interests 页面,并添加它们对应的导航操作。为了将 Navigation Rail 集成到应用中,我们对顶层应用组件做了一些更改。首先,我们获取当前的窗口大小类,以及显示较小尺寸上的 ModalDrawer,然后确保设置了 ModalDrawer 让其只响应该尺寸中的手势。再将 Navigation Rail 与包含应用中所有屏幕的主导航图并排放置:
@Composable
fun JetnewsApp() {
val windowSize = rememberWindowSizeState()
val isDrawerActive = windowSize == WindowSize.Compact
ModalDrawer(
gesturesEnabled = isDrawerActive
drawerContent = {...}
) {
val showNavRail = isDrawerActive
Row() {
if (showNavRail) {
AppNavRail()
}
JetnewsNavGraph()
}
}
}
然后我们发现由于文章列表依然在大屏下没有充分利用空间,因此我们决定在大屏下构建列表/详情布局,这一布局方式是 Material Design 中推荐的大屏幕规范布局之一,让我们将文章列表与打开的文章并排显示。JetNews 应用有两个我们可以复用的组件: PostList 和 PostContent,这种在一开始就将界面拆分为组件的做法,不仅能让测试更加容易,还能让我们轻松对布局进行改进。
为了并排显示 Feed 和 Post,JetNews 简单地使用 Row 包裹两个组件,第一个组件具有固定宽度,第二个组件填充屏幕的其余部分。详情组件包裹在交叉渐变动画中,这让用户点击列表打开文章时看到带有动画过渡的转换效果。
要正确构建列表/详情结构,除了实际布局之外我们还需要解决几个问题。其中比较有趣的一点是思考应用如何在不同尺寸布局之间转换,例如对于可折叠手机,应用可能会从较大的屏幕变为较小的屏幕。
为了正确处理如何将列表和详情窗口折叠成单窗口层次结构,当在较小的屏幕上时,我们需要知道用户最后与哪个窗口交互,为此,我们实现了一个简单的自定义修饰符来记录最后一次交互,并以此决定,在不同的折叠状态下应该显示什么内容,从而进一步提升层次结构。
@Composable
fun HomeFeedWithArticleDetailsScreen(...) {
Row() {
PostList(
modifier
.width(334.dp)
.notifyInput(onInteractWithList))
Crossfade(...) {
PostContent(
modifier
.fillMaxSize()
.notifyInput {
onInteractWithDetail(detailPost.id)
}
)
}
)}
}
我们还需要知道,我们是从多大尺寸的屏幕将一次只显示其中一个窗口转变为显示列表/详情布局的。在 JetNews 中我们首先获取窗口大小类的信息,在较小和中等型宽度显示单窗口,而在展开型宽度显示列表/详情布局。
val windowSize = rememberWindowSizeState()
val homeScreenType = when (windowSize) {
WindowSize.Compact,
WindowSize.Medium -> HomeScreenType.Feed
WIndowSize.Expanded -> HomeScreenType.FeeWithArticleDetails
}
然后,开始针对 JetNews 的导航进行更改。JetNews 最初以主页面和文章页面构建而成,每个页面都有自己的 ViewModel,导航和 ViewModel 之间的集成意味着两个页面始终在不同的导航路径上。但是,为了将页面重组成列表/详情布局,我们需要将这两个屏幕并排显示,此处我们有两种可选方案。一是在详情页面嵌套 NavHost,另外一种方案是统一 ViewModel,由于详情页面内并没有下一级别的导航入口而只会显示一篇打开的文章,我们决定采用第二种方式,将两个 ViewModel 合二为一来简化结构。
我们创建了三个主界面入口点,一个是 HomeFeedScreen,它只负责展示 PostList;一个是 ArticleScreen 负责展示 PostContent;以及新的 HomeFeedWithArticleDetailsScreen 负责显示包含 PostList 和 PostContent 的列表/详情布局。
获取更好的用户体验
除了目前提到的 API 之外,我们一直努力开发 Compose 的内部构件,以增强包括键盘和鼠标支持在内的输入设备,这对于在 Chrome OS 上运行的应用尤其有用。如需了解 Chrome OS 和输入详细信息,敬请关注我们近期的文章发布。
如需了解更多 Compose 示例详情,请查阅 Compose 示例代码:
https://github.com/android/compose-samples
新的 Compose 和大屏幕指南——构建自适应布局,希望能够对您的开发有所帮助:
测试和维护
现在您已了解如何轻松更新应用,来构建可调整尺寸的新界面。如何测试和维护项目也是一个非常重要的课题。维护并支持所有不同尺寸的界面会大大引入测试复杂性,我们一直努力在不提高工作量的情况下,通过新的自动化测试工具和 API,让您能够配置更多设备来增加测试覆盖率。我们将会通过 Gradle 托管设备,从而实现在各种屏幕尺寸和 API 级别上运行虚拟设备来运行现有的 instrumentation 测试。您只需描述要在其上运行测试的设备的配置,其余均由 Gradle 负责,包括设备预先配置和测试工作的运行。
只需在构建脚本过程中定义设备,并将其添加到设备组:
testOptions
devices {
pixel2api29 (com.android.build.api.dsl.ManagedVirtualDevice) {
nexus9api30 (com.android.build.api.dsl.ManagedVirtualDevice) {
device = "Nexus 9"
apiLevel = 30
systemImageSource = "google"
abi = "×86"
}
}
deviceGroups {
mediumAndExpandedWidth{
targetDevices.addAll(devices.pixel2api29)
targetDevices.addAll(devices.nexus9api30)
}
}
然后使用 Gradle 托管设备组来运行测试:
$ gradlew -Pandroid.experimental.androidTest.useUnifiedTestPlatform=true mediumAndExpandedWidthGroupDebugAndroidTest
$ gradlew -Pandroid.experimental.androidTest.numManagedDeviceShards=2 deviceDebugAndroidTest
但我们知道运行大量虚拟设备会占用 CPU 和内存,这可能会限制 Gradle 托管设备和测试分片的用处。为了解决此问题,Gradle 托管设备引入了一种针对 instrumentation 测试而优化的新型虚拟设备,称为自动化测试设备,这些设备以 headless 模式运行,禁用了自动化测试通常不需要的后台进程和服务,从而降低了每台设备的总体 CPU 和内存使用率,这将让您能够同时针对代表不同屏幕尺寸的多台设备运行测试。当前,这一功能可在 Android 10 上使用,随着时间的推移将支持更高的 API 级别,以确保现有的屏幕截图测试能够继续与自动化测试设备配合运行。
我们还在开发一组全新 AndroidX Testing API,让您能够将设备置于不同的状态进行测试。例如,您可以测试应用从平折变为半开状态,或在纵向或横向模式之间旋转时的反应。
AndroidX Testing API
https://developer.android.google.cn/jetpack/androidx/releases/test
总结
推荐阅读