前言
做过安卓再去看鸿蒙,第一感觉一般是:这也太像了。等写到第三个页面,就会发现 Ability 生命周期、Stage 模型、分布式软总线,和 Android 的 Activity 完全不是一回事。更头疼的是业务方一句"手机、平板、车机都要上",如果每个端单独维护一套代码,团队人效直接崩盘。
这个系列打算用三篇文章聊透鸿蒙端云一体化。第一篇先回答最基础、也最容易被忽略的问题:Stage 模型到底怎么组织代码,才能真正做到一次开发、多端部署?
1. Stage 模型不是 Activity 的翻版
HarmonyOS Next 全面采用 Stage 模型,FA 模型基本进入维护状态。核心变化其实就两点:
- 生命周期统一交给 Stage 调度,而不是每个 Ability 自己玩自己的。
- UI 内容交给 WindowStage 管理,Ability 只负责入口和状态。
这带来的直接好处是同一份业务逻辑,可以挂到不同设备的入口上。手机上是全屏 Page,车机里可能是嵌入 Dashboard 的 Ability,手表上变成 Form 卡片。业务代码不用重写。
简单对比一下:
如果项目还在用 FA 模型,迁移优先级建议直接 P0。不是它马上不能用,而是服务卡片、意图框架、云开发这些新能力基本只在 Stage 下支持。
2. 一次开发多端部署的四个层级
很多人把"一次开发多端部署"理解成同一套代码跑在所有设备上,其实它可以分成四个层次,每一层的复杂度和收益都不一样:
| | | |
|---|
| 同一个 entry 包,deviceTypes 同时声明 phone/tablet/car | | |
| entry + feature 模块,按设备类型或用户权限动态下载 | | |
| 通过分布式软总线把 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/
三个要点:
- abilities 层只负责启动和路由,不要塞业务逻辑。Ability 一多,生命周期代码满天飞,后期很难改。
- features 层按业务域拆分,每个 feature 自己管数据、状态、接口。手机和平板共用同一个 OrderService。
- 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 判断,而是按可用宽度。折叠屏展开后宽度变了,就该走平板布局。这才是"一次开发、多端部署"的关键。
不同断点下的布局策略可以参考下表:
除了 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. 案例:一个订单模块在手机和平板间复用
需求是"订单列表 + 订单详情"。手机用"列表 -> 点击 -> 详情页"的导航流,平板用"左侧列表 + 右侧详情"的双栏布局。
实现策略:
- OrderList 页面同时存在,平板布局下把 OrderDetail 嵌入右侧。
- OrderService 提供统一的 fetchOrders() 和 getOrderDetail(id)。
- 点击列表项时,手机用 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 开发者文档:Stage 模型开发指南