增强 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. 实现基本的绘图应用

首先,您将为基本的绘图应用构建必要的布局,该应用允许用户绘图,并使用CanvasComposable函数在屏幕上显示手写笔属性。它看起来像下面的图片

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

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

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

  1. 在 Android Studio 中,打开克隆的存储库。
  2. 点击app > java > com.example.stylus,然后双击MainActivityMainActivity.kt文件将打开。
  3. MainActivity类中,请注意StylusVisualizationDrawAreaComposable函数。在本节中,您将重点关注DrawAreaComposable函数。

创建一个StylusState**类**

  1. 在相同的ui目录中,点击文件 > 新建 > Kotlin/类文件
  2. 在文本框中,将名称占位符替换为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方法通过CanvasComposable函数存储渲染的线条。Path对象的工作原理。

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

坐标

类型

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. 创建一个类型为 Pathpath 变量,使用 Path() 构造函数。
  3. 创建一个 for 循环,在其中迭代 currentPath 变量中的每个数据点。
  4. 如果数据点的类型为 START,则调用 moveTo 方法以在指定的 xy 坐标处开始一条线。
  5. 否则,使用数据点的 xy 坐标调用 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
   }

将数据发送到 UI

要更新 StylusViewModel 类以便 UI 可以收集 StylusState 数据类中的更改,请按照以下步骤操作

  1. StylusViewModel 类中,创建一个 _stylusState 变量,其类型为 StylusState 类的 MutableStateFlow 类型,以及一个 stylusState 变量,其类型为 StylusState 类的 StateFlow 类型。每当在 StylusViewModel 类中更改触笔状态时,都会修改 _stylusState 变量,而 stylusState 变量则由 MainActivity 类中的 UI 使用。

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. 创建一个 requestRendering 函数,该函数接受一个 StylusState 对象参数

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() 函数创建路径。这会触发一个流事件,您稍后会在 UI 中连接它。

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 状态时,活动都会接收它,新的 StylusState 对象会更新本地 MainActivity 类的 stylusState 变量。

  1. DrawArea Composable 函数的主体中,将 pointerInteropFilter 修饰符添加到 Canvas Composable 函数以提供 MotionEvent 对象。
  1. MotionEvent 对象发送到 StylusViewModelprocessMotionEvent 函数进行处理

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. 用触笔在屏幕上涂鸦,然后观察每个可视化效果如何对您的输入做出反应。

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 数据点的索引,并且只保留从第一个数据点到索引减一的 data。

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 变量是否为真。如果是,则调用 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. 实现低延迟

在本节中,您将减少用户输入和屏幕渲染之间的延迟以提高性能。延迟有多种原因,其中之一是较长的图形管道。您可以使用前缓冲渲染来减少图形管道。前缓冲渲染使开发人员可以直接访问屏幕缓冲区,这为手写和素描提供了极好的效果。

GLFrontBufferedRenderer 类由 androidx.graphics 提供,负责前缓冲和双缓冲渲染。它使用 onDrawFrontBufferedLayer 回调函数优化 SurfaceView 对象以进行快速渲染,并使用 onDrawDoubleBufferedLayer 回调函数进行正常渲染。GLFrontBufferedRenderer 类和 GLFrontBufferedRenderer.Callback 接口与用户提供的数据类型一起工作。在此代码实验室中,您使用 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 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.

创建一个低延迟的Surface和布局

  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 方法。
  • 通过 fastRenderer 类实现的 GLFrontBufferedRenderer.Callback 接口需要在调用 onAttachedToWindow 函数时附加到 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 变量,它存储一个 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 常量的主体中,创建一个 previousX 变量来存储 currentX 变量,以及一个 previousY 变量来存储 currentY 变量。
  2. 创建一个 currentX 变量来保存 MotionEvent 对象的当前 x 坐标,以及一个 currentY 变量来保存其当前 y 坐标。
  3. 创建一个 Segment 变量,它存储一个 Segment 对象,该对象接受 previousXpreviousYcurrentXcurrentY 参数。
  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 文件中,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 回调函数。

onDrawDoubleBufferedLayer 回调函数在 commit 函数之后被调用,以允许保留线条。回调提供 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. 单击**文件 > 使用 Gradle 文件同步项目**。
  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. 创建一个 Segment 对象,该对象使用当前和预测的 x 和 y 坐标。
  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来提高延迟。这些增强功能共同实现,提供了更自然的触笔体验。

了解更多