[Flutter]用GetX一次完成APP基本架構

HC
33 min readApr 26, 2024

Flutter是開發手機APP的一個很好的工具,可以同時開發Android和IOS端的APP,現在甚至是網頁、Windows、MacOS都有支援,當然,目前最常用Flutter開發的還是在手機平台。

對於剛開始用Flutter開發最困難的或許就是要如何建立基本架構,我的畫面要放在哪裡?資料要放在哪裡?畫面又要如何跟資料做連結呢?至少這些困擾了我一陣子。

對我來說使用一個狀態管理的套件是最好的解決方案,目前大家常用的有Provider、Bloc、GetX等等,這麼多又要如何選擇呢?當你問我這個問題時我的答案絕對是GetX(當然每個人的想法不同,最好絕對是自己去查是看看),雖然我沒有用過的所有的狀態管理套件,我用過的僅有ProviderGetX,然而GetX實在是太好用了,不僅使用簡單、程式碼簡潔更包含多種功能,這也是我推薦使用GetX的原因。

好了,話不多說馬上開始介紹GetX的使用吧。

1.建立Flutter專案

在開始前,我們先簡單的介紹一下Flutter原生的狀態管理(當然如果你不是第一次用flutter這邊你可以先跳過了),首先,開啟IDE(整合式開發環境,我使用的是Android Stuido),建立一個新的專案

選擇Flutter,Next

選擇Flutter專案

建立名為get_example的專案

建立完成後我們可以在lib -> main.dart找到自動建立的範本

import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}

class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});

// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.

// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".

final String title;

@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;

void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}

@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}

MyHomePage是一個StatefulWidget,而其中State<MyHomePage> createState() => _MyHomePageState()建立了一個狀態可以被改變的Class _MyHomePageState,我們的畫面、狀態(我們暫存的資料等等)都會存放在這裡。

_MyHomePageState中我們可以看到在點擊FAB(Float action button)後,_incrementCounter中會調用setState()並在其中執行_counter ++。這裡,程式會先執行_counter ++,然後會調用build()重新刷新畫面,達成更新畫面的動作,這就是setState()的作用。

void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}

以上是範例中展示的簡單狀態改變操作,而我們可以發現當我們的程式碼很少時,這個方法方便好用,而且很直觀,但當我們的程式碼多起來時,資料、邏輯與畫面的code混在一起就不易維護了。

這時Get就派上用場了,我接下來會基於這個範例加入Get來做狀態管理。

2.導入GetX

首先,我們需要在pubspec.yaml中加入get: ^latest,並點擊Pub get導入Get。

接下來,我會建立一個app.dart的檔案,將原本main.dart的MyApp class移至裡面,至於為甚麼要這麼做?單純是因為我覺得這樣比較容易閱讀。

建立一個screens的資料夾來存放畫面檔案,建立後在裡面新增home_page.dart的檔案,並將原本main.dart中的MyHomePage和_MyHomePageState丟到裡面。一樣這邊也是個人習慣,你可以照自己的習慣去整理code。

將main.dart裡的code分割整理完後,我們回到app.dart中,將MaterialApp替換成GetMaterialApp。

