本文用 flutter_screenshot_detect 0.1.7 完成一个可运行的 Flutter 鸿蒙
Demo:页面启动后监听当前应用窗口的截图事件,展示事件次数、检测方式和时间,同时可以暂停与恢复监听。代码、HAP 和截图均在
HarmonyOS 真机上实测,下文会把依赖锁定、API 用法、异常处理和资源释放一次讲清。

三方库仓库: https://atomgit.com/oh-flutter/flutter_screenshot_detect

OHOS 适配分支: feat/ohos_flutter_screenshot_detect_0.1.7

OHOS 适配 TAG: 尚未发布,当前请锁定受测提交

本文受测版本: 0.1.7 / 1da71f4294faa3fc2584c720e3d75df770893236

当前远程 HEAD: 609b0793a6f0a6917cc4a4b6f7ec717fbd141bea,后续两次提交只补充验证文档

完整 Demo: flutter_screenshot_detect/example(受测提交)

一、最终真机效果

先看结果。我在真机上启动 Demo,订阅截图流后连续触发两次系统截图。页面计数变为 2,两条记录都显示 ohos_window_screenshot,且有各自的实际时间戳。

在这里插入图片描述

图 1:CHZ-AL00 / HarmonyOS 7.0.0.105 上的实际 Flutter 应用,恢复监听后成功收到第二次截图事件。

验证点实测结果证据
AtomGit 依赖解析锁定 1da71f4294faa3fc2584c720e3d75df770893236图 3
静态分析和自动化无静态问题,19 项 Dart/Widget/ArkTS 用例通过图 9
HAP 构建、签名、安装与启动通过图 1、图 3
截图事件收到 method 和时间戳,计数按次增加图 1、图 6
暂停监听暂停期间系统截图不增加计数图 7、图 8
截图文件路径OHOS 返回 null图 10

二、为什么在项目中用它

我要处理的是“截图发生后做一件事”,例如在票据页提醒用户注意隐私,在内容页记录一次本地行为,或在阅后即焚页面立即停止展示。如果自己分别写 Android、iOS 和 OHOS 通道,除了系统 API,还要处理事件数据格式、页面销毁、重复订阅和取消时的旧回调。

flutter_screenshot_detect 已经把这些差异收到同一个 Dart 事件流中。我的页面只需订阅 onScreenshot,收到 FlutterScreenshotEvent 后更新状态,离开页面时取消订阅。

这里有一个必须先说明的边界:它是“截图通知”库,不是“防截图”库。它不阻止系统截图,不读取相册,不获取截图内容,也不监听录屏。如果业务要屏蔽敏感页面的截图和录屏,应该使用窗口隐私模式类能力,不能把两类插件混为一谈。

2.1 选库依据

对比项核对结果
库名称与版本flutter_screenshot_detect 0.1.7
AtomGit 仓库oh-flutter/flutter_screenshot_detect
OHOS 支持feat/ohos_flutter_screenshot_detect_0.1.7 分支
本文受测代码1da71f4294faa3fc2584c720e3d75df770893236
所需 APIonScreenshotstartListening()dispose()
许可证MIT
结论满足前台窗口截图通知需求,但尚无稳定 OHOS TAG,所以用 commit 锁定

截至 2026 年 9 月 12 日,远程默认 HEAD 为 609b0793a6f0a6917cc4a4b6f7ec717fbd141bea,相比真机受测代码只多了两次验证文档提交。为了让本文结果可复现,依赖和 Demo 链接都锁定实际安装到真机的代码 commit。

在这里插入图片描述

图 2:AtomGit origin、OHOS 适配分支、当前 HEAD 和干净工作区的核对结果。

三、实测环境与能力范围

组件实测版本
Flutter OH3.41.10-ohos-1.0.1
Dart3.11.5
DevEco Studio26.0.0 Release
HarmonyOS SDKAPI 26,Demo 兼容 API 18
真机CHZ-AL00
系统HarmonyOS 7.0.0.105
三方库flutter_screenshot_detect 0.1.7

Flutter 鸿蒙环境搭建不在本文重复,可参考 Flutter OH 环境搭建指南

截至 2026 年 9 月 12 日,版本号最大的 Flutter OH 标签是预览版 3.44.9+ohos-0.0.1-canary1,最新正式稳定标签仍是 3.41.10-ohos-1.0.1。本文选用已完成插件构建、签名和真机回归的稳定版,不把其他 Demo 在预览 SDK 上的结果当成本库实测结论。

