跳转至正文

如何在 Capacitor 中实现后台音频播放

在 Capacitor 中后台播放音频:iOS 音频后台模式、Android 前台服务、锁屏控制、播放列表和中断处理。

在 Capacitor 应用中实现带锁屏控件的后台音频播放

要在 Capacitor 中实现后台音频播放,需要两个原生组件: audio iOS 的后台模式,以及 mediaPlayback Android 上的前台服务。通过 media session 添加锁屏控制,用户锁屏或切换应用时,音乐、播客或冥想应用仍能继续播放。本指南展示 Capacitor 8 上的两套配置,包括 @capgo/capacitor-native-audio 用于原生播放,以及 @capgo/capacitor-media-session 用于系统控制。

后台音频为什么会停止

Capacitor 应用是基于 WebView 的原生壳,因此会遵循各平台的生命周期规则:

iOS Android
后台会发生什么 应用会在几秒内被挂起 Activity 被暂停,WebView 定时器被节流,进程可能被终止
音频如何保持播放 UIBackgroundModes 包含 audio,音频会话类别 playback 类型为……的前台服务 mediaPlayback 并带有通知
静音开关 playback 类别会忽略它, ambient 会遵循它 无对应项
锁屏控制 MPNowPlayingInfoCenter 以及 MPRemoteCommandCenter MediaSession 带有媒体样式通知
商店规则 必须播放用户期望听到的内容(guideline 2.5.4) 前台服务类型必须与实际用途匹配,并在 Play Console 中声明

两个平台默认都不支持后台音频,只修复其中一端是“iPhone 正常,Android 停止”这类 bug 报告最常见的原因。

选择你的播放方案

有两种不错的架构:

  1. 原生播放,使用 @capgo/capacitor-native-audio:音频在 iOS 上由 AVFoundation 解码,在 Android 上由 Media3 ExoPlayer 解码。最适合音乐播放器、有声书、冥想应用,以及任何 WebView 可能被节流的情况。它支持本地文件、远程 URL、HLS 流、淡入淡出、变速和 Now Playing 元数据。
  2. HTML <audio> 加 @capgo/capacitor-media-session:保留现有的 Web 播放器,让插件将元数据和控件发布到操作系统。在 Android 上,它还会在会话播放期间运行前台服务。适用于已有 Web 播放器或需要为 Web 和原生端使用单一 code 路径的场景。
@capgo/capacitor-native-audio <audio> + 媒体会话
播放引擎 原生 WebView
Web 端支持 是(HTML 音频回退) 是
锁屏元数据 showNotification + notificationMetadata setMetadata
Android 前台服务 你启动一个 插件在播放或暂停时启动它
远程控制事件 playbackState 监听器 setActionHandler
精准低延迟音效 是 有限

配置 iOS 后台音频播放

打开 ios/App/App/Info.plist 并添加后台模式。你也可以在 Xcode 的 Signing &amp; Capabilities > Background Modes 下勾选 “Audio, AirPlay, and Picture in Picture”,这会写入相同的键值:

<key>UIBackgroundModes</key>
<array>
  <string>audio</string>
</array>

音频会话类别(Audio Session Category)也很重要。 @capgo/capacitor-native-audio 会帮你设置好:使用 showNotification: true 它使用 .playback 使用默认模式,这会中断其他应用(Spotify 会暂停),并让你的应用成为“正在播放”应用。使用 showNotification: false 它使用 .playback 配合 mixWithOthers,它适合音效,但不会显示锁屏控制。每个应用选一种,而不是每首曲目选一种。

如果你通过 <audio>,WebKit 会在媒体开始播放时激活播放会话。请确保首次播放由用户手势触发,否则 iOS 会拦截。

在上传到 App Store Connect 之前,请使用 Xcode 26 或更高版本进行 build,这是自 2026 年 4 月起的要求。 Capgo 构建 如果你在 Windows 或 Linux 上工作,可以在云端完成。

配置 Android 后台音频

将权限添加到 android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

