📦 Flutter 三方库 wear_plus 用于穿戴页面的屏幕形状判断,但选定的上游版本尚未适配鸿蒙。我在原库基础上补上鸿蒙系统形状查询,让页面继续通过 WatchShape 选择布局。
完整代码已开源在 oh-flutter/wear_plus,可直接使用。有问题可以到仓库提 Issue,也可以来 CPF-Flutter 鸿蒙社区 交流 Flutter 鸿蒙适配问题。

前言

我在做一个 Flutter 手表计时示例:中间显示时间,下面放“记圈”和“重置”。把它装到 Watch 4 Pro 后,页面能按圆屏布局显示,可查询屏幕形状的通道却报了 MissingPluginException。界面看着正常,底下的查询怎么没接通?🔍

我顺着 WatchShape 查源码,发现组件先用圆屏作为默认值,再异步查询系统;选定的 wear_plus 版本只注册了 Android,鸿蒙端还没有处理器。于是,我保留原来的 Dart 接口,补上鸿蒙形状查询,再用同一个计时页面做对照。接入后,独立诊断从“查询未接入”变成“圆屏已确认”,日志也收到了系统返回的 round。两组原本都能记圈和重置,这次补齐的是形状查询;默认圆屏布局和系统确认结果的区别,也成了我想讲清楚的地方。

手表吉祥物与屏幕形状适配流程

图1 手表形状适配

一、计时卡片为什么需要知道屏幕形状

1.1 同一张卡片放到两种屏幕上

我把复现页面收成了一个小计时器:打开后从零开始,点“记圈”留下这一圈的用时,点“重置”开始下一轮。跑步或间歇训练时可以使用这样的交互,不过本文实际操作的是模拟器上的 Example。页面的重点是让时间、记录和按钮在小屏上有清楚的位置,运动传感器和配速计算留给具体应用。

如果把所有内容都放进靠近矩形窗口四角的位置,圆屏页面可能显示不全;如果始终按圆屏内接区域收缩,矩形屏又会留下不必要的空白。应用需要一条可复用的判断依据,然后自己决定按钮、文字和边距。我希望判断依据能跟着设备走,换型号时仍然适用。

场景页面要完成的操作本次关注的问题
圆屏计时读时间、记圈、重置关键内容放在圆形显示区域以内
矩形屏复用使用相同的计时逻辑根据形状选择边距,保留可用空间
迁移已有穿戴页面沿用 WatchShape鸿蒙端能否响应原有通道请求

表1 页面使用场景

Flutter 的窗口尺寸描述的是布局可用的矩形。宽高相等可以说明这个矩形是正方形,不能单独说明设备的屏幕是圆形。Watch 4 Pro 这次回读的显示宽高是 466 × 466,独立探针还读到了 screenShape: 1。形状属性补充的是尺寸里没有的信息。安全区、窗口大小和物理屏幕形状也各有用途,不能拿其中一个字段替代其他两个。

我把责任分在三层:

  • 系统:提供屏幕形状。
  • 插件:把原生枚举转换为上游约定的字符串。
  • Example:消费 WatchShape,计算适合当前形状的内容区域。

计时、记圈和按钮样式都属于应用。读回一个 round 也不会让任意 Flutter 页面自动适配圆屏,布局仍然需要应用处理。

1.2 选择既有插件,先限定这次补齐什么

wear_plus 原本面向 Google Wear OS,使用 BSD-3-Clause 许可。它已有 Wear.instance.getShape() 和 WatchShape,适合继续承接屏幕形状查询。固定的 1.2.4 源码中,Dart 通过 MethodChannel('wear') 调用 getShape,pubspec.yaml 只注册 Android。这是本次增加平台实现的出发点:公开接口存在,鸿蒙系统也有对应的公开查询能力,中间缺少鸿蒙处理器。

wear_plus 1.2.5 的 pub.dev 包页面及 Android 平台标签

图2 上游包页面

截图展示 wear_plus 1.2.5 的包页面,平台标签为 Android;本文的适配基线仍为 1.2.4,版本选择见下文。

