需求只有一句话:用户在控制中心点一下,不打开 App,后台直接开始录音;再点一下,停止并入库。
iOS 18 给了 Control(ControlWidgetToggle),iOS 18 又给了 AudioRecordingIntent,看上去是 Apple 专门为这个场景准备的。我先搭了一套 Spike,在真机上装了四轮,结论是"可行"------探针在后台录到了一段 82KB、电平正常的有效音频。
然后正式实现第一次上真机,进程直接崩在系统框架里:
less
AppIntents/PerformActionExecutorTask.swift:669: Fatal error:
AudioRecordingIntent was performed with an active audio session but without a Live Activity
修掉崩溃之后,后台起录又稳定失败:start audio capture failed,沙盒里一个新文件都没有。于是我写下终审结论:"后台起录在本项目不可行,退回『点击后打开 App 再录』"。
几天后这个终审结论也被推翻了------后台起录是可行的,只是我们一直没拿到那张"许可证",或者拿到了也在用之前就过期了。
两次结论,一次假阳性,一次假阴性。 这篇把整个过程按踩坑顺序摊开。它首先是一篇 App Intents / Control 的实战记录,但我觉得更值钱的是后半段:为什么一个认真做的 Spike 会连续两次给出错误答案。
环境:iOS 18 起的 Control 与 App Intents,真机验证在 iOS 26 上完成。
supportedModes/continueInForeground为 iOS 26 API。文中示例代码由生产代码简化而来,未逐行编译验证。
一、先把约束讲死
动手之前,先把系统给你的"格子"列清楚。这几条决定了后面所有的弯路:
- 一个
ControlWidgetToggle只能绑定一个SetValueIntent,开和关走同一个 Intent 类型,方向由value: Bool传进来。 openAppWhenRun必须是编译期字面量。你没办法写"开的时候打开 App、关的时候静默"------同一个类型表达不了方向差异。ControlWidgetTemplateBuilder只有buildExpression/buildBlock,没有buildEither/buildOptional。也就是说你连"按状态绑定不同 Intent"这条后路都没有。perform()跑在哪个进程,取决于 Intent 的类型------这一条文档里没有一张清楚的表,而它决定了一切。
第 4 条是整个问题的地基。我在 Spike 里给每个 perform() 的入口都打了一行日志,落到 App Group 容器里,再用脚本从设备上取回:
swift
func logEnter(_ tag: String) {
let isAppex = Bundle.main.bundlePath.hasSuffix(".appex")
VSLog.append("[\(tag)] ENTER isAppex=\(isAppex) pid=\(getpid()) "
+ "process=\(ProcessInfo.processInfo.processName)")
}
为什么要落 App Group 而不是看 Xcode 控制台?因为你要测的恰恰是"App 已被杀死、用户从控制中心点击"的冷启动路径,那时候调试器根本挂不上去。最需要观测的路径,恰好是调试器进不去的路径------这件事在后面还会反复出现。
实测结果整理成一张表(图 1):
| Intent 形态 | perform() 所在进程 |
麦克风权限 | 同进程 NotificationCenter 能否到达主 App |
|---|---|---|---|
普通 AppIntent / SetValueIntent |
Widget Extension 进程 | 'deny' |
否 |
+ LiveActivityIntent |
主 App 进程(可在后台被冷启动) | 与 App 一致 | 是 |
openAppWhenRun = true |
主 App 进程,且会被推到前台 | 与 App 一致 | 是 |

