Tăng cường hỗ trợ bút cảm ứng trong ứng dụng Android

1. Trước khi bắt đầu

Bút cảm ứng là một công cụ có hình dạng bút, giúp người dùng thực hiện công việc đòi hỏi độ chính xác cao. Trong lớp học lập trình này, bạn sẽ tìm hiểu cách triển khai trải nghiệm dùng bút cảm ứng một cách tự nhiên qua thư viện android.osandroidx. Bạn cũng tìm hiểu cách sử dụng lớp MotionEvent để hỗ trợ áp lực, hướng và độ nghiêng, cũng như tính năng chống tì tay để tránh những thao tác chạm không mong muốn. Ngoài ra, bạn được tìm hiểu cách giảm độ trễ của bút cảm ứng bằng tính năng dự đoán chuyển động và kết xuất đồ hoạ có độ trễ thấp qua OpenGL và lớp SurfaceView.

Điều kiện tiên quyết

  • Có kinh nghiệm về Kotlin và lambda.
  • Có kiến thức cơ bản về cách sử dụng Android Studio.
  • Có kiến thức cơ bản về Jetpack Compose.
  • Có hiểu biết cơ bản về OpenGL cho đồ hoạ có độ trễ thấp.

Kiến thức bạn sẽ học được

  • Cách sử dụng lớp MotionEvent cho bút cảm ứng.
  • Cách triển khai các tính năng của bút cảm ứng, bao gồm khả năng hỗ trợ áp lực, hướng và độ nghiêng.
  • Cách vẽ trên lớp Canvas.
  • Cách triển khai tính năng dự đoán chuyển động.
  • Cách kết xuất đồ hoạ có độ trễ thấp bằng OpenGL và lớp SurfaceView.

Bạn cần có

2. Lấy đoạn mã khởi đầu

Để lấy mã nguồn có chứa giao diện và chế độ thiết lập cơ bản của ứng dụng khởi đầu, hãy làm theo các bước sau:

  1. Sao chép kho lưu trữ này trên GitHub:
git clone https://github.com/android/large-screen-codelabs
  1. Mở thư mục advanced-stylus. Thư mục start chứa mã khởi đầu và thư mục end chứa mã giải pháp.

3. Triển khai một ứng dụng vẽ cơ bản

Trước tiên, bạn cần tạo bố cục cần thiết cho một ứng dụng vẽ cơ bản. Ứng dụng này giúp người dùng vẽ và thể hiện các thuộc tính của bút cảm ứng trên màn hình bằng hàm Composable Canvas. Bố cục này sẽ có giao diện như hình sau:

Ứng dụng vẽ cơ bản. Phần trên dùng để vẽ hình ảnh của bút cảm ứng và phần dưới dùng để vẽ.

Phần trên là hàm Composable Canvas, nơi bạn vẽ hình ảnh của bút cảm ứng và thể hiện các thuộc tính của bút cảm ứng, chẳng hạn như hướng, độ nghiêng và áp lực. Phần dưới là một hàm Composable Canvas khác nhận tính năng nhập bằng bút cảm ứng và vẽ các nét đơn giản.

Để triển khai bố cục cơ bản của ứng dụng vẽ này, hãy làm theo các bước sau:

  1. Trong Android Studio, hãy mở kho lưu trữ được sao chép.
  2. Nhấp vào app > java > com.example.stylus rồi nhấp đúp vào MainActivity. Tệp MainActivity.kt sẽ mở ra.
  3. Trong lớp MainActivity, hãy lưu ý hàm StylusVisualizationDrawArea Composable. Bạn sẽ tập trung vào hàm DrawArea Composable trong phần này.

Tạo một StylusState lớp

  1. Trong chính thư mục ui, hãy nhấp vào File > New > Kotlin/Class file (Tệp > Mới > Tệp Kotlin/Class).
  2. Trong hộp văn bản, hãy thay thế phần giữ chỗ Name (Tên) bằng StylusState.kt, sau đó nhấn Enter (hoặc return trên macOS).
  3. Trong tệp StylusState.kt, hãy tạo lớp dữ liệu StylusState, sau đó thêm các biến của bảng sau:

Biến

Loại

Giá trị mặc định

Nội dung mô tả

pressure

Float

0F

Một giá trị dao động từ 0 đến 1.0.

orientation

Float

0F

Một giá trị radian dao động từ -pi đến pi.

tilt

Float

0F

Một giá trị radian dao động từ 0 đến pi/2.

path

Path

Path()

Lưu trữ các đường do hàm Canvas Composable kết xuất bằng phương thức 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(),
)

Khung hiển thị trang tổng quan về các chỉ số độ nghiêng, hướng và áp lực

  1. Trong tệp MainActivity.kt, hãy tìm lớp MainActivity, sau đó thêm trạng thái bút cảm ứng bằng hàm 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())

Lớp DrawPoint

Lớp DrawPoint lưu trữ dữ liệu về từng điểm được vẽ trên màn hình. Khi bạn liên kết những điểm này, bạn sẽ tạo ra các đường. Hàm này bắt chước cách hoạt động của đối tượng Path.

Lớp DrawPoint mở rộng lớp PointF. Lớp này chứa các dữ liệu sau:

Tham số

Loại

Nội dung mô tả

x

Float

Toạ độ

y

Float

Toạ độ

type

DrawPointType

Loại điểm

Có hai loại đối tượng DrawPoint được mô tả bằng enum DrawPointType:

Loại

Nội dung mô tả

START

Di chuyển phần đầu của một đường đến một vị trí.

LINE

Theo dõi một đường bằng điểm trước.

DrawPoint.kt

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

Kết xuất các điểm dữ liệu thành một đường dẫn

Đối với ứng dụng này, lớp StylusViewModel chứa dữ liệu của đường vẽ, chuẩn bị dữ liệu để kết xuất và thực hiện một số thao tác trên đối tượng Path dành cho tính năng chống tì tay.

  • Để lưu giữ dữ liệu của các đường, trong lớp StylusViewModel, hãy tạo một danh sách có thể thay đổi bao gồm các đối tượng DrawPoint:

StylusViewModel.kt

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

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

Để kết xuất các điểm dữ liệu thành một đường dẫn, hãy làm theo các bước sau:

  1. Trong lớp StylusViewModel của tệp StylusViewModel.kt, hãy thêm một hàm createPath.
  2. Tạo biến path thuộc loại Path bằng hàm khởi tạo Path().
  3. Tạo vòng lặp for, trong đó bạn lặp lại từng điểm dữ liệu trong biến currentPath.
  4. Nếu điểm dữ liệu thuộc loại START, hãy gọi phương thức moveTo để bắt đầu một đường tại toạ độ được chỉ định xy.
  5. Nếu không, hãy gọi phương thức lineTo bằng toạ độ xy của điểm dữ liệu để liên kết đến điểm trước đó.
  6. Trả về đối tượng 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() {
}

Xử lý đối tượng MotionEvent

Các sự kiện bút cảm ứng diễn ra trên các đối tượng MotionEvent. Các đối tượng này cung cấp thông tin về hành động được thực hiện và dữ liệu liên quan đến hành động đó, chẳng hạn như vị trí của con trỏ và áp lực. Bảng sau đây chứa một số hằng số của đối tượng MotionEvent và dữ liệu của các hằng số này. Bạn có thể sử dụng dữ liệu này để xác định thao tác mà người dùng thực hiện trên màn hình:

Hằng số

Dữ liệu

ACTION_DOWN

Con trỏ chạm vào màn hình. Đây là điểm bắt đầu của một đường tại vị trí được đối tượng MotionEvent báo cáo.

ACTION_MOVE

Con trỏ di chuyển trên màn hình. Đây là đường được vẽ.

ACTION_UP

Con trỏ ngừng chạm vào màn hình. Đây là phần cuối đường vẽ.

ACTION_CANCEL

Phát hiện thao tác chạm không mong muốn. Huỷ nét vẽ gần đây nhất.

Khi ứng dụng nhận được một đối tượng MotionEvent mới, màn hình sẽ kết xuất để phản ánh hoạt động đầu vào mới của người dùng.

  • Để xử lý các đối tượng MotionEvent trong lớp StylusViewModel, hãy tạo một hàm thu thập toạ độ của đường đó:

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
   }

