You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

iOS调音器应用:如何合理强制ObservableObject更新视图?

调音器视图更新问题优化方案

问题背景

做了一个调用iPhone内置麦克风的调音器,将检测到的频率作为调音数据。为了做Mock测试,定义了TunerConductorProtocol协议来切换真实麦克风采集的调音器和Mock调音器,同时用TunerViewModel来管理调音器实例并供视图观察。但现在必须在ViewModel里加定时器(修改一个无用的@Published属性)才能触发视图更新,否则调音器在后台队列运行时视图不会刷新,想知道这种方式是否合理,有没有更优的强制更新方案。

核心问题分析

当前用定时器触发更新的方式本质是通过修改ViewModel的@Published属性间接发送视图刷新通知,属于冗余且低效的 workaround。根本原因有两个:

  1. 真实调音器和Mock调音器中的published_pitch只是普通变量,不是@Published属性,即使协议继承了ObservableObject,也不会自动发送属性变更通知。
  2. 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.17 02:20:35