iOS 扫描二维码/条形码

作者: QiShare | 来源:发表于2018-12-10 08:31 被阅读113次

    级别:★★☆☆☆
    标签:「iOS 原生扫描」「AVCaptureSession」「AVCaptureDevice」「rectOfInterest」
    作者: Xs·H
    审校: QiShare团队


    最近做IoT项目,在智能设备配网过程中有一个扫描设备或说明书上的二维码/条形码来读取设备信息的需求,要达到的效果大体如下:

    扫码效果

    想到几年前在帐号卫士中开发过扫码功能,就扒出来封装了一下(可以从QiQRCode中获取),以方便在项目中复用。
    封装共包括QiCodeManager和QiCodePreviewView两个类。QiCodeManager负责扫描功能(二维码/条形码的识别和读取等),QiCodePreviewView负责扫描界面(扫码框、扫描线、提示语等)。可按照如下方式在项目中使用两个类。

    // 初始化扫码界面
    _previewView = [[QiCodePreviewView alloc] initWithFrame:self.view.bounds];
    _previewView.autoresizingMask = UIViewAutoresizingFlexibleHeight;
    [self.view addSubview:_previewView];
    
    // 初始化扫码管理类
    __weak typeof(self) weakSelf = self;
    _codeManager = [[QiCodeManager alloc] initWithPreviewView:_previewView completion:^{
        // 开始扫描
        [weakSelf.codeManager startScanningWithCallback:^(NSString * _Nonnull code) {} autoStop:YES];
    }];
    

    QiCodePreviewView内部使用CAShapeLayer绘制了遮罩maskLayer、扫描框rectLayer、框角标cornerLayer和扫描线lineLayer。因为此部分涉及代码较多,本文不做详解,可从QiQRCode中查看源码。关于CAShapeLayer的使用,QiShare在iOS 绘制圆角文章中有介绍到。

    接下来重点介绍一下QiCodeManager中扫码功能的实现过程。

    一、识别(捕捉)二维码/条形码

    QiCodeManager是基于iOS 7+,对AVFoundation框架中的AVCaptureSession及相关类进行的封装。AVCaptureSessionAVFoundation框架中捕捉音视频等数据的核心类。要实现扫码功能,除了用到AVCaptureSession之外,还要用到AVCaptureDeviceAVCaptureDeviceInputAVCaptureMetadataOutputAVCaptureVideoPreviewLayer。核心代码如下:

    // input
    AVCaptureDevice *device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
    AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:device error:nil];
    
    // output
    AVCaptureMetadataOutput *output = [[AVCaptureMetadataOutput alloc] init];
    [output setMetadataObjectsDelegate:self queue:dispatch_get_main_queue()];
    
    // session
    _session = [[AVCaptureSession alloc] init];
    _session.sessionPreset = AVCaptureSessionPresetHigh;
    if ([_session canAddInput:input]) {
        [_session addInput:input];
    }
    if ([_session canAddOutput:output]) {
        [_session addOutput:output];
        // output在被add到session后才可设置metadataObjectTypes属性
        output.metadataObjectTypes = @[AVMetadataObjectTypeQRCode, AVMetadataObjectTypeCode128Code, AVMetadataObjectTypeEAN13Code];    
    }
    
    // previewLayer
    AVCaptureVideoPreviewLayer *previewLayer = [AVCaptureVideoPreviewLayer layerWithSession:_session];
    previewLayer.frame = previewView.layer.bounds;
    previewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill;
    [previewView.layer insertSublayer:previewLayer atIndex:0];
    
    // AVCaptureMetadataOutputObjectsDelegate
    - (void)captureOutput:(AVCaptureOutput *)output didOutputMetadataObjects:(NSArray<__kindof AVMetadataObject *> *)metadataObjects fromConnection:(AVCaptureConnection *)connection {
        
        AVMetadataMachineReadableCodeObject *code = metadataObjects.firstObject;
        if (code.stringValue) { }
    }
    

    以“面向人脑”的编程思想对上述代码进行解释:
    1、我们需要使用AVCaptureVideoPreviewLayer的实例previewLayer显示扫描二维码/条形码时看到的影像;
    2、但是previewLayer的初始化需要AVCaptureSession的实例session对数据的输入输出进行控制;
    3、那我们就初始化一个session,并将输出流的质量设置为高质量AVCaptureSessionPresetHigh;
    4、因为session是依靠AVCaptureDeviceInput和AVCaptureMetadataOutput来控制数据输入输出的;
    5、那就用AVCaptureDevice的实例device初始化一个input,指明device为AVMediaTypeVideo类型;
    6、再初始化一个output,设置好delegate和queue以及所支持的元数据类型(二维码和不同格式的条形码);
    7、然后将inputoutput添加到session中就OK了,调用[session startRunning];就可以扫描二维码了;
    8、最终从- captureOutput:didOutputMetadataObjects:fromConnection:方法中得到捕捉到的二维码/条形码数据。

    至此,在previewLayer范围内就可以识别二维码/条形码了。

    二、指定识别二维码/条形码的区域

    如果要控制在previewLayer的指定区域内识别二维码/条形码,可以通过修改output的rectOfInterest属性来达到目的。代码如下:

    // 计算rect坐标
    CGFloat y = rectFrame.origin.y;
    CGFloat x = previewView.bounds.size.width - rectFrame.origin.x - rectFrame.size.width;
    CGFloat h = rectFrame.size.height;
    CGFloat w = rectFrame.size.width;
    CGFloat rectY = y / previewView.bounds.size.height;
    CGFloat rectX = x / previewView.bounds.size.width;
    CGFloat rectH = h / previewView.bounds.size.height;
    CGFloat rectW = w / previewView.bounds.size.width;
    
    // 坐标赋值
    output.rectOfInterest = CGRectMake(rectY, rectX, rectH, rectW);
    

    1、上述的CGRectMake(rectY, rectX, rectH, rectW)与CGRectMake(x, y, w, h)的传统定义不同,可以将rectOfInterest理解成被翻转过的CGRect;
    2、而rectY, rectX, rectH, rectW也不是控件或区域的值,而是所对应的比例,如上述代码中的计算公式,y, x, h, w的值可参考下图;
    3、rectOfInterest的默认值为CGRectMake(.0, .0, 1.0, 1.0),表示识别二维码/条形码的区域为全屏(previewLayer区域)。

    PS: 其实iOS提供了官方API来将标准rect转换成rectOfInterest,但只有在[session startRunning]之后调用才有效果,而且还会时不时地出现卡顿式地闪一下。代码如下:

    // 可以在[session startRunning]之后用此语句设置扫码区域
    metadataOutput.rectOfInterest = [previewLayer metadataOutputRectOfInterestForRect:rectFrame];
    
    rectOfInterest计算辅助图
    三、拉近二维码/条形码(放大视频内容)

    当二维码/条形码离我们较远时,拉近二维码/条形码会是一个不错的功能,效果如下:

    放大扫码效果

    上述效果是使用双指缩放的方式来实现的,具体代码如下:

    // 添加缩放手势
    UIPinchGestureRecognizer *pinchGesture = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(pinch:)];
    [previewView addGestureRecognizer:pinchGesture];
    
    - (void)pinch:(UIPinchGestureRecognizer *)gesture {
        
        AVCaptureDevice *device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
        
        // 设定有效缩放范围,防止超出范围而崩溃
        CGFloat minZoomFactor = 1.0;
        CGFloat maxZoomFactor = device.activeFormat.videoMaxZoomFactor;
        if (@available(iOS 11.0, *)) {
            minZoomFactor = device.minAvailableVideoZoomFactor;
            maxZoomFactor = device.maxAvailableVideoZoomFactor;
        }
        
        static CGFloat lastZoomFactor = 1.0;
        if (gesture.state == UIGestureRecognizerStateBegan) {
            // 记录上次缩放的比例,本次缩放在上次的基础上叠加
            lastZoomFactor = device.videoZoomFactor;// lastZoomFactor为外部变量
        }
        else if (gesture.state == UIGestureRecognizerStateChanged) {
            CGFloat zoomFactor = lastZoomFactor * gesture.scale;
            zoomFactor = fmaxf(fminf(zoomFactor, maxZoomFactor), minZoomFactor);
            [device lockForConfiguration:nil];// 修改device属性之前须lock
            device.videoZoomFactor = zoomFactor;// 修改device的视频缩放比例
            [device unlockForConfiguration];// 修改device属性之后unlock
        }
    }
    

    上述代码的核心逻辑比较简单:
    1、在previewView上添加一个双指捏合的手势 pinchGesture,并设定target和selector
    2、在selector方法中根据gesture.scale调整device.videoZoomFactor;
    3、注意在修改device属性之前要lock一下,修改完后unlock一下。

    四、弱光环境下开启手电筒

    弱光环境对扫码功能有较大的影响,通过监测光线亮度给用户提供打开手电筒的选择会提升不少的体验,如下图:

    弱光监测打开手电筒效果

    弱光监测的代码如下:

    - (void)observeLightStatus:(void (^)(BOOL, BOOL))lightObserver {
        
        _lightObserver = lightObserver;
        
        AVCaptureVideoDataOutput *lightOutput = [[AVCaptureVideoDataOutput alloc] init];
        [lightOutput setSampleBufferDelegate:self queue:dispatch_get_main_queue()];
        
        if ([_session canAddOutput:lightOutput]) {
            [_session addOutput:lightOutput];
        }
    }
    
    // AVCaptureVideoDataOutputSampleBufferDelegate
    - (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
        
        // 通过sampleBuffer获取到光线亮度值brightness
        CFDictionaryRef metadataDicRef = CMCopyDictionaryOfAttachments(NULL, sampleBuffer, kCMAttachmentMode_ShouldPropagate);
        NSDictionary *metadataDic = (__bridge NSDictionary *)metadataDicRef;
        CFRelease(metadataDicRef);
        NSDictionary *exifDic = metadataDic[(__bridge NSString *)kCGImagePropertyExifDictionary];
        CGFloat brightness = [exifDic[(__bridge NSString *)kCGImagePropertyExifBrightnessValue] floatValue];
        
        // 初始化一些变量,作为是否透传brightness的因数
        AVCaptureDevice *device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
        BOOL torchOn = device.torchMode == AVCaptureTorchModeOn;
        BOOL dimmed = brightness < 1.0;
        static BOOL lastDimmed = NO;
        
        // 控制透传逻辑:第一次监测到光线或者光线明暗变化(dimmed变化)时透传
        if (_lightObserver) {
            if (!_lightObserverHasCalled) {
                _lightObserver(dimmed, torchOn);
                _lightObserverHasCalled = YES;
                lastDimmed = dimmed;
            }
            else if (dimmed != lastDimmed) {
                _lightObserver(dimmed, torchOn);
                lastDimmed = dimmed;
            }
        }
    }
    

    弱光监测是依赖AVCaptureVideoDataOutput和AVCaptureVideoDataOutputSampleBufferDelegate来实现的。
    1、初始化AVCaptureVideoDataOutput的实例lightOutput后,设定delegate并将lightOutput添加到session中;
    2、实现AVCaptureVideoDataOutputSampleBufferDelegate的回调方法-captureOutput:didOutputSampleBuffer:fromConnection:
    3、对回调方法中的sampleBuffer进行各种操作(具体参考上述代码细节),并最终获取到光线亮度brightness
    4、根据brightness的值设定弱光的标准以及是否透传给业务逻辑(这里认为brightness < 1.0为弱光)。

    调用- observeLightStatus:方法并实现blck即可接收透传过来的光线状态和手电筒状态,并根据状态对UI做相应的调整,代码如下:

    __weak typeof(self) weakSelf = self;
    [self observeLightStatus:^(BOOL dimmed, BOOL torchOn) {
        if (dimmed || torchOn) {// 变为弱光或者手电筒处于开启状态
            [weakSelf.previewView stopScanning];// 停止扫描动画
            [weakSelf.previewView showTorchSwitch];// 显示手电筒开关
        } else {// 变为亮光并且手电筒处于关闭状态
            [weakSelf.previewView startScanning];// 开始扫描动画
            [weakSelf.previewView hideTorchSwitch];// 隐藏手电筒开关
        }
    }];
    

    当出现手电筒开关时,我们可以通过点击开关改变手电筒的状态。开关手电筒的代码如下:

    + (void)switchTorch:(BOOL)on {
        
        AVCaptureDevice *device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
        AVCaptureTorchMode torchMode = on? AVCaptureTorchModeOn: AVCaptureTorchModeOff;
        
        if (device.hasFlash && device.hasTorch && torchMode != device.torchMode) {
            [device lockForConfiguration:nil];// 修改device属性之前须lock
            [device setTorchMode:torchMode];// 修改device的手电筒状态
            [device unlockForConfiguration];// 修改device属性之后unlock
        }
    }
    

    手电筒开关(按钮)封装在QiCodePreviewView中,QiCodeManager中通过QiCodePreviewViewDelegate的-codeScanningView:didClickedTorchSwitch:方法获取手电筒开关的点击事件,并做相应的逻辑处理。代码如下:

    // QiCodePreviewViewDelegate
    - (void)codeScanningView:(QiCodePreviewView *)scanningView didClickedTorchSwitch:(UIButton *)switchButton {
        
        switchButton.selected = !switchButton.selected;
        
        [QiCodeManager switchTorch:switchButton.selected];
        _lightObserverHasCalled = switchButton.selected;
    }
    

    综上,扫描二维码/条形码的功能就实现完了。此外,QiCodeManager中还封装了生成二维码/条形码的方法,下篇文章介绍。


    示例源码:QiQRCode可从GitHub的QiShare开源库中获取。


    关注我们的途径有:
    QiShare(简书)
    QiShare(掘金)
    QiShare(知乎)
    QiShare(GitHub)
    QiShare(CocoaChina)
    QiShare(StackOverflow)
    QiShare(微信公众号)

    推荐文章:
    iOS 了解Xcode Bitcode
    iOS 重绘之drawRect
    iOS 编写高质量Objective-C代码(八)
    iOS KVC与KVO简介
    奇舞周刊

    相关文章

      网友评论

        本文标题:iOS 扫描二维码/条形码

        本文链接:https://www.haomeiwen.com/subject/dtqgtqtx.html