处理输入源变更

Chromebook 为用户提供了多种不同的输入选项:键盘、鼠标、触控板、触屏、触控笔、MIDI 以及游戏手柄/蓝牙控制器。这意味着,同一设备可以成为 DJ 的工作站、艺术家的画布,也可以成为游戏玩家首选的 AAA 流式游戏平台。

作为开发者,您可以利用用户已有的输入设备(从外接键盘到触控笔再到 Stadia 游戏控制器),为用户打造多功能且令人兴奋的应用体验。不过,所有这些可能性也要求您考虑界面,以确保应用体验顺畅且符合逻辑。如果您的应用或游戏是专门为手机设计的,这一点尤其重要。例如,如果您的游戏具有适用于手机的屏幕触控操纵杆,那么当用户使用键盘玩游戏时,您可能需要隐藏此操纵杆。

在此页面上,您将了解在考虑多个输入源时需要注意的主要问题,以及解决这些问题的策略。

用户发现支持的输入法

理想情况下,无论用户选择使用何种输入方式,您的应用都能顺畅地做出响应。这种情况通常很简单,您无需向用户提供额外信息。例如,如果用户使用鼠标、触控板、触摸屏、触控笔等点击按钮,该按钮应能正常工作,您无需告知用户可以使用这些不同的设备来激活按钮。

不过,在某些情况下,输入方法可以改善用户体验,因此让用户知道您的应用支持该输入方法可能是有意义的。部分示例:

  • 媒体播放应用可能支持许多便捷的键盘快捷键,但用户可能无法轻易猜到这些快捷键。
  • 如果您创建了 DJ 应用,用户可能一开始会使用触屏,而不会意识到您允许他们使用键盘/触控板来触控访问某些功能。同样,他们可能不知道您支持多种 MIDI DJ 控制器,因此鼓励他们查看受支持的硬件可能会带来更真实的 DJ 体验。
  • 您的游戏可能在触摸屏和键盘/鼠标上表现出色,但用户可能不知道它还支持许多蓝牙游戏控制器。连接其中一种服务可以提高用户满意度和互动度。

您可以在适当的时间通过消息帮助用户发现输入选项。每款应用的实现方式会有所不同。以下是一些示例:

  • 首次运行或每日提示弹出式窗口
  • 设置面板中的配置选项可以被动地向用户表明存在支持。例如,游戏设置面板中的“游戏控制器”标签页表示支持控制器。
  • 情境消息。例如,如果您检测到实体键盘,并发现用户正在使用鼠标或触屏点击某项操作,您可能需要显示一条有用的提示,建议用户使用键盘快捷键
  • 键盘快捷键列表。检测到实体键盘时,在界面中提供一种方式来显示可用键盘快捷键的列表,这样既可以表明支持键盘,又可以方便用户查看和记住支持的快捷键

输入变体的界面标签/布局

理想情况下,如果使用其他输入设备,您的可视化界面不应需要进行太多更改,所有可能的输入都应“正常工作”。不过,也有一些重要的例外情况。其中两个主要功能是触控专用界面和屏幕提示。

特定于触控的界面

每当您的应用具有特定于触控的界面元素(例如游戏中的屏幕操纵杆)时,您都应考虑在不使用触控时用户体验会如何。在某些移动游戏中,屏幕的很大一部分被基于触摸的游戏所需的控件占用,但如果用户使用游戏手柄或键盘玩游戏,这些控件是不必要的。您的应用或游戏应提供逻辑来检测当前正在使用的输入法,并相应地调整界面。如需查看一些相关示例,请参阅下方的“实现”部分

赛车游戏界面 - 一个带有屏幕控件,另一个带有键盘

屏幕提示

您的应用可能正在向用户提供有用的屏幕提示。请注意,这些功能不依赖于输入设备。例如:

  • 滑动到…
  • 点按任意位置即可关闭
  • 双指张合缩放
  • 按“X”可…
  • 长按即可启用

有些应用可能会调整其措辞,以实现与输入无关。在合理的情况下,最好使用这种方法,但在许多情况下,具体性非常重要,您可能需要根据所用的输入法显示不同的消息,尤其是在教程类模式下(例如在应用首次运行时)。

多语言注意事项

如果您的应用支持多种语言,您需要仔细考虑字符串架构。例如,如果您支持 3 种输入模式和 5 种语言,那么每条界面消息可能需要有 15 个不同的版本。这会增加添加新功能所需的工作量,并提高拼写错误的几率。

一种方法是将界面操作视为一组单独的字符串。例如,如果您将“关闭”操作定义为自己的字符串变量,并包含特定于输入的变体,例如“点按任意位置即可关闭”“按‘Esc’键即可关闭”“点击任意位置即可关闭”“按任意按钮即可关闭”,那么所有需要告知用户如何关闭某些内容的界面消息都可以使用这一个“关闭”字符串变量。当输入法发生变化时,只需更改此变量的值即可。

软键盘 / IME 输入

