针对 Chrome 操作系统优化 Android 应用

由于能够在 Chromebook 上运行 Android 应用,用户现在可使用庞大的应用生态系统和大量新功能。尽管对于开发者来说这是一个好消息,但是某些应用需要通过优化,才能满足用户的使用预期,并提供卓越的用户体验。此 Codelab 将向您介绍其中一些最常见的优化方法。

f60cd3eb5b298d5d.png

构建内容

您将构建一个功能性 Android 应用,从中了解针对 Chrome 操作系统的最佳做法和优化。您的应用将:

处理键盘输入,包括:

  • Enter 键
  • 箭头键
  • Ctrl- 和 Ctrl-Shift- 快捷键
  • 当前所选项的视觉反馈

处理鼠标输入,包括:

  • 右键点击
  • 悬停效果
  • 提示
  • 拖放

使用架构组件实现以下目标

  • 保持状态
  • 自动更新界面

52240dc3e68f7af8.png

学习内容

  • 在 Chrome 操作系统中处理键盘和鼠标输入的最佳做法
  • Chrome 操作系统特定优化
  • ViewModelLiveData 架构组件的基本实现

前提条件

从 GitHub 克隆代码库

git clone https://github.com/googlecodelabs/optimized-for-chromeos

…或者下载代码库的 ZIP 文件并解压缩

下载 Zip 文件

导入项目

  • 打开 Android Studio
  • 选择 Import Project 或依次选择 File > New > Import Project
  • 转到您克隆或解压缩项目的位置
  • 导入项目 optimized-for-chromeos
  • 请注意,有两个模块,分别是 startcomplete

尝试应用

  • 构建并运行 start 模块
  • 开始仅使用触控板
  • 点击恐龙
  • 发送一些秘密消息
  • 尝试将“Drag Me”文字或某个文件拖放到“Drop Things Here”区域
  • 尝试使用键盘导航和发送消息
  • 尝试在平板电脑模式下使用应用
  • 尝试旋转设备或调整窗口大小

您有什么看法?

虽然此应用非常基础,且看起来有问题的部分非常容易修正,但用户体验却很糟糕。让我们解决这个问题!

a40270071a9b5ac3.png

如果您使用键盘输入了一些秘密消息,则会发现 Enter 键未执行任何操作。这会使用户感到失望。

下面的示例代码和处理键盘操作文档应该可以帮助您达到理想的结果。

MainActivity.kt (onCreate)

// Enter key listener
edit_message.setOnKeyListener(View.OnKeyListener { v, keyCode, keyEvent ->
    if (keyEvent.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_ENTER) {
        button_send.performClick()
        return@OnKeyListener true
    }
    false
})

开始测试!只使用键盘便能发送消息是一种更出色的用户体验。

只使用键盘进行此应用的导航不更好吗?和以前一样,体验很糟糕,当用户面前有键盘而应用无法响应键盘操作时,会让人感到失望。

使视图可通过箭头和 Tab 键导航的最简单方法之一是让视图可聚焦。

请检查布局文件,并查看 ButtonImageView 标记。请注意,focusable 属性设为 false。在 XML 中将其更改为 true:

activity_main.xml

android:focusable="true"

或者以程序化方式进行操作:

MainActivity.kt

button_send.setFocusable(true)
image_dino_1.setFocusable(true)
image_dino_2.setFocusable(true)
image_dino_3.setFocusable(true)
image_dino_4.setFocusable(true)

试试看!您应该可以使用箭头键和 Enter 键来选择恐龙,但您可能无法看出当前所选的项,具体取决于您的操作系统版本、屏幕和光线情况。为了帮助您解决此问题,请将图片的背景资源设置为 R.attr.selectableItemBackground。

MainActivity.kt (onCreate)

val highlightValue = TypedValue()
theme.resolveAttribute(R.attr.selectableItemBackground, highlightValue, true)

image_dino_1.setBackgroundResource(highlightValue.resourceId)
image_dino_2.setBackgroundResource(highlightValue.resourceId)
image_dino_3.setBackgroundResource(highlightValue.resourceId)
image_dino_4.setBackgroundResource(highlightValue.resourceId)

通常情况下,Android 在确定哪个 View 位于当前已聚焦的 View 上方、下方、左侧或右侧方面相当出色。那它在此应用中的运行情况如何?请务必测试箭头键和 Tab 键。尝试使用箭头键在消息字段和发送按钮之间导航。现在,选择三角龙,然后按 Tab 键。焦点是否会切换至您预期的视图?

在本例中,情况有点失常(这是我们故意设置的)。对于用户而言,输入反馈中的一些小问题会让他们感到非常沮丧。

通常,如需手动调整箭头/Tab 键的行为,您可以使用如下代码:

箭头键

android:nextFocusLeft="@id/view_to_left"
android:nextFocusRight="@id/view_to_right"
android:nextFocusUp="@id/view_above"
android:nextFocusDown="@id/view_below"

Tab 键

android:nextFocusForward="@id/next_view"

或者以程序化方式进行操作:

箭头键

myView.nextFocusLeftId = R.id.view_to_left
myView.nextFocusRightId = R.id.view_to_right
myView.nextFocusTopId = R.id.view_above
myView.nextFocusBottomId = R.id.view_below

Tab 键

myView.nextFocusForwardId - R.id.next_view

在本例中,可使用以下代码更正焦点顺序:

MainActivity.kt

edit_message.nextFocusForwardId = R.id.button_send
edit_message.nextFocusRightId = R.id.button_send
button_send.nextFocusForwardId = R.id.image_dino_1
button_send.nextFocusLeftId = R.id.edit_message
image_dino_2.nextFocusForwardId = R.id.image_dino_3
image_dino_3.nextFocusForwardId = R.id.image_dino_4

您现在可以选择恐龙,但您可能很难看到所选项的突出显示,具体取决于您的屏幕、光线条件、视野和视力。例如,在下面的图片中,默认设置为在灰色背景上显示灰色内容。

c0ace19128e548fe.png

如需为您的用户提供更醒目的视觉反馈,请将以下代码添加到 AppTheme 下的 res/values/styles.xml

res/values/styles.xml

<item name="colorControlHighlight">@color/colorAccent</item>

23a53d405efe5602.png

看来您非常喜欢粉色,但上方图片中的突出显示效果对您来说可能太张扬,并且如果所有图片尺寸不完全一致,看起来就显得有些凌乱。借助状态列表可绘制对象,您可以创建仅在用户选择项目时显示的边框可绘制对象。

res/drawable/box_border.xml

<?xml version="1.0" encoding="UTF-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
   <item android:state_focused="true">
       <shape android:padding="2dp">
           <solid android:color="#FFFFFF" />
           <stroke android:width="1dp" android:color="@color/colorAccent" />
           <padding android:left="2dp" android:top="2dp" android:right="2dp"
               android:bottom="2dp" />
       </shape>
   </item>
</selector>

现在,请使用以下新的 box_border 背景资源替换上一步中的 highlightValue/setBackgroundResource 行:

MainActivity.kt (onCreate)

image_dino_1.setBackgroundResource(R.drawable.box_border)
image_dino_2.setBackgroundResource(R.drawable.box_border)
image_dino_3.setBackgroundResource(R.drawable.box_border)
image_dino_4.setBackgroundResource(R.drawable.box_border)

77ac1e50cdfbea01.png

631df359631b28bb.png

键盘用户期望基于 Ctrl 的常用快捷键能够正常使用。因此,您现在将向应用添加撤消 (Ctrl-Z) 和重做 (Ctrl-Shift-Z) 快捷键。

首先,请创建一个简单的点击历史记录堆栈。假设一位用户执行了 5 次操作,然后按两次 Ctrl-Z,以使操作 4 和 5 位于重做堆栈中,操作 1、2 和 3 位于撤消堆栈中。如果用户再次按下 Ctrl-Z,则操作 3 会从撤消堆栈移至重做堆栈。如果用户按下 Ctrl-Shift-Z,则操作 3 会从重做堆栈移至撤消堆栈。

9d952ca72a5640d7.png

请在主类的顶部定义不同的点击操作,并使用 ArrayDeque 创建堆栈。

MainActivity.kt

private var undoStack = ArrayDeque<Int>()
private var redoStack = ArrayDeque<Int>()

private val UNDO_MESSAGE_SENT = 1
private val UNDO_DINO_CLICKED = 2

每当用户发送消息时或点击恐龙时,将该操作添加到撤消堆栈。执行新操作时,清除重做堆栈。更新您的点击监听器,如下所示:

