从一个推荐问题卡片重构,聊聊 HarmonyOS UI 复刻里最容易踩的坑

最近在重构一个聊天页里的推荐问题卡片。

这个卡片看起来不复杂:上面是一个带机器人头像的 Header,下面是 4 条推荐问题。点击问题后,把问题填入输入框并发送消息。

但真正开始对照设计稿还原时,发现它并不是“调几个宽高和颜色”这么简单。尤其是渐变背景、渐变文字、卡片裁剪、头像悬浮、列表高度这些细节,很容易一边改 UI,一边把原来的业务逻辑也改乱。

这篇文章就用这个推荐问题卡片作为例子,记录一下这次重构里踩到的几个点。

先看原来的问题

这个卡片的核心业务逻辑其实很简单:

ForEach(
  this.vm.quickPhrases.slice(0, 4),
  (question: string) => {
    Row() {
      Text(question)
      Image($r('app.media.ic_arrow_right_thin'))
    }
    .onClick(() => {
      this.vm.userInput = question
      this.vm.sendMessage(true)
    })
  }
)

也就是说:

  • 推荐问题来自 vm.quickPhrases
  • 最多展示 4 条
  • 点击后设置 userInput
  • 然后调用 sendMessage(true)

这部分本身没有问题。

真正的问题出现在 UI 重构时:我一开始用了函数去动态计算列表高度,但后来发现这个列表本身就是固定只展示 4 条,动态计算反而把问题复杂化了。

如果业务已经明确“最多展示 4 条”,那列表高度完全可以围绕这 4 条去设计,而不是额外引入一套高度计算逻辑。

UI 重构时最重要的一点是:不要为了还原样式,顺手改掉原来的业务结构。

UI 重构不是重写业务

这次重构里,我尽量保留了原来的业务逻辑:

this.vm.quickPhrases.slice(0, 4)

这一句没有动。

点击逻辑也没有动:

this.vm.userInput = question
this.vm.sendMessage(true)

真正改的是展示层:

Text(question)
  .fontSize(14)
  .lineHeight(14)
  .fontWeight(FontWeight.Regular)
  .fontColor(AgentTheme.colorTextHeavy)
  .maxLines(2)
  .textOverflow({
    overflow: TextOverflow.Ellipsis
  })

这里有一个小变化:之前问题文本是 maxLines(1),现在改成了 maxLines(2)

这个变化不是业务变化,而是 UI 变化。因为设计稿里推荐问题允许两行展示,如果还保持一行,就会导致长问题被过早截断。

所以我的理解是:

  • 数据来源不动
  • 循环方式不动
  • 点击行为不动
  • 只根据设计稿调整展示规则

这才是一次比较安全的 UI 重构。

不要盲目相信 AI:UI 复刻里也会有幻觉

这次还有一个很现实的感受:用 AI 辅助写 UI 代码时,不能完全相信它。

哪怕我已经明确告诉 AI:

只重构 UI,不要动原来的业务逻辑。

它还是可能会偷偷改掉一些看起来“不重要”的东西。

比如原来的列表逻辑其实很明确:

this.vm.quickPhrases.slice(0, 4)

也就是最多展示 4 条推荐问题。

但 AI 很容易觉得这里需要“更通用”,于是帮你加动态高度计算,或者封装一个根据列表数量计算高度的函数。

问题是,这个组件在当前设计里就是固定展示 4 条。动态计算不但没有提升代码质量,反而让代码变复杂了。

还有一个例子是文本行数。

设计稿里推荐问题可以展示两行,所以这里应该是:

.maxLines(2)

但 AI 可能会根据常见列表项习惯,自动写成:

.maxLines(1)

从代码上看,这不算明显错误;但从 UI 复刻角度看,它就是错的。因为一行会导致长问题提前截断,和设计稿不一致。

所以我后来意识到,AI 辅助 UI 复刻时最危险的不是语法错误,而是这种“看起来合理,但和当前需求不一致”的改动。

它可能会:

  • 把固定高度改成动态高度
  • 把固定展示 4 条改成适配任意数量
  • maxLines(2) 改成常见的 maxLines(1)
  • 为了代码“优雅”额外封装函数
  • 为了“通用性”改变原本简单直接的结构
  • 在没理解设计稿的情况下,自动补一些它认为合理的样式

这些都属于 UI 复刻里的幻觉。

因为 UI 复刻不是自由发挥,它的目标不是写一个“差不多能用”的组件,而是尽量贴近设计稿。

所以和 AI 协作时,我觉得要反复强调几件事:

不要改数据来源。
不要改循环逻辑。
不要改点击逻辑。
不要把写死的设计改成动态计算。
不要为了通用性牺牲还原度。
除非设计稿明确不同,否则保留原来的业务代码。

但光说还不够,最后还是要自己逐行检查。

尤其是这些地方:

this.vm.quickPhrases.slice(0, 4)
.maxLines(2)
.onClick(() => {
  this.vm.userInput = question
  this.vm.sendMessage(true)
})

这些代码看起来普通,但它们就是组件的业务边界。

UI 可以重构,布局可以调整,渐变可以慢慢调,但这些边界不能随便变。

这也是我这次学到的一个经验:AI 可以帮我更快写代码,但不能替我判断设计意图。复刻 UI 的时候,最终还是要靠自己确认:

  • 这个改动是不是设计稿要求的?
  • 这个改动有没有影响原业务逻辑?
  • 这个封装是不是真的必要?
  • 这个“优化”是不是只是 AI 自己脑补出来的?

AI 很适合帮忙生成第一版代码,也适合解释复杂属性,比如 blendMode、渐变、裁剪、层级关系。

但涉及业务边界和设计还原时,不能盲信。

为什么渐变色这么难还原

这次最折磨的是 Header 背景渐变。

设计稿里看起来只是一个很淡的蓝紫色背景,但实际实现时会发现它不是单纯的一个颜色,而是多层效果叠出来的:

Column()
  .width('100%')
  .height(160)
  .backgroundColor('#66FFFFFF')
  .linearGradient({
    angle: 118,
    colors: [
      ['#1A7385ED', 0.0],
      ['#182793FF', 0.30],
      ['#0D00AAFF', 0.59],
      ['#0000AAFF', 0.81],
      ['#0000AAFF', 1.0]
    ]
  })

这里最容易看不懂的是颜色前面的两位透明度。

例如:

#66FFFFFF
#1A7385ED
#0000AAFF

它们不是普通的 RGB,而是 ARGB:

#66FFFFFF  表示 40% 透明度的白色
#1A7385ED  表示 10% 透明度的蓝紫色
#0000AAFF  表示完全透明的蓝色

也就是说,设计稿里的颜色很多时候不是“蓝色”,而是“带透明度的蓝色”。

如果只看后六位,很容易误判颜色浓度。

比如:

#7385ED

看起来是一个很明显的蓝紫色。

但实际设计稿用的是:

#1A7385ED

它只有大约 10% 的透明度,所以最终看到的是非常淡的蓝紫雾感。

单层渐变不够,就用图层思维

一开始我尝试只用一层渐变去还原 Header。

但很快遇到一个问题:设计稿里上半部分靠近机器人之前已经比较白了,而下半部分的蓝色又要延伸到文字附近。

这意味着它不是一个简单的从左到右渐变。

最后更合理的做法是拆成两层。

第一层负责蓝紫色底色:

Column()
  .width('100%')
  .height(160)
  .backgroundColor('#66FFFFFF')
  .linearGradient({
    angle: 118,
    colors: [
      ['#1A7385ED', 0.0],
      ['#182793FF', 0.30],
      ['#0D00AAFF', 0.59],
      ['#0000AAFF', 0.81],
      ['#0000AAFF', 1.0]
    ]
  })

第二层负责右上角的白色雾化:

Column()
  .width('100%')
  .height(160)
  .linearGradient({
    angle: 65,
    colors: [
      ['#00FFFFFF', 0.0],
      ['#00FFFFFF', 0.34],
      ['#26FFFFFF', 0.48],
      ['#66FFFFFF', 0.62],
      ['#B3FFFFFF', 0.78],
      ['#F2FFFFFF', 0.92],
      ['#FFFFFFFF', 1.0]
    ]
  })

这样做的好处是:

  • 蓝紫色负责整体氛围
  • 白色渐变负责虚化
  • 两层都覆盖完整 Header,不会产生横向断层

之前我尝试过只给上半部分加白色遮罩,比如 height(88),结果中间很容易出现一条明显的分界线。后来才意识到,雾化层最好不要半截结束,而是完整覆盖 Header,通过渐变位置来控制视觉范围。

渐变文字是怎么实现的

这个卡片里还有一处比较难理解的地方:渐变文字。

代码大概是这样:

Column() {
  Text(this.vm.config.welcomeDescription)
    .width('100%')
    .height(48)
    .fontSize(12)
    .lineHeight(16)
    .fontWeight(FontWeight.Regular)
    .blendMode(
      BlendMode.DST_IN,
      BlendApplyType.OFFSCREEN
    )
}
.width('100%')
.linearGradient({
  direction: GradientDirection.Right,
  colors: [
    ['#FF7385ED', 0.33],
    ['#FF2793FF', 0.66],
    ['#FF00AAFF', 1.0]
  ]
})
.blendMode(
  BlendMode.SRC_OVER,
  BlendApplyType.OFFSCREEN
)

这里不是直接给 Text 设置渐变色。

它的思路更像是:

  1. 外层先画一层渐变背景
  2. 文字作为遮罩
  3. 只保留文字形状里的渐变颜色

所以会用到 BlendMode.DST_INBlendApplyType.OFFSCREEN

简单理解就是:先有渐变,再用文字把渐变裁出来。

这种写法刚开始看会比较绕,但它解决的是一个很常见的问题:普通文字颜色只能设置纯色,而设计稿里需要渐变文字。

卡片裁剪和头像悬浮

这个卡片还有一个细节:机器人头像是悬浮在右上角的,超出了卡片主体。

所以最外层不能直接裁剪:

Stack() {
  // 卡片主体
  // 机器人头像
}
.width('100%')
.clip(false)

如果最外层设置了 clip(true),机器人头像超出的部分就会被裁掉。

但是 Header 自己需要圆角裁剪:

Stack() {
  // Header 背景
  // Header 内容
}
.width('100%')
.height(160)
.borderRadius({
  topLeft: 24,
  topRight: 24,
  bottomLeft: 0,
  bottomRight: 0
})
.clip(true)

这里就有一个层级关系:

  • 最外层 Stack 不裁剪,保证头像能露出来
  • Header 自己裁剪,保证顶部圆角正确
  • 列表区域自己设置背景和圆角,盖住 Header 底部 24vp

这种结构比单纯一个大 Column 更适合做悬浮元素。

列表区域为什么要覆盖 Header

设计稿里 Header 和问题列表不是硬切开的,而是列表区域向上覆盖了一点 Header:

.margin({
  top: -24
})

这 24vp 的负边距很关键。

它让下面的白色列表区域“压”到 Header 上面,形成一种卡片融合的效果。

列表本身再设置圆角:

.borderRadius(24)
.clip(true)

这样看起来就像下面的白色区域从 Header 里长出来,而不是上下两块生硬拼接。

这次重构后的经验

这次卡片重构下来,我最大的感受是:UI 复刻不能只盯着某一个属性改。

尤其是渐变这种东西,单看一行颜色值意义不大,必须结合:

  • 图层顺序
  • 透明度
  • 渐变方向
  • 渐变停靠点
  • 组件裁剪范围
  • 背景色
  • 上层元素遮挡关系

同一个颜色值,在不同背景上显示出来的效果完全不一样。

这也是为什么渐变色会显得特别“阴间”:它不是一个颜色问题,而是一个图层合成问题。

总结

这次推荐问题卡片重构,表面上是在调 UI,实际上让我理解了几个更重要的点。

第一,UI 重构时不要轻易动业务逻辑。像 quickPhrases.slice(0, 4)、点击发送这些逻辑本来就是稳定的,应该尽量保留。

第二,固定展示 4 条的问题列表,就不要额外引入动态高度计算。能写简单,就不要把问题复杂化。

第三,使用 AI 辅助 UI 复刻时不能盲信。AI 可能会把固定逻辑改成动态逻辑,也可能会自动把 maxLines(2) 改成 maxLines(1),这些看起来合理的小改动都会影响最终还原效果。

第四,渐变色不能只看 RGB,还要看透明度。#1A7385ED#7385ED 不是一个视觉效果。

第五,复杂渐变要用图层思维。底色、蓝紫渐变、白色雾化层应该分开处理,而不是强行塞进一层渐变里。

第六,裁剪要分层处理。外层为了头像悬浮不能裁剪,Header 为了圆角必须裁剪。

这类 UI 复刻一开始会觉得很细、很烦,但真正拆开之后,其实它训练的是对组件层级、图层合成和设计还原的敏感度。

写业务代码时,状态和数据流很重要。

写 UI 时,图层和边界感同样重要。

Logo

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

更多推荐