请注意,如果用户使用的应用没有实体键盘,则可以通过屏幕键盘输入文字。请务必测试在屏幕键盘显示时,必要的界面元素是否未被遮挡。如需了解详情,请参阅 Android IME 可见性文档

实现

在大多数情况下,应用或游戏应正确响应所有有效输入,无论屏幕上显示什么内容。如果用户使用触摸屏 10 分钟,然后突然改用键盘,则键盘输入应正常工作,无论屏幕上是否有视觉提示或控件。换句话说,功能应优先于视觉效果/文字。这有助于确保即使输入检测逻辑出现错误或发生意外情况,您的应用/游戏也能正常使用。

下一步是针对当前使用的输入法显示正确的界面。如何准确检测到这种情况?如果用户在使用您的应用时在不同的输入法之间切换,会发生什么情况?如果他们同时使用多种方法,该怎么办?

基于所接收事件的优先状态机

一种方法是跟踪当前的“有效输入状态”(表示当前向用户显示的输入提示),方法是跟踪应用接收到的实际输入事件,并使用优先逻辑在状态之间转换。

优先顺序

为何要优先考虑输入状态?用户与设备的互动方式多种多样,您的应用应支持用户的选择。例如,如果用户选择同时使用触屏和蓝牙鼠标,系统应支持此操作。但您应该显示哪些屏幕上的输入提示和控件?鼠标还是触控?

将每组提示定义为“输入状态”,然后确定其优先级,有助于以一致的方式做出此决定。

接收到的输入事件决定了状态

为什么只对收到的输入事件执行操作?您可能会认为,可以通过跟踪蓝牙连接来指示是否连接了蓝牙控制器,或者通过监控 USB 连接来指示是否连接了 USB 设备。出于以下两个主要原因,我们不建议采用这种方法。

首先,您能够根据 API 变量猜测出的有关已连接设备的信息并不一致,并且蓝牙/硬件/触控笔设备的数量在不断增加。

使用接收到的事件而不是连接状态的第二个原因是,用户可能已连接鼠标、蓝牙控制器、MIDI 控制器等,但并未主动使用它们与您的应用或游戏互动。

通过响应应用主动接收的输入事件,您可以确保实时响应用户的实际操作,而不是试图根据不完整的信息猜测用户的意图。

示例:支持触控、键盘/鼠标和控制器的游戏

假设您开发了一款适用于触控式手机的赛车游戏。玩家可以加速、减速、向右转、向左转,也可以使用氮气来提升速度。

当前的触屏界面包括:屏幕左下角的屏幕操纵杆(用于控制速度和方向),以及右下角的氮气按钮。用户可以在赛道上收集氮气罐,当屏幕底部的氮气条满时,按钮上方会显示一条消息,提示“按此按钮可使用氮气!”。如果用户是首次玩此游戏,或者一段时间内未收到任何输入,操纵杆上方会显示“教程”文字,向用户展示如何让赛车移动。

您想添加键盘和蓝牙游戏控制器支持。从哪里开始?

支持触控的赛车游戏

输入状态

首先,确定游戏可能运行的所有输入状态,然后列出您希望在每种状态下更改的所有参数。

                                                                                                                                                                        
触摸键盘/鼠标游戏控制器
       

对以下内容做出反应

     
       

所有输入

     
       

所有输入

     
       

所有输入

     
       

屏幕控件

     
       

- 屏幕摇杆
- 氮气按钮

     
       

- 无操纵杆
- 无氮气按钮

     
       

- 无操纵杆
- 无氮气按钮

     
       

文本

     
       

点按即可使用 Nitro!

     
       

按“N”键即可使用 Nitro!

     
       

按“A”即可使用 Nitro!

     
       

教程

     
       

用于控制速度/方向的操纵杆的图片

     
       

显示箭头键和 WASD 键的图片,用于控制速度/方向

     
       

用于控制速度/方向的游戏手柄的图片

     

跟踪“有效输入”状态,然后根据该状态更新界面(如果需要)。

注意:请注意:无论处于何种状态,游戏/应用都应响应所有输入方法。例如,如果用户正在驾驶汽车,并通过触摸屏进行操作,但按下了键盘上的 N 键,则应触发氮气加速操作。

优先状态变更

部分用户可能会同时使用触摸屏和键盘输入。有些用户可能一开始在沙发上以平板电脑模式使用您的游戏/应用,然后切换到在桌子上使用键盘。其他玩家可能会在游戏过程中连接或断开游戏控制器。

理想情况下,您不希望出现错误的界面元素,例如在用户使用触摸屏时提示用户按 n 键。同时,如果用户同时使用多个输入设备(例如触屏和键盘),您不希望界面在两种状态之间不断切换。

一种处理方法是确定输入源的优先级,并在状态变化之间设置延迟。对于上述赛车游戏,您始终需要确保在收到屏幕触控事件时,屏幕上的操纵杆可见,否则用户可能会觉得游戏无法使用。这样一来,触摸屏就会成为优先级最高的设备。

如果同时收到键盘和触屏事件,游戏应保持触屏模式,但仍会响应键盘输入。如果在 5 秒后未收到任何触屏输入,但仍在接收键盘事件,屏幕上的控件可能会淡出,游戏会进入键盘状态。

