Android 控件——放大镜实现

本文链接:https://rainmonth.github.io/posts/A210823.html

背景

最近要做一个看图找字功能的游戏化场景,决定采用原生实现,具体就是利用放大镜找到图片中的汉字,识别当前区域和目标区域,在松手后给出判断。要实现这个demo需要解决如下问题:

  1. 得到要进行放大操作的目标位图(Bitmap)注意 Bitmap 的加载优化,避免OOM;
  2. 像素放大的实现方式(目前可采用ShaderDrawablePath
  3. 放大的像素区域和添加的放大镜图片的整合(因为不同的识别结果需要切换成不同的资源图片)
  4. 目标区域的转换,考虑到目标区域在不同的设备上会有表现,所以要注意兼容性处理;
  5. 放大识别判定(如何判定放大后的像素区域是目标区域),并添加识别回调;
  6. 边界优化,放大到边界怎么处理;
  7. UI细节上的处理
  8. 利用合适的设计模式封装使用;

实现方式

基本原理都是将要放大区域的目标像素按一定的比例通过矩阵变化来进行放大处理。

  1. 利用ShapeDrawable来确定放大的区域,然后采用BitmapShader 来实现区域的放大。(BitmapShader,Shader的一种,作用是将Bitmap以纹理的方式绘制出来)
  2. 采用Path,利用clipPath获取要放大的区域,然后在放大的区域中绘制放大后的像素;
  3. 采用系统的Magnifier组件来实现(API 28以上,即Android 9系统以上系统才有系统级支持,API 29以上支持Builder配置);

ShapeDrawable+BitmapShader方式

这种方式主要利用ShapeDrawable来确定位置,利用BitmapShader来处理放大效果,具体实现方式也分一下几个步骤:

  1. 获取源bitmap,即获取要放大处理的bitmap;

  2. 设置放大区域,并根据放大区域来创建相应的ShapeDrawableShapeDrawable支持不同的形状,具体可以参考ShapeDrawable的API);

  3. 根据放大区域,从源bitmap裁剪出对应范围的cutBitmap,然后对cutBitmap进行放大处理得到scaledBitmap(调用Bitmap.createScaledBitmap方法);

  4. 这时候的scaledBitmap就有放大效果了,只需要将scaledBitmap设置到BitmapShader中去即可(BitmapShader支持纹理的绘制方式,具体参见TileMode

  5. 将得到的BitmapShader设置到ShapeDrawablePaint中;

  6. 绘制出ShapeDrawable

clipPath + Matrix方式

这种方式主要采用Path来确定位置,然后利用Matrix来实现放大、平移操作,关于Matrix相关说明,见底部知识储备Matrix相关。

具体实现步骤:

  1. 根据需要的放大配置(如放大倍数factor,放大位置,确定Path)

  2. 通过clipPath裁剪出要放大的区域;

  3. 根据放大倍数,通过Matrix的setScale方法来设置放大系数

  4. 通过Matrix的postTranslate来修正放大中心

    1
    2
    3
    float dx = -curX*(factor-1);
    float dy = -curY*(facgtor-1);
    matrix.postTranslate(dy, dy);
  5. 获取放大后的bitmap

  6. 调用canvas 包含matrix的drawBitmap方法,进行bitmap绘制;

Magnifier方式

根据文档的说法,这个放大效果可以应用到任何View上。这个组件有几个关键的内部类

  • Magnifier.Builder,放大镜效果的配置参数都在这个Builder对象里;

  • InternalPopupWindow,内部实现的一个PopupWindow

  • SurfaceInfo,放大镜效果用到的Surface和与Surface相关的信息;

Magnifier类在被加载的时候就启动了一个 sPixelCopyHandlerThread 的线程,即像素拷贝线程,由此可见Magnifier的实现方式应该和像素拷贝有关,后面会分析到

下面结合Magnifier的使用,来对Magnifier的源码进行分析。

Magnifier的创建

API 28 之前,通过 Magnifier的构造函数来创建:

1
2
3
public Magnifier(@NonNull View view) {
...
}

API 29 之前,可以通过 Magnifier.Builder来构建:

1
2
3
4
5
6
7
Magnifier magnifier = new Magnifier.Builder(targetView)
.setSize(width, height)// 设置放大区域的宽、高
.setInitialZoom(2)//设置初始放大倍数,和 Magnifier的setZoom效果一样,都设置了以后者为准
.setCornerRadius(150)// 设置放大区域的圆角
.setOverlay(overlayDrawable)// 设置覆盖物
.setClippingEnabled(false)// false 时放大区域可以超出屏幕,true 时放大区域不能超出屏幕
.build();

Magnifier的显示

调用Magnifier的show方法可以显示出放大镜,有两个show方法,分别是

  • public void show(float sourceCenterX, float sourceCenterY)

  • public void show(float sourceCenterX, float sourceCenterY, float magnifierCenterX, float magnifierCenterY)

参数的意义分别表示要放大内容的中心x坐标、要放大内容的中心y坐标,放大镜的中心x坐标、放大镜的中心y坐标。

下面分析一下show方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public void show(@FloatRange(from = 0) float sourceCenterX,
@FloatRange(from = 0) float sourceCenterY,
float magnifierCenterX, float magnifierCenterY) {
// 获取surface
obtainSurfaces();
// 获取内容坐标信息
obtainContentCoordinates(sourceCenterX, sourceCenterY);

int startX = mClampedCenterZoomCoords.x - mSourceWidth / 2;
final int startY = mClampedCenterZoomCoords.y - mSourceHeight / 2;
// 鱼眼效果相关配置
if (mIsFishEyeStyle) {
...
}
// 获取Window坐标信息
obtainWindowCoordinates(magnifierCenterX, magnifierCenterY);

if (sourceCenterX != mPrevShowSourceCoords.x || sourceCenterY != mPrevShowSourceCoords.y
|| mDirtyState) {
if (mWindow == null) {
synchronized (mLock) {
mWindow = new InternalPopupWindow(mView.getContext(), mView.getDisplay(),
mParentSurface.mSurfaceControl, mWindowWidth, mWindowHeight, mZoom,
mRamp, mWindowElevation, mWindowCornerRadius,
mOverlay != null ? mOverlay : new ColorDrawable(Color.TRANSPARENT),
Handler.getMain() /* draw the magnifier on the UI thread */, mLock,
mCallback, mIsFishEyeStyle);
}
}
// 进行像素拷贝
performPixelCopy(startX, startY, true /* update window position */);
} else if (magnifierCenterX != mPrevShowWindowCoords.x
|| magnifierCenterY != mPrevShowWindowCoords.y) {
final Point windowCoords = getCurrentClampedWindowCoordinates();
final InternalPopupWindow currentWindowInstance = mWindow;
sPixelCopyHandlerThread.getThreadHandler().post(() -> {
synchronized (mLock) {
if (mWindow != currentWindowInstance) {
// The magnifier was dismissed (and maybe shown again) in the meantime.
return;
}
mWindow.setContentPositionForNextDraw(windowCoords.x, windowCoords.y);
}
});
}
mPrevShowSourceCoords.x = sourceCenterX;
mPrevShowSourceCoords.y = sourceCenterY;
mPrevShowWindowCoords.x = magnifierCenterX;
mPrevShowWindowCoords.y = magnifierCenterY;
}

performPixelCopy像素拷贝成功后,会调用mWindow.updateContent(),这个方法内部会进行时图内容的更新

知识储备

Bitmap获取

获取bitmap的方式这里不赘述,根据不同的业务场景,有不同的获取方式:

  1. BitmapFactory.decodeXXX方法,支持从文件、drawable资源、byte[]、InputStream中获取Bitmap

  2. Fresco图片加载框架获取最终展示的bitmap方式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    new BaseControllerListener<Object>() {
    @Override
    public void onFinalImageSet(String id, @Nullable Object imageInfo, @Nullable Animatable animatable) {
    super.onFinalImageSet(id, imageInfo, animatable);
    if (imageInfo instanceof CloseableStaticBitmap) {
    CloseableStaticBitmap staticBitmap = (CloseableStaticBitmap) imageInfo;
    // 获取到最终展示的bitmap
    Bitmap bitmap = staticBitmap.getUnderlyingBitmap();
    }
    }
    ...
    }
  3. ViewsetDrawingCacheEnabled(boolean enabled)方法

Matrix相关

Matrix相关知识详解 android matrix 最全方法详解

Shader相关

Shader 是一个基类对象,在绘制时会返回一个水平跨越的颜色对象,主要功能是在绘制时通过setShader方法设置着色器的子类对象之后,任何对象(除了位图之外)都可从着色器中得到它的想要的颜色。系统提供的子类有:

  • BitmapShader,位图的图像渲染器,上述就利用了Bitmap Shader来实现了放大镜效果;

  • ComposeShader ,组合渲染器;

  • LinearGradient,线性渲染器;

  • SweepGradient,梯度渲染器(即扫描渲染),可以使用该渲染器实现,如:微信等雷达扫描效果、手机卫士垃圾扫描。

  • RadialGradient,环形渲染器,一般的水波纹效果,充电水波纹扩散效果、调色板都可以使用该渲染器实现。

RenderNode

RecordingCanvas