【天体运行模拟|16】HarmonyOS ArkTS 多设备布局实战:适配手机、平板和 PC/2in1 的窗口变化

【天体运行模拟|16】HarmonyOS ArkTS 多设备布局实战:适配手机、平板和 PC/2in1 的窗口变化

同一套 ArkUI 页面跑到手机、平板和 PC/2in1,真正困难的并不是把宽度改成百分比,而是窗口变化后仍能保持信息层级、操作可达性和业务状态稳定。手机竖屏需要紧凑网格,平板分屏可能只有半屏宽,2in1 窗口还能被鼠标连续拖动。如果布局只按设备名称分支,设备没变、窗口却变了,页面就会卡在错误结构里。

"天体运行模拟"的真实源码已经使用 onAreaChange 获取页面宽度,并在 HomePage.etsLearningPage.ets 中动态计算网格列数;Index.ets 通过 Tabs 组织四个一级页面,并读取状态栏和底部系统栏高度。本文面向 HarmonyOS 5.0 及以上版本,基于这些可复核实现,拆解如何把现有代码整理成稳定的多设备布局策略,同时明确哪些是当前能力,哪些是可落地的增强方案。

本文重点包括:

  • 为什么应按可用窗口宽度适配,而不是按 phone、tablet、2in1 写死;
  • onAreaChange、派生列模板与 ArkUI 状态更新如何配合;
  • 首页、知识页、关卡列表和"我的"页各自的适配风险;
  • 安全区、滚动、长文本、鼠标键盘和连续缩放怎样验证;
  • 如何避免窗口变化触发业务数据重载或页面状态丢失。

项目基线:应用版本 1.0.0targetSdkVersion 6.0.2(22)compatibleSdkVersion 6.0.1(21),目标设备包含 phone、tablet 与 2in1。本文描述的源码事实来自 Index.etsHomePage.etsLabPage.etsLearningPage.etsMinePage.ets

一、多设备适配首先是窗口问题

"平板就一定宽"并不成立。平板分屏后的应用窗口可能比大屏手机还窄;2in1 可自由调整窗口;折叠设备展开、折叠与悬停时也会改变可用区域。因此,布局输入应该是页面实际获得的宽度,而不是设备类别字符串。

可以把策略简化为三个宽度等级:

等级 示例范围 主要目标
compact 小于等于 600vp 单手操作、两列紧凑内容
medium 601~840vp 增加列数,控制阅读行长
expanded 大于 840vp 提高信息密度或采用主从结构

阈值不是系统常量,也不是所有应用通用答案。它应由页面内容最小宽度、间距和可读性推导,并通过真实窗口连续缩放验证。

二、真实源码怎样获得宽度

HomePage.ets 的根容器维护页面宽度:

复制代码
@State screenWidth: number = 360

.onAreaChange((_o, n) => {
  this.screenWidth = n.width as number
})

这个实现有两个关键优点:一是信号来自组件实际布局区域;二是宽度变化进入 @State 后,依赖它的 ArkUI 表达式会重新求值。分屏、旋转或窗口缩放都不必重新识别设备。

不过,页面不应该到处直接写 screenWidth > 600。当多个页面阈值不一致时,同一个窗口会出现首页两列、知识页四列之类的跳变。应先把原始宽度归一化为布局等级。

三、把阈值集中为布局策略

建议定义明确类型:

复制代码
export type WindowSizeClass =
  'compact' | 'medium' | 'expanded'

export function resolveSizeClass(
  width: number
): WindowSizeClass {
  if (width > 840) return 'expanded'
  if (width > 600) return 'medium'
  return 'compact'
}

页面只消费等级:

复制代码
private gridColumns(): string {
  const sizeClass =
    resolveSizeClass(this.screenWidth)
  if (sizeClass === 'expanded') {
    return '1fr 1fr 1fr 1fr'
  }
  if (sizeClass === 'medium') {
    return '1fr 1fr 1fr'
  }
  return '1fr 1fr'
}

这不是为了抽象而抽象。统一策略让阈值、列数、导航方式和测试用例有同一语言,也避免页面散落魔法数字。

四、首页的真实列数逻辑

HomePage.ets 当前实现:

复制代码
private gridColumns(): string {
  if (this.screenWidth > 840) {
    return '1fr 1fr 1fr'
  }
  if (this.screenWidth > 600) {
    return '1fr 1fr'
  }
  return '1fr 1fr'
}

这意味着 medium 与 compact 都是两列,expanded 才变成三列。它仍是有效的响应式逻辑,只是第二个判断目前没有形成视觉差异。文章不能把它描述成"首页已实现 2/3/4 列",更准确的结论是:首页当前为两列或三列,知识页才是两列、三列、四列。

如果首页卡片最小宽度允许,可以把 medium 调成三列;如果卡片包含更长说明,保持两列反而更稳。列数必须服从内容,而不是追求"屏幕越大列越多"。

五、知识页已形成三级密度

LearningPage.ets 的策略更完整:

复制代码
private gridCols(): string {
  if (this.screenWidth > 840) {
    return '1fr 1fr 1fr 1fr'
  }
  if (this.screenWidth > 600) {
    return '1fr 1fr 1fr'
  }
  return '1fr 1fr'
}

知识分类和辅助工具两个网格复用同一模板,因此窗口变化时页面节奏一致。1fr 让每列均分可用宽度,columnsGap(10) 与页面内边距共同决定单卡实际宽度。

推导卡片是否装得下时,应使用:

复制代码
卡片宽度 =
  (容器宽度 - 左右内边距 - 列间距总和)
  / 列数

只看窗口宽度而忽略内边距,会在阈值附近得到过窄卡片。

六、为什么布局状态不能混入业务状态

窗口缩放可能在一秒内触发多次面积变化。screenWidth 可以高频更新,因为它只派生列模板;收藏列表、实验记录和学习内容不能因此重新请求。

错误模式是:

复制代码
.onAreaChange((_o, n) => {
  this.screenWidth = n.width as number
  this.reloadExperiments()
})

尺寸变化不等于业务数据变化。把两者绑定会制造重复 IO、列表闪烁和异步覆盖。正确边界是:窗口信号只影响布局策略,页面生命周期或用户动作才影响业务数据。

七、只在等级变化时提交状态

连续拖动窗口时,每个像素都更新 @State 未必必要。可存储等级而不是原始宽度:

复制代码
@State sizeClass: WindowSizeClass = 'compact'

private updateWidth(width: number): void {
  const next = resolveSizeClass(width)
  if (next === this.sizeClass) return
  this.sizeClass = next
}

这样同一等级内拖动不会反复切换结构。若页面还需要精确宽度绘制 Canvas,可同时保留原始宽度,但把网格和导航仅绑定到等级。

八、阈值抖动需要迟滞

窗口在 600vp 附近来回一两个像素时,列数可能快速切换。桌面拖拽和系统测量舍入都会放大这个现象。可以使用迟滞区间:

复制代码
private nextClass(
  width: number,
  current: WindowSizeClass
): WindowSizeClass {
  if (current === 'compact' && width < 616) {
    return 'compact'
  }
  if (current === 'medium' && width > 584) {
    return width > 856 ? 'expanded' : 'medium'
  }
  return resolveSizeClass(width)
}

迟滞并非必须,但在可自由缩放窗口中能减少布局来回跳动。实际数值要通过产品视觉测试确定。

九、首页 Hero 的宽屏风险

首页 Hero 固定高度 188,Canvas 轨道按照 ctx.widthctx.height 绘制,文案层则使用整宽。手机上结构紧凑;超宽窗口中,文案行会过长,天体又集中在左侧,视觉重心可能失衡。

可采用最大内容宽度,而不是无限拉伸:

复制代码
Column() {
  // 页面内容
}
.width('100%')
.constraintSize({ maxWidth: 1200 })
.alignSelf(ItemAlign.Center)

也可以在 expanded 模式增加 Hero 高度或把文字宽度约束在合理范围。不要简单按窗口比例放大字号;字体应使用稳定层级,宽屏通过结构和留白适配。

十、Canvas 需要处理重新绘制

真实 Hero Canvas 在 onReady 中绘制星空与轨道。窗口改变后,Canvas 尺寸可能变化,但只依赖 onReady 的绘制逻辑未必自动按新尺寸完整重绘。工程上应把绘制提取为方法,并在尺寸变化后按需要调用。

复制代码
private drawHero(): void {
  const ctx = this.heroCtx
  const width = ctx.width
  const height = ctx.height
  ctx.clearRect(0, 0, width, height)
  // 按当前尺寸重新绘制
}

绘制前 clearRect,避免旧轨迹残留;重绘频率可以按帧合并,防止拖动窗口时大量同步绘制阻塞 UI。

十一、关卡页为什么列表优先

LabPage.ets 使用纵向 List 展示实验,而不是随宽度改变列数。这不是"没有做适配"。实验项包含图标、名称、两行说明、类别、难度与收藏按钮,列表在窄屏上可读性更高。

expanded 模式可演进为双列卡片,但需要先验证:

  • 两行描述不会因卡片变窄而失去信息;
  • 收藏按钮仍有独立点击区域;
  • 卡片点击与星标点击不会事件冲突;
  • 键盘焦点顺序符合视觉顺序;
  • 筛选后奇数项布局不会显得断裂。

多设备设计不是所有页面统一改 Grid,而是为信息形态选择合适容器。

十二、横向分类条保留内容可达

关卡分类使用横向 Scroll,手机上避免标签被压缩;平板上即使所有标签都能显示,也不影响使用。关键配置是:

复制代码
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')

标签文本通过内边距维持触控面积,容器负责横向可达。若 expanded 模式改为换行标签,需要确保页面高度变化不会遮挡列表首项,也要保持键盘导航顺序。

十三、"我的"页不能只看固定高度

MinePage.ets 的菜单行高为 48,七项菜单加头部与底部卡片,在小高度窗口中可能超出页面;根容器当前不是 Scroll。手机竖屏也许能容纳,但手机横屏、2in1 矮窗口和分屏场景需要验证。

可靠方案是让内容区滚动:

复制代码
Scroll() {
  Column() {
    // 用户区、菜单、离线说明
  }
  .width('100%')
}
.layoutWeight(1)
.scrollBar(BarState.Auto)

适配不仅看宽度,还要确保任何高度下核心入口都可达。

十四、根 Tabs 与安全区

Index.ets 真实代码从 AppStorage 读取:

复制代码
@StorageProp('statusBarHeight')
statusBarHeight: number = 36

@StorageProp('bottomBarHeight')
bottomBarHeight: number = 0

随后在根 Tabs 上设置:

复制代码
.padding({
  top: this.statusBarHeight,
  bottom: this.bottomBarHeight
})

这表明页面已考虑系统栏占用,但默认值只是兜底。生产环境应由窗口避让区域或统一能力层更新真实值,并验证状态栏、导航栏和手势区在横竖屏、分屏与窗口化状态下都不遮挡内容。

十五、底部导航需要稳定尺寸

Tab 图标固定 24vp,TabBuilder 高度和 barHeight 都为 56vp。稳定尺寸可以避免标签选中后布局跳动,标题还设置:

复制代码
.maxLines(1)
.textOverflow({
  overflow: TextOverflow.Ellipsis
})

这对窄窗口很重要。但在 PC/2in1 上,底部导航并不总是最高效。expanded 模式可以考虑侧边导航,不过这属于结构升级,不应只把 Tabs 旋转过去;还要保持当前页索引、路由返回、焦点顺序和安全区一致。

十六、导航结构切换要保持选中状态

如果 compact 使用底部 Tabs、expanded 使用侧栏,两种结构必须共享 currentIndex

复制代码
@State currentIndex: number = 0

private selectTab(index: number): void {
  if (this.currentIndex === index) return
  this.currentIndex = index
  this.tabController.changeIndex(index)
}

窗口跨阈值时不能自动回首页,也不能重建子页面导致滚动位置和输入草稿丢失。布局结构可以变,业务会话不能因为宽度变化被重置。

