增强 Android 应用中的触控笔性能支持

1. 前言

触控笔是一种画笔工具,可帮助用户执行精确的任务。在此 Codelab 中,您将学习如何通过 android.os 和 androidx 库实现自然的触控笔体验。您还将学习如何使用 MotionEvent 类支持压力、倾斜度和方向,并防止手掌误触,以防产生不必要的轻触。此外,您还将学习如何使用 OpenGL 和 SurfaceView 类,通过动作预测和低延迟图形来缩短触控笔延迟时间。

前提条件

  • 有使用 Kotlin 和 lambda 的经验。
  • 具备有关如何使用 Android Studio 的基础知识。
  • 具备有关 Jetpack Compose 的基础知识。
  • 对 OpenGL 的低延迟图形有基本的了解。

学习内容

  • 如何将 MotionEvent 类用于触控笔。
  • 如何实现触控笔功能,包括对压力、倾斜度和方向的支持。
  • 如何在 Canvas 类中实现绘制操作。
  • 如何实现动作预测。
  • 如何使用 OpenGL 和 SurfaceView 类渲染低延迟图形。

所需条件

2. 获取起始代码

如需获取包含起始应用的主题和基本设置的代码,请按以下步骤操作:

  1. 克隆以下 GitHub 代码库:
git clone https://github.com/android/large-screen-codelabs
  1. 打开 advanced-stylus 文件夹。start 文件夹包含起始代码,end 文件夹包含解决方案代码。

3. 实现基本绘图应用

首先,为基本绘图应用构建必要的布局,让用户可以进行绘制,并使用 Canvas Composable 函数在界面上显示触控笔属性。该应用如下图所示:

基本的绘图应用。上半部分用于绘制可视化结果,下半部分用于绘图。

上半部分是一个 Canvas Composable 函数,用于绘制触控笔可视化结果,并显示触控笔的不同属性,如方向、倾斜度和压力。下半部分是另一个 Canvas Composable 函数,用于接收触控笔输入并绘制简单的笔画。

如需实现绘图应用的基本布局,请按以下步骤操作:

  1. 在 Android Studio 中,打开克隆的代码库。
  2. 依次点击 app > java > com.example.stylus,然后双击 MainActivity。MainActivity.kt 文件随即会打开。
  3. 在 MainActivity 类中,请注意观察 StylusVisualization 和 DrawArea Composable 函数。在本部分中,您将重点关注 DrawArea Composable 函数。

创建 StylusState 类

  1. 在同一 ui 目录中,依次点击 File > New > Kotlin/Class file。
  2. 在文本框中,使用 StylusState.kt 替换 Name 占位符,然后按 Enter(在 macOS 上,按 return)。
  3. 在 StylusState.kt 文件中,创建 StylusState 数据类,然后添加下表中的变量:

变量

类型

默认值

说明

pressure

Float

0F

介于 0 到 1.0 之间的值。

orientation

Float

0F

介于 -pi 到 pi 之间的弧度值。

tilt

Float

0F

介于 0 到 pi/2 之间的弧度值。

path

Path

Path()

存储由 Canvas Composable 函数通过 drawPath 方法渲染的线条。

StylusState.kt

package com.example.stylus.ui
import androidx.compose.ui.graphics.Path

data class StylusState(
   var pressure: Float = 0F,
   var orientation: Float = 0F,
   var tilt: Float = 0F,
   var path: Path = Path(),
)

显示方向、倾斜度和压力指标的信息中心视图

  1. 在 MainActivity.kt 文件中,找到 MainActivity 类,然后使用 mutableStateOf() 函数添加触控笔状态:

MainActivity.kt

import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import com.example.stylus.ui.StylusState

class MainActivity : ComponentActivity() {
    private var stylusState: StylusState by mutableStateOf(StylusState())

DrawPoint 类

DrawPoint 类会存储有关在界面上绘制的每个点的数据;当您将这些点连接起来后,便可创建线条。它模拟了 Path 对象的运作方式。

DrawPoint 类扩展了 PointF 类。它包含以下数据:

参数

类型

说明

x

Float

坐标

y

Float

坐标

type

DrawPointType

点的类型

DrawPoint 对象有两种类型,由 DrawPointType 枚举描述:

类型

说明

START

将线条的起点移到某个位置。

LINE

从上一个点跟踪线条。

DrawPoint.kt

import android.graphics.PointF
class DrawPoint(x: Float, y: Float, val type: DrawPointType): PointF(x, y)

将数据点渲染到路径中

对于此应用,StylusViewModel 类会保留线条数据,准备数据以进行渲染,并对 Path 对象执行一些操作以便防止手掌误触。

