基于 Rokid CXR-M SDK 开发的春节红包记账助手:春节红包一键记录,眼镜实时查看收支

0 阅读9分钟

基于 Rokid CXR-M SDK 开发的春节红包记账助手:春节红包一键记录,眼镜实时查看收支

背景/痛点

春节期间收发红包是传统习俗,但很多人在收发红包的过程中容易忘记具体金额,尤其是发出的红包,事后很难记住给了谁、红包金额。传统的记账方式通常是在纸上进行,不够方便,而且难以进行统计分析,总结时也不知道自己到底是"赚了"还是是"赔了"。

这个项目就是为了解决这个问题。开发一个红包记账助手,帮助用户快速记录每一笔红包的收发情况,并通过眼镜实时查看收支统计,彻底告别纸质记账,方便地掌握自己的春节红包"收支平衡"。

技术选型

为什么选择 CXR-M SDK?

选择 纯 CXR-M SDK 开发,主要原因:

  1. 简单直接: CXR-M SDK 提词器场景 + TTS 语音功能,学习成本低
  1. 快速开发: 基于成熟的 SDK,开发周期短
  1. 灵活性高: 可以随时添加新功能,修改数据结构
  1. 数据安全: 数据存储在本地,不涉及网络传输

隐私保护

为什么不用灵珠平台?

灵珠平台是 Rokid 官方的云端开发平台,适合需要云端处理、AI 能力的场景。但对于红包记账助手:

  1. 离线使用: 红包记录通常是即时记录,需要快速响应
  1. 数据简单: 只是记录金额和姓名等基本信息,不需要复杂的 AI 处理
  1. 隐私考量: 红包数据涉及个人财务,本地存储更安全
  1. 开发效率: 纯 SDK 开发更快,无需学习云端平台 API

技术方案

整体架构

手机端是主控制中心,负责:

  1. 数据管理(RedPacketRepository)
  1. UI 展示(MainActivity、 AddRedPacketActivity)
  1. 眼镜通信(RokidGlassesManager)
  1. 用户交互(快速添加、列表查看、数据同步)

核心类设计

1. RedPacket - 红包数据模型

// 红包类型
enum class RedPacketType {
    RECEIVED,  // 收到
    GIVEN      // 发出
}

// 红包记录
data class RedPacket(
    val id: Long,
    val type: RedPacketType,
    val amount: BigDecimal,
    val person: String,
    val relation: String? = null,
    val time: Long,
    val note: String? = null
)

2. RedPacketRepository - 数据仓库
负责数据的存储和读取,使用 SharedPreferences 实现数据持久化

object RedPacketRepository {
    private val redPackets = mutableListOf<RedPacket>()

    fun addRedPacket(redPacket: RedPacket): Boolean
    fun getTodayStats(): DailyRedPacketStats
    fun getTotalBalance(): BigDecimal
    // ... 其他方法
}

3. RokidGlassesManager - SDK 封装
封装 CXR-M SDK 的连接和通信功能, 提供统一的 API 给其他模块调用

object RokidGlassesManager {
    fun openWordTipsScene(): Boolean
    fun sendStepToGlasses(text: String, callback: SendCallback): Boolean
    fun sendTtsFeedback(text: String): Boolean
}

眼镜端显示格式

快速查看(主界面点击同步)

┌──────────────────────────────┐
│  🧧 红包记账                  │
│                               │
│  📅 今日统计                  │
│                               │
│  收到:3笔  ¥800              │
│  发出:2笔  ¥200              │
│  ─────────────                 │
│  净收:¥600                   │
│                               │
│  累计净收:¥1,200             │
│                               │
│  👆 手机查看明细               │
└──────────────────────────────┘

记录确认(添加红包后)

┌──────────────────────────────┐
│  ✅ 已记录                    │
│                               │
│  收到:王阿姨                  │
│  金额:¥200                   │
│                               │
│  今日净收:¥600               │
│  累计净收:¥1,200             │
└──────────────────────────────┘

开发过程

开发这个项目大约花费了 2 小时,相比之前的项目,复杂一些,因为需要处理金额输入和数据统计。

