本文基于 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 开发者平台一致

 如果本文对你有帮助,欢迎点赞收藏,有问题欢迎评论区交流!

更多推荐