【鸿蒙实战】从零撸一个「华为商城」App:登录 + 商品首页,新手也能跟着做的完整项目

学完 ArkUI 的状态管理、布局和路由之后,我没有继续往下刷知识点,而是独立做了一个综合小项目 —— Huawei_Shop(华为商城),把前面学的东西一次性串起来。

项目不大,但业务闭环完整:手机号登录 → 携带用户信息跳转 → 商城首页(账号卡片 + 分类标签 + 20 款商品列表)。本文记录从零到一的完整开发过程、核心代码、5 个真实踩坑与解决方案,可直接复现,建议作为鸿蒙入门后的第一个练手项目。
在这里插入图片描述


一、项目背景

说明
项目名Huawei_Shop(华为商城 Demo)
项目定位鸿蒙入门后第一个脱离教程的独立作业
业务闭环登录校验 → 路由传参 → 商城首页展示
开发语言ArkTS(ArkUI 声明式范式)
状态写法状态管理 V2@ComponentV2 + @Local
运行平台HarmonyOS NEXT(API 12),DevEco Studio
数据方式Mock 写死(20 条华为真实商品名/价格)

在这里插入图片描述


二、技术栈介绍

类别技术点在项目中的用途
状态管理 V2@ComponentV2 / @Local登录页输入框实时绑定
基础组件Column / Row / Text / TextInput / Button / Image / Blank页面骨架与表单
列表与渲染ForEach + 键生成器分类标签、商品列表渲染
滚动Scroll首页内容超出屏幕可上下滑动
弹性布局Flex({ wrap: FlexWrap.Wrap }) + 25% 宽度商品分类自动换行
UI 复用@Builder 全局构建函数抽取商品卡片 GoodItem
路由router.pushUrl + params登录成功携带 User 对象跳首页
交互反馈promptAction.showToast账号密码错误提示
资源系统$r('app.media.xxx') / $r('app.color.xxx')头像图片与颜色引用

三、整体流程与项目结构

3.1 页面流程图

在这里插入图片描述

3.2 工程目录

Huawei_Shop/
└── entry/src/main/
    ├── resources/base/
    │   ├── element/                 # color / float / string
    │   ├── media/
    │   │   ├── people.png           # 账号头像
    │   │   ├── startIcon.png / foreground.png / background.png
    │   │   └── layered_image.json
    │   └── profile/main_pages.json  # ⭐ 页面路由注册表
    └── ets/
        ├── entryability/EntryAbility.ets
        └── ├── pages/
        │   ├── Login.ets            # 登录页(启动页)
        │   └── ShopInfo.ets         # 商城首页
        └── model/
            ├── User.ets             # 用户模型
            ├── Goods.ets            # 商品模型
            └── MockGoods.ets        # 20 条商品 Mock 数据

💡 分层思路:pages 只管 UI 和交互,model 管数据结构与 Mock 数据,后期换真实接口只改 model 层。


四、开发步骤拆解

Step 1:新建工程并注册路由

DevEco 新建 Empty Ability 工程,包名 com.example.huawei_shop第一件事就是在 main_pages.json 中把两个页面都登记好:

{
  "src": [
    "pages/Login",
    "pages/ShopInfo"
  ]
}

在这里插入图片描述

Step 2:搭建数据模型层

model/User.ets —— 用户信息:

export class User {
  name: string
  phone: string
  gender: string

  constructor(name: string, phone: string, gender: string) {
    this.name = name
    this.phone = phone
    this.gender = gender
  }
}

model/Goods.ets —— 商品信息:

export class Goods {
  title: string
  desc: string
  price: number

  constructor(title: string, desc: string, price: number) {
    this.title = title
    this.desc = desc
    this.price = price
  }
}

model/MockGoods.ets —— 20 条商品数据:

import { Goods } from './Goods'

export class MockGoods {
  static getGoodList(): Goods[] {
    return [
      new Goods("Mate 60 Pro", "麒麟9000S·卫星通话", 6999),
      new Goods("华为 MatePad Pro", "12.6英寸 2.5K高刷", 4999),
      new Goods("HUAWEI Pura 70 Ultra", "超聚光伸缩摄像头·高速风驰闪拍", 9999),
      new Goods("HUAWEI Mate X5", "内折折叠屏·玄武钢化昆仑玻璃", 12999),
      new Goods("HUAWEI MateBook X Pro", "14.2英寸 触控全面屏", 9299),
      new Goods("HUAWEI Watch GT5 Pro", "14天超长续航·陶瓷表壳", 2488),
      // ……共 20 条,覆盖手机/平板/电脑/穿戴/智慧屏/家居
    ]
  }
}

Step 3:登录页 Login.ets(核心)

使用 V2 状态写法@Local 实时持有手机号和密码,点击登录做本地固定账号校验

import { User } from '../model/User'
import { promptAction, router } from '@kit.ArkUI'

@Entry
@ComponentV2
struct Login {
  @Local phone: string = ''
  @Local pwd: string = ''

