How to Build a Custom RenderObject in Flutter - Cách xây dựng RenderObject tùy chỉnh trong Flutter
1. Introduction (Giới thiệu)
Nếu bạn đã làm việc với Flutter một thời gian, bạn sẽ quen thuộc với việc xây dựng giao diện bằng cách kết hợp các Widget có sẵn như Container, Row, Column, ListView,... Nhưng sẽ ra sao nếu bạn cần một layout độc đáo mà framework không hỗ trợ sẵn? Hoặc khi bạn cần tối ưu hóa hiệu năng cho một phần giao diện cực kỳ phức tạp?
Câu trả lời nằm ở tầng sâu hơn của Flutter: RenderObject.
RenderObject là những "viên gạch" thực sự xây dựng nên giao diện của bạn. Chúng là các đối tượng cốt lõi chịu trách nhiệm về layout (sắp xếp vị trí, tính toán kích thước), painting (vẽ), và hit testing (xử lý sự kiện chạm). Hầu hết các widget bạn dùng hàng ngày (Row, Padding, Text) đều chỉ là lớp "cấu hình" (configuration) cho một RenderObject tương ứng ở bên dưới.
Trong bài viết này, chúng ta sẽ cùng nhau tìm hiểu cách tự xây dựng một RenderObject custom. Bằng cách này, bạn không chỉ có thể tạo ra các layout tùy biến và hiệu năng cao mà còn hiểu sâu hơn về cách Flutter hoạt động.
Để làm ví dụ xuyên suốt, chúng ta sẽ phân tích một widget TimestampedChatMessage, có khả năng hiển thị một tin nhắn chat kèm theo timestamp, tương tự như các ứng dụng nhắn tin phổ biến. Widget này sẽ đủ thông minh để đặt timestamp trên cùng một dòng với tin nhắn nếu có đủ không gian, và tự động xuống dòng nếu không.
2. Flutter's Three Trees (Ba cây của Flutter)
Để hiểu RenderObject, trước tiên chúng ta cần hiểu vị trí của nó trong kiến trúc của Flutter. Flutter quản lý giao diện thông qua ba cây song song: Widget, Element, và RenderObject.

