【共创季稿事节】鸿蒙MediaQueryListener布局实战

鸿蒙原生 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.ArkUImediaquery 模块提供的核心类。它通过字符串形式的媒体查询条件(如 (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 页面为例,展示上述技术的综合运用。页面包含四个核心区域:

  1. 响应式状态面板 --- 展示当前屏幕宽高、方向、断点名称、推荐列数
  2. 断点标尺条 --- 可视化呈现 XS → XL 五级断点及当前位置
  3. 自适应内容网格 --- 根据宽度自动切换单列/双列/三列布局
  4. 横竖屏感知演示 --- 设备旋转时改变背景色和文案

四、核心实现详解

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 列,GridColspan 之和等于 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 封装可复用组件

statusCardcontentCard 使用 @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 等控制流语法,不可以出现 constlet 等变量声明:

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 模拟器或真机后:

  1. 竖屏手机(宽度 < 600px):状态面板两列排列,内容卡片单列纵向滚动,方向区域显示蓝色「竖屏模式」
  2. 横屏手机(600px ≤ 宽度 < 840px):状态面板两列排列,内容卡片双列栅格,方向区域变为棕色「横屏模式」
  3. 平板(840px ≤ 宽度 < 1200px):状态面板三列排列,内容卡片三列栅格
  4. 拖拽窗口边缘:所有状态数值实时更新,断点标尺游标平滑移动
  5. 旋转设备:orientation 监听器触发,方向区域背景色和文案即时切换

八、总结与最佳实践

通过本文的完整示例,我们验证了 MediaQueryListener 在鸿蒙原生应用中的实用性和灵活性。以下是笔者总结的几条最佳实践:

实践 说明
状态集中化 将依赖屏幕尺寸的计算集中在 updateLayout 一个方法中,避免多路状态源
统一尺寸读取 使用 display.getDefaultDisplaySync() 作为尺寸来源,保持各监听器回调一致性
资源必释放 aboutToDisappear 中务必调用 listener.off(),防止内存泄漏
优先用栅格 能用 GridRow/GridCol 声明式解决的问题,优先于条件渲染
遵守 ArkTS 规范 组件属性本地初始化、build 方法内不声明变量、Color 类型不传字符串

MediaQueryListener 与 GridRow/GridCol 的结合,使得鸿蒙应用可以在完全不依赖 JavaScript 或第三方库的情况下,实现媲美 Web 端 CSS Media Query 的响应式布局能力。这种「声明式优先」的设计理念,正是鸿蒙 ArkUI 框架的核心优势所在。


九、参考资料


相关推荐
不羁的木木2 小时前
《HarmonyOS 6.1 新能力实战之智感握姿》第五篇:综合实战——打造自适应阅读器
华为·harmonyos
金启攻2 小时前
鸿蒙原生应用开发实战(三):数据管理与多页面交互——渔获记录、装备管理与个人中心
harmonyos
伶俜662 小时前
鸿蒙原生应用实战(九)ArkUI 天气预报 App:HTTP 请求 + 定位 + 动效
http·华为·harmonyos
伶俜662 小时前
鸿蒙原生应用实战(四)ArkUI 语音变声器:录音 + 4 种音效 + 音调变换算法
算法·华为·harmonyos
HwJack202 小时前
HarmonyOS APP开发终结“户外运动数据失踪”的玄学:玩透穿戴设备 P2P 穿透与心跳保活的心法
华为·harmonyos·p2p
芒鸽2 小时前
HarmonyOS 网络编程实战:HTTP、WebSocket 与 Socket 通信详解
网络·http·harmonyos
风满城332 小时前
鸿蒙原生应用实战(二):数独游戏核心逻辑开发 — 棋盘渲染与交互
harmonyos
风满城3311 小时前
【鸿蒙原生应用开发实战】第五篇:项目总结——ArkTS 最佳实践与从 MVP 到生产的升级之路
华为·harmonyos