Flutter hoạt động như thế nào? — Phần 2

The Vinh Luong
OneID Engineering
Published in
11 min readOct 6, 2020

Trong bài viết trước chúng ta đã nói về cách thức hoạt động cơ bản bên trong của Flutter. Giờ chúng ta hãy cùng tìm hiểu về Widget nhé.

Trên trang homepage của Flutter có nhấn mạnh rằng mọi thứ đều là Widget.

Nói thế này chưa chuẩn lắm, theo mình sẽ phải là:

Dưới góc nhìn của lập trình viên thì mọi thứ liên quan đến UI ở khía cạnh layout và tương tác người dùng đều được thực hiện thông qua Widget.

Widget cho phép chúng ta định nghĩa một phần trên MHHT (màn hình hiển thị) về các khía cạnh như kích thước, nội dung, cách bố cục và tương tác. Tuy nhiên đó chưa phải là tất cả. Vậy thực sự Widget là gì?

Cấu hình bất biến (Immutable configuration)

Khi lục lọi source code của Flutter, các bạn sẽ thấy Widget được định nghĩa như sau:

@immutable
abstract class Widget extends DiagnosticableTree {
const Widget({ this.key });
final Key key;...
}

Vậy là sao?

Annotation @immutable ở đây đóng vai trò rất quan trọng: Nó nói cho chúng ta biết là tất cả các variable trong class Widget đều phải là final. Nói cách khác thì tất cả các variable đều chỉ được định nghĩa và gán giá trị cho nó một lần duy nhất. Như vậy là một khi đã được khởi tạo thì Widget sẽ không cho phép chúng ta thay đổi các thuộc tính của nó nữa.

Có thể coi Widget như một dạng cấu hình do nó cho phép chúng ta định nghĩa cách UI, UX được thể hiện thông qua việc thiết lập cấu hình cho nó. Tuy nhiên do việc thiết lập chỉ được phép thực hiện 1 lần duy nhất nên chúng ta sẽ gọi nó là cấu hình bất biến.

Cấu trúc phân cấp của Widget

Khi định nghĩa cấu trúc cho một screen bằng Widget thì chúng ta sẽ có đoạn code kiểu như sau:

Widget build(BuildContext context){
return SafeArea(
child: Scaffold(
appBar: AppBar(
title: Text('My title'),
),
body: Container(
child: Center(
child: Text('Centered Text'),
),
),
),
);
}

Ví dụ ở trên sử dụng 7 widget, cùng nhau chúng tạo nên một cấu trúc phân cấp. Phía dưới là thể hiện dưới dạng diagram dễ nhìn hơn:

Như các bạn thấy đấy, nó là một cấu trúc dạng cây với root là SafeArea.

Giấu rừng trong cây 😕

Tiêu đề không sai đâu các bạn. Bởi nếu các bạn chưa biết thì tự bản thân một widget nó đã có thể là tổng hợp của rất nhiều widget khác (cũng dưới dạng tree). Hay nói dễ hiểu hơn thì một widget có thể là đại diện cho một widget tree khác. Ví dụ chúng ta có thể viết lại đoạn code trên như sau:

Widget build(BuildContext context){
return MyOwnWidget();
}

Ở đây thì MyOwnWidget đại diện cho cả cái widget tree bao gồm SafeArea, Scaffold...

Một widget có thể đại diện cho một widget tree. Nếu được dùng như một node của widget tree nào đó khác thì cũng có thể coi nó như đại diện cho một subtree.

Khái niệm element trong tree

Cái này rất quan trọng.

Thực tế thì khái niệm widget tree được sinh ra để đơn giản hoá việc đọc hiểu cho dev. Ở trên mình có nói widget là một dạng thông tin cấu hình.

Vậy thông tin cấu hình đó được dùng để làm gì?

Nó được dùng để sinh ra một kiểu đối tượng khác tương ứng có tên gọi là Element.

Với mỗi một widget sẽ có tương ứng cho nó một element. Cũng như widget thì các element sẽ được liên kết với nhau để tạo thành dạng cây.

