Rokid 智能眼镜开发实战:手机端 + 眼镜端双端协同 App 完整指南
本文基于 Rokid CXR-M SDK(手机端)和CXR-L SDK(眼镜端),从零讲解如何开发一个手机-眼镜双端协同应用,涵盖蓝牙扫描配对、眼镜连接、远程拍照、媒体同步、自定义视图推送、HID 遥控等核心功能。
一、项目概览
本项目包含两个独立 APK,分别运行在手机和 Rokid 智能眼镜上:
|
模块 |
SDK | 运行平台 | 核心能力 |
| APP | CXR-M SDK v1.0.9 |
Android |
蓝牙扫描配对、连接眼镜、远程拍照、Wi-Fi P2P 媒体同步、自定义视图推送 |
| Glasses | CXR-L SDK v1.0.1 | Android |
蓝牙 RFCOMM 接收指令、CXR 服务绑定、屏幕显示 |
技术栈:Kotlin 2.1.0、Android SDK 36 、Gradle 8.11.1、GreenDAO、Retrofit+OkHttp、Glide 、RxJava3、EventBus、QMUI
二、环境配置
2.1 仓库配置
Rokid SDK 托管在私有 Maven 仓库,需在settings.gradle中添加:
dependencyResolutionManagement {
repositories {
maven { url 'https://maven.aliyun.com/repository/public/' }
maven { url 'https://maven.aliyun.com/repository/google/' }
maven { url = uri("https://maven.rokid.com/repository/maven-public/") }
google()
mavenCentral()
}
}
2.2 SDK 依赖
在gradle/libs.versions.toml`中声明版本:
[versions]
cxr-m-version = "1.0.9"
cxr-l-version = "1.0.1"
[libraries]
rokid-cxr-m = { module = "com.rokid.cxr:client-m", version.ref = "cxr-m-version" }
rokid-cxr-l = { module = "com.rokid.cxr:client-l", version.ref = "cxr-l-version" }
手机端app/build.gradle:
implementation(libs.rokid.cxr.m) {
exclude group: 'com.rokid.cxr', module: 'client-m-sources'
}
眼镜端 `glasses/build.gradle`:
implementation fileTree(dir: 'libs', include: ['*.jar'])
2.3 鉴权配置
手机端需要.lc`鉴权文件和 CLIENT_SECRET:
1.lc鉴权文件和SECRET的获取方式:
https://account-web.rokid.com/
注意:实名认证和开发者认证都需要通过后才可进行下一步

进入凭证信息中,查看客户端密钥,里面有等会需要填写的CLENT_SECRET

还需要回到首页的控制台Rokid AR Platform进入设备管理,SN可以从设备包装盒或手机APP中查看。目前只支持绑定设备,不支持删除或修改设备。


点击下载鉴权文件即可下载.lc文件

