☰
Delphi 12 FMXUI ListView 开发实战:外观选择、数据绑定与性能优化
2026/10/16 3:58:33 网站建设 项目流程

简介:Delphi 12 FMXUI的ListView示例与组件源码包,面向使用FireMonkey框架开发跨平台应用的Delphi程序员,尤其适合需要实现复杂列表交互、自定义视图、分组折叠的中级开发者,也适合希望系统学习界面组件用法的初学者。压缩包共224个文件、约4.24MB,以100个pas单元、54个fmx窗体与布局文件、6个dproj工程文件为主体,附带png、psd设计源文件以及json等配置,目录结构清晰,便于按需查阅。内容涵盖多个可运行的示例工程,集中演示了ListView的数据绑定、列表项对象定制、分组显示、平滑动画和事件处理等常用特性,并保留了组件扩展所需的适配器框架。已有1131人学习下载,适合对照源码理解列表控件从基础渲染到高级交互的实现路径,可直接借鉴其中的界面布局与代码组织方式,既能满足快速原型验证,也能为正式项目提供可落地的列表界面方案。

1. Delphi 12 里的 FMXUI ListView 很好用,但先绕开默认外观的大坑

把 Delphi 12 的界面从 Windows 挪到 Android、iOS 上时,FMXUI 几乎是绕不开的一层,而 FMXUI 里使用频率最高、也最容易被低估的控件就是 ListView。它不是老 WinForms 里那个笨重的列表控件,而是一个自带复用机制、外观分离和滚动容器的事件驱动组件;很多人说“很好用哦”的时候,真正对应的体验是:一套代码在手机和平板上都能滚出接近原生的手感,同时能通过动态外观做出多行卡片、聊天气泡和带图片的信息流。这篇文章会从工作原理、最小跑通步骤到真实调试踩坑一次讲完,不管你是刚接触 FMX 还是已经在生产项目里维护列表页,都能拿去照着调。

2. FMXUI 里的 ListView 到底是个什么层级:先不看代码,看它和原生 ListView 的分工

2.1 FMXUI 不是一套独立 UI 库,先分清你在哪一层写代码

平时我们说的“FMXUI”,在不同人嘴里指两个东西:一个是 FireMonkey 自带的 UI 框架本身,也就是用 Delphi 做跨平台界面时的整套控件体系;另一个是社区里基于 FMX 做的一套第三方扩展组件包,名字正好也叫 FMXUI。这两个东西的层级不一样,但和 ListView 的关系却是一致的:ListView 始终是那个“显示数据列表的容器”,FMXUI 负责的是它的外观、触摸反馈和平台适配。

所以在 Delphi 12 里,你要做的第一件事不是急着找控件,而是先明确自己写在哪一层。如果你用的是官方 FMX 框架,那么 ListView 在工具箱里直接拖出来就能用,属性、事件、数据绑定都是官方的;如果你装了第三方 FMXUI 包,那几个进阶控件的写法也基本沿用了 TListView 的事件模型,只是外观封装更激进,复用逻辑藏得更深。

我个人的建议是:新项目优先用官方 TListView 把业务跑通,等确实需要特殊风格或者遇到官方外观定制太费劲的时候,再引入第三方 FMXUI 包。因为第三方包的“好用”背后往往带一套自己的缓存策略,出了问题排起来会比官方控件多花时间。官方 ListView 的坑网上已经有很多沉淀,第三方包的坑通常只有你自己踩。

2.2 默认 ListItem 外观与 DynamicAppearance:两种出图逻辑怎么选

TListView 在 Delphi 12 里有两套完全不同的出图方式,名字分别是 ListItem 和 DynamicAppearance。很多人第一次用的时候直接沿用 ListItem,发现做不出想要的两行文字加小图标的布局,就说 ListView 不好使;其实只是没切外观模式。

默认的 ListItem 外观,每一项只给你三个字段:Text、Detail 和 Image。Text 是主标题,Detail 是副标题,Image 是右侧或左侧的图标。适合做设置页、简单通讯录、下拉选择列表。这种模式下写代码最快,一个 TListViewItem 对象往里塞三个值就完事。