因此,我把这项工作定位为平台能力扩展。上游原有的支持范围是 Wear OS,在鸿蒙宿主里出现 MissingPluginException,说明需要增加对应的处理器。页面怎样利用返回值,仍然沿用原来的 Flutter 组件和应用布局。

我检查了 oh-Flutter、CPF-Flutter 的公开仓库清单,分别取得 399 项和 394 项,再按穿戴关键词筛选名称与描述。在这些仓库和候选包中,我没有找到可直接使用的 wear_plus 鸿蒙移植实现。对表中的五个候选,我继续核对发布源包中的接口、平台注册和实现职责;这个结论限定于上述检索范围。

候选与核对版本与本场景的关系本次处理
wear_plus 1.2.4 / 1.2.5已有形状 API 和 WatchShape固定兼容版本,增加鸿蒙实现
wear 1.1.0早期同类穿戴包比较维护与接口来源
is_wear 0.0.2+3判断设备是否属于穿戴设备不返回圆形、矩形信息
wearable_rotary 2.0.2 / 2.0.4处理表冠输入不承担屏幕形状判断
flutter_wear_os_connectivity 1.0.0处理手机与手表通信与本次本机布局查询职责不同

表2 候选职责对照

旧包的版本约束值得单独看。wear 1.1.0 写着 Dart SDK >=2.12.0 ❤️.0.0。按照 Dart 3 迁移说明,pub 会把这类空安全包的旧式上限解释为 <4.0.0,具体兼容性仍要结合依赖和源码检查。我选择 wear_plus 的依据是它的维护来源、与早期包的继承关系,以及已有的 WatchShape 用法。表里的其他包各有职责,判断设备类型、读取表冠和连接手机,都不能直接提供这里需要的屏幕形状。

我检查的上游 master 分支与 1.2.5 标签指向同一版本,另外核对了公开列表中的全部 8 个分支,都没有发现 ohos 目录。社区仓库 oh-flutter/wear_plus 导入上游源码后建立了 ohos/wear_plus 分支作为鸿蒙适配接收基线,合入前同样只保留原有 Android 目录。所用版本与各分支快照的对应关系保存在工程说明中。

版本方面,本文对照实验固定 wear_plus 1.2.4。对照的上游 1.2.5 将 Dart 下限提高到 ^3.12.0、Flutter 下限提高到 >=3.44.0,还调整了 Android Gradle 构建配置;两个版本的差异中,Dart 业务逻辑和方法签名保持一致,也不含 Dart 3.12 专有语法。

这次使用 Flutter 3.41.9+ohos-1.0.2 和 Dart 3.11.5,对照组保留 1.2.4 的原始环境声明完成对比验证;向社区 ohos/wear_plus 分支贡献的适配分支则同步基于 1.2.5 放宽环境约束,并补齐鸿蒙实现与开源合规文档,让当前社区工具链也能直接解析构建。

1.3 对照实验要保留上游的真实默认行为

只看圆屏上的首帧,会漏掉一个问题。上游 WatchShape 先把 _shape 初始化为 WearShape.round,随后才异步查询平台。即使插件还没接入,页面也可以先按圆屏布局显示。

⚠️ 易错提醒:看起来放得下,不代表底层查询已经成功。

我接着对照通道记录。baseline 中的 MissingPluginException 说明系统查询尚未接通,而默认形状仍留在组件里。因此这次保留这份默认行为,并在同一页面加上独立的查询状态:原版显示“查询未接入”,适配版收到回执后显示“圆屏已确认”。两组在圆表上都采用安全布局,差别通过状态和日志来核对。

二、同一页面上的调用结果与运行范围

2.1 页面相同,诊断状态分别记录

两组应用使用不同包名便于并排安装:baseline 是 com.example.wearbaseline,patched 是 com.example.wearpatched。除平台实现和必要的包标识外,计时页面、只读探针、按钮操作以及宿主设备声明保持一致。画面直接采集模拟器窗口,保留设备外框;时间从零累计,记圈记录来自按钮操作。

原版在圆表上的默认布局与通道诊断

图3 原版调用记录

适配版在圆表上的系统回执与计时操作

图4 适配调用记录

