Flutter/OpenHarmony 三方库适配讲解:status_bar_control 系统栏控制

状态栏看起来只是屏幕顶部的一小条区域,但它同时牵涉窗口布局、系统图标明暗、沉浸模式、导航栏属性和 Flutter 平台通道。很多三方库已经在 Android 与 iOS 上提供了成熟接口,却没有 OpenHarmony 实现,业务迁移到鸿蒙后最典型的现象就是 Dart 方法没有返回,或者界面状态变化了而系统栏毫无反应。

本文讲解 status_bar_control 的 OpenHarmony 适配过程。适配版本为 3.2.1-ohos.1,核心原则是保留原 Dart API、保留 Android 与 iOS 行为,只在 OHOS 平台层补齐方法通道和窗口能力,并把平台能力边界如实写进文档。

一、项目信息与适配目标

项目内容
原始包名status_bar_control
OHOS 适配版本3.2.1-ohos.1
方法通道status_bar_control
平台实现OpenHarmony HAR 模块
适配目标状态栏颜色、内容样式、显隐、全屏布局、导航栏颜色和样式、状态栏高度

适配目标不是“让一个同名方法返回成功”,而是要完成四层闭环:

Dart 公共 API
      |
      v
MethodChannel("status_bar_control")
      |
      v
ArkTS StatusBarControlPlugin
      |
      v
window.Window + AvoidArea + Ability 生命周期
      |
      v
真实设备上的系统栏和应用布局变化

其中 Dart 层负责保持原库的调用方式,ArkTS 层负责参数校验、平台 API 调用和错误返回,示例应用负责把每一次操作的结果展示出来。

二、先保留 Dart 层,避免业务代码分叉

原库的 Dart 层已经完成了枚举序列化、颜色编码和方法参数组装。如果为鸿蒙重新设计一套 API,业务页面就必须增加平台判断,Android、iOS 和 OHOS 的调用路径也会逐渐分叉。因此适配时继续使用原来的 StatusBarControl 静态接口。

例如状态栏颜色和透明度仍然是同一个方法:

static Future<bool> setColor(Color color, {bool animated = false}) async {
  if (kIsWeb) return false;
  return await _channel.invokeMethod(
    'setColor',
    {'color': color.value, 'animated': animated},
  );
}

显隐接口继续携带动画参数,但动画值先在 Dart 层转换为字符串,避免把 Dart 枚举对象直接传给原生层:

static Future<bool> setHidden(
  bool hidden, {
  StatusBarAnimation animation = StatusBarAnimation.NONE,
}) async {
  if (kIsWeb) return false;
  return await _channel.invokeMethod('setHidden', {
    'hidden': hidden,
    'animation': _StatusBarAnimation.getAnimation(animation),
  });
}

这样调用方仍然可以写:

await StatusBarControl.setColor(
  Colors.black.withOpacity(0.8),
  animated: true,
);

await StatusBarControl.setHidden(
  true,
  animation: StatusBarAnimation.SLIDE,
);

await StatusBarControl.setStyle(StatusBarStyle.LIGHT_CONTENT);

需要覆盖的通道方法包括:

方法OHOS 落点
setColor修改当前窗口状态栏颜色
setTranslucent修改窗口全屏布局
setHidden独立控制状态栏显隐
setStyle修改状态栏内容颜色和图标明暗
setNavigationBarColor修改导航栏颜色
setNavigationBarStyle修改导航栏内容颜色和图标明暗
getHeight读取系统避让区域高度
setNetworkActivityIndicatorVisible保持调用兼容,但不伪造 OHOS 不存在的系统指示器

setNetworkActivityIndicatorVisible 是一个典型的跨平台能力差异。OpenHarmony 没有与 iOS 完全等价的网络活动指示器,因此插件校验参数后返回成功,让原业务流程继续执行,但不会声称系统中真的出现了这个指示器。

三、让 Flutter 工具识别 OHOS 插件

插件注册不能只依赖 ArkTS 文件存在。pubspec.yaml 必须声明 ohos 平台和插件入口类,这样 Flutter OHOS 工具链才知道 HAR 模块中应该加载哪个类:

flutter:
  plugin:
    platforms:
      android:
        package: com.foo.statusbarcontrol
        pluginClass: StatusBarControlPlugin
      ios:
        pluginClass: StatusBarControlPlugin
      ohos:
        pluginClass: StatusBarControlPlugin

HAR 模块声明保持为普通 OpenHarmony HAR,入口文件只导出插件和映射层:

import StatusBarControlPlugin
  from './src/main/ets/components/plugin/StatusBarControlPlugin';
import StatusBarControlMapper
  from './src/main/ets/components/plugin/StatusBarControlMapper';

export { StatusBarControlMapper };
export default StatusBarControlPlugin;

Hvigor 模块配置如下:

{
  "module": {
    "name": "status_bar_control",
    "type": "har",
    "deviceTypes": ["default", "tablet"]
  }
}

这三个配置分别解决“Dart 如何找到 OHOS 插件”“HAR 如何导出入口”“Hvigor 如何编译模块”三个问题,缺一个都可能表现为方法通道存在但实际没有插件处理。

四、ArkTS 插件的生命周期:为什么必须持有 UIAbility

插件入口同时实现 FlutterPlugin、MethodCallHandler 和 AbilityAware。Engine 绑定时创建通道,Ability 绑定时保存当前 UIAbility 的 Context,Ability 分离时立即释放,避免插件在窗口已经销毁后继续持有旧引用:

const CHANNEL = 'status_bar_control';

export default class StatusBarControlPlugin
  implements FlutterPlugin, MethodCallHandler, AbilityAware {
  private channel: MethodChannel | null = null;
  private context: common.Context | null = null;
  private uiAbilityContext: common.UIAbilityContext | null = null;

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.context = binding.getApplicationContext();
    this.channel = new MethodChannel(binding.getBinaryMessenger(), CHANNEL);
    this.channel.setMethodCallHandler(this);
  }

  onAttachedToAbility(binding: AbilityPluginBinding): void {
    this.uiAbilityContext = binding.getAbility().context;
  }

  onDetachedFromAbility(): void {
    this.uiAbilityContext = null;
  }
}

这里不能只保存 ApplicationContext。窗口操作需要解析当前应用的主窗口,如果只持有 ApplicationContext,window.getLastWindow 可能拿不到当前 Ability 对应的窗口;更隐蔽的问题是方法返回成功,但系统栏实际上没有变化。

最终实现优先使用 uiAbilityContext,只有在 Ability 尚未绑定时才退回 ApplicationContext:

private async runWindowOperation(
  operation: (target: window.Window) => Promise<void>,
  result: MethodResult,
): Promise<void> {
  const context = this.uiAbilityContext ?? this.context;
  if (context === null) {
    result.error(
      'window_unavailable',
      'No application context is attached',
      null,
    );
    return;
  }

  try {
    const target = await window.getLastWindow(context);
    await operation(target);
    result.success(true);
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    result.error('window_operation_failed', message, null);
  }
}

这层生命周期处理是显隐功能真正生效的关键,不只是为了满足编译器。

五、方法通道如何落到窗口属性

颜色修改不能直接创建一个全新的 SystemBarProperties。状态栏和导航栏共享同一组窗口属性,如果只提交本次修改的字段,可能把另一组字段清空。因此每次操作都先读取当前属性,再合并本次 patch:

case 'setColor': {
  const color = StatusBarControlMapper.colorArgument(call, 'color');
  const animated = StatusBarControlMapper.booleanArgument(call, 'animated');

  this.runWindowOperation(async (target: window.Window): Promise<void> => {
    const patch: window.SystemBarProperties = {
      statusBarColor: StatusBarControlMapper.colorToHex(color),
      enableStatusBarAnimation: animated,
    };
    const properties = StatusBarControlMapper.mergeProperties(
      target.getWindowSystemBarProperties(),
      patch,
    );
    await target.setWindowSystemBarProperties(properties);
  }, result);
  return;
}

mergeProperties 的职责非常单一:保留当前值,只覆盖 patch 中明确提供的字段:

const merged: window.SystemBarProperties = {
  statusBarColor: current.statusBarColor,
  isStatusBarLightIcon: current.isStatusBarLightIcon,
  statusBarContentColor: current.statusBarContentColor,
  navigationBarColor: current.navigationBarColor,
  isNavigationBarLightIcon: current.isNavigationBarLightIcon,
  navigationBarContentColor: current.navigationBarContentColor,
  enableStatusBarAnimation: current.enableStatusBarAnimation,
  enableNavigationBarAnimation: current.enableNavigationBarAnimation,
};

if (patch.statusBarColor !== undefined) {
  merged.statusBarColor = patch.statusBarColor;
}
if (patch.navigationBarColor !== undefined) {
  merged.navigationBarColor = patch.navigationBarColor;
}

状态栏样式则把 Dart 的枚举转换成 OHOS 需要的内容色和图标亮度:

static statusStyle(style: string): BarStyle {
  if (style === 'light-content') {
    return { contentColor: '#FFFFFF', lightIcon: true };
  }
  return { contentColor: '#000000', lightIcon: false };
}

颜色值不能丢失 alpha 通道。Dart 的 Color.value 是无符号 32 位整数,映射层使用无符号右移并补齐为八位十六进制:

static colorToHex(value: number): string {
  const normalized = value >>> 0;
  return `#${normalized.toString(16).toUpperCase().padStart(8, '0')}`;
}

参数进入窗口 API 前必须先校验。布尔值、颜色数值、字符串类型和枚举范围都有明确错误,非法参数统一返回 invalid_arguments;窗口获取失败或窗口 API 抛错则返回 window_operation_failed,这样测试日志可以直接区分“调用方传错参数”和“平台窗口不可用”。

六、显隐适配中的关键修复

显隐是最容易出现“接口成功但界面没变化”的地方。最初实现通过启用系统栏集合来隐藏状态栏,接口返回成功,示例开关也变成了开启状态,但时间、电量和信号仍然可见;进一步检查布局树发现应用根窗口仍从纵坐标 117px 开始。

问题不在动画参数,而在上下文和接口选择:

  1. 只保存 ApplicationContext,无法稳定解析当前 UIAbility 的主窗口。
  2. 旧式集合接口把状态栏和三键导航栏绑定处理,不符合原库的独立显隐语义。
  3. 界面开关状态只能证明 Dart 调用完成,不能证明系统栏已经退出。

最终显隐逻辑只针对 status,并使用当前 UIAbility Context:

case 'setHidden': {
  const hidden = StatusBarControlMapper.booleanArgument(call, 'hidden');
  const animation = this.validateAllowed(
    StatusBarControlMapper.stringArgument(call, 'animation'),
    'animation',
    ['none', 'fade', 'slide'],
  );

  this.runWindowOperation(
    async (target: window.Window): Promise<void> => {
      await target.setSpecificSystemBarEnabled(
        'status',
        StatusBarControlMapper.shouldEnableStatusBar(hidden),
        StatusBarControlMapper.shouldAnimateStatusBar(animation),
      );
    },
    result,
  );
  return;
}

动画映射遵循“不夸大平台能力”的原则:

static shouldEnableStatusBar(hidden: boolean): boolean {
  return !hidden;
}

static shouldAnimateStatusBar(animation: string): boolean {
  return animation !== 'none';
}

也就是说,NONE 不启用动画,FADE 和 SLIDE 都映射为 OHOS 的通用显隐动画。OHOS 没有分别暴露淡出和滑动两种状态栏转场,因此文档不会声称三种动画在鸿蒙上拥有与 iOS 完全一致的视觉效果。

修复后重新构建并安装,Hidden 开关开启时应用根布局从 [0,117] 变为 [0,0],状态栏时间、信号和电量消失;恢复或点击 Reset bars 后,根布局重新回到 117px 区域。这才是完整的显隐闭环。

七、示例应用如何验证插件

示例应用不直接修改系统 API,而是把每个控件映射到公开的 Dart 方法。初始化时先读取状态栏高度:

Future<void> initPlatformState() async {
  double? statusBarHeight;
  try {
    statusBarHeight = await StatusBarControl.getHeight;
  } on PlatformException {
    statusBarHeight = 0.0;
  }

  if (!mounted) return;
  setState(() {
    _statusBarHeight = statusBarHeight;
  });
}

颜色选择、样式选择和重置操作分别调用原库接口:

void updateStatusBar() {
  StatusBarControl.setColor(
    _statusBarColor!.withOpacity(_statusBarOpacity),
    animated: _statusBarColorAnimated,
  );
}

void statusBarStyleChanged(StatusBarStyle val) {
  setState(() {
    _statusBarStyle = val;
  });
  StatusBarControl.setStyle(val);
}

Future<void> _resetBars() async {
  await StatusBarControl.setStyle(StatusBarStyle.DEFAULT);
  await StatusBarControl.setNavigationBarStyle(NavigationBarStyle.DEFAULT);
  await StatusBarControl.setHidden(false);
  await StatusBarControl.setTranslucent(false);
  await StatusBarControl.setColor(Colors.black);
  await StatusBarControl.setNavigationBarColor(Colors.black);
}

示例页面还保留了 Translucent、导航栏颜色、导航栏样式、Fullscreen 和状态栏高度等控件,方便在同一次设备运行中观察多个 API 的组合效果。

八、测试代码:先锁定通道契约

Dart 测试不依赖真实窗口,而是拦截 MethodChannel,检查每个公开方法是否发送了文档约定的方法名和参数:

const channel = MethodChannel('status_bar_control');
final calls = <MethodCall>[];

TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
    .setMockMethodCallHandler(channel, (MethodCall call) async {
  calls.add(call);
  if (call.method == 'getHeight') return 24.0;
  return true;
});

test('public API sends the documented channel payloads', () async {
  expect(
    await StatusBarControl.setColor(
      const Color(0x80112233),
      animated: true,
    ),
    true,
  );
  expect(await StatusBarControl.setTranslucent(true), true);
  expect(
    await StatusBarControl.setHidden(
      true,
      animation: StatusBarAnimation.SLIDE,
    ),
    true,
  );

  expect(
    calls.map((MethodCall call) => call.method).toList(),
    <String>[
      'setColor',
      'setTranslucent',
      'setHidden',
    ],
  );
});

Hypium 测试进一步覆盖纯映射逻辑和参数拒绝:

it('merges only requested system bar properties', 0, () => {
  const current = {
    statusBarColor: '#FF000000',
    navigationBarColor: '#FF00FF00',
    isStatusBarLightIcon: false,
    isNavigationBarLightIcon: false,
  };
  const patch = { statusBarColor: '#FFFF0000' };
  const merged = StatusBarControlMapper.mergeProperties(current, patch);

  expect(merged.statusBarColor).assertEqual('#FFFF0000');
  expect(merged.navigationBarColor).assertEqual('#FF00FF00');
});

测试内容分为三类:

测试层重点
Dart 测试方法名、参数结构、颜色和枚举序列化
Hypium 测试映射函数、属性合并、参数类型和枚举范围
设备测试窗口 API 是否真正改变系统栏和应用布局

这三层不能互相替代。Dart 测试通过不代表窗口 API 生效,界面单选框选中也不代表系统栏颜色已经改变。

九、构建、安装与测试命令

本次验证环境如下:

Flutter: 3.44.9+ohos-0.0.1-canary1
Dart: 3.12.2
HarmonyOS SDK: API 26 / OpenHarmony-7.0.0.105
Device: 127.0.0.1:5555
Application ID: com.foo.status_bar_control_example

常用验证命令:

cd /Users/wx/project/wuyong/wenz/status_bar_control_ohos
/Users/wx/Developer/flutter-oh/bin/flutter pub get
/Users/wx/Developer/flutter-oh/bin/dart analyze .
/Users/wx/Developer/flutter-oh/bin/flutter test

cd example
/Users/wx/Developer/flutter-oh/bin/flutter build hap --debug --no-codesign

验证记录中的最终结果:

检查项结果
根包 dart analyze .退出码 0,39 条 info,无 error
根包 flutter test1/1 通过
示例 dart analyze .退出码 0,35 条 info,无 error
示例 flutter test1/1 通过
调试 HAP 构建成功
Hvigor 测试 HAP成功,43 个任务
设备 Hypium6 项通过,0 失败,0 错误
设备错误扫描未发现 MissingPlugin、参数错误或窗口操作失败

需要注意的是,第一次并行执行 Flutter 和 Hvigor 构建时出现过 oh_modules 缓存竞争,最终改为串行构建后通过。这类问题属于构建缓存竞争,不应被误判为插件源码编译失败。

十、真实截图与设备观察

下面截图全部来自 HarmonyOS 7.0 模拟器上的示例应用,图片文件保存在 status_bar_control_ohos/screenshots/。

1. 基线状态

状态栏正常可见,示例报告显示 Status Bar Height: 0.0。这个值是该模拟器和当前窗口避让接口的实际返回结果,文章不把它伪造为非零值。

2. 状态栏颜色调用

选择 Orange 后,Dart 方法和通道调用完成,单选状态更新;当前模拟器没有出现清晰的橙色状态栏变化,因此这张截图证明的是操作路径和调用完成,不单独证明颜色效果。

截图必须和布局数据一起看。尤其在模拟器、手势导航和 SafeArea 同时存在时,“控件选中”“方法返回成功”“系统栏真正变化”是三个不同层次的证据。

十一、OpenHarmony 的能力边界与排错

这次适配没有把“同名方法能调用”包装成完全跨平台一致。以下边界应保留在 README 和后续维护说明中:

  • OHOS 的颜色或图标属性在 Flutter SafeArea、手势导航和模拟器策略共同作用下,未必出现与 Android 截图相同的效果。
  • 显隐动画只有“无动画”和“通用动画”两类语义,无法精确复刻 iOS 的淡出与滑动选项。
  • 网络活动指示只保持成功返回,不生成不存在的系统状态。
  • 高度读取依赖当前窗口的系统避让区域;本次模拟器返回 0.0,不能用固定值伪造结果。
  • 导航栏颜色的可见性取决于设备是否使用三键导航,手势导航下不应承诺传统底色一定可见。

排错时建议按以下顺序检查:

  1. pubspec.yaml 是否声明了 ohos.pluginClass。
  2. onAttachedToAbility 是否拿到当前 UIAbility Context。
  3. MethodChannel 方法名和参数是否与 Dart 测试一致。
  4. 当前窗口是否存在,window.getLastWindow 是否返回有效窗口。
  5. 属性更新是否使用 mergeProperties 保留另一组系统栏字段。
  6. 设备截图和布局坐标是否同步变化。

如果方法返回 true 但界面没有变化,优先检查窗口上下文、导航模式、SafeArea 和平台能力边界;不要直接增加固定高度或伪造颜色结果。

十二、总结与维护建议

status_bar_control 的适配重点不是增加一个 OHOS 分支,而是让原有 Dart API 在新平台上继续拥有清晰、可测试、可追踪的实现:

  • Dart 层保持方法名、参数结构和枚举语义不变。
  • ArkTS 层使用 AbilityAware 绑定当前 UIAbility,保证窗口操作指向正确窗口。
  • 窗口属性采用读取、合并、写回,避免状态栏和导航栏互相覆盖。
  • 参数错误、窗口不可用和窗口操作失败使用不同错误码。
  • Dart 测试、Hypium 测试、HAP 构建和设备截图组成同一组验收动作。
  • 对颜色、动画、网络活动指示器和高度等平台差异如实记录,不把兼容返回包装成完全等价。

后续升级 Flutter OH 或 HarmonyOS SDK 时,应重新验证 setSpecificSystemBarEnabled、SystemBarProperties 字段、手势导航表现和系统避让区域返回值,并保留新的真实截图作为回归证据。

欢迎加入 CPF-Flutter 鸿蒙社区,一起交流 Flutter 三方库适配、工程实践和真机验证经验:

https://atomgit.com/CPF-Flutter

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