ArkUI电商首页完整实战

系列文章 :鸿蒙NEXT开发实战系列 -- 第14篇 适合人群 :有ArkUI基础的开发者 开发环境 :DevEco Studio 5.0.5+ | HarmonyOS NEXT (API 14) 阅读时长:约30分钟


一、引言:为什么用电商首页练手

电商首页是前端/移动端开发中最经典的综合实战场景。一个看似简单的电商首页,实际上涵盖了绝大部分常见的 UI 交互模式:

  • 轮播图:自动播放、手势滑动、指示器联动

  • 分类导航:横向滚动、图标+文字组合布局

  • 商品列表:瀑布流/网格布局、图片懒加载、价格标签

  • 底部导航栏:多 Tab 切换、图标选中态、页面路由

  • 下拉刷新与上拉加载:列表性能优化、分页数据加载

如果你能独立实现一个完整的电商首页,说明你已经掌握了 ArkUI 开发中 80% 的核心能力。本文将带你从零搭建一个功能完整、代码可复用的鸿蒙电商首页,每个组件都配有详细解析,确保你能真正理解原理并应用到自己的项目中。


二、最终效果预览

完成本实战后,你将得到如下效果的页面:

复制代码
+------------------------------------------+
|  [搜索栏]                      [消息图标] |
+------------------------------------------+
|  +--------------------------------------+|
|  |        Swiper 轮播广告区域            ||
|  |     (自动轮播 + 底部圆点指示器)        ||
|  +--------------------------------------+|
+------------------------------------------+
| [分类1] [分类2] [分类3] [分类4] [分类5] > |
| [图标]  [图标]  [图标]  [图标]  [图标]   |
+------------------------------------------+
|  热销推荐                                |
| +-------------+ +-------------+          |
| |  商品图片    | |  商品图片    |          |
| |  商品标题    | |  商品标题    |          |
| |  ¥99.00    | |  ¥199.00   |          |
| +-------------+ +-------------+          |
| +-------------+ +-------------+          |
| |  ...        | |  ...        |          |
| +-------------+ +-------------+          |
+------------------------------------------+
| [首页]  [分类]  [购物车]  [我的]         |
+------------------------------------------+

整体采用经典的电商布局:顶部搜索栏 + 轮播图 + 分类导航 + 双列商品网格 + 底部 TabBar,并支持下拉刷新和上拉加载更多。


三、项目架构设计

3.1 页面结构拆分

我们将页面拆分为以下独立组件,每个组件职责单一、可复用:

复制代码
pages/
  └── Index.ets                 // 主页面,负责组装各组件

components/
  ├── SearchBar.ets             // 顶部搜索栏
  ├── BannerSwiper.ets          // 轮播图组件
  ├── CategoryNav.ets           // 分类导航组件
  ├── ProductGrid.ets           // 商品瀑布流网格
  ├── ProductCard.ets           // 单个商品卡片
  └── BottomTabBar.ets          // 底部自定义TabBar

3.2 数据模型定义

在开始编写组件之前,先定义好核心数据模型:

复制代码
// models/Product.ets

/** 商品数据模型 */
export interface Product {
  id: number;
  title: string;       // 商品标题
  price: number;       // 价格
  originalPrice: number; // 原价
  image: string;       // 商品图片地址
  sales: number;       // 销量
}

/** 轮播图数据模型 */
export interface BannerItem {
  id: number;
  image: string;
  title: string;
}

/** 分类导航数据模型 */
export interface CategoryItem {
  id: number;
  name: string;
  icon: string;        // 图标资源路径或symbol名称
}

3.3 Mock 数据准备

为了让项目可以独立运行,我们准备一组 Mock 数据:

复制代码
// data/MockData.ets

import { BannerItem, CategoryItem, Product } from '../models/Product';

/** 轮播图数据 */
export const bannerList: BannerItem[] = [
  { id: 1, image: $r('app.media.banner1'), title: '618年中大促' },
  { id: 2, image: $r('app.media.banner2'), title: '新品首发' },
  { id: 3, image: $r('app.media.banner3'), title: '品牌特卖' },
];