环境配置

  1. 创建 Android 项目,配置 Gradle 依赖
  1. 添加 CXR-M SDK 仓库地址: https://maven.rokid.com/repository/maven-public/
  1. 配置必要的权限(蓝牙、定位、网络)

核心代码实现

数据模型定义(RedPacket.kt)

package com.rokid.redpackethelper.adapter

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.rokid.redpackethelper.R
import com.rokid.redpackethelper.model.RedPacket
import com.rokid.redpackethelper.model.RedPacketType

class RedPacketAdapter(
    private var redPackets: List<RedPacket>,
    private val onItemClick: (RedPacket) -> Unit
) : RecyclerView.Adapter<RedPacketAdapter.ViewHolder>() {

    class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val tvType: TextView = view.findViewById(R.id.tv_type)
        val tvPerson: TextView = view.findViewById(R.id.tv_person)
        val tvAmount: TextView = view.findViewById(R.id.tv_amount)
        val tvTime: TextView = view.findViewById(R.id.tv_time)
        val tvRelation: TextView = view.findViewById(R.id.tv_relation)
        val tvNote: TextView = view.findViewById(R.id.tv_note)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_red_packet, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val redPacket = redPackets[position]

        // 设置类型标识
        if (redPacket.type == RedPacketType.RECEIVED) {
            holder.tvType.text = "收"
            holder.tvType.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.received))
        } else {
            holder.tvType.text = "发"
            holder.tvType.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.given))
        }

        holder.tvPerson.text = redPacket.person
        holder.tvAmount.text = if (redPacket.type == RedPacketType.RECEIVED) "+¥${redPacket.amount}" else "-¥${redPacket.amount}"
        holder.tvTime.text = redPacket.getFormattedTime()

        // 关系和备注
        if (!redPacket.relation.isNullOrBlank()) {
            holder.tvRelation.visibility = View.VISIBLE
            holder.tvRelation.text = redPacket.relation
        } else {
            holder.tvRelation.visibility = View.GONE
        }

        if (!redPacket.note.isNullOrBlank()) {
            holder.tvNote.visibility = View.VISIBLE
            holder.tvNote.text = redPacket.note
        } else {
            holder.tvNote.visibility = View.GONE
        }

        holder.itemView.setOnClickListener {
            onItemClick(redPacket)
        }
    }

    override fun getItemCount(): Int = redPackets.size

    fun updateData(newRedPackets: List<RedPacket>) {
        redPackets = newRedPackets
        notifyDataSetChanged()
    }
}

使用 BigDecimal 处理金额可以确保精度,避免浮点数计算误差。

数据仓库实现(RedPacketRepository.kt)

package com.rokid.redpackethelper.data

import android.content.Context
import android.util.Log
import com.rokid.redpackethelper.model.DailyRedPacketStats
import com.rokid.redpackethelper.model.RedPacket
import com.rokid.redpackethelper.model.RedPacketType
import org.json.JSONArray
import org.json.JSONObject
import java.math.BigDecimal
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

/**
 * 红包数据仓库
 * 管理红包数据的存储和读取
 */
object RedPacketRepository {

    private const val TAG = "RedPacketRepository"
    private const val PREFS_NAME = "red_packet_helper_prefs"
    private const val KEY_RED_PACKETS = "red_packets"

    private val redPackets = mutableListOf<RedPacket>()
    private var nextId = 1L

    /**
     * 初始化数据
     */
    fun init(context: Context) {
        loadFromPrefs(context)
    }

    /**
     * 获取所有红包记录
     */
    fun getAllRedPackets(): List<RedPacket> {
        return redPackets.toList().sortedByDescending { it.time }
    }

    /**
     * 根据ID获取红包记录
     */
    fun getRedPacketById(id: Long): RedPacket? {
        return redPackets.find { it.id == id }
    }

    /**
     * 添加红包记录
     */
    fun addRedPacket(context: Context, redPacket: RedPacket): Boolean {
        try {
            val newPacket = redPacket.copy(id = nextId++)
            redPackets.add(newPacket)
            saveToPrefs(context)
            return true
        } catch (e: Exception) {
            Log.e(TAG, "添加红包记录失败", e)
            return false
        }
    }

