AVFoundation trên Swift và ứng dụng để xây dựng tính năng QR Scan

by DuongVH21
188 views

Xin chào tất cả các bạn

Thời gian gần đây, như các bạn có thể thấy cứ bước chân ra khỏi nhà là thấy đâu đâu cũng có những ô mã QR. Từ việc đi đá bát phở cũng quét thanh toán QR, đăng nhập zalo hay telegram cũng có thể quét QR, hay thậm chí trà đá vỉa hè cũng có QR luôn…. Điều đó chứng tỏ mã QR đang dần được ứng dụng rất rộng rãi vào tất cả các lĩnh vực trong cuộc sống, thay thế cho giấy tờ truyền thống cũng như giúp cuộc sống trở nên tiện lợi hơn.

Tuy nhiên nếu chỉ có mỗi mã QR thì cũng không giúp ích được gì khi mà không có những thiết bị đọc và giải mã những chiếc QR vi diệu này. Và những thiết bị để đọc mã QR cũng chẳng đâu xa ngay chính trên chiếc smart phone mà lúc nào cũng theo các bạn 24/7. Việc tích hợp QR Scan vào các ứng dụng di động ngày nay như một tính năng không thể thiếu cũng như giúp ứng dụng trở nên đa nhiệm hơn

Thời gian vừa qua mình cũng có cơ duyên được trải qua một dự án về ngân hàng và phát triển tính năng QRPay, một tính năng mà như các bạn có thể thấy lúc nào cũng xuất hiện trên các ứng dụng Internet Banking cũng như các ví điện tử. Vậy nên ở bài viết này, mình xin chia sẻ các bạn cách tạo một ứng dụng Scan QR đơn giản và những framework liên quan trên iOS bằng ngôn ngữ lập trình Swift. Let’s go !

Phần 1: Tìm hiểu về mã QR

QR là viết tắt của Quick response có thể tạm dịch là mã phản hồi nhanh. Đây là dạng mã vạch có thể đọc được bởi một máy đọc chuyên dụng hoặc bằng smartphone có chức năng chụp ảnh kèm với ứng dụng cho phép quét mã. Mã QR còn có thể được gọi là Mã vạch ma trận (Matrix-barcode) hoặc Mã vạch 2 chiều (2D), là một dạng thông tin đã được mã hóa và có thể hiển thị để máy quét mã có thể đọc được.

Mã QR là một mã vạch ma trận được phát triển bởi công ty Denso Wave vào năm 1994. Denso Wave là công ty con của Toyota. Mã QR gồm những chấm đen và các ô vuông trên nền trắng, nó thể chứa đa dạng các thông tin như URL, thông tin cá nhân, thời gian, địa điểm của một sự kiện nào đó, mô tả, giới thiệu một sản phẩm nào đó,…

Phần 2: AV Foundation Framework trong Swift

Như các bạn có thể thấy, để làm việc với Media trên các thiết bị iOS, Apple đã cung cấp rất nhiều các framework mạnh mẽ. Ở tầng cao nhất (high-level) là UIKit và AVKit, tầng thấp nhất (low-level) là CoreAudio, Core Media, Core Animation và ở giữa chính là nhân vật chính của chúng ta – AVFoundation

UIKit framework giúp dễ dàng kết hợp tính năng chụp ảnh tĩnh và quay video cơ bản vào ứng dụng. Cả Mac OS X và iOS đều có thể sử dụng thẻ HTML5 <audio> và <video> bên trong WebView hoặc UIWebView để phát nội dung âm thanh và video. 

Ngoài ra còn có AVKit framework, giúp đơn giản hóa việc xây dựng các ứng dụng phát video hiện đại. Tất cả các framework này đều thuận tiện và dễ sử dụng và nên thường được dùng khi thêm chức năng phương tiện vào ứng dụng. Tuy nhiên, mặc dù các framework này rất tiện lợi nhưng chúng thường thiếu tính linh hoạt và khả năng kiểm soát cần thiết cho các ứng dụng nâng cao hơn.

Ở tầng thấp nhất, chúng ta có các low-level framework, cung cấp chức năng hỗ trợ và được sử dụng bởi tất cả các framework cấp cao hơn. Hầu hết chúng đều là các framework cấp độ thấp, cực kỳ mạnh mẽ và hiệu quả, nhưng rất phức tạp để tìm hiểu và sử dụng, đồng thời yêu cầu chúng ta phải hiểu rõ về cách phương tiện được xử lý ở cấp độ phần cứng.

