基于 Rokid CXR-M SDK 开发的春节红包记账助手:春节红包一键记录,眼镜实时查看收支
背景/痛点
春节期间收发红包是传统习俗,但很多人在收发红包的过程中容易忘记具体金额,尤其是发出的红包,事后很难记住给了谁、红包金额。传统的记账方式通常是在纸上进行,不够方便,而且难以进行统计分析,总结时也不知道自己到底是"赚了"还是是"赔了"。
这个项目就是为了解决这个问题。开发一个红包记账助手,帮助用户快速记录每一笔红包的收发情况,并通过眼镜实时查看收支统计,彻底告别纸质记账,方便地掌握自己的春节红包"收支平衡"。
技术选型
为什么选择 CXR-M SDK?
选择 纯 CXR-M SDK 开发,主要原因:
- 简单直接: CXR-M SDK 提词器场景 + TTS 语音功能,学习成本低
- 快速开发: 基于成熟的 SDK,开发周期短
- 灵活性高: 可以随时添加新功能,修改数据结构
- 数据安全: 数据存储在本地,不涉及网络传输
隐私保护
为什么不用灵珠平台?
灵珠平台是 Rokid 官方的云端开发平台,适合需要云端处理、AI 能力的场景。但对于红包记账助手:
- 离线使用: 红包记录通常是即时记录,需要快速响应
- 数据简单: 只是记录金额和姓名等基本信息,不需要复杂的 AI 处理
- 隐私考量: 红包数据涉及个人财务,本地存储更安全
- 开发效率: 纯 SDK 开发更快,无需学习云端平台 API
技术方案
整体架构
手机端是主控制中心,负责:
- 数据管理(RedPacketRepository)
- UI 展示(MainActivity、 AddRedPacketActivity)
- 眼镜通信(RokidGlassesManager)
- 用户交互(快速添加、列表查看、数据同步)
核心类设计
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 小时,相比之前的项目,复杂一些,因为需要处理金额输入和数据统计。
环境配置
- 创建 Android 项目,配置 Gradle 依赖
- 添加 CXR-M SDK 仓库地址:
https://maven.rokid.com/repository/maven-public/
- 配置必要的权限(蓝牙、定位、网络)
核心代码实现
数据模型定义(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:金额精度处理
最初考虑使用 Double 或 Int,但会导致精度问题。
解决方案: 使用 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)
测试和调试
在开发过程中进行了以下测试:
- 添加红包测试: 测试收/发两种类型的红包记录
- 统计准确性测试: 验证今日统计和累计统计的计算是否正确
- 眼镜同步测试: 测试数据是否能正确同步到眼镜
- 数据持久化测试: 添加数据后重启应用,检查数据是否保存
- 金额格式化测试: 验证大额金额显示是否正确
- 空状态测试: 删除所有记录后检查空状态是否显示
测试方式: 在真机上进行功能测试,由于没有 Rokid 眼镜设备,使用了模拟方式验证逻辑。
测试结果:
- 所有功能正常工作
- 统计计算准确
- 眼镜同步功能正常
- 数据持久化正常
- 金额格式化正确
- 空状态显示正常
最终效果
功能清单
- 快速添加红包(收/发两种类型)
- 实时统计(今日/累计的收/发金额、净收入)
- 知名/关系/备注信息
- 列表展示所有红包记录
- 删除记录功能
- 连接 Rokid 眼镜
- 同步统计到眼镜(快速查看)
- 同步记录详情到眼镜(记录确认)
- TTS 语音反馈
- 数据持久化(SharedPreferences)
- 数据导出功能(预留接口)
使用流程
- 添加红包
-
- 打开应用
-
- 点击底部"收到红包" 或 "发出红包" 按钮
-
- 选择类型(默认收到)
-
- 输入金额(可使用快捷按钮)
-
- 输入姓名(必须)
-
- 输入关系和备注(可选)
-
- 点击保存
-
- 自动同步到眼镜并显示确认信息
- 查看统计
-
- 主界面显示累计统计
-
- 点击"同步到眼镜" 查看今日统计
- 管理记录
-
- 在列表中查看所有记录
-
- 点击记录可查看详情
- 连接眼镜
-
- 点击"连接眼镜" 按钮
-
- 授权蓝牙权限
-
- 自动搜索并连接 Rokid 眼镜
眼镜端显示效果
快速查看(点击同步到眼镜)
┌──────────────────────────────┐
│ 🧧 红包记账 │
│ │
│ 📅 今日统计 │
│ │
│ 收到:3笔 ¥800 │
│ 发出:2笔 ¥200 │
│ ───────────── │
│ 净收:¥600 │
│ │
│ 累计净收:¥1,200 │
│ │
│ 👆 手机查看明细 │
└──────────────────────────────┘
记录确认(添加红包后)
┌──────────────────────────────┐
│ ✅ 已记录 │
│ │
│ 收到:王阿姨 │
│ 金额:¥200 │
│ │
│ 今日净收:¥600 │
│ 累计净收:¥1,200 │
└──────────────────────────────┘
总结
项目亮点
- 春节专属场景: 紧扣春节主题,解决实际痛点
- 快速记录: 优化的添加流程,几秒钟完成一笔记录
- 实时统计: 眼镜端即可查看收发统计,无需掏手机
- 简洁界面: Material Design 风格,界面清晰易用
- 数据安全: 本地存储,无网络传输,隐私保护
不足与改进
- 图表分析: 目前只有数字统计,后续可添加饼图/柱状图
- 数据导出: 添加导出 Excel 功能,方便用户备份数据
- 多账户支持: 支持家庭成员分别记账
- 预算统计: 添加按预算记录的红包功能
- 语音输入: 支持语音输入姓名,提高记录效率
技术改进方向
- 使用 Room 数据库替代 SharedPreferences, 提升查询性能
- 添加桌面小部件, 快捷显示今日统计
- 巻加数据加密功能,保护敏感财务信息
- 支持云同步, 在多设备间同步数据
- 添加主题切换功能,根据节假日调整界面主题色
相关资源
- 项目源码:
D:\Download\Activities\rokid\RedPacketHelper
- 官方文档: Rokid 开发者中心
- CXR-M SDK 文档: maven.rokid.com/repository/…
- 征文活动信息:
D:\Download\Activities\rokid\征文活动信息.md