DevelopersIOの自分の著者ページURLをQRコードにして、それを読み取った後にページを表示させたくなったのでQRコード読み取りについて調べてみました。
作ったもの
環境
- Xcode 13.2.1
処理の流れ
ざっくりの説明になりますが、
- カメラでビデオ撮影
- ビデオ撮影したものからフレームをキャプチャし、イメージを切り出す
- 切り出したイメージ内にQRコードがある場合は、そのQRコードからURLを取得する
- URLが取得出来たら、SafariViewControllerで当該ページを表示する
といった流れになっています。
今回はQRコード読み取りにMLKit
を使用するのでインストールします。
MLKitのインストール
バーコードスキャンAPIは元々Firebaseの一部でありましたが、現在はFirebaseの有無に関わらず使用できるMLKit SDKに移行されております。 なので、今回はMLKit SDKを使用していきたいと思います。
こちらを見ると、まだSPMの対応がされてなさそうだったので今回はCocoaPodsを使用します。
下記をPod Fileに追記し、pod install
を行います。
pod 'GoogleMLKit/BarcodeScanning', '2.6.0'
pod install
完了後に.xcworkspace
が出来るのでそちらを開いて作業をしていきます。
Camera Usage Descriptionを設定
カメラを使用する為に、Info.plistにPrivacy - Camera Usage Description
を追加します。
カメラからの出力を取得する
まずはカメラからの出力をキャプチャする為のクラスVideoCapture
を作成します。
import AVFoundation
protocol VideoCaptureDelegate: AnyObject {
func videoCapture(_ videoCapture: VideoCapture, didSet previewLayer: AVCaptureVideoPreviewLayer)
func videoCapture(_ videoCapture: VideoCapture, didOutput sampleBuffer: CMSampleBuffer)
}
class VideoCapture: NSObject {
weak var delegate: VideoCaptureDelegate?
// AVの出入力のキャプチャを管理するセッションオブジェクト
private let captureSession = AVCaptureSession()
// ビデオを記録し、処理を行うためにビデオフレームへのアクセスを提供するoutput
private let videoOutput = AVCaptureVideoDataOutput()
// カメラセットアップとフレームキャプチャを処理する為のDispathQueue
private let sessionQueue = DispatchQueue(label: "video-capture-queue")
// キャプチャの開始
func startCapturing() {
// capture deviceのメディアタイプを決め、
// 何からインプットするかを決める
guard let captureDevice = AVCaptureDevice.default(for: .video),
let deviceInput = try? AVCaptureDeviceInput(device: captureDevice)
else { return }
// captureSessionにdeviceInputを入力値として入れる
captureSession.addInput(deviceInput)
// キャプチャセッションの開始
captureSession.startRunning()
// ビデオフレームの更新ごとに呼ばれるデリゲートをセット
videoOutput.setSampleBufferDelegate(self, queue: sessionQueue)
// captureSessionから出力を取得するためにdataOutputをセット
captureSession.addOutput(videoOutput)
// captureSessionをUIに描画するためにPreviewLayerにsessionを追加
let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
delegate?.videoCapture(self, didSet: previewLayer)
}
// キャプチャの再開
func restartCapturingIfNeeded() {
if !captureSession.isRunning {
captureSession.startRunning()
}
}
// キャプチャの停止
func stopCapturing() {
// キャプチャセッションの停止
captureSession.stopRunning()
}
}
extension VideoCapture: AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection) {
delegate?.videoCapture(self, didOutput: sampleBuffer)
}
}
以下に詳細を説明します。
プロパティ
Delegate
weak var delegate: VideoCaptureDelegate?
AVCaptureSession
// AVの出入力のキャプチャを管理するセッションオブジェクト
private let captureSession = AVCaptureSession()
AVCaptureVideoDataOutput()
// ビデオを記録し、処理を行うためにビデオフレームへのアクセスを提供するoutput
private let videoOutput = AVCaptureVideoDataOutput()
SessionQueue
// カメラセットアップとフレームキャプチャを処理する為のDispathQueue
private let sessionQueue = DispatchQueue(label: "video-capture-queue")
startCapturing()
キャプチャの開始にはstartCapturing()
メソッドを使用します。
func startCapturing() {
// capture deviceのメディアタイプを決め、
// 何からインプットするかを決める
guard let captureDevice = AVCaptureDevice.default(for: .video),
let deviceInput = try? AVCaptureDeviceInput(device: captureDevice)
else { return }
// captureSessionにdeviceInputを入力値として入れる
captureSession.addInput(deviceInput)
// キャプチャセッションの開始
captureSession.startRunning()
// ビデオフレームの更新ごとに呼ばれるデリゲートをセット
videoOutput.setSampleBufferDelegate(self, queue: sessionQueue)
// captureSessionから出力を取得するためにdataOutputをセット
captureSession.addOutput(videoOutput)
// captureSessionをUIに描画するためにPreviewLayerにsessionを追加
let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
delegate?.videoCapture(self, didSet: previewLayer)
}
まず最初に、captureDevice
として、AVCaptureDevice.default(for: .video)
を設定しています。
AVCaptureDevice.default(for:)
では様々なAVMediaType
を指定できますが、今回はvideoからキャプチャしたので.video
にしています。
captrureDevice
からcaptureSession
へ取得データを提供する為に、AVCaptureDeviceInput(device: captureDevice)
を準備しておきます。
addInput(_ input)
準備したdeviceInput
をcaptureSession
に追加します。
startRunning()
インプットデータも決まったので、captureSession.startRunning()
でcaptureSession
の実行をスタートさせます。
setSampleBufferDelegate(_:queue:)
カメラのビデオをキャプチャし、そのフレームが更新される度に画像の分類やViewへの描画を行いたいので、setSampleBufferDelegate(_:queue:)
をセットしてデリゲートメソッドを使えるようにしておきます。
addOutput(_ output)
captureSession.addOutput(_ output)
でcaptureSession
の出力にvideoOutput
を追加します
AVCaptureVideoPreviewLayer
AVCaptureVideoPreviewLayer
は、キャプチャされたビデオを表示するレイヤーで、今回はパラメーターにカメラからのキャプチャSessionとしてcaptureSession
を渡し、プレビューレイヤーをイニシャライズしています。
let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
previewLayer
を定義したら、そのpreviewLayer
をVideoCaptureDelegate
メソッドのvideoCapture(_ videoCapture: VideoCapture, didSet previewLayer: AVCaptureVideoPreviewLayer)
に渡しています。
キャプチャの再開
キャプチャを停止後に、再開する時に使用します。
func restartCapturingIfNeeded() {
if !captureSession.isRunning {
captureSession.startRunning()
}
}
キャプチャの停止
stopRunnning
でcaptureSession
を停止します。
func stopCapturing() {
// キャプチャセッションの停止
captureSession.stopRunning()
}
AVCaptureVideoDataOutputSampleBufferDelegate
ビデオデータ出力からサンプルバッファを受信し、そのステータスを監視できるメソッドで、その中のcaptureOutput(_ output: , didOutput sampleBuffer: , from connection:)
で新しいビデオフレームが書き込まれたことを検知することができます。
extension VideoCapture: AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection) {
delegate?.videoCapture(self, didOutput: sampleBuffer)
}
}
このパラメーターCMSampleBuffer
には、ビデオフレームのデータやフレームに関する情報が含まれています。
そして、sampleBuffer
をVideoCaptureDelegate
メソッドのvideoCapture(_ videoCapture: VideoCapture, didOutput sampleBuffer: CMSampleBuffer)
に渡しています。
カメラ部分は完成したので、次はQRコードリーダー部分を作成します。
QRコードの読み取り
QRコードを読み取り、URLに変換するクラスQRCodeReader
を作成します。
import MLKit
import AVFoundation
protocol QRCodeReaderDelegate: AnyObject {
func qrCodeReader(_ qrCodeReader: QRCodeReader, didScan url: URL)
}
class QRCodeReader: NSObject {
weak var delegate: QRCodeReaderDelegate?
// バーコードスキャナー
private let barcodeScanner: BarcodeScanner = {
let barcodeOptions = BarcodeScannerOptions(formats: .qrCode)
let barcodeScanner = BarcodeScanner.barcodeScanner(options: barcodeOptions)
return barcodeScanner
}()
// サンプルバッファをスキャンする
func scan(sampleBuffer: CMSampleBuffer) {
// サンプルバッファをVisionImageに変換
let image = VisionImage(buffer: sampleBuffer)
// 画像の向きを指定
image.orientation = imageOrientation(
deviceOrientation: UIDevice.current.orientation,
cameraPosition: .back)
// バーコードスキャンの処理を開始
barcodeScanner.process(image) { [weak self] barcodes, error in
guard let self = self,
error == nil,
let barcodes = barcodes,
!barcodes.isEmpty else { return }
var scannedUrl: URL?
// バーコードの中にURL情報がある場合は取得する
for barcode in barcodes {
guard let urlString = barcode.url?.url else { continue }
scannedUrl = URL(string: urlString)
break
}
guard let url = scannedUrl else { return }
// URLが取得出来たのでDelegateメソッドにURLを渡す
self.delegate?.qrCodeReader(self, didScan: url)
}
}
// 画像データの向きを指定する
private func imageOrientation(deviceOrientation: UIDeviceOrientation,
cameraPosition: AVCaptureDevice.Position) -> UIImage.Orientation {
switch deviceOrientation {
case .portrait:
return cameraPosition == .front ? .leftMirrored : .right
case .landscapeLeft:
return cameraPosition == .front ? .downMirrored : .up
case .portraitUpsideDown:
return cameraPosition == .front ? .rightMirrored : .left
case .landscapeRight:
return cameraPosition == .front ? .upMirrored : .down
case .faceDown, .faceUp, .unknown:
return .up
@unknown default:
fatalError()
}
}
}
プロパティ
Delegate
weak var delegate: QRCodeReaderDelegate?
バーコードスキャナー
private let barcodeScanner: BarcodeScanner = {
let barcodeOptions = BarcodeScannerOptions(formats: .qrCode)
let barcodeScanner = BarcodeScanner.barcodeScanner(options: barcodeOptions)
return barcodeScanner
}()
今回はQRコードリーダーとして使用するので、バーコードスキャナーのオプションは、.qrCode
を指定していますが、他にも下記のフォーマットをサポートしています。
- code128
- code39
- code93
- codaBar
- dataMatrix
- EAN13
- EAN8
- ITF
- qrCode
- UPCA
- UPCE
- PDF417
- aztec
scan(sampleBuffer: CMSampleBuffer)
このscan(sampleBuffer:)
でVideoCapture
が出力したsampleBuffer
からバーコード情報を取得します。
スキャン実装部分は、Google公式ドキュメントiOSでMLキットを使用してバーコードをスキャンするに丁寧に書いてありました。
func scan(sampleBuffer: CMSampleBuffer) {
// サンプルバッファをVisionImageに変換
let image = VisionImage(buffer: sampleBuffer)
// 画像の向きを指定
image.orientation = imageOrientation(
deviceOrientation: UIDevice.current.orientation,
cameraPosition: .back)
// バーコードスキャンの処理を開始
barcodeScanner.process(image) { [weak self] barcodes, error in
guard let self = self,
error == nil,
let barcodes = barcodes,
!barcodes.isEmpty else { return }
var scannedUrl: URL?
// バーコードの中からURL情報がある場合は取得する
for barcode in barcodes {
guard let urlString = barcode.url?.url else { continue }
scannedUrl = URL(string: urlString)
break
}
guard let url = scannedUrl else { return }
// URLが取得出来たのでDelegateメソッドにURLを渡す
self.delegate?.qrCodeReader(self, didScan: url)
}
}
まずは、サンプルバッファをVisionImage
に変更して、画像の向きをimageOrientation(deviceOrientation:,cameraPosition:)
で指定しています。
barcodeScanner.process(_ image:, completion:)
barcodeScanner.process(_ image:, completion:)
に画像を渡して、その中にあるバーコードを情報を取得します。
エラーがない、またbarcodes
がnil
でない場合は、バーコードの中からURL
取得を行ってます。今回は、URL
しか取得していないですが、下記の例のようにbarcode.valueType
をみて、タイプによって処理を変更することも可能です。
// 例
let valueType = barcode.valueType
switch valueType {
case .wiFi:
let ssid = barcode.wifi?.ssid
let password = barcode.wifi?.password
let encryptionType = barcode.wifi?.type
case .URL:
let title = barcode.url!.title
let url = barcode.url!.url
case .text
let text = barcode.displayValue
default:
print("process something")
}
バーコードからURL
を取得出来たら、QRCodeReaderDelegate
のqrCodeReader(_ qrCodeReader: QRCodeReader, didScan url: URL)
に渡しています。
VideoCaptureとQRコード読み取りを組み合わせる
作成したクラスを使用して、キャプチャ出力からQRコード内のURLを取得し、その当該ページに遷移させます。
import UIKit
import AVFoundation
import SafariServices
class QRReadableCameraViewController: UIViewController {
@IBOutlet private weak var previewView: UIView!
private let videoCapture = VideoCapture()
private let qrCodeReader = QRCodeReader()
override func viewDidLoad() {
super.viewDidLoad()
qrCodeReader.delegate = self
videoCapture.delegate = self
videoCapture.startCapturing()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
videoCapture.restartCapturingIfNeeded()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
videoCapture.stopCapturing()
}
}
extension QRReadableCameraViewController: VideoCaptureDelegate {
// previewLayerがセットされた時に呼ばれる
func videoCapture(_ videoCapture: VideoCapture, didSet previewLayer: AVCaptureVideoPreviewLayer) {
previewView.layer.addSublayer(previewLayer)
previewLayer.frame = previewView.frame
}
// サンプルバッファーが出力される度に呼ばれる
func videoCapture(_ videoCapture: VideoCapture, didOutput sampleBuffer: CMSampleBuffer) {
qrCodeReader.scan(sampleBuffer: sampleBuffer)
}
}
extension QRReadableCameraViewController: QRCodeReaderDelegate {
// QRからURLがスキャンされた時に呼ばれる
func qrCodeReader(_ qrCodeReader: QRCodeReader, didScan url: URL) {
let safariViewController = SFSafariViewController(url: url)
present(safariViewController, animated: true)
}
}
プロパティ
@IBOutlet private weak var previewView: UIView!
private let videoCapture = VideoCapture()
private let qrCodeReader = QRCodeReader()
カメラの出力を画面に反映する為のpreviewView: UIView
をIBOutlet
で用意しておきます。また、用意した2つのクラスもインスタンス化します。
viewDidLoad
override func viewDidLoad() {
super.viewDidLoad()
qrCodeReader.delegate = self
videoCapture.delegate = self
videoCapture.startCapturing()
}
読み込み時には、各Delegateにself
を渡して、ビデオのキャプチャを開始しています。
viewWillAppear
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
videoCapture.restartCapturingIfNeeded()
}
遷移後の画面から戻ってきた際にキャプチャを再開できるようにrestartCapturingIfNeeded()
を記述しています。
viewWillDisappear
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
videoCapture.stopCapturing()
}
画面が見えなくなる際は、キャプチャを停止しています。
VideoCaptureDelegate
didSet
func videoCapture(_ videoCapture: VideoCapture, didSet previewLayer: AVCaptureVideoPreviewLayer) {
previewView.layer.addSublayer(previewLayer)
previewLayer.frame = previewView.frame
}
previewLayer
がセットされたら、画面反映用のpreviewView
のレイヤーとして追加し、frame
をprevieView.frame
と合わせています。
didOutput
func videoCapture(_ videoCapture: VideoCapture, didOutput sampleBuffer: CMSampleBuffer) {
qrCodeReader.scan(sampleBuffer: sampleBuffer)
}
VideoCapture
から出力としてサンプルバッファを受け取ったら、そのサンプルバッファをスキャンします。
QRCodeReaderDelegate
didScan
func qrCodeReader(_ qrCodeReader: QRCodeReader, didScan url: URL) {
let safariViewController = SFSafariViewController(url: url)
present(safariViewController, animated: true)
}
QRコードのスキャンでURL
が取得したら、今回はそのURL
を使ってSafariViewController
を表示させています。
今回のQRコード
今回は、このURLhttps://dev.classmethod.jp/author/littleossa/
をQRコードとして作成したので、クラスメソッドの私の著者ページが出ると完成です!
おわりに
MLKitを使用すると、本当に簡単にQRコードを読み取るアプリを作ることが出来ました。今回はQRコードでしたが、他にも認識できるものは沢山あるので他のものでも試したくなりました!SwiftUIでも試してみたいですね。
モバイルアプリ開発のチームメンバー絶賛募集中!
モバイル事業部では事業会社様と一緒に、数年間にわたり長期でモバイルアプリをグロースさせています。
そんなモバイルアプリ開発のチームメンバーを絶賛募集中です!
もちろんモバイルアプリ開発以外のエンジニアも募集中です!