从一个推荐问题卡片重构,聊聊 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=questionthis.vm.sendMessage(true)})})也就是说:
- 推荐问题来自
vm.quickPhrases - 最多展示 4 条
- 点击后设置
userInput - 然后调用
sendMessage(true)
这部分本身没有问题。
真正的问题出现在 UI 重构时:我一开始用了函数去动态计算列表高度,但后来发现这个列表本身就是固定只展示 4 条,动态计算反而把问题复杂化了。
如果业务已经明确“最多展示 4 条”,那列表高度完全可以围绕这 4 条去设计,而不是额外引入一套高度计算逻辑。
UI 重构时最重要的一点是:不要为了还原样式,顺手改掉原来的业务结构。
UI 重构不是重写业务
这次重构里,我尽量保留了原来的业务逻辑:
this.vm.quickPhrases.slice(0,4)这一句没有动。
点击逻辑也没有动:
this.vm.userInput=questionthis.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=questionthis.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设置渐变色。
它的思路更像是:
- 外层先画一层渐变背景
- 文字作为遮罩
- 只保留文字形状里的渐变颜色
所以会用到BlendMode.DST_IN和BlendApplyType.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 时,图层和边界感同样重要。