Vòng đời và quản lý trạng thái của Widget trong Flutter

1. StatefulWidget Lifecycle chuyên sâu

1.1. Khái niệm vòng đời của StatefulWidget

Trong Flutter, vòng đời của StatefulWidget là chuỗi các giai đoạn mà một Widget trải qua từ khi được tạo, khởi tạo trạng thái, hiển thị giao diện, nhận các thay đổi cấu hình cho đến khi bị loại bỏ hoàn toàn khỏi cây giao diện.

Khác với StatelessWidget, vốn không có đối tượng State riêng để lưu trữ trạng thái có thể thay đổi, StatefulWidget hoạt động kết hợp với một đối tượng State để quản lý dữ liệu và những thay đổi trong suốt thời gian tồn tại của giao diện.

Một StatefulWidget thường được sử dụng khi giao diện cần phản ứng với những thay đổi như:

  • Người dùng nhập dữ liệu vào biểu mẫu.

  • Nút bấm thay đổi trạng thái bật hoặc tắt.

  • Bộ đếm tăng hoặc giảm giá trị.

  • Dữ liệu được tải bất đồng bộ từ API.

  • Animation thay đổi theo thời gian.

  • Widget cha truyền cấu hình mới xuống Widget con.

  • Một Widget được di chuyển đến vị trí khác trong cây Widget.

Điểm quan trọng là vòng đời của đối tượng Widget và vòng đời của đối tượng State không hoàn toàn giống nhau. Widget là đối tượng cấu hình bất biến, trong khi State có thể tồn tại qua nhiều lần tạo lại đối tượng Widget.

1.2. Các phương thức chính trong vòng đời StatefulWidget

Flutter Widget Lifecycle — Understanding Stateful Widgets | by Atef Elsaeed | Medium

【Flutter学习】之 Flutter 的生命周期 - 梁飞宇 - 博客园

1 Introducing Flutter · Flutter in Action

Các phương thức quan trọng trong vòng đời StatefulWidget bao gồm:

Phương thức Vai trò chính
createState() Tạo đối tượng quản lý trạng thái cho Widget.
initState() Khởi tạo dữ liệu và tài nguyên một lần cho mỗi đối tượng State.
didChangeDependencies() Xử lý khi các dependency mà State phụ thuộc vào thay đổi.
build() Xây dựng mô tả giao diện hiện tại.
didUpdateWidget() Xử lý khi cấu hình Widget thay đổi nhưng State được giữ lại.
deactivate() Thông báo State sắp bị loại khỏi vị trí hiện tại trong cây.
dispose() Giải phóng tài nguyên khi State bị loại bỏ vĩnh viễn.

Ngoài ra, trong thực tế còn có reassemble() dành cho quá trình phát triển, đặc biệt liên quan đến Hot Reload. Phương thức này không thuộc luồng khởi tạo và hủy thông thường của Widget.

1.3. Phương thức createState()

createState() là phương thức được định nghĩa trong lớp StatefulWidget. Flutter gọi phương thức này để tạo đối tượng State tương ứng với một Widget Element mới.

Ví dụ:

import 'package:flutter/material.dart';

class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});

  @override
  State<CounterWidget> createState() {
    return _CounterWidgetState();
  }
}

class _CounterWidgetState extends State<CounterWidget> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return Text('Count: $count');
  }
}

Trong ví dụ trên:

  • CounterWidget mô tả cấu hình của giao diện.

  • _CounterWidgetState quản lý biến count.

  • createState() tạo một đối tượng _CounterWidgetState.

Thông thường, createState() được gọi khi Flutter tạo một Element mới cho StatefulWidget. Không nên hiểu rằng mỗi lần build() chạy thì createState() cũng chạy.

Khi Flutter chỉ cập nhật cấu hình của một Widget hiện có và Element tương ứng vẫn được giữ lại, State cũ thường tiếp tục được sử dụng.

1.4. Phương thức initState()

initState() được gọi khi đối tượng State được gắn vào cây Widget lần đầu tiên. Đây là vị trí thích hợp để khởi tạo những tài nguyên gắn với vòng đời của State.

Các công việc thường thực hiện trong initState() gồm:

  • Khởi tạo AnimationController.

  • Tạo TextEditingController.

  • Khởi tạo ScrollController.

  • Đăng ký listener.

  • Thiết lập subscription.

  • Khởi tạo biến trạng thái ban đầu.

  • Bắt đầu một tác vụ bất đồng bộ không phụ thuộc trực tiếp vào inherited dependency.

Ví dụ:

class SearchWidget extends StatefulWidget {
  const SearchWidget({super.key});

  @override
  State<SearchWidget> createState() => _SearchWidgetState();
}

class _SearchWidgetState extends State<SearchWidget> {
  late final TextEditingController controller;

  @override
  void initState() {
    super.initState();

    controller = TextEditingController();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: controller,
      decoration: const InputDecoration(
        hintText: 'Nhập từ khóa',
      ),
    );
  }
}

Trong ví dụ này, TextEditingController được tạo một lần trong initState() và được giải phóng trong dispose().

Một số quy tắc quan trọng:

  1. Phải gọi super.initState() khi override phương thức này.

  2. Không nên gọi setState() trong initState() để cập nhật giao diện thông thường, vì Flutter sẽ gọi build() sau quá trình khởi tạo.

  3. Không nên truy cập dependency thông qua các API như Theme.of(context) hoặc MediaQuery.of(context) trong initState() nếu thao tác đó cần được cập nhật khi dependency thay đổi. Trường hợp này phù hợp hơn với didChangeDependencies().

  4. Không sử dụng initState() để cập nhật những tài nguyên cần thay đổi khi cấu hình Widget cha thay đổi. Những trường hợp đó cần được xử lý thêm trong didUpdateWidget().

1.5. Phương thức didChangeDependencies()

didChangeDependencies() được gọi ngay sau initState() trong quá trình khởi tạo State. Sau đó, phương thức có thể được gọi lại khi một dependency mà State đã đăng ký sử dụng thay đổi.

Những dependency phổ biến gồm:

  • Theme.of(context).

  • MediaQuery.of(context).

  • Localizations.of(context, ...).

  • Các đối tượng được cung cấp qua InheritedWidget.

  • Một số trạng thái được quản lý bởi thư viện bên ngoài dựa trên cơ chế inherited dependency.

Ví dụ:

class ResponsiveWidget extends StatefulWidget {
  const ResponsiveWidget({super.key});

  @override
  State<ResponsiveWidget> createState() =>
      _ResponsiveWidgetState();
}

class _ResponsiveWidgetState extends State<ResponsiveWidget> {
  bool isWideScreen = false;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();

    final width = MediaQuery.sizeOf(context).width;

    isWideScreen = width >= 600;
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: EdgeInsets.all(isWideScreen ? 32 : 16),
      child: Text(
        isWideScreen ? 'Giao diện rộng' : 'Giao diện hẹp',
      ),
    );
  }
}

Trong ví dụ này, Widget lấy kích thước màn hình thông qua MediaQuery. Khi kích thước màn hình hoặc dependency liên quan thay đổi, Flutter có thể gọi lại didChangeDependencies().

Tuy nhiên, trong ví dụ trên, việc cập nhật trực tiếp biến isWideScreen chỉ nhằm minh họa vị trí đọc dependency. Với trạng thái được suy ra hoàn toàn từ MediaQuery, cách tốt hơn là tính toán ngay trong build():

@override
Widget build(BuildContext context) {
  final isWideScreen = MediaQuery.sizeOf(context).width >= 600;

  return Padding(
    padding: EdgeInsets.all(isWideScreen ? 32 : 16),
    child: Text(
      isWideScreen ? 'Giao diện rộng' : 'Giao diện hẹp',
    ),
  );
}

Cách này tránh lưu trữ một giá trị có thể tính toán lại từ dữ liệu đã có.

Lưu ý: didChangeDependencies() không đồng nghĩa với việc mọi biến trong ứng dụng thay đổi. Phương thức chủ yếu phản ứng với những dependency mà State đã đăng ký theo dõi.

1.6. Phương thức build()

build() là phương thức mô tả giao diện hiện tại dựa trên trạng thái và cấu hình của Widget.

Flutter có thể gọi build() nhiều lần khi:

  • State thay đổi thông qua setState().

  • Widget cha cập nhật cấu hình Widget con.

  • Dependency mà Widget sử dụng thay đổi.

  • Flutter cần xây dựng lại một phần cây giao diện.

  • Những điều kiện khác của hệ thống khiến Element cần được dựng lại.

Ví dụ:

class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});

  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int count = 0;

  void increment() {
    setState(() {
      count++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text('Số lần bấm: $count'),
        ElevatedButton(
          onPressed: increment,
          child: const Text('Tăng'),
        ),
      ],
    );
  }
}

Khi người dùng bấm nút:

  1. increment() được thực thi.

  2. setState() cập nhật biến count.

  3. Flutter đánh dấu Element cần được xây dựng lại.

  4. Flutter gọi lại build().

  5. Giao diện được cập nhật với giá trị mới.