两段动图中的差别可以这样核对:

  • baseline:默认圆屏布局和“查询未接入”同时出现。
  • patched:原始查询收到 round,状态改为“圆屏已确认”,页面继续使用圆屏布局。

随后点“记圈”,记录行出现圈数和这一圈的用时;点“重置”,记录清空,计时重新开始。两段动图展示的是这些操作与状态变化,体验结论限定在这次模拟器。

圆屏计时页面默认布局

图5 原版圆屏默认

圆屏计时页面系统确认状态

图6 系统确认圆屏

适配版计时页面的记圈结果

图7 记圈操作结果

页面里有两条查询路径,用途分别是:

  • 布局:WatchShape 仍是应用的布局入口。
  • 诊断:Example 另外直接调用一次原始 MethodChannel('wear'),仅记录这次通道的成功或错误,不把诊断结果反过来改变布局。

这样既能验证上游组件用法,也不会把公开 API 的默认返回误认成原生查询成功。

这次 patched 日志因此有两次原生 getShape resolved round:一次由组件初始化触发,一次来自独立诊断。这是 Example 的两次请求,不是事件重复推送。普通应用只消费 WatchShape 时,可以去掉诊断查询。两组操作记录都包含连续记到第 5 圈、重置、再次记到第 1 圈;按钮功能在原版页面里同样可用。

2.2 日志只回答它真正记录的问题

屏幕形状查询与计时操作日志

图8 形状调用日志

核对项baselinepatched
独立显示探针读取系统的尺寸、密度和形状使用相同探针
原始 getShape 通道MissingPluginException返回 round
WatchShape 圆屏布局初始默认值,没有系统确认与系统查询结果一致
记圈、重置属于相同的页面逻辑属于相同的页面逻辑

表3 两组核对项目

独立探针运行在 Example 宿主中,只读取显示属性,不注册 wear 通道。它能提供参照,但不能替插件完成被测请求。只有 patched 的原始 getShape 收到正确响应,才说明新增原生模块参与了调用。主机测试里返回一个模拟的 round,也不能代替这一条设备记录。

读这份日志时,我先看探针确认的形状,再找原始请求的结果,最后核对按钮操作。时间戳用于关联先后顺序;探针与查询结果之间还包含其他代码和日志转发,不适合作为 API 耗时。这里要确认的是插件是否响应了正确的方法、是否返回约定的值。

2.3 环境与测试层次

插件声明和穿戴宿主配置

图9 插件宿主配置

项目测试环境
主机macOS,Apple Silicon arm64
DevEco Studio26.0.0.865
Flutter / Dart3.41.9+ohos-1.0.2 / 3.11.5
编译 SDK / 最低兼容 APIAPI 26 / API 18
固定插件wear_plus 1.2.4
模拟器Watch 4 Pro,wearable,466 × 466,320dpi
系统OpenHarmony-7.0.0.105,API 26
构建方式debug,无签名 HAP

表4 测试运行环境

验证先从主机测试开始:

  • 插件测试,5 项:检查通道返回与组件形状。
  • 页面测试,5 项:覆盖圆屏和矩形布局、查询失败、未知返回,以及连续记圈和重置。
  • 原生模拟分支,7 项:检查枚举映射、错误码和解绑。

三组均已通过,静态分析没有发现问题。随后,我在 ASCII 路径的干净副本构建两组 HAP,再安装到模拟器核对注册与系统调用。

原生脚本里的 display、hilog 和通道都是替身,便于模拟矩形枚举、字段缺失及系统抛错。它验证的是分支逻辑,完整的 ArkTS 工程兼容性仍由 HAP 构建检查。当前设备成功路径在圆屏模拟器取得,矩形手表只覆盖了对应的主机用例。

三、从系统形状到 Flutter 布局

3.1 保持协议,补上原生处理器

屏幕形状查询的跨端调用流程

图10 形状调用流程

鸿蒙的 Display.screenShape 从 API 18 开始提供,但属性可能缺失。插件把圆形枚举转换成 round,把矩形枚举转换成 square,沿用上游协议。这里的 square 包含矩形屏,不能再用宽高相等来限制返回。

