iOS调音器应用:如何合理强制ObservableObject更新视图?
问题背景
做了一个调用iPhone内置麦克风的调音器,将检测到的频率作为调音数据。为了做Mock测试,定义了TunerConductorProtocol协议来切换真实麦克风采集的调音器和Mock调音器,同时用TunerViewModel来管理调音器实例并供视图观察。但现在必须在ViewModel里加定时器(修改一个无用的@Published属性)才能触发视图更新,否则调音器在后台队列运行时视图不会刷新,想知道这种方式是否合理,有没有更优的强制更新方案。
核心问题分析
当前用定时器触发更新的方式本质是通过修改ViewModel的@Published属性间接发送视图刷新通知,属于冗余且低效的 workaround。根本原因有两个:
- 真实调音器和Mock调音器中的
published_pitch只是普通变量,不是@Published属性,即使协议继承了ObservableObject,也不会自动发送属性变更通知。 - ViewModel中的
conductor是协议类型(any TunerConductorProtocol),SwiftUI无法直接观察协议类型内部的ObservableObject变化,需要手动转发变更事件。
优化方案
方案1:转发Conductor的变更事件到ViewModel
让ViewModel监听Conductor的objectWillChange事件,当Conductor内部属性变化时,ViewModel主动发送自己的objectWillChange通知,触发视图更新。
步骤1:修正协议与实现类的属性
把published_pitch改为@Published属性,确保Conductor内部变化会发送通知:
// 协议定义:继承ObservableObject,要求有published_pitch属性 protocol TunerConductorProtocol: ObservableObject, HasAudioEngine { var published_pitch: Float { get } var amplitude: Float { get } } // 真实调音器实现 class TunerConductor: TunerConductorProtocol { @Published var published_pitch: Float = 0.0 // 改为@Published @Published var amplitude: Float = 0.0 let engine = AudioEngine() let initialDevice: Device let mic: AudioEngine.InputNode let tappableNodeA: Fader let silence: Fader var tracker: PitchTap! init() { guard let input = engine.input else { fatalError("无法获取音频输入") } guard let device = engine.inputDevice else { fatalError("无法获取音频设备") } initialDevice = device mic = input tappableNodeA = Fader(mic) silence = Fader(tappableNodeA, gain: 0) engine.output = silence tracker = PitchTap(mic) { pitch, amp in DispatchQueue.main.async { self.update(pitch[0], amp[0]) } } tracker.start() do { try engine.start() } catch { print("音频引擎启动失败:\(error)") } } func update(_ pitch: AUValue, _ amp: AUValue) { guard amp > 0.1 else { return } published_pitch = pitch amplitude = amp } } // Mock调音器实现 class MockTunerConductor: TunerConductorProtocol { @Published var published_pitch: Float = 0.0 // 改为@Published @Published var amplitude: Float = 0.0 let engine = AudioEngine() private var timer: Timer? init() { published_pitch = 50 amplitude = 1.3 timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in guard let self = self else { return } self.published_pitch += 1.0 } } }
步骤2:修改ViewModel监听Conductor的变更
去掉冗余定时器,在ViewModel中监听Conductor的objectWillChange并转发:
import Combine class TunerViewModel: ObservableObject { let conductor: any TunerConductorProtocol private var cancellable: AnyCancellable? init(tunerConductor: any TunerConductorProtocol) { self.conductor = tunerConductor // 监听Conductor的变更事件,转发到ViewModel的objectWillChange cancellable = conductor.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() } } }
步骤3:视图直接使用ViewModel中的Conductor属性
视图无需修改,依然可以直接访问conductorVm.conductor.published_pitch:
struct TunerView: View { @StateObject var conductorVm: TunerViewModel var body: some View { VStack { HStack { Text("频率") Spacer() Text("\(conductorVm.conductor.published_pitch, specifier: "%0.1f") Hz") } .padding() .font(.title) HStack { Text("音量") Spacer() Text("\(conductorVm.conductor.amplitude, specifier: "%0.2f")") } .padding() .font(.title) } } }
方案2:在ViewModel中包装Conductor的属性
另一种方式是把Conductor的published_pitch包装成ViewModel自己的@Published属性,通过监听Conductor的变化来更新:
class TunerViewModel: ObservableObject { let conductor: any TunerConductorProtocol @Published var pitch: Float = 0.0 private var cancellable: AnyCancellable? init(tunerConductor: any TunerConductorProtocol) { self.conductor = tunerConductor self.pitch = conductor.published_pitch // 监听Conductor的published_pitch变化,更新ViewModel的pitch cancellable = conductor.$published_pitch.assign(to: \.pitch, on: self) } }
此时视图需要改为访问ViewModel的pitch属性:
struct TunerView: View { @StateObject var conductorVm: TunerViewModel var body: some View { VStack { HStack { Text("频率") Spacer() Text("\(conductorVm.pitch, specifier: "%0.1f") Hz") } .padding() .font(.title) } } }
完整项目配置要求
- Info.plist:添加
Privacy - Microphone Usage Description权限描述 - 依赖库:导入AudioKit、AudioKitEX、SoundpipeAudioKit
- Application Scene Manifest:设置
Enable Multiple Windows为No,配置默认Scene Delegate为$(PRODUCT_MODULE_NAME).SceneDelegate - Build Settings:添加Other Linker Flags
-lstdc++
内容的提问来源于stack exchange,提问作者Rick

