HarmonyOS开发:电商首页商品展示

举报
Jack20 发表于 2026/06/26 16:55:17 2026/06/26
【摘要】 HarmonyOS开发:电商首页商品展示📌 核心要点:电商首页是整个App的门面,Banner轮播、金刚区导航、瀑布流商品列表三大模块协同工作,下拉刷新与无限加载保证数据实时性和流畅体验。 背景与动机你打开一个电商App,第一眼看到什么?Banner轮播图、分类导航、商品列表——这就是电商首页的三大件。看起来简单?你试试把这三样东西塞到一个页面里,还要保证滑动不卡、数据实时、加载流畅。B...

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对首页相关组件做了以下更新:

  1. WaterFlow性能优化:WaterFlow组件新增了cachedCount参数,支持预渲染屏幕外的FlowItem。滑到列表底部时,下一页的卡片已经渲染好了,用户感知不到加载延迟。
WaterFlow()
  .cachedCount(6)  // 预渲染6个屏幕外的Item
  .columnsTemplate('1fr 1fr')
  1. Swiper嵌套滚动:之前Swiper嵌套在Scroll里时,手势冲突严重——左右滑动Banner和上下滚动页面打架。HarmonyOS 6优化了嵌套滚动的手势优先级,Swiper的左右滑动和Scroll的上下滚动不再冲突。

  2. Refresh组件自定义:Refresh组件支持完全自定义刷新动画,不再局限于系统默认的圆形进度条。你可以放Lottie动画、品牌Logo旋转、任何你想要的刷新效果。

  3. LazyForEach数据懒加载:For large product lists, LazyForEach replaces ForEach for on-demand rendering. Only items in the viewport are created, significantly reducing memory usage for lists with thousands of items.

  4. 图片解码优化:Image组件新增autoResize属性,自动根据Image组件的显示尺寸解码图片,不再加载原始大图再缩放。一张4000x3000的商品图显示为150x150的缩略图,内存占用从48MB降到90KB。

总结

电商首页看起来就是一个页面,但里面藏着模块化架构、瀑布流布局、数据流管理、性能优化四大难题。

核心记住三点:

  • 模块化是基础,Banner、金刚区、商品列表各自独立,数据独立请求、独立缓存、独立刷新
  • 瀑布流必须有图片高度,服务端返回宽高信息,客户端用固定高度渲染,避免列表跳动
  • 无限加载要加锁isLoadingMore标志位防止重复请求,这是最容易忽略的bug
评估维度 说明
学习难度 ⭐⭐⭐⭐ 模块化架构和瀑布流需要经验,下拉刷新和无限加载细节多
使用频率 ⭐⭐⭐⭐⭐ 电商首页是所有电商App的入口,必须掌握
重要程度 ⭐⭐⭐⭐⭐ 首页体验直接决定用户留存,做不好等于白搭

电商首页不是"画个页面"那么简单。Banner轮播卡顿、商品列表跳动、加载重复请求——每个细节都能让用户直接卸载你的App。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。