HarmonyOS应用开发实战:猫猫大作战-HUD 拆成独立子组件、用 `@Prop` 接收父组件得分/时间为锚点,把 @Prop 声明与传递、单向只

前言

前面我们用 @State 让数据驱动 UI,所有状态都在 Index 这一个组件里。但实战中组件要拆分------HUD、棋盘、底部栏各自独立,才好维护复用。拆分后遇到第一个问题:父组件的得分怎么传给子组件 HUD? @State 只在当前组件有效,跨组件要用 @Prop(单向只读)@Link(双向)

本篇以「猫猫大作战」把 HUD 拆成独立子组件、用 @Prop 接收父组件得分/时间为锚点,把 @Prop 声明与传递单向只读语义@Prop 的浅拷贝@Prop vs @Link 取舍四大要点讲透。

提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1--39 篇。本篇是阶段二第十篇。

一、场景拆解:HUD 拆分子组件

回顾「猫猫大作战」HUD(第 11、23、24 篇)是 Index 内的 @Builder

ts 复制代码
// 来源:entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct Index {
  @State score: number = 0;
  @State combo: ComboInfo = { /* ... */ };
  @State gameTime: number = 0;

  @Builder
  GameHUD() {
    Row() {
      Column() { Text('得分'); Text(this.score.toString()) }    // ← 直接读 Index 的 @State
      /* ... 连击、时间 */
    }
  }
}

痛点 :HUD 逻辑和 Index 主逻辑混在一起,Index 越来越臃肿。想把 HUD 拆成独立组件 GameHUD

ts 复制代码
@Component
struct GameHUD {
  @Prop score: number;           // ← 从父接收
  @Prop combo: ComboInfo;
  @Prop gameTime: number;

  build() {
    Row() {
      Column() { Text('得分'); Text(this.score.toString()) }   // 读 @Prop
      /* ... */
    }
  }
}

父组件 Index 传值:

ts 复制代码
GameHUD({ score: this.score, combo: this.combo, gameTime: this.gameTime })

关键经验@Prop = 父传子单向只读------父改值子同步刷新,子改值不回传父。

二、@Prop 基本用法

2.1 子组件声明 @Prop

ts 复制代码
@Component
struct GameHUD {
  @Prop score: number;              // 从父接收,只读
  @Prop combo: ComboInfo;
  @Prop gameTime: number;

  build() {
    Row() {
      Column() {
        Text('得分').fontSize(11).fontColor('#95A5A6')
        Text(this.score.toString())      // 读 @Prop
          .fontSize(22).fontWeight(FontWeight.Bold).fontColor('#2C3E50')
      }
      /* ... 连击、时间 */
    }
  }
}

拆解

片段 含义
@Prop 装饰器,标记「从父接收只读数据」
score 变量名
number 类型(必须与父传值类型一致)

关键约束@Prop 不需要初始值------值由父传入,不赋默认。但 ArkTS 严格模式可能要显式类型。

2.2 父组件传值

ts 复制代码
@Entry
@Component
struct Index {
  @State score: number = 0;
  @State combo: ComboInfo = { count: 0, multiplier: 1, lastMergeTime: 0 };
  @State gameTime: number = 0;

  build() {
    Column() {
      GameHUD({
        score: this.score,           // 传 @State score
        combo: this.combo,           // 传 @State combo
        gameTime: this.gameTime      // 传 @State gameTime
      })
      /* ... 棋盘、底部栏 */
    }
  }
}

传递语法子组件名({ prop1: 父值1, prop2: 父值2 })------类似 React 的 props。

2.3 单向同步机制

ts 复制代码
// 父改 @State,子 @Prop 自动同步
this.score = 99;
// ArkUI:
// 1. Index 的 @State score = 99
// 2. 检测到 GameHUD 的 @Prop score 依赖父 @State score
// 3. 把 99 同步到子 @Prop score
// 4. 子组件 build() 重渲染,Text('99')

关键经验@Prop 是「父改子同步,子改父不回」的单向流------数据从父流向子,子组件不能改 @Prop 反向影响父。

三、@Prop 的只读语义

3.1 子组件不能改 @Prop

ts 复制代码
@Component
struct GameHUD {
  @Prop score: number;

  build() {
    Button('加 10').onClick(() => {
      this.score += 10;       // ❌ @Prop 只读,改了不回传父,且可能ArkUI 警告
    })
  }
}

为什么只读@Prop 是「父数据的子副本」,子改了不回传父,会导致父子数据不一致------ArkUI 为保证数据流单向,子改 @Prop 不影响父,且子下次重渲染会被父值覆盖