二、坑 1:权限明明给了,进程却说 'deny'
症状 :Spike 第一轮,普通 SetValueIntent + AudioRecordingIntent,点击后 AVAudioSession.setActive(true) 失败,错误码 '!pla'。
我第一反应是"Widget 里不让录音",差点直接判负。但 AudioRecordingIntent 在 AppIntents 里是一个继承自 SystemIntent 的空标记协议,它存在的意义就是让系统知道"这个 Intent 要录音"。如果 appex 里根本不能录,这个协议的设计就说不通。所以我没有照着先验判负,而是去看两条日志的时间差:
ini
02:41:14 [Audio] main app start -> OK permission=true ← 主 App 进程
02:41:42 PROBE permission recordPermission='deny' ← Widget 进程
02:41:42 PROBE audioSession FAILED '!pla'
同一个 App,相隔 28 秒,主 App 进程里权限是 true,Widget Extension 进程里是 'deny'。
根因 :麦克风授权记在主 App 身上,Widget Extension 是另一个进程,拿不到这份授权。没有权限,.playAndRecord 必然激活失败。所以问题不是"不能录",而是 perform() 跑错了进程。
解法 :加上 LiveActivityIntent。它的语义本来是"与 Live Activity 交互",但它有一个实测稳定的副作用------perform() 会被路由到主 App 进程 ,App 被杀死时会被系统冷启动到后台,不会跳到前台。编译产物里能直接看到这层差异,Metadata.appintents 里的 systemProtocols 多了一项 SessionStarting:
ini
普通形态: systemProtocols = [AudioRecording, SetValue]
+LiveActivityIntent: systemProtocols = [AudioRecording, SessionStarting, SetValue]
这一招不只对录音有用。只要你的 Intent 需要主 App 的权限、单例或者同进程通知,都可以用它把 perform() 拉回主进程------否则你发的 NotificationCenter 通知在另一个进程里,主 App 永远收不到,只剩 App Group 轮询这条慢通道。
反直觉的地方 :AudioQueue 和 AVAudioSession 的 FourCC 错误码是排查这类问题最有信息量的东西,但它们是以 OSStatus 整数的形式出现的。560557684、2003329396 这种数字直接看毫无意义,解成四个字符才是 '!int'、'what'。我在日志工具里统一做了这一步:
swift
func fourCC(_ status: OSStatus) -> String {
let n = UInt32(bitPattern: status)
let bytes = [24, 16, 8, 0].map { UInt8((n >> $0) & 0xFF) }
guard bytes.allSatisfy({ (0x20...0x7E).contains($0) }) else { return "\(status)" }
return "'" + String(decoding: bytes, as: UTF8.self) + "'"
}
三、坑 2:去掉一个 option,后台就激活不了
症状 :进程对了、权限是 'grnt' 了,AVAudioRecorder.record() 却返回 false。我怀疑是 .mixWithOthers 对输入采集有副作用,于是把 options 去掉重测------结果连 session 都激活不了:
ini
带 .mixWithOthers → setActive 成功,inputAvailable=true inputs=1
去掉 options → setActive 失败,OSStatus 560557684 = '!int'
根因 :'!int' 是 AVAudioSessionErrorCodeCannotInterruptOthers。App 在后台时,激活一个会打断其他 App 音频的 session 会被系统拒绝。 .mixWithOthers 正是在声明"我不打断别人",所以它是后台激活的必要条件,而不是可有可无的优化项。
解法 :后台路线的 setCategory 必须带 .mixWithOthers:
swift
try session.setCategory(.playAndRecord, mode: .default, options: [.mixWithOthers])
try session.setActive(true)
这条值得记下来,因为它和前台开发的直觉完全相反。前台录音时你往往会刻意不 带 .mixWithOthers,好让音乐自动停下,避免录进背景音。同一行配置,前台是"体验优化",后台是"能不能跑"。
四、坑 3:诊断工具自己在撒谎
record() 返回 false,而且不给任何错误对象 ------准备失败、后台限制、配置错误全部塌缩成同一个 false。于是我补强了探针,把一次 record() 拆成几步分别留痕:
- 先往目标目录写、删一个 canary 文件,单独排除"路径不可写";
- 显式调
prepareToRecord(),把"准备失败"和"启动失败"拆开; - 两组 session 配置各试一次,记录激活后实际生效的 category / mode / 输入设备数。
补强后的第一轮日志一次就打中了根因,但根因让我很难堪:
bash
A2/(d) PROBE dir NOT writable
NSFilePath=.../AppGroup/<uuid>/spike_probe_A2/spike_canary.tmp
^^^^^^^^^^^^^^ 被解释成了目录
探针的 tag 是 "A2/(d)",本来是给人看日志用的,里面有斜杠。我直接把它拼进了文件名:
swift
// ❌ tag = "A2/(d)" → 实际路径是 spike_probe_A2/(d).m4a,父目录不存在
let url = container.appendingPathComponent("spike_probe_\(tag).m4a")
// ✅ 先 slug 化
let slug = tag.filter { $0.isLetter || $0.isNumber } // "A2d"
let url = container.appendingPathComponent("spike_probe_\(slug).m4a")
父目录不存在,AVAudioRecorder 建不了文件,record() 静默返回 false。在此之前我已经据此推出了"iOS 不允许从后台新建录音会话""需要配合 Live Activity"好几条方向,全部作废。
同一天还有一个更隐蔽的:我一度认定"主 App 没声明 UIBackgroundModes"是根因,因为 plutil 从编译产物里读不到它。实际上是 plutil -extract KEY json FILE 在不带 -o - 时,会把提取结果写回原文件 ------第一次 extract 就把产物 Info.plist 覆盖成了 ["audio","voip"],之后再读当然"查不到"。
bash
# ❌ 会把 FILE 改写成提取结果
plutil -extract UIBackgroundModes json Info.plist
# ✅ 输出到 stdout
plutil -extract UIBackgroundModes json -o - Info.plist
这一节的教训:Spike 里所有的判断都建立在观测工具之上,而观测工具本身也是代码,也有 bug。在拿它的输出下结论之前,先让它自证一次------canary 文件就是干这个的。
五、坑 4:Spike 成功,生产 fatalError(第一次被骗)
修掉路径 bug 之后,探针在后台录到了 3 秒有效音频:文件 82KB,averagePower 每秒采样都在正常范围。结论:"可行"。
正式实现第一次上真机,进程就崩在了开头那行:
css
AudioRecordingIntent was performed with an active audio session but without a Live Activity
根因 :AudioRecordingIntent 有一条文档里只提了一句、很容易被忽略的契约------perform() 结束时,如果 audio session 处于 active,就必须存在一个活跃的 Live Activity 。而且它检查的是全局 session 状态:哪怕这次 perform() 根本没碰 session,只要主 App 进程里有一个残留的 active session(之前录过音或播过声音),照样崩。
那 Spike 为什么没崩?因为探针录 3 秒就 setActive(false) 了 。perform() 返回时 session 已经关掉,正好绕过了检查点。生产实现要做的是"开始录音然后返回",perform() 返回时 session 必然还是 active------必崩。
更糟的是第二个差异:探针用的是 AVAudioRecorder,生产用的是底层 AudioQueueNewInput + AudioQueueStart。探针在后台录成功了,对生产那条路径没有任何约束力。
这里我要承认一件事:项目里早就有一段注释,写着"底层录音靠 AudioQueueStart,属于发起录音,App 在后台会被系统拒绝,静音保活方案已真机判负"。本期开工时我没有全仓搜这个关键词,换了个 API 重新"验证"了一遍,白白耗掉四轮装机。
这次我给自己定的 Spike 铁律:
Spike 必须走生产实现的同一条代码路径。 换 API、换调用方式、换生命周期时机,都等于没验证。开工前先全仓 grep 关键词。
然后我按这条铁律写下了终审结论:AudioQueue 后台起录被拒是既有事实,本项目下后台录音不可行,退回 openAppWhenRun = true,点击后打开 App 前台录。
我当时以为这是一次踏实的纠错。
六、坑 5:判负同样可能是错的(第二次被骗)
后来团队复盘这条链路,发现"后台起录必败"这个现象是真的,归因却是错的。
先看生产链路上 perform() 到底干了什么:
arduino
perform()
└─ post 通知 → return ← perform 结束
(系统:Intent 执行完毕)
main queue
└─ startAudioRecording
└─ DispatchQueue.main.async(权限检查)
└─ startAudioRecord
└─ AudioQueueStart ✗ 'what' (kAudioSessionUnspecifiedError)
根因 :AudioRecordingIntent 给的后台麦克风采集授权,只在 perform() 执行期间有效 。perform() 发完通知就返回,而宿主侧的起录链路中间还隔着两跳异步。等真正执行到 AudioQueueStart 时,授权窗口早就关了。再加上我在修 fatalError 时把 AudioRecordingIntent 一并去掉了,授权根本就没申请过(图 2)。
所以正确的说法是:"后台起录失败,是因为起录发生在授权窗口之外。"而不是"后台不能起录"。

