Я flutter разработчик, в основном специализируюсь на разработке под iOS и Android. Нравится делать приложения для сферы развлечений и образования.
Недавно была задача по добавлению модуля рисования. В целом несложная фича, где проводя пальцем по экрану рисуешь линию:
![](https://habrastorage.org/getpro/habr/upload_files/26f/81c/ba5/26f81cba5f0502e1db4fd630edbd936f.gif)
Над реализацией долго не думал, поэтому выполнил следующие шаги:
Создал класс, который будет в себе хранить основные характеристики линии (координаты точек, цвет и толщина)
class LineObject {
final List<Offset> points;
final Color color;
final double strokeWidth;
const LineObject({
required this.points,
this.color = Colors.red,
this.strokeWidth = 20.0,
});
}
Реализовал
FingerPainter
для виджетаCustomPaint
, в котором расписал логику для рисования линий
class FingerPainter extends CustomPainter {
final List<LineObject> lines;
const FingerPainter({
required this.lines,
});
@override
void paint(Canvas canvas, Size size) {
for (int index = 0; index < lines.length; index++) {
final line = lines[index];
final paint = Paint()
..style = PaintingStyle.stroke
..color = line.color
..strokeWidth = line.strokeWidth
..strokeCap = StrokeCap.round;
for (var i = 0; i < line.points.length - 1; i++) {
canvas.drawLine(line.points[i], line.points[i + 1], paint);
}
}
}
@override
bool shouldRepaint(FingerPainter oldDelegate) => true;
}
Далее реализовал сам экран с возможностью рисования
class FingerPainterScreen extends StatefulWidget {
const FingerPainterScreen({super.key});
@override
State<FingerPainterScreen> createState() => _FingerPainterScreenState();
}
class _FingerPainterScreenState extends State<FingerPainterScreen> {
final List<LineObject> lines = [];
void _onClearLines() {
setState(() {
lines.clear();
});
}
void _onPanUpdate(DragUpdateDetails details) {
setState(() {
lines.last.points.add(details.localPosition);
});
}
void _onPanStart(DragStartDetails details) {
setState(() {
lines.add(LineObject(points: [details.localPosition]));
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: _onClearLines,
child: const Icon(Icons.clear),
),
body: GestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
child: CustomPaint(
size: MediaQuery.sizeOf(context),
painter: FingerPainter(
lines: lines,
),
),
),
);
}
}
В целом, этого достаточно для начала. Далее можно потихоньку усложнять, кастомизировать и оптимизировать…
Какие существуют проблемы в текущей реализации?
Проведем небольшое код-ревью своего же кода.
Во-первых, мы рисуем все линии сразу. В методе paint
каждый раз запускается цикл, в котором по очереди рисуется каждая линия из списка. Давайте это поправим, оставив задачу FingerPainter
отрисовать только одну конкретную линию:
class FingerPainter extends CustomPainter {
final LineObject line;
const FingerPainter({
required this.line,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..style = PaintingStyle.stroke
..color = line.color
..strokeWidth = line.strokeWidth
..strokeCap = StrokeCap.round;
for (var i = 0; i < line.points.length - 1; i++) {
canvas.drawLine(line.points[i], line.points[i + 1], paint);
}
}
@override
bool shouldRepaint(FingerPainter oldDelegate) => true;
}
Поправим также на самом экране:
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: _onClearLines,
child: const Icon(Icons.clear),
),
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
child: Stack(
children: [
for (int index = 0; index < lines.length; index++)
CustomPaint(
size: MediaQuery.sizeOf(context),
painter: FingerPainter(
line: lines[index],
),
),
],
),
),
);
}
Теперь у нас цикл в виджете Stack
. В GestureDetector
изменено проперти behavior
на HitTestBehavior.opaque
. По умолчанию, GestureDetector
реагирует на попадание в child, или же children в стеке. Если children нет, то и новые линии тоже создаваться не будут.
Почему лучше сделать цикл именно в стеке? Я вернусь к этому вопросу чуть позже.
Во-вторых, shouldRepaint всегда true. Этот параметр говорит нам, когда следует перерисовать виджет (то есть вызвать метод paint
). Если он всегда true
, то, соответственно, при любом чихе или, например, вызове build в родителе будет происходить прерисовка, и все N линий будут заново отрисованы. Добавим условие, чтобы перерисовка происходила только при изменении количества точек в линии.
@override
bool shouldRepaint(FingerPainter oldDelegate) {
return line.points.length != oldDelegate.line.points.length;
}
Если делать пошагово, как в данной статье, то на этом шаге у нас опять перестанут рисоваться линии. Это потому что условие всегда возвращает false.
Дело в том, что class LineObject
не иммутабельный, и мы можем изменять проперти List<Offset> points
без каких-либо угрызений совести. Вроде, в этом ничего плохого нет до поры до времени.
Когда мы в первый раз создаем объект класса LineObject
, мы создаем некую ссылку (референс) на участок в памяти. Всегда, когда мы изменяем проперти points, мы делаем это в одном и том же объекте, в том же участке памяти.
Поскольку в конструкторе FingerPainter
мы передаем именно референс на объект LineObject
, то в любой момент времени, когда вызывается shouldRepaint
, мы обращаемся к одному и тому же объекту. Соответственно, и проперти points
имеет ту же длину.
Чтобы избежать такой ситуации, можно намеренно сделать класс полностью иммутабельным, воспользовавшись, например, библиотекой freezed. Тогда если придут другие разработчики, то они будут вынуждены создавать новый экземпляр класса вместо изменения свойст существующего экземпляра.
В данной статье обойдемся созданием копии экземпляра, добавив метод copyWith
:
class LineObject {
final List<Offset> points;
final Color color;
final double strokeWidth;
const LineObject({
required this.points,
this.color = Colors.red,
this.strokeWidth = 20.0,
});
LineObject copyWith({
List<Offset>? points,
Color? color,
double? strokeWidth,
}) {
return LineObject(
points: points ?? this.points,
color: color ?? this.color,
strokeWidth: strokeWidth ?? this.strokeWidth,
);
}
}
Скорректируем методы _onPanStart
и _onPanUpdate
void _onPanStart(DragStartDetails details) {
setState(() {
lines.add(
LineObject(
points: [details.localPosition],
),
);
});
}
void _onPanUpdate(DragUpdateDetails details) {
setState(() {
lines.last = lines.last.copyWith(
points: [...lines.last.points, details.localPosition],
);
});
}
Теперь при добавлении новой точки будет создаваться новый объект и заменяться в списке lines
.
В-третьих, перестроение виджетов не изолировано. Есть очень полезный виджет, который называется RepaintBoundary
. Он позволяет изолировать один виджет от других, как бы “защищая” от лишних перестроений и себя при изменении других виджетов, и другие виджеты при перестроении его самого.
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: _onClearLines,
child: const Icon(Icons.clear),
),
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
child: Stack(
children: [
for (int index = 0; index < lines.length; index++)
RepaintBoundary(
child: CustomPaint(
size: MediaQuery.sizeOf(context),
painter: FingerPainter(
line: lines[index],
),
),
),
],
),
),
);
}
Таким образом, перестроение происходит только в последнем виджете из списка.
Как я упоминал ранее, лучше вынести цикл в Stack
, потому что это просто удобно, как показал пример выше.
Заключение
Мы оптимизировали процесс рисования пальцем за счет снижения количества перестроений виджетов. Данный материал является общим для использования и в других аналогичных кейсах.
В следующей части разберем, какие решения можно использовать для оптимизации данного кейса.