build() nên tập trung mô tả giao diện, không nên chứa những tác vụ có tác dụng phụ như gửi yêu cầu thanh toán, tạo subscription mới liên tục hoặc khởi tạo Controller mới trong mỗi lần gọi.

Nếu một tác vụ cần chạy một lần trong vòng đời State, nên xem xét initState(). Nếu tác vụ phụ thuộc vào cấu hình mới, hãy cân nhắc didUpdateWidget(). Nếu tác vụ phụ thuộc vào inherited dependency, hãy cân nhắc didChangeDependencies().

1.7. Phương thức didUpdateWidget()

didUpdateWidget() được gọi khi Flutter cập nhật cấu hình của một StatefulWidget hiện có nhưng vẫn giữ lại State tương ứng.

Tình huống phổ biến là Widget cha truyền tham số mới xuống Widget con.

Ví dụ:

class UserGreeting extends StatefulWidget {
  final String name;

  const UserGreeting({
    super.key,
    required this.name,
  });

  @override
  State<UserGreeting> createState() => _UserGreetingState();
}

class _UserGreetingState extends State<UserGreeting> {
  @override
  void didUpdateWidget(covariant UserGreeting oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (oldWidget.name != widget.name) {
      debugPrint('Tên người dùng đã thay đổi');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Text('Xin chào ${widget.name}');
  }
}

Giả sử Widget cha ban đầu truyền name = 'Tiến', sau đó đổi thành name = 'An'.

Nếu Flutter xác định Widget mới tương thích với Widget cũ tại cùng vị trí và cùng loại, đồng thời key phù hợp, Flutter có thể giữ lại State cũ. Khi đó:

  • oldWidget.name chứa giá trị cấu hình trước đó.

  • widget.name chứa giá trị cấu hình mới.

  • didUpdateWidget() được gọi để xử lý sự thay đổi.

  • build() được gọi tiếp theo để cập nhật giao diện.

Không cần gọi setState() bên trong didUpdateWidget() chỉ để phản ánh những tham số mới vào giao diện. Flutter sẽ gọi build() sau callback này.

1.8. Phương thức deactivate()

deactivate() được gọi khi State bị loại khỏi vị trí hiện tại trong cây Element.

Điểm quan trọng là deactivate() không có nghĩa State đã bị hủy hoàn toàn.

State có thể bị deactivate khi:

  • Widget bị xóa khỏi cây và không được sử dụng lại.

  • Một Widget được di chuyển sang vị trí khác trong cùng một lần cập nhật cây.

  • Flutter cần tháo một Element khỏi vị trí hiện tại trước khi gắn lại ở vị trí khác.

Ví dụ, một Widget có GlobalKey được di chuyển từ khu vực bên trái sang khu vực bên phải trong cùng một lần cập nhật. Flutter có thể deactivate Element cũ rồi gắn lại Element đó ở vị trí mới.

Trong trường hợp này, State có thể được giữ nguyên.

Vì vậy, không nên giải phóng mọi tài nguyên trong deactivate() nếu các tài nguyên đó cần tiếp tục tồn tại khi Widget được gắn lại.

Ví dụ:

@override
void deactivate() {
  debugPrint('State tạm thời rời khỏi cây Widget');

  super.deactivate();
}

deactivate() thường được override khi có nhu cầu xử lý đặc biệt liên quan đến việc tháo hoặc di chuyển Element. Trong nhiều Widget thông thường, không cần override phương thức này.

1.9. Phương thức dispose()

dispose() được gọi khi State bị loại bỏ vĩnh viễn và không còn được tái sử dụng.

Đây là nơi thích hợp để giải phóng các tài nguyên mà State sở hữu, chẳng hạn:

  • TextEditingController.

  • AnimationController.

  • ScrollController.

  • FocusNode.

  • StreamSubscription.

  • Timer.

  • Listener đã đăng ký.

  • Các tài nguyên được tạo và quản lý thủ công.

Ví dụ:

class TimerWidget extends StatefulWidget {
  const TimerWidget({super.key});

  @override
  State<TimerWidget> createState() => _TimerWidgetState();
}

class _TimerWidgetState extends State<TimerWidget> {
  late final TextEditingController controller;

  @override
  void initState() {
    super.initState();

    controller = TextEditingController();
  }

  @override
  void dispose() {
    controller.dispose();

    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(controller: controller);
  }
}

Nếu không giải phóng tài nguyên đúng cách, ứng dụng có thể gặp những vấn đề như listener tiếp tục hoạt động ngoài dự kiến, subscription nhận dữ liệu khi Widget không còn tồn tại hoặc tài nguyên không được thu hồi đúng thời điểm.

Một quy tắc thực tế là: tài nguyên được tạo và sở hữu bởi State thường cần được giải phóng trong dispose().

Sau khi dispose() hoàn tất, State không thể được gắn lại vào cây Widget như một State đang hoạt động bình thường. Nếu cần một Widget mới, Flutter sẽ tạo Element và State mới phù hợp.

1.10. Thứ tự gọi các phương thức trong vòng đời

1. createState()

Tạo đối tượng State

2. initState()

Khởi tạo tài nguyên và dữ liệu

3. didChangeDependencies()

Xử lý dependency ban đầu

4. build()

Xây dựng giao diện

Trong quá trình tồn tại

build()/didUpdateWidget() -> deactivate() -> dispose()

Thứ tự khởi tạo thông thường là:

createState()
    ↓
initState()
    ↓
didChangeDependencies()
    ↓
build()

Khi cấu hình Widget thay đổi và State được giữ lại:

didUpdateWidget(oldWidget)
    ↓
build()

Khi dependency thay đổi:

didChangeDependencies()
    ↓
build()

Khi Widget rời khỏi cây:

deactivate()
    ↓
Nếu được gắn lại: State có thể tiếp tục tồn tại
Nếu bị loại bỏ vĩnh viễn: dispose()

Đây là các luồng phổ biến, không phải một chuỗi duy nhất bắt buộc xuất hiện giống hệt trong mọi tình huống. Một State có thể được xây dựng lại nhiều lần, có thể nhận nhiều lần cập nhật cấu hình và có thể trải qua deactivate() mà không lập tức bị dispose().

1.11. Ví dụ theo dõi vòng đời thực tế

import 'package:flutter/material.dart';

class LifecycleDemo extends StatefulWidget {
  final int value;

  const LifecycleDemo({
    super.key,
    required this.value,
  });

  @override
  State<LifecycleDemo> createState() {
    debugPrint('createState');
    return _LifecycleDemoState();
  }
}

class _LifecycleDemoState extends State<LifecycleDemo> {
  @override
  void initState() {
    super.initState();
    debugPrint('initState');
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    debugPrint('didChangeDependencies');
  }

  @override
  void didUpdateWidget(covariant LifecycleDemo oldWidget) {
    super.didUpdateWidget(oldWidget);
    debugPrint(
      'didUpdateWidget: ${oldWidget.value} -> ${widget.value}',
    );
  }

  @override
  void deactivate() {
    debugPrint('deactivate');
    super.deactivate();
  }

  @override
  void dispose() {
    debugPrint('dispose');
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    debugPrint('build: ${widget.value}');
    return Text('Giá trị: ${widget.value}');
  }
}

Đoạn code này giúp quan sát vòng đời trong Debug Console. Khi thử nghiệm, cần phân biệt việc rebuild thông thường, cập nhật tham số, di chuyển Widget và loại bỏ Widget. Mỗi tình huống có thể tạo ra chuỗi log khác nhau.

1.12. Kết luận về StatefulWidget Lifecycle

Vòng đời của StatefulWidget là nền tảng để quản lý giao diện, tài nguyên và dữ liệu trong Flutter. Việc sử dụng đúng từng callback giúp tránh khởi tạo lặp, cập nhật sai trạng thái, rò rỉ tài nguyên và lỗi khi Widget bị di chuyển hoặc loại bỏ.

Trong quá trình phát triển, cần xác định rõ công việc thuộc giai đoạn nào: khởi tạo ở initState(), theo dõi dependency ở didChangeDependencies(), xử lý cấu hình mới ở didUpdateWidget(), xây dựng giao diện ở build() và giải phóng tài nguyên ở dispose().

2. State Persistence và State Restoration

2.1. Tổng quan về khả năng duy trì trạng thái

Trong Flutter, State Persistence và State Restoration là hai khái niệm liên quan đến việc duy trì dữ liệu giao diện, nhưng chúng giải quyết những tình huống khác nhau.

State Persistence đề cập đến khả năng giữ hoặc lưu trạng thái qua một khoảng thời gian, có thể chỉ trong bộ nhớ khi Widget rebuild hoặc thông qua một cơ chế lưu trữ lâu dài.

State Restoration tập trung vào việc khôi phục một phần trạng thái giao diện sau khi hệ điều hành kết thúc ứng dụng và sau đó tạo lại ứng dụng trong một phiên mới.

Ví dụ, một ứng dụng mua sắm có thể cần duy trì:

  • Từ khóa tìm kiếm đang nhập.

  • Vị trí cuộn của danh sách sản phẩm.

  • Bộ lọc danh mục.

  • Tab đang được chọn.

  • Nội dung biểu mẫu chưa gửi.

  • Các sản phẩm trong giỏ hàng.

  • Thông tin phiên đăng nhập, nếu được lưu theo cơ chế bảo mật phù hợp.

Không phải mọi loại trạng thái đều cần được khôi phục theo cùng một cách.

2.2. Các cấp độ duy trì trạng thái

Cấp độ 1 — Rebuild

State vẫn nằm trong bộ nhớ. Flutter dựng lại giao diện nhưng không tạo State mới nếu Element cũ vẫn được giữ lại.

Cấp độ 2 — Di chuyển trong cây Widget

State có thể được giữ lại khi Widget được di chuyển trong cùng một lần cập nhật cây và Flutter có thể nhận diện nó, thường nhờ GlobalKey.

Cấp độ 3 — Lưu trữ lâu dài

Dữ liệu được lưu ra bộ nhớ bền vững, chẳng hạn bằng Hive, SQLite hoặc SharedPreferences, để sử dụng lại sau khi ứng dụng khởi động.

Cấp độ 4 — State Restoration

Flutter khôi phục những dữ liệu giao diện đã được đăng ký với hệ thống restoration sau khi ứng dụng được tạo lại.

2.3. Duy trì State trong quá trình rebuild

Rebuild là hoạt động bình thường trong Flutter. Khi setState() được gọi, Flutter không mặc định hủy Widget cũ và tạo toàn bộ State mới.

Thay vào đó, Flutter thường giữ lại Element hiện có nếu Widget mới tương thích với Widget cũ theo loại Widget và key tại vị trí tương ứng.

Ví dụ:

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Count: $count'),
        ElevatedButton(
          onPressed: () {
            setState(() {
              count++;
            });
          },
          child: const Text('Tăng'),
        ),
      ],
    );
  }
}

Khi người dùng bấm nút, biến count được giữ trong cùng một đối tượng State. Flutter chỉ yêu cầu xây dựng lại giao diện để hiển thị giá trị mới.

Tuy nhiên, nếu Widget bị loại khỏi cây và State bị dispose, giá trị count trong đối tượng State đó sẽ không còn được giữ lại. Nếu Widget được tạo lại sau đó, nó thường bắt đầu từ giá trị ban đầu.

Điều này cho thấy việc giữ State qua rebuild khác hoàn toàn với việc lưu dữ liệu lâu dài.

2.4. Duy trì State khi Widget di chuyển

Trong một số giao diện phức tạp, Widget có thể chuyển vị trí mà vẫn cần giữ nguyên dữ liệu đang quản lý.

Ví dụ:

  • Một biểu mẫu được chuyển giữa hai khu vực hiển thị.

  • Một Widget chỉnh sửa nội dung được chuyển sang một container khác.

  • Một thành phần giao diện có animation cần duy trì State khi thay đổi vị trí trong cây.

  • Một Widget có Controller cần tiếp tục sử dụng dữ liệu hiện có.

GlobalKey có thể giúp Flutter nhận diện cùng một Widget có State khi Widget được di chuyển sang vị trí khác trong cùng một lần cập nhật cây.

Ví dụ:

final GlobalKey<FormState> formKey = GlobalKey<FormState>();

Form(
  key: formKey,
  child: Column(
    children: [
      TextFormField(
        validator: (value) {
          if (value == null || value.isEmpty) {
            return 'Vui lòng nhập tên';
          }

          return null;
        },
      ),
      ElevatedButton(
        onPressed: () {
          final isValid = formKey.currentState?.validate() ?? false;

          if (isValid) {
            debugPrint('Biểu mẫu hợp lệ');
          }
        },
        child: const Text('Xác nhận'),
      ),
    ],
  ),
);

Ở ví dụ này, GlobalKey được sử dụng để truy cập trạng thái của Form, không phải để lưu trạng thái lâu dài sau khi ứng dụng bị tắt.

Một giới hạn quan trọng là GlobalKey không thể bảo đảm State tồn tại qua mọi thao tác xóa và tạo lại Widget. Nếu State đã bị dispose hoàn toàn, key không thể tự phục hồi đối tượng State cũ.

2.5. Lưu dữ liệu lâu dài bằng Local Storage

Đối với những dữ liệu cần tồn tại qua nhiều lần mở ứng dụng, cần sử dụng một cơ chế lưu trữ phù hợp.

Các lựa chọn phổ biến:

Công nghệ Mục đích thường dùng
SharedPreferences Cài đặt nhỏ như chế độ giao diện hoặc tùy chọn người dùng.
Hive Lưu dữ liệu cục bộ có cấu trúc đơn giản đến vừa phải.
SQLite Lưu dữ liệu có cấu trúc, cần truy vấn và quan hệ dữ liệu.
File Storage Lưu tệp hoặc dữ liệu dạng file.
Backend/API Lưu dữ liệu tài khoản, đơn hàng và thông tin cần đồng bộ giữa nhiều thiết bị.

Ví dụ lưu bộ đếm bằng SharedPreferences:

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

class PersistentCounter extends StatefulWidget {
  const PersistentCounter({super.key});

  @override
  State<PersistentCounter> createState() =>
      _PersistentCounterState();
}

class _PersistentCounterState extends State<PersistentCounter> {
  int count = 0;

  @override
  void initState() {
    super.initState();
    loadCount();
  }

  Future<void> loadCount() async {
    final prefs = await SharedPreferences.getInstance();
    final savedCount = prefs.getInt('counter') ?? 0;

    if (!mounted) return;

    setState(() {
      count = savedCount;
    });
  }

  Future<void> increment() async {
    final prefs = await SharedPreferences.getInstance();
    final newCount = count + 1;

    await prefs.setInt('counter', newCount);

    if (!mounted) return;

    setState(() {
      count = newCount;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text('Số lần bấm: $count'),
        ElevatedButton(
          onPressed: increment,
          child: const Text('Tăng'),
        ),
      ],
    );
  }
}

Đoạn code trên cần khai báo package shared_preferences trong dự án. Dữ liệu được đọc từ bộ nhớ cục bộ khi Widget khởi tạo và được ghi lại khi người dùng bấm nút.

Trong ứng dụng thực tế, cần xử lý thêm trạng thái đang tải, lỗi đọc ghi và những tình huống có nhiều thao tác ghi diễn ra liên tiếp.

2.6. State Restoration của Flutter

Flutter cung cấp cơ chế Restoration để khôi phục một số trạng thái giao diện sau khi hệ điều hành kết thúc ứng dụng rồi tạo lại ứng dụng.

Cơ chế này khác với việc chỉ giữ một đối tượng State trong RAM. Khi ứng dụng bị kết thúc hoàn toàn, đối tượng State cũ không còn tồn tại. Flutter cần dữ liệu restoration đã được lưu để tạo lại trạng thái tương ứng.

Các thành phần quan trọng gồm:

  • restorationScopeId: xác định phạm vi lưu trữ trạng thái cần khôi phục.

  • RestorationMixin: hỗ trợ State đăng ký và quản lý dữ liệu restoration.

  • RestorableProperty: lớp cơ sở cho các thuộc tính có thể khôi phục.

  • RestorableInt, RestorableBool, RestorableString: những kiểu dữ liệu restoration có sẵn.

Ví dụ:

import 'package:flutter/material.dart';

class RestorableCounter extends StatefulWidget {
  const RestorableCounter({super.key});

  @override
  State<RestorableCounter> createState() =>
      _RestorableCounterState();
}

class _RestorableCounterState extends State<RestorableCounter>
    with RestorationMixin {
  final RestorableInt count = RestorableInt(0);

  @override
  String? get restorationId => 'counter_state';

  @override
  void restoreState(
    RestorationBucket? oldBucket,
    bool initialRestore,
  ) {
    registerForRestoration(count, 'count');
  }

  @override
  void dispose() {
    count.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text('Count: ${count.value}'),
        ElevatedButton(
          onPressed: () {
            setState(() {
              count.value++;
            });
          },
          child: const Text('Tăng'),
        ),
      ],
    );
  }
}

void main() {
  runApp(
    MaterialApp(
      restorationScopeId: 'app',
      home: Scaffold(
        body: Center(
          child: RestorableCounter(),
        ),
      ),
    ),
  );
}

Trong ví dụ này:

  • MaterialApp cung cấp restorationScopeId ở cấp ứng dụng.

  • State sử dụng RestorationMixin.

  • RestorableInt giữ giá trị bộ đếm dưới dạng có thể khôi phục.

  • restoreState() đăng ký biến count với hệ thống restoration.

  • dispose() giải phóng đối tượng restoration property.

Đây là ví dụ minh họa cơ chế restoration cơ bản. Để kiểm tra khả năng khôi phục, cần thử trên nền tảng có hỗ trợ restoration và mô phỏng đúng tình huống hệ điều hành kết thúc rồi tạo lại ứng dụng.

