ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Revit二次开发双事件实战:DocumentChanged与Idling的完美配合

Revit二次开发双事件实战:DocumentChanged与Idling的完美配合 自从我入行做 Revit 二次开发几乎每一个带“自动”字样的插件最后都会绕到“事件”这堵墙上。早年间接手一个族库同步工具需求听起来很简单用户在 Revit 里改了某个门窗族实例的参数插件得自动把变动同步出去再顺手把相关构件更新到位。可问题在于我怎么知道用户动了哪个族实例总不能让人每次改了参数再点一次按钮。后来我把“空闲事件”和“DocumentChanged事件”这一对双事件组合在一起项目才真正跑顺。这篇东西不是来复述 API 文档的我想把这两个事件单独拆开讲透再讲它们为什么要组合、怎么组合最后把我在真实项目里踩过的坑一起抖出来。无论你是刚接触 Revit 二次开发还是正在被事件回调折磨的老兵这篇应该都能帮你省几天的排查时间。1. 为什么说双事件是Revit自动化的地基1.1 单事件方案有什么坑先聊一个最常见的设计错误只监听 DocumentChanged然后在事件回调里直接改文档。很多新手会这么做因为直觉上“文档变了 响应”是顺理成章的。但 Revit 的 API 不是这么任性的DocumentChanged 触发的时间点是事务已经提交之后此时文档其实处于一种“刚被改动、还没完全缓过来”的状态你在回调里立刻开新事务轻则报“Document is being modified”之类的错重则直接让 Revit 崩溃。反过来如果只监听 Idling 空闲事件你确实可以在里面安全地开事务、改参数、同步数据但你怎么知道用户刚才干了什么空闲事件不会告诉你哪个构件被改了、哪个构件被删了它只会在 Revit “没事干”的时候冒出来。没有数据源你每次空闲都得全文档扫描一遍性能完全不可接受。这就是单事件的死结一个知道“改了什么”但没法安全动手一个能安全动手但不知道“该动哪里”。所以聪明的做法是让两者各干各的活用消息队列的思路把它们串起来。DocumentChanged 负责采集变化Idling 负责在安全时机消费这些变化也就不难理解为什么社区里把这对组合叫“双事件”了。1.2 双事件的正确组合思路双事件的核心是“事件驱动 延迟处理”拆开看其实是一个很朴素的生产者消费者模型DocumentChanged 是生产者Revit 里任何一次事务提交它都会跑一遍我只需要把新增、修改、删除的元素 Id 记录下来存到一个内存集合里绝不在回调里做重活。Idling 是消费者Revit 空闲时触发我从内存集合里把元素 Id 捞出来在事务里做真正的数据校验、参数修改、同步逻辑。用这个模型你会天然规避掉“回调里改文档”的禁忌也能避免全图扫描的性能灾难。更重要的是这个结构是通用的。我后来做的几个插件不管是模型检查、版本迁移、还是族库联动全都直接套这个骨架只是消费者逻辑不同而已。2. 吃透空闲事件触发时机和用法2.1 空闲事件的触发原理Idling 是 UIApplication 下面的事件触发条件很简单Revit 处理完当前所有 UI 消息、处于空闲状态的时候事件就会被触发。注意它不是严格按固定时间间隔触发的更像“排队等叫号”用户不动、Revit 没收到新指令时机到了就会来一次。这时候就有一个容易混淆的细节如果你在 Idling 回调里没有提交任何事务Revit 认为“没什么可干的”下一次 Idling 就不会立刻再来。这其实很合理否则 Revit 会一直空转。但如果你的回调里开了事务比如改了某个参数Revit 会认为文档发生了新的变化Idling 就很有可能被触发新的一轮。这个特性既可爱又危险可爱的是我们可以在空闲事件里做持续性的后台任务危险的是如果不加控制很容易把自己带进死循环。2.2 SetRaiseWithoutTransaction 的作用聊到持续触发就躲不开 SetRaiseWithoutTransaction 这个方法。它的意思是即使当前没有事务提交你也希望 Revit 持续触发 Idling。什么时候会用最常见的场景是后台分片处理。比如你要对一栋高层建筑的几万个构件做合法性检查一次不可能算完也不想让界面卡住就可以在空闲事件里每次只处理一小批。如果某一轮处理没有改任何文档属性Revit 默认是不会继续触发的这时候调用e.SetRaiseWithoutTransaction(true)就是在告诉 Revit“即便我这轮没提交事务你也不要停继续给我机会处理下一批。”这里我踩过一个典型的坑某次做批量重命名工具我在每轮 Idling 里都没开事务只是往内存队列里读数据、写日志结果程序跑一半就没反应了。排查半天才发现是漏了SetRaiseWithoutTransaction(true)导致队列还没清空Revit 就不再触发 Idling 了。反过来也要提醒一句这个开关不是越多越好如果每轮回调都让它继续触发但队列早就空了Revit 就一直空转CPU 会莫名升高。2.3 空闲事件适合做什么说句实在话利用好空闲事件你的插件体验会上一个档次。它能做的事大概有这么几类延迟任务处理系统里攒了一批待办在空闲时统一消化避免打断用户操作。批量分片执行长耗时任务拆成一沓小任务每轮空闲处理一点UI 始终能响应。状态同步比如把当前模型信息定期同步到 UI 面板、状态栏或者外部数据库不需要用户手动刷新。但凡是都有限度。别在空闲事件里做重 IO、不要在回调里弹模态对话框、更不要在里面执行 DoEvents 之类的折腾。Revit 再空闲也是别人的地盘你的代码只是个租客。3. 吃透DocumentChanged事件参数和实践场景3.1 触发条件与事件参数DocumentChanged 是 ControlledApplication 下面的事件由 Application 对象触发。只要文档里有事务被提交事件就会触发包括新建元素、删除元素、修改参数这些常见行为甚至撤销和重做也算。事件回调的参数是 DocumentChangedEventArgs它给你提供的信息非常详细GetDocument()拿到发生变更的文档对象。GetAddedElementIds()新增元素的 Id 集合。GetDeletedElementIds()被删除元素的 Id 集合。GetModifiedElementIds()被修改元素的 Id 集合。GetTransactionNames()触发这次事件的所有事务名称。IsTransactioned()当前变更是否来自事务操作。OperationId可以理解成本次操作的编号用于区分一次连续操作。这套信息就像是一份“变更清单”帮我们精准定位到底哪些构件需要被处理。我经常用事务名称来过滤掉插件自身的写操作比如我自己的事务叫“族库同步-写入版本号”在 DocumentChanged 里先判断GetTransactionNames()如果只包含这个名字就直接忽略否则一改数据就触发一遍插件会自己跟自己打架。3.2 项目文档与族文档的区别做二次开发的人不能忽略一个细节DocumentChanged 事件不光在项目文档里触发在族编辑器里同样有效。这意味着如果你做了一个族库管理工具用户打开某个 .rfa 族文件做修改你的代码同样能感知到。这对族库类工具是重大利好。最常见的家具族、门窗族用户可能会在编辑器里调整参数你希望改动实时反映到族库系统里那就可以用同样的双事件逻辑把族文档的变更也收集起来。唯一的麻烦是项目文档和族文档的处理逻辑不同你需要根据document.IsFamilyDocument做分流。项目文档里可以改实例参数、同步数据族文档里则更多是改族参数和几何逻辑。3.3 DocumentChanged 最适合的场景从实际效果来看最值钱的使用场景有几个参数联动用户改了一个构件的某个共享参数你同步更新其他构件或系统数据。变更记录与审核自动记录谁在什么时候改了哪些构件方便做模型审查和追溯。实时数据同步把 Revit 里的构件状态同步到 Excel、数据库或云端平台做数字孪生或者 Precast 对接。族库自动更新模型里插入某个族后自动校验版本如果族库有新版本就提醒甚至自动替换。不过再大的能力也得有节制。DocumentChanged 承载的是“感知”不是“处理”。你如果试图在回调里做完整的数据同步比如写数据库、调用 Web APIRevit 会明显变卡用户拖一个构件都能卡半天。正确姿势永远是在这里只做轻量级记录把重活留给空闲事件去慢慢干。4. 双事件联动的完整实现4.1 搭建外部应用框架这部分我给出一个可以直接跑通的核心骨架。开发环境默认是 Visual Studio C# Revit API建议用 .NET Framework 4.8 或对应版本引用 RevitAPI.dll 和 RevitAPIUI.dll并确保“复制本地”设为 false避免把大几百兆的 API 拷到插件目录。外部应用需要实现 IExternalApplication 接口在 OnStartup 里注册两个事件在 OnShutdown 里注销。注意事件注册一定要成对出现否则 Revit 会一直留着已卸载插件的引用时间久了会拖慢整个 Revit 的性能。using System; using System.Collections.Generic; using System.Linq; using Autodesk.Revit.ApplicationServices; using Autodesk.Revit.Attributes; using Autodesk.Revit.DB; using Autodesk.Revit.UI; namespace DoubleEventDemo { public class App : IExternalApplication { private UIControlledApplication _uiApp; private ControlledApplication _ctrApp; private readonly HashSetElementId _pendingIds new HashSetElementId(); private readonly HashSetElementId _deletedIds new HashSetElementId(); private bool _isProcessing; public Result OnStartup(UIControlledApplication application) { _uiApp application; _ctrApp application.ControlledApplication; _ctrApp.DocumentChanged OnDocumentChanged; _uiApp.Idling OnIdling; return Result.Succeeded; } public Result OnShutdown(UIControlledApplication application) { _ctrApp.DocumentChanged - OnDocumentChanged; _uiApp.Idling - OnIdling; return Result.Succeeded; } } }这里我用了 HashSet 而不是 List最大的好处是自动去重。用户连续对同一个构件改三次参数咱们记录一个 Id 就够了别让队列无限膨胀。4.2 DocumentChanged里收集变更的写法OnDocumentChanged 的职责只有一个把变化的元素 Id 收集到集合里。这一步不建议做任何筛选之外的重活比如查扩展数据、写日志、弹提示统统不要。private void OnDocumentChanged(object sender, DocumentChangedEventArgs e) { Document doc e.GetDocument(); if (doc null) return; // 如果这个事务来自插件自己跳过防止自触发 IListstring transactionNames e.GetTransactionNames(); if (transactionNames.Any(name name.Contains(DoubleEventDemo))) { return; } foreach (ElementId id in e.GetAddedElementIds()) { _pendingIds.Add(id); } foreach (ElementId id in e.GetModifiedElementIds()) { _pendingIds.Add(id); } // 删除的元素不能再用 GetElement 去查单独记录 foreach (ElementId id in e.GetDeletedElementIds()) { _deletedIds.Add(id); _pendingIds.Remove(id); } }有一点要特别说明对于新增元素和修改元素我们在 Idling 里可以用doc.GetElement(id)拿回对象。但被删除的元素在事务提交后已经不存在了再去 GetElement 会拿到 null。所以我把删除的 Id 单独放一个集合方便后面做专属的删除后处理。这里的事务名称过滤很关键。因为 Idling 里执行修改时肯定也会触发 DocumentChanged如果不过滤我们的处理流程就会被自己的行为再次激活形成虚假的“变化数据”。虽然用标志位也能绕开部分问题但从源头上把插件自己的事务排除掉逻辑最清爽。4.3 Idling里统一处理的写法Idling 回调是双重保险既要确保当前有活动文档又要防止回调重入和队列为空时白跑一圈。我习惯这样写private void OnIdling(object sender, IdlingEventArgs e) { if (_pendingIds.Count 0 _deletedIds.Count 0) return; UIApplication uiApp sender as UIApplication; Document doc uiApp?.ActiveUIDocument?.Document; if (doc null) { // 没有活动文档但还有积压任务希望后续继续触发 e.SetRaiseWithoutTransaction(true); return; } if (_isProcessing) return; _isProcessing true; try { ListElementId pending new ListElementId(_pendingIds); ListElementId deleted new ListElementId(_deletedIds); _pendingIds.Clear(); _deletedIds.Clear(); using (Transaction trans new Transaction(doc, DoubleEventDemo-Sync)) { trans.Start(); foreach (ElementId id in pending) { Element element doc.GetElement(id); if (element null) continue; // 在这里写你的核心处理逻辑 ProcessElement(doc, element); } foreach (ElementId id in deleted) { ProcessDeletedElement(id); } trans.Commit(); } } catch (Exception ex) { TaskDialog.Show(DoubleEventDemo, ex.Message); } finally { _isProcessing false; } }整段代码的精髓在于先把待处理集合取出来并清空再统一处理。这样即使处理过程中 DocumentChanged 又被触发新产生的变更会进入下一轮 Idling不会跟当前轮次的数据混在一起。如果你省略了“先取出、后清空”这一步而是边遍历边清空或者遍历完再清空数据一致性会变得很难维护。4.4 防止死循环和重复处理的措施双事件模式里最容易翻车的就是死循环我列三个高频场景场景一处理逻辑本身会修改元素参数修改后触发 DocumentChanged新参数跟旧参数可能不一样于是又进入下一轮处理。比如你给构件写入一个“是否符合规范”的参数构件变化后再次触发又检查一遍写一遍无穷无尽。场景二处理逻辑会创建新元素新增元素又被收集进队列下一轮 Idling 再次处理如果处理逻辑没有幂等性就是无限复制。场景三DocumentChanged 过滤事务名称没做或做错了插件自己的事务也被当成外部变化循环触发。解决办法首先是在事务名称上做隔离其次是在业务逻辑里保证幂等。比如写入参数前先比对当前值如果已经是指定的目标值就直接跳过不写任何东西。如果必须新增元素先检查是否已经存在同类标记别闭着眼睛往里塞。必要的时候再加一个限流计数器比如同一批元素在一分钟内最多处理两次反正不能让插件在后台空转到停不下来。4.5 触发频率与实际项目中的降频策略做过大型项目的人都知道一个真实模型动辄几万个构件用户在 Revit 里连续创建一个楼层可能产生上百次事务DocumentChanged 也会跟着触发上百次。如果每次都往队列里塞一大堆 Id空闲事件处理不及时UI 还是会卡。我常用的降频策略有两招。第一招是时间窗口合并在 DocumentChanged 里不马上加入集合而是记录“最后一次变更时间”真正写集合的动作放到一个定时检查里比如用户连续操作超过 500 毫秒后再批量记录。这需要额外写一个定时器或者结合 Idling 判断时间差。第二招是处理时的批量阈值每次 Idling 只处理最多 500 个元素剩下的下一轮再处理避免单个事务体量过大拖垮 Revit。其实双事件模型本身已经是一种天然降频。DocumentChanged 再频繁也只是往 HashSet 里塞 Id真正吃性能的重活被压缩到 Idling 中统一执行。只要不在 DocumentChanged 里做计算和 IO整体压力是完全可控的。5. 常见问题与排查技巧实录5.1 事件注册不生效这是我被问得最多的问题。明明照抄了代码但改了 Revit 文档插件毫无反应。原因大多数出在三个地方。一是你注册的是 Application.DocumentChanged不是 UIApplication.DocumentChanged。Application 级别的注册需要在外部应用里拿到 ControlledApplication 后再挂事件。二是你把代码写在了一个外部命令ExternalCommand里等命令执行完局部变量和事件都释放了自然不会持续触发。三是你在 OnShutdown 里忘了注销或者多个版本插件同时存在事件被重复注册等调试的时候已经分不清是哪个实例在响应。建议方案调试阶段可以在 DocumentChanged 回调里写一个日志文件的输出用File.AppendAllText记录每次触发时间这样注册是否生效一目了然。5.2 在DocumentChanged里开事务报错这个坑我非常熟悉最早做族库同步时就在这栽过跟头。你在 DocumentChanged 回调里执行new Transaction(doc)或者doc.Regenerate()运气好是抛异常运气不好就是死锁或者让 Revit 直接退出。为什么因为 DocumentChanged 是事务提交后的通知事件此时 Revit 还处于内部状态机的敏感阶段不允许再次进行写操作。你非要在里面写东西等于在人家刚洗完碗还没擦手的时候硬塞给他一块肥皂。解决办法就是前面反复强调的在 DocumentChanged 里只收集信息把开事务的动作推迟到 Idling 回调里进行。5.3 事件回调导致UI卡死有一种很隐蔽的卡死场景你在 Idling 里处理队列时每次处理都调用了TaskDialog.Show弹窗。弹窗会阻塞良好用户没点确定之前Revit 一直处于等待状态而空闲事件又无法继续触发队列越积越多看起来就像程序死机了。处理长耗时任务时建议把反馈信息集中到状态栏、内置的进度对话框或者外部日志里尽量避免在事件回调里弹阻塞式窗口。如果非要提示用户可以设置一个“待反馈队列”在 Idling 的某一轮空闲时统一弹一次同时用互斥标志避免弹窗重入不然 Revit 会同时弹十几个对话框直接把用户吓跑。5.4 明明改了图却没触发事件有时候用户修改了某个图元但 DocumentChanged 里的 modified 集合根本没有这个 Id。不是说 API 骗你而是有些操作不会产生变更事务比如视图缩放、纯视觉切换、临时捕捉标记这类操作本来就不算“文档变更”。还有一种情况是用户改了族实例的某个参数但参数类型是“实例参数”而你在过滤条件里不小心把某些系统类别排除了比如BuiltInCategory.OST_Views、OST_HatchPatterns等造成漏数据。排查这种问题我一般会在第一次 Demo 里先把三个集合added、modified、deleted全部记录下来人工做一次操作看日志里到底汇报了什么再做减法。5.5 调试技巧用日志代替断点Revit 二次开发里打断点最坑断点命中时会把 Revit 主线程挂住一挂就是好几分钟然后各种超时问题就出来了。所以我在做事件相关开发时强烈建议用日志驱动调试。写日志时别只记录时间和 Id最好把事务名称、当前文档标题、IsTransactioned 的值一并记录。很多时候你发现事件没触发并不是真的没触发而是事务名称被过滤干掉或者文档是后台文档这些细枝末节从日志里一眼就能看出来。日志文件建议放在%LocalAppData%\Revit\Autodesk\Revit 202X目录下和 Revit 自己的日志放一起排查时顺手很多。我做一个临时速查表方便你以后直接对着排查现象可能原因解决办法DocumentChanged 不触发事件挂在错误的对象上用 ControlledApplication 挂事件回调里开事务报错在事件回调中做写操作把写操作移到 Idling插件自动运行时不断循环没有过滤自身事务通过事务名称排除插件事务Idling 只跑一次就不跑了没有调用 SetRaiseWithoutTransaction在需要持续处理时调用该方法UI 卡顿严重回调里做了重 IO 或弹窗轻量化回调异步处理耗时报队列数据莫名重复用 List 没去重改用 HashSet 存储6. 这套模式还能用到哪里6.1 族库同步与家具族更新回到开头那个族库同步项目双事件的落点非常清晰。用户从族库拖一个共享家具族进项目DocumentChanged 马上能抓到新增的族实例如果用户在项目里改了实例尺寸modified 集合会出现对应 Id如果用户删了构件deleted 集合也能捕捉到。空闲事件里再去更新数据库、比对族库最新版本甚至生成导出记录整个流程变成了一个完全自动化的后台流水线。我后来在家族库管理工具里还加了版本检测当检测到新增族实例后插件会读取族文件的版本号参数如果低于族库最新版会在空闲事件里生成一个高亮提醒不会打扰用户当前操作但会通过状态栏给出提示。当时就是靠 DocumentChanged 收集 Idling 延迟处理的套路把体验做流畅的。6.2 模型检查与自动批注模型协同审查也是双事件的绝佳应用场景。比如设计规范要求所有门洞都要有净高检查传统做法是一条命令跑全模型但用户希望在绘图过程中就发现违规。那你可以在 DocumentChanged 里收集新增或修改的门窗和墙构件Idling 里做规则检查把不符合规范的构件用直接形状标注出来或者写入一个“检查状态”共享参数。这样做的好处是用户一边画图后台一边检查几秒钟后状态栏就会提示有多少构件需要修正整个过程完全不需要用户主动跑命令。对大型项目的协同场景来说这种“无声的守护”远比事后批处理更能提高效率。6.3 版本转换与迁移工具中的自动化很多人做版本转换工具时最怕的就是用户把高版本模型转成低版本时某些图元细节需要重新处理比如替换不可用的族类型、清理临时几何。双事件在这里也能派上用场打开低版本文档后插件在 DocumentChanged 里监控有没有发生“替换族”这样的操作Idling 里再批量清理旧版本遗留的不可用对象和报错信息。配合 Revit 的 TransmissionData 机制可以做一个比较完整的模型迁移闭环保洁。我自己做过一个工具把高版本模型降版保存后通过 DocumentChanged 判断哪些构件发生了降级替换再在 Idling 里修正材质和连接状态最终交付给结构工程师时模型基本不用二次返工。6.4 与其他二次开发工具的衔接双事件这东西天然适合做“中间件”。比如你有一个基于其他平台的插件需要感知 Revit 的实时变化完全可以通过 DocumentChanged 把变更发到内存队列由 Idling 定期写进某个共享文件或者数据库那边再轮询或者监听通知。这样一来你根本不需要深度绑定 Revit 的 API只需要让外部系统订阅一个“Revit 变更事件流”就行。我之前把一套原型工具接到 BIM 管理平台时就是让 Revit 侧的插件通过双事件实时推送构件变更状态平台那边只需要处理标准化的 JSON 消息两边各干各的耦合度低到可以忽略。写在最后的一点体会双事件组合看起来只是两个 API 的叠加但真正吃透它需要理解 Revit 的事件时机、事务边界和 UI 空闲模型。我做了这么多年二次开发越来越觉得很多“高级功能”不是靠某个黑魔法 API 实现的而是靠把最普通的事件、事务、集合这些基础组件搭出一个靠谱的架构。DocumentChanged 负责“听声辨位”Idling 负责“安全动手”这俩搭好Revit 里的自动化难题基本就解决了一大半。如果你正打算写一个需要感知模型变化的工具我的建议是别急着堆功能先把双事件骨架跑通用一个简单的日志输出验证事件触发时机准确无误再往里面添业务逻辑。地基稳了后面怎么建楼都踏实。
返回列表