为了复用flutter能力,提高代码维护性,所以将原生生成水印的操作移动到flutter层面处理,减少了原生代码的维护复杂程度。(当然在flutter层处理图片,效率没有原生层面高)

考虑:

1.首先考虑到代码侵入性,所以决定继承ImageProvider。

2.考虑到缓存的问题,如果需要每次生成新的缓存,就需要将cacheKey所相关的逻辑进行改动。

如果水印的内容如果不同,就需要每次修改缓存键,来生成不同的水印图片。

这里我将cacheKey+url作为水印图片的缓存,防止只通过url获取到的缓存图片是同一张。

Future<ui.Codec> _loadImageAsync(
    String url,
    String? cacheKey,
    StreamController<ImageChunkEvent> chunkEvents,
    DecoderCallback decode,
    BaseCacheManager cacheManager,
    int? maxHeight,
    int? maxWidth,
    Map<String, String>? headers,
    Function()? errorListener,
    Function() evictImage,
  ) async {
   
          
      ...

      final cacheDefine = "$url/cache=$cacheKey";
      Completer<ui.Codec> completer = Completer();

      /// if cacheKey is not null,first to get File from cache
      if (cacheKey != null) {
        FileInfo? cacheFile = await cacheManager.getFileFromCache(cacheDefine,
            ignoreMemCache: true);
        if (cacheFile != null && cacheFile.validTill.isAfter(DateTime.now())) {
          var bytes = await cacheFile.file.readAsBytes();
          if (!completer.isCompleted) completer.complete(await decode(bytes));
        }
      }
       
      ...
}

主要分成2个步骤:

1.首先将文件流转换成ui.Image类型的数据,是通过any2Image()来实现。

2.将需要绘制的内容绘制到合成图片的文件流,然后返回,这个是通过generateWaterMaskData()实现。

绘制好的内容可以根据新生成的缓存规则,存在缓存中以便后用。

var stream = cacheManager is ImageCacheManager
          ? cacheManager.getImageFile(url,
              maxHeight: maxHeight,
              maxWidth: maxWidth,
              withProgress: true,
              headers: headers)
          : cacheManager.getFileStream(url,
              withProgress: true, headers: headers);
      streamSubscription = stream.listen((result) async {
        if (result is DownloadProgress) {
          chunkEvents.add(ImageChunkEvent(
            cumulativeBytesLoaded: result.downloaded,
            expectedTotalBytes: result.totalSize,
          ));
        }
        if (result is FileInfo) {
          var file = result.file;
          var bytes = await file.readAsBytes();
          if (settingModel != null) {
            var image = await any2Image(Convert2ImageType.DATA, data: bytes);
            var waterCacheData =
                await generateWaterMaskData(image, settingModel!);
            var cacheBytes = waterCacheData!.buffer.asUint8List();
            cacheManager.putFile(cacheDefine, cacheBytes,
                maxAge: const Duration(days: 7),
                fileExtension: fileName?.getMine() ?? 'file');
            if (!completer.isCompleted) {
              await chunkEvents.close();
              completer.complete(await decode(cacheBytes));
            }
          } else {
            if (!completer.isCompleted) {
              await chunkEvents.close();
              completer.complete(await decode(bytes));
            }
          }
          streamSubscription?.cancel();
        }
      });

首先将文件流转换成ui.Image,这个过程很简单,根据类型来判定传入的数据是文件流还是图片,如果是图片路径则需要先转换成文件流。主要是要注意生成的图片宽高,最好可以按比例缩小一部分,否则可能会影响到水印生成的速度,这里我设置了0.85的缩放系数。

Future<ui.Image> any2Image(Convert2ImageType type,
      {String? path,
      Uint8List? data,
      int? width,
      int? height,
      double scaleRatio = 0.85}) async {
    late Uint8List codecData;
    if (type == Convert2ImageType.DATA) {
      codecData = data!;
    } else {
      codecData = File(path!).readAsBytesSync();
    }
    late ui.Codec codec;
    if (width != null && width != 0 && height != null && height != 0) {
      codec = await ui.instantiateImageCodec(codecData,
          targetWidth: width * scaleRatio ~/ 1,
          targetHeight: height * scaleRatio ~/ 1);
    } else {
      codec = await ui.instantiateImageCodec(codecData);
    }
    ui.FrameInfo fi =
        await codec.getNextFrame().whenComplete(() => codec.dispose());
    return fi.image;
  }

generateWaterMaskData()实现逻辑:

1.考虑到水印不能和图片的整体颜色相近导致看不清,所以这时使用palette_generator库,截取了一部分的图片区域作为样本,取得样本中出现次数最多的色号,然后判断RGB值是偏明还是偏暗,如果偏暗则水印的颜色使用白色,反之亦然。

2.原文件图片作为背景,绘制到一个新的Canvas中。

3.为了水印生成的层次清晰,需要将图片根据宽度分成N等份,然后根据行数进行交错渲染。同时绘制时,会将Canvas进行一定角度的旋转,然后将图片绘制上。结束绘制后Canvas必须要按照原来的角度旋转回来,坐标系会根据根据当前的角度进行累加,导致绘制效果异常。

4.将叠加绘制的Canvas转换成文件流,此时可以选择根据原文件流的尺寸进行部分缩放,否则文件流大小将影响整体的处理速度。最后将图片流占用的内存释放即可。

Future<ByteData?> generateWaterMaskData(
      ui.Image originImage, ImageWaterMarkSettingModel settingModel) async {
    PaletteGenerator? res = await PaletteGenerator.fromImage(originImage,
        region: Rect.fromCenter(
            center: Offset((originImage.width / 4), (originImage.height / 4)),
            width: (originImage.width / 4),
            height: (originImage.height / 4)));
    PaletteColor? paletteColor = res.dominantColor;
    var i = 0;
    if ((paletteColor?.color.red ?? 0) < 128) {
      i++;
    }
    if ((paletteColor?.color.blue ?? 0) < 128) {
      i++;
    }
    if ((paletteColor?.color.green ?? 0) < 128) {
      i++;
    }
    Color renderColor =
        i >= 2 ? settingModel.lightColor : settingModel.darkColor;

    ///首先根据图片宽度分为四等份
    settingModel.textWidth = originImage.width / 4;

    ///根据配置第一项生成水印(为了获取水印渲染高度)
    var paragraph = _generateText(settingModel!, renderColor);

    ///startPosition 水印第一个元素与x、y轴间距
    ///verticalDistance 水印行y轴间距
    double startPosition = 25, verticalDistance = 200;
    late int height;

    ///获取图片高度
    height = paragraph.height ~/ 1;

    verticalDistance += (height / 600).ceil() * 50;

    ///计算图片可以配置多少行水印(当配置的角度非0时,由于倾斜的问题,可能会导致边角出现空余,所以角度不为0时,需要多渲染一行)
    var heightNumber = settingModel.radians != 0
        ? (originImage.height - height) ~/ (height + verticalDistance) + 2
        : (originImage.height - height) ~/ (height + verticalDistance) + 1;
    ImageWaterMarkSettingModel tempSetting = settingModel;
    double widthUnit = originImage.width / tempSetting.divided;
    double heightUnit = originImage.height / tempSetting.divided;

    var scaleFont = (originImage.width / 800) * settingModel.fontSize;

    var textWidth = (originImage.width - startPosition) / 4;

    var templateParagraph = _generateText(
        ImageWaterMarkSettingModel(
            fontSize: scaleFont,
            textStyle: tempSetting.textStyle,
            text: tempSetting.text,
            textWidth: textWidth),
        renderColor);

    ///再创建canvas
    ui.PictureRecorder pictureRecorder = ui.PictureRecorder();
    ui.Canvas canvas = ui.Canvas(pictureRecorder);
    ui.Paint paint = ui.Paint();

    ///原图大小绘制在最底层
    canvas.drawImage(originImage, ui.Offset.zero, paint);

    ///一行默认渲染4个
    for (var i = -1; i < 5; i++) {
      for (var j = 0; j < heightNumber; j++) {
        ImageWaterMarkSettingModel settings = ImageWaterMarkSettingModel();
        settings.asset = tempSetting.asset;
        settings.divided = tempSetting.divided;
        settings.radians = tempSetting.radians;
        settings.text = tempSetting.text;
        settings.fontSize = scaleFont;
        settings.textStyle = tempSetting.textStyle;
        settings.textWidth = textWidth;
        settings.type = tempSetting.type;

        ///由于要形成交错效果,所以每行的间距都会出现一段距离
        settings.dx = (startPosition +
                i * ((originImage.width - startPosition) / 2) +
                (j % 2 == 0 ? 0 : 0 - (originImage.width - startPosition) / 2) /
                    2) /
            widthUnit;
        settings.dy =
            (startPosition + j * (height + verticalDistance)) / heightUnit;

        ///如果是隔行,且为第一个元素,且角度为0,则该元素不需要渲染
        if (j % 2 != 0 && i == 0 && settings.radians == 0) continue;

        canvas.rotate(-settings.radians);
        // if (waterMarkList[i].runtimeType == ui.Paragraph) {
        ///canvas以左上角点为原点建立坐标系
        //以下出现2次rotate,因为旋转canvas后,整个画布坐标系会一起旋转,如不重置回原来,则下一次旋转的角度将根据此次角度叠加
        canvas.drawParagraph(templateParagraph,
            Offset(widthUnit * settings.dx, heightUnit * settings.dy));
        // }
        canvas.rotate(settings.radians);
      }
    }

    ///生成与原图尺寸一样的合成图片
    ui.Image generateImage = await pictureRecorder
        .endRecording()
        .toImage(originImage.width * 0.9 ~/ 1, originImage.height * 0.9 ~/ 1);
    ByteData? data = await generateImage
        .toByteData(format: ui.ImageByteFormat.png)
        .whenComplete(() {
      originImage.dispose();
      generateImage.dispose();
    });
    return Future.value(data);
  }

最后该情况还有2点问题暂时没有解决:

1.如何加快整个过程的速度,目前通过断点和日志,可以发现大部分的耗时都是在图片转文件流的处理上,目前的解决的方案:

        1.1通过缩小图片的尺寸,获取到更小的文件流大小来达到效果。

        1.2通过isolate来分批处理不同逻辑。但是该方法发现最耗时的部分,无法放在子isolate中处 理。

2.另一个问题是,渲染的水印如果是文字,文字的大小似乎与系统的缩放比无关,导致渲染出来的字体大小不太完美。