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
.
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àmbuild
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ọisetState()
, hàmbuild
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âyElement
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ữaWidget
vàRenderObject
. Nó so sánh cây Widget mới và cũ, chỉ cập nhật nhữngRenderObject
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
hayImage
. 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ủaRenderObject
custom 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:
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 RenderObject
s 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
size
củaRenderObject
(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ọ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
performLayout
và chỉ xếpRenderObject
vào hàng đợi để được gọipaint()
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ọ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ủaRenderObject
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:
- Nhận
constraints
:RenderObject
của bạn có thể truy cập thuộc tínhconstraints
. Đây là một đối tượngBoxConstraints
chứaminWidth
,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. - 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
và_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ínhsize
củ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ảosize
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ố:
PaintingContext context
: Cung cấp mộtcanvas
để 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.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ởiRenderObject
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ớioffset
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
:- 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
sentAtOffset
dựa trên_sentAtFitsOnLastLine
và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àtrue
nếuRenderObject
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ủaTimestampedChatMessageRenderObject
và 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ánsize
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. - 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ạoRenderObject
củ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