【句匠|16】HarmonyOS ArkTS 多设备布局实战:适配手机、平板和 PC/2in1 的窗口变化

HarmonyOS 一套代码运行在手机、折叠屏、平板和 PC/2in1 上,真正困难的不是把某个卡片宽度改成百分比,而是建立一条稳定的窗口响应链路:窗口宽度变化后,应用要重新判断断点;断点要进入共享状态;页面还要结合自己真正拿到的内容区宽度,决定继续使用单列、切换多列,或者调整主次内容关系。状态栏、底部手势区和可滚动区域也必须一起参与计算,否则“大屏能显示”并不等于“大屏可用”。
本文基于句匠项目 D:\huawei\one18-11 的真实 ArkTS 源码展开,重点核对 libraryb/src/main/ets/utils/BreakpointSystem.ets、EntryAbility.ets、Index.ets、HomePage.ets、BankListPage.ets、ExamTab.ets、BankDetailPage.ets、CategoryPage.ets、DailyCheckInPage.ets 与 AICorrectPage.ets。
本文唯一源码标识:com.jiaweikang.one18。
先说明能力边界:源码已经实现 sm/md/lg 窗口断点、AppStorage 共享、多个内容页的实际宽度测量、宽屏三列题库与题库详情双栏,以及系统上下避让区处理;但 Index.ets 当前根导航判断覆盖了三个合法断点,侧边导航的 else 分支无法进入。AICorrectPage 和 DailyCheckInPage 也主要依赖流式宽度或固定七列,并没有完整的宽屏重排。本文不会把待修复分支写成已经上线的侧边导航能力。
一、为什么设备型号不是可靠的布局条件
同一台平板可能全屏运行,也可能处于分屏、小窗或自由窗口;折叠屏会在折叠和展开之间切换;PC/2in1 的窗口更可以被用户持续拖动。如果页面只判断“当前是不是平板”,就会出现一个典型错误:设备是平板,但应用窗口只剩 520vp,页面仍强行渲染三列,标题、按钮和说明文字互相挤压。
句匠选择按窗口宽度划分三档:
| 断点 | 源码条件 | 适合承载的布局 |
|---|---|---|
sm | width <= 600vp | 手机窄窗、单列内容、底部主导航 |
md | 600vp < width <= 840vp | 展开折叠屏、小平板、中等窗口 |
lg | 840vp < width | 大平板、PC/2in1 宽窗口 |
这比设备枚举更接近 UI 的真实约束。布局关心的是“当前能用多少空间”,不是硬件宣传名称。
二、断点系统如何监听窗口变化
真实实现位于 BreakpointSystem.ets:
export type BreakpointType = 'sm' | 'md' | 'lg'
BreakpointSystem.smListener =
mediaquery.matchMediaSync('(width<=600vp)')
BreakpointSystem.mdListener =
mediaquery.matchMediaSync('(600vp<width<=840vp)')
BreakpointSystem.lgListener =
mediaquery.matchMediaSync('(840vp<width)')
三个 MediaQueryListener 分别监听自己的范围。当窗口进入某个范围时,只在 matches 为真时更新:
BreakpointSystem.mdListener.on(
'change',
(result: mediaquery.MediaQueryResult) => {
if (result.matches) {
BreakpointSystem.update('md')
}
}
)
注册完成后,源码还会立即读取三个监听器的 matches,完成首次判断。这一点很重要。如果只监听后续 change,应用启动后窗口没有发生变化,currentBreakpoint 就可能一直停留在默认的 sm。

完整链路是:
窗口宽度变化
→ mediaquery 匹配 sm / md / lg
→ BreakpointSystem.update()
→ AppStorage 写入 currentBreakpoint
→ 页面 @StorageLink 收到变化
→ 结合内容区宽度选择单列、三列或双栏
三、为什么把断点放入 AppStorage
BreakpointSystem.update() 没有直接持有页面实例,而是写入共享状态:
private static update(bp: BreakpointType): void {
BreakpointSystem.currentBp = bp
AppStorage.setOrCreate<string>('currentBreakpoint', bp)
}
页面只需要声明:
@StorageLink('currentBreakpoint')
currentBp: string = 'sm'
Index、HomePage、BankListPage、ExamTab、LearningStatsPage 和 BankDetailPage 都使用了这个键。这样,窗口监听属于基础能力层,页面只消费结果,不必各自注册一套 mediaquery 监听。
这种设计还有两个工程收益:
- 页面进入和离开时不需要反复创建监听器;
- 多个页面对断点名称和阈值保持一致,避免一页把 720vp 当平板,另一页又把 768vp 当平板。
不过类型仍有改进空间。BreakpointSystem 已经导出了 BreakpointType,页面却普遍把 currentBp 声明为 string。建议后续统一改成:
@StorageLink('currentBreakpoint')
currentBp: BreakpointType = 'sm'
这是改进建议,不是当前源码状态。
四、监听注册与释放必须归属同一生命周期
EntryAbility.onCreate() 中完成注册:
UserDataManager.init(this.context)
AppStorage.setOrCreate<number>('currentTabIndex', 0)
AppStorage.setOrCreate<number>('topAvoidAreaHeightPx', 0)
AppStorage.setOrCreate<number>('navigationIndicatorHeightPx', 0)
BreakpointSystem.register()
onDestroy() 则释放:
BreakpointSystem.unregister()
unregister() 对三个监听器调用 off('change')。注册与释放对称,可以避免 Ability 生命周期结束后继续保留无效回调。
当前实现仍有一个可维护性细节:register() 没有先检查是否已经注册。如果未来生命周期调整或其他入口重复调用,可能创建新监听器并覆盖静态引用。更稳妥的版本可以先 unregister(),或者增加 registered 标记。文章只把它列为防重复注册建议,不声称源码已经处理。
五、全局断点与局部宽度要同时成立
只用 currentBp === 'lg' 还不够。根容器可能有侧栏、分栏、外边距或系统避让,某个子页面真正得到的内容宽度会小于整个窗口宽度。

句匠多个页面采用“双条件”:
@State pageWidth: number = 360
private useGridLayout(): boolean {
return this.currentBp === 'lg' && this.pageWidth >= 700
}
页面根容器再用 onAreaChange 记录真实宽度:
.onAreaChange((oldArea: Area, newArea: Area) => {
const width = Number(newArea.width)
if (width > 0) {
this.pageWidth = width
}
})
这是一层很实用的保护。即使全局窗口大于 840vp,只要当前内容区因为其他布局只剩 680vp,就仍保持单列,不会勉强塞入三列卡片。
六、题库列表如何从单列切到三列
BankListPage 的窄屏分支使用 List:
List({ space: 12 }) {
ForEach(this.filteredBanks(), (bank: Bank) => {
ListItem() {
BankCard({ bank: bank })
}
.padding({
left: Sizes.PADDING_LARGE,
right: Sizes.PADDING_LARGE
})
})
}
当 currentBp === 'lg' 且内容宽度至少 700vp 时,切换成可换行 Flex:
Flex({
wrap: FlexWrap.Wrap,
justifyContent: FlexAlign.SpaceBetween
}) {
ForEach(this.filteredBanks(), (bank: Bank) => {
Column() {
BankCard({ bank: bank })
}
.width('32%')
.margin({ bottom: 12 })
})
}
三列使用 32%,剩余空间由 SpaceBetween 分配为列间距。这个方案没有依赖固定像素宽度,窗口继续增大时卡片可以平滑变宽。
但百分比网格也有验证重点:BankCard 内部标题、标签、按钮必须使用 layoutWeight、maxLines 和 textOverflow,否则外层三列成立,内部仍可能溢出。响应式不是只改最外层容器。
七、考试入口复用同一套宽屏判定
ExamTab 同样保存 pageWidth,并用相同的 useGridLayout() 规则。窄屏使用纵向 Column,宽屏使用三列 Flex。这说明项目没有把多设备处理局限在首页,而是把题库选择这一类重复信息架构统一成“窄屏单列、宽屏三列”。
它的卡片本体仍是横向 Row:
Row({ space: 12 }) {
Image(bank.cover)
.width(62)
.height(62)
Column({ space: 5 }) {
Text(bank.name + ' 模拟卷')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
GreenButton({ text: '开始' })
.width(76)
}
这里的关键不是把所有尺寸都放大,而是维持信息层级:封面与操作按钮保持稳定,标题区域用 layoutWeight(1) 吸收变化,并用单行省略保护极端宽度。
八、题库详情页为什么更适合双栏
列表页适合增加列数,详情页则更适合建立主次栏。BankDetailPage 在宽屏条件成立时,左侧滚动区占 38%,用于封面、进度与简介;右侧区域承载章节列表。窄屏则回到单个纵向 Scroll。
这种转换不是简单把手机页面拉宽,而是重新组织阅读顺序:
手机:
封面 → 进度 → 简介 → 标签 → 章节
宽屏:
左栏:封面 + 进度 + 简介
右栏:章节与操作
源码中的宽屏封面高度也从 210 调整到 260:
.height(this.useWideLayout() ? 260 : 210)
双栏布局需要关注独立滚动。源码左右区域分别承载内容,右侧章节较长时不能让整个界面高度无限增长;底部操作还要保留系统手势避让。
九、分类页用局部宽度切换两列与四列
CategoryPage 没有读取全局断点,而是直接根据自己的 pageWidth 计算卡片宽度:
private itemWidth(): string {
return this.pageWidth >= 720 ? '24%' : '48%'
}
配合换行 Flex,窄屏显示两列,宽屏显示四列。这个页面的需求简单,局部宽度已经足够表达,不必为了形式统一强行订阅全局断点。
这也说明工程里可以有两类响应条件:
| 条件来源 | 适用场景 |
|---|---|
| 全局断点 | 主导航、整体结构、多个页面共享的设备层级 |
| 局部区域宽度 | 某个网格的列数、子区域是否还能容纳双栏 |
合理做法不是二选一,而是让判断靠近它真正控制的布局。
十、首页当前只对推荐题库启用三列
HomePage 也实现了 currentBp + pageWidth 双判断,但当前 useGridLayout() 只用于 BankSection()。推荐题库在宽屏时三列,其余区块仍保持手机式结构。
例如快捷入口继续是两张卡片一行,功能入口继续是两个 Row。这意味着首页实现的是“局部渐进增强”,不是完整的桌面信息架构重排。
这并非一定错误。多设备适配可以分阶段:
- 先保证内容可达、无截断;
- 再让重复卡片增加列数;
- 最后根据大屏任务重构信息密度和主次区域。
文章只把推荐题库三列写成已实现能力,不把整个首页描述成完整 PC 工作台。
十一、根导航存在一个不可达分支
Index.ets 准备了 BottomNavItem() 和 SideNavItem() 两套构建器,也写了侧边导航 + 内容区的 Row。但当前 build() 的条件是:
if (
this.currentBp === 'sm' ||
this.currentBp === 'md' ||
this.currentBp === 'lg'
) {
// 手机模式:底部导航
} else {
// 平板/折叠模式:侧边导航 + 内容区
}
BreakpointType 只有 sm、md、lg 三种合法值,因此条件永远为真,else 永远不可达。实际运行时,即使窗口进入 lg,根导航仍然走底部导航。
如果产品意图是 sm/md 使用底部导航、lg 使用侧边导航,最小修复应是:
if (this.currentBp === 'sm' || this.currentBp === 'md') {
this.PhoneScaffold()
} else {
this.WideScaffold()
}
或者直接写成:
if (this.currentBp === 'lg') {
this.WideScaffold()
} else {
this.PhoneScaffold()
}
以上是基于现有意图的修复建议。由于本文没有修改应用源码,也没有完成真机回归,不能声称侧边导航已经修复或发布。
十二、安全区域是多设备布局的一部分
窗口宽度不是唯一变量。EntryAbility 还读取顶部系统避让区和底部导航指示区:
const navigationArea =
this.mainWindow.getWindowAvoidArea(
window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR
)
const systemArea =
this.mainWindow.getWindowAvoidArea(
window.AvoidAreaType.TYPE_SYSTEM
)
结果写入:
AppStorage.setOrCreate<number>(
'topAvoidAreaHeightPx',
topHeight
)
AppStorage.setOrCreate<number>(
'navigationIndicatorHeightPx',
Math.max(navigationHeight, systemHeight)
)
Index 再通过 px2vp 转换:
private bottomSafePadding(): number {
return Math.max(
Sizes.BOTTOM_NAV_MIN_PADDING,
this.getUIContext().px2vp(
this.navigationIndicatorHeightPx
)
)
}
这样底部导航高度不是固定值,而是 TAB_BAR_HEIGHT + bottomSafePadding()。在带手势指示区的手机上避免按钮贴底,在没有底部避让的窗口上仍保留项目定义的最小空间。
十三、避让区监听如何随窗口状态更新
onWindowStageCreate() 获取主窗口后注册 avoidAreaChange:
this.avoidAreaCallback =
(data: window.AvoidAreaOptions) => {
if (
data.type ===
window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR ||
data.type === window.AvoidAreaType.TYPE_SYSTEM
) {
this.updateNavigationIndicatorHeight()
}
}
窗口进入全屏、分屏或系统栏状态变化时,避让区可能改变。监听回调重新读取区域,能比启动时只算一次更可靠。
源码在 onWindowStageDestroy() 和 onDestroy() 中都尝试解绑。由于 off 需要正确回调引用,代码把回调保存在 avoidAreaCallback 字段,而不是临时匿名函数,这一点值得保留。
十四、每日打卡页的七列网格有什么真实边界
DailyCheckInPage 的日历始终使用:
.columnsTemplate(
'1fr 1fr 1fr 1fr 1fr 1fr 1fr'
)
.rowsGap(6)
.columnsGap(4)
.height(280)
七列符合日历语义,列数不应因为大屏变成十四列。但单元格内部仍使用固定 36 × 36,整个网格固定高 280。宽屏时它不会自动放大或限制最大内容宽度,视觉上可能显得过于稀疏;小窗下如果外边距和字体增大,也需要检查 36vp 圆形是否仍能容纳。
更稳妥的策略是保留七列,同时给日历主体设置合理 maxWidth,在大屏居中;或者根据实际单元格宽度调整圆形尺寸。当前源码没有这层处理,文章只指出验证边界。
十五、AI 纠错页主要依靠流式宽度
AICorrectPage 没有订阅 currentBreakpoint,也没有 onAreaChange。它大量使用 width('100%')、layoutWeight(1)、Scroll、TextArea 和左右边距,因此能够跟随容器伸缩,但不会在宽屏主动切换为“输入区 + 结果区”双栏。
这类页面在 PC/2in1 上通常有两种演进方向:
- 限制正文最大宽度并居中,保持专注阅读;
- 宽屏时左侧输入、右侧结果,减少纵向滚动。
源码当前属于第一阶段的流式布局,但还没有 maxWidth 或双栏分支。因此不能仅凭 100% 宽度就声称完成 PC 深度适配。
十六、响应式判断要避免边界抖动
断点条件为:
sm: width <= 600vp
md: 600vp < width <= 840vp
lg: 840vp < width
三个范围没有空隙,也没有重叠。用户拖动窗口穿过 600vp 或 840vp 时,只会有一个监听器匹配。
但页面局部阈值又使用 700vp 或 720vp。于是可能出现:
窗口 850vp → 全局 lg
内容区 680vp → 页面仍单列
内容区 710vp → 题库页变三列
这是预期行为,不是断点不一致。全局断点决定设备级布局能力,局部阈值保证当前区域真的放得下。如果视觉上在 700vp 附近频繁抖动,可以增加滞后策略或让布局过渡更平滑,但当前源码未实现这类机制。
十七、建议把布局选择写成可测试的纯函数
目前 useGridLayout() 分散在多个页面,逻辑相同:
return this.currentBp === 'lg' &&
this.pageWidth >= 700
当项目继续扩展,可以把“是否三列”“列宽”“是否双栏”抽成纯函数,但不要把所有页面强行合并成一个万能配置:
export function bankColumnCount(
bp: BreakpointType,
contentWidth: number
): number {
return bp === 'lg' && contentWidth >= 700 ? 3 : 1
}
纯函数可以覆盖临界值:
expect(bankColumnCount('md', 900)).toBe(1)
expect(bankColumnCount('lg', 699)).toBe(1)
expect(bankColumnCount('lg', 700)).toBe(3)
这段是建议代码,不在当前项目中。其价值是让布局决策脱离 ArkUI 运行时,测试时不必真的拖动窗口。
十八、多设备验证不能只看一张平板截图
建议针对真实源码建立以下验证矩阵:
| 场景 | 重点 |
|---|---|
| 手机竖屏 360vp | 底部导航、长标题、按钮可达、底部避让 |
| 手机横屏/小窗 | 内容仍可滚动,不出现不可达操作 |
| 600vp 临界点 | sm 与 md 切换准确 |
| 840vp 临界点 | md 与 lg 切换准确 |
| 平板宽屏 | 题库三列、详情双栏、文本无溢出 |
| PC/2in1 拖窗 | 连续缩放时不白屏、不冻结、不反复跳动 |
| 分屏 | 全局窗口与局部内容宽度组合正确 |
| 系统栏变化 | 顶部和底部操作不被遮挡 |
还要覆盖状态场景:空列表、99+ 徽标、超长题库名、多行说明、考试历史十条、日历六周、纠错结果很多、键盘弹出和返回导航。
十九、从源码得到的三个工程结论
第一,响应式布局应该由窗口驱动。句匠用 mediaquery 定义 sm/md/lg,比按设备型号判断更适合折叠、分屏和自由窗口。
第二,全局断点不能替代局部测量。currentBreakpoint 负责统一设备级状态,onAreaChange 得到内容区真实宽度,两者共同决定三列或双栏。
第三,写了宽屏分支不代表宽屏分支真的可达。Index.ets 的条件覆盖全部合法断点,正是静态阅读就能发现的回归风险。发布前必须在 840vp 两侧真实拖动窗口,观察导航和内容是否按预期切换。
二十、结语:多端适配是一条持续响应链
句匠已经搭起一条可复核的基础链路:EntryAbility 注册窗口断点和避让区监听,BreakpointSystem 将断点写入 AppStorage,页面通过 @StorageLink 获取全局状态,再用 onAreaChange 判断自己的真实宽度。题库列表与考试入口可以在宽屏切换三列,题库详情可以切换双栏,分类页按局部宽度在两列与四列之间变化。
同时,源码也清楚暴露了尚未闭环的部分:根导航侧栏分支不可达,首页只有局部区域增强,AI 纠错页没有宽屏重排,日历网格缺少最大宽度约束。把这些边界写清楚,比笼统宣称“一套代码全设备完美适配”更有工程价值。
真正可靠的多设备布局,不是若干 if (isTablet),而是“窗口监听、共享断点、局部测量、结构切换、安全避让、临界值回归”组成的完整响应链。只有每一环都能从源码和运行结果中复核,HarmonyOS 5.0 及以上设备上的自适应体验才经得起持续缩放和真实审核。
本文部分内容由 AI 辅助整理,所有能力判断、代码路径与限制均以句匠项目真实源码为依据,未虚构云端、多设备协同或已发布修复结果。
更多推荐



所有评论(0)