Không nên nhầm việc bấm nút dừng ứng dụng, force-stop ứng dụng và việc hệ điều hành hủy tiến trình nền với cùng một hành vi. Khả năng khôi phục còn phụ thuộc vào nền tảng, trạng thái restoration đã được lưu và cách ứng dụng được khởi chạy lại.

2.7. Phân biệt State Persistence và State Restoration

Tiêu chí State Persistence State Restoration
Phạm vi Khái niệm rộng về việc duy trì hoặc lưu trạng thái. Cơ chế khôi phục trạng thái giao diện của Flutter.
Rebuild Có thể giữ State trong RAM. Không phải mục đích chính.
Di chuyển Widget Có thể giữ State nếu Element được tái sử dụng. Không phải mục đích chính.
Ứng dụng bị kết thúc Cần lưu dữ liệu ra nơi bền vững nếu muốn giữ lâu dài. Có thể khôi phục dữ liệu đã đăng ký và được lưu bởi hệ thống restoration.
Dữ liệu lớn Có thể dùng database hoặc backend. Thích hợp với trạng thái giao diện cần khôi phục.
Ví dụ Giỏ hàng lưu bằng database. Khôi phục tab đang chọn hoặc bộ đếm.

State Restoration không thay thế database hoặc backend. Với dữ liệu nghiệp vụ quan trọng như đơn hàng, thông tin thanh toán hoặc lịch sử giao dịch, ứng dụng vẫn cần lưu dữ liệu theo cơ chế bền vững phù hợp.

2.8. Nguyên tắc lựa chọn cơ chế duy trì trạng thái

Khi thiết kế ứng dụng Flutter, cần xác định mức độ quan trọng và thời gian tồn tại của dữ liệu.

  • Nếu dữ liệu chỉ phục vụ giao diện trong thời gian Widget tồn tại, có thể lưu trong State.

  • Nếu dữ liệu cần tồn tại khi Widget di chuyển, hãy xem xét cấu trúc cây và GlobalKey khi thực sự cần thiết.

  • Nếu dữ liệu cần tồn tại qua lần khởi động ứng dụng tiếp theo, hãy sử dụng Local Storage hoặc backend.

  • Nếu dữ liệu là trạng thái giao diện cần được khôi phục sau khi hệ điều hành kết thúc ứng dụng, hãy cân nhắc State Restoration.

  • Nếu dữ liệu cần đồng bộ giữa nhiều thiết bị, nên lưu tại backend và xác định rõ chiến lược đồng bộ.

2.9. Kết luận về State Persistence và State Restoration

Duy trì trạng thái là một phần quan trọng trong trải nghiệm người dùng. Một ứng dụng tốt cần bảo đảm dữ liệu không bị mất ngoài dự kiến khi giao diện rebuild, Widget thay đổi vị trí hoặc ứng dụng được mở lại.

State trong RAM phù hợp với dữ liệu tạm thời. Local Storage phù hợp với dữ liệu cần lưu lâu dài. State Restoration phù hợp với những trạng thái giao diện cần được khôi phục theo cơ chế của Flutter. Việc lựa chọn đúng giải pháp giúp ứng dụng ổn định, dễ bảo trì và có trải nghiệm liền mạch hơn.

3. didUpdateWidget và cập nhật cấu hình

3.1. Vai trò của cập nhật cấu hình Widget

Trong Flutter, Widget thường nhận dữ liệu từ Widget cha thông qua constructor. Các tham số này có thể thay đổi trong quá trình ứng dụng hoạt động.

Ví dụ:

  • Widget cha truyền một tên người dùng mới.

  • Một màn hình chi tiết nhận productId khác.

  • Một biểu đồ nhận bộ dữ liệu mới.

  • Một ô tìm kiếm nhận giá trị tìm kiếm mặc định mới.

  • Một Widget nhận Controller hoặc đối tượng quản lý dữ liệu mới.

Vì Widget là bất biến, Flutter không sửa trực tiếp những trường dữ liệu của đối tượng Widget cũ. Thay vào đó, Widget cha thường tạo một đối tượng Widget mới với cấu hình mới.

Nếu Widget mới tương thích với Widget cũ tại cùng vị trí trong cây, Flutter có thể giữ lại Element và State, sau đó cập nhật tham chiếu cấu hình mới cho Element.

Trong trường hợp này, didUpdateWidget() giúp State nhận biết sự khác biệt giữa cấu hình trước đó và cấu hình hiện tại.

3.2. Điều kiện để didUpdateWidget() được gọi

didUpdateWidget() không được gọi mỗi khi một biến bất kỳ trong ứng dụng thay đổi.

Phương thức được gọi khi Flutter cập nhật cấu hình cho một Element đang có State và Widget mới được xác định là tương thích với Widget cũ. Việc tương thích thường dựa trên loại Widget và key tại vị trí tương ứng.

Ví dụ:

class ProductTitle extends StatefulWidget {
  final String title;

  const ProductTitle({
    super.key,
    required this.title,
  });

  @override
  State<ProductTitle> createState() => _ProductTitleState();
}

class _ProductTitleState extends State<ProductTitle> {
  @override
  void didUpdateWidget(covariant ProductTitle oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (oldWidget.title != widget.title) {
      debugPrint(
        'Tiêu đề thay đổi: ${oldWidget.title} -> ${widget.title}',
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Text(widget.title);
  }
}

Giả sử Widget cha thay đổi title từ "Laptop" thành "Điện thoại".

Nếu State được giữ lại, Flutter sẽ gọi didUpdateWidget() với:

  • oldWidget.title = "Laptop".

  • widget.title = "Điện thoại".

Sau callback, Flutter gọi build() để cập nhật giao diện.

3.3. Phân biệt initState và didUpdateWidget

Đây là hai phương thức dễ bị sử dụng nhầm khi quản lý dữ liệu phụ thuộc vào cấu hình Widget.

Tiêu chí initState() didUpdateWidget()
Thời điểm Khi State được khởi tạo. Khi State hiện có nhận cấu hình Widget mới.
Số lần gọi Một lần trong vòng đời của mỗi State. Có thể nhiều lần.
Dữ liệu Khởi tạo tài nguyên và trạng thái ban đầu. So sánh cấu hình cũ và mới.
Controller Tạo Controller ban đầu. Cập nhật Controller khi nguồn dữ liệu thay đổi.
Subscription Đăng ký subscription ban đầu. Hủy subscription cũ và đăng ký lại nếu nguồn thay đổi.
Rebuild Không phải callback rebuild. Không được gọi chỉ vì setState() của chính State.

Nguyên tắc thực hành là không tạo lại toàn bộ tài nguyên trong didUpdateWidget() một cách vô điều kiện. Chỉ thực hiện việc cập nhật khi cấu hình thực sự thay đổi.

3.4. Cập nhật TextEditingController theo cấu hình mới

Giả sử Widget nhận initialText từ cha và cần hiển thị nội dung tương ứng. Khi cha thay đổi initialText, Widget cần cập nhật Controller.

import 'package:flutter/material.dart';

class EditableTextField extends StatefulWidget {
  final String initialText;

  const EditableTextField({
    super.key,
    required this.initialText,
  });

  @override
  State<EditableTextField> createState() =>
      _EditableTextFieldState();
}

class _EditableTextFieldState extends State<EditableTextField> {
  late final TextEditingController controller;

  @override
  void initState() {
    super.initState();

    controller = TextEditingController(
      text: widget.initialText,
    );
  }

  @override
  void didUpdateWidget(covariant EditableTextField oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (oldWidget.initialText != widget.initialText &&
        controller.text != widget.initialText) {
      controller.value = TextEditingValue(
        text: widget.initialText,
        selection: TextSelection.collapsed(
          offset: widget.initialText.length,
        ),
      );
    }
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: controller,
      decoration: const InputDecoration(
        labelText: 'Nội dung',
      ),
    );
  }
}

Trong ví dụ này:

  1. initState() tạo Controller với giá trị ban đầu.

  2. didUpdateWidget() so sánh initialText cũ và mới.

  3. Nếu giá trị thay đổi, Controller được cập nhật.

  4. dispose() giải phóng Controller.

Tuy nhiên, cần xác định rõ ý nghĩa của initialText. Nếu đây chỉ là giá trị ban đầu, không nhất thiết phải ghi đè nội dung người dùng đang nhập mỗi khi Widget cha rebuild. Nếu cha thực sự cần điều khiển nội dung hiện tại, nên thiết kế API rõ ràng hơn, chẳng hạn phân biệt initialText với một giá trị điều khiển liên tục.

3.5. Quản lý Subscription khi nguồn dữ liệu thay đổi

Một tình huống phổ biến khác là Widget nhận một đối tượng Stream từ Widget cha. Khi nguồn Stream thay đổi, State phải hủy subscription cũ và đăng ký subscription mới.

Nếu chỉ đăng ký trong initState(), Widget có thể tiếp tục nghe Stream cũ sau khi cấu hình mới đã được truyền xuống.

Ví dụ:

import 'dart:async';
import 'package:flutter/material.dart';

class StreamValueWidget extends StatefulWidget {
  final Stream<int> stream;

