DEV Community

HarmonyOS
HarmonyOS

Posted on

Effective use of multi-builder architecture with state management

Read the original article:Effective use of multi-builder architecture with state management

Requirement Description

In modern application development, especially within the context of a single page, it is often necessary to manage both static and dynamic UI elements effectively. While some elements remain unchanged, others must update in response to user interactions or data changes. This is where the combination of multi-builder architecture and state management becomes crucial. By leveraging these techniques, developers can create efficient, scalable, and responsive user interfaces.

Background Knowledge

In the context of state management, an @State-decorated variable, also known as a state variable, is a fundamental concept. These variables hold state properties and drive the rendering of the custom component they belong to. When a state variable changes, the UI is automatically re-rendered to reflect the new state. The @State decorator is the cornerstone of state management, serving as the primary mechanism for defining stateful variables. It is the foundation for most state-related operations and ensures that the UI remains in sync with the underlying data.

On the other hand, the @Builder decorator in ArkUI provides a lightweight mechanism for reusing UI elements. A builder is a custom component with a fixed internal structure that receives data from the parent component. By abstracting reusable UI elements into builder methods, developers can simplify their code and improve maintainability. Builders are particularly useful for creating modular and scalable UI components.

Implementation Steps

To effectively implement multi-builder architecture with state management in a HarmonyOS project, follow these steps:

Define Builders: Create as many builders as needed within the page. Each builder should encapsulate a specific UI element or section that can be reused across the application. For example, a builder could represent a header, a button, or a card component.

Use Static Elements: In the main build method, define static UI elements directly. These are elements that do not change dynamically and can be rendered once without relying on state variables.

Integrate Stateful Builders: For builders that depend on changing state variables, include them within the main build method. Wrap these builders in appropriate conditional statements (if conditions) to ensure they are only rendered when their associated state variables change.

Manage State Changes: Use the @State decorator to define variables that drive dynamic UI changes. When these variables are updated, the corresponding builders will re-render, ensuring the UI stays up-to-date.

Code Snippet

Here is a sample example code below;

export struct CallView {

  icons: Icon[] = IconList
  @State callStatus: 'RINGING' | 'ACCEPTED' | 'REJECTED' | 'ENDED' = 'RINGING';
  @State currentContact: Contact = {
    id: -1,
    name: 'Unknown',
    isDefault: true
  } || null
  @State callerName: string = ''
  @State callDuration: number = 0;
  private timer: number | undefined;
  private contactUtils!: ContactUtils;
  @State vibrationInterval: number | undefined = undefined;

  context = getContext(this) as common.UIAbilityContext;
  filesDir = this.context.filesDir;
  private wlComponentController: WlComponentController | null = null;
  private wlPlayer: WlPlayer | null = null;
  @State
  private loadStatus: WlLoadStatus = WlLoadStatus.WL_LOADING_STATUS_START
  private progress: number = 0
  @State
  private timeInfo: String = '00:00/00:00'
  @State index: number = 0;
  private path = getContext(this).filesDir + '/ringtone.mp3';
  private pathUser = ''

  async aboutToAppear() {
    const context = getContext(this);
    const prefs = await dataPreferences.getPreferences(context, 'fakeCallPrefs');
    this.contactUtils = new ContactUtils(prefs);

    FileUtil.resourcesFile2SandboxFile('testvideos/ringtone.mp3', this.filesDir + '/ringtone.mp3');
    this.wlPlayer = new WlPlayer();
    this.wlPlayer.setOnMediaInfoListener({
      onPrepared: (): void => {
        this.wlPlayer?.start();
      },
      onTimeInfo: (currentTime: number, bufferTime: number): void => {
        if (this.wlPlayer!!.getDuration() > 0) {
          this.progress = currentTime * 100 / this.wlPlayer!!.getDuration();
        } else {
          this.progress = 50;
        }
        this.timeInfo =
          `${WlTimeUtil.secondToTimeFormat(currentTime)}/${WlTimeUtil.secondToTimeFormat(this.wlPlayer?.getDuration())}`
      },
      onComplete: (complete: WlCompleteType, msg: string): void => {
        if (complete === WlCompleteType.WL_COMPLETE_EOF) {
        } else if (complete === WlCompleteType.WL_COMPLETE_ERROR) {
        } else if (complete === WlCompleteType.WL_COMPLETE_HANDLE) {
        } else if (complete === WlCompleteType.WL_COMPLETE_NEXT) {
        } else if (complete === WlCompleteType.WL_COMPLETE_TIMEOUT) {
        } else if (complete === WlCompleteType.WL_COMPLETE_LOOP) {
        }
      },
      onLoadInfo: (loadStatus: WlLoadStatus, progress: number, speed: number): void => {
        this.loadStatus = loadStatus;
      },
      onFirstFrameRendered: (): void => {
      },
      onSeekFinish: (): void => {
      },
      onAudioInterrupt: (type: WlAudioInterruptType, hint: WlAudioInterruptHint) => {
      }
    });
    this.wlComponentController = new WlComponentController(this.wlPlayer);


    await this.loadDefaultContact();
    this.startVibration()
  }

  async aboutToDisappear() {
    this.stopVibration();
    this.stopCallTimer();
  }
  async loadDefaultContact() {
    const defaultContact = await this.contactUtils.getDefaultContact();
    if (defaultContact) {
      this.currentContact = defaultContact;
      this.callerName = defaultContact.name;
    } else {
      this.callerName = 'Unknown';
    }
  }

  private terminateApp() {
    const context = getContext(this) as common.UIAbilityContext;
    setTimeout(() => {
      context.terminateSelf()
    }, 2000);
  }

  private startVibration() {
    this.stopVibration();
    vibrator.startVibration({
      type: 'time',
      duration: 1000
    }, {
      id: 0,
      usage: 'alarm'
    });

    this.wlPlayer?.stop()
    this.pathUser = getContext(this).filesDir + '/ringtone.mp3';
    this.wlPlayer?.setSource(getContext(this).filesDir +'/ringtone.mp3')
    this.wlPlayer?.prepare()
    this.vibrationInterval = setInterval(() => {
      vibrator.startVibration({
        type: 'time',
        duration: 1000
      }, {
        id: 0,
        usage: 'alarm'
      });
    }, 2000);
  }

  private stopVibration() {
    if (this.vibrationInterval !== undefined) {
      clearInterval(this.vibrationInterval);
      this.vibrationInterval = undefined;
      vibrator.stopVibration().catch((err: BusinessError) => {
        console.error('Failed to stop vibration:', err);
      });
    }
    this.wlPlayer?.stop()
  }

  startCallTimer() {
    this.callDuration = 0;
    this.timer = setInterval(() => {
      this.callDuration += 1;
    }, 1000);
  }

  stopCallTimer() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = undefined;
    }
  }

  formatCallDuration(): string {
    const minutes = Math.floor(this.callDuration / 60);
    const seconds = this.callDuration % 60;
    return `${minutes}:${seconds < 10 ? '0' + seconds : seconds}`;
  }

  build() {
    Column() {
      Column() {
        Image($r('app.media.user'))
          .width(140)
          .height(140)
          .borderRadius(100)
          .margin({bottom: 20})

        Text(this.callerName)
          .fontSize(30)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .margin({ bottom: 5 })
      }
      .height('50%')
      .justifyContent(FlexAlign.End)
      if (this.callStatus === 'RINGING') {
        this.buildRingingScreen()
      } else if (this.callStatus === 'ACCEPTED') {
        this.buildAcceptedScreen()
      } else if (this.callStatus === 'REJECTED') {
        this.buildRejectedScreen()
      } else if (this.callStatus === 'ENDED') {
        this.buildEndedScreen()
      }
    }
    .width('100%')
    .height('100%')
    .linearGradient({
      angle: 180,
      colors: [
        ['#0A3D75', 0.0],
        ['#000C1A', 1.0]
      ]
    })
  }
  @Builder
  buildEndedScreen() {
    Column() {
      Row() {
        Text('Call Ended')
          .fontSize(30)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .margin({ bottom: 5 })
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .margin({ top: 175 })
    }
    .height('100%')
    .width('100%')
  }

  @Builder
  buildRingingScreen() {
    Column() {
      Row() {
        Column() {
          Image($r('app.media.callAccept'))
            .width(70)
            .height(70)
            .onClick(() => {
              this.callStatus = 'ACCEPTED'
              this.stopVibration()
              this.startCallTimer()
            })
        }
        .margin({right: 50})

        Column() {
          Image($r('app.media.callDecline'))
            .width(70)
            .height(70)
            .onClick(() => {
              this.callStatus = 'REJECTED';
              this.stopVibration()
              this.terminateApp()
            })
        }
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .margin({ top: 175 })
    }
    .height('100%')
    .width('100%')
  }

  @Builder
  buildRejectedScreen() {
    Column() {
      Row() {
        Text('Call Rejected')
          .fontSize(30)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .margin({ bottom: 5 })
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .margin({ top: 175 })
    }
    .height('100%')
    .width('100%')
  }

  @Builder
  buildAcceptedScreen() {
    Column() {
      Row() {
        Text(this.formatCallDuration())
          .fontSize(24)
          .fontColor('#D3D3D3')
          .margin({ top: 10 })
      }

      Grid() {
        ForEach(this.icons, (item: Icon) => {
          GridItem() {
            Image($r(`app.media.${item.name}`))
              .width(50)
              .height(50)
              .opacity(item.isEndCall ? 1 : 0.8)
              .onClick(() => {
                if (item.isEndCall) {
                  this.callStatus = 'ENDED'
                  this.terminateApp()
                }
              })
          }
        })
      }
      .columnsTemplate('1fr 1fr 1fr')
      .rowsTemplate('1fr 1fr 1fr')
      .width('60%')
      .height('30%')
      .margin({ top: 20 })
    }
    .height('100%')
    .width('100%')
  }
}

Enter fullscreen mode Exit fullscreen mode

In this scenario, the call screen handles multiple states—like receiving a call, accepting it, or rejecting it—each managed through distinct Builder structures within the same page.

Limitations or Considerations

This structure is only applicable in HarmonyOS applications developed using ArkTS and ArkUI.

Related Documents or Links

https://developer.huawei.com/consumer/en/doc/harmonyos-guides-V5/arkts-ui-paradigm-basic-syntax-V5

https://developer.huawei.com/consumer/en/doc/harmonyos-guides-V5/arkts-state-management-V5

Written by Mehmet Algul

Top comments (0)