ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

TIPTOP 4GL combobox动态下拉与级联联动开发指南

TIPTOP 4GL combobox动态下拉与级联联动开发指南

采购单上要选一笔供应商资料,主档里有几千笔数据,直接用静态下拉一条条翻,光是找人就要半分钟,遇上跨厂区的单子还得先选工厂、再选供应商、再选联系人,三级筛下来,静态列表根本撑不住。这类需求在做 TIPTOP 4GL 单据开发的时候几乎每张单都会碰到,很多人第一反应是把选项写死在 form 里,结果上线两星期主档一变,代码就得跟着改一次。

所谓 combobox 动态下拉,本质上是把「候选清单的来源」从编译期挪到运行期:列表内容由一条 SQL、一个数组或者一次界面事件实时拼出来,主档改了不用重新编译程序,筛选条件变了列表立刻跟着变。这套做法落地之后,供应商选择从「翻几百条」变成「三秒钟点完」,后端校验也能同步收紧。这篇内容适合已经能写基础 4GL 程序、正在被下拉联动折磨的中级开发者,也适合刚接手单据维护、想搞清楚 combobox 到底怎么灌数据的新人——下面会从控件形态、取数结构、联动刷新、故障排查一路讲到能直接抄的代码骨架。

1. 先认清 combobox 在 TIPTOP 里的三种存在形态

很多同学写不出来动态下拉,不是语法不会,而是没分清手上这个「下拉框」到底是哪一种东西。TIPTOP 的界面层基于 Genero BDL 运行时,combobox 至少有三种实现路径,它们的生命周期、数据灌入时机、可用的 API 完全不同。选错形态,后面所有努力都是白费。

1.1 form 里声明成 COMBOBOX 类型的字段

这是最容易被误认为「只能静态」的一种。在.frm(或部分版本里的.per)窗体档中,把一个 item 的类型写成COMBOBOX,编译后界面上就是一个带下拉箭头的输入框。它的特点是:控件本身由窗体定义,位置、大小、颜色都在 form 里画好,4GL 只负责在运行时往里塞选项。

这种形态最大的好处是和 TIPTOP 现有的窗体体系完全兼容——字段校验、必填、跳格顺序、BEFORE INPUT/AFTER INPUT事件全都照常工作,不需要额外处理布局。很多老项目的下拉都是这么干的,只是在BEFORE INPUT里用COMBOBOX语句一次性列出选项,看起来就像写死的。实际上它完全可以动态化,只要把选项来源换成变量或数组。

需要建立的第一条认知是:form 里声明 COMBOBOX 不等于静态,选项内容由谁决定,取决于你什么时候、用什么方式往里灌数据。

1.2 运行时创建的自由控件

Genero 提供的ui.ComboBox是一个可以完全在 4GL 代码里create出来的控件,不依赖 form 声明。它的优势是灵活:可以随时addItem、removeItem,可以挂setDefaultInitializer做懒加载,甚至可以在一个窗口里动态增删多个下拉框。

代价是布局要自己管。要么通过setGeometry指定像素坐标(窗口缩放时容易错位),要么用DIALOG.addWidget把它挂进已有的 dialog 容器里。在 TIPTOP 这种窗体高度标准化的环境下,纯代码建控件通常只用在「弹出的临时选择窗」或者「参数设定画面」这类场景,主档维护界面还是走 form 声明的路子更稳。

1.3 用 INPUT ARRAY 弹窗模拟下拉

还有一种土办法:不用 combobox,点一下字段弹出一个小窗口,里面是DISPLAY ARRAY的可滚动物件清单,选中一行回填。这种做法在数据量上千条时反而比下拉好用,因为可以带列头、可以多列显示、可以模糊查询。

三种形态没有绝对优劣,关键看数据量和交互诉求:

形态数据灌入方式适用数据量布局成本典型场景
form 声明 COMBOBOXui.ComboBox.forName或 COMBOBOX 语句几十到几百条低(窗体已画好)单据头的供应商、部门、币别
代码创建 ui.ComboBoxcreate+addItem几十到上千条中(需定位或挂载)参数画面、临时查询条件
INPUT ARRAY 弹窗数组 + DISPLAY ARRAY上千条以上高(要单独做 form)料号、客户、科目这类大主档