MainActivity.kt

//In button_send onClick listener
undoStack.push(UNDO_MESSAGE_SENT)
redoStack.clear()

...

//In ImageOnClickListener
undoStack.push(UNDO_DINO_CLICKED)
redoStack.clear()

现在我们将实际映射快捷键。目前支持 Ctrl- 命令,并且在 Android O 及更高版本中,可使用 dispatchKeyShortcutEvent 添加对 Alt- 和 Shift- 命令的支持。

MainActivity.kt (dispatchKeyShortcutEvent)

override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean {
    if (event.getKeyCode() == KeyEvent.KEYCODE_Z) {
        // Undo action
        return true
    }
    return super.dispatchKeyShortcutEvent(event)
}

在本例中,我们的要求略有些严苛。如需强调只有 Ctrl-Z 能触发回调,而 Alt-ZShift-Z 则不能,请使用 hasModifiers。按如下所示,填充撤消堆栈操作。

MainActivity.kt (dispatchKeyShortcutEvent)

override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean {
    // Ctrl-z == Undo
    if (event.keyCode == KeyEvent.KEYCODE_Z && event.hasModifiers(KeyEvent.META_CTRL_ON)) {
        val lastAction = undoStack.poll()
        if (null != lastAction) {
            redoStack.push(lastAction)

            when (lastAction) {
                UNDO_MESSAGE_SENT -> {
                    messagesSent--
                    text_messages_sent.text = (Integer.toString(messagesSent))
                }

                UNDO_DINO_CLICKED -> {
                    dinosClicked--
                    text_dinos_clicked.text = Integer.toString(dinosClicked)
                }

                else -> Log.d("OptimizedChromeOS", "Error on Ctrl-z: Unknown Action")
            }

            return true
        }
    }
    return super.dispatchKeyShortcutEvent(event)
}

开始测试!代码是否按预期运行?现在,使用带有辅助键标记的 OR 添加 Ctrl-Shift-Z

MainActivity.kt (dispatchKeyShortcutEvent)

// Ctrl-Shift-z == Redo
if (event.keyCode == KeyEvent.KEYCODE_Z &&
    event.hasModifiers(KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON)) {
    val prevAction = redoStack.poll()
    if (null != prevAction) {
        undoStack.push(prevAction)

        when (prevAction) {
            UNDO_MESSAGE_SENT -> {
                messagesSent++
                text_messages_sent.text = (Integer.toString(messagesSent))
            }

            UNDO_DINO_CLICKED -> {
                dinosClicked++
                text_dinos_clicked.text = Integer.toString(dinosClicked)
            }

            else -> Log.d("OptimizedChromeOS", "Error on Ctrl-Shift-z: Unknown Action")
        }

        return true
    }
}

对于许多界面,用户认定使用鼠标右键点击或点按两次触控板会打开上下文菜单。在此应用中,我们希望提供此上下文菜单,以便用户可以将这些炫酷的恐龙图片发送给好友。

8b8c4a377f5e743b.png

创建上下文菜单会自动添加右键点击功能。在许多情况下,您只需要此功能。此设置分为 3 个部分:

告知界面此视图有上下文菜单

针对您要添加上下文菜单的每个视图(本例中有 4 张图片),使用 registerForContextMenu

MainActivity.kt

registerForContextMenu(image_dino_1)
registerForContextMenu(image_dino_2)
registerForContextMenu(image_dino_3)
registerForContextMenu(image_dino_4)

定义上下文菜单的外观

设计 XML 格式菜单,其中应包含您所需的所有上下文选项。对于此任务,只需添加“Share”选项即可。

res/menu/context_menu.xml

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/menu_item_share_dino"
        android:icon="@android:drawable/ic_menu_share"
        android:title="@string/menu_share" />
</menu>

然后,在您的主 Activity 类中,替换 onCreateContextMenu 并传入 XML 文件。

MainActivity.kt

override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {
    super.onCreateContextMenu(menu, v, menuInfo)
    val inflater = menuInflater
    inflater.inflate(R.menu.context_menu, menu)
}

定义选择特定内容时要执行的操作

最后,通过替换 onContextItemSelected 定义要执行的操作。此处只会显示一个简短的 Snackbar,让用户知道图片已分享成功。

MainActivity.kt

