播放按钮已经变成了暂停图标,扬声器却没有声音。遇到这种界面,先别急着调音量:按钮点击只表达用户意图,播放器进入播放状态才表达执行结果。把两者绑在一个布尔变量上,资源解析、异步准备和生命周期变化都会从缝隙里冒出来。声笺播放器从一段本地音频起步,先把这些时序收进一个可检查的引擎,再逐步加入系统播控、进度拖动、后台运行与焦点处理。

声笺播放器的音频状态技术封面

一、先把声音当成资源,而不是按钮效果

本系列使用 Stage 模型和 API 12 及以上工程,播放器来自 Media Kit。这里依赖的创建、准备、播放、暂停与释放接口早于 API 12 已开放;选择这个基线,是为了统一后续页面和会话代码,不意味着所有接口都从这一版本新增。官方 AVPlayer 接口 给出了每个调用允许的状态,工程实现必须沿着这些约束组织。

同一个引擎只持有一个播放器实例、一个音源句柄和一组观察者。页面不保存另一套播放事实,只订阅快照。页面发出播放请求后,引擎等待系统状态事件,再让页面更新按钮。这多出来的一层不是为了把几行调用包装得更漂亮,而是为后续接入耳机按键、系统播控和后台任务准备唯一控制入口。

本地资源适合先验证生命周期。它不受网络连接、证书和服务端重定向影响,出现准备失败时,排查范围能够收敛到资源、格式和调用时序。随后替换成网络地址,需要另外声明网络权限并考虑连接中断;不能用一段本地播放成功推导流媒体链路已经可靠。当前页面只支持一段音频,暂时也没有切歌竞争,因此不会提前引入播放列表、缓存目录与复杂下载器。

资源放在 entry/src/main/resources/rawfile/focus.wav。为了让示例不依赖外部素材,可以在工程根目录运行下面的 Python 脚本,生成十秒低幅度提示音。它是输入资源制作步骤,不是播放性能测试。

from pathlib import Path
import math, struct, wave
out = Path('entry/src/main/resources/rawfile/focus.wav')
out.parent.mkdir(parents=True, exist_ok=True)
rate, seconds = 16000, 10
with wave.open(str(out), 'wb') as stream:
    stream.setnchannels(1)
    stream.setsampwidth(2)
    stream.setframerate(rate)
    for n in range(rate * seconds):
        fade = min(1.0, n / 1600, (rate * seconds - n) / 1600)
        sample = int(1800 * fade * math.sin(2 * math.pi * 220 * n / rate))
        stream.writeframesraw(struct.pack('<h', sample))

应用读取自己的打包资源不需要申请麦克风权限,也不需要读取整个媒体库。资源管理器给出的描述包含文件句柄、偏移与长度,三者应一起传给播放器。把偏移删掉、把长度当成音频毫秒数,都会破坏资源边界。句柄交给播放器后也不再拿去执行其他读写操作,释放顺序保持播放器在先、资源句柄在后。

有一个容易误判的细节:时长只有在资源解析到合适阶段后才有意义。刚创建实例时读到的初始值,不应直接拿来计算百分比,否则页面可能先出现无效进度,再突然跳到正常位置。快照先用零表示尚无可展示的时长,准备完成后再接收播放器返回的数据。这个零是界面默认值,不是对资源长度的测量结论。

示例把资源内容保持固定,也是为了隔离变量。验证音量时使用同一段音频,验证退出时不同时更换音源,验证暂停时不再叠加网络断开。每次只改变一个条件,事件序列才有解释价值。将来换成长音频,可以沿用同一套检查顺序;将来换成直播源,则应重新定义是否可拖动、是否有总时长,不能继续照搬当前页面的百分比模型。

二、让异步状态驱动引擎

状态机里面最容易被忽视的是准备阶段。创建实例得到的是空闲播放器;设置音源后才进入初始化状态;此时调用准备方法,准备完成后才能播放。连续写下三个调用并不能替代状态到达。我们把准备动作放进初始化事件,把播放动作留给用户,并通过同一条命令链处理播放和暂停,避免快速点击导致两个异步操作交叉。

下面是 entry/src/main/ets/service/AudioEngine.ets 的完整基础实现。后续各篇都在这个类上增量扩展;快照、订阅和命令入口保持一致。公开快照与底层播放器分离,观察者拿到的是一份新的状态对象,不能通过修改页面字段反过来改变播放器。

import { media } from '@kit.MediaKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

export class AudioSnapshot {
  state: string = 'idle';
  currentMs: number = 0;
  durationMs: number = 0;
  error: string = '';
}

type Observer = (value: AudioSnapshot) => void;

export class AudioEngine {
  private player?: media.AVPlayer;
  private context?: common.UIAbilityContext;
  private opened: boolean = false;
  private closing: boolean = false;
  private loadTask?: Promise<void>;
  private commands: Promise<void> = Promise.resolve();
  private observers: Set<Observer> = new Set<Observer>();
  private value: AudioSnapshot = new AudioSnapshot();