引擎挂载时,插件建立 wear 通道并安装处理器;引擎卸载时,清除处理器和引用。每次查询通过 getDefaultDisplaySync() 读取默认显示属性,再返回结果。这里没有显示事件订阅,也不持有页面上下文,因此无需增加暂停、恢复一类长驻监听协议。

公开注册在原 pubspec.yaml 的 flutter.plugin.platforms 中增加 ohos,将 pluginClass 指向 WearPlusPlugin,原有 Android 注册项保持不变。完整配置与 HAR 导出、宿主和测试都在仓库中。

核心查询由 handleGetShape 完成。下面只去掉诊断日志,保留系统读取、枚举映射、错误回执和提前返回;通道注册与日志见完整插件源码。

private handleGetShape(result: MethodResult): void {
  try {
    const defaultDisplay: display.Display = display.getDefaultDisplaySync();
    const rawShape: display.ScreenShape | undefined = defaultDisplay.screenShape;
    // 只接受系统给出的已知形状,缺失值不当作圆屏。
    if (rawShape === display.ScreenShape.ROUND) {
      result.success('round');
      return;
    }
    if (rawShape === display.ScreenShape.RECTANGLE) {
      // 沿用上游命名:square 在这里也表示矩形屏。
      result.success('square');
      return;
    }
    result.error(
      'DISPLAY_SHAPE_UNAVAILABLE',
      `Display.screenShape is unavailable or unrecognized: ${String(rawShape)}`,
      null
    );
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    result.error('DISPLAY_READ_FAILED', `Failed to query display shape: ${message}`, null);
  }
}

这段代码区分两类失败:

  • DISPLAY_SHAPE_UNAVAILABLE:取得了显示对象,但形状不在已知枚举中。
  • DISPLAY_READ_FAILED:查询本身失败。

两个错误码把“属性不支持”和“读取过程失败”分开,排查时可以沿不同入口继续找原因。每条分支只回复一次;遇到未知枚举时保留错误,让调用端决定怎样处理。

3.2 上游默认值与成功回执要分开看

保持 API 不变也意味着保留原有行为。Wear.getShape() 捕获 PlatformException 后,会记录错误并返回 'round'。以下是固定上游版本的完整函数,注释改为中文:

Future<String> getShape() async {
    try {
      return (await _channel.invokeMethod<String>('getShape'))!;
    } on PlatformException catch (e, st) {
      // 系统查询报错时保留圆屏兜底,不代表硬件查询成功。
      debugPrint('Error calling getShape: $e\n$st');
      return 'round';
    }
  }

所以,调用公开方法得到 round 有两种可能:

  • 平台确实返回圆屏。
  • 平台返回错误,由 Dart 层选择圆屏兜底。

兜底结果不是硬件事实。它可以帮助页面保守布局;但字段缺失时,即使鸿蒙实现返回了明确错误码,公开 Dart API 仍可能返回圆屏,这一点必须告诉接入者。

MissingPluginException 又是另一种情况。它不属于这里捕获的 PlatformException,固定版本不会在这条路径中返回 'round'。与此同时,WatchShape 的状态本来就初始化为圆屏;异步调用失败不等于首帧从未显示。baseline 可以同时出现“页面按圆屏布局”和“异步通道报错”,两者并不矛盾。

我保留了上游已有的异常处理和默认形状语义。要让业务明确区分“系统确认”和“兜底结果”,需要另行设计兼容的状态 API,并讨论旧调用者如何迁移。那属于不同范围。本例只在诊断区读取原始通道,帮助验收,不建议业务到处绕过公开 API 自行复制通道协议。

3.3 Example 负责内容区域和计时状态

圆屏布局采用居中的内接正方形。设可用圆形直径为 D,正方形对角线等于 D,所以边长为 D / √2,每侧留白为 (D - D / √2) / 2,约占直径的 14.64%。这里是几何上的保守内容区域,不是系统返回的安全区,也不是对所有手表 UI 的最佳设计结论。

