使用camera2+zxing打造一个简易的扫码工具

3,253 阅读3分钟

一、背景

最近将项目minSdkVersion升级到21以上后,AS lint开始报warning:

'android.hardware.Camera' is deprecated as of API 21: Android 5.0 (Lollipop)

原因是扫码工具中使用的camera API已经废弃,查看Camera类,可以看到javadoc的注解描述写着建议使用新API的camera2。

/**
 * @deprecated We recommend using the new {@link android.hardware.camera2} API for new
 *             applications.
 */
@Deprecated
public class Camera {
}

camera2相比camera,不是简单地对老接口的@param和@return做了调整,也不是替换了个别调用接口,而是完全换了一套调用方式。

二、框架

对项目中的扫码工具进行了camera到camera2的升级重构,解码部分仍然使用zxing库实现,下面是扫码工具的框架图。 鉴于camera2相比camera巨大的差异,与其说在原项目上进行重构,不如抛弃老代码,重新再写一个,下面将一一对框架各个层级的设计进行阐述。

三、UI层设计

1.扫码框View,根据自己的需要去自定义View。

public final class QRFinderView extends View {
    private Rect frame;
    public QRFinderView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
    public void setFrame(Rect frame) {
        this.frame = frame;
    }
    public void onDraw(Canvas canvas) {
        if (frame == null) {
            return;
        }
        ...
        this.postInvalidateDelayed(14L, frame.left, frame.top, frame.right, frame.bottom);
    }
}

2.为了交互更友好,可以在扫码成功时播放提示音,使用播放器MediaPlayer加载本地voice资源实现。

soundPlayer.setDataSource(context, uri)
soundPlayer.start()

3.闪光灯,与旧版camera通过Camera.setParameters()设置相机参数不同,camera2开启闪光灯使用发起CaptureRequest.FLASH_MODE请求实现。

public void openFlashLight() {
     previewRequestBuilder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH);
     try {
         captureSession.setRepeatingRequest(previewRequestBuilder.build(), sessionCaptureCallback, null);
     } catch (CameraAccessException e) {
         e.printStackTrace();
     }
}

4.预览画面,使用TextureView呈现,

//构建预览请求
previewRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
SurfaceTexture texture = textureView.getSurfaceTexture();
texture.setDefaultBufferSize(mPreviewSize.getWidth(), mPreviewSize.getHeight());
Surface surface = new Surface(texture);
//添加显示预览视图
previewRequestBuilder.addTarget(surface);

预览视图需要选择合适的尺寸mPreviewSize,而不是直接使用TextureView的size,而是从相机支持的所有预览尺寸中选择,不超过view寸尺的最大尺寸,作为实际预览尺寸。

private void setUpCameraOutputs(int viewWidth, int viewHeight) {
   try {
        CameraCharacteristics characteristics = getCameraManager().getCameraCharacteristics(getBackCameraId());
        StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
        //当前相机设备支持的预览尺寸
        Size[] supportedSizes = map.getOutputSizes(SurfaceTexture.class);
        //屏幕方向
        int windowRotation = getActivity().getWindowManager().getDefaultDisplay().getRotation();
        //相机方向
        int cameraOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION);
        //是否需要根据屏幕方向调整相机旋转方向(当屏幕方向和相机方向不一致时需要)
        boolean needSwappedDimensions = false;
        switch (windowRotation) {
           case Surface.ROTATION_0:
           case Surface.ROTATION_180:
                if (cameraOrientation == 90 || cameraOrientation == 270) {
                     needSwappedDimensions = true;
                }
                break;
           case Surface.ROTATION_90:
           case Surface.ROTATION_270:
                if (cameraOrientation == 0 || cameraOrientation == 180) {
                    needSwappedDimensions = true;
                }
                break;
        }
        int rotatedPreviewWidth = viewWidth;
        int rotatedPreviewHeight = viewHeight;
        if (needSwappedDimensions) {
            rotatedPreviewWidth = viewHeight;
            rotatedPreviewHeight = viewWidth;
        }

        //从相机支持的所有预览尺寸中选择,不超过view寸尺的最大尺寸,作为实际预览尺寸
        mPreviewSize = new Size(rotatedPreviewWidth, rotatedPreviewHeight);
        float ration = (float) rotatedPreviewWidth / rotatedPreviewHeight;
        for (Size option : supportedSizes) {
            if ((float) option.getWidth() / option.getHeight() == ration
                      && option.getWidth() <= rotatedPreviewWidth
                      && option.getHeight() <= rotatedPreviewHeight) {
                mPreviewSize = option;
                break;
            }
        }
    } catch (CameraAccessException | NullPointerException e) {
         e.printStackTrace();
    }
}

