一、为什么需要这样一款应用
春节是个热闹的节日,走亲访友、聚会吃喝,往往一整天下来水都顾不上喝几口。等到感觉口渴时,身体其实已经处于轻度脱水状态。今年马年春节,我决定用Rokid AR眼镜做点什么——让虚拟助手在视野角落里悄悄提醒我按时喝水,顺便记录每天喝了多少杯,达标了给点鼓励。这种“存在感不强但关键时刻出现”的设计,很适合AR眼镜的微交互场景。
这篇文章会完整呈现这个“AR喝水提醒助手”的开发过程,包括核心逻辑、UI实现、数据持久化,以及如何为Rokid眼镜扩展震动和语音播报。所有代码都在Unity 2022 中编写,基于UGUI,稍作调整就能跑在Rokid设备上。
二、功能拆解与设计思路
动手之前,我先理清了几个核心需求:
- 每小时自动提醒:不需要精确到秒,累加计时,满一小时触发一次。
- 当日杯数记录:目标8杯,每提醒一次自动加1,用户也可以手动点击“我喝了”加1。
- 跨日自动重置:第二天重新从0开始。
- 多感官反馈:提醒时图标闪烁,手机震动,可选音效,预留语音播报接口。
- 数据持久化:应用退出或暂停时保存当前杯数和计时进度,下次打开接着计。
- UI简洁不挡视线:放在视野右下角,半透明,显示水杯图标、进度文字和一个按钮。
基于这些需求,我决定把核心逻辑和UI彻底分离:一个WaterReminderManager单例负责所有数据、计时和事件;一个WaterReminderUI负责显示和交互,通过订阅事件来更新。这样哪怕以后换一套UI,逻辑部分完全不用动。
三、核心逻辑层:WaterReminderManager
3.1 单例与常量的设定
public class WaterReminderManager : MonoBehaviour
{
public const int TargetCups = 8;
public const float HourInSeconds = 3600f;
public static WaterReminderManager Instance { get; private set; }
public int CupsDrunk => _cupsDrunk;
private int _cupsDrunk;
private float _timerAccumulator;
private string _lastSavedDate;
...
}
单例的好处是任何地方都能通过Instance访问,UI、按钮、调试脚本都能轻松调用AddOneCup()。目标杯数8杯是常量,提醒间隔3600秒也是常量,但为了调试方便,我在Inspector里暴露了一个debugIntervalSeconds,这样在编辑器里可以设成10秒快速测试。
3.2 计时与事件触发
Update里每帧累加时间,达到间隔就触发提醒:
private void Update()
{
float interval = debugIntervalSeconds > 0 ? debugIntervalSeconds : HourInSeconds;
_timerAccumulator += Time.deltaTime;
if (_timerAccumulator >= interval)
{
_timerAccumulator -= interval;
TriggerHourlyReminder();
}
}
触发提醒时做了几件事:自动加一杯、震动、播放心跳音、语音播报占位、抛事件让UI知道。这里把音效和语音都设计成可选,reminderClip和goalReachedClip可以在Inspector里拖拽赋值。
private void TriggerHourlyReminder()
{
AddOneCup();
TryHaptic();
PlayClip(reminderClip);
SpeakReminder("该喝水啦,记得补充水分哦~");
OnHourlyReminder?.Invoke();
}
注意AddOneCup()内部会检查是否已达目标,如果刚好加满8杯,会触发达标事件并播放达标音效。
3.3 数据持久化:跨日自动重置
我选择用PlayerPrefs存三个值:日期、杯数、累计计时秒数。每次启动时加载,如果保存的日期不是今天,就重置为0。
private void LoadDailyData()
{
string today = DateTime.Now.ToString("yyyy-MM-dd");
_lastSavedDate = PlayerPrefs.GetString(KeyDate, "");
if (_lastSavedDate != today)
{
_cupsDrunk = 0;
_timerAccumulator = 0f;
_lastSavedDate = today;
}
else
{
_cupsDrunk = Mathf.Clamp(PlayerPrefs.GetInt(KeyCups, 0), 0, TargetCups);
_timerAccumulator = PlayerPrefs.GetFloat(KeyTimer, 0f);
}
SaveDailyData(); // 确保当天数据立即写入
}
这样即使应用被系统回收,重启后也能接着上次的进度继续计时。OnApplicationPause和OnApplicationQuit里都调了SaveDailyData(),确保数据不丢。
3.4 扩展点:震动、音效、语音
震动直接用Unity的Handheld.Vibrate(),在移动端有效。AR眼镜通常也支持震动,到时可以换成眼镜SDK的震动接口。
private void TryHaptic()
{
#if UNITY_ANDROID || UNITY_IOS
Handheld.Vibrate();
#endif
}
音效通过AudioSource.PlayOneShot播放,简单可靠。语音播报我留了一个SpeakReminder方法,目前只打log,但注释里写了如何接入Android和iOS的TTS。实际接Rokid眼镜时,可以调用眼镜的系统语音合成接口。
四、悬浮UI层:WaterReminderUI
UI的设计目标是“不打扰,但一眼能看到”。我选择了右下角锚点,半透明深色背景,白色文字,蓝色水杯图标,按钮放在底部中间。
4.1 两种使用方式:绑定现有UI或动态创建
为了方便集成到不同项目,UI脚本支持两种模式:
- 拖拽模式:在Inspector里把做好的
panelRoot、statusText、cupIcon、addCupButton拖进去,脚本会自动使用它们。 - 自动创建模式:如果没拖拽任何引用,脚本会在
Awake时调用BuildFloatingPanel(),在Canvas下动态生成一套默认UI。
BuildFloatingPanel()里全是RectTransform的计算,关键点是锚点设置和偏移量计算,确保“我喝了”按钮不会和文字、图标重叠。
_panel.anchorMin = new Vector2(1f, 0f);
_panel.anchorMax = new Vector2(1f, 0f);
_panel.pivot = new Vector2(1f, 0f);
_panel.anchoredPosition = new Vector2(-24f, 24f);
这种写法让面板永远贴在屏幕右下角,距离边缘24像素。
4.2 订阅事件与更新UI
在OnEnable里订阅Manager的事件,OnDisable里取消订阅,防止内存泄漏。
private void OnEnable()
{
if (WaterReminderManager.Instance != null)
{
WaterReminderManager.Instance.OnHourlyReminder += OnHourlyReminder;
WaterReminderManager.Instance.OnGoalReached += OnGoalReached;
}
RefreshText();
}
OnHourlyReminder里做两件事:开启图标闪烁(_blinkTimer = 2f),刷新文字。OnGoalReached里只刷新文字,因为达标时不需要闪烁。
4.3 图标闪烁效果
Update里判断_blinkTimer是否大于0,如果是,就让图标颜色在白色和亮黄色之间来回变化,模拟闪烁。闪烁持续2秒后自动停止。
if (_blinkTimer > 0f)
{
_blinkTimer -= Time.deltaTime;
_icon.color = Color.Lerp(Color.white, new Color(1f, 0.9f, 0.5f), Mathf.PingPong(Time.time * 8f, 1f));
}
这里用Mathf.PingPong产生0~1来回波动的值,频率8Hz,看起来挺舒服。
4.4 按钮响应
按钮点击时直接调用WaterReminderManager.Instance.AddOneCup(),然后刷新文字。非常简单。
五、在Rokid AR项目中集成
既然目标是Rokid眼镜,就需要把这两个脚本放进基于Rokid UXR SDK的Unity项目里。大致步骤:
- 按照官方文档导入Rokid UXR SDK,创建AR场景,确保有
AR Session Origin和AR Camera。 - 在场景中创建一个空物体,挂上
WaterReminderManager,并为其指定音效(可选)。 - 创建一个Canvas(Screen Space - Overlay),挂上
WaterReminderUI脚本。如果不想手动布局,就把所有引用留空,让它自动生成。 - 为了在Rokid眼镜上获得震动和语音,需要在
TryHaptic和SpeakReminder里调用Rokid SDK的对应API。例如震动可以用Rokid.UXR.Haptic类,语音可以用Rokid.Speech.SpeechSynthesizer。 - 最后Build到Android,安装到眼镜即可。
注意:在Rokid眼镜上,UI是叠加在现实世界上的,所以用Screen Space Overlay完全没问题。如果想让UI固定在某个真实位置(比如贴在客厅墙上),那就要用World Space Canvas,但这里只是“视野角落”的提示,Overlay更适合。
六、完整代码
下面把两个脚本完整贴出来,方便直接复制使用。代码里已经包含了必要的注释。
WaterReminderManager.cs
using System;
using UnityEngine;
public class WaterReminderManager : MonoBehaviour
{
public const int TargetCups = 8;
public const float HourInSeconds = 3600f;
public static WaterReminderManager Instance { get; private set; }
public int CupsDrunk => _cupsDrunk;
public event Action OnHourlyReminder;
public event Action OnGoalReached;
[Header("可选:提醒音")]
public AudioClip reminderClip;
[Header("可选:达标音")]
public AudioClip goalReachedClip;
[Header("调试:编辑器内用短间隔测试(秒),0=不启用")]
public float debugIntervalSeconds;
private int _cupsDrunk;
private float _timerAccumulator;
private string _lastSavedDate;
private AudioSource _audioSource;
private const string KeyDate = "WaterReminder_Date";
private const string KeyCups = "WaterReminder_Cups";
private const string KeyTimer = "WaterReminder_Timer";
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
_audioSource = gameObject.AddComponent<AudioSource>();
LoadDailyData();
}
private void Update()
{
float interval = debugIntervalSeconds > 0 ? debugIntervalSeconds : HourInSeconds;
_timerAccumulator += Time.deltaTime;
if (_timerAccumulator >= interval)
{
_timerAccumulator -= interval;
TriggerHourlyReminder();
}
}
private void TriggerHourlyReminder()
{
AddOneCup();
TryHaptic();
PlayClip(reminderClip);
SpeakReminder("该喝水啦,记得补充水分哦~");
OnHourlyReminder?.Invoke();
}
public void AddOneCup()
{
if (_cupsDrunk >= TargetCups) return;
_cupsDrunk++;
SaveDailyData();
if (_cupsDrunk >= TargetCups)
{
PlayClip(goalReachedClip);
SpeakReminder("今日达标,真棒!");
OnGoalReached?.Invoke();
}
}
private void SpeakReminder(string message)
{
Debug.Log($"[语音] {message}");
// 接入Rokid TTS示例:
// var tts = Rokid.Speech.SpeechSynthesizer.GetInstance();
// tts.Speak(message);
}
private void TryHaptic()
{
#if UNITY_ANDROID || UNITY_IOS
Handheld.Vibrate();
#endif
// 接入Rokid震动:
// Rokid.UXR.Haptic.Vibrate(200);
}
private void PlayClip(AudioClip clip)
{
if (clip != null && _audioSource != null)
_audioSource.PlayOneShot(clip);
}
private void LoadDailyData()
{
string today = DateTime.Now.ToString("yyyy-MM-dd");
_lastSavedDate = PlayerPrefs.GetString(KeyDate, "");
if (_lastSavedDate != today)
{
_cupsDrunk = 0;
_timerAccumulator = 0f;
_lastSavedDate = today;
}
else
{
_cupsDrunk = Mathf.Clamp(PlayerPrefs.GetInt(KeyCups, 0), 0, TargetCups);
_timerAccumulator = PlayerPrefs.GetFloat(KeyTimer, 0f);
}
SaveDailyData();
}
private void SaveDailyData()
{
_lastSavedDate = DateTime.Now.ToString("yyyy-MM-dd");
PlayerPrefs.SetString(KeyDate, _lastSavedDate);
PlayerPrefs.SetInt(KeyCups, _cupsDrunk);
PlayerPrefs.SetFloat(KeyTimer, _timerAccumulator);
PlayerPrefs.Save();
}
private void OnApplicationPause(bool pause) { if (pause) SaveDailyData(); }
private void OnApplicationQuit() { SaveDailyData(); }
}
WaterReminderUI.cs
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(Canvas))]
public class WaterReminderUI : MonoBehaviour
{
[Header("可选:绑定已有UI(不绑则运行时自动创建)")]
public RectTransform panelRoot;
public Text statusText;
public Image cupIcon;
public Button addCupButton;
private Canvas _canvas;
private RectTransform _panel;
private Text _text;
private Image _icon;
private float _blinkTimer;
private void Awake()
{
_canvas = GetComponent<Canvas>();
if (_canvas.renderMode != RenderMode.ScreenSpaceOverlay)
{
_canvas.renderMode = RenderMode.ScreenSpaceOverlay;
if (GetComponent<CanvasScaler>() == null) gameObject.AddComponent<CanvasScaler>();
if (GetComponent<GraphicRaycaster>() == null) gameObject.AddComponent<GraphicRaycaster>();
}
if (panelRoot != null)
{
_panel = panelRoot;
_text = statusText;
_icon = cupIcon;
}
else
BuildFloatingPanel();
if (addCupButton != null) addCupButton.onClick.AddListener(OnAddCupClicked);
}
private void OnEnable()
{
if (WaterReminderManager.Instance != null)
{
WaterReminderManager.Instance.OnHourlyReminder += OnHourlyReminder;
WaterReminderManager.Instance.OnGoalReached += OnGoalReached;
}
RefreshText();
}
private void OnDisable()
{
if (WaterReminderManager.Instance != null)
{
WaterReminderManager.Instance.OnHourlyReminder -= OnHourlyReminder;
WaterReminderManager.Instance.OnGoalReached -= OnGoalReached;
}
}
private void Update()
{
if (_blinkTimer > 0f)
{
_blinkTimer -= Time.deltaTime;
if (_icon != null)
_icon.color = Color.Lerp(Color.white, new Color(1f, 0.9f, 0.5f), Mathf.PingPong(Time.time * 8f, 1f));
}
else if (_icon != null)
_icon.color = Color.white;
}
private void OnAddCupClicked()
{
WaterReminderManager.Instance?.AddOneCup();
RefreshText();
}
private void OnHourlyReminder()
{
_blinkTimer = 2f;
RefreshText();
}
private void OnGoalReached() => RefreshText();
private void RefreshText()
{
if (_text == null) return;
var mgr = WaterReminderManager.Instance;
if (mgr == null) { _text.text = "今日已喝 0/8 杯"; return; }
bool reached = mgr.CupsDrunk >= WaterReminderManager.TargetCups;
_text.text = reached ? "🎉 今日达标,真棒!" : $"今日已喝 {mgr.CupsDrunk}/8 杯";
}
private void BuildFloatingPanel()
{
var go = new GameObject("WaterReminderPanel");
go.transform.SetParent(transform, false);
_panel = go.AddComponent<RectTransform>();
_panel.anchorMin = new Vector2(1f, 0f);
_panel.anchorMax = new Vector2(1f, 0f);
_panel.pivot = new Vector2(1f, 0f);
_panel.anchoredPosition = new Vector2(-24f, 24f);
_panel.sizeDelta = new Vector2(220f, 120f);
var bg = go.AddComponent<Image>();
bg.color = new Color(0.1f, 0.1f, 0.15f, 0.75f);
// 水杯图标
var iconGo = new GameObject("CupIcon");
iconGo.transform.SetParent(_panel, false);
var iconRect = iconGo.AddComponent<RectTransform>();
iconRect.anchorMin = new Vector2(0f, 1f);
iconRect.anchorMax = new Vector2(0f, 1f);
iconRect.pivot = new Vector2(0f, 1f);
iconRect.anchoredPosition = new Vector2(16f, -16f);
iconRect.sizeDelta = new Vector2(48f, 48f);
_icon = iconGo.AddComponent<Image>();
_icon.color = new Color(0.4f, 0.7f, 1f);
// 文字
var textGo = new GameObject("StatusText");
textGo.transform.SetParent(_panel, false);
var textRect = textGo.AddComponent<RectTransform>();
textRect.anchorMin = new Vector2(0f, 1f);
textRect.anchorMax = new Vector2(1f, 1f);
textRect.pivot = new Vector2(0.5f, 1f);
textRect.anchoredPosition = Vector2.zero;
textRect.offsetMin = new Vector2(72f, -42f);
textRect.offsetMax = new Vector2(-12f, -12f);
_text = textGo.AddComponent<Text>();
_text.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
_text.fontSize = 18;
_text.color = Color.white;
_text.alignment = TextAnchor.MiddleLeft;
// 按钮
var btnGo = new GameObject("AddCupButton");
btnGo.transform.SetParent(_panel, false);
var btnRect = btnGo.AddComponent<RectTransform>();
btnRect.anchorMin = new Vector2(0.5f, 0f);
btnRect.anchorMax = new Vector2(0.5f, 0f);
btnRect.pivot = new Vector2(0.5f, 0f);
btnRect.anchoredPosition = new Vector2(0f, 10f);
btnRect.sizeDelta = new Vector2(120f, 28f);
var btnImage = btnGo.AddComponent<Image>();
btnImage.color = new Color(0.2f, 0.5f, 0.8f);
var btn = btnGo.AddComponent<Button>();
var btnTextGo = new GameObject("Text");
btnTextGo.transform.SetParent(btnRect, false);
var btRect = btnTextGo.AddComponent<RectTransform>();
btRect.anchorMin = Vector2.zero;
btRect.anchorMax = Vector2.one;
btRect.offsetMin = Vector2.zero;
btRect.offsetMax = Vector2.zero;
var bt = btnTextGo.AddComponent<Text>();
bt.text = "我喝了";
bt.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
bt.fontSize = 14;
bt.color = Color.white;
bt.alignment = TextAnchor.MiddleCenter;
btn.onClick.AddListener(OnAddCupClicked);
RefreshText();
}
}
七、在Unity中测试与调试
在编辑器里测试时,我会把debugIntervalSeconds设成10秒,这样每分钟能看到好几次提醒。点击“我喝了”按钮,杯数会立刻增加,达到8杯时显示达标信息,并播放达标音效(如果指定了)。为了验证跨日重置,可以手动改系统时间(注意Unity编辑器可能需要重启才能识别时间变化),或者临时把LoadDailyData里的日期比较逻辑改成非今日就重置,方便测试。
图标闪烁效果很直观,提醒时图标闪2秒,达标后文字变化,没有闪烁。震动在编辑器里无效,但真机上会生效。语音播报目前只打Log,集成Rokid TTS后就能听见声音了。
八、扩展与个性化
这个基础版本可以轻松扩展:
- 自定义目标杯数:把
TargetCups改成可配置的变量,存到PlayerPrefs里。 - 可调节提醒间隔:同理,把
HourInSeconds改成变量,允许用户设置1小时、2小时等。 - 更换图标和颜色:UI里的水杯图标可以换成自定义Sprite,背景色、文字颜色都可以在代码里调整,或者暴露给Inspector。
- 接入真实TTS:Rokid眼镜支持离线语音合成,只需几行代码就能让眼镜开口说话。
- 配合健康数据:如果眼镜有心率传感器,可以动态调整提醒策略,比如运动后缩短间隔。
九、结语
AR眼镜的魅力在于把信息轻柔地融入现实,而不是打断现实。这个喝水提醒助手就是一个例子:它安静地待在视野角落,每小时闪一闪图标,震动一下,偶尔说句话,不会霸占你的注意力,但能帮你养成更好的习惯。代码本身不复杂,但涵盖了单例模式、事件系统、数据持久化、UI动态创建等常用技术,适合作为AR应用开发的入门练习。希望这篇文章能给打算在Rokid眼镜上做健康类应用的朋友一些启发,也祝大家马年春节身体健康,喝水达标!