鸿蒙应用开发实战【07】— ArkUI 状态管理全解析(@State/@Prop/@Link/@Watch)

前言

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
在这里插入图片描述

ArkUI 的响应式系统是其最核心的特性之一——当被装饰器标记的变量发生变化时,ArkUI 框架会自动、精确地重新渲染受影响的 UI 节点,无需手动调用 setState 或触发更新。本篇全面讲解 ArkUI 的状态管理体系,结合号码助手项目中的真实代码,深入理解每个装饰器的使用场景和注意事项。

本篇涵盖:@State(组件内状态)、@Prop(父传子只读)、@Link(双向绑定)、@Watch(变量监听)、@Observed + @ObjectLink(嵌套对象)、以及常见的状态更新陷阱。


一、ArkUI 状态管理体系全览

1.1 装饰器一览表

装饰器 作用域 数据流向 适用场景
@State 组件内 单向(内部驱动 UI) 组件自己的状态
@Prop 父传子 单向(父→子,子只读) 父组件传递配置
@Link 父传子 双向(父↔子同步) 父子共享可变状态
@Watch 附加在其他装饰器上 监听变化触发回调 状态变化时执行副作用
@StorageProp AppStorage→组件 单向(全局→组件) 读取全局状态
@StorageLink AppStorage↔组件 双向(全局↔组件) 读写全局状态
@Provide 祖先→后代 单向(跨层传递) 跨多层组件传数据
@Consume 接收 @Provide 双向(自动关联) 消费祖先注入的数据
@Observed 类声明 使对象属性可被监听
@ObjectLink 子组件 双向(监听对象属性) 列表项组件编辑

1.2 号码助手中使用的装饰器统计

@State:  HomePage(10个)、AddCardPage(6个)、AppDetailPage(3个)、...
@Prop:   AppListItem(6个)、AvatarBadge(5个)、StatusBadge(1个)
@StorageProp: AppDetailPage(1个)
@Watch:  (当前未使用,可用于监听筛选条件变化)

二、@State — 组件内响应式状态

2.1 基本用法

@Entry
@Component
struct HomePage {
  // @State 变量改变时,ArkUI 自动重新渲染关联的 UI 节点
  @State loading: boolean = true
  @State rows: AppRow[] = []
  @State selectMode: boolean = false
  @State selectedIds: number[] = []

  build() {
    Column() {
      if (this.loading) {
        LoadingProgress()            // loading 为 true → 渲染这个
      } else if (this.rows.length === 0) {
        Text('暂无数据')              // rows 空 → 渲染这个
      } else {
        // rows 有数据 → 渲染列表
        ForEach(this.rows, (row: AppRow) => {
          Text(row.binding.app_name)
        }, (row: AppRow) => `${row.binding.id ?? 0}`)
      }
    }
  }
}

2.2 @State 支持的类型

类型 示例 是否支持
基本类型 boolean / number / string
对象类型 CardEntity / AppRow
数组类型 number[] / AppRow[]
null string | null = null
any ❌(ArkTS 禁止 any)
undefined ⚠️(需谨慎,建议用 null)

2.3 @State 数组更新的正确姿势

// ❌ 直接 push 不触发 UI 刷新(引用未变)
this.selectedIds.push(newId)

// ✅ 使用 concat 创建新数组(引用变化触发刷新)
this.selectedIds = this.selectedIds.concat([newId])

// ❌ 直接修改数组元素
this.rows[0].binding.status = '已停用'

// ✅ 替换整个数组(或使用 @Observed + @ObjectLink)
this.rows = this.rows.map((r: AppRow) => {
  if (r.binding.id === targetId) {
    return { ...r, binding: { ...r.binding, status: '已停用' } }
  }
  return r
})

ArkUI 响应式原理@State 跟踪的是变量引用。基本类型赋新值、对象/数组赋新引用都会触发刷新;直接修改对象属性或 push 数组元素不会触发刷新。


三、@Prop — 父传子单向绑定

3.1 基本用法

// 父组件:AppListItem 的使用方
AppListItem({
  appName: row.binding.app_name,   // 传递属性
  status: row.binding.status,
  bindingId: row.binding.id ?? 0
})

// 子组件:AppListItem 的定义
@Component
export struct AppListItem {
  @Prop appName: string = ''        // 接收父组件属性,必须有默认值
  @Prop status: string = '使用中'
  @Prop bindingId: number = 0

