《HarmonyOS技术精讲-窗口管理》第六篇:避让区域(AvoidArea)详解

一、开篇:这个 API 很容易被误用

HarmonyOS 开发里,窗口避让区域(AvoidArea)是一个经常被提及但实现效果参差不齐的能力。很多人第一次接触时,以为只是单纯的"把内容往下挪一点",结果发现真机上的导航栏、状态栏、挖孔区域,经常把按钮或重要信息挡住。

官方文档描述了 on('avoidAreaChange')getAvoidArea 这两个方法,但实际开发中,单纯调用它们并不够------避让区域的变化时机、类型区分、与页面布局的同步机制,才是真正容易出问题的地方。

这篇内容就集中解决一个问题:如何让窗口内容,自动避开系统UI(状态栏、导航栏、挖孔屏)的占用,并且在设备旋转、手势切换等场景下,布局能实时更新。

二、避让区域(AvoidArea)解决什么问题

在 HarmonyOS 手机上,系统UI会占用一部分屏幕空间:

  • 状态栏(显示时间、电量、信号)
  • 导航栏(三键或手势条区域)
  • 挖孔/刘海区域(摄像头、传感器)

如果应用直接绘制这些区域,就会出现内容被遮挡的问题。早期一些应用通过硬编码固定边距来适配,但这种方法在面对不同设备(平板、折叠屏、挖孔位置不同)、不同导航方式(手势 vs 三键)时,非常脆弱。

避让区域机制,则是系统主动告诉你:"哪些位置被占了,你的内容应该避开这些区域"。它通过 AvoidAreaType 区分不同类型的系统元素:

类型 说明 场景
TYPE_SYSTEM 系统UI,如状态栏、导航栏 顶部状态栏 + 底部导航栏
TYPE_CUTOUT 屏幕挖孔区域 打孔屏、刘海屏
TYPE_SYSTEM_GESTURE 系统手势区域 手势条区域
TYPE_KEYBOARD 软键盘区域 键盘弹起

核心思路:不直接硬编码边距,监听避让区域变化,用事件驱动去更新布局边距。

三、环境说明

text 复制代码
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机(支持手势、三键、挖孔屏)

四、核心实现:自动避让的示例页面

功能目标:

  1. 页面内容自动避开状态栏和导航栏。
  2. 当导航方式从"手势"切换为"三键"时,底部边距自动调整。
  3. 当设备横竖屏切换时,避让区域重新计算。

4.1 获取窗口实例并注册避让区域监听

创建一个 WindowManagerService.ets,专门管理窗口相关的操作:

typescript 复制代码
// services/WindowManagerService.ets
import { window } from '@kit.ArkUI';

export class WindowManagerService {
  private static instance: WindowManagerService;
  private mainWindow: window.Window | undefined;
  private avoidAreaCallBack: ((area: window.AvoidArea, type: window.AvoidAreaType) => void) | undefined;

  private constructor() {
    // 单例模式
  }

  public static getInstance(): WindowManagerService {
    if (!WindowManagerService.instance) {
      WindowManagerService.instance = new WindowManagerService();
    }
    return WindowManagerService.instance;
  }

  public async init(win: window.Window) {
    this.mainWindow = win;
    // 注册避让区域变化监听
    this.mainWindow.on('avoidAreaChange', (data: window.AvoidAreaEvent) => {
      console.info(`AvoidAreaChange, type: ${data.type}`);
      const area = win.getWindowAvoidArea(data.type);
      if (this.avoidAreaCallBack) {
        this.avoidAreaCallBack(area, data.type);
      }
    });
  }

  public getAvoidArea(type: window.AvoidAreaType): window.AvoidArea {
    if (!this.mainWindow) {
      return { topRect: { left: 0, top: 0, width: 0, height: 0 },
               bottomRect: { left: 0, top: 0, width: 0, height: 0 } };
    }
    return this.mainWindow.getWindowAvoidArea(type);
  }

  public onAvoidAreaChange(callback: (area: window.AvoidArea, type: window.AvoidAreaType) => void) {
    this.avoidAreaCallBack = callback;
  }

  public destroy() {
    if (this.mainWindow) {
      this.mainWindow.off('avoidAreaChange');
    }
    this.avoidAreaCallBack = undefined;
  }
}

