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.os và androidx. 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
MotionEventcho 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ó
- Phiên bản Android Studio mới nhất.
- Kinh nghiệm về cú pháp Kotlin, bao gồm cả lambda.
- Kinh nghiệm cơ bản về Compose Nếu bạn chưa hiểu rõ về Compose, hãy hoàn thành lớp học lập trình Kiến thức cơ bản về Jetpack Compose.
- Một thiết bị có hỗ trợ bút cảm ứng.
- Một bút cảm ứng đang hoạt động.
- Git.
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:
- Sao chép kho lưu trữ này trên GitHub:
git clone https://github.com/android/large-screen-codelabs
- Mở thư mục
advanced-stylus. Thư mụcstartchứa đoạn mã khởi đầu và thư mụcendchứa đoạn 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:

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:
- Trong Android Studio, hãy mở kho lưu trữ được sao chép.
- Nhấp vào
app>java>com.example.stylusrồi nhấp đúp vàoMainActivity. TệpMainActivity.ktsẽ mở ra. - Trong lớp
MainActivity, hãy lưu ý hàmStylusVisualizationvàDrawAreaComposable. Bạn sẽ tập trung vào hàmDrawAreaComposabletrong phần này.
Tạo một StylusState lớp
- 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). - Trong hộp văn bản, hãy thay thế phần giữ chỗ Name (Tên) bằng
StylusState.kt, sau đó nhấnEnter(hoặcreturntrên macOS). - Trong tệp
StylusState.kt, hãy tạo lớp dữ liệuStylusState, 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ả |
|
| Một giá trị dao động từ 0 đến 1.0. | |
|
| Một giá trị radian dao động từ -pi đến pi. | |
|
| Một giá trị radian dao động từ 0 đến pi/2. | |
|
| Lưu trữ các đường do hàm |
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(),
)

- Trong tệp
MainActivity.kt, hãy tìm lớpMainActivity, sau đó thêm trạng thái bút cảm ứng bằng hàmmutableStateOf():
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ả |
|
| Toạ độ |
|
| Toạ độ |
|
| 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ả |
| Di chuyển phần đầu của một đường đến một vị trí. |
| 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ượngDrawPoint:
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:
- Trong lớp
StylusViewModelcủa tệpStylusViewModel.kt, hãy thêm một hàmcreatePath. - Tạo biến
paththuộc loạiPathbằng hàm khởi tạoPath(). - Tạo vòng lặp
for, trong đó bạn lặp lại từng điểm dữ liệu trong biếncurrentPath. - Nếu điểm dữ liệu thuộc loại
START, hãy gọi phương thứcmoveTođể bắt đầu một đường tại toạ độ được chỉ địnhxvày. - Nếu không, hãy gọi phương thức
lineTobằng toạ độxvàycủa điểm dữ liệu để liên kết đến điểm trước đó. - 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 |
| 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 |
| Con trỏ di chuyển trên màn hình. Đây là đường được vẽ. |
| Con trỏ ngừng chạm vào màn hình. Đây là phần cuối đường vẽ. |
| 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
MotionEventtrong lớpStylusViewModel, 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:
- Trong lớp
StylusViewModel, hãy tạo một biến_stylusStatethuộc loạiMutableStateFlowcủa lớpStylusStatevà một biếnstylusStatethuộc loạiStateFlowcủaStylusState. 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ớpStylusViewModelvà biếnstylusStateđược giao diện người dùng sử dụng trong lớpMainActivity.
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
- Tạo một hàm
requestRenderingchấp nhận tham số đối tượngStylusState:
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
}
}
- Ở cuối hàm
processMotionEvent, hãy thêm một lệnh gọi hàmrequestRenderingchứa tham sốStylusState. - Trong tham số
StylusState, hãy truy xuất các giá trị về độ nghiêng, hướng và áp lực qua biếnmotionEvent, sau đó tạo đường dẫn bằng hàmcreatePath(). 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()
)
)
Liên kết giao diện người dùng với lớp StylusViewModel
- Trong lớp
MainActivity, hãy tìm hàmsuper.onCreatecủa hàmonCreaterồ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ộ.
- Trong phần nội dung của hàm
ComposableDrawArea, hãy thêm đối tượng sửa đổipointerInteropFiltervào hàmComposableCanvasđể cung cấp các đối tượngMotionEvent.
- Gửi đối tượng
MotionEventđến hàmprocessMotionEventcủ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)
}
) {
}
}
- Gọi hàm
drawPathbằng thuộc tínhpathstylusState, 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
)
}
}
}
- 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
- Trong tệp
MainActivity.kt, hãy tìm hàmComposableStylusVisualization, sau đó sử dụng thông tin cho đối tượng luồngStylusStateđể 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)
}
}
}
- 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 lực và độ nghiêng.
- 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.

