多设备布局封面

HarmonyOS 应用做多设备适配,最容易出现一种“看起来已经适配”的假象:项目里有 smmdlg 三档断点,也写了底部导航和侧边导航,但真正改变窗口宽度时,外层导航没有切换;部分内容页已经变成三列,另一些页面仍保持手机布局;底部按钮看似有安全间距,却没有把系统导航指示区算进去。代码存在不等于路径可达,多设备布局必须从“窗口信号、共享状态、布局决策、组件重排、安全区”整条链路逐项核验。

中国方言题库的真实源码已经具备一套可分析的基础:BreakpointSystem 用媒体查询监听窗口宽度,把断点写入 AppStorage;首页、题库、考试和统计页面再结合 onAreaChange 得到内容区实际宽度;题库详情页在宽屏时切成左右双栏;EntryAbility 监听避让区变化,给顶部和底部导航提供安全距离。与此同时,源码里也保留了一个非常典型的条件分支问题,导致主框架的侧边导航实际上不可达。本文面向 HarmonyOS 5.0 及以上版本,既讲清已经成立的适配能力,也明确指出不能被包装成“已经完成”的部分。

本文唯一核验标记:断点负责选形态,内容宽度负责守住可用空间

一、本文复核的真实源码范围

核心文件包括:

libraryb/src/main/ets/utils/BreakpointSystem.ets
entry/src/main/ets/entryability/EntryAbility.ets
entry/src/main/ets/pages/Index.ets
entry/src/main/ets/views/HomePage.ets
entry/src/main/ets/views/BankListPage.ets
entry/src/main/ets/views/ExamTab.ets
entry/src/main/ets/pages/BankDetailPage.ets
entry/src/main/ets/pages/CategoryPage.ets
entry/src/main/ets/pages/LearningStatsPage.ets
entry/src/main/ets/pages/ExamResultPage.ets
entry/src/main/ets/common/components/TopBar.ets
librarya/src/main/ets/constants/ThemeConstants.ets

HakkaBankPage.ets 等地区题库页没有复制一套适配逻辑,而是复用 BankDetailContent({ fixedBankId: 'b_hakka' })。这意味着题库详情的单列、双列和底部安全区行为能够被各地区入口共同继承。

需要先说明边界:源码没有基于设备型号直接判断“这是平板”或“这是 PC”,而是依据当前窗口宽度选择布局。对支持自由窗口、分屏和 2in1 调整窗口大小的场景,这比设备类型判断更可靠;但文章不能据此宣称已经在所有真机形态上完成测试。

二、为什么多设备适配要看窗口,而不是只看设备名称

同一台平板可能全屏运行,也可能只占半屏;同一台 2in1 设备可能从宽窗口缩到接近手机宽度;折叠屏在折叠、展开和分屏后,可用区域也不同。如果代码只在启动时读取一次设备类型,那么窗口变化后布局不会自动重算。

中国方言题库把判断拆成两类信号:

全局窗口断点 currentBreakpoint
  -> sm / md / lg
  -> 决定导航形态和大类布局

页面内容宽度 pageWidth
  -> onAreaChange 实时更新
  -> 决定当前内容区是否真的放得下多列

这种组合比单独使用断点更稳。主框架切成侧边栏后,内容区宽度会扣掉侧栏;如果页面只看到整个窗口是 lg,却不知道自己的内容区只剩多少,就可能在狭窄区域强行排三列。源码中多处使用 currentBp === 'lg' && pageWidth >= 700,正是在做第二层保护。

窗口信号到布局重排流程

三、BreakpointSystem 如何把窗口变化变成共享状态

BreakpointSystem.ets 定义了三档媒体查询:

BreakpointSystem.smListener =
  mediaquery.matchMediaSync('(width<=600vp)')
BreakpointSystem.mdListener =
  mediaquery.matchMediaSync('(600vp<width<=840vp)')
BreakpointSystem.lgListener =
  mediaquery.matchMediaSync('(840vp<width)')

对应关系是:

sm:0~600vp
md:600~840vp
lg:840vp 以上

每个监听器都订阅 change 事件,匹配后调用同一个 update()

private static update(bp: BreakpointType): void {
  BreakpointSystem.currentBp = bp
  AppStorage.setOrCreate<string>('currentBreakpoint', bp)
}

页面通过:

@StorageLink('currentBreakpoint') currentBp: string = 'sm'

共享断点值。窗口越过阈值时,不需要每个页面重复注册媒体查询,AppStorage 的变化会驱动使用该状态的组件重新计算布局。这是当前工程中比较清晰的职责边界:基础能力层监听窗口,页面层只消费布局状态。

register() 还会在注册监听后立即检查三个 listener 的 matches,设置首个断点。因此页面不是必须等到用户第一次改变窗口后才拿到正确值。

四、注册和注销必须跟随 Ability 生命周期

EntryAbility.onCreate() 中调用:

BreakpointSystem.register()

onDestroy() 中调用:

BreakpointSystem.unregister()

这两个动作成对出现很重要。如果只注册不注销,Ability 销毁后监听器仍可能保留;重新进入时又注册一遍,就会产生重复回调和难以定位的状态更新。当前 unregister() 对三档 listener 都调用 off('change'),生命周期闭环是成立的。

不过,当前实现的 listener 字段在注销后没有显式置为 null。这不等于已经发生泄漏,因为回调已解除,但如果后续要支持重复注册、Ability 重建或自动化测试,建议再增加“是否已注册”的幂等保护,并在注销后清空引用。文章只把它作为维护建议,不把尚未出现的风险说成线上故障。

五、主框架的真实问题:侧边导航分支目前不可达

Index.ets 同时实现了手机底部导航 BottomNavItem 和宽屏侧边导航 SideNavItem。从组件结构看,设计意图很明确:

手机:内容区 + 底部五项导航
平板/折叠屏:80vp 侧边导航 + 内容区

但真正决定分支的代码是:

if (this.currentBp === 'sm' ||
    this.currentBp === 'md' ||
    this.currentBp === 'lg') {
  // 手机模式:底部导航
} else {
  // 平板/折叠模式:侧边导航 + 内容区
}

BreakpointType 只有 smmdlg 三个合法值,所以这个条件对所有正常断点都为真,else 永远不会执行。也就是说,源码虽然写了侧边导航,但当前运行路径下,lg 仍会进入底部导航。

这是多设备文章必须如实披露的地方。不能因为代码仓库里存在 SideNavItem() 就宣称平板已切换侧栏。更合理的条件应该根据产品策略明确写出,例如:

if (this.currentBp === 'sm') {
  // 手机底部导航
} else {
  // md、lg 使用侧边导航
}

或者中等宽度仍使用底部导航、大屏使用侧边导航:

if (this.currentBp !== 'lg') {
  // sm、md 使用底部导航
} else {
  // lg 使用侧边导航
}

选择哪一种不是语法问题,而是交互策略问题。折叠屏展开态是否立即切侧栏,需要结合实际窗口宽度、导航项数量和可触达性验证。当前文章不修改已上架应用源码,只记录可复核的现状与修正方向。

六、为什么页面还要记录 pageWidth

首页、题库列表、考试页、学习统计和题库详情都使用了相似模式:

@State pageWidth: number = 360

.onAreaChange((oldArea: Area, newArea: Area) => {
  const width = Number(newArea.width)
  if (width > 0) {
    this.pageWidth = width
  }
})

随后通过:

private useGridLayout(): boolean {
  return this.currentBp === 'lg' && this.pageWidth >= 700
}

决定是否进入网格布局。这里有两个门槛:

  1. 全局窗口已经进入 lg
  2. 当前页面内容区仍至少有 700vp。

如果未来修复主框架侧栏,窗口宽度可能是 850vp,但扣除 80vp 侧栏及边界后,内容区会变窄。pageWidth >= 700 可以避免三列卡片被挤得过窄。这个判断不是重复,而是对组件真实可用空间的校验。

七、首页如何在列表与三列题库之间切换

HomePage 的推荐题库区域在宽布局下使用:

Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
  ForEach(BANKS, (bank: Bank) => {
    Column() {
      BankCard({ bank: bank })
    }
    .width('32%')
    .margin({ bottom: 12 })
  })
}

窄布局下则使用纵向 Column

Column({ space: 12 }) {
  ForEach(BANKS, (bank: Bank) => {
    BankCard({ bank: bank })
  })
}

三列卡片使用 32% 而不是精确固定宽度,给 SpaceBetween 留出列间空隙;窄屏复用同一个 BankCard,没有为手机和大屏复制两套业务组件。布局容器变化,数据和点击行为保持一致,这种方式比维护两套页面更容易保证功能同步。

不过,首页的题型分类区域固定使用每项 23%,在当前手机界面是四列设计,在大屏上仍只是四列并被拉宽,并没有随窗口增加更多列。它具备比例适配和自动换行,不等于充分利用大屏。文章应把“不会溢出”和“宽屏信息密度优化”区分开。

八、题库列表和考试页如何提升大屏信息密度

BankListPage 在窄屏时使用 List

List({ space: 12 }) {
  ForEach(this.filteredBanks(), (bank: Bank) => {
    ListItem() {
      BankCard({ bank: bank })
    }
  })
}

大屏时切换为三列 FlexExamTab 的考试入口也采用相同策略:窄屏逐项排列,宽屏三列。两处都保持卡片内部按钮、图片和数据逻辑不变,只改变外层排列。

这种做法适合“同一信息单元在不同宽度下改变密度”的页面。相比把整个手机页面等比放大,它能让平板和 2in1 同屏展示更多题库,减少纵向滚动距离。

LearningStatsPage 还进一步调整概览区:

窄屏:四项指标分成 2 × 2
宽屏:四项指标放在同一 Row

同时,各题库进度从单列切成三列。这里不仅卡片数量变化,指标结构也重新编排,属于真正的响应式重排。

九、题库详情页为何使用双滚动列

BankDetailContent 的宽屏判断是:

private useWideLayout(): boolean {
  return this.currentBp === 'lg' && this.pageWidth >= 700
}

宽屏时,内容被拆为左右两列:

左侧 38%
  HeroCard
  BankSummaryCard
  ProfileCard

右侧 layoutWeight(1)
  FocusCard
  ChapterSection

两列各自放在 Scroll 中,并设置 height('100%')。这样右侧章节很多时,用户可以独立滚动章节,不必让左侧介绍区域反复离开视野。左侧设置 38%,右侧用剩余空间,宽度关系是弹性的。

窄屏时,页面恢复为一个 Scroll,按“题库封面、学习摘要、简介、重点、章节”顺序纵向排列。业务组件完全复用,只是组合方式改变。

这里还有一个细节:Hero 图片高度在宽屏为 260vp,窄屏为 210vp。大屏不是把 210vp 的手机图生硬拉宽,而是同步增加可视高度,避免横向过宽、纵向过薄。

多设备布局的职责结构

十、CategoryPage 展示了局部宽度自适应

CategoryPage 没有订阅 currentBreakpoint,而是直接根据页面宽度设置卡片占比:

private itemWidth(): string {
  return this.pageWidth >= 720 ? '24%' : '48%'
}

窗口较窄时每行两项,较宽时每行四项。容器使用:

Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween })

因此窗口调整后,卡片会自动换行。这个页面证明多设备适配不必所有地方都依赖全局断点;对独立、可复用的小型内容区域,组件自身宽度往往是更直接的输入。

但阈值 720vp 与全局的 600/840vp 并不统一。这未必是错误:720vp 是四列卡片的内容阈值,840vp 是全局大窗口断点,两者职责不同。真正需要避免的是没有注释、没有测试矩阵的随意数字。后续可把 700720 等内容阈值集中为语义常量,例如 CONTENT_WIDE_MIN_WIDTHCATEGORY_FOUR_COLUMN_MIN_WIDTH

十一、Flex、layoutWeight 和百分比如何共同防止挤压

源码中的适配并不只靠断点,还大量使用 ArkUI 的弹性布局能力:

width('100%')
layoutWeight(1)
FlexWrap.Wrap
SpaceBetween
maxLines(1/2)
TextOverflow.Ellipsis

例如 BankDetailPage 的章节项,章节标题区域使用 layoutWeight(1),右侧操作固定为 64vp。标题过长时设置单行省略,避免把“开始/继续”按钮顶出屏幕。ExamTab 的考试卡片中,文字列同样使用 layoutWeight(1),开始按钮固定 76vp。

这类局部约束是多设备稳定性的基础。仅仅让根容器宽度为 100%,并不能保证内部长文本、按钮和图标不冲突;必须明确谁固定、谁伸缩、谁省略、谁换行。

十二、系统避让区为何必须从 px 转成 vp

EntryAbility 获取主窗口后,读取:

window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR
window.AvoidAreaType.TYPE_SYSTEM

并把顶部系统区高度、底部导航指示区高度写入 AppStorage

AppStorage.setOrCreate<number>('topAvoidAreaHeightPx', topHeight)
AppStorage.setOrCreate<number>(
  'navigationIndicatorHeightPx',
  Math.max(navigationHeight, systemHeight)
)

这些值来自窗口 API,单位是像素。页面中使用:

this.getUIContext().px2vp(this.navigationIndicatorHeightPx)

转成 vp 后再参与布局。直接把 px 当 vp 使用,会在不同像素密度设备上产生过大或过小的安全距离。

Index 的底部安全间距是:

Math.max(
  Sizes.BOTTOM_NAV_MIN_PADDING,
  this.getUIContext().px2vp(this.navigationIndicatorHeightPx)
)

BOTTOM_NAV_MIN_PADDING 在主题常量中为 28vp。也就是说,即使系统返回 0,底部仍保留至少 28vp;如果系统指示区更高,则采用真实避让高度。题库详情页和考试结果页的底部按钮也复用了同样策略。

十三、避免顶部重复留白

Index 根布局会根据 topAvoidAreaHeightPx 设置顶部 padding:

private topSafePadding(): number {
  return Math.max(
    Sizes.PADDING_SMALL,
    this.getUIContext().px2vp(this.topAvoidAreaHeightPx)
  )
}

而普通二级页的 TopBar 也会读取顶部避让区。工程维护时要特别注意:如果某个页面既被包在已经处理顶部安全区的父容器中,又让自身 TopBar 再加一遍避让,就会出现重复留白。当前主 Tab 页面由 Index 统一处理顶部区域,路由二级页则由各自顶部栏或页面处理,职责大体分开。

多设备测试时不应只看“内容有没有被状态栏遮住”,也要看是否出现多余空白、不同页面标题基线不一致、横竖屏切换后 padding 没有刷新。

十四、窗口变化后的状态链路

把当前实现串起来,可以得到两条并行链路。

第一条是断点:

窗口宽度改变
  -> mediaquery listener change
  -> BreakpointSystem.update()
  -> AppStorage.currentBreakpoint
  -> @StorageLink 页面重新计算
  -> 列表/网格或单列/双列切换

第二条是安全区:

系统避让区改变
  -> mainWindow avoidAreaChange
  -> EntryAbility 重新读取 avoid area
  -> AppStorage 写入顶部/底部 px
  -> 页面 px2vp
  -> 顶栏、底栏和操作区更新 padding

页面自身还有第三个信号:

组件实际区域改变
  -> onAreaChange
  -> pageWidth
  -> 内容阈值判断
  -> 两列/三列/四列布局

三条链路各自解决不同问题:断点负责全局形态,区域宽度负责内容可用空间,避让区负责系统 UI 不遮挡操作。

十五、当前实现中不能忽略的限制

1. 主框架侧边导航不可达

这是已经从代码条件直接确认的问题。修复前不能说 lg 已切换侧栏。

2. md 没有独立视觉策略

当前内容页通常只有“lg 且足够宽”和“其他”两种布局。md 会沿用窄屏方案。这样可以保证稳定,但没有专门利用折叠屏展开或小平板空间。

3. 各页面阈值没有集中管理

700vp、720vp 与全局断点分散在页面方法中。未来修改时容易出现某页提前切换、某页迟迟不切换的体验差异。

4. 部分固定 Row 仍需长文本测试

考试历史中的日期、首页 Hero、结果页三宫格、底部双按钮都使用固定结构。现有中文文案可以容纳,不代表更长的本地化文本或系统大字体下一定安全。

5. 未看到键盘与鼠标专属交互

布局可以在 PC/2in1 宽窗口中显示,但源码未体现键盘焦点导航、快捷键、悬停态或鼠标右键等专属能力。因此应表述为“窗口布局具备 2in1 适配基础”,而不是“完整 PC 交互已完成”。

十六、建议的最小修复顺序

第一步,先修正 Index 的分支条件,并明确 md 的产品策略。该问题影响整个应用的导航形态,优先级最高。

第二步,把内容阈值集中到布局常量:

class LayoutBreakpoints {
  static readonly CATEGORY_FOUR_COLUMNS: number = 720
  static readonly CONTENT_WIDE: number = 700
}

第三步,为外层导航和内容区建立组合测试:

599vp:sm + 底部导航 + 单列
600vp:边界值
601vp:md 策略
839vp:md 上边界
840vp:边界值
841vp:lg 策略
宽窗口扣除侧栏后内容不足 700vp
宽窗口且内容达到 700vp

第四步,检查长文本和字体缩放。测试题库名、章节名、考试历史日期和按钮文案的最长组合,确认省略不会隐藏关键动作。

第五步,在真机或模拟器上验证状态栏、导航指示区、横竖屏、分屏、自由窗口和折叠展开。代码审查只能证明条件与数据流,不能替代运行时验证。

十七、适合自动化回归的断言

多设备布局测试不应只依赖截图肉眼比较,还可以加入结构性断言:

currentBreakpoint=sm 时底部导航存在
currentBreakpoint=lg 时侧边导航存在
pageWidth<700 时题库详情只有一个滚动内容列
pageWidth>=700 且 breakpoint=lg 时出现两个滚动列
CategoryPage 719vp 时卡片宽度为 48%
CategoryPage 720vp 时卡片宽度为 24%
底部 padding 不小于 28vp
系统避让高度大于 28vp 时采用转换后的真实值

当前代码在“lg 时侧边导航存在”这一项会失败,这正是自动化测试的价值:它能识别“组件写了但分支永远进不去”的问题。

十八、性能上为什么应避免在 build 中做重计算

窗口拖拽时,媒体查询和 onAreaChange 可能连续触发。当前页面的布局判断只是字符串比较和数字比较:

this.currentBp === 'lg' && this.pageWidth >= 700

成本很低。数据列表的排序由 filteredBanks() 完成,数据量也很小。若后续题库数量大幅增加,不应在每次区域变化时重新做昂贵的数据聚合、图片处理或网络请求。响应式重排应该尽量只改变布局容器,不改变业务数据源。

同时,onAreaChange 中先判断 width > 0,避免初始化阶段把无效宽度写入状态。若窗口拖动导致回调非常密集,再考虑去抖;在没有性能证据前,不需要为简单赋值引入额外复杂度。

十九、从工程角度总结这套适配方法

中国方言题库已经形成了一个值得保留的骨架:

BreakpointSystem
  -> 集中监听 sm/md/lg

AppStorage + @StorageLink
  -> 跨页面共享断点

onAreaChange + pageWidth
  -> 以组件实际宽度做二次判断

Flex / layoutWeight / 百分比
  -> 控制局部伸缩与换行

AvoidArea + px2vp
  -> 保护顶部和底部操作区

共享业务组件
  -> 手机与大屏只重排,不复制逻辑

真正需要修正的是导航分支可达性,以及更系统的 md 策略和测试矩阵。多设备适配的质量,不由项目里出现多少个断点名称决定,而由每个断点是否能触发正确布局、内容区是否仍有足够空间、系统区域是否不会遮挡操作来决定。

二十、结语

HarmonyOS 5.0 及以上版本的多设备开发,核心不是为手机、平板、PC 分别写三套页面,而是让同一套业务组件在窗口变化时重新组合。中国方言题库的列表转三列、统计指标重排、详情双栏和安全区换算已经提供了真实基础;Index 中覆盖全部断点的条件也提醒我们,必须验证分支是否可达,不能只验证代码是否存在。

落到工程实践,可以记住一句话:断点负责选形态,内容宽度负责守住可用空间。再配合避让区、弹性布局、长文本约束和边界值测试,才能让手机、平板、折叠屏和 PC/2in1 窗口变化时,界面不是简单“能显示”,而是保持可读、可操作、可验证。

AI 辅助声明:本文在资料整理、结构梳理和语言润色环节使用了 AI 辅助;所有源码路径、断点条件、布局分支、宽度阈值与限制说明均依据项目实际 ArkTS 文件复核。

Logo

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

更多推荐