(iOS)ReplayKit을 이용한 화면 녹화 기능 만들기

BK·2022년 4월 20일
0

ios

목록 보기
2/2

목적 : iPhone 화면 녹화 기능 만들기

ReplayKit

ReplayKit는 개발자가 앱에 녹화 및 라이브 방송 기능을 추가할 수 있도록 하는 베타 프레임워크입니다.
Reference 문서는 하단 링크를 통해 확인이 가능합니다.

iOS 9.0+ 부터 사용가능한 Framework 입니다.
(function 별로 또 가능한 O/S 버전이 다르니 확인이 필요합니다.)

1. Project Sample Source

간단하게 예제 프로젝트를 만들어 진행해보도록 하겠습니다.
시뮬레이터에서는 화면녹화가 동작하지 않아 실제 기기에서 테스트해야 합니다.


맥OS 용 샘플 앱 -> macOS Recording Sample App Link

맥 OS용 프로젝트를 받아 iOS 샘플앱을 만들었습니다.


iOS 용 ReplayKit 샘플 앱 -> BK Github Link

1) ReplayKit 기능 설명

macOS Sample 앱에서 나오는 기능은 아래와 같습니다.

  • Recording
  • Capture
  • Streaming
  • Clip

제가 만든 샘플 소스는 이 중, RecordingCapture만 이용하여 화면을 녹화하는 기능을 만들었습니다.

두 기능 모두 앱 화면을 녹화하면서 오디오(inAudio, micAudio)를 함께 녹음해주는 기능입니다.
차이점은 아래와 같습니다.

  • Recording
    - 고화질 화면 녹음. (ex. 4초에 audio 포함 용량 130kb)
    - 저장시 꼭 프리뷰화면을 거쳐서 저장 시켜줘야 함.
  • Capture
    - 저화질 화면 녹음. (ex. 11초에 audio 없는 용량 16kb)
    - buffer 파일을 별도로 생성하고 media에 저장 시킬수 있음

사용 시에 꼭 1회씩 사용자 허용 요청을 받게 되어 있습니다.

2. Source 설명

ReplayKit을 사용하려면 ViewController에 import ReplayKit import 선언을 선행합니다.

1) Recording

< start recording code >

RPScreenRecorder.shared().startRecording { error in
            // If there is an error, print it and set the button title and state.
            if error == nil {
                // 에러가 없는 정상인 경우. 사용자에게 동작하는 상태를 보여줍니다.
            } else {
                // Print the error.
                print("Error starting recording")
            }
        }

RPScreenRecorder를 이용하여 startRecording 블럭을 호출해 주면 됩니다.
사용자 허용 팝업이 뜬 뒤 녹화가 진행 됩니다.


< stop recording code >

RPScreenRecorder.shared().stopRecording { previewViewController, error in
            if error == nil {
                // 정상적으로 종료가 된 경우
                print("Presenting Preview View Controller")
                
                guard previewViewController != nil else {
                    print("Preview controller is not available.")
                    return
                }
                previewViewController?.modalPresentationStyle = .overFullScreen
                previewViewController?.previewControllerDelegate = self
                self.present(previewViewController!, animated: true, completion: nil)
            } else {
                // There's an error stopping the recording, so print an error message.
                print("Error starting recording")
            }            
            // Set the recording state.
        }

RPScreenRecorder를 이용하여 stopRecording 블럭을 호출한 뒤 error가 없으면 해당 블럭에서 return 된 previewController를 현재 viewController에서 present 합니다.
사용자가 preview를 확인하고 저장 혹은 취소 액션을 합니다.


< RPPreviewViewControllerDelegate >

