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

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

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

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

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

這裏致敬 《Effective C++》的行文模式,以條款的形式來描述和示例個人我的思考和總結(以最近深度參與的 RTC SDK 接口設計爲例子)。app

條款 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 的接口設計經驗就介紹到這裏了,每一個人都會有本身的風格和喜愛,這裏僅表明我我的的一些觀點和見解,歡迎留言討論或者來信 lujun.hust@gmail.com 交流,或者關注個人微信公衆號 @Jhuster 獲取後續更多的文章和資訊~~ide

相關文章
相關標籤/搜索