FOREGROUND_SERVICE_MEDIA_PLAYBACK 对于面向 Android 14 (API 34) 或更高版本的应用是必需的,而 Capacitor 8 面向 API 36。没有它,启动 service 会抛出 SecurityException。发布时,Play Console 会要求你声明 foreground service 类型并说明理由。“用户启动的媒体播放”是符合要求的用途。

POST_NOTIFICATIONS 是 Android 13+ 上的运行时权限。media session 通知不受通知权限限制,仍会显示在通知栏,但你的 service 发送的其他通知都需要该权限。

Android 12+ 会阻止应用在后台时启动 foreground service。请在用户点击播放、且你的 activity 可见时启动它。

方案 A:让 media session plugin 来运行 service

@capgo/capacitor-media-session 声明一个 mediaPlayback service 在其自身的 manifest 中,并在你将播放状态设置为 playing 或 paused时启动它。当状态变回 none 且应用处于后台时停止它。如果你希望该 service 在整个应用会话期间运行,请在 capacitor.config.ts:

plugins: {
  MediaSession: {
    foregroundService: 'always',
  },
},

中设置插件配置。

默认情况下,仅在播放期间启动,这是大多数应用所需的行为。

@capgo/capacitor-native-audio 选项 B:启动你自己的前台 service

android/app/src/main/java/com/example/app/PlaybackService.java:

package com.example.app;

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import androidx.core.app.NotificationCompat;

public class PlaybackService extends Service {
    private static final String CHANNEL_ID = "playback";

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            NotificationChannel channel = new NotificationChannel(
                CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW);
            getSystemService(NotificationManager.class).createNotificationChannel(channel);
        }
        Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("Playing audio")
            .setSmallIcon(R.mipmap.ic_launcher)
            .setOngoing(true)
            .build();

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
        } else {
            startForeground(1, notification);
        }
        return START_NOT_STICKY;
    }

    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }
}

PlaybackServicePlugin.java 在同一个包中:

package com.example.app;

import android.content.Intent;
import androidx.core.content.ContextCompat;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;

@CapacitorPlugin(name = "PlaybackService")
public class PlaybackServicePlugin extends Plugin {
    @PluginMethod
    public void start(PluginCall call) {
        ContextCompat.startForegroundService(getContext(), new Intent(getContext(), PlaybackService.class));
        call.resolve();
    }

    @PluginMethod
    public void stop(PluginCall call) {
        getContext().stopService(new Intent(getContext(), PlaybackService.class));
        call.resolve();
    }
}

在 MainActivity.java 在……之前 super.onCreate,并在 manifest 中声明该服务:

public class MainActivity extends BridgeActivity {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        registerPlugin(PlaybackServicePlugin.class);
        super.onCreate(savedInstanceState);
    }
}
<service
    android:name=".PlaybackService"
    android:exported="false"
    android:foregroundServiceType="mediaPlayback" />

从 TypeScript 调用:

import { Capacitor, registerPlugin } from '@capacitor/core';

interface PlaybackServicePlugin {
  start(): Promise<void>;
  stop(): Promise<void>;
}

export const PlaybackService = registerPlugin<PlaybackServicePlugin>('PlaybackService');

export const isAndroid = Capacitor.getPlatform() === 'android';

使用 @capgo/capacitor-native-audio 原生播放音频

bun add @capgo/capacitor-native-audio
bunx cap sync

启动时配置一次,然后预加载并播放:

import { NativeAudio } from '@capgo/capacitor-native-audio';
import { PlaybackService, isAndroid } from './playback-service';

await NativeAudio.configure({
  focus: true,               // request audio focus, pause others
  background: true,          // keep playing in the background
  backgroundPlayback: true,  // Android: skip auto pause on background
  showNotification: true,    // lock screen and notification controls
});

