ARTICLE DETAIL

资讯详情

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

Revit二次开发:Idling与DocumentChanged事件组合实现自动更新

Revit二次开发:Idling与DocumentChanged事件组合实现自动更新 1. 为什么自动更新引擎必须由这对事件组合支撑去年做的一个机电出图插件卡了我将近一周的问题不是几何算法也不是参数绑定而是“插件根本不知道模型什么时候变了”。当时我的做法很直接在命令里点击一次按钮插件就全量刷新一次视图。结果项目上真正的使用场景是——建模师在属性面板里批量改了几十根风管的高度图纸却还是旧数据直到他们手动点了一次刷新按钮才更新。这种体验用一次就嫌弃了。Revit二次开发里要解决“模型变了插件自动跟上”这个问题绕不开一对黄金组合空闲事件Idling和DocumentChanged事件。这两个事件几乎总是一起出现做Revit插件自动更新机制时它们就像人的眼睛和手DocumentChanged负责“看见”文档里发生了什么变化Idling负责在安全时机“动手”处理这些变化。单独用其中一个都会出问题。别小看这对组合它涉及的不只是事件注册和回调函数还有Revit的事务模型、UI线程调度、事件触发风暴这些底层机制。这篇文章把我实际搭建这套机制时的完整思路、代码骨架和踩坑记录都摊开讲适合刚接触Revit二次开发、准备给自己的工具加上自动更新能力的开发者参考。1.1 单独使用任意一个事件的困境先说一个很多新手容易踩的思维误区既然DocumentChanged事件能感知文档变化为什么不能直接在事件回调里更新模型、刷新视图因为DocumentChanged事件触发的时候文档往往刚结束一个事务Revit内部还处于一个微妙的“不稳定窗口期”。这时候你去拿Transaction去改文档很容易碰到“文档正忙”的异常。更要命的是一次用户操作可能触发多次DocumentChanged如果你在事件里做重计算Revit界面会卡到没法用。反过来如果只用Idling事件定期去检查文档有没有变化你连“怎么判断模型变了”这个问题都很难解决。Revit没有提供一个公开的、像Git一样可以对比文档版本的接口。你可以给每个元素记录参数快照然后逐个比对但这种做法一是慢二是逻辑复杂三是很难覆盖所有变更类型——比如元素被删除了、元素被复制了、族类型参数变化了这些变更类型各不相同你要自己维护一套全量的快照机制工作量太大了。所以分工就变得很自然DocumentChanged负责精确告诉你什么变了、哪些元素变了、是哪个事务造成的Idling负责在Revit真正空闲、文档状态稳定的时候统一消费这些变更信息再执行你的更新逻辑。两者配合既不会漏掉变化也不会在错误的时机动手。1.2 两个事件的事务边界差异理解这对组合的关键是理解Revit的事务边界。Revit把模型修改封装在事务里一个事务从Start到Commit这个过程中其他代码不能插入新的修改否则会报“不能在此上下文中进行修改”之类的错误。Idling事件触发的时候通常处于没有任何外部命令在执行、没有事务在进行的空闲状态所以它是少有的、可以在事件回调里安全开启新事务的时机之一。但这不代表Idling里可以无限地为所欲为。它有个微妙的点即便事件触发了Revit底层也可能还在处理一些内部命令的收尾工作。所以稳妥的做法是在Idling里开事务之前先检查文档状态再做操作并且要把整个处理逻辑包进try-catch捕获一切可能的异常。我在后面第4章的代码里就用了这个思路。DocumentChanged的时机则更微妙它是在事务提交完成后立即触发的。这个时机太“热”了Revit还没真正歇下来所以你几乎不应该在这个事件里直接开事务去写文档。正确做法是把变更信息记录下来交给Idling去执行。这就是典型的“感知与执行分离”模式。2. 空闲事件触发时机、注册方式与执行边界2.1 注册位置与生命周期管理Idling事件有多个版本ControlledApplication.Idling、UIApplication.Idling还有一个Application.Idling。我在项目中通常使用UIApplication.Idling原因很简单它能直接拿到当前活跃的UIDocument方便在回调里判断当前打开的是哪个文档、是否需要处理界面刷新、能否访问DockablePane等UI元素。生命周期管理方面推荐在App.OnStartup里注册在App.OnShutdown里注销。很多新手会忽略OnShutdown里的注销操作结果插件卸载后事件没有断开导致Revit崩溃或者残留回调重复触发。这一点第5章还会细讲。public class App : IExternalApplication { public Result OnStartup(UIControlledApplication application) { application.ControlledApplication.DocumentOpened OnDocumentOpened; _uiApp new UIApplication(application); _uiApp.Idling OnIdling; _uiApp.DocumentChanged OnDocumentChanged; return Result.Succeeded; } public Result OnShutdown(UIControlledApplication application) { if (_uiApp ! null) { _uiApp.Idling - OnIdling; _uiApp.DocumentChanged - OnDocumentChanged; } return Result.Succeeded; } }注册事件时有一个值得注意的细节UIApplication对象不能随手创建一个用完就扔事件会绑定在它内部的原生对象上。如果注册事件后你把这个UIApplication包装类丢掉了事件仍然挂在Revit原生的Application对象上后面想解绑就找不到原来的引用了所以一定要把它缓存为类级别的字段。2.2 触发时机与SetRaiseWithoutTransaction的坑Idling事件的触发策略是“没有事务就停有事务再触发”。这是什么意思Revit只有在检测到一次事务发生之后才会在接下来的空闲时刻触发Idling事件触发一次之后如果后续没有新事务它就不会再自动触发。这对做轮询式操作的人来说是个大坑——你以为每帧空闲都在执行其实只执行了一次。如果你想在某个任务没有新事务介入的情况下继续触发Idling必须调用IdlingEventArgs.SetRaiseWithoutTransaction()。这个方法会告诉Revit这次空闲事件处理完之后把“需要再次触发”的标记置位这样下一个空闲时刻还会继续进入你的回调。我在双事件联动方案里会用到这个方法当变更队列里还有未处理完的任务时我就在Idling回调里调用SetRaiseWithoutTransaction()告诉Revit队列还没清空请继续触发我如果队列已经清空就不调用让Idling安静地停下来避免无谓的空转消耗CPU。这个细节对性能影响非常大新手往往忽略。2.3 Idling里能做什么、不能做什么很多文档对Idling事件的定位描述得很含糊我根据自己的使用经验总结了一张边界清单可以在Idling回调中做不建议在Idling回调中做读取模型参数、几何体立即模态弹窗可能导致对话框打不开或关闭不了执行与文档无关的计算、数据聚合同步调用需要长时间等待的IO操作在确认文档状态安全后开启事务改模型假定文档一定处于可编辑状态更新DockablePane、状态栏把整个模型的重生成逻辑全塞进去调用外部事件(ExternalEvent)排队命令处理一次就执行上百个元素的复杂重构理论上Idling事件内可以直接开启事务但实际运行时它受很多条件约束。比如你刚结束一个外部命令Revit内部可能还没把命令的后续操作收尾完此时Idling虽然触发了但你一开事务就会抛异常。所以“允许开事务”和“一定能开事务”是两回事。我的习惯是在Idling回调里先判断doc.IsReadOnly、doc.IsFamilyDocument、当前有没有激活的对话框再做后续操作所有异常统一捕获。3. DocumentChanged事件感知模型变化的正确打开方式3.1 事件参数与变更集合DocumentChanged事件在文档完成更新后触发它的核心信息都封装在DocumentChangedEventArgs里。这个参数类提供了三个最常用的方法也是整个联动方案的起点GetAddedElementIds()返回本次更新中新增的元素ID集合GetModifiedElementIds()返回本次更新中被修改的元素ID集合GetDeletedElementIds()返回本次更新中被删除的元素ID集合GetTransactionNames()返回引发本次更新的事务名称集合GetOriginalTransactionNames()返回事务在撤销/重做前的事务名称做撤销处理时很有用private void OnDocumentChanged(object sender, DocumentChangedEventArgs e) { var added e.GetAddedElementIds(); var modified e.GetModifiedElementIds(); var deleted e.GetDeletedElementIds(); var txnNames e.GetTransactionNames(); if (added.Count 0 modified.Count 0 deleted.Count 0) return; Debug.WriteLine($变更文档: {e.Document.Title}); Debug.WriteLine($新增元素: {added.Count}, 修改元素: {modified.Count}, 删除元素: {deleted.Count}); foreach (var name in txnNames) Debug.WriteLine($事务: {name}); }有一个很容易误导人的细节GetModifiedElementIds()并不只包含几何被修改的元素参数值变化、属性变化也会导致元素被标记为Modified。比如你改了墙体类型里的一条类型属性项目里所有该类型的墙实例可能都会被返回为Modified。这在联动刷新场景下可能带来大量无意义的计算需要额外做类型过滤。3.2 用隔离方式收集变更避免事件风暴文档变更非常频繁尤其是建模师在属性面板里连续修改参数时一次操作可能触发多次DocumentChanged事件。如果每次触发你都在事件回调里同步处理全部变更逻辑UI线程会被拖死。我在项目中采用的模式是DocumentChanged只负责把变更信息“入队”不处理实际业务逻辑。队列使用ConcurrentQueue避免并发问题。事件回调执行得足够轻量这样就天然地把高频率的“感知”和低频的“执行”隔离开了。public class ChangeRecord { public Document Document { get; set; } public ICollectionElementId AddedIds { get; set; } public ICollectionElementId ModifiedIds { get; set; } public ICollectionElementId DeletedIds { get; set; } public IListstring TransactionNames { get; set; } }当用户修改了十根管道可能触发一次或多次DocumentChanged每次触发都往队列里塞一条变更记录。Idling事件在合适的时候把这些记录取出来合并处理而不是每来一条就处理一条。这样即使用户操作再频繁你的核心逻辑始终以批量方式执行压力就完全可控了。3.3 事务名称过滤让插件不响应自己造成的变化使用这个方案后你会立刻遇到一个新问题插件在Idling里执行更新时必然会开启新事务去修改模型这个新事务又会触发一次DocumentChanged然后变更记录又入队Idling再次处理再次触发DocumentChanged——死循环解决这个问题的利器是事务名称过滤。你在执行插件自己的写操作时给事务起一个固定的、辨识度极高的名字比如MyPlugin.ProcessChanges然后在DocumentChanged回调里检查事务名称如果发现当前变更包含自己的事务名直接跳过不再入队。private const string UpdateTransactionName MyPlugin.ProcessChanges; private void OnDocumentChanged(object sender, DocumentChangedEventArgs e) { var txnNames e.GetTransactionNames(); foreach (var name in txnNames) { if (name.Equals(UpdateTransactionName, StringComparison.OrdinalIgnoreCase)) return; } // 继续收集变更 }这里有一个实战细节一个事务引发的变更在DocumentChanged事件里看到的TransactionNames可能不止一个因为Revit的DocumentChanged事件是基于“提交操作”触发的而一个提交操作可能包含多个事务。所以要遍历全部事务名只要命中自己的固定事务名就返回千万别只比对第一个。除了事务名过滤我还会加一道互斥锁用一个布尔变量_isProcessingChanges标记当前是否正在执行自己的更新流程。如果为true即使有其他未过滤的变更进来也暂时不入队这样双保险。4. 双事件联动一套稳定的“感知—消费—执行”编排方案4.1 数据流与核心流程把前两章的内容整合起来就形成了一套双事件联动方案的数据流用户或外部命令修改Revit模型 → 事务提交 → DocumentChanged触发 → 插件把变更ID和事务名打包成ChangeRecord压入队列 → Revit进入空闲 → Idling触发 → 插件检查队列非空后批量取出所有记录 → 开启一个统一事务执行更新逻辑 → 提交事务 → 如果队列还有剩余或需要继续处理调用SetRaiseWithoutTransaction请求下一次Idling。这个流程的好处有两个第一DocumentChanged回调只做入队操作必然轻快不会拖沓Revit的更新响应第二所有实际的文档写入集中在一个Idling回调里事务被统一管理事务数量可控不会因为模型频繁变化而产生大量碎事务撤销/重做也更友好。4.2 可直接落地的代码骨架下面是一套可以拿来改改就用的C#代码骨架。它把队列管理、事件绑定、变更收集、Idling处理串成了一个完整的类using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using Autodesk.Revit.ApplicationServices; using Autodesk.Revit.DB; using Autodesk.Revit.UI; public class DocumentChangeProcessor : IDisposable { private readonly UIApplication _uiApp; private readonly ConcurrentQueueChangeRecord _changeQueue new ConcurrentQueueChangeRecord(); private bool _isProcessingChanges; private const string UpdateTransactionName MyPlugin.ProcessChanges; public DocumentChangeProcessor(UIApplication uiApp) { _uiApp uiApp; _uiApp.DocumentChanged OnDocumentChanged; _uiApp.Idling OnIdling; } public void Dispose() { _uiApp.DocumentChanged - OnDocumentChanged; _uiApp.Idling - OnIdling; } private void OnDocumentChanged(object sender, DocumentChangedEventArgs e) { // 过滤自己产生的事务防止事件死循环 var txnNames e.GetTransactionNames(); foreach (var name in txnNames) { if (name.Equals(UpdateTransactionName, StringComparison.OrdinalIgnoreCase)) return; } if (_isProcessingChanges) return; var addedIds e.GetAddedElementIds(); var modifiedIds e.GetModifiedElementIds(); var deletedIds e.GetDeletedElementIds(); if (addedIds.Count 0 modifiedIds.Count 0 deletedIds.Count 0) return; _changeQueue.Enqueue(new ChangeRecord { Document e.Document, AddedIds addedIds, ModifiedIds modifiedIds, DeletedIds deletedIds, TransactionNames txnNames.ToList() }); } private void OnIdling(object sender, IdlingEventArgs e) { if (_changeQueue.IsEmpty) return; if (_isProcessingChanges) return; Document doc _uiApp.ActiveUIDocument?.Document; if (doc null) return; _isProcessingChanges true; try { // 统一处理所有排队中的变更记录只处理当前活动文档的记录 var recordsToProcess new ListChangeRecord(); while (_changeQueue.TryDequeue(out var record)) { if (record.Document.Equals(doc)) recordsToProcess.Add(record); } if (recordsToProcess.Count 0) return; using (var trans new Transaction(doc, UpdateTransactionName)) { if (trans.Start() ! TransactionStatus.Started) return; try { foreach (var record in recordsToProcess) { ProcessChange(doc, record); } trans.Commit(); } catch { trans.RollBack(); throw; } } } catch (Exception ex) { // 这里的日志系统很重要后面会展开讲 Logger.Log(ex); } finally { _isProcessingChanges false; } if (!_changeQueue.IsEmpty) { e.SetRaiseWithoutTransaction(); } } private void ProcessChange(Document doc, ChangeRecord record) { // 在这里实现你的业务逻辑例如刷新某个视图、同步参数、更新明细表 foreach (var id in record.ModifiedIds) { var elem doc.GetElement(id); if (elem ! null) { // 处理修改元素 } } } }这个骨架有几个关键点要特别说明事务名称MyPlugin.ProcessChanges必须固定。这个名称不仅用于过滤还便于你在撤销/重做历史里一眼看出哪些步骤是插件干的。trans.RollBack()在异常时把整个批量操作回滚避免一个元素处理失败导致半截更新留下数据不一致的状态。SetRaiseWithoutTransaction()只在队列还有记录时调用。如果队列已经空了就让Idling事件彻底休眠节省不必要的CPU轮询。4.3 文档状态检查与多文档排队策略生产环境里用户很可能同时打开着项目文档和族文档甚至开着多个项目窗口。DocumentChanged事件可能记录的是后台非活动文档的变更而Idling回调里你拿到的ActiveUIDocument可能指向另一个文档。如果把非活动文档的变更直接丢弃切回那个文档时数据就不同步了。一个可行的策略是如果变更记录指向的文档当前不是活动文档就把记录重新放回队列等用户切到那个文档后再处理。改一下上面的代码就行var doc _uiApp.ActiveUIDocument?.Document; var deferredRecords new ListChangeRecord(); while (_changeQueue.TryDequeue(out var record)) { if (record.Document.Equals(doc)) recordsToProcess.Add(record); else deferredRecords.Add(record); } // 处理完当前文档的记录后把暂存记录重新放回队列头部 foreach (var record in deferredRecords) _changeQueue.Enqueue(record);不过要注意重新入队后这些记录会排到队尾。如果队里的变更记录一直很多非活动文档的更新可能被一直延后。所以更稳妥的方案是按文档ID分组为每个打开的文档单独维护一个队列。这个复杂度会高一些但项目文档和族文档的更新逻辑通常完全不同分开维护反而更清晰。我建议在达到一定规模后尽早切换到按文档分队列的设计否则后期改造成本更高。5. 实战中躲不开的坑与排查思路5.1 场景复现在Idling里直接开事务报“无法启动事务”我在前期做验证时把业务逻辑直接写在DocumentChanged回调里运行到transaction.Start()时永远抛异常。后续又尝试把全部逻辑放到Idling里结果某些时机还是偶发异常。排查过程是这样的首先在异常处打了断点发现抛错的位置是Transaction.Start()。查看调用栈异常源头指向Revit内部的一个InvalidOperationException提示当前上下文中无法启动事务。当时我的第一反应是“Idling事件里不是允许开事务吗为什么不行”。进一步排查后我意识到虽然Idling触发时大部分时间处于空闲态但有个例外——如果用户正在拖动一个可停靠窗口或者某个外部命令正好在收尾阶段Revit底层窗口消息循环仍然在忙碌此时Idling回调进入的上下文并不完全安全。换句话说“没有正在执行的事务”和“文档处于完全可编辑状态”是两个不同层级的状态。问题的解法不是不开事务而是增加前置状态校验并且把真正的写入操作延迟到确认安全之后再执行。最终我采用的是文章中这套代码骨架先检查ActiveUIDocument是否为null、文档是否只读、是否处于族编辑模式再开启事务并把所有异常捕获后记录日志。经过这个处理偶发异常基本消失。5.2 事件风暴修改一次模型插件循环处理十几次这个问题在早期原型里非常典型插件更新模型 → 更新事务触发DocumentChanged → 插件感知变更 → Idling又跑一次更新逻辑 → 更新逻辑再次触发DocumentChanged形成无限循环。界面的表现是Revit的CPU占用率持续100%操作卡顿任务管理器里能明显看到Revit进程在疯狂运转。排查时我在DocumentChanged回调里打印了所有事务名称发现日志里反复出现自己UpdateTransactionName确认死循环路径就是自己触发的。修复方案有两层第一层是事务名称过滤见到自己的事务名直接跳过不收集变更。这是最基础的防线。第二层是_isProcessingChanges互斥锁处理自己的批次时不收集任何变更。如果第一层因为某个细节漏了名字第二层还能拦住。实际项目中两层都加上基本可以杜绝事件风暴。5.3 打开文档时的初始变更风暴另一个几乎必踩的坑是打开文档的瞬间DocumentChanged事件会以极高的频率触发而且其中包含大量你本不该关心的“初始变更”。原因是在文档加载过程中Revit内部会逐步生成族实例、初始化视图、设置参数这些操作每个都可能触发DocumentChanged。如果此时队列被全部塞满Idling还没来得及处理新记录又源源不断地进来界面直接卡死在加载阶段。排查时我在DocumentChanged回调里打印了触发时间发现打开一个大型MEP项目的前两秒内这个事件竟然被触发了上千次。这时候去处理这些变更没有任何意义因为用户还没有产生任何有意识的模型修改。解决策略是让插件在文档完全打开之后再开始监听变更。具体做法是用DocumentOpened事件作为启动信号在DocumentOpened事件里给该文档延迟注册DocumentChanged的收集逻辑或者在DocumentChanged回调里检查文档的IsModifiable、文档是否已经处于“完成加载”的状态——不过Revit API里没有一个公开的“文档加载完成”属性最实用的还是延迟注册。也可以在DocumentChanged回调里判断e.Document.IsFamilyDocument对族文档和项目文档分别定义不同的收集策略。5.4 插件卸载后事件残留这个坑可能不会马上暴露但一旦暴露就非常恶心。开发调试时你改了代码重新编译第一次加载的插件实例没有正确解绑事件旧的插件DLL被Revit锁住新代码无法加载或者更严重的是插件从Revit插件目录移除后事件仍挂在Revit原生的Application对象上每次有文档变化时之前绑定的回调还是会触发而且因为DLL已被释放直接抛异常。排查时我试着在OnShutdown里加断点结果发现插件是通过“手动卸载”的方式移除的OnShutdown根本没被调用。后来我查了Revit的插件加载机制才知道工具里点“卸载”只是把一个外部应用从当前会话移除但Revit内部的事件分发器上仍然可能持有旧实例的委托引用。如果你没有在Dispose或者OnShutdown里显式地解绑事件这些委托会继续存活。建议在开发调试期就养成好习惯所有事件绑定的解绑逻辑统一放在一个Dispose()方法里OnShutdown调用它插件的入口类实现IDisposable以便随时清理。另外正常卸载插件之后如果还在调试尽量重启一次Revit再继续避免残留状态干扰判断。5.5 问题速查表现象可能原因解决方向在事件回调里开事务报异常触发时机不安全文档处于忙碌状态增加文档状态检查延迟到Idling中处理CPU占用率持续100%插件自己的更新事务触发事件死循环事务名称过滤 互斥锁打开大型文档卡死DocumentChanged初始事件风暴使用DocumentOpened事件延迟注册监听插件卸载后仍触发回调事件未在OnShutdown中解绑统一封装事件绑定Dispose时解绑Idling只触发一次就不再执行没有调用SetRaiseWithoutTransaction有未完成任务时调用该方法请求继续触发不同文档的变更被错误处理未区分变更来源文档按文档ID分组维护队列6. 性能优化与真实项目中的取舍6.1 用变更队列做批量合并上一章的代码骨架已经体现了批量合并的思想但实际项目中还有一个可以优化得更彻底的地方如果用户在10秒内连续修改了同一批元素你的队列里可能存在多条包含相同ElementId的变更记录。Idling处理时如果对每条记录都执行一次完整计算等于重复计算了好几遍。我的做法是在入队时做一次轻量合并检查如果新记录的TransactionNames和队尾记录的完全一致且共享的ElementId集合高度重叠就直接合并成一条记录取并集。这能把大量碎变更压缩为一条有效记录大幅降低实际的计算次数。这种做法的前提是你的更新逻辑在输入元素集合合并后依然能得到正确的最终结果。如果你的更新逻辑对“中间状态”有依赖比如必须先处理删除再处理新增就不能随便合并需要先定义清楚幂等语义。6.2 大任务切片让空闲事件不拖垮界面即使有队列合并某些更新任务依然很重。比如一个模型中有2万个管道构件你需要在Idling里同步它们的系统缩写参数。如果在一个Idling回调里循环处理这2万个元素Revit界面会进入明显的未响应状态因为Idling回调运行在UI线程上耗时逻辑会阻塞窗口消息循环。处理办法是把大任务拆成多个小批次。每次Idling最多处理500个元素处理完一批后调用SetRaiseWithoutTransaction请求下一个空闲时段继续处理下一批。这样虽然总耗时没变但Revit界面始终能保持响应用户不会觉得插件把Revit卡死了。切片还有一个额外的好处你可以顺便判断“这一批处理完队列里是否又来了新的变更”如果有优先处理新变更旧的批次可以稍后继续。这里其实有一个取舍如果每次都优先处理新变更旧的大任务可能被无限延后导致更新永远追不上用户的操作。实际调优时我会设置一个最大延迟阈值比如某个大任务如果排了超过5秒还没有完成就暂停接收新变更先把这个任务收尾。6.3 显著影响调试效率的日志设计最后说一个真实项目中最容易被忽视、但影响最大的部分——日志。双事件联动涉及异步的、事件驱动的流程你很难通过单步调试去复现事件触发的时序关系。我在项目中维护了一套两级的日志系统第一级是每次事件触发的摘要日志包括事件类型、文档标题、事务名称、元素数量、当前队列长度。这级日志用来判断事件是否正常触发、是否出现事件风暴、队列是否积压。第二级是业务处理日志记录每次Idling批处理的实际耗时、处理了多少元素、是否有异常。两级日志配合基本一眼就能定位是事件层出了问题还是业务层太慢。还有一个实用技巧在调试阶段可以加一个“事件开关”的配置项允许你临时禁用DocumentChanged监听只用Idling配合按钮手动触发。这个开关在排查“到底是事件没触发还是触发了但没处理”时非常高效省去了一轮轮重编译的麻烦。实际使用这套机制跑了几个项目后我的最大体会是写业务逻辑的难度远小于管理事件和事务的时序。把事件生命周期、队列、互斥、日志这套基础设施做扎实了后面叠加任何自动更新功能都会非常省心否则每加一个新功能都要重新排查一遍“为什么没触发”和“为什么触发太多次”。
返回列表