前言
近期看到谷歌官方推文有一篇关于Jetpack Ink API的文章,随即进行了了解和研究,该SDK主要就是低延时的手写绘制,比如通过手指或者触控笔在安卓设备上面进行笔记记录或者在安卓设备上面进行素描之类类似于纸张上面的操作。当然了可能现在市场上已经存在了此类APP,或者此类的SDK,具体实现方式可能不一,有通过Canvas实现的或者OpenGL实现的,但如果没有系统层面支持的话,一般书写延迟都会相对较高,特别是屏幕越大,越明显,但博主对该SDK书写进行了验证,确实非常丝滑。
下面看下官方是如何描述的:
总结下可能就以下几点:
-
1、笔记书写,这个就不用多说了,主要功能。
-
2、低延迟,功能特色,同纸张上面书写一样流畅,号称延迟降低到了4ms,这一点其实博主是抱怀疑态度的,熟知安卓系统显示的同学可能都知道,要实现这一点,应该难度非常大,况且这个SDK还是通用的,当然也可能是实验性的特定设备吧,并不是所有设备都能达到4ms。
-
3、对于书写实现简单,但是几何图形方面可能会相对会困难一些。当然这一点博主是根据官方字面意思看到的,本篇主要是对书写方面进行了实现,实际博主还未进行几何图形方面的验证,但后续会持续进行该SDK探索,敬请期待。
添加Ink API依赖
libs.version.toml文件:
[versions]
ink = "1.0.0-alpha01"
input = "1.0.0-beta05"[libraries]
ink-authoring = {group = "androidx.ink", name = "ink-authoring", version.ref = "ink"}
ink-brush = {group = "androidx.ink", name = "ink-brush", version.ref = "ink"}
ink-geometry = {group = "androidx.ink", name = "ink-geometry", version.ref = "ink"}
ink-nativeloader = {group = "androidx.ink", name = "ink-nativeloader", version.ref = "ink"}
ink-rendering = {group = "androidx.ink", name = "ink-rendering", version.ref = "ink"}
ink-strokes = {group = "androidx.ink", name = "ink-strokes", version.ref = "ink"}
input-motionprediction = {group = "androidx.input", name = "input-motionprediction", version.ref = "input"}
app下的build.gradle中添加:
dependencies {//...implementation(libs.ink.authoring)implementation(libs.ink.brush)implementation(libs.ink.geometry)implementation(libs.ink.nativeloader)implementation(libs.ink.rendering)implementation(libs.ink.strokes)implementation(libs.input.motionprediction)
}
代码实现
通过Ink API实现书写的时,我们需要用到SDK中的InProgressStrokesView控件,如下先将其添加到布局:
<androidx.ink.authoring.InProgressStrokesViewandroid:id="@+id/inProgressStrokesView"android:layout_width="match_parent"android:layout_height="match_parent"/>
为了可持续验证与后续方便使用,这里我对InProgressStrokesView功能进行了封装,新建一个名为InProgressStrokesViewWrapper的类:
class InProgressStrokesViewWrapper(private var inProgressStrokesView: InProgressStrokesView):OnTouchListener{//预测器,加速过程中会用到点的预测private val predictor:MotionEventPredictor = MotionEventPredictor.newInstance(inProgressStrokesView)//这个东西可以理解为笔形吧private val defaultBrush = Brush.createWithColorIntArgb(family = StockBrushes.pressurePenLatest,colorIntArgb = Color.Black.toArgb(),size = 10F,epsilon = 0.1F)init {//添加触控监听器inProgressStrokesView.setOnTouchListener(this)}
}
这个类实际上可以看做是对InProgressStrokesView的代理,在该类中首先创建看了一个触控点的预测器,这个预测器主要是为了触控加速使用,使触控点更加跟手,接着创建了一个书写的笔形defaultBrush,InProgressStrokesView在绘制时将会通过这个笔形进行书写形状的绘制,让我们继续完善这个类,实现OnTouchListener接口:
override fun onTouch(view: View, event: MotionEvent): Boolean {//调试过程中发现如果点的位置和事件事件一致的话会崩溃,常见于某些设备up时上报的点与最后一个move的点,这块简单加了一个逻辑可能并不是很合适。if(lastEventPoint.x == event.x&&lastEventPoint.y==event.y){event.setLocation(event.x+1,event.y+1)}lastEventPoint.set(event.x,event.y)//预测器记录当前的触控事件predictor.record(event)//预测触控事件val predictedEvent = predictor.predict()try {when (event.actionMasked) {MotionEvent.ACTION_DOWN -> touchActionDown(view,event)MotionEvent.ACTION_MOVE -> touchActionMove(view,event,predictedEvent)MotionEvent.ACTION_UP -> touchActionUp(view,event)MotionEvent.ACTION_CANCEL -> touchActionCancel(view,event)else -> false}}finally {predictedEvent?.recycle()}return true}
因为调试的过程中发现某些设备上面绘制时存在崩溃,定位下来是因为前后两个点的坐标和时间完全一致导致的,因为崩溃点在native暂时不知道怎么看源码,可能是SDK内有什么保护,所以为什么这样会导致崩溃,未知!所以这里简单的加了个保护,可能并不是很合适,但暂且如此吧,主要也是为了看效果。
接着是预测器记录当前的触控,基于当前的触控进行预测并返回预测结果。
when代码块就是通过触控事件进行绘制逻辑的代码了,在看实际实现之前,我们先来了解下InProgressStrokesView的手写绘制流程。
触控事件 | InProgressStrokesView 方法 | 描述 |
---|---|---|
ACTION_DOWN | startStroke() | 开始笔画渲染 |
ACTION_MOVE | addToStroke() | 继续渲染笔画 |
ACTION_UP | finishStroke() | 完成笔画渲染 |
ACTION_CANCEL or FLAG_CANCELED | cancelStroke() | 取消笔画渲染 |
通过上面这个表格,大家应该能直观可以的看出来InProgressStrokesView的绘制流程,这里就不再过多赘述,让我我们继续。
//触控按下private fun touchActionDown(view: View, event: MotionEvent){// 这个号称无缓冲调度模式,看着好像会对触控有所加速view.requestUnbufferedDispatch(event)//Demo仅支持单笔绘制(看官方接口设计应该是可以支持多点书写),因此这里记录了第一个触发down事件的触控点的Idval pointerIndex = event.actionIndexval pointerId = event.getPointerId(pointerIndex)currentPointerId = pointerId//启动绘制currentStrokeId = inProgressStrokesView.startStroke(event = event, //当前触控事件pointerId = pointerId, //触控点Idbrush = defaultBrush //书写笔形)}
触控按下的时候首先给当前View设置了无缓冲调度模式,看API描述是强烈建议这么做,应该是会对触控有所加速吧。
然后保存了当前触控点的Id,后续只会针对该触控点进行笔画书写,在最后调用了inProgressStrokesView.startStroke告诉InProgressStrokesView可以开始笔画绘制了。
//触控移动private fun touchActionMove(view: View, event: MotionEvent,predictedEvent:MotionEvent?){val pointerId = currentPointerIdval strokeId = checkNotNull(currentStrokeId)for (pointerIndex in 0 until event.pointerCount) {if (event.getPointerId(pointerIndex) != pointerId) continue//继续绘制inProgressStrokesView.addToStroke(event, //触控事件pointerId, //触控点IdstrokeId, //启动绘制返回的strokeIdpredictedEvent //预测后的触控事件)}}
在收到触控移动事件时,只针对按下事件时记录的触控id进行了笔画的绘制处理,看API应该是可以支持多点同时绘制,但是我们主要是进行初次运行验证,所以就没有做成多点,先有0.5然后再有1吧。
//触控抬起private fun touchActionUp(view: View, event: MotionEvent){val pointerIndex = event.actionIndexval pointerId = event.getPointerId(pointerIndex)check(pointerId == currentPointerId)val currentStrokeId = checkNotNull(currentStrokeId)inProgressStrokesView.finishStroke(event,pointerId,currentStrokeId)}
在触控抬起事件时处理与移动事件类似,都是先校验触控点是否是我们需要进行处理的触控点,如果时这里会调用finishStroke结束笔画绘制。
//触控取消private fun touchActionCancel(view: View, event: MotionEvent){val pointerIndex = event.actionIndexval pointerId = event.getPointerId(pointerIndex)check(pointerId == currentPointerId)val currentStrokeId = checkNotNull(currentStrokeId)//触控取消后,也要执行书写的取消绘制inProgressStrokesView.cancelStroke(currentStrokeId, event)}
同样的逻辑,触控取消也需要判断是那个触控点取消了,如果是我们当前绘制的,就直接cancelStroke(),逻辑都不难,大家看下都能看懂应该。
至此笔画绘制流程就已经添加结束了,但是好像少了点什么,对了,我们需要添加一个清空绘制内容的函数,还有一个用户保存绘制内容的函数。
让我们先来看清除绘制如何实现。
//清空书写内容fun clearStrokes(){//清空的话,可以清空所有,也可以清空一部分,可以自行选择,这里是清空所有val finishedStrokes = mutableMapOf<InProgressStrokeId, Stroke>().apply { putAll(inProgressStrokesView.getFinishedStrokes()) }inProgressStrokesView.removeFinishedStrokes(finishedStrokes.keys)}
这个实现很简单,首先是通过inProgressStrokesView.getFinishedStrokes()获取到当前已经完成的所有的笔画,然后将需要删除的笔画,通过removeFinishedStrokes函数传给inProgressStrokesView进行删除即可,嗯…饶了一圈的感觉,不过通过这个删除笔画的函数应该不难看出,是可以选择性的进行删除的(比如实现撤销),不过这里做的是全部清空。
接下来看如何保存我们绘制的内容。
//将书写内容保存为bitmapfun saveAsBitmap(): Bitmap {//创建一个bitmap用于保存绘制内容var bitmap = Bitmap.createBitmap(inProgressStrokesView.width,inProgressStrokesView.height,Bitmap.Config.ARGB_8888)var canvas = Canvas(bitmap)val canvasTransform = Matrix()//保存时需要通过Ink API的渲染器进行操作,这个是必须的val canvasStrokeRenderer = CanvasStrokeRenderer.create()//拿到所有的已经完成的笔画val finishedStrokes = mutableMapOf<InProgressStrokeId, Stroke>().apply { putAll(inProgressStrokesView.getFinishedStrokes()) }finishedStrokes.forEach { (_, stroke) ->canvasStrokeRenderer.draw(canvas,stroke,canvasTransform)}return bitmap}
这个函数首先创建了一个用于保存所有笔画的bitmap,接着基于该bitmap创建了一个Canvas,这个Canvas的绘图操作全部被CanvasStrokeRenderer所代理,别问为什么,问就是Ink API设计如此,我本来想看下它内部实现,但是这里面有点绕,得花点时间理理,后续有机会再补充,这里额外说下canvasTransform这个是Matrix,如果有这个的话,那么说明,理论上inProgressStrokesView的画图区域可以无限大。
看到这里我们InProgressStrokesViewWrapper的代码就已经全部完成了,接下来让我们看看怎么使用,花了这么长时间进行封装了,使用那肯定是相当简单的,简单的几行代码就可以搞定:
private lateinit var inkWrapper:InProgressStrokesViewWrapperinkWrapper = InProgressStrokesViewWrapper(findViewById(R.id.inProgressStrokesView))
接着通过inkWrapper对象就可以进行清空绘制内容:
inkWrapper.clearStrokes()
或者保存为bitmap:
var bitmap = inkWrapper.saveAsBitmap()
至此我们Jetpack Ink API初探在这里就算是圆满结束了,感谢大家观看,本次结束,意味着下次的开始,后续我将继续探索Ink API的使用,以期能够发现其核心低延迟的奥秘。
另附上InProgressStrokesViewWrapper完整源码:
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.PointF
import android.view.MotionEvent
import android.view.View
import android.view.View.OnTouchListener
import androidx.ink.authoring.InProgressStrokeId
import androidx.ink.authoring.InProgressStrokesView
import androidx.ink.brush.Brush
import androidx.ink.brush.StockBrushes
import androidx.ink.brush.color.Color
import androidx.ink.brush.color.toArgb
import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer
import androidx.ink.strokes.Stroke
import androidx.input.motionprediction.MotionEventPredictorclass InProgressStrokesViewWrapper(private var inProgressStrokesView: InProgressStrokesView):OnTouchListener{private var currentPointerId = -1private var currentStrokeId: InProgressStrokeId? = nullprivate var lastEventPoint = PointF()//预测器,加速过程中会用到点的预测private val predictor:MotionEventPredictor = MotionEventPredictor.newInstance(inProgressStrokesView)//这个东西可以理解为笔形吧private val defaultBrush = Brush.createWithColorIntArgb(family = StockBrushes.pressurePenLatest,colorIntArgb = Color.Black.toArgb(),size = 10F,epsilon = 0.1F)init {//添加触控监听器inProgressStrokesView.setOnTouchListener(this)}//清空书写内容fun clearStrokes(){//清空的话,可以清空所有,也可以清空一部分,可以自行选择,这里是清空所有val finishedStrokes = mutableMapOf<InProgressStrokeId, Stroke>().apply { putAll(inProgressStrokesView.getFinishedStrokes()) }inProgressStrokesView.removeFinishedStrokes(finishedStrokes.keys)}//将书写内容保存为bitmapfun saveAsBitmap(): Bitmap {//创建一个bitmap用于保存绘制内容var bitmap = Bitmap.createBitmap(inProgressStrokesView.width,inProgressStrokesView.height,Bitmap.Config.ARGB_8888)var canvas = Canvas(bitmap)val canvasTransform = Matrix()//保存时需要通过Ink API的渲染器进行操作,这个是必须的val canvasStrokeRenderer = CanvasStrokeRenderer.create()//拿到所有的已经完成的笔画val finishedStrokes = mutableMapOf<InProgressStrokeId, Stroke>().apply { putAll(inProgressStrokesView.getFinishedStrokes()) }finishedStrokes.forEach { (_, stroke) ->canvasStrokeRenderer.draw(canvas,stroke,canvasTransform)}return bitmap}override fun onTouch(view: View, event: MotionEvent): Boolean {//调试过程中发现如果点的位置和事件事件一致的话会崩溃,常见于某些设备up时上报的点与最后一个move的点,这块简单加了一个逻辑可能并不是很合适。if(lastEventPoint.x == event.x&&lastEventPoint.y==event.y){event.setLocation(event.x+1,event.y+1)}lastEventPoint.set(event.x,event.y)//预测器记录当前的触控事件predictor.record(event)//预测触控事件val predictedEvent = predictor.predict()try {when (event.actionMasked) {MotionEvent.ACTION_DOWN -> touchActionDown(view,event)MotionEvent.ACTION_MOVE -> touchActionMove(view,event,predictedEvent)MotionEvent.ACTION_UP -> touchActionUp(view,event)MotionEvent.ACTION_CANCEL -> touchActionCancel(view,event)else -> false}}finally {predictedEvent?.recycle()}return true}//触控按下private fun touchActionDown(view: View, event: MotionEvent){// 这个号称无缓冲调度模式,看着好像会对触控有所加速view.requestUnbufferedDispatch(event)//Demo仅支持单笔绘制(看官方接口设计应该是可以支持多点书写),因此这里记录了第一个触发down事件的触控点的Idval pointerIndex = event.actionIndexval pointerId = event.getPointerId(pointerIndex)currentPointerId = pointerId//启动绘制currentStrokeId = inProgressStrokesView.startStroke(event = event, //当前触控事件pointerId = pointerId, //触控点Idbrush = defaultBrush //书写笔形)}//触控移动private fun touchActionMove(view: View, event: MotionEvent,predictedEvent:MotionEvent?){val pointerId = currentPointerIdval strokeId = checkNotNull(currentStrokeId)for (pointerIndex in 0 until event.pointerCount) {if (event.getPointerId(pointerIndex) != pointerId) continue//继续绘制inProgressStrokesView.addToStroke(event, //触控事件pointerId, //触控点IdstrokeId, //启动绘制返回的strokeIdpredictedEvent //预测后的触控事件)}}//触控抬起private fun touchActionUp(view: View, event: MotionEvent){val pointerIndex = event.actionIndexval pointerId = event.getPointerId(pointerIndex)check(pointerId == currentPointerId)val currentStrokeId = checkNotNull(currentStrokeId)inProgressStrokesView.finishStroke(event,pointerId,currentStrokeId)}//触控取消private fun touchActionCancel(view: View, event: MotionEvent){val pointerIndex = event.actionIndexval pointerId = event.getPointerId(pointerIndex)check(pointerId == currentPointerId)val currentStrokeId = checkNotNull(currentStrokeId)//触控取消后,也要执行书写的取消绘制inProgressStrokesView.cancelStroke(currentStrokeId, event)}}