Hướng Nghiệp Dữ Liệu

Xây dựng app Flutter đầu tiên: bố cục, điều hướng và gọi API

Được viết bởi Admin|0 lượt xem
Xây dựng app Flutter đầu tiên: bố cục, điều hướng và gọi API
Mục lục bài viết (34)

Đây là bài cuối trong series 5 bài về Flutter. Bạn đã biết Dart, đã hiểu widget là gì, đã phân biệt được StatelessWidgetStatefulWidget. Nhưng có một khoảng trống mà rất nhiều người mới mắc kẹt: biết từng viên gạch, không biết xây nhà.

Thực tế khi nhận một màn hình mới trong app, bạn phải trả lời 4 câu hỏi gần như cùng lúc: bố cục thế nào? chuyển màn hình ra sao? lấy dữ liệu từ đâu? trạng thái đặt ở đâu? Bài này trả lời cả 4, theo thứ tự bạn sẽ gặp khi làm thật, và kết thúc bằng một app 3 màn hình chạy được có gọi API.

Mục tiêu không phải là dạy bạn thuộc hết API của Flutter — mà là để bạn có một khuôn mẫu đúng để lặp lại cho mọi màn hình về sau.

Từ widget rời rạc đến app: bạn còn thiếu 4 thứ

Sau bài 4, bạn viết được một widget và cho nó đổi trạng thái. Nhưng một app thật cần thêm:

Thiếu Câu hỏi cần trả lời Bài này ở mục
Bố cục Làm sao xếp nhiều widget thành màn hình không tràn? 2–4
Điều hướng Làm sao chuyển màn hình và truyền dữ liệu qua lại? 5
Dữ liệu Làm sao gọi API, hiển thị loading, xử lý lỗi? 6
Tổ chức code File nào để ở đâu khi app có 20 màn hình? 1, 7

Đây cũng chính là những thứ tách một "demo học tập" khỏi một "dự án để nộp" — và là phần nhà tuyển dụng nhìn vào khi xem portfolio của bạn.

Một app Flutter gồm mấy lớp?

Trước khi viết dòng code nào, hãy nắm bức tranh. Một màn hình Flutter điển hình có 3 lớp:

[object Object]

Quy tắc giúp bạn không viết code rối:

  • Widget không gọi API trực tiếp. Widget chỉ nói "tôi cần danh sách khoá học"; lớp dữ liệu lo phần lấy.
  • Trạng thái không nằm rải rác. Một màn hình nên có một nguồn dữ liệu rõ ràng.
  • Mỗi lớp chỉ biết lớp ngay dưới nó. UI không cần biết API trả JSON hay đọc từ SQLite.

Bạn chưa cần thư viện kiến trúc nào để làm đúng điều này — chỉ cần tách thư mụcfile đúng chỗ.

Cấu trúc thư mục nên dùng ngay từ đầu

Đừng để tất cả vào main.dart. Với một app vừa và nhỏ, cấu trúc dưới đây là đủ và vẫn mở rộng được:

[object Object]

Nguyên tắc đơn giản: screens/ chứa màn hình, widgets/ chứa thứ được dùng lại ở nhiều màn hình, services/ chứa chỗ duy nhất biết cách lấy dữ liệu. Khi app lớn lên, bạn tách tiếp theo tính năng (features/) — nhưng đừng làm sớm.

Bố cục: 6 widget và 4 cái bẫy

6 widget bố cục bạn dùng 90% thời gian

Widget Dùng khi
Column / Row Xếp con theo chiều dọc / ngang
Expanded / Flexible Chia phần không gian còn lại cho con
Padding / SizedBox Tạo khoảng cách (SizedBox nhẹ hơn)
Stack Chồng lớp — ảnh nền có chữ, badge, dialog
ListView Danh sách cuộn đượckhông biết trước số lượng
Container Khi cần đồng thời: kích thước + nền + bo góc + viền

Bẫy 1 — RenderFlex overflowed by X pixels

Đây là lỗi số 1 của người mới. Nguyên nhân: Row/Column chỉ có một trục không giới hạn, và bạn nhét vào đó nội dung dài hơn màn hình.

[object Object]

Ghi nhớ: Expanded là cách nói "phần còn lại chia cho con này". Trong Row, Expanded chia theo chiều ngang; trong Column, theo chiều dọc.

Bẫy 2 — Column bên trong Column không giới hạn chiều cao

[object Object]

Expanded bên trong ListView sẽ lỗi vì ListView cuộn được nên chiều cao là vô hạn, không có gì để "chia". Cách sửa: bỏ Expanded, hoặc dùng shrinkWrap: true cẩn thận, hoặc đổi ListView thành Column nếu danh sách ngắn.