class ViewController: UIViewController,
                      RPPreviewViewControllerDelegate {

viewController에 delegate를 추가해 줍니다.

	// MARK: - RPPreviewViewController Delegate
    func previewControllerDidFinish(_ previewController: RPPreviewViewController) {
        // This delegate method tells the app when the user finishes with the
        // preview view controller sheet (when the user exits or cancels the sheet).
        // End the presentation of the preview view controller here.
        print("previewControllerDidFinish")
        dismiss(animated: true, completion: nil)
    }

delegate 함수를 선언해 주고 previewController가 완료되었을 때 동작 dismiss 를 추가합니다.


2) Capture

< start capture code >

RPScreenRecorder.shared().startCapture { sampleBuffer, sampleBufferType, error in
            // Check for an error and, if there is one, print it.
            if error != nil {
                print("Error receiving sample buffer for in app capture")
            } else {
                // 캡처 정상 동작 중. 일정 sampleBuffer가 type 별로 return 되어 온다.
                
                //self.assetWriter.write(buffer: sampleBuffer, bufferType: sampleBufferType)                
            }
        } completionHandler: { error in
            if error == nil {
                // 정상 시작 됨.
            } else {
                print("Error starting in app capture session")
            }
        }

RPScreenRecorder를 이용하여 startCapture 블럭을 호출해 주면 버퍼 정보가 블럭에서 return되어 들어옵니다.
사용자 허용 팝업이 뜬 뒤 화면 캡처가 진행 됩니다.
여기서 들어오는 buffer를 합치고 후에 캡처가 종료될 때 save 시켜줘야 합니다.


< stop capture code >

RPScreenRecorder.shared().stopCapture { error in
            // The sample calls the handler when the stop capture finishes. Update the capture state.
            
            //self.assetWriter.finishWriting()
            
            // Check and print the error, if necessary.
            if error != nil {
                print("Encountered and error attempting to stop in app capture")
            }
        }

RPScreenRecorder를 이용하여 stopCapture 블럭을 호출한 뒤 계속 저장되고 있던 buffer를 save시킵니다.


< AssetWriter.swift >

import UIKit
import Foundation
import AVFoundation
import ReplayKit

class AssetWriter {
    private var assetWriter: AVAssetWriter?
    private var videoInput: AVAssetWriterInput?
    private var audioInput: AVAssetWriterInput?
    private var micInput: AVAssetWriterInput?
    private var fileName: String
    
    let writeQueue = DispatchQueue(label: "writeQueue")
    
    init(fileName: String) {
        self.fileName = fileName
    }
    
    
    private var videoDirectoryPath: String {
        let dir = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
        return dir + "/Videos"
    }
    
    private var filePath: String {
        return videoDirectoryPath + "/\(fileName)"
    }
    
    private func setupWriter(buffer: CMSampleBuffer) {
        if FileManager.default.fileExists(atPath: videoDirectoryPath) {
            do {
                try FileManager.default.removeItem(atPath: videoDirectoryPath)
            } catch {
                print("fail to removeItem")
            }
        }
        do {
            try FileManager.default.createDirectory(atPath: videoDirectoryPath, withIntermediateDirectories: true, attributes: nil)
        } catch {
            print("fail to createDirectory")
        }
        
        self.assetWriter = try? AVAssetWriter(outputURL: URL(fileURLWithPath: filePath), fileType: AVFileType.mov)
        
        let writerOutputSettings = [
            AVVideoCodecKey: AVVideoCodecType.h264,
            AVVideoWidthKey: UIScreen.main.bounds.width,
            AVVideoHeightKey: UIScreen.main.bounds.height,
            ] as [String : Any]
        
        self.videoInput = AVAssetWriterInput(mediaType: AVMediaType.video, outputSettings: writerOutputSettings)
        self.videoInput?.expectsMediaDataInRealTime = true
        
        guard let format = CMSampleBufferGetFormatDescription(buffer),
            let stream = CMAudioFormatDescriptionGetStreamBasicDescription(format) else {
                print("fail to setup audioInput")
                return
        }
        
        let audioOutputSettings = [
            AVFormatIDKey : kAudioFormatMPEG4AAC,
            AVNumberOfChannelsKey : stream.pointee.mChannelsPerFrame,
            AVSampleRateKey : stream.pointee.mSampleRate,
            AVEncoderBitRateKey : 64000
            ] as [String : Any]
        
        self.audioInput = AVAssetWriterInput(mediaType: AVMediaType.audio, outputSettings: audioOutputSettings)
        self.audioInput?.expectsMediaDataInRealTime = true
        
        self.micInput = AVAssetWriterInput(mediaType: AVMediaType.audio, outputSettings: audioOutputSettings)
        self.micInput?.expectsMediaDataInRealTime = true
        
        if let videoInput = self.videoInput, (self.assetWriter?.canAdd(videoInput))! {
            self.assetWriter?.add(videoInput)
        }
        
        if  let audioInput = self.audioInput, (self.assetWriter?.canAdd(audioInput))! {
            self.assetWriter?.add(audioInput)
        }
        
        if let micInput = self.micInput, (self.assetWriter?.canAdd(micInput))! {
            self.assetWriter?.add(micInput)
        }
    }
    