十七、长文本要同时约束宽度和行数

源码在实验名称、工具名称、Tab 标签和说明文字中使用 maxLinesTextOverflow.Ellipsis,这是多设备适配的基础。仅设置 width('100%') 不会自动解决文字问题。

对于 Row 中间的文本列,还要配合:

复制代码
Column() {
  Text(description)
    .maxLines(2)
    .textOverflow({
      overflow: TextOverflow.Ellipsis
    })
}
.layoutWeight(1)
.constraintSize({ minWidth: 0 })

minWidth: 0 能让弹性区域真正收缩,避免右侧收藏按钮被长文本挤出屏幕。

十八、不要用窗口宽度缩放字体

多设备适配常见误区是:

复制代码
.fontSize(this.screenWidth / 20)

这样超宽窗口会出现夸张标题,阈值附近也会持续变化。正确做法是保持离散的排版层级,expanded 模式通过列数、内容最大宽度、双栏结构与留白提升体验。用户字体缩放还需要单独验证,不能用窗口公式抵消。

十九、列表与网格都需要稳定键

窗口跨阈值后,网格会重新排布。ForEach 若没有稳定键,复杂卡片可能被重建,内部状态或动画进度丢失。实验和知识分类都有稳定 id 时,应提供键生成函数:

复制代码
ForEach(
  this.experiments,
  (item: Experiment) => {
    this.ExperimentCard(item)
  },
  (item: Experiment) => item.id
)

布局重排只改变位置,不应改变业务对象身份。

二十、鼠标与键盘不是附加项

PC/2in1 适配不止"窗口变宽"。需要验证:

  • 鼠标点击、滚轮和悬停反馈;
  • Tab 键焦点顺序与焦点可见性;
  • Enter 或 Space 激活按钮;
  • 滚动区域在触控板和鼠标滚轮下正常;
  • 收藏星标与整卡点击可以被分别聚焦;
  • 窗口缩放时焦点不跳到不可见组件。

当前源码主要体现触控点击路径,不能宣称已经完整实现键盘导航。文章提出的是基于现状的增强与验收边界。

二十一、为页面建立布局模型

当页面越来越多,可以把派生值收敛到只读模型:

复制代码
interface PageLayout {
  sizeClass: WindowSizeClass
  contentMaxWidth: number
  knowledgeColumns: string
  homeColumns: string
  useSideNavigation: boolean
}

输入只有窗口宽度和安全区,输出是页面能直接使用的布局值。该模型不读取收藏、记录或路由,因而可以用普通单元测试验证边界值。

二十二、边界测试比设备截图更有效

至少测试阈值两侧:

复制代码
const widths: number[] = [
  320, 599, 600, 601,
  839, 840, 841, 1200
]

检查每个宽度得到的等级、列数与导航形式。截图只覆盖几个静态尺寸,边界测试可以发现 >>= 导致的空档或错误跳变。之后再用真实设备验证字体缩放、安全区和交互。

二十三、连续缩放的运行时验收

2in1 上应拖动窗口从窄到宽再回窄,观察:

  1. 网格只在阈值处改变列数;
  2. 卡片没有重叠、截断或瞬时空白;
  3. 当前 Tab、筛选条件和收藏状态保持;
  4. Canvas 按新尺寸清理并重绘;
  5. 列表滚动位置没有无故归零;
  6. CPU 占用不会因高频重绘持续升高;
  7. 焦点仍落在可见组件。

这一步比只启动三个模拟器更接近真实窗口化使用。

二十四、分设备验收矩阵

场景 重点检查
phone 竖屏 两列卡片、单手触控、底部安全区
phone 横屏 页面高度、菜单可滚动、Hero 文案
tablet 竖屏 三列密度、内容最大宽度
tablet 分屏 按实际宽度降级,不按设备类型
tablet 横屏 四列卡片、行长与视觉重心
2in1 窄窗 compact 结构、鼠标滚轮
2in1 宽窗 expanded 结构、键盘焦点
连续缩放 状态保持、无阈值抖动

