当前位置:首页>鸿蒙APP>鸿蒙端云一体化实战(一):Stage 模型与一次开发多端部署的工程落地

鸿蒙端云一体化实战(一):Stage 模型与一次开发多端部署的工程落地

  • 2026-10-10 17:52:12
鸿蒙端云一体化实战(一):Stage 模型与一次开发多端部署的工程落地

前言

做过安卓再去看鸿蒙,第一感觉一般是:这也太像了。等写到第三个页面,就会发现 Ability 生命周期、Stage 模型、分布式软总线,和 Android 的 Activity 完全不是一回事。更头疼的是业务方一句"手机、平板、车机都要上",如果每个端单独维护一套代码,团队人效直接崩盘。

这个系列打算用三篇文章聊透鸿蒙端云一体化。第一篇先回答最基础、也最容易被忽略的问题:Stage 模型到底怎么组织代码,才能真正做到一次开发、多端部署?

1. Stage 模型不是 Activity 的翻版

HarmonyOS Next 全面采用 Stage 模型,FA 模型基本进入维护状态。核心变化其实就两点:

  • 生命周期统一交给 Stage 调度,而不是每个 Ability 自己玩自己的。
  • UI 内容交给 WindowStage 管理,Ability 只负责入口和状态。

这带来的直接好处是同一份业务逻辑,可以挂到不同设备的入口上。手机上是全屏 Page,车机里可能是嵌入 Dashboard 的 Ability,手表上变成 Form 卡片。业务代码不用重写。

简单对比一下:

维度
FA 模型
Stage 模型
生命周期调度
各 Ability 独立
Stage 统一调度
窗口管理
与 Ability 绑定
WindowStage 独立
多设备适配
多套 Ability
同一 Ability + 响应式布局
适用版本
鸿蒙 2.x
HarmonyOS Next / API 9+

如果项目还在用 FA 模型,迁移优先级建议直接 P0。不是它马上不能用,而是服务卡片、意图框架、云开发这些新能力基本只在 Stage 下支持。

2. 一次开发多端部署的四个层级

很多人把"一次开发多端部署"理解成同一套代码跑在所有设备上,其实它可以分成四个层次,每一层的复杂度和收益都不一样:

层级
核心做法
难点
适合场景
同一 HAP 多设备
同一个 entry 包,deviceTypes 同时声明 phone/tablet/car
响应式布局、交互差异
业务逻辑高度一致的场景
多 HAP 按需分发
entry + feature 模块,按设备类型或用户权限动态下载
HAP 拆分、模块间通信、路由
功能差异较大的设备,比如车机专属模式
跨设备流转
通过分布式软总线把 Ability 迁移到另一台设备
状态同步、安全授权、网络发现
视频通话、导航、文档协作
端云一体化
设备端 + 云函数 + 云数据库统一交付
离线策略、数据一致性、流量成本
需要用户数据多端实时同步的业务

这次系列主要覆盖第一层和第四层。先保证同一套代码在手机、平板、车机上稳定运行,再逐步接入云端能力。对大多数业务来说,先把第一层做扎实,收益就已经很明显,没必要一上来就追求跨设备流转。

3. 设计原则:能力下沉,UI 上浮

多端复用最容易犯的错是"把 UI 判断和业务判断混在一起"。比如代码里写 if (deviceType === 'car') { doSomething() },时间一长,这种分支会扩散到整个项目,想改一个逻辑要动几十处。

比较稳的做法是把能力分成三层:

  • 业务层:纯业务逻辑,不知道自己在哪个设备上运行。订单的创建、查询、校验都在这里。
  • 适配层:根据设备能力决定行为。比如车机默认语音播报、平板默认展开详情、手机默认 push 到下一页。
  • UI 层:只负责把状态和布局画出来,规则由适配层注入。

用一个简单的例子说明。假设提交订单前要校验库存:

// features/order/OrderService.etsexportclassOrderService {staticasyncsubmit(order: OrderDraft): Promise<SubmitResult> {// 纯业务逻辑,不涉及设备类型const stock = awaitthis.checkStock(order.skuId)if (stock < order.quantity) {return { ok: false, reason: '库存不足' }    }returnhttpRequest('/api/orders', { method: 'POST', body: order })  }}
// features/order/OrderPresenter.etsimport { DeviceAdapter, DeviceType } from'../../utils/DeviceAdapter'exportclassOrderPresenter {staticshouldAutoExpandDetail(): boolean {returnDeviceAdapter.getDeviceType() === DeviceType.TABLET  }}