四、扫码层设计

1.开启相机。与旧版camera直接使用Camera.open()就可以开启不同,开启新版CameraDevice需要先指定cameraId,然后在open时监听相机的状态。

/**
  * 开启相机
  */
private void openCamera() {
    try {
        cameraManager.openCamera(getBackCameraId(), deviceStateCallback, null);
    } catch (CameraAccessException e) {
        e.printStackTrace();
    }
}
/**
  * 获取后置相机id
  */
private String getBackCameraId() {
   CameraManager cameraManager = getCameraManager();
   try {
      String[] ids = cameraManager.getCameraIdList();
      for (String id : ids) {
           CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(id);
           int orientation = characteristics.get(CameraCharacteristics.LENS_FACING);
           if (orientation == CameraCharacteristics.LENS_FACING_BACK) {
                return id;
           }
      }
   } catch (CameraAccessException e) {
         e.printStackTrace();
   } return null;
}
//相机状态
private CameraDevice.StateCallback deviceStateCallback = new CameraDevice.StateCallback() {
    @Override
    public void onOpened(@NonNull CameraDevice camera) {
        cameraDevice = camera;
        startPreview();
    }

    @Override
    public void onDisconnected(@NonNull CameraDevice camera) {
         camera.close();
         cameraDevice = null;
    }

   @Override
   public void onError(@NonNull CameraDevice camera, int error) {
   }
};

2.开启预览。当监听到CameraDevice onPened()后,便可以开始预览了。在旧版API中,是通过Camera.startPreview()开启预览,并通过PreviewCallback接收预览结果。但CameraDevice没有提供startPreview(),而是将显示预览与捕获预览结果分开成两个独立请求过程。显示预览的请求在前面UI层设计已经说明了。而请求获取预览结果需要先通过createCaptureSession()设置预览监听CameraCaptureSession.StateCallback,并在onConfigured()回调中拿到CameraCaptureSession,

private void startPreview() {
   try {
      //构建预览请求           
      previewRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
      SurfaceTexture texture = textureView.getSurfaceTexture();
      texture.setDefaultBufferSize(mPreviewSize.getWidth(), mPreviewSize.getHeight());
      Surface surface = new Surface(texture);
      //添加显示预览视图
      previewRequestBuilder.addTarget(surface);            
      //发送预览请求(请求结果将输出到ImageReader)
      cameraDevice.createCaptureSession(Arrays.asList(surface, imageReader.getSurface()), sessionStateCallback, null);
    } catch (CameraAccessException e) {
       e.printStackTrace();
    }
 }    
//预览状态
private CameraCaptureSession.StateCallback sessionStateCallback = new CameraCaptureSession.StateCallback() {
    @Override
    public void onConfigured(@NonNull CameraCaptureSession session) {
        captureSession = session;
        previewRequestBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);
        previewRequestBuilder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH);
        try {
           //一直请求预览
           session.setRepeatingRequest(previewRequestBuilder.build(), sessionCaptureCallback, null);
        } catch (CameraAccessException e) {
            e.printStackTrace();
        }
   }
}

最后,由CameraCaptureSession发出预览请求。

session.setRepeatingRequest(previewRequestBuilder.build(), sessionCaptureCallback, null);

3.接收预览结果。由于需要展示实时预览画面,得先指定好预览画面的Surface,这个前面UI层设计中已经提到了。关键是接收预览结果,需要事先指定预览接收ImagerReader。

//预览输出到ImageReader中
ImageReader imageReader = ImageReader.newInstance(mPreviewSize.getWidth(), mPreviewSize.getHeight(), ImageFormat.YUV_420_888, /*maxImages*/2);
//监听预览输出
imageReader.setOnImageAvailableListener(imageAvailableListener, null);

并设置好预览捕获结果监听OnImageAvaliableListener,当捕获到预览图片时,便会回调其onImageAvailable()方法,传回Image字节流数据。