3.2 子要改数据用 @Event 回调父

子组件想「加 10」,要通过回调让父改:

ts 复制代码
@Component
struct GameHUD {
  @Prop score: number;
  @Event onScoreAdd: (delta: number) => void;   // 事件回调(第 49 篇专讲)

  build() {
    Button('加 10').onClick(() => {
      this.onScoreAdd(10);      // 触发事件,让父改 @State score
    })
  }
}

// 父
@Entry
@Component
struct Index {
  @State score: number = 0;

  build() {
    GameHUD({
      score: this.score,
      onScoreAdd: (delta: number) => { this.score += delta; }   // 父改 @State
    })
  }
}

关键经验子改数据用 @Event 回调父,@Prop 只用于显示------这是单向数据流的标准模式。本系列第 49 篇会专讲 @Event。

3.3 @Prop 的浅拷贝

ts 复制代码
@Prop combo: ComboInfo;       // 接收对象

机制@Prop 对父传入的对象做浅拷贝------子得到一份副本,改副本内部属性不影响父,但改了也不触发子重渲染(@Prop 浅观察)。

ts 复制代码
// 父
this.combo = { count: 5, multiplier: 3, lastMergeTime: Date.now() };
// 子 @Prop combo 收到新副本,重渲染

// 子改 @Prop combo 内部属性
this.combo.count = 10;       // ⚠️ 不触发子重渲染,且不回传父
// 父的 combo.count 还是 5

实战经验@Prop 对象当作只读快照用------别改内部属性,要改让父改整个对象传入。

四、完整改造:HUD 拆为子组件

4.1 创建 GameHUD 子组件

新建 entry/src/main/ets/components/GameHUD.ets

ts 复制代码
import { ComboInfo } from './GameTypes';
import { formatTime } from './GameUtils';        // 假设有工具函数

@Component
export struct GameHUD {
  @Prop score: number;
  @Prop combo: ComboInfo;
  @Prop gameTime: number;

  build() {
    Row() {
      // 左:得分
      Column() {
        Text('得分')
          .fontSize(11).fontColor('#95A5A6')
        Text(this.score.toString())
          .fontSize(22).fontWeight(FontWeight.Bold).fontColor('#2C3E50')
      }
      .layoutWeight(2)
      .displayPriority(1)
      .alignItems(HorizontalAlign.Start)

      // 中:连击(条件渲染)
      if (this.combo.count > 1) {
        Row() {
          Text(`🔥 x${this.combo.multiplier}`)
            .fontSize(18).fontWeight(FontWeight.Bold).fontColor('#E74C3C')
        }
        .layoutWeight(1)
        .displayPriority(2)
        .justifyContent(FlexAlign.Center)
        .padding({ left: 8, right: 8, top: 4, bottom: 4 })
        .backgroundColor('rgba(231, 76, 60, 0.1)')
        .borderRadius(16)
      }

      // 右:时间
      Column() {
        Text('时间')
          .fontSize(11).fontColor('#95A5A6')
        Text(formatTime(this.gameTime))
          .fontSize(18).fontWeight(FontWeight.Medium).fontColor('#2C3E50')
      }
      .layoutWeight(2)
      .displayPriority(1)
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 12, bottom: 8 })
  }
}

4.2 Index 引用并传值

ts 复制代码
// 来源:entry/src/main/ets/pages/Index.ets(改造后)
import { GameHUD } from '../components/GameHUD';
import { Cat, CatLevel, GameConfig, CatConfig, GameState, ComboInfo } from '../components/GameTypes';
import { GameEngine } from '../components/GameEngine';
import { formatTime } from '../components/GameUtils';

@Entry
@Component
struct Index {
  @State gameState: GameState = GameState.IDLE;
  @State score: number = 0;
  @State cats: Cat[] = [];
  @State combo: ComboInfo = { count: 0, multiplier: 1, lastMergeTime: 0 };
  @State nextCatLevel: CatLevel = CatLevel.SMALL;
  @State highScore: number = 0;
  @State gameTime: number = 0;
  @State maxCombo: number = 0;
  @State mergeCount: number = 0;
  @State highestLevel: CatLevel = CatLevel.SMALL;
  @State @Watch('onGameStateChange') gameState: GameState = GameState.IDLE;

  private gameEngine: GameEngine = new GameEngine();
  private gameLoopTimer: number = -1;
  private spawnTimer: number = -1;
  private timeTimer: number = -1;
  private readonly cols: number[] = [0, 1, 2, 3, 4];
  private readonly rows: number[] = [0, 1, 2, 3, 4, 5, 6, 7];

