Android 抖音爆紅的口紅挑戰爬坑總結

項目背景

今年,相信不少人都會在各個商場或者是電影院中能夠看到各類娃娃機、幸運盒子、口紅挑戰等等相似機器。在抖音上《口紅挑戰》這款機子也是火的一塌糊塗,你只要花 10 塊錢就有可能贏走一個 YSL 口紅,想一想就以爲頗有誘惑力。或許這些機器被程序員一瞧就知道其中的貓膩,可是這個機器瞄準的是那些容易衝動消費的消費者,好比情侶、女生、帶小孩的大人;像程序員這麼奇葩的生物,通常都是直接被無視的哈哈。css

而後呢,咱們公司就是爲這些設備的正常運行提供解決方案的。所以纔有我今天的爬坑總結,哈哈哈哈哈.....html

咱們提供的解決方案是這樣的,在一個門店裏面會包含以下的設備:娃娃機、口紅挑戰、排行榜、中控,固然其中還有咱們的後臺服務。那麼首先我會先介紹一下整個系統的架構以及各個設備的職責:java

  • 系統架構圖

門店系統結構圖.png

  • 服務後臺
    • 服務後臺不屬於 Android 這端負責的,所以不須要去關注太多,服務後臺相對於門店設備而言,它的一個職責是負責給機器發送上分質量,監測設備狀態,及一些其餘的信息。
  • 中控(本地服務:不鏈接外網):這個中控也是也 Android 設備,它的功能有兩個:
    1. 排行榜:用來接收娃娃機中發送過來的用戶夾中娃娃的信息,並最後將其顯示在排行榜上。
    2. 資源分發中控:做爲資源的分發中心,中控須要分發 apk 安裝包、圖片、
  • 娃娃機:這一塊其實包含了兩個部分,一個是 Android 設備,另一個硬件設備。
    1. Android 設備主要是用來顯示 banner、處理服務器數據(例如:上分)、對接中控(資源更新、數據反饋等)、對接硬件設備
    2. 硬件設備主要是處理用戶上分、出禮、心跳等信息,並將這些信息交給 Android 設備處理。
  • 口紅挑戰:關於口紅挑戰這個設備能夠劃分爲 3 個模塊,分別爲見縫插針遊戲、常規程序模塊、硬件模塊
    1. 關於見縫插針的這個遊戲是用白鷺引擎作的,最後以 h5 的形式嵌入到 APP 中,主要負責遊戲的主邏輯以及和程序主模塊進行遊戲邏輯數據的反饋。
    2. 常規模塊主要是有處理服務後臺的數據、對接硬件模塊(格子選中、打開格子等)、對接遊戲(啓動遊戲、遊戲結果反饋等)、物料後臺管理。
  • rocket:這個程序有點特殊,由於用戶是看不到它的,在出廠的時候,這個程序就被寫進去了,那麼它負責的工做以下:
    1. 屏蔽設備的 systemui 程序和 launcher 程序,防止用戶作一些非法的操做。
    2. 檢測 U 盤是否插入,而後移動或者負責制定文件(apk 安裝包、ipconfig.json 文件、三元組配資文件config.json、h5 資源文件等)
    3. 接收廣播,自動安裝或者更新娃娃機或者口紅挑戰程序

要解決的問題

同步加載資源

關於資源同步的,首先咱們先理一下咱們須要同步的資源有哪些,這些資源分別爲: apk 安裝包、圖片、h5 相關的 index 資源。android

資源更新的方式

關於更新的方式,這裏其實就有一個比較坑的地方了,一開始的時候咱們選擇的資源更新方式比較傻,直接使用 websocket 進行資源更新的,一開始的時候只有一個設備進行鏈接,問題卻是不大,可是後來發現多臺設備鏈接同時更新資源的時候問題特別大,鏈接常常斷開,致使資源更新失敗。那麼這裏是我遇到的第一個坑。發現這個坑以後呢,個人選擇資源更新的方式就更改成:NanoHttpd。NanoHttpd 是一個開源庫,是用 Java 實現的,它能夠在 Android 設備上創建一個輕量級的 web server。其實在 Android 設備上建立一個輕量級的 web server 纔是咱們一開始就應該要選擇的方向。爲何呢?首先 NanoHttpd 的使用是比較簡單的,所以咱們只須要幾行代碼就能夠實現一個 web server 了;其次呢,NanoHttpd 是比較穩定的,相對於咱們手動使用 websocket 去實現一個資源分發要穩定太多了。git

那麼在咱們選擇了資源的更新方式以後,有另一個問題浮出水面了,關於服務器的 IP 地址。咱們都知道,關於 Android 設備鏈接上移動互聯網或者 WiFi 的時候都會被自動分配一個 IP 地址,所以這個 IP 地址是會變化的,咱們的設備在天天晚上都會關機,而後在次日開啓重啓的時候又會被分配到一個新的 IP 地址,所以服務器的 IP 地址是一直在變化的,因此這裏咱們須要作的是想辦法把某個設備的 IP 地址給固定下來。那麼接下來就來說講關於 NanoHttpd 建立輕量級的 web server 和如何解決 IP 變化的問題。程序員

NanoHttpd 實現 web server

implementation 'org.nanohttpd:nanohttpd-webserver:2.3.1'
複製代碼
  • 實現方式
File resourceDir = new File(Environment.getExternalStorageDirectory(), "myRootDir");
    SimpleWebServer httpServer = new SimpleWebServer(null, 18103, resourceDir, true, "*");
    httpServer.start(NanoHTTPD.SOCKET_READ_TIMEOUT, true);
複製代碼
  • SimpleWebServer 構造函數的參數
    • host:服務器 ip 地址
    • port:端口號(取值範圍:1024~65535)
    • wwwroot:放置靜態資源的根目錄
    • quiet:是否爲安靜模式
    • cors:
  • 訪問方式
    • 在同個局域網下,那麼咱們在瀏覽器中輸入地址:http://10.0.0.34:18103,咱們就能夠訪問到咱們服務器中的資源了,固然目前實現的服務器是靜態的,只能處理 get 資源請求,不可以處理 post、put 等其餘請求,目前是處理不了的,若是須要本身再處理 post 和 put 等其餘的請求的話,那麼能夠本身去 項目原地址 中參考它的用法去實現,在這裏就很少講了。

解決 IP 變化的問題

在 Android 設備中,它的一個 IP 地址是會變化的,並且每一個門店都會有一個本身的內部中控機,那麼咱們是必需要處理 IP 地址變化的這個問題的。咱們的解決方案有以下兩個步驟:github

  1. 在路由器中根據 Mac 地址,爲門店內的中控設備設置固定的 IP 地址
  2. 爲每一個娃娃機和口紅挑戰設備提供一個 IP 地址的配置文件,這個文件裏面有門店中控的 IP 地址信息,放在 U 盤的指定目錄下,但插入設備的時候,由 Rocket 程序將文件從 U 盤中將配置文件 copy 到設備的制定目錄下,設備每次啓動的時候都須要先讀取配置文件,再鏈接本地的服務器。

