Odoo 开发

Odoo 的 onchange 返回值为什么经常“不生效”:RecordSnapshot 比对、脏字段与 diff 回传链路讲透

很多人还把 onchange 理解成“后端函数返回 value/warning,前端照单全收”。但 Odoo Web 端源码里的真实链路早已复杂得多:先构造内存 record,再做 snapshot,对 changed values、recompute 和多轮 onchange 处理后,最终只把 diff 回传给客户端。

Odoo 开发
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 6 阅读

很多开发者学 Odoo 时,脑子里还留着一个很早期的模型:

  • 前端改一个字段
  • 后端 onchange 方法返回 {'value': ..., 'warning': ...}
  • 前端把这个结果直接灌回表单

这个理解不能说全错,但已经远远不够了。

/home/ubuntu/odoo-temp/addons/web/models/models.py 的源码看,现代 Odoo 的 onchange 真实链路更接近:

  1. 根据当前表单值构造一个内存中的临时 record
  2. 先做一份 RecordSnapshot
  3. 把 changed values 写进缓存并触发 recompute / onchange
  4. 多轮判断还有哪些字段发生变化需要继续处理
  5. 再做一份新的 RecordSnapshot
  6. 最后只把 snapshot diff 回传给客户端

也就是说:

onchange 的重点早就不是“返回了什么”,而是“最终 diff 出了什么”。

一、第一步不是执行 onchange,而是先把表单态数据组装成 record

源码开头做了几件很关键的事:

  • flush_all(),保证测试和环境一致性
  • 首次调用时补 default_get
  • 预取 x2many 行及其子字段,尽量减少后续新记录上的昂贵计算
  • values 切成 initial_valueschanged_values

然后,Odoo 会根据当前场景创建内存记录:

  • 如果原本有真实记录,就 self.new(cache_values, origin=self) 再叠加表单值
  • 如果是纯新建,就 self.new(initial_values)

这一段非常关键,因为它决定了后续 onchange 根本不是直接改数据库,而是在一份表单态缓存对象上运行。

二、为什么会先做 RecordSnapshot

Odoo 随后会创建:

snapshot0 = RecordSnapshot(record, fields_spec, fetch=(not first_call))

RecordSnapshot 本质上是一个按字段树展开的值快照:

  • 普通字段直接记当前值
  • x2many 字段则递归存成“行 id → 子快照”的结构

这个设计很重要,因为 Odoo 不想把整份 record 全量重新发给前端,它想知道的是:

  • 哪些字段真的变了
  • x2many 哪些行被删了、改了、加了
  • 对已有行应该生成 update,还是 link,还是 create

如果没有 snapshot 机制,复杂表单的 onchange 返回值要么极其冗余,要么很难保证一致。

三、真正的“脏值”不是返回字典,而是缓存中的 changed values

源码里 changed_values 会先被写回 record cache:

record._update_cache(changed_values)

然后再通过 record.modified(...) 标记字段已被修改,推动:

  • 依赖字段重算
  • inherited 字段同步到父记录
  • 下一轮 onchange 判定

这意味着 onchange 的主战场并不是 Python 返回字典,而是:

  • 当前内存 record 的缓存状态
  • 依赖图驱动下哪些字段被视为已修改
  • 哪些字段在多轮处理后又发生了变化

所以很多“我明明在 onchange 里给某字段赋值了,为什么界面没变”的问题,根源往往不在返回值格式,而在于:

  • 这个值最后有没有体现在 snapshot diff 里
  • 它是不是又被后续重算覆盖了
  • 当前字段是否真的在 fields_spec 这棵树里

四、为什么 onchange 可能会跑多轮

源码里维护了 tododone,并在每一轮后判断:

  • 哪些字段在 snapshot0 对比下已经变化
  • 这些字段还没处理过
  • 如果允许 recursive_onchanges,就继续下一轮

这说明 Odoo 的 onchange 不是“每个字段只调用一次方法就结束”,而是一个带递归传播特征的稳定化过程

一个字段变化后:

  • 可能先触发它自己的 onchange
  • 再改动另一个字段
  • 后者又触发第二轮 onchange
  • 最终直到没有新的 relevant 变化为止

所以你看到的最终结果,常常不是某个方法单独返回的结果,而是多轮传播后的稳定态。

五、RecordSnapshot.diff() 才是真正发回前端的“协议层”

后半段源码很关键:

snapshot1 = RecordSnapshot(record, fields_spec)
result['value'] = snapshot1.diff(snapshot0, force=first_call)

这意味着:

  • Odoo 不会简单把当前 record 全量序列化回前端
  • 它会比较“前快照”和“后快照”
  • 只发差异

对于普通字段,这个 diff 通过 web_read() 格式化。

对于 x2many,逻辑更复杂:

  • 消失的行生成 remove / unlink / delete
  • 已有来源的行根据 _origin 决定是 LINK 还是 UPDATE
  • 新行生成 CREATE

这就是为什么复杂 one2many 表单里,onchange 看起来总像“有一套隐藏协议”:

因为它确实有,而且这套协议的核心就是 snapshot diff + commands。

六、开发时最容易误会的三个点

1)“我 return 了 value,所以前端一定会看到”

不一定。

真正决定前端收到什么的,是最终 diff,而不是你在某个 onchange 方法里短暂返回了什么。

2)“onchange 会直接把数据写库”

不会。主流程运行在 new() 构造的表单态 record 上,本质是内存缓存对象。

3)“x2many 的 onchange 很玄学”

其实不玄。只是它不是按简单字典回填,而是按:

  • 行身份
  • _origin
  • snapshot 差异
  • commands 协议

来表达。

结语

理解 Odoo onchange 最重要的一步,是把脑中模型从“函数返回值”升级成“临时 record + RecordSnapshot + diff 协议”。

只有这样,你才能真正解释这些常见现象:

  • 为什么有些赋值看似不生效
  • 为什么 one2many 编辑结果总像被框架“重写”
  • 为什么多轮 onchange 后最终值和你单个方法返回值不完全一致

onchange 从来不是“后端算一下,前端抄一下”这么简单。

它本质上是 Odoo 为复杂表单状态同步设计的一套 内存态稳定化与差异回传机制

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。