在 Android 应用中增强手写笔支持

1. 开始之前

手写笔是一种笔形工具,可帮助用户执行精确任务。在本 Codelab 中,您将学习如何使用 android.osandroidx 库实现自然的手写笔体验。您还将学习如何使用 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 函数在屏幕上显示手写笔属性。它如下所示:

The basic drawing app. The upper part is for visualization and the lower part is for drawing.

上部是一个 Canvas Composable 函数,您可以在其中绘制手写笔可视化效果,并显示手写笔的不同属性,例如方向、倾斜和压力。下部是另一个接收手写笔输入并绘制简单线条的 Canvas Composable 函数。

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

  1. 在 Android Studio 中,打开克隆的代码库。
  2. 点击 app > java > com.example.stylus,然后双击 MainActivity。打开 MainActivity.kt 文件。
  3. MainActivity 类中,注意 StylusVisualizationDrawArea Composable 函数。本部分重点介绍 DrawArea Composable 函数。

创建一个 StylusState

  1. 在同一 ui 目录中,,点击 File > New > Kotlin/Class file
  2. 在文本框中,将 Name 占位符替换为 StylusState.kt,然后按 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()

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

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(),
)

A dashboard view of the orientation, tilt, and pressure metrics

  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() 构造函数创建一个类型为 Pathpath 变量。
  3. 创建一个 for 循环,遍历 currentPath 变量中的每个数据点。
  4. 如果数据点的类型为 START,则调用 moveTo 方法以在指定的 xy 坐标处开始绘制一条线。
  5. 否则,调用 lineTo 方法,传入数据点的 xy 坐标,以连接到上一个点。
  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 类中,创建一个 _stylusState 变量,类型为 StylusState 类的 MutableStateFlow;以及一个 stylusState 变量,类型为 StylusState 类的 StateFlow_stylusState 变量在 StylusViewModel 类中手写笔状态改变时进行修改,而 stylusState 变量由 MainActivity 类中的界面使用。

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 函数的主体中,向 Canvas Composable 函数添加 pointerInteropFilter 修饰符,以提供 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. 调用 drawPath 函数,传入 stylusStatepath 属性,然后提供颜色和描边样式。

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. 使用手写笔在屏幕上随意涂写,然后观察每种可视化效果如何根据您的输入做出反应。

The visualized orientation, pressure, and tilt for the word 'hello' written with a stylus

  1. 检查 StylusVisualization.kt 文件,了解每种可视化效果的构建方式。

5. 实现手掌防误触

屏幕可能会记录意外触控。例如,当用户在手写时自然地将手放在屏幕上以获得支撑时,就会发生这种情况。

手掌防误触是一种机制,可检测此行为并通知开发者取消最后一组 MotionEvent 对象。一组 MotionEvent 对象以 ACTION_DOWN 常量开始。

这意味着您必须维护输入的历史记录,以便从屏幕上移除意外触控并重新渲染合法的用户输入。幸运的是,您已将历史记录存储在 StylusViewModel 类中的 currentPath 变量中。

Android 通过 MotionEvent 对象的 ACTION_CANCEL 常量向开发者告知意外触控。从 Android 13 开始,MotionEvent 对象提供了 FLAG_CANCELED 常量,应在 ACTION_POINTER_UP 常量上检查此常量。

实现 cancelLastStroke 函数

  • 如需移除从最后一个 START 数据点开始的数据点,请返回 StylusViewModel 类,然后创建一个 cancelLastStroke 函数,该函数查找最后一个 START 数据点的索引,并仅保留从第一个数据点到索引减一的数据:

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_CANCELFLAG_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 文件中,已通过 implementation "androidx.graphics:graphics-core:1.0.0-alpha03" 声明导入 androidx.graphics
  • LowLatencySurfaceView 类扩展了 SurfaceView 类,用于在屏幕上渲染 OpenGL 代码。
  • LineRenderer 类包含用于在屏幕上渲染线条的 OpenGL 代码。
  • FastRenderer 类支持快速渲染并实现了 GLFrontBufferedRenderer.Callback 接口。它还拦截 MotionEvent 对象。
  • StylusViewModel 类使用 LineManager 接口保存数据点。
  • Segment 类定义了一个线段,如下所示:
  • x1, y1: 第一个点的坐标
  • x2, y2: 第二个点的坐标

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

MotionEvent are captured by LowLatencySurfaceView and sent to the onTouchListener for processing. onTouchListener processes and requests Front or Doubled buffer rendering to GLFrontBufferRenderer. GLFrontBufferRenderer renders to the 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 常量的主体中,创建一个 currentX 变量,用于存储 MotionEvent 对象的 x 坐标;以及一个 currentY 变量,用于存储其 y 坐标。
  3. 创建一个 Segment 变量,用于存储接受两个 currentX 参数实例和两个 currentY 参数实例的 Segment 对象,因为它是线的起点。
  4. 调用 renderFrontBufferedLayer 方法,传入 segment 参数,以触发 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 常量的主体中,创建一个 previousX 变量,用于存储 currentX 变量;以及一个 previousY 变量,用于存储 currentY 变量。
  2. 创建一个 currentX 变量,用于保存 MotionEvent 对象的当前 x 坐标;以及一个 currentY 变量,用于保存其当前 y 坐标。
  3. 创建一个 Segment 变量,用于存储接受 previousXpreviousYcurrentXcurrentY 参数的 Segment 对象。
  4. 调用 renderFrontBufferedLayer 方法,传入 segment 参数,以触发 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 文件中,onDrawFrontBufferedLayeronDrawDoubleBufferedLayer 回调函数执行 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.glClearColorGLES20.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.graphicsandroidx.input来改进延迟。这些增强功能的协同实现,提供了更自然的手写笔体验。

了解详情