最近在贴吧 Android 客户端的回帖入口中增加了一个“涂鸦表情”功能。用户不再只从已有表情中选择一个发送,而是可以打开画板,用手指画出自己的表情,然后把这张涂鸦作为回复内容发到帖子里。
从界面上看,它像是给回复框加了一个绘图入口。但从实现角度看,完整链路远不只是“用 Canvas 画几条线”:
- 回帖页面需要进入一套轻量绘图编辑器。
- 手指移动过程需要实时、流畅地显示笔迹。
- 用户需要能够撤销、重画、切换颜色或笔触。
- 绘图结果要转成一张可以上传的图片。
- 图片上传成功后,还需要接入原有的回帖发布链路。
这类需求很能体现客户端工作的特点:交互、渲染、状态、资源处理和业务流程需要在同一个功能中连起来。
先拆清楚交互链路
在回帖页面中,涂鸦表情可以作为图片、表情之外的一种内容输入方式。一次完整操作大致如下:
点击涂鸦入口 -> 打开涂鸦编辑页或编辑面板 -> 用户手绘并进行简单编辑 -> 点击完成,生成预览图 -> 上传图片资源 -> 将上传结果加入回复内容 -> 发布回帖
因此,涂鸦模块至少要输出一项稳定结果:一张本地图片文件,或者一份能够交给上传模块的 Bitmap / 文件路径。
绘图编辑器不应该直接承担发帖逻辑。它负责产生素材;回帖模块负责把素材纳入既有的图片上传和回复发布流程。这样即使以后涂鸦入口发生变化,绘制能力本身仍然可以独立复用。
使用自定义 View 承载绘图区域
Android 中实现手写画板,比较直接的方式是创建一个继承自 View 的绘图控件,在 onTouchEvent() 中记录手指轨迹,在 onDraw() 中绘制出来。
最简单的核心结构是 Paint 加 Path:
public class DoodleView extends View { private Paint mPaint; private Path mCurrentPath; private List<Stroke> mStrokes = new ArrayList<>(); public DoodleView(Context context, AttributeSet attrs) { super(context, attrs); initPaint(); } private void initPaint() { mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mPaint.setStyle(Paint.Style.STROKE); mPaint.setStrokeCap(Paint.Cap.ROUND); mPaint.setStrokeJoin(Paint.Join.ROUND); mPaint.setStrokeWidth(dpToPx(4)); mPaint.setColor(Color.BLACK); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); for (Stroke stroke : mStrokes) { stroke.draw(canvas); } if (mCurrentPath != null) { canvas.drawPath(mCurrentPath, mPaint); } }}
这里的 Path 用来表示一条连续笔迹,Paint 描述这条线的颜色、粗细与边缘效果。设置 ROUND 的线帽和连接方式以后,手写轨迹的拐角不会显得太生硬。
将触摸事件转成连续笔迹
真正影响手感的是手指移动过程。一次绘制通常由三类事件组成:
ACTION_DOWN:手指按下,开始一条新笔迹。ACTION_MOVE:手指移动,持续向路径加入点。ACTION_UP:手指抬起,当前笔迹完成。
private float mLastX;private float mLastY;@Overridepublic boolean onTouchEvent(MotionEvent event) { float x = event.getX(); float y = event.getY(); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: mCurrentPath = new Path(); mCurrentPath.moveTo(x, y); mLastX = x; mLastY = y; invalidate(); return true; case MotionEvent.ACTION_MOVE: float middleX = (x + mLastX) / 2f; float middleY = (y + mLastY) / 2f; mCurrentPath.quadTo(mLastX, mLastY, middleX, middleY); mLastX = x; mLastY = y; invalidate(); return true; case MotionEvent.ACTION_UP: mCurrentPath.lineTo(x, y); mStrokes.add(new Stroke(mCurrentPath, new Paint(mPaint))); mCurrentPath = null; invalidate(); return true; } return super.onTouchEvent(event);}
如果每一个移动点都直接使用 lineTo() 连接,轨迹在移动较快时容易出现明显折线。使用 quadTo() 结合相邻点的中点做简单平滑处理,通常就能获得更自然的手写效果。
在实际实现中还可以加一个最小移动距离,手指移动非常小时不立刻追加点,以减少无意义的刷新和轨迹抖动。
笔迹要保存为数据,而不只是像素
画板最初很容易写成这样:用户移动一次,就直接把线画进一个 Bitmap。这样做显示效率不错,但很快会遇到撤销困难的问题,因为一旦像素被覆盖,就很难知道“上一笔”是什么。
更合适的方式是把每一次落笔保存成一个 Stroke:
public class Stroke { private Path mPath; private Paint mPaint; public Stroke(Path path, Paint paint) { mPath = path; mPaint = paint; } public void draw(Canvas canvas) { canvas.drawPath(mPath, mPaint); }}
一条笔迹不仅包含路径,也包含当时的颜色和粗细。这样用户切换笔刷以后,已经画好的内容不会被新的配置影响。
撤销和重做也可以围绕笔迹列表处理:
private List<Stroke> mStrokes = new ArrayList<>();private Stack<Stroke> mRedoStrokes = new Stack<>();public void undo() { if (mStrokes.isEmpty()) { return; } mRedoStrokes.push(mStrokes.remove(mStrokes.size() - 1)); invalidate();}public void redo() { if (mRedoStrokes.isEmpty()) { return; } mStrokes.add(mRedoStrokes.pop()); invalidate();}
当用户完成一条新的笔迹时,需要清空重做栈,因为此时编辑历史已经走上了一条新的分支。
这个选择的本质是:编辑过程保存可重放的绘制指令,发送时再生成最终图片。它比直接操作像素更适合需要编辑能力的场景。
橡皮擦与清空画布
涂鸦表情一般不需要很复杂的画图能力,但两项基础功能会明显影响可用性:
- 清空:删除全部
Stroke,重新绘制空白画布。 - 橡皮擦:擦除手指经过的区域。
清空比较简单,直接清除笔迹列表即可。橡皮擦要稍复杂一些。如果绘图背景支持透明通道,可以通过 PorterDuff.Mode.CLEAR 将目标区域清空:
Paint eraserPaint = new Paint(Paint.ANTI_ALIAS_FLAG);eraserPaint.setStyle(Paint.Style.STROKE);eraserPaint.setStrokeCap(Paint.Cap.ROUND);eraserPaint.setStrokeJoin(Paint.Join.ROUND);eraserPaint.setStrokeWidth(dpToPx(16));eraserPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
不过橡皮擦和矢量笔迹历史结合时,会增加撤销与重新绘制的复杂度。对于表情涂鸦这种轻量场景,第一版也可以优先提供撤销和清空,将交互控制在更稳定的范围内。
绘制性能:实时显示与最终导出分开处理
用户画画时需要的是跟手,提交时需要的是清晰可上传的图片。这两个阶段的目标不同。
在编辑过程中,每次手指移动都会触发重绘。如果每次都重新遍历大量历史笔迹,笔画越来越多以后可能影响流畅度。一种处理方式是维护一张离屏缓存图:
- 已经完成的历史笔迹绘制在缓存
Bitmap上。 - 当前正在移动的这条笔迹直接绘制在 View 的
Canvas上。 ACTION_UP后,再把当前笔迹固化进缓存图。- 如果执行撤销,则根据笔迹数据重新生成缓存图。
这样,普通绘制过程既能保持即时响应,又不会丢掉撤销所需的笔迹模型。
同时,表情并不需要按手机全屏分辨率生成。输出画布尺寸应当有明确上限,例如固定为一个适合回帖展示和上传的正方形尺寸,避免产生过大的 Bitmap 导致内存浪费甚至 OutOfMemoryError。
将涂鸦导出成图片
当用户点击完成时,需要把当前绘制结果导出为图片。可以创建一张与目标尺寸一致的 Bitmap,把画布背景和所有笔迹重新绘制进去:
public Bitmap exportBitmap() { Bitmap bitmap = Bitmap.createBitmap( getWidth(), getHeight(), Bitmap.Config.ARGB_8888 ); Canvas canvas = new Canvas(bitmap); canvas.drawColor(Color.WHITE); for (Stroke stroke : mStrokes) { stroke.draw(canvas); } return bitmap;}
随后将图片保存到缓存目录:
public File saveBitmap(Bitmap bitmap, File cacheDir) throws IOException { File file = new File(cacheDir, "doodle_" + System.currentTimeMillis() + ".png"); FileOutputStream outputStream = new FileOutputStream(file); bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream); outputStream.flush(); outputStream.close(); return file;}
如果希望保留透明背景,可以不填充白色,并使用 PNG 保存透明通道。如果回帖中的涂鸦最终按照普通图片展示,也可以使用固定背景色,让不同页面主题下的显示结果更加确定。
接入回帖发布链路
从业务上看,涂鸦表情最终仍是一种回复附件。因此它比较适合复用原有的图片上传链路:
编辑完成 -> 生成本地涂鸦图片 -> 上传图片 -> 获取服务端返回的图片信息 -> 写入回复内容模型 -> 调用发布回复接口
这里有几个实际需要处理的问题:
- 上传过程中需要显示进度或禁用重复提交。
- 图片上传失败时,不能让用户的涂鸦直接丢失,应允许重试。
- 用户退出编辑器时,如果尚未发送,可以根据产品选择保留草稿或明确提示丢弃。
- 发帖失败时,应保留已经上传完成的附件信息,避免用户重新绘制。
这部分已经超出了绘图控件的范围,却直接决定功能是否真正可用。一个能够画出线条的 Demo 并不难;把它可靠地接到真实的内容发布场景中,才是客户端需求的完整实现。
页面状态与资源回收
涂鸦页还需要注意 Android 页面生命周期带来的问题:
- 页面切到后台后回来,已画内容是否还在。
- 屏幕旋转或系统重建页面时,笔迹是否能够恢复。
- 导出和上传完成后,临时
Bitmap与缓存文件是否及时释放或清理。
笔迹数据本身比整张大图更适合保存编辑状态。如果笔迹数量有限,可以保存必要的路径信息或将草稿临时写入本地文件;在需要发送时,再生成最终图片。
对于不再使用的 Bitmap,也不要长期保留在页面引用中。涂鸦功能看起来轻量,但绘图和图片上传同时存在时,内存管理仍然需要认真处理。
小结
涂鸦表情这个需求,把 Android 客户端中的多个关键能力串在了一起:
- 使用自定义 View 和
Canvas完成触摸绘图。 - 使用笔迹模型支撑撤销、重做和样式切换。
- 在流畅编辑和图片导出之间控制渲染与内存开销。
- 将生成的图片接入上传与回帖发布流程。
- 在页面生命周期变化中维护用户尚未提交的内容。
用户最终看到的只是回帖框旁边多了一个可以自由表达的小入口,但实现它,需要把交互体验、绘制技术和内容发布链路完整地连接起来。技术实现最终仍要落回到用户能否顺畅地完成一次表达。