  // 模拟正确账号密码
  private correctPhone = "13888888888"
  private correctPwd = "123456"

  build() {
    Column({ space: 12 }) {
      Text("账号登录")
        .fontWeight(FontWeight.Bold)
        .fontSize(26)
        .margin({ bottom: 30 })

      TextInput({ text: this.phone, placeholder: "请输入手机号" })
        .width('100%')
        .onChange((val: string) => this.phone = val)

      TextInput({ text: this.pwd, placeholder: "请输入密码" })
        .type(InputType.Password)
        .width('100%')
        .onChange((val: string) => this.pwd = val)

      Button("登录")
        .margin({ top: 20 })
        .onClick(() => {
          if (this.phone === this.correctPhone && this.pwd === this.correctPwd) {
            let userInfo = new User("华为用户", this.phone, '女')
            router.pushUrl({
              url: "pages/ShopInfo",
              params: { user: userInfo }   // ⭐ 直接携带对象
            })
          } else {
            promptAction.showToast({ message: "账号或者密码错误" })
          }
        })
    }
    .width('100%').height('100%').padding(20)
    .justifyContent(FlexAlign.Center)
  }
}

核心逻辑讲解:

  • TextInput({ text: this.phone }) + onChange 把输入实时回写到 @Local 变量,形成闭环;
  • 密码框加 .type(InputType.Password) 自动密文显示;
  • 校验通过后构造 User 对象,通过 router.pushUrlparams 携带整个对象(比只传字符串更贴近真实业务)。
    在这里插入图片描述

Step 4:商城首页 ShopInfo.ets

页面结构从上到下分四块,整体用 Scroll 包裹保证可滚动:

import { router } from '@kit.ArkUI'
import { User } from '../model/User'
import { MockGoods } from '../model/MockGoods'
import { Goods } from '../model/Goods'

interface PageParams {
  user: User
}

@Entry
@Component
struct InfoPage {
  @State user: User = new User('', '', '')
  @State goodsList: Goods[] = MockGoods.getGoodList()
  private typeList: string[] = ['手机', '平板', '电脑', '穿戴', '手机', '平板', '电脑', '穿戴']

  aboutToAppear() {
    const params = router.getParams() as PageParams
    if (params?.user) {
      this.user = new User(params.user.name, params.user.phone, params.user.gender)
    }
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          // ① 顶部标题栏:华为商城 + 搜索占位
          Row() {
            Text('华为商城').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#e60012')
            Blank()
            Text('🔍').backgroundColor('#f5f5f5').borderRadius(14)
          }.width('100%')

          // ② 顶部分类标签行(取前 4 个)
          Row({ space: 8 }) {
            ForEach(this.typeList.slice(0, 4), (t: string) => {
              Text(t).padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .backgroundColor('#f2f2f2').borderRadius(15)
            }, (t: string) => t)
          }.width('100%').margin({ top: 10 })

          // ③ 账号信息卡片:接收登录传来的 User
          Row() { /* 姓名/手机号/性别 + Image($r('app.media.people')) */ }
          // ④ 商品类型 Flex 换行区(每个占 25%)
          // ⑤ 热门商品单列 ForEach
        }.padding(12).alignItems(HorizontalAlign.Start)
      }.scrollBar(BarState.Off)
    }.backgroundColor('#ffffff')
  }
}

关键片段 A —— 商品分类自动换行(4 个一行):

Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(this.typeList, (t: string, idx: number) => {
    Column() {
      Text(t)
        .padding({ left: 18, right: 18, top: 7, bottom: 7 })
        .backgroundColor('#ffffff')
        .border({ width: 1, color: '#e5e5e5' })
        .borderRadius(16)
    }
    .width('25%')                          // 每行 4 个
    .alignItems(HorizontalAlign.Center)
    .margin({ bottom: 10 })
  }, (t: string, idx: number) => `${t}_${idx}`)  // ⭐ key 防重复
}

关键片段 B —— @Builder 抽取商品卡片:

@Builder
function GoodItem(item: Goods) {
  Row() {
    Column() {
      Text(item.title).fontSize(18).fontWeight(FontWeight.Bold).margin({ bottom: 4 })
      Text(item.desc).fontSize(14).fontColor('#666').margin({ bottom: 6 })
      Text(`¥${item.price}`).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#e60012')
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Start)

    Button('购买', { type: ButtonType.Normal, stateEffect: true })
      .backgroundColor('#dd2222').fontColor('#ffffff').fontSize(14)
  }
  .width('100%').padding(12).borderRadius(12)
  .backgroundColor('#fafafa').border({ width: 1, color: '#eee' })
  .alignItems(VerticalAlign.Center)
}

热门商品区直接复用:

Column({ space: 8 }) {
  ForEach(this.goodsList, (item: Goods) => {
    GoodItem(item)
  }, (item: Goods) => item.title)
}

在这里插入图片描述

五、开发踩坑汇总(报错原因 + 详细解决)

🚩 坑 1:跳转 ShopInfo 直接白屏 / 路由报错

  • 原因:新建了 ShopInfo.ets 却忘了在 main_pages.jsonsrc 中注册,router.pushUrl 找不到目标页。
  • 解决:补注册 "pages/ShopInfo"以后每新建页面,第一件事就是注册路由,我已经把它写进肌肉记忆。在这里插入图片描述

🚩 坑 2:首页取登录参数是 undefined

  • 现象:登录成功了,首页账号卡一片空白。
  • 原因:取参数时机/判空没处理好,直接 router.getParams() as PageParams 后访问属性,异常情况下拿到 undefined。
  • 解决:在 aboutToAppear() 里取参,可选链判空 + 重新 new 一个 User,避免直接持有跨页对象引用:
aboutToAppear() {
  const params = router.getParams() as PageParams
  if (params?.user) {
    this.user = new User(params.user.name, params.user.phone, params.user.gender)
  }
}

🚩 坑 3:ForEach 分类标签 key 重复

  • 现象typeList 里「手机/平板/电脑/穿戴」出现了两次,如果键生成器只返回文本 t,key 重复会导致渲染异常/控制台警告。
  • 解决把下标拼进 key 保证全局唯一:
}, (t: string, idx: number) => `${t}_${idx}`)

经验:ForEach 键生成器的唯一性要站在"整个数据源"角度看,而不是只看当前这一项。

🚩 坑 4:商品一多,屏幕外的内容看不全也滑不动

  • 原因:最外层用了 Column,内容超出屏幕高度后没有滚动能力。
  • 解决:用 Scroll 包住内容容器,并关掉滚动条让界面更干净:
Scroll() {
  Column() { /* 首页所有内容 */ }
}.scrollBar(BarState.Off)

🚩 坑 5:头像图片不显示

  • 排查路径:① 图片是否真的放进 resources/base/media/;② 引用名与文件名是否完全一致;③ 是否需要重新 Build。
  • 解决:把头像命名为 people.png 放入 media,代码用 Image($r('app.media.people')) 引用,重新运行即正常显示。

六、项目运行效果展示

页面实现效果
登录页红色品牌标题、手机号/密码输入、错误 Toast、成功跳转
账号信息卡展示登录用户姓名、手机号(138****8888 格式可后期加密)、性别 + 头像
分类区顶部 4 个胶囊标签 + 下方 4 列自动换行的商品类型
商品列表20 款商品单列卡片,华为红价格 ¥xxxx +「购买」按钮
整体体验全屏白色背景、Scroll 顺滑滚动、卡片圆角边框

测试账号:手机号 13888888888 / 密码 123456

在这里插入图片描述


七、个人实战感悟

1. 看教程和自己写完全是两回事。 跟写时觉得 router 传参、ForEach 都懂了,独立做时白屏、undefined、key 重复一个没落全踩了一遍。报错才是最好的老师,每解决一个坑,知识才算真正长在自己身上。

2. 先搭骨架再填肉,效率高很多。 这次我刻意按「注册路由 → 数据模型 → 登录页 → 首页四块区域」的顺序推进,每完成一块就运行一次,而不是一口气写完几百行再找 bug,定位问题快了非常多。

3. 数据层与 UI 分层的意识开始形成。 User / Goods / MockGoods 独立在 model 目录,页面只负责展示。这个小习惯会让后面接入真实 HTTP 接口时非常轻松。

4. 品牌色与间距统一,"像不像"全在细节。 华为红 #e60012、卡片圆角 12、灰底分区,这些小参数让 Demo 的观感从"作业"变成了"作品"。


八、技术能力提升总结

通过这个项目,我把前面散落的知识点真正连成了线

状态管理 V2(@Local)
   + 表单与校验(TextInput / Toast)
   + 路由与对象传参(router.pushUrl / getParams / 判空)
   + 布局(Column/Row/Flex/Blank/Scroll/25% 栅格)
   + 列表渲染(ForEach + 唯一键生成器)
   + UI 复用(@Builder 商品卡片)
   + 资源系统(media 图片 / 颜色)
   = 一个业务闭环完整的原生 App 页面

后续迭代计划(已排期):

  • 手机号正则校验 + 密码长度校验
  • 商品列表点击 → 商品详情页(传 Goods 对象)
  • 分类标签点击筛选对应品类商品
  • 接入 Preferences 记住登录态
  • Mock 数据替换为 HTTP 真实接口

源码我放在了个人鸿蒙练习仓库https://gitee.com/jiao001的 Huawei_Shop 目录,需要参考的同学自取。如果本文对你有帮助,求一个 点赞 👍 + 收藏 ⭐ + 关注,这是我持续输出实战项目的最大动力!

💬 评论区聊聊:你学鸿蒙时踩的第一个坑是什么?我先来——忘记注册路由导致白屏 😂

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