Chính vì vậy, vị cứu tính của chúng ta – AV Foundation nằm ở giữa low-level và high-level framework. Mang trong mình sức mạnh cũng như hiệu năng của các low-level framework nhưng lại dễ tiếp cận, dễ đọc hơn cho các developer. Nó có thể làm việc trực tiếp với các framework cấp thấp như Core Media và Core Audio và hoạt động với các high-level framework như Media Player và Assets Library. Như vậy chúng ta có thể đủ thấy sức mạnh của AVF mạnh như nào phải k ạ ^^

AV Foundation là framework đầy đủ tính năng để làm việc với phương tiện nghe nhìn trên iOS, macOS, watchOS và tvOS. Sử dụng AV Foundation, chúng ta có thể dễ dàng phát, tạo và chỉnh sửa phim QuickTime và các tệp MPEG-4, HLS streams và xây dựng chức năng truyền thông mạnh mẽ vào ứng dụng.

Với AV Foundation, chúng ta có thể tạo ra các ứng dụng liên quan đến chụp ảnh và quay video, phát nhạc,… nói chúng tất cả những thứ liên quan đến cụm từ Media trên mobile và ngoài ra nó còn kèm theo rất nhiều thứ hay ho như điều khiển đèn flash phía trước và phía sau, âm thanh cho video…vv.

Như vậy có thể thấy AV Foundation framework khá lớn nên trong bài viết này, mình chỉ tìm hiểu một ứng dụng nhỏ của framework này đó là sử dụng camera trên thiết bị của Apple để đọc mã QR Code. Và chi tiết cách xây dựng ở phần 3 dưới đây.

Phần 3: Xây dựng ứng dụng QR Scan đơn giản bằng AV Foundation framework trên Swift

Đầu tiên chúng ta sẽ tạo một class QRScanCustomView như sau:

import Foundation
import UIKit
import AVFoundation

protocol QRScanCustomViewDelegate: AnyObject {
    func onDecodeFailed()
    func onDecodeSuccess(_ decodeString: String)
    func onCameraAccessDenied()
}

public class QRScanCustomView: UIView {
    weak var delegate: QRScanCustomViewDelegate?
    private var captureSession: AVCaptureSession?
    private var scanAreaWidth: CGFloat = 300.0
    private var scanAreaHeight: CGFloat = 300.0
    private var scanAreaXpos: CGFloat = 0.0
    private var scanAreaYpos: CGFloat = 0.0
    private var scanAreaCornerRadius: CGFloat = 8.0
    private var scanViewBackgroundColor = UIColor.black.withAlphaComponent(0.9)
    private var isAnimationFromTop: Bool = true
    private var animationTimer = Timer()
    private var animationView = UIView()
    private let gradientBackgroundView = UIView()
    private var gradientView = UIView()
    private let animationContainerView = UIView()
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        commonInit()
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    
    public override var layer: AVCaptureVideoPreviewLayer {
        if let layer = super.layer as? AVCaptureVideoPreviewLayer {
            return layer
        }
        return AVCaptureVideoPreviewLayer()
    }
}

extension QRScanCustomView {
    func startScanning() {
        captureSession?.startRunning()
    }
    
    func stopScanning() {
        captureSession?.stopRunning()
    }
    
    func startAnimation() {
        runAnimation()
        animationTimer = Timer.scheduledTimer(timeInterval: 2.0, target: self, selector: #selector(runAnimation), userInfo: nil, repeats: true)
    }
    
    func stopAnimation() {
        animationTimer.invalidate()
    }
    
    func setSizeOfScanArea(width: CGFloat, height: CGFloat) {
        scanAreaWidth = width
        scanAreaHeight = height
    }
    
    private func commonInit() {
        self.clipsToBounds = true
        captureSession = AVCaptureSession()
        
        guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
            return
        }
        let videoInput: AVCaptureDeviceInput
        do {
            videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
        } catch {
            delegate?.onCameraAccessDenied()
            return
        }
        
        if captureSession?.canAddInput(videoInput) ?? false {
            captureSession?.addInput(videoInput)
        } else {
            scanningDidFail()
            return
        }
        
        let metadataOutput = AVCaptureMetadataOutput()
        
        if captureSession?.canAddOutput(metadataOutput) ?? false {
            captureSession?.addOutput(metadataOutput)
            
            metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
            metadataOutput.metadataObjectTypes = [.qr]
        } else {
            scanningDidFail()
            return
        }
        
        layer.session = captureSession
        layer.videoGravity = .resizeAspectFill
        layer.frame = UIScreen.main.bounds
        
        captureSession?.startRunning()
        
        // MARK: SET AVAILABLE RANGE TO SCAN
        let rectView = layer.metadataOutputRectConverted(fromLayerRect: rectForScannerRange())
        metadataOutput.rectOfInterest = rectView
    }
    