我个人的取舍线是:选项不超过 200 条走 combobox,超过就走弹窗。原因很简单,下拉展开后一屏最多显示十几条,超过两三百条用户就得靠滚轮硬翻,体验还不如一次弹个列表让人家看清楚列头。

2. 数据从哪来:取数策略决定了下拉的响应速度

确定了形态,接下来是核心问题——选项从数据库里怎么捞。这一步的选择直接决定用户点开下拉时是「秒开」还是「转圈三秒」。见过太多程序是在ON CHANGE里直接SELECT ... FROM customer一次性全捞,数据量一上去,整张单据都跟着卡。

2.1 全量预载和按条件取数要分场景

全量预载的意思是程序初始化时把整张主档读进内存数组,之后所有下拉都从这个数组里筛。它的优点是快——内存遍历比数据库往返快一个数量级,用户反复切换工厂、切换类别,列表都能瞬间刷新。缺点是占内存,而且主档如果在程序运行期间被别修改了,界面上的数据会是旧的。

按条件取数的意思是用PREPARE+EXECUTE拼一条带WHERE的语句,用户选了什么条件就捞什么。优点是永远拿最新数据,缺点是每次联动都要走一次数据库。

实务上的分界线看两点:主档笔数和联动频率。几百笔以内、而且主档很少变动的(部门、币别、付款条件、税率代码),闭着眼睛全量预载;上万笔而且变动频繁的(客户、料号、员工),老老实实按条件取,但取的时候必须把WHERE条件卡死,不能留空条件让人捞全表。

提示:按条件取数时,如果前置条件还没填(比如工厂没选),宁可让下拉为空并给出提示,也不要退化成无条件全捞。这条是性能事故的高发点。

2.2 用动态数组承载 key/value 对

承载列表的结构,我会统一用一个动态数组,每个元素是一组code+name。动态数组的好处是长度可变,不用预估上限,clear()之后能反复重用。

DEFINE g_arr_supp DYNAMIC ARRAY OF RECORD code STRING, name STRING END RECORD DEFINE g_arr_contact DYNAMIC ARRAY OF RECORD code STRING, name STRING END RECORD

灌数据的标准写法:

FUNCTION load_supp_array(p_factory) DEFINE p_factory STRING DEFINE l_cnt INTEGER CALL g_arr_supp.clear() DECLARE cur_supp CURSOR FOR SELECT supp_no, supp_name FROM supp_master WHERE factory = p_factory AND status = 'Y' ORDER BY supp_no FOREACH cur_supp INTO g_arr_supp[g_arr_supp.getLength() + 1].* LET l_cnt = l_cnt + 1 END FOREACH RETURN l_cnt END FUNCTION

这里有几个细节值得说。FOREACH ... INTO arr[arr.getLength()+1].*这个写法可以边读边追加,不用先算总数,比SELECT COUNT(*)再FOR i = 1 TO n少一次数据库往返。ORDER BY一定要写,下拉的顺序对用户找东西的效率影响很大,按编号排是最符合使用者习惯的,实在不行按名称排也行,但绝不能不给顺序让数据库自由发挥。

另外status这类有效性条件别忘了。生产环境的主档里通常有大量停用数据,如果不过滤,下拉里会混进一堆早就该消失的供应商,用户选错了才发现不能立账。这类问题在测试环境发现不了,因为测试库的主档往往没几笔停用资料。

2.3 显示值和存储值必须分开存

这是新手最容易搞混的地方。下拉框在界面上显示的是「名称」,但真正要存进数据库、传给下一个环节的是「编号」。如果只存一份数据,你就得在显示的时候查一次、取值的时候反查一次,代码又臭又长还容易错。

正确做法是在数组里同时保留code和name,加进 combobox 的时候用两个参数的addItem:

CALL l_cb.addItem(g_arr_supp[i].code, g_arr_supp[i].name)

第一个参数是内部值,用户看不见;第二个参数是显示文本。取值的时候拿到的永远是内部值,显示的时候永远是文本,中间不需要任何转换。

