Раскраска SVG картинки во flutter, и почему я решил отключить Impeller

от автора

Привет всем, в прошлой статье я рассказал, как можно реализовать раскраску для растровых изображений, и сегодня расскажу, как можно похожее реализовать и для векторных картинок, а также какое влияние может оказать выбор графического движка в этом кейсе.


Реализация раскраски

Требования следующие:

  1. Векторная картинка загружается по url

  2. Можно масштабировать и перемещать картинку

  3. При нажатии на область она должна закрашиваться в нужный цвет

Про векторные изображения

Прежде чем приступить к основной реализации, вспомним, что из себя представляет векторная картинка.

SVG (Scalable Vector Graphics) — это формат векторной графики, использующийся для описания изображений с помощью XML-подобных тегов. Векторные изображения в формате SVG хорошо масштабируются на любой размер без потери качества, в отличие от растровых изображений, таких как JPEG или PNG, качество которых ухудшается при увеличении.

Пример svg

Пример svg

Если заглянуть в код файла SVG, изображающего картинку выше, то можно увидеть примерно следующее:

<?xml version="1.0" encoding="UTF-8"?> <svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">   <!-- Красный прямоугольник с закругленными углами -->   <rect x="10" y="10" width="50" height="50" rx="10" ry="10" fill="red" stroke="black" stroke-width="2"/>    <!-- Синий круг -->   <circle cx="100" cy="50" r="20" fill="blue" stroke="green" stroke-width="3"/>    <!-- Желтый эллипс -->   <ellipse cx="160" cy="50" rx="20" ry="10" fill="yellow" stroke="pink" stroke-width="2"/>    <!-- Черная линия -->   <line x1="10" y1="100" x2="50" y2="150" stroke="black" stroke-width="4"/>    <!-- Полилиния (ломаная линия) -->   <polyline points="60 100, 90 120, 120 100, 150 120" stroke="orange" stroke-width="2" fill="none"/>    <!-- Многоугольник (закрытая фигура) -->   <polygon points="160 100, 190 120, 160 140, 130 120" stroke="purple" fill="lime" stroke-width="1"/>    <!-- Путь (комплексная фигура) -->   <path d="M10 160 Q 50 200, 90 160 T 170 160" fill="none" stroke="brown" stroke-width="2"/> </svg> 
  1. XML-объявление: Опционально, указывает, что файл является XML-файлом. Например: <?xml version="1.0" encoding="UTF-8"?>.

  2. SVG тег: Корневой элемент, определяющий пространство, в котором будет отображаться графика. Например: <svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">. Иногда это пространство может быть задано такviewBox="0 0 200 200".

  3. Элементы графики: Внутри SVG тега размещаются элементы, которые описывают изображение. Это могут быть:

    • Примитивы (линии, круги, эллипсы, прямоугольники и т.д.): Например, <rect x="10" y="10" width="50" height="50" rx="10" ry="10"/> для прямоугольника.

    • Пути (<path>): Самый мощный элемент SVG, описывающий сложные формы и кривые.

    • Текст (<text>): Для добавления текста.

    • Группы (<g>): Для группировки элементов SVG.

  4. Атрибуты: Определяют свойства элементов, такие как координаты, размеры, стили заливки, обводки, трансформации. Например, fill="red" задает красный цвет заливки.

    И многие другие. На них не будем заострять внимание.

Можно заметить, что набор элементов очень напоминает апи Canvas во flutter, который используется в CustomPaint.

Реализация

Для упрощения в этом кейсе рассмотрим картинки, у которых в качестве основных элементов только замкнутые path.

Шаги:

  •  Во-первых, создал два класса: VectorImage, который будет хранить все элементы картинки и её размер, PathSvgItem, который будет хранить Path path и цвет заливки Color? fill

// models.dart  import 'dart:ui';  class VectorImage {   const VectorImage({     required this.items,     this.size,   });    final List<PathSvgItem> items;   final Size? size; }  class PathSvgItem {   const PathSvgItem({     required this.path,     this.fill,   });    final Path path;   final Color? fill; }
  • Во-вторых, для отрисовки реализуем CustomPainter