Để dễ hình dung thì giả sử chúng ta có 1 element tên X như hình bên dưới. X là một node không phải leaf cũng không phải root trong 1 element tree nên nó sẽ có cả node cha và node con. Ngoài ra thì nó có thể tham chiếu đến một widget và cả một RenderObject.

Bên dưới là hình minh họa ánh xạ giữa 3 loại tree:

Mình vẫn sẽ chưa nói đến việc tại sao lại sinh ra cái gọi là Element.

Phân loại Widget

Mình xin phân loại nó ra làm 3 loại theo tài liệu không chính thống nhé:

  • Proxy: Vai trò của loại widget này là để nắm giữ và cung cấp data mà widget khác (là node của subtree mà proxy làm root) cần. Một ví dụ điển hình là InheritedWidget hoặc LayoutId. Cũng chính vì chỉ dùng để cung cấp data cho widget khác mà loại widget này không gây tác động trực tiếp đến UI.
  • Renderer: Loại widget này liên quan trực tiếp đến cách bố cục giao diện trên màn. Ví dụ tiêu biểu như: Row, Column, Stack, ngoài ra có cả Padding, Align, Opacity...
  • Component: Đây là những Widget không trực tiếp cung cấp thông tin cuối cùng liên quan đến kích thước, vị trí, hình thức. Chúng chỉ cung cấp thông tin phụ trợ góp phần đưa ra thông tin cuối cùng. Ví dụ tiêu biểu cho loại này là : RaisedButton, Scaffold, Text, GestureDetector.

Các bạn có thể tham khảo bảng tổng hợp phân loại ở đây.

Việc phân loại Widget như này rất quan trọng do với mỗi loại Widget thì sẽ có một loại Element tương ứng.

Các loại Element

Dưới đây là liệt kê các loại element hiện có:

Như các bạn có thể thấy thì Element đang được chia thành 2 loại chính:

  • ComponentElement: Các element loại này không trực tiếp tương ứng với phần giao diện được render nào.
  • RenderObjectElement: Các element này tương ứng với một phần giao diện được render trên màn.

Giờ thì chúng ta hãy cùng tìm hiểu mối quan hệ giữa WidgetElement là thế nào nhé.

Widget và Element hoạt động với nhau dư lào

Trong Flutter, việc trigger render frame mới có thể thực hiện qua việc invalidate (đánh dấu vô hiệu) element hoặc render object.

Việc invalidate một element có thể thực hiện bằng nhiều cách khác nhau như:

  • Sử dụng setState. Bằng cách này thì toàn bộ StatefulElement (không phải StatefulWidget nha) sẽ bị invalidate.
  • Thông qua các notification (thông báo) của các proxyElement khác (ví dụ như InheritedWidget), proxyElement sẽ invalidate bất cứ element nào phụ thuộc vào nó.

Kết quả của việc invalidate element sẽ là một hoặc nhiều element tương ứng sẽ được tham chiếu tới từ một list được gọi là dirty elements.

Việc invalidate một renderObject đồng nghĩa với việc không có sự thay đổi cấu trúc nào xảy ra trên element tree. Sự thay đổi này đến từ level của renderObject, ví dụ như:

  • Thay đổi về mặc kích thước, vị trí, hình dạng hình học…
  • Cần thực hiện việc repaint (sơn lại), ví dụ như khi chúng ta đổi màu background, font style…

Kết quả của việc invalidate render object cũng sẽ là các renderObject tương ứng sẽ được tham chiếu tới bởi list các renderObject cần rebuild hay repaint.

Bất kể loại invalidation nào xảy ra thì đều dẫn đến việc SchedulerBinding (bạn còn nhớ?) được yêu cầu để "xin xỏ" Flutter Engine schedule một frame mới.

Và thời khắc mà Flutter Engine đánh thức SchedulerBinding chính là lúc các phép màu xuất hiện...

onDrawFrame()

Trong phần 1 thì mình có đề cập đến việc SchedulerBinding có 2 trách nhiệm chính, một trong số đó là thực hiện các request liên quan đến việc rebuild lại frame đến từ Flutter Engine. Bây giờ chúng ta sẽ nói kỹ hơn về vấn đề này nhé...

