《HarmonyOS技术精讲-窗口管理》第六篇:避让区域(AvoidArea)详解
一、开篇:这个 API 很容易被误用
HarmonyOS 开发里,窗口避让区域(AvoidArea)是一个经常被提及但实现效果参差不齐的能力。很多人第一次接触时,以为只是单纯的“把内容往下挪一点”,结果发现真机上的导航栏、状态栏、挖孔区域,经常把按钮或重要信息挡住。
官方文档描述了on('avoidAreaChange')和getAvoidArea这两个方法,但实际开发中,单纯调用它们并不够——避让区域的变化时机、类型区分、与页面布局的同步机制,才是真正容易出问题的地方。
这篇内容就集中解决一个问题:如何让窗口内容,自动避开系统UI(状态栏、导航栏、挖孔屏)的占用,并且在设备旋转、手势切换等场景下,布局能实时更新。
二、避让区域(AvoidArea)解决什么问题
在 HarmonyOS 手机上,系统UI会占用一部分屏幕空间:
- 状态栏(显示时间、电量、信号)
- 导航栏(三键或手势条区域)
- 挖孔/刘海区域(摄像头、传感器)
如果应用直接绘制这些区域,就会出现内容被遮挡的问题。早期一些应用通过硬编码固定边距来适配,但这种方法在面对不同设备(平板、折叠屏、挖孔位置不同)、不同导航方式(手势 vs 三键)时,非常脆弱。
避让区域机制,则是系统主动告诉你:“哪些位置被占了,你的内容应该避开这些区域”。它通过AvoidAreaType区分不同类型的系统元素:
| 类型 | 说明 | 场景 |
|---|---|---|
| TYPE_SYSTEM | 系统UI,如状态栏、导航栏 | 顶部状态栏 + 底部导航栏 |
| TYPE_CUTOUT | 屏幕挖孔区域 | 打孔屏、刘海屏 |
| TYPE_SYSTEM_GESTURE | 系统手势区域 | 手势条区域 |
| TYPE_KEYBOARD | 软键盘区域 | 键盘弹起 |
核心思路:不直接硬编码边距,监听避让区域变化,用事件驱动去更新布局边距。
三、环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上 HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上 目标设备:手机(支持手势、三键、挖孔屏)四、核心实现:自动避让的示例页面
功能目标:
- 页面内容自动避开状态栏和导航栏。
- 当导航方式从“手势”切换为“三键”时,底部边距自动调整。
- 当设备横竖屏切换时,避让区域重新计算。
4.1 获取窗口实例并注册避让区域监听
创建一个WindowManagerService.ets,专门管理窗口相关的操作:
// services/WindowManagerService.etsimport{window}from'@kit.ArkUI';exportclassWindowManagerService{privatestaticinstance:WindowManagerService;privatemainWindow:window.Window|undefined;privateavoidAreaCallBack:((area:window.AvoidArea,type:window.AvoidAreaType)=>void)|undefined;privateconstructor(){// 单例模式}publicstaticgetInstance():WindowManagerService{if(!WindowManagerService.instance){WindowManagerService.instance=newWindowManagerService();}returnWindowManagerService.instance;}publicasyncinit(win:window.Window){this.mainWindow=win;// 注册避让区域变化监听this.mainWindow.on('avoidAreaChange',(data:window.AvoidAreaEvent)=>{console.info(`AvoidAreaChange, type:${data.type}`);constarea=win.getWindowAvoidArea(data.type);if(this.avoidAreaCallBack){this.avoidAreaCallBack(area,data.type);}});}publicgetAvoidArea(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}};}returnthis.mainWindow.getWindowAvoidArea(type);}publiconAvoidAreaChange(callback:(area:window.AvoidArea,type:window.AvoidAreaType)=>void){this.avoidAreaCallBack=callback;}publicdestroy(){if(this.mainWindow){this.mainWindow.off('avoidAreaChange');}this.avoidAreaCallBack=undefined;}}说明:
- 封装在一个 Service 类里,避免在页面组件里直接引用
window实例,方便管理和测试。 - 监听
avoidAreaChange事件,每次变化时主动调用回调,通知页面更新。 getWindowAvoidArea方法是同步的,可以直接返回当前避让区域。- 注意:在页面销毁时,必须调用
destroy()去掉监听,否则组件回收后回调还有引用,会导致内存泄漏。
4.2 页面组件使用避让区域更新布局
创建pages/AvoidAreaDemo.ets:
// pages/AvoidAreaDemo.etsimport{window}from'@kit.ArkUI';import{WindowManagerService}from'../services/WindowManagerService';@Entry@Componentstruct AvoidAreaDemo{// 分别存储顶部和底部边距@StatetopInset:number=0;@StatebottomInset:number=0;@StateleftInset:number=0;@StaterightInset:number=0;privatewms:WindowManagerService=WindowManagerService.getInstance();aboutToAppear():void{// 获取当前窗口实例constcontext=getContext(this)asUIAbilityContext;// 注意:获取窗口实例需要从 UIAbility 中的 context 拿到// 这里为了演示,假设外部已经初始化了窗口实例// 实际项目中,建议在 UIAbility 的 onWindowStageCreate 中初始化constwin=window.getLastWindow(context);// 需要传入 contextif(win){this.wms.init(win);// 注册回调this.wms.onAvoidAreaChange((area,type)=>{this.updateInsets(type,area);});// 主动获取一次,初始化边距constsystemArea=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();}}这段代码做了什么:
- 在
aboutToAppear中获取窗口实例,初始化 WindowManagerService。 - 注册避让区域变化回调,当系统UI变化时更新
@State变量。 - 在
build方法中,通过@State变量动态控制顶部、底部、左右边距。 - 页面销毁时,清除监听,避免泄漏。
为什么这样写:
- 使用
@State驱动 UI,ArkUI 会自动重新渲染。 - 把监听逻辑从 UI 组件抽离到 Service 层,当有多个页面需要避让时,可以复用。
- 主动调用一次
getAvoidArea初始化,避免首次加载时没有任何边距信息。
4.3 在 UIAbility 中初始化
// entryability/EntryAbility.etsimport{UIAbility,window}from'@kit.ArkUI';import{WindowManagerService}from'../services/WindowManagerService';exportdefaultclassEntryAbilityextendsUIAbility{onWindowStageCreate(windowStage:window.WindowStage):void{// 获取主窗口实例constmainWindow=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')事件,在窗口尺寸变化时主动重新获取一次避让区域。
// 在 init 方法中补充this.mainWindow.on('windowSizeChange',()=>{constarea=this.mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);if(this.avoidAreaCallBack){this.avoidAreaCallBack(area,window.AvoidAreaType.TYPE_SYSTEM);}});六、最佳实践
不要在 build() 中频繁调用
getWindowAvoidArea。ArkUI 的 build 方法会被多次调用,直接在里面获取避让区域会导致性能浪费。应该通过@State绑定一次,并在回调中更新。避让区域的类型要区分使用。普通应用只需要监听
TYPE_SYSTEM和TYPE_SYSTEM_GESTURE即可。TYPE_CUTOUT只在挖孔屏设备上有值,且值可能为 0。如果你的应用是阅读器或全屏播放器,建议额外关心TYPE_CUTOUT。页面销毁后必须清理监听。如果窗口实例还在,但页面组件已经销毁,回调里的 UI 操作可能会报错。要么在
aboutToDisappear调用off去掉监听,要么在回调里加一个标志位判断页面是否已销毁。
七、Demo 入口
// pages/Index.etsimport{WindowManagerService}from'../services/WindowManagerService';@Entry@Componentstruct Index{build(){Column(){// 首页入口,启动后自动跳转避让区域示例NavigateTo({url:'pages/AvoidAreaDemo'})}.width('100%').height('100%')}}示例代码项目地址:项目地址
八、FAQ
Q1:为什么真机上避让区域正常,但模拟器上一直返回 0?
A:模拟器不支持屏幕方向切换和手势/三键导航切换,避让区域数据在模拟器上可能是固定的,甚至部分属性为 0。避让区域相关逻辑,务必在真机上完整验证。
Q2:页面返回后,底部边距突然消失了,为什么?
A:检查页面aboutToDisappear中是否调用了destroy()或off('avoidAreaChange')。如果页面只是被覆盖(比如打开了一个半透明弹窗),页面实例未被销毁,但监听被误删了。建议在onPageHide和onPageShow中重新注册和恢复监听。
Q3:我在全屏视频播放页面里,为什么设置setWindowLayoutFullScreen(true)后,避让区域依然存在?
A:全屏模式下,状态栏和导航栏会隐藏或变为半透明,但避让区域仍然会返回一个较小的值(比如状态栏高度变为 0,但导航区域可能保留 24dp 左右的手势条)。如果你希望内容完全覆盖所有区域,可以忽略避让区域,但同时要处理好交互穿透的问题,否则用户可能在状态栏区域触发手势。建议全屏模式下结合getWindowAvoidRectAvoidArea的结果来判断是否需要忽略顶部区域。