游戏控制器输入将遵循类似的模式:控制器界面状态的优先级将低于键盘/鼠标和触控,并且仅在收到游戏控制器输入(而非其他形式的输入)时显示。游戏始终会响应控制器输入。

下面是相应示例的状态图和转换表。根据您的应用或游戏调整创意。

优先状态机 - 触摸屏、键盘/鼠标、游戏控制器

                                                                                                                                        
#1 触控屏#2 键盘#3 游戏手柄
       

移至 #1

     
       

不适用

     
       

- 收到触控输入
- 立即移动到触控输入状态

     
       

- 收到触控输入
- 立即移动到触控输入状态

     
       

移至第 2 步

     
       

- 5 秒内未触摸
- 收到键盘输入
- 移至键盘输入状态

     
       

不适用

     
       

- 收到键盘输入
(立即移至“键盘输入”状态)

     
       

移至第 3 步

     
       

- 5 秒内无触控
- 5 秒内无键盘输入
- 收到游戏手柄输入
- 移至游戏手柄输入状态

     
       

- 5 秒内无键盘输入
- 收到游戏手柄输入
- 移至游戏手柄输入状态

     
       

不适用

     

注意:请注意,优先级有助于明确哪种类型的输入应占主导地位。输入状态立即在优先级中“向上”移动:

3. 游戏手柄 -> 2. 键盘 -> 1. 触控

一旦使用优先级更高的设备,就会立即切换到该设备,但其优先级会缓慢“下降”,只有在延迟一段时间后,并且只有在较低优先级的设备正在使用时才会下降。

输入事件

以下是一些示例代码,展示了如何使用标准 Android API 检测来自各种输入设备的输入事件。使用这些事件来驱动状态机,如上所示。您应根据预期的输入事件类型以及您的应用或游戏调整此一般概念。

键盘和控制器按钮

// Drive the state machine based on the received input type
// onKeyDown drives the state machine, but does not trigger game actions
// Both keyboard and game controller events come through as key events
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
    if (event != null) {
        // Check input source
        val outputMessage = when (event.source) {
            SOURCE_KEYBOARD -> {
                MyStateMachine.KeyboardEventReceived()
                "Keyboard event"
            }
            SOURCE_GAMEPAD -> {
                MyStateMachine.ControllerEventReceived()
                "Game controller event"
            }
            else -> "Other key event: ${event.source}"
        }
        // Do something based on source type
        findViewById(R.id.text_message).text = outputMessage
    }
    // Pass event up to system
    return super.onKeyDown(keyCode, event)
}
// Trigger game events based on key release
// Both keyboard and game controller events come through as key events
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
   when(keyCode) {
       KeyEvent.KEYCODE_N -> {
           MyStateMachine.keyboardEventReceived()
           engageNitro()
           return true // event handled here, return true
       }
   }
   // If event not handled, pass up to system
   return super.onKeyUp(keyCode, event)
}

注意:对于 KeyEvents,您可以选择使用 onKeyDown()onKeyUp()。在此示例中,onKeyDown() 用于控制状态机,而 onKeyUp() 用于触发游戏事件。

如果用户按住某个按钮,onKeyUp() 将仅在每次按键时触发一次,而 onKeyDown() 将被多次调用。如果您想对按下操作做出反应,应在 onKeyDown() 中处理游戏事件,并实现相应逻辑来处理重复事件。如需了解详情,请参阅处理键盘操作文档。

触控和触控笔

// Touch and stylus events come through as touch events
override fun onTouchEvent(event: MotionEvent?): Boolean {
   if (event != null) {
       // Get tool type
       val pointerIndex = event.action and ACTION_POINTER_INDEX_MASK shr ACTION_POINTER_INDEX_SHIFT
       val toolType = event.getToolType(pointerIndex)

       // Check tool type
       val outputMessage = when (toolType) {
           TOOL_TYPE_FINGER -> {
               MyStateMachine.TouchEventReceived()
               "Touch event"
           }
           TOOL_TYPE_STYLUS -> {
                MyStateMachine.StylusEventReceived()
               "Stylus event"
           }
           else -> "Other touch event: ${toolType}"
       }

       // Do something based on tool type, return true if event handled
       findViewById(R.id.text_message).text = outputMessage
   }
   // If event not handled, pass up to system
   return super.onGenericMotionEvent(event)
}

鼠标和操纵杆

// Mouse and joystick events come through as generic events
override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
   if (event != null) {
       // Check input source
       val outputMessage = when (event.source) {
           SOURCE_JOYSTICK -> {
                MyStateMachine.ControllerEventReceived()
               "Controller event"
           }
           SOURCE_MOUSE -> {
                MyStateMachine.MouseEventReceived()
               "Mouse event"
           }
           else -> "Other generic event: ${event.source}"
       }
       // Do something based on source type, return true if event handled
       findViewById(R.id.text_message).text = outputMessage
   }
   // If event not handled, pass up to system
   return super.onGenericMotionEvent(event)
}