/** 分类导航数据 */
export const categoryList: CategoryItem[] = [
  { id: 1, name: '手机', icon: 'phone' },
  { id: 2, name: '电脑', icon: 'monitor' },
  { id: 3, name: '服饰', icon: 'shirt' },
  { id: 4, name: '美妆', icon: 'palette' },
  { id: 5, name: '家居', icon: 'home' },
  { id: 6, name: '食品', icon: 'coffee' },
  { id: 7, name: '运动', icon: 'run' },
  { id: 8, name: '图书', icon: 'book' },
];

/** 生成商品Mock数据 */
export function generateProducts(page: number, pageSize: number): Product[] {
  const products: Product[] = [];
  const startId = (page - 1) * pageSize + 1;
  for (let i = 0; i < pageSize; i++) {
    const id = startId + i;
    products.push({
      id,
      title: `鸿蒙精选好物第${id}款 超值特惠不容错过`,
      price: Math.round(Math.random() * 500 + 50),
      originalPrice: Math.round(Math.random() * 800 + 200),
      image: $r('app.media.product_sample'),
      sales: Math.round(Math.random() * 10000),
    });
  }
  return products;
}

四、实现1:Swiper 轮播图组件

4.1 UI 效果描述

轮播图占据页面顶部核心区域,支持自动循环播放(3秒间隔),底部有圆点指示器跟随当前轮播页切换,用户也可以手动左右滑动。

4.2 完整代码

复制代码
// components/BannerSwiper.ets

@Component
export struct BannerSwiper {
  @Link bannerList: BannerItem[];
  @State currentIndex: number = 0;

  build() {
    Column() {
      Swiper() {
        ForEach(this.bannerList, (item: BannerItem) => {
          Image(item.image)
            .width('100%')
            .height(180)
            .borderRadius(12)
            .objectFit(ImageFit.Cover)
        })
      }
      .autoPlay(true)           // 自动播放
      .interval(3000)           // 播放间隔3秒
      .loop(true)               // 循环播放
      .indicator(false)         // 隐藏默认指示器,使用自定义指示器
      .duration(500)            // 切换动画时长
      .curve(Curve.EaseInOut)   // 切换动画曲线
      .width('100%')
      .height(180)
      .margin({ top: 12 })
      .padding({ left: 16, right: 16 })
      .onChange((index: number) => {
        this.currentIndex = index;
      })

      // 自定义圆点指示器
      Row() {
        ForEach(this.bannerList, (_: BannerItem, index: number) => {
          Circle()
            .width(this.currentIndex === index ? 16 : 8)
            .height(8)
            .fill(this.currentIndex === index ? '#FF6B35' : '#CCCCCC')
            .borderRadius(4)
            .animation({
              duration: 300,
              curve: Curve.EaseInOut,
            })
            .margin({ left: 3, right: 3 })
        })
      }
      .justifyContent(FlexAlign.Center)
      .width('100%')
      .margin({ top: 8 })
    }
  }
}

4.3 关键代码解析

属性/方法 作用
autoPlay(true) 开启自动播放,无需手动触发定时器
interval(3000) 设置自动播放间隔为 3000 毫秒
indicator(false) 隐藏 Swiper 内置指示器,改用自定义圆点
onChange 回调当前页索引,用于同步指示器状态
Circle + 动画 自定义指示器,选中态宽度展开、颜色变化

要点 :自定义指示器比内置指示器灵活得多,可以自由控制样式、颜色和动画效果。通过 animation 属性让宽度变化带有过渡动效,提升视觉体验。


五、实现2:商品分类导航

5.1 UI 效果描述

横向可滚动的图标列表,每行显示 4-5 个分类,支持超出屏幕后左右滑动。每个分类由圆形图标和文字标签组成,点击后有按压反馈效果。

5.2 完整代码

复制代码
// components/CategoryNav.ets

@Component
export struct CategoryNav {
  @Link categoryList: CategoryItem[];

