ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Delphi 12 FMX TListView 列表性能优化

Delphi 12 FMX TListView 列表性能优化

简介:面向具备一定 Delphi 12 基础的跨平台界面开发者,这份 FMXUI 组件学习包围绕 ListView 列表控件展开,可用于理解多端界面搭建、数据展示、交互响应与控件定制思路,适合希望提升桌面端和移动端开发效率的中级人员参考。压缩包共二百二十四个文件,约四点二四兆字节,以一百个源码单元、五十四个界面窗体、三十三个图片素材为主,并配有工程文件、说明文档和少量资源文件,目录结构较完整,便于直接对照编译、运行与研究。已有一千一百三十四人学习下载。内容涉及数据绑定、自定义列表项、点击与聚焦事件、分组折叠、滚动动画、排序过滤以及适配器扩展等要点,借助示例工程和源码可梳理组件组织方式、界面实现路径与常见排错方向,也能为实际项目中的列表页搭建提供参考。

1. 从一块「看着能跑,滚两屏就卡」的列表说起

在 Delphi 12 的 FMXUI 体系里,TListView 的出场率几乎是最高的,也是最容易被低估的控件。不少人在 Windows 上把界面调得挺顺,一份 Delphi 12 源代码编译到 Android 之后,滑三屏就开始掉帧、图片莫名其妙发白、点某一行没反应,回头查半天发现根本不是数据的问题,而是 ItemAppearance 定义得太重。这份源代码值钱的地方不在于「有个列表」,而在于它把 TListView 拆成了三层:行模板(ItemAppearance)、数据填充(OnUpdateObjects 或代码直填)、参数调优(行高、缓存、批量更新),每一层都有明确的边界。刚接触 FMXUI 的人可以照着把第一条列表跑通,做过几年的人能在这里看到自己踩过的那几个坑到底出在哪一步。下面按「渲染模型 → 跑通第一版 → 参数怎么设 → 坑在哪」的顺序拆。

2. 拆开 ItemAppearance:一条列表行的三层结构

FMX 的 TListView 和 VCL 里那个同名控件完全是两个东西。VCL 的 TListView 底层是系统的公共控件,样式由系统主题决定;FMX 的 TListView 是纯自绘的,每一行长什么样,完全由 StyleBook 里的一组对象描述。这也意味着,你在 FMX 里看到一条「长得不对」的行,八成不是数据错了,而是模板里某个对象的属性没对上。

2.1 StyleBook、ItemAppearance、ItemAppearanceObjects 的包含关系

这三层是嵌套关系,很多人第一次接触会混淆。StyleBook 是窗体级的样式容器,里面可以放多个 TItemAppearance 对象,每个 TItemAppearance 就是一套「行模板」,比如带左图右文的、只有文字的、带详情行的。每个 TItemAppearance 内部又挂着一组 ItemAppearanceObjects,也就是具体绘制出来的 Text1、Text2、Image1、GlyphButton1 这些可视对象。真正决定一行里出现几个文本块、图片放在左边还是右边、有没有右侧小箭头的,是这最里面的一层。

代码里访问这些对象走的是序号而不是名字。AItem.Objects.Text[0]拿到的是模板里第一个文本对象,Objects.Text[1]是第二个,顺序和 StyleBook 里对象出现的先后一致。也就是说,你把模板里的 Text1 删掉、换成一个新的 Text3,编译能过,运行到Objects.Text[1]这一句就会越界。这种错在调试器里表现得很隐蔽——有时候只是某几行空白,不报异常,因为重绘时被吞掉了。

提示:改完 StyleBook 里的模板,尤其是增删了 Text/Image 对象之后,别急着跑,先在设计期把 TListView 的 ItemAppearance 重新选一遍,让它重新绑定一次对象集合。

2.2 三种常用外观的选型理由

FMX 自带的外观不多,常用的就三个:ListItem(纯文字)、ImageListItem(左侧一张图加一行文字)、ImageListWithDetail(左侧图、右侧上下两行文字)。选哪个不是看美观,是看性能预算。ImageListWithDetail 每行要绘制一个位图和两个文本对象,在 Windows 上没什么感觉,在中低端 Android 设备上滚到几百行时,帧率差异会很明显。

外观名称每行对象数适用场景性能开销
ListItem1 个 Text纯文字菜单、设置项最低
ImageListItem1 图 + 1 文本联系人群、简单图文流中
ImageListWithDetail1 图 + 2 文本消息流、订单列表、文章列表较高