  snapshot(): AudioSnapshot {
    const next = new AudioSnapshot();
    next.state = this.value.state;
    next.currentMs = this.value.currentMs;
    next.durationMs = this.value.durationMs;
    next.error = this.value.error;
    return next;
  }

  subscribe(observer: Observer): () => void {
    this.observers.add(observer);
    observer(this.snapshot());
    return () => { this.observers.delete(observer); };
  }

  private emit(): void {
    this.observers.forEach((observer: Observer) => observer(this.snapshot()));
  }

  private fail(error: BusinessError): void {
    if (this.closing) { return; }
    this.value.error = `${error.code}: ${error.message}`;
    this.emit();
  }

  private enqueue(action: () => Promise<void>): Promise<void> {
    this.commands = this.commands.then(action).catch((error: BusinessError) => {
      this.fail(error);
    });
    return this.commands;
  }

  open(context: common.UIAbilityContext): Promise<void> {
    if (this.loadTask) { return this.loadTask; }
    this.context = context;
    this.loadTask = this.load(context).catch((error: BusinessError) => {
      this.fail(error);
    });
    return this.loadTask;
  }

  private async load(context: common.UIAbilityContext): Promise<void> {
    const player = await media.createAVPlayer();
    this.player = player;
    if (this.closing) { return; }
    player.on('stateChange', (state: string) => {
      if (this.closing) { return; }
      this.value.state = state;
      this.value.currentMs = Math.max(0, player.currentTime);
      if (state === 'prepared') {
        this.value.durationMs = Math.max(0, player.duration);
      }
      this.emit();
      if (state === 'initialized') {
        this.enqueue(async () => {
          if (!this.closing && player.state === 'initialized') {
            await player.prepare();
          }
        });
      }
    });
    player.on('timeUpdate', (time: number) => {
      if (this.closing) { return; }
      this.value.currentMs = Math.max(0, time);
      this.emit();
    });
    player.on('error', (error: BusinessError) => this.fail(error));
    const source = await context.resourceManager.getRawFd('focus.wav');
    this.opened = true;
    if (this.closing) { return; }
    player.fdSrc = { fd: source.fd, offset: source.offset, length: source.length };
  }

  play(): Promise<void> {
    return this.enqueue(async () => {
      const player = this.player;
      if (this.closing || !player) { return; }
      if (['prepared', 'paused', 'completed'].includes(player.state)) {
        await player.play();
      }
    });
  }

  pause(): Promise<void> {
    return this.enqueue(async () => {
      const player = this.player;
      if (!this.closing && player?.state === 'playing') {
        await player.pause();
      }
    });
  }

  async close(): Promise<void> {
    if (this.closing) { return; }
    this.closing = true;
    await this.loadTask;
    await this.commands;
    const player = this.player;
    try {
      if (player) {
        player.off('stateChange');
        player.off('timeUpdate');
        player.off('error');
        await player.release();
      }
    } catch (error) {
      console.error(`release failed: ${(error as BusinessError).code}`);
    } finally {
      this.player = undefined;
      if (this.opened && this.context) {
        try {
          await this.context.resourceManager.closeRawFd('focus.wav');
        } catch (error) {
          console.error(`closeRawFd failed: ${(error as BusinessError).code}`);
        }
      }
      this.opened = false;
      this.observers.clear();
    }
  }
}

错误事件和异步异常必须都有人接住。前者来自播放器内部,后者来自调用本身;只处理其中一种,页面仍可能永远停在等待状态。这里把错误放进快照,页面能够明确显示失败原因。释放异常单独记录,资源句柄清理仍然执行,避免一次失败让后续清理全部被跳过。记录时不输出用户文件内容,后续接入网络地址也不应把带签名的完整链接写入日志。

关闭标记解决的是另一个时间缝隙:页面离开时,资源句柄可能还没获取回来。先标记关闭,再等待资源加载和已排队命令结束,最后统一释放,能够避免晚到的初始化回调重新准备音源。这个引擎实例关闭后不重复打开;重新进入播放业务时创建新实例,让所有权边界保持清楚。

AudioNote 工程中的状态回调与右侧播放页面

命令链的目的也需要说清楚:它负责维持应用发出调用的先后关系,并不把所有系统事件变成同步过程。初始化事件、时间更新和错误通知仍由系统异步到达。每个排队动作执行时再次读取播放器状态,相当于在真正操作前重新确认前提;如果只在入队时检查,等待期间状态已经改变,旧判断仍然可能把非法调用送进去。

观察者集合允许后续增加媒体会话订阅,而不必覆盖页面已有回调。取消订阅只移除当前观察者,不会释放播放器;释放引擎则清除所有观察者和资源。这两个动作对应不同的所有权层级。区分它们以后,页面离开、媒体会话解绑、整个播放业务结束就不再需要争夺同一个模糊的销毁方法。

