简介:Delphi 12 FMXUI 组件示例包面向跨平台界面开发者,围绕 ListView 讲解数据绑定、定制列表项、点击交互、分组折叠、滚动动画与排序过滤等用法,适合已有基础并希望深入 FMX 界面的读者。包内共224个文件,以100个 pas 源码、54个 fmx 窗体和33个 png 界面素材为主,另有工程文件、组件包及说明文档,压缩包约4.24MB,目录按组件、布局和列表示例分模块组织,便于对照阅读。已有1134人学习下载,可用于研究与学习 ListView 在实际项目中的组织方式与复用思路。读者能获得可运行示例、界面布局参考、自定义适配器与交互事件写法,并借助多平台工程配置理解应用搭建过程;对数据源绑定、分组展示和动画过渡的实现细节也能形成直观认识,为后续开发跨平台界面提供可借鉴的完整代码基础。
1. Delphi 12 FMX 里把列表交给 TListView:一个页面少写两百行创建代码
Delphi 12 里做 FMX 界面,列表这块我基本不再纠结选型:TListView 直接上。第一次把 TListBox 版本的通讯录页改成 TListView,代码从三百多行掉到八十行不到——原来每个条目都要 Create 一堆 Label、Image、按钮再手算坐标,现在只在 ItemAppearance 里搭一次版,剩下的交给 OnUpdateObjects 填数据。这份源码包就是围绕这条路整理的:桌面端和 Android 端共用一套窗体,列表项带头像、两行文本和右侧状态图标,分页加载、点击分流、单行局部刷新都在里面,编译即跑。适合已经会拖控件、但被“一千行就卡”卡住的人,也适合想把 FMX 列表参数摸到边界的老手。
2. 拆开 TListView 的三层结构:ItemAppearance 到底替你画了什么
2.1 从 TListBox 换到 TListView 的选型理由
TListBox 的每一个条目都是一个真实的 TListBoxItem,本质上是容器控件,你往里塞 TLabel、TImage、TButton 都行,甚至塞一个 TEdit 做行内编辑也没问题。自由度高,代价是每一行都要付出完整的控件开销:对象分配、样式解析、布局计算、消息循环。一千行就是一千套控件树,滚动时逐帧做布局,桌面端还能忍,移动端就是肉眼可见的掉帧。更麻烦的是坐标要自己算,改一版设计就得把位置常量全改一遍,维护成本很高。
TListView 走的是另一条路:条目本身只是一份数据描述(TListViewItem),长什么样由一个叫 ItemAppearance 的模板决定,绘制交给列表的适配层。移动端这套适配层还会落到平台原生列表上,滚动惯性、边缘回弹、点击高亮都跟系统一致,视觉上不用自己造。所以在“图文混排 + 状态标记”这类最常见的列表需求上,TListView 的性价比明显更高。
代价是自由度收缩。条目里只能摆有限的几种对象,不能随手塞 TEdit、TPanel、TFrame。判断标准很简单:列表项需要内嵌可输入的控件、需要复杂动画、需要每行结构完全不同,那就退回 TListBox 或者干脆自绘;只是头像加文字加角标,一律 TListView,别犹豫。另外要清楚一点:FMX 的 TListView 并不是完全虚拟化的,Items 里仍然是一个个 TListViewItem 对象实打实存在,只是绘制轻了。所以“一千行”这个量级,条目对象本身的开销仍然要算进去,后面第 6 章会给出量化口径。
2.2 ItemAppearanceObjects 里几种对象的能力边界
ItemAppearance 有两个来源:预设样式和动态外观。预设样式里有 ListItem、ListItemRightDetail、ImageListItem 这类开箱即用的组合,改改颜色就能跑;但真实项目里字段一对不上就得自己搭,所以我一般直接选 DynamicAppearance,然后在 ItemAppearanceObjects 里手工加对象。这个集合里的对象类型是固定的,摸清它们各自能干什么,比背属性表有用得多。
| 对象类型 | 类 | 关键属性 | 典型用途 |
|---|---|---|---|
| 文本 | TListItemText | Text、Detail、Font、TextColor、TextAlign、VertAlign、Trimming、PlaceOffset | 主标题、副标题、右侧数值 |
| 图片 | TListItemImage | Bitmap、Images、ImageIndex、OwnsBitmap | 头像、缩略图、行首图标 |
| 图标按钮 | TListItemGlyphButton | Images、ImageIndex、Visible | 行尾“更多”、删除、状态切换 |
| 文字按钮 | TListItemTextButton | Text、TextColor、Visible | 行尾“查看”“重试” |
| 附属标记 | TListItemAccessory | AccessoryType、Visible | 勾选态、右侧箭头 |
几个边界需要提前记牢。同一个 Name 在集合里必须唯一,FindObject 只认第一个匹配,重名会导致你改 A 项结果 B 项变色这种玄学现象。对象之间的层叠顺序就是添加顺序,后加的在上层,如果一张 Image 覆盖了整个条目区域,压在下面的 GlyphButton 就点不到——这不是事件没挂对,是命中测试被挡住了。文本对象的 Detail 属性在预设外观里是第二行,但在 DynamicAppearance 下手动加的 Text 对象里它不一定按你预期渲染,我一般老老实实加两个 Text 对象,标题归标题、副标题归副标题,互不干扰。
2.3 在对象检查器里搭一套两行布局
动手顺序如下,跟着走一遍就能得到一个可用的模板。
- 窗体上放一个 TListView,Align 设 Client,ItemAppearance 选 DynamicAppearance。
- 双击 ListView 打开条目设计器,切到外观页,依次添加 Image、Text、Text、GlyphButton 四个对象。
- 按上表把 Name 改成 imgAvatar、txtTitle、txtSub、btnMore,Name 是后面 FindObject 的唯一凭据,别用默认的 Text1、Text2。
- 设置各自的 Position 与 Size。我的习惯是头像固定在左侧 12 像素处,文本从 68 像素起,行尾按钮右对齐。
- 回到窗体,把 ItemHeight 设成 76,ItemSpacing 设 0。
具体参数参照下表,数值按 96 DPI 的逻辑像素给,高 DPI 下由 FMX 自己缩放:
| 对象 | Position.X / Y | Size.Width / Height | 其他 |
|---|---|---|---|
| imgAvatar | 12 / 14 | 48 / 48 | FixedSize 打开,避免拉伸变形 |
| txtTitle | 68 / 12 | 200 / 24 | Font.Size 15,Trimming 设 EllipsisCharacter |
| txtSub | 68 / 40 | 200 / 20 | Font.Size 12,TextColor 调灰 |
| btnMore | 右对齐 / 22 | 28 / 28 | Images 指向一个 28×28 的图标列表 |
这里有个容易被忽略的参数:txtTitle 的 Width 不要写成固定 200 就完事,条目宽度在不同设备上差很多,右对齐的按钮会跑位。稳妥做法是把文本对象的 Width 设成相对值,或者在 OnUpdateObjects 里按 AItem.Width 减去右侧留白动态算一次。我一开始图省事写死宽度,结果在平板上右侧空出一大块,返工了一次。
3. 数据怎么进到行里:OnUpdateObjects 与 LiveBindings 两条路的取舍
3.1 手写 OnUpdateObjects 的最小闭环
这是最可控的一条路,也是源码包里默认用的方式。核心思路是:Items.Add 只负责占位,不给内容;真正把数据写进外观对象,是在 OnUpdateObjects 里做的。这个事件在条目需要呈现时触发,滚动、重绘、数据变化都可能再触发一次,所以里面只能做“读数据、写对象”,绝不能增删条目。
type TUserRec = class public Name: string; Dept: string; State: string; AvatarIdx: Integer; end; procedure TForm1.FormCreate(Sender: TObject); begin FUsers := TObjectList<TUserRec>.Create(True); // 拥有对象,统一释放 LoadUsersFromCache(FUsers); // 假定的数据来源 ListView1.BeginUpdate; // 批量操作先挂起重绘 try ListView1.Items.Clear; for var I := 0 to FUsers.Count - 1 do ListView1.Items.Add; // 只占位,不写内容 finally ListView1.EndUpdate; // 一次性重排 end; end; procedure TForm1.ListView1UpdateObjects(const Sender: TObject; const AItem: TListViewItem); var U: TUserRec; LTitle, LSub: TListItemText; LPic: TListItemImage; begin // 索引越界保护:数据源被重建时这个判断能救一次崩溃 if (AItem.Index < 0) or (AItem.Index >= FUsers.Count) then Exit; U := FUsers[AItem.Index]; LTitle := AItem.Objects.FindObjectT<TListItemText>('txtTitle'); LSub := AItem.Objects.FindObjectT<TListItemText>('txtSub'); LPic := AItem.Objects.FindObjectT<TListItemImage>('imgAvatar'); // 分组表头没有这些对象,必须判空 if LTitle <> nil then LTitle.Text := U.Name; if LSub <> nil then LSub.Text := U.Dept + ' · ' + U.State; if LPic <> nil then begin LPic.Images := ImageList1; // 走 ImageList,避免每条目建位图 LPic.ImageIndex := U.AvatarIdx; end; end;逻辑上有三处值得说清。第一,为什么不在 Items.Add 之后立刻给对象赋值:条目在滚动和重排过程中可能被重新呈现,你赋的值会被后续的 OnUpdateObjects 覆盖,两边写法不一致就会出现“滚动一下内容变了”的怪象;统一在 OnUpdateObjects 里写,只有一处真相。第二,BeginUpdate / EndUpdate 是批量增删的必需品,中间每一次 Add 都可能触发一次布局,一千行不加这对括号,构建时间能翻好几倍。第三,FindObjectT 是泛型版,找不到返回 nil,所以判空不是防御性编程的客套,是必须的——分组表头、尾部的“加载更多”条目都没有你定义的那几个对象。
参数层面,AItem.Index 是条目在 Items 里的位置,它和数据源下标一致的前提是你构建 Items 的顺序和数据源完全一样。一旦中间插入了表头行,这个一一对应就断了,所以第 3.3 节会用 TagObject 或 TagString 把主键挂到条目上,避免靠下标硬算。另外注意一点:数据改了之后要让界面重画,我一般直接 ListView1.Invalidate;如果某些版本上不生效,把该条目的 Height 重新赋一次值强制重排,土办法但管用。
3.2 LiveBindings 绑定的写法与字段映射约定
数据源是数据集、字段又多的时候,手写 FindObjectT 一长串会很啰嗦,这时候用 LiveBindings 更省事。步骤不复杂:
- 放一个 TBindSourceDB(或 TPrototypeBindSource 做原型),接上数据集。
- 在条目设计器里给每个外观对象填 FieldName,比如 txtTitle 的 FieldName 写 Name,txtSub 写 Dept。
- 用 LiveBindings 向导里的 LinkListControlToField,把数据源字段和 ListView 关联起来,条目数量和 Data 字典由绑定层负责。
- 需要格式化、需要拼串的地方,仍然在 OnUpdateObjects 里做二次加工。
procedure TForm1.ListView1UpdateObjects(const Sender: TObject; const AItem: TListViewItem); var LSub: TListItemText; begin LSub := AItem.Objects.FindObjectT<TListItemText>('txtSub'); if LSub = nil then Exit; // 绑定层已把字段灌进 Data 字典,这里只做拼接和兜底 LSub.Text := AItem.Data['dept'].AsString + ' · ' + AItem.Data['state'].AsString; end;AItem.Data 是一个按键取值的字典,取出来的是 TValue,用 AsString / AsInteger 转。这套写法的好处是字段增删只改绑定,不用动代码;坏处是调试时断点不好下,字段名写错不会报错,只会静默拿到空串,第一次踩的时候我盯着空白列表看了半小时。
两条路不要混用。既配了 LinkListControlToField,又在 OnUpdateObjects 里手写 Text,表面上跑得通,实际上填充时机不一定在你以为的那一步,快速滚动时会看到文字闪一下再变——这种闪烁在真机上尤其明显。选择的依据很直白:数据结构稳定、字段多、要支持设计期预览,用绑定;数据要在代码里做业务加工、要精确控制每行呈现,用手写。
3.3 分组、表头和刷新的正确姿势
带分组标题的列表,靠 TListItemPurpose 实现。Purpose 设为 Header 的条目会被当成表头,通常只显示一行粗体文字,不参与普通条目的点击逻辑;Footer 常用来放“加载更多”。构建顺序必须是先表头后成员,插入点在切换分组时重置。
procedure TForm1.RebuildGroupedList; var H, It: TListViewItem; begin ListView1.BeginUpdate; try ListView1.Items.Clear; for var G in FGroups do begin H := ListView1.Items.Add; H.Purpose := TListItemPurpose.Header; // 表头 H.Text := G.Title; for var U in G.Users do begin It := ListView1.Items.Add; It.Purpose := TListItemPurpose.None; It.TagString := U.ID; // 主键挂在条目上 end; end; finally ListView1.EndUpdate; end; end;TagString 存主键、TagObject 存对象引用,这两个属性是分组场景下替代下标的正确做法。用 TagObject 的时候要清楚生命周期:条目被清空时 TagObject 指向的对象不会被自动释放,谁创建谁释放,别指望列表帮你兜底。整表刷新一律走 Clear + 重建,配合 BeginUpdate 包住;单行更新则不要重建,直接改那一行的对象再 Invalidate,代价差着两个数量级。
4. 交互判定落在哪:点击命中、行内按钮和滚动加载的三个写法
4.1 OnItemClickEx 与 ItemObject 的命中判断
点击事件有两个版本,OnItemClick 只给一个 ItemIndex,OnItemClickEx 多了点击坐标和命中的具体对象。行内有按钮的时候只能用后者,否则你没法区分用户点的是整行还是行尾那个小图标。两个事件同时挂的行为在不同版本上不完全一致,我的习惯是只挂 OnItemClickEx,从一而终。
procedure TForm1.ListView1ItemClickEx(const Sender: TObject; ItemIndex: Integer; const LocalClickPos: TPointF; const ItemObject: TListItemDrawable); var It: TListViewItem; begin if ItemIndex < 0 then Exit; // 空白区域的点击 It := ListView1.Items[ItemIndex]; if It.Purpose = TListItemPurpose.Header then Exit; // 表头不响应 if (ItemObject <> nil) and SameText(ItemObject.Name, 'btnMore') then DoMore(ItemIndex) // 命中行尾图标 else if (ItemObject = nil) and (LocalClickPos.X > It.Width - 56) then DoMore(ItemIndex) // 热区兜底:右侧 56 像素 else DoOpen(ItemIndex); end;参数的意义要说清。ItemIndex 是点中的行号,-1 表示点在列表空白处;LocalClickPos 是相对于条目左上角的坐标,已经扣掉了滚动偏移,可以直接和条目宽度比较,用它做热区兜底很稳;ItemObject 是命中的绘制对象,点在整行空白处时可能是 nil,所以判空和判名要一起写。热区兜底那段看着多余,实际上很有必要——图标做小了,手指点在图标边缘外一两个像素,命中就落到整行上了,用户体验上就是“点了没反应”。
4.2 行内按钮与状态切换的事件分流
行内按钮做完点击分流之后,还要解决“只刷新这一行”的问题。最省的做法是改数据、改对象,不重建列表。
procedure TForm1.ToggleState(AIndex: Integer); var It: TListViewItem; LBtn: TListItemGlyphButton; LTitle: TListItemText; begin FUsers[AIndex].Done := not FUsers[AIndex].Done; It := ListView1.Items[AIndex]; LBtn := It.Objects.FindObjectT<TListItemGlyphButton>('btnMore'); if LBtn <> nil then begin LBtn.Images := ImageList1; LBtn.ImageIndex := Ord(FUsers[AIndex].Done); // 0/1 两个图标位 end; LTitle := It.Objects.FindObjectT<TListItemText>('txtTitle'); if LTitle <> nil then LTitle.TextColor := IfThenColor(FUsers[AIndex].Done, $FF9E9E9E, $FF202020); end;这里把状态标记做成了 GlyphButton 的图标切换,而不是用附属对象的勾选态。原因很实际:勾选态在不同平台上渲染出来的尺寸和位置有差异,图标则完全由你控制,视觉一致性更好。按钮的 Images 指向同一个 ImageList 是安全的,ImageList 里的位图由列表共享引用,不会因为多行而复制;但如果换成直接赋 Bitmap,就必须每个条目一份独立实例,共用同一个 TBitmap 会导致所有行显示同一张图,甚至退出时二次释放崩溃——这个坑后面还会展开。
副作用要留意:在 ToggleState 里改完对象之后,如果该行恰好在可视区外,下一次滚进来时 OnUpdateObjects 会重新覆盖你的修改。所以状态的真值必须落在数据源里,对象上的修改只是当下的视觉反馈,两条路都要改,只改一边迟早对不上。
4.3 滚动到底自动加载与刷新条目的实现
分页加载的触发点,很多人第一反应是监听滚动事件,然后根据滚动位置判断。这条路在桌面端能走通,在移动端会因为滚动惯性和事件频率变得很难调。更稳的做法是把判断塞进 OnUpdateObjects:当渲染到接近尾部的条目时,说明用户已经滚到底了,这时候触发加载。
procedure TForm1.ListView1UpdateObjects(const Sender: TObject; const AItem: TListViewItem); begin // ... 前面是正常的行内容填充 ... // 尾项触发预加载,倒数第 3 行就开始拉,用户感知不到等待 if (not FLoading) and FHasMore and (AItem.Index >= ListView1.Items.Count - 3) then LoadNextPageAsync; end; procedure TForm1.LoadNextPageAsync; begin FLoading := True; // 重入锁,必须有 TThread.ForceQueue(nil, procedure begin try FetchPage(FPage); // 网络/IO 放这里,别在绘制回调里做 Inc(FPage); ListView1.BeginUpdate; try for var U in FLastBatch do ListView1.Items.Add; finally ListView1.EndUpdate; end; finally FLoading := False; end; end); end;三个设计点解释一下。重入锁 FLoading 不是可选项:OnUpdateObjects 会因为重绘、滚动、窗口激活等原因反复触发,没有锁的话一次滚动能发出七八个请求,后台日志上一片重复。ForceQueue 的作用是把“追加条目”这个动作推迟到当前绘制周期之后,正在渲染的时候改 Items 集合,轻则闪烁重则访问越界,这类崩溃最难查,因为没有稳定复现路径。预加载阈值取 3 行是个经验值,行高 76 的情况下大约相当于提前半屏,网络慢的环境可以调到 5。
5. 避坑排查:桌面端与 Android 端最容易翻车的五个现场
5.1 渲染与外观类问题
现象一:改了 ItemAppearance,运行时还是老样子,或者文字下半截被裁掉。原因通常有两个:一是条目适配层在运行时已经按旧外观构建过,改设计期的外观不会自动重建;二是 ItemHeight 设得比对象实际占位小,文本对象的默认高度加上下边距超了行高,多出来的部分直接被裁。解决办法是先 BeginUpdate,再 Items.Clear,然后重建条目,让适配层按新外观走一遍;如果不想清空,把 ItemAppearance 切到另一个值再切回来,也能触发重建。行高方面,取“最高对象的高度 + 上下留白各 8 像素”,移动端字号普遍比桌面端大一点,再留 4 到 6 像素余量,别贴着算。
现象二:列表里每一行都显示同一张图,或者程序退出时崩在释放上。原因几乎都是共用了同一个 TBitmap 实例。TListItemImage 的 Bitmap 属性持有的是引用,你把同一个位图赋给一百个条目,它们指向同一块内存,改一个就是改全部;更糟的是 OwnsBitmap 为真时,每个条目都会尝试释放它,第二次释放直接崩。解决方式有两条:走 Images / ImageIndex 用 ImageList 托管,这是首选;确实需要独立位图时,每条目新建一个实例并把 OwnsBitmap 设为 True,让条目自己管释放,外面绝不再 Free。
现象三:Android 端滚动明显掉帧,行高还会跳动。排查下来十有八九是 OnUpdateObjects 里干了重活:现场解码图片、读本地文件、查数据库、甚至拼一大段字符串做格式化。这个回调在滚动过程中会被高频调用,任何一次 IO 都会变成掉帧。解决方式是把准备工作前移,进列表之前把缩略图解码进 ImageList,文本格式化的结果缓存到数据对象上,回调里只做赋值。行高跳动一般是文本对象的 WordWrap 打开了但行高没跟着变,两行文本撑成三行,把 WordWrap 关掉、Trimming 改成省略号就能稳住。
5.2 数据与生命周期类问题
现象四:列表刷新之后偶发访问越界,或者界面上一片空白。典型的生命周期错配:数据源 FUsers 被 Clear 或 Free 了,Items 里的条目还在,OnUpdateObjects 一跑,按下标去取就崩了。还有一种变体是反过来的,Items 清了但数据没清,行数和数据量对不上,界面显示一半空白。解决方式是把两者的生命周期绑死,重建列表时先清 Items 再清数据,或者干脆用“新建一个数据列表、整体替换引用、再重建 Items”的顺序,中间不留半成品状态。OnUpdateObjects 开头那句下标越界判断看着啰嗦,实际能挡掉大部分这类崩溃。
现象五:滚动几屏之后,某些行的内容和位置错位了。这个问题最像玄学,原因却很简单:在 OnUpdateObjects 里对 Items 做了增删,或者在填充过程中调用了会触发重排的方法。这个回调只应该读数据写对象,一旦它在遍历过程中改变了集合本身,遍历顺序就乱了,表现出来就是“张三的头像配了李四的名字”。解决办法是把所有增删操作移到回调之外,用 ForceQueue 推到下一个消息循环;查这类问题时,我会在回调入口打一行 Index 日志,看有没有重复或跳号,比盲猜快得多。
6. 千行列表的验收标准:四个可量化的指标和一次压测
列表优化最怕凭感觉,说“挺流畅的”没有意义。我给这份源码包定了四个能测的口径,交付前必跑一遍:
| 指标 | 目标值 | 观测方式 |
|---|---|---|
| 千行构建耗时 | 小于 150 ms | TStopwatch 包住 BeginUpdate 到 EndUpdate |
| 单次 OnUpdateObjects 耗时 | 小于 1 ms | 在该回调内累计计时,取平均值 |
| 滚动帧率 | 稳定 50 fps 以上 | 真机上录屏后按帧统计,或看渲染耗时 |
| 峰值内存增量 | 千行不超过 60 MB | 任务管理器或平台自带的内存面板 |
构建耗时用几行代码就能测出来,别嫌土,数字比感觉可靠:
var SW: TStopwatch; begin SW := TStopwatch.StartNew; ListView1.BeginUpdate; try ListView1.Items.Clear; for var I := 0 to 999 do ListView1.Items.Add; // 只测构建,不测数据填充 finally ListView1.EndUpdate; end; SW.Stop; Caption := Format('build: %d ms', [SW.ElapsedMilliseconds]); end;如果这一千行构建超过 300 毫秒,基本可以断定是每条目都在做重活:要么 Add 之后立刻赋了位图,要么中间夹了没必要的 Invalidate。把赋值全部挪到 OnUpdateObjects、把图片挪进 ImageList,通常能砍掉一半以上。单次回调耗时那个指标更关键,它决定了滚动稳不稳,超过 1 毫秒就要查里面有没有隐藏的字符串拼接和对象创建。
还有一个反直觉的结论:与其纠结外观对象的数量,不如先把数据准备做干净。我见过把四五个文本对象塞进一行的模板,跑起来照样顺滑;也见过只有两个文本对象但每行都在现场解码 PNG 的列表,滚起来像幻灯片。瓶颈从来不在画,在取数。
最后说个血泪经验。早期我图省事,把数据源
本文还有配套的精品资源,点击获取