写鸿蒙开发也有两年多了,从一开始在手机上折腾 ArkTS,到后来把同一套代码跑在平板和 PC(2in1)上,中间踩过的坑能写一本小册子。今天想跟你聊聊 HarmonyOS 多终端适配这件事,特别是 ArkTS 在手机和 PC 这种跨度比较大的设备之间,到底是怎么做到“一套代码、多处运行”的。
先说结论:鸿蒙的适配思路和传统安卓、iOS 完全不一样。安卓讲究“dp 密度适配 + 屏幕适配”,本质还是围绕手机。鸿蒙 NEXT 5.0(API 12+)直接引入了“断点 + 栅格 + 自适应布局”这套组合拳,把屏幕宽度当成第一适配维度,设备类型反而是次要的。这篇文章我会从底子讲起,包括 ArkTS 语言约束、vp/fp/lpx 单位体系、mediaquery 断点监听、GridRow 栅格布局,再带你把一个真实项目从手机布局改到 PC 布局。适合刚上手鸿蒙的开发者,也适合那些被“手机好好的、一上 PC 就乱”折磨到崩溃的朋友。
我不写那种“官方文档搬运体”,尽量用我实际测试过的方案来讲,该吐槽的地方也会直接吐槽。
1. 为什么多终端适配在鸿蒙里是“一等公民”
1.1 设备形态本质上是连续的光谱,不是离散的格子
很多开发者的第一反应是:手机、平板、PC 是三种设备,我分别写三种布局不就行了?这个思路在传统开发里没问题,但在鸿蒙里会非常难受。原因很简单:设备形态在鸿蒙里不是离散的,而是连续的。
手机横竖屏切换、折叠屏展开、平板分屏、PC 窗口自由拖拽大小,这些场景叠加在一起,屏幕宽度可能从 320vp 一路变到 1200vp 以上。如果你在代码里写“deviceType == phone,用 A 布局;deviceType == pc,用 B 布局”,那折叠屏展开这种场景你根本没法处理,因为你拿到的 deviceType 没变,但可用宽度已经变了。
鸿蒙的做法是:把窗口宽度作为第一判断维度,设备类型只决定默认值和能力边界。这套思路在 PC 上尤其明显。PC 应用不是全屏跑的,你可以把一个 800vp 宽的小窗拖到 1600vp,这时候布局必须跟着窗口宽度重新排列,而不是跟着“PC”这个标签死板地走。
所以你会看到鸿蒙官方的断点规范是三个档位:
- sm:宽度 < 600vp,对应手机竖屏。
- md:600vp ~ 840vp,对应平板竖屏、手机横屏、折叠屏展开。
- lg:> 840vp,对应平板横屏、PC 大窗。
这就是“适配的锚点”。你在 PC 上把窗口从 700vp 拖到 900vp,布局会立刻从 md 档切到 lg 档,整个过程和 deviceType 无关。
1.2 不只是 UI 缩放,是信息架构重组
多说一句,很多人把“多终端适配”理解成“让界面在不同尺寸下不变形”,这是不对的。真正的适配是信息架构的重组。
手机上空间有限,底部 Tab + 单列列表是最合理的信息结构。PC 上屏幕宽裕,用户可以同时看到导航、列表、详情三个层级,这时候单列列表反而是浪费。鸿蒙的 Navigation 组件有个NavigationMode.Auto模式,它会根据窗口宽度自动决定是单层页面栈回归模式,还是变成“侧边栏 + 内容区”的分栏模式。这种变化不是简单的缩放,而是布局模式的整体切换。
官方的说法叫“一多”:一套代码,多端部署。但这背后真正的含义是:你的页面结构必须是“可重排”的,而不是“可缩放”的。这一点想通了,后面写代码才会顺手。
2. 先搞清楚 ArkTS、API 12+ 和 DevEco 的底子
2.1 ArkTS 是“带着枷锁跳舞”的 TypeScript
鸿蒙 NEXT 5.0(API 12+)的官方开发语言是 ArkTS。它本质上是一个 TypeScript 的超集,但做了非常多的约束。说直白点,ArkTS 就是“去掉 JS 动态特性、强化静态类型”的 TypeScript。
你写惯了 React/Vue 里的 TS,刚上手 ArkTS 会很不习惯。重点说几个实际开发中高频遇到的限制:
第一,不允许any类型。ArkTS 会强制要求所有变量有明确类型。这对写过大型 TS 项目的人其实是个福音,但对喜欢“先用 any 糊着,后面再改”的人来说就是噩梦。
第二,对象字面量必须对应明确的接口或类。你不能写let obj = { name: 'xx', age: 18 }然后到处传给各种函数。你必须先声明一个 interface 或 class,再按这个结构传值。
第三,部分 JS 动态特性被禁用,比如Object.defineProperty、Proxy、with语句这些都不行。
为什么 ArkTS 要这么严格?核心原因是方舟编译器的设计目标:静态分析能发现大部分类型问题,编译期做优化,运行时性能更稳定。特别是在 PC 这种大屏设备上跑复杂页面,JS 动态特性带来的不确定性会被放大。这套约束虽然让写码时有点“憋屈”,但长期看对工程质量是加分项。
2.2 工程结构:module.json5 里的 deviceTypes 是入口
新建一个 HarmonyOS 工程(DevEco Studio 里选Empty Ability),你会看到entry/src/main/module.json5这个文件。这里有个关键字段:
{ "module": { "name": "entry", "type": "entry", "deviceTypes": ["phone", "tablet", "2in1"], "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "exported": true } ] } }注意deviceTypes数组:这决定了你的应用能装到哪些设备上。如果你想跑 PC(2in1),这里必须包含"2in1",否则模拟器/真机上根本装不了。很多新手上来只保留phone,然后在 PC 模拟器上怎么装都失败,排查半天发现是这个字段没写。
另外你还会看到srcEntry指向EntryAbility.ets,这是应用入口。里面有个onWindowStageCreate回调,是窗口创建后你的代码第一次能碰 UI 层的地方。后面我们聊窗口监听时会再回来。
2.3 尺寸单位体系:vp、fp、lpx 是三个完全不同的东西
鸿蒙的尺寸单位有三个,新手特别容易混:
| 单位 | 全称 | 特点 | 适用场景 |
|---|---|---|---|
| vp | virtual pixel | 虚拟像素,基于设备密度归一化,在不同密度设备上保持视觉尺寸一致 | 布局尺寸、间距、宽高 |
| fp | font pixel | 字体像素,跟随系统字体大小设置缩放 | 文字大小 |
| lpx | logical pixel | 逻辑像素,以屏幕宽度为基准(默认按 960 lpx 设计稿) | 特殊场景,如卡片设计稿快速换算 |
最常用的是 vp 和 fp。你只要记住:布局和间距用 vp,字号用 fp,特别是涉及到多设备时,绝对不要用 px 或者直接用 vp 做字号。在 PC 上用户可能设置了 150% 的系统缩放,用 fp 的字号会跟着正确变大,用 vp 就会显得偏小。
lpx 相对边缘,它在“界面要跟随屏幕宽度做等比缩放”时有点用,但用多了很容易出问题,建议默认不用,先掌握 vp/fp。
3. 多终端适配机制的三个核心层,把原理讲透
3.1 第一层:资源与配置差异化
很多人不知道,鸿蒙的resources目录天生支持多设备覆盖。默认结构是:
entry/src/main/resources/ ├── base/ │ ├── element/ │ │ ├── string.json │ │ └── color.json │ └── media/ ├── en_US/ ├── dark/ ├── tablet/ └── landscape/也就是说,你可以在base/element/string.json里写一个main_title,再在tablet/element/string.json里写一个同名的main_title用不同的值。运行时系统会根据当前设备自动选择对应目录下的资源。
比如:
// base/element/string.json { "string": [ { "name": "main_title", "value": "首页" } ] }// tablet/element/string.json { "string": [ { "name": "main_title", "value": "控制台首页" } ] }在代码里引用:
this.title = $r('app.string.main_title');这时在平板上你会看到“控制台首页”,在手机上看到“首页”,代码不用做任何分支判断。
这套“资源限定符”机制同样支持布局、颜色、图片等资源。我的经验是:能放进资源目录的内容就别写死在代码里,这既方便多端适配,也方便后期做多语言。
3.2 第二层:自适应布局与栅格
如果说资源限定符是“换零件”,那自适应布局就是“让零件自己会伸缩”。鸿蒙的自适应布局核心是两样东西:Flex 弹性布局和GridRow/GridCol 栅格布局。
Flex 比较好理解,跟 Flutter 的 Flex、前端的 Flexbox 类似。justifyContent和alignItems控制主轴/交叉轴对齐,flexShrink和flexGrow控制伸缩比例。写出来的界面天然能在不同宽度下重排。
栅格是重头戏。传统的 Grid 布局要指定“在这个位置放几个格子”,而鸿蒙的GridRow/GridCol是响应式的——你可以针对不同断点指定不同的列数。看个例子:
GridRow({ columns: { xs: 2, sm: 4, md: 8, lg: 12 }, gutter: 12 }) { GridCol({ span: { xs: 2, sm: 2, md: 4, lg: 6 } }) { this.buildCard() } // ... 更多 GridCol }意思是:在 xs(手机小屏)上总列数 2,每个卡片占 2 列,一行一个;在 sm 上总列数 4,卡片占 2 列,一行两个;在 lg 上总列数 12,卡片占 6 列,一行两个但更宽松。
这种写法的精髓在于:你不用自己判断断点,栅格系统会根据当前窗口宽度自动套用对应列规则。卡片增多减少、内容变化时,排列自动调整。
我实际用下来的建议是:能上栅格就上栅格,不要手动写一堆if (width > 600)。栅格把适配逻辑集中到了布局声明里,代码可读性和可维护性强出一个档次。
3.3 第三层:断点监听与页面结构切换
资源限定符负责“资源自动选型”,栅格负责“局部自动重排”,但有些全局性的结构切换必须靠断点监听。典型场景:手机上底部 Tab 导航,PC 上变左侧菜单。
鸿蒙做断点监听最常用的方式是通过mediaquery。看代码:
import { mediaquery } from '@kit.ArkUI'; const listener = mediaquery.matchMediaSync('(width >= 0vp) and (width < 600vp)'); listener.on('change', (result) => { if (result.matches) { this.currentBreakpoint = 'sm'; } else { this.currentBreakpoint = 'lg'; } });你可以在aboutToAppear里注册监听,在aboutToDisappear里释放。拿到currentBreakpoint之后,你就可以在 build 函数里做结构切换:
build() { if (this.currentBreakpoint === 'sm') { this.buildPhoneLayout(); } else { this.buildPcLayout(); } }注意一点:不要真的写两种完全不同的页面。你应该是“同一批组件,不同的组合方式”,比如手机上底部 Tab,PC 上左侧导航,但中间的内容卡片是复用的。
Navigation 组件在 API 12 上已经支持自动分栏模式了。直接用NavigationMode.Auto,当窗口宽度超过一定阈值时,Navigation 会自动从“单页面堆栈”变成“侧边栏 + 内容区”。我建议自己在工程里先跑个 demo 感受一下,比听我描述直观得多。
4. 实操:做一个自适应“任务看板” App
4.1 建工程、配置设备类型
我实际做这个项目的时候,目标是把一个移动端向的任务管理 App 跑在手机和 PC 上。先新建工程,工程模板选Empty Ability,包名自己定。然后改module.json5:
{ "module": { "name": "entry", "type": "entry", "deviceTypes": ["phone", "tablet", "2in1"], "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "exported": true, "window": { "designWidth": 360, "autoDesignWidth": true } } ] } }designWidth和autoDesignWidth这个是涉及 px 转 vp 的设计稿适配。如果你的设计稿是 360vp 宽(参考手机竖屏),开着这个选项,你在代码里写 720 会被自动折算成 360vp 比例下的值。不过我用下来还是建议尽量直接用 vp 写布局,不要把换算逻辑完全丢给框架。
4.2 首页断点布局的完整代码
下面这是核心页面代码。注意我用了GridRow/GridCol做内容区栅格,用NavigationMode.Auto处理页面导航结构。这段代码可以直接跑在 API 12+ 工程里。
import { mediaquery } from '@kit.ArkUI'; @Entry @Component struct Index { @State currentBreakpoint: string = 'sm'; private listener?: mediaquery.MediaQueryListener; aboutToAppear(): void { this.listener = mediaquery.matchMediaSync('(width >= 0vp) and (width < 600vp)'); this.listener.on('change', (result: mediaquery.MediaQueryResult) => { this.currentBreakpoint = result.matches ? 'sm' : 'lg'; }); } aboutToDisappear(): void { this.listener?.off('change'); } build() { Navigation() { if (this.currentBreakpoint === 'sm') { this.buildPhoneContent(); } else { this.buildPcContent(); } } .mode(NavigationMode.Auto) .title(this.currentBreakpoint === 'sm' ? '任务看板' : '任务看板 - 工作台') .navBarWidth(240) } @Builder buildPhoneContent() { // 手机端:单列卡片流 + 底部操作按钮 Scroll() { Column({ space: 12 }) { ForEach(this.tasks, (task: TaskModel) => { TaskCard({ task: task }) }, (task: TaskModel) => task.id) } .padding(12) } .layoutWeight(1) Button('新建任务') .width('90%') .height(48) .margin({ bottom: 12 }) } @Builder buildPcContent() { // PC 端:左侧为统计面板,右侧为卡片栅格 Row() { Column({ space: 12 }) { Text('进行中').fontSize(20).fontWeight(FontWeight.Bold) Text(`${this.tasks.filter(item => item.status === 'active').length} 项`) .fontSize(16) .fontColor('#666') // 统计图表占位 Progress({ value: 60, total: 100 }) .width('100%') } .padding(16) .width(240) .height('100%') Scroll() { GridRow({ columns: { xs: 4, sm: 4, md: 8, lg: 12 }, gutter: 12 }) { ForEach(this.tasks, (task: TaskModel) => { GridCol({ span: { xs: 4, sm: 2, md: 4, lg: 3 } }) { TaskCard({ task: task }) } }, (task: TaskModel) => task.id) } .padding(12) } .layoutWeight(1) } .height('100%') } }说一下这段代码的思路:
手机上buildPhoneContent是单列滚动列表,底部一个大按钮,符合单手操作习惯。PC 上buildPcContent左边是 240vp 宽的面板,右边是栅格卡片区。两个内容的构建方式是逻辑分支,但里面的TaskCard是同一个组件,只是容器重新排了序。这才是“自适应”和“硬编码两台设备页面”的本质区别。
4.3 跨端状态同步(保留应用状态)
真正的多终端不仅仅是 UI 重排,状态也得跟着设备走。当然,分布式数据同步是个大话题,今天不展开。我想说的是一个在同一设备内部非常容易被忽视的状态问题:窗口尺寸变化时,页面里的内容不能丢失。
PC 上用户把窗口从 1000vp 缩到 500vp,布局从 lg 切到 sm。如果你在手机上用的@State数据存的是列表滚动位置或选中项,切换布局后这些状态应该仍然保留。
实操里最容易出问题的做法是:在build里根据断点重新创建子组件。比如:
if (this.currentBreakpoint === 'sm') { this.buildPhoneContent(); }这样切换时子组件会被销毁重建,局部状态就丢了。解决办法是:把需要保留的关键数据提升到页面组件这一层(@State),或者用@StorageLink绑定到应用级存储。我在上面代码里把 tasks 放在 Index 层统一管理,就是为了切换布局时不丢数据。
4.4 把应用跑在 PC 模拟器上
DevEco Studio 的设备选择器里,除了Phone还有Tablet和2in1(PC 模拟器)。新手常犯的错是只创建了 phone 的模拟器,然后发现没法加 2in1 镜像。
实际步骤如下:
- 打开 DevEco Studio,点击右上角 Device Manager。
- 在模拟器管理里新增设备,选择
2in1,下载对应的系统镜像(API 12 或更高)。 - 回到工程,确认
module.json5里deviceTypes包含"2in1"。 - 点击 Run,选择 2in1 模拟器。
跑起来之后你可以直接用鼠标拖拽模拟器窗口大小,观察布局变化。我强烈建议在开发阶段就用 2in1 模拟器跑,因为 Previewer(预览器)在模拟窗口尺寸时不够真实,很多布局问题只有真机/模拟器上才暴露。
5. 常见问题与排查技巧实录
下面这些是我自己踩过、也在社区里反复看到的典型问题,整理成表格,方便你对照排查。
| 问题现象 | 根因 | 解决思路 |
|---|---|---|
| 手机上正常,到 PC 上布局乱掉 | 使用了固定宽高(px/vp 写死) | 把固定宽高改为百分比、权重或栅格 |
| 在 PC 上字体特别小 | 用 vp 设置了字号,没走 fp | 字号统一用 fp,跟随系统缩放 |
| 窗口拖拽大小时卡顿、闪退 | 在onWindowResize或断点回调里做了重复且耗时的逻辑 | 回调只改状态,不直接操作 UI 密度;必要时节流 |
| PC 上鼠标点击没任何视觉反馈 | 默认 touch 反馈在 hover/click 场景不明显 | 针对鼠标设备补充 hover 态样式 |
| 断点切换后页面数据丢失 | 子组件在 build 里被销毁重建 | 状态数据提升到父组件持有 |
| 模拟器上正常、真机上字体偏大/偏小 | 系统字体缩放设置不一致 | 排查用户设备上的显示大小设置,全部用 fp |
5.1 “断点切换后布局对了,但动画特别怪”
这个问题比较隐晦。原因是:布局切换时你用if/else直接换了组件结构,ArkUI 没有足够的过渡动画状态。解决方法是加animateTo配合transition效果。我自己写的时候是给 Column/Row 加.transition(TransitionType.ALL, { opacity: 0.2 }之类,虽然不能完全像 Flutter 那样丝滑,但观感能接受。
5.2 “同样的代码,手机看没问题,PC 上滚动区域失效”
PC 上滚动容器默认是鼠标滚轮,在 Previewer 里用触屏手势模拟容易误判。真机 PC 上没问题,但 Previewer 里滚动不到底。这个其实不是代码 bug,而是工具差异。建议设置 PC 模拟器真机验证,不要依赖 Previewer 判断滚动行为。
5.3 “想针对 PC 加右键菜单,怎么判断当前是 PC?”
不要用 deviceType 判断。正确做法是:监听inputDevice相关事件,比如onMouse、onHover这些事件只有鼠标设备才会频繁触发。或者更简单,在 PC 上布局本身就是 lg 断点,直接用断点判断要不要显示右键菜单就是合理的方案。
5.4 “断点数量太粗暴,平板横竖屏之间还想再细分怎么办?”
断点规范是死的,需求是活的。你可以在 sm/md/lg 之间再加一层自定义断点,比如xl(> 1200vp)。核心是:把断点定义成常数,阅读和传播都方便。
export const Breakpoint = { SM: '(width >= 0vp) and (width < 600vp)', MD: '(width >= 600vp) and (width < 840vp)', LG: '(width >= 840vp) and (width < 1200vp)', XL: '(width >= 1200vp)' } as const;然后在代码里组合监听。
6. 聊聊上架和成本,给准备做产品的人泼盆冷水
6.1 上架流程与必须准备的材料
HarmonyOS 应用上架到华为应用市场,走的是 AppGallery Connect。大概流程是:注册开发者账号 -> 创建应用 -> 配置软件包 -> 提交审核 -> 上架。
材料方面大家最常忽略的是软著(软件著作权)。应用市场一般要求提供软著证明,这个自己申请免费但要等时间,找代办几百块能加急。另外就是隐私政策文件,涉及用户信息收集的必须写清楚。别等到提交审核了才发现缺材料,上线周期会直接拉长两周以上。
签名证书方面,鸿蒙有 debug 和 release 两套签名体系。Release 证书要去 AppGallery Connect 后台申请,过程不算复杂,但需要把 CSR 文件、证书指纹这些提前准备好。
6.2 “开发一个 App 上架要多少钱?”的真实答案
网上搜这个热词的人很多,但答案非常取决于需求。我给一个比较现实的分层:
- 个人开发者自己做工具类小应用,上架成本大约 0 到几千元(软著代办、开发者账号认证、测试真机)。
- 找外包做 MVP,一个功能简单的 App 报价通常在几万到十几万,但这只是开发费,不含服务器、运营、推广。
- 做一个像“网约车 App”这种复杂的业务系统,核心成本根本不在客户端,在于后端调度、支付、地图、合规这些。有人说几十万,有人说几百万,都不奇怪。客户端 UI 的多端适配,在这类项目里反而只是很小的一块预算。
我的看法是:如果你是在自己做产品,别被“开发一个 App 多少钱”这种问题困住。先用低成本把核心场景跑通,再考虑多端。一个手机端能验证的业务模型,没必要一开始就铺到 PC 上。
最后再补一句实操体会吧。我自己做完这个任务看板项目后最深的感受是:多终端适配真正难的不是技术,而是思维转变。当你还在想“这个页面在手机上怎么放、在 PC 上怎么放”的时候,你已经把设备当成敌人了。正确的姿势是:把你的内容拆成组件,定义信息层级,然后让栅格和断点帮你决定组件怎么排。
还有一个小技巧,写代码时把手机断点和 PC 断点的布局放在同一个文件里对比着看,改完栅格参数后在 Previewer 里左右拖窗口宽度,体验非常直观。希望这篇文章能帮你少踩几个坑。