ReplayKit는 개발자가 앱에 녹화 및 라이브 방송 기능을 추가할 수 있도록 하는 베타 프레임워크입니다.
Reference 문서는 하단 링크를 통해 확인이 가능합니다.
iOS 9.0+ 부터 사용가능한 Framework 입니다.
(function 별로 또 가능한 O/S 버전이 다르니 확인이 필요합니다.)
간단하게 예제 프로젝트를 만들어 진행해보도록 하겠습니다.
시뮬레이터에서는 화면녹화가 동작하지 않아 실제 기기에서 테스트해야 합니다.
맥OS 용 샘플 앱 -> macOS Recording Sample App Link
맥 OS용 프로젝트를 받아 iOS 샘플앱을 만들었습니다.
iOS 용 ReplayKit 샘플 앱 -> BK Github Link
macOS Sample 앱에서 나오는 기능은 아래와 같습니다.
제가 만든 샘플 소스는 이 중, Recording과 Capture만 이용하여 화면을 녹화하는 기능을 만들었습니다.
두 기능 모두 앱 화면을 녹화하면서 오디오(inAudio, micAudio)를 함께 녹음해주는 기능입니다.
차이점은 아래와 같습니다.
사용 시에 꼭 1회씩 사용자 허용 요청을 받게 되어 있습니다.
ReplayKit을 사용하려면 ViewController에 import ReplayKit
import 선언을 선행합니다.
< 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 를 추가합니다.
< 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을 추가해 줍니다.
클립 기능은 github 예제 소스에 추가는 해놓았는데 테스트는 못해보았습니다.
나중에 테스트를 하게 된다면 올리도록 하겠습니다.