HarmonyOS厨房助手实战第8篇:一多断点、响应式导航与大屏主从布局
HarmonyOS厨房助手实战第8篇:一多断点、响应式导航与大屏主从布局
摘要
本文拆解 HarmonyOS 厨房助手从手机扩展到折叠屏、平板和 2in1 的一多适配方案。项目没有简单地把手机页面等比拉宽,而是建立 sm / md / lg 三档断点,在窄屏使用底部导航,在大屏切换侧边导航,并让食谱列表、详情和编辑页面在宽屏中形成主从双栏。
文章覆盖媒体查询监听、断点单例、生命周期解绑、泛型取值工具、导航状态复用、主从布局、固定栏宽、触控与键鼠差异,以及多设备测试清单。
一、一多适配不是把宽度改成百分比
同一个界面放到不同设备上,真正变化的不只是尺寸:
| 场景 | 用户习惯 | 合适的结构 |
|---|---|---|
| 手机竖屏 | 单手触控、逐页深入 | 底部导航、单列页面 |
| 折叠屏展开 | 双手操作、信息并排 | 更宽列表或局部双栏 |
| 平板横屏 | 浏览与编辑并重 | 主从布局 |
| 2in1 / PC | 键鼠、高信息密度 | 侧边导航、固定工具区 |
如果只是给卡片设置 width('100%'),手机界面到了大屏会出现大片空白,用户仍需频繁前进和返回。
二、建立三档断点
项目定义:
export enum BreakpointKey {
SM = 'sm',
MD = 'md',
LG = 'lg'
}
对应范围:
sm < 600vp
md 600vp <= width < 840vp
lg >= 840vp
断点值不是固定真理,应根据页面内容决定。选择 600 和 840 的依据是:
- 600vp 以下保持手机单列;
- 中等宽度可增加留白和列数;
- 840vp 以上能容纳导航 Rail 与双栏内容。
判断标准应是“布局在哪个宽度开始拥挤或浪费”,而不是机械复制某个平台数字。
三、封装 BreakpointSystem
如果每个页面都创建三组媒体查询,会产生重复代码和监听器管理问题。项目把它封装成单例:
import mediaquery from '@ohos.mediaquery';
type ChangeCallback =
(breakpoint: BreakpointKey) => void;
export class BreakpointSystem {
private static instance:
BreakpointSystem | null = null;
private smListener:
mediaquery.MediaQueryListener | null = null;
private mdListener:
mediaquery.MediaQueryListener | null = null;
private lgListener:
mediaquery.MediaQueryListener | null = null;
private callbacks: Set<ChangeCallback> =
new Set<ChangeCallback>();
private current: BreakpointKey = BreakpointKey.SM;
static get(): BreakpointSystem {
if (BreakpointSystem.instance === null) {
BreakpointSystem.instance =
new BreakpointSystem();
}
return BreakpointSystem.instance;
}
}
所有页面共享同一个当前断点和底层媒体查询。
四、注册与立即回调
注册时立即把当前值通知页面:
register(callback: ChangeCallback): void {
this.callbacks.add(callback);
callback(this.current);
if (this.smListener === null) {
this.startListening();
}
}
unregister(callback: ChangeCallback): void {
this.callbacks.delete(callback);
}
立即回调避免页面等待下一次尺寸变化才拿到断点。页面生命周期中成对注册和注销:
@State bp: BreakpointKey = BreakpointKey.SM;
private bpCallback =
(value: BreakpointKey): void => {
this.bp = value;
};
aboutToAppear(): void {
BreakpointSystem.get().register(this.bpCallback);
}
aboutToDisappear(): void {
BreakpointSystem.get().unregister(this.bpCallback);
}
回调要保存成稳定字段。若注册和注销分别写两个匿名函数,后者无法从 Set 中移除前者。
五、启动媒体查询监听
private startListening(): void {
this.smListener =
mediaquery.matchMediaSync('(max-width: 599vp)');
this.mdListener =
mediaquery.matchMediaSync(
'(600vp <= width < 840vp)'
);
this.lgListener =
mediaquery.matchMediaSync('(min-width: 840vp)');
this.smListener.on('change', result => {
if (result.matches) {
this.updateCurrent(BreakpointKey.SM);
}
});
this.mdListener.on('change', result => {
if (result.matches) {
this.updateCurrent(BreakpointKey.MD);
}
});
this.lgListener.on('change', result => {
if (result.matches) {
this.updateCurrent(BreakpointKey.LG);
}
});
if (this.lgListener.matches) {
this.updateCurrent(BreakpointKey.LG);
} else if (this.mdListener.matches) {
this.updateCurrent(BreakpointKey.MD);
} else {
this.updateCurrent(BreakpointKey.SM);
}
}
初始化时必须主动读取 matches。监听器只通知后续变化,不能代替首次判断。
六、只在值变化时通知
private updateCurrent(bp: BreakpointKey): void {
if (this.current === bp) {
return;
}
this.current = bp;
this.callbacks.forEach(callback => {
callback(bp);
});
}
尺寸变化可能频繁触发,如果断点没变就不更新页面状态。这样旋转和拖动窗口时能减少无意义重建。
七、泛型 pickByBp
对尺寸、间距和列数,可以使用通用函数:
export function pickByBp<T>(
bp: BreakpointKey,
sm: T,
md: T,
lg: T
): T {
if (bp === BreakpointKey.LG) {
return lg;
}
if (bp === BreakpointKey.MD) {
return md;
}
return sm;
}
示例:
const columns: number =
pickByBp(this.bp, 1, 2, 3);
const pagePadding: number =
pickByBp(this.bp, 16, 24, 32);
const cardWidth: string =
pickByBp(this.bp, '100%', '48%', '31%');
这个函数适合数值和简单枚举。复杂结构变化仍应在 build() 中明确分支。
八、窄屏底部导航
手机和中等宽度使用底部 Tab:
@Component
export struct BottomNavBar {
@Prop items: BottomTabItem[] = [];
@Prop selectedKey: string = TabKey.Recipe;
onChange: (key: string) => void = () => {};
build() {
Row() {
ForEach(this.items, item => {
Column({ space: ThemeSpace.xs }) {
Text(item.icon)
.fontSize(20)
Text(item.label)
.fontSize(ThemeFont.tab)
.fontColor(
item.key === this.selectedKey
? ThemeColor.tabSelected
: ThemeColor.tabUnselected
)
}
.layoutWeight(1)
.onClick(() => {
this.onChange(item.key);
})
}, item => item.key)
}
.width('100%')
}
}
底部导航适合三到五个一级入口,触控距离短。不要把大量次级功能都塞进底栏。
九、大屏侧边导航 Rail
lg 断点使用固定宽度侧栏:
@Component
export struct SideNavRail {
@Prop items: SideNavItem[] = [];
@Prop selectedKey: string = '';
onChange: (key: string) => void = () => {};
build() {
Column({ space: ThemeSpace.s }) {
ForEach(this.items, item => {
Column({ space: ThemeSpace.xs }) {
Text(item.icon)
Text(item.label)
.fontColor(
this.selectedKey === item.key
? ThemeColor.brand
: ThemeColor.textSecondary
)
}
.width('100%')
.padding({
top: ThemeSpace.m,
bottom: ThemeSpace.m
})
.onClick(() => {
this.onChange(item.key);
})
}, item => item.key)
}
.width(96)
.height('100%')
}
}
侧栏固定 96vp,右侧内容使用 layoutWeight(1) 吃掉剩余空间。固定导航宽度能减少窗口变化时的跳动。
十、同一导航状态驱动两种外观
主页面只保留一个 selectedKey:
@State selectedKey: string = TabKey.Recipe;
private switchTab(key: string): void {
this.selectedKey = key;
if (key === TabKey.Profile) {
this.profileTick += 1;
}
}
然后按断点选择容器:
if (this.bp === BreakpointKey.LG) {
Row() {
SideNavRail({
selectedKey: this.selectedKey,
onChange: key => this.switchTab(key)
})
Column() {
this.Content()
}
.layoutWeight(1)
}
} else {
Column() {
Column() {
this.Content()
}
.layoutWeight(1)
BottomNavBar({
selectedKey: this.selectedKey,
onChange: key => this.switchTab(key)
})
}
}
导航外观可以变化,业务状态不应复制两份。这样设备旋转跨过断点时,当前 Tab 不会丢失。
十一、主从双栏布局
大屏上的食谱页面适合:
左侧 42%:列表、搜索、分类
右侧 58%:详情、编辑或烹饪
示意代码:
if (this.bp === BreakpointKey.LG) {
Row() {
Column() {
this.ListPane()
}
.width('42%')
Divider()
.vertical(true)
Column() {
if (this.selectedRecipe !== null) {
RecipeDetailPage({
recipeId: this.selectedRecipe.id
})
} else {
EmptyView({
title: '请选择食谱',
hint: '从左侧列表选择一个条目'
})
}
}
.layoutWeight(1)
}
} else {
this.SinglePane()
}
主从布局减少“列表 -> 详情 -> 返回”的往返,尤其适合键鼠和横屏。
十二、跨断点状态恢复
窗口从大屏缩到手机时,要决定右侧详情怎么处理:
- 保持当前详情并切换到单页详情;
- 回到列表并记住选中项;
- 根据用户正在编辑还是浏览采用不同策略。
推荐维护明确的页面模式:
enum RecipePaneMode {
List = 'list',
Detail = 'detail',
Edit = 'edit',
Cooking = 'cooking'
}
断点只决定模式怎样呈现,不直接清空 selectedRecipeId 或表单草稿。
十三、不要让断点替代内容约束
即使是 lg,窗口也可能只有 840vp。布局还需要:
- 左栏最小宽度;
- 右栏最小宽度;
- 文本最大行数和省略;
- 卡片稳定高度;
- 工具栏可换行或折叠;
- 弹窗最大宽度。
例如:
Column() {
this.ListPane()
}
.constraintSize({
minWidth: 320,
maxWidth: 560
})
.width('42%')
只写百分比可能在临界宽度产生不可用的窄栏。
十四、触控与键鼠差异
大屏适配还应检查:
- Hover 状态;
- 焦点顺序;
- Enter 和 Space 激活;
- 滚轮滚动;
- 右键是否需要菜单;
- 文本是否可选择;
- 点击区域是否仍适合触控。
2in1 设备可能同时使用触摸和鼠标,不能因为进入 lg 就缩小所有点击目标。
十五、系统栏与安全区域
手机底部导航要考虑系统手势区域,大屏窗口模式要考虑标题栏和窗口尺寸变化。页面不要写死整屏像素高度。
推荐:
Column() {
this.Content()
}
.width('100%')
.height('100%')
并让主内容使用 layoutWeight(1),导航占自身稳定高度。
十六、断点系统的生命周期
当前单例启动后一直持有底层监听器,适合应用级使用。如果希望完全释放,需要在最后一个 callback 移除后关闭媒体查询监听:
unregister(callback: ChangeCallback): void {
this.callbacks.delete(callback);
if (this.callbacks.size === 0) {
this.stopListening();
}
}
stopListening() 中对三个 listener 调用 off('change') 并置空。是否需要这样做取决于该系统是否贯穿整个应用生命周期。
十七、多设备测试矩阵
至少覆盖:
| 设备 | 方向/窗口 | 重点 |
|---|---|---|
| 手机 | 竖屏 | 底栏、单手操作、文本换行 |
| 手机 | 横屏 | 高度不足、键盘遮挡 |
| 折叠屏 | 折叠 | 单列和状态保持 |
| 折叠屏 | 展开 | md 布局和列数 |
| 平板 | 竖屏 | 中间宽度是否浪费 |
| 平板 | 横屏 | 主从双栏 |
| 2in1 | 窗口化 | 拖动跨断点 |
| 2in1 | 最大化 | Rail、键鼠和焦点 |
关键测试动作是连续拖动窗口跨过 600vp 和 840vp,观察是否出现闪烁、状态丢失和布局重叠。
十八、常见问题排查
页面一直停在 sm
检查启动后是否读取 listener 的 matches,以及媒体查询表达式是否被当前系统版本支持。
注销监听无效
检查注册和注销是否使用同一个函数引用。
跨断点后当前 Tab 重置
不要在两个布局分支中分别声明状态,状态应提升到共同父组件。
大屏双栏右侧为空
为空时应展示明确选择提示,不要保留纯白区域。
列表滚动带动整个页面
为左右 Pane 建立独立高度约束和 Scroll 容器,避免滚动职责混乱。
十九、总结
一多适配的核心可以概括为:
断点统一监听
状态保持单一来源
导航按设备重组
宽屏利用主从关系
内容设置最小约束
生命周期正确解绑
跨设备真实测试
同一套业务页面可以在不同设备上呈现不同结构,但不应该复制业务逻辑。断点负责“怎么摆”,页面状态负责“用户正在做什么”,两者边界清晰后,适配才容易维护。
常见问题
1. md 为什么仍使用底部导航?
当前信息架构只有三个一级入口,折叠屏和平板竖屏仍适合底栏。若 md 空间充足,也可以切换紧凑 Rail,需要通过真实设备验证。
2. 是否每个页面都要监听断点?
只有结构或关键尺寸会变化的页面需要。纯内容组件可以由父组件传入布局参数。
3. 为什么不用设备类型判断?
同一设备可能窗口化、旋转或折叠。可用宽度比静态设备名称更能反映当前布局条件。
4. 断点越多越好吗?
不是。断点过多会增加分支和测试成本。只在内容结构确实需要变化的位置增加断点。
更多推荐



所有评论(0)