  • 如需保留线条的数据,请在 StylusViewModel 类中创建一个 DrawPoint 对象的可变列表:

StylusViewModel.kt

import androidx.lifecycle.ViewModel
import com.example.stylus.data.DrawPoint

class StylusViewModel : ViewModel() {private var currentPath = mutableListOf<DrawPoint>()

如需将数据点渲染到路径中,请按以下步骤操作:

  1. 在 StylusViewModel.kt 文件的 StylusViewModel 类中,添加一个 createPath 函数。
  2. 使用 Path() 构造函数创建一个类型为 Path 的 path 变量。
  3. 创建一个 for 循环,在其中迭代 currentPath 变量中的每个数据点。
  4. 如果数据点的类型为 START,请调用 moveTo 方法,以在指定的 x 和 y 坐标处开始绘制一条线。
  5. 否则,使用数据点的 x 和 y 坐标调用 lineTo 方法,以连接到上一个数据点。
  6. 返回 path 对象。

StylusViewModel.kt

import androidx.compose.ui.graphics.Path
import com.example.stylus.data.DrawPoint
import com.example.stylus.data.DrawPointType


class StylusViewModel : ViewModel() {
   private var currentPath = mutableListOf<DrawPoint>()

   private fun createPath(): Path {
      val path = Path()

      for (point in currentPath) {
          if (point.type == DrawPointType.START) {
              path.moveTo(point.x, point.y)
          } else {
              path.lineTo(point.x, point.y)
          }
      }
      return path
   }

private fun cancelLastStroke() {
}

处理 MotionEvent 对象

触控笔事件会经历一些 MotionEvent 对象,这些对象提供有关所执行操作的信息以及关联数据,例如指针的位置和压力。下表列出了 MotionEvent 对象的一些常量及其数据,它们可用于识别用户在界面上执行的操作:

常量

数据

ACTION_DOWN

指针轻触屏幕。它是线条的起点,位于 MotionEvent 对象报告的位置。

ACTION_MOVE

指针在界面上移动。它是绘制的线条。

ACTION_UP

指针停止轻触界面。线条到此结束。

ACTION_CANCEL

检测到不必要的轻触。取消最后一个笔画。

当应用收到新的 MotionEvent 对象时,界面应该会进行渲染以反映新的用户输入。

  • 若要处理 StylusViewModel 类中的 MotionEvent 对象,请创建一个收集线条坐标的函数:

StylusViewModel.kt

import android.view.MotionEvent

class StylusViewModel : ViewModel() {
   private var currentPath = mutableListOf<DrawPoint>()

   ...

   fun processMotionEvent(motionEvent: MotionEvent): Boolean {
      when (motionEvent.actionMasked) {
          MotionEvent.ACTION_DOWN -> {
              currentPath.add(
                  DrawPoint(motionEvent.x, motionEvent.y, DrawPointType.START)
              )
          }
          MotionEvent.ACTION_MOVE -> {
              currentPath.add(DrawPoint(motionEvent.x, motionEvent.y, DrawPointType.LINE))
          }
          MotionEvent.ACTION_UP -> {
              currentPath.add(DrawPoint(motionEvent.x, motionEvent.y, DrawPointType.LINE))
          }
          MotionEvent.ACTION_CANCEL -> {
              // Unwanted touch detected.
              cancelLastStroke()
          }
          else -> return false
      }
   
      return true
   }

将数据发送到界面

如需更新 StylusViewModel 类,以便界面可以收集 StylusState 数据类中的更改,请按以下步骤操作:

  1. 在 StylusViewModel 类中,创建类型为 MutableStateFlow 且类为 StylusState 的 _stylusState 变量,以及一个类型为 StateFlow 且类为 StylusState 的 stylusState 变量。每当 StylusViewModel 类中的触控笔状态发生变化,并且 MainActivity 类中的界面使用了 stylusState 变量时,_stylusState 变量都会修改。

StylusViewModel.kt

import com.example.stylus.ui.StylusState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

class StylusViewModel : ViewModel() {