  build() {
    Column() {
      Text('全部分类')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .padding({ left: 16, top: 12, bottom: 8 })

      Scroll() {
        Row() {
          ForEach(this.categoryList, (item: CategoryItem) => {
            Column() {
              // 图标容器
              Stack() {
                Circle()
                  .width(48)
                  .height(48)
                  .fill('#FFF0EB')
                Text(this.getIconSymbol(item.icon))
                  .fontSize(24)
                  .fontColor('#FF6B35')
              }

              Text(item.name)
                .fontSize(12)
                .fontColor('#333333')
                .margin({ top: 6 })
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .width(72)
            .alignItems(HorizontalAlign.Center)
            .padding({ top: 8, bottom: 8 })
            .borderRadius(12)
            .onClick(() => {
              console.info(`点击分类: ${item.name}, id: ${item.id}`);
            })
          })
        }
        .padding({ left: 16, right: 16 })
      }
      .scrollable(ScrollDirection.Horizontal)  // 横向滚动
      .scrollBar(BarState.Off)                 // 隐藏滚动条
      .edgeEffect(EdgeEffect.Spring)           // 弹性边缘效果
    }
    .width('100%')
    .backgroundColor(Color.White)
    .borderRadius(12)
    .margin({ left: 16, right: 16, top: 12 })
    .padding({ bottom: 12 })
  }

  /** 根据icon名称返回对应的symbol或文字占位 */
  private getIconSymbol(iconName: string): string {
    // 实际项目中建议使用 SymbolGlyph 或 Image 组件
    const iconMap: Record<string, string> = {
      'phone': '\uf10b',
      'monitor': '\uf108',
      'shirt': '\uf553',
      'palette': '\uf53f',
      'home': '\uf015',
      'coffee': '\uf0f4',
      'run': '\uf70c',
      'book': '\uf02d',
    };
    return iconMap[iconName] ?? '\uf05a';
  }
}

5.3 关键代码解析

  • Scroll + Row 组合Scroll 组件的 scrollable(ScrollDirection.Horizontal) 让内容横向滚动,内部用 Row 水平排列子项。这是 ArkUI 中实现横向滚动列表的标准模式。

  • scrollBar(BarState.Off):隐藏滚动条,保持界面整洁。

  • edgeEffect(EdgeEffect.Spring):滚动到边缘时有弹性回弹效果,符合移动端操作习惯。

  • 按压交互 :在实际项目中,建议在外层 Column 上添加 .stateStyles 实现按压态颜色变化,增强触感反馈。


六、实现3:Grid 商品瀑布流

6.1 UI 效果描述

商品区域采用双列网格布局(类似淘宝/京东),每个商品卡片包含:商品图片(带圆角)、标题(最多两行,超出省略)、销量标签、原价(划线价)和现价。整体支持滚动和懒加载。

6.2 完整代码

复制代码
// components/ProductCard.ets

@Component
export struct ProductCard {
  product: Product = {} as Product;

  build() {
    Column() {
      // 商品图片
      Image(this.product.image)
        .width('100%')
        .aspectRatio(1)            // 1:1 正方形
        .objectFit(ImageFit.Cover)
        .borderRadius({ topLeft: 12, topRight: 12 })

      // 信息区域
      Column() {
        // 商品标题
        Text(this.product.title)
          .fontSize(14)
          .fontColor('#333333')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .lineHeight(20)

        // 销量
        Text(`已售 ${this.formatSales(this.product.sales)}`)
          .fontSize(11)
          .fontColor('#999999')
          .margin({ top: 4 })

        // 价格区域
        Row() {
          Text('¥')
            .fontSize(12)
            .fontColor('#FF4D4F')
            .fontWeight(FontWeight.Bold)
          Text(this.product.price.toFixed(2))
            .fontSize(18)
            .fontColor('#FF4D4F')
            .fontWeight(FontWeight.Bold)

          Text(`¥${this.product.originalPrice.toFixed(2)}`)
            .fontSize(11)
            .fontColor('#BBBBBB')
            .decoration({ type: TextDecorationType.LineThrough })
            .margin({ left: 6 })
        }
        .alignItems(VerticalAlign.Bottom)
        .margin({ top: 8 })
      }
      .padding({ left: 8, right: 8, top: 6, bottom: 10 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .backgroundColor(Color.White)
    .borderRadius(12)
    .shadow({
      radius: 8,
      color: 'rgba(0,0,0,0.06)',
      offsetX: 0,
      offsetY: 2,
    })
  }

  /** 格式化销量数字 */
  private formatSales(sales: number): string {
    if (sales >= 10000) {
      return (sales / 10000).toFixed(1) + '万';
    }
    return sales.toString();
  }
}

// components/ProductGrid.ets

@Component
export struct ProductGrid {
  @Link productList: Product[];

  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: 16, bottom: 8 })

      // 双列网格
      Grid() {
        ForEach(this.productList, (product: Product) => {
          GridItem() {
            ProductCard({ product: product })
          }
        })
      }
      .columnsTemplate('1fr 1fr')      // 两列等宽
      .columnsGap(8)                    // 列间距
      .rowsGap(8)                       // 行间距
      .width('100%')
      .padding({ left: 8, right: 8, bottom: 8 })
      .layoutWeight(1)                  // 占据剩余空间
    }
    .width('100%')
  }
}

