HarmonyOS 断点监听怎么写稳:UIContext getMediaQuery、生命周期解绑和主从布局切换
做 HarmonyOS 一多适配时,很多问题不是出在布局组件本身,而是出在“谁来判断当前窗口属于 sm、md 还是 lg”。
如果每个页面都自己写一套宽度判断,短期能跑,后面会很难收:有的页面按600vp切,有的页面按720vp切;有的页面切到大屏以后丢了选中项;有的页面返回几次以后,窗口变化一次,回调却执行三四遍。
我更推荐把断点监听收成一个独立的小服务:页面只订阅当前断点,不直接关心媒体查询怎么创建、什么时候解绑、不同断点对应什么导航结构。
先把边界讲清楚
官方文档里有几个点要放在一起看:
| 能力 | 在这类问题里负责什么 |
|---|---|
| 媒体查询 | 监听窗口、方向、尺寸这些特征是否命中条件 |
| 响应式布局 | 让页面结构根据 sm、md、lg 这类断点变化 |
| UIContext | 在当前页面上下文里拿 UI 相关能力,避免多实例、多窗口下拿错环境 |
| 页面生命周期 | 页面出现时订阅,页面消失时解绑 |
这几个词单独看都不难,真正容易出问题的是它们混在一起以后:断点监听写在哪里、监听回调改哪些状态、页面销毁时谁负责清理。
问题一:窗口变宽了,页面结构变了,状态也跟着丢了
先看一个常见页面:左边是菜谱列表,右边是菜谱详情。手机上只能一页一页跳;平板或 2in1 宽度够了,就希望变成左列表、右详情的主从结构。
错误写法通常是这样:
@Statewidth:number=360@StateselectedRecipeId:string=''@Statekeyword:string=''build(){if(this.width>=840){Row(){RecipeList({keyword:this.keyword})RecipeDetail({id:this.selectedRecipeId})}}else{RecipeList({keyword:this.keyword})}}这段代码的问题不是if不能用,而是状态和布局判断绑得太紧。窗口一变化,页面结构重新分支,如果选中项、搜索词、滚动位置也跟着某个子组件重建,就容易出现这些现象:
- 手机切到平板后,详情区是空的;
- 搜索词还在输入框里,但列表按默认数据重新渲染;
- 返回上一页以后,再切窗口大小,列表滚动位置回到顶部;
- 不同页面各自维护断点,切换时页面表现不一致。
更稳的做法是把断点当成“页面外部环境”,把搜索词、选中项、滚动位置当成“页面自己的业务状态”。窗口变化只改变布局结构,不重置页面状态。
typeBreakpointName='sm'|'md'|'lg'interfaceBreakpointRule{name:BreakpointName min:numbermax:numbernavigation:'bottom-tabs'|'compact-rail'|'side-rail'columns:numberdetail:'push-page'|'sheet-or-push'|'master-detail'}constBREAKPOINTS:BreakpointRule[]=[{name:'sm',min:0,max:599,navigation:'bottom-tabs',columns:1,detail:'push-page'},{name:'md',min:600,max:839,navigation:'compact-rail',columns:2,detail:'sheet-or-push'},{name:'lg',min:840,max:Number.MAX_SAFE_INTEGER,navigation:'side-rail',columns:3,detail:'master-detail'}]functionresolveBreakpoint(widthVp:number):BreakpointRule{consthit=BREAKPOINTS.find(item=>widthVp>=item.min&&widthVp<=item.max)if(!hit){thrownewError(`No breakpoint for${widthVp}`)}returnhit}页面拿到的不是一堆零散宽度,而是一个明确的结构判断:
interfaceRecipePageState{breakpoint:BreakpointName selectedRecipeId:stringsearchKeyword:stringscrollY:number}functionbuildRecipeShell(state:RecipePageState):string{constbp=state.breakpoint==='sm'?BREAKPOINTS[0]:state.breakpoint==='md'?BREAKPOINTS[1]:BREAKPOINTS[2]if(bp.detail==='master-detail'){return`SideRail +${bp.columns}column list + detail(${state.selectedRecipeId})`}if(bp.detail==='sheet-or-push'){return`CompactRail +${bp.columns}column list + optional detail sheet`}return`BottomTabs + single list + push detail page`}这样写以后,断点变化只会影响navigation / columns / detail,不会顺手把selectedRecipeId / searchKeyword / scrollY清掉。
问题二:返回几次以后,同一个监听触发多遍
第二个坑更隐蔽。页面第一次进入时注册媒体查询监听,退出时没有解绑。下一次再进入,又注册一次。来回几次以后,窗口只变了一次,回调却执行多次。
这种问题在页面里表现得很乱:
- 日志里同一条断点变化打印多遍;
- 列表刷新多次,看起来像卡顿;
- 大屏主从布局来回闪;
- 一个页面已经离开了,还在收到窗口变化回调。
断点监听需要生命周期兜住。实际 ArkUI 页面里可以在aboutToAppear/aboutToDisappear管住订阅和解绑。下面是一个简化版写法,重点看职责边界:
classBreakpointStore{privateactive:BreakpointRule=resolveBreakpoint(360)privatelisteners:Set<(bp:BreakpointRule)=>void>=newSet()subscribe(listener:(bp:BreakpointRule)=>void):()=>void{this.listeners.add(listener)listener(this.active)return()=>{this.listeners.delete(listener)}}updateWidth(widthVp:number):void{constnext=resolveBreakpoint(widthVp)if(next.name===this.active.name){return}this.active=nextthis.listeners.forEach(listener=>listener(next))}}页面只做两件事:出现时订阅,消失时解绑。
@Entry@Componentstruct RecipeShellPage{privatebreakpointStore:BreakpointStore=newBreakpointStore()privateunsubscribe?:()=>void@Statebreakpoint:BreakpointName='sm'@StateselectedRecipeId:string='mapo-tofu'@StatesearchKeyword:string='豆腐'@StatescrollY:number=180aboutToAppear():void{if(this.unsubscribe){return}this.unsubscribe=this.breakpointStore.subscribe((bp)=>{this.breakpoint=bp.name})}aboutToDisappear():void{if(this.unsubscribe){this.unsubscribe()this.unsubscribe=undefined}}build(){Column(){Text(`当前断点:${this.breakpoint}`)Text(`当前选中:${this.selectedRecipeId}`)Text(`搜索词:${this.searchKeyword}`)}}}实际接入媒体查询时,可以把mediaquery能力封装在BreakpointStore内部。页面不要直接到处创建监听器,尤其不要在多个子组件里重复监听同一件事。
用 UIContext 拿媒体查询,别让页面拿错环境
Stage 模型下,应用可能存在多个 UI 实例或窗口。UI 相关能力更适合从当前页面的 UIContext 里拿,而不是在任意文件里写一个全局调用。
思路可以这样落:
import{mediaquery}from'@kit.ArkUI'classBreakpointQueryController{privatelistener?:mediaquery.MediaQueryListenerprivateunlisten?:()=>voidstart(uiContext:UIContext,onChange:(width:number)=>void):void{this.stop()constmq=uiContext.getMediaQuery()this.listener=mq.matchMediaSync('(600vp <= width)')this.listener.on('change',(event)=>{// 这里只负责把环境变化告诉上层,别顺手清页面状态onChange(event.matches?600:360)})}stop():void{if(this.listener){this.listener.off('change')this.listener=undefined}this.unlisten?.()this.unlisten=undefined}}这里的示例没有把所有断点条件都写满,是为了看清楚关键点:媒体查询监听属于当前 UI 上下文;页面生命周期要能停掉监听;监听回调只更新断点,不负责重置页面数据。
两种方案怎么选
| 方案 | 适合场景 | 风险 |
|---|---|---|
| 页面内直接判断宽度 | 页面很小,只做一次样式微调 | 多页面标准不一致,状态容易跟布局重建绑死 |
| 每个组件自己监听媒体查询 | 组件完全独立、数量很少 | 监听器变多,退出页面后容易漏解绑 |
| 统一 BreakpointStore | 首页、列表、详情、编辑页都要适配一多 | 需要先定好断点模型和页面状态边界 |
我会优先选第三种。它多写了一层,但换来的是统一断点、统一解绑、统一日志,也更容易写测试。
本地验证结果
我把断点判断抽成了一个独立脚本,跑了两个案例:
nodearticle109-breakpoint-demo.mjs验证点有两个:
- 窗口从
360vp切到720vp,再切到1024vp,页面断点变成lg,但selectedRecipeId、searchKeyword、scrollY不被重置。 - 页面连续调用两次
appear(),监听器数量仍然只有一个;调用disappear()后,监听器数量回到 0。
输出结果类似这样:
{"ok":true,"results":[{"case":"layout-switch-preserves-page-state","state":{"breakpoint":"lg","navigation":"side-rail","columns":3,"detail":"master-detail","selectedRecipeId":"mapo-tofu","searchKeyword":"豆腐","scrollY":180}},{"case":"listener-lifecycle-cleanup","during":1,"after":0}]}这个验证不替代真机测试,但它能先把最容易漏掉的两个判断钉住:跨断点不要丢页面状态,页面退出不要留下监听器。
落到项目里怎么检查
以后写一多适配页面,我会先检查这几项:
- 断点规则是否统一放在一个地方;
- 页面状态是否和布局状态分开;
- 媒体查询是否从当前 UIContext 获取;
- 页面退出时是否解绑监听;
- sm、md、lg 三档是否分别跑过;
- 大屏主从布局切换时,选中项、搜索词、滚动位置是否保留;
- 日志里一次窗口变化是否只触发一次断点更新。
如果这几项没过,先不要急着调 Row、Column、Grid 的样式。样式只是最后一层,真正影响稳定性的,是断点来源、状态归属和生命周期清理。