平行视界 1:1、1:2、2:1:页面比例改变后 UI 还需要怎么适配【鸿蒙心迹】

👋 你好,欢迎来到我的博客!我是【菜鸟学鸿蒙】
我是一名在路上的移动端开发者,正从传统“小码农”转向鸿蒙原生开发的进阶之旅。为了把学习过的知识沉淀下来,也为了和更多同路人互相启发,我决定把探索 HarmonyOS 的过程都记录在这里。
🛠️ 主要方向:ArkTS 语言基础、HarmonyOS 原生应用(Stage 模型、UIAbility/ServiceAbility)、分布式能力与软总线、元服务/卡片、应用签名与上架、性能与内存优化、项目实战,以及 Android → 鸿蒙的迁移踩坑与复盘。
🧭 内容节奏:从基础到实战——小示例拆解框架认知、专项优化手记、实战项目拆包、面试题思考与复盘,让每篇都有可落地的代码与方法论。
💡 我相信:写作是把知识内化的过程,分享是让生态更繁荣的方式。
如果你也想拥抱鸿蒙、热爱成长,欢迎关注我,一起交流进步!🚀
前言
接入平行视界之后,很多开发者认为双页面能正常显示就算完成了。但实际上,平行视界的核心挑战不在于"能不能分屏",而在于左右两侧的页面能否在比例持续变化时维持合理的信息密度和布局结构。
比例从 1:1 变成 1:2,右侧内容区突然宽了一倍,如果布局依赖固定宽度,排版就会塌掉;比例变成 2:1,左侧列表宽到不知道该怎么填充。这篇文章专门处理这个问题——不是讲怎么接入平行视界,而是讲接入之后 UI 到底还需要做什么。
一、平行视界支持的页面比例
平行视界(Parallel Vision)是 HarmonyOS 在折叠屏(Foldable)设备展开态下提供的多窗口并行能力。用户或系统可以在以下三种比例之间切换:
| 比例 | 含义 |
|---|---|
| 1:1 | 左右页面宽度相等,各占展开屏约 50% |
| 1:2 | 右侧页面宽度约为左侧的两倍 |
| 2:1 | 左侧页面宽度约为右侧的两倍 |
这三种比例并非开发者指定,而是由用户在分屏交互中拖动分割线产生的结果。用户也可以在拖动过程中产生连续的中间宽度值,布局必须能够响应这种连续变化,而不是只对三个"固定档位"做处理。
从开发视角看,每一侧页面实际上是一个独立的 UIAbility 窗口,其可用宽度由系统分配,会随着分割线位置变化而动态改变。
二、为什么不能用固定宽度
这里比较容易出现的理解偏差是:把平行视界当成"宽屏适配",然后用某个固定值(例如 width: 360)来定义两侧的宽度。
问题在于,平行视界下每一侧的实际可用宽度是由系统分配并动态传递给应用的。开发者无法提前知道这个值,也无法保证它是某个固定数字。
HarmonyOS 官方推荐的做法是:使用断点(Breakpoint) 机制配合响应式布局,监听窗口尺寸变化事件,根据当前窗口宽度动态决定布局结构,而不是硬编码宽度值。
核心接口是 windowSizeChange 事件,通过 window.on('windowSizeChange', callback) 监听,也可以通过 ArkUI 的 GridRow / GridCol 组件内置的断点系统自动响应。
三、搭一个最小实践场景
用一个"列表 + 编辑区"的经典主从布局来演示适配思路。
目标:
- 左侧是一个联系人列表(List)
- 右侧是一个编辑/详情区(Form)
- 在 1:1 时,左右各占一半,两侧都正常显示
- 在 1:2 时,右侧宽度增大,编辑区应扩展为多列或增加辅助信息
- 在 2:1 时,左侧宽度增大,列表应展示更多列或辅助文字
这个场景的关键并不是写一个"自适应 Flex"那么简单,而是需要在布局层面对宽度变化做出有意义的响应——不只是"拉伸填满",而是在信息层面上有所调整。
需要的配置前提:
应用工程已经在 module.json5 中配置了支持折叠屏设备(deviceTypes 包含 "phone" 或不限制),并且该能力基于 HarmonyOS 标准 SDK,API Level 建议使用 API 10 及以上。
四、核心代码实现
4.1 通过 windowSizeChange 获取当前窗口宽度,转换为断点
下面这段代码在 Ability 的 onWindowStageCreate 中注册窗口尺寸监听,并将当前断点存入 AppStorage,供全局页面读取。
// EntryAbility.ets
import { UIAbility, Want, AbilityConstant } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
export default class EntryAbility extends UIAbility {
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.getMainWindow().then((mainWindow: window.Window) => {
// 先获取初始尺寸
const size = mainWindow.getWindowProperties().windowRect;
this.updateBreakpoint(size.width);
// 监听后续变化
mainWindow.on('windowSizeChange', (windowSize: window.Size) => {
this.updateBreakpoint(windowSize.width);
});
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
console.error('loadContent failed: ' + JSON.stringify(err));
}
});
});
}
private updateBreakpoint(windowWidth: number): void {
// vp 换算:1vp = 当前设备像素密度对应的实际像素
// 这里直接使用 px,实际项目中建议换算为 vp 后再划分断点
// 以下断点划分仅为示例,具体值需根据实际折叠屏分辨率调整
let bp: string;
if (windowWidth < 600) {
bp = 'sm';
} else if (windowWidth < 840) {
bp = 'md';
} else {
bp = 'lg';
}
AppStorage.setOrCreate<string>('currentBreakpoint', bp);
}
}
这段代码真正需要关注的是两个地方:
getWindowProperties().windowRect.width拿到的是像素值,不是 vp。折叠屏展开态分辨率较高,建议在比较前先除以设备像素密度比(可通过display.getDefaultDisplaySync().densityPixels获取)换算为 vp,再与断点阈值比较。- 必须同时处理初始尺寸和变化事件,否则应用启动时断点是空的,页面初次渲染会出现布局异常。
4.2 列表侧:根据断点调整列数和条目内容
左侧列表在 2:1 时宽度较大,如果还是单列窄条目,视觉上会非常空旷。
// ContactList.ets
@Component
struct ContactList {
@StorageProp('currentBreakpoint') currentBreakpoint: string = 'md';
build() {
Column() {
// 2:1 时左侧较宽,展示双列列表
if (this.currentBreakpoint === 'lg') {
Grid() {
ForEach(contactData, (item: ContactItem) => {
GridItem() {
ContactCard({ contact: item, showDetail: true })
}
})
}
.columnsTemplate('1fr 1fr')
.rowsGap(8)
.columnsGap(8)
.padding(12)
} else {
// 1:1 或 1:2 时左侧较窄,单列列表
List({ space: 8 }) {
ForEach(contactData, (item: ContactItem) => {
ListItem() {
ContactCard({ contact: item, showDetail: false })
}
})
}
.padding({ left: 12, right: 12 })
}
}
.width('100%')
.height('100%')
}
}
这里的逻辑重点不是"Grid vs List"本身,而是showDetail这个开关——在左侧较宽时,条目内可以额外显示联系人的邮箱、标签等辅助信息;在左侧较窄时只显示姓名和头像。信息密度的调整,比单纯改变列数更有意义。
4.3 右侧编辑区:1:2 时扩展为多列表单
// EditPanel.ets
@Component
struct EditPanel {
@StorageProp('currentBreakpoint') currentBreakpoint: string = 'md';
build() {
Scroll() {
Column() {
// 1:2 时右侧较宽,表单字段分两列排布
if (this.currentBreakpoint === 'lg') {
GridRow({ columns: 2, gutter: { x: 16 } }) {
GridCol() { FormField({ label: '姓名', placeholder: '请输入姓名' }) }
GridCol() { FormField({ label: '电话', placeholder: '请输入电话' }) }
GridCol() { FormField({ label: '邮箱', placeholder: '请输入邮箱' }) }
GridCol() { FormField({ label: '公司', placeholder: '请输入公司' }) }
// 备注字段在宽屏下跨两列
GridCol({ span: 2 }) {
FormField({ label: '备注', placeholder: '请输入备注', multiLine: true })
}
}
.padding(16)
} else {
// 1:1 时右侧标准宽度,单列表单
Column({ space: 12 }) {
FormField({ label: '姓名', placeholder: '请输入姓名' })
FormField({ label: '电话', placeholder: '请输入电话' })
FormField({ label: '邮箱', placeholder: '请输入邮箱' })
FormField({ label: '公司', placeholder: '请输入公司' })
FormField({ label: '备注', placeholder: '请输入备注', multiLine: true })
}
.padding(16)
}
}
}
.width('100%')
.height('100%')
}
}
GridRow / GridCol 是 ArkUI 提供的栅格布局组件,span 属性控制某个字段跨列数。在 1:2 场景下,宽屏表单用双列排布可以避免字段之间留白过大,体验更接近 PC 端表单。
4.4 分割线拖动过程中的连续响应
分割线拖动时,windowSizeChange 事件会连续触发,断点值可能快速在 md 和 lg 之间来回切换。如果每次状态变化都立刻触发完整的布局重建,会产生明显的抖动。
一个实用的处理方式是引入防抖(debounce)机制,在连续事件中只响应稳定后的最终值:
// 在 EntryAbility 中加入简单防抖
private debounceTimer: number = -1;
private updateBreakpoint(windowWidth: number): void {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
let bp: string;
const widthVp = windowWidth / AppStorage.get<number>('densityPixels')!;
if (widthVp < 600) {
bp = 'sm';
} else if (widthVp < 840) {
bp = 'md';
} else {
bp = 'lg';
}
AppStorage.setOrCreate<string>('currentBreakpoint', bp);
}, 50); // 50ms 内连续触发只处理最后一次
}
这里的 50ms 是一个参考值,实际项目中建议根据目标设备帧率和交互体验实际调整。
五、几个值得单独拆开的问题
5.1 断点阈值的单位必须统一
windowSizeChange 返回的 Size.width 是物理像素,而 ArkUI 中大多数布局单位是 vp(virtual pixel)。折叠屏设备的像素密度通常在 2~3 之间,如果直接用像素值与 600、840 比较,结果会完全错误。
正确做法是先除以 densityPixels 换算为 vp 后再判断断点。densityPixels 可以通过 display.getDefaultDisplaySync().densityPixels 获取,建议在应用启动时存入 AppStorage。
5.2 @StorageProp 的默认值不能只是占位符
@StorageProp('currentBreakpoint') currentBreakpoint: string = 'md' 中的默认值 'md' 会在 AppStorage 中没有该 key 时生效。如果 EntryAbility 里的初始化代码在页面渲染前没有正确执行,页面就会一直停在默认断点状态。
排查这类问题时,优先检查 onWindowStageCreate 中是否在 loadContent 之前完成了断点初始化——注意 getMainWindow() 是 Promise,要确保 setOrCreate 在 Promise resolve 之后、loadContent 回调前执行。
5.3 两侧窗口的断点是各自独立的
平行视界下,左右两侧是各自独立的 UIAbility 实例,各自有各自的 Window 对象。每一侧只能监听到自己的窗口尺寸变化,无法感知对侧的宽度。所以不要在一侧页面里试图通过"猜测"对侧宽度来做布局判断,各自监听各自的 windowSizeChange 即可。
六、容易踩坑的地方
一、把 windowSizeChange 注册在页面级而非 Ability 级
如果在 @Entry 页面的 aboutToAppear 里注册 windowSizeChange,而不是在 EntryAbility 里,会遇到一个问题:页面跳转导致监听解除后,新页面无法收到宽度变化。建议统一在 Ability 级管理监听,通过 AppStorage 向所有页面分发。
二、忘记在页面销毁时解除监听
mainWindow.on('windowSizeChange', callback) 注册后需要在对应生命周期中调用 mainWindow.off('windowSizeChange', callback) 解除,否则可能出现内存泄漏或回调作用于已销毁的 Ability。
三、GridRow 的 columns 参数不适合直接用来区分比例
GridRow 的 columns 属性支持按断点设置不同列数,但如果应用整体断点划分不够细,可能无法覆盖所有中间状态。建议同时使用 columns 的对象形式和监听机制双保险。
七、排查思路
遇到平行视界比例变化后布局异常,建议按以下顺序排查:
-
确认设备和系统版本:平行视界能力仅在折叠屏展开态下生效,确认当前设备支持该形态,HarmonyOS 版本满足 API Level 要求(API 10+)。
-
确认断点是否正确更新:在
AppStorage里打印currentBreakpoint,确认三种比例下值是否按预期改变。如果值不变,回查windowSizeChange是否正确注册,以及单位换算是否正确。 -
确认页面是否正确响应
@StorageProp变化:@StorageProp绑定的变量变化后,对应@Builder或条件渲染块应该自动触发重建,如果没有,检查是否缺少状态装饰器或数据绑定层级错误。 -
检查是否存在固定宽度硬编码:全局搜索页面代码中
width: xxx形式的固定 vp 值,特别是关键容器组件,改为'100%'或响应式计算值。 -
检查防抖逻辑是否过于激进:如果防抖延迟设置太长,拖动结束后布局刷新会有明显延迟感,建议控制在 50ms 以内。
开发经验总结
- 平行视界适配的核心不是"分屏能显示",而是三种比例下布局各自有合理的信息密度——拉伸填充和真正适配之间有明显差距。
windowSizeChange返回的是物理像素,务必换算为 vp 后再判断断点,否则断点阈值会完全失效。- 左右两侧 Ability 独立监听各自窗口,不要跨侧感知宽度。
- 分割线拖动会产生连续事件流,加入防抖可以避免布局频繁重建带来的抖动。
- 如果你正在做平行视界适配,可以重点看一下三种比例切换时页面是否存在依赖固定宽度的容器——这类问题往往在单一比例下测试时发现不了,只有连续拖动分割线才会暴露出来。
📝 写在最后
如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!
我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!
感谢你的阅读,我们下篇文章再见~👋
✍️ 作者:菜鸟不学编程
🧵 本文原创,转载请注明出处。
更多推荐




所有评论(0)