针对大屏设备的实战宝典

Android 提供打造 5 星级大屏应用所需的全部要素。在此实战宝典中的各个方案里,我们会选择并结合利用各种高品质要素,解决开发方面的特定问题。每个方案都包含最佳实践、优质的代码示例和分步说明,可帮助您成为大屏应用开发的高手。

星级评分

我们根据方案对大屏应用质量指南的遵循程度对其进行星级评分。

5 星 符合 1 级标准,应用在大屏设备上表现优异
4 星 符合 2 级标准,应用针对大屏设备实现了优化
3 星 符合 3 级标准,应用支持大屏设备
2 星 提供一些支持大屏设备的功能,但不符合大屏应用质量指南
1 星 满足特定用例的需求,但无法正常支持大屏设备

Chromebook 相机支持

3 星

在 Google Play 上获得 Chromebook 用户的关注。

如果您的相机应用只需基本的相机功能即可正常运行,请注意避免因为无意中指定高端手机上才有的高级相机功能而致使应用商店阻止 Chromebook 用户安装该应用。

Chromebook 内置有前置(朝向用户)的摄像头,非常适合视频会议、快照及其他应用场景。不过并非所有 Chromebook 都有后置(朝向外面)的摄像头,而且 Chromebook 上朝向用户的摄像头大多不支持自动对焦或闪光灯。

最佳实践

兼容面广的相机应用支持所有设备(无论摄像头配置如何),包括配有前置摄像头、后置摄像头和通过 USB 连接外接摄像头的设备。

为确保应用商店能让尽可能多的设备使用您的应用,请务必声明应用使用的所有相机功能,并明确指出各项功能是否为必需功能。

所需要素

  • CAMERA 权限:向应用授予对设备相机的使用权限
  • <uses-feature> 清单元素:向应用商店声明应用所使用的功能
  • required 属性:向应用商店指明应用在不用某项特定功能的情况下能否运行

步骤

总结

声明 CAMERA 权限。声明可提供基本相机支持的相机功能。指明每项功能是否为必需功能。

1. 声明 CAMERA 权限

在应用清单中添加以下权限:

<uses-permission android:name="android.permission.CAMERA" />
2. 声明基本的相机功能

在应用清单中添加以下功能:

<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<uses-feature android:name="android.hardware.camera.flash" android:required="false" />
3. 指明每项功能是否为必需功能

android.hardware.camera.any 功能设置 android:required="false" 以允许具有任何种类的内置或外置摄像头(或根本没有摄像头)的设备使用您的应用。

对于其他功能,请设置 android:required="false",以确保没有后置摄像头、自动对焦或闪光灯功能的设备(例如 Chromebook)也能使用您发布到应用商店中的应用。

成果

Chromebook 用户可以从 Google Play 和其他应用商店下载并安装您的应用。在具有全面的相机支持的设备(例如手机)上,相机功能不会受到限制。

通过明确设置应用支持的相机功能并指定应用必需的功能,您已让自己的应用可在尽可能多的设备上使用。

其他资源

如需了解更多信息,请参阅 <uses-feature> 文档中的相机硬件功能

限制应用在手机上的屏幕方向,但不限制其在大屏设备上的屏幕方向

2 星

您的应用在手机上的纵向模式下效果好,因此您将应用限制为仅限纵向显示。但是您发现,对于大屏设备而言,该应用在横向模式下或许可以实现更多优势。

如何同时兼顾两种场景?也就是说,如何让应用在小屏设备上仅可纵向显示,但在大屏设备上也可横向显示?

最佳实践

出色的应用会遵循用户偏好设置,例如设备屏幕方向设置。

大屏设备应用质量指南建议应用支持所有设备配置,包括纵向和横向屏幕方向、多窗口模式以及可折叠设备的折叠和展开状态。应用应针对不同的配置优化布局和界面,并且在配置发生更改时应保存及恢复状态。

此方案是一种临时性措施 - 提供些许的大屏幕支持。在您能够改进应用,使其针对所有设备配置提供全面支持之前,可以权且使用此方案。

所需要素

步骤

总结

在应用清单中让应用默认能够响应屏幕方向变化。在运行时,确定应用窗口大小。如果应用窗口很小,则通过替换清单中的屏幕方向设置来限制应用的屏幕方向。

1. 在应用清单中指定屏幕方向设置

您可以避免声明应用清单的 screenOrientation 元素(在这种情况下,屏幕方向默认为 unspecified),或将屏幕方向设为 fullUser。如果用户没有锁定基于传感器的旋转,您的应用将支持所有设备屏幕方向。

