鸿蒙 ArkTS 电商 Demo 闭环复盘:商品列表 → 详情加购 → 全局购物车持久化

鸿蒙 ArkTS 电商 Demo 闭环复盘:商品列表 → 详情加购 → 全局购物车持久化

项目:MyApplication 模块:entry 复盘主题:把原来"6 张写死卡片 + setPopParam 计数"的粗糙首页,重构为完整的电商闭环------商品浏览 / 搜索 / 详情 / 加购 / 全局购物车 / 持久化,覆盖 ArkTS V2 响应式(@ComponentV2 / @Param / @Event / @ObservedV2 / @Trace / @Local / @Type)、PersistenceV2、RelativeContainer 规则布局、HMRouter 传参等鸿蒙核心知识点。


一、为什么要做这次重构

之前的首页大致是这个样子:

  • 6 张硬编码的商品卡片,全部内嵌在 HomeTabComp 里用 @Builder 渲染
  • 点击商品 → 详情页加 / 减数量 → HMRouterMgr.setPopParam(count) → 首页 onResult 回调里 cartCount += addCount
  • 首页顶部角标只是一个孤立的整数,没有任何商品快照
  • 关 App 重启,购物车清零
  • 没有搜索、没有购物车页、没有真实图片

满足不了"一个能演示 ArkTS 真实能力的 Demo"的需求。所以这次围绕商品 → 购物车的核心链路重写,目标是:

  1. 首页展示丰富商品列表(搜索 / 真实图 / 标签 / 划线原价 / 销量 / 评分)
  2. 商品卡片 拆成独立 @ComponentV2 组件,通过 @Param / @Event 跟父级解耦
  3. 详情页RelativeContainer 做规则布局,演示 ArkUI 的相对定位能力
  4. 全局购物车状态 CartStatePersistenceV2 + @Type(Product) 持久化,关 App 重启购物车还在
  5. 购物车页面支持 +/- 一件一件改、左滑删全部、空状态、底部支付栏
  6. 首页角标 / 详情页加购 / 购物车页三处状态自动同步(全局 CartState 是唯一数据源)

二、整体架构思路

从一开始就坚持鸿蒙工程里常用的 Page / ViewModel / Controller 三层 + 全局响应式状态的搭配:

text 复制代码
HomeTabComp                ← 纯 UI(顶部问候 / 搜索 / 商品网格 / 角标)
   ├─ HomeViewModel        ← 仅 searchKeyword(cartCount 已迁全局)
   ├─ HomeController       ← getFilteredProducts / goDetail
   └─ ProductCardComp      ← V2 @Param product / @Event onTap
   
ProductDetailPage          ← 纯 UI(NavBar / 封面 / 信息卡 / 加购调节 / 底部按钮)
   ├─ ProductDetailViewModel  ← product / addCount / lifecycleLog
   └─ ProductDetailController ← initFromParam / initLifecycle / confirmAndBack

CartPage                   ← 纯 UI(NavBar / 空状态 or List / 底部支付)
   └─ CartController       ← increase / decrease / remove / goPay

viewmodel/CartState.ets    ← ★ 全局响应式 + PersistenceV2 持久化
   - items: Product[]      ← @Trace @Type(Product)
   - add / addN / removeOne / remove / clear
   - getGrouped() 实时聚合
   - getCartState() 单例工厂

关键决策 :让 CartState 作为唯一数据源,首页角标、详情页加购、购物车页通通围绕它转。这样任何一处变化,其他两处自动响应,不需要手动 propagate。

数据流:

text 复制代码
首页 HomeTabComp.cart.totalCount           ← 角标
   ↑
   读
   ↑
[全局] CartState ──→ PersistenceV2 ──→ Preferences 本地存储
   ↑       ↑
   写       写
   ↑       ↑
详情页 cart.addN(product, count)         购物车页 cart.add / removeOne / remove

三、Step 1:数据 Model 与本地图片

3.1 扩展 Product 字段

原来的 Product 只有 id / name / price / desc / tag,对一个电商卡片来说太薄。补到:

