2.3 Operator状态机四重契约


2.3 Operator 状态机四重契约

本节摘要:Operator 是 Blender 中唯一被内核赋予完整事务能力的对象,它的四个方法构成契约闭环——poll 是准入契约、invoke 是交互契约、execute 是执行契约、draw 是界面契约。本节逐个拆解四份"合同条款",讲清模态操作与状态码的语义,以及重做机制为什么要求 execute 幂等。读懂这四重契约,你的插件就能无缝接入撤销、重做、快捷键、宏录制这些系统级能力。

上手前先明确

阅读完本节,你应当能够:

  1. 写出四个方法各自的正确签名与返回值语义;
  2. 解释重做机制为什么要求 execute 幂等且无外部副作用;
  3. 实现一个基本的模态 Operator(含状态快照与退出清理);
  4. 判断一个功能该用普通 Operator 还是模态 Operator。

一、为什么说 Operator 是状态机

把 Operator 理解成"按钮的回调"是最危险的误读。它真正封装的,是一次用户意图从萌发到落地的完整过程:先要判断当前环境允不允许(poll),再决定以什么方式响应原始输入(invoke),然后执行确定性计算(execute),期间参数以浮动面板呈现给用户调整(draw)。这四步不是并列的四个函数,而是一条有先后、有条件的状态流转链——所以称它为状态机。

用户按 Ctrl+Z 再按 Ctrl+Shift+Z 时,系统不是回放日志,而是重新构造 Operator 实例再次执行。这个设计决定了整个契约的严格性:你的 execute 必须像数学函数一样,同样的参数永远得到同样的结果。

二、poll:准入契约

poll 是类方法,不接收 self——因为调用它时实例尚未创建。它只基于上下文当前状态返回真或假:活动物体是否存在、是否是网格、是否在编辑模式、是否有选中顶点。界面系统在绘制按钮前调用 poll,返回假则按钮置灰。

三条纪律。第一,poll 必须纯粹:只读上下文,不写任何数据——它在每次重绘都会被调用,写操作等于每帧改一次场景。第二,poll 要便宜:复杂判断会拖慢整个界面刷新。第三,poll 失败要"可解释":用户看到灰按钮却不知道为什么时,可以配合描述或在面板上给出提示文案。

class MESH_OT_edge_tool(bpy.types.Operator): bl_idname = "mesh.edge_tool" bl_label = "边工具" @classmethod def poll(cls, context): obj = context.active_object return (obj is not None and obj.type == 'MESH' and context.mode == 'EDIT_MESH')

poll 的存在本身,是架构对"上下文敏感性"的制度化承认——它强制开发者把环境判断前置成显式断言,而不是在执行中补救。

三、invoke:交互契约

invoke 在用户首次触发操作(点击、快捷键、菜单)时被调用,额外接收 event 参数——鼠标坐标、按键状态、时间戳等原始输入。它的职责是决定这次操作"以何种姿态展开":立即执行并返回完成;弹出参数对话框让用户确认;或进入模态等待,把控制权交给后续的事件流。

def invoke(self, context, event): # 记住鼠标位置,供模态使用 self.start_mouse = (event.mouse_region_x, event.mouse_region_y) if context.active_object is None: return self.execute(context) wm = context.window_manager return wm.invoke_props_dialog(self) # 弹出参数确认框

调用参数对话框的管理器方法会触发 draw 渲染浮动面板,用户确认后 execute 才被调用。这是"交互归交互、计算归计算"的关注点分离:invoke 处理人机意图,execute 承担确定性业务。

四、execute:执行契约

execute 是业务逻辑入口,两条硬性要求。

幂等:多次调用产生相同结果。重做机制依赖此特性——用户按重做时,内核重建 Operator 实例、还原参数、再次调用 execute。若 execute 内部修改了全局列表或外部缓存,重做就会失真。无外部副作用依赖:不依赖执行时机才成立的可变状态。你声明的每个参数属性都会被序列化进操作历史,execute 应当只依赖这些参数与传入的上下文。

返回值是协议的一部分:FINISHED 成功、CANCELLED 放弃、模态场景下 RUNNING_MODAL 表示继续接收事件、PASS_THROUGH 表示放行事件给后续处理。返回别的值等于协议违规。

⚠️ 常见坑:在 execute 里用时间戳或随机数做默认参数("每次执行自动换个种子")。撤销后重做,结果对不上,用户以为文件坏了。需要随机性时,把种子声明为参数属性,让它进历史。

五、draw:界面契约

当操作以默认交互方式调用时,draw 方法被用于绘制参数面板。它接收布局对象,用标准构建块摆放控件;所有控件必须绑定到 self 的属性工厂属性——这保证参数值被自动序列化、存入操作历史、重做时还原。draw 里放了不绑定属性的纯装饰控件没问题,但别指望它记住任何状态。

def draw(self, context): layout = self.layout layout.use_property_split = True layout.prop(self, "threshold") row = layout.row() row.prop(self, "keep_sharp")

💡 关键直觉:四个方法是一条流水线——poll 守门、invoke 接客、execute 干活、draw 报价单。任何一步越权(poll 写数据、execute 里画界面),流水线就乱。

六、模态操作:在时间维度上织交互

普通 Operator 一次执行完就退场。模态 Operator 则驻留在主循环里,跨越几十上百帧持续接收事件——这是实现交互式工具(框选后实时预览、拖拽中动态反馈)的唯一正规路径。骨架如下:

