把一张手机图鉴页面拖到横向窗口,最显眼的问题往往不是“组件太窄”,而是内容被直接拉伸:照片变得很扁,卡片文字横跨大半个屏幕,收藏按钮离标题越来越远。于是开发者加上“宽度超过某个值就换一套页面”的判断,结果布局变漂亮了,刚刚选中的翠鸟和收藏状态却在切换后消失。对 HarmonyOS 的多窗口、平板和二合一设备来说,排版变化与业务状态变化应当彼此解耦,不必为一列、两列和三列分别维护三份页面数据。

这里做一个完整的独立示例 BirdAtlas:三张固定的图鉴卡片分别介绍翠鸟、白鹭和红嘴蓝鹊;页面可以按栖息环境筛选,点击一张卡片会更新“当前关注”,收藏按钮独立切换星标。手机窄屏为单列,中等宽度为双列,大宽度为三列。我们只使用 ArkUI 自带的 GridRow、GridCol、ForEach、@State 和普通基础组件,不依赖第三方响应式框架,也不向任何外部服务发送数据。

观鸟图鉴多端布局与状态保持的技术封面

一、不要让业务状态跟着断点一起换对象

常见的实现先获取窗口宽度,然后 if (isWide) { WidePage() } else { PhonePage() }。这种方式并非错误,但如果两边都定义了自己的 @State selectedId、自己的收藏列表甚至各自拉取数据,窗口变化就等于切换两棵独立组件树。某些子组件被卸载重建后状态恢复的代价,不仅是用户再次点击,还可能出现筛选条件、滚动位置和打开的详情不一致。问题的根源不在某一个宽度阈值,而在“同一个业务事实存在多个拥有者”。

我们只在根组件中维护三份状态:filter 代表当前分类,selectedId 代表当前关注的鸟类 ID,starredIds 代表收藏集合。三项都不含屏幕宽度,不随着 GridRow 的断点更新。页面根据这些状态计算可见卡片和摘要;响应式容器只决定同一张卡片占用多少列。这样从竖屏转向较宽的窗口时,可能发生卡片重排,但 selectedId='kingfisher' 仍指向同一项。

固定图鉴数据采用以下三个对象:翠鸟 kingfisher,栖息环境“河岸”;白鹭 egret,环境“湿地”;红嘴蓝鹊 blue_magpie,环境“林缘”。每条数据均有稳定的 id、名称、说明和环境标签。这里的描述只是示例文本,不声称是识别鸟类的权威资料;真实野外识别应用还需物种资料授权、图片来源与地理位置合规处理。因为本篇重点是布局,不引入权限、定位、相机或网络请求。

分栏的数学关系可以用十二列网格理解:窄屏每项占十二列,即一行一项;中等屏每项占六列,即一行两项;宽屏每项占四列,即一行三项。只修改 GridCol 的 span 就能得到三种排列,不需要复制三套 ForEach。华为的 GridRow/GridCol 布局指南和 GridCol API 参考明确支持按断点设置列跨度,xs/sm/md/lg 分别表示不同容器或窗口宽度区间。

二、完整的数据与筛选逻辑

在 DevEco Studio 新建普通 Stage ArkTS 工程,使用 API 12 及以上已具备的 GridRow 能力,将下述示例放入 entry/src/main/ets/pages/Index.ets。图鉴资源只使用文字、色彩和 Unicode 图形符号,便于复现;如果后期替换为实际图片,可以在 resources/base/media 放置获授权资源并通过 $r('app.media.xxx') 访问。当前示例不需要 module.json5 额外权限。

interface Bird {
  id: string;
  name: string;
  habitat: string;
  description: string;
  symbol: string;
}
const BIRDS: Bird[] = [
  { id: 'kingfisher', name: '翠鸟', habitat: '河岸',
    description: '蓝绿色背羽,常在水边短暂停留', symbol: '🐦' },
  { id: 'egret', name: '白鹭', habitat: '湿地',
    description: '体羽洁白,常见于浅水区域', symbol: '🪶' },
  { id: 'blue_magpie', name: '红嘴蓝鹊', habitat: '林缘',
    description: '尾羽修长,适合观察枝间活动', symbol: '🌿' }
];

@Entry
@Component
struct Index {
  @State filter: string = '全部';
  @State selectedId: string = 'kingfisher';
  @State starredIds: string[] = ['kingfisher'];
  @State currentBreakpoint: string = 'sm';
  private filters: string[] = ['全部', '河岸', '湿地', '林缘'];

