一款優秀的 SDK 接口設計十大原則

這些年我參與和主導過多款音視頻 SDK 的設計和開發,也服務過大大小小几十家 toB 客戶,其中,有一條深深的感悟:

一個 PaaS 技術中間件產品,無論它的服務端 & 內核設計和實現的多麼牛逼多麼漂亮,最終交付給客戶開發者的 SDK 纔是最最關鍵的要素和門面,它設計得好,即使背後有不足也能有一定程度上的彌補;它設計的爛,就幾乎廢棄掉了底層所有的努力,還會平添無數的無效加班和問題排障的投入。

本文關注一款優秀的 SDK 應該如何設計接口規格,以實現如下幾個目標: 

  1. 簡潔明瞭,邊界清晰,接口正交(不存在 2 個接口相互衝突),使用者不容易踩坑
  2. 每一個 API 的行爲確定,調用錯誤或者運行時異常的反饋及時準確
  3. 面向高級客戶:配置豐富,回調豐富,業務擴展性和靈活性好

這裏致敬 《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 獲取後續更多的文章和資訊~~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章