一款優秀的 SDK 介面設計十大原則。

玻璃窗起霧了發表於2021-01-20

這些年我參與和主導過多款音影片 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

 


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69992957/viewspace-2751564/,如需轉載,請註明出處,否則將追究法律責任。

相關文章