API 或能力用途本文是否演示真机结论
onScreenshot监听截图事件通过
StreamSubscription.cancel()暂停页面订阅通过
重新订阅恢复事件投递通过
startListening() / dispose()便捷回调接口代码与自动化未单独做真机页面
多实例共享事件流避免实例互相中断自动化未真机验收
后台、Ability 重建、控制中心截图更广生命周期与入口未验证

调用链可以简化为:

Flutter 页面 -> onScreenshot 广播流 -> EventChannel -> OHOS 当前窗口 screenshot 事件

四、从 AtomGit 引入依赖

仓库内的 example 用 path 依赖指向上一级插件,这是 Flutter 插件仓库的常见写法。普通业务工程不在同一仓库内,应改为 AtomGit Git 依赖。由于当前没有 OHOS 稳定 TAG,本文直接锁定已验证 commit:

dependencies:
  flutter:
    sdk: flutter
  flutter_screenshot_detect:
    git:
      url: https://atomgit.com/oh-flutter/flutter_screenshot_detect.git
      ref: 1da71f4294faa3fc2584c720e3d75df770893236

执行:

flutter pub get
flutter pub deps

pubspec.lock 中应该看到类似下面的结果:

flutter_screenshot_detect:
  dependency: "direct main"
  description:
    path: "."
    ref: "1da71f4294faa3fc2584c720e3d75df770893236"
    resolved-ref: "1da71f4294faa3fc2584c720e3d75df770893236"
    url: "https://atomgit.com/oh-flutter/flutter_screenshot_detect.git"
  source: git
  version: "0.1.7"

如果声明中暂时使用分支名,那么 ref 会显示分支,resolved-ref 仍应是具体 commit。发布项目时不要只看 pub get 成功,还要核对 resolved-ref,否则分支后续变化可能让构建结果漂移。

在这里插入图片描述

图 3:已构建的无签名 HAP 摘要和隔离验证宿主的 AtomGit resolved-ref

五、鸿蒙宿主配置

这个库不需要截图权限、相册权限或存储权限。它监听的是当前 Ability 窗口的公共 screenshot 事件,不读文件。插件自身的 ohos/src/main/module.json5 也没有声明任何权限。

接入时只需要确认两点:

  1. 使用支持 OHOS 的 Flutter SDK,不要误用标准 Flutter SDK 构建鸿蒙宿主。
  2. 保留 pubspec.yaml 中插件的 ohos.pluginClass,让 Flutter 生成注册代码,不要手工修改 GeneratedPluginRegistrant.ets

Demo 宿主的 ohos.permission.INTERNET 来自 Flutter 示例工程,不是截图监听所必需的权限。业务项目应按自己的网络功能决定是否保留,不要为了这个插件增加无关权限。

在这里插入图片描述

图 4:源码核对显示 OHOS 使用当前窗口的 screenshot 事件,并回传 method、timestamp 和可空 path。使用方不需要复制这段原生代码。

六、核心 API 用法

6.1 直接订阅 onScreenshot

onScreenshotStream<FlutterScreenshotEvent>,适合页面需要保存 StreamSubscription 并主动暂停、恢复或销毁的场景。

final FlutterScreenshotDetect _detector = FlutterScreenshotDetect();
StreamSubscription<FlutterScreenshotEvent>? _subscription;

void startScreenshotListening() {
  _subscription ??= _detector.onScreenshot.listen(
    (event) {
      debugPrint('method=${event.method}');
      debugPrint('time=${event.timestamp}');
      debugPrint('path=${event.path}');
    },
    onError: (Object error) {
      debugPrint('screenshot listener failed: $error');
    },
  );
}

Future<void> stopScreenshotListening() async {
  final subscription = _subscription;
  _subscription = null;
  await subscription?.cancel();
}

_subscription ??= 防止同一页面因重复点击又新建一条订阅。停止时先把字段置空,页面就可以立即进入“已停止”状态,再等待底层取消完成。

6.2 startListening 便捷接口

只需一个回调、不需要中途切换时,也可以使用:

final detector = FlutterScreenshotDetect();

detector.startListening((event) {
  debugPrint(event.toString());
});


void dispose() {
  detector.dispose();
  super.dispose();
}

重复调用 startListening() 会用新回调替换该实例的旧回调,detector.dispose() 只会取消由 startListening() 创建的内部订阅。如果代码是直接通过 onScreenshot.listen() 创建订阅,这条订阅属于调用方,必须自己调用 subscription.cancel()。这是最容易遗漏的资源释放点。

6.3 理解事件字段

字段OHOS 实际值使用建议
methodohos_window_screenshot可用于日志和跨平台统计,不要据此推断截图文件位置
timestampDart DateTime用于页面展示或行为时序,OHOS 源数据单位转为微秒,实际精度仍是毫秒
pathnull必须按可空值处理,不要拼凑文件路径

在这里插入图片描述

图 5:另一次真机运行样本,页面显示 ohos_window_screenshot 和真实事件时间。OHOS 页面没有显示 path,因为它为 null

七、完整可运行 Demo

下面是本次受测 example/lib/main.dart 的完整主流程。它保留最近 100 条事件,可以暂停和恢复,同时会把原生注册失败显示到页面。

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_screenshot_detect/flutter_screenshot_detect.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
      home: const ScreenshotDetectDemo(),
    );
  }
}

class ScreenshotDetectDemo extends StatefulWidget {
  const ScreenshotDetectDemo({super.key});

  
  State<ScreenshotDetectDemo> createState() => _ScreenshotDetectDemoState();
}

class _ScreenshotDetectDemoState extends State<ScreenshotDetectDemo> {
  final FlutterScreenshotDetect _detector = FlutterScreenshotDetect();
  final List<FlutterScreenshotEvent> _events = [];
  StreamSubscription<FlutterScreenshotEvent>? _subscription;
  String? _error;
  bool _changing = false;

  
  void initState() {
    super.initState();
    _start();
  }

  void _start() {
    _subscription = _detector.onScreenshot.listen((event) {
      if (!mounted) return;
      setState(() {
        _events.insert(0, event);
        if (_events.length > 100) _events.removeLast();
      });
    }, onError: (Object error) {
      if (!mounted) return;
      setState(() => _error = error.toString());
    });
  }

  Future<void> _toggle() async {
    setState(() {
      _changing = true;
      _error = null;
    });
    try {
      final subscription = _subscription;
      if (subscription == null) {
        _start();
      } else {
        _subscription = null;
        await subscription.cancel();
      }
    } catch (error) {
      if (mounted) setState(() => _error = error.toString());
    } finally {
      if (mounted) setState(() => _changing = false);
    }
  }

  
  void dispose() {
    final subscription = _subscription;
    if (subscription != null) unawaited(subscription.cancel());
    _detector.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    final listening = _subscription != null;
    return Scaffold(
      appBar: AppBar(
        title: const Text('Screenshot Detector Example'),
      ),
      body: SafeArea(
        child: Column(
          children: [
            ListTile(
              title: Text('Screenshots detected: ${_events.length}'),
              subtitle: Text(listening ? 'Listening' : 'Stopped'),
              trailing: IconButton(
                tooltip: listening ? 'Stop listening' : 'Start listening',
                onPressed: _changing ? null : _toggle,
                icon: Icon(listening ? Icons.pause : Icons.play_arrow),
              ),
            ),
            if (_error != null)
              Padding(
                padding: const EdgeInsets.all(16),
                child: SelectableText(_error!),
              ),
            Expanded(
              child: ListView.builder(
                itemCount: _events.length,
                itemBuilder: (context, index) {
                  final event = _events[index];
                  return ListTile(
                    leading: const Icon(Icons.screenshot),
                    title: Text('Method: ${event.method}'),
                    subtitle: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text('Time: ${event.timestamp}'),
                        if (event.path != null) Text('Path: ${event.path}'),
                      ],
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

页面的调用顺序很直接:initState() 创建订阅,原生事件到达后插入列表头部,图标按钮调用 _toggle() 取消或重建订阅,页面销毁时再次做幂等取消。mounted 判断用来防止页面已经退出后仍调用 setState()_changing 则避免用户连续点击导致取消与恢复交叉。

八、真机操作过程

这轮截图不只保留了最终页,还保留了初始、暂停和恢复三个中间状态。

在这里插入图片描述

图 6:启动后计数为 0,页面显示 Listening。捕获这张系统截图后,插件随即收到第一个事件。

在这里插入图片描述

图 7:第一次事件到达后点击暂停,状态变为 Stopped,计数为 1。

我在 Stopped 状态下再次让系统截图,然后点击播放图标恢复订阅。如果取消没有生效,这时计数应该已经变成 2;实际页面仍然是 1。

在这里插入图片描述

图 8:恢复监听时计数保持 1,证明暂停期间的截图没有被投递给 Flutter。

最后在 Listening 状态再做一次系统截图,页面增加到 2,结果就是开头的图 1。这组过程同时验证了正常投递、取消无投递和重订阅后恢复投递,不是只看到 App 能打开就结束。

九、检查、构建与设备验证

2026 年 9 月 11 日在仓库根目录和 example 中复跑:

flutter analyze
flutter test
node --test test/ohos_lifecycle_test.cjs
cd example
flutter analyze
flutter test
flutter build hap --debug --no-codesign
检查项实测结果
插件静态分析No issues found
插件 Dart 测试10 项通过
ArkTS 生命周期测试8 项通过,Node 有实验性 API 警告
Example 静态分析No issues found
Example Widget 测试1 项通过
无签名 HAP构建成功
签名、覆盖安装和启动真机通过

在这里插入图片描述

图 9:静态分析无问题,10 项 Dart 和 9 项 ArkTS/Widget 用例共 19 项通过。

最早一轮真机验收还用 HDC UITest 注入音量减和电源组合键,订阅时计数从 0 变为 1,取消后保持 1,重新订阅后再变为 2。补图轮使用系统 snapshot_display 复现了同样的页面状态。两类触发都是系统真实截图,不是向 Flutter 伪造一个事件 Map。

在这里插入图片描述

图 10:另一轮真机记录汇总,保留了 count、method、path 以及取消与重订阅结果。

本次只验证了一台 API 26 真机、当前前台 Ability 窗口和两类系统截图触发方式。控制中心入口、后台投递、多实例真机行为、Ability 重建和其他系统版本没有全部覆盖,所以结论是“本次环境通过”,不是“所有鸿蒙设备全面兼容”。

十、实际接入容易踩的坑

Q1:收到事件却没有 path

  • 现象: methodtimestamp 正常,pathnull
  • 原因: OHOS 公共窗口截图回调不提供文件保存路径。
  • 解决方法: 把事件当作通知,业务对 path 做可空处理,不自行拼凑路径。
  • 验证结果: 真机两次事件均正常投递,path 保持 null

Q2:调用 detector.dispose() 后直接订阅仍在

  • 现象: 页面通过 onScreenshot.listen() 创建订阅,只调用 detector.dispose() 后以为已经取消。
  • 原因: dispose() 只管理 startListening() 在 detector 内部持有的订阅,直接订阅属于调用方。
  • 解决方法: 保存 StreamSubscription,在页面 dispose() 中调用 cancel()
  • 验证结果: Dart 单测覆盖了两种订阅的所有权边界。

Q3:暂停按钮快速连点后状态乱了

  • 现象: 取消订阅尚未完成时又执行恢复,界面状态和底层订阅可能不一致。
  • 原因: StreamSubscription.cancel() 是异步操作,连续切换会让两次状态变更交叉。
  • 解决方法:_changing 暂时禁用按钮,等本次切换完成后再允许点击。
  • 验证结果: Widget 测试覆盖启动、暂停、恢复和页面销毁。

Q4:把监听误当成防截图

  • 现象: 接入后用户仍能保存截图。
  • 原因: 插件只在系统截图后投递事件,不修改窗口安全属性。
  • 解决方法: 需要隐私防护时另行接入窗口防截图能力;需要事后提示或记录时才使用本库。
  • 验证结果: 真机截图实际生成,同时 App 收到事件,与库的设计边界一致。

十一、什么时候适合使用

适合

  • 票据、证件、聊天或内容页在截图后显示隐私提示。
  • 在前台页面中统计截图行为,且可以接受 OHOS 不返回文件路径。
  • 项目愿意在正式 OHOS TAG 发布前锁定已验证 commit。

暂不适合

  • 需要阻止截图或录屏的强安全页面。
  • 必须获取截图文件、图片内容或精确保存路径的功能。
  • 需要保证所有截图入口、所有系统版本和后台状态都投递事件的场景。

十二、总结

flutter_screenshot_detect 在这个 Flutter 鸿蒙 Demo 中完成了一件很具体的事:当前应用窗口发生系统截图时,将事件方式和时间送到 Dart 页面。使用时最重要的是锁定真正受测的 AtomGit commit、为直接流订阅成对调用 cancel(),并把 OHOS 的 path=null 当作正常平台行为。

本次静态分析、19 项自动化、无签名 HAP 构建和 API 26 真机的订阅、取消、恢复都已通过。它不阻止截图、不读取文件、不监听录屏,这些限制需要在产品需求确认时先说清。

十三、参考链接

欢迎加入CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