【时光清单|16】HarmonyOS ArkTS 多设备布局实战:适配手机、平板和 PC/2in1 的窗口变化
【时光清单|16】HarmonyOS ArkTS 多设备布局实战:适配手机、平板和 PC/2in1 的窗口变化
多设备适配最容易出现一种误判:页面根节点已经写了 width('100%')、height('100%'),所以“天然支持所有屏幕”。百分比只能让容器填满窗口,却不会自动决定宽屏应该显示几列、导航应该放底部还是侧边、正文是否需要最大阅读宽度、卡片信息密度是否合理。手机页面直接铺到平板或 PC/2in1 上,通常不会编译失败,但会出现更隐蔽的体验问题:内容横向拉得过长,五列快捷入口在窄窗拥挤,固定高度网格截断,底部 Tab 在宽屏上利用率低,窗口缩小时控件来不及重排。
时光清单 当前真实工程提供了一个很适合审计的起点。主入口使用全屏 NavigationMode.Stack,MainTabShell 采用底部五 Tab;首页快捷功能固定为五列、180vp 高;相册固定为三列;大量页面使用百分比宽高、layoutWeight、Scroll、maxLines 和省略号;窗口能力层计算顶部与底部安全区;倒计时服务卡片分别实现 2×2、2×4、4×4 三种独立布局。与此同时,module.json5 的 deviceTypes 当前只声明了 phone,工程内没有发现窗口宽度分级或动态断点。
这意味着本文必须明确区分两件事:现有版本已经具备手机端弹性布局和多尺寸服务卡片基础,但不能据此声称已发布平板或 PC/2in1 版本;平板与 PC/2in1 适配是基于真实布局结构提出的演进方案。本文从“先审计当前能力”开始,给出不推翻业务页面的分层改造路径,并把设备声明、布局实现、窗口测试和 AppGallery 材料作为同一条交付链。

