32일차에는 페이지 별로 컨트롤러를 생성하고, 바인딩하여 사용하는 방식을 학습했다. 이는 디자인패턴 중 MVC에 해당하며, 이를 사용해 로그인 기능을 포함한 앱을 제작해 보았다.
학습한 내용
- 페이지 별로 Controller 생성
- Binding
- MVC 패턴
- GetX AuthController
페이지마다 컨트롤러를 두고 관리하면 유지보수가 쉽고, 코드가 깔끔해진다.
Page별 Controller 예시
- LoginPage에는 LoginController를 만들어 사용
- SignupPage에는 SignupController를 만들어 사용
- MainPage에는 MainController를 만들어 사용
하나의 페이지에서 컨트롤러를 사용하기 위해서는 Get.put
을 하고, Get.find
를 통해 사용해야 한다. 이러한 Binding을 앱이 시작할 때나, 페이지가 만들어 질 때 하는 두 가지 방법이 있다.
GetMaterialApp
의 initialBinding
을 통해 앱이 생성될 때 함께 생성할 컨트롤러를 정의할 수 있다.GetMaterialApp(
...
initialBinding: BindingBuilder(() {
Get.put(LoginController());
Get.put(SignupController());
Get.put(MainController());
}),
...
);
하지만 컨트롤러가 많아지면 initialBinding
에 모든 컨트롤러를 등록했을 때 효율이 너무 떨어진다. 따라서 바로 필요하지 않은 컨트롤러를 페이지를 사용할 때 등록할 수 있도록 대기시키는 Get.lazyPut
을 사용해야 한다.
GetMaterialApp(
...
initialBinding: BindingBuilder(() {
Get.lazyPut(LoginController());
Get.lazyPut(SignupController());
Get.put(MainController());
}),
...
);
Get.lazyPut
을 사용하면 Get.find
를 사용하기 바로 전에 Get.put
을 한다.
GetPage
에 바인딩을 설정할 수도 있다.GetPage(
name: LoginPage.route,
page: () => LoginPage(),
binding: BindingsBuilder(() {
Get.put(LoginController());
}),
),
이렇게 등록한 컨트롤러를 사용하기 위해 원래는 Get.find
를 사용했다. 하지만 Get.find
를 사용하지 않고도 컨트롤러를 찾아 사용할 수 있는데 페이지 위젯을 만들 때, StatelessWidget
부분을 GetView<컨트롤러명>
으로 변경하면 된다.
class LoginPage extends GetView<LoginController> {
...
}
디자인 패턴은 소프트웨어를 개발할 때 매우 중요한 요소이다. 제작할 소프트웨어에 적절한 디자인 패턴을 사용하면 유지 보수가 용이하고, 코드 작성도 편리해질 수 있다.
MVC 패턴은 많이 사용되는 디자인 패턴 중 하나로 모델-뷰-컨트롤러를 의미한다. MVC 모델의 세 가지 부분은 아래와 같이 설명할 수 있다.
MVC 모델의 요소
1. 모델: 데이터와 비즈니스 로직을 관리한다.
2. 뷰: 레이아웃과 화면을 처리한다.
3. 컨트롤러: 명령을 모델과 뷰 부분으로 라우팅한다.
간단한 쇼핑 리스트 앱을 예시로 들어보자. 해당 앱은 사야할 각 항목의 이름, 개수, 가격의 리스트를 가지고 있다.
모델
모델은 앱이 포함할 데이터를 정의한다. 따라서 쇼핑 리스트 앱에서는 품목, 가격 등 리스트의 아이템을 정의한다.
뷰
뷰는 앱의 데이터를 보여주는 방식을 정의한다. 따라서 표시할 데이터를 모델로부터 받아 레이아웃을 구성한다.
컨트롤러
컨트롤러는 앱의 사용자로부터의 입력에 대한 응답으로 모델 또는 뷰를 업데이트하는 로직을 포함한다. 쇼핑 리스트가 항목을 추가하거나 제거하는 입력 폼과 버튼을 가지면 이러한 이벤트를 통해 모델이 업데이트 되는 것을 컨트롤러가 처리하여 업데이트된 데이터를 뷰로 전송한다.
Flutter에서 MVC
그동안 Flutter에서 MVC를 사용하기 위해 하나하나 파일을 분리했었다.GetxController
를 사용해 페이지별로 컨트롤러를 생성해controller
폴더에 저장하고,User
등과 같은 클래스 모델을 만들에model
폴더에 저장했다. 또한widget
,page
등의 폴더를 만들어 레이아웃을 그리는 파일들을 모았는데 이러한 페이지를view
폴더로 묶으면 MVC 모델이 적용된 플러터 프로젝트를 구성할 수 있다.
MVC 모델 이외에도 MVVM(모델-뷰-뷰모델), MVP(모델-뷰-프리젠터) 등의 다양한 디자인 패턴이 있다. 앱에 맞는 적절한 디자인 패턴을 선택하고, 이를 적용하여 기능을 분리한 앱을 제작하면 훨씬 좋은 코드를 만들 수 있을 것이다.
- GetX AuthController를 사용해 앱 제작하기
User
의 정보를 담고있는 AuthController
를 사용해 로그인 기능을 포함한 앱을 만들고자 한다.
AuthController
는 User
의 정보만을 담고 있다. 로그인을 하면 유저를 식별할 수 있는 토큰 값도 함께 받아볼 수 있는데 해당 토큰 값을 AuthController
내에 저장할 수 있도록 한다.API URL
http://52.79.115.43:8090/api/collections/users/auth-with-password
API Request
Method : POST
data : identity(String), password(String)
Teddy/sfac12341234
API Response
{
"token": "JWT_TOKEN",
"record": {
"id": "RECORD_ID",
"collectionId": "_pb_users_auth_",
"collectionName": "users",
"created": "2022-01-01 01:00:00Z",
"updated": "2022-01-01 23:59:59Z",
"username": "username123",
"verified": false,
"emailVisibility": true,
"email": "test@example.com",
"name": "test",
"avatar": "filename.jpg"
}
}
MainController
에는 readDocuments
라는 멤버 함수(메서드)를 제작한다.AuthController
를 find
하여 토큰 값이 존재하면(로그인 되었다면) 실행할 수 있도록 한다.API URL
http://52.79.115.43:8090/api/collections/documents/records
API Request
Method: GET
해당 API는 인증된 사용자만 사용할 수 있기 때문에
로그인 시 획득한 Token을 반드시 Request 헤더에 Authorization을 포함시켜야만합니다.
API Response
{
"page": 1,
"perPage": 30,
"totalPages": 1,
"totalItems": 2,
"items": [
{
"id": "RECORD_ID",
"collectionId": "bjqjkp8usyz0lpb",
"collectionName": "documents",
"created": "2022-01-01 01:00:00Z",
"updated": "2022-01-01 23:59:59Z",
"title": "test",
"content": "test",
"sec_level": "high",
"attachment": "filename.jpg",
"attachment_url": "test"
},
{
"id": "RECORD_ID",
"collectionId": "bjqjkp8usyz0lpb",
"collectionName": "documents",
"created": "2022-01-01 01:00:00Z",
"updated": "2022-01-01 23:59:59Z",
"title": "test",
"content": "test",
"sec_level": "high",
"attachment": "filename.jpg",
"attachment_url": "test"
}
]
}
위 API 정보를 토대로 응답 데이터 형식에 맞게 Document
커스텀 클래스를 제작하고, MainPage
의 Home
이 아래와 같이 출력되도록 한다.
FAB을 누르면 readDocuments
를 실행하고, 결과를 화면에 출력한다.
document
리스트는 MainController
멤버 변수로 저장한다.Document
클래스는 아래의 코드를 사용한다.
// ignore_for_file: public_member_api_docs, sort_constructors_first, non_constant_identifier_names
class Document {
String title;
String content;
String sec_level;
String? attachment_url;
Document({
required this.title,
required this.content,
required this.sec_level,
this.attachment_url,
});
factory Document.fromMap(Map<String, dynamic> map) {
return Document(
title: map['title'] as String,
content: map['content'] as String,
sec_level: map['sec_level'] as String,
attachment_url:
map['attachment_url'] != '' ? map['attachment_url'] : null,
);
}
}
attachment_url
에는 해당 이미지의 URL이 담겨있다. 여기서 김스팩의 비고란에 무엇이 쓰여있는지 알아내도록 한다.import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:new_app/controller/auth_controller.dart';
import 'package:new_app/controller/login_controller.dart';
import 'package:new_app/controller/main_controller.dart';
import 'package:new_app/util/app_routes.dart';
import 'package:new_app/util/app_pages.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return GetMaterialApp(
theme: ThemeData(useMaterial3: true),
//컨트롤러 바인딩
initialBinding: BindingsBuilder(() {
Get.put(AuthController());
Get.lazyPut(() => MainController(), fenix: true);
Get.lazyPut(() => LoginController(), fenix: true);
}),
getPages: AppPages.pages, //페이지 라우팅 설정
//첫 화면
home: Scaffold(
body: Center(
child: TextButton(
onPressed: () => Get.toNamed(AppRoutes.login),
child: const Text('Hello World!'),
),
),
),
);
}
}
main.dart
에서는 사용할 컨트롤러들을 바인딩하고, 페이지 라우팅을 연결한다. 홈은 가운데에 "Hello World!"가 출력되는 텍스트 버튼을 넣었고, 눌렀을 때 로그인 페이지로 이동한다. Get.lazePut
에서 fenix
를 true
로 설정하면 뒤로 가기를 눌렀을 때 Controller
가 삭제되어 다시 페이지에 들어갔을 경우 오류가 발생하는 것을 막을 수 있다.
// ignore_for_file: public_member_api_docs, sort_constructors_first
class User {
String id;
String collectionId;
String collectionName;
DateTime created;
DateTime updated;
String username;
bool verified;
bool emailVisibility;
String email;
String name;
String avatar;
User({
required this.id,
required this.collectionId,
required this.collectionName,
required this.created,
required this.updated,
required this.username,
required this.verified,
required this.emailVisibility,
required this.email,
required this.name,
required this.avatar,
});
factory User.fromMap(Map<String, dynamic> map) {
return User(
id: map['id'] as String,
collectionId: map['collectionId'] as String,
collectionName: map['collectionName'] as String,
created: DateTime.parse(map['created']),
updated: DateTime.parse(map['updated']),
username: map['username'] as String,
verified: map['verified'] as bool,
emailVisibility: map['emailVisibility'] as bool,
email: map['email'] as String,
name: map['name'] as String,
avatar: map['avatar'] as String,
);
}
}
User
클래스는 사용자의 정보를 담는 객체로 로그인 시 전달 받은 데이터들을 멤버 변수로 설정했다. 코드에는 일반 생성자와 맵을 받아 객체를 생성하는 User.fromMap
만 작성했다.
// ignore_for_file: public_member_api_docs, sort_constructors_first, non_constant_identifier_names
class Document {
String title;
String content;
String sec_level;
String? attachment_url;
Document({
required this.title,
required this.content,
required this.sec_level,
this.attachment_url,
});
factory Document.fromMap(Map<String, dynamic> map) {
return Document(
title: map['title'] as String,
content: map['content'] as String,
sec_level: map['sec_level'] as String,
attachment_url:
map['attachment_url'] != '' ? map['attachment_url'] : null,
);
}
}
Document
는 주어진 코드를 그대로 사용했다. API로 전달받은 Document
값들을 저장한다.
import 'package:dio/dio.dart';
import 'package:get/get.dart';
import 'package:new_app/model/user.dart';
import 'package:new_app/util/api_routes.dart';
import 'package:new_app/util/app_routes.dart';
class AuthController extends GetxController {
final Rxn<User> _user = Rxn(); //유저 정보
String? _token; //토큰
Dio dio = Dio(); //dio 객체
User? get user => _user.value; //user w정보 읽기
String? get token => _token; //토큰 읽기
//로그인
login(String id, String pw) async {
dio.options.baseUrl = 'http://52.79.115.43:8090/';
try {
var res = await dio.post(
ApiRoutes.authWithPassword,
data: {
'identity': id,
'password': pw,
},
);
if (res.statusCode == 200) {
var user = User.fromMap(res.data['record']);
_user(user); //유저 정보 저장
_token = res.data['token']; //토큰 저장
}
} on DioError catch (e) {
print(e.message);
}
}
//로그 아웃
logout() {
_user.value = null; //유저 정보 삭제
}
//유저 정보에 따른 페이지 이동
_handleAuthChanged(User? data) {
//유저 정보가 있으면 메인페이지로 이동
if (data != null) {
Get.toNamed(AppRoutes.main);
return;
}
//유저 정보가 없으면 로그인 페이지로 이동
Get.toNamed(AppRoutes.login);
return;
}
void onInit() {
super.onInit();
//유저 정보를 관찰하여 변경된 경우 실행
ever(_user, _handleAuthChanged);
}
}
AuthController
에서는 login()
메서드에서 dio요청을 보내 결과를 받아 유저의 정보와 토큰을 저장한다. logout()
메서드는 유저의 정보를 제거해 로그아웃을 수행한다. _handleAuthChanged()
는 유저의 정보가 있으면 메인 페이지로, 없으면 로그인 페이지로 이동하는데 ever
를 사용해 유저의 정보에 변화가 있으면 실행한다.
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import 'package:new_app/controller/auth_controller.dart';
class LoginController extends GetxController {
var idController = TextEditingController(); //id 텍스트 필드 컨트롤러
var pwController = TextEditingController(); //패스워드 텍스트 필트 컨트롤러
//AuthController의 login()을 사용해 로그인 수행
login() {
Get.find<AuthController>().login(idController.text, pwController.text);
}
}
LoginController
는 아이디와 패스워드 텍스트 필드의 컨트롤러를 가지고 있으며, 로그인을 하면 AuthController
의 login()
을 호출한다.
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:new_app/controller/auth_controller.dart';
import 'package:new_app/model/document.dart';
import 'package:new_app/util/api_routes.dart';
class MainController extends GetxController {
var pageController = PageController(); //페이지뷰 컨트롤러
RxInt curPage = 0.obs; //현제 페이지
Dio dio = Dio(); //dio 객체
RxList<Document> documents = RxList(); //document 리스트
//하단 바에서 페이지를 눌렀을 때
onPageTapped(int value) {
pageController.jumpToPage(value); //페이지 이동
curPage(value); //현제 페이지 변경
}
//로그아웃
logout() {
Get.find<AuthController>().logout();
}
//document 리스트 가져오기
readDocuments() async {
dio.options.baseUrl = 'http://52.79.115.43:8090/';
var token = Get.find<AuthController>().token;
try {
documents.clear();
//토큰을 넣어 데이터 요청
var res = await dio.get(
ApiRoutes.documents,
options: Options(headers: {'authorization': token}),
data: {token: token},
);
if (res.statusCode == 200) {
List<Map<String, dynamic>> data =
List<Map<String, dynamic>>.from(res.data['items']);
documents.addAll(data.map((e) => Document.fromMap(e)).toList().obs);
}
} on DioError catch (e) {
print(e.message);
}
}
}
MainController
는 메인 페이지에서 사용할 페이지뷰의 컨트롤러와 현재 페이지 정보를 가지고 있다. 또한 네트워크에서 받아올 documents
리스트를 저장한다.
onPageTapped()
는 하단 바의 페이지를 눌렀을 때 실행되며, logout
은 AuthController
의 logout()
을 호출한다. readDocumnets()
네트워크에 데이터를 요청하여 documents
리스트에 저장한다. 이 때, 토큰을 넣어 데이터를 요청한다.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:new_app/controller/login_controller.dart';
class LoginPage extends GetView<LoginController> {
const LoginPage({super.key});
static const String route = '/login';
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: controller.idController,
),
TextField(
controller: controller.pwController,
),
ElevatedButton(
onPressed: controller.login,
child: const Text('Login'),
),
],
),
),
);
}
}
LoginPage
는 로그인을 수행할 아이디와 패스워드 텍스트 필드를 가지고 있고, 로그인 버튼을 누르면 LoginController
의 login()
을 호출한다.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:new_app/controller/auth_controller.dart';
import 'package:new_app/controller/main_controller.dart';
import 'package:new_app/view/screen/home_screen.dart';
import 'package:new_app/view/screen/my_screen.dart';
class MainPage extends GetView<MainController> {
const MainPage({super.key});
static const String route = '/main';
Widget build(BuildContext context) {
var user = Get.find<AuthController>().user!;
return Scaffold(
bottomNavigationBar: Obx(
() => BottomNavigationBar(
currentIndex: controller.curPage.value,
onTap: controller.onPageTapped,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'My'),
],
),
),
floatingActionButton: Obx(
() {
if (controller.curPage.value == 0) {
return FloatingActionButton(
onPressed: controller.readDocuments,
child: const Icon(Icons.refresh),
);
}
return const SizedBox();
},
),
body: SafeArea(
child: PageView(
controller: controller.pageController,
physics: const NeverScrollableScrollPhysics(),
children: [
//홈 스크린
Obx(
() => HomeScreen(
user: user,
document: controller.documents.toList(),
),
),
//마이 스크린
MyScreen(user: user, onTap: controller.logout),
],
),
),
);
}
}
MainPage
는 하단 바에서 페이지 뷰의 이동을 설정하고, HomeScreen
에서만 FAB을 출력한다. FAB을 누르면 readDocuments()
로 네트워크에 데이터를 요청해 documnets
리스트를 가져와 저장한다.
본문은 PageView
로 만들고, 각 페이지는 HomeScreen
과 MyScreen
으로 따로 작성했다.
import 'package:flutter/material.dart';
import 'package:new_app/model/document.dart';
import 'package:new_app/model/user.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.user, required this.document});
final User user; //사용자 정보
final List<Document> document; //document 리스트
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
style: const TextStyle(
fontSize: 32,
),
'${user.username}님 안녕하세요',
),
//documnet를 리스트뷰로 출력
ListView.builder(
shrinkWrap: true,
itemCount: document.length,
itemBuilder: (context, index) {
var doc = document[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
style: const TextStyle(
fontSize: 16,
),
doc.title),
Text(doc.content),
if (doc.attachment_url != null)
Image.network(doc.attachment_url!),
],
),
);
},
),
],
),
);
}
}
HomeScreen
은 유저의 정보인 user
와 documnet
리스트를 전달받고, 이를 출력한다.
import 'package:flutter/material.dart';
import 'package:new_app/model/user.dart';
class MyScreen extends StatelessWidget {
const MyScreen({super.key, required this.user, required this.onTap});
final User user;
final VoidCallback onTap;
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text(user.username),
subtitle: Text(user.name),
),
ListTile(
title: const Text('로그아웃하기'),
leading: const Icon(Icons.logout),
onTap: onTap,
),
],
);
}
}
MyScreen
은 유저 정보를 전달받아 출력하고, 로그아웃을 클릭하면 onTap
핸들러를 호출하는데 이는 MainPage
에서 MainController
의 logout()
을 호출한다.
class ApiRoutes {
static const String authWithPassword =
'api/collections/users/auth-with-password'; //로그인 api
static const String documents = 'api/collections/documents/records'; //documents api
}
ApiRoutes
는 사용할 API의 라우트를 저장한다.
import 'package:new_app/view/page/login_page.dart';
import 'package:new_app/view/page/main_page.dart';
class AppRoutes {
static const main = MainPage.route; //메인 페이지 라우트
static const login = LoginPage.route; //로그인 페이지 라우트
}
AppRoutes
는 앱에서 사용되는 페이지들의 라우트를 저장해 관리한다.
import 'package:get/get.dart';
import 'package:new_app/view/page/login_page.dart';
import 'package:new_app/view/page/main_page.dart';
import 'package:new_app/util/app_routes.dart';
class AppPages {
static final pages = [
GetPage(name: AppRoutes.main, page: () => const MainPage()),
GetPage(name: AppRoutes.login, page: () => const LoginPage()),
];
}
AppPages
는 앱에서 사용되는 페이지 이동 설정을 저장해 관리한다.
김스팩의 비고란에 쓰여있는 비밀은 사장님막내아들 이었습니다!!
지금까지 배운 내용들을 종합해서 사용하려니까 약간 어려웠다. ㅋㅋㅋㅋ 특히 API 요청 보낼 때 document가 토큰을 같이 보내야 하는데 문제를 제대로 안 읽어서 한참 헤맸다.ㅠㅠ 문제를 제대로 읽고 하자 ㅠㅠ 과제를 수행하면서 하나 궁금한게 있었는데 메인페이지를 만들 때 홈스크린과 마이스크린을 커스텀 위젯으로 나눠 작성했는데 메인 페이지에서 컨트롤러의 값을 가져와 변수로 넘겨주는 방식을 사용했다. 여기서 이렇게 넘겨주는게 좋은지 아니면 홈 스크린에서도 컨트롤러를 가져와 데이터를 사용하는 방식이 좋은지 모르겠다... 다음에 질문 드려야겠다.
근데 무엇보다 코드가 많아지니까 글 작성이 오래걸린다. ㅋㅋㅋㅋ 다음부터는 깃헙에 올리고 링크를 첨부해야되나...😂😂