AR 眼镜拯救社恐:我用 Kotlin 写了个拜年提词器

AR 眼镜拯救社恐:我用 Kotlin 写了个拜年提词器

零、一场社死的春节

大年初一早上七点,我站在二姑家楼下,手心出汗,大脑宕机。

"等会见面...说什么来着?福如东海?不对,那是给老人家的...二姑才五十..."

这已经是我连续第三年拜年"翻车"了。作为一个能徒手撸出复杂状态机的程序员,面对亲戚时却连句完整的吉利话都憋不出来。我爸在旁边疯狂使眼色,我妈事后数落我"读了这么多年书,嘴巴还没三岁小孩利索"。

拜年词穷,本质上是社交场景下的高并发检索问题------你脑子里存了一堆祝福语,但压力上来时,数据库直接锁死,返回 404。

回去的路上,我盯着口袋里的 Rokid AR 眼镜,突然灵光一闪:

为什么不用 AR 眼镜做一个"拜年提词器"?

抬眼就能看到,别人完全察觉不到,简直就是社恐救星。

说干就干,我花了一天时间开发了这个「拜年祝福助手」。


一、技术选型:为什么是 AR 眼镜

在动手之前,我对比了几种方案:

方案 优点 缺点
手机备忘录 简单 太明显,像在念稿子
智能手表 隐蔽 屏幕太小,翻页困难
蓝牙耳机 完全隐蔽 只能听不能看,容易漏听
AR 眼镜 抬眼即见,完全隐蔽 需要开发 APP

AR 眼镜的优势太明显了:你只是在"看远方",别人根本不知道你在看提词器。而且 Rokid 提供了完善的 SDK,支持文字投射和语音播报,简直是为此场景量身定做的。

最终技术栈:

  • Android 原生开发(Kotlin)
  • Material Design 3 组件库
  • Rokid CXR-M SDK(眼镜通信)

二、架构设计:简约但不简单

整体架构采用经典的分层设计:

为什么单独抽一层 SDK 封装?因为 Rokid 的蓝牙连接流程比较特殊,需要处理各种回调和状态,封装后上层调用就变得非常简单。


三、开发实战:从零开始

3.1 项目初始化

首先配置 Rokid Maven 仓库,在 settings.gradle.kts 中添加:

scss 复制代码
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        // Rokid Maven 仓库
        maven { url = uri("https://s01.oss.sonatype.org/content/repositories/releases/") }
        maven { url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") }
    }
}

然后在 app/build.gradle.kts 中引入依赖:

scss 复制代码
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")

    // Lifecycle 组件
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
    implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
}

蓝牙权限配置 AndroidManifest.xml

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" />

<!-- 部分设备需要位置权限才能扫描蓝牙 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

3.2 数据模型:祝福语分类管理

为了覆盖不同拜年场景,我设计了分类系统:

kotlin 复制代码
// GreetingTemplate.kt

/**
 * 祝福语分类 - 覆盖主要拜年对象
 */
enum class GreetingCategory(val displayName: String) {
    ELDERS("长辈"),      // 爷爷奶奶、叔叔阿姨
    PEERS("同辈"),       // 表兄弟、堂姐妹
    CHILDREN("晚辈"),    // 侄子侄女
    COLLEAGUES("同事"),  // 工作相关
    FRIENDS("朋友")      // 死党闺蜜
}

/**
 * 祝福语模板
 */
data class GreetingTemplate(
    val id: Int,
    val category: GreetingCategory,  // 分类
    val scene: String,               // 场景:拜年/发红包/微信拜年
    val content: String,             // 祝福语内容
    val isFormal: Boolean            // 是否正式风格
)

然后是内置的祝福语数据库,我精心准备了 25 条不同风格的祝福语:

kotlin 复制代码
object GreetingData {
    val templates: List<GreetingTemplate> = listOf(
        // 长辈 - 正式庄重
        GreetingTemplate(1, ELDERS, "拜年",
            "爷爷奶奶新年好!祝您福如东海、寿比南山!", true),
        GreetingTemplate(2, ELDERS, "拜年",
            "祝您新春快乐,身体健康,万事如意!", true),
        GreetingTemplate(3, ELDERS, "拜年",
            "叔叔阿姨过年好!祝您工作顺利,阖家幸福!", true),
        GreetingTemplate(4, ELDERS, "发红包",
            "恭喜发财,红包拿来!祝您财源广进!", false),

        // 同辈 - 轻松有趣
        GreetingTemplate(5, PEERS, "拜年",
            "新年快乐!今年一起暴富!", false),
        GreetingTemplate(6, PEERS, "拜年",
            "过年好!祝你脱单成功,升职加薪!", false),
        GreetingTemplate(7, PEERS, "发红包",
            "恭喜发财,大吉大利!红包走一波!", false),

        // 晚辈 - 亲切可爱
        GreetingTemplate(8, CHILDREN, "拜年",
            "新年快乐!祝你学业进步,快高长大!", true),
        GreetingTemplate(9, CHILDREN, "发红包",
            "新年好!红包拿去买好吃的!", false),

        // ... 更多祝福语
    )

    // 按分类筛选
    fun getByCategory(category: GreetingCategory): List<GreetingTemplate> =
        templates.filter { it.category == category }

    // 随机获取一条
    fun getRandom(category: GreetingCategory): GreetingTemplate =
        getByCategory(category).random()
}

3.3 SDK 封装:核心通信逻辑

这是整个项目最核心的部分。Rokid 的蓝牙连接流程比较特殊,需要两步握手

kotlin 复制代码
// RokidGlassesManager.kt

object RokidGlassesManager {

    private val cxrApi: CxrApi by lazy { CxrApi.getInstance() }
    private var connectionCallback: ConnectionCallback? = null

    // 连接状态
    val isConnected: Boolean
        get() = cxrApi.isBluetoothConnected

    /**
     * 连接眼镜 - 两步流程
     * 第一步:initBluetooth 获取连接信息
     * 第二步:connectBluetooth 建立实际连接
     */
    fun connectGlasses(context: Context, device: BluetoothDevice) {
        connectionCallback?.onConnecting()

        // 第一步:初始化蓝牙,获取 UUID 和 MAC
        cxrApi.initBluetooth(context, device, object : BluetoothStatusCallback() {
            override fun onConnectionInfo(
                socketUuid: String?,
                macAddress: String?,
                rokidAccount: String?,
                glassesType: Int
            ) {
                // 获取到连接信息,进行第二步连接
                if (!socketUuid.isNullOrEmpty() && !macAddress.isNullOrEmpty()) {
                    connectBluetooth(context, socketUuid, macAddress)
                } else {
                    connectionCallback?.onFailed("获取连接信息失败")
                }
            }

            override fun onFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?) {
                connectionCallback?.onFailed(errorCode?.name ?: "连接失败")
            }
        })
    }

    // 第二步:使用获取的信息建立连接
    private fun connectBluetooth(context: Context, uuid: String, mac: String) {
        cxrApi.connectBluetooth(context, uuid, mac, object : BluetoothStatusCallback() {
            override fun onConnected() {
                Log.d("Rokid", "蓝牙连接成功")
            }
            override fun onDisconnected() {
                connectionCallback?.onDisconnected()
            }
            override fun onFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?) {
                connectionCallback?.onFailed(errorCode?.name ?: "连接失败")
            }
        })
    }
}

连接成功后,就可以发送祝福语到眼镜了:

kotlin 复制代码
/**
 * 发送祝福语到眼镜显示
 * 关键点:必须使用 UTF-8 编码,否则中文乱码
 */
fun sendGreeting(text: String, callback: SendCallback? = null): Boolean {
    if (!isConnected) {
        callback?.onFailed("眼镜未连接")
        return false
    }

    // 1. 打开提词器场景
    cxrApi.controlScene(ValueUtil.CxrSceneType.WORD_TIPS, true, null)

    // 2. 发送文本流 - 注意 UTF-8 编码!
    val status = cxrApi.sendStream(
        type = ValueUtil.CxrStreamType.WORD_TIPS,
        stream = text.toByteArray(Charsets.UTF_8),  // 关键!
        fileName = "greeting.txt",
        cb = object : SendStatusCallback() {
            override fun onSendSucceed() { callback?.onSuccess() }
            override fun onSendFailed(errorCode: ValueUtil.CxrSendErrorCode?) {
                callback?.onFailed(errorCode?.name ?: "发送失败")
            }
        }
    )
    return status == ValueUtil.CxrStatus.REQUEST_SUCCEED
}

/**
 * TTS 语音播报(可选功能)
 */