  const StreamValueWidget({
    super.key,
    required this.stream,
  });

  @override
  State<StreamValueWidget> createState() =>
      _StreamValueWidgetState();
}

class _StreamValueWidgetState extends State<StreamValueWidget> {
  StreamSubscription<int>? subscription;
  int value = 0;

  @override
  void initState() {
    super.initState();
    subscribeToStream();
  }

  void subscribeToStream() {
    subscription = widget.stream.listen((newValue) {
      if (!mounted) return;

      setState(() {
        value = newValue;
      });
    });
  }

  @override
  void didUpdateWidget(covariant StreamValueWidget oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (oldWidget.stream != widget.stream) {
      subscription?.cancel();
      subscription = null;
      subscribeToStream();
    }
  }

  @override
  void dispose() {
    subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text('Giá trị: $value');
  }
}

Cách quản lý này bảo đảm:

  • Stream ban đầu được đăng ký khi State khởi tạo.

  • Stream mới được đăng ký khi cấu hình nguồn dữ liệu thay đổi.

  • Subscription cũ được hủy khi không còn sử dụng.

  • Subscription được hủy khi State bị dispose.

Trong một ứng dụng thực tế, có thể cần bổ sung xử lý lỗi Stream, trạng thái loading và quy tắc giữ hoặc đặt lại giá trị value khi đổi nguồn.

3.6. Cập nhật AnimationController và các tài nguyên phụ thuộc cấu hình

Một số tài nguyên không chỉ phụ thuộc vào việc Widget tồn tại mà còn phụ thuộc vào cấu hình của Widget.

Ví dụ, một Widget animation nhận duration từ Widget cha. Nếu thời lượng thay đổi, State có thể cập nhật Controller hiện có thay vì tạo lại toàn bộ Widget.

class AnimatedBox extends StatefulWidget {
  final Duration duration;

  const AnimatedBox({
    super.key,
    required this.duration,
  });

  @override
  State<AnimatedBox> createState() => _AnimatedBoxState();
}

class _AnimatedBoxState extends State<AnimatedBox>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;

  @override
  void initState() {
    super.initState();

    controller = AnimationController(
      vsync: this,
      duration: widget.duration,
    );
  }

  @override
  void didUpdateWidget(covariant AnimatedBox oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (oldWidget.duration != widget.duration) {
      controller.duration = widget.duration;
    }
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: controller,
      child: const SizedBox(
        width: 100,
        height: 100,
        child: ColoredBox(color: Colors.blue),
      ),
    );
  }
}

Đoạn code minh họa việc cập nhật thuộc tính duration. Khi ứng dụng cần điều khiển animation hoàn chỉnh, phải bổ sung logic khởi chạy hoặc lặp animation phù hợp với yêu cầu.

Điều quan trọng là Controller chỉ được tạo một lần trong State, sau đó cập nhật khi cần và được giải phóng trong dispose().

3.7. Những lỗi thường gặp khi sử dụng didUpdateWidget

Lỗi 1: Khởi tạo Controller trong build()

Việc tạo Controller trong mỗi lần build() có thể dẫn đến nhiều đối tượng không cần thiết, trạng thái nhập liệu bị mất hoặc tài nguyên không được giải phóng đúng cách.

Lỗi 2: Chỉ xử lý cấu hình trong initState()

Nếu Widget nhận tham số mới sau lần khởi tạo, logic trong initState() không tự chạy lại. Cần dùng didUpdateWidget() khi tài nguyên phụ thuộc vào cấu hình mới.

Lỗi 3: Luôn hủy và tạo lại tài nguyên

Không phải mọi lần Widget cha rebuild đều yêu cầu đăng ký lại subscription hoặc tạo lại Controller. Hãy so sánh cấu hình cũ và mới trước khi cập nhật.

Lỗi 4: Gọi setState() không cần thiết

Trong didUpdateWidget(), Flutter sẽ gọi build() sau callback. Vì vậy, không cần gọi setState() chỉ để hiển thị tham số mới của Widget.

Lỗi 5: Bỏ qua việc giải phóng tài nguyên cũ

Khi đổi subscription hoặc thay Controller, cần xác định rõ đối tượng nào được State sở hữu và đối tượng nào được Widget cha sở hữu. Không được tùy tiện dispose tài nguyên thuộc quyền quản lý của bên khác.

3.8. Kết luận về didUpdateWidget

didUpdateWidget() là cơ chế quan trọng giúp StatefulWidget phản ứng với cấu hình mới mà không cần tạo lại toàn bộ State. Phương thức đặc biệt hữu ích trong những trường hợp quản lý Controller, animation, subscription và dữ liệu phụ thuộc vào tham số từ Widget cha.

Việc triển khai đúng callback này giúp ứng dụng tránh sử dụng dữ liệu cũ, tránh đăng ký listener trùng lặp và duy trì trạng thái giao diện nhất quán khi cấu hình thay đổi.

4. GlobalKey và GlobalKey Reparenting

4.1. Tổng quan về GlobalKey

Trong Flutter, GlobalKey là một loại Key có phạm vi nhận diện toàn cục trong cây Element đang hoạt động. Nó giúp Flutter nhận diện một Widget hoặc Element cụ thể, đồng thời cung cấp khả năng truy cập một số đối tượng liên quan đến Widget đó.

Khác với ValueKey hoặc ObjectKey, GlobalKey có thể được sử dụng để truy cập State của một StatefulWidget, truy cập Element tương ứng và trong một số trường hợp di chuyển Element giữa các vị trí trong cây.

Các trường hợp sử dụng phổ biến gồm:

  • Truy cập FormState để kiểm tra biểu mẫu.

  • Truy cập State của một Widget tùy chỉnh khi thực sự cần.

  • Giữ lại State khi Widget được di chuyển trong cùng một lần cập nhật cây.

  • Hỗ trợ một số tình huống giao diện phức tạp cần nhận diện một Widget cụ thể.

Tuy nhiên, GlobalKey không phải công cụ quản lý trạng thái mặc định cho mọi Widget. Việc sử dụng quá nhiều GlobalKey làm tăng độ phức tạp của cây giao diện và khiến việc quản lý vòng đời khó hơn.

4.2. Phân biệt LocalKey và GlobalKey

Tiêu chí LocalKey GlobalKey
Phạm vi nhận diện Trong phạm vi cha trực tiếp phù hợp. Toàn bộ cây Element đang hoạt động.
Ví dụ ValueKey, ObjectKey, UniqueKey. GlobalKey, GlobalObjectKey.
Truy cập State Không cung cấp cơ chế truy cập State trực tiếp tương tự GlobalKey. Có thể truy cập State thông qua currentState khi phù hợp.
Di chuyển Element Không hỗ trợ cơ chế reparenting toàn cục. Có thể hỗ trợ di chuyển Element giữa các vị trí phù hợp.
Chi phí Thường thấp hơn. Có thêm chi phí theo dõi và xử lý việc nhận diện toàn cục.
Trường hợp sử dụng Danh sách, các Widget con cần nhận diện. Biểu mẫu hoặc tình huống đặc biệt cần truy cập State hay di chuyển Widget.

4.3. Cấu trúc của GlobalKey

Có thể khai báo GlobalKey theo nhiều cách, tùy vào mục đích sử dụng.

Ví dụ truy cập State của Form:

final GlobalKey<FormState> formKey = GlobalKey<FormState>();

Ví dụ truy cập State của Widget tùy chỉnh:

final GlobalKey<_CounterState> counterKey =
    GlobalKey<_CounterState>();

Trong ví dụ thứ hai, _CounterState là lớp State của Widget tùy chỉnh. Vì đây là kiểu riêng tư của thư viện Dart chứa nó, cách khai báo key cũng phải nằm trong phạm vi có thể truy cập kiểu đó.

Một quy tắc quan trọng: nên tạo GlobalKey một lần trong đối tượng quản lý phù hợp, không tạo GlobalKey mới trong mỗi lần build().

Ví dụ không nên làm:

@override
Widget build(BuildContext context) {
  final key = GlobalKey<FormState>();

  return Form(
    key: key,
    child: const SizedBox(),
  );
}

Mỗi lần build() chạy, một key mới được tạo. Điều này có thể làm mất lợi ích của việc nhận diện Widget, khiến State không được giữ lại như mong đợi và làm tăng độ phức tạp trong quá trình cập nhật cây.

Cách phù hợp hơn:

class MyForm extends StatefulWidget {
  const MyForm({super.key});

  @override
  State<MyForm> createState() => _MyFormState();
}

class _MyFormState extends State<MyForm> {
  final GlobalKey<FormState> formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: formKey,
      child: const TextFormField(),
    );
  }
}

4.4. Truy cập State thông qua GlobalKey

Một trong những ứng dụng phổ biến nhất của GlobalKey là gọi các phương thức công khai của State mà không cần truyền dữ liệu qua nhiều tầng Widget.

