HarmonyOS APP《画伴梦工厂》开发第4篇:@Link、@Prop 与 @StorageLink——组件间数据通信

难度 :⭐⭐ 进阶

前置知识 :1.3 @State 与状态管理

涉及源文件CreationComponents.etsBreakpointSystem.etsIndex.etsRecognitionWaitingPage.etsPhotoRecognitionPage.etsFreeDoodlePage.ets


在上一篇中,我们学习了用 @State 管理组件内部状态。但在真实应用中,数据很少只在一个组件内闭环------它需要在父子组件之间兄弟组件之间 、甚至跨页面之间 共享和同步。HarmonyOS ArkUI 为这些场景提供了三种装饰器:@Prop@Link@StorageLink

本文将通过「画伴梦工厂」项目的真实代码,带你掌握它们的核心区别与使用场景。


1. 三种装饰器概览

装饰器 数据流方向 适用场景 同步机制
@Prop 父 → 子(单向) 子组件接收父组件传入的只读/可覆写初始值 父变则子变,子变不影响父
@Link 父 ↔ 子(双向) 父子组件共享同一状态,任一方修改都同步 数据引用共享,实时同步
@StorageLink 任意组件 ↔ AppStorage(跨组件/跨页面) 多页面共享全局状态,如主题、断点、用户信息 通过 AppStorage 代理同步

一句话总结:

  • @Prop:父组件"告诉"子组件一个值,子组件可以自己改着玩,但不影响父组件。
  • @Link:父子组件"共享"一个值,谁改都会通知对方。
  • @StorageLink:任何组件都能读写一个全局值,适合跨页面状态。

在「画伴梦工厂」中,PhotoRecognitionPage(父组件)和 PhotoRecognitionComponent(子组件)之间通过 @Link 实现了紧密的状态同步。

CreationComponents.ets 中,子组件用 @Link 声明需要从父组件获取的共享状态:

typescript 复制代码
@Component
export struct PhotoRecognitionComponent {
  @Link generationProgress: number;  // 生成进度
  @Link noticeText: string;          // 通知文本

  // 组件内部通过 @State 管理自身状态
  @State private hasPhoto: boolean = false;
  @State private recognizing: boolean = false;
  @State private activeStep: number = 0;
  @State private capturedImageUri: string = '';

  // 在方法中可以直接修改 @Link 变量
  private capturePhoto(uri: string = '', sourceLabel: string = '拍照图片') {
    // ...
    this.generationProgress = 35;     // ← 修改同步到父组件
    this.noticeText = '已采集画作,可以直接生成动画';
  }
}

同理,FreeDoodleComponent 声明了更多的 @Link 变量:

typescript 复制代码
@Component
export struct FreeDoodleComponent {
  @Link selectedTool: number;
  @Link selectedColor: number;
  @Link brushSize: number;
  @Link generationProgress: number;
  @Link noticeText: string;
  @Link isDrawingOnCanvas: boolean;
  // ...
}

2.2 父组件通过 $ 语法传递引用

父组件 PhotoRecognitionPage.ets 在自己的 @State 中定义状态,然后通过 $ 前缀将引用传给子组件:

typescript 复制代码
@Entry
@Component
struct PhotoRecognitionPage {
  @State private generationProgress: number = 0;
  @State private noticeText: string = '';

  build() {
    Scroll() {
      Column() {
        // $ 符号将 @State 变量转为 @Link 引用
        PhotoRecognitionComponent({
          generationProgress: $generationProgress,
          noticeText: $noticeText
        })
      }
    }
  }
}

FreeDoodlePage 的用法类似:

typescript 复制代码
FreeDoodleComponent({
  selectedTool: $selectedTool,
  selectedColor: $selectedColor,
  brushSize: $brushSize,
  generationProgress: $generationProgress,
  noticeText: $noticeText,
  isDrawingOnCanvas: $isDrawingOnCanvas
})

关键点$variableName 是 ArkUI 中获取 @State 变量引用的语法,传递给子组件的 @Link 后,双方操作的是同一个数据源。