export async function playEpisode(id: string, url: string, title: string, artwork: string) {
  await NativeAudio.preload({
    assetId: id,
    assetPath: url,
    isUrl: true,
    notificationMetadata: {
      title,
      artist: 'My Podcast',
      artworkUrl: artwork,
    },
  });

  if (isAndroid) await PlaybackService.start();
  await NativeAudio.play({ assetId: id });
}

export async function stopEpisode(id: string) {
  await NativeAudio.stop({ assetId: id });
  await NativeAudio.unload({ assetId: id });
  if (isAndroid) await PlaybackService.stop();
}

assetPath 接受相对于 Web 资源的路径(audio/intro.mp3)、 file:// URL、 https:// URL 或 HLS .m3u8 流。对于非打包资源,请设置 isUrl: true。如果音频主机需要身份验证,请传入 headers.

构建一个自动播放的播放列表

本指南中介绍的 complete 当曲目结束时触发 event,即使屏幕已锁定,因为原生播放器仍在运行:

const queue = [
  { id: 'ep1', url: 'https://cdn.example.com/ep1.mp3', title: 'Episode 1' },
  { id: 'ep2', url: 'https://cdn.example.com/ep2.mp3', title: 'Episode 2' },
];
let index = 0;

await NativeAudio.addListener('complete', async ({ assetId }) => {
  await NativeAudio.unload({ assetId });
  index += 1;
  if (index < queue.length) {
    const next = queue[index];
    await NativeAudio.preload({
      assetId: next.id,
      assetPath: next.url,
      isUrl: true,
      notificationMetadata: { title: next.title, artist: 'My Podcast' },
    });
    await NativeAudio.play({ assetId: next.id });
  } else if (isAndroid) {
    await PlaybackService.stop();
  }
});

在曲目之间保持前台服务运行。如果在播放完一首歌后停止服务,并尝试在后台重新启动,Android 12+ 会抛出 ForegroundServiceStartNotAllowedException.

让 UI 与锁屏保持同步

用户可能会在锁屏、耳机或通知中按下暂停。请监听 playbackState 并将原生播放器视为唯一事实来源:

await NativeAudio.addListener('playbackState', (event) => {
  // event.state: 'playing' | 'paused' | 'stopped'
  // event.reason: 'play', 'pause', 'remotePlay', 'complete', ...
  store.setPlaying(event.isPlaying);
  if (event.currentTime !== undefined) store.setPosition(event.currentTime);
});

await NativeAudio.addListener('currentTime', ({ currentTime }) => {
  store.setPosition(currentTime); // every 100 ms while playing
});

应用回到前台时,也使用以下方式刷新状态 isPlaying 以及 getCurrentTime因为在事件发出期间,WebView 可能已被暂停。

使用带 media session 控制的 HTML audio 元素

如果你已有 Web 播放器,保留它并添加该插件:

bun add @capgo/capacitor-media-session
bunx cap sync
import { MediaSession } from '@capgo/capacitor-media-session';

const audio = new Audio();
audio.preload = 'auto';

export async function play(track: { url: string; title: string; artist: string; cover: string }) {
  audio.src = track.url;
  await audio.play();

  await MediaSession.setMetadata({
    title: track.title,
    artist: track.artist,
    artwork: [{ src: track.cover, sizes: '512x512', type: 'image/jpeg' }],
  });
  await MediaSession.setPlaybackState({ playbackState: 'playing' });
}

await MediaSession.setActionHandler({ action: 'play' }, async () => {
  await audio.play();
  await MediaSession.setPlaybackState({ playbackState: 'playing' });
});
await MediaSession.setActionHandler({ action: 'pause' }, async () => {
  audio.pause();
  await MediaSession.setPlaybackState({ playbackState: 'paused' });
});
await MediaSession.setActionHandler({ action: 'seekto' }, (details) => {
  if (details.seekTime != null) audio.currentTime = details.seekTime;
});
await MediaSession.setActionHandler({ action: 'nexttrack' }, () => playNext());

audio.addEventListener('timeupdate', () => {
  MediaSession.setPositionState({
    duration: audio.duration || 0,
    position: audio.currentTime,
    playbackRate: audio.playbackRate,
  });
});

