最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

Flutter給圖片添加多行文字水印的三種實(shí)現(xiàn)方案

 更新時(shí)間:2026年03月05日 08:19:33   作者:明君87997  
本文介紹了三種在Flutter中為圖片批量添加水印的方法,并最終推薦了使用Canvas和TextPainter繪制水印的方案,該方案支持中文、自定義字體、文字陰影和換行等排版特性,適用于需要高質(zhì)量輸出和批量處理的場(chǎng)景,需要的朋友可以參考下

最近在做一個(gè)工程評(píng)估的 App,需要給拍攝的現(xiàn)場(chǎng)照片批量加上多行水?。?xiàng)目名稱、時(shí)間、地點(diǎn)等信息),研究了一圈發(fā)現(xiàn)網(wǎng)上大多數(shù)方案要么太簡(jiǎn)陋,要么性能拉胯。折騰了幾天,總算搞出一套還算滿意的方案,記錄一下。

效果目標(biāo)

  • 圖片右下角(或底部)顯示多行水印文字
  • 文字帶陰影,保證在亮色 圖片上也清晰可見(jiàn)
  • 批量處理時(shí)不卡頓,支持大圖
  • 可以直接復(fù)制使用

實(shí)現(xiàn)方案有哪幾種?

在 Flutter 里給圖片加水印,大體上有三條路可以走,我逐一說(shuō)一下優(yōu)缺點(diǎn),最后也說(shuō)說(shuō)我為什么選了第三種。

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

最直覺(jué)的做法,用 Stack 把水印 Text 覆蓋在 Image 上面,用 RepaintBoundary + RenderRepaintBoundary.toImage() 截圖導(dǎo)出。

// 示意
Stack(
  children: [
    Image.file(file),
    Positioned(
      bottom: 20,
      left: 20,
      child: Column(
        children: lines.map((l) => Text(l, style: style)).toList(),
      ),
    ),
  ],
)
// 截圖導(dǎo)出
final boundary = key.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3.0);

優(yōu)點(diǎn): 寫(xiě)起來(lái)最簡(jiǎn)單,和 Flutter UI 完全一致。
缺點(diǎn):

  • 必須把 Widget 渲染到屏幕(或離屏樹(shù))才能截圖,流程繁瑣
  • 分辨率受 pixelRatio 控制,原圖是 4000px 的大圖的話,截出來(lái)的質(zhì)量無(wú)法保證
  • 批量處理多張圖時(shí),需要反復(fù) build/dispose Widget,性能差

適用場(chǎng)景: 只需要截一張圖、預(yù)覽展示用,不在乎原始分辨率。

方案二:image 包純 CPU 繪制

image 包自帶的 drawString 直接在像素級(jí)別寫(xiě)文字。

import 'package:image/image.dart' as img;

final font = img.arial14;   // 內(nèi)置字體,只有英文
img.drawString(
  imageFile,
  'Hello Watermark',
  font: font,
  x: 20,
  y: imageFile.height - 40,
  color: img.ColorRgb8(255, 255, 255),
);

優(yōu)點(diǎn): 純 Dart 實(shí)現(xiàn),不依賴 Flutter engine,可以丟進(jìn) Isolate 完全不阻塞 UI。
缺點(diǎn):

  • 內(nèi)置字體只有英文,中文默認(rèn)無(wú)法顯示
  • 支持中文需要提前用 BMFont / Hiero 等工具把漢字"燒"進(jìn)位圖字體(BitmapFont),生成 .fnt + atlas PNG 后打包進(jìn) assets 加載:
final font = await img.BitmapFont.fromZip(await rootBundle.load('assets/fonts/chinese.zip'));
img.drawString(imageFile, '項(xiàng)目名稱', font: font, x: 20, y: 100);

但這條路有三個(gè)硬傷:① 常用漢字 3500 個(gè),一個(gè)字號(hào)的 atlas PNG 就可能超過(guò) 5MB;② 一個(gè)字號(hào)需要一套文件,無(wú)法動(dòng)態(tài)縮放;③ 水印內(nèi)容里出現(xiàn)圖集里沒(méi)收錄的字,直接空白無(wú)報(bào)錯(cuò)。

  • 沒(méi)有文字陰影、不支持自動(dòng)換行等排版功能

適用場(chǎng)景: 純英文水印、或水印漢字內(nèi)容完全固定且字符集可控、同時(shí)對(duì) Isolate 隔離有強(qiáng)需求的場(chǎng)景。

方案三:Canvas + TextPainter(本文方案)

