Как я повысил производительность flutter приложения с помощью FragmentShader. Часть 2
Я flutter разработчик, в основном специализируюсь на разработке под iOS и Android. Нравится делать приложения для сферы развлечений и образования.
В предыдущей части мы разобрали, как оптимизировать процесс рисования пальцем за счет снижения количества перестроений виджетов.
В этой части разберем, какие решения можно использовать для оптимизации данного кейса.
А где про FragmentShader?
Итак, мы оптимизировали текущий вариант кода, как могли. Но что в этой реализации может быть еще не так?
Как можно заметить, дерево виджетов линейно разрастается с увеличением количества линий, а в данном случае на каждый виджет есть соответствующий RenderObject
, который как минимум присутствует в памяти, максимум отрисован на экране в одном из слоев. Не буду вдаваться в детали рендеринга и растеризации, а опишу простыми словами.
Допустим, есть функционал стирания линий. Это, скорее всего, какая-то линия с цветом бэкграунда (в нашем случае белая линия).
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 = lines.last.copyWith(
points: [...lines.last.points, details.localPosition],
);
});
}
void _onPanStart(DragStartDetails details) {
setState(() {
lines.add(
LineObject(
color: _currentColor,
points: [details.localPosition],
),
);
});
}
Color get _currentColor => _eraserModeEnabled ? Theme.of(context).scaffoldBackgroundColor : Colors.red;
bool _eraserModeEnabled = false;
void _onToggleEraser(bool value) {
setState(() {
_eraserModeEnabled = value;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: _onClearLines,
child: const Icon(Icons.clear),
),
bottomNavigationBar: SafeArea(
child: ListTile(
title: const Text('Eraser enabled'),
trailing: Switch(
value: _eraserModeEnabled,
onChanged: _onToggleEraser,
),
),
),
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],
),
),
),
],
),
),
);
}
}
Если мы нарисуем тысячу красных линий и начнем их стирать таким образом, то в конце концов можем получить просто белый экран. Но по факту это будут всё те же тысяча красных линий, замазанных еще одним слоем белых. Это, очевидно, скажется на производительности, когда таких слоев будет достаточно много.
Приведу конкретные примеры из существующего приложения, где реализация такая же, но с добавлением различных типов линий, цветов, текстур и т. д.:
Чтобы решить проблему с отображением большого количества элементов на экране, мне пришла в голову идея не отображать большое количество элементов на экране :)
По сути, нарисованные линии — это статичные элементы. Значит, можно сделать из них картинку и оставить в бэкграунде. При добавлении новой линии обновлять эту картинку.
Чтобы реализовать подобную задумку, на помощь снова приходит виджет RepaintBoundary
, но уже в связке с GlobalKey
:
import 'dart:ui' as ui;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
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();
_imageBytes = null;
});
}
void _onPanUpdate(DragUpdateDetails details) {
setState(() {
lines.last = lines.last.copyWith(
points: [...lines.last.points, details.localPosition],
);
});
}
void _onPanStart(DragStartDetails details) {
setState(() {
lines.add(
LineObject(
color: _currentColor,
points: [details.localPosition],
),
);
});
}
Color get _currentColor => _eraserModeEnabled ? Theme.of(context).scaffoldBackgroundColor : Colors.red;
bool _eraserModeEnabled = false;
void _onToggleEraser(bool value) {
setState(() {
_eraserModeEnabled = value;
});
}
final _repaintKey = GlobalKey();
Uint8List? _imageBytes;
Future<void> _capturePng() async {
final boundary = _repaintKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
final ui.Image image = boundary.toImageSync(pixelRatio: 1.0);
final ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png);
setState(() {
_imageBytes = byteData!.buffer.asUint8List();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: _onClearLines,
child: const Icon(Icons.clear),
),
bottomNavigationBar: SafeArea(
child: ListTile(
title: const Text('Eraser enabled'),
trailing: Switch(
value: _eraserModeEnabled,
onChanged: _onToggleEraser,
),
),
),
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: (_) => _capturePng(),
child: RepaintBoundary(
key: _repaintKey,
child: Stack(
children: [
// for (int index = 0; index < lines.length; index++)
// RepaintBoundary(
// child: CustomPaint(
// size: MediaQuery.sizeOf(context),
// painter: FingerPainter(
// line: lines[index],
// ),
// ),
// ),
if (_imageBytes != null)
Positioned.fill(
child: Image.memory(
_imageBytes!,
fit: BoxFit.cover,
),
),
if (lines.isNotEmpty)
RepaintBoundary(
child: CustomPaint(
size: MediaQuery.sizeOf(context),
painter: FingerPainter(
line: lines.last,
),
),
),
],
),
),
),
);
}
}
1) Строки 83-85. Мы обернули виджет Stack
в RepaintBoundary
и пометили ключом _repaintKey
2) Строки 51-60. Далее по этому ключу мы получаем картинку из виджета и преобразуем в байты.
Параметр pixelRatio
следует сделать равным 1, потому что нам нужны размеры 1:1. К слову, в иных случаях этот параметр можно делать больше 1, чтобы повысить разрешение полученной картинки.
В целом, этого достаточно, чтобы количество renderObject
не росло и дерево виджетов всегда имело такой вид:
Мы хотим максимально увеличить производительность и уменьшить количество выполняемых операций, как это сделать?
У CustomPainter
в методе paint
есть также метод рисования картинки. Напишем реализацию для него:
class ImagePainter extends CustomPainter {
final ui.Image image;
const ImagePainter({
required this.image,
});
@override
void paint(Canvas canvas, Size size) {
canvas.drawImage(image, Offset.zero, Paint());
}
@override
bool shouldRepaint(ImagePainter oldDelegate) {
return oldDelegate.image != image;
}
}
Обновим код:
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();
// _imageBytes = null;
_image = null;
});
}
void _onPanUpdate(DragUpdateDetails details) {
setState(() {
lines.last = lines.last.copyWith(
points: [...lines.last.points, details.localPosition],
);
});
}
void _onPanStart(DragStartDetails details) {
setState(() {
lines.add(
LineObject(
color: _currentColor,
points: [details.localPosition],
),
);
});
}
Color get _currentColor => _eraserModeEnabled ? Theme.of(context).scaffoldBackgroundColor : Colors.red;
bool _eraserModeEnabled = false;
void _onToggleEraser(bool value) {
setState(() {
_eraserModeEnabled = value;
});
}
final _repaintKey = GlobalKey();
// Uint8List? _imageBytes;
ui.Image? _image;
Future<void> _capturePng() async {
final boundary = _repaintKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = boundary.toImageSync(pixelRatio: 1.0);
// final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
setState(() {
// _imageBytes = byteData!.buffer.asUint8List();
_image = image;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: _onClearLines,
child: const Icon(Icons.clear),
),
bottomNavigationBar: SafeArea(
child: ListTile(
title: const Text('Eraser enabled'),
trailing: Switch(
value: _eraserModeEnabled,
onChanged: _onToggleEraser,
),
),
),
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: (_) => _capturePng(),
child: RepaintBoundary(
key: _repaintKey,
child: Stack(
children: [
// for (int index = 0; index < lines.length; index++)
// RepaintBoundary(
// child: CustomPaint(
// size: MediaQuery.sizeOf(context),
// painter: FingerPainter(
// line: lines[index],
// ),
// ),
// ),
if (_image != null)
Positioned.fill(
child: CustomPaint(
painter: ImagePainter(
image: _image!,
),
),
),
// if (_imageBytes != null)
// Positioned.fill(
// child: Image.memory(
// _imageBytes!,
// fit: BoxFit.cover,
// ),
// ),
if (lines.isNotEmpty)
RepaintBoundary(
child: CustomPaint(
size: MediaQuery.sizeOf(context),
painter: FingerPainter(
line: lines.last,
),
),
),
],
),
),
),
);
}
}
В указанной версии я закомментировал ненужные части старого варианта и заменил использование байтов на использование ui.Image
.
Можно снова остановиться на достигнутом, потому что дальше пойдут дебри.
Я заметил, что качество при отрисовке картинок в CustomPainter
может сильно теряться, они как бы становятся пиксельными. Заметно, когда переводишь обычную красивую картинку, например, из ассетов в ui.Image
и рисуешь в пейнтере
Чтобы не потерять в качестве можно воспользоваться шейдером. Выполним следующие шаги
Создадим файл шейдера в ассетах:
Добавим в pubspec.yaml
flutter:
uses-material-design: true
shaders:
- assets/shaders/simple_shader.frag
assets:
- assets/images/
Напишем следующий код для шейдера:
#include<flutter/runtime_effect.glsl>
uniform vec2 uResolution; // The resolution of the screen
uniform sampler2D uTexture; // The texture
out vec4 fragColor;
// A function to perform simple anti-aliasing
vec4 applyAntiAliasing(vec2 coord) {
vec2 aaLevel = vec2(1.0) / uResolution; // Adjust as needed, ensuring it's a vec2
vec4 color = vec4(0.0);
// Sample the texture at multiple points around the pixel and average them
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
vec2 sampleCoord = coord + vec2(float(x), float(y)) * aaLevel;
color += texture(uTexture, sampleCoord);
}
}
return color / 9.0; // Average of the 9 samples
}
void main() {
vec2 st=FlutterFragCoord().xy / uResolution;
// fragColor = texture(uTexture, st);
fragColor = applyAntiAliasing(st);
}
Что тут происходит простыми словами:
uniform vec2 uResolution
— переменная, у которой два параметра. В данном случае это размеры виджета с шириной и высотой. Этот параметр нужно задать, чтобы воспользоваться шейдером.
uniform sampler2D uTexture
— это по сути ui.Image
. Этот параметр тоже нужно передать, чтобы воспользоваться шейдером.
out vec4 fragColor
— переменная, у которой 4 параметра. Это цвет пикселя и как раз то, что пойдет на выход.
vec2 st
— координата этого пикселя в относительных единицах.
vec4 applyAntiAliasing(vec2 coord)
— для эффекта сглаживания (убрать пиксельность).
Обновим
ImagePainter
с использованием шейдера:
class ImagePainter extends CustomPainter {
final ui.Image image;
final ui.FragmentProgram program;
const ImagePainter({
required this.image,
required this.program,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..shader = _getShader(size)
..style = PaintingStyle.fill;
final rect = Rect.fromPoints(
const Offset(0, 0),
Offset(size.width, size.height),
);
canvas.drawRect(rect, paint);
}
ui.FragmentShader _getShader(Size size) {
final shader = program.fragmentShader();
// resolution
shader.setFloat(0, size.width);
shader.setFloat(1, size.height);
// texture
shader.setImageSampler(0, image!);
return shader;
}
@override
bool shouldRepaint(ImagePainter oldDelegate) {
return oldDelegate.image != image;
}
}
Здесь в методе _getShader
мы задаем в шейдере необходимые параметры: размер виджета и саму картинку.
Обновим итоговый код:
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
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();
_image = null;
});
}
void _onPanUpdate(DragUpdateDetails details) {
setState(() {
lines.last = lines.last.copyWith(
points: [...lines.last.points, details.localPosition],
);
});
}
void _onPanStart(DragStartDetails details) {
setState(() {
lines.add(
LineObject(
color: _currentColor,
points: [details.localPosition],
),
);
});
}
Color get _currentColor => _eraserModeEnabled ? Theme.of(context).scaffoldBackgroundColor : Colors.red;
bool _eraserModeEnabled = false;
void _onToggleEraser(bool value) {
setState(() {
_eraserModeEnabled = value;
});
}
final _repaintKey = GlobalKey();
ui.Image? _image;
void _capturePng() {
final boundary = _repaintKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = boundary.toImageSync(pixelRatio: 1.0);
setState(() {
_image = image;
});
}
ui.FragmentProgram? _program;
Future<void> _loadMyShader() async {
final fragmentProgram = await ui.FragmentProgram.fromAsset('assets/shaders/simple_shader.frag');
setState(() {
_program = fragmentProgram;
});
}
Future<void> _onPanEnd(DragEndDetails details) async {
if (_program == null) {
await _loadMyShader();
}
_capturePng();
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: _onClearLines,
child: const Icon(Icons.clear),
),
bottomNavigationBar: SafeArea(
child: ListTile(
title: const Text('Eraser enabled'),
trailing: Switch(
value: _eraserModeEnabled,
onChanged: _onToggleEraser,
),
),
),
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
child: RepaintBoundary(
key: _repaintKey,
child: Stack(
children: [
if (_image != null && _program != null)
CustomPaint(
size: MediaQuery.sizeOf(context),
painter: ImagePainter(
image: _image!,
program: _program!,
),
),
if (lines.isNotEmpty)
RepaintBoundary(
child: CustomPaint(
size: MediaQuery.sizeOf(context),
painter: FingerPainter(
line: lines.last,
),
),
),
],
),
),
),
);
}
}
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) {
return oldDelegate.line.points.length != line.points.length;
}
}
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,
);
}
}
class ImagePainter extends CustomPainter {
final ui.Image image;
final ui.FragmentProgram program;
const ImagePainter({
required this.image,
required this.program,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..shader = _getShader(size)
..style = PaintingStyle.fill;
final rect = Rect.fromPoints(
const Offset(0, 0),
Offset(size.width, size.height),
);
canvas.drawRect(rect, paint);
}
ui.FragmentShader _getShader(Size size) {
final shader = program.fragmentShader();
// resolution
shader.setFloat(0, size.width);
shader.setFloat(1, size.height);
// texture
shader.setImageSampler(0, image);
return shader;
}
@override
bool shouldRepaint(ImagePainter oldDelegate) {
return oldDelegate.image != image;
}
}
1) Стр. 60-66 - Добавлена загрузка программы шейдера
2) Стр. 68-73 - Вынесен метод обновления картинки и загрузки шейдера по необходимости
Теперь после рисования новой линии будет выполняться скриншот, и далее он будет отображаться на бэкграунде как растровая картинка. Качество картинки при этом не потеряется.
Заключение
Мы достигли оптимизации за счет:
снижения количества ненужных перестроений
преобразования отрисованных объектов в растровую картинку.
Плюс воспользовались шейдером, чтобы не потерять в качестве.
Я надеюсь, что материал данной статьи поможет вам в ваших проектах и вдохновит на нетривиальное решение сложных задач.