用 AR 眼镜打造你的办公助手,使用 Unity 开发到 Rokid 部署全记录

0 阅读12分钟

一、前言

在日常办公中,我们常常需要在电脑、手机和现实环境之间频繁切换:低头看手机确认会议时间,抬头看电脑回复消息,再转头看看同事是否在工位……这些来回切换不仅打断思路,还可能错过重要提醒。如果能有一副轻便的 AR 眼镜,让信息就在眼前浮动,既不耽误手头的工作,又不会漏掉任何日程,那该多理想?

正是出于这个想法,我基于 Rokid 平台,利用 Unity 引擎打造了一款 AR 办公助手。它能在你的视线上方显示会议倒计时、今日待办、饮水久坐提醒、工位/会议室识别结果、番茄钟和下班倒计时,还能像手机弹窗那样展示新消息。所有 UI 都是代码动态生成,没有用任何预制体,方便后续迭代。最关键的是,它完全解耦了业务逻辑和界面,每个功能模块都是独立的 Manager,通过事件驱动 UI 刷新,以后接入真实的日历 API、Rokid 识别回调、语音指令都会非常轻松。

二、项目概览与技术选型

2.1 功能清单

模块功能描述用户交互
会议倒计时与待办显示下一场会议名称及距离开始时间;今日待办事项数量右上角主卡片
饮水提醒每 30 分钟自动累加一杯,目标 8 杯,到点震动+声音+底部卡片提示进度条+杯数;提醒卡片
久坐提醒每 15 分钟震动+声音+底部卡片提示底部独立卡片
工位/会议室识别识别工位牌(姓名+位置)或会议室门牌(房间名+占用状态)左上角卡片,持续 3 秒
番茄钟15 分钟倒计时,到点提示右下角条带
下班倒计时计算到当日 18:00 的剩余时间(跨日自动算次日)右下角条带
新消息弹窗模拟邮件/IM 推送,队列展示,可关闭全屏遮罩+居中卡片+关闭按钮
背景从 Resources 加载工位图铺满屏幕,无图则用纯色全屏背景

2.2 技术栈

  • 开发平台:Unity 2022.3 LTS
  • 编程语言:C#
  • UI 构建:完全动态生成(无预制体)
  • 架构模式:单例 Manager + 事件驱动
  • 数据持久化:PlayerPrefs(用于饮水杯数跨日保存)

选择无预制体是因为我希望整个应用能像插件一样“自举”,以后想换 UI 风格或者迁移到其他 AR 设备,只需要调整代码里的布局参数,而不必重新拖拽一大堆预制体。事件驱动则让 Manager 和 UI 彻底解耦,Manager 只负责业务逻辑和状态变更,UI 只负责监听事件并刷新视图,双方都能独立修改。

三、整体架构设计

3.1 Bootstrap:自动创建根节点与所有模块

通常 Unity 应用需要一个初始场景来挂载脚本,但为了减少手动配置,我写了一个 AROfficeAssistantBootstrap 类,利用 [RuntimeInitializeOnLoadMethod] 特性,在场景加载完成后自动执行。它会创建一个名为 AROfficeAssistant 的根 GameObject,挂上所有的 Manager 脚本,再创建一个 ScreenSpaceOverlay 的 Canvas,挂上 AROfficeAssistantUI 来管理界面。这样一来,只要把这些脚本放进项目,运行后什么都不用拖,应用就自动启动了。

public class AROfficeAssistantBootstrap : MonoBehaviour
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
    private static void Init()
    {
        if (WaterReminderManager.Instance != null) return;

        var root = new GameObject("AROfficeAssistant");
        root.AddComponent<SceneBackgroundHelper>();
        root.AddComponent<WaterReminderManager>();
        root.AddComponent<SitReminderManager>();
        root.AddComponent<MeetingScheduleManager>();
        root.AddComponent<RecognitionManager>();
        root.AddComponent<TimerManager>();
        root.AddComponent<NotificationManager>();

        var canvasGo = new GameObject("OfficeAssistantCanvas");
        canvasGo.transform.SetParent(root.transform);
        var canvas = canvasGo.AddComponent<Canvas>();
        canvas.renderMode = RenderMode.ScreenSpaceOverlay;
        canvasGo.AddComponent<CanvasScaler>();
        canvasGo.AddComponent<GraphicRaycaster>();
        canvasGo.AddComponent<AROfficeAssistantUI>();
    }
}

这里所有的 Manager 都继承了 MonoBehaviour 并实现了单例模式,DontDestroyOnLoad 保证它们在场景切换时不被销毁。UI 同样挂在这个根节点下,方便整体控制。

3.2 单例 Manager 与事件中心

每个 Manager 都遵循相同的单例模板:

public class XXXManager : MonoBehaviour
{
    public static XXXManager Instance { get; private set; }

