Excel 明明还开着,定时宏为什么没执行
摘要
很多人第一次用 Application.OnTime 时,看到宏到点没有执行,往往先怀疑时间格式。实际更常见的是 Excel 当时正忙、上一次任务没有取消,或者工作簿关闭后还留下了旧排程。本文把定时宏拆成登记、执行、续排和关闭清理四步,并给出一套可以直接放进工作簿测试的代码。
一、到点没有执行,先别急着改时间
1.1 OnTime 只是在 Excel 里登记一次调用
Application.OnTime 会让 Excel 在指定时间调用一个过程。它不是 Windows 的后台计划任务,也不是独立线程。Excel 退出后,它不会替你继续运行;目标工作簿已经关闭、宏过程不可用时,也没有地方可以继续执行这段 VBA。
我一般先把这个边界说清楚。定时刷新日报、定时写日志这类工作适合 OnTime;如果你不希望依赖 Excel 进程持续运行,或者需要在无人登录的会话中处理文件,就该考虑任务计划程序或其他自动化方式。
1.1.1 Excel 忙碌时,LatestTime 决定这次任务等多久
EarliestTime 是最早执行时间。到了这个时间,如果 Excel 正在运行另一个过程,或者处于复制、剪切、查找等不能运行宏的状态,任务未必会立刻开始。
不传 LatestTime 时,Excel 会一直等到可以运行。对于每隔几分钟跑一次的任务,这很容易把旧任务拖到下一轮。本文代码把等待窗口设为 30 秒:30 秒内恢复可运行状态就执行,超过 30 秒就跳过这一次,下一轮再登记。
资料链接:Microsoft Learn:https://learn.microsoft.com/zh-cn/office/vba/api/excel.application.ontime
1.2 带参数时,先把调用字符串写对
OnTime 没有单独的参数列表,但它会解析 Procedure 字符串中的过程调用,所以带参数的过程也能被调度。例如 "'PopUpReminder ""Hello""'" 会在到点后调用 PopUpReminder "Hello"。我仍建议把常用定时过程放在标准模块并声明为 Public Sub,这样引用和排错都更直接;但这是一种稳妥写法,不是“过程绝不能带参数”的限制。
Excel 也不会自动替你传入工作簿或工作表对象。简单的文本、数字和日期可以写进调用字符串;对象、数组、字典或复杂状态则更适合由无参数包装过程从 ThisWorkbook、模块变量或持久化数据中取得。
Sub 自动保存()
Dim NewTime
NewTime = Now + TimeValue("00:00:05")
ThisWorkbook.Save
Application.OnTime NewTime, "自动保存"
'如果需要参数 可以这么写,s就是参数
'Application.OnTime NewTime, "自动保存(""" & s & """)"
EndSub
1.2.1 我会让任务完成后再登记下一次
有些写法在任务一开始就登记下一次。如果本次业务代码卡了 8 分钟,5 分钟后的下一次任务已经在队列里等着,等 Excel 空下来就可能连续运行两次。下面的写法把续排放在任务末尾,本轮结束后才计算下一次时间,排程不会堆叠。
二、取消时,Excel 靠什么找到原任务
2.1 取消任务时,过程名和执行时间要完全一致
取消不是写一行 Schedule:=False 就结束。Excel 需要同一份过程名和同一个 EarliestTime 才能找到原来的登记项,所以我把它们保存在模块级变量 mNextRun 和 mScheduleActive 中。
这样做还有一个直接的好处:手动再次执行“启动定时任务”时,代码会先取消旧任务,再创建新任务。你不需要猜上一次到底约在几点,也不会因为重复点运行而留下多个队列项。
2.1.1 ThisWorkbook 比当前活动工作簿更稳
定时任务经常在用户切换到别的文件后才触发。代码里如果使用 ActiveWorkbook 或 ActiveSheet,结果很可能写进了当时刚好处于前台的另一个文件。本文所有业务对象都从 ThisWorkbook 取得,任务始终作用于存放代码的那一本工作簿。
三、先用一张日志表验证排程
3.1 标准模块中的完整代码
在 VBA 编辑器中插入一个标准模块,例如 M_Timer,把下面代码完整放进去。第一次测试时,它会创建或使用“系统日志”工作表,并在 B1 写入最近一次执行时间。确认时间能按预期更新后,再把 WriteRunLog 换成你自己的刷新或汇总过程。
3.1.1 完整代码
OptionExplicit
PrivateConst INTERVAL_MINUTES AsLong = 5
PrivateConst WAIT_SECONDS AsLong = 30
Private mNextRun AsDate
Private mScheduleActive AsBoolean
PublicSub StartScheduledJob()
' 先清理已有排程,避免重复登记
StopScheduledJob
ScheduleNextRun DateAdd("n", INTERVAL_MINUTES, Now)
EndSub
PublicSub StopScheduledJob()
IfNot mScheduleActive ThenExitSub
' 取消时必须使用原来的执行时间和过程名
OnErrorResumeNext
Application.OnTime EarliestTime:=mNextRun, Procedure:=TimerProcedureName(), Schedule:=False
OnErrorGoTo0
mScheduleActive = False
mNextRun = 0
EndSub
PublicSub RunScheduledJob()
mScheduleActive = False
OnErrorGoTo TaskFailed
' 这里替换成你的实际业务过程
WriteRunLog
ScheduleAgain:
' 本轮结束后才登记下一次,避免任务重叠
OnErrorGoTo0
ScheduleNextRun DateAdd("n", INTERVAL_MINUTES, Now)
ExitSub
TaskFailed:
' 发生错误时保留下一轮排程,错误信息可在立即窗口查看
Debug.Print "定时任务失败:" & Err.Number & " - " & Err.Description
Resume ScheduleAgain
EndSub
PrivateSub ScheduleNextRun(ByVal executeAt AsDate)
mNextRun = executeAt
Application.OnTime EarliestTime:=mNextRun, _
Procedure:=TimerProcedureName(), _
LatestTime:=DateAdd("s", WAIT_SECONDS, mNextRun), _
Schedule:=True
mScheduleActive = True
EndSub
PrivateFunction TimerProcedureName() AsString
' 工作簿名可能含单引号,先按 Excel 宏名称规则转义
TimerProcedureName = "'" & Replace(ThisWorkbook.Name, "'", "''") & "'!RunScheduledJob"
EndFunction
PrivateSub WriteRunLog()
Dim logSheet As Worksheet
Set logSheet = GetOrCreateLogSheet()
logSheet.Range("A1").Value = "最近执行时间"
logSheet.Range("B1").Value = Now
EndSub
PrivateFunction GetOrCreateLogSheet() As Worksheet
OnErrorResumeNext
Set GetOrCreateLogSheet = ThisWorkbook.Worksheets("系统日志")
OnErrorGoTo0
If GetOrCreateLogSheet IsNothingThen
Set GetOrCreateLogSheet = ThisWorkbook.Worksheets.Add(After:=ThisWorkbook.Worksheets(ThisWorkbook.Worksheets.Count))
GetOrCreateLogSheet.Name = "系统日志"
EndIf
EndFunction
这里的 On Error Resume Next 只用于查找已有工作表和取消已不存在的排程,后面立刻恢复了默认错误处理。它不应该包住刷新报表、导入数据这类业务代码,否则工作表改名或数据异常会被悄悄吞掉。
3.2 关闭工作簿前,把已登记的任务撤掉
正常关闭工作簿时,最容易漏掉的动作就是取消排程。如果 Excel 应用仍在运行,旧时间一到,它还会尝试调用刚才登记的过程。下面这段事件代码放在 ThisWorkbook 模块,不放在标准模块。
3.2.1 ThisWorkbook 中的关闭事件
PrivateSub Workbook_Open()
' 打开工作簿后自动开始计时
StartScheduledJob
EndSub
PrivateSub Workbook_BeforeClose(Cancel AsBoolean)
' 只有确认关闭时才撤销任务
IfNot Cancel Then
StopScheduledJob
EndIf
EndSub
如果你不希望每次打开文件就自动启动,可以删除 Workbook_Open,改为手动运行 StartScheduledJob。但 Workbook_BeforeClose 建议保留,它负责把正常关闭路径收干净。
四、运行时我会这样检查
4.1 先确认日志表,而不是盯着 VBA 编辑器
按 Alt + F8 运行一次 StartScheduledJob,等待 5 分钟。正常情况下,“系统日志”表的 B1 会写入执行时间。之后继续等待下一个间隔,B1 应再次更新。
这个测试比直接套进复杂的刷新宏更容易定位问题。日志没有变化时,先检查 RunScheduledJob 是否仍是标准模块中的公共过程,再确认 Excel 是否在编辑单元格、复制数据或运行其他长宏。
4.1.1 两个容易误判的情况
•如果 Excel 忙碌超过 30 秒,本例会放弃这一轮。这是 LatestTime 的预期行为,不代表后续排程丢失。
•如果在 VBA 编辑器里按了“重置”按钮,模块变量会被清空。此时旧排程可能还在,而代码已经不知道原来的 mNextRun。我会关闭并重新打开工作簿,再重新启动任务,而不是继续反复点击启动过程。
•如果 Excel 本身退出或异常终止,OnTime 不能替代系统级定时器。需要跨 Excel 会话执行的任务,不适合依赖这套代码。
五、把日志过程换成业务宏前,再看三件事
5.1 业务代码要能独立运行
定时过程最好只调用一个已经手动验证过的业务宏。例如刷新查询、汇总当日数据或导出固定报表。不要把需要弹窗确认、依赖当前选择区域、需要用户输入参数的宏直接塞进 RunScheduledJob,这些交互会让它在无人操作时停住。
5.1.1 出错后是否继续下一轮,要由业务决定
本文示例即使本轮失败也会续排,适合偶发的临时错误。要是失败意味着数据结构已经变化,例如关键工作表被删除,我会在 TaskFailed 里停止续排并提示维护者处理。维护者至少要能判断:本轮失败后,下一轮是否还会创建。
六、写在最后
Application.OnTime 本身并不复杂,容易遗漏的是排程登记后的清理动作。保存执行时间,取消时传回同一份标识,关闭前撤销任务,并在本轮结束后才登记下一轮,定时宏就更容易按预期运行,也更容易排错。
如果你也经常用 Excel 和 VBA 处理重复性的工作,欢迎关注 VBA爱好者。后面我会继续整理这些能直接落到工作簿里的实用写法。