前言
在很久之前,我写了一篇关于安卓录屏的文章:在安卓上录制屏幕的的实现方式 。虽然文章没什么深度但是也是基于我的项目的实际应用来写的,算是写给自己看的一个 “速查手册” 吧,同时还能抛砖引玉,给不了解安卓录屏的开发者一个搜索的方向。
还是这个项目,去年年底我已经将它移植到了 Flutter ,并上架到了 App Store ,只是有个功能一直没有完成移植,那就是录屏功能。
不得不说,相对于 Android ,iOS 的开发资料少的多,我去年简单调研的时候搜索 iOS 录屏,得到的答案是只能录制当前 APP ,不能全局录屏。因为忙着移植其他功能,所以也没有深究这个答案,就这么认为 iOS 确实不支持这个功能了。
直到最近无意中发现某个 iOS APP 居然支持录屏,我才知道原来 iOS 是支持录屏的。
于是就重启了关于录屏功能的移植,其中也踩了不少坑,这就有了这篇文章。
前置知识
iOS 录屏的发展史
iOS 9
iOS 首次提供录屏功能是在 iOS 9,在 iOS 9 中新增了 replaykit 功能,但是该功能局限性非常强:
- 只能录制当前 APP
- 录制内容不包括系统 UI
- 不能拿到原始数据,只能在录制完成后由用户自己选择分享还是保存到相册,并且当前已经是编码完成的视频文件了
iOS 10
iOS 10 增加了一个 Extension 扩展: Broadcast Upload Extension
,通过这个扩展我们可以实时获取到当前录制的音视频流数据,这样就可以在录制时实时对录制数据进行处理或是如扩展名字一样,实时上传音视频流到服务器,实现直播功能。
但是,当前,这个扩展依旧有非常多的局限性,它依旧只支持录制当前 APP,不支持录制其他 APP。
iOS 11
在 iOS 11 中终于支持了系统级别的录制,也就是说终于可以任意录制所有的 APP 了。
但是,依旧有很大的局限性,那就是没有开放录制 API,想要启动录制只能由用户在手机系统设置中将屏幕录制添加到控制中心,然后从控制中心长按屏幕录制按钮后选择我们的 APP ,才能开始录屏。
虽说相对于前几代的录屏,这一次已经非常好了,可惜就是想要开始录屏还需要用户进行一套繁琐的操作,显然非常不合理。
iOS 12
在 iOS 12 中终于迎来了可用的完全体录屏,在这个版本中,我们终于可以通过 API 直接由我们自己的程序来控制开始/停止录制了。
只是 apple 还是非常 “为用户着想”,虽然能够通过 API 直接启动录制了,但是这个 API 的 UI 是不可定制的,只能使用系统的录制按钮 UI ,好在我们可以通过一些技巧“规避”这个问题。
Extension
在上一节中我们说到,在 iOS 10 之后的录屏采用了 Extension 来实现。
那么 Extension 是一个什么东西呢?
根据官方文档:
借助 App 扩展,你可以将自定功能和内容扩展到你的 App 之外,在用户与其他 App 或系统进行互动时向他们提供这些功能和内容。例如,你的 App 可以作为小组件显示在主屏幕上、在操作菜单中添加新按钮、在“照片”App 中提供照片滤镜,或者自动升级用户账户以使用强密码或“通过 Apple 登录”。通过使用扩展,你的 App 可以在用户最需要的地方发挥作用。
官方说明写的云里雾里的,简单理解就是,由于 iOS 的机制,我们的 APP 是无法在后台运行的,但是某些功能又要求我们必须在后台运行,比如桌面小组件以及我们本文的重点录制屏幕。
为了解决这个问题,Apple 就想出了 Extension 扩展这么个玩意儿,既然不让我 APP 在后台运行,但是某些场景又不得不在后台运行,那我就针对 “某些场景” 给开发者提供一个系统级别的进程,用于 “某些场景” 的后台运行需求。
但是这也就带来个问题,既然这个 Extension 是系统级别的新进程,那么肯定就会出现进程间通信的问题,都是不同的进程了,数据不用说,那当然是不互通的了。
还有一点需要额外注意的是,iOS 的录制屏幕 Extension 进程有 50M 的最大内存限制,如果超过了这个内存限制就会被系统无情的杀死。
进程间通信方式
在 iOS 中进程间可以直接或间接的通过以下方式进行通信:
- 使用 CFNotificationCenterGetDarwinNotifyCenter 即本地通知来从 Extension 和 宿主 APP 之间传递数据,但是这种方式能传递的数据量非常有限。
- 使用 App Group 共享储存空间,iOS 一种称之为 App Group 的东西,可以为不同的进程开辟一块可以互相共享的储存空间,我们可以通过向这个储存空间读/写文件实现通信。
- 本地 Socket ,我们也可以创建一个本地 Socket 服务,用于在不同进程之间传递数据。
实践开始
整体方案设计
根据上述的前置知识铺垫,相信各位读者对于如何实现在 iOS 上的录屏已经有了个大概的轮廓。
在正式开始实现之前我们也先说一下整体的思路。
首先,基于我们的需求:需要实现系统级别的全局录屏,且用户体验不能太差。
我们只能选择使用 iOS 12 之后的解决方案,同时由于我们的需求只是简单的实现录屏后将其编码成视频文件并保存下来使用,所以对于进程间的通讯不需要做太复杂的处理。
我们可以直接将音视频流的编码处理直接放到 Extension 中进行。
这样也可以规避如果使用宿主 APP 对音视频流进行处理,还需要想办法保证宿主 APP 不会被系统杀死。
同时为了让我们的 APP 能够拿到编码完成后的视频文件,我们采用 App Group 的方式,将视频文件编码至共享空间中,这样我们的 APP 就能自由的拿到录屏文件。
最后,为了我们良好的用户体验,如果我们的宿主 APP 在录制结束后并没有被系统杀死,抑或是用户是通过我们的 APP 来进行停止录屏操作(换句话说就是此时我们的宿主 APP 100% 还在运行),那么我们应该给用户一个反馈,所以我们可以通过本地通知的方式,在录制完成时由 Extension 发送完成通知给 APP 。
创建所需文件
Broadcast Upload Extension
在我们的 Xcode 项目中(Flutter 即使用 Xcode 打开 Flutter 项目的 iOS 文件夹),点击 File - New - Target ,然后选择 Broadcast Upload Extension,创建我们需要的 Extension。
创建时不用勾选 "Inlcude UI Extension" 因为我们用不上这个,这个主要是用于兼容 iOS 10 的录制的:
点击 Finish 后我们会看到新建了一个文件夹 RecordExtension
(就是我们创建 Extension 时自己取的名字)。文件夹中有个文件 SampleHandler.swift
这个文件就是我们用于处理录屏状态以及接收录屏实时数据流的文件,它的初始内容如下:
//
// SampleHandler.swift
// RecodeExtension
//
// Created by equation l on 2024/5/19.
//
import ReplayKit
class SampleHandler: RPBroadcastSampleHandler {
override func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) {
// 录制开始,可以在这里初始化我们录制需要的东西
// User has requested to start the broadcast. Setup info from the UI extension can be supplied but optional.
}
override func broadcastPaused() {
// 录制暂停
// User has requested to pause the broadcast. Samples will stop being delivered.
}
override func broadcastResumed() {
// 录制恢复
// User has requested to resume the broadcast. Samples delivery will resume.
}
override func broadcastFinished() {
// 录制完成,我们需要在这里对我们的音视频编码等逻辑做收尾处理
// User has requested to finish the broadcast.
}
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) {
// 接收到新的音视频流
switch sampleBufferType {
case RPSampleBufferType.video:
// 接收到的是视频流
// Handle video sample buffer
break
case RPSampleBufferType.audioApp:
// 接收到的是 APP 内的音频流
// Handle audio sample buffer for app audio
break
case RPSampleBufferType.audioMic:
// 接收到的是麦克风的音频流
// Handle audio sample buffer for mic audio
break
@unknown default:
// Handle other sample buffer types
fatalError("Unknown type of sample buffer")
}
}
}
各个回调的作用非常好理解,上述代码中我也加上了注释,基本一看就懂。
需要重点注意的是 broadcastStarted
回调,我们需要在这里初始化我们的编码器(用于编码接收到的音视频流数据);broadcastFinished
回调,我们需要在这里对我们的音视频编码做收尾工作以及发送录屏完成通知给宿主 APP;processSampleBuffer
回调,在这个回调中我们需要将接收到的对应的数据流编码进我们的文件中。
最后,还有一个比较坑的地方,创建完 Extension 的 Target 后记得看一下 xcodeproj 信息,把 Extension 的 Minimum Deployments 改到你项目的版本或者按情况设置,因为 Xcode 有个坑的地方,新建的 Target 会默认使用最新的 iOS 版本,而不是主项目的 iOS 版本,这就会导致如果安装的设备或模拟器比这个版本底,这个 Extension 就会被直接忽略,表现在我们的实际使用中就是录屏菜单中不会展示我们的 APP :
创建 App Group
首先我们需要在 Apple 的开发者网站中创建一个 App Group 的 Identifier。
在开发者账号页面选择 Identifier ,然后点击 + 加号新建,选择 App Groups,然后填入你的标志符即可:
创建完成返回 Xcode ,分别在主 Target 和 Extension 的 xcodeproj 信息中的 Singing & Capabilities 属性一栏中点击 + Capability 新建一个 App Group:
然后在新增的 App Group 属性中选择我们刚才在 Apple 开发者网站新建的 App Group :
如果这里没有显示我们刚才新建的 App Group 的话可以点一下下面这个刷新图标刷新。
记得主 Target 和 Extension 都要选择同一个 App Group !
踩坑点和提示:
- 其实我们也可以不用去 Apple 开发者网站手动注册 App Group 直接在刚才那个页面的 App Group 属性页面点击 + 加号也可以直接在 Xcode 中注册 App Group 。
- 如果你的项目不是 Automatically manage singing 而是使用描述文件的话,那么你需要在 Apple 开发者网站重新修改你的 App ID 并在 Capabilities 中勾选上 App Group :
然后更新并重新下载你的描述文件,否则将无法使用 App Group 功能。
录屏界面
首先,正如我们上面所说的,iOS 真的很为用户着想,开始录屏只能使用系统的录制按钮来开始,所以我们只能创建一个指定的按钮 UI RPSystemBroadcastPickerView
然后将其添加到我们的原有的界面中,用户只能通过点击 RPSystemBroadcastPickerView
这个按钮才能触发开始录制:
// 创建录屏按钮,且坐标为 100,100,尺寸为 50x50
let pickerView = RPSystemBroadcastPickerView(frame:CGRect(x: 100, y: 100, width: 50, height: 50))
// 指定只使用我们自己的 Extension
pickerView.preferredExtension = "com.equationl.screenRecordDemo.RecodeExtension"
// 显示录制麦克风按钮
pickerView.showsMicrophoneButton = true
// 添加到布局中
view.addSubview(pickerView)
在上面的代码中,需要注意设置 pickerView.preferredExtension
为我们之前创建的 Extension 的 id ,如果不设置这个的话,弹出的确认录制窗口将显示所有支持录屏的 APP,设置这个之后只会显示设置的这个 APP。
运行这个代码的话,会在我们原有的界面上显示一个非常非常非常丑的录制按钮,和我们原有的 UI 显得格格不入:
但是好在我们还有其他的方法可以规避这个问题:
- 优化我们 UI 的布局,让我们原有的 UI 去适应这个按钮
- 调整布局层级,用其他 UI 遮住这个按钮,但是不消耗点击事件,使点击事件依旧能传递给这个按钮。
- 我们就不添加这个按钮了,只是创建,但是不添加到当前界面中,然后使用代码“自动触发”它的点击事件。
这里我们采用方案 3 ,只是需要注意,使用方案 3 违背了 Apple 的规范,有一定的风险哦:
struct ContentView: View {
var body: some View {
VStack {
Button(
action: {
startScreenRecord()
},
label: { Text("开始录屏") }
)
}
.padding()
}
}
func startScreenRecord() {
// 创建录屏按钮
let pickerView = RPSystemBroadcastPickerView()
// 指定只使用我们自己的 Extension
pickerView.preferredExtension = "com.equationl.screenRecordDemo.RecodeExtension"
// 显示录制麦克风按钮
pickerView.showsMicrophoneButton = true
// 直接触发它的点击事件
for view in pickerView.subviews {
if let button = view as? UIButton {
button.sendActions(for: .allEvents)
}
}
}
现在,我们只要点击我们自己的录制按钮,就可以直接触发开始录制弹窗:
什么?你还想连这个弹窗都干掉?别想了,这个真做不到,毕竟开放如 Android 的录屏在开始时也会弹出无法修改的系统弹窗让用户选择才能真正的开始录屏。
另外,各位可能注意到,列表中的 APP 名字并不是我们的 APP 名字,而是我们的 Extension 名称。
没关系,我们可以通过修改 Extension 的 Target 的 Info - Bundle display name 来修改这个名称:
处理音视频流
在开始处理之前,我们先创建一个帮助类,用来发送本地通知:
NotiHelper.swift
import Foundation
class NotiHelper {
static let shared = NotiHelper()
private init() {}
func addObserver(_ target: Any, selector: Selector, name: String) {
// CFNotificationCenterGetDarwinNotifyCenter()
NotificationCenter.default.addObserver(
target,
selector: selector,
name: NSNotification.Name(name + ".ext"),
object: nil)
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
nil, { _, _, name, _, _ in
let nameExt = "\(name!.rawValue as String).ext"
NotificationCenter.default.post(name: NSNotification.Name(nameExt), object: nil)
},
name as CFString,
nil,
.deliverImmediately)
}
func removeObserver(_ target: Any, name: String) {
let nameExt = name + ".ext"
NotificationCenter.default.removeObserver(
target,
name: NSNotification.Name(nameExt),
object: nil)
CFNotificationCenterRemoveObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
nil,
CFNotificationName(rawValue: name as CFString),
nil)
}
func postNotification(name: String, saveName: String) {
let options: CFDictionary = ["saveName" : saveName] as CFDictionary
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(name as CFString),
nil, options, true)
}
}
然后,记得把这个文件的 Target Membership 同时勾选上宿主 APP 和 Extension:
接下来就是重头戏,我们需要处理接收到的音视频流,修改 SampleHandler.swift
:
//
// SampleHandler.swift
// RecodeExtension
//
// Created by equation l on 2024/5/19.
//
import Photos
import ReplayKit
class SampleHandler: RPBroadcastSampleHandler {
var writter: AVAssetWriter?
var videoInput: AVAssetWriterInput!
var microInput: AVAssetWriterInput!
let appGroup = "group.com.equationl.screenRecordDemo"
let fileManager = FileManager.default
var sessionBeginAtSourceTime: CMTime!
var isRecording = false
var outputFileURL: URL!
var outputName: String = ""
let notificaitonName = "com.equationl.screenRecordDemo.broadcast.finished"
override func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) {
setupAssetWritter()
writter?.startWriting()
}
override func broadcastPaused() {
// User has requested to pause the broadcast. Samples will stop being delivered.
}
override func broadcastResumed() {
// User has requested to resume the broadcast. Samples delivery will resume.
}
override func broadcastFinished() {
// User has requested to finish the broadcast.
onFinishRecording()
}
override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) {
guard canWrite() else {
return
}
if sessionBeginAtSourceTime == nil {
sessionBeginAtSourceTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
writter!.startSession(atSourceTime: sessionBeginAtSourceTime)
}
switch sampleBufferType {
case RPSampleBufferType.video:
// Handle video sample buffer
if videoInput.isReadyForMoreMediaData {
videoInput.append(sampleBuffer)
}
case RPSampleBufferType.audioApp:
// Handle audio sample buffer for app audio
break
case RPSampleBufferType.audioMic:
// Handle audio sample buffer for mic audio
// if microInput.isReadyForMoreMediaData {
// microInput.append(sampleBuffer)
// }
break
@unknown default:
// Handle other sample buffer types
fatalError("Unknown type of sample buffer")
}
}
func canWrite() -> Bool {
return writter?.status == .writing
}
func setupAssetWritter() {
outputFileURL = videoFileLocation()
print("\(self).\(#function) output file at: \(outputFileURL)")
guard let writter = try? AVAssetWriter(url: outputFileURL, fileType: .mp4) else {
return
}
self.writter = writter
let scale = UIScreen.main.scale
var width = UIScreen.main.bounds.width * scale
var height = UIScreen.main.bounds.height * scale
// 不知道为什么 ipad 获取到的宽高是反的,所以这里就反转一下得了
if UIDevice.current.userInterfaceIdiom == .pad {
let temp = width
width = height
height = temp
}
let videoCompressionPropertys = [
AVVideoAverageBitRateKey: width * height * 10.1
]
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
AVVideoCompressionPropertiesKey: videoCompressionPropertys
]
videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
videoInput.expectsMediaDataInRealTime = true
// Add the microphone input
var acl = AudioChannelLayout()
memset(&acl, 0, MemoryLayout<AudioChannelLayout>.size)
acl.mChannelLayoutTag = kAudioChannelLayoutTag_Mono
let audioOutputSettings: [String: Any] =
[AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 64000,
AVChannelLayoutKey: Data(bytes: &acl, count: MemoryLayout<AudioChannelLayout>.size)]
microInput = AVAssetWriterInput(mediaType: AVMediaType.audio, outputSettings: audioOutputSettings)
microInput.expectsMediaDataInRealTime = true
if writter.canAdd(videoInput) {
writter.add(videoInput)
}
if writter.canAdd(microInput) {
writter.add(microInput)
}
}
func onFinishRecording() {
print("\(self).\(#function)")
sessionBeginAtSourceTime = nil
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
if fileManager.fileExists(atPath: outputFileURL.path) {
print(try? fileManager.attributesOfItem(atPath: outputFileURL.path))
}
videoInput.markAsFinished()
microInput.markAsFinished()
writter!.finishWriting { [weak self] in
print("writter finish writing")
guard let self = self else {
return
}
let documentsPath = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroup)!
var finishFile = documentsPath
.appendingPathComponent("Library/Caches/videoRecord/\(outputName)")
.appendingPathExtension("finish")
if (FileManager.default.createFile(atPath: finishFile.path, contents: nil, attributes: nil)) {
print("File created successfully.")
} else {
print("File not created.")
}
self.postNotification()
}
dispatchGroup.wait()
}
func videoFileLocation() -> URL {
outputName = String(NSDate().timeIntervalSince1970)
let documentsPath = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroup)!
var videoOutputUrl = documentsPath
.appendingPathComponent("Library/Caches/videoRecord/")
//.appendingPathExtension("mp4")
do
{
try FileManager.default.createDirectory(atPath: videoOutputUrl.path, withIntermediateDirectories: true, attributes: nil)
}
catch let error as NSError
{
print("Unable to create directory \(error.debugDescription)")
}
videoOutputUrl = videoOutputUrl
.appendingPathComponent("\(outputName)")
.appendingPathExtension("mp4")
do {
if fileManager.fileExists(atPath: videoOutputUrl.path) {
try fileManager.removeItem(at: videoOutputUrl)
}
} catch {
print(error)
}
return videoOutputUrl
}
fileprivate func postNotification() {
print("\(self).\(#function) ")
NotiHelper.shared.postNotification(name: notificaitonName, saveName: outputFileURL.path)
}
}
在本例中,我们编码音视频就简单的使用了 iOS 自带的 AVAssetWriter
来进行编码,有需要的也可以自己实现编码。
在 setupAssetWritter()
中初始化 AVAssetWriter
时,我们需要通过 UIScreen.main.bounds.width
和 UIScreen.main.bounds.height
获取到当前设备的宽高尺寸,因为 iOS 特性,这个获取到的不是真实尺寸,还需要乘以一个 UIScreen.main.scale
,然后这里就有个问题,不知道为什么,在 iPhone 上这个方法获取到的宽高是对的,但是在 iPad 上,宽和高恰好是相反的,我找了很久,没找到原因,也没找到解决方案,索性直接改成如果当前设备是 iPad 那就反转一下宽高。
另外,还有一点需要注意的是,不管当前设备的状态是横屏还是竖屏,接收到的视频流都是以竖屏的形式返回的,所以如果这里有特殊要求的话需要自行按照当前设备方向旋转一下再编码,上述代码中并未对这里做处理。
视频文件的储存位置,我们可以通过 fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
获取到当前 App Group 的共享储存路径,然后将视频文件储存至这个目录下的 Library/Caches/videoRecord/
文件夹中,这里的储存位置没有特殊要求,你存在共享目录的根目录下也是可以的。
最后,在录制完成后,我们通过 NotiHelper.shared.postNotification(name: notificaitonName, saveName: outputFileURL.path)
将录制完成的信息发送给宿主 APP 。
在 Flutter 中使用
在 Flutter 中使用无非就是通过 Channel 调用 iOS 端我们上面已经写好的录屏代码,用 Xcode 打开 Flutter 项目中 ios 目录下的 Runner - Runner 找到 AppDelegate.swift
文件,修改内容为:
import UIKit
import Flutter
import ReplayKit
private var commonSink: FlutterEventSink?
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
let appGroup = "group.com.equationl.screenRecordDemo"
let fileManager = FileManager.default
let notificationNameRaw = "com.equationl.screenRecordDemo.broadcast.finished"
let notificaitonName = NSNotification.Name(rawValue: "com.equationl.screenRecordDemo.broadcast.finished")
deinit {
removeNotificationObserver()
}
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
setupNotification()
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
let commonChannel = FlutterMethodChannel(name: "com.equationl.videoCaptureLite/common", binaryMessenger: controller.binaryMessenger)
let commonEvent = FlutterEventChannel(name: "com.equationl.videoCaptureLite/commonEvent", binaryMessenger: controller.binaryMessenger)
var commonEventSink: FlutterEventSink? = nil
commonChannel.setMethodCallHandler(handlerCommonChannel)
commonEvent.setStreamHandler(CommonEventHandler())
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func handlerCommonChannel(call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "startScreenRecord":
startScreenRecord(call: call, result: result)
case "stopScreenRecord":
startScreenRecord(call: call, result: result)
case "getScreenRecordSavePath":
getSavePath(call: call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
func startScreenRecord(call: FlutterMethodCall, result: @escaping FlutterResult) {
if #available(iOS 12.0, *) {
let pickerView = RPSystemBroadcastPickerView()
pickerView.preferredExtension = "com.equationl.screenRecordDemo.RecodeExtension"
pickerView.showsMicrophoneButton = false
// 自动点击录制按钮
for view in pickerView.subviews {
if let button = view as? UIButton {
button.sendActions(for: .allEvents)
result(true)
}
}
}
else {
result(false)
}
}
func setupNotification() {
print("\(self).\(#function) ")
ExHelper.shared.addObserver(
self,
selector: #selector(Self.handleNotification(_:)),
name: notificationNameRaw)
NotificationCenter.default.addObserver(
self,
selector: #selector(Self.handleNotification(_:)),
name: notificaitonName,
object: nil)
}
func removeNotificationObserver() {
ExHelper.shared.removeObserver(self, name: notificationNameRaw)
print("\(self).\(#function)")
}
@objc func handleNotification(_ notification: NSNotification) {
print("\(self).\(#function) \(notification)")
onRecordFinish()
}
func onRecordFinish() {
commonSink?("{\"type\":\"screenRecordFinish\"}")
}
func getSavePath(call: FlutterMethodCall, result: @escaping FlutterResult) {
let savePath = videoFileLocation().path
result(savePath)
}
func videoFileLocation() -> URL {
let documentsPath = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroup)!
let videoOutputUrl = documentsPath
.appendingPathComponent("Library/Caches/videoRecord")
do
{
try FileManager.default.createDirectory(atPath: videoOutputUrl.path, withIntermediateDirectories: true, attributes: nil)
}
catch let error as NSError
{
print("Unable to create directory \(error.debugDescription)")
}
return videoOutputUrl
}
}
class CommonEventHandler: NSObject, FlutterStreamHandler {
public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
commonSink = events
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
commonSink = nil
return nil
}
}
代码很简单也很通俗易懂,就不一一介绍了。
首先,我们定义了一个 commonChannel
用于处理在 Flutter 中调用 swift 代码,这里我们只定义了三个方法: startScreenRecord
、stopScreenRecord
、getScreenRecordSavePath
。
其中 getScreenRecordSavePath
用于返回当前 App Group 的共享储存路径,即我们录屏文件的储存位置,其代码和 Extension 中获取储存位置的是一样的。
而 startScreenRecord
和 stopScreenRecord
实际上调用的都是同一个函数,都是自动触发 RPSystemBroadcastPickerView
的点击事件。
然后,我们还定义了一个 commonEvent
用于在接收到录屏完成通知时发送事件给 Flutter。
而我们通过 setupNotification()
注册并监听指定的通知消息,在接收到消息后就会通过 commonEventSink
发送给 Flutter 。
最后,额外补充一点,其实我们可以通过 UIScreen.main.isCaptured
来判断当前是否正在录屏,但是需要注意的是,这个录屏不一定是我们自己的 APP 在录屏,甚至都不一定是在录屏,其他的操作诸如投屏之类的,这个参数也会返回 true ,所以这个只能用于辅助判断。
同样,还可以通过:
NotificationCenter.default.addObserver(self, selector: #selector(screenCaptureDidChange),
name: UIScreen.capturedDidChangeNotification,
object: nil)
实时监听当前录屏状态的变化,但是这里的录屏状态也和上面一样,不一定真是录屏。
其他未提及错误
Cycle inside Runner; building could produce unreliable results.
报错信息:
Could not build the precompiled application for the device.
Error (Xcode): Cycle inside Runner; building could produce unreliable results.
Cycle details:
→ Target 'Runner' has copy command from '/Users/equationl/flutterProject/screenRecordDemo/build/ios/Debug-iphoneos/RecordExtension.appex' to '/Users/equationl/flutterProject/screenRecordDemo/build/ios/Debug-iphoneos/Runner.app/PlugIns/RecordExtension.appex'
○ That command depends on command in Target 'Runner': script phase “Thin Binary”
○ Target 'Runner' has process command with output '/Users/equationl/flutterProject/screenRecordDemo/build/ios/Debug-iphoneos/Runner.app/Info.plist'
○ Target 'Runner' has copy command from '/Users/equationl/flutterProject/screenRecordDemo/build/ios/Debug-iphoneos/RecordExtension.appex' to '/Users/equationl/flutterProject/screenRecordDemo/build/ios/Debug-iphoneos/Runner.app/PlugIns/RecordExtension.appex'
大概就是说出现了循环依赖,解决方法很简单,更改一下构建脚本顺序即可。
在 Xcode 中找到 Runner 中的 Build Phases 选项,将 Embed Foundation Extensions (1 item) 拖到 Run Script 前面即可。
结语
自此,我们的 iOS 录屏就已经完全完成了。
具体的实现效果可以看看我这个用 Flutter 写的 iOS App :隐云 Lite
当然,正如前言所说,本文也只是做一个简单的介绍,所以写的比较浅,另外因为我也不熟悉 iOS 相关内容,所以难免会有纰漏,如有发现,欢迎随时指正。
完整的 Demo 可以在 Github 上查看: IosScreenRecordDemoByFlutter
有需要的可以 clone 下来试试。