WidgetTree: Đây là cây mà bạn tương tác nhiều nhất. Nó được tạo ra bởi code Dart trong hàmbuildcủa bạn. Widget là những bản thiết kế (blueprints) bất biến (immutable), chỉ chứa thông tin cấu hình. Khi bạn gọisetState(), hàmbuildchạy lại và tạo ra một cây Widget mới.ElementTree: Flutter rất thông minh. Thay vì phá hủy và xây dựng lại toàn bộ giao diện từ cây Widget mới, nó sử dụng câyElementlàm trung gian.Elementlà các đối tượng "sống" (mutable) quản lý vòng đời (lifecycle) và là cầu nối giữaWidgetvàRenderObject. Nó so sánh cây Widget mới và cũ, chỉ cập nhật nhữngRenderObjectnào thực sự có thay đổi.RenderObjectTree: Đây là cây thực hiện tất cả công việc "nặng nhọc". Nó nhận thông tin từElement, tính toán layout, và vẽ mọi thứ lên màn hình.
Khi chúng ta tạo một RenderObject custom, chúng ta đang trực tiếp can thiệp vào cây thứ ba này.
3. Creating a Custom RenderObject (Tạo một RenderObject tùy chỉnh)
Chúng ta không thường xuyên làm việc trực tiếp với RenderObject. Thay vào đó, chúng ta sẽ tạo một Widget đặc biệt có nhiệm vụ "điều khiển" RenderObject của chúng ta.
RenderObjectWidget
Đây là lớp cha cho tất cả các widget có RenderObject tương ứng. Có ba loại chính:
LeafRenderObjectWidget: Dành cho các widget không có con (children), ví dụ nhưTexthayImage. Widget chat của chúng ta sẽ là mộtLeafRenderObjectWidget.SingleChildRenderObjectWidget: Dành cho các widget chỉ có một con, ví dụ nhưPadding,Container,Opacity.MultiChildRenderObjectWidget: Dành cho các widget có nhiều con, ví dụ nhưRow,Column,Stack.
Key Methods in RenderObjectWidget: createRenderObject & updateRenderObject
Một RenderObjectWidget có hai phương thức tối quan trọng bạn cần implement:
createRenderObject(BuildContext context): Được Flutter gọi một lần duy nhất khi widget của bạn lần đầu được đưa vào cây. Nhiệm vụ của nó là khởi tạo và trả về một instance củaRenderObjectcustom của bạn.updateRenderObject(BuildContext context, covariant RenderObject renderObject): Được gọi mỗi khi widget của bạn được rebuild (ví dụ sau khisetStateđược gọi) với dữ liệu mới. Tại đây, bạn sẽ lấy dữ liệu mới từ widget (ví dụ:this.text) và cập nhật các thuộc tính tương ứng trênrenderObject.
4. RenderBox: The Core Layout Protocol (Giao thức Layout cốt lõi)
RenderBox là lớp con phổ biến nhất của RenderObject, sử dụng hệ tọa độ Descartes 2D. Nó tuân theo một giao thức layout đơn giản nhưng rất mạnh mẽ:
Constraints go down. Sizes go up. Parent sets position. (Ràng buộc đi xuống. Kích thước đi lên. Cha quyết định vị trí.)
- Constraints go down: 
RenderObjectcha truyền xuống các ràng buộc (constraints) cho con (ví dụ: "con có thể rộng tối đa 300 pixels"). - Sizes go up: Dựa vào ràng buộc đó, 
RenderObjectcon tự quyết định kích thước (size) của nó và báo lại cho cha. - Parent sets position: 
RenderObjectcha quyết định vị trí (position/offset) của con trong hệ tọa độ của cha. 
Key Methods to Override in RenderBox
Để tạo một RenderBox custom, bạn sẽ cần override một vài hàm chính.
The markNeeds...() Family of Methods (Họ hàm markNeeds...)
How RenderObjects are notified of changes.
Trước khi đi sâu vào các hàm override chính, chúng ta cần hiểu cách một RenderObject báo cho Flutter framework biết rằng nó cần được cập nhật. RenderObject không tự động "re-render" mỗi khi có một thuộc tính thay đổi. Thay vào đó, chúng ta phải "đánh dấu" nó là "dirty" (bẩn) một cách có chủ ý. Đây là một cơ chế tối ưu hóa hiệu năng cốt lõi.
Khi một thuộc tính của RenderObject thay đổi (ví dụ, text, style, v.v.), chúng ta cần gọi một trong các hàm markNeeds...() để đưa RenderObject này vào pipeline của frame tiếp theo.
- 
markNeedsLayout(): Đây là hàm "nặng" nhất. Khi gọi hàm này, bạn đang báo cho Flutter rằng: "Một thuộc tính nào đó liên quan đến layout đã thay đổi, vì vậy hãy chạy lại toàn bộ quá trình layout cho tôi". Điều này bao gồm:- Chạy lại 
performLayout(). - Vì layout đã thay đổi, nó cũng hàm ý rằng việc painting cũng cần được thực hiện lại, nên 
paint()cũng sẽ được gọi. - Semantics cũng có thể bị ảnh hưởng, nên chúng cũng sẽ được cập nhật.
 
Hãy gọi hàm này khi một thuộc tính thay đổi có thể ảnh hưởng đến
sizecủaRenderObject(ví dụ: thay đổi text, font size, padding). Trong ví dụTimestampedChatMessageRenderObject, mỗi khi_texthoặc_textStylethay đổi, chúng ta đều gọimarkNeedsLayout(). - Chạy lại 
 - 
markNeedsPaint(): Hàm này "nhẹ" hơn. Nó báo với Flutter: "Layout (size) của tôi không thay đổi, nhưng vẻ ngoài của tôi thì có. Hãy chỉ cần vẽ lại tôi thôi".- Nó sẽ bỏ qua 
performLayoutvà chỉ xếpRenderObjectvào hàng đợi để được gọipaint()trong frame tiếp theo. - Ví dụ: nếu chỉ có 
colorcủa text thay đổi mà không làm thay đổi kích thước, về mặt lý thuyết chúng ta có thể gọimarkNeedsPaint()để tối ưu. Tuy nhiên, để đảm bảo an toàn, việc gọimarkNeedsLayout()thường phổ biến hơn trừ khi bạn chắc chắn 100% rằng sự thay đổi không ảnh hưởng đến layout. 
 - Nó sẽ bỏ qua 
 - 