override fun onContextItemSelected(item: MenuItem): Boolean {
    if (R.id.menu_item_share_dino == item.itemId) {
        Snackbar.make(findViewById(android.R.id.content),
            getString(R.string.menu_shared_message), Snackbar.LENGTH_SHORT).show()
        return true
    } else {
        return super.onContextItemSelected(item)
    }
}

开始测试!右键点击图片时,系统应该会显示上下文菜单。

MainActivity.kt

myView.setOnContextClickListener {
    // Display right-click options
    true
}

添加悬停时显示的提示文字是一种简单的方法,有助于用户了解您的界面的工作方式或提供更多信息。

17639493329a9d1a.png

使用 setTootltipText() 方法为每张照片添加恐龙名称提示。

MainActivity.kt

// Add dino tooltips
TooltipCompat.setTooltipText(image_dino_1, getString(R.string.name_dino_hadrosaur))
TooltipCompat.setTooltipText(image_dino_2, getString(R.string.name_dino_triceratops))
TooltipCompat.setTooltipText(image_dino_3, getString(R.string.name_dino_nodosaur))
TooltipCompat.setTooltipText(image_dino_4, getString(R.string.name_dino_afrovenator))

当指控设备悬停在视图上方时,向某些视图添加视觉反馈效果十分有用。

如需添加此类反馈,请使用以下代码,让 Send 按钮在鼠标悬停在其上方时变为绿色。

MainActivity.kt (onCreate)

button_send.setOnHoverListener(View.OnHoverListener { v, event ->
    val action = event.actionMasked

    when (action) {
        ACTION_HOVER_ENTER -> {
            val buttonColorStateList = ColorStateList(
                arrayOf(intArrayOf()),
                intArrayOf(Color.argb(127, 0, 255, 0))
            )
            button_send.setBackgroundTintList(buttonColorStateList)
            return@OnHoverListener true
        }

        ACTION_HOVER_EXIT -> {
            button_send.setBackgroundTintList(null)
            return@OnHoverListener true
        }
    }

    false
})

添加另一个悬停效果:更改与可拖动的 TextView 相关联的背景图片,以便用户知道该文本是可拖动的。

MainActivity.kt (onCreate)

text_drag.setOnHoverListener(View.OnHoverListener { v, event ->
    val action = event.actionMasked

    when (action) {
        ACTION_HOVER_ENTER -> {
            text_drag.setBackgroundResource(R.drawable.hand)
            return@OnHoverListener true
        }

        ACTION_HOVER_EXIT -> {
            text_drag.setBackgroundResource(0)
            return@OnHoverListener true
        }
    }

    false
})

开始测试!当鼠标悬停在“Drag Me!”(意思为:拖动我)文字上方时,您应该会看到界面上出现一个很大的手状图形。即使是这种花哨的反馈也会让用户体验更具触觉刺激感。

如需了解详情,请参阅 View.OnHoverListenerMotionEvent 文档。

在桌面环境中,将项目拖放到应用中(尤其是从 Chrome 操作系统的文件管理器中)是非常自然的做法。在此步骤中,设置一个可以接收文件或纯文本项的拖放目标。在本 Codelab 的下一部分中,我们将实现一个可拖动项。

cfbc5c9d8d28e5c5.gif

首先,请创建一个空的 OnDragListener。在开始编码之前,先查看其结构:

MainActivity.kt

protected inner class DropTargetListener(private val activity: AppCompatActivity
) : View.OnDragListener {
    override fun onDrag(v: View, event: DragEvent): Boolean {
        val action = event.action

        when (action) {
            DragEvent.ACTION_DRAG_STARTED -> {
                    return true
            }

            DragEvent.ACTION_DRAG_ENTERED -> {
                return true
            }

            DragEvent.ACTION_DRAG_EXITED -> {
                return true
            }

            DragEvent.ACTION_DRAG_ENDED -> {
                return true
            }

            DragEvent.ACTION_DROP -> {
                return true
            }

            else -> {
                Log.d("OptimizedChromeOS", "Unknown action type received by DropTargetListener.")
                return false
            }
        }
    }
}

