使用Web Audio时Angular变更检测失效问题求助
我来帮你搞定这个头疼的问题!你遇到的核心矛盾其实是Web Audio API的回调不在Angular的变更检测上下文里,导致状态更新了但UI没反应——而你测试的"fake-events"是在Angular自己的上下文里触发的,所以一切正常。
问题根源
Web Audio的线程是独立于浏览器主线程的,它的onended回调以及播放启动后的状态更新,都跑在Zone.js(Angular用来跟踪变更的工具)的监控范围之外。哪怕你在组件里调用了markForCheck(),也没法让Angular察觉到这些外部线程里的状态变化,自然就不会更新UI了。
解决方案:用NgZone把操作拉回Angular上下文
我们可以用Angular提供的NgZone服务,把音频相关的状态更新逻辑包裹在zone.run()方法里,强制这些操作回到Angular的变更检测范围内,这样UI就能跟着状态同步更新了。
修改后的代码示例
1. 先更新你的音频服务(AudioService)
注入NgZone,然后在状态变更和onended回调里用上它:
import { Injectable, NgZone } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class AudioService { private audioCtx?: AudioContext; private audioSrc?: AudioBufferSourceNode; private playingSubject = new BehaviorSubject<boolean>(false); public playing$: Observable<boolean> = this.playingSubject.asObservable(); private playing = false; constructor(private zone: NgZone) {} public playBlob(data: Blob) { if (!this.audioCtx) { this.audioCtx = new AudioContext(); } const fr: FileReader = new FileReader(); fr.onload = () => { this.audioCtx.decodeAudioData(<ArrayBuffer>fr.result, (res) => { this.play(res); }); }; fr.readAsArrayBuffer(data); } private play(audioBuff: AudioBuffer) { // 先停止可能正在播放的音频 if (this.audioSrc) { this.stopPlay(); } this.audioSrc = this.audioCtx!.createBufferSource(); this.audioSrc.buffer = audioBuff; this.audioSrc.connect(this.audioCtx!.destination); this.audioSrc.start(0); // 用NgZone包裹状态更新,确保Angular能检测到 this.zone.run(() => { this.playing = true; this.playingSubject.next(this.playing); console.debug('audioService has set playing to true'); }); this.audioSrc.onended = () => { // 结束回调同样要放到NgZone里 this.zone.run(() => { this.playing = false; this.playingSubject.next(this.playing); console.debug('audioService set playing to false'); this.audioSrc = null; }); }; } public stopPlay() { if (this.audioSrc) { try { this.audioSrc.stop(); } catch (e) { // 忽略音频已经结束的异常 } this.zone.run(() => { this.playing = false; this.playingSubject.next(this.playing); this.audioSrc = null; }); } } public hackSetPlaying(value: boolean) { this.playing = value; this.playingSubject.next(this.playing); } }
2. 组件代码的小优化
你的组件代码其实已经没问题了,不过用了NgZone之后,甚至可以去掉this.ref.markForCheck()——因为状态更新已经在Angular的变更检测上下文里了,Zone会自动触发UI更新:
import { Component, Input, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; import { Subscription } from 'rxjs'; import { AudioService } from './audio.service'; import { DataService } from './data.service'; @Component({ selector: 'hello', template: ` <input type="checkbox" [(ngModel)]="playReal">Real playback (vs. fake-events)<br> Currently {{playing?"playing":"stopped"}} <button type="button" *ngIf="!playing" (click)="play()">play</button> <button type="button" *ngIf="playing" (click)="stop()">stop</button> `, styles: [`h1 { font-family: Lato; }`] }) export class HelloComponent implements OnInit, OnDestroy { @Input() name: string; playing: boolean = false; subscription: Subscription; playReal: boolean = true; constructor( public audio: AudioService, public dataSvc: DataService, private ref: ChangeDetectorRef ) { } ngOnInit() { this.subscription = this.audio.playing$.subscribe(value => { this.playing = value; console.debug('observer has fired. new value: ', value); // 现在可以删除这一行,NgZone已经帮我们搞定了变更检测 // this.ref.markForCheck(); }); } ngOnDestroy() { this.subscription.unsubscribe(); } play() { this.dataSvc.getAudio().subscribe( data => { if (this.playReal) { this.audio.playBlob(data); } else { this.audio.hackSetPlaying(true); } } ) } stop() { if (this.playReal) { this.audio.stopPlay(); } else { this.audio.hackSetPlaying(false); } } }
原理说明
Angular的Zone.js会自动跟踪大部分异步操作(比如setTimeout、Promise、AJAX请求等),并在这些操作完成后触发变更检测。但Web Audio的回调属于浏览器的独立音频线程,不在Zone的默认跟踪列表里。通过zone.run(),我们把这些外部线程的操作"包裹"进Angular的Zone中,让Zone能够检测到状态变化,进而自动更新UI。
内容的提问来源于stack exchange,提问作者Chris

