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)
- Từ widget rời rạc đến app: bạn còn thiếu 4 thứ
- Một app Flutter gồm mấy lớp?
- Cấu trúc thư mục nên dùng ngay từ đầu
- Bố cục: 6 widget và 4 cái bẫy
- 6 widget bố cục bạn dùng 90% thời gian
- Bẫy 1 — RenderFlex overflowed by X pixels
- Bẫy 2 — Column bên trong Column không giới hạn chiều cao
- Bẫy 3 — ListView lồng trong SingleChildScrollView
- Bẫy 4 — Lạm dụng Container
- Bố cục dùng chung cho mọi màn hình
- Responsive: đủ dùng, không cần thư viện
- Điều hướng: Navigator hay go_router?
- Cách cơ bản — Navigator.push
- Cách cho app thật — go_router
- Gọi API: từ JSON đến màn hình
- Bước 1 — Chọn thư viện
- Bước 2 — Viết model với fromJson
- Bước 3 — Service: nơi duy nhất biết gọi API
- Bước 4 — Hiển thị 4 trạng thái, không phải 1
- Bước 5 — Màn hình danh sách hoàn chỉnh
- Quản lý trạng thái: chọn đúng mức, đừng nhảy cóc
- Form và nhập liệu
- Tổng kết: 6 lỗi hay gặp và cách sửa
- Thực hành: 3 bài tập nên làm ngay
- Checklist: app đầu tiên của bạn đã đủ tốt chưa?
- Bước tiếp theo
- Câu hỏi thường gặp
- Bao lâu thì làm được app như trong bài này?
- Có nên học go_router ngay không?
- http hay dio?
- Vì sao phải qua model mà không dùng thẳng JSON?
- Có cần biết lập trình backend để học Flutter không?
- setState có làm app chậm không?
- Series 5 bài học Flutter cho người mới
Đâ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 StatelessWidget và StatefulWidget. 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ục và file đú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 được và khô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ú ý:
SafeAreatránh nội dung bị tai thỏ / thanh dưới che.Expandedbọc phần danh sách để nó chiếm hết chiều cao còn lại và cuộn được.crossAxisAlignment: CrossAxisAlignment.stretchcho các con rộng bằng nhau — thay vì phảiwidth: double.infinitytừ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:
- Luôn
awaitvà kiểm tramountednếu bạn dùngcontextsau khiawait. - Truyền dữ liệu đi bằng tham số constructor; trả kết quả về bằng
Navigator.pop(context, giáTrị). MaterialPageRoutelà 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 | Có StatefulShellRoute |
| Kiểm soát chuyển hướng tập trung | Rải rác | 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:
- 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. - Luôn có
timeout. Không có timeout, app sẽ quay loading mãi khi mạng yếu. dart:convertkhông cần cài gì thêm — nó có sẵn trong SDK.- 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, Success là hợ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ớ:
TextEditingControllerphảidispose(). Không gọidispose, 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ảvalidatorvà hiển thị lỗi dưới từng ô. Đừng tự viếtifcho 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õ.
- Màn hình chi tiết (
/post/:id). ThêmPostDetailScreengọifetchPost(id), cóStateViewriêng, cóAppBarvớ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. - Tìm kiếm + lọc phía client. Thêm
TextFieldlọc danh sách theo tiêu đề (không gọi lại API). Mục tiêu: hiểusetStatevà cách dữ liệu dẫn xuất (derived data) không cần lưu lại state. - Chuyển sang
go_router. Đổi 2 màn hình sanggo_router, thử mở/post/3trên Chrome để thấy deep link chạy. Mục tiêu: thấy rõNavigatorvàgo_routerkhá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
awaitrồi dùngcontext/setStateđều kiểm tramounted - Mọi
ControllervàTimerđều đượcdispose -
flutter analyzekhô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:
- Đư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.
- Quản lý trạng thái nghiêm túc — khi bạn thấy
setStatebắt đầu đau, hãy họcProvidertrước,Riverpodsau. - 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
- Có nên học Flutter năm 2026? Lộ trình khóa học lập trình Flutter
- Cài đặt Flutter trên Windows, macOS, Linux – chi tiết từng bước
- Dart cho người mới: 10 khái niệm cần nắm trước khi viết app Flutter
- Widget trong Flutter là gì? Hiểu StatelessWidget và StatefulWidget
- 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.
