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
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ó
- 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ụcstart
chứa mã khởi đầu và thư mụcend
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:
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.stylus
rồi nhấp đúp vàoMainActivity
. TệpMainActivity.kt
sẽ mở ra. - Trong lớp
MainActivity
, hãy lưu ý hàmStylusVisualization
vàDrawArea
Composable
. Bạn sẽ tập trung vào hàmDrawArea
Composable
trong 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ặcreturn
trê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
StylusViewModel
của tệpStylusViewModel.kt
, hãy thêm một hàmcreatePath
. - Tạo biến
path
thuộc loạiPath
bằ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ỉ địnhx
vày
. - Nếu không, hãy gọi phương thức
lineTo
bằng toạ độx
vày
củ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
MotionEvent
trong 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_stylusState
thuộc loạiMutableStateFlow
của lớpStylusState
và một biếnstylusState
thuộc loạiStateFlow
củ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ớpStylusViewModel
và 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
requestRendering
chấ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àmrequestRendering
chứa tham sốStylusState
. - Trong tham số
StylusState
, hãy truy xuất các giá trị về độ nghiêng, hướng và áp suất 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.onCreate
của hàmonCreate
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ộ.
- Trong phần nội dung của hàm
Composable
DrawArea
, hãy thêm đối tượng sửa đổipointerInteropFilter
vào hàmComposable
Canvas
để cung cấp các đối tượngMotionEvent
.
- Gửi đối tượng
MotionEvent
đến hàmprocessMotionEvent
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)
}
) {
}
}
- Gọi hàm
drawPath
bằng thuộc tínhpath
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
)
}
}
}
- 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àmComposable
StylusVisualization
, 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 suất 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
START
gần đây nhất, hãy quay lại lớpStylusViewModel
, sau đó tạo một hàmcancelLastStroke
tìm chỉ mục của điểm dữ liệuSTART
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_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_CANCELED
có đượ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
canceled
có giá trị true hay không. Nếu có, hãy gọi hàmcancelLastStroke
để xoá tập hợp các đối tượngMotionEvent
gầ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ượngMotionEvent
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))
}
}
- 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
LowLatencySurfaceView
mở rộng lớpSurfaceView
để 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ệnGLFrontBufferedRenderer.Callback
. Việc này cũng chặn các đối tượngMotionEvent
. - Lớp
StylusViewModel
lưu giữ các điểm dữ liệu có giao diệnLineManager
. - Lớp
Segment
xá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àmonCreate
củ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
Composable
DrawAreaLowLatency
. - 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ượ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
onCreate
sau hàmComposable
Divider
, hãy thêm hàmComposable
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()
}
}
- Trong thư mục
gl
, hãy mở tệpLowLatencySurfaceView.kt
rồi chú ý những nội dung sau trong lớpLowLatencySurfaceView
:
- Lớp
LowLatencySurfaceView
mở rộng lớpSurfaceView
. Phương thức này sử dụng phương thứconTouchListener
của đối tượngfastRenderer
. - Giao diện
GLFrontBufferedRenderer.Callback
thông qua lớpfastRenderer
cần được đính kèm vào đối tượngSurfaceView
khi 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.Callback
thông qua lớpfastRenderer
cầ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ếncurrentX
lưu trữ toạ độx
của đối tượngMotionEvent
và một biếncurrentY
lưu trữ toạ độy
. - Tạo biến
Segment
lưu trữ đối tượngSegment
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. - Gọi phương thức
renderFrontBufferedLayer
có 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ếnpreviousX
lưu trữ biếncurrentX
và biếnpreviousY
lưu trữ biếncurrentY
. - Tạo một
currentX
biến lưu toạ độx
của vật thểMotionEvent
và một biếncurrentY
lưu tọa độ hiện tạiy
. - Tạo biến
Segment
lưu trữ đối tượngSegment
chấp nhận các tham sốpreviousX
,previousY
,currentX
vàcurrentY
. - Gọi phương thức
renderFrontBufferedLayer
có tham sốsegment
để kích hoạt lệnh gọi lại trên hàmonDrawFrontBufferedLayer
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ứccommit
để kích hoạt một lệnh gọi trên hàmonDrawDoubleBufferedLayer
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 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.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ậnModelViewProjection
. - 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ậ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
drawLine
của hàmLineRenderer
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())
}
- 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ếnopenGlLines
lưu trữ danh sách đối tượngSegment
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) {
- Trong tệp
FastRenderer.kt
, hãy tìm hàm callbackonDrawDoubleBufferedLayer
củ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.glClearColor
và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
FastRendering
của tệpFastRendering.kt
, hãy khai báo đối tượngmotionEventPredictor
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
- 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ượngmotionEventPredictor
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.
- Dự đoán một đối tượng nhân tạo
MotionEvent
bằng phương thứcpredict
. - Tạo một đối tượng
Segment
sử 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.