一、 为啥要做这个
跳绳是个挺简单的运动,但有个地方一直觉得别扭:想知道跳了多少个、心率多少,或者间歇训练还剩多久休息,就得停下来看手机,节奏一下就断了。我就想,要是这些数字能一直飘在眼前就好了。手头正好有Rokid Glasses和它的CXR-M SDK开发套件,就决定自己动手试一下。
主要想实现几个功能:
- 基础计数:得能准确实时数我跳了多少个。
- 心率显示:连上心率手环,数据要能同步显示。
- 训练模式:除了随便跳的自由模式,还得支持间歇训练(比如跳45秒休15秒)。
- 好玩一点:能跟朋友联网实时PK。
二、 大概怎么实现的
整体思路不复杂:手机当成一个智能计算中心,眼镜当成一个显示屏。
- 手机干活:运行一个App,负责用手机传感器数跳绳,通过蓝牙接收心率数据,管理训练模式(自由、间歇、PK)的逻辑计时。
- 眼镜显示:眼镜上运行一个简单的应用,只负责接收数据并把它们画在屏幕上。
- 连接桥梁:两者通过CXR-M SDK连接,手机把处理好的数据打包发给眼镜。
整个系统的数据流和工作流程大致如下:
三、 具体怎么做的
1. 数清楚跳了多少个 一开始用简单的波峰检测,发现手稍微晃一下就可能误数。后来改进了算法,结合了加速度计和陀螺仪的数据。简单说,就是只有检测到“往上跳”的加速度变化,同时伴随“摇绳”的旋转动作时,才算一次有效跳跃。这样能过滤掉很多无关的抖动。
2. 心率数据处理 从蓝牙手环读心率数据,关键是要稳。会遇到信号干扰,突然跳出一个离谱的数字(比如从70瞬间跳到200)。我的处理方法是加了个判断:如果新心率值和上次比差别太大,或者超出了合理范围(比如低于40或高于200),就把它扔掉,还是显示上一次的稳定值。虽然可能会延迟一秒更新,但总显示乱跳的数字强。
3. 管理三种训练模式 我用一个“状态机”来管理所有模式,这样代码清楚,不容易乱。
// 状态机的核心定义
public class TrainingStateMachine {
// 三种模式
public enum Mode { FREE, INTERVAL, PK }
// 几种状态
public enum State { IDLE, WORKING, RESTING, PK_BATTLE }
private Mode currentMode = Mode.FREE;
private State currentState = State.IDLE;
private int setsCompleted = 0; // 当前组数
private long timeLeftInPhase = 0; // 当前阶段剩余时间
// 这个方法把要显示的数据打包成一个JSON对象
public JSONObject packData(int jumpCount, int heartRate) {
JSONObject data = new JSONObject();
try {
// 不管什么模式,核心数据都一样
data.put("jumps", jumpCount);
data.put("heartRate", heartRate);
data.put("mode", currentMode.name());
data.put("state", currentState.name());
// 不同模式,再塞点特有的数据
if (currentMode == Mode.INTERVAL) {
data.put("sets", setsCompleted + "/" + totalSets);
data.put("timeLeft", timeLeftInPhase);
} else if (currentMode == Mode.PK) {
data.put("opponentJumps", pkOpponentJumps);
data.put("pkTimeLeft", pkTimeLeft);
}
} catch (Exception e) { e.printStackTrace(); }
return data;
}
// ... 这里还有控制状态切换的逻辑 ...
}
用状态机的好处是,逻辑清晰。比如在RESTING状态,计时器归零后自动切回WORKING状态,并开始新一组计数,不容易出错。
4. 联网实时PK功能的核心实现 PK模式是最复杂的,需要和服务器通信。我在服务器上搭了个简单的WebSocket服务,处理匹配和实时数据转发。
手机端PK管理器核心代码:
public class PKBattleManager {
private WebSocketClient wsClient;
private String matchId;
private int myJumps = 0;
private int opponentJumps = 0;
private boolean isConnected = false;
// 开始一场PK
public void startPKBattle(String opponentId) {
// 连接WebSocket服务器
wsClient = new WebSocketClient("ws://yourserver.com/pk") {
@Override
public void onMessage(String message) {
// 处理服务器发来的消息
processServerMessage(message);
}
@Override
public void onConnected() {
isConnected = true;
// 发送开始PK的请求
JSONObject startMsg = new JSONObject();
startMsg.put("type", "start_battle");
startMsg.put("opponent_id", opponentId);
startMsg.put("duration", 60); // PK时长为60秒
wsClient.send(startMsg.toString());
}
};
wsClient.connect();
}
// 处理服务器消息
private void processServerMessage(String message) {
try {
JSONObject msg = new JSONObject(message);
String type = msg.getString("type");
if ("opponent_update".equals(type)) {
// 收到对手数据更新
opponentJumps = msg.getInt("jumps");
int opponentHeartRate = msg.getInt("heart_rate");
updatePKDisplay(); // 更新眼镜显示
} else if ("match_result".equals(type)) {
// 收到比赛结果
String winner = msg.getString("winner");
showPKResult(winner);
}
} catch (JSONException e) { e.printStackTrace(); }
}
// 向服务器发送自己的最新数据
public void sendMyUpdate(int jumps, int heartRate) {
if (!isConnected) return;
JSONObject updateMsg = new JSONObject();
try {
updateMsg.put("type", "player_update");
updateMsg.put("match_id", matchId);
updateMsg.put("jumps", jumps);
updateMsg.put("heart_rate", heartRate);
updateMsg.put("timestamp", System.currentTimeMillis());
wsClient.send(updateMsg.toString());
} catch (JSONException e) { e.printStackTrace(); }
}
// 更新眼镜上的PK界面
private void updatePKDisplay() {
JSONObject pkData = new JSONObject();
try {
pkData.put("mode", "PK");
pkData.put("state", "PK_BATTLE");
pkData.put("my_jumps", myJumps);
pkData.put("opponent_jumps", opponentJumps);
pkData.put("difference", myJumps - opponentJumps);
// 通过SDK发送给眼镜
sendToGlasses(pkData);
} catch (JSONException e) { e.printStackTrace(); }
}
}
服务器端简单的消息转发逻辑(Node.js示例):
// WebSocket服务器消息处理
wsServer.on('connection', (client) => {
client.on('message', (message) => {
const data = JSON.parse(message);
if (data.type === 'start_battle') {
// 记录匹配信息
client.matchId = data.match_id;
client.opponentId = data.opponent_id;
} else if (data.type === 'player_update') {
// 将一名玩家的数据转发给其对手
const opponent = findClientById(data.opponent_id);
if (opponent) {
opponent.send(JSON.stringify({
type: 'opponent_update',
jumps: data.jumps,
heart_rate: data.heart_rate,
timestamp: data.timestamp
}));
}
}
});
});
5. 把数据发给眼镜 用SDK里的“飞传”功能,把上面打包好的JSON数据发过去。
public void sendToGlasses(JSONObject data) {
FileTransferManager transferManager = FileTransferManager.getInstance();
String jsonString = data.toString();
byte[] bytes = jsonString.getBytes();
// 把数据当成一个虚拟的文件流发过去
ByteArrayInputStream virtualFile = new ByteArrayInputStream(bytes);
transferManager.sendFile(virtualFile, "sport_data.json", bytes.length, new FileTransferCallback() {
@Override
public void onSuccess(String fileId) {
// 发送成功
}
@Override
public void onError(String fileId, int errorCode) {
// 发送失败,可能需要重试
}
});
}
这里没生成真实文件,直接在内存里操作,速度快。
6. 眼镜上怎么显示 眼镜端的应用根据收到的数据,决定显示什么。
// 眼镜端应用(JavaScript代码)
FileTransfer.on('file', (file) => {
if (file.name === 'sport_data.json') {
const data = JSON.parse(fs.readFileSync(file.path, 'utf8'));
updateDisplay(data);
}
});
function updateDisplay(data) {
let line1, line2;
// 关键是看当前是什么状态
if (data.state === 'WORKING') {
line1 = `计数: ${data.jumps}`;
line2 = `心率: ${data.heartRate}`;
} else if (data.state === 'RESTING') {
// 休息时,倒计时最重要,用大点字号
line1 = `休息: ${data.timeLeft}s`;
line2 = `下一组准备`;
} else if (data.state === 'PK_BATTLE') {
// PK模式,显示对比
line1 = `我: ${data.my_jumps} 对手: ${data.opponent_jumps}`;
line2 = `差: ${data.difference}`;
}
// 调用SDK的方法,把文字画到屏幕上
displayOverlay(line1, line2);
}
这样设计,显示的内容就和训练状态紧密相关,比较智能。
四、 遇到的问题和解决办法
- 数据太多眼镜卡:一开始传感器数据来得飞快,眼镜渲染不过来。后来在手机端做了限制,每秒只发5次更新,流畅多了。
- 间歇训练计时不准:刚开始发现手机和眼镜的时间有微小偏差。后来让眼镜在每阶段开始时都跟手机对一下时,解决了。
- PK模式有延迟:网络传输总有延迟,导致双方数据不同步。我做了个优化:在等网络数据的时候,眼镜先根据自己的节奏显示一个预测值,等真实数据到了再悄悄修正过来,用户就不太容易感觉到延迟了。
- PK网络断线重连:WiFi不稳定时PK会断掉。后来加了自动重连机制,断线后尝试重连,并同步最新的比赛状态。
五、 最后的结果和感想
最后做出来的东西,基本能用:
- 跳绳计数挺准的,正常跳的话,误差很小。
- 心率显示稳定,不会乱跳。
- 三种模式都能正常工作,PK模式也挺有趣,实时性还不错。
做这个项目,感觉CXR-M SDK在连接和基础通信上做得不错,省了我很多事。最大的体会是,这种项目难点往往不在多高深的算法,而在于怎么把各个模块(传感器、蓝牙、网络、显示)顺畅地拼在一起,并处理好各种边界情况。
如果以后还有时间,可能想再加个语音提示功能,或者在PK模式里加更多玩法。但现在这个样子,已经能解决我最初“不想低头看手机”的问题了。技术能这样解决点实际的小麻烦,我觉得就挺有价值。