    // MARK: Get rect of scan range
    private func rectForScannerRange() -> CGRect {
        scanAreaXpos = (UIScreen.main.bounds.size.width - scanAreaWidth) / 2
        scanAreaYpos = (UIScreen.main.bounds.size.height - scanAreaHeight) / 2
        let newRect = CGRect(x: scanAreaXpos, y: scanAreaYpos, width: scanAreaWidth, height: scanAreaHeight)
        return newRect
    }
    // MARK: Add view with clear scanner range
    func addQRScannerOverlayView(view: UIView) {
        view.addSubview(overlayView())
        view.layer.addSublayer(createCornerFrame(with: rectForScannerRange()))
        // Set authorize here to run it after qrScannerDelegate was declared
        setupScanAnimationView(view: view)
    }
    // MARK: Create scan range
    private func overlayView() -> UIView {
        let overlayView = UIView(frame: frame)
        overlayView.backgroundColor = scanViewBackgroundColor
        let path = CGMutablePath()
        path.addRoundedRect(in: rectForScannerRange(), cornerWidth: scanAreaCornerRadius, cornerHeight: scanAreaCornerRadius)
        path.closeSubpath()
        path.addRect(CGRect(origin: .zero, size: overlayView.frame.size))
        
        let maskLayer = CAShapeLayer()
        maskLayer.backgroundColor = UIColor.black.cgColor
        maskLayer.path = path
        maskLayer.fillRule = CAShapeLayerFillRule.evenOdd
        overlayView.layer.mask = maskLayer
        overlayView.clipsToBounds = true
        
        return overlayView
    }
    // MARK: Draw focus corner frame
    private func createCornerFrame(with rect: CGRect) -> CAShapeLayer {
        let length: CGFloat = 24.0
        let radius: CGFloat = 8.0
        let offset: CGFloat = 8.0
        let lineWidth: CGFloat = 4.0
        let distance: CGFloat = rect.size.width - offset * 2 - length * 2 - radius * 2
        
        let path = CGMutablePath()
        
        var start = CGPoint(x: rect.origin.x + offset, y: rect.origin.y + offset + radius + length)
        var end = CGPoint(x: start.x + radius + length, y: start.y - length - radius)
        path.move(to: start)
        path.addLine(to: CGPoint(x: start.x, y: start.y - length))
        path.addArc(tangent1End: CGPoint(x: start.x, y: start.y - length - radius),
                    tangent2End: CGPoint(x: start.x + radius, y: start.y - length - radius),
                    radius: radius)
        path.addLine(to: end)
        
        start = CGPoint(x: end.x + distance, y: end.y)
        end = CGPoint(x: start.x + length + radius, y: start.y + radius + length)
        path.move(to: start)
        path.addLine(to: CGPoint(x: start.x + length, y: start.y))
        path.addArc(tangent1End: CGPoint(x: start.x + length + radius, y: start.y),
                    tangent2End: CGPoint(x: start.x + length + radius, y: start.y + radius),
                    radius: radius)
        path.addLine(to: end)
        
        start = CGPoint(x: end.x, y: end.y + distance)
        end = CGPoint(x: start.x - radius - length, y: start.y + length + radius)
        path.move(to: start)
        path.addLine(to: CGPoint(x: start.x, y: start.y + length))
        path.addArc(tangent1End: CGPoint(x: start.x, y: start.y + length + radius),
                    tangent2End: CGPoint(x: start.x - radius, y: start.y + length + radius),
                    radius: radius)
        path.addLine(to: end)
        
        start = CGPoint(x: end.x - distance, y: end.y)
        end = CGPoint(x: start.x - length - radius, y: start.y - radius - length)
        path.move(to: start)
        path.addLine(to: CGPoint(x: start.x - length, y: start.y))
        path.addArc(tangent1End: CGPoint(x: start.x - length - radius, y: start.y),
                    tangent2End: CGPoint(x: start.x - length - radius, y: start.y - radius),
                    radius: radius)
        path.addLine(to: end)
        
        let shape = CAShapeLayer()
        shape.path = path
        shape.strokeColor = UIColor.orange.cgColor
        shape.lineWidth = lineWidth
        shape.fillColor = UIColor.clear.cgColor
        shape.lineCap = .round
        return shape
    }
    // MARK: Create scan animation view, size of inputView = size of QRScanView
    private func setupScanAnimationView(view: UIView) {
        animationContainerView.backgroundColor = .clear
        animationContainerView.frame = rectForScannerRange()
        view.addSubview(animationContainerView)
        
        animationView.frame = CGRect(x: animationContainerView.bounds.minX - 8,
                                     y: animationContainerView.bounds.minY,
                                     width: animationContainerView.bounds.width + 16,
                                     height: 3.0)
        animationView.layer.cornerRadius = animationView.frame.height / 2
        animationView.backgroundColor = UIColor.orange
        animationContainerView.addSubview(animationView)
        animationContainerView.bringSubviewToFront(animationView)
        
        gradientBackgroundView.frame = animationContainerView.bounds
        gradientBackgroundView.backgroundColor = .clear
        animationContainerView.addSubview(gradientBackgroundView)
        animationContainerView.bringSubviewToFront(gradientBackgroundView)
        gradientBackgroundView.clipsToBounds = true
    }
    
