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

The_kemusan 的 Index.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 也要求单一根节点,但可以使用 Text、Image 等非容器组件。这里的入口页面用一个 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 路由栈。源码没有 NavPathStack、router_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 若出现多个并列顶层节点,调用方就难以统一控制宽度、滚动和间距,应先用 Column、Row 或 Stack 包住。
叶子 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 声明块适合组件语法、if、ForEach 和 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 规则有一个容易忽略的风险:传给 @Builder 的 string、number、boolean 等原始值可能在调用处被捕获。后续 @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();
}
})
}
并非所有原始值参数都要移除。ControlButton 的 label/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 内直接读取。这样既保持声明式边界,也避免页面看似拆开了,状态却在参数传递中断掉。
更多推荐



所有评论(0)