《HarmonyOS技术精讲-UI开发》第9篇:自适应布局

一、开篇:多设备适配的痛点

HarmonyOS NEXT 开发里,UI自适应是一个绕不开的问题。手机、平板、折叠屏、智慧屏,屏幕尺寸差异巨大,同一个布局在不同设备上要么撑满变形,要么留白过多。很多开发者第一次接触 ArkUI 时,会用固定 px 写布局,结果一到真机测试就出问题。

vp/sp 单位、媒体查询、GridRow 栅格布局,这三个能力是解决自适应布局的核心手段。但官方示例通常只展示单个 API 的用法,没告诉你这三个能力怎么配合使用,也没说折叠屏状态切换时布局怎么平滑过渡。这篇文章会用一个完整的网格列表示例,把这三个能力串起来,并处理折叠屏展开/折叠的布局切换。

二、先讲清楚"自适应布局"解决什么问题

自适应布局指的是页面元素根据设备屏幕尺寸、方向、折叠状态动态调整排列方式,不依赖横向滚动。

为什么需要自适应布局?

HarmonyOS 的目标设备类型很多,从 2 英寸的穿戴设备到 13 英寸的平板,固定布局无法覆盖所有屏幕。ArkUI 提供了几种方案:

方案 原理 适用场景 局限
百分比宽高 按父容器比例计算 简单撑满场景 无法改变布局结构
Flex 弹性布局 按主轴排列、自动换行 列表、导航栏 多列网格控制不精确
媒体查询 + GridRow 按屏幕断点切换栅格列数 内容展示型页面 需要预先定义断点

推荐组合方案:媒体查询监听屏幕宽度,GridRow/GridCol 实现栅格列数切换,vp/sp 保证尺寸随屏幕密度缩放。这套方案在多个商业项目里验证过,稳定性比纯 Flex 布局高,布局切换时不会出现闪烁。

三、环境说明

text 复制代码
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机 / 平板 / 折叠屏(真机优先)

四、核心实现:自适应图片网格

下面实现一个"自适应图片网格",要求:

  • 手机(< 600vp):2 列
  • 平板(600vp ~ 840vp):3 列
  • 大平板(> 840vp):4 列
  • 折叠屏折叠时:2 列,展开时:4 列

4.1 数据模型

先定义一个简单的图片数据模型,方便网格渲染。

typescript 复制代码
// model/ImageData.ets
export class ImageItem {
  id: number = 0
  title: string = ''
  color: string = '#ccc'

  constructor(id: number, title: string, color: string) {
    this.id = id
    this.title = title
    this.color = color
  }
}

// 生成测试数据
export function generateMockData(): ImageItem[] {
  const colors: string[] = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8',
                            '#F7DC6F', '#BB8FCE', '#85C1E9', '#F1948A', '#82E0AA']
  const items: ImageItem[] = []
  for (let i = 1; i <= 20; i++) {
    items.push(new ImageItem(i, `Item ${i}`, colors[i % colors.length]))
  }
  return items
}

4.2 核心:自适应网格组件

这是整个功能的核心,包含媒体查询、GridRow 栅格布局、折叠屏状态监听。

typescript 复制代码
// components/AdaptiveGrid.ets
import { mediaquery } from '@kit.ArkUI'
import { ImageItem } from '../model/ImageData'

@Component
export struct AdaptiveGrid {
  private items: ImageItem[] = []
  // 当前列数,根据屏幕宽度动态变化
  @State currentColumns: number = 2
  // 用于mediaquery的监听条件
  @State breakPoint: string = 'sm'

  // 媒体查询监听器
  private mediaListener: mediaquery.MediaQueryListener | null = null

  aboutToAppear(): void {
    // 注册媒体查询:监听屏幕宽度断点
    // sm: <600vp  md: 600-840vp  lg: >840vp
    this.mediaListener = mediaquery.matchMediaSync('(min-width: 600vp) and (max-width: 840vp)')
    this.mediaListener.on('change', (result: mediaquery.MediaQueryResult) => {
      if (result.matches) {
        // 600vp ~ 840vp => 3列
        this.changeColumns(3)
        this.breakPoint = 'md'
        console.info('AdaptiveGrid: breakpoint md, columns=3')
      }
    })

    // 监听大屏断点
    const lgListener = mediaquery.matchMediaSync('(min-width: 840vp)')
    lgListener.on('change', (result: mediaquery.MediaQueryResult) => {
      if (result.matches) {
        this.changeColumns(4)
        this.breakPoint = 'lg'
        console.info('AdaptiveGrid: breakpoint lg, columns=4')
      } else {
        // 小于840vp但可能大于600vp,由前一个监听处理
        // 如果小于600vp,默认2列
        if (this.breakPoint !== 'md') {
          this.changeColumns(2)
          this.breakPoint = 'sm'
          console.info('AdaptiveGrid: breakpoint sm, columns=2')
        }
      }
    })

    // 初始判断
    this.initColumns()
  }

  /**
   * 初始化列数:根据当前屏幕宽度决定
   * 实际项目里可以用 display.getWindowInfo() 获取宽高
   * 这里用媒体查询回退方案
   */
  initColumns(): void {
    // 简单模拟:在aboutToAppear中无法同步获取媒体查询结果
    // 所以默认先设2列,稍后回调会纠正
    this.currentColumns = 2
  }

  changeColumns(cols: number): void {
    if (this.currentColumns !== cols) {
      this.currentColumns = cols
    }
  }

  aboutToDisappear(): void {
    // 移除监听,防止内存泄漏
    if (this.mediaListener) {
      this.mediaListener.off('change')
    }
  }

  build() {
    Column() {
      // 标题栏,显示当前状态
      Row() {
        Text(`自适应网格 - 当前列数: ${this.currentColumns} 断点: ${this.breakPoint}`)
          .fontSize(14)
          .fontColor('#666')
          .textAlign(TextAlign.Start)
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 8 })

      // GridRow 栅格布局
      GridRow({
        columns: this.currentColumns,
        gutter: { x: 8, y: 8 },
        breakpoints: {
          value: ['200vp', '600vp', '840vp'],
          reference: BreakpointsReference.WindowSize
        }
      }) {
        ForEach(this.items, (item: ImageItem, index: number) => {
          GridCol({
            span: {
              xs: 1,  // <200vp 占1列
              sm: 1,  // 200-600vp 占1列
              md: 1,  // 600-840vp 占1列
              lg: 1   // >840vp 占1列
            }
          }) {
            // 每个网格项
            Column() {
              // 用颜色块模拟图片
              Stack() {
                Rectangle()
                  .width('100%')
                  .aspectRatio(1.0) // 正方形
                  .fill(item.color)
                  .radius(8)
                Text(`${item.id}`)
                  .fontSize(24)
                  .fontColor('#fff')
                  .fontWeight(FontWeight.Bold)
              }
              .width('100%')

              Text(item.title)
                .fontSize(14)
                .fontColor('#333')
                .margin({ top: 4 })
                .textAlign(TextAlign.Center)
                .width('100%')
            }
            .width('100%')
            .padding(4)
          }
        }, (item: ImageItem) => item.id.toString())
      }
      .width('100%')
      .onBreakpointChange((breakpoint: string) => {
        // GridRow自带的断点变化回调
        console.info(`GridRow breakpoint changed: ${breakpoint}`)
        // 这里可以同步更新状态,但不直接修改currentColumns
        // 因为GridRow的列数由columns属性决定,断点回调只是通知
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

4.3 折叠屏状态监听(附加)

折叠屏的特殊性在于:物理形态变化(折叠/展开)会改变屏幕宽度,触发媒体查询回调。但有些场景需要单独监听折叠状态以便做更精细的控制。

HarmonyOS 提供了 display.on('foldStatusChange') 来监听折叠状态变化。

typescript 复制代码
// utils/FoldStatusManager.ets
import { display } from '@kit.ArkUI'

export class FoldStatusManager {
  private static instance: FoldStatusManager
  private foldStatus: display.FoldStatus = display.FoldStatus.UNKNOWN
  private listeners: Array<(folded: boolean) => void> = []

  private constructor() {
    // 初始化状态
    this.foldStatus = display.getFoldStatus()
    // 监听变化
    display.on('foldStatusChange', (status: display.FoldStatus) => {
      const oldFolded = this.isFolded()
      this.foldStatus = status
      const newFolded = this.isFolded()
      if (oldFolded !== newFolded) {
        this.notify(newFolded)
      }
    })
  }

  static getInstance(): FoldStatusManager {
    if (!FoldStatusManager.instance) {
      FoldStatusManager.instance = new FoldStatusManager()
    }
    return FoldStatusManager.instance
  }

  isFolded(): boolean {
    return this.foldStatus === display.FoldStatus.FOLDED
  }

  register(callback: (folded: boolean) => void): void {
    this.listeners.push(callback)
  }

  unregister(callback: (folded: boolean) => void): void {
    const idx = this.listeners.indexOf(callback)
    if (idx >= 0) {
      this.listeners.splice(idx, 1)
    }
  }

  private notify(folded: boolean): void {
    for (const cb of this.listeners) {
      cb(folded)
    }
  }

  release(): void {
    display.off('foldStatusChange')
    this.listeners = []
  }
}

在 AdaptiveGrid 中集成折叠状态监听:

typescript 复制代码
// 在 AdaptiveGrid 中增加折叠屏处理
aboutToAppear(): void {
  // ... 原有的媒体查询代码 ...
  
  // 注册折叠状态监听
  FoldStatusManager.getInstance().register(this.handleFoldChange)
}

private handleFoldChange = (folded: boolean): void => {
  if (folded) {
    // 折叠状态 => 2列
    this.changeColumns(2)
    console.info('AdaptiveGrid: folded, columns=2')
  } else {
    // 展开状态 => 根据当前断点决定列数
    // 这里由媒体查询回调决定,不再重复处理
    console.info('AdaptiveGrid: unfolded, waiting for media query')
  }
}

aboutToDisappear(): void {
  // ... 原有的清理代码 ...
  FoldStatusManager.getInstance().unregister(this.handleFoldChange)
}

4.4 主页面入口

typescript 复制代码
// pages/Index.ets
import { AdaptiveGrid } from '../components/AdaptiveGrid'
import { generateMockData } from '../model/ImageData'

@Entry
@Component
struct Index {
  private data = generateMockData()

  build() {
    Column() {
      // 状态说明
      Text('不同屏幕宽度自动切换列数')
        .fontSize(16)
        .fontColor('#333')
        .margin({ top: 16, bottom: 8 })

      Text('手机: 2列 | 平板: 3列 | 大平板/折叠展开: 4列')
        .fontSize(12)
        .fontColor('#999')
        .margin({ bottom: 16 })

      // 自适应网格
      AdaptiveGrid({ items: this.data })
        .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

五、踩坑记录

坑1:媒体查询回调在页面初始化时不触发

现象:页面首次加载时,列数一直是默认值 2,即使屏幕宽度大于 840vp。需要手动旋转屏幕或重新进入页面才能更新。

原因mediaquery.matchMediaSync().on('change') 只在媒体条件从匹配变为不匹配,或不匹配变为匹配时触发。如果页面加载时条件已经满足,不会触发回调。所以初始化阶段无法依赖回调来设置正确的列数。

解决方案 :在 aboutToAppear 中主动获取一次屏幕宽度,设置初始列数。推荐使用 display.getWindowInfo() 获取窗口宽度。

typescript 复制代码
// 初始化修正
import { display } from '@kit.ArkUI'

aboutToAppear(): void {
  // 先主动获取窗口宽度,设置初始值
  const windowInfo = display.getWindowInfo()
  const widthVp = windowInfo.width / display.getDensityInfo().densityPixels
  if (widthVp >= 840) {
    this.currentColumns = 4
    this.breakPoint = 'lg'
  } else if (widthVp >= 600) {
    this.currentColumns = 3
    this.breakPoint = 'md'
  } else {
    this.currentColumns = 2
    this.breakPoint = 'sm'
  }
  // 再注册监听
  // ... 注册代码 ...
}

坑2:GridRow 的 columns 属性变化后,UI 更新不及时

现象 :调用 this.currentColumns = 3 后,UI 不会立即变为 3 列,有时需要等待几百毫秒,或者需要手动触发刷新。

原因GridRowcolumns 属性是动态绑定的,但 ArkUI 的渲染引擎对栅格布局的变化做了缓存,连续多次修改同一属性时,只有最后一次生效。如果在短时间内频繁修改 currentColumns,可能导致渲染丢帧。

解决方案 :在修改列数前做防抖,或者使用 @State 配合 changeColumns 方法统一管理,避免在多处直接修改 currentColumns

typescript 复制代码
private changeColumns(cols: number): void {
  if (this.currentColumns !== cols) {
    // 确保只在值变化时更新
    this.currentColumns = cols
    // 如果需要强制刷新,可以配合一个临时key
    // 但一般不推荐,ArkUI会自行处理
  }
}

坑3:折叠屏展开时,媒体查询结果和折叠状态监听结果不一致

现象 :折叠屏从折叠状态展开时,foldStatusChange 先触发(状态变为 UNFOLDED),然后媒体查询回调才触发。但中间有短暂的时间窗口,布局列数已经根据折叠状态改为 4 列,但媒体查询结果还没来得及更新,导致布局闪烁。

原因:两个回调的执行顺序不确定。折叠状态变化后,屏幕尺寸还没完全稳定,媒体查询的响应有延迟。

解决方案:在折叠状态回调中不做列数切换,只记录状态。真正的列数切换完全由媒体查询回调控制。折叠状态回调只用于 UI 提示或日志记录。

typescript 复制代码
private handleFoldChange = (folded: boolean): void => {
  // 只记录状态,不直接修改列数
  this.isCurrentlyFolded = folded
  // 列数切换由媒体查询回调完成
}

六、最佳实践

  1. 媒体查询条件中的单位用 vp,不要用 px。vp 会跟随屏幕密度自动缩放,px 在不同密度设备上表现不一致。这是 ArkUI 开发中的基础规范,但很多从其他平台转过来的开发者容易忽略。

  2. 不要同时修改 GridRow 的 columns 和 breakpoints 属性来切换列数breakpoints 用于定义 GridRow 内部的断点值,columns 用于指定列数。实际项目里,columns 动态绑定就够用了,breakpoints 使用默认值就行。两个一起改容易造成逻辑冲突。

  3. 折叠屏的宽高比变化比普通设备更剧烈,建议把媒体查询的断点值设为 600vp 和 840vp,而不是硬编码成手机/平板的分界线。折叠屏展开后宽度接近小平板,但高度变化不大,断点值需要覆盖这个区间。

  4. 在 aboutToDisappear 中必须清理媒体查询监听和折叠状态监听,否则组件销毁后回调仍在执行,会触发"页面已销毁但状态仍在更新"的警告,严重时会导致内存泄漏。这是 ArkTS 声明式开发范式里容易踩的坑。

七、FAQ

Q:为什么在模拟器上折叠屏状态监听不生效?

A:模拟器不支持物理折叠状态变化,display.getFoldStatus() 在模拟器上始终返回 UNKNOWN。折叠屏相关功能必须在真机上测试。建议使用华为折叠屏设备或远程真机调试。

Q:GridRow 的 span 设置了 xs、sm、md、lg 不同的值,为什么列数没变?

A:span 控制的是单个网格项占用的列数,不是总列数。总列数由 columns 属性控制。如果每个 GridColspan 都是 1,总列数由 columns 决定。如果 span 设置为 2,表示该项占 2 列宽度。两者功能不同,容易混淆。

Q:页面返回后,再次进入时媒体查询回调不触发,为什么?

A:页面返回后,组件被销毁。再次进入时创建新的组件实例,媒体查询监听器重新注册。但如果页面被缓存(使用了 @Entryreuse 特性),可能沿用旧的监听器,导致回调异常。建议在每个页面的 aboutToAppear 中重新注册监听,aboutToDisappear 中移除,不要依赖全局缓存。

Q:使用 vp 单位后,不同设备的显示效果还是不一致,怎么办?

A:先检查是否在 CSS 中混用了 px 和 vp。同一布局中,所有尺寸单位应该统一用