借助 Flutter 的 CanvasTextPainter 繪制文字,最終通過(guò) PictureRecorder 錄制導(dǎo)出。

image_utils.Image → RGBA 像素 → ui.Image → Canvas 繪制 → JPEG 輸出

優(yōu)點(diǎn):

  • 完美支持中文、自定義字體、文字陰影、換行等所有排版特性
  • 直接操作像素,輸出分辨率和原圖完全一致
  • 通過(guò)緩存 TextPainterTextStyle,批量處理性能優(yōu)秀
  • 不需要把 Widget 渲染到屏幕

缺點(diǎn):

  • 依賴 Flutter engine(dart:ui),不能用純 Isolate 執(zhí)行,需要在 UI 線程或 compute 配合使用
  • 代碼比方案一復(fù)雜一些

適用場(chǎng)景: 需要中文水印、大圖高質(zhì)量輸出、批量處理場(chǎng)景,也就是大多數(shù)實(shí)際業(yè)務(wù)需求。

三種方案對(duì)比

Widget 截圖image 包繪制Canvas + TextPainter
中文支持???
原始分辨率?? 依賴 pixelRatio??
批量性能???
代碼復(fù)雜度
可用 Isolate???
文字陰影/換行???

綜合下來(lái),方案三是實(shí)際項(xiàng)目里最合適的選擇,下面直接看實(shí)現(xiàn)。

依賴

dependencies:
  image: ^4.0.0   # 用于圖片編碼/解碼

pubspec.yaml 里加上 image 這個(gè)包,它提供了 JPEG 編解碼能力。Flutter 自帶的 dart:ui 負(fù)責(zé) Canvas 繪制。

核心思路

整體流程如下:

原始圖片字節(jié) → image_utils.Image
     ↓
轉(zhuǎn)為 ui.Image(避免二次編解碼)
     ↓
用 Canvas + TextPainter 繪制多行文字
     ↓
錄制 Picture → 轉(zhuǎn)回 ui.Image
     ↓
導(dǎo)出 RGBA 字節(jié) → 編碼為 JPEG

關(guān)鍵點(diǎn)在于直接用像素?cái)?shù)據(jù)構(gòu)建 ui.Image,而不是把圖片先編碼成 JPEG 再解碼,節(jié)省了一次無(wú)謂的編解碼開(kāi)銷。

完整實(shí)現(xiàn)

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,同字號(hào)復(fù)用同一個(gè)對(duì)象
  static final Map<String, TextStyle> _textStyleCache = {};

  // 緩存 TextPainter,相同文字+字號(hào)+寬度直接復(fù)用
  static final Map<String, TextPainter> _textPainterCache = {};

  // 復(fù)用 Paint 對(duì)象,避免重復(fù)創(chuàng)建
  static final Paint _imagePaint = Paint()
    ..filterQuality = FilterQuality.medium;

  /// 給 image_utils.Image 添加多行水印,返回 JPEG 字節(jié)
  static Future<Uint8List> addWatermark({
    required image_utils.Image imageFile,
    required List<String> lines,
  }) async {
    // 水印從下往上排,先把順序反轉(zhuǎn)
    final watermarkLines = lines.reversed.toList();

    // 字體大小按圖片短邊的 1/38 計(jì)算,自適應(yīng)不同分辨率
    final int imageWidth =
        imageFile.width > imageFile.height ? imageFile.height : imageFile.width;
    final int fontSize = imageWidth ~/ 38;

    // Step 1: image_utils.Image → ui.Image(直接用像素,跳過(guò)編碼)
    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: 錄制結(jié)束,生成帶水印的 ui.Image
    final watermarkedImage = await recorder
        .endRecording()
        .toImage(originalImage.width, originalImage.height);

    // Step 4: 導(dǎo)出 RGBA 字節(jié)
    final ByteData? byteData =
        await watermarkedImage.toByteData(format: ui.ImageByteFormat.rawRgba);

    watermarkedImage.dispose();
    originalImage.dispose();

    if (byteData == null) throw Exception('圖片數(shù)據(jù)轉(zhuǎn)換失敗');

    // Step 5: RGBA → JPEG
    return _rgbaToJPEG(
        byteData.buffer.asUint8List(), imageFile.width, imageFile.height);
  }

  // ──────────────────────────────────────────
  //  私有方法
  // ──────────────────────────────────────────

  /// image_utils.Image → ui.Image(不經(jīng)過(guò) 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;
      }
    }
  }

  /// 繪制單行文字,返回繪制區(qū)域 Rect(用于計(jì)算下一行位置)
  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 條,超出時(shí)清理一半
      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 字節(jié) → 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));
  }

  /// 手動(dòng)清理緩存(內(nèi)存敏感場(chǎng)景可調(diào)用)
  static void clearCache() {
    _textStyleCache.clear();
    _textPainterCache.clear();
  }
}