資源何時更新

關於資源更新的,咱們首先須要明確咱們須要更新的資源有哪些以及咱們須要更新的方式。web

更新的資源

  • Resource.json
  • apk 包
  • 娃娃機中的顯示器輪播圖
  • 娃娃機中顯示 banner 的 h5 資源

更新的配置文件

  • 關於咱們資源跟新的全部數據都是保存在 Resource.json 這個文件夾裏面的,那麼咱們每隔 5min 就從中控服務端(局域網內)獲取 Resource.json,而後每一個類型的資源就根據寫在 Resource.json 中的數據進行判斷。那麼寫入 Resource.json 文件中的實現及具體內容以下:
    1. 資源的 ResList model
    public class ResListModel {
        // 娃娃機 banner 的 h5 資源(index.html等文件)
        public HashMap<String, String> bannerFiles = new HashMap();
        // 門店中全部娃娃機都會顯示的輪播圖
        // key 爲 圖片的 hash 值
        // value 爲圖片的在服務器中的相對路徑
        public HashMap<String, String> PublicFiles = new HashMap();
        // 門店中特定娃娃機的私有顯示輪播圖
        // key 爲設備的 id
        // value 爲圖片圖片的 hash 及路徑信息(對應 PublicFiles)
        public HashMap<String, HashMap<String, String>> PrivateFiles = new HashMap();
        // 更新的 apk 路徑
        public String UpdateApk;
        // 更新的 apk 包名
        public String UpdateApkPackageName;
        // 更新的 apk 版本名
        public String UpdateApkVersion;
        // 更新的 apk 版本號
        public int UpdateApkVersionCode;
    }
    複製代碼
    1. 寫入到 Resourse.json 文件
    ResListModel res = new ResListModel();
        // 略過添加數據的過程
        ...;
        File resourceFile = new File(baseDir, "Resource.json");
        RandomAccessFile out = new RandomAccessFile(resourceFile, "rw");
        byte[] json = JsonStream.serialize(res).getBytes("utf-8");
        out.setLength(json.length);
        out.write(json);
        out.close();
    複製代碼
    1. Resourse.json 的內容
    {
        "PrivateFiles":{},
        "PublicFiles":
            {
                "1A7D3394A6F10D3668FB29D8CCA1CA8B":"Public/timg.jpg"
            },
        "UpdateApk":null,
        "UpdateApkPackageName":null,
        "UpdateApkVersion":null,
        "UpdateApkVersionCode":0,
        "bannerFiles":
            {
                "C609D70832710E3DCF0FB88918113B18":"banner/Resource.json",
                "FC1CF2C83E898357E1AD60CEF87BE6EB":"banner/app.8113390c.js",
                "27FBF214DF1E66D0307B7F78FEB8266F":"banner/manifest.json",
                "A192A95BFF57FF326185543A27058DE5":"banner/index.html",
                "61469B10DBD17FDEEB14C35C730E03C7":"banner/app.8113390c.css"
                
            }
    }
    複製代碼

資源圖片和 banner 的資源文件的更新

  • 關於圖片和 banner 的資源文件的更新方式是相似的,只是存放的路徑不在同一個目錄下而已。那麼對這類資源的更新,咱們是經過技術資源的 hash 值和文件名來進行判斷的。娃娃機或者口紅挑戰設備會每隔 5min 從中控中獲取 Resourse.json 文件,而後取出 ResListModel,ResListModel 在以前介紹過了,是保存資源更新的配置文件;以後咱們從中取出相對於的配置,首先根據文件名判斷該文件是否已經存在本地了,若是不存在,則直接添加到資源更新的列表中,若是存在則再判斷 hash 值是否相同,相同就不更新,不相同先將本地的文件刪除,而後再將其就添加到更新資源的列表中。
  • 圖片和 banner 資源更新流程圖:

資源更新流程圖.png

  • 中控中計算資源你的 hash 值
try {
        // banner 資源文件
        String fileName = fileFilter.getAbsolutePath().substring(baseDirLength);
        RandomAccessFile randomAccessFile = new RandomAccessFile(fileFilter,"r");
        byte[] buf = new byte[(int) randomAccessFile.length()];
        randomAccessFile.read(buf);
        randomAccessFile.close();
        MessageDigest md5 = MessageDigest.getInstance("md5");
        byte[] hash = md5.digest(buf);
        String hashStr = ByteToHex(hash,0,hash.length);
        res.bannerFiles.put(hashStr,fileName);
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    }
複製代碼
// 字節轉換爲 16 進制
    public static String ByteToHex(byte[] bt, int offset, int len) {
        StringBuffer sb = new StringBuffer();
        for (int i = offset; i < offset + len; i++) {
            int tmp = bt[i] & 0xff;
            String tmpStr = Integer.toHexString(tmp);
            if (tmpStr.length() < 2)
                sb.append("0");
            sb.append(tmpStr);
        }
        return sb.toString().toUpperCase();
    }

複製代碼
  • 娃娃機設備檢查更新(例如:banner 資源文件)