本文将解决:
- 如何区分设备类型、窗口宽度和服务卡片尺寸。
- 当前手机布局中哪些写法可复用,哪些固定值会阻碍宽屏。
- 怎样建立 compact、medium、expanded 三档窗口策略。
- 主导航、内容宽度、网格列数与详情页如何分别适配。
- 2×2、2×4、4×4 卡片为何要独立设计而不是整体缩放。
- 上架前如何核对设备声明、素材和窗口变化测试。
本文唯一标记:
CSDN-SERIES:ALL-163209207
一、先做能力审计:当前包仍是 phone 范围
多设备文章最重要的第一步不是写断点,而是核对发布边界。当前 entry/src/main/module.json5 中的真实声明为:
"deviceTypes": [
"phone"
]
因此,现阶段可以复核的是手机窗口、横竖屏和服务卡片多尺寸实现。若要把产品范围扩展到平板或 PC/2in1,不能只修改 ArkUI 页面,还要同步处理模块设备类型、包能力、交互方式、截图素材、AGC 设备选择、隐私与审核说明,并完成对应终端测试。
本文后续出现的断点、宽屏导航和双栏详情代码均属于演进示例。它们说明如何利用现有结构改造,不代表当前上架版本已经具备这些能力。这条事实边界必须写在设计文档和发布检查单中。
历史证据:构建记录和折叠屏环境不能替代适配结论
项目的 PROJECT_ERRORS.md 留下了两类可以核对的历史记录。第一类是 2026 年 5 月 20 日若干修复完成后运行 assembleHap 成功;第二类是同一天在 HarmonyOS 6.0、Mate X5 环境收到文字与背景对比度不足的审核反馈,随后调整颜色并重新构建。这些记录能说明当时特定修复曾经完成过本地构建,也能说明项目曾在一台折叠屏环境暴露过视觉问题,但证据到此为止。
不能把历史构建记录改写成“当前版本已经通过构建”,也不能因为设备名称是 Mate X5 就推断折叠态、展开态、横竖屏和自由窗口都已验证。对比度审核关注的是文字可读性,不等于布局适配验收。本轮精修没有执行新的构建、安装、旋转、折叠、平板分屏或 PC/2in1 拖窗测试,所以后文验证矩阵仍是建议执行项,不是已经取得的结果。
这一区分对技术复盘尤其重要:源码事实回答“现在写了什么”,历史记录回答“过去在哪个时间点做过什么”,建议实现回答“下一步应该怎么做”。三者如果混写,读者很容易把设计建议误认为现成功能,也会让发布范围与真实包能力失去一致性。
二、设备类型不等于窗口大小
折叠屏展开、平板分屏、PC/2in1 自由窗口都说明同一件事:设备类型只能告诉我们大致硬件类别,真正决定当前布局的是可用窗口。
一个平板应用处于窄分屏时,可能需要采用手机式单列;PC/2in1 窗口被用户拖窄后,也不能继续维持三栏。反过来,横屏手机可用宽度增加,也未必应该直接切换到完整桌面导航。
建议把布局决策拆成三层:
| 决策层 | 输入 | 输出 |
|---|---|---|
| 产品支持层 | deviceTypes、AGC 配置 | 哪些设备允许安装 |
| 窗口策略层 | 当前窗口宽度与方向 | compact / medium / expanded |
| 组件布局层 | 父容器约束、内容类型 | 列数、间距、显隐与排列 |
窗口变化时只更新策略层,业务数据和路由不应因此重建。这样从全屏切到分屏只是布局重排,不会丢失当前 Tab、编辑草稿或导航栈。
三、现有根导航是稳定壳,但模式固定
Index.ets 当前把共享 NavPathStack 绑定到根 Navigation:
Navigation(this.pathStack) {
MainTabShell()
}
.navDestination(appRouter)
.hideTitleBar(true)
.hideToolBar(true)
.mode(NavigationMode.Stack)
.backgroundColor(this.themeBg);
这一层适合作为多设备改造的稳定壳。路由栈、目的地映射和主题背景可以跨设备保持不变,真正需要响应窗口的主要是 MainTabShell 内部导航形态与页面内容布局。
当前 NavigationMode.Stack 对手机很自然:详情页覆盖主 Tab。进入宽屏阶段后可以有两条路线:
- 保持 Stack,先解决内容最大宽度、网格列数和侧边留白,改动小。
- 在 expanded 窗口引入主从布局,左侧列表、右侧详情,改动更大。
第一条适合渐进交付。不要为了“看起来像平板”立即重写路由,先让现有页面在每个窗口尺寸下不截断、可操作、可返回。

