iOS Advent Calendar 2017 3日目のかっくん a.k.a. fromkkです。
iOS 9で導入されたReplay Kitですが、当時は画面のライブ配信、簡易的な録画からのシェアが目的でした。
iOS 11からReplay Kitを利用してアプリの画面録画をハンドリングする事が出来る様になりました。
一番シンプルな画面録画
ちなみに一番シンプルな画面録画は以下の様に記述出来ます。
import ReplayKit
func startRecording() {
// 既に録画中だと何もしない
guard !RPScreenRecorder.shared().isRecording else { return }
// 録画開始
RPScreenRecorder.shared().startRecording(withMicrophoneEnabled: true, handler: { (error) in
if let error = error {
debugPrint(#function, "recording something failed", error)
}
})
}
func endRecording() {
// 録画中じゃないと終了しない
guard RPScreenRecorder.shared().isRecording else { return }
// 録画終了
RPScreenRecorder.shared().stopRecording(handler: { (previewViewController, error) in
guard let previewViewController = previewViewController else { return }
previewViewController.previewControllerDelegate = self //delegateを実装しないとdismissされない
// プレビューの表示
self.present(previewViewController, animated: true, completion: nil)
})
}
func previewControllerDidFinish(_ previewController: RPPreviewViewController) {
DispatchQueue.main.async {
self.previewController.dismiss(animated: true, completion: nil)
}
}
正常に録画が終了すると RPPreviewViewController というクラスのインスタンスが取得出来るんですが動画のURLぐらい返してくれても良さそうなのにドキュメントを見ても何も保持して無いんですね。
画面録画をハンドリングする
iOS 11からReplayKitの RPScreenRecorder
クラスに下記のメソッドが追加されました。
func startCapture(handler captureHandler: ((CMSampleBuffer, RPSampleBufferType, Error?) -> Void)?,
completionHandler: ((Error?) -> Void)? = nil)
func stopCapture(handler: ((Error?) -> Void)? = nil)
こちらのメソッドを利用する事で CMSampleBuffer
が取得出来ます。
簡単にいえばこのサンプルバッファー結合して動画にしてしまえば良いのです。
という事で出来上がったコードがこちらです。
@available(iOS 11.0, *)
public class ScreenRecorder: NSObject {
let screenRecorder = RPScreenRecorder.shared()
public typealias Completion = (URL?, Error?) -> ()
let completion: Completion
let configuration: Configuration
public init(configuration: Configuration, completion: @escaping Completion) {
self.configuration = configuration
self.completion = completion
super.init()
}
/// 画面のキャプチャを開始する
///
/// - Throws: ScreenRecorderError
public func start() throws {
guard screenRecorder.isAvailable else {
throw ScreenRecorderError.notAvailable
}
guard !screenRecorder.isRecording else {
throw ScreenRecorderError.alreadyRunning
}
try setUp()
assetWriter?.startWriting()
assetWriter?.startSession(atSourceTime: kCMTimeZero)
screenRecorder.startCapture(handler: { [weak self] (cmSampleBuffer, rpSampleBufferType, error) in
if let error = error {
debugPrint(#function, "something happened", error)
return
}
if RPSampleBufferType.video == rpSampleBufferType {
self?.appendVideo(sampleBuffer: cmSampleBuffer)
}
}) { [weak self] (error) in
if let error = error {
self?.completion(nil, error)
}
}
}
/// 画面のキャプチャを終了する
///
/// - Throws: ScreenRecorderError
public func end() throws {
guard screenRecorder.isRecording else {
throw ScreenRecorderError.notRunning
}
screenRecorder.stopCapture { [weak self] (error) in
if let error = error {
self?.completion(nil, error)
}
self?.videoAssetWriterInput?.markAsFinished()
self?.assetWriter?.finishWriting {
DispatchQueue.main.async {
self?.completion(self?.cacheFileURL, nil)
}
}
}
}
/// 録画中かどうか
public var isRecording: Bool {
return screenRecorder.isRecording
}
private var assetWriter: AVAssetWriter?
private var videoAssetWriterInput: AVAssetWriterInput?
private var writerInputPixelBufferAdaptor: AVAssetWriterInputPixelBufferAdaptor?
/// 事前準備
///
/// - Throws: Error
private func setUp() throws {
try createCacheDirectoryIfNeeded()
try removeOldCachedFile()
guard let cacheURL = cacheFileURL else {
throw ScreenRecorderError.invalidURL
}
let assetWriter = try AVAssetWriter(url: cacheURL, fileType: configuration.fileType)
let videoSetting: [String: Any] = [
AVVideoCodecKey: configuration.codec,
AVVideoWidthKey: UInt(configuration.videoSize.width),
AVVideoHeightKey: UInt(configuration.videoSize.height),
]
let videoAssetWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSetting)
videoAssetWriterInput.expectsMediaDataInRealTime = true
if assetWriter.canAdd(videoAssetWriterInput) {
assetWriter.add(videoAssetWriterInput)
}
self.assetWriter = assetWriter
self.videoAssetWriterInput = videoAssetWriterInput
self.writerInputPixelBufferAdaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: videoAssetWriterInput, sourcePixelBufferAttributes: [
kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32ARGB)
])
}
private var startTime: CMTime?
private func appendVideo(sampleBuffer: CMSampleBuffer) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let firstTime: CMTime
if let startTime = self.startTime {
firstTime = startTime
} else {
firstTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
startTime = firstTime
}
let currentTime: CMTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
let diffTime: CMTime = CMTimeSubtract(currentTime, firstTime)
if writerInputPixelBufferAdaptor?.assetWriterInput.isReadyForMoreMediaData ?? false {
writerInputPixelBufferAdaptor?.append(pixelBuffer, withPresentationTime: diffTime)
}
}
/// キャッシュディレクトリーのURL
private var cacheDirectoryURL: URL? = {
guard let path = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first else {
return nil
}
return URL(fileURLWithPath: path).appendingPathComponent("ScreenRecorder")
}()
/// キャッシュファイルのURL
private var cacheFileURL: URL? {
guard let cacheDirectoryURL = cacheDirectoryURL else { return nil }
return cacheDirectoryURL.appendingPathComponent("screenrecord.mp4")
}
/// キャッシュディレクトリが無ければ作成する
///
/// - Throws: Error
private func createCacheDirectoryIfNeeded() throws {
guard let cacheDirectoryURL = cacheDirectoryURL else { return }
let fileManager = FileManager.default
guard !fileManager.fileExists(atPath: cacheDirectoryURL.path) else {
return
}
try fileManager.createDirectory(at: cacheDirectoryURL, withIntermediateDirectories: true, attributes: nil)
}
private func removeOldCachedFile() throws {
guard let cacheURL = cacheFileURL else { return }
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: cacheURL.path) else { return }
try fileManager.removeItem(at: cacheURL)
}
}
@available(iOS 11.0, *)
extension ScreenRecorder {
public struct Configuration {
public var codec: AVVideoCodecType = .h264
public var fileType: AVFileType = .mp4
public var videoSize: CGSize = CGSize(width: UIScreen.main.bounds.size.width * UIScreen.main.scale, height: UIScreen.main.bounds.size.height * UIScreen.main.scale)
public var audioQuality: AVAudioQuality = AVAudioQuality.medium
public var audioFormatID: AudioFormatID = kAudioFormatMPEG4AAC
public var numberOfChannels: UInt = 2
public var sampleRate: Double = 44100.0
public var bitrate: UInt = 16
public init() {}
}
public enum ScreenRecorderError: Error {
case notAvailable
case alreadyRunning
case notRunning
case invalidURL
}
}
使い方はこちらです
private lazy var recorder: ScreenRecorder = ScreenRecorder(configuration: ScreenRecorder.Configuration(), completion: { (url, error) in
guard let url = url else {
fatalError("\(#function) record failed \(error)")
}
debugPrint(#function, "success", url)
})
func recordStart() {
guard !recorder.isRecording else { return }
do {
try recorder.start()
} catch {
fatalError("start recording failed \(error)")
}
}
func recordStop() {
guard recorder.isRecording else { return }
do {
try recorder.end()
} catch {
fatalError("start recording failed \(error)")
}
}
RPSampleBufferType
の値をハンドリングすれば端末が発している音声も取得出来るみたいですが、僕は必要無かったので今回は対応しませんでした。
興味のある方は是非試してみて頂ければと思います。
まとめ
実はこれを作ったのは ARKit
のを利用した画面を録画したかったからなんですが、ReplayKit
を使うと録画開始時にネイティブのアラートダイアログが表示されてしまうんですね。
ユーザーからすると録画するという意思表示をした後に再度アラートが表示されてしまうという体験になってしまいよろしくないので ReplayKit
を使う事は諦めましたが、せっかくここまで作ったので供養の意味も込めて公開させていただきました。
(ARKitの画面、音声の録画の方法はどこかで公開出来ればと思います)
もし ReplayKit
で録画した動画のURLを取得したくて困っている方の助けになれば幸いです!