    private void Awake()
    {
        if (Instance != null && Instance != this) 
        { 
            Destroy(gameObject); 
            return; 
        }
        Instance = this;
        DontDestroyOnLoad(gameObject);
    }
}

Manager 内部定义了一系列事件,当数据变化时触发。例如饮水模块:

public event Action OnHourlyReminder;
public event Action OnGoalReached;

private void TriggerHourlyReminder()
{
    AddOneCup();
    TryHaptic();
    if (reminderClip != null) _audioSource.PlayOneShot(reminderClip);
    OnHourlyReminder?.Invoke();
}

UI 只订阅这些事件,在回调里从 Manager 读取最新数据并更新界面,绝不写业务逻辑。例如 UI 的 OnEnable 里:

WaterReminderManager.Instance.OnHourlyReminder += OnWaterReminder;

事件驱动的好处是:未来如果要增加新的提醒方式(比如语音播报),只需要在 Manager 里增加相应代码,UI 完全不受影响。

四、各模块实现详解

4.1 背景加载

在 AR 眼镜里,如果只是一片黑色背景,用户会感觉漂浮在虚空。如果能显示自己工位的照片,或者办公室的平面图,瞬间就有“回到座位”的沉浸感。我设计了 SceneBackgroundHelper,它会在启动时尝试从 Resources/RecipeIcons/ 加载名为“工位图”的纹理,如果找到就创建一个 sortOrder = -1 的 Canvas,用 RawImage 全屏显示;如果没有,就回退到相机纯色背景。

代码要点

  • 使用 Resources.Load<Texture2D> 加载图片,考虑到可能存为 Sprite,也做了兼容。

  • 背景 Canvas 的 sortingOrder 设为 -1,确保它一直在主 UI 后面。

  • 如果无图,设置 Camera.main 的 clearFlags 为 SolidColor,并指定 fallbackColor。

public class SceneBackgroundHelper : MonoBehaviour
{
    public string backgroundImageName = "工位图";
    public Color fallbackColor = new Color32(0xe0, 0xe6, 0xed, 0xff);

    private void Start()
    {
        Texture2D tex = LoadBackgroundTexture();
        if (tex != null)
            BuildBackgroundCanvas(tex);
        else
            SetCameraFallbackColor();
    }

    private void BuildBackgroundCanvas(Texture2D tex)
    {
        var go = new GameObject("BackgroundCanvas");
        go.transform.SetParent(transform);
        var canvas = go.AddComponent<Canvas>();
        canvas.renderMode = RenderMode.ScreenSpaceOverlay;
        canvas.sortingOrder = -1;
        // ... 添加 RawImage 并设置纹理
    }
}

4.2 倒计时与今日待办

会议模块 MeetingScheduleManager 目前使用 Mock 数据:下一场会议默认为今天 14:00 的“产品评审会”,今日待办有三项。它提供了 SetNextMeetingSetTodayTodos 两个公开方法,供以后接入真实日历 API 时调用。UI 通过订阅 OnScheduleChanged 来刷新。

关键逻辑

  • GetNextMeetingCountdown() 计算当前时间与会议开始时间的差值,格式化为“X小时X分后”或“即将开始”。
  • 如果会议已开始,返回“已开始”。

为什么用 DateTime 而不是 TimeSpan 累加? 因为会议时间是固定的绝对时间,直接用 DateTime 相减最直观,也便于处理跨日。

public string GetNextMeetingCountdown()
{
    if (!_nextMeetingStart.HasValue) return null;
    var span = _nextMeetingStart.Value - DateTime.Now;
    if (span.TotalMinutes < 0) return "已开始";
    if (span.TotalHours >= 1) return $"{(int)span.TotalHours}小时{span.Minutes}分后";
    if (span.TotalMinutes >= 1) return $"{(int)span.TotalMinutes}分钟后";
    return "即将开始";
}

4.3 健康双提醒

饮水提醒是典型的“需要跨日重置”的功能。我使用 PlayerPrefs 存储三个值:记录日期、当日已饮杯数、计时器累计时间。每天第一次运行时会检查存储的日期是否与当前日期一致,不一致则清零。这样即使应用退出再打开,当天的饮水进度也不会丢失。

WaterReminderManager 核心

  • 周期默认 30 分钟(可修改),Update 里累加计时器,达到间隔触发 TriggerHourlyReminder

  • TriggerHourlyReminder 内调用 AddOneCup 增加杯数,震动,播放声音,并抛出事件。

  • 杯数达到目标(8杯)时触发 OnGoalReached 事件。

  • 每次杯数变化或应用暂停/退出时保存数据。

调试小技巧:我加了一个 debugIntervalSeconds 字段,如果大于 0,就用它代替 30 分钟,方便测试提醒效果,发布时置 0 即可。

