从一个尴尬的春节聚会说起:我用 Rokid AR 眼镜做了个聚会游戏助手
今年春节,我被委以重任——负责组织家里亲戚们的游戏环节。本以为简单的真心话大冒险,却让我手忙脚乱:一边在手机上翻找题目,一边还要解释规则,更要命的是,每次我刚把题目看个大概,旁边眼尖的表弟就已经喊出了答案。整个游戏下来,我疲于奔命,大家也玩得不尽兴。 那一刻我就在想:如果有一个设备能让我从容掌控游戏节奏,同时又不暴露题目给所有人,该多好? 直到我接触到 Rokid CXR-M SDK,我意识到——这个想法可以实现。这篇文章,就是我如何用这款 SDK 开发聚会游戏助手的完整记录。
一、为什么是 AR 眼镜?一个产品思考
在动手写代码之前,我花了不少时间思考:为什么不用手机 App 就够了?
| 场景 | 手机方案 | AR眼镜方案 |
|---|---|---|
| 组织者状态 | 眼睛盯着手机屏幕 | 抬头看向参与者 |
| 题目保密 | 容易被旁人看到 | 只有组织者可见 |
| 游戏氛围 | “等等,我看下题” | 流畅自然 |
| 时间把控 | 需要看时钟 | 倒计时直接显示 |
核心差异在于:手机方案把组织者变成了"管理员",而眼镜方案让组织者回归"参与者"。 Rokid 的 CXR-M SDK 提供了「提词器场景」——这正是我需要的:将文字内容推送到眼镜屏幕显示。配合 TTS(语音合成)能力,还能在游戏开始或结束时播放提示
二、项目架构:简单但不简陋
这个项目的核心原则是保持简单——毕竟只是一个聚会小工具。整个应用只有三个核心类:
com.rokid.game/
├── MainActivity.kt # 主界面,处理所有交互逻辑
├── data/
│ └── GameData.kt # 数据模型和预设题目
└── sdk/
└── RokidGlassesManager.kt # SDK 封装层
为什么把 SDK 封装单独放一层?因为我想让业务代码与 SDK 实现解耦。如果将来 SDK 升级或者换成其他方案,只需要修改这一个文件。
三、Step by Step:从零开始的开发过程
第一步:配置项目依赖
首先是引入 CXR-M SDK。在 settings.gradle.kts 中配置仓库:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://s01.oss.sonatype.org/content/repositories/releases/") }
maven { url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") }
}
}
然后在 app/build.gradle.kts 中添加依赖:
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")
}
踩坑提示:CXR-M SDK 需要 Android API 28+,记得在 defaultConfig 中设置 minSdk = 28
第二步:配置蓝牙权限
眼镜通过蓝牙与手机连接,需要在 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" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
踩坑提示:Android 12+ 需要动态申请 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT 权限,在代码中要处理这个逻辑:
// MainActivity.kt
private fun checkPermissions() {
val perms = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
perms.add(Manifest.permission.BLUETOOTH_SCAN)
perms.add(Manifest.permission.BLUETOOTH_CONNECT)
}
val notGranted = perms.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (notGranted.isNotEmpty()) {
ActivityCompat.requestPermissions(this, notGranted.toTypedArray(), 100)
}
}
第三步:设计数据模型
我选择了三种经典聚会游戏:真心话大冒险、你比我猜、我是谁。数据模型的设计直接影响后续代码的复杂度,所以我在这里花了不少心思:
// GameData.kt
enum class GameType(val displayName: String) {
TRUTH_OR_DARE("真心话大冒险"),
CHARADES("你比我猜"),
WHO_AM_I("我是谁"),
COUNTDOWN("数数字")
}
data class GameQuestion(
val id: Int,
val gameType: GameType,
val content: String,
val answer: String? = null, // "我是谁"需要答案
val isTruth: Boolean = true // 真心话大冒险需要区分真心话/大冒险
)
这里有一个设计细节:answer 字段是可空的,因为真心话大冒险和你比我猜不需要答案显示。而 isTruth 字段只对真心话大冒险有意义,用于在眼镜上显示「真心话」还是「大冒险」的标题。
预设数据我直接硬编码在 GameData 单例中:
// GameData.kt
object GameData {
val questions: List<GameQuestion> = listOf(
// 真心话
GameQuestion(1, GameType.TRUTH_OR_DARE, "你最近一次哭是什么时候?", null, true),
GameQuestion(2, GameType.TRUTH_OR_DARE, "你最尴尬的经历是什么?", null, true),
GameQuestion(3, GameType.TRUTH_OR_DARE, "你有暗恋的人吗?", null, true),
// 大冒险
GameQuestion(6, GameType.TRUTH_OR_DARE, "给通讯录第5个人打电话说新年快乐", null, false),
GameQuestion(7, GameType.TRUTH_OR_DARE, "模仿一个动物叫声", null, false),
// 你比我猜
GameQuestion(11, GameType.CHARADES, "包饺子", null),
GameQuestion(12, GameType.CHARADES, "放鞭炮", null),
// 我是谁
GameQuestion(17, GameType.WHO_AM_I, "孙悟空", "西游记角色"),
GameQuestion(18, GameType.WHO_AM_I, "奥特曼", "动漫角色"),
// ... 更多题目
)
}
随机选题要避免重复,我实现了一个简单但有效的方法
// GameData.kt
fun getRandom(type: GameType, used: Set<Int>): GameQuestion? {
val available = getByType(type).filter { it.id !in used }
// 如果全部用完了,就从所有题目中随机选
return if (available.isNotEmpty()) available.random() else getByType(type).random()
}
第四步:封装 SDK 交互
这是整个项目最核心的部分。我创建了一个 RokidGlassesManager 单例来封装所有与眼镜的交互。 首先定义回调接口,让调用方能够异步处理结果:
// RokidGlassesManager.kt
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
}
连接眼镜的流程稍微复杂一些,需要先初始化蓝牙、获取连接信息、再建立连接:
// RokidGlassesManager.kt
fun connectGlasses(context: Context, device: BluetoothDevice) {
connectionCallback?.onConnecting()
cxrApi.initBluetooth(context, device, object : BluetoothStatusCallback() {
override fun onConnectionInfo(uuid: String?, mac: String?, account: String?, type: Int) {
if (!uuid.isNullOrEmpty() && !mac.isNullOrEmpty()) {
cxrApi.connectBluetooth(context, uuid, mac, object : BluetoothStatusCallback() {
override fun onConnected() { connectionCallback?.onConnected() }
override fun onDisconnected() { connectionCallback?.onDisconnected() }
override fun onFailed(e: ValueUtil.CxrBluetoothErrorCode?) {
connectionCallback?.onFailed(e?.name ?: "连接失败")
}
// 需要空实现这个方法,即使我们不使用它
override fun onConnectionInfo(a: String?, b: String?, c: String?, d: Int) {}
})
} else {
connectionCallback?.onFailed("获取连接信息失败")
}
}
override fun onConnected() { connectionCallback?.onConnected() }
override fun onDisconnected() { connectionCallback?.onDisconnected() }
override fun onFailed(e: ValueUtil.CxrBluetoothErrorCode?) {
connectionCallback?.onFailed(e?.name ?: "连接失败")
}
})
}
踩坑提示:connectBluetooth 的回调中,onConnectionInfo 方法必须实现,否则可能无法正常回调 onConnected。这个问题困扰了我好几个小时。 查找眼镜设备的逻辑很简单,就是遍历已配对的蓝牙设备:
// RokidGlassesManager.kt
fun findRokidGlasses(adapter: BluetoothAdapter): BluetoothDevice? {
if (ActivityCompat.checkSelfPermission(adapter.javaClass, Manifest.permission.BLUETOOTH_CONNECT)
!= PackageManager.PERMISSION_GRANTED) return null
return adapter.bondedDevices.find {
it.name?.contains("Rokid", ignoreCase = true) == true
}
}
发送内容到眼镜是核心功能。CXR-M SDK 的提词器场景通过 sendStream 方法发送文本
// RokidGlassesManager.kt
fun sendGameContent(text: String, callback: SendCallback? = null): Boolean {
if (!isConnected) {
callback?.onFailed("眼镜未连接")
return false
}
// 先激活提词器场景
cxrApi.controlScene(ValueUtil.CxrSceneType.WORD_TIPS, true, null)
// 再发送内容
val status = cxrApi.sendStream(
ValueUtil.CxrStreamType.WORD_TIPS,
text.toByteArray(Charsets.UTF_8),
"game.txt",
object : SendStatusCallback() {
override fun onSendSucceed() { callback?.onSuccess() }
override fun onSendFailed(e: ValueUtil.CxrSendErrorCode?) {
callback?.onFailed(e?.name ?: "发送失败")
}
}
)
return status == ValueUtil.CxrStatus.REQUEST_SUCCEED
}
TTS 语音播报是锦上添花的功能,可以在倒计时结束时播报"时间到":
// RokidGlassesManager.kt
fun sendTts(text: String): Boolean {
if (!isConnected) return false
return if (cxrApi.sendTtsContent(text) == ValueUtil.CxrStatus.REQUEST_SUCCEED) {
cxrApi.notifyTtsAudioFinished()
true
} else false
}
第五步:主界面逻辑
主界面 MainActivity.kt 负责所有用户交互。我选择了简洁的设计:顶部显示游戏类型和当前题目,底部是操作按钮。 游戏类型切换的逻辑:
// MainActivity.kt
private fun selectGameType(type: GameType) {
currentType = type
usedQuestions.clear() // 切换游戏时清空已用题目
binding.tvGameType.text = type.displayName
nextQuestion()
updateButtonStyles(type)
}
倒计时功能是你比我猜游戏的核心。我使用 Android 的 CountDownTimer,并在最后 10 秒同步更新眼镜显示:
// MainActivity.kt
private fun startCountdown() {
countdownTimer?.cancel()
binding.tvCountdown.text = "60"
countdownTimer = object : CountDownTimer(60000, 1000) {
override fun onTick(millis: Long) {
binding.tvCountdown.text = "${millis / 1000}"
// 最后10秒同步到眼镜
if (millis / 1000 <= 10) {
sendToGlasses("⏱ 倒计时:${millis / 1000}秒")
}
}
override fun onFinish() {
binding.tvCountdown.text = "0"
RokidGlassesManager.sendTts("时间到!")
}
}.start()
}
发送到眼镜的内容格式需要精心设计,保证在眼镜上显示清晰易读:
// MainActivity.kt
private fun buildDisplayText(): String = buildString {
val q = currentQuestion ?: return ""
appendLine("🎮 ${currentType.displayName}")
appendLine()
if (currentType == GameType.TRUTH_OR_DARE) {
appendLine("────── ${if (q.isTruth) "真心话" else "大冒险"} ──────")
} else {
appendLine("────── 题目 ──────")
}
appendLine()
appendLine(q.content)
appendLine()
appendLine("👆 手机点击下一题")
}
四、实际使用体验
开发完成后,我在一次朋友聚会上测试了这个应用。使用流程是:
- 打开 APP,选择游戏类型
- 连接 Rokid 眼镜(首次需要配对)
- 点击「发送到眼镜」,题目出现在眼镜屏幕上
- 游戏进行中,用手机翻页或启动倒计时 眼镜端的显示效果:
┌──────────────────────────────┐
│ 🎮 你比我猜 │
│ │
│ ────── 题目 ────── │
│ │
│ 包饺子 │
│ │
│ 👆 手机点击下一题 │
└──────────────────────────────┘
实际效果:作为组织者,我终于可以抬头面对参与者,通过眼镜确认题目,而不用低头看手机。游戏节奏明显更流畅了,大家玩得也更尽兴。
五、遇到的问题与解决
问题一:题目全部用完后怎么办?
最初的实现会导致空指针异常。解决方案是在 getRandom 方法中,当没有可用题目时,重新从所有题目中随机选:
fun getRandom(type: GameType, used: Set<Int>): GameQuestion? {
val available = getByType(type).filter { it.id !in used }
return if (available.isNotEmpty()) available.random() else getByType(type).random()
}
问题二:倒计时精度问题
CountDownTimer 在某些设备上会有精度问题。对于聚会游戏这种场景,秒级精度足够了,但如果需要更精确的计时,建议使用 Handler + Runnable 的方式
private val handler = Handler(Looper.getMainLooper())
private var remainingSeconds = 60
private val tickRunnable = object : Runnable {
override fun run() {
if (remainingSeconds > 0) {
remainingSeconds--
updateDisplay()
handler.postDelayed(this, 1000)
} else {
onTimeUp()
}
}
}
问题三:屏幕常亮
游戏过程中屏幕不能熄灭,否则重新唤醒需要时间。解决方案是在 onCreate 中添加:
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
六、功能清单
| 功能 | 说明 |
|---|---|
| 四种游戏 | 真心话大冒险/你比我猜/我是谁/数字 |
| 随机出题 | 自动避免重复 |
| 60秒倒计时 | 你比我猜模式专用,最后10秒同步眼镜 |
| 眼镜同步 | 题目实时推送到眼镜显示 |
| TTS语音 | 倒计时结束播报“时间到” |
| 蓝牙连接 | 自动查找已配对的 Rokid 设备 |
七、不足与展望
当前版本的不足: ● 题目数量有限,且硬编码在代码中 ● 不支持用户自定义添加题目 ● 没有积分和排行榜系统 ● 只支持单机模式 后续可以改进的方向:
- 云端题库:将题目存储在云端,支持实时更新
- 自定义题目:允许用户添加自己的题目
- 多人模式:通过局域网实现多设备同步
- 更多游戏:增加狼人杀、谁是卧底等游戏
八、结语
这个项目虽然规模不大,但让我深入理解了 AR 眼镜在日常生活中可能的应用场景。聚会游戏助手解决的不是一个技术难题,而是一个体验问题——让组织者从"管理员"回归"参与者"。 Rokid CXR-M SDK 的封装做得不错,让开发者可以专注于业务逻辑,而不用关心底层通信细节。提词器场景的设计也很巧妙,非常适合这类需要私密显示内容的应用。 如果你也有类似的想法,不妨动手试试。代码量不大,但成就感满满。 项目源码:PartyGameHelper/ 相关资源: CXR-M SDK 官方文档 Rokid 开发者论坛