灯光模拟HarmonyOS应用实战-18-主页面Builder拆分如何降低灯光台界面复杂度

封面

The_kemusanIndex.ets 有 1757 行:首页、理论题、科三灯光模拟、科三实操、题库搜索、历史记录和设置都在同一个页面组件里。这样的文件很容易走向两个极端——要么所有 ArkUI 节点都塞进 build(),要么看到重复就急着抽成一堆小组件,最后业务状态在组件之间来回传递。

源码选择了一条更朴素的路:build() 只做页面分发,屏幕区域由 @Builder 组合,点击事件只把动作交给普通方法,判题、计时和持久化仍留在方法层。它还留下一个值得警惕的点:部分 Builder 接收由 @State 计算出的 string 原始值,后续重构时要防止展示值被调用时捕获,导致状态已经改变而文字没有同步。

先用“谁负责什么”审视 1757 行页面

文件长不等于 build() 复杂。真正需要控制的是声明式 UI、页面状态和业务流程有没有混在同一段里。

流程图

当前文件可以按职责读成下面四层:

层次 真实成员 应承担的责任
页面分发 build()currentPage 选择当前展示哪块 UI
区域组合 HomeView()SubjectThreeExamView()HistoryView() 安排状态行、内容区和底部操作
叶子呈现 LightStatus()ControlButton()HistoryCard() 根据输入渲染并转发事件
业务方法 handleLightAction()finishLightExam()addSimpleRecord() 判题、状态迁移、计时和写记录

这比按“UI 文件/逻辑文件”二分更实用。一个 Builder 可以读取页面状态,但不应该同时决定考试是否合格;一个业务方法可以改变 @State,但不应该拼一整段 ArkUI 节点。

build() 只有一个根容器,并且只负责分发

当前 Index 同时带有 @Entry@Component,其 build() 必须保持单一根节点,且根节点必须是容器。普通 @Component 也要求单一根节点,但可以使用 TextImage 等非容器组件。这里的入口页面用一个 Column 包住标题区和所有页面分支:

build() {
  Column() {
    this.Header()
    if (this.currentPage === PAGE_HOME) {
      this.HomeView()
    } else if (this.currentPage === PAGE_SUBJECT_THREE_EXAM) {
      this.SubjectThreeExamView()
    } else if (this.currentPage === PAGE_SUBJECT_THREE_FLOW) {
      this.SubjectThreePracticalView()
    } else if (this.currentPage === PAGE_QUESTION_BANK) {
      this.QuestionBankView()
    } else if (this.currentPage === PAGE_HISTORY) {
      this.HistoryView()
    } else if (this.currentPage === PAGE_SETTINGS) {
      this.SettingsView()
    } else {
      this.TheoryPracticeView()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor($r('app.color.app_bg'))
}

这里没有启动计时器、随机抽题或写入 Preferences。build() 每次重组 UI 时只读取状态,因此不会把有副作用的业务操作带进重绘路径。新增页面时,审阅者也能在这段分支中马上确认入口是否接上。

要注意:这里的 currentPage 是页面内部字符串状态,不是 Navigation 路由栈。源码没有 NavPathStackrouter_map.json@ohos.router 调用,不能把这段条件渲染描述成系统页面路由。

页面级 Builder 是组合边界,不是第二个 build()

科三灯光模拟页没有把几十个节点直接复制到分发处,而是组合状态行、指令面板、动作按钮和底部区:

@Builder
SubjectThreeExamView() {
  Column() {
    Scroll() {
      Column({ space: 14 }) {
        this.LightStatusRow()
        this.CommandPanel()
        Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
          ForEach(ACTION_OPTIONS, (item: ActionOption) => {
            this.ControlButton(item.label, item.icon, item.action)
          }, (item: ActionOption) => item.action)
        }
        Text(this.message)
          .fontColor(this.isSuccessMessage
            ? $r('app.color.success')
            : $r('app.color.danger'))
      }
      .width('100%')
    }
    .layoutWeight(1)
    this.SubjectThreeExamActionFooter()
  }
  .layoutWeight(1)
  .width('100%')
}

这个 Builder 的职责是“把一屏组织起来”。它知道状态行、内容区和底栏的空间关系,但不知道哪个动作是正确答案。以后要调整底栏是否跟随滚动,只需在这一层处理;修改判题规则则去找 handleLightAction()finishLightExam()

区域 Builder 最好保持一个清晰的顶层容器。叶子 Builder 可以像 AnswerOption() 一样只有一个 Text,但一个代表“整块区域”的 Builder 若出现多个并列顶层节点,调用方就难以统一控制宽度、滚动和间距,应先用 ColumnRowStack 包住。

叶子 Builder 只渲染,并把动作原样转发

ControlButton() 接受三个稳定字段:文案、短图标和动作常量。点击后只转发 action

@Builder
ControlButton(label: string, icon: string, action: string) {
  Column({ space: 7 }) {
    Text(icon)
      .backgroundColor($r('app.color.panel_bg'))
      .borderRadius(17)
    Text(label)
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
  }
  .width('31%')
  .height(86)
  .backgroundColor($r('app.color.button_bg'))
  .borderRadius(18)
  .onClick(() => {
    this.handleLightAction(action);
  })
}

这个边界能防止两个常见问题。第一,按钮不自行比较 currentLightCommand.action,因此模拟考试和演示模式共用同一套按钮;第二,动作常量来自 ACTION_OPTIONS,展示顺序变化不会改变判题协议。

如果某个叶子节点既修改灯光、又停止计时、又写历史记录,它就不再是展示单元。此时应把操作收回业务方法,而不是继续向 Builder 增加参数。

结构图

声明式边界里不要临时计算,也不要制造并列根节点

ArkTS 的 UI 声明块适合组件语法、ifForEach 和 Builder 调用,不适合随手插入普通变量和流程代码。下面这种写法会让边界变得含糊:

// 不建议:在区域 Builder 中准备业务数据,并产生两个并列区域
@Builder
ExamBody() {
  const progress = `${this.currentIndex + 1}/${this.totalQuestions}`;
  Text(progress)
  Flex() {
    // buttons
  }
}

更稳的写法是把派生值放进普通方法,把 UI 保持为一个组合根:

private getExamProgress(): string {
  return `${this.currentIndex + 1}/${this.totalQuestions}`;
}

@Builder
ExamBody() {
  Column({ space: 12 }) {
    Text(this.getExamProgress())
    Flex({ wrap: FlexWrap.Wrap }) {
      ForEach(ACTION_OPTIONS, (item: ActionOption) => {
        this.ControlButton(item.label, item.icon, item.action)
      }, (item: ActionOption) => item.action)
    }
  }
  .width('100%')
}

@Entry 入口组件的单根容器是编译约束;普通 @Component 的根节点则不要求一定是容器。区域 Builder 坚持单一组合根,是为了让布局所有权清楚,不应混同为入口组件的规则。若编译器同时给出“只能写 UI 组件语法”和后续 Rollup 解析错误,应先回到第一个声明式结构错误,不要被第二个报错带偏。两类组件的区别可查阅 OpenHarmony 官方自定义组件文档

原始值参数可能把响应性截断

本地 ArkTS 规则有一个容易忽略的风险:传给 @Builderstringnumberboolean 等原始值可能在调用处被捕获。后续 @State 改变,Builder 里的派生参数不一定按预期刷新。

源码中有一个值得回归关注的调用:

this.BigActionButton(this.examActive ? '考试中' : '开始考试')

@Builder
BigActionButton(text: string) {
  Text(text)
    .backgroundColor(this.examActive
      ? $r('app.color.button_disabled')
      : $r('app.color.accent'))
}

背景色直接读取 this.examActive,而文字通过 text 参数传入。源码审计不能直接断言设备上一定出现“背景已禁用、文字仍是开始考试”,但这种输入方式存在状态两路读取的风险。更稳的改造是让动态 Builder 直接消费同一个 @State

@Builder
ExamActionButton() {
  Text(this.examActive ? '考试中' : '开始考试')
    .backgroundColor(this.examActive
      ? $r('app.color.button_disabled')
      : $r('app.color.accent'))
    .onClick(() => {
      if (!this.examActive) {
        this.startActiveSubjectThreeExam();
      }
    })
}

并非所有原始值参数都要移除。ControlButtonlabel/icon/action 来自稳定配置项,HistoryCard 接收具体记录对象,这些都更像静态输入。真正需要直接读取页面状态的是倒计时、选中态、当前题号、考试中/未开始等持续变化的值。

业务方法下沉,才能避免重绘触发副作用

按钮转发后,handleLightAction() 决定当前是演示还是考试,再交给结束或下一题流程:

private handleLightAction(action: string): void {
  if (!this.examActive || this.examFinished) {
    this.applyActionToState(action);
    this.message = '当前为灯光演示,不计入考试';
    this.isSuccessMessage = true;
    return;
  }
  if (this.actionLocked || !this.currentLightCommand) {
    return;
  }
  this.applyActionToState(action);
  if (action === this.currentLightCommand.action) {
    this.markLightCorrect('操作正确');
  } else {
    this.finishLightExam(false,
      `操作错误:${this.currentLightCommand.text}`,
      this.currentLightCommand.id,
      getActionLabel(this.currentLightCommand.action),
      getActionLabel(action));
  }
}

这个方法拥有四个边界:校验考试状态、拒绝重复动作、修改灯光、分发正确/错误结果。最终写记录仍由 addSimpleRecord() 负责。若未来把 ControlButton 抽成独立 @Component,它只需要一个领域化回调 onAction,不必获得题目、计时器和存储对象。

什么时候保留 Builder,什么时候抽成组件

情况 更合适的形式 原因
只在当前页面复用,强依赖页面状态 @Builder 不增加跨组件状态协议
简短的派生值或状态判断 普通方法 声明块保持纯 UI
跨页面复用且有明确输入/回调 @Component 独立所有权更清楚
判题、计时、持久化 service 或普通业务方法 不应由 UI 重绘驱动
仅减少三四行相同样式 先不抽 过早抽象会隐藏上下文

以当前源码为例,CommandPanel()HistoryCard()EmptyState() 留在页面 Builder 中是合理的;PracticeStore 已经独立为服务。若要继续拆文件,优先抽那些输入稳定、能够单独说明职责的可复用卡片,不要先拆依赖十几个页面字段的科三整屏。

拆分后的回归要看“状态是否真的被消费”

场景 操作 需要观察的状态 UI 期望
页面分发 从首页进入科三模拟 currentPage SubjectThreeExamView() 替换首页区域
考试开始 点开始 examActive=true 按钮文案与背景同时更新
倒计时 等待 1 秒 timeLeft 数字和进度宽度同步变化
灯光动作 点远光 lightState 状态行激活项更新
答题锁定 连点两个选项 selectedAnswer 只记录第一次选择
空历史 清空记录 records=[] 展示 EmptyState()
返回首页 考试中点首页 timerId/altFlashTimerId 计时和闪烁停止

这张表尤其要关注同一状态被不同区域消费的情况。例如 examActive 同时影响按钮颜色、按钮文字和点击保护;只看其中一个区域会漏掉原始值参数带来的响应风险。

编译或交互异常时,从边界开始定位

现象 第一处检查 修正方向
提示只能写 UI 组件语法 Builder 中是否声明临时变量 把计算移到普通方法
第二个同级节点处出现解析错误 build() 是否有多个根节点 用容器包住,并先修首个错误
点击后背景变了、文字没变 是否传入动态原始值 Builder 直接读取对应 @State
UI 里出现大量判题分支 叶子 Builder 是否越界 下沉到 handle/start/finish/reset 方法
抽组件后参数暴增 组件是否依赖过多页面状态 退回页面 Builder 或重划业务边界
列表样式错乱 ForEach key 是否稳定 使用动作 id、记录 id 等领域键

源码审计已经能确认 Builder 和方法的组织关系,但不能把它等同于编译或设备运行结论。真正改造后仍应运行工程构建,并在模拟器或真机上走一次考试、答题、历史和返回首页链路。

结语:拆分的目标是让变化有明确落点

这份页面代码可读的关键,不是 Builder 数量多,而是不同变化有不同落点:页面切换改分发层,布局改区域 Builder,样式改叶子 Builder,规则改业务方法,记录改 PracticeStore

继续演进时还要补上一条响应性纪律:稳定配置可以作为 Builder 参数,持续变化的 @State 尽量在 Builder 内直接读取。这样既保持声明式边界,也避免页面看似拆开了,状态却在参数传递中断掉。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