解法 :让 perform() 挂住,一直等到宿主侧确认"真的在录了"或者"确认起不来",再返回。
swift
enum BackgroundStartGate {
static let didSettle = Notification.Name("BackgroundAudioRecordDidSettle")
/// 8s:冷启动时主线程可能被初始化占住数秒,6s 偶发不够;
/// 又要小于系统对 perform 的执行时限,留出余量。超时只会丢授权,不会拖崩 Intent。
static let timeout: TimeInterval = 8
static func dispatchAndWait(_ dispatch: @escaping () -> Void) async -> String? {
await withCheckedContinuation { (cont: CheckedContinuation<String?, Never>) in
let lock = NSLock()
var finished = false
var token: NSObjectProtocol?
// 回信与超时是竞态的两条腿,重复 resume 会直接崩
let finish: (String?) -> Void = { reason in
lock.lock()
guard !finished else { lock.unlock(); return }
finished = true
let observer = token
lock.unlock()
if let observer { NotificationCenter.default.removeObserver(observer) }
cont.resume(returning: reason)
}
// queue: nil ------ 在发信线程同步回调,不再多绕一次 main queue
token = NotificationCenter.default.addObserver(
forName: didSettle, object: nil, queue: nil) { note in
finish(note.userInfo?["settleReason"] as? String)
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { finish(nil) }
dispatch()
}
}
}
宿主在 isRecording 确认为真(或者确认失败)之后 post didSettle,同时带上一个"结算原因"。Intent 声明回到三条 conformance:
swift
@available(iOS 18.0, *)
struct VoiceRecordIntent: SetValueIntent, LiveActivityIntent, AudioRecordingIntent {
static var openAppWhenRun: Bool { false }
@Parameter(title: "Voice Recording") var value: Bool
func perform() async throws -> some IntentResult {
if value {
// 开始方向:挂到宿主确认为止
_ = await BackgroundStartGate.dispatchAndWait { postStartCommand() }
} else {
postStopCommand() // 停止方向不需要授权窗口,立即返回
}
return .result()
}
}
三条缺一不可:SetValueIntent 是 Toggle 的绑定要求,LiveActivityIntent 把 perform() 拉回主进程,AudioRecordingIntent 让系统授予后台采集能力。
这是全文我最想让你记住的一个反直觉点 :在 Swift Concurrency 的语境里,我们被训练成"尽快返回、别阻塞"。可是在 AudioRecordingIntent 这里,perform() 返回得越快越糟------返回就意味着授权窗口关闭。异步编程里通常"做完再返回"和"派发完就返回"是等价的,在这里它们是能用和不能用的区别。
回头看第五节那条铁律,它本身没错,但只说了一半。判负和判正需要同样强度的证据。 我用"项目里早有注释说后台被拒"来支撑判负,而那段注释描述的也只是现象。现象被复现了,归因却从来没有被单独验证过。
七、坑 6:后台能录了,但还有三种"起不来"
授权窗口的问题解决后,后台静默起录成了常态。回归测试又挖出三个边缘情况,每一个都会让用户看到"点了没反应"。
① 连录两段,第二段偶尔直接崩。 还是那条契约:session active 时必须有 Live Activity,而且系统认的是本次 perform() 期间新建的那个 。连续录两段时,上一段的 Live Activity 还在延迟清场,这次新建被互斥挡住了;用户在系统设置里关掉实时活动也是同样的结果。perform() 一返回,fatalError,录音跟着进程一起没了。
② 距离上次前台超过一两分钟,后台起录被拒。 回归日志里,App 离开前台大约 1~2 分钟之后再从控制中心点击,setActive 报 '!pla',start audio capture failed------而且是在 perform() 挂着、授权窗口开着的时候。系统对"长时间不在前台的 App 发起录音"另有限制。
③ 麦克风从未授权。 权限弹窗只能在前台弹。
这三种情况都得"打开 App 兜底"。可问题是,openAppWhenRun 已经是 false,怎么在运行时按需打开 App?我试了两条路:
.result(opensIntent: OpenURLIntent(...)):在控制中心 Toggle 语境下,系统不执行它。 日志确认perform()已经返回了 opensIntent,App 没有被打开。- iOS 26 的
continueInForeground():能打开,但如果在perform()还没结束时直接调,App 前台化后didBecomeActive里的setActive(true)和挂起请求的兑现,会赶在异步契约检查之前把"session active + 无 Live Activity"重新造出来------照样 fatalError。
最后能工作的顺序是:宿主先 abort(停掉录音、去激活 session),再回信结算原因;Intent 收到"需要前台"的原因后才调 continueInForeground()。前台化后的兑现入口也要守住:Live Activity 权限关着的时候只做引导、不起录。
swift
let reason = await BackgroundStartGate.dispatchAndWait { postStartCommand() }
if [.permissionRequired, .liveActivityMissing, .retryInForeground].contains(reason) {
if #available(iOS 26.0, *) {
// 此刻宿主已 abort:录音已停、session 已去激活,契约条件不会被重新制造
try? await continueInForeground(nil, alwaysConfirm: false)
}
// iOS 18.x:没有运行时前台化手段,只能挂起到用户下次进 App 时兑现
}
挂起请求还要加一个时效。真机上出现过:后台起录失败、App 又没被拉起,用户 87 秒后自己打开 App,结果被自动开始录音。如果没有上限,几小时后打开也会这样。我们把时效定为 60 秒------设计内的自动兑现都是秒级,分钟级、小时级的兑现一律丢弃。一个"迟到的正确操作"对用户来说就是一次惊吓。
八、坑 7:冷启动的时序,第一次点击会丢
症状:App 被杀死后,从控制中心点击,第一次没反应;32 秒后再点,成功了。
ini
05:20:02 [Control][Voice] intent dispatch start=true
← 之后没有任何 start= 记录
05:20:34 [Control][Voice] start=YES result=OK
根因 :用户点击 → 系统冷启动主 App 进程 → perform() 发出同进程通知。可这时 didFinishLaunching 还没跑到注册观察者那一行,通知发出去没人听,静默丢失。
我原本是留了兜底的:Intent 在发通知之前,先往 App Group 写一个"待执行指令"位。但这个指令位只在 UIApplicationDidBecomeActiveNotification 里消费------而这个需求的全部意义就是不打开 App ,所以 didBecomeActive 永远不会来。
解法 :在观察者注册完成的那一刻,主动 drain 一次指令位。幂等由"先清后返回 + 30 秒时效窗"保证,和 didBecomeActive 那一处不会重复执行。
objc
static void VSRegisterControlObservers(void) {
[[NSNotificationCenter defaultCenter] addObserver:... name:kVoiceStart ...];
// 通知可能早于这里发出:补一次,把"通知早于监听"的窗口填上
VSDrainControlPendingActionIfNeeded();
}
openAppWhenRun = true 的另一类 Control 也有同样的时序陷阱。我在真机上确认过,它确实会把 App 推到前台,但日志顺序是 ENTER → app not foreground yet → EXIT。也就是说,perform() 执行的那一瞬间,App 已经冷启动了,但还没到前台 。在 perform() 里直接调 startPictureInPicture() 这类必须在前台才能发起的操作,一定会失败,只能挂起到 didBecomeActive 再兑现。
九、坑 8:录音成功,列表里却没有
症状 :后台录音"看起来成功"(start=YES result=OK),停止后工作室列表里没有这条录音。
这一次我没有去推理,直接用 devicectl 把设备沙盒拉下来:
Documents/audioRecord/xxx.m4a存在,约 13KB;afconvert能解码,说明 moov 写得完整,AVAudioRecorder已经正确 finalize;- 1.44 秒、44.1kHz 单声道 AAC,RMS 不为零------有真实声音;
- Core Data 里没有对应的实体;
- 控制台有一行
SQLite: unable to close due to unfinalized statements。
链路是"录音 ✅ → 文件落盘 ✅ → 入库 ❌",失败点精确落在 Core Data。
根因 :停止方向的 perform() 派发完通知就返回。系统认为 Intent 执行完毕,关闭 audio session,App 失去 UIBackgroundModes=audio 的保活,进程被挂起 。可主队列上的 insert + save 还没跑完。文件之所以完整,是因为 AVAudioRecorder 的 finalize 是同步的,而 Core Data 的 save 不是。那行 SQLite 报错,正是数据库还没干净收尾、进程就被冻结的痕迹。
解法 :用 beginBackgroundTask 显式申请一段后台时间,覆盖"停止 → 入库"全程。
objc
static UIBackgroundTaskIdentifier sSaveTask = NSUIntegerMax; // 见下方说明
static void EndSaveTask(void) {
if (sSaveTask == UIBackgroundTaskInvalid) return;
UIBackgroundTaskIdentifier t = sSaveTask;
sSaveTask = UIBackgroundTaskInvalid; // 先置位再 end,防重入
[[UIApplication sharedApplication] endBackgroundTask:t];
}
static void BeginSaveTask(void) {
if (sSaveTask != UIBackgroundTaskInvalid) return; // 连点不重复申请
sSaveTask = [[UIApplication sharedApplication]
beginBackgroundTaskWithExpirationHandler:^{ EndSaveTask(); }];
}
有三个细节:
- 申请点必须在
stopAudioRecord之前。停止会关 session,停完之后就没有保活了,再申请可能根本来不及执行。 - 释放点挂在"入库完成"的通知上,而不是"停止完成"。
- 静态初始值写
NSUIntegerMax而不是UIBackgroundTaskInvalid。后者是 UIKit 的 extern 运行时常量,不是编译期常量,拿它做 C 静态变量的初始值会报initializer element is not a compile-time constant。两者的取值是相同的。
这里还有一个判断标准上的教训。Spike 阶段我对"能否录到音"的验收标准,原文写的是"录音数据完整落地并可被主 App 读取 "。探针只验了前半句(文件大小、电平),就判了通过。验收标准写了两句,取证就必须覆盖两句。
十、诚实地说:你可能不需要这些
把上面八个坑踩完,得到的是"控制中心点一下,不打开 App,后台录音"。代价是:一个需要挂住 perform() 的闸门、三种结算原因、iOS 18 和 iOS 26 两套兜底,外加一个有时效的挂起请求。
如果你的产品能接受"点击后打开 App",直接用 openAppWhenRun = true :perform() 跑在主进程,App 会到前台,前台起录不受任何后台限制,AudioRecordingIntent 的契约也不用管。唯一要注意的就是第八节那条:"perform() 执行时 App 还没到前台"。这条路简单到不值得写一篇文章。
值得走后台路线的,只有"打开 App 会破坏场景本身"的需求。比如开会时悄悄开始录音、边播音乐边录------这时候跳一下界面就是功能失败。还有一种情况也不值得硬上:你的底层录音引擎是一个你改不动的二进制,而它在后台起录有自己的限制。这种时候先确认引擎能不能在授权窗口里同步起录,确认不了就别承诺"不打开 App"。
还有一条现实约束:这套方案在 iOS 18.x 上没有运行时前台化的手段,未授权、Live Activity 缺席这几种情况只能降级为"下次打开 App 时兑现"。所以给产品的承诺应该是"常态后台静默,异常时引导",而不是"永远不打开 App"。
十一、比 API 更值钱的:Spike 为什么会骗人
回头看这两次错判:
| 结论 | 看上去的证据 | 实际缺陷 | |
|---|---|---|---|
| 第一次 | 可行(假阳性) | 探针后台录到 82KB 有效音频 | 探针用 AVAudioRecorder、录完即关 session;生产用 AudioQueue、持续录音 |
| 第二次 | 不可行(假阴性) | AudioQueueStart 后台稳定失败,项目旧注释也这么说 |
起录发生在授权窗口之外,且 conformance 被一并删掉了 |