//预览图片完成
private ImageReader.OnImageAvailableListener imageAvailableListener = new ImageReader.OnImageAvailableListener() {
    @Override
    public void onImageAvailable(ImageReader reader)
        Image image = reader.acquireLatestImage();
        if (image == null) {
           return;
        }
        ByteBuffer buffer = image.getPlanes()[0].getBuffer();
        int imageWidth = image.getWidth();
        int imageHeight = image.getHeight();
        byte[] data = new byte[buffer.remaining()];
        buffer.get(data);
        image.close();
        decodeImageByZxing(data, imageWidth, imageHeight);
    }
};

五、解码层设计

zxing是google出品的一款非常流行的解码工具,引入时可以去其官网上查看最新版本号。

implementation 'com.google.zxing:core:3.4.1'

zxing解码的过程:将相机获取的图片数据流byte[],转为对应的PlanarYUVLuminanceSource数据,然后解析source获取Result,最后根据Result判断是否扫码成功。

1.构建PlanarYUVLuminanceSource数据。CameraDevice会通过ImagerReader输出一个图片数据流byte[],结合指定的输出预览区域边界,可以构建出zxing解码所需要的PlanarYUVLuminanceSource数据。

private void decodeImageByZxing(byte[] imageData, int imageWidth, int imageHeight) {
    Rect rect = new Rect(mFramingRectInPreview);
    PlanarYUVLuminanceSource planarYUVLuminanceSource = new PlanarYUVLuminanceSource(imageData, imageWidth, imageHeight, rect.left, rect.top, rect.width(), rect.height(), false);
}

2.指定解码数据格式。格式类型从BarcodeFormat枚举中选定,这里要识别二维码,就需要添加BarcodeFormat.QR_CODE格式。

private MultiFormatReader getMultiFormatReader() {
    MultiFormatReader mMultiFormatReader = new MultiFormatReader();
    Collection<BarcodeFormat> decodeFormats = EnumSet.noneOf(BarcodeFormat.class);
    //指定扫码数据类型
    decodeFormats.add(BarcodeFormat.QR_CODE);
    decodeFormats.add(BarcodeFormat.DATA_MATRIX);
    final Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class);
    hints.put(DecodeHintType.POSSIBLE_FORMATS, decodeFormats);
    hints.put(DecodeHintType.CHARACTER_SET, "UTF8");
    hints.put(DecodeHintType.NEED_RESULT_POINT_CALLBACK, new QRFinderResultPointCallback(finderView));
    mMultiFormatReader.setHints(hints);
    return mMultiFormatReader;
}

3.执行解码。先将PlanarYUVLuminanceSource包装成BinaryBitmap,使用指定好数据格式的MultiFormatReader进行decode,解码结果通过Result对象输出。

private Result decode(PlanarYUVLuminanceSource planarYUVLuminanceSource) {
    MultiFormatReader multiFormatReader = getMultiFormatReader();
    Result result = null;
    if (planarYUVLuminanceSource != null) {
        BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(planarYUVLuminanceSource));
        try {
           result = multiFormatReader.decodeWithState(bitmap);
        } catch (ReaderException re) {
            Log.e(TAG, ": ", re);
        } finally {
            multiFormatReader.reset();
        }
    }
    return result;
}

4.解析解码Result。扫码采用一直请求预览的方式,每一次预览捕获到Image数据后,都会传入zxing进行解码,并停止预览等待解码结果。当未扫描到二维码时,解码输出的Result为null,这时就需要继续发出预览请求,直到扫描到有效二维码后,得到的解码Result不为null,成功识别出二维码信息。


private void handleScanResult(Result result) {
   if (result != null) {
       //扫码成功
       if (qrScanResultCallback != null) {
            qrScanResultCallback.onResult(result.getText());
       }
    } else {
         //扫码不成功,重新再来
         try {
            captureSession.setRepeatingRequest(previewRequestBuilder.build(), sessionCaptureCallback, null);
         } catch (CameraAccessException e) {
            e.printStackTrace();
         }
   }
}

六、集成QRScanSdk

查看github源码

在AndroidStudio中集成:

Step 1. Add it in your root build.gradle at the end of repositories:

allprojects {
	repositories {
		...
		maven { url 'https://jitpack.io' }
	}
}

Step 2. Add the dependency

dependencies {
        implementation 'com.github.zouhecan:QRScanSdk:2.1'
}

Step 3. Using QRScanSdk in your activity

class MainActivity : AppCompatActivity(), QRScanResultCallback {
             fun startScan() {
                    QRScanManager.startScan(this, this)
              }

              override fun onResult(result: String?) {
                  //do something when the scan is completed
              }
        }