做 HarmonyOS 7 页面时,最容易把人绕进去的往往不是布局,而是"这个值到底该放哪儿"。页面计数、加载状态与条件渲染 看起来只是几行代码,真接进项目后,经常会遇到 UI 不刷新、返回页面数据变旧、弹窗取消后值却被改掉,或者一个请求失败把整个页面状态搅成一团。

这篇不背概念。我就按项目里最常见的写法,把 @State、普通变量与 UI 刷新的边界 拆开讲。目标很简单:代码能跑,状态变化能解释,出了问题知道先查哪儿。
本文环境按 HarmonyOS 7 + ArkTS + DevEco Studio 的工程习惯组织。不同 API 版本如果有细节差异,以你当前 SDK 的类型提示为准。
1. 先说问题:为什么这个状态值得单独管
很多页面刚开始都很简单。一个变量,一个按钮,一个接口。于是我们很自然地把值全塞进组件里。功能一多,问题就来了。
比如 页面计数、加载状态与条件渲染。用户点一次,你改一个变量;接口回来,再改一个变量;页面切走回来,又要恢复一次。只要其中某一步没有明确"谁负责修改、谁负责展示",状态就开始互相污染。
我判断一个值要不要进入 UI 状态,通常先问三个问题:它变了以后界面要不要立刻跟着变?它是不是只属于当前组件?页面销毁后还需不需要保留?这三个问题基本能过滤掉大多数乱用状态装饰器的情况。
这里有个很实用的习惯:业务数据和界面过程状态分开命名。 数据是 data、list、detail;过程状态是 loading、submitting、dialogVisible、error。别把一个布尔值既当"有没有数据"又当"是不是请求完成",后面一定难维护。
2. 先写一个最小版本
下面这段我故意写得短一点,先把核心跑通:
ts
@Entry
@Component
struct StateDemo {
@State count: number = 0
@State loading: boolean = false
build() {
Column({ space: 16 }) {
Text(`当前数量:${this.count}`)
Button('加 1').onClick(() => { this.count++ })
if (this.loading) { LoadingProgress() }
}.padding(20)
}
}
这段代码最重要的不是语法,而是状态变化路径。用户操作发生后,只修改真正需要变化的状态;UI 根据状态重新计算展示结果。这样调试时你可以沿着"事件 → 状态 → UI"往下找,而不是在十几个回调里猜。
实际项目里建议先把最小版本跑通,再加接口、弹窗和缓存。很多人一上来就把所有逻辑堆进去,最后报错时根本分不清是状态问题还是网络问题。
3. 真正容易踩坑的是状态边界
第一类坑是"能不用状态的值也做成状态"。例如一个只在点击事件内部使用的临时变量,它不参与 UI 渲染,就没必要为了保险全部声明成响应式状态。状态越多,阅读成本越高,也更容易出现无意义刷新。
第二类坑是"同一个事实存两份"。比如列表长度本来可以从 list.length 得到,又额外维护一个 count。新增数据时改了 list,忘了改 count,页面马上出现两个答案。能计算出来的值尽量计算,不要重复存。
第三类坑是异步回来以后不管页面当前处境直接赋值。请求发出去时用户可能已经切换筛选条件,旧请求后返回,就可能覆盖新结果。真实业务里要么给请求带查询条件并在返回时校验,要么统一做请求取消/序列控制。
ts
private requestSeq: number = 0
async refresh() {
const seq = ++this.requestSeq
this.loading = true
try {
const result = await loadData()
if (seq !== this.requestSeq) return
this.data = result
} finally {
if (seq === this.requestSeq) this.loading = false
}
}
这个小技巧很朴素,但对搜索、筛选、分页这种连续请求特别有用。旧请求即使晚回来,也没有资格覆盖最新状态。
4. 把页面状态画出来,代码会清楚很多
如果一个页面已经出现五六个布尔值,我建议先别继续写。拿纸画一下状态。比如请求页面通常就四种:初始、加载中、成功、失败。弹窗则是关闭、编辑中、确认完成。
布尔值最大的问题是可以组合出很多"不应该存在"的情况:loading=true 同时 error=true 到底展示谁?所以复杂页面可以直接用联合类型或枚举表达互斥状态。
ts
type ViewState = 'loading' | 'content' | 'empty' | 'error'
@State viewState: ViewState = 'loading'
渲染时只认这一份状态,页面就不会同时冒出 Loading 和错误提示。这个思路比不停加条件判断好维护得多。
5. 我在项目里会怎么拆
我一般把页面分成三层。第一层是页面容器,负责请求、路由和页面级状态;第二层是业务组件,只接收自己需要的数据;第三层是纯展示组件,尽量不碰网络和持久化。
这么拆的好处不是为了"架构漂亮",而是改需求时少牵连。比如产品只改空状态样式,你不应该碰请求代码;接口字段调整,也不应该顺手改弹窗显示逻辑。
ts
@Component
struct ContentPanel {
@Prop title: string
@Prop disabled: boolean = false
build() {
Row() {
Text(this.title)
Blank()
Text(this.disabled ? '不可操作' : '可操作')
}.width('100%').padding(16)
}
}
子组件只拿它需要的东西。不要图省事把整个巨大对象传进去,再让组件内部到处读字段。字段依赖越隐蔽,后面越难判断哪个变化会引起哪个组件更新。
6. 调试时别只盯着 UI
状态类问题有个特点:界面表现是结果,真正的原因往往发生在前面。所以我会在关键状态入口打日志,而不是每一行都打。建议至少记录操作名、请求序号、旧值和新值。
ts
function stateLog(name: string, before: object, after: object) {
console.info(`[state] ${name} before=${JSON.stringify(before)} after=${JSON.stringify(after)}`)
}
如果第二次点击没有反应,就先确认点击事件有没有进;进了以后状态条件是不是把逻辑提前 return;再看异步请求有没有真正发出。这个顺序比一上来清缓存、重装模拟器靠谱得多。
7. 再补一个完整一点的处理方式
真实页面里,我更喜欢把一次操作包成一个完整方法:入口校验、切换过程状态、执行业务、处理异常、最后恢复。这样按钮事件本身很薄。
ts
async onQueryClick() {
if (this.loading) return
this.loading = true
this.errorText = ''
try {
const result = await this.queryService()
this.applyResult(result)
} catch (err) {
this.errorText = '操作失败,请稍后重试'
} finally {
this.loading = false
}
}
finally 很关键。成功、失败、提前抛异常,最终都要把 loading 收回来。项目里那种"第一次能点,第二次永远没反应"的问题,经常就是某条异常路径没有恢复状态。
另外,不建议为了让 UI 立即变化到处加延时。setTimeout 能暂时把问题藏起来,但它没有解决状态归属和执行顺序。除非业务真的需要延时,否则先查数据流。
8. 再往真实项目推进一步
页面状态不是孤立的。真正的项目还会碰到返回页面是否重新请求、多个组件是否共享同一份筛选条件、缓存和网络谁先展示、快速重复点击如何防抖等问题。处理这些问题时,不要先问"该用哪个装饰器",先问数据生命周期:谁创建、谁修改、谁消费、什么时候失效。
如果一份数据只服务一个局部组件,就尽量留在局部;如果父组件需要控制子组件,就让依赖方向清晰;如果需要跨页面长期保存,再考虑持久化或更高层的数据模型。把生命周期想明白以后,API 反而是最简单的一步。
9. 这一篇记住什么
回头看 @State、普通变量与 UI 刷新的边界,核心其实就几句话:需要驱动 UI 的值才进入响应式状态;同一个事实尽量只有一个数据源;异步结果要防止旧请求覆盖新状态;复杂页面用明确的状态枚举替代一堆互相打架的布尔值;组件只接收自己真正需要的数据。
你可以拿手上的一个 HarmonyOS 7 页面做个小练习:把所有 @State 列出来,逐个问"它变化后真的需要刷新 UI 吗""这个值能不能从别的状态算出来""页面离开以后还要不要它"。通常扫一遍,就能删掉一批没必要的状态。
下一篇继续往真实项目里走,不讲虚的,直接处理下一层状态协作问题。