每当发生以下任一不同的拖动事件时,系统就会调用 onDrag() 方法:开始拖动,将鼠标悬停在拖放区域上方,或实际拖动某项时。下面简要介绍了不同的拖动事件

  • ACTION_DRAG_STARTED 在任何项被拖动时触发。您的目标应该寻找可以接收的有效项,并提供视觉指示,表明它是就绪目标。
  • ACTION_DRAG_ENTEREDACTION_DRAG_EXITED 在某项被拖动且该项进入/退出拖放区域时触发。您应该提供视觉反馈,告知用户他们可以放下相应项。
  • ACTION_DROP 在相应项被实际放下时触发。在此处处理相应项。
  • ACTION_DRAG_ENDED 在放下成功完成或取消时触发。界面恢复到正常状态。

ACTION_DRAG_STARTED

每次开始拖动时触发此事件。在此处指明目标是可以接收特定项(返回 true),还是不可以接收特定项(返回 false)并直观地告知用户此情况。拖动事件将包含一个 ClipDescription,其中包含拖动项的相关信息。

如需确定此拖动监听器能否接收某个项,请检查该项的 MIME 类型。在本例中,我们通过将背景色调为浅绿色来指明目标是有效的目标。

MainActivity.kt

DragEvent.ACTION_DRAG_STARTED -> {
    // Limit the types of items that can be received
    if (event.clipDescription.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) ||
        event.clipDescription.hasMimeType("application/x-arc-uri-list")) {

        // Greenify background colour so user knows this is a target
        v.setBackgroundColor(Color.argb(55, 0, 255, 0))
        return true
    }

    // If the dragged item is of an unrecognized type, indicate this is not a valid target
    return false
}

ENTERED、EXITED 和 ENDED

ENTERED 和 EXITED 是视觉/触感反馈逻辑的方向。在本例中,当项目悬停在目标区域上方时加深绿色,以指示用户可以拖放该项。在 ENDED 中,将界面重置为正常非拖放状态。

MainActivity.kt

DragEvent.ACTION_DRAG_ENTERED -> {
    // Increase green background colour when item is over top of target
    v.setBackgroundColor(Color.argb(150, 0, 255, 0))
    return true
}

DragEvent.ACTION_DRAG_EXITED -> {
    // Less intense green background colour when item not over target
    v.setBackgroundColor(Color.argb(55, 0, 255, 0))
    return true
}

DragEvent.ACTION_DRAG_ENDED -> {
    // Restore background colour to transparent
    v.setBackgroundColor(Color.argb(0, 255, 255, 255))
    return true
}

ACTION_DROP

当项目实际放到目标上时,触发此事件。在此处完成相应处理。

注意:您需要使用 ContentResolver 访问 Chrome 操作系统文件。

在此演示中,目标可以接收纯文本对象或文件。对于纯文本,请在 TextView 中显示文本。如果是文件,请复制前 200 个字符并显示这些内容。

MainActivity.kt

DragEvent.ACTION_DROP -> {
    requestDragAndDropPermissions(event) // Allow items from other applications
    val item = event.clipData.getItemAt(0)
    val textTarget = v as TextView

    if (event.clipDescription.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) {
        // If this is a text item, simply display it in a new TextView.
        textTarget.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
        textTarget.text = item.text
        // In STEP 10, replace line above with this
        // dinoModel.setDropText(item.text.toString())
    } else if (event.clipDescription.hasMimeType("application/x-arc-uri-list")) {
        // If a file, read the first 200 characters and output them in a new TextView.

        // Note the use of ContentResolver to resolve the ChromeOS content URI.
        val contentUri = item.uri
        val parcelFileDescriptor: ParcelFileDescriptor?
        try {
            parcelFileDescriptor = contentResolver.openFileDescriptor(contentUri, "r")
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
            Log.e("OptimizedChromeOS", "Error receiving file: File not found.")
            return false
        }

        if (parcelFileDescriptor == null) {
            textTarget.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
            textTarget.text = "Error: could not load file: " + contentUri.toString()
            // In STEP 10, replace line above with this
            // dinoModel.setDropText("Error: could not load file: " + contentUri.toString())
            return false
        }

        val fileDescriptor = parcelFileDescriptor.fileDescriptor

        val MAX_LENGTH = 5000
        val bytes = ByteArray(MAX_LENGTH)

        try {
            val `in` = FileInputStream(fileDescriptor)
            try {
                `in`.read(bytes, 0, MAX_LENGTH)
            } finally {
                `in`.close()
            }
        } catch (ex: Exception) {
        }

        val contents = String(bytes)

        val CHARS_TO_READ = 200
        val content_length = if (contents.length > CHARS_TO_READ) CHARS_TO_READ else 0

        textTarget.setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f)
        textTarget.text = contents.substring(0, content_length)
        // In STEP 10, replace line above with this
        // dinoModel.setDropText(contents.substring(0, content_length))
    } else {
        return false
    }
    return true
}

