本文介绍AliPlayerKit事件系统的核心概念、事件分类、使用方法及最佳实践。
概念介绍
什么是事件?
事件 (Event) 是播放器运行过程中产生的状态变化或行为通知。每个事件都携带播放器 ID(playerId),用于标识事件来源,实现多播放器实例的事件隔离。
AliPlayerKit 中的事件分为两大类:
类型 | 说明 | 示例 |
状态事件 | 播放器状态变化的通知,由播放器内部产生。 |
|
命令事件 | 控制播放器行为的指令,由 UI 或外部组件产生。 |
|
所有事件都继承自 PlayerEvent 基类,保证统一的事件结构。
什么是事件系统?
事件系统 (Event System) 是用于管理事件订阅和发布的通信架构,核心组件是 PlayerEventBus。
它提供以下能力:
类型安全订阅:按事件类型订阅,编译期保证类型安全。
线程安全:支持多线程环境下的事件发布和订阅。
弱引用管理:使用弱引用存储监听器,自动清理已回收的订阅者。
事件隔离:通过
playerId实现多播放器实例的事件隔离。
通过事件系统,播放器的 UI 组件(Slot)与控制器(Controller)完全解耦:UI 只负责发送命令和订阅状态变化,不关心命令如何执行;控制器只负责处理命令和发布状态变化,不关心谁在监听。
功能特性
解决问题
组件解耦:消除组件间直接依赖,降低耦合度,提升可替换性与可测试性。
事件溯源:解决多播放器实例场景下事件来源混乱问题。
职责分离:剥离UI组件与业务逻辑混杂,明确职责边界。
核心价值
事件系统将组件通信标准化,开发者可以自主选择使用方式:
使用方式 | 说明 | 优势 |
订阅事件 | 监听播放器状态变化。 | 解耦 UI 与业务逻辑,响应式更新界面。 |
发送命令 | 控制播放器行为。 | UI 无需持有控制器引用,命令式交互。 |
自定义事件 | 扩展事件类型。 | 满足特定业务场景,保持架构一致性。 |
架构优势:
解耦:组件间通过事件通信,无直接依赖。
可测试:组件可独立测试,通过事件模拟交互。
可扩展:自定义事件类型,不修改框架代码。
线程安全:支持多线程环境,无需额外同步。
核心能力
能力 | 说明 |
类型安全订阅 | 按事件类型订阅,编译期类型检查。 |
弱引用管理 | 自动清理已回收的监听器,防止内存泄漏。 |
事件继承支持 | 订阅父类事件可接收子类事件。 |
多播放器隔离 | 通过 |
事件分类
播放事件(PlayerEvents)
播放器内部产生的状态变化事件,用于通知外部组件播放器状态。
事件 | 说明 | 携带数据 |
| 播放器准备完成。 |
|
| 首帧渲染完成。 | - |
| 播放状态变化。 |
|
| 视频尺寸变化。 |
|
| 播放进度更新。 |
|
| 播放错误。 |
|
| 开始缓冲。 | - |
| 缓冲进度。 |
|
| 缓冲结束。 | - |
| 倍速设置完成。 |
|
| 截图完成。 |
|
| 循环设置完成。 |
|
| 静音设置完成。 |
|
| 填充模式设置完成。 |
|
| 镜像设置完成。 |
|
| 旋转设置完成。 |
|
| 清晰度列表更新。 |
|
| 清晰度选择完成。 |
|
播放命令(PlayerCommand)
用于控制播放器行为的命令事件,由 UI 组件或外部代码发送。
命令 | 说明 | 参数 |
| 开始播放。 | - |
| 暂停播放。 | - |
| 切换播放/暂停。 | - |
| 停止播放。 | - |
| 重播。 | - |
| 跳转到指定位置。 |
|
| 设置播放速度。 |
|
| 截图。 | - |
| 设置循环播放。 |
|
| 设置静音。 |
|
| 设置填充模式。 |
|
| 设置镜像。 |
|
| 设置旋转。 |
|
| 切换清晰度。 |
|
手势事件(GestureEvents)
用户手势操作产生的事件,描述手势行为本身,不包含业务逻辑。
事件 | 说明 | 携带数据 |
| 单击。 |
|
| 双击。 |
|
| 长按开始。 |
|
| 长按结束。 | - |
| 水平拖动开始。 |
|
| 水平拖动更新。 |
|
| 水平拖动结束。 | - |
| 左侧垂直拖动开始。 |
|
| 左侧垂直拖动更新。 |
|
| 左侧垂直拖动结束。 | - |
| 右侧垂直拖动开始。 |
|
| 右侧垂直拖动更新。 |
|
| 右侧垂直拖动结束。 | - |
控制栏事件(ControlBarEvents)
控制栏显示状态同步事件。
事件 | 说明 |
| 显示控制栏。 |
| 隐藏控制栏。 |
| 重置自动隐藏计时器。 |
| 显示设置界面。 |
全屏事件(FullscreenEvents)
全屏模式切换事件。
事件 | 说明 |
| 切换全屏状态。 |
生命周期事件(PlayerLifecycleEvents)
播放器生命周期策略事件,用于观察播放器实例的创建、复用、销毁等行为。
事件 | 说明 |
| 创建新播放器实例。 |
| 销毁播放器实例。 |
| 复用空闲播放器实例。 |
| 命中活跃播放器实例。 |
| 播放器实例被淘汰。 |
插槽事件(SlotEvents)
插槽内部产生的事件。
事件 | 说明 |
| 顶部栏返回按钮点击。 |
基础使用
事件系统提供两种使用策略:
策略 | 说明 | 适用场景 |
订阅事件 | 监听播放器状态变化。 | UI 更新、业务逻辑响应。 |
发送命令 | 控制播放器行为。 | 用户交互、外部控制。 |
策略一:订阅事件
订阅事件需要三个步骤:获取事件总线、创建监听器、订阅事件。
public class MyActivity extends AppCompatActivity {
private PlayerEventBus mEventBus;
private PlayerEventBus.EventListener<PlayerEvents.Info> mInfoListener;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 1. 获取事件总线单例
mEventBus = PlayerEventBus.getInstance();
// 2. 创建监听器
mInfoListener = event -> {
// 处理播放进度更新
long position = event.currentPosition;
long duration = event.duration;
updateProgressUI(position, duration);
};
// 3. 订阅事件
mEventBus.subscribe(PlayerEvents.Info.class, mInfoListener);
}
@Override
protected void onDestroy() {
super.onDestroy();
// 4. 取消订阅,避免内存泄漏
if (mEventBus != null && mInfoListener != null) {
mEventBus.unsubscribe(PlayerEvents.Info.class, mInfoListener);
}
}
} 策略二:发送命令
发送命令通过事件总线post()方法实现:
// 1. 获取事件总线单例
PlayerEventBus eventBus = PlayerEventBus.getInstance();
// 2. 获取目标播放器 ID
// 【场景 A:在插槽内部】 直接继承调用机制
// String playerId = getPlayerId();
// 【场景 B:在 Activity/Fragment 中】 通过你持有的控制器获取
String playerId = mAliPlayerKitController.getPlayer().getPlayerId();
// 3. 发布命令(发布后,挂载了该 playerId 的 Controller 即会同步执行)
eventBus.post(new PlayerCommand.Play(playerId));
// 其他常用命令示例:
eventBus.post(new PlayerCommand.Pause(playerId)); // 暂停播放
eventBus.post(new PlayerCommand.Toggle(playerId)); // 切换播放/暂停状态
eventBus.post(new PlayerCommand.Seek(playerId, 30000)); // 精准跳转到 30 秒
eventBus.post(new PlayerCommand.SetSpeed(playerId, 1.5f)); // 设置为 1.5 倍速 进阶使用
如何在插槽中使用事件?
在插槽内部使用事件有两种方式:订阅事件和发送命令。
方式一:订阅事件(推荐使用 observedEvents())
BaseSlot 提供了 observedEvents() 和 onEvent() 方法,框架会自动管理订阅生命周期:
public class MySlot extends BaseSlot {
// 1. 声明要订阅的事件类型
@Override
protected List<Class<? extends PlayerEvent>> observedEvents() {
return Arrays.asList(
PlayerEvents.StateChanged.class,
PlayerEvents.Info.class
);
}
// 2. 处理事件回调
@Override
protected void onEvent(@NonNull PlayerEvent event) {
if (event instanceof PlayerEvents.StateChanged) {
PlayerEvents.StateChanged stateChanged = (PlayerEvents.StateChanged) event;
updatePlayPauseIcon(stateChanged.newState);
} else if (event instanceof PlayerEvents.Info) {
PlayerEvents.Info info = (PlayerEvents.Info) event;
updateProgress(info.currentPosition, info.duration);
}
}
} 方式二:发送命令
在插槽中通过postEvent()方法发送命令:
public class MySlot extends BaseSlot {
private void onPlayPauseClick() {
// 获取 playerId(在 onAttach 之后可用)
String playerId = getPlayerId();
if (playerId != null) {
// 发送切换播放/暂停命令
postEvent(new PlayerCommand.Toggle(playerId));
}
}
private void onSeekTo(long position) {
String playerId = getPlayerId();
if (playerId != null) {
// 发送跳转命令
postEvent(new PlayerCommand.Seek(playerId, position));
}
}
} 插槽的完整使用方法请参考插槽系统。
如何在策略中使用事件?
在策略中使用事件与插槽类似,通过 observedEvents() 和 onEvent() 方法进行订阅和处理。但策略有以下特点:
特点 | 说明 |
主要用于监控 | 策略一般只订阅事件进行分析,不发送命令。 |
需要过滤事件 | 多播放器场景下必须通过 |
访问上下文 | 通过 |
示例代码:
public class MyAnalyticsStrategy extends BaseStrategy {
@Nullable@Overrideprotected List<Class<? extends PlayerEvent>> observedEvents() {
return Arrays.asList(
PlayerEvents.StateChanged.class,
PlayerEvents.Info.class
);
}
@Overridepublic void onEvent(@NonNull PlayerEvent event) {
// 必须过滤事件来源,避免多播放器串台if (!isCurrentPlayer(event)) return;
if (event instanceof PlayerEvents.StateChanged) {
// 处理状态变化
PlayerEvents.StateChanged stateChanged = (PlayerEvents.StateChanged) event;
logState(stateChanged.newState);
} else if (event instanceof PlayerEvents.Info) {
// 处理播放进度
PlayerEvents.Info info = (PlayerEvents.Info) event;
trackProgress(info.currentPosition, info.duration);
}
}
}策略的完整使用方法请参考策略系统。
如何实现自定义事件?
当业务需要自定义事件时,可以继承 PlayerEvent 创建新的事件类型。
创建事件类。
继承
PlayerEvent并添加业务所需的数据字段:/** * 自定义播放事件 */public class MyCustomEvent extends PlayerEvent { /** * 自定义数据字段 */public final String customData; /** * 构造函数 * * @param playerId 播放器 ID * @param customData 自定义数据 */public MyCustomEvent(@NonNull String playerId, @NonNull String customData) { super(playerId); this.customData = customData; } }发布事件。
// 创建并发布事件 MyCustomEvent event = new MyCustomEvent(playerId, "custom_data"); PlayerEventBus.getInstance().post(event);订阅自定义事件。
// 订阅自定义事件 PlayerEventBus.getInstance().subscribe(MyCustomEvent.class, event -> { // 处理自定义事件 String data = event.customData; });
如何实现多播放器事件隔离?
每个事件都携带 playerId,通过判断 playerId 实现多播放器事件隔离:
// 订阅事件时过滤 playerId
mEventBus.subscribe(PlayerEvents.Info.class, event -> {
// 只处理当前播放器的事件if (mPlayerId.equals(event.playerId)) {
updateProgress(event.currentPosition, event.duration);
}
});如何批量订阅命令?
PlayerCommand 提供了 ALL_COMMANDS 数组,可用于批量订阅:
// 批量订阅所有播放命令for (Class<? extends PlayerCommand> commandClass : PlayerCommand.ALL_COMMANDS) {
mEventBus.subscribe(commandClass, mCommandListener);
}
// 批量取消订阅for (Class<? extends PlayerCommand> commandClass : PlayerCommand.ALL_COMMANDS) {
mEventBus.unsubscribe(commandClass, mCommandListener);
}最佳实践
订阅生命周期管理
场景 | 推荐做法 | 原因 |
Activity/Fragment | 在 | 避免 |
Slot 内部 | 使用 | 框架自动处理生命周期。 |
单例组件 | 使用弱引用或手动管理。 | 单例生命周期长,需主动清理。 |
线程安全
事件分发在调用线程同步执行,注意以下场景:
// 在主线程订阅,事件回调也在主线程
runOnUiThread(() -> {
mEventBus.subscribe(PlayerEvents.Info.class, event -> {
// 此回调在发布事件的线程执行// 如果在子线程发布事件,需要切换到主线程更新 UI
runOnUiThread(() -> updateUI(event));
});
});命令发送时机
命令 | 建议发送时机 | 说明 |
| 任意时刻。 | 安全命令,无副作用。 |
| 播放器 | 确保视频已加载。 |
| 播放中或暂停时。 | 确保播放器已初始化。 |
| 首帧渲染后。 | 确保有画面可截。 |
注意事项
构造函数风险:禁止在构造函数中订阅/发送事件(此时对象未初始化,存在空指针风险;
playerId尚未注入)。监听器匹配:取消订阅时需使用原监听器实例(依赖对象引用匹配机制)。
耗时操作禁止:事件回调中禁止耗时操作(事件同步分发,阻塞线程)。
高频事件性能红线:
场景:
PlayerEvents.Info(播放进度)、LoadingProgress及GestureEvents.Update等高频事件(最高数十次/秒)。要求:避免内存分配(如
new String())、重度布局刷新;建议复用对象,采用节流策略与同值过滤减少无效重绘。
示例参考
项目提供了完整的示例,位于 playerkit-examples/example-event-system。
示例功能
功能 | 说明 |
订阅播放进度 | 实时接收 |
发送播放命令 | 通过 |
事件日志展示 | 显示最近 20 条事件信息。 |
运行示例
在 Demo App 中选择 Event System 示例查看效果。
API 参考
核心接口
接口/类 | 说明 |
| 事件基类,所有事件继承此类。 |
| 命令基类,所有命令继承此类。 |
| 事件总线,管理订阅和发布。 |
| 事件监听器接口。 |
PlayerEventBus 方法
方法 | 说明 |
| 获取事件总线单例。 |
| 订阅指定类型的事件。 |
| 取消订阅指定类型的事件。 |
| 取消指定监听器的所有订阅。 |
| 发布事件。 |
| 获取指定事件类型的订阅者数量。 |
| 检查是否有订阅者。 |
| 清除所有订阅(慎用)。 |
技术原理
弱引用机制
事件总线使用 WeakReference 存储监听器,有以下优势:
自动清理:监听器被 GC 回收后,自动从订阅列表中移除。
防止泄漏:即使忘记取消订阅,也不会导致严重泄漏。
安全访问:每次分发前检查弱引用是否有效。
// 弱引用存储监听器
List<WeakReference<EventListener<? extends PlayerEvent>>> listeners;
// 分发前检查有效性for (WeakReference<EventListener> ref : listeners) {
EventListener listener = ref.get();
if (listener != null) {
listener.onEvent(event); // 有效则调用
} else {
listeners.remove(ref); // 无效则清理
}
}单向数据流与状态同步闭环
事件系统主要承担“瞬态通知”的职责。它与 PlayerStateStore 状态存储相结合,构成了严密的单向数据流(UDF)架构。
数据流向:
方向 | 机制 | 说明 |
命令上行 | UI → Controller | Slot 发送命令事件控制播放器行为。 |
状态下行 | Controller → UI | 控制器发布状态变化事件通知 Slot。 |
状态拉取 | UI → StateStore | Slot 主动拉取当前状态(补偿机制)。 |
为何需要状态拉取?
事件具有瞬态性,晚绑定的插槽可能在attach后错过历史事件。通过getPlayerStateStore()可即时同步获取播放器当前状态,无需依赖事件订阅时机。
@Overridepublic void onAttach(@NonNull SlotHost host) {
super.onAttach(host);
// 插槽晚绑定,错过了 Prepared、StateChanged 等事件?// 主动拉取当前状态进行 UI 初始化
IPlayerStateStore stateStore = host.getPlayerStateStore();
PlayerState currentState = stateStore.getPlayState(); // 当前播放状态long position = stateStore.getCurrentPosition(); // 当前播放位置long duration = stateStore.getDuration(); // 视频总时长// 根据当前状态初始化 UI
updatePlayPauseIcon(currentState);
updateProgress(position, duration);
}
这种架构彻底解耦了 UI 与控制器:UI 无需持有控制器引用,只需订阅事件、发送命令;控制器无需关心谁在监听,只需发布事件、处理命令。
事件继承支持
订阅父类事件可接收所有子类事件:
// 订阅 PlayerEvent 可接收所有事件
mEventBus.subscribe(PlayerEvent.class, event -> {
// 接收所有 PlayerEvent 子类事件
});
// 订阅 PlayerCommand 可接收所有命令
mEventBus.subscribe(PlayerCommand.class, event -> {
// 接收所有 PlayerCommand 子类命令
});常见问题
为什么收不到事件?
请按以下步骤进行排查:
订阅状态:确认已调用
subscribe()。事件匹配:订阅类型需与发布类型一致。
标识校验:事件
playerId必须匹配。回收检测:监听器是否被GC提前回收。
如何调试事件?
使用 LogHub 查看日志,TAG 为 PlayerEventBus:
// 查看订阅日志// I/PlayerEventBus: Subscribed to Info// 查看发布日志(需开启详细日志)// I/PlayerEventBus: Posted event: Info to 2 listeners高频崩溃错例
以下是开发者最常遇到的问题,请务必避免:
错例 1:忘记取消订阅导致内存泄漏
错误代码:
public class MyActivity extends AppCompatActivity {
@Overrideprotected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 订阅后未取消订阅
PlayerEventBus.getInstance().subscribe(PlayerEvents.Info.class, event -> {
updateUI(event.currentPosition); // Activity 泄漏!
});
}
// 没有 onDestroy 取消订阅
}
正确代码:
public class MyActivity extends AppCompatActivity {
private PlayerEventBus.EventListener<PlayerEvents.Info> mListener;
@Overrideprotected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mListener = event -> updateUI(event.currentPosition);
PlayerEventBus.getInstance().subscribe(PlayerEvents.Info.class, mListener);
}
@Overrideprotected void onDestroy() {
super.onDestroy();
// ✅ 必须取消订阅
PlayerEventBus.getInstance().unsubscribe(PlayerEvents.Info.class, mListener);
}
}
崩溃原因:Lambda 表达式持有 Activity 引用,未取消订阅导致 Activity 无法释放。
错例 2:在构造函数中订阅事件
错误代码:
public class MyComponent {
public MyComponent() {
// 构造函数中订阅,对象未完全初始化
PlayerEventBus.getInstance().subscribe(PlayerEvents.Info.class, event -> {
// 此时 this 可能未完全初始化
});
}
}
正确做法:
public class MyComponent {
private PlayerEventBus.EventListener<PlayerEvents.Info> mListener;
public void init() {
// 在初始化方法中订阅
mListener = event -> handleEvent(event);
PlayerEventBus.getInstance().subscribe(PlayerEvents.Info.class, mListener);
}
public void destroy() {
// 取消订阅
PlayerEventBus.getInstance().unsubscribe(PlayerEvents.Info.class, mListener);
}
}
崩溃原因:构造函数执行时对象未完全初始化,可能导致空指针或状态不一致。
错例 3:在插槽构造函数中发送事件
错误代码:
public class MySlot extends BaseSlot {
public MySlot(Context context) {
super(context);
// 构造函数中 playerId 未设置
postEvent(new PlayerCommand.Toggle(getPlayerId())); // getPlayerId() 返回 null!
}
}
正确做法:
public class MySlot extends BaseSlot {
@Overridepublic void onAttach(@NonNull SlotHost host) {
super.onAttach(host);
// 在 onAttach 之后发送事件
String playerId = getPlayerId();
if (playerId != null) {
postEvent(new PlayerCommand.Toggle(playerId));
}
}
}
崩溃原因:getPlayerId() 在 onAttach() 之后才返回有效值,构造函数中为 null。
错例 4:事件回调中执行耗时操作
错误代码:
mEventBus.subscribe(PlayerEvents.Info.class, event -> {
// 在回调中执行网络请求,阻塞事件分发
String data = fetchFromNetwork(); // 阻塞!
updateUI(data);
});
正确做法:
mEventBus.subscribe(PlayerEvents.Info.class, event -> {
// 耗时操作异步执行
executorService.execute(() -> {
String data = fetchFromNetwork();
runOnUiThread(() -> updateUI(data));
});
});
崩溃原因:事件分发是同步的,耗时操作会阻塞所有后续事件的分发,导致 UI 卡顿。
错例 5:Slot 中未使用 observedEvents() 订阅
错误代码:
public class MySlot extends BaseSlot {
@Overridepublic void onAttach(@NonNull SlotHost host) {
super.onAttach(host);
// 手动订阅,需要手动取消
PlayerEventBus.getInstance().subscribe(PlayerEvents.Info.class, mListener);
}
@Overridepublic void onDetach() {
// 忘记取消订阅,导致泄漏super.onDetach();
}
}
正确做法:
public class MySlot extends BaseSlot {
// 使用 observedEvents() 自动管理@Overrideprotected List<Class<? extends PlayerEvent>> observedEvents() {
return Arrays.asList(PlayerEvents.Info.class);
}
@Overrideprotected void onEvent(@NonNull PlayerEvent event) {
if (event instanceof PlayerEvents.Info) {
// 处理事件
}
}
}崩溃原因:手动订阅需要手动取消,忘记取消会导致监听器泄漏。
如果确实需要手动订阅,必须在 onDetach() 中取消:
@Overridepublic void onDetach() {
PlayerEventBus.getInstance().unsubscribe(PlayerEvents.Info.class, mListener);
super.onDetach();
}