Flutter 适配鸿蒙手表屏幕形状:让计时卡片按圆屏和方屏布局
📦 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。这是本次增加平台实现的出发点:公开接口存在,鸿蒙系统也有对应的公开查询能力,中间缺少鸿蒙处理器。

图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 形状调用日志
| 核对项 | baseline | patched |
|---|---|---|
| 独立显示探针 | 读取系统的尺寸、密度和形状 | 使用相同探针 |
原始 getShape 通道 | MissingPluginException | 返回 round |
WatchShape 圆屏布局 | 初始默认值,没有系统确认 | 与系统查询结果一致 |
| 记圈、重置 | 属于相同的页面逻辑 | 属于相同的页面逻辑 |
表3 两组核对项目
独立探针运行在 Example 宿主中,只读取显示属性,不注册 wear 通道。它能提供参照,但不能替插件完成被测请求。只有 patched 的原始 getShape 收到正确响应,才说明新增原生模块参与了调用。主机测试里返回一个模拟的 round,也不能代替这一条设备记录。
读这份日志时,我先看探针确认的形状,再找原始请求的结果,最后核对按钮操作。时间戳用于关联先后顺序;探针与查询结果之间还包含其他代码和日志转发,不适合作为 API 耗时。这里要确认的是插件是否响应了正确的方法、是否返回约定的值。
2.3 环境与测试层次

图9 插件宿主配置
| 项目 | 测试环境 |
|---|---|
| 主机 | macOS,Apple Silicon arm64 |
| DevEco Studio | 26.0.0.865 |
| Flutter / Dart | 3.41.9+ohos-1.0.2 / 3.11.5 |
| 编译 SDK / 最低兼容 API | API 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 时,我会先检查过滤条件,再用页面上的原始回执确认调用状态,避免把采集范围误当成运行结果。
我按调用结果选择排查入口:
| 观察到的结果 | 下一步检查 |
|---|---|
MissingPluginException | wear 通道是否安装了处理器 |
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。

图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.4 实测页面与探针时,可以查看 feat/ohos-watch-shape 分支中的完整 Example和复现 README。日常业务接入使用上面的依赖与组件即可。
小结
对这张计时卡片来说,形状查询接通后,页面有了来自系统的布局依据。应用继续负责内容怎么摆,插件负责把形状读回来,两部分可以分别复用。
参考资料
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)