播放运行时与跨端生命周期
本页说明 Hydrogen Music 在 Android 端如何把 Capacitor/JavaScript 播放状态连接到原生媒体会话、通知栏控制和前台服务,并覆盖应用退出这一类由前端触发的生命周期操作。
Purpose and Scope
本页聚焦播放运行时的 Android 原生边界:MediaNotificationPlugin 如何创建并维护 MediaSession,如何接收通知栏、锁屏和媒体按键动作并回传前端,以及 MediaPlaybackService 如何通过前台服务保持媒体通知和播放进程的可用性。同时说明 AppControlPlugin.exitApp() 的退出语义,以及这些组件在 AndroidManifest.xml 中的声明和权限。
播放列表、音频解码器、前端播放器状态机、下载实现和本地音乐扫描不在本页展开;这些属于独立的业务或数据能力。由于当前可见源码材料只覆盖了原生生命周期边界,本页不会推断未读取的前端调用方、具体音频引擎或完整通知构建细节。
Overview
Android 端的播放运行时采用“Capacitor 插件负责媒体状态与事件、Service 负责前台存活”的分工:
MediaNotificationPlugin暴露MediaNotification插件,接收show、setPlaying、setProgress等 JS 调用,并维护标题、艺术家、专辑、曲目标识、时长、位置和播放状态。- 插件在
load()中创建 AndroidMediaSession,注册BroadcastReceiver,并将播放、暂停、上一首、下一首等动作通过notifyListeners("mediaAction", data, true)送回前端。 MediaPlaybackService不自行创建通知;它读取同进程中由插件写入的静态currentNotification,在onStartCommand()中调用startForeground(),从而让系统将应用视为正在进行媒体播放。- 服务返回
START_STICKY,但停止时在onDestroy()中显式移除前台通知;这避免清空播放后媒体卡片仍由前台服务粘住。 AppControlPlugin将退出决策留给前端,实际在 UI 线程调用finishAndRemoveTask(),并在失败时退回finish()。
Architecture
Sources:
图中的关系直接对应已读取的原生实现:插件持有 MediaSession 和 BroadcastReceiver,服务只接管前台通知生命周期;两者通过同进程静态 MediaPlaybackService.currentNotification 衔接。AndroidManifest.xml 将服务声明为 foregroundServiceType="mediaPlayback",并声明网络、通知和媒体前台服务权限。
设计边界与原因
通知功能被设计成“增强能力”而不是 App 启动的硬依赖。MediaNotificationPlugin.load() 对初始化整体捕获 Throwable,失败时只记录错误;这意味着 WebView 或 Android 媒体 API 差异不会直接阻止应用启动。相反,一旦用户实际播放,前台服务承担的是系统生命周期职责:普通 NotificationManager.notify() 不足以保证后台进程和媒体卡片持续存在,因此服务使用 startForeground() 注册当前通知。
核心生命周期与控制流
1. 插件加载阶段
MediaNotificationPlugin.load() 首先取得插件上下文;在 Android 5.0(LOLLIPOP)及以上创建名为 HydrogenMusic 的 MediaSession,安装回调并激活会话。回调把系统媒体操作转换成插件内部的 emit() 调用:onPlay()、onPause()、onSkipToNext() 和 onSkipToPrevious() 分别映射为 play、pause、next 和 previous。
随后插件设置 FLAG_HANDLES_MEDIA_BUTTONS 与 FLAG_HANDLES_TRANSPORT_CONTROLS。这一步不是装饰性配置:源码注释明确指出,若不声明能力,通知栏按钮和媒体/蓝牙按键不会被转交给该会话。最后,插件注册四个显式 action 的 BroadcastReceiver;Android 13 及以上使用 Context.RECEIVER_NOT_EXPORTED,旧版本使用兼容的两参数重载。
2. 显示或刷新媒体通知
show(PluginCall) 读取标题、艺术家、专辑和 playing 状态;曲目变化时重置 positionMs 和 metadataDurationMs,避免系统控件继续使用上一首歌的播放位置。时长通过私有 readLong() 读取,而不是直接假设 Capacitor 桥返回 Java Long:实现兼容 Number 和可解析字符串,并在异常时使用 fallback。
封面 URL 变化时调用 loadCover(nextCover),之后设置 visible = true、调用 updateNotification() 并 resolve 调用。若过程出现异常,插件使用 call.reject("显示媒体通知失败: ...") 将失败返回给 JS,而不是吞掉调用结果。
setPlaying(PluginCall) 只更新播放布尔值;只有通知已可见时才刷新原生通知。setProgress(PluginCall) 将位置规范化为不小于零的值,并在不可见、没有 MediaSession 或系统版本低于 Lollipop 时直接 resolve。这种短路使“进度推送”可以由前端周期性调用,而在通知尚未建立时不会产生额外的原生错误。
3. 前台服务阶段
服务的 onStartCommand() 不读取 Intent 参数,也不创建通知。它检查静态 currentNotification 是否为空;有通知时用固定的 MediaNotificationPlugin.NOTIFICATION_ID 调用 startForeground(),随后返回 START_STICKY。因此,通知构建责任留在插件,服务只承担系统要求的前台托管责任。
服务是非绑定服务:onBind() 固定返回 null。销毁时按 API 版本选择 stopForeground(STOP_FOREGROUND_REMOVE) 或旧版的 stopForeground(true),确保前台服务注册的通知随服务移除。startForeground() 和清理过程都捕获 Throwable 并写入标准错误日志,说明实现优先保证宿主生命周期不因通知操作异常而崩溃。
Source: MediaNotificationPlugin.java
Source: MediaPlaybackService.java
原生入口与系统声明
AndroidManifest.xml 将 MainActivity 设置为 singleTask、竖屏并允许多项配置变化;这为宿主 Activity 的生命周期提供了稳定入口。媒体服务声明为非导出(exported="false"),并标记 foregroundServiceType="mediaPlayback",使其只由应用自身使用,同时满足 Android 14+ 对媒体前台服务类型的声明要求。
相关权限包括:
| 权限或声明 | 源码用途 |
|---|---|
android.permission.INTERNET | 封面 URL 等网络访问所需;具体网络加载实现位于插件中。 |
android.permission.POST_NOTIFICATIONS | Android 13+ 通知权限声明。 |
android.permission.FOREGROUND_SERVICE | 使用前台服务的基础权限。 |
android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK | 媒体播放类型的前台服务权限。 |
foregroundServiceType="mediaPlayback" | 将 MediaPlaybackService 标识为媒体播放前台服务。 |
1<service
2 android:name=".MediaPlaybackService"
3 android:enabled="true"
4 android:exported="false"
5 android:foregroundServiceType="mediaPlayback" />
6
7<uses-permission android:name="android.permission.INTERNET" />
8<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
9<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
10<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />Source: AndroidManifest.xml
使用示例
从前端显示媒体通知
插件注释给出的 JS 合约是 MediaNotification.show({title,artist,album,coverUrl,playing})、setPlaying({playing}) 和 hide()。当前读取范围中没有前端调用文件,因此下面只引用原生侧实际处理的 Capacitor 参数读取代码,不虚构前端调用方。
1@PluginMethod
2public void show(PluginCall call) {
3 try {
4 title = call.getString("title", "");
5 artist = call.getString("artist", "");
6 album = call.getString("album", "");
7 playing = Boolean.TRUE.equals(call.getBoolean("playing", Boolean.FALSE));
8 String nextMediaId = call.getString("mediaId", "");
9 if (nextMediaId != null && !nextMediaId.isEmpty() && !nextMediaId.equals(mediaId)) {
10 positionMs = 0;
11 metadataDurationMs = 0;
12 mediaId = nextMediaId;
13 }
14 long nextDuration = readLong(call, "duration", -1L);
15 if (nextDuration > 0) durationMs = nextDuration;
16 visible = true;
17 updateNotification();
18 call.resolve();
19 } catch (Throwable error) {
20 call.reject("显示媒体通知失败: " + error.getMessage());
21 }
22}Source: MediaNotificationPlugin.java
回传后台媒体动作
通知按钮和媒体按键最终都会进入 emit()。keepAlive=true 是关键行为:源码明确要求应用退到后台时仍能把动作交回前端。
1private void emit(String action) {
2 try {
3 System.out.println("[MediaNotification] 收到动作 -> " + action);
4 JSObject data = new JSObject();
5 data.put("action", action);
6 notifyListeners("mediaAction", data, true);
7 System.out.println("[MediaNotification] 已回传前端: " + action);
8 } catch (Throwable error) {
9 System.err.println("[MediaNotification] 事件回传失败: " + error.getMessage());
10 }
11}Source: MediaNotificationPlugin.java
前台服务挂载与销毁
1@Override
2public int onStartCommand(Intent intent, int flags, int startId) {
3 try {
4 if (currentNotification != null) {
5 startForeground(MediaNotificationPlugin.NOTIFICATION_ID, currentNotification);
6 }
7 } catch (Throwable error) {
8 System.err.println("[MediaPlaybackService] startForeground 失败: " + error.getMessage());
9 }
10 return START_STICKY;
11}
12
13@Override
14public void onDestroy() {
15 try {
16 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
17 stopForeground(STOP_FOREGROUND_REMOVE);
18 } else {
19 stopForeground(true);
20 }
21 } catch (Throwable error) {
22 System.err.println("[MediaPlaybackService] 摘除前台通知失败: " + error.getMessage());
23 }
24 super.onDestroy();
25}Source: MediaPlaybackService.java
Source: MediaPlaybackService.java
API 参考
MediaNotificationPlugin.show(PluginCall call): void
显示或刷新媒体通知。读取 title、artist、album、playing、mediaId、duration 和 coverUrl;新 mediaId 会将位置归零;成功后 resolve,异常时 reject。源码未声明 Java 返回值以外的 checked exception,内部以 Throwable 兜底。
MediaNotificationPlugin.setPlaying(PluginCall call): void
读取 playing 布尔值并更新通知。若当前 visible 为 false,只更新状态而不创建通知;成功 resolve,异常 reject。
MediaNotificationPlugin.setProgress(PluginCall call): void
读取毫秒级 position 与可选 duration。负位置归零;当通知不可见、会话为空或系统低于 Android 5.0 时直接成功返回。源码注释说明该方法用于前端按秒级节流持续推送,从而让 PlaybackState 的位置和速度可用于系统媒体卡片。
MediaNotificationPlugin.load(): void
初始化 MediaSession 与动作接收器。媒体 API 初始化和接收器注册异常会被记录,不向外抛出,以保证通知增强能力不会阻断 App 启动。
MediaPlaybackService.onStartCommand(Intent, int, int): int
若 currentNotification 非空,以固定通知 ID 8801 调用 startForeground(),并始终返回 START_STICKY。Intent、flags 和 startId 当前不参与分支判断。
MediaPlaybackService.onDestroy(): void
按系统版本移除前台状态和通知,然后调用 super.onDestroy()。
AppControlPlugin.exitApp(PluginCall call): void
先 resolve Capacitor 调用,再切换到 UI 线程执行 finishAndRemoveTask();如果该操作失败,退回 finish()。退出是当前 Activity/任务的关闭,不应被描述为停止音频引擎,因为读取到的实现没有直接操作播放器或服务停止逻辑。
退出与跨端生命周期
AppControlPlugin 的职责很窄:它没有引入官方 @capacitor/app,而是为前端提供一个确定的 AppControl.exitApp() 原生入口。先 resolve 再异步结束 Activity,避免前端调用因 Activity 关闭而无法收到完成响应。finishAndRemoveTask() 优先移除任务;兼容性异常时使用 finish(),因此最坏情况下仍能关闭当前 Activity。
1@PluginMethod
2public void exitApp(PluginCall call) {
3 call.resolve();
4 getActivity().runOnUiThread(() -> {
5 try {
6 getActivity().finishAndRemoveTask();
7 } catch (Throwable ignored) {
8 getActivity().finish();
9 }
10 });
11}Source: AppControlPlugin.java
数据与状态关系
当前源码显示,通知运行时状态集中在 MediaNotificationPlugin 的实例字段中,而前台服务只持有一份静态 Notification 引用。关键状态包括:
- 内容:
title、artist、album、mediaId、coverUrlLoaded。 - 播放进度:
durationMs、positionMs。 - 渲染优化:
metadataDurationMs,用于避免每秒重建带位图的 metadata。 - 可见性:
playing和visible。 - 系统桥接:
mediaSession、actionReceiver、单线程executor。
Sources:
失败模式、边界条件与并发
已实现的失败隔离
MediaSession、接收器注册和通知能力初始化统一捕获Throwable,失败只输出错误日志;这把媒体通知从启动关键路径中隔离出来。show、setPlaying、setProgress对 Capacitor 调用异常使用call.reject()或直接 resolve,避免异常穿透到 JS 桥。emit回传失败只记录错误,不让系统广播回调崩溃。startForeground与stopForeground都有独立兜底日志,服务仍按既定生命周期返回或调用父类。- Android 版本差异显式处理:Lollipop 保护
MediaSession,Android 13 保护 receiver 注册,Nougat 保护stopForeground重载。
边界值
readLong()对缺失值使用 fallback;duration只有大于 0 时才覆盖当前时长。setProgress()将负位置改为 0。mediaId非空且发生变化才触发切歌重置;相同或空标识不会重置位置。setPlaying()在通知不可见时不刷新系统通知。onStartCommand()在currentNotification == null时不会调用startForeground(),但仍返回START_STICKY。源码未显示谁在何时填充该静态字段,调用顺序应由未读取的完整通知构建流程保证。
并发与性能
插件显式创建 Executors.newSingleThreadExecutor(),表明封面或通知相关后台工作采用单线程执行器;当前读取范围不足以确认其完整提交、关闭和异常处理策略,因此不能进一步推断任务调度语义。源码同时维护 metadataDurationMs,避免每秒因进度刷新而重建包含位图的 metadata;这减少系统侧刷新和封面重复处理。
跨组件共享的 currentNotification 是静态字段,依赖“插件和服务在同一进程”的前提。源码没有看到同步锁或 volatile 声明,因此多线程可见性和服务启动时序应作为运维排查点;本文不将其推断为线程安全设计。
配置与运维要点
| 配置项 | 类型 | 当前值 | 作用 |
|---|---|---|---|
MediaNotificationPlugin.CHANNEL_ID | String | hydrogen_music_playback | 通知渠道标识。 |
MediaNotificationPlugin.NOTIFICATION_ID | int | 8801 | 前台服务使用的通知 ID。 |
android:foregroundServiceType | Manifest 属性 | mediaPlayback | 声明媒体播放前台服务类型。 |
android:exported(服务) | Manifest 属性 | false | 禁止其他应用直接导出并启动该服务。 |
android:usesCleartextTraffic | Manifest 属性 | true | 允许应用明文流量;该设置来自 Manifest,但当前材料没有证明具体播放或封面请求是否使用明文 URL。 |
排查通知不出现或按钮无响应时,应按以下顺序核对:权限是否满足、MediaNotificationPlugin.load() 是否成功、MediaSession 是否 active、通知是否已经使 visible=true、currentNotification 是否在服务启动前被写入,以及服务是否确实进入 onStartCommand()。排查通知残留时,应确认走到了 MediaPlaybackService.onDestroy() 的 stopForeground(...REMOVE) 分支,而不能只调用普通通知取消。
Extension Points
扩展媒体动作时,需要同时修改系统入口和前端事件语义:在 MediaSession.Callback 或 BroadcastReceiver 中增加实际 action 映射,再通过 emit() 保持统一的 mediaAction 事件回传。扩展通知内容时,应沿用 show() 对曲目变化、时长转换和封面 URL 变化的边界处理;不要把每秒进度更新设计成每秒重建完整 metadata,因为现有字段 metadataDurationMs 已明确承担该优化。
修改服务时,应保持服务与通知插件的职责分离:服务负责 startForeground/stopForeground,插件负责通知和媒体会话。若需要改变停止策略,还必须重新验证前台服务注册通知的移除行为。
相关链接
测试文件、完整前端调用点以及音频播放器实现未在本页读取范围内发现,因此相关保证和集成细节应在对应的播放器或测试目录文档中补充。