ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ArkUI歌曲列表实战:声明式UI布局与状态管理详解

ArkUI歌曲列表实战:声明式UI布局与状态管理详解 做客户端开发这些年我一直有个习惯拿到新框架先用列表页面练手。列表是移动端最基础也最考验功底的场景——布局层级、状态管理、交互反馈、性能优化全都能覆盖到。最近在学 HarmonyOS 的 ArkUI就选了“歌曲列表”这个题目做界面布局练习把声明式 UI 的思路完整过了一遍。这篇文章就把我的拆解过程、布局设计、核心代码和踩坑记录都放出来给同在 ArkUI 入门阶段的朋友做个参考。歌唱列表这个项目听起来简单实际做起来你会发现它恰好能逼你把List、Row、Column、ForEach、State这些 ArkUI 高频 API 全用一遍。更关键的是你还能在这个小界面上想明白“布局参数怎么写才舒服”“状态怎么管理才不别扭”这类比 API 本身更值钱的经验。无论你是刚从传统 View 体系转过来还是直接接触声明式 UI这篇文章里的思路都能直接抄作业。1. 项目背景与需求拆解1.1 为什么拿歌曲列表练手我在没接触 ArkUI 之前已经把 SwiftUI 和 Jetpack Compose 都过了一遍。这三套声明式框架的底层逻辑高度相似都是通过修饰符或者参数去控制布局靠状态驱动刷新。但 ArkUI 有自己的表达方式比如用Row和Column做线性排列用List做懒加载滚动篇幅长度单位统一用vpvirtual pixel而不是 dp 或 px。这些细节不真刀真枪写一遍光看文档是记不住的。歌曲列表这个题目特别合适原因有三点。第一它的数据模型非常标准每一条包含封面、歌名、歌手和可选的状态图标能覆盖“图文混排”的典型场景。第二它需要一个可滚动的容器能练到List的正确打开方式如果只用一个Column包所有行你会立刻感受到性能压力。第三它天然要做点击响应比如点击某一首播放、切换播放状态这就引出了状态管理的练习。一个小项目能练到三层能力“布局基础 性能观念 状态管理”比单纯的静态页面有价值得多。1.2 需求拆解与页面结构规划动手写代码之前我习惯先画个结构草图。这个歌曲列表页从上到下可以拆成三段顶部的标题栏、中间的歌曲列表、底部的播放控制条。如果你看过主流音乐 App会发现这个结构几乎是行业标准——顶部提供导航和页面语义中间是内容主体底部常驻播控条切换 App 内别的页面时播放不中断。放到 ArkUI 里顶部标题栏我用一个Row放在安全区内中间用List撑满剩余空间底部用一个固定在页面底部的Row做播放条。整个页面的根布局是Column排列方向是纵向List部分必须设置layoutWeight(1)来占满剩余高度否则底部的播放条就会贴到列表中间去。这一步看着简单却是新手最容易犯的错——很多人习惯把列表高度写死结果不同机型上要么列表占不满屏要么底部条被顶出去。数据层面我用一个Song类来承载一条歌曲信息title、artist、cover、duration外加一个isPlaying的布尔状态。封面图我会先用本地资源或网络占位图等布局调通后再换成真实 CDN 地址。这样能避免一开始就卡在图片加载上把注意力先集中在布局本身。2. 界面布局的整体设计思路2.1 容器选型为什么用 List 而不是 ScrollColumn在开始写第一个版本的时候我差点图省事用Scroll包Column来渲染所有歌曲行。因为手动写循环看着直观列表长度也就十几条用 Scroll 完全够用。但后来我还是改回了List因为List在 ArkUI 里是专门的懒加载列表容器它会按需创建和销毁可视区域内的子组件配合LazyForEach能实现真正的长列表流畅滑动。而ScrollColumn的本质是“一次性把全部内容都创建出来”列表只有 10 条时当然没感觉一旦撑到 500 首本地歌曲内存和掉帧问题就全来了。做一个合格的音乐列表而不是练习页就得用长期主义的角度去选型。List还顺带解决了分割线、侧滑按钮、吸顶分组这些扩展需求。比如我后面想加一个“最近播放”分组List的ListItemGroup就能直接实现而不用自己写粘性头部。所以哪怕你现在只是练手也建议直接逼自己用List把正确习惯养起来。2.2 布局参数的“为什么”间距、对齐与权重ArkUI 的布局本质上就是 Flexbox 的一维排列。每条歌曲行我定为约 72vp 高封面Image是 48vp 见方、圆角 8vp歌名和歌手上下叠在一个Column里行尾放一个播放状态图标。行与行之间不画分割线而是用空白间距隔离这样视觉更干净也更符合现代播放器的简洁风格。为什么选 72vp 而不是 64vp因为列表项要兼顾触控热区和视觉密度。64vp 能塞下更多内容但手指点按稍有偏差就容易误触80vp 又显得太松散底部栏和信息密度都不太协调。72vp 是实付过多个页面的权衡值当然你也可以微调到 68vp关键是明白这个数字不是随手拍的。内部的间距我统一用了margin和padding组合封面外的右间距是 12vp文字和尾部图标之间留 8vp。这样所有项目间距都成体系而不是东一个西一个。可能你听起来觉得小题大做但实际开发中界面不舒服的九成原因就是间距没有“体系感”每个地方差 2vp累积起来就非常乱。2.3 尺寸单位与屏幕适配ArkUI 推荐用vp作为尺寸单位它和响应式像素类似会跟随不同屏幕的密度和字体大小自动缩放。如果你写死px同一个 300px 的宽度在低分辨率手机和高分辨率平板上会呈现完全不同的物理视觉大小基本等于放弃适配。我在这个练习里全部使用vp和百分比字符串比如width(100%)来布局。列表项的封面、字体大小、行高全部用vp根容器自适应这样不同尺寸屏幕上跑起来视觉比例保持一致。对页面头部标题我设了fontSize(20)配合fontWeight(FontWeight.Bold)字号没有用组件默认值因为默认字号在视觉层级上不够突出标题栏需要有明确的“页面标题”语义。3. 核心布局细节实战3.1 先搭一个干净的音乐数据模型我习惯把练习用的数据结构写在独立的model/Song.ets里。ArkUI 的页面文件后缀是.ets里面可以同时保留类型定义和组件函数。但为了组织清晰我还是把纯数据模型与 UI 分开。下面是一个极简的Song类型export class Song { id: number; title: string; artist: string; cover: string; duration: number; // 单位秒 isPlaying: boolean false; constructor(id: number, title: string, artist: string, cover: string, duration: number) { this.id id; this.title title; this.artist artist; this.cover cover; this.duration duration; } }isPlaying我直接放进类里作为字段实际上更好的做法是放到页面状态里用一个单独的当前播放 ID 来控制。因为isPlaying是跟着“当前播放的歌曲”走的不属于歌曲的固有属性。但你做练习时可以简化把状态直接放进去方便演示State的刷新效果。等你做真实项目再把状态上提到页面层级即可。3.2 列表项的 Row 布局拆解歌曲行核心是一个Row从左到右依次是封面、文字区、状态区。我先把完整的列表项构建函数写出来然后再逐行说明为什么这么写Builder SongItem(item: Song, index: number) { Row() { Image(this.getCover(item)) .width(48) .height(48) .borderRadius(8) .objectFit(ImageFit.Cover) .backgroundColor(#F1F3F5) Column({ space: 4 }) { Text(item.title) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(#1A1A1A) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(item.artist) .fontSize(13) .fontColor(#8F8F94) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .margin({ left: 12 }) if (item.isPlaying) { Image(/res/ic_pause.png) .width(20) .height(20) .margin({ right: 4 }) } else { Blank() } } .width(100%) .height(72) .padding({ left: 16, right: 16 }) .alignItems(VerticalAlign.Center) .onClick(() { this.togglePlay(item) }) }有几个细节特别值得展开。第一封面的圆角borderRadius(8)不是附带的装饰而是现代列表的设计语言它能削弱图片边缘的锐利感让用户在高速滚动时不那么刺眼。第二文字区的layoutWeight(1)是灵魂它让歌名和歌手列的可用宽度自动占满封面右侧的所有剩余空间不给尾部图标流出预设宽度这样歌名最长显示到状态图标之前天然做到自适应。第三maxLines(1)加textOverflow是列表项必须做的保护不然长歌名会把布局挤爆。3.3 用 ForEach 渲染歌曲列表拿到数据结构后我用List加ForEach把数组渲染出来。一开始我图省事用普通ForEach后来为了性能换成了LazyForEach。这里先给出ForEach版本因为代码更直观适合作为练习的起步点List({ space: 0 }) { ForEach(this.songList, (item: Song, index: number) { ListItem() { this.SongItem(item, index) } }, (item: Song) item.id.toString()) } .width(100%) .layoutWeight(1) .scrollBar(BarState.Off)ForEach的第三个参数是键值生成器我直接用item.id转字符串。这个键值非常重要它决定了 ArkUI 在数组变更时如何复用和重排节点。如果用数组下标当键增删元素时 UI 会错乱尤其是做“删除歌曲”功能时极大概率出现动画错位或内容串行。这是我在练习中被狠狠教育过的地方。3.4 顶部标题栏与底部播放条页面根布局用Column顶部和底部是固定区域中间是List。顶部标题栏我要做到“页面标题居中左侧留返回按钮的位置”但这个练习里没有返回栈所以只放了一个居中标题Row() { Text(我的歌单) .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor(#1A1A1A) } .width(100%) .height(56) .justifyContent(FlexAlign.Center)底部播放条我用一个悬浮式圆角卡片效果。它在根布局中不是List的子项而是与List平级的兄弟节点。播放条高度 64vp左右留 16vp 边距上下留一点安全区。卡片本身用白色背景、圆角 16vp 和轻微阴影同时显示当前播放歌名和一首控制按钮实际代码如下Row() { Image(this.currentCover) .width(40) .height(40) .borderRadius(8) Column({ space: 2 }) { Text(this.currentTitle) .fontSize(14) .maxLines(1) Text(this.currentArtist) .fontSize(12) .fontColor(#8F8F94) .maxLines(1) } .layoutWeight(1) .margin({ left: 12, right: 12 }) .alignItems(HorizontalAlign.Start) Image(this.isPlaying ? /res/ic_pause_small.png : /res/ic_play_small.png) .width(32) .height(32) .onClick(() { this.isPlaying !this.isPlaying }) } .width(100%) .height(64) .padding({ left: 16, right: 16 }) .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 24, color: #22000000, offsetY: 4 }) .margin({ left: 16, right: 16, bottom: 12 })底部播放条如果不加阴影会显得像是“浮在列表上的一块白板”一旦加上柔和的投影层次感立刻出来了。shadow的参数我踩过坑offsetY用 4vp、radius用 24vp 是比较自然的值太大像弥散光太小又毫无存在感。3.5 整体页面组装把上面三块拼起来后主页面结构大致如下build() { Column() { this.TitleBar() this.SongList() this.PlayerBar() } .width(100%) .height(100%) .backgroundColor(#F7F8FA) }这里的backgroundColor我选了偏冷的浅灰#F7F8FA而不是纯白。因为列表项之间要透气纯白背景加上白色卡片型播放条会缺少区分度。浅灰给整个页面一层淡淡的“画布感”黑色文字和彩色封面更容易被识别出来。你也可以用#F5F5F7效果差不多。4. 状态管理与交互补全4.1 用 State 驱动界面刷新ArkUI 声明式 UI 的核心是状态驱动。当页面数据变化时不需要手动调用更新方法只需要修改被State标记的变量页面就会自动重渲染。我把歌单数组和当前播放信息都放在页面组件里State songList: Song[] initSongs() State currentId: number -1 State isPlaying: boolean false这里有一个练习初期容易忽略的点State监听的是深层变化吗对于数组如果我用this.songList[0].isPlaying true这样直接改某个对象的字段ArkUI 不一定能感知到对象属性的变化。所以正确的方式是创建新对象替换旧对象或者把Song类中需要响应式刷新的字段用Observed和ObjectLink处理。对于这个练习我选择了一条更轻的路不在SongItem内部直接读取item.isPlaying来做状态判断而是把“当前正在播放”提升到页面层级通过currentId判断。这样数据变更集中在currentId和isPlaying两个基础类型状态上State完全能覆盖不需要引入更复杂的观测机制。列表项中的if (item.id this.currentId)判断自然就控制了状态图标的显隐。4.2 点击切换播放逻辑我实现了两个交互点击列表任意一项把它设为当前播放项点击播放条上的按钮切换播放/暂停。代码如下togglePlay(item: Song) { if (this.currentId item.id) { this.isPlaying !this.isPlaying } else { this.currentId item.id this.isPlaying true } }这里我刻意把状态逻辑写得极简但里面已经含了一个关键判断点击正在播放的歌效果是暂停点击另一首歌效果是切歌并播放。这个分支处理是真实播放器交互的雏形练习时能想明白这一步后面接播放引擎时你只需要把isPlaying的切换换成audioPlayer.play()或audioPlayer.pause()即可。4.3 列表项的状态图标反馈在SongItem里我用当前页面的状态来判断显示什么图标而不是直接用item.isPlayingif (this.currentId item.id this.isPlaying) { Image(/res/ic_volume.png) .width(18) .height(18) } else if (this.currentId item.id !this.isPlaying) { Image(/res/ic_pause.png) .width(18) .height(18) } else { Text(${this.formatDuration(item.duration)}) .fontSize(12) .fontColor(#B0B0B5) }注意第三分支当这首歌不是当前播放项时我没有放空占位而是显示歌曲时长。这样右侧区域始终有一个元素占据宽度避免了播放图标出现时文字宽度跳动。这个细节很多人不做结果就是每点一首歌整个列表的文字都在轻微左右抖动非常影响体验。4.4 用 Builder 拆分复用逻辑Builder是 ArkUI 里非常有用的语法糖它可以把一段 UI 结构抽成函数在同一组件内复用。我的SongItem就是用Builder写的。比起写一个自定义组件Component SongItemViewBuilder的好处是写法轻、不需要额外传参和状态绑定适合页面内的局部复用。如果你的列表项需要单独被多个页面使用或者内部有独立的业务逻辑那应该抽成真正的自定义Component。我的建议是练习阶段先用Builder它能让你把精力集中在布局本身等组件复杂到需要自己的State时再升级为Component。别一上来就做过度设计。5. 常见问题与排查技巧实录5.1 图片不显示或显示灰色块这是 ArkUI 新手第一个大坑。练习时如果用网络地址加载图片必须在module.json5里开启网络权限并且配置网络安全策略。文件里要有这样一段{ module: { requestPermissions: [ { name: ohos.permission.INTERNET } ] } }如果权限开了还是不显示重点检查Image的objectFit属性。网络图片的尺寸五花八门不设ImageFit.Cover时默认的缩放行为会把图片“塞进”框内比例不对就出现白边。我习惯在写Image时立刻跟上.objectFit(ImageFit.Cover)加上固定的宽高否则图片一旦加载出来布局会突然跳一下。5.2 List 滑动卡顿的初级与进阶排查小列表一般不会卡。如果你用真实手机测试时感觉到滚动不跟手优先检查是不是用了ScrollColumn渲染全量列表。换成ListLazyForEach后再检查列表项内部是否有过多嵌套或复杂的阴影效果。shadow在列表项上慎用它需要在滚动时实时计算投影逐帧渲染开销很大。另一个隐蔽的性能问题是列表项中频繁使用三目运算或if分支。ArkUI 在状态变化时会对比 UI 描述分支表达越多diff 成本越高。对于状态图标切换这种轻量操作完全没问题但不要在列表项里放复杂的状态计算或耗时函数。我把formatDuration做成了普通函数而不是每次直接拼接字符串尽量减小不必要的开销。5.3 底部播放条遮挡 List 最后一项如果你把播放条放在Stack里或者直接悬浮List的内容会延伸到底部播放条的底下最后一项看不清。我的做法是在页面布局层面就让 $List$ 和播放条成为Column的兄弟节点List用layoutWeight(1)占据播放条以上的空间这样从布局结构上就杜绝了遮挡问题。如果你确实需要在播放条上方给列表留出额外“底部透明区域”比如列表最后一项要被展开的播放条遮住一部分可以在List里加一个最后一个ListItem的高度占位或者给List设置contentEndOffset。我目前这个练习没有用到但你在做真实播放器时要提前预判这个坑。5.4 歌名过长导致右侧图标被挤掉我一开始没有给文字区的父容器加layoutWeight(1)直接让文字区和尾部图标并排。结果歌名一长就把时长或播放图标挤出了屏幕。加上layoutWeight(1)之后文字区成为弹性收缩区域图标始终保留。同时Text必须设置maxLines(1)和省略号否则文字不换行但会把布局撑爆。这两个条件缺一不可缺了任何一个长歌名的列表都会翻车。5.5 状态刷新不生效的问题这也是我调试最久的问题。用State songList后我直接改songList[0].isPlaying true页面上图标纹丝不动。原因是State只能观察到数组对象自身的变化比如push、splice、替换整个数组不能观察到数组元素的深层属性变化。解决方案有三种替换整个数组、给数组元素使用Observed类、或者像我一样把关键状态提升到页面层。三种方案没有绝对优劣看你具体业务。对于播放状态这种“全局唯一”的状态提升到页面层管理是最干净的选择。5.6 预览器与真机表现不一致ArkUI 的 Previewer 在布局上大体可靠但部分表现和真机有差异比如字体渲染、阴影深度和安全区高度。我遇到过开发预览器里播放条位置完美上真机后底部多了一大块空白因为预览器没有完整模拟底部安全区。建议在页面布局里主动处理安全区让底部播放条在安全区之上.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])或者给播放条设置padding值为this.bottomInset。练习阶段你可以先在预览器里看布局但最终效果一定要上真机确认。这也是老开发者的习惯——预览器是参考真机是真相。6. 从练习到实际的扩展建议6.1 给列表加上侧滑操作歌曲列表最常见的高频操作是删除歌曲。List的ListItem提供了swipeAction属性可以设置左侧和右侧的滑动按钮。我给删除按钮设了红色圆角背景点击后从歌单里移除该项。这要求我动态操作songList并且保证ForEach的键值稳定。好在我用了item.id作为键值删除操作不会导致列表重排错乱。.swipeAction({ end: this.DeleteButton(item) })DeleteButton里可以放一个带onClick的Text。侧滑按钮的宽度不要小于 64vp否则手指操作难度很大。真机测试时还要注意滑动和点击手势冲突swipeAction区域的点击事件要注意收敛否则会出现滑出按钮后轻轻一点又触发了行点击的问题。6.2 接入真实数据源练习完成后你可以把initSongs()替换成真实的请求。ArkUI 请求网络数据一般用ohos.net.http。拿到数据后转换成Song数组再赋值给State songList。这个过程中要处理好异步竞态比如用户快速切换页面时上一个请求回调回来可能覆盖新数据。解决思路是加请求序号或者取消标志。虽然这是练习项目但我还是建议想一想万一以后接线上环境这个坎是绕不过去的。6.3 增加分组与吸顶功能如果用这首歌单来存放大量歌曲可以按歌手名称或专辑首字母分组。List配合ListItemGroup能直接实现分组和吸顶标题。你需要把ForEach的层级从一层嵌套变成“外层分组内层渲染歌曲”。分组头可以显示组名和歌曲数量视觉上用 28vp 高度加浅灰背景。这个扩展练一遍基本就把 ArkUI 列表的进阶用法摸清了。6.4 用Link或Prop与子组件通信我已经把列表项放进了Builder里整体代码非常紧凑。但如果把SongItem拆成一个独立子组件就会遇到父子组件通信的问题。Prop用于单向数据传递Link用于双向同步。一个比较典型的场景是播放条组件需要同时读写页面层的currentTitle和isPlaying这时候就可以用Link来接。但记住Link增加耦合能不用就不用。子组件尽量只接收数据、抛事件状态仍然由父组件统一管理。这是我写多端框架的通用原则ArkUI 也一样。写在最后的个人体会一套“歌曲列表”练下来我对 ArkUI 的布局语法和状态机制有了比看文档深刻得多的体感。很多属性单独看都认识组合起来才意识到难点在于设计“状态从哪来、到哪里去、改变了谁”。比如底部播放条和列表项共用同一个currentId看似只是简单引用但真实决定了整个页面的数据流结构。这种从页面整体出发的思考方式比记住一个 API 签名重要得多。如果你正在学 ArkUI我建议不要直接照抄某个成品项目的完整代码而是像我一样从最小可运行的歌单开始一点点加上删除、播放状态、分组这些功能。每加一个功能你都会遇到一个真实的问题而解决这个问题的过程才是框架和业务结合时最值钱的经验积累。后面等我做完了侧滑删除和真实数据对接再写一篇更偏实战的记录分享出来。
返回列表