【细胞工坊|06】HarmonyOS ArkTS 实验台选择页实战:把场景、指标和选择态做清楚 在教学实验类应用里实验开始前的选择页很容易被写成一个普通列表几个图标、几行文字、一个确认按钮看起来能用但后续维护会很痛。真正的问题不是列表难写而是页面是否把“数据模型、可见状态、入口边界、适配约束”分清楚。场景选择页只要边界不清后面接实验模拟页、实验记录页和结果页时就会出现选中状态丢失、按钮看起来能提交但没有业务、列表在小窗口底部被系统导航遮住等问题。这篇文章拆解的是 细胞工坊 的生物实验台选择页。它的源码并不复杂但有几个值得保留的工程点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 } }这段代码有三个边界字段来源页面职责不能夸大的能力statusBarHeightAppStorage顶部安全区避让不代表完整多窗口策略bottomBarHeightAppStorage底部安全区避让不代表所有设备已实测scenesgetDefaultScenes()列表数据源不代表远程配置或动态推荐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 卡片isSelectedtrueB 卡片也isSelectedtrue的数据不一致问题。页面渲染只问一个问题“当前卡片 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和Constantsimport { 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间距和圆角来自Constantsstatic 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 小窗口时重点应该检查两类问题。第一类是文字溢出。场景名称和描述都没有显式maxLinesText(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 教学实验应用来说选择页不是入口装饰它是实验链路的第一道状态边界。先把实验台类型、环境指标、选中态和安全区适配做清楚再接确认路由和参数传递后续实验模拟、结果展示、记录复盘才会稳定。