这种布局便于说明适配:正方形四个角位于圆周内,文字和按钮在区域内排布即可。代价也很明确,它没有利用圆屏上下两端的弧形空间。如果页面需要环形刻度、弧形标签或可滚动列表,应用可以采用其他布局;插件只提供形状,不强制这一公式。矩形分支则使用常规边距,不再套用圆屏收缩比例。

以下是本次 Example 的完整布局函数,位于 example/lib/main.dart。它只根据 WatchShape 的形状决定内容区域,原始通道诊断不会改写这个选择:


  Widget build(BuildContext context) {
    return WatchShape(
      builder: (context, shape, _) {
        return Scaffold(
          backgroundColor: const Color(0xFF0A0F18),
          body: LayoutBuilder(
            builder: (context, constraints) {
              final side = math.min(
                constraints.maxWidth,
                constraints.maxHeight,
              );
              // 圆屏按内接正方形留边距,矩形屏沿用应用设定。
              final inset = shape == WearShape.round
                  ? side * ((1 - math.sqrt1_2) / 2)
                  : _rectangularInset;
              return Padding(
                key: const ValueKey('shape-layout'),
                padding: EdgeInsets.all(inset),
                child: _buildTimerContent(),
              );
            },
          ),
        );
      },
    );
  }

诊断、计时和记圈的实现见完整 Example。

计时器使用 Stopwatch 记录经过时间,定时器每隔 100 ms 通知界面刷新。假如某一帧刷新晚了,下一次显示仍读取经过时间,计数不依赖回调执行了多少次。显示保留十分之一秒,页面关闭后这次记录结束;后台训练计时和持久化恢复需要应用另行处理。

记圈时,应用用当前经过时间减去上一次记圈的时间,得到这一圈的用时;日志同时保留累计时间和本圈时间,便于核对。圈数单独累计,列表只保留最近三条,即使旧记录移出列表,下一圈编号也能继续增长。重置后计时重新累计,下一次记圈从第 1 圈开始。

读者可以保留自己的计时和记录方式,只把形状查询接回原来的 WatchShape。

四、接入时容易混淆的条件

4.1 最低 API 与设备声明要一起检查

宿主的 compatibleSdkVersion 需要与原生能力对应。本次两组都按 API 18 声明最低兼容范围,编译使用 API 26,与 screenShape 的引入版本对齐。字段仍然是可选的,所以代码保留缺失值的错误分支;版本符合要求以后,还要看该次查询实际返回了什么。

设备类型是另一项独立配置。插件 HAR 和示例宿主都列入 wearable,宿主同时保留完整的 EntryAbility 和 Flutter 插件注册路径。我的检查顺序是先核对这些声明,再构建、安装和启动。若接入时停在安装阶段,应结合原始错误检查设备类型和签名;若已经进入页面却报通道未实现,则继续检查依赖和注册器,避免把两个阶段的问题混在一起。

本地的工程维护路径带中文,而当前 Hvigor 构建对路径有额外限制。复现说明因此要求把完整源码复制到 ASCII 路径,再清理自动生成缓存、解析依赖和构建。复制的是整个工程,不是只复制一个 .ets 文件;原仓库的许可和必要依赖锁也应保留。把构建机器上的 SDK 绝对路径写进发布包,不能解决工程的可移植性。

4.2 按调用阶段排查日志

原生日志使用应用日志域,记录附着、查询结果和错误。日志是否可见还受设备配置、过滤条件和采集等级影响。查不到 INFO 时,我会先检查过滤条件,再用页面上的原始回执确认调用状态,避免把采集范围误当成运行结果。

我按调用结果选择排查入口:

观察到的结果下一步检查
MissingPluginExceptionwear 通道是否安装了处理器
DISPLAY_READ_FAILED沿错误上下文检查显示查询
读回 round,页面仍排版异常转向 Flutter 布局

表5 排查入口

按阶段拆开以后,插件注册、系统读取和页面显示都有各自的排查入口,日志也无需塞进无关的应用数据。

4.3 常显能力不在本次适配范围

wear_plus 的常显组件和相关状态、恢复设置仍按 Wear OS 机制设计。本次鸿蒙实现只处理形状查询,其他方法返回未实现。

