本文介紹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));
});
});命令發送時機
命令 | 建議發送時機 | 說明 |
| 任意時刻。 | 安全命令,無副作用。 |
| 播放器 | 確保視頻已載入。 |
| 播放中或暫停時。 | 確保播放器已初始化。 |
| 首幀渲染後。 | 確保有畫面可截。 |
注意事項
建構函式風險:禁止在建構函式中訂閱/發送事件(此時對象未初始化,存在null 指標風險;
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);
}
}
崩潰原因:建構函式執行時對象未完全初始化,可能導致null 指標或狀態不一致。
錯例 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();
}