ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从手写签名板入门Android自定义View:绘制流程与触摸事件全解析

2026/9/9 10:12:35 拓冰建站 浏览量
从手写签名板入门Android自定义View:绘制流程与触摸事件全解析 说实话自定义View这个东西很多Android开发干了三四年还是怕。一提到手写View脑子里就浮现出onMeasure、onDraw、Canvas、Paint这一堆东西总感觉是高手才配碰的领域。但实际上自定义View没有想象中那么玄乎它就是一套有章法的流程——你把绘制逻辑搞清楚了把触摸事件理顺了把重绘时机掌握了剩下的就是按部就班地写代码。这篇文章我打算从一个实际需求出发带着大家从零手写一个手写签名板。这个场景特别适合用来讲透自定义View因为它在绘制、触摸、性能优化上都有硬需求而且做出来的东西有明确的应用价值——电商签收、合同确认、平板手写笔记都能用上。无论是刚入门的初级开发还是写了两年业务代码想突破瓶颈的中级开发这篇文章都能让你对Android自定义View的完整链路有个清晰的认知。1. 整体设计与思路拆解1.1 手写View到底在解决什么问题先说清楚一个底层问题自定义View不是炫技它解决的是Android原生控件应付不来的场景。拿手写签名来说系统里没有现成的SignatureView给你用你也不能指望ImageView去处理连续的触摸轨迹。这时候就需要自己画——你要在用户手指滑过的地方实时渲染出笔迹同时还得保证这个过程足够流畅不能掉帧不能卡顿。拆解下来手写View需要解决三个核心问题接收触摸事件用户手指按下、移动、抬起你要能捕获到这些事件并且把触点坐标换算成View坐标系里的点。绘制笔迹拿到坐标点之后要用Canvas把轨迹画出来。这里涉及画笔的粗细、颜色、圆角、透明度等一系列参数设置。局部更新与性能平衡手指每移动一毫米就会产生大量事件如果每次都全屏重绘性能一定会崩。怎么在“实时显示”和“性能开销”之间找到平衡点是对一个自定义View是否合格的核心检验标准。除了这三个问题手写板和普通的自定义View还有一个关键区别——它需要持久化结果。用户签完名之后你得有办法把这张“纸”导出成图片这就逼着你考虑离屏渲染的问题。这些问题叠加在一起恰好把自定义View最常见的知识密集区全覆盖了。1.2 从坐标系上手写View的第一课要手写View第一步不是写代码而是先理解Android的坐标系。很多初学者在这里就容易犯迷糊——View的坐标和屏幕坐标为什么不一样触摸事件返回的坐标到底是哪个坐标系Android的坐标系统以View的左上角为原点X轴向右增大Y轴向下增大。这里记住一个核心原则View的onDraw方法里Canvas默认的坐标系原点就是View自身的左上角而不是屏幕左上角。所以在绘制的时候你不需要关心View在屏幕上的绝对位置只需要关心“相对自身左上角”的偏移量。触摸事件方面就复杂一点了MotionEvent里提供了好几种坐标获取方式getX()/getY()相对于当前View左上角的坐标这个最常用。getRawX()/getRawY()相对于屏幕左上角的绝对坐标。getXPrecision()/getYPrecision()带精度信息的坐标手写板这类场景才会用。写手写板时使用getX()和getY()就够了因为它们天然就是View坐标系里的点可以直接用来绘制不需要再做额外的换算。这里有个小陷阱如果View外层套了ScrollView或者有滚动效果getRawX()和getX()之间的差值会随着滚动而改变容易算出飘移的轨迹。所以签名板这类跟滚动互斥的组件最好用getX()并处理好事件拦截。1.3 为什么选择自定义View而不是其他方案有朋友可能有疑问签名板的需求用GestureOverlayView或者放在WebView里用Canvas.js写不也能实现吗确实能但我强烈建议你手写一个原生View原因有三。第一性能差距明显。原生View直接走CPU/GPU绘制的硬件加速通道不需要经过WebView的JS Bridge指尖滑动到笔迹显示的延迟能做到极低。稍微对比一下就能感知到WebView里画线的延迟在低端机上能到30~50ms而原生View能做到10ms以内。第二电量与内存更可控。WebView动不动就占几十MB内存而且没有触摸流事件的原生级API要想拿到高频采样就非常吃力。原生View可以通过重写onTouchEvent拿到所有细节还能配合VelocityTracker做手速分析、笔锋模拟这些能力只有原生层才有。第三系统级适配少。WebView会有碎片化适配问题不同厂商内核渲染同一段Canvas代码线条粗细可能都不一样。原生View只要处理了View的尺寸和密度走到哪儿表现都一样。所以说手写签名板这种高频交互、要求稳定一致表现的需求最合适的技术选型就是自定义View没有之一。2. 核心细节解析与实操要点2.1 onMeasure测量机制View的尺寸从哪来自定义View一上来就要跟onMeasure()打交道原因是View必须先知道自己的宽高后续才能正确绘制。很多人不看measure就直接写onDraw结果控件在布局里显示错乱查半天才发现是尺寸算错了。onMeasure()接收到两个参数widthMeasureSpec和heightMeasureSpec它们是由父布局传入的“测量规格”。每个MeasureSpec由两部分组成SpecMode测量模式和SpecSize尺寸大小。SpecMode有三种SpecMode含义典型场景EXACTLY确定尺寸设置了固定宽高或match_parentAT_MOST最大尺寸wrap_content时尺寸不能超过给定值UNSPECIFIED未指定可随意取尺寸如ScrollView内部高度手写板的尺寸逻辑其实很简单如果父布局传进来的是EXACTLY就直接用如果是AT_MOST就取一个默认的合理尺寸比如300dp宽、200dp高。关键在于代码里要保持清晰的处理路径不能漏掉分支。这里还有一个很多人踩过的坑手写板的宽高会影响笔迹坐标。因为onDraw里的Canvas坐标系原点在View左上角如果你的View在高频布局下尺寸变化了比如横竖屏切换之前画好的笔迹很可能就错位了。所以签名板这种控件要么在Manifest里固定方向要么在onSizeChanged()里重画历史笔迹。2.2 Canvas与Paint绘制的基础双人组要画出手写的笔迹Canvas和Paint一个都少不得。Canvas是一个画板你所有的drawXxx操作都在它上面进行Paint是一支画笔颜色、粗细、透明度、字距都由它决定。在手写签名板的场景里Paint的设置有几个关键点Paint paint new Paint(); paint.setAntiAlias(true); // 抗锯齿否则线条边缘毛刺严重 paint.setStyle(Paint.Style.STROKE); // 描边样式区别于FILL填充 paint.setStrokeCap(Paint.Cap.ROUND); // 线条端点用圆角 paint.setStrokeJoin(Paint.Join.ROUND); // 线条连接处用圆角不然折点非常突兀 paint.setStrokeWidth(dp2px(4)); // 笔迹宽度 paint.setColor(Color.BLACK); // 笔迹颜色 paint.setDither(true); // 抖动防带色彩过渡更自然 paint.setFilterBitmap(true); // 如果涉及Bitmap采样开启过滤这几个参数里setAntiAlias和setStrokeCap(ROUND)是我建议新手一定要注释理解的关键点。没有抗锯齿线条边缘会有一圈明显的“台阶”非常影响观感不用ROUND的圆角端点笔画一转折就能看到锐利的切角手写感瞬间没了。Canvas方面的核心绘制指令是drawPath()。Path是Android里描述复杂线条的类你可以往里面不断添加点、贝塞尔曲线。手写板最经典的路径绘制方式是用Path.moveTo()移动到起点再用Path.quadTo()或Path.cubicTo()画贝塞尔曲线连接后续的点而不是简单地把点用直线连起来。原因是直线连接会产生明显的折角贝塞尔曲线则能让轨迹平滑圆润。2.3 触摸事件与坐标转换如果View不重写onTouchEvent()它默认是不会消费任何触摸事件的。手写板的核心逻辑就在这个回调里。Override public boolean onTouchEvent(MotionEvent event) { float x event.getX(); float y event.getY(); switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: // 手指按下 onActionDown(x, y); return true; case MotionEvent.ACTION_MOVE: // 手指移动 onActionMove(x, y); break; case MotionEvent.ACTION_UP: // 手指抬起 onActionUp(x, y); break; case MotionEvent.ACTION_CANCEL:// 事件被系统取消 onActionCancel(); break; } return true; // 关键必须返回true表示事件已被消费 }这里的灵魂就是最后的return true。如果返回false这个View会告诉父布局“我不处理这次事件”后续的MOVE、UP都不会再传进来手写板直接就废了。另外要注意ACTION_CANCEL。它出现在极端情况比如父布局拦截了事件、系统电话打进来抢走了触摸焦点。如果只在ACTION_UP里做收尾处理比如保存路径但没在ACTION_CANCEL里做同样的处理就会导致签名笔迹丢失或状态错乱。很多隐藏Bug就是在这里留下的真正写代码时一定要把这个分支补全。2.4 重绘机制别让invalidate拖垮性能Android的View绘制是“被动”的你想让某个View更新必须调用invalidate()系统才会安排它在下一次VSYNC信号到达时重绘。手写板是高频重绘的典型场景性能优化必须从减少绘制范围入手。View的invalidate(Rect dirty)允许你只重绘脏区域但手写轨迹往往跨越多个区域实际用起来非常繁琐。业界更常见的优化方式是不直接在主画布上绘制而是维护一个“已经画好”的Bitmap缓存每次只用Canvas绘制新增的一段路径就能大幅减少绘制的路径长度。这个思路简单来说就是手指移动时把上一帧到当前帧的路径增量绘制到一个离屏Bitmap上。onDraw()里先drawBitmap显示缓存再叠加绘制当前帧的小段增量。手指抬起后把当前Path提交到“已完成路径列表”中。这样一来即使手写板写满了签名每帧重绘的成本也只是“一张位图一小段路径”而不是把整张签名重新描一遍。2.5 内存管理离屏Bitmap的精打细算离屏Bitmap方案听上去很好但它不是免费的——一张和View同尺寸的Bitmap在内存里占用的空间可能比整条笔迹的Path还要大。在Android上Bitmap在内存中按像素计算ARGB_8888格式下每像素占4字节。假设签名板是1080x1920的屏幕区域光一张离屏Bitmap就占用约8MB内存。所以离屏Bitmap的创建时机和回收时机必须处理好不要在onDraw()里创建Bitmap否则一帧就创建一个内存瞬间爆炸。在onSizeChanged()里根据实际View尺寸创建既可以保证和View大小一致又能避免无效分配。在View销毁onDetachedFromWindow()时回收避免内存泄漏。另外一个容易被忽略的点是离屏Bitmap需要带上硬件加速兼容性。现代Android默认开启硬件加速这种情况下绘制Bitmap也很快但如果你为了兼容老系统关闭了硬件加速CPU软绘的Bitmap重绘代价会高出一个量级这时就需要谨慎评估是否真的要用离屏缓存。3. 实操过程与核心环节实现3.1 项目准备与环境配置先准备好开发环境。我用的是Android Studio Hedgehog版本Gradle插件版本8.2minSdk按需设置为24以上就足够覆盖绝大多数现代设备了。如果你的项目用的是更高的AGP版本本文代码不需要做任何额外配置也能直接跑通。新建一个项目后直接在MainActivity对应的布局文件里放上自定义View即可。如果单独把“手写签名板”抽成一个库模块也只需要保证一个Java/Kotlin文件无外部依赖整个代码可以几十行内搞定。这里多说一句有网友问Android Studio怎么设置中文、怎么汉化这类问题我觉得对开发自定义View没什么影响但你要是看着不习惯在Settings-Plugins里搜“Chinese Language Pack”装上重启就行不影响编译器功能。3.2 定义自定义属性一个好的自定义View会用attrs.xml暴露属性让外部通过XML配置而不是硬编码参数。这样布局文件里就可以这样使用com.example.signature.SignatureView android:idid/signature_view android:layout_widthmatch_parent android:layout_height200dp app:signColor#333333 app:signWidth3dp app:bgColor#FFFFFF /在res/values/attrs.xml里定义这些属性resources declare-styleable nameSignatureView attr namesignColor formatcolor / attr namesignWidth formatdimension / attr namebgColor formatcolor / /declare-styleable /resources然后在View构造方法里通过obtainStyledAttributes解析用完记得回收public SignatureView(Context context, AttributeSet attrs) { super(context, attrs); TypedArray ta context.obtainStyledAttributes(attrs, R.styleable.SignatureView); try { signColor ta.getColor(R.styleable.SignatureView_signColor, Color.BLACK); signWidth ta.getDimension(R.styleable.SignatureView_signWidth, dp2px(3)); bgColor ta.getColor(R.styleable.SignatureView_bgColor, Color.WHITE); } finally { ta.recycle(); } init(); }这里有一个经验性的建议能交给外部配置的参数就不要内部写死。哪怕一个属性暂时只有一个使用场景也要暴露出去。等以后产品经理说要加“红色印章笔迹”“超细荧光笔”你只需要在XML里改参数而不用改View代码这是自定义View的公共接口意识。3.3 核心代码完整实现接着来看核心代码。我用Java写Kotlin语法基本可以一一对应大家移植起来也不费力。路径管理类先设计一个轻量的“笔画”数据结构。每一笔就是一个Path加上它的Paint属性这样以后支持多笔画、不同颜色的需求时不用推翻重写。public class SignatureView extends View { private Paint paint; // 当前使用的画笔 private ListStroke strokes; // 已完成笔画列表 private Path currentPath; // 正在绘制中的笔画 private Bitmap bufferBitmap; // 离屏缓存Bitmap private Canvas bufferCanvas; // 离屏缓存Canvas private int signColor Color.BLACK; private float signWidth dp2px(3); private int bgColor Color.WHITE; private float lastX, lastY; private static class Stroke { Path path; int color; float width; } }初始化与尺寸变化private void init() { paint new Paint(Paint.ANTI_ALIAS_FLAG); paint.setStyle(Paint.Style.STROKE); paint.setStrokeCap(Paint.Cap.ROUND); paint.setStrokeJoin(Paint.Join.ROUND); strokes new ArrayList(); } Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); // 创建离屏缓存尺寸与View一致 if (w 0 h 0) { bufferBitmap Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); bufferCanvas new Canvas(bufferBitmap); // 清空背景 bufferCanvas.drawColor(bgColor); } }onDrawOverride protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 先把缓存画出来 if (bufferBitmap ! null) { canvas.drawBitmap(bufferBitmap, 0, 0, paint); } // 再把正在绘制中的路径画出来 if (currentPath ! null !currentPath.isEmpty()) { canvas.drawPath(currentPath, paint); } }触摸处理Override public boolean onTouchEvent(MotionEvent event) { float x event.getX(); float y event.getY(); switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: // 开新一笔 currentPath new Path(); currentPath.moveTo(x, y); lastX x; lastY y; paint.setColor(signColor); paint.setStrokeWidth(signWidth); return true; case MotionEvent.ACTION_MOVE: if (currentPath ! null) { // 用二次贝塞尔曲线画中间段让轨迹平滑 float midX (lastX x) / 2; float midY (lastY y) / 2; currentPath.quadTo(lastX, lastY, midX, midY); lastX x; lastY y; // 把增量绘制到离屏缓存避免全路径重绘 if (bufferCanvas ! null) { bufferCanvas.drawPath(currentPath, paint); } // 记录当前路径到临时变量后续在onDraw中显示 lastDrawnPath currentPath; invalidate(); } break; case MotionEvent.ACTION_UP: if (currentPath ! null) { Stroke stroke new Stroke(); stroke.path currentPath; stroke.color signColor; stroke.width signWidth; strokes.add(stroke); currentPath null; } break; case MotionEvent.ACTION_CANCEL: // 事件被取消这一笔要丢弃 currentPath null; break; } return true; }清空与保存public void clear() { strokes.clear(); currentPath null; if (bufferCanvas ! null) { bufferCanvas.drawColor(bgColor); } invalidate(); } public Bitmap getSignatureBitmap() { // 从缓存拷贝一份位图返回避免外部直接修改缓存 return bufferBitmap ! null ? bufferBitmap.copy(Bitmap.Config.ARGB_8888, false) : null; } public void saveToFile(String path) throws IOException { // 使用compress同步写入文件 try (FileOutputStream fos new FileOutputStream(path)) { bufferBitmap.compress(Bitmap.CompressFormat.PNG, 100, fos); fos.flush(); } }清理资源Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); if (bufferBitmap ! null !bufferBitmap.isRecycled()) { bufferBitmap.recycle(); bufferBitmap null; bufferCanvas null; } }3.4 在MainActivity里集成写好了自定义View剩下的就是在Activity里把它用起来。比如同时放一个“保存”按钮和一个“清空”按钮public class MainActivity extends AppCompatActivity { private SignatureView signatureView; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); signatureView findViewById(R.id.signature_view); findViewById(R.id.btn_clear).setOnClickListener(v - signatureView.clear()); findViewById(R.id.btn_save).setOnClickListener(v - { // 保存到应用私有目录下的一个文件 File file new File(getExternalFilesDir(null), sign_ System.currentTimeMillis() .png); try { signatureView.saveToFile(file.getAbsolutePath()); Toast.makeText(this, 已保存到: file.getAbsolutePath(), Toast.LENGTH_SHORT).show(); } catch (IOException e) { e.printStackTrace(); Toast.makeText(this, 保存失败, Toast.LENGTH_SHORT).show(); } }); } }我建议保存时优先写入getExternalFilesDir()或getCacheDir()这种应用专属目录不需要动态申请存储权限避免在Android 11的Scoped Storage机制下出现权限弹窗问题。让文件落在这类目录里对应了某些系统相册扫描不到的场景反而省掉了媒体库权限的坑。3.5 扩展功能带笔锋效果的手写如果你的需求升级需要模拟真实签字笔的粗细变化那就不能简单用固定宽度的Paint了。这里的思路是根据触摸事件的速度动态调整笔迹的宽度并用PathMeasure精确计算每个分段的位置。具体做法是在ACTION_MOVE时计算速度速度越快笔画越细速度越慢笔画越粗。绘制的时候用两层Path叠加底层用固定宽度绘制整个轨迹作为基础色带上层再用动态宽度绘制高光细节。这个方案在比较旧的Android设备上也能流畅跑核心开销只在于多了一段PathMeasure计算。不过说实话对于大部分签名场景固定粗细的笔画已经完全够用了。笔锋效果更多是锦上添花适合用在画画类App里签名的核心诉求是清晰可辨识不是艺术效果。所以我建议先把固定画笔版本做扎实再考虑加笔锋。4. 常见问题与排查技巧实录4.1 常见问题排查对照表以下是我基于实际项目经验整理出的问题清单基本上覆盖了手写View开发最常见的坑现象原因解决方案手写无轨迹onTouchEvent返回false导致后续事件中断检查返回值是否为true轨迹断断续续父布局拦截了事件如VerticalScrollView父布局调用requestDisallowInterceptTouchEvent笔画出现锐利折角用直线连接了点没做平滑改用quadTo/cubicTo贝塞尔曲线文字画出来毛刺明显缺少抗锯齿Paint设置ANTI_ALIAS_FLAG旋转屏幕后笔迹错位View尺寸变化后未重绘缓存在onSizeChanged中迁移或重绘保存图片为黑屏未先绘制背景在离屏Canvas上先drawColor背景内存反复抖动每帧都创建Path/Bitmap复用对象改动绘制逻辑横竖屏切换时闪退未处理Bitmap生命周期onDetachedFromWindow中回收Bitmap保存到公共目录失败没有申请存储权限或目标SDK限制使用应用专属目录绕过权限View在ScrollView中笔迹飘移触摸坐标受滚动影响用getX/getY而非getRawX/getRawY4.2 性能优化从“能用”到“流畅”很多刚上手的朋友做出来一个签名板发现在中低端手机上书写时有明显延迟就开始怀疑是不是离屏缓存方案不行。其实问题往往出在“每次移动都重绘全部内容”上。我在3.3中的代码用了增量绘制到离屏Bitmap这样onDraw只需要drawBitmap加一条短路径移动事件的帧开销很小。你还能增强一点事件采样降频系统触摸事件的频率非常高120Hz甚至更高但对签名来说60Hz的采样足够。可以在ACTION_MOVE里做时间判断比如上一帧间隔小于12ms就跳过这次坐标实测对流畅度几乎没有影响。避免layout开销不要在手写板里调用requestLayout()它会导致布局重排代价远高于invalidate()。每次移动只需要重绘不需要重排尺寸。硬件加速确认View在开启硬件加速的环境下运行。如果项目因某种原因强制关闭了硬件加速手写这类高频场景会直接完蛋所以保存签名前必须检查。4.3 屏幕适配与密度处理Android碎片化的问题在手写板上同样存在。不同设备的屏幕密度不一样dp和px的换算处理不好就会出现同一行代码在平板上笔画细成丝、在低端机上粗成刷子的现象。笔迹宽度必须用dp定义、px绘制。在代码里做一个dp2px的转换函数private float dp2px(float dp) { return TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics() ); }或者直接乘上getResources().getDisplayMetrics().density。我在3.2中给sgnWidth用的就是dp转px的结果这样在平板和手机上都能获得视觉一致的笔画粗细。另外提醒一句如果签名板被放在RecyclerView或ViewPager2的item中要注意复用机制带来的状态覆盖问题。最好在Adapter里对每条数据单独持有签名状态不能把strokes直接放成静态变量——不同持笔人签在同一张“纸”上这个Bug一经发现就很尴尬。4.4 踩过的坑和一些经验写手写View的过程中我自己也踩过几个不太容易察觉的坑在这里集中说一说。第一个坑是ACTION_CANCEL处理。早期版本里我只在ACTION_UP中收尾后来在测试时发现在快速滑动过程里偶尔会出现“画着画着这一笔就没了”的现象。查了很久发现是系统在某些情况下会往View发送ACTION_CANCEL而我只在ACTION_UP里做了stroke提交CANCEL分支把currentPath置空后并没有把已画的部分提交结果就白白丢了一笔。最终在每个分支里都做了统一收尾问题才解决。第二个坑是白色背景与透明笔迹的混淆。如果你的View背景是透明的而保存成Bitmap时又叠加了一个白色背景签名区域外会残留透明像素放到某些图片查看器里会变成黑色这多半是保存时没有把背景色先绘制到离屏Bitmap上。解决方案就是我在代码里写的创建离屏Canvas后先drawColor(bgColor)铺一层底后续所有笔迹都画在这层底之上。第三个坑是和ScrollView配合使用时的经典冲突。当你把SignatureView放进ScrollView里你会发现手指一滑动签名字迹就变成一堆离散的点。原因是ScrollView把手势拦截走了只有ACTION_DOWN被传入后续的MOVE全部被父布局消费了。解决办法是在ACTION_DOWN时调用父布局的requestDisallowInterceptTouchEvent(true)明确告诉外层“这次触摸你不要抢”等手写事件结束再允许滚动。不过要慎重如果需求是一边滚动一边签名那就不合适了此时最好把签名板和滚动区域拆成两个页面不要硬焊在一起。5. 实操总结写到这里手写签名板的核心实现已经完整梳理了一遍。从整体设计思路、坐标系理解到onMeasure测量、Canvas绘制、触摸事件处理、重绘优化、内存管理再到完整代码实现、常见问题排查和性能优化这套方案基本覆盖了Android自定义View开发的主干知识。我做自定义View这几年最大的一个体会是不要把自定义View想得过于复杂但也不要轻视它的系统度。它本身只是四个方面的组合拳——测量、布局、绘制、事件处理。真正让你拉开和普通程序员差距的是对细节的处理能力有没有处理ACTION_CANCEL有没有做离屏缓存有没有考虑屏幕密度这些细节累积起来决定了一个View从“能跑”到“好用”的距离。如果你是一个人独立开发我建议把签名板当成一个小项目完整跑通从定义属性、实现绘制、触摸交互、图片导出一条链路都走一遍。这个过程做完你对Android UI渲染管线的理解会有一个质的提升。以后遇到进度环、图表、轮播图这类需求你也不会再打怵了。最后再分享一个小经验自定义View写完以后不要只在模拟器上测一定要借一台真机试。有些渲染问题、触摸采样频率问题只在真机上才能暴露出来。开了这个习惯之后你能少走不少弯路。