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

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

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

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

效果目標

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

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

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

方案一: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);

優(yōu)點: 寫起來最簡單,和 Flutter UI 完全一致。
缺點:

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

適用場景: 只需要截一張圖、預覽展示用,不在乎原始分辨率。

方案二:image 包純 CPU 繪制

image 包自帶的 drawString 直接在像素級別寫文字。

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)點: 純 Dart 實現(xiàn),不依賴 Flutter engine,可以丟進 Isolate 完全不阻塞 UI。
缺點:

  • 內(nèi)置字體只有英文,中文默認無法顯示
  • 支持中文需要提前用 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;② 一個字號需要一套文件,無法動態(tài)縮放;③ 水印內(nèi)容里出現(xiàn)圖集里沒收錄的字,直接空白無報錯。

  • 沒有文字陰影、不支持自動換行等排版功能

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

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

借助 Flutter 的 CanvasTextPainter 繪制文字,最終通過 PictureRecorder 錄制導出。

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

優(yōu)點:

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

缺點:

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

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

三種方案對比

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

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

依賴

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

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

核心思路

整體流程如下:

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

關鍵點在于直接用像素數(shù)據(jù)構建 ui.Image,而不是把圖片先編碼成 JPEG 再解碼,節(jié)省了一次無謂的編解碼開銷。

完整實現(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,同字號復用同一個對象
  static final Map<String, TextStyle> _textStyleCache = {};

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

  // 復用 Paint 對象,避免重復創(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 計算,自適應不同分辨率
    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: 錄制結(jié)束,生成帶水印的 ui.Image
    final watermarkedImage = await recorder
        .endRecording()
        .toImage(originalImage.width, originalImage.height);

    // Step 4: 導出 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)過 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(用于計算下一行位置)
  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 字節(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));
  }

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

調(diào)用方式

// 準備水印文字,每個元素一行
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);

幾個細節(jié)說明

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

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

2. 字體大小自適應

final int fontSize = imageWidth ~/ 38;

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

3. TextPainter 緩存

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

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

4. 水印位置

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

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

踩過的坑

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

image 包的 Image.fromBytes 默認通道順序是 RGB
Flutter 導出的是 RGBA,所以一定要加 numChannels: 4,否則顏色會錯亂。

緩存要設上限
TextPainter 持有 ParagraphBuilder 等原生資源,不加上限的話批量處理幾百張圖內(nèi)存會飆升。

小結(jié)

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

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

相關文章

  • 簡析Android五大布局(LinearLayout、FrameLayout、RelativeLayout等)

    簡析Android五大布局(LinearLayout、FrameLayout、RelativeLayout等)

    這篇文章主要為大家簡單分析了Android五大布局,內(nèi)容有LinearLayout、FrameLayout、RelativeLayout、AbsoluteLayout和TableLayout的相關資料,感興趣的小伙伴們可以參考一下
    2016-06-06
  • 非常好看的android音量旋鈕

    非常好看的android音量旋鈕

    這篇文章主要為大家詳細介紹了android好看的音量旋鈕,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下
    2020-02-02
  • Android中RecyclerView實現(xiàn)多級列表展示效果

    Android中RecyclerView實現(xiàn)多級列表展示效果

    本文介紹了如何使用RecyclerView實現(xiàn)多級列表展示效果,通過點擊節(jié)點展開或收起子節(jié)點,每個子節(jié)點縮進指定距離,文章附有完整的代碼示例,感興趣的朋友跟隨小編一起看看吧
    2026-01-01
  • 利用Kotlin如何實現(xiàn)Android開發(fā)中的Parcelable詳解

    利用Kotlin如何實現(xiàn)Android開發(fā)中的Parcelable詳解

    這篇文章主要給大家介紹了關于利用Kotlin如何實現(xiàn)Android開發(fā)中的Parcelable的相關資料,并且給大家介紹了關于Kotlin使用parcelable出現(xiàn):BadParcelableException: Parcelable protocol requires a Parcelable.Creator...問題的解決方法,需要的朋友可以參考下。
    2017-12-12
  • Android實現(xiàn)定時器的五種方法實例詳解

    Android實現(xiàn)定時器的五種方法實例詳解

    這篇文章主要介紹了Android實現(xiàn)定時器的五種方法,非常不錯,具有參考借鑒價值,需要的朋友可以參考下
    2017-02-02
  • Android實現(xiàn)后臺開啟服務默默拍照功能

    Android實現(xiàn)后臺開啟服務默默拍照功能

    這篇文章主要為大家詳細介紹了Android實現(xiàn)后臺開啟服務默默拍照功能,具有一定的參考價值,感興趣的小伙伴們可以參考一下
    2018-06-06
  • Kotlin中的contract到底有什么用詳解

    Kotlin中的contract到底有什么用詳解

    Kotlin contracts是一種通知編譯器有關函數(shù)行為的方式,下面這篇文章主要給大家介紹了關于Kotlin中contract到底有什么用的相關資料,文中通過實例代碼介紹的非常詳細,需要的朋友可以參考下
    2022-01-01
  • Android App更改應用的圖標的實現(xiàn)方法

    Android App更改應用的圖標的實現(xiàn)方法

    這篇文章主要介紹了Android App更改應用的圖標的實現(xiàn)方法的相關資料,主要是通過入口Activity android:icon="@drawable/new_app_icon" 指向新的應用圖標,需要的朋友可以參考下
    2017-08-08
  • Android 組合控件實現(xiàn)布局的復用的方法

    Android 組合控件實現(xiàn)布局的復用的方法

    本篇文章主要介紹了Android 組合控件實現(xiàn)布局的復用的方法,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧
    2017-08-08
  • Android 中的 XRecyclerview的使用案例

    Android 中的 XRecyclerview的使用案例

    這篇文章主要介紹了Android 中的 XRecyclerview的使用案例,本文給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友參考下吧
    2024-01-01

最新評論

辉南县| 潼南县| 洛扎县| 阳东县| 土默特右旗| 侯马市| 静安区| 康马县| 汉中市| 克东县| 和林格尔县| 竹北市| 洛川县| 华宁县| 丰台区| 涟水县| 五河县| 鱼台县| 东兰县| 德保县| 库伦旗| 澳门| 连南| 泸州市| 芜湖县| 乐都县| 红河县| 开平市| 吴旗县| 平南县| 鸡西市| 南川市| 柳林县| 泸州市| 元阳县| 岗巴县| 体育| 桦川县| 神木县| 盐边县| 岳阳县|