金蝶云苍穹插件开发:表单插件、IDataModel 与事务边界实战

发布时间:2026/9/17 11:10:39
金蝶云苍穹插件开发:表单插件、IDataModel 与事务边界实战 简介这份《金蝶云苍穹插件操作指南精华版》面向金蝶云苍穹平台的中初级开发者与实施人员聚焦插件开发中「单个对象查询与结果集使用」这一高频难点帮助读者理解业务场景下如何取数、组装条件并回写界面。内容从请假申请单出发梳理 BusinessDataServiceHelper 与 QueryServiceHelper 两套类库的核心方法如 loadSingle、loadSingleFromCache、query、queryOne、queryDataSet、queryPrimaryKeys、exists 等并说明 DynamicObject 这一返回结果封装类的动态属性特性同时给出 propertyChanged 事件中的完整 Java 代码示例演示 QFilter 条件拼装与字段赋值思路并延伸至多对象关联查询的选型考量。资源为单个 PDF 文件压缩包约 2.97MB共 24 页篇幅精炼便于随手查阅。目前已有 759 人学习适合希望快速掌握苍穹插件查询写法、规避取数陷阱的开发者。1. 元数据先行的扩展机制苍穹插件到底挂在哪儿从传统 ERP 二开转过来的人第一次打开金蝶云苍穹的设计器往往会愣一下既没有「脚本」标签页也找不到往表单里贴代码的位置。这不是工具藏得深而是苍穹从设计上就把界面、单据、权限、校验都交给业务对象元数据描述插件只是挂在元数据上的一组 Java 扩展点。设计器里填好插件标识串运行时框架走到对应生命周期节点再回调你的方法代码才开始生效同一段逻辑挂到表单上响应界面操作挂到操作上参与事务行为完全不同。标题里那句「精华版」说白了就是把散落在文档、示例工程和同事口头经验里的操作要点收拢成一份能照着敲的清单。下面按工程配置、表单插件骨架、IDataModel 取值赋值、操作插件事务边界、调试与上线检查这条线推下去目标是在自己的开发环境里跑通第一个插件并且知道每一行代码踩在哪。2. 插件工程到注册串让第一个表单插件跑起来2.1 平台包走 provided插件为什么不能独立启动苍穹插件的入口不是main方法而是平台框架在生命周期节点上的回调。这意味着工程结构上它就是一个普通 Maven 模块把kd.bos.*相关包声明成编译期依赖运行期由苍穹服务自己的 classpath 提供。这一步做错最典型的后果是本地编译通过、部署后抛NoClassDefFoundError或者插件加载了却像没生效。JDK 版本要跟服务端保持一致常见部署是 8。用 11 或 17 编译出来的 class 文件在 8 的运行环境里会直接报UnsupportedClassVersionError而错误日志里往往只看到「插件加载失败」看不出是版本问题。# 1. 编译打包平台包只在编译期生效不进产物 mvn -q clean package -Dmaven.test.skiptrue # 2. 自检产物里不该出现任何平台类 unzip -l target/demo-plugin-1.0.0.jar | grep -c kd/bos/ # 期望输出 0如果大于 0说明平台包被误打进了 jar必须改回 provided # 3. 部署拷到环境约定的扩展目录路径以实际部署方式为准 cp target/demo-plugin-1.0.0.jar $COSMIC_HOME/mservice/plugin-lib/这三条命令里的关键参数-Dmaven.test.skiptrue跳过测试编译避免本地测试类引入额外依赖grep -c统计匹配行数返回 0 才是干净产物$COSMIC_HOME是部署根目录容器化部署和传统部署的子路径不一样别照抄路径先在环境里确认插件加载目录。部署完记得重启服务或走平台的热加载入口否则新类不会被类加载器读到。注意如果环境启用了多节点部署只把 jar 拷到一台机器上会出现「有时生效有时不生效」的随机现象排查时优先确认每台节点上的 jar 是否一致。2.2 表单插件骨架registerListener 与三段式回调一个能跑的最小表单插件核心是三件事注册监听、处理数据绑定后的界面状态、处理按钮点击。基类选AbstractFormPlugin下面这段可以直接作为模板。package kd.demo.form; import java.math.BigDecimal; import java.util.EventObject; import kd.bos.entity.datamodel.IDataModel; import kd.bos.form.control.events.ItemClickEvent; import kd.bos.form.plugin.AbstractFormPlugin; import kd.bos.logging.Log; import kd.bos.logging.LogFactory; public class DemoFormPlugin extends AbstractFormPlugin { private static final Log LOGGER LogFactory.getLog(DemoFormPlugin.class); Override public void afterCreateNewData(EventObject e) { // 点「新增」之后触发数据模型已可写适合给字段塞默认值 this.getModel().setValue(kdtest_textfield, 默认值); } Override public void afterBindData(EventObject e) { // 数据绑定完成后触发此时才能读到库里的值 IDataModel model this.getModel(); Object status model.getValue(kdtest_status); boolean editable A.equals(status); this.getView().setEnable(editable, kdtest_textfield); this.getView().setVisible(editable, kdtest_btncheck); LOGGER.info(afterBindData, billStatus{}, status); } Override public void registerListener(EventObject e) { super.registerListener(e); // 注册工具栏点击不注册的话 itemClick 永远不触发 this.addItemClickListeners(tbmain); } Override public void itemClick(ItemClickEvent e) { if (kdtest_btncheck.equals(e.getItemKey())) { BigDecimal amount (BigDecimal) this.getModel().getValue(kdtest_amount); if (amount null || amount.signum() 0) { this.getView().showErrorNotification(金额必须大于 0); return; } this.getView().showTipNotification(校验通过金额 amount); } } }逻辑说明registerListener里必须调super.registerListener(e)否则父类注册的默认监听会丢addItemClickListeners(tbmain)的参数是工具栏标识常见主工具栏就是tbmain自定义工具栏要用设计器里配置的标识。afterCreateNewData只在新增时触发afterBindData每次打开单据都会触发读值放后者、赋默认值放前者这是最省事的划分方式。参数说明setEnable(boolean, String...)第一个参数是目标状态后面是字段标识可以一次传多个setValue(String, Object)两参数版本作用于主记录操作分录字段必须用三参数版本带行号这点在下一章展开。日志用LogFactory.getLog而不是 System.out输出会进平台的日志文件按类名过滤就能定位。2.3 插件注册串类全名#标识的写法与错配排查代码写完了不会自动生效还要在设计器里把插件挂到业务对象上。标准写法是「类全名」或「类全名#标识」多个插件用逗号分隔。标识的作用是让同一个类在同一个对象上挂多份实例时能区分插件内部通过this.getPluginName()拿到它。举个典型场景采购订单和销售订单共用一个校验类但校验规则略有差异就可以用#purchase、#sale两个标识区分。症状大概率原因排查动作插件完全没反应类全名拼错、包名大小写不符对照编译产物的包路径逐字核对只有部分方法不触发方法签名写错、参数类型不对确认参数是EventObject而非自定义类型抛 ClassNotFoundjar 没进 classpath 或平台包污染检查部署目录、重新执行产物自检两个对象行为串了用了标识但代码里没取getPluginName()在分支里补上标识判断3. IDataModel 取值赋值的四个高频坑3.1 主记录与分录setValue 的行索引为什么必须显式给IDataModel是插件里操作数据的主要入口也是最容易出错的地方。根本原因在于它同时承载主记录和多个分录主记录只有一行分录有多行所以凡是分录字段赋值和取值都必须带rowIndex。不带行号的两参数版本只作用于主记录用在分录字段上轻则赋值丢失重则抛下标越界。// 主记录字段两参数版本 this.getModel().setValue(kdtest_remark, 由插件写入); // 分录字段三参数版本行号从 0 开始 int rows this.getModel().getEntryRowCount(kdtest_entryentity); for (int i 0; i rows; i) { Object qtyObj this.getModel().getValue(kdtest_qty, i); Object priceObj this.getModel().getValue(kdtest_price, i); BigDecimal qty qtyObj null ? BigDecimal.ZERO : new BigDecimal(qtyObj.toString()); BigDecimal price priceObj null ? BigDecimal.ZERO : new BigDecimal(priceObj.toString()); // 计算型字段不要在元数据里配公式再让插件覆盖避免双写打架 this.getModel().setValue(kdtest_amountitem, qty.multiply(price), i); }逻辑说明getEntryRowCount返回当前分录的实际行数循环用它做边界不要硬编码。取值时做 null 判断是必须的新建单据时字段可能还没填过直接参与运算会抛空指针。金额类字段返回值可能是BigDecimal也可能是字符串转成字符串再构造BigDecimal是最稳的写法。参数说明分录标识kdtest_entryentity要和元数据里配置的实体标识完全一致字段标识同理。写完循环后如果不确定结果可以在设计器里打开调试面板看数据模型快照比翻日志快。3.2 批量赋值与分录行增删循环里逐行setValue会反复触发数据变更事件分录行数一多界面会明显卡顿。平台提供了批量模式把一段赋值包在成对调用之间结束再统一刷新。this.getModel().beginInit(); try { int rows this.getModel().getEntryRowCount(kdtest_entryentity); for (int i 0; i rows; i) { this.getModel().setValue(kdtest_flag, Y, i); } } finally { this.getModel().endInit(); } // 新增一行分录并回填内容 this.getModel().createNewEntryRow(kdtest_entryentity); int last this.getModel().getEntryRowCount(kdtest_entryentity) - 1; this.getModel().setValue(kdtest_qty, BigDecimal.ONE, last); // 删除指定行 this.getModel().deleteEntryRow(kdtest_entryentity, last - 1);逻辑说明beginInit与endInit必须成对出现放在try...finally里保证异常时也能收尾否则模型会一直停在未刷新状态表现为界面字段显示旧值。createNewEntryRow返回新行插入的位置不同版本可能在中间插入所以取行号要用返回值的思路或者重新计算别默认追加在末尾。deleteEntryRow的行号是删除前的下标多行删除要从后往前删。3.3 beforeF7Select 过滤与 QFilter 组合基础资料字段弹出选择列表时需要按当前单据的上下文过滤这个需求用beforeF7Select处理。Override public void beforeF7Select(BeforeF7SelectEvent e) { if (!kdtest_customer.equals(e.getProperty().getName())) { return; } Object orgValue this.getModel().getValue(kdtest_org); // QCP.equals 之外常用 QCP.large_than、QCP.in、QCP.like QFilter filter new QFilter(status, QCP.equals, A) .and(org, QCP.equals, orgValue); e.setFilter(filter); }逻辑说明先判断触发字段避免一个插件里多个 F7 字段互相干扰。QFilter支持链式and/or组合多条件时链式写法比手工拼字符串安全得多。e.getProperty().getName()拿的是触发过滤的字段标识部分版本提供的是别的取值方法写之前在设计器里补全一下方法名最稳妥。提示过滤条件里如果用了组织、期间这类上下文变量注意跨组织查询的场景直接取主记录组织可能在选单界面拿到空值必要时从页面参数里取。4. 操作插件与事务边界校验和写数该放哪一层4.1 表单插件和操作插件的职责划分很多人在表单插件的beforeDoOperation里做提交前校验本地测试没问题一上并发就出事校验通过之后数据被别的操作改了或者校验过程中抛异常导致半截数据落库。原因在于表单插件运行在界面线程不参与操作事务操作插件运行在服务端事务里回滚有保证。维度表单插件操作插件运行位置界面线程服务端事务内能否回滚不能能抛业务异常即回滚整批批量数据只拿当前单据getDataEntities()拿到选中集合适用场景界面联动、按钮响应提交前校验、编号赋值、汇总回写保存后写关联单容易留下脏数据推荐放这类逻辑判断原则很简单只要逻辑需要「要么全成、要么全不成」就该放操作插件。界面上的可编辑性控制、提示信息、按钮点击留在表单插件。4.2 onPreparePropertys 和 beginOperationTransaction 的配合操作插件里最容易被忽略的是字段按需加载。苍穹为了性能不会把整张单据的所有字段都加载进来没在onPreparePropertys里声明过的字段事务里取出来是 null。public class DemoSubmitPlugin extends AbstractOperationServicePlugIn { private static final Log LOGGER LogFactory.getLog(DemoSubmitPlugin.class); Override public void onPreparePropertys(PreparePropertysEventArgs e) { // 必须声明否则事务里取到 null e.getFieldKeys().add(kdtest_amount); e.getFieldKeys().add(kdtest_entryentity); } Override public void beginOperationTransaction(BeginOperationTransactionArgs e) { for (DynamicObject bill : e.getDataEntities()) { DynamicObjectCollection entry bill.getDynamicObjectCollection(kdtest_entryentity); if (entry null || entry.isEmpty()) { throw new KDBizException(明细不能为空请补充后再提交); } BigDecimal total BigDecimal.ZERO; for (DynamicObject row : entry) { Object v row.get(kdtest_amountitem); if (v ! null) { total total.add(new BigDecimal(v.toString())); } } bill.set(kdtest_totalfield, total); LOGGER.info(submit bill{}, total{}, bill.get(billno), total); } } }逻辑说明onPreparePropertys里把主记录字段和分录实体都加上加分录实体意味着分录下的所有字段都会加载按需再加具体字段标识可以进一步减少查询量。beginOperationTransaction是事务开始后的第一站在这里改数据、抛异常都能被事务接管抛KDBizException会带上提示信息返回给用户抛运行时异常则按系统异常处理。参数说明e.getDataEntities()返回的是本批操作的DynamicObject数组提交、审核、批量保存都可能一次带多条循环里别假设只有一条。bill.set改的是内存对象事务提交时统一落库不需要手工调保存。4.3 三种校验方式的取舍方式触发时机优点局限表单插件里判断后 return界面点击时反馈快可被绕过无事务KDBizException操作事务内自动回滚、提示清晰一次只报第一条独立 Validator操作前统一收集可一次报多条、可复用需要额外注册选择逻辑界面友好性优先的轻量校验放表单插件数据一致性相关的放操作插件一批单据需要把问题一次性列给用户就写AbstractValidator挂到操作的校验器列表里。三者不是互斥的同一操作可以同时挂校验器和操作插件但校验器先执行。5. 断点、日志与上线前的自检清单5.1 远程调试怎么挂上去苍穹插件跑在服务端进程里本地没法直接main启动常规做法是让开发环境以调试模式启动在启动参数里打开 JDWP 端口本地 IDE 用 Remote JVM Debug 方式 attach 上去。这和调 IDE 插件、VS Code 插件的思路一样断点打在回调方法的第一行然后在界面上点一下对应按钮命中了再往下走。断点位置建议优先打三处afterBindData的第一行用来确认插件到底有没有被加载itemClick的 if 判断前用来确认按钮标识对不对操作插件的beginOperationTransaction入口用来看getDataEntities()里到底有几条。注意attach 之前先在日志里确认插件类已经被加载否则断点不命中会被误判成代码问题白白折腾半天。5.2 上线前必须过的检查项检查项具体动作不做的后果日志级别去掉循环内的 info改为按单条输出批量操作时日志量翻倍空值兜底所有getValue结果做 null 判断偶发空指针线上难复现字段声明onPreparePropertys补全用到的字段事务里取到 null异常类型业务提示用KDBizException用户看到系统错误码批量性能循环赋值包在beginInit/endInit里分录多时界面卡死幂等操作插件里判断是否已处理过重复提交产生重复数据最后补一个实操技巧调试阶段把日志里的单据编号和插件类名一起打出来排查时按单据编号 grep 一遍就能串起表单插件、操作插件、校验器的完整执行链路比对着时间戳找快得多。上线前把onPreparePropertys里漏掉的字段补齐远比事后在日志里翻空指针要省事。本文还有配套的精品资源点击获取