Flutter 截屏工具实现方式详解
本文详细介绍Flutter框架实现截屏功能的多种方式。核心原理是利用RenderRepaintBoundary组件捕获Widget渲染内容并转换为图像。提供三种实现方案:基础截屏支持普通和滚动截屏;带保存路径选择的版本允许用户自定义存储位置;综合截屏工具还支持全屏/区域截屏和编辑功能。文章包含完整代码实现,涵盖权限配置、性能优化等注意事项,并比较了不同方案的适用场景,为Flutter开发者提供了全
·
在移动应用开发中,截屏功能是一个常见的需求。本文将详细介绍基于 Flutter 框架实现的多种截屏方式,包括普通截屏、滚动截屏、全屏截屏、区域截屏以及截图编辑功能,并提供完整的可运行代码实现。
一、核心技术原理
Flutter 截屏的核心原理是利用RenderRepaintBoundary组件捕获 Widget 的渲染内容并转换为图像,核心步骤如下:
- 使用
GlobalKey标识需要截屏的RenderRepaintBoundary组件 - 通过
findRenderObject()获取RenderRepaintBoundary实例 - 调用
toImage()方法将渲染内容转换为ui.Image对象 - 使用
toByteData()方法将图像转换为字节数据 - 将字节数据保存为图片文件
二、实现方式一:基础截屏(ScreenshotTabPage.dart)
功能概述
最基础的截屏实现,支持普通截屏和滚动截屏,默认将截图保存到应用文档目录,无自定义保存路径和编辑功能。
核心代码
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'dart:ui' as ui;
import 'package:path_provider/path_provider.dart';
class ScreenshotTabPage extends StatefulWidget {
@override
_ScreenshotTabPageState createState() => _ScreenshotTabPageState();
}
class _ScreenshotTabPageState extends State<ScreenshotTabPage> with TickerProviderStateMixin {
String path = '';
GlobalKey normalKey = GlobalKey();
GlobalKey scrollKey = GlobalKey();
final ScrollController _scrollController = ScrollController();
List<String> buttons = ['普通截屏', '滚动截屏'];
int pageIndex = 0;
// 普通截屏
Future<void> normalScreenshot() async {
try {
if (normalKey.currentContext != null) {
final RenderObject? boundary = normalKey.currentContext!.findRenderObject();
if (boundary is RenderRepaintBoundary) {
// 捕获Widget为Image对象
ui.Image image = await boundary.toImage();
// 转换为PNG字节数据
ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData != null) {
Uint8List pngBytes = byteData.buffer.asUint8List();
// 保存到应用文档目录
final directory = await getApplicationDocumentsDirectory();
final imagePath = '${directory.path}/screenshot.png';
File imageFile = File(imagePath);
await imageFile.writeAsBytes(pngBytes);
mySetState(() => path = imageFile.path);
}
}
}
} catch (e) {
print('普通截屏失败: $e');
}
}
// 滚动截屏(分段截图+合并)
Future<void> scrollScreenshot() async {
try {
final scrollContext = scrollKey.currentContext;
if (scrollContext == null) return;
final scrollPosition = _scrollController.position;
final renderObject = scrollContext.findRenderObject();
if (renderObject == null || renderObject is! RenderRepaintBoundary) return;
// 记录初始滚动位置、视口高度、内容总高度
final initialOffset = scrollPosition.pixels;
final viewportHeight = scrollPosition.viewportDimension;
final contentHeight = scrollPosition.maxScrollExtent + viewportHeight;
List<ui.Image> imagePieces = [];
double currentOffset = 0.0;
// 分段截取滚动内容
while (currentOffset < contentHeight) {
// 滚动到指定位置
await scrollPosition.animateTo(
currentOffset,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
await Future.delayed(const Duration(milliseconds: 200)); // 等待渲染完成
// 截取当前视口内容
ui.Image image = await renderObject.toImage();
imagePieces.add(image);
// 更新滚动偏移
currentOffset += viewportHeight;
if (currentOffset > contentHeight) {
currentOffset = contentHeight - viewportHeight;
if (currentOffset < 0) currentOffset = 0;
}
}
// 恢复初始滚动位置
await scrollPosition.animateTo(
initialOffset,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
// 合并截图并保存
if (imagePieces.isNotEmpty) {
final mergedImage = await _mergeImages(imagePieces, viewportHeight, contentHeight);
ByteData? byteData = await mergedImage.toByteData(format: ui.ImageByteFormat.png);
if (byteData != null) {
Uint8List pngBytes = byteData.buffer.asUint8List();
final directory = await getApplicationDocumentsDirectory();
final imagePath = '${directory.path}/scroll_screenshot.png';
File imageFile = File(imagePath);
await imageFile.writeAsBytes(pngBytes);
mySetState(() => path = imageFile.path);
}
}
} catch (e) {
print('滚动截屏失败: $e');
}
}
// 合并多个截图片段为完整长图
Future<ui.Image> _mergeImages(List<ui.Image> images, double viewportHeight, double contentHeight) async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
double offsetY = 0.0;
for (int i = 0; i < images.length; i++) {
final image = images[i];
double drawHeight = viewportHeight;
// 最后一段只绘制剩余高度
if (i == images.length - 1) {
double remainingHeight = contentHeight - offsetY;
drawHeight = remainingHeight;
}
// 绘制当前截图片段
canvas.drawImageRect(
image,
Rect.fromLTWH(0, 0, image.width.toDouble(), drawHeight),
Rect.fromLTWH(0, offsetY, image.width.toDouble(), drawHeight),
Paint(),
);
offsetY += drawHeight;
image.dispose(); // 释放资源,避免内存泄漏
}
final picture = recorder.endRecording();
return picture.toImage(images[0].width, contentHeight.toInt());
}
// 安全的setState(避免页面销毁后调用)
void mySetState(VoidCallback callBack) {
if (mounted) setState(callBack);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('基础截屏')),
body: pageIndex == 0 ? normalScreenshotUI() : scrollScreenshotUI(),
floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: buttons.map((e) => Row(
children: [
TextButton(
onPressed: () => setState(() => pageIndex = buttons.indexOf(e)),
child: Text(e, style: const TextStyle(color: Colors.white, fontSize: 12)),
),
TextButton(
onPressed: () => pageIndex == 0 ? normalScreenshot() : scrollScreenshot(),
child: const Text('执行', style: TextStyle(color: Colors.white, fontSize: 12)),
),
],
)).toList(),
),
);
}
// 普通截屏演示UI
Widget normalScreenshotUI() {
return RepaintBoundary(
key: normalKey,
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(10),
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisSpacing: 40, mainAxisSpacing: 40, childAspectRatio: 2),
itemCount: 20,
itemBuilder: (_, index) => Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(30),
),
child: Text('$index', style: const TextStyle(color: Colors.white)),
),
),
),
);
}
// 滚动截屏演示UI
Widget scrollScreenshotUI() {
return RepaintBoundary(
key: scrollKey,
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(10),
child: GridView.builder(
controller: _scrollController,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisSpacing: 40, mainAxisSpacing: 40, childAspectRatio: 2),
itemCount: 20,
itemBuilder: (_, index) => Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(30),
),
child: Text('$index', style: const TextStyle(color: Colors.white)),
),
),
),
);
}
}
三、实现方式二:带保存路径选择的截屏(ScreenshotSaver.dart)
功能概述
扩展基础截屏功能,核心新增:
- 支持用户自定义截图保存路径
- 自动生成带时间戳的唯一文件名
- 增加保存结果的 SnackBar 提示
- 优化滚动截屏的稳定性
核心代码
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart';
class ScreenshotSaverPage extends StatefulWidget {
@override
_ScreenshotSaverPageState createState() => _ScreenshotSaverPageState();
}
class _ScreenshotSaverPageState extends State<ScreenshotSaverPage> with TickerProviderStateMixin {
String path = '';
GlobalKey normalKey = GlobalKey();
GlobalKey scrollKey = GlobalKey();
final ScrollController _scrollController = ScrollController();
List<String> buttons = ['普通截屏', '滚动截屏'];
int pageIndex = 0;
// 选择保存文件夹(依赖file_picker插件)
Future<String?> _selectSaveDirectory() async {
try {
return await FilePicker.platform.getDirectoryPath(dialogTitle: "选择保存文件夹");
} catch (e) {
print("选择文件夹失败:$e");
return null;
}
}
// 通用图片保存方法(复用逻辑)
Future<void> _saveImage(Uint8List imageBytes, String fileName) async {
// 1. 选择保存目录
String? saveDir = await _selectSaveDirectory();
if (saveDir == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('未选择保存文件夹')),
);
return;
}
// 2. 拼接路径并保存
final imagePath = '$saveDir/$fileName';
File imageFile = File(imagePath);
try {
await imageFile.writeAsBytes(imageBytes);
if (mounted) {
setState(() => path = imageFile.path);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存成功:$path')),
);
}
print('图片保存路径:$path');
} catch (e) {
print('保存失败:$e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e')),
);
}
}
// 普通截屏(复用基础逻辑+自定义保存)
Future<void> normalScreenshot() async {
try {
final renderObject = normalKey.currentContext?.findRenderObject();
if (renderObject is! RenderRepaintBoundary) return;
ui.Image image = await renderObject.toImage();
ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData != null) {
Uint8List pngBytes = byteData.buffer.asUint8List();
// 带时间戳的文件名,避免覆盖
String fileName = 'normal_screenshot_${DateTime.now().millisecondsSinceEpoch}.png';
_saveImage(pngBytes, fileName);
}
} catch (e) {
print('普通截屏失败:$e');
}
}
// 滚动截屏(复用基础逻辑+自定义保存)
Future<void> scrollScreenshot() async {
try {
final scrollContext = scrollKey.currentContext;
if (scrollContext == null) return;
final scrollPosition = _scrollController.position;
final renderObject = scrollContext.findRenderObject();
if (renderObject == null || renderObject is! RenderRepaintBoundary) return;
// 分段截图逻辑(同基础版)
final initialOffset = scrollPosition.pixels;
final viewportHeight = scrollPosition.viewportDimension;
final contentHeight = scrollPosition.maxScrollExtent + viewportHeight;
List<ui.Image> imagePieces = [];
double currentOffset = 0.0;
while (currentOffset < contentHeight) {
await scrollPosition.animateTo(
currentOffset,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
await Future.delayed(const Duration(milliseconds: 200));
ui.Image image = await renderObject.toImage();
imagePieces.add(image);
currentOffset += viewportHeight;
if (currentOffset > contentHeight) {
currentOffset = contentHeight - viewportHeight;
if (currentOffset < 0) currentOffset = 0;
}
}
// 恢复滚动位置
await scrollPosition.animateTo(
initialOffset,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
// 合并并保存
if (imagePieces.isNotEmpty) {
final mergedImage = await _mergeImages(imagePieces, viewportHeight, contentHeight);
ByteData? byteData = await mergedImage.toByteData(format: ui.ImageByteFormat.png);
if (byteData != null) {
Uint8List pngBytes = byteData.buffer.asUint8List();
String fileName = 'scroll_screenshot_${DateTime.now().millisecondsSinceEpoch}.png';
_saveImage(pngBytes, fileName);
}
}
} catch (e) {
print('滚动截屏失败:$e');
}
}
// 合并截图(修复原代码偏移错误)
Future<ui.Image> _mergeImages(List<ui.Image> images, double viewportHeight, double contentHeight) async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
double offsetY = 0.0;
for (int i = 0; i < images.length; i++) {
final image = images[i];
double drawHeight = viewportHeight;
if (i == images.length - 1) {
double remainingHeight = contentHeight - offsetY;
drawHeight = remainingHeight;
}
// 修复原代码的偏移错误,保证拼接无缝
canvas.drawImageRect(
image,
Rect.fromLTWH(0, 0, image.width.toDouble(), drawHeight),
Rect.fromLTWH(0, offsetY, image.width.toDouble(), drawHeight),
Paint(),
);
offsetY += drawHeight;
image.dispose();
}
final picture = recorder.endRecording();
return picture.toImage(images[0].width, contentHeight.toInt());
}
// 安全更新状态
void mySetState(VoidCallback callBack) {
if (mounted) setState(callBack);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('自定义保存路径截屏')),
body: pageIndex == 0 ? normalScreenshotUI() : scrollScreenshotUI(),
floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: buttons.map((e) => Row(
children: [
TextButton(
onPressed: () => setState(() => pageIndex = buttons.indexOf(e)),
child: Text(e, style: const TextStyle(color: Colors.white, fontSize: 12)),
),
TextButton(
onPressed: () => pageIndex == 0 ? normalScreenshot() : scrollScreenshot(),
child: const Text('执行', style: TextStyle(color: Colors.white, fontSize: 12)),
),
],
)).toList(),
),
);
}
// 普通截屏UI
Widget normalScreenshotUI() {
return RepaintBoundary(
key: normalKey,
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(10),
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisSpacing: 40, mainAxisSpacing: 40, childAspectRatio: 2),
itemCount: 20,
itemBuilder: (_, index) => Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(30),
),
child: Text('$index', style: const TextStyle(color: Colors.white, fontSize: 50)),
),
),
),
);
}
// 滚动截屏UI
Widget scrollScreenshotUI() {
return RepaintBoundary(
key: scrollKey,
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(10),
child: GridView.builder(
controller: _scrollController,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisSpacing: 40, mainAxisSpacing: 40, childAspectRatio: 2),
itemCount: 50,
itemBuilder: (_, index) => Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(30),
),
child: Text('$index', style: const TextStyle(color: Colors.white, fontSize: 50)),
),
),
),
);
}
}
三、实现方式二:带保存路径选择的截屏(ScreenshotSaver.dart)
功能概述
扩展基础截屏功能,核心新增:
- 支持用户自定义截图保存路径
- 自动生成带时间戳的唯一文件名
- 增加保存结果的 SnackBar 提示
- 优化滚动截屏的稳定性
核心代码
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart';
class ScreenshotSaverPage extends StatefulWidget {
@override
_ScreenshotSaverPageState createState() => _ScreenshotSaverPageState();
}
class _ScreenshotSaverPageState extends State<ScreenshotSaverPage> with TickerProviderStateMixin {
String path = '';
GlobalKey normalKey = GlobalKey();
GlobalKey scrollKey = GlobalKey();
final ScrollController _scrollController = ScrollController();
List<String> buttons = ['普通截屏', '滚动截屏'];
int pageIndex = 0;
// 选择保存文件夹(依赖file_picker插件)
Future<String?> _selectSaveDirectory() async {
try {
return await FilePicker.platform.getDirectoryPath(dialogTitle: "选择保存文件夹");
} catch (e) {
print("选择文件夹失败:$e");
return null;
}
}
// 通用图片保存方法(复用逻辑)
Future<void> _saveImage(Uint8List imageBytes, String fileName) async {
// 1. 选择保存目录
String? saveDir = await _selectSaveDirectory();
if (saveDir == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('未选择保存文件夹')),
);
return;
}
// 2. 拼接路径并保存
final imagePath = '$saveDir/$fileName';
File imageFile = File(imagePath);
try {
await imageFile.writeAsBytes(imageBytes);
if (mounted) {
setState(() => path = imageFile.path);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存成功:$path')),
);
}
print('图片保存路径:$path');
} catch (e) {
print('保存失败:$e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e')),
);
}
}
// 普通截屏(复用基础逻辑+自定义保存)
Future<void> normalScreenshot() async {
try {
final renderObject = normalKey.currentContext?.findRenderObject();
if (renderObject is! RenderRepaintBoundary) return;
ui.Image image = await renderObject.toImage();
ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData != null) {
Uint8List pngBytes = byteData.buffer.asUint8List();
// 带时间戳的文件名,避免覆盖
String fileName = 'normal_screenshot_${DateTime.now().millisecondsSinceEpoch}.png';
_saveImage(pngBytes, fileName);
}
} catch (e) {
print('普通截屏失败:$e');
}
}
// 滚动截屏(复用基础逻辑+自定义保存)
Future<void> scrollScreenshot() async {
try {
final scrollContext = scrollKey.currentContext;
if (scrollContext == null) return;
final scrollPosition = _scrollController.position;
final renderObject = scrollContext.findRenderObject();
if (renderObject == null || renderObject is! RenderRepaintBoundary) return;
// 分段截图逻辑(同基础版)
final initialOffset = scrollPosition.pixels;
final viewportHeight = scrollPosition.viewportDimension;
final contentHeight = scrollPosition.maxScrollExtent + viewportHeight;
List<ui.Image> imagePieces = [];
double currentOffset = 0.0;
while (currentOffset < contentHeight) {
await scrollPosition.animateTo(
currentOffset,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
await Future.delayed(const Duration(milliseconds: 200));
ui.Image image = await renderObject.toImage();
imagePieces.add(image);
currentOffset += viewportHeight;
if (currentOffset > contentHeight) {
currentOffset = contentHeight - viewportHeight;
if (currentOffset < 0) currentOffset = 0;
}
}
// 恢复滚动位置
await scrollPosition.animateTo(
initialOffset,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
// 合并并保存
if (imagePieces.isNotEmpty) {
final mergedImage = await _mergeImages(imagePieces, viewportHeight, contentHeight);
ByteData? byteData = await mergedImage.toByteData(format: ui.ImageByteFormat.png);
if (byteData != null) {
Uint8List pngBytes = byteData.buffer.asUint8List();
String fileName = 'scroll_screenshot_${DateTime.now().millisecondsSinceEpoch}.png';
_saveImage(pngBytes, fileName);
}
}
} catch (e) {
print('滚动截屏失败:$e');
}
}
// 合并截图(修复原代码偏移错误)
Future<ui.Image> _mergeImages(List<ui.Image> images, double viewportHeight, double contentHeight) async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
double offsetY = 0.0;
for (int i = 0; i < images.length; i++) {
final image = images[i];
double drawHeight = viewportHeight;
if (i == images.length - 1) {
double remainingHeight = contentHeight - offsetY;
drawHeight = remainingHeight;
}
// 修复原代码的偏移错误,保证拼接无缝
canvas.drawImageRect(
image,
Rect.fromLTWH(0, 0, image.width.toDouble(), drawHeight),
Rect.fromLTWH(0, offsetY, image.width.toDouble(), drawHeight),
Paint(),
);
offsetY += drawHeight;
image.dispose();
}
final picture = recorder.endRecording();
return picture.toImage(images[0].width, contentHeight.toInt());
}
// 安全更新状态
void mySetState(VoidCallback callBack) {
if (mounted) setState(callBack);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('自定义保存路径截屏')),
body: pageIndex == 0 ? normalScreenshotUI() : scrollScreenshotUI(),
floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: buttons.map((e) => Row(
children: [
TextButton(
onPressed: () => setState(() => pageIndex = buttons.indexOf(e)),
child: Text(e, style: const TextStyle(color: Colors.white, fontSize: 12)),
),
TextButton(
onPressed: () => pageIndex == 0 ? normalScreenshot() : scrollScreenshot(),
child: const Text('执行', style: TextStyle(color: Colors.white, fontSize: 12)),
),
],
)).toList(),
),
);
}
// 普通截屏UI
Widget normalScreenshotUI() {
return RepaintBoundary(
key: normalKey,
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(10),
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisSpacing: 40, mainAxisSpacing: 40, childAspectRatio: 2),
itemCount: 20,
itemBuilder: (_, index) => Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(30),
),
child: Text('$index', style: const TextStyle(color: Colors.white, fontSize: 50)),
),
),
),
);
}
// 滚动截屏UI
Widget scrollScreenshotUI() {
return RepaintBoundary(
key: scrollKey,
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(10),
child: GridView.builder(
controller: _scrollController,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisSpacing: 40, mainAxisSpacing: 40, childAspectRatio: 2),
itemCount: 50,
itemBuilder: (_, index) => Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(30),
),
child: Text('$index', style: const TextStyle(color: Colors.white, fontSize: 50)),
),
),
),
);
}
}
四、实现方式三:综合截屏工具(ComprehensiveScreenshotPage.dart)
功能概述
最完整的截屏解决方案,支持:
- 普通 / 滚动截屏
- 截图编辑(涂鸦、添加文字)
- 多格式保存(PNG/JPEG/WEBP)
- 自定义文件名和保存路径
核心代码
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:file_picker/file_picker.dart';
import 'package:get/get.dart';
// ---------------------- 数据模型 ----------------------
// 涂鸦点模型
class DrawingPoint {
final Offset? point;
final Paint paint;
DrawingPoint({required this.point, required this.paint});
}
// 文字绘制模型
class DrawingText {
final Offset position;
final String text;
final TextStyle style;
DrawingText({required this.position, required this.text, required this.style});
}
// ---------------------- 自定义绘制器 ----------------------
// 区域选择绘制器(截屏时的选框)
class AreaSelectionPainter extends CustomPainter {
final Offset startPoint;
final Offset endPoint;
AreaSelectionPainter(this.startPoint, this.endPoint);
@override
void paint(Canvas canvas, Size size) {
final left = min(startPoint.dx, endPoint.dx);
final top = min(startPoint.dy, endPoint.dy);
final right = max(startPoint.dx, endPoint.dx);
final bottom = max(startPoint.dy, endPoint.dy);
final rect = Rect.fromLTRB(left, top, right, bottom);
// 选框背景
final paint = Paint()
..color = Colors.blue.withOpacity(0.3)
..style = PaintingStyle.fill
..strokeWidth = 2.0;
// 选框边框
final borderPaint = Paint()
..color = Colors.blue
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
canvas.drawRect(rect, paint);
canvas.drawRect(rect, borderPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
// 截图编辑绘制器(涂鸦+文字)
class DrawingPainter extends CustomPainter {
final List<DrawingPoint> drawingPoints;
final List<DrawingText> drawingTexts;
DrawingPainter(this.drawingPoints, this.drawingTexts);
@override
void paint(Canvas canvas, Size size) {
// 绘制涂鸦线条
for (int i = 0; i < drawingPoints.length - 1; i++) {
if (drawingPoints[i].point != null && drawingPoints[i + 1].point != null) {
canvas.drawLine(drawingPoints[i].point!, drawingPoints[i + 1].point!, drawingPoints[i].paint);
}
}
// 绘制文字
for (final text in drawingTexts) {
final textSpan = TextSpan(text: text.text, style: text.style);
final textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(canvas, text.position);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
// ---------------------- 主页面 ----------------------
class ComprehensiveScreenshotPage extends StatefulWidget {
@override
_ComprehensiveScreenshotPageState createState() => _ComprehensiveScreenshotPageState();
}
class _ComprehensiveScreenshotPageState extends State<ComprehensiveScreenshotPage> with TickerProviderStateMixin {
// 截屏用GlobalKey
final GlobalKey normalKey = GlobalKey();
final GlobalKey scrollKey = GlobalKey();
final GlobalKey fullScreenKey = GlobalKey(); // 全屏截屏用
final ScrollController _scrollController = ScrollController();
// 状态管理
String _savePath = '';
Uint8List? _currentScreenshot; // 当前截图的字节数据
bool _isEditing = false; // 是否处于编辑模式
final List<DrawingPoint> _drawingPoints = []; // 涂鸦数据
final List<DrawingText> _drawingTexts = []; // 文字数据
String _textToAdd = ''; // 待添加的文字
bool _isAddingText = false; // 是否处于添加文字状态
// 保存设置
String _saveFormat = 'PNG';
String _fileNamePattern = 'screenshot_${DateTime.now().millisecondsSinceEpoch}';
// 区域截屏
bool _isSelectingArea = false;
Offset? _startPoint;
Offset? _endPoint;
late TabController tabController;
@override
void initState() {
super.initState();
tabController = TabController(vsync: this, length: 2);
}
@override
void dispose() {
_scrollController.dispose();
tabController.dispose();
super.dispose();
}
// ---------------------- UI构建 ----------------------
@override
Widget build(BuildContext context) {
return RepaintBoundary(
key: fullScreenKey, // 全屏截屏的容器
child: Scaffold(
appBar: AppBar(
title: const Text('综合截屏工具'),
actions: [
Container(
margin: const EdgeInsets.only(right: 20),
child: IconButton(
icon: const Icon(Icons.save),
onPressed: _currentScreenshot != null ? _saveCurrentScreenshot : null,
),
),
],
),
body: Column(
children: [
_buildScreenshotControls(), // 截屏控制按钮
_buildSaveSettings(), // 保存设置
Expanded(
child: Stack(
children: [
_buildDemoContent(), // 演示内容
_buildAreaSelectionOverlay(), // 区域选择覆盖层
_buildEditInterface(), // 编辑界面
],
),
),
_buildScreenshotPreview(), // 截图预览
],
),
),
);
}
// 截屏控制按钮栏
Widget _buildScreenshotControls() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(onPressed: _takeNormalScreenshot, child: const Text('普通截屏')),
ElevatedButton(onPressed: _takeScrollScreenshot, child: const Text('滚动截屏')),
ElevatedButton(onPressed: _takeFullScreenScreenshot, child: const Text('全屏截屏')),
ElevatedButton(onPressed: () => setState(() => _isSelectingArea = true), child: const Text('区域截屏')),
],
),
);
}
// 保存设置栏
Widget _buildSaveSettings() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('保存设置:', style: TextStyle(fontWeight: FontWeight.bold)),
Row(
children: [
const Text('格式:'),
DropdownButton<String>(
value: _saveFormat,
items: ['PNG', 'JPEG', 'WEBP']
.map((format) => DropdownMenuItem(value: format, child: Text(format)))
.toList(),
onChanged: (value) => setState(() => _saveFormat = value!),
),
const SizedBox(width: 20),
const Text('文件名:'),
const SizedBox(width: 5),
Expanded(
child: TextField(
controller: TextEditingController(text: _fileNamePattern),
onChanged: (value) => setState(() => _fileNamePattern = value),
decoration: const InputDecoration(hintText: '输入文件名', border: OutlineInputBorder()),
),
),
],
),
],
),
);
}
// 演示内容(Tab切换普通/滚动内容)
Widget _buildDemoContent() {
return TabBarView(
controller: tabController,
children: [
// 普通内容
RepaintBoundary(
key: normalKey,
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: Column(
children: [
const Text('普通Widget截屏示例', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 20),
Container(width: 200, height: 200, color: Colors.blue, child: const Center(child: Text('蓝色方块'))),
const SizedBox(height: 20),
ListView.builder(
shrinkWrap: true,
itemCount: 5,
itemBuilder: (context, index) => ListTile(
title: Text('列表项 ${index + 1}'),
subtitle: const Text('这是一个示例列表项'),
),
),
],
),
),
),
),
// 滚动内容
RepaintBoundary(
key: scrollKey,
child: Container(
color: Colors.white,
child: GridView.builder(
controller: _scrollController,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1.5,
),
itemCount: 50,
itemBuilder: (context, index) => Container(
color: Colors.primaries[index % Colors.primaries.length],
child: Center(child: Text('项目 $index', style: const TextStyle(color: Colors.white))),
),
),
),
),
],
);
}
// 区域选择覆盖层
Widget _buildAreaSelectionOverlay() {
if (!_isSelectingArea) return const SizedBox.shrink();
return Stack(
children: [
GestureDetector(
onTap: () => setState(() {
_isSelectingArea = false;
_startPoint = null;
_endPoint = null;
}),
onPanStart: (details) => setState(() {
_startPoint = details.localPosition;
_endPoint = details.localPosition;
}),
onPanUpdate: (details) => setState(() => _endPoint = details.localPosition),
onPanEnd: (details) => setState(() {
_isSelectingArea = false;
_takeAreaScreenshot(); // 完成选择后执行区域截屏
}),
child: Container(
color: Colors.black.withOpacity(0.3),
child: CustomPaint(
painter: _startPoint != null && _endPoint != null
? AreaSelectionPainter(_startPoint!, _endPoint!)
: null,
),
),
),
const Positioned(
bottom: 20,
left: 0,
right: 0,
child: Center(
child: Text('点击空白区域取消选择', style: TextStyle(color: Colors.white, fontSize: 16)),
),
),
],
);
}
// 截图编辑界面
Widget _buildEditInterface() {
if (!_isEditing || _currentScreenshot == null) return const SizedBox.shrink();
return Stack(
alignment: Alignment.center,
children: [
// 涂鸦/文字绘制层
CustomPaint(
painter: DrawingPainter(_drawingPoints, _drawingTexts),
child: GestureDetector(
// 涂鸦
onPanStart: (details) => setState(() => _drawingPoints.add(DrawingPoint(
point: details.localPosition,
paint: Paint()..color = Colors.red..strokeWidth = 5..strokeCap = StrokeCap.round,
))),
onPanUpdate: (details) => setState(() => _drawingPoints.add(DrawingPoint(
point: details.localPosition,
paint: Paint()..color = Colors.red..strokeWidth = 5..strokeCap = StrokeCap.round,
))),
onPanEnd: (details) => setState(() => _drawingPoints.add(DrawingPoint(point: null, paint: Paint()))),
// 添加文字
onTapDown: (details) {
if (_isAddingText && _textToAdd.isNotEmpty) {
setState(() {
_drawingTexts.add(DrawingText(
position: details.localPosition,
text: _textToAdd,
style: const TextStyle(color: Colors.red, fontSize: 24, fontWeight: FontWeight.bold),
));
_textToAdd = '';
_isAddingText = false;
});
}
},
),
),
// 编辑工具栏
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(10),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
ElevatedButton(onPressed: () => setState(() => _isEditing = false), child: const Text('完成编辑')),
const SizedBox(width: 10),
ElevatedButton(
onPressed: () => setState(() {
_drawingPoints.clear();
_drawingTexts.clear();
}),
child: const Text('清除所有'),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: _addTextToScreenshot,
style: ElevatedButton.styleFrom(backgroundColor: _isAddingText ? Colors.green : null),
child: const Text('添加文字'),
),
],
),
const SizedBox(height: 10),
TextField(
decoration: InputDecoration(
hintText: _isAddingText ? '点击屏幕放置文字' : '输入要添加的文字',
border: const OutlineInputBorder(),
),
onChanged: (value) => setState(() => _textToAdd = value),
onSubmitted: (value) => _addTextToScreenshot(),
),
],
),
),
),
],
);
}
// 截图预览栏
Widget _buildScreenshotPreview() {
if (_currentScreenshot == null) return const SizedBox.shrink();
return Container(
height: 100,
padding: const EdgeInsets.all(10),
child: Column(
children: [
const Text('截图预览'),
IconButton(
icon: const Icon(Icons.edit, size: 30),
onPressed: () => setState(() => _isEditing = true),
),
],
),
);
}
// ---------------------- 核心功能实现 ----------------------
// 添加文字到截图
void _addTextToScreenshot() {
if (_textToAdd.isEmpty) return;
setState(() => _isAddingText = true);
}
// 普通截屏
Future<void> _takeNormalScreenshot() async {
tabController.index = 0;
try {
final boundary = normalKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) return;
final image = await boundary.toImage(pixelRatio: ui.window.devicePixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData?.buffer.asUint8List();
if (pngBytes != null) {
setState(() {
_currentScreenshot = pngBytes;
_isEditing = false;
});
_showSuccessMessage('普通截屏成功');
}
} catch (e) {
_showErrorMessage('普通截屏失败: $e');
}
}
// 滚动截屏
Future<void> _takeScrollScreenshot() async {
tabController.index = 1;
try {
final scrollContext = scrollKey.currentContext;
if (scrollContext == null) return;
final scrollPosition = _scrollController.position;
final renderObject = scrollContext.findRenderObject() as RenderRepaintBoundary?;
if (renderObject == null) return;
// 分段截图逻辑
final initialOffset = scrollPosition.pixels;
final viewportHeight = scrollPosition.viewportDimension;
final contentHeight = scrollPosition.maxScrollExtent + viewportHeight;
List<ui.Image> imagePieces = [];
double currentOffset = 0.0;
while (currentOffset < contentHeight) {
await scrollPosition.animateTo(
currentOffset,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
await Future.delayed(const Duration(milliseconds: 200));
final image = await renderObject.toImage(pixelRatio: ui.window.devicePixelRatio);
imagePieces.add(image);
currentOffset += viewportHeight;
if (currentOffset > contentHeight) {
currentOffset = contentHeight - viewportHeight;
if (currentOffset < 0) currentOffset = 0;
}
}
// 恢复初始位置
await scrollPosition.animateTo(
initialOffset,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
// 合并截图
if (imagePieces.isNotEmpty) {
final mergedImage = await _mergeImages(imagePieces, viewportHeight, contentHeight);
final byteData = await mergedImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData?.buffer.asUint8List();
if (pngBytes != null) {
setState(() {
_currentScreenshot = pngBytes;
_isEditing = false;
});
_showSuccessMessage('滚动截屏成功');
}
}
} catch (e) {
_showErrorMessage('滚动截屏失败: $e');
}
}
// 全屏截屏
Future<void> _takeFullScreenScreenshot() async {
try {
final boundary = fullScreenKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) return;
final image = await boundary.toImage(pixelRatio: ui.window.devicePixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData?.buffer.asUint8List();
if (pngBytes != null) {
setState(() {
_currentScreenshot = pngBytes;
_isEditing = false;
});
_showSuccessMessage('全屏截屏成功');
}
} catch (e) {
_showErrorMessage('全屏截屏失败: $e');
}
}
// 区域截屏
Future<void> _takeAreaScreenshot() async {
if (_startPoint == null || _endPoint == null) return;
try {
final boundary = fullScreenKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) return;
// 获取全屏截图
final fullImage = await boundary.toImage(pixelRatio: ui.window.devicePixelRatio);
final pixelRatio = ui.window.devicePixelRatio;
// 计算裁剪区域(转换为像素坐标)
final left = (_startPoint!.dx * pixelRatio).clamp(0.0, fullImage.width.toDouble());
final top = (_startPoint!.dy * pixelRatio).clamp(0.0, fullImage.height.toDouble());
final right = (_endPoint!.dx * pixelRatio).clamp(0.0, fullImage.width.toDouble());
final bottom = (_endPoint!.dy * pixelRatio).clamp(0.0, fullImage.height.toDouble());
final width = (right - left).abs();
final height = (bottom - top).abs();
if (width <= 0 || height <= 0) return;
// 裁剪指定区域
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawImageRect(
fullImage,
Rect.fromLTWH(left, top, width, height),
Rect.fromLTWH(0.0, 0.0, width, height),
Paint(),
);
final picture = recorder.endRecording();
final croppedImage = await picture.toImage(width.toInt(), height.toInt());
// 转换为字节数据
final byteData = await croppedImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData?.buffer.asUint8List();
if (pngBytes != null) {
setState(() {
_currentScreenshot = pngBytes;
_isEditing = false;
});
_showSuccessMessage('区域截屏成功');
}
// 释放资源
fullImage.dispose();
croppedImage.dispose();
} catch (e) {
_showErrorMessage('区域截屏失败: $e');
}
}
// 合并滚动截图的分段
Future<ui.Image> _mergeImages(List<ui.Image> images, double viewportHeight, double contentHeight) async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
double offsetY = 0.0;
for (int i = 0; i < images.length; i++) {
final image = images[i];
double drawHeight = viewportHeight;
if (i == images.length - 1) {
double remainingHeight = contentHeight - offsetY;
drawHeight = remainingHeight;
}
canvas.drawImageRect(
image,
Rect.fromLTWH(0, 0, image.width.toDouble(), drawHeight),
Rect.fromLTWH(0, offsetY, image.width.toDouble(), drawHeight),
Paint(),
);
offsetY += drawHeight;
image.dispose();
}
final picture = recorder.endRecording();
return picture.toImage(images[0].width, contentHeight.toInt());
}
// 生成包含编辑内容的最终图片
Future<Uint8List?> _generateFinalImage() async {
if (_currentScreenshot == null) return null;
try {
// 解码原始截图
final codec = await ui.instantiateImageCodec(_currentScreenshot!);
final frame = await codec.getNextFrame();
final image = frame.image;
// 绘制编辑后的内容
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder, Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()));
canvas.drawImage(image, Offset.zero, Paint()); // 绘制原始截图
// 绘制涂鸦
for (int i = 0; i < _drawingPoints.length - 1; i++) {
if (_drawingPoints[i].point != null && _drawingPoints[i + 1].point != null) {
canvas.drawLine(_drawingPoints[i].point!, _drawingPoints[i + 1].point!, _drawingPoints[i].paint);
}
}
// 绘制文字
for (final text in _drawingTexts) {
final textSpan = TextSpan(text: text.text, style: text.style);
final textPainter = TextPainter(text: textSpan, textDirection: TextDirection.ltr);
textPainter.layout();
textPainter.paint(canvas, text.position);
}
// 生成最终图片
final picture = recorder.endRecording();
final finalImage = await picture.toImage(image.width, image.height);
final byteData = await finalImage.toByteData(format: _getImageByteFormat());
return byteData?.buffer.asUint8List();
} catch (e) {
_showErrorMessage('生成最终图片失败: $e');
return null;
}
}
// 根据保存格式选择对应的ImageByteFormat
ui.ImageByteFormat _getImageByteFormat() {
switch (_saveFormat) {
case 'PNG':
return ui.ImageByteFormat.png;
case 'JPEG':
return ui.ImageByteFormat.jpeg;
case 'WEBP':
return ui.ImageByteFormat.png; // WEBP需额外处理,此处暂用PNG
default:
return ui.ImageByteFormat.png;
}
}
// 保存当前截图(含编辑内容)
Future<void> _saveCurrentScreenshot() async {
if (_currentScreenshot == null) return;
try {
// 生成最终图片
final finalImageBytes = await _generateFinalImage();
if (finalImageBytes == null) return;
// 选择保存目录
final directory = await FilePicker.platform.getDirectoryPath(dialogTitle: '选择保存目录');
if (directory == null) {
_showErrorMessage('未选择保存目录');
return;
}
// 构建文件名
String extension = _saveFormat.toLowerCase();
String fileName = '$_fileNamePattern.$extension';
String filePath = '$directory/$fileName';
// 保存文件
final file = File(filePath);
await file.writeAsBytes(finalImageBytes);
setState(() => _savePath = filePath);
_showSuccessMessage('截图已保存到: $filePath');
} catch (e) {
_showErrorMessage('保存截图失败: $e');
}
}
// 提示工具
void _showSuccessMessage(String message) => Get.snackbar('成功', message, duration: const Duration(seconds: 2));
void _showErrorMessage(String message) => Get.snackbar('错误', message, duration: const Duration(seconds: 2));
}
五、三种实现方式对比
| 实现方式 | 支持功能 | 保存方式 | 截图类型 | 编辑功能 | 适用场景 |
|---|---|---|---|---|---|
| 基础截屏 | 普通、滚动截屏 | 固定路径(应用文档目录) | 普通、滚动 | 无 | 简单需求、快速集成 |
| 带保存路径选择 | 普通、滚动截屏 | 用户自定义路径(带时间戳文件名) | 普通、滚动 | 无 | 需要指定保存位置的场景 |
| 综合截屏工具 | 普通、滚动截屏 | 自定义路径 + 多格式 + 自定义文件名 | 普通、滚动 | 涂鸦、文字添加 | 复杂需求、完整功能 |
六、使用建议与注意事项
1. 依赖配置
在pubspec.yaml中添加必要依赖:
dependencies:
flutter:
sdk: flutter
path_provider: ^2.1.2 # 获取应用目录
file_picker: ^5.5.0 # 选择保存路径
get: ^4.6.6 # 提示框
2. 权限配置
Android:在android/app/src/main/AndroidManifest.xml添加
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- Android 13+ 需添加媒体权限 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
iOS:在ios/Runner/Info.plist添加
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册以保存截图</string>
3. 性能优化
- 滚动截屏时,调整
Duration参数(如 100ms 滚动 + 200ms 等待),避免截图不完整 - 截图后及时调用
dispose()释放ui.Image资源,避免内存泄漏 - 高分辨率截图可降低
pixelRatio(如 1.5)平衡清晰度和性能
4. 跨平台兼容
- iOS 的文件系统权限更严格,建议优先保存到应用沙盒目录
- 不同设备的
devicePixelRatio不同,截图时需指定该参数保证清晰度
七、总结
关键点回顾
- Flutter 截屏的核心是
RenderRepaintBoundary组件,通过它可以捕获任意 Widget 的渲染内容并转换为图片; - 滚动截屏需分段截取视口内容后合并,区域截屏需基于全屏截图裁剪指定区域;
- 截图编辑功能基于
Canvas实现,可扩展涂鸦、文字、贴纸等更多编辑能力; - 实际开发中可根据需求选择基础版(快速集成)或综合版(完整功能),并注意权限和性能优化。
更多推荐


所有评论(0)