先说一个场景

我做一个书摘清单:六本在读的书,每张卡片有标签、书名、作者、一句摘录和阅读进度。

竖着拿手机,卡片一张一张往下排,一屏看两条;把手机横过来,同样的六张卡片两列并排,一屏能扫四条。再往外扩,如果窗口宽度到了平板那个量级,就三列。

不是写两个页面,也不是给横屏单独做一套布局。从头到尾只有一份 Index.ets,变的是几个数字:列数、页面边距、标题字号。这篇文章就把这份代码拆开,讲清楚这套数字是怎么算出来的。

竖屏:361vp,一列,边距 16vp

同一份代码,换个宽度就换列数

把手机横过来,页面右上角的角标从 sm 361vp 变成 md 804vp,一句话的说明也跟着从“1 列 · 页面边距 16vp”变成“2 列 · 页面边距 24vp”。

横屏:804vp,两列,边距 24vp

除了列数和边距,标题字号也从 24 涨到 27,卡片之间的间距没动,因为两列之间的间隙本来就是固定的。卡片本身的高度、内容顺序一个都没改。

这个角标是我特意加的。调试适配最怕“看着差不多”,把当前窗口宽度和命中的断点直接画在页面上,转一次屏就能确认状态对不对,比翻日志快得多。上线前把它删掉或者藏起来就行。

断点不是设备型号,是窗口宽度

适配最容易走错的一步,是按“手机 / 平板 / PC”去写判断。鸿蒙的断点只看一个东西:当前窗口有多宽。

三个断点:600 和 840 两条线

  • 小于 600vp 是 sm,一列;
  • 600 到 840vp 是 md,两列;
  • 840vp 以上是 lg,三列。

为什么强调“窗口”而不是“屏幕”?因为同一台手机,竖屏 361vp 是 sm,横屏 804vp 就进了 md——上面两张截图就是同一台机器。折叠屏展开、平板分屏、PC 上拖窗口边缘,改的都是窗口宽度,不是设备身份。

这就带来一个好处:布局里不需要 if (deviceType === 'tablet') 这种分支。只要窗口够宽,就该用宽布局;窗口被用户拖窄了,就该收回去。

四步做完一次适配

整条链路很短:监听窗口尺寸、换算单位、算出断点、让布局读这个断点。

监听 → 换算 → 分档 → 应用

第一步:拿到窗口宽度,并持续监听

import { window } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';

aboutToAppear(): void {
  const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext;
  window.getLastWindow(ctx).then((w: window.Window) => {
    this.win = w;
    const rect = w.getWindowProperties().windowRect;
    this.updateWidth(rect.width);
    w.on('windowSizeChange', (size: window.Size) => {
      this.updateWidth(size.width);
    });
  });
}

两个容易漏的点:

一是初始值。on('windowSizeChange') 只在尺寸变化时回调,页面刚起来那一下不会触发,所以要先 getWindowProperties().windowRect 取一次。

二是回调线程。on('windowSizeChange') 只在主线程调用,直接在回调里改 @State 没问题,不需要再往 UI 线程抛。

第二步:px 换 vp,算出断点

@State widthVp: number = 360;
@State bp: string = 'sm';

updateWidth(px: number): void {
  const vp = px2vp(px);
  this.widthVp = Math.round(vp);
  if (vp >= 840) {
    this.bp = 'lg';
  } else if (vp >= 600) {
    this.bp = 'md';
  } else {
    this.bp = 'sm';
  }
}

窗口接口给的是 px,断点阈值是 vp,中间必须过一道 px2vp()。这一步漏掉,判断的方向会整个反过来:竖屏 361vp 在这台 3 倍屏手机上是 1083px,直接拿 px 去和 600 比,会被误判成宽屏,一列当场变两列。

换算完只保留一个结果:bp 是 'sm' | 'md' | 'lg' 三个字符串之一。不要把 widthVp 到处传下去让每个组件自己比大小,那样宽度一变化,几十处判断各自刷新,顺序还不好保证。

第三步:布局只读这个状态

columns(): string {
  if (this.bp === 'lg') {
    return '1fr 1fr 1fr';
  }
  if (this.bp === 'md') {
    return '1fr 1fr';
  }
  return '1fr';
}

// ...
Grid() {
  ForEach(this.books, (item: Book) => {
    GridItem() {
      this.bookCard(item)
    }
  }, (item: Book) => item.title)
}
.columnsTemplate(this.columns())
.columnsGap(12)
.rowsGap(12)
.width('100%')
.layoutWeight(1)

列数只改一个属性:columnsTemplate。1fr 表示一列占满,1fr 1fr 是两等分,三列同理。卡片内部不用知道自己处在第几列,GridItem 会把宽度分配好。

Grid 放在 layoutWeight(1) 的容器里,宽度撑满、高度吃掉剩余空间,内容超出时自己滚动。横屏那张截图下方只露出第二行卡片的上沿,就是因为两列三行的内容比屏幕高;往上滑一下,第 5、6 张卡片能正常滚出来。

顺带说一句高度:页面根节点是 height('100%'),Grid 用 layoutWeight(1) 占剩余空间,这样页头、标尺、说明行的位置都不会被内容顶走。

第四步:边距和字号也挂上去

只换列数,在手机上还行,一到宽屏就会显得“内容被摊得很薄”。所以边距和字号跟着断点一起走:

pagePadding(): number {
  if (this.bp === 'lg') {
    return 32;
  }
  if (this.bp === 'md') {
    return 24;
  }
  return 16;
}

// 标题
Text('书摘清单')
  .fontSize(this.bp === 'lg' ? 30 : (this.bp === 'md' ? 27 : 24))

// 页面
Column() { /* ... */ }
  .padding({
    left: this.pagePadding(),
    right: this.pagePadding(),
    top: 20,
    bottom: 16
  })

一套数字就够了:一列时边距 16,两列 24,三列 32;标题 24 / 27 / 30。宽屏留白多一点,内容不贴边,视线也不用横跨整块屏幕。

我做这个 demo 时踩的三个坑

这几条都是实际调出来的,不是看文档看出来的。

@Builder 的参数默认按值传递,界面不会跟着刷新。 页面上那条 sm / md / lg 的标尺,我最初写成 this.rulerSeg(name, range, this.bp === 'md') 这样的多参数形式。结果横屏之后列数、边距都变了,标尺的高亮还停在 sm。原因是 @Builder 用普通参数传入时,值只在首次渲染时固定,状态变了不会重新计算;改成传一个对象(this.rulerSeg({ name: 'md', range: '600 - 840', active: this.bp === 'md' }))之后才正常。这种 bug 很隐蔽——布局是对的,只有一小块 UI 不跟手。

auto_rotation 跟的是传感器,不是系统显示方向。 我为了拍横屏那张图,先用命令把屏幕旋转到 90 度,截出来还是竖屏;因为应用配的是 auto_rotation,手机本身还竖着,它就按传感器转回竖屏。最后是把 ability 的 orientation 临时改成 landscape 重新出包,才拍到 804vp 的横屏状态。拍完再改回 auto_rotation,不然应用会被强制锁在横屏。

监听和反注册必须成对。 页面销毁时忘了 off('windowSizeChange'),回调会继续持有已经销毁的页面引用。我在 aboutToDisappear() 里补上:

aboutToDisappear(): void {
  this.win?.off('windowSizeChange');
}

鸿蒙 7 里的新选择:容器断点

前面讲的都是窗口断点:窗口宽度变了,整页跟着换挡。这个方案适合“页面级”的布局,比如书摘清单从一列变两列。

鸿蒙 7 的 API 26 里多了一个组件:ContainerReader,官方叫容器断点。它看的不是窗口多宽,而是组件自己所在容器有多宽,同时支持宽度断点和高度断点,还能通过 breakpointConfig 自定义阈值,适合三种场景:组件级响应式布局、嵌套布局、可复用的自定义组件。

举个具体的例子:一个书摘卡片,在手机上占满整行,在平板上被放进侧边栏只占一半宽。它跟着窗口断点走会判断错,跟着自己容器的宽度走才对。这种场景就该用 ContainerReader,而不是把窗口宽度一路传进卡片里。

本文这个 demo 没有换成容器断点,原因很简单:整页的列数本来就是跟着窗口走的,用窗口断点更直接。两者不是替代关系,是分层:页面级看窗口,组件级看容器。

验证到哪一步

说清楚边界,比含糊地写“已适配多端”更有用:

  • 竖屏 361vp → sm,一列,16vp 边距:真机实拍,就是第一张图。
  • 横屏 804vp → md,两列,24vp 边距:真机实拍,临时把 orientation 设成 landscape 得到的,就是第二张图。
  • 840vp 以上的 lg 三列:没有平板或大窗口设备,没有实拍。代码路径和 sm、md 完全一样,只是 columnsTemplate 换成三列、边距 32、标题 30,但我不会写“已验证”。
  • 折叠屏展开、分屏、PC 拖窗口:同样没有验证,只确认了监听写法走的是同一条 windowSizeChange。

这个分寸要守住:能跑的写实测,没跑的写“没验证”。

三条建议

  1. 先列一张断点表,再写页面。 三列分别对应多少列、多大边距、多大字号,先定死。写代码时只是查表,不会一边调一边改。
  2. 宽度压成一个状态,布局只读状态。 页面里出现第二个“当前宽度”的副本,早晚不一致。
  3. 每个断点截一张图。 转屏两秒钟的事,比盯着代码猜“应该没问题”可靠得多。截图里把断点和 vp 打在角标上,出问题一眼能看出命中了哪一档。

总结

多设备适配听起来是大工程,落到这个页面上,就是四个动作:监听窗口尺寸变化、把 px 换成 vp、按 600 和 840 算出一个断点、让列数边距字号去读这个断点。

真正的成本不在写代码,在“知道自己在哪个断点”。角标、标尺这些小东西看着像调试工具,其实是适配过程里最值钱的部分——它们让“差不多”变成“就是这两列”。

鸿蒙 7 补上的容器断点,把这套思路从页面推到了组件:页面看窗口,卡片看容器。这件事分层理清了,再多的屏幕形态也只是多几行参数。

参考地址

  1. https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-multi-device-overview
  2. https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-multi-device-responsive-layout
  3. https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-multi-device-page-layout
  4. https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-layout-development-container-reader
  5. https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-window
  6. https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-grid
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