如果用老式的COMBOBOX语句,它只接受显示文本的列表,那就是「显示什么就取回什么」,这时候就得保证显示文本本身唯一,或者干脆显示「编号 + 名称」这种复合串,取回后再用SUBSTR截出编号。相比之下addItem的两参数形式从源头就把问题解决了。

3. 联动刷新:主档一变,下拉怎么跟着换

级联下拉真正的难点不在「怎么灌数据」,而在「什么时候灌、灌之前要不要清、清完之后旧值怎么办」。这三件事处理不好,就会出现「选了工厂没选供应商却能把单子存进去」这种数据脏掉的问题。

3.1 ON CHANGE 的触发时机与递归陷阱

字段变更事件在DIALOG块里用ON CHANGE捕获。最常见的写法:

DIALOG ... ON CHANGE g_factory CALL reload_supp_combo(g_factory) CALL reload_contact_combo(NULL) END DIALOG

这里有个容易被忽略的点:ON CHANGE在用户每敲一个字符、每按一次方向键时都可能触发,具体行为取决于控件的NOT NULL与触发配置。为了不做无用功,函数入口先比对一下值有没有真的变化:

FUNCTION reload_supp_combo(p_factory) DEFINE p_factory STRING IF p_factory IS NULL THEN CALL clear_supp_combo() RETURN END IF IF p_factory = g_last_factory THEN RETURN END IF LET g_last_factory = p_factory CALL load_supp_array(p_factory) CALL fill_supp_combo() END FUNCTION

用一个g_last_factory记住上次的条件,值没变就直接返回。这一招在数据量大时能省掉大量重复查询,尤其是用户按着方向键在工厂列表里来回扫的时候,效果非常明显。

还有一个更隐蔽的陷阱:联动函数内部如果改了别的字段,会触发那字段的ON CHANGE,形成链式甚至环形触发。比如选完供应商自动带出付款条件,付款条件一改又回头去重算供应商可用列表,就会死循环。防御办法是加一个g_in_link标志位,进入联动前置真、退出时置假,链路上每个联动函数开头都先判断这个标志。

3.2 重建列表的标准四步

不管用哪种形态,重建下拉都要按固定顺序走完四步,少一步就出问题:

  1. 清空旧选项。ui.ComboBox用倒序removeItem逐个删,老版本没有批量清空方法,正序删会因为下标前移漏掉一半元素。
  2. 重新取数。按上一步选定的策略重新读数组。
  3. 灌入新选项。遍历数组addItem。
  4. 处理当前值。要么置空,要么回填一个合法的默认值。
FUNCTION clear_supp_combo() DEFINE l_cb ui.ComboBox DEFINE i INTEGER LET l_cb = ui.ComboBox.forName("form1.cb_supp") IF l_cb IS NULL THEN RETURN END IF FOR i = l_cb.getItemCount() TO 1 STEP -1 CALL l_cb.removeItem(i) END FOR END FUNCTION FUNCTION fill_supp_combo() DEFINE l_cb ui.ComboBox DEFINE i INTEGER LET l_cb = ui.ComboBox.forName("form1.cb_supp") CALL clear_supp_combo() FOR i = 1 TO g_arr_supp.getLength() CALL l_cb.addItem(g_arr_supp[i].code, g_arr_supp[i].name) END FOR END FUNCTION

ui.ComboBox.forName是打通 form 声明和代码操作的桥梁,参数要写「窗体名.字段名」,窗体名对不上会拿到NULL,所以每次用之前判空是个好习惯。

3.3 旧值残留的三种处理办法

上游条件变了,下游下拉里原来选中的值很可能已经不在新列表里了。这时候有三种选择:

  • 直接清空:最简单也最安全,用户必须重新选。适合强关联的下游字段,比如工厂改了,供应商必须重选。
  • 尝试保留:如果旧值在新列表里还能找到,就保留选中,找不到再清空。适合弱关联,比如部门改了但成本中心大概率不变,能省用户一次点击。
  • 回填默认值:新列表只有一笔时自动选中。这个体验最好,但要小心——只有一笔不代表就是用户想要的,涉及金额、税率的字段不建议这么做。
