1 一多到底在解决什么问题?

1.1 问题的本质:设备形态爆炸

传统多端开发面临的问题是组合爆炸:一个应用要覆盖手机、折叠屏、平板、PC/2in1、智慧屏、智能穿戴,若为每个形态维护一套代码,则:

  • 业务逻辑要在 N 套代码里重复实现 N 遍;
  • 一个 bug 要修 N 次,且极易漏改;
  • 功能迭代时 N 套代码逐渐发散,最终变成 N 个不同应用。

鸿蒙"一次开发,多端部署"(下文简称"一多")的目标,是用一套代码工程,通过架构分层布局响应式两大手段,让同一份代码在不同设备上呈现符合该设备 UX 规范的体验。

1.2 一多的三个层次

很多开发者把"一多"等同于"响应式布局",这是不完整的。从官方技术白皮书的结构看,一多包含三个层次:

层次要解决的问题核心手段
工程架构层代码如何在多设备间最大化复用三层工程结构(common / features / products)、模块类型选择、部署模型
界面适配层UI 如何随窗口尺寸与设备形态变化自适应布局(七种能力)、响应式布局(断点 / 栅格 / 容器断点 / 多态组件)
功能与交互层能力差异、输入方式差异如何处理系统能力判断(canIUse)、交互归一事件、设备专属交互(遥控器 / 表冠)

只做第三层是"能跑",做完第一、二层才是"一多"。

1.3 一个必须纠正的认知前提:断点面向窗口,而非设备

这是官方文档反复强调、却最容易被忽略的一条:

“断点面向窗口而非设备类型,相同断点区间的窗口展示相同的页面布局。同一设备上的不同窗口形态(例如全屏显示、分屏显示、自由窗口等)可能落入不同的断点区间,展示不同布局。”
—— 华为官方文档《响应式布局》

这句话有三个直接推论:

  1. 不要写"如果是平板就双栏"这种代码。 平板在分屏时窗口可能只有手机宽度,此时就应显示单栏。
  2. 折叠屏不需要特殊判断。 折叠屏展开与折叠,本质是窗口宽度从 sm 跳到 md/lg,断点机制天然覆盖,不需要监听折叠状态。
  3. 断点相同则布局相同,这是设计目标而非妥协。 官方"原则一"明确要求"两个宽度相近的窗口,页面布局相同,断点归一"。

官方文档甚至对媒体查询的设备类型查询能力给出了明确的负面建议:

“当前媒体查询还提供了设备类型的查询能力,建议开发者尽量避免使用此功能进行页面布局管理,以确保页面布局在各种设备间最大化复用。”
—— 华为官方技术白皮书《界面开发》4.2.1.2

记住这句话,第四部分我们会看到,「HMOS代码工坊」正是严格遵守了这一条——它全仓零处使用 mediaquery 做布局判断,并且那个专门用来判断设备类型的 AppTypeUtil 工具类,最终变成了一段无人调用的死代码。这不是疏漏,恰恰是架构约束生效的证据。

2 架构篇:三层工程结构

2.1 官方三层规范原文

华为官方对多设备应用工程结构的推荐如下(摘自《一次开发,多端部署概览》与《架构设计》):

common(公共能力层)

存放公共基础能力,包括公共 UI 组件、数据管理、外部交互和工具库等共享功能。应用可调用这些公共能力。提供稳定可靠的功能支持,确保应用的稳定性和可维护性。
common 层可编译成一个或多个 HAR 包或 HSP 包,其只可以被 products 和 features 依赖,不可以反向依赖

features(基础特性层)

位于公共能力层之上,用于存放相对独立的功能 UI 和业务逻辑实现。每个功能模块都具备高内聚、低耦合、可定制的特点,支持产品的灵活部署。
无需单独部署的 feature 通常编译为 HAR 包或 HSP 包,供各个 product 的 Entry HAP 使用。需要单独部署的 feature 通常编译为 Feature 类型的 HAP 包,和 products 下 Entry 类型的 HAP 包进行组合部署。
features 层可以横向调用及依赖 common 层,不可以反向依赖 products 层

products(产品定制层)

专注于满足不同设备或使用场景的个性化需求,包括 UI 设计、资源和配置,以及特定场景的交互逻辑和功能特性。
products 层各个子目录各自编译为一个 Entry 类型的 HAP 包,作为应用主入口。products 层不可以横向调用
default 类型的 product 是多个类型的设备公用的,如应用的部分界面在手机和平板上的 UX 设计相似,可共用 default,其 HAP 包需同时配置 phone 和 tablet 设备类型

官方给出的抽象工程结构:

/application
├── common                 # 可选。公共能力层,编译为 HAR 包或 HSP 包
├── features               # 可选。基础特性层
│   ├── feature1           # 子功能1,编译为 HAR 包或 HSP 包或 Feature 类型的 HAP 包
│   ├── feature2           # 子功能2
│   └── ...
└── products               # 必选。产品定制层
    ├── wearable           # 智能穿戴泛类目录,编译为 Entry 类型的 HAP 包
    ├── default            # 默认设备泛类目录,编译为 Entry 类型的 HAP 包
    └── ...

依赖关系:

products  ──依赖──>  features  ──依赖──>  common
   │                    │
   └────────────────────┴────────────>  common        (允许跨层向下依赖)

features  可以横向互相调用(同一个 feature 依赖另一个 feature)
features  可以依赖 common
common    不得依赖 features / products        (禁止反向依赖)
features  不得依赖 products                   (禁止反向依赖)
products  不得横向互相调用                     (禁止横向依赖)

这四条禁令是"一多"架构能成立的根本。一旦打破(比如 common 里 import 了某个 feature 的类),复用链就断了,最终会退化成"多套代码放在一个仓里"。

2.2 项目实证:86% 的代码在复用层

「HMOS代码工坊」完整实现了这套三层结构。按 src/main/ets 下的 .ets 文件统计:

模块数模块类型.ets 文件数占比
common1HAR9319.9%
features7HAR31166.5%
products4entry HAP6413.7%
合计12468100%

复用层(common + features)占比 86.3%。

各 feature 模块的代码量分布(features/ 下):

模块文件数说明
componentlibrary200组件库,最大模块
devpractices25样例模块
mine23我的模块
commonbusiness19feature 公共模块
exploration18实践模块
abilitycommon15聚合点,见 2.5 节
widgetcommon11卡片公共模块

四个 product 各自的规模:

模块文件数deviceTypes
products/phone13["phone", "tablet"] ← 官方所述 phone+tablet 共用
products/pc15["2in1"]
products/tv22["tv"]
products/wearable14["wearable"]

注意 phone 模块的 deviceTypes["phone", "tablet"]——这正是官方文档所说"手机和平板 UX 设计相似,可共用 default,其 HAP 包需同时配置 phone 和 tablet 设备类型"的直接落地。手机、折叠屏、平板共用同一个 HAP,它们之间的差异全部由窗口断点在运行时处理,不靠编译期分包。

2.3 模块类型怎么选:HAR / HSP / Feature HAP / Entry HAP

这是工程搭建时第一个必须做的决策。官方规则结合本工程的实践,可归纳为下表:

模块类型适用场景编译产物本工程中的实例
HAR(静态共享包)无需单独部署、无需按需加载的公共能力或业务特性.harcommon + 全部 7 个 features(本工程全部是 HAR)
HSP(动态共享包)需要按需加载 / 多模块共享同一份运行时代码.hsp本工程未使用
Feature HAP需要独立部署、承载 Ability 的特性模块.hap(feature)本工程未使用(其 Sample 子包通过 Deploy Multi Hap 组合)
Entry HAP应用主入口.hap(entry)products/{phone,pc,tv,wearable}

HAR 与 HSP 的关键区别(官方原文):

“HAR 中的代码和资源跟随使用方编译,如果有多个使用方,它们的编译产物中会存在多份相同拷贝;而 HSP 中的代码和资源可以独立编译,运行时在一个进程中代码也只会存在一份。”

选择建议:

  • 默认选 HAR。绝大多数一多工程,common 与 features 用 HAR 就够了,编译简单、依赖清晰。
  • 当出现以下信号时,才考虑 HSP:多模块共享且体积大、需要按需下载、或要求运行时单例。
  • Feature HAP 只在需要"特性独立分发/独立部署"时使用,会增加 Deploy Multi Hap 的配置复杂度(本工程 README 中大量的 Deploy Multi Hap 勾选说明,正是这一复杂性的体现)。

2.4 部署模型 A 与部署模型 B

“部署模型 A 和部署模型 B 的主要差异点集中在 products 层:部署模型 A 在 products 目录下同一子目录中做功能和特性集成;部署模型 B 在 products 目录下不同子目录中对不同的产品做差异化的功能和特性集成。”

