大家好,我最近开发了一款App《SleepDiary(睡眠声音日记)》。

作为一款睡眠监测类 App,核心业务逻辑可以用一句话概括:
"录一整夜的音,把打呼噜和说梦话的片段摘出来,最后生成睡眠报告。 "
看似简单,但在工程实现上却困难重重:
- 隐私与成本问题:长达 8 小时的音频绝对不能一整段传到服务器端,这不仅会直接把你的服务器带宽跑破产,还会被用户骂死(谁敢把在卧室一整夜的录音全传到网上?)。
- 性能与功耗问题:放在端侧跑模型,势必要使用长时间的后台保活,如何避免手机发热和 OOM (Out Of Memory)?
经过最近这段时间的研究,我用 AVFoundation + Core ML + SwiftData 的纯血原生技术栈把这套流程跑通了。今天就和大家分享一下我的实现思路与踩坑日记。
一、端侧的 AI:硬核从零训练自己的鼾声分类模型
最初的设计方案很简单粗暴:开个录音,每秒去判断分贝,超过阈值就保存。但这完全不行,深夜翻身的声音、空调声、外面的汽车声都会被误判。
市面上现成的声音分类模型要么太大(动辄上百MB),要么对"鼾声"、"梦话"这种特定场景不够敏锐。于是我决定硬核一点------自己动手,从收集数据开始训练一个专用的轻量级神经网络(SnoreWave.mlpackage)。
1.1 数据收集与模型训练
为了让模型足够精准,我花了大量时间收集开源数据集并结合自己实录的各种"打雷级"打呼声(最终 1.2w 条数据)。 把杂乱的音频转换成模型能"看懂"的输入是第一步------将音频流转化为梅尔频谱图(Mel-spectrogram) 。这相当于将一维的声音信号,变成了二维的图像图像特征,然后再喂给我用深度学习框架搭建的 CNN(卷积神经网络)进行分类。
模型训练收敛后,我依靠 coremltools 将其转换为了 Apple 原生支持的 .mlpackage。为了控制 App 包体积并保证低功耗运转,这个模型被我极致压缩,剥离了非必要分支,达到了极高的预测效率。
1.2 AVAudioEngine 实时截流送显
有了自己的模型,下一步就是在 iOS 端跑通流式推理。 我们不使用高层的 AVAudioRecorder,而是使用 AVAudioEngine。因为它允许我们通过 installTap 在音频流经过的过程中"截胡"到 AVAudioPCMBuffer。
然后在端侧把这个 Buffer 原样转化成模型需要的数组输入:
swift
// 截胡音频流的伪代码
let inputNode = audioEngine.inputNode
let format = inputNode.inputFormat(forBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] (buffer, time) in
// 捕获到音频帧后,交给我们自定义的分类器管线
self?.audioCaptureService.processAudioBuffer(buffer)
}
audioEngine.prepare()
try audioEngine.start()
1.3 降维打击 OOM 崩溃:用 Actor 隔离模型生命周期
坑点来了!如果每次截取到一个 buffer,都在主线程或者随机的 Dispatch Queue 去实例化这个自定义模型进行预测,一晚上下来你的 App 必因为内存暴涨被系统强制 Kill 掉(Jetsam Event)。
解法:引入 Swift Actor 隔离与复用机制
在《睡眠声音日记》中,我是用全局唯一的 Actor 来维持模型的单一生命周期,使用环形缓冲区去缓存几秒钟的声音片断,组合后一次性输出:
swift
swift
actor EventDetectionPipeline {
// 全局唯一持有我们自己训练好的模型实例
private let model = try? SnoreWaveformCNN(configuration: MLModelConfiguration())
func processAudioWindow(_ window: AudioWindow) async {
// 将音频转化成梅尔频谱所需的 MLMultiArray
guard let multiArray = window.toMLMultiArray() else { return }
// 发起端侧离线推理
if let prediction = try? model?.prediction(input: multiArray) {
if prediction.classLabel == "snore" {
// 命中目标:触发存储!
await persistCapturedEvent(label: .snore)
}
}
}
}
通过自己训练轻量级模型 + Actor 的串行数据处理,保证了模型资源的极致释放。即便后台连续疯狂推理 8 个小时,CPU 的平均占用率也能被压在极低的水平,用户即使整晚充着电,手机也完全不发烫。
二、存储的艺术:音频文件与 SwiftData 模型分离
识别完事件后,怎么持久化? 这引发了第二个大问题------千万别把音频这种大块二进制流全都写进 SwiftData 或者 Core Data!
2.1 相对路径是王道
我的存储策略是:结构化数据走 SwiftData(打点时间、标签量化数据),音频文件走沙盒原生写入 。 在《睡眠声音日记》的 SleepEventRecord 模型中,我只存了一个相对路径(filePath)。
swift
@Model
final class SleepEventRecord {
var timestamp: Date
var duration: TimeInterval
var eventLabel: EventLabel // .snore, .speech, .cough
var filePath: String? // 只存相对路径: "20240315/snore_0234.m4a"
init(timestamp: Date, eventLabel: EventLabel) {
self.timestamp = timestamp
self.eventLabel = eventLabel
}
}
为什么要相对路径? 因为沙盒路径(UUID)在每次应用重签或重新安装时是会变的。如果存绝对路径,第二天文件全找不到了!读取时,永远使用 FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent(filePath) 动态拼装。
2.2 防治 iCloud 把服务器挤爆
录了一晚上的高音质 M4A 文件,如果不加限制,系统的 iCloud 备份会自动把它们全传上去。用户那可怜的 5GB iCloud 很快就会爆满。因此,我在写入音频文件后,立马用原生 API 给文件打上"拒绝备份"的 Tag:
swift
var url = documentDirectoryURL.appendingPathComponent(fileName)
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true // 保护用户的 iCloud 空间!
try url.setResourceValues(resourceValues)
三、私有 CloudKit 的优雅同步体验
音频不用同步了,但我们的 SleepSessionRecord(当晚评分,鼾声次数统计,分析数据)需要跨设备(尤其是和 Apple Watch 联动时)和防止在用户删掉 App 重装后丢失。
以往做 Core Data + CloudKit 繁琐得让人想死。但在 iOS 17 的 SwiftData 下,它变成了真正的"优雅":我们甚至可以动态控制它的开启闭合。
我把开关值存到了 NSUbiquitousKeyValueStore(KVS),根据这个从远程同步过来的用户偏好,动态初始化 ModelContainer:
swift
// 在 SleepDiaryApp.swift 入口处动态配置容器
var sharedModelContainer: ModelContainer = {
let schema = Schema([SleepSessionRecord.self, SleepEventRecord.self])
// 读取 UserDefaults/KVS 的 iCloud 开关
let isCloudSyncEnabled = UserDefaults.standard.bool(forKey: "iCloudSyncEnabled")
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
// 如果开启,赋予 private 数据库标识;如果不开启,设为 .automatic 或本地优先
cloudKitDatabase: isCloudSyncEnabled ? .private("iCloud.xxxxx") : .none
)
do {
return try ModelContainer(for: schema, configurations: [configuration])
} catch {
fatalError("Could not create ModelContainer: (error)")
}
}()
依靠云端大容量配额下的 .private 标识,只要用户打开 iCloud 同步,他们在换了新手机后重新下载 App,所有的睡眠数据历史记录就会像魔法一样哗哗哗回到列表中。
四、写在最后
开发《睡眠声音日记》的这段时间里,我最大的感触是:苹果的原生护城河真的很香。 只依靠一套 Swift 兵器库:从 SwiftUI 的丝滑动画绘制、到 HealthKit 获取深度睡眠的联动、再到 Core ML 的底层加速,这是以往杂糅其它中间件完全得不到的性能优势和开发爽感。
感兴趣的同行们,可以在 App Store 搜 "睡眠声音日记-SleepDiary" 下载把玩一下,有任何架构或技术点上的建议,大家评论区见,或者私下找我交流。也欢迎吐槽!