Gửi dữ liệu đến giao diện người dùng

Để cập nhật lớp StylusViewModel sao cho giao diện người dùng có thể thu thập những thay đổi trong lớp dữ liệu StylusState, hãy làm theo các bước sau:

  1. Trong lớp StylusViewModel, hãy tạo một biến _stylusState thuộc loại MutableStateFlow của lớp StylusState và một biến stylusState thuộc loại StateFlow của StylusState. Biến _stylusState được chỉnh sửa mỗi khi trạng thái bút cảm ứng được thay đổi trong lớp StylusViewModel và biến stylusState được giao diện người dùng sử dụng trong lớp 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. Tạo một hàm requestRendering chấp nhận tham số đối tượng 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. Ở cuối hàm processMotionEvent, hãy thêm một lệnh gọi hàm requestRendering chứa tham số StylusState.
  2. Trong tham số StylusState, hãy truy xuất các giá trị về độ nghiêng, hướng và áp suất qua biến motionEvent, sau đó tạo đường dẫn bằng hàm createPath(). Thao tác này kích hoạt một sự kiện luồng mà sau này bạn sẽ kết nối trong giao diện người dùng.

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. Trong lớp MainActivity, hãy tìm hàm super.onCreate của hàm onCreate rồi sau đó thêm bộ sưu tập trạng thái. Để tìm hiểu thêm về việc thu thập trạng thái, hãy xem phần Thu thập các luồng theo cách nhận biết vòng đời.

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

Bây giờ, bất cứ khi nào lớp StylusViewModel đăng một trạng thái StylusState mới, hoạt động sẽ nhận được trạng thái đó và đối tượng StylusState mới sẽ cập nhật biến stylusState của lớp MainActivity cục bộ.

  1. Trong phần nội dung của hàm Composable DrawArea, hãy thêm đối tượng sửa đổi pointerInteropFilter vào hàm Composable Canvas để cung cấp các đối tượng MotionEvent.
  1. Gửi đối tượng MotionEvent đến hàm processMotionEvent của StylusViewModel để xử lý:

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. Gọi hàm drawPath bằng thuộc tính path stylusState, sau đó cung cấp một kiểu màu và nét vẽ.

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. Chạy ứng dụng rồi để ý đến việc bạn có thể vẽ trên màn hình.

4. Triển khai tính năng hỗ trợ áp lực, hướng và độ nghiêng

Trong phần trước, bạn đã xem cách truy xuất thông tin bút cảm ứng qua các đối tượng MotionEvent, chẳng hạn như áp lực, hướng và độ nghiêng.

StylusViewModel.kt

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

Tuy nhiên, lối tắt này chỉ hoạt động với con trỏ đầu tiên. Khi cử chỉ nhiều điểm chạm được phát hiện, nhiều con trỏ được phát hiện và lối tắt này chỉ trả về giá trị cho con trỏ đầu tiên — hoặc con trỏ đầu tiên trên màn hình. Để yêu cầu dữ liệu về một con trỏ cụ thể, bạn có thể sử dụng tham số pointerIndex:

StylusViewModel.kt

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

Để tìm hiểu thêm về con trỏ và nhiều điểm chạm, hãy xem nội dung Xử lý cử chỉ nhiều điểm chạm.

Thêm hình ảnh cho áp lực, hướng và độ nghiêng

  1. Trong tệp MainActivity.kt, hãy tìm hàm Composable StylusVisualization, sau đó sử dụng thông tin cho đối tượng luồng StylusState để kết xuất hình ảnh:

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. Chạy ứng dụng. Bạn sẽ thấy 3 chỉ báo ở đầu màn hình cho biết hướng, áp suất và độ nghiêng.
  2. Vẽ nguệch ngoạc trên màn hình bằng bút cảm ứng, sau đó quan sát cách mỗi hình ảnh phản ứng với thao tác đầu vào của bạn.