所有场景还要覆盖深浅色、状态栏与导航栏可读性。若应用锁定主题,也必须验证系统模式变化不会破坏对比度。

二十五、性能优化的顺序

先测量再优化。多设备布局常见性能热点包括 Canvas 重绘、复杂 Grid 大量重建和窗口变化时重复读取数据。推荐顺序:

  1. 把 IO 从尺寸回调移除;
  2. 只在宽度等级变化时更新结构状态;
  3. ForEach 提供稳定键;
  4. 合并 Canvas 重绘请求;
  5. 对长列表使用合适的懒加载容器;
  6. 用性能工具验证掉帧而不是凭感觉加缓存。

不要为了减少一次简单字符串计算引入全局缓存。gridCols() 本身代价极低,真正昂贵的是重建、绘制与 IO。

二十六、上架前布局检查

  • 依据实际窗口宽度,而不是设备名称选择布局;
  • compact、medium、expanded 阈值统一;
  • 首页真实为两列/三列,知识页真实为两列/三列/四列;
  • 窗口变化不触发业务数据重载;
  • Canvas 尺寸变化后可正确重绘;
  • 所有长页面在小高度窗口中可滚动;
  • 文本设置收缩、行数和溢出策略;
  • 底部操作避开系统导航或手势区;
  • 结构切换不重置 Tab、筛选和草稿;
  • phone、tablet、2in1 完成连续缩放验证;
  • 鼠标、触控板和键盘核心流程可用;
  • 深浅色下文本、图标和系统栏对比度合格;
  • release 包完成安装、启动、缩放、核心流程和卸载冒烟。

总结

HarmonyOS 多设备布局的核心不是维护三套页面,而是建立稳定的数据流:窗口提供真实可用尺寸,布局策略把尺寸归一化为等级,页面只消费列数、最大宽度和导航形式等派生值,业务状态则独立于窗口变化。

"天体运行模拟"已经具备这条链路的基础:根页面处理系统栏,首页和知识页通过 onAreaChange 响应宽度,Grid 使用弹性列,列表和横向滚动保证内容可达。进一步统一阈值、补齐小高度滚动、Canvas 重绘、稳定键与键鼠验证后,同一套 ArkUI 代码就能在手机、平板和 PC/2in1 的窗口变化中保持清晰、稳定且可复核。

本文唯一标记:CSDN-SERIES:ALL-163201046
AI 辅助声明:本文部分内容由 AI 辅助整理,源码事实、工程边界与验证结论均依据文中所列项目文件复核。本文没有执行新的构建、连续缩放、真机、键鼠或发布包验收,因此相关状态均不表述为已验证。

相关推荐
大雷神22 分钟前
HarmonyOS AR Engine高精几何重建实战——扫描纸盒并测量体积
华为·ar·harmonyos
用户09340777351427 分钟前
HarmonyOS WPS Open SDK 实践:registerApp 鉴权与就绪门禁
android·typescript·harmonyos
特立独行的猫a1 小时前
仓颉语言原生 Coding Agent:cjh · 仓颉语言实现的 Harness
ai·agent·harmonyos·仓颉·cangjie·harness
大锅盖11 小时前
警示橙如何驱动巡检闭环?ArkUI 物业安全平台的声明式实现
安全·华为·harmonyos
贾伟康1 小时前
【时光清单|03】HarmonyOS ArkTS 提醒服务实战:计算触发时间并防止重复注册
harmonyos·arkts·后台任务·幂等设计·通知提醒
李蚊子1 小时前
从代理提醒到真实响铃:懒熊闹钟的鸿蒙开发实践
前端·harmonyos
GKxx1 小时前
在 HarmonyOS 上从源码构建 GCC 16(gcc/g++ + libstdc++ + libsanitizer):完整记录
c++·华为·harmonyos·鸿蒙·gcc
贾伟康1 小时前
【时光清单|04】HarmonyOS ArkTS 通知服务实战:管理权限、渠道和点击跳转
harmonyos·arkts·系统通知·notificationkit·wantagent
独守一片天2 小时前
HarmonyOS|鸿蒙新生态服务卡片设计与状态同步
华为·harmonyos