要在 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 报告最常见的原因。
选择你的播放方案
有两种不错的架构:
- 原生播放,使用
@capgo/capacitor-native-audio:音频在 iOS 上由 AVFoundation 解码,在 Android 上由 Media3 ExoPlayer 解码。最适合音乐播放器、有声书、冥想应用,以及任何 WebView 可能被节流的情况。它支持本地文件、远程 URL、HLS 流、淡入淡出、变速和 Now Playing 元数据。 - 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 & 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 与暂停:导航提示会压低你的音频。语音类应用应改为暂停,因为被压低的语音很难听清。
测试后台播放
模拟器无法真实反映后台行为。请在真机上测试:
- 开始播放,锁定屏幕,等待两分钟。音频必须继续播放。
- 使用锁屏上的播放、暂停、下一首和进度控制,然后解锁并检查 UI 是否同步。
- 播放后切换到另一个播放音频的应用,再切回来。
- 播放时发起电话或 FaceTime 音频通话。
- 在 Android 上,开启省电模式,并在三星或小米设备上测试。部分 OEM 会激进地终止后台应用。前台服务可以存活,而普通后台任务则不行。
- 在 Android 13+ 上,拒绝通知权限,并确认播放仍然正常。
- 让播放列表在屏幕关闭的情况下连续播放多首曲目。
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 音频录制插件.