<activity
    android:name=".MyActivity"
    android:screenOrientation="fullUser">

使用 unspecifiedfullUser 之间的区别虽然微不足道,但很重要。如果您未声明 screenOrientation 值,系统会选择屏幕方向,并且系统用于定义屏幕方向的政策可能会因设备而异。另一方面,指定 fullUser 与用户为设备定义的行为更相符:如果用户锁定了基于传感器的旋转,应用会遵循用户的偏好设置;否则,系统会允许设备处于四种可能的屏幕方向(纵向、横向、反向纵向或反向横向)中的任何一种。请参阅 android:screenOrientation

2. 确定屏幕尺寸

在清单中设置为支持用户允许的所有屏幕方向后,您就能以编程方式根据屏幕尺寸指定应用的屏幕方向。

使用 Jetpack WindowManager 库的 WindowMetricsCalculator#computeMaximumWindowMetrics() 方法可获取设备屏幕尺寸(作为 WindowMetrics 对象)。窗口指标可与窗口大小类进行比较,以确定何时限制屏幕方向。

窗口大小类提供小屏幕和大屏幕之间的断点。

使用 WindowWidthSizeClass#COMPACTWindowHeightSizeClass#COMPACT 断点来确定屏幕尺寸:

Kotlin

/** Determines whether the device has a compact screen. **/
fun compactScreen() : Boolean {
    val metrics = WindowMetricsCalculator.getOrCreate().computeMaximumWindowMetrics(this)
    val width = metrics.bounds.width()
    val height = metrics.bounds.height()
    val density = resources.displayMetrics.density
    val windowSizeClass = WindowSizeClass.compute(width/density, height/density)

    return windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT ||
        windowSizeClass.windowHeightSizeClass == WindowHeightSizeClass.COMPACT
}

Java

/** Determines whether the device has a compact screen. **/
private boolean compactScreen() {
    WindowMetrics metrics = WindowMetricsCalculator.getOrCreate().computeMaximumWindowMetrics(this);
    int width = metrics.getBounds().width();
    int height = metrics.getBounds().height();
    float density = getResources().getDisplayMetrics().density;
    WindowSizeClass windowSizeClass = WindowSizeClass.compute(width/density, height/density);
    return windowSizeClass.getWindowWidthSizeClass() == WindowWidthSizeClass.COMPACT ||
                windowSizeClass.getWindowHeightSizeClass() == WindowHeightSizeClass.COMPACT;
}
    注意
  • 以上示例作为 activity 的方法实现;因此,系统会在 computeMaximumWindowMetrics() 的参数中将 activity 作为 this 解除引用。
  • 示例中使用 computeMaximumWindowMetrics() 方法代替 computeCurrentWindowMetrics(),因为应用可以在多窗口模式下启动,该模式会忽略屏幕方向设置。除非应用窗口占满整个设备屏幕,否则确定应用窗口大小并替换屏幕方向设置没有意义。

如需了解如何声明依赖项以使 computeMaximumWindowMetrics() 方法在您的应用中可用,请参阅 WindowManager

3. 替换应用清单设置

确定设备的屏幕尺寸较小后,可以调用 Activity#setRequestedOrientation() 来替换清单的 screenOrientation 设置:

Kotlin

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    requestedOrientation = if (compactScreen())
        ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else
        ActivityInfo.SCREEN_ORIENTATION_FULL_USER
    ...
    // Replace with a known container that you can safely add a
    // view to where the view won't affect the layout and the view
    // won't be replaced.
    val container: ViewGroup = binding.container

    // Add a utility view to the container to hook into
    // View.onConfigurationChanged. This is required for all
    // activities, even those that don't handle configuration
    // changes. You can't use Activity.onConfigurationChanged,
    // since there are situations where that won't be called when
    // the configuration changes. View.onConfigurationChanged is
    // called in those scenarios.
    container.addView(object : View(this) {
        override fun onConfigurationChanged(newConfig: Configuration?) {
            super.onConfigurationChanged(newConfig)
            requestedOrientation = if (compactScreen())
                ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else
                ActivityInfo.SCREEN_ORIENTATION_FULL_USER
        }
    })
}

Java