我一般的做法是:先用 ImageListWithDetail 把交互和数据流跑通,等功能稳定了,再按实际页面的重要程度往下裁。列表页如果只是导航用途,直接降到 ListItem,能省掉一大半的图片解码和内存占用。反过来,如果你发现某个页面滚动时手势跟手性很差,先别怀疑代码,去 StyleBook 里数一下这个模板挂了多少个可视对象。

2.3 OnUpdateObjects 与运行时重建

数据怎么进到行里,FMX 提供了两条路。一条是 LiveBindings 绑定数据集,另一条是手写OnUpdateObjects事件,在每一项重绘之前把值塞进去。前者适合数据源本身就是 TDataSet 的场景,后者适合数据来自对象列表、JSON 或者网络请求。

// OnUpdateObjects:每一项即将重绘时触发 procedure TFormMain.ListView1UpdateObjects(const Sender: TObject; const AItem: TListViewItem); var Rec: TFeedItem; begin // Tag 里存的是数据列表中的下标,O(1) 取回原始记录 if (AItem.Tag < 0) or (AItem.Tag >= FData.Count) then Exit; Rec := FData[AItem.Tag]; // 按模板对象的序号赋值,Text[0] 是标题,Text[1] 是详情 AItem.Objects.Text[0].Text := Rec.Title; if AItem.Objects.Text.Count > 1 then AItem.Objects.Text[1].Text := Rec.Detail; // 图片不在这里解码,交给异步队列,避免滚动时卡主线程 AItem.Objects.Image[0].Bitmap := FThumbCache.GetOrLoad(Rec.ThumbPath); end;

这段代码有三个关键点。第一,AItem.Tag是 NativeInt 类型,存下标比存对象指针安全,因为列表项被回收重建时指针可能已经失效,而下标只要数据列表不变就一直有效。第二,Objects.Text.Count要先判断,模板换了对象数量会变,直接按固定下标取就是给自己埋雷。第三,图片走缓存对象FThumbCache,绝不能在事件里现场LoadFromFile一个大图——这个事件在滚动时每帧都可能被调用几十次。

OnUpdateObjects触发频率很高,它不是一个「数据加载完成」的回调,而是「这一行要画了」的回调。所以里面只做赋值,不做查询、不做文件 IO、不创建对象。这是 FMX 列表性能的第一条铁律,比任何参数调优都管用。

运行时切换外观也是常见需求,比如点击「简洁模式」把图文列表变成纯文字。写法是给ListView1.ItemAppearance.ItemAppearance赋一个模板名字符串,然后调用方法重建列表。重建这一步不能省,不重建的话屏幕上看不到任何变化,因为已有条目还挂在旧模板上。

// 运行时切换行模板,必须重建,否则已有条目仍用旧模板渲染 ListView1.BeginUpdate; try ListView1.ItemAppearance.ItemAppearance := 'ListItem'; ListView1.RebuildList; finally ListView1.EndUpdate; end;

RebuildList的作用是丢掉当前所有已渲染的条目并重新生成,代价不小,所以只在用户主动切换视图时才调,不要放在滚动或者定时器里。

3. 跑通第一版列表:从数据模型到可滚动界面

拿到一份 Delphi 12 源代码,第一件该做的事不是点运行,是看单元划分。列表这类界面最容易写成一坨:窗体单元里既有 HTTP 请求、又有 JSON 解析、又有 Bitmap 缩放、还有 UI 赋值,两千行堆在一个procedure里。分单元不是洁癖,是为了让你在出问题时能快速定位是数据层还是渲染层。

3.1 一个能维护的工程该有的单元职责

我按这个项目里的组织方式整理了一遍,五个单元,职责切得很干净:

单元职责关键类型
Main.pas窗体、控件事件、列表填充TFormMain
Feed.Data.pas数据模型与内存列表TFeedItem、TFeedList
Feed.Loader.pas数据来源(本地 JSON / 接口)TFeedLoader
Thumb.Cache.pas缩略图缓存与解码TThumbCache
Feed.Consts.pas行高、图片尺寸、分页大小等常量常量定义

这样切的好处很直接:列表卡顿的时候,先看 Main 里的填充逻辑;图片内存炸了,只看 Thumb.Cache;数据不对,只看 Loader。不用在一个两千行的单元里翻。

3.2 数据模型与填充流程

数据模型用一个普通类就够了,不需要继承 TComponent。字段就三个:标题、详情、缩略图路径。列表本身用TList<TFeedItem>或者TObjectList<TFeedItem>管生命周期。

