一个应用要同时跑在手机、折叠屏和平板上,最偷懒的做法是把手机页面等比放大——列表一行占满 1000 多 vp,字还是那么大,空白大到能停车。用户一眼就能看出"这是个手机应用"。第二种做法是维护三套页面,写三个 if 分支各自渲染,结果一个需求改三处,改到第三处的时候第一处已经忘了。

这两种做法的问题出在同一个地方:把"多设备"当成了三份工作量。而一多开发(一次开发、多端部署)的思路是把设备差异收敛成一个状态变量——断点。整棵 UI 树只认断点,不认设备:窄屏一个样,宽屏一个样,至于这台设备是折叠屏展开、平板竖屏还是电脑上的自由窗口,UI 根本不关心。

下面我们拿一个列表来探索,因为"列表 + 详情"是双栏化收益最大的页面形态——邮箱、系统设置、笔记应用全是这个结构。它在窄屏上是最普通的压栈导航,在宽屏上天然适合左右分栏,两种形态的落差足够大,断点驱动的每个决策都能被清楚地看见、清楚地验证。反过来说,如果你的页面在窄屏和宽屏上长得几乎一样,只是留白变多了,那大概率不需要我们下面要做的导航架构改造,一个栅格就够。

下面我们把这个思路完整落地:它跑在手机上是单栏卡片列表、点卡片进详情;跑在折叠屏展开态和平板上自动变成左右双栏,左侧列表右侧详情,点卡片右侧原地刷新;来回折叠屏幕,页面状态不丢。

页面整体结构一句话就能说完:根节点挂 onAreaChange 负责宽度感知,里面包一个 Navigation;Navigation 的主页内容是"分类页签 + 栅格列表",详情页注册在它的路由表里;顶部再压一条状态条,把断点、宽度、导航模式这些"幕后状态"亮出来。后面每一节拆其中一层。
手机
11.png
折叠屏
12.png
平板
13.png

先把地基打对:断点到底是什么

HarmonyOS 的窗口宽度单位是 vp(逻辑像素),不是 px。一块 1080px 宽的屏幕,密度一般是 3,换算下来就是 360vp。断点体系用的全是 vp:sm(< 600vp)、md(600–840vp)、lg(> 840vp),这是社区通用的分档,手机竖屏落在 sm,折叠屏展开落在 md,平板落在 lg。用 vp 而不是 px 做分界,原因很直接:不同设备像素密度差一倍,同样的 1080px 在另一块屏上可能只有 320vp,用 px 分档等于每次换设备都要重新标定。

宽度有两个来源,单位不一样。display.getDefaultDisplaySync() 给的 widthpx,要除以 densityPixels(屏幕密度)才能得到 vp;而 onAreaChange 回调给的 Area.width,SDK 声明文件里明确标注单位就是 vp,可以直接和阈值比较。

三档断点对应的设备形态大致是:手机竖屏和折叠屏合拢后的外屏在 sm(一般 320–400vp);折叠屏展开在 md(700vp 上下);平板在 lg(横竖屏都超过 840vp)。但别把这些对应关系记死——断点描述的是窗口,不是设备。手机横过来可能就进了 md,平板开分屏后每半边可能只有 sm,电脑上应用窗口随便拖,三个档位都会路过。这也是为什么判断逻辑里从头到尾没出现设备名:设备名枚举不完,窗口宽度是唯一可靠的公共语言。还有一档更小的 xs(小于 320vp),留给穿戴这类小屏设备,本文不展开——穿戴的圆形屏幕和轻量 UI 是另一个话题,但断点思路完全通用。

第二个容易忽视的事:断点有两种参照系。栅格组件 GridRow 的断点既可以跟随窗口宽度(WindowSize,默认值),也可以跟随组件自身宽度(ComponentSize)。这两种参照不是二选一的替代关系,而是各管一层——后面会看到,一个页面里两种要同时用。

第三个事:Navigation 的 Auto 模式分栏阈值是 520vp,不是 600vpNavigationMode.Auto 看起来很省事——窄屏自动 Stack、宽屏自动 Split,不用自己管。但它的分界线在 SDK 声明文件里写的是 520vp,和 600/840 的断点体系对不齐。520–600vp 之间会出现一个"灰区":窗口已经够宽,Navigation 切成了双栏,但按 600 分档的其它逻辑还认为自己是窄屏——如果你还有按断点显隐的按钮、按断点换的列表列数,这些 UI 会和导航模式打架。所以下面的实现里导航模式不用 Auto,而是用窗口断点显式驱动,整棵 UI 树只有一个真相来源。

