部分内容由AI辅助生成。本文面向 HarmonyOS 5.0 及以上版本,基于 细胞工坊 项目真实源码展开,源码根目录为 D:\huawei\one14-9。本文重点复核 entry/src/main/ets/views/experiment/SceneSelectorPage.etsentry/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
}

这里的字段命名带着物理实验模板的痕迹,比如 heightgravity。但在当前生物实验页里,页面文案把它们解释成环境温度和污染指数:

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
}

这段不是当前源码,而是可以考虑的重构方向。当前文章仍以真实源码为准:Sceneheightgravity 字段,页面展示为环境温度、污染指数。这样写读者才能把文章和工程文件逐行对上。

三、默认场景列表决定了页面第一屏能不能直接读懂

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 就能刷新页面选中态。

四、列表渲染用 ListListItem,让页面天然适合长内容

场景选择页不是固定四宫格,而是使用 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 主要使用 AppColorsAppFontsConstants

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 模型没有 leveldifficulty 或类似字段,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,异常场景不会崩溃,返回后选中态是否需要保留。当前源码还没到这一步,所以本文不把它写成已交付能力。

十四、常见问题与处理方式

问题 常见原因 处理建议
点击卡片后多个卡片看起来都选中 同时维护 isSelectedselectedId 保留一个状态真源,优先使用 selectedId
自定义实验台让用户误解能配置 只有齿轮图标,没有配置流程 加明确文案或补自定义参数页
确认按钮点击无反应 当前按钮没有 .onClick() confirmSelection(),并传递路由参数
底部按钮被系统栏遮住 忽略底部安全区 根容器使用 bottomBarHeight,按钮保留底部间距
描述文字挤压布局 文案变长但没有行数限制 增加 maxLinestextOverflow
难度筛选无法实现 Scene 模型没有难度字段 先扩展模型,再做筛选控件,不要只改 UI 文案

这些问题都不是抽象规范,而是能从当前源码推导出来的实际风险。尤其是确认按钮和难度筛选,文章必须明确边界,不能为了让页面看起来完整而把未实现逻辑说成已经实现。

十五、小结:选择页的质量来自边界清楚

SceneSelectorPage 的实现没有复杂算法,但它体现了一个很实用的 HarmonyOS 页面写法:数据模型提供列表输入,页面状态只维护当前选中项,列表项根据 selectedId 渲染高亮,根容器处理系统栏避让,主题常量统一颜色和字号。这样的页面可读、可改,也方便后续接入实验模拟页。

同时,源码里也有明确的未完成边界:确认选择 按钮没有业务回调,isCustom 只影响齿轮图标,难度筛选没有字段和控件支撑。把这些边界写清楚,反而能让文章更有工程价值。读者拿到代码后知道哪里可以直接复用,哪里需要自己补齐,而不是被一个看似完整的功能描述误导。

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

Logo

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

更多推荐