  private visibleBirds(): Bird[] {
    if (this.filter === '全部') { return BIRDS; }
    return BIRDS.filter((bird: Bird) => bird.habitat === this.filter);
  }
  private selectedBirdName(): string {
    const item: Bird | undefined = BIRDS.find((bird: Bird) =>
      bird.id === this.selectedId);
    return item === undefined ? '未选择' : item.name;
  }
  private isStarred(id: string): boolean {
    return this.starredIds.includes(id);
  }
  private toggleStar(id: string): void {
    this.starredIds = this.isStarred(id)
      ? this.starredIds.filter((value: string) => value !== id)
      : [...this.starredIds, id];
  }
}

这段逻辑里最值得保留的不是数组长度,而是三条约束。其一,收藏始终存储业务 ID,不以可见卡片下标作为身份;筛选后数组位置发生变化,也不会把白鹭误认为翠鸟。其二,toggleStar() 不在原地 push 或 splice 同一份被观察数组,而是赋一份新数组,数据变更意图更加明确。其三,selectedBirdName() 从总目录按 ID 查找,不从当前过滤结果里查;用户先关注翠鸟再切到湿地,底部摘要仍然知道此前关注的是谁。这个行为是产品设计选择,若需求要求“筛选时取消关注”,应在切换筛选的事件中显式处理,而不是碰巧依赖组件销毁。

DevEco Studio 中 GridRow 断点跨度与观鸟卡片的白色开发拟真图

三、一个 GridRow 同时承担三种宽度

卡片可以拆成 @Builder,保留可选中和收藏的两个独立事件。点击卡片主体改变关注项,点击收藏按钮只改变收藏数组;不让按钮回调顺便修改 selectedId。适配断点使用 ['320vp','600vp','840vp'],结合 BreakpointsReference.WindowSize 表示按照窗口宽度选择断点。xs 与 sm 都占十二列,md 占六列,lg 占四列。特别注意:GridCol 不是直接的列表容器,必须作为 GridRow 的子节点;ForEach 的产物正是每项的 GridCol。

@Builder
private birdCard(bird: Bird) {
  Column({ space: 10 }) {
    Row() {
      Row() {
        Text(bird.symbol).fontSize(40)
        Column() {
          Text(bird.name).fontSize(21).fontWeight(FontWeight.Bold)
          Text(bird.habitat).fontSize(13).fontColor('#657388')
        }.alignItems(HorizontalAlign.Start)
      }.layoutWeight(1)
       .onClick(() => { this.selectedId = bird.id; })
      Button(this.isStarred(bird.id) ? '★' : '☆')
        .fontSize(22)
        .backgroundColor('#F3F7F8')
        .fontColor('#C98A35')
        .onClick(() => this.toggleStar(bird.id))
    }.width('100%')
    Text(bird.description).fontSize(14).fontColor('#566676')
      .onClick(() => { this.selectedId = bird.id; })
    if (this.selectedId === bird.id) {
      Text('当前关注').fontSize(12).fontColor('#147C89')
    }
  }
  .padding(16).width('100%')
  .backgroundColor(this.selectedId === bird.id ? '#E6F5F4' : Color.White)
  .borderRadius(18)
  .border({ width: this.selectedId === bird.id ? 2 : 1,
    color: this.selectedId === bird.id ? '#219A96' : '#E4EBF0' })
}

build() {
  Column({ space: 12 }) {
    Text('观鸟图鉴').fontSize(28).fontWeight(FontWeight.Bold)
    Text('选择栖息环境,记录你关注的鸟').fontColor('#75879B')
    Row({ space: 8 }) {
      ForEach(this.filters, (name: string) => {
        Button(name).fontSize(13)
          .backgroundColor(this.filter === name ? '#167E8E' : '#EDF3F5')
          .fontColor(this.filter === name ? Color.White : '#38546A')
          .onClick(() => { this.filter = name; })
      }, (name: string) => name)
    }.width('100%')
    Scroll() {
      GridRow({
        columns: 12,
        gutter: { x: 12, y: 12 },
        breakpoints: {
          value: ['320vp', '600vp', '840vp'],
          reference: BreakpointsReference.WindowSize
        }
      }) {
        ForEach(this.visibleBirds(), (bird: Bird) => {
          GridCol({ span: { xs: 12, sm: 12, md: 6, lg: 4 } }) {
            this.birdCard(bird)
          }
        }, (bird: Bird) => bird.id)
      }
      .width('100%')
      .onBreakpointChange((bp: string) => {
        this.currentBreakpoint = bp;
      })
    }.layoutWeight(1)
    Row() {
      Text(`当前关注:${this.selectedBirdName()}`)
      Blank()
      Text(`已收藏 ${this.starredIds.length}`)
    }.padding(16).backgroundColor('#EAF2F5').borderRadius(14)
  }
  .padding(16).width('100%').height('100%')
  .backgroundColor('#F7F9FB')
}