public static Observable<Boolean> updateBannerRes(ResListBean resListBean) throws IOException, NoSuchAlgorithmException {
        // 獲取遠程 banner 的文件
        HashMap<File, String> remoteFiles = new HashMap();
        for (HashMap.Entry<String, String> entry : resListBean.bannerFiles.entrySet()) {
            remoteFiles.put(new File(entry.getValue()), entry.getKey());
        }

        FileUtils.GetFilesInDir(bannerDir,localBannerList,null);
        int baseDirLength = resDir.getAbsolutePath().length()+1;
        // step1:刪除本地文件(遠程 banner 中沒有的文件)
        for (File localFile : localBannerList) {
            File chileFile = new File(localFile.getAbsolutePath().substring(baseDirLength));
            if (!remoteFiles.containsKey(chileFile)) {
                MainActivity.appendAndScrollLog(String.format("刪除 banner 資源文件 %s\n", localFile.getAbsolutePath()));
                localFile.delete();
            }
        }

        // 下載本地沒有的文件
        ArrayList<Observable<File>> taskList = new ArrayList();
        for (Map.Entry<File, String> fileEntry : remoteFiles.entrySet()) {
            File file = new File(resDir,fileEntry.getKey().getAbsolutePath());

            // step2:本地中存在和遠程相同的文件名
            if (localBannerList.contains(file)) {
                // step3:根據 hash 值判斷是否爲同一文件
                String hashStr = FileUtils.getFileHashStr(file);
                if (TextUtils.equals(hashStr,fileEntry.getValue())){
                    MainActivity.appendAndScrollLog(String.format("保留 banner 文件 %s\n", file.getAbsolutePath()));
                    taskList.add(Observable.just(file));
                    continue;
                }
            }

            // step4:下載本地沒有的文件
            String url = new URL("http", Config.instance.centralServerAddress,
                    Config.instance.httpPort,
                    new File(BuildConfig.APPLICATION_ID, fileEntry.getKey().getAbsolutePath()).getAbsolutePath()).toString();
            // step5:加入文件下載列表
            taskList.add(DownLoadUtils.getDownLoadFile(url,file));
        }

        return Observable.concat(taskList)
                .toFlowable(BackpressureStrategy.MISSING)
                .parallel()
                .runOn(Schedulers.io())
                .sequential()
                .toList()
                .observeOn(Schedulers.computation())
                .map(new Function<List<File>, ArrayList<File>>() {
                    @Override
                    public ArrayList<File> apply(List<File> files) throws Exception {
                        ArrayList<File> list = new ArrayList();
                        for (File file : files) {
                            if (!file.getAbsolutePath().isEmpty()) {
                                list.add(file);
                            }
                        }
                        if (list.size() > 0) {
                            if (!Utils.EqualCollection(list, localBannerList)) {
                                Collections.sort(list);
                            } else {
                                list.clear();
                            }
                        }
                        return list;
                    }
                })
                .observeOn(AndroidSchedulers.mainThread())
                .map(new Function<ArrayList<File>, Boolean>() {
                    @Override
                    public Boolean apply(ArrayList<File> list) throws Exception {
                        if (list.size() > 0) {
                            localBannerList = list;
                            webViewHasLoad = false;
                            loadH5();
                        }
                        return true;
                    }
                })
                .observeOn(Schedulers.io())
                .map(new Function<Boolean, Boolean>() {
                    @Override
                    public Boolean apply(Boolean aBoolean) throws Exception {
                        FileUtils.DelEmptyDir(resDir);
                        return true;
                    }
                })
                .toObservable();
    }

複製代碼

程序升級的問題

關於程序的升級,相比較於圖片資源的更新要簡單許多。算法

  • 咱們的實現版本更新的步驟以下:
    • step1:找出本地存在的 apk 文件(設備的中的 apk 都是制定路徑和制定文件名的),將其刪除。
    • step2:判斷中控中的安裝包的版本號是否大於本地程序的版本號,若是是則進入 step3;不然忽略,不須要程序升級
    • step3:下載最新版本的 apk 安裝包
    • step4:下載成功後,發送廣播(action:包名;extra:apk文件路徑)給 rocket 程序
    • step5:rocket 程序接收到廣播以後就升級程序
  • 程序升級流程圖

版本更新流程.png

  • 具體代碼實現
public static Observable<Boolean> updateGame(ResListBean res) throws IOException, InterruptedException {
        ArrayList<File> apkList = new ArrayList();
        FileUtils.GetFilesInDir(resDir, apkList, new String[]{
                ".apk",
        });
        // 刪除本地存在的 apk 包
        for (File file : apkList) {
            file.delete();
        }
        do {
            if (res.UpdateApk == null || res.UpdateApkVersion == null) {
                break;
            }
            // 判斷是否須要升級
            if (BuildConfig.VERSION_CODE >= res.UpdateApkVersionCode) {
                break;
            }
            
            // apk 的 URL
            final String url = new URL("http", Config.instance.centralServerAddress, Config.instance.httpPort, new File(BuildConfig.APPLICATION_ID, res.UpdateApk).getAbsolutePath()).toString();
            MainActivity.appendAndScrollLog(String.format("下載升級文件 %s\n", url));
            // 下載 apk 文件
            return DownLoadUtils.getDownLoadFile(url,resDir.getAbsolutePath(),res.UpdateApk)
                    .subscribeOn(Schedulers.io())
                    .observeOn(Schedulers.io())
                    .flatMap(new Function<File, ObservableSource<String>>() {
                        @Override
                        public ObservableSource<String> apply(File file) throws Exception {
                            String path = file.getAbsolutePath();
                            MainActivity.appendAndScrollLog(String.format("升級文件下載完成 %s %s\n", path, url));
                            PackageManager pm = MainActivity.instance.getPackageManager();
                            PackageInfo pi = pm.getPackageArchiveInfo(path, 0);
                            if (pi == null) {
                                MainActivity.appendAndScrollLog(String.format("升級文件打開失敗 %s\n", path));
                                return Observable.just("");
                            }
                            MainActivity.appendAndScrollLog(String.format("升級文件對比:Native(%s %s)/Remote(%s %s)\n", BuildConfig.APPLICATION_ID, BuildConfig.VERSION_NAME, pi.packageName, pi.versionName));
                            if (!BuildConfig.APPLICATION_ID.equals(pi.packageName)
                                    || BuildConfig.VERSION_CODE >= pi.versionCode) {
                                return Observable.just("");
                            }
                            return Observable.just(path);
                        }
                    })
                    .flatMap(new Function<String, Observable<Boolean>>() {
                        @Override
                        public Observable<Boolean> apply(String updateApk) throws Exception {
                            if (!updateApk.isEmpty()) {
                                Log.e(TAG, "等待遊戲結束後安裝升級文件...");
                                MainActivity.appendAndScrollLog("等待遊戲結束後安裝升級文件...\n");
                                synchronized (GamePlay.class) {//防止在遊戲運行時更新版本
                                    Log.e(TAG, "發佈廣播");
                                    Intent intent = new Intent();
                                    intent.setAction(Config.updateBroadcast);
                                    intent.putExtra("apk", updateApk);
                                    MainActivity.instance.sendBroadcast(intent);
                                    System.exit(0);
                                }
                            }
                            return Observable.just(true);
                        }
                    });
        } while (false);
        return Observable.just(true);
    }

複製代碼

資源文件下載

關於資源文件的下載,我是選擇 okdownload。okdownload 是一個支持多線程,多任務,斷點續傳,可靠,靈活,高性能以及強大的下載引擎。詳情能夠去看 okdownload GitHub 地址shell

  • 依賴方式
implementation 'com.liulishuo.okdownload:okdownload:1.0.5'
    implementation 'com.liulishuo.okdownload:okhttp:1.0.5'
複製代碼
  • 簡單實用示例

單文件下載

DownloadTask task = new DownloadTask.Builder(url, parentFile)
         .setFilename(filename)
         // the minimal interval millisecond for callback progress
         .setMinIntervalMillisCallbackProcess(30)
         // do re-download even if the task has already been completed in the past.
         .setPassIfAlreadyCompleted(false)
         .build();
 
 
task.enqueue(listener);
 
// cancel
task.cancel();
 
// execute task synchronized
task.execute(listener);
複製代碼

