【细胞工坊|06】HarmonyOS ArkTS 实验台选择页实战:把场景、指标和选择态做清楚
部分内容由AI辅助生成。本文面向 HarmonyOS 5.0 及以上版本,基于 细胞工坊 项目真实源码展开,源码根目录为 D:\huawei\one14-9。本文重点复核 entry/src/main/ets/views/experiment/SceneSelectorPage.ets 与 entry/src/main/ets/model/Scene.ets,只讨论源码已经实现的场景列表、环境指标、选中态、安全区避让、返回操作和确认入口外观,不虚构难度筛选、网络同步、云端推荐或确认后写入业务。
在教学实验类应用里,实验开始前的选择页很容易被写成一个普通列表:几个图标、几行文字、一个确认按钮,看起来能用,但后续维护会很痛。真正的问题不是列表难写,而是页面是否把“数据模型、可见状态、入口边界、适配约束”分清楚。场景选择页只要边界不清,后面接实验模拟页、实验记录页和结果页时,就会出现选中状态丢失、按钮看起来能提交但没有业务、列表在小窗口底部被系统导航遮住等问题。
这篇文章拆解的是 细胞工坊 的生物实验台选择页。它的源码并不复杂,但有几个值得保留的工程点:Scene 模型统一描述实验台数据,getDefaultScenes() 提供默认场景,SceneSelectorPage 使用 @State 管理当前选中项,页面根节点读取状态栏和底部栏高度做避让,列表项通过背景色、边框和勾选符号表达选中态。更重要的是,当前源码里的 确认选择 按钮还没有绑定业务回调,文章会如实标出这个边界,避免把“入口存在”写成“提交逻辑已经完成”。

一、先把源码边界讲清楚:这是选择页,不是调度器
SceneSelectorPage 的职责是让用户看到可选实验台,并在页面内改变选中态。它没有启动实验模拟,没有保存选择,也没有把场景参数写入持久化层。这个边界很关键,因为读者在复用页面时,最容易在确认按钮上直接补一堆业务逻辑,最后让一个列表页同时承担路由、实验参数转换、记录保存和错误处理。
源码里页面入口很清楚:
@Entry
@Component
struct SceneSelectorPage {
@StorageProp('statusBarHeight') statusBarHeight: number = 36
@StorageProp('bottomBarHeight') bottomBarHeight: number = 0
@State scenes: Scene[] = getDefaultScenes()
@State selectedId: string = 'microscope_room'
build() {
// UI tree
}
}
这段代码有三个边界:
| 字段 | 来源 | 页面职责 | 不能夸大的能力 |
|---|---|---|---|
statusBarHeight |
AppStorage |
顶部安全区避让 | 不代表完整多窗口策略 |
bottomBarHeight |
AppStorage |
底部安全区避让 | 不代表所有设备已实测 |
scenes |
getDefaultScenes() |
列表数据源 | 不代表远程配置或动态推荐 |
selectedId |
页面状态 | 当前选中项 | 不代表选择已提交到实验页 |
实际工程里,选择页可以先保持轻量。页面只维护“用户当前点了哪个实验台”,确认后的业务应该进入更明确的路由或服务方法。这样做的好处是,后续如果要把场景选择接到实验模拟页,只需要新增一个提交方法,而不是在列表渲染逻辑里到处插业务。

