断点看的是窗口,不是设备——鸿蒙一多开发该不该做、怎么做

开发环境:DevEco Studio、ArkTS、API 12+
前置知识:会写 ArkUI 布局(Column / Row / List)

引子:一个把很多人带偏的直觉

做多端适配,最容易冒出来的想法是:

if (deviceInfo.deviceType === 'tablet') {
  // 平板布局:左右分栏
} else {
  // 手机布局:上下堆叠
}

这个写法是错的,而且错得很隐蔽——因为在"一台设备一个窗口"的时代它确实能用。

问题出在两件事上:

  1. 平板上开了分屏,窗口可能只剩手机那么宽,但 deviceType 还是 tablet
  2. 手机上开了自由窗口(折叠屏、PC 模式),窗口可以拖得很宽,但 deviceType 还是 phone

官方的说法很直接:

断点面向窗口而非设备类型,相同断点区间的窗口展示相同的页面布局。同一设备上的不同窗口形态(例如全屏显示、分屏显示、自由窗口等)可能落入不同的断点区间,展示不同布局。

你的界面该跟着"窗口有多大"变,而不是跟着"这是什么设备"变。

这一条是鸿蒙一多开发的根基,理解错了,后面全是歪的。


一、先分清两个词:响应式 vs 自适应

这两个词经常被混着用,但它们解决的问题不一样:

响应式布局自适应布局
变的是什么布局结构(单列 → 双列 → 三列)布局尺寸(按可用空间伸缩)
靠什么实现断点、媒体查询、栅格百分比、layoutWeight、constraintSize
典型场景手机上一列列表,平板上一左一右输入框永远占满剩余宽度
代码形态要写判断不写判断,属性自己算

判断口诀:结构变了 = 响应式;尺寸跟着变 = 自适应。

这个区分很实用,因为它能帮你判断"要不要上断点":

大部分页面只需要自适应就够了。 真正需要断点的场景是"手机上放不下、平板上想并排显示"这种结构性差异。

一上来就给所有页面做三套断点,是过度设计。


二、断点:官方定义的区间

断点(breakpoint)就是把窗口特征切成几个区间,跨区间时换布局。官方分了两套:

横向断点:按窗口宽度

断点名窗口宽度(vp)典型设备
xs(0, 320)智能穿戴
sm[320, 600)手机竖屏
md[600, 840)折叠屏内屏、平板竖屏
lg[840, 1440)平板横屏
xl[1440, +∞)PC / 大屏

纵向断点:按窗口高宽比

断点名高宽比含义
sm(0, 0.8)扁的,横向窗口
md[0.8, 1.2)接近方形
lg[1.2, +∞)竖长的

为什么要两套

官方给的设计原则是两条:

原则一:两个宽度相近的窗口,页面布局相同,断点归一。
原则二:高度相对宽度较小的窗口,呈现横向窗口或类方形窗口时,页面布局进行差异化设计,增加断点。

翻译成人话:

  • 横向断点回答"有多宽"——决定内容能并排几列
  • 纵向断点回答"有多高"——决定纵向空间够不够,比如一个竖屏手机上可以上下堆很多卡片,但横屏时高度只剩一点点,就得换成横向滚动

举例:

场景宽度高宽比横向断点纵向断点
手机竖屏374vp827/374 ≈ 2.21smlg
手机横屏827vp374/827 ≈ 0.45mdsm
平板竖屏800vp1280/800 = 1.6mdlg
平板横屏1280vp800/1280 = 0.625lgsm

(数据取自官方文档:主流手机全屏窗口约 827 × 374 vp。如果你的机型更宽,横屏可能落到 lg——官方提到部分大屏手机、小折叠屏横屏时确实会进入 lg 区间,所以实际值以 getWindowProperties() 取到的为准。)

看出来了吗:手机横屏(md)和平板横屏(lg)的横向断点不同,但纵向断点都是 sm——因为它们的窗口都很扁,高度都不够。

如果只看横向断点,你会觉得这两个场景"不一样"(一个 md、一个 lg),于是给它们不同的处理;但真正的问题是同一个:高度空间紧张,内容得改成横向排列或允许滚动。

这就是纵向断点的价值:它补上了"宽度"这个维度看不到的信息。


三、四种响应式能力

官方给的框架是四样,从粗到细:

能力干什么什么时候用
断点把窗口宽度切成区间,跨区间时换布局主要手段,最常用
媒体查询监听更丰富的条件(宽度、横竖屏、深浅色、设备类型)需要监听"横竖屏切换""深色模式"时
栅格12 列网格,子元素按列数排布内容需要按列排(卡片列表、表单)
响应式组件Tabs/Swiper/Grid/List/GridRow 自带断点参数用这些组件时顺手配置

① 栅格:最省事的一种

GridRow 是容器(定总列数),GridCol 是子项(定占几列):

GridRow({ columns: { sm: 4, md: 8, lg: 12 }, gutter: 12 }) {
  GridCol({ span: { sm: 4, md: 4, lg: 6 } }) {
    this.Card('消息')
  }
  GridCol({ span: { sm: 4, md: 4, lg: 6 } }) {
    this.Card('联系人')
  }
}

读法:

断点总列数每卡片占结果
sm(手机)44一行一个
md(平板竖屏)84一行两个
lg(平板横屏)126一行两个,但每个更宽

不用写任何 if,栅格自己按断点算。这是它比手写断点判断最大的好处——逻辑集中在一处,不会散落。

总列数到底该不该随断点变

这里有个容易踩的版本差异,网上教程写法不一,说明一下:

版本不传 columns 时的默认值
API 20 之前固定 12
API 20 及之后{ xs: 2, sm: 4, md: 8, lg: 12, xl: 12, xxl: 12 }(随断点变)

所以两种写法都成立,选一种就别混:

写法 A:总列数固定 12,只靠 span 变

GridRow() {                                          // 用默认 12 列
  GridCol({ span: { sm: 12, md: 6, lg: 6 } }) { ... } // 手机上占满 12 列 → 一行一个
}

适合想统一逻辑,或者要兼容 API 20 之前的版本。

写法 B:总列数随断点变

GridRow({ columns: { sm: 4, md: 8, lg: 12 } }) {
  GridCol({ span: { sm: 4, md: 4, lg: 6 } }) { ... }
}

适合想让"一行几个"更直观:手机上 4/4 = 1 个,平板上 8/4 = 2 个。

上面示例用的是 B,因为读起来"占几列"和"总共几列"的关系更清楚。如果你用的 SDK 在 API 20 之前,注意 columns 不传就是固定 12,那时 span 要按 12 为基数来算。

② 媒体查询:要监听更多条件时

import { mediaquery } from '@kit.ArkUI'

@State isWide: boolean = false
// 先声明,别在这里初始化——matchMediaSync 依赖 UI 执行上下文
private listener: mediaquery.MediaQueryListener | null = null

aboutToAppear(): void {
  this.listener = mediaquery.matchMediaSync('(width >= 600vp)')
  this.listener.on('change', (result: mediaquery.MediaQueryResult) => {
    this.isWide = result.matches        // 窗口跨过 600vp 时触发
  })
}

aboutToDisappear(): void {
  this.listener?.off('change')          // ← 必须退订
  this.listener = null
}

两个容易出错的点:

  1. matchMediaSync 要放在组件创建之后调。官方明确说这个模块依赖 UI 执行上下文,不能在 UIAbility 的生命周期(比如 onCreate)里调用——那时还没有 UI 实例。
  2. off 传不传回调,行为不一样:
写法效果
off('change', this.onChange)只取消指定的那一个回调
off('change')取消该事件类型下注册过的全部回调

上面示例只注册了一个回调,所以 off('change') 够用。但如果同一个组件里对同一个 listener 注册了多个回调,就必须用第一种写法——否则会把别人的也一起取消掉。

它比断点强的地方是能查更多条件:

mediaquery.matchMediaSync('(orientation: landscape)')     // 横屏
mediaquery.matchMediaSync('(dark-mode: true)')            // 深色模式
mediaquery.matchMediaSync('(height <= 400vp)')            // 高度限制

订阅了不退订就是内存泄漏,而且回调会越积越多——这跟 addListener/removeListener 是同一条规矩。

③ 自适应布局的几个手段

这部分不需要断点,靠属性自己算:

手段作用例子
width('90%')按父容器百分比卡片宽度
layoutWeight(1)按权重分配剩余空间输入框占满、固定按钮之外的部分
constraintSize({ maxWidth: '65%' })约束上下限聊天气泡最宽不超过 65%
aspectRatio(1)固定宽高比头像、缩略图保持正方形
Flex 布局弹性排列,可换行标签流式布局
displayPriority空间不够时按优先级隐藏空间紧张时先隐藏次要信息

这几个我在聊天列表里全用上了:

Column() {
  Text(msg.displayText())
}
.constraintSize({ maxWidth: '65%' })     // 气泡最宽 65%,不会一长条拉满
TextInput({ ... })
  .layoutWeight(1)                        // 输入框占满剩余,发送按钮固定宽

这类写法一行判断都不用写,但天生就是"多尺寸友好"的——比写死像素强得多。


四、我的项目怎么做的

老实说:我做的是纯手机应用,没做完多端适配。

理由四条,都是实的:

判断项我的情况
要不要上平板 / 手表不做,范围就是手机
有没有多窗口 / 分屏场景主要是手机,分屏用得少
做多端适配的成本每个页面要考虑三种断点,工作量接近翻倍
当前时间预算主线是把功能做完

但**“不做适配"不等于"可以写死尺寸”**。我给自己留了两条余地:

  1. 不用固定宽度,能用 layoutWeight 就用。输入框、气泡都是这样
  2. 结构上不假设"一定是窄屏"。列表用 List 而不是自己拼 Column,将来换布局成本低

这就是分层的思路搬到布局上:给变化留接口,但别提前实现。


五、踩坑清单

  1. 按设备类型判断布局(deviceType === 'tablet')→ 分屏/自由窗口时错乱,应该判断断点
  2. 到处写死 width(360) → 平板上一片空白,分屏时溢出
  3. 只考虑横向断点,忘了纵向 → 横屏时高度不够,内容被截断
  4. 媒体查询 on('change') 没配 off → 内存泄漏 + 回调重复触发
  5. 栅格里各 span 之和超过 columns → 子项被挤到下一行,或直接溢出
  6. GridRow 里放大量内容不做滚动 → 超出父容器高度后看不到(GridRow 本身不滚动,外面要套 Scroll)
  7. 一上来就给所有页面做三套断点 → 过度设计,维护成本高

六、小结

问题答案
断点按什么划分窗口特征,不是设备类型
横向断点看什么窗口宽度(xs 到 xl)
纵向断点看什么窗口高宽比(sm/md/lg),判断"够不够高"
响应式和自适应的区别结构变 vs 尺寸变
什么时候用栅格内容需要按列排,且列数随断点变化
什么时候该做多端有明确的平板/手表/PC 场景才做

最后一句:一多开发的难点不在 API,在"什么时候不做"。 断点、栅格、媒体查询这些用起来都不难,难的是判断"我这个项目到底需不需要"。只做一个手机端的产品,硬上三套断点,收益为零,成本是每一处改动都要考虑三种情况。

先把尺寸写活(不写死像素),比什么都重要——那一步几乎零成本,而且给未来的所有可能性留了门。


相关文章

Logo

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

更多推荐