FUNCTION restore_or_clear(p_old_value) DEFINE p_old_value STRING DEFINE l_cb ui.ComboBox DEFINE i INTEGER LET l_cb = ui.ComboBox.forName("form1.cb_supp") FOR i = 1 TO g_arr_supp.getLength() IF g_arr_supp[i].code = p_old_value THEN LET l_cb.value = p_old_value RETURN END IF END FOR LET l_cb.value = NULL END FUNCTION

4. 五个高频故障的排查链路

下面这几类问题,我在不同项目里反复遇到,每一个都值得单独拎出来说清楚排查顺序。直接给结论没意义,重点是让你下次遇到类似现象时知道从哪一步开始怀疑。

4.1 列表明明灌了,界面却是一片空白

现象是点开下拉没有任何选项,但你在函数里加了DISPLAY打印,数组长度明明大于零。

排查顺序按这个来:先确认ui.ComboBox.forName返回的引用不是NULL,返回NULL就说明窗体名或者字段名拼错了,这是最常见的原因;再确认 form 里那个字段的类型确实编译成了COMBOBOX,有些项目里 form 是从旧版本沿用的,字段类型还是EDIT,这种情况下forName可能拿到对象但addItem不生效;最后确认 form 文件改完之后重新编译过——只编译 4GL 不重新编译 form,界面上的控件类型永远不会变,这个坑几乎每个新手都会踩一次。

还有一个可能是setDefaultInitializer用得不当。这个机制是在下拉真正展开时才调用初始化函数,如果你在BEFORE INPUT里灌了数据,同时又挂了 initializer,展开时 initializer 会重新跑一遍并可能把已有选项清掉。要么用 initializer 做懒加载,要么在事件里手动灌,别混着来。

4.2 选完取值拿回来是编号不是名称

这通常不是 bug,而是设计如此。addItem(code, name)之后,.value返回的就是code。如果业务上确实需要名称,要么在数组里按code查一次,要么在使用处直接取getItemText。

真正需要警惕的是反过来的情况:老代码用的是COMBOBOX语句,显示什么取回什么,某天有人把显示文本从「S001」改成了「S001 甲公司」,所有依赖这个值做后续查询的地方全部失配,而且编译期毫无提示。所以只要涉及「值会被存库或被别的模块引用」,一定要用addItem的双参数形式把显示和存储解耦。

4.3 联动之后旧值没清,脏数据进了库

表现是存盘成功,但查出来发现「A 工厂的单子挂了个 B 工厂才有的供应商」。根因往往在联动时只重建了列表,没处理value。

排查的时候,在ON CHANGE函数末尾加一行日志,把清空后的value打出来。如果它还是旧值,说明重建列表的时候没有显式置NULL——removeItem删的是选项,不是当前值,控件会保留那个已经不在列表里的字符串。这一点非常反直觉,但确实是它的行为。

配套的防御是在AFTER INPUT或存盘前做一次合法性校验:拿当前value去数组里找一遍,找不到就报错拦下。

FUNCTION check_supp_valid(p_supp) DEFINE p_supp STRING DEFINE i INTEGER FOR i = 1 TO g_arr_supp.getLength() IF g_arr_supp[i].code = p_supp THEN RETURN TRUE END IF END FOR RETURN FALSE END FUNCTION

4.4 数据量大时点开下拉要等好几秒

下拉展开时卡顿,来源通常有三处。一是灌数据时在循环里做了额外查询,典型的 N+1 问题,数组里每一笔都去查一次名称,一千笔就是一千次往返;二是列表长度失控,几千个选项塞进 combobox,渲染本身就慢;三是每次ON CHANGE都重新全量取数,用户来回切几下就卡死。

优化顺序:先把循环内的查询全部提到外面用JOIN一次取完;再给列表加个上限,超过一定笔数就提示用户先输入关键字筛选;最后用前面提到的g_last_xxx变量缓存上次条件,值没变不重复取。

补充一个容易被忽略的点:ui.ComboBox的选项是全部一次性加载到客户端的。如果你有一万条主档,即使用户只会选其中一条,也要全部传过去。这种情况下应该改成「下拉只放常用值 + 弹窗查全部」的混合方案。

4.5 同一个值出现两次,选中后跳回第一条