Lưu đồ trình tự phía dưới sẽ cho các bạn thấy điều gì sẽ xảy ra khi SchedulerBinding tiếp nhận một request onDrawFrame() từ Flutter Engine.

Bước 1:

Do BuildOwner chịu trách nhiệm cho việc handle element tree nên WidgetBinding sẽ invoke hàm buildScope của BuildOwner. Hàm này sẽ duyệt qua danh sách các element bị đánh dấu invalidate (=dirty) và sẽ yêu cầu chúng phải rebuild.

Quá trình từ khi gọi rebuild() sẽ là:

  1. Yêu cầu element (giả sử gọi là X) phải rebuild() hầu như đều dẫn đến việc gọi đến hàm build() (chính là hàm Widget build(BuildContext context){...}) của widget tham chiếu đến bởi X.
  2. Nếu X không có child thì widget mới sẽ được inflate (xem hình bên dưới).
  3. Trong trường hợp element có child (giả sử child đó là Y ) thì widget mới sẽ được so sánh với widget được tham chiếu tới bởi Y:
    - Nếu chúng có thể hoán đổi được cho nhau (= cùng kiểu widget và key) thì việc update sẽ được thực hiện, nhưng Y sẽ được giữ nguyên.
    - Nếu chúng không hoán đổi được cho nhau thì Y sẽ bị unmount (bị bỏ) và widget mới sẽ được inflate.
  4. Việc inflate một widget mới dẫn đến việc một element mới cũng sẽ được khởi tạo, và được mount như là một child mới của Y.

Các bạn có thể xem hình dưới để dễ hình dung hơn.

Một số lưu ý về việc inflate widget

Khi widget được inflate thì nó sẽ được yêu cầu tạo một element với một type nhất định, được định nghĩa từ bảng phân loại widget, cụ thể:

  • InheritedWidget sẽ sinh ra một InheritedElement.
  • StatefulWidget sẽ sinh ra một StatefulElement.
  • StatelessWidget sẽ sinh ra một StatelessElement.
  • InheritedModel sẽ sinh ra một InheritedModelElement.
  • InheritedNotifier sẽ sinh ra một InheritedNotifierElement.
  • LeafRenderObjectWidget sẽ sinh ra một LeafRenderObjectElement.
  • SingleChildRenderObjectWidget sẽ sinh ra một SingleChildRenderObjectElement
  • MultiChildRenderObjectWidget sẽ sinh ra một MultiChildRenderObjectElement.
  • ParentDataWidget sẽ sinh ra một ParentDataElement

Mỗi một loại element này sẽ có behavior khác nhau. Ví dụ:

  • Một StatefulElement sẽ invoke hàm widget.createState() tại thời điểm khởi tạo, dẫn đến việc State sẽ được khởi tạo và link đến element.
  • Kiểu RenderObjectElement sẽ tạo một RenderObject khi mà element đó sẽ được mount, renderObject sẽ được thêm vào render tree và link đến element.

Bước 2:

Sau khi tất cả các hành động liên quan đến các dirty element đã được thực hiện thì lúc này công việc liên quan đến element tree coi như đã xong. Quá trình tiếp theo sẽ được thực hiện là quá trình rendering.

RendererBinding chịu trách nhiệm việc handle rendering tree, WidgetBinding sẽ invoke hàm drawFrame của RendererBinding.

Biểu đồ phía dưới sẽ mô tả chi tiết chuỗi các hành động được thực hiện sau khi hàm drawFrame được gọi.

Cụ thể, sẽ có các action sau diễn ra:

  • Các renderObject được đánh dấu là dirty sẽ thực hiện lại quá trình layout (tính toán lại kích thước và hình dáng hình học).
  • Các renderObject được đánh dấu là need paint sẽ được tô màu lại bằng cách sử dụng layer của renderObject.
  • Kết quả chúng ta sẽ có 1 scene mới được build và gửi đến Flutter Engine để hiển thị lên màn hình.
  • Cuối cùng thì Semantics (mình sẽ viết bài khác về nó) cũng sẽ được update và gửi đến Flutter Engine.