Bẫy 3 — ListView lồng trong SingleChildScrollView

Lỗi kinh điển: "Vertical viewport was given unbounded height". Cách sửa đúng là shrinkWrap: true + physics: NeverScrollableScrollPhysics():

[object Object]

Nhưng lời khuyên thật là: nếu danh sách có thể dài, đừng lồng. Dùng CustomScrollView hoặc để ListView là widget cuộn duy nhất.

Bẫy 4 — Lạm dụng Container

Container làm được rất nhiều việc nên người mới dùng nó cho mọi thứ. Mỗi Container là một cụm widget (Padding + DecoratedBox + ConstrainedBox...). Khi chỉ cần khoảng cách, dùng SizedBox; chỉ cần nền, dùng DecoratedBox; chỉ cần canh giữa, dùng Center.

Đây không phải chuyện "code cho đẹp": trong một ListView dài, thay Container bằng SizedBox ở vài trăm item có thể giảm đáng kể chi phí dựng layout.

Bố cục dùng chung cho mọi màn hình

Một khuôn mẫu bạn có thể copy cho gần như mọi màn hình danh sách:

[object Object]

Ba chi tiết đáng chú ý:

  • SafeArea tránh nội dung bị tai thỏ / thanh dưới che.
  • Expanded bọc phần danh sách để nó chiếm hết chiều cao còn lại và cuộn được.
  • crossAxisAlignment: CrossAxisAlignment.stretch cho các con rộng bằng nhau — thay vì phải width: double.infinity từng cái.

Responsive: đủ dùng, không cần thư viện

Flutter chạy trên điện thoại, tablet, web và desktop. Bạn không cần giải pháp phức tạp, chỉ cần 2 công cụ:

[object Object]

Và khi bố cục cần thay đổi hẳn giữa điện thoại và tablet — không chỉ khoảng cách:

[object Object]

Ghi nhớ ngắn gọn: MediaQuery để lấy kích thước màn hình, LayoutBuilder để biết không gian mà widget cha dành cho bạn. Trong widget con nằm sâu trong cây, LayoutBuilder chính xác hơn.

Điều hướng: Navigator hay go_router?

Cách cơ bản — Navigator.push

Flutter có sẵn Navigator. Với 2–3 màn hình, cách này hoàn toàn ổn:

[object Object]

Ba điều cần nhớ về Navigator:

  1. Luôn await và kiểm tra mounted nếu bạn dùng context sau khi await.
  2. Truyền dữ liệu đi bằng tham số constructor; trả kết quả về bằng Navigator.pop(context, giáTrị).
  3. MaterialPageRoute là animation mặc định. Trên iOS nên để mặc định để có cử chỉ vuốt về.

Cách cho app thật — go_router

Khi app có deep link, bottom navigation bar, hoặc màn hình cần URL (bản web), hãy dùng go_router:

[object Object]
[object Object]
Navigator.push go_router
Cài đặt Có sẵn Thêm package
Hợp với 2–5 màn hình, app nhỏ App nhiều màn hình, web, deep link
Deep link / URL Phải tự xử lý Có sẵn theo cấu trúc route
Bottom nav giữ trạng thái Tự làm, dễ sai StatefulShellRoute
Kiểm soát chuyển hướng tập trung Rải rác redirect, refreshListenable

Khuyến nghị: học Navigator trước để hiểu bản chất, nhưng nếu bạn chắc chắn app sẽ có từ 5 màn hình trở lên, dựng go_router ngay từ đầu sẽ tiết kiệm kha khá công refactor về sau.

Gọi API: từ JSON đến màn hình

Bước 1 — Chọn thư viện

[object Object]

Với người mới, http là đủ. dio mạnh hơn nhưng phần lớn sức mạnh đó chỉ cần khi bạn có auth token, refresh token, logging request ở cấp app.

Bước 2 — Viết model với fromJson

Đây là chỗ nhiều người bỏ qua và trả giá về sau: đừng dùng Map<String, dynamic> xuyên suốt app.

[object Object]

Lợi ích cụ thể: khi API đổi tên trường, bạn sửa một chỗ. Khi bạn gõ sai tên trường, IDE báo ngay chứ không đợi tới lúc chạy app mới null. Và fromJson là nơi đúng để xử lý dữ liệu thiếu — như ?? '(không có tiêu đề)' ở trên.

Bước 3 — Service: nơi duy nhất biết gọi API

[object Object]

Bốn điểm cần chú ý — đây là những chỗ phân biệt code học tập với code đi làm:

  1. Luôn kiểm tra statusCode. Nhiều bạn chỉ jsonDecode(res.body) rồi ngạc nhiên khi app crash vì body là trang HTML lỗi 500.
  2. Luôn có timeout. Không có timeout, app sẽ quay loading mãi khi mạng yếu.
  3. dart:convert không cần cài gì thêm — nó có sẵn trong SDK.
  4. Ném lỗi ở tầng service, bắt ở tầng UI. Service không nên biết gì về SnackBar.

