ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【细胞工坊|06】HarmonyOS ArkTS 实验台选择页实战:把场景、指标和选择态做清楚

【细胞工坊|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 卡片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 教学实验应用来说,选择页不是入口装饰,它是实验链路的第一道状态边界。先把实验台类型、环境指标、选中态和安全区适配做清楚,再接确认路由和参数传递,后续实验模拟、结果展示、记录复盘才会稳定。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进