第一步:把窗口宽度变成可信的状态

一多页面的第一条链路是宽度感知:初始渲染时拿到当前宽度,之后窗口尺寸变化时持续跟踪。折叠屏开合、平板转屏、电脑上拖拽窗口边框,都会走到同一个回调——onAreaChange

有人会问:既然 onAreaChange 首次布局也会回调,初始值是不是可以不取、等回调就行?不行。首次回调发生在第一帧渲染之后,如果初始断点默认 sm,宽屏设备上第一帧会先按窄屏渲染,下一帧才纠正成双栏——用户会看到一次布局闪变。所以初始值必须在 aboutToAppear 里同步算好,让第一帧就是正确的形态。display.getDefaultDisplaySync() 是同步接口,取的 px 和密度:

aboutToAppear(): void {
  try {
    const dis: display.Display = display.getDefaultDisplaySync()
    this.density = dis.densityPixels > 0 ? dis.densityPixels : FALLBACK_DENSITY
    const wVp: number = pxToVp(dis.width, this.density)
    this.windowWidthVp = Math.round(wVp)
    this.breakpoint = widthToBreakpoint(wVp)
  } catch (e) {
    // 拿不到 display 时保持 sm 兜底,首次 onAreaChange 会纠正
  }
  this.syncWideDefault()
}

之后的所有变化都交给挂在页面根节点上的 onAreaChange。这里有个细节:首次回调不算"断点切换"onAreaChange 在首次布局完成时也会触发一次,如果不加区分,应用一启动"切换次数"就是 1。用一个私有标记把首次回调排除掉:

private onRootAreaChange(newArea: Area): void {
  // Area.width 的单位本来就是 vp,直接用;px→vp 换算只在 display 那条路上做
  const wVp: number = newArea.width as number
  this.windowWidthVp = Math.round(wVp)
  const bp: string = widthToBreakpoint(wVp)
  if (bp !== this.breakpoint) {
    this.breakpoint = bp
    if (this.widthInitialized) {
      this.bpSwitchCount++
    }
    this.syncWideDefault()
  }
  this.widthInitialized = true
}

宽度换算和分档这两个函数故意不写在页面里,而是放进纯数据层:

export function pxToVp(px: number, densityPixels: number): number {
  const density: number = densityPixels > 0 ? densityPixels : FALLBACK_DENSITY
  return px / density
}

export function widthToBreakpoint(widthVp: number): string {
  if (widthVp >= WIDTH_LG) {
    return 'lg'
  }
  if (widthVp >= WIDTH_MD) {
    return 'md'
  }
  return 'sm'
}

阈值、换算、派生判断这些逻辑放进纯函数,LocalUnit 测试里一个用例一行就能验证(widthToBreakpoint(599) === 'sm'widthToBreakpoint(600) === 'md'),页面文件里只剩接线和渲染。断点逻辑是一多的地基,地基必须能被自动测试钉死。

为了把断点状态"看"出来,我在页面顶部加了一条深色状态条:当前断点徽标(sm 蓝 / md 绿 / lg 橙)、窗口宽度的 vp 数值、当前导航模式、断点切换次数,右侧还有三个开关(自动 / 单栏 / 双栏),可以在任何设备上强制切换导航模式预览另一种形态。实现上就是一个普通 @Builder,所有值内联直读状态:

@Builder
StatusStrip() {
  Column({ space: 8 }) {
    Row({ space: 10 }) {
      Text(this.breakpoint)
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 10, right: 10, top: 3, bottom: 3 })
        .borderRadius(8)
        .backgroundColor(this.breakpointColor())

      Text(`${this.windowWidthVp}vp`)
        .fontSize(11)
        .fontColor('#C7CBDA')
      Text(this.navModeLabel())
        .fontSize(11)
        .fontColor('#C7CBDA')
      Blank()
      Text(`断点切换 ${this.bpSwitchCount} 次`)
        .fontSize(10)
        .fontColor('#8A90A6')
    }
    .width('100%')

    Row({ space: 6 }) {
      Blank()
      this.ModeChip('auto', '自动')
      this.ModeChip('stack', '单栏')
      this.ModeChip('split', '双栏')
    }
    .width('100%')
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 10, bottom: 8 })
  .backgroundColor('#22263A')
}

