Jetpack Compose数字墨迹识别精度低于XML布局的原因与优化
问题描述
在Android应用中使用Jetpack Compose集成MLKit数字墨迹识别功能时,发现识别精度远低于传统XML布局的实现。尽管遵循了相同的识别流程,Compose版本的识别结果始终误差更大、准确性更低,调整参数后也无明显改善。
代码对比
Compose实现代码
MainActivity.kt
val TAG = "Image Recognition" public class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { StrokeManager.downloadModel() } setContent { DemoTheme { App() } } } } @OptIn(ExperimentalComposeUiApi::class) @Composable fun App() { var textState by remember { mutableStateOf("") } val lines = remember { mutableStateListOf<Line>() } Column( verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth() ) { Canvas( modifier = Modifier .fillMaxWidth() .height(300.dp) .pointerInteropFilter { event -> // 直接将触摸事件传递给StrokeManager处理 StrokeManager.addNewTouchEvent(event) // 根据事件类型更新绘制的线条 when (event.actionMasked) { MotionEvent.ACTION_DOWN -> lines.clear() MotionEvent.ACTION_MOVE -> { val historicalX = if (event.historySize > 0) event.getHistoricalX(event.historySize - 1) else event.x val line = Line( start = Offset(historicalX, event.y), end = Offset(event.x, event.y) ) lines.add(line) } } true } ) { lines.forEach { line -> drawLine( color = line.color, start = line.start, end = line.end, strokeWidth = line.strokeWidth.toPx(), cap = StrokeCap.Round ) } } Button( modifier = Modifier .height(50.dp) .fillMaxWidth(), onClick = { StrokeManager.recognize { textState = it } } ) { Text("识别字符") } Spacer(modifier = Modifier.height(10.dp)) Button( modifier = Modifier .height(50.dp) .fillMaxWidth(), onClick = { lines.clear() StrokeManager.clearInk() textState = "" } ) { Text("清空") } if (textState.isNotEmpty()) { Text(textState) } } } data class Line( val start: Offset, val end: Offset, val color: Color = Color.Black, val strokeWidth: Dp = 8.dp )
StrokeManager.kt
object StrokeManager { private val TAG = "Image Recognition" private val inkBuilder: Ink.Builder = Ink.builder() private lateinit var strokeBuilder: Ink.Stroke.Builder private lateinit var digitalInkRecognitionModel: DigitalInkRecognitionModel fun addNewTouchEvent(event: MotionEvent) { val action = event.actionMasked val x = event.x val y = event.y when (action) { MotionEvent.ACTION_DOWN -> { strokeBuilder = Ink.Stroke.builder() strokeBuilder.addPoint(Ink.Point.create(x, y)) } MotionEvent.ACTION_MOVE -> { strokeBuilder.addPoint(Ink.Point.create(x, y)) } MotionEvent.ACTION_UP -> { strokeBuilder.addPoint(Ink.Point.create(x, y)) inkBuilder.addStroke(strokeBuilder.build()) } else -> { Log.d(TAG, "不相关的触摸事件") } } } suspend fun downloadModel() { val modelIdentifier = DigitalInkRecognitionModelIdentifier.fromLanguageTag("en-IN") digitalInkRecognitionModel = DigitalInkRecognitionModel.builder(modelIdentifier!!).build() val remoteModelManager = RemoteModelManager.getInstance() remoteModelManager.isModelDownloaded(digitalInkRecognitionModel) .addOnSuccessListener { isDownloaded -> if (isDownloaded) { Log.i(TAG, "模型已下载") } else { Log.i(TAG, "开始下载模型") remoteModelManager.download(digitalInkRecognitionModel, DownloadConditions.Builder().build()) .addOnSuccessListener { Log.i(TAG, "模型下载完成") } .addOnFailureListener { e -> Log.e(TAG, "模型下载失败: $e") } } } } fun recognize(recognised: (String) -> Unit) { if (!this::digitalInkRecognitionModel.isInitialized) { Log.e(TAG, "模型未初始化") recognised("错误:模型未初始化") return } val recognizer: DigitalInkRecognizer = DigitalInkRecognition.getClient( DigitalInkRecognizerOptions.builder(digitalInkRecognitionModel).build() ) val ink = inkBuilder.build() recognizer.recognize(ink) .addOnSuccessListener { result -> val textState = result.candidates.joinToString(separator = "\n-> ") { it.text } recognised("识别结果:\n-> $textState") Log.d(TAG, "识别结果: $textState") } .addOnFailureListener { e -> Log.e(TAG, "识别失败: $e") recognised("识别失败: ${e.message}") } } fun clearInk() { inkBuilder.clear() } }
XML实现代码
MainActivity.kt
class MainActivity : AppCompatActivity() { private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) binding.apply { textView.movementMethod = ScrollingMovementMethod() StrokeManager.downloadModel() buttonRecognize.setOnClickListener { textView.visibility = View.VISIBLE buttonRecognize.visibility = View.INVISIBLE StrokeManager.recognize { result -> textView.text = result } } buttonClear.setOnClickListener { drawView.clear() StrokeManager.clearInk() textView.visibility = View.INVISIBLE buttonRecognize.visibility = View.VISIBLE } } } }
DrawView.kt
class DrawView(context: Context, attributeSet: AttributeSet?) : View(context, attributeSet) { private var currentStrokePaint: Paint = Paint() private val canvasPaint: Paint = Paint(Paint.DITHER_FLAG) private val currentStroke: Path = Path() private var drawCanvas: Canvas? = null private lateinit var canvasBitmap: Bitmap init { currentStrokePaint.color = Color.BLACK currentStrokePaint.isAntiAlias = true currentStrokePaint.strokeWidth = STROKE_WIDTH_DP currentStrokePaint.style = Paint.Style.STROKE currentStrokePaint.strokeJoin = Paint.Join.ROUND currentStrokePaint.strokeCap = Paint.Cap.ROUND } override fun onTouchEvent(event: MotionEvent): Boolean { val action = event.actionMasked val x = event.x val y = event.y when (action) { MotionEvent.ACTION_DOWN -> currentStroke.moveTo(x, y) MotionEvent.ACTION_MOVE -> currentStroke.lineTo(x, y) MotionEvent.ACTION_UP -> { currentStroke.lineTo(x, y) drawCanvas?.drawPath(currentStroke, currentStrokePaint) currentStroke.reset() } else -> {} } StrokeManager.addNewTouchEvent(event) invalidate() return true } override fun onDraw(canvas: Canvas) { canvas.drawBitmap(canvasBitmap, 0f, 0f, canvasPaint) canvas.drawPath(currentStroke, currentStrokePaint) } fun clear() { onSizeChanged(canvasBitmap.width, canvasBitmap.height, canvasBitmap.width, canvasBitmap.height) } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { canvasBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) drawCanvas = Canvas(canvasBitmap) invalidate() } companion object { private const val STROKE_WIDTH_DP = 6.0f } }
布局文件
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <androidx.appcompat.widget.AppCompatButton android:id="@+id/buttonClear" android:layout_width="40dp" android:layout_height="40dp" android:layout_gravity="top|end" android:background="@drawable/icon_delete"/> <FrameLayout android:layout_width="match_parent" android:layout_height="match_parent"> <com.ibrahimcanerdogan.digitalinkrecognition.view.DrawView android:id="@+id/drawView" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/white"/> </FrameLayout> <FrameLayout android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.appcompat.widget.AppCompatTextView android:id="@+id/textView" android:layout_width="match_parent" android:layout_height="200dp" android:background="@drawable/background_textview" android:layout_gravity="bottom" android:paddingStart="20dp" android:paddingEnd="20dp" android:paddingTop="10dp" android:textSize="17sp" android:textColor="@color/black" android:scrollbars="vertical" android:visibility="invisible"/> <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton android:id="@+id/buttonRecognize" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center|bottom" android:layout_margin="15dp" app:icon="@drawable/icon_start" android:text="@string/recognize" android:textAllCaps="true"/> </FrameLayout> </FrameLayout>
问题根源分析
触摸事件重复处理:原Compose代码同时使用
detectDragGestures和pointerInteropFilter处理触摸事件,导致事件被拆分或重复传递给StrokeManager,无法形成连续的笔画(Stroke),而MLKit的墨迹识别依赖连续的笔画数据来提升精度。InkBuilder实例不一致:原代码在MainActivity的companion object中维护了一个独立的inkBuilder实例,和StrokeManager内部的实例不同步,导致部分笔画没有被正确添加到识别用的Ink对象中。
错误的事件分支触发:原StrokeManager的else分支逻辑错误,会在非标准触摸事件时创建新的Stroke并立即添加到Ink中,导致单个笔画被拆分成多个微小Stroke,严重干扰识别精度。
修复方案
统一触摸事件处理:移除
detectDragGestures,仅通过pointerInteropFilter将触摸事件传递给StrokeManager,确保事件流程和XML版本一致。统一InkBuilder管理:将InkBuilder的维护完全交给StrokeManager,外部仅调用其提供的方法,避免实例不一致问题。
修正事件分支逻辑:删除StrokeManager中else分支的错误逻辑,仅处理标准的ACTION_DOWN/ACTION_MOVE/ACTION_UP事件,保证笔画的连续性。
添加清空方法:在StrokeManager中新增
clearInk方法,统一清空InkBuilder,避免残留笔画影响下一次识别。
内容的提问来源于stack exchange,提问作者Shubham

