1. 程式人生 > >WebSocket安卓客戶端實現詳解(一)--連線建立與重連

WebSocket安卓客戶端實現詳解(一)--連線建立與重連

前言


這裡特別說明下因為WebSocket服務端是公司線上專案所以這裡url和具體協議我全部抹去了,但我會盡力給大家講明白並且demo我都是測試過,還望各位看官見諒

我們先粗獷的講下流程,掌握個大概的方向,然後在深入講解細節的實現.這裡先解答一個疑惑,為啥我們這要用WebSocket而不是Socket呢,因為WebSocket是一個應用層協議很多東西都規定好了我們直接按他的規定來用就好,而Socket是傳輸層和應用層的一個抽象層很多東西我們還得自己規定相對來說會比較麻煩,所以這裡我們用的WebSocket.

既然WebSocket是一個應用層協議,我們肯定不可能自己去實現,所以第一步是需要找一個實現了該協議的框架,這裡我用的

nv-websocket-client,api我就不介紹了,庫中readme已經詳細的介紹了,後面我就直接使用了.

關於通訊協議為了方便,這裡我們使用的是json.

接下來我們先簡單描述下我們將要做的事情

使用者登入流程


第一步使用者輸入賬號密碼登入成功後,我們將會通過websocket協議建立連線,當連線失敗回撥的時候我們嘗試重連,直到連線成功,當然這個嘗試重連的時間間隔我是根據重連失敗次數按一定規則寫的具體後面再說.

第二步當連線建立成功後,我們需要在後臺通過長連線傳送請求驗證該使用者的身份也就是上圖的授權,既然前面使用者登入都成功了一般情況下授權是不會失敗的,所以這裡對於授權失敗並未處理,授權成功後我們開啟心跳,並且傳送同步資料請求到服務端獲取還未收到的訊息.

客戶端傳送請求流程


第一步將請求引數封裝成請求物件,然後新增超時任務並且將該請求的回撥新增到回撥集合.

這裡有點需要說明下,封裝請求引數的時候這裡額外添加了兩個引數seqId和reqCount,這裡我們是通過長連線請求當服務端響應的時候為了能夠找到對應的回撥,所以每個請求我們都需要傳給服務端一個唯一標識來標識該請求,這裡我用的seqId,請求成功後服務端再把seqId回傳,我們再通過這個seqId作為key從回撥集合中找到對應的回撥.而reqCount的話主要針對請求超時的情況,如果請求超時,第二次請求的時候就把reqCount++在放入request中,我們約定同一個請求次數大於三次時候走http補償通道,那麼當request中的reqCount>3的時候我們就通過http傳送該請求,然後根據響應回撥對應結果.

第二步開始請求,成功或者失敗的話通過seqId找到對應回撥執行並從回撥集合中移除該回調,然後取消超時任務.如果超時的話根據seqId拿到對應的回撥並從回撥集合中移除該回調,然後判斷請求次數如果小於等於3次再次通過websocket嘗試請求,如果大於3次通過http請求,根據請求成功失敗情況執行對應回撥.

服務端主動推送訊息流程


先說明下這裡服務端推送的訊息僅僅是個事件,不攜帶具體訊息.

第一步根據notify中事件型別找到對應的處理類,一般情況下這裡需要同步對應資料.

第二步然後用eventbus通知對應的ui介面更新

第三步如果需要ack,傳送ack請求

上面只是一個概括,對於心跳,重連,傳送請求這裡有不少細節需要注意的下一節我們將詳細講解

具體實現

理論說完了,接下來我們將一步步實現客戶端程式碼.首先我們新增依賴

    compile 'com.neovisionaries:nv-websocket-client:2.2'
  •  

然後建立一個單利的WsManager管理websocket供全域性呼叫,

public class WsManager {

    private static WsManager mInstance;

    private WsManager() {
    }

    public static WsManager getInstance(){
        if(mInstance == null){
            synchronized (WsManager.class){
                if(mInstance == null){
                    mInstance = new WsManager();
                }
            }
        }
        return mInstance;
    }
}

建立連線


