K3Cloud动态表单插件开发:View/Model分离与事件时序
简介面向K/3 Cloud BOS插件开发初学者的入门文档围绕企业个性化与非标准化业务扩展需求梳理BOSIDE、套打、万能报表等应用背景并聚焦插件开发所需的知识框架。文档系统讲解开发环境配置、服务器与客户端软硬件要求、动态表单模型及四类插件动态表单、服务、校验规则、表单构建的定位同时给出Visual Studio 2012、Silverlight 5、SQL Server或Oracle等工具与数据库注意事项以及从定义插件类、重载方法到注册运行的基础步骤。压缩包共1个docx文件约1.9MB内容以图文与操作要点组织适合边看边搭建环境、对照理解插件机制。已有216人学习可作为入门查漏与实操参考。1. 一个 Hello World 插件暴露的是「标准产品如何被改写」在 K3Cloud 插件开发里最容易踩的误区是以为难点在写代码。真正决定成败的是另外一件事你的代码在什么时刻被调用拿到的对象是什么状态。一个点击菜单弹对话框的插件代码只有三行可它背后压着四条链路——元数据加载、视图与模型分离、事件分发、状态管理。BOS 平台把标准业务封装成动态领域模型插件是唯一合法的改写入口入口开在哪里、什么时候开全由框架的装配过程决定。搞懂装配插件就是填空不懂装配插件就是玄学。BOSIDE 设计器里点的每一个勾最终都会变成运行时的一次方法调用。2. 动态表单模型为什么 View 和 Model 必须拆成两个对象2.1 一份元数据两块内容LayoutInfo 与 BusinessInfo在 BOSIDE 里画一张单据看起来很直观拖控件、设标题、配字段、挂操作。但设计器最终交给运行时的不是一张「界面图」而是一份元数据这份元数据在结构上被切成两半。一半叫布局元数据 LayoutInfo记录表单外观有哪些控件、放在哪、页签怎么分、字段标题是什么、在什么场景下可见可编辑。另一半叫业务元数据 BusinessInfo记录业务对象逻辑有哪些实体 Entity、每个实体有哪些字段 Field、字段的类型和取值范围、挂了哪些操作、有哪些校验规则和业务规则。运行时View 持有 LayoutInfoModel 持有 BusinessInfo 外加当前的数据对象。这个拆分不是洁癖是被复用需求逼出来的。同一份 BusinessInfo 可以配出多份 LayoutInfo编辑界面的布局、列表界面的布局、移动端的布局、套打模板的布局。反过来改外观不动逻辑改逻辑不动外观两边可以各自演进。如果外观和逻辑揉在一个类里每加一个端就得复制一遍业务规则那种代码库的维护成本是灾难级的。元数据里还有一类细节很值得看大量属性用位标志而不是布尔值。字段的FunControl是一个整型位集合FUNCONTROL_BULK_EDIT只占其中一位表示这个字段支持批量修改外观的Appearance.Visible同样是位集合VIS_EDIT表示在「修改」场景下可见。为什么不用一堆 bool因为可见性、可编辑性、可打印性是「场景 × 能力」的笛卡尔积用位可以一次判定位运算组合加一个场景只加一位不用改表结构也不用给每个已有记录补默认值。这种设计在元数据驱动的系统里几乎是标配。2.2 请求进来之后谁先动控制器到视图的调用链理解了元数据的两块内容再看一次表单请求是怎么走完的。客户端发起 HTTP 请求服务端由控制器服务接住转送给动态表单模型控制器控制器去访问动态表单视图视图加载外观模型也就是 LayoutInfo随后从动态表单模型里取数据模型也就是 BusinessInfo 和 DataObject最后把控件树和值一起铺出来。注意这条链路里有个关键的不对称视图负责加载外观但不持有数据。数据只在 Model 的 DataObject 里。视图拿到的是对 Model 的引用渲染时按 LayoutInfo 描述的位置去 Model 里取值。所以你在插件里改一个字段的值走 Model改一个控件的可见性走 View。这两件事永远不能互换换了就是绕过框架。视图对外暴露两个接口IDynamicFormView和IDynamicFormViewService。前者给插件用含领域模型元数据、多视图模型接口、操作转发指令和通用属性方法后者是动态表单内部的消息路由定义插件不需要关注。这种「稳定面 / 内部面」双接口的做法在框架设计里很常见内部路由方法一旦被外部依赖框架就没法演进了因为每次重构都会打破兼容。把插件能碰的 API 冻结成一套窄接口剩下的大面积内部方法随便改API 的维护成本立刻降下来。2.3 视图层与模型层各给插件留了什么口子View 侧的口子偏「呈现与交互」拿菜单项GetMainBarItem、GetBarItem、拿控件GetControlT、改样式StyleManager.SetVisible、设标题SetFormTitle、弹消息、打开别的表单、发客户端指令刷新界面甚至在运行时动态注册插件。Model 侧的口子偏「数据生命周期」设值、取某个实体的数据集合、新增分录行、批量新增分录行、删除行、复制数据、定位当前分录行。一条血泪经验改界面表现走 View改数据走 Model别混。有人图省事直接用View.GetControl...().SetValue(...)改控件上的值看起来界面立刻变了但 Model 的 DataObject 没动。等到保存的时候数据包按 Model 的状态组织用户在界面上看到的那个值直接被丢掉——这种 bug 极难查因为界面上什么都是对的只有落库结果不对。下面这段代码来自一个批量修改界面的初始化正好把两层的关系写明白了元数据是 Model 侧的输入下拉框是 View 侧的出口。// 批量修改界面初始化把「允许批量修改且当前场景可见」的字段装进下拉框 public override void OnInitialize(InitializeEventArgs e) { // 从父表单视图的业务元数据里拿到源单据 FormId再按 FormId 加载完整元数据 var metadata (FormMetadata)ServiceHelper.MetaDataServiceHelper.Load( this.View.Context, this.View.ParentFormView.BillBusinessInfo.GetForm().Id); // 标题拼接源单标题-[当前表单标题] string title string.Format({1}-[{0}], metadata.GetLayoutInfo().GetFormAppearance().Caption, this.View.LayoutInfo.GetFormAppearance().Caption); var localeTitle new LocaleValue(); localeTitle.Add(new KeyValuePairint, string(this.Context.UserLocale.LCID, title)); this.View.SetFormTitle(localeTitle); var items new ListEnumItem(); foreach (Field field in metadata.BusinessInfo.GetFieldList()) { // 位标志一字段本身要支持批量修改 if ((field.FunControl Field.FUNCONTROL_BULK_EDIT) ! Field.FUNCONTROL_BULK_EDIT) continue; // 位标志二在「修改」场景下不可见的字段不参与 var app metadata.GetLayoutInfo().GetAppearance(field.Key); if (app ! null (app.Visible Appearance.VIS_EDIT) ! Appearance.VIS_EDIT) continue; items.Add(new EnumItem { Value field.Key, Caption field.Name }); } // 按当前语言下的标题排序保证下拉体验稳定 items items.OrderBy(p p.Caption[this.View.Context.UserLocale.LCID]).ToList(); if (items.Count 0) this.View.GetControlComboFieldEditor(FCombo).SetComboItems(items); }几个参数值得单说。GetFieldList()返回的是全量字段大单据上可能有几百个遍历本身不贵但每个字段都去GetAppearance查一次布局是哈希查找量级上可以接受不要在这个循环里再做服务调用就行。Caption是LocaleValue结构多语言环境下要按UserLocale.LCID取直接ToString()拿到的可能是默认语言多语言站点上会显示成另一种语言。OnInitialize里做这些事是安全的因为它只准备控件数据源不碰数据包如果把SetValue写在这儿多半不生效原因在第 4 章会讲透。3. 插件继承体系抽象基类怎么把扩展点漏出来3.1 四类平台插件与五种动态表单插件不是一回事平台文档里会同时出现两组分类很容易看晕。第一组是四类平台插件动态表单插件、服务插件、校验规则插件、表单构建插件。这一组按「扩展的是什么」划分——动态表单插件介入单据上的业务事件、操作和服务是最常用的一类服务插件是服务端的编程接口供外部服务直接调用通常给集成场景用校验规则插件专门做业务规则校验表单构建插件在界面加载控件和元数据时介入可以动态调整控件和元数据。第二组是五种动态表单插件单据插件、列表插件、过滤条件插件、账表插件、动态表单插件。这一组按「挂在哪种领域模型上」划分。动态表单模型为了方便使用被拆成了多个表单领域模型每个领域模型配一个对应的插件基类。两组分类是正交的第一组回答「扩展点在哪一层」第二组回答「扩展的是哪种模型」。表单构建插件是四类里唯一能在界面渲染出来之前改结构的这个位置很特殊——想动态加一列、改一个字段的元数据只有这个入口能干普通单据插件的AfterBindData已经太晚了控件树都建完了。3.2 继承链为什么是 AbstractDynamicFormPlugIn → AbstractBillPlugIn → AbstractBasePlugIn动态表单插件全部继承自同一个抽象基类这个基类同时实现了视图层和模型层两个插件接口并且提供View和Model两个属性。这一层是「事件总入口」所有事件都在这里被框架回调。往上收窄一层是单据插件基类它把View和Model收窄成单据专用的强类型版本多暴露了BillBusinessInfo、BillView这类属性还带了单据特有的一批事件。再往上收窄一层是基础资料插件基类它继承自单据插件。这一点经常让人困惑基础资料为什么能继承单据插件因为在数据模型上基础资料就是「带编码规则、带分组、带预置数据、通常被别处引用」的特殊单据它的字段结构、操作体系、事件时序和单据几乎一致。平台的选择是继承再裁剪——复用单据插件的全部事件能力把流程、单据类型这些概念从语义上裁掉。这个取舍很值得琢磨。如果给基础资料单独设计一套事件体系好处是语义干净、没有多余事件代价是事件表要维护两份一处修 bug 两处改而且开发者从一个模型切到另一个模型时要重新学一遍。继承的代价是基类里会挂着一批对基础资料无意义的事件比如流程相关新手看着会懵。平台选了继承本质上是赌「学习成本一致」比「语义纯净」更值钱。列表插件则没有并入这条链因为列表的数据模型和单据不一样列表是查询结果集不是单据实体行没有持久化身份事件语义完全不同。3.3 两个接口View 层事件与 Model 层事件的分界视图层接口里的事件参数基本都带着交互信息主菜单点击、分录菜单点击、工具栏点击、按钮点击、基础资料弹窗调出前与返回后、分录行单击与双击、字段标题点击、页签切换、树节点单击与拖拽、页面初始化、绑定数据前后、页面关闭前。这些事件有个共同点——它们都在控件树存在之后或即将存在才发生你能从参数里拿到控件、行号、点击的菜单标识。模型层接口里的事件则围绕数据生命周期创建新业务对象、业务对象创建后的扩展、新增分录行后、字段值更新前、字段值改变后。这些事件发生在数据包层面那时候可能还没有控件树。所以AfterBindData归在视图层AfterCreateNewData归在模型层不是随手分的——绑定是「把数据铺到控件上」的呈现动作绑定完成后可访问的是控件数据包创建时只有数据没有控件。分界带来的实际影响是一个插件类可以同时实现两个接口抽象基类已经做了所以你能在同一个类里既写BarItemClick又写AfterCreateNewData。但事件顺序有硬约束写错了顺序假设就一定翻车。最常见的一处是菜单标识用字符串硬编码e.BarItemKey HelloWorld这种写法在开发环境里跑得很好等标准产品改了菜单标识插件静默失效连异常都没有。稳妥的写法是把菜单标识提成常量并且加一个「找不到就记日志」的兜底。4. 加载时序OnInitialize 到 AfterBindData 之间的七个落点4.1 新增与打开走的是两条路动态表单第一次被访问时先加载元数据初始化视图和模型对象初始化页面然后创建数据包并绑定数据。这条主干上框架给二次开发留了七个落点按顺序是页面初始化、数据包创建、分录行创建后、数据包创建后、绑定数据前、绑定数据后、页面关闭前。关键在于这条完整链路只在「新增」时走。打开一张已有单据走的是另一条路从库里加载数据包直接进入绑定数据前和绑定数据后CreateNewData和AfterCreateNewData根本不触发。AfterCreateModelData更严格只在新增表单模型后触发一次而且它里面的操作不会引起模型的数据变更事件。这个差异是踩坑率最高的一处。需求是「新建单据时给某个字段一个默认值」开发者很自然地写进CreateNewData测试时也新建了一遍看起来是对的上线后用户反馈「打开老单据那个字段的值被清空了」——其实不是被清空是那段代码压根没执行用户看到的是原值但另一些逻辑按「一定执行过」的前提写就错位了。判断当前是新增还是加载应该看打开参数里的状态而不是靠事件有没有触发来推断。4.2 状态管理器为什么 AfterCreateNewData 里设的值不产生变更集这是整套机制里最绕、也最值得讲透的一点。Model 的SetValue不是简单地把值写进数据对象它还要过状态管理器记录这次变更、生成变更集、下发给客户端做增量更新。但状态管理器需要一个「基线」才能判断什么是变更。在数据包创建阶段CreateNewData和AfterCreateNewData数据包还没下发给客户端客户端手里什么都没有基线不存在。这时候的赋值不会被状态管理器记录而是跟整个数据包一起一次性发到客户端。这个设计的好处很直接一张单据初始化时可能要设几十个默认值如果每个字段都产生一次增量更新就是几十次网络往返合并成一次初始包只有一次传输。代价是脏标记不成立——数据包在客户端眼里就是「初始值」后面保存时变更集是空的框架认为用户没改过任何东西。BeforeBindData也是同样的性质在这个阶段改数据不会被状态管理器记录。真正会进入状态管理、产生增量更新并触发字段值更新前事件的是绑定完成之后的修改。所以「设默认值」和「做联动」要放在完全不同的位置默认值放数据包创建阶段享受批量下发的性能红利联动改值放字段值更新前的链路里才能被状态管理器捕获保证保存时不会丢。提示判断一段赋值代码该放哪儿先问一句「这个值需不需要参与脏检查」。需要就放到绑定之后不需要就放到数据包创建阶段顺便省一次网络往返。4.3 每个事件里该干什么、不该干什么OnInitialize只做跟数据无关的准备控件数据源、标题、全局参数、动态初始化下拉项。别在这儿设值。CreateNewData是唯一能换掉整个数据对象的入口e.BizDataObject赋什么界面就显示什么。只在新增触发打开表单不触发。AfterCreateNewEntryRow每新增一行分录触发一次。字段默认值优先用 BOSIDE 里的实体服务规则配置配置表达不了的逻辑再写代码。这个事件在「插入行」时也会触发e.Row不一定是最后一行所以给行赋值前要判断是追加还是插入。// 新增分录行后自动编工序号向上取整到 10 的倍数上限 9999 public override void AfterCreateNewEntryRow(CreateNewEntryEventArgs e) { base.AfterCreateNewEntryRow(e); if (!e.Entity.Key.Equals(CONST_FSubEntity)) return; var rows this.Model.GetEntityDataObject(e.Entity); // 只有追加到末尾才自动编号插入行不覆盖用户已有的编号 if (e.Row ! rows.Count() - 1) return; // 注意空集合直接 Max() 会抛异常先兜一个 0 int maxNo rows.Select(w w.GetDynamicObjectItemValueint(KEY_FOperNumber)) .DefaultIfEmpty(0) .Max(); int next (int)Math.Ceiling((maxNo 1) / 10m) * 10; this.Model.SetValue(KEY_FOperNumber, next 9999 ? 9999 : next, e.Row); }这段代码里有三个坑点值得逐个说。第一Math.Ceiling的参数必须是decimal如果写成整数除法(maxNo 1) / 10整除会把小数截掉Ceiling就变成了恒等编号永远只加 1 而不是跳到 10 的倍数。第二Max()对空序列会抛异常用DefaultIfEmpty(0)兜底可以避免在「第一行分录」这个场景直接崩掉。第三用e.Row ! rows.Count() - 1判断追加依赖于事件触发的顺序恰好是「行已加进集合并且加在末尾」插入行时这个判断会失败正好起到保护作用——但如果业务上要求插入行也重排就得改成按行号重新编号代价是全表更新得掂量。AfterCreateNewData是批量设默认值的主场根据当前组织查一张配置表把邮件服务器、端口、消息类型一次性赋进去。查配置用带组织过滤条件的查询别在循环里查一次拿回来。BeforeBindData适合按类型预建分录行比如是基础资料类型就建「查询控件」行是业务单据就建「字段参数控件」行。批量建行用模型上的批量新增方法不要 for 循环单行加。AfterBindData是最常用的事件加载和界面刷新都会调用所以它必须幂等。在这里干三件事控制可见性、改控件样式、置菜单项的显示与可用。注意「加载和刷新都会调用」意味着你不能在这里做有副作用的写操作否则刷新一次数据就变一次。5. 避坑与排查插件不生效、值不落地、字符集翻车5.1 插件注册与编译输出的三类失效现象一代码改完重新编译界面上什么都没变。原因通常是最朴素的——编译输出路径没指到产品的插件目录K3CloudServer\Bin之下运行时加载的是旧程序集。解决方式是固定输出路径别靠手动拷贝改完之后确认目录里那个 dll 的时间戳是刚才那一刻。现象二插件在列表里注册了但事件不进断点。原因可能是插件挂错了模型或者单据属性里注册的插件被后来的一次保存覆盖了。更隐蔽的一种是服务端缓存了元数据程序集换了但元数据没失效。处理办法是清站点缓存后重进。现象三插件列表里有别的插件看着碍眼就删了。这是最危险的操作。列表里的插件在运行时会动态加载删掉一个可能直接让某条业务数据算错因为那个插件正在承担某个字段的赋值或校验。正确的做法是先确认那个插件的来源和职责拿不准就只停用不删除或者先在测试环境验证。这种「静默依赖」是注册制架构的通病。5.2 事件语义误用导致的值不生效现象在数据包创建事件里设了值界面上看得到用户改完之后保存落库的还是旧值或者用户压根没改保存时变更集是空的。原因在 4.2 里说过那个阶段的赋值不进状态管理器不产生变更集。解决分两种情况——如果是纯默认值用户不改就不用存那没问题如果这个值必须落库就得挪到绑定之后赋值或者在保存前的操作里补一次显式赋值。现象新增分录行时自动编号插到中间行之后编号乱了。原因是插入行也触发新增行事件而判断逻辑默认「新行在末尾」。解决办法是判断行号位置或者改用「按行号重排全部行」的策略但要接受全表更新的代价。现象基础资料弹窗点了没反应或者弹出来之后返回值丢了。前者的原因多半是在弹窗调出之前的事件里抛了异常这个方法常被用来做前置拦截抛异常就等于阻断后者的原因是在弹窗事件里改了值却没走模型界面看着变了但数据包没变。5.3 环境层字符集与排序规则现象中文显示成问号或乱码查询结果顺序诡异唯一索引莫名其妙冲突。原因在数据库这一层。SQL Server 的排序规则要设成Chinese_PRC_CI_AS中文、不区分大小写、区分重音这个排序规则会影响字符串比较、排序和索引行为装库时选错后面改起来极麻烦。Oracle 这边数据库字符集必须是AL32UTF8国家字符集必须是AL16UTF16两者不匹配时NVARCHAR2类型的中文会出问题。这类问题之所以归到「避坑」而不是「配置教程」是因为它不会在开发阶段暴露。开发机上数据量小、字符简单一切正常上线后数据进来才批量出问题那时候改库就是一次迁移工程。5.4 客户端渲染链路的依赖不能想当然现象某些客户端打开表单白屏或者控件不渲染控制台没有明显报错。原因是这条产品线的客户端渲染依赖浏览器插件和特定浏览器版本需要 Silverlight 5浏览器侧对 IE 8/932 位与 64 位和 Firefox 12 及以上的 32 位版本有明确支持范围。版本对不上时表现不是「提示不支持」而是渲染到一半停住。排查顺序是先确认插件装没装、版本对不对、浏览器位数对不对再看服务端。6. 设计取舍弱类型数据对象换来的是装配自由度6.1 DynamicObject 为什么是默认载体ClrType 又为什么要求派生平台默认用DynamicObject承载数据一张单据头是一个 DynamicObject单据体是DynamicObjectCollection而且可以有多个单据体。字段访问不靠属性靠字段元数据对象上的动态属性去取值和赋值。这个设计的好处是元数据在运行时才知道字段有哪些代码不需要为每个字段生成一个 CLR 属性。加一个字段BOSIDE 里配一下就能跑插件代码不用动。代价也很明显——没有编译期类型检查。GetDynamicObjectItemValueint(FQty)里字符串写错一个字母编译通过运行时拿到默认值 0然后一路算下去最后在某个报表上体现为数字不对。这种 bug 的排查成本远高于编译错误。平台留了一个口子可以通过DynamicObjectType.ClrType指定自定义类。但要求这个类必须派生自DynamicObject。为什么必须派生因为框架内部统一按 DynamicObject 的接口访问数据如果允许一个任意 POCO 进来框架就得为「不认识的类型」写适配层适配层的分支会渗透到每一处数据访问。强制派生保证了「自定义类型也是 DynamicObject」这个不变量框架不需要知道你的类型也能安全地读写。这是一个典型的用继承约束换取实现简单的取舍——牺牲了一部分自由度换来了一个不需要特判的数据通路。6.2 插件注册制列表里的插件就是一颗定时炸弹插件机制的本质是「不改标准代码的前提下改写行为」。这个目标决定了实现方式只能是注册制插件列表存在单据属性里运行时按列表动态加载。好处是标准产品的升级包可以直接覆盖个性化逻辑不受影响部署也只是把 dll 放进目录、在列表里勾一下。但注册制有个结构性代价可运维性差。系统的实际行为不由代码仓库决定而由那一份注册列表决定。同一份代码在一套环境里跑得好在另一套环境里因为列表里多挂了一个插件就行为不同。更麻烦的是删除风险——列表里那些看着陌生的插件很可能正在承担某个字段的赋值。这就是为什么文档里会专门写一句「该列表中可能已注册有其他插件删除插件可能会导致业务数据错误」。这句话不是免责声明是血泪经验。排查手段能验证什么局限在事件里打断点事件是否触发、触发顺序无法验证客户端渲染抓 HTTP 请求看数据包下发几次、包多大、字段有没有带需要装抓包工具并会读报文对比注册前后的行为是否由某个插件引起需要能安全地停用插件直接查库看落库结果数据最终是否正确定位不到是哪一层丢的6.3 怎么验证你的理解是对的验证事件顺序最直接的办法是在每个重载方法里打一行日志新建一张单据走一遍把日志按时间排出来跟文档上的顺序对一遍。日志里带上当前是新增还是加载你会立刻看到两条不同的路径。验证值有没有进状态管理器看抓包工具里数据包下发的次数。在数据包创建阶段设十个字段应该只看到一次下发在绑定之后设十个字段会看到十次增量。这个对比比读十遍文档都管用。验证视图和模型有没有串故意用控件直接改值然后保存看落库结果。亲手制造一次这种 bug比看别人描述十次都记得牢。我自己的习惯是写任何插件之前先在纸上把这次要碰的事件按顺序列出来标清楚每个事件里「界面存不存在、数据包存不存在、状态管理器认不认这次修改」。这三列填不满就先别写代码因为大概率会返工。踩过几次「界面上是对的、落库是错的」之后这套表已经变成肌肉记忆了。希望帮到你。本文还有配套的精品资源点击获取