  /* startGame / pauseGame / resumeGame / endGame / handleColumnClick / clearTimers 筁略 */

  formatTime(seconds: number): string {
    const min = Math.floor(seconds / 60);
    const sec = seconds % 60;
    return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
  }

  build() {
    Stack() {
      if (this.gameState === GameState.IDLE) {
        this.MainMenuView()
      } else {
        this.GameView()
      }
      if (this.gameState === GameState.PAUSED) {
        this.PauseOverlay()
      }
      if (this.gameState === GameState.GAME_OVER) {
        this.GameOverOverlay()
      }
    }
    .width('100%').height('100%')
  }

  @Builder
  GameView() {
    Column() {
      // 改造:用子组件替代 @Builder GameHUD
      GameHUD({
        score: this.score,           // 传 @State
        combo: this.combo,
        gameTime: this.gameTime
      })

      Column() {
        /* 预告区、棋盘 */
      }.alignItems(HorizontalAlign.Center)

      Spacer()

      /* 底部控制栏 */
    }
    .width('100%').height('100%')
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [['#E8F4F8', 0.0], ['#D6EEF5', 0.5], ['#C9E8F2', 1.0]]
    })
    .alignItems(HorizontalAlign.Center)
  }

  /* MainMenuView / PauseOverlay / GameOverOverlay / StatItem 等略 */
}

4.3 改造对比

维度 原 @Builder 版 改造 @Prop 子组件版
HUD 代码位置 Index 内的 @Builder 独立 GameHUD.ets 文件
数据传递 直接读 this.score @Prop 接收 + 父传值
复用性 ❌ 只能在 Index 用 ✅ 任何组件可引用
可维护性 ❌ Index 越来越臃肿 ✅ 分文件维护
单向数据流 ❌ 子可直接改父 state ✅ @Prop 只读,强制单向

ArkUI 三种跨组件数据流,容易混淆:

5.1 对比表

装饰器 方向 读写 适合
@Prop 父→子 只读 显示型子组件(HUD、卡片)
@Link 父↔子 双向 子要改父数据(表单输入)
@Event 子→父 事件回调 子触发父方法(按钮点击)

5.2 场景对照

ts 复制代码
// 场景 1:HUD 显示得分 → @Prop(只读)
GameHUD({ score: this.score })

// 场景 2:表单子组件改父的 username → @Link(双向)
TextInput({ value: this.username })   // 内部用 @Link 同步

// 场景 3:子按钮点击让父加 10 → @Event(回调)
GameHUD({ score: this.score, onScoreAdd: (d) => this.score += d })

关键经验显示型子组件用 @Prop,改父数据用 @Link,触发父方法用 @Event。本系列第 41、49 篇会专讲 @Link、@Event。

5.3 取舍决策树

复制代码
子组件要改父数据?
  ├─ 是 → 子要改父的 @State?
      ├─ 是 → @Link(双向)
      └─ 否 → @Event(回调让父改)
  └─ 否 → @Prop(只读显示)

六、踩坑提示

6.1 忘记父传值

ts 复制代码
// ❌ 错误:没传 score,子 @Prop 是 undefined
GameHUD({ combo: this.combo, gameTime: this.gameTime })
// 子 Text(this.score.toString()) → Text('undefined')

// ✅ 正确:所有 @Prop 都传
GameHUD({ score: this.score, combo: this.combo, gameTime: this.gameTime })

6.2 类型不匹配

ts 复制代码
// 父
@State score: number = 0;

// ❌ 错误:子 @Prop 声明 string,父传 number
@Component
struct GameHUD {
  @Prop score: string;      // 类型不匹配
}

// ✅ 正确:类型一致
@Component
struct GameHUD {
  @Prop score: number;      // 与父 @State 类型一致
}

6.3 子改 @Prop 期望回传父

ts 复制代码
// ❌ 错误:改 @Prop 期望影响父
@Component
struct GameHUD {
  @Prop score: number;
  build() {
    Button('加 10').onClick(() => {
      this.score += 10;       // 改 @Prop,不回传父!
    })
  }
}

// ✅ 正确:用 @Event 回调父
@Component
struct GameHUD {
  @Prop score: number;
  @Event onScoreAdd: (delta: number) => void;
  build() {
    Button('加 10').onClick(() => {
      this.onScoreAdd(10);
    })
  }
}

6.4 对象改内部属性不触发重渲染

ts 复制代码
// ❌ 错误:父改 combo.count,子 @Prop combo 不重渲染
this.combo.count = 5;       // @State 浅观察,不触发;子也不同步

