AR 眼镜上的出行助手:从零构建基于 Rokid CXR-M SDK 的行程管理应用
春节回家,是中国人一年中最重要的一段旅程。
抢到票的那一刻是欣喜的,但随之而来的是另一种焦虑:发车时间几点?哪个站台上车?座位号是多少?这些信息散落在不同的短信、App、截图中。候车时反复掏出手机确认,生怕漏掉任何细节。
Rokid AR 眼镜提供了一个独特的解决方案:把关键信息"钉"在视野里。抬眼就能看到,不用解锁手机,不会被消息打断。本文将完整记录如何利用 Rokid CXR-M SDK 构建一款实用的出行导航助手。

一、技术方案设计
1.1 场景分析
出行场景的核心需求是什么?通过调研和分析,我总结了以下几点:
|-------|-----------------|-----|
| 需求 | 描述 | 优先级 |
| 行程展示 | 显示车次、时间、座位等核心信息 | P0 |
| 实时倒计时 | 距离发车还有多久 | P0 |
| 眼镜同步 | 信息推送到 AR 眼镜显示 | P0 |
| 多行程管理 | 支持多段行程切换 | P1 |
| 紧急提醒 | 临发车前的强提醒 | P2 |
1.2 为什么选择提词器场景
Rokid CXR-M SDK 提供了多种场景能力,我选择了提词器场景(WORD_TIPS)。原因有三:
- 文本渲染完美:提词器专为文本展示优化,支持多行、中文、Emoji
- 实时更新:可以随时推送新内容,适合倒计时场景
- 开发门槛低:纯文本传输,无需处理复杂的 3D 渲染
1.3 系统架构
整体采用经典的分层架构:

二、开发环境搭建
2.1 项目依赖配置
首先在 settings.gradle.kts 中添加 Rokid Maven 仓库:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
// Rokid 官方 Maven 仓库
maven { url = uri("https://maven.rokid.com/repository/maven-public/") }
}
}

然后在 app/build.gradle.kts 中引入 SDK:
// app/build.gradle.kts
dependencies {
// Rokid CXR-M SDK 核心库
implementation("com.rokid.cxr:client-m:1.0.1-20250812.080117-2")
// Android 基础组件
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")
}

2.2 权限声明
眼镜通过蓝牙与手机通信,需要申请蓝牙相关权限。在 AndroidManifest.xml 中声明:
<!-- AndroidManifest.xml -->
<!-- 基础蓝牙权限 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<!-- Android 12+ 蓝牙权限(需要运行时申请) -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
注意 neverForLocation 标志:我们只需要扫描蓝牙设备用于连接眼镜,不需要通过蓝牙推断位置,这样可以简化权限申请流程。
2.3 运行时权限处理
Android 12 及以上版本需要动态申请蓝牙权限。我在 MainActivity 中实现了权限检查:

// MainActivity.kt
private fun checkPermissions() {
val permissions = mutableListOf<String>()
if (android.os.Build.VERSION.SDK_INT >= android.os.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)
}
}
三、核心数据模型
3.1 行程数据结构
出行信息包含多种交通类型,我设计了统一的数据结构:

// data/Trip.kt
enum class TripType(val displayName: String, val icon: String) {
FLIGHT("飞机", "✈️"),
TRAIN("高铁", "🚄"),
BUS("大巴", "🚌"),
SELF_DRIVE("自驾", "🚗")
}
data class Trip(
val id: Int,
val type: TripType,
val title: String,
val departureTime: Long, // 时间戳,便于计算和比较
val arrivalTime: Long,
val departurePlace: String,
val arrivalPlace: String,
val tripNo: String? = null, // 车次/航班号
val seat: String? = null, // 座位号
val gate: String? = null, // 检票口/登机口
val note: String? = null // 备注
)
3.2 眼镜端文本生成
数据模型最重要的方法是将行程信息转换为眼镜显示的文本格式:
// data/Trip.kt
fun toGlassesDisplayText(): String {
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
val countdown = departureTime - System.currentTimeMillis()
return buildString {
appendLine("${type.icon} ${tripNo ?: type.displayName}")
appendLine()
appendLine("$departurePlace → $arrivalPlace")
appendLine()
appendLine("发车:${sdf.format(Date(departureTime))}")
appendLine("到达:${sdf.format(Date(arrivalTime))}")
seat?.let { appendLine("座位:$it") }
gate?.let { appendLine("检票口:$it") }
appendLine()
if (countdown > 0) {
appendLine("⏱ 距发车还有 ${formatCountdown(countdown)}")
} else {
appendLine("⚠️ 已过发车时间")
}
}
}
private fun formatCountdown(millis: Long): String {
val hours = millis / (1000 * 60 * 60)
val minutes = (millis / (1000 * 60)) % 60
return when {
hours > 0 -> "${hours}小时${minutes}分钟"
minutes > 0 -> "${minutes}分钟"
else -> "即将出发"
}
}
这里的设计考量:
- 使用
buildString构建多行文本,代码清晰
- 空行用于视觉分隔,提高可读性
- Emoji 图标增强信息辨识度
- 倒计时动态计算,每次推送都是最新状态
3.3 倒计时显示
手机端需要更详细的倒计时显示,我单独实现了这个方法:
fun getCountdownText(): String {
val diff = departureTime - System.currentTimeMillis()
if (diff <= 0) return "已发车"
val hours = diff / (1000 * 60 * 60)
val minutes = (diff / (1000 * 60)) % 60
return when {
hours > 24 -> "还有 ${(hours / 24)}天"
hours > 0 -> "还有 ${hours}小时${minutes}分钟"
minutes > 0 -> "还有 ${minutes}分钟"
else -> "即将出发"
}
}
四、SDK 封装层实现
4.1 眼镜管理器设计
为了解耦业务代码和 SDK 调用,我封装了 RokidGlassesManager 单例对象:
// sdk/RokidGlassesManager.kt
object RokidGlassesManager {
private val cxrApi: CxrApi by lazy { CxrApi.getInstance() }
private var connectionCallback: ConnectionCallback? = null
// 连接状态
val isConnected: Boolean
get() = cxrApi.isBluetoothConnected
interface ConnectionCallback {
fun onConnecting()
fun onConnected()
fun onDisconnected()
fun onFailed(errorMsg: String)
}
interface SendCallback {
fun onSuccess()
fun onFailed(errorMsg: String)
}
}
4.2 蓝牙连接流程
连接眼镜分为两步:先从已配对设备中查找,然后建立连接。
// 查找 Rokid 眼镜
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(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: CxrBluetoothErrorCode?) {
connectionCallback?.onFailed(e?.name ?: "连接失败")
}
override fun onConnectionInfo(a: String?, b: String?, c: String?, d: Int) {}
})
} else {
connectionCallback?.onFailed("获取连接信息失败")
}
}
// ... 其他回调
})
}
这里有个关键点:连接分两个阶段。initBluetooth 获取连接参数,connectBluetooth 执行实际连接。这种设计可能是为了安全性考虑------敏感的连接参数由系统分发。
4.3 数据发送到眼镜
这是最核心的功能:将行程信息推送到眼镜提词器场景。
fun sendTrip(text: String, callback: SendCallback? = null): Boolean {
// 1. 检查连接状态
if (!isConnected) {
callback?.onFailed("眼镜未连接")
return false
}
// 2. 激活提词器场景
cxrApi.controlScene(ValueUtil.CxrSceneType.WORD_TIPS, true, null)
// 3. 发送文本数据
val status = cxrApi.sendStream(
type = ValueUtil.CxrStreamType.WORD_TIPS,
stream = text.toByteArray(Charsets.UTF_8), // 注意使用 UTF-8 编码
fileName = "trip_info.txt",
cb = object : SendStatusCallback() {
override fun onSendSucceed() { callback?.onSuccess() }
override fun onSendFailed(e: CxrSendErrorCode?) {
callback?.onFailed(e?.name ?: "发送失败")
}
}
)
return status == ValueUtil.CxrStatus.REQUEST_SUCCEED
}
关键步骤解析:
- 场景控制 :
controlScene(WORD_TIPS, true, null)告诉眼镜启用提词器场景。第二个参数true表示激活,null表示使用默认配置。
- 数据传输 :
sendStream发送数据流。关键参数:
-
type:指定为提词器类型
-
stream:文本的字节数组,必须使用 UTF-8 编码以支持中文
-
fileName:文件名,眼镜端用于识别内容
- 异步回调:发送是异步操作,结果通过回调通知。
五、界面层实现
5.1 主界面布局
界面采用 Material Design 风格,主要分为三个区域:连接状态卡片、行程信息卡片、操作按钮区。
<!-- res/layout/activity_main.xml -->
<androidx.coordinatorlayout.widget.CoordinatorLayout ...>
<com.google.android.material.appbar.AppBarLayout>
<MaterialToolbar android:id="@+id/toolbar" ... />
</>
<ConstraintLayout ...>
<!-- 连接状态 -->
<MaterialCardView android:id="@+id/cardConnection">
<LinearLayout>
<ImageView android:id="@+id/ivStatus" />
<TextView android:id="@+id/tvConnectionStatus" />
<MaterialButton android:id="@+id/btnConnect" />
</LinearLayout>
</MaterialCardView>
<!-- 行程信息 -->
<MaterialCardView android:id="@+id/cardTrip">
<LinearLayout>
<TextView android:id="@+id/tvCountdown" /> <!-- 倒计时 -->
<TextView android:id="@+id/tvTripNo" /> <!-- 车次 -->
<TextView android:id="@+id/tvRoute" /> <!-- 路线 -->
<!-- 详细信息区域 -->
<TextView android:id="@+id/tvDeparture" />
<TextView android:id="@+id/tvArrival" />
<TextView android:id="@+id/tvSeat" />
<TextView android:id="@+id/tvPage" />
</LinearLayout>
</MaterialCardView>
<!-- 操作按钮 -->
<LinearLayout>
<MaterialButton android:id="@+id/btnPrev" />
<MaterialButton android:id="@+id/btnSend" />
<MaterialButton android:id="@+id/btnNext" />
</LinearLayout>
</ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
5.2 动态倒计时实现
倒计时需要定期刷新,但频率需要根据紧迫程度动态调整:
// MainActivity.kt
private val updateHandler = Handler(Looper.getMainLooper())
private val countdownRunnable = object : Runnable {
override fun run() {
trips.getOrNull(currentIndex)?.let { updateCountdown(it) }
// 动态调整更新频率
val trip = trips.getOrNull(currentIndex)
val interval = trip?.let {
getUpdateInterval(it.departureTime - System.currentTimeMillis())
} ?: 60000L
updateHandler.postDelayed(this, interval)
}
}
private fun getUpdateInterval(countdown: Long): Long = when {
countdown <= 0 -> 60000L // 已发车:1分钟刷新
countdown < 10 * 60 * 1000 -> 10000L // 10分钟内:10秒刷新
countdown < 30 * 60 * 1000 -> 30000L // 30分钟内:30秒刷新
countdown < 2 * 60 * 60 * 1000 -> 60000L // 2小时内:1分钟刷新
else -> 5 * 60 * 1000L // 其他:5分钟刷新
}
这种设计既保证了临发车时的精确显示,又避免了长时间内的频繁刷新消耗电量。
5.3 连接状态观察
通过回调模式观察眼镜连接状态,更新 UI:
private fun observeConnection() {
RokidGlassesManager.setConnectionCallback(object : ConnectionCallback {
override fun onConnecting() {
runOnUiThread {
binding.btnConnect.text = "连接中..."
binding.ivStatus.setImageResource(android.R.drawable.presence_away)
}
}
override fun onConnected() {
runOnUiThread {
binding.btnConnect.text = "断开连接"
binding.ivStatus.setImageResource(android.R.drawable.presence_online)
Toast.makeText(this@MainActivity, "眼镜连接成功", Toast.LENGTH_SHORT).show()
}
}
override fun onDisconnected() {
runOnUiThread {
binding.btnConnect.text = "连接眼镜"
binding.ivStatus.setImageResource(android.R.drawable.presence_invisible)
}
}
override fun onFailed(errorMsg: String) {
runOnUiThread {
Toast.makeText(this@MainActivity, errorMsg, Toast.LENGTH_SHORT).show()
}
}
})
}
六、实际运行效果
6.1 手机端界面
应用启动后显示行程列表,通过左右按钮切换不同行程。大字号的倒计时一目了然,连接状态实时显示。
6.2 眼镜端显示
连接眼镜后点击"发送到眼镜",行程信息会显示在眼镜视野中:
┌──────────────────────────────┐
│ 🚄 G1234 │
│ │
│ 北京南站 → 上海虹桥站 │
│ │
│ 发车:01-28 08:30 │
│ 到达:01-28 12:45 │
│ 座位:05车 12A │
│ 检票口:12 │
│ │
│ ⏱ 距发车还有 2小时15分钟 │
└──────────────────────────────┘
6.3 使用场景
- 候车时:把行程发送到眼镜,放下手机,抬眼就能确认车次和座位
- 进站时:检票口信息随时可见,不用在人群中翻手机
- 换乘时:多段行程切换查看,衔接信息一目了然
七、开发踩坑记录
7.1 文本编码问题
问题:第一次测试时,眼镜显示的中文全是乱码。
原因 :直接使用默认编码 text.toByteArray(),不同设备默认编码可能不同。
解决:显式指定 UTF-8 编码:
stream = text.toByteArray(Charsets.UTF_8)
7.2 倒计时精度问题
问题:固定每分钟更新倒计时,临发车时不够精确。
解决:实现动态刷新频率,越接近发车时间刷新越频繁。
7.3 蓝牙权限适配
问题:Android 12 上连接失败,日志显示权限被拒绝。
解决 :BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT 需要运行时申请,且 BLUETOOTH_SCAN 可以声明 neverForLocation 避免申请位置权限。
八、项目结构总览
TripHelper/
├── app/
│ ├── src/main/
│ │ ├── java/com/rokid/trip/
│ │ │ ├── MainActivity.kt # 主界面
│ │ │ ├── data/
│ │ │ │ └── Trip.kt # 数据模型
│ │ │ └── sdk/
│ │ │ └── RokidGlassesManager.kt # SDK封装
│ │ ├── res/
│ │ │ ├── layout/
│ │ │ │ └── activity_main.xml # 主界面布局
│ │ │ └── values/
│ │ │ ├── colors.xml
│ │ │ ├── strings.xml
│ │ │ └── themes.xml
│ │ └── AndroidManifest.xml
│ └── build.gradle.kts
├── build.gradle.kts
└── settings.gradle.kts
九、功能清单与后续规划
已实现功能
|-------|----|---------------|
| 功能 | 状态 | 说明 |
| 行程展示 | ✅ | 卡片式显示,支持多行程切换 |
| 实时倒计时 | ✅ | 动态刷新频率 |
| 眼镜连接 | ✅ | 自动发现已配对设备 |
| 提词器推送 | ✅ | 支持中文、Emoji |
| 多交通类型 | ✅ | 高铁/飞机/大巴/自驾 |
后续规划
- 行程导入:支持从 12306、航旅纵横等 App 解析短信/邮件自动导入
- 实时动态:接入列车晚点、航班延误等实时信息
- 智能提醒:基于位置和时间,在合适时机主动推送提醒
- 多人协同:家庭成员行程共享,互相查看进度
十、总结
这个项目的核心价值在于验证了一个理念:AR 眼镜不只是游戏和娱乐的载体,更是解决日常痛点的实用工具。
春运出行的焦虑,很大程度上源于信息的不确定性。这款应用把关键信息"钉"在用户的视野里,抬眼即见,无需操作。这种"零交互"的信息获取方式,是手机无法比拟的。
从技术角度看,Rokid CXR-M SDK 的提词器场景非常适合这类信息展示应用。API 设计简洁,回调机制完善,几行代码就能实现核心功能。对于想要快速上手的开发者来说,这是一个很好的切入点。
AR 眼镜的普及还在早期,但应用场景的探索不能等待。希望这个项目能给其他开发者一些启发,让更多"小而美"的 AR 应用涌现出来,让技术真正服务于生活。
项目源码 :TripHelper/
相关资源: