📖 引言
在上一篇文章中,我们详细实现了首页的 Banner 轮播图组件BannerCarousel,通过 Swiper 容器、自动轮播、自定义指示器圆点等技术,打造了一个视觉吸引力极强的首屏展示区域。当用户滑动浏览完轮播图后,接下来映入眼帘的就是科普文章列表——每一篇科普文章都由一个精心设计的卡片呈现,这就是本文的主角:TopicCard 文章卡片组件。
卡片组件是移动端 UI 设计中出场频率最高的元素之一。在《奇妙科学乐园》中,TopicCard 负责在科普列表页(Topics.ets)中以列表形式展示所有科普文章。它需要同时承载封面图片、分类标签(Badge)、文章标题、内容摘要、阅读量五大信息要素,并在有限的卡片空间中做到层次分明、视觉舒适。
这看似简单的卡片,背后却涉及多个关键布局技术的协作:Stack 层叠布局实现图片上悬浮分类标签、Column 纵向布局组织文字信息区、Row 横向布局排列底部统计行、圆角裁剪 clip 确保图片不溢出卡片边界。本文将从 Props 设计、混合布局、分类 Badge、点击事件回调四个维度,完整拆解 TopicCard 的实现细节。
源码仓库:https://atomgit.com/2301_79280419/WonderSciencePark
🎯 学习目标
完成本文后,你将能够:
- ✅ 掌握 ArkTS 组件 Props 设计模式:@Prop 传递数据对象 + 可选事件回调
- ✅ 运用 Stack + Column + Row 混合布局实现复杂卡片结构
- ✅ 使用 clip(true) 圆角裁剪防止图片溢出卡片边界
- ✅ 实现分类 Badge 标签:半透明背景 + 圆角胶囊样式
- ✅ 设计可选点击回调 onItemClick,遵循可选调用安全模式
- ✅ 使用 textOverflow + maxLines 控制长文本截断显示
💡 需求分析
TopicCard 组件的功能定位
TopicCard 是科普文章列表页的核心展示单元,每当用户进入"科普知识"页面,就会看到一列整齐排列的 TopicCard。它的功能定位如下:
| 功能要素 | 描述 | UI 表现 |
|---|---|---|
| 封面图片 | 展示文章所属分类的封面图 | Stack 容器中 110vp 高度的 Image |
| 分类标签 | 标识文章属于哪个科学分类 | 图片左上角悬浮的半透明 Badge |
| 文章标题 | 显示科普文章的标题 | 15fp 加粗文字,单行截断 |
| 内容摘要 | 展示文章前两段内容的缩略 | 12fp 灰色文字,单行截断 |
| 阅读量 | 显示文章被阅读的次数 | 底部行左侧,附带眼睛图标 |
| 点击交互 | 点击卡片跳转到文章详情 | onClick 回调传递 Topic 对象 |
| 卡片容器 | 圆角白色卡片,1px 边框 | borderRadius(16) + border |
组件 Props 设计需求
TopicCard 作为一个可复用的展示组件,需要满足以下 Props 需求:
| Props 名称 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| topic | Topic | 否 | 空 Topic 对象 | 科普文章数据对象 |
| onItemClick | (topic: Topic) => void | 否 | undefined | 点击卡片的回调函数 |
设计决策:为什么 topic 使用 @Prop 而不是 @Link?因为 TopicCard 是纯展示组件,不需要修改 Topic 数据,只需要接收并展示。遵循"单向数据流"原则,@Prop 足矣。
卡片信息层次分析
从视觉设计角度,TopicCard 的信息层次分为三个区域:
┌─────────────────────────────┐ │ ┌─────────────────────────┐ │ │ │ 封面图片区域 │ │ ← 第一层:视觉吸引区 │ │ ┌──────────┐ │ │ │ │ │ 太空探索 │ ← Badge │ │ │ │ └──────────┘ │ │ │ └─────────────────────────┘ │ │ │ │ 为什么太阳会发光? │ ← 第二层:核心信息区 │ 太阳是一颗巨大的恒星...│ 标题 + 摘要 │ │ │ 👁 2.3k 阅读 → │ ← 第三层:辅助信息区 │ │ 阅读量 + 跳转箭头 └─────────────────────────────┘🛠️ 核心实现
步骤1: 数据模型与默认值工厂函数
功能说明
TopicCard 依赖Topic数据模型来渲染内容。在组件内部,我们需要一个工厂函数getDefaultTopic()来生成安全的默认值,防止在数据尚未加载时组件渲染异常。这与我们之前在 Skeleton 骨架屏组件中的设计思路一脉相承——先有兜底值,再等真实数据。
源码实现
//文件路径:entry/src/main/ets/model/Topic.ets /** * 冷知识数据模型 */ export interface FunFact { title: string;//冷知识标题 content: string;//冷知识内容 } /** * 科普文章数据模型 * 包含文章的完整信息:标题、分类、内容段落、冷知识、动画类型等 */ export interface Topic { id: number;//文章唯一标识 title: string;//文章标题 category: string;//分类ID(如'space') categoryName: string;//分类名称(如'太空探索') categoryColor: string;//分类主题色 icon: string;//分类图标emoji gradientStart: string;//渐变色起始值 gradientEnd: string;//渐变色结束值 content: string[];//文章内容段落数组 funFacts: FunFact[];//冷知识数组 animationType: string;//动画类型标识 has3DModel: boolean;//是否有3D模型 readTime: number;//预估阅读时长(分钟) readCount: number;//阅读量 difficulty:'easy'|'medium'|'hard';//难度等级 }// 文件路径:entry/src/main/ets/components/topic/TopicCard.etsimport{Topic,FunFact}from'../../model/Topic';import{Category}from'../../model/Category';import{ scienceData }from'../../viewmodel/ScienceData';import{ThemeColors}from'../../constants/AppConstants';import{FormatUtil}from'../../utils/FormatUtil';/** * 生成默认的空 Topic 对象 * 用于组件初始化时的兜底值,避免 undefined 渲染异常 *@returns空的 Topic 对象,所有字段为初始值 */functiongetDefaultTopic():Topic{constemptyFacts:FunFact[] = [];// 空冷知识数组constemptyContent: string[] = [];// 空内容数组consttopic:Topic= {id:0,title:'',category:'',categoryName:'',categoryColor:'',icon:'',gradientStart:'#ffffff',gradientEnd:'#ffffff',content: emptyContent,funFacts: emptyFacts,animationType:'',has3DModel:false,readTime:0,readCount:0,difficulty:'easy'};returntopic; }设计要点
| 设计决策 | 说明 | 对比分析 |
|---|---|---|
| 工厂函数 vs 内联默认值 | 使用独立工厂函数,逻辑清晰,可复用 | ❌ 内联默认值会导致组件声明臃肿 |
| 空数组初始化 | emptyFacts和emptyContent单独声明 | ❌ 直接赋值[]会导致类型推断丢失 |
| difficulty 默认值 | 设为'easy',符合字面量联合类型约束 | ❌ 不设默认值会导致编译错误 |
| id 默认为 0 | 0 作为"无数据"标识,配合条件渲染使用 | ✅ 语义清晰,便于判断 |
步骤2: 组件声明与 Props 定义
功能说明
使用@Component装饰器声明 TopicCard 为可复用组件,通过@Prop接收父组件传递的 Topic 数据。点击回调onItemClick设计为可选属性(?修饰),这样父组件可以选择不传回调,组件内部通过if (this.onItemClick)安全调用。
源码实现
@ComponentexportstructTopicCard{// 接收父组件传递的文章数据,@Prop 单向传递,组件内不可修改@Proptopic:Topic=getDefaultTopic();// 可选的点击回调函数,父组件传入时点击卡片触发跳转onItemClick?:(topic: Topic) =>void;build() {// ... 构建UI} }Props 使用的正确与错误对比
// ✅ 正确:@Prop 用于纯展示组件,单向数据流@Proptopic:Topic=getDefaultTopic();// ❌ 错误:展示组件使用 @Link,违反单向数据流原则@Linktopic:Topic;// TopicCard 不需要修改数据,@Link 是多余的// ✅ 正确:可选回调使用 ? 修饰符,内部安全判断onItemClick?:(topic: Topic) =>void;// ❌ 错误:强制必传回调,降低组件灵活性onItemClick:(topic: Topic) =>void;// 某些场景可能不需要点击跳转// ✅ 正确:父组件传入回调TopicCard({topic: topic,onItemClick:(t: Topic) =>this.goToTopicDetail(t) })// ❌ 错误:直接在组件内部硬编码路由跳转,破坏组件复用性.onClick(() =>{ router.pushUrl({url:'pages/TopicDetail'});// 组件不应感知路由细节})步骤3: 封面图片区——Stack 层叠 + 分类 Badge
功能说明
卡片顶部是封面图片区域,使用 Stack 容器将图片和分类标签层叠在一起。分类 Badge 以半透明黑色背景悬浮在图片左上角,采用胶囊形状(borderRadius: 9999),既不遮挡主体画面,又能清晰标识文章分类。
源码实现
build(){Column(){// ===== 第一层:封面图片 + 分类 Badge(Stack 层叠布局)=====Stack({alignContent: Alignment.TopStart }){// 底层:分类封面图片Image(this.getCategoryCover()) .width('100%') .height(110) .objectFit(ImageFit.Cover);// 等比裁剪填满,不留白边// 上层:分类标签 BadgeText(this.topic.categoryName).fontSize(11).fontColor(ThemeColors.TEXT_WHITE)// 白色文字.backgroundColor('rgba(0, 0, 0, 0.4)')// 半透明黑色背景.padding({ left:10, right:10, top:4, bottom:4}) .borderRadius(9999)// 胶囊形状.margin({ top:10, left:10});// 距离左上角内边距} .width('100%');// ... 第二层和第三层代码} }Stack 对齐方式对比
// ✅ 正确:TopStart 对齐,Badge 自然定位在左上角Stack({ alignContent: Alignment.TopStart }){ Image(...) Text('太空探索').margin({top:10, left:10});// 相对左上角偏移}// ❌ 错误:Center 对齐,需要额外计算偏移量Stack({ alignContent: Alignment.Center }){ Image(...) Text('太空探索')// 居中对齐,不自然,需要复杂的 position 定位}// ❌ 错误:BottomEnd 对齐,Badge 跑到右下角Stack({ alignContent: Alignment.BottomEnd }){ Image(...) Text('太空探索')// 语义上 Badge 应在左上角}分类 Badge 样式对比
//✅ 正确:半透明背景 + 圆角胶囊 + 白色文字 .backgroundColor('rgba(0, 0, 0, 0.4)') .borderRadius(9999) .fontColor(ThemeColors.TEXT_WHITE)//❌ 错误:纯黑色背景,遮挡过多画面 .backgroundColor('#000000')//过于突兀,破坏图片美感//❌ 错误:无圆角矩形,视觉生硬 .borderRadius(0)//缺少圆润感,不适合儿童应用//❌ 错误:使用固定宽高,不同分类名称长度不同会截断 .width(60).height(20)//"海洋生物"可能显示不全//应该用 padding + borderRadius(9999) 自适应宽度步骤4: 封面图片动态获取——getCategoryCover 方法
功能说明
卡片的封面图片并非来自 Topic 数据本身,而是根据 Topic 的 category 字段,从 ScienceData 服务中查询对应 Category 的 topicCoverImage 资源。这样做的优势是:同一分类下的所有文章共享同一张封面图,减少了资源冗余。
源码实现
/** * 根据文章的分类ID获取分类封面图片资源 * 优先从 ScienceData 服务中查询,查不到则使用默认太阳图片 * @returns 图片资源引用 */privategetCategoryCover(): Resource { const category = scienceData.getCategoryById(this.topic.category); return category ? category.topicCoverImage :$r('app.media.topic_sun'); }图片获取策略对比
// ✅ 正确:通过 ScienceData 服务查询,有兜底默认值privategetCategoryCover(): Resource { const category = scienceData.getCategoryById(this.topic.category); return category ? category.topicCoverImage :$r('app.media.topic_sun'); }// ❌ 错误:无兜底值,category 为空时直接崩溃privategetCategoryCover(): Resource { const category = scienceData.getCategoryById(this.topic.category); return category.topicCoverImage;// category 可能为 undefined}// ❌ 错误:直接返回固定图片,忽略分类差异privategetCategoryCover(): Resource { return$r('app.media.topic_sun');// 所有分类都显示太阳图片}// ❌ 错误:在 build() 方法中内联逻辑,违反单一职责Image(scienceData.getCategoryById(this.topic.category)?.topicCoverImage ??$r('app.media.topic_sun'))// build() 方法中不应包含复杂的三元运算和可选链步骤5: 核心信息区——标题 + 摘要 + 阅读量
功能说明
图片区域下方是文字信息区,使用 Column 纵向排列三个要素:文章标题(加粗)、内容摘要(灰色)、底部统计行(阅读量 + 箭头)。所有文字都设置了maxLines(1)和textOverflow(TextOverflow.Ellipsis),确保长文本不会撑破卡片布局。
源码实现
// ===== 第二层 + 第三层:文字信息区域 =====Column(){// 文章标题Text(this.topic.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(ThemeColors.TEXT_PRIMARY)// #333333 主文字色.width('100%') .margin({ bottom:6}) .maxLines(1)// 最多显示1行.textOverflow({overflow: TextOverflow.Ellipsis });// 超出显示省略号// 内容摘要Text(this.getTopicSummary()) .fontSize(12).fontColor(ThemeColors.TEXT_SECONDARY)// #666666 次要文字色.width('100%') .margin({ bottom:8}) .maxLines(1).textOverflow({overflow: TextOverflow.Ellipsis });// 底部统计行Row(){// 阅读量Text('👁 ' + FormatUtil.formatReadCount(this.topic.readCount)+ ' 阅读') .fontSize(12).fontColor(ThemeColors.TEXT_TERTIARY)// #999999 三级文字色.layoutWeight(1);// 占满剩余空间// 跳转箭头Text('→').fontSize(16).fontColor(ThemeColors.PRIMARY);// #ff6b6b 主题色} .width('100%'); } .padding(12) .width('100%');摘要生成逻辑——getTopicSummary 方法
/** * 获取文章摘要文本 * 优先拼接前两段内容作为摘要,如果只有一段则用一段,无内容返回空字符串 *@returns摘要文本 */privategetTopicSummary(): string {if(this.topic.content &&this.topic.content.length >0) {if(this.topic.content.length >=2) {// 拼接前两段作为摘要,展示更多预览信息returnthis.topic.content[0] +this.topic.content[1]; }// 只有一段内容时,直接返回该段returnthis.topic.content[0]; }return''; }文本截断处理对比
// ✅ 正确:maxLines + textOverflow 组合使用,优雅处理长文本Text(this.topic.title).maxLines(1).textOverflow({overflow: TextOverflow.Ellipsis });// ❌ 错误:不设置截断,长标题会撑宽卡片Text(this.topic.title).fontSize(15).fontWeight(FontWeight.Bold)// 缺少 maxLines 和 textOverflow,"为什么太阳会发光发热,它是如何形成..."会溢出// ❌ 错误:使用固定宽度限制,不同屏幕尺寸下表现不一致Text(this.topic.title).constraintSize({maxWidth: 200 })// 硬编码宽度,不适配不同屏幕// ✅ 正确:使用 layoutWeight 让阅读量占满空间,箭头自然靠右Text('👁 2.3k 阅读').layoutWeight(1);Text('→')// 不设 layoutWeight,保持固有宽度// ❌ 错误:使用 Blank() 组件填充空白Text('👁 2.3k 阅读')Blank()// 多此一举,layoutWeight(1) 已足够Text('→')步骤6: 卡片容器——圆角裁剪与整体样式
功能说明
TopicCard 的最外层 Column 充当卡片容器,设置了白色背景、16vp 圆角、1px 边框和 12vp 底部间距。关键点是使用clip(true)开启圆角裁剪,确保 Stack 中的图片不会溢出卡片的圆角边界。
源码实现
build() { Column() {//封面图片区 Stack({ alignContent: Alignment.TopStart }) {/* ... */}//文字信息区 Column() {/* ... */} } .width('100%') .backgroundColor(ThemeColors.BG_PRIMARY)//白色背景 .borderRadius(16)//16vp 圆角 .border({ width:1, color: ThemeColors.BORDER_COLOR })//1px 边框 .clip(true)//圆角裁剪,关键! .margin({ bottom:12})//卡片间距 .onClick(() => {//安全调用可选回调if(this.onItemClick) { this.onItemClick(this.topic); } }); }clip(true) 的必要性对比
// ✅ 正确:使用 clip(true),图片被圆角裁剪Column(){Stack(){Image($r('app.media.topic_sun')) .width('100%').height(110).objectFit(ImageFit.Cover); }// ...} .borderRadius(16).clip(true)// 图片被限制在圆角内,左上角和右上角不会出现直角溢出// ❌ 错误:缺少 clip(true),图片溢出圆角Column(){Stack(){Image($r('app.media.topic_sun')) .width('100%').height(110).objectFit(ImageFit.Cover); } } .borderRadius(16)// 没有 clip(true),图片的四个角会以直角突出,破坏圆角效果整体布局结构图
Column (卡片容器) ├── Stack (封面区, alignContent: TopStart) │ ├── Image (分类封面,100%x110) │ └── Text (分类Badge, 悬浮左上角) ├── Column (信息区, padding:12) │ ├── Text (文章标题,15fp Bold) │ ├── Text (内容摘要,12fp) │ └── Row (统计行) │ ├── Text (阅读量, layoutWeight:1) │ └── Text (箭头 →)步骤7: 父组件集成——在 Topics 列表页中使用 TopicCard
功能说明
TopicCard 作为子组件,在科普列表页Topics.ets中通过 LazyForEach 懒加载渲染。父组件通过topic属性传递数据,通过onItemClick回调处理跳转逻辑。这种"数据 + 回调"的组件通信模式是 HarmonyOS 组件设计的标准范式。
源码实现
//文件路径:entry/src/main/ets/pages/Topics.ets//在列表中使用 TopicCard//列表区域 List() { ListItem() { Row() { Text('共 '+ this.topicList.length +' 篇文章') .fontSize(13) .fontColor(ThemeColors.TEXT_SECONDARY); } .width('100%') .margin({ bottom:12}); }//使用 LazyForEach 懒加载 TopicCard LazyForEach(this.topicDataSource, (topic: Topic) => { ListItem() { TopicCard({ topic: topic,//传递文章数据 onItemClick: (t: Topic) => this.goToTopicDetail(t)//点击跳转回调 }); } }, (topic: Topic) => topic.id.toString()) } .width('100%') .layoutWeight(1) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) .padding(16) .backgroundColor(ThemeColors.BG_SECONDARY);// 跳转到文章详情页的方法goToTopicDetail(topic: Topic):void{constparams: RouterParams = { topicId: topic.id };constoptions: RouterOptions = { url: RouteUrls.TOPIC_DETAIL,params:params}; RouterUtil.pushUrl(options,'Topics'); }父组件使用方式对比
// ✅ 正确:传递 topic + 回调,数据流清晰TopicCard({topic: topic,onItemClick:(t: Topic) =>this.goToTopicDetail(t) })// ❌ 错误:不传回调,点击无响应(虽然不会崩溃,但交互体验差)TopicCard({topic: topic })// ❌ 错误:在 ForEach 中直接内联复杂逻辑TopicCard({topic: topic,onItemClick:() =>{// 大量逻辑写在这里,不利于维护constparams = {topicId: topic.id};RouterUtil.pushUrl({url:RouteUrls.TOPIC_DETAIL, params }); } })// ✅ 正确:使用 LazyForEach 懒加载,性能更优LazyForEach(this.topicDataSource,(topic: Topic) =>{ListItem() {TopicCard({topic: topic,onItemClick: ... }); } },(topic: Topic) =>topic.id.toString())// ❌ 错误:使用普通 ForEach,一次性渲染所有卡片ForEach(this.topicList,(topic: Topic) =>{ListItem() {TopicCard({topic: topic,onItemClick: ... }); } })// 当文章数量多时,所有卡片同时渲染,首屏性能差🔍 关键技术详解
6.1 @Prop 装饰器深度理解
@Prop 是 ArkTS 中用于父子组件单向数据传递的装饰器。它的核心特性是值拷贝——父组件传递的值会被拷贝一份到子组件内部,子组件对 @Prop 变量的修改不会影响父组件。
| 特性 | @Prop | @Link | @Provide/@Consume |
|---|---|---|---|
| 数据方向 | 父 → 子(单向) | 父 ↔ 子(双向) | 跨层级传递 |
| 是否拷贝 | 是(值拷贝) | 否(引用同步) | 否(引用同步) |
| 适用场景 | 纯展示组件 | 需要回写父组件 | 深层嵌套共享 |
| TopicCard 适用性 | ✅ 非常适合 | ❌ 不需要 | ❌ 不需要 |
// @Prop 的值拷贝特性示例@Componentstruct ParentComponent {@StateparentTopic: Topic = getDefaultTopic(); build() { TopicCard({ topic: this.parentTopic })// parentTopic 的值被拷贝给 TopicCard 内部的 topic// TopicCard 内部修改 topic 不会影响 parentTopic} }6.2 可选回调的安全调用模式
onItemClick?: (topic: Topic) => void中的?表示该属性可能为 undefined。在调用前必须进行存在性判断。
// ✅ 正确:先判断再调用.onClick(() => {if(this.onItemClick) {this.onItemClick(this.topic); } })// ❌ 错误:直接调用,可能抛出 TypeError.onClick(() => {this.onItemClick!(this.topic);// 非空断言操作符 ! 危险})// ✅ 正确:使用可选链操作符(更简洁的写法).onClick(() => {this.onItemClick?.(this.topic); })6.3 FormatUtil 阅读量格式化工具
TopicCard 中使用了FormatUtil.formatReadCount()来格式化阅读量数字,将大数字转换为更友好的显示格式。
// 文件路径:entry/src/main/ets/utils/FormatUtil.etsexportclassFormatUtil {/** * 格式化数字显示 * 大于1万显示为 "X.X万" * 大于1千显示为 "X.Xk" * 否则显示原数字 */static formatNumber(num:number):string{if(num >=10000) { return (num/10000).toFixed(1)+'万'; }elseif(num >=1000) { return (num/1000).toFixed(1)+'k'; } return num.toString(); } static formatReadCount(count:number):string{ returnFormatUtil.formatNumber(count); } }// 使用示例:// 2300 → "2.3k"// 34500 → "3.5万"// 856 → "856"⚠️ 常见问题与排查
问题1: 图片未显示或显示异常
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 图片区域空白 | getCategoryCover() 返回了无效资源 | 检查 ScienceData 是否已初始化,Category 的 topicCoverImage 是否正确 |
| 图片拉伸变形 | objectFit 未设置为 Cover | 添加.objectFit(ImageFit.Cover) |
| 图片溢出卡片圆角 | 未设置 clip(true) | 在卡片容器 Column 上添加.clip(true) |
问题2: 分类 Badge 不显示
| 现象 | 原因 | 解决方案 |
|---|---|---|
| Badge 文字为空 | topic.categoryName 为空字符串 | 确认数据源中 categoryName 字段已正确赋值 |
| Badge 背景色不对 | rgba 值设置错误 | 使用'rgba(0, 0, 0, 0.4)'标准格式 |
| Badge 位置偏移 | Stack 对齐方式不对 | 确认使用Alignment.TopStart |
问题3: 点击卡片无响应
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 点击没反应 | 父组件未传 onItemClick 回调 | 父组件添加onItemClick: (t) => this.goToDetail(t) |
| 回调执行但跳转失败 | RouterUtil 参数有误 | 检查 RouteUrls.TOPIC_DETAIL 路径是否正确 |
📊 性能优化
LazyForEach 懒加载优化
TopicCard 在列表中使用 LazyForEach 而非 ForEach,这是关键的性能优化点:
场景:100篇文章列表 ❌ ForEach:一次渲染100个TopicCard实例-首屏渲染时间:约800ms-内存占用:高(100个组件树) ✅ LazyForEach:仅渲染可视区域的TopicCard-首屏渲染时间:约150ms-内存占用:低(约5-8个组件树)-滚动时动态创建/销毁组件图片资源复用策略
同一分类下的多篇文章共享同一张封面图,HarmonyOS 的图片缓存机制会自动复用已解码的图片资源:
太空探索分类(3篇文章) ├── 为什么太阳会发光? → topic_sun.jpg← 已解码缓存 ├── 月球上的脚印 → topic_sun.jpg← 命中缓存,零开销 └── 星星的秘密 → topic_sun.jpg← 命中缓存,零开销🧪 测试验证
测试用例清单
| 测试场景 | 预期结果 | 验证方式 |
|---|---|---|
| 正常数据渲染 | 标题、摘要、阅读量、Badge 全部正确显示 | 目视比对 JSON 数据 |
| 空数据兜底 | 显示空卡片,不崩溃 | 传入 getDefaultTopic() |
| 长标题截断 | 超过一行显示省略号 | 传入超长 title 字符串 |
| 点击跳转 | 跳转到正确详情页 | 点击卡片,检查跳转 URL |
| 无回调点击 | 不崩溃,无跳转 | 不传 onItemClick,点击卡片 |
| 大阅读量格式化 | 10000+ 显示为 "X.X万" | 传入 readCount: 23000 |
| 分类封面回退 | 无分类时显示默认太阳图 | 传入 category: 'unknown' |
📝 总结
本文完整拆解了 TopicCard 文章卡片组件的实现细节,涵盖了以下核心技术点:
| 技术点 | 实现方式 | 设计原则 |
|---|---|---|
| Props 设计 | @Prop topic + 可选 onItemClick | 单向数据流 + 回调分离 |
| 混合布局 | Stack(Column, Row) 嵌套 | 层次分明,职责清晰 |
| 圆角裁剪 | clip(true) + borderRadius(16) | 容器级裁剪,子元素无忧 |
| 分类 Badge | 半透明背景 + 胶囊圆角 | 视觉层次不遮挡主体 |
| 文本截断 | maxLines(1) + textOverflow | 防止长文本溢出布局 |
| 阅读量格式化 | FormatUtil.formatReadCount | 大数字友好显示 |
| 组件集成 | LazyForEach + 回调跳转 | 性能优化 + 交互解耦 |
TopicCard 的设计体现了"高内聚、低耦合"的组件设计原则:组件内部自包含所有展示逻辑,通过 Props 接收数据、通过回调通知事件,不依赖任何外部路由或状态管理。这使得 TopicCard 可以在任何需要展示科普文章摘要的场景中复用。
🔗 相关链接
- 项目源码:Atomgit仓库
- 上一篇:HarmonyOS应用<奇妙科学乐园>开发第43篇:Banner轮播图组件——Swiper实战与自动滚动
- 下一篇:HarmonyOS应用<奇妙科学乐园>开发第45篇:Stack层叠布局——Hero图片区与渐变遮罩实现