📦 Flutter 三方库 dual_screen 是用于读取折叠屏布局与铰链角度数据的 package。在支架调节页中,原有鸿蒙实现会在页面退出后继续更新角度缓存。我在原库基础上保留原入口兼容,增加了随消费者连接和取消的按需订阅入口。
完整代码已开源在 oh-flutter/flutter-dualscreen,可直接使用。有问题可以到仓库提 Issue,也可以来 CPF-Flutter 鸿蒙社区 交流 Flutter 鸿蒙适配问题。
前言
我做了一个支架调节页,在 Mate X7 模拟器上切换折叠姿态,看着度数调整位置。点“返回首页”后,角度页已经销毁,首页也不用这些数据,可我继续折动屏幕时,原生日志仍在收到角度回调。
🔍 我顺着页面的取消调用往下查,发现 dual_screen 原入口会持续更新最近一次角度缓存,方便后续组件读取。这种用法适合常驻组件,但我的工具页只在打开时需要度数,于是我保留原入口,另外增加了按需订阅。用相同的动作再次采样,无消费者期间的原生回调从 3 条降到了 0 条,重新进页则先等待新的角度变化。
一、使用场景与痛点
1.1 支架调节页只需要短时观察角度

图1 使用场景
图 1 展示了四类常见的折叠姿态交互。半折悬停观影、桌面视频通话、帐篷模式时钟都可能读取铰链角度,但是否需要跨页面保留监听,要看具体页面的职责。我把本次示例限定在其中的调节环节:用户需要看度数时打开工具页,调好位置后关闭它,后续首页不依赖角度变化。表 1 按“离开后需求”和“再次进入预期”区分了这两类诉求。
| 常见情景 | 示例场景 | 离开后需求 | 再次进入预期 |
|---|---|---|---|
| 辅助调整悬停姿态 | 支架调节页 | 停止角度观察 | 随铰链微调获取新事件 |
| 硬件功能自检 | 折叠能力检查页 | 结束本次检查 | 重新发起独立采样 |
| 开发联调核验 | 临时传感器调试面板 | 停止后台回调 | 开启全新观察会话 |
| 全局布局或角标联动 | 常驻状态面板 | 继续维护最新值 | 订阅后立即回放缓存角度 |
表1 订阅场景
表 1 前三类页面退出后可以结束观察,第四类则要继续维护随设备变化刷新的角度。这里有一个容易混淆的区分点——保留历史值不等于继续更新缓存:
- 应用可以在停止监听的同时保存上一次读数,只是那份数字不再代表当前物理角度。
- 原库选择持续监听,让页面离开期间的缓存也跟着硬件变化实时更新。
- 我增加的按需入口则在最后一个消费者退出时清空会话缓存,下次进入等待新的传感器事件。
两种策略各自对应真实需求,接入前需要先确认页面属于哪一类。为了复现短时观察路径,我在 example/lib/lifecycle_probe.dart 中写了一个普通工具页,交互只有三步:
- 启动应用后停在“折叠工具”首页,界面只放说明文字和“打开折叠角度页”按钮,首页自身不创建角度订阅。
- 点击按钮压入“折叠角度”子页,先查询折叠屏能力,再订阅
hingeAngleEventsOnDemand,实时显示大字号度数与当前会话收到的事件条数。 - 点击“返回首页”弹出当前路由,触发
dispose()取消订阅;再次进入角度页时作为新会话等待系统角度变化。
阅读示例时还要分清路由可见性与实例销毁的边界:
- 路由出栈销毁:示例里的“离开”指返回首页并销毁角度页
State,从而在dispose()中触发取消。 - 页面保活隐藏:如果用
Navigator.push在角度页之上压入详情页,或者用IndexedStack保留后台标签,原页面的State仍在内存中,订阅不会自动释放,业务需结合路由监听或可见性回调主动调用cancel()。 - 职责范围:示例仅展示设备上报的角度读数,不控制物理支架,也不判断某个悬停角度是否安全。
1.2 查库核验
我检查了 oh-Flutter、CPF-Flutter 组织下的仓库,以及上游 microsoft/flutter-dualscreen 源码。这几个仓库各自负责不同部分:
- 上游包维护方(microsoft/flutter-dualscreen):定义跨平台公开 API(
TwoPane、MediaQueryData.hinge、DualScreenInfo)以及角度流的最近值缓存约定。 - 鸿蒙社区适配维护方(oh-flutter/flutter-dualscreen):提供鸿蒙端插件工程,接通
display.isFoldable()能力检测与display.on('foldAngleChange')事件通道(其中布局折痕数据仍受引擎侧约束,本篇仅核验角度通道),并接收本次按需订阅核心补丁与单测。 - 公开复现分支(abaoge/flutter-dualscreen):在社区源码 PR 合入发版前,保留同款验证页与采样复现材料,方便读者直接拉取对照。
图 2 是 pub.dev 上的 dual_screen 1.0.4 包页面,可以看到发布方、Android 平台标记,以及双栏布局和铰链角度数据的用途说明。

图2 上游包页面
图 3 是鸿蒙社区适配仓的目录,包含 ohos、lib、example 和 test。选库时,我把上游包信息与这份鸿蒙实现对照核查,再确定需要补充的订阅策略。

图3 社区适配仓
既然鸿蒙系统 API 与通道已经接通,就不需要另建重复包。至于原入口为什么退出页面后不断开原生监听,查阅上游变更记录可以看到 1.0.0+2 版本的明确说明:
DualScreenInfo.hingeAngleEvents now caches the last value.
—— 出处:microsoft/flutter-dualscreen 上游 CHANGELOG.md(1.0.0+2)
源码中的 _repeatLatest 在首次读取旧入口时订阅平台广播流,并缓存最新角度。页面取消时,只从消费者集合中移除当前页面,底层源订阅仍保留。因此我把按需启停做成可选能力,保留原来的缓存行为。
不过,支架调节页确实需要在退出时停止观察,而原入口走不通最后一步:
- 业务页面的取消请求停在 Dart 多播集合里,不会因最后一个页面退出而触发鸿蒙端的
display.off('foldAngleChange', this.foldAngleCallback)。 - 在引擎仍挂载、源流仍活跃的期间,后续折叠动作还会进入应用刷新那份无人读取的缓存(引擎卸载时的
onDetachedFromEngine清理属于另一条生命周期路径)。 - 业务页面拿不到插件私有的源订阅对象,在包外再套一层自定义
Stream也无法通知底层解绑。
因此,按需启停必须放回 dual_screen 内部实现:首个消费者出现时连接上游,同一会话内共享读数,最后一个消费者退出时清空缓存并取消上游订阅。
1.3 版本基线与改动范围

图4 入口与配置
我采用 oh-flutter/flutter-dualscreen 的 main 源码快照作为基线,包版本为 1.0.4,鸿蒙适配变更记录为 1.0.4-ohos-1.0.0-beta.1。准确提交映射保存在公开工程的来源清单中。
版本核对提示:该仓库在 pubspec.yaml 中沿用上游版本号 1.0.4,但 CHANGELOG.OpenHarmony.md 记录的是 1.0.4-ohos-1.0.0-beta.1(Beta 阶段适配)。pub.dev 上的 dual_screen 1.0.4 仅包含 Android 原生实现,复现时不能只看版本号相同就直接替换成中心仓依赖。
原仓库中文文档记录的早期验证环境为 3.44.9+ohos-0.0.1-canary1。迁到本文使用的 HarmonyOS 7(API 26)和社区稳定工具链时,我按下面的顺序处理:
- 增加按需订阅入口,保留旧入口的缓存与回放语义。
- 增加生命周期和路由测试,检查最后一个消费者退出及重新进入的行为。
- 调整宿主编译配置和设备声明,让同款页面能在当前模拟器运行。
- 为对照采样增加原生日志,只记录注册、解绑与角度回调,不改变监听逻辑。
补丁文件与应用顺序保存在仓库工程说明中,业务直接使用第五章的依赖即可。
导出的工程完整保留了原库 MIT 许可证、版权头、Android 实现、38 项原有单测以及 example/lib/main.dart 双栏示例。图 4 右侧展示了更新后的模块声明:
- 插件 HAR
deviceTypes声明为phone和tablet。 - 示例宿主
compileSdkVersion与targetSdkVersion设为 26.0.0,runtimeOS为 HarmonyOS。 - 宿主
requestPermissions为空数组——监听折叠角度调用的是鸿蒙公开显示接口,不需要申请额外权限,顺手移除模板残留的INTERNET权限不影响调用路径。
二、按需订阅的实际效果
2.1 场景页进出前后的实测对照
图 5、图 6 采集自同一台 Mate X7(API 26)折叠屏模拟器。两组运行相同的 lifecycle_probe.dart 工具页,仅切换第 109 行订阅的公开 getter,分别对比原入口 hingeAngleEvents 与按需入口 hingeAngleEventsOnDemand。

图5 原版页面动图

图6 按需页面动图
两组按完全一致的四步顺序操作:
- 从“折叠工具”首页点击“打开折叠角度页”,将模拟器切到半折叠(90.0°),再恢复展开(180.0°)。
- 点击返回回到首页,确认角度页路由销毁后,在首页无业务消费者期间再次将模拟器切为半折叠(90.0°)。
- 第二次点击“打开折叠角度页”,手不触碰铰链,直接观察刚进入页面时的初始读数与事件计数。
- 在第二次会话中将模拟器展开到 180.0°,观察页面实时刷新情况后返回首页。

图7 原版重入回放

图8 按需重入等待

图9 按需实时刷新
第二次进入角度页时,两组表现就不同了。画面中央的浅蓝竖条是 DevEco 模拟器的折痕避让区提示。
- 原入口(图 5、图 7):刚打开页面就立即显示 90.0°,“本次页面收到 1 条角度数据”。此时尚未做新的折叠动作,这条读数来自停留首页期间后台继续更新的静态缓存。
- 按需入口(图 6、图 8、图 9):刚打开页面时显示占位符 — 与“等待设备角度变化,本次页面收到 0 条角度数据”。上一轮退出后插件已停止接收首页期间的角度变化,也没有保留旧值;直到随后展开屏幕,页面才实时刷新为 180.0°(图 9)。
表 2 列出了这些截图与日志对应的软硬件环境。DevEco 设备管理器显示的镜像号为 7.0.0.107,而通过系统属性 const.ohos.fullname 回读的字符串为 OpenHarmony-7.0.0.105,表中分开记录,避免混淆两个不同字段。
| 环境项 | 实测配置 |
|---|---|
| Flutter / Dart | 3.41.9+ohos-1.0.2 / 3.11.5 |
| DevEco Studio | 26.0.0.865 |
| HarmonyOS SDK | 26.0.0.105 Release(API 26) |
| 模拟器模板 | Mate X7,arm64,4 核、4 GiB |
| 模拟器镜像版本 | 7.0.0.107 |
| 设备系统回读版本 | OpenHarmony-7.0.0.105 |
| 构建与安装方式 | Debug 未签名 HAP(仅用于本机模拟器调试) |
| 观测与采样工具 | 模拟器官方折叠控制命令、hdc、HiLog、自动采样器 |
表2 验证环境
2.2 固定时序下的原生注册与回调计数
只看界面读数还不能确认底层 display 监听是否真正解绑。我另外使用 lifecycle_runner.dart 在同一台 Mate X7 上跑固定时间表采样,消除人工点击快慢带来的时序偏差。这里记录的是原入口 baseline-02 与按需入口 ondemand-01 各一次约 40 秒的运行记录(单次运行内部包含两轮订阅,并非多轮实验取均值):
- 启动准备(0~3 秒):启动采样器并完成设备折叠能力检查。
- 第一轮活跃期
active_1(3~13 秒):建立订阅保持 10 秒,在第 6、10 秒切换折叠姿态。 - 第一段空闲期
idle_1(13~23 秒):取消订阅静置 10 秒,在第 17、21 秒无消费者状态下切换折叠姿态。 - 第二轮活跃期
active_2与收尾空闲complete_idle(23~40 秒):第 23 秒重新订阅保持 10 秒(第 27、31 秒切换姿态),第 33 秒再次取消并在第 37 秒做最后一次空闲姿态切换。
七次 90°/180° 折叠命令在两组中按相同计划时点触发,统计结果见表 3。
| 观察指标 | 原入口 hingeAngleEvents | 按需入口 hingeAngleEventsOnDemand |
|---|---|---|
| 业务层订阅/取消次数 | 2/2 | 2/2 |
| 鸿蒙原生注册/注销成功次数 | 1/0 | 2/2 |
| 无业务消费者期间原生角度回调 | 3 | 0 🔴↓ |
| 采样结束时注册减注销差额 | 1 | 0 🔴↓ |
| 原生层收到的系统角度事件总数 | 7 | 4 |
| 业务层收到的角度回调总数 | 5 | 4 |
表3 实测计数
拆开看表 3 的三组核心差异:
- 空闲期原生回调(3 对 0):第 17、21 秒落在
idle_1,第 37 秒落在complete_idle。原入口一直保留系统监听,七次切换全部记入nativeAngle;按需入口在退出活跃期时调用了display.off,只接收两段活跃期内的四次变化,空闲期间为零。 - 业务层事件数(5 对 4):按需入口两轮各收一次 90° 和一次 180°,合计四条;原入口多出的第 5 条,是第 23 秒第二次订阅建立后回放的 180.0° 缓存。
- 注册减注销差额(1 对 0):差额按
nativeRegistered减去nativeUnregistered计算。原入口最终为 1,说明它只维持唯一一条共享监听,不会随页面进出无限累加;按需入口两进两出完成 2/2 配对,结束归零,代价是比原入口多执行一次注册和两次注销。
我没有测量功耗、堆内存、CPU 或电流。应用调用 display.off 只代表当前插件解除了自己的回调,系统桌面等服务仍可能读取铰链状态,不能推断物理传感器已经断电,因此上述计数仅用于核验订阅生命周期,不换算成节电比例或性能提升幅度。
2.3 从真实 HiLog 核对原生解绑时序
两组采样包都加入同一份观测日志,记录请求监听、注册成功、请求取消、解绑成功和收到角度五类事件。图 10 左侧为 baseline-02 的 26 行原始记录(PID 21851),右侧为 ondemand-01 的 28 行原始记录(PID 23207)。

图10 原生日志对照
原入口日志时序:取消停留在多播层
对照图 10 左栏行号,原入口在两轮订阅中的关键轨迹如下:
- 首次注册:第 06、07 行(03:31:49.977~978)打印唯一一次
nativeListen与nativeRegistered。 - 首次取消与空闲穿透:第 12、13 行(03:31:59.982~984)业务层打出
cancelRequested、cancelReturned后,原生层没有出现nativeCancel;随后空闲期的 03:32:04.838(第 14 行)和 03:32:08.954(第 15 行)照常收到angle=90与angle=180。 - 重入回放与尾声回调:到 03:32:09.990(第 17、18 行),第二轮订阅返回与
angleReceived angle=180.0 total=3落在同一毫秒。缓存实际通过异步事件投递,毫秒级时间戳不足以区分先后,不能把它当作同步回调。到第 25 行runnerComplete结束四秒多后的 03:32:24.786(第 26 行),原生层仍在接收第七条nativeAngle angle=90。
按需入口日志时序:取消透传至原生解绑
再看图 10 右栏行号,按需入口将业务取消一路传到了鸿蒙显示模块:
- 首次取消即解绑:第 12、13 行发起第一次取消,Dart 在 03:33:22.861 打印
cancelReturned;下一毫秒(03:33:22.862,第 14、15 行),原生层紧接着打出nativeCancel与nativeUnregistered,此后十秒空闲窗口内没有任何nativeAngle。 - 二次会话成对收尾:第二轮订阅于 03:33:32.864(第 18、19 行)重新注册,接住新的 90°、180° 变化后,在 03:33:42.867~870(第 27、28 行)完成第二次解绑。
日志核验提示:业务侧的取消 Future 不等待原生解绑完成。EventChannel 内部虽然等待取消回包,但广播控制器的取消回调不返回 Future,这段等待没有传回业务侧。应结合原生解绑成功日志和空闲期零回调核验;这里只比较同一份 HiLog 的先后顺序,不跨时钟相减计算通道耗时。
三、原理与核心代码
3.1 先把四层订阅链路拆开
顺着页面手里的订阅对象往下追踪,一次角度监听横跨了从业务 Widget 到鸿蒙系统模块的四个层级:
- 第 1 层(业务消费层):具体页面持有的
StreamSubscription<double>,随页面生命周期建立与取消。 - 第 2 层(Dart 多播与缓存控制层):
DualScreenInfo用Stream.multi封装的分发器,决定何时连接上游以及是否保留最近一次角度。 - 第 3 层(共享平台广播源):
EventChannel.receiveBroadcastStream()建立的跨端广播流,按上游订阅计数向原生层发送listen或cancel。 - 第 4 层(鸿蒙原生显示监听):ArkTS 插件 DualScreenInfo.ets 在
@ohos.display上注册与解绑foldAngleChange回调。
图 11 标出了原入口与按需入口在第 2 层的分流位置。

图11 四层订阅链路
页面在 dispose() 中取消的只是第 1 层到第 2 层的连接:
- 原入口
_repeatLatest收到取消后,仅把当前页面从currentListeners移除,始终保活第 3 层平台源。 - 新入口
_repeatLatestWhileListening在首个消费者加入时才向第 3 层发起listen,最后一个消费者退出时主动取消第 3 层订阅。只要进程内从未激活过旧入口,第 3 层监听数归零便会通过 EventChannel 发送cancel,驱动第 4 层执行display.off('foldAngleChange', this.foldAngleCallback);反之,若旧入口已被其他组件读取,其常驻订阅仍在,按需入口不会越权替它断开共享源。
3.2 鸿蒙原生层已有的注册与回调引用解绑
社区原有的 DualScreenInfo.ets 已负责原生启停:引擎挂载时建立角度通道,收到监听请求时注册,收到取消请求或引擎卸载时解绑。下面摘录完整角度回调与收尾函数,仅补充中文注释;其余通道配置见完整原生源码。
private readonly foldAngleCallback = (angles: Array<number>): void => {
if (angles.length === 0) {
return;
}
// Dart 接口只传一个角度,多折轴设备按系统约定取第一轴。
this.hingeAngleSink?.success(angles[0]);
};
注册函数先清理旧监听,再检查设备是否可折叠。非折叠设备直接结束流;可折叠设备保存事件接收端,注册角度回调。注册抛错时记录系统错误并通知 Dart 订阅者。收尾函数如下:
private stopListening(): void {
if (this.isListening) {
try {
// 传回同一回调引用,只解除本插件的监听。
display.off('foldAngleChange', this.foldAngleCallback);
} catch (error) {
const businessError = error as BusinessError;
hilog.warn(
0,
TAG,
`Failed to stop fold angle listener: ${businessError.code} ${businessError.message}`,
);
}
}
this.isListening = false;
this.hingeAngleSink = null;
}
回调引用与解绑边界
审查原生代码时,首先要确认 display.on 与 display.off 传入的是不是同一个函数对象引用:
- 插件将箭头函数保存在只读属性
foldAngleCallback中,解绑时显式传回同一引用,只精准移除本插件注册的监听。 - 如果省略
display.off('foldAngleChange')的第二个参数,系统会清空该事件名下全部已注册回调,可能误伤宿主或其他 SDK;反之若每次临时传入新的匿名函数,又无法匹配已有回调导致解绑失效。 - 注册前先清理旧监听、检查设备能力,避免重复注册。这部分沿用社区实现。
异常分支与单轴协议约束
注册和解绑异常走的是两条不对等的通知路径,排查时需要留意:
- 注册失败:记录系统错误,并推入 Dart 流的错误回调,页面可以显示失败提示。
- 解绑失败:只记录原生告警,然后清空本地监听状态和事件接收端。异常不会回传给 Dart,排查时需要查看 HiLog。
- 单轴协议:系统返回角度数组,公开接口却只传一个浮点数,因此插件取数组第一项。按系统文档,多折轴设备在充电口朝下时以最右侧铰链作为第一轴;一个角度不能代表三折屏的完整姿态。
3.3 两个公开入口为什么要共用一个平台广播源
新增 hingeAngleEventsOnDemand 后,同一进程内可能既有旧入口消费者、又有新入口消费者。我没有让两个 getter 各自调用 _hingeAngleEventChannel.receiveBroadcastStream(),原因在于 Flutter BinaryMessenger 在同一通道名称上只维护一个消息处理器(setMessageHandler):如果两份广播流各自创建底层通道订阅,后激活的一方会覆盖前一方的处理器,任意一方取消时还可能把另一方正在使用的处理器直接清掉并关停原生监听。
因此,两个入口共用一个平台广播源,只在上层采用不同的缓存策略。下面保留共享源及新 getter;旧入口仍把同一源交给原来的缓存函数,完整声明见插件源码。
// 两个入口共用同一平台流,避免覆盖同名通道的处理器。
static final Stream<double> _platformHingeAngleEvents =
_hingeAngleEventChannel.receiveBroadcastStream().map(
(event) => (event as num).toDouble(),
);
static Stream<double>? _onDemandHingeAngleEvents;
static Stream<double> get hingeAngleEventsOnDemand {
try {
return _onDemandHingeAngleEvents ??=
_repeatLatestWhileListening(_platformHingeAngleEvents);
} catch (e) {
// 沿用入口的同步异常兜底;运行中的流错误仍交给订阅者处理。
return Stream.empty();
}
}
这里还有两处设计细节值得说明:
- 惰性映射:
_platformHingeAngleEvents上的.map(...)只做数值转型,保留广播流的惰性特征;只要下游没有人调用listen,就不会激活 EventChannel。新入口直接命名为hingeAngleEventsOnDemand,阅读业务代码时一眼就能分辨当前页面采用了哪种策略,无需引入隐式全局开关。 - 错误传递范围:getter 外层的
try ... catch仅兜底创建流对象时的同步异常;原生侧通过events.error发出的错误走异步流错误通道,业务页面仍须在listen中传入onError,而通道激活或停用阶段的平台异常则交由 Flutter 框架错误报告机制处理。
3.4 最后一个消费者退出时怎样安全收尾
原入口的行为已在第一章说明。这里看新增缓存函数中的两个完整处理单元:连接上游的局部函数,以及最后一位消费者取消时的回调。完整外层函数仍在插件源码中,下面按职责分开阅读。
外层保存消费者集合、最近值、当前订阅、取消中的 Future 和会话编号。新消费者加入集合后会收到本会话缓存,再尝试连接上游;如果源流已经结束,则直接关闭该消费者。
void startListening() {
// 没人需要、已经连接或仍在收尾时,都不重复连接。
if (done ||
currentListeners.isEmpty ||
subscription != null ||
cancelling != null) return;
final session = ++generation;
subscription = original.listen(
(event) {
if (session != generation) return;
latest = event;
for (var listener in [...currentListeners]) listener.addSync(event);
},
onError: (Object error, StackTrace stack) {
if (session != generation) return;
for (var listener in [...currentListeners])
listener.addErrorSync(error, stack);
},
onDone: () {
if (session != generation) return;
done = true;
latest = null;
subscription = null;
final listeners = [...currentListeners];
currentListeners.clear();
for (var listener in listeners) listener.closeSync();
},
);
}
连接前的检查避免重复订阅,也会等待上一轮 Dart 取消流程结束。每个回调都检查会话编号,旧会话的迟到数据和错误因此不会更新当前消费者;结束回调则关闭现有消费者并记住终态。
controller.onCancel = () async {
currentListeners.remove(controller);
if (done || currentListeners.isNotEmpty) return;
// 最后一个业务订阅退出后,旧角度不再作为下一次采样的实时值。
latest = null;
final previous = subscription;
if (previous == null) {
await cancelling;
return;
}
subscription = null;
generation++;
// 等 Dart 上游订阅取消后再重订;该 Future 不等待原生 cancel 回包。
cancelling = previous.cancel().whenComplete(() {
cancelling = null;
startListening();
});
await cancelling;
};
取消回调先移除当前消费者。仍有人使用时继续监听;最后一人退出才清空缓存、使旧会话失效并取消上游。等待期间若有新消费者进入,收尾后会重新检查并连接,不会漏掉这次订阅请求。
会话怎样切换
为了看清 _repeatLatestWhileListening 在快速进出页面、并发监听和流终态之间的切换关系,图 12 用状态流转图梳理了它的完整会话状态机:

图12 会话状态机
图 12 还标出了快速重进、回调内取消和流结束时的边界。
快速退出重进时的取消衔接
上一轮 Dart 取消流程未结束时,新消费者先留在集合中等待。收尾后,如果消费者仍在,就建立新连接;如果它也已退出,则保持空闲。这里等待的是 Dart 侧取消 Future,不能据此判断鸿蒙原生解绑已经完成,实际时序见第二章日志。
会话代次与结束路径
会话编号只能隔离 Dart 已识别的旧订阅回调。跨端传来的数据只有角度,没有原生会话号;若旧硬件事件到下一次连接后才送达,Dart 无法仅靠编号判断它属于哪一轮。因此,真正停止系统观察仍依赖原生解绑。
回调中取消订阅
向消费者派发事件、错误或关闭信号时,代码统一遍历 [...currentListeners] 浅拷贝快照。如果某个消费者采用 .first 或在收到首条角度时立刻在回调里执行 cancel(),直接遍历原集合会触发并发修改异常,快照遍历则能安全隔离。
非折叠设备的流终态
非折叠设备会由原生端返回流结束信号。状态机随后进入 Done 终态,关闭现有消费者;后来加入的订阅也直接结束,不会反复连接一条已终结的平台流。
3.5 四十九项库测试与路由测试怎样把关
工程保留原库 38 项单测,新增 11 项库测试(合计 49 项),外加独立 Example 的 2 项 Widget 路由测试。由于 DualScreenInfo 持有静态缓存流——旧入口一旦被读取就会在当前 isolate 留下持久订阅,平台流一旦触发 onDone 也无法重新打开——如果把所有用例塞进同一个测试文件,执行先后顺序就会互相污染。
我利用 flutter test 按测试文件隔离 Dart isolate 的机制拆分四组测试入口:
- test/dual_screen_lifecycle_test.dart(8 项常规生命周期用例):覆盖惰性启动、末位取消、同会话共享回放、跨会话清缓存、快速重订、等待期中途退出及错误多播。内部通过
Completer控制取消回包时机,验证平台通道严格按['listen', 'cancel', 'listen']顺序收发请求,且不把原生回包完成当作 Dart 取消 Future 的前置条件。 - test/dual_screen_completion_test.dart(1 项独立终态用例):单独在独立 isolate 中验证
endOfStream关闭当前消费者并拒绝后续重启,避免done = true终态污染其他生命周期用例。 - test/dual_screen_legacy_compatibility_test.dart 与 test/dual_screen_mixed_lifecycle_test.dart(各 1 项混用兼容用例):分别验证“先读取旧入口再使用按需入口”与“按需采样中途加入旧入口”时的共享保活与取消隔离行为。
- example/test/lifecycle_probe_test.dart(2 项页面路由用例):挂载
HingeLifecycleProbeApp,检查首页零订阅、进入角度页刷新读数、返回首页触发取消、重入显示等待态,以及能力查询抛错时不发起监听。
这些主机测试通过平台通道替身检查 Dart 状态机和路由交互。鸿蒙端的实际解绑时序,我另外用第 2 节的模拟器日志核对。
四、踩坑与边界条件
4.1 混用旧入口、暂停订阅与构建路径排查
接入后如果发现页面已经调用 cancel()、但日志行为与预期不一致,可以优先对照表 4 排查调用点与工程配置。
| 容易踩坑的现象 | 背后的真实原因 | 建议的处理方式 |
|---|---|---|
当前页面已改用 hingeAngleEventsOnDemand 且已退出,但 HiLog 里看不到 nativeUnregistered | 进程内其他组件(如常驻角标)曾读取过旧入口 hingeAngleEvents,其持久订阅保活了共享广播源 | 全局检索 hingeAngleEvents 调用点;若全应用只需按需观察,统一迁移至新入口 |
| 页面隐藏后只暂停订阅 | 暂停只暂缓 Dart 事件派发,底层仍监听 | 不再需要数据时取消,下次显示再订阅 |
在含中文目录名的路径下执行 flutter build hap 报 00306003 Invalid project path | 当前版本 DevEco / Hvigor 构建工具链对工程路径字符集有限制 | 构建前用 rsync 或克隆到纯英文 ASCII 路径再编译 |
| 原库模板用 API 26 SDK 编译安装时报设备类型或残留权限提示 | 原示例 module.json5 设备类型为 default 且带了未使用的 INTERNET 权限 | 按目标设备声明 phone/tablet,并删除未使用的网络权限 |
表4 接入排查
图 13 把前三种订阅操作放在一起比较。是否能释放原生监听,既取决于当前页面是否取消,也取决于共享源上还有没有其他订阅。

