
一、开篇:多设备适配的痛点
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 列,有时需要等待几百毫秒,或者需要手动触发刷新。
原因 :GridRow 的 columns 属性是动态绑定的,但 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
// 列数切换由媒体查询回调完成
}
六、最佳实践
-
媒体查询条件中的单位用 vp,不要用 px。vp 会跟随屏幕密度自动缩放,px 在不同密度设备上表现不一致。这是 ArkUI 开发中的基础规范,但很多从其他平台转过来的开发者容易忽略。
-
不要同时修改 GridRow 的 columns 和 breakpoints 属性来切换列数 。
breakpoints用于定义 GridRow 内部的断点值,columns用于指定列数。实际项目里,columns动态绑定就够用了,breakpoints使用默认值就行。两个一起改容易造成逻辑冲突。 -
折叠屏的宽高比变化比普通设备更剧烈,建议把媒体查询的断点值设为 600vp 和 840vp,而不是硬编码成手机/平板的分界线。折叠屏展开后宽度接近小平板,但高度变化不大,断点值需要覆盖这个区间。
-
在 aboutToDisappear 中必须清理媒体查询监听和折叠状态监听,否则组件销毁后回调仍在执行,会触发"页面已销毁但状态仍在更新"的警告,严重时会导致内存泄漏。这是 ArkTS 声明式开发范式里容易踩的坑。
七、FAQ
Q:为什么在模拟器上折叠屏状态监听不生效?
A:模拟器不支持物理折叠状态变化,display.getFoldStatus() 在模拟器上始终返回 UNKNOWN。折叠屏相关功能必须在真机上测试。建议使用华为折叠屏设备或远程真机调试。
Q:GridRow 的 span 设置了 xs、sm、md、lg 不同的值,为什么列数没变?
A:span 控制的是单个网格项占用的列数,不是总列数。总列数由 columns 属性控制。如果每个 GridCol 的 span 都是 1,总列数由 columns 决定。如果 span 设置为 2,表示该项占 2 列宽度。两者功能不同,容易混淆。
Q:页面返回后,再次进入时媒体查询回调不触发,为什么?
A:页面返回后,组件被销毁。再次进入时创建新的组件实例,媒体查询监听器重新注册。但如果页面被缓存(使用了 @Entry 的 reuse 特性),可能沿用旧的监听器,导致回调异常。建议在每个页面的 aboutToAppear 中重新注册监听,aboutToDisappear 中移除,不要依赖全局缓存。
Q:使用 vp 单位后,不同设备的显示效果还是不一致,怎么办?
A:先检查是否在 CSS 中混用了 px 和 vp。同一布局中,所有尺寸单位应该统一用