   private var _stylusState = MutableStateFlow(StylusState())
   val stylusState: StateFlow<StylusState> = _stylusState
  1. 创建一个接受 StylusState 对象参数的 requestRendering 函数:

StylusViewModel.kt

import kotlinx.coroutines.flow.update

...

class StylusViewModel : ViewModel() {
   private var _stylusState = MutableStateFlow(StylusState())
   val stylusState: StateFlow<StylusState> = _stylusState

   ...
   
    private fun requestRendering(stylusState: StylusState) {
      // Updates the stylusState, which triggers a flow.
      _stylusState.update {
          return@update stylusState
      }
   }
  1. 在 processMotionEvent 函数的末尾,添加带有 StylusState 参数的 requestRendering 函数调用。
  2. 在 StylusState 参数中,从 motionEvent 变量检索倾斜度、压力和方向值,然后使用 createPath() 函数创建路径。这会触发数据流事件,稍后您可以在界面中连接该事件。

StylusViewModel.kt

...

class StylusViewModel : ViewModel() {

   ...

   fun processMotionEvent(motionEvent: MotionEvent): Boolean {

      ...
         else -> return false
      }


      requestRendering(
         StylusState(
             tilt = motionEvent.getAxisValue(MotionEvent.AXIS_TILT),
             pressure = motionEvent.pressure,
             orientation = motionEvent.orientation,
             path = createPath()
         )
      )
  1. 在 MainActivity 类中,找到 onCreate 函数的 super.onCreate 函数,然后添加状态收集方式。如需详细了解状态收集方式,请参阅以可感知生命周期的方式收集数据流。

MainActivity.kt

import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.flow.onEach
import androidx.lifecycle.Lifecycle
import kotlinx.coroutines.flow.collect

...
class MainActivity : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)

      lifecycleScope.launch {
          lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
              viewModel.stylusState
                  .onEach {
                      stylusState = it
                  }
                  .collect()
          }
      }

现在,每当 StylusViewModel 类发布新的 StylusState 状态时,activity 都会收到该状态,并且新的 StylusState 对象会更新本地 MainActivity 类的 stylusState 变量。

  1. 在 DrawArea Composable 函数的正文中,将 pointerInteropFilter 修饰符添加到 Canvas Composable 函数以提供 MotionEvent 对象。
  1. 将 MotionEvent 对象发送到 StylusViewModel 的 processMotionEvent 函数进行处理:

MainActivity.kt

import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.input.pointer.pointerInteropFilter

...
class MainActivity : ComponentActivity() {

   ...

@Composable
@OptIn(ExperimentalComposeUiApi::class)
fun DrawArea(modifier: Modifier = Modifier) {
   Canvas(modifier = modifier
       .clipToBounds()

 .pointerInteropFilter {
              viewModel.processMotionEvent(it)
          }

   ) {

   }
}
  1. 使用 stylusState path 属性调用 drawPath 函数,然后提供颜色和笔画样式。

MainActivity.kt

class MainActivity : ComponentActivity() {

...

   @Composable
   @OptIn(ExperimentalComposeUiApi::class)
   fun DrawArea(modifier: Modifier = Modifier) {
      Canvas(modifier = modifier
          .clipToBounds()
          .pointerInteropFilter {
              viewModel.processMotionEvent(it)
          }
      ) {
          with(stylusState) {
              drawPath(
                  path = this.path,
                  color = Color.Gray,
                  style = strokeStyle
              )
          }
      }
   }
  1. 运行应用,然后您会发现可以在界面上绘制内容了。

4. 实现对压力、方向和倾斜度的支持

在上一部分中,您了解了如何从 MotionEvent 对象中检索触控笔信息,例如压力、方向和倾斜度。

StylusViewModel.kt

tilt = motionEvent.getAxisValue(MotionEvent.AXIS_TILT),
pressure = motionEvent.pressure,
orientation = motionEvent.orientation,

不过,此快捷方式仅适用于第一个指针。检测到多点触控时,会检测到多个指针,而此快捷方式仅返回第一个指针(即界面上的第一个指针)的值。如需请求有关特定指针的数据,您可以使用 pointerIndex 参数:

StylusViewModel.kt

tilt = motionEvent.getAxisValue(MotionEvent.AXIS_TILT, pointerIndex),
pressure = motionEvent.getPressure(pointerIndex),
orientation = motionEvent.getOrientation(pointerIndex)

如需详细了解指针和多点触控,请参阅处理多点触控手势。

为压力、方向和倾斜度添加可视化结果

  1. 在 MainActivity.kt 文件中,找到 StylusVisualization Composable 函数,然后使用 StylusState 数据流对象的信息来渲染可视化结果:

MainActivity.kt

import StylusVisualization.drawOrientation
import StylusVisualization.drawPressure
import StylusVisualization.drawTilt
...
class MainActivity : ComponentActivity() {

   ...

   @Composable
   fun StylusVisualization(modifier: Modifier = Modifier) {
      Canvas(
          modifier = modifier
      ) {
          with(stylusState) {
              drawOrientation(this.orientation)
              drawTilt(this.tilt)
              drawPressure(this.pressure)
          }
      }
   }
  1. 运行应用。您会在界面顶部看到三个指示方向、压力和倾斜度的指示器。
  2. 使用触控笔在界面上涂画,然后观察每个可视化j结果对您的输入如何反应。

针对使用触控笔手写的“hello”一词显示的方向、压力和倾斜度

  1. 检查 StylusVisualization.kt 文件,了解如何构建每个可视化结果。

5. 实现防手掌误触功能

界面可以注册不必要的轻触。例如,为了支撑身体,用户在手写时会自然地将手放在屏幕上。

防手掌误触是一种用于检测此行为的机制,它会通知开发者取消上一组 MotionEvent 对象。一组以 ACTION_DOWN 常量开头的 MotionEvent 对象。

也就是说,您必须保留输入内容的历史记录,以便可以从界面上移除多余的轻触,然后重新渲染有效的用户输入。幸运的是,您已将历史记录存储在 StylusViewModel 类的 currentPath 变量中。

Android 提供 MotionEvent 对象中的 ACTION_CANCEL 常量,以通知开发者不必要的轻触。从 Android 13 开始,MotionEvent 对象会提供应针对 ACTION_POINTER_UP 常量进行检查的 FLAG_CANCELED 常量。

实现 cancelLastStroke 函数

  • 若要从最后一个 START 数据点移除数据点,请返回到 StylusViewModel 类,然后创建一个 cancelLastStroke 函数,该函数查找最后一个 START 数据点的索引,并且仅保留第一个数据点中的数据,直到索引减 1:

StylusViewModel.kt

...
class StylusViewModel : ViewModel() {
    ...

   private fun cancelLastStroke() {
      // Find the last START event.
      val lastIndex = currentPath.findLastIndex {
          it.type == DrawPointType.START
      }

      // If found, keep the element from 0 until the very last event before the last MOVE event.
      if (lastIndex > 0) {
          currentPath = currentPath.subList(0, lastIndex - 1)
      }
   }

添加 ACTION_CANCEL 和 FLAG_CANCELED 常量

  1. 在 StylusViewModel.kt 文件中,找到 processMotionEvent 函数。
  2. 在 ACTION_UP 常量中,创建一个 canceled 变量,用于检查当前 SDK 版本是否为 Android 13 或更高版本,以及 FLAG_CANCELED 常量是否已激活。
  3. 在下一行中,创建一个条件,用于检查 canceled 变量是否为 true。如果是,则调用 cancelLastStroke 函数来移除上一组 MotionEvent 对象。如果不是,则调用 currentPath.add 方法来添加上一组 MotionEvent 对象。

StylusViewModel.kt

import android.os.Build
...
class StylusViewModel : ViewModel() {
    ...
    fun processMotionEvent(motionEvent: MotionEvent): Boolean {
    ...
        MotionEvent.ACTION_POINTER_UP,
        MotionEvent.ACTION_UP -> {
           val canceled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
           (motionEvent.flags and MotionEvent.FLAG_CANCELED) == MotionEvent.FLAG_CANCELED

           if(canceled) {
               cancelLastStroke()
           } else {
               currentPath.add(DrawPoint(motionEvent.x, motionEvent.y, DrawPointType.LINE))
           }
        }
  1. 在 ACTION_CANCEL 常量中,请注意观察 cancelLastStroke 函数:

StylusViewModel.kt

...
class StylusViewModel : ViewModel() {
    ...
    fun processMotionEvent(motionEvent: MotionEvent): Boolean {
        ...
        MotionEvent.ACTION_CANCEL -> {
           // unwanted touch detected
           cancelLastStroke()
        }

已实现防止手掌误触!您可以在 palm-rejection 文件夹中找到有效的代码。

6. 实现低延迟

在本部分中,您将缩短用户输入内容与界面渲染之间的延迟时间,以提高性能。导致出现延迟时间的原因有多种,其中之一是长图形管道。您可以通过前端缓冲区渲染来减小图形管道。借助前端缓冲区渲染功能,开发者可以直接访问界面缓冲区,从而获得出色的手写和素描效果。

androidx.graphics 库提供的 GLFrontBufferedRenderer 类负责前端和双倍缓冲区渲染。它使用 onDrawFrontBufferedLayer 回调函数优化 SurfaceView 对象,以实现快速渲染,并使用 onDrawDoubleBufferedLayer 回调函数进行正常渲染。GLFrontBufferedRenderer 类和 GLFrontBufferedRenderer.Callback 接口可处理用户提供的数据类型。在此 Codelab 中,您将使用 Segment 类。

如要开始使用,请按以下步骤操作:

  1. 在 Android Studio 中,打开 low-latency 文件夹,以获取所有必需的文件:
  2. 请注意项目中的以下新文件:
  • 在 build.gradle 文件中,androidx.graphics 库已通过 implementation "androidx.graphics:graphics-core:1.0.0-alpha03" 声明导入。
  • LowLatencySurfaceView 类会扩展 SurfaceView 类,以在界面上渲染 OpenGL 代码。
  • LineRenderer 类包含 OpenGL 代码,用于在界面上渲染线条。
  • FastRenderer 类支持快速渲染,并实现了 GLFrontBufferedRenderer.Callback 接口。它还可拦截 MotionEvent 对象。
  • StylusViewModel 类包含数据点以及 LineManager 接口。
  • Segment 类按如下方式定义线段:
  • x1、y1:第一个点的坐标
  • x2、y2:第二个点的坐标

下图显示了数据如何在每个类之间移动:

MotionEvent 由 LowLatencySurfaceView 捕获,并发送到 onTouchListener 进行处理。onTouchListener 进行处理,并请求 GLFrontBufferRenderer 进行前端或双倍缓冲区渲染。GLFrontBufferRenderer 渲染到 LowLatencySurfaceView。

创建低延迟界面和布局

  1. 在 MainActivity.kt 文件中,找到 MainActivity 类的 onCreate 函数。
  2. 在 onCreate 函数的正文中,创建一个 FastRenderer 对象,然后传入 viewModel 对象:

MainActivity.kt

class MainActivity : ComponentActivity() {
   ...
   override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)

      fastRendering = FastRenderer(viewModel)

      lifecycleScope.launch {
      ...
  1. 在同一文件中,创建一个 DrawAreaLowLatency Composable 函数。
  2. 在函数正文中,使用 AndroidView API 封装 LowLatencySurfaceView 视图,然后提供 fastRendering 对象:

MainActivity.kt

import androidx.compose.ui.viewinterop.AndroidView
​​import com.example.stylus.gl.LowLatencySurfaceView

class MainActivity : ComponentActivity() {
   ...
   @Composable
   fun DrawAreaLowLatency(modifier: Modifier = Modifier) {
      AndroidView(factory = { context ->
          LowLatencySurfaceView(context, fastRenderer = fastRendering)
      }, modifier = modifier)
   }
  1. 在 onCreate 函数中的 Divider Composable 函数后面,将 DrawAreaLowLatency Composable 函数添加到布局中:

MainActivity.kt

class MainActivity : ComponentActivity() {
   ...
   override fun onCreate(savedInstanceState: Bundle?) {
   ...
   Surface(
      modifier = Modifier
          .fillMaxSize(),
      color = MaterialTheme.colorScheme.background
   ) {
      Column {
          StylusVisualization(
              modifier = Modifier
                  .fillMaxWidth()
                  .height(100.dp)
          )
          Divider(
              thickness = 1.dp,
              color = Color.Black,
          )
          DrawAreaLowLatency()
      }
   }
  1. 在 gl 目录中,打开 LowLatencySurfaceView.kt 文件,然后注意 LowLatencySurfaceView 类中的以下内容:
  • LowLatencySurfaceView 类扩展了 SurfaceView 类。它会使用 fastRenderer 对象的 onTouchListener 方法。
  • 调用 onAttachedToWindow 函数时,需要将使用 fastRenderer 类的 GLFrontBufferedRenderer.Callback 接口附加到 SurfaceView 对象,以便回调可以渲染到 SurfaceView 视图。
  • 调用 onDetachedFromWindow 函数时,需要释放使用 fastRenderer 类的 GLFrontBufferedRenderer.Callback 接口。

LowLatencySurfaceView.kt

class LowLatencySurfaceView(context: Context, private val fastRenderer: FastRenderer) :
   SurfaceView(context) {

   init {
       setOnTouchListener(fastRenderer.onTouchListener)
   }

   override fun onAttachedToWindow() {
       super.onAttachedToWindow()
       fastRenderer.attachSurfaceView(this)
   }

   override fun onDetachedFromWindow() {
       fastRenderer.release()
       super.onDetachedFromWindow()
   }
}

使用 onTouchListener 接口处理 MotionEvent 对象

如需在检测到 ACTION_DOWN 常量时处理 MotionEvent 对象,请按以下步骤操作:

  1. 打开 gl 目录下的 FastRenderer.kt 文件。
  2. 在 ACTION_DOWN 常量的正文中,创建用于存储 MotionEvent 对象的 x 坐标的 currentX 变量和存储其 y 坐标的 currentY 变量。
  3. 创建一个 Segment 变量,用于存储 Segment 对象,该对象接受 currentX 参数的两个实例和 currentY 参数的两个实例,因为它是线条的起点。
  4. 使用 segment 参数调用 renderFrontBufferedLayer 方法,以触发对 onDrawFrontBufferedLayer 函数的回调。

FastRenderer.kt

class FastRenderer ( ... ) {
   ...
   val onTouchListener = View.OnTouchListener { view, event ->
   ...
   MotionEvent.ACTION_DOWN -> {
      // Ask that the input system not batch MotionEvent objects,
      // but instead deliver them as soon as they're available.
      view.requestUnbufferedDispatch(event)

      currentX = event.x
      currentY = event.y

      // Create a single point.
      val segment = Segment(currentX, currentY, currentX, currentY)

      frontBufferRenderer?.renderFrontBufferedLayer(segment)
   }

如需在检测到 ACTION_MOVE 常量时处理 MotionEvent 对象,请按以下步骤操作:

  1. 在 ACTION_MOVE 常量的正文中,创建用于存储 currentX 变量的 previousX 变量和存储 currentY 变量的 previousY 变量。
  2. 创建一个用于保存 MotionEvent 对象当前 x 坐标的 currentX 变量,以及一个用于保存其当前 y 坐标的 currentY 变量。
  3. 创建一个 Segment 变量,用于存储接受 previousX、previousY、currentX 和 currentY 参数的 Segment 对象。
  4. 使用 segment 参数调用 renderFrontBufferedLayer 方法,以触发对 onDrawFrontBufferedLayer 函数的回调并执行 OpenGL 代码。

FastRenderer.kt

class FastRenderer ( ... ) {
   ...
   val onTouchListener = View.OnTouchListener { view, event ->
   ...   
   MotionEvent.ACTION_MOVE -> {
      previousX = currentX
      previousY = currentY
      currentX = event.x
      currentY = event.y

      val segment = Segment(previousX, previousY, currentX, currentY)
  
      // Send the short line to front buffered layer: fast rendering
      frontBufferRenderer?.renderFrontBufferedLayer(segment)
   }
  • 如需在检测到 ACTION_UP 常量时处理 MotionEvent 对象,请调用 commit 方法以触发对 onDrawDoubleBufferedLayer 函数的调用并执行 OpenGL 代码:

FastRenderer.kt

class FastRenderer ( ... ) {
   ...
   val onTouchListener = View.OnTouchListener { view, event ->
   ...   
   MotionEvent.ACTION_UP -> {
      frontBufferRenderer?.commit()
   }

实现 GLFrontBufferedRenderer 回调函数

在 FastRenderer.kt 文件中,onDrawFrontBufferedLayer 和 onDrawDoubleBufferedLayer 回调函数会执行 OpenGL 代码。在每个回调函数的开头,以下 OpenGL 函数会将 Android 数据映射到 OpenGL 工作区:

  • GLES20.glViewport 函数可定义在其中渲染场景的矩形的大小。
  • Matrix.orthoM 函数会计算 ModelViewProjection 矩阵。
  • Matrix.multiplyMM 函数会执行矩阵乘法,以将 Android 数据转换为 OpenGL 引用,并为 projection 矩阵提供设置。

FastRenderer.kt

class FastRenderer( ... ) {
    ...
    override fun onDraw[Front/Double]BufferedLayer(
       eglManager: EGLManager,
       bufferInfo: BufferInfo,
       transform: FloatArray,
       params: Collection<Segment>
    ) {
        val bufferWidth = bufferInfo.width
        val bufferHeight = bufferInfo.height

        GLES20.glViewport(0, 0, bufferWidth, bufferHeight)
        // Map Android coordinates to OpenGL coordinates.
        Matrix.orthoM(
           mvpMatrix,
           0,
           0f,
           bufferWidth.toFloat(),
           0f,
           bufferHeight.toFloat(),
           -1f,
           1f
        )

        Matrix.multiplyMM(projection, 0, mvpMatrix, 0, transform, 0)

系统会为您完成这部分代码设置,以便您专注于执行实际渲染的代码。onDrawFrontBufferedLayer 回调函数用于渲染界面上的小区域。它可提供 Segment 类型的 param 值,以便您快速渲染单个线段。LineRenderer 类是笔刷的 OpenGL 渲染程序,会应用线条的颜色和大小。

如需实现 onDrawFrontBufferedLayer 回调函数,请按以下步骤操作:

  1. 在 FastRenderer.kt 文件中,找到 onDrawFrontBufferedLayer 回调函数。
  2. 在 onDrawFrontBufferedLayer 回调函数的正文中,调用 obtainRenderer 函数以获取 LineRenderer 实例。
  3. 使用以下参数调用 LineRenderer 函数的 drawLine 方法:
  • 之前计算的 projection 矩阵。
  • Segment 对象的列表,在本例中为单个线段。
  • 线条的 color。

FastRenderer.kt

import android.graphics.Color
import androidx.core.graphics.toColor

class FastRenderer( ... ) {
...
override fun onDrawFrontBufferedLayer(
   eglManager: EGLManager,
   bufferInfo: BufferInfo,
   transform: FloatArray,
   params: Collection<Segment>
) {
   ...
   
   Matrix.multiplyMM(projection, 0, mvpMatrix, 0, transform, 0)

   obtainRenderer().drawLine(projection, listOf(param), Color.GRAY.toColor())
}
  1. 运行应用,然后您会发现在界面上绘制时的延迟时间非常短。不过,应用不会保留该线条,因为您仍需实现 onDrawDoubleBufferedLayer 回调函数。

系统会在 commit 函数之后调用 onDrawDoubleBufferedLayer 回调函数,以便保留该线条。回调会提供 params 值,其中包含一系列 Segment 对象。前端缓冲区上的所有线段都会在双缓冲区中重放以持久保留。

如需实现 onDrawDoubleBufferedLayer 回调函数,请按以下步骤操作:

  1. 在 StylusViewModel.kt 文件中,找到 StylusViewModel 类,然后创建一个 openGlLines 变量来存储 Segment 对象的可变列表:

StylusViewModel.kt

import com.example.stylus.data.Segment

class StylusViewModel : ViewModel() {
    private var _stylusState = MutableStateFlow(StylusState())
    val stylusState: StateFlow<StylusState> = _stylusState

    val openGlLines = mutableListOf<List<Segment>>()

    private fun requestRendering(stylusState: StylusState) {
  1. 在 FastRenderer.kt 文件中,找到 FastRenderer 类的 onDrawDoubleBufferedLayer 回调函数。
  2. 在 onDrawDoubleBufferedLayer 回调函数的正文中,使用 GLES20.glClearColor 和 GLES20.glClear 方法清除界面,以便从头开始渲染场景,并将线条添加到 viewModel 对象以将它们保留下来:

FastRenderer.kt

class FastRenderer( ... ) {
   ...
   override fun onDrawDoubleBufferedLayer(
      eglManager: EGLManager,
      bufferInfo: BufferInfo,
      transform: FloatArray,
      params: Collection<Segment>
   ) {
      ...
      // Clear the screen with black.
      GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f) 
      GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)

      viewModel.openGlLines.add(params.toList())
  1. 创建一个 for 循环,用于迭代并渲染 viewModel 对象中的每条线:

FastRenderer.kt

class FastRenderer( ... ) {
   ...
   override fun onDrawDoubleBufferedLayer(
      eglManager: EGLManager,
      bufferInfo: BufferInfo,
      transform: FloatArray,
      params: Collection<Segment>
   ) {
      ...
      // Clear the screen with black.
      GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f) 
      GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)

      viewModel.openGlLines.add(params.toList())

      // Render the entire scene (all lines).
      for (line in viewModel.openGlLines) {
         obtainRenderer().drawLine(projection, line, Color.GRAY.toColor())
      }
   }
  1. 运行应用,然后您会发现您可以在界面上绘制内容;线条在触发 ACTION_UP 常量后会保留。

7. 实现动作预测

您可以使用 androidx.input 库进一步缩短延迟时间。该库可分析触控笔的方向,并预测下一个点的位置,然后插入该点以进行渲染。

如需设置动作预测,请按以下步骤操作:

  1. 在 app/build.gradle 文件中,在依赖项部分中导入该库:

app/build.gradle

...
dependencies {
    ...
    implementation"androidx.input:input-motionprediction:1.0.0-beta01"
  1. 依次点击 File > Sync project with Gradle files。
  2. 在 FastRendering.kt 文件的 FastRendering 类中,将 motionEventPredictor 对象声明为属性:

FastRenderer.kt

import androidx.input.motionprediction.MotionEventPredictor

class FastRenderer( ... ) {
   ...
   private var frontBufferRenderer: GLFrontBufferedRenderer<Segment>? = null
   private var motionEventPredictor: MotionEventPredictor? = null
  1. 在 attachSurfaceView 函数中,初始化 motionEventPredictor 变量:

FastRenderer.kt

class FastRenderer( ... ) {
   ...
   fun attachSurfaceView(surfaceView: SurfaceView) {
      frontBufferRenderer = GLFrontBufferedRenderer(surfaceView, this)
      motionEventPredictor = MotionEventPredictor.newInstance(surfaceView)
   }
  1. 在 onTouchListener 变量中,调用 motionEventPredictor?.record 方法,以便 motionEventPredictor 对象获取运动数据:

FastRendering.kt

class FastRenderer( ... ) {
   ...
   val onTouchListener = View.OnTouchListener { view, event ->
      motionEventPredictor?.record(event)
      ...
      when (event?.action) {
   

下一步是使用 predict 函数预测 MotionEvent 对象。我们建议预测收到 ACTION_MOVE 常量的时间以及完成 MotionEvent 对象记录的时间。换言之,您应预测笔画的发生时间。

  1. 使用 predict 方法预测人工 MotionEvent 对象。
  2. 创建一个使用当前及预测的 x 坐标和 y 坐标的 Segment 对象。
  3. 使用 frontBufferRenderer?.renderFrontBufferedLayer(predictedSegment) 方法请求快速渲染预测的线段。

FastRendering.kt

class FastRenderer( ... ) {
   ...
   val onTouchListener = View.OnTouchListener { view, event ->
       motionEventPredictor?.record(event)
       ...
       when (event?.action) {
          ...
          MotionEvent.ACTION_MOVE -> {
              ...
              frontBufferRenderer?.renderFrontBufferedLayer(segment)

              val motionEventPredicted = motionEventPredictor?.predict()
              if(motionEventPredicted != null) {
                 val predictedSegment = Segment(currentX, currentY,
       motionEventPredicted.x, motionEventPredicted.y)
                 frontBufferRenderer?.renderFrontBufferedLayer(predictedSegment)
              }

          }
          ...
       }

插入预测事件以进行渲染,从而缩短延迟时间。

  1. 运行应用,然后注意延迟时间是否缩短。

缩短延迟时间将为触控笔用户提供更自然的触控笔体验。

8. 恭喜

恭喜!您已了解了如何像专业人士一样处理触控笔!

您学习了如何处理 MotionEvent 对象,以提取有关压力、方向和倾斜度的信息。您还学习了如何通过实现 androidx.graphics 库和 androidx.input 库来缩短延迟时间。这些增强功能相辅相成,提供更自然的触控笔体验。

了解更多内容