ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android自定义View手写板核心实现:测量、绘制、触摸与撤销全解析

Android自定义View手写板核心实现:测量、绘制、触摸与撤销全解析 做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代表你不处理系统会把这个事件交给父容器处理。手写板肯定要消费触摸事件所以直接返回trueoverride 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还没存入列表导出时别忘了把它也画上去否则最后一笔会丢失。保存到相册用MediaStoreAndroid 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的性能会下降谨慎使用。维护一个“擦除区域列表”在上层通过clipRectdrawPath实现擦除逻辑更复杂但可以保留硬件加速。我个人建议是如果只是给白板加“橡皮擦”这种低频功能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或quadToPath内部的点数组会不断扩容。手写板长时间使用后整个View的内存占用会越来越大。解决方案是给Path做“分段管理”超过一定点数就开启新Path但要注意绘制时把旧Path也画出来否则会出现部分笔迹消失的bug。方案上是把StrokeData设计成“Path数组”而不是单个Path每次分段都在同一笔画上连续绘制class StrokeData( val paths: MutableListPath mutableListOf(Path()), val paint: Paint )每次画出一定量的点之后新建一个Path并立即把当前位置moveTo过去保证各段之间视觉是连续的。这样既能控制Path内存增长又能保留撤销的粒度。5.4 各种“不显示”的排查思路自定义View写出来不显示是新手最容易焦虑的问题。基于我的经验排查顺序基本是布局测量为0View没有宽高自然是白板。检查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() }触摸坐标也要逆变换“屏幕坐标”变成“画布坐标”再交给Pathval 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这条路没有太多捷径多写、多跑真机、多总结才是提升最快的方式。这篇文章里的手写板方案是我在真实项目中验证过的你照着搭一版基础功能再根据业务需求往里面加扩展应该能避开大多数常见的坑。
返回列表