鸿蒙原生 ArkTS 布局方式之 MediaQueryListener 媒体查询布局实战
适用平台: HarmonyOS NEXT(API 24+ / API 12+)
核心关键词: MediaQueryListener、onPortrait、onLandscape、自适应布局、响应式栅格
一、引言
在鸿蒙生态快速迭代的今天,应用需要覆盖手机、折叠屏、平板、车机甚至智慧屏等多种设备形态。屏幕尺寸从 2 英寸到 12 英寸以上不等,宽高比也千差万别。如果为每种设备分别维护一套布局代码,开发和维护成本将呈指数级上升。
媒体查询(Media Query) 正是为解决这个问题而生。鸿蒙 ArkUI 框架提供了 MediaQueryListener API,允许开发者根据屏幕宽度、方向等条件动态调整组件的布局和样式。本文通过一个完整的实战示例,详细讲解如何利用 MediaQueryListener 结合 GridRow/GridCol 栅格系统,以及通过 orientation 媒体查询实现横竖屏自适应布局。
二、技术背景
2.1 什么是 MediaQueryListener
MediaQueryListener 是 @kit.ArkUI 中 mediaquery 模块提供的核心类。它通过字符串形式的媒体查询条件(如 (min-width: 600px))创建一个监听器,当屏幕状态满足或不再满足该条件时,触发回调函数通知开发者。
关键 API:
typescript
import { mediaquery } from '@kit.ArkUI';
// 创建监听器
const listener = mediaquery.matchMediaSync('(min-width: 600px)');
// 注册回调
listener.on('change', (result: mediaquery.MediaQueryResult) => {
console.info(`条件满足? ${result.matches}`);
});
// 释放资源
listener.off('change');
2.2 断点体系与 GridRow/GridCol
鸿蒙的栅格系统 GridRow + GridCol 提供了五个预定义断点:
| 断点标识 | 含义 | 最小宽度 |
|---|---|---|
xs |
超小屏 | 0px |
sm |
小屏 | 520px |
md |
中屏 | 840px |
lg |
大屏 | 1080px |
xl |
超大屏 | 1710px |
每个 GridCol 可以通过 span 属性设置在不同断点下占据的列数,从而实现无需 JavaScript 逻辑的声明式自适应布局。
2.3 orientation 媒体查询
除了宽度,orientation 媒体查询条件可以检测屏幕方向:
typescript
mediaquery.matchMediaSync('(orientation: landscape)');
当设备旋转时,回调中的 matches 值会同步变化,开发者据此切换布局。
三、示例应用架构概览
本节以一个完整的 @Entry @Component 页面为例,展示上述技术的综合运用。页面包含四个核心区域:
- 响应式状态面板 --- 展示当前屏幕宽高、方向、断点名称、推荐列数
- 断点标尺条 --- 可视化呈现 XS → XL 五级断点及当前位置
- 自适应内容网格 --- 根据宽度自动切换单列/双列/三列布局
- 横竖屏感知演示 --- 设备旋转时改变背景色和文案
四、核心实现详解
4.1 初始化屏幕尺寸
在 aboutToAppear 生命周期中,我们通过 display.getDefaultDisplaySync() 获取设备屏幕的初始宽度和高度。这是 ArkTS 中读取屏幕尺寸的标准方式,不依赖任何运行时上下文:
typescript
aboutToAppear(): void {
const defaultDisplay = display.getDefaultDisplaySync();
this.currentWidth = defaultDisplay.width;
this.currentHeight = defaultDisplay.height;
this.updateLayout(this.currentWidth);
// ...
}
为什么不用 getUIContext().getWindowInfo()? 在 API 24 中,UIContext 并未提供 getWindowInfo() 方法(编译器会报错:Property 'getWindowInfo' does not exist on type 'UIContext')。display.getDefaultDisplaySync() 是更底层且通用的方案。
4.2 创建多个 MediaQueryListener
为了实现精细的断点响应,我们创建了三个监听器,分别监听中屏(≥600px)、大屏(≥840px)和横屏方向:
typescript
private listenerMedium: mediaquery.MediaQueryListener | null = null;
private listenerLarge: mediaquery.MediaQueryListener | null = null;
private listenerOrientation: mediaquery.MediaQueryListener | null = null;
aboutToAppear(): void {
// 中屏:宽度 >= 600px
this.listenerMedium = mediaquery.matchMediaSync('(min-width: 600px)');
this.listenerMedium.on('change', () => {
this.refreshDisplaySize();
});
// 大屏:宽度 >= 840px
this.listenerLarge = mediaquery.matchMediaSync('(min-width: 840px)');
this.listenerLarge.on('change', () => {
this.refreshDisplaySize();
});
// 横屏方向
this.listenerOrientation = mediaquery.matchMediaSync('(orientation: landscape)');
this.listenerOrientation.on('change', (result) => {
this.isLandscape = result.matches;
});
}
设计要点: 为什么宽度监听器的回调里不直接使用 result.matches,而是调用 refreshDisplaySize() 从 display 重新读取?因为 matches 只返回布尔值(满足/不满足),但我们还需要具体的像素值 来驱动标尺条等 UI 元素。通过统一调用 refreshDisplaySize(),可以保证宽度、高度、断点名称、列数等状态一次更新完毕。
4.3 窗口尺寸变化监听
单靠媒体查询断点还不够------当用户在模拟器中拖拽调整窗口大小时,我们需要实时跟踪具体的像素值。这里使用 window.getLastWindow() 获取当前窗口对象,然后订阅 windowSizeChange 事件:
typescript
window.getLastWindow(getContext(this)).then((win: window.Window) => {
win.on('windowSizeChange', (data: window.Size) => {
this.currentWidth = data.width;
this.currentHeight = data.height;
this.updateLayout(data.width);
});
}).catch((err: Error) => {
console.error(`获取窗口失败:${err.message}`);
});
注意事项: getLastWindow() 返回的是 Promise,需要使用 .then() 或 await 处理异步结果。在 aboutToAppear 中我们使用 .then() 链式调用,避免阻塞组件初始化。
4.4 状态集中管理
为了减少不必要的 UI 刷新,我们将所有依赖屏幕宽度的状态计算集中到 updateLayout 方法中:
typescript
private updateLayout(width: number): void {
this.breakpoint = breakpointName(width);
this.columns = layoutColumns(width);
}
breakpointName 将像素值映射为可读的断点名称:
typescript
function breakpointName(width: number): string {
if (width < 360) return 'XS --- 极小屏';
if (width < 600) return 'SM --- 小屏(手机竖屏)';
if (width < 840) return 'MD --- 中屏(手机横屏 / 小平板)';
if (width < 1200) return 'LG --- 大屏(平板)';
return 'XL --- 超大屏(桌面级)';
}
layoutColumns 根据宽度决定内容网格的列数:
typescript
function layoutColumns(width: number): number {
if (width < 600) return 1; // 单列------竖屏手机
if (width < 840) return 2; // 双列------横屏手机或小平板
return 3; // 三列------平板及以上
}
这种做法的好处是:无论触发源是媒体查询事件还是窗口尺寸变化事件,状态更新逻辑都只有一份,避免了多路状态源导致的 UI 闪烁或不一致。
4.5 资源释放
在 aboutToDisappear 中,必须调用 listener.off('change') 解除回调注册,否则会导致内存泄漏:
typescript
aboutToDisappear(): void {
this.listenerMedium?.off('change');
this.listenerLarge?.off('change');
this.listenerOrientation?.off('change');
}
由于监听器字段声明为 mediaquery.MediaQueryListener | null,这里使用可选链 ?. 进行安全调用,符合 ArkTS 的「组件属性必须本地初始化」规则。
五、UI 构建详解
5.1 状态面板的响应式栅格
状态面板使用 GridRow + GridCol 实现,五个状态卡片在不同断点下的布局密度自动调整:
typescript
GridRow() {
GridCol({ span: { xs: 12, sm: 6, md: 6, lg: 4, xl: 4 } }) {
this.statusCard('屏幕宽度', `${this.currentWidth}px`, Color.Blue)
}
GridCol({ span: { xs: 12, sm: 6, md: 6, lg: 4, xl: 4 } }) {
this.statusCard('屏幕高度', `${this.currentHeight}px`, Color.Green)
}
// ... 更多卡片
}
span 的取值含义:
xs: 12--- 超小屏时占满 12 列(每行 1 个卡片)sm: 6--- 小屏时占 6 列(每行 2 个卡片)lg: 4--- 大屏时占 4 列(每行 3 个卡片)
栅格系统总共 12 列,GridCol 的 span 之和等于 12 表示占满一行。
5.2 条件渲染切换单列/多列
内容区域使用 if (this.columns === 1) 条件渲染,这是 ArkTS build 方法中唯一允许的逻辑分支语法 (不支持 switch):
typescript
if (this.columns === 1) {
// 单列:纵向排列
Column({ space: 12 }) {
this.contentCard(1, Color.Blue)
this.contentCard(2, Color.Green)
this.contentCard(3, Color.Orange)
this.contentCard(4, Color.Red)
}
} else {
// 多列:栅格排列
GridRow() {
GridCol({ span: { xs: 12, sm: 6, md: 6, lg: 4, xl: 4 } }) {
this.contentCard(1, Color.Blue)
}
// ...
}
}
当 columns === 1 时,卡片垂直堆叠,每个卡片高度 120vp,适合竖屏单手操作;当 columns >= 2 时,卡片并行排列,高度缩减为 100vp,信息密度更高。
5.3 横竖屏方向感知
方向感知区域使用 @State isLandscape 驱动不同的背景色和提示文案:
typescript
Column() {
if (this.isLandscape) {
Text('🌅 横屏模式').fontSize(18).fontWeight(FontWeight.Bold)
// 横屏提示内容
} else {
Text('📱 竖屏模式').fontSize(18).fontWeight(FontWeight.Bold)
// 竖屏提示内容
}
}
.backgroundColor(this.isLandscape ? Color.Brown : Color.Blue)
isLandscape 的值由 orientation 媒体查询的回调维护。当用户旋转设备时,build 方法自动重新执行,UI 即刻响应。
5.4 @Builder 封装可复用组件
statusCard 和 contentCard 使用 @Builder 装饰器封装,避免在 build 方法中重复书写大量属性链:
typescript
@Builder
private statusCard(label: string, value: string, cardColor: Color): void {
Column() {
Text(label).fontSize(12).fontColor(Color.Gray).opacity(0.8)
Text(value).fontSize(16).fontColor(cardColor).fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(Color.White)
.borderRadius(12)
.shadow({ radius: 4, color: Color.Gray, offsetY: 2 })
.width('100%')
}
@Builder 方法的参数类型必须显式声明,ArkTS 不支持类型推断。
六、ArkTS 编码规范要点
在开发过程中,笔者遇到并解决了以下 ArkTS 特有的编译约束,在此列出供读者参考:
6.1 组件属性必须本地初始化
这是 ArkTS 最严格的规则之一:所有组件成员变量(包括 @State、@Prop、私有成员等)在声明时必须赋予一个不依赖运行时上下文的合法默认值。
typescript
// ❌ 错误:使用 ! 明确赋值断言
private listener!: MediaQueryListener;
// ✅ 正确:使用联合类型 + null 默认值
private listener: MediaQueryListener | null = null;
6.2 build 方法内禁止变量声明
build() 方法内只能包含组件构造函数和 if/ForEach 等控制流语法,不可以出现 const、let 等变量声明:
typescript
build() {
Column() {
// ❌ 错误:不能在 build 内声明变量
const color = this.columns >= 3 ? Color.Blue : Color.Gray;
Text('hello').fontColor(color)
// ✅ 正确:inline 表达式
Text('hello').fontColor(this.columns >= 3 ? Color.Blue : Color.Gray)
}
}
6.3 字符串不能直接赋值给 Color 类型
backgroundColor()、fontColor() 等方法要求参数类型为 Color 枚举或 Resource,不能直接传入 CSS 风格的十六进制字符串:
typescript
// ❌ 错误:Argument of type 'string' is not assignable to parameter of type 'Color'
.backgroundColor('#3B82F6')
// ✅ 正确:使用 Color 枚举
.backgroundColor(Color.Blue)
6.4 导入语句必须精确
ArkTS 要求从 @kit.ArkUI 中显式导入所有使用到的模块:
typescript
import { mediaquery, display, window } from '@kit.ArkUI';
这三种类型分别对应媒体查询、显示信息和窗口操作,在 API 24 中分别位于 @kit.ArkUI 的不同子模块中,但顶层导入均可直接访问。
6.5 UIAbility 页面路径配置
创建新的页面文件后,需要在 main_pages.json 中注册路由,并在 EntryAbility 中指向起始页:
json
// resources/base/profile/main_pages.json
{
"src": [
"pages/Index",
"pages/MediaQuerySample"
]
}
typescript
// EntryAbility.ets
windowStage.loadContent('pages/MediaQuerySample', (err) => {
if (err.code) { /* 处理错误 */ }
});
七、运行效果与验证
将项目部署到 DevEco Studio 模拟器或真机后:
- 竖屏手机(宽度 < 600px):状态面板两列排列,内容卡片单列纵向滚动,方向区域显示蓝色「竖屏模式」
- 横屏手机(600px ≤ 宽度 < 840px):状态面板两列排列,内容卡片双列栅格,方向区域变为棕色「横屏模式」
- 平板(840px ≤ 宽度 < 1200px):状态面板三列排列,内容卡片三列栅格
- 拖拽窗口边缘:所有状态数值实时更新,断点标尺游标平滑移动
- 旋转设备:orientation 监听器触发,方向区域背景色和文案即时切换
八、总结与最佳实践
通过本文的完整示例,我们验证了 MediaQueryListener 在鸿蒙原生应用中的实用性和灵活性。以下是笔者总结的几条最佳实践:
| 实践 | 说明 |
|---|---|
| 状态集中化 | 将依赖屏幕尺寸的计算集中在 updateLayout 一个方法中,避免多路状态源 |
| 统一尺寸读取 | 使用 display.getDefaultDisplaySync() 作为尺寸来源,保持各监听器回调一致性 |
| 资源必释放 | aboutToDisappear 中务必调用 listener.off(),防止内存泄漏 |
| 优先用栅格 | 能用 GridRow/GridCol 声明式解决的问题,优先于条件渲染 |
| 遵守 ArkTS 规范 | 组件属性本地初始化、build 方法内不声明变量、Color 类型不传字符串 |
MediaQueryListener 与 GridRow/GridCol 的结合,使得鸿蒙应用可以在完全不依赖 JavaScript 或第三方库的情况下,实现媲美 Web 端 CSS Media Query 的响应式布局能力。这种「声明式优先」的设计理念,正是鸿蒙 ArkUI 框架的核心优势所在。
九、参考资料


