从一个推荐问题卡片重构聊聊 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.userInputquestionthis.vm.sendMessage(true)})})也就是说推荐问题来自vm.quickPhrases最多展示 4 条点击后设置userInput然后调用sendMessage(true)这部分本身没有问题。真正的问题出现在 UI 重构时我一开始用了函数去动态计算列表高度但后来发现这个列表本身就是固定只展示 4 条动态计算反而把问题复杂化了。如果业务已经明确“最多展示 4 条”那列表高度完全可以围绕这 4 条去设计而不是额外引入一套高度计算逻辑。UI 重构时最重要的一点是不要为了还原样式顺手改掉原来的业务结构。UI 重构不是重写业务这次重构里我尽量保留了原来的业务逻辑this.vm.quickPhrases.slice(0,4)这一句没有动。点击逻辑也没有动this.vm.userInputquestionthis.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 重构。不要盲目相信 AIUI 复刻里也会有幻觉这次还有一个很现实的感受用 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.userInputquestionthis.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 时图层和边界感同样重要。