Ví dụ:

import 'package:flutter/material.dart';

class FormExample extends StatefulWidget {
  const FormExample({super.key});

  @override
  State<FormExample> createState() => _FormExampleState();
}

class _FormExampleState extends State<FormExample> {
  final GlobalKey<FormState> formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: formKey,
      child: Column(
        children: [
          TextFormField(
            decoration: const InputDecoration(
              labelText: 'Email',
            ),
            validator: (value) {
              if (value == null || !value.contains('@')) {
                return 'Email không hợp lệ';
              }

              return null;
            },
          ),
          ElevatedButton(
            onPressed: () {
              final isValid =
                  formKey.currentState?.validate() ?? false;

              if (isValid) {
                debugPrint('Dữ liệu hợp lệ');
              }
            },
            child: const Text('Kiểm tra'),
          ),
        ],
      ),
    );
  }
}

Ở đây, formKey.currentState cung cấp quyền truy cập tới State của Form khi Form đang được gắn trong cây.

Toán tử ?. giúp tránh lỗi nếu currentState chưa tồn tại hoặc không còn khả dụng tại thời điểm truy cập.

Trong những ứng dụng lớn, không nên lạm dụng việc gọi trực tiếp State thông qua GlobalKey để thay thế hoàn toàn các phương pháp quản lý trạng thái như callback, Provider hoặc Riverpod. Các phương pháp này thường giúp luồng dữ liệu rõ ràng và dễ kiểm thử hơn.

4.5. Cơ chế GlobalKey Reparenting

Reparenting là quá trình di chuyển một Element từ vị trí cha này sang vị trí cha khác trong cây Element, đồng thời giữ lại State của Element đó trong những điều kiện phù hợp.

Trong Flutter, GlobalKey có thể giúp thực hiện quá trình này khi Widget được di chuyển trong cùng một lần cập nhật cây.

Ví dụ về ý tưởng:

Cây giao diện ban đầu

Scaffold
 ├── LeftPanel
 │    └── StatefulWidget (GlobalKey)
 └── RightPanel

Sau khi di chuyển:

Cây giao diện mới

Scaffold
 ├── LeftPanel
 └── RightPanel
      └── StatefulWidget (GlobalKey)

Nếu Widget có GlobalKey được di chuyển đúng cách trong cùng một lần cập nhật cây, Flutter có thể nhận diện Element cũ và gắn nó vào vị trí mới thay vì tạo State hoàn toàn mới.

Kết quả có thể bao gồm:

  • Biến State được giữ nguyên.

  • Nội dung người dùng đang nhập tiếp tục tồn tại.

  • Controller tiếp tục gắn với State cũ.

  • Một số trạng thái animation có thể được giữ lại tùy cấu trúc và quá trình cập nhật.

Tuy nhiên, reparenting không phải cơ chế di chuyển tùy ý giữa mọi thời điểm. Nếu Widget bị loại bỏ hoàn toàn, State đã bị dispose hoặc key không được sử dụng đúng cách, State cũ không được bảo đảm sẽ tồn tại.

4.6. Ví dụ minh họa việc di chuyển Widget có GlobalKey

Ví dụ sau minh họa một Widget có trạng thái bộ đếm được đặt ở một trong hai khu vực.

import 'package:flutter/material.dart';

class MovableCounter extends StatefulWidget {
  const MovableCounter({super.key});

  @override
  State<MovableCounter> createState() => _MovableCounterState();
}

class _MovableCounterState extends State<MovableCounter> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text('Giá trị: $count'),
        ElevatedButton(
          onPressed: () {
            setState(() {
              count++;
            });
          },
          child: const Text('Tăng'),
        ),
      ],
    );
  }
}

class ReparentingDemo extends StatefulWidget {
  const ReparentingDemo({super.key});

  @override
  State<ReparentingDemo> createState() => _ReparentingDemoState();
}

class _ReparentingDemoState extends State<ReparentingDemo> {
  final GlobalKey<_MovableCounterState> counterKey =
      GlobalKey<_MovableCounterState>();

  bool moveToRight = false;

  @override
  Widget build(BuildContext context) {
    final counter = MovableCounter(key: counterKey);

    return Column(
      children: [
        ElevatedButton(
          onPressed: () {
            setState(() {
              moveToRight = !moveToRight;
            });
          },
          child: const Text('Di chuyển Widget'),
        ),
        Row(
          children: [
            Expanded(
              child: Column(
                children: [
                  const Text('Khu vực trái'),
                  if (!moveToRight) counter,
                ],
              ),
            ),
            Expanded(
              child: Column(
                children: [
                  const Text('Khu vực phải'),
                  if (moveToRight) counter,
                ],
              ),
            ),
          ],
        ),
      ],
    );
  }
}

Ví dụ này minh họa việc tái sử dụng cùng một key để di chuyển Widget giữa hai nhánh trong quá trình cập nhật cây. Tuy nhiên, để tránh phụ thuộc vào thứ tự cập nhật và bảo đảm cấu trúc phù hợp với quy tắc reparenting, trong ứng dụng thực tế cần kiểm thử kỹ trên cây Widget cụ thể. Flutter chỉ có thể giữ lại State khi quá trình cập nhật và nhận diện Element đáp ứng các điều kiện cần thiết.

4.7. Chi phí và giới hạn của GlobalKey

GlobalKey mang lại khả năng đặc biệt, nhưng cũng có những chi phí cần xem xét.

Chi phí cập nhật cây

Khi Element được di chuyển, Flutter có thể phải deactivate và gắn lại một phần cây con. Các dependency liên quan có thể thay đổi, khiến một số Widget cần được xây dựng lại.

Phạm vi duy nhất

Một GlobalKey không được sử dụng đồng thời cho hai Widget đang gắn trong cùng cây Element. Nếu cùng một key được đặt lên hai Widget ở hai vị trí khác nhau, Flutter có thể báo lỗi trùng GlobalKey.

Khả năng truy cập quá mức

Khi một Widget có thể được truy cập từ bất kỳ nơi nào thông qua key, luồng phụ thuộc giữa các thành phần có thể trở nên khó theo dõi. Việc gọi trực tiếp State từ xa cũng có thể khiến mã nguồn khó kiểm thử hơn.

Không thay thế lưu trữ lâu dài

GlobalKey chỉ hỗ trợ nhận diện Widget và một số thao tác liên quan đến State đang tồn tại. Nó không lưu trạng thái sau khi ứng dụng bị kết thúc và không thay thế database hoặc State Restoration.

Không phải giải pháp cho mọi vấn đề quản lý State

Nếu mục tiêu chỉ là cập nhật giao diện khi dữ liệu thay đổi, callback, ValueNotifier, Provider hoặc Riverpod thường phù hợp hơn việc sử dụng GlobalKey để truy cập State từ nơi khác.

4.8. Các trường hợp nên và không nên sử dụng GlobalKey

Nên sử dụng khi:

  • Cần gọi FormState.validate() hoặc FormState.save().

  • Cần truy cập State của một Widget đặc biệt mà không có phương án đơn giản hơn.

  • Cần di chuyển một Widget có State giữa các vị trí trong cây.

  • Có yêu cầu rõ ràng về việc nhận diện toàn cục.

Không nên sử dụng khi:

  • Chỉ cần phân biệt các phần tử trong danh sách.

  • Có thể truyền callback từ cha xuống con.

  • Có thể sử dụng state management để chia sẻ dữ liệu.

  • Chỉ cần lưu một giá trị đơn giản.

  • GlobalKey bị tạo lại trong mỗi lần build().

  • Cùng một GlobalKey được gán cho nhiều Widget đồng thời.

4.9. Kết luận về GlobalKey và Reparenting

GlobalKey là công cụ nhận diện Widget có phạm vi toàn cục trong cây Element, hỗ trợ truy cập State và trong một số trường hợp cho phép di chuyển Element mà vẫn giữ lại trạng thái hiện có.

Tuy nhiên, khả năng này đi kèm các yêu cầu nghiêm ngặt về tính duy nhất của key và cách Flutter cập nhật cây. Vì vậy, nên sử dụng GlobalKey có chủ đích, ưu tiên LocalKey cho danh sách và các trường hợp nhận diện thông thường, đồng thời lựa chọn cơ chế quản lý trạng thái phù hợp với kiến trúc ứng dụng.

5. Keys trong danh sách động

5.1. Vai trò của Key trong Flutter

Key là cơ chế giúp Flutter nhận diện các Widget trong quá trình cập nhật cây giao diện. Key đặc biệt quan trọng khi một danh sách chứa nhiều phần tử cùng loại và có thể thay đổi thứ tự, thêm phần tử hoặc xóa phần tử.

Ví dụ, một danh sách công việc có ba phần tử:

[A, B, C]

Sau khi xóa phần tử đầu tiên, danh sách trở thành:

[B, C]

Nếu không sử dụng key phù hợp, Flutter có thể tái sử dụng Element theo vị trí và khiến State của các phần tử không còn gắn với đúng dữ liệu như mong muốn.

