Flutter- 文本绘制与离屏渲染实现水印功能

1,029 阅读3分钟

在实际场景中,大多数情况下水印是需要铺满整个屏幕的,如果不需满铺,通常直接用组件组合即可实现。

水印组件WaterMark

通过绘制一个单元水印,然后让它在整个水印组件的背景中重复即可实现满铺效果。因此可以直接使用DecoratedBox,它拥有背景图重复功能。重复的问题处理了,主要问题是绘制单元水印,为了灵活好扩展,定义一个水印画笔接口,这样可以预置一些常用的画笔来满足大多数场景,同时如果有自定义需求也可以通过自定义画笔实现。

定义一个画笔:

    //定义水印画笔
    abstract class SSLWaterMarkPainter{
      //绘制单元水印,完整的水印由单元水印重复平铺后组成,返回值为单元水印占用空间的大小
      //[devicePixelRatio]:因为最终要将内容保存为图片,
      // 所以在绘制时需要根据屏幕的DPR来放大,以防失真
      Size paintUnit(Canvas canvas, double devicePixelRatio);
      //是否需要重绘,当画笔状态发生变化时返回true进行重绘
      bool shouldRepaint(covariant SSLWaterMarkPainter oldPainter) => true;
    }

WaterMark定义:

class SSLWaterMark extends StatefulWidget{
  final SSLWaterMarkPainter painter;
  final ImageRepeat repeat;
  const SSLWaterMark({
    Key? key,
    this.repeat = ImageRepeat.repeat,
    required this.painter,
  }):super(key: key);
  @override
  State<StatefulWidget> createState() {
    // TODO: implement createState
    return SSLWaterMarkState();
  }
}

class SSLWaterMarkState extends State<SSLWaterMark>{
  late Future<MemoryImage> memoryImageFuture;
  @override
  void initState() {
    // TODO: implement initState
    memoryImageFuture = getWaterMarkImage();
    super.initState();
  }

  //离线缓存逻辑
  Future<MemoryImage> getWaterMarkImage() async{
    final recorder = PictureRecorder();
    final canvas = Canvas(recorder);
    //获取屏幕的像素和实际坐标的比例,iOS中的dpt
    double dpr = window.devicePixelRatio;
    //获取绘制返回的尺寸
    final Size size = widget.painter.paintUnit(canvas, dpr);
    final picture = recorder.endRecording();
    //生成绘制的图片
    final img = await picture.toImage(size.width.ceil(), size.height.ceil());
    //将图片转成byte数据
    final byteData = await img.toByteData(format: ImageByteFormat.png);
    final pngBytes = byteData!.buffer.asUint8List();
    //缓存图片,传递给渲染层
    return MemoryImage(pngBytes);

  }

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return SizedBox.expand(
      child: FutureBuilder(
        future: memoryImageFuture,
        builder: (BuildContext context, AsyncSnapshot snapshot){
          if (snapshot.connectionState != ConnectionState.done){
            return Container();
          }else{
            return DecoratedBox(
                decoration: BoxDecoration(
                  image: DecorationImage(
                    image: snapshot.data,//显示缓存的数据
                    repeat: widget.repeat,//重复类型
                    alignment: Alignment.topLeft,
                  )
                )
            );
          }
        },
      ),
    );
  }
  @override
  void didUpdateWidget(covariant SSLWaterMark oldWidget) {
    // TODO: implement didUpdateWidget
    if (widget.painter.runtimeType != oldWidget.painter.runtimeType || widget.painter.shouldRepaint(oldWidget.painter)){
      //释放之前的缓存
      memoryImageFuture.then((value) => value.evict());
      memoryImageFuture = getWaterMarkImage();
    }
    super.didUpdateWidget(oldWidget);
  }

  @override
  void dispose() {
    // TODO: implement dispose
    //释放图片缓存
    memoryImageFuture.then((value) => value.evict());
    super.dispose();
  }
}

调用Canvas API后,实际上产生的是一系列绘制指令,绘制指令执行后才能获取绘制结果,而PictureRecorder就是一个绘制指令记录器,它可以记录一段时间内所有绘制指令,通过调用recorder.endRecording()方法来获取记录的绘制指令,该方法返回一个picture对象,是绘制指令的载体,它有一个toImage方法,调用后会执行绘制指令获得绘制的像素结果(ui.image对象),之后就可以将像素结果转为png格式的数据并缓存再MemoryImage中.

定义绘制文本

class SSLTextWaterPainter extends SSLWaterMarkPainter{
  double rotate;
  TextStyle textStyle;
  EdgeInsets padding;
  String text;

  SSLTextWaterPainter({
    Key? key,
    double? rotate,
    EdgeInsets? padding,
    TextStyle? textStyle,
    required this.text,
  }):assert(rotate == null || rotate >= -90 && rotate <= 90),
  rotate = rotate ?? 0,
  padding = padding ?? const EdgeInsets.all(10.0),
  textStyle = textStyle ?? const TextStyle(
    color: Colors.black,
    fontSize: 14,
  );

  @override
  Size paintUnit(Canvas canvas, double devicePixelRatio) {
    //TODO: implement paintUnit
    //使用系统的TextPainter
    TextPainter painter = TextPainter(
      textDirection: TextDirection.ltr,
      textScaleFactor: devicePixelRatio
    );
    painter.text = TextSpan(text: text,style:  textStyle);
    painter.layout();
    final textWidth = painter.width;
    final textHeight = painter.height;
    debugPrint("ssl paint width$textWidth height$textHeight");
    painter.paint(canvas, Offset.zero);
    return Size(textWidth, textHeight);
  }
  @override
  bool shouldRepaint(covariant SSLTextWaterPainter oldPainter) {
    // TODO: implement shouldRepaint
    return oldPainter.text != text || oldPainter.textStyle != textStyle;
  }

}

测试示例:

class SSLTextWaterRoute extends StatelessWidget{
  const SSLTextWaterRoute({Key? key}):super(key: key);

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return Scaffold(
      appBar: AppBar(
        title: const Text("SSL Text water"),
      ),
      body: Stack(
        children: [
          IgnorePointer(
            child: SSLWaterMark(
                painter: SSLTextWaterPainter(
                  text: "ssl white",
                  textStyle: const TextStyle(color: Colors.lightGreen),
                )
            ),
          ),
        ],
      ),
    );
  }
}

此时一个简单的水印功能已经实现了,但是实际中可能都需要对水印进行旋转。下面进行优化。

带角度的水印

先看一张图,看下旋转后宽高的计算: image.png

上代码:

class TextWaterMarkPainter extends SSLWaterMarkPainter{
  double rotate;
  TextStyle textStyle;
  EdgeInsets padding;
  String text;
  TextWaterMarkPainter({
    Key? key,
    double? rotate,
    EdgeInsets? padding,
    TextStyle? textStyle,
    required this.text,
  }): assert(rotate == null || rotate >= -90 && rotate <= 90),
  rotate = rotate ?? 0,
  padding = padding ?? const EdgeInsets.all(10.0),
  textStyle = textStyle ?? const TextStyle(color: Colors.black,fontSize: 14);

  @override
  Size paintUnit(Canvas canvas, double devicePixelRatio) {
    // TODO: implement paintUnit
    TextPainter painter = TextPainter(
      textDirection: TextDirection.ltr,
      textScaleFactor: devicePixelRatio,//根据屏幕放大
    );
    painter.text = TextSpan(text: text,style: textStyle);
    painter.layout();
  //获取未旋转的文本宽高
    final textWidth = painter.width;
    final textHeight = painter.height;
    final radians = math.pi * rotate / 180;
    final orgSin = math.sin(radians);
    final sin = orgSin.abs();
    final cos = math.cos(radians).abs();
    final width = textWidth*cos;
    final height = textWidth*sin;
    final adjustWidth = textHeight * sin;
    final adjustHeight = textHeight * cos;

    if (orgSin >= 0){
      canvas.translate(adjustWidth + padding.left, padding.top);
    }else{
      canvas.translate(padding.left, height + padding.top);
    }
    canvas.rotate(radians);
    painter.paint(canvas, Offset.zero);
    //计算最终需要绘制宽高
    Size size = Size(width + adjustWidth + padding.horizontal, height + adjustHeight + padding.vertical);
    debugPrint("ssl paint size $size\n");
    return size;
  }

  @override
  bool shouldRepaint(covariant TextWaterMarkPainter oldPaint) {
    // TODO: implement shouldRepaint
    // return super.shouldRepaint(oldPaint);
    return oldPaint.rotate != rotate ||
        oldPaint.textStyle != textStyle ||
        oldPaint.text != text ||
        oldPaint.padding != padding;
  }

}

测试代码:

class TestTextWater extends StatefulWidget{
  const TestTextWater({Key? key}):super(key: key);

  @override
  State<StatefulWidget> createState() {
    // TODO: implement createState
    return TestTextWaterState();
  }
}

class TestTextWaterState extends State<TestTextWater>{
  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return Scaffold(
      appBar: AppBar(
        title: const Text("SSL Water Mark"),
      ),
      body:
        Stack(
          children: [
            wTextPainterTest(),
            Center(
              child: ElevatedButton(onPressed: (){
                debugPrint("Chick Button");
              }, child: const Text("Chick Button")
              ),
            ),
            IgnorePointer(
              child: SSLWaterMark(
                painter: TextWaterMarkPainter(
                  text: "SSL White Monkey",
                  textStyle: const TextStyle(
                    fontSize: 15,
                    color: Colors.purple,
                    fontWeight: FontWeight.w200
                  ),
                  rotate: -20,
                ),
              ),
            ),
          ],
        ),
    );
  }

  Widget wTextPainterTest() {
    // 我们想提前知道 Text 组件的大小
    Text text = const Text('SSL Water Mark', style: TextStyle(fontSize: 15));
    // 使用 TextPainter 来测量
    TextPainter painter = TextPainter(textDirection: TextDirection.ltr);
    // 将 Text 组件文本和样式透传给TextPainter
    painter.text = TextSpan(text: text.data,style:text.style);
    // 开始布局测量,调用 layout 后就能获取文本大小了
    painter.layout();
    // 自定义组件 AfterLayout 可以在布局结束后获取子组件的大小,我们用它来验证一下
    // TextPainter 测量的宽高是否正确
    return SSLAfterLayout(
      callback: (SSLRenderAfterLayout value) {
        // 输出日志
        debugPrint('text size(painter): ${painter.size}');
        debugPrint('text size(after layout): ${value.size}');
      },
      child: text,
    );
  }
}

再次升级一下,实现交错文本水印:

class SSLStaggerTextPainter extends SSLWaterMarkPainter{
  String text1;
  String text2;
  //旋转角度
  double? rotate;
  //文本风格
  TextStyle? textStyle;
  //边距
  EdgeInsets? padding1;
  EdgeInsets? padding2;
  //文本排列方向
  Axis staggerAxis;
  
  SSLStaggerTextPainter({
    required this.text1,
    this.padding1,
    this.padding2 = const EdgeInsets.all(30),
    this.rotate,
    this.textStyle,
    this.staggerAxis = Axis.vertical,
    String? text2,
  }):text2 = text2 ?? text1;
  
  @override
  ui.Size paintUnit(ui.Canvas canvas, double devicePixelRatio) {
    // TODO: implement paintUnit
    final TextWaterMarkPainter painter = TextWaterMarkPainter(
        text: text1,
      padding: padding1,
      rotate: rotate ?? 0,
      textStyle: textStyle
    );
    //绘制第一个文本水印前保存画布状态,因为再绘制过程中可能会平移或旋转画布
    canvas.save();
    //绘制第一个文本水印
    final size = painter.paintUnit(canvas, devicePixelRatio);
    //绘制完毕后回复画布状态
    canvas.restore();
    bool vertical = staggerAxis == Axis.vertical;
    canvas.translate(vertical?0:size.width, vertical? size.height:0);
    painter
    ..padding = padding2!
    ..text = text2;
    final size2 = painter.paintUnit(canvas, devicePixelRatio);
    return Size(vertical? math.max(size.width, size2.width) : size.width + size2.width, vertical?size.height + size2.height:math.max(size.height, size2.height));
  }
  @override
  bool shouldRepaint(covariant SSLStaggerTextPainter oldPainter) {
    // TODO: implement shouldRepaint
    return oldPainter.rotate != rotate ||
    oldPainter.text1 != text1 ||
    oldPainter.text2 != text2 ||
    oldPainter.staggerAxis != staggerAxis ||
    oldPainter.padding1 != padding1 ||
    oldPainter.padding2 != padding2 ||
    oldPainter.textStyle != textStyle;
  }
}

注意:

  • 在绘制第一个文本之前需要先调用canvas.save保存画布状态,因为绘制过程中可能会平移或旋转画布,在绘制第二个文本之前回复画布状态,并将Canvas平移至第二个文本水印的起始绘制点。
  • 两个文本可以沿水平方向排列也可以沿竖直方向排列,不同的排列规则会影响最终水印单元的大小。
  • 交错的偏移通过padding2来指定。