class MyApp extends StatelessWidget {
const MyApp({super.key});

// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}

到這邊我們已經成功導入Get了。

3.建立GetXController

GetXcontroller是Get套件提供的控制器,我們將會在裡面存放畫面要用到的資料與邏輯。在這邊我們要先建立一個名為controllers的資料夾,並新增home_controller.dart。

之後在home_controller.dart中建立一個名為HomeController的class並用extends繼承GetXcontroller。

接著我們在HomeController建立一個參數counter。

class HomeController extends GetxController{
var counter = 0.obs;

//也可以這樣寫
Rx<int> counterRx = Rx(0);

//如果是初始話是null的話可以這樣寫
Rx<int?> counterNull = Rx(null);
}

在建立參數時我們在值的後面加上.obs會讓原本的參數類型變成Rx類型(ex:Rx<int>),這會讓這個參數變成可觀察,當Rx的value改變時,畫面觀察到就會自動刷新了。

再來建立incrementCounter(),用來處理counter增加的動作。

class HomeController extends GetxController{
var counter = 0.obs;

//也可以這樣寫
Rx<int> counterRx = Rx(0);

//如果是初始話是null的話可以這樣寫
Rx<int?> counterNull = Rx(null);

void incrementCounter() {
counter.value ++;
}
}

在引用counter時,你會發現counter ++會出錯,仔細觀察後可以發現counter的類型不再是Int了,而是RxInt,那當然++這個方法就不可用了。因此我們需要在counter後面加入.value來取出值再來做加減。

基本的controller到這邊已經設定完成了,接下來我們會處理畫面的部份。

4.畫面刷新

建立了controller後,我們還需要將畫面與controller做連結,這樣畫面才會去觀察Rx參數的改變來做畫面更新。

回到home_page.dart,我們要先將原本的StatefulWidget改成StatelessWidget,在使用了Get之後我們將不再需要StatefulWidget了,Get會處理所有的狀態管並做畫面的刷新,同時我們也不會在MyHomePage()加入任何狀態了,所以也請把_counter和_incrementCounter()移除或註解掉。

小提示:把滑鼠點在StatefulWidget上按下Alt+Enter可以快速轉換為StatelessWidget,如果沒有出現要先把_MyHomePageState中的可變參數和setState()拿掉,在這邊可以直接把_counter和_incrementCounter()移除

整理後的home_page.dart如下:

class MyHomePage extends StatelessWidget {
const MyHomePage({
super.key,
required this.title,
});

final String title;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

接下來,我們在build()下面加入

HomeController homeController = Get.put(HomeController());

使用Get.put()可以建立HomeController的實例,只要這頁面還存活,你就可以在各個地方用Get.find()取用HomeController。在建立HomeController後,我們可以將原本的_counter和_incrementCounter替換成HomeController裡建立的參數了(counter和incrementCounter)。

class MyHomePage extends StatelessWidget {
const MyHomePage({
super.key,
required this.title,
});

final String title;

@override
Widget build(BuildContext context) {
HomeController homeController = Get.put(HomeController());
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'${homeController.counter}',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: homeController.incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

然而這時候你會發現怎麼點了按鈕畫面卻沒有刷新?沒錯,雖然引用了HomeController的參數,但我們卻沒有讓畫面去監聽數值的變化。為了讓數值改變時畫面能自動做更新,我們需要在我們想要更新的Widget外去包裹Obx()。以上面的例子來說,我們想要的是當counter改變時去更新Text(),因此我們就需要在Text()外包裹Obx(),Obx()會去監聽數值改變並去更新底下的畫面,如下:

Obx(() => Text(
'${homeController.counter}',
style: Theme.of(context).textTheme.headlineMedium,
))

這樣在重新運行後應該會發現畫面可以刷新了,當數值改變時,只有有Obx包裹的畫面會自動做刷新,也因此畫面不會再像使用setState()時從build()整個做更新了。

注意,Obx()內必須包含Rx類型的參數,不然會報錯喔。

到這裡去run就可以發現畫面會自動刷新囉。

5.建立路由與Binding

前面已經說明了Get狀態管理的基本配置了,我們已經看到Get狀態管理的效果了,但如果我要寫一個很多頁面的專案要怎麼寫比較好呢?這時候就會用到GetPage和binding了。

第一步,我們要先建立一個bindings的資料夾,並在裡面建立home_binding.dart,然後再建立HomeBinding的class,用extends繼承Bindings。

這時會出現錯誤,只要將滑鼠放在錯誤上並點擊Create 1 missing override,添加遺失的override function就可以了。

接著,在剛剛加入的override dependencies()裡面加入Get.lazyPut(() => HomeController()),完整如下:

class HomeBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut(() => HomeController());
}
}

Binding的用處在於可以將畫面跟controller綁定在一起,使controller會在畫面建立時自動被生成,並且在畫面關閉時被釋放,讓我們更好的去管理controller,同時也不需要再寫Get.put()去實例化controller了。

但我們現在還沒有完全將controller跟畫面綁定,我們還需要建立路由才能完成綁定,並且之後的任何頁面跳轉都需要使用Get提供的跳轉方式,才會有binding的效果。接下來會詳細介紹。

再來要建立路由了,我們先建立routes.dart,並在裡面建立名為Routes的class。

然後建立一個名為home的路由,名稱怎麼定都可以,但通常會像網址一樣,一層一層排下去,例如如果我會在MyHomePage開啟AccountPage,我AccountPage的路由就可以寫成/home/account。

static const home = '/home';

接著需要建立List<GetPage>,GetPage可以將我們畫面跟路由綁定在一起,這樣Get就會知道這個Page的路徑是什麼了,之後用路徑就可以找到相對應的頁面了。另外也可以在GetPage裡加上前面建立的binding,使路由、畫面和controller綁在一起,如下:

class Routes {
static const home = '/home';

static final routePage = [
GetPage(
name: home,
page: () => const MyHomePage(),
binding: HomeBinding(),
),
];
}

這邊特別提一點,原本MyHomePage需要傳入一個參數title,在這邊要先移除,之後將不會再用建構式傳入參數了,而是會用Get的方式去處理,這個在後面會說明。

到這裡幾乎快完成了,讓我們回到app.dart,我們還需要做一些改造Get才能把我們剛剛的設定套用到APP裡。

首先,在GetMaterialApp底下找到getPages並傳入我們前一步Routes裡建立的routePage,接著移除home,然後找到initialRoute並傳入Routes中home的路由,這樣Get就知道進APP時要先開啟home這個路由了。

class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
getPages: Routes.routePage,
initialRoute: Routes.home,
// home: const MyHomePage(),
);
}
}

