一次开发多端部署与响应式适配

本文讲清 HarmonyOS NEXT 的一次开发多端部署:断点机制与窗口宽度阈值、媒体查询监听、GridRow 与 GridCol 栅格布局、自适应布局的尺寸单位与约束属性、资源限定目录的匹配规则,以及窗口与折叠屏状态管理。文中给出手机单列切换平板双栏的完整示例与两张对照表,并汇总硬编码 px、限定词命名错误等常见坑。

开篇:一次开发多端部署的三层能力

鸿蒙的卖点之一是"一次开发,多端部署",但它并不是说同一份代码在所有设备上长得一样,而是提供了三层递进的适配能力:

  • 自适应布局:控件能根据容器自动伸缩,解决"不溢出、不错位"。
  • 响应式布局:根据窗口尺寸切换布局结构,解决"手机单列、平板双栏"。
  • 能力型适配:根据设备是否具备某项能力(是否有摄像头、是否支持触控)决定功能开关。

这三层不是替代关系,而是叠加关系。绝大多数应用只需要做到前两层,第三层留给跨设备协同这类特殊场景。本文聚焦前两层的工程落地,并覆盖资源与窗口管理这两个容易被忽略的环节。

如果你还没掌握 ArkUI 的布局容器,建议先读 ArkUI 布局系统与自定义组件 。

一、设备形态与适配目标

设备形态典型窗口宽度主要挑战适配重点
手机320 至 600vp空间紧张单列布局、内容优先级
折叠屏展开600 至 840vp尺寸动态变化监听尺寸切换布局
平板600vp 以上留白过多双栏、多列网格
智慧屏960vp 以上远距离观看字号放大、焦点导航
车机宽且扁安全与快捷大按钮、少层级
穿戴320vp 以下面积极小单任务、强摘要

选型原则:不要试图用一套布局通吃所有形态。合理的做法是先保证手机端体验,再针对平板与折叠屏做增量适配,穿戴与车机通常需要单独设计。

二、断点机制

2.1 窗口宽度断点

断点是响应式布局的基石。鸿蒙约定用三档断点描述窗口宽度,社区的通用取值如下。

断点缩写窗口宽度范围布局建议
Smallsm小于 320vp单列、极简
Mediummd320 至 600vp单列或两列
Largelg大于 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限制最大最小宽高防止大屏无限拉伸
flexGrowFlex 子项拉伸比例仅在 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,尺寸变化只改布局状态不触发副作用。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「鸿蒙开发」更多文章

  1. 鸿蒙 ohpm 包管理与 Hypium 测试框架
  2. ArkUI 动画体系与手势交互
  3. 鸿蒙应用安全:权限模型与 HUKS 密钥管理