Sau chuỗi action trên thì màn hình hiển thị sẽ được cập nhật.

Phần 3: Gesture handling

Các gesture (sự kiện liên quan đến viết vuốt chạm màn hình) sẽ được handle bởi GestureBinding.

Khi Flutter Engine gửi các thông tin liên quan đến sự kiện gesture thì GestureBinding sẽ intercept các thông tin này bằng cách sử dụng API window.onPointerDataPacket, thực hiện việc buffering và:

  1. Chuyển đổi các toạ độ được emit bởi Flutter Engine sao cho khớp với tỉ lệ pixel của device.
  2. Yêu cầu renderView cung cấp TẤT CẢ RenderObjects phụ trách phần màn hình có chứa toạ độ của sự kiện gesture.
  3. Duyệt qua list renderObject đó để lần lượt dispatch event gesture tương ứng cho mỗi phần tử của list.
  4. Khi renderObject nhận được event thì nó sẽ bắt đầu xử lý.

Phần 4: Animation

Phần này mình sẽ làm rõ hơn về khái niệm AnimationTicker.

Bình thường khi start một Animation thì chúng ta sẽ sử dụng một AnimationController hoặc Widget tương tự bất kỳ nào đấy.

Trong Flutter thì toàn bộ mọi thứ liên quan đến animation đều dính dáng đến một khái niệm là Ticker.

Ticker chỉ làm một nhiệm vụ duy nhất, đó là nó sẽ yêu cầu SchedulerBinding đăng ký một one-time callback cho nó. Callback này sẽ được invoke khi Flutter Engine đã sẵn sàng để tiếp nhận request vẽ một frame mới.

Flutter Engine sẽ báo hiệu thời điểm này cho SchedulerBinding biết thông qua việc nó invoke callback onBeginFrame. SchedulerBinding khi này sẽ duyệt qua và invoke callback của tất cả các ticker (việc này gọi là tick) được đăng ký.

Mỗi sự kiện tick này sẽ được animation controller intercept để cung cấp thông tin liên quan đến frame tiếp theo của animation. Khi animation kết thúc thì ticker sẽ bị “disabled”. Trường hợp chưa kết thúc thì ticker sẽ request SchedulerBinding schedule một callback tiếp theo, và cứ thế lặp lại cho đến khi animation kết thúc…

BuildContext

Nếu các bạn chưa xem qua class Element thì cụ tỉ signature của nó là dư lày:

abstract class Element extends DiagnosticableTree implements BuildContext {
...
}

Nhìn lướt qua thì chắc mọi người sẽ nhận ra thứ khá quen thuộc: BuildContext. Chúng ta chủ yếu chỉ dùng BuildContext trong hàm build() của StatelessWidget và StatefulWidget, hoặc là trong object State của StatefulWidget

Chúng ta có thể coi BuildContext như là đại diện cho Element có thể tham chiếu đến từ Widget.

Vậy là phần lớn chúng ta đã vô thức dùng Element mà không hề hay biết, cả mình cũng vậy =))

BuildContext hữu dụng như nào?

Chúng ta có thể dùng BuildContext của một widget để làm những việc như:

  • Lấy tham chiếu của RenderObject tương ứng với widget (hoặc nếu widget đó không thuộc loại renderer thì sẽ lấy được của widget con).
  • Lấy được size của RenderObject.
  • Lấy được thông tin của các ancestor widget (các widget có level thấp hơn nó, tức là gần root hơn).

Kết luận

Cảm ơn bạn đã đọc đến cuối bài viết. Hi vọng qua bài viết này các bạn đã có cái nhìn rõ ràng hơn về cách mà Flutter hoạt động. Đặc biệt là những khái niệm như Widget, Element, BuildContextRenderObject — những thứ mà chúng ta sẽ phải đụng chạm đến rất nhiều.

Gặp lại các bạn ở những bài viết sau.

Happy coding~

Nguồn tham khảo: https://www.didierboelens.com/2019/09/flutter-internals/

--

--