然後新增建立連線程式碼,這裡關於WebSocket協議的操作用的都是nv-websocket-client,我也加上了詳細的註釋,實在不理解可以去讀一遍readme檔案.

public class WsManager {
    private static WsManager mInstance;
    private final String TAG = this.getClass().getSimpleName();

    /**
     * WebSocket config
     */
    private static final int FRAME_QUEUE_SIZE = 5;
    private static final int CONNECT_TIMEOUT = 5000;
    private static final String DEF_TEST_URL = "測試服地址";//測試服預設地址
    private static final String DEF_RELEASE_URL = "正式服地址";//正式服預設地址
    private static final String DEF_URL = BuildConfig.DEBUG ? DEF_TEST_URL : DEF_RELEASE_URL;
    private String url;

    private WsStatus mStatus;
    private WebSocket ws;
    private WsListener mListener;

    private WsManager() {
    }

    public static WsManager getInstance(){
        if(mInstance == null){
            synchronized (WsManager.class){
                if(mInstance == null){
                    mInstance = new WsManager();
                }
            }
        }
        return mInstance;
    }

    public void init(){
        try {
          /**
           * configUrl其實是快取在本地的連線地址
           * 這個快取本地連線地址是app啟動的時候通過http請求去服務端獲取的,
           * 每次app啟動的時候會拿當前時間與快取時間比較,超過6小時就再次去服務端獲取新的連線地址更新本地快取
           */
            String configUrl = "";
            url = TextUtils.isEmpty(configUrl) ? DEF_URL : configUrl;
            ws = new WebSocketFactory().createSocket(url, CONNECT_TIMEOUT)
                .setFrameQueueSize(FRAME_QUEUE_SIZE)//設定幀佇列最大值為5
                .setMissingCloseFrameAllowed(false)//設定不允許服務端關閉連線卻未傳送關閉幀
                .addListener(mListener = new WsListener())//添加回調監聽
                .connectAsynchronously();//非同步連線
            setStatus(WsStatus.CONNECTING);
            Logger.t(TAG).d("第一次連線");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }


    /**
     * 繼承預設的監聽空實現WebSocketAdapter,重寫我們需要的方法
     * onTextMessage 收到文字資訊
     * onConnected 連線成功
     * onConnectError 連線失敗
     * onDisconnected 連線關閉
     */
    class WsListener extends WebSocketAdapter{
        @Override
        public void onTextMessage(WebSocket websocket, String text) throws Exception {
            super.onTextMessage(websocket, text);
            Logger.t(TAG).d(text);
        }


        @Override
        public void onConnected(WebSocket websocket, Map<String, List<String>> headers)
            throws Exception {
            super.onConnected(websocket, headers);
            Logger.t(TAG).d("連線成功");
            setStatus(WsStatus.CONNECT_SUCCESS);
        }


        @Override
        public void onConnectError(WebSocket websocket, WebSocketException exception)
            throws Exception {
            super.onConnectError(websocket, exception);
            Logger.t(TAG).d("連線錯誤");
            setStatus(WsStatus.CONNECT_FAIL);
        }


        @Override
        public void onDisconnected(WebSocket websocket, WebSocketFrame serverCloseFrame, WebSocketFrame clientCloseFrame, boolean closedByServer)
            throws Exception {
            super.onDisconnected(websocket, serverCloseFrame, clientCloseFrame, closedByServer);
            Logger.t(TAG).d("斷開連線");
            setStatus(WsStatus.CONNECT_FAIL);
        }
    }

    private void setStatus(WsStatus status){
        this.mStatus = status;
    }

    private WsStatus getStatus(){
        return mStatus;
    }

    public void disconnect(){
        if(ws != null)
        ws.disconnect();
    }
}
public enum WsStatus {
    CONNECT_SUCCESS,//連線成功
    CONNECT_FAIL,//連線失敗
    CONNECTING;//正在連線
}

從註釋我們可以知道,這裡我們是app啟動的時候通過http請求獲取WebSocket連線地址,如果獲取失敗就走本地預設的url建立連線.並且內部自己維護了一個websocket狀態後面傳送請求和重連的時候會用上.

其實獲取連線地址這個地方是可以優化的,就是app啟動的時候先比較上次獲取的時間如果大於6小時就通過http請求獲取websocket的連線地址,這個地址應該是個列表,然後存入本地,連線的時候我們可以先ping下地址,選擇耗時最短的地址接入.如果連不上我們在連耗時第二短的地址以此類推.但這裡我們就以簡單的方式做了.

至於建立連線程式碼在哪呼叫的話,我選擇的是主介面onCreate()的時候,因為一般能進入主介面了,就代表使用者已經登入成功.

WsManager.getInstance().init();
  •  

斷開連線的話在主介面onDestroy()的時候呼叫

WsManager.getInstance().disconnect();
  •  

重連


建立連線有成功就有失敗,對於失敗情況我們需要重連,那麼下面我們分別說明重連的時機,重連的策略和當前是否應該重連的判斷.

對於重連的時機有如下幾種情況我們需要嘗試重連