  build() {
    Row() {
      Text(this.appName)            // 只读,不能修改父组件状态
      StatusBadge({ status: this.status })
    }
  }
}

3.2 @Prop 的关键约束

// ❌ 不允许:在子组件中修改 @Prop 值来影响父组件
.onClick(() => {
  this.appName = '新名字'   // 子组件可以改自己的副本,但不同步到父
})

// ✅ 需要修改父数据时,使用回调函数模式
@Component
export struct AppListItem {
  @Prop bindingId: number = 0
  onDelete?: () => void    // 回调函数,非 @Prop

  build() {
    Row() {
      Button('删除').onClick(() => {
        if (this.onDelete) {
          this.onDelete()   // 通知父组件
        }
      })
    }
  }
}

// 父组件使用回调
AppListItem({
  bindingId: row.binding.id ?? 0,
  onDelete: () => { this.deleteBinding(row.binding.id ?? 0) }
})

四、@Link — 父子双向绑定

4.1 基本用法

// 父组件:将 @State 变量通过 $$ 符号传递给子组件
@Entry
@Component
struct AddCardPage {
  @State selectedColorIndex: number = 0

  build() {
    Column() {
      // 使用 $$ 传递 @Link 绑定
      ColorPicker({ selectedIndex: $selectedColorIndex })
      Text(`当前选中色块:${this.selectedColorIndex}`)
    }
  }
}

// 子组件:使用 @Link 接收双向绑定
@Component
struct ColorPicker {
  @Link selectedIndex: number   // 双向绑定,修改会同步到父

  build() {
    Row() {
      ForEach([0, 1, 2, 3, 4], (i: number) => {
        Circle()
          .onClick(() => { this.selectedIndex = i })  // 修改会同步到父组件
      }, (i: number) => `${i}`)
    }
  }
}

4.2 @Prop vs @Link 对比

特性 @Prop @Link
数据流向 父→子(单向) 父↔子(双向)
子修改后父同步 ❌ 不同步 ✅ 同步
父修改后子刷新 ✅ 刷新 ✅ 刷新
传递方式 直接传值 $$ 传引用
适用场景 展示型子组件 交互型子组件

4.3 bindSheet 使用 @Link 的典型场景

// AddAppPage.ets — bindSheet 使用 @Link 控制显示状态
@Entry
@Component
struct AddAppPage {
  @State showCardSheet: boolean = false  // @State 控制底部抽屉

  build() {
    Column() {
      // 点击按钮打开底部抽屉
      Button('选择卡号')
        .onClick(() => { this.showCardSheet = true })
    }
    // bindSheet 使用 $$ 绑定(内部使用 @Link 语义)
    .bindSheet($$this.showCardSheet, this.CardPickerSheet, {
      height: SheetSize.FIT_CONTENT,
      preferType: SheetType.BOTTOM
    })
  }
}

五、@Watch — 状态变化监听

5.1 基本用法

@Entry
@Component
struct FilterDemo {
  // @Watch 监听 filterCatIdx 变化,自动触发回调
  @State @Watch('onFilterChanged') filterCatIdx: number = 0
  @State @Watch('onFilterChanged') filterCardIdx: number = 0
  @State @Watch('onFilterChanged') filterStatIdx: number = 0

  @State filteredCount: number = 0

  // 任何一个筛选条件变化时自动调用
  onFilterChanged(propName: string): void {
    console.info(`筛选条件变化:${propName}`)
    // 重新计算过滤结果数量
    this.filteredCount = this.getFilteredRows().length
  }
}

5.2 @Watch 与直接计算的对比

// 方案A:@Watch 触发副作用(适合有异步操作的场景)
@State @Watch('saveFilter') filterCatIdx: number = 0

saveFilter(): void {
  // 异步保存筛选状态到持久化
  preferences.saveFilter(this.filterCatIdx)
}

// 方案B:直接计算属性(适合纯 UI 计算,更推荐)
private getFilteredRows(): AppRow[] {
  return this.rows.filter((r: AppRow) => {
    const catOk = this.filterCatIdx === 0 ||
      r.binding.category === CATEGORIES[this.filterCatIdx]
    // ...
    return catOk
  })
}
// 在 build() 中直接调用 this.getFilteredRows()

