Flutter图片添加多行文字水印的三种实现方案详解
时间:2026-08-17 | 作者:火苗实验室 | 阅读:0最近在做一个工程评估的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加载:
final font = await img.BitmapFont.fromZip(await rootBundle.load('assets/fonts/chinese.zip'));
img.drawString(imageFile, '项目名称', font: font, x: 20, y: 100);
但这条路有三个硬伤:
- ① 常用汉字3500个,一个字号的atlas PNG就可能超过5MB
- ② 一个字号需要一套文件,无法动态缩放
- ③ 水印内容里出现图集没收录的字,直接空白且没报错
- 没有文字阴影、不支持自动换行等排版功能
适用场景: 纯英文水印,或者水印汉字内容完全固定、字符集可控,同时对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 _textStyleCache = {};
// 缓存 TextPainter,相同文字+字号+宽度直接复用
static final Map _textPainterCache = {};
// 复用 Paint 对象,避免重复创建
static final Paint _imagePaint = Paint()
..filterQuality = FilterQuality.medium;
/// 给 image_utils.Image 添加多行水印,返回 JPEG 字节
static Future addWatermark({
required image_utils.Image imageFile,
required List 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 _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 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默认通道顺序是RGBFlutter导出的是RGBA,所以一定要加
numChannels: 4,否则颜色会错乱。 -
缓存要设上限
TextPainter持有ParagraphBuilder等原生资源,不加上限的话批量处理几百张图内存会飙升。
小结
核心就三步:
- 用像素数据直接构建
ui.Image - Canvas绘文字
- RGBA转JPEG
这样可以避开多余的编解码。
再加上TextPainter缓存,即使批量处理几十张图也不会感觉到卡顿。
免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。
相关文章
更多-
- 不卖课纯干货:详解Android分层架构与面试策略
- 时间:2026-08-27
-
- Jetpack 和 AndroidX 到底是什么关系?一篇讲清概念、区别与实际用法
- 时间:2026-08-25
-
- Android Spinner实战:用法、案例与常见问题解答
- 时间:2026-08-24
-
- Android 多层嵌套 RecyclerView 滚动详解:从 Fling 中断到丝滑联动
- 时间:2026-08-24
-
- Android Activity启动模式详解与应用场景解析
- 时间:2026-08-23
-
- Android 自定义 View 实战:打造一个跟随滑动的丝滑指示器
- 时间:2026-08-22
-
- 谷歌Android推虚假来电检测功能 基于RCS防范AI伪造诈骗
- 时间:2026-08-21
-
- Android工程师快速入门Dart开发实战攻略
- 时间:2026-08-21
精选合集
更多大家都在玩
大家都在看
更多-
- 糖尿病完全不能吃糖吗
- 时间:2026-09-15
-
- 蚂蚁庄园小课堂2026年9月16日最新题目答案
- 时间:2026-09-15
-
- 小鸡答题今天的答案是什么2026年9月16日
- 时间:2026-09-15
-
- 蚂蚁庄园每日答题答案2026年9月16日
- 时间:2026-09-15
-
- 以下哪种粮食是酿造绍兴黄酒的主要原料 蚂蚁庄园今日答案9月16日
- 时间:2026-09-15
-
- 劝学名句“及时当勉励,岁月不待人”出自哪位诗人 蚂蚁庄园今日答案9.16
- 时间:2026-09-15
-
- 蚂蚁庄园今天答题答案2026年9月16日
- 时间:2026-09-15
-
- 蚂蚁庄园答题今日答案2026年9月16日
- 时间:2026-09-15
