AR喝水提醒助手:用Rokid眼镜守护你的春节健康

0 阅读9分钟

一、为什么需要这样一款应用

春节是个热闹的节日,走亲访友、聚会吃喝,往往一整天下来水都顾不上喝几口。等到感觉口渴时,身体其实已经处于轻度脱水状态。今年马年春节,我决定用Rokid AR眼镜做点什么——让虚拟助手在视野角落里悄悄提醒我按时喝水,顺便记录每天喝了多少杯,达标了给点鼓励。这种“存在感不强但关键时刻出现”的设计,很适合AR眼镜的微交互场景。

这篇文章会完整呈现这个“AR喝水提醒助手”的开发过程,包括核心逻辑、UI实现、数据持久化,以及如何为Rokid眼镜扩展震动和语音播报。所有代码都在Unity 2022 中编写,基于UGUI,稍作调整就能跑在Rokid设备上。

二、功能拆解与设计思路

动手之前,我先理清了几个核心需求:

  1. 每小时自动提醒:不需要精确到秒,累加计时,满一小时触发一次。
  2. 当日杯数记录:目标8杯,每提醒一次自动加1,用户也可以手动点击“我喝了”加1。
  3. 跨日自动重置:第二天重新从0开始。
  4. 多感官反馈:提醒时图标闪烁,手机震动,可选音效,预留语音播报接口。
  5. 数据持久化:应用退出或暂停时保存当前杯数和计时进度,下次打开接着计。
  6. 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知道。这里把音效和语音都设计成可选,reminderClipgoalReachedClip可以在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(); // 确保当天数据立即写入
}

这样即使应用被系统回收,重启后也能接着上次的进度继续计时。OnApplicationPauseOnApplicationQuit里都调了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里把做好的panelRootstatusTextcupIconaddCupButton拖进去,脚本会自动使用它们。
  • 自动创建模式:如果没拖拽任何引用,脚本会在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项目里。大致步骤:

  1. 按照官方文档导入Rokid UXR SDK,创建AR场景,确保有AR Session OriginAR Camera
  2. 在场景中创建一个空物体,挂上WaterReminderManager,并为其指定音效(可选)。
  3. 创建一个Canvas(Screen Space - Overlay),挂上WaterReminderUI脚本。如果不想手动布局,就把所有引用留空,让它自动生成。
  4. 为了在Rokid眼镜上获得震动和语音,需要在TryHapticSpeakReminder里调用Rokid SDK的对应API。例如震动可以用Rokid.UXR.Haptic类,语音可以用Rokid.Speech.SpeechSynthesizer
  5. 最后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眼镜上做健康类应用的朋友一些启发,也祝大家马年春节身体健康,喝水达标!