type TFeedItem = class private FTitle: string; FDetail: string; FThumbPath: string; public constructor Create(const ATitle, ADetail, AThumb: string); property Title: string read FTitle; property Detail: string read FDetail; property ThumbPath: string read FThumbPath; end; // 一次性把数据灌进列表,中间不加锁、不触发重绘 procedure TFormMain.FillList; var I: Integer; Item: TListViewItem; begin ListView1.BeginUpdate; try ListView1.Items.Clear; for I := 0 to FData.Count - 1 do begin Item := ListView1.Items.Add; Item.Tag := I; // 下标即索引,重绘时反查数据 Item.Height := C_ITEM_HEIGHT; // 单行独立高度,见第 4 章 end; ListView1.ItemIndex := -1; // Clear 之后必须手动复位 finally ListView1.EndUpdate; end; end;

BeginUpdate/EndUpdate这一对是必须的。列表项每 Add 一次,如果没有批量模式的包裹,控件会尝试重算滚动范围并重绘,几百项下来就是一个肉眼可见的白屏卡顿。而且EndUpdate要放在finally里,中间任何一处抛异常而没走到EndUpdate,整个列表会一直处于挂起状态,表现是「列表空白但数据明明加了」。

ItemIndex := -1这一句看着多余,实际是刚需。Items.Clear之后索引并不总是回到 -1,某些情况下会停在 0,导致界面一加载第一行就是高亮选中态,用户点它反而不触发OnItemClick——因为索引没变。这种问题在产品验收时才被提出来,很浪费时间。

3.3 缩略图加载与缓存策略

图片是列表性能的第二大杀手,仅次于模板对象数量。原始图片动辄几千像素宽,直接塞进 ListViewItem 的 Bitmap,一张就是十几兆内存,滚几十行就吃满。而且 FMX 在 Android 上解码大图的开销都在主线程,滚动时那种一顿一顿的手感基本都是这么来的。

做法是进缓存之前先缩到目标尺寸。目标尺寸按设备缩放算:设计稿上图片占 48 逻辑像素宽,那就按48 * Scene.Scale取实际像素,宁小勿大。

function TThumbCache.GetOrLoad(const APath: string): TBitmap; var Src, Thumb: TBitmap; Dest: TRectF; Size: Integer; begin if FCache.TryGetValue(APath, Result) then Exit; // 命中缓存直接返回,不重复解码 Src := TBitmap.Create; Thumb := TBitmap.Create; try Src.LoadFromFile(APath); // 按目标边长等比缩放,长边对齐,短边留白 Size := Trunc(C_THUMB_SIZE * FSceneScale); Thumb.SetSize(Size, Size); Thumb.Clear(TAlphaColors.Null); Dest := TRectF.Create(0, 0, Size, Size); Thumb.Canvas.BeginScene; try Thumb.Canvas.DrawBitmap(Src, TRectF.Create(0, 0, Src.Width, Src.Height), Dest, 1.0); finally Thumb.Canvas.EndScene; end; FCache.AddOrSetValue(APath, Thumb); Result := Thumb; finally Src.Free; // Src 用完就放,Thumb 交给缓存托管 end; end;

几个参数说明一下。C_THUMB_SIZE是逻辑尺寸,写在常量单元里,改一处全站生效;FSceneScale是当前窗体的缩放系数,在OnResize或者ScaleChanged里更新。DrawBitmap的最后一个参数是透明度,填 1.0。缓存用TDictionary<string, TBitmap>,键用文件路径,天然去重。

缓存本身要有上限。列表滚到底再滚回来,几百张缩略图全留着,内存一样会涨。常见做法是给字典加一个容量判断,超过阈值就清掉最久未使用的一批,或者干脆监听列表滚动,滚出可视区超过两屏的图片主动释放。这一步不做,测试机上跑一天没事,用户手机上刷半小时就闪退。

4. 参数手册:行高、动态高度与滚动性能的取舍

Delphi 12 的 TListView 属性面板看起来不长,但真正影响体验的就那么几个。剩下的要么是外观微调,要么是某些平台上才生效的开关。把下面这张表里的值调对,列表的基本盘就稳了。

4.1 关键属性对照表

属性 / 方法所属对象作用实践取值
ItemAppearance.ItemAppearanceTListView.ItemAppearance指定行模板名称按页面选,见 2.2
ItemAppearance.ItemHeightTListView.ItemAppearance模板级默认行高56 ~ 72
ItemHeightTListView列表级行高与模板保持一致
SelectionModeTListView单选 / 多选 / 不可选详情页 smSingle,展示页 smNone
ShowScrollBarTListView是否显示滚动条桌面 True,移动端可关
ItemIndexTListView当前选中项初始化置 -1
BeginUpdate / EndUpdateTListView批量操作包裹成对出现,放 try/finally
OnUpdateObjects事件逐项填充只赋值,不做 IO

