鸿蒙音乐播放器实战 03:手把手实现推荐页首页UI(搜索栏/轮播图/横向卡片/歌单列表)
开篇:系列承接与本篇定位
在上一篇实战中,我们完成了整个应用的底部 Tab 导航架构,搭建好了「推荐/发现/动态/我的」四大页面的切换框架。从本篇开始,我们将逐个填充页面内容,首当其冲的就是音乐 App 最核心的首页——推荐页。
推荐页是用户打开 App 第一眼看到的页面,承载了搜索、广告轮播、个性化推荐、歌单入口等核心功能。本篇我们将从零到一完成推荐页的完整 UI 开发,全程遵循企业级开发规范,不仅讲「怎么写」,更讲「为什么这么写」,同时把开发过程中所有真实踩坑点全部复盘,帮你避开 90% 新手会遇到的问题。
你将从本篇学到
-
移动端首页模块化拆分思路,掌握从设计稿到代码的布局拆解能力
-
熟练运用 Row/Column/Swiper/List/Stack/Scroll 六大核心布局组件
-
理解 TypeScript 接口强类型约束的价值,规范数据结构定义
-
掌握 @Builder 组件复用技巧,告别冗余代码,提升可维护性
-
吃透图片填充、圆角裁剪、文本溢出、横向列表等高频 UI 问题的解决方案
-
完整掌握鸿蒙网络权限配置与 HTTP 明文请求放行流程
-
学会排查组件导入、资源失效、解析报错等常见编译与运行问题
前置准备
-
已完成前两篇的项目搭建与底部 Tab 导航开发
-
DevEco Studio 开发环境正常,可运行模拟器
-
掌握 ArkTS 基础语法与声明式 UI 开发范式
一、页面整体设计与技术选型
1.1 页面模块拆分
我们参考主流音乐 App 的首页结构,将推荐页从上到下拆分为四个独立模块,模块之间低耦合、可单独调试、方便后续迭代替换:
-
顶部搜索栏:全局搜索入口,搭配扫码功能,固定在页面顶部
-
轮播 Banner 区:运营广告位,自动轮播,支持手势滑动切换
-
每日推荐卡片区:个性化推荐入口,横向滚动卡片,差异化配色
-
推荐歌单列表区:歌单广场入口,封面叠加播放量,横向滚动展示
整个页面外层包裹 Scroll 容器,实现整页垂直滑动,符合移动端信息流的交互习惯。
1.2 核心技术选型思考
每个功能点都有多种实现方式,我们选择方案的核心原则是:性能优先、符合官方规范、便于后续扩展。
-
横向滚动:List 优先于 Scroll+Row少量固定卡片时两者差异不大,但 List 内置组件复用机制,数据量增大时性能优势明显,且 API 更丰富,支持后续扩展分割线、分组等能力。本次直接采用 List 方案,为后续接口对接预留性能空间。
-
轮播图:原生 Swiper 组件不用自定义 Scroll 封装轮播,原生 Swiper 自带手势适配、切换动画、循环播放、指示器等能力,稳定性更高,是官方推荐的 Banner 实现方案。
-
数据层:Interface 强类型约束即使是模拟数据,也提前定义数据接口,一方面编译期校验字段,避免拼写错误;另一方面后续对接真实接口时,只需替换数据源,UI 代码无需改动。
-
组件复用:@Builder 装饰器标题栏这类重复出现的 UI 片段,通过 @Builder 封装复用,统一维护样式,符合 DRY(不要重复自己)开发原则。
二、分步实现:从 0 到 1 搭建推荐页
我们从上到下逐个模块实现,每一步都可单独预览调试,降低出错概率。所有代码最终都整合在 pages/home/Recommend.ets 文件中。
2.1 数据层封装:强类型接口定义
开发UI前先规范数据结构,是工程化开发的基础。通过TS接口约束卡片、歌单数据格式,让UI与数据精准对应。
// 每日推荐卡片数据类型约束
// 作用:统一所有推荐卡片的数据结构,编译期校验字段,代码自动提示,规避数据错乱问题
interface recommendDailyType {
img: string, // 网络图片地址:卡片封面背景图
title: string, // 卡片底部简介文字:推荐内容描述
type: string, // 卡片顶部标题文字:分类标签(每日推荐/私人漫游等)
top: string, // 顶部标题背景色:十六进制配色
bottom: string // 底部简介背景色:十六进制配色
}
// 推荐歌单数据类型约束
// 作用:规范歌单列表数据格式,适配横向歌单卡片UI
interface RecommendListType {
img: string; // 歌单封面图网络地址
title: string; // 歌单名称文案
count: string; // 歌单播放量展示文案
}
2.2 全局数据源配置(官方原生阿里云资源)
全程使用课程配套原生OSS图片资源,无需替换、无需修改,适配项目原生配置,保证与课程案例完全一致。
@Component
export struct Recommend {
// 轮播图数据源:官方原生Banner图片资源
swiperList: string[] = [
"http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/banner1.png",
"http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/banner3.png"
]
// 每日推荐卡片数据源:原生推荐图+配套文案+差异化配色
dailyRecommend: recommendDailyType[] = [
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/recommend1.png',
title: '每日推荐 | 今天从《不得不爱》听起 | 私人雷达',
type: '每日推荐',
top: '#660000',
bottom: '#382e2f'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/recommend2.png',
title: '从 [Nothing on Me] 开启无限漫游',
type: '私人漫游',
top: '#382e2f',
bottom: '#a37862'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/recommend3.png',
title: '每日推荐 | 今天从《不得不爱》听起 | 私人雷达',
type: '华语流行',
top: '#a37862',
bottom: '#174847'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/recommend4.png',
title: '每日推荐 | 今天从《不得不爱》听起 | 私人雷达',
type: '私人雷达',
top: '#174847',
bottom: '#174847'
}
]
// 推荐歌单数据源:原生歌单封面+真实业务文案
recommendList: RecommendListType[] = [
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/list1.jpg',
title: '每日推荐 | 今天从《不得不爱》听起 | 私人雷达',
count: '270.9万'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/list2.jpg',
title: 'Yasuo和更多好听的 | 华语私人雷达 | 回忆8090',
count: '476.1万'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/list3.jpg',
title: 'Trap Remix丨当欧美热单遇上毒性低音',
count: '186.3万'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/list4.jpg',
title: '满级人类进化之路必备BGM | 根本停不下来',
count: '186.3万'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/list5.jpg',
title: '认真去聆听这个世界的每一分每一秒 (强烈推荐)',
count: '362.8万'
}
]
2.3 通用UI组件封装(@Builder精讲)
@Builder是ArkUI轻量级UI复用核心语法,适合页面内通用小段UI封装,无需新建文件,简化代码结构、统一样式。
/**
* 自定义标题Builder:每日推荐模块标题栏
* @param title 动态传入标题文字,实现组件复用
* 布局结构:左侧标题文字 + 右侧更多图标,两端自适应对齐
*/
@Builder
daytitleBuilder(title:string){
Row(){
// 左侧主标题:加粗自适应占位
Text(title)
.fontColor('#fff')
.fontWeight(700)
.layoutWeight(1) // 占满剩余空间,自动挤压右侧图标
// 右侧更多功能图标
Image($rawfile('ic_more.svg'))
.width(22)
.fillColor('#fff')
}.width('100%')
.height(35)
.justifyContent(FlexAlign.SpaceBetween) // 两端对齐布局
}
/**
* 自定义标题Builder:推荐歌单模块大标题
* 样式差异化设计:无右侧图标,字号更大、权重更高,区分二级模块标题
* @param title 模块标题文字
*/
@Builder
songtitleBuilder(title: string) {
Text(title)
.fontSize(18)
.fontColor('#fff')
.fontWeight(FontWeight.Bold)
.width('92%')
.margin({ top: 20, bottom: 12 }) // 上下外边距,分隔模块间距
}
2.4 顶部自定义搜索栏
效果目标
实现深色主题下的胶囊形搜索栏,左侧搜索图标、中间输入框、右侧功能图标,宽度自适应屏幕,视觉风格统一。
实现思路
外层用 Row 水平布局,左右图标固定宽度,中间输入框通过 layoutWeight 自适应填充剩余空间;外层容器统一设置背景与圆角,输入框设为透明背景,避免原生样式干扰。
完整代码
// 1. 顶部自定义搜索区域
Row(){
Image($rawfile('ic_search.svg')).width(22).fillColor('#817D83')
TextInput({placeholder:'只因你太美🔥'}).placeholderColor('#817D83')
.padding({left:5}).fontColor('#999').layoutWeight(1)
.backgroundColor('transparent')
Image($rawfile('ic_code.svg')).width(20).fillColor('#817D83')
}.width('92%')
.height(36)
.backgroundColor('#2D2B29')
.borderRadius(20)
.padding({left:8,right:8,top:3})
核心知识点精讲
-
layoutWeight(1) 自适应原理这是弹性布局的核心属性。设置该属性的组件会在父容器主轴方向上,自动填充所有剩余空间。本例中左右图标宽度固定,输入框占满中间区域,无论屏幕宽度多少,布局都不会变形。
-
透明背景的设计意义TextInput 原生自带边框与背景,和我们自定义的胶囊样式冲突。设置透明背景后,视觉上完全由外层容器控制样式,UI 一致性更高。
-
92% 宽度的留白设计不使用 100% 宽度贴边,左右各留 4% 边距,符合移动端视觉规范,避免内容贴边产生的压迫感。
新手避坑
-
不要给输入框设置固定宽度,否则小屏幕会挤压图标,大屏幕会留白,必须用 layoutWeight 自适应
-
圆角数值建议为高度的一半,实现完美胶囊效果,本例 36vp 高度对应 20vp 圆角
2.5 轮播图 Banner 区
效果目标
实现自动轮播的广告 Banner,3 秒切换一次,无限循环,底部带指示器,图片采用对角圆角的差异化设计。
实现思路
使用原生 Swiper 组件作为容器,ForEach 遍历图片数组渲染轮播项;图片设置等比裁剪填充,避免变形;通过 border 属性单独设置左上角与右下角圆角,实现异形视觉效果。
完整代码
// 2. 轮播图区域
Swiper() {
ForEach(this.swiperList,(item:string)=>{
Image(item).width("100%")
.height(160)
.objectFit(ImageFit.Cover)
.border({radius:{topLeft:40,bottomRight:40}})
})
}
.width('92%')
.autoPlay(true)
.loop(true)
.interval(3000)
.indicator(true)
核心知识点精讲
-
objectFit 图片填充模式详解这是图片开发最容易踩坑的属性,五种模式差异明显:本例使用 Cover 模式,保证不同比例的网络图片都能填满 Banner 区域,视觉整齐。
-
Cover(推荐):保持宽高比放大,填满容器,超出部分裁剪,无留白,适合 Banner、封面
-
Contain:保持宽高比缩小,完整显示图片,容器可能留白,适合 LOGO、产品图
-
Fill:拉伸图片填满容器,会变形,非特殊场景不建议使用
-
None:不缩放,按原始尺寸显示
-
ScaleDown:仅缩小不放大,小图保持原尺寸,大图缩小显示
-
-
单角圆角实现通过 border.radius 对象可以分别控制四个角的圆角大小,实现差异化设计,比统一圆角更有设计感。
-
轮播核心参数autoPlay 开启自动播放、loop 开启循环、interval 设置间隔毫秒数、indicator 显示底部指示器,是商业 Banner 的标准四件套配置。
新手避坑
-
必须给 Swiper 或内部图片设置固定高度,否则容器高度为 0,轮播不显示
-
网络图片必须配置网络权限,否则真机上图片空白(后文有完整配置教程)
2.6 每日推荐横向卡片区
效果目标
实现横向滚动的推荐卡片,每张卡片采用「顶部标题栏 + 中间封面图 + 底部简介」的三段式结构,差异化渐变配色,整体圆角裁剪,长文本自动省略。
实现思路
先通过 Interface 定义卡片数据结构,准备模拟数据源;用 @Builder 封装通用标题栏;外层使用横向 List 承载卡片列表;单张卡片用 Column 垂直布局,外层设置圆角并开启 clip 裁剪,实现整体圆角效果。
完整代码
// 3. 每日推荐横向卡片区域
this.daytitleBuilder('每日推荐')
List(){
ForEach(this.dailyRecommend,(item:recommendDailyType)=>{
ListItem() {
Column() {
// 卡片顶部标题
Text(item.type)
.width('100%')
.padding(5)
.height(35)
.backgroundColor(item.top)
.fontColor('#fff')
// 卡片主体图片
Image(item.img).width('100%')
.layoutWeight(1)
.objectFit(ImageFit.Cover)
// 卡片底部简介文字(两行省略)
Text(item.title)
.width('100%')
.padding(5)
.fontSize(14)
.fontColor('#fff')
.backgroundColor(item.bottom)
.maxLines(2)
.textOverflow({overflow:TextOverflow.Ellipsis})
}
.width(160)
.height(220)
.border({ radius: 10 })
.margin({ right: 10 })
.clip(true) // 裁剪圆角,解决文字溢出直角问题
}
})
}.listDirection(Axis.Horizontal)
.scrollBar(BarState.Off)
.width('92%')
.height(220)
核心知识点精讲
-
Interface 强类型的价值很多新手觉得模拟数据没必要写接口,这是错误的认知。接口的核心作用有三个:一是编译期校验,写错字段名、漏写字段直接报错,提前发现问题;二是代码提示,书写时自动补全字段,提升开发效率;三是规范统一,团队协作时所有人遵循同一份数据结构,避免混乱。
-
clip(true) 圆角裁剪原理父容器设置 borderRadius 后,默认只会改变自身边框形状,内部的子组件(图片、色块)仍然是直角,会溢出父容器的圆角边界。开启
clip(true)后,系统会按照父容器的边缘轮廓对子组件进行裁剪,超出圆角范围的内容直接不渲染,从而实现整体圆角效果。这是鸿蒙开发中解决「圆角不生效」问题的最常用方案,90% 的圆角失效问题都是因为忘记开启裁剪。 -
文本溢出处理
maxLines限制最大行数,textOverflow设置超出部分用省略号显示,两者搭配使用彻底解决长文案撑破布局的问题,是列表开发的必备技巧。 -
layoutWeight 垂直适配卡片高度固定为 220vp,上下文字区域高度固定,中间图片设置 layoutWeight(1) 自动填充剩余高度,保证所有卡片高度完全一致,不会因为图片尺寸不同而错落。
新手避坑
-
横向 List 必须设置固定高度,否则容器会默认占满全屏,导致页面布局塌陷
-
List 内部必须包裹 ListItem,这是官方规范,直接写子组件会触发编译警告与潜在性能问题
-
隐藏滚动条可以让页面更干净,符合移动端 App 的视觉规范
2.7 推荐歌单列表区
效果目标
实现横向滚动的歌单卡片,封面图左上角悬浮播放量,下方展示歌单名称,长名称两行省略,整体样式对齐主流音乐 App。
实现思路
使用 Stack 堆叠布局实现播放量悬浮在封面之上的效果;外层同样采用横向 List 承载列表;歌单名称做文本溢出处理;播放量文字添加半透明背景,保证在亮色图片上的可读性。
完整代码
// 4. 推荐歌单区域
this.songtitleBuilder('推荐歌单')
List() {
ForEach(this.recommendList, (item: RecommendListType) => {
ListItem() {
Column({ space: 6 }) {
// 封面+播放量悬浮层
Stack({ alignContent: Alignment.TopStart }) {
Image(item.img)
.width(120)
.height(120)
.objectFit(ImageFit.Cover)
.borderRadius(8)
Text(item.count)
.fontColor('#fff')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.margin(5)
.backgroundColor('rgba(0,0,0,0.4)')
.padding({left:4,right:4})
.borderRadius(4)
}
.width(120)
// 歌单名称文字
Text(item.title)
.fontColor('#fff')
.fontSize(12)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width(120)
}
}
})
}
.width('92%')
.height(180)
.listDirection(Axis.Horizontal)
.scrollBar(BarState.Off)
核心知识点精讲
-
Stack 堆叠布局原理Stack 内部的子组件按书写顺序层级叠加,先写的在底层,后写的在顶层。通过
alignContent可以控制子组件的对齐位置,本例设置为左上角对齐,实现播放量悬浮在封面左上角的效果,是实现标签、水印、悬浮按钮的核心布局方式。 -
半透明背景提升可读性直接把白色文字放在图片上,遇到亮色图片会看不清。添加一层 40% 透明度的黑色背景,既不遮挡封面主体,又能保证文字在任何图片上都清晰可读,是行业通用做法。
-
固定尺寸保证整齐封面、文字宽度统一设置为 120vp,所有歌单卡片尺寸完全一致,横向排列整齐美观,不会因为图片和文字长度不同而变形。
三、核心原理深度解析
3.1 横向滚动方案对比:List vs Scroll+Row
很多新手会疑惑,为什么不用更简单的 Scroll+Row 实现横向滚动,这里做一个完整对比:
|
对比维度 |
Scroll + Row |
List + ListItem |
|
渲染机制 |
一次性渲染所有子组件 |
按需渲染可见区域,滚动时回收复用组件 |
|
性能表现 |
数据量少时流畅,20项以上内存占用显著上升 |
大数据量下性能优势明显,100项也能保持流畅 |
|
功能丰富度 |
基础滚动能力 |
支持分割线、分组、粘性标题、懒加载等高级特性 |
|
代码复杂度 |
写法简单 |
需要嵌套 ListItem,略繁琐 |
|
适用场景 |
少量固定选项、标签栏 |
数据列表、卡片流、后续可能扩展数据的场景 |
本次歌单、推荐卡片后续都会对接真实接口,数据量可能增加,因此直接选用 List 方案,为后续扩展留足空间。
3.2 @Builder 组件复用的意义
@Builder 是 ArkUI 提供的轻量级 UI 复用机制,它的核心价值在于:
-
减少冗余代码:相同样式的标题、卡片只写一次,多处调用
-
统一维护成本:修改样式只改一处,所有引用位置同步生效,避免多处修改遗漏
-
提升代码可读性:业务代码更简洁,结构更清晰,聚焦核心逻辑
注意区分:@Builder 用于页面内小范围 UI 复用;如果是跨页面复用的通用组件,应该封装成独立的自定义组件(单独 struct)。
3.3 为什么必须配置网络权限
鸿蒙系统对应用权限做了严格管控,默认状态下应用没有任何网络访问能力,这是出于安全与隐私保护的设计。应用要访问网络,必须在配置文件中主动声明互联网权限,系统才会开放对应的网络能力。
除此之外,鸿蒙默认只允许 HTTPS 加密请求,HTTP 明文请求会被系统拦截。这是因为 HTTP 传输的数据不加密,存在被窃听、篡改的风险。开发阶段如果使用 HTTP 测试资源,需要额外配置网络安全策略放行明文流量,正式上线建议全部切换为 HTTPS。
四、网络权限完整配置指南
我们的页面使用了大量网络图片,必须完成两步配置才能正常加载,缺一不可。
4.1 第一步:声明互联网权限
-
打开配置文件:
entry/src/main/module.json5 -
在
module节点下添加requestPermissions数组 -
写入
ohos.permission.INTERNET权限配置
{
"module": {
// routerMap、name、pages等其余原有配置保持不变
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
},
{
"name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
}
],
"abilities": [
{
"name": "EntryAbility",
// 其余原有配置保持不变
"backgroundModes": [
"audioPlayback"
]
}
]
}
}
说明:互联网权限属于普通权限,静态声明即可生效,不需要用户手动授权。
4.2 第二步:放行 HTTP 明文请求
-
找到目录:
entry/src/main/resources/base/profile,没有 profile 文件夹就手动新建 -
在 profile 目录下新建文件:
network_config.json -
写入全局明文放行配置
{
"network-security-config": {
"base-config": {
"cleartextTrafficPermitted": true
}
}
}
注意:该配置仅用于开发测试;正式上线请全部使用 HTTPS 地址,并按需给指定域名放开权限,提升安全性。
4.3 配置生效步骤
-
点击 DevEco Studio 顶部的 Sync Project 同步项目配置
-
卸载模拟器/真机上的旧版本应用
-
重新编译运行,网络图片即可正常加载
五、实战踩坑全记录(附完整解决方案)
以下是本次开发过程中真实遇到的所有问题,每一个都是新手高频踩坑点,建议收藏对照排查。
坑1:编译报错 - 组件导出导入名称不匹配
报错信息:
10505001 ArkTS Compiler Error
'"./home/Recommend"' has no exported member named 'Recommend'. Did you mean 'Recommend1'?
10905204 ArkTS Compiler Error
'Recommend()' does not meet UI component syntax.
原因分析:
组件文件内导出的结构体名称,和导入时使用的名称不一致。鸿蒙对组件名称严格匹配,大小写、字符差异都会导致识别失败,进而连锁触发 UI 组件语法报错。
解决方案:
保持「文件名 = 组件名」的命名规范,Recommend.ets 文件内导出 struct Recommend,导入时也使用 Recommend,三者完全统一。
避坑建议:
养成一个文件一个组件、文件名和组件名完全一致的开发习惯,从源头避免此类问题。
坑2:运行异常 - 网络图片全部空白
现象:代码编译正常,但页面上所有网络图片位置都是空白,没有报错。
排查思路:
-
先检查是否配置了 INTERNET 网络权限
-
如果是 HTTP 地址,检查是否配置了明文请求放行
-
检查图片链接是否有效,是否过期、是否禁止外部访问
-
卸载旧应用重新安装,排除配置未生效问题
本次原因:课程配套的阿里云 OSS 资源已过期,禁止外部访问,属于资源失效问题,和代码无关。
解决方案:替换为有效的公共图片资源,或使用本地图片兜底。
坑3:编辑器警告 - 网页解析失败
现象:编辑器提示「网页解析失败,可能是不支持的网页类型」,但模拟器/真机上图片可以正常显示。
原因分析:
这是编辑器的资源解析器导致的提示。纯图片链接只返回二进制图片数据,没有网页文本结构,编辑器的网页解析器无法识别,就会抛出该警告。该警告仅存在于编辑器,完全不影响实际运行效果。
解决方案:
-
方案一:忽略即可,不影响编译和运行
-
方案二:将图片资源下载到本地 rawfile 目录,使用本地资源引用,彻底消除警告
-
方案三:更换支持网页解析的图床资源(一般带跳转页面的图片链接)
六、Recommend.ets 最终完整源码
以下是整合所有模块、修复所有问题、可直接编译运行的完整代码,直接替换文件内容即可使用。
/**
* 实战03 推荐页
* 分层结构:搜索区+轮播区+每日推荐区+歌单区
*/
// 每日推荐卡片数据类型约束
interface recommendDailyType {
img: string, // 网络图片地址
title: string, // 卡片底部简介文字
type: string, // 卡片顶部标题文字
top: string, // 顶部标题背景色
bottom: string // 底部简介背景色
}
// 推荐歌单数据类型约束
interface RecommendListType {
img: string; // 歌单封面图
title: string; // 歌单名称
count: string; // 歌单播放量
}
@Component
export struct Recommend {
// 轮播图网络图片数据源
swiperList: string[] = [
"http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/banner1.png",
"http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/banner3.png"
]
// 每日推荐卡片数据源
dailyRecommend: recommendDailyType[] = [
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/recommend1.png',
title: '每日推荐 | 今天从《不得不爱》听起 | 私人雷达',
type: '每日推荐',
top: '#660000',
bottom: '#382e2f'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/recommend2.png',
title: '从 [Nothing on Me] 开启无限漫游',
type: '私人漫游',
top: '#382e2f',
bottom: '#a37862'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/recommend3.png',
title: '每日推荐 | 今天从《不得不爱》听起 | 私人雷达',
type: '华语流行',
top: '#a37862',
bottom: '#174847'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/recommend4.png',
title: '每日推荐 | 今天从《不得不爱》听起 | 私人雷达',
type: '私人雷达',
top: '#174847',
bottom: '#174847'
}
]
// 推荐歌单数据源
recommendList: RecommendListType[] = [
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/list1.jpg',
title: '每日推荐 | 今天从《不得不爱》听起 | 私人雷达',
count: '270.9万'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/list2.jpg',
title: 'Yasuo和更多好听的 | 华语私人雷达 | 回忆8090',
count: '476.1万'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/list3.jpg',
title: 'Trap Remix丨当欧美热单遇上毒性低音',
count: '186.3万'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/list4.jpg',
title: '满级人类进化之路必备BGM | 根本停不下来',
count: '186.3万'
},
{
img: 'http://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/HeimaCloudMusic/list5.jpg',
title: '认真去聆听这个世界的每一分每一秒 (强烈推荐)',
count: '362.8万'
}
]
// 自定义标题Builder:每日推荐标题+更多按钮
@Builder
daytitleBuilder(title:string){
Row(){
Text(title)
.fontColor('#fff')
.fontWeight(700)
.layoutWeight(1)
Image($rawfile('ic_more.svg'))
.width(22)
.fillColor('#fff')
}.width('100%')
.height(35)
.justifyContent(FlexAlign.SpaceBetween)
}
// 自定义标题Builder:歌单模块标题
@Builder
songtitleBuilder(title: string) {
Text(title)
.fontSize(18)
.fontColor('#fff')
.fontWeight(FontWeight.Bold)
.width('92%')
.margin({ top: 20, bottom: 12 })
}
build() {
// 外层滚动容器,实现整页上下滑动
Scroll() {
Column({space:15}){
// 1. 顶部自定义搜索区域
Row(){
Image($rawfile('ic_search.svg')).width(22).fillColor('#817D83')
TextInput({placeholder:'只因你太美🔥'}).placeholderColor('#817D83')
.padding({left:5}).fontColor('#999').layoutWeight(1)
.backgroundColor('transparent')
Image($rawfile('ic_code.svg')).width(20).fillColor('#817D83')
}.width('92%')
.height(36)
.backgroundColor('#2D2B29')
.borderRadius(20)
.padding({left:8,right:8,top:3})
// 2. 轮播图区域
Swiper() {
ForEach(this.swiperList,(item:string)=>{
Image(item).width("100%")
.height(160)
.objectFit(ImageFit.Cover)
.border({radius:{topLeft:40,bottomRight:40}})
})
}
.width('92%')
.autoPlay(true)
.loop(true)
.interval(3000)
.indicator(true)
// 3. 每日推荐横向卡片区域
this.daytitleBuilder('每日推荐')
List(){
ForEach(this.dailyRecommend,(item:recommendDailyType)=>{
ListItem() {
Column() {
// 卡片顶部标题
Text(item.type)
.width('100%')
.padding(5)
.height(35)
.backgroundColor(item.top)
.fontColor('#fff')
// 卡片主体图片
Image(item.img).width('100%')
.layoutWeight(1)
.objectFit(ImageFit.Cover)
// 卡片底部简介文字(两行省略)
Text(item.title)
.width('100%')
.padding(5)
.fontSize(14)
.fontColor('#fff')
.backgroundColor(item.bottom)
.maxLines(2)
.textOverflow({overflow:TextOverflow.Ellipsis})
}
.width(160)
.height(220)
.border({ radius: 10 })
.margin({ right: 10 })
.clip(true) // 裁剪圆角,解决文字溢出直角问题
}
})
}.listDirection(Axis.Horizontal)
.scrollBar(BarState.Off)
.width('92%')
.height(220)
// 4. 推荐歌单区域
this.songtitleBuilder('推荐歌单')
List() {
ForEach(this.recommendList, (item: RecommendListType) => {
ListItem() {
Column({ space: 6 }) {
// 封面+播放量悬浮层
Stack({ alignContent: Alignment.TopStart }) {
Image(item.img)
.width(120)
.height(120)
.objectFit(ImageFit.Cover)
.borderRadius(8)
Text(item.count)
.fontColor('#fff')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.margin(5)
.backgroundColor('rgba(0,0,0,0.4)')
.padding({left:4,right:4})
.borderRadius(4)
}
.width(120)
// 歌单名称文字
Text(item.title)
.fontColor('#fff')
.fontSize(12)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width(120)
}
}
})
}
.width('92%')
.height(180)
.listDirection(Axis.Horizontal)
.scrollBar(BarState.Off)
}
.width('100%')
.padding({top:10})
}
.width('100%')
.height('100%')
.backgroundColor('#131215')
.scrollBar(BarState.Off)
}
}
效果图:

七、本篇总结与下篇预告
本篇核心收获
-
掌握了移动端首页模块化拆分的设计思路,能独立完成从设计到代码的布局拆解
-
熟练运用六大核心布局组件,理解各自的适用场景与性能差异
-
吃透了图片填充、圆角裁剪、文本溢出三大高频 UI 问题的底层原理与解决方案
-
建立了强类型开发意识,学会了用 Interface 规范数据、用 @Builder 复用 UI
-
完整掌握了鸿蒙网络配置流程,具备独立排查图片加载问题的能力
-
积累了三个真实开发踩坑经验,避开新手常见误区
下篇预告
目前我们完成了推荐页的完整 UI 开发后,下一篇我们将正式进入发现组件页面的搭建实战,继续完善音乐应用的 Tab 导航体系。通用卡片封装、UI 数据解耦与后端接口对接等进阶内容,会放在后续章节逐步展开。。
更多推荐



所有评论(0)