HarmonyOS开发:电商首页商品展示
HarmonyOS开发:电商首页商品展示
📌 核心要点:电商首页是整个App的门面,Banner轮播、金刚区导航、瀑布流商品列表三大模块协同工作,下拉刷新与无限加载保证数据实时性和流畅体验。
背景与动机
你打开一个电商App,第一眼看到什么?Banner轮播图、分类导航、商品列表——这就是电商首页的三大件。
看起来简单?你试试把这三样东西塞到一个页面里,还要保证滑动不卡、数据实时、加载流畅。Banner自动轮播和手动滑动打架怎么办?金刚区图标多了怎么排列?商品列表用ListView还是瀑布流?下滑加载更多怎么判断触底?上拉刷新时Banner还在自动播放怎么办?
一个电商首页,背后是数据管理、布局编排、性能优化三大难题。你用LinearLayout硬堆,代码写到一半自己都看不下去了。更别提后续加需求——运营说金刚区要能配置、商品要支持视频封面、Banner要加倒计时——你一个一个改,改到怀疑人生。
这篇文章就来说清楚:电商首页怎么架构、怎么布局、怎么优化,才能既好看又好维护。
核心原理
电商首页的架构核心是模块化+数据驱动。每个区域(Banner、金刚区、商品列表)是独立模块,各自管理数据和UI,通过一个首页容器协调。
flowchart TD
A[用户打开电商首页] --> B[HomePage容器初始化]
B --> C[并行加载各模块数据]
C --> D[Banner模块]
C --> E[金刚区模块]
C --> F[商品列表模块]
D --> D1[请求Banner数据]
D1 --> D2[Swiper轮播渲染]
D2 --> D3[自动播放+手动滑动]
E --> E1[请求分类配置]
E1 --> E2[Grid布局渲染]
E2 --> E3[点击跳转分类页]
F --> F1[请求首页推荐商品]
F1 --> F2[瀑布流布局渲染]
F2 --> F3{用户操作}
F3 -->|下拉刷新| G[重置页码 重新请求]
F3 -->|上滑触底| H[加载下一页数据]
F3 -->|点击商品| I[跳转商品详情]
G --> F1
H --> J[追加数据到列表]
J --> F2
classDef container fill:#1565C0,color:#fff,stroke:#0D47A1
classDef module fill:#2E7D32,color:#fff,stroke:#1B5E20
classDef action fill:#E65100,color:#fff,stroke:#BF360C
classDef data fill:#6A1B9A,color:#fff,stroke:#4A148C
class A,B,C,container
class D,D1,D2,D3,E,E1,E2,E3,F,F1,F2,module
class G,H,I,action
class J,data
三大模块的职责划分
| 模块 | 组件 | 数据源 | 交互 |
|---|---|---|---|
| Banner轮播 | Swiper | 运营配置接口 | 自动播放、手动滑动、点击跳转 |
| 金刚区 | Grid | 分类配置接口 | 点击跳转对应分类 |
| 商品列表 | WaterFlow | 推荐算法接口 | 下拉刷新、无限加载、点击详情 |
数据流设计
首页数据采用分模块独立请求策略,不用一个大接口返回所有数据。为什么?Banner数据更新频率低(一天换几次),商品列表更新频率高(每次刷新都变)。如果用一个接口,Banner没变也要跟着重新拉一遍,浪费流量。
每个模块独立请求,独立缓存,独立刷新。Banner可以本地缓存24小时,商品列表每次都从服务端拿最新数据。
代码实战
基础用法:Banner轮播与金刚区
先搞定首页上半部分:Banner轮播 + 金刚区导航。
// HomePage.ets — 电商首页
import { router } from '@kit.ArkUI'
// Banner数据模型
interface BannerItem {
id: string
imageUrl: string
title: string
linkUrl: string // 点击跳转地址
startTime: number // 生效开始时间
endTime: number // 生效结束时间
}
// 金刚区分类模型
interface CategoryItem {
id: string
name: string
icon: string // 图标资源
routePath: string // 跳转路由
}
@Entry
@Component
struct HomePage {
// Banner数据
@State bannerList: BannerItem[] = []
@State currentBannerIndex: number = 0
// 金刚区数据
@State categoryList: CategoryItem[] = []
// 商品列表数据
@State productList: ProductItem[] = []
@State currentPage: number = 1
@State hasMore: boolean = true
@State isLoading: boolean = false
// 下拉刷新控制器
private refreshController: RefreshController = new RefreshController()
aboutToAppear() {
// 并行加载各模块数据
this.loadBannerData()
this.loadCategoryData()
this.loadProductData(1)
}
build() {
Column() {
// 顶部搜索栏
this.SearchBar()
// 可滚动内容区
Refresh({ refreshing: $$this.isLoading, controller: this.refreshController }) {
// 用一个Column包裹所有模块,整体滚动
Scroll() {
Column() {
// Banner轮播
this.BannerSection()
// 金刚区
this.CategorySection()
// 商品列表
this.ProductSection()
}
}
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
.onRefreshing(() => {
this.onPullToRefresh()
})
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
// ========== Banner轮播 ==========
@Builder
BannerSection() {
Swiper() {
ForEach(this.bannerList, (item: BannerItem) => {
Image(item.imageUrl)
.width('100%')
.height(180)
.borderRadius(8)
.objectFit(ImageFit.Cover)
.onClick(() => {
// 点击Banner跳转
router.pushUrl({ url: item.linkUrl })
})
}, (item: BannerItem) => item.id)
}
.autoPlay(true)
.interval(3000)
.indicator(
new DotIndicator()
.itemWidth(6)
.itemHeight(6)
.selectedItemWidth(16)
.selectedItemHeight(6)
.color('#CCCCCC')
.selectedColor('#FF4444')
)
.onChange((index: number) => {
this.currentBannerIndex = index
})
.width('100%')
.height(180)
.margin({ top: 8, left: 12, right: 12 })
}
// ========== 金刚区导航 ==========
@Builder
CategorySection() {
Grid() {
ForEach(this.categoryList, (item: CategoryItem) => {
GridItem() {
Column() {
Image(item.icon)
.width(44)
.height(44)
.fillColor('#333333')
Text(item.name)
.fontSize(12)
.fontColor('#333333')
.margin({ top: 6 })
}
.justifyContent(FlexAlign.Center)
.onClick(() => {
router.pushUrl({ url: item.routePath })
})
}
}, (item: CategoryItem) => item.id)
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr') // 每行5个
.rowsGap(12)
.columnsGap(8)
.width('100%')
.padding({ left: 12, right: 12, top: 16, bottom: 16 })
.backgroundColor(Color.White)
.borderRadius(8)
.margin({ top: 8, left: 12, right: 12 })
}
// ========== 搜索栏 ==========
@Builder
SearchBar() {
Row() {
Image($r('app.media.ic_search'))
.width(20)
.height(20)
.fillColor('#999999')
Text('搜索商品')
.fontSize(14)
.fontColor('#999999')
.margin({ left: 8 })
}
.width('100%')
.height(36)
.padding({ left: 12 })
.backgroundColor('#F0F0F0')
.borderRadius(18)
.margin({ left: 12, right: 12, top: 8, bottom: 4 })
.onClick(() => {
router.pushUrl({ url: 'pages/SearchPage' })
})
}
// ========== 加载Banner数据 ==========
async loadBannerData() {
try {
// 实际项目中请求后端接口
// const response = await http.get('/api/home/banners')
// this.bannerList = response.data
// 模拟数据
this.bannerList = [
{ id: '1', imageUrl: 'https://example.com/banner1.jpg', title: '新品首发', linkUrl: 'pages/NewArrivalPage', startTime: 0, endTime: 0 },
{ id: '2', imageUrl: 'https://example.com/banner2.jpg', title: '限时特惠', linkUrl: 'pages/SalePage', startTime: 0, endTime: 0 },
{ id: '3', imageUrl: 'https://example.com/banner3.jpg', title: '品牌日', linkUrl: 'pages/BrandDayPage', startTime: 0, endTime: 0 },
]
} catch (error) {
console.error(`[HomePage] Banner加载失败: ${JSON.stringify(error)}`)
}
}
// ========== 加载金刚区数据 ==========
async loadCategoryData() {
try {
this.categoryList = [
{ id: '1', name: '数码', icon: 'app.media.ic_digital', routePath: 'pages/CategoryPage' },
{ id: '2', name: '服装', icon: 'app.media.ic_clothes', routePath: 'pages/CategoryPage' },
{ id: '3', name: '食品', icon: 'app.media.ic_food', routePath: 'pages/CategoryPage' },
{ id: '4', name: '家居', icon: 'app.media.ic_home', routePath: 'pages/CategoryPage' },
{ id: '5', name: '美妆', icon: 'app.media.ic_beauty', routePath: 'pages/CategoryPage' },
{ id: '6', name: '运动', icon: 'app.media.ic_sport', routePath: 'pages/CategoryPage' },
{ id: '7', name: '图书', icon: 'app.media.ic_book', routePath: 'pages/CategoryPage' },
{ id: '8', name: '母婴', icon: 'app.media.ic_baby', routePath: 'pages/CategoryPage' },
{ id: '9', name: '家电', icon: 'app.media.ic_appliance', routePath: 'pages/CategoryPage' },
{ id: '10', name: '更多', icon: 'app.media.ic_more', routePath: 'pages/AllCategoryPage' },
]
} catch (error) {
console.error(`[HomePage] 分类加载失败: ${JSON.stringify(error)}`)
}
}
}
进阶用法:瀑布流商品列表与无限加载
电商首页的商品列表不是规整的网格,而是瀑布流——左列和右列高度不同,错落有致。HarmonyOS提供了WaterFlow组件专门干这个事。
// ProductWaterFlow.ets — 瀑布流商品列表
import { router } from '@kit.ArkUI'
// 商品数据模型
interface ProductItem {
id: string
title: string
price: number
originalPrice: number
coverUrl: string
sales: number
tags: string[] // 标签:新品、热卖等
imageHeight: number // 封面图高度(瀑布流需要)
}
@Component
export struct ProductWaterFlow {
@State productList: ProductItem[] = []
@State currentPage: number = 1
@State hasMore: boolean = true
@State isLoadingMore: boolean = false
private pageSize: number = 20
private waterFlowController: WaterFlowController = new WaterFlowController()
// 瀑布流数据源
private dataSource: ProductDataSource = new ProductDataSource([])
aboutToAppear() {
this.loadProductData(1)
}
build() {
Column() {
// 区域标题
Row() {
Text('为你推荐')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Blank()
Text('查看更多 >')
.fontSize(13)
.fontColor('#999999')
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
// 瀑布流列表
WaterFlow() {
ForEach(this.productList, (item: ProductItem) => {
FlowItem() {
this.ProductCard(item)
}
}, (item: ProductItem) => item.id)
}
.columnsTemplate('1fr 1fr') // 两列布局
.columnsGap(8)
.rowsGap(8)
.padding({ left: 12, right: 12 })
.width('100%')
.layoutWeight(1)
.onReachEnd(() => {
// 触底加载更多
if (this.hasMore && !this.isLoadingMore) {
this.loadMoreData()
}
})
}
}
// ========== 商品卡片 ==========
@Builder
ProductCard(item: ProductItem) {
Column() {
// 商品封面
Image(item.coverUrl)
.width('100%')
.height(item.imageHeight)
.objectFit(ImageFit.Cover)
.borderRadius({ topLeft: 8, topRight: 8 })
Column() {
// 标签
if (item.tags.length > 0) {
Row() {
ForEach(item.tags.slice(0, 2), (tag: string) => {
Text(tag)
.fontSize(10)
.fontColor('#FF4444')
.backgroundColor('#FFF0F0')
.borderRadius(2)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.margin({ right: 4 })
}, (tag: string) => tag)
}
}
// 商品标题
Text(item.title)
.fontSize(13)
.fontColor('#333333')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 6 })
// 价格行
Row() {
Text(`¥${item.price}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FF4444')
if (item.originalPrice > item.price) {
Text(`¥${item.originalPrice}`)
.fontSize(11)
.fontColor('#999999')
.decoration({ type: TextDecorationType.LineThrough })
.margin({ left: 6 })
}
Blank()
Text(`${item.sales > 999 ? '999+' : item.sales}人付款`)
.fontSize(10)
.fontColor('#999999')
}
.width('100%')
.margin({ top: 8 })
}
.padding({ left: 8, right: 8, top: 6, bottom: 10 })
.alignItems(HorizontalAlign.Start)
}
.backgroundColor(Color.White)
.borderRadius(8)
.onClick(() => {
router.pushUrl({ url: 'pages/ProductDetailPage', params: { productId: item.id } })
})
}
// ========== 加载更多 ==========
async loadMoreData() {
if (this.isLoadingMore || !this.hasMore) return
this.isLoadingMore = true
this.currentPage++
try {
const newData = await this.fetchProducts(this.currentPage, this.pageSize)
if (newData.length < this.pageSize) {
this.hasMore = false // 没有更多数据了
}
// 追加数据到列表
this.productList = [...this.productList, ...newData]
} catch (error) {
console.error(`[ProductList] 加载更多失败: ${JSON.stringify(error)}`)
this.currentPage-- // 回退页码
} finally {
this.isLoadingMore = false
}
}
// ========== 请求商品数据 ==========
async fetchProducts(page: number, size: number): Promise<ProductItem[]> {
// 实际项目中请求后端接口
// const response = await http.get(`/api/home/products?page=${page}&size=${size}`)
// return response.data
// 模拟数据
return new Promise((resolve) => {
setTimeout(() => {
const mockData: ProductItem[] = []
for (let i = 0; i < size; i++) {
const index = (page - 1) * size + i
mockData.push({
id: `product_${index}`,
title: `精选商品标题展示两行文字超出截断 ${index + 1}`,
price: Math.round(Math.random() * 500 + 10),
originalPrice: Math.round(Math.random() * 800 + 100),
coverUrl: `https://picsum.photos/300/${200 + Math.round(Math.random() * 100)}`,
sales: Math.round(Math.random() * 2000),
tags: index % 3 === 0 ? ['新品'] : index % 3 === 1 ? ['热卖'] : [],
imageHeight: 150 + Math.round(Math.random() * 80) // 随机高度实现瀑布流效果
})
}
resolve(mockData)
}, 500)
})
}
// ========== 下拉刷新回调 ==========
async loadProductData(page: number) {
try {
const data = await this.fetchProducts(page, this.pageSize)
this.productList = data
this.currentPage = page
this.hasMore = data.length >= this.pageSize
} catch (error) {
console.error(`[ProductList] 加载失败: ${JSON.stringify(error)}`)
}
}
}
// 瀑布流数据源(用于大数据量场景)
class ProductDataSource implements IDataSource {
private dataList: ProductItem[] = []
private listeners: DataChangeListener[] = []
constructor(data: ProductItem[]) {
this.dataList = data
}
totalCount(): number {
return this.dataList.length
}
getData(index: number): ProductItem {
return this.dataList[index]
}
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener)
}
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const pos = this.listeners.indexOf(listener)
if (pos >= 0) {
this.listeners.splice(pos, 1)
}
}
// 追加数据
appendData(data: ProductItem[]): void {
this.dataList = this.dataList.concat(data)
this.listeners.forEach(listener => listener.onDataReloaded())
}
// 重置数据
resetData(data: ProductItem[]): void {
this.dataList = data
this.listeners.forEach(listener => listener.onDataReloaded())
}
}
完整示例:电商首页全模块整合
把Banner、金刚区、瀑布流商品列表、下拉刷新、无限加载串成完整链路。
// ECommerceHomePage.ets — 电商首页完整实现
import { router } from '@kit.ArkUI'
import { http } from '@kit.NetworkKit'
// ========== 数据模型 ==========
interface BannerItem {
id: string
imageUrl: string
title: string
linkUrl: string
}
interface CategoryItem {
id: string
name: string
icon: Resource
routePath: string
}
interface ProductItem {
id: string
title: string
price: number
originalPrice: number
coverUrl: string
sales: number
tags: string[]
imageHeight: number
}
// ========== 首页状态管理 ==========
class HomeViewModel {
// Banner
bannerList: BannerItem[] = []
bannerLoaded: boolean = false
// 金刚区
categoryList: CategoryItem[] = []
categoryLoaded: boolean = false
// 商品
productList: ProductItem[] = []
currentPage: number = 1
hasMore: boolean = true
isLoading: boolean = false
isLoadingMore: boolean = false
private pageSize: number = 20
// 初始化所有数据
async initAllData(): Promise<void> {
// 并行请求,谁先回来谁先渲染
await Promise.allSettled([
this.loadBanners(),
this.loadCategories(),
this.loadProducts(1)
])
}
async loadBanners(): Promise<void> {
try {
// 实际项目:const resp = await http.get('/api/home/banners')
this.bannerList = this.getMockBanners()
this.bannerLoaded = true
} catch (e) {
console.error('[HomeVM] Banner加载失败')
}
}
async loadCategories(): Promise<void> {
try {
this.categoryList = this.getMockCategories()
this.categoryLoaded = true
} catch (e) {
console.error('[HomeVM] 分类加载失败')
}
}
async loadProducts(page: number): Promise<void> {
if (this.isLoading) return
this.isLoading = true
try {
const data = await this.fetchProducts(page, this.pageSize)
if (page === 1) {
this.productList = data
} else {
this.productList = [...this.productList, ...data]
}
this.currentPage = page
this.hasMore = data.length >= this.pageSize
} catch (e) {
console.error('[HomeVM] 商品加载失败')
} finally {
this.isLoading = false
this.isLoadingMore = false
}
}
// 下拉刷新
async refresh(): Promise<void> {
this.hasMore = true
this.isLoadingMore = false
await Promise.allSettled([
this.loadBanners(),
this.loadCategories(),
this.loadProducts(1)
])
}
// 加载更多
async loadMore(): Promise<void> {
if (!this.hasMore || this.isLoadingMore) return
this.isLoadingMore = true
await this.loadProducts(this.currentPage + 1)
}
// 模拟Banner数据
private getMockBanners(): BannerItem[] {
return [
{ id: '1', imageUrl: 'https://picsum.photos/750/360?random=1', title: '新品首发', linkUrl: '' },
{ id: '2', imageUrl: 'https://picsum.photos/750/360?random=2', title: '限时特惠', linkUrl: '' },
{ id: '3', imageUrl: 'https://picsum.photos/750/360?random=3', title: '品牌日', linkUrl: '' },
]
}
// 模拟分类数据
private getMockCategories(): CategoryItem[] {
return [
{ id: '1', name: '数码', icon: $r('app.media.ic_digital'), routePath: '' },
{ id: '2', name: '服装', icon: $r('app.media.ic_clothes'), routePath: '' },
{ id: '3', name: '食品', icon: $r('app.media.ic_food'), routePath: '' },
{ id: '4', name: '家居', icon: $r('app.media.ic_home'), routePath: '' },
{ id: '5', name: '美妆', icon: $r('app.media.ic_beauty'), routePath: '' },
{ id: '6', name: '运动', icon: $r('app.media.ic_sport'), routePath: '' },
{ id: '7', name: '图书', icon: $r('app.media.ic_book'), routePath: '' },
{ id: '8', name: '母婴', icon: $r('app.media.ic_baby'), routePath: '' },
{ id: '9', name: '家电', icon: $r('app.media.ic_appliance'), routePath: '' },
{ id: '10', name: '更多', icon: $r('app.media.ic_more'), routePath: '' },
]
}
// 模拟商品数据
private async fetchProducts(page: number, size: number): Promise<ProductItem[]> {
return new Promise((resolve) => {
setTimeout(() => {
const list: ProductItem[] = []
for (let i = 0; i < size; i++) {
const idx = (page - 1) * size + i
list.push({
id: `p_${idx}`,
title: `精选好物推荐商品标题展示 ${idx + 1}`,
price: Math.round(Math.random() * 500 + 10),
originalPrice: Math.round(Math.random() * 800 + 100),
coverUrl: `https://picsum.photos/300/${200 + Math.round(Math.random() * 100)}?random=${idx}`,
sales: Math.round(Math.random() * 2000),
tags: idx % 3 === 0 ? ['新品'] : idx % 5 === 0 ? ['限时'] : [],
imageHeight: 150 + Math.round(Math.random() * 80)
})
}
resolve(list)
}, 300)
})
}
}
// ========== 电商首页 ==========
@Entry
@Component
struct ECommerceHomePage {
@State viewModel: HomeViewModel = new HomeViewModel()
private scroller: Scroller = new Scroller()
aboutToAppear() {
this.viewModel.initAllData()
}
build() {
Column() {
// 顶部搜索栏
this.TopBar()
// 主体内容:下拉刷新 + 滚动列表
Refresh({ refreshing: $$this.viewModel.isLoading }) {
Scroll(this.scroller) {
Column() {
// Banner轮播
this.BannerSection()
// 金刚区
this.CategorySection()
// 活动入口(可选)
this.ActivitySection()
// 瀑布流商品列表
this.ProductSection()
// 底部加载状态
this.LoadMoreFooter()
}
}
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
.onRefreshing(() => {
this.viewModel.refresh()
})
.refreshStyle(RefreshStyle.Translate) // 下拉翻译风格
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
// ========== 顶部栏 ==========
@Builder
TopBar() {
Row() {
Image($r('app.media.ic_scan'))
.width(24)
.height(24)
.fillColor('#333333')
.margin({ right: 12 })
Row() {
Image($r('app.media.ic_search'))
.width(16)
.height(16)
.fillColor('#999999')
Text('搜索商品、品牌')
.fontSize(14)
.fontColor('#999999')
.margin({ left: 6 })
}
.layoutWeight(1)
.height(36)
.padding({ left: 12 })
.backgroundColor('#F0F0F0')
.borderRadius(18)
.onClick(() => {
router.pushUrl({ url: 'pages/SearchPage' })
})
Image($r('app.media.ic_message'))
.width(24)
.height(24)
.fillColor('#333333')
.margin({ left: 12 })
}
.width('100%')
.height(52)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
.alignItems(VerticalAlign.Center)
}
// ========== Banner轮播 ==========
@Builder
BannerSection() {
if (this.viewModel.bannerList.length > 0) {
Swiper() {
ForEach(this.viewModel.bannerList, (item: BannerItem) => {
Image(item.imageUrl)
.width('100%')
.height(180)
.borderRadius(8)
.objectFit(ImageFit.Cover)
.onClick(() => {
if (item.linkUrl) {
router.pushUrl({ url: item.linkUrl })
}
})
}, (item: BannerItem) => item.id)
}
.autoPlay(true)
.interval(4000)
.loop(true)
.indicator(
new DotIndicator()
.itemWidth(6)
.itemHeight(6)
.selectedItemWidth(16)
.selectedItemHeight(6)
.color('#80FFFFFF')
.selectedColor('#FFFFFF')
)
.width('100%')
.height(180)
.margin({ top: 8, left: 12, right: 12 })
}
}
// ========== 金刚区 ==========
@Builder
CategorySection() {
if (this.viewModel.categoryList.length > 0) {
Column() {
Grid() {
ForEach(this.viewModel.categoryList, (item: CategoryItem) => {
GridItem() {
Column() {
Image(item.icon)
.width(44)
.height(44)
.fillColor('#333333')
Text(item.name)
.fontSize(12)
.fontColor('#333333')
.margin({ top: 6 })
.textAlign(TextAlign.Center)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
router.pushUrl({ url: item.routePath || 'pages/CategoryPage' })
})
}
}, (item: CategoryItem) => item.id)
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr')
.rowsGap(16)
.columnsGap(0)
.width('100%')
.height(160) // 两行高度
}
.width('100%')
.padding({ top: 16, bottom: 12, left: 12, right: 12 })
.backgroundColor(Color.White)
.borderRadius(8)
.margin({ top: 8, left: 12, right: 12 })
}
}
// ========== 活动入口 ==========
@Builder
ActivitySection() {
Row() {
Column() {
Text('限时秒杀')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FF4444')
Text('每日10点开抢')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 12 })
Column() {
Text('品牌特卖')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FF6600')
Text('低至1折起')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 12 })
}
.width('100%')
.height(64)
.backgroundColor(Color.White)
.borderRadius(8)
.margin({ top: 8, left: 12, right: 12 })
.padding({ top: 10, bottom: 10 })
}
// ========== 瀑布流商品列表 ==========
@Builder
ProductSection() {
Column() {
// 标题栏
Row() {
Text('为你推荐')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Blank()
Text('换一批')
.fontSize(13)
.fontColor('#FF4444')
.onClick(() => {
this.viewModel.loadProducts(1)
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
// 瀑布流
WaterFlow() {
ForEach(this.viewModel.productList, (item: ProductItem) => {
FlowItem() {
this.ProductCard(item)
}
}, (item: ProductItem) => item.id)
}
.columnsTemplate('1fr 1fr')
.columnsGap(8)
.rowsGap(8)
.padding({ left: 12, right: 12 })
.width('100%')
.onReachEnd(() => {
this.viewModel.loadMore()
})
}
.margin({ top: 8 })
}
// ========== 商品卡片 ==========
@Builder
ProductCard(item: ProductItem) {
Column() {
Image(item.coverUrl)
.width('100%')
.height(item.imageHeight)
.objectFit(ImageFit.Cover)
.borderRadius({ topLeft: 8, topRight: 8 })
Column() {
// 标签
if (item.tags.length > 0) {
Row() {
ForEach(item.tags, (tag: string) => {
Text(tag)
.fontSize(10)
.fontColor('#FF4444')
.backgroundColor('#FFF0F0')
.borderRadius(2)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.margin({ right: 4 })
}, (tag: string) => tag)
}
}
Text(item.title)
.fontSize(13)
.fontColor('#333333')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
Row() {
Text(`¥${item.price}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FF4444')
if (item.originalPrice > item.price) {
Text(`¥${item.originalPrice}`)
.fontSize(11)
.fontColor('#BBBBBB')
.decoration({ type: TextDecorationType.LineThrough })
.margin({ left: 4 })
}
Blank()
Text(`${item.sales > 999 ? '999+' : item.sales}人付款`)
.fontSize(10)
.fontColor('#999999')
}
.width('100%')
.margin({ top: 6 })
}
.padding({ left: 8, right: 8, top: 4, bottom: 8 })
.alignItems(HorizontalAlign.Start)
}
.backgroundColor(Color.White)
.borderRadius(8)
.onClick(() => {
router.pushUrl({
url: 'pages/ProductDetailPage',
params: { productId: item.id }
})
})
}
// ========== 底部加载状态 ==========
@Builder
LoadMoreFooter() {
Row() {
if (this.viewModel.isLoadingMore) {
LoadingProgress()
.width(24)
.height(24)
.color('#999999')
Text('正在加载...')
.fontSize(13)
.fontColor('#999999')
.margin({ left: 8 })
} else if (!this.viewModel.hasMore) {
Text('— 已经到底了 —')
.fontSize(13)
.fontColor('#CCCCCC')
}
}
.width('100%')
.height(48)
.justifyContent(FlexAlign.Center)
.margin({ top: 8, bottom: 16 })
}
}
踩坑与注意事项
坑1:Swiper自动播放和手动滑动冲突
Swiper设置了autoPlay(true)后,用户手动滑动时自动播放不会暂停。如果用户正在看某张Banner,3秒后自动跳到下一张,体验很差。
解决方案:监听Swiper的触摸事件,用户触摸时暂停自动播放,松手后恢复。或者干脆把自动播放间隔设长一点(4-5秒),别太激进。
Swiper()
.autoPlay(true)
.interval(5000) // 5秒间隔,别太短
.loop(true)
.disableSwipe(false) // 允许手动滑动
坑2:瀑布流图片高度不一致导致跳动
WaterFlow的FlowItem高度不固定时,如果图片还没加载完,高度是0或默认值,图片加载完后高度变化,列表就会跳动。
解决方案:商品数据必须携带图片高度信息(imageHeight),服务端返回商品列表时把图片宽高一起返回。客户端用固定高度渲染,不依赖图片实际加载后的尺寸。
坑3:下拉刷新时数据还没回来就关闭刷新动画
Refresh组件的refreshing状态要和数据加载状态绑定。如果加载是异步的,onRefreshing回调里直接设refreshing = false,动画瞬间消失,用户以为没刷新。
解决方案:等数据加载完成再设refreshing = false。
Refresh({ refreshing: $$this.viewModel.isLoading })
.onRefreshing(async () => {
await this.viewModel.refresh() // 等数据回来
// refreshing会自动变成false
})
坑4:无限加载重复请求
用户滑到底部触发onReachEnd,但数据还没回来时又触了一次,导致同一页数据请求了两遍。
解决方案:加个isLoadingMore锁,请求中不再触发。
.onReachEnd(() => {
if (this.viewModel.hasMore && !this.viewModel.isLoadingMore) {
this.viewModel.loadMore()
}
})
坑5:金刚区图标数量不是5的倍数
Grid设置了5列,如果分类数量是8个,第二行只有3个图标,右边空着很难看。
解决方案:金刚区分两行,每行5个,总共10个。不够10个就补"更多"入口,超过10个就第二行最后一个显示"更多"。
坑6:首页数据缓存策略
每次进入首页都重新请求所有数据?用户来回切换页面,首页数据反复加载,既浪费流量又影响体验。
解决方案:Banner和金刚区数据缓存本地,设置过期时间(如2小时)。商品列表每次都请求最新数据,但保留上次的数据先展示,等新数据回来再替换。
HarmonyOS 6适配说明
HarmonyOS 6对首页相关组件做了以下更新:
- WaterFlow性能优化:WaterFlow组件新增了
cachedCount参数,支持预渲染屏幕外的FlowItem。滑到列表底部时,下一页的卡片已经渲染好了,用户感知不到加载延迟。
WaterFlow()
.cachedCount(6) // 预渲染6个屏幕外的Item
.columnsTemplate('1fr 1fr')
-
Swiper嵌套滚动:之前Swiper嵌套在Scroll里时,手势冲突严重——左右滑动Banner和上下滚动页面打架。HarmonyOS 6优化了嵌套滚动的手势优先级,Swiper的左右滑动和Scroll的上下滚动不再冲突。
-
Refresh组件自定义:Refresh组件支持完全自定义刷新动画,不再局限于系统默认的圆形进度条。你可以放Lottie动画、品牌Logo旋转、任何你想要的刷新效果。
-
LazyForEach数据懒加载:For large product lists,
LazyForEachreplacesForEachfor on-demand rendering. Only items in the viewport are created, significantly reducing memory usage for lists with thousands of items. -
图片解码优化:Image组件新增
autoResize属性,自动根据Image组件的显示尺寸解码图片,不再加载原始大图再缩放。一张4000x3000的商品图显示为150x150的缩略图,内存占用从48MB降到90KB。
总结
电商首页看起来就是一个页面,但里面藏着模块化架构、瀑布流布局、数据流管理、性能优化四大难题。
核心记住三点:
- 模块化是基础,Banner、金刚区、商品列表各自独立,数据独立请求、独立缓存、独立刷新
- 瀑布流必须有图片高度,服务端返回宽高信息,客户端用固定高度渲染,避免列表跳动
- 无限加载要加锁,
isLoadingMore标志位防止重复请求,这是最容易忽略的bug
| 评估维度 | 说明 |
|---|---|
| 学习难度 | ⭐⭐⭐⭐ 模块化架构和瀑布流需要经验,下拉刷新和无限加载细节多 |
| 使用频率 | ⭐⭐⭐⭐⭐ 电商首页是所有电商App的入口,必须掌握 |
| 重要程度 | ⭐⭐⭐⭐⭐ 首页体验直接决定用户留存,做不好等于白搭 |
电商首页不是"画个页面"那么简单。Banner轮播卡顿、商品列表跳动、加载重复请求——每个细节都能让用户直接卸载你的App。
- 点赞
- 收藏
- 关注作者
评论(0)