커스텀 위젯 설계 — RenderObject와 렌더링 파이프라인

Flutter의 위젯은 내부적으로 3단계 트리를 거쳐 화면에 그려집니다. 대부분은 기존 위젯 조합으로 해결되지만, 성능 최적화나 완전히 새로운 레이아웃이 필요할 때 RenderObject를 직접 다뤄야 합니다.


렌더링 파이프라인

PLAINTEXT
Widget → Element → RenderObject → Layer → Skia(엔진) → 화면

상세 과정

  1. Build Phase: Widget 트리 생성/갱신
  2. Layout Phase: RenderObject가 크기와 위치 계산
  3. Paint Phase: RenderObject가 Canvas에 그리기
  4. Composite Phase: Layer를 합성하여 최종 이미지 생성

3개의 트리 복습

DART
// Widget: 설정/구성 정보 (불변, 가벼움)
const Text('안녕')  // 매 빌드마다 새로 생성 가능

// Element: Widget과 RenderObject의 다리 (상태 유지)
// - Widget이 바뀌었을 때 RenderObject를 재사용할지 결정
// - BuildContext가 사실 Element

// RenderObject: 실제 레이아웃과 페인팅 (무거움)
// - 크기 계산 (performLayout)
// - 화면 그리기 (paint)

면접 포인트: "Widget을 매번 새로 만드는데 성능 문제가 없나요?"라는 질문에 대한 답. Widget은 가벼운 설정 객체일 뿐이고, 실제 무거운 RenderObject는 Element가 재사용 여부를 판단하여 최적화합니다.


LeafRenderObjectWidget — 자식 없는 커스텀 위젯

DART
// 단순한 원을 그리는 커스텀 위젯
class DotWidget extends LeafRenderObjectWidget {
  final Color color;
  final double size;

  const DotWidget({
    super.key,
    this.color = Colors.blue,
    this.size = 20,
  });

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderDot(color: color, dotSize: size);
  }

  @override
  void updateRenderObject(BuildContext context, RenderDot renderObject) {
    renderObject
      ..color = color
      ..dotSize = size;
  }
}

class RenderDot extends RenderBox {
  Color _color;
  double _dotSize;

  RenderDot({required Color color, required double dotSize})
      : _color = color,
        _dotSize = dotSize;

  Color get color => _color;
  set color(Color value) {
    if (_color == value) return;
    _color = value;
    markNeedsPaint();  // 다시 그리기 요청
  }

  double get dotSize => _dotSize;
  set dotSize(double value) {
    if (_dotSize == value) return;
    _dotSize = value;
    markNeedsLayout();  // 다시 레이아웃 요청
  }

  @override
  void performLayout() {
    // 크기 결정
    size = constraints.constrain(Size(_dotSize, _dotSize));
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    final paint = Paint()..color = _color;
    final center = offset + Offset(size.width / 2, size.height / 2);
    context.canvas.drawCircle(center, size.width / 2, paint);
  }
}

// 사용
DotWidget(color: Colors.red, size: 30)

SingleChildRenderObjectWidget — 자식 1개

DART
class CustomPadding extends SingleChildRenderObjectWidget {
  final double padding;

  const CustomPadding({
    super.key,
    required this.padding,
    required super.child,
  });

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderCustomPadding(padding: padding);
  }

  @override
  void updateRenderObject(
    BuildContext context,
    RenderCustomPadding renderObject,
  ) {
    renderObject.padding = padding;
  }
}

class RenderCustomPadding extends RenderProxyBox {
  double _padding;

  RenderCustomPadding({required double padding}) : _padding = padding;

  double get padding => _padding;
  set padding(double value) {
    if (_padding == value) return;
    _padding = value;
    markNeedsLayout();
  }

  @override
  void performLayout() {
    if (child != null) {
      child!.layout(
        constraints.deflate(EdgeInsets.all(_padding)),
        parentUsesSize: true,
      );
      size = constraints.constrain(
        Size(
          child!.size.width + _padding * 2,
          child!.size.height + _padding * 2,
        ),
      );
    } else {
      size = constraints.constrain(Size.zero);
    }
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null) {
      context.paintChild(
        child!,
        offset + Offset(_padding, _padding),
      );
    }
  }
}

Constraints와 Layout 프로토콜

Flutter의 레이아웃은 2단계 패스 로 동작합니다.

PLAINTEXT
부모 → 자식: Constraints (제약) 전달
자식 → 부모: Size (크기) 보고

"Constraints go down. Sizes go up. Parent sets position."
DART
@override
void performLayout() {
  // 1. 자식에게 제약 전달하며 레이아웃 요청
  child!.layout(
    BoxConstraints(
      maxWidth: constraints.maxWidth,
      maxHeight: constraints.maxHeight / 2,
    ),
    parentUsesSize: true,  // 자식 크기를 사용할지
  );

  // 2. 자신의 크기 결정
  size = Size(
    constraints.maxWidth,
    child!.size.height + 20,
  );
}

markNeedsLayout vs markNeedsPaint

메서드호출 시점비용
markNeedsLayout()크기/위치가 변할 때높음 (레이아웃 + 페인트)
markNeedsPaint()색상/스타일만 변할 때낮음 (페인트만)

성능을 위해 가능하면 markNeedsPaint()만 호출하세요.


언제 RenderObject를 직접 만들어야 할까?

상황방법
기존 위젯 조합으로 가능StatelessWidget/StatefulWidget
커스텀 그리기 필요CustomPainter
새로운 레이아웃 알고리즘RenderObject
극한의 성능 최적화RenderObject

대부분의 경우 CustomPainter로 충분합니다. RenderObject는 정말 특별한 경우에만 사용하세요.


정리

  • Flutter는 Widget → Element → RenderObject → Layer 순으로 렌더링합니다
  • Widget은 가벼운 설정 객체, RenderObject가 실제 레이아웃과 페인팅을 담당합니다
  • "Constraints go down, Sizes go up, Parent sets position" 규칙을 기억하세요
  • markNeedsLayout()은 비용이 높으니, 가능하면 markNeedsPaint()를 사용하세요
  • 대부분은 CustomPainter로 충분하고, RenderObject는 특수한 경우에만 필요합니다
댓글 로딩 중...