【中国方言题库|16】HarmonyOS ArkTS 多设备布局实战:适配手机、平板和 PC/2in1 的窗口变化

HarmonyOS 应用做多设备适配,最容易出现一种“看起来已经适配”的假象:项目里有 sm、md、lg 三档断点,也写了底部导航和侧边导航,但真正改变窗口宽度时,外层导航没有切换;部分内容页已经变成三列,另一些页面仍保持手机布局;底部按钮看似有安全间距,却没有把系统导航指示区算进去。代码存在不等于路径可达,多设备布局必须从“窗口信号、共享状态、布局决策、组件重排、安全区”整条链路逐项核验。
中国方言题库的真实源码已经具备一套可分析的基础: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 只有 sm、md、lg 三个合法值,所以这个条件对所有正常断点都为真,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
}
决定是否进入网格布局。这里有两个门槛:
- 全局窗口已经进入
lg。 - 当前页面内容区仍至少有 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 })
}
})
}
大屏时切换为三列 Flex。ExamTab 的考试入口也采用相同策略:窄屏逐项排列,宽屏三列。两处都保持卡片内部按钮、图片和数据逻辑不变,只改变外层排列。
这种做法适合“同一信息单元在不同宽度下改变密度”的页面。相比把整个手机页面等比放大,它能让平板和 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 是全局大窗口断点,两者职责不同。真正需要避免的是没有注释、没有测试矩阵的随意数字。后续可把 700、720 等内容阈值集中为语义常量,例如 CONTENT_WIDE_MIN_WIDTH 和 CATEGORY_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 文件复核。
更多推荐



所有评论(0)