做应用开发这几年,我最大的感受是:信息展示类页面,布局决定体验的底线。在HarmonyOS 6上做界面开发,卡片布局已经成了信息流、工作台、数据看板这类场景的事实标准——通过卡片把标题、配图、摘要、操作入口打包成一个个独立的“信息单元”,整个界面既不会因为内容太杂而显得凌乱,也不会因为元素太少而显得空洞。这篇文章就围绕HarmonyOS 6的ArkTS声明式开发,把卡片布局从设计思路、核心参数、完整实现到性能调优一次性讲透。内容基于我实际写过的信息流页面整理,适合正在入门HarmonyOS开发、想快速做出有质感的界面的人,也适合已经在写业务代码、希望优化卡片细节的开发者。你不需要有多深的UI功底,跟着下面的思路走,就能搭出一个可复用的卡片组件。
1. 卡片布局的整体设计思路
1.1 为什么信息展示场景偏爱卡片
从用户视角讲,卡片做的其实是同一件事:视觉分组。人眼在扫描一块屏幕时,会下意识通过背景颜色、圆角轮廓、阴影深度来区分内容区块。传统列表用一条细细的分割线把不同条目隔开,信息一多就容易看晕;卡片则直接给每个内容单元一块“独立的底板”,标题、图片、描述、时间作者这些信息被聚合在一块明确的区域里,用户不需要费力去分辨哪条文字属于哪条内容,扫一眼就能完成定位。
这种分组能力在HarmonyOS 6的界面设计中尤为重要。手机屏幕就那么大,信息密度和可读性天然是矛盾的。卡片本质上是拿“有限的留白”换“明确的信息边界”,让用户能以更低的认知成本读取内容。一个设计合格的卡片,从视觉识别到点击操作,整个交互链路应该是顺滑且自然的。我甚至可以说,信息展示类页面的开发质量,很大程度上就体现在卡片细节的打磨上——间距、圆角、阴影、文字层级,每一项单独看都不起眼,合在一起就是界面“高级感”的由来。
1.2 ArkUI下的方案选型:List还是Grid还是Scroll
在做卡片布局之前,先要确定容器骨架。HarmonyOS 6的ArkUI框架里,承载多个卡片的主流方案有三种:
- List加ListItem:纵向滚动列表,每个ListItem内放一张卡片。适合资讯流、消息列表、订单列表这类上下排列的内容,有天然的滚动复用机制,官方也推荐长列表用List。
- Grid加GridItem:网格布局,卡片以两列、三列甚至瀑布流的方式排列。适合商品陈列、功能入口、图片墙这类需要横向铺开的场景。
- Column包Scroll:纯手动排列,适合卡片数量固定、不需要滚动性能优化的页面,比如个人中心、详情页底部的推荐模块。
我的建议是:凡是数据量可能动态变化的卡片集合,优先上List或Grid,别用Column硬排。原因后面讲性能时再细说。如果页面里同时存在多种卡片类型,还可以用List嵌套不同样式的ListItem,通过判断数据类型来渲染对应的卡片模板,这种“异构列表”在实际业务里非常常见。选型的关键不是哪个更高级,而是哪个更适合当前的数据规模和交互方式。
1.3 一个卡片的视觉构成层次
从UI拆解的角度,一张卡片通常由四层构成:外间距(卡片与卡片之间的间隔)、容器主体(背景色、圆角、阴影)、内部内容区(内边距和文字图片的排列)、点缀元素(标签、图标、操作按钮)。四个层次是有依赖关系的:外间距决定呼吸感,容器主体决定视觉重量,内容区决定信息可读性,点缀元素决定交互引导。实际开发时,我习惯先确定外间距和内边距,再调圆角和阴影,最后才去处理文字样式,这样不容易返工。
间距和圆角的取值有一定规律可循。HarmonyOS官方设计规范里推荐卡片间距一般取8vp或12vp,圆角在12vp到16vp之间,内边距取12vp到16vp。这个区间既能保证卡片之间有清晰的边界,又不会因为圆角过大而显得过于轻飘。具体取值得看你卡片的承载内容:如果卡片里有大图,圆角适当缩小到12vp;如果卡片以文字为主,16vp会让整体更柔和。我见过很多失败案例,都是把间距设计稿里的像素值直接搬到代码里,完全忽略了vp和px的换算,导致真机上卡片挤成一团。HarmonyOS里默认使用vp作为尺寸单位,设计稿给的px像素值通常需要除以一个密度系数再填进去。
2. 核心组件与布局参数解析
2.1 卡片容器的圆角与阴影配置
卡片容器本身不需要引入什么特殊组件,一个Column、Row或者Stack都可以作为卡片底板,关键在于样式参数的组合。HarmonyOS 6的ArkUI里,圆角和阴影是卡片视觉的核心,常用的写法如下:
Column() { // 卡片内容区 } .width('100%') .padding(16) .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 16, color: 'rgba(0, 0, 0, 0.06)', offsetX: 0, offsetY: 4 })这里有几个细节值得注意。shadow的radius参数控制阴影的模糊半径,不是阴影的大小;color的透明度决定了阴影的“存在感”,透明度建议控制在0.04到0.08之间,太浅了阴影等于没有,太深了界面会显得脏。offsetX和offsetY控制阴影的偏移方向,信息流卡片通常只给向下的偏移,营造一种“卡片微微浮起”的效果,左右偏移反而会让阴影显得不自然。
关于圆角,还有一个容易踩的坑:如果卡片内部有图片且图片没有单独设置圆角,图片会顶在卡片四角,直角和圆角形成明显的割裂感。常见的处理方式是把外层卡片的圆角设置为16vp,内部图片则用clip设置为跟随容器裁剪,或者直接给图片单独设置borderRadius。怎么选取决于你的图片是否铺满卡片内容区。如果图片在卡片最上方,我建议给图片单独设置上左、上右的圆角,值比卡片圆角略小2vp左右,视觉过渡更自然。这套微调逻辑同样适用于底部有操作栏的卡片。
2.2 文字排版与截断的细节
卡片里文字部分是最容易做糙的地方。很多新手会把标题、摘要、来源、时间全部用默认样式堆上去,结果一眼看去全是黑的,没有层级。正确的做法是建立一套字号和颜色的梯度。以资讯类卡片为例:标题用17fp、中等字重、主文字色;摘要用14fp、常规字重、次级文字色;来源和时间用12fp、辅助文字色。同一张卡片里,字号层级控制在3档以内,字色层级控制在3档以内,超过这个范围界面就会显得杂乱。
文字一多就要考虑截断问题。ArkUI里控制两行截断很简单:
Text(this.news.title) .fontSize(17) .fontWeight(FontWeight.Medium) .fontColor('#182431') .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%')注意,maxLines和textOverflow必须配套设置,否则内容会被直接裁掉,而不是优雅地显示省略号。另外,如果卡片标题本身是动态数据,建议预留两行空间,给超长标题一个缓冲。不要为了美观把所有卡片都压成固定高度,数据一变就会破版。我能理解固定高度在视觉上很齐整,但动态内容场景下,固定的行数约束比固定的高度约束更安全。
2.3 卡片间距与对齐的体系化
间距是卡片布局中最能体现“高级感”的隐形因素。同样一组卡片,间距调得好,界面看起来就干净;间距乱七八糟,再好的设计稿也会被写废。我的做法是定义一套间距变量,比如spacing_s = 8vp、spacing_m = 12vp、spacing_l = 16vp、spacing_xl = 24vp,在同一个页面里尽量只取这套变量里的值,避免到处写死数字。列表的外间距、卡片的内边距、元素之间的间距都从这套变量里取值,界面整体的一致性就能大幅提升。
对齐方面,卡片内部的文字和图片要有一条统一的视觉左边界。我见过不少卡片,标题左边对齐了,但图片左边又缩进去了几个像素,标签又单独居中,导致整个卡片内部看起来是散的。解决方式很简单:内容区用一个Column统一承载,所有水平排列的元素(比如“来源+时间”这种行)在Column内部也保持同一条左对齐线,需要强调的右侧信息用Blank或space-between来推,而不是靠嵌套Row反复调margin。这样无论是2倍屏还是3倍屏,视觉左边界都能保持一致。
3. 实操:从零构建一个资讯卡片信息流
3.1 数据模型与页面骨架
理论说再多,不如直接上手。我用一个资讯信息流页面来演示完整的卡片布局实现。先定义数据模型,HarmonyOS 6的ArkTS对类型要求比较严格,建议把所有卡片数据抽象成一个interface:
interface NewsItem { id: string; title: string; summary: string; source: string; publishTime: string; tag: string; readCount: number; }页面的整体结构是:顶部一个标题栏,下面是一个List列表,列表每一项是一张新闻卡片。页面组件和列表的框架搭起来之后,效果就已经完成了一半。
@Entry @Component struct NewsFeedPage { @State newsList: NewsItem[] = []; aboutToAppear(): void { this.loadData(); } loadData(): void { this.newsList = [ { id: '1', title: 'HarmonyOS 6 新特性解析', summary: '从布局能力到性能优化,几个值得关注的变化', source: '鸿蒙开发者', publishTime: '10分钟前', tag: '技术', readCount: 3200 }, // 更多模拟数据 ]; } build() { Column() { Row() { Text('资讯推荐') .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor('#182431') } .width('100%') .padding({ left: 16, right: 16, top: 12, bottom: 12 }) List({ space: 12 }) { ForEach(this.newsList, (item: NewsItem) => { ListItem() { NewsCard({ news: item }) .onClick(() => { this.handleCardClick(item); }) } }, (item: NewsItem) => item.id) } .width('100%') .layoutWeight(1) .padding({ left: 12, right: 12, top: 4, bottom: 12 }) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) } .width('100%') .height('100%') .backgroundColor('#F1F3F5') } }ForEach的第三个参数是key生成器,这里用item.id作为唯一key。这个细节很关键:如果不给key,ArkUI在列表数据更新时可能复用错乱的item,导致状态错位和渲染异常。id必须是数据里真正唯一的字段,不要用index,因为index在增删数据时会变化。我之前因为图省事用index做key,结果删除列表中间某条数据后,后面所有卡片的内部状态全部串位,排查了很久才定位到问题。
3.2 卡片的模板实现与样式细节
接下来是卡片的实现。我把卡片封装成一个子组件NewsCard,方便复用,也让页面组件保持干净。这里的新闻卡片包含:分类标签、标题、摘要、来源、阅读量、时间。为了演示不同场景下的复用方式,我还用@Builder做了一层模板封装。
@Component struct NewsCard { @Prop news: NewsItem = { id: '', title: '', summary: '', source: '', publishTime: '', tag: '', readCount: 0 }; build() { Column() { this.TagRow(this.news.tag) Text(this.news.title) .fontSize(17) .fontWeight(FontWeight.Medium) .fontColor('#182431') .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') .margin({ top: 8 }) Text(this.news.summary) .fontSize(14) .fontColor('#666666') .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') .margin({ top: 6 }) Row() { Text(this.news.source) .fontSize(12) .fontColor('#999999') Text('·') .fontSize(12) .fontColor('#999999') .margin({ left: 4, right: 4 }) Text(this.news.readCount.toString() + '阅读') .fontSize(12) .fontColor('#999999') Blank() Text(this.news.publishTime) .fontSize(12) .fontColor('#999999') } .width('100%') .margin({ top: 12 }) } .width('100%') .padding(16) .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 16, color: 'rgba(0, 0, 0, 0.06)', offsetX: 0, offsetY: 4 }) } @Builder TagRow(tag: string) { Text(tag) .fontSize(11) .fontColor('#0A59F7') .backgroundColor('#E8F0FE') .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .borderRadius(4) } }这里我把标签封装成了@Builder方法。使用@Builder的好处是:同一个组件里,如果多个位置需要展示带不同参数的标签样式,只需要调用TagRow并传入不同参数即可,代码不会重复。标签的配色用的是浅蓝底加深蓝字,这个组合在白色卡片下清晰度很高,而且不会抢占标题的视觉权重。你也可以根据业务主题换配色,但原则是一样的:标签色和正文色要拉开层次,标签本身要弱于标题,不能喧宾夺主。
关于@Prop装饰器,不同SDK版本的校验规则略有差异。我这里给@Prop成员提供了默认值,保证了组件在没有被父组件传参时也不会报错。你在自己项目里遇到编译报错时,优先按SDK提示调整装饰器的初始化方式,这不是什么大问题,但卡住很浪费时间。
3.3 点击反馈与跳转交互
卡片作为点击入口,点击反馈是必不可少的。ArkUI里的onClick默认有自己的按压态效果,但默认效果比较生硬,更像按钮的反馈,不太适合整张卡片。我的做法是给卡片加一个透明度联动:点击时卡片整体透明度降到0.9,同时阴影稍微收一点,模拟一种“被按下去”的物理感受。
实际实现有两种方式。简单一点,在onClick里通过@State维护一个pressed标志位,按下置true,松开置false,再给Column的opacity和shadow绑定这个标志位。复杂一点,可以用触摸事件onTouch代替onClick,在TouchType.Down、Up、Cancel时分别处理状态。后者更精准,因为onClick只有松手才算触发,如果用户按住卡片不放,onClick还没触发时是没有任何反馈的,体验上会有一种“死板”的感觉。用onTouch提前处理按压态,交互会跟手很多。
跳转方面,卡片的onClick里调用router.pushUrl或页面路由跳转即可。如果卡片里还有独立的操作按钮(比如“分享”“删除”),注意按钮自身的onClick和卡片父容器的onClick可能会同时触发。我实际遇到的情况是:点卡片内的“删除”按钮,会先触发按钮自身的逻辑,紧接着又触发了卡片的跳转逻辑。解决思路是把交互拆分清楚:卡片的点击区域不要覆盖到操作按钮上,或者对事件做拦截。这块在HarmonyOS里的处理方式和Web端不太一样,不能想当然用stopPropagation那一套,要以真机实测为准。
4. 常见问题与排查技巧实录
4.1 圆角失效和阴影显示不正常的排查
写卡片布局时,圆角和阴影是最容易出问题的两个点。我遇到过几次“圆角没生效”的情况,最终定位下来基本都是同一个原因:卡片容器自身设置了背景色,但内部某个子元素(比如铺满全宽的图片)没有被裁剪,把圆角区域完全盖住了。换句话说,圆角其实生效了,但被内部的直角元素遮住了视觉。解决办法有两个:一是内部图片单独设置圆角,二是给内部图片包一层带裁剪的容器,用clip(true)让内容跟随容器形状裁剪。
阴影显示不正常的典型案例是:阴影设了,但完全看不见,或者阴影出现一个明显的“方块轮廓”。前者的原因是阴影透明度设得太低,或者阴影半径太小、偏移太大,导致阴影被挤出可视区域;后者的原因通常是容器背景色不透明白,阴影被强底色覆盖。排查时先把阴影的颜色透明度调到0.2以上,确认能看到阴影轮廓后,再逐步调回合适的视觉值,这样能快速定位是参数问题还是层级问题。这个习惯帮我省了很多反复试参数的时间。
4.2 长列表卡顿与LazyForEach的使用
很多人在信息流卡片数量超过几十条时会遇到滚动卡顿。原因往往不是卡片样式太复杂,而是用了ForEach一次性渲染全部数据。HarmonyOS的ArkUI对长列表有专门的懒加载方案:LazyForEach。它只在列表滚动到可视区域附近时才创建对应item的组件,配合List的缓存扩展属性,能显著降低内存和渲染压力。
用LazyForEach需要实现一个IDataSource接口类,负责管理数据集合。核心步骤整理如下:
class NewsDataSource implements IDataSource { private list: NewsItem[] = []; private listeners: DataChangeListener[] = []; totalCount(): number { return this.list.length; } getData(index: number): NewsItem { return this.list[index]; } registerDataChangeListener(listener: DataChangeListener): void { this.listeners.push(listener); } unregisterDataChangeListener(listener: DataChangeListener): void { const index = this.listeners.indexOf(listener); if (index > -1) { this.listeners.splice(index, 1); } } addData(item: NewsItem): void { this.list.push(item); this.listeners.forEach(listener => { listener.onDataAdd(this.list.length - 1); }); } }然后在List里这样用:
List({ space: 12 }) { LazyForEach(this.dataSource, (item: NewsItem) => { ListItem() { NewsCard({ news: item }) } }, (item: NewsItem) => item.id) } .cachedCount(5)LazyForEach的key生成器同样不能省略,而且要求key在数据范围内绝对唯一。实际使用中还有一个细节:数据更新时不要直接替换整个dataSource,而是通过addData、deleteData这类方法触发对应的监听回调。如果整体替换,LazyForEach会认为数据发生了大范围变化,可能重建大量item,反而更卡。cachedCount这个属性可以设置列表在可视区域外提前缓存的item数量,我一般取5左右,平衡内存和滚动流畅度。
4.3 不同屏幕尺寸与折叠屏的适配
卡片布局在不同设备上的适配,核心是间距和列数的弹性处理。普通手机竖屏下,资讯卡片单列展示没有争议,但到了平板和折叠屏展开态,单列卡片会变得非常宽,阅读时视线来回扫动的距离太长。合理做法是让卡片集合的列数根据屏幕宽度动态变化:窄屏一列,宽屏两列。在ArkUI里,可以通过Grid的columnsTemplate动态设置列数,或者监听屏幕宽度来做切换。
折叠屏的适配还要注意卡片内部的图片比例。折叠态和展开态的屏幕宽高比不同,卡片内图片如果写死高度,展开后就会拉伸变形。我的建议是:图片高度用aspectRatio约束,不写死vp,让图片自己去适配容器宽度。容器宽度变了,高度按比例自动调整,图片不会变形,卡片高度也会自然伸缩。这套做法在多种折叠形态下基本都能保持稳定。除了宽度适配,还要检查卡片左右两侧的间距是否足够,折叠屏展开后如果卡片贴着屏幕边缘,视觉上会显得很压迫。
5. 性能优化与视觉细节打磨
5.1 骨架屏与加载状态
信息流页面刚进入时,数据往往还在加载。如果直接白屏,用户会以为页面坏了;如果直接显示loading转圈,体验又显得粗糙。我在实际项目中更推荐骨架屏方案:用一组灰色圆角块模拟卡片的形状和布局,数据加载完成后替换成真实卡片。骨架屏的实现并不复杂,写一个SkeletonCard组件,内部放几个灰色的矩形块,分别对应标签、标题、摘要、底部信息行的位置,配合透明度动画循环播放,视觉上非常自然。
骨架屏的圆角要和真实卡片保持一致,宽度和Padding也要一致,不然加载完成后布局会发生跳动。我见过不少页面骨架屏和真实列表的圆角一个12一个16,或者内边距差了4vp,切换时整个页面明显抖了一下,这种细节相当影响体感。如果你是第一次写骨架屏,建议直接从真实卡片的样式复制一份,把文字替换成灰色块,成功率会高很多。
5.2 深色模式下卡片的配色处理
HarmonyOS 6的应用普遍要适配深色模式,卡片布局在深色模式下最容易翻车的就是阴影和背景色。深色模式下,卡片背景不能继续用纯白,通常会换成深灰色系的卡片色,但阴影如果还用黑色系透明度,在深色背景上会完全看不出来。这时候阴影要改为调整卡片与背景的亮度差,用比背景更亮一点的卡片色加上1vp左右的边框线,让卡片在深色背景中“浮”出来。
我这里用资源文件管理颜色的方式来处理:把卡片背景色、标题色、副标题颜色都定义成系统资源,在resources里分别为浅色模式和深色模式配置不同的值。代码里不要直接用Color.White这种写死的颜色,而是用$r('app.color.card_bg')这样的资源引用,系统会自动根据当前模式加载对应值。改起来也方便,不用在代码里到处找颜色值替换。我之前在浅色模式下把颜色全部写死,后来接深色模式的时候翻了两个小时的代码,从此再也不敢偷这个懒。
5.3 我实测下来的几个心得
写卡片布局写到一定量之后,我明显感觉有几个原则值得反复强调。
第一,不要在一个页面上堆超过三种卡片样式。样式多了,页面看起来热闹,但其实信息层级全被拉平了。信息流页面里,核心卡片用一种样式,次要内容用相对弱化的样式,特殊内容用突出样式,三种足以应付绝大多数场景。
第二,卡片内部的点击热区要足够大。卡片可能只有80vp高,但内部文字的点击区域如果只包在文字上,用户每次点击都要精确瞄准,体验非常差。我习惯把点击事件绑在卡片容器上,而不是绑在内部某个Text上。这样整张卡片都是热区,加一个按压反馈,交互体感会好很多。
第三,动画别贪多。卡片布局的入场动画、列表的滚动回弹、点击的按压效果,加起来不需要很复杂,做一两处精的就好。如果每个元素都加动画,界面反而显得轻浮。我目前的信息流页面只保留了按压反馈和骨架上屏的渐隐效果,用户普遍反馈干净利落。
我在实际项目里反复验证过这套卡片布局方案,从演示Demo到上线的信息流应用,最核心的改动始终围绕间距体系、圆角阴影和文字层级这三件事。如果你正打算在HarmonyOS 6上做信息展示类页面,建议先别急着堆功能,拿一个最简单的卡片列表把上面这些参数调一遍,你会明显感受到界面的变化。我个人还有个习惯:每次调完间距和圆角,都会把页面切到多设备预览模式看一眼整体节奏,哪里挤了哪里松了一眼就能看出来。这个习惯帮我少走了很多返工的弯路,你可以试试。