调试一多问题时这条价值极大——你一眼就能确认"布局变了,但断点没变"还是"断点变了但布局没跟上",这两类问题的排查方向完全不同。断点没变但布局变了,去查栅格参照系和容器宽度;断点变了但布局没跟上,去查派生链路是不是断在哪个按值传参的地方。没有这条状态条,你只能靠猜。切换计数字段还有第二个用途:第五节验证折叠屏连续性时,它就是那个"探针"。
21.gif

22.gif

23.gif

第二步:列表自适应——让栅格跟随容器而不是窗口

列表是最能体现"一套代码多端表现"的部分。同一个菜谱卡片列表:手机上一列太挤两列正好;折叠屏展开后左栏 380vp 左右,两列;平板左栏放宽到 480vp,可以三列。

这个需求用 GridRow / GridCol 栅格做,但要选对参照系。前面说过栅格断点有两种参照:WindowSize 跟窗口宽,ComponentSize 跟组件自身宽。双栏模式下列表被限制在左栏里,窗口可能是 1000vp,但列表实际只有 380vp——如果列表栅格还跟着窗口走,它会把 380vp 的容器硬按"宽屏"分档排三列,直接挤爆。

所以这里的结论是:窗口断点管架构(要不要双栏),组件断点管排版(一列还是三列)。列表栅格显式声明用 ComponentSize 参照,并且给出自己的分档阈值:

GridRow({
  columns: 12,
  gutter: 12,
  breakpoints: { value: ['320vp', '460vp'], reference: BreakpointsReference.ComponentSize }
}) {
  ForEach(this.visibleRecipes, (r: Recipe) => {
    GridCol({ span: { sm: 12, md: 6, lg: 4 } }) {
      RecipeCardView({
        recipe: r,
        selected: this.selectedRecipeId === r.id,
        onClickCard: () => {
          this.openRecipe(r.id)
        }
      })
    }
  }, (r: Recipe) => r.id)
}

12 列栅格,三个档位下卡片分别占 12 / 6 / 4 列,也就是一列 / 两列 / 三列。阈值 320 / 460 是对着容器宽定的:手机整屏 360vp 落在 md(两列),双栏左栏 380vp 也是 md(两列),平板左栏 480vp 落在 lg(三列)。阈值不是拍脑袋——先定左栏宽度(下一步会设 380 / 480),再让栅格分档贴着这两个值切,才能保证每种形态下列数符合预期。

注意 span: { sm: 12, md: 6, lg: 4 } 这里的 sm / md / lg 是这个栅格自己的档位,跟窗口断点的 sm / md / lg 是两套体系,只是名字恰好一样。阈值换成 320/460 之后,栅格的 md 就不再是 600vp 了——写代码时别把两套档位混着比。

栅格管的是"一张卡片占几列",卡片本身是个独立子组件。这里有个分工原则:卡片自己零派生计算,选中态由父组件算好传下来:

@ComponentV2
struct RecipeCardView {
  @Param recipe: Recipe = emptyRecipe()
  @Param selected: boolean = false
  @Event onClickCard: () => void = () => {
  }

  build() {
    Row({ space: 10 }) {
      Stack() {
        Text(this.recipe.emoji)
          .fontSize(24)
      }
      .width(48)
      .height(48)
      .borderRadius(12)
      .linearGradient({ colors: recipeGradient(this.recipe) })

      Column({ space: 4 }) {
        Text(this.recipe.name)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#2A2F45')
          .maxLines(1)
        Text(this.recipe.blurb)
          .fontSize(11)
          .fontColor('#8A90A6')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(`${this.recipe.minutes} 分钟 · ${this.recipe.difficulty} · ${this.recipe.servings} 人份`)
          .fontSize(10)
          .fontColor('#A5AAC0')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)
    .padding(12)
    .backgroundColor(this.selected ? '#EEF2FF' : '#FFFFFF')
    .borderRadius(14)
    .border({
      width: this.selected ? 1.5 : 1,
      color: this.selected ? '#4A6FE3' : '#ECEEF5'
    })
    .onClick(() => {
      this.onClickCard()
    })
  }
}

“是否选中"是列表级的状态(跟谁在对比),卡片本地算不出来,所以由父组件传 selected: this.selectedRecipeId === r.id 进来。选中卡片换背景色和描边,在双栏模式下这个高亮是右栏内容的空间锚点——用户眼睛看右栏的时候,左栏的高亮告诉他"现在显示的是哪张卡片”。另外注意 ForEach 的键值生成器只返回 r.id,菜谱内容是静态数据,id 稳定,键值就稳定,组件可以最大化复用;如果列表数据会变(点赞数、收藏状态),键值就要把会变的字段编进去,否则界面不刷新,这里的数据静态,从简处理。

分类页签是普通的横向 Scroll,一行胶囊按钮,点击切换 activeCategory,列表跟着 visibleRecipes 重算。它没有任何断点逻辑——一列放得下五个页签,窄屏宽屏表现一致,就不需要为它写分支。“不需要自适应"也是一种自适应决策,前提是你逐个组件问过一遍"它在宽屏上会怎样”。

第三步:导航架构自适应——Stack 与 Split 的切换才是重头戏

前两步只是"排版自适应",一多真正的分水岭在导航:窄屏上导航是时间轴,宽屏上导航是空间轴。手机上点一道菜,详情页压在列表上面,返回键退回来——页面在时间上堆叠;宽屏上点一道菜,详情出现在列表右边,两者在空间上并排。这不是同一个布局的拉伸变形,是两种导航范式。好在 Navigation 组件把这两种范式做成了同一个组件的两种模式,切换只需要改一个属性:

Navigation(this.pathStack) {
  Column() {
    this.CategoryBar()
    this.RecipeList()
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#F5F6FA')
}
.mode(this.navMode)
.navBarWidth(this.isWide ? navBarWidthForBreakpoint(this.breakpoint) : 360)
.navBarWidthRange([320, 560])
.hideTitleBar(true)
.navDestination(this.DetailMap)

mode 不写死,绑定到派生值 navMode——宽屏 Split、窄屏 Stack,来源是窗口断点而不是 Auto:

@Computed
get navMode(): NavigationMode {
  if (this.modeOverride === 'stack') {
    return NavigationMode.Stack
  }
  if (this.modeOverride === 'split') {
    return NavigationMode.Split
  }
  return this.isWide ? NavigationMode.Split : NavigationMode.Stack
}

navBarWidth 控制双栏时左栏宽度(md 给 380,lg 给 480,正好接住上一步栅格的分档),navBarWidthRange 给一个 320–560 的合法区间,防止自由窗口时代码窗口被拖得极窄时左栏把详情挤没了。这个区间在实际拖拽窗口时才显出价值:窗口从 900vp 拖到 500vp 再拖回来,左栏在区间内平滑伸缩,缩到下限就不再缩,宁可右栏变窄也保住列表的基本可读宽度。没有这个区间,某些极端窗口宽度下左栏会被压缩到卡片只剩图标,或者反向撑开把详情挤成一条缝。

点菜这道交互,窄屏和宽屏走两条路

真正要动脑子的地方是"点一张卡片"这个动作在两种模式下的语义差异:

private openRecipe(id: string): void {
  this.selectedRecipeId = id
  if (this.navMode === NavigationMode.Split && this.pathStack.size() > 0) {
    return
  }
  this.pathStack.pushPathByName('recipeDetail', null)
}
  • 窄屏(Stack):先记下选中的 id,再 push 详情页。导航栈管理一切,返回键天然可用。
  • 宽屏(Split)且右栏已有详情:只更新 selectedRecipeId 就直接 return,不压栈。详情页 UI 读的是这个状态,右栏原地刷新成新菜。如果这里无脑 push,用户连点十张卡片,栈里就压了十层一模一样的详情页,按返回键要退十次才能回到"栈空"状态——双栏下这完全是灾难。
  • 宽屏但栈是空的(刚进入宽屏、还没点过任何东西):push 一次,把右栏填上。

判断依据用 pathStack.size() 而不是自己维护一个布尔标记,因为用户在宽屏下也可能主动 pop 掉详情,栈的真实状态只有 NavPathStack 自己知道。

宽屏的默认态:右栏不能空着

应用直接在平板上启动时,导航栈是空的。Split 模式下栈空会发生什么?右栏一片空白,页面像缺了半边。系统应用的做法可以借鉴:邮件类应用在宽屏上永远默认选中第一封邮件。所以每次进入宽屏(断点切换、或者用状态条的开关强制切双栏)时补一个默认选中:

private syncWideDefault(): void {
  if (this.navMode === NavigationMode.Split && this.pathStack.size() === 0 && this.visibleRecipes.length > 0) {
    this.selectedRecipeId = this.visibleRecipes[0].id
    this.pathStack.pushPathByName('recipeDetail', null)
  }
}

三个条件缺一不可:是双栏、栈是空的、当前分类下确实有菜。最后一个条件防的是用户切到一个空分类后旋转屏幕的边界情况。

详情页的路由注册

详情页通过 navDestination 属性注册,它是 Navigation 的路由表——pushPathByName('recipeDetail', null) 推入的页面在这里渲染:

@Builder
DetailMap(name: string) {
  if (name === 'recipeDetail') {
    NavDestination() {
      this.DetailContent()
    }
    .hideTitleBar(true)
    .backgroundColor('#F5F6FA')
  }
}

这里有个实现细节值得展开:详情页的 UI 我没有做参数透传,而是让 DetailContent 这组 @Builder 直接读页面状态(this.currentRecipe()this.breakpoint)。之前在别的项目里踩过一个坑——@Builder 按值传参的动态文本会停在首帧值,状态变了界面不动。所以涉及"随状态刷新的取值"一律内联直读 this.xxx,@Builder 只用来复用纯静态的排版。上面 RecipeCardView 接收的 selected 也是同理:卡片是独立子组件用 @Param 接收父组件算好的值,这条刷新链路是可靠的;要是把 selected 当参数传进 @Builder,就又回到坑里了。

宽屏下点不同的卡片,selectedRecipeId 变化,DetailContent 里所有读 this.currentRecipe() 的地方随之重算,右栏就刷新了——这依赖的正是"详情 UI 直读页面状态"这一条。假如当初把菜谱对象当成参数沿着 pushPathByName(name, param) 传进去,再从 param 里解析,刷新链路就断在路由参数这一层:换菜时 push 被跳过(不压栈),param 不会更新,右栏就停在第一道菜上。路由参数适合传"一次性快照"(比如从外部深链进来),不适合传"会原地变化的选择状态",后者必须走组件状态。

41.gif

42.gif

43.gif

第四步:详情页自己也是一多

详情页不是"适应宽屏"就完事了,它的交互冗余也该随断点变化。看窄屏和宽屏下同一个详情页的两个差异:

一是返回方式。窄屏详情页覆盖全屏,左上角必须有"‹ 返回"按钮,点击 pathStack.pop()。宽屏双栏下列表一直就在左边,返回按钮成了冗余——这时候该藏掉:

if (!this.isWide) {
  Text('‹ 返回')
    .fontSize(14)
    .fontColor('#FFFFFF')
    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
    .borderRadius(14)
    .backgroundColor('#33FFFFFF')
    .margin(12)
    .onClick(() => {
      this.pathStack.pop()
    })
}

二是内容排版。窄屏上食材区和步骤区上下堆叠,一路滚下去;宽屏右栏宽度接近半屏,上下堆叠会变成"两根细长的面条",横向空间浪费。食材是短词条、步骤是长句,正好左右分栏各取所需:

if (this.isWide) {
  Row({ space: 12 }) {
    Column({ space: 10 }) {
      this.DetailIngredients()
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)

    Column({ space: 10 }) {
      this.DetailSteps()
    }
    .layoutWeight(1.5)
    .alignItems(HorizontalAlign.Start)
  }
  .width('100%')
  .alignItems(VerticalAlign.Top)
} else {
  Column({ space: 10 }) {
    this.DetailIngredients()
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)

  Column({ space: 12 }) {
    this.DetailSteps()
  }
  .width('100%')
  .alignItems(HorizontalAlign.Start)
}

食材词条用 Flex({ wrap: FlexWrap.Wrap }) 自动换行,宽屏右栏里一行能放五六个词条,窄屏里放三四个,不用写任何分栏代码。

顶部那排指标格(耗时 / 难度 / 份量 / 热量)也是同样的处理思路——四个格子长得一样、数据不同,抽成子组件,父组件算好值传进来:

@ComponentV2
struct MetricCellView {
  @Param label: string = ''
  @Param value: string = ''
  @Param unit: string = ''

  build() {
    Column({ space: 2 }) {
      Text(this.label)
        .fontSize(10)
        .fontColor('#9AA0B4')
      Row({ space: 2 }) {
        Text(this.value)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#2A2F45')
        if (this.unit !== '') {
          Text(this.unit)
            .fontSize(10)
            .fontColor('#9AA0B4')
        }
      }
      .alignItems(VerticalAlign.Bottom)
    }
    .layoutWeight(1)
    .padding({ top: 10, bottom: 10 })
    .backgroundColor('#F5F6FA')
    .borderRadius(10)
  }
}

四个格子在一行里各占 layoutWeight(1),窄屏宽屏都是四等分——这种"等分布局"天然跨端,无需分支。指标值会随右栏换菜而变,子组件 @Param 这条链路是可靠的;不把它写成 @Builder metricCell(label, value) 按值传参的形式,原因在第三步的路由注册那节说过了,动态值不进 @Builder 参数。

值得强调的是:这是同一份详情 UI,不是两套页面。分支只包住排布容器,食材和步骤的渲染逻辑一份都没复制。如果发现自己在 if/else 两侧各写了一套内容渲染,那一定是抽象层级放错了——分支应该在"容器"这一层,不在"内容"这一层。

第五步:折叠屏的连续性——断点切换不能是页面重建

折叠屏是这个需求里最考验功底的部分。用户折叠/展开屏幕时,期望的是当前状态无缝延续:展开时正在看的菜谱自动进右栏,合上时它还在,选过的分类没变,甚至详情页里按过的按钮状态都还在。

先说结论:这套实现里断点切换天然不丢状态,因为所有状态都挂在页面组件上(breakpointactiveCategoryselectedRecipeId……都是组件的本地状态),折叠/展开只是改变了 onAreaChange 给的宽度,触发一次状态赋值——组件没有被销毁重建,状态当然都在。真正会丢状态的是"按设备形态路由到不同页面"的写法:折叠时跳转 mobile 页、展开时跳转 tablet 页,页面一切换状态全清零。

但有两件事需要主动处理。

第一件,宽→窄的导航栈。折叠前右栏开着详情(栈里有一页),折叠后 Navigation 切回 Stack 模式,这页详情自动变成覆盖全屏的页面,用户按返回退回列表——行为完全符合直觉,Navigation 自己处理好了,不用写代码。反方向(窄→宽)也一样:手机上正看着某道菜的详情,展开屏幕,详情页已在栈里,切到 Split 后它自动进右栏,列表出现在左边,不需要任何手动搬运。

有一个更隐蔽的分支:展开时导航栈是空的怎么办?比如用户在窄屏按返回退回了列表,然后展开屏幕——右栏又要空了。这正是第三步里 syncWideDefault 在断点切换回调里也调了一遍的原因:每次进入宽屏,只要发现"双栏 + 栈空 + 列表有数据",就自动选中第一道菜补进右栏。用户视角是"展开后右边自动有了内容",而不是"展开后右边白了一块,还得自己点一下"。

第二件,验证状态真的没丢。口说无凭,我在页面里放了两个"探针":顶部状态条的"断点切换 N 次"计数器,和详情页底部的"开始烹饪"按钮(点一下变成"已开火 · 点击收工")。操作序列:展开屏 → 点开始烹饪 → 折叠屏 → 再展开。如果一切正常,按钮始终显示"已开火",计数器 +2,选中的菜谱和分类原样。要是哪一步按钮变回了"开始烹饪",就说明有代码把组件重建了。这两个探针在排查"折叠后页面状态异常"类问题时比肉眼盯屏幕可靠得多——肉眼很难记住三步操作前每个控件的状态,探针替你记着。
51.gif

总结

回头看整个实现,核心设计可以压缩成三句话:

断点是状态,UI 是断点的函数。 整个页面只有一个真相来源——breakpoint。导航模式、左栏宽度、栅格列数、返回按钮有无、详情页排布,全部由它派生。任何"判断设备"的代码都是坏味道,设备会出新品类,断点档位不会。

两种参照系各管一层。 窗口断点(onAreaChange 换算 vp)管架构层——要不要双栏、左栏多宽;组件断点(GridRow 的 ComponentSize 参照)管排版层——列表几列、卡片多宽。架构看全局,排版看局部,别让排版逻辑去读窗口宽度。

导航范式跟着断点切换,而不是页面跟着设备切换。 Stack 和 Split 是同一个 Navigation 的两种模式,切换只改一个属性;页面切换(mobile 页换 tablet 页)才是状态丢失的万恶之源。

这套结构不只服务"手机 + 折叠屏 + 平板"三种设备。电脑上的自由窗口随便拖,拖到 500vp 就该是窄屏体验、拖到 900vp 就该是宽屏体验——用断点驱动的页面天然支持,用设备名驱动的页面每出一个新品类就要加一个分支。一多的"一",指的不是一套代码,是一套判断标准。

Logo

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

更多推荐