Đây là vấn đề dễ xảy ra với danh sách chứa StatefulWidget, chẳng hạn mỗi hàng có:

  • Ô checkbox.

  • Bộ đếm riêng.

  • Trạng thái mở rộng hoặc thu gọn.

  • Trường nhập liệu.

  • Animation.

  • Dữ liệu giao diện tạm thời.

Key giúp Flutter nhận diện chính xác hơn phần tử nào cần được giữ lại, cập nhật hoặc loại bỏ.

5.2. Cơ chế so khớp Widget khi cập nhật cây

Khi Flutter so sánh Widget cũ và mới tại một vị trí, quy tắc cơ bản là hai Widget có thể cập nhật cùng một Element nếu chúng có cùng runtimeType và key tương đương.

Với Widget không có key, hai Widget cùng loại ở cùng vị trí thường được xem là tương thích.

Điều này có nghĩa rằng nếu danh sách thay đổi thứ tự nhưng không có key, một Element có thể được tái sử dụng cho dữ liệu khác ở vị trí cũ. Nếu Element đó sở hữu State, State có thể xuất hiện trên hàng không còn tương ứng với dữ liệu ban đầu.

Key giải quyết vấn đề nhận diện này bằng cách cung cấp thông tin bổ sung để Flutter đối chiếu các phần tử.

5.3. ValueKey

ValueKey sử dụng một giá trị làm cơ sở nhận diện. Hai ValueKey có cùng kiểu giá trị và giá trị bằng nhau sẽ được xem là tương đương.

Ví dụ:

ValueKey(1)
ValueKey('product-01')
ValueKey('task-100')

ValueKey phù hợp khi mỗi phần tử có một mã định danh ổn định, chẳng hạn:

  • ID sản phẩm.

  • ID người dùng.

  • ID công việc.

  • Mã đơn hàng.

  • Mã danh mục.

Ví dụ danh sách công việc:

class Task {
  final int id;
  final String title;

  const Task({
    required this.id,
    required this.title,
  });
}

ListView(
  children: tasks.map((task) {
    return TaskTile(
      key: ValueKey(task.id),
      task: task,
    );
  }).toList(),
)

Khi danh sách thay đổi thứ tự, Flutter có thể sử dụng ValueKey(task.id) để nhận diện đúng Widget theo ID.

Nên chọn giá trị key ổn định trong suốt vòng đời logic của phần tử. Không nên dùng vị trí của phần tử làm key nếu phần tử có thể bị thêm, xóa hoặc sắp xếp lại.

5.4. ObjectKey

ObjectKey sử dụng danh tính của một đối tượng làm cơ sở nhận diện. Nó thường phù hợp khi mỗi phần tử được gắn với một đối tượng dữ liệu có danh tính ổn định.

Ví dụ:

class Product {
  final int id;
  final String name;

  Product(this.id, this.name);
}

class ProductTile extends StatefulWidget {
  final Product product;

  const ProductTile({
    super.key,
    required this.product,
  });

  @override
  State<ProductTile> createState() => _ProductTileState();
}

class _ProductTileState extends State<ProductTile> {
  bool expanded = false;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(widget.product.name),
      subtitle: expanded
          ? Text('ID: ${widget.product.id}')
          : null,
      trailing: IconButton(
        icon: Icon(
          expanded ? Icons.expand_less : Icons.expand_more,
        ),
        onPressed: () {
          setState(() {
            expanded = !expanded;
          });
        },
      ),
    );
  }
}

Khi sử dụng:

ProductTile(
  key: ObjectKey(product),
  product: product,
)

ObjectKey nhận diện dựa trên cùng đối tượng theo danh tính của object, không đơn thuần dựa vào việc hai object có cùng các trường dữ liệu.

Điểm cần lưu ý: nếu ứng dụng tải lại danh sách từ API và tạo những object Product mới, các object mới có thể không còn cùng danh tính với object cũ dù chúng có cùng id. Trong tình huống đó, ValueKey(product.id) thường phù hợp hơn.

5.5. UniqueKey

UniqueKey là một key chỉ bằng với chính đối tượng key đó. Mỗi lần tạo UniqueKey() mới, Flutter xem đó là một key khác.

Ví dụ:

final key = UniqueKey();

UniqueKey có thể hữu ích khi cần một Widget được nhận diện là phần tử mới thay vì tái sử dụng Element cũ.

Ví dụ:

Widget buildItem() {
  return StatefulItem(
    key: UniqueKey(),
  );
}

Tuy nhiên, cần cẩn thận nếu tạo UniqueKey trong build():

@override
Widget build(BuildContext context) {
  return StatefulItem(
    key: UniqueKey(),
  );
}

Mỗi lần build() chạy, key mới được tạo. Flutter có thể xem Widget đó là một phần tử mới, loại bỏ Element cũ và tạo State mới. Hậu quả có thể là mất trạng thái nhập liệu, đặt lại animation hoặc khởi tạo lại dữ liệu tạm thời.

Do đó, UniqueKey phù hợp khi thực sự muốn tạo danh tính mới, không phù hợp để duy trì State ổn định cho từng phần tử trong danh sách động.

5.6. GlobalKey trong danh sách

GlobalKey cũng có thể được dùng để nhận diện Widget, nhưng nó có phạm vi toàn cục và khả năng truy cập State. Vì vậy, không nên chọn GlobalKey chỉ vì muốn danh sách giữ đúng trạng thái từng hàng.

Ví dụ:

final GlobalKey<FormState> formKey = GlobalKey<FormState>();

Cách sử dụng này phù hợp với một biểu mẫu cần được truy cập từ bên ngoài.

Đối với danh sách sản phẩm, thông thường nên dùng:

ProductTile(
  key: ValueKey(product.id),
  product: product,
)

thay vì tạo một GlobalKey riêng cho từng hàng, trừ khi mỗi hàng thực sự có yêu cầu truy cập State hoặc di chuyển toàn cục đặc biệt.

5.7. So sánh các loại Key

Loại Key Cơ sở nhận diện Trường hợp phù hợp
ValueKey Giá trị bằng nhau Danh sách có ID ổn định.
ObjectKey Danh tính đối tượng dữ liệu Phần tử gắn với cùng object.
UniqueKey Danh tính duy nhất của chính key Buộc Flutter xem phần tử là mới.
GlobalKey Danh tính toàn cục trong cây Element Truy cập State hoặc hỗ trợ reparenting.

Trong hầu hết danh sách động thông thường, ValueKey dựa trên ID duy nhất và ổn định là lựa chọn đơn giản, dễ đọc và dễ bảo trì.

5.8. Ví dụ lỗi State khi không dùng Key

Giả sử mỗi hàng có một ô checkbox và người dùng chọn hàng đầu tiên. Khi xóa hàng đầu tiên, trạng thái checkbox có thể bị gắn sai với dữ liệu còn lại nếu Flutter tái sử dụng Element theo vị trí.

Ví dụ Widget hàng:

class Task {
  final int id;
  final String title;

  const Task({
    required this.id,
    required this.title,
  });
}

class TaskRow extends StatefulWidget {
  final Task task;

  const TaskRow({
    super.key,
    required this.task,
  });

  @override
  State<TaskRow> createState() => _TaskRowState();
}

class _TaskRowState extends State<TaskRow> {
  bool completed = false;

  @override
  Widget build(BuildContext context) {
    return CheckboxListTile(
      title: Text(widget.task.title),
      value: completed,
      onChanged: (value) {
        setState(() {
          completed = value ?? false;
        });
      },
    );
  }
}

Nếu danh sách được dựng như sau:

Column(
  children: tasks.map((task) {
    return TaskRow(task: task);
  }).toList(),
)

Khi xóa hoặc sắp xếp lại phần tử, các Widget cùng loại không có key có thể được đối chiếu theo vị trí. State completed vì thế có thể tiếp tục tồn tại trên một hàng nhưng hàng đó đã nhận dữ liệu của công việc khác.

Cách khắc phục:

Column(
  children: tasks.map((task) {
    return TaskRow(
      key: ValueKey(task.id),
      task: task,
    );
  }).toList(),
)

Khi đó, Flutter có thêm thông tin để nhận diện đúng từng công việc khi danh sách thay đổi.

5.9. Ví dụ danh sách động hoàn chỉnh

Đoạn code dưới đây minh họa thêm, xóa và sắp xếp danh sách công việc với ValueKey.

import 'package:flutter/material.dart';

class Task {
  final int id;
  final String title;

  const Task({
    required this.id,
    required this.title,
  });
}

class TaskRow extends StatefulWidget {
  final Task task;
  final VoidCallback onDelete;

  const TaskRow({
    super.key,
    required this.task,
    required this.onDelete,
  });

  @override
  State<TaskRow> createState() => _TaskRowState();
}

class _TaskRowState extends State<TaskRow> {
  bool completed = false;

