ArkUI 响应式实战:手机、平板、2in1 共用一套羽毛球工具界面

系列第 2 篇。本文聚焦一件事:不要为手机、平板、2in1 各写一套页面,而是在同一套业务页面上建立可维护的响应式外壳。

一、真实问题背景

羽毛球工具的使用场景天然跨设备:组织者可能用手机录入人员,场边可能用平板看对阵安排,2in1 设备适合横屏展示实时计分。如果只按手机竖屏设计,平板会显得空;如果按平板写死宽布局,手机又会拥挤。

当前项目在 entry/src/main/module.json5 中声明了 phonetablet2in1。这不是为了"多写几个设备名",而是让工程从配置层就承认多端体验是产品目标。

二、目标与边界

响应式适配要解决三个层次:

  1. 设备宽度变化后,页面知道自己处在哪个断点。2. 主导航在小屏用底部 Tab,大屏用侧边导航。3. 业务页面内部使用相同状态,不因为布局切换丢数据。

边界是:本文不讲折叠屏姿态、不讲跨设备流转,也不讲手表端。这些属于后续协同体验文章。本文只讲当前 App 已经落地的 ArkUI 响应式基础。

三、断点系统设计

项目把断点监听放在 common/src/main/ets/utils/BreakpointSystem.ets,而不是分散到每个页面。核心思路是:入口 Ability 初始化断点系统,断点变化写入 AppStorage('currentBreakpoint'),页面通过 @StorageProp 读取。

复制代码
private static smQuery: string = '(width<600vp)';
private static mdQuery: string = '(600vp<=width<840vp)';
private static lgQuery: string = '(840vp<=width<1080vp)';
private static xlQuery: string = '(1080vp<=width)';

BreakpointSystem.smListener = mediaquery.matchMediaSync(BreakpointSystem.smQuery);
BreakpointSystem.smListener.on('change', (result: mediaquery.MediaQueryResult) => {
  if (result.matches) {
    BreakpointSystem.updateBreakpoint(BreakpointConstants.BREAKPOINT_SM);
  }
});

这个方案的优点是低侵入。业务页面不需要自己维护窗口监听,也不需要把窗口对象层层传参。只要读取 currentBreakpoint,就能决定布局密度、边距、列数和导航方式。

四、入口页面切换导航

entry/src/main/ets/pages/Index.ets 是主壳页面。它用 @StorageLink('active_tab_index') 维护当前 Tab,用 @StorageProp('currentBreakpoint') 读取断点。

复制代码
@StorageLink('active_tab_index') currentIndex: number = 0;
@StorageProp('currentBreakpoint') currentBreakpoint: string = 'sm';

isSmall(): boolean {
  return this.currentBreakpoint === BreakpointConstants.BREAKPOINT_SM;
}

构建 UI 时,小屏走底部 Tab,大屏走左侧导航。业务内容通过 PageContent() 分发,首页、对阵、计分、排名、我的页仍然是同一批 features 组件。

复制代码
if (this.isSmall()) {
  Column() {
    this.PageContent();
    Row() {
      this.BottomTabItem(this.tabs[0], 0);
      this.BottomTabItem(this.tabs[1], 1);
      this.BottomTabItem(this.tabs[2], 2);
      this.BottomTabItem(this.tabs[3], 3);
      this.BottomTabItem(this.tabs[4], 4);
    }
  }
} else {
  Row() {
    Column() {
      this.SideNavItem(this.tabs[0], 0);
      this.SideNavItem(this.tabs[1], 1);
      this.SideNavItem(this.tabs[2], 2);
      this.SideNavItem(this.tabs[3], 3);
      this.SideNavItem(this.tabs[4], 4);
    }
    this.PageContent();
  }
}

五、业务页面如何跟随

业务页面也读取 currentBreakpoint。例如费用页、对阵页、排名页会根据断点调整 padding、最大宽度、卡片密度。我的页头像选择区域则进一步根据真实区域宽度计算列数,避免在平板上仍然按手机三列挤在一起。

相关源码包括:

  • features/src/main/ets/home/HomePage.ets- features/src/main/ets/fee/FeeInputPage.ets- features/src/main/ets/stats/StatsPage.ets- features/src/main/ets/me/MePage.ets- common/src/main/ets/utils/BreakpointConstants.ets

这类页面适配最容易踩的坑是"只改外壳,不改内容"。外壳变宽后,如果内部仍然写死宽度或用过大的字号,平板体验依旧不自然。

六、取舍与风险

当前项目选择 mediaquery + AppStorage,没有引入更复杂的设备类型判断。这样做的取舍是:宽度优先,逻辑简单,适合工具型 App;缺点是无法直接表达折叠态、悬浮窗态、外接屏状态。后续如果要做平板大屏记分牌或跨设备展示,需要增加更细的窗口上下文。

另一个风险是 @Builder 中读取响应式状态时,可能出现状态变化但局部组件不刷新的情况。项目的做法是尽量把依赖 @StorageProp 的布局判断放在主 build 分支,或者显式传参给 Builder。

七、构建验证

验证命令:

复制代码
& 'D:\HuaweiDevelopFormalStudy\DevEco Studio\tools\hvigor\bin\hvigorw.bat' assembleHap --mode module -p product=default --no-daemon

验证时间:2026-06-28。当前结果为 BUILD SUCCESSFUL。截图来自 doc/screenshots_current/phonedoc/screenshots_current/tablet,用于证明不是只在代码里写了断点,而是真的覆盖了手机和平板两种显示状态。

八、官方参考

ArkUI 响应式布局和媒体查询能力应以官方文档为准,可从 HarmonyOS ArkUI 指南 进入,并结合当前 SDK 的 @kit.ArkUI API 说明核对。

九、工程验收清单

  • module.json5 声明 phone/tablet/2in1。- BreakpointSystem 统一维护断点,不在每个页面重复监听。- 主壳页面小屏底部导航,大屏侧边导航。- 业务页面通过 @StorageProp('currentBreakpoint') 跟随布局。- 手机和平板截图都能对应到同一套源码。

十、小结

响应式布局不是把元素变大,也不是给平板加几个空白。更实用的方式是:状态统一、导航换形、内容调密度。这个羽毛球工具的实现还不复杂,但已经足够支撑后续跨设备文章:手机控制、平板展示、手表计分都可以继续复用这套状态和页面分层。

十一、下一篇衔接

下一篇讲本地优先数据方案:为什么这个 App 用 Preferences + AppStorage 管住对局、费用和设置,而不是让每个页面自己保存一份状态。