markNeedsSemanticsUpdate(): Hàm này được sử dụng khi các thông tin về ngữ nghĩa (accessibility) củaRenderObjectthay đổi, nhưng không ảnh hưởng đến layout hay painting. Nó sẽ lên lịch đểdescribeSemanticsConfigurationđược gọi lại. 
performLayout()
Where the sizing and layout calculations happen.
Đây là trái tim của RenderObject. Nhiệm vụ của nó là tính toán và quyết định size (kích thước) của RenderObject dựa trên constraints (ràng buộc) được truyền từ RenderObject cha.
Quy trình trong performLayout() thường như sau:
- Nhận 
constraints:RenderObjectcủa bạn có thể truy cập thuộc tínhconstraints. Đây là một đối tượngBoxConstraintschứaminWidth,maxWidth,minHeight, vàmaxHeight.RenderObjectcủa bạn BẮT BUỘC phải tuân thủ các ràng buộc này. - Tính toán kích thước: Đây là nơi logic chính của bạn được thực thi. Bạn có thể cần phải đo kích thước của text (sử dụng 
TextPainter), tính toán vị trí của các thành phần con, v.v.- Trong ví dụ 
TimestampedChatMessageRenderObject,performLayoutgọi một hàm helper là_layoutText. Hàm này sử dụng_textPaintervà_sentAtTextPainterđể layout text vớimaxWidthđược cung cấp. Nó tính toán chiều rộng của dòng dài nhất, chiều cao tổng thể, và quan trọng nhất là quyết định xem timestamp (sentAt) có thể nằm trên cùng một dòng với dòng cuối của tin nhắn hay không (_sentAtFitsOnLastLine). 
 - Trong ví dụ 
 - Gán 
size: Sau khi tất cả các tính toán hoàn tất, bạn PHẢI gán kết quả cuối cùng cho thuộc tínhsizecủaRenderObject.size = Size(calculatedWidth, calculatedHeight);- Hoặc, như trong ví dụ của chúng ta: 
size = constraints.constrain(Size(unconstrainedSize.width, unconstrainedSize.height));. Việc sử dụngconstraints.constrain()là một cách tốt để đảm bảosizecuối cùng luôn nằm trong giới hạn cho phép. 
 
Hàm này sẽ không được gọi ở mỗi frame. Nó chỉ được gọi khi RenderObject được đánh dấu là "dirty" bằng markNeedsLayout().
paint()
Where the RenderObject actually draws to the screen.
Sau khi performLayout() đã xác định kích thước, Flutter sẽ gọi hàm paint() để RenderObject tự vẽ lên màn hình.
Hàm này nhận hai tham số:
PaintingContext context: Cung cấp mộtcanvasđể bạn có thể vẽ lên.context.canvaslà nơi bạn thực hiện tất cả các hoạt động painting.Offset offset: Vị trí (góc trên bên trái) màRenderObjectcủa bạn nên được vẽ, được quyết định bởiRenderObjectcha. Tất cả các thao tác vẽ của bạn nên được thực hiện tương đối vớioffsetnày.
Bên trong hàm paint, bạn sẽ sử dụng các API của canvas (ví dụ: drawLine, drawRect, drawImage) hoặc các helper cấp cao hơn như TextPainter để vẽ.
- Trong ví dụ 
TimestampedChatMessageRenderObject:- Nó gọi 
_textPainter.paint(context.canvas, offset)để vẽ phần text chính của tin nhắn. - Sau đó, nó tính toán 
sentAtOffsetdựa trên_sentAtFitsOnLastLinevàsizeđã được quyết định trongperformLayout. - Cuối cùng, nó gọi 
_sentAtTextPainter.paint(context.canvas, sentAtOffset)để vẽ timestamp ở vị trí chính xác. 
 - Nó gọi 
 
Hàm này có thể được gọi thường xuyên hơn performLayout (ví dụ: khi animation diễn ra hoặc khi markNeedsPaint() được gọi).
describeSemanticsConfiguration()
Making your custom widget accessible.
Semantics (ngữ nghĩa) là cách bạn mô tả widget của mình cho các công cụ hỗ trợ tiếp cận (accessibility tools) như trình đọc màn hình (screen readers). Việc implement hàm này là rất quan trọng để ứng dụng của bạn có thể được sử dụng bởi tất cả mọi người.
Trong hàm describeSemanticsConfiguration, bạn sẽ "trang trí" một đối tượng SemanticsConfiguration được cung cấp với các thông tin về RenderObject của bạn.
config.isSemanticBoundary = true: Đặt làtruenếuRenderObjectcủa bạn đại diện cho một đối tượng ngữ nghĩa hoàn chỉnh, độc lập. Một tin nhắn chat là một ví dụ hoàn hảo.config.label: Đây là chuỗi văn bản mà trình đọc màn hình sẽ đọc to. Nó nên mô tả đầy đủ nội dung của widget. Trong ví dụ, nó được gán là'$_text, sent $_sentAt', cung cấp một trải nghiệm người dùng rất tốt.config.textDirection: Chỉ định hướng của văn bản (trái-sang-phải hoặc phải-sang-trái).
Bằng cách implement hàm này, bạn đảm bảo rằng người dùng sử dụng VoiceOver (iOS) hoặc TalkBack (Android) có thể hiểu và tương tác với widget custom của bạn một cách hiệu quả.
5. Case Study: TimestampedChatMessage
Bây giờ, hãy áp dụng những lý thuyết trên vào ví dụ chat_message_render_box.dart.
Phân tích TimestampedChatMessage (LeafRenderObjectWidget)
Widget này là "cửa ngõ" để vào thế giới RenderObject của chúng ta.
class TimestampedChatMessage extends LeafRenderObjectWidget {
  const TimestampedChatMessage({
    super.key,
    required this.text,
    required this.sentAt,
    this.style,
  });
  final String text;
  final String sentAt;
  final TextStyle? style;
  