Minh hoạ hướng, áp lực và độ nghiêng của từ "hello" được viết bằng bút cảm ứng

  1. Kiểm tra tệp StylusVisualization.kt để nắm được cách tạo từng hình ảnh.

5. Triển khai tính năng chống tì tay

Màn hình có thể ghi lại các thao tác chạm không mong muốn, chẳng hạn như khi người dùng tự nhiên đặt tay lên màn hình để hỗ trợ trong khi viết tay.

Tính năng chống tì tay là cơ chế phát hiện hành vi này và thông báo cho nhà phát triển để huỷ tập hợp đối tượng MotionEvent gần nhất. Một tập hợp đối tượng MotionEvent bắt đầu bằng hằng số ACTION_DOWN.

Tức là bạn phải lưu giữ nhật ký hoạt động đầu vào để có thể xoá những thao tác chạm không mong muốn khỏi màn hình, cũng như kết xuất lại hoạt động đầu vào hợp lệ của người dùng. Rất may là nhật ký này được lưu vào lớp StylusViewModel trong biến currentPath.

Android cung cấp hằng số ACTION_CANCEL qua đối tượng MotionEvent để thông báo cho nhà phát triển về thao tác chạm không mong muốn. Kể từ Android 13, đối tượng MotionEvent cung cấp hằng số FLAG_CANCELED cần được kiểm tra qua hằng số ACTION_POINTER_UP.

Triển khai hàm cancelLastStroke

  • Để xoá một điểm dữ liệu khỏi điểm dữ liệu START gần đây nhất, hãy quay lại lớp StylusViewModel, sau đó tạo một hàm cancelLastStroke tìm chỉ mục của điểm dữ liệu START gần đây nhất và chỉ giữ lại dữ liệu từ điểm dữ liệu đầu tiên cho đến phần chỉ mục trừ đi 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)
      }
   }

Thêm hằng số ACTION_CANCELFLAG_CANCELED

  1. Trong tệp StylusViewModel.kt, hãy tìm hàm processMotionEvent.
  2. Trong hằng số ACTION_UP, hãy tạo một biến canceled để kiểm tra xem phiên bản SDK hiện tại có phải là Android 13 trở lên hay không, cũng như liệu hằng số FLAG_CANCELED có được kích hoạt hay không.
  3. Ở dòng tiếp theo, hãy tạo một điều kiện để kiểm tra xem biến canceled có giá trị true hay không. Nếu có, hãy gọi hàm cancelLastStroke để xoá tập hợp các đối tượng MotionEvent gần nhất. Nếu không, hãy gọi phương thức currentPath.add để thêm tập hợp các đối tượng MotionEvent gần nhất.

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. Trong hằng số ACTION_CANCEL, hãy lưu ý hàm cancelLastStroke:

StylusViewModel.kt

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

Đã triển khai tính năng chống tì tay thành công! Bạn có thể tìm thấy đoạn mã đang hoạt động trong thư mục palm-rejection.

6. Triển khai độ trễ thấp

Trong phần này, bạn sẽ giảm độ trễ giữa hoạt động đầu vào của người dùng và kết xuất màn hình để cải thiện hiệu suất. Độ trễ có nhiều nguyên nhân, một trong số đó là quy trình kết xuất đồ hoạ quá dài. Bạn có thể giảm bớt quy trình kết xuất đồ hoạ bằng tính năng kết xuất vùng đệm trước. Tính năng kết xuất vùng đệm trước cho phép nhà phát triển truy cập trực tiếp vào vùng đệm màn hình. Điều này giúp việc viết tay và phác thảo trở nên dễ dàng hơn.

Lớp GLFrontBufferedRenderer do thư viện androidx.graphics cung cấp sẽ xử lý quá trình kết xuất bộ đệm phía trước và vùng đệm kép. Việc này tối ưu hoá một đối tượng SurfaceView để kết xuất nhanh bằng hàm callback onDrawFrontBufferedLayer và kết xuất bình thường bằng hàm callback onDrawDoubleBufferedLayer. Lớp GLFrontBufferedRenderer và giao diện GLFrontBufferedRenderer.Callback làm việc với loại dữ liệu do người dùng cung cấp. Trong lớp học lập trình này, bạn sử dụng lớp Segment.

