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

蘆葦科技APP團隊發表於2019-01-11

專案背景

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

然後呢,我們公司就是為這些裝置的正常執行提供解決方案的。因此才有我今天的爬坑總結,哈哈哈哈哈.....

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

  • 系統架構圖

門店系統結構圖.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 資源。

資源更新的方式

關於更新的方式,這裡其實就有一個比較坑的地方了,一開始的時候我們選擇的資源更新方式比較傻,直接使用 websocket 進行資源更新的,一開始的時候只有一個裝置進行連線,問題倒是不大,但是後來發現多臺裝置連線同時更新資源的時候問題特別大,連線經常斷開,導致資源更新失敗。那麼這裡是我遇到的第一個坑。發現這個坑之後呢,我的選擇資源更新的方式就更改為:NanoHttpd。NanoHttpd 是一個開源庫,是用 Java 實現的,它可以在 Android 裝置上建立一個輕量級的 web server。其實在 Android 裝置上建立一個輕量級的 web server 才是我們一開始就應該要選擇的方向。為什麼呢?首先 NanoHttpd 的使用是比較簡單的,因此我們只需要幾行程式碼就可以實現一個 web server 了;其次呢,NanoHttpd 是比較穩定的,相對於我們手動使用 websocket 去實現一個資源分發要穩定太多了。

那麼在我們選擇了資源的更新方式之後,有另外一個問題浮出水面了,關於伺服器的 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 地址變化的這個問題的。我們的解決方案有如下兩個步驟:

  1. 在路由器中根據 Mac 地址,為門店內的中控裝置設定固定的 IP 地址
  2. 為每個娃娃機和口紅挑戰裝置提供一個 IP 地址的配置檔案,這個檔案裡面有門店中控的 IP 地址資訊,放在 U 盤的指定目錄下,但插入裝置的時候,由 Rocket 程式將檔案從 U 盤中將配置檔案 copy 到裝置的制定目錄下,裝置每次啟動的時候都需要先讀取配置檔案,再連線本地的伺服器。

資源什麼時候更新

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

更新的資源

  • 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 地址

  • 依賴方式
    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 年左右吧。如果有需要我們為 口紅機開發 或者是 娃娃機開發 提供解決方案的,可以聯絡我們,目前我們在這個方面已經有相對較為成熟的解決方案了。

相關文章