typescript 复制代码
export class Product {
  id: string = ''
  name: string = ''
  price: number = 0          // 当前价
  desc: string = ''
  image: string = ''         // 'images/1.png' 形如此
  originalPrice: number = 0  // 原价(用于划线展示折扣感)
  tag: string = ''           // '新品' / '热门' / '推荐' / ''
  rating: number = 0         // 评分 0~5
  sales: number = 0          // 销量
  stock: number = 0          // 库存
}

MOCK_PRODUCTS 从 6 条扩到 12 条,主题继续围绕鸿蒙书 / 课程。

3.2 图片资源策略:rawfile + 动态拼接

鸿蒙资源有两个目录:

目录 引用 适用
resources/base/media/ $r('app.media.xxx') 系统图标、多语言/多分辨率适配
resources/rawfile/ $rawfile('xxx.jpg') 业务数据图,支持动态路径拼接

商品图明显属于"按 id 动态选图"的场景,所以选 rawfile

css 复制代码
entry/src/main/resources/rawfile/images/1.png

Product.image 字段存相对路径 'images/1.png',渲染时:

typescript 复制代码
Image($rawfile(this.product.image))

阶段性占位时,所有商品先复用同一张 1.png------先跑通比好看重要,等 UI 稳定再批量换。

📖 资源分类与访问:developer.huawei.com/consumer/cn...


四、Step 2:商品卡片组件拆分(V2 父子通信)

4.1 为什么要拆

HomeTabComp 把卡片 UI 内嵌在一个 @Builder productCard(product) 里,导致:

  • 200+ 行 UI 全堆在一个文件
  • 卡片样式跟首页耦合,无法在搜索结果页 / 收藏夹复用
  • 子组件级别的样式与状态管理混乱

4.2 @ComponentV2 + @Param + @Event

ArkTS V2 引入了一套全新的组件 + 装饰器体系。父子组件通信的关键差异:

装饰器 作用 类比 V1
@Param 父传子的单向只读属性 @Prop
@Event 父传子的回调函数 直接传 function
@Once 配合 @Param,只接收一次初始值 ---
@Local 子组件自己的内部状态 @State
@Consumer / @Provider 跨层级响应式注入 @Consume / @Provide

新建的 ProductCardComp

typescript 复制代码
@ComponentV2
export struct ProductCardComp {
  @Param product: Product = new Product()
  @Event onTap: (p: Product) => void = (_p: Product) => {}
  @Consumer() theme: ThemeState = new ThemeState()

  build() {
    Stack({ alignContent: Alignment.TopEnd }) {
      Column({ space: 6 }) {
        Image($rawfile(this.product.image))...
        Text(this.product.name)...
        // 价格 / 评分 / 销量 ...
      }
      // tag 角标(右上角)
      if (this.product.tag) { Text(...) }
    }
    .onClick(() => { this.onTap(this.product) })
  }
}

4.3 父组件调用:必须是对象字面量

V2 组件调用不能按位置传参 ,必须传一个对象字面量 ,key 与子组件的 @Param / @Event 字段名一一对应:

typescript 复制代码
// ❌ 错的,编译报 'Type has no properties in common with...'
ProductCardComp(product, this.vm.searchKeyword)

// ✓ 对的
ProductCardComp({
  product: product,
  onTap: (p: Product) => { this.controller.goDetail(p) }
})

📖 @ComponentV2:developer.huawei.com/consumer/cn... 📖 @Param:developer.huawei.com/consumer/cn... 📖 @Event:developer.huawei.com/consumer/cn...


五、Step 3:首页改造(搜索 + 角标 + 网格)

5.1 顶部栏 + 搜索栏

HomeTabComp.build 简化结构:

text 复制代码
Column
├─ Row 顶部栏
│   ├─ Column { '你好,xxx' / '欢迎来到精选课程' }
│   ├─ Blank()
│   └─ Stack 购物车角标(点击 push 到 CartPage)
├─ Search { value, placeholder, onChange }
└─ Grid 商品网格(columnsTemplate '1fr 1fr')

Search 组件双向绑定 vm.searchKeyword

typescript 复制代码
Search({ value: this.vm.searchKeyword, placeholder: '搜索商品 / 教程' })
  .onChange((v: string) => { this.vm.searchKeyword = v })

