学习微信编辑功能可以让我们更加扎实Android自定义的基础,学习最好的方法的就是
死命啃源码,当然了,我们弄不到微信的源码,但是我们可以找到别人写好的源码来一个一个学习相关功能,找到一个代码写的很好的一个源码,而且本身也是仿照微信功能的,我们看下源码地址:
minetsh/Imaging: Android Image Edit Lib. Android 图片编辑库,微信图片编辑库 (github.com)
作者针对该源码写了一份博客,有兴趣的同学可以去看下
Android 图片编辑的原理与实现——涂鸦与马赛克 (qq.com)
如果同学们觉得还了解不够多的话,那么我们一起全面解析该源码吧!
我们简称 minetsh/Imaging: Android Image Edit Lib. Android 图片编辑库,微信图片编辑库 (github.com) 为 图片编辑库
我为了更加彻底学习源码,将一个一个功能全部拆解出来一个例子,这样理解会容易很多!
在最下面会放出学习的例子源码。
但是在我们讲解例子前,先讲图片编辑库主要由两个类来构成,一个是自定义类IMGView
,继承于FrameLayout控件,然后在绘制图片的时候,会通过自定义一个类IMGImage
来进行相关绘制。所有关于触摸操作(单点移动、多点缩放、涂鸦、马赛克等)都是在IMGView
的onTouchEvent下解析,针对当前模式解析触摸操作,进行对应的绘制,这就是编辑图片的主要思路了。
那么我们看下图片编辑库有以下几大技术点:
- Matrix矩阵
- 移动图片
- 缩放图片
- 涂鸦图片
- 马赛克图片
- 裁剪图片
Matrix矩阵
所有有关变换图片的操作都会涉及到Matrix,所以我们这边单独针对Matrix矩阵进行详细讲解,我单独抽出一个文章讲解了Matrix
Android Matrix的set\pre\post方法的区别和使用
移动图片
- 通过GestureDetector手势监听类,传递MotionEvent来执行相关onScroll方法
- 通过GestureDetector传递的xy,在当前view的xy基础上叠加
- 最后得到的值,通过当前view的scrollTo方法调用
- 图像的平移不会影响图片的画布位置,当前控件的视图窗口会发生变化,也就是scrollX、scrollY 的值发生变化。
private void initialize(Context context) {
// 手势监听类
mGDetector = new GestureDetector(context, new MoveAdapter());
}
/**
* 处理触屏事件
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
return onTouch(event);
}
/**
* 处理触屏事件.详情
*/
boolean onTouch(MotionEvent event) {
Log.d(TAG, "onTouch");
return onTouchNONE(event);
}
private boolean onTouchNONE(MotionEvent event) {
Log.d(TAG, "onTouchNONE");
return mGDetector.onTouchEvent(event);
}
private boolean onScroll(float dx, float dy) {
return onScrollTo(getScrollX() + Math.round(dx), getScrollY() + Math.round(dy));
}
private boolean onScrollTo(int x, int y) {
if (getScrollX() != x || getScrollY() != y) {
scrollTo(x, y);
return true;
}
return false;
}
private class MoveAdapter extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDown(MotionEvent e) {
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
return ScrollyFrameLayout.this.onScroll(distanceX, distanceY);
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
// TODO
return super.onFling(e1, e2, velocityX, velocityY);
}
}
缩放图片
跟移动类似,通过ScaleGestureDetector类触发onScale,在本身view的基础上添加getFocusX
private void initialize(Context context) {
// 用于处理缩放的工具类
mSGDetector = new ScaleGestureDetector(context, this);
}
@Override
public boolean onScale(ScaleGestureDetector detector) {
if (mPointerCount > 1) {
mImage.onScale(detector.getScaleFactor(),
getScrollX() + detector.getFocusX(),
getScrollY() + detector.getFocusY());
invalidate();
return true;
}
return false;
}
可以看到mImage.onScale()的具体代码如下
public void onScale(float factor, float focusX, float focusY) {
if (factor == 1f) return;
// 针对这个有图片的边框进行比例缩放
M.setScale(factor, factor, focusX, focusY);
M.mapRect(mFrame);
}
M是Matrix矩阵,关于这个Matrix.setScale就有点意思了,具体详解如下:
public boolean postScale(float sx, float sy, float px, float py)
这个api的第一个参数是X轴的缩放大小,第二个参数是Y轴的缩放大小,第三四个参数是缩放中心点。
一般这个缩放中心点比较不好理解。这个中心点并不一定在图片的中心位置。有可能在图片的外面。我们可以这样理解。以这个中心点为坐标原点画X轴跟Y轴。图片可能会跟X轴或者Y轴相交,也可能完全在一个区间内。
画图理解如下:
那么用到这个方法就能实现到 客户根据当前触摸焦点而进行缩放
。
涂鸦图片
涂鸦图片也是在onTouchEvent分发事件下执行相关涂鸦操作
/**
* 处理触屏事件
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
return onTouch(event);
}
/**
* 处理触屏事件.详情
*/
boolean onTouch(MotionEvent event) {
return onTouchPath(event);
}
/**
* 画笔线
*/
private boolean onTouchPath(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
// 钢笔初始化
return onPathBegin(event);
case MotionEvent.ACTION_MOVE:
// 画线
return onPathMove(event);
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
// 画线完成,绘制路径加入到绘制列表
return mPen.isIdentity(event.getPointerId(0)) && onPathDone();
}
return false;
}
关于绘制整体思路是:通过上面代码分发绘制事件,onPathMove执行画线,每次画线都会invalidate view,当画线完成后,绘制路径加入到绘制列表,在onDraw事件里面,都会重新把绘制路径全部渲染出来。
比较有点意思的一个技术点是,在缩放view的情况下,如何精确到绘制路径呢,那么就必须仔细看看IMGImage的addPath
方法了,具体看代码注释和图解
/**
* addPath方法详解:
* M.setTranslate(sx, sy);
* 矩阵平移到跟view的xy轴一样,注意,是getScrollX()和getScrolly()
*
* M.postTranslate(-mFrame.left, -mFrame.top);
* 如果按照getScrollX()直接绘制进手机屏幕上是会出格的,因为view能缩放到比手机屏幕还要大,那么就需要减掉mFrame的x和y,剩下的就是手机绘制的正确的点
*/
public void addPath(IMGPath path, float sx, float sy) {
if (path == null) return;
float scale = 1f / getScale();
M.setTranslate(sx, sy);
M.postTranslate(-mFrame.left, -mFrame.top);
M.postScale(scale, scale);
// 矩阵变换
path.transform(M);
mDoodles.add(path);
}
/**
* 1 * view缩放后的宽度 / 图片固定宽度 = 缩放比例
*/
public float getScale() {
return 1f * mFrame.width() / mImage.getWidth();
}
如果觉得还不够理解,具体还请到源码作者博客看看Android 图片编辑的原理与实现——涂鸦与马赛克 (qq.com)
马赛克
在马赛克这里频繁用到一个很有意思的东西,canvas.save() 和 canvas.restore(),restoreToCount跟他们一样意思,只是restoreToCount细化到某个id。
如果不是很了解他们的意思,可以直接看
Android canvas.save()与canvas.restore()的使用总结_Nothing-CSDN博客
马赛克跟涂鸦一样,都是通过onTouchEvent
分发进行绘画动作,但是跟涂鸦不一样的是,马赛克会先画一个马赛克的图片,按照源码作者的原话是:
其实很简单,马赛克就是将整个区域的颜色变成一个颜色值,如将 10x10 区域内的颜色变成其中的一个颜色值,所以我们将一张图片缩放到一个较小的尺寸,然后再放大到原始尺寸去显示,这个图片就很模糊了,然后关闭 Paint 的滤波功能:paint.setFilterBitmap(false),这样就得到了一个图片的马赛克效果,如下:
绘制马赛克图片如下:
/**
* 创建同样的马赛克图和马赛克画笔
*/
private void makeMosaicBitmap() {
Log.d(TAG, "makeMosaicBitmap");
if (mMosaicImage != null || mImage == null) {
return;
}
// 原图的宽高相除64
int w = Math.round(mImage.getWidth() / 64f);
int h = Math.round(mImage.getHeight() / 64f);
// 取最大值,即不能小于8
w = Math.max(w, 8);
h = Math.max(h, 8);
// 马赛克画刷,注意是SRC_IN,刷子刷后就显示相应的马赛克层了
if (mMosaicPaint == null) {
mMosaicPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mMosaicPaint.setFilterBitmap(false);
mMosaicPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
}
// 创建马赛克图
mMosaicImage = Bitmap.createScaledBitmap(mImage, w, h, false);
}
所以为什么要将一整张图变成马赛克呢?可以用一张图简单表示如下:
将马赛克路径图层与马赛克图层合并显示即可。也就是 Paint 的如下功能:
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN))
关键代码如下:
private void onDrawImages(Canvas canvas) {
// 图片
mImage.onDrawImage(canvas);
// 马赛克
if (!mImage.isMosaicEmpty() || (!mPen.isEmpty())) {
int count = mImage.onDrawMosaicsPath(canvas);
mDoodlePaint.setStrokeWidth(IMGPath.BASE_MOSAIC_WIDTH);
canvas.save();
canvas.translate(getScrollX(), getScrollY());
canvas.drawPath(mPen.getPath(), mDoodlePaint);
canvas.restore();
mImage.onDrawMosaic(canvas, count);
canvas.save();
canvas.restore();
}
}
/**
* 绘制马赛克路径
*/
public int onDrawMosaicsPath(Canvas canvas) {
Log.d(TAG, "onDrawMosaicsPath");
// 所有状态都保存
int layerCount = canvas.saveLayer(mFrame, null, Canvas.ALL_SAVE_FLAG);
if (!isMosaicEmpty()) {
canvas.save();
float scale = getScale();
canvas.translate(mFrame.left, mFrame.top);
canvas.scale(scale, scale);
for (IMGPath path : mMosaics) {
path.onDrawMosaic(canvas, mPaint);
}
canvas.restore();
}
return layerCount;
}
/**
* 绘制马赛克
*/
public void onDrawMosaic(Canvas canvas, int layerCount) {
Log.d(TAG, "onDrawMosaic");
canvas.drawBitmap(mMosaicImage, null, mFrame, mMosaicPaint);
canvas.restoreToCount(layerCount);
}