  1. 應用網路的切換.具體點就是可用網路狀態的切換,比如4g切wifi連線會斷開我們需要重連.

  2. 應用回到前臺的時候,判斷如果連線斷開我們需要重連,這個是儘量保持當應用再前臺的時候連線的穩定.

  3. 收到連線失敗或者連線斷開事件的時候,這個沒什麼好解釋.

  4. 心跳連續3次失敗時候.當然這個連續失敗3次是自己定義的,大夥可以根據自己app的情況定製.

等會我們先展示前三種情況,心跳失敗這個在後面我們把客戶端傳送請求講完再說.

上面把需要重連的情景說了,現在講講具體的重連策略.

這裡我定義了一個最小重連時間間隔min和一個最大重連時間間隔max,當重連次數小於等於3次的時候都以最小重連時間間隔min去嘗試重連,當重連次數大於3次的時候我們將重連地址替換成預設地址DEF_URL,將重連時間間隔按min*(重連次數-2)遞增最大不不超過max.

還有最後一個當前是否應該重連的判斷

  1. 使用者是否登入,可以通過本地是否有快取的使用者資訊來判斷.因為重連成功後我們需要將使用者資訊通過WebSocket傳送到伺服器進行身份驗證所以這裡必須登入成功.

  2. 當前連線是否可用,這個通過nv-websocket-client庫中的api判斷ws.isOpen().

  3. 當前不是正在連線狀態,這裡我們根據自己維護的狀態來判斷getStatus() != WsStatus.CONNECTING.

  4. 當前網路可用.

下面我們show code.跟之前相同的程式碼這裡就省略了

public class WsManager {

    .....省略部分跟之前程式碼一樣.....

    /**
     * 繼承預設的監聽空實現WebSocketAdapter,重寫我們需要的方法
     * onTextMessage 收到文字資訊
     * onConnected 連線成功
     * onConnectError 連線失敗
     * onDisconnected 連線關閉
     */
    class WsListener extends WebSocketAdapter {
        @Override
        public void onTextMessage(WebSocket websocket, String text) throws Exception {
            super.onTextMessage(websocket, text);
            Logger.t(TAG).d(text);
        }


        @Override
        public void onConnected(WebSocket websocket, Map<String, List<String>> headers)
            throws Exception {
            super.onConnected(websocket, headers);
            Logger.t(TAG).d("連線成功");
            setStatus(WsStatus.CONNECT_SUCCESS);
            cancelReconnect();//連線成功的時候取消重連,初始化連線次數
        }


        @Override
        public void onConnectError(WebSocket websocket, WebSocketException exception)
            throws Exception {
            super.onConnectError(websocket, exception);
            Logger.t(TAG).d("連線錯誤");
            setStatus(WsStatus.CONNECT_FAIL);
            reconnect();//連線錯誤的時候呼叫重連方法
        }


        @Override
        public void onDisconnected(WebSocket websocket, WebSocketFrame serverCloseFrame, WebSocketFrame clientCloseFrame, boolean closedByServer)
            throws Exception {
            super.onDisconnected(websocket, serverCloseFrame, clientCloseFrame, closedByServer);
            Logger.t(TAG).d("斷開連線");
            setStatus(WsStatus.CONNECT_FAIL);
            reconnect();//連線斷開的時候呼叫重連方法
        }
    }


