简介:面向Delphi 12开发者的FMXUI源码学习包,以FireMonkey框架中ListView组件实战为主线,从基础数据绑定到分组、动画等高级扩展,围绕实际开发中常见的列表交互场景,适合需要在Windows、macOS、iOS、Android等多平台构建统一界面,并处理从简单文本列表到图文混合列表的Delphi中级与进阶开发者。压缩包共224个文件,大小约4.24MB,以100个Pascal源文件、54个FMX界面文件和33个图片资源为主体,Pascal源码展示控件的创建与数据流动,FMX文件对应界面布局,图片资源用于图标和示例效果;同时附带项目管理文件与配置,目录结构清晰,方便快速定位模块。示例工程覆盖基础列表、动态分组、自定义卡片等多种形态,可分别运行并对比代码调整对界面的影响,目前已有1131人学习浏览,适合不同阶段的开发者按需取用。内容覆盖ListView的数据绑定、使用ItemObjects定制列表项外观、处理点击交互、分组折叠、平滑滚动与动画、排序过滤,以及继承TListViewBaseAdapter扩展自定义功能等关键开发点。借助这些源代码,既能从零理解FMXUI列表的组装逻辑,也能参考现成列表项的复杂布局与交互写法,灵活迁移到自己的项目,缩短跨平台界面开发周期。
1. 从一次界面重构说起:FMX ListView 为什么会成为首选
有一次我在做一个跨平台应用,数据量不算大,但界面交互多:每行要放图片、标题、状态标签,还要支持下拉刷新。最初用 ScrollBox 套 Frame 实现,写起来很顺手,一跑起来就翻车——列表滚动掉帧,内存高居不下。后来把整个工程的长列表都换成 Delphi 12 的 FMXUI 里自带的 TListView,同一个页面在低端 Android 上也滚得丝滑,代码量反而少了三成。这个项目没有太多玄学,核心结论就一句话:在 FMX 里做列表展示,TListView 是最值得优先选用的组件。适合谁看?刚接触 FMX 想找捷径的人,以及已经用了 ListView 但搞不清内部机制、想自己动手封装改造的人。
2. 技术选型与工程骨架:Delphi 12 FMXUI 的工程结构
2.1 FireMonkey 框架与 ListView 的组件定位
Delphi 12 的 FMXUI 指的就是 FireMonkey 这套自绘 UI 框架。它和 VCL 最大的区别在于:VCL 的按钮、编辑框在 Windows 上调用系统原生控件,而 FMX 把所有控件都绘制在自己的画布上。好处非常实际——同一套源码编译出的 Android 包和 Windows 程序,界面观感基本一致,不会出现 Windows 上好好的按钮到 Android 上变回系统默认风格的情况。
TListView 在这套框架里的定位是“高性能列表容器”。它内部按可视区域管理条目,滚动时不断回收复用 item,而不是把所有行实例化一遍。这个机制和 Android 的 RecyclerView 是同一个思路,也是它和 ScrollBox 方案拉开差距的根本原因。
在工程结构上,凡是用到 ListView 的项目,我一般会把代码拆成四层:
- 窗体层:放 TListView、TButton、TLayout 等可视控件,负责交互
- 数据层:用 TList 或对象列表保存业务数据,不关心界面
- 外观层:对应 ListView 的样式和动态外观,决定每行长什么样
- 事件层:处理点击、下拉刷新、滑动触发加载等逻辑
把这四层分清楚之后,ListView 在工程里就只是一个“视图容器”,数据怎么组织、外观怎么变化都跟控件本身解耦。后面做二次封装时,这个分层能省掉大量重复劳动。
2.2 手动装配与 LiveBindings:两条数据路径怎么选
ListView 填数据有两条常见路径。先说手动装配,这也是我推荐多数场景使用的方式:
listView1.BeginUpdate; try for i := 0 to FNewsList.Count - 1 do begin Item := listView1.Items.Add; Item.Text := FNewsList[i].Title; Item.Detail := FNewsList[i].Summary; Item.TagString := FNewsList[i].PicUrl; Item.Tag := i; end; finally listView1.EndUpdate; end;这段代码里的 BeginUpdate 和 EndUpdate 是成对出现的,含义是“先别急着重绘,等我全部加完再统一刷新一次”。如果不加这两行,每 Add 一条都会触发布局刷新,数据量上百时界面会肉眼可见地闪动,体验很差。Item.Text 对应主标题,Item.Detail 对应副标题,这是内置样式里预设好的两个文本位。Item.TagString 适合放 URL 这类字符串附加数据,Item.Tag 则存数据索引,方便后续在事件里快速定位到原始数据。
另一条路径是 LiveBindings,通过 TBindingsList 把对象列表直接绑到 ListView 上,在 IDE 里配置字段映射,运行时自动生成条目。优点是省代码,适合字段完全固定的数据源;缺点是链路长,显示不对时黑匣子一样难排查。我的经验是:纯展示型数据用 LiveBindings 可以,凡是涉及动态外观、异步图片、点击后带参数跳转的列表,一律手动装配。
2.3 为什么不用 ScrollBox 套动态控件
ScrollBox 方案并不算错,只是它和 ListView 的适用边界非常清晰。ScrollBox 的做法是循环创建 Frame 或 TLabel,所有行都是真实控件实例,都参与布局计算和事件分发。数据量小的时候没问题,一旦超过几十条,滚动时控件频繁触发测量、重排,CPU 和内存同时吃紧。
ListView 则不同。它只创建当前屏幕可见范围内的 item,滚动时旧条目离开可视区就进入复用池,新出现的条目从复用池里取出来重新绑定数据。同一时间内存里的 item 实例只有一屏左右,跟数据总量没有线性关系。这也是为什么 100 条数据和 10000 条数据,在 ListView 里滚动性能基本不衰减。
3. 核心模块实战:ListView 的数据流与界面刷新链路
3.1 先定义一条数据的结构
List 展示的数据不能散着传,我会先定义一个记录类型,把一行需要展示的元素全部封装进去:
type TNewsItem = record Title: string; // 主标题 Summary: string; // 摘要,显示在副标题位置 PicUrl: string; // 图片远程地址 Kind: Integer; // 0 普通,1 置顶,影响外观样式 end;数据准备好后放在 TList<TNewsItem> 里。下面这张表说明各字段在 ListView 中的落点:
| 字段 | 对应控件对象 | 说明 |
|---|---|---|
| Title | TListItemText | 主标题,字体较大 |
| Summary | TListItemText | 副标题,字体较小,颜色偏灰 |
| PicUrl | TListItemImage | 行首缩略图,需要异步加载 |
| Kind | 动态外观 | 置顶行可以在背景色上做区分 |
把数据和展示字段先映射清楚,后面写 OnUpdateObjects 时不容易漏项。
3.2 用 OnUpdateObjects 控制动态外观
ListView 的 ItemAppearance 设置为 DynamicAppearance 之后,每一个 item 的外观不再固定,而是通过事件在显示前动态组装。核心事件是 OnUpdateObjects,它在 item 进入可视区域即将绘制时触发。
procedure TMainForm.ListView1UpdateObjects(const Sender: TObject; const AItem: TListViewItem); var TextObj: TListItemText; PicObj: TListItemImage; DataItem: TNewsItem; begin DataItem := FNewsList[AItem.Tag]; TextObj := AItem.Objects.FindObject('Title') as TListItemText; if Assigned(TextObj) then begin TextObj.Text := DataItem.Title; if DataItem.Kind = 1 then TextObj.FontColor := TAlphaColorRec.Red else TextObj.FontColor := TAlphaColorRec.Black; end; PicObj := AItem.Objects.FindObject('Pic') as TListItemImage; if Assigned(PicObj) then begin PicObj.Bitmap := LoadCachedImage(DataItem.PicUrl); PicObj.Width := 56; PicObj.Height := 56; end; end;这段代码是动态外观的核心。步骤是这样:先通过 AItem.Tag 找到原始数据记录,Tag 在填充列表时已经存好了索引;然后用 FindObject 按名称找到在 DynamicAppearance 里预先定义的文本对象和图片对象;最后逐个赋值属性。
需要注意 AItem.Tag 的默认值是 0,如果列表的第一条数据索引也是 0,看起来正常,但一旦 Tag 没有正确赋值,所有 item 都会错用第一条数据,这是一个隐藏很深的坑。所以填充阶段强制写上 Item.Tag := i 是保险做法。OnUpdateObjects 在滚动过程中会被频繁调用,这个事件里不要做耗时操作,比如网络请求、大文件读取,否则会把性能问题重新引回来。
3.3 下拉刷新与加载更多的落地
下拉刷新在 ListView 上触发时,我习惯的处理逻辑是重新拉数据、清空重建 item。伪代码逻辑如下:
procedure TMainForm.RefreshByPull; begin // 这个回调由 ListView 的下拉手势触发 LoadNewsFromRemote(FNewsList); listView1.Items.Clear; FillListView(FNewsList); end;这里有两个关键点。一是 LoadNewsFromRemote 内部应该用多线程请求,避免 UI 卡住;二是在重新填充时依然要走 BeginUpdate/EndUpdate 包裹,否则 Clear 和 Add 交替触发重绘,刷新动画会明显顿挫。
加载更多的常见做法是监听列表滚动位置是否接近底部,接近时触发请求下一页数据。核心判断思路是“最后一个可见条目索引 + 可视区条目数 >= 总条目数 - 阈值”。这部分事件细节在不同版本里略有差异,但思路一致,判断条件里留一个缓冲阈值(比如剩 5 条就开始加载)可以避免用户滑到底部时白屏等待。
3.4 远程图片的异步加载与缓存
列表里带网络图片是最容易写崩的地方。如果直接在 OnUpdateObjects 里同步请求图片,主线程会被网络阻塞,滚动直接卡死。常见做法是异步下载、主线程回填,并维护一套简单的内存缓存。
function LoadCachedImage(const AUrl: string): TBitmap; var Bmp: TBitmap; begin if FImageCache.TryGetValue(AUrl, Result) then Exit; Bmp := TBitmap.Create; try // 这里实际是异步下载,下载完成后在主线程同步 Bmp.LoadFromStream(nil); FImageCache.Add(AUrl, Bmp); Result := Bmp; except Bmp.Free; Result := nil; end; end;这段代码是为了说明缓存结构,真正的下载部分需要配合 TTask 和 TThread.Synchronize 使用。缓存的 key 必须是完整 URL,value 是 TBitmap 对象,注意 Bitmap 被多个 item 复用时不要让 item 释放它,统一由缓存字典管理生命周期。为避免内存无限增长,FImageCache 应该限制条数或定期清理最久未使用的图片,这也是列表长时间运行内存不涨的关键。
4. 避坑指南:FMX ListView 开发中的常见问题与排查
4.1 列表滚动卡顿,数据量不大却掉帧
现象:列表只有几十条,每行一张小图、两行文字,滚动时明显掉帧。
原因:我在 OnUpdateObjects 里每次重新创建 TBitmap 并做图片缩放。旧 Bitmap 对象没有释放,事件又频繁触发,内存分配和 GC 压力直接把主线程拖垮。
解决:图片走异步加载 + 内存缓存,事件里只从缓存取图,取不到就先显示占位图,不阻塞绘制。另一个常见原因是把图片源文件弄成几 MB 的大图,显示却只有 56x56,极致压缩放在 4 倍图上,滚动自然吃力。
4.2 数据改了,列表显示还是旧内容
现象:调用 Items.Clear 之后重新 FillListView,屏幕上显示的内容还是旧数据。
原因:动态外观模式下,被复用的 item 在外观对象上保留了旧的 Text、颜色、图片。我只在数据“变化”时赋值,而巧合的是两次数据相同,控件认为没必要重绘。
解决:OnUpdateObjects 里每次都强制赋值,不要写类似 if TextObj.Text <> NewText then 的短路逻辑。另外 Clear 之后不要忘了调用 ListView1.Refresh,强制触发一次整体重绘。
4.3 快速滚动时行内容对不上号
现象:快速滑动列表,某一行显示的是另一行的标题和图片,停住后慢慢恢复正常。
原因:item 是复用的,异步图片下载完成后回调里直接引用了局部变量 PicObj,而这时 PicObj 可能已经被滚动复用到其他索引的数据上了。
解决:异步回调里不要只校验控件对象是否存在,还要校验它当前对应的数据索引是否还是发请求时的索引。我一般会把 AItem.Tag 一起带进回调,回填前先比对,对不上就丢弃这次结果,不赋值。
4.4 Windows 上正常,Android 上图片不显示
现象:同一套代码 Windows 预览正常,打包到 Android 上图片区域空白。
原因:路径分隔符和 URL 处理习惯不同,Android 对本地路径或资源路径更严格;另外 TListItemImage 在 Android 原生渲染路径上对位图尺寸和密度处理方式有差异。
解决:先用内置浏览器控件测试图片 URL 在 Android 上能不能直接打开,排除网络层问题;再把图片加载逻辑单独抽出来打 Log,看是加载失败还是赋值失败。平台差异化不是玄学,绝大多数是路径或格式兼容问题,Log 一打就清楚了。
5. 二次开发:把 ListView 封装成业务组件
5.1 封装通用的列表视图基类
项目里页面多了之后,每个界面都写一遍 FillListView、OnUpdateObjects 很烦。我会把 ListView 封装成一个 TBaseListFrame,对外暴露两个方法:
type TBaseListFrame = class(TFrame) private FList: TListView; public procedure SetData(AData: TList<TNewsItem>); procedure NotifyDataSetChanged; end;SetData 负责把数据列表整体刷进控件并触发重绘;NotifyDataSetChanged 用于数据局部变化后刷新。业务页面只需要关心数据来源,不再直接操作 ListView 的 Item 集合。
5.2 扩展点设计:点击回调和行样式策略
封装时我预留两个扩展点:一个是 OnItemClick 选中的回调,把数据和索引一起抛出去;另一个是行样式更新的事件,外部可以注入自己的 OnUpdateObjects 实现。这样基础能力放在基类里,业务差异通过事件暴露出去,不会为了一个特殊页面把基类改得面目全非。
5.3 从这里开始动手改
拿到这个工程后,第一步不要急着改界面,先把 TNewsItem 换掉,换成你业务里的真实数据模型;第二步找到 FillListView,把字段映射关系改掉;第三步跑起来验证滚动和下拉刷新。用这套顺序改,半小时内就能把一个 Demo 变成你自己项目里的基础列表页。
那次重构以后,我每次新建 FMX 界面时都强制自己先回答一个问题:这块内容是“列表”还是“表单”。是列表就默认用 ListView,绝不再用 ScrollBox 堆控件。这个习惯让我少走了很多弯路,希望帮到你。
本文还有配套的精品资源,点击获取