部署模型 A部署模型 B
products 结构单个子目录(如 default多个子目录(如 phone / pc / tv / wearable
适用各端 UX 高度一致,仅靠响应式布局即可各端存在结构性差异(导航范式、交互方式不同)
代价最低,一份 entry每个端一个 entry HAP,需维护各自的 routerMap

本工程采用的是部署模型 B——四个 product 各有独立目录。原因很现实:PC 需要侧边栏 + 自由窗口,TV 需要遥控器焦点导航,穿戴需要圆形表盘 + 表冠,这些不是断点能解决的交互范式差异

选型判据(工程实践总结,非官方强制):

问:各端的"信息架构"是否一致?
├─ 一致(同样的 Tab / 同样的层级)→ 部署模型 A,一个 entry 解决
└─ 不一致(导航范式不同、交互方式不同)→ 部署模型 B,按端拆 entry

2.5 聚合点模式:用一层间接依赖收敛复杂度

本工程有一个值得学习的设计:features/abilitycommon

它本身只有 15 个 .ets 文件,却依赖了全部 4 个业务 feature + common + commonbusiness + widgetcommon。phone 与 pc 的 oh-package.json5 中只需声明 abilitycommon 一个依赖,就间接拿到了全部业务能力。

products/phone  ──>  features/abilitycommon  ──>  features/componentlibrary
                │                            ├──>  features/devpractices
                │                            ├──>  features/exploration
                │                            ├──>  features/mine
                │                            └──>  common
                └──>  common(直接依赖,用于基础设施)

收益:

  1. 新增端时成本极低。 新增一个 product,只需声明 abilitycommon 一个依赖,无需逐个去数需要哪些 feature。
  2. 业务 feature 的增删对上层透明。 新增一个 feature,只需改 abilitycommon 的依赖,所有 product 立即生效。
  3. 共享 UI 有天然的归属地。 跨端共享的主容器组件(本工程是 HomeView)放在 abilitycommon 里,职责清晰——它不属于任何一个具体业务,但被所有端复用。

本工程依赖声明实测:

product依赖的模块
phonecommonabilitycommoncommonbusinesswidgetcommon
pccommonabilitycommoncommonbusinesswidgetcommon(与 phone 完全一致
tvcommondevpracticescommonbusiness
wearablecommondevpracticescommonbusiness

phone 与 pc 依赖清单完全一致——这是"两端高复用"在工程配置上的直接证据。而 tv / wearable 只依赖 devpractices(拿数据模型),不依赖 abilitycommon(不用主容器 UI),这是"两端独立实现"的证据。

2.6 一个容易被忽略的约束:deviceTypes 决定复用边界

本工程公共层的 deviceTypes 配置值得注意:

common                     ["default", "tablet", "2in1"]
features/abilitycommon     ["default", "tablet", "2in1"]
features/commonbusiness    ["default", "tablet", "2in1"]
features/componentlibrary  ["default", "tablet", "2in1"]
features/devpractices      ["default", "tablet", "2in1"]
features/exploration       ["default", "tablet", "2in1"]
features/mine              ["default", "tablet", "2in1"]
features/widgetcommon      ["default", "tablet", "2in1"]

全部 8 个可复用模块都是 ["default", "tablet", "2in1"]——不含 phone(由 default 覆盖)、不含 tv、不含 wearable

这个配置从工程层面解释了后文将提到的现象:tv 与 wearable 端几乎无法复用 features 层的 UI 组件,只能复用数据模型。 如果你计划让可复用模块支持智慧屏或穿戴,必须在设计之初就把对应 deviceTypes 写进去,否则后期补的成本很高。

3 官方布局能力全景

3.1 两层能力:连续自适应 与 阶跃响应式

官方把布局能力分为两层,理解这个区分是做一多的关键:

“布局能力涵盖基础的自适应布局和进阶的响应式布局。使用自适应布局可以应对各种屏幕变化,但在大尺寸屏幕上,结合响应式布局能力,可以更有效地利用大尺寸屏幕优势,为用户提供更加场景化的优质应用体验。”

自适应布局响应式布局
变化方式连续——容器尺寸连续变化,UI 连续跟随阶跃——跨越断点阈值时,布局发生结构性跳变
典型效果宽度拉伸、间距均分、图片按比例缩放单栏 → 双栏 → 三栏、底部 Tab → 侧边 Tab
解决的问题同一档位内的尺寸波动不同档位间的形态差异
官方能力七种(见 3.2)断点、媒体查询、栅格、容器断点

正确的开发顺序:先用自适应布局保证"任何尺寸都不崩",再用响应式布局保证"大屏不空、小屏不挤"。跳过第一步直接上断点,会导致每个断点内都存在布局细节问题。

3.2 七种自适应布局能力(官方表 4-1)

官方从常见开发场景中提炼的七种能力,可独立使用也可组合使用:

类别能力场景描述实现方式
自适应拉伸拉伸能力容器尺寸变化时,增加或减小的空间全部分配给指定区域Blank 组件;弹性布局 FlexAlign.SpaceBetween
自适应拉伸均分能力容器尺寸变化时,增加或减小的空间均匀分配给所有空白区域Row/Column/FlexjustifyContent 设为 FlexAlign.SpaceEvenly
自适应缩放占比能力子组件宽高按预设比例随容器变化宽高设为父组件百分比;或 layoutWeight 属性
自适应缩放缩放能力子组件宽高按预设比例随容器变化,且宽高比不变aspectRatio 属性
自适应延伸延伸能力子组件按先后顺序随容器尺寸变化显示或隐藏List 自带滚动;或 Scroll 配合 Row/Column
自适应延伸隐藏能力子组件按预设显示优先级随容器尺寸变化显示或隐藏displayPriority 属性
自适应折行折行能力布局方向尺寸不足以显示完整内容时自动换行Flexwrap 设为 FlexWrap.Wrap

官方给出的典型正例与反例:

正例:用占比能力

// 正例:layoutWeight(1) 让搜索框自动占满 Flex 剩余空间
Flex() {
  Text('搜索...')
    .layoutWeight(1)
  Button() {
    Image($r('app.media.search_history')).width('100%').height('100%').fillColor(Color.White)
  }
}

折行能力示例(官方原文):

// Flex 设置 wrap 为换行后,容器横向宽度不足以一行显示时自动换行
Flex({ wrap: FlexWrap.Wrap }) {
  MenuBar()      // 二级菜单导航
  SearchBar()    // 搜索功能区
}

反例:用固定 vp 宽度(官方明确批评)

// 反例:XSmall 和 Small 断点时宽度为 280vp,Medium、Large、XLarge 时宽度为 240vp
TextInput({ placeholder: '搜索...' })
  .width(new WidthBreakpointType(280, 280, 240, 240, 240).getValue(this.currentWidthBreakpoint))

官方给出的失败案例:Pura X 屏幕宽度 440vp,280vp 固定宽度的搜索框无法铺满整行。

本工程实证layoutWeight 出现 21 处、aspectRatio 出现 33 处,说明这七种自适应能力在该工程中被大量使用(如 common/src/main/ets/component/TopNavigationView.ets:94layoutWeight:78aspectRatio)。这正是官方推荐的开发顺序——自适应打底,响应式点睛。

3.3 响应式布局核心:断点

3.3.1 横向断点(宽度,单位 vp)

断点名称窗口宽度(vp)典型设备
xs(0, 320)智能穿戴
sm[320, 600)手机竖屏、折叠屏外屏
md[600, 840)折叠屏展开态、阔折叠内屏
lg[840, 1440)平板横屏、智慧屏、部分手机横屏
xl[1440, +∞)PC / 2in1、大屏

官方补充要点:

  • 断点面向窗口而非设备类型;同一设备的全屏 / 分屏 / 自由窗口可能落入不同断点。
  • 部分手机、小折叠屏横屏/反向横屏时横向断点会落入 lg,如 Pura70 Pro/Ultra、Pocket 2 系列、nova Flip/Flip S、Mate 70 Air 等机型。
  • 可按业务需要新增 xlxxl
  • 若确定某页面不会在智能穿戴上显示,可不适配 xs

3.3.2 纵向断点(高宽比,无单位)

断点名称高宽比含义
sm(0, 0.8)横向窗口
md[0.8, 1.2)类方形窗口
lg[1.2, +∞)纵向窗口

横向断点 5 档 × 纵向断点 3 档 = 最多 15 种界面布局设计

横向窗口与类方形窗口的判断(官方推荐写法):

// 横向窗口
if (this.currentHeightBreakpoint === HeightBreakpoint.HEIGHT_SM &&
    this.currentWidthBreakpoint === WidthBreakpoint.WIDTH_MD) {
  // 横向窗口页面布局
}
// 类方形窗口
if (this.currentHeightBreakpoint === HeightBreakpoint.HEIGHT_MD &&
    this.currentWidthBreakpoint === WidthBreakpoint.WIDTH_SM) {
  // 类方形窗口页面布局
}

3.3.3 官方常用设备断点区间表

产品类型常见型号窗口全屏尺寸(vp)横向断点纵向断点
手机(竖屏)Mate60/70、Pura70/80 等827 * 374smlg
阔折叠 Pura X内屏 / 外屏内屏 707 * 440;外屏 326 * 326内屏 sm;外屏 sm内屏 lg;外屏 md
阔折叠 Pura X Max内屏 / 外屏内屏 939 * 664;外屏 459 * 672内屏 lg;外屏 sm内屏 sm;外屏 lg
小折叠 Pocket 2内屏860 * 364smlg
双折叠 Mate X5/X6内屏 / 外屏内屏 798 * 711;外屏 801 * 345内屏 md;外屏 sm内屏 md;外屏 lg
三折叠 Mate XTF 态 / M 态 / G 态776*350 / 776*712 / 1107*776sm / md / lglg / md / sm
平板(横屏)MatePad、MatePad Pro1137 * 711lgsm
电脑MateBook Pro(横屏)1642 * 1094xlsm
电脑MateBook Fold(展开态)1831 * 1307xlsm
智慧屏Mate TV1280 * 720lgsm

这张表是做设计评审时最有用的参考资料:先确定要覆盖的设备落在哪几个横向断点,再决定要设计几套布局。 官方示例:应用需支持手机、双折叠、平板,则对应 smmdlg 三个断点,设计三套布局。

3.3.4 断点 API 与"为什么不用媒体查询"

官方提供的断点查询 API:

API作用返回区间
UIContext.getWindowWidthBreakpoint()查询横向断点XSmall / Small / Medium / Large / XLarge 共 5 档
UIContext.getWindowHeightBreakpoint()查询纵向断点Small / Medium / Large 共 3 档
window.Window.on('windowSizeChange')监听窗口尺寸变化
window.Window.getWindowProperties()获取窗口宽高属性

WidthBreakpoint / HeightBreakpointArkUI 全局内置类型,无需 import(本工程已验证:全仓 import.*WidthBreakpoint 零匹配)。

关于媒体查询,官方的态度是明确且值得注意的:

“媒体查询是多设备开发的另一个关键工具,支持查询屏幕分辨率、横竖屏、深浅色模式等与用户界面相关的要素。值得注意的是,当前媒体查询还提供了设备类型的查询能力,建议开发者尽量避免使用此功能进行页面布局管理,以确保页面布局在各种设备间最大化复用。”

结论(工程实践建议):

  • 需要随窗口宽度做布局切换 → 用系统断点 APIgetWindowWidthBreakpoint),而非媒体查询。
  • 媒体查询仍适用于:深浅色模式、横竖屏等非宽度维度的监听。
  • 绝不用媒体查询的 device-typedeviceInfo.deviceType 来做布局分支。

3.3.5 官方取值器工具类

官方示例中给出的两种写法:

写法一:工具类(推荐用于多值映射)

export class WidthBreakpointType<T> {
  sm: T; md: T; lg: T; xl: T;
  constructor(sm: T, md: T, lg: T, xl: T) {
    this.sm = sm; this.md = md; this.lg = lg; this.xl = xl;
  }
  getValue(widthBp: WidthBreakpoint): T {
    if (widthBp === WidthBreakpoint.WIDTH_XS || widthBp === WidthBreakpoint.WIDTH_SM) {
      return this.sm;
    }
    if (widthBp === WidthBreakpoint.WIDTH_MD) { return this.md; }
    if (widthBp === WidthBreakpoint.WIDTH_LG) { return this.lg; }
    return this.xl;
  }
}

// 使用:字体大小
Text('Test')
  .fontSize(new BreakpointType('14fp', '16fp', '18fp').getValue(this.currentWidthBreakpoint))

写法二:三元表达式(适合二选一)

Text('Test')
  .fontSize(this.currentWidthBreakpoint === WidthBreakpoint.WIDTH_LG ? '16fp' : '14fp')

3.4 栅格布局

核心概念

栅格由三个属性决定:Margin(距窗口/父容器左右边缘距离)、Gutter(相邻 Column 间距)、Columns(列数)。单个 Column 宽度由系统自动计算,不允许手动配置

HarmonyOS 栅格系统采用 12 列设计——因为 12 可被 2、3、4、6 整除,布局组合最灵活。

官方栅格参数表

参数项手机折叠屏平板车机智慧屏
竖屏栅格数(个)488--
横屏栅格数(个)88121212
基础栅格 Margin(vp)2424242448
基础栅格 Gutter(vp)2424242424
卡片栅格 Margin(vp)1212121248
卡片栅格 Gutter(vp)1212121224

网格基本单位 8vp;更小的控件(如图标)可对齐 4vp 网格。

竖屏手机栅格宽度计算示例(官方原文):

屏幕宽度 360vp,Margin 24vp,Gutter 24vp,栅格数 4
1 个栅格宽度 = (360 - 24×2 - 24×3) / 4 = 60vp
2 个栅格宽度 = 60 + 24 + 60           = 144vp
3 个栅格宽度 = 144 + 24 + 60          = 228vp
4 个栅格宽度 = 228 + 24 + 60          = 312vp

GridRow / GridCol 用法

GridRow 默认提供 xssmmdlg 四个断点,支持修改断点取值范围并启用 xlxxl最多 6 个断点

// 不同断点下定义不同 columns 和 gutter(官方示例)
GridRow({
  columns: { sm: 4, md: 8, lg: 12 },
  gutter: {
    x: { sm: 8, md: 16, lg: 24 },
    y: { sm: 8, md: 16, lg: 24 }
  }
}) {
  ForEach(this.bgColors, (bgColor: ResourceColor) => {
    GridCol({ span: { sm: 2, md: 2, lg: 2 } }) {
      Row().width('100%').backgroundColor(bgColor).height(30)
    }
  }, (bgColor: ResourceColor) => bgColor.toString())
}

组件级断点(关键高级特性):通过 reference: BreakpointsReference.ComponentSize,让栅格以自身宽度而非窗口宽度为参照物响应断点。

GridRow({
  breakpoints: {
    value: ['320vp', '600vp', '840vp', '1440vp'],
    reference: BreakpointsReference.ComponentSize   // 以组件自身宽度为参照
  }
}) {
  // ...
}
.onBreakpointChange((currentBreakpoint: string) => {
  this.currentBreakpoint = currentBreakpoint;
})

这个能力在分栏场景中至关重要:侧边栏宽度可被用户拖拽改变,内容区实际宽度随之变化,此时内容区内的栅格应该是按"内容区宽度"而非"窗口宽度"决定列数。

本工程实证features/devpractices/src/main/ets/component/CategorySamples.ets:81 正是使用了 BreakpointsReference.ComponentSize

注意:旧的 GridContainer 组件及原"栅格设置"已废弃,请使用 GridRow / GridCol

3.5 容器断点

容器断点组件基于组件自身实际尺寸和断点阈值数组确定断点值,通过双向绑定实时返回容器尺寸和断点信息。与窗口断点的区别在于:容器断点关注组件所在容器的实际尺寸,而非整个窗口尺寸。

适用于:可被用户拖拽改变尺寸的侧边栏内容区、可折叠面板内部、卡片内部布局等。

3.6 多态组件:官方已经替你封装好断点逻辑

API 20 起,HarmonyOS 提供 UI Design Kit(UIDesignKit) 的多态组件。这些组件内置了断点逻辑,是"一多"开发最省力的部分。

组件断点行为支持设备
HdsSideBar屏幕 ≤840vp:悬浮式展开(Overlay);>840vp:嵌入式展开(Embed)手机 / Pad / PC / 智慧屏 / 座舱
HdsNavigation≤600vp:单栏布局;>600vp:一二级页面分栏布局手机 / Pad / PC / 智慧屏 / 座舱
HdsListitemCard多种设备上的系统列表样式手机 / Pad / PC / 智慧屏 / 座舱 / 穿戴
HdsActionBar多种按钮组合样式,手机与座舱规范不同手机 / Pad / PC / 智慧屏 / 座舱
HdsSnackBar各设备避让规则、样式、模糊效果差异化手机 / Pad / PC / 智慧屏 / 座舱
import { hdsSideBar, HdsNavigation, HdsTabs } from '@kit.UIDesignKit';

三分栏组合(HdsSideBar + HdsNavigation)

屏幕宽度≤600vp(600, 840]>840vp
展开类型单栏悬浮双栏悬浮三栏常驻
HdsSideBar({
  sideBarPanelBuilder: (): void => { this.sideBarBuilder() },      // 第一个子组件 = 侧边栏
  contentPanelBuilder: (): void => { HdsNavigation() { /* ... */ } }, // 第二个子组件 = 内容区
  isShowSideBar: false   // ≤840vp 设 false;>840vp 设 true
})

本工程实证products/pc/src/main/ets/page/PcMainPage.ets:104 使用 HdsSideBar(... SideBarContainerType.Embed, autoHide: false)features/abilitycommon/.../HomeView.ets:237 使用 HdsTabs。工程紧跟了最新 API。

3.7 Tabs 页签位置:官方明确规范

这是最常用也最容易做错的一处。官方规则:

横向断点verticalbarPosition页签位置
lg / xltrueBarPosition.Start页面左侧(竖向)
xs / sm / mdfalseBarPosition.End屏幕下方

官方基础写法:

Tabs({
  barPosition: this.currentWidthBreakpoint === WidthBreakpoint.WIDTH_LG
    ? BarPosition.Start : BarPosition.End
}).vertical(this.currentWidthBreakpoint === WidthBreakpoint.WIDTH_LG)

官方优化写法(覆盖 XLarge,推荐):

Tabs({
  barPosition: [WidthBreakpoint.WIDTH_LG, WidthBreakpoint.WIDTH_XL].includes(this.currentWidthBreakpoint)
    ? BarPosition.Start : BarPosition.End
})
.vertical([WidthBreakpoint.WIDTH_LG, WidthBreakpoint.WIDTH_XL].includes(this.currentWidthBreakpoint))

官方特别说明:三折叠进入三屏展开状态的横屏时,Tab 导航会自动由屏幕下方切换至屏幕左侧。

另一条重要提示:Tab 导航位于屏幕下方时需要避让 AI 导航条

3.8 典型布局模式

官方技术白皮书 4.2.4 给出了五类典型场景,可归纳为四种复用模式:

模式一:单栏 / 双栏 / 三栏

形态触发条件实现
单栏xs/smNavigation ≤600vpNavigationMode.Stack
双栏md/lg/xlNavigation >600vpNavigationMode.Split
三栏>840vpHdsSideBar + HdsNavigation 组合

模式二:列表-详情(List-Detail)

以 IM 聊天为例(官方示例):

Navigation(this.pageInfos) { /* 聊天内容区 */ }
.mode([WidthBreakpoint.WIDTH_SM, WidthBreakpoint.WIDTH_XS].includes(this.currentWidthBreakpoint)
  ? NavigationMode.Stack : NavigationMode.Split)
.navBarWidth([WidthBreakpoint.WIDTH_LG, WidthBreakpoint.WIDTH_XL]
  .includes(this.currentWidthBreakpoint) ? '44.5%' : '50%')

两个必须处理的细节:

  1. 占位页:宽屏(md/lg/xl)初次进入、尚未选择详情项时,右侧需显示占位页面(如 ConversationDetailNone)。
  2. 折叠开合的状态保持:用 @Watch 监听断点变化,展开时 push 占位页、折叠时 clear 路由栈回退列表。
@StorageLink('currentWidthBreakpoint') @Watch('currentWidthBreakpointChange')
currentWidthBreakpoint: WidthBreakpoint = WidthBreakpoint.WIDTH_SM;

currentWidthBreakpointChange() {
  if (![WidthBreakpoint.WIDTH_XS, WidthBreakpoint.WIDTH_SM].includes(this.currentWidthBreakpoint)
      && this.pageInfos.size() === 0) {
    // 展开态:路由到详情占位页
    this.pageInfos.pushPath({ name: 'ConversationDetailNone' });
  } else if ([WidthBreakpoint.WIDTH_XS, WidthBreakpoint.WIDTH_SM].includes(this.currentWidthBreakpoint)
      && this.pageInfos.size() > 0) {
    // 折叠态:若栈顶是占位页则回退到列表
    let pathNames = this.pageInfos.getAllPathName();
    if (pathNames[pathNames.length - 1] === 'ConversationDetailNone') {
      this.pageInfos.clear();
    }
  }
}

模式三:挪移布局(Reflow)

同一批内容在窄屏上下堆叠、宽屏左右分栏。官方明确推荐用 GridRowspan 变化实现,而非 if-else 多分支——理由是"在折叠机开合场景保障开合前后的连续性,避免额外进行播放进度、播放内容等连续性开发"。

// 长视频非全屏:sm/md 上下堆叠,lg/xl 左右 9:3 分栏
GridRow() {
  GridCol({ span: { xs: 12, sm: 12, md: 12, lg: 9, xl: 9 } }) {
    // 视频、相关列表、视频简介
  }
  GridCol({ span: { xs: 12, sm: 12, md: 12, lg: 3, xl: 3 } }) {
    // 评论列表、评论功能
  }
}

图文分栏(sm 上图下文,lg 左图右文):

GridRow({ columns: { xs: 12, sm: 12, md: 10, lg: 12, xl: 12 }, gutter: 20 }) {
  GridCol({ span: { xs: 12, sm: 12, md: this.upDownStructure ? 10 : 6, lg: 6, xl: 6 } }) { /* 图片 */ }
  GridCol({ span: { xs: 12, sm: 12, md: this.upDownStructure ? 10 : 4, lg: 6, xl: 6 } }) { /* 文本 */ }
}

模式四:宫格 / 瀑布流

横向断点xssmmdlgxl
列数12345
// 等高宫格
Grid()
  .columnsTemplate(`repeat(${new BreakpointType(1, 2, 3, 4, 5).getValue(this.currentWidthBreakpoint)}, 1fr)`)

// 不等高(瀑布流)
WaterFlow({ layoutMode: WaterFlowLayoutMode.SLIDING_WINDOW }) { /* LazyForEach */ }
  .columnsTemplate(new WidthBreakpointType(
    ColumnTemplateUnit.repeat(1), ColumnTemplateUnit.repeat(2),
    ColumnTemplateUnit.repeat(3), ColumnTemplateUnit.repeat(4),
    ColumnTemplateUnit.repeat(5)
  ).getValue(this.currentWidthBreakpoint))

补充:Swiper 轮播的两种策略

// 方式一:永远展示一张,为不同断点准备不同尺寸图片资源
Image([WidthBreakpoint.WIDTH_XS, WidthBreakpoint.WIDTH_SM].includes(this.currentWidthBreakpoint)
  ? item.getImgSrcSm() : item.getImgSrc())

// 方式二:不同断点展示不同数量
Swiper()
  .displayCount([WidthBreakpoint.WIDTH_XS, WidthBreakpoint.WIDTH_SM]
    .includes(this.currentWidthBreakpoint) ? 1 : 2)

3.9 交互归一

不同设备的输入方式差异,官方通过"交互归一"解决——同样的语义,不同输入设备触发同样的归一化事件,开发者只需监听归一化事件。

输入(事件)触控屏触控板鼠标手写笔
悬浮 onHover光标移动光标移动笔尖靠近
点击 onClick单指单击单指轻点单击左键笔尖点击
双击 TapGesture双击轻点两下双击左键笔尖双击
长按 LongPressGesture单指长按单指长按长按左键笔尖长按
上下文菜单 ContentMenu单指长按双指轻点单击右键笔尖长按
拖拽 Drag长按并移动按压并滑动按住左键移动笔尖长按后滑动
轻扫 SwipeGesture单指快滑双指快移滚轮一格笔尖快滑
滚动平移 PanGesture单指滑动双指移动滚轮笔尖滑动
缩放 PinchGesture双指捏合双指捏合Ctrl + 滚轮不支持
旋转 RotationGesture双指旋转双指旋转不支持不支持

关键反面案例(官方原文):长按交互不要使用 touch 原始事件,因为在鼠标操作场景不会触发,会导致鼠标无法长按。应使用归一化手势:

Image(item.preview)
  .gesture(
    LongPressGesture({ repeat: false }).onAction(() => {
      // 控制视频弹窗出现
    })
  )

快捷键定制:官方保留了原始键盘按键事件,支持场景化快捷键(空格键控制播放暂停、Ctrl + S 保存文档等)。

3.10 横竖屏与启动页

横竖屏

官方强烈建议:避免在应用启动时或进入特定页面时强制设定屏幕方向为竖屏

人因分析结论:当屏幕较短一边的逻辑像素大于 348vp 时可以获得较好的横屏阅读体验

setDefaultOrientation(): void {
  let windowRect: window.Rect = this.windowObj!.getWindowProperties().windowRect;
  let windowWidthVp: number = this.uiContext!.px2vp(windowRect.width);
  let windowHeightVp: number = this.uiContext!.px2vp(windowRect.height);
  if (Math.min(windowWidthVp, windowHeightVp) > 348) {
    this.windowObj?.setPreferredOrientation(window.Orientation.AUTO_ROTATION_RESTRICTED);
  } else {
    this.windowObj?.setPreferredOrientation(window.Orientation.PORTRAIT);
  }
}

并需在窗口尺寸变化时动态调整(三折叠由折叠进入展开时存在一次横竖屏状态变化):

windowStage.loadContent('pages/Index', (err) => {
  this.uiContext = this.windowObj!.getUIContext();
  this.setDefaultOrientation();
  this.windowObj!.on('windowSizeChange', this.onWindowSizeChange);
});

增强启动页

官方建议使用增强启动页而非简易启动页——简易启动页仅支持 startWindowIconstartWindowBackground,“往往无法应对屏幕尺寸多变场景”;增强启动页的资源能够根据窗口尺寸自适应调整。

推荐路径 resources/base/profile/start_window.json

{
  "startWindowIllustration": "$media:illustration",
  "startWindowBrandingImage": "$media:branding",
  "startWindowBackgroundColor": "$color:start_window_background",
  "startWindowBackgroundImage": "$media:startingWindowBackground",
  "startWindowBackgroundImageFit": "Cover"
}

深色模式在 resources/dark 目录配置;其他设备如座舱、平板可创建 cartablet 目录。

4 HMOS代码工坊的六个关键机制

前面是规范,这一节看工业级工程怎么落地。以下机制全部来自真实源码,标注了文件路径。

机制一:全局断点状态中心

问题:断点需要被成百上千个组件消费,如何避免每个组件各自监听窗口?

方案:单例状态 + AppStorage 广播。

状态载体common/src/main/ets/model/GlobalInfoModel.ets):

@Observed
export class GlobalInfoModel {
  public foldExpanded: boolean = false;
  public widthBreakpoint: WidthBreakpoint = WidthBreakpoint.WIDTH_MD;
  public heightBreakpoint: HeightBreakpoint = HeightBreakpoint.HEIGHT_SM;
  public naviIndicatorHeight: number = 0;
  public statusBarHeight: number = 0;
  public decorHeight: number = 0;
  public deviceHeight: number = 0;
  public deviceWidth: number = 0;
  public needDynamicHideBar: boolean = false;
  public aspectRatio: number = 0;
}

export const WIDTH_BREAKPOINTS: string[] = ['xs', 'sm', 'md', 'lg', 'xl'];

注意它同时承载了断点(横向 + 纵向)、窗口尺寸避让区高度(状态栏、导航条、装饰器)、宽高比——这些是所有端都需要的公共上下文,全部下沉到 common 层。

监听与更新common/src/main/ets/util/WindowUtil.ets):

