当前位置: 代码网 > it编程>App开发>flutter > Flutter给图片添加多行文字水印的三种实现方案

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

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

最近在做一个工程评估的 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 的 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<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
最朴素的做法是先把它 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.imagecanvas 绘文字rgba 转 jpeg
避开了多余的编解码,加上 textpainter 缓存,即使批量处理几十张图也不会感觉到卡顿。

以上就是flutter给图片添加多行文字水印的三种实现方案的详细内容,更多关于flutter给图片添加文字水印的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com