HarmonyOS应用<奇妙科学乐园>开发第48篇:空状态组件EmptyState——ResourceStr图标与文案兜底

📅 2026/8/5 2:52:15
HarmonyOS应用<奇妙科学乐园>开发第48篇:空状态组件EmptyState——ResourceStr图标与文案兜底
引言在上一篇文章中我们深入拆解了 AnimationDemo 动画演示组件的 setInterval Math.sin 动画驱动机制以及六种科普场景太阳系、植物光合、海洋世界、雨天气象、星空梦境、科技机器人的实现细节。动画演示为文章详情页带来了生动的交互体验。然而并非所有页面都能展示丰富内容——当收藏列表为空、浏览历史被清空、网络请求失败时用户看到的将是一片空白。这时候就需要一个关键的兜底组件EmptyState 空状态组件。EmptyState 是《奇妙科学乐园》中所有列表页面的最后一道防线。它负责在数据为空、加载失败、网络异常等场景下向用户展示友好的提示信息并提供操作入口如去发现按钮引导用户采取行动。在儿童应用中空状态的设计尤为重要——一个冰冷的暂无数据远不如一个带图标、带描述、带操作按钮的温馨提示来得友好。源码仓库https://atomgit.com/2301_79280419/WonderSciencePark 学习目标完成本文后你将能够✅ 掌握 EmptyState 组件的图标 标题 描述 操作按钮四层结构设计✅ 理解 ResourceStr 类型在图标属性中的兼容性设计字符串 vs Resource✅ 运用 EmptyStateAction 接口实现可选操作按钮的类型安全✅ 掌握 layoutWeight(1) 实现空状态垂直居中的布局技巧✅ 理解三态设计空数据 / 加载失败 / 网络异常的统一组件适配✅ 学会多个页面复用 EmptyState 时的差异化配置策略 需求分析EmptyState 的功能定位EmptyState 是一个通用的空状态兜底组件在项目中被多个页面使用收藏页 Favorites ├── 加载中 → SkeletonList 骨架屏 ├── 有数据 → LazyForEach 列表 └── 无数据 → EmptyState ←还没有收藏哦去发现按钮 浏览历史 History ├── 加载中 → SkeletonList 骨架屏 ├── 有数据 → LazyForEach 列表 └── 无数据 → EmptyState ←还没有浏览记录哦去发现按钮 错题本 WrongQuiz ├── 加载中 → SkeletonList 骨架屏 ├── 有数据 → 列表 / 练习模式 └── 无数据 → EmptyState ←错题本是空的去做题按钮 文章详情 TopicDetail数据异常时 └── topic 为null→ 内联空状态未使用 EmptyState 组件三态设计需求状态图标标题描述操作按钮空数据icon_favorite还没有收藏哦遇到喜欢的文章点击右上角收藏起来吧去发现空数据icon_history还没有浏览记录哦阅读过的文章会出现在这里去发现空数据icon_wrong错题本是空的做错的题目会自动收录到这里去做题加载失败icon_empty加载失败请检查网络后重试重新加载网络异常icon_empty网络连接异常请检查网络设置重试Props 设计Props 名称类型必填默认值说明iconResourceStr否空状态图标支持 emoji 字符串和 Resource 资源引用titlestring否暂无数据主标题文案descriptionstring否补充描述文案为空时不渲染actionEmptyStateAction否undefined操作按钮配置为空时不渲染按钮EmptyStateAction 接口exportinterfaceEmptyStateAction {text:string;// 按钮文案onClick:() void;// 点击回调}️ 整体架构设计组件结构概览EmptyState 采用四层条件渲染的极简架构EmptyState ├── 第一层图标Image │ ├── 支持 emoji 字符串、等 │ └── 支持 Resource 引用$r(app.media.icon_favorite) ├── 第二层主标题Text │ └── 必须展示fontSize16Medium 字重 ├── 第三层描述文案Text ← 可选 │ └── description 非空时渲染最多2行 └── 第四层操作按钮Button ← 可选 └──action非空时渲染胶囊样式视觉布局结构┌────────────────────────────────────┐ │ │ │[64x64]│ ←icon图标 │ │ │ │ │ 还没有收藏哦 │ ← title 主标题 │ │ │ 遇到喜欢的文章点击右上角 │ ← description 描述可选 │ 收藏起来吧 │ │ │ │[ 去发现 ]│ ← action 操作按钮可选 │ │ └────────────────────────────────────┘ ↑ 整体 layoutWeight(1) 居中对齐 核心实现拆解1. 接口定义与类型设计export interface EmptyStateAction { text: string; onClick: () void; }Componentexport struct EmptyState {icon: ResourceStr ; title: string 暂无数据; description: string ; action?: EmptyStateAction;关键设计要点EmptyStateAction 接口单独定义操作按钮的类型包含text按钮文案和onClick点击回调两个字段。使用 interface 而非内联对象字面量方便类型复用和 IDE 智能提示。icon: ResourceStr这是整个组件最精巧的类型选择。ResourceStr是 HarmonyOS ArkUI 中表示字符串或资源引用的联合类型它可以同时接受普通字符串、、暂无图片资源引用$r(app.media.icon_empty)、$r(app.media.icon_favorite)action?: EmptyStateAction使用?可选修饰符当调用方不传 action 时按钮区域不渲染。这种可选属性 条件渲染的模式是 ArkUI 组件设计中非常常见的模式。ResourceStr 的兼容性价值//场景1使用 emoji 字符串作为图标简单快捷 EmptyState({ icon:,//emoji 字符串 title:暂无数据})//场景2使用 SVG 资源作为图标更专业 EmptyState({ icon:$r(app.media.icon_favorite),//Resource 资源引用 title:还没有收藏哦, description:遇到喜欢的文章点击右上角收藏起来吧, action: { text:去发现, onClick: () {/* 跳转到发现页 */} } })✅ 正确做法icon 属性声明为ResourceStr类型兼容字符串和 Resource 两种形式最大化组件的灵活性。❌ 错误做法将 icon 声明为string类型导致无法传入$r()资源引用或者声明为Resource类型导致无法使用简单的 emoji 字符串。2. 图标层Image 组件的 ResourceStr 兼容build(){ Column() { Image(this.icon).width(64).height(64).objectFit(ImageFit.Contain).margin({bottom:16});Image 组件与 ResourceStr 的兼容机制ArkUI 的Image组件本身支持多种类型的 src 参数string网络 URL 或本地路径PixelMap像素图对象Resource通过$r()引用的资源当我们将icon声明为ResourceStr并传入Image(this.icon)时传入Image 会尝试加载名为的资源由于不是有效的资源路径会显示为空白或占位符。但在实际使用中项目更倾向于使用 Resource 类型。实际项目中的使用情况通过搜索项目中所有 EmptyState 的使用位置发现实际调用均使用Resource类型//Favorites.ets 中 EmptyState({ icon:$r(app.media.icon_favorite),//Resource 类型 title:还没有收藏哦, description:遇到喜欢的文章点击右上角收藏起来吧, action: { text:去发现, onClick: () { ... } } });//History.ets 中 EmptyState({ icon:$r(app.media.icon_history),//Resource 类型 title:还没有浏览记录哦, ... });//WrongQuiz.ets 中 EmptyState({ icon:$r(app.media.icon_wrong),//Resource 类型 title:错题本是空的, ... });尽管如此保留ResourceStr类型仍然是正确的设计决策——它为未来可能的 emoji 简写场景预留了扩展空间且不会带来额外的类型安全风险。3. 标题层必展示的主文案Text(this.title).fontSize(16).fontWeight(FontWeight.Medium).fontColor(ThemeColors.TEXT_PRIMARY).margin({bottom:8});设计要点fontSize 16作为空状态的视觉焦点标题需要比描述文案13更大、更醒目FontWeight.Medium中等字重在视觉层级上仅次于页面标题的 BoldTEXT_PRIMARY#333333使用最深的主文字色确保在浅色背景下有足够的对比度**margin({ bottom: 8 })**与下方描述文案保持 8vp 间距视觉上紧密关联标题是 EmptyState 唯一的必渲染元素。即使 description 和 action 都为空标题也能单独传达当前没有数据的核心信息。4. 描述层条件渲染的补充文案if (this.description) { Text(this.description).fontSize(13).fontColor(ThemeColors.TEXT_SECONDARY).textAlign(TextAlign.Center).margin({ bottom:20}).maxLines(2).width(80%); }设计要点**if (this.description)**当 description 为空字符串时整个 Text 组件不渲染避免出现空行fontSize 13比标题小 3 号形成清晰的主次层级TEXT_SECONDARY#666666使用次要文字色在视觉上弱于标题**textAlign(TextAlign.Center)**居中对齐与图标和标题保持中轴线一致**maxLines(2)**限制最多显示 2 行防止描述文案过长导致布局变形**width(80%)**限制文本宽度为父容器的 80%两侧留白避免长文案贴边✅ 正确做法使用width(80%)textAlign(TextAlign.Center)的组合确保长文案在居中的同时不会撑满整个屏幕宽度。❌ 错误做法不设width直接用默认全宽。当描述文案很长时会从左到右铺满视觉上不够精致。5. 操作按钮层可选的行动引导if(this.action) { Button(this.action.text) .fontSize(14) .fontColor(#ffffff) .backgroundColor(ThemeColors.PRIMARY) .borderRadius(20) .padding({ left:24, right:24, top:8, bottom:8}) .onClick(() {if(this.action this.action.onClick) {this.action.onClick(); } }); }设计要点**if (this.action)**操作按钮是可选的。某些空状态如页面底部的没有更多数据提示不需要操作按钮ThemeColors.PRIMARY#ff6b6b使用项目主题色与整体视觉风格保持一致**borderRadius(20)**胶囊形按钮圆角值为高度32的 62.5%呈现柔和的胶囊形态onClick 中的双重判断if (this.action this.action.onClick)确保在异步更新场景下不会因为 action 被置空而导致崩溃操作按钮的安全回调设计.onClick(() {if(this.action this.action.onClick) {this.action.onClick(); } });这行代码包含了两层防御this.action确保 action 对象存在可能被父组件动态置空this.action.onClick确保回调函数存在接口字段可能为 undefined✅ 正确做法在 onClick 回调中进行双重判空防止运行时异常。❌ 错误做法直接写this.action.onClick()当 action 为 undefined 时会抛出Cannot read property onClick of undefined错误。6. 整体布局垂直居中策略Column(){//...图标 标题 描述 按钮 }.width(100%).layoutWeight(1).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center);布局策略拆解**width(100%)**占满父容器宽度**layoutWeight(1)**这是实现垂直居中的关键layoutWeight(1)让 EmptyState 占据父容器中所有剩余的垂直空间。配合justifyContent(FlexAlign.Center)内容自然在可用空间的正中央显示**justifyContent(FlexAlign.Center)**垂直方向居中**alignItems(HorizontalAlign.Center)**水平方向居中为什么不用 height(100%)// ❌ 错误使用固定 height 可能在某些布局中导致溢出Column(){ ... }.width(100%).height(100%)// 如果父容器有其他子组件100% 可能超出.justifyContent(FlexAlign.Center)// ✅ 正确使用 layoutWeight 自适应剩余空间Column(){ ... }.width(100%).layoutWeight(1)// 自动占据剩余空间不会溢出.justifyContent(FlexAlign.Center)在典型的页面布局中EmptyState 通常与 AppBar 共存Column { AppBar()//固定高度 ~56vp EmptyState()//layoutWeight(1)占据剩余全部空间 }layoutWeight(1)确保 EmptyState 精确占据 AppBar 下方到屏幕底部的所有空间不会出现滚动条也不会溢出。 三态配置对照表项目中各页面的 EmptyState 配置页面icontitledescriptionaction.textFavorites$r(app.media.icon_favorite)还没有收藏哦遇到喜欢的文章点击右上角收藏起来吧去发现History$r(app.media.icon_history)还没有浏览记录哦阅读过的文章会出现在这里去发现WrongQuiz$r(app.media.icon_wrong)错题本是空的做错的题目会自动收录到这里去做题建议的扩展配置网络异常、加载失败场景icontitledescriptionaction.text加载失败$r(app.media.icon_empty)加载失败请稍后再试重新加载网络异常$r(app.media.icon_empty)网络连接异常请检查网络设置后重试重试搜索无结果没有找到相关内容试试其他关键词吧清空搜索 实际页面集成案例案例1收藏页 Favorites 的三段式布局Favorites 页面采用了经典的加载中 - 有数据 - 空数据三段式布局build() {Column() {AppBar({barTitle:我的收藏,showBack:true,onBack:() this.goBack() });if(this.isLoading) {// 第一态加载中 → 骨架屏Scroll() {SkeletonList({count:5,showImage:true}); } .layoutWeight(1) .backgroundColor(ThemeColors.BG_SECONDARY); }elseif(this.favoriteTopics.length0) {// 第二态有数据 → 列表List() {ListItem() {Text(共收藏 this.favoriteTopics.length 篇文章) .fontSize(13) .fontColor(ThemeColors.TEXT_SECONDARY); }LazyForEach(this.topicDataSource,(topic: Topic) {ListItem() {TopicCard({topic: topic,onItemClick:(t: Topic) this.goToTopicDetail(t) }); } },(topic: Topic) topic.id.toString()); } .layoutWeight(1) .backgroundColor(ThemeColors.BG_SECONDARY); }else{// 第三态空数据 → EmptyStateEmptyState({icon: $r(app.media.icon_favorite),title:还没有收藏哦,description:遇到喜欢的文章点击右上角收藏起来吧,action: {text:去发现,onClick:() {constparams:RouterParams {tabIndex:1};constoptions:RouterOptions {url:RouteUrls.MAIN_TABS,params: params };RouterUtil.replaceUrl(options,Favorites); } } }); } } .width(100%) .height(100%) .backgroundColor(ThemeColors.BG_SECONDARY); }三段式布局的核心逻辑if(isLoading) → 骨架屏用户看到正在加载的暗示elseif(有数据) → 真实列表用户看到实际内容else→ EmptyState用户看到友好的空状态提示这种三段式布局是移动端列表页面的标准模式保证了用户在任何数据状态下都能看到有意义的 UI。案例2EmptyState action 中的路由跳转Favorites 页面的去发现按钮使用了RouterUtil.replaceUrl跳转到发现 Tabaction: { text:去发现, onClick: () {// 使用 replaceUrl 而非 pushUrl避免在导航栈中积累页面constparams: RouterParams { tabIndex:1};constoptions: RouterOptions { url: RouteUrls.MAIN_TABS,params:params}; RouterUtil.replaceUrl(options,Favorites); } }路由策略选择replaceUrl替换当前页面用户点击返回时不会回到空白的收藏页pushUrl在导航栈上叠加新页面用户需要点两次返回才能退出在空状态场景中使用replaceUrl是更合理的策略——既然收藏页是空的用户返回来还是空页面不如直接替换。⚠️ 避坑指南1. action 回调中的闭包陷阱// ❌ 错误在 ForEach 中直接引用循环变量 ForEach(this.pages,(page:string){ EmptyState({action: {text:去 page,onClick:(){ // 闭包捕获的是 page 的引用可能导致所有按钮都跳转到最后一个页面 RouterUtil.pushUrl({url: page }); } } }); },(page:string)page); // ✅ 正确使用 const 固定闭包变量 ForEach(this.pages,(page:string){ consttargetUrl:string page; // 用 const 锁定值 EmptyState({action: {text:去 targetUrl,onClick:(){ RouterUtil.pushUrl({url: targetUrl }); } } }); },(page:string)page);2. description 为空字符串时的渲染判断// ❌ 错误使用 !this.description 无法区分空字符串和未传入if(!this.description) { Text(this.description);// 空字符串也会进入此分支}// ✅ 正确直接用 if (this.description) 判断空字符串if(this.description) { Text(this.description) .fontSize(13) .maxLines(2) .width(80%); }在 ArkTS 中空字符串是 falsy 值if (this.description)在 description 为时不会执行这正是我们想要的行为。3. layoutWeight 在非 Flex 容器中无效//❌ 错误在非 Column/Row/Flex 容器中使用 layoutWeight Stack() { EmptyState()//layoutWeight(1) 在 Stack 中无效 }//✅ 正确确保父容器是 Column/Row/Flex Column() { AppBar(); EmptyState()//layoutWeight(1) 在 Column 中正常工作 }4. Image 组件不支持 emoji 渲染的替代方案// ⚠️ 注意Image 组件传入 emoji 字符串可能无法正常渲染// 如果确实需要显示 emoji 图标建议使用 Text 组件// 方案A使用 Text 显示 emoji当前项目实际采用 Resource 方案Text().fontSize(48).margin({bottom:16});// 方案B保持 Image Resource项目实际使用的方案Image($r(app.media.icon_favorite)).width(64).height(64).objectFit(ImageFit.Contain).margin({bottom:16});在本项目中所有页面的 EmptyState 都使用了$r()Resource 引用方式传入图标因此 Image 组件是正确的选择。如果未来需要支持 emoji 图标可以考虑在组件内部做类型判断// 进阶方案根据 icon 类型自动选择渲染组件BuilderIconBuilder(){if(typeof this.iconstring) {Text(this.iconasstring).fontSize(48).margin({ bottom:16}); }else{Image(this.iconasResource).width(64) .height(64) .objectFit(ImageFit.Contain).margin({ bottom:16}); } } 进阶思考空状态组件的扩展设计扩展方向一支持自定义内容插槽当前 EmptyState 通过 props 配置图标、标题、描述和按钮。如果需要更灵活的内容定制如添加特定插画、动画等可以考虑使用Builder插槽// 进阶方案Builder 插槽非项目当前实现仅供参考Component exportstructAdvancedEmptyState { icon: ResourceStr ; title:string 暂无数据; description:string ; action?: EmptyStateAction; customContent?:()void;// 自定义内容插槽build(){Column(){Image(this.icon).width(64).height(64) .objectFit(ImageFit.Contain).margin({ bottom:16});Text(this.title).fontSize(16).fontWeight(FontWeight.Medium).fontColor(ThemeColors.TEXT_PRIMARY).margin({ bottom:8});if(this.description) {Text(this.description).fontSize(13).fontColor(ThemeColors.TEXT_SECONDARY).textAlign(TextAlign.Center).maxLines(2).width(80%) .margin({ bottom:20}); }// 自定义内容区域if(this.customContent) { this.customContent(); }if(this.action){ Button(this.action.text).fontSize(14).fontColor(#ffffff).backgroundColor(ThemeColors.PRIMARY).borderRadius(20).onClick((){if(this.action?.onClick) { this.action.onClick(); } }); } } .width(100%) .layoutWeight(1).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center); } }扩展方向二支持加载动画在空状态中添加一个微妙的呼吸动画让页面看起来活着// 进阶方案呼吸动画非项目当前实现仅供参考StatebreathScale:number1;aboutToAppear() {// 简单的呼吸动画setInterval(() {this.breathScale1Math.sin(Date.now() *0.003) *0.05; },50); }// 在图标上应用Image(this.icon) .width(64) .height(64) .scale({x:this.breathScale,y:this.breathScale}) .objectFit(ImageFit.Contain) .margin({bottom:16});扩展方向三主题色适配// 进阶方案支持深色模式非项目当前实现仅供参考Prop isDark: boolean false;// 图标颜色根据主题变化Image(this.icon).width(64) .height(64) .fillColor(this.isDark? #ffffff : ThemeColors.TEXT_TERTIARY).objectFit(ImageFit.Contain);⚠️ 常见问题Q1: EmptyState 在页面中没有垂直居中而是紧贴顶部现象EmptyState 组件渲染后图标、标题、按钮全部紧贴页面顶部没有居中显示。原因layoutWeight(1)只在 Flex 容器Column/Row中生效。如果 EmptyState 的父容器是Stack或ScrolllayoutWeight(1)会被忽略组件高度由内容撑开而非占据剩余空间。解决方案确保 EmptyState 的直接父容器是 Column。// ❌ 错误写法在 Stack 中使用 EmptyStatelayoutWeight 无效Stack(){AppBar();EmptyState()// layoutWeight(1) 在 Stack 中不生效}// ✅ 正确写法在 Column 中使用 EmptyState正确占据剩余空间Column(){AppBar();EmptyState()// layoutWeight(1) 在 Column 中正常工作}Q2: 点击 EmptyState 的操作按钮时应用崩溃现象当 EmptyState 的action属性通过异步条件动态赋值时点击按钮偶尔抛出Cannot read property onClick of undefined错误。原因onClick回调中没有对this.action做判空保护。当父组件在异步更新中将action置空而用户恰好在此时点击按钮就会触发空引用错误。解决方案在 onClick 中进行双重判空。// ❌ 错误写法直接调用回调未做判空保护if(this.action) { Button(this.action.text) .onClick(() {this.action.onClick();// action 可能在异步中被置空}); }// ✅ 正确写法onClick 内部再次判空if(this.action) { Button(this.action.text) .onClick(() {if(this.action this.action.onClick) {this.action.onClick();// 双重判空安全调用} }); }Q3: Image 组件传入 emoji 字符串时图标区域显示空白现象使用EmptyState({ icon: , title: 暂无数据 })时图标区域渲染为空白。原因Image组件期望接收string类型的路径或Resource类型的资源引用。传入 emoji 字符串时Image 尝试加载名为的资源文件自然找不到而显示空白。解决方案使用$r()Resource 引用传入 SVG/PNG 图标资源或改用Text组件渲染 emoji。//❌ 错误写法Image 组件无法渲染 emoji 字符串 EmptyState({ icon:,//Image 无法渲染 emoji title:暂无数据})//✅ 正确写法使用 Resource 引用传入矢量图标资源 EmptyState({ icon:$r(app.media.icon_favorite), title:还没有收藏哦}) 小结EmptyState 空状态组件是《奇妙科学乐园》中不起眼但不可或缺的基础组件。本文从以下六个方面进行了完整拆解类型设计ResourceStr 类型的 icon 属性兼容字符串和 Resource 两种形式四层结构图标 标题 可选描述 可选按钮的条件渲染架构布局策略layoutWeight(1) justifyContent(Center) 实现精确的垂直居中安全回调onClick 中的双重判空防止运行时异常三态适配同一个组件通过 props 差异化配置适配空数据/加载失败/网络异常页面集成Favorites 等页面的三段式布局加载中/有数据/空数据核心设计理念用最少的代码67 行实现最通用的空状态兜底能力通过 props 差异化配置适配不同场景避免在每个页面中重复编写空状态 UI。这是组件化思维在 HarmonyOS ArkUI 开发中的典型实践。源码仓库https://atomgit.com/2301_79280419/WonderSciencePark组件路径entry/src/main/ets/components/base/EmptyState.ets 相关链接项目源码Atomgit仓库上一篇HarmonyOS应用奇妙科学乐园开发第47篇:动画演示组件AnimationDemo——科普互动设计下一篇HarmonyOS应用奇妙科学乐园开发第49篇:通用列表项ListItem——icon标题箭头封装