二、Scene 模型把实验台数据压成稳定输入
页面能稳定渲染,首先取决于数据结构稳定。Scene.ets 定义了实验台的最小模型:
export interface Scene {
id: string
name: string
description: string
height: number
gravity: number
icon: Resource
isSelected: boolean
isCustom: boolean
}
这里的字段命名带着物理实验模板的痕迹,比如 height 和 gravity。但在当前生物实验页里,页面文案把它们解释成环境温度和污染指数:
if (scene.height > 0) {
Text(`环境温度:${scene.height} ℃`)
.fontSize(11)
.fontColor(AppColors.TEXT_HINT)
}
if (scene.gravity > 0) {
Text(`污染指数:${scene.gravity}%`)
.fontSize(11)
.fontColor(AppColors.TEXT_HINT)
}
这说明一个真实项目里常见的迁移状态:底层模型字段可能沿用旧语义,页面层已经切换到新业务文案。文章不能把 height 写成“培养箱高度”,也不能把 gravity 写成“重力模拟”,因为当前 UI 明确展示的是温度和污染指数。
更稳的写法,是在后续重构时把模型字段改成业务语义更清晰的名字:
export interface BioScene {
id: string
name: string
description: string
temperature: number
contamination: number
icon: Resource
isCustom: boolean
}
这段不是当前源码,而是可以考虑的重构方向。当前文章仍以真实源码为准:Scene 有 height、gravity 字段,页面展示为环境温度、污染指数。这样写读者才能把文章和工程文件逐行对上。
三、默认场景列表决定了页面第一屏能不能直接读懂
getDefaultScenes() 返回四个默认实验台:显微观察区、无菌培养区、DNA 提取区、自定义实验台。源码中由于文件显示编码问题,部分中文在命令行输出里可能呈现乱码,但从页面文件与资源命名可以复核到它们对应的生物实验场景。
核心结构可以概括为:
export function getDefaultScenes(): Scene[] {
return [
{
id: 'microscope_room',
name: '显微观察区',
description: '适合载玻片、染色和细胞结构观察',
height: 25,
gravity: 0,
icon: $r('app.media.ic_bio_microscope'),
isSelected: true,
isCustom: false
},
{
id: 'culture_room',
name: '无菌培养区',
description: '适合培养皿、样本转移和菌落培养',
height: 37,
gravity: 0,
icon: $r('app.media.ic_bio_petri'),
isSelected: false,
isCustom: false
}
]
}
默认数据的价值不是“写死几个卡片”,而是让页面在没有网络、没有用户历史记录、没有复杂配置的情况下依然可用。对 HarmonyOS 教学工具来说,这是很实际的选择:实验入口不依赖服务器,审核材料也更容易说明离线使用边界。
需要注意的是,Scene 模型里虽然有 isSelected,页面实际选中态使用的是 selectedId。这意味着 isSelected 当前更像是默认数据里的历史字段,真正驱动 UI 的状态并不是它:
@State selectedId: string = 'microscope_room'
如果后续要清理数据模型,可以考虑去掉 isSelected,或者在初始化时用它推导 selectedId。否则团队成员会误以为改 scene.isSelected 就能刷新页面选中态。
四、列表渲染用 List 和 ListItem,让页面天然适合长内容
场景选择页不是固定四宫格,而是使用 List 承载每个实验台卡片:
List({ space: 12 }) {
ForEach(this.scenes, (scene: Scene) => {
ListItem() {
Row() {
Image(scene.icon)
.width(64)
.height(64)
.borderRadius(12)
Column() {
Text(scene.name)
Text(scene.description)
}
.layoutWeight(1)
}
}
})
}
.width('100%')
.layoutWeight(1)
List 的好处是后续扩展场景数量时不用重写布局。如果默认实验台从 4 个增加到 10 个,列表仍然可以滚动;如果窗口变窄,layoutWeight(1) 会把文字区域控制在剩余空间内,而不是把右侧内容挤出屏幕。
这里还有一个实际适配点:列表区域使用 layoutWeight(1),底部确认按钮固定在下方。这样页面不会因为列表项变多而把确认按钮挤出可视区域。对 HarmonyOS 手机、折叠屏小窗和 2in1 窗口来说,这比固定高度列表更稳。
五、选中态只用一个 selectedId,避免每个卡片各管一份状态
场景选择页的交互很简单:点击一个实验台,当前项变成选中。源码没有在每个 Scene 对象里改 isSelected,而是只更新页面状态 selectedId:
.onClick(() => {
this.selectedId = scene.id
})
然后 UI 根据 selectedId 判断当前卡片是否高亮:
.backgroundColor(this.selectedId === scene.id ? AppColors.PRIMARY_BG : AppColors.CARD_BG)
.border({
width: this.selectedId === scene.id ? 1 : 0,
color: AppColors.PRIMARY
})
这种写法有一个明显优点:单选状态只有一个真源。不会出现 A 卡片 isSelected=true,B 卡片也 isSelected=true 的数据不一致问题。页面渲染只问一个问题:“当前卡片 id 是否等于 selectedId?”
选中态还用勾选符号做可见反馈:
if (this.selectedId === scene.id) {
Text(' ✓')
.fontSize(16)
.fontColor(AppColors.ACCENT_GREEN)
}
颜色、边框和勾选符号叠加后,用户不用精读文字也能知道当前选择。对触屏设备来说,这种反馈比只改背景色更可靠,因为深色卡片之间的色差可能在不同屏幕亮度下不够明显。

