AR 眼镜上的出行助手从零构建基于 Rokid CXR-M SDK 的行程管理应用春节回家是中国人一年中最重要的一段旅程。抢到票的那一刻是欣喜的但随之而来的是另一种焦虑发车时间几点哪个站台上车座位号是多少这些信息散落在不同的短信、App、截图中。候车时反复掏出手机确认生怕漏掉任何细节。Rokid AR 眼镜提供了一个独特的解决方案把关键信息钉在视野里。抬眼就能看到不用解锁手机不会被消息打断。本文将完整记录如何利用 Rokid CXR-M SDK 构建一款实用的出行导航助手。一、技术方案设计1.1 场景分析出行场景的核心需求是什么通过调研和分析我总结了以下几点需求描述优先级行程展示显示车次、时间、座位等核心信息P0实时倒计时距离发车还有多久P0眼镜同步信息推送到 AR 眼镜显示P0多行程管理支持多段行程切换P1紧急提醒临发车前的强提醒P21.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:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / !-- Android 12 蓝牙权限需要运行时申请 -- uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT /注意neverForLocation标志我们只需要扫描蓝牙设备用于连接眼镜不需要通过蓝牙推断位置这样可以简化权限申请流程。2.3 运行时权限处理Android 12 及以上版本需要动态申请蓝牙权限。我在MainActivity中实现了权限检查// MainActivity.kt private fun checkPermissions() { val permissions mutableListOfString() 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:idid/toolbar ... / / ConstraintLayout ... !-- 连接状态 -- MaterialCardView android:idid/cardConnection LinearLayout ImageView android:idid/ivStatus / TextView android:idid/tvConnectionStatus / MaterialButton android:idid/btnConnect / /LinearLayout /MaterialCardView !-- 行程信息 -- MaterialCardView android:idid/cardTrip LinearLayout TextView android:idid/tvCountdown / !-- 倒计时 -- TextView android:idid/tvTripNo / !-- 车次 -- TextView android:idid/tvRoute / !-- 路线 -- !-- 详细信息区域 -- TextView android:idid/tvDeparture / TextView android:idid/tvArrival / TextView android:idid/tvSeat / TextView android:idid/tvPage / /LinearLayout /MaterialCardView !-- 操作按钮 -- LinearLayout MaterialButton android:idid/btnPrev / MaterialButton android:idid/btnSend / MaterialButton android:idid/btnNext / /LinearLayout /ConstraintLayout /androidx.coordinatorlayout.widget.CoordinatorLayout5.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 连接状态观察通过回调模式观察眼镜连接状态更新 UIprivate 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(thisMainActivity, 眼镜连接成功, 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(thisMainActivity, 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/相关资源CXR-M SDK 官方文档Rokid 开发者论坛