说明:

  • 封装在一个 Service 类里,避免在页面组件里直接引用 window 实例,方便管理和测试。
  • 监听 avoidAreaChange 事件,每次变化时主动调用回调,通知页面更新。
  • getWindowAvoidArea 方法是同步的,可以直接返回当前避让区域。
  • 注意: 在页面销毁时,必须调用 destroy() 去掉监听,否则组件回收后回调还有引用,会导致内存泄漏。

4.2 页面组件使用避让区域更新布局

创建 pages/AvoidAreaDemo.ets

typescript 复制代码
// pages/AvoidAreaDemo.ets
import { window } from '@kit.ArkUI';
import { WindowManagerService } from '../services/WindowManagerService';

@Entry
@Component
struct AvoidAreaDemo {
  // 分别存储顶部和底部边距
  @State topInset: number = 0;
  @State bottomInset: number = 0;
  @State leftInset: number = 0;
  @State rightInset: number = 0;

  private wms: WindowManagerService = WindowManagerService.getInstance();

  aboutToAppear(): void {
    // 获取当前窗口实例
    const context = getContext(this) as UIAbilityContext;
    // 注意:获取窗口实例需要从 UIAbility 中的 context 拿到
    // 这里为了演示,假设外部已经初始化了窗口实例
    // 实际项目中,建议在 UIAbility 的 onWindowStageCreate 中初始化
    const win = window.getLastWindow(context); // 需要传入 context
    if (win) {
      this.wms.init(win);
      // 注册回调
      this.wms.onAvoidAreaChange((area, type) => {
        this.updateInsets(type, area);
      });

      // 主动获取一次,初始化边距
      const systemArea = this.wms.getAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
      this.updateInsets(window.AvoidAreaType.TYPE_SYSTEM, systemArea);
    }
  }

  updateInsets(type: window.AvoidAreaType, area: window.AvoidArea): void {
    if (type === window.AvoidAreaType.TYPE_SYSTEM || type === window.AvoidAreaType.TYPE_SYSTEM_GESTURE) {
      // 顶部边距:状态栏区域
      this.topInset = area.topRect.height;
      // 底部边距:导航栏/手势条区域
      this.bottomInset = area.bottomRect.height;
      // 左右边距:处理折叠屏等场景
      this.leftInset = area.leftRect.width;
      this.rightInset = area.rightRect.width;
    }
  }

  build() {
    Column() {
      // 顶部留白区,模拟状态栏
      Column()
        .width('100%')
        .height(this.topInset)
        .backgroundColor('#33000000')

      // 主内容区域
      Column() {
        Text('这是主内容区域')
          .fontSize(24)
        Text(`顶部边距:${this.topInset}px`)
        Text(`底部边距:${this.bottomInset}px`)
        Text(`左边距:${this.leftInset}px`)
        Text(`右边距:${this.rightInset}px`)
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)

      // 底部留白区,模拟导航栏
      Column()
        .width('100%')
        .height(this.bottomInset)
        .backgroundColor('#33000000')
    }
    .width('100%')
    .height('100%')
    .padding({ left: this.leftInset, right: this.rightInset })
    .backgroundColor(Color.White)
  }

  aboutToDisappear(): void {
    this.wms.destroy();
  }
}

这段代码做了什么:

  1. aboutToAppear 中获取窗口实例,初始化 WindowManagerService。
  2. 注册避让区域变化回调,当系统UI变化时更新 @State 变量。
  3. build 方法中,通过 @State 变量动态控制顶部、底部、左右边距。
  4. 页面销毁时,清除监听,避免泄漏。

为什么这样写:

  • 使用 @State 驱动 UI,ArkUI 会自动重新渲染。
  • 把监听逻辑从 UI 组件抽离到 Service 层,当有多个页面需要避让时,可以复用。
  • 主动调用一次 getAvoidArea 初始化,避免首次加载时没有任何边距信息。

4.3 在 UIAbility 中初始化

typescript 复制代码
// entryability/EntryAbility.ets
import { UIAbility, window } from '@kit.ArkUI';
import { WindowManagerService } from '../services/WindowManagerService';

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage): void {
    // 获取主窗口实例
    const mainWindow = windowStage.getMainWindowSync();
    // 初始化 WindowManagerService,传入窗口实例
    WindowManagerService.getInstance().init(mainWindow);
    // 加载页面
    windowStage.loadContent('pages/AvoidAreaDemo', (err) => {
      if (err) {
        console.error(`Failed to load content: ${err.code}`);
      }
    });
  }
}