六、顶部导航做了返回,但没有把返回藏在系统手势里
页面顶部有一个显式返回入口:
Row() {
Text('←')
.fontSize(22)
.fontColor(AppColors.TEXT_PRIMARY)
.width(40)
.height(40)
.onClick(() => {
router.back()
})
Text('实验台选择')
.fontSize(AppFonts.SUBTITLE_SIZE)
.fontWeight(AppFonts.WEIGHT_BOLD)
.fontColor(AppColors.TEXT_PRIMARY)
.layoutWeight(1)
.textAlign(TextAlign.Center)
}
HarmonyOS 应用不能只依赖用户记得系统返回手势。二级页面给一个可见返回入口,能减少误操作,也能帮助桌面窗口、平板键鼠场景下的用户理解页面层级。
这里的布局采用左右占位,让标题居中:
Text('')
.width(22)
这不是最优雅的抽象,但在当前页面足够直接。若后续多个页面都需要相同导航栏,可以抽出 TopBar 组件,统一处理返回、标题、右侧操作和安全区。但当前文章不把它写成已存在组件,因为源码里还没有。
七、安全区适配写在根容器,避免内容贴住系统栏
页面根容器读取两个存储属性:
@StorageProp('statusBarHeight') statusBarHeight: number = 36
@StorageProp('bottomBarHeight') bottomBarHeight: number = 0
然后在根 Column 里统一使用:
.width('100%')
.height('100%')
.backgroundColor(AppColors.PAGE_BG)
.padding({ top: this.statusBarHeight, bottom: this.bottomBarHeight })
这比在顶部导航、列表、底部按钮上分别硬写间距更稳。根容器统一避让系统区域,内部组件只需要关心页面自己的间距。尤其是底部确认按钮,如果没有 bottomBarHeight,在手势导航或系统导航栏较高的设备上可能贴得过低。
底部按钮本身还有额外外边距:
Button('确认选择')
.height(48)
.width('60%')
.margin({ bottom: 24, top: 12 })
根容器底部避让加按钮底部间距,能让主要操作不至于卡在系统手势区域。这是一个很实际的 AppGallery 布局审查点:底部按钮不能被导航栏遮挡,也不能贴边到难以点击。
八、主题常量集中管理,页面不用散落硬编码颜色
SceneSelectorPage 主要使用 AppColors、AppFonts 和 Constants:
import { AppColors, AppFonts } from '../../common/Theme'
import { Constants } from '../../common/Constants'
页面里的关键颜色来自主题类:
static readonly PAGE_BG: string = '#0B1120'
static readonly CARD_BG: string = '#111827'
static readonly TEXT_PRIMARY: string = '#E5F7FF'
static readonly TEXT_SECONDARY: string = '#A7B6C8'
static readonly ACCENT_GREEN: string = '#00FFB2'
间距和圆角来自 Constants:
static readonly PAGE_PADDING: number = 16
static readonly CARD_RADIUS: number = 16
对一篇工程文章来说,这个点值得写出来。很多页面质量问题不是组件选错,而是颜色、字号、间距分散在每个页面里,后续想做深浅色适配、品牌色调整或低对比度修复时非常麻烦。当前源码已经把页面主色、文字色、卡片背景、字号和间距放在公共文件里,选择页只是消费这些 token。
但也要看到一个小问题:按钮文字色使用了硬编码:
.fontColor('#07111F')
这不一定是错误,但如果项目要进一步统一深色主题,按钮文字色也可以进入 AppColors,例如 BUTTON_TEXT_ON_ACCENT。这样后续做对比度检查时更容易全局排查。
九、自定义实验台只展示入口,不表示定制流程已完成
列表中对 isCustom 做了一个特殊展示:
if (scene.isCustom) {
Text('⚙')
.fontSize(20)
.fontColor(AppColors.TEXT_SECONDARY)
}
这段代码说明页面有“自定义实验台”的视觉入口,但当前源码没有看到点击后打开参数配置页的逻辑。点击自定义项只会更新 selectedId。因此文章不能写“支持自定义配置温度和观察条件”,只能写“列表中有自定义实验台入口,当前点击行为仍是选中该项”。
如果要把自定义实验台真正做完整,可以补一个明确的入口分支:
private onSceneClick(scene: Scene): void {
this.selectedId = scene.id
if (scene.isCustom) {
// 后续可在这里进入自定义参数页
}
}
再把 onClick 改成:
.onClick(() => {
this.onSceneClick(scene)
})
这段是建议方向,不是当前实现。它的价值是把“点击选择”和“自定义配置”分开,避免未来在 UI 树里堆判断。
十、确认按钮目前只有外观,业务提交需要单独补齐
当前源码底部按钮如下:
Button('确认选择')
.fontSize(15)
.fontColor('#07111F')
.backgroundColor(AppColors.ACCENT_GREEN)
.borderRadius(24)
.height(48)
.width('60%')
.margin({ bottom: 24, top: 12 })
这里没有 .onClick()。这意味着按钮只是显示在页面上,尚未执行“确认选择”的业务。这个事实必须在文章里写清楚,因为它直接影响读者复用代码时的判断。
如果要补齐确认逻辑,比较稳的方式是先找到选中场景,再通过路由参数进入实验页:
private confirmSelection(): void {
const scene = this.scenes.find((item: Scene) => item.id === this.selectedId)
if (!scene) {
return
}
router.pushUrl({
url: 'pages/experiment/ExperimentSimPage',
params: {
sceneId: scene.id,
sceneName: scene.name,
temperature: scene.height,
contamination: scene.gravity
}
})
}
按钮再绑定:
Button('确认选择')
.onClick(() => {
this.confirmSelection()
})
这段代码是后续补齐方向,不能当成当前源码结果。当前真实状态是:选中态已经可见,确认入口已经绘制,确认后的业务动作还没有实现。
十一、不要把“难度筛选”写成已实现能力
队列 brief 的 outcome 里提到了难度,但 Scene 模型没有 level、difficulty 或类似字段,SceneSelectorPage 也没有分段筛选、标签筛选或下拉筛选逻辑。项目的 Constants 里确实有难度常量:
static readonly LEVEL_BEGINNER: string = '初级'
static readonly LEVEL_CLASSIC: string = '经典'
static readonly LEVEL_ADVANCED: string = '进阶'
static readonly LEVEL_EXPERT: string = '高级'
这些常量说明项目其他页面或未来功能可能有难度体系,但不能证明当前场景选择页已经支持难度筛选。严谨的写法应该是:
| 能力 | 当前源码是否支持 | 证据 |
|---|---|---|
| 实验台列表 | 支持 | scenes: Scene[] = getDefaultScenes() |
| 单选高亮 | 支持 | selectedId === scene.id |
| 环境温度展示 | 支持 | Text(\环境温度:${scene.height} ℃\) |
| 污染指数展示 | 条件支持 | 仅 scene.gravity > 0 时展示 |
| 难度筛选 | 未在本页实现 | 无难度字段和筛选控件 |
| 确认提交 | 未在本页实现 | Button('确认选择') 无 onClick |
这张表比泛泛介绍功能更有价值。读者能知道哪些代码可以直接复用,哪些能力需要自己补。
十二、迁移到多设备页面时,优先检查文字和底部操作
这个页面虽然代码量不大,但它已经具备几个适合多设备调整的基础:根容器 100% 宽高、列表 layoutWeight(1)、文字区域 layoutWeight(1)、顶部和底部安全区避让、底部按钮固定尺寸。真正迁移到折叠屏、平板或 2in1 小窗口时,重点应该检查两类问题。
第一类是文字溢出。场景名称和描述都没有显式 maxLines:
Text(scene.description)
.fontSize(AppFonts.CAPTION_SIZE)
.fontColor(AppColors.TEXT_SECONDARY)
如果后续描述变长,建议增加:
Text(scene.description)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
第二类是底部按钮宽度。当前按钮宽度是 60%,在超宽窗口下可能显得过宽,在窄小窗口下也需要验证是否足够可点击。可以用最大宽度约束:
Button('确认选择')
.width('60%')
.constraintSize({ maxWidth: 360, minWidth: 180 })
这同样是建议,不是当前源码。写文章时要区分“已经实现”和“可优化方向”,否则技术文章会变成无法复核的愿望清单。
十三、本地验证清单:先看状态,再看适配
复核这类页面时,不需要一开始就跑复杂链路。先用最小路径验证 UI 状态是否符合源码意图:
| 检查项 | 操作 | 预期 |
|---|---|---|
| 默认选中 | 打开页面 | microscope_room 对应卡片有高亮和勾选 |
| 切换选中 | 点击其他卡片 | selectedId 更新,只有一个卡片高亮 |
| 返回 | 点击左上角箭头 | 调用 router.back() 返回上一页 |
| 自定义项 | 点击自定义实验台 | 只切换选中态,不进入配置页 |
| 确认按钮 | 点击确认选择 | 当前源码无绑定动作,不应期待跳转 |
| 底部避让 | 手势导航设备查看 | 按钮不贴住系统底部区域 |
如果后续补上确认逻辑,再增加路由参数验证:
router.pushUrl({
url: 'pages/experiment/ExperimentSimPage',
params: {
sceneId: this.selectedId
}
})
验证重点是:目标页能拿到 sceneId,异常场景不会崩溃,返回后选中态是否需要保留。当前源码还没到这一步,所以本文不把它写成已交付能力。
十四、常见问题与处理方式
| 问题 | 常见原因 | 处理建议 |
|---|---|---|
| 点击卡片后多个卡片看起来都选中 | 同时维护 isSelected 和 selectedId |
保留一个状态真源,优先使用 selectedId |
| 自定义实验台让用户误解能配置 | 只有齿轮图标,没有配置流程 | 加明确文案或补自定义参数页 |
| 确认按钮点击无反应 | 当前按钮没有 .onClick() |
补 confirmSelection(),并传递路由参数 |
| 底部按钮被系统栏遮住 | 忽略底部安全区 | 根容器使用 bottomBarHeight,按钮保留底部间距 |
| 描述文字挤压布局 | 文案变长但没有行数限制 | 增加 maxLines 和 textOverflow |
| 难度筛选无法实现 | Scene 模型没有难度字段 |
先扩展模型,再做筛选控件,不要只改 UI 文案 |
这些问题都不是抽象规范,而是能从当前源码推导出来的实际风险。尤其是确认按钮和难度筛选,文章必须明确边界,不能为了让页面看起来完整而把未实现逻辑说成已经实现。
十五、小结:选择页的质量来自边界清楚
SceneSelectorPage 的实现没有复杂算法,但它体现了一个很实用的 HarmonyOS 页面写法:数据模型提供列表输入,页面状态只维护当前选中项,列表项根据 selectedId 渲染高亮,根容器处理系统栏避让,主题常量统一颜色和字号。这样的页面可读、可改,也方便后续接入实验模拟页。
同时,源码里也有明确的未完成边界:确认选择 按钮没有业务回调,isCustom 只影响齿轮图标,难度筛选没有字段和控件支撑。把这些边界写清楚,反而能让文章更有工程价值。读者拿到代码后知道哪里可以直接复用,哪里需要自己补齐,而不是被一个看似完整的功能描述误导。
对 HarmonyOS 教学实验应用来说,选择页不是入口装饰,它是实验链路的第一道状态边界。先把实验台类型、环境指标、选中态和安全区适配做清楚,再接确认路由和参数传递,后续实验模拟、结果展示、记录复盘才会稳定。
更多推荐


所有评论(0)