  @override
  Widget build(BuildContext context) {
    return CheckboxListTile(
      key: ValueKey(widget.task.id),
      title: Text(widget.task.title),
      value: completed,
      onChanged: (value) {
        setState(() {
          completed = value ?? false;
        });
      },
      secondary: IconButton(
        icon: const Icon(Icons.delete),
        onPressed: widget.onDelete,
      ),
    );
  }
}

class TaskListPage extends StatefulWidget {
  const TaskListPage({super.key});

  @override
  State<TaskListPage> createState() => _TaskListPageState();
}

class _TaskListPageState extends State<TaskListPage> {
  int nextId = 4;

  final List<Task> tasks = [
    const Task(id: 1, title: 'Học Flutter'),
    const Task(id: 2, title: 'Ôn Dart'),
    const Task(id: 3, title: 'Làm bài tập'),
  ];

  void addTask() {
    setState(() {
      tasks.add(
        Task(
          id: nextId,
          title: 'Công việc $nextId',
        ),
      );

      nextId++;
    });
  }

  void deleteTask(int id) {
    setState(() {
      tasks.removeWhere((task) => task.id == id);
    });
  }

  void reverseTasks() {
    setState(() {
      tasks.setAll(0, tasks.reversed.toList());
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Danh sách công việc'),
        actions: [
          IconButton(
            onPressed: reverseTasks,
            icon: const Icon(Icons.swap_vert),
            tooltip: 'Đảo thứ tự',
          ),
          IconButton(
            onPressed: addTask,
            icon: const Icon(Icons.add),
            tooltip: 'Thêm công việc',
          ),
        ],
      ),
      body: ListView(
        children: tasks.map((task) {
          return TaskRow(
            key: ValueKey(task.id),
            task: task,
            onDelete: () => deleteTask(task.id),
          );
        }).toList(),
      ),
    );
  }
}

Trong ví dụ này, ValueKey(task.id) giúp Flutter đối chiếu từng hàng theo ID. Khi xóa, thêm hoặc đảo thứ tự, State của mỗi hàng có thể được giữ đúng với công việc tương ứng.

Lưu ý rằng ValueKey chỉ hỗ trợ nhận diện Widget; nó không tự lưu trạng thái checkbox vào database. Nếu cần giữ trạng thái hoàn thành sau khi ứng dụng bị đóng, dữ liệu completed cần được đưa vào mô hình dữ liệu và lưu bằng cơ chế phù hợp.

5.10. Keys trong ListView.builder

Với danh sách lớn, ListView.builder thường được sử dụng để chỉ tạo những phần tử cần thiết cho vùng hiển thị.

Ví dụ:

ListView.builder(
  itemCount: tasks.length,
  itemBuilder: (context, index) {
    final task = tasks[index];

    return TaskRow(
      key: ValueKey(task.id),
      task: task,
      onDelete: () => deleteTask(task.id),
    );
  },
)

Đối với danh sách có thay đổi thứ tự hoặc có phần tử bị thêm, xóa, key giúp Flutter nhận diện các hàng theo ID thay vì chỉ dựa vào vị trí.

Với một số cấu trúc danh sách phức tạp sử dụng lazy child delegate, khi cần duy trì chính xác việc ánh xạ Element trong danh sách đã thay đổi mạnh, có thể cần triển khai findChildIndexCallback. Callback này cho Flutter biết vị trí mới của một child dựa trên key, đặc biệt hữu ích khi các phần tử được sắp xếp lại.

5.11. Nguyên tắc lựa chọn Key trong dự án thực tế

Khi lựa chọn Key, nên ưu tiên các nguyên tắc sau:

  1. Sử dụng ID duy nhất và ổn định của dữ liệu làm ValueKey.

  2. Không sử dụng index làm key khi danh sách có thể thay đổi thứ tự hoặc xóa phần tử.

  3. Dùng ObjectKey khi danh tính object thực sự là điều cần theo dõi.

  4. Chỉ dùng UniqueKey khi muốn tạo danh tính mới cho Widget.

  5. Dùng GlobalKey khi có nhu cầu truy cập State hoặc reparenting thực sự.

  6. Không tạo key mới không cần thiết trong mỗi lần build().

  7. Đảm bảo key của các phần tử trong cùng phạm vi đối chiếu không bị trùng theo cách gây xung đột.

  8. Kiểm tra State của từng hàng khi thêm, xóa, lọc và sắp xếp danh sách.

5.12. Kết luận về Keys trong danh sách động

Key là một thành phần quan trọng trong quá trình Flutter đối chiếu Widget cũ và mới. Khi làm việc với danh sách động, key phù hợp giúp bảo toàn mối liên hệ giữa dữ liệu và State của từng phần tử.

ValueKey thường là lựa chọn tốt nhất khi dữ liệu có ID ổn định. ObjectKey phù hợp với danh tính đối tượng, UniqueKey dùng khi cần danh tính mới và GlobalKey dành cho các trường hợp đặc biệt cần nhận diện toàn cục.

Việc sử dụng key đúng cách giúp danh sách hoạt động chính xác hơn, hạn chế lỗi trạng thái bị gắn nhầm phần tử và nâng cao khả năng bảo trì ứng dụng Flutter.

6. Tổng kết kiến thức vòng đời và quản lý trạng thái Widget

Năm nội dung trên liên kết chặt chẽ với nhau trong quá trình phát triển giao diện Flutter.

  • StatefulWidget Lifecycle giúp xác định thời điểm khởi tạo, cập nhật và giải phóng tài nguyên.

  • State Persistence và State Restoration giúp lựa chọn phương pháp duy trì dữ liệu phù hợp với vòng đời của giao diện và ứng dụng.

  • didUpdateWidget xử lý những thay đổi cấu hình từ Widget cha mà không cần tạo lại State.

  • GlobalKey và Reparenting hỗ trợ nhận diện toàn cục, truy cập State và giữ trạng thái trong những tình huống di chuyển Widget phù hợp.

  • Keys trong danh sách động giúp Flutter đối chiếu chính xác các phần tử khi danh sách thêm, xóa hoặc sắp xếp lại.

Khi áp dụng vào dự án thực tế, cần kết hợp các kiến thức này với nguyên tắc quản lý tài nguyên, thiết kế luồng dữ liệu và lựa chọn giải pháp lưu trữ phù hợp. Một Widget được xây dựng tốt không chỉ hiển thị giao diện chính xác mà còn phải duy trì trạng thái đúng lúc, phản ứng với cấu hình mới và giải phóng tài nguyên an toàn khi không còn được sử dụng.

Bài viết khác

REST API & HTTP trong Flutter

REST API & HTTP trong Flutter 1. REST API và HTTP 1.1. Khái niệm REST API REST API là một phương thức thiết kế giao tiếp giữa các hệ thống phần mềm thông qua giao thức HTTP. REST là viết tắt của Representational State Transfer, còn API là viết tắt của Application Programming Interface. REST […]

Dio & HTTP Client trong Flutter

Dio & HTTP Client trong Flutter 1. Tổng quan về Dio và HTTP Client Trong quá trình phát triển ứng dụng Flutter, việc giao tiếp với máy chủ (Server) là một phần quan trọng để ứng dụng có thể lấy dữ liệu, gửi thông tin, đăng nhập, đăng ký tài khoản, tải hình ảnh, cập […]

Flutter Widget Fundamentals – Tối ưu hiệu năng và kiểm thử Widget

Tối ưu hiệu năng và kiểm thử Widget trong Flutter 1. Rebuild Optimization – Tối ưu quá trình xây dựng lại giao diện 1.1. Khái niệm Rebuild Optimization Rebuild Optimization là quá trình tối ưu việc xây dựng lại giao diện trong Flutter nhằm giảm những lần thực thi build() không cần thiết, hạn chế […]

Equivalence Partitioning(Phân vùng tương đương)

1.Equivalence Partitioning (Phân vùng tương đương) là gì? Equivalence Partitioning (Phân vùng tương đương) là một trong những kỹ thuật thiết kế kiểm thử hộp đen (Black-box Testing Techniques) cơ bản và quan trọng nhất trong kỹ nghệ phần mềm. Kỹ thuật này được áp dụng nhằm giảm thiểu số lượng ca kiểm thử (Test […]

Flutter Widget Fundamentals – Các cơ chế UI nâng cao trong Flutter

Các cơ chế UI nâng cao trong Flutter 1. Gesture System và Hit Testing 1.1. Tổng quan về Gesture System Gesture System là hệ thống xử lý cử chỉ tương tác trong Flutter, cho phép ứng dụng nhận biết và phản hồi những hành động của người dùng như chạm, nhấn giữ, kéo thả, vuốt, […]

UAT(Kiểm thử chấp nhận người dùng)

UAT là gì? Trong toàn bộ Vòng đời phát triển phần mềm (SDLC), sau khi trải qua hàng loạt các cấp độ kiểm thử kỹ thuật nội bộ như Unit Testing, Integration Testing, System Testing, sản phẩm phần mềm vẫn chưa thể chính thức phát hành. Bước đệm cuối cùng và mang tính chất quyết […]

Leave a Reply

Your email address will not be published. Required fields are marked *