2. 将 .lc文件放到app/src/main/res/raw/目录
3. 在CommonModel.kt中配置密钥:
object CommonModel {
const val CLIENT_SECRET = "your-client-secret"
}
三、核心功能实现
3.1 蓝牙扫描与配对
手机端通过 BLE 扫描发现 Rokid 眼镜设备,使用 CXR-M SDK 的 Service UUID 过滤:
class BluetoothScanActivity : BaseActivity<ActivityBluetoothScanBinding>(...) {
private fun startScan() {
val filter = ScanFilter.Builder()
.setServiceUuid(ParcelUuid(UUID.fromString("00009100-0000-1000-8000-00805f9b34fb")))
.build()
val settings = ScanSettings.Builder().build()
bluetoothLeScanner?.startScan(listOf(filter), settings, scanCallback)
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult?) {
result?.let {
val device = BluetoothDevice(
device = it.device,
name = it.device.name,
address = it.device.address,
rssi = it.rssi
)
deviceAdapter.addDevice(device)
}
}
}
}
注意:Android 12+ 需要申请BLUETOOTH_SCAN和BLUETOOTH_CONNECT权限,低于 Android 12 使用BLUETOOTH和BLUETOOTH_ADMIN。
3.2 连接眼镜(CXR-M SDK 核心)
点击扫描到的设备后,通过CxrApi建立连接,整个流程分两步:
第一步:BLE初始化→获取 Socket UUID 和 MAC 地址
第二步:Socket连接→建立数据通道
object CxrUtil {
fun initConnectDevice(
context: Context,
device: BluetoothDevice,
listener: BluetoothDeviceConnectListener,
) {
// 第一步:BLE 初始化,获取连接信息
CxrApi.getInstance().initBluetooth(context, device, object : BluetoothStatusCallback {
override fun onConnectionInfo(
socketUuid: String?, macAddress: String?,
rokidAccount: String?, glassesType: Int,
) {
// 拿到 socketUuid 和 macAddress,进入第二步
socketUuid?.let { uuid ->
macAddress?.let { address ->
connectGlasses(context, device.name, uuid, address) { state ->
if (state == GlassesLinkState.CONNECTED) {
// 保存设备信息到 SharedPreferences
SPUtils.getInstance().put(ConsModel.SP_KEY_SOCKET_UUID, uuid)
SPUtils.getInstance().put(ConsModel.SP_KEY_MAC_ADDRESS, address)
}
listener.onDeviceConnected(state, CommonModel.glassesLinkErrorMsg)
}
}
}
}
override fun onConnected() { listener.onBluetoothConnect(BluetoothLinkState.CONNECTED) }
override fun onDisconnected() { listener.onBluetoothConnect(BluetoothLinkState.DISCONNECTED) }
override fun onFailed(p0: ValueUtil.CxrBluetoothErrorCode?) {
listener.onBluetoothConnect(BluetoothLinkState.FAILED, errorMsg)
}
})
}
// 第二步:使用 Socket UUID + MAC + 鉴权信息建立连接
fun connectGlasses(
context: Context, deviceName: String,
socketUuid: String, macAddress: String,
action: (GlassesLinkState) -> Unit,
) {
CxrApi.getInstance().connectBluetooth(
context, socketUuid, macAddress,
object : BluetoothStatusCallback { /* 回调处理 */ },
readRawFile(context),
CommonModel.CLIENT_SECRET
)
initGlassesListener()
}
}
连接状态流转:
UNPAIRED → BLE扫描 → UNCONNECTED → initBluetooth → ON_CONNECTION_INFO
→ connectBluetooth → CONNECTING → CONNECTED
已配对设备快速重连:
fun initGlassesLink(appContext: Context, action: (GlassesLinkState) -> Unit) {
if (CommonModel.deviceMacAddress.isNotEmpty()) {
connectGlasses(appContext, CommonModel.deviceDeviceName,
CommonModel.deviceSocketUuid, CommonModel.deviceMacAddress, action)
}
}
3.3 远程拍照
CXR-M SDK 提供两种拍照方式:
| API | 返回数据 | 适用场景 |
|
takeGlassPhotoGlobal() |
字节数组 (ByteArray) |
AI 识别、即时处理 |
|
takeGlassPhoto() |
文件路径 (String) |
保存到眼镜本地 |
推荐使用 takeGlassPhotoGlobal(),直接获取图片字节数据:
class HomeVm(val appContext: Application) : AndroidViewModel(appContext) {
val cameraSize = Size(1024, 768) // 眼镜摄像头旋转90°,宽高互换
fun takePhotoGlobal(size: Size) {
when (val status = CxrApi.getInstance().takeGlassPhotoGlobal(
size.width, size.height, 100, pictureCallback
)) {
ValueUtil.CxrStatus.REQUEST_SUCCEED -> { /* 请求已发送 */ }
ValueUtil.CxrStatus.REQUEST_WAITING -> { /* 眼镜忙,稍后重试 */ }
ValueUtil.CxrStatus.REQUEST_FAILED -> { /* 请求失败 */ }
else -> { /* 异常 */ }
}
}
private val pictureCallback = PhotoResultCallback { status, imageData ->
when (status) {
ValueUtil.CxrStatus.RESPONSE_SUCCEED -> {
if (imageData.isNullOrEmpty()) return@PhotoResultCallback
// 保存图片到本地
val imagePath = AppInternalFileUtil.createRecordFile(
"rokid_${AppTimeUtils.getCurrentTime()}.webp"
)
File(imagePath).writeBytes(imageData)
// 写入数据库记录
writeUpdateRecord(GlassesRecordModel().apply {
direction = InteractionDirection.FROM_DEVICE.value
content = imagePath
message = "眼镜拍照 WebP"
})
}
else -> { /* 拍照失败 */ }
}
}
}
踩坑提醒:眼镜摄像头物理方向旋转了 90°,拍照时 width/height 需要互换。比如想要 768×1024 的照片,应传入 Size(1024, 768)。
3.4 Wi-Fi P2P 媒体同步
眼镜端照片/视频通过 Wi-Fi P2P 传输到手机,流程如下:
initWifiP2P → P2P连接成功 → startSync → 逐文件同步回调 → onSyncFinished → deinitWifiP2P
// 1. 建立 Wi-Fi P2P 连接
CxrApi.getInstance().initWifiP2P(object : WifiP2PStatusCallback {
override fun onConnected() {
// 2. P2P 连接成功,开始同步
startSync(mediaTypes, saveDir)
}
override fun onDisconnected() { /* 断开 */ }
override fun onFailed(errorCode: ValueUtil.CxrWifiErrorCode?) { /* 失败 */ }
})
// 3. 启动同步
fun startSync(mediaTypes: Array<ValueUtil.CxrMediaType>, saveDir: File) {
val savePath = saveDir.absolutePath + File.separator
CxrApi.getInstance().startSync(savePath, mediaTypes, object : SyncStatusCallback {
override fun onSyncStart() { /* 同步开始 */ }
override fun onSingleFileSynced(fileName: String?) {
// 单个文件同步完成,fileName 是保存到本地的完整路径
val file = resolveSyncedFile(fileName)
writeSyncedMediaRecord(file)
}
override fun onSyncFailed() { disconnectWifiP2P() }
override fun onSyncFinished() { disconnectWifiP2P() }
})
}
// 查询未同步数量
CxrApi.getInstance().getUnsyncNum { status, audioNum, pictureNum, videoNum ->
// 获取各类型未同步文件数
}
// 停止同步
CxrApi.getInstance().stopSync()
CxrApi.getInstance().deinitWifiP2P()
同步媒体类型:
// 仅同步照片
syncPicturesOnly() // arrayOf(ValueUtil.CxrMediaType.PICTURE)
// 仅同步视频
syncVideosOnly() // arrayOf(ValueUtil.CxrMediaType.VIDEO)
// 同步照片和视频
syncPicturesAndVideos() // arrayOf(ValueUtil.CxrMediaType.PICTURE, ValueUtil.CxrMediaType.VIDEO)
3.5 自定义视图推送(在眼镜上显示 )
CXR-M SDK 支持通过 JSON 描述在眼镜屏幕上渲染自定义视图:
fun showCustomView(text: String) {
val customViewMap = mapOf(
"type" to "LinearLayout",
"props" to mapOf(
"id" to "main",
"layout_width" to "match_parent",
"layout_height" to "match_parent",
"orientation" to "vertical",
"gravity" to "center_vertical",
"backgroundColor" to "#FF000000"
),
"children" to listOf(
mapOf(
"type" to "TextView",
"props" to mapOf(
"text" to text,
"textSize" to "16sp",
"textStyle" to "bold",
"textColor" to "#FFFFFFFF"
)
)
)
)
// 打开自定义视图
CxrApi.getInstance().openCustomView(Gson().toJson(customViewMap))
// 更新视图
CxrApi.getInstance().updateCustomView(updatedJson)
// 关闭视图
CxrApi.getInstance().closeCustomView()
}
支持的视图类型:LinearLayout、RelativeLayout、TextView、ImageView。
视图生命周期回调:
CxrApi.getInstance().setCustomViewListener(object : CustomViewListener {
override fun onIconsSent() { /* 图标已发送 */ }
override fun onOpened() { /* 视图已打开 */ }
override fun onOpenFailed(p0: Int) { /* 打开失败 */ }
override fun onUpdated() { /* 视图已更新 */ }
override fun onClosed() { /* 视图已关闭 */ }
})
3.6 眼镜端 APP 开发
眼镜端 APP 有两种通信方式:
方式一:蓝牙 RFCOMM(自定义通道)
手机端作为客户端,眼镜端作为服务端,通过 RFCOMM 传输 JSON 消息。
眼镜端 — BluetoothServer:
class BluetoothServer(private val listener: OnMessageListener) {
companion object {
val SERVICE_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
}
fun start(): Boolean {
val adapter = BluetoothAdapter.getDefaultAdapter()
serverSocket = adapter.listenUsingRfcommWithServiceRecord("GlassesTextServer", SERVICE_UUID)
running = true
acceptThread = Thread { acceptLoop() }
acceptThread?.start()
return true
}
private fun readLoop(socket: BluetoothSocket) {
val input = socket.inputStream
val buffer = ByteArray(4096)
val stringBuilder = StringBuilder()
while (running && socket.isConnected) {
val bytesRead = input.read(buffer)
if (bytesRead == -1) break
stringBuilder.append(String(buffer, 0, bytesRead, Charsets.UTF_8))
// 按换行符分割 JSON 消息
var newlineIdx: Int
while (stringBuilder.indexOf('\n').also { newlineIdx = it } >= 0) {
val line = stringBuilder.substring(0, newlineIdx).trim()
stringBuilder.delete(0, newlineIdx + 1)
if (line.isNotEmpty()) handleMessage(line)
}
}
}
private fun handleMessage(line: String) {
val json = JSONObject(line)
val type = json.optString("type", "") // "show_text" / "close_text"
val text = json.optString("text", "")
listener.onMessageReceived(type, text)
}
}
手机端 — BluetoothClient:
object BluetoothClient {
val SERVICE_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
fun connect(macAddress: String): Boolean {
val device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(macAddress)
socket = device.createRfcommSocketToServiceRecord(SERVICE_UUID)
socket?.connect()
outputStream = socket?.outputStream
return true
}
fun sendText(text: String): Boolean = sendMessage("show_text", text)
fun sendClose(): Boolean = sendMessage("close_text", "")
private fun sendMessage(type: String, text: String): Boolean {
val json = JSONObject().apply { put("type", type); put("text", text) }
val data = (json.toString() + "\n").toByteArray(Charsets.UTF_8)
outputStream?.write(data)
outputStream?.flush()
return true
}
}
方式二:CXR 服务绑定(AIDL)
眼镜端绑定 Rokid 系统服务 `IMediaStreamService`,接收手机端通过 `sendCustomCmd` 发送的指令:
class CxrServiceManager(private val context: Context) {
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
mediaStreamService = IMediaStreamService.Stub.asInterface(service)
registerCustomCmdCallback()
}
override fun onServiceDisconnected(name: ComponentName?) {
mediaStreamService = null
}
}
fun connect(): Boolean {
val intent = Intent("com.rokid.sprite.aiapp.externalapp.MEDIA_STREAM_SERVICE").apply {
setPackage("com.rokid.cxrservice")
putExtra("auth_token", "glasses_app_token")
}
return context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
private val customCmdCallback = object : ICustomCmdCallback.Stub() {
override fun onCustomCmdResult(cmd: String?, data: ByteArray?) {
val caps = Caps.fromBytes(data)
when (cmd) {
"show_text" -> {
val text = caps.a[0].getString()
messageListener?.onMessageReceived(text)
}
"close_text" -> messageListener?.onMessageReceived("")
}
}
}
private fun registerCustomCmdCallback(): Boolean {
return mediaStreamService?.registerCustomCmdCallback(customCmdCallback) ?: false
}
}
3.7 设备状态监听
连接成功后,可监听眼镜的音量、亮度、电量变化:
fun initGlassesListener() {
CxrApi.getInstance().setVolumeUpdateListener { volume ->
CommonModel.glassesInfo.volume = volume
}
CxrApi.getInstance().setBrightnessUpdateListener { brightness ->
CommonModel.glassesInfo.brightness = brightness
}
CxrApi.getInstance().setBatteryLevelUpdateListener { batteryLevel, charging ->
CommonModel.glassesInfo.battery = batteryLevel
CommonModel.glassesInfo.charging = charging
}
}
// 主动设置音量/亮度(范围 0-15)
CxrApi.getInstance().setGlassVolume(volume)
CxrApi.getInstance().setGlassBrightness(brightness)
3.8 录像控制
fun controlVideoRecord(toOpen: Boolean) {
CxrApi.getInstance().controlScene(
ValueUtil.CxrSceneType.VIDEO_RECORD,
toOpen, // true=开始录像, false=停止录像
null
)
}
3.9 AI 场景(语音触发拍照)
fun initAiScene() {
// 监听眼镜端语音流
CxrApi.getInstance().setAudioStreamListener(object : AudioStreamListener {
override fun onStartAudioStream(p0: Int, p1: Int, p2: String?) {
// 语音开始 → 自动触发拍照
takePhotoGlobal(cameraSize)
}
override fun onAudioStream(p0: Int, p1: ByteArray?, p2: Int, p3: Int) { /* 音频数据 */ }
override fun onAudioStreamFinish(p0: Int) { /* 语音结束 */ }
})
// 监听 AI 按键事件
CxrApi.getInstance().setAiEventListener(object : AiEventListener {
override fun onAiKeyDown() { /* AI 按键按下 */ }
override fun onAiKeyUp() { /* AI 按键抬起 */ }
override fun onAiExit() { /* AI 退出 */ }
})
}
// 识别结果反馈到眼镜端(TTS 播报)
CxrApi.getInstance().sendGlobalMsgContent(0, feedbackText, false)
四、权限清单
手机端 `AndroidManifest.xml` 需要的权限:
<!-- 蓝牙 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- 位置(BLE 扫描需要) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- 存储 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!-- 网络(Wi-Fi P2P) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<!-- 其他 -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
五、踩坑总结
| 问题 | 原因 | 解决方案 |
|
Android12以下扫描不到设备 |
缺BLUETOOTH/BLUETOOTH_ADMIN权限 |
按 SDK 版本动态申请权限 |
|
拍照返回图片方向不对 |
眼镜摄像头旋转 90° |
传入Size(1024, 768)而非Size(768, 1024) |
|
takeGlassPhoto vs takeGlassPhotoGlobal |
前者返回路径,后者返回字节数组 |
需要即时处理用 Global,保存到眼镜用路径版 |
|
Wi-Fi P2P 连接失败 |
眼镜和手机不在同一网络 |
确保眼镜Wi-Fi已开启,SDK 会自动处理 P2P 协商 |
|
自定义视图不显示 |
JSON 格式错误或缺少必要字段 |
检查 type/props/children 结构,确保 id字段存在 |
|
.lc`鉴权失败 |
文件路径或 CLIENT_SECRET不匹配 |
确认 .lc`文件在 res/raw/下,密钥与 Rokid 开发者平台一致 |
如果本文对你有帮助,欢迎点赞收藏,有问题欢迎评论区交流!
更多推荐
所有评论(0)