級(jí)別:★★☆☆☆
標(biāo)簽:「iOS 原生掃描」「AVCaptureSession」「AVCaptureDevice」「rectOfInterest」
作者: Xs·H
審校: QiShare團(tuán)隊(duì)
最近做IoT項(xiàng)目,在智能設(shè)備配網(wǎng)過程中有一個(gè)掃描設(shè)備或說明書上的二維碼/條形碼來讀取設(shè)備信息的需求,要達(dá)到的效果大體如下:

想到幾年前在帳號(hào)衛(wèi)士中開發(fā)過掃碼功能,就扒出來封裝了一下(可以從QiQRCode中獲?。?,以方便在項(xiàng)目中復(fù)用。
封裝共包括QiCodeManager和QiCodePreviewView兩個(gè)類。QiCodeManager負(fù)責(zé)掃描功能(二維碼/條形碼的識(shí)別和讀取等),QiCodePreviewView負(fù)責(zé)掃描界面(掃碼框、掃描線、提示語等)。可按照如下方式在項(xiàng)目中使用兩個(gè)類。
// 初始化掃碼界面
_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內(nèi)部使用CAShapeLayer繪制了遮罩maskLayer、掃描框rectLayer、框角標(biāo)cornerLayer和掃描線lineLayer。因?yàn)榇瞬糠稚婕按a較多,本文不做詳解,可從QiQRCode中查看源碼。關(guān)于CAShapeLayer的使用,QiShare在iOS 繪制圓角文章中有介紹到。
接下來重點(diǎn)介紹一下QiCodeManager中掃碼功能的實(shí)現(xiàn)過程。
一、識(shí)別(捕捉)二維碼/條形碼
QiCodeManager是基于iOS 7+,對(duì)AVFoundation框架中的AVCaptureSession及相關(guān)類進(jìn)行的封裝。AVCaptureSession是AVFoundation框架中捕捉音視頻等數(shù)據(jù)的核心類。要實(shí)現(xiàn)掃碼功能,除了用到AVCaptureSession之外,還要用到AVCaptureDevice、AVCaptureDeviceInput、AVCaptureMetadataOutput和AVCaptureVideoPreviewLayer。核心代碼如下:
// 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后才可設(shè)置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) { }
}
以“面向人腦”的編程思想對(duì)上述代碼進(jìn)行解釋:
1、我們需要使用AVCaptureVideoPreviewLayer的實(shí)例previewLayer顯示掃描二維碼/條形碼時(shí)看到的影像;
2、但是previewLayer的初始化需要AVCaptureSession的實(shí)例session對(duì)數(shù)據(jù)的輸入輸出進(jìn)行控制;
3、那我們就初始化一個(gè)session,并將輸出流的質(zhì)量設(shè)置為高質(zhì)量AVCaptureSessionPresetHigh;
4、因?yàn)?code>session是依靠AVCaptureDeviceInput和AVCaptureMetadataOutput來控制數(shù)據(jù)輸入輸出的;
5、那就用AVCaptureDevice的實(shí)例device初始化一個(gè)input,指明device為AVMediaTypeVideo類型;
6、再初始化一個(gè)output,設(shè)置好delegate和queue以及所支持的元數(shù)據(jù)類型(二維碼和不同格式的條形碼);
7、然后將input和output添加到session中就OK了,調(diào)用[session startRunning];就可以掃描二維碼了;
8、最終從- captureOutput:didOutputMetadataObjects:fromConnection:方法中得到捕捉到的二維碼/條形碼數(shù)據(jù)。
至此,在previewLayer范圍內(nèi)就可以識(shí)別二維碼/條形碼了。
二、指定識(shí)別二維碼/條形碼的區(qū)域
如果要控制在previewLayer的指定區(qū)域內(nèi)識(shí)別二維碼/條形碼,可以通過修改output的rectOfInterest屬性來達(dá)到目的。代碼如下:
// 計(jì)算rect坐標(biāo)
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;
// 坐標(biāo)賦值
output.rectOfInterest = CGRectMake(rectY, rectX, rectH, rectW);
1、上述的CGRectMake(rectY, rectX, rectH, rectW)與CGRectMake(x, y, w, h)的傳統(tǒng)定義不同,可以將
rectOfInterest理解成被翻轉(zhuǎn)過的CGRect;
2、而rectY,rectX,rectH,rectW也不是控件或區(qū)域的值,而是所對(duì)應(yīng)的比例,如上述代碼中的計(jì)算公式,y, x, h, w的值可參考下圖;
3、rectOfInterest的默認(rèn)值為CGRectMake(.0, .0, 1.0, 1.0),表示識(shí)別二維碼/條形碼的區(qū)域?yàn)槿粒╬reviewLayer區(qū)域)。
PS: 其實(shí)iOS提供了官方API來將標(biāo)準(zhǔn)rect轉(zhuǎn)換成
rectOfInterest,但只有在[session startRunning]之后調(diào)用才有效果,而且還會(huì)時(shí)不時(shí)地出現(xiàn)卡頓式地閃一下。代碼如下:
// 可以在[session startRunning]之后用此語句設(shè)置掃碼區(qū)域
metadataOutput.rectOfInterest = [previewLayer metadataOutputRectOfInterestForRect:rectFrame];

三、拉近二維碼/條形碼(放大視頻內(nèi)容)
當(dāng)二維碼/條形碼離我們較遠(yuǎn)時(shí),拉近二維碼/條形碼會(huì)是一個(gè)不錯(cuò)的功能,效果如下:

上述效果是使用雙指縮放的方式來實(shí)現(xiàn)的,具體代碼如下:
// 添加縮放手勢(shì)
UIPinchGestureRecognizer *pinchGesture = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(pinch:)];
[previewView addGestureRecognizer:pinchGesture];
- (void)pinch:(UIPinchGestureRecognizer *)gesture {
AVCaptureDevice *device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
// 設(shè)定有效縮放范圍,防止超出范圍而崩潰
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) {
// 記錄上次縮放的比例,本次縮放在上次的基礎(chǔ)上疊加
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
}
}
上述代碼的核心邏輯比較簡(jiǎn)單:
1、在previewView上添加一個(gè)雙指捏合的手勢(shì)pinchGesture,并設(shè)定target和selector;
2、在selector方法中根據(jù)gesture.scale調(diào)整device.videoZoomFactor;
3、注意在修改device屬性之前要lock一下,修改完后unlock一下。
四、弱光環(huán)境下開啟手電筒
弱光環(huán)境對(duì)掃碼功能有較大的影響,通過監(jiān)測(cè)光線亮度給用戶提供打開手電筒的選擇會(huì)提升不少的體驗(yàn),如下圖:

弱光監(jiān)測(cè)的代碼如下:
- (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];
// 初始化一些變量,作為是否透?jìng)鱞rightness的因數(shù)
AVCaptureDevice *device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
BOOL torchOn = device.torchMode == AVCaptureTorchModeOn;
BOOL dimmed = brightness < 1.0;
static BOOL lastDimmed = NO;
// 控制透?jìng)鬟壿嫞旱谝淮伪O(jiān)測(cè)到光線或者光線明暗變化(dimmed變化)時(shí)透?jìng)? if (_lightObserver) {
if (!_lightObserverHasCalled) {
_lightObserver(dimmed, torchOn);
_lightObserverHasCalled = YES;
lastDimmed = dimmed;
}
else if (dimmed != lastDimmed) {
_lightObserver(dimmed, torchOn);
lastDimmed = dimmed;
}
}
}
弱光監(jiān)測(cè)是依賴AVCaptureVideoDataOutput和AVCaptureVideoDataOutputSampleBufferDelegate來實(shí)現(xiàn)的。
1、初始化AVCaptureVideoDataOutput的實(shí)例lightOutput后,設(shè)定delegate并將lightOutput添加到session中;
2、實(shí)現(xiàn)AVCaptureVideoDataOutputSampleBufferDelegate的回調(diào)方法-captureOutput:didOutputSampleBuffer:fromConnection:;
3、對(duì)回調(diào)方法中的sampleBuffer進(jìn)行各種操作(具體參考上述代碼細(xì)節(jié)),并最終獲取到光線亮度brightness;
4、根據(jù)brightness的值設(shè)定弱光的標(biāo)準(zhǔn)以及是否透?jìng)鹘o業(yè)務(wù)邏輯(這里認(rèn)為brightness < 1.0為弱光)。
調(diào)用- observeLightStatus:方法并實(shí)現(xiàn)blck即可接收透?jìng)鬟^來的光線狀態(tài)和手電筒狀態(tài),并根據(jù)狀態(tài)對(duì)UI做相應(yīng)的調(diào)整,代碼如下:
__weak typeof(self) weakSelf = self;
[self observeLightStatus:^(BOOL dimmed, BOOL torchOn) {
if (dimmed || torchOn) {// 變?yōu)槿豕饣蛘呤蛛娡蔡幱陂_啟狀態(tài)
[weakSelf.previewView stopScanning];// 停止掃描動(dòng)畫
[weakSelf.previewView showTorchSwitch];// 顯示手電筒開關(guān)
} else {// 變?yōu)榱凉獠⑶沂蛛娡蔡幱陉P(guān)閉狀態(tài)
[weakSelf.previewView startScanning];// 開始掃描動(dòng)畫
[weakSelf.previewView hideTorchSwitch];// 隱藏手電筒開關(guān)
}
}];
當(dāng)出現(xiàn)手電筒開關(guān)時(shí),我們可以通過點(diǎn)擊開關(guān)改變手電筒的狀態(tài)。開關(guān)手電筒的代碼如下:
+ (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的手電筒狀態(tài)
[device unlockForConfiguration];// 修改device屬性之后unlock
}
}
手電筒開關(guān)(按鈕)封裝在QiCodePreviewView中,QiCodeManager中通過QiCodePreviewViewDelegate的-codeScanningView:didClickedTorchSwitch:方法獲取手電筒開關(guān)的點(diǎn)擊事件,并做相應(yīng)的邏輯處理。代碼如下:
// QiCodePreviewViewDelegate
- (void)codeScanningView:(QiCodePreviewView *)scanningView didClickedTorchSwitch:(UIButton *)switchButton {
switchButton.selected = !switchButton.selected;
[QiCodeManager switchTorch:switchButton.selected];
_lightObserverHasCalled = switchButton.selected;
}
綜上,掃描二維碼/條形碼的功能就實(shí)現(xiàn)完了。此外,QiCodeManager中還封裝了生成二維碼/條形碼的方法,下篇文章介紹。
示例源碼:QiQRCode可從GitHub的QiShare開源庫中獲取。
推薦文章:
iOS 了解Xcode Bitcode
iOS 重繪之drawRect
iOS 編寫高質(zhì)量Objective-C代碼(八)
iOS KVC與KVO簡(jiǎn)介