fun sendTts(text: String): Boolean {
    if (!isConnected) return false
    val status = cxrApi.sendTtsContent(text)
    if (status == ValueUtil.CxrStatus.REQUEST_SUCCEED) {
        cxrApi.notifyTtsAudioFinished()
        return true
    }
    return false
}

3.4 UI 界面:简洁实用

界面设计遵循 Material Design 3 规范,采用红色喜庆主题:

xml 复制代码
<!-- activity_main.xml 核心结构 -->

<LinearLayout android:orientation="vertical">

    <!-- 顶部工具栏 - 喜庆红色 -->
    <Toolbar
        android:background="#D32F2F"
        app:title="拜年祝福助手"
        app:titleTextColor="#FFFFFF" />

    <ScrollView>
        <LinearLayout android:padding="16dp">

            <!-- 分类选择器 -->
            <TextView text="选择祝福对象" />
            <ChipGroup android:id="@+id/chipGroup" />

            <!-- 祝福语展示卡片 -->
            <CardView app:cardCornerRadius="12dp">
                <LinearLayout android:gravity="center">
                    <TextView android:id="@+id/tvScene" />     <!-- 场景标签 -->
                    <TextView android:id="@+id/tvContent" />   <!-- 祝福语内容 -->
                    <TextView android:id="@+id/tvPage" />      <!-- 页码 -->
                </LinearLayout>

            </CardView>

            <!-- 翻页按钮组 -->
            <LinearLayout android:orientation="horizontal">
                <Button android:id="@+id/btnPrev" text="上一条" />
                <Button android:id="@+id/btnRandom" text="随机" />
                <Button android:id="@+id/btnNext" text="下一条" />
            </LinearLayout>

            <!-- 眼镜控制区域 -->
            <CardView>
                <LinearLayout>
                    <TextView text="Rokid 眼镜" />
                    <TextView android:id="@+id/tvStatus" text="未连接" />
                    <Button android:id="@+id/btnConnect" text="连接眼镜" />
                    <Button android:id="@+id/btnSend" text="发送到眼镜" />
                    <Button android:id="@+id/btnTts" text="语音播报" />
                </LinearLayout>

            </CardView>

        </LinearLayout>

    </ScrollView>

</LinearLayout>

Activity 逻辑实现:

kotlin 复制代码
// MainActivity.kt

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private var currentCategory = GreetingCategory.ELDERS
    private var currentIndex = 0
    private var currentTemplates: List<GreetingTemplate> = emptyList()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        setupChips()           // 初始化分类选择器
        setupButtons()         // 绑定按钮事件
        checkPermissions()     // 检查蓝牙权限
        observeConnection()    // 监听连接状态
        loadCategory(GreetingCategory.ELDERS)
    }

    private fun setupChips() {
        GreetingCategory.values().forEach { category ->
            val chip = Chip(this).apply {
                text = category.displayName
                isCheckable = true
                setOnClickListener { loadCategory(category) }
            }
            binding.chipGroup.addView(chip)
        }
    }

    private fun loadCategory(category: GreetingCategory) {
        currentCategory = category
        currentTemplates = GreetingData.getByCategory(category)
        currentIndex = 0
        updateDisplay()
    }

    private fun updateDisplay() {
        val template = currentTemplates[currentIndex]
        binding.tvContent.text = template.content
        binding.tvScene.text = "场景:${template.scene}"
        binding.tvPage.text = "${currentIndex + 1}/${currentTemplates.size}"
    }

    // 发送祝福语到眼镜
    private fun sendToGlasses() {
        if (!RokidGlassesManager.isConnected) {
            Toast.makeText(this, "请先连接眼镜", Toast.LENGTH_SHORT).show()
            return
        }

        val template = currentTemplates[currentIndex]
        val displayText = buildString {
            appendLine("🧧 拜年祝福")
            appendLine("对象:${template.category.displayName}")
            appendLine()
            appendLine("────── 祝福语 ──────")
            appendLine()
            appendLine(template.content)
            appendLine()
            appendLine("◀ ${currentIndex + 1}/${currentTemplates.size} ▶")
        }

        RokidGlassesManager.sendGreeting(displayText, object : SendCallback {
            override fun onSuccess() {
                runOnUiThread {
                    Toast.makeText(this@MainActivity, "已发送到眼镜", Toast.LENGTH_SHORT).show()
                }
            }
            override fun onFailed(errorMsg: String) {
                runOnUiThread {
                    Toast.makeText(this@MainActivity, errorMsg, Toast.LENGTH_SHORT).show()
                }
            }
        })
    }
}