    private void setStatus(WsStatus status) {
        this.mStatus = status;
    }


    private WsStatus getStatus() {
        return mStatus;
    }


    public void disconnect() {
        if (ws != null) {
            ws.disconnect();
        }
    }


    private Handler mHandler = new Handler();

    private int reconnectCount = 0;//重連次數
    private long minInterval = 3000;//重連最小時間間隔
    private long maxInterval = 60000;//重連最大時間間隔


    public void reconnect() {
        if (!isNetConnect()) {
            reconnectCount = 0;
            Logger.t(TAG).d("重連失敗網路不可用");
            return;
        }

        //這裡其實應該還有個使用者是否登入了的判斷 因為當連線成功後我們需要傳送使用者資訊到服務端進行校驗
        //由於我們這裡是個demo所以省略了
        if (ws != null &&
            !ws.isOpen() &&//當前連線斷開了
            getStatus() != WsStatus.CONNECTING) {//不是正在重連狀態

            reconnectCount++;
            setStatus(WsStatus.CONNECTING);

            long reconnectTime = minInterval;
            if (reconnectCount > 3) {
                url = DEF_URL;
                long temp = minInterval * (reconnectCount - 2);
                reconnectTime = temp > maxInterval ? maxInterval : temp;
            }

            Logger.t(TAG).d("準備開始第%d次重連,重連間隔%d -- url:%s", reconnectCount, reconnectTime, url);
            mHandler.postDelayed(mReconnectTask, reconnectTime);
        }
    }


    private Runnable mReconnectTask = new Runnable() {

        @Override
        public void run() {
            try {
                ws = new WebSocketFactory().createSocket(url, CONNECT_TIMEOUT)
                    .setFrameQueueSize(FRAME_QUEUE_SIZE)//設定幀佇列最大值為5
                    .setMissingCloseFrameAllowed(false)//設定不允許服務端關閉連線卻未傳送關閉幀
                    .addListener(mListener = new WsListener())//添加回調監聽
                    .connectAsynchronously();//非同步連線
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    };


    private void cancelReconnect() {
        reconnectCount = 0;
        mHandler.removeCallbacks(mReconnectTask);
    }


    private boolean isNetConnect() {
        ConnectivityManager connectivity = (ConnectivityManager) WsApplication.getContext()
            .getSystemService(Context.CONNECTIVITY_SERVICE);
        if (connectivity != null) {
            NetworkInfo info = connectivity.getActiveNetworkInfo();
            if (info != null && info.isConnected()) {
                // 當前網路是連線的
                if (info.getState() == NetworkInfo.State.CONNECTED) {
                    // 當前所連線的網路可用
                    return true;
                }
            }
        }
        return false;
    }
}

上面程式碼通過handler實現了一定時間間隔的重連,然後我們在WsListener監聽中的onConnectError()onDisconnected()呼叫了reconnect()實現重連,onConnected()中呼叫了cancelReconnect()取消重連並初始化重連次數.

所以當需要重連的時候我們呼叫reconnect()方法,如果失敗onConnectError()onDisconnected()回撥會再次呼叫reconnect()實現重連,如果成功onConnected()中會呼叫cancelReconnect()取消重連並初始化重連次數.

並且這裡我們已經實現了需要重連的情景3,收到連線失敗或者連線斷開事件的時候進行重連.

接下來我們實現情景1和2

  1. 應用網路的切換.具體點就是可用網路狀態的切換,比如4g切wifi連線會斷開我們需要重連.

  2. 應用回到前臺的時候,判斷如果連線斷開我們需要重連,這個是儘量保持當應用再前臺的時候連線的穩定.

對於可用網路的切換這裡通過廣播來監聽實現重連

public class NetStatusReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        if (ConnectivityManager.CONNECTIVITY_ACTION.equals(action)) {

            // 獲取網路連線管理器
            ConnectivityManager connectivityManager
                = (ConnectivityManager) WsApplication.getContext()
                .getSystemService(Context.CONNECTIVITY_SERVICE);
            // 獲取當前網路狀態資訊
            NetworkInfo info = connectivityManager.getActiveNetworkInfo();

            if (info != null && info.isAvailable()) {
                Logger.t("WsManager").d("監聽到可用網路切換,呼叫重連方法");
                WsManager.getInstance().reconnect();//wify 4g切換重連websocket
            }

        }
    }
}

應用回到前臺情況的重連.

通過Application.ActivityLifecycleCallbacks實現app前後臺切換監聽如下

public class ForegroundCallbacks implements Application.ActivityLifecycleCallbacks {