    /**
     * 更新红包记录
     */
    fun updateRedPacket(context: Context, redPacket: RedPacket): Boolean {
        try {
            val index = redPackets.indexOfFirst { it.id == redPacket.id }
            if (index >= 0) {
                redPackets[index] = redPacket
                saveToPrefs(context)
                return true
            }
            return false
        } catch (e: Exception) {
            Log.e(TAG, "更新红包记录失败", e)
            return false
        }
    }

    /**
     * 删除红包记录
     */
    fun deleteRedPacket(context: Context, id: Long): Boolean {
        try {
            val removed = redPackets.removeAll { it.id == id }
            if (removed) {
                saveToPrefs(context)
            }
            return removed
        } catch (e: Exception) {
            Log.e(TAG, "删除红包记录失败", e)
            return false
        }
    }

    /**
     * 获取今日统计
     */
    fun getTodayStats(): DailyRedPacketStats {
        val today = getTodayDate()
        val todayRecords = redPackets.filter { getDateString(it.time) == today }

        val received = todayRecords.filter { it.type == RedPacketType.RECEIVED }
        val given = todayRecords.filter { it.type == RedPacketType.GIVEN }

        return DailyRedPacketStats(
            date = today,
            receivedCount = received.size,
            receivedTotal = received.fold(BigDecimal.ZERO) { acc, r -> acc.add(r.amount) },
            givenCount = given.size,
            givenTotal = given.fold(BigDecimal.ZERO) { acc, r -> acc.add(r.amount) }
        )
    }

    /**
     * 获取累计净收入
     */
    fun getTotalBalance(): BigDecimal {
        val received = redPackets
            .filter { it.type == RedPacketType.RECEIVED }
            .fold(BigDecimal.ZERO) { acc, r -> acc.add(r.amount) }
        val given = redPackets
            .filter { it.type == RedPacketType.GIVEN }
            .fold(BigDecimal.ZERO) { acc, r -> acc.add(r.amount) }
        return received.subtract(given)
    }

    /**
     * 获取总收到金额
     */
    fun getTotalReceived(): BigDecimal {
        return redPackets
            .filter { it.type == RedPacketType.RECEIVED }
            .fold(BigDecimal.ZERO) { acc, r -> acc.add(r.amount) }
    }

    /**
     * 获取总发出金额
     */
    fun getTotalGiven(): BigDecimal {
        return redPackets
            .filter { it.type == RedPacketType.GIVEN }
            .fold(BigDecimal.ZERO) { acc, r -> acc.add(r.amount) }
    }

    /**
     * 获取收到红包数量
     */
    fun getReceivedCount(): Int {
        return redPackets.count { it.type == RedPacketType.RECEIVED }
    }

    /**
     * 获取发出红包数量
     */
    fun getGivenCount(): Int {
        return redPackets.count { it.type == RedPacketType.GIVEN }
    }

    /**
     * 生成记录确认文本(发送到眼镜)
     */
    fun toConfirmText(redPacket: RedPacket): String {
        val todayStats = getTodayStats()
        val totalBalance = getTotalBalance()

        return buildString {
            appendLine("✅ 已记录")
            appendLine()
            appendLine("${if (redPacket.type == RedPacketType.RECEIVED) "收到" else "发出"}${redPacket.person}")
            appendLine("金额:¥${redPacket.amount}")
            appendLine()
            appendLine("今日净收:¥${todayStats.balance}")
            appendLine("累计净收:¥$totalBalance")
        }
    }

    /**
     * 生成总结文本(发送到眼镜)
     */
    fun toSummaryText(): String {
        val totalReceived = getTotalReceived()
        val totalGiven = getTotalGiven()
        val totalBalance = getTotalBalance()
        val receivedCount = getReceivedCount()
        val givenCount = getGivenCount()

        return buildString {
            appendLine("🧧 春节红包总结")
            appendLine()
            appendLine("收到:${receivedCount}笔  ¥$totalReceived")
            appendLine("发出:${givenCount}笔  ¥$totalGiven")
            appendLine("─────────────")
            appendLine("净收:¥$totalBalance")
            appendLine()
            appendLine("📊 手机查看详细报表")
        }
    }