    @objc
    private func runAnimation() {
        animationView.clipsToBounds = false
        if isAnimationFromTop {
            for subView in gradientBackgroundView.subviews {
                subView.removeFromSuperview()
            }
            gradientBackgroundView.addSubview(createGradientViewEffect(rect: CGRect(x: 0,
                                                                                    y: -20,
                                                                                    width: gradientBackgroundView.bounds.width,
                                                                                    height: 20),
                                                                       isRevertColor: false))
            UIView.animate(withDuration: 2.0, animations: {
                self.animationView.transform = CGAffineTransform(translationX: 0,
                                                                 y: self.animationContainerView.frame.size.height)
                self.gradientView.transform = CGAffineTransform(translationX: 0,
                                                                y: self.animationContainerView.frame.size.height)
                Timer.scheduledTimer(timeInterval: 1.6, target: self, selector: #selector(self.hideGradientView), userInfo: nil, repeats: false)
            })
            
        } else {
            for subView in gradientBackgroundView.subviews {
                subView.removeFromSuperview()
            }
            gradientBackgroundView.addSubview(createGradientViewEffect(rect: CGRect(x: 0,
                                                                                    y: gradientBackgroundView.bounds.height,
                                                                                    width: gradientBackgroundView.bounds.width,
                                                                                    height: 20),
                                                                       isRevertColor: true))
            UIView.animate(withDuration: 2.0, animations: {
                self.animationView.transform = CGAffineTransform(translationX: 0,
                                                                 y: 0)
                self.gradientView.transform = CGAffineTransform(translationX: 0,
                                                                y: 0)
                Timer.scheduledTimer(timeInterval: 1.6, target: self, selector: #selector(self.hideGradientView), userInfo: nil, repeats: false)
            })
        }
        isAnimationFromTop = !isAnimationFromTop
    }
    
    @objc
    private func hideGradientView() {
        UIView.animate(withDuration: 0.4, animations: {
            self.gradientView.alpha = 0
        })
    }
    
    private func createGradientViewEffect(rect: CGRect, isRevertColor: Bool) -> UIView {
        gradientView.layer.sublayers?.removeAll()
        gradientView.frame = rect
        let gradientLayer: CAGradientLayer = CAGradientLayer()
        gradientLayer.frame = gradientView.bounds
        let firstColor = UIColor.orange.withAlphaComponent(0.01).cgColor
        let secondColor = UIColor.orange.withAlphaComponent(1).cgColor
        gradientLayer.colors = isRevertColor ? [secondColor, firstColor] : [firstColor, secondColor]
        gradientLayer.locations = [0.0, 1.0]
        gradientView.layer.insertSublayer(gradientLayer, at: 0)
        gradientView.alpha = 0.6
        return gradientView
    }
    
    func scanningDidFail() {
        delegate?.onDecodeFailed()
        captureSession = nil
    }
    
    func scanSuccess(code: String) {
        delegate?.onDecodeSuccess(code)
    }
}

extension QRScanCustomView: AVCaptureMetadataOutputObjectsDelegate {
    public func metadataOutput(_ output: AVCaptureMetadataOutput,
                               didOutput metadataObjects: [AVMetadataObject],
                               from connection: AVCaptureConnection) {
        stopScanning()
        
        if let metadataObject = metadataObjects.first {
            guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else {
                return
            }
            guard let stringValue = readableObject.stringValue else {
                return
            }
            AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
            scanSuccess(code: stringValue)
        }
    }
}

Để có thể sử dụng thư viện, chúng ta phải import thư viện “import AVFoundation” và khởi tạo “captureSession” để quản lý session capture. Có thể hiểu đơn giản rằng captureSession giúp quản lý việc sử dụng camera của chúng ta.

Ngoài ra, vì ứng dụng của chúng ta sử dụng camera để capture mã QR nên phải khai báo Camera Usage Description trong Info.plist. Nếu không khai báo ứng dụng của chúng ta sẽ không thể chạy vì vi phạm policy của Apple

Tiếp theo chúng ta sẽ khởi tạo AVCaptureVideoPreviewLayer:

public override var layer: AVCaptureVideoPreviewLayer {
        if let layer = super.layer as? AVCaptureVideoPreviewLayer {
            return layer
        }
        return AVCaptureVideoPreviewLayer()
    }

Layer này chính là một layer hiển thị lên màn hình có vai trò thực hiện việc sử dụng camera để scan mã QR

Tạo ra các func để quản lý start, stop capture session:

   func startScanning() {
        captureSession?.startRunning()
    }
    
    func stopScanning() {
        captureSession?.stopRunning()
    }

Ở func commonInit(), chúng ta sẽ tạo khởi tạo videoCaptureDevice để sử dụng kiểu capture video sử dụng camera của chúng ta và add vào kiểu input của captureSession, ở đây có thể hiểu là chúng ta đang khởi tạo đầu vào cho ứng dụng của chúng ta sử dụng camera để scan QR

        guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
            return
        }
        let videoInput: AVCaptureDeviceInput
        do {
            videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
        } catch {
            delegate?.onCameraAccessDenied()
            return
        }
        
