在这里插入图片描述

摘要

本文以「漫游城市」城市打卡应用为例,演示如何在 HarmonyOS 上用一套代码适配手机、折叠屏、平板三种形态。核心思路是把「业务状态」与「布局策略」解耦:状态只有一份,布局由断点(sm / md / lg)驱动。文章依次讲解断点纯函数与 mediaquery 监听、GridRow/GridCol 栅格列数随断点变化、用单个 isWide() 判断切换双栏/单栏、deviceTypes 多端配置,并整理了 5 个真机/模拟器上踩到的 ArkTS 严格模式坑,最后附上 hypium 单测与工程结构速查。代码已在 HarmonyOS 5.1.1(API 19)编译通过。

目录


一、为什么是「全场景适配」

这次 InfoQ 征文的主题是"让鸿蒙应用,玩出新可能"。我翻了下自己手边的设备:一台手机、一台折叠屏、一台平板——同样是"我的应用",在三个屏幕上的体验却天差地别。折叠屏展开是一块近平方的屏,平板是横宽屏,手机是竖长屏。如果为每一种形态各写一套布局和状态,工作量直接翻三倍,后续每改一个功能也要同步三处。

这正是 HarmonyOS 一直强调的「一次开发,多端部署」要解决的问题。我就想做一个最小但能说明问题的 demo:「漫游城市」——一个城市打卡应用,功能很简单(选城市、看景点美食、打卡),但同一套代码要在手机上走"单栏列表 → 点进去看详情",在平板/展开折叠屏上直接"左列表 + 右详情"双栏并排,城市卡片还要在 1 列 / 2 列 / 3 列之间随宽度变化。

做完之后最大的体会是:所谓"一码多形",本质不是多写布局,而是把"业务状态"和"布局策略"拆开——状态只有一份,布局策略跟着断点走。


二、核心过程:断点驱动的一码多形

1) 断点系统:把窗口宽度翻译成 sm / md / lg

多端适配的第一步,是知道"我现在在多宽的屏幕上"。这里有个关键的认知:不要监听物理分辨率,要监听窗口宽度。因为折叠屏展开、平板分屏,窗口宽都不等于物理屏宽。鸿蒙的 mediaquery 正好干这事,而且折叠屏展开时会自动触发断点变化,不用我手动处理。

我把"宽度 → 断点"的换算抽成了一个纯函数,这样它不依赖任何 UI 环境,能用单元测试直接覆盖:

// common/BreakpointSystem.ets
export type Breakpoint = 'sm' | 'md' | 'lg';

// 纯函数:宽度(vp) → 断点。可被 hypium 单测直接覆盖。
export function breakpointOf(width: number): Breakpoint {
  if (width >= 840) return 'lg';
  if (width >= 600) return 'md';
  return 'sm';
}

断点监听用一个独立类包起来,避免散落在页面里:

export class BreakpointMonitor {
  private onChange: (bp: Breakpoint) => void = () => {};

  constructor(onChange: (bp: Breakpoint) => void) {
    this.onChange = onChange;
  }

  start(): void {
    // 用 mediaquery 监听三档区间,区间内匹配即回调当前断点
    const sm = mediaquery.matchMediaSync('(max-width: 599vp)');
    const md = mediaquery.matchMediaSync('(min-width: 600vp) and (max-width: 839vp)');
    const lg = mediaquery.matchMediaSync('(min-width: 840vp)');
    sm.on('change', (m) => { if (m.matches) this.onChange('sm'); });
    md.on('change', (m) => { if (m.matches) this.onChange('md'); });
    lg.on('change', (m) => { if (m.matches) this.onChange('lg'); });
  }
}

整条链路可以概括为下图:窗口宽度变化触发 mediaquery 监听,回调里交给 breakpointOf 纯函数换算成 sm / md / lg,再由断点同时驱动两件事——GridCol 的 span 决定卡片列数,isWide() 决定双栏还是单栏。断点只算一次,布局各处复用,这正是「一码多形」的关键。

width < 600vp

600 ~ 839vp

>= 840vp

窗口宽度变化
折叠屏展开 / 平板分屏 / 旋转

mediaquery.matchMediaSync
监听窗口宽度区间

BreakpointMonitor 回调
onChange(bp)

breakpointOf(width)
纯函数换算

宽度区间判断

sm 断点

md 断点

lg 断点

cardSpan() = 12
单列卡片

cardSpan() = 6
两列卡片

cardSpan() = 4
三列卡片

isWide() = false
单栏:列表 / 详情互斥

isWide() = true
双栏:左列表 + 右详情

GridCol span 生效
布局重排

2) 栅格响应式:卡片列数跟着断点走

城市卡片列表用 GridRow(12 栅格)+ GridCol 实现。列数不写死,而是按断点算 span:lg 每卡占 4 列 → 三列;md 占 6 → 两列;sm 占 12 → 单列。

private cardSpan(): number {
  const bp: Breakpoint = this.effectiveBp();
  if (bp === 'lg') return 4;   // 12 栅格下每卡占 4 → 三列
  if (bp === 'md') return 6;   // 两列
  return 12;                   // 单列
}
// GridCol({ span: this.cardSpan() }) { this.CityCard(c) }

3) 布局形态:双栏还是单栏,只由一个判断决定

真正的"一码多形"发生在主布局。我用同一个 isWide() 判断,让页面在两种结构间切换:

build() {
  Column() {
    this.TopBanner()
    if (this.isWide()) {
      // 宽屏:左列表 + 右详情,双栏并排
      Row() {
        this.CityListColumn(360)
        this.DetailPanel()
      }
    } else {
      // 窄屏:列表与详情互斥,选中即进入全屏详情
      if (this.hasSelection()) {
        this.DetailPanel()
      } else {
        this.CityListColumn(0)
      }
    }
  }
}

注意这里没有两套页面,只有一个 DetailPanel()。在宽屏它常驻右侧,在窄屏它变成全屏详情——因为 DetailPanel 内部用 @Prop 接收数据和"返回/打卡"回调,自身不关心自己被放在哪里。业务状态(TourState:哪些城市打卡了)始终只有一份,存在页面 @State 里。

为了方便没有折叠屏/平板的人也能看效果,我在顶部加了个"演示切换"按钮,循环在 自动(真实窗口) → sm → md → lg 之间强制断点——实机按真实窗口宽度自适应,演示时一键切形态,截图写文章都方便。

4) 工程配置:deviceTypes 别漏了多端

要让应用真的能装到平板和折叠屏上,module.json5 的 deviceTypes 必须包含对应类型。一开始我只写了 phone,后来补成:

"deviceTypes": [ "phone", "tablet", "2in1" ]

(这里有个坑,见下一节。)


三、踩坑实录(都是真机/模拟器上撞的)

1. deviceTypes 里没有 foldable。 我第一版写的是 ["phone","tablet","foldable"],编译直接 00303038 Configuration Error,schema 校验不过。API 19 的枚举里折叠屏形态叫 2in1(二合一/折叠屏),不是 foldable(foldable 是更新 SDK 才有的值)。改完立刻过。

2. ArkTS 严格模式下,@Builder 里不能写 return;。 我一开始在 DetailPanel 里这么写:

if (!this.hasSelection() || !this.currentCity()) {
  Column() { ... }
  return;          // ❌ @Builder 函数体必须是 UI 描述,不能有命令式 return
}
CityDetail({ ... })

编译器报 Only UI component syntax can be written here。改成 if / else 包裹就对了——空态和详情各占一个分支,不要裸 return。

3. @Builder / build 里不能声明 const。 声明式 UI 的构建作用域里不允许局部变量,需要的中间值一律抽成页面的普通方法(如 currentCity()、effectiveBp()),在表达式里直接调用。

4. 构造函数的参数属性不支持。 我原本写 constructor(private onChange: (bp) => void) {},报 Declaring fields in "constructor" is not supported。改成"字段声明 + 构造函数里赋值"就好。

5. 方法名别和基类撞车。 我最初把"卡片跨度"方法命名为 gridSpan(),编译报"与基类 CustomComponent 的同名属性不兼容"。改成 cardSpan() 解决。这类命名冲突在 ArkUI 里不止一处,起名时稍微避一下 grid*、row* 之类的前缀更稳。


四、关键结论与感悟

做完这个 demo,我对"全场景"的理解从"多端适配"变成了一句更具体的话:全场景 = 一份业务状态 + 一套断点调度的布局策略。

状态(哪些城市打卡了、当前选中谁)只有一份,放在页面或全局状态里;布局只是状态的"投影",由断点决定投影方式。这样的好处是:业务迭代只改状态层,多端展示几乎不用动。反过来,如果一开始就把"平板布局""手机布局"写成两份独立页面,状态一旦要加字段,两边都要改,迟早裂开。

给做全场景适配的同学一个可复用 Checklist:

  1. 用 mediaquery 监听窗口宽度,别监听物理分辨率;
  2. 断点换算(width→sm/md/lg)抽成纯函数,便于单测与复用;
  3. 栅格 GridRow/GridCol 的 span 随断点变化,别写死列数;
  4. 双栏/单栏用一个判断切换,不要维护两套页面;
  5. 详情等"子视图"用 @Prop + 回调解耦,让它不知道自己被放在左栏还是全屏;
  6. deviceTypes 按目标设备填全(手机 phone、平板 tablet、折叠屏 2in1);
  7. 严格 ArkTS 下:@Builder/build 内不写 const、不写 return,方法命名避开基类保留词;
  8. 没有多设备时,用"强制断点"按钮做演示与截图,效果一样能讲清楚。

五、附:用 hypium 单测断点纯函数

breakpointOf 不依赖任何 UI,直接可以测。示例(hypium):

import { describe, it, expect } from '@ohos/hypium';
import { breakpointOf } from '../main/ets/common/BreakpointSystem';

describe('BreakpointSystem', () => {
  it('width < 600 → sm', 0, () => {
    expect(breakpointOf(320)).assertEqual('sm');
    expect(breakpointOf(599)).assertEqual('sm');
  });
  it('600..839 → md', 0, () => {
    expect(breakpointOf(600)).assertEqual('md');
    expect(breakpointOf(839)).assertEqual('md');
  });
  it('>= 840 → lg', 0, () => {
    expect(breakpointOf(840)).assertEqual('lg');
    expect(breakpointOf(1280)).assertEqual('lg');
  });
});

六、工程结构速查

entry/src/main/ets/
├─ common/BreakpointSystem.ets   # 断点纯函数 + mediaquery 监听(零业务依赖,可单测)
├─ model/CityData.ets           # 12 座城市数据 + 打卡状态纯逻辑(toggleVisited 等)
├─ pages/Index.ets              # 主页面:断点调度 + 栅格 + 双栏/单栏 + 演示切换
└─ entryability/EntryAbility.ets# 标准入口

代码均已在 HarmonyOS 5.1.1(API 19)环境编译通过(assembleHap BUILD SUCCESSFUL),设计对齐 HarmonyOS 7 的「一码多形 / 一次开发多端部署」理念。折叠屏、平板、手机三种形态,一套代码全包了。

Logo

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

更多推荐