多文件下載

final DownloadTask[] tasks = new DownloadTask[2];
tasks[0] = new DownloadTask.Builder("url1", "path", "filename1").build();
tasks[1] = new DownloadTask.Builder("url2", "path", "filename1").build();
DownloadTask.enqueue(tasks, listener);
複製代碼
  • 結合 Rxjava 實現文件下載
public class DownLoadUtils {

    /**
     * 從中控下載文件到本地
     * @param url
     * @param parentPath            保存到本地文件的父文件路徑
     * @param downloadFileName      保存到本地的文件名
     * @return
     */
    public static Observable<File> getDownLoadFile(String url,String parentPath,String downloadFileName){
        // 下載本地沒有的文件
        MainActivity.appendAndScrollLog(String.format("開始下載資源文件 %s\n", url));
        final DownloadTask task = new DownloadTask.Builder(url, parentPath, downloadFileName).build();
        return Observable.create(new ObservableOnSubscribe<File>() {
            @Override
            public void subscribe(final ObservableEmitter<File> emitter) throws Exception {
                task.enqueue(new DownloadListener2() {
                    @Override
                    public void taskStart(DownloadTask task) {

                    }

                    @Override
                    public void taskEnd(DownloadTask task, EndCause cause, Exception realCause) {
                        if (cause != EndCause.COMPLETED) {
                            MainActivity.appendAndScrollLog(String.format("資源文件下載失敗 %s %s\n", cause.toString(), task.getUrl()));
                            emitter.onNext(new File(""));
                            emitter.onComplete();
                            return;
                        }
                        File file = task.getFile();
                        MainActivity.appendAndScrollLog(String.format("資源文件下載完成 %s\n", file.getAbsolutePath()));
                        emitter.onNext(file);
                        emitter.onComplete();
                    }
                });
            }
        }).retry();
    }

    /**
     * 從中控下載文件到本地
     * @param url
     * @param saveFile  保存到本地的文件
     * @return
     */
    public static Observable<File> getDownLoadFile(String url, File saveFile){
        return getDownLoadFile(url,saveFile.getParentFile().getAbsolutePath(),saveFile.getName());
    }
}
複製代碼

屏蔽下拉菜單和底部導航欄

像娃娃機和格子機這些設備都是在線下直接面向用戶的,所以咱們不能將咱們的 Android 設備所有都展示給咱們的用戶,咱們須要對用戶的行爲作些限制,例如禁止用戶經過導航欄或者下拉菜單退出當前程序,防止他們作出一些危險的操做。個人解決方案是把當前的 rocket 程序設置爲默認啓動和桌面應用程序,並將 Android 設備中自帶的 launcher 程序 和 systemui 程序給禁用掉,那麼設備一開始啓動的時候就會啓動咱們的 rocket 應用,併成功的禁止了用戶使用導航欄和下拉菜單來作非法的操做。

  • 查找 Android 設備中自帶的 launcher 程序 和 systemui 程序的對應包名

    • 咱們使用 adb shell pm list packages 就能夠找出設備中已經安裝的程序列表,主要是以包名顯示的。
    • 查找 launcher 程序的包名,找出包名爲:com.android.launcher3
    LW-PC0920@lw1002022 MINGW64 ~/Desktop
    $ adb shell pm list packages | grep launcher
    package:com.android.launcher3
    複製代碼
    • 查找 systemui 程序的包名:找出包名爲:com.android.systemui
    LW-PC0920@lw1002022 MINGW64 ~/Desktop
    $ adb shell pm list packages | grep systemui
    package:com.android.systemui
    複製代碼
  • 禁止 Android 設備中自帶的 launcher 程序 和 systemui 程序的使用

    • 禁止 launcher 程序的使用
    adb shell pm disable com.android.launcher3
    複製代碼
    • 禁止 systemui 程序的使用
    adb shell pm disable com.android.systemui
    複製代碼
  • 代碼實現禁止 Android 設備中自帶的 launcher 程序 和 systemui 程序的使用

public static void enableLauncher(Boolean enabled) {
        List<PackageInfo> piList = MainActivity.instance.packageManager.getInstalledPackages(0);
        ArrayList<String> packages = new ArrayList();
        for (PackageInfo pi : piList) {
            String name = pi.packageName;
            if (name.contains("systemui") || name.contains("launcher")) {
                packages.add(name);
            }
        }
        for (String packageName : packages) {
            su(String.format("pm %s %s\n", enabled ? "enable" : "disable", packageName));
        }
    }
    
    /**
     *  執行 adb 指令
     *
     */
    public static int su(String cmd) {
        try {
            Process p = Runtime.getRuntime().exec("su");
            DataOutputStream os = new DataOutputStream(p.getOutputStream());
            os.writeBytes(cmd);
            os.writeBytes("exit\n");
            os.flush();
            os.close();
            return p.waitFor();
        } catch (Exception ex) {
            return -1;
        }
    }
複製代碼

Iot 的實現

關於 IoT 的實現,咱們這邊使用的是阿里的《微消息隊列 for IoT》服務,關於《微消息隊列 for IoT》服務,阿里的解釋以下:

微消息隊列 for IoT 是消息隊列(MQ)的子產品。針對用戶在移動互聯網以及物聯網領域的存在的特殊消息傳輸需求,消息隊列(MQ) 經過推出微消息隊列 for IoT 開放了對 MQTT 協議的完整支持

  • MQTT 協議?
    • MQTT 的全稱是:Message Queuing Telemetry Transport( 消息隊列遙測傳輸),是一種輕量的,基於發佈訂閱模型的即時通信協議。該協議設計開放,協議簡單,平臺支持豐富,幾乎能夠把全部聯網物品和外部鏈接起來,所以在移動互聯網和物聯網領域擁有衆多優點。
  • MQTT 的特色
    • 使用發佈/訂閱(Pub/Sub)消息模式,提供一對多的消息分發,解除了應用程序之間的耦合;
    • 對負載內容屏蔽的消息傳輸;
    • 使用 TCP/IP 提供基礎的網絡鏈接;
    • 有三種級別的消息傳遞服務;
    • 小型傳輸,開銷很小(頭部長度固定爲 2 字節),協議交換最小化,以下降網絡流量。
  • 關鍵名詞的解釋
    名詞 解釋
    Parent Topic MQTT 協議基於 Pub/Sub 模型,所以任何消息都屬於一個 Topic。根據 MQTT 協議,Topic 存在多級,定義第一級 Topic 爲父 Topic(Parent Topic),使用 MQTT 前,該 Parent Topic 須要先在 MQ 控制檯建立。
    Subtopic MQTT 的二級 Topic,甚至三級 Topic 都是父 Topic 下的子類。使用時,直接在代碼裏設置,無需建立。須要注意的是 MQTT 限制 Parent Topic 和 Subtopic 的總長度爲64個字符,若是超出長度限制將會致使客戶端異常。
    Client ID MQTT 的 Client ID 是每一個客戶端的惟一標識,要求全局惟一,使用相同的 Client ID 鏈接 MQTT 服務會被拒絕

