今年,相信不少人都會在各個商場或者是電影院中能夠看到各類娃娃機、幸運盒子、口紅挑戰等等相似機器。在抖音上《口紅挑戰》這款機子也是火的一塌糊塗,你只要花 10 塊錢就有可能贏走一個 YSL 口紅,想一想就以爲頗有誘惑力。或許這些機器被程序員一瞧就知道其中的貓膩,可是這個機器瞄準的是那些容易衝動消費的消費者,好比情侶、女生、帶小孩的大人;像程序員這麼奇葩的生物,通常都是直接被無視的哈哈。css
而後呢,咱們公司就是爲這些設備的正常運行提供解決方案的。所以纔有我今天的爬坑總結,哈哈哈哈哈.....html
咱們提供的解決方案是這樣的,在一個門店裏面會包含以下的設備:娃娃機、口紅挑戰、排行榜、中控,固然其中還有咱們的後臺服務。那麼首先我會先介紹一下整個系統的架構以及各個設備的職責:java
關於資源同步的,首先咱們先理一下咱們須要同步的資源有哪些,這些資源分別爲: 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 變化的問題。程序員
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);
複製代碼
在 Android 設備中,它的一個 IP 地址是會變化的,並且每一個門店都會有一個本身的內部中控機,那麼咱們是必需要處理 IP 地址變化的這個問題的。咱們的解決方案有以下兩個步驟:github
關於資源更新的,咱們首先須要明確咱們須要更新的資源有哪些以及咱們須要更新的方式。web
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;
}
複製代碼
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();
複製代碼
{
"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"
}
}
複製代碼
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();
}
複製代碼
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();
}
複製代碼
關於程序的升級,相比較於圖片資源的更新要簡單許多。算法
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);
複製代碼
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 程序的對應包名
LW-PC0920@lw1002022 MINGW64 ~/Desktop
$ adb shell pm list packages | grep launcher
package:com.android.launcher3
複製代碼
LW-PC0920@lw1002022 MINGW64 ~/Desktop
$ adb shell pm list packages | grep systemui
package:com.android.systemui
複製代碼
禁止 Android 設備中自帶的 launcher 程序 和 systemui 程序的使用
adb shell pm disable com.android.launcher3
複製代碼
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 的實現,咱們這邊使用的是阿里的《微消息隊列 for IoT》服務,關於《微消息隊列 for IoT》服務,阿里的解釋以下:
微消息隊列 for IoT 是消息隊列(MQ)的子產品。針對用戶在移動互聯網以及物聯網領域的存在的特殊消息傳輸需求,消息隊列(MQ) 經過推出微消息隊列 for IoT 開放了對 MQTT 協議的完整支持
名詞 | 解釋 |
---|---|
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 服務會被拒絕 |
關於顯示 iot 鏈接的實現過程是這樣的:首先咱們將設備的三元組從管理後臺中批量生成,文件名的格式爲 deviceName.json(例如:00001.json),裏面是關於每一個設備的三元組信息;接着咱們將裝有三元組文件的 U 盤插入到 Android 設備中(娃娃機或者口紅挑戰);rocket 程序會自動監測到 U 盤的插入並將文件剪切到 Android 設備的制定目錄下;再接着 Android 設備能夠去讀取指定文件中三元組信息;最後使用此三元組進行鏈接 mqtt。
implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.0'
複製代碼
關於三元組
屬性 | 用處 |
---|---|
productKey | 對應程序的 key,相似於 appid |
deviceName | 對應上述的 Client ID,用來惟一識別一臺 Android 設備的 |
deviceSecret | 使用 HmacSHA1 算法計算簽名字符串,並將簽名字符串設置到 Password 參數中用於鑑權 |
關於訂閱的 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);
}
複製代碼
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();
}
}
});
}
複製代碼
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) {
}
});
}
複製代碼
@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();
}
複製代碼
在娃娃機和口紅挑戰的這兩個設備中,咱們都須要和設備進行通訊,例如:娃娃機投幣、娃娃機出禮反饋、按下選中口紅的格子等等這些都是須要和硬件模塊進行通訊的。在關於串口通訊的框架選擇方面,咱們主要是選擇 Google 的 android-serialport-api 來實現。項目原地址
依賴方式
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
複製代碼
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 進行的。中控端是 server,而後娃娃機是 client。
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 端,目前須要作的人物有斷開重連以及數據發送的操做。斷開重連的時候須要在新的子線程中進行,不然會報以下錯誤:
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'
複製代碼
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();
}
}
複製代碼
/**
* 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;
}
}
複製代碼
/**
* 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 年左右吧。若是有須要咱們爲 口紅機開發 或者是 娃娃機開發 提供解決方案的,能夠聯繫咱們,目前咱們在這個方面已經有相對較爲成熟的解決方案了。