Flutter Custom RenderObjects у 2026: Власне малювання та питання на співбесіді

Повний посібник зі створення власних RenderObjects у Flutter. Архітектура трьох дерев, імплементація RenderBox, піксельне малювання та підготовка до технічних співбесід.

Flutter Custom RenderObjects та власне малювання

Власні RenderObjects у Flutter забезпечують найнижчий рівень доступу до конвеєра рендерингу фреймворку, надаючи піксельну точність контролю над компонуванням, малюванням та обробкою дотиків, якої стандартні віджети не можуть запропонувати. Розуміння цього рівня відрізняє розробників, які створюють застосунки на Flutter, від тих, хто створює сам Flutter.

Сигнал на співбесіді

Позиції senior Flutter очікують від кандидатів пояснення трьох дерев (Widget, Element, RenderObject), визначення коли власний RenderObject перевершує CustomPainter та демонстрації робочої імплементації, що коректно обробляє constraints та hit testing.

Архітектура трьох дерев, що керує рендерингом Flutter

Flutter рендерить кадри через три взаємопов'язані дерева, кожне з окремими обов'язками. Дерево Widget містить незмінні конфігураційні об'єкти, які пишуть розробники. Дерево Element діє як постійний міст, що переживає перебудови та керує життєвим циклом. Дерево RenderObject виконує фактичні обчислення: компонування, малювання та hit testing.

Ця відмінність важлива для продуктивності. Коли setState ініціює перебудову, Flutter проходить дерево Element і порівнює нове дерево Widget зі старим. Лише змінені Elements створюють нові RenderObjects або оновлюють існуючі. Цей механізм reconciliation пояснює, чому Flutter може перебудовувати віджети 60 разів на секунду без втрати кадрів.

custom_gauge_widget.dartdart
// Мінімальна структура widget-element-renderobject
class GaugeWidget extends LeafRenderObjectWidget {
  const GaugeWidget({super.key, required this.value});
  
  final double value; // від 0.0 до 1.0
  
  
  RenderObject createRenderObject(BuildContext context) {
    return RenderGauge(value: value);
  }
  
  
  void updateRenderObject(BuildContext context, RenderGauge renderObject) {
    renderObject.value = value; // Оновлення без перестворення
  }
}

Базовий клас LeafRenderObjectWidget сигналізує, що цей віджет не має дочірніх елементів. Коли значення змінюється, updateRenderObject мутує існуючий екземпляр RenderGauge замість його заміни, зберігаючи всі закешовані обчислення компонування.

RenderBox vs RenderObject: вибір правильного базового класу

Більшість сценаріїв власного рендерингу повинні розширювати RenderBox, а не безпосередньо RenderObject. RenderBox імплементує декартову коробкову модель з обмеженнями ширини та висоти, що відповідає 99% випадків використання в мобільних та веб-застосунках. Документація класу RenderObject рекомендує безпосередньо наслідувати RenderObject лише при побудові фундаментально іншого протоколу компонування, такого як полярна система координат.

Базовий класКоли використовуватиПриклади
RenderBoxСтандартні 2D компонування з коробковими обмеженнямиВласні діаграми, індикатори, поверхні малювання
RenderSliverПрокручуваний контент з ледачим завантаженням на основі viewportВласні заголовки списків, ефекти паралакса
RenderObjectВласні протоколи компонування (рідко)Полярні компонування, власні системи координат
render_gauge.dartdart
class RenderGauge extends RenderBox {
  RenderGauge({required double value}) : _value = value;
  
  double _value;
  double get value => _value;
  set value(double newValue) {
    if (_value == newValue) return;
    _value = newValue;
    markNeedsPaint(); // Лише перемальовування, без компонування
  }
  
  
  void performLayout() {
    // Приймаємо найбільший дозволений розмір зберігаючи квадратні пропорції
    final shortestSide = constraints.biggest.shortestSide;
    size = Size.square(shortestSide);
  }
  
  
  void paint(PaintingContext context, Offset offset) {
    final canvas = context.canvas;
    final center = offset + Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2 - 10;
    
    // Фонова дуга
    final backgroundPaint = Paint()
      ..color = const Color(0xFFE0E0E0)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 20
      ..strokeCap = StrokeCap.round;
    
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      2.4, // Початковий кут (радіани)
      4.9, // Кут розгортки (радіани)
      false,
      backgroundPaint,
    );
    
    // Дуга значення
    final valuePaint = Paint()
      ..color = const Color(0xFF4CAF50)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 20
      ..strokeCap = StrokeCap.round;
    
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      2.4,
      4.9 * _value, // Часткове заповнення
      false,
      valuePaint,
    );
  }
  
  
  bool hitTestSelf(Offset position) => true;
}

Метод performLayout повинен встановити властивість size в межах наданих constraints. Виклик markNeedsPaint() у сеттері забезпечує, що зміни значення ініціюють перемальовування без перерахунку компонування, оптимізуючи продуктивність під час анімацій.

Система constraints: як батьки комунікують з дочірніми елементами

Flutter використовує модель "constraints йдуть вниз, розміри йдуть вгору" для компонування. Батьки передають BoxConstraints дочірнім елементам, а дочірні повертають свій обраний Size. Цей односпрямований потік дозволяє обчислення компонування за час O(n) без множинних проходів.

dart
// Розуміння BoxConstraints
void performLayout() {
  // constraints.minWidth: мінімальна ширина яку дочірній елемент повинен мати
  // constraints.maxWidth: максимальна ширина яку дочірній елемент може мати
  // constraints.minHeight: мінімальна висота яку дочірній елемент повинен мати
  // constraints.maxHeight: максимальна висота яку дочірній елемент може мати
  
  // Tight constraints: min == max (точний розмір вимагається)
  if (constraints.isTight) {
    size = constraints.smallest;
    return;
  }
  
  // Loose constraints: min < max (діапазон дозволений)
  final preferredWidth = 200.0;
  final preferredHeight = 100.0;
  
  size = constraints.constrain(Size(preferredWidth, preferredHeight));
}

Питання на співбесідах часто перевіряють розуміння цієї системи. Кандидати повинні пояснити, чому double.infinity як maxWidth не спричиняє помилок (RenderBox вибирає скінченний розмір) та як tight constraints примушують точні розміри.

Імплементація hit testing для інтерактивних компонентів

Hit testing визначає, які RenderObjects отримують події вказівника. Стандартна імплементація перевіряє, чи точка знаходиться в межах коробки, але власні форми потребують перевизначення.

render_gauge.dart - розширення hit testingdart
class RenderGauge extends RenderBox {
  // ... попередній код ...
  
  
  bool hitTest(BoxHitTestResult result, {required Offset position}) {
    if (!hitTestSelf(position)) return false;
    
    result.add(BoxHitTestEntry(this, position));
    return true;
  }
  
  
  bool hitTestSelf(Offset position) {
    // Круговий hit test замість прямокутного
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2;
    final distance = (position - center).distance;
    return distance <= radius;
  }
  
  
  void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
    if (event is PointerDownEvent) {
      // Обробка дотику
      _onTapDown(entry.localPosition);
    }
  }
  
  void _onTapDown(Offset localPosition) {
    // Обчислюємо кут дотику та відповідно оновлюємо значення
    final center = Offset(size.width / 2, size.height / 2);
    final angle = (localPosition - center).direction;
    // Конвертуємо кут у значення 0.0-1.0
  }
}

Hit testing проходить від кореня до листя. Кожен RenderObject вирішує, поглинути подію чи дозволити їй поширюватися далі. Розуміння цього поширення необхідне при побудові перекриваючих інтерактивних елементів.

CustomPainter vs RenderObject: коли використовувати що

CustomPainter пропонує простіший API для власного малювання, але RenderObject забезпечує більше контролю. Вибір залежить від вимог:

ВластивістьCustomPainterRenderObject
Власна логіка компонуванняНіТак
Власний hit testingОбмеженийПовний контроль
Композиція шарівНіТак
Підтримка дочірніх елементівНіТак
Складність імплементаціїНизькаВисока
dart
// Коли CustomPainter достатньо
class SimpleChartPainter extends CustomPainter {
  final List<double> data;
  
  SimpleChartPainter(this.data);
  
  
  void paint(Canvas canvas, Size size) {
    // Просте малювання без потреби власного компонування
    final paint = Paint()..color = Colors.blue;
    for (var i = 0; i < data.length; i++) {
      final x = i * (size.width / data.length);
      final height = data[i] * size.height;
      canvas.drawRect(
        Rect.fromLTWH(x, size.height - height, size.width / data.length - 2, height),
        paint,
      );
    }
  }
  
  
  bool shouldRepaint(SimpleChartPainter oldDelegate) => data != oldDelegate.data;
}

CustomPainter працює всередині стандартного RenderBox, наданого віджетом CustomPaint. Коли потрібен контроль над компонуванням, hit testing або композицією шарів, RenderObject стає необхідним.

Оптимізація продуктивності: markNeedsLayout vs markNeedsPaint

Розрізнення цих методів безпосередньо впливає на продуктивність. markNeedsLayout() інвалідує обчислення розміру та позиції, примушуючи прохід компонування. markNeedsPaint() лише позначає RenderObject для перемальовування без перерахунку компонування.

dart
class RenderOptimizedGauge extends RenderBox {
  double _value;
  Color _color;
  double _strokeWidth;
  
  set value(double newValue) {
    if (_value == newValue) return;
    _value = newValue;
    markNeedsPaint(); // Лише перемальовування
  }
  
  set color(Color newColor) {
    if (_color == newColor) return;
    _color = newColor;
    markNeedsPaint(); // Лише перемальовування
  }
  
  set strokeWidth(double newWidth) {
    if (_strokeWidth == newWidth) return;
    _strokeWidth = newWidth;
    markNeedsLayout(); // Може вплинути на розмір, потрібне компонування
  }
}

Виклик неправильного методу марнує цикли CPU. Анімації, що змінюють лише колір або значення, повинні використовувати markNeedsPaint(). Зміни, що впливають на розмір компонента, потребують markNeedsLayout().

Композиція шарів для складних ефектів

Шари Flutter дозволяють просунуті ефекти, такі як обрізання, трансформації та візуальні ефекти без впливу на продуктивність малювання.

dart
class RenderClippedGauge extends RenderBox {
  
  void paint(PaintingContext context, Offset offset) {
    // Обрізання до кола
    context.pushClipPath(
      needsCompositing,
      offset,
      Offset.zero & size,
      Path()..addOval(Offset.zero & size),
      (context, offset) {
        // Малювання всередині обрізаної області
        _paintGauge(context.canvas, offset);
      },
    );
  }
  
  
  bool get needsCompositing => true; // Примушує окремий шар
  
  void _paintGauge(Canvas canvas, Offset offset) {
    // Фактичне малювання індикатора
  }
}

Прапорець needsCompositing інформує Flutter, що цей RenderObject потребує власного шару композиції. Це необхідно для ефектів на рівні шару, таких як opacity, clip та transform.

Часті питання на співбесідах

Під час технічних співбесід на позиції senior Flutter рекрутери часто задають такі питання:

  1. Поясніть різницю між Widget, Element та RenderObject. Widgets — це незмінні конфігурації. Elements — це постійні мости, що керують життєвим циклом. RenderObjects виконують фактичний рендеринг.

  2. Коли використовувати RenderObject замість CustomPainter? Коли потрібна власна логіка компонування, повний контроль над hit testing або композиція шарів.

  3. Як працює система constraints у Flutter? Батьки передають BoxConstraints дочірнім елементам, дочірні вибирають розмір в цих межах та повертають його батькам. Односпрямований потік забезпечує компонування O(n).

  4. Яка різниця між markNeedsLayout() та markNeedsPaint()? markNeedsLayout() примушує перерахунок розміру та позиції. markNeedsPaint() лише позначає для перемальовування. Використання неправильного методу впливає на продуктивність.

  5. Як імплементувати круговий hit testing? Перевизначити hitTestSelf() та перевіряти відстань від центру замість використання стандартного прямокутного тесту.

Готовий до співбесід з Flutter?

Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.

Висновок

Оволодіння власними RenderObjects відкриває повні можливості рушія рендерингу Flutter. Розуміння архітектури трьох дерев, системи constraints та оптимізації продуктивності дозволяє створювати компоненти, що виходять за межі можливостей стандартних віджетів. Ці навички високо цінуються під час технічних співбесід на позиції senior Flutter і виділяють кандидатів, які знають фреймворк на глибокому рівні.

Щоденний виклик

Чи знайдеш ти помилку в Flutter?

Справжній фрагмент коду, прихована помилка, одна спроба на день. Щоб спробувати, акаунт не потрібен.

Anthony Fillion-Maillet

Автор:

Anthony Fillion-Maillet

Засновник SharpSkill

Fullstack-розробник понад 10 років. Керує SharpSkill і відповідає за все, що тут публікується.

Оновлено 11 вересня 2026 р.

Теги

#flutter
#renderobject
#custom painting
#співбесіда
#просунутий рівень

Поділитися

Пов'язані статті