乐于分享
好东西不私藏

给父母做了一个报时 App:我在 iOS 后台播报这件事上踩了多少坑

给父母做了一个报时 App:我在 iOS 后台播报这件事上踩了多少坑

起因:一个来自父母的需求

父母年纪大了,有时候看不清手表,或者做着事情忘了时间。我想给他们做一个简单的 iOS App,功能很朴素:每到整点或半点,自动播报一声”现在是下午三点整”——就这么一个需求。

功能不复杂,UI 也不需要多花哨,大字体、大按钮,老人拿起手机就能看懂。我用 SwiftUI 两天就把界面和前台逻辑写好了,大时钟、整点/半点开关、活跃时间段设置,一切顺利。

问题出在后台


第一个坑:Timer 在后台停了

App 放到后台,语音报时就哑了。

原因很简单:iOS 的进程管理机制会在 App 进入后台后不久挂起进程,所有 Timer 停止运行,AVSpeechSynthesizer也自然无从调用。

我的第一反应是用 UNCalendarNotificationTrigger调度本地通知——每天预先注册好所有整点/半点的通知,到时间系统推送,App 在 didReceive里收到通知、触发语音。

这个方案在模拟器上跑得很好。

然后我装到真机上——没有任何语音,只有静静的通知横幅。


第二个坑:模拟器骗了我

排查了很久,才意识到 UNUserNotificationCenterDelegate的 didReceive回调的行为在模拟器和真机上完全不同

场景
模拟器
真机
willPresent

(前台)
通知到达时触发 ✅
通知到达时触发 ✅
didReceive

(后台)
通知到达时触发 ✅
用户点击通知才触发

真机上,didReceive只在用户主动点击通知 banner 时才会回调。系统通知到达时,如果用户没有点击,App 根本不会被唤醒。

所以之前的所有努力,在真机上是零效果。


第三个坑:BGAppRefreshTask 不精确

我转而尝试 BGTaskScheduler,用 BGAppRefreshTask定期后台唤醒 App。

配置了 Info.plist的 BGTaskSchedulerPermittedIdentifiers,注册任务,在进入后台时调度下一次唤醒时间点……然后发现一个残酷的事实:

BGAppRefreshTask 的唤醒时间由 iOS 系统决定,不是你说几点几点就几点几点的。

系统会综合电量、网络、用户习惯等因素来决定”什么时候唤醒合适”,唤醒时机可能延迟几分钟甚至十几分钟。整点报时这种场景,差一分钟都是错的。

这条路也堵死了。


最终方案:无声循环保活(Silence Loop Keep-Alive)

最终,我把问题丢给了 Cursor——就一句话:

“真机下不会后台报时,模拟器下可以。你解决下。”

就这一句提示,Cursor 直接给出了正确方案。说实话,有点震到我了。

用一段代码动态生成的无声音频,无限循环播放,骗过系统以为 App 一直在播音频,进程就不会被挂起。

原理解析

iOS 的 UIBackgroundModes: audio允许正在播放音频的 App 在后台持续运行、不被挂起。关键词是”正在播放”——只要 AVAudioSession处于活跃状态,进程就会保持运行。

那么,让它一直”在播放”就好了。

实现步骤

第一步:动态生成无声 WAV 数据

不需要打包任何音频资源文件,纯代码构造一段 0.5 秒、全零 PCM 的 WAV:

privatestaticfuncmakeSilentWavData()->Data{
let sampleRate =44100
let durationSeconds =0.5
let numSamples =Int(Double(sampleRate)* durationSeconds)
// ... 构造标准 WAV header + 全零 PCM 数据
var d =Data()
    d.append(contentsOf:"RIFF".utf8)
    d.appendLittleEndian(UInt32(36+ dataSize))
    d.append(contentsOf:"WAVE".data(using:.ascii)!)
// fmt chunk + data chunk(全零)
    d.append(Data(count: dataSize))
return d
}

第二步:用 AVAudioPlayer 无限循环播放

