這些年我參與和主導過多款音視頻 SDK 的設計和開發,也服務過大大小小几十家 toB 客戶,其中,有一條深深的感悟:
一個 PaaS 技術中間件產品,無論它的服務端 & 內核設計和實現的多麼牛逼多麼漂亮,最終交付給客戶開發者的 SDK 纔是最最關鍵的要素和門面,它設計得好,即使背後有不足也能有一定程度上的彌補;它設計的爛,就幾乎廢棄掉了底層所有的努力,還會平添無數的無效加班和問題排障的投入。
本文關注一款優秀的 SDK 應該如何設計接口規格,以實現如下幾個目標:
- 簡潔明瞭,邊界清晰,接口正交(不存在 2 個接口相互衝突),使用者不容易踩坑
- 每一個 API 的行爲確定,調用錯誤或者運行時異常的反饋及時準確
- 面向高級客戶:配置豐富,回調豐富,業務擴展性和靈活性好
這裏致敬 《Effective C++》的行文模式,以條款的形式來描述和示例我的個人思考和總結(以最近深度參與的 RTC SDK 接口設計爲例子)。
條款 1 :參數配置提供獨立的 profile 類,不要每個參數都提供一個 set 方法
// good case
// 記得給出合理的默認值
class AudioProfile
{
int samplerate{44100};
int channels{1};
};
// 記得給出合理的默認值
class VideoProfile
{
int maxEncodeWidth{1280};
int maxEncodeHeight{720};
int maxEncodeFps{15};
};
// 可以很好地進行擴展,比如 SystemProfile,ScreenProfile...
class EngineProfile
{
AudioProfile audio;
VideoProfile video;
};
class RtcEngine
{
public:
static RtcEngine* CreateRtcEngine(const EngineProfile& profile) = 0;
};
// bad case
// 1. 核心接口類 RtcEngine 的函數數量爆炸
// 2. 無法約束業務方調用 API 的時間(可能在加入房間後或者某個不合適的時間去配置參數)
// 3. 如果某個配置期望支持動態更新怎麼辦 ?通常配置是不建議頻繁動態更新的(會影響 SDK 內部行爲),
// 如有必須,請顯式在 engine 提供 updateXXXX or switchXXX 接口
class RtcEngine
{
public:
static RtcEngine* CreateRtcEngine() = 0;
virtual void setAudioSampelerate(int samplerate) = 0;
virtual void setAudioChannels(int channels) = 0;
virtual void setVideoMaxEncodeResolution(int width, int height) = 0;
virtual void setVideoMaxEncodeFps(int fps) = 0;
};
條款 2 :非運行時的狀態 & 信息的查詢和配置接口提供靜態方法
// good case
class RtcEngine
{
public:
static int GetSdkVersion();
static void SetLogLevel(int loglevel);
};
條款 3 :關鍵的異步方法附帶上閉包回調告知結果
// good case
typedef std::function<void(int code, string message)> Callback;
class RtcEngine
{
public:
// 客戶可及時在 callback 中處理事件,比如:改變 UI 狀態|提示錯誤|再次重試
virtual void Publish(Callback const& callback = nullptr) = 0;
virtual void Subscribe(Callback const& callback = nullptr) = 0;
};
// bad case
class RtcEngine
{
public:
class Listener
{
// 需要根據 code 來詳細判斷錯誤事件,且不一定能對得上哪一次 API 調用產生的錯誤
// 錯誤種類繁多,且跳出原來的邏輯,很多業務方會忽略在這裏處理一些關鍵錯誤
virtual void OnError(int code, string message) = 0;
};
void SetListener(Listener * listener)
{
_listener = listener;
}
virtual void Publish() = 0;
virtual void Subscribe() = 0;
private:
Listener * _listener;
};
條款 4 :所有接口儘量保證 “正交” 關係(不存在 2 個接口相互衝突)
// bad case
// EnalbeAudio 與其他 API 接口並不 “正交”,組合起來容易用錯
// MuteLocalAudioStream(true) & MuteAllRemoteAudioStreams(true) 依賴了使用者先調用 EnalbeLocalAudio(true)
class RtcEngine
{
public:
// EnalbeLocalAudio + MuteLocalAudioStream + MuteRemoteAudioStream
virtual void EnalbeAudio(bool enable) = 0;
// 打開本地的音頻設備(麥克風 & 揚聲器)
virtual void EnalbeLocalAudio(bool enable) = 0;
// 發佈/取消發佈本地音頻流
virtual void MuteLocalAudioStream(bool mute) = 0;
// 訂閱/取消訂閱遠端音頻流
virtual void MuteAllRemoteAudioStreams(bool mute) = 0;
};
條款 5 :考慮擴展性,可抽象的對象儘量用結構體代替原子類型
// good case
class RtcUser
{
string userId;
string metadata;
};
class RtcEngineEventListenr
{
public:
// 未來可以很容易擴展 User 的信息和屬性
virtual void OnUserJoined(const RtcUser& user) = 0;
};
// bad case
class RtcEngineEventListenr
{
public:
// 一旦接口提供出去後,未來關於 User 對象的一些擴展信息和屬性無法添加
virtual void OnUserJoined(string userId, string metadata) = 0;
};
條款 6 :不可恢復的退出事件使用明確的 OnExit 且給出原因
客戶在面對 SDK 提供的 OnError 回調事件的時候,由於錯誤種類特別多,他們往往不知道該如何應對和處理,建議有明確的文檔告知處理方案。另外,當 SDK 內部發生了必須銷燬對象退出頁面的事件時,建議給出獨立的 callback 函數讓客戶專門處理。
enum ExitReason {
EXIT_REASON_FATAL_ERROR, // 未知的關鍵異常
EXIT_REASON_RECONNECT_FAILED, // 斷線後自動重連達到次數&時間上限
EXIT_REASON_ROOM_CLOSED, // 房間被關閉了
EXIT_REASON_KICK_OUT, // 被踢出房間了
};
class RtcEngineEventListenr
{
public:
// 一些警告消息,不礙事,接着用
virtual void OnWarning(int code, const string &message) = 0;
// 發生了必須銷燬 SDK 對象的事件,請關閉頁面
virtual void OnExit(ExitReason reason, const string &message) = 0;
};
條款 7 :PaaS 產品的 SDK 不要包含業務邏輯和信息
// bad case
enum ClientRole {
CLIENT_ROLE_BROADCASTER, // 主播,可以推流也可以拉流
CLIENT_ROLE_AUDIENCE // 觀衆,不能推流僅可以拉流
};
class RtcEngine
{
public:
// 需要明確的文檔介紹不同的 role 所對應的角色,以及 role 切換產生的行爲
// 該 API 與其他的 API 不是 “正交” 的,比如:Publish
virtual void SetClientRole(ClientRole& role) = 0;
};
// good case
// 建議在 examples 或者最佳實踐中,封裝多個 SDK 的原子接口,以達成上述 API 所起到的作用
class RoleManager
{
public:
// 通過這種方式,客戶可以顯式地感知到這個 API 背後的一系列的行爲動作
void SetClientRole(ClientRole& role)
{
// _engine->xxxxx1();
// _engine->xxxxx2();
// _engine->xxxxx3();
}
private:
RtcEngine * _engine;
};
條款 8 :請提供所有必要的狀態查詢和事件回調,別讓使用方 cache 狀態
// good case
class RtcUser
{
string userId;
string metadata;
bool audio{false}; // 是否打開並且發佈了音頻流
bool video{false}; // 是否打開並且發佈了視頻流
bool screen{false}; // 是否打開並且發佈了屏幕流
};
class RtcEngine
{
public:
// 由 SDK 內部來保持用戶狀態(最準確實時),並提供明確的查詢 API
// 而不是讓客戶在自己的代碼中 cache 狀態(很容易出現兩邊狀態不一致的問題)
virtual list<RtcUser> GetUsers() = 0;
virtual RtcUser GetUsers(const string& userId) = 0;
};
條款 9 :儘可能爲參數配置提供枚舉能力,並且返回 bool 告知配置結果
class VideoProfile
{
public:
// 提供能力的枚舉和配置結果,從而防止客戶以爲的配置跟實際的情況不一致
bool IsHwEncodeSupported();
bool SetHwEncodeEnabled(bool enabled);
// 提供能力的枚舉和配置結果,從而防止客戶以爲的配置跟實際的情況不一致
int GetSupportedMaxEncodeWidth();
int GetSupportedMaxEncodeHeight();
bool SetMaxEncodeResolution(int width, int height);
};
條款 10 :接口文件的位置和命名風格保持一定的規則和關係
// good case
// 某個代碼 repo 的目錄結構(當然,僅 Android 的包客戶可感知,C++ 的庫外部無法感知目錄結構)
// 建議所有的對外的 interface 頭文件都在根目錄下,而實現文件隱藏在內部文件夾中
// 合理的頭文件位置關係,能夠幫助開發者自己 & 客戶準確地感知哪些是接口文件,哪些是內部文件
// 所有的對外的頭文件,不允許 include 內部的文件,否則存在頭文件污染問題
// 所有的接口 Class 命名都以統一的風格開頭,比如 RtcXXXX,回調都叫 XXXCallback 等等
src
- base
- audio
- video
- utils
- metrics
- rtc_types.h
- rtc_engine.h
- rtc_engine_event_listener.h
小結
關於 SDK 的接口設計經驗就介紹到這裏了,每個人都會有自己的風格和喜好,這裏僅代表我個人的一些觀點和看法,歡迎留言討論或者來信 [email protected] 交流,或者關注我的微信公衆號 @Jhuster 獲取後續更多的文章和資訊~~