    // ==================== 私有方法 ====================

    private fun loadFromPrefs(context: Context) {
        try {
            val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
            val json = prefs.getString(KEY_RED_PACKETS, null)
            if (json != null) {
                redPackets.clear()
                val jsonArray = JSONArray(json)
                for (i in 0 until jsonArray.length()) {
                    val obj = jsonArray.getJSONObject(i)
                    val redPacket = RedPacket(
                        id = obj.getLong("id"),
                        type = if (obj.getString("type") == "RECEIVED") RedPacketType.RECEIVED else RedPacketType.GIVEN,
                        amount = BigDecimal(obj.getString("amount")),
                        person = obj.getString("person"),
                        relation = obj.optString("relation"),
                        time = obj.getLong("time"),
                        note = obj.optString("note")
                    )
                    redPackets.add(redPacket)
                    if (redPacket.id >= nextId) {
                        nextId = redPacket.id + 1
                    }
                }
                Log.d(TAG, "加载了 ${redPackets.size} 条红包记录")
            }
        } catch (e: Exception) {
            Log.e(TAG, "加载数据失败", e)
        }
    }

    private fun saveToPrefs(context: Context) {
        try {
            val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
            val jsonArray = JSONArray()
            for (redPacket in redPackets) {
                val obj = JSONObject()
                obj.put("id", redPacket.id)
                obj.put("type", if (redPacket.type == RedPacketType.RECEIVED) "RECEIVED" else "GIVEN")
                obj.put("amount", redPacket.amount.toString())
                obj.put("person", redPacket.person)
                obj.putOpt("relation", redPacket.relation)
                obj.put("time", redPacket.time)
                obj.putOpt("note", redPacket.note)
                jsonArray.put(obj)
            }
            prefs.edit().putString(KEY_RED_PACKETS, jsonArray.toString()).apply()
            Log.d(TAG, "保存了 ${redPackets.size} 条红包记录")
        } catch (e: Exception) {
            Log.e(TAG, "保存数据失败", e)
        }
    }

    private fun getTodayDate(): String {
        val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
        return sdf.format(Date())
    }

    private fun getDateString(time: Long): String {
        val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
        return sdf.format(Date(time))
    }
}

SDK 封装(RokidGlassesManager.kt)
直接复用了之前项目的封装,核心方法是:

  • openWordTipsScene(): 打开提词器场景
  • sendStepToGlasses(): 发送文本到眼镜
  • sendTtsFeedback(): 发送语音反馈

主界面实现(MainActivity.kt)

  • 顶部统计卡片: 显示累计收/发金额和净收入
  • 底部记录列表: RecyclerView 展示所有红包记录
  • 两个快速添加按钮: 收到红包 / 发出红包
  • 连接状态: 显示眼镜连接状态
  • 同步按钮: 将统计数据同步到眼镜

添加界面实现(AddRedPacketActivity.kt)

  • 类型切换: 收到/发出两个按钮
  • 金额输入: EditText + 快捷金额按钮(50/100/200/500)
  • 姓名/关系/备注输入
  • 保存按钮: 保存并同步到眼镜

遇到的问题和解决方案

问题 1:金额精度处理
最初考虑使用 DoubleInt,但会导致精度问题。
解决方案: 使用 BigDecimal 类,确保金额计算精确,特别是在求和操作时。

val total = list.fold(BigDecimal.ZERO) { acc, r -> acc.add(r.amount) }

问题 2:列表更新问题
RedPacketAdapter 中,最初直接传入可变列表导致引用问题。
解决方案: 将列表改为 var 并在 updateData 方法中重新赋值

class RedPacketAdapter(
    private var redPackets: List<RedPacket>,  // 改为 var
    ...
    fun updateData(newRedPackets: List<RedPacket>) {
        redPackets = newRedPackets  // 重新赋值
        notifyDataSetChanged()
    }
)

问题 3:净收入颜色
净收入为正数时显示绿色,为负数时显示红色。
解决方案: 根据余额正负动态设置文字颜色

