
搞过Revit二次开发的朋友都知道事件机制是插件从“被动响应用户点击”走向“主动感知模型变化”的关键跳板。今天想好好拆一拆两个最容易配合使用、也最容易用出坑的事件空闲事件Idling和DocumentChanged事件。很多人单独用过其中一个但真正把它们组成“双事件”联动来做的却不多。这两个事件组合起来能干很多重活模型一旦被改动系统自动记录变更元素等用户停下手里的操作、Revit空闲下来再统一去做批量处理、参数同步、结果校验甚至写外部数据库。整个过程用户无感、不挡手、不弹事务冲突。这个模式在自动化审查、模型合规检查、构件统计、族库批量维护这类插件里非常常见也是把“手动插件”升级成“智能助手”的核心套路。这篇文章不绕弯子直接讲清三件事这两个事件各自的脾气、怎么注册怎么用以及把它们连起来做联动时有哪些坑必须绕开。适合刚接触事件机制的新手也适合已经写了不少插件但总觉得“事件消耗太高”“老触发不了”的老手。1. 事件机制概览为什么偏偏是这两个事件1.1 事件驱动在Revit插件里的角色多数人写的第一个Revit插件都是“找个按钮点一下执行命令”本质是IExternalCommand的Execute被触发。这种模型没毛病但它的上限很低——用户必须主动点按钮插件才知道该干活了。事件驱动则是反过来让Revit在特定时机主动通知你的插件“发生了某件事”你再决定要不要响应。这能让插件自动跟随模型变化做到“用户什么都不用点结果自动更新”。Revit的API事件体系分为不少类型有文档打开关闭的、有界面激活切换的、有视图刷新相关的事件。但对“模型内容变化”这个需求来说最关键的就是DocumentChanged和Idling这一对。1.2 两个事件各自解决什么问题DocumentChanged解决的是一切“发生了什么”的问题。比如用户改了墙的类型参数、删了一扇窗、新建了一个房间事务一旦提交这个事件就会触发并且还会告诉你这次事务里新增了哪些元素、修改了哪些元素、删除了哪些元素。Idling解决的则是“何时动手处理”的问题。Revit作为主线程应用极不愿意在处理模型的过程中被打断。DocumentChanged事件触发时文档正处在一个刚提交完事务的“敏感区”这时候你要是直接改模型Revit基本会直接翻脸。Idling就是那个“等高手离开手边的模型你再去碰”的合适时机。1.3 双事件组合的典型场景这里我列几个常见的落地场景下面所有代码和调试思路都围绕它们展开构件参数自动同步用户改了某个构件的“楼层”属性插件自动把关联的“房间名”“分区编号”“防火分区”一类参数更新。模型合规校验每次事务提交后插件检查当前构件是否满足你们公司的标准比如净高、面积、管线间距不满足就把该构件标红或写入问题清单。统计与报表联动用户改了模型后台自动更新工程量统计表、构件明细甚至把变更记录同步到Excel或数据库。族库批量维护你在项目里插入大量族实例双击改完参数后插件把这些变更归并同步到本地的族库/参数映射表。这些场景的共同点是变化是高频的处理是低频的且处理通常需要写文档。也正因为这样才需要“监听”和“执行”两套机制拆开。2. 空闲事件Idling的使用与性能陷阱2.1 触发条件与生命周期Idling事件挂在UIApplication上触发时机是Revit进入“空闲状态”之后。什么是空闲翻译成人话就是当前没有正在执行的命令没有正在交互中的操作比如拖拽、绘制也没有处于活动状态的模态对话框。要注意它不是“文档空闲”而是“UI空闲”。也就是说用户停下手里的鼠标键盘、Revit没有正在处理的任务队列时Idling就会按一定频率触发。这个表现带来一个很实际的后果如果你打开了Idling监听Revit原本在用户什么都不做时就“睡着”现在会为了你定时“醒过来”检查一次。所以“Idling不能乱用、也不能长期挂着不做事”是很多人踩过坑之后才顿悟的第一原则。2.2 注册Idling事件的基本姿势Idling注册需要UIApplication实例。而很多人在IExternalApplication的OnStartup里犯难参数给的是UIControlledApplication没有UIApplication怎么注册常见做法是在插件首次执行某个界面命令时拿到UIApplication后初始化事件订阅。代码结构大概是这样的public class App : IExternalApplication { public static UIApplication UiApp { get; private set; } public static bool IsInitialized { get; private set; } public Result OnStartup(UIControlledApplication application) { // 这里先创建Ribbon面板和按钮 // ... return Result.Succeeded; } public static void InitializeEvents(UIApplication uiApp) { if (IsInitialized) return; UiApp uiApp; uiApp.Idling AppEvents.OnIdling; IsInitialized true; } public static void UnsubscribeEvents() { if (!IsInitialized) return; UiApp.Idling - AppEvents.OnIdling; IsInitialized false; } }在命令Execute里调用InitializeEvents(uiApp)后事件就挂上了。这个方案能绕开OnStartup拿不到UIApplication的限制而且事件初始化一次整个Revit会话期间都生效后续打开多少个文档都不影响。2.3 关键参数IsStillIdle与SetRaiseWithoutTransaction使用Idling事件时有两个成员必须搞清楚否则你会被表现整懵。第一个是e.IsStillIdle。它的默认值是false。当你把IsStillIdle设为true时Revit会认为你希望继续保持“高频率唤醒”于是会拼命触发Idling事件。如果你没在事件里做多少事但把IsStillIdle设成trueCPU占用基本秒升到两位数甚至接近100%。绝大多数情况下你不应该有目的地去把它改成true。真正需要的是false让Revit在触发一次后降低唤醒频率节省资源。第二个是e.SetRaiseWithoutTransaction()。调用这个方法的作用是即使当前文档中还有未提交的事务比如用户正在操作对话框或某个命令还没走完Revit也应尝试触发Idling事件。默认情况下如果文档处于事务未提交状态Idling会被抑制。在联动场景里我们一般会在处理前调一下任这个方法保证事件不会被“压制”。我给出的建议模板是这样的private static void OnIdling(object sender, IdlingEventArgs e) { if (!App.HasPendingWork || App.IsProcessing) return; e.SetRaiseWithoutTransaction(); App.IsProcessing true; try { WorkExecutor.ProcessPendingChanges(); } catch (Exception ex) { // 记录日志别让异常打断Revit主线程 } finally { App.IsProcessing false; } }2.4 性能红线别让空闲事件变成CPU杀手这是我最想强调的一节。Idling事件是“方便”但也是“危险品”。我这几年见过不少项目自动化插件一加载Revit整机风扇狂转、鼠标拖拽都卡最后定位到就是有人在Idling里跑全模型遍历。控制性能有三条红线不要在Idling事件里做全文档遍历。如果一个遍历要耗时几秒Revit整个UI会被锁死等于插件把软件搞瘫了。如果确实需要遍历尽量限定在DocumentChanged收集到的更改元素集合内。不要设置IsStillIdle true。只有做动画、持续刷新视图这类极特殊需求才可能用到它常规业务都不要动它。处理要带“门槛”用HasPendingWork加IsProcessing双布尔判断没有待处理任务就直接return别让事件处理每次都跑完整逻辑。一句话总结Idling是“让CPU出点力帮你看着模型”不是“让CPU往死里跑”。只要把握住这个原则性能基本可控。3. DocumentChanged事件的使用与禁区3.1 触发时机与事件参数DocumentChanged是文档级事件可以注册在Application或ControlledApplication上。它的触发时机很明确文档中任何一个事务Transaction提交成功之后。注意“提交之后”这五个字。也就意味着你在这个事件里读到的文档已经是事务提交后的稳定状态可以安全读取元素、参数、几何等信息。但这同时也意味着事件已经发生在“改动生效后”你不能阻止这次改动只能事后响应。事件参数DocumentChangedEventArgs提供了几个重要的方法GetTransactionName()返回导致事件触发的事务名称。GetAddedElementIds()新增的元素ID集合。GetModifiedElementIds()修改过的元素ID集合。GetDeletedElementIds()被删除的元素ID集合。GetElementIds()所有受影响元素ID的合集。3.2 如何获取新增/删除/修改的元素代码上这段逻辑可以说是整个“双事件”模式的信息源。我通常会在一个静态类里维护一个变更ID集合public static class ChangeTracker { public static readonly object LockObj new object(); public static HashSetElementId ElementIds new HashSetElementId(); public static bool HasPendingWork false; public static void OnDocumentChanged(object sender, DocumentChangedEventArgs e) { // 先过滤自己的事务防止自触发 if (string.Equals( e.GetTransactionName(), App.PluginTxnName, StringComparison.OrdinalIgnoreCase)) { return; } lock (LockObj) { ElementIds.UnionWith(e.GetAddedElementIds()); ElementIds.UnionWith(e.GetModifiedElementIds()); ElementIds.UnionWith(e.GetDeletedElementIds()); HasPendingWork true; } } }这里最容易被忽略的是DeletedElementIds里的元素已经从文档中删除了。你拿到ID之后没法再用doc.GetElement(id)去取它因为你取不到它已经不存在了。正确的处理是用这些ID去更新外部数据库或缓存索引把对应记录删掉或标记为“已删除”而不要试图读取它们的参数。3.3 不可修改文档的铁律这是DocumentChanged事件最硬的一条规矩在事件处理函数内绝对不允许修改文档。要理解为什么得回到事件触发时机。事务提交后触发DocumentChanged此时Revit还处于事件分发状态文档内部的一致性约束还没有全部释放。此时如果你试图创建新Transaction修改文档Revit会抛出InvalidOperationException之类异常或者带来不可预期的状态损坏。正确的姿势始终是DocumentChanged只做“记录”不做“修改”。记录信息、记录ID、置脏标记放到共享集合里然后真正需要动手改文档的活全部交给Idling去干。3.4 如何识别自己的事务避免自触发双事件一旦联动起来必然出现一种情况你在Idling里创建事务修改了文档这个事务一提交又触发了DocumentChanged。于是逻辑变成“改了→触发→记录→处理→再改→再触发”的循环。破局方式就是看事务名。在Idling处理时用固定名称开启事务例如public const string PluginTxnName MyProductAutoSyncTxn;然后在DocumentChanged里比较e.GetTransactionName()是否等于这个固定名称等于就丢弃。这也是我在代码里写第一行判断的原因。这里还要提一个细节Revit的Undo/Redo操作也会触发DocumentChanged。如果你不限制用户在“撤销”时会触发大量变化你的集合被反复添加。但好消息是如果事务名判断对自己的改动被撤销时也会被识别并跳过整体逻辑依然可控。4. 双事件联动从监听到处理的完整链路4.1 脏标记模式为什么需要二次调度什么叫脏标记模式说穿了就是把“变化”和“处理”解耦。DocumentChanged只负责把“脏”元素ID记下来设置一个HasPendingWork标志Idling则是“工头”每次醒来看看有没有脏标记有就开工。你可能会问既然DocumentChanged在事务提交后触发那为什么不能在里面直接处理两个原因一是不能改文档这是硬性限制二是即使只做数据计算DocumentChanged触发频率也极高。你在墙上连续拖动一个门窗会看到参数一点点变化每次变化都提交事务每次都触发DocumentChanged。如果每次触发都立刻跑一遍全量处理Revit界面必然被卡死。放在Idling里批量处理等用户停下来了再一次性处理体验天差地别。这有点类似于前端开发里的防抖节流思路。一次拖拽可能产生几十次事件但真正有价值的处理只有最后一次完成后那一次。4.2 完整代码实现收集变更加空闲处理下面给出一个可以直接抄进项目骨架的完整示例。你需要三个部分事件订阅、变更收集、Idling处理。首先是订阅部分的完整版public class App : IExternalApplication { public static UIApplication UiApp { get; private set; } public static bool IsInitialized { get; private set; } public const string PluginTxnName MyProductAutoSyncTxn; public Result OnStartup(UIControlledApplication application) { // Ribbon在这里创建 return Result.Succeeded; } public Result OnShutdown(UIControlledApplication application) { AppEvents.Unsubscribe(); return Result.Succeeded; } }然后是事件类public static class AppEvents { private static bool _isSubscribed; public static void Subscribe(UIApplication uiApp) { if (_isSubscribed) return; App.UiApp uiApp; uiApp.Idling OnIdlingEvent; uiApp.ControlledApplication.DocumentChanged OnDocumentChangedEvent; _isSubscribed true; } public static void Unsubscribe() { if (!_isSubscribed) return; if (App.UiApp ! null) { App.UiApp.Idling - OnIdlingEvent; App.UiApp.ControlledApplication.DocumentChanged - OnDocumentChangedEvent; } _isSubscribed false; } private static void OnDocumentChangedEvent(object sender, DocumentChangedEventArgs e) { // 过滤自身事务 string txnName e.GetTransactionName(); if (string.Equals(txnName, App.PluginTxnName, StringComparison.OrdinalIgnoreCase)) return; lock (ChangeTracker.LockObj) { ChangeTracker.ElementIds.UnionWith(e.GetAddedElementIds()); ChangeTracker.ElementIds.UnionWith(e.GetModifiedElementIds()); ChangeTracker.ElementIds.UnionWith(e.GetDeletedElementIds()); ChangeTracker.HasPendingWork true; } } private static void OnIdlingEvent(object sender, IdlingEventArgs e) { if (ChangeTracker.IsProcessing || !ChangeTracker.HasPendingWork) return; e.SetRaiseWithoutTransaction(); ChangeTracker.IsProcessing true; try { SyncHelper.ProcessChanges(App.UiApp.ActiveUIDocument?.Document); } catch (Exception ex) { // 生产环境务必记录日志 TaskDialog.Show(同步错误, ex.Message); } finally { ChangeTracker.IsProcessing false; } } }最后是处理部分这里展示一个“给修改过的构件更新注释参数”的示例public static class SyncHelper { public static void ProcessChanges(Document doc) { if (doc null) return; HashSetElementId snapshot; lock (ChangeTracker.LockObj) { snapshot new HashSetElementId(ChangeTracker.ElementIds); ChangeTracker.ElementIds.Clear(); ChangeTracker.HasPendingWork false; } using (Transaction t new Transaction(doc, App.PluginTxnName)) { t.Start(); foreach (ElementId id in snapshot) { Element el doc.GetElement(id); if (el null) continue; // 已被删除或无法访问 if (el is Wall || el is Floor || el is RoofBase) { Parameter p el.get_Parameter(BuiltInParameter.ALL_MODEL_COMMENTS); if (p ! null !p.IsReadOnly) { p.Set(已自动同步 - DateTime.Now.ToString(HH:mm:ss)); } } } t.Commit(); } } }如果你需要处理删除元素建议在提交事务之前先遍历snapshot里GetElement返回null的ID把它们作为“已删除”同步到外部数据库或统计表。4.3 事务嵌套与上下文处理在Idling里开启事务处理时有一个隐藏的世界难题你的Idling事件触发时也许当前文档已经在一个外部事务的包裹中其实不会Idling的设计目标就是让Revit处于空闲状态时才触发也就是文档此时不属于任何活动事务。但有一种情况要注意如果你的处理逻辑比较耗时用户在此期间又手动开始拖拽或修改模型Revit会优先执行用户的输入你的事务可能会被“搁置”。事务在Revit里是有队列机制的你创建了一个事务并Start后并不保证立刻完成。所以在事务里不要写“必须在这几秒内完成否则影响后面所有操作”的假设尽量让每个事务的处理时间控制在100毫秒级别减少被用户操作打断的概率。实操中遇到处理量大时我会把snapshot集合拆成多个小批次每个批次开一个事务提交避免长时间锁文档。这在批量修改几百个族实例时尤其明显大事务的取消、回滚、冲突概率比小事务高很多。4.4 联动中的竞态与幂等设计双事件模式里还有两个看不见的同学一个叫竞态一个叫幂等。竞态的意思是DocumentChanged在收集ID的时候可能你的Idling正在处理上一批数据。虽然Revit的事件都是主线程触发理论上是顺序执行但为了稳妥我还是会在所有共享集合上加上lock并用IsProcessing布尔标记防止重入。这样即使Revit在某些版本的行为有差异也不至于出现数据混乱。幂等设计更是必须的。你的同步逻辑要能接受“同一个元素被处理两次”或者“一个元素刚被处理完又被再次标记”的情况。判断依据不要依赖元素当前值是否已经被改过而应该直接执行一遍你的逻辑。因为Revit元素参数被设置相同值时Revit内部可能不会把它视为重复修改但你的外部数据结构可能会受影响。比如“给元素加一个标记名”如果标记已经存在重复添加会报错。所以处理逻辑里必须带上存在性判断保证重复执行不产生副作用。5. 常见问题与排查技巧实录5.1 事件不触发的排查清单双事件异常里最常见的不是代码报错而是“就是不触发”。我总结了几个排查方向按优先级排序事件是否真的注册成功。很多人把订阅写在了命令Execute里但命令每次执行都创建新实例导致事件被反复注册或注册后又被垃圾回收。正确做法是全局静态类维护注册状态例如上面代码里的_isSubscribed。是否在错误的级别上注册。Idling一定要挂在UIApplication上Application上没有。DocumentChanged则挂在Application或ControlledApplication上。搞混了编译可能不报错但运行起来什么都不会发生。是否因为事件处理里抛异常导致后续事件被中断。Revit里如果事件处理器抛出未捕获异常后续处理链条会被打断。为了稳定事件处理器最外层务必加try-catch。是否被自己的事务名过滤掉了。如果你把所有DocumentChanged都过滤掉自然不会触发。先注释掉过滤条件看看模型是否恢复触发再逐步排查。5.2 递归循环与死循环问题这个场景非常经典你在Idling里给元素改参数参数改动提交事务触发DocumentChanged你的收集器把元素又加回来HasPendingWork又变成true下一个Idling周期又处理一次。如果处理逻辑每次都会产生“新的改动”这就成了无限循环。我的解决方案有两层。第一层是靠事务名过滤这一步拦截掉自己引发的绝大多数循环第二层是增加“处理中”状态判断确保同一次批处理没有结束前不会再次进入。如果业务上确实需要“改完了再检查一遍”那也要控制检查次数上限比如最多递归3次就退出并记录日志。5.3 UI卡顿与性能问题很多时候用户反馈“装了这个插件后Revit变卡了”十有八九就是Idling处理里跑了重逻辑。你可以通过在事件处理入口和出口打印耗时日志看看单次处理耗时。如果单次超过500毫秒就得优化。优化的几个方向缩小处理范围用DocumentChanged给的ID集合不要遍历全文档。延迟非关键操作写入文件、操作数据库这类IO操作放到后台Task里做不要占用Revit主线程。当然访问Document还是得回到主线程但纯IO不需要。控制处理次数已经处理过的元素如果没再次出现在DocumentChanged里就不要反复处理。很多时候用户只是滚动了一下视图你却把所有构件又同步了一遍这显然是浪费。5.4 问题排查速查表现象可能原因处理建议Idling不触发未在UIApplication上注册注册后被GC回收用全局静态类持有事件引用检查注册级别DocumentChanged不触发注册在错误事件源文档打开的是族文件且当前没有事务检查是Application级还是Document级事件模型一改就死循环没有过滤自己的事务名所有内部事务使用固定名称并在事件里过滤CPU占用高Idling处理里做了全模型遍历或设IsStillIdletrue只处理变化元素保持IsStillIdlefalse在DocumentChanged里开事务报错撞了不可修改文档的铁律改为记录ID延迟到Idling中处理代码修改了文档但元素参数没更新批处理事务被用户操作打断回滚尽量使用小事务并检查transaction.Commit()结果6. 实战经验与设计建议6.1 事件注册的生命周期管理事件订阅最好做成“全局单例式”。不要让每个文档、每个命令都去注册一遍否则你会遇到事件被重复触发、CPU飙升、内存泄漏等一连串乱子。我习惯的做法是在IExternalApplication里只负责创建Ribbon在第一个命令执行时通过静态方法Subscribe(uiApp)初始化事件。之后不管打开多少文档事件始终在。OnShutdown时调用Unsubscribe()把事件摘掉。这里还有一个细节如果你注册的是ControlledApplication.DocumentChanged在OnShutdown时解除订阅时别忘了用之前持有的ControlledApplication引用而不是临时new一个。6.2 与ExternalEvent的选型对比遇到“后台线程需要主动唤醒Revit主线程处理文档”的需求时很多人也会想到ExternalEvent。这里我把三者和大家做个对比方便你选型机制触发来源可否修改文档典型场景IdlingRevit空闲时自动触发可以合并并处理文档变更、自动后台整理DocumentChanged任意事务提交后触发不可以记录模型变化、审计、数据同步捕获ExternalEvent任意线程显式Raise可以后台线程请求主线程执行特定操作如果你的逻辑是非要有“外部事件触发”才动手比如回调、文件监听、网络消息就用ExternalEvent。如果是纯粹“模型发生了变化我想在合适时机处理”DocumentChanged加Idling组合是最自然的选择。6.3 扩展思路从后台处理到自动化审查这套双事件模式一旦跑通可以往很多方向扩展。比如你可以把处理逻辑从“改参数”换成“合规检查”用户每改一次模型就自动收集变化元素空闲时检查这些元素的净高、间距、类型参数是否符合规范然后把违规元素ID写入一个“问题清单”面板。如果再结合ExternalEvent你可以做一个独立的后台线程不断轮询外部数据库发现有新的任务就通过ExternalEvent唤醒主线程自动改模型。这就是为什么我坚持认为学会了事件驱动你的Revit插件才能从“工具箱”升级成“自动管家”。而这一切的起点就是先把Idling和DocumentChanged这两个事件的特性摸熟。最后再分享一个个人偏好在所有事件处理器最外层都加统一的try-catch并把异常信息写入独立的日志文件。事件驱动代码一旦运行起来它是在Revit环境里“潜伏”的出了问题很难复现日志是最可靠的线索。我自己早期没加日志结果用户反馈“不稳定”“用着用着就静默失效”根本无从查起后来每个事件都落日志问题定位效率翻了几倍。