// :147 注册监听
private static registerBreakpoint(windowClass: window.Window) {
  windowClass.on('windowSizeChange', (windowSize: window.Size) => WindowUtil.setWindowSize(windowSize));
  windowClass.on('avoidAreaChange', (avoidAreaOption) => { /* 更新避让区 */ });
}

// :180 窗口尺寸变化 → 重算断点
public static setWindowSize(windowSize?: window.Size | window.Rect) {
  const globalInfoModel: GlobalInfoModel = AppStorage.get(StorageKey.GLOBAL_INFO) ?? new GlobalInfoModel();
  if (windowSize) {
    globalInfoModel.deviceHeight = WindowUtil.uiContext.px2vp(windowSize.height);
    globalInfoModel.deviceWidth  = WindowUtil.uiContext.px2vp(windowSize.width);
    if (vpHeight > 0) { globalInfoModel.aspectRatio = vpWidth / vpHeight; }
  }
  globalInfoModel.heightBreakpoint = WindowUtil.uiContext.getWindowHeightBreakpoint();
  if (AppStorage.get<boolean>(StorageKey.IS_SIDEBAR_LAYOUT)) {
    // PC 分支:关心窗口装饰高度
    globalInfoModel.decorHeight = WindowUtil.windowClass?.getWindowDecorHeight() ?? 0;
  } else {
    // 移动/平板分支:关心横向断点
    globalInfoModel.widthBreakpoint = WindowUtil.uiContext.getWindowWidthBreakpoint();
    // ... 结合宽高比计算 needDynamicHideBar
  }
  AppStorage.setOrCreate(StorageKey.GLOBAL_INFO, globalInfoModel);
}

