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. 断点越多越好吗?

不是。断点过多会增加分支和测试成本。只在内容结构确实需要变化的位置增加断点。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