接入时应把支持范围写清楚:鸿蒙侧可以使用本次形状查询,常显组件仍不支持。若页面里原先包着 AmbientMode,需要先把那部分平台调用分开处理。后续扩展常显能力时,还要重新核对鸿蒙公开 API 的生命周期、功耗和设备限制,方法名相似并不代表行为相同。

4.4 默认显示与当前窗口不是同一概念

插件读取默认显示的物理形状。这次在手表的全屏窗口验证这条路径,没有验证外接屏、多显示器或复杂的窗口迁移。如果应用将来出现在不同显示器上,需要重新确认“默认显示”是否就是承载当前窗口的显示器,而不是把本次实现无条件推广到所有桌面场景。

应用还要继续处理窗口约束、文字缩放和布局溢出。本例使用简短标签,在页面上显示最近一次记圈,让内容在有限高度内保持可读。增加长文案或放大字体后,可能需要滚动、重新分组,或者把记录放到另一个页面。这些调整依赖实际内容;形状查询为布局提供依据,应用再决定怎样利用这块空间。

五、结论与可直接复用的建议

5.1 这次适配解决了什么

我补齐了 wear_plus 的鸿蒙形状查询:原来的 Dart 入口不用改,系统形状可以传入 Flutter 布局。圆屏模拟器已收到正确回执,矩形分支通过了主机测试;矩形手表实机尚未验证。计时与记圈是示例场景,库可以供其他穿戴页面复用,社区仓库主页见 oh-flutter/wear_plus。

AtomGit 上 oh-flutter/wear_plus 社区仓库页面与源码目录

图11 社区适配仓库

5.2 在自己的项目中使用

💡 社区仓库的 ohos/wear_plus 分支合入并发版前,可以在应用的 pubspec.yaml 中引用公开适配分支 abaoge/wear_plus(feat/ohos_wear_plus_1.2.5)对应的固定提交,其中包含鸿蒙插件实现与开源合规文档;合入后即可直接切换为社区仓库的 ohos/wear_plus 分支。上游原始标签不含本次适配,不能只按相同版本号替换依赖。

dependencies:
  wear_plus:
    git:
      url: https://atomgit.com/abaoge/wear_plus.git
      ref: c1b0629bd138066cdf8133caed95ca0378f1e5b2

解析依赖后,导入 package:wear_plus/wear_plus.dart,把需要随形状变化的内容放进 WatchShape.builder。第三章的完整布局函数可以直接作为参考:圆屏使用内接区域,矩形屏使用普通边距。保留自己的内容组件即可,无需接入独立探针或整套计时页面。

宿主最低兼容 API 为 18,并声明 wearable 设备类型;本篇使用的编译环境见第二章。首次加入原生插件后需要完整重建应用,单靠 Dart 热重载不能安装新的鸿蒙处理器。若仍报通道未实现,再按第四章检查依赖解析结果和插件注册。

5.3 使用时保留这几个判断

  1. 按形状选择布局。 系统只提供形状,文字、按钮与滚动区域仍由应用安排。内容较多时,不必把所有信息硬塞进一屏。
  2. 按用途解读结果。 普通布局可以沿用上游的圆屏兜底;如果业务必须确认硬件形状,公开接口的默认值不足以作依据,应结合第三章说明的失败语义设计状态提示。
  3. 只使用已支持的能力。 常显接口尚未适配;外接屏和窗口迁移也不在这次验证范围。不要因为形状查询可用,就直接打开包内所有穿戴能力。

中文适配说明整理了接口支持范围与接入条件;需要对照第二章的 1.2.4 实测页面与探针时,可以查看 feat/ohos-watch-shape 分支中的完整 Example和复现 README。日常业务接入使用上面的依赖与组件即可。

小结

对这张计时卡片来说,形状查询接通后,页面有了来自系统的布局依据。应用继续负责内容怎么摆,插件负责把形状读回来,两部分可以分别复用。

参考资料

  1. HarmonyOS 屏幕属性与形状 API
  2. HarmonyOS module.json5 配置
  3. wear_plus 上游源码
  4. oh-flutter/wear_plus 鸿蒙社区仓库
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