四、建立窗口分级,而不是到处判断具体设备
可以在公共层定义稳定的宽度类别。下面阈值是项目策略示例,需根据真实页面测试调整:
export enum WindowSizeClass {
COMPACT = 'compact',
MEDIUM = 'medium',
EXPANDED = 'expanded'
}
export function classifyWindow(widthVp: number): WindowSizeClass {
if (widthVp < 600) return WindowSizeClass.COMPACT;
if (widthVp < 840) return WindowSizeClass.MEDIUM;
return WindowSizeClass.EXPANDED;
}
公共层只输出语义,不输出“phone”“tablet”“pc”。页面关心的是可用空间,不是设备名字。根页面或窗口能力层监听尺寸变化后,把 windowSizeClass 写入共享状态;子页面只读取类别并计算布局。
@StorageLink('windowSizeClass')
sizeClass: WindowSizeClass = WindowSizeClass.COMPACT;
private featureColumns(): string {
switch (this.sizeClass) {
case WindowSizeClass.EXPANDED:
return '1fr 1fr 1fr 1fr 1fr';
case WindowSizeClass.MEDIUM:
return '1fr 1fr 1fr 1fr';
default:
return '1fr 1fr';
}
}
这里的五列不是简单复用当前手机值。列数必须和卡片最小宽度、文案长度、触控目标一起验证。若中文标签出现换行或图标触控区过窄,应减少列数或改变入口形态。
五、首页五列固定网格是第一个改造点
当前首页快捷功能真实代码:
Grid() {
// 10 个 GridItem
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr')
.rowsGap(10)
.columnsGap(10)
.width('100%')
.height(180)
固定五列加固定高度在单一手机尺寸上容易控制,但窗口变窄时每个入口会被压缩;字体放大时两行内容可能超出;窗口变宽时卡片虽然变宽,信息密度却没有真正提升。更稳的改法是列数和高度同时由数据量推导:
private featureColumnCount(): number {
if (this.sizeClass === WindowSizeClass.EXPANDED) return 5;
if (this.sizeClass === WindowSizeClass.MEDIUM) return 4;
return 2;
}
private featureGridHeight(itemCount: number): number {
const rows = Math.ceil(itemCount / this.featureColumnCount());
return rows * 76 + Math.max(0, rows - 1) * 10;
}
Grid() {
// 快捷入口
}
.columnsTemplate(this.featureColumns())
.height(this.featureGridHeight(10))
真实工程共有 10 个入口,compact 两列需要五行,显然不能继续使用 180vp。高度必须和行数一起变化,或者把网格放入允许自身滚动的内容流中。
六、宽屏不是无限拉伸,需要内容最大宽度
首页外层目前是:
Scroll() {
Column() {
HeroHeader(...)
Column() {
// 每日一言、快捷入口、事项列表
}
.width('100%')
.padding({
left: AppSpacing.pagePadding,
right: AppSpacing.pagePadding,
top: AppSpacing.lg,
bottom: 80
})
}
}
.width('100%')
.height('100%')
这能保证手机内容可滚动并填满窗口,是很好的基础。但 expanded 窗口如果仍让正文撑满,会导致句子过长、卡片跨度过大,视线移动成本上升。
可增加一个内容宽度方法:
private contentMaxWidth(): number {
if (this.sizeClass === WindowSizeClass.EXPANDED) return 1180;
if (this.sizeClass === WindowSizeClass.MEDIUM) return 760;
return 600;
}
Column() {
// 页面主体
}
.width('100%')
.constraintSize({ maxWidth: this.contentMaxWidth() })
.alignSelf(ItemAlign.Center)
百分比宽度负责跟随父容器,最大宽度负责控制可读性。两者配合才能同时覆盖窄窗和宽屏。
七、底部五 Tab 在宽屏上应评估侧边导航
MainTabShell 当前用底部 Tabs:
Tabs({ barPosition: BarPosition.End, index: this.currentIndex }) {
// 首页、全部、新建、组件、我的
}
.barMode(BarMode.Fixed)
.barHeight(56 + px2vp(this.safeBottom))
手机上底部一级导航符合拇指操作习惯,底部安全区也已经纳入高度。平板横屏或 PC/2in1 上,底部五个入口会分散到很宽的区域,鼠标移动距离增加,主内容宽度也没有被利用。
演进时可以保留同一组 TabDef 和 currentIndex,只切换导航呈现:
if (this.sizeClass === WindowSizeClass.EXPANDED) {
Row() {
this.SideNavigation()
this.ActiveTabContent()
}
} else {
this.BottomTabs()
}
这里最重要的不是某个容器名称,而是状态复用:currentIndex、visitedTabs、路由栈和业务页面实例不应因导航位置改变而出现两套逻辑。侧边导航按钮还要提供鼠标悬停、键盘焦点和明确选中态。
八、安全区要跟随窗口,而不是写死底部 80
窗口能力层已经有真实实现:主窗口开启沉浸式布局后读取系统区域和导航指示器区域,把顶部、底部高度写入 AppStorage,并监听 avoidAreaChange。
MainTabShell 使用:
@StorageProp(StateKeys.SAFE_AREA_BOTTOM)
private safeBottom: number = 0;
.barHeight(56 + px2vp(this.safeBottom))
这是正确方向,因为系统区域在旋转、分屏、手势导航变化时可能改变。与此同时,多个页面还存在 bottom: 80、bottom: 100 之类固定留白。这些值在手机上用于避开底部 Tab,但切换到侧边导航后就会变成多余空白。
建议把页面底部间距抽成语义方法:
private pageBottomPadding(): number {
if (this.sizeClass === WindowSizeClass.EXPANDED) {
return AppSpacing.xl;
}
return 56 + px2vp(this.safeBottom) + AppSpacing.lg;
}
系统避让和产品导航占位必须分别计算。不要把“某台测试机上看起来合适”的常数复制到所有页面。
九、文本适配要靠剩余空间与省略策略
项目中已经大量使用两种基础能力:
Text(this.title)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Column() {
Text(this.title)
Text(this.subtitle)
}
.layoutWeight(1)
layoutWeight(1) 让文本区域占据操作按钮以外的剩余空间,maxLines 和省略号避免长标题把按钮挤出屏幕。这些写法在多窗口下仍然有效。
但不能给所有文本都只设一行。标题、备注、隐私说明和错误提示的语义不同:
| 内容 | 推荐策略 |
|---|---|
| Tab 标签 | 单行、必要时缩短文案 |
| 列表标题 | 单行省略,详情可查看全文 |
| 列表副标题 | 一到两行 |
| 备注正文 | 多行并允许滚动 |
| 按钮文字 | 不截断,保证最小宽度 |
| 错误提示 | 完整可读,不用省略号掩盖原因 |
适配测试还要打开系统大字体。窗口够宽并不代表文字一定放得下,字体缩放和本地化同样会改变布局。
十、相册三列固定网格需要按最小卡片宽度重排
AlbumView 当前固定三列:
Grid() {
ForEach(this.photos, (photo: AlbumItem) => {
GridItem() {
// 120vp 图片、标题和编辑/删除按钮
}
})
}
.columnsTemplate('1fr 1fr 1fr')
.rowsGap(8)
.columnsGap(4)
三列卡片中还包含两个按钮。窗口变窄时,按钮文字和触控区最先出问题;窗口变宽时,120vp 图片高度不变,卡片可能横向拉长。
可以按窗口档位调整列数,并给图片稳定宽高比:
private albumColumns(): string {
if (this.sizeClass === WindowSizeClass.EXPANDED) {
return '1fr 1fr 1fr 1fr 1fr';
}
if (this.sizeClass === WindowSizeClass.MEDIUM) {
return '1fr 1fr 1fr';
}
return '1fr 1fr';
}
图片区域应保持一致比例,操作区应有稳定高度。若 compact 窗口两列仍不足以容纳双按钮,可以把操作移入菜单或详情页,而不是继续缩小触控目标。
十一、详情页宽屏优先做双栏,窄屏保持单列
详情页当前是单列 Scroll,顶部卡片、编辑区、信息区和删除操作自上而下排列。这在 compact 窗口可达性好。expanded 窗口可以把核心视觉与编辑信息拆成左右两栏:
if (this.sizeClass === WindowSizeClass.EXPANDED) {
Row({ space: AppSpacing.xxl }) {
Column() {
this.CountdownHero()
}
.layoutWeight(1)
Scroll() {
Column() {
this.EditSection()
this.MetadataSection()
this.DangerActions()
}
}
.layoutWeight(1)
}
} else {
Scroll() {
Column() {
this.CountdownHero()
this.EditSection()
this.MetadataSection()
this.DangerActions()
}
}
}
双栏不是把两个页面拼在一起,而是同一业务组件在不同父布局中的重组。保存、删除、返回和版本通知仍共用原方法。窗口从 expanded 缩到 compact 时,编辑草稿必须保留。
十二、服务卡片尺寸是组件级适配的真实范例
项目的服务卡片没有拿一个布局整体缩放,而是分别实现:
CountdownCard2x2:只突出天数、单位和单行标题。CountdownCard2x4:左侧标题与副标题,右侧大号天数。CountdownCard4x4:标题、品牌、天数、两行副标题和详情提示。
form_config.json 也分别声明 2*2、2*4、4*4,并启用 autoDesignWidth。这体现了多尺寸设计的正确原则:空间增加后提升信息层级,而不是等比放大字体。
2×2 的真实代码主动限制标题:
Text(this.title)
.fontSize(11)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
2×4 使用 layoutWeight(1) 给左侧文本留出剩余空间:
Column() {
Text(this.title).maxLines(1)
Text(this.subtitle).maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
4×4 才增加两行副标题。这个方法可以反向应用到应用窗口:compact 保留任务主线,medium 增加辅助信息,expanded 再增加并行面板。

十三、服务卡片还要验证动态内容边界
三种卡片都使用 @LocalStorageProp 接收标题、天数和副标题,数据更新与布局解耦。适配测试不能只放“倒计时”和“0”,还要覆盖:
标题:距离项目正式上线还有多少天
天数:12345
副标题:2027 年 12 月 31 日 · 每年重复
极端数据会暴露:
- 大数字是否挤压单位。
- 长标题是否按预期省略。
- 4×4 两行副标题是否和底部提示重叠。
- 深色模式下固定白底是否与系统风格冲突。
- 不同桌面缩放下圆角与边距是否仍清晰。
当前卡片颜色使用硬编码白底和深色文字,而 form_config.json 的 colorMode 为 auto。如果要完整支持深色模式,需要为卡片提供对应颜色资源或根据模式切换,而不能只依赖声明。
十四、窗口变化时不要重置导航与数据
尺寸变化只应更新布局状态。下面这些状态需要在重排时保持:
MainTabShell.currentIndexvisitedTabs- 共享
NavPathStack - 表单输入草稿
- 当前滚动位置
- Repository 内存数据
- 主题与
DATA_VERSION
如果把 compact 和 expanded 写成两个完全独立的页面树,并在切换时重新创建业务组件,可能导致 Tab 回到首页、编辑内容丢失或重复加载。
更稳的结构是将业务块提取为 @Builder 或子组件,让不同窗口只改变组合方式。数据状态放在共同父层或 ViewModel 中,布局分支不拥有第二份业务数据。
十五、PC/2in1 还需要鼠标与键盘路径
从手机扩展到 PC/2in1,不是“能在大窗口显示”就结束。至少要补充:
- 鼠标悬停与按下反馈。
- 键盘 Tab 焦点顺序。
- Enter 或 Space 激活主要操作。
- Esc 或系统返回关闭次级界面。
- 滚轮可滚动长列表。
- 输入框与快捷键不冲突。
- 可调整窗口边缘时布局连续变化。
当前工程主要围绕触控和底部 Tab 设计,源码中没有展示完整键鼠适配。因此 PC/2in1 应列为新增验收范围,不能仅通过模拟器截图判断完成。
十六、性能:断点变化只重排,不要触发业务重载
窗口拖动可能连续产生尺寸变化。如果每次变化都重新读 Preferences、重建 Repository 或刷新所有数据,会出现卡顿。
窗口策略应只存储小型枚举:
private updateWindowSize(widthVp: number): void {
const next = classifyWindow(widthVp);
if (next === this.sizeClass) return;
this.sizeClass = next;
}
只有跨越断点时才更新类别。组件内部的 featureColumns()、contentMaxWidth() 和 pageBottomPadding() 根据类别计算。数据加载仍由生命周期和 DATA_VERSION 控制,布局变化不修改业务版本号。
图片网格还应避免在宽屏一次解码过多大图。相册可采用缩略图、懒加载和稳定尺寸,减少窗口拖动时的重复测量与解码。
十七、多设备验证矩阵
发布前建议使用同一组真实数据执行:
| 场景 | 重点检查 |
|---|---|
| 手机竖屏 | 底部 Tab、安全区、两列入口、长列表滚动 |
| 手机横屏 | 内容高度、键盘遮挡、详情页返回 |
| 折叠屏折叠态 | compact 布局与触控目标 |
| 折叠屏展开态 | medium/expanded 切换、状态不丢失 |
| 平板竖屏 | 内容最大宽度、网格列数、留白 |
| 平板横屏 | 侧边导航或宽屏内容、双栏详情 |
| PC/2in1 窄窗 | 自动回退 compact/medium |
| PC/2in1 宽窗 | 鼠标、键盘焦点、滚轮、窗口拖动 |
| 2×2 卡片 | 大数字、长标题、明暗模式 |
| 2×4 卡片 | 左右区域不挤压、单行省略 |
| 4×4 卡片 | 两行副标题、底部提示不重叠 |
每次测试都要覆盖空数据、长标题、大字体、深浅色、加载中、保存失败和权限拒绝。窗口适配不是只看首页正常截图。
十八、常见问题与修复
| 现象 | 根因 | 修复方向 |
|---|---|---|
| 窄窗快捷入口被压扁 | 固定五列 | 根据窗口类别调整列数和高度 |
| 宽屏正文横向过长 | 只有 width('100%') | 增加内容最大宽度并居中 |
| 切到侧边导航仍有底部大空白 | 页面写死 bottom: 80/100 | 拆分系统安全区和导航占位 |
| 相册按钮放不下 | 固定三列且卡片操作过多 | compact 改两列或收拢操作 |
| 窗口缩放后回到首页 | 两套页面树各自维护状态 | 共享父状态与路由栈 |
| 服务卡片深色模式突兀 | 固定白底和深色字 | 使用颜色资源或模式适配 |
| 平板可显示但无法上架 | 仅改布局,未改设备声明与素材 | 同步 module、AGC、截图和测试 |
| PC 上只能点击不能键盘操作 | 仍按触控模型设计 | 增加焦点、键盘与悬停路径 |
十九、发布前核对
- 当前版本若仍只支持手机,
deviceTypes与 AGC 选择保持一致。 - 扩展平板或 PC/2in1 前,先完成模块声明、布局、键鼠交互和实机验证。
- compact、medium、expanded 由窗口宽度决定,不按设备名硬编码。
- 根导航和业务路由不因窗口变化重建。
- 快捷入口、相册网格的列数和高度同步变化。
- 宽屏页面有内容最大宽度,正文不无限拉伸。
- 底部安全区、底部 Tab 和侧边导航占位分开计算。
- 标题、按钮、错误提示在大字体下不截断或重叠。
- 2×2、2×4、4×4 卡片均使用极端数据和深浅色验证。
- AppGallery 每个已选设备组都有匹配的真实截图与说明。
- 安装、启动、主流程、窗口缩放、返回和卸载 smoke test 已完成。
二十、总结:一套业务,多种空间策略
时光清单 已有的全屏根容器、共享导航栈、Scroll、百分比宽度、layoutWeight、文本省略、安全区监听以及三种服务卡片,为多设备演进提供了可复用基础。真正的缺口也很明确:当前包只声明 phone,首页和相册使用固定列数,底部导航和固定留白面向手机,工程尚未建立窗口断点及 PC/2in1 键鼠路径。
合理的改造顺序是:先建立窗口类别和内容最大宽度,再让网格与页面组合响应类别,随后评估宽屏侧边导航和双栏详情,最后扩展设备声明、AGC 素材与测试范围。业务数据、ViewModel、Repository 和路由协议保持不变,变化的只是空间策略。
多设备开发的目标不是让同一张手机页面被拉伸到所有屏幕,而是让同一业务在不同可用空间中保留清晰层级、稳定状态和可达操作。只有源码、设备声明、测试证据和上架材料四者一致,才可以把“能够响应窗口”称为真正完成了手机、平板与 PC/2in1 适配。
本文基于 D:\huawei\one8 中 module.json5、Index.ets、MainTabShell.ets、HomeView.ets、AllView.ets、AlbumView.ets、EntryAbility.ets、StateKeys.ets、三种 CountdownCard 服务卡片及 form_config.json 的真实源码复核。当前 module.json5 仅声明 phone;文中断点、侧边导航和双栏布局均明确作为建议实现,不声称已经开发、构建、实机验证或发布。
AI 辅助声明: 本文内容由作者结合真实项目源码整理,部分内容由 AI 辅助生成,并已进行人工核验与技术校正。
更多推荐




所有评论(0)