位置:首页 > Dart > Flutter图片添加多行文字水印的三种实现方案详解

Flutter图片添加多行文字水印的三种实现方案详解

时间:2026-08-17  |  作者:火苗实验室  |  阅读:0

最近在做一个工程评估的App,需要给拍摄的现场照片批量打上多行水印,比如项目名称、时间、地点这些信息。

网上搜了一圈,发现大多数方案要么功能太简单,要么性能拉胯。折腾了几天,总算搞出一套让人满意的方案,记录分享一下。

Flutter给图片添加多行文字水印的三种实现方案

效果目标

  • 水印在图片右下角(或底部)展示多行文字
  • 文字自带阴影,亮色背景上也清晰可辨
  • 批量处理不卡顿,支持大图
  • 代码可以直接复制用

实现方案有哪些?

在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的CanvasTextPainter绘制文字,再通过PictureRecorder录制导出。

image_utils.Image → RGBA 像素 → ui.Image → Canvas 绘制 → JPEG 输出

优点:

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

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

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

2. 字体大小自适应

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即可。

踩过的坑

  • 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缓存,即使批量处理几十张图也不会感觉到卡顿。

免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。

相关文章

更多

精选合集

更多

大家都在玩

热门话题

大家都在看

更多