調(diào)用方式

// 準(zhǔn)備水印文字,每個(gè)元素一行
final lines = [
  '項(xiàng)目:XX大廈改造工程',
  '位置:3號(hào)樓-東立面',
  '時(shí)間:2024-06-18 14:32',
  '拍攝人:張三',
];

// imageFile 是通過(guò) image.decodeJpg() 解碼的 image_utils.Image
final Uint8List result = await WatermarkUtils.addWatermark(
  imageFile: imageFile,
  lines: lines,
);

// 寫(xiě)入文件
await File('/path/to/output.jpg').writeAsBytes(result);

幾個(gè)細(xì)節(jié)說(shuō)明

1. 為什么不直接用drawImage+drawParagraph?

Flutter 的 Canvas 是基于 ui.Image 工作的,而 image 包解碼出來(lái)的是自己的 image_utils.Image
最樸素的做法是先把它 encodeJpgdecodeImageFromList,但這樣白白多了一次編解碼。
更好的方案是直接拿 RGBA 像素?cái)?shù)據(jù),通過(guò) ui.ImageDescriptor.raw 構(gòu)建 ui.Image,速度快很多。

2. 字體大小自適應(yīng)

final int fontSize = imageWidth ~/ 38;

取圖片短邊除以 38,這個(gè)比例在 1000px~4000px 的圖片上效果都比較好,文字不會(huì)太小也不會(huì)太大。根據(jù)實(shí)際效果可以調(diào)整這個(gè)除數(shù)。

3. TextPainter 緩存

TextPainter.layout() 是相對(duì)耗時(shí)的操作。在批量處理多張圖片時(shí),如果水印內(nèi)容相同(比如同一個(gè)項(xiàng)目的照片),可以直接復(fù)用已經(jīng) layout 好的 TextPainter,避免重復(fù)計(jì)算。

緩存鍵由 文字內(nèi)容 + 字號(hào) + 最大寬度 組成,三者相同才復(fù)用。

4. 水印位置

目前是從圖片底部向上排列,代碼里 startYimageHeight - fontSize * 2 開(kāi)始,每繪制一行就往上移一個(gè)文字高度 + 間距(20px)。

如果想改成右下角對(duì)齊,把 Offset(20, ...) 里的 20 換成 imageWidth - painter.width - 20 即可。

踩過(guò)的坑

toByteData 必須在主線程(或 Isolate 里用 compute
ui.Image.toByteData 是異步的,但它內(nèi)部依賴 Flutter engine,不能隨意放到普通 Isolate 里,否則會(huì)直接崩。

image 包的 Image.fromBytes 默認(rèn)通道順序是 RGB
Flutter 導(dǎo)出的是 RGBA,所以一定要加 numChannels: 4,否則顏色會(huì)錯(cuò)亂。

緩存要設(shè)上限
TextPainter 持有 ParagraphBuilder 等原生資源,不加上限的話批量處理幾百?gòu)垐D內(nèi)存會(huì)飆升。

小結(jié)

核心就三步:用像素?cái)?shù)據(jù)直接構(gòu)建 ui.ImageCanvas 繪文字RGBA 轉(zhuǎn) JPEG。
避開(kāi)了多余的編解碼,加上 TextPainter 緩存,即使批量處理幾十張圖也不會(huì)感覺(jué)到卡頓。

以上就是Flutter給圖片添加多行文字水印的三種實(shí)現(xiàn)方案的詳細(xì)內(nèi)容,更多關(guān)于Flutter給圖片添加文字水印的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!

相關(guān)文章

最新評(píng)論

济源市| 玉田县| 扶风县| 阳东县| 堆龙德庆县| 扶余县| 紫金县| 衡南县| 永和县| 灵丘县| 堆龙德庆县| 南丹县| 凤山县| 仁化县| 泰宁县| 延吉市| 和顺县| 镇远县| 黄龙县| 施秉县| 三明市| 广灵县| 花垣县| 正定县| 临沧市| 会同县| 河间市| 内黄县| 密云县| 吉安市| 平利县| 东平县| 白朗县| 齐齐哈尔市| 师宗县| 龙游县| 习水县| 漾濞| 鱼台县| 深水埗区| 临桂县|