HarmonyOS ArkTS 列表渲染与数据持久化:从历史记录页面看 List 组件与存储方案选型 引子列表是应用的记忆一个完整的应用还需要记住用户做过什么。骰子应用的历史记录页面就承担了这个职责——它把每次投掷的结果保存下来让用户可以回顾。完整效果HistoryTab 的代码结构先看历史记录页面的核心代码BuilderHistoryTab(){Column(){Row(){Text(历史).fontSize(FontSize.title).fontColor(this.gc().text).fontWeight(FontWeight.Bold)Blank()if(this.history.length0){Text(清空).fontSize(FontSize.sm).fontColor(#EF4444).onClick(()this.clr())}}.width(100%).padding({left:Spacing.md,right:Spacing.md,top:Spacing.lg,bottom:Spacing.sm})if(this.history.length0){Column(){Text().fontSize(48)Text(还没有投掷记录).fontSize(FontSize.md).fontColor(this.gc().text2)}.width(100%).layoutWeight(1).justifyContent(FlexAlign.Center)}else{List(){ForEach(this.history,(r:RollResult){ListItem(){Row(){Text(DICE_TYPES.find((d:DiceType)d.keyr.diceType)?.emoji||).fontSize(22)Column(){Text((DICE_TYPES.find((d:DiceType)d.keyr.diceType)?.label||) ×String(r.results.length)).fontSize(FontSize.sm).fontColor(this.gc().text)Text([r.results.join(, )]).fontSize(FontSize.xs).fontColor(this.gc().text2).maxLines(1)}.margin({left:Spacing.sm}).alignItems(HorizontalAlign.Start)Blank()Column(){Text(String(r.total)).fontSize(FontSize.lg).fontColor(this.gc().accent).fontWeight(FontWeight.Bold)Text(this.fmt(r.rolledAt)).fontSize(FontSize.xs).fontColor(this.gc().text2)}}.width(100%).padding(Spacing.md).backgroundColor(this.gc().surface).borderRadius(BorderRadius.md).margin({bottom:Spacing.sm,left:Spacing.md,right:Spacing.md})}})}.width(100%).layoutWeight(1).scrollBar(BarState.Off)}}.width(100%).height(100%)}这段代码虽然不长但包含了几个重要的设计模式空状态处理、列表渲染、数据格式化、批量操作。空状态设计用户体验的细节当历史记录为空时页面显示一个友好的提示if(this.history.length0){Column(){Text().fontSize(48)Text(还没有投掷记录).fontSize(FontSize.md).fontColor(this.gc().text2)}.width(100%).layoutWeight(1).justifyContent(FlexAlign.Center)}空状态设计是用户体验的重要细节。很多开发者觉得空就空呗有什么好显示的但其实空状态有三个作用解释现状告诉用户这里没有数据而不是让用户猜测是不是出了 bug引导操作暗示用户去投掷几次骰子吧视觉平衡避免页面大面积空白保持视觉完整性这个应用的空状态设计还不错但可以更进一步——加一个按钮引导用户去投掷if(this.history.length0){Column(){Text().fontSize(48)Text(还没有投掷记录).fontSize(FontSize.md).fontColor(this.gc().text2).margin({top:Spacing.sm})Button(去投掷骰子).fontSize(FontSize.md).fontColor(#FFFFFF).backgroundColor(this.gc().primary).borderRadius(BorderRadius.xl).margin({top:Spacing.md}).onClick(()this.tabdice)// 切换到骰子 Tab}.width(100%).layoutWeight(1).justifyContent(FlexAlign.Center)}这样用户看到空状态后可以一键跳转到骰子页面而不是手动点击底部 Tab。列表渲染ForEach vs List代码中用了ListForEach的组合List(){ForEach(this.history,(r:RollResult){ListItem(){...}})}.width(100%).layoutWeight(1).scrollBar(BarState.Off)这里有两个关键点1. 为什么用 List 而不是 Column如果用ColumnForEach所有列表项都会被渲染即使用户只看前几项。当数据量大时这会导致初始渲染慢需要创建所有列表项的 UI 元素内存占用高所有列表项都占用内存滚动卡顿滚动时需要重新布局所有元素List组件支持懒加载只渲染用户可见的列表项。当用户滚动时List会自动创建新的列表项销毁不可见的列表项。这样无论数据量多大渲染的列表项数量都是固定的大约 10-20 个性能稳定。2. ForEach 的 key 参数ForEach的第二个参数是 item generator第三个参数是 key generator可选。代码中没有提供 key这在数据量小时没问题但当数据量大时可能导致渲染问题。比如用户删除了一条记录ForEach会重新渲染整个列表。如果没有稳定的 keyArkUI 可能会复用错误的列表项导致显示异常。建议添加 keyForEach(this.history,(r:RollResult,index:number){ListItem(){...}},(r:RollResult,index:number)index.toString())用索引作为 key 虽然不是最优解理想情况应该用唯一 ID但比没有 key 好。数据格式化时间戳的处理代码中用fmt方法格式化时间戳privatefmt(ts:number):string{constdnewDate(ts);return(d.getMonth()1)/d.getDate() String(d.getHours()).padStart(2,0):String(d.getMinutes()).padStart(2,0);}这个方法把时间戳格式化为M/D HH:MM的格式比如7/17 19:35。为什么不用 toLocaleStringJavaScript 和 ArkTS 都提供了toLocaleString()方法可以自动根据用户 locale 格式化日期。但代码没有用它原因是一致性toLocaleString()的输出格式取决于系统设置不同设备可能不一样可控性手动格式化可以精确控制输出格式性能toLocaleString()涉及 locale 计算性能略差对于这个应用手动格式化是合理的选择。但要注意getMonth()返回的月份是从 0 开始的0-11所以需要加 1。更好的时间格式化方案手动拼接字符串容易出错建议用工具函数functionformatTime(timestamp:number):string{constdnewDate(timestamp);constmonthd.getMonth()1;constdayd.getDate();consthoursString(d.getHours()).padStart(2,0);constminutesString(d.getMinutes()).padStart(2,0);return${month}/${day}${hours}:${minutes};}这样代码更清晰也更容易维护。如果将来要改格式比如加年份只需修改这一个函数。数据查找DICE_TYPES.find() 的性能代码中多次用DICE_TYPES.find()来查找骰子类型Text(DICE_TYPES.find((d:DiceType)d.keyr.diceType)?.emoji||)find()方法会遍历数组直到找到匹配的元素。对于 7 个元素的DICE_TYPES数组性能完全没问题。但如果数组很大比如几百个元素频繁调用find()可能有性能问题。优化方案是用 MapconstDICE_MAPnewMapstring,DiceType();DICE_TYPES.forEach((d:DiceType)DICE_MAP.set(d.key,d));// 使用Text(DICE_MAP.get(r.diceType)?.emoji||)Map 的查找是 O(1) 复杂度比find()的 O(n) 快很多。但在这个应用中性能差异可以忽略不计用find()就够了。数据持久化DiceDatabase 的设计DiceDatabase类封装了底层的存储逻辑。虽然看不到完整代码但从使用方式可以推断出它的接口设计classDiceDatabase{asyncinit():Promisevoid{...}add(result:RollResult):void{...}getHistory():RollResult[]{...}asyncclear():Promisevoid{...}}Preferences API vs 关系型数据库对于这种简单的键值对存储HarmonyOS 的 Preferences API 就够了。Preferences 适合存储少量配置数据API 简单性能好。但如果要支持复杂查询比如按日期筛选、统计分析建议用关系型数据库 RDB。特性Preferences关系型数据库 (RDB)数据结构键值对表、行、列查询能力按 key 获取SQL 查询性能读写快复杂查询慢适用场景配置、少量数据大量结构化数据数据上限约 1MB无硬性限制在这个应用中历史记录可能会增长到几百条。如果用 Preferences每次获取历史记录都需要读取整个数据集性能会下降。而 RDB 支持分页查询可以只获取最近的 N 条记录。数据同步策略代码中每次投掷后都会调用this.db.getHistory()重新获取历史列表if(this.db){this.db.add(createRollResult(this.diceType,fr));this.historythis.db.getHistory();}这种写后读的策略简单可靠但不是最优的。更好的方案是if(this.db){constnewResultcreateRollResult(this.diceType,fr);this.db.add(newResult);this.history[newResult,...this.history];// 直接更新状态}这样不需要重新从数据库读取直接把新结果添加到列表开头。但要注意这种方式假设add()一定成功。如果add()失败状态和数据库就不一致了。需要权衡一致性和性能。数据清空的确认代码中点击清空按钮会直接清空所有历史记录if(this.history.length0){Text(清空).fontSize(FontSize.sm).fontColor(#EF4444).onClick(()this.clr())}这很危险——用户可能误触。建议添加确认弹窗privateshowClearConfirm:booleanfalse;// 在 UI 中AlertDialog({title:确认清空,message:确定要清空所有历史记录吗此操作不可恢复。,primaryButton:{value:取消,action:(){this.showClearConfirmfalse;}},secondaryButton:{value:清空,fontColor:#EF4444,action:(){this.clr();this.showClearConfirmfalse;}}})这样用户误触时还有机会取消避免数据丢失。列表项的布局分析每个历史记录项的布局是这样的┌─────────────────────────────────┐ │ 六面骰 ×2 14 7/17│ │ [3, 11] 19:35│ └─────────────────────────────────┘左边是骰子图标和类型信息右边是总和和时间。这种左信息、右数值的布局在移动端很常见适合展示概要信息。代码用RowBlank()来实现左右分布Row(){// 左侧图标和类型信息Text(emoji).fontSize(22)Column(){...}Blank()// 占据中间空间// 右侧总和和时间Column(){...}}Blank()是 ArkUI 的弹性空白组件会占据剩余空间。这种布局方式比手动计算宽度更灵活也更容易适配不同屏幕尺寸。列表项的点击交互当前代码没有给列表项添加点击事件。如果要查看详情或删除单条记录需要添加onClickRow(){...}.width(100%).padding(Spacing.md).backgroundColor(this.gc().surface).borderRadius(BorderRadius.md).onClick(()this.showDetail(r))// 添加点击事件但要注意列表项的点击事件和List组件的滚动事件可能会冲突。如果点击响应不灵敏可能需要调整点击区域或增加长按事件。性能优化大数据量的处理当历史记录增长到几百甚至上千条时性能优化就变得重要了。几个优化方向1. 分页加载不要一次性加载所有历史记录而是分页加载Statepage:number1;StatepageSize:number20;StateallHistory:RollResult[][];privateloadMore():void{conststart(this.page-1)*this.pageSize;constendstartthis.pageSize;constnewItemsthis.db.getHistoryRange(start,end);this.allHistory[...this.allHistory,...newItems];this.page;}在List组件的onReachEnd事件中触发加载更多List(){ForEach(this.allHistory,...)}.onReachEnd(()this.loadMore())2. 虚拟列表ArkUI 的List组件默认支持虚拟列表只渲染可见区域的元素。但需要确保ListItem的高度是固定的或可预测的否则虚拟列表的效果会打折扣。3. 图片和 Emoji 的渲染代码中用了 Emoji 作为图标、 等。Emoji 的渲染比图片快但有些设备可能不支持某些 Emoji。如果要兼容更多设备建议用图片资源代替 Emoji。总结历史记录页面虽然功能简单但涉及了 ArkUI 开发的多个核心知识点列表渲染、空状态设计、数据格式化、数据持久化、性能优化。在实际开发中这些知识点会反复出现只是复杂度不同。适用边界这个页面适合用作 ArkUI 列表渲染和数据持久化的入门案例涵盖了 List 组件、ForEach 渲染、空状态设计、时间格式化等核心知识点。但如果要上架应用商店还需要补充分页加载、删除确认、详情查看、数据导出等功能。建议在此基础上逐步扩展而不是一次性做完所有功能。对于 ArkTS 新手建议从类似的小项目入手逐步理解框架的设计哲学。ArkTS 的声明式 UI 和 React 有相似之处但状态管理和生命周期有明显区别需要花时间适应。对于有经验的开发者重点是理解 ArkTS 的约束——它不是 TypeScript 的简单扩展而是一个有自己规则的框架。遵循框架的最佳实践才能写出可维护、高性能的代码。

本周精选

本月热点