做Android开发这几年,我越来越觉得“自定义View”是区分初中级和高级工程师的一道分水岭。你可以在XML里堆出一百个控件,但如果让你从零手写一个能画、能存、能撤销的笔迹控件,很多人会卡在第一步。“自定义手写View”这件事,按我的理解包含两层:一层是字面意义上的“手写”,像签字板、手绘白板那样接收手指输入;另一层是“亲手写一个自定义View”,把测量、绘制、触摸、数据保存这条链路完整走一遍。这篇博文就以一个手写板控件为主线,把这两个层面一起讲透。
无论你是刚接触自定义View的初学者,还是已经在项目里写过几个但总感觉“差点意思”的朋友,这篇文章都值得看完。我会先讲清楚为什么系统控件撑不起这类需求,再按测量、绘制、触摸、撤销、性能优化的顺序逐步拆解,最后分享一些真实项目里的踩坑记录和扩展思路。
1. 想清楚再动手:这个View到底要解决什么问题
1.1 系统控件撑不起来的场景
有人说Android自带的View体系已经覆盖了90%的UI需求,这个说法没错,但它恰恰忽略了那10%的场景才是自定义View的用武之地。手写板就是典型例子:你要一个能接收手指滑动、显示笔迹、支持撤销、还能导出图片的控件。往上翻TextView不行,ImageView不行,RecyclerView更不行,这些控件压根没考虑“用户绘制”这个维度。
硬要用现有的控件拼也不是不行——用ImageView叠加Canvas、用多个View拼接——但性能、交互、扩展性都会成为问题。为什么?因为系统控件的绘制逻辑和事件处理是写死的,你改不了它内部怎么画、怎么响应。而手写板这类需求的核心恰恰是“自定义绘制+自定义触摸响应”,与其在系统控件上做各种hack,不如从零写一个。
1.2 自定义View的三条路线怎么选
很多新手一上来就问“自定义View怎么写”,其实自定义View是三套方法论,对应三种不同的继承路线:
- 继承View:重写
onDraw、onMeasure、onTouchEvent,实现一个“从里到外都是自己的”控件,适合雷达图、仪表盘、手写板这类特殊外观和交互的控件。 - 继承ViewGroup:重点在
onLayout和onMeasure,处理子View的排列和布局规则,适合流式布局、标签云、自定义容器。 - 组合控件:把几个系统控件组合封装成一个新的控件,逻辑简单、开发效率高,适合“标题栏+内容区+底部按钮”这类业务组件。
手写板属于第一类。因为它没有子View,主要工作是处理画笔和画布,继承View是最合理的选择。这也提醒你:写自定义View之前先搞清楚你要走哪条路线,路线错了后面全是坑。
1.3 手写板的需求拆解与方案定型
动手写代码之前,我习惯先把需求拆成几条硬性指标。手写板的核心需求无非这些:
- 手指触摸能产生笔迹,笔迹要顺滑、跟手,不能断断续续。
- 笔迹有颜色和粗细,能通过外部接口动态设置。
- 支持撤销上一步操作(至少一级,最好能多级)。
- 能保存成图片,方便后续上传或分享。
- 性能要稳,快速滑动时不能掉帧。
这样一拆,方案的轮廓就出来了:View作为载体,Path存储笔迹轨迹,Paint定义画笔样式,onTouchEvent接收触摸事件,invalidate触发重绘,撤销用快照栈实现。下面每个环节我都会详细展开。
2. 核心流程逐段拆解:测量、绘制、绘制到底是怎么串起来的
2.1 onMeasure:先量好尺寸再开工
很多新手写完自定义View丢进布局,发现控件要么不显示,要么尺寸不对,问题多半出在onMeasure。onMeasure是整个View树布局环节的第一步,系统会问这个View“你有多大”,你必须在测量结果里给出尺寸。
onMeasure的核心是MeasureSpec,它是个32位的int值,高2位是测量模式,低30位是大小。三种模式的理解方式我总结成一句话:
| 模式 | 含义 | 对应布局写法 | 控件此时该怎么做 |
|---|---|---|---|
| EXACTLY | 尺寸确定,不用商量 | match_parent或100dp | 老老实实用MeasureSpec给的大小 |
| AT_MOST | 最大就这么多,别超了 | wrap_content | 自己计算内容尺寸,取两者较小值 |
| UNSPECIFIED | 没有限制,随便发挥 | ScrollView等内部 | 按内容自然大小处理 |
自定义View最容易踩的坑就是wrap_content失效。默认情况下,如果View没有重写onMeasure,系统会将wrap_content当成match_parent处理,控件会占满整个父容器,这不是我们想要的。
手写板这种控件,业务上一般希望它铺满可用区域,所以尺寸逻辑比较简单。但如果你想让它也能在wrap_content下给出合理尺寸,就得自己写一套逻辑:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val defaultSize = 600 // 业务上规定的最小尺寸,或者根据内容计算 val width = resolveSize(defaultSize, widthMeasureSpec) val height = resolveSize(defaultSize, heightMeasureSpec) setMeasuredDimension(width, height) } // resolveSize其实就是: // EXACTLY -> 使用specSize // AT_MOST -> 取min(specSize, defaultSize) // UNSPECIFIED -> 使用defaultSizeresolveSize是系统提供的方法,核心逻辑就是上面那个表格的Java实现,用它可以少写一堆if else。
2.2 onDraw:画笔和画布的基本功
onDraw是整个View重绘的出口,所有视觉内容都在这里画出来。手写板的onDraw其实很轻量,主要就是画笔配合Path画线:
class HandWriteView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val mPaint = Paint().apply { isAntiAlias = true color = Color.BLACK style = Paint.Style.STROKE strokeWidth = 8f strokeCap = Paint.Cap.ROUND strokeJoin = Paint.Join.ROUND } private val mPath = Path() override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.drawPath(mPath, mPaint) } }这段代码有几个细节值得说道说道:
Paint.Style.STROKE表示只画线条不填充,这是笔迹的基本需求。strokeCap和strokeJoin设置为ROUND,让笔迹拐弯处是圆角而不是生硬的直角,手感会好很多。isAntiAlias = true务必开启,否则斜线和弧线上会有一圈“毛刺”,看起来像锯齿。Path是承载笔迹轨迹的数据结构,canvas.drawPath一笔画完。
这里我要强调一个原则:不要在onDraw里做多余的事情。onDraw的调用频率极高(每帧都可能调用),你在这里new对象、做耗时计算,内存抖动和卡顿马上就会来找你。正确做法是:所有对象在构造时创建好,onDraw只负责“画”。
2.3 坐标系与View边界:画布上定位的心智模型
Android的坐标系是“左上角原点,x轴向右,y轴向下”,这个很多人知道,但真正画的时候还是会犯迷糊。比如你想画一条从左上角到右下角的对角线,坐标是(0,0) -> (width, height),没问题。但如果你想画一条“居中”的线,就必须在onSizeChanged或onDraw里动态获取width和height,不能写死。
override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val centerX = width / 2f val centerY = height / 2f canvas.drawLine(centerX - 100f, centerY, centerX + 100f, centerY, mPaint) }手写板里坐标系还关系到触摸坐标的映射。MotionEvent.getX()和getY()返回的是相对于当前View的坐标,所以不用做额外转换,直接用就行。但如果后续要做双指缩放、图片旋转、PDF标注这类功能,就要引入Matrix来做坐标变换了,这部分在扩展章节细说。
3. 让笔画跟手:触摸事件与Path绘制细节
3.1 事件分发与消费:为什么你的View老是没反应
onTouchEvent是自定义View接收触摸信号的入口。很多人写了自定义View却发现触摸没反应,十有八九是返回值写错了。onTouchEvent返回true代表你消费了这个事件,返回false代表你不处理,系统会把这个事件交给父容器处理。
手写板肯定要消费触摸事件,所以直接返回true:
override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { ... } MotionEvent.ACTION_MOVE -> { ... } MotionEvent.ACTION_UP -> { ... } } return true }这里要注意actionMasked和action的区别。在单指操作时两者一样,但一旦有多指触控(缩放、旋转),event.action会包含pointer index信息,直接和ACTION_DOWN比较会出错。定义写法用actionMasked是稳妥的习惯。
还有一个隐藏知识点:如果你的View还有子View或者被嵌套在可滚动的容器里,触摸事件会涉及拦截和分发。比如手写板放在ScrollView里,上下滑动的操作会被ScrollView抢走。这时需要在ACTION_DOWN时调用parent.requestDisallowInterceptTouchEvent(true),告诉父容器“别跟我抢事件”。
3.2 直线与贝塞尔曲线:笔画为什么断
触摸事件拿到手之后,最直观的做法是每来一个ACTION_MOVE就往Path里连一条直线:
MotionEvent.ACTION_MOVE -> { mPath.lineTo(event.x, event.y) invalidate() }这段代码跑起来你会发现:笔画是能画,但画出来的线有明显的折角和顿挫感,尤其是快速滑动的时候,像锯齿拼起来的一样。根本原因是ACTION_MOVE事件是按帧采样的,两个事件之间的坐标变化是离散的,直接用直线连接采样点,路径就是一条折线。
解决方法是使用贝塞尔曲线。贝塞尔曲线本身是数学上的平滑曲线,但我们拿到的是一堆离散点,怎么让曲线穿过这些点?业内常用的方案是二次贝塞尔quadTo,用中点做锚点、前一个点做控制点:
private var mPreX = 0f private var mPreY = 0f MotionEvent.ACTION_DOWN -> { mPath.moveTo(event.x, event.y) mPreX = event.x mPreY = event.y } MotionEvent.ACTION_MOVE -> { val midX = (event.x + mPreX) / 2 val midY = (event.y + mPreY) / 2 mPath.quadTo(mPreX, mPreY, midX, midY) mPreX = event.x mPreY = event.y invalidate() }简单解释一下:每来一个新点,我们都把前一个点和当前点的中点作为二次贝塞尔曲线的终点,前一个点作为控制点。这样画出来的曲线不是简单连接采样点,而是用一种平滑的方式穿过采样点的中点,视觉效果非常顺滑,很多手写识别和绘图App用的都是这个思路。
这里还要提一个要求:不要在ACTION_DOWN里调用invalidate。按下时只是记录起始点,画面没有变化,不需要触发重绘。等ACTION_MOVE真正产生新的笔迹了再刷新,能省掉很多无意义的绘制。
3.3 画笔参数:锯齿、粗细、圆角的三件套
笔迹的观感很大程度上取决于Paint的配置。我总结了一套手写笔迹的“三件套”配置,基本能满足大多数场景:
private val mPaint = Paint().apply { isAntiAlias = true // 抗锯齿,消除毛边 style = Paint.Style.STROKE // 只描边 strokeWidth = 8f // 笔迹粗细,可根据业务调整 strokeCap = Paint.Cap.ROUND // 线帽圆角,起笔收笔更柔和 strokeJoin = Paint.Join.ROUND // 转折圆角,拐弯不尖锐 }如果你做的是画笔工具,想把粗细做成可变的,常规做法是用Paint.setStrokeWidth()每次更新,或者更进阶一点,用Paint.SetStrokeCap()配合VALID时间间隔计算速度,让慢画时笔迹更粗、快画时更细。这种“压力模拟”体验非常加分,但实现起来需要多维护一个轨迹数据列表,因为Path没有修改历史笔迹的能力,只能每次从轨迹列表重新构建路径。
另外一个容易忽略的点是:Paint的颜色和粗细改变不能同步到已经画过的笔迹。如果你在onDraw里直接改画笔再画整条Path,旧笔迹也会被重刷成新样式。要做到“不同笔画不同属性”,就得维护一个“笔迹对象列表”,每个对象包含自己的Path和Paint,一帧一帧地全部绘制。这个设计在数据撤销时也会用到,我放在下一节讲。
4. 笔迹数据不丢:撤销、重做与保存实现
4.1 把笔迹当数据来管理
很多人在做手写板时,把Path和Paint都当成了“全局单例”,画一笔就去改同一个Path。这样简单是简单,但一旦要做撤销,就傻眼了:Path混在一起,没法区分哪一段是上一步画的。
更好的方法是从头就设计“笔迹对象”的概念。定义一个数据类,把一次手势产生的所有轨迹和样式保存下来:
class StrokeData( val path: Path, val paint: Paint )然后在View里维护一个mStrokes列表,每次ACTION_UP把当前完成的路径存入列表。绘制的时候遍历整个列表:
override fun onDraw(canvas: Canvas) { super.onDraw(canvas) for (stroke in mStrokes) { canvas.drawPath(stroke.path, stroke.paint) } canvas.drawPath(mCurrentPath, mCurrentPaint) // 正在画的路径 }这样每一条笔画都是独立的数据对象,撤销时可以精确移除最后一条,保存时也可以单独控制某条笔迹的显示与隐藏。这个列表就是整个手写板的“数据模型”,后面所有的功能都围绕它展开。
4.2 基于Path快照的撤销/重做栈
有了笔迹列表,撤销和重做就变成了纯粹的栈操作:
- 撤销:从
mStrokes中弹出最后一个StrokeData,压入重做栈。 - 重做:从重做栈中弹出一个
StrokeData,压回mStrokes。 - 每次操作后调用
invalidate()刷新界面。
这里有一个细节要处理:Path是可变对象,一旦把它加入列表,后续如果继续对它做lineTo等操作,前面保存的对象也会被污染。所以ACTION_UP时,一定要把正在构建的Pathclone一份再存入列表:
MotionEvent.ACTION_UP -> { if (!mCurrentPath.isEmpty) { val stroke = StrokeData(Path(mCurrentPath), Paint(mCurrentPaint)) mStrokes.add(stroke) redoStack.clear() // 新笔画产生后,重做栈失效 mCurrentPath.reset() } invalidate() }Path有拷贝构造器Path(mCurrentPath),Paint也有Paint(mCurrentPaint),这两个构造函数都是自带的,直接拿来用就行。如果你用了strokeWidth、颜色等动态变化的画笔,注意在ACTION_DOWN时就要把当前画笔单独存一份,不能共用同一个画笔实例,否则所有笔画会互相影响。
4.3 把画板导出成图片
保存手写板内容到相册或上传服务器,核心逻辑是创建一个跟View尺寸一致的Bitmap,把它作为画布,把所有笔迹重新画一遍:
fun exportBitmap(): Bitmap { val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) // 画背景色(根据需要) canvas.drawColor(Color.WHITE) // 绘制所有笔迹 for (stroke in mStrokes) { canvas.drawPath(stroke.path, stroke.paint) } canvas.drawPath(mCurrentPath, mCurrentPaint) return bitmap }有几个细节要注意:
Bitmap.Config.ARGB_8888是常规选择,支持透明通道和128级灰度,颜色精度高。如果你内存紧张,可以考虑RGB_565,但会丢失透明度,手写板场景不建议。- 导出时
width和height是View的像素尺寸,如果View在屏幕上是dp单位,导出图片的像素密度可能不够清晰。想导出高分辨率图,可以创建一个更大的Bitmap,然后对笔迹坐标做scale变换,这个进阶需求比较长,先用基础版够用。 ACTION_UP时如果mCurrentPath还没存入列表,导出时别忘了把它也画上去,否则最后一笔会丢失。
保存到相册用MediaStore,Android 10以上需要分区存储适配。代码不复杂,但要注意官方推荐的写法已经多次变更,建议直接用MediaStore.Images的insert方式:
val contentValues = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, "handwrite_${System.currentTimeMillis()}.png") put(MediaStore.Images.Media.MIME_TYPE, "image/png") } val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) if (uri != null) { contentResolver.openOutputStream(uri)?.use { outputStream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) } }5. 卡顿、花屏、不刷新:性能优化与踩坑实录
5.1 刷新频率与invalidate局部刷新
invalidate()是整个View重绘的总开关。你知道它能触发重绘,但不一定知道它有几个重载方法,以及它们之间的性能差距:
invalidate():刷新整个View。invalidate(Rect dirty):只刷新指定区域。invalidate(left, top, right, bottom):只刷新指定坐标范围。
手写板画布一般是全屏尺寸,如果你手快画一条特长线,整个区域都可能脏掉,直接用invalidate()并不可怕。但如果是嵌在一个页面里的较大View,或者做了“画笔预览悬浮窗”之类局部更新的UI,那局部刷新就能明显降低GPU负载。
还有一个多线程场景容易踩坑:如果你在子线程里更新了数据,想刷新界面,必须用postInvalidate()而不是invalidate()。invalidate()只在UI线程有效,在非UI线程调用会有异常风险。手写板的触摸事件本来就是UI线程分发的,一般用不到postInvalidate,但如果你做了“后台识别笔迹并回显”的功能,就一定会碰上。
5.2 硬件加速与复杂路径的博弈
Android在API 21以上默认开启硬件加速,绝大多数情况下这是好事,GPU分担了CPU的绘制压力。但硬件加速并不总是对自定义View有利,尤其是涉及复杂Path操作时,有些绘制API在硬件加速模式下会失效或者性能反而变差。
举个例子:橡皮擦功能,很多人的第一反应是用Paint的PorterDuff.Mode.CLEAR来实现:
val eraserPaint = Paint().apply { color = Color.TRANSPARENT xfermode = PorterDuff.Mode.CLEAR }这个方法在纯软件绘制下没问题,但在硬件加速的Canvas上,PorterDuff.Mode.CLEAR并不能直接用于drawPath,要么笔迹擦不干净,要么花屏。解决办法有两个:
- 给View设置
setLayerType(View.LAYER_TYPE_SOFTWARE, null),强制走软件绘制,但整个View的性能会下降,谨慎使用。 - 维护一个“擦除区域列表”,在上层通过
clipRect+drawPath实现擦除,逻辑更复杂但可以保留硬件加速。
我个人建议是:如果只是给白板加“橡皮擦”这种低频功能,LAYER_TYPE_SOFTWARE问题不大;如果是高频手绘场景,老老实实把擦除做成数据操作(比如从笔迹列表中移除与擦除区域相交的Path片段),虽然实现成本高,但性能最稳。
5.3 高频触摸下的内存抖动与卡顿
手写板这种控件最考验的是ACTION_MOVE高频回调下的性能稳定性。很多人写了基本功能觉得没问题,但用真机快速滑动几秒就能感受到明显掉帧。常见的性能杀手有三个:
在
onDraw里创建对象:比如每次都new Paint(),内存频繁分配和回收导致GC卡顿。正确做法是把Paint、Path等对象提到成员变量,复用。在
onTouchEvent里做耗时操作:比如每次都把当前Path转成Bitmap,或者做复杂的贝塞尔计算。贝塞尔本身计算量不大,但如果你每次ACTION_MOVE都去遍历一个很大的List重画全部路径,就要注意了。Path对象无限膨胀:如果你在
ACTION_MOVE里对同一个Path不断lineTo或quadTo,Path内部的点数组会不断扩容。手写板长时间使用后,整个View的内存占用会越来越大。解决方案是给Path做“分段管理”,超过一定点数就开启新Path,但要注意绘制时把旧Path也画出来,否则会出现部分笔迹消失的bug。
方案上是把StrokeData设计成“Path数组”而不是单个Path,每次分段都在同一笔画上连续绘制:
class StrokeData( val paths: MutableList<Path> = mutableListOf(Path()), val paint: Paint )每次画出一定量的点之后,新建一个Path并立即把当前位置moveTo过去,保证各段之间视觉是连续的。这样既能控制Path内存增长,又能保留撤销的粒度。
5.4 各种“不显示”的排查思路
自定义View写出来不显示,是新手最容易焦虑的问题。基于我的经验,排查顺序基本是:
- 布局测量为0:View没有宽高,自然是白板。检查
onMeasure是否正确执行,setMeasuredDimension是否调用。 - 绘制内容没有尺寸:
Paint的strokeWidth为0,或者画的内容超出View边界。 - 背景盖住了画布:父布局的背景色比View的绘制内容更靠上,你需要调整View层级。
- 坐标系搞反了:画的内容在屏幕外,比如x坐标算成了负数。
- 没有调用
invalidate:数据变了,界面不刷新,看起来像没反应。这一步最容易被忽略,检查一下你的ACTION_MOVE里有没有触发重绘。
还有一个小坑:自定义View如果设置了android:clipChildren或者父容器有裁剪逻辑,绘制在View边界外的内容会被裁掉,看起来像“画没了”。
5.5 对现实设备的分寸感:像素密度与手势阈值
自定义View的尺寸和触摸事件都和像素密度、设备屏幕有关。同样的dp在不同的dpi设备上对应的像素不同,如果你的笔迹粗细直接用dp转px,在不同设备上观感会保持一致,但如果写死了px,小屏手机上会偏粗,平板会偏细。
转换公式:
val strokeWidthInPx = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 4f, resources.displayMetrics )触摸事件的另一个陷阱是TouchSlop。系统有一个最小的滑动距离阈值,小于这个距离的事件不会被认为是滑动。如果手写板放在ScrollView里,你在ACTION_DOWN后没有立刻requestDisallowInterceptTouchEvent,抬手前的极小位移可能被父容器判断为点击,导致笔迹始终画不出来或画出来很短。解决方案是ACTION_DOWN后马上把事件锁死在当前View,保证后续ACTION_MOVE都能到达onTouchEvent。
6. 真实项目里还可以怎么扩展
6.1 双指缩放与平移:从画板到“白板”的工具进化
手写板的基础功能做完之后,很多人会想做白板。白板需要双指缩放、双指平移、可以放大细节画图。这个功能引入了坐标变换的概念。
Android的Canvas支持Matrix变换,缩放和平移可以作用在整个Canvas上:
override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.save() canvas.concat(mMatrix) // 应用缩放平移 for (stroke in mStrokes) { canvas.drawPath(stroke.path, stroke.paint) } drawCurrentPath(canvas) canvas.restore() }触摸坐标也要逆变换:“屏幕坐标”变成“画布坐标”再交给Path:
val inverseMatrix = Matrix() mMatrix.invert(inverseMatrix) val points = floatArrayOf(event.x, event.y) inverseMatrix.mapPoints(points) val canvasX = points[0] val canvasY = points[1]这个方案可行,但要注意缩放中心点的选择:一般以手指的双指中心为缩放中心,而不是屏幕中心,这样手感更自然。实现思路是:记录上一次双指中点和当前双指中点的距离比值,以及位移量,把它们累加到mMatrix里。细节不少,但用Matrix做变换比手动修改所有Path坐标要省事得多。
6.2 动态更换画笔与背景:数据结构的红利
我在前面强调的StrokeData列表设计,在扩展功能时优势非常明显。要支持换颜色、换画笔粗细、加背景图,只需要修改Paint对象或者新增背景绘制逻辑,不需要改动整体架构。
如果你想让“新笔迹”和“旧笔迹”使用不同样式,就需要维护一个“当前画笔”和“历史画笔”两个实例。绘制时用每段StrokeData自己携带的Paint,而不是全局画笔,这样换颜色只影响新画的内容,不会破坏已完成的笔迹。
背景图片的绘制也很简单,在onDraw最前面加一个判断:
mBackgroundBitmap?.let { background -> canvas.drawBitmap(background, 0f, 0f, null) }背景和笔迹分离,"导出图片"时就可以选择带背景或不带背景两个版本,业务上很实用。
6.3 数据持久化与进程重建恢复
手写板上画了一堆东西,用户切到后台,进程被系统杀掉,回来发现内容全没了,这个体验很差。解决思路是把StrokeData序列化到本地:
- 简单方案:存一张合并后的Bitmap。
- 进阶方案:把每条
Path的坐标点序列化成文件或数据库,这样还能保留分笔画的数据结构,支持后续的语义分析或还原为矢量化内容。
Path本身不可直接序列化,但你可以遍历PathMeasure或者自己维护点的List,再写入文件。这里我建议:如果是刚学阶段,先做Bitmap缓存;如果产品定位是可编辑的白板,再考虑坐标序列化。多数App的实际情况是,用户画完立即导出图片,对矢量还原的需求其实没那么强。
6.4 按需缩放导出:让你的图像在高分屏上依然清晰
一个真实项目里常见的需求是:用户希望导出的图片不是屏幕分辨率,而是更高清晰度。实现原理是通过缩放Canvas来重绘所有笔迹:
fun exportBitmap(scaleFactor: Float): Bitmap { val bitmap = Bitmap.createBitmap( (width * scaleFactor).toInt(), (height * scaleFactor).toInt(), Bitmap.Config.ARGB_8888 ) val canvas = Canvas(bitmap) canvas.scale(scaleFactor, scaleFactor) drawStrokesToCanvas(canvas) return bitmap }把原来exportBitmap里的绘制逻辑抽成一个通用方法,传入不同的Canvas即可。这个方法绕开了View自带的分辨率限制,做高分辨率签名导出时非常有用。
6.5 从“能用”到“好用”的几个小贴士
写手写板这类自定义View,性能和数据结构的优先级,要高于“一次性把功能写全”。踩过几次坑之后,我个人的体会是:
- 早一点引入列表式数据管理,宁可先多写20行代码,也别等撤销功能要求来了再重构。
- 多测试真机上的绘制效果,模拟器里的触摸坐标和真机差异很大,很多笔迹断裂问题在模拟器上根本复现不出来。
- 给你的View提供一套简单的“对外API”,比如
clear()、undo()、redo()、setStrokeColor()、setStrokeWidth(),这样业务层调用时不用关心内部实现,后续换底层重写时对上层代码的影响也最小。
自定义View这条路没有太多捷径,多写、多跑真机、多总结,才是提升最快的方式。这篇文章里的手写板方案是我在真实项目中验证过的,你照着搭一版基础功能,再根据业务需求往里面加扩展,应该能避开大多数常见的坑。