调用 setPositionState 在生产环境中,每秒最多更新一两次。锁屏会在更新之间插值,而频繁向 bridge 发送 timeupdate 事件会浪费电量。

处理中断和路由变化

电话、Siri、闹钟和其他应用会中断播放。Native audio 会在中断时暂停,在 iOS 上,系统认为应该恢复时会恢复。还有两种情况需要注意:

  • 耳机被拔出:用户期望播放暂停。在 Android 上,请注册一个 receiver 来监听 AudioManager.ACTION_AUDIO_BECOMING_NOISY 在你的 service 中(如果你的播放器尚未自动暂停)。在 iOS 上,请使用以下方式监听路由变化: @capgo/capacitor-audio-session:
import { AudioSession, RouteChangeReasons } from '@capgo/capacitor-audio-session';

await AudioSession.addListener('routeChanged', (reason) => {
  if (reason === RouteChangeReasons.OLD_DEVICE_UNAVAILABLE) {
    pausePlayback();
  }
});

await AudioSession.addListener('interruption', (type) => {
  // 'began' or 'ended'
});
  • Ducking 与暂停:导航提示会压低你的音频。语音类应用应改为暂停,因为被压低的语音很难听清。

测试后台播放

模拟器无法真实反映后台行为。请在真机上测试:

  1. 开始播放,锁定屏幕,等待两分钟。音频必须继续播放。
  2. 使用锁屏上的播放、暂停、下一首和进度控制,然后解锁并检查 UI 是否同步。
  3. 播放后切换到另一个播放音频的应用,再切回来。
  4. 播放时发起电话或 FaceTime 音频通话。
  5. 在 Android 上,开启省电模式,并在三星或小米设备上测试。部分 OEM 会激进地终止后台应用。前台服务可以存活,而普通后台任务则不行。
  6. 在 Android 13+ 上,拒绝通知权限,并确认播放仍然正常。
  7. 让播放列表在屏幕关闭的情况下连续播放多首曲目。

adb shell dumpsys activity services | grep -A3 PlaybackService 显示你的服务是否在前台运行。

故障排除

iOS 上音频在约 30 秒后停止: UIBackgroundModes 缺少 audio,或会话类别为 ambient 或 soloAmbient的文件夹。请检查 Info.plist 在构建后的应用中,而不仅仅是源代码里。

Android 上音频播放几分钟后停止:没有前台服务,或服务类型不是 mediaPlayback。同时检查 OEM 电池优化设置。

SecurityException: Starting FGS with type mediaPlayback ... requires permissions:添加 FOREGROUND_SERVICE_MEDIA_PLAYBACK.

ForegroundServiceStartNotAllowedException:在应用处于后台时启动了服务。请在用户点击播放时启动。

iOS 上无锁屏控制: showNotification 为 false(可混合会话),或未设置元数据。只有拥有不可混合会话的应用才会成为“正在播放”应用。

Android 上出现两个通知:你已启用 showNotification 在 native audio 中,并且也发布了你自己的 service notification。请保持 service notification 尽可能精简,或仅使用 media session 插件的 service。

其他音乐应用会覆盖你的应用继续播放:你配置了 mixable session。请设置 focus: true 以及 showNotification: true.

更快发布播放器改动

播放器的队列逻辑、UI、元数据和分析都在 JavaScript 里。一旦上面的原生配置进入商店 build, Capgo 实时更新 就能把修复推送到那个 code,无需再走一轮审核。 原生音频文档 以及 媒体会话文档 涵盖了所有选项;如果你的应用还要录制音频,请参阅 Capacitor 音频录制插件.

为 Capacitor 应用提供 Live Updates

当 Web 层出现线上 Bug 时,通过 Capgo 发布修复,而不用等 App Store 审核好几天。用户会在后台收到更新,原生变更则仍走正常审核流程。

博客最新动态

Capgo 为你提供打造真正专业的移动应用所需的最有价值的洞察。