OnDragListener

设置好 DropTargetListener 后,将其附加到您希望用来接收拖放项的视图。

MainActivity.kt

text_drop.setOnDragListener(DropTargetListener(this))

开始测试!请记住,您需要从 Chrome 操作系统的文件管理器中拖动文件。您可以使用 Chrome 操作系统文本编辑器创建文本文件,也可以从互联网下载图片文件。

现在,在您的应用中设置可拖动项。拖动过程通常通过长按视图触发。如需指明用户可以拖动某项,请创建一个 LongClickListener,为系统提供要传输的数据,并指明数据类型。您还可以在此处配置拖动项目时项目的外观。

设置从 TextView 中提取字符串的纯文本拖动项。将内容 MIME 类型设置为 ClipDescription.MIMETYPE_TEXT_PLAIN

如需在拖动期间呈现视觉效果,请使用内置的 DragShadowBuilder 呈现标准半透明拖动样式。如需更复杂的示例,请参阅文档中的开始拖动

请务必设置 DRAG_FLAG_GLOBAL 标记,以指明此项可以拖动到其他应用中。

MainActivity.kt

protected inner class TextViewLongClickListener : View.OnLongClickListener {
    override fun onLongClick(v: View): Boolean {
        val thisTextView = v as TextView
        val dragContent = "Dragged Text: " + thisTextView.text

        //Set the drag content and type
        val item = ClipData.Item(dragContent)
        val dragData = ClipData(dragContent, arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN), item)

        //Set the visual look of the dragged object
        //Can be extended and customized. We use the default here.
        val dragShadow = View.DragShadowBuilder(v)

        // Starts the drag, note: global flag allows for cross-application drag
        v.startDragAndDrop(dragData, dragShadow, null, View.DRAG_FLAG_GLOBAL)

        return false
    }
}

现在将 LongClickListener 添加到可拖动的 TextView 中。

MainActivity.kt (onCreate)

text_drag.setOnLongClickListener(TextViewLongClickListener())

试试看!您能否从 TextView 中拖动文字?

您的应用应该看起来相当不错:支持键盘操作、支持鼠标操作,还会显示恐龙!不过,在桌面环境中,用户会经常调整应用大小、最大化、取消最大化、翻转到平板电脑模式以及更改屏幕方向。已拖放项、已发送消息计数器和点击计数器会发生什么情况?

在创建 Android 应用时,必须了解 Activity 生命周期。随着应用变得越来越复杂,管理生命周期状态可能非常困难。幸运的是,架构组件可让您更轻松地以可靠的方式处理生命周期问题。在本 Codelab 中,我们将重点介绍如何使用 ViewModelLiveData 来保留应用状态。

ViewModel 有助于跨越生命周期更改维护界面相关数据。LiveData 充当观察者,以自动更新界面元素。

请考虑此应用中需要跟踪的数据:

  • 已发送消息计数器(ViewModel、LiveData)
  • 图片点击计数器(ViewModel、LiveData)
  • 当前拖放目标文本(ViewModel、LiveData)
  • 撤消/重做堆栈 (ViewModel)

检查用于设置此属性的 ViewModel 类的代码。从本质上看,它包含使用单例模式的 getter 和 setter。

DinoViewModel.kt

class DinoViewModel : ViewModel() {
    private val undoStack = ArrayDeque<Int>()
    private val redoStack = ArrayDeque<Int>()

    private val messagesSent = MutableLiveData<Int>().apply { value = 0 }
    private val dinosClicked = MutableLiveData<Int>().apply { value = 0 }
    private val dropText = MutableLiveData<String>().apply { value = "Drop Things Here!" }

    fun getUndoStack(): ArrayDeque<Int> {
        return undoStack
    }

    fun getRedoStack(): ArrayDeque<Int> {
        return redoStack
    }

    fun getDinosClicked(): LiveData<Int> {
        return dinosClicked
    }

    fun getDinosClickedInt(): Int {
        return dinosClicked.value ?: 0
    }