// ✅ 正确:父重新赋值整个 combo
this.combo = { count: 5, multiplier: 3, lastMergeTime: Date.now() };
// 父 @State 触发,子 @Prop 同步新副本

七、调试技巧

  1. console.info 在子 build 首行 :log this.score,追 @Prop 接收的值。
  2. 子不刷新排查:检查父是否重新赋值(对象/数组要整体赋值);检查类型是否匹配;检查是否忘传值。
  3. 子改了不回传排查:@Prop 本就单向,要回传用 @Event 或 @Link。
  4. DevEco ArkUI Inspector:查看组件树和 @Prop 绑定。

八、性能与最佳实践

  1. 显示型子组件用 @Prop------HUD、卡片、列表项等只读显示。
  2. 所有 @Prop 都要父传值------忘传子是 undefined。
  3. 类型与父 @State 一致------number 配 number,ComboInfo 配 ComboInfo。
  4. 对象/数组整体赋值才同步------@Prop 和 @State 都是浅观察。
  5. 子改数据用 @Event 或 @Link------@Prop 只读,改了不回传。
  6. @Prop 浅拷贝对象------子改副本不影响父,但也不触发重渲染。

九、阶段二进度小结(31-40)

本篇是阶段二「状态管理 + 交互 + 动画」第 10 篇,进度小结:

主题 核心要点
31 @State 响应式状态,改变触发重渲染
32 多 @State 同帧批量更新,一次重渲染
33 setInterval 主循环 100ms 物理周期,暂停短路
34 计时器 1000ms 秒级,formatTime 格式化
35 spawnTimer 2000ms 自动生成,随机列选择
36 clearTimers -1 哨兵,三时机清理,aboutToDisappear 兜底
37 onClick 列投放 闭包捕获 col,handleColumnClick 三步
38 箭头函数 this 回调统一箭头保留 this,普通函数打断链
39 @Watch 状态变化副作用集中,防递归守卫
40(本篇) @Prop 父子单向只读,浅拷贝,显示型子组件

接下来第 41-50 篇会进入 V1 状态管理深水区:@Link 双向、@Provide/@Consume 跨层、@Observed+@ObjectLink 深观察、数组项替换、批量更新、嵌套陷阱,然后 V2 迁移:@Local、@Param、@Event、@ObservedV2+@Trace、@Monitor、AppStorageV2、PersistenceV2。

总结

本篇我们从 @Prop 父子单向切入,掌握了**@Prop 声明与父传值语法**、单向只读语义(子改不回传)浅拷贝与浅观察@Prop vs @Link vs @Event 取舍 四大要点,并给出了 HUD 拆为子组件的完整改造代码。核心要点:显示型子组件用 @Prop;所有 @Prop 都要父传值;对象整体赋值才同步;子改数据用 @Event 或 @Link

下一篇我们将拆解 @Link------父子双向绑定。

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


相关资源:

相关推荐
yaoyaoxingzhe1 小时前
HarmonyOS应用开发实战:猫猫大作战-`ForEach` 的双层嵌套、`keyGenerator` 密钥、`width` 百分比响应式格子
harmonyos·鸿蒙
echohelloworld112 小时前
HarmonyOS应用开发实战:猫猫大作战-Row 横向布局、Spacer 占位、Button 双态样式(暂停灰 / 重新开始红)、底部 paddin
harmonyos·鸿蒙
爱写代码的森3 小时前
鸿蒙三方库 | harmony-utils之ObjectUtil对象拷贝与合并详解
华为·harmonyos·鸿蒙·huawei
fiona20264 小时前
HarmonyOS应用开发实战:猫猫大作战-Scroll 容器、Scroller 控制器、滚动监听与性能
harmonyos·鸿蒙
GitCode官方4 小时前
开源鸿蒙跨平台直播| 快手KRN鸿蒙适配与性能优化
华为·开源·harmonyos·atomgit
爱写代码的森4 小时前
鸿蒙三方库 | harmony-utils之TypeUtil类型检查工具详解
华为·harmonyos·鸿蒙·huawei
萌新源4 小时前
【鸿蒙开发实战】HarmonyOS 跨设备分享教程:隔空传送与碰一碰分享
华为·harmonyos
用户0934077735144 小时前
HarmonyOS WPS Open SDK 二开周回顾:从注册到关窗回传怎么串
typescript·harmonyos
程序员黑豆5 小时前
鸿蒙应用开发实战:轻松实现列表上拉加载更多
前端·华为·harmonyos