
1. 卡片布局为什么能撑起信息展示的半边天做HarmonyOS应用开发这几年我越来越觉得一个App的视觉质感往往不是靠某个炫酷的页面撑起来的而是靠那些每天都在看的信息展示模块堆出来的。而在所有信息展示形态里卡片布局算是最能兼顾美观与实用的方案——新闻信息流、电商商品列表、股票自选、日程待办几乎所有需要看得舒服、扫得快速的场景最后都会回归到卡片这个形态上。很多人问过我一个问题同样是展示信息为什么非要做成卡片直接一行行罗列不就行了吗我的回答是卡片本质上是一种信息分组的视觉容器。它把一条完整的业务数据标题、摘要、配图、标签、时间打包成一个视觉上闭合的区域让用户在扫视时能快速判断这是一条独立的、相关的信息而不是在一堆文字里费力辨认边界。心理学上这叫格式塔分组原则落到UI上就是降低认知负荷。尤其在大屏和折叠屏上信息密度高卡片的分组作用更加明显。HarmonyOS 6 这套系统里ArkUI 对卡片布局的支持已经相当成熟。从早期的Row/Column手动搭圆角阴影到后来原生的Card组件再到List、Grid、WaterFlow这些容器配合LazyForEach做长列表渲染基本覆盖了从单张静态卡片到复杂信息流的全部需求。这篇文章我会从容器选型、视觉细节、完整实战、多端适配、动效交互、性能优化几个角度把我实际开发中验证过的方案和踩过的坑一次性讲透。内容适合正在做 HarmonyOS 应用、想提升信息展示页面质感的朋友也适合刚接触 ArkUI 想系统了解卡片布局的初学者。2. 容器选型原生Card组件、List还是Grid2.1 原生Card组件到底解决了什么问题HarmonyOS 从 API 12 开始提供了原生Card组件。很多新手会问我之前用Column加borderRadius和shadow也能做出卡片的样子为什么还要用Card这其实是组件化思维的问题。原生Card组件自带了一套完整的卡片视觉规范默认的圆角值、背景色、边框、阴影都帮你处理好了还支持CardThemeColor来切换主题配色。它最大的价值不是省去几个修饰符而是提供了一种语义化的结构——代码里读到Card任何人立刻知道这是一张完整的、可复用的卡片而不是十个Column嵌套里的一员。对于团队协作和后续维护这种语义清晰度非常值钱。不过我也要说实话原生Card组件的默认样式偏标准如果你要做高度定制化的设计比如特殊圆角、渐变背景、多层阴影叠加还是得自己用容器组件包一层。我的习惯是标准内容、标准间距的卡片用Card需要深度定制的用Column加修饰符。2.2 信息流场景的容器选型对比卡片很少单独出现更多是作为列表或网格里的子项。这时候容器选型就非常关键。ArkUI 里常见的容器有这么几种我整理了一张对比表容器布局形态适用场景性能特点ListListItem单列垂直滚动新闻流、帖子列表、消息列表自带懒加载支持LazyForEach滚动流畅度好GridGridItem多列网格商品陈列、应用中心、菜单入口列数固定或动态调整适合规整排列WaterFlowFlowItem瀑布流图片社区、种草内容高度不等的卡片逐行排布Swiper横向滑动Banner、精选卡片轮播页面级容器支持循环播放Flex/Row/Column手写布局页内局部卡片排列灵活但需自行处理滚动与复用选型时我一般遵循三条经验。第一内容条数不确定且可能很多优先List它是 ArkUI 里滚动性能优化的重点对象。第二内容需要多列展示且高度相对规整用Grid如果卡片高度参差不齐换成WaterFlow。第三只有少量固定卡片直接用Row/Column排布不要为了用组件而用组件反而增加复杂度。2.3 卡片子组件的抽取与状态管理卡片布局最容易犯的错是把一个页面的所有内容堆在一个build()里。一旦卡片样式有调整或者某个字段要变化就得在几百行代码里找来找去。我的做法是每张卡片都抽成独立的Component子组件通过Prop或ObjectLink接收数据。比如一个新闻卡片Component struct NewsCard { Prop news: NewsData; State isLiked: boolean false; build() { Column({ space: 8 }) { // 封面图 Image(this.news.coverUrl) .width(100%) .height(160) .borderRadius({ topLeft: 16, topRight: 16 }) .objectFit(ImageFit.Cover) // 标题与摘要 Column({ space: 4 }) { Text(this.news.title) .fontSize(18) .fontWeight(FontWeight.Bold) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(this.news.summary) .fontSize(14) .fontColor(#666) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .alignItems(HorizontalAlign.Start) .padding({ left: 12, right: 12, bottom: 12 }) } .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 12, color: rgba(0, 0, 0, 0.06), offsetX: 0, offsetY: 4 }) } }子组件抽取之后父组件只需要负责数据传递和容器编排。这样做的第二个好处是便于局部刷新——Prop修饰的数据变化时只有对应的卡片子组件重新渲染不会拖累整个列表。我在实际项目里用这种结构做消息列表滑动时的帧率表现明显比整页刷新好。3. 视觉细节圆角、阴影和配色如何决定卡片质感3.1 圆角不是越大越好卡片最明显的视觉特征就是圆角。很多人喜欢把圆角拉满觉得圆润就是精致。但实际开发中圆角的大小应该和内容的性质、屏幕的尺寸、整体的设计语言匹配。我一般遵循这几个经验值小卡片比如标签、按钮型卡片圆角 8~12vp克制、精致中等内容卡片新闻、商品圆角 12~16vp兼顾柔和与利落大卡片Banner、详情卡片圆角 16~24vp空间感强不显笨重HarmonyOS 里设置圆角有两种方式。一种是直接用borderRadius修饰符设置统一圆角另一种是对不同角分别设置Column() { // 卡片内容 } .borderRadius({ topLeft: 16, topRight: 16, bottomLeft: 0, bottomRight: 0 })第一种适合大多数场景。第二种常在卡片底部要跟另一个元素衔接时用到比如底部展开的面板卡片、跟 Tab 栏齐平的卡片。需要注意圆角值不要写成跟随屏幕宽度动态计算的公式除非你明确在做自适应设计否则容易在不同设备上出现视觉失衡。3.2 阴影的克制用法阴影是卡片立体感的主要来源但也是性能的隐形杀手。ArkUI 的shadow修饰符支持radius、color、offsetX、offsetY四个参数。我的默认配置是这样的.shadow({ radius: 12, color: rgba(0, 0, 0, 0.06), offsetX: 0, offsetY: 4 })这套配置的核心思路是阴影颜色透明度低、模糊半径适中、纵向偏移轻微。透明度太高会显得脏模糊半径太大会消耗渲染性能偏移太明显会显得卡片像悬浮而不是纸面。如果你追求的是贴近 Material Design 的那种层次感可以把offsetY调到 6、radius调到 16但透明度务必保持在 0.05~0.1 之间。在列表场景里我强烈建议不要给每张卡片都加复杂阴影。实测下来当屏幕上同时渲染 8~10 张带大模糊半径阴影的卡片时滚动帧率会有肉眼可见的下降。解决办法是列表里用细线边框加极浅阴影或者干脆只用边框而对用户正在查看的那张重点卡片用完整阴影。这个局部重点阴影的思路在视觉上反而更能引导注意力。3.3 配色让卡片内容自然分层卡片本身的背景色、内部元素的颜色、整体页面的底色三者要形成一个自然的层级。我的习惯是页面底色#F5F6F8这类浅灰让白色卡片跳出来卡片背景纯白或接近纯白保证文字对比度卡片内部辅助元素用透明度表达层级比如次要文字用#999或rgba(0,0,0,0.5)分割线用rgba(0,0,0,0.06)HarmonyOS 里支持颜色直接在资源里定义也可以用#RRGGBB或rgba()字符串。我建议在项目里建立一个统一颜色资源文件避免散落的魔法色值。比如定义theme.card.bg、theme.page.bg、theme.text.secondary这套路径后续换肤、暗黑模式适配都会容易很多。提示如果你要适配深色模式不要把卡片背景写死成纯白。应该使用资源的dark属性配置或者基于系统颜色动态切换。3.4 卡片内容留白与间距很多卡片看起来闷问题不是配色而是留白不够。卡片内部的padding至少要有 12vp内容间距用 8vp 作为基准。我常用的间距体系是卡片外间距 12vp、卡片内边距 12~16vp、标题与摘要间距 8vp。这套体系在手机、平板上的表现都比较均衡。两个卡片之间的间距也值得多说一句间距太小卡片边界模糊间距太大一屏内容太少用户浏览效率下降。List里我一般通过space参数控制12vp 是我经过多次调试后觉得最舒服的值。在Grid里则通过columnsGap和rowsGap分别控制纵横间距商品类网格我习惯用 10vp 的间距。4. 完整实战用ArkUI搭一个新闻信息流卡片页4.1 数据结构与数据源准备实战部分我以一个典型的新闻信息流页面为例。先定义数据模型Observed class NewsData { id: string; title: string; summary: string; coverUrl: string; source: string; publishTime: string; category: string; isLiked: boolean; constructor(id: string, title: string, summary: string, coverUrl: string, source: string, publishTime: string, category: string) { this.id id; this.title title; this.summary summary; this.coverUrl coverUrl; this.source source; this.publishTime publishTime; this.category category; this.isLiked false; } }注意我用了Observed装饰器。它的作用是让数据对象本身具备可观察性。这样当子组件内部对isLiked这类字段做修改时UI 能自动更新。如果不加Observed嵌套对象的变化不会触发视图刷新这是新手最容易踩的坑。然后是IDataSource数据源实现。这个类负责给LazyForEach提供数据class NewsDataSource implements IDataSource { private dataList: NewsData[] []; private listeners: DataChangeListener[] []; totalCount(): number { return this.dataList.length; } getData(index: number): NewsData { return this.dataList[index]; } registerDataChangeListener(listener: DataChangeListener): void { this.listeners.push(listener); } unregisterDataChangeListener(listener: DataChangeListener): void { const index this.listeners.indexOf(listener); if (index 0) { this.listeners.splice(index, 1); } } reloadData(list: NewsData[]) { this.dataList list; this.listeners.forEach(listener { listener.onDataReloaded(); }); } }onDataReloaded()会通知LazyForEach全部数据已更新列表重新渲染。如果需要增量刷新可以在新增或删除数据时调用onDataAdd、onDataDelete对应的方法性能更优。4.2 页面骨架与卡片渲染页面主体代码如下Entry Component struct NewsFeedPage { State feedDataSource: NewsDataSource new NewsDataSource(); aboutToAppear(): void { // 模拟加载数据实际业务中通常从网络层获取 const list: NewsData[] []; for (let i 0; i 50; i) { list.push(new NewsData( news_${i}, HarmonyOS 卡片布局实战 ${i 1}这些细节决定界面质感, 这是一段摘要信息用于展示卡片布局中的文本换行与截断效果..., https://example.com/cover_${i}.png, 示例媒体, 10分钟前, 技术 )); } this.feedDataSource.reloadData(list); } build() { List({ space: 12 }) { LazyForEach(this.feedDataSource, (item: NewsData) { ListItem() { NewsCard({ news: item }) } }, (item: NewsData) item.id) } .padding(12) .backgroundColor(#F5F6F8) .edgeEffect(EdgeEffect.Spring) .scrollBar(BarState.Off) } }这里有三个细节值得展开。第一LazyForEach的第三个参数是键值生成函数我用了item.id。这个键值对列表的高效更新至关重要——LazyForEach会依据键值判断哪些条目需要重新创建、哪些可以复用。如果键值不稳定或重复轻则列表错乱重则崩溃。永远不要用数组下标当键值因为插入或删除一条数据后后面所有条目的下标都变了会导致整段列表重建。第二List的edgeEffect(EdgeEffect.Spring)让列表在滚动到底或顶时有橡皮筋回弹效果。信息流类页面我建议加上手感更接近原生应用。第三我把背景色设在List上而不是页面根容器上。这样在滚动时露出的部分始终是页面底色不会出现白底闪烁。4.3 卡片内的图文混排与标签展示新闻信息流卡片里除了标题摘要通常还有来源、时间、分类标签。这部分布局看起来简单但细节都在对齐上Row() { Text(this.news.source) .fontSize(12) .fontColor(#888) Blank() Text(this.news.category) .fontSize(12) .fontColor(#4A90D9) .padding({ left: 8, right: 8, top: 2, bottom: 2 }) .backgroundColor(rgba(74, 144, 217, 0.1)) .borderRadius(4) Text(this.news.publishTime) .fontSize(12) .fontColor(#AAA) } .width(100%)Blank()是 ArkUI 里一个非常实用的占位组件它会自动伸缩填充剩余空间实现来源靠左、时间和标签靠右的效果比手动算间距可靠得多。标签的底色用了rgba透明色比纯色更柔和这也是提升卡片精致感的一个小技巧。这里还要注意一个组件复用的细节整张卡片的布局我建议把图片和文字区域都放进同一个Column图片用objectFit(ImageFit.Cover)保证裁剪统一。不要用Stack把文字压在图片上除非你就是想做个沉浸式头图卡片——那种场景文字阴影要额外处理否则低对比度下根本看不清。4.4 卡片点击跳转与参数传递信息流卡片最重要的交互是点击进入详情页。HarmonyOS 里最常用的是router.pushUrl或Navigation体系。我强烈推荐用NavigationNavDestination替代传统router原因有三个转场动画更自然、页面生命周期管理和返回栈更清晰、代码结构更符合单页应用的思维。Entry Component struct NewsFeedPage { private navStack: NavPathStack new NavPathStack(); build() { Navigation(this.navStack) { List({ space: 12 }) { LazyForEach(this.feedDataSource, (item: NewsData) { ListItem() { NewsCard({ news: item }) .onClick(() { this.navStack.pushPath({ name: NewsDetailPage, param: item.id }); }) } }, (item: NewsData) item.id) } } } }注意我在param里只传了item.id没有传整个数据对象。这是为了避免跨页面共享可变对象带来的状态问题。详情页拿到id后自行从数据仓库获取完整数据是更稳妥的做法。5. 多尺寸适配卡片布局在手机、平板和折叠屏上的表现5.1 从固定列数到动态列数手机上一屏展示两列商品卡片刚好到了平板或折叠屏展开态还是两列就显得空旷。HarmonyOS 的GridRow/GridCol提供了一套栅格系统来应对这种情况。栅格系统把屏幕宽度分成若干列默认 12 列卡片通过GridCol设置跨列数来响应不同宽度GridRow({ columns: { sm: 4, md: 8, lg: 12 }, gutter: 12 }) { ForEach(this.productList, (item: ProductData) { GridCol({ span: { sm: 2, md: 4, lg: 3 } }) { ProductCard({ data: item }) } }, (item: ProductData) item.id) }我来解释一下这套断点体系sm对应手机竖屏约 320~600vpmd对应平板或折叠屏展开约 600~840vplg对应大屏或横屏840vp 以上。上述配置下手机端一排 2 个4列/2跨平板端一排 2 个8列/4跨大屏端一排 4 个12列/3跨。同样是两列每个卡片的宽度却跟着屏幕合理增长视觉比例保持稳定。这里面有个很容易出错的地方断点值是 vp 而非物理像素。GridRow内部会根据实际可用宽度判断当前处于哪个区间不需要你自己监听窗口变化写起来很省心。5.2 卡片内部的自适应细节光靠栅格控制列数还不够。你有没有遇到过这种情况同一个卡片在手机上显示刚好拖到平板上就显得字号太小、图片太矮所以卡片内部的尺寸也要跟着环境变化。常见的做法是在卡片组件里判断当前窗口宽度动态调整字号和间距StorageLink(winWidth) winWidth: number 720; build() { Column() { // 内容 } .padding(this.winWidth 600 ? 20 : 12) }StorageLink(winWidth)可以监听窗口宽度变化跨组件共享状态。当平板横竖屏切换时卡片内边距、标题字号、图片高度都能跟着变。我通常是定义一组紧凑/常规/宽松三档参数按窗口宽度切换而不是每个数值单独调这样代码可读性高也方便后续统一调整。提示折叠屏开发时不要只依赖winWidth做判断。折叠态和展开态的切换还涉及onFoldingStateChange回调如果卡片内部有特殊布局比如图片横向排列变纵向排列需要在回调里做结构性的布局切换而不是只改尺寸。5.3 用自适应布局代替硬编码坐标很多开发者习惯用width(240)、height(100)这样的硬编码来布局卡片。在单一机型上调试没问题一旦放到不同尺寸的设备上就原形毕露。我在做卡片布局时有一条铁律能用相对尺寸百分比、比例、layoutWeight就绝不用绝对尺寸。ArkUI 的layoutWeight非常像 Flexbox 里的flex-grow。假设卡片底部要放两个按钮左边是点赞右边是分享希望它们平分宽度Row() { Button(点赞).layoutWeight(1) Button(分享).layoutWeight(1) } .width(100%)这样无论卡片多宽两个按钮始终各占一半。如果再加入第三个按钮只需要加layoutWeight(1)宽度自动重新分配完全不用改布局代码。图片的自适应也很关键。卡片封面图我一般这么写Image(item.coverUrl) .width(100%) .aspectRatio(16 / 9) .objectFit(ImageFit.Cover)aspectRatio(16 / 9)保证了图片不管在什么宽度的卡片下高度都按 16:9 比例自动计算。这是避免卡片图忽高忽低的最好方法。6. 交互加分项按压反馈、转场动画与骨架屏6.1 按压缩放与点击态卡片布局如果只有样式好看没有交互反馈用户会觉得界面死。ArkUI 做按压反馈比较简单但要有控制力不能无脑加。我常用的方案是手势控制的按压缩放State isPressed: boolean false; State scaleNum: number 1.0; build() { Column() { // 卡片内容 } .scale({ x: this.scaleNum, y: this.scaleNum }) .gesture( GestureGroup(GestureMode.Parallel, TapGesture().onAction((event: GestureEvent) { // 处理点击跳转 }), LongPressGesture().onAction((event: GestureEvent) { // 处理长按可选弹出菜单 }) ) ) .onTouch((event: TouchEvent) { if (event.type TouchType.Down) { animateTo({ duration: 100 }, () { this.scaleNum 0.97; }); } if (event.type TouchType.Up || event.type TouchType.Cancel) { animateTo({ duration: 120, curve: Curve.Sharp }, () { this.scaleNum 1.0; }); } }) }这里我用onTouch手动控制缩放而不是依赖系统默认点击态原因是可以精确控制动画时长和弹性曲线。缩放数值0.97是我试出来的——太小了卡片像要塌陷太大了没反馈感。120ms 的恢复时长配合Curve.Sharp曲线手指抬起瞬间会有一个轻微的回弹手感很跟手。6.2 卡片转场动画信息流页面里用户从列表点击进入详情页如果只是生硬地切换页面体验会打折扣。NavigationNavDestination自带的转场效果已经够用但如果你希望详情页的入场看起来是从卡片展开的效果可以自定义转场.onClick(() { this.navStack.pushPath( { name: NewsDetailPage, param: item.id }, { transition: { pushEnter: (view) { view.enter({ type: TransitionType.PushEnter, duration: 300, curve: Curve.EaseOut, translate: { x: 80 }, opacity: 0 }); } }} ); })这个效果让新页面从右侧滑入的同时带一点透明度变化视觉上比较柔和。不过我要提醒转场动画时长不宜超过 350ms否则会让人感觉页面切换拖沓。300ms 是一个比较通用的值。卡片内部元素的逐个出现动画比如从y: 12的位移 透明度 0 渐变到正常也能提升质感但不要每张卡片都做。我通常只对首屏可见的卡片做入场动画滚动的卡片不做否则每次滚回来卡片都重新播放动画会很干扰。6.3 加载态与骨架屏信息流场景里网络请求的这段时间如果白屏或者闪一个进度条体验很生硬。好的做法是给卡片做骨架屏——用灰色块模拟卡片的轮廓让用户感觉内容马上要出现了。骨架屏在 ArkUI 里可以用一个独立的组件实现Component struct SkeletonCard { build() { Column({ space: 8 }) { Rect() .width(100%) .height(160) .fill(#EFEFEF) .borderRadius(16) Rect() .width(80%) .height(16) .fill(#EFEFEF) .borderRadius(4) Rect() .width(60%) .height(12) .fill(#EFEFEF) .borderRadius(4) } .padding(12) .backgroundColor(Color.White) .borderRadius(16) } }加载完数据后把state从loading切到success骨架屏列表换成真实卡片列表。骨架屏还有个隐性价值提前占位可以让列表高度稳定避免加载完成后列表突然跳变。我甚至建议把骨架屏卡片数量设为跟加载完成后的首屏卡片数量一致这样切换时视觉上是无缝的。7. 性能红线长列表卡片渲染的优化链路7.1 LazyForEach 与懒加载的正确姿势卡片布局的性能问题几乎都集中在长列表上。如果你用ForEach直接遍历 100 条数据渲染卡片HarmonyOS 会一次性创建所有组件节点内存和 CPU 都会告急。LazyForEach是解决这个问题的核心方案。它只在列表滚动到某个条目附近时才创建对应组件离开视口后组件被销毁或复用。使用时有几个硬性要求数据源必须实现IDataSource接口键值生成函数必须返回稳定且唯一的字符串不要在LazyForEach的 item 构建函数里执行耗时操作第一点和第二点我在前面已经讲过了这里重点说第三点。有些人习惯在LazyForEach的 itemBuilder 里做字符串拼接、日期格式化和图片加载前的逻辑处理这是大忌。这些操作会阻塞 UI 线程滚动一快就会掉帧。耗时的数据加工应该在数据源层面完成架构上让数据源交付给 UI 的已经是最终形态。7.2 图片加载与缓存策略信息流卡片里图片是性能大头。HarmonyOS 的Image组件本身有解码缓存但缓存策略需要你主动配置。我建议在项目初始化时设置图片加载策略Image(item.coverUrl) .interpolation(ImageInterpolation.Medium) .edgeAntialiasing(0) .renderMode(ImageRenderMode.Original)如果图片来自网络强烈建议配合图片加载库统一管理缓存。没有缓存的情况下列表滚动时每张新卡片都要发起网络请求滑到哪卡到哪。设置了内存缓存和磁盘缓存之后反复滚动基本秒开。还有个小技巧优先加载卡片头图的小尺寸缩略图。如果服务器支持请求 URL 可以加参数控制图片宽度比如按屏幕宽度的一半请求这样加载速度快、省内存视觉上也没有明显差异。等用户点击进入详情页时再加载高清大图。7.3 组件复用与卡片 count 控制HarmonyOS 6 里List的ListItem支持reuseId让不同类型的卡片在滚动离开视口后被系统缓存复用。如果你同一屏上存在多种卡片类型比如图文卡片、纯文字卡片、广告卡片用reuseId区分ListItem() { if (item.type imageText) { ImageTextCard({ data: item }).reuseId(imageTextCard) } else { TextCard({ data: item }).reuseId(textCard) } }组件复用的收益在卡片数量超过 50 条时非常明显。如果不设置reuseId每次滚动重建组件的开销会让帧率出现周期性抖动。另外控制可见卡片数量也很重要。我见过有人把一页内容拆成十几张迷你卡片每张都带独立阴影和圆角结果一屏几十个组件节点再怎么优化也卡。我的经验是首屏卡片数量控制在 4~6 张之间比较健康如果页面信息实在太多优先考虑把次要信息折叠到卡片内部。7.4 实测数据与调优结果我这里有一组实测数据是在一次新闻资讯类项目里做的优化对比。测试机为 HarmonyOS 6 设备的入门机型列表包含 100 条新闻卡片优化项优化前帧率优化后帧率说明ForEach全量渲染约 30 FPS—首屏卡顿明显切换LazyForEach约 45 FPS—滚动基本流畅加组件复用reuseId—约 55 FPS长时间滚动稳定阴影轻量化 图片缩略图—60 FPS 稳定滚动跟手这个结果和我预期一致。说实话性能优化不是某一个点做对了就万事大吉而是多个环节一起做才有质的飞跃。列表懒加载是基础图片缓存是关键组件复用是锦上添花阴影和渲染负载控制则是底线工程。四件事叠满之后卡片列表才会真正顺滑到可以闭眼滚。8. 踩坑实录我把五年开发里常见的卡片布局问题都过了一遍8.1 圆角与阴影被父组件裁剪一个很典型的场景在List的ListItem内放卡片卡片设置了圆角跑起来却发现圆角部分被切成了直角。原因是ListItem或上层容器有.clip(true)或默认裁剪行为把超出部分裁掉了。排查方法很简单逐层注释掉父容器的clip、overflow相关修饰符找到裁剪源。如果你确实需要裁剪比如列表项里的内容要溢出那就把圆角移到最外层可见的容器上让卡片本身承担圆角绘制而不是依赖子元素。8.2 卡片高度被拉伸List的ListItem默认会撑满父容器的交叉轴宽度但垂直方向上如果父List设置了height(100%)且没有明确约束 item 高度有时会出现卡片被异常拉伸的情况。我遇到过卡片里一张图片被拉伸到半屏高看着像被CALayer 拉伸了一样。解决方法是给卡片内部主要元素设置明确的aspectRatio或固定高度并且ListItem里不要使用依赖测量自身高度的复杂嵌套。最简单粗暴的验证方式把卡片的背景色设成醒目的红色运行看红色区域的实际大小一眼就能定位是哪个容器在作祟。8.3 数据更新不刷新UI用State数组存卡片数据直接this.newsList[0].isLiked true修改 UI 不刷新这是 ArkUI 新手高频问题。原因很简单State只能观察到数组整体替换或基本类型字段的变化做不到深度监听。我给出的方案是数据项类用Observed装饰子组件接收用ObjectLink而非Prop。ObjectLink对嵌套对象的字段级修改有感知能力修改item.isLiked true后对应卡片会自动刷新其他卡片不受影响。如果你不想引入Observed体系那就替换整个数组对象但这样会触发整页刷新性能代价高。8.4 阴影重叠与绘制过度当多个卡片带阴影并且卡片间距刚好等于阴影半径时两张卡片的阴影会重叠视觉上出现一道诡异的暗线。我在一个电商项目里被这个坑折磨了一下午最后发现是阴影半径12vp大于卡片间距10vp导致的。解决方法的优先级排列先把间距调大一劳永逸、再把阴影半径缩小、最后考虑用边框代替阴影。我个人的偏好是列表卡片用浅色边框#E8E8E8加极浅阴影既干净又不重叠。8.5 卡片内部滚动冲突某些特殊卡片内部有横向滑动区域比如图片轮播、标签横滑卡片本身又在List里纵向滚动。此时手势识别容易冲突——左右滑动变成了上下滑。处理思路是调整手势优先级.parallelGesture( PanGesture({ direction: PanDirection.Horizontal }) .onActionUpdate((event: GestureEvent) { // 处理横向滑动 }) )横向手势用parallelGesture与List的纵向滚动并行系统会根据手势方向自动判定由谁消费。这里要记住不要试图拦截所有手势会破坏系统层面的手势仲裁导致页面卡死或者滑动失灵。9. 我说给后来者的几句实在话做卡片布局做到最后我发现真正决定一个信息展示界面好坏的往往不是某个炫酷的组件而是一堆不起眼的细节叠加圆角是不是跟整体设计语言统一、阴影是不是克制、数据更新时 UI 是不是精准刷新、滚动时是不是一直保持 60 帧。如果你现在正打算从零开始做一个包含卡片布局的页面我的建议是可以先抄一套成熟的设计规范但不要死记参数。更重要的是建立自己的判断力——当你拿到一张设计稿时先问自己三个问题这张卡片承载的信息边界在哪里它在不同尺寸设备上应该怎么响应用户在滚动和点击时的感知是怎样的把这三个问题想清楚剩下的都是实现层面的功夫。HarmonyOS 6 的 ArkUI 已经把这些能力都铺到了你面前剩下的就看你怎么组织它们。写到最后我也把当初踩坑最多的那条经验再重复一遍列表永远用LazyForEach卡片永远拆子组件数据源永远和 UI 解耦。这三条做到了你已经赢过了八成半路放弃的人。