Android 中實現 iot

關於顯示 iot 鏈接的實現過程是這樣的:首先咱們將設備的三元組從管理後臺中批量生成,文件名的格式爲 deviceName.json(例如:00001.json),裏面是關於每一個設備的三元組信息;接着咱們將裝有三元組文件的 U 盤插入到 Android 設備中(娃娃機或者口紅挑戰);rocket 程序會自動監測到 U 盤的插入並將文件剪切到 Android 設備的制定目錄下;再接着 Android 設備能夠去讀取指定文件中三元組信息;最後使用此三元組進行鏈接 mqtt。

  • 添加依賴
implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.0'
複製代碼
  • 關於三元組

    • 在 Android 設備中須要關心的三個東西,mqtt 協議中用來識別一個設備的必要三要素,若是存在相同的三元組,那麼必然出錯,致使mqtt 頻繁斷開重連。三元組這個主要是在阿里的管理後臺生成的,Android 設備這端只須要拿來用就能夠了。
    屬性 用處
    productKey 對應程序的 key,相似於 appid
    deviceName 對應上述的 Client ID,用來惟一識別一臺 Android 設備的
    deviceSecret 使用 HmacSHA1 算法計算簽名字符串,並將簽名字符串設置到 Password 參數中用於鑑權
  • 關於訂閱的 topic

    • 關於 topic 是在阿里雲的後臺管理中進行設置的,咱們的收發消息都是經過這些 topic 來進行的。
  • 代碼實現 iot 鏈接

    • 剪切三元組配置文件
    /**
     * 剪切配置文件(三元組)
     * @param packageName
     */
    public static void moveConfig(String packageName) {
        File usbConfigDir = new File(UsbStorage.usbPath, Config.wejoyConfigDirInUsb);
        File extProjectDir = new File(Environment.getExternalStorageDirectory(), Config.resourceDirName);
        File extConfigFile = new File(extProjectDir, Config.wejoyConfigFileInSdcard);
        if (!usbConfigDir.exists() || extConfigFile.exists()) {
            return;
        }
        extProjectDir.mkdirs();
        File[] configFiles = usbConfigDir.listFiles();
        if (configFiles.length > 0) {
            Arrays.sort(configFiles);
            moveFile(configFiles[0], extConfigFile);
        }
    }
    
    public static void moveFile(File src, File dst) {
        su(String.format("mv -f %s %s\n", src.getAbsolutePath(), dst.getAbsolutePath()));
    }
    
    複製代碼
    • 讀取指定路徑的配置文件信息(三元組)
    public static File configFile = new File(new File(Environment.getExternalStorageDirectory(), "WejoyRes"), "Config.json");
    
    static void read() throws IOException {
        if (configFile.exists()) {
            RandomAccessFile in = new RandomAccessFile(configFile, "r");
            byte[] buf = new byte[(int) configFile.length()];
            in.read(buf);
            in.close();
            instance = JsonIterator.deserialize(new String(buf, "utf-8"), Config.class);
        } else {
            instance = new Config();
        }
        mqttRequestTopic = String.format("/sys/%s/%s/rrpc/request/", instance.productKey, instance.deviceName);
        mqttResponseTopic = String.format("/sys/%s/%s/rrpc/response/", instance.productKey, instance.deviceName);
        mqttPublishTopic = String.format("/%s/%s/update", instance.productKey, instance.deviceName);
    }
    
    複製代碼
    • 鏈接 mqtt
    static void init() {
        instance = new IoT();
        DeviceInfo deviceInfo = new DeviceInfo();
        deviceInfo.productKey = Config.instance.productKey;
        deviceInfo.deviceName = Config.instance.deviceName;
        deviceInfo.deviceSecret = Config.instance.deviceSecret;
        final LinkKitInitParams params = new LinkKitInitParams();
        params.deviceInfo = deviceInfo;
        params.connectConfig = new IoTApiClientConfig();
        LinkKit.getInstance().registerOnPushListener(instance);
        initDisposable = Observable.interval(0, Config.instance.mqttConnectIntervalSeconds, TimeUnit.SECONDS)
                .subscribeOn(Schedulers.io())
                .observeOn(Schedulers.io())
                .map(new Function<Long, Boolean>() {
                    @Override
                    public Boolean apply(Long aLong) throws Exception {
                        if (!initialized) {
                            LinkKit.getInstance().init(MainActivity.instance, params, instance);
                        }
                        return initialized;
                    }
                })
                .subscribe(new Consumer<Boolean>() {
                    @Override
                    public void accept(Boolean aBoolean) throws Exception {
                        if (aBoolean) {
                            initDisposable.dispose();
                        }
                    }
                });
    }
    複製代碼
    • 發送消息: 發送消息的時候,咱們須要指定 topic,不然服務器沒法接收到咱們的消息。
    static void publish(String json) {
        Log.e(TAG, "publish: "+json );
        MqttPublishRequest res = new MqttPublishRequest();
        res.isRPC = false;
        res.topic = Config.mqttPublishTopic;
        res.payloadObj = json;
        LinkKit.getInstance().publish(res, new IConnectSendListener() {
            @Override
            public void onResponse(ARequest aRequest, AResponse aResponse) {
            }
    
            @Override
            public void onFailure(ARequest aRequest, AError aError) {
            }
        });
    }
    複製代碼
    • 接收消息: 接收消息的時候,咱們也須要判斷是來自哪一個 topic 中的,除了咱們指定的 topic,其餘的 topic 咱們都不作處理;當咱們接收到服務器中發送來的消息的時候,咱們是先判斷消息的類型,而後根據相對應的類型作出不一樣的反應。例如咱們收到後臺請求給娃娃機的上分的指令,那麼咱們就向設備中的硬件模塊發送上分的指令,並等待設備反應並給後臺發送一條響應信息。這條響應的消息是須要在指定的時間內完成,不然認爲超時。
    @Override
    public void onNotify(String s, final String topic, final AMessage aMessage) {
        if (!topic.startsWith(Config.mqttRequestTopic)) {
            return;
        }
        Observable.create(new ObservableOnSubscribe<MqttMessage>() {
            @Override
            public void subscribe(ObservableEmitter<MqttMessage> emitter) throws Exception {
                MqttMessage msg = JsonIterator.deserialize(new String((byte[]) aMessage.data, "utf-8"), MqttMessage.class);
                if (msg == null) {
                    return;
                }
                emitter.onNext(msg);
                emitter.onComplete();
            }
        })
                .subscribeOn(Schedulers.io())
                .observeOn(Schedulers.io())
                .flatMap(new Function<MqttMessage, ObservableSource<MqttMessage>>() {
                    @Override
                    public ObservableSource<MqttMessage> apply(MqttMessage msg) throws Exception {
                        Log.e(TAG, "收到消息 key:"+msg.key+" msg:"+msg.body.m);
                        switch (msg.key) {
                            case "h": {//
                                SetHeartBeatDownstream setHeartBeatDownstream = msg.body.m.as(SetHeartBeatDownstream.class);
                                // 和設備進行通訊,並等待設備的響應
                                return Device.setHeartBeat(setHeartBeatDownstream);
                            }
                            case "b": {//
                                AddCoinsDownstream addCoinsDownstream = msg.body.m.as(AddCoinsDownstream.class);
                                // 和設備進行通訊,並等待設備的響應
                                return Device.addCoins(addCoinsDownstream);
                            }
                            case "g": {//
                                // 和設備進行通訊,並等待設備的響應
                                return Device.getParam();
                            }
                            case "s": {//
                                SetParamDownstream setParamDownstream = msg.body.m.as(SetParamDownstream.class);
                                // 和設備進行通訊,並等待設備的響應
                                return Device.setParam(setParamDownstream);
                            }
                        }
                        return Observable.never();
                    }
                })
                .observeOn(Schedulers.io())
                .map(new Function<MqttMessage, Boolean>() {
                    @Override
                    public Boolean apply(MqttMessage msg) throws Exception {
                        MqttPublishRequest res = new MqttPublishRequest();
                        res.isRPC = false;
                        res.topic = topic.replace("request", "response");
                        //res.msgId = topic.split("/")[6];
                        res.payloadObj = JsonStream.serialize(msg);
                        LinkKit.getInstance().publish(res, new IConnectSendListener() {
                            @Override
                            public void onResponse(ARequest aRequest, AResponse aResponse) {
                            }
    
                            @Override
                            public void onFailure(ARequest aRequest, AError aError) {
                            }
                        });
                        return true;
                    }
                })
                .subscribe();
    }
    複製代碼