设计要点:

  1. 用的是 getWindowWidthBreakpoint() 系统 API,不是媒体查询、不是自己算阈值。全仓 mediaquery 零匹配。
  2. 一次监听,全局广播。 所有组件通过 AppStorage 拿到同一份 GlobalInfoModel,配合 @Observed / @StorageLink 实现响应式刷新。
  3. 避让区一并处理。 avoidAreaChange 监听状态栏、导航条高度变化——这是"底部 Tab 需要避让 AI 导航条"这条官方规范的落地方式。

机制二:泛型取值器 BreakpointType<T>

问题if (bp === LG) { A } else if (bp === MD) { B } 这种写法散布全仓会非常难维护。

方案(`common/src/main/ets/util/BreakpointSystem.ets,完整文件):

export interface BreakpointTypes<T> {
  xs?: T; sm: T; md: T; lg: T; xl?: T;
}

export class BreakpointType<T> {
  private xs: T; private sm: T; private md: T; private lg: T; private xl: T;

  public constructor(param: BreakpointTypes<T>) {
    this.xs = param.xs ?? param.sm;   // xs 未指定则回落到 sm
    this.sm = param.sm;
    this.md = param.md;
    this.lg = param.lg;
    this.xl = param.xl ?? param.lg;   // xl 未指定则回落到 lg
  }

  public getValue(currentBreakpoint: WidthBreakpoint): T {
    if (currentBreakpoint === WidthBreakpoint.WIDTH_XS) { return this.xs; }
    if (currentBreakpoint === WidthBreakpoint.WIDTH_SM) { return this.sm; }
    if (currentBreakpoint === WidthBreakpoint.WIDTH_MD) { return this.md; }
    if (currentBreakpoint === WidthBreakpoint.WIDTH_XL) { return this.xl; }
    return this.lg;
  }
}

比官方示例更完善的两点设计(值得借鉴):

  1. 回落机制xs 未指定时用 smxl 未指定时用 lg。这样绝大多数只需写 sm/md/lg 三个值——因为 xs(穿戴)与 xl(大屏)往往与相邻档位一致。
  2. 泛型 + Length 类型:可承载任意类型的值(数字、字符串、Length、资源引用),不只是数字。

使用方式features/abilitycommon/.../HomeView.ets:251):

.barHeight(this.showTabBar ? new BreakpointType<Length>({
  sm: CommonConstants.TAB_BAR_HEIGHT + this.globalInfoModel.naviIndicatorHeight,
  md: CommonConstants.TAB_BAR_HEIGHT + this.globalInfoModel.naviIndicatorHeight,
  lg: '100%',
}).getValue(this.globalInfoModel.widthBreakpoint) : 0)

注意 sm/md 时是"固定高度 + AI 导航条避让高度",lg 时是 '100%'(因为竖排页签占满整个高度)——一个表达式同时处理了断点差异与设备避让。

配套常量(`common/src/main/ets/constant/CommonConstants.ets):

// 分栏列数(与官方宫格 1~3 列建议一致)
public static readonly LANE_SM: number = 1;
public static readonly LANE_MD: number = 2;
public static readonly LANE_LG: number = 3;
// 导航尺寸
public static NAVIGATION_HEIGHT: number = 56;
public static TAB_BAR_HEIGHT: number = 56;
public static TAB_BAR_WIDTH: number = 96;
public static SIDE_BAR_WIDTH: number = 240;
// 宽高比阈值(配合纵向断点做更精细判断)
public static ASPECT_LANDSCAPE_SQUARE: number = 9 / 7.2;   // ≈1.25
public static ASPECT_SQUARE_PORTRAIT: number = 9 / 10.8;   // ≈0.833

栅格列数常量(`common/src/main/ets/constant/CommonEnums.ets:32)——与官方栅格参数表完全一致

export enum ColumnEnum {
  SM = 4,    // 手机竖屏 4 列   ← 官方表:手机竖屏栅格数 4
  MD = 8,    // 折叠屏 8 列    ← 官方表:折叠屏 8
  LG = 12,   // 平板/宽屏 12 列 ← 官方表:平板横屏 12
  XL = 12,
}

这组常量的一致性说明:工程严格遵循了官方栅格规范,没有自创数值。 这是值得学习的地方——一多工程中的所有数值都应该能追溯到官方规范表。

机制三:端形态开关 IS_SIDEBAR_LAYOUT

前面两个机制解决的是"窗口宽度变化",但有一类差异不是宽度问题:PC 上就应该有常驻侧边栏,手机上就应该没有。这不是 840vp 能切分的,而是产品形态差异

方案:一个 AppStorage 布尔量,由各端 Ability 在 onCreate 时写入。

// products/phone/src/main/ets/entryability/EntryAbility.ets:27
AppStorage.setOrCreate<boolean>(StorageKey.IS_SIDEBAR_LAYOUT, false);

// products/pc/src/main/ets/pcability/PcAbility.ets:26
AppStorage.setOrCreate<boolean>(StorageKey.IS_SIDEBAR_LAYOUT, true);

该标志在整个工程中被 60+ 处消费,决定:Tab 是否显示、是否走侧边栏布局、圆角与边距、状态栏配色逻辑等。

这两个机制的关系(本文最重要的一个洞察):

widthBreakpointIS_SIDEBAR_LAYOUT
语义窗口有多宽这是哪一种产品形态
决定时机运行时,随窗口变化进程启动时,由端决定
变化频率频繁(分屏、旋转、自由窗口)基本不变
处理的问题同一端内的尺寸波动跨端的结构性差异
官方依据断点规范部署模型 B / 产品定制层

两者正交,缺一不可。 只用断点,PC 上窗口缩窄时会错误地长出一个手机式底部 Tab;只用形态开关,手机横屏到大尺寸时无法变成双栏。

HomeView 里的组合判断:241,逐字引用):

.vertical((this.globalInfoModel.widthBreakpoint === WidthBreakpoint.WIDTH_LG ||
  this.globalInfoModel.widthBreakpoint === WidthBreakpoint.WIDTH_XL) &&
  !AppStorage.get<boolean>(StorageKey.IS_SIDEBAR_LAYOUT))
.barPosition((this.globalInfoModel.widthBreakpoint === WidthBreakpoint.WIDTH_LG ||
  this.globalInfoModel.widthBreakpoint === WidthBreakpoint.WIDTH_XL) &&
  !AppStorage.get<boolean>(StorageKey.IS_SIDEBAR_LAYOUT) ? BarPosition.Start :
  BarPosition.End)

语义是:"宽到 lg/xl,且不是侧边栏形态"→ 左边竖排页签;否则 → 底部页签。

这与官方 3.7 节的 Tabs 规范完全吻合,且多加了一个形态开关条件——因为 PC 端的页签由 HdsSideBar 承载,HdsTabs 只作为内容切换器,不应再显示成左侧页签(否则出现两个侧边栏)。

机制四:共享主容器 + 参数化装配

这是 phone/pc 两端高复用的核心。

同一个组件features/abilitycommon/src/main/ets/view/HomeView.ets),两端传入不同参数:

// products/phone/src/main/ets/page/MainPage.ets:87
HomeView({ showTabBar: true, currentIndex: this.currentIndex, isShown: this.isShown })

// products/pc/src/main/ets/page/PcMainPage.ets:84
HomeView({ showTabBar: false, currentIndex: this.currentIndex, isShown: this.isShown })

HomeView 内部装配四个业务 Tab:213):

@Builder
TabContentsBuilder() {
  TabContent() { ComponentHomeView({ scroller: this.componentScroller, homeTabController: this.tabController }) }
    .tabBar(this.showTabBar ? this.bottomTabBarStyle(TabBarType.HOME) : new SubTabBarStyle(''))
  TabContent() { PracticeHomeView({ scroller: this.sampleScroller, homeTabController: this.tabController }) }
    .tabBar(this.showTabBar ? this.bottomTabBarStyle(TabBarType.SAMPLE) : new SubTabBarStyle(''))
  TabContent() { ExplorationHomeView({ scroller: this.articleScroller, homeTabController: this.tabController }) }
    .tabBar(this.showTabBar ? this.bottomTabBarStyle(TabBarType.PRACTICE) : new SubTabBarStyle(''))
  TabContent() { MineHomeView({ scroller: this.mineScroller, homeTabController: this.tabController }) }
    .tabBar(this.showTabBar ? this.bottomTabBarStyle(TabBarType.MINE) : new SubTabBarStyle(''))
}

showTabBar: false 时,用空的 SubTabBarStyle('') 顶替页签——因为 PC 端页签由外层 HdsSideBar 中的 CustomSideBar 提供。

这个设计的可复用之处:

  1. 差异收敛为参数。 两端的差异被压缩成一个布尔量,而不是复制两份页面代码。
  2. 业务内容完全共享。 ComponentHomeView / PracticeHomeView / ExplorationHomeView / MineHomeView 四个业务视图来自不同 feature 模块,两端完全共用。
  3. 外壳在 product 层。 phone 用 HdsNavDestination 外壳 + 双击退出;pc 用 HdsSideBar + CustomSideBar 外壳。外壳薄,内容厚。

机制五:跨模块路由(PageContext + routerMap)

问题:feature 之间不能互相依赖(虽然官方允许 features 横向调用,但工程上应尽量避免),那 A feature 如何跳转到 B feature 的页面?

方案:把 NavPathStack 的封装实例放到 AppStorage,各模块按 key 取用。

薄封装common/src/main/ets/routermanager/PageContext.ets):

export interface RouterParam {
  routerName: PageEnum;
  param?: object;
}

export class PageContext implements IPageContext {
  private readonly pathStack: NavPathStack;
  constructor() { this.pathStack = new NavPathStack(); }
  public get navPathStack(): NavPathStack { return this.pathStack; }
  // openPage / popPage / replacePage / popPageByIndex / clear
}

在 Ability 初始化时注入features/abilitycommon/src/main/ets/abilityhelper/BaseAbilityHelper.ets:98):

AppStorage.setOrCreate(StorageKey.HOME_PAGE_CONTEXT, new PageContext());
AppStorage.setOrCreate(StorageKey.SAMPLE_PAGE_CONTEXT, new PageContext());
AppStorage.setOrCreate(StorageKey.COMPONENT_PAGE_CONTEXT, new PageContext());
AppStorage.setOrCreate(StorageKey.EXPLORATION_PAGE_CONTEXT, new PageContext());
AppStorage.setOrCreate(StorageKey.MINE_PAGE_CONTEXT, new PageContext());

路由目标由各端的 routerMap 决定resources/base/profile/router_map.json):

routerMap 条数说明
phone1MainPageMainPageBuilder
pc1MainPageMainPageBuilder(指向 PcMainPage.ets
tv4独立页面体系
wearable2独立页面体系

关键洞察:phone 与 pc 的 routerMap name 相同、buildFunction 同名,但 pageSourceFile 指向各自的文件。这是"一份路由契约、多端不同实现"的标准做法——上层代码统一写 openPage({ routerName: PageEnum.MainPage }),实际落到哪个页面由所在端的 routerMap 决定。

机制六:数据差异化走 rawfile 运行时拼路径

官方资源限定词(如 resources/tablet/resources/2in1/)可以按设备提供不同资源,但本工程几乎没用——全工程唯一的设备限定词目录是 features/componentlibrary/src/main/resources/2in1-dark/element/color.json,内容只有一个色值。

替代方案common/src/main/ets/storagemanager/MockRequest.ets:61):

const filePath = requestParam['supportDevice']
  ? `mockdata/${requestParam['supportDevice']}/${trigger}.json`
  : `mockdata/${trigger}.json`;

数据目录为 common/src/main/resources/rawfile/mockdata/{phone, tablet, 2in1}/

优劣分析(工程实践视角):

  • 优点:一套数据结构、一份解析代码,按端取不同数据文件;支持运行时动态切换;无需为每个限定词目录复制整套资源。
  • 缺点:绕过了系统资源匹配机制,需要自己维护 supportDevice 参数(SampleService.ets 中通过 deviceInfo.deviceType 获取并作为网络请求参数)。
  • 适用:数据驱动的内容(列表、配置、mock);不适用:图片、颜色、尺寸、字符串等标准资源(这些应优先用资源限定词)。

4.7 一个必须说清楚的边界:tv 与 wearable 并未高复用

前面讲的四个高度复用机制,全部只适用于 phone / pc 两端。诚实地说:

主页面是否复用 HomeView实际复用内容
phoneproducts/phone/.../MainPage.etsshowTabBar: true全部 features
pcproducts/pc/.../PcMainPage.etsshowTabBar: false全部 features
tvproducts/tv/.../pages/MainPage.etsSingleSampleData 数据模型
wearableproducts/wearable/.../pages/MainPage.etsMediaTypeEnumSingleSampleData 数据模型

为什么? 三层次的原因:

  1. 交互范式不同:tv 需遥控器方向键值焦点导航(自写 HomeContent + BarComponent);wearable 需圆形表盘(用 ArcSwiper 自写 HomePageComponent)。这些不是布局问题,无法用断点解决。
  2. 工程配置限制:全部 8 个可复用模块的 deviceTypes["default", "tablet", "2in1"]不含 tv / wearable
  3. 信息架构不同:tv 与 wearable 的功能集本身是父集的子集。

这给我们的方法论启示:一多不是"所有端共用一切"。正确的判断标准是——信息架构与交互范式是否一致。一致则共用(phone/pc/tablet),不一致则只共享数据与领域模型、各端独立实现 UI(tv/wearable)。


5 从零搭一套一多工程

下面给出一套可执行的落地流程。所有代码均可直接使用,数值全部遵循官方规范。

5.1 规划工程结构与模块类型

MyApp/
├── common/                     # HAR:断点系统、网络、存储、公共组件
├── features/
│   ├── feature_home/           # HAR:首页
│   ├── feature_detail/         # HAR:详情
│   └── abilitycommon/          # HAR:聚合点 + 共享主容器
└── products/
    ├── phone/                  # Entry HAP,deviceTypes: ["phone", "tablet"]
    └── pc/                     # Entry HAP,deviceTypes: ["2in1"]

module.json5 关键配置:

// common/src/main/module.json5
{
  "module": {
    "name": "common",
    "type": "har",
    "deviceTypes": ["default", "tablet", "2in1"]
  }
}
// products/phone/src/main/module.json5
{
  "module": {
    "name": "phone",
    "type": "entry",
    "deviceTypes": ["phone", "tablet"],   // 官方:手机与平板 UX 相似可共用
    "pages": "$profile:main_pages",
    "routerMap": "$profile:router_map"
  }
}
// products/pc/src/main/module.json5
{
  "module": {
    "name": "pc",
    "type": "entry",
    "deviceTypes": ["2in1"],
    "pages": "$profile:main_pages",
    "routerMap": "$profile:router_map"
  }
}

依赖声明(`products/phone/oh-package.json5):

{
  "dependencies": {
    "@ohos/common": "file:../../common",
    "@ohos/abilitycommon": "file:../../features/abilitycommon"
  }
}

如果计划支持 tv / wearable,务必在这一步就把 deviceTypes 写全。 后期补代价很大。

5.2 搭建断点基础设施

2.1 状态模型common/src/main/ets/model/GlobalInfoModel.ets

@Observed
export class GlobalInfoModel {
  public widthBreakpoint: WidthBreakpoint = WidthBreakpoint.WIDTH_MD;
  public heightBreakpoint: HeightBreakpoint = HeightBreakpoint.HEIGHT_SM;
  public deviceWidth: number = 0;
  public deviceHeight: number = 0;
  public aspectRatio: number = 0;
  public statusBarHeight: number = 0;      // 状态栏避让
  public naviIndicatorHeight: number = 0;  // AI 导航条避让(底 Tab 必须)
}

2.2 取值器common/src/main/ets/util/BreakpointSystem.ets

export interface BreakpointTypes<T> {
  xs?: T; sm: T; md: T; lg: T; xl?: T;
}

export class BreakpointType<T> {
  private xs: T; private sm: T; private md: T; private lg: T; private xl: T;
  constructor(param: BreakpointTypes<T>) {
    this.xs = param.xs ?? param.sm;
    this.sm = param.sm;
    this.md = param.md;
    this.lg = param.lg;
    this.xl = param.xl ?? param.lg;
  }
  public getValue(currentBreakpoint: WidthBreakpoint): T {
    if (currentBreakpoint === WidthBreakpoint.WIDTH_XS) { return this.xs; }
    if (currentBreakpoint === WidthBreakpoint.WIDTH_SM) { return this.sm; }
    if (currentBreakpoint === WidthBreakpoint.WIDTH_MD) { return this.md; }
    if (currentBreakpoint === WidthBreakpoint.WIDTH_XL) { return this.xl; }
    return this.lg;
  }
}

2.3 窗口监听common/src/main/ets/util/WindowUtil.ets

export class WindowUtil {
  private static windowClass: window.Window | undefined = undefined;
  private static uiContext: UIContext | undefined = undefined;

  public static init(windowStage: window.WindowStage): void {
    WindowUtil.windowClass = windowStage.getMainWindowSync();
    WindowUtil.uiContext = WindowUtil.windowClass.getUIContext();
    WindowUtil.registerBreakpoint(WindowUtil.windowClass);
    WindowUtil.setWindowSize();
  }

  private static registerBreakpoint(windowClass: window.Window): void {
    windowClass.on('windowSizeChange', (size: window.Size) => WindowUtil.setWindowSize(size));
    windowClass.on('avoidAreaChange', (option) => {
      // 更新状态栏 / 导航条避让高度
      if (option.type === window.AvoidAreaType.TYPE_SYSTEM ||
          option.type === window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR) {
        WindowUtil.updateAvoidArea(option.type, option.area);
      }
    });
  }

  public static setWindowSize(windowSize?: window.Size | window.Rect): void {
    const info: GlobalInfoModel = AppStorage.get('GlobalInfo') ?? new GlobalInfoModel();
    if (windowSize) {
      info.deviceWidth  = WindowUtil.uiContext!.px2vp(windowSize.width);
      info.deviceHeight = WindowUtil.uiContext!.px2vp(windowSize.height);
      if (info.deviceHeight > 0) { info.aspectRatio = info.deviceWidth / info.deviceHeight; }
    }
    // 关键:使用系统断点 API,而非媒体查询、而非自己算阈值
    info.widthBreakpoint  = WindowUtil.uiContext!.getWindowWidthBreakpoint();
    info.heightBreakpoint = WindowUtil.uiContext!.getWindowHeightBreakpoint();
    AppStorage.setOrCreate('GlobalInfo', info);
  }
}

2.4 在 Ability 中初始化

onWindowStageCreate(windowStage: window.WindowStage): void {
  windowStage.loadContent('pages/SplashPage', () => {
    WindowUtil.init(windowStage);
  });
}

2.5 在组件中消费

@Component
struct MyPage {
  @StorageLink('GlobalInfo') globalInfo: GlobalInfoModel = new GlobalInfoModel();

  build() {
    Column() {
      // 直接用 globalInfo.widthBreakpoint 驱动布局
    }
  }
}

5.3 用七种自适应能力打底

在写任何断点判断之前,先用自适应能力保证连续变化不出问题:

// ① 占比能力:搜索框占满剩余空间(官方正例)
Flex() {
  TextInput({ placeholder: '搜索...' }).layoutWeight(1)
  Button('搜索').width(80)
}

// ② 折行能力:窄屏时菜单与搜索换行
Flex({ wrap: FlexWrap.Wrap }) {
  MenuBar()
  SearchBar()
}

// ③ 缩放能力:图片保持宽高比
Image($r('app.media.banner'))
  .width('100%')
  .aspectRatio(16 / 9)

// ④ 拉伸能力:中间内容区拉伸,两侧留白收缩
Flex({ justifyContent: FlexAlign.Center }) {
  Row().width(150).flexGrow(0).flexShrink(1)   // 左留白
  Image($r('app.media.illustrator')).width(300).flexGrow(1).flexShrink(0)
  Row().width(150).flexGrow(0).flexShrink(1)   // 右留白
}

// ⑤ 均分能力:底部工具栏等距分布
Flex({ justifyContent: FlexAlign.SpaceEvenly }) { /* ... */ }

// ⑥ 隐藏能力:按优先级隐藏次要内容
Row() {
  Text('主标题').displayPriority(1)
  Text('次要信息').displayPriority(2)
  Text('可省略信息').displayPriority(3)
}

// ⑦ 延伸能力:超出则滚动
List() { /* ... */ }

5.4 用断点做响应式

4.1 Tabs 页签位置(严格遵循官方规范)

@Component
struct HomeView {
  @StorageLink('GlobalInfo') globalInfo: GlobalInfoModel = new GlobalInfoModel();

  // 官方推荐:lg / xl 时在左侧,其余在底部
  private get isSideTab(): boolean {
    return this.globalInfo.widthBreakpoint === WidthBreakpoint.WIDTH_LG ||
           this.globalInfo.widthBreakpoint === WidthBreakpoint.WIDTH_XL;
  }

  build() {
    Tabs({ barPosition: this.isSideTab ? BarPosition.Start : BarPosition.End }) {
      TabContent() { HomePage() }.tabBar('首页')
      TabContent() { MinePage() }.tabBar('我的')
    }
    .vertical(this.isSideTab)
    .barHeight(this.isSideTab ? '100%' : 56 + this.globalInfo.naviIndicatorHeight)  // 底部需避让 AI 导航条
  }
}

4.2 宫格列数(官方 1/2/3/4/5)

Grid()
  .columnsTemplate(
    `repeat(${new BreakpointType(1, 2, 3, 4, 5).getValue(this.globalInfo.widthBreakpoint)}, 1fr)`
  )
  .columnsGap(12)
  .rowsGap(12)

4.3 Swiper 展示数量

Swiper()
  .displayCount(new BreakpointType({
    sm: 1, md: 2, lg: 3,
  }).getValue(this.globalInfo.widthBreakpoint))

本工程对应实现见 features/exploration/src/main/ets/component/ExperienceCard.ets,列数常量为 LANE_SM=1 / LANE_MD=2 / LANE_LG=3

4.4 栅格布局(严格使用官方栅格参数)

// 栅格列数常量,与官方参数表一致
export enum ColumnEnum { SM = 4, MD = 8, LG = 12, XL = 12 }

GridRow({
  columns: { sm: ColumnEnum.SM, md: ColumnEnum.MD, lg: ColumnEnum.LG },
  gutter: { x: new BreakpointType({ sm: 8, md: 16, lg: 24 }).getValue(this.globalInfo.widthBreakpoint) },
  direction: GridRowDirection.Row,
  breakpoints: { reference: BreakpointsReference.ComponentSize }  // 内容区场景用组件级断点
}) {
  GridCol({ span: { sm: 4, md: 4, lg: 6 } }) { /* 卡片 */ }
}

5.5 分栏与列表-详情

5.1 使用多态组件(最省事,推荐优先)

import { HdsNavigation, HdsSideBar } from '@kit.UIDesignKit';

// HdsNavigation:≤600vp 单栏,>600vp 自动分栏
HdsNavigation(this.pageInfos) { /* 内容区 */ }

// HdsSideBar:≤840vp 悬浮,>840vp 嵌入
HdsSideBar({
  sideBarPanelBuilder: (): void => { this.sideBarBuilder() },
  contentPanelBuilder: (): void => { HdsNavigation() { /* ... */ } },
  isShowSideBar: this.globalInfo.deviceWidth > 840
})

5.2 使用 Navigation(需精确控制时)

Navigation(this.pageInfos) { /* 内容区 */ }
.mode([WidthBreakpoint.WIDTH_XS, WidthBreakpoint.WIDTH_SM]
  .includes(this.globalInfo.widthBreakpoint)
  ? NavigationMode.Stack : NavigationMode.Split)
.navBarWidth([WidthBreakpoint.WIDTH_LG, WidthBreakpoint.WIDTH_XL]
  .includes(this.globalInfo.widthBreakpoint) ? '44.5%' : '50%')

5.3 占位页与折叠状态保持(见 3.8 模式二的完整代码)

5.6 多端 entry 装配

6.1 共享主容器模式(推荐,phone/pc 两端复用)

// features/abilitycommon/src/main/ets/view/HomeView.ets
@Component
export struct HomeView {
  @Prop showTabBar: boolean = true;   // 差异收敛为参数
  // ...
}
// products/phone/src/main/ets/page/MainPage.ets
HomeView({ showTabBar: true })

// products/pc/src/main/ets/page/PcMainPage.ets
HdsSideBar({ /* ... */ }) {
  HomeView({ showTabBar: false })     // 页签由侧边栏承载
}

6.2 routerMap:同一契约,不同实现

// products/phone/src/main/resources/base/profile/router_map.json
{
  "routerMap": [
    { "name": "MainPage", "pageSourceFile": "src/main/ets/page/MainPage.ets",
      "buildFunction": "MainPageBuilder" }
  ]
}
// products/pc/src/main/resources/base/profile/router_map.json
{
  "routerMap": [
    { "name": "MainPage", "pageSourceFile": "src/main/ets/page/PcMainPage.ets",
      "buildFunction": "MainPageBuilder" }
  ]
}

上层统一调用 openPage({ routerName: PageEnum.MainPage }),落到哪端由运行时决定。

5.7 交互归一

// 正确:归一化手势,触控屏与鼠标都能触发
Image(item.preview)
  .gesture(LongPressGesture({ repeat: false }).onAction(() => { this.showPreview() }))

// 错误:使用原始 touch 事件,鼠标场景不触发
Image(item.preview)
  .onTouch((event) => { /* 鼠标无法长按 */ })

PC 端补充键盘快捷键(官方保留原始按键事件支持场景化定制):

.onKeyEvent((event: KeyEvent) => {
  if (event.keyCode === KeyCode.KEYCODE_SPACE) { this.togglePlay(); }
})

5.8 横竖屏与启动页

// 依据官方人因结论:短边 > 348vp 才支持横屏
setDefaultOrientation(): void {
  const rect = this.windowObj!.getWindowProperties().windowRect;
  const w = this.uiContext!.px2vp(rect.width);
  const h = this.uiContext!.px2vp(rect.height);
  if (Math.min(w, h) > 348) {
    this.windowObj?.setPreferredOrientation(window.Orientation.AUTO_ROTATION_RESTRICTED);
  } else {
    this.windowObj?.setPreferredOrientation(window.Orientation.PORTRAIT);
  }
}

启动页使用增强启动页(见 3.10 节的 start_window.json)。

6 十八条反面清单

A. 架构层

#反模式正确做法
1common 里 import 了 feature 的类反向依赖。把该类下沉到 common,或用接口倒置
2feature 依赖 products反向依赖,禁止
3两个 product 互相 importproducts 不可横向调用,公共部分下沉
4所有代码堆在 entry 里复用层占比应显著高于 products 层(本工程为 86%)
5为支持 tv/wearable 才回头改 deviceTypes立项时就把 deviceTypes 写全
6所有 feature 全部编译为 Feature HAP默认用 HAR,按需才用 HSP / Feature HAP

B. 断点层

#反模式正确做法
7if (deviceInfo.deviceType === 'tablet') { 双栏 }widthBreakpoint;官方明确不建议按设备类型做布局分支
8用媒体查询的 device-type 做布局官方原话:“建议开发者尽量避免使用此功能进行页面布局管理”
9自己写 if (width < 600) { ... } 判断阈值getWindowWidthBreakpoint(),与系统阈值保持一致
10监听折叠状态 getFoldStatus() 来切布局折叠屏开合本质是窗口宽度变化,断点天然覆盖
11只判断 lg,遗漏 xl官方优化写法要求 [LG, XL].includes(...)
12每个组件各自调用 matchMediaSync一次监听 + AppStorage 广播(本工程 WindowUtil 模式)
13断点值写死在各处 magic number收敛到常量(如 ColumnEnumLANE_*)并追溯官方规范

C. 布局层

#反模式正确做法
14搜索框/卡片写死 280vp 宽度layoutWeight(1) 或百分比(官方反面案例:Pura X 440vp 铺不满)
15if-else 多分支切换上下/左右布局GridRowspan 挪移布局,保障折叠开合的连续性
16底部 Tab 未避让 AI 导航条barHeight 加上 naviIndicatorHeight
17强制竖屏 PORTRAIT官方强烈建议避免;用短边 >348vp 判据
18使用已废弃的 GridContainerGridRow / GridCol

D. 本工程中发现的三个具体问题(供对照自查)

问题 1:AppTypeUtil 是死代码

common/src/main/ets/util/AppTypeUtil.ets 定义了 isPhoneApp() / isPcApp() / isWearableApp() / isTvApp() 等方法,但全仓搜索除该文件自身与编译产物外,无任何调用点setAppType() 也没有任何调用。

这说明工程在开发过程中主动放弃了按设备类型分支的方案,转而使用断点 + 形态开关。结论:AppTypeUtil 应删除,避免后人误用。

问题 2:断点判断不对称(潜在缺陷)

// products/phone/src/main/ets/page/MainPage.ets:70 —— 缺少 IS_SIDEBAR_LAYOUT 条件
this.globalInfo.widthBreakpoint === WidthBreakpoint.WIDTH_LG ||
this.globalInfo.widthBreakpoint === WidthBreakpoint.WIDTH_XL

// features/abilitycommon/.../HomeView.ets:151 —— 有该条件
this.globalInfo.widthBreakpoint === WidthBreakpoint.WIDTH_LG ||
this.globalInfo.widthBreakpoint === WidthBreakpoint.WIDTH_XL ||
AppStorage.get<boolean>(StorageKey.IS_SIDEBAR_LAYOUT)

HomeView.ets:241.vertical(...) 也带了 !IS_SIDEBAR_LAYOUT。目前 pc 因走 IS_SIDEBAR_LAYOUT=true 分支而不计算 widthBreakpoint,两者未产生实际冲突;但逻辑不对称,后续修改手机宽屏逻辑时极易踩坑。建议统一为一个共享的判断方法。

问题 3:tv / wearable 的复用断裂

公共层 deviceTypes 不含 tv / wearable,导致这两个端无法复用 features 层 UI,只能复用数据模型。这是立项时的配置决策导致的,后期补齐成本高。若你的产品要覆盖智慧屏/穿戴,务必在 Step 1 就配置好。

7 一多落地自查清单

工程架构

  • 采用 common / features / products 三层结构
  • common 不依赖 features / products
  • features 不依赖 products
  • products 之间无横向依赖
  • 复用层(common + features)代码量占比 ≥ 80%
  • 所有需要支持的设备类型都写进了 deviceTypes
  • 模块类型(HAR / HSP / Feature HAP / Entry HAP)选择有明确理由
  • 手机与平板共用一个 entry,deviceTypes 同时含 phone 与 tablet

断点基础设施

  • 使用 getWindowWidthBreakpoint() / getWindowHeightBreakpoint(),未自算阈值
  • 通过 windowSizeChange 监听,一次监听全局广播
  • 有统一的断点取值器(如 BreakpointType<T>
  • 断点相关数值全部收敛为常量,且可追溯官方规范
  • 使用媒体查询的 device-type 做布局分支
  • 使用 deviceInfo.deviceType 做布局分支
  • 同时处理了横向断点与纵向断点(如有需要)
  • 状态栏 / AI 导航条避让高度已纳入全局状态

界面适配

  • 先用七种自适应能力打底,再用断点做响应式
  • 无固定 vp 宽度(尤其是搜索框、输入框、卡片)
  • Tabs 在 lg/xl 为左侧竖排(BarPosition.Start + vertical(true)),其余为底部
  • 底部页签已避让 AI 导航条
  • 挪移布局使用 GridRow span 变化,而非 if-else 重建
  • 栅格列数与 gutter 符合官方参数表
  • 分栏场景使用 HdsNavigation / HdsSideBarNavigationMode.Split
  • 列表-详情场景实现了占位页与折叠开合状态保持
  • 宫格列数遵循 xs1 / sm2 / md3 / lg4 / xl5

交互与配置

  • 长按等交互使用归一化手势,未使用原始 touch 事件
  • PC 端补充了键盘快捷键(如适用)
  • 未强制竖屏;横屏支持依据短边 >348vp 判据
  • 使用增强启动页而非简易启动页
  • 深色模式资源已在 resources/dark 配置

验收测试

  • 手机竖屏 / 横屏
  • 折叠屏外屏 / 内屏(含开合连续性:播放进度、滚动位置、路由栈不丢)
  • 平板竖屏 / 横屏
  • PC 全屏 / 自由窗口(拖拽改变窗口宽度,验证断点实时响应)
  • 分屏 / 悬浮窗(验证"断点面向窗口"确实生效)
  • 深色模式
  • 遥控器 / 表冠(如支持 tv / wearable)

附录 官方数值速查表

断点

类型断点取值
横向(vp)xs(0, 320)
sm[320, 600)
md[600, 840)
lg[840, 1440)
xl[1440, +∞)
纵向(高宽比)sm(0, 0.8) 横向窗口
md[0.8, 1.2) 类方形窗口
lg[1.2, +∞) 纵向窗口

横向 5 档 × 纵向 3 档 = 最多 15 种布局设计。

组件阈值

阈值含义
348vp屏幕短边大于此值可获得较好横屏阅读体验,建议支持横屏
600vpHdsNavigation 单栏 / 分栏分界;Navigation 自适应模式分栏触发点
840vpHdsSideBar 悬浮式 / 嵌入式分界;三栏常驻触发点
44.5% / 50%NavigationnavBarWidth(lg/xl 为 44.5%,其余 50%)

栅格

参数项手机折叠屏平板车机智慧屏
竖屏栅格数488--
横屏栅格数88121212
基础栅格 Margin(vp)2424242448
基础栅格 Gutter(vp)2424242424
卡片栅格 Margin(vp)1212121248
卡片栅格 Gutter(vp)1212121224
  • 栅格系统:12 列(可被 2/3/4/6 整除)
  • 网格基本单位:8vp;小控件可对齐 4vp
  • GridRow 默认 4 个断点,最多启用 6 个断点

宫格列数

xssmmdlgxl
12345

Tabs 页签

断点verticalbarPosition位置
lg / xltrueBarPosition.Start页面左侧
xs / sm / mdfalseBarPosition.End屏幕下方

视频沉浸播放窗口比例规则(16:9 视频源)

窗口比例处理
< 14.4:9优先保障视频完整性,左右留白
14.4:9 ~ 18:9铺满一边后裁剪超出部分,全屏沉浸
18:9 ~ 20:9视频从状态栏区域开始显示,顶部与左右三边沉浸
> 20:9视频纵向居中显示
Logo

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

更多推荐