@Override
protected void onCreate(Bundle savedInstance) {
    super.onCreate(savedInstanceState);
    if (compactScreen()) {
        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
    } else {
        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_USER);
    }
    ...
    // Replace with a known container that you can safely add a
    // view to where the view won't affect the layout and the view
    // won't be replaced.
    ViewGroup container = binding.container;

    // Add a utility view to the container to hook into
    // View.onConfigurationChanged. This is required for all
    // activities, even those that don't handle configuration
    // changes. You can't use Activity.onConfigurationChanged,
    // since there are situations where that won't be called when
    // the configuration changes. View.onConfigurationChanged is
    // called in those scenarios.
    container.addView(new View(this) {
        @Override
        protected void onConfigurationChanged(Configuration newConfig) {
            super.onConfigurationChanged(newConfig);
            if (compactScreen()) {
                setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
            } else {
                setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_USER);
            }
        }
    });
}

通过将逻辑添加到 onCreate()View.onConfigurationChanged() 方法,您可以获取最大窗口指标,并且每当 activity 调整大小或在不同显示屏之间移动(例如在设备旋转后或者可折叠设备折叠或展开时)时,均可替换屏幕方向设置。如需详细了解何时配置会更改以及何时会导致 activity 重新创建,请参阅处理配置更改

成果

现在,在小屏设备上,无论设备如何旋转,应用应该会始终保持纵向模式。在大屏设备上,该应用应支持横向模式和纵向模式。

其他资源

如果您希望升级应用以使其始终支持所有设备配置,并需要这方面的帮助,请参阅以下内容:

使用外接键盘空格键暂停和继续播放媒体

4 星

针对大屏幕的优化包括处理外接键盘输入内容的功能,例如对用户按空格键做出响应,以暂停或继续播放视频和其他媒体。这尤其适用于通常连接到外接键盘的平板电脑,以及通常附带外接键盘但可在平板电脑模式下使用的 Chromebook。

当媒体是窗口中唯一的元素(例如全屏视频播放)时,在 activity 级别或(在 Jetpack Compose 中)屏幕级别响应按键事件。

最佳实践

每当您的应用播放媒体文件时,用户都应该能够通过按实体键盘上的空格键暂停和继续播放。

所需要素

Compose

  • onPreviewKeyEvent:此 Modifier 让组件可在自身(或其子项之一)获得焦点时拦截硬件按键事件。
  • onKeyEvent:与 onPreviewKeyEvent 类似,此 Modifier 让组件可在自身(或其子项之一)获得焦点时拦截硬件按键事件。

视图

  • onKeyUp():当某按键被释放且不由 activity 中的 View 处理时被调用。

步骤

总结

基于视图的应用和基于 Jetpack Compose 的应用会以类似的方式响应键盘按键操作:应用必须监听按键事件、过滤事件并响应所选的按键操作,例如按下空格键。

1. 监听键盘事件

视图

在应用的 activity 中,替换 onKeyUp() 方法:

Kotlin

override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
    ...
}

Java

@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
    ...
}

此方法会在每次某个按下的键被释放时调用,因此它会针对每次按键操作触发一次。

Compose

使用 Jetpack Compose 时,您可以在管理按键操作的界面中使用 onPreviewKeyEventonKeyEvent 修饰符:

Column(modifier = Modifier.onPreviewKeyEvent { event ->
    if (event.type == KeyEventType.KeyUp) {
        ...
    }
    ...
})

Column(modifier = Modifier.onKeyEvent { event ->
    if (event.type == KeyEventType.KeyUp) {
        ...
    }
    ...
})

2. 过滤对空格键的按键操作

onKeyUp() 方法或 Compose onPreviewKeyEventonKeyEvent 修饰符方法中,过滤 KeyEvent.KEYCODE_SPACE 以将正确的事件发送到您的媒体组件:

视图

Kotlin

if (keyCode == KeyEvent.KEYCODE_SPACE) {
    togglePlayback()
    return true
}
return false

Java

if (keyCode == KeyEvent.KEYCODE_SPACE) {
    togglePlayback();
    return true;
}
return false;

Compose

Column(modifier = Modifier.onPreviewKeyEvent { event ->
    if (event.type == KeyEventType.KeyUp && event.key == Key.Spacebar) {
        ...
    }
    ...
})

Column(modifier = Modifier.onKeyEvent { event ->
    if (event.type == KeyEventType.KeyUp && event.key == Key.Spacebar) {
        ...
    }
    ...
})

成果

您的应用现在可以响应对空格键的按键操作,以暂停和继续播放视频或其他媒体。

其他资源

如需详细了解键盘事件以及如何管理这些事件,请参阅处理键盘输入

触控笔防手掌误触

5 星

触控笔是一种可用于大屏设备上的极其高效、富有创造力的工具。但是,当使用触控笔绘图、手写或与应用互动时,用户有时会用手掌触摸屏幕。系统可能会在将其识别为意外手掌误触并予以忽略之前便将相应触摸事件报告给您的应用。

