HarmonyOS 断点监听排查图

做 HarmonyOS 一多适配时,很多问题不是出在布局组件本身,而是出在“谁来判断当前窗口属于 sm、md 还是 lg”。

如果每个页面都自己写一套宽度判断,短期能跑,后面会很难收:有的页面按 600vp 切,有的页面按 720vp 切;有的页面切到大屏以后丢了选中项;有的页面返回几次以后,窗口变化一次,回调却执行三四遍。

我更推荐把断点监听收成一个独立的小服务:页面只订阅当前断点,不直接关心媒体查询怎么创建、什么时候解绑、不同断点对应什么导航结构。

先把边界讲清楚

官方文档里有几个点要放在一起看:

能力 在这类问题里负责什么
媒体查询 监听窗口、方向、尺寸这些特征是否命中条件
响应式布局 让页面结构根据 sm、md、lg 这类断点变化
UIContext 在当前页面上下文里拿 UI 相关能力,避免多实例、多窗口下拿错环境
页面生命周期 页面出现时订阅,页面消失时解绑

这几个词单独看都不难,真正容易出问题的是它们混在一起以后:断点监听写在哪里、监听回调改哪些状态、页面销毁时谁负责清理。

问题一:窗口变宽了,页面结构变了,状态也跟着丢了

先看一个常见页面:左边是菜谱列表,右边是菜谱详情。手机上只能一页一页跳;平板或 2in1 宽度够了,就希望变成左列表、右详情的主从结构。

错误写法通常是这样:

@State width: number = 360
@State selectedRecipeId: string = ''
@State keyword: string = ''

build() {
  if (this.width >= 840) {
    Row() {
      RecipeList({ keyword: this.keyword })
      RecipeDetail({ id: this.selectedRecipeId })
    }
  } else {
    RecipeList({ keyword: this.keyword })
  }
}

这段代码的问题不是 if 不能用,而是状态和布局判断绑得太紧。窗口一变化,页面结构重新分支,如果选中项、搜索词、滚动位置也跟着某个子组件重建,就容易出现这些现象:

  • 手机切到平板后,详情区是空的;
  • 搜索词还在输入框里,但列表按默认数据重新渲染;
  • 返回上一页以后,再切窗口大小,列表滚动位置回到顶部;
  • 不同页面各自维护断点,切换时页面表现不一致。

更稳的做法是把断点当成“页面外部环境”,把搜索词、选中项、滚动位置当成“页面自己的业务状态”。窗口变化只改变布局结构,不重置页面状态。

type BreakpointName = 'sm' | 'md' | 'lg'

interface BreakpointRule {
  name: BreakpointName
  min: number
  max: number
  navigation: 'bottom-tabs' | 'compact-rail' | 'side-rail'
  columns: number
  detail: 'push-page' | 'sheet-or-push' | 'master-detail'
}

const BREAKPOINTS: 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' }
]

function resolveBreakpoint(widthVp: number): BreakpointRule {
  const hit = BREAKPOINTS.find(item => widthVp >= item.min && widthVp <= item.max)
  if (!hit) {
    throw new Error(`No breakpoint for ${widthVp}`)
  }
  return hit
}

页面拿到的不是一堆零散宽度,而是一个明确的结构判断:

interface RecipePageState {
  breakpoint: BreakpointName
  selectedRecipeId: string
  searchKeyword: string
  scrollY: number
}

function buildRecipeShell(state: RecipePageState): string {
  const bp = 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 管住订阅和解绑。下面是一个简化版写法,重点看职责边界:

class BreakpointStore {
  private active: BreakpointRule = resolveBreakpoint(360)
  private listeners: Set<(bp: BreakpointRule) => void> = new Set()

  subscribe(listener: (bp: BreakpointRule) => void): () => void {
    this.listeners.add(listener)
    listener(this.active)
    return () => {
      this.listeners.delete(listener)
    }
  }

  updateWidth(widthVp: number): void {
    const next = resolveBreakpoint(widthVp)
    if (next.name === this.active.name) {
      return
    }

    this.active = next
    this.listeners.forEach(listener => listener(next))
  }
}

页面只做两件事:出现时订阅,消失时解绑。

@Entry
@Component
struct RecipeShellPage {
  private breakpointStore: BreakpointStore = new BreakpointStore()
  private unsubscribe?: () => void

  @State breakpoint: BreakpointName = 'sm'
  @State selectedRecipeId: string = 'mapo-tofu'
  @State searchKeyword: string = '豆腐'
  @State scrollY: number = 180

  aboutToAppear(): 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'

class BreakpointQueryController {
  private listener?: mediaquery.MediaQueryListener
  private unlisten?: () => void

  start(uiContext: UIContext, onChange: (width: number) => void): void {
    this.stop()

    const mq = 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 首页、列表、详情、编辑页都要适配一多 需要先定好断点模型和页面状态边界

我会优先选第三种。它多写了一层,但换来的是统一断点、统一解绑、统一日志,也更容易写测试。

本地验证结果

我把断点判断抽成了一个独立脚本,跑了两个案例:

node article109-breakpoint-demo.mjs

验证点有两个:

  1. 窗口从 360vp 切到 720vp,再切到 1024vp,页面断点变成 lg,但 selectedRecipeIdsearchKeywordscrollY 不被重置。
  2. 页面连续调用两次 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 的样式。样式只是最后一层,真正影响稳定性的,是断点来源、状态归属和生命周期清理。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