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窗体、控件事件、列表填充TFormMainFeed.Data.pas数据模型与内存列表TFeedItem、TFeedListFeed.Loader.pas数据来源本地 JSON / 接口TFeedLoaderThumb.Cache.pas缩略图缓存与解码TThumbCacheFeed.Consts.pas行高、图片尺寸、分页大小等常量常量定义这样切的好处很直接列表卡顿的时候先看 Main 里的填充逻辑图片内存炸了只看 Thumb.Cache数据不对只看 Loader。不用在一个两千行的单元里翻。3.2 数据模型与填充流程数据模型用一个普通类就够了不需要继承 TComponent。字段就三个标题、详情、缩略图路径。列表本身用TListTFeedItem或者TObjectListTFeedItem管生命周期。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。缓存用TDictionarystring, TBitmap键用文件路径天然去重。缓存本身要有上限。列表滚到底再滚回来几百张缩略图全留着内存一样会涨。常见做法是给字典加一个容量判断超过阈值就清掉最久未使用的一批或者干脆监听列表滚动滚出可视区超过两屏的图片主动释放。这一步不做测试机上跑一天没事用户手机上刷半小时就闪退。4. 参数手册行高、动态高度与滚动性能的取舍Delphi 12 的 TListView 属性面板看起来不长但真正影响体验的就那么几个。剩下的要么是外观微调要么是某些平台上才生效的开关。把下面这张表里的值调对列表的基本盘就稳了。4.1 关键属性对照表属性 / 方法所属对象作用实践取值ItemAppearance.ItemAppearanceTListView.ItemAppearance指定行模板名称按页面选见 2.2ItemAppearance.ItemHeightTListView.ItemAppearance模板级默认行高56 ~ 72ItemHeightTListView列表级行高与模板保持一致SelectionModeTListView单选 / 多选 / 不可选详情页 smSingle展示页 smNoneShowScrollBarTListView是否显示滚动条桌面 True移动端可关ItemIndexTListView当前选中项初始化置 -1BeginUpdate / EndUpdateTListView批量操作包裹成对出现放 try/finallyOnUpdateObjects事件逐项填充只赋值不做 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、缩略图一律走缓存对象这三步不做完不写业务逻辑。希望帮到你。本文还有配套的精品资源点击获取