    fun setDinosClicked(newNumClicks: Int): LiveData<Int> {
        dinosClicked.value = newNumClicks
        return dinosClicked
    }

    fun getMessagesSent(): LiveData<Int> {
        return messagesSent
    }

    fun getMessagesSentInt(): Int {
        return messagesSent.value ?: 0
    }

    fun setMessagesSent(newMessagesSent: Int): LiveData<Int> {
        messagesSent.value = newMessagesSent
        return messagesSent
    }

    fun getDropText(): LiveData<String> {
        return dropText
    }

    fun setDropText(newDropText: String): LiveData<String> {
        dropText.value = newDropText
        return dropText
    }
}

在主 Activity 中使用 ViewModelProvider 获取 ViewModel。这样将使生命周期更易于管理。例如,撤消和重做堆栈会自动在调整大小、方向和出现布局更改时保持其状态。

MainActivity.kt (onCreate)

// Get the persistent ViewModel
dinoModel = ViewModelProviders.of(this).get(DinoViewModel::class.java)

// Restore our stacks
undoStack = dinoModel.getUndoStack()
redoStack = dinoModel.getRedoStack()

对于 LiveData 变量,请创建并附加 Observer 对象,并告知界面在变量发生变化时如何作出更改。

MainActivity.kt (onCreate)

// Set up data observers
dinoModel.getMessagesSent().observe(this, androidx.lifecycle.Observer { newCount ->
    text_messages_sent.setText(Integer.toString(newCount))
})

dinoModel.getDinosClicked().observe(this, androidx.lifecycle.Observer { newCount ->
    text_dinos_clicked.setText(Integer.toString(newCount))
})

dinoModel.getDropText().observe(this, androidx.lifecycle.Observer { newString ->
    text_drop.text = newString
})

这些观察者就位后,所有点击回调中的代码便可简化,只需修改 ViewModel 变量数据即可。

下面的代码展示了不需要直接操控 TextView 对象的方式,即,使用 LiveData 观察者的所有界面元素会自动更新。

MainActivity.kt

internal inner class SendButtonOnClickListener(private val sentCounter: TextView) : View.OnClickListener {
    override fun onClick(v: View?) {
        undoStack.push(UNDO_MESSAGE_SENT)
        redoStack.clear()
        edit_message.getText().clear()

        dinoModel.setMessagesSent(dinoModel.getMessagesSentInt() + 1)
    }
}

internal inner class ImageOnClickListener(private val clickCounter: TextView) : View.OnClickListener {
    override fun onClick(v: View) {
        undoStack.push(UNDO_DINO_CLICKED)
        redoStack.clear()

        dinoModel.setDinosClicked(dinoModel.getDinosClickedInt() + 1)
    }
}

最后,更新撤消/重做命令以使用 ViewModel 和 LiveData,而不是直接操控界面。

MainActivity.kt

when (lastAction) {
    UNDO_MESSAGE_SENT -> {
        dinoModel.setMessagesSent(dinoModel.getMessagesSentInt() - 1)
    }

    UNDO_DINO_CLICKED -> {
        dinoModel.setDinosClicked(dinoModel.getDinosClickedInt() - 1)
    }

    else -> Log.d("OptimizedChromeOS", "Error on Ctrl-z: Unknown Action")
}

...

when (prevAction) {
    UNDO_MESSAGE_SENT -> {
        dinoModel.setMessagesSent(dinoModel.getMessagesSentInt() + 1)
    }

    UNDO_DINO_CLICKED -> {
        dinoModel.setDinosClicked(dinoModel.getDinosClickedInt() + 1)
    }

    else -> Log.d("OptimizedChromeOS", "Error on Ctrl-Shift-z: Unknown Action")
}

试试看!现在如何调整它的大小?您喜欢架构组件吗?

有关架构组件的详细介绍,请参阅 Android 生命周期 Codelab这篇博文是了解 ViewModel 和 onSavedInstanceState 如何工作和交互的绝佳资源。

大功告成!太棒了!您已经对于在针对 Chrome 操作系统优化 Android 应用时开发者最常遇到的一些问题有了相当程度的了解。

52240dc3e68f7af8.png

示例源代码

从 GitHub 克隆代码库

git clone https://github.com/googlecodelabs/optimized-for-chromeos

…或者以 Zip 文件的形式下载代码库

下载 Zip 文件