Bước 4 — Hiển thị 4 trạng thái, không phải 1

Đây là điều tôi thấy thiếu nhiều nhất ở app của người mới: họ chỉ code trạng thái có dữ liệu. Một màn hình thật có 4 trạng thái:

[object Object]

Và widget dùng chung để hiển thị:

[object Object]

Ba trạng thái Loading, Failure, Successhợp đồng giữa tầng dữ liệu và tầng UI. Khi bạn có nó, việc thêm màn hình mới trở nên máy móc: gọi service → bắt lỗi → đẩy vào UiState.

Bước 5 — Màn hình danh sách hoàn chỉnh

[object Object]

Chi tiết if (!mounted) return; rất quan trọng. Không có nó, bạn sẽ gặp lỗi setState() called after dispose() khi người dùng bấm back trong lúc request đang bay. Đây là lỗi runtime phổ biến nhất ở app gọi API của người mới.

Vì sao dùng initState + setState thay vì FutureBuilder? Cả hai đều đúng. FutureBuilder gọn cho trường hợp đọc một lần, nhưng khó "thử lại" và dễ gọi API lại mỗi lần rebuild nếu bạn tạo Future trực tiếp trong build(). Với màn hình có nút refresh, cách viết trên rõ ràng hơn.

Quản lý trạng thái: chọn đúng mức, đừng nhảy cóc

Sau khi app có 2–3 màn hình chia sẻ dữ liệu, setState sẽ bắt đầu khó chịu. Bảng dưới đây giúp bạn chọn đúng mức mà không cần thử hết:

Quy mô Nên dùng Dấu hiệu đã đến lúc đổi
1 màn hình, dữ liệu riêng setState
Vài widget cùng nghe một giá trị ValueNotifier + ValueListenableBuilder Không muốn gọi setState cả màn hình
Nhiều màn hình chia sẻ dữ liệu Provider (hoặc Riverpod) Phải truyền dữ liệu qua 3 tầng widget
Logic phức tạp, nhiều luồng Riverpod / BLoC Cần test state mà không dựng UI

Lời khuyên thật lòng: đừng học Riverpod/BLoC trong tuần đầu. Người mới dùng ngay thường viết ra những file cấu hình phức tạp mà chưa hiểu vì sao cần. Cứ để setState làm khổ bạn một chút — chính cái khổ đó giúp bạn hiểu vì sao các thư viện kia tồn tại.

Form và nhập liệu

Form trong Flutter gọn hơn bạn nghĩ, nhờ Form + TextFormField:

[object Object]

Hai điều bắt buộc nhớ:

  • TextEditingController phải dispose(). Không gọi dispose, bạn rò rỉ bộ nhớ mỗi lần mở và đóng form. Đây là lỗi tôi thấy ở gần như mọi project đầu tay.
  • _formKey.currentState!.validate() chạy tất cả validator và hiển thị lỗi dưới từng ô. Đừng tự viết if cho từng trường.

Tổng kết: 6 lỗi hay gặp và cách sửa

Lỗi / triệu chứng Nguyên nhân Cách sửa
RenderFlex overflowed Nội dung dài hơn khung Bọc Expanded/Flexible, thêm overflow: TextOverflow.ellipsis
Vertical viewport was given unbounded height ListView trong Column không có chiều cao xác định Bọc Expanded, hoặc shrinkWrap: true + NeverScrollableScrollPhysics
setState() called after dispose() Request xong sau khi màn hình bị gỡ if (!mounted) return; trước setState
App quay loading mãi Không có timeout, hoặc không xử lý nhánh lỗi .timeout(...) + try/catch + trạng thái Failure
type 'Null' is not a subtype of type 'String' Ép kiểu JSON không cẩn thận Dùng as String? + giá trị mặc định trong fromJson
Hot reload không đổi giao diện Thay đổi trong initState/main hoặc thêm biến toàn cục Nhấn R (hot restart), dùng flutter clean nếu vẫn lạ

Ba lệnh cứu bạn khi bế tắc:

[object Object]

Thực hành: 3 bài tập nên làm ngay