说明:

  • onWindowStageCreate 中尽早获取窗口实例并完成注册。
  • 这样可以确保页面创建前,窗口已经能监听到避让区域变化。

五、踩坑记录

坑1:getWindowAvoidArea 在页面未渲染时返回全为 0

现象:

aboutToAppear 中直接调用 getWindowAvoidArea,返回的 area.topRect.height 为 0。

原因:

getWindowAvoidArea 是同步方法,但窗口的避让区域信息需要等到页面渲染完成后才完整。在 aboutToAppear 阶段,页面还在构建中,系统尚未完成布局,因此返回的避让区域信息不完整。

解法:

不要在 aboutToAppear 中获取第一帧数据。改为使用 postTask 等延迟执行,或监听 on('avoidAreaChange') 事件,系统会在首次渲染后触发一次。

推荐做法:在 aboutToAppear 中只注册监听,首次数据由 on('avoidAreaChange') 的回调提供。

坑2:on('avoidAreaChange') 在 API 12 和 API 11 中的行为不同

现象:

在 API 11 的设备上,当用户从手势导航切换到三键导航时,avoidAreaChange 事件不会触发,导致底部边距没有更新。但在 API 12 的设备上,切换时能正常触发。

原因:

这是原生的 API 行为差异。API 11 下,avoidAreaChange 只会在窗口创建、销毁、旋转等场景下触发,而导航方式的切换属于系统UI变更,但它不在这个事件的通知列表中。

解法:

可以在 API 11 设备上,结合 window.on('windowSizeChange') 事件,在窗口尺寸变化时主动重新获取一次避让区域。

typescript 复制代码
// 在 init 方法中补充
this.mainWindow.on('windowSizeChange', () => {
  const area = this.mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
  if (this.avoidAreaCallBack) {
    this.avoidAreaCallBack(area, window.AvoidAreaType.TYPE_SYSTEM);
  }
});

六、最佳实践

  1. 不要在 build() 中频繁调用 getWindowAvoidArea 。ArkUI 的 build 方法会被多次调用,直接在里面获取避让区域会导致性能浪费。应该通过 @State 绑定一次,并在回调中更新。

  2. 避让区域的类型要区分使用。 普通应用只需要监听 TYPE_SYSTEMTYPE_SYSTEM_GESTURE 即可。TYPE_CUTOUT 只在挖孔屏设备上有值,且值可能为 0。如果你的应用是阅读器或全屏播放器,建议额外关心 TYPE_CUTOUT

  3. 页面销毁后必须清理监听。 如果窗口实例还在,但页面组件已经销毁,回调里的 UI 操作可能会报错。要么在 aboutToDisappear 调用 off 去掉监听,要么在回调里加一个标志位判断页面是否已销毁。

七、Demo 入口

typescript 复制代码
// pages/Index.ets
import { WindowManagerService } from '../services/WindowManagerService';

@Entry
@Component
struct Index {
  build() {
    Column() {
      // 首页入口,启动后自动跳转避让区域示例
      NavigateTo({ url: 'pages/AvoidAreaDemo' })
    }
    .width('100%')
    .height('100%')
  }
}

示例代码项目地址:项目地址

八、FAQ

Q1:为什么真机上避让区域正常,但模拟器上一直返回 0?

A:模拟器不支持屏幕方向切换和手势/三键导航切换,避让区域数据在模拟器上可能是固定的,甚至部分属性为 0。避让区域相关逻辑,务必在真机上完整验证。

Q2:页面返回后,底部边距突然消失了,为什么?

A:检查页面 aboutToDisappear 中是否调用了 destroy()off('avoidAreaChange')。如果页面只是被覆盖(比如打开了一个半透明弹窗),页面实例未被销毁,但监听被误删了。建议在 onPageHideonPageShow 中重新注册和恢复监听。

Q3:我在全屏视频播放页面里,为什么设置 setWindowLayoutFullScreen(true) 后,避让区域依然存在?

A:全屏模式下,状态栏和导航栏会隐藏或变为半透明,但避让区域仍然会返回一个较小的值(比如状态栏高度变为 0,但导航区域可能保留 24dp 左右的手势条)。如果你希望内容完全覆盖所有区域,可以忽略避让区域,但同时要处理好交互穿透的问题,否则用户可能在状态栏区域触发手势。建议全屏模式下结合 getWindowAvoidRectAvoidArea 的结果来判断是否需要忽略顶部区域。