四、踩坑实录:那些让我怀疑人生的 Bug

坑一:中文乱码

第一次发送祝福语到眼镜,显示的是一堆乱码。

原因String.toByteArray() 默认不是 UTF-8 编码。

scss 复制代码
// ❌ 错误写法
stream = text.toByteArray()

// ✅ 正确写法
stream = text.toByteArray(Charsets.UTF_8)

坑二:快速点击翻页导致内容错乱

用户快速点击翻页按钮时,祝福语显示会出现错位。

解决方案:添加点击防抖

kotlin 复制代码
private var lastClickTime = 0L

fun onNextClick() {
    val now = System.currentTimeMillis()
    if (now - lastClickTime < 300) return  // 300ms 防抖
    lastClickTime = now
    // 正常翻页逻辑
}

坑三:祝福语过长被截断

眼镜屏幕有限,过长的祝福语会被截断。

解决方案:在数据层面控制祝福语长度在 80 字以内。

坑四:Android 12+ 蓝牙权限问题

Android 12 引入了新的蓝牙权限模型,需要在运行时动态申请。

kotlin 复制代码
private fun checkPermissions() {
    val permissions = mutableListOf<String>()
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        permissions.add(Manifest.permission.BLUETOOTH_SCAN)
        permissions.add(Manifest.permission.BLUETOOTH_CONNECT)
    }
    // ... 申请权限
}

五、最终效果

眼镜端显示效果:

复制代码
┌─────────────────────────────────┐
│                                 │
│    🧧 拜年祝福                   │
│    对象:长辈                     │
│                                 │
│    ────── 祝福语 ──────          │
│                                 │
│    爷爷奶奶新年好!               │
│    祝您福如东海、寿比南山!        │
│                                 │
│    ◀ 1/6 ▶                      │
│                                 │
└─────────────────────────────────┘

使用流程:

  1. 打开 APP,选择祝福对象(长辈/同辈/晚辈...)
  1. 浏览祝福语,或点击「随机」选择
  1. 点击「连接眼镜」,等待连接成功
  1. 点击「发送到眼镜」
  1. 拜年时自然抬眼,照着念就行
  1. (可选)点击「语音播报」,让眼镜念出来

六、总结与展望

这个项目从构思到完成花了一天时间,核心代码不到 300 行,但它解决了一个真实痛点。

技术要点回顾:

  • Rokid CXR-M SDK 的两步蓝牙连接流程
  • controlScene + sendStream 发送文本到提词器
  • UTF-8 编码处理中文字符
  • Material Design 3 组件构建界面

可能的后续改进:

  1. 自定义祝福语 - 允许用户添加自己的祝福语
  1. AI 生成 - 接入大模型,根据对象和场景生成个性化祝福
  1. 语音控制 - 用语音翻页,彻底解放双手
  1. 收藏功能 - 标记常用的祝福语

技术不需要多高大上,能解决生活中真实存在的问题,就是好技术。希望这个小工具能帮更多人拜年不再社死。


项目源码: NewYearGreetingHelper/

参考资料:


代码能解决 Bug,也能解决社恐。这就是技术的温度。

相关推荐
吾日三省Java2 小时前
Spring Cloud架构下的日志追踪:传统MDC vs 王炸SkyWalking
java·后端·架构
想打游戏的程序猿2 小时前
服务端用AI写前端:隐患、困境与思考
后端
前端拿破轮2 小时前
从0到1搭建个人网站(三):用 Cloudflare R2 + PicGo 搭建高速图床
前端·后端·面试
树獭叔叔2 小时前
深度拆解 DiT:扩散模型与 Transformer 的巅峰结合
后端·aigc·openai
ZhengEnCi2 小时前
08c. 检索算法与策略-混合检索
后端·python·算法
用户7344028193423 小时前
Java 8 Stream 的终极技巧——Collectors 操作
后端
树獭叔叔3 小时前
深度拆解 VAE:生成式 AI 的潜空间大门
后端·aigc·openai
任沫3 小时前
字符串
数据结构·后端
Java编程爱好者5 小时前
2026 大厂 Java 八股文面试题库|附答案(完整版)
后端