DynamicAppearance 则是把每一项拆成多个“外观对象”,比如两个文本、一张图片、一条分隔线,每个外观对象有独立的名称和布局参数。出图顺序由外观定义决定,代码里只负责往 Data 字典里填值。它适合做订单卡片、聊天列表、带封面的资讯流。

对比项ListItem 默认外观DynamicAppearance
单行字段数固定 3 个自定义,任意个
布局灵活度低,适合简单列表高,适合复杂卡片
代码量最少中等
运行期开销低低(外观对象复用)
常见翻车点字段不够用把外观对象当数据存,导致串行

选型标准很简单:单行文本能解决的,不要上动态外观;必须上动态外观的,从一开始就用,不要中途把一个已经铺了 50 行的 ListItem 列表改过去,那个工作量几乎等于重写界面层。

2.3 复用的发生位置:为什么滑动性能不是玄学

很多从 FireMonkey 入门的人都有一个疑问:一个 ListView 里放几千个 TListViewItem,每个 Item 如果是一堆 TLabel 和 TImage 的组合,那内存和滚动帧率怎么可能扛得住?答案是:TListView 根本不按“每行一组真实控件”的方式渲染。

在 FMX 框架里,TListView 维护的是可视窗口内的一批 Item 外壳。当手指或者鼠标滚轮让列表移动时,滚出可视区的 Item 不会立刻销毁,而是进入一个复用池;滚入可视区的 Item 会被重新赋上当前行的数据。到了 DynamicAppearance 模式下,这种复用更彻底:每行共享同一套外观描述,真正变化的是每个 Item 背后 Data 里存的字符串、颜色、图片引用。

这也是为什么滚动性能不是玄学。ScrollView 的 Content 在移动,但真正参与布局计算的只有可视区域那十几个 Item。只要不在滚动事件里创建对象、不做同步磁盘读取、不让图片解码阻塞主线程,几千行数据也能保持流畅。所谓“ListView 很好用哦”,本质就是它把“复用”这件事替你做好了,前提是你别自己打破这个机制,具体哪些操作会打破,第 5 章会一条条讲。

3. 把 Delphi 12 的 FMXUI ListView 跑通:从 Form 到首屏数据的最小工程

3.1 工程与控件的三处关键配置

这一步不讲大道理,直接说怎么把工程建出来。Delphi 12 里新建一个 Multi-Device 空工程,然后在 Form 上放一个 TListView,按照下面的顺序改三个属性。

第一处,对齐方式。把 ListView 的 Align 属性设为 Client,让它自动撑满整个 Form。不要用 Absolute 然后手工拉大小,那样换屏幕方向或不同分辨率时还要写一堆代码去适配,完全没必要。

第二处,外观模式。根据你要做的界面,把 ItemAppearance 设置为 ListItem 或 DynamicAppearance。如果你还不确定,先选 ListItem 跑通流程,后续要换再换。

第三处,行高。设置 RowHeight 为 56 或 64,单位是逻辑像素。56 适合手机上的紧凑列表,64 适合带图卡片。行高的作用不只是视觉高度,它还直接影响滚动到底部的计算,后面做懒加载时会用到。

配置完这三处,Form 上的 ListView 已经能显示空列表并正常滚动。接下来要做的是把数据填进去。

3.2 用默认外观先填 100 条数据:BeginUpdate 和 EndUpdate 的正确姿势

先从最简单的默认外观开始。新建一个按钮,点击后把它下面的代码跑一遍。

procedure TForm1.ButtonLoadClick(Sender: TObject); var I: Integer; LItem: TListViewItem; begin ListView1.BeginUpdate; try ListView1.Items.Clear; for I := 0 to 99 do begin LItem := ListView1.Items.Add; LItem.Text := '任务 ' + I.ToString; LItem.Detail := '点击查看详情 ' + (100 - I).ToString; LItem.Tag := I; end; finally ListView1.EndUpdate; end; end;

这段代码的逻辑很直白:BeginUpdate 告诉 ListView“我接下来要批量改数据,你暂时不要重绘”;循环里往 Items 集合追加 TListViewItem,并给三个字段赋值;finally 里的 EndUpdate 无论中途是否异常都会执行,此时 ListView 一次性计算所有新行的布局并重绘。

这里有两个使用习惯值得养成。第一个是 Tag 属性:TListViewItem.Tag 是一个整数,用来保存业务索引。比如订单 ID、用户 ID,而不是用字符串去存,避免滚动时因为 Item 复用而拿错数据。第二个是 BeginUpdate 和 EndUpdate 必须成对出现,而且 EndUpdate 一定要放在 finally 里。如果中间某个数据源抛出异常,列表会一直停留在“免重绘”状态,表现是数据已经加进去了但界面不刷新,排查起来非常误导人。

3.3 改用 DynamicAppearance 自定义两行小标题

默认外观只够应付简单列表,做一个带双行文本的列表卡片时,需要切换到 DynamicAppearance。先在 IDE 里完成外观定义,再写代码绑定。

在 Form 上选中 ListView1,把 ItemAppearance 改为 DynamicAppearance,然后右键控件,选择“Edit Dynamic Appearance...”。在弹出的编辑器里添加两个 TextView 外观,一个命名为 titleText,放在左边作为主标题;一个命名为 subText,放在 titleText 下方作为副标题。位置用设计器拖动调好。做完这些,Form 的单元文件里会自动生成外观定义,代码里就能按名字访问到它们。

procedure TForm1.FillDynamicData; var I: Integer; LItem: TListViewItem; LTitle: TListItemText; LSubTitle: TListItemText; begin ListView1.BeginUpdate; try ListView1.Items.Clear; for I := 0 to 99 do begin LItem := ListView1.Items.Add; // 往 Data 字典里写值,而不是直接改外观对象 LTitle := TListItemText(LItem.Data['titleText']); if LTitle <> nil then LTitle.Text := '主标题 ' + I.ToString; LSubTitle := TListItemText(LItem.Data['subText']); if LSubTitle <> nil then LSubTitle.Text := '副标题 ' + (I * 7).ToString; end; finally ListView1.EndUpdate; end; end;

这里的关键点是 LItem.Data。DynamicAppearance 模式下,每个 ListViewItem 自带一个 Data 字典,字典的键就是你在设计器里给外观对象起的名字。TListItemText(LItem.Data['titleText']) 取出来的不是新建的控件,而是当前行数据与共享外观对象之间的一个连接体。给它的 Text 赋值,最终会绘制到共享外观上。

特别提醒一句:不要在代码里自己创建 TListItemText 或任何 TextViewAppearance 对象。外观对象是 ListView 在初始化时创建并复用的,手工再建一套会导致内存被外部引用,释放时很容易出现随机指针错误。

3.4 给列表加上下拉刷新:自带 PullToRefresh 的最小实现

Delphi 12 的 TListView 自带下拉刷新能力,不需要额外装第三方包。把 ListView 的 PullToRefresh 属性设为 True,然后实现 OnPullRefresh 事件即可。

procedure TForm1.ListView1PullRefresh(Sender: TObject); begin // 模拟从本地数据库重新查询 TThread.CreateAnonymousThread( procedure var I: Integer; begin Sleep(1500); // 模拟网络耗时 TThread.Synchronize(nil, procedure begin FillDynamicData; // 收起顶部加载动画 ListView1.EndPullRefresh; end); end).Start; end;

OnPullRefresh 事件触发时,顶部已经出现了转圈或拉动动画。事件回调本身跑在主线程,所以不能在里面直接 Sleep,否则 Windows 上按钮无响应,Android 上会直接触发 ANR。正确做法是把耗时任务丢进后台线程,等数据准备好以后,用 TThread.Synchronize 回到主线程更新列表。

填写完数据后,必须调用 EndPullRefresh 让动画收起来。很多第一次用的人会忘掉这一句,结果就是列表数据已经刷新了,但顶部转圈一直挂着,看起来像卡死。EndPullRefresh 一定要放在数据填充代码之后,因为如果先收起动画再填数据,用户会看到列表突然跳动一下,观感很差。