以上 @Builder 与 build() 是接在前一段同一个 struct Index 内的成员,不应在文件中创建第二个组件。教程使用的关键是 GridCol 的 span 显式覆盖全部需要的断点。官方文档提示,自较新 API 版本开始,省略部分断点时的继承规则可能发生变化;因此与其依靠默认推断,不如把 xs/sm/md/lg 都写齐。对于本案例,窗口小于 600vp 时始终单列;600~840vp 时双列;840vp 及以上时三列。320vp 以下属于 xs,仍单列,但极窄窗口需要再评估按钮宽度与文案换行。

观鸟图鉴在竖屏下的单列卡片与收藏交互界面

四、状态保持如何做验证,而不是靠目测

最有价值的检查是建立操作序列:窄屏打开图鉴,默认关注翠鸟且收藏数为一;点白鹭后,底部文字应改为“当前关注:白鹭”;再收藏红嘴蓝鹊,收藏数应为二;把窗口拓宽到中等断点,三张卡片重排为两列,但关注 ID 与收藏 ID 集合完全不变。接着选择“河岸”筛选,页面只显示翠鸟,但若当前关注是白鹭,底部仍应保留“白鹭”这一事实。重新选“全部”时,三张卡片再次显示,关注高亮和星标来自原先状态,不应被新的渲染位置覆盖。

ForEach 的 keyGenerator 必须使用 bird.id,这样筛选重排时才能给框架提供稳定身份。直接使用位置下标作为 key,在删除第一条或改变排序后可能复用原来的组件子树,状态敏感的子组件容易出现内容错位。参考官方 ForEach 渲染控制;@State 对数组的更新与组件刷新关系参见 状态管理说明。本例的 currentBreakpoint 只是观测值,业务筛选、关注和收藏均不依赖它;断点变化不会触发额外业务提交。

观鸟图鉴从单列到三列的断点布局与数据流示意

还需要检查交互无障碍和长文字压力:将名称换成较长的说明,确认卡片高度允许内容自然增长;把系统字体调大,检查过滤按钮是否拥挤;横屏下点击收藏后再回竖屏,按钮星标与总计应一致。示例的顶端筛选标签使用横向 Row,在非常窄的设备可能溢出,应改成水平滚动或允许换行,这属于布局后续迭代,不应该说所有尺寸已经完美适配。若将来加入大量鸟类图片,需进一步考虑图片资源尺寸、懒加载与可视项复用,而不能把网格断点当作性能优化的全部。

一个容易忽视的细节是卡片间距也参与最终列宽计算。GridRow 设置的 gutter: { x: 12, y: 12 } 不是在每张卡片外随意叠加一个 margin;当三张卡片排列在同一行时,横向间隙会占用容器总宽度。如果卡片内部再给图片一个固定宽度,平板横屏上很可能出现溢出或不均匀的空白。这里将卡片外层设置 width('100%'),内容列使用可伸缩空间,让网格决定实际卡片宽度。业务方可以更换图片比例,但不能让媒体组件的固定尺寸破坏容器分配。

对持续保持的关注态也应设定边界。本例的筛选是纯计算函数,每次根据 filter 生成可见数组,原始目录不被修改。于是从“全部”切换到“湿地”再切回来,Bird 对象的业务 ID 仍然一致;如果产品未来允许排序和增删条目,也应优先保留稳定 ID。窗口变化触发的是布局引擎对列跨度的重新计算,不应被开发者误认为数据库变化,更不能在 .onBreakpointChange() 中重新初始化收藏列表,否则断点监听本身反而成为数据丢失源。

五、这套布局适合什么,不适合什么

GridRow/GridCol 尤其适合同一批数据在不同宽度下改变每行显示数量的场景:图鉴、商品目录、课程卡片和作品集都能使用这套模式。它使布局决定权留给容器,而数据选择和收藏状态始终位于父组件,减少了不同设备重复实现同一规则的维护负担。对于主从详情双窗格、自由拖拽工作台或多窗口间共享数据,更复杂的布局与状态同步仍需专门设计,不能只靠十二列网格解决。

本篇保持了业务上的“同一状态、同一集合、同一稳定身份”,同时让三个断点完成一列、两列和三列的排版切换。它不宣称应用退出后也能保留收藏,因为这里的 @State 仍是内存状态;若确有跨重启保存需求,应单独选择 Preferences 或数据库并处理失败。把“随窗口重排”与“跨进程持久化”分清,才能在 HarmonyOS 多设备交付时做出可解释、可测试且可维护的技术选择。

Logo

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

更多推荐