    public func changeFileName(fileName: String) {
        self.fileName = fileName
    }
    
    public func write(buffer: CMSampleBuffer, bufferType: RPSampleBufferType) {
        writeQueue.sync {
            if assetWriter == nil {
                if bufferType == .audioApp {
                    setupWriter(buffer: buffer)
                }
            }
            
            if assetWriter == nil {
                return
            }
            
            if self.assetWriter?.status == .unknown {
                print("Start writing")
                let startTime = CMSampleBufferGetPresentationTimeStamp(buffer)
                self.assetWriter?.startWriting()
                self.assetWriter?.startSession(atSourceTime: startTime)
            }
            if self.assetWriter?.status == .failed {
                print("assetWriter status: failed error: \(String(describing: self.assetWriter?.error))")
                return
            }
            
            if CMSampleBufferDataIsReady(buffer) == true {
                if bufferType == .video {
                    if let videoInput = self.videoInput, videoInput.isReadyForMoreMediaData {
                        videoInput.append(buffer)
                    }
                } else if bufferType == .audioApp {
                    if let audioInput = self.audioInput, audioInput.isReadyForMoreMediaData {
                        audioInput.append(buffer)
                    }
                } else if bufferType == .audioMic {
                    if let micInput = self.micInput, micInput.isReadyForMoreMediaData {
                        micInput.append(buffer)
                    }
                }
            }
        }
    }
    
    public func finishWriting() {
        writeQueue.sync {
            self.assetWriter?.finishWriting(completionHandler: {
                print("finishWriting")
                
                UISaveVideoAtPathToSavedPhotosAlbum(self.filePath, nil, nil, nil)
            })
        }
    }
}

AssetWriter class는 openSource에서 가져왔으며, 제가 voice 부분을 약간 수정하였습니다.
replayKit Capture에서 들어온 bufferType 별로 inputData를 만들어 합치고, finishWriting()에서 사진첩에 저장 시키는 기능을 포함하고 있습니다.


< AssetWriter instance init >

class ViewController: UIViewController,
                      RPPreviewViewControllerDelegate {
    
    private var assetWriter = AssetWriter(fileName: "test.mp4")
    
    
    ...
    self.assetWriter.write(buffer: sampleBuffer, bufferType: sampleBufferType)
    ...
    
    ...
    self.assetWriter.finishWriting()
    ...
    

assetWriter 전역 객체 생성 후, startCapture{}, stopCapture{} 부분에 write(), finishWriting() 함수를 추가합니다.


< Info.plist >
혹시 사진 저장이 되지 않고 오류가 발생한다면, Property List에 descripion을 추가해 줍니다.

3) Clip

클립 기능은 github 예제 소스에 추가는 해놓았는데 테스트는 못해보았습니다.
나중에 테스트를 하게 된다면 올리도록 하겠습니다.

profile
k-힙합을 사랑하는 개발자

0개의 댓글