5.2 搜索过滤:V2 响应式自动驱动

业务逻辑放 HomeController.getFilteredProducts()

typescript 复制代码
getFilteredProducts(): Product[] {
  const kw = this.vm.searchKeyword.trim()
  if (!kw) return MOCK_PRODUCTS
  return MOCK_PRODUCTS.filter(p => p.name.includes(kw) || p.desc.includes(kw))
}

Grid 的数据源直接调它:

typescript 复制代码
ForEach(
  this.controller.getFilteredProducts(),
  (product: Product) => { GridItem() { ProductCardComp({...}) } },
  (p: Product) => p.id   // ★ keyGenerator 推荐补,V2 下能避免无谓重渲染
)

注意:不需要 @Monitor 。V2 下 build 内部访问了 vm.searchKeyword(getFilteredProducts 内部用到),自动收集依赖;关键词变化 → build 重跑 → 列表过滤。

我一开始写成了:

typescript 复制代码
// ❌ 错误示范
@Monitor('this.vm.searchKeyword')
search() {
  this.controller.getFilteredProducts()  // 调了又不接返回值,等于没用
}

两个错:路径不该带 this.,更根本的是根本没必要用 @Monitor@Monitor 是用来在字段变化时执行"副作用"(自动持久化、自动埋点等),不是用来驱动渲染。

📖 @Monitor:developer.huawei.com/consumer/cn...

5.3 购物车角标:读全局 CartState

typescript 复制代码
@Local cart: CartState = getCartState()  // 全局单例

// 角标
if (this.cart.totalCount > 0) {
  Text(`${this.cart.totalCount}`)
    .backgroundColor(this.theme.danger)
    .borderRadius(8)
    .offset({ x: 4, y: -4 })
}

详情页加购 → 全局 CartState.items 变化 → @Trace 通知 → 角标自动重渲染。完全不需要 setPopParam / onResult / cartCount 字段这一套老路。


六、Step 4:详情页 RelativeContainer 规则布局

6.1 为什么用 RelativeContainer

详情页的"商品信息卡"有 6 个元素需要互相对位:

  • 价格在左上
  • 原价紧贴价格右侧(划线)
  • 标签在右上
  • 商品名在价格下方
  • 元信息(⭐ / 销量 / 库存)在商品名下方
  • 描述在元信息下方撑满左右

用嵌套 Column / Row 也能实现,但 RelativeContainer 更直观------每个子元素声明自己的锚点,符合"规则布局"思维。

6.2 核心约定

概念 说明
子组件 id 必须 .id('xxx'),否则不参与布局
容器锚点 保留 id '__container__' 代表容器自己
alignRules 声明对齐规则:垂直 top/center/bottom、水平 start/middle/end(也支持 left/right,但 start/end 适配 RTL)
VerticalAlign Top / Center / Bottom
HorizontalAlign Start / Center / End

6.3 关键陷阱

⚠️ 三个必踩的坑

  1. 容器高度不能 'auto'------文档明确指出:宽高为 auto 时,子组件以容器为锚点的方向自适应不生效
  2. 每个子组件至少要有一个水平 + 一个垂直锚点,否则会塌缩到中间
  3. 不要写循环依赖------A 锚 B、B 又锚 A 会被忽略并报警告

6.4 实战代码片段

typescript 复制代码
RelativeContainer() {
  // ① 当前价(左上)
  Text(`¥${this.vm.product.price}`)
    .id('price')
    .alignRules({
      top: { anchor: '__container__', align: VerticalAlign.Top },
      start: { anchor: '__container__', align: HorizontalAlign.Start }
    })

  // ② 原价划线(紧贴 price 右侧)
  if (this.vm.product.originalPrice > this.vm.product.price) {
    Text(`¥${this.vm.product.originalPrice}`)
      .id('origPrice')
      .decoration({ type: TextDecorationType.LineThrough })
      .alignRules({
        bottom: { anchor: 'price', align: VerticalAlign.Bottom },
        start: { anchor: 'price', align: HorizontalAlign.End }
      })
      .margin({ left: 8 })
  }

  // ③ 角标(右上)
  if (this.vm.product.tag) {
    Text(this.vm.product.tag)
      .id('tag')
      .alignRules({
        top: { anchor: '__container__', align: VerticalAlign.Top },
        end: { anchor: '__container__', align: HorizontalAlign.End }
      })
  }

  // ④ 商品名(price 下方)
  Text(this.vm.product.name)
    .id('name')
    .alignRules({
      top: { anchor: 'price', align: VerticalAlign.Bottom },
      start: { anchor: '__container__', align: HorizontalAlign.Start }
    })
    .margin({ top: 12 })

  // ⑤ 元信息(name 下方)
  Text(`⭐ ${this.vm.product.rating}    已售 ${this.vm.product.sales}    库存 ${this.vm.product.stock}`)
    .id('meta')
    .alignRules({
      top: { anchor: 'name', align: VerticalAlign.Bottom },
      start: { anchor: '__container__', align: HorizontalAlign.Start }
    })
    .margin({ top: 8 })

  // ⑥ 描述(meta 下方,撑满左右)
  Text(this.vm.product.desc)
    .id('desc')
    .alignRules({
      top: { anchor: 'meta', align: VerticalAlign.Bottom },
      start: { anchor: '__container__', align: HorizontalAlign.Start },
      end: { anchor: '__container__', align: HorizontalAlign.End }
    })
    .margin({ top: 10 })
}
.width('100%')
.height(180)              // ★ 必须给固定高度
.padding(16)
.backgroundColor(this.theme.surface)
.borderRadius(12)

6.5 详情页其他改造

  • 删除原来用于学习的"路由传参展示"和"生命周期日志"两个区块
  • 封面图从灰色占位换成 Image($rawfile(this.vm.product.image))
  • 加购按钮 .enabled(addCount > 0 && product.stock > 0),库存为 0 时禁用
  • 加购数量调节区的 + / - 按钮必须ButtonType.Normal + padding(0),否则默认 padding 会把内容挤掉(详见踩坑章节)

6.6 路由参数怎么取

HMRouter 的路由参数不是通过 V2 的 @Param 自动注入的 ------@Param 是父子组件通信,路由参数得在详情页里用 HMRouterMgr.getCurrentParam() 显式取:

typescript 复制代码
// ProductDetailController.ets
initFromParam(): void {
  const param = HMRouterMgr.getCurrentParam()
  if (param) {
    this.vm.product = param as Product
  }
}

// ProductDetailPage.ets
aboutToAppear(): void {
  this.controller.initFromParam()
}

刚开始我直接写了 @Param product: Product,结果 product 永远是默认 new Product(),价格名字全空------这是把"组件通信"和"路由通信"混了。

📖 RelativeContainer:developer.huawei.com/consumer/cn...


七、Step 5:全局购物车状态(PersistenceV2 + @Type)

7.1 设计哲学:不查重,纯 push,展示时聚合

最初我考虑过 Map<productId, count> 的方案,但发现:

  1. Map 在 PersistenceV2 里不可序列化(必须转成数组)
  2. 需要查重逻辑(已存在 → count++,不存在 → 新增)
  3. 同 id 的多种"加购形态"(不同时间、不同来源)丢失

最终方案极简:

items: Product[],加购就 push(不查重),展示时按 productId 聚合。

typescript 复制代码
@ObservedV2
export class CartState {
  @Trace @Type(Product) items: Product[] = []

  get totalCount(): number { return this.items.length }
  get totalAmount(): number { return this.items.reduce((s, p) => s + p.price, 0) }

  add(p: Product): void { this.items.push(p) }
  addN(p: Product, n: number): void { for (let i = 0; i < n; i++) this.items.push(p) }
  removeOne(id: string): void { /* splice 第一个匹配 */ }
  remove(id: string): void { this.items = this.items.filter(p => p.id !== id) }
  clear(): void { this.items = [] }

  // 展示用聚合:相同 id 合并成 { product, count }
  getGrouped(): GroupedCartItem[] { /* Map 实时聚合 */ }
}

export function getCartState(): CartState {
  return PersistenceV2.connect(CartState, 'CartState', () => new CartState())!
}

7.2 @Type(Product) 是什么

PersistenceV2 把状态序列化到本地存储时,对数组里装的"普通对象"是无能为力的------反序列化后会变成 plain object,丢失类型信息(如果 Product 上有方法、有 getter,这些都没了)。

@Type(Product) 装饰器告诉框架:

"这个字段的数组元素是 Product 类,反序列化时还原成 Product 实例。"

写法:

typescript 复制代码
@Trace @Type(Product) items: Product[] = []

一行装饰器,复杂度可控。

📖 PersistenceV2:gitee.com/openharmony... 📖 @Type:gitee.com/openharmony...

7.3 单例工厂

typescript 复制代码
export function getCartState(): CartState {
  return PersistenceV2.connect(CartState, 'CartState', () => new CartState())!
}

任何地方调它都拿到同一个 CartState 实例 ,不需要 @Provider / @Consumer 透传。在响应式组件里用 @Local cart: CartState = getCartState() 装一下,UI 就能响应字段变化。

7.4 删除语义:remove vs removeOne

  • removeOne(productId)减一件 (splice 第一个匹配项)------ 购物车页的 - 按钮用
  • remove(productId)删除全部相同 id ------ 购物车页左滑出现的红色"删除"按钮用

这跟主流电商体验一致:

  • 长按 / 左滑 = 我不要这个商品了
  • - = 我少买一件

八、Step 6:购物车页面(List + 聚合 + 左滑)

8.1 页面结构

text 复制代码
Column
├─ Row NavBar(← 返回 / '购物车' 标题)
├─ if items.length === 0 → Column 空状态(🛒 emoji + '去逛逛' 按钮)
│  else → List
│    └─ ForEach(cart.getGrouped(), item => ListItem { Row { 图 | 名/价 | -/数/+ } }.swipeAction)
└─ Row 底部支付栏(合计 / 件数 / '去支付' 按钮)

8.2 聚合展示

typescript 复制代码
ForEach(this.cart.getGrouped(), (item: GroupedCartItem) => {
  ListItem() {
    Row({ space: 12 }) {
      Image($rawfile(item.product.image)).width(80).height(80)
      Column { Text(item.product.name); Text(`¥${item.product.price}`) }.layoutWeight(1)
      Row { 
        Button('-').onClick(() => this.controller.decrease(item.product.id))
        Text(`${item.count}`)
        Button('+').onClick(() => this.controller.increase(item.product))
      }
    }
  }
  .swipeAction({ end: this.deleteBuilder(item.product.id) })
}, (item: GroupedCartItem) => item.product.id)

注意 item.product 是 Product 实例(拜 @Type(Product) 所赐),所以 increase 直接传 item.product 进去 cart.add() 即可。

8.3 左滑删除

ListItem.swipeAction({ end: Builder })

typescript 复制代码
@Builder
deleteBuilder(productId: string) {
  Row() {
    Button('删除')
      .backgroundColor(this.theme.danger)
      .height(80)
      .onClick(() => this.controller.remove(productId))
  }
  .padding({ left: 12 })
  .height('100%')
}

📖 ListItemSwipeAction:developer.huawei.com/consumer/cn...


九、闭环串联:一次完整加购的数据流

整个项目最 satisfying 的地方就是 三处状态自动同步。一个用户故事走完:

text 复制代码
1. 用户在首页搜索 "ArkTS" 
   → HomeViewModel.searchKeyword = 'ArkTS'
   → @Trace 通知 build 重跑
   → ForEach 重算 controller.getFilteredProducts()
   → Grid 只展示匹配商品

2. 点击 "ArkTS 快速入门" 卡片
   → HomeController.goDetail(product)
   → HMRouterMgr.push({ pageUrl, param: product })

3. 详情页 aboutToAppear
   → ProductDetailController.initFromParam()
   → getCurrentParam() → vm.product = product

4. 点 + 三次
   → ProductDetailController.addToCart()
   → vm.addCount: 0 → 1 → 2 → 3
   → @Trace addCount → 加购按钮 label 实时变 "加入购物车(3)"

5. 点 "加入购物车(3)"
   → ProductDetailController.confirmAndBack()
   → cart.addN(product, 3)         ← 全局 CartState push 3 次
   → PersistenceV2 自动序列化到本地
   → HMRouterMgr.pop() 回首页

6. 首页可见
   → @Local cart: CartState 是同一个单例引用
   → cart.totalCount 从 0 → 3
   → @Trace items → 角标自动重渲染显示 "3"

7. 点角标 → 进购物车页
   → CartPage @Local cart = getCartState() 拿到同一个单例
   → cart.items.length > 0 → 走 List 分支
   → cart.getGrouped() → [{ product: ArkTS, count: 3 }]
   → 列表显示 1 行,数量 3

8. 点 - 按钮
   → CartController.decrease(productId)
   → cart.removeOne(id) → splice 第一项
   → @Trace items → ForEach 重算 → count 显示 2,底部合计减一份金额

9. 关 App、重启
   → PersistenceV2 从本地反序列化 → @Type(Product) 把 items 还原成 Product 实例
   → 购物车状态完全恢复

核心 :从头到尾没有显式 setState / 没有事件总线 / 没有手动 propagate。整个链路靠 ArkTS V2 响应式 + PersistenceV2 持久化天然 wire 起来。


十、踩坑总结

10.1 V2 组件调用必须用对象字面量

typescript 复制代码
ProductCardComp(product, kw)          // ❌ 'Type has no properties in common...'
ProductCardComp({ product, onTap })   // ✓

10.2 @Event 别加 ? 让它 optional

typescript 复制代码
@Event onTap?: (p: Product) => void   // ❌ 调用时还要判空,跟默认 noop 矛盾
@Event onTap: (p: Product) => void = (_p) => {}   // ✓

10.3 HMRouter 路由参数不是 @Param

typescript 复制代码
@Param product: Product = new Product()   // ❌ 永远是默认空对象

必须 HMRouterMgr.getCurrentParam() as ProductaboutToAppear 里取。

10.4 @Monitor 的路径不带 this.

typescript 复制代码
@Monitor('this.vm.searchKeyword')    // ❌
@Monitor('vm.searchKeyword')         // ✓

而且大多数情况下根本不需要 @Monitor------V2 build 自动收集依赖。@Monitor 留给"字段变化时做副作用"的场景(如自动持久化、自动埋点)。

10.5 RelativeContainer 高度必须固定

'auto' 时子组件以容器为锚点的方向自适应失效 。给 .height(180) 或被父级 layout weight 撑开。

10.6 RelativeContainer 子组件忘记 .id()

不参与布局规则,直接被忽略。

10.7 Button 小尺寸文字被裁

这是最隐蔽的一个坑 。默认 typeCapsule(旧 API)或 ROUNDED_RECTANGLE(API 18+),自带较大的内部 paddingwidth(28).height(28).fontSize(16) 的 "+/-" 按钮文字会被吞掉。

修复:

typescript 复制代码
Button('-')
  .type(ButtonType.Normal)   // ★ 关掉胶囊默认样式
  .borderRadius(14)          // ★ 自己控圆角(视觉仍像圆形)
  .padding(0)                // ★ 清掉默认 padding
  .width(28).height(28).fontSize(16)

字号大小不是主因------padding 才是元凶。

10.8 PersistenceV2 + 类对象数组必须 @Type

typescript 复制代码
@Trace items: Product[] = []                   // ❌ 反序列化后变 plain object
@Trace @Type(Product) items: Product[] = []   // ✓

10.9 数组改动用赋值新数组,比 splice 更稳

typescript 复制代码
// ❌ 有时不触发 V2 重渲染
this.items.splice(idx, 1)

// ✓ 整体赋值新数组,保证 ForEach 重算 keyGenerator
this.items = this.items.filter(p => p.id !== id)

10.10 ForEach 一定补 keyGenerator

typescript 复制代码
ForEach(list, (p) => GridItem() { ProductCardComp({ product: p, ... }) }, (p: Product) => p.id)
//                                                                       ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
//                                                                       推荐补,避免无谓重渲染

十一、目录结构对照

最终 entry 模块下跟本次重构相关的文件:

text 复制代码
entry/src/main/
├─ ets/
│  ├─ pages/
│  │  ├─ CartPage.ets                   ★ 新建:购物车页
│  │  ├─ ProductDetailPage.ets          重写:RelativeContainer 规则布局
│  │  └─ HomePage.ets                   不变(顶层容器)
│  ├─ components/
│  │  ├─ ProductCardComp.ets            ★ 新建:商品卡片(@ComponentV2)
│  │  └─ HomeTabComp.ets                重写:搜索/角标/网格
│  ├─ controller/
│  │  ├─ CartController.ets             ★ 新建:代理 CartState
│  │  ├─ ProductDetailController.ets    改:confirmAndBack → cart.addN
│  │  └─ HomeController.ets             改:删 onResult 回调
│  ├─ viewmodel/
│  │  ├─ CartState.ets                  ★ 新建:全局购物车(PersistenceV2)
│  │  ├─ ProductDetailViewModel.ets     不变
│  │  └─ HomeViewModel.ets              改:删 cartCount,只剩 searchKeyword
│  ├─ models/
│  │  └─ productModel.ets               扩展字段 + MOCK 数据
│  └─ constants/
│     └─ EntryRoutes.ets                加 PAGE_CART 常量
└─ resources/
   └─ rawfile/images/1.png              占位商品图

跨模块复用:

  • KeyboardControllerchat/controller/ 提到 common/utils/,通过 common/Index.ets 暴露给 chat 和 entry 共用

十二、关键文档链接汇总

ArkTS V2 状态管理:

持久化:

布局 / 组件:

资源 / 权限:

路由:


十三、下一步可以做什么

这次只跑通了"加购"的核心闭环。后面可以继续完善:

  1. 支付页 / 订单页 :现在 goPay 只弹 Toast,可以做真实的订单流
  2. 收藏功能 :跟 CartState 同款套路,再来一个 FavoriteState
  3. 商品详情页轮播图:单图改成 Swiper + 多张
  4. 首页分类 / 推荐 Tab:把现有的"商品网格"升级成"分类 + 推荐"两段式
  5. 下拉刷新 + 上拉加载更多 :List 接 Refresh 组件
  6. 真实接口 :MOCK_PRODUCTS 替换为 /api/products 真实请求(项目里已有 HttpUtil)
  7. 搜索历史 + 热门搜索:搜索框点击展开历史词
  8. 网络图片切换 :把本地 1.png 占位换成网络 URL(已经预留了 image.startsWith('http') 兼容判断点)

十四、写在最后

这次重构最大的收获不是某个具体的 API,而是体会到了 ArkTS V2 响应式 + PersistenceV2 持久化 这两套机制是怎么自然 wire 在一起的。

只要:

  • 全局状态用 @ObservedV2 + @Trace
  • 通过 PersistenceV2.connect 拿单例
  • 在组件里用 @Local 接住引用
  • 类对象数组别忘 @Type

整个数据流就不需要事件总线、不需要 setPopParam 回调、不需要手动 propagate。任何地方写一下,所有读它的地方自动响应。这跟 React + Zustand / Vue + Pinia 给我的体验是一模一样的------区别只是装饰器名字。

而且鸿蒙最让我觉得"省心"的一点:持久化跟响应式是一套话语体系 。在 PersistenceV2.connect 后的对象上 cart.items.push(...),你完全感觉不到自己在写文件存储,UI 和磁盘同步发生。这是过去 SharedPreferences / Realm / Room 时代很难有的体验。

相关推荐
甲维斯1 小时前
Opus4.8 才是真的夯爆了!实测 9个例子表现出众!
前端·人工智能
Doris_20232 小时前
eslint
前端·架构·前端框架
_喆2 小时前
视频切片上传
前端
前端拷贝猿2 小时前
微信绑定流程
前端
ZC跨境爬虫2 小时前
跟着 MDN 学CSS day_51:支持旧浏览器的布局策略
前端·css·html·tensorflow·媒体
Larcher2 小时前
从 0 到 1:Node.js 调用 AI API 的完整避坑指南
前端·javascript·css
ricardo19732 小时前
Web Worker + 时间切片:破解主线程阻塞的两种武器
前端·面试
wuhen_n3 小时前
LangGraph 入门:AI Agent 工作流可视化编排
前端·langchain·ai编程