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

SwiftUI+AudioKit iOS应用从后台打开时卡顿问题求助

iOS端AudioKit调音器后台恢复卡顿问题排查求助

我正在开发一款支持macOS和iOS的调音器应用,使用AudioKit处理音频逻辑。macOS端运行正常,但iOS端将应用置于后台后再切回前台时,约有半数概率出现卡顿,后台停留时间越长触发概率越高。

推测卡顿原因是:AudioKit的AudioEngine从后台恢复后未正常启动,导致应用无法获取音频数据,进而停止UI更新。我已尝试三种音频引擎启停方案,均未解决问题:

  • 使用.onAppear/.onDisappear控制引擎启停
  • 监听scenePhase状态切换控制引擎
  • 保持引擎持续运行

可复现问题的完整代码

import AudioKit
import SoundpipeAudioKit // PitchTap
import AudioKitEX // Fader
import AVFoundation // AVAudioSession
import SwiftUI

struct ContentView: View {
    @StateObject var td = ToneDetector()
    var body: some View {
        VStack {
            Text(td.data.note)
                .font(.system(size: 100, design: .serif))
        }
        .task {
            await PermissionsChecker.getMicrophoneAccess()
        }
        .task {
            if !td.engine.avEngine.isRunning {
                td.start()
            }
        }
    }
}

struct TunerData {
    var note = "-"
}

class ToneDetector : ObservableObject, HasAudioEngine {
    @Published var data = TunerData()
    let engine = AudioEngine()
    
    let mic: AudioEngine.InputNode
    let tappableA: Fader
    let tappableB: Fader
    let silence: Fader
    var tracker: PitchTap!
    
    let noteFrequencies: [Float] = [16.35, 17.32, 18.35, 19.45, 20.6, 21.83, 23.12, 24.5, 25.96, 27.5, 29.14, 30.87]
    let noteNamesFlats = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"]
    let noteNamesSharps = ["C", "D♭", "D", "E♭", "E", "F", "G♭", "G", "A♭", "A", "B♭", "B"]
    
    init() {
        guard let inputTmp = engine.input else { fatalError() }
        mic = inputTmp

        tappableA = Fader(mic)
        tappableB = Fader(tappableA)
        silence = Fader(tappableB, gain: 0)
        engine.output = silence
        
        PermissionsChecker.setSessionParameters()
        
        tracker = PitchTap(mic) { pitch, amp in
            DispatchQueue.main.async {
                self.update(pitch[0], amp[0])
            }
        }
        tracker.start()
    }
    
    func update(_ pitch: AUValue, _ amp: AUValue) {
        guard amp > 0.07 else { return }
        
        // Get the frequency down or up to the range that we know
        var frequency = pitch
        while frequency > Float(noteFrequencies[noteFrequencies.count - 1]) {
            frequency /= 2.0
        }
        while frequency < Float(noteFrequencies[0]) {
            frequency *= 2.0
        }
        
        // Find the known note frequency we are CLOSEST to
        var minDistance: Float = 10000.0
        var index = 0
        for possibleIndex in 0..<noteFrequencies.count {
            let distance = fabsf(Float(noteFrequencies[possibleIndex]) - frequency)
            if distance < minDistance {
                index = possibleIndex
                minDistance = distance
            }
        }
        if noteNamesFlats[index] == noteNamesSharps[index] {
            data.note = "\(noteNamesFlats[index])"
        } else {
            data.note = "\(noteNamesFlats[index]) / \(noteNamesSharps[index])"
        }
    }
}

class PermissionsChecker {
    
    static func getMicrophoneAccess() async {
        if #available(iOS 17.0, *) {
            let permission = AVAudioApplication.shared.recordPermission
            switch permission {
                case .granted: return
                case .denied: print("Microphone permission not granted.")
                case .undetermined: break
                default: break
            }
            
            await AVAudioApplication.requestRecordPermission()
        }
    }
    
    static func setSessionParameters() {
        #if os(iOS)
            do {
                Settings.bufferLength = .short
                try AVAudioSession.sharedInstance().setPreferredIOBufferDuration(Settings.bufferLength.duration)
                try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .mixWithOthers, .allowBluetooth])
                try AVAudioSession.sharedInstance().setActive(true)
            } catch {
                print("Failed to configure AVAudioSession.")
            }
        #endif
    }
}

已尝试的三种方案代码

尝试1:使用.onAppear/.onDisappear

struct ContentView: View {
    let engine = AudioEngine()
    var body: some View {
        VStack {
            ...
        }
        .onAppear() {
            engine.start()
        }
        .onDisappear() {
            engine.stop()
        }
    }
}

尝试2:监听scenePhase

struct ContentView: View {
    let engine = AudioEngine()
    @Environment(\.scenePhase) var scenePhase
    var body: some View {
        VStack {
            ...
        }
        .onChange(of: scenePhase) { oldPhase, newPhase in
            if newPhase == .active {
                if !engine.avEngine.isRunning {
                    engine.start()
                }
            } else if newPhase == .inactive {
                if engine.avEngine.isRunning {
                    engine.stop()
                }
            } else if newPhase == .background {
                if engine.avEngine.isRunning {
                    engine.stop()
                }
            }
        }
    }
}

尝试3:保持引擎持续运行

struct ContentView: View {
    let engine = AudioEngine()
    var body: some View {
        VStack {
            ...
        }
        .task {
            if !engine.avEngine.isRunning {
                engine.start()
            }
        }
    }
}

排查方向与解决思路

  1. 同步AVAudioSession与AudioEngine状态
    iOS后台时AVAudioSession可能被系统暂停,恢复时不要依赖avEngine.isRunning的判断(该值可能与实际session状态不一致)。需先重新激活session,再启动引擎,并处理激活失败的情况:

    func restartAudioEngine() {
        #if os(iOS)
            do {
                try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation)
                if !engine.isRunning {
                    try engine.start()
                    tracker?.start() // 重启PitchTap监听
                }
            } catch {
                print("重启音频引擎失败:\(error)")
            }
        #endif
    }
    
  2. 重启PitchTap监听
    后台状态下PitchTap可能被终止,仅重启引擎无法恢复音频数据监听。需在应用恢复前台时,手动重启PitchTap。

  3. 调整引擎启停策略
    避免在background/inactive状态下手动停止引擎,让系统自动暂停音频会话;恢复前台时,通过系统通知触发音频链路的重新初始化,减少手动启停带来的状态不一致问题。

  4. 优化异步启动时序
    当前的.task启动逻辑可能与权限请求、session设置存在时序冲突。需确保麦克风权限获取完成后,再启动引擎:

    .task {
        await PermissionsChecker.getMicrophoneAccess()
        if !td.engine.isRunning {
            do {
                try td.start()
            } catch {
                print("启动引擎失败:\(error)")
            }
        }
    }
    
  5. 监听音频会话通知
    注册AVAudioSession.interruptionNotification和AVAudioSession.routeChangeNotification,在收到中断结束通知时重新初始化音频组件:

    // 在ToneDetector的init中添加监听
    NotificationCenter.default.addObserver(self, selector: #selector(handleAudioInterruption(_:)), name: AVAudioSession.interruptionNotification, object: nil)
    
    @objc func handleAudioInterruption(_ notification: Notification) {
        guard let info = notification.userInfo,
              let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
              let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
        
        if type == .ended {
            restartAudioEngine()
        }
    }
    
  6. 检查AudioKit版本
    确认当前使用的AudioKit版本是否存在后台恢复的已知bug,尝试升级到最新稳定版。

内容的提问来源于stack exchange,提问作者Sophie_S

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.22 12:25:55