为一篇中文技术文章绘制一张 sketch-notes 风格的框架图,主题是“HarmonyOS7 商

前言

商品卡片看起来很小,但它经常是电商页面里最容易变复杂的组件。

一开始只有图片、标题、价格。过几天运营说要加标签,产品说要加库存,设计又补一个收藏按钮。要是 ColumnRow 一开始没有分工,后面每加一个字段,布局都要跟着抖一下。

商品卡片不是把内容塞进去就完事,重点是先把信息层级定住。

这篇用 HarmonyOS7 的 ArkUI 写一个商品卡片。代码不追求花哨,重点看三件事:图片区怎么固定,信息区怎么伸缩,操作区怎么跟状态绑定。

为什么这个问题经常被写乱

商品卡片用 Column 和 Row 写清楚 这类内容很容易被写成“代码能跑就算讲完了”,但对初学者来说,这恰恰是最不够的地方。真正让人卡住的,往往不是某个组件名记不住,而是不知道这段代码为什么要这样拆、状态为什么要这样放、以后需求变化时应该从哪里改。

所以这篇文章不只想给你一个能跑的例子,更想把背后的判断过程讲清楚。你只要把这个判断过程吃透,后面自己改页面、补需求、查问题时,心里会稳很多。

先把卡片拆成三块

为一篇中文技术文章绘制一张 sketch-notes 风格的流程图,内容是 HarmonyOS7 A

我通常会把商品卡片拆成这三层:

区域 负责内容 写代码时要注意
图片区 商品图、角标、售罄遮罩 尺寸稳定,不跟文案一起变化
信息区 标题、副标题、标签、价格 长文案要截断,不挤压按钮
操作区 收藏、加购、库存提示 状态和按钮可用性要来自同一份数据

Row 适合处理图片和文字区域的横向关系,Column 适合处理右侧信息的纵向节奏。不要一上来就嵌很多层,先让结构读起来像页面本身。

我的经验是:商品卡片后期难维护,通常不是因为样式复杂,而是因为每块信息的责任没有分清楚。

先把页面目标想清楚

在真正写代码之前,先别急着盯着 API。更有用的做法是先想清楚:这个页面到底想解决什么问题,用户最在意的反馈是什么,哪些状态必须一直保持一致。

当你先把这条主线想明白,再回头看组件和状态设计,很多选择都会顺理成章。对小白来说,这一步尤其重要,因为它能帮你从“照着抄”慢慢过渡到“看得懂、改得动”。

完整 ArkUI 示例

下面这个例子模拟一个商品卡片:左边是商品图占位和角标,右边是标题、卖点、标签、价格和收藏按钮,底部还有一个加购按钮。

interface ProductCardItem {
  id: number
  title: string
  subTitle: string
  price: number
  marketPrice: number
  stock: number
  tags: string[]
  favorite: boolean
}

@Entry
@Component
struct ProductCardPage {
  @State product: ProductCardItem = {
    id: 1001,
    title: 'HarmonyOS7 开发者双肩包',
    subTitle: '防泼水面料,适合通勤和短途出差',
    price: 199,
    marketPrice: 269,
    stock: 8,
    tags: ['限时直降', '包邮'],
    favorite: false
  }

  private formatPrice(value: number): string {
    return `¥${value.toFixed(2)}`
  }

  build() {
    Column() {
      Row({ space: 12 }) {
        Stack({ alignContent: Alignment.TopStart }) {
          Rect()
            .width(104)
            .height(104)
            .fill('#EAF2FF')
            .radius(10)
          Text('新品')
            .fontSize(11)
            .fontColor(Color.White)
            .backgroundColor('#E53935')
            .padding({ left: 6, right: 6, top: 3, bottom: 3 })
            .borderRadius({ topLeft: 10, bottomRight: 8 })
        }
        .width(104)
        .height(104)

        Column({ space: 8 }) {
          Text(this.product.title)
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .maxLines(2)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
          Text(this.product.subTitle)
            .fontSize(12)
            .fontColor('#666666')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })

          Row({ space: 6 }) {
            ForEach(this.product.tags, (tag: string) => {
              Text(tag)
                .fontSize(11)
                .fontColor('#C62828')
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .backgroundColor('#FFF0F0')
                .borderRadius(4)
            }, (tag: string) => tag)
          }

          Row() {
            Text(this.formatPrice(this.product.price))
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#D32F2F')
            Text(this.formatPrice(this.product.marketPrice))
              .fontSize(12)
              .fontColor('#999999')
              .decoration({ type: TextDecorationType.LineThrough, color: '#999999' })
            Blank()
            Button(this.product.favorite ? '已收藏' : '收藏')
              .height(30)
              .fontSize(12)
              .type(ButtonType.Capsule)
              .onClick(() => {
                this.product.favorite = !this.product.favorite
              })
          }
          .width('100%')
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
      }
      .padding(14)
      .backgroundColor(Color.White)
      .borderRadius(12)
      .shadow({ radius: 10, color: '#14000000', offsetY: 4 })

      Button(this.product.stock > 0 ? `加入购物车 · 剩余 ${this.product.stock}` : '暂时缺货')
        .width('100%')
        .margin({ top: 12 })
        .enabled(this.product.stock > 0)
        .onClick(() => {
          if (this.product.stock > 0) {
            this.product.stock -= 1
          }
        })
    }
    .padding(16)
    .backgroundColor('#F5F7FA')
    .width('100%')
  }
}

为一篇中文技术文章绘制一张 sketch-notes 风格的对比图,主题是“商品卡片写乱 vs 写清

把关键代码一段段拆开

ProductCardItem 是这篇代码的起点。标题、价格、库存、标签、收藏状态都放在这里,页面就不用从按钮文字里反推业务状态。后面接接口时,也更容易和后端字段对齐。

Row({ space: 12 }) 负责卡片主体的横向布局。左边图片固定 104vp,右边信息区使用 layoutWeight(1) 吃掉剩余空间。这样标题再长,也只会在右侧区域内换行或省略,不会把卡片撑坏。

Column({ space: 8 }) 管右侧文案的纵向节奏。标题、副标题、标签、价格各占一层,代码读起来和视觉层级是一致的。

收藏按钮直接修改 this.product.favorite,加购按钮直接依赖 this.product.stock一个状态字段最好同时决定文案、样式和交互可用性,这样页面不会出现“显示缺货但还能点击”的尴尬问题。

放到真实项目里,我会补这几件事

商品图这里用 Rect 做占位,是为了把布局讲清楚。真实项目里可以换成 Image,但一定要给图片区域稳定尺寸,避免图片加载前后卡片高度跳动。

标签数量也要控制。两个标签还好,如果运营一次塞五六个,价格行会被挤下去。比较稳的做法是最多展示两个,剩下的在详情页展示。

还有一个容易忽略的点:加购不应该只扣本地库存。真实业务里要先走接口,接口成功后再更新库存;失败时给出明确提示。这个例子为了聚焦 UI 状态,只保留了本地演示逻辑。

新手最容易踩的坑

这一类示例最容易让人产生错觉:界面出来了,就以为已经掌握了。其实真正容易出问题的地方,通常都在效果之外,比如状态有没有收拢、失败后怎么兜底、以后要扩展时会不会牵一发动全身。

所以你练这篇内容时,别只看“现在能不能跑”,还要继续看“以后好不好改”。能把这个习惯养起来,你写出来的页面会比单纯照着示例拼出来的页面稳很多。

放进真实项目还要补什么

示例代码的重点是把核心思路讲明白,所以很多工程化细节会故意省掉。真正落到项目里时,你通常还要继续补接口联动、异常处理、边界保护、资源抽离,以及和其他页面状态之间的配合。

写在最后

比较稳的做法是分三步走:先把结构和职责立住,再把真实业务接进去,最后再优化视觉和交互体验。这样改出来的页面不只是“能演示”,而是真的更接近可以长期维护的业务代码。

Logo

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

更多推荐