6.3 关键代码解析

  • columnsTemplate('1fr 1fr') :这是 Grid 实现双列布局的关键,1fr 1fr 表示两列等分可用空间。如果要三列则写 '1fr 1fr 1fr'

  • columnsGap / rowsGap:控制网格的列间距和行间距,让卡片之间留有呼吸空间。

  • ProductCard 独立组件:将单个商品卡片抽为独立组件,方便在其他页面(搜索结果、收藏列表等)复用。

  • aspectRatio(1):让商品图片保持 1:1 的正方形比例,这是电商图片的标准比例。

  • shadow :通过 shadow 属性给卡片添加微弱的阴影,营造"浮起来"的视觉层次感。

  • 销量格式化:超过 1 万的销量显示为 "1.2万",更符合中文阅读习惯。


七、实现4:自定义底部 TabBar

7.1 UI 效果描述

底部包含 4 个 Tab:首页、分类、购物车、我的。选中态图标变色、文字加粗变色,未选中态为灰色。支持点击切换,同时配合页面路由实现真正的页面切换。

7.2 完整代码

复制代码
// components/BottomTabBar.ets

export interface TabItem {
  title: string;
  icon: Resource;        // 未选中图标
  selectedIcon: Resource; // 选中图标
  index: number;
}

@Component
export struct BottomTabBar {
  @Link selectedIndex: number;
  tabItems: TabItem[] = [];

  build() {
    Row() {
      ForEach(this.tabItems, (item: TabItem) => {
        Column() {
          Image(this.selectedIndex === item.index ? item.selectedIcon : item.icon)
            .width(24)
            .height(24)
            .objectFit(ImageFit.Contain)
            .animation({ duration: 200, curve: Curve.EaseInOut })

          Text(item.title)
            .fontSize(10)
            .fontColor(this.selectedIndex === item.index ? '#FF6B35' : '#999999')
            .fontWeight(this.selectedIndex === item.index ? FontWeight.Bold : FontWeight.Normal)
            .margin({ top: 2 })
            .animation({ duration: 200, curve: Curve.EaseInOut })
        }
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
        .height('100%')
        .onClick(() => {
          if (this.selectedIndex !== item.index) {
            this.selectedIndex = item.index;
          }
        })
      })
    }
    .width('100%')
    .height(56)
    .backgroundColor(Color.White)
    .border({
      width: { top: 0.5 },
      color: '#E5E5E5',
    })
    .padding({ bottom: 8 })
    .shadow({
      radius: 8,
      color: 'rgba(0,0,0,0.08)',
      offsetX: 0,
      offsetY: -2,
    })
  }
}

7.3 主页面集成 TabBar

复制代码
// pages/Index.ets

import { bannerList, categoryList, generateProducts } from '../data/MockData';
import { BannerSwiper } from '../components/BannerSwiper';
import { CategoryNav } from '../components/CategoryNav';
import { ProductGrid } from '../components/ProductGrid';
import { BottomTabBar, TabItem } from '../components/BottomTabBar';
import { Product } from '../models/Product';

@Entry
@Component
struct Index {
  @State currentTab: number = 0;
  @State products: Product[] = generateProducts(1, 10);
  @State isRefreshing: boolean = false;
  @State currentPage: number = 1;

  private tabItems: TabItem[] = [
    { title: '首页', icon: $r('app.media.ic_home'), selectedIcon: $r('app.media.ic_home_active'), index: 0 },
    { title: '分类', icon: $r('app.media.ic_category'), selectedIcon: $r('app.media.ic_category_active'), index: 1 },
    { title: '购物车', icon: $r('app.media.ic_cart'), selectedIcon: $r('app.media.ic_cart_active'), index: 2 },
    { title: '我的', icon: $r('app.media.ic_profile'), selectedIcon: $r('app.media.ic_profile_active'), index: 3 },
  ];