页面层只调用 presenter 给的状态,不用自己判断设备:

if (OrderPresenter.shouldAutoExpandDetail()) {this.showDetailPanel = true}

这套分层在安卓组件化里也很常见,只是鸿蒙里 abilities/features/mediaquery 的边界更明确,执行起来更顺手。

Stage 模型里几个概念的关系可以用下面这张图概括:

Application   │   ├── Stage   │     ├── WindowStage   │     │       └── UI Content (ArkUI Page)   │     └── Ability   │           ├── Page Ability (普通页面)   │           ├── Service Ability (后台任务)   │           └── Form Ability (服务卡片)   │   └── App Context (全局状态、账号、缓存)

Ability 不再是"页面容器",而是"入口声明"。真正的页面渲染交给 WindowStage,这一点和 Android 的 Activity 自己管理 Window 差别很大。

4. module.json 与 HAP 类型配置

多端部署不只是代码层面的事,还涉及 HAP 包的拆分策略。module.json 里两个关键字段:moduleType 和 deviceTypes。

{    "module":{    "name":"entry",    "type":"entry",    "description":"$string:module_desc",    "mainElement":"EntryAbility",    "deviceTypes":[        "phone",        "tablet",        "car"    ],    "deliveryWithInstall":true,    "installationFree":false,    "pages":"$profile:main_pages",    "abilities":[    {        "name":"EntryAbility",        "srcEntry":"./ets/abilities/EntryAbility.ets",        "description":"$string:EntryAbility_desc",        "icon":"$media:icon",        "label":"$string:EntryAbility_label",        "startWindowIcon":"$media:icon",       "startWindowBackground":"$color:start_window_background",        "exported":true,        "skills":[            {            "entities":[                "entity.system.home"            ],        "actions":[            "action.system.home"            ]        }        ]    }    ]    }}

值得注意的地方:

  • 如果 module 的 deviceTypes 包含 car,不代表代码能直接跑上车机,还需要通过响应式布局做 UI 适配。
  • type: entry
     是应用入口,必须有且仅有一个。type: feature 用于业务模块,可以按需动态下载。
  • installationFree
     为 true 时,模块可以作为原子服务免安装使用,适合服务卡片场景。

合理拆 HAP 后,手机用户只下载核心 entry,车机用户再按需拉取 feature 模块,包大小和更新成本都能降下来。

feature 模块的典型配置如下:

{"module":{"name":"carMode","type":"feature","deviceTypes":["car"],"deliveryWithInstall":false,"installationFree":true,"pages":"$profile:main_pages"}}

这里 deliveryWithInstall: false 表示首次安装时不携带该模块,等用户在车机上登录后再通过 HMS 的模块化能力动态下载。需要注意,feature 模块不能独立运行,它依赖 entry 模块提供的基础能力和路由表。

5. 工程结构:一层骨架,多端复用

下面这个目录结构是我个人在项目中验证过的:

entry/src/main/ets/├── abilities/# Ability 入口│   ├── EntryAbility.ets    # 主入口│   └── FormAbility.ets     # 服务卡片入口├── pages/# 页面│   ├── Index.ets│   └── OrderDetail.ets├── features/# 按业务模块划分│   ├── order/│   │   ├── OrderList.ets│   │   ├── OrderDetail.ets│   │   └── OrderService.ets│   └── user/├── components/# 公共组件├── mediaquery/# 断点与响应式工具└── entryability/

三个要点:

  1. abilities 层只负责启动和路由,不要塞业务逻辑。Ability 一多,生命周期代码满天飞,后期很难改。
  2. features 层按业务域拆分,每个 feature 自己管数据、状态、接口。手机和平板共用同一个 OrderService。
  3. mediaquery 层统一处理屏幕断点,页面只接收 breakPoint 状态,不用关心具体设备类型。

我还习惯在根目录放一个 DeviceAdapter.ets,把设备形态判断收敛到一个文件里:

// utils/DeviceAdapter.etsimport { display } from'@kit.ArkUI'exportenumDeviceType {PHONE,TABLET,CAR,WEARABLE}exportclassDeviceAdapter {staticgetDeviceType(): DeviceType {const info = display.getDefaultDisplaySync()const width = info.width / info.densityPixelsif (width < 600) {returnDeviceType.PHONE    } elseif (width < 900) {returnDeviceType.TABLET    } else {returnDeviceType.CAR    }  }}

