iOS开发中经常要用到生成二维码与扫描二维码的功能,iOS7开始,系统支持原生的扫描二维码,iOS7 扫描二维码可以,但从相册照片读取二维码苹果不支持,必须是iOS8+。在 iOS7 以前,在iOS中实现二维码和条形码扫描,我们所知的有,两大开源组件ZBar与ZXing,这里总结下各自的缺点:
-
ZBar
ZBar在扫描的灵敏度上,和内存的使用上相对于ZXing上都是较优的,但是对于 “圆角二维码” 的扫描确很困难。如:
-
Zing
ZXing 是 Google Code上的一个开源的条形码扫描库,是用java设计的,连Google Glass 都在使用的。但有人为了追求更高效率以及可移植性,出现了c++ port. Github上的Objectivc-C port,其实就是用OC代码封装了一下而已,而且已经停止维护。这样效率非常低,在instrument下面可以看到CPU和内存疯涨,在内存小的机器上很容易崩溃。
-
AVFoundation
AVFoundation无论在扫描灵敏度和性能上来说都是最优的,所以毫无疑问我们应该切换到AVFoundation,需要兼容iOS 6或之前的版本可以用zbar或zxing代替。
扫码原理
扫码是使用系统原生的AVCaptureSession类来发起的,这个类在官方文档中给出的解释是AVFundation框架中Capture类的中枢,起到管理协调的作用,而扫码是一个从摄像头(input)到 解析出字符串(output) 的过程,用AVCaptureSession 来协调。其中是通过 AVCaptureConnection 来连接各个 input 和 output,还可以用它来控制 input 和 output 的 数据流向。
一、如何扫描二维码(相关类介绍)
使用系统提供的AVFoundation框架中提供的扫描API主要会用到以下几个类:
- AVCaptureSession //输入输出的中间桥梁
- AVCaptureDevice //获取摄像设备
- AVCaptureDeviceInput //创建输入流
- AVCaptureMetadataOutput //创建输出流
- AVCaptureVideoPreviewLayer //扫描窗口
接下来我们就研究一下这几个类都有什么用。
1. AVCaptureSession //输入输出的中间桥梁
我的理解:AVCaptureSession是input和output的桥梁,协调input和output的数据传输,负责把捕获的音视频数据输出到输出设备中。一个AVCaptureSession可以有多个输入输出。
官方说明:
An object that manages capture activity and coordinates the flow of data from input devices to capture outputs.
To perform a real-time or offline capture, you instantiate an AVCaptureSession
object and add appropriate inputs (such as [AVCaptureDeviceInput]), and outputs (such as [AVCaptureMovieFileOutput]).
翻译:
一种管理捕获活动并协调来自输入设备的数据流以捕获输出的对象。
要实时或离线采集,你实例化一个avcapturesession对象和添加适当的输入(如avcapturedeviceinput)、输出(如avcapturemoviefileoutput)。
其他开发者解释:
AVCaptureSession是AVFoundation的核心类,用于捕捉视频和音频,协调视频和音频的输入和输出流.下面是上找的围绕AVCaptureSession的图
2. AVCaptureDevice //获取摄像设备
我的理解:AVCaptureDevice代表着输入设备(硬件设备),包括麦克风、摄像头,主要通过该对象设置物理设备的一些属性(例如相机聚焦、白平衡等)。
官方说明:
A device that provides input (such as audio or video) for capture sessions and offers controls for hardware-specific capture features.
An AVCaptureDevice object represents a physical capture device and the properties associated with that device. You use a capture device to configure the properties of the underlying hardware. A capture device also provides input data (such as audio or video) to an [AVCaptureSession]) object.
You use the methods of the AVCaptureDevice class to enumerate the available devices, query their capabilities, and be informed about when devices come and go. Before you attempt to set properties of a capture device (its focus mode, exposure mode, and so on), you must first acquire a lock on the device using the [lockForConfiguration:]) method. You should also query the device’s capabilities to ensure that the new modes you intend to set are valid for that device. You can then set the properties and release the lock using the [unlockForConfiguration]) method. You may hold the lock if you want all settable device properties to remain unchanged. However, holding the device lock unnecessarily may degrade capture quality in other applications sharing the device and is not recommended.
Most common configurations of capture settings are available through the [AVCaptureSession]) object and its available presets. However, on iOS devices, some specialized options (such as high frame rate) require directly setting a capture format on an AVCaptureDevice instance.
翻译:
为捕获会话提供输入(如音频或视频)的设备,为硬件特定的捕获特性提供控制。
一个avcapturedevice对象代表一个物理捕获设备和与设备相关联的特性。您使用捕获设备来配置底层硬件的属性。捕获设备还提供了输入数据(如音频或视频)的avcapturesession对象。
你使用的avcapturedevice类的方法枚举可用的设备,查询他们的能力,并了解当设备来来去去。在你试图设置一个捕获设备性能(其对焦模式,曝光模式,等等),你必须首先获得使用lockforconfiguration装置锁:方法。您还应该查询设备的功能,以确保您打算设置的新模式对该设备有效。然后你可以设置属性和使用unlockforconfiguration方法释放锁。你可以把锁,如果你想要所有的设置设备属性保持不变。然而,不必要地持有设备锁可能会降低共享设备的其他应用程序的捕获质量,并且不推荐使用。
捕捉设置最常见的配置都可以通过avcapturesession对象及其可用的预置。然而,在iOS设备上,一些专门的选项(如高帧率)需要直接设置在一个avcapturedevice实例捕获格式。
其他开发者解释:
AVCaptureDevice 主要用来获取和设置iOS设备一些关于相机设备的属性。如:
- 前置和后置摄像头
- 闪光灯开关
- 手电筒开关--其实就是相机的闪光灯
- 焦距模式调整
- 曝光量调节
- 白平衡
- 距离调整
3. AVCaptureDeviceInput //创建输入流
我的理解:
-
设备输入数据管理对象,可以根据AVCaptureDevice创建对应的AVCaptureDeviceInput对象,该对象将会被添加到AVCaptureSession中管理。
-
AVCaptureDeviceInput代表着输入数据的设备(可以是它的子类),AVCaptureDevice适配AVCaptureDeviceInput,我们需要使用AVCaptureDeviceInput来让设备添加到session中, AVCaptureDeviceInput负责管理设备端口。我们可以理解它为设备的抽象。一个设备可能可以同时提供视频和音频的捕捉。我们可以分别用AVCaptureDeviceInput来代表视频输入和音频输入。
官方说明:
A capture input that provides media from a capture device to a capture session.
AVCaptureDeviceInput is a concrete sub-class of [AVCaptureInput]) you use to capture data from an [AVCaptureDevice]) object.
翻译:
从捕获设备向捕获会话提供媒体的捕获输入。
AVCaptureDeviceInput是AVCaptureInput的子类,是你使用AVCaptureDevice捕获的数据。
4. AVCaptureMetadataOutput //创建输出流
我的理解:
-
为了从session中取得数据,我们需要创建一个AVCaptureOutput,AVCaptureOutput代表输出数据,管理着输出到一个movie或者图像,AVCaptureMetadataOutput是AVCaptureOutput的子类。
-
这里是与捕捉视频流所不一致的地方。我们捕捉视频流需要的是AVCaptureVideoDataOutput,而在这里我们需要捕捉的是二维码信息。因此我们需要AVCaptureMetadataOutput。并且我们需要指定捕捉的metadataObject类型。在这里我们指定的是AVMetadataObjectTypeQRCode,我们还可以指定其他类型,例如PDF417条码类型。。
官方说明:
A capture output for processing timed metadata produced by a capture session.
An AVCaptureMetadataOutput object intercepts metadata objects emitted by its associated capture connection and forwards them to a delegate object for processing. You can use instances of this class to process specific types of metadata included with the input data. You use this class the way you do other output objects, typically by adding it as an output to an [AVCaptureSession]) object.
翻译:
用于处理捕获会话产生的定时元数据的捕获输出。
一个avcapturemetadataoutput对象拦截元数据对象通过其相关的捕获连接发射和转发给处理委托对象。您可以使用这个类的实例处理输入数据所包含的特定类型的元数据。你用这类方式你做其他输出对象,通常通过添加它作为一个输出到avcapturesession对象。
5. AVCaptureVideoPreviewLayer //扫描窗口(镜头捕捉到的预览图层)
二、使用AVFoundation拍照、扫描或视频的一般步骤如下:
1. 创建AVCaptureSession对象。
2. 使用AVCaptureDevice的静态方法获得需要使用的设备,例如拍照和录像就需要获得摄像头设备,录音就要获得麦克风设备。
3. 利用输入设备AVCaptureDevice初始化AVCaptureDeviceInput对象。
4. 初始化输出数据管理对象,如果要拍照就初始化AVCaptureStillImageOutput对象;如果拍摄视频就初始化AVCaptureMovieFileOutput对象。
5. 将数据输入对象AVCaptureDeviceInput、数据输出对象AVCaptureOutput添加到媒体会话管理对象AVCaptureSession中。
6. 创建视频预览图层AVCaptureVideoPreviewLayer并指定媒体会话,添加图层到显示容器中,调用AVCaptureSession的startRuning方法开始捕获。
7. 将捕获的音频或视频数据输出到指定文件。
三、扫描二维码具体代码实现(需要说明本文仅为部分代码,相关代码文末有GitHub下载地址)
Demo实例图如下
1. 导入头文件
#import
#import
因为要使用相册资源中的照片,需要使用PHPhotoLibrary去判断相册的授权状态以及请求授权,所以要导入Photos框架中的
2. 遵守协议并声明变量
@interface ScanViewController () {
AVCaptureSession * session;//输入输出的中间桥梁
AVCaptureDevice * device;//获取摄像设备
AVCaptureDeviceInput * input;//创建输入流
AVCaptureMetadataOutput * output;//创建输出流
AVCaptureVideoPreviewLayer * layer;//扫描窗口
}
@property (nonatomic, weak) UIImageView *activeImage;
@property (nonatomic, strong) UIImagePickerController *photoLibraryVC;
AVCaptureMetadataOutputObjectsDelegate需要使用该协议中的
- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputMetadataObjects:(NSArray *)metadataObjects fromConnection:(AVCaptureConnection *)connection;
来获取扫描结果的数据。
声明的变量之前已经介绍过,这里不做过多描述,需要补充的是AVCaptureDevice对象可以不用在这里声明,因为AVCaptureDeviceInput对应着AVCaptureDevice,所以AVCaptureDevice对象是一个局部变量也可以。
activeImage是扫描框中的上下滑动的条。
photoLibraryVC是我们使用的系统提供给我们的选取照片的照片选择器控制器。
3. viewDidLoad中进行初始化设置(相关代码中有部分注释,不做过多描述)
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"相册" style:UIBarButtonItemStylePlain target:self action:@selector(openPhotos:)];
CGFloat imageX = ScreenWidth*0.15;
CGFloat imageY = ScreenWidth*0.15+64;
// 扫描框中的四个边角的背景图
UIImageView *scanImage = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"saoyisao"]];
scanImage.frame = CGRectMake(imageX, imageY, ScreenWidth*0.7, ScreenWidth*0.7);
[self.view addSubview:scanImage];
// 上下移动的扫描条
UIImageView *activeImage = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"saoyisao-3"]];
activeImage.frame = CGRectMake(imageX, imageY, ScreenWidth*0.7, 4);
[self.view addSubview:activeImage];
self.activeImage = activeImage;
// 扫描框下面的提示按钮
UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(0, CGRectGetMaxY(scanImage.frame), ScreenWidth, 40)];
label.text = @"将二维码放入框内即可自动扫描";
label.font = [UIFont systemFontOfSize:15];
label.textAlignment = NSTextAlignmentCenter;
[self.view addSubview:label];
//添加全屏的黑色半透明蒙版
UIView *maskView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height)];
maskView.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.5];
[self.view addSubview:maskView];
//从蒙版中扣出扫描框那一块,这块的大小尺寸将来也设成扫描输出的作用域大小
UIBezierPath *maskPath = [UIBezierPath bezierPathWithRect:self.view.bounds];
[maskPath appendPath:[[UIBezierPath bezierPathWithRect:CGRectMake(imageX, imageY, ScreenWidth*0.7, ScreenWidth*0.7)] bezierPathByReversingPath]];
CAShapeLayer *maskLayer = [[CAShapeLayer alloc] init];
maskLayer.path = maskPath.CGPath;
maskView.layer.mask = maskLayer;
// 判断相机权限
[self checkCaptureStatus];
// 初始化扫描需要用的相关实例变量
[self initScanningContent];
// 开始动画,扫描条上下移动
[self performSelectorOnMainThread:@selector(timerFired) withObject:nil waitUntilDone:NO];
// 添加监听->APP从后台返回前台,重新扫描
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(sessionStartRunning:) name:UIApplicationDidBecomeActiveNotification object:nil];
}
4. 设置扫描需要使用的相关变量
/**
* 添加扫描控件
*/
- (void)initScanningContent{
//获取摄像设备
device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
//创建输入流
input = [AVCaptureDeviceInput deviceInputWithDevice:device error:nil];
//创建输出流
output = [[AVCaptureMetadataOutput alloc]init];
//设置代理 在主线程里刷新
[output setMetadataObjectsDelegate:self queue:dispatch_get_main_queue()];
//初始化链接对象
session = [[AVCaptureSession alloc]init];
//高质量采集率
[session setSessionPreset:AVCaptureSessionPresetHigh];
[session addInput:input];
[session addOutput:output];
//设置扫码支持的编码格式(如下设置条形码和二维码兼容)
output.metadataObjectTypes=@[AVMetadataObjectTypeQRCode,AVMetadataObjectTypeEAN13Code, AVMetadataObjectTypeEAN8Code, AVMetadataObjectTypeCode128Code];
layer = [AVCaptureVideoPreviewLayer layerWithSession:session];
layer.videoGravity=AVLayerVideoGravityResizeAspectFill;
//设置相机可视范围--全屏
layer.frame = self.view.bounds;
[self.view.layer insertSublayer:layer atIndex:0];
//开始捕获
[session startRunning];
//设置扫描作用域范围(中间透明的扫描框)
CGRect intertRect = [layer metadataOutputRectOfInterestForRect:CGRectMake(ScreenWidth*0.15, ScreenWidth*0.15+64, ScreenWidth*0.7, ScreenWidth*0.7)];
output.rectOfInterest = intertRect;
}
5. app回到前台(接收到通知)
- (void)sessionStartRunning:(NSNotification *)notification {
if (session != nil) {
// AVCaptureSession开始工作
[session startRunning];
//开始动画
[self performSelectorOnMainThread:@selector(timerFired) withObject:nil waitUntilDone:NO];
}
}
6. 扫描条上下移动的动画
/**
* 加载动画
*/
-(void)timerFired {
// [self.activeImage.layer addAnimation:[self moveY:3 Y:[NSNumber numberWithFloat:ScreenWidth*0.7-4]] forKey:nil];
[self.activeImage.layer addAnimation:[self moveY:2.5 Y:[NSNumber numberWithFloat:(ScreenWidth*0.7-4)]] forKey:nil];
}
/**
* 扫描线动画
*
* @param time 单次滑动完成时间
* @param y 滑动距离
*
* @return 返回动画
*/
- (CABasicAnimation *)moveY:(float)time Y:(NSNumber *)y {
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath : @"transform.translation.y" ]; ///.y 的话就向下移动。
animation.toValue = y;
animation.duration = time;
animation.removedOnCompletion = YES ; //yes 的话,又返回原位置了。
animation.repeatCount = MAXFLOAT ;
animation.fillMode = kCAFillModeForwards;
// animation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear]; //匀速变化
return animation;
}
7. 扫描完成调用代理方法,拿到数据进行处理
/**
* 获取扫描到的结果
*
* @param captureOutput 输出
* @param metadataObjects 结果
* @param connection 连接
*/
-(void)captureOutput:(AVCaptureOutput *)captureOutput didOutputMetadataObjects:(NSArray *)metadataObjects fromConnection:(AVCaptureConnection *)connection{
if (metadataObjects.count > 0) {
[session stopRunning];
AVMetadataMachineReadableCodeObject *metadataObject = [metadataObjects objectAtIndex:0];
GCLog(@"stringValue = %@",metadataObject.stringValue);
if ([[metadataObject type] isEqualToString:AVMetadataObjectTypeQRCode]) {
// if ([metadataObject.stringValue containsString:@"http://"] || [metadataObject.stringValue containsString:@"https://"]) {
// NSString *urlResult = metadataObject.stringValue;
// [[UIApplication sharedApplication] openURL:[NSURL URLWithString:urlResult] options:@{UIApplicationOpenURLOptionUniversalLinksOnly:@NO} completionHandler:nil];
// }
[self performSegueWithIdentifier:@"ScanResult" sender:metadataObject.stringValue];
}
}
}
四、从相册获取二维码进行扫描
- iOS8之后,可以使用CIDetector(CIDetector可用于人脸识别)进行图片解析,从而使我们可以便捷的从相册中获取到二维码。
- 1.调用系统相册,从系统相册中选取图片
2.使用探测器(CIDetector)对选取的图片进行处理,取得图片二维码中包含的数据信息。 - 下面是简单的代码实现示例
照片选择器选好照片调用的代理方法
- (void)imagePickerController:(UIImagePickerController*)picker didFinishPickingMediaWithInfo:(NSDictionary *)info {
NSString *type = [info objectForKey:UIImagePickerControllerMediaType];
if ([type isEqualToString:@"public.image"]) {
UIImage *pickImage = [info objectForKey:UIImagePickerControllerEditedImage];
NSData *imageData = UIImagePNGRepresentation(pickImage);
CIImage *ciImage = [CIImage imageWithData:imageData];
//创建探测器
CIDetector *detector = [CIDetector detectorOfType:CIDetectorTypeQRCode context:nil options:@{CIDetectorAccuracy: CIDetectorAccuracyLow}];
NSArray *features = [detector featuresInImage:ciImage];
NSString *content;
//取出探测到的数据
// for (CIQRCodeFeature *result in features) {
// content = result.messageString;
// }
if (features.count > 0) {
CIQRCodeFeature *feature = features[0];
content = feature.messageString;
}
__weak typeof(self) weakSelf = self;
//选中图片后先返回扫描页面,然后跳转到新页面进行展示
[picker dismissViewControllerAnimated:NO completion:^{
if (!content) {
//震动
[weakSelf playBeep];
[self performSegueWithIdentifier:@"ScanResult" sender:content];
}else{
UIAlertController *alertVC = [UIAlertController alertControllerWithTitle:@"扫描提示" message:@"未识别图中的二维码" preferredStyle:UIAlertControllerStyleAlert];
UIAlertAction *actionCancel = [UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleCancel handler:nil];
[alertVC addAction:actionCancel];
[self presentViewController:alertVC animated:YES completion:nil];
}
}];
}
}
五、扫描二维码注意点(部分参考其他开发者)
1. info.plist 文件权限请求 Privacy - Camera Usage Description等
iOS10对用户的隐私又做了进一步加强,就好像当初iOS8对定位隐私进行加强一样,作为开发者的我们貌似也是应该时刻保持这种对新知识警觉性的。
除了相册的权限,iOS10之后如下的权限请求也是需要我们填写请求描述的,在这里也给大家提醒一下:
- NSContactsUsageDescription -> 通讯录
- NSMicrophoneUsageDescription -> 麦克风
- NSPhotoLibraryUsageDescription -> 相册
- NSCameraUsageDescription -> 相机
- NSLocationAlwaysUsageDescription -> 地理位置
- NSLocationWhenInUseUsageDescription -> 地理位置
- Privacy - Bluetooth Peripheral Usage Description -> 蓝牙权限
- Privacy - Speech Recognition Usage Description -> 语音转文字权限
- Privacy - Calendars Usage Description -> 日历权限
- Privacy - Contacts Usage Description -> 通讯录权限
2. 使用AudioServices.h播放音效需要导入AudioToolbox.framework框架
3. 图片很小的二维码
以前测试提了一个bug,说有二维码扫不了,拿到二维码一看,是个很小的二维码,边长不到1cm,于是就修改了 sessionPreset 为 1080p 的,AVCaptureSession 可以设置 sessionPreset 属性,这个决定了视频输入每一帧图像质量的大小。
- AVCaptureSessionPreset320x240
- AVCaptureSessionPreset352x288
- AVCaptureSessionPreset640x480
- AVCaptureSessionPreset960x540
- AVCaptureSessionPreset1280x720
- AVCaptureSessionPreset1920x1080
以上列举了部分的属性值,分别代表输入图片质量大小,一般来说AVCaptureSessionPreset640x480就够使用,但是如果要保证较小的二维码图片能快速扫描,最好设置高些,如AVCaptureSessionPreset1920x1080(就是我们常说的1080p).
4. scanCrop
另一个提升扫描速度和性能的就是设置解析的范围,AVFoundation中设置 AVCaptureMetadataOutput 的 rectOfInterest 属性来配置解析范围。
最开始我按照文档说的按照比例值来设置这个属性,如下:
CGSize size = self.view.bounds.size;
CGRect cropRect = CGRectMake(40, 100, 240, 240);
captureOutput.rectOfInterest = CGRectMake(cropRect.origin.x/size.width,
cropRect.origin.y/size.height,
cropRect.size.width/size.width,
cropRect.size.height/size.height);
但是发现 好像不对啊,扫不到了,明显不正确呢,于是猜想: AVCapture输出的图片大小都是横着的,而iPhone的屏幕是竖着的,那么我把它旋转90°呢:
CGSize size = self.view.bounds.size;
CGRect cropRect = CGRectMake(40, 100, 240, 240);
captureOutput.rectOfInterest = CGRectMake(cropRect.origin.y/size.height,
cropRect.origin.x/size.width,
cropRect.size.height/size.height,
cropRect.size.width/size.width);
OK,貌似对了,在iPhone5上一切工作良好,但是在4s上,或者换了sessionPreset的大小之后,这个框貌似就不那么准确了, 可能发现超出框上下一些也是可以扫描出来的。 再次猜想: 图片的长宽比和手机屏幕不是一样的,这个rectOfInterest是相对于图片大小的比例。比如iPhone4s屏幕大小是 640x960, 而图片输出大小是 1920x1080. 实际的情况可能就是下图中的效果:
上图中下面的代表iPhone4s屏幕,大小640x960, 上面代表AVCaptureVideoPreviewLayer中预览到的图片位置,在图片输入为1920x1080大小时,实际大小上下会被截取一点的,因为我们AVCaptureVideoPreviewLayer设置的videoGravity是AVLayerVideoGravityResizeAspectFill, 类似于UIView的UIViewContentModeScaleAspectFill效果。
于是我对大小做了一下修正:
CGSize size = self.view.bounds.size;
CGRect cropRect = CGRectMake(40, 100, 240, 240);
CGFloat p1 = size.height/size.width;
CGFloat p2 = 1920./1080.; //使用了1080p的图像输出
if (p1 < p2) {
CGFloat fixHeight = bounds.size.width * 1920. / 1080.;
CGFloat fixPadding = (fixHeight - size.height)/2;
captureOutput.rectOfInterest = CGRectMake((cropRect.origin.y + fixPadding)/fixHeight,
cropRect.origin.x/size.width,
cropRect.size.height/fixHeight,
cropRect.size.width/size.width);
} else {
CGFloat fixWidth = bounds.size.height * 1080. / 1920.;
CGFloat fixPadding = (fixWidth - size.width)/2;
captureOutput.rectOfInterest = CGRectMake(cropRect.origin.y/size.height,
(cropRect.origin.x + fixPadding)/fixWidth,
cropRect.size.height/size.height,
cropRect.size.width/fixWidth);
}
经过上面的验证,证实了猜想rectOfInterest是基于图像的大小裁剪的。