拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Flutter 鸿蒙适配:screen_protector 防截屏与截屏监听实战

1. 为什么要在鸿蒙上折腾 screen_protector做过金融类、企业办公类 App 的同行应该都有体会防截屏这件事在 Android 和 iOS 上早就有一套成熟方案了。Android 侧靠FLAG_SECURE一个窗口标志位就能搞定iOS 侧监听UIApplicationUserDidTakeScreenshotNotification再叠一层录屏检测基本够用。但到了 OpenHarmony 这边情况就完全不一样了——系统 API 换了、窗口管理机制换了、连截屏事件的回调方式都换了。screen_protector这个 Flutter 插件在 Android/iOS 上口碑不错它把防截屏、防录屏、截屏监听这几件事封装得比较干净。但官方版本并没有覆盖 OpenHarmony 平台所以当项目要往鸿蒙上迁移时这个插件就成了一个必须自己动手适配的硬骨头。我这次接到的需求很明确一个涉及敏感数据展示的 Flutter 应用要上鸿蒙页面里的关键信息不能被截屏、不能被录屏同时还要能感知到用户截屏行为并做出响应比如弹提示、打水印、上报日志。这篇文章就是把这套适配过程完整拆开讲。适合两类人看一是正在做 Flutter 鸿蒙化迁移、手里有一堆插件要适配的开发者二是想搞清楚 OpenHarmony 窗口安全机制到底怎么用的鸿蒙原生开发。我会从鸿蒙的窗口安全 API 讲起到 Flutter 插件通道怎么搭再到实际适配中踩到的坑尽量把每一步的为什么都说清楚。先说结论鸿蒙这边防截屏的核心是window.setWindowPrivacyMode()截屏监听则依赖window.on(screenshot)事件。这两个 API 是整件事的地基后面所有适配工作都围绕它们展开。但光知道 API 名字没用真正麻烦的是 Flutter 插件架构和鸿蒙 ArkTS 之间的桥接、生命周期对齐、以及不同鸿蒙版本上的行为差异。2. 鸿蒙窗口安全机制的地基setWindowPrivacyMode 与截屏事件2.1 setWindowPrivacyMode 到底做了什么在 Android 上FLAG_SECURE是一个加在 Window 上的标志位系统在合成屏幕画面时会把这个窗口的内容排除掉所以截屏、录屏、甚至最近任务列表里的缩略图都会变成空白或黑屏。鸿蒙的思路类似但实现层面走的是另一条路。鸿蒙的窗口管理模块提供了setWindowPrivacyMode(isPrivacyMode: boolean)这个接口调用后窗口进入隐私模式。进入隐私模式后系统在截屏和录屏时会自动屏蔽该窗口内容。注意这里的关键词是系统级屏蔽也就是说这不是应用自己画一层遮罩而是系统在合成阶段就把内容过滤掉了所以从截屏结果里拿不到任何画面信息。调用方式大致是这样import window from ohos.window; // 获取当前窗口 let windowClass: window.Window await window.getLastWindow(getContext(this)); // 开启隐私模式 await windowClass.setWindowPrivacyMode(true); // 关闭隐私模式 await windowClass.setWindowPrivacyMode(false);这里有个细节值得说setWindowPrivacyMode是异步的返回 Promise。如果你在页面aboutToAppear里调用但没 await可能会出现页面已经渲染出来了但隐私模式还没生效的短暂窗口期这个时间差虽然只有几十毫秒但在安全敏感场景下是不能接受的。我的做法是在页面显示前就 await 完成或者用一个 loading 态挡住。2.2 截屏事件的监听方式防截屏是一回事知道用户截了屏是另一回事。鸿蒙提供了窗口级别的截屏事件监听windowClass.on(screenshot, (data) { console.info(用户截屏了触发时间戳 data.timestamp); // 在这里做业务响应弹提示、上报、打水印等 });这个回调触发时机是系统完成截屏动作之后回调里能拿到截屏的时间戳信息。需要注意的是这个事件在隐私模式开启时是否还会触发不同鸿蒙版本行为不完全一致。我在 API 11 和 API 12 的设备上实测隐私模式下截屏事件依然会触发但截屏内容已经被屏蔽了。这个组合其实是最理想的用户截了屏你知道这件事但截到的图是空的。2.3 和 Android/iOS 机制的对照为了让大家理解得更透我把三个平台的机制做个对照平台防截屏机制截屏监听生效层级AndroidFLAG_SECURE 窗口标志无原生回调需 ContentObserver 监听媒体库窗口合成层iOS无直接防截屏靠录屏检测 遮罩UIApplicationUserDidTakeScreenshotNotification应用层OpenHarmonysetWindowPrivacyMode 隐私模式window.on(screenshot)窗口合成层从表里能看出来鸿蒙这套机制其实比 Android 和 iOS 都更完整——既有系统级屏蔽又有原生截屏回调。Android 的截屏监听要靠监听媒体库变化这种曲线救国的方式iOS 干脆没有防截屏只有检测。所以鸿蒙在安全能力上是有后发优势的问题只在于 Flutter 这层怎么把它接出来。3. Flutter 插件桥接MethodChannel 怎么把 ArkTS 能力暴露给 Dart3.1 插件目录结构怎么摆Flutter 插件的鸿蒙适配本质上是在插件的ohos目录下写一套 ArkTS 实现然后通过 MethodChannel 和 Dart 层通信。一个标准的带鸿蒙支持的 Flutter 插件目录大概长这样screen_protector/ ├── lib/ │ └── screen_protector.dart # Dart 接口层 ├── android/ # Android 原生实现 ├── ios/ # iOS 原生实现 ├── ohos/ # 鸿蒙原生实现 │ ├── src/main/ets/ │ │ ├── ScreenProtectorPlugin.ets # 插件入口实现 FlutterPlugin │ │ └── ScreenProtectorApi.ets # 具体能力实现 │ ├── src/main/module.json5 # 模块配置 │ └── oh-package.json5 # 依赖配置 └── pubspec.yaml关键点在于ohos目录下的module.json5要正确声明插件能力oh-package.json5要引入ohos/flutter_ohos这个 Flutter 鸿蒙适配的基础库。这个库是 Flutter 官方鸿蒙化适配的核心依赖MethodChannel、EventChannel、PluginRegistry 这些基础设施都在里面。3.2 MethodChannel 的注册与调用链路Dart 层调用ScreenProtector.enableSecure()这个调用要经过这样一条链路才能到达鸿蒙系统 APIDart 层通过MethodChannel(screen_protector)发起invokeMethod(enableSecure)Flutter 引擎把调用转发到鸿蒙侧的插件实现鸿蒙侧ScreenProtectorPlugin的onMethodCall收到方法名和参数调用ScreenProtectorApi.enableSecure()内部执行setWindowPrivacyMode(true)结果通过result.success()回传 Dart鸿蒙侧插件入口的实现大概是这样import { FlutterPlugin, FlutterPluginBinding, MethodCall, MethodCallHandler, MethodChannel, Result } from ohos/flutter_ohos; export class ScreenProtectorPlugin implements FlutterPlugin, MethodCallHandler { private channel: MethodChannel | null null; private api: ScreenProtectorApi new ScreenProtectorApi(); getUniqueClassName(): string { return ScreenProtectorPlugin; } onAttachedToEngine(binding: FlutterPluginBinding): void { this.channel new MethodChannel(binding.getBinaryMessenger(), screen_protector); this.channel.setMethodCallHandler(this); } onDetachedFromEngine(binding: FlutterPluginBinding): void { if (this.channel ! null) { this.channel.setMethodCallHandler(null); this.channel null; } } onMethodCall(call: MethodCall, result: Result): void { switch (call.method) { case enableSecure: this.api.enableSecure().then(() result.success(true)) .catch((err: Error) result.error(ENABLE_FAILED, err.message, null)); break; case disableSecure: this.api.disableSecure().then(() result.success(true)) .catch((err: Error) result.error(DISABLE_FAILED, err.message, null)); break; case startScreenshotListening: this.api.startListening(result); break; default: result.notImplemented(); } } }这里有个容易忽略的点getUniqueClassName()必须返回一个全局唯一的类名Flutter 鸿蒙引擎靠这个来管理插件实例。如果你复制粘贴别的插件代码忘了改会出现插件注册冲突表现是某个插件的方法调用莫名其妙失败。3.3 截屏监听为什么用 EventChannel 更合适防截屏是设置一次的操作用 MethodChannel 没问题。但截屏监听是持续性的——用户可能截很多次屏每次都要通知 Dart 层。这种原生主动推事件给 Dart的场景用 EventChannel 比 MethodChannel 更自然。EventChannel 的鸿蒙侧实现思路是插件里持有一个EventSink在onListen时注册鸿蒙的window.on(screenshot)监听每次截屏事件触发就通过eventSink.success()把数据推给 Dart。onCancel时反注册监听避免内存泄漏。export class ScreenshotStreamHandler implements StreamHandler { private windowClass: window.Window | null null; private callback: ((data: window.ScreenshotEvent) void) | null null; async onListen(args: Object, eventSink: EventSink): Promisevoid { this.windowClass await window.getLastWindow(getContext()); this.callback (data: window.ScreenshotEvent) { eventSink.success({ timestamp: data.timestamp }); }; this.windowClass.on(screenshot, this.callback); } async onCancel(args: Object): Promisevoid { if (this.windowClass this.callback) { this.windowClass.off(screenshot, this.callback); } this.windowClass null; this.callback null; } }注意window.on(screenshot)注册的回调函数必须是同一个引用才能被off正确反注册。如果你用匿名函数注册off的时候传一个新的匿名函数是解绑不掉的这个坑我在调试时踩过一次表现是页面退出后截屏还会触发回调日志里能看到已经销毁页面的方法被调用。4. 适配过程中真正卡住我的几个问题4.1 窗口对象获取时机不对导致隐私模式失效最开始我的实现是在插件的onAttachedToEngine里就去getLastWindow拿窗口对象然后缓存起来。结果发现有时候隐私模式设置不生效日志里也没报错。排查了半天才想明白onAttachedToEngine触发时Flutter 的鸿蒙 Ability 可能还没完成窗口创建这时候getLastWindow拿到的窗口对象是不完整的或者拿到的是旧的窗口引用。正确的做法是每次调用enableSecure时实时获取窗口对象而不是缓存。虽然多了一次异步调用但可靠性高得多。如果你确实想缓存至少要在onWindowStageCreate之后再获取并且监听窗口变化事件及时更新引用。private async getWindow(): Promisewindow.Window { // 每次都实时获取避免拿到失效的窗口引用 return await window.getLastWindow(getContext(this)); }4.2 多页面场景下隐私模式的开关边界一个真实的 App 不会只有一个页面。假设首页不需要防截屏但详情页需要那隐私模式的开关时机就很关键。我一开始的做法是在详情页initState里开启、dispose里关闭。但 Flutter 的页面切换有动画过渡期如果用户在过渡动画期间截屏可能出现详情页内容已经渲染但隐私模式还没开或者已经离开详情页但隐私模式还开着导致首页也黑屏的情况。后来我改成了在路由层面统一管理进入需要保护的页面时开启离开时关闭并且把开关操作放在页面动画开始之前。具体来说可以用 Flutter 的RouteObserver监听路由变化在didPush时开启、didPop时关闭。这样能保证隐私模式和页面生命周期严格对齐。还有一个更细的边界如果两个需要保护的页面连续跳转A 到 BA 的didPop和 B 的didPush触发顺序可能导致隐私模式被短暂关闭。解决办法是维护一个引用计数只有计数归零时才真正关闭隐私模式。4.3 截屏事件在后台时是否触发这个行为我实测下来是这样的应用切到后台后window.on(screenshot)的回调不会触发因为窗口已经不可见了。但用户完全可以在后台状态下通过系统截屏快捷键截屏这时候截到的内容取决于系统行为——如果隐私模式开着截到的是空白如果没开可能截到应用切后台前的最后一帧。所以如果你的业务逻辑依赖用户截屏就上报要注意后台截屏是监听不到的。对于安全要求极高的场景不能只依赖截屏事件还要结合隐私模式本身来兜底。隐私模式是防截屏事件是知两者要配合使用不能只靠其中一个。4.4 不同鸿蒙版本上的 API 差异鸿蒙的 API 版本迭代比较快setWindowPrivacyMode和screenshot事件在不同 API 版本上的行为有细微差别。我整理了一个实测对照API 版本setWindowPrivacyModescreenshot 事件备注API 10支持不支持只能防不能监听API 11支持支持隐私模式下事件仍触发API 12支持支持行为与 11 一致性能更好如果你的应用要兼容 API 10那截屏监听功能就得做降级处理——检测到系统版本低于 11 时startScreenshotListening直接返回不支持Dart 层做好兜底逻辑。这个版本判断可以在鸿蒙侧用deviceInfo.sdkApiVersion来做。5. Dart 层的接口设计与调用姿势5.1 对外暴露的 API 应该长什么样插件适配完了Dart 层的接口设计直接影响使用体验。我参考原版 screen_protector 的接口风格结合鸿蒙的能力特点设计了这样一组 APIclass ScreenProtector { static const MethodChannel _methodChannel MethodChannel(screen_protector); static const EventChannel _eventChannel EventChannel(screen_protector/screenshot); /// 开启防截屏隐私模式 static Futurebool enableSecure() async { final result await _methodChannel.invokeMethodbool(enableSecure); return result ?? false; } /// 关闭防截屏 static Futurebool disableSecure() async { final result await _methodChannel.invokeMethodbool(disableSecure); return result ?? false; } /// 截屏事件流 static StreamScreenshotEvent get onScreenshot { return _eventChannel.receiveBroadcastStream().map((event) { return ScreenshotEvent.fromMap(MapString, dynamic.from(event)); }); } }这里onScreenshot返回的是一个 Stream业务层可以这样用StreamSubscription? _subscription; override void initState() { super.initState(); ScreenProtector.enableSecure(); _subscription ScreenProtector.onScreenshot.listen((event) { // 弹提示、上报日志、打水印 _showScreenshotWarning(); }); } override void dispose() { _subscription?.cancel(); ScreenProtector.disableSecure(); super.dispose(); }5.2 为什么用 Stream 而不是回调有人可能会问为什么截屏监听要用 Stream 而不是传一个回调函数进去。原因有几个一是 Stream 天然支持多个监听者如果多个组件都需要响应截屏事件Stream 可以广播二是 Stream 的取消机制更清晰cancel()之后资源释放路径明确三是和 Flutter 生态的其他事件流比如传感器、位置保持一致降低使用者的心智负担。不过要注意receiveBroadcastStream创建的是广播流多个监听者会共享同一个原生监听。如果第一个监听者取消后第二个还在原生侧的onCancel不会被触发这是符合预期的。但如果你希望每个监听者独立控制原生监听就需要用普通 Stream 而不是广播流这个取舍看具体业务需求。5.3 错误处理与降级策略鸿蒙适配最怕的就是在不支持的设备上直接崩。所以 Dart 层调用每个方法都要做好异常捕获和降级static Futurebool enableSecure() async { try { final result await _methodChannel.invokeMethodbool(enableSecure); return result ?? false; } on PlatformException catch (e) { // 鸿蒙低版本可能不支持降级为不防截屏 debugPrint(enableSecure failed: ${e.code} - ${e.message}); return false; } on MissingPluginException { // 插件未注册比如跑在非鸿蒙平台 return false; } }MissingPluginException这个异常特别要处理因为你的代码可能同时跑在 Android、iOS 和鸿蒙上如果某个平台没有实现对应方法不捕获的话会直接抛到业务层。返回 false 让业务层自己决定怎么处理比直接崩溃要好得多。6. 实测验证怎么确认防截屏真的生效了6.1 验证方法要覆盖三个维度写完代码不等于功能可用防截屏这种安全功能必须实测验证。我一般从三个维度验证第一系统截屏快捷键。鸿蒙设备通常用电源键音量下键截屏在开启隐私模式的页面上截屏去相册看结果应该是空白或黑屏。如果能看到内容说明隐私模式没生效。第二系统录屏。鸿蒙自带录屏功能开启录屏后操作应用录完看视频里敏感页面是否被屏蔽。这里要注意有些设备的录屏在隐私模式下会显示为纯黑有些会显示为模糊都属于正常屏蔽行为。第三最近任务列表。从底部上滑进入多任务界面看应用卡片的缩略图是否被屏蔽。这个容易被忽略但其实是信息泄露的一个常见途径。6.2 截屏事件回调的验证截屏事件验证相对简单在监听开启的状态下截屏看日志里有没有打印回调信息。但有几个点要专门测连续快速截屏多次看是否每次都触发回调有没有丢事件页面切换过程中截屏看回调是否还能正确路由到当前页面应用切后台再切回来监听是否还正常我实测下来连续截屏的事件触发是可靠的没有出现丢事件的情况。但页面切换过程中的事件路由需要业务层自己处理——原生回调不知道当前是哪个 Flutter 页面需要 Dart 层根据当前路由判断要不要响应。6.3 性能影响的观察隐私模式对性能有没有影响我用 DevTools 的 Performance 面板对比了开启和关闭隐私模式时的帧率在正常滚动列表的场景下没有观察到明显差异都在 60fps 左右。但有一点要注意隐私模式下系统合成阶段多了一层过滤理论上会增加一点点 GPU 开销不过在常规设备上感知不到。真正需要关注的是截屏监听的性能。window.on(screenshot)是系统级事件注册本身开销很小但如果你的回调里做了重操作比如同步写文件、大量计算会阻塞主线程。建议回调里只做轻量级操作重活丢到 isolate 或者异步队列里。7. 几个能直接抄的实战配置7.1 module.json5 的关键配置鸿蒙插件的module.json5里有几个字段配错了会导致插件加载失败{ module: { name: screen_protector, type: har, deviceTypes: [default, tablet], requestPermissions: [ { name: ohos.permission.PRIVACY_WINDOW } ] } }ohos.permission.PRIVACY_WINDOW这个权限是设置隐私模式需要的虽然它是 system_grant 级别安装即授予但不在配置里声明的话setWindowPrivacyMode调用会失败。这个坑很隐蔽因为报错信息不会直接告诉你缺权限。7.2 pubspec.yaml 的鸿蒙平台声明Flutter 插件的pubspec.yaml里要声明支持鸿蒙平台否则 Flutter 构建时不会把ohos目录纳入编译flutter: plugin: platforms: android: package: com.example.screen_protector pluginClass: ScreenProtectorPlugin ios: pluginClass: ScreenProtectorPlugin ohos: pluginClass: ScreenProtectorPlugin注意ohos这个 key 的拼写以及pluginClass要和 ArkTS 里实现的类名完全一致。大小写错了会导致插件注册不上表现是 Dart 调用时抛MissingPluginException。7.3 页面级防截屏的封装建议如果你的应用有多个页面需要防截屏建议封装一个 Mixin 或者 Widget 来统一管理避免每个页面都写一遍开启关闭逻辑mixin SecureScreenMixinT extends StatefulWidget on StateT { override void initState() { super.initState(); ScreenProtector.enableSecure(); } override void dispose() { ScreenProtector.disableSecure(); super.dispose(); } }用的时候直接class _DetailPageState extends StateDetailPage with SecureScreenMixin干净利落。但前面提到的引用计数问题这个简单 Mixin 没有处理如果页面嵌套跳转需要自己加一层计数逻辑。8. 适配完之后的一些个人体会这套适配做下来最大的感受是鸿蒙在窗口安全这块的 API 设计其实比 Android 和 iOS 都要清晰。setWindowPrivacyMode一个方法解决防截屏window.on(screenshot)一个事件解决监听没有 Android 那种要监听媒体库的弯弯绕绕。真正的工作量不在鸿蒙原生侧而在 Flutter 插件桥接这层——MethodChannel 的注册、EventChannel 的事件流、生命周期对齐这些才是耗时的地方。另外一个体会是版本兼容不能偷懒。我一开始只在一台 API 12 的设备上测觉得没问题就交付了结果在 API 10 的设备上截屏监听直接不工作。后来补了版本判断和降级逻辑才通过测试。鸿蒙生态现在设备版本跨度比较大做插件适配一定要覆盖多个 API 版本。最后分享一个小技巧调试截屏事件的时候可以在回调里把时间戳和当前页面路由一起打日志这样能快速定位事件路由问题。我是在日志里加了DateTime.now().toIso8601String()和当前路由名排查页面切换时的事件归属特别有用。这个日志在正式发版前记得关掉或者降级为 debug 级别避免影响性能。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门