简介:面向金蝶 K/3 Cloud BOS 平台初学者和二次开发人员的插件开发入门文档,围绕 BOSIDE、套打、万能报表等 BOS 产品应用,聚焦如何通过插件实现个性化与非标准化业务。内容从开发环境配置讲起,给出团队服务器、数据库服务器和客户端的 CPU、内存基准值,以及 SQL Server 2008R2、Oracle 11g、IIS 与 Silverlight 5 等运行环境要求;随后解析 BOS 动态表单架构模型,区分动态表单插件、服务插件、校验规则插件和表单构建插件四类用途,并列出 Visual Studio 2012、跟踪工具和插件编译路径。文档还以 Hello World 为例,完整演示定义插件类、重载 BarItemClick、注册插件到运行测试的流程,并指出 Chinese_PRC_CI_AS 排序规则与 AL32UTF8 字符集等容易出错的地方,便于读者对照搭建环境。包内为 1 个 docx 文件,约 1.9MB,已有 216 人学习。
1. 从一张改不动的采购收料单说起
手上接过一个需求:客户要在标准采购收料单上多一个「一键校验」按钮,点下去把单据体里几列数据按规则核一遍,不合格的行标红。标准产品没这个按钮,源码也拿不到,唯一的口子就是 BOS 插件开发。这类需求在 BOS 产品应用汇总里占了绝大多数——不是重构业务,而是在标准单据的生命周期里找准一个事件插进去。BOS 平台用动态领域模型把表单外观和业务逻辑拆成视图(View)和模型(Model)两层,标准服务、操作、校验全部以接口形式暴露,插件只要继承对应的抽象基类、重载想要的事件方法,就能在不碰标准产品一行代码的前提下改行为。这套东西适合有 .NET 底子、要接企业管理软件二开活的开发者;如果完全没碰过动态表单模型,第二章的环境和第三章的分层概念比代码本身更值得先啃透。
2. 环境与工程骨架:从管理中心地址到 Bin 输出目录
2.1 公共环境与个人环境,两台机器的分工
插件开发的环境不是一台机器搞定的事,标准做法是拆成「公共环境」和「个人环境」。公共环境里有一台数据库服务器装 SQL Server 或 Oracle,一台 Web 服务器装产品并配置成管理中心站点,团队所有人的元数据、单据模型、注册过的插件都指向这里。个人环境则是你自己的开发机,装 Visual Studio 和一份产品客户端,但不配管理中心——它只是连过去用。
这个分工的意义在于:单据模型、插件注册记录这些元数据是共享的,你要是每个人本地一套管理中心,改了单据互相看不见,插件注册了别人也测不到,联调阶段能把人逼疯。
配置基准值大致如下,按团队规模往上加就行,别在这上面省钱,内存不够的时候编译一次插件要等半分钟:
| 角色 | CPU | 内存 | 备注 |
|---|---|---|---|
| 团队数据库服务器 | 4 核 2.0 GHz 以上 | SQL Server 8 GB / Oracle 16 GB | 元数据与业务库同机 |
| 客户端 | 双核 2.0 GHz 以上 | 推荐 4 GB | 浏览器 + Silverlight |
| 个人一体机(客户端 + 数据库) | 双核 2.0 GHz 以上 | SQL Server 4 GB 以上 / Oracle 8 GB 以上 | 开发机自带数据库 |
运行环境的版本约束是这样的,抄下来贴到项目 wiki 首页:
| 组件 | 版本要求 |
|---|---|
| 数据库 | SQL Server 2008R2 标准版/企业版以上,或 Oracle 11g 以上 |
| 操作系统 | Windows Server 2008 以上;Oracle 场景可跑 Linux x86-64 / AIX |
| Web 服务器 | IIS 6.0 以上 |
| .NET Framework | 4.0 |
| 浏览器 | IE 8.0 / 9.0(32 位与 64 位)+ Silverlight 5;Firefox 12 及以上(32 位 Windows) |
个人环境装完之后有一件事必须做,就是告诉客户端「管理中心在哪」。打开产品安装目录下的Server\App_Data\Common.config,找到managementSiteUrl这个 key,把地址改成公共环境里那台管理中心机器的 IP 和端口:
<!-- 安装目录\Server\App_Data\Common.config --> <add key="managementSiteUrl" value="http://192.168.10.20:8000/" />这个值改错的表现很有迷惑性:客户端能启动、能打开登录框,但登录之后加载元数据一直转圈,或者提示找不到数据中心。我第一次配的时候填了localhost,本地根本没有管理中心服务,折腾了快一个小时才反应过来。改完记得重启一下客户端进程,配置文件是启动时读的,不重启不生效。
2.2 数据库排序规则和字符集:装机阶段最容易翻车的地方
这一条单独拎出来说,因为它不属于「代码问题」,出问题的时候你根本不会往数据库上想。
SQL Server 装的时候排序规则必须是Chinese_PRC_CI_AS。如果你图省事用了默认的SQL_Latin1_General_CP1_CI_AS,安装过程可能一路绿灯,但后面中文字段排序、模糊查询、单据名称比对会开始出现各种玄学问题——同一个客户名,按名称排序的结果和预期对不上,或者LIKE查不出来。这种问题排查起来没有任何报错信息可抓。
Oracle 这边是两个字符集:数据库字符集必须AL32UTF8,国家字符集必须AL16UTF16。装错了后面中文直接变问号,而且改字符集要重建库,代价极高。
-- 装完 SQL Server 先验一下排序规则 SELECT SERVERPROPERTY('Collation') AS CollationName; -- 期望输出:Chinese_PRC_CI_AS -- Oracle 侧验证字符集 SELECT * FROM nls_database_parameters WHERE parameter IN ('NLS_CHARACTERSET', 'NLS_NCHAR_CHARACTERSET'); -- 期望:AL32UTF8 / AL16UTF16提示:这两个检查放在装机 checklist 的第一步,别等业务数据灌进去了才发现。SQL Server 改排序规则要重建实例,Oracle 改字符集要重建库,两者都不是「改个配置重启一下」的量级。
2.3 工程引用与编译输出:Bin 目录为什么必须对上
工程本身是个普通的类库项目,但编译输出路径必须指到产品的Server\Bin\目录下。原因是运行时的插件加载器只在这个目录里找程序集,你把 dll 编译到bin\Debug里,插件注册界面浏览的时候能看到文件,但运行起来就是不生效——因为它压根没被加载。
新建类库工程,引用两个核心程序集:XX.BOS.Core和XX.BOS(不同版本程序集名略有差异,按本地安装目录里的实际文件来)。然后在项目属性里把输出路径改成产品的 Bin 目录绝对路径,比如:
输出路径:D:\Program Files\BOS\Server\Bin\这里有个细节:多人协作时每个人的安装路径不一样,把绝对路径写进.csproj提交到版本库,别人拉下来直接编译失败。常见做法是用 MSBuild 属性加一个本地的Directory.Build.props覆盖,或者干脆在.csproj里写成相对的环境变量:
<PropertyGroup> <!-- BOS_HOME 由开发者本机环境变量提供,避免绝对路径进版本库 --> <OutputPath>$(BOS_HOME)\Server\Bin\</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> </PropertyGroup>AppendTargetFrameworkToOutputPath设成 false 是为了别让输出目录变成Bin\net40\这种多一层的样子,加载器只认Bin根目录。顺手再说一句,编译前先把客户端关掉——dll 被进程占用导致覆盖失败,是每天都会遇到一次的经典问题。
3. 四类插件与视图/模型分层:插件到底挂在哪一层
3.1 四类插件的分工:动态表单、服务、校验规则、表单构建
平台把扩展点按职责切成了四类,选错类型会让代码写得很别扭。
第一类是动态表单插件,用来介入单据上的各种事件、操作和服务,它是四类里最常用的,九十以上的二开需求都落在这里。第二类是服务插件,基于动态表单服务端,提供服务编程接口,供外部系统直接调用,适合「别的系统要调我这个单据的某个能力」的场景。第三类是校验规则插件,专门做业务规则校验,把校验逻辑从表单事件里剥离出来,好处是可配置、可复用。第四类是表单构建插件,作用在界面加载控件和元数据的那一瞬间,用来动态调整控件和元数据,比如某个客户要求特定条件下隐藏整个页签、改字段标题,就属于这一类。
选型上的经验是:能落在动态表单插件就别另起炉灶;校验逻辑如果会被多处复用,抽成校验规则插件;只有确实要动界面元数据本身(而不是数据绑定后的样式),才上表单构建插件。
3.2 IDynamicFormView 与 IDynamicFormModel:外观与数据的两个入口
动态表单模型把「长什么样」和「算什么」拆开了。外观部分由视图(View)管理,控件外观、样式、布局都在这里;业务逻辑部分由模型(Model)管理,服务、校验器、操作、业务规则归它管。这两部分在 IDE 里配置,配置结果存进动态表单模型元数据,具体分别由布局元数据LayoutInfo和业务元数据BusinessInfo记录,两个类分别被 View 和 Model 持有。
插件直接接触的是两个接口:
IDynamicFormView:视图接口,包含领域模型元数据、多视图模型接口、操作转发指令和通用属性方法。它有两个重要属性BusinessInfo和LayoutInfo,还提供访问菜单、修改控件样式、设置标题、更新界面、执行操作、发送客户端指令、刷新界面、打开表单、动态注册插件这些能力。IDynamicFormModel:模型接口,同样是BusinessInfo加数据操作方法,但它多了一个关键属性DataObject,也就是当前表单的数据对象。这个对象是个DynamicObject,包含单据头和单据体数据,单据体是DynamicObjectCollection集合,可以有多个。
数据承载默认用DynamicObject,也可以通过DynamicObjectType.ClrType指定自定义类,但要求这个类必须派生自DynamicObject。模型层的方法主要围绕数据:初始化、新增、复制、删除、定位当前分录行、设置值。
接口还有一个IDynamicFormViewService和IDynamicFormModelService,那是框架内部做 Controller 消息路由用的,插件开发不用管它。这条边界一开始容易搞混,记住「插件只碰不带 Service 后缀的那两个」就行。
3.3 继承体系和 HelloWorld:一个菜单点击的最小闭环
插件按业务模型分成四类基类,继承关系是这样的:
System.Object └─ AbstractDynamicFormPlugIn // 动态表单插件基类 └─ AbstractBillPlugIn // 单据插件 └─ AbstractBasePlugIn // 基础资料插件 AbstractListPlugIn // 列表插件(另一条分支)命名空间XX.BOS.Core.DynamicForm.PlugIn下还有几个常用的:AbstractDynamicFormDataBinder(数据绑定器抽象类)、AbstractDynamicWebFormBuilderPlugIn(页面元数据构建插件)、AbstractOperationServicePlugIn(操作服务插件)。插件层面则分IDynamicFormModelPlugIn和IDynamicFormViewPlugIn两个控制接口,分别接收 Model 层和 View 层事件。
概念说完,直接上最小闭环:在单据上加一个菜单项,点了弹一句 Hello World。
using XX.BOS.Core.Bill.PlugIn; using XX.BOS.Core.DynamicForm; using XX.BOS.Core.DynamicForm.PlugIn.Args; using XX.BOS.Core.DynamicForm.PlugIn.ControlModel; namespace MyDev.SCM.Stock.Business.PlugIn { /// <summary> /// 采购收料单编辑界面插件,仅做菜单响应演示 /// </summary> public class ReceiptEdit : AbstractBillPlugIn { public override void BarItemClick(BarItemClickEventArgs e) { base.BarItemClick(e); // BarItemKey 是 IDE 里给菜单项配的标识,必须完全一致,大小写敏感 if (e.BarItemKey == "HelloWorld") { this.View.ShowMessage("Hello world!", MessageBoxType.Notice); } } } }BarItemClick是主菜单单击事件,参数BarItemClickEventArgs里最关键的是BarItemKey,它对应 IDE 中菜单项的标识。this.View就是当前视图接口的实例,ShowMessage是视图提供的消息提示方法,第二个参数控制提示类型(Notice / Warning / Error)。
写完编译到Server\Bin\,回到 IDE 设计器:打开单据属性窗口,编辑「采购收料单」这条单据属性,在插件列表里点注册插件,浏览选中刚编译出来的 dll,勾上ReceiptEdit,确定保存。然后运行时点开单据,菜单上就会出现 HelloWorld,点一下弹框。
注意:插件列表里可能已经注册了其他插件,这些插件运行时是动态加载的,随手删掉别人的条目可能导致业务数据出错。删之前先在测试环境确认。
4. 生命周期事件链:一次打开单据到底走了哪些方法
4.1 加载机制与七个事件的执行顺序
第一次访问表单时,框架先加载元数据,初始化视图和模型对象,初始化页面,然后创建数据包并绑定数据。整个过程中插件的执行顺序是固定的:
OnInitialize 页面初始化 CreateNewData 动态表单数据包创建(仅新增触发) AfterCreateNewEntryRow 创建分录行后(每次新增分录都触发) AfterCreateNewData 数据包创建后(仅新增触发) BeforeBindData 绑定数据前 AfterBindData 绑定数据及控件状态(加载和刷新都触发) BeforeClosed 页面关闭前这张顺序表值得贴在显示器边上。事件选错的典型症状是「代码明明跑了但值没生效」「值生效了但界面不刷新」「刷新了但状态管理器记录脏数据」,全都是执行时机不对。
4.2 OnInitialize 与 CreateNewData:初始化与造数据
OnInitialize负责动态表单实例初始化,典型用途是拿 Global 参数、动态初始化控件数据源。举个实际场景:批量修改界面初始化时,把允许修改的字段塞进下拉列表。
/// <summary> /// 界面初始化:加载源单据元数据,把允许批量修改的字段填入下拉 /// </summary> public override void OnInitialize(InitializeEventArgs e) { // 根据列表的 FormId 反查源单据元数据 metadata = (FormMetadata)ServiceHelper.MetaDataServiceHelper.Load( this.View.Context, this.View.ParentFormView.BillBusinessInfo.GetForm().Id); // 设置标题为「源单据标题-[当前表单标题]」 string strTitle = string.Format("{1}-[{0}]", metadata.GetLayoutInfo().GetFormAppearance().Caption, this.View.LayoutInfo.GetFormAppearance().Caption); LocaleValue formTitle = new LocaleValue(); formTitle.Add(new KeyValuePair<int, string>(this.Context.UserLocale.LCID, strTitle)); this.View.SetFormTitle(formTitle); List<EnumItem> list = new List<EnumItem>(); foreach (Field field in metadata.BusinessInfo.GetFieldList()) { // 只挑标记了「允许批量修改」的字段 if ((field.FunControl & Field.FUNCONTROL_BULK_EDIT) != Field.FUNCONTROL_BULK_EDIT) continue; // 编辑态隐藏的字段不展示 Appearance app = metadata.GetLayoutInfo().GetAppearance(field.Key); if (app != null && (app.Visible & Appearance.VIS_EDIT) != Appearance.VIS_EDIT) continue; _lstFields.Add(field); list.Add(new EnumItem { Value = field.Key, Caption = field.Name }); } list = list.OrderBy(p => p.Caption[this.View.Context.UserLocale.LCID]).ToList(); if (list.Count != 0) selectedFielKey = list.FirstOrDefault().Value; this.View.GetControl<ComboFieldEditor>("FCombo").SetComboItems(list); }这里Load的第一个参数是上下文,第二个是源单据 FormId;GetFieldList()返回字段集合;FunControl是字段的功能控制位,按位与判断是不是支持批量编辑;SetComboItems把EnumItem列表灌进下拉控件。用LocaleValue而不是直接SetFormTitle(string)是为了做多语言,LCID取当前用户的语言标识。
CreateNewData是数据包创建,只在新增时触发,打开已有单据不触发。IDE 里画完单据不写一行代码,打开就能看到一张空单据,就是因为它调了新建。需要预填缺省值、或者新增时从其他服务取数据展示的,挂这里:
public override void CreateNewData(BizDataEventArgs e) { if (!billFormId.IsNullOrEmptyOrWhiteSpace()) { // 按单据类型参数加载业务数据,false 表示不走缓存 DynamicObject obj = BusinessDataServiceHelper.LoadBillTypePara( context, businessInfo, billFormId, false); // 直接替换数据包,界面只负责展示 e.BizDataObject = obj; } base.CreateNewData(e); }BizDataEventArgs.BizDataObject就是最终要绑定到界面的数据对象,给它赋值等于接管了数据包创建过程。另一个常见用法是把外部算出来的结果集组装成DynamicObject塞进单据体:
public override void CreateNewData(BizDataEventArgs e) { // 用当前表单的元数据构造空数据包 e.BizDataObject = new DynamicObject( this.View.OpenParameter.FormMetaData.BusinessInfo.GetDynamicObjectType()); BusinessInfo info = this.View.OpenParameter.FormMetaData.BusinessInfo; Entity resultEntity = info.GetEntity("FEntity"); Field seqField = info.GetField("FSeq"); Field nameField = info.GetField("FName"); Field statusField = info.GetField("FStatus"); DynamicObjectCollection rows = (DynamicObjectCollection)resultEntity.DynamicProperty.GetValue(e.BizDataObject); int row = 0; foreach (OperateResult rowResult in _results) { DynamicObject rowData = new DynamicObject(resultEntity.DynamicObjectType); // 通过字段的 DynamicProperty 赋值,比字符串索引更安全,早期就能暴露 key 拼错 seqField.DynamicProperty.SetValue(rowData, row + 1); nameField.DynamicProperty.SetValue(rowData, rowResult.Name); statusField.DynamicProperty.SetValue(rowData, rowResult.SuccessStatus ? "1" : "0"); rows.Add(rowData); row++; } }参数说明:OpenParameter.FormMetaData.BusinessInfo是从打开参数里拿的元数据;GetEntity("FEntity")拿单据体定义;DynamicProperty.GetValue/SetValue是字段级的取值赋值入口。用Field.DynamicProperty而不是rowData["FSeq"]这种字符串索引,好处是字段 key 写错在代码阶段就能通过字段对象显式拿到,而不是运行时抛个索引异常。
4.3 AfterCreateNewEntryRow 与 AfterCreateNewData:分录行与缺省值
AfterCreateNewEntryRow每次新增分录都会触发,适合做「新增这一行之后要顺手算什么」的逻辑。典型例子是工序号按上取整规则自动生成:
public override void AfterCreateNewEntryRow(CreateNewEntryEventArgs e) { base.AfterCreateNewEntryRow(e); if (e.Entity.Key.Equals(CONST_FSubEntity.ENTITY_FSubEntity)) { IEnumerable<DynamicObject> rows = this.Model.GetEntityDataObject(e.Entity); // 只在追加行时赋值,插入行不动(否则会覆盖中间行已有的编号) if (e.Row == rows.Count() - 1) { int maxOperNumber = rows .Select(w => w.GetDynamicObjectItemValue<int>(CONST_FSubEntity.ORM_OperNumber)) .Max(); // 工序号 = 上取整((最大值+1)/10)*10,封顶 9999 int newOperNumber = (int)Math.Ceiling(((decimal)maxOperNumber + 1) / 10) * 10; this.Model.SetValue(CONST_FSubEntity.KEY_FOperNumber, newOperNumber > 9999 ? 9999 : newOperNumber, e.Row); } } }e.Entity是当前分录实体,e.Row是行号,this.Model.SetValue(key, value, row)带行号赋值。注意这里maxOperNumber对空集合调Max()会抛InvalidOperationException,生产代码里要加DefaultIfEmpty(0),这一点我在第 5 章细说。
AfterCreateNewData是数据包创建完成之后,同样只在新增时触发。字段缺省值优先用 IDE 里的实体服务规则配置,但涉及按参数动态取值就得写代码。比如新增单据时按当前组织取邮件服务器缺省值:
public override void AfterCreateNewData(EventArgs e) { base.AfterCreateNewData(e); // 按当前组织过滤邮件缺省设置 OQLFilter filter = new OQLFilter(); filter.Add(new OQLFilterHeadEntityItem { FilterString = string.Format(" FORGID = {0} ", this.Model.Context.CurrentOrganizationInfo.ID) }); DynamicObject[] objs = BusinessDataServiceHelper.Load( this.View.Context, "BAS_MAILDEFAULTSET", null, filter); if (objs != null && objs.Any()) { DynamicObject defaultSet = objs[0]; this.View.Model.SetValue("FMessageType", defaultSet["FMessageType"]); this.View.Model.SetValue("FServer", defaultSet["FOutgoingMailServer"]); this.View.Model.SetValue("FSMTPPort", defaultSet["FSMTPPort"]); } }这个事件设置的值不会进状态管理器,整个数据包是一次性发到客户端的,比AfterBindData里逐个SetValue性能好。代价是它只在新增时跑,修改已有单据不触发。
4.4 BeforeBindData、AfterBindData 与 AfterCreateModelData:绑定前后的窗口期
BeforeBindData在数据绑定前触发,可以改数据,改动不会被状态管理器记录。常用于按类型增加分录行:
public override void BeforeBindData(EventArgs e) { base.BeforeBindData(e); if (_modelTypeId == ElementType.ELEMENTTYPE_BASE.ToString()) { this.View.Model.CreateNewEntryRow("FSearchControl"); } else if (_modelTypeId == ElementType.ELEMENTTYPE_BILL.ToString()) { this.View.Model.CreateNewEntryRow("FFieldParamControl"); } // 操作参数页签始终保留一行 this.View.Model.CreateNewEntryRow("FPARAMOPERATION"); // 批量新增多行用 this.Model.BatchCreateNewEntryRow(key, rowCount),比循环快 }AfterBindData是使用频率最高的一个,加载和界面刷新都会走它,用来处理可见性、菜单启用/禁用:
public override void AfterBindData(EventArgs e) { base.AfterBindData(e); // 主菜单项隐藏 this.View.GetMainBarItem("tbNew").Visible = false; // 分录工具栏按钮启用 this.View.GetBarItem("FEntity", "tbAdd").Enabled = true; if (_modelTypeId == ElementType.ELEMENTTYPE_BASE.ToString()) { this.View.StyleManager.SetVisible("FTab_Field", null, false); } else if (_modelTypeId == ElementType.ELEMENTTYPE_BILL.ToString()) { // 单据不带「单据类型」字段时,整页签屏蔽 if (!this._metaData.GetLayoutInfo().GetFieldAppearances() .Any(f => f is BillTypeFieldAppearance)) { this.View.StyleManager.SetVisible("FTab_Type", null, false); } } }GetMainBarItem取主菜单项,GetBarItem(entityKey, itemKey)取分录工具栏项,StyleManager.SetVisible(控件 key, 行号, 是否可见)控制样式层可见性。注意控制可见性有两个入口:控件自身Visible和StyleManager,前者影响更彻底,后者只影响渲染。分不清的时候,表现就是「设了 false 还看得见」。
AfterCreateModelData在模型层数据包创建完毕时触发,只在新增后执行,且这里改数据不会引起DataChanged。典型场景是订单变更查询:打开界面时默认把第一行基准版本的订单内容显示出来。
public override void AfterCreateModelData(EventArgs e) { if (listVersions != null && listVersions.Any()) { // 反序列化第一行版本的 JSON 数据作为基准单据内容 baseOrderData = Common.DeserializeJsonStringToDynamicObject( orderBusinessInfo, listVersions[0].JsonData); ShowOrderBillVersion(); } }5. 避坑与排查:五条踩过的血泪记录
5.1 装机阶段的两个硬约束
坑一:客户端登录后一直转圈,或者提示找不到数据中心。
现象是客户端能启动,登录框弹得出来,输完账号密码之后卡住,日志里看不到明显错误。原因基本是个人环境的managementSiteUrl还指向默认值或者写成了localhost,本地没有管理中心服务,自然连不上。解决办法:打开Server\App_Data\Common.config,把地址改成公共环境管理中心机器的 IP 和端口,改完重启客户端进程。
坑二:中文排序、模糊查询结果和预期不符,没有任何报错。
现象是同一个客户名称,按名称排序的结果和业务预期对不上,或者LIKE查不到明明存在的数据。原因是 SQL Server 用了默认排序规则SQL_Latin1_General_CP1_CI_AS而不是Chinese_PRC_CI_AS。解决方式是安装时选对,装错了只能重建实例。Oracle 侧对应的是NLS_CHARACTERSET必须是AL32UTF8、NLS_NCHAR_CHARACTERSET必须是AL16UTF16,错了中文直接变问号,同样要重建库。这两个检查放在装机 checklist 第一步,别等业务数据灌进去才发现。
5.2 编译与注册阶段的坑
坑三:插件注册界面浏览能看到 dll,运行时却完全不生效。
现象是设计器里注册成功、勾选也保存了,运行时点菜单什么反应都没有,也不报错。原因是编译输出路径没指到产品的Server\Bin\目录,加载器只在这个目录下找程序集。解决方式是把项目输出路径改成安装目录下的Bin,并且确保AppendTargetFrameworkToOutputPath为 false,别让输出多套一层net40文件夹。改完重新编译,重启客户端。
坑四:编译时报「文件被占用,无法复制到 Bin」。
现象是编译失败,提示 dll 被另一个进程占用。原因是客户端还开着,加载过旧版 dll,进程没释放。解决方式是关掉客户端再编译。频繁调试的时候可以写个批处理先taskkill再编译,但别在共享环境里这么干,容易误杀别人的进程。
坑五:注册插件时把列表里别人的条目删掉了,后来业务数据出错。
现象是某张单据过一段时间出现数据异常,排查发现是某个不起眼的插件不见了。原因就是插件列表里的条目运行时是动态加载的,随手删掉别人的注册会让那段扩展逻辑消失。解决方式是删之前先在测试环境验证,生产环境只加不删。
5.3 事件里的坑
有一个坑必须单独说:在AfterCreateNewEntryRow里对空分录取Max()会抛异常。
现象是新增第一行分录时直接报InvalidOperationException: Sequence contains no elements,新增第二行就正常。原因是rows.Select(...).Max()在集合为空时没有默认值。解决方式是加DefaultIfEmpty(0):
int maxOperNumber = rows .Select(w => w.GetDynamicObjectItemValue<int>(CONST_FSubEntity.ORM_OperNumber)) .DefaultIfEmpty(0) .Max();顺带说,这个事件还有个语义坑:插入行也会触发,如果把「只在最后一行赋值」的判断去掉,插入到中间时会把那一行已有的编号覆盖掉。原文代码里那句if (e.Row == rows.Count() - 1)不是可有可无的装饰,是保命的。
另外,需要批量新增多行时用BatchCreateNewEntryRow(key, rowCount),别写 for 循环调CreateNewEntryRow,行数一多界面会明显卡顿,因为每调一次都会触发一轮分录行事件。
6. 二次开发改造:把一次性的 HelloWorld 变成能维护的插件
6.1 先收 key,再谈逻辑
新手写插件最常见的写法是把菜单 key、字段 key、单据 FormId 直接硬编码在判断里,一张单据上挂三五个插件之后,找某个"HelloWorld"到底对应哪个菜单要翻半天。我一般的做法是先建一个常量类,把这批 key 集中管理:
internal static class ReceiptConst { public const string FORM_ID = "PUR_Receipt"; public const string MENU_HELLO = "HelloWorld"; public const string ENTITY_DETAIL = "FEntity"; public const string FIELD_QTY = "FQty"; }超过两个插件共用同一批 key 的时候,这层抽象立刻回本。改菜单标识只需要改一行,而不是全局搜索替换赌运气。
6.2 选对扩展点比写对代码更重要
同一个需求,挂在AfterBindData和挂在AfterCreateNewData里的效果完全不一样:前者刷新会重复执行、改的值进状态管理器,后者只在新增跑一次、值是整包发给客户端的。选扩展点的判断顺序我固定成三条:
| 需求类型 | 首选扩展点 | 原因 |
|---|---|---|
| 预填缺省值、构造初始数据包 | AfterCreateNewData/CreateNewData | 一次性,不进状态管理器,性能好 |
| 界面样式、菜单可见性、字段锁定 | AfterBindData | 加载和刷新都能覆盖 |
| 绑定前批量加行、改数据 | BeforeBindData | 改动不记录状态,避免脏标记 |
还有一条边界:如果逻辑是「外部系统要调这个单据的某个能力」,别硬塞进动态表单插件,用服务插件暴露接口更干净;如果是可复用的业务规则校验,抽成校验规则插件,别在BarItemClick里堆 if。
6.3 验证靠三条线:抓包、断点、日志
插件不生效的时候,别靠猜。第一条线是抓包工具(HttpWatch Pro 之类),在AfterCreateNewData里设的值会随数据包一次性发到客户端,抓一次请求就能看到字段到底有没有带上。第二条线是断点,把调试目标挂到客户端进程上,确认事件到底有没有进来、e.BarItemKey的值是不是你以为的那个。第三条线是日志,异常被框架吞掉的情况不少,关键分支前后各打一条。
调试配置也有个注意点:如果插件加载不到、断点不进,先检查 dll 是不是真的输出到了Server\Bin\,再看客户端进程有没有重启——很多时候不是代码问题,是加载的还是旧 dll。
从那以后我每接一个二开需求,都先把事件锚点和 key 写进常量表再动手,宁愿多花十分钟建一个常量类,也不想在三个月后回来改的时候对着满屏字符串猜哪个是哪个。希望帮到你。
本文还有配套的精品资源,点击获取