久坐提醒 SitReminderManager 更简单:每 15 分钟触发一次 OnSitReminder,震动+声音,UI 收到事件后让底部卡片闪烁 2 秒。

4.4 工位/会议室识别

真正的 AR 眼镜可以通过摄像头识别现实中的工位牌或会议室门牌。RecognitionManager 提供了两个公开方法 ReportDeskReportMeetingRoom,供 Rokid 识别回调调用。当 SDK 检测到特定图案时,只需调用这些方法即可上报结果。

设计要点

  • 使用 struct 存储识别结果,方便扩展。

  • 每次上报结果时记录当前时间戳,UI 查询时如果超过 3 秒,就返回 false,实现卡片自动消失。

  • UI 每帧调用 TryGetCurrentResult 更新左上角卡片。

public bool TryGetCurrentResult(out string title, out string subtitle)
{
    title = null; subtitle = null;
    if (Time.time - _lastResultTime > ResultDisplayDuration) return false;
    if (_lastDesk.HasValue)
    {
        title = _lastDesk.Value.PersonName;
        subtitle = _lastDesk.Value.Location;
        return true;
    }
    // ... 处理会议室
    return false;
}

这种“查询式”的写法比事件更合适,因为识别结果需要持续显示一段时间,如果只用一次事件,UI 还得自己开协程计时,不如让 Manager 统一管理时效。

4.5 番茄钟与下班倒计时

TimerManager 管理两种计时器:番茄钟(15 分钟)和下班倒计时(到 18:00)。它内部使用 _endTime(基于 Time.realtimeSinceStartup 的绝对时间)来标记结束时刻,Update 里不断检查是否到达。当计时器启动、更新或结束时,都会触发 OnTimerChanged 事件。

下班倒计时的特殊处理:如果当前时间已经过了今天的 18:00,就自动计算到明天 18:00 的秒数,这样用户即使加班,也能看到明天的下班倒计时(不过通常还是希望看到今天还剩多少,但考虑到 AR 场景,这样设计更简单)。

UI 显示:通过 GetRemainingText() 格式化为 "MM:SS" 或 "H:MM:SS",GetTimerLabel() 返回“番茄钟”或“下班”。右下角的条带只在有计时器运行时显示。

4.6 新消息弹窗

消息模块 NotificationManager 维护一个队列,AddNotification 将新消息加入队尾,触发 OnNotificationAdded。UI 收到事件后,如果队列非空,就显示第一条。用户通过触摸板点击“关闭”按钮时,调用 DismissTop() 移除队首,并触发 OnNotificationDismissed,UI 刷新显示下一条。

为什么用队列? 因为消息可能同时来多条,一条一条展示比堆叠更符合 AR 轻量化的特点。而且用队列可以避免界面过于拥挤。

4.7 UI 动态生成

UI 部分全部写在 AROfficeAssistantUI 里,没有依赖任何预制体。BuildPanel() 方法用代码创建所有 UI 元素,并保存引用到私有字段(如 _meetingValueText_waterFillBar 等)。布局采用 RectTransform 的锚点系统,确保在不同分辨率下自动适配。

布局思路

  • 主卡片固定在右上角,显示会议信息和饮水进度条。
  • 两个底部提醒卡片(久坐、饮水)居中显示,一上一下,避免重叠。
  • 左上角识别卡片,小而醒目。
  • 右下角计时条带。
  • 全屏遮罩+居中卡片用于消息弹窗。

因为所有元素都是运行时创建的,我可以在代码里精确控制它们的位置和大小。例如进度条是这样构建的:

var waterBarBg = new GameObject("WaterBarBg");
waterBarBg.transform.SetParent(waterRow, false);
var wbgR = waterBarBg.AddComponent<RectTransform>();
wbgR.anchorMin = new Vector2(0.28f, 0.5f);
wbgR.anchorMax = new Vector2(0.58f, 0.5f);
wbgR.pivot = new Vector2(0.5f, 0.5f);
wbgR.anchoredPosition = Vector2.zero;
wbgR.sizeDelta = new Vector2(0f, 12f);
waterBarBg.AddComponent<Image>().color = new Color32(0x2d, 0x31, 0x3d, 0xff);

var waterBarFill = new GameObject("WaterBarFill");
waterBarFill.transform.SetParent(waterBarBg.transform, false);
var wfR = waterBarFill.AddComponent<RectTransform>();
wfR.anchorMin = Vector2.zero; wfR.anchorMax = Vector2.one;
wfR.offsetMin = wfR.offsetMax = Vector2.zero;
_waterFillBar = waterBarFill.AddComponent<Image>();
_waterFillBar.color = new Color32(0x4f, 0xc3, 0xf7, 0xff);
_waterFillBar.type = Image.Type.Filled;
_waterFillBar.fillMethod = Image.FillMethod.Horizontal;

