鸿蒙窗口管理在 Flutter 项目里的落地:沉浸式、系统栏、返回键拦截的协同

适合谁看

  • 正在做 Flutter 鸿蒙项目窗口配置但遇到布局异常的开发者

  • 想理解鸿蒙沉浸式窗口对 Flutter MediaQuery 影响的开发者

  • 遇到"返回键拦截后 Flutter 页面无响应"问题的人

问题背景

在纯 Flutter 项目中,窗口管理(状态栏、导航栏、返回键)主要通过 SystemUiOverlayStyleWillPopScope 处理。但在 Flutter 鸿蒙项目中,这些能力由鸿蒙系统 API 控制,需要在 ArkTS 侧配置,再通过事件通道同步到 Flutter 侧。

典型问题:

  • 配置沉浸式后,Flutter 页面的 SafeArea 不生效

  • 返回键被 ArkTS 拦截后,Flutter 侧收不到通知

  • 状态栏颜色和 Flutter 主题不一致

项目中的真实场景

食界探味在 EntryAbility.onWindowStageCreate 中配置沉浸式全屏,在 Index.ets 中拦截返回键:

复制代码
// EntryAbility.ets
onWindowStageCreate(windowStage: window.WindowStage): void {
  super.onWindowStageCreate(windowStage)
  windowStage.getMainWindow().then((mainWindow: window.Window) => {
    mainWindow.setWindowLayoutFullScreen(true)
    mainWindow.setWindowSystemBarEnable([])
  }).catch((err: Error) => {
    console.error(`Failed to enable immersive window: ${JSON.stringify(err)}`)
  })
}

// Index.ets
Entry(storage)
@Component
struct Index {
  private context = getContext(this) as common.UIAbilityContext
  @LocalStorageLink('viewId') viewId: string = "";

  build() {
    Column() {
      FlutterPage({ viewId: this.viewId })
    }
  }

  onBackPress(): boolean {
    this.context.eventHub.emit(EVENT_BACK_PRESS)
    return true
  }
}

核心实现

沉浸式窗口配置

setWindowLayoutFullScreen(true) 的作用:

  • 应用内容延伸到状态栏和导航栏区域

  • 状态栏和导航栏变为透明覆盖层

  • MediaQuery.padding.top 变为 0(因为系统栏不再占据布局空间)

setWindowSystemBarEnable([]) 的作用:

  • 隐藏状态栏和导航栏

  • 应用获得完整的全屏显示区域

对 Flutter 侧的影响:

复制代码
// Flutter 侧获取窗口信息
final padding = MediaQuery.of(context).padding;
// 配置沉浸式后:
// padding.top == 0(状态栏被隐藏)
// padding.bottom == 0(导航栏被隐藏)

// SafeArea 在这种情况下不会添加额外间距
// 因为 SafeArea 依赖 MediaQuery.padding

viewId 的作用

Index.ets 中的 @LocalStorageLink('viewId') 是一个 ArkUI 状态变量,它通过 LocalStorageFlutterPage 组件关联。viewId 的作用是:

  1. 唯一标识当前 Flutter 页面实例

  2. 当系统需要向 Flutter 页面传递事件时,通过 viewId 定位目标

  3. 在多窗口场景下区分不同的 Flutter 页面

    // Index.ets
    @Entry(storage)
    @Component
    struct Index {
    @LocalStorageLink('viewId') viewId: string = "";

    build() {
    Column() {
    FlutterPage({ viewId: this.viewId })
    }
    }
    }

FlutterPage@ohos/flutter_ohos 提供的组件,它负责承载 Flutter 引擎渲染的页面。viewId 作为属性传递给 FlutterPage,用于页面标识。

返回键拦截

Index.etsonBackPress 方法拦截系统返回键:

复制代码
onBackPress(): boolean {
  this.context.eventHub.emit(EVENT_BACK_PRESS)
  return true  // 返回 true 表示拦截,不执行默认返回行为
}

拦截后通过 eventHub.emit 发送事件。但这个事件目前只在 ArkTS 侧传播,Flutter 侧需要通过 MethodChannel 或 EventChannel 监听。

Flutter 侧适配策略

Flutter 侧需要处理两个问题:

  1. 沉浸式布局适配 :在 MediaQuery.padding.top == 0 时,手动添加状态栏高度的安全间距

  2. 返回键事件监听 :通过 MethodChannel 监听 ArkTS 侧的 EVENT_BACK_PRESS 事件

    // Flutter 侧 - 沉浸式布局适配
    class ImmersiveLayout extends StatelessWidget {
    final Widget child;

    const ImmersiveLayout({required this.child});

    @override
    Widget build(BuildContext context) {
    final padding = MediaQuery.of(context).padding;
    final isImmersive = padding.top == 0;

    复制代码
     return Column(
       children: [
         // 如果是沉浸式,手动添加状态栏高度间距
         if (isImmersive)
           SizedBox(
             height: MediaQuery.of(context).viewPadding.top,
           ),
         Expanded(child: child),
       ],
     );

    }
    }

    // Flutter 侧 - 返回键事件监听
    class BackButtonHandler {
    static const _channel = MethodChannel('com.foodvoyage.back_button');
    static VoidCallback? _onBack;

    static void initialize() {
    _channel.setMethodCallHandler((call) async {
    if (call.method == 'onBackPress') {
    _onBack?.call();
    }
    });
    }

    static void setCallback(VoidCallback onBack) {
    _onBack = onBack;
    }
    }

关键代码位置

  • app/ohos/entry/src/main/ets/entryability/EntryAbility.ets:44-53 --- 沉浸式窗口配置

  • app/ohos/entry/src/main/ets/pages/Index.ets --- FlutterPage 承载与返回键拦截

  • app/lib/main.dart --- Flutter 侧窗口适配

鸿蒙侧实现

鸿蒙侧的窗口管理涉及三个层次:

  1. Ability 层EntryAbility.ets):在 onWindowStageCreate 中配置窗口属性

  2. 页面层Index.ets):onBackPress 拦截返回键,viewId 标识页面

  3. 系统层window.WindowStagewindow.Window 提供窗口操作 API

窗口属性配置的时序:

复制代码
EntryAbility.onCreate
  ↓
EntryAbility.onWindowStageCreate
  ↓ 获取 mainWindow
  ↓ setWindowLayoutFullScreen(true)
  ↓ setWindowSystemBarEnable([])
  ↓
Index.build
  ↓ FlutterPage({ viewId: this.viewId })
  ↓
Flutter 引擎渲染

Flutter 侧实现

Flutter 侧的适配策略:

  1. 检测沉浸式状态 :通过 MediaQuery.padding.top == 0 判断

  2. 手动添加安全间距 :使用 MediaQuery.viewPadding.top 获取真实状态栏高度

  3. 监听返回键事件 :通过 MethodChannel 接收 ArkTS 侧的 EVENT_BACK_PRESS

常见坑

  • 坑 1: setWindowLayoutFullScreen(true) SafeArea不生效SafeArea 依赖 MediaQuery.padding,沉浸式配置后 padding.top 变为 0,SafeArea 不会添加间距。需要手动处理。

  • 坑 2: onBackPress返回 true后 Flutter 页面无响应eventHub.emit 只在 ArkTS 侧传播,Flutter 侧需要额外的 MethodChannel 监听。

  • 坑 3: setWindowSystemBarEnable([])在某些设备上不生效。部分鸿蒙设备的系统栏行为不同,需要做兼容性测试。

  • 坑 4: viewId在多窗口场景下的冲突 。如果应用支持分屏,多个 Index 实例的 viewId 可能冲突。需要确保 viewId 唯一。

  • 坑 5:沉浸式配置后 Flutter 页面的点击区域偏移。如果 Flutter 页面的点击区域和系统栏重叠,可能触发系统栏的点击事件。需要在 Flutter 侧避免在顶部区域放置可点击元素。

可复用模板

复制代码
// Flutter 侧 - 沉浸式窗口适配模板
class WindowAdaptiveLayout extends StatelessWidget {
  final Widget child;
  final bool includeTopPadding;

  const WindowAdaptiveLayout({
    required this.child,
    this.includeTopPadding = true,
  });

  @override
  Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);
    final isImmersive = mediaQuery.padding.top == 0;

    return Column(
      children: [
        if (isImmersive && includeTopPadding)
          SizedBox(height: mediaQuery.viewPadding.top),
        Expanded(child: child),
        if (isImmersive)
          SizedBox(height: mediaQuery.viewPadding.bottom),
      ],
    );
  }
}

// 鸿蒙侧 - 返回键拦截模板
Entry(storage)
@Component
struct MainPage {
  private context = getContext(this) as common.UIAbilityContext
  @LocalStorageLink('viewId') viewId: string = "";

  onBackPress(): boolean {
    this.context.eventHub.emit('BACK_PRESS')
    return true
  }

  build() {
    Column() {
      FlutterPage({ viewId: this.viewId })
    }
  }
}

本篇总结

鸿蒙窗口管理在 Flutter 项目中的落地,核心是三个环节的协同:ArkTS 侧配置窗口属性(沉浸式、系统栏)→ 页面层拦截系统事件(返回键)→ Flutter 侧适配布局变化(安全间距、事件监听)。理解这些环节的关键在于搞清楚"谁控制窗口、谁拦截事件、谁适配布局"。