鸿蒙生鲜超市项目实战:ArkTS页面路由、状态管理与购物车结算完整落地

发布时间:2026/10/27 23:48:52
鸿蒙生鲜超市项目实战:ArkTS页面路由、状态管理与购物车结算完整落地 简介这份资源是面向鸿蒙应用开发初学者与进阶者的实战项目包聚焦华为HarmonyOS在生鲜超市行业的落地场景帮助开发者理解如何用DevEco Studio构建兼容鸿蒙的购物类应用。压缩包约16.07MB内含项目源码、资源文件与配置文件等便于直接阅读和二次修改。项目围绕商品展示、智能推荐、库存管理、在线支付、订单追踪及智能家居联动等核心功能展开并体现鸿蒙分布式架构下手机、平板、智慧屏等多设备无缝切换的购物体验。通过研读代码结构与模块划分读者可掌握鸿蒙微内核、自适应UI与组件库的实际用法积累行业应用开发与排错思路。目前已有197人学习适合希望以真实案例入门鸿蒙开发的技术人员参考。1. 从「鸿蒙开发设计 生鲜超市.zip」看一个真实可交付的鸿蒙应用长什么样很多人第一次看到「鸿蒙开发设计 生鲜超市.zip」这个标题第一反应是「又一个课程作业压缩包」。但如果你正在做鸿蒙应用开发或者准备拿一个完整项目去面试、去接单、去参加比赛这个标题背后其实藏着一个非常具体的问题一个生鲜电商类的鸿蒙应用从工程结构到页面跳转、从商品列表到购物车结算到底该怎么落地它解决的不是「鸿蒙是什么」这种科普问题而是「给我一个能跑起来的完整业务闭环」的问题。适合谁看适合已经装好 DevEco Studio、能新建一个 Empty Ability 工程但一写到多页面跳转、状态管理、列表渲染就卡住的开发者。生鲜超市这个场景选得很聪明——它同时覆盖了列表、详情、购物车、订单四个高频页面又不像社交或金融那样涉及复杂权限和风控是练手鸿蒙 ArkTS 声明式 UI 的绝佳载体。下面我按实际做项目的顺序把这个压缩包该有的东西一层层拆开。2. 工程骨架与页面路由生鲜超市的目录到底怎么摆2.1 先想清楚为什么生鲜超市适合用 Stage 模型鸿蒙现在的应用开发默认走 Stage 模型也就是 UIAbility WindowStage 页面路由这一套。生鲜超市这种多页面应用如果用 FA 模型页面跳转和生命周期会写得很别扭Stage 模型下一个 UIAbility 承载整个应用页面之间用 router 或 Navigation 跳转状态用 AppStorage 或 LocalStorage 共享结构非常清晰。我一般会这样划分目录entry/src/main/ets/ ├── entryability/ │ └── EntryAbility.ets ├── pages/ │ ├── Index.ets // 首页商品列表 │ ├── Detail.ets // 商品详情 │ ├── Cart.ets // 购物车 │ └── Order.ets // 订单确认 ├── model/ │ ├── Goods.ets // 商品数据模型 │ └── CartItem.ets // 购物车项模型 ├── viewmodel/ │ └── CartViewModel.ets // 购物车逻辑 └── common/ └── Constants.ets // 常量与路由名这个结构不是随便摆的。pages 放页面model 放数据结构viewmodel 放业务逻辑common 放公共常量。生鲜超市的商品数据、购物车增删改、订单金额计算全部有地方放不会全堆在页面里。2.2 路由配置main_pages.json 与 router 跳转页面建好后必须在entry/src/main/resources/base/profile/main_pages.json里注册否则跳转直接白屏{ src: [ pages/Index, pages/Detail, pages/Cart, pages/Order ] }然后在页面里用 router 跳转import router from ohos.router; // 跳转到详情页携带商品 id router.pushUrl({ url: pages/Detail, params: { goodsId: this.goodsId } }); // 在 Detail 页面接收参数 aboutToAppear() { const params router.getParams() as Recordstring, string; this.goodsId params.goodsId; }逻辑说明pushUrl是压栈跳转replaceUrl是替换当前页back是返回。生鲜超市里首页到详情用 pushUrl详情到购物车用 pushUrl购物车到订单确认用 pushUrl订单完成回首页用 replaceUrl 或 clear 路由栈。参数说明params只支持可序列化的对象别传函数或类实例。商品 id 用字符串最稳数字在跨页面时偶尔会有类型丢失的玄学问题。提示main_pages.json 里路径不带.ets后缀写错一个字母就是白屏且控制台不一定报错这是新手最常见的翻车点。2.3 用 Navigation 替代 router 的时机如果你的生鲜超市要做平板或折叠屏适配router 就不够用了。Navigation 组件支持分栏显示左边列表右边详情这是鸿蒙元服务和大屏应用的标准做法。但 Navigation 的学习曲线比 router 陡我的建议是先 router 跑通业务再重构 Navigation。别一上来就啃 Navigation容易卡在路由栈管理上出不来。3. 商品列表与详情页ArkTS 声明式 UI 的实战写法3.1 商品数据模型与假数据构造生鲜超市的核心是商品。先定义模型// model/Goods.ets export class Goods { id: string ; name: string ; price: number 0; image: ResourceStr ; category: string ; stock: number 0; description: string ; } // 构造假数据 export function mockGoodsList(): Goods[] { const list: Goods[] []; const names [有机菠菜, 新鲜草莓, 土鸡蛋, 进口牛排, 海南香蕉]; for (let i 0; i names.length; i) { const g new Goods(); g.id goods_${i}; g.name names[i]; g.price 9.9 i * 5; g.category i % 2 0 ? 蔬菜 : 水果; g.stock 100 - i * 10; g.description ${names[i]}当日采摘冷链配送; list.push(g); } return list; }逻辑说明用类而不是接口是因为 ArkTS 对类的支持更完整后续要加方法也方便。假数据用循环生成避免手写一堆重复对象。参数说明image用ResourceStr类型可以接$r(app.media.xxx)或网络地址。本地图片放resources/base/media下网络图片需要申请网络权限。3.2 用 List ForEach 渲染商品列表首页的核心是一个可滚动的商品列表import { Goods, mockGoodsList } from ../model/Goods; import router from ohos.router; Entry Component struct Index { State goodsList: Goods[] mockGoodsList(); build() { Column() { Text(生鲜超市) .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 10 }) List({ space: 12 }) { ForEach(this.goodsList, (item: Goods) { ListItem() { Row() { Image(item.image) .width(80) .height(80) .borderRadius(8) .backgroundColor(#f0f0f0) Column() { Text(item.name) .fontSize(18) .fontWeight(FontWeight.Medium) Text(¥${item.price.toFixed(2)}) .fontSize(16) .fontColor(#e60012) .margin({ top: 6 }) Text(库存${item.stock}) .fontSize(12) .fontColor(#999) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .margin({ left: 12 }) } .padding(12) .backgroundColor(Color.White) .borderRadius(12) .onClick(() { router.pushUrl({ url: pages/Detail, params: { goodsId: item.id } }); }) } }, (item: Goods) item.id) } .layoutWeight(1) .padding({ left: 16, right: 16 }) } .width(100%) .height(100%) .backgroundColor(#f5f5f5) } }逻辑说明List是鸿蒙里性能最好的长列表组件ForEach的第三个参数是 key 生成函数必须写且要唯一否则列表更新时会错乱。layoutWeight(1)让列表占满剩余空间。参数说明space: 12是列表项间距。borderRadius和backgroundColor是视觉优化生鲜类应用用白色卡片 浅灰背景最稳妥。onClick里跳转携带goodsId详情页据此查找商品。3.3 详情页接收参数与加入购物车详情页要做两件事根据 id 找到商品提供「加入购物车」按钮。import router from ohos.router; import { Goods, mockGoodsList } from ../model/Goods; import { CartViewModel } from ../viewmodel/CartViewModel; Entry Component struct Detail { State goods: Goods new Goods(); private cartVM: CartViewModel CartViewModel.getInstance(); aboutToAppear() { const params router.getParams() as Recordstring, string; const id params.goodsId; const list mockGoodsList(); const found list.find((g: Goods) g.id id); if (found) { this.goods found; } } build() { Column() { // 商品图片、名称、价格、描述 Image(this.goods.image) .width(100%) .height(240) .backgroundColor(#f0f0f0) Text(this.goods.name) .fontSize(22) .fontWeight(FontWeight.Bold) .margin({ top: 16 }) Text(¥${this.goods.price.toFixed(2)}) .fontSize(20) .fontColor(#e60012) .margin({ top: 8 }) Text(this.goods.description) .fontSize(14) .fontColor(#666) .margin({ top: 12 }) Button(加入购物车) .width(80%) .height(48) .margin({ top: 24 }) .onClick(() { this.cartVM.addItem(this.goods); router.pushUrl({ url: pages/Cart }); }) } .width(100%) .height(100%) .padding(16) } }逻辑说明aboutToAppear是页面生命周期在这里解析参数并查找商品。CartViewModel用单例模式保证购物车数据全局唯一。参数说明find是数组方法返回第一个匹配项。如果商品不存在goods保持默认空对象页面显示空白但不崩溃这是防御性写法。4. 购物车与订单结算状态管理与金额计算4.1 用 AppStorage 做跨页面状态共享购物车数据要在首页、详情页、购物车页、订单页之间共享。ArkTS 里最直接的方式是 AppStorage// viewmodel/CartViewModel.ets import { Goods } from ../model/Goods; export class CartItem { goods: Goods new Goods(); count: number 1; } export class CartViewModel { private static instance: CartViewModel; private items: CartItem[] []; static getInstance(): CartViewModel { if (!CartViewModel.instance) { CartViewModel.instance new CartViewModel(); } return CartViewModel.instance; } addItem(goods: Goods): void { const existing this.items.find((i: CartItem) i.goods.id goods.id); if (existing) { existing.count 1; } else { const item new CartItem(); item.goods goods; item.count 1; this.items.push(item); } AppStorage.setOrCreate(cartItems, this.items); } getItems(): CartItem[] { return this.items; } getTotalPrice(): number { let total 0; for (const item of this.items) { total item.goods.price * item.count; } return total; } removeItem(goodsId: string): void { this.items this.items.filter((i: CartItem) i.goods.id ! goodsId); AppStorage.setOrCreate(cartItems, this.items); } }逻辑说明单例保证全局只有一个购物车实例。addItem先查重存在就加数量不存在就新增。每次变更后同步到 AppStorage页面用StorageLink监听变化。参数说明AppStorage.setOrCreate是「有则更新无则创建」。getTotalPrice用循环累加避免reduce在 ArkTS 里的类型推断问题。4.2 购物车页面StorageLink 实时刷新import { CartItem, CartViewModel } from ../viewmodel/CartViewModel; import router from ohos.router; Entry Component struct Cart { StorageLink(cartItems) cartItems: CartItem[] []; private cartVM: CartViewModel CartViewModel.getInstance(); build() { Column() { Text(购物车) .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 10 }) List() { ForEach(this.cartItems, (item: CartItem) { ListItem() { Row() { Text(item.goods.name) .layoutWeight(1) .fontSize(16) Text(x${item.count}) .fontSize(14) .fontColor(#666) Text(¥${(item.goods.price * item.count).toFixed(2)}) .fontSize(16) .fontColor(#e60012) .margin({ left: 12 }) Button(删除) .fontSize(12) .height(28) .margin({ left: 8 }) .onClick(() { this.cartVM.removeItem(item.goods.id); }) } .padding(12) .backgroundColor(Color.White) .borderRadius(8) .margin({ bottom: 8 }) } }, (item: CartItem) item.goods.id) } .layoutWeight(1) .padding({ left: 16, right: 16 }) Row() { Text(合计¥${this.cartVM.getTotalPrice().toFixed(2)}) .fontSize(18) .fontColor(#e60012) .layoutWeight(1) Button(去结算) .width(120) .height(44) .onClick(() { router.pushUrl({ url: pages/Order }); }) } .width(100%) .padding(16) .backgroundColor(Color.White) } .width(100%) .height(100%) .backgroundColor(#f5f5f5) } }逻辑说明StorageLink(cartItems)让页面和 AppStorage 双向绑定购物车增删后页面自动刷新不需要手动调this.cartItems ...。参数说明ForEach的 key 用item.goods.id保证删除时列表正确更新。合计金额每次渲染时重新计算数据量小的时候没问题数据量大要改成缓存值。4.3 订单页金额计算与提交模拟订单页展示最终金额模拟提交后清空购物车import { CartViewModel } from ../viewmodel/CartViewModel; import router from ohos.router; import promptAction from ohos.promptAction; Entry Component struct Order { private cartVM: CartViewModel CartViewModel.getInstance(); State total: number 0; aboutToAppear() { this.total this.cartVM.getTotalPrice(); } build() { Column() { Text(订单确认) .fontSize(24) .fontWeight(FontWeight.Bold) .margin({ top: 20, bottom: 20 }) Text(应付金额¥${this.total.toFixed(2)}) .fontSize(20) .fontColor(#e60012) Button(提交订单) .width(80%) .height(48) .margin({ top: 32 }) .onClick(() { promptAction.showToast({ message: 下单成功 }); this.cartVM.getItems().length 0; AppStorage.setOrCreate(cartItems, []); router.replaceUrl({ url: pages/Index }); }) } .width(100%) .height(100%) .padding(16) } }逻辑说明aboutToAppear里算一次总价避免 build 里重复计算。提交后清空数组并同步 AppStorage用replaceUrl回首页防止用户返回到订单页重复提交。参数说明promptAction.showToast是鸿蒙的轻提示比 AlertDialog 轻量。replaceUrl会销毁当前页适合订单完成这种场景。5. 避坑与排查生鲜超市项目里最容易翻车的 5 个点5.1 页面白屏控制台无报错现象点击商品跳转详情页面一片白日志里没有明显错误。原因main_pages.json里没注册 Detail 页面或者路径大小写不一致。鸿蒙的页面路径是大小写敏感的pages/detail和pages/Detail是两个东西。解决检查main_pages.json的src数组确保每个页面路径和实际文件名完全一致。改完要重新编译热重载有时不生效。5.2 ForEach 列表删除后显示错乱现象购物车删除一个商品剩下的商品名称和价格对不上。原因ForEach的 key 生成函数返回了重复值或者用了数组索引当 key。索引在删除后会变化导致复用错误。解决key 必须用唯一且稳定的字段比如item.goods.id。永远不要用index当 key。5.3 StorageLink 不刷新现象详情页加入购物车后购物车页面数量没变。原因AppStorage.setOrCreate传的是同一个数组引用ArkTS 检测不到变化。解决每次变更后传一个新数组比如this.items [...this.items]或者用AppStorage.setOrCreate(cartItems, this.items.slice())。这是 ArkTS 响应式的一个血泪经验。5.4 图片不显示现象商品图片位置是空白或灰色方块。原因本地图片没放对目录或者网络图片没申请ohos.permission.INTERNET权限。解决本地图片放entry/src/main/resources/base/media/引用写$r(app.media.图片名)。网络图片在module.json5里加权限声明。5.5 金额计算出现浮点误差现象9.9 19.9 算出来是 29.799999999999997。原因JavaScript/ArkTS 的浮点数精度问题所有语言都有。解决金额统一用「分」为单位存整数显示时再除以 100。或者用toFixed(2)只解决显示不解决计算。生鲜超市客单价低误差不明显但订单量大了一定要改。6. 进阶技巧用元服务卡片把生鲜超市的复购率拉起来生鲜超市这类应用用户打开频率高但停留时间短最适合用鸿蒙的元服务卡片做桌面直达。元服务卡片不需要用户打开应用在桌面就能看到今日特价、购物车数量、待收货订单。这是鸿蒙区别于传统 App 的一个杀手锏也是「鸿蒙 元服务」这个词最近搜索量上涨的原因。具体做法在工程里新建一个 FormAbility配置卡片布局和刷新逻辑。卡片数据通过formBindingData传递定时刷新用updateForm。// entry/src/main/ets/formability/EntryFormAbility.ets import formBindingData from ohos.app.form.formBindingData; import FormExtensionAbility from ohos.app.form.FormExtensionAbility; import formProvider from ohos.app.form.formProvider; export default class EntryFormAbility extends FormExtensionAbility { onAddForm(want) { const data { title: 今日特价, goodsName: 有机菠菜, price: ¥9.9, cartCount: 3 }; return formBindingData.createFormBindingData(data); } onUpdateForm(formId: string) { const data { title: 今日特价, goodsName: 新鲜草莓, price: ¥19.9, cartCount: 5 }; formProvider.updateForm(formId, formBindingData.createFormBindingData(data)); } }逻辑说明onAddForm在卡片首次添加时调用返回初始数据。onUpdateForm在定时刷新或主动刷新时调用更新卡片内容。参数说明卡片布局在resources/base/profile/form_config.json里配置支持 2x2、2x4、4x4 等尺寸。生鲜超市用 2x2 展示一个特价商品最合适4x4 可以展示购物车摘要。验证方法编译安装后在桌面长按应用图标选择「服务卡片」添加后观察是否显示数据。修改onUpdateForm里的数据等刷新周期默认 30 分钟或手动触发看卡片是否更新。我自己的习惯是每做完一个页面先在预览器里跑一遍再用真机跑一遍。预览器对 router 和 AppStorage 的支持不完整很多问题只有真机才暴露。生鲜超市这个项目不大但五脏俱全把路由、状态、列表、卡片都串了一遍。做完之后再去看「鸿蒙应用开发底部导航栏」或者「鸿蒙 布局 relativecontainer flex tabs」这些热搜词你会发现底层逻辑是通的。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询