Để bắt đầu, hãy làm theo các bước sau:

  1. Trong Android Studio, hãy mở thư mục low-latency để lấy mọi tệp cần thiết:
  2. Hãy lưu ý các tệp mới sau trong dự án:
  • Trong tệp build.gradle, thư viện androidx.graphics được nhập vào cùng với phần khai báo implementation "androidx.graphics:graphics-core:1.0.0-alpha03".
  • Lớp LowLatencySurfaceView mở rộng lớp SurfaceView để kết xuất đoạn mã OpenGL trên màn hình.
  • Lớp LineRenderer chứa đoạn mã OpenGL để kết xuất một đường trên màn hình.
  • Lớp FastRenderer giúp kết xuất nhanh và triển khai giao diện GLFrontBufferedRenderer.Callback. Việc này cũng chặn các đối tượng MotionEvent.
  • Lớp StylusViewModel lưu giữ các điểm dữ liệu có giao diện LineManager.
  • Lớp Segment xác định một phân đoạn như sau:
  • x1, y1: toạ độ của điểm thứ nhất
  • x2, y2: toạ độ của điểm thứ hai

Các hình ảnh sau cho thấy cách dữ liệu di chuyển giữa các lớp:

MotionEvent được LowLatencySurfaceView ghi lại rồi gửi tới onTouchListener để xử lý. onTouchListener xử lý và yêu cầu vùng đệm phía trước hoặc vùng đệm kép kết xuất cho GLFrontBufferRenderer. GLFrontBufferRenderer kết xuất cho LowLatencySurfaceView.

Tạo bề mặt và bố cục có độ trễ thấp

  1. Trong tệp MainActivity.kt, hãy tìm hàm onCreate của lớp MainActivity.
  2. Trong phần nội dung của hàm onCreate, tạo một đối tượng FastRenderer, sau đó truyền vào đối tượng viewModel:

MainActivity.kt

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

      fastRendering = FastRenderer(viewModel)

      lifecycleScope.launch {
      ...
  1. Trong chính tệp đó, hãy tạo một hàm Composable DrawAreaLowLatency.
  2. Trong phần nội dung của hàm, hãy sử dụng API AndroidView để gói khung hiển thị LowLatencySurfaceView rồi cung cấp đối tượng 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. Trong hàm onCreate sau hàm Composable Divider, hãy thêm hàm Composable DrawAreaLowLatency vào bố cục:

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. Trong thư mục gl, hãy mở tệp LowLatencySurfaceView.kt rồi chú ý những nội dung sau trong lớp LowLatencySurfaceView:
  • Lớp LowLatencySurfaceView mở rộng lớp SurfaceView. Phương thức này sử dụng phương thức onTouchListener của đối tượng fastRenderer.
  • Giao diện GLFrontBufferedRenderer.Callback thông qua lớp fastRenderer cần được đính kèm vào đối tượng SurfaceView khi hàm onAttachedToWindow được gọi, để các lệnh gọi lại có thể kết xuất cho khung hiển thị SurfaceView.
  • Giao diện GLFrontBufferedRenderer.Callback thông qua lớp fastRenderer cần được phát hành khi gọi hàm onDetachedFromWindow.

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

Xử lý các đối tượng MotionEvent bằng giao diện onTouchListener

Để xử lý các đối tượng MotionEvent khi phát hiện hằng số ACTION_DOWN, hãy làm theo các bước sau:

  1. Trong thư mục gl, hãy mở tệp FastRenderer.kt.
  2. Trong phần nội dung của hằng số ACTION_DOWN, hãy tạo một biến currentX lưu trữ toạ độ x của đối tượng MotionEvent và một biến currentY lưu trữ toạ độ y.
  3. Tạo biến Segment lưu trữ đối tượng Segment chấp nhận hai phiên bản của tham số currentX và hai phiên bản của tham số currentY vì đó là phần đầu của một đường.
  4. Gọi phương thức renderFrontBufferedLayer có tham số segment để kích hoạt lệnh gọi lại trên hàm 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)
   }