- 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
STARTgần đây nhất, hãy quay lại lớpStylusViewModel, sau đó tạo một hàmcancelLastStroketìm chỉ mục của điểm dữ liệuSTARTgầ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_CANCEL và FLAG_CANCELED
- Trong tệp
StylusViewModel.kt, hãy tìm hàmprocessMotionEvent. - Trong hằng số
ACTION_UP, hãy tạo một biếncanceledđể 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_CANCELEDcó được kích hoạt hay không. - Ở dòng tiếp theo, hãy tạo một điều kiện để kiểm tra xem biến
canceledcó giá trị true hay không. Nếu có, hãy gọi hàmcancelLastStrokeđể xoá tập hợp các đối tượngMotionEventgần nhất. Nếu không, hãy gọi phương thứccurrentPath.addđể thêm tập hợp các đối tượngMotionEventgầ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))
}
}
- Trong hằng số
ACTION_CANCEL, hãy lưu ý hàmcancelLastStroke:
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:
- Trong Android Studio, hãy mở thư mục
low-latencyđể lấy mọi tệp cần thiết: - Hãy lưu ý các tệp mới sau trong dự án:
- Trong tệp
build.gradle, thư việnandroidx.graphicsđược nhập vào cùng với phần khai báoimplementation "androidx.graphics:graphics-core:1.0.0-alpha03". - Lớp
LowLatencySurfaceViewmở rộng lớpSurfaceViewđể kết xuất đoạn mã OpenGL trên màn hình. - Lớp
LineRendererchứa đoạn mã OpenGL để kết xuất một đường trên màn hình. - Lớp
FastRenderergiúp kết xuất nhanh và triển khai giao diệnGLFrontBufferedRenderer.Callback. Việc này cũng chặn các đối tượngMotionEvent. - Lớp
StylusViewModellưu giữ các điểm dữ liệu có giao diệnLineManager. - Lớp
Segmentxác định một phân đoạn như sau: x1,y1: toạ độ của điểm thứ nhấtx2,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:

Tạo bề mặt và bố cục có độ trễ thấp
- Trong tệp
MainActivity.kt, hãy tìm hàmonCreatecủa lớpMainActivity. - Trong phần nội dung của hàm
onCreate, tạo một đối tượngFastRenderer, sau đó truyền vào đối tượngviewModel:
MainActivity.kt
class MainActivity : ComponentActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
fastRendering = FastRenderer(viewModel)
lifecycleScope.launch {
...
- Trong chính tệp đó, hãy tạo một hàm
ComposableDrawAreaLowLatency. - Trong phần nội dung của hàm, hãy sử dụng API
AndroidViewđể gói khung hiển thịLowLatencySurfaceViewrồi cung cấp đối tượngfastRendering:
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)
}
- Trong hàm
onCreatesau hàmComposableDivider, hãy thêm hàmComposableDrawAreaLowLatencyvà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()
}
}
- Trong thư mục
gl, hãy mở tệpLowLatencySurfaceView.ktrồi chú ý những nội dung sau trong lớpLowLatencySurfaceView:
- Lớp
LowLatencySurfaceViewmở rộng lớpSurfaceView. Phương thức này sử dụng phương thứconTouchListenercủa đối tượngfastRenderer. - Giao diện
GLFrontBufferedRenderer.Callbackthông qua lớpfastRenderercần được đính kèm vào đối tượngSurfaceViewkhi hàmonAttachedToWindowđượ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.Callbackthông qua lớpfastRenderercần được phát hành khi gọi hàmonDetachedFromWindow.
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:
- Trong thư mục
gl, hãy mở tệpFastRenderer.kt. - Trong phần nội dung của hằng số
ACTION_DOWN, hãy tạo một biếncurrentXlưu trữ toạ độxcủa đối tượngMotionEventvà một biếncurrentYlưu trữ toạ độy. - Tạo biến
Segmentlưu trữ đối tượngSegmentchấp nhận hai phiên bản của tham sốcurrentXvà hai phiên bản của tham sốcurrentYvì đó là phần đầu của một đường. - Gọi phương thức
renderFrontBufferedLayercó tham sốsegmentđể kích hoạt lệnh gọi lại trên hàmonDrawFrontBufferedLayer.
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:
- Trong phần nội dung của hằng số
ACTION_MOVE, hãy tạo một biếnpreviousXlưu trữ biếncurrentXvà biếnpreviousYlưu trữ biếncurrentY. - Tạo một
currentXbiến lưu toạ độxcủa vật thểMotionEventvà một biếncurrentYlưu toạ độ hiện tạiy. - Tạo biến
Segmentlưu trữ đối tượngSegmentchấp nhận các tham sốpreviousX,previousY,currentXvàcurrentY. - Gọi phương thức
renderFrontBufferedLayercó tham sốsegmentđể kích hoạt lệnh gọi lại trên hàmonDrawFrontBufferedLayervà 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
MotionEventkhi phát hiện hằng sốACTION_UP, hãy gọi phương thứccommitđể kích hoạt một lệnh gọi trên hàmonDrawDoubleBufferedLayervà 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 onDrawFrontBufferedLayer và onDrawDoubleBufferedLayer 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.glViewportxá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.orthoMtính toán ma trậnModelViewProjection. - Hàm
Matrix.multiplyMMthự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ậnprojection.
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:
- Trong tệp
FastRenderer.kt, hãy tìm hàm callbackonDrawFrontBufferedLayer. - Trong phần nội dung của hàm callback
onDrawFrontBufferedLayer, hãy gọi hàmobtainRendererđể lấy thực thểLineRenderer. - Gọi phương thức
drawLinecủa hàmLineRendererbằ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. colorcủ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())
}
- 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:
- Trong tệp
StylusViewModel.kt, hãy tìm lớpStylusViewModel, sau đó tạo một biếnopenGlLineslưu trữ danh sách đối tượngSegmentcó 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) {
- Trong tệp
FastRenderer.kt, hãy tìm hàm callbackonDrawDoubleBufferedLayercủa lớpFastRenderer. - 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ứcGLES20.glClearColorvàGLES20.glClearđể có thể kết xuất cảnh từ đầu rồi thêm các đường vào đối tượngviewModelđể 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())
- Tạo vòng lặp
forđể lặp lại rồi kết xuất từng đường bằng đối tượngviewModel:
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())
}
}
- 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:
- 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"
- Nhấp vào File > Sync project with Gradle files (Tệp > Đồng bộ hoá dự án với tệp Gradle).
- Trong lớp
FastRenderingcủa tệpFastRendering.kt, hãy khai báo đối tượngmotionEventPredictordướ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
- Trong hàm
attachSurfaceView, hãy khởi động biếnmotionEventPredictor:
FastRenderer.kt
class FastRenderer( ... ) {
...
fun attachSurfaceView(surfaceView: SurfaceView) {
frontBufferRenderer = GLFrontBufferedRenderer(surfaceView, this)
motionEventPredictor = MotionEventPredictor.newInstance(surfaceView)
}
- Trong biến
onTouchListener, hãy gọi phương thứcmotionEventPredictor?.recordđể đối tượngmotionEventPredictornhậ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.
- Dự đoán một đối tượng nhân tạo
MotionEventbằng phương thứcpredict. - Tạo một đối tượng
Segmentsử dụng các toạ độ x và y hiện tại và được dự đoán. - 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ễ.
- 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.graphics và thư 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.