Đừng đọc hết rồi bỏ qua phần này — nhớ code chỉ khi tay bạn gõ.

  1. Màn hình chi tiết (/post/:id). Thêm PostDetailScreen gọi fetchPost(id), có StateView riêng, có AppBar với tiêu đề động. Mục tiêu: quen truyền tham số và xử lý 4 trạng thái cho một màn hình mới.
  2. Tìm kiếm + lọc phía client. Thêm TextField lọc danh sách theo tiêu đề (không gọi lại API). Mục tiêu: hiểu setState và cách dữ liệu dẫn xuất (derived data) không cần lưu lại state.
  3. Chuyển sang go_router. Đổi 2 màn hình sang go_router, thử mở /post/3 trên Chrome để thấy deep link chạy. Mục tiêu: thấy rõ Navigatorgo_router khác nhau ở đâu.

Sau 3 bài tập này, bạn đã có một app nhỏ đủ để làm portfolio — và đủ để đọc hiểu mọi project Flutter khác.

Checklist: app đầu tiên của bạn đã đủ tốt chưa?

  • Không có file nào dài trên 300 dòng, mỗi màn hình một file
  • Không có Map<String, dynamic> đi lạc vào tầng UI
  • Mọi màn hình gọi API đều có Loading / Success / Failure, có nút thử lại
  • Mọi await rồi dùng context/setState đều kiểm tra mounted
  • Mọi ControllerTimer đều được dispose
  • flutter analyze không còn cảnh báo
  • Chạy thử trên màn hình nhỏ (điện thoại) và màn hình lớn (web) không tràn layout

Bước tiếp theo

Bạn vừa hoàn thành series 5 bài. Hướng đi hợp lý tiếp theo, theo thứ tự tôi khuyên:

  1. Đưa AI vào app — đây là phần được quan tâm nhất hiện nay, và app của bạn đã đủ nền tảng để làm: Tích hợp Gemini AI vào app Flutter bằng ví dụ thực tế. Bài đó dạy cách gọi AI qua backend proxy thay vì để API key trong app — kiến thức bảo mật bạn buộc phải biết trước khi làm dự án thật.
  2. Quản lý trạng thái nghiêm túc — khi bạn thấy setState bắt đầu đau, hãy học Provider trước, Riverpod sau.
  3. Làm một dự án có người dùng thật — đồ án, app cho công việc của chính bạn, hoặc app cho một người quen. Dự án có người dùng thật dạy bạn nhiều hơn 10 bài tutorial.

Câu hỏi thường gặp

Bao lâu thì làm được app như trong bài này?

Nếu bạn học đều 1–2 giờ mỗi ngày, khoảng 2–3 tuần sau khi nắm widget. Phần tốn thời gian nhất không phải cú pháp mà là quen với việc dữ liệu chảy từ API qua state đến UI. Khi nắm được luồng đó, màn hình mới chỉ là lặp lại.

Có nên học go_router ngay không?

Nếu bạn làm app trên 5 màn hình hoặc cần chạy web: có. Nếu chỉ đang luyện 2–3 màn hình: học Navigator trước, nó là nền tảng và go_router cũng dựng trên nó.

http hay dio?

http cho gần như mọi dự án học tập và phần lớn dự án thật. Chuyển sang dio khi bạn cần interceptor để tự gắn token, log request, hoặc retry theo cấu hình.

Vì sao phải qua model mà không dùng thẳng JSON?

Vì model là hợp đồng giữa API và UI. Dùng thẳng Map, mỗi lần API đổi một trường bạn phải sửa rải rác nhiều màn hình, và IDE không giúp bạn phát hiện lỗi gõ sai. Chi phí viết fromJson rất nhỏ so với lợi ích.

Có cần biết lập trình backend để học Flutter không?

Không, khi học. Nhưng để làm dự án thật thì cần biết gọi API và hiểu khái niệm API key không được để trong app. Hai bài học này đủ cho phần lớn app đọc dữ liệu.

setState có làm app chậm không?

Bản thân nó không. Cái làm chậm là gọi setState ở quá cao trong cây widget khiến cả màn hình phải dựng lại. Cách sửa: đưa state xuống widget nhỏ nhất cần nó, và tách widget con thành class riêng để chúng không bị rebuild theo.

Series 5 bài học Flutter cho người mới

  1. Có nên học Flutter năm 2026? Lộ trình khóa học lập trình Flutter
  2. Cài đặt Flutter trên Windows, macOS, Linux – chi tiết từng bước
  3. Dart cho người mới: 10 khái niệm cần nắm trước khi viết app Flutter
  4. Widget trong Flutter là gì? Hiểu StatelessWidget và StatefulWidget
  5. Xây dựng app Flutter đầu tiên: bố cục, điều hướng và gọi API (bài này)

Xong series, hướng đi tiếp theo là Tích hợp Gemini AI vào app Flutter.

Nếu muốn học có người sửa bài và làm dự án thật từ đầu, bạn có thể xem khóa học Flutter thực chiến, lộ trình nâng cao hoặc lịch khai giảng.