2.3 实际效果

当用户在 PhotoRecognitionComponent 中点击「生成动画」时:

  1. 子组件修改 this.generationProgress = 100
  2. 父组件 PhotoRecognitionPage 中的 generationProgress 同步更新
  3. 父组件 UI 中的 Progress 组件自动刷新
typescript 复制代码
// PhotoRecognitionPage build 中的进度条
Progress({ value: this.generationProgress, total: 100, type: ProgressType.Linear })

这就是 @Link 双向绑定的威力------子组件操作状态,父组件的 UI 立即响应。


3. @Prop 单向传值:父传子,子可改写但不影响父

@Prop@Link 的核心区别在于同步方向

  • 父组件更新 → @Prop 变量同步更新 ✅
  • 子组件修改 @Prop 变量 → 同步回父组件 ❌

虽然「画伴梦工厂」项目中未直接使用 @Prop,但我们可以通过 @Builder 的参数传递模式来理解单向传值的概念(详见 1.5 篇)。

typescript 复制代码
// 这相当于单向传值模式------父组件传参进来,子 UI 片段使用
@Builder
private Pill(text: string, active: boolean = false) {
  Text(text)
    .fontSize(12)
    .fontColor(active ? '#FFFFFF' : '#6F7590')
    .backgroundColor(active ? this.brandPurple : '#F0F1F8')
    .borderRadius(14)
}

何时用 @Prop? 当子组件只需要"读"父组件的值,或者子组件想有自己的副本但不影响父组件时。比如一个仅展示用的信息卡片。


如果说 @Link 解决的是父子组件通信,那 @StorageLink 解决的是跨页面/跨组件 的全局状态共享。它通过 ArkUI 的 AppStorage 实现。

4.1 注册断点系统

Index.ets 中,通过 @StorageLink 声明一个应用级状态变量:

typescript 复制代码
@Entry
@Component
struct Index {
  @StorageLink('mainBreakpoint') currentBreakpoint: string = 'md';
  //                     ↑ 键名              ↑ 默认值
  // ...
  private readonly breakpointSystem: BreakpointSystem = new BreakpointSystem('mainBreakpoint');
}

4.2 BreakpointSystem 写入 AppStorage

BreakpointSystem.ets 监听媒体查询变化,当断点改变时通过 AppStorage.set() 更新全局状态:

typescript 复制代码
export class BreakpointSystem {
  private currentBreakpoint: string = 'md';
  private readonly breakpointId: string;

  constructor(breakpointId: string) {
    this.breakpointId = breakpointId;
  }

  public register(uiContext: UIContext): void {
    this.breakpoints.forEach((breakpoint, index) => {
      let condition: string = '';
      if (index === this.breakpoints.length - 1) {
        condition = `(${breakpoint.size}vp<=width)`;
      } else {
        condition = `(${breakpoint.size}vp<=width<${this.breakpoints[index + 1].size}vp)`;
      }
      breakpoint.mediaQueryListener = uiContext.getMediaQuery().matchMediaSync(condition);
      breakpoint.mediaQueryListener.on('change', (mediaQueryResult) => {
        if (mediaQueryResult.matches) {
          this.updateCurrentBreakpoint(breakpoint.name);
        }
      });
    });
  }

  private updateCurrentBreakpoint(breakpoint: string): void {
    if (this.currentBreakpoint !== breakpoint) {
      this.currentBreakpoint = breakpoint;
      // 写入 AppStorage,所有 @StorageLink('mainBreakpoint') 自动更新
      AppStorage.set<string>(this.breakpointId, this.currentBreakpoint);
    }
  }
}

4.3 Index.ets 响应断点变化

AppStorage 中的 'mainBreakpoint' 发生变化时,Index.ets 中的 currentBreakpoint 自动更新,驱动 UI 响应式布局:

typescript 复制代码
// 根据 currentBreakpoint 返回不同的间距值
private pageEdge(): number {
  return new BreakPointType<number>({ sm: 12, md: 24, lg: 36, xl: 56 })
    .getValue(this.currentBreakpoint);
}