行高这一项,桌面端 56 看着宽松,到了手机上会显得挤,因为手指触控面积需要更大。我一般桌面 56、移动端 68 到 72,用TPlatformServices判断一次平台,在窗体创建时把C_ITEM_HEIGHT定下来,而不是在每个填充循环里判断。

4.2 ItemHeight 与自动换行的冲突

这是踩得最多的一类坑。需求说「标题太长要换行,行高跟着内容自适应」,于是把模板里 Text 对象的WordWrap打开,觉得应该就自动撑高了。跑起来发现文字确实换行了,但换行的第二行被裁掉一半,因为行高还是模板里那个固定值。

FMX 里行高不会因为文本换行自动增长。要让它真正自适应,得在数据填充阶段自己算。算法也不复杂:按可用宽度和字号估算文本行数,再乘以行高加上下内边距,最后把结果赋给Item.Height。

// 按文本长度估算行高:宽度固定,行数向上取整 function TFormMain.CalcItemHeight(const AText: string; AWidth: Single): Single; var LineCount: Integer; TextW: Single; begin // 粗略估算:中文字符按一个字宽,英文按半个字宽折半计算 TextW := Length(AText) * C_FONT_SIZE * 0.6; LineCount := Ceil(TextW / AWidth); if LineCount < 1 then LineCount := 1; Result := C_PADDING * 2 + LineCount * C_LINE_HEIGHT; if Result < C_ITEM_HEIGHT then Result := C_ITEM_HEIGHT; // 不低于最小行高,避免单行被压扁 end;

这段估算不精确,但够用。真正精确要调Canvas.TextWidth配合MeasureText,代价是每一行都要开一次绘制上下文,几百行算下来比省下的那点高度值钱得多。我的经验是:固定行高 90% 的场景都够,只有消息流、评论这种内容长度差异极大的列表才值得做自适应,而且自适应也要加缓存,同一个字符串别算两次。

还有一点容易忽略:一旦开启了行高自适应,ItemAppearance.ItemHeight就不要再设固定值去「兜底」。两个值同时存在时,谁生效跟版本和模板都有关系,表现出来就是「有的行高有的行矮」,查起来非常费劲。要么全固定,要么全自适应,别混。

4.3 大数据量下的三个开关

列表超过 500 项之后,几个平时无感的开销会开始放大。第一个是批量更新,前面说过,BeginUpdate包裹之后填充时间通常能降一个数量级。第二个是图片缓存命中率,滚动时反复解码同一张图是最亏的,缓存键要对准文件的稳定标识,别用带时间戳的临时路径。第三个是按需加载,也就是分页。

分页的做法不是「一次加载完再分批显示」,而是真的只保留当前窗口附近的数据。常见实现是监听列表的滚动位置,当滚到距底部还有 20 项时触发下一页请求,返回后BeginUpdate追加、EndUpdate收尾。这个策略在移动端几乎必须做,网络列表一次性拉 2000 条 JSON 再解析,光是解析加建项就能卡住 UI 三秒以上。

注意:追加数据时不要调用Items.Clear。清空再重建会让列表瞬间滚回顶部,用户的手势会被打断,体验上像是「滑了一下又跳回去了」。

5. 排查记录:ListView 最容易翻车的五个现场

下面这五条都是实际调试中反复出现的,每一条都按「现象 → 原因 → 解决」写,遇到同类问题时可以直接对号入座。

5.1 渲染与布局类故障

现象一:改了 StyleBook 里的模板,运行时完全没变化。

原因有两层。一是窗体上 TListView 的ItemAppearance.ItemAppearance还指向旧模板名,改的那个模板根本没被引用;二是即便指向对了,已经生成的条目仍挂着旧对象,不重建就不会重绘。解决方式是先确认模板名字符串一致,再在切换后调用一次重建,重建放在BeginUpdate/EndUpdate之间。改完记得把应用完整卸载重装一次,某些平台上资源缓存会保留旧样式。

现象二:列表能滚,但点击某一行没有任何反应,其他行正常。