let data =Self.makeSilentWavData()
let player =tryAVAudioPlayer(data: data)
player.numberOfLoops =-1// -1 = 无限循环
player.volume =0.001// 极小音量,不为零(确保系统认为在播放)
silencePlayer = player
silencePlayer?.play()

第三步:设置音频会话,配合 Info.plist

tryAVAudioSession.sharedInstance().setCategory(
.playback,
    mode:.default,
    options:[.mixWithOthers]// 不打断用户的音乐
)
tryAVAudioSession.sharedInstance().setActive(true)

Info.plist里的 UIBackgroundModes需要包含 audio——这是这个方案能工作的系统级前提。

第四步:播报时暂停无声循环,播完恢复

funcspeak(text:String, completion:(()->Void)?=nil){
    silencePlayer?.pause()// 先暂停无声循环

let utterance =AVSpeechUtterance(string: text)
    utterance.voice =AVSpeechSynthesisVoice(language:"zh-CN")
    utterance.rate =0.45
    synthesizer.speak(utterance)
}

// AVSpeechSynthesizerDelegate 回调
funcspeechSynthesizer(_ synthesizer:AVSpeechSynthesizer,
                       didFinish utterance:AVSpeechUtterance){
    silencePlayer?.play()// 播完恢复无声循环
}

第五步:在 App 前后台切换时同步保活状态

funcapplicationDidEnterBackground(_ application:UIApplication){
NotificationManager.shared.syncVoiceBackgroundKeepAliveFromDefaults()
}

funcapplicationDidBecomeActive(_ application:UIApplication){
NotificationManager.shared.syncVoiceBackgroundKeepAliveFromDefaults()
}

还踩了一个真机特有的类型 Bug

上线真机调试时发现一个奇怪的问题:有时语音不播。

最终定位到:通知的 userInfo里存了一个 Bool值("useVoice": true),在模拟器上读出来是 Bool,在真机上读出来却是 NSNumber

iOS 在真机上序列化通知 userInfo时,Swift 的 Bool类型会被转换成 NSNumber,导致 as? Bool直接返回 nil

修复方法:

privatefuncboolFromUserInfo(_ value:Any?)->Bool{
guardlet value else{returnfalse}
iflet b = value as?Bool{return b }
iflet n = value as?NSNumber{return n.boolValue }
returnfalse
}

一个小细节,但让我在真机上折腾了不少时间。


整体架构回顾

最终 App 的后台播报架构:

前台运行
  └── ContentView 的 Timer(每秒触发)
       └── 检测到整点/半点 → 直接调用 speak()

进入后台
  └── applicationDidEnterBackground
       └── 启动无声循环(silencePlayer 无限播放)
            └── 进程不被挂起,Timer 继续运行
                 └── 检测到整点/半点 → speak()
                      ├── 暂停无声循环
                      ├── 播放语音
                      └── 播完后恢复无声循环

用户点击通知 banner
  └── didReceive 触发 → 也调用 speak()(保底)

一点感想

这个 App 的功能说出来就一句话,但真正做起来才发现 iOS 的后台机制远比我想的复杂。模拟器和真机的行为差异让我绕了很大的弯路;各种后台模式、任务调度 API 背后都有系统级的限制,不是你想用就能用的。

“无声循环保活”这个方案听起来有点 hack,但本质上是完全合规地使用了 audio后台模式——我们确实在播放音频,只是音量极小。音乐播放 App 也是这样保活的。

最后,App 跑在父母的手机上了。每到整点,手机会轻声说一句”现在是下午三点整”。功能很小,但他们用得挺开心。

附上我的appstore上架地址:https://apps.apple.com/cn/app/%E8%80%81%E5%B9%B4%E6%8A%A5%E6%97%B6/id6761399392,或者点阅读原文下载
欢迎下载体验


项目名:ElderClock(老年报时)
技术栈:SwiftUI + AVFoundation + UNUserNotifications
开发工具:Xcode + Cursor(AI 辅助编程)