Flutter 如何给图片添加多行文字水印
最近在做一个工程评估的 App,需要给拍摄的现场照片批量加上多行水印(项目名称、时间、地点等信息),研究了一圈发现网上大多数方案要么太简陋,要么性能拉胯。折腾了几天,总算搞出一套还算满意的方案,记录一下。
效果目标
- 图片右下角(或底部)显示多行水印文字
- 文字带阴影,保证在亮色图片上也清晰可见
- 批量处理时不卡顿,支持大图
- 可以直接复制使用
实现方案有哪几种?
在 Flutter 里给图片加水印,大体上有三条路可以走,我逐一说一下优缺点,最后也说说我为什么选了第三种。
方案一:Widget 叠加(Stack + Positioned)
最直觉的做法,用 Stack 把水印 Text 覆盖在 Image 上面,用 RepaintBoundary + RenderRepaintBoundary.toImage() 截图导出。
// 示意
Stack(
children: [
Image.file(file),
Positioned(
bottom: 20,
left: 20,
child: Column(
children: lines.map((l) => Text(l, style: style)).toList(),
),
),
],
)
// 截图导出
final boundary = key.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3.0);
优点: 写起来最简单,和 Flutter UI 完全一致。
缺点:
- 必须把 Widget 渲染到屏幕(或离屏树)才能截图,流程繁琐
- 分辨率受
pixelRatio控制,原图是 4000px 的大图的话,截出来的质量无法保证 - 批量处理多张图时,需要反复 build/dispose Widget,性能差
适用场景: 只需要截一张图、预览展示用,不在乎原始分辨率。
方案二:image 包纯 CPU 绘制
用 image 包自带的 drawString 直接在像素级别写文字。
import 'package:image/image.dart' as img;
final font = img.arial14; // 内置字体,只有英文
img.drawString(
imageFile,
'Hello Watermark',
font: font,
x: 20,
y: imageFile.height - 40,
color: img.ColorRgb8(255, 255, 255),
);
优点: 纯 Dart 实现,不依赖 Flutter engine,可以丢进 Isolate 完全不阻塞 UI。
缺点:
- 内置字体只有英文,中文默认无法显示
- 支持中文需要提前用 BMFont / Hiero 等工具把汉字"烧"进位图字体(BitmapFont),生成
.fnt+ atlas PNG 后打包进 assets 加载:但这条路有三个硬伤:① 常用汉字 3500 个,一个字号的 atlas PNG 就可能超过 5MB;② 一个字号需要一套文件,无法动态缩放;③ 水印内容里出现图集里没收录的字,直接空白无报错。final font = await img.BitmapFont.fromZip(await rootBundle.load('assets/fonts/chinese.zip')); img.drawString(imageFile, '项目名称', font: font, x: 20, y: 100); - 没有文字阴影、不支持自动换行等排版功能
适用场景: 纯英文水印、或水印汉字内容完全固定且字符集可控、同时对 Isolate 隔离有强需求的场景。
方案三:Canvas + TextPainter(本文方案)
借助 Flutter 的 Canvas 和 TextPainter 绘制文字,最终通过 PictureRecorder 录制导出。
image_utils.Image → RGBA 像素 → ui.Image → Canvas 绘制 → JPEG 输出
优点:
- 完美支持中文、自定义字体、文字阴影、换行等所有排版特性
- 直接操作像素,输出分辨率和原图完全一致
- 通过缓存
TextPainter和TextStyle,批量处理性能优秀 - 不需要把 Widget 渲染到屏幕
缺点:
- 依赖 Flutter engine(
dart:ui),不能用纯Isolate执行,需要在 UI 线程或compute配合使用 - 代码比方案一复杂一些
适用场景: 需要中文水印、大图高质量输出、批量处理场景,也就是大多数实际业务需求。
三种方案对比
| Widget 截图 | image 包绘制 | Canvas + TextPainter | |
|---|---|---|---|
| 中文支持 | ✅ | ❌ | ✅ |
| 原始分辨率 | ⚠️ 依赖 pixelRatio | ✅ | ✅ |
| 批量性能 | ❌ | ✅ | ✅ |
| 代码复杂度 | 低 | 低 | 中 |
| 可用 Isolate | ❌ | ✅ | ❌ |
| 文字阴影/换行 | ✅ | ❌ | ✅ |
综合下来,方案三是实际项目里最合适的选择,下面直接看实现。
依赖
dependencies:
image: ^4.0.0 # 用于图片编码/解码
pubspec.yaml 里加上 image 这个包,它提供了 JPEG 编解码能力。Flutter 自带的 dart:ui 负责 Canvas 绘制。
核心思路
整体流程如下:
原始图片字节 → image_utils.Image
↓
转为 ui.Image(避免二次编解码)
↓
用 Canvas + TextPainter 绘制多行文字
↓
录制 Picture → 转回 ui.Image
↓
导出 RGBA 字节 → 编码为 JPEG
关键点在于直接用像素数据构建 ui.Image,而不是把图片先编码成 JPEG 再解码,节省了一次无谓的编解码开销。
完整实现
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:image/image.dart' as image_utils;
class WatermarkUtils {
// 缓存 TextStyle,同字号复用同一个对象
static final Map<String, TextStyle> _textStyleCache = {};
// 缓存 TextPainter,相同文字+字号+宽度直接复用
static final Map<String, TextPainter> _textPainterCache = {};
// 复用 Paint 对象,避免重复创建
static final Paint _imagePaint = Paint()
..filterQuality = FilterQuality.medium;
/// 给 image_utils.Image 添加多行水印,返回 JPEG 字节
static Future<Uint8List> addWatermark({
required image_utils.Image imageFile,
required List<String> lines,
}) async {
// 水印从下往上排,先把顺序反转
final watermarkLines = lines.reversed.toList();
// 字体大小按图片短边的 1/38 计算,自适应不同分辨率
final int imageWidth =
imageFile.width > imageFile.height ? imageFile.height : imageFile.width;
final int fontSize = imageWidth ~/ 38;
// Step 1: image_utils.Image → ui.Image(直接用像素,跳过编码)
final ui.Image originalImage =
await _createUIImageFromImageUtils(imageFile);
// Step 2: 用 Canvas 绘制原图 + 水印文字
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawImage(originalImage, Offset.zero, _imagePaint);
_drawWatermarkTexts(
canvas,
watermarkLines,
imageFile.height,
fontSize,
imageWidth,
);
// Step 3: 录制结束,生成带水印的 ui.Image
final watermarkedImage = await recorder
.endRecording()
.toImage(originalImage.width, originalImage.height);
// Step 4: 导出 RGBA 字节
final ByteData? byteData =
await watermarkedImage.toByteData(format: ui.ImageByteFormat.rawRgba);
watermarkedImage.dispose();
originalImage.dispose();
if (byteData == null) throw Exception('图片数据转换失败');
// Step 5: RGBA → JPEG
return _rgbaToJPEG(
byteData.buffer.asUint8List(), imageFile.width, imageFile.height);
}
// ──────────────────────────────────────────
// 私有方法
// ──────────────────────────────────────────
/// image_utils.Image → ui.Image(不经过 JPEG 编解码)
static Future<ui.Image> _createUIImageFromImageUtils(
image_utils.Image img) async {
final bytes = img.getBytes(order: image_utils.ChannelOrder.rgba);
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
final descriptor = ui.ImageDescriptor.raw(
buffer,
width: img.width,
height: img.height,
pixelFormat: ui.PixelFormat.rgba8888,
);
final codec = await descriptor.instantiateCodec();
final frameInfo = await codec.getNextFrame();
descriptor.dispose();
codec.dispose();
return frameInfo.image;
}
/// 从底部向上逐行绘制水印文字
static void _drawWatermarkTexts(
Canvas canvas,
List<String> lines,
int imageHeight,
int fontSize,
int imageWidth,
) {
double startY = imageHeight - (fontSize * 2.0);
const double lineGap = 20;
final double maxWidth = imageWidth - 40.0;
for (final line in lines) {
if (line.isNotEmpty) {
final rect = _drawText(canvas, line, startY, fontSize,
maxWidth: maxWidth);
startY = rect.top - lineGap;
}
}
}
/// 绘制单行文字,返回绘制区域 Rect(用于计算下一行位置)
static Rect _drawText(Canvas canvas, String text, double y, int fontSize,
{double maxWidth = double.infinity}) {
if (text.isEmpty) return Rect.zero;
final cacheKey = '${text}_${fontSize}_${maxWidth.toInt()}';
TextPainter? painter = _textPainterCache[cacheKey];
if (painter == null) {
final styleKey = fontSize.toString();
TextStyle? style = _textStyleCache[styleKey];
if (style == null) {
style = TextStyle(
color: Colors.white,
fontSize: fontSize.toDouble(),
shadows: [
Shadow(
offset: const Offset(1, 1),
blurRadius: 3.0,
color: Colors.black.withOpacity(0.5),
),
],
);
_textStyleCache[styleKey] = style;
}
painter = TextPainter(
text: TextSpan(text: text, style: style),
textDirection: TextDirection.ltr,
maxLines: 2,
textAlign: TextAlign.left,
)..layout(maxWidth: maxWidth);
// 缓存上限 50 条,超出时清理一半
if (_textPainterCache.length >= 50) {
final keys = _textPainterCache.keys.take(25).toList();
for (final k in keys) {
_textPainterCache.remove(k);
}
}
_textPainterCache[cacheKey] = painter;
}
final offset = Offset(20, y - painter.height);
painter.paint(canvas, offset);
return Rect.fromLTWH(20, y - painter.height, painter.width, painter.height);
}
/// RGBA 字节 → JPEG Uint8List
static Uint8List _rgbaToJPEG(Uint8List rgba, int width, int height) {
final img = image_utils.Image.fromBytes(
width: width,
height: height,
bytes: rgba.buffer,
numChannels: 4,
);
return Uint8List.fromList(image_utils.encodeJpg(img, quality: 95));
}
/// 手动清理缓存(内存敏感场景可调用)
static void clearCache() {
_textStyleCache.clear();
_textPainterCache.clear();
}
}
调用方式
// 准备水印文字,每个元素一行
final lines = [
'项目:XX大厦改造工程',
'位置:3号楼-东立面',
'时间:2024-06-18 14:32',
'拍摄人:张三',
];
// imageFile 是通过 image.decodeJpg() 解码的 image_utils.Image
final Uint8List result = await WatermarkUtils.addWatermark(
imageFile: imageFile,
lines: lines,
);
// 写入文件
await File('/path/to/output.jpg').writeAsBytes(result);
几个细节说明
1. 为什么不直接用 drawImage + drawParagraph?
Flutter 的 Canvas 是基于 ui.Image 工作的,而 image 包解码出来的是自己的 image_utils.Image。
最朴素的做法是先把它 encodeJpg 再 decodeImageFromList,但这样白白多了一次编解码。
更好的方案是直接拿 RGBA 像素数据,通过 ui.ImageDescriptor.raw 构建 ui.Image,速度快很多。
2. 字体大小自适应
final int fontSize = imageWidth ~/ 38;
取图片短边除以 38,这个比例在 1000px~4000px 的图片上效果都比较好,文字不会太小也不会太大。根据实际效果可以调整这个除数。
3. TextPainter 缓存
TextPainter.layout() 是相对耗时的操作。在批量处理多张图片时,如果水印内容相同(比如同一个项目的照片),可以直接复用已经 layout 好的 TextPainter,避免重复计算。
缓存键由 文字内容 + 字号 + 最大宽度 组成,三者相同才复用。
4. 水印位置
目前是从图片底部向上排列,代码里 startY 从 imageHeight - fontSize * 2 开始,每绘制一行就往上移一个文字高度 + 间距(20px)。
如果想改成右下角对齐,把 Offset(20, ...) 里的 20 换成 imageWidth - painter.width - 20 即可。
踩过的坑
-
toByteData必须在主线程(或 Isolate 里用compute)
ui.Image.toByteData是异步的,但它内部依赖 Flutter engine,不能随意放到普通 Isolate 里,否则会直接崩。 -
image包的Image.fromBytes默认通道顺序是 RGB
Flutter 导出的是 RGBA,所以一定要加numChannels: 4,否则颜色会错乱。 -
缓存要设上限
TextPainter持有ParagraphBuilder等原生资源,不加上限的话批量处理几百张图内存会飙升。
小结
核心就三步:用像素数据直接构建 ui.Image → Canvas 绘文字 → RGBA 转 JPEG。
避开了多余的编解码,加上 TextPainter 缓存,即使批量处理几十张图也不会感觉到卡顿。
代码可以直接复制使用,有问题欢迎留言。