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/

相关资源

相关推荐
DaLiangChen1 天前
Vuforia 地面识别案例
unity·ar
DaLiangChen1 天前
Vuforia 地面识别
ar·vuforia
各类产品分享2 天前
工业AR巡检系统哪家好
ar·ar巡检
各类产品分享2 天前
电力行业AR智能运维哪个品牌好
运维·ar·ar巡检
HMS Core2 天前
借助AR Engine人脸识别与跟踪能力,直播不露脸也生动
ar·harmonyos
各类产品分享2 天前
国内做AR远程协作的公司有哪些
ar·ar巡检
各类产品分享3 天前
什么是景区AR实景导航系统
ar·文旅景区ar实景导航
各类产品分享3 天前
国内做景区AR实景导航方案的公司有哪些
ar·文旅景区ar实景导航
各类产品分享5 天前
地铁隧道AR巡检和人工巡检比哪个好
ar·地铁隧道ar巡检系统
各类产品分享5 天前
买地铁隧道AR巡检设备怎么避免踩坑
ar·地铁隧道ar巡检系统