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

多设备适配最容易出现一种误判:页面根节点已经写了 width('100%')height('100%'),所以“天然支持所有屏幕”。百分比只能让容器填满窗口,却不会自动决定宽屏应该显示几列、导航应该放底部还是侧边、正文是否需要最大阅读宽度、卡片信息密度是否合理。手机页面直接铺到平板或 PC/2in1 上,通常不会编译失败,但会出现更隐蔽的体验问题:内容横向拉得过长,五列快捷入口在窄窗拥挤,固定高度网格截断,底部 Tab 在宽屏上利用率低,窗口缩小时控件来不及重排。

时光清单 当前真实工程提供了一个很适合审计的起点。主入口使用全屏 NavigationMode.StackMainTabShell 采用底部五 Tab;首页快捷功能固定为五列、180vp 高;相册固定为三列;大量页面使用百分比宽高、layoutWeightScrollmaxLines 和省略号;窗口能力层计算顶部与底部安全区;倒计时服务卡片分别实现 2×2、2×4、4×4 三种独立布局。与此同时,module.json5deviceTypes 当前只声明了 phone,工程内没有发现窗口宽度分级或动态断点。

这意味着本文必须明确区分两件事:现有版本已经具备手机端弹性布局和多尺寸服务卡片基础,但不能据此声称已发布平板或 PC/2in1 版本;平板与 PC/2in1 适配是基于真实布局结构提出的演进方案。本文从“先审计当前能力”开始,给出不推翻业务页面的分层改造路径,并把设备声明、布局实现、窗口测试和 AppGallery 材料作为同一条交付链。

时光清单多设备布局与窗口适配封面

本文将解决:

  1. 如何区分设备类型、窗口宽度和服务卡片尺寸。
  2. 当前手机布局中哪些写法可复用,哪些固定值会阻碍宽屏。
  3. 怎样建立 compact、medium、expanded 三档窗口策略。
  4. 主导航、内容宽度、网格列数与详情页如何分别适配。
  5. 2×2、2×4、4×4 卡片为何要独立设计而不是整体缩放。
  6. 上架前如何核对设备声明、素材和窗口变化测试。

本文唯一标记: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。进入宽屏阶段后可以有两条路线:

  1. 保持 Stack,先解决内容最大宽度、网格列数和侧边留白,改动小。
  2. 在 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 上,底部五个入口会分散到很宽的区域,鼠标移动距离增加,主内容宽度也没有被利用。

演进时可以保留同一组 TabDefcurrentIndex,只切换导航呈现:

if (this.sizeClass === WindowSizeClass.EXPANDED) {
  Row() {
    this.SideNavigation()
    this.ActiveTabContent()
  }
} else {
  this.BottomTabs()
}

这里最重要的不是某个容器名称,而是状态复用:currentIndexvisitedTabs、路由栈和业务页面实例不应因导航位置改变而出现两套逻辑。侧边导航按钮还要提供鼠标悬停、键盘焦点和明确选中态。

八、安全区要跟随窗口,而不是写死底部 80

窗口能力层已经有真实实现:主窗口开启沉浸式布局后读取系统区域和导航指示器区域,把顶部、底部高度写入 AppStorage,并监听 avoidAreaChange

MainTabShell 使用:

@StorageProp(StateKeys.SAFE_AREA_BOTTOM)
private safeBottom: number = 0;

.barHeight(56 + px2vp(this.safeBottom))

这是正确方向,因为系统区域在旋转、分屏、手势导航变化时可能改变。与此同时,多个页面还存在 bottom: 80bottom: 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*22*44*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 再增加并行面板。

compact、medium、expanded 三档布局结构

十三、服务卡片还要验证动态内容边界

三种卡片都使用 @LocalStorageProp 接收标题、天数和副标题,数据更新与布局解耦。适配测试不能只放“倒计时”和“0”,还要覆盖:

标题:距离项目正式上线还有多少天
天数:12345
副标题:2027 年 12 月 31 日 · 每年重复

极端数据会暴露:

  • 大数字是否挤压单位。
  • 长标题是否按预期省略。
  • 4×4 两行副标题是否和底部提示重叠。
  • 深色模式下固定白底是否与系统风格冲突。
  • 不同桌面缩放下圆角与边距是否仍清晰。

当前卡片颜色使用硬编码白底和深色文字,而 form_config.jsoncolorModeauto。如果要完整支持深色模式,需要为卡片提供对应颜色资源或根据模式切换,而不能只依赖声明。

十四、窗口变化时不要重置导航与数据

尺寸变化只应更新布局状态。下面这些状态需要在重排时保持:

  • MainTabShell.currentIndex
  • visitedTabs
  • 共享 NavPathStack
  • 表单输入草稿
  • 当前滚动位置
  • Repository 内存数据
  • 主题与 DATA_VERSION

如果把 compact 和 expanded 写成两个完全独立的页面树,并在切换时重新创建业务组件,可能导致 Tab 回到首页、编辑内容丢失或重复加载。

更稳的结构是将业务块提取为 @Builder 或子组件,让不同窗口只改变组合方式。数据状态放在共同父层或 ViewModel 中,布局分支不拥有第二份业务数据。

十五、PC/2in1 还需要鼠标与键盘路径

从手机扩展到 PC/2in1,不是“能在大窗口显示”就结束。至少要补充:

  1. 鼠标悬停与按下反馈。
  2. 键盘 Tab 焦点顺序。
  3. Enter 或 Space 激活主要操作。
  4. Esc 或系统返回关闭次级界面。
  5. 滚轮可滚动长列表。
  6. 输入框与快捷键不冲突。
  7. 可调整窗口边缘时布局连续变化。

当前工程主要围绕触控和底部 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\one8module.json5Index.etsMainTabShell.etsHomeView.etsAllView.etsAlbumView.etsEntryAbility.etsStateKeys.ets、三种 CountdownCard 服务卡片及 form_config.json 的真实源码复核。当前 module.json5 仅声明 phone;文中断点、侧边导航和双栏布局均明确作为建议实现,不声称已经开发、构建、实机验证或发布。

AI 辅助声明: 本文内容由作者结合真实项目源码整理,部分内容由 AI 辅助生成,并已进行人工核验与技术校正。

Logo

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

更多推荐