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 的 Canvas 和 TextPainter 绘制文字,最终通过 PictureRecorder 录制导出。

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

优点:

  • 完美支持中文、自定义字体、文字阴影、换行等所有排版特性
  • 直接操作像素,输出分辨率和原图完全一致
  • 通过缓存 TextPainter 和 TextStyle,批量处理性能优秀
  • 不需要把 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。

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

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

2. 字体大小自适应

dart 复制代码
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 即可。


踩过的坑

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

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

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

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


小结

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

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

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

相关推荐
wangruofeng2 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng2 小时前
给前端开发者的 100 个设计参考网站
前端·设计
LinXunFeng5 小时前
拿竞品 App 一扫,技术栈全暴露了
android·flutter·开源
星栈5 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技5 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户47949283569155 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马8 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
wangruofeng8 小时前
从 GIF 到 AVIF:网页图片格式演进与 2026 年选型指南
前端·架构
清晨Coding9 小时前
为什么用 React Developer Tools 查看组件树这么乱?
前端·react.js
鶴哥只手遮天9 小时前
《Electron 45.2.3 内核解剖》第 4 章 补丁体系:380 个补丁改了什么
前端·electron