最佳实践

您的应用必须识别无关的触摸事件,并忽略这些事件。Android 通过分派 MotionEvent 对象来取消手掌触摸。针对 ACTION_CANCELACTION_POINTER_UPFLAG_CANCELED 检查该对象,以确定是否拒绝因手掌触摸而产生的手势。

所需要素

  • MotionEvent:表示触摸和移动事件。包含确定是否应忽略某事件所需的信息。
  • OnTouchListener#onTouch():接收 MotionEvent 对象。
  • MotionEvent#getActionMasked():返回与动作事件关联的操作。
  • ACTION_CANCELMotionEvent 常量,用于表明某手势应被撤消。
  • ACTION_POINTER_UPMotionEvent 常量,用于表明除第一个指针以外的某个指针已被释放(即放弃了与设备屏幕的接触)。
  • FLAG_CANCELEDMotionEvent 常量,用于表明指针释放导致了一次无意中的触摸事件。已添加到 Android 13(API 级别 33)及更高版本的 ACTION_POINTER_UPACTION_CANCEL 事件。

步骤

总结

检查分派给您的应用的 MotionEvent 对象。使用 MotionEvent API 确定事件特征:

  • 单指针事件 - 检查是否有 ACTION_CANCEL。在 Android 13 及更高版本中,还要检查是否有 FLAG_CANCELED
  • 多指针事件 - 在 Android 13 及更高版本中,检查是否有 ACTION_POINTER_UPFLAG_CANCELED

响应 ACTION_CANCELACTION_POINTER_UP/FLAG_CANCELED 事件。

1. 获取动作事件对象

OnTouchListener 添加到您的应用中:

Kotlin

val myView = findViewById<View>(R.id.myView).apply {
    setOnTouchListener { view, event ->
        // Process motion event.
    }
}

Java

View myView = findViewById(R.id.myView);
myView.setOnTouchListener( (view, event) -> {
    // Process motion event.
});
2. 确定事件操作和标志

检查是否有 ACTION_CANCEL,它在所有 API 级别上都用于指示单指针事件。在 Android 13 及更高版本中,检查 ACTION_POINTER_UP 中是否有 FLAG_CANCELED.

Kotlin

val myView = findViewById<View>(R.id.myView).apply {
    setOnTouchListener { view, event ->
        when (event.actionMasked) {
            MotionEvent.ACTION_CANCEL -> {
                //Process canceled single-pointer motion event for all SDK versions.
            }
            MotionEvent.ACTION_POINTER_UP -> {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
                   (event.flags and MotionEvent.FLAG_CANCELED) == MotionEvent.FLAG_CANCELED) {
                    //Process canceled multi-pointer motion event for Android 13 and higher.
                }
            }
        }
        true
    }
}

Java

View myView = findViewById(R.id.myView);
myView.setOnTouchListener( (view, event) -> {
    switch (event.getActionMasked()) {
        case MotionEvent.ACTION_CANCEL:
            // Process canceled single-pointer motion event for all SDK versions.
        case MotionEvent.ACTION_UP:
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
               (event.getFlags() & MotionEvent.FLAG_CANCELED) == MotionEvent.FLAG_CANCELED) {
                //Process canceled multi-pointer motion event for Android 13 and higher.
            }
    }
    return true;
});
3. 撤消手势

识别出手掌触摸后,您就可以撤消相应手势在屏幕上的效果。

您的应用必须保留用户操作历史记录,以便撤消手掌触摸等意外输入。如需查看示例,请参阅增强 Android 应用中的触控笔性能支持 Codelab 中的实现基本绘图应用

成果

现在,在 Android 13 及更高 API 级别上,您的应用可以针对多指针事件识别和拒绝手掌触摸;在所有 API 级别上,您的应用可以针对单指针事件识别和拒绝手掌触摸。

其他资源

如需了解详情,请参阅以下内容:

WebView 状态管理

3 星

WebView 是一个常用的组件,用于提供一种高级的状态管理系统。WebView 必须在配置更改后保持原来的状态和滚动位置。用户旋转设备或展开可折叠手机时,WebView 可能会丢失滚动位置,这会迫使用户从 WebView 顶部重新滚动到之前的滚动位置。

最佳实践

尽可能减少重新创建 WebView 的次数。WebView 善于管理其状态,并且您可以通过管理尽可能多的配置更改来利用这种特性。您的应用必须处理配置更改,因为重新创建 Activity(系统处理配置更改的方式)也会重新创建 WebView,这会导致 WebView 丢失其状态。

所需要素

步骤

总结

如需保存 WebView 状态,请尽可能避免重新创建 Activity,然后让 WebView 失效,以便它可以在保持其状态的同时调整大小。

1. 将配置更改添加到应用的 AndroidManifest.xml 文件中

指定由您的应用(而不是由系统)处理的配置更改,以避免重新创建 activity:

<activity
  android:name=".MyActivity"
  android:configChanges="screenLayout|orientation|screenSize
      |keyboard|keyboardHidden|smallestScreenSize" />

2. 每当应用收到配置更改时,让 WebView 失效

Kotlin

override fun onConfigurationChanged(newConfig: Configuration) {
    super.onConfigurationChanged(newConfig)
    webView.invalidate()
}

Java

@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    webview.invalidate();
}

此步骤仅适用于 View 系统,因为 Jetpack Compose 无需让任何元素失效即可正确调整 Composable 元素的大小。不过,如果管理方式有误,Compose 会经常重新创建 WebView。请使用 Accompanist WebView 封装容器在 Compose 应用中保存和恢复 WebView 状态。

成果

现在,应用的 WebView 组件在遇到多种配置更改时仍可保持原来的状态和滚动位置,无论是调整大小、更改屏幕方向还是折叠和展开都不例外。

其他资源

如需详细了解配置更改及其管理方式,请参阅处理配置更改

RecyclerView 状态管理

3 星

RecyclerView 可以使用尽可能少的图形资源显示大量数据。在 RecyclerView 滚动浏览其项列表时,RecyclerView 会重复使用已滚动到界面外的项的 View 实例,随着项在屏幕上滚动而创建新的项。但是,配置更改(例如设备旋转)可以重置 RecyclerView 的状态,迫使用户重新滚动到之前在 RecyclerView 项列表中的位置。

最佳实践

RecyclerView 应在所有配置更改期间保持其状态(特别是滚动位置)及其列表元素的状态。

所需要素

步骤

总结

设置 RecyclerView.Adapter 的状态恢复政策以保存 RecyclerView 滚动位置。保存 RecyclerView 列表项的状态。将列表项的状态添加到 RecyclerView 适配器,并在列表项绑定到 ViewHolder 后恢复其状态。

1. 启用 Adapter 状态恢复政策

启用 RecyclerView 适配器的状态恢复政策,以便在遇到各种配置更改时保持 RecyclerView 的滚动位置。将政策规范添加到适配器构造函数:

Kotlin

class MyAdapter() : RecyclerView.Adapter() {
    init {
        stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
    }
    ...
}

Java

class MyAdapter extends RecyclerView.Adapter {

    public Adapter() {
        setStateRestorationPolicy(StateRestorationPolicy.PREVENT_WHEN_EMPTY);
    }
    ...
}

2. 保存有状态列表项的状态

保存复杂 RecyclerView 列表项(例如包含 EditText 元素的项)的状态。例如,如需保存 EditText 的状态,请添加类似于 onClick 处理程序的回调来捕获文本更改。在回调中,定义要保存的数据:

Kotlin

input.addTextChangedListener(
    afterTextChanged = { text ->
        text?.let {
            // Save state here.
        }
    }
)

Java

input.addTextChangedListener(new TextWatcher() {
    
    ...

    @Override
    public void afterTextChanged(Editable s) {
        // Save state here.
    }
});

ActivityFragment 中声明回调。使用 ViewModel 存储状态。

3. 将列表项状态添加到 Adapter

将列表项的状态添加到 RecyclerView.Adapter。在您的主机 ActivityFragment 被创建后,将项状态传递到适配器构造函数:

Kotlin

val adapter = MyAdapter(items, viewModel.retrieveState())

Java

MyAdapter adapter = new MyAdapter(items, viewModel.retrieveState());

4. 在适配器的 ViewHolder 中恢复列表项状态

RecyclerView.Adapter 中,当您将 ViewHolder 绑定到某个项后,恢复此项的状态:

Kotlin

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
    ...
    val item = items[position]
    val state = states.firstOrNull { it.item == item }

    if (state != null) {
        holder.restore(state)
    }
}

Java

@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
    ...
    Item item = items[position];
    Arrays.stream(states).filter(state -> state.item == item)
        .findFirst()
        .ifPresent(state -> holder.restore(state));
}

成果

现在,您的 RecyclerView 能够恢复其滚动位置以及 RecyclerView 列表中每个项的状态。

其他资源