Android 和硬件通訊

在娃娃機和口紅挑戰的這兩個設備中,咱們都須要和設備進行通訊,例如:娃娃機投幣、娃娃機出禮反饋、按下選中口紅的格子等等這些都是須要和硬件模塊進行通訊的。在關於串口通訊的框架選擇方面,咱們主要是選擇 Google 的 android-serialport-api 來實現。項目原地址

  • 依賴方式

    1. 在根build.gradle中添加
    allprojects {
        repositories {
            ...
            maven { url 'https://jitpack.io' }
        }
    }
    複製代碼
    1. 子module添加依賴
    dependencies {
        implementation 'com.github.licheedev.Android-SerialPort-API:serialport:1.0.1'
    }
    複製代碼
  • 修改su路徑

// su默認路徑爲 "/system/bin/su"
// 可經過此方法修改
SerialPort.setSuPath("/system/xbin/su");
複製代碼
  • 鏈接方式

鏈接串口的時候須要指定串口號以及波特率,以後定時處理機器發送的指令。

static void init() throws IOException {
        SerialPort.setSuPath("/system/xbin/su");
        // 設置串口號及波特率
        serialPort = new SerialPort(Config.serialPort, Config.baudrate);
        // 接收指令流
        inputStream = serialPort.getInputStream();
        // 發送指令流
        outputStream = serialPort.getOutputStream();
        // 每隔 100ms 處理機器信息
        Observable.interval(100, TimeUnit.MILLISECONDS)
                .observeOn(serialScheduler)
                .subscribe(new Consumer<Long>() {
                    @Override
                    public void accept(Long aLong) throws Exception {
                        // 處理機器發送的指令
                        handleRecv();
                    }
                });
    }

複製代碼
  • 向機器發送指令

向機器發送指令的時候是結合 Rxjava 來實現的。除此以外,向機器發送指令是須要有規定格式的(內部制定的通訊協議),咱們發送及接收數據都是一個字節數組,所以咱們格式是須要嚴格按照咱們制定的協議進行的,以下是娃娃機投幣的簡單示例:

static ObservableSource<MqttMessage> addCoins(final AddCoinsDownstream msg) {
        return Observable.create(new ObservableOnSubscribe<MqttMessage>() {
            @Override
            public void subscribe(ObservableEmitter<MqttMessage> emitter) throws Exception {
                currentUser = msg.u;
                currentHeadUrl = msg.h;
                currentNickname = msg.nk;
                byte[] buf = new byte[]{0x11, addCoinsCmd, msg.num, msg.c, 0, 0x00, 0x00};
                byte[] ret = sign(buf);
                try {
                    outputStream.write(ret);
                } catch (IOException e) {
                    e.printStackTrace();
                }
                penddingCmd = addCoinsCmd;
                penddingEmitter = emitter;
            }
        })
                .subscribeOn(serialScheduler);
    }

複製代碼
  • 接收機器指令

關於接受機器消息這一塊是每隔 100ms 進行的,在處理機器指令的時候,首先須要過濾到無效的字節,以後再按照咱們制定的協議來處理消息,判斷是娃娃機上分,仍是遊戲結果等信息,最後並對機器的數據返回進行 CRC16 校驗。

