Skip to content
Go back

如何在贴吧回帖中实现手绘涂鸦表情

最近在贴吧 Android 客户端的回帖入口中增加了一个“涂鸦表情”功能。用户不再只从已有表情中选择一个发送,而是可以打开画板,用手指画出自己的表情,然后把这张涂鸦作为回复内容发到帖子里。

从界面上看,它像是给回复框加了一个绘图入口。但从实现角度看,完整链路远不只是“用 Canvas 画几条线”:

  1. 回帖页面需要进入一套轻量绘图编辑器。
  2. 手指移动过程需要实时、流畅地显示笔迹。
  3. 用户需要能够撤销、重画、切换颜色或笔触。
  4. 绘图结果要转成一张可以上传的图片。
  5. 图片上传成功后,还需要接入原有的回帖发布链路。

这类需求很能体现客户端工作的特点:交互、渲染、状态、资源处理和业务流程需要在同一个功能中连起来。

先拆清楚交互链路

在回帖页面中,涂鸦表情可以作为图片、表情之外的一种内容输入方式。一次完整操作大致如下:

点击涂鸦入口  -> 打开涂鸦编辑页或编辑面板  -> 用户手绘并进行简单编辑  -> 点击完成,生成预览图  -> 上传图片资源  -> 将上传结果加入回复内容  -> 发布回帖

因此,涂鸦模块至少要输出一项稳定结果:一张本地图片文件,或者一份能够交给上传模块的 Bitmap / 文件路径。

绘图编辑器不应该直接承担发帖逻辑。它负责产生素材;回帖模块负责把素材纳入既有的图片上传和回复发布流程。这样即使以后涂鸦入口发生变化,绘制能力本身仍然可以独立复用。

使用自定义 View 承载绘图区域

Android 中实现手写画板,比较直接的方式是创建一个继承自 View 的绘图控件,在 onTouchEvent() 中记录手指轨迹,在 onDraw() 中绘制出来。

最简单的核心结构是 PaintPath

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 的线帽和连接方式以后,手写轨迹的拐角不会显得太生硬。

将触摸事件转成连续笔迹

真正影响手感的是手指移动过程。一次绘制通常由三类事件组成:

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();}

当用户完成一条新的笔迹时,需要清空重做栈,因为此时编辑历史已经走上了一条新的分支。

这个选择的本质是:编辑过程保存可重放的绘制指令,发送时再生成最终图片。它比直接操作像素更适合需要编辑能力的场景。

橡皮擦与清空画布

涂鸦表情一般不需要很复杂的画图能力,但两项基础功能会明显影响可用性:

清空比较简单,直接清除笔迹列表即可。橡皮擦要稍复杂一些。如果绘图背景支持透明通道,可以通过 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));

不过橡皮擦和矢量笔迹历史结合时,会增加撤销与重新绘制的复杂度。对于表情涂鸦这种轻量场景,第一版也可以优先提供撤销和清空,将交互控制在更稳定的范围内。

绘制性能:实时显示与最终导出分开处理

用户画画时需要的是跟手,提交时需要的是清晰可上传的图片。这两个阶段的目标不同。

在编辑过程中,每次手指移动都会触发重绘。如果每次都重新遍历大量历史笔迹,笔画越来越多以后可能影响流畅度。一种处理方式是维护一张离屏缓存图:

  1. 已经完成的历史笔迹绘制在缓存 Bitmap 上。
  2. 当前正在移动的这条笔迹直接绘制在 View 的 Canvas 上。
  3. ACTION_UP 后,再把当前笔迹固化进缓存图。
  4. 如果执行撤销,则根据笔迹数据重新生成缓存图。

这样,普通绘制过程既能保持即时响应,又不会丢掉撤销所需的笔迹模型。

同时,表情并不需要按手机全屏分辨率生成。输出画布尺寸应当有明确上限,例如固定为一个适合回帖展示和上传的正方形尺寸,避免产生过大的 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,也不要长期保留在页面引用中。涂鸦功能看起来轻量,但绘图和图片上传同时存在时,内存管理仍然需要认真处理。

小结

涂鸦表情这个需求,把 Android 客户端中的多个关键能力串在了一起:

用户最终看到的只是回帖框旁边多了一个可以自由表达的小入口,但实现它,需要把交互体验、绘制技术和内容发布链路完整地连接起来。技术实现最终仍要落回到用户能否顺畅地完成一次表达。


Share this post on: