开篇:一次开发多端部署的三层能力
鸿蒙的卖点之一是"一次开发,多端部署",但它并不是说同一份代码在所有设备上长得一样,而是提供了三层递进的适配能力:
- 自适应布局:控件能根据容器自动伸缩,解决"不溢出、不错位"。
- 响应式布局:根据窗口尺寸切换布局结构,解决"手机单列、平板双栏"。
- 能力型适配:根据设备是否具备某项能力(是否有摄像头、是否支持触控)决定功能开关。
这三层不是替代关系,而是叠加关系。绝大多数应用只需要做到前两层,第三层留给跨设备协同这类特殊场景。本文聚焦前两层的工程落地,并覆盖资源与窗口管理这两个容易被忽略的环节。
如果你还没掌握 ArkUI 的布局容器,建议先读 ArkUI 布局系统与自定义组件 。
一、设备形态与适配目标
| 设备形态 | 典型窗口宽度 | 主要挑战 | 适配重点 |
|---|---|---|---|
| 手机 | 320 至 600vp | 空间紧张 | 单列布局、内容优先级 |
| 折叠屏展开 | 600 至 840vp | 尺寸动态变化 | 监听尺寸切换布局 |
| 平板 | 600vp 以上 | 留白过多 | 双栏、多列网格 |
| 智慧屏 | 960vp 以上 | 远距离观看 | 字号放大、焦点导航 |
| 车机 | 宽且扁 | 安全与快捷 | 大按钮、少层级 |
| 穿戴 | 320vp 以下 | 面积极小 | 单任务、强摘要 |
选型原则:不要试图用一套布局通吃所有形态。合理的做法是先保证手机端体验,再针对平板与折叠屏做增量适配,穿戴与车机通常需要单独设计。
二、断点机制
2.1 窗口宽度断点
断点是响应式布局的基石。鸿蒙约定用三档断点描述窗口宽度,社区的通用取值如下。
| 断点 | 缩写 | 窗口宽度范围 | 布局建议 |
|---|---|---|---|
| Small | sm | 小于 320vp | 单列、极简 |
| Medium | md | 320 至 600vp | 单列或两列 |
| Large | lg | 大于 600vp | 双栏、多列 |
需要说明的是,断点的具体阈值应当以官方文档当前版本为准,不同 API 版本可能有细微调整。工程上更稳妥的做法是把阈值抽成常量集中管理,而不是散落在各个页面里。
export class BreakpointConstants {
static readonly SM_MIN: number = 0;
static readonly MD_MIN: number = 320;
static readonly LG_MIN: number = 600;
static of(width: number): string {
if (width < BreakpointConstants.MD_MIN) {
return 'sm';
}
if (width < BreakpointConstants.LG_MIN) {
return 'md';
}
return 'lg';
}
}
2.2 监听窗口变化
窗口尺寸变化通过 window 模块的 windowSizeChange 事件获取,这是折叠屏展开与分屏场景下的必接事件。
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
export function watchWindowSize(onChange: (width: number, height: number) => void): void {
window.getLastWindow(getContext()).then((win: window.Window) => {
win.on('windowSizeChange', (size: window.Size) => {
onChange(size.width, size.height);
});
}).catch((err: BusinessError) => {
console.error(`getLastWindow failed: ${err.code}`);
});
}
注意 getLastWindow 返回的是 Promise,必须在拿到 Window 实例之后再注册监听。此外,页面销毁时要记得调用 off('windowSizeChange'),否则监听会持续持有回调引用。
三、媒体查询
除了手动监听窗口,还可以用 @ohos.mediaquery 以声明式的方式响应尺寸变化。它的语法与 CSS 媒体查询接近,适合在组件内部做局部判断。
import { mediaquery } from '@kit.ArkUI';
@Entry
@Component
struct AdaptivePage {
@State isLarge: boolean = false;
private listener: mediaquery.MediaQueryListener =
mediaquery.matchMediaSync('(width>=600vp)');
aboutToAppear(): void {
this.isLarge = this.listener.matches;
this.listener.on('change', (result: mediaquery.MediaQueryResult) => {
this.isLarge = result.matches;
});
}
aboutToDisappear(): void {
this.listener.off('change');
}
build() {
Column() {
Text(this.isLarge ? '大屏布局' : '小屏布局')
.fontSize(18)
}
}
}
媒体查询的优点是代码内聚,缺点是条件写多了之后可读性下降。一般建议只在两三个分支时使用,分支更多时改用断点常量加条件渲染。
四、栅格布局 GridRow 与 GridCol
GridRow 与 GridCol 是鸿蒙响应式布局的核心工具,它们把一行拆成若干列,通过 span 指定每个子项占几列,通过 breakpoints 指定不同断点下的列数。
4.1 基本用法
@Entry
@Component
struct GridDemo {
build() {
GridRow({
columns: { sm: 4, md: 8, lg: 12 },
gutter: { x: 12, y: 12 },
breakpoints: { value: ['320vp', '600vp'], reference: BreakpointsReference.WindowSize }
}) {
GridCol({ span: { sm: 4, md: 4, lg: 6 } }) {
Text('左侧卡片')
.width('100%')
.height(120)
.backgroundColor('#F1F3F5')
.textAlign(TextAlign.Center)
}
GridCol({ span: { sm: 4, md: 4, lg: 6 } }) {
Text('右侧卡片')
.width('100%')
.height(120)
.backgroundColor('#E9ECEF')
.textAlign(TextAlign.Center)
}
}
.width('100%')
.padding(16)
}
}
breakpoints.value 给出断点分界值,数组长度决定了断点数量;reference 决定以窗口尺寸还是组件自身尺寸为基准,做局部自适应时应选组件尺寸。
4.2 span、offset 与 order
GridCol 除了 span,还支持 offset(列偏移)与 order(排列顺序)。这两个属性在响应式场景下非常有用:可以在小屏时把次要内容排到后面,在大屏时提前。
| 属性 | 作用 | 典型用法 |
|---|---|---|
| span | 占用列数 | 小屏整行、大屏半行 |
| offset | 左侧空出的列数 | 居中一个窄卡片 |
| order | 排列顺序 | 大屏时把侧栏提前 |
需要警惕的是 span 的总和不要超过 columns,超过部分会被挤到下一行,视觉上表现为莫名的换行。
五、自适应布局手段
5.1 尺寸单位
| 单位 | 含义 | 使用建议 |
|---|---|---|
| vp | 虚拟像素,随屏幕密度换算 | 布局尺寸首选 |
| fp | 字体像素,随系统字号缩放 | 文本字号首选 |
| lpx | 逻辑像素,按设计稿基准换算 | 与设计稿对齐时使用 |
| px | 物理像素 | 仅在像素级绘制时使用 |
| % | 相对父容器百分比 | 自适应宽高 |
硬编码 px 是跨设备错位的头号原因。在 1.5 倍密度的设备上看起来正常的间距,换到 2 倍密度设备上就会明显偏小。除非是做像素级图像处理,业务代码里应当尽量避免 px。
5.2 弹性与约束
自适应布局的关键属性只有几个,但组合起来能覆盖大部分场景。
| 属性 | 作用 | 注意点 |
|---|---|---|
| layoutWeight | 按权重分配剩余空间 | 父容器必须有确定尺寸 |
| constraintSize | 限制最大最小宽高 | 防止大屏无限拉伸 |
| flexGrow | Flex 子项拉伸比例 | 仅在 Flex 内生效 |
| aspectRatio | 锁定宽高比 | 适合图片与视频卡片 |
| width(‘100%’) | 撑满父容器 | 需确认父容器尺寸确定 |
一个常见误区是给 List 的每一项都写死高度。更好的做法是用 constraintSize 给出最小高度,让内容自然撑开,同时用 aspectRatio 锁定图片比例,避免加载完成后布局抖动。
六、资源限定目录
6.1 限定词规则
鸿蒙通过资源目录名中的限定词自动匹配设备与语言,这是"一次开发多端部署"在资源层面的体现。
| 目录 | 匹配条件 | 典型内容 |
|---|---|---|
| resources/base | 默认兜底 | 通用字符串与图标 |
| resources/phone | 手机 | 手机专属布局尺寸 |
| resources/tablet | 平板 | 平板专属尺寸 |
| resources/dark | 深色模式 | 深色配色 |
| resources/zh_CN | 简体中文 | 中文文案 |
| resources/en_US | 英文 | 英文文案 |
匹配顺序是从具体到通用:系统先找同时满足所有限定词的目录,找不到再逐级回退到 base。因此 base 目录必须存在且内容完整,否则在某些设备上会出现资源缺失。
6.2 $r 引用
@Entry
@Component
struct ResourceDemo {
build() {
Column({ space: 8 }) {
Text($r('app.string.app_title'))
.fontSize($r('app.float.title_size'))
Image($r('app.media.logo'))
.width(80)
.height(80)
}
}
}
$r('app.string.xxx') 会按当前语言自动选取文案,$r('app.float.xxx') 会按设备选取尺寸。这种做法的代价是需要维护多份资源文件,收益是同一份 UI 代码能在不同设备与语言下正确呈现。
6.3 多语言与镜像布局
面向阿拉伯语、希伯来语等从右到左的语言时,布局需要整体镜像。鸿蒙的做法是用方向相关的属性替代绝对方向属性,让框架自动处理。
| 写法 | LTR 下的含义 | RTL 下的含义 |
|---|---|---|
| margin start | 左边距 | 右边距 |
| padding end | 右内边距 | 左内边距 |
| 容器 direction 设为 Auto | 从左到右 | 从右到左 |
| 文本 textAlign Start | 左对齐 | 右对齐 |
规则很简单:布局里不要出现 left 与 right,统一用 start 与 end。这样同一份代码在 LTR 与 RTL 语言下都能正确呈现,不必为每种语言维护一套布局。
七、窗口管理与折叠屏
折叠屏的适配难点在于尺寸会动态变化,且变化过程中布局可能经历一次重建。除了监听 windowSizeChange,还需要处理窗口模式变化。
import { window } from '@kit.ArkUI';
export function watchWindowMode(onChange: (mode: window.WindowMode) => void): void {
window.getLastWindow(getContext()).then((win: window.Window) => {
win.on('windowStatusChange', (status: window.WindowStatusType) => {
console.info(`window status: ${status}`);
});
win.on('windowSizeChange', () => {
onChange(win.getWindowProperties().windowMode);
});
});
}
折叠屏适配的关键经验是:不要在尺寸变化时做重布局之外的副作用(如重新请求网络),只更新与布局相关的状态即可。否则每次折叠展开都会触发一轮数据刷新,既浪费流量又会让页面闪烁。
窗口状态与页面生命周期的关系,可以参考鸿蒙应用与页面生命周期里的回调时序说明。
八、实战:手机单列到平板双栏
把断点、栅格与条件渲染组合起来,就能实现最常见的响应式需求:手机单列、平板双栏。
@Entry
@Component
struct ArticleListPage {
@State breakpoint: string = 'md';
@State articles: string[] = ['文章一', '文章二', '文章三'];
aboutToAppear(): void {
this.updateBreakpoint();
}
private updateBreakpoint(): void {
const width = this.getUIContext().getHostContext()
? px2vp(360) : 360;
this.breakpoint = BreakpointConstants.of(width);
}
@Builder
detailPanel() {
Column() {
Text('详情面板')
.fontSize(20)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.height('100%')
.backgroundColor('#F8F9FA')
.justifyContent(FlexAlign.Center)
}
build() {
Row() {
List({ space: 8 }) {
ForEach(this.articles, (item: string) => {
ListItem() {
Text(item)
.fontSize(16)
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(8)
}
}, (item: string) => item)
}
.layoutWeight(this.breakpoint === 'lg' ? 1 : 2)
if (this.breakpoint === 'lg') {
this.detailPanel()
.layoutWeight(1)
}
}
.width('100%')
.height('100%')
}
}
这段代码的要点是 layoutWeight 与条件渲染的配合:大屏时列表与详情各占一半,小屏时详情面板根本不渲染,列表独占空间。用 if 而不是隐藏,能让不渲染的分支完全不参与测量,性能更好。
响应式适配的最终目标是可访问性与信息密度之间的平衡。大屏不等于要把字号放大,而是要把同一屏承载的信息层级做得更清楚,这一点与 前端可访问性与国际化 中讲的阅读体验原则一致。卡片类的小尺寸组件也有类似约束,见 鸿蒙元服务与卡片开发 。
九、常见坑清单
- 硬编码
px尺寸,在不同屏幕密度上比例错乱。 vp与px混用且未做换算,间距忽大忽小。- 资源限定目录名拼写错误(如
tablets),系统静默回退到base。 base目录缺少某资源,导致部分设备白屏。GridCol的span总和超过columns,出现意外换行。- 折叠屏展开时未监听
windowSizeChange,界面停留在旧布局。 - 窗口监听未
off,页面销毁后回调仍在执行。 - 把网络请求写在尺寸变化回调里,折叠一次刷新一次。
- 用隐藏代替条件渲染,不可见分支仍在参与测量。
- 大屏直接放大字号而不调整信息层级,可读性反而变差。
小结
多端适配的核心是分层:先用 vp、百分比、constraintSize 把自适应做扎实,解决"不溢出";再用断点、媒体查询与 GridRow 做响应式,解决"结构切换";最后用资源限定目录处理设备与语言的差异。记住三条底线:业务代码不写死 px,所有窗口监听都要配对 off,尺寸变化只改布局状态不触发副作用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。