// svg_painter.dart  import 'package:flutter/material.dart';  import 'models.dart';  class SvgPainter extends CustomPainter {   const SvgPainter(this.pathSvgItem);   final PathSvgItem pathSvgItem;    @override   void paint(Canvas canvas, Size size) {     Path path = pathSvgItem.path;      final paint = Paint();     paint.color = pathSvgItem.fill ?? Colors.white;     paint.style = PaintingStyle.fill;      canvas.drawPath(path, paint);   }    @override   bool shouldRepaint(SvgPainter oldDelegate) => false; }
  • Получение SVG файла по урл в виде строки:

// utils.dart  import 'package:http/http.dart' as http;  Future<String> getSvgData(String url) async {   final http.Response data = await http.get(Uri.parse(url));   return data.body; }
  • Парсинг SVG файла. Зная приблизительную структуру SVG файла и используя библиотеку для чтения XML файлов, можем достать элементы и атрибуты. В том же файле можно добавить следующее:

// utils.dart  import 'package:flutter/material.dart'; import 'package:path_drawing/path_drawing.dart'; import 'package:xml/xml.dart';  import 'models.dart';  VectorImage getVectorImageFromStringXml(String svgData) {   List<PathSvgItem> items = [];    // step 1: parse the xml   XmlDocument document = XmlDocument.parse(svgData);    // step 2: get the size of the svg   Size? size;   String? width = document.findAllElements('svg').first.getAttribute('width');   String? height = document.findAllElements('svg').first.getAttribute('height');   String? viewBox = document.findAllElements('svg').first.getAttribute('viewBox');   if (width != null && height != null) {     width = width.replaceAll(RegExp(r'[^0-9.]'), '');     height = height.replaceAll(RegExp(r'[^0-9.]'), '');     size = Size(double.parse(width), double.parse(height));   } else if (viewBox != null) {     List<String> viewBoxList = viewBox.split(' ');     size = Size(double.parse(viewBoxList[2]), double.parse(viewBoxList[3]));   }    // step 3: get the paths   final List<XmlElement> paths = document.findAllElements('path').toList();   for (int i = 0; i < paths.length; i++) {     final XmlElement element = paths[i];      // get the path     String? pathString = element.getAttribute('d');     if (pathString == null) {       continue;     }     Path path = parseSvgPathData(pathString);      // get the fill color     String? fill = element.getAttribute('fill');     String? style = element.getAttribute('style');     if (style != null) {       fill = _getFillColor(style);     }      // get the transformations     String? transformAttribute = element.getAttribute('transform');     double scaleX = 1.0;     double scaleY = 1.0;     double? translateX;     double? translateY;     if (transformAttribute != null) {       ({double x, double y})? scale = _getScale(transformAttribute);       if (scale != null) {         scaleX = scale.x;         scaleY = scale.y;       }       ({double x, double y})? translate = _getTranslate(transformAttribute);       if (translate != null) {         translateX = translate.x;         translateY = translate.y;       }     }      final Matrix4 matrix4 = Matrix4.identity();     if (translateX != null && translateY != null) {       matrix4.translate(translateX, translateY);     }     matrix4.scale(scaleX, scaleY);      path = path.transform(matrix4.storage);      items.add(PathSvgItem(       fill: _getColorFromString(fill),       path: path,     ));   }    return VectorImage(items: items, size: size); }  ({double x, double y})? _getScale(String data) {   RegExp regExp = RegExp(r'scale\(([^,]+),([^)]+)\)');   var match = regExp.firstMatch(data);    if (match != null) {     double scaleX = double.parse(match.group(1)!);     double scaleY = double.parse(match.group(2)!);      return (x: scaleX, y: scaleY);   } else {     return null;   } }  ({double x, double y})? _getTranslate(String data) {   RegExp regExp = RegExp(r'translate\(([^,]+),([^)]+)\)');   var match = regExp.firstMatch(data);    if (match != null) {     double translateX = double.parse(match.group(1)!);     double translateY = double.parse(match.group(2)!);      return (x: translateX, y: translateY);   } else {     return null;   } }  String? _getFillColor(String data) {   RegExp regExp = RegExp(r'fill:\s*(#[a-fA-F0-9]{6})');   RegExpMatch? match = regExp.firstMatch(data);    return match?.group(1); }  Color _hexToColor(String hex) {   final buffer = StringBuffer();   if (hex.length == 6 || hex.length == 7) buffer.write('ff');   buffer.write(hex.replaceFirst('#', ''));   return Color(int.parse(buffer.toString(), radix: 16)); }  Color? _getColorFromString(String? colorString) {   if (colorString == null) return null;   if (colorString.startsWith('#')) {     return _hexToColor(colorString);   } else {     switch (colorString) {       case 'red':         return Colors.red;       case 'green':         return Colors.green;       case 'blue':         return Colors.blue;       case 'yellow':         return Colors.yellow;       case 'white':         return Colors.white;       case 'black':         return Colors.black;       default:         return Colors.transparent;     }   } } 

Из-за наличия огромного многообразия атрибутов приходится городить такую портянку из своих хэлперов. Здесь покрываются далеко не все кейсы, и я уверен, что для всего этого какой-нибудь умный человек уже давно сделал библиотеку. Но я такой не нашел и просто воспользовался ChatGPT для бойлерплейта.

Что здесь происходит пошагово, императивно, хардкорно:

  1. Парсинг строки в XML объект, из которого можно доставать нужные нам данные

  2. Определяем размеры изображения либо через заданные width и height, либо через viewBox

  3. Также делаем предположение, что все нужные нам элементы представлены в виде path, если это не так, то пропускаем. Для преобразования строки в объект Path воспользуемся методом библиотеки path_drawing:
    Path parseSvgPathData(String svg);

  4. Для каждого элемента достаем необходимые атрибуты:
    — цвет, который может быть в виде строки «red», «blue» или в виде hex
    — трансформации, без указания которых линии будут рисоваться от координаты (0;0) и в неправильном масштабе

  5. В конце концов результат сводим в кучу и получаем на выходе один объект VectorImage

При желании можно добавить обработку таких атрибутов, как strokeWidth, strokeJoin и др

  • Результат, который можно тянуть в другие места:

Future<VectorImage> getVectorImage(String url) async {   final String svgData = await getSvgData(url);   final VectorImage vectorImage = getVectorImageFromStringXml(svgData);   return vectorImage; }
  • Наконец, реализуем сам экран для отображения полученной картинки:

import 'package:flutter/material.dart'; import 'package:painter/features/coloring_svg/svg_painter.dart';  import 'models.dart'; import 'utils.dart';  class ColoringSvgScreen extends StatefulWidget {   const ColoringSvgScreen({super.key});    @override   State<ColoringSvgScreen> createState() => _ColoringSvgScreenState(); }  class _ColoringSvgScreenState extends State<ColoringSvgScreen> {      @override   void initState() {     _init();     super.initState();   }    Size? _size;   List<PathSvgItem>? _items;    static const urlDogWithSmile =       'https://vk.com/doc223802256_674334116?hash=407AqZBhX6zQrqcI3cGxCZdJGaZDbv1ywq65EZ8eHqH&dl=5KapGZXnEYzXOUUA977vWJoTB0kvZSrUzp7drp4qPIX';    Future<void> _init() async {     final value = await getVectorImage(urlDogWithSmile);     setState(() {       _items = value.items;       _size = value.size;     });   }    @override   Widget build(BuildContext context) {     return Scaffold(       appBar: AppBar(title: const Text('Coloring SVG')),       body: _items == null || _size == null           ? const Center(child: CircularProgressIndicator())           : InteractiveViewer(               child: Center(                 child: FittedBox(                   // RepaintBoundary should be used to prevent rebuilds                   // during transformations with InteractiveViewer                   child: RepaintBoundary(                      child: SizedBox(                       width: _size!.width,                       height: _size!.height,                       child: Stack(                         children: [                           for (int index = 0; index < _items!.length; index++)                             SvgPainterImage(                               item: _items![index],                             )                         ],                       ),                     ),                   ),                 ),               ),             ),     );   } }  class SvgPainterImage extends StatelessWidget {   const SvgPainterImage({     super.key,     required this.item,   });   final PathSvgItem item;    @override   Widget build(BuildContext context) {     return CustomPaint(       painter: SvgPainter(item),     );   } } 
Результат

Результат

Таким образом, выполнены требования 1 и 2.

Как отловить, на какую из фигур тапнули?

В CustomPainter есть метод bool? hitTest(Offset position), который можно использовать для этих целей.

class SvgPainter extends CustomPainter {   const SvgPainter(this.pathSvgItem, this.onTap);   final PathSvgItem pathSvgItem;   final VoidCallback onTap;    @override   void paint(Canvas canvas, Size size) {     Path path = pathSvgItem.path;      final paint = Paint();     paint.color = pathSvgItem.fill ?? Colors.white;     paint.style = PaintingStyle.fill;      canvas.drawPath(path, paint);   }    @override   bool? hitTest(Offset position) {     Path path = pathSvgItem.path;     if (path.contains(position)) {       onTap();       return true;     }     return super.hitTest(position);   }    @override   bool shouldRepaint(SvgPainter oldDelegate) {     return pathSvgItem != oldDelegate.pathSvgItem;   } } 
  • Здесь добавлен коллбэк onTap(), который обрабатывает нажатие, если Offset position находится внутри контура Path path. Следует также вернуть true, чтобы на виджетах, находящихся ниже в стеке, не отрабатывал метод hitTest(). Это сделано на случай, если окажется, что path.contains(position) == true для нескольких PathSvgItem.

  • В методе shouldRepaint также добавлено условие, когда нужно перерисовать виджет.

Обновленный код виджета ColoringSvgScreen:

class ColoringSvgScreen extends StatefulWidget {   const ColoringSvgScreen({super.key});    @override   State<ColoringSvgScreen> createState() => _ColoringSvgScreenState(); }  class _ColoringSvgScreenState extends State<ColoringSvgScreen> {   @override   void initState() {     _init();     super.initState();   }    Size? _size;   List<PathSvgItem>? _items;    static const urlDogWithSmile =       'https://vk.com/doc223802256_674334116?hash=407AqZBhX6zQrqcI3cGxCZdJGaZDbv1ywq65EZ8eHqH&dl=5KapGZXnEYzXOUUA977vWJoTB0kvZSrUzp7drp4qPIX';    Future<void> _init() async {     final value = await getVectorImage(urlDogWithSmile);     setState(() {       _items = value.items;       _size = value.size;     });   }    void _onTap(int index) {     setState(() {       _items![index] = _items![index].copyWith(         fill: Colors.red,       );     });   }    @override   Widget build(BuildContext context) {     return Scaffold(       appBar: AppBar(title: const Text('Coloring SVG')),       body: _items == null || _size == null           ? const Center(child: CircularProgressIndicator())           : InteractiveViewer(               child: Center(                 child: FittedBox(                   // RepaintBoundary should be used to prevent rebuilds                   // during transformations with InteractiveViewer                   child: RepaintBoundary(                     child: SizedBox(                       width: _size!.width,                       height: _size!.height,                       child: Stack(                         children: [                           for (int index = 0; index < _items!.length; index++)                             SvgPainterImage(                               item: _items![index],                               size: _size!,                               onTap: () => _onTap(index),                             )                         ],                       ),                     ),                   ),                 ),               ),             ),     );   } }  class SvgPainterImage extends StatelessWidget {   const SvgPainterImage({     super.key,     required this.item,     required this.size,     required this.onTap,   });   final PathSvgItem item;   final Size size;   final VoidCallback onTap;    @override   Widget build(BuildContext context) {     return CustomPaint(       size: size,       foregroundPainter: SvgPainter(item, onTap),     );   } } 
  • Добавлен метод void _onTap(int index), в котором вся логика изменения цвета в конкретном PathSvgItem item. В виджете SvgPainterImage в реализации CustomPaint нужно передавать размеры Size size svg картинки, чтобы отрабатывал hitTest(). Иначе он отрабатывать не будет, потому что по умолчанию считает размеры равными Size.zero, и из-за этого по виджету фактически невозможно попасть

  • Заменен painter на foregroundPainter. Это сделано потому что при изначальном варианте некорректно происходила обработка нажатия.


Мы реализовали раскраску векторного изображения. В примере была относительно простая картинка. Но что если попробовать на более сложных и детализированных изображениях?

При таком простом тесте я обнаружил, что чем больше на экране виджетов, тем сильнее лагает.

Impeller

Impeller

Многое перепробовал, но сильная разница в перформансе была обнаружена, когда сменил графический движок. По умолчанию с определенной версии fluter на ios используется Impeller, на андроид этот движок пока как дополнительная опция. Я поставил для обеих платформ по умолчанию Skia.

Skia

Skia

Это решило вопрос с лагами при перемещении картинки из стороны в сторону. Но при масштабировании вопрос остался открытым:

Skia

Skia

Тогда я решил сделать по-хитрому, как описывал в этой статье

  • Во-первых, сделал пейнтер, который рисует картинку ui.Image:

class ImagePainter extends CustomPainter {   final ui.Image image;    const ImagePainter(this.image);    @override   void paint(Canvas canvas, Size size) {     final paint = Paint();     canvas.drawImage(image, Offset.zero, paint);   }    @override   bool shouldRepaint(ImagePainter oldDelegate) => false; }
  • Во-вторых, реализовал логику по получению ui.Image по глобальному ключу

  final GlobalKey _key = GlobalKey();   bool _isInteraction = false;   ui.Image? _image;    void _onInteractionStart() {     if (_isInteraction) return;     _image = (_key.currentContext!.findRenderObject()! as RenderRepaintBoundary).toImageSync();     setState(() {       _isInteraction = true;     });   }    void _onInteractionEnd() {     if (!_isInteraction) return;     setState(() {       _isInteraction = false;     });     _image?.dispose();     _image = null;   }
  • В-третьих, поправил реализацию в методе build основного виджета

  @override   Widget build(BuildContext context) {     return Scaffold(       appBar: AppBar(title: const Text('Coloring SVG')),       body: _items == null || _size == null           ? const Center(child: CircularProgressIndicator())           : InteractiveViewer(               onInteractionStart: (_) => _onInteractionStart(),               onInteractionEnd: (_) => _onInteractionEnd(),               child: Center(                 child: FittedBox(                   child: _isInteraction                       ? CustomPaint(                           size: _size!,                           painter: ImagePainter(_image!),                         )                       // RepaintBoundary should be used to prevent rebuilds                       // during transformations with InteractiveViewer                       : RepaintBoundary(                           key: _key,                           child: SizedBox(                             width: _size!.width,                             height: _size!.height,                             child: Stack(                               children: [                                 for (int index = 0; index < _items!.length; index++)                                   SvgPainterImage(                                     item: _items![index],                                     size: _size!,                                     onTap: () => _onTap(index),                                   )                               ],                             ),                           ),                         ),                 ),               ),             ),     );   }

Таким образом, уже вращаем и перемещаем не тяжеловесный объект рендеринга, а растровую картинку, что для любых движков должно быть проще:

Skia

Skia
Impeller

Impeller

Итоги

Мы реализовали раскраску векторных картинок и протестировали перформанс при различных ситуациях. Оказалось, что для новый движок Impeller уступает Skia в кейсах, когда надо отобразить большое количество виджетов на экране.
Иногда можно обойти этот барьер, как показал пример, с помощью манипуляций с растровым изображением, а не самим виджетом. Но, к сожалению, так не всегда получается, и в некоторых своих проектах я отказался от использования Impeller как основного графического движка.

Я надеюсь, что материал данной статьи поможет вам оптимизировать UI в ваших проектах.


ссылка на оригинал статьи https://habr.com/ru/articles/792858/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *