
写鸿蒙开发也有两年多了从一开始在手机上折腾 ArkTS到后来把同一套代码跑在平板和 PC2in1上中间踩过的坑能写一本小册子。今天想跟你聊聊 HarmonyOS 多终端适配这件事特别是 ArkTS 在手机和 PC 这种跨度比较大的设备之间到底是怎么做到“一套代码、多处运行”的。先说结论鸿蒙的适配思路和传统安卓、iOS 完全不一样。安卓讲究“dp 密度适配 屏幕适配”本质还是围绕手机。鸿蒙 NEXT 5.0API 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对应手机竖屏。md600vp ~ 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.0API 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数组这决定了你的应用能装到哪些设备上。如果你想跑 PC2in1这里必须包含2in1否则模拟器/真机上根本装不了。很多新手上来只保留phone然后在 PC 模拟器上怎么装都失败排查半天发现是这个字段没写。另外你还会看到srcEntry指向EntryAbility.ets这是应用入口。里面有个onWindowStageCreate回调是窗口创建后你的代码第一次能碰 UI 层的地方。后面我们聊窗口监听时会再回来。2.3 尺寸单位体系vp、fp、lpx 是三个完全不同的东西鸿蒙的尺寸单位有三个新手特别容易混单位全称特点适用场景vpvirtual pixel虚拟像素基于设备密度归一化在不同密度设备上保持视觉尺寸一致布局尺寸、间距、宽高fpfont pixel字体像素跟随系统字体大小设置缩放文字大小lpxlogical 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(); } }注意一点不要真的写两种完全不同的页面。你应该是“同一批组件不同的组合方式”比如手机上底部 TabPC 上左侧导航但中间的内容卡片是复用的。Navigation 组件在 API 12 上已经支持自动分栏模式了。直接用NavigationMode.Auto当窗口宽度超过一定阈值时Navigation 会自动从“单页面堆栈”变成“侧边栏 内容区”。我建议自己在工程里先跑个 demo 感受一下比听我描述直观得多。4. 实操做一个自适应“任务看板” App4.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和2in1PC 模拟器。新手常犯的错是只创建了 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 里被销毁重建状态数据提升到父组件持有模拟器上正常、真机上字体偏大/偏小系统字体缩放设置不一致排查用户设备上的显示大小设置全部用 fp5.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 里左右拖窗口宽度体验非常直观。希望这篇文章能帮你少踩几个坑。