// 根据断点返回不同的面板宽度
private panelWidth(): string {
  return new BreakPointType<string>({ sm: '90%', md: '86%', lg: '74%', xl: '64%' })
    .getValue(this.currentBreakpoint);
}

// 底部栏宽度也响应断点
private bottomBarWidth(): string {
  return new BreakPointType<string>({ sm: '86%', md: '72%', lg: '58%', xl: '48%' })
    .getValue(this.currentBreakpoint);
}

// Hero 区域高度
private heroHeight(): number {
  return new BreakPointType<number>({ sm: 420, md: 430, lg: 460, xl: 480 })
    .getValue(this.currentBreakpoint);
}

4.4 数据流全景

复制代码
设备窗口变化
    ↓
BreakpointSystem.register() 监听媒体查询
    ↓
断点触发 → updateCurrentBreakpoint()
    ↓
AppStorage.set('mainBreakpoint', 'lg')
    ↓
Index.ets @StorageLink('mainBreakpoint') 自动更新
    ↓
pageEdge() / panelWidth() / heroHeight() 重新计算
    ↓
UI 响应式刷新

这种模式下,任何页面只要用 @StorageLink('mainBreakpoint') 声明变量,就能自动感知断点变化,实现真正的响应式设计。

在 Index.ets 的 aboutToAppear 中,还展示了 AppStorage.getAppStorage.setOrCreate 的用法:

typescript 复制代码
aboutToAppear() {
  // 从 AppStorage 读取跨页面传递的参数
  const launchTab = AppStorage.get<string>('launchTab');
  if (launchTab === 'works') {
    const launchWorkIndex = AppStorage.get<number>('launchWorkIndex');
    if (typeof launchWorkIndex === 'number') {
      this.openWorkDetail(launchWorkIndex);
    }
    AppStorage.setOrCreate<string>('launchTab', '');
    AppStorage.setOrCreate<number>('launchWorkIndex', -1);
  }
}

这种方式适合页面间传递启动参数,比 Router 传参更灵活------即使页面还未创建,数据也不会丢失。


5. 页面间数据传递:Router + getParams()

除了 @StorageLink,页面间还有一种常用的传参方式:Router 参数传递

5.1 发起方:pushUrl 携带参数

CreationComponents.ets 中,组件通过 getUIContext().getRouter().pushUrl() 跳转并传参:

typescript 复制代码
private navigateToGeneration() {
  this.getUIContext().getRouter().pushUrl({
    url: 'pages/RecognitionWaitingPage',
    params: {
      source: '图片生成',
      workSource: 'photo',
      prompt: this.buildVideoPrompt(),
      imageUri: this.capturedImageUri,
      coverUri: this.capturedImageUri
    }
  });
}

FreeDoodleComponent 也是同样的模式:

typescript 复制代码
this.getUIContext().getRouter().pushUrl({
  url: 'pages/RecognitionWaitingPage',
  params: {
    source: '自由涂鸦',
    workSource: 'doodle',
    prompt: '把这张儿童涂鸦变成温暖的短动画,保留粗笔触和明亮色块',
    imageUri: imageUri,
    coverUri: imageUri
  }
});

5.2 接收方:aboutToAppear + getParams()

RecognitionWaitingPage.ets 中,通过 aboutToAppear 生命周期方法接收参数:

typescript 复制代码
interface WaitingParams {
  source?: string;
  workSource?: string;
  prompt?: string;
  imageUri?: string;
  coverUri?: string;
  recognitionResult?: string;
}

@Entry
@Component
struct RecognitionWaitingPage {
  @State private source: string = '画作识别';
  @State private workSource: string = 'photo';
  @State private prompt: string = '';
  @State private imageUri: string = '';
  @State private coverUri: string = '';

