鸿蒙原生 + Flutter 混合开发:页面生命周期怎么和原生关联
鸿蒙原生 + Flutter 混合开发:页面生命周期怎么和原生关联
这篇只讲一件事:Flutter 页面和鸿蒙原生页面在同一个 App 里,生命周期怎么接上。
接错了不会报错,只会出现三种现象:进页面不加载、切回来不刷新、退出后回调还在跑。
代码基线:ArkTS 原生 + Flutter 相册模块,Flutter 只占宿主的一个 Tab(嵌入式模式)。
一、先看全景:一次进页面,数据是怎么走的
【App 启动】一次性动作
UIAbility.configureFlutterEngine
└─► Bridge.attach(engine) ← 两条通道在这里挂好,只挂一次
UIAbility.onWindowStageCreate
├─► 创建 FlutterView
├─► 把 viewId 交给 Bridge
└─► loadContent 回调里调用 flutterView.onWindowCreated() ← 少这行会白屏
【切到相册 Tab】每次都发生
AlbumFlutterPage 可见(onVisibleAreaChange)
└─► emitAlbumVisible()
└─► 事件 'visible' ──► Flutter 侧 refreshContent()
【相机连上 / 断开】
@Monitor(netBean.isConnectCamera)
└─► emitConnectionChanged()
└─► 事件 'connection' ──► Flutter 侧刷新空状态与列表
【Flutter 页面自己的生命周期】
AlbumPage.initState ──► 通道 'initialize'
AlbumPage.dispose ──► 通道 'dispose'
【App 退出】一次性动作
UIAbility.onDestroy
└─► Bridge.detach() ← 解绑监听、停定时器、释放播放器
记三句话就够了:
- 通道挂在引擎上,不挂在页面上。
- 刷新靠原生推事件,不靠 Flutter 的生命周期回调。
- 切 Tab 不会销毁 Flutter,
initState/dispose都只跑一次。
二、两个基础概念,先垫一下
2.1 通道是什么
Flutter 页面和原生页面在同一个 App 里,但跑在两套互不相通的运行环境里(一边 Dart VM,一边 ArkTS 运行时)。
两边内存不共享,不能直接调对方的函数,也不能直接传对象。
通道(Platform Channel)就是两边约好的一条数据管道:
| 关于通道 | 说明 |
|---|---|
| 怎么找到对方 | 靠一个字符串名字,比如 com.example.hybrid/native_calculator,相当于频道号 |
| 要不要握手 | 不需要。名字对上就能用,第一次调用就自动接上 |
| 名字写错会怎样 | 各说各话,报 MissingPluginException |
| 能传什么 | 只能是"值":键值对、数组、字符串、数字,不能传对象引用 |
2.2 三层是什么:三种寿命不同的容器
三层不是架构分层,而是寿命不同。判断标准只有一条:它什么时候生、什么时候死。
| 层 | 具体是什么 | 什么时候生 | 什么时候死 | 一生几次 |
|---|---|---|---|---|
| 引擎层 | FlutterEngine(跑 Dart 的运行时)+ 宿主 UIAbility |
App 启动 | App 退出 | 1 次 |
| 视图层 | FlutterView(XComponent)+ FlutterPage 承载组件 |
宿主窗口创建 | 宿主窗口销毁 | 通常 1 次 |
| 页面层 | 宿主 TabContent + Flutter 的 AlbumPage Widget |
切到相册 Tab | 切走只是藏起来 | 反复显示隐藏,但不销毁 |
打个比方:引擎层是主机,视图层是显示器,页面层是屏幕上正在播的节目。
切 Tab 相当于切台——节目不会因为切台就"结束"。
2.3 所以东西该挂哪一层
| 要挂的东西 | 挂哪层 | 挂错的后果 |
|---|---|---|
| 通道、事件监听、定时器 | 引擎层(configureFlutterEngine) |
挂页面层 → 页面重建时重复注册,事件丢一半 |
| 尺寸 / 键盘 / 全屏等视图配置 | 视图层(FlutterPage) |
挂页面层 → 视图还没建好就配置,白屏 |
| 页面状态(选中项、编辑模式) | 页面层(Flutter 的 Controller) | 挂引擎层 → 所有页面共用一份状态,互相污染 |
这一节就是后面所有"为什么必须推事件"的答案:
切换发生在页面层,而页面层切走时并不销毁,所以 Flutter 那边收不到任何"我又回来了"的提醒。
三、鸿蒙和 Flutter 的生命周期对照
| 你想做的事 | ArkUI 原生写法 | Flutter 写法 | 本项目怎么接 |
|---|---|---|---|
| 首次显示时加载 | aboutToAppear() |
initState() |
Flutter 调通道 initialize |
| 再次可见时刷新 | onPageShow()(整页)onVisibleAreaChange()(嵌入) |
⚠️ 没有对应回调 | 原生推 visible 事件 |
| 销毁时清理 | aboutToDisappear() |
dispose() |
Flutter 调通道 dispose;切 Tab 不触发 |
| 外部状态变化 | @Monitor |
ChangeNotifier |
原生推事件,如 connection |
表格第二行是这套方案的核心:
Flutter 里没有"页面又可见了"这个回调——因为视图挂在TabContent里一直活着,切 Tab 不重建、不 dispose。
这件事只能由原生主动推过去。
四、关键代码,按全景图的顺序看
4.1 通道挂在引擎上(只挂一次)
// EntryAbility.ets —— 唯一的挂载点
configureFlutterEngine(engine: FlutterEngine): void {
super.configureFlutterEngine(engine);
AlbumFlutterBridge.getInstance().attach(engine); // ✅ 挂在引擎上
}
onDestroy(): void {
AlbumFlutterBridge.getInstance().detach(); // ✅ 统一释放
super.onDestroy();
}
❌ 不要写在页面的 aboutToAppear 里——页面会重建,通道会被重复注册。
4.2 viewId 从哪来(三步,顺序不能换)
// EntryAbility.onWindowStageCreate(节选)
const flutterView = delegate?.createView(this.context); // ① 建视图
AlbumFlutterBridge.getInstance().setFlutterViewId(flutterView.getId()); // ② 交 viewId
windowStage.loadContent('pages/Index', storage, (err) => {
flutterView?.onWindowCreated(); // ③ 内容加载完才真正渲染,少这行白屏
});
4.3 组件层:FlutterPage 和 FlutterEntry 别选错
FlutterPage(嵌入式,本项目用) |
FlutterEntry(整页) |
|
|---|---|---|
| 适用场景 | Flutter 只占宿主一个 Tab | 整个页面都是 Flutter |
aboutToAppear / aboutToDisappear |
✅ 有 | ✅ 有 |
onPageShow / onPageHide |
❌ 没有 | ✅ 有 |
onBackPress(返回键) |
❌ 没有 | ✅ 有,内部会转发给 Flutter 路由 |
// AlbumFlutterPage.ets:嵌入式承载组件
build() {
Column() {
FlutterPage({ viewId: this.viewId }) // 把 Flutter 视图挂进来
.width('100%').height('100%')
}
.onVisibleAreaChange([0.0, 1.0], (isExpanding: boolean, ratio: number) => {
// 嵌入式没有 onPageShow,就用"可见面积变化"当显示信号
if (isExpanding && ratio > 0) {
AlbumFlutterBridge.getInstance().emitAlbumVisible();
}
})
}
顺带说明:SDK 里
FlutterPage.aboutToDisappear()只做了"移除首帧监听",
既没销毁引擎,也没释放视图。这就是"切 Tab 不触发 Flutter dispose"的根源。
4.4 页面可见:一条事件桥过去
// 原生:推
emitAlbumVisible(): void {
this.emitEvent('visible', { 'visible': true });
}
// Flutter:收
case 'visible':
unawaited(refreshContent(triggerNativeLoad: true)); // 重新拉一次数据
unawaited(_refreshTabsFromNative()); // 顺带重算一级 Tab
4.5 状态变化也一样靠推
// 原生:盯住全局状态,一变就推
@Monitor('netBean.isConnectCamera')
onConnectionChange(): void {
AlbumFlutterBridge.getInstance().emitConnectionChanged(this.netBean.isConnectCamera);
}
// Flutter:收到后更新界面
case 'connection':
_cameraConnected = event.data['connected'] == true;
if (!_cameraConnected) { _isEditMode = false; _selectedIds.clear(); } // 断开就清干净
_safeNotify();
4.6 Flutter 侧自己的生命周期(对应两次通道调用)
// AlbumPage
void initState() {
super.initState();
_controller.initialize(); // → 通道 'initialize'
}
void dispose() {
_subscription?.cancel(); // ① 先断开事件订阅
unawaited(_service.dispose()); // ② 再让原生释放通道(→ 通道 'dispose')
super.dispose();
}
⚠️ 顺序别反:先断订阅再通知原生,否则原生释放时推的最后一两条事件会打到已作废的订阅上。
五、Flutter 子页面(预览页、下载页)和宿主的关系
子页面是 Flutter 用自己的 Navigator 推的,原生完全不知道。需要协调的只有三件事:
| 事情 | 谁负责 | 怎么协调 |
|---|---|---|
| 页面跳转 | Flutter 的 Navigator |
原生无感知 |
| 宿主底栏遮挡 | 宿主 MainPage |
Flutter 调 setHostTabBarVisible(true),原生转成全局事件 |
| 系统返回键 | 宿主 Index.onBackPress() |
⚠️ 目前没有转发给 Flutter,子页靠页面内的返回按钮 |
两个写成坑的经验:
- 跨层传布尔量,字段名和取值必须统一语义。
早期 Bridge 传的是"底栏是否可见",宿主字段是"是否隐藏",直接赋值就反了——表现是编辑面板被底栏整条盖住。 - 返回键归谁管要提前定。
SDK 的FlutterView其实留好了通路:监听EVENT_BACK_PRESS→navigationChannel.popRoute()。
整页模式(FlutterEntry)会自动转发;嵌入式(FlutterPage)需要宿主自己转发。
本工程的宿主页只做了"双击退出",没有转发,所以系统返回键目前退不出 Flutter 子页。
这一段是按 SDK 代码读出来的结论,还没在本工程真机验证过,接入时请以实测为准。
六、踩过的坑
| 坑 | 现象 | 正确做法 |
|---|---|---|
通道 attach 写在页面 aboutToAppear |
通道重复注册、事件丢一半 | 挂在 configureFlutterEngine,加幂等保护 |
忘了 flutterView.onWindowCreated() |
Flutter 区域白屏 | 在 loadContent 回调里调用 |
以为切 Tab 会触发 Flutter dispose |
定时器、订阅、播放器一直不释放 | 刷新用事件驱动,释放在 dispose / detach |
把"页面可见"逻辑写进 onListen |
一辈子只触发一次 | onListen/onCancel 只管订阅,可见性走 visible 事件 |
| 跨层布尔量语义反了 | "要求显示"变成"隐藏" | 字段名与取值统一语义 |
| 系统返回键被宿主拦截 | Flutter 子页返回不了 | 转发 EVENT_BACK_PRESS,或保留页面内返回按钮 |
在 Flutter 里用 print 打日志 |
真机日志一条都没有 | 走通道转发到原生日志(另见《Flutter 端日志打印》) |
浙公网安备 33010602011771号