虽然布局尽量按宽度断点处理,但有些业务逻辑确实需要知道设备类型,比如车机上默认开启语音播报。这种判断放在一处,避免散落在各个页面。

这里有个细节:车机屏幕可能很宽,按宽度会误判为平板。所以更稳妥的做法是结合 deviceType 和宽度一起判断。鸿蒙在 deviceInfo 模块里提供了 deviceType 字段,取值包括 phone、tablet、wearable、car 等。建议把 deviceType 用于业务默认值,把宽度用于布局断点,两者不要互相替代。

6. 响应式布局:同一套规则伸缩,而不是写三套 UI

鸿蒙常用的适配手段有三种:gridRow/gridCol、mediaquery、breakpointSystem。实战中我最习惯用的是 breakpointSystem 加 Flex/Row/Column。

下面这段代码监听屏幕宽度,把断点分成 sm、md、lg 三档:

// mediaquery/BreakPointSystem.etsimport mediaQuery from'@ohos.mediaquery'exportclassBreakPointSystem {privatelisteners: Array<(breakPoint: string) =>void> = []private smListener = mediaQuery.matchMediaSync('(width<600vp)')private mdListener = mediaQuery.matchMediaSync('(600vp<=width<900vp)')private lgListener = mediaQuery.matchMediaSync('(width>=900vp)')register(listener: (breakPoint: string) => void) {this.listeners.push(listener)this.smListener.on('change', () =>this.notify('sm'))this.mdListener.on('change', () =>this.notify('md'))this.lgListener.on('change', () =>this.notify('lg'))  }privatenotify(bp: string) {this.listeners.forEach(l =>l(bp))  }}

页面里这样用:

// pages/OrderList.etsimport { BreakPointSystem } from'../mediaquery/BreakPointSystem'@Entry@Componentstruct OrderList {@StatebreakPoint: string = 'sm'aboutToAppear() {newBreakPointSystem().register(bp =>this.breakPoint = bp)  }build() {Column() {if (this.breakPoint === 'sm') {OrderListMobile()      } else {OrderListTablet()      }    }    .width('100%')    .height('100%')  }}

注意这里不是按 deviceType 判断,而是按可用宽度。折叠屏展开后宽度变了,就该走平板布局。这才是"一次开发、多端部署"的关键。

不同断点下的布局策略可以参考下表:

断点
宽度范围
典型设备
布局策略
sm
< 600vp
竖屏手机
单列堆叠,底部导航
md
600-900vp
折叠屏展开、小平板
双栏或侧边抽屉
lg
>= 900vp
大平板、车机
三栏、左侧固定 + 右侧详情

除了 BreakPointSystem,官方还提供了 GridRow / GridCol 组件,适合商品列表、宫格入口这种内容区域需要自动换行的场景。下面是一个三列自适应的示例:

// components/ProductGrid.ets@BuilderfunctionProductGrid(products: Product[]) {GridRow({ columns: { sm: 2, md: 3, lg: 4 }, gutter: 12 }) {ForEach(products, (item: Product) => {GridCol({ span: { sm: 1, md: 1, lg: 1 } }) {ProductCard({ product: item })      }    })  }  .margin(16)}

这种方式的好处是组件自己声明列数,页面不用写 if/else。缺点是粒度不如 BreakPointSystem 灵活,复杂布局还是推荐后者。

7. Ability 生命周期与状态恢复

Stage 模型下 EntryAbility 的生命周期已经很精简:

// abilities/EntryAbility.etsimport { AbilityConstant, UIAbility, Want } from'@kit.AbilityKit'import { window } from'@kit.ArkUI'exportdefaultclassEntryAbilityextendsUIAbility {onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {// 应用级初始化:账号、推送 token、全局状态  }onWindowStageCreate(windowStage: window.WindowStage): void {    windowStage.loadContent('pages/Index')  }onForeground(): void {// 切到前台  }onBackground(): void {// 切到后台  }onDestroy(): void {// 清理  }}

几条实战经验:

  • onCreate 只做一次性全局初始化,不要在这里请求网络。
  • onWindowStageCreate 负责加载 UI,如果 want 参数需要跳转不同页面,可以在这里分发。
  • onBackground 适合保存草稿,但不要做同步阻塞操作。

很多新手容易忽略的是 onNewWant。当系统已经有一个 Ability 实例,外部通过 Want 再次拉起同一个 Ability 时,不会走 onCreate,而是走 onNewWant。如果你的 App 支持从服务卡片、推送、搜索建议等多个入口跳转,必须在这里处理参数:

onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {// 把 want 参数通知给当前页面的 ViewModelAppEventBus.emit('onNewWant', want)}

另外,Ability 在后台可能被系统回收。ArkTS 不像 Android 那样有 onSaveInstanceState,但可以通过 AppStorage 或持久化存储保留关键状态。我的做法是:把用户正在编辑的表单临时写入 Preferences,onCreate 时恢复。

module.json 里还能配置 launchType,默认值是 standard,每次启动都会新建实例。如果希望同一个入口只保留一个实例,可以设为 singleton。但要注意,singleton 模式下 onCreate 只走一次,后续启动靠 onNewWant 传递参数,页面需要监听全局事件来刷新。

8. 从安卓迁移的常见坑

安卓团队切到鸿蒙时,最容易踩的几个坑:

把 Activity 直接对应成 Ability

Ability 更接近"入口",而不是"页面"。一个 Ability 可以加载多个 ArkUI Page,Page 之间的导航用 router 模块。如果每个页面对应一个 Ability,模块间跳转会变成进程间通信,延迟和复杂度都会上升。

Intent 换 Want 时漏了 URI 参数

Want 支持 action、entities、uri、parameters。其中 parameters 传复杂对象时要注意序列化。鸿蒙有类型系统限制,建议只传 string/number 这类基础类型,对象用 JSON 字符串。

忽略折叠屏的连续变化

折叠屏展开或合上时,应用会收到配置变化。不要只在 onCreate 里读一次屏幕尺寸,要通过 mediaquery 持续监听。否则用户展开屏幕后,界面还是手机布局。

跨设备假设同一进程

鸿蒙的分布式能力允许不同设备间调用 Ability,但这些调用是跨进程的。共享数据不能靠内存,必须走分布式数据库、云数据库或自己实现的 RPC。

把全局状态放在 Ability 里

有些开发者习惯在 EntryAbility 里挂一个单例对象,其他页面通过它读取用户信息。Ability 被系统回收后,这个单例就没了。建议把需要持久化的状态放到 AppStorage 或本地存储里,Ability 只保留临时导航参数。

9. 案例:一个订单模块在手机和平板间复用

需求是"订单列表 + 订单详情"。手机用"列表 -> 点击 -> 详情页"的导航流,平板用"左侧列表 + 右侧详情"的双栏布局。

实现策略:

  1. OrderList 页面同时存在,平板布局下把 OrderDetail 嵌入右侧。
  2. OrderService 提供统一的 fetchOrders() 和 getOrderDetail(id)。
  3. 点击列表项时,手机用 router.pushUrl,平板通过事件更新右侧详情。
// features/order/OrderService.etsexportclassOrderService {staticasyncfetchOrders(): Promise<Order[]> {returnhttpRequest('/api/orders')  }staticasyncgetOrderDetail(id: number): Promise<Order> {returnhttpRequest(`/api/orders/${id}`)  }}

页面中:

// features/order/OrderList.ets@StateselectedOrderId: number = -1onItemClick(order: Order) {if (this.breakPoint === 'sm') {    router.pushUrl({ url: 'pages/OrderDetail', params: { id: order.id } })  } else {this.selectedOrderId = order.id  }}

平板布局可以写成左右分栏:

// features/order/OrderList.ets@BuilderOrderListTablet() {Row() {OrderListPanel({ onItemClick: (o) =>this.onItemClick(o) })      .layoutWeight(1)if (this.selectedOrderId > 0) {OrderDetailPanel({ orderId: this.selectedOrderId })        .layoutWeight(2)    } else {EmptyDetailPanel()        .layoutWeight(2)    }  }  .width('100%')  .height('100%')}

手机布局则直接跳到详情页。两套 UI 共用同一个 ViewModel 和 Service,状态由 selectedOrderId 驱动。业务模型没变,变的只是 UI 组织方式。后续上车机、上折叠屏,改动量基本可以控制在 UI 层。

10. 小结与下篇预告

Stage 模型本身不难,难的是克制"把安卓那套原样搬过来"的冲动。abilities 管入口、features 管业务、mediaquery 管布局,这三层分清楚,多端复用就稳了。

下一篇进入鸿蒙最有辨识度的能力之一:服务卡片。它不只是桌面小部件,还是连接用户、系统推荐和云端数据的入口。


参考阅读

  • 《HarmonyOS 应用开发白皮书》
  • HarmonyOS 开发者文档:Stage 模型开发指南
  • 华为开发者论坛:一次开发多端部署最佳实践

最新文章

随机文章