这类问题八成出在模板里塞了可交互对象。ImageListWithDetail 模板里如果带 GlyphButton 或者 Accessory,触摸事件会先被那个子对象吃掉,父级的OnItemClick收不到。判断方法是把模板换回 ListItem 试一次,如果点击恢复正常,就基本确定了。解决办法是把那个子对象的HitTest设为 False,让它只负责显示不接收触摸,或者改在子对象自己的点击事件里处理跳转。

现象三:滚动时列表项背景闪烁,文字有一瞬间变白。

原因是填充逻辑里对同一行重复赋值,触发了多次重绘。OnUpdateObjects里如果有条件分支,不同分支赋的值不一致,滚动过程中就会看到颜色跳变。解决是把所有赋值收敛到一处,无条件赋完整值,别做「值没变就不赋」的优化——FMX 内部对相同值的处理比你想的聪明,反而是你自己加的条件判断制造了不一致。

5.2 数据与线程类故障

现象四:网络请求回来后更新列表,程序直接闪退,异常栈指向绘制层。

典型的跨线程操作 UI。数据在后台线程里拿到了,直接调Items.Add或者给Item.Objects.Text[0].Text赋值,主线程正在绘制,两边同时动同一个对象,崩是必然的。解决办法是统一回主线程:用TThread.Synchronize包一层,或者用TTask配合TThread.ForceQueue投递到主线程队列。判断标准很简单——只要这一句代码碰了任何可视控件,就必须在主线程。

现象五:图片列表滚到几百行后内存持续上涨,最终被系统杀掉。

原因通常是两个叠加:缩略图没有缩放,原图直接进 Bitmap;缓存没有上限,滚过的图片全留着。解决分两步,先按 3.3 的方式在解码阶段就缩到目标尺寸,再给缓存加容量上限和淘汰策略。验证方法是跑一个循环滚动的脚本,观察内存曲线,正常情况下应该在一个区间内波动,而不是单调上升。

6. 进阶:搜索过滤与分页加载的组合用法

列表做出来之后,用户第一个提的需求通常是搜索。FMX 的 TListView 自带一个搜索框,把SearchBox的可见性打开就能出现在列表顶部,输入时触发OnFilter事件。关键在于这个事件里不要做「隐藏某些行」的操作——FMX 没有行级可见性开关,正确做法是重建列表,只把匹配的数据灌进去。

// 输入即过滤:先生成结果集,再整体重建列表 procedure TFormMain.ListView1Filter(Sender: TObject; const AFilter: string); var I: Integer; Key: string; begin Key := LowerCase(Trim(AFilter)); FFiltered.Clear; for I := 0 to FData.Count - 1 do if (Key = '') or FData[I].Title.ToLower.Contains(Key) then FFiltered.Add(FData[I]); FillListFrom(FFiltered); // 复用填充逻辑,内部自带批量更新 end;

这里有个细节:FFiltered和FData要分开维护,过滤结果只是视图,不能覆盖原始数据,否则用户清空搜索框之后就再也回不到全量列表了。另外过滤重建的频率很高,每敲一个字符触发一次,所以FillListFrom里必须带BeginUpdate,否则输入法还没上屏界面就卡住了。

分页和过滤要配合着来。带过滤时,分页的「加载更多」应该基于过滤后的结果集,而不是原始数据集,不然用户搜一个词,滚到底加载出来的全是不匹配的条目。

场景数据源是否分页备注
全量浏览FData是滚到底加载下一页
搜索中FFiltered否结果通常较少,一次给全
搜索后清空FData是恢复分页,滚动位置归零

写这套逻辑的时候,我习惯先把「全量 + 分页」跑通,再叠过滤。反过来做的话,过滤逻辑会渗透到分页的每一个分支里,改一处漏一处。还有个后知后觉的教训:OnFilter里千万别直接调接口。输入框每敲一个字符发一次请求,服务器那边看到的就是一串毫无意义的碎片查询,而且返回顺序还不保证。要搜服务端,就加个 300 毫秒的防抖定时器,等用户停手了再发。

这套 Delphi 12 源代码的结构和上面拆的对应得上,数据层、缓存层、UI 层是分开的,照着改比从零搭省事。拿过去之后建议先只跑 ListItem 那个最简模板,确认滚动和点击链路通了,再往 ImageListWithDetail 上加图片和详情,一层一层加比一次性全开容易定位问题。

从那以后我每次新建列表页,都会强制先做三件事:把 ItemIndex 初始化为 -1、所有填充循环套上 BeginUpdate、缩略图一律走缓存对象,这三步不做完不写业务逻辑。希望帮到你。

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

RELATED READING

延伸阅读

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