一、前言
在日常办公中,我们常常需要在电脑、手机和现实环境之间频繁切换:低头看手机确认会议时间,抬头看电脑回复消息,再转头看看同事是否在工位......这些来回切换不仅打断思路,还可能错过重要提醒。如果能有一副轻便的 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 来管理界面。这样一来,只要把这些脚本放进项目,运行后什么都不用拖,应用就自动启动了。
ini
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 都遵循相同的单例模板:
kotlin
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 内部定义了一系列事件,当数据变化时触发。例如饮水模块:
csharp
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 里:
ini
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。
ini
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 的"产品评审会",今日待办有三项。它提供了 SetNextMeeting 和 SetTodayTodos 两个公开方法,供以后接入真实日历 API 时调用。UI 通过订阅 OnScheduleChanged 来刷新。


关键逻辑:
GetNextMeetingCountdown()计算当前时间与会议开始时间的差值,格式化为"X小时X分后"或"即将开始"。- 如果会议已开始,返回"已开始"。
为什么用 DateTime 而不是 TimeSpan 累加? 因为会议时间是固定的绝对时间,直接用 DateTime 相减最直观,也便于处理跨日。
csharp
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 提供了两个公开方法 ReportDesk 和 ReportMeetingRoom,供 Rokid 识别回调调用。当 SDK 检测到特定图案时,只需调用这些方法即可上报结果。
设计要点:
-
使用 struct 存储识别结果,方便扩展。
-
每次上报结果时记录当前时间戳,UI 查询时如果超过 3 秒,就返回 false,实现卡片自动消失。
-
UI 每帧调用
TryGetCurrentResult更新左上角卡片。
csharp
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 的锚点系统,确保在不同分辨率下自动适配。
布局思路:
- 主卡片固定在右上角,显示会议信息和饮水进度条。
- 两个底部提醒卡片(久坐、饮水)居中显示,一上一下,避免重叠。
- 左上角识别卡片,小而醒目。
- 右下角计时条带。
- 全屏遮罩+居中卡片用于消息弹窗。
因为所有元素都是运行时创建的,我可以在代码里精确控制它们的位置和大小。例如进度条是这样构建的:
ini
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 饮水提醒的持久化逻辑
ini
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 中的应用
ini
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 识别结果的时效控制
csharp
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 计时器的一致性保证
scss
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 眼镜开发轻量级生产力应用是可行的,而且门槛并不高。如果有更好的想法,欢迎在评论区交流。