    public static final long CHECK_DELAY = 600;
    public static final String TAG = ForegroundCallbacks.class.getName();
    private static ForegroundCallbacks instance;
    private boolean foreground = false, paused = true;
    private Handler handler = new Handler();
    private List<Listener> listeners = new CopyOnWriteArrayList<Listener>();
    private Runnable check;

    public static ForegroundCallbacks init(Application application) {
        if (instance == null) {
            instance = new ForegroundCallbacks();
            application.registerActivityLifecycleCallbacks(instance);
        }
        return instance;
    }

    public static ForegroundCallbacks get(Application application) {
        if (instance == null) {
            init(application);
        }
        return instance;
    }

    public static ForegroundCallbacks get(Context ctx) {
        if (instance == null) {
            Context appCtx = ctx.getApplicationContext();
            if (appCtx instanceof Application) {
                init((Application) appCtx);
            }
            throw new IllegalStateException(
                    "Foreground is not initialised and " +
                            "cannot obtain the Application object");
        }
        return instance;
    }

    public static ForegroundCallbacks get() {

        return instance;
    }

    public boolean isForeground() {
        return foreground;
    }

    public boolean isBackground() {
        return !foreground;
    }

    public void addListener(Listener listener) {
        listeners.add(listener);
    }

    public void removeListener(Listener listener) {
        listeners.remove(listener);
    }

    @Override
    public void onActivityResumed(Activity activity) {
        paused = false;
        boolean wasBackground = !foreground;
        foreground = true;
        if (check != null)
            handler.removeCallbacks(check);
        if (wasBackground) {

            for (Listener l : listeners) {
                try {
                    l.onBecameForeground();
                } catch (Exception exc) {

                }
            }
        } else {

        }
    }

    @Override
    public void onActivityPaused(Activity activity) {
        paused = true;

        if (check != null)
            handler.removeCallbacks(check);
        handler.postDelayed(check = new Runnable() {
            @Override
            public void run() {
                if (foreground && paused) {
                    foreground = false;
                    for (Listener l : listeners) {
                        try {
                            l.onBecameBackground();
                        } catch (Exception exc) {

                        }
                    }
                } else {

                }
            }
        }, CHECK_DELAY);
    }

    @Override
    public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
    }

    @Override
    public void onActivityStarted(Activity activity) {
    }

    @Override
    public void onActivityStopped(Activity activity) {
    }

    @Override
    public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
    }

    @Override
    public void onActivityDestroyed(Activity activity) {
    }

    public interface Listener {
        public void onBecameForeground();

        public void onBecameBackground();
    }
}

然後在application中初始化該監聽,當應用回到前臺的時候嘗試重連

public class WsApplication extends Application {


    @Override
    public void onCreate() {
        super.onCreate();
        initAppStatusListener();
    }

    private void initAppStatusListener() {
        ForegroundCallbacks.init(this).addListener(new ForegroundCallbacks.Listener() {
            @Override
            public void onBecameForeground() {
                Logger.t("WsManager").d("應用回到前臺呼叫重連方法");
                WsManager.getInstance().reconnect();
            }

            @Override
            public void onBecameBackground() {

            }
        });
    }
}

到這裡連線的建立和重連講完了,還剩客戶端傳送請求和服務端主動通知訊息.

本來我準備一篇把WebSocket客戶端實現寫完的,現在才一半就已經這麼多了,索性分為幾篇算了,下篇我們將介紹 WebSocket安卓客戶端實現詳解(二)–客戶端傳送請求.