跳到主要内容
基于 Rokid AR 眼镜的会议纪要助手开发实录 | 极客日志
Kotlin AI 大前端
基于 Rokid AR 眼镜的会议纪要助手开发实录 综述由AI生成 记录了基于 Rokid CXR-M SDK 和 Kotlin 开发 AR 会议纪要助手的完整过程。文章分析了传统会议管理方案的不足,阐述了 AR 眼镜在被动信息获取和自然交互上的优势。内容涵盖系统架构设计、依赖配置、权限处理、数据模型定义及 SDK 封装通信等核心模块。重点解决了蓝牙连接两阶段问题、后台计时误差及中文乱码等技术难点,并提供了功能演示与未来规划,展示了 AR 技术在办公场景的应用潜力。
性能调优 发布于 2026/4/6 更新于 2026/5/19 24 浏览基于 Rokid AR 眼镜的会议纪要助手开发实录
'李总,需求评审环节已经超时 12 分钟了,后面的自由讨论时间不够了……'
相信每个经常主持或参与会议的人都经历过这样的尴尬:一个议题讨论过于热烈,时间悄然流逝,等到发现时,整个会议日程已经被打乱。手机上的计时器?太容易被忽略。电脑上的提醒?开会时你根本不会盯着屏幕看。
如果能在眼前实时看到当前议题、已用时间、超时警告呢?这就是我开发这款会议纪要助手的初衷——把议程管理"戴"在眼前。
本文将从零开始,完整记录基于 Rokid CXR-M SDK 开发这款 AR 会议助手的全过程,涵盖技术选型、架构设计、核心代码实现与踩坑经验。
一、为什么是 AR 眼镜?
1.1 传统方案的困境
在正式开发之前,我调研了市面上常见的会议管理工具:
方案 问题 手机计时 App 需要频繁解锁查看,打断会议节奏 电脑倒计时 主持人注意力在屏幕,而非与会者 人工报时 需要专人负责,且容易忘记 投影时钟 只有演讲者能看到,主持人无法兼顾
这些方案的共同问题是:信息获取需要主动动作 。主持人要么低头看手机,要么转头看屏幕,这在某种程度上都会分散注意力,影响会议的流畅性。
1.2 AR 眼镜的优势
AR 眼镜提供了一个独特的交互场景:抬眼即见,无需分心 。
被动信息获取 :信息直接出现在视野中,不需要主动去"找"
自然交互 :主持人可以保持与与会者的眼神交流
实时提醒 :时间节点可以通过语音或视觉主动推送
专注主持 :不被设备操作打断会议节奏
这正是 Rokid CXR-M SDK 提供的"提词器场景"的天然应用——把会议议程像提词器一样展示在眼前。
二、系统架构设计
2.1 整体架构
系统采用经典的手机端控制 + 眼镜端显示 的架构:
2.2 技术选型
开发语言 :Kotlin(简洁、安全、与 Android 深度集成)
最低 SDK :Android 9 (API 28),支持绝大多数现代设备
核心依赖 :Rokid CXR-M SDK 1.0.1
UI 框架 :传统 ViewBinding + XML 布局(简单直接)
三、从零开始:项目配置
3.1 依赖配置
首先,在项目级 settings.gradle.kts 中添加 Rokid Maven 仓库:
repositories {
maven { url = uri("https://maven.rokid.com/repository/maven-public/" ) }
google()
mavenCentral()
}
然后在 app/build.gradle.kts 中添加 SDK 依赖:
plugins {
id("com.android.application" )
id("org.jetbrains.kotlin.android" )
}
android {
namespace = "com.rokid.meeting"
compileSdk = 34
defaultConfig {
applicationId = "com.rokid.meetinghelper"
minSdk = 28
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("com.rokid.cxr:client-m:1.0.1-20250812.080117-2" )
implementation("androidx.core:core-ktx:1.12.0" )
implementation("androidx.appcompat:appcompat:1.6.1" )
implementation("com.google.android.material:material:1.11.0" )
implementation("androidx.constraintlayout:constraintlayout:2.1.4" )
implementation("androidx.cardview:cardview:1.0.0" )
}
3.2 权限配置 眼镜通过蓝牙连接,需要声明相应的蓝牙权限。在 AndroidManifest.xml 中:
<manifest xmlns:android ="http://schemas.android.com/apk/res/android"
xmlns:tools ="http://schemas.android.com/tools" >
<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" />
<application ... >
<activity android:name =".MainActivity"
android:exported ="true" >
<intent-filter >
<action android:name ="android.intent.action.MAIN" />
<category android:name ="android.intent.category.LAUNCHER" />
</intent-filter >
</activity >
</application >
</manifest >
注意 :BLUETOOTH_SCAN 添加了 neverForLocation 标志,因为我们只需要扫描蓝牙设备,不需要获取位置信息。这样可以简化权限申请流程。
四、核心模块一:数据模型 好的数据模型是清晰代码的基础。我们定义两个核心数据类:Meeting(会议)和 AgendaItem(议程项)。
4.1 数据类定义
package com.rokid.meeting.data
data class Meeting (
val id: Int ,
val title: String,
val startTime: Long ,
val totalTime: Int ,
val agenda: List<AgendaItem>
)
data class AgendaItem (
val index: Int ,
val title: String,
val speaker: String?,
val duration: Int ,
var notes: String? = null
)
4.2 预设会议数据 object MeetingData {
val meetings = listOf(
Meeting(
id = 1 ,
title = "产品周会" ,
startTime = System.currentTimeMillis(),
totalTime = 60 ,
agenda = listOf(
AgendaItem(1 , "上周工作回顾" , "张三" , 10 , null ),
AgendaItem(2 , "本周计划" , "李四" , 15 , null ),
AgendaItem(3 , "需求评审" , "王五" , 20 , "需提前准备文档" ),
AgendaItem(4 , "自由讨论" , null , 15 , null )
)
)
)
}
speaker 可为空,支持"自由讨论"这类没有固定主讲人的环节
notes 字段用于记录提醒事项,比如"需提前准备文档"
index 与列表位置分离,方便后续扩展议程排序功能
五、核心模块二:SDK 封装与眼镜通信 这是整个项目最核心的部分——如何与 Rokid 眼镜建立连接并发送数据。我将 CXR-M SDK 的功能封装成 RokidGlassesManager 单例对象。
5.1 SDK 初始化与连接
package com.rokid.meeting.sdk
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import com.rokid.cxr.api.CxrApi
import com.rokid.cxr.callback.BluetoothStatusCallback
import com.rokid.cxr.util.ValueUtil
object RokidGlassesManager {
private val cxrApi: CxrApi by lazy { CxrApi.getInstance() }
private var connectionCallback: ConnectionCallback? = null
interface ConnectionCallback {
fun onConnecting ()
fun onConnected ()
fun onDisconnected ()
fun onFailed (errorMsg: String )
}
interface SendCallback {
fun onSuccess ()
fun onFailed (errorMsg: String )
}
val isConnected: Boolean get () = cxrApi.isBluetoothConnected
}
5.2 查找并连接眼镜
fun findRokidGlasses (bluetoothAdapter: BluetoothAdapter ) : BluetoothDevice? {
if (ActivityCompat.checkSelfPermission(
bluetoothAdapter.javaClass, Manifest.permission.BLUETOOTH_CONNECT
) != PackageManager.PERMISSION_GRANTED
) {
return null
}
return bluetoothAdapter.bondedDevices.find {
it.name?.contains("Rokid" , ignoreCase = true ) || it.name?.contains("Glasses" , ignoreCase = true )
}
}
fun connectGlasses (context: Context , device: BluetoothDevice ) {
connectionCallback?.onConnecting()
cxrApi.initBluetooth(context, device, object : BluetoothStatusCallback() {
override fun onConnectionInfo (
socketUuid: String ?,
mac: String ?,
rokidAccount: String ?,
glassesType: Int
) {
if (!socketUuid.isNullOrEmpty() && !mac.isNullOrEmpty()) {
connectBluetooth(context, socketUuid, mac)
} else {
connectionCallback?.onFailed("获取连接信息失败" )
}
}
override fun onConnected () {
connectionCallback?.onConnected()
}
override fun onDisconnected () {
connectionCallback?.onDisconnected()
}
override fun onFailed (errorCode: ValueUtil .CxrBluetoothErrorCode ?) {
connectionCallback?.onFailed(errorCode?.name ?: "连接失败" )
}
})
}
private fun connectBluetooth (context: Context , socketUuid: String , macAddress: String ) {
cxrApi.connectBluetooth(context, socketUuid, macAddress, object : BluetoothStatusCallback() {
override fun onConnected () {
Log.d("RokidGlassesManager" , "蓝牙连接确认成功" )
}
override fun onDisconnected () {
connectionCallback?.onDisconnected()
}
override fun onFailed (errorCode: ValueUtil .CxrBluetoothErrorCode ?) {
connectionCallback?.onFailed(errorCode?.name ?: "连接失败" )
}
override fun onConnectionInfo (socketUuid: String ?, macAddress: String ?, rokidAccount: String ?, glassesType: Int ) {
}
})
}
踩坑经验 :CXR-M SDK 的蓝牙连接分为两阶段——先通过 initBluetooth() 初始化并获取连接参数(socketUuid 和 macAddress),再通过 connectBluetooth() 完成真正的连接。直接调用 initBluetooth() 后以为连接成功,结果发送数据时一直失败,就是这个原因。
5.3 发送议程到眼镜
fun sendAgenda (meeting: Meeting , currentIndex: Int , callback: SendCallback ? = null ) : Boolean {
if (!isConnected) {
callback?.onFailed("眼镜未连接" )
return false
}
val item = meeting.agenda.getOrNull(currentIndex) ?: return false
val text = buildDisplayText(meeting, item, currentIndex)
cxrApi.controlScene(ValueUtil.CxrSceneType.WORD_TIPS, true , null )
val status = cxrApi.sendStream(
type = ValueUtil.CxrStreamType.WORD_TIPS,
stream = text.toByteArray(Charsets.UTF_8),
fileName = "agenda.txt" ,
cb = object : SendStatusCallback() {
override fun onSendSucceed () {
callback?.onSuccess()
}
override fun onSendFailed (errorCode: ValueUtil .CxrSendErrorCode ?) {
callback?.onFailed(errorCode?.name ?: "发送失败" )
}
}
)
return status == ValueUtil.CxrStatus.REQUEST_SUCCEED
}
fun disconnect () {
cxrApi.deinitBluetooth()
}
5.4 构建显示文本 眼镜端显示的内容需要精心设计——信息要全面,但不能过于拥挤:
private fun buildDisplayText (meeting: Meeting , item: AgendaItem , currentIndex: Int ) : String {
return buildString {
appendLine("📋 ${meeting.title} " )
appendLine()
appendLine("────── 当前议题 ──────" )
appendLine()
appendLine("${currentIndex + 1 } . ${item.title} " )
appendLine()
item.speaker?.let { appendLine("主讲:$it " ) }
appendLine("预计:${item.duration} 分钟" )
item.notes?.let {
appendLine()
appendLine("📝 $it " )
}
}
}
┌──────────────────────────────┐
│ 📋 产品周会 │
│ │
│ ────── 当前议题 ────── │
│ │
│ 3. 需求评审 │
│ │
│ 主讲:王五 │
│ 预计:20 分钟 │
│ │
│ 📝 需提前准备文档 │
└──────────────────────────────┘
六、核心模块三:主界面与业务逻辑
6.1 Activity 结构 主界面是整个应用的控制中心,负责会议流程的管理和眼镜通信的触发:
package com.rokid.meeting
class MainActivity : AppCompatActivity () {
private lateinit var binding: ActivityMainBinding
private var currentMeeting: Meeting? = null
private var currentAgendaIndex = 0
private var startTime: Long = 0
private var timer: java.util.Timer? = null
override fun onCreate (savedInstanceState: Bundle ?) {
super .onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
supportActionBar?.title = "会议纪要助手"
checkPermissions()
setupButtons()
observeConnection()
}
}
6.2 按钮事件绑定 private fun setupButtons () {
binding.btnConnect.setOnClickListener {
if (RokidGlassesManager.isConnected) {
RokidGlassesManager.disconnect()
updateConnectionStatus()
} else {
connectGlasses()
}
}
binding.btnStart.setOnClickListener { startMeeting() }
binding.btnPrev.setOnClickListener { previousAgenda() }
binding.btnNext.setOnClickListener { nextAgenda() }
binding.btnSend.setOnClickListener { sendToGlasses() }
}
6.3 计时器实现 计时是会议管理的核心功能。这里采用基于系统时间计算 的方式,避免定时器累积误差:
private fun startMeeting () {
currentMeeting = MeetingData.meetings[0 ]
currentAgendaIndex = 0
startTime = System.currentTimeMillis()
startTimer()
updateDisplay()
}
private fun startTimer () {
val meeting = currentMeeting ?: return
timer?.cancel()
timer = java.util.Timer()
timer?.scheduleAtFixedRate(object : TimerTask() {
override fun run () {
val elapsed = (System.currentTimeMillis() - startTime) / 1000
val minutes = elapsed / 60
val seconds = (elapsed % 60 ).toInt()
runOnUiThread {
binding.tvElapsed.text = "已进行 ${minutes} 分${seconds} 秒"
}
}
}, 0 , 1000 )
}
var seconds = 0
timer.scheduleAtFixedRate({
seconds++
updateDisplay(seconds)
}, 1000 )
这种实现的问题是:如果手机进入低电量模式或后台运行,定时器可能会被系统暂停或变慢,导致计时不准确。而基于 System.currentTimeMillis() 的计算,无论定时器是否精确,显示的时间永远是准确的。
6.4 议程切换 private fun previousAgenda () {
currentMeeting?.let { meeting ->
if (currentAgendaIndex > 0 ) {
currentAgendaIndex--
startTime = System.currentTimeMillis()
timer?.cancel()
startTimer()
updateDisplay()
}
}
}
private fun nextAgenda () {
currentMeeting?.let { meeting ->
if (currentAgendaIndex < meeting.agenda.size - 1 ) {
currentAgendaIndex++
startTime = System.currentTimeMillis()
timer?.cancel()
startTimer()
updateDisplay()
}
}
}
private fun updateDisplay () {
val meeting = currentMeeting ?: return
val item = meeting.agenda.getOrNull(currentAgendaIndex) ?: return
binding.apply {
tvTitle.text = meeting.title
tvCurrentAgenda.text = item.title
item.speaker?.let { binding.tvSpeaker.text = "发言人:$it " }
binding.tvDuration.text = "预计 ${item.duration} 分钟"
binding.tvPage.text = "议题 ${currentAgendaIndex + 1 } /${meeting.agenda.size} "
}
}
6.5 发送到眼镜 private fun sendToGlasses () {
if (!RokidGlassesManager.isConnected) {
Toast.makeText(this , "请先连接眼镜" , Toast.LENGTH_SHORT).show()
return
}
val meeting = currentMeeting ?: return
RokidGlassesManager.sendAgenda(meeting, currentAgendaIndex, object : RokidGlassesManager.SendCallback {
override fun onSuccess () {
runOnUiThread {
Toast.makeText(this @MainActivity , "已发送到眼镜" , Toast.LENGTH_SHORT).show()
}
}
override fun onFailed (errorMsg: String ) {
runOnUiThread {
Toast.makeText(this @MainActivity , errorMsg, Toast.LENGTH_SHORT).show()
}
}
})
}
6.6 蓝牙连接处理 private fun connectGlasses () {
val adapter = BluetoothAdapter.getDefaultAdapter
if (adapter == null || !adapter.isEnabled) {
Toast.makeText(this , "请开启蓝牙" , Toast.LENGTH_SHORT).show()
return
}
val device = RokidGlassesManager.findRokidGlasses(adapter)
if (device == null ) {
Toast.makeText(this , "未找到眼镜,请先配对" , Toast.LENGTH_SHORT).show()
return
}
RokidGlassesManager.connectGlasses(this , device)
}
private fun observeConnection () {
RokidGlassesManager.setConnectionCallback(
object : RokidGlassesManager.ConnectionCallback {
override fun onConnecting () {
runOnUiThread { binding.btnConnect.text = "连接中..." }
}
override fun onConnected () {
runOnUiThread {
binding.btnConnect.text = "断开连接"
Toast.makeText(this @MainActivity , "眼镜已连接" , Toast.LENGTH_SHORT).show()
}
}
override fun onDisconnected () {
runOnUiThread { binding.btnConnect.text = "连接眼镜" }
}
override fun onFailed (errorMsg: String ) {
runOnUiThread {
binding.btnConnect.text = "连接眼镜"
Toast.makeText(this @MainActivity , errorMsg, Toast.LENGTH_SHORT).show()
}
}
}
)
}
6.7 权限检查 Android 12+ 对蓝牙权限做了细分,需要动态申请:
private fun checkPermissions () {
val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
permissions.add(Manifest.permission.BLUETOOTH_SCAN)
permissions.add(Manifest.permission.BLUETOOTH_CONNECT)
}
val notGranted = permissions.filter {
ContextCompat.checkSelfPermission(this , it) != PackageManager.PERMISSION_GRANTED
}
if (notGranted.isNotEmpty()) {
ActivityCompat.requestPermissions(this , notGranted.toTypedArray(), 100 )
}
}
七、开发中的踩坑与解决方案
7.1 问题一:连接成功但发送失败 现象 :initBluetooth() 回调了 onConnected(),但调用 sendStream() 时返回失败。
原因 :SDK 的蓝牙连接是两阶段的,initBluetooth() 只是初始化阶段,还需要调用 connectBluetooth() 完成真正的连接。
解决 :在 onConnectionInfo() 回调中获取 socketUuid 和 macAddress,然后调用 connectBluetooth()。
7.2 问题二:计时器在后台不准确 现象 :手机锁屏后再打开,计时器显示的时间明显偏少。
原因 :系统为了省电会限制后台应用的定时器执行频率。
解决 :使用 System.currentTimeMillis() 计算已用时间,而不是累加计数器。这样即使定时器不精确,显示的时间也是准确的。
7.3 问题三:眼镜显示中文乱码 stream = text.toByteArray(Charsets.UTF_8)
7.4 问题四:蓝牙权限被拒绝 现象 :在 Android 12+ 设备上,应用启动时直接崩溃。
原因 :Android 12 新增了 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT 权限,需要动态申请。
解决 :在 onCreate() 中检查并申请权限,同时在 AndroidManifest.xml 中声明。
八、功能演示
8.1 功能清单 功能 说明 状态 蓝牙连接 查找并连接 Rokid 眼镜 ✅ 会议管理 预设会议模板 ✅ 议程控制 上一个/下一个切换 ✅ 实时计时 精确到秒的计时显示 ✅ 眼镜同步 议程内容发送到眼镜 ✅ 超时提醒 TTS 语音提醒 🔜 会议纪要 AI 自动生成 🔜
8.2 使用流程
准备阶段 :在手机蓝牙设置中配对 Rokid 眼镜
启动应用 :打开会议纪要助手,授予蓝牙权限
连接眼镜 :点击"连接眼镜"按钮
开始会议 :点击"开始会议",计时自动开始
切换议题 :使用"上一个""下一个"按钮切换议程
同步眼镜 :点击"发送到眼镜",当前议程显示在眼镜上
会议结束 :断开眼镜连接,退出应用
九、总结与展望
9.1 项目总结 这个项目虽然功能相对简单,但完整地展示了 AR 眼镜应用的开发流程:
理解场景 :从用户痛点出发,找到 AR 眼镜的真正价值点
SDK 集成 :学习 CXR-M SDK 的 API,理解其设计思想
架构设计 :合理分层,将 SDK 封装与业务逻辑解耦
细节打磨 :处理好权限、编码、计时等细节问题
9.2 后续规划 当前版本还只是一个 MVP(最小可行产品),后续计划增加以下功能:
超时语音提醒 :当议题超时时,通过眼镜 TTS 发出语音提醒
会议纪要生成 :接入语音识别,自动生成会议纪要
云端同步 :会议记录上传云端,支持多设备查看
多人协作 :支持多副眼镜同时连接,参会者都能看到议程
9.3 关于 AR 办公的思考 AR 眼镜在办公场景有着巨大的想象空间。会议管理只是一个小切口,类似的场景还有:
演讲提词器 :演讲者眼前实时显示台词
培训指导 :操作步骤直接叠加在视野中
远程协作 :专家远程标注,本地实时可见
信息展示 :会议室、工位的信息卡片
期待更多开发者加入 AR 生态,一起探索这个新的人机交互边界。
相关免费在线工具 RSA密钥对生成器 生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
Mermaid 预览与可视化编辑 基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
随机西班牙地址生成器 随机生成西班牙地址(支持马德里、加泰罗尼亚、安达卢西亚、瓦伦西亚筛选),支持数量快捷选择、显示全部与下载。 在线工具,随机西班牙地址生成器在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online