36일차에서는 이미지와 비디오 등의파일을 저장할 수 있는 firebase_storage를 학습했고, 유저 프로필 사진을 등록하는 법을 배웠다. 추가로 예전에 만들었던 앱을 리팩토링 했다.
학습한 내용
- firebase_storage
- 유저 프로필 사진 등록
- 리팩토링
Firebase는 데이터 클라우드 저장 공간을 제공한다.
Firebase Storage는 이미지, 파일, 동영상 등 멀티미디어나 바이너리를 저장하여 사용할 수 있다.
파일 업로드
아래와 같이 경로를 설정하여 파일을 업로드할 수 있다. (ImagePicker 등을 사용하여 이미지를 불러온 뒤 파일을 저장하는 등의 경우에 사용 가능하다.)
final storageRef = FirebaseStorage.instance.ref("image/example.jpg");
await storageRef.putData(data);
파일 다운로드
파일 다운로드는 URL을 사용하여 처리할 수 있다.
final storageRef = FirebaseStorage.instance.ref("images/example.jpg");
var fileUrl = await storageRef.getDownloadUrl();
Firebase의 유저 정보에는 프로필 사진을 포함하고 있다. 이를 등록하기 위해서는 아래와 같은 과정을 거친다.
- Firebase Storage에 프로필로 등록할 이미지를 저장한다.
- 이 때 경로에 유저의 uid가 포함되도록 한다.
경로 예시) images/유저uId/profile.jpg
getDownloadUrl()
을 사용해 이미지의 URL을 가져온다.- 해당 URL을 유저의
updatePhotoURL()
을 사용해 등록한다.
이러한 과정으로 유저의 프로필을 등록할 수 있고, 유저의 정보에서 이를 가져와 출력할 수 있다.
아래는 유저의 프로필 사진을 등록하는 코드의 예시이다.
uploadUserImage() async {
var picker = ImagePicker();
var res = await picker.pickImage(source: ImageSource.gallery);
if(res != null) {
//Firebase Storage 올리기
var ref = FirebaseStorage.instance.ref('profile/$user.uid}');
await ref.putFile(File(res.path));
var downloadUrl = awiat ref.getDownloadURL();
//유저 프로필 사진 등록
user.updatePhotoURL(downloadUrl);
}
}
소프트웨어 개발을 위해 코드를 작성할 때 여러명의 사람과 함께 작업을 한다. 따라서 협업을 하는 사람이 이해할 수 있는 코드를 작성하는 것이 중요하다.
리팩토링이란 이미 작성된 소스코드에서 구현된 일련의 행위들을 변경없이, 코드의 가독성과 유지보수성을 높이기 위해 내부 구조를 변경하는 것이다.
리팩토링의 목적
- 소프트웨어 설계에서 질적 향상을 위해 리팩토링을 한다. 코드 중복을 제거하고, 수정 용이성을 향상 시킨다.
- 가독성을 좋게 만들어 소프트웨어 이해도를 높인다.
- 버그를 찾는데 도움이 될 수 있다.
- 프로그램 개발 속도를 높일 수 있다.
리팩토링은 똑같은 내용이 반복되어 작성되었거나 새로운 기능을 추가하기 어려운 경우에 수행될 수 있다. 또한 코드 리뷰를 하면서 비효율적인 코드를 리팩토링할 수도 있다.
결론적으로 기능을 추가할 때는 기존 코드를 수정하지 않고, 기능과 테스트 코드만 추가하고, 리팩토링을 할 때는 기능을 추가하지 않는 것이 좋다.
- 키오스크 앱 리팩토링
2주차 주간과제로 만들었던 키오스크 앱의 코드 구성과 위젯 구성을 재배치하여 코드를 개선하는 리팩토링을 진행하고자 한다.
기존의 키오스크 앱의 코드는 아래 링크에서 확인할 수 있다.
리팩토링이란, 소프트웨어를 보다 쉽게 이해할 수 있도록 만드는 작업이다.
기존의 키오스크 앱의 요구사항은 아래와 같다.
- 음식을 누르면 주문 리스트에 담기는 키오스크앱을 만들어봅니다.
- 음식이미지는 자유입니다.
- 하단에 떠있는 버튼을 누르면 지금까지 주문된 주문 리스트를 초기화합니다.
- 하단에 떠있는 버튼은 정중앙의 하단, 넓게 펴진 형태로 [ 초기화하기 ] 텍스트를 포함합니다.
- 음식이 보여지는 것은 [갤러리] 형태로 보여지게 합니다.
- 그 외 UI 디자인은 자유입니다.
기존의 코드는 main.dart
하나의 파일로 모든 코드를 작성하여 가독성이 떨어진다.
리팩토링을 진행하며 가장 중점을 둔 부분들은 아래와 같다.
- MVC 패턴 적용 (기능 분류)
- 불필요한 코드 제거
- 커스텀 위젯 활용
이를 적용하여 프로젝트의 파일구조를 아래와 같이 설정했다.
lib
ㄴ controller
ㄴ main_controller.dart
ㄴ model
ㄴ food.dart
ㄴ view
ㄴ page
ㄴ main_page.dart
ㄴ widget
ㄴ food_tile.dart
ㄴ main.dart
dependencies:
cupertino_icons: ^1.0.2
flutter:
sdk: flutter
get: ^4.6.5
flutter:
assets:
- assets/images/option_beer.png
- assets/images/option_bokki.png
- assets/images/option_kimbap.png
- assets/images/option_omurice.png
- assets/images/option_pork_cutlets.png
- assets/images/option_ramen.png
- assets/images/option_udon.png
pubspec.yaml
에서 이미지 설정은 기존의 코드와 같고, 패키지에 get
을 추가로 설치했다.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:kiosk_app/controller/main_controller.dart';
import 'package:kiosk_app/view/page/main_page.dart';
void main() {
runApp(const KioskApp());
}
class KioskApp extends StatelessWidget {
const KioskApp({super.key});
// This widget is the root of your application.
Widget build(BuildContext context) {
return GetMaterialApp(
//컨트롤러 바인딩
initialBinding: BindingsBuilder(() {
Get.put(MainController());
}),
//페이지 라우트 설정
getPages: [
GetPage(name: MainPage.route, page: () => const MainPage()),
],
initialRoute: MainPage.route,
);
}
}
main.dart
는 기존의 모든 코드가 작성되어있는 형태에서 MainPage
를 호출하는 코드로 변경했다.
해당 파일에서는 컨트롤러를 바인딩하고, 페이지 라우트를 설정한다. 이를 별도의 파일로 나눠 관리할 수도 있지만 보여지는 페이지가 하나이기 때문에 따로 나누지 않고 작성했다. 앱의 규모가 커진다면 util
폴더에 appRoutes
와 appPages
등의 파일로 따로 분류하는 것도 좋다.
// ignore_for_file: public_member_api_docs, sort_constructors_first
class Food {
String name; //음식 이름
String image; //음식 이미지
Food({
required this.name,
required this.image,
});
}
새로 생성한 Food
모델은 음식의 정보를 가진다. 네트워크에서 JSON을 받아오지 않기 때문에 Food.fromMap
은 따로 작성하지 않고, 기본 생성자만 만들었다.
import 'package:get/get.dart';
import 'package:kiosk_app/model/food.dart';
class MainController extends GetxController {
//음식 리스트
final List foodList = [
Food(name: '떡볶이', image: 'assets/images/option_bokki.png'),
Food(name: '맥주', image: 'assets/images/option_beer.png'),
Food(name: '김밥', image: 'assets/images/option_kimbap.png'),
Food(name: '오므라이스', image: 'assets/images/option_omurice.png'),
Food(name: '돈까스', image: 'assets/images/option_pork_cutlets.png'),
Food(name: '라면', image: 'assets/images/option_ramen.png'),
Food(name: '우동', image: 'assets/images/option_udon.png'),
];
RxList order = [].obs; //주문 리스트
//주문 추가
addOrder(String foodName) => order.add(foodName);
//주문 초기화
clearOrder() => order.clear();
}
MainController
는 음식 리스트와 주문 리스트를 가지고 있다. 기존의 음식 리스트는 Map
형태의 데이터였지만 이번에는 앞에서 작성한 Food
모델을 사용해 데이터를 저장했다.
주문 리스트는 변화를 관찰하기 위해 RxList
타입으로 생성했다.
주문을 추가하는 addOrder()
와 주문을 초기화하는 clearOrder()
메서드도 작성했다.
import 'package:flutter/material.dart';
import 'package:kiosk_app/model/food.dart';
class FoodTile extends StatelessWidget {
const FoodTile({super.key, required this.food, required this.onTap});
final Food food; //음식
final VoidCallback onTap; //onTap 이벤트
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//음식 이미지
Expanded(
child: Image(
image: AssetImage(
food.image,
),
width: double.infinity,
fit: BoxFit.cover,
),
),
//음식 이름
Text(
style: const TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
),
food.name,
),
//담기 텍스트
const Text(
style: TextStyle(
color: Colors.black,
),
'[담기]',
),
],
),
),
);
}
}
FoodTile
은 음식의 정보를 보여주는 타일이다. 기존에는 main.dart
에서 모두 작성했지만 이번에는 커스텀 위젯으로 분류하였다.
음식 정보인 food
와 현재 타일을 눌렀을 때 이벤트를 처리할 onTap
을 전달받아 음식 정보를 그려준다.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:kiosk_app/controller/main_controller.dart';
import 'package:kiosk_app/view/widget/food_tile.dart';
class MainPage extends GetView<MainController> {
const MainPage({super.key});
static const route = '/';
Widget build(BuildContext context) {
return Scaffold(
//앱바
appBar: AppBar(
title: const Text('분식왕 이테디 주문하기'),
centerTitle: true, //타이틀 글씨 가운데 정렬
backgroundColor: Colors.transparent,
foregroundColor: Colors.black,
elevation: 0,
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
'주문 리스트',
),
//주문 리스트
Obx(() => Text(controller.order.toString())),
const SizedBox(height: 8),
const Text(
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
'음식',
),
Expanded(
// 음식 리스트 그리드 뷰
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
itemCount: controller.foodList.length,
itemBuilder: (context, index) {
//음식 타일(눌렀을 때 주문 추가)
return FoodTile(
onTap: () =>
controller.addOrder(controller.foodList[index].name),
food: controller.foodList[index],
);
},
),
),
],
),
),
// 하단 버튼(눌렀을 때 주문 초기화)
floatingActionButton: FloatingActionButton.extended(
onPressed: controller.clearOrder,
label: const Text('초기화하기'),
),
// 하단 버튼 위치
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
);
}
}
MainPage
는 StatefulWidget
이 아닌 StatelessWidget
으로 만들고 GetView
로 MainController
를 가져왔다.
본문에서 기존의 주문 리스트를 Obx
로 감싸 변화를 관찰해 그려주었고, 출력되는 값은 컨트롤러의 order
를 가져왔다.
음식들은 기존의 GridView.count
과 List.generate
를 사용하는 코드가 불필요하다고 느껴 간단하게 GridView.builder
로 변경했다.
그리드 뷰의 내부 요소들은 컨트롤러의 foodList
를 가져와 그려주었고, 앞에서 만든 FoodTile
을 사용했다. 타일을 누르면 주문에 추가되도록 메서드를 연결했다.
FAB은 기존과 같지만 onPressed
이벤트에 컨트롤러에서 작성한 주문 초기화 메서드를 연결했다.
결과는 아래와 같이 기존의 키오스크 앱과 동일하지만 코드가 훨씬 가독성이 높아지고, 유지 보수가 수월하도록 기능이 분류되었다.
플러터의 학습 강의는 아마도 오늘이 끝이다.ㅠㅠ 36일차까지 많은 내용을 배웠고, 개발도 해보았다. 내일 부터는 월말 평가로 개인 앱 개발을 시작하게 된다.(금요일에 일별 과제가 하나 더 있다고는 함) 오늘은 간단하게 이미 만들었던 앱을 리팩토링 했는데 이전에 만든 코드가 가독성이 너무 안 좋았다고 느꼈다.ㅋㅋㅋㅋ 원래 추가 내용 정리로 firebase의 인증 과정이 어떤 방식으로 이루어지는지 정리하려고 했지만 생각 보다 내용이 좀 어려워서 이해를 하고 나중에 정리를 하는 것이 좋을 것 같다. 아 그리고 강의는 끝났지만 추가로 학습하는 내용과 프로젝트 진행 사항 등을 계속 블로그로 작성할 예정이다.