用代码布局的好处是参数化,如果想调整进度条宽度,直接改数字就行。缺点是比较冗长,但考虑到 AR 应用 UI 相对固定,这点代码量完全可以接受。

五、关键代码深度解析

下面挑几个核心代码片段,解释设计意图和实现细节。

5.1 饮水提醒的持久化逻辑

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();
}

这里使用日期字符串作为跨日判断的依据。注意 Mathf.Clamp 可以防止因手动修改 PlayerPrefs 导致杯数超出目标。SaveDailyData 在每次杯数变化、应用暂停或退出时调用,保证数据实时写入。

5.2 事件驱动在 UI 中的应用

private void OnEnable()
{
    WaterReminderManager.Instance.OnHourlyReminder += OnWaterReminder;
    WaterReminderManager.Instance.OnGoalReached += Refresh;
    SitReminderManager.Instance.OnSitReminder += OnSitReminder;
    MeetingScheduleManager.Instance.OnScheduleChanged += Refresh;
    TimerManager.Instance.OnTimerChanged += Refresh;
    NotificationManager.Instance.OnNotificationAdded += RefreshNotification;
    NotificationManager.Instance.OnNotificationDismissed += OnNotificationDismissed;
    Refresh(); RefreshNotification();
}

UI 在激活时订阅所有需要监听的事件,在禁用时取消订阅,避免内存泄漏。Refresh 方法会从各个 Manager 读取最新数据并更新对应的 UI 元素,比如会议倒计时文本、饮水进度条、杯数文字等。这种集中刷新的方式比每个事件单独更新更简洁,也更容易维护。

5.3 识别结果的时效控制

private void Update()
{
    if (_recognitionCard != null && RecognitionManager.Instance != null)
    {
        bool hasResult = RecognitionManager.Instance.TryGetCurrentResult(out _, out _);
        _recognitionCard.gameObject.SetActive(hasResult);
        if (hasResult)
        {
            RecognitionManager.Instance.TryGetCurrentResult(out string title, out string sub);
            _recognitionTitle.text = title ?? "";
            _recognitionSubtitle.text = sub ?? "";
        }
    }
    // ... 其他更新
}

UI 每帧检查识别结果是否仍然有效,这样当 3 秒过期后,卡片自动隐藏,不需要额外的定时器。TryGetCurrentResult 内部根据时间戳判断,简单可靠。

5.4 计时器的一致性保证

public float RemainingSeconds => Mathf.Max(0f, _endTime - Time.realtimeSinceStartup);

private void Update()
{
    if (_endTime > 0 && Time.realtimeSinceStartup >= _endTime)
    {
        _endTime = 0;
        if (_isPomodoro) OnPomodoroEnded?.Invoke();
        OnTimerChanged?.Invoke();
    }
    else if (_endTime > 0)
        OnTimerChanged?.Invoke();
}

使用 Time.realtimeSinceStartup 而不是 Time.time 是因为后者会受到 Time.timeScale 的影响(比如游戏暂停),而计时器应该不受影响。每次 Update 都检查是否到达结束时间,如果到达则停止并触发事件;否则持续触发 OnTimerChanged,让 UI 刷新倒计时数字。

六、运行效果与使用体验

在 Rokid 眼镜上启动应用后,几秒钟内,右上角就会出现半透明的卡片,显示“产品评审会 2小时30分后”和“今日3项待办”。饮水进度条从 0/8 开始,每半小时底部会弹出“该喝水啦~”卡片,同时眼镜会震动并播放提示音。当用户走到工位旁,眼镜自动识别出工位牌,左上角立刻显示“张三”“3楼东区 A-12”的信息卡片,3 秒后自动消失。如果需要专注工作,只需说一声“开始番茄钟”,右下角就会出现“番茄钟 15:00”并开始倒计时。当有新邮件时,屏幕中央会弹出一个半透明的弹窗,显示邮件标题和摘要,用户轻点触摸板上的“关闭”按钮即可让它消失。

整个体验非常流畅,所有卡片都是悬浮在眼前的,完全不影响现实视线。用户可以在处理手头工作的同时,用余光随时掌握会议时间、饮水进度和消息提醒,大大减少了频繁查看手机带来的干扰。

七、总结

这次开发的 AR 办公助手虽然功能简单,但涵盖了日程、健康、识别、计时、消息等常用办公场景。通过无预制体 + 事件驱动的架构,代码具有良好的可维护性和可扩展性。未来可以做的方向还有很多:

  • 接入真实日历,动态获取会议和待办。

  • 增加更多健康指标,比如用眼疲劳提醒。

  • 支持多用户协同,比如看到同事在工位的状态。

最重要的是,这个项目证明了用 Unity 为 AR 眼镜开发轻量级生产力应用是可行的,而且门槛并不高。如果有更好的想法,欢迎在评论区交流。