鸿蒙原生应用实战(二):首页开发 —— Grid分类网格与热歌排行榜
鸿蒙原生应用实战(二):首页开发 —— Grid分类网格与热歌排行榜
前言
上一篇完成了项目初始化与架构设计。本篇进入核心开发——首页(Index.ets)。
首页是App的门面,「乐迷笔记」的首页承载着五大模块:
- 顶部问候栏 —— 根据时段显示不同问候语
- 每日精选Banner —— 促销/推荐展示位
- 音乐分类网格 —— 8大分类的2×4 Grid布局
- 推荐专辑横向滚动 —— 向左滑动发现更多
- 热歌排行榜 —— 带序号的Top8榜单
这些功能模块覆盖了 ArkTS 中最常用的布局组件:Grid、Scroll、Row、Column、List、Stack 等。
一、页面结构规划
Index.ets
├── @Builder buildTopBar() ← 问候语 + 头像按钮
├── @Builder buildDateRow() ← 日期显示
├── @Builder buildBanner() ← 每日精选广告位
├── @Builder buildGenreGrid() ← 8大音乐分类 (Grid 2×4)
├── @Builder buildFeaturedAlbums() ← 推荐专辑 (Scroll 横向)
├── @Builder buildTopChart() ← 热歌榜 Top8
├── @Builder buildBottomNav() ← 底部导航 (首页/发现/歌单/我的)
└── build() ← 主布局组装
1.1 数据接口
interface Genre {
name: string; // 分类名:流行/摇滚/民谣...
icon: string; // Emoji图标
color: string; // 展示色
count: number; // 歌曲数量
}
interface Album {
id: number; // 专辑ID(路由传参用)
title: string; // 专辑名
artist: string; // 歌手
cover: string; // 封面(预留)
year: number; // 发行年份
genre: string; // 类型
songCount: number;
rating: number; // 评分(0-50整数)
}
interface Song {
id: number;
title: string;
artist: string;
albumId: number; // 关联专辑ID
duration: string; // 时长 mm:ss
plays: number; // 播放量
}
二、@State状态变量与初始化
2.1 状态声明
@Component
struct Index {
@State currentDate: string = '';
@State greeting: string = '';
@State genres: Genre[] = [];
@State featuredAlbums: Album[] = [];
@State topSongs: Song[] = [];
}
全部使用 @State 装饰,任何修改都会自动触发UI刷新。
2.2 日期与问候语
initDateAndGreeting(): void {
const now: Date = new Date();
const hour: number = now.getHours();
// 根据不同时段返回不同问候语
if (hour < 6) this.greeting = '夜深了';
else if (hour < 9) this.greeting = '早上好';
else if (hour < 12) this.greeting = '上午好';
else if (hour < 14) this.greeting = '中午好';
else if (hour < 18) this.greeting = '下午好';
else if (hour < 22) this.greeting = '晚上好';
else this.greeting = '夜深了';
// 格式化日期
const year: number = now.getFullYear();
const month: number = now.getMonth() + 1;
const day: number = now.getDate();
const weekMap: string[] = ['日', '一', '二', '三', '四', '五', '六'];
this.currentDate = `${year}年${month}月${day}日 星期${weekMap[now.getDay()]}`;
}
设计细节:21:00-06:00 显示"夜深了",暗示用户该休息了——从细节上体现产品温度。
2.3 八大分类数据
initGenres(): void {
this.genres = [
{ name: '流行', icon: '🎤', color: '#FF6B6B', count: 128 },
{ name: '摇滚', icon: '🎸', color: '#4ECDC4', count: 96 },
{ name: '民谣', icon: '🎶', color: '#45B7D1', count: 64 },
{ name: '电子', icon: '🎧', color: '#96CEB4', count: 72 },
{ name: '古典', icon: '🎻', color: '#DDA0DD', count: 48 },
{ name: 'R&B', icon: '🎵', color: '#F0E68C', count: 56 },
{ name: '嘻哈', icon: '🎙️', color: '#FFA07A', count: 80 },
{ name: '爵士', icon: '🎷', color: '#87CEEB', count: 40 }
];
}
每个分类包含:名称、Emoji图标、品牌色、歌曲数量。这8个数据涵盖了主流音乐类型,足够展示分类网格的多样性。
三、@Builder组件详解
3.1 顶部问候栏 buildTopBar()
@Builder buildTopBar() {
Row() {
Column() {
Text(this.greeting)
.fontSize(14).fontColor('#7C3AED').fontWeight(FontWeight.Medium)
Text('探索音乐的无限可能')
.fontSize(11).fontColor('#9CA3AF').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
Blank()
// 头像按钮 → 跳转个人中心
Stack() {
Column().width(40).height(40).borderRadius(20)
.backgroundColor('#EDE9FE')
Text('🎵').fontSize(20)
}
.onClick(() => { router.pushUrl({ url: 'pages/ProfilePage' }); })
}
.width('100%').padding({ left: 20, right: 20, top: 16 })
}
布局要点:
- 左侧:问候语(动态) + 副标题(固定)
- 右侧:带Emoji的圆形头像按钮
Blank()自动撑满中间空间,实现左右对齐
3.2 Banner buildBanner()
@Builder buildBanner() {
Stack() {
Column().width('100%').height(140)
.backgroundColor('#EDE9FE').borderRadius(16)
Row() {
Column() {
Text('🎶 每日精选').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#5B21B6')
Text('发现属于你的旋律').fontSize(12).fontColor('#7C3AED').margin({ top: 6 })
Text('今日更新 20+ 首新歌').fontSize(11).fontColor('#A78BFA').margin({ top: 4 })
}.margin({ left: 20 })
Blank()
Text('🎵').fontSize(56).margin({ right: 20 })
}
.width('100%')
}
.width('100%').height(140).margin({ top: 12 })
.padding({ left: 20, right: 20 })
}
Stack + Row 双布局实现:底层是紫色背景,上层是Row(左侧文案 + 右侧大号Emoji),文字与装饰元素叠放。这种设计是Banner广告位的标准实现方式。
3.3 音乐分类 Grid buildGenreGrid()
这是首页最核心的组件之一,使用 Grid 容器实现 4列×2行 的排列:
@Builder buildGenreGrid() {
Column() {
// 区域标题
Row() {
Text('音乐分类').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1F1B2E')
Blank()
Text('全部 >').fontSize(12).fontColor('#7C3AED')
}
.width('100%').padding({ left: 20, right: 20, top: 20 })
// Grid网格
Grid() {
ForEach(this.genres, (genre: Genre) => {
GridItem() {
Column() {
Text(genre.icon).fontSize(28)
Text(genre.name).fontSize(12).fontColor('#1F1B2E')
.fontWeight(FontWeight.Medium).margin({ top: 6 })
Text(`${genre.count}首`).fontSize(10).fontColor('#9CA3AF').margin({ top: 2 })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.padding({ top: 16, bottom: 12 })
.backgroundColor('#FFFFFF').borderRadius(12)
}
.onClick(() => {
router.pushUrl({
url: 'pages/SearchPage',
params: { genre: genre.name } // 跳转发现页并携带分类参数
});
})
}, (genre: Genre) => genre.name)
}
.columnsTemplate('1fr 1fr 1fr 1fr') // 4列等宽
.rowsTemplate('1fr 1fr') // 2行
.columnsGap(12) // 列间距
.rowsGap(12) // 行间距
.width('100%')
.padding({ left: 20, right: 20, top: 12 })
}
}
Grid核心属性:
| 属性 | 值 | 说明 |
|---|---|---|
columnsTemplate |
'1fr 1fr 1fr 1fr' |
4列,每列等分剩余空间 |
rowsTemplate |
'1fr 1fr' |
2行,高度相等 |
columnsGap |
12 |
列间距12vp |
rowsGap |
12 |
行间距12vp |
fr单位:类似CSS的flex-grow,1fr 表示等分一份。4个 1fr = 四等分。
Grid vs ForEach → Column/Row:
- Grid:真正支持行列对齐,适合日历、分类等规则排列
- 手动Column/Row:适合不规则布局
点击跳转带参:每个GridItem点击时,携带 genre 参数跳转到搜索页,搜索页自动选中对应分类。
3.4 推荐专辑横向滚动 buildFeaturedAlbums()
@Builder buildFeaturedAlbums() {
Column() {
Row() {
Text('推荐专辑').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1F1B2E')
Blank()
Text('更多 >').fontSize(12).fontColor('#7C3AED')
}
.width('100%').padding({ left: 20, right: 20, top: 20 })
Scroll() { // ← 外层Scroll控制水平滚动
Row() { // ← Row容纳多个专辑卡片
ForEach(this.featuredAlbums, (album: Album) => {
Column() {
// 封面占位
Stack() {
Column().width(130).height(130)
.backgroundColor('#EDE9FE').borderRadius(12)
Text('💿').fontSize(44)
}
.width(130).height(130)
Text(album.title).fontSize(13).fontWeight(FontWeight.Medium)
.fontColor('#1F1B2E').margin({ top: 8 })
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.width(130)
Text(album.artist).fontSize(11).fontColor('#6B7280')
.margin({ top: 2 }).width(130)
Row() {
Text(`★ ${(album.rating / 10).toFixed(1)}`).fontSize(11)
.fontColor(this.getRatingColor(album.rating))
Blank()
Text(`${album.songCount}首`).fontSize(11).fontColor('#9CA3AF')
}.width(130).margin({ top: 4 })
}
.margin({ right: 16 })
.onClick(() => {
router.pushUrl({
url: 'pages/AlbumPage',
params: { albumId: album.id }
});
})
}, (album: Album) => album.id.toString())
}
.padding({ left: 20, right: 20 })
}
.scrollable(ScrollDirection.Horizontal) // ← 关键:水平滚动
.height(210)
}
}
横向滚动三要素:
Scroll+scrollable(ScrollDirection.Horizontal)- 内部
Row作为容器,不限制宽度 - 每个卡片固定宽度(130vp),用
margin({ right: 16 })控制间距
评分显示:rating 存储为整数(如48代表4.8分),通过 (album.rating / 10).toFixed(1) 转为带一位小数的字符串。
3.5 热歌榜 buildTopChart()
@Builder buildTopChart() {
Column() {
Row() {
Text('🏆 热歌榜').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1F1B2E')
Blank()
Text('完整榜单 >').fontSize(12).fontColor('#7C3AED')
}
.width('100%').padding({ left: 20, right: 20, top: 20 })
Column() {
ForEach(this.topSongs, (song: Song, index?: number) => {
Row() {
// 序号(前三名红色)
Text(((index as number) + 1).toString())
.fontSize(14).fontWeight(FontWeight.Bold)
.fontColor((index as number) < 3 ? '#EF4444' : '#9CA3AF')
.width(28)
Column() {
Text(song.title).fontSize(14).fontColor('#1F1B2E')
Text(`${song.artist} · ${song.duration}`)
.fontSize(11).fontColor('#9CA3AF').margin({ top: 2 })
}
.layoutWeight(1).margin({ left: 8 })
.alignItems(HorizontalAlign.Start)
Text(this.formatPlays(song.plays)).fontSize(11).fontColor('#6B7280')
}
.width('100%').padding({ left: 20, right: 20, top: 10, bottom: 10 })
.backgroundColor('#FFFFFF').borderRadius(10)
.margin({ top: 6 })
.onClick(() => {
router.pushUrl({
url: 'pages/AlbumPage',
params: { albumId: song.albumId > 0 ? song.albumId : 1 }
});
})
}, (song: Song) => song.id.toString())
}
.padding({ left: 20, right: 20 })
}
}
排行序号设计:
- 前三名:红色粗体(
#EF4444),突出显示 - 第4-8名:灰色普通
- 通过三元表达式
(index < 3 ? '#EF4444' : '#9CA3AF')实现
播放量格式化:
formatPlays(plays: number): string {
if (plays >= 10000) return (plays / 10000).toFixed(1) + '万';
return plays.toString();
}
12580 → “1.3万”,9870 → “9870”
3.6 底部导航栏 buildBottomNav()
@Builder buildBottomNav() {
Row() {
Column() {
Text('🎵').fontSize(20)
Text('首页').fontSize(10).fontColor('#7C3AED').margin({ top: 2 })
}.layoutWeight(1)
Column() { // 发现页
Text('🔍').fontSize(20)
Text('发现').fontSize(10).fontColor('#9CA3AF').margin({ top: 2 })
}.layoutWeight(1).onClick(() => { router.pushUrl({ url: 'pages/SearchPage' }); })
Column() { // 歌单页
Text('📋').fontSize(20)
Text('歌单').fontSize(10).fontColor('#9CA3AF').margin({ top: 2 })
}.layoutWeight(1).onClick(() => { router.pushUrl({ url: 'pages/PlaylistPage' }); })
Column() { // 我的
Text('👤').fontSize(20)
Text('我的').fontSize(10).fontColor('#9CA3AF').margin({ top: 2 })
}.layoutWeight(1).onClick(() => { router.pushUrl({ url: 'pages/ProfilePage' }); })
}
.width('100%').height(60).backgroundColor('#FFFFFF')
}
四个Tab的图标:
- 🎵 首页(高亮紫色)
- 🔍 发现
- 📋 歌单
- 👤 我的
使用 layoutWeight(1) 实现四等分宽度。首页Tab因为文字是紫色,表示当前所在页面。
四、主布局组装
build(): void {
Column() {
this.buildTopBar() // 问候栏(固定顶部)
this.buildDateRow() // 日期(固定顶部)
Scroll() { // 中间内容区可滚动
Column() {
this.buildBanner()
this.buildGenreGrid()
this.buildFeaturedAlbums()
this.buildTopChart()
}
.width('100%').padding({ bottom: 20 })
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1).width('100%')
this.buildBottomNav() // 底部导航(固定底部)
}
.width('100%').height('100%').backgroundColor('#F8F7FF')
}
布局层次:
Column (100% × 100%)
├── buildTopBar() ← 顶部固定
├── buildDateRow() ← 顶部固定
├── Scroll (layoutWeight=1) ← 中间可滚动
│ └── Banner → Grid → 推荐专辑 → 热歌榜
└── buildBottomNav() ← 底部固定
五、辅助方法与工具函数
// 评分颜色映射
getRatingColor(rating: number): string {
if (rating >= 48) return '#EF4444'; // 高分:红
if (rating >= 44) return '#F59E0B'; // 中分:橙
return '#9CA3AF'; // 低分:灰
}
// 播放量格式化
formatPlays(plays: number): string {
if (plays >= 10000) return (plays / 10000).toFixed(1) + '万';
return plays.toString();
}
六、ArkTS严格模式避坑
6.1 Grid的ForEach key
Grid() {
ForEach(this.genres, (genre: Genre) => {
GridItem() { /* ... */ }
}, (genre: Genre) => genre.name) // key必须是唯一且稳定的
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsTemplate('1fr 1fr')
注意:Grid配合ForEach时,rowsTemplate 和 columnsTemplate 必须合理设置。如果数据量超过网格容量,多余数据会被忽略。
6.2 Scroll嵌套Column
Scroll内部只能有一个子组件。如果需要展示多个模块,要用一个Column包裹:
Scroll() {
Column() { // ← 唯一的直接子组件
A()
B()
C()
}
}
6.3 评分显示的浮点数
// 避免浮点数运算误差
Text(`★ ${(album.rating / 10).toFixed(1)}`)
toFixed(1) 确保始终显示一位小数,如 4.8、4.5。
七、性能优化提示
- Grid数据量控制:8个GridItem用ForEach直接渲染,性能无压力。如果扩展到100+,考虑使用LazyForEach。
- 图片占位:当前使用Emoji作为封面占位,实际项目中建议用Image组件 + 懒加载。
- Scroll内避免全量渲染:热歌榜Top8数据量小,直接渲染适合。如果是Top100,建议分页加载。

八、篇末总结
本篇完成了首页全部开发,核心内容包括:
- ✅ Grid组件实现8分类 4×2 网格布局
- ✅ Scroll + Row 实现横向专辑推荐滚动
- ✅ 排行榜序号着色(前三名红色高亮)
- ✅ @Builder组件化解耦五大模块
- ✅ 时段问候语的动态逻辑
- ✅ 多模块在一个Scroll中的垂直滚动组合
下一篇将实现发现页(搜索)与专辑详情页,深入讲解:
- 多维筛选联动(关键词 + 分类 + 年份)
- Wrap标签云布局
- 路由传参跳转动态详情
- 曲目列表收藏/取消收藏交互
文章索引:
- (一)项目初始化与Stage模型架构设计
- (二)首页开发 —— Grid分类网格与热歌排行榜 ← 当前
- (三)发现页与专辑详情 —— 多维筛选与曲目管理
- (四)歌单管理 —— 创建歌单与歌曲编排
- (五)个人中心与数据可视化 —— 统计图表与成就徽章
更多推荐


所有评论(0)