class VIEW3D_OT_smart_tool(bpy.types.Operator): bl_idname = "view3d.smart_tool" bl_label = "智能工具" def invoke(self, context, event): self._snapshot_state(context) context.window_manager.modal_handler_add(self) context.window_manager.event_timer_add(0.01, window=context.window) return {'RUNNING_MODAL'} def modal(self, context, event): # 每次进入先快照校验,防环境突变 obj = context.active_object if obj is None or obj.name not in bpy.data.objects: self._cleanup(context) return {'CANCELLED'} if event.type == 'MOUSEMOVE': self._update_preview(context, event) elif event.type == 'LEFTMOUSE' and event.value == 'PRESS': self._cleanup(context) return {'FINISHED'} elif event.type == 'ESC': self._cleanup(context) return {'CANCELLED'} return {'RUNNING_MODAL'} def _cleanup(self, context): # 退出契约:清定时器、删临时数据、还原状态 context.window_manager.event_timer_remove(self._timer)

模态的两个挑战恰是它的价值所在。状态一致性:每次 modal 被调用,环境可能已变——用户切了对象、删了依赖集合;所以每次进入先做状态快照与有效性校验,失效即优雅退出。退出契约:cancel 路径必须执行资源清理仪式——移除定时器、清除临时网格、还原标志位。系统只保证退出后不再发事件,不替你打扫。

特性 普通 Operator 模态 Operator
生命周期 一次执行 跨帧驻留
输入来源 参数属性 参数加事件流
典型用途 批处理、一次性操作 交互式绘制、实时预览
复杂度 高(状态快照加清理)
事件过滤 无需 必须只响应关心的类型

事件类型要精准过滤:鼠标移动、中间帧鼠标移动、定时器、按键按下与释放各有类型,全接的话每次事件都跑一遍逻辑,交互会发黏。

四重契约流水线

四重契约流水线

一个 Operator 的完整生命周期

七、四契约的常见问答

poll 和 execute 里都判空,是不是重复?

不是重复,是两道不同的闸门。poll 决定"按钮能不能点",execute 判空决定"点下去之后坏数据会不会被我处理"。批量脚本、宏录制等场景可以绕过界面直接调用操作符,此时 poll 不被咨询,execute 内部的防御是最后一道防线。两层都写,代价是几行代码,换来的是任何入口进来都不会炸。

为什么参数面板不显示我加的控件?

最常见的原因是控件没有绑定到操作符自身的属性。参数面板的本质是"操作历史的报价单"——只有绑定到 RNA 属性的控件,其值才会被序列化进历史、参与撤销重做。用普通变量摆控件,要么渲染不出来,要么值不被记录。检查方法:确认每个属性都经属性工厂声明、绘制里引用的都是自身属性名。

模态操作器执行到一半,用户切换了工作区怎么办?

这正是每次进入模态都要做状态快照的原因。工作区切换意味着上下文巨变:原来的区域可能已销毁,缓存的空间数据失效。稳妥做法是把关键资源句柄(定时器、临时对象名)存在实例上,每次进入时校验有效性,失效即走取消路径并清理。绝不假设"上次有效的,这次还有效"。

撤销之后插件状态乱了,怎么回事?

八成是状态放错了地方。操作符框架回滚的是数据空间的变更,不会回滚你模块里的全局变量。执行时更新了全局缓存、撤销时数据回去了而缓存没回,两边就不一致了。解法:凡是从数据派生的缓存,要么不缓存每次现算,要么订阅数据变更事件主动失效缓存——让缓存永远只是数据的影子,而不是另一个真相源。

操作符与宏录制:契约的复利

契约守得越好,系统级能力的复利越大——宏录制是最好的例子。用户的操作历史本质是一串操作符及其参数序列,界面的"重复上次操作"就是把最近一个操作符原参数再执行一遍。你的操作符参数声明得越完整(该是参数的都进了属性、不该随机的都确定了),"重复"就越精准——用户可以把"加细分、调级别、平滑着色"这样的三连做成肌肉记忆。

反过来,违约的操作符会污染整条历史链:execute 里偷读全局状态的,重复时读到的是新状态,结果对不上;参数漏声明的,重复时用默认值,行为漂移。所以契约不是合规负担,是让你的操作融入宿主能力池的入场券——撤销、重做、重复、宏、快捷键、菜单,六件系统武器只对守约者开放。

测试也因此有了统一入口:对任何操作符都可以写"执行-断言-撤销-断言恢复"的四步用例,这个模式覆盖了交互逻辑的大部分回归需求,第 6 章的契约测试直接建立在其上。

八、契约检查的自动化伏笔

手动守约终有疏漏,本节的契约天然适合自动化检查——这里先立框架,第 6 章补齐工具链。可自动化的检查分两类:静态的(扫描代码:操作符是否声明撤销选项、poll 里是否出现赋值语句、execute 是否引用了模块级可变对象——三者都能靠规则识别)与动态的(运行时验证:注册每个操作符后自动跑一遍"执行-撤销-断言恢复"的通用用例,四步全绿才算通过注册)。把这两类检查挂进提交钩子与流水线,契约就从"个人修养"升级为"系统门禁"。这也是本教程反复出现的母题:好的实践先成为习惯,再成为工具。

要点速记

  • 要点一:Operator 四重契约:poll 守门(只读、便宜)、invoke 接客(原始输入与姿态)、execute 干活(幂等、标准状态码)、draw 报价(控件绑属性);
  • 要点二:撤销与重做的本质是重建实例重新执行,execute 的纯度决定历史系统的可信度;
  • 要点三:随机数与时间戳不要当隐形默认值,需要就声明成参数属性;
  • 要点四:模态 Operator 是交互式工具的唯一正规路径,每次进入先快照校验;
  • 要点五:模态的退出契约是资源清理仪式,系统不替你打扫;
  • 要点六:事件过滤要精准,全量响应会让交互发黏。

动作契约讲完了,下一节看数据契约:PropertyGroup 与它背后的上下文机制。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U