图13 取消边界
⚠️ 我的接入提醒:最隐蔽的情况是应用其他角落曾读取过一次旧入口。例如首页顶栏一直靠
hingeAngleEvents维护分屏角标,此时支架调节页退出就不应强行切断共享通道,否则顶栏也会失灵。正因如此,我没有设计forceStopAllHingeListeners()这类强制全局断流接口;业务应先查清哪些组件仍在消费角度,再决定统一迁移还是新旧并存。
4.2 重新进入角度页可能先看到等待占位符
图 8 展示的初始等待态是按需策略的直接表现:上一轮退出清空了 latest,再次注册 display.on('foldAngleChange') 后尚未收到新的角度变化事件,界面便显示 — 与“等待设备角度变化”(该监听接口本身不同步返回当前度数,物理真机注册瞬间是否会主动推送一次初值,我还没有在真机上验证)。围绕初始显示与能力查询,页面处理遵循三条原则:
- 不用假数字填充未知角度:示例没有把初始空值写成 0.0° 或 180.0°,避免把设备尚未上报的度数展示成真实读数。
- 不用折叠状态反推角度:
display.getFoldStatus()只返回展开、折叠或半折叠等离散状态,同一个半折叠状态可能对应 75° 或 115°,不能据此计算角度。 - 区分不支持与查询异常:鸿蒙端以
display.isFoldable()响应hasHingeAngleSensor,而原库 Dart 实现会把通道异常捕获并转换成false。调用方仅凭false无法区分“非折叠设备”和“通道查询失败”,因此示例文案写为“未能确认设备具备折叠角度能力”,不断言硬件缺失传感器。
五、结论与可直接复用的建议
5.1 适合哪些页面
这次扩展让短时查看角度的页面可以“用完即停”。在 Mate X7 模拟器的同条件采样中,无消费者空闲期的原生回调由 3 条降为 0 条;重新进入页面后,等待新的角度事件再更新读数。常驻组件仍可沿用旧入口的缓存与回放行为。
选择按需入口前,先确认页面能够接受短暂等待新读数。若界面需要保留上次结果,可以自行保存并标明“上次记录”;不要把历史值显示成刚从设备获取的角度。
5.2 添加依赖并订阅
核心改动已提交到社区仓库 PR #1。在合入发版前,应用可以直接依赖下面的公开固定提交,包名和导入路径仍是 dual_screen:
dependencies:
dual_screen:
git:
url: https://atomgit.com/abaoge/flutter-dualscreen.git
ref: 0ae1d75c79945e390f6dd9296770779745ac57d3
导入 package:dual_screen/dual_screen.dart 后,在页面初始化时调用下面的启动函数。示例沿用 State 的三个成员:_subscription 为可空 StreamSubscription,_angle 为可空 double,_status 初始为“正在查询设备能力”。订阅类型从 dart:async 导入,页面使用 Flutter Material 库。

图14 页面接入
下面保留启动与销毁两个完整方法,去掉了采样计数和访问编号;查询失败、流错误及取消异常仍各自处理。
Future<void> _start() async {
// 原库会把能力查询异常转换为 false,不能据此区分不支持和查询失败。
final supported = await DualScreenInfo.hasHingeAngleSensor;
if (!mounted) return;
if (!supported) {
setState(() => _status = '未能确认设备具备折叠角度能力');
return;
}
setState(() => _status = '等待设备角度变化');
_subscription = DualScreenInfo.hingeAngleEventsOnDemand.listen(
(angle) {
if (!mounted) return;
setState(() {
_angle = angle;
_status = '已收到设备角度';
});
},
onError: (Object error) {
if (mounted) setState(() => _status = '角度读取失败:$error');
},
onDone: () {
if (mounted) setState(() => _status = '设备角度数据流已结束');
},
);
}
void dispose() {
// dispose 无法等待 Future,但仍观察取消失败,避免吞掉清理异常。
_subscription?.cancel().then<void>(
(_) {},
onError: (Object error, StackTrace stack) {
debugPrint('取消角度订阅失败:$error\n$stack');
},
);
super.dispose();
}
能力查询返回时页面可能已经关闭,因此查询后先判断页面是否仍挂载。取消操作发生在同步销毁方法里,无法等待完成,但仍记录取消异常和堆栈。页面数据流结束时更新提示,避免界面一直显示“等待”。
这里的“离开”指路由出栈并销毁页面。如果页面只是被新路由盖住,或保留在标签页中,就要在可见性变化时主动取消,重新显示时再订阅;不能只依赖销毁回调。
5.3 接入时注意什么
- 检查是否混用了旧入口。 进程内只要还有旧入口的持久订阅,共享原生监听就会继续工作。不要为了关闭当前页面,强行断开其他组件的数据源。
- 区分暂停和取消。 暂停只影响 Dart 事件派发。页面不再需要角度时应取消,恢复使用时重新订阅。
- 按实际返回显示状态。 能力查询返回 false 可能是不支持,也可能是查询异常;新会话未收到角度时保持等待提示。多折轴设备、真机初始推送和后台挂起尚未验证。
完整 Example保留了页面与日志处理;构建、测试和对照采样放在仓库复现说明。业务只需添加依赖、使用新入口并管理自己的订阅,无需重新应用补丁。
小结
支架调节页需要的是打开时有读数、关闭后结束观察。把这项选择放进库的显式入口后,应用能按页面用途选择订阅方式,也能保留原有常驻组件的行为。
8



被折叠的 条评论
为什么被折叠?