4. 把 ListView 真正接到业务上:懒加载、多条目类型和滚动定位

4.1 不做懒加载,1000 条数据会让首屏卡 3 秒

如果把 10000 条数据一次性塞进 ListView,它依然能显示,但首屏会卡顿,因为 BeginUpdate 期间所有数据都要完成字符串创建和索引构建。生产环境里更稳妥的做法是分页加载:首屏只放 20 到 50 条,滚动到底部或顶部时再加载下一页。

procedure TForm1.LoadPage(APage: Integer); var LPageData: TArray<string>; begin TTask.Run( procedure begin // QueryPageData 是业务层函数,模拟按页查数据 LPageData := QueryPageData(APage); TThread.Queue(nil, procedure begin AppendPageData(LPageData); end); end); end; procedure TForm1.AppendPageData(const AData: TArray<string>); var I: Integer; begin ListView1.BeginUpdate; try for I := 0 to High(AData) do AddOneRow(AData[I]); finally ListView1.EndUpdate; end; end;

TTask.Run 存在于 System.Threading 单元,Delphi 12 项目里可以直接使用。LPageData 在后台线程中被赋值,然后通过 TThread.Queue 回到主线程执行 AppendPageData。这里不推荐用 Synchronize,因为 Queue 不会阻塞后台线程,连续翻页时会更顺滑。

一个必须记住的边界:进入 ListView 的 Item 数据,只能在主线程里创建和修改。后台线程里直接调用 ListView1.Items.Add,轻则随机闪退,重则破坏内存结构,而且这种问题通常只在 Release 模式下出现,Debug 下怎么跑都没事,排查成本极高。

4.2 多条目类型:一个 ListView 渲染两种卡片

做 IM 界面或信息流时,一个列表里往往同时有“纯文本行”和“带图卡片行”。FMX ListView 没有像 Android RecyclerView 那样显式的 ViewType 系统,但通过 Tag 字段和 Data 字典可以模拟出来。

在填数据时,用 Tag 标记条目类型,0 表示纯文本,1 表示带图卡片。然后在 OnUpdateItem 事件里根据类型做差异化更新。

procedure TForm1.ListView1UpdateItem(Sender: TObject; AItem: TListViewItem); var LSubTitle: TListItemText; LImage: TListItemImage; begin if AItem.Tag = 1 then begin LSubTitle := TListItemText(AItem.Data['subText']); if LSubTitle <> nil then LSubTitle.Text := '卡片类型 ' + AItem.Tag.ToString; LImage := TListItemImage(AItem.Data['coverImage']); if LImage <> nil then LImage.Bitmap := LoadCoverByIndex(AItem.Tag); end else begin LSubTitle := TListItemText(AItem.Data['subText']); if LSubTitle <> nil then LSubTitle.Text := ''; // 文本行不显示副标题 end; end;

OnUpdateItem 是 TListView 在某个 Item 即将进入可视区时触发的事件。它非常适合做按需赋值的逻辑,但有一条红线:不要在事件里创建任何对象,也不要做耗时的文件读取。这个事件在滑动过程中会被频繁调用,一旦某一次耗时超过 50 毫秒,滚动立刻掉帧。

如果你发现多条目类型的列表在滑动时偶尔出现“内容闪了一下”,大概率是在 OnUpdateItem 里给图像位图加载了原图。解决办法是把 Bitmap 的加载丢进后台线程,主线程只负责赋值已加载完成的位图引用。位图本身也要做一层内存缓存,同一张图不要反复从磁盘解码。

4.3 滚动到底部自动加载下一页:用 ScrollView.Content 的视口位置

分页数据联动滚动,是列表业务里最常见也最容易写错的一环。TListView 内部有一个 ScrollView,滚动发生在它内部的 Content 上。通过监听 Content 的视口位置变化,可以精确判断是否触底。

在 FormCreate 里,或者在列表初始化完成后,先取到滚动内容对象,再挂上视口位置变化事件。

var LScrollContent: TScrollContent; begin LScrollContent := ListView1.ScrollView.Content as TScrollContent; LScrollContent.OnViewportPositionChange := HandleScrollChange; end;

事件处理函数里,用当前滚动位置和最大滚动位置做差值判断。

procedure TForm1.HandleScrollChange(Sender: TObject; const OldViewportPosition, NewViewportPosition: TPointF; const ContentSizeChanged: Boolean); var LMaxScroll: Single; LRemain: Single; begin LMaxScroll := ListView1.ScrollView.ContentBounds.Height - ListView1.ScrollView.Height; LRemain := LMaxScroll - NewViewportPosition.Y; // 小于 48 视为触底,提前触发加载 if (LRemain < 48) and (not FLoadingPage) then begin FLoadingPage := True; LoadPage(FCurrentPage + 1); end; end;

FLoadingPage 是一个布尔标志,防止一次滚动到底连续触发多次翻页。加载完成后,在 AppendPageData 的 finally 里把 FLoadingPage 设回 False。这里有一个细节:如果当前页加载的数据量少于预期,滚动位置不会继续前进,OnViewportPositionChange 不会再次触发,所以要在 AppendPageData 里判断返回行数,发现不足一页时直接结束,避免死循环翻页。

4.4 列表定位:回到顶部和跳到指定行

业务上经常需要“加载完成后回到顶部”或者“点击某个索引后滚动到对应行”。最直接的做法是操作 ScrollView.Content 的 Position。

// 回到顶部 ListView1.ScrollView.Content.Position.Y := 0; // 跳到第 20 行 ListView1.ScrollView.Content.Position.Y := 20 * ListView1.RowHeight;

用索引乘以行高的方式定位,前提是列表里所有行高一致。如果你的 DynamicAppearance 里有些行高度不一致,这个公式就不适用,需要改用 Item 的 Position 计算:先通过 ListView1.Items[Index] 拿到目标 Item,用它的 Position.Y 作为滚动目标。FMX 里 TListViewItem 继承自视觉对象,Position.Y 表示它在内容坐标系里的位置,这是比 RowHeight 更可靠的定位基准。

5. FMXUI ListView 的避坑与排查:这 5 个坑,我每个都踩过

5.1 快速滑动白屏闪烁

现象:列表在 Android 上快速滑动时,部分区域突然白屏,松手后立刻恢复;Windows 上不明显,真机上随机出现。

原因:白屏背后的直接原因是没有足够的 Item 外壳进入复用池,或者 OnUpdateItem 里做了耗时的对象创建,导致 ListView 来不及填充进入可视区的行。Debug 模式下因为编译优化不同,问题表现不明显,Release 模式才暴露。

解决:首先确认外观模式是 DynamicAppearance,不要在 OnUpdateItem 里做对象创建;其次把图片加载全部移入后台线程,图片加载完成前用纯色占位;最后,把 ListView 的 RecycleItems 相关属性打开,FMX 在部分版本里需要显式启用 Item 回收,在对象检查器里找到 RecycleItems 并设为 True。

5.2 OnPullRefresh 里直接 Sleep,Android 直接 ANR

现象:在 PullToRefresh 事件里写 Sleep(1500) 模拟网络延迟,Windows 上只是界面卡一下,Android 上跑几秒后弹出“无响应”对话框。

原因:OnPullRefresh 回调运行在主线程,Sleep 会阻塞 UI 消息循环。Android 系统检测到主线程长时间不响应,就会触发 ANR。

解决:把耗时操作放进 TThread 或 TTask,数据准备好后通过 TThread.Synchronize 回主线程更新列表,最后调用 EndPullRefresh 收起动画。记住一个通用规则:凡是 ListView 的回调事件,内部都不能有超过 16 毫秒的同步阻塞。

5.3 所有行显示同一条数据

现象:数据明明正确添加了,但列表滑动后所有行都变成第一行的内容,而且这个 bug 不是必现,有时候滑两下才出现。

原因:最常见的原因是把文字赋值给了共享外观对象本身,而不是 Item 的 Data。在 DynamicAppearance 模式下,所有 Item 共享同一套 TextViewAppearance,如果你在 OnUpdateItem 里写了 TListItemText(SomeSharedAppearanceObject).Text := X,那这个赋值会影响所有正在显示的行。

解决:赋值统一走 AItem.Data['key'] 入口,不要直接持有外观对象引用并赋值。每次滚动时 OnUpdateItem 会重新触发,Data 里存的才是当前 Item 的独立数据。排查时先在 OnUpdateItem 里加断点,看 Data['key'] 取出来的对象地址是不是全局只有一份。

5.4 Release 包样式丢失,Debug 正常

现象:同一个工程,Debug 编译运行一切正常;切到 Release 配置后,ListView 的样式消失,或者一打开列表界面就直接崩溃,错误信息指向 Style 相关代码。

原因:FMX 的样式资源在 Release 模式下会被链接器优化掉。尤其是动态加载的场景,某些外观对象在编译时没有被直接引用,链接器认为“这段资源没用”就裁剪了,运行时才报缺失。

解决:在项目选项的 FMX 相关配置里,把用到的 Style 资源从“自动裁剪”改成“保留”,或者在 Form 上放一个 StyleBook 并显式指定它,强制执行样式解析。另外一个更彻底的做法是,在代码里用 TStyleManager 加载一个嵌入资源样式文件,这样风格资源不会被裁剪。

5.5 切换深色主题后,ListView 文字还是黑色

现象:应用支持深色模式,切换主题后其他控件都变色了,只有 ListView 里已经加载出来的行还保持旧颜色,滑动新行出现后颜色又恢复正常。

原因:主题切换时,已经创建的外观对象没有被强制重建。FMX 的通知机制会通知控件树,但 ListView 的共享外观对象不会主动刷新已经物化的文本颜色,要等 Item 销毁重建后才生效。

解决:在主题切换完成后,主动让 ListView 重建当前可见 Item。常见做法是做一个 ApplyListViewTheme 过程,在里面遍历当前 Items,重新给 Data 里的 TListItemText 设置颜色,然后调用 ListView1.EndUpdate 刷新。具体代码放在第 6 章,那是一个可以抄走的模板。

6. 收尾技巧:把外观颜色、行高和间距做成一套主题适配函数

很多人的 ListView 代码到最后会变成一团乱麻:行高在 FormCreate 里设了一次,颜色在 OnUpdateItem 里设了一次,间距又放在设计器里,深色模式一来,要翻三个地方去改。我现在的习惯是,所有和列表外观相关的参数都收敛到一个过程里,界面代码只负责业务数据。

procedure TForm1.ApplyListViewTheme; var LText: TListItemText; LSubTitle: TListItemText; begin ListView1.BeginUpdate; try ListView1.RowHeight := 64; ListView1.ItemSpacing := 4; if TStyleManager.ActiveStyle.Name.Contains('Dark') then begin LText := TListItemText(ListView1.Items[0].Data['titleText']); if LText <> nil then LText.TextColor := TAlphaColorRec.White; LSubTitle := TListItemText(ListView1.Items[0].Data['subText']); if LSubTitle <> nil then LSubTitle.TextColor := TAlphaColorRec.Lightgray; end else begin LText := TListItemText(ListView1.Items[0].Data['titleText']); if LText <> nil then LText.TextColor := TAlphaColorRec.Black; LSubTitle := TListItemText(ListView1.Items[0].Data['subText']); if LSubTitle <> nil then LSubTitle.TextColor := TAlphaColorRec.Gray; end; finally ListView1.EndUpdate; end; end;

这段代码只在列表为空或首屏刚初始化时调用一次。要适配旋转,就在 Form 的 OnResize 里根据当前宽度动态微调行高和字号,但不重新创建 Item,只改外观参数。ListView 内部会按新行高重新布局,不需要你手动清空 Items。

FormCreate 里把 OnResize 事件挂上,然后调用一次 ApplyListViewTheme。之后不管系统切深色模式,还是横竖屏切换,都能保持一致的列表观感。这个收敛配置的习惯帮我少改了很多重复代码,也希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询