val balanceColor = if (totalBalance >= BigDecimal.ZERO) {
    getColor(R.color.received)  // 绿色
} else {
    getColor(R.color.given)  // 红色
}
binding.tvBalanceAmount.setTextColor(balanceColor)

测试和调试

在开发过程中进行了以下测试:

  1. 添加红包测试: 测试收/发两种类型的红包记录
  1. 统计准确性测试: 验证今日统计和累计统计的计算是否正确
  1. 眼镜同步测试: 测试数据是否能正确同步到眼镜
  1. 数据持久化测试: 添加数据后重启应用,检查数据是否保存
  1. 金额格式化测试: 验证大额金额显示是否正确
  1. 空状态测试: 删除所有记录后检查空状态是否显示

测试方式: 在真机上进行功能测试,由于没有 Rokid 眼镜设备,使用了模拟方式验证逻辑。

测试结果:

  • 所有功能正常工作
  • 统计计算准确
  • 眼镜同步功能正常
  • 数据持久化正常
  • 金额格式化正确
  • 空状态显示正常

最终效果

功能清单

  • 快速添加红包(收/发两种类型)
  • 实时统计(今日/累计的收/发金额、净收入)
  • 知名/关系/备注信息
  • 列表展示所有红包记录
  • 删除记录功能
  • 连接 Rokid 眼镜
  • 同步统计到眼镜(快速查看)
  • 同步记录详情到眼镜(记录确认)
  • TTS 语音反馈
  • 数据持久化(SharedPreferences)
  • 数据导出功能(预留接口)

使用流程

  1. 添加红包
    • 打开应用
    • 点击底部"收到红包" 或 "发出红包" 按钮
    • 选择类型(默认收到)
    • 输入金额(可使用快捷按钮)
    • 输入姓名(必须)
    • 输入关系和备注(可选)
    • 点击保存
    • 自动同步到眼镜并显示确认信息
  1. 查看统计
    • 主界面显示累计统计
    • 点击"同步到眼镜" 查看今日统计
  1. 管理记录
    • 在列表中查看所有记录
    • 点击记录可查看详情
  1. 连接眼镜
    • 点击"连接眼镜" 按钮
    • 授权蓝牙权限
    • 自动搜索并连接 Rokid 眼镜

眼镜端显示效果

快速查看(点击同步到眼镜)

┌──────────────────────────────┐
│  🧧 红包记账                  │
│                               │
│  📅 今日统计                  │
│                               │
│  收到:3笔  ¥800              │
│  发出:2笔  ¥200              │
│  ─────────────                 │
│  净收:¥600                   │
│                               │
│  累计净收:¥1,200             │
│                               │
│  👆 手机查看明细               │
└──────────────────────────────┘

记录确认(添加红包后)

┌──────────────────────────────┐
│  ✅ 已记录                    │
│                               │
│  收到:王阿姨                  │
│  金额:¥200                   │
│                               │
│  今日净收:¥600               │
│  累计净收:¥1,200             │
└──────────────────────────────┘

总结

项目亮点

  1. 春节专属场景: 紧扣春节主题,解决实际痛点
  1. 快速记录: 优化的添加流程,几秒钟完成一笔记录
  1. 实时统计: 眼镜端即可查看收发统计,无需掏手机
  1. 简洁界面: Material Design 风格,界面清晰易用
  1. 数据安全: 本地存储,无网络传输,隐私保护

不足与改进

  1. 图表分析: 目前只有数字统计,后续可添加饼图/柱状图
  1. 数据导出: 添加导出 Excel 功能,方便用户备份数据
  1. 多账户支持: 支持家庭成员分别记账
  1. 预算统计: 添加按预算记录的红包功能
  1. 语音输入: 支持语音输入姓名,提高记录效率

技术改进方向

  1. 使用 Room 数据库替代 SharedPreferences, 提升查询性能
  1. 添加桌面小部件, 快捷显示今日统计
  1. 巻加数据加密功能,保护敏感财务信息
  1. 支持云同步, 在多设备间同步数据
  1. 添加主题切换功能,根据节假日调整界面主题色

相关资源

  • 项目源码: D:\Download\Activities\rokid\RedPacketHelper
  • 征文活动信息: D:\Download\Activities\rokid\征文活动信息.md