一套代码,三种形态:用「一码多形」把城市打卡做成全场景应用【鸿蒙心迹】

摘要
本文以「漫游城市」城市打卡应用为例,演示如何在 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() 决定双栏还是单栏。断点只算一次,布局各处复用,这正是「一码多形」的关键。
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:
- 用
mediaquery监听窗口宽度,别监听物理分辨率; - 断点换算(width→sm/md/lg)抽成纯函数,便于单测与复用;
- 栅格
GridRow/GridCol的span随断点变化,别写死列数; - 双栏/单栏用一个判断切换,不要维护两套页面;
- 详情等"子视图"用
@Prop+ 回调解耦,让它不知道自己被放在左栏还是全屏; deviceTypes按目标设备填全(手机phone、平板tablet、折叠屏2in1);- 严格 ArkTS 下:
@Builder/build内不写const、不写return,方法命名避开基类保留词; - 没有多设备时,用"强制断点"按钮做演示与截图,效果一样能讲清楚。
五、附:用 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 的「一码多形 / 一次开发多端部署」理念。折叠屏、平板、手机三种形态,一套代码全包了。
更多推荐


所有评论(0)