当前位置: 首页 > news >正文

AR 眼镜上的出行助手:从零构建基于 Rokid CXR-M SDK 的行程管理应用

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)。原因有三:

  1. 文本渲染完美:提词器专为文本展示优化,支持多行、中文、Emoji
  1. 实时更新:可以随时推送新内容,适合倒计时场景
  1. 开发门槛低:纯文本传输,无需处理复杂的 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 }

关键步骤解析

  1. 场景控制controlScene(WORD_TIPS, true, null)告诉眼镜启用提词器场景。第二个参数true表示激活,null表示使用默认配置。
  1. 数据传输sendStream发送数据流。关键参数:
    • type:指定为提词器类型
    • stream:文本的字节数组,必须使用 UTF-8 编码以支持中文
    • fileName:文件名,眼镜端用于识别内容
  1. 异步回调:发送是异步操作,结果通过回调通知。

五、界面层实现

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 使用场景

  1. 候车时:把行程发送到眼镜,放下手机,抬眼就能确认车次和座位
  1. 进站时:检票口信息随时可见,不用在人群中翻手机
  1. 换乘时:多段行程切换查看,衔接信息一目了然

七、开发踩坑记录

7.1 文本编码问题

问题:第一次测试时,眼镜显示的中文全是乱码。

原因:直接使用默认编码text.toByteArray(),不同设备默认编码可能不同。

解决:显式指定 UTF-8 编码:

stream = text.toByteArray(Charsets.UTF_8)

7.2 倒计时精度问题

问题:固定每分钟更新倒计时,临发车时不够精确。

解决:实现动态刷新频率,越接近发车时间刷新越频繁。

7.3 蓝牙权限适配

问题:Android 12 上连接失败,日志显示权限被拒绝。

解决BLUETOOTH_SCANBLUETOOTH_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

多交通类型

高铁/飞机/大巴/自驾

后续规划

  1. 行程导入:支持从 12306、航旅纵横等 App 解析短信/邮件自动导入
  1. 实时动态:接入列车晚点、航班延误等实时信息
  1. 智能提醒:基于位置和时间,在合适时机主动推送提醒
  1. 多人协同:家庭成员行程共享,互相查看进度

十、总结

这个项目的核心价值在于验证了一个理念:AR 眼镜不只是游戏和娱乐的载体,更是解决日常痛点的实用工具

春运出行的焦虑,很大程度上源于信息的不确定性。这款应用把关键信息"钉"在用户的视野里,抬眼即见,无需操作。这种"零交互"的信息获取方式,是手机无法比拟的。

从技术角度看,Rokid CXR-M SDK 的提词器场景非常适合这类信息展示应用。API 设计简洁,回调机制完善,几行代码就能实现核心功能。对于想要快速上手的开发者来说,这是一个很好的切入点。

AR 眼镜的普及还在早期,但应用场景的探索不能等待。希望这个项目能给其他开发者一些启发,让更多"小而美"的 AR 应用涌现出来,让技术真正服务于生活。


项目源码TripHelper/

相关资源

  • CXR-M SDK 官方文档
  • Rokid 开发者论坛
http://www.jsqmd.com/news/450563/

相关文章:

  • STM32F407ZGT6 USART1 DMA接收配置避坑指南:从NORMAL到CIRCLAR的实战经验
  • IGBT驱动芯片2ED020I12F2避坑指南:去饱和电路常见的5个设计误区及解决方案
  • Herbie气象数据工具:专业气象数据获取与处理的技术指南
  • 基于Coze API的智能客服本地化部署实战:效率提升与避坑指南
  • 护眼工具与视觉健康:Dark Reader的全方位屏幕保护方案
  • 零基础玩转机器人:快马AI带你编写第一个clawbot程序
  • J-LINK和ST-LINK切换的那些坑:当Keil项目残留配置导致No Cortex-M Device错误时
  • 顶点动画纹理技术指南:从原理到跨平台实践
  • 新手入门安卓开发:基于快马生成24点棋牌游戏学事件处理
  • GHelper:解决华硕笔记本性能控制难题的轻量级优化方案
  • 避坑指南:Python爬取百度图片时常见的5个错误及解决方法
  • 用Visual Studio打造蚂蚁世界:有限状态机(FSM)游戏AI实战教程
  • Flutter 三方库 fennec 的鸿蒙化适配指南 - 掌控服务端框架资产、精密 Web 治理实战、鸿蒙级全栈专家
  • Cannot Load Flash Programming Algorithm!
  • 3步解决多语言字体兼容难题:Warcraft Font Merger的跨平台解决方案
  • 解锁企业级流程自动化:Flowable工作流引擎3大核心应用场景与实践指南
  • 颠覆传统的3大技术突破:猫抓Cat-Catch网页视频提取全解析
  • Vue3+Element Plus组合拳:手把手教你实现路由离开确认弹窗(含完整代码)
  • 颠覆GUI开发:3步实现Python界面零代码构建
  • 索尼Xperia设备修复与优化工具:Flashtool全方位技术指南
  • CVPR‘26 FastGS 开源!3DGS训练的全能加速器,覆盖静态/动态/表面/大场景/稀疏视角/SLAM六大重建任务!
  • OpCore-Simplify:黑苹果EFI配置自动化流程全解析
  • Rust新手必看:从零开始搭建开发环境到RustRover配置(附常见问题解决)
  • ESP32智能语音助手开发指南:从部署到定制的全流程实践
  • OpCore Simplify:零门槛构建稳定Hackintosh系统的完整指南
  • Ubuntu新手必看:3秒切换图形界面与命令行的隐藏快捷键(附常见登录问题解决)
  • Three.js新手必看:AxesHelper坐标轴辅助器的5个实用技巧
  • 智能EFI构建:OpCore-Simplify自动化黑苹果配置的创新方法
  • 2026油田除砂器优质产品推荐指南 助力精准选型 - 优质品牌商家
  • 拆解OSTrack的Attention魔法:用可视化工具透视Transformer如何锁定运动目标