  RenderObject createRenderObject(BuildContext context) {
    // ...
    return TimestampedChatMessageRenderObject(
      text: text,
      sentAt: sentAt,
      textDirection: Directionality.of(context),
      textStyle: effectiveTextStyle!,
      sentAtStyle: effectiveTextStyle.copyWith(color: Colors.grey),
    );
  }
  
  void updateRenderObject(
    BuildContext context,
    TimestampedChatMessageRenderObject renderObject,
  ) {
    // ...
    renderObject.text = text;
    renderObject.textStyle = effectiveTextStyle!;
    renderObject.sentAt = sentAt;
    renderObject.sentAtStyle = effectiveTextStyle.copyWith(color: Colors.grey);
    renderObject.textDirection = Directionality.of(context);
  }
}
- Nó kế thừa 
LeafRenderObjectWidgetvì nó không có con. createRenderObjecttạo ra một instance củaTimestampedChatMessageRenderObjectvà truyền vào các giá trị ban đầu.updateRenderObjectđược gọi khi widget rebuild. Nó nhận vàorenderObjectđang tồn tại và cập nhật các thuộc tính của nó (text,sentAt,style, ...) một cách hiệu quả.
Phân tích TimestampedChatMessageRenderObject (RenderBox)
Đây là nơi tất cả phép màu xảy ra.
Properties và Setters:
Mỗi khi một thuộc tính như text hay sentAt được cập nhật từ updateRenderObject, setter tương ứng trong RenderBox sẽ được gọi. Điều quan trọng là bên trong setter, sau khi cập nhật giá trị, nó phải gọi markNeedsLayout() để thông báo cho Flutter rằng cần phải tính toán lại layout.
  set text(String val) {
    if (val == _text) return;
    _text = val;
    _textPainter.text = textTextSpan;
    markNeedsLayout(); // Tell Flutter to re-layout
    markNeedsSemanticsUpdate();
  }
performLayout():
Hàm này trong ví dụ của chúng ta ủy thác công việc cho một hàm helper là _layoutText. performLayout chỉ đơn giản là gọi _layoutText và sau đó gán size đã được tính toán.
// In TimestampedChatMessageRenderObject
void performLayout() {
  final unconstrainedSize = _layoutText(constraints.maxWidth);
  size = constraints.constrain(
    Size(unconstrainedSize.width, unconstrainedSize.height),
  );
}
Size _layoutText(double maxWidth) {
  // 1. Layout text chính và timestamp để lấy các thông số
  _textPainter.layout(maxWidth: maxWidth);
  final textLines = _textPainter.computeLineMetrics();
  _sentAtTextPainter.layout(maxWidth: maxWidth);
  _sentAtLineWidth = _sentAtTextPainter.computeLineMetrics().first.width;
  // Cache lại các giá trị quan trọng
  _lastMessageLineWidth = textLines.last.width;
  _lineHeight = textLines.last.height;
  _numMessageLines = textLines.length;
  // 2. Logic cốt lõi: Kiểm tra xem timestamp có vừa trên dòng cuối không
  final lastLineWithDate = _lastMessageLineWidth + (_sentAtLineWidth * 1.08);
  if (textLines.length == 1) {
    _sentAtFitsOnLastLine = lastLineWithDate < maxWidth;
  } else {
    double longestLineWidth = 0;
    for (final line in textLines) {
      longestLineWidth = max(longestLineWidth, line.width);
    }
    _sentAtFitsOnLastLine =
        lastLineWithDate < min(longestLineWidth, maxWidth);
  }
  // 3. Tính toán và trả về size cuối cùng dựa trên kết quả
  late Size computedSize;
  if (!_sentAtFitsOnLastLine) {
    // Không vừa -> Thêm chiều cao của timestamp
    computedSize = Size(
      _textPainter.width,
      _textPainter.height + _sentAtTextPainter.height,
    );
  } else {
    // Vừa -> Chiều cao không đổi
    if (textLines.length == 1) {
      // Tin nhắn 1 dòng là trường hợp đặc biệt, chiều rộng bằng tổng cả 2
      computedSize = Size(lastLineWithDate, _textPainter.height);
    } else {
      // Tin nhắn nhiều dòng, chiều rộng là chiều rộng của dòng dài nhất
      computedSize = Size(_textPainter.width, _textPainter.height);
    }
  }
  return computedSize;
}
Giải thích performLayout:
- Nó sử dụng hai đối tượng 
TextPainterđể đo và layout phần text chính và phần timestamp. - Logic cốt lõi: Nó tính toán xem liệu chiều rộng của dòng cuối cùng cộng với chiều rộng của timestamp có nhỏ hơn chiều rộng tối đa cho phép hay không. Kết quả được lưu vào biến 
_sentAtFitsOnLastLine. - Dựa vào 
_sentAtFitsOnLastLine, nó tính toánsizecuối cùng cho toàn bộRenderBox. Nếu timestamp vừa vặn, chiều cao sẽ chỉ bằng chiều cao của text chính. Nếu không, chiều cao sẽ là tổng của cả hai. - Cuối cùng, nó gán 
sizeđã tính toán. 
paint():
Sau khi performLayout() hoàn thành, paint() được gọi để vẽ. Dựa vào cờ _sentAtFitsOnLastLine đã được thiết lập, nó biết chính xác phải vẽ timestamp ở đâu.
// In TimestampedChatMessageRenderObject
void paint(PaintingContext context, Offset offset) {
  // 1. Vẽ nội dung tin nhắn chính
  _textPainter.paint(context.canvas, offset);
  // 2. Tính toán vị trí của timestamp
  late Offset sentAtOffset;
  if (_sentAtFitsOnLastLine) {
    // Vừa -> Đặt ở cuối dòng cuối cùng
    sentAtOffset = Offset(
      offset.dx + (size.width - _sentAtLineWidth),
      offset.dy + (_lineHeight * (_numMessageLines - 1)),
    );
  } else {
    // Không vừa -> Đặt ở dòng mới bên dưới, căn phải
    sentAtOffset = Offset(
      offset.dx + (size.width - _sentAtLineWidth),
      offset.dy + _lineHeight * _numMessageLines,
    );
  }
  // 3. Vẽ timestamp
  _sentAtTextPainter.paint(context.canvas, sentAtOffset);
}
6. Conclusion (Kết luận)
Việc tự xây dựng RenderObject mở ra một thế giới hoàn toàn mới trong Flutter. Nó cho phép bạn phá vỡ các giới hạn của bộ widget có sẵn và tạo ra các giải pháp layout độc đáo, tối ưu.
Hãy tóm tắt lại quy trình:
- Tạo một 
RenderObjectWidget(Leaf, SingleChild, hoặc MultiChild). - Implement 
createRenderObjectđể tạoRenderObjectcủa bạn vàupdateRenderObjectđể cập nhật nó. - Tạo lớp 
RenderObject(thường làRenderBox) của riêng bạn. - Implement 
performLayout()để tính toánsize. - Implement 
paint()để vẽ lêncanvas. - Đừng quên gọi các hàm 
markNeeds...()trong các setter để kích hoạt pipeline rendering. - Implement 
describeSemanticsConfigurationđể đảm bảo tính tiếp cận (accessibility). 
Mặc dù việc này đòi hỏi sự hiểu biết sâu sắc hơn về framework, nhưng nó là một kỹ năng cực kỳ mạnh mẽ. Đừng ngần ngại khám phá mã nguồn của các widget Flutter core như Text, Row, Padding để xem chúng được xây dựng như thế nào. Chúc bạn thành công trên hành trình chinh phục RenderObject!
7. Lời cảm ơn & Tài liệu tham khảo
Bài viết này và ví dụ TimestampedChatMessage được truyền cảm hứng mạnh mẽ và tham khảo trực tiếp từ các tài liệu và mã nguồn tuyệt vời của Craig Labenz. Xin gửi lời cảm ơn đến anh vì đã chia sẻ kiến thức chuyên sâu về rendering trong Flutter.
- Mã nguồn Gist: chat_message_render_box.dart by craiglabenz
 - Video Flutter Show YouTube: RenderObjects | Decoding Flutter
 
All rights reserved