【鸿蒙实战】从零撸一个「华为商城」App
【鸿蒙实战】从零撸一个「华为商城」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.pushUrl的params携带整个对象(比只传字符串更贴近真实业务)。

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.json的src中注册,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 目录,需要参考的同学自取。如果本文对你有帮助,求一个 点赞 👍 + 收藏 ⭐ + 关注,这是我持续输出实战项目的最大动力!
💬 评论区聊聊:你学鸿蒙时踩的第一个坑是什么?我先来——忘记注册路由导致白屏 😂
更多推荐



所有评论(0)