现象是列表里明明看得见你要选的那条,点完却跳到了另一条同名的记录上。原因是addItem的第一个参数出现了重复,控件按 value 定位时只会命中第一个。

根因一般是 SQL 没去重,或者JOIN之后主表被从表放大。检查方式很简单,在灌数据前跑一遍SELECT code, COUNT(*) ... GROUP BY code HAVING COUNT(*) > 1,有结果就说明数据源本身不唯一。

现象首要怀疑点快速验证方法
下拉空白窗体名/字段名拼错打印forName返回值是否为NULL
取值是编号用了双参数addItem检查.value与getItemText的用法
旧值残留重建列表未置空value清空后打印.value
展开卡顿循环内查询或选项过多统计数组长度与 SQL 执行次数
选中跳转value 重复对数据源做GROUP BY查重

5. 一套能直接落地的骨架:客户—联系人级联下拉

前面讲的都是零件,这里拼一台能跑的整机。场景很典型:单据头选客户,明细或者联系人区块要跟着只显示该客户下的联系人。

5.1 窗体与变量的声明

form 里把两个字段都声明成COMBOBOX类型,字段名分别叫cb_cust和cb_contact,放在同一行。4GL 里的变量声明建议统一:

DEFINE g_cust_no LIKE customer.cust_no DEFINE g_contact_no LIKE contact.contact_no DEFINE g_arr_cust DYNAMIC ARRAY OF RECORD code STRING, name STRING END RECORD DEFINE g_arr_contact DYNAMIC ARRAY OF RECORD code STRING, name STRING END RECORD DEFINE g_last_cust STRING DEFINE g_in_link BOOLEAN

g_last_cust用来做条件缓存,g_in_link防止联动递归。这两个变量看着不起眼,但没有它们,程序在真实数据量下的表现会差一个档次。

5.2 初始化与两个联动函数

初始化阶段把客户列表一次性预载进来:

FUNCTION init_cust_combo() DEFINE l_cb ui.ComboBox DEFINE i INTEGER CALL g_arr_cust.clear() DECLARE cur_cust CURSOR FOR SELECT cust_no, cust_name FROM customer WHERE status = 'Y' ORDER BY cust_no FOREACH cur_cust INTO g_arr_cust[g_arr_cust.getLength() + 1].* END FOREACH LET l_cb = ui.ComboBox.forName("form1.cb_cust") FOR i = 1 TO g_arr_cust.getLength() CALL l_cb.addItem(g_arr_cust[i].code, g_arr_cust[i].name) END FOR END FUNCTION

联系人联动则按条件实时取:

FUNCTION reload_contact_combo(p_cust) DEFINE p_cust STRING DEFINE l_cb ui.ComboBox DEFINE i INTEGER LET l_cb = ui.ComboBox.forName("form1.cb_contact") IF l_cb IS NULL THEN RETURN END IF -- 倒序清空 FOR i = l_cb.getItemCount() TO 1 STEP -1 CALL l_cb.removeItem(i) END FOR LET l_cb.value = NULL IF p_cust IS NULL THEN CALL g_arr_contact.clear() RETURN END IF CALL g_arr_contact.clear() DECLARE cur_contact CURSOR FOR SELECT contact_no, contact_name FROM contact WHERE cust_no = p_cust AND status = 'Y' ORDER BY contact_no FOREACH cur_contact INTO g_arr_contact[g_arr_contact.getLength() + 1].* END FOREACH FOR i = 1 TO g_arr_contact.getLength() CALL l_cb.addItem(g_arr_contact[i].code, g_arr_contact[i].name) END FOR END FUNCTION

5.3 事件挂接与校验兜底

DIALOG BEFORE INPUT CALL init_cust_combo() CALL reload_contact_combo(g_cust_no) ON CHANGE g_cust_no IF NOT g_in_link THEN LET g_in_link = TRUE CALL reload_contact_combo(g_cust_no) LET g_in_link = FALSE END IF BEFORE FIELD g_contact_no IF g_cust_no IS NULL THEN NEXT FIELD g_cust_no END IF END DIALOG

BEFORE FIELD里拦住没选客户就想选联系人的操作,比等用户填完再报错友好得多。存盘前的兜底校验也别省:

IF NOT check_contact_valid(g_cust_no, g_contact_no) THEN ERROR "所选联系人不属于当前客户,请重新选择" NEXT FIELD g_contact_no END IF

5.4 上线前的自测清单

代码写完不要直接交给用户,这几项自己先过一遍,能省掉后面一堆返工:

  1. 客户切换三次以上,联系人列表每次都是对应的集合,没有残留。
  2. 客户选完再清空(如果允许),联系人列表同步变空。
  3. 客户 A 的联系人选好后切到客户 B,联系人是否被清空。
  4. 主档里新增一笔有效客户,不重启程序能否在刷新后出现(取决于是否取消预载)。
  5. 数据量最大的那个客户,展开联系人下拉不卡顿。
  6. 直接点存盘而不选任何下拉,必填校验是否生效、报错信息是否指向正确字段。

6. 再往前一步:缓存、自动筛选与降级处理

骨架跑通只是及格线,真要在生产环境站住,还有几件事值得做。

6.1 把主档缓存到模块级数组

客户列表、部门列表、币别列表这类几乎不变的主档,在单体程序里可以缓存到模块级变量,第一次取完之后就不再走数据库。做法是加一个g_cache_loaded标志,初始化函数入口判断一下:

FUNCTION load_cust_cached() IF g_cache_loaded THEN RETURN END IF -- ... 取数逻辑 ... LET g_cache_loaded = TRUE END FUNCTION

要注意的是缓存的生命周期。如果是长驻的批处理程序,缓存会导致主档更新后看不到新数据;如果是常规单据画面,一次会话内的缓存反而更符合用户预期——开单中途主档被别人改了,界面不跟着跳,是个好事。这个取舍要看具体业务,没有统一答案。

6.2 输入即筛选的简易实现

下拉选项多的时候,让用户输入前几个字符就能缩小范围,体验提升非常明显。Genero 的 combobox 在部分配置下支持输入过滤,但如果环境不支持,可以用一个简单的降级方案:在ON CHANGE里拿输入值去数组做LIKE匹配,重建列表。

FUNCTION filter_cust(p_keyword) DEFINE p_keyword STRING DEFINE l_cb ui.ComboBox DEFINE i INTEGER LET l_cb = ui.ComboBox.forName("form1.cb_cust") FOR i = l_cb.getItemCount() TO 1 STEP -1 CALL l_cb.removeItem(i) END FOR FOR i = 1 TO g_arr_cust.getLength() IF p_keyword IS NULL OR g_arr_cust[i].code LIKE "%" || p_keyword || "%" OR g_arr_cust[i].name LIKE "%" || p_keyword || "%" THEN CALL l_cb.addItem(g_arr_cust[i].code, g_arr_cust[i].name) END IF END FOR END FUNCTION

注意LIKE前面加通配符走不了索引,但这是内存数组遍历,不涉及数据库,所以没有性能问题。这类过滤只适合几百条的量级,上千条就要考虑把过滤下推到 SQL 里。

6.3 取数失败时不要留下半截列表

最后提一个容易被忽略的健壮性问题。如果因为主档被锁、网络抖动,取数中途失败,程序抛了异常跳出去,此时 combobox 可能处于「已经清空但没灌新数据」的状态,用户看到的是个空下拉,还以为自己没有权限。

稳妥做法是把「清空」和「灌入」放在同一个WHENEVER保护块里,异常捕获后主动把列表恢复成一个安全的默认状态,同时给出明确提示:

WHENEVER ERROR CALL clear_contact_combo() DISPLAY "联系人资料读取失败,请稍后重试" RETURN END WHENEVER

宁可让用户重新选一次,也不要让他面对一个既空着又说不清原因的下拉框。


我在几个项目上做过统计,combobox 相关的客诉里,超过六成集中在「联动后旧值没清」和「列表显示异常」这两类,真正难的是把这几十行代码在合适的时机、以合适的顺序调用起来,语法本身占的比重不到三成。所以建议在第一次动手时,先把clear → load → fill → restore这四步写成一个固定套路,之后再谈优化。另外,改动 form 里的字段类型之后,记得把 form 和 4GL 一起重新编译,只编一边的排查成本,比自己重写一遍还高。

返回列表