鸿蒙应用开发中,如何封装媒体查询实现响应式布局?

摘要:【学习目标】 掌握 媒体查询的引入与Stage模型标准使用流程,理解监听句柄与生命周期管理 吃透 媒体查询完整语法规则:媒体类型、逻辑操作符、核心媒体特征 实现 横竖屏切换、深浅色模式跟随、多设备适配 三大高频响应式场景 封装 可跨页面复用
【学习目标】 掌握 媒体查询的引入与Stage模型标准使用流程,理解监听句柄与生命周期管理 吃透 媒体查询完整语法规则:媒体类型、逻辑操作符、核心媒体特征 实现 横竖屏切换、深浅色模式跟随、多设备适配 三大高频响应式场景 封装 可跨页面复用的媒体查询工具类,一行代码实现状态监听 一、工程目录结构 MediaQueryDemo/ ├── entry/src/main/ets/ │ ├── utils/ │ │ └── MediaQueryUtil.ets // 媒体查询复用工具类 │ └── pages/ │ └── Index.ets // 综合示例与工具测试 └── resources/ // 工程原生资源目录 二、媒体查询核心基础 2.1 什么是媒体查询 媒体查询是鸿蒙响应式设计的核心能力,它可以根据设备的固有属性(设备类型、屏幕尺寸)、应用的实时状态(可绘制宽高、横竖屏方向)、系统配置(深浅色模式),动态修改应用的布局与样式,实现「一套代码,多设备适配」。 2.2 两大核心应用场景 静态适配:针对不同设备类型(手机/平板/车机/穿戴),预设匹配的布局规则 动态响应:监听设备状态实时变化(横竖屏切换、深浅色切换、分屏),同步更新页面布局 2.3 使用步骤 步骤1:导入媒体查询模块 import { mediaquery } from '@kit.ArkUI'; 步骤2:创建查询条件,获取监听句柄 通过 getUIContext().getMediaQuery().matchMediaSync() 接口设置查询条件,获取监听句柄 MediaQueryListener。 private listener: mediaquery.MediaQueryListener | null = null; aboutToAppear() { // 必须在aboutToAppear中初始化,确保UIContext已就绪 const mediaQueryListener = this.getUIContext().getMediaQuery().matchMediaSync('(orientation: landscape)'); } 步骤3:绑定回调函数,监听状态变化 给监听句柄绑定 on('change') 回调,当媒体特征发生变化时,会自动触发回调,通过 mediaQueryResult.matches 判断是否匹配查询条件。 aboutToAppear() { // 必须在aboutToAppear中初始化 监听屏幕方向 const mediaQueryListener = this.getUIContext().getMediaQuery().matchMediaSync('(orientation: landscape)'); this.listener = mediaQueryListener // 初始化时手动触发一次,获取初始状态 this.onOrientationChange(mediaQueryListener) // 绑定回调 this.listener.on('change', (result: mediaquery.MediaQueryResult) => { this.onOrientationChange(result); }); } onOrientationChange(mediaQueryResult: mediaquery.MediaQueryResult) { if (mediaQueryResult.matches) { console.info('当前为横屏状态'); // 横屏布局逻辑 } else { console.info('当前为竖屏状态'); // 竖屏布局逻辑 } } 步骤4:页面销毁时解绑回调,避免内存泄漏 必须在 aboutToDisappear 生命周期中解绑已注册的回调函数,否则会造成内存泄漏。 aboutToDisappear() { if (this.listener) { this.listener.off('change'); this.listener = null; } } 2.3 媒体查询完整语法规则 媒体查询条件由三部分组成,语法格式如下: [媒体类型] [逻辑操作符] [(媒体特征)] 媒体类型 类型 说明 使用规范 screen 按屏幕相关参数进行媒体查询 唯一常用类型,省略时默认使用,必须写在查询条件开头 逻辑操作符 操作符 逻辑 说明 示例 and 与 所有条件同时满足时成立 screen and (orientation: landscape) and (width > 600vp) or / , 或 任一条件满足时成立 (max-height: 1000vp) or (round-screen: true) not 非 对查询结果取反 not screen and (min-width: 600vp) >= / <= / > / < 范围 用于数值类特征的范围判断 (width >= 600vp) 核心媒体特征 宽高类特征支持 vp 和 px 单位,无单位时默认使用 px,开发中推荐使用vp单位。 特征 说明 可选值/示例 width / height 应用页面可绘制区域的宽/高(实时更新) (width: 360vp) / (height > 600vp) orientation 屏幕横竖屏方向 portrait(竖屏) / landscape(横屏) dark-mode 系统深浅色模式 true(深色模式) / false(浅色模式) device-type 设备类型 phone / tablet / tv / car / wearable / 2in1 三、媒体查询工具类封装 将核心能力封装成单例工具类,实现一行代码完成状态监听,统一管理生命周期,避免内存泄漏。 工具类代码(utils/MediaQueryUtil.ets) import { mediaquery } from '@kit.ArkUI'; class InternalKey { static readonly BREAKPOINT: string = 'internal_bp'; static readonly ORIENTATION: string = 'internal_ori'; static readonly DARK_MODE: string = 'internal_dark'; static readonly DEVICE_TYPE: string = 'internal_device'; static readonly ROUND_SCREEN: string = 'internal_round'; } /** 栅格断点类型(支持6个断点) */ export type GridBreakpointType = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; /** 设备类型 */ export type DeviceType = 'default' | 'phone' | 'tablet' | 'tv' | 'car' | 'wearable' | '2in1'; /** 屏幕方向类型 */ export type OrientationType = 'portrait' | 'landscape'; /** 媒体状态接口 */ export interface MediaFullState { /** 栅格断点 */ breakpoint: GridBreakpointType; /** 屏幕方向 */ orientation: OrientationType; /** 是否深色模式 */ isDarkMode: boolean; /** 设备类型 */ deviceType: DeviceType; /** 是否圆形屏幕 */ isRoundScreen: boolean; } /** 监听项内部管理接口 */ interface MediaQueryListenerItem { /** 监听句柄 */ listener: mediaquery.MediaQueryListener; /** 绑定的回调函数 */ callback: (result: mediaquery.MediaQueryResult) => void; } // ==================== 媒体查询核心工具类 ==================== export class MediaQueryUtil { /** 单例实例 */ private static instance: MediaQueryUtil | null = null; /** UI上下文 */ private uiContext?: UIContext; /** 监听句柄管理Map */ private listenerMap: Map<string, MediaQueryListenerItem> = new Map(); /** 默认栅格断点 */ private static readonly DEFAULT_BREAKPOINTS: number[] = [320, 600, 840, 1440, 1600]; /** 私有构造函数,禁止外部new实例 */ private constructor() {} /** * 获取单例实例 * @returns 工具类全局唯一实例 */ static getInstance(): MediaQueryUtil { if (!MediaQueryUtil.instance) { MediaQueryUtil.instance = new MediaQueryUtil(); } return MediaQueryUtil.instance; } /** * 初始化工具类 * @param uiContext 应用上下文 * @param customBreakpoints 自定义栅格断点 */ init(uiContext: UIContext, customBreakpoints?: number[]): void { if (this.uiContext) return; this.uiContext = uiContext; if (customBreakpoints?.length) { MediaQueryUtil.DEFAULT_BREAKPOINTS.splice(0, MediaQueryUtil.DEFAULT_BREAKPOINTS.length, ...customBreakpoints); } } // ==================== 核心内部方法 ==================== /** * 注册监听 */ private register( key: string, condition: string, onChange: (isMatch: boolean) => void ): void { if (!this.uiContext) { throw new Error('MediaQueryUtil 未初始化,请在应用启动时调用 init(uiContext)'); } this.removeListener(key); const listener = this.uiContext.getMediaQuery().matchMediaSync(condition); const callback = (result: mediaquery.MediaQueryResult) => { onChange(!!result.matches); }; listener.on('change', callback); this.listenerMap.set(key, { listener, callback }); onChange(!!listener.matches); } /** * 移除单个监听 */ private removeListener(key: string): void { const item = this.listenerMap.get(key); if (item) { item.listener.off('change', item.callback); this.listenerMap.delete(key); } } // ==================== 对外:取消单个监听 ==================== /** 取消断点监听 */ offBreakpointChange(): void { this.removeListener(InternalKey.BREAKPOINT); } /** 取消横竖屏监听 */ offOrientationChange(): void { this.removeListener(InternalKey.ORIENTATION); } /** 取消深色模式监听 */ offDarkModeChange(): void { this.removeListener(InternalKey.DARK_MODE); } /** 取消设备类型监听 */ offDeviceTypeChange(): void { this.removeListener(InternalKey.DEVICE_TYPE); } /** 取消圆形屏幕监听 */ offRoundScreenChange(): void { this.removeListener(InternalKey.ROUND_SCREEN); } // ==================== 对外:移除所有监听 ==================== removeAllListeners(): void { this.listenerMap.forEach(item => { item.listener.off('change', item.callback); }); this.listenerMap.clear(); } /** 销毁工具类 */ destroy(): void { this.removeAllListeners(); this.uiContext = undefined; MediaQueryUtil.instance = null; } /** 监听栅格断点变化 */ onBreakpointChange(onChange: (breakpoint: GridBreakpointType) => void): void { const update = () => { const mq = this.uiContext!.getMediaQuery(); const bps = MediaQueryUtil.DEFAULT_BREAKPOINTS; let bp: GridBreakpointType = 'xs'; if (mq.matchMediaSync(`(width >= ${bps[4]}vp)`).matches) bp = 'xxl'; else if (mq.matchMediaSync(`(width >= ${bps[3]}vp)`).matches) bp = 'xl'; else if (mq.matchMediaSync(`(width >= ${bps[2]}vp)`).matches) bp = 'lg'; else if (mq.matchMediaSync(`(width >= ${bps[1]}vp)`).matches) bp = 'md'; else if (mq.matchMediaSync(`(width >= ${bps[0]}vp)`).matches) bp = 'sm'; onChange(bp); }; this.register(InternalKey.BREAKPOINT, '(width >= 0vp)', update); } /** 监听横竖屏 */ onOrientationChange(onChange: (ori: OrientationType) => void): void { this.register(InternalKey.ORIENTATION, '(orientation: portrait), (orientation: landscape)', () => { const isLand = this.uiContext!.getMediaQuery().matchMediaSync('(orientation: landscape)').matches; onChange(isLand ? 'landscape' : 'portrait'); }); } /** 监听深色模式 */ onDarkModeChange(onChange: (isDark: boolean) => void): void { this.register(InternalKey.DARK_MODE, '(dark-mode: true)', onChange); } /** 监听设备类型 */ onDeviceTypeChange(onChange: (type: DeviceType) => void): void { const update = () => { const mq = this.uiContext!.getMediaQuery(); const types: DeviceType[] = ['phone', 'tablet', 'tv', 'car', 'wearable', '2in1']; for (const t of types) { if (mq.matchMediaSync(`(device-type: ${t})`).matches) { onChange(t); return; } } onChange('default'); }; this.register(InternalKey.DEVICE_TYPE, '(device-type: phone)', update); } /** 全量监听 */ onFullStateChange(onChange: (state: MediaFullState) => void): void { let fullState: MediaFullState = { breakpoint: 'xs', orientation: 'portrait', isDarkMode: false, deviceType: 'default', isRoundScreen: false }; this.onBreakpointChange((bp) => { fullState.breakpoint = bp; onChange(fullState); }); this.onOrientationChange((ori) => { fullState.orientation = ori; onChange(fullState); }); this.onDarkModeChange((dark) => { fullState.isDarkMode = dark; onChange(fullState); }); this.onDeviceTypeChange((device) => { fullState.deviceType = device; onChange(fullState); }); this.register( InternalKey.ROUND_SCREEN, '(round-screen: true)', (round) => { fullState.isRoundScreen = round; onChange(fullState); } ); } /** 取消全量监听 */ offFullStateChange(): void { this.offBreakpointChange(); this.offOrientationChange(); this.offDarkModeChange(); this.offDeviceTypeChange(); this.offRoundScreenChange(); } } 四、综合示例测试功能 完整代码(pages/Index.ets) import { MediaQueryUtil, MediaFullState } from '../utils/MediaQueryUtil'; @Entry @Component struct Index { // 【响应式状态】 @State isLandscape: boolean = false; @State isDarkMode: boolean = false; @State currentBreakpoint: string = 'xs'; @State currentDeviceType: string = 'default'; @State isRoundScreen: boolean = false; // 【样式参数】 @State pageBgColor: string = '#F5F5F5'; @State cardBgColor: string = '#FFFFFF'; @State textColor: string = '#000000'; aboutToAppear(): void { this.initMediaQuery(); } build() { Column() { // 标题 Text('媒体查询工具类演示') .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor(this.textColor) .margin({ top: 40, bottom: 30 }) // 状态信息卡片 Column({ space: 12 }) { Text(`当前断点:${this.currentBreakpoint}`) .fontSize(18) .fontColor(this.textColor) Text(`横竖屏:${this.isLandscape ? '横屏 Landscape' : '竖屏 Portrait'}`) .fontSize(18) .fontColor(this.textColor) Text(`深浅色模式:${this.isDarkMode ? '深色 Dark' : '浅色 Light'}`) .fontSize(18) .fontColor(this.textColor) Text(`设备类型:${this.currentDeviceType}`) .fontSize(18) .fontColor(this.textColor) Text(`是否圆形屏幕:${this.isRoundScreen ? '是' : '否'}`) .fontSize(18) .fontColor(this.textColor) } .width('90%') .padding(20) .borderRadius(16) .backgroundColor(this.cardBgColor) .shadow({ radius: 8, color: '#00000010', offsetY: 4 }) // 自适应网格布局示例 Text('响应式网格示例') .fontSize(22) .fontWeight(FontWeight.Medium) .fontColor(this.textColor) .alignSelf(ItemAlign.Start) .margin({ left: '5%', top: 25, bottom: 15 }) GridRow({ columns: this.currentBreakpoint === 'xs' ? 2 : 4, gutter: { x: 15, y: 15 } }) { ForEach([1, 2, 3, 4, 5, 6, 7, 8], (id: number) => { GridCol() { Column() { Text(`卡片 ${id}`) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(this.textColor) Text('99.00元') .fontSize(14) .fontColor('#FF3B30') } .width('100%') .aspectRatio(0.9) .justifyContent(FlexAlign.Center) .backgroundColor(this.cardBgColor) .borderRadius(12) .shadow({ radius: 5, color: '#00000008', offsetY: 2 }) } }) } .width('90%') } .width('100%') .height('100%') .backgroundColor(this.pageBgColor) } /** * 初始化媒体查询监听 * 封装成独立函数,逻辑更清晰 */ private initMediaQuery(): void { const mediaUtil = MediaQueryUtil.getInstance(); mediaUtil.init(this.getUIContext()); mediaUtil.onFullStateChange((state: MediaFullState) => { this.isLandscape = state.orientation === 'landscape'; this.isDarkMode = state.isDarkMode; this.currentBreakpoint = state.breakpoint; this.currentDeviceType = state.deviceType; this.isRoundScreen = state.isRoundScreen; this.updateTheme(state.isDarkMode); }); } /** * 更新主题配色 * @param isDark 是否深色模式 */ private updateTheme(isDark: boolean): void { if (isDark) { this.pageBgColor = '#121212'; this.cardBgColor = '#1E1E1E'; this.textColor = '#FFFFFF'; } else { this.pageBgColor = '#F5F5F5'; this.cardBgColor = '#FFFFFF'; this.textColor = '#000000'; } } /** * 页面销毁时解绑 * 防止内存泄漏 */ aboutToDisappear(): void { MediaQueryUtil.getInstance().removeAllListeners(); } } 测试结果 五、内容总结 核心作用:媒体查询是鸿蒙响应式布局的核心,实现「一套代码,多设备适配」。 标准流程:导入模块 → aboutToAppear中创建监听 → 绑定change回调 → aboutToDisappear中解绑回调。 语法规则:查询条件由「媒体类型 + 逻辑操作符 + 媒体特征」组成,宽高优先使用vp单位。 工具类封装:单例模式统一管理监听,一行代码实现状态监听,彻底避免内存泄漏。 综合测试:在一个页面中同时测试横竖屏、深浅色、多设备适配三大高频场景。 六、代码仓库 工程名称:MediaQueryDemo 仓库地址:https://gitee.com/HarmonyOS-UI-Basics/harmony-os-ui-basics.git 七、下节预告 下一节我们将正式学习 鸿蒙响应式布局核心:栅格布局 (GridRow/GridCol),彻底搞定多设备布局的标准化方案: 掌握 GridRow 栅格容器的断点规则、总列数配置、排列方向与间距设置 吃透 GridCol 栅格子组件的 span(占用列数)、offset(偏移列数)、order(排序)三大核心属性 实现 手机/平板/折叠屏 多设备自适应布局,一套代码适配全尺寸设备 理解 栅格组件的嵌套使用,完成复杂页面的响应式布局