Flutter 如何给图片添加多行文字水印

Flutter 如何给图片添加多行文字水印

最近在做一个工程评估的 App,需要给拍摄的现场照片批量加上多行水印(项目名称、时间、地点等信息),研究了一圈发现网上大多数方案要么太简陋,要么性能拉胯。折腾了几天,总算搞出一套还算满意的方案,记录一下。


效果目标

  • 图片右下角(或底部)显示多行水印文字
  • 文字带阴影,保证在亮色图片上也清晰可见
  • 批量处理时不卡顿,支持大图
  • 可以直接复制使用

实现方案有哪几种?

在 Flutter 里给图片加水印,大体上有三条路可以走,我逐一说一下优缺点,最后也说说我为什么选了第三种。

方案一:Widget 叠加(Stack + Positioned)

最直觉的做法,用 Stack 把水印 Text 覆盖在 Image 上面,用 RepaintBoundary + RenderRepaintBoundary.toImage() 截图导出。

dart 复制代码
// 示意
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 直接在像素级别写文字。

dart 复制代码
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 加载:

    dart 复制代码
    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 的 CanvasTextPainter 绘制文字,最终通过 PictureRecorder 录制导出。

css 复制代码
image_utils.Image → RGBA 像素 → ui.Image → Canvas 绘制 → JPEG 输出

优点:

  • 完美支持中文、自定义字体、文字阴影、换行等所有排版特性
  • 直接操作像素,输出分辨率和原图完全一致
  • 通过缓存 TextPainterTextStyle,批量处理性能优秀
  • 不需要把 Widget 渲染到屏幕

缺点:

  • 依赖 Flutter engine(dart:ui),不能用纯 Isolate 执行,需要在 UI 线程或 compute 配合使用
  • 代码比方案一复杂一些

适用场景: 需要中文水印、大图高质量输出、批量处理场景,也就是大多数实际业务需求。


三种方案对比

Widget 截图 image 包绘制 Canvas + TextPainter
中文支持
原始分辨率 ⚠️ 依赖 pixelRatio
批量性能
代码复杂度
可用 Isolate
文字阴影/换行

综合下来,方案三是实际项目里最合适的选择,下面直接看实现。


依赖

yaml 复制代码
dependencies:
  image: ^4.0.0   # 用于图片编码/解码

pubspec.yaml 里加上 image 这个包,它提供了 JPEG 编解码能力。Flutter 自带的 dart:ui 负责 Canvas 绘制。


核心思路

整体流程如下:

css 复制代码
原始图片字节 → image_utils.Image
     ↓
转为 ui.Image(避免二次编解码)
     ↓
用 Canvas + TextPainter 绘制多行文字
     ↓
录制 Picture → 转回 ui.Image
     ↓
导出 RGBA 字节 → 编码为 JPEG

关键点在于直接用像素数据构建 ui.Image,而不是把图片先编码成 JPEG 再解码,节省了一次无谓的编解码开销。


完整实现

dart 复制代码
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();
  }
}

调用方式

dart 复制代码
// 准备水印文字,每个元素一行
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

最朴素的做法是先把它 encodeJpgdecodeImageFromList,但这样白白多了一次编解码。

更好的方案是直接拿 RGBA 像素数据,通过 ui.ImageDescriptor.raw 构建 ui.Image,速度快很多。

2. 字体大小自适应

dart 复制代码
final int fontSize = imageWidth ~/ 38;

取图片短边除以 38,这个比例在 1000px~4000px 的图片上效果都比较好,文字不会太小也不会太大。根据实际效果可以调整这个除数。

3. TextPainter 缓存

TextPainter.layout() 是相对耗时的操作。在批量处理多张图片时,如果水印内容相同(比如同一个项目的照片),可以直接复用已经 layout 好的 TextPainter,避免重复计算。

缓存键由 文字内容 + 字号 + 最大宽度 组成,三者相同才复用。

4. 水印位置

目前是从图片底部向上排列,代码里 startYimageHeight - fontSize * 2 开始,每绘制一行就往上移一个文字高度 + 间距(20px)。

如果想改成右下角对齐,把 Offset(20, ...) 里的 20 换成 imageWidth - painter.width - 20 即可。


踩过的坑

  1. toByteData 必须在主线程(或 Isolate 里用 compute
    ui.Image.toByteData 是异步的,但它内部依赖 Flutter engine,不能随意放到普通 Isolate 里,否则会直接崩。

  2. image 包的 Image.fromBytes 默认通道顺序是 RGB

    Flutter 导出的是 RGBA,所以一定要加 numChannels: 4,否则颜色会错乱。

  3. 缓存要设上限
    TextPainter 持有 ParagraphBuilder 等原生资源,不加上限的话批量处理几百张图内存会飙升。


小结

核心就三步:用像素数据直接构建 ui.ImageCanvas 绘文字RGBA 转 JPEG

避开了多余的编解码,加上 TextPainter 缓存,即使批量处理几十张图也不会感觉到卡顿。

代码可以直接复制使用,有问题欢迎留言。

相关推荐
leolee182 小时前
Redux Toolkit 实战使用指南
前端·react.js·redux
bluceli2 小时前
React Hooks最佳实践:写出优雅高效的组件代码
前端·react.js
IT_陈寒2 小时前
JavaScript代码效率提升50%?这5个优化技巧你必须知道!
前端·人工智能·后端
IT_陈寒2 小时前
Java开发必知的5个性能优化黑科技,提升50%效率不是梦!
前端·人工智能·后端
LDX前端校草2 小时前
前端开发规则配置
前端
代码老中医2 小时前
2026前端工程化新范式:如何用AI驱动你的设计系统?
前端
用户11481867894843 小时前
Vite项目中的SVG雪碧图
前端·面试
这个实现不了3 小时前
vue写一些进度条样式1
前端
小蜜蜂dry3 小时前
可视化大屏适配方案之- px-To-viewport
前端