Để xử lý các đối tượng MotionEvent khi phát hiện hằng số ACTION_MOVE, hãy làm theo các bước sau:

  1. Trong phần nội dung của hằng số ACTION_MOVE, hãy tạo một biến previousX lưu trữ biến currentX và biến previousY lưu trữ biến currentY.
  2. Tạo một currentX biến lưu toạ độ x của vật thể MotionEvent và một biến currentY lưu tọa độ hiện tại y.
  3. Tạo biến Segment lưu trữ đối tượng Segment chấp nhận các tham số previousX, previousY, currentXcurrentY.
  4. Gọi phương thức renderFrontBufferedLayer có tham số segment để kích hoạt lệnh gọi lại trên hàm onDrawFrontBufferedLayer và thực thi đoạn mã 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)
   }
  • Để xử lý các đối tượng MotionEvent khi phát hiện hằng số ACTION_UP, hãy gọi phương thức commit để kích hoạt một lệnh gọi trên hàm onDrawDoubleBufferedLayer và thực thi đoạn mã OpenGL:

FastRenderer.kt

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

Triển khai các hàm callback GLFrontBufferedRenderer

Trong tệp FastRenderer.kt, hàm callback onDrawFrontBufferedLayeronDrawDoubleBufferedLayer thực thi đoạn mã OpenGL. Ở đầu mỗi hàm callback, các hàm OpenGL dưới đây sẽ ánh xạ dữ liệu Android đến không gian làm việc OpenGL:

  • Hàm GLES20.glViewport xác định kích thước của hình chữ nhật mà bạn kết xuất cảnh.
  • Hàm Matrix.orthoM tính toán ma trận ModelViewProjection.
  • Hàm Matrix.multiplyMM thực hiện phép nhân ma trận để chuyển đổi dữ liệu Android thành tệp tham chiếu OpenGL và cung cấp chế độ thiết lập cho ma trận 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)

Với phần mã đã được thiết lập, bạn có thể tập trung vào đoạn mã thực hiện việc kết xuất trên thực tế. Hàm callback onDrawFrontBufferedLayer kết xuất một vùng nhỏ trên màn hình. Lớp này cung cấp giá trị param thuộc loại Segment để bạn có thể kết xuất nhanh một phân đoạn. Lớp LineRenderer là trình kết xuất đồ hoạ OpenGL cho bút vẽ áp dụng màu sắc và kích thước của một đường.

Để triển khai hàm callback onDrawFrontBufferedLayer, hãy làm theo các bước sau:

  1. Trong tệp FastRenderer.kt, hãy tìm hàm callback onDrawFrontBufferedLayer.
  2. Trong phần nội dung của hàm callback onDrawFrontBufferedLayer, hãy gọi hàm obtainRenderer để lấy thực thể LineRenderer.
  3. Gọi phương thức drawLine của hàm LineRenderer bằng các tham số sau:
  • Ma trận projection được tính toán trước đó.
  • Danh sách đối tượng Segment, trong trường hợp này là một phân đoạn duy nhất.
  • color của đường này.

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. Chạy ứng dụng rồi chú ý đến việc bạn có thể vẽ trên màn hình với độ trễ tối thiểu. Tuy nhiên, ứng dụng sẽ không lưu trữ đường này được lâu dài vì bạn vẫn cần triển khai hàm callback onDrawDoubleBufferedLayer.

Hàm callback onDrawDoubleBufferedLayer được gọi sau hàm commit để cho phép việc lưu giữ đường này. Lệnh gọi lại cung cấp giá trị params chứa tập hợp các đối tượng Segment. Mọi phân đoạn trên vùng đệm trước đều được phát lại trong vùng đệm kép để giữ lại.