建议:纯 UI 相关的计算用 private 方法直接计算(ArkUI 会在相关 @State 变化时自动重新调用);有副作用(网络、存储、日志)的操作才用 @Watch


六、号码助手中的状态管理实战

6.1 首页的完整状态设计

@Entry
@Component
struct HomePage {
  // ── 数据状态 ──────────────────────────────────
  @State loading: boolean = true           // UI 加载态
  @State rows: AppRow[] = []              // 绑定列表
  @State cards: CardEntity[] = []         // 卡号列表(供筛选使用)
  @State pendingSwitch: number = 0        // 待换绑数量
  @State pendingCancel: number = 0        // 待注销数量
  @State disabledCount: number = 0        // 已停用数量

  // ── UI 交互状态 ──────────────────────────────
  @State showFilter: boolean = false      // 筛选弹窗是否显示
  @State filterCatIdx: number = 0         // 分类筛选(0=全部)
  @State filterCardIdx: number = 0        // 卡号筛选(0=全部)
  @State filterStatIdx: number = 0        // 状态筛选(0=全部)
  @State selectMode: boolean = false      // 批量选择模式
  @State selectedIds: number[] = []       // 已选 ID 列表
}

6.2 AddCardPage 的表单状态

@Entry
@Component
struct AddCardPage {
  // 每个表单字段对应一个 @State
  @State phone: string = ''
  @State label: string = ''
  @State selectedColorIndex: number = 0    // 0-4 对应 blue/purple/green/orange/pink
  @State selectedCarrierIndex: number = 0  // 0-2 对应移动/联通/电信
  @State saving: boolean = false           // 防重复提交

  // 表单校验:不需要 @State,直接计算
  private canSave(): boolean {
    return this.phone.trim().length === 11 &&
           this.label.trim().length > 0 &&
           !this.saving
  }
}

6.3 AppDetailPage 的复合状态

@Entry
@Component
struct AppDetailPage {
  // 页面数据状态(三态 UI)
  @State loading: boolean = true
  @State loadError: boolean = false
  @State binding: AppBindingEntity | null = null

  // 从全局 AppStorage 读取传递的 ID
  @StorageProp('current_binding_id') bindingId: number = 0
}

七、状态更新常见陷阱

7.1 ForEach key 函数影响更新效率

// ❌ key 函数只用 index:导致数组顺序变化时全量重渲染
ForEach(this.rows, (row: AppRow) => {
  AppListItem({ ... })
}, (row: AppRow, index: number) => `${index}`)   // 仅用 index 作为 key

// ✅ key 函数包含数据特征:精确定位需要更新的列表项
ForEach(this.rows, (row: AppRow) => {
  AppListItem({ ... })
}, (row: AppRow) => `binding_${row.binding.id ?? 0}_${row.binding.status}`)
//  ↑ 包含 id(唯一标识)和 status(影响显示的状态)

7.2 @State 对象深层属性不触发更新

// ❌ 修改对象深层属性不触发刷新
this.binding.status = '已停用'  // ArkUI 不检测深层属性变化

// ✅ 替换整个对象触发刷新
const updated: AppBindingEntity = { ...this.binding, status: '已停用' }
this.binding = updated

7.3 async 函数中的状态更新

// ❌ catch 中忘记更新 loading 状态,页面永久转圈
private async loadData(): Promise<void> {
  this.loading = true
  try {
    this.rows = await AppBindingDao.listAll()
  } catch (e) {
    console.error(e)
    // 忘记 this.loading = false!
  }
  this.loading = false  // 如果 catch,这里不会执行
}

// ✅ 使用 finally 确保 loading 一定被关闭
private async loadData(): Promise<void> {
  this.loading = true
  try {
    this.rows = await AppBindingDao.listAll()
  } catch (e) {
    console.error(e)
  } finally {
    this.loading = false   // ✅ 无论成功失败都执行
  }
}

八、本篇小结

装饰器 核心用途 号码助手中的使用
@State 组件内响应式状态 loading、rows、selectMode 等
@Prop 父传子只读属性 AppListItem 的 appName、status 等
@Link 父子双向绑定 bindSheet 的 showCardSheet
@Watch 状态变化副作用 可用于监听筛选条件保存
@StorageProp 全局状态只读 AppDetailPage 的 bindingId

最重要的记忆点:数组/对象的深层修改不触发 @State 刷新,必须替换整个引用(this.arr = [...newArr])。


参考资料

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