摘要
项目列表页面普遍存在 4 种状态:加载中、空数据、网络错误、正常列表。每个页面重复编写状态判断、Loading 动画、空视图、错误重试按钮,冗余代码极多。封装一套全局通用状态兜底组件StateView,统一承载加载、空数据、网络异常、正常内容四种视图,支持自定义文案、重试回调、图片、动画,所有列表页面直接复用,大幅减少页面重复布局代码。API23 优化组件状态刷新渲染、组件销毁资源回收、动画自动释放逻辑,修复状态切换闪烁、多次重复渲染、加载动画后台持续运行耗电等问题。
关键词
OpenHarmony;ArkUI;通用状态组件;空页面;加载视图;错误重试;状态封装;复用组件
一、引言
1.1 页面状态开发痛点
- 每个列表页面重复写
@State isLoading / isEmpty / isError状态变量; - 加载动画、空白提示、错误重试布局到处复制粘贴,样式不统一;
- 网络出错页面无重试按钮,用户需手动返回刷新;
- 状态切换时页面布局抖动、闪烁,无平滑过渡;
- 页面销毁后加载动画未终止,持续占用 CPU;
- 不同页面空页面文字、图标不统一,UI 视觉割裂。
1.2 页面四种标准业务状态
- 加载中 Loading:接口请求未返回,展示旋转加载动画 + 提示文字
- 空数据 Empty:接口正常返回,列表长度为 0,展示空图标 + 提示文案
- 网络 / 业务错误 Error:接口报错、超时、500,展示错误图标 + 重试按钮
- 正常 Content:数据正常返回,渲染业务列表 / 卡片
API23 组件渲染核心升级:
@BuilderParam参数缓存优化,状态切换仅局部刷新,不重绘整个页面;- 内置动画生命周期监听,组件销毁自动停止循环旋转动画;
- 条件渲染分支优化,避免多状态同时渲染造成布局重叠;
- 支持全局默认样式统一配置,单页面可局部覆盖自定义参数。
二、通用状态组件封装 components/common/StateView.ets
ets
@Component
export struct StateView {
// 页面当前状态枚举
@Param state: PageState = PageState.LOADING
// 自定义文字
@Param loadingText: string = "数据加载中..."
@Param emptyText: string = "暂无数据"
@Param errorText: string = "加载失败,请点击重试"
// 重试回调(错误页面点击执行刷新)
@Param onRetry: () => void = () => {}
// 正常业务内容插槽
@BuilderParam contentBuilder: () => void
// 旋转动画状态
@State rotateAngle: number = 0
aboutToAppear() {
// 加载状态开启无限旋转动画
animateTo({
duration: 1200,
iterations: Infinity,
curve: Curve.Linear
}, () => {
this.rotateAngle += 360
})
}
aboutToDisappear() {
// 组件销毁重置角度,终止循环动画
this.rotateAngle = 0
}
// 加载视图
@Builder LoadingView() {
Column({ space: 16 }) {
Text("⟳")
.fontSize(42)
.rotate({ angle: this.rotateAngle })
.animation({ duration: 1200, iterations: Infinity, curve: Curve.Linear })
Text(this.loadingText)
.fontSize(16)
.fontColor("#999999")
}
.width("100%")
.height("100%")
.justifyContent(FlexAlign.Center)
}
// 空数据视图
@Builder EmptyView() {
Column({ space: 16 }) {
Image($r("sys.media.ohos_ic_public_empty"))
.width(80)
.height(80)
.fillColor("#cccccc")
Text(this.emptyText)
.fontSize(16)
.fontColor("#999999")
}
.width("100%")
.height("100%")
.justifyContent(FlexAlign.Center)
}
// 错误重试视图
@Builder ErrorView() {
Column({ space: 20 }) {
Image($r("sys.media.ohos_ic_public_fail"))
.width(80)
.height(80)
.fillColor("#cccccc")
Text(this.errorText)
.fontSize(16)
.fontColor("#999999")
Button("点击重试")
.width(140)
.height(44)
.backgroundColor("#007DFF")
.onClick(this.onRetry)
}
.width("100%")
.height("100%")
.justifyContent(FlexAlign.Center)
}
build() {
Column() {
if (this.state === PageState.LOADING) {
this.LoadingView()
} else if (this.state === PageState.EMPTY) {
this.EmptyView()
} else if (this.state === PageState.ERROR) {
this.ErrorView()
} else {
// 正常业务列表内容
this.contentBuilder()
}
}
.width("100%")
.layoutWeight(1)
}
}
// 页面状态枚举,全局统一管理
export enum PageState {
LOADING,
EMPTY,
ERROR,
CONTENT
}
三、列表页面实战完整调用(笔记列表页改造)
ets
import StateView, { PageState } from '../components/common/StateView'
import RdbUtil, { Note } from '../utils/rdb_util'
import LogUtil from '../utils/log_util'
@Entry
@Component
struct NoteListPage {
@State pageState: PageState = PageState.LOADING
@State noteList: Note[] = []
async aboutToAppear() {
RdbUtil.setContext(getContext(this))
await this.loadData()
}
// 统一刷新数据方法,重试按钮复用
async loadData() {
this.pageState = PageState.LOADING
try {
const list = await RdbUtil.queryNoteList(0, 20)
this.noteList = list
if (list.length === 0) {
this.pageState = PageState.EMPTY
} else {
this.pageState = PageState.CONTENT
}
LogUtil.info("NoteList", "笔记数据加载完成,条数:", list.length)
} catch (err)
LogUtil.error("NoteList", "数据库查询失败", err)
this.pageState = PageState.ERROR
}
}
build() {
Column({ space: 12 }) {
Row() {
Text("我的笔记").fontSize(22).layoutWeight(1)
Button("新增笔记").backgroundColor("#007DFF")
}
.width("95%")
// 通用状态容器,传入状态、重试回调、业务列表插槽
StateView({
state: this.pageState,
onRetry: () => this.loadData()
}) {
// 正常业务列表内容插槽
List({ space: 10 }) {
ForEach(this.noteList, (item: Note) => {
ListItem() {
Row() {
Column().layoutWeight(1) {
Text(item.title).fontSize(17)
Text(item.content).fontColor("#666")
}
Button("删除").backgroundColor("#f56c6c")
}
.width("100%")
.padding(16)
.backgroundColor(Color.White)
.borderRadius(10)
}
})
}
.width("95%")
}
}
.width("100%")
.height("100%")
.padding(12)
.backgroundColor("#F5F5F5")
}
async aboutToDisappear() {
await RdbUtil.closeDB()
}
}
四、自定义文案局部覆盖示例(资讯页面)
ets
StateView({
state: this.pageState,
loadingText: "资讯拼命加载中...",
emptyText: "暂无资讯内容",
errorText: "网络开小差了,点击重新加载",
onRetry: () => this.refreshNews()
}) {
// 资讯列表业务布局
List() { ... }
}
五、通用状态组件开发编码规范
5.1 状态变量规范
- 所有列表页面统一使用
@State pageState: PageState,禁止自定义 isLoading、isEmpty 零散变量; - 数据请求开始强制赋值
PageState.LOADING; - 请求成功判断数组长度,0 条切换 EMPTY,有数据切换 CONTENT;
- 请求捕获异常统一切换 ERROR。
5.2 组件复用规范
- 所有列表、分页、接口页面统一使用 StateView 包裹业务列表;
- 全局默认文字统一写在组件入参默认值,统一产品 UI 风格;
- 单页面特殊场景仅局部传参覆盖文字,不重复新建空页面布局。
5.3 动画生命周期规范
- 组件内置 aboutToDisappear 重置旋转角度,终止无限加载动画;
- 禁止页面内单独写加载动画,统一复用组件内置动画。
5.4 重试逻辑规范
- 数据加载逻辑抽离独立
loadData方法,页面初始化、重试按钮共用; - 错误页面点击重试自动执行完整刷新流程,无需重复编写请求代码。
5.5 性能渲染规范
- 使用
@BuilderParam插槽,仅切换状态时局部重渲染,不刷新页面其他控件; - 四种视图互斥渲染,同一时间仅显示一种,避免多层组件重叠占用渲染资源。
六、高频问题与解决方案
问题 1:页面退出后加载旋转动画持续后台运行 解决:组件内部 aboutToDisappear 重置 rotateAngle 终止无限循环动画。
问题 2:接口返回空列表,页面同时显示加载和空视图 解决:请求完成后同步更新 pageState,互斥 if 分支只会渲染单一视图。
问题 3:多个页面空页面图标、文字不统一 解决:统一在 StateView 设置默认参数,全局一套 UI 标准,特殊页面单独覆盖文字。
问题 4:点击重试重复发起多次请求 解决:页面增加请求 loading 锁,请求期间拦截重复点击按钮。
问题 5:状态切换页面明显抖动、布局偏移 解决:四种视图统一设置宽高 100% 居中,固定占位区域,布局尺寸无变化。
七、总结
StateView 通用状态兜底组件统一封装加载、空数据、网络错误三大兜底页面,通过枚举统一管理页面业务状态,利用 Builder 插槽实现业务布局与状态视图解耦,一套组件适配项目所有列表、资讯、笔记、商品页面,消除大量重复布局代码,统一 APP 全局空 / 错误 / 加载 UI 风格。 完全兼容前文 RDB、Http 网络请求、日志工具配套使用,API23 优化组件渲染与动画回收,是企业级鸿蒙项目通用基础业务组件,可直接整合进整套 HAR 分层架构。