  build() {
    Column() {
      // ---- 首页内容区域 ----
      if (this.currentTab === 0) {
        this.HomePage()
      } else if (this.currentTab === 1) {
        this.CategoryPage()
      } else if (this.currentTab === 2) {
        this.CartPage()
      } else {
        this.ProfilePage()
      }

      // ---- 底部 TabBar ----
      BottomTabBar({
        selectedIndex: $currentTab,
        tabItems: this.tabItems,
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  @Builder
  HomePage() {
    // 首页内容在下一节实现,包含下拉刷新和上拉加载
  }

  @Builder
  CategoryPage() {
    Column() {
      Text('分类页面')
        .fontSize(24)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }

  @Builder
  CartPage() {
    Column() {
      Text('购物车页面')
        .fontSize(24)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }

  @Builder
  ProfilePage() {
    Column() {
      Text('我的页面')
        .fontSize(24)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

7.4 关键代码解析

  • @Link 双向绑定selectedIndex 使用 @Link 装饰器,实现父组件 IndexcurrentTab 与子组件 BottomTabBar 之间的双向同步。当用户点击 Tab 时,currentTab 自动更新,页面内容随之切换。

  • @Builder 页面构建器 :使用 @Builder 装饰器定义各个 Tab 对应的页面内容,代码结构清晰,每页独立维护。

  • animation 动画:图标和文字切换时带有 200ms 的过渡动画,避免生硬的瞬间切换。

  • 顶部阴影 :通过 shadowoffsetY: -2 向上方投射阴影,让 TabBar 与内容区域有明确的视觉分界。


八、实现5:下拉刷新与上拉加载更多

8.1 UI 效果描述

首页商品列表支持两种加载交互:

  • 下拉刷新:下拉到顶部后松手,触发数据刷新,列表重置为第一页。

  • 上拉加载更多:滚动到底部时自动加载下一页数据,追加到列表末尾。

8.2 完整代码

现在补全首页 HomePage Builder 的实现,将所有组件组装在一起:

复制代码
// pages/Index.ets -- HomePage 部分完善

@Entry
@Component
struct Index {
  @State currentTab: number = 0;
  @State bannerData: BannerItem[] = bannerList;
  @State categoryData: CategoryItem[] = categoryList;
  @State products: Product[] = generateProducts(1, 10);
  @State currentPage: number = 1;
  @State isLoadingMore: boolean = false;
  @State hasMore: boolean = true;
  private scroller: Scroller = new Scroller();

  @Builder
  HomePage() {
    List({ scroller: this.scroller }) {
      // 轮播图区域
      ListItem() {
        BannerSwiper({ bannerList: $bannerData })
      }

      // 分类导航区域
      ListItem() {
        CategoryNav({ categoryList: $categoryData })
      }

      // 商品网格区域
      ListItem() {
        ProductGrid({ productList: $products })
          .padding({ top: 12 })
      }

      // 加载状态提示
      ListItem() {
        Row() {
          if (this.isLoadingMore) {
            LoadingProgress()
              .width(20)
              .height(20)
              .color('#FF6B35')
            Text('加载中...')
              .fontSize(13)
              .fontColor('#999999')
              .margin({ left: 6 })
          } else if (!this.hasMore) {
            Text('-- 已经到底了 --')
              .fontSize(13)
              .fontColor('#CCCCCC')
          }
        }
        .width('100%')
        .height(50)
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
    .layoutWeight(1)
    .scrollBar(BarState.Off)
    .edgeEffect(EdgeEffect.Spring)
    .onReachEnd(() => {
      // 上拉加载更多
      if (!this.isLoadingMore && this.hasMore) {
        this.loadMore();
      }
    })
    .onScrollStop(() => {
      console.info('列表停止滚动');
    })
  }

  /** 模拟下拉刷新 */
  private onRefresh(): void {
    this.currentPage = 1;
    this.hasMore = true;
    // 模拟网络请求延迟
    setTimeout(() => {
      this.products = generateProducts(1, 10);
      this.isLoadingMore = false;
    }, 1000);
  }

  /** 模拟上拉加载更多 */
  private loadMore(): void {
    this.isLoadingMore = true;
    this.currentPage++;

    // 模拟网络请求延迟
    setTimeout(() => {
      const newProducts = generateProducts(this.currentPage, 10);
      if (this.currentPage > 5) {
        // 模拟没有更多数据
        this.hasMore = false;
      } else {
        this.products = [...this.products, ...newProducts];
      }
      this.isLoadingMore = false;
    }, 1000);
  }
}

8.3 下拉刷新 -- Refresh 组件方式

HarmonyOS NEXT 提供了原生的 Refresh 组件来实现下拉刷新,用法如下:

复制代码
@Builder
HomePage() {
  Refresh({
    refreshing: $$this.isRefreshing,  // 双向绑定刷新状态
    offset: 60,                       // 下拉触发偏移量
    friction: 65,                     // 摩擦系数
  }) {
    List({ scroller: this.scroller }) {
      // ... 上述 List 内容保持不变 ...
    }
    .width('100%')
    .layoutWeight(1)
    .scrollBar(BarState.Off)
    .onReachEnd(() => {
      if (!this.isLoadingMore && this.hasMore) {
        this.loadMore();
      }
    })
  }
  .onRefreshing(() => {
    // 下拉刷新触发时的回调
    this.onRefresh();
  })
  .width('100%')
  .height('100%')
}

8.4 关键代码解析

机制 说明
onReachEnd() List 滚动到底部时触发,用于上拉加载更多
Refresh 组件 原生下拉刷新容器,refreshing 双向绑定控制加载动画
isLoadingMore 状态锁 防止重复触发加载请求,确保上一次请求完成后再发起新请求
hasMore 标记 标识是否还有更多数据,到底后显示"已经到底了"提示
展开运算符合并数组 [...this.products, ...newProducts] 将新数据追加到现有列表末尾

性能提示 :当商品列表数据量较大时,建议使用 LazyForEach 替代 ForEach,实现按需渲染,避免一次性创建过多组件导致内存压力。

使用 LazyForEach 的改写方式:

复制代码
// 需要实现 IDataSource 接口
class ProductDataSource implements IDataSource {
  private products: Product[] = [];

  totalCount(): number {
    return this.products.length;
  }

  getData(index: number): Product {
    return this.products[index];
  }

  registerDataChangeListener(listener: DataChangeListener): void {}
  unregisterDataChangeListener(listener: DataChangeListener): void {}

  pushData(newProducts: Product[]): void {
    this.products.push(...newProducts);
  }

  resetData(newProducts: Product[]): void {
    this.products = newProducts;
  }
}

九、完整源码汇总

9.1 Index.ets 主页面

复制代码
// pages/Index.ets

import { BannerItem, CategoryItem, Product } from '../models/Product';
import { bannerList, categoryList, generateProducts } from '../data/MockData';
import { BannerSwiper } from '../components/BannerSwiper';
import { CategoryNav } from '../components/CategoryNav';
import { ProductGrid } from '../components/ProductGrid';
import { BottomTabBar, TabItem } from '../components/BottomTabBar';

@Entry
@Component
struct Index {
  @State currentTab: number = 0;
  @State bannerData: BannerItem[] = bannerList;
  @State categoryData: CategoryItem[] = categoryList;
  @State products: Product[] = [];
  @State currentPage: number = 1;
  @State isRefreshing: boolean = false;
  @State isLoadingMore: boolean = false;
  @State hasMore: boolean = true;

  private scroller: Scroller = new Scroller();

  private tabItems: TabItem[] = [
    { title: '首页', icon: $r('app.media.ic_home'), selectedIcon: $r('app.media.ic_home_active'), index: 0 },
    { title: '分类', icon: $r('app.media.ic_category'), selectedIcon: $r('app.media.ic_category_active'), index: 1 },
    { title: '购物车', icon: $r('app.media.ic_cart'), selectedIcon: $r('app.media.ic_cart_active'), index: 2 },
    { title: '我的', icon: $r('app.media.ic_profile'), selectedIcon: $r('app.media.ic_profile_active'), index: 3 },
  ];

  aboutToAppear(): void {
    this.products = generateProducts(1, 10);
  }

  build() {
    Column() {
      // 顶部搜索栏
      this.SearchBarBuilder()

      // 主内容区域
      if (this.currentTab === 0) {
        this.HomePage()
      } else if (this.currentTab === 1) {
        this.PlaceholderPage('分类')
      } else if (this.currentTab === 2) {
        this.PlaceholderPage('购物车')
      } else {
        this.PlaceholderPage('我的')
      }

      // 底部TabBar
      BottomTabBar({ selectedIndex: $currentTab, tabItems: this.tabItems })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  /** 顶部搜索栏 */
  @Builder
  SearchBarBuilder() {
    Row() {
      Row() {
        Text('\uf002') // 搜索图标
          .fontSize(14)
          .fontColor('#999999')
          .margin({ right: 8 })
        Text('搜索商品、品牌')
          .fontSize(14)
          .fontColor('#CCCCCC')
      }
      .height(36)
      .borderRadius(18)
      .backgroundColor('#F0F0F0')
      .padding({ left: 16, right: 16 })
      .layoutWeight(1)
      .margin({ right: 12 })

      // 消息图标
      Text('\uf0f3') // 铃铛图标
        .fontSize(20)
        .fontColor('#333333')
    }
    .width('100%')
    .height(52)
    .padding({ left: 16, right: 16 })
    .backgroundColor(Color.White)
    .alignItems(VerticalAlign.Center)
  }

  /** 首页内容 */
  @Builder
  HomePage() {
    Refresh({
      refreshing: $$this.isRefreshing,
      offset: 60,
      friction: 65,
    }) {
      List({ scroller: this.scroller }) {
        ListItem() {
          BannerSwiper({ bannerList: $bannerData })
        }

        ListItem() {
          CategoryNav({ categoryList: $categoryData })
        }

        ListItem() {
          ProductGrid({ productList: $products })
            .padding({ top: 12 })
        }

        ListItem() {
          this.LoadingFooter()
        }
      }
      .width('100%')
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .edgeEffect(EdgeEffect.Spring)
      .onReachEnd(() => {
        if (!this.isLoadingMore && this.hasMore) {
          this.loadMore();
        }
      })
    }
    .onRefreshing(() => {
      this.onRefresh();
    })
    .width('100%')
    .height('100%')
  }

  /** 列表底部加载状态 */
  @Builder
  LoadingFooter() {
    Row() {
      if (this.isLoadingMore) {
        LoadingProgress().width(20).height(20).color('#FF6B35')
        Text('加载中...').fontSize(13).fontColor('#999999').margin({ left: 6 })
      } else if (!this.hasMore) {
        Text('-- 已经到底了 --').fontSize(13).fontColor('#CCCCCC')
      }
    }
    .width('100%')
    .height(50)
    .justifyContent(FlexAlign.Center)
  }

  /** 占位页面 */
  @Builder
  PlaceholderPage(title: string) {
    Column() {
      Text(`${title}页面`)
        .fontSize(24)
        .fontColor('#999999')
    }
    .width('100%')
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
  }

  /** 下拉刷新 */
  private onRefresh(): void {
    this.currentPage = 1;
    this.hasMore = true;
    setTimeout(() => {
      this.products = generateProducts(1, 10);
      this.isRefreshing = false;
    }, 1000);
  }

  /** 上拉加载更多 */
  private loadMore(): void {
    this.isLoadingMore = true;
    this.currentPage++;
    setTimeout(() => {
      if (this.currentPage > 5) {
        this.hasMore = false;
      } else {
        const newProducts = generateProducts(this.currentPage, 10);
        this.products = [...this.products, ...newProducts];
      }
      this.isLoadingMore = false;
    }, 1500);
  }
}

9.2 核心组件汇总清单

文件路径 组件名 职责
components/BannerSwiper.ets BannerSwiper 轮播图 + 自定义指示器
components/CategoryNav.ets CategoryNav 横向滚动分类导航
components/ProductCard.ets ProductCard 单个商品卡片展示
components/ProductGrid.ets ProductGrid 双列商品网格容器
components/BottomTabBar.ets BottomTabBar 自定义底部导航栏
models/Product.ets 接口定义 Product / BannerItem / CategoryItem
data/MockData.ets 数据层 Mock 数据生成

十、总结与扩展建议

10.1 本篇知识点回顾

通过这个电商首页实战,我们系统性地练习了 ArkUI 中最核心的布局和交互能力:

组件/能力 核心知识点
Swiper 自动播放、循环、自定义指示器、onChange 回调
Scroll + Row 横向滚动列表、弹性边缘效果
Grid + GridItem 网格布局、columnsTemplate、间距控制
List + ListItem 纵向滚动列表、onReachEnd 事件
Refresh 原生下拉刷新、refreshing 双向绑定
@Link / @State 父子组件数据双向同步
@Builder 声明式 UI 片段复用
shadow / borderRadius 卡片视觉层次和圆角

10.2 生产环境扩展建议

如果你准备将此代码用于实际项目,以下几点建议供参考:

1. 网络请求层

将 Mock 数据替换为真实的网络请求,推荐封装统一的网络工具类:

复制代码
import { http } from '@kit.NetworkKit';

async function fetchProducts(page: number): Promise<Product[]> {
  const response = await http.createHttp().request(
    `https://api.example.com/products?page=${page}`,
    { method: http.RequestMethod.GET }
  );
  return JSON.parse(response.result as string).data;
}

2. 状态管理

当项目规模增大,建议引入状态管理方案。对于中小型项目,@Observed + @ObjectLink 足够;大型项目可以考虑 AppStorage 或第三方状态管理库。

3. 图片优化

  • 使用 ImageKnifeGlide 等图片加载库实现三级缓存

  • 服务端返回缩略图用于列表,点击后加载高清大图

  • 为不同屏幕密度提供合适的图片资源

4. 性能优化

  • 商品列表使用 LazyForEach 实现懒加载,减少内存占用

  • 图片使用 cachedCount 属性预加载可视区域外的图片

  • 避免在 build 方法中创建复杂对象,将计算逻辑前置到 aboutToAppear 或数据层

5. 无障碍适配

为关键组件添加 accessibilityTextaccessibilityDescription,让视障用户也能通过 TalkBack 使用你的应用。


10.3 系列文章导航

  • 本文为 鸿蒙NEXT开发实战系列 第14篇

  • 下一篇预告:ArkUI 动画进阶 -- 手势驱动的交互动效实战

如果你在实现过程中遇到问题,欢迎在评论区留言讨论。完整的项目源码已同步到 GitHub 仓库,可以直接 clone 运行。


写在最后:电商首页看似简单,实则是一个综合性极强的 UI 实战项目。掌握本文中的所有组件用法和布局技巧后,你不仅能够独立完成鸿蒙应用的首页开发,更能在面对其他复杂页面时举一反三。技术的提升从来不是一蹴而就的,把每一个案例做精做透,才是成长的捷径。

相关推荐
xmdy58661 小时前
Flutter+开源鸿蒙实战|城市共享驿站智能存取系统 Day1 项目初始化+架构分层+多端适配+全局状态基座
flutter·开源·harmonyos
前端不太难1 小时前
AI 能力如何变成鸿蒙 App 的基础设施
人工智能·状态模式·harmonyos
空中海2 小时前
01 鸿蒙知识体系图与环境基础
华为·harmonyos
三声三视2 小时前
鸿蒙 ArkTS 国际化实战全攻略:多语言切换、格式本地化与 RTL 布局一步到位
华为·harmonyos·鸿蒙
月光技术杂谈2 小时前
openEuler各镜像目录区别、部署差异及5G基站平台稳定高性能系统构建方案
5g·华为·信创·镜像·openeuler·国产·欧拉
空中海2 小时前
05 鸿蒙APP 测试、性能、安全、发布与生产实践
安全·华为·harmonyos
●VON2 小时前
鸿蒙Widget开发实战:3张卡片实现桌面-App全链路同步
华为·app·harmonyos·鸿蒙·von
nashane3 小时前
HarmonyOS 6学习:Web组件本地资源跨域访问全解析与实战
前端·学习·harmonyos·harmonyos 5
特立独行的猫a3 小时前
HarmonyOS / OpenHarmony 鸿蒙PC平台三方库移植:AI自动化编译框架build_in_harmonyos介绍及使用
人工智能·自动化·harmonyos·三方库移植·鸿蒙pc·opendesk