Để triển khai hàm callback onDrawDoubleBufferedLayer, hãy làm theo các bước sau:

  1. Trong tệp StylusViewModel.kt, hãy tìm lớp StylusViewModel, sau đó tạo một biến openGlLines lưu trữ danh sách đối tượng Segment có thể thay đổi:

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. Trong tệp FastRenderer.kt, hãy tìm hàm callback onDrawDoubleBufferedLayer của lớp FastRenderer.
  2. Trong phần nội dung của hàm callback onDrawDoubleBufferedLayer, hãy xoá màn hình bằng các phương thức GLES20.glClearColorGLES20.glClear để có thể kết xuất cảnh từ đầu rồi thêm các đường vào đối tượng viewModel để lưu giữ:

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. Tạo vòng lặp for để lặp lại rồi kết xuất từng đường bằng đối tượng 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. Chạy ứng dụng, rồi chú ý đến việc bạn có thể vẽ trên màn hình và đường đó sẽ được lưu giữ sau khi kích hoạt hằng số ACTION_UP.

7. Triển khai tính năng dự đoán chuyển động

Bạn có thể giảm độ trễ hơn nữa bằng cách sử dụng thư viện androidx.input. Thư viện này phân tích quá trình chuyển động của bút cảm ứng, cũng như dự đoán vị trí của điểm tiếp theo rồi chèn vị trí đó để kết xuất.

Để thiết lập tính năng dự đoán chuyển động, hãy làm theo các bước sau:

  1. Trong tệp app/build.gradle, hãy nhập thư viện vào mục phần phụ thuộc:

app/build.gradle

...
dependencies {
    ...
    implementation"androidx.input:input-motionprediction:1.0.0-beta01"
  1. Nhấp vào File > Sync project with Gradle files (Tệp > Đồng bộ hoá dự án với tệp Gradle).
  2. Trong lớp FastRendering của tệp FastRendering.kt, hãy khai báo đối tượng motionEventPredictor dưới dạng một thuộc tính:

FastRenderer.kt

import androidx.input.motionprediction.MotionEventPredictor

class FastRenderer( ... ) {
   ...
   private var frontBufferRenderer: GLFrontBufferedRenderer<Segment>? = null
   private var motionEventPredictor: MotionEventPredictor? = null
  1. Trong hàm attachSurfaceView, hãy khởi động biến motionEventPredictor:

FastRenderer.kt

class FastRenderer( ... ) {
   ...
   fun attachSurfaceView(surfaceView: SurfaceView) {
      frontBufferRenderer = GLFrontBufferedRenderer(surfaceView, this)
      motionEventPredictor = MotionEventPredictor.newInstance(surfaceView)
   }
  1. Trong biến onTouchListener, hãy gọi phương thức motionEventPredictor?.record để đối tượng motionEventPredictor nhận dữ liệu chuyển động:

FastRendering.kt

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

Bước tiếp theo là dự đoán đối tượng MotionEvent bằng hàm predict. Bạn nên dự đoán đối tượng này khi nhận được hằng số ACTION_MOVE và sau khi đối tượng MotionEvent được ghi lại. Nói cách khác, bạn nên dự đoán thời điểm nét vẽ đang diễn ra.

  1. Dự đoán một đối tượng nhân tạo MotionEvent bằng phương thức predict.
  2. Tạo một đối tượng Segment sử dụng các toạ độ xy hiện tại và được dự đoán.
  3. Yêu cầu kết xuất nhanh phân đoạn được dự đoán bằng phương thức 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)
              }

          }
          ...
       }

Các sự kiện dự đoán được chèn vào để kết xuất và giảm độ trễ.

  1. Chạy ứng dụng rồi để ý đến việc độ trễ được cải thiện.

Việc cải thiện độ trễ sẽ mang lại cho người dùng bút cảm ứng một trải nghiệm sử dụng bút cảm ứng tự nhiên hơn.

8. Xin chúc mừng

Xin chúc mừng! Bạn đã nắm được cách sử dụng bút cảm ứng thật chuyên nghiệp!

Bạn đã tìm hiểu cách xử lý đối tượng MotionEvent để trích xuất thông tin về áp lực, hướng và độ nghiêng. Bạn cũng đã tìm hiểu cách cải thiện độ trễ bằng cách triển khai cả thư viện androidx.graphicsthư viện androidx.input. Những cải tiến này được triển khai cùng lúc, giúp mang lại cho người dùng một trải nghiệm dùng bút cảm ứng tự nhiên hơn.

Tìm hiểu thêm