新手上手:Rokid 移動端 + 眼鏡端最小實踐
每個開發,都喜歡逛論壇。我就是在逛論壇的時候,發現了Rokid,好吧,我承認我對這玩意很感興趣。稍微看了下Rokid的開發文檔,CXR-S SDK 和CXR-M SDK 分別是眼鏡端和移動端的SDK,居然有Kotlin的寫法,恰好我居然是寫Android的,真是太巧合了,拿着文檔啃了起來。
如果您有任何疑問、對文章寫的不滿意、發現錯誤或者有更好的方法,如果你想支持下一期請務必點贊~,歡迎在評論、私信或郵件中提出,非常感謝您的支持。🙏
那麼你將獲得
- 端到端路徑:手機→ 眼鏡端顯示
- 可直接複製的 Kotlin 代碼片段(移動端與眼鏡端)
- 高頻踩坑與排錯清單
一、總體流程
我們看看總體流程,也是非常的清晰~~
從移動端初始化到眼鏡端顯示,最小閉環分為四步:
1) 初始化與權限:應用啓動 → 動態申請藍牙/定位/網絡權限 → 打開 BLE/Wi‑Fi。
2) 發現與連接:移動端 BLE 掃描(按 Rokid 服務 UUID 過濾)→ 選擇設備 → 通過 CXR-M 建鏈。
3) 消息通道:移動端建立 CXR 通道併發送統一格式的消息(如字符串/JSON)。
4) 渲染展示:眼鏡端訂閲相同消息名 → 解析載荷 → 更新 UI 渲染到顯示面板。
二、消息交互
那麼重中之重就是兩者的交互了,從下面的圖,我們可以很容易的瞭解整個消息交互的流程。
And 在整個流程中
- 建議以業務語義命名,如
glass_text,capture_ctrl; - 名稱兩端統一且固定,不在名字中混入環境/灰度信息;
- 一類業務一個通道,便於限流與排障。
三、移動端(Android/Kotlin)
前置:確保在 Gradle 中加入 Rokid Maven 倉庫,按官方文檔導入 CXR-M 相關依賴,並在AndroidManifest.xml聲明藍牙/定位/網絡權限;Android 12+ 需要BLUETOOTH_SCAN/BLUETOOTH_CONNECT。
記得申請權限!!!
1) 動態權限(藍牙/定位/網絡)
private fun requestPermissions(activity: Activity) {
val permissions = buildList {
add(Manifest.permission.ACCESS_FINE_LOCATION)
add(Manifest.permission.ACCESS_COARSE_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
add(Manifest.permission.BLUETOOTH_SCAN)
add(Manifest.permission.BLUETOOTH_CONNECT)
} else {
add(Manifest.permission.BLUETOOTH)
add(Manifest.permission.BLUETOOTH_ADMIN)
}
add(Manifest.permission.ACCESS_WIFI_STATE)
add(Manifest.permission.CHANGE_WIFI_STATE)
add(Manifest.permission.INTERNET)
}.toTypedArray()
ActivityCompat.requestPermissions(activity, permissions, 1001)
}
2) 設備掃描(用 UUID 過濾 Rokid 設備)
private fun startBleScan(adapter: BluetoothAdapter, onFound: (BluetoothDevice) -> Unit) {
val scanner = adapter.bluetoothLeScanner ?: return
val filters = listOf(
ScanFilter.Builder()
.setServiceUuid(ParcelUuid.fromString(ROKID_SERVICE_UUID))
.build()
)
val settings = ScanSettings.Builder().build()
scanner.startScan(filters, settings, scanCallback)
tvStatus.text = "正在掃描 Rokid 設備..."
btnScan.text = "掃描中..."
btnScan.isEnabled = false
}
3) 建立藍牙連接
private fun connectRokid(context: Context, device: BluetoothDevice) {
CxrApi.getInstance().initBluetooth(context, device, object : BluetoothStatusCallback {
override fun onConnectionInfo(
socketUuid: String?, macAddress: String?, rokidAccount: String?, glassesType: Int
) {
if (socketUuid != null && macAddress != null) {
CxrApi.getInstance().connectBluetooth(context, socketUuid, macAddress, this)
}
}
override fun onConnected() {
Log.d(TAG, "Connected")
runOnUiThread {
tvStatus.text = "已連接到 Rokid 設備"
btnSend.isEnabled = true
btnScan.text = "連接成功"
btnScan.isEnabled = false
}
}
override fun onDisconnected() {
Log.d(TAG, "Disconnected")
runOnUiThread {
tvStatus.text = "設備已斷開連接"
btnSend.isEnabled = false
btnScan.text = "重新掃描"
btnScan.isEnabled = true
}
}
override fun onFailed(code: ValueUtil.CxrBluetoothErrorCode?) {
Log.e(TAG, "Failed: ${code}")
runOnUiThread {
tvStatus.text = "連接失敗: $code"
btnSend.isEnabled = false
btnScan.text = "重新掃描"
btnScan.isEnabled = true
}
}
})
}
4) 發送消息
private fun sendToGlasses(channel: String = "glass_test", content: String = "Hello Rokid") {
val caps = Caps().apply {
write("TEXT_UPDATE") // 子命令,眼鏡端按需解析
write(content) // 直接發送字符串
}
val status = CxrApi.getInstance().sendCustomCmd(channel, caps)
runOnUiThread {
tvStatus.text = "發送簡單消息: '$content', 狀態: $status"
}
}
注:請在移動端與眼鏡端統一消息名與載荷格式!!!!!!!!這很重要啊!
4)完整代碼
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.ParcelUuid
import android.util.Log
import android.widget.Button
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.gson.Gson
import com.rokid.cxr.Caps
import com.rokid.cxr.client.extend.CxrApi
import com.rokid.cxr.client.extend.callbacks.BluetoothStatusCallback
import com.rokid.cxr.client.utils.ValueUtil
import java.util.*
/**
* 移動端連接Activity - 基於文章"新手上手:Rokid移動端+眼鏡端最小實踐"的源代碼實現
*
* 對應文章章節:三、移動端(Android/Kotlin)
* - 1) 動態權限(藍牙/定位/網絡)
* - 2) 設備掃描(用 UUID 過濾 Rokid 設備)
* - 3) 建立藍牙連接
* - 4) 發送消息(示例:字符串載荷)
*/
class ConnectActivity : AppCompatActivity() {
companion object {
private const val TAG = "ConnectActivity"
private const val CHANNEL_TEXT = "glass_text"
private const val CHANNEL_TEST = "glass_test"
private const val ROKID_SERVICE_UUID = "00009100-0000-1000-8000-00805f9b34fb"
}
private lateinit var tvStatus: TextView
private lateinit var btnScan: Button
private lateinit var btnSend: Button
private var bluetoothAdapter: BluetoothAdapter? = null
private var bluetoothLeScanner: BluetoothLeScanner? = null
private var connectedDevice: BluetoothDevice? = null
private val gson = Gson()
// 權限請求回調
private val requestPermissionsLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val allGranted = permissions.all { it.value }
if (allGranted) {
initBluetooth()
} else {
tvStatus.text = "權限未授予,無法進行藍牙掃描"
}
}
// 文章中的掃描回調示例
@SuppressLint("MissingPermission")
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
result.device?.let { device ->
Log.d(TAG, "發現設備: ${device.name} - ${device.address}")
tvStatus.text = "發現設備: ${device.name ?: "未知設備"}"
scanAndConnect(device)
stopScan()
}
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "掃描失敗: $errorCode")
tvStatus.text = "掃描失敗: $errorCode"
btnScan.text = "重新掃描"
btnScan.isEnabled = true
}
}
@SuppressLint("MissingInflatedId")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_connect)
tvStatus = findViewById(R.id.tvStatus)
btnScan = findViewById(R.id.btnScan)
btnSend = findViewById(R.id.btnSend)
btnScan.setOnClickListener { startBluetoothScan() }
btnSend.setOnClickListener { sendTestMessage() }
btnSend.isEnabled = false
// 檢查並請求權限
checkAndRequestPermissions()
}
private fun checkAndRequestPermissions() {
requestPermissions(this)
}
// 文章中的動態權限申請示例
private fun requestPermissions(activity: Activity) {
val permissions = buildList {
add(Manifest.permission.ACCESS_FINE_LOCATION)
add(Manifest.permission.ACCESS_COARSE_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
add(Manifest.permission.BLUETOOTH_SCAN)
add(Manifest.permission.BLUETOOTH_CONNECT)
} else {
add(Manifest.permission.BLUETOOTH)
add(Manifest.permission.BLUETOOTH_ADMIN)
}
add(Manifest.permission.ACCESS_WIFI_STATE)
add(Manifest.permission.CHANGE_WIFI_STATE)
add(Manifest.permission.INTERNET)
}.toTypedArray()
val needsPermission = permissions.any { permission ->
ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED
}
if (needsPermission) {
ActivityCompat.requestPermissions(activity, permissions, 1001)
} else {
initBluetooth()
}
}
private fun initBluetooth() {
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (bluetoothAdapter == null) {
tvStatus.text = "設備不支持藍牙"
return
}
if (!bluetoothAdapter!!.isEnabled) {
tvStatus.text = "請開啓藍牙"
return
}
bluetoothLeScanner = bluetoothAdapter!!.bluetoothLeScanner
tvStatus.text = "藍牙初始化完成,點擊掃描按鈕開始掃描"
}
@SuppressLint("MissingPermission")
private fun startBluetoothScan() {
// 文章中的設備掃描示例(用 UUID 過濾 Rokid 設備)
if (bluetoothAdapter != null) {
startBleScan(bluetoothAdapter!!) { device ->
Log.d(TAG, "發現設備: ${device.name} - ${device.address}")
tvStatus.text = "發現設備: ${device.name ?: "未知設備"}"
scanAndConnect(device)
stopScan()
}
}
}
// 文章中的設備掃描(用 UUID 過濾 Rokid 設備)函數
@SuppressLint("MissingPermission")
private fun startBleScan(adapter: BluetoothAdapter, onFound: (BluetoothDevice) -> Unit) {
val scanner = adapter.bluetoothLeScanner ?: return
val filters = listOf(
ScanFilter.Builder()
.setServiceUuid(ParcelUuid.fromString(ROKID_SERVICE_UUID))
.build()
)
val settings = ScanSettings.Builder().build()
scanner.startScan(filters, settings, scanCallback)
tvStatus.text = "正在掃描 Rokid 設備..."
btnScan.text = "掃描中..."
btnScan.isEnabled = false
}
@SuppressLint("MissingPermission")
private fun scanAndConnect(device: BluetoothDevice) {
connectedDevice = device
Log.d(TAG, "嘗試連接設備: ${device.address}")
tvStatus.text = "正在連接 ${device.name ?: device.address}..."
// 文章中的藍牙連接示例
connectRokid(this, device)
}
// 文章中的建立藍牙連接示例
private fun connectRokid(context: Context, device: BluetoothDevice) {
CxrApi.getInstance().initBluetooth(context, device, object : BluetoothStatusCallback {
override fun onConnectionInfo(
socketUuid: String?, macAddress: String?, rokidAccount: String?, glassesType: Int
) {
if (socketUuid != null && macAddress != null) {
CxrApi.getInstance().connectBluetooth(context, socketUuid, macAddress, this)
}
}
override fun onConnected() {
Log.d(TAG, "Connected")
runOnUiThread {
tvStatus.text = "已連接到 Rokid 設備"
btnSend.isEnabled = true
btnScan.text = "連接成功"
btnScan.isEnabled = false
}
}
override fun onDisconnected() {
Log.d(TAG, "Disconnected")
runOnUiThread {
tvStatus.text = "設備已斷開連接"
btnSend.isEnabled = false
btnScan.text = "重新掃描"
btnScan.isEnabled = true
}
}
override fun onFailed(code: ValueUtil.CxrBluetoothErrorCode?) {
Log.e(TAG, "Failed: ${code}")
runOnUiThread {
tvStatus.text = "連接失敗: $code"
btnSend.isEnabled = false
btnScan.text = "重新掃描"
btnScan.isEnabled = true
}
}
})
}
private fun sendTestMessage() {
// 使用文章中的示例代碼
sendToGlasses(channel = "glass_test", content = "Hello Rokid")
// 同時發送信封格式的消息示例
val body = mapOf("text" to "Hello Rokid from Envelope")
sendEnvelope(channel = CHANNEL_TEXT, body = body, type = "TEXT_UPDATE")
}
// 發送字符串載荷 (文章示例)
private fun sendToGlasses(channel: String = "glass_test", content: String = "Hello Rokid") {
val caps = Caps().apply {
write("TEXT_UPDATE") // 子命令,眼鏡端按需解析
write(content) // 直接發送字符串
}
val status = CxrApi.getInstance().sendCustomCmd(channel, caps)
runOnUiThread {
tvStatus.text = "發送簡單消息: '$content', 狀態: $status"
}
}
// 發送信封格式的消息(文章示例)
private fun sendEnvelope(channel: String, body: Any, type: String) {
val env = mapOf(
"version" to "1.0",
"type" to type,
"seqId" to System.nanoTime(),
"timestamp" to System.currentTimeMillis(),
"traceId" to UUID.randomUUID().toString(),
"body" to body
)
val json = gson.toJson(env)
// 通過 CxrApi 的自定義指令通道發送:
// onValueUpdate 的默認分支會把 (channel, caps) 投遞給眼鏡端的自定義命令監聽
val caps = Caps().apply {
write(type) // 子命令/事件名,例如 "TEXT_UPDATE"
write(json) // 載荷(字符串/JSON)
}
val status = CxrApi.getInstance().sendCustomCmd(channel, caps)
runOnUiThread {
tvStatus.text = "發送信封消息: '$type', 狀態: $status"
}
}
@SuppressLint("MissingPermission")
private fun stopScan() {
bluetoothLeScanner?.stopScan(scanCallback)
btnScan.text = "重新掃描"
btnScan.isEnabled = true
}
override fun onDestroy() {
super.onDestroy()
stopScan()
if (connectedDevice != null) {
CxrApi.getInstance().deinitBluetooth()
}
}
}
四、眼鏡端
眼鏡端基於 CXR-S SDK,需要在眼鏡設備上運行,其實也適合移動端是一樣的思路!
1) 項目配置與依賴
build.gradle.kts 中添加 CXR-S 依賴:
dependencies {
implementation("com.rokid.cxr:cxr-service-bridge:1.0-20250519.061355-45")
}
2) 最小工作示例(MainActivity)
import android.os.Bundle
import android.util.Log
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.example.myapplication.R
import com.google.gson.Gson
import com.rokid.cxr.Caps
import com.rokid.cxr.client.extend.CxrApi
import com.rokid.cxr.client.extend.listeners.CustomCmdListener
class GlassesMainActivity : AppCompatActivity() {
companion object {
private const val TAG = "GlassesMain"
private const val CHANNEL_TEXT = "glass_text"
private const val CHANNEL_TEST = "glass_test"
}
private lateinit var tvMessage: TextView
private lateinit var tvStatus: TextView
private val gson = Gson()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupUi()
setupMessageListener()
logStatus("眼鏡端已啓動,等待移動端連接...")
}
private fun setupUi() {
setContentView(R.layout.activity_glasses_main)
tvMessage = findViewById(R.id.tvMessage)
tvStatus = findViewById(R.id.tvStatus)
}
private fun setupMessageListener() {
// 設置自定義命令監聽器,處理來自移動端的消息
CxrApi.getInstance().setCustomCmdListener(object : CustomCmdListener {
override fun onCustomCmd(name: String, args: Caps?) {
Log.d(TAG, "收到命令: channel=$name, args.size=${args?.size()}")
when (name) {
CHANNEL_TEXT -> handleTextUpdate(args)
CHANNEL_TEST -> handleTestMessage(args)
else -> Log.w(TAG, "未知通道: $name")
}
}
})
}
private fun handleTextUpdate(caps: Caps?) {
if (caps == null || caps.size() < 2) {
Log.e(TAG, "handleTextUpdate: caps 格式錯誤")
return
}
try {
// 按發送端寫入順序解析:
// 第1個:子命令 (如 "TEXT_UPDATE")
// 第2個:載荷 (JSON字符串)
val subCommand = caps.at(0).getString()
val payloadJson = caps.at(1).getString()
Log.d(TAG, "子命令: $subCommand, 載荷: $payloadJson")
// 解析 JSON 載荷
val jsonMap = gson.fromJson(payloadJson, Map::class.java) as? Map<String, Any?>
val messageBody = jsonMap?.get("body") as? Map<String, Any?>
val text = messageBody?.get("text") as? String
if (!text.isNullOrEmpty()) {
updateMessage("文本更新: $text")
} else {
updateMessage("收到的載荷格式不正確")
}
} catch (e: Exception) {
Log.e(TAG, "解析文本更新失敗", e)
updateMessage("解析出錯: ${e.message}")
}
}
private fun handleTestMessage(caps: Caps?) {
if (caps == null || caps.size() < 2) {
Log.e(TAG, "handleTestMessage: caps 格式錯誤")
return
}
// 簡化處理,直接使用第二個寫入的內容
val content = caps.at(1).getString()
updateMessage("測試消息: $content")
}
private fun updateMessage(message: String) {
Log.d(TAG, "更新UI: $message")
runOnUiThread {
tvMessage.text = message
// 可選:回覆 ACK 給移動端
sendAckToMobile(message)
}
}
private fun updateStatus(status: String) {
Log.d(TAG, "狀態更新: $status")
runOnUiThread {
tvStatus.text = status
}
}
private fun logStatus(status: String) {
Log.i(TAG, status)
updateStatus(status)
}
// 發送 ACK 回覆給移動端(可選)
private fun sendAckToMobile(originalMessage: String) {
try {
val ackJson = gson.toJson(mapOf(
"code" to 0,
"message" to "ACK",
"original" to originalMessage,
"timestamp" to System.currentTimeMillis()
))
val caps = Caps().apply {
write("ACK")
write(ackJson)
}
CxrApi.getInstance().sendCustomCmd("glass_ack", caps)
Log.d(TAG, "已發送 ACK")
} catch (e: Exception) {
Log.e(TAG, "發送 ACK 失敗", e)
}
}
override fun onDestroy() {
super.onDestroy()
// 清理監聽器
CxrApi.getInstance().setCustomCmdListener(null)
Log.d(TAG, "眼鏡端已銷燬")
}
}
3) 佈局文件 (activity_glasses_main.xml)
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tvStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="眼鏡端狀態"
android:textSize="14sp"
android:textColor="#666"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/tvMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="等待消息..."
android:textSize="18sp"
android:textColor="#333"
android:background="#f5f5f5"
android:padding="12dp"
android:layout_marginTop="8dp" />
</LinearLayout>
4) AndroidManifest.xml 配置
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.MyApplication">
<activity
android:name=".GlassesMainActivity"
android:exported="true"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
5) 擴展功能
眼鏡端還可以處理更多場景:
class GlassesMainActivity : AppCompatActivity() {
// ... 前面的代碼 ...
private fun setupExtendedListeners() {
// 電量監聽
CxrApi.getInstance().setBatteryLevelUpdateListener { level, isCharging ->
updateStatus("電量: $level%, 充電狀態: ${if (isCharging) "充電中" else "未充電"}")
}
// 亮度監聽
CxrApi.getInstance().setBrightnessUpdateListener { brightness ->
updateStatus("當前亮度: $brightness")
}
// 音量監聽
CxrApi.getInstance().setVolumeUpdateListener { volume ->
updateStatus("當前音量: $volume")
}
}
private fun demonstrateSceneControl() {
// 控制特定場景(如翻譯、提詞器等)
CxrApi.getInstance().controlScene(
ValueUtil.CxrSceneType.TRANSLATION,
true, // 啓用
null // 額外參數
)
updateStatus("已啓用翻譯場景")
}
private fun sendStatusToMobile() {
val status = mapOf(
"battery" to 85,
"brightness" to 50,
"volume" to 30,
"timestamp" to System.currentTimeMillis()
)
val caps = Caps().apply {
write("STATUS_UPDATE")
write(gson.toJson(status))
}
CxrApi.getInstance().sendCustomCmd("glass_status", caps)
}
}
6) 常見眼鏡端 API 速覽
| API | 用途 | 示例 |
|---|---|---|
setCustomCmdListener() |
接收移動端消息 | 核心交互通道 |
setBatteryLevelUpdateListener() |
監聽電池狀態 | 監控電量變化 |
setBrightnessUpdateListener() |
監聽亮度變化 | UI 適配 |
controlScene() |
控制應用場景 | 翻譯/提詞器/拍照 |
sendCustomCmd() |
發送消息給移動端 | 狀態反饋/ACK |
這樣就完成了一個完整可運行的眼鏡端示例,包含消息接收、解析、UI更新和狀態反饋功能。
五、把一切串起來(3 步走)
1) 移動端完成權限和連接;
2) 點擊按鈕發送消息(消息名與載荷格式與眼鏡端約定一致);
3) 眼鏡端訂閲同名通道,解析載荷並更新 UI。
建議先用最簡單的字符串載荷跑通,再逐步替換為結構化消息~~~
六、踩坑與排錯速查
在這個過程中,會遇到一些奇奇怪怪的問題,這邊我先把我遇到的問題提前分享給各位,
- 發現不到設備:權限是否全部授予、BLE/Wi‑Fi 是否開啓、距離與干擾、固件版本
- 回調不觸發:監聽註冊時機(onCreate/onStart)、生命週期銷燬、重複訂閲衝突
- 消息發不出去/收不到:消息名不一致、載荷編碼不一致(JSON/Caps)、Wi‑Fi 未初始化
- UI 不更新:未切回主線程、Activity 非前台、Fragment 生命週期影響
- Android 12+:藍牙權限拆分(SCAN/CONNECT),注意清單與動態申請一致
七、進一步擴展
第一次上手兩端協同,看起來複雜,其實只要把“初始化—連接—消息—UI”四步逐一打通,就已完成 80% 的路徑。先小後大、先通後優;跑通最小閉環,再逐步加功能與體驗,你會更快抵達“可發佈”的成果。
而只要打通了這一步,和打通了任督二脈一樣。剩下的無非就是你發發,我發發的事情啦,比如做個天氣可視化啦~~~
八、結語
沒了。
如果您有任何疑問、對文章寫的不滿意、發現錯誤或者有更好的方法,如果你想支持下一期請務必點贊~,歡迎在評論、私信或郵件中提出,這對我真的很重要,非常感謝您的支持。🙏