每个开发,都喜欢逛论坛。我就是在逛论坛的时候,发现了Rokid,好吧,我承认我对这玩意很感兴趣。稍微看了下Rokid的开发文档,CXR-S SDK 和CXR-M SDK 分别是眼镜端和移动端的SDK,居然有Kotlin的写法,恰好我居然是写Android的,真是太巧合了,拿着文档啃了起来。
如果您有任何疑问、对文章写的不满意、发现错误或者有更好的方法,如果你想支持下一期请务必点赞~,欢迎在评论、私信或邮件中提出,非常感谢您的支持。🙏
那么你将获得
- 端到端路径:手机→ 眼镜端显示
- 可直接复制的 Kotlin 代码片段(移动端与眼镜端)
- 高频踩坑与排错清单
一、总体流程
我们看看总体流程,也是非常的清晰~~
从移动端初始化到眼镜端显示,最小闭环分为四步:
- 初始化与权限:应用启动 → 动态申请蓝牙/定位/网络权限 → 打开 BLE/Wi‑Fi。
- 发现与连接:移动端 BLE 扫描(按 Rokid 服务 UUID 过滤)→ 选择设备 → 通过 CXR-M 建链。
- 消息通道:移动端建立 CXR 通道并发送统一格式的消息(如字符串/JSON)。
- 渲染展示:眼镜端订阅相同消息名 → 解析载荷 → 更新 UI 渲染到显示面板。

二、消息交互
那么重中之重就是两者的交互了,从下面的图,我们可以很容易的了解整个消息交互的流程。

And 在整个流程中
- 建议以业务语义命名,如
glass_text
,capture_ctrl
; - 名称两端统一且固定,不在名字中混入环境/灰度信息;
- 一类业务一个通道,便于限流与排障。
三、移动端(Android/Kotlin)
前置:确保在 Gradle 中加入 Rokid Maven 仓库,按官方文档导入 CXR-M 相关依赖,并在
AndroidManifest.xml
声明蓝牙/定位/网络权限;Android 12+ 需要BLUETOOTH_SCAN/BLUETOOTH_CONNECT
。
记得申请权限!!!
1) 动态权限(蓝牙/定位/网络)
scss
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 设备)
kotlin
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) 建立蓝牙连接
kotlin
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) 发送消息
kotlin
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)完整代码
kotlin
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 依赖:
scss
dependencies {
implementation("com.rokid.cxr:cxr-service-bridge:1.0-20250519.061355-45")
}
2) 最小工作示例(MainActivity)
kotlin
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)
ini
<?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 配置
ini
<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) 扩展功能
眼镜端还可以处理更多场景:
kotlin
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 步走)
- 移动端完成权限和连接;
- 点击按钮发送消息(消息名与载荷格式与眼镜端约定一致);
- 眼镜端订阅同名通道,解析载荷并更新 UI。
建议先用最简单的字符串载荷跑通,再逐步替换为结构化消息~~~
六、踩坑与排错速查
在这个过程中,会遇到一些奇奇怪怪的问题,这边我先把我遇到的问题提前分享给各位,
- 发现不到设备:权限是否全部授予、BLE/Wi‑Fi 是否开启、距离与干扰、固件版本
- 回调不触发:监听注册时机(onCreate/onStart)、生命周期销毁、重复订阅冲突
- 消息发不出去/收不到:消息名不一致、载荷编码不一致(JSON/Caps)、Wi‑Fi 未初始化
- UI 不更新:未切回主线程、Activity 非前台、Fragment 生命周期影响
- Android 12+:蓝牙权限拆分(SCAN/CONNECT),注意清单与动态申请一致
七、进一步扩展
第一次上手两端协同,看起来复杂,其实只要把"初始化---连接---消息---UI"四步逐一打通,就已完成 80% 的路径。先小后大、先通后优;跑通最小闭环,再逐步加功能与体验,你会更快抵达"可发布"的成果。
而只要打通了这一步,和打通了任督二脉一样。剩下的无非就是你发发,我发发的事情啦,比如做个天气可视化啦~~~
八、结语
没了。
如果您有任何疑问、对文章写的不满意、发现错误或者有更好的方法,如果你想支持下一期请务必点赞 ~,欢迎在评论、私信或邮件中提出,这对我真的很重要,非常感谢您的支持。🙏