三、页面只显示事实,不猜测播放结果

页面放在 entry/src/main/ets/pages/Index.ets,并保留 Empty Ability 模板中的默认页面配置。订阅在页面出现时建立,页面离开时先取消观察,再关闭引擎。现在声音属于这个页面;第四篇加入后台播放时,这个所有权会明确上移到 Ability 生命周期,不能一边希望后台继续,一边在页面消失时释放播放器。

import { common } from '@kit.AbilityKit';
import { AudioEngine, AudioSnapshot } from '../service/AudioEngine';

@Entry
@Component
struct Index {
  @State snapshot: AudioSnapshot = new AudioSnapshot();
  private engine: AudioEngine = new AudioEngine();
  private unsubscribe?: () => void;

  aboutToAppear(): void {
    this.unsubscribe = this.engine.subscribe((value: AudioSnapshot) => {
      this.snapshot = value;
    });
    this.engine.open(getContext(this) as common.UIAbilityContext);
  }

  aboutToDisappear(): void {
    this.unsubscribe?.();
    this.engine.close();
  }

  build() {
    Column({ space: 20 }) {
      Text('声笺播放器').fontSize(28).fontWeight(FontWeight.Bold)
      Text('本地音频 · 状态机起步').fontColor('#607580')
      Text('专注音景').fontSize(24).margin({ top: 40 })
      Text(this.snapshot.state).fontColor('#177B75')
      Text(`${Math.floor(this.snapshot.currentMs / 1000)} 秒 / ` +
        `${Math.floor(this.snapshot.durationMs / 1000)} 秒`)
      Button(this.snapshot.state === 'playing' ? '暂停' : '播放')
        .enabled(['prepared', 'playing', 'paused', 'completed']
          .includes(this.snapshot.state))
        .onClick(() => {
          if (this.snapshot.state === 'playing') { this.engine.pause(); }
          else { this.engine.play(); }
        })
      if (this.snapshot.error.length > 0) {
        Text(this.snapshot.error).fontColor('#C14444')
      }
    }
    .width('100%').height('100%').padding(28).backgroundColor('#F3F8F7')
  }
}

按钮禁用只是交互反馈,引擎里面的状态判断仍然不可省略。以后耳机按键不会经过这个页面,系统播控也不会关心按钮是否禁用;真正的调用门槛必须留在引擎。反过来,按钮也不应被命令返回值直接驱动,因为用户需要看到的是当前播放事实,而不是某次请求曾经被接受。

声笺播放器准备完成时的完整竖屏界面

四、用事件顺序验收第一条播放链路

先使用生成的本地音源,观察准备完成后按钮才可点击;点击播放,页面状态应进入播放中;暂停后时间停止推进,再次播放应接着当前位置继续;到达末尾后进入完成状态。这里的状态文本直接使用事件值,便于开发阶段检查。产品化时可以映射成中文,但映射只能改变文案,不能改变底层状态含义。

接着做两组逆向操作。第一组是在准备阶段立即离开页面,确认离开后没有继续播放,也没有已销毁页面被回调更新。第二组是在播放和暂停之间连续点击,核对最终状态由最后一个有效操作决定,不出现按钮显示与声音相反。测试记录应同时保留动作、状态事件和错误码,单独截一张带播放图标的图片不能证明整个链路成立。

空闲、初始化、准备、播放、暂停与释放的状态关系

如果没有声音,按资源是否存在、描述边界是否完整、状态是否进入准备、播放请求是否被接受、设备输出路径是否正确逐层检查。不要先加延时等待;固定等待只是把偶发问题换成另一种偶发问题。状态不到达就不执行,错误出现就收敛到可见反馈,这才是能够移植到不同设备和不同音源的约束。

这一版还有明确边界:它不处理播放列表切换,不持久化播放进度,也不承诺页面离开后继续播放。这些能力涉及不同的所有权和恢复语义,应当在底座稳定之后分别接入。下一篇会把播放、暂停入口交给系统媒体会话,同时继续以播放器事件作为状态依据。到那时,页面按钮、锁屏操作和耳机按键才能共享同一条可追踪的控制链。

最后检查一次资源重复进入场景:在播放页准备完成后退出,再重新进入,必须使用新的引擎实例重新获取资源描述,不能复用已经释放的播放器。若工程的导航策略保留组件实例,就应把创建引擎的位置放到业务进入入口,并配套新的订阅,而不是假设每次出现都会重新构造页面。示例使用单页模板,扩展导航时需要显式确定这一点。

从性能角度看,这一阶段没有必要用更高频的定时器补齐时间更新。播放器已有进度事件,额外计时器只会制造第二套时间来源;应用切后台后计时器的调度节奏也未必保持。先保证事件含义、状态所有权和释放顺序清晰,再讨论渲染频率与采样策略,优化才有能够验证的起点。

参考资料

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