ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Delphi 12 FMX TListView 渲染机制与性能优化实践

Delphi 12 FMX TListView 渲染机制与性能优化实践

简介:面向使用 Delphi 12 与 FireMonkey 进行跨平台界面开发的初中级开发者,这份源码包围绕 FMXUI 中的 ListView 组件展开,可用于学习数据绑定、列表项自定义、点击与焦点交互、分组折叠、滚动动画、排序过滤以及适配器扩展等常见实现,适合需要为 Windows、macOS、iOS、Android 与 Linux 多端项目搭建现代列表界面的读者参考。压缩包共 224 个文件,约 4.24MB,以 100 个 pas 源码和 54 个 fmx 窗体文件为主体,辅以 png 界面素材、txt 说明、dproj/dpr 工程与包文件,结构上覆盖多个可运行示例和预览组件。已有 1134 人学习,便于通过示例工程理解 ListView 的布局组织、数据呈现与交互处理,并借鉴到实际多平台应用开发中。

1. 为什么 FMX 的 ListView 值得先啃:一次列表渲染到底卡在哪

在 Delphi 12 里做跨平台界面,只要页面上出现超过二十条同构数据,第一个被拉出来试的几乎都是 TListView。我最初把它当普通列表控件用,往每条记录里塞面板和按钮,结果在一台旧安卓机上滚两屏就开始掉帧——FMX UI 的渲染路径和 VCL 根本不是一回事。后来把 ItemAppearance 和对象复用这两件事弄明白,才发现它确实省事:同一批 Delphi 12 源代码,Windows、Android、iOS 三个目标平台都能跑,不用为每个平台再写一遍列表。这篇拆的是一个通讯录样式的列表模块,从外观配置到批量刷新再到异步图片,适合正在用 FMX 做业务界面、被滚动和刷新折腾过的人。

2. TListView 的渲染骨架:Item、Appearance、平台层怎么分工

2.1 一个 item 从数据到像素要经过哪三层

TListView 表面上是一个列表控件,拆开看其实是三样东西叠起来的:TListViewItem、TItemAppearance,还有平台渲染层。

第一层最容易误解。TListViewItem 不是 TControl,它没有 Handle,没有 Parent,也没有 BoundsRect,本质上就是个数据容器。很多从 VCL 搬过来的写法,一上来就想往 item 里挂 TEdit、TButton,卡在这里往往不是 API 不熟,而是方向本身就不对——这个列表压根没打算让你往里塞控件。它想让你做的是:把数据放进 item,把长什么样交给外观去描述。

第二层是 TItemAppearance。它描述一行里有哪些槽位、每个槽位放在什么位置。槽位类型是固定的几种:文本(Text / Text2 / Text3)、详情(Detail)、图片(Image)、附件(Accessory)、行内按钮(GlyphButton / Button)。这些槽位不是控件实例,是绘制描述,ListView 在滚动时按描述把内容画到对应位置。所以调样式的时候,你改的是这份描述,不是某个 Label 的属性。

第三层是渲染。滚动时 ListView 只对可见范围做绘制,一屏大概十来条;但这不等于 Items 集合里只存了十来条对象。你往里加 5000 条,就有 5000 个 TListViewItem 实实在在躺在内存里,只是暂时没被画出来。估算内存和首屏耗时的时候,这个区别很关键:卡顿往往不是画得慢,而是构建这 5000 个对象慢。

这也是 TListView 和 TListBox 两条路线的分水岭。TListBox 的每一项更接近一个真正的控件容器,单项内放控件的自由度大,但项数一多,控件的创建和销毁成本就压不住。TListView 用“数据项 + 外观描述”换来了规模上的稳定,代价就是单个 item 内部的交互要绕着走。

2.2 十分钟跑通一个能滚的列表:最小代码骨架

先把最小闭环跑起来,再谈优化。下面这段放在窗体单元里,假设窗体上已经放了一个 TListView,命名为 ListView1。

type TContact = class public ID: string; Name: string; Phone: string; constructor Create(const AID, AName, APhone: string); end; procedure TFormContactList.FormCreate(Sender: TObject); begin // 1) 外观先定下来。设计期也能设,写在代码里便于版本管理 ListView1.ItemAppearance.ItemAppearance := TItemAppearanceType.ListItem; ListView1.ItemAppearance.ItemHeight := 64; // 2) 准备数据源。真实项目里来自数据库或接口,这里用内存对象代替 FContacts := TObjectList<TContact>.Create(True); FContacts.Add(TContact.Create('C001', '张三', '138****0001')); FContacts.Add(TContact.Create('C002', '李四', '138****0002')); LoadContacts; end; procedure TFormContactList.LoadContacts; var I: Integer; Item: TListViewItem; begin ListView1.BeginUpdate; // 挂起逐条重绘 try ListView1.Items.Clear; // 清空也放在挂起区间内 for I := 0 to FContacts.Count - 1 do begin Item := ListView1.Items.Add; // 创建的是数据项,不是控件 Item.Text := FContacts[I].Name; // 映射到当前外观的 Text 槽位 Item.Detail := FContacts[I].Phone; // 映射到 Detail 槽位 Item.TagString := FContacts[I].ID; // 业务主键,字符串类型 Item.TagObject := FContacts[I]; // 原始对象引用,不转移所有权 end; finally ListView1.EndUpdate; // 无论中间是否抛异常都要成对调用 end; end;

BeginUpdate / EndUpdate 是这段代码里最重要的两行。不加的话,每 Add 一条都会触发一次内部重排和重绘,两百条数据就是两百次布局,肉眼能看出加载过程一条条蹦出来。加上之后,构建阶段只做数据写入,EndUpdate 时一次性重排。

Item.TagString和Item.TagObject的区别值得单独说。TagString 是 string 类型,长度没有硬性上限,适合放主键、GUID、状态码这类东西,它在内部是跟着 item 一起管理的,列表清空时一起没。TagObject 是一个裸对象指针,ListView 不持有它的所有权,也不负责释放,Clear 的时候只是把引用丢掉——这一点后面避坑章节还会再提一次,因为它是最容易埋雷的地方。

点击事件回读数据:

procedure TFormContactList.ListView1ItemClick(const Sender: TObject; const AItem: TListViewItem); var C: TContact; begin // 用 TagObject 拿回业务对象,比按 Index 反查数组稳 if AItem.TagObject is TContact then begin C := TContact(AItem.TagObject); Caption := Format('%s / %s', [C.Name, C.Phone]); end; end;

这里刻意没用AItem.Index。Index 是当前在集合里的位置,删掉一条之后后面的索引全变,一旦列表支持删除,按索引回查就是定时炸弹。

2.3 选型:TListView、TListBox、TVertScrollBox 怎么挑

同样是列表需求,三个控件适合

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进