static void handleRecv() {
        try {
            for (; ; ) {
                int len = inputStream.available();
                if (len <= 0) {
                    break;
                }
                len = inputStream.read(buf, bufReadOffset, buf.length - bufReadOffset);
                //Log.d("serialPort", String.format("read: %s", byteToHex(buf, bufReadOffset, len)));
                bufReadOffset += len;
                for (; ; ) {
                    if (bufParseEnd == -1) {
                        for (; bufParseStart < bufReadOffset; bufParseStart++) {
                            if (buf[bufParseStart] == (byte) 0xAA) {
                                bufParseEnd = bufParseStart + 1;
                                break;
                            }
                        }
                    }
                    if (bufParseEnd != -1) {
                        for (; bufParseEnd < bufReadOffset; bufParseEnd++) {
                            if (buf[bufParseEnd] == (byte) 0xAA) {
                                bufParseStart = bufParseEnd;
                                bufParseEnd += 1;
                                continue;
                            }
                            if (buf[bufParseEnd] == (byte) 0xDD) {
                                if (bufParseEnd - bufParseStart >= 5) {
                                    bufParseEnd += 1;
                                    byte size = buf[bufParseStart + 1];
                                    byte index = buf[bufParseStart + 2];
                                    byte cmd = buf[bufParseStart + 3];
                                    byte check = (byte) (size ^ index ^ cmd);
                                    for (int i = bufParseStart + 4; i < bufParseEnd - 2; i++) {
                                        check ^= buf[i];
                                    }
                                    if (check == buf[bufParseEnd - 2]) {
                                        //Log.d("serialPort", String.format("protocol: %s, size: %d, index: %d, cmd: %d, check: %d, data: %s", byteToHex(buf, bufParseStart, bufParseEnd - bufParseStart), size, index, cmd, check, byteToHex(buf, bufParseStart + 4, size - 3)));
                                        switch (cmd) {
                                            // 心跳
                                            case heartBeatCmd: {
                                            }
                                            break;
                                            
                                            // 上分
                                            case addCoinsCmd: {
                                                
                                            }
                                            break;
                                            
                                            // 遊戲結果
                                            case gameResultCmd: {
                                                boolean gift = buf[bufParseStart + 7] != 0;
                                                IoT.sendGameResult(gift);
                                                if (gift) {
                                                    // 發送用戶信息到中控,進行排行榜顯示
                                                    WSSender.getInstance().sendUserInfo(currentUser, currentHeadUrl, currentNickname);
                                                }
                                            }
                                            break;
                                            default:
                                                break;
                                        }
                                    }
                                }
                                bufParseStart = bufParseEnd;
                                bufParseEnd = -1;
                                break;
                            }
                        }
                    }
                    if (bufParseStart >= bufReadOffset || bufParseEnd >= bufReadOffset) {
                        break;
                    }
                }
                if (bufReadOffset == buf.length) {
                    System.arraycopy(buf, bufParseStart, buf, 0, bufReadOffset - bufParseStart);
                    if (bufParseEnd != -1) {
                        bufParseEnd -= bufParseStart;
                        bufReadOffset = bufParseEnd;
                    } else {
                        bufReadOffset = 0;
                    }
                    bufParseStart = 0;
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
複製代碼

websocket 通訊

在中控和娃娃機進行通訊的方式咱們是選擇 websocket 進行的。中控端是 server,而後娃娃機是 client。

server

  • Server 的實現:目前 server 的實現只是爲了接收娃娃機的數據反饋,因此並無什麼複雜的操做。
class WSServer extends WebSocketServer {
    private MainActivity mainActivity;

    public void setMainActivity(MainActivity mainActivity) {
        this.mainActivity = mainActivity;
    }

    WSServer(InetSocketAddress address) {
        super(address);
    }

    @Override
    public void onOpen(WebSocket conn, ClientHandshake handshake) {
        mainActivity.appendAndScrollLog("客戶端:" + conn.getRemoteSocketAddress() + " 已鏈接\n");
    }

    @Override
    public void onClose(WebSocket conn, int code, String reason, boolean remote) {
        mainActivity.appendAndScrollLog("客戶端:" + conn.getRemoteSocketAddress() + " 已斷開\n");
    }

    @Override
    public void onMessage(WebSocket conn, final String message) {
        Observable.create(new ObservableOnSubscribe<SocketMessage>() {
            @Override
            public void subscribe(ObservableEmitter<SocketMessage> emitter) throws Exception {
                final SocketMessage socketMessage = JsonIterator.deserialize(message, SocketMessage.class);
                emitter.onNext(socketMessage);
                emitter.onComplete();
            }
        })
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Consumer<SocketMessage>() {
                    @Override
                    public void accept(SocketMessage socketMessage) throws Exception {
                        if (socketMessage.getCode() == SocketMessage.TYPE_USER) {
                            // 夾到娃娃
                            
                        } else if (socketMessage.getCode() == SocketMessage.TYPE_SAY_HELLO) {
                            // 鏈接招呼語
                        }
                    }
                });


    }

    @Override
    public void onError(WebSocket conn, Exception ex) {
    }

    @Override
    public void onStart() {

    }
}
複製代碼
  • 簡單使用方式
appendAndScrollLog("初始化WebSocket服務...\n");
    WSServer wsServer = new WSServer(18104);
    wsServer.setMainActivity(MainActivity.this);
    wsServer.setConnectionLostTimeout(5);
    wsServer.setReuseAddr(true);
    wsServer.start();
    appendAndScrollLog("初始化WebSocket服務完成\n");
複製代碼

client

在 client 端,目前須要作的人物有斷開重連以及數據發送的操做。斷開重連的時候須要在新的子線程中進行,不然會報以下錯誤:

You cannot initialize a reconnect out of the websocket thread. Use reconnect in another thread to insure a successful cleanup
複製代碼

所以,咱們每次斷開從新的時候是須要在新的子線程中進行的。除此以外,在發送數據的時候,若是恰好 socket 沒有鏈接上,那麼發送數據是會報異常的,所以咱們有數據要發送的時候若是 socket 沒有鏈接,那麼就先緩存到本地,等到 socket 鏈接上以後再把滯留的數據一次性發送出去。

  • 依賴配置
implementation 'org.java-websocket:Java-WebSocket:1.3.9'
複製代碼
  • WSClient.java
class WSClient extends WebSocketClient {

    private static final String TAG = "WSClient";
    private static WSClient instance;
    private static URI sUri;
    private WSReceiver mWSReceiver;
    private Disposable mReconnectDisposable;
    private ConnectCallback mConnectCallback;

    /**
     * step 1:須要先調用,設置 url
     * @param uri
     */
    public static void setUri(URI uri){
        sUri = uri;
    }

    /**
     * step 1:
     * 須要先調用,設置服務端的 url
     * @param ipAddress
     * @param port
     */
    public static void setUri(String ipAddress,int port){
        try {
            sUri = new URI(String.format("ws://%s:%d", ipAddress, port));
        } catch (URISyntaxException e) {
            e.printStackTrace();
        }
    }


    public static WSClient getInstance(){
        if (instance == null) {
            synchronized (WSClient.class){
                if (instance == null) {
                    instance = new WSClient(sUri);
                }
            }
        }
        return instance;
    }

    /**
     * step 2:鏈接 websocket
     */
    public void onConnect(){
        setConnectionLostTimeout(Config.instance.webSocketTimeoutSeconds);
        setReuseAddr(true);
        connect();
    }

    private WSClient(URI server) {
        super(server);
        // 初始化消息發送者
        WSSender.getInstance().setWSClient(this);
        // 初始化消息接收者
        mWSReceiver = new WSReceiver();
        mWSReceiver.setWSClient(this);
        mWSReceiver.setWSSender(WSSender.getInstance());

    }


    @Override
    public void onOpen(ServerHandshake handshakedata) {
        Log.d(TAG, "onOpen: ");
        MainActivity.appendAndScrollLog("websocket 已鏈接\n");
        Observable.just("")
                .subscribeOn(AndroidSchedulers.mainThread())
                .subscribe(new Consumer<Object>() {
                    @Override
                    public void accept(Object o) throws Exception {
                        if (mConnectCallback != null) {
                            mConnectCallback.onWebsocketConnected();
                        }
                    }
                });

        // 清除滯留的全部消息
        WSSender.getInstance().clearAllMessage();
        
    }

    @Override
    public void onMessage(String message) {
        Log.d(TAG, "onMessage: ");
        mWSReceiver.handlerMessage(message);
    }

    @Override
    public void onClose(int code, String reason, boolean remote) {
        Log.d(TAG, "onClose: ");
        MainActivity.appendAndScrollLog(String.format("websocket 已斷開,斷開緣由:%s\n",reason));
        Observable.just("")
                .subscribeOn(AndroidSchedulers.mainThread())
                .subscribe(new Consumer<Object>() {
                    @Override
                    public void accept(Object o) throws Exception {
                        if (mConnectCallback != null) {
                            mConnectCallback.onWebsocketClosed();
                        }
                    }
                });
        onReconnect();
    }

    @Override
    public void onError(Exception ex) {
        if (ex != null) {
            Log.d(TAG, "onError: "+ex.getMessage());
            MainActivity.appendAndScrollLog(String.format("websocket 出現錯誤,錯誤緣由:%s\n",ex.getMessage()));
        }
        onReconnect();
    }


    public void onReconnect() {
        if (mReconnectDisposable != null
                && !mReconnectDisposable.isDisposed()){
            return;
        }
        mReconnectDisposable = Observable.timer(1, TimeUnit.SECONDS)
                .subscribeOn(Schedulers.io())
                .subscribe(new Consumer<Long>() {
                    @Override
                    public void accept(Long aLong) throws Exception {
                        Log.d(TAG, "websocket reconnect");
                        WSClient.this.reconnect();
                        mReconnectDisposable.dispose();
                    }
                });

    }

    public void setConnectCallback(ConnectCallback mConnectCallback) {
        this.mConnectCallback = mConnectCallback;
    }

    public interface ConnectCallback{
        void onWebsocketConnected();
        void onWebsocketClosed();
    }
}

複製代碼
  • WSSender.java
/**
 * Created by runla on 2018/10/26.
 * 文件描述:Websocket 的消息發送者
 */

public class WSSender {
    private static final String TAG = "WSSender";
    public static final int MAX_MESSAGE_COUNT = 128;
    private static WSSender instance;
    private WSClient mWSClientManager;
    // 消息隊列
    private LinkedList<String> mMessageList = new LinkedList<>();

    private WSSender() {
    }

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

    public void setWSClient(WSClient wsClientManager) {
        this.mWSClientManager = wsClientManager;
    }

    /**
     * 發送全部滯留的消息
     */
    public void clearAllMessage() {
        if (mWSClientManager == null) {
            return;
        }

        while (mMessageList.size() > 0
                && mMessageList.getFirst() != null) {
            Log.d(TAG, "sendMessage: " + mMessageList.size());
            mWSClientManager.send(mMessageList.getFirst());
            mMessageList.removeFirst();
        }
    }

    /**
     * 發送消息,若是消息發送不出去,那麼就等到鏈接成功後再次嘗試發送
     *
     * @param msg
     * @return
     */
    public boolean sendMessage(String msg) {
        if (mWSClientManager == null) {
            throw new NullPointerException("websocket client is null");
        }
        if (TextUtils.isEmpty(msg)) {
            return false;
        }
        // 將須要發送的數據添加到隊列的尾部
        mMessageList.addLast(msg);

        while (mMessageList.size() > 0
                && mMessageList.getFirst() != null) {
            Log.d(TAG, "sendMessage: " + mMessageList.size());
            if (!mWSClientManager.isOpen()) {
                // 嘗試重連
                mWSClientManager.onReconnect();
                break;
            } else {
                mWSClientManager.send(mMessageList.getFirst());
                mMessageList.removeFirst();
            }
        }

        // 若是消息隊列中超過咱們設置的最大容量,那麼移除最早添加進去的消息
        if (mMessageList.size() >= MAX_MESSAGE_COUNT) {
            mMessageList.removeFirst();
        }
        return false;
    }
}

複製代碼
  • WSReceiver.java
/**
 * Created by runla on 2018/10/26.
 * 文件描述:Websocket 的消息接收者
 */

public class WSReceiver {
    private WSClient mWSClientManager;
    private WSSender mWSSender;
    private OnMessageCallback onMessageCallback;
    public WSReceiver() {
    }


    public void setWSClient(WSClient mWSClientManager) {
        this.mWSClientManager = mWSClientManager;
    }

    public void setWSSender(WSSender mWSSender) {
        this.mWSSender = mWSSender;
    }

    /**
     * 處理接收消息
     * @param message
     */
    public void handlerMessage(String message){

        if (onMessageCallback != null){
            onMessageCallback.onHandlerMessage(message);
        }
    }

    public void setOnMessageCallback(OnMessageCallback onMessageCallback) {
        this.onMessageCallback = onMessageCallback;
    }

    public interface OnMessageCallback{
        void onHandlerMessage(String message);
    }
}

複製代碼
  • 鏈接調用
appendAndScrollLog("初始化WebSocket客戶端...\n");
    WSClient.setUri( Config.instance.centralServerAddress, Config.instance.webSocketPort);
    WSClient.getInstance().onConnect();
    WSClient.getInstance().setConnectCallback(MainActivity.this);
    appendAndScrollLog("初始化WebSocket客戶端完成\n");

複製代碼
  • 數據發送
// 清除滯留的全部消息
WSSender.getInstance().clearAllMessage();

// 發送消息
WSSender.getInstance().sendMessage(msg);

複製代碼

數據庫存儲

在中控端,咱們須要顯示排行版,用來顯示夾中娃娃機的用戶在本月及本週夾中娃娃的排行,所以咱們須要再中控端保存用戶的夾中娃娃數量以及我的的其餘信息,GreenDAO 是一款開源的面向 Android 的輕便、快捷的 ORM 框架,將 Java 對象映射到 SQLite 數據庫中,咱們操做數據庫的時候,不在須要編寫複雜的 SQL語句, 在性能方面,GreenDAO 針對 Android 進行了高度優化, 最小的內存開銷 、依賴體積小,同時仍是支持數據庫加密。關於 GreenDAO 的用法我就不在這裏作,具體的用法能夠參考官網 GreenDAO


寫在最後

關於整個系統的架構搭建過程當中遇到了好多坑,以上是我爲這個項目提供的部分解決方案,當前所有的是不可能都放寫出來的,此項目目前已經在西安和成都等地都有門店點了,據反饋,利潤極大,不過這種類型的項目紅利期不會太長,估計也是 2~3 年左右吧。若是有須要咱們爲 口紅機開發 或者是 娃娃機開發 提供解決方案的,能夠聯繫咱們,目前咱們在這個方面已經有相對較爲成熟的解決方案了。

相關文章
相關標籤/搜索