iOS 掃描二維碼/條形碼

QiShare發表於2018-12-10

級別:★★☆☆☆
標籤:「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簡介
奇舞週刊

相關文章