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/

相关资源

相关推荐
ar01233 小时前
AR视频巡检:智慧运维的新模式
人工智能·ar
ar01233 小时前
AR远程协助对比:打造高效协作新格局
人工智能·ar
ar01233 小时前
AR眼镜在巡检当中的作用—让巡检更高效、更智能
人工智能·ar
摘星编程3 小时前
AR 眼镜拯救社恐:我用 Kotlin 写了个拜年提词器
kotlin·ar·restful
AI能见度14 天前
硬核:如何用大疆 SRT 数据实现高精度 AR 视频投射?
ar·无人机·webgl
程序员敲代码吗14 天前
A-Frame与WebXR:构建丰富的VR及AR体验
ar·vr
Once_day19 天前
GCC编译(6)静态库工具AR
c语言·ar·编译和链接
mtouch33319 天前
三维沙盘系统配置管理数字沙盘模块
人工智能·ai·ar·vr·虚拟现实·电子沙盘·数字沙盘
好家伙VCC24 天前
# 发散创新:基于ARCore的实时3D物体识别与交互开发实战 在增强现实(
java·python·3d·ar·交互