        if captureSession?.canAddInput(videoInput) ?? false {
            captureSession?.addInput(videoInput)
        } else {
            scanningDidFail()
            return
        }

Sau khi thực hiện khởi tạo đầu vào, chúng ta sẽ khởi tạo đầu ra cho captureSession qua class AVCaptureMetadataOutput, ở đây mình đã custom một khoảng trắng giữa màn hình để tạo vùng scan bằng thuộc tính “rectOfInterest”. Nếu không gắn thuộc tính này, mặc định cả màn hình capture của chúng ra sẽ là vùng scan:

 let metadataOutput = AVCaptureMetadataOutput()
        
        if captureSession?.canAddOutput(metadataOutput) ?? false {
            captureSession?.addOutput(metadataOutput)
            
            metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
            metadataOutput.metadataObjectTypes = [.qr]
        } else {
            scanningDidFail()
            return
        }
        
        layer.session = captureSession
        layer.videoGravity = .resizeAspectFill
        layer.frame = UIScreen.main.bounds
        
        captureSession?.startRunning()
        
        // MARK: Set scan Area
        let rectView = layer.metadataOutputRectConverted(fromLayerRect: rectForScannerRange())
        metadataOutput.rectOfInterest = rectView

Để hứng được output của captureSession. Chúng ta sẽ kế thừa delegate của AVCaptureMetadataOutput. Delegate này sẽ giúp chúng ta get được String decode được từ mã QR:

extension QRScanCustomView: AVCaptureMetadataOutputObjectsDelegate {
    public func metadataOutput(_ output: AVCaptureMetadataOutput,
                               didOutput metadataObjects: [AVMetadataObject],
                               from connection: AVCaptureConnection) {
        stopScanning()
        
        if let metadataObject = metadataObjects.first {
            guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else {
                return
            }
            guard let stringValue = readableObject.stringValue else {
                return
            }
            AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
            scanSuccess(code: stringValue)
        }
    }
}

Ngoài ra mình còn tạo ra các func để custom màn hình và tạo animation quét QR để trông ứng dụng của chúng ta trông đẹp mắt hơn. Các bạn có thể tham khảo ở phần source đầy đủ bên trên.

Về cơ bản chỉ cần khởi tạo đủ AVCaptureSession, tạo ra input và output cho AVCaptureSession và kế thừa delegate để hứng output là chúng ta có thể tạo ra một ứng dụng scan QR đơn giản.

Bài viết của mình vẫn đang trong quá trình update để đầy đủ và chi tiết hơn, nếu có góp ý gì mọi người có thể comment bên dưới để mình bổ sung và cải thiện thêm nhé.

Cảm ơn mọi người đã quan tâm đến bài viết của mình. Chúc mọi người có một ngày làm việc hiệu quả !

Leave a Comment

* By using this form you agree with the storage and handling of your data by this website.

You may also like