十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Delphi FMX TStringGrid表头点击排序实现与排雷指南

Delphi FMX TStringGrid表头点击排序实现与排雷指南 简介资源包围绕Delphi XE下FireMonkey FMX框架中StringGrid点击表头排序的实现面向有基本Delphi桌面或移动开发经验的开发者。内含完整的示例工程与源码覆盖VCL与FMX两个分支涵盖Pas源文件、Dpr工程、Dfm/Fmx界面定义、Dcu编译单元、Res资源及编译好的Exe可执行文件共72个文件压缩包大小7.71MB结构和命名清晰便于直接打开工程查看或运行对比。已获得430人学习下载。通过该资源可以掌握监听列头点击事件、获取当前列索引、升序/降序自动切换、对数据源执行排序以及刷新表格显示等完整处理流程同时附带工程配置、部署清单和Android相关文件对跨平台界面开发也有参考价值。针对动态数据源和大量数据场景资源还给出了缓存与性能优化的注意事项可直接借鉴到实际业务数据表格中。 当你在 Delphi XE 里用 FireMonkey 框架拖一个 TStringGrid 到界面上灌进几十行数据后第一反应往往是点一下表头试试能不能排序。结果大概率是没反应。FMX 的 TStringGrid 和 VCL 时代的 StringGrid 结构差异很大表头本身是独立的 Header 组件数据区也不像 VCL 那样把第 0 行预留成标题行而且它默认就没有把表头点击排序这个功能内置进去。这篇文章要解决的问题就是这个给 FMX 的 TStringGrid 自己实现一套点击表头排序的方案。包括事件入口怎么处理、数据怎么临时存取、比较函数怎么写才能兼顾字符串/数值/日期、排序之后怎么把整行数据正确写回网格以及实际项目中必然会遇到的空值、小数分隔符、闪烁和性能问题。如果你刚好在用 Delphi XE 到 Delphi 12 之间的任何版本并且手里有一个非 LiveBindings 绑定模式的 StringGrid这篇文章里的方案可以直接抄作业。1. 为什么 FMX 的 StringGrid 不能直接点表头排序先搞清楚根本原因才不会在写代码时反复怀疑人生。FMX 的 TStringGrid 内部结构跟 VCL 的一个很大区别是列标题通过 Columns 集合里每个 TColumn 对象的 Header 属性来维护它不占用 Cells 的行号。也就是说当你设置RowCount : 10并访问Cells[col, row]时这个 row 是从 0 到 9 的数据行全部是真实数据没有一行是“标题行”。很多从 VCL 切过来的兄弟在这里最容易翻车排序时下意识跳过第一行结果把第一行数据给丢了或者把列头文字塞进Cells[0, 0]排序后表头跟着数据一起乱跑。另一个原因是排序策略本身就牵扯很多选择。你点表头系统只知道你点了“第几列”并不知道这一列到底是数值、日期、还是普通文本。直接拿字符串 CompareStr 比较是最省事的写法但“10”会排在“9”前面日期“2024-01-05”也拼不过“2024-01-25”这肯定不是你要的效果。所以排序功能虽然看着小实际上需要你自己搞定三个环节捕捉点击事件、判断当前排序列、设计一个足够健壮的比较函数。第三个原因是刷新逻辑。FMX StringGrid 没有像 TListView 那样完善的缓存和虚拟化机制非绑定模式下往 Cells 里写数据就会触发 UI 刷新。如果数据量到几千行每排序一次全量读写一遍单元格界面会明显卡顿和闪烁。这些坑你不在动手前有预期写出来的代码就只能在 demo 里跑一上真实数据就露馅。2. 动手前的三个准备2.1 确认事件入口和列索引FMX 的 TStringGrid 提供了 OnColumnClick 事件签名是procedure TForm1.Grid1ColumnClick(Sender: TObject; const Column: TColumn);用户点击表头时会触发参数里的 Column 对象能直接拿到列信息Column.Index就是我们要的排序列序号。先把事件挂上在里面写一句调试代码验证触发然后再开始做排序。有个小细节如果你在项目里用了自定义 TColumn 子类或者第三方网格组件事件签名可能略有差异比如某些版本事件里带var Handled: Boolean参数。遇到这种情况不需要纠结签名直接在事件里取 Column 对象用即可。如果测试时发现点表头没反应先检查两个地方StringGrid 的 ShowHeader 属性是不是 True以及事件是不是真的绑定到了右侧的 StringGrid 而不是外层某个 Layout 上。2.2 排序状态存哪排序不能每点一次都从头升序否则用户会很烦躁。常规做法是在 Form 的私有字段里记录两个值private FSortColumn: Integer; FSortAsc: Boolean;逻辑是点击同一列时把方向反转点击新列时重置为升序。如果你有“记住每一列上次方向”的需求可以扩展成一个TDictionaryInteger, Boolean我后面在进阶部分讲。2.3 先弄清楚 Cells 怎么读怎么写在非绑定模式下TStringGrid 的数据访问非常简单// 读第 3 列第 5 行 S : Grid1.Cells[2, 4]; // 写第 2 列第 0 行 Grid1.Cells[1, 0] : Hello;行号从 0 开始全部是数据行。你要排序本质上就是把这组二维格子里的内容重新排列。先把这个基础理解透后面所有代码都建立在这个模型上。3. 核心实现表头点击后的排序逻辑3.1 先把整张表读进二维数组排序时如果你只对点击列做交换那整行数据就散了第 2 列排好了第 1 列和第 3 列还停在原位置表格彻底乱掉。所以第一件事是把网格内容整体搬进一个临时二维数组type TCellArray array of array of string; function GridToArray(SG: TStringGrid): TCellArray; var r, c: Integer; begin SetLength(Result, SG.RowCount, SG.ColumnCount); for r : 0 to SG.RowCount - 1 do for c : 0 to SG.ColumnCount - 1 do Result[r, c] : SG.Cells[c, r]; end;这个操作的目的是让数据和 UI 暂时解耦。后续无论做多少次交换、排序都是对内存数组操作等到最终结果确定了再一次性回写到网格里。我在一万行、8 列的网格上实测过用这个方案排序耗时几乎可以忽略而如果直接在 Cells 上反复交换界面会卡顿得让人怀疑程序死掉了。3.2 比较函数是灵魂排序算法可以无脑用快速排序但比较函数必须自己写。一个合格的比较函数要做三件事去空格、识别数值、识别日期最后才退回字符串比较。uses System.SysUtils, System.Math; function CompareCellValues(const S1, S2: string): Integer; var V1, V2: Double; D1, D2: TDateTime; S1Trim, S2Trim: string; begin S1Trim : Trim(S1); S2Trim : Trim(S2); // 都按数值解析 if TryStrToFloat(S1Trim, V1) and TryStrToFloat(S2Trim, V2) then Exit(CompareValue(V1, V2)); // 都按日期解析 if TryStrToDate(S1Trim, D1) and TryStrToDate(S2Trim, D2) then Exit(CompareValue(D1, D2)); // 默认按字符串 Result : CompareText(S1Trim, S2Trim); end;这里有个坑必须要提醒TryStrToFloat 默认使用系统区域设置。如果界面里存的是英式小数“1,234.56”而你系统区域是简体中文函数就会解析失败只能退回字符串比较排序结果照样错。稳妥做法是统一使用不变区域设置我下面会把这段代码再补全。3.3 用 TArray.Sort 对行索引排序排序时不应该动二维数组本身而是维护一个“行索引数组”把数据行的原始下标打乱重排。例如原始行号是 [0, 1, 2, 3]排序后变成 [2, 0, 3, 1]最后按这个映射关系写回网格即可。这样做的好处是交换成本极低只需要移动整数索引。type TRowIndexArray array of Integer; procedure WriteBack(SG: TStringGrid; Data: TCellArray; Indices: TRowIndexArray); var r, c: Integer; begin for r : 0 to High(Indices) do for c : 0 to SG.ColumnCount - 1 do SG.Cells[c, r] : Data[Indices[r], c]; end;这里用了 Delphi XE 之后就可以用的泛型排序容器。需要引入System.Generics.Collections和System.Generics.Defaults两个单元TArray.SortInteger(Indices, TComparerInteger.Construct( function(const L, R: Integer): Integer begin Result : CompareCellValues(Data[L, FSortColumn], Data[R, FSortColumn]); if not FSortAsc then Result : -Result; end ) );TArray.Sort 在内部实现上是内省排序最坏情况是 O(n log n)不会像简单冒泡那样在数据量上来后直接崩掉。如果你用的 Delphi 版本比较老连泛型匿名函数都支持不顺那就用经典快速排序手写一个比较器思路完全一样。3.4 完整的事件处理器把上面的零件拼起来就是一个能用的表头排序功能procedure TForm1.Grid1ColumnClick(Sender: TObject; const Column: TColumn); var Data: TCellArray; Indices: TRowIndexArray; i: Integer; begin if Column.Index 0 then Exit; // 点击同一列时切换方向点击新列时默认升序 if Column.Index FSortColumn then FSortAsc : not FSortAsc else begin FSortColumn : Column.Index; FSortAsc : True; end; Data : GridToArray(Grid1); SetLength(Indices, Grid1.RowCount); for i : 0 to Grid1.RowCount - 1 do Indices[i] : i; TArray.SortInteger(Indices, TComparerInteger.Construct( function(const L, R: Integer): Integer begin Result : CompareCellValues(Data[L, FSortColumn], Data[R, FSortColumn]); if not FSortAsc then Result : -Result; end ) ); WriteBack(Grid1, Data, Indices); end;到这里你已经可以点击表头并按字符串/数值/日期规则排序了。先别急着跑下面几个问题几乎是必现的我直接帮大家列出来。4. 实际排雷测试记录4.1 空值的处理策略如果数据列里有空字符串比较函数直接去比对会让空值排在最前面而且经常和“0”或“空行”混在一起看着很奇怪。我的建议是做一个统一策略空值一律排在最后或者最前取决于产品需求。function CompareWithNull(const S1, S2: string): Integer; var S1Trim, S2Trim: string; begin S1Trim : Trim(S1); S2Trim : Trim(S2); if S1Trim then begin if S2Trim then Exit(0) else Exit(1); // S1 为空排后面 end else if S2Trim then Exit(-1); // 继续走原来的数值/日期/字符串比较 Result : CompareCellValuesAlternative(S1Trim, S2Trim); end;建议把空值判断放在比较函数的最前面因为空值是最高优先级的“特殊值”。4.2 小数点和区域格式这个坑我在一个面向国内外同时部署的项目里踩得很深。同样的代码国内设备正常国外设备排序结果却不对。原因就是小数分隔符在不同系统区域下的差异。解决办法是排序比较时强制使用不变格式不要依赖用户区域设置var FS: TFormatSettings; begin FS : TFormatSettings.Invariant; // Delphi XE8 可用 end;如果你还在 Delphi XE没有 TFormatSettings.Invariant就手动初始化FS : TFormatSettings.Create; FS.DecimalSeparator : .; FS.DateSeparator : -; FS.ShortDateFormat : yyyy-MM-dd;然后在 TryStrToFloat 和 TryStrToDate 里传入这个 FSif TryStrToFloat(S1Trim, V1, FS) and TryStrToFloat(S2Trim, V2, FS) then Exit(CompareValue(V1, V2));这么做以后排序逻辑和界面显示格式解耦无论设备区域怎么变排序结果都能保持一致。显示层你该用本地化格式还是用本地化排序层用不变格式这是最干净的做法。4.3 排序时偶发 EListError 或索引越界FMX 的字符串网格在排序过程中如果触发视图重建或者 Selection 变化偶尔会出现列表操作冲突。这种问题很闹心因为不是必现但出现一次就够头疼。我的经验是在 OnColumnClick 事件一开始就把Column.Index存到一个整数局部变量里后续所有判断、比较、排序都用这个整数不要在任何排序逻辑里继续访问 Column 对象本身。排序完成后如果需要更新列头文字或重新定位选中行也放到事件方法的最后面统一做。这样能把 UI 对象生命周期的问题降到最低。4.4 大数据量重绘闪烁几千行数据每排序一次就全量刷新网格界面闪烁是必然的。FMX 的 TStringGrid 没有公开的 BeginUpdate/EndUpdate 方法如果你用的 Delphi 新版本能直接编译就用它编不过我提供一个“土法锁定绘制”// 排序前保存状态 OldRowCount : Grid1.RowCount; OldSelected : Grid1.Selected; // 临时缩小行数让网格内部不渲染那么多行 Grid1.RowCount : 1; try // 排序并 WriteBack finally Grid1.RowCount : OldRowCount; if OldSelected nil then Grid1.Selected : OldSelected; end;我实测过这个办法确实能把闪烁压到一个很低的程度。代价是临时缩小 RowCount 会触发 Selection 变化所以要把 Selected 先保存排序后恢复。5. 进阶需求箭头指示、绑定模式和扩展方向5.1 在表头上显示排序方向和箭头排序列最好给用户一个视觉反馈。FMX 里表头文本通过Columns[i].Header控制所以最简单的方法是动态拼接箭头字符procedure RefreshHeader(SG: TStringGrid; const OriginalHeaders: TArraystring); var i: Integer; begin for i : 0 to SG.ColumnCount - 1 do begin if i FSortColumn then begin if FSortAsc then SG.Columns[i].Header : OriginalHeaders[i] ^ else SG.Columns[i].Header : OriginalHeaders[i] v; end else SG.Columns[i].Header : OriginalHeaders[i]; end; end;跨平台要注意字符渲染。Windows 下用 ▲ ▼ 完全没问题但到了 Android 或 iOS 上某些字体会把特殊符号渲染成小方块或不显示。为了兼容性我最后选择了 ASCII 字符^和v虽然没那么好看胜在所有平台一致。 如果你项目要求高颜值可以改成在 TColumnHeader 里放一个 TImage但那需要定制样式成本高不少看需求决定。5.2 每一列的排序方向独立记忆如果希望用户点 A 列升序、点 B 列还是升序、再点回 A 列保持降序可以引入字典private FSortState: TDictionaryInteger, Boolean;点击逻辑改成// 先取该列上次方向 if FSortState.TryGetValue(Column.Index, FSortAsc) then FSortAsc : not FSortAsc else FSortAsc : True; FSortState.AddOrSetValue(Column.Index, FSortAsc);记得在 Form 的 OnDestroy 里释放FSortState.Free;5.3 用 LiveBindings 绑定的 StringGrid 怎么办如果你用的是 LiveBindings 模式拖一个 ClientDataSet 或 TFDMemTable 到设计器再绑定到 StringGrid千万不要用上面这套 Cells 读写方案。那个模式下StringGrid 只是数据源的展示层你要排序的对象应该是数据源本身。以 TFDMemTable 为例FDMemTable1.IndexFieldNames : FieldName:D; // D 表示降序A 表示升序TClientDataSet 可以用ClientDataSet1.IndexName : idx_Rank;数据源排序后StringGrid 会自动刷新这是最干净、性能也最好的方案。所以如果你的项目允许建议优先走 LiveBindings排序逻辑都交给数据组件省去自己处理兼容性的麻烦。6. 一些心得和收尾建议排序功能看着不起眼但牵扯的细节一点不少。比较函数的健壮性、区域格式、空值策略、刷新锁定每一条都直接影响最终体验。建议把排序逻辑单独封装成一个类比如 TGridSorter把 GridToArray、CompareCellValues、WriteBack 都放进去窗体只需要在 OnColumnClick 里调用一个方法后续换项目、换网格组件都能直接复用。关于区域格式我最后再唠叨一遍凡是涉及数值比较的功能统一先用 Invariant 格式解析再按用户区域格式回显。这样排序结果不受设备地域影响部署到任何地方行为都是一致的。我们内部有一句话排序的归排序显示的归显示两个逻辑不要混在一起。如果你还有更高的需求比如多列组合排序先按 A 列再按 B 列、排序后保持选中行位置、或者对十万行数据做虚拟化排序这个方案也留了扩展空间。多列排序的核心是让比较函数依次比较多列的值保持选中位置则是在排序前记录当前选中行索引排序后重新定位。沿着这个思路往下改代码不会走弯路。本文还有配套的精品资源点击获取
返回列表