  aboutToAppear() {
    const params = this.getUIContext().getRouter().getParams() as WaitingParams;
    if (params && params.source) {
      this.source = params.source;
    }
    if (params && params.workSource) {
      if (params.workSource === 'doodle') {
        this.workSource = 'doodle';
      } else if (params.workSource === 'ai-chat') {
        this.workSource = 'ai-chat';
      } else {
        this.workSource = 'photo';
      }
    }
    if (params && params.prompt) {
      this.prompt = params.prompt;
    }
    if (params && params.imageUri) {
      this.imageUri = params.imageUri;
    }
    if (params && params.coverUri) {
      this.coverUri = params.coverUri;
    }
    this.startGeneration();
  }
}
传递方式 适用场景 优点 缺点
Router + params 页面跳转时的上下文数据 职责清晰,一次性传递 仅跳转时可用,返回时需重新传
@StorageLink / AppStorage 全局状态、跨页面共享 随时读写,全局生效 需要管理键名,避免冲突
@Link 父子组件实时同步 强类型,编译期检查 仅限父子关系

6. 回调模式:通过参数传递函数实现反向通信

@Builder 除了构建 UI,还可以通过参数传递回调函数 实现子组件到父组件的反向通信。虽然这不是严格意义上的 @Prop/@Link,但在 ArkUI 中是一种常见的通信模式。

Index.ets 中,NavIcon 通过 onClick 回调父组件的方法:

typescript 复制代码
@Builder
private NavIcon(index: number) {
  Column() {
    Text(NAV_ICONS[index])
      .fontSize(20)
      .fontColor(this.isPrimaryTabActive(index) ? this.brandPurple : '#8E94A6')
    Text(NAV_ITEMS[index])
      .fontSize(12)
      .fontColor(this.isPrimaryTabActive(index) ? this.brandPurple : '#747A92')
  }
  .onClick(() => {
    this.openPrimaryTab(index);  // ← 调用父组件的方法
  })
}

FeatureCard 通过 modeIndex 参数和 onClick 回调调用 openCreationFlow

typescript 复制代码
@Builder
private FeatureCard(title: string, desc: string, icon: string, color: string, modeIndex: number) {
  // ...
  .onClick(() => {
    this.openCreationFlow(modeIndex);  // 回调父组件方法
  })
}

这种模式本质上是一种事件冒泡------子 UI 片段通过回调函数通知父组件执行逻辑,父组件持有状态的修改权。


7. 对比总结

特性 @Prop @Link @StorageLink
数据流 单向(父→子) 双向(父↔子) 全局双向
使用场景 子组件展示父数据 父子实时同步状态 跨页面/跨组件
声明方式 @Prop variable: type @Link variable: type @StorageLink('key') variable: type
传递方式 直接传值 variable={value} $ 引用 variable: $stateVar 通过 AppStorage 自动同步
子组件修改 可修改,不影响父 修改后同步到父 修改后同步到所有使用者
性能特征 浅比较,仅值变化时更新 引用绑定,实时同步 AppStorage 代理,跨页面无延迟
项目中示例 @Builder 参数模拟) PhotoRecognitionComponent BreakpointSystem + currentBreakpoint

8. 小结

本文从三个维度梳理了 ArkUI 的数据通信体系:

  1. @Link :父子组件双向绑定,用 $ 语法传递引用,适合紧密耦合的组件对。
  2. @Prop:父子组件单向传值,适合子组件只读展示或独立维护副本。
  3. @StorageLink + AppStorage:应用级全局状态,通过键值对实现任意组件的状态共享,是实现响应式布局的基石。

此外,我们还学习了 Router getParams() 的页面间传参方式,以及 回调函数模式 在 @Builder 中的反向通信用法。

在实际开发中,数据通信方案的选择应遵循"最小化共享范围"原则:能用局部 @State 解决的不用 @Link,能用 @Link 解决的不用 @StorageLink,避免过度设计导致的状态管理混乱。


下一篇预告:第1.5篇《@Builder 与 @BuilderParam------复用 UI 的利器》。当你的页面中有大量重复的 UI 片段时,@Builder 是如何让代码变得简洁而优雅的?我们将在下一篇中深入探讨。