简介:Delphi 12 FMXUI 是一套聚焦 FireMonkey 跨平台开发的示例资源,面向需要快速掌握 FMX 界面组件的中高级 Delphi 程序员,尤其适合研究 ListView 在 Windows、macOS、iOS、Android 等多平台下的落地用法。资源共 224 个文件,以 100 个 pas 源代码、54 个 fmx 界面文件及 33 个 png 图片为主,辅以项目文件、文档和设计资源,整体压缩包约 4.24MB。已有 1131 人学习下载。包内含 ModernList、Layout、BaseFrame 等多个示例项目,覆盖数据绑定、视图定制、分组折叠、动画交互等典型场景;通过阅读源码结构与 FMX 布局文件,可以清晰理解 ListView 的适配机制,并直接参考其中的自定义 Item 设计来构建自己的业务列表界面。对于正在从 VCL 转向 FMX 或需要完善多端列表交互的开发者,这套资源提供了可运行、可改写的实践起点。
1. 先把结论放在前面:FMX ListView 是 Delphi 12 跨平台项目里被低估的组件
拿到这份 Delphi 12 FMXUI 源码的时候,我正被某跨平台系统项目里的列表页搞得头大。VCL 时代用习惯了 TListView 的直属列、双击编辑,一换到 FMX 这边,第一眼看 ListView 又没网格又没列头,差点直接放弃换回 VCL。但等我把它的 ItemAppearance 和动态外观机制摸明白,才发现 Delphi 12 的这个 Fmx UI 组件是真能打:移动端底下走的是原生列表渲染,桌面端又能用 DynamicAppearance 做高度自定义,一套代码两个端都能交付。这篇就按我拆包时的顺序,从数据填充、动态模板、图片加载讲到避坑和性能优化,新手能照着抄,熟手可以直接跳到第 4 章看翻车记录。
2. TListView 的工作原理与三种数据填充方式:先搞清楚它在各端渲染成什么,再决定怎么填数据
2.1 同样是 ListView,它在 iOS、Android 和 Windows 上其实是三套渲染行为
拆这份 Delphi 12 FMXUI 源码包之前,我一直以为 FMX 的 ListView 是纯自绘控件,后来翻着源码往下追才发现不是这么回事。TListView 在 iOS 端映射到 UITableView、Android 端映射到原生 ListView 适配层,Windows 和 macOS 桌面端才走 FMX 自绘。这个差异直接决定了你在设计器里看到的效果和真机运行效果是两码事。
理解了这个底子,再回头看它的行为就说得通了。移动端滚动惯性、边缘回弹、触摸反馈这些体验不是 FMX 模拟出来的,而是原生列表天然带进来的,这也是为什么 TListView 在手机上的滚动流畅度明显优于同屏堆几十个 TRectangle 的做法。桌面端自绘带来的则是高自由度,Item 内部放什么视觉元素、什么颜色、什么对齐方式,完全由你控制。
但也有代价。两端渲染机制不一样,坑也不一样。最常见的情况是桌面端 Windows 上调试得好好的文字布局,跑到 Android 上发现高度算错了、图片间距不对劲。这不是你的代码有问题,而是自绘和原生渲染对尺寸的处理本来就不同。我建议在项目里做列表页时,一周至少真机跑一次,别等到最后联调才拿出来。
2.2 三种填充数据的方式:直接 Items.Add、LiveBindings、DynamicAppearance 该怎么选
FMX ListView 填充数据有三条路,先看这张对比表,再按项目阶段选。
| 方式 | 上手难度 | 适合场景 | 维护成本 | 性能边界 |
|---|---|---|---|---|
| 直接操作 Items.Add | 低 | 数据量小、原型验证、固定几条的菜单页 | 低 | 千条以内足够 |
| LiveBindings 数据绑定 | 中 | 与数据库、对象列表联动,字段多且结构稳定 | 中 | 中量数据,字段修改需重编译 |
| DynamicAppearance 动态外观 | 中高 | 图文混排、视觉定制度高的列表 | 中高 | 视觉对象由模板控制,可做到数千条流畅 |
我的习惯是:如果只是展示一批文本,直接 Items.Add 就行,简单直接,不引入额外的绑定层。如果数据来自数据库而且要实时联动,LiveBindings 能省掉很多手工同步代码。但到了真正要交付的界面,比如带图标、带状态、带副标题那种列表,我会直接上 DynamicAppearance——只有它能让你完全控制每个 Item 长什么样。
这份源码包给我的启发也在这里。它没有只演示一种写法,而是把三种模式并列摆在同一个工程里,同一个数据源分别用三种方式填充,运行起来对比性能和代码量。拆包时强烈建议你按这个节奏走一遍,比只看文档有用得多。
2.3 先抄一段最简单的:直接 Items.Add 的骨架代码
先从最基础的方式入手。把 ListView 放到窗体上之后,在代码里这样填充:
procedure TForm1.BuildSimpleList; var LI: TListViewItem; I: Integer; begin ListView1.BeginUpdate; try ListView1.Items.Clear; for I := 0 to 9 do begin LI := ListView1.Items.Add; LI.Text := '任务 ' + I.ToString; LI.Detail := '这是列表第 ' + I.ToString + ' 行'; end; finally ListView1.EndUpdate; end; end;这段代码的逻辑很简单:先调用 BeginUpdate 告诉 ListView 我要连续改数据了,你不要每条都刷新界面,等我全部加完再一次刷新;接着清空旧 Items,然后用循环往里加 10 行;最后 EndUpdate 收尾,界面一次性更新。
这里有两个参数要特别注意。一个是 LI.Text,它对应列表项的主标题,默认显示在左侧;另一个是 LI.Detail 副标题,它需要配合 ItemAppearance 里的 ListItemRightDetail 或 ListItemTopDetail 模式才会显示,如果只在默认外观下赋了 Detail,界面上是看不到任何变化的。这句话值得记住,我刚开始做的时候就踩过:字段赋了好几个,界面上什么都不变,以为控件坏了,其实是外观模式没切。
把 ItemAppearance.ItemAppearance 切到 ListItemRightDetail,BuildSimpleList 里赋的 Detail 就会出现在主标题右侧。这是理解后面 DynamicAppearance 的起点。
3. 动态 Appearance 实战:设计器里搭模板,代码里填数据,跑起来才是图文列表
3.1 先在设计器里把视觉骨架搭好:模板命名是代码和视觉之间的契约
DynamicAppearance 和前面两种方式的本质区别在于:列表项长什么样不是控件预制的,而是你在设计器里用一个模板定义出来的,模板里放多少个 TListItemText、TListItemImage、TListItemSwitch,实际运行时每个 Item 就会带多少个子视觉对象。
在这个 Delphi 12 源码包里,动态模板是在 ListView1 的属性编辑器里打开的。步骤是把 ItemAppearance.ItemAppearance 设为 DynamicAppearance,然后点开 Appearance 集合编辑器,在里面添加 TListItemText 和 TListItemImage。
这里有一个很容易被忽略但至关重要的事:给模板里的元素取名字。名字是你代码访问它的唯一凭据,我建议按前缀区分类型和用途,格式固定成下面这种:
const CTitleName = 'txtTitle'; // 标题文本 CDetailName = 'txtDetail'; // 副标题文本 CIconName = 'imgIcon'; // 左侧图标之所以用常量写死,是因为后续在代码里要大量引用这三个名字,直接写字符串容易拼错,而且一旦模板改名,你也只需要改这一处。我把命名格式定为 txt 前缀代表文本、img 前缀代表图片,看代码立即能知道每个元素是干什么的。
模板搭好后,设计期看到的是一个空的样子,真正的数据填充发生在代码里,这就引出下面这个环节。
3.2 通过 Data 字典和 OnUpdateObjects 事件批量填充图文数据
动态外观下,数据流的正确写法是把数据挂到 TListViewItem.Data 上,再由 OnUpdateObjects 事件把 Data 里的值搬到模板元素上。先看这段核心代码:
procedure TForm1.LoadListData(ATaskList: TMyTaskList); var I: Integer; LItem: TListViewItem; begin ListView1.BeginUpdate; try ListView1.Items.Clear; for I := 0 to ATaskList.Count - 1 do begin LItem := ListView1.Items.Add; LItem.Data['title'] := ATaskList[I].Title; LItem.Data['detail'] := ATaskList[I].Detail; LItem.Data['icon'] := ATaskList[I].IconIndex; end; finally ListView1.EndUpdate; end; // 数据准备好后不需要手动刷,ListView 会按需触发 UpdateObjects end; procedure TForm1.ListView1UpdateObjects(const Sender: TObject; const AItem: TListViewItem); var LText: TListItemText; LImg: TListItemImage; begin LText := AItem.Objects.FindObjectByName(CTitleName) as TListItemText; if LText <> nil then LText.Text := AItem.Data['title'].AsString; LText := AItem.Objects.FindObjectByName(CDetailName) as TListItemText; if LText <> nil then LText.Text := AItem.Data['detail'].AsString; LImg := AItem.Objects.FindObjectByName(CIconName) as TListItemImage; if LImg <> nil then begin if AItem.Data['icon'].AsInteger >= 0 then begin LImg.ImageList := ImageList1; LImg.ImageIndex := AItem.Data['icon'].AsInteger; end else LImg.Visible := False; end; end;拆开讲这段的逻辑。LoadListData 负责造数据,它把每条数据的标题、副标题、图标索引存进 LItem.Data 这个字典里。Data 是 TListViewItem 自带的附加数据容器,键是字符串、值可以是字符串或者整数,非常适合做这种数据和视图的临时中转站。
OnUpdateObjects 是动态外观机制里的关键回调,ListView 在某个 Item 即将显示时会调用它。你要做的就是从 AItem.Objects 里按名字找到模板元素,再把 Data 的值赋上去。注意 FindObjectByName 返回的是父类 TListItemVisual,所以要用 as 做强转到具体子类。万一样板里没这个名字,返回值就是 nil,所以每个查找都要做空判断,不用省,省一次崩一次。
这个方案最舒服的地方是数据和视觉完全解耦。填充数据时你只关心数据本身,完全不管显示细节;显示细节全部集中在 OnUpdateObjects 里,要调整视觉效果只改一个地方。批量加载几千条时也不会因为每条都重复查找模板而卡死,因为 ListView 只对可见项触发这个回调,滚到哪才更新到哪。
3.3 点击事件里取出业务 ID:别在 AItem 上长期存对象引用
列表做好之后马上要面对点击交互。OnItemClick 事件给出的 AItem 参数就是用户点中的那一行,但如果你的业务数据是一个对象,千万不要把对象引用挂到 AItem 上再存着,因为 ListView 的 Item 会被回收复用,你持有的引用可能在某次滚动后被框架改掉了。
推荐的做法是把业务 ID 存进 Data 字典里,点击事件里取出来用:
procedure TForm1.ListView1ItemClick(const Sender: TObject; const AItem: TListViewItem); var LTaskId: Integer; begin LTaskId := AItem.Data['id'].AsInteger; ShowTaskDetail(LTaskId); end;存 id 而不是存 Task 对象,这是列表开发里很常见的设计习惯。Item 负责展示事,数据存储归还给业务层。点击事件拿到 id 之后去全局数据源里查完整对象就好,不会有引用失效带来的各种怪问题。
4. 避坑记录:四个让 ListView 翻车的细微问题,每个都花了我一整天
4.1 图片显示不出来:Android 相册返回的 content URI 不是文件路径
现象:列表里的头像从系统相册选择后,保存完刷新列表,图片区域要么空白要么直接黑块,Windows 上一切正常。
原因:TListItemImage.Bitmap.LoadFromFile 接收的是文件路径,但 Android 相册返回的是一个 content:// 形式的 URI,这个 URI 不是真实文件路径,FMX 拿它去 LoadFromFile 必然失败。这个机制在 iOS 上表现还不一样,iOS 返回的也是经过沙箱处理的路径,两端行为不一致。
解决:在赋值给 TListItemImage 之前,先把图片复制到应用沙箱目录,再用真实文件路径去加载。示意做法如下:
function CopyToSandboxIfNeeded(const AValue: string): string; var LBytes: TBytes; begin Result := AValue; if AValue.StartsWith('content://') then begin LBytes := LoadBytesFromContent(AValue); // 平台相关实现,走内容解析器 Result := TPath.Combine(TPath.GetDocumentsPath, TPath.GetFileNameWithoutExtension(AValue) + '.jpg'); TFile.WriteAllBytes(Result, LBytes); end; end;LoadBytesFromContent 这个函数需要按 Android 平台实现,FMX 没有统一封装所有 content URI 的读取。从这之后我就形成习惯了:凡是列表里要展示图片,加载前先过一道沙箱复制逻辑,保证传入 TListItemImage 的路径一定是本地文件。
4.2 滚动两屏之后显示错乱:别长时间持有 TListViewItem 引用
现象:列表滚动到后面几屏,前一屏的标题串到了后一屏的 Item 上,甚至点击某一行的按钮触发的事件却跑到了另一个业务数据上。
原因:TListView 在滚动时会回收离开可视区的 Item,把滚动进入可视区的行复用给新数据。如果你在外部变量里保存了一个 TListViewItem 指针,滚动之后这个指针指向的对象已经被框架改写了,它上面的 Text、Data 全部不是你当初赋值的内容。这不是随机 bug,是 Item 复用的回收机制决定的。
解决:把需要长期用的数据从 Data 字典里拿出来,存到业务层的变量或者字段里,而不是保存 Item 指针。每次需要访问某一行的数据时,用 ListView1.Items[Index] 重新取,这属于短引用就没有问题。简单的规则是:item 引用拿到后用,用完即弃,不在事件处理函数之外保存。
4.3 数据一多全量刷新卡顿:别把列表清空了再重建
现象:数据源发生变化时,调用 Items.Clear 再全量循环 Add,数据一旦超过两千条,界面肉眼可见地卡一下,Android 上更明显。
原因:Clear 会释放所有 Item 视觉对象,全量 Add 又会重新创建一批,这个重建过程还伴随 OnUpdateObjects 的集体触发,频繁做完全是重复劳动。列表本来就有复用机制,已有的 Item 完全可以原地更新数据。
解决:能用更新就别重建。如果只是部分行数据变化,直接定位到对应 Items 索引,找到模板元素改属性,然后不用碰 Clear 和 BeginUpdate:
procedure TForm1.UpdateOneRow(AIndex: Integer; const ATitle: string); var LText: TListItemText; begin LText := ListView1.Items[AIndex].Objects. FindObjectByName(CTitleName) as TListItemText; if LText <> nil then LText.Text := ATitle; end;只更新一个文本的速度是微秒级,完全不会被感知到。如果确实是数据规模变化比较大,用 BeginUpdate 包裹,但仍然要避免 Clear 后全量 Add,改成先调整 Items.Count 再逐条更新 Data。
4.4 动态外观下 OnUpdateObjects 频繁执行查找:模板多就别每次都 FindObjectByName
现象:单个 Item 模板里有三四个文本、两个图片,列表滚动时 OnUpdateObjects 每次都做五六次 FindObjectByName,数据量大了之后平滑动效掉帧。
原因:FindObjectByName 是线性查找子对象列表,单看一次代价不高,但滚动时屏幕上有十几条 Item 被触发更新,每条找五六次,合计就是上百次查找。这是能堆出真实开销的。
解决:一是尽量减少模板元素,能用一层布局表达就不要再包一层。二是把查找结果缓存到局部变量,同一个 Item 的多次属性赋值共用同一个对象引用。上面的代码已经体现了这个思路——同一个 LText 变量被复用了两次,先查 title 再查 detail,避免重复查找。三是在高频刷新场景下,优先走 4.3 的局部更新方式,不要依赖整页 OnUpdateObjects 重跑。
5. 性能优化的四个关卡:让几千条数据的列表滚动不卡在设计期就能决定
5.1 数据模型先定稿:加载列表时别在循环里做耗时解析
列表卡顿的根子往往不在 ListView 本身,而在进入 OnUpdateObjects 之前的数据准备阶段。常见的错误是循环里每条都去解析 JSON、查数据库或者格式化大字符串,这些性能瓶颈会直接透传到列表刷新的节奏里。
我一般会先把数据源处理成只读的内存列表,比如 TList 里放业务对象,对象里存的都是预格式化好的字符串和整数索引。循环填充 Items.Data 时只做赋值,不做解析,这样 ListView 的数据准备速度才追得上滚动速度。
在这个源码包里,数据层已经做成了与视图分离的结构,填充函数只负责把内存列表翻译成 Data 字典,这正好对应 5.2 要讲的事。
5.2 视觉对象数量控制:一层能表达的布局不要堆第二层
DynamicAppearance 给你自由度,但它不等于让你随便堆。模板里每多一个视觉元素,每个 Item 就多一次创建和更新的开销。做图文列表最常见的过度设计是:头像、标题、副标题、右侧图标、底部日期全部用独立的 TListItemText 和 TListItemImage 平铺在模板里。
实际上做得好的列表模板往往只有三四个元素:一个图标、一个标题、一个副标题、一个右侧状态。如果需要表达更多信息,优先用缩进和对齐解决,而不是继续加元素。减少视觉对象这里,我用一个很直接的判断标准:模板里每个元素必须能回答"这行数据里哪个字段在用它",答不上来的元素就是不必要元素,删掉。
5.3 图片加载是最大的性能变量:能用 TImageList 就别反复 LoadFromFile
列表里带图片时,图片加载慢的优先级永远高于其他优化项。TImageList 管理的图标资源在内存里只有一份,通过 ImageList 和 ImageIndex 引用,滚动时不需要重新从磁盘读取,这是首选方案。
如果列表图片来自网络或者用户本地选择,无法放进 TImageList,那就必须在后台线程完成图片文件的读取和写入缓存,主线程只负责把准备好后的文件路径赋给 TListItemImage。图片解码这个操作不能放在 OnUpdateObjects 里,它会让列表滚动瞬间变成幻灯片。我习惯在数据加载阶段就预生成好图片的本地缓存文件,OnUpdateObjects 里只传路径字符串。
5.4 局部更新代替整表刷新:EndUpdate 不是万能后悔药
BeginUpdate 和 EndUpdate 适合批量填充,但是它在 EndUpdate 时会对整个列表做一次完整的视觉同步,如果只是改一两行数据,这个同步成本就白付了。高频变化场景下,比如任务进度实时刷新,应该直接找到对应行做局部属性更新,而不是重走全量流程。
我把局部更新的代码封装成了一个通用方法,传入行号和新的 Data 字典,内部通过 Objects.FindObjectByName 精确修改目标元素。这样既保留了数据驱动的写法,又避免了重复触发整页刷新。核心思路是区分清楚什么时候该全量同步、什么时候该局部修改,性能和代码整洁度能同时保住。
6. 收尾技巧:给列表加上下拉刷新与滚动到底自动加载更多,二十行代码搞定
列表做到能展示、能点击之后,紧接着就是两个最常见的交互需求:下拉刷新数据和滚动到底部加载下一页。FMX TListView 本身内置了 PullRefresh 支持,配合滚动位置监听可以做完整的加载链路,整个实现不超过二十行关键代码。
下拉刷新的入口是 OnPullRefresh 事件,开启方式是在设计器里把 ListView 的 PullRefresh 属性设为 True。事件触发时调用数据层刷新,完成后必须调用 PullRefreshFinished 收掉系统刷新指示器,不然顶部转圈会一直挂着:
procedure TForm1.ListView1PullRefresh(Sender: TObject); begin FLoadingMore := False; LoadFromServer(True); // True 表示强制刷新 ListView1.PullRefreshFinished; end;触底加载通过 OnScrollViewChange 事件判断滚动位置。原理是拿内容总高度减去当前滚动位置和可视区高度,差值小于某个阈值就触发加载:
procedure TForm1.ListView1ScrollViewChange(const Sender: TObject; const ANewScrollPos, AOldScrollPos: TPointF; const AScrolling: Boolean); begin if FLoadingMore then Exit; // 防止一次滚动事件触发多次加载 if AScrolling and (ANewScrollPos.Y > AOldScrollPos.Y) then begin if ListView1.ScrollView.Content.Size.Height - (ANewScrollPos.Y + ListView1.ScrollView.ViewportSize) < 80 then begin FLoadingMore := True; LoadFromServer(False); // False 表示增量追加 end; end; end;FLoadingMore 是防重入的关键开关,加载完成后在 LoadFromServer 的结束回调里复位。阈值 80 的意思是距离底部还有 80 个像素时就开始预加载,这样用户滑到最底下时新数据差不多已经进来了,体验上很顺。
用这套组合拳,列表就有了完整的刷新和翻页能力,滚动性能也受到前面的优化策略保护。现在每次做列表,我都会强制走一遍这个流程:先定数据模型,再评估用哪种填充方式,模板元素控制到最少,图片提前落沙箱,最后才接手交互事件。这套流程写进我的习惯里,是从第一次在真机上翻车之后开始的——那一次列表页面完全没法看,标题错乱、图片全黑、滚动卡顿,教训全写在上面这五章里了,希望帮到你。
本文还有配套的精品资源,点击获取