最後,我們回到home_page.dart,將原本的StatelessWidget改為GetView<HomeController>,並且移除HomeController homeController = Get.put(HomeController()),然後將原本的homeController替換成controller。現在home_page.dart應該如下:

class MyHomePage extends GetView<HomeController> {
const MyHomePage({
super.key,
});


@override
Widget build(BuildContext context) {
// HomeController homeController = Get.put(HomeController());
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text(''),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Obx(() => Text(
'${controller.counter}',
style: Theme.of(context).textTheme.headlineMedium,
)),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: controller.incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

你會發現controller就等於是原本的homeController,你不需要再寫Get.find()或Get.put()去取得Controller,你可以直接在畫面中調用controller,這會讓我們的程式碼更簡潔也更好維護。

現在,點擊run後應該可以看到畫面正確展示了MyHomePage,並且點擊+號畫面也會刷新了,而我們的程式碼已經跟一開始完全不同了。

6.畫面跳轉與資料傳輸

在最後我們再建立一個新的頁面去看畫面要如何跳轉和傳輸資料吧。

讓我們先建立second_page.dart、second_controller.dart、second_binding.dart,並且在Routes新增路由,作法跟前面相同,這邊就直接展示結果了:

second_page.dart

class SecondPage extends GetView<SecondController> {
const SecondPage({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Second Page'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'This is second page count:',
),
Obx(() => Text(
'${controller.counter}',
style: Theme.of(context).textTheme.headlineMedium,
)),
ElevatedButton(
onPressed: () {
//返回時夾帶參數
Get.back(result: controller.counter.value);
},
child: const Text('Back'),
)
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: controller.incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}

我們建立了一個跟MyHomePage很相似的畫面,但多了一個返回按鈕,其中Get.back()相當於Navigator.of(context).pop()會跳出這一頁,而Get.back()中的result則是我們要返回的資料。

second_controller.dart

class SecondController extends GetxController{
var counter = 0.obs;

@override
void onInit() {
//當controller被建立時會先執行這段

//用Get.arguments可以取得頁面跳轉時夾帶的參數
//Get.arguments是dynamic所以有可能是null,務必要加上??
counter.value = Get.arguments ?? 0;

//也可以這樣寫取得HomeController的counter,因為HomeController還存活著
//counter.value = Get.find<HomeController>().counter.value;
super.onInit();
}

void incrementCounter() {
counter.value ++;
}
}

這裡也是跟HomeController一樣,但多了onInit(),controller在生成時會先執行onInit()這個function,有要初始化的數值可以放在這裡。另外我們使用了Get.arguments去取得頁面跳轉時傳送的參數(我們會將MyHomePage的counter傳過來),並將值賦予給counter。

second_binding.dart

class SecondBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut(() => SecondController());
}
}

routes.dart

class Routes {
static const home = '/home';

static const second = '/home/second';

static final routePage = [
GetPage(
name: home,
page: () => const MyHomePage(),
binding: HomeBinding(),
),
GetPage(
name: second,
page: () => const SecondPage(),
binding: SecondBinding(),
),
];
}

以上完成後我們回到home_page.dart,再這邊新增一個按鈕跳轉頁面到SecondPage

ElevatedButton(
onPressed: () async {
//使用Get.toNamed跳轉頁面,並夾帶arguments
var result = await Get.toNamed(
Routes.second,
arguments: controller.counter.value,
);
if(result != null){
controller.counter.value = result;
}

},

這裡用Get.toNamed(),使用SecondPage的路由並在arguments夾帶counter.value,這樣我們前面在SecondController的Get.arguments就可以接收到值了。

另外我們也在Get.toNamed()加上await並用result去接收回傳值,當回傳不為null時將值賦予給HomeController的counter。這時當你點擊SecondPage的back按鈕返回時就會發現在SecondPage的counter變化也被傳回來了,而點擊AppBar的返回時則不會回傳任何東西。你也會發現只是將值賦予給counter畫面就會自動刷新了,這就是Get狀態管理好用的地方了。

到這裡就全部結束了,run一次專案就能看到結果了。

結語

以上就是Get狀態管理的基本架構了,我認為使用這個架構可以很好的處理規模較大的專案,擴展性很好也方便維護,非常推薦大家使用Get。

補充:

  1. 當我的Rx是一個class或list時畫面不會刷新怎麼辦?

這邊我習慣使用refresh()去更新Rx的value,如下:

class Controller extends GetxController {

var member = Member(name: 'Elon', phone: '0204').obs;

void updateMember({
required String name,
required String phone,
}) {
member.value.name = name;
member.value.phone = phone;

//call refresh()會刷新這個物件,畫面也會刷新
member.refresh();
}
}

class Member {
String name;
String phone;

Member({
required this.name,
required this.phone,
});
}

--

--