How to Build a Custom RenderObject in Flutter - Cách xây dựng RenderObject tùy chỉnh trong Flutter

0 0 0

Người đăng: Anh Tú Trần

Theo Viblo Asia

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.

Screenshot 2025-06-27 at 11.34.34.png

  • Widget Tree: Đâ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àm build củ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ọi setState(), hàm build chạy lại và tạo ra một cây Widget mới.
  • Element Tree: 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ây Element làm trung gian. Element là các đối tượng "sống" (mutable) quản lý vòng đời (lifecycle) và là cầu nối giữa WidgetRenderObject. Nó so sánh cây Widget mới và cũ, chỉ cập nhật những RenderObject nào thực sự có thay đổi.
  • RenderObject Tree: Đâ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ư Text hay Image. Widget chat của chúng ta sẽ là một LeafRenderObjectWidget.
  • 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:

  1. 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ủa RenderObject custom của bạn.
  2. updateRenderObject(BuildContext context, covariant RenderObject renderObject): Được gọi mỗi khi widget của bạn được rebuild (ví dụ sau khi setState đượ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ên renderObject.

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: RenderObject cha 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 đó, RenderObject con tự quyết định kích thước (size) của nó và báo lại cho cha.
  • Parent sets position: RenderObject cha 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:

    1. Chạy lại performLayout().
    2. 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.
    3. 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 size của RenderObject (ví dụ: thay đổi text, font size, padding). Trong ví dụ TimestampedChatMessageRenderObject, mỗi khi _text hoặc _textStyle thay đổi, chúng ta đều gọi markNeedsLayout().

  • 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 performLayout và chỉ xếp RenderObject vào hàng đợi để được gọi paint() trong frame tiếp theo.
    • Ví dụ: nếu chỉ có color củ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ọi markNeedsPaint() để tối ưu. Tuy nhiên, để đảm bảo an toàn, việc gọi markNeedsLayout() 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.
  • markNeedsSemanticsUpdate(): Hàm này được sử dụng khi các thông tin về ngữ nghĩa (accessibility) của RenderObject thay đổ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:

  1. Nhận constraints: RenderObject của bạn có thể truy cập thuộc tính constraints. Đây là một đối tượng BoxConstraints chứa minWidth, maxWidth, minHeight, và maxHeight. RenderObject của bạn BẮT BUỘC phải tuân thủ các ràng buộc này.
  2. 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, performLayout gọi một hàm helper là _layoutText. Hàm này sử dụng _textPainter_sentAtTextPainter để layout text với maxWidth đượ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).
  3. 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ính size của RenderObject.
    • 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ụng constraints.constrain() là một cách tốt để đảm bảo size cuố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ố:

  1. PaintingContext context: Cung cấp một canvas để bạn có thể vẽ lên. context.canvas là nơi bạn thực hiện tất cả các hoạt động painting.
  2. Offset offset: Vị trí (góc trên bên trái) mà RenderObject của bạn nên được vẽ, được quyết định bởi RenderObject cha. Tất cả các thao tác vẽ của bạn nên được thực hiện tương đối với offset nà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:
    1. Nó gọi _textPainter.paint(context.canvas, offset) để vẽ phần text chính của tin nhắn.
    2. Sau đó, nó tính toán sentAtOffset dựa trên _sentAtFitsOnLastLinesize đã được quyết định trong performLayout.
    3. Cuối cùng, nó gọi _sentAtTextPainter.paint(context.canvas, sentAtOffset) để vẽ timestamp ở vị trí chính xác.

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à true nếu RenderObject củ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 LeafRenderObjectWidget vì nó không có con.
  • createRenderObject tạo ra một instance của TimestampedChatMessageRenderObject và truyền vào các giá trị ban đầu.
  • updateRenderObject được gọi khi widget rebuild. Nó nhận vào renderObject đ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:

  1. Nó sử dụng hai đối tượng TextPainter để đo và layout phần text chính và phần timestamp.
  2. 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.
  3. Dựa vào _sentAtFitsOnLastLine, nó tính toán size cuố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.
  4. 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:

  1. Tạo một RenderObjectWidget (Leaf, SingleChild, hoặc MultiChild).
  2. Implement createRenderObject để tạo RenderObject của bạn và updateRenderObject để cập nhật nó.
  3. Tạo lớp RenderObject (thường là RenderBox) của riêng bạn.
  4. Implement performLayout() để tính toán size.
  5. Implement paint() để vẽ lên canvas.
  6. Đừng quên gọi các hàm markNeeds...() trong các setter để kích hoạt pipeline rendering.
  7. 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.

Bình luận

Bài viết tương tự

- vừa được xem lúc

Học Flutter từ cơ bản đến nâng cao. Phần 1: Làm quen cô nàng Flutter

Lời mở đầu. Gần đây, Flutter nổi lên và được Google PR như một xu thế của lập trình di động vậy.

0 0 303

- vừa được xem lúc

Học Flutter từ cơ bản đến nâng cao. Phần 3: Lột trần cô nàng Flutter, BuildContext là gì?

Lời mở đầu. Màn làm quen cô nàng FLutter ở Phần 1 đã gieo rắc vào đầu chúng ta quá nhiều điều bí ẩn về nàng Flutter.

1 1 368

- vừa được xem lúc

Flutter Animation: Creating medium’s clap animation in flutte Part II

Trong phần 1 mình đã giới thiệu với các bạn cơ bản về Animation trong Flutter. Score Widget Size Animation.

0 0 73

- vừa được xem lúc

Flutter - GetX - Using GetConnect to handle API request (Part 4)

Giới thiệu. Xin chào các bạn, lại là mình với series về GetX và Flutter.

0 0 374

- vừa được xem lúc

StatefulWidget và StatelessWidget trong Flutter

I. Mở đầu. Khi các bạn build một ứng dụng với Flutter thì Widgets là thứ không thể thiếu đúng không ạ. Và 2 loại Widget không thể thiếu đó là StatefullWidget và StatelessWidget.

0 0 161

- vừa được xem lúc

Tìm hiểu về Riverpod - Provider nhưng không hắn :v

Trong Flutter có rất nhiều các quản lý state: Provider, Bloc, GetX, Redux,... khó mà nói cái nào tốt hơn cái nào. Tuy nhiên nếu bạn đã làm quen với Provider thì không ngại để tìm hiểu thêm về Riverpod. Một bản nâng cấp của Provider. Nếu bạn để ý thì cái tên "Riverpod" là các chữ cái của "Provider" đ

0 0 75