鸿蒙应用开发UI基础第三十四节:媒体查询核心解析 —— 响应式布局与工具类封装

【学习目标】

  • 掌握 媒体查询的引入与Stage模型标准使用流程,理解监听句柄与生命周期管理
  • 吃透 媒体查询完整语法规则:媒体类型、逻辑操作符、核心媒体特征
  • 实现 横竖屏切换、深浅色模式跟随、多设备适配 三大高频响应式场景
  • 封装 可跨页面复用的媒体查询工具类,一行代码实现状态监听

一、工程目录结构

text 复制代码
MediaQueryDemo/
├── entry/src/main/ets/
│   ├── utils/
│   │   └── MediaQueryUtil.ets    // 媒体查询复用工具类
│   └── pages/
│       └── Index.ets              // 综合示例与工具测试
└── resources/                     // 工程原生资源目录

二、媒体查询核心基础

2.1 什么是媒体查询

媒体查询是鸿蒙响应式设计的核心能力,它可以根据设备的固有属性(设备类型、屏幕尺寸)、应用的实时状态(可绘制宽高、横竖屏方向)、系统配置(深浅色模式),动态修改应用的布局与样式,实现「一套代码,多设备适配」。

2.2 两大核心应用场景

  1. 静态适配:针对不同设备类型(手机/平板/车机/穿戴),预设匹配的布局规则
  2. 动态响应:监听设备状态实时变化(横竖屏切换、深浅色切换、分屏),同步更新页面布局

2.3 使用步骤

步骤1:导入媒体查询模块
javascript 复制代码
import { mediaquery } from '@kit.ArkUI';
步骤2:创建查询条件,获取监听句柄

通过 getUIContext().getMediaQuery().matchMediaSync() 接口设置查询条件,获取监听句柄 MediaQueryListener

javascript 复制代码
private listener: mediaquery.MediaQueryListener | null = null;
aboutToAppear() {
  // 必须在aboutToAppear中初始化,确保UIContext已就绪
const mediaQueryListener  = this.getUIContext().getMediaQuery().matchMediaSync('(orientation: landscape)');
}
步骤3:绑定回调函数,监听状态变化

给监听句柄绑定 on('change') 回调,当媒体特征发生变化时,会自动触发回调,通过 mediaQueryResult.matches 判断是否匹配查询条件。

javascript 复制代码
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 生命周期中解绑已注册的回调函数,否则会造成内存泄漏。

javascript 复制代码
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)
核心媒体特征

宽高类特征支持 vppx 单位,无单位时默认使用 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)

javascript 复制代码
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)

javascript 复制代码
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();
  }
}

测试结果

五、内容总结

  1. 核心作用:媒体查询是鸿蒙响应式布局的核心,实现「一套代码,多设备适配」。
  2. 标准流程:导入模块 → aboutToAppear中创建监听 → 绑定change回调 → aboutToDisappear中解绑回调。
  3. 语法规则:查询条件由「媒体类型 + 逻辑操作符 + 媒体特征」组成,宽高优先使用vp单位。
  4. 工具类封装:单例模式统一管理监听,一行代码实现状态监听,彻底避免内存泄漏。
  5. 综合测试:在一个页面中同时测试横竖屏、深浅色、多设备适配三大高频场景。

六、代码仓库

七、下节预告

下一节我们将正式学习 鸿蒙响应式布局核心:栅格布局 (GridRow/GridCol),彻底搞定多设备布局的标准化方案:

  • 掌握 GridRow 栅格容器的断点规则、总列数配置、排列方向与间距设置
  • 吃透 GridCol 栅格子组件的 span(占用列数)、offset(偏移列数)、order(排序)三大核心属性
  • 实现 手机/平板/折叠屏 多设备自适应布局,一套代码适配全尺寸设备
  • 理解 栅格组件的嵌套使用,完成复杂页面的响应式布局
相关推荐
性感博主在线瞎搞2 小时前
【鸿蒙开发】OpenHarmony与HarmonyOS调用C/C++教程
华为·harmonyos·鸿蒙·鸿蒙系统·openharmony
baivfhpwxf20232 小时前
DataGrid 中增加选择列 功能实现
ui·wpf
SAP小崔说事儿3 小时前
SAP B1 批量应用用户界面配置模板
java·前端·ui·sap·b1·无锡sap
大雷神3 小时前
HarmonyOS APP<玩转React>开源教程二十三:面试题库功能
harmonyos
程序猿追3 小时前
HarmonyOS 5.0 自定义组件与状态管理实战:用 RelationalStore 构建可复用的任务看板
华为·harmonyos
程序猿追4 小时前
HarmonyOS 6.0 实战:用 Native C++ NDK 开发一款本地计步器应用
c++·华为·harmonyos
程序猿追5 小时前
HarmonyOS 6.0 PC端蓝牙开发全攻略:从设备扫描到数据收发
华为·harmonyos
大雷神5 小时前
HarmonyOS APP<玩转React>开源教程二十四:错题本功能
react.js·面试·开源·harmonyos
UXbot5 小时前
AI App 设计生成工具哪个好?
ui·kotlin·软件构建·产品经理·ai编程·swift