我从中提炼出三条,它们跟 App Intents 无关,适用于任何"先验证、再承诺"的技术预研:
1. Spike 验证的是"一条代码路径",不是"一种能力"。 "iOS 能不能后台录音"这个问题没有意义。有意义的问题是:"我这条从 perform() 到 AudioQueueStart 的具体路径,在这个时机、这个进程里能不能跑通"。探针只要换了 API、换了时机、换了生命周期,结论就不能外推。
2. 否定结论同样需要复现和归因。 我们天然更警惕"说能行",因为说能行要兑现;对"说不行"就宽容得多,因为说不行就不用做了。可一个错误的否定结论,代价是一个本可以做好的功能被砍掉,而且几乎没人会回头去查。现象复现了不等于归因对了,归因要单独验证。
3. 观测工具先自证,再作证。 斜杠进了文件名、plutil 改写了原文件,这两次都是诊断手段本身在制造假象。在工具输出之上下结论之前,先用一个已知答案(canary 文件、一次已知成功的调用)跑一遍,确认工具说的是真话。
控制中心上那一个小小的圆形按钮,背后是这样一段来回。我觉得这段来回比最终那份代码更值得留下来。
示例代码由生产代码简化而来,未逐行编译验证;系统行为均为真机实测记录,iOS 版本差异请以你的目标系统实测为准。