iOS 掃描二維碼/條形碼

級別:★★☆☆☆
標籤:「iOS 原生掃描」「AVCaptureSession」「AVCaptureDevice」「rectOfInterest」
做者: Xs·H
審校: QiShare團隊php


最近作IoT項目,在智能設備配網過程當中有一個掃描設備或說明書上的二維碼/條形碼來讀取設備信息的需求,要達到的效果大致以下:git

掃碼效果

想到幾年前在賬號衛士中開發過掃碼功能,就扒出來封裝了一下(能夠從QiQRCode中獲取),以方便在項目中複用。
封裝共包括QiCodeManager和QiCodePreviewView兩個類。QiCodeManager負責掃描功能(二維碼/條形碼的識別和讀取等),QiCodePreviewView負責掃描界面(掃碼框、掃描線、提示語等)。可按照以下方式在項目中使用兩個類。github

// 初始化掃碼界面
_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中掃碼功能的實現過程。微信

1、識別(捕捉)二維碼/條形碼

QiCodeManager是基於iOS 7+,對AVFoundation框架中的AVCaptureSession及相關類進行的封裝。AVCaptureSessionAVFoundation框架中捕捉音視頻等數據的核心類。要實現掃碼功能,除了用到AVCaptureSession以外,還要用到AVCaptureDeviceAVCaptureDeviceInputAVCaptureMetadataOutputAVCaptureVideoPreviewLayer。核心代碼以下:session

// 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) { }
}
複製代碼

以「面向人腦」的編程思想對上述代碼進行解釋:
一、咱們須要使用AVCaptureVideoPreviewLayer的實例previewLayer顯示掃描二維碼/條形碼時看到的影像;
二、可是previewLayer的初始化須要AVCaptureSession的實例session對數據的輸入輸出進行控制;
三、那咱們就初始化一個session,並將輸出流的質量設置爲高質量AVCaptureSessionPresetHigh;
四、由於session是依靠AVCaptureDeviceInput和AVCaptureMetadataOutput來控制數據輸入輸出的;
五、那就用AVCaptureDevice的實例device初始化一個input,指明device爲AVMediaTypeVideo類型;
六、再初始化一個output,設置好delegate和queue以及所支持的元數據類型(二維碼和不一樣格式的條形碼);
七、而後將inputoutput添加到session中就OK了,調用[session startRunning];就能夠掃描二維碼了;
八、最終從-captureOutput:didOutputMetadataObjects:fromConnection:方法中獲得捕捉到的二維碼/條形碼數據。框架

至此,在previewLayer範圍內就能夠識別二維碼/條形碼了。ide

2、指定識別二維碼/條形碼的區域

若是要控制在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);
複製代碼

一、上述的CGRectMake(rectY, rectX, rectH, rectW)與CGRectMake(x, y, w, h)的傳統定義不一樣,能夠將rectOfInterest理解成被翻轉過的CGRect;
二、而rectY, rectX, rectH, rectW也不是控件或區域的值,而是所對應的比例,如上述代碼中的計算公式,y, x, h, w的值可參考下圖;
三、rectOfInterest的默認值爲CGRectMake(.0, .0, 1.0, 1.0),表示識別二維碼/條形碼的區域爲全屏(previewLayer區域)。spa

PS: 其實iOS提供了官方API來將標準rect轉換成rectOfInterest,但只有在[session startRunning]以後調用纔有效果,並且還會時不時地出現卡頓式地閃一下。代碼以下:

// 能夠在[session startRunning]以後用此語句設置掃碼區域
metadataOutput.rectOfInterest = [previewLayer metadataOutputRectOfInterestForRect:rectFrame];
複製代碼

rectOfInterest計算輔助圖

3、拉近二維碼/條形碼(放大視頻內容)

當二維碼/條形碼離咱們較遠時,拉近二維碼/條形碼會是一個不錯的功能,效果以下:

放大掃碼效果

上述效果是使用雙指縮放的方式來實現的,具體代碼以下:

// 添加縮放手勢
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
    }
}
複製代碼

上述代碼的核心邏輯比較簡單:
一、在previewView上添加一個雙指捏合的手勢 pinchGesture,並設定target和selector
二、在selector方法中根據gesture.scale調整device.videoZoomFactor;
三、注意在修改device屬性以前要lock一下,修改完後unlock一下。

4、弱光環境下開啓手電筒

弱光環境對掃碼功能有較大的影響,經過監測光線亮度給用戶提供打開手電筒的選擇會提高很多的體驗,以下圖:

弱光監測打開手電筒效果

弱光監測的代碼以下:

- (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來實現的。
一、初始化AVCaptureVideoDataOutput的實例lightOutput後,設定delegate並將lightOutput添加到session中;
二、實現AVCaptureVideoDataOutputSampleBufferDelegate的回調方法-captureOutput:didOutputSampleBuffer:fromConnection:
三、對回調方法中的sampleBuffer進行各類操做(具體參考上述代碼細節),並最終獲取到光線亮度brightness
四、根據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簡介
奇舞週刊

相關文章
相關標籤/搜索