사전캠프 마지막 날
오늘은 5주차 강의 실전 메모 앱에 기능을 더했고, 애드몹 연결, 배포까지 진행해보았다.
마지막 날이었는데 5주차 강의까지 모두 완료했다.
📌 MemoWriteContoller 초기화
import 'package:get/get.dart'; // class MemoWriteController extends GetxController {}✔
memo_write_controller.dart파일을 만든다.
📌 MemoWriteController 의존성 주입
floatingActionButton: FloatingActionButton( onPressed: () { Get.to(MemoWritePage(), binding: BindingsBuilder(() { Get.put(MemoWriteController()); })); }, backgroundColor: Color(0xffF7C354), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(50), ), child: Image.asset('assets/images/plus.png'), ),✔
MemoWriteController를 메모 등록 페이지 진입 시에 사용할 수 있도록Get.to라우팅할 때 의존성을 주입한다.
📌 MemoWriteContoller 파이어베이스 데이터베이스 초기화
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:get/get.dart'; // class MemoWriteController extends GetxController { late CollectionReference memoCollectionRef; // void onInit() { super.onInit(); memoCollectionRef = FirebaseFirestore.instance.collection('memo'); } }✔ 데이터베이스를 초기화한다.
📌 MemoWriteContoller 상태관리 구현
class MemoWriteController extends GetxController { late CollectionReference memoCollectionRef; // String title = ''; String memo = ''; DateTime? memoDate; // void onInit() { super.onInit(); memoCollectionRef = FirebaseFirestore.instance.collection('memo'); memoDate = DateTime.now(); } // void setTitle(String title) { this.title = title; update(); } // void setMemo(String memo) { this.memo = memo; update(); } }
📌 MemoWritePage 상태관리 연결 및 이벤트 등록
body: Padding( padding: const EdgeInsets.symmetric(horizontal: 25), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( decoration: InputDecoration( border: InputBorder.none, hintText: '제목을 입력해주세요.', ), style: TextStyle( fontSize: 27, height: 1.5, fontWeight: FontWeight.bold, letterSpacing: -1, ), maxLines: null, onChanged: (value) { Get.find<MemoWriteController>().setTitle(value); }, // 여러 줄 입력 가능 ), Text( Get.find<MemoWriteController>().memoDate!.toString(), style: TextStyle(fontSize: 13, color: Color(0xffE3AC34)), ), Expanded( child: TextField( decoration: InputDecoration( border: InputBorder.none, hintText: '내용을 입력해주세요.', ), style: TextStyle( fontSize: 15, height: 1.5, color: Color(0xff848484), letterSpacing: -1, ), maxLines: null, // 여러 줄 입력 가능 onChanged: (value) { Get.find<MemoWriteController>().setMemo(value); }, ), ), ], ), ),✔ 화면에서 제목과 메모가 수정될 때,
update함수를 연결한다.
📌 날짜 포멧을 도와주는 라이브러리 설치 (intl)
flutter pub add intl✔ 포멧팅을 도와주는 라이브러리를 설치한다.
📌 MemoDataUtils 클래스 날짜 포멧 함수 구현
class MemoDataUtils { static String formatDate(String format, DateTime date) { return DateFormat(format).format(date); } }✔
data_utils.dart파일을 만든다.
📌 MemoWritePage날짜 포멧팅
Text( MemoDataUtils.formatDate( 'yyyy-MM-dd', Get.find<MemoWriteController>().memoDate!), style: TextStyle(fontSize: 13, color: Color(0xffE3AC34)), ),✔ 날짜를 원하는 포맷으로 변경하게 도와주는 함수를 유틸 클래스에 정의한다.
📌 MemoWritePage 완료 버튼 이벤트 연결
GestureDetector( onTap: () { Get.find<MemoWriteController>().save(); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 25.0), child: Text( '완료', style: TextStyle(fontSize: 17, color: Color(0xffE3AC34)), ), ), ),✔ 완료 버튼을 통해 메모 데이터를 서버인 파이어 베이스 데이터베이스로 저장한다.
📌 MemoWriteController 완료 이벤트 만들기
void save() {}
📌 Memo모델 설계
import 'package:uuid/uuid.dart'; // class MemoModel { final String id; final String title; final String memo; final DateTime createdAt; // MemoModel({ String? id, required this.title, required this.memo, DateTime? createdAt, }) : id = Uuid().v4(), createdAt = DateTime.now(); // factory MemoModel.fromJson(Map<String, dynamic> json) { return MemoModel( id: json['id'], title: json['title'], memo: json['memo'], createdAt: DateTime.parse(json['createdAt']), ); } // Map<String, dynamic> toMap() { return { 'id': id, 'title': title, 'memo': memo, 'createdAt': createdAt.toIso8601String(), }; } }
📌 MemoWriteController 완료 이벤트 구현
void save() { var memoModel = MemoModel(title: title, memo: memo); memoCollectionRef.add(memoModel.toMap()); Get.back(result: memoModel); }
📌 MemoListController 생성
import 'package:get/get.dart'; // class MemoListController extends GetxController {}✔
memo_list_controller.dart파일을 만든다.
📌 MemoListController 의존성 주입
Widget build(BuildContext context) { return GetMaterialApp( title: 'Flutter Demo', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), initialBinding: BindingsBuilder(() { Get.put(MemoListController()); }), home: Home(), ); }✔ 앱 실행 시
MemoListController의존성 주입을 통해 홈 화면에서 바로 사용할 수 있도록 처리한다.
💡 이전까지는main.dart파일의build함수 시작 부분에 의존성을 주입했다.
이번에는GetMaterialApp의 옵션 중initBinding이라는 옵션에BindingBuilder를 사용하여, 의존성을 주입하도록 처리했다.
원래가 이렇게 주입을 해줘야한다. 다양한 방식으로 의존성을 맺을 수 있음을 보여준 것이며, 앞으로 프로젝트 진행 시initalBinding에 의존성을 주입하면 된다.
📌 MemoListController 데이터 베이스 연동하여 데이터 로드 하기
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter_memo_app/memo_model.dart'; import 'package:get/get.dart'; // class MemoListController extends GetxController { late CollectionReference memoCollectionRef; List<MemoModel> memoList = []; // void onInit() { super.onInit(); memoCollectionRef = FirebaseFirestore.instance.collection('memo'); loadAllMemos(); } // void loadAllMemos() async { var memoData = await memoCollectionRef.get(); memoList = memoData.docs .map<MemoModel>( (data) => MemoModel.fromJson(data.data() as Map<String, dynamic>)) .toList(); update(); } }✔ 보여줄 데이터를 연동한다.
📌 홈화면 MemoListController를 통한 메모 리스트 로드 body
body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 25), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( '메모', style: TextStyle( fontSize: 35, fontWeight: FontWeight.bold, ), ), _searchBar(), GetBuilder<MemoListController>(builder: (controller) { return _monthlyMemoGroup(controller.memoList); }), ], ), ), ),
📌 홈화면 MemoListController를 통한 메모 리스트 로드 _monthlyMemoGroupWidget _monthlyMemoGroup(List<MemoModel> memoList) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox(height: 30), Text( '8월', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20), ), SizedBox(height: 10), Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Colors.white, ), padding: const EdgeInsets.only(left: 25), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: List.generate( memoList.length, (i) { return Container( padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: Color(0xffECECEC), ), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( memoList[i].title, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 17, ), ), Text( memoList[i].memo, style: TextStyle(fontSize: 14, color: Color(0xff848484)), ), ], ), ); }, ), ), ) ], ); }✔ 우선 월별 그룹 없이 나열하는 것으로 개발한 뒤에 그룹화한다.
📌 MemoListController memoList 그룹화 작업
class MemoListController extends GetxController { late CollectionReference memoCollectionRef; List<MemoModel> memoList = []; Map<String, List<MemoModel>> memoGroup = {}; // void onInit() { super.onInit(); memoCollectionRef = FirebaseFirestore.instance.collection('memo'); loadAllMemos(); } // void loadAllMemos() async { var memoData = await memoCollectionRef.get(); memoList = memoData.docs .map<MemoModel>( (data) => MemoModel.fromJson(data.data() as Map<String, dynamic>)) .toList(); var monthkey = -1; memoList.map((memo) { monthkey = memo.createdAt.month; var group = memoGroup[monthkey.toString()]; if (group == null) { group = [memo]; } else { group.add(memo); } memoGroup[monthkey.toString()] = group; }).toList(); print(memoGroup); update(); } }✔ 이제 몇 가지 메모를 추가하여 월별로 나눠서 보이는 것을 개발한다.
📌 MemoModel 생성자 수정
MemoModel({ String? id, required this.title, required this.memo, DateTime? createdAt, }) : id = id ?? Uuid().v4(), createdAt = createdAt ?? DateTime.now();✔ 그룹별 리스트를 갖도록 설계한다. 각 그룹의 키로 월을 사용한다. 이제 모든 리스트에
loop를 돌면서monthKey별로 분기를 통해 나눠지도록 처리했다.
✔ 그런데 디버그로 돌려서
이것은 모델을 생성할 때createdAt을 항상 현 시간으로 표기를 하고 있어서 발생되는 버그로, 수정해준다.
📌 홈화면 memo 그룹별 화면 처리
GetBuilder<MemoListController>(builder: (controller) { List<String> keys = []; List<List<MemoModel>> values = []; controller.memoGroup.forEach((key, value) { keys.add(key); values.add(value); }); return Column( children: List.generate(keys.length, (i) { return _monthlyMemoGroup(keys[i], values[i]); }), ); }),
📌 홈화면 _monthlyMemoGroup 월표기 위한 파라미터 추가
Widget _monthlyMemoGroup(String monthString, List<MemoModel> memoList) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox(height: 30), Text( '$monthString월', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20), ), SizedBox(height: 10), Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Colors.white, ), padding: const EdgeInsets.only(left: 25), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: List.generate( memoList.length, (i) { return Container( padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: Color(0xffECECEC), ), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( memoList[i].title, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 17, ), ), Text( memoList[i].memo, style: TextStyle(fontSize: 14, color: Color(0xff848484)), ), ], ), ); }, ), ), ) ], ); }✔ 이제 화면에서 각 분기별로 나눠서 그려주면 된다.
📌 메모 수정페이지 라우팅 처리 _monthlyMemoGroup 함수
return GestureDetector( onTap: () { Get.to(MemoWritePage(), binding: BindingsBuilder(() { Get.put(MemoWriteController(memoModel: memoList[i])); })); }, child: Container( padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: Color(0xffECECEC), ), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( memoList[i].title, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 17, ), ), Text( memoList[i].memo, style: TextStyle(fontSize: 14, color: Color(0xff848484)), ), ], ), ), );✔ 페이지 라우팅할 때 등록과는 달리 메모 정보를 함께 전달하도록 수정한다.
📌 MemoWriteController 수정
MemoWriteController({this.memoModel}); MemoModel? memoModel;✔
MemoWriteController의 생성자에MemoModel을 옵셔널로 받을 수 있도록 수정한다.
📌 MemoWriteController onInit 수정
void onInit() { super.onInit(); memoCollectionRef = FirebaseFirestore.instance.collection('memo'); if (memoModel != null) { title = memoModel!.title; memo = memoModel!.memo; memoDate = memoModel!.createdAt; } else { memoDate = DateTime.now(); } }✔
onInit함수에서memoModel이 있는지에 따라title과memo에 데이터를 넣어준다.
📌 title,memo TextField에 초깃값세팅
final TextEditingController titleTextController = TextEditingController(); final TextEditingController memoTextController = TextEditingController(); // void onInit() { super.onInit(); memoCollectionRef = FirebaseFirestore.instance.collection('memo'); if (memoModel != null) { title = memoModel!.title; memo = memoModel!.memo; memoDate = memoModel!.createdAt; titleTextController.text = title; memoTextController.text = memo; } else { memoDate = DateTime.now(); } }✔ 화면은 날짜를 제외하고 모두
textField이기 때문에 초깃값을 세팅하기 위해서TextEditingController가 필요하다.
📌 TextEditingController 주입
body: Padding( padding: const EdgeInsets.symmetric(horizontal: 25), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: Get.find<MemoWriteController>().titleTextController, decoration: InputDecoration( border: InputBorder.none, hintText: '제목을 입력해주세요.', ), style: TextStyle( fontSize: 27, height: 1.5, fontWeight: FontWeight.bold, letterSpacing: -1, ), maxLines: null, onChanged: (value) { Get.find<MemoWriteController>().setTitle(value); }, // 여러 줄 입력 가능 ), Text( MemoDataUtils.formatDate( 'yyyy-MM-dd', Get.find<MemoWriteController>().memoDate!), style: TextStyle(fontSize: 13, color: Color(0xffE3AC34)), ), Expanded( child: TextField( controller: Get.find<MemoWriteController>().memoTextController, decoration: InputDecoration( border: InputBorder.none, hintText: '내용을 입력해주세요.', ), style: TextStyle( fontSize: 15, height: 1.5, color: Color(0xff848484), letterSpacing: -1, ), maxLines: null, // 여러 줄 입력 가능 onChanged: (value) { Get.find<MemoWriteController>().setMemo(value); }, ), ), ], ), ),
📌 MemoWriteController save 함수 수정
void save() async { var newMemoModel = MemoModel( id: memoModel?.id, title: title, memo: memo, createdAt: DateTime.now(), ); if (memoModel != null) { var doc = await memoCollectionRef.where('id', isEqualTo: memoModel!.id).get(); memoCollectionRef.doc(doc.docs.first.id).update(newMemoModel.toMap()); } else { memoCollectionRef.add(newMemoModel.toMap()); } Get.back(result: newMemoModel); }✔ 수정까지 잘 저장되는 것을 데이터베이스를 통해 확인하였다. 하지만 수정이 완료되고 난 뒤에 새로 메모 갱신이 되고 있지 않다.
📌 메모 등록/수정 페이지에서 돌아온 home 이벤트 처리
var result = await Get.to(MemoWritePage(), binding: BindingsBuilder(() { Get.put(MemoWriteController(memoModel: memoList[i])); })); if (result != null) { Get.find<MemoListController>().reload(); }
📌 MemoListController reload함수 개발
void reload() { memoGroup = {}; loadAllMemos(); }✔
MemoWriteController에서save가완료되고 나면return으로result값을 전달해 주기 때문에, 해당 값이 있다면 갱신이 필요한 것이므로MemoListController에reload함수를 호출한다.
📌 메모 등록 이벤트 callBack부분 reload 처리
floatingActionButton: FloatingActionButton( onPressed: () async { var result = await Get.to(MemoWritePage(), binding: BindingsBuilder(() { Get.put(MemoWriteController()); })); if (result != null) { Get.find<MemoListController>().reload(); } }, backgroundColor: Color(0xffF7C354), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(50), ), child: Image.asset('assets/images/plus.png'), ),✔
reload는 수정뿐만 아니라 메모 등록 이벤트 돌아온 후에도 처리해야 한다.
📌 메모 등록 페이지 삭제 버튼 배치 및 이벤트 연결
bottomNavigationBar: Padding( padding: const EdgeInsets.all(15), child: GestureDetector( onTap: () { Get.find<MemoWriteController>().delete(); }, child: Container( height: 50, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Color(0xffF81717), ), child: Center( child: Text( '삭제', style: TextStyle(fontSize: 17, color: Colors.white), ), ), ), ), ),✔ 하단에 배치하기 위한 가장 쉬운 방법은
Scaffold의bottomNavigationBar이다.
물론 목적은 bottomNavigationBar를 사용할 목적으로 설계되었지만, 확장성이 가능하도록Widget으로 받아 주고 있어서, 반드시BottomNavigationBar를 사용하지 않아도 된다.
📌 메모 삭제 로직 개발
void delete() async { var doc = await memoCollectionRef.where('id', isEqualTo: memoModel!.id).get(); memoCollectionRef.doc(doc.docs.first.id).delete(); Get.back(result: true); }✔
result에true를 전달해 주는 이유는 이미 메모 등록/수정 페이지로부터 돌아올 때result값이null이 아니라면reload처리하도록 개발했기 때문이다.
Get.back()만 보내면 새로고침이 처리되지 않아서 삭제한 메모가 그대로 남아 있는 것처럼 보이게 되는데, 이것을 방지하기 위해서result에true를 담아서 보낸다.
📌 기존 삭제 위젯 onTap 이벤트
bottomNavigationBar: Padding( padding: const EdgeInsets.all(15), child: GestureDetector( onTap: () { _showDeleteConfirmDialog(context); }, child: Container( height: 50, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Color(0xffF81717), ), child: Center( child: Text( '삭제', style: TextStyle(fontSize: 17, color: Colors.white), ), ), ), ), ),
📌 삭제 Confirm 메세지
void _showDeleteConfirmDialog(BuildContext context) { showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text('메모 삭제'), content: Text('메모를 삭제하시겠습니까?'), actions: <Widget>[ TextButton( onPressed: () { Get.back(); }, child: Text('취소'), ), TextButton( onPressed: () { Get.back(); Get.find<MemoWriteController>().delete(); }, child: Text( '삭제', style: TextStyle(color: Colors.red), ), ), ], ); }, ); }
📚 검색 개발의 두 가지 방식
📕 사용자가 검색을 할 때 서버(파이어 베이스) 쿼리 조회로 검색
[ 장점 ]
✔ 메모 데이터를 모두 가져올 필요가 없이 검색하는 키워드만 조회할 수 있다.
✔ 실시간 데이터를 모두 반영할 수 있다.
[ 단점 ]
✔ 검색할 때마다 비용이 발생한다.
✔ 인터넷이 연결되어 있어야만 검색이 가능하다.
📘 앱 내로 모든 메모 정보를 로드 후 외부 리소스 비용 없이 앱 내에서 검색
[ 장점 ]
✔ 매우 빠른 검색이 가능하다.
✔ offline mode에서도 검색이 가능하다.
✔ 키보드 입력별로 실시간 검색 처리가 가능하다
[ 단점 ]
✔ 메모 양이 방대해질 경우 모든 데이터를 한 번에 불러오기가 버거울 수 있다.
✔ 실시간 데이터를 반영할 수 없다.
✅ 메모 앱의 경우 다른 사람들과의 메모 공유가 필요 없기 때문에, 두 번째 방식을 선택한다.
💡 키보드 입력에 따라 외부 api를 사용하거나, 네트워크를 이용할 경우는debounce라는 기술을 통해 빈번하지 않게 적당한 시점에 요청하는 방식이 있다.
📌 홈 화면 검색 onChange 이벤트 연결
TextField( decoration: InputDecoration( border: InputBorder.none, hintText: '검색', hintStyle: TextStyle( color: Color(0xff888888), fontSize: 15, ), ), onChanged: (value) { Get.find<MemoListController>().search(value); }, ),✔ 키보드 입력에 따라 이벤트 처리를 하기 위해서, 검색
TextField에 `onChanged 함수를 사용한다.
📌 MemoListController 검색 구현
void search(String searchKeyword) { var searchResult = memoList.where((memo) { return memo.title.contains(searchKeyword) || memo.memo.contains(searchKeyword); }).toList(); memoGroup = {}; var monthkey = -1; searchResult.map((memo) { monthkey = memo.createdAt.month; var group = memoGroup[monthkey.toString()]; if (group == null) { group = [memo]; } else { group.add(memo); } memoGroup[monthkey.toString()] = group; }).toList(); update(); }
📌 검색 input 우측 remove 아이콘 배치 및 이벤트 연결
GestureDetector( onTap: () { Get.find<MemoListController>().clearSearchKeyword(); }, child: Icon( Icons.close, color: Color(0xff888888), ), ),✔ 검색이 완료되고 검색 키워드를 한 번에 지울 수 있는 버튼이 우측에 아이콘으로 있으면 좋은 사용자 경험을 줄 수 있기 때문에 추가해준다.
📌 MemoListController 검색 초기화
TextEditingController searchKeywordController = TextEditingController(); // void clearSearchKeyword() { searchKeywordController.text = ''; reload(); }
📌 Home 검색 input controller 연결
TextField( controller: Get.find<MemoListController>().searchKeywordController, decoration: InputDecoration( border: InputBorder.none, hintText: '검색', hintStyle: TextStyle( color: Color(0xff888888), fontSize: 15, ), ), onChanged: (value) { Get.find<MemoListController>().search(value); }, ),
📌 키보드 비활성화 처리
Widget build(BuildContext context) { return Scaffold( backgroundColor: Color(0xffEBEBEB), body: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { FocusScope.of(context).unfocus(); // 추가 }, child: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 25), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( '메모', style: TextStyle( fontSize: 35, fontWeight: FontWeight.bold, ), ), _searchBar(), GetBuilder<MemoListController>(builder: (controller) { List<String> keys = []; List<List<MemoModel>> values = []; controller.memoGroup.forEach((key, value) { keys.add(key); values.add(value); }); return Column( children: List.generate(keys.length, (i) { return _monthlyMemoGroup(keys[i], values[i]); }), ); }), ], ), ), ), ), floatingActionButton: FloatingActionButton( onPressed: () async { var result = await Get.to(MemoWritePage(), binding: BindingsBuilder(() { Get.put(MemoWriteController()); })); if (result != null) { Get.find<MemoListController>().reload(); } }, backgroundColor: Color(0xffF7C354), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(50), ), child: Image.asset('assets/images/plus.png'), ), ); }✔ 검색을 하기 위해 키보드가 활성화되었는데 input 영역이 아닌 다른 영역을 눌러 키보드가 비활성화 되도록 수정한다.
✅ 로그인 후 결제 설정 미완료로 뜨는 문제 해결 버튼을 눌러 이동한다.
✅ 결제 계정 추가 버튼을 눌러 입력하고 제출 버튼을 누르면 인증 프로세스가 진행된다.
✅ 좌측 앱 추가 버튼을 눌러 플랫폼 > Android로 진행한다.
✅ 앱 등록 전이기 때문에 등록되어 있냐는 문항에 아니오를 클릭하고 계속한다.
✅ 앱 이름은 자유롭게 등록이 가능하지만, 구글에서는 앱 이름을 앱 스토어 등록정보와 일치시키는 것이 좋다고 가이드 되어있다.
✅ 완료하고 나면 광고 단위를 추가할 수 있는데, 그중 배너를 클릭하고, 광고 단위 이름을 어느 위치에 둘지 작성한다.
✅ 광고 단위 아이디를 통해 광고를 연결할 예정으로, 언제든 확인이 가능하기 때문에 완료 버튼을 눌러준다.
📌 google_mobile_ads 설치
flutter pub add google_mobile_ads✔ Terminal에 입력하여 설치한다.
📌 AndroidManifest.xml 에 광고 단위 추가
<meta-data android:name="com.google.android.gms.ads.APPLICATION_ID" android:value="ca-app-pub-xxxxxxxxxxxxxxxx~yyyyyyyyyy"/>✔
AndroidManifest.xml에 광고 id를 등록해야 한다.
✔</application>위에 위치 시키고,ca-app-pub-xxxxxxxxxxxxxxx~yyyyyyyy이것을 그대로 입력하는 것이 아닌ad mob콘솔에 앱 ID를 넣어줘야 한다.
📌 구글 광고 SDK 초기화
MobileAds.instance.initialize();✔ 앱이 실행되는 main 함수에
WidgetsFlutterBinding.ensureInitialized();이후 소스코드를 추가한다.
📌 AdmobBanner 소스 초기화
import 'package:flutter/widgets.dart'; // class AdmobBanner extends StatefulWidget { const AdmobBanner({super.key}); // State<AdmobBanner> createState() => _AdmobBannerState(); } // class _AdmobBannerState extends State<AdmobBanner> { Widget build(BuildContext context) { return Container(); } }✔
admob_banner.dart파일을 만든다.
📌 initState 광고 로드
class _AdmobBannerState extends State<AdmobBanner> { AdManagerBannerAd? _bannerAd; bool _isLoaded = false; // final adUnitId = '/21775744923/example/adaptive-banner'; // void loadAd() { _bannerAd = AdManagerBannerAd( adUnitId: adUnitId, request: const AdManagerAdRequest(), sizes: [AdSize.banner], listener: AdManagerBannerAdListener( onAdLoaded: (ad) { debugPrint('$ad loaded.'); setState(() { _isLoaded = true; }); }, onAdFailedToLoad: (ad, err) { ad.dispose(); }, ), )..load(); } // void initState() { super.initState(); loadAd(); } // Widget build(BuildContext context) { if (_bannerAd != null && _isLoaded) { return SizedBox( width: _bannerAd!.sizes.first.width.toDouble(), height: _bannerAd!.sizes.first.height.toDouble(), child: AdWidget(ad: _bannerAd!), ); } return Container(height: 1); } }✔
StatefulWidget으로 개발하는 이유는 광고 로드는 한 번만 불러와져야 하기 때문으로,statefulWidget의 라이프 사이클의initState때 광고 로드 함수를 호출한다.
✔ 광고가 로드되기 전에는AdWidget을 사용하면 안되기 때문에 로드 여부에 따라 빈Container를 반환한다.
여기서height : 1을 설정한 이유는 광고가 보통bottomNavigationBar위치에 하게 되는데Height가 없는Container를 사용하면 화면 전체를 덮게 되는 현상이 있다.
⚠️ 광고 단위 adUnitId
'/21775744923/example/adaptive-banner';
💡이렇게 설정한 이유?
✔ 구글에서 가이드 한 방식으로 테스트 광고를 사용할 때 사용하게 된다.
보통 앱을 개발할 때 디버그 모드로 돌리기 때문에 그때는 라이브 광고를 사용하게 될 경우 부정 광고라고 광고가 막힐 수가 있으니 주의해야 한다.
라이브로 할 때 이 광고 단위를 바꿔줘야 한다.
테스트 광고가 노출되게 되는데, 실제 광고 단위를 설정할 때는 광고 단위를 다음과 같이 설정한다.
final adUnitId = 'ca-app-pub-xxxxxxxxx/yyyyyyy';
✅ 광고 단위 확인하는 방법
✔ 구글 애드몹 콘솔에 접속하여 광고 단위 탭에서 확인한다.
📚 안드로이드 앱 배포를 위한 사전준비
📕 앱 아이콘 만들기 (512*512)
📙 앱 출시 정보 준비하기
✔ 이름
✔ 설명(간단 설명, 자세한 설명)
✔ 키워드
✔ 앱을 소개하는 대표 이미지
- 1024 500 그래픽 이미지
- 320 3840 스크린샷 2~8장
📒 jks 키 만들기
📗 배포를 위한 gradle 세팅
✅ 원하는 앱 로고를 512*512 사이즈에 맞게 제작한다.
📌 flutter_launcher_icons 설치
flutter pub add --dev flutter_launcher_icons✔
flutter_launcher_icons는 프로젝트에서 앱의 아이콘을 쉽게 생성하고 설정할 수 있도록 도와주는 라이브러리로, 다양한 해상도와 플랫폼(iOS, Android)에 맞는 앱 아이콘을 자동으로 생성하고 적용할 수 있다. Terminal을 열어 설치한다.
✔ 자동 앱 사이즈별 에셋을 만들기 위해 위에서 만들어 놓은 앱 로고를assets/images/icon/폴더를 만들어 넣는다.
📌 flutter_launcher_icons 스크립트를 위한 세팅
flutter_icons: android: "launcher_icon" ios: false image_path: "assets/images/icon/memo_app_logo.png"✔ 스크립트를 통해 앱 아이콘들 만들게 되는데, 그것을 위해서
pubspec.yaml파일 하단부에 관련 정보를 제공해야 한다.
📌 flutter_launcher_icons 스크립트 실행 명령
flutter pub run flutter_launcher_icons✅
flutter_launcher_icons에서 참조하는 정보
✔android: 안드로이드 로고 이름
✔ios: ios 앱 아이콘 생성 유무
✔image_path: 앱 아이콘으로 만들 앱 로고 애셋 위치
📚 출시 정보
📕 이름 : 마이메모
📙 간단 설명 : “마이메모”는 빠르고 간편하게 메모를 작성하고 월별로 정리하여 손쉽게 관리할 수 있는 메모 앱입니다.
📒 자세한 설명 : 마이메모는 일상 속에서 메모가 필요할 때, 빠르고 간편하게 메모를 기록하고 이를 체계적으로 관리할 수 있도록 도와주는 앱입니다. 사용자 인터페이스가 직관적이고 사용하기 쉬워, 복잡한 설정 없이 바로 메모를 작성할 수 있습니다.
📗 키워드
메모 앱, 빠른 메모,메모 관리,월별 메모,간편한 노트,메모 정리,일상 기록,개인 노트,메모장,메모 자동 저장
📘 앱 소개하는 대표 이미지 320*3840
📚 JKS 파일이 Android 배포에서 필요한 이유
📕 앱 무결성 보장
✔ 앱이 JKS 파일을 사용하여 서명되면, 사용자는 앱이 개발자에 의해 인증되었으며, 다운로드 이후에 변경되지 않았다는 것을 확신할 수 있다.
✔ 서명되지 않은 앱은 설치가 불가능하며, 이미 설치된 앱이 서명되지 않은 버전으로 교체될 수 없다. 이는 사용자에게 보안과 신뢰를 제공한다.
📙 업데이트 관리
✔ 앱이 악의적으로 변경되지 않도록 하며, 동일한 개발자가 배포하는 공식 업데이트임을 보장한다.
📒 Google Play 등록
✔ Google Play는 이 서명을 사용하여 개발자의 신원을 확인하고, 사용자에게 안전한 앱을 제공한다.
📗 APK파일 서명
✔ KS 파일에는 개인 키(private key)와 공인된 인증서(certificate)가 포함되어 있으며, 이들이 APK 파일에 서명을 추가하는 데 사용된다.
📚 jks 키 만들기
📕 안드로이드 스튜디오 열기
📙
Generate Signed Bundle / APK메뉴 선택
📒 Android App Bundle
📗 Key store 생성
💡 만일 키 스토어가 있다면 Choose existing… 을 선택해서 기존의 키 스토어를 이용한다.
📘 Key Store 입력
✔ Alias, 비밀번호는은 중요해서 잊지 말아야 한다. 이후에 앱 배포시key.properties로 사용된다.
✅ key.properties 만들기
📌 key.properties파일
storeFile=jks파일 위치 절대경로 storePassword=jks 생성시 설정한 store 비밀번호 keyAlias=jks 생성시 설정한 alias keyPassword=jks 생성시 설정한 key 비밀번호✔ android 폴더 내부에
New File을 해주고key.properties라는 파일을 만든다. 이 파일은jks파일의 정보를 담는다.
✅ app/build.gradle 설정
📌 key.properties파일 로드
def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) }✔ 이제
key.properties파일을 읽어들여서build시 사용할 수 있도록 설정한다.
📌 keystoreProperties변수 사용 signingConfig 설정
signingConfigs { release { keyAlias keystoreProperties['keyAlias'] keyPassword keystoreProperties['keyPassword'] storeFile file(keystoreProperties['storeFile']) storePassword keystoreProperties['storePassword'] } }
📌 buildTypes 설정
buildTypes { release { signingConfig signingConfigs.release // minifyEnabled true shrinkResources true // proguardFiles getDefaultProguardFile( 'proguard-android-optimize.txt'), 'proguard-rules.pro' } debug { signingConfig signingConfigs.debug } }
📚
buildTypes블록은 Android 프로젝트의build.gradle파일에서 빌드 구성을 정의하는 부분이다. 이 블록을 통해release및debug와 같은 빌드 타입에 대한 설정을 정의할 수 있다.
📘 release:
✔ 릴리즈 빌드 타입을 정의하며, 이 빌드 타입은 주로 앱을 최종 사용자에게 배포할 때 사용된다. 릴리즈 빌드에서는 보통 최적화가 활성화되고, 코드 및 리소스의 크기를 줄이기 위한 여러 가지 작업이 수행된다.
📗 debug:
✔ 디버그 빌드 타입을 정의하며, 이 빌드 타입은 주로 개발 중에 사용되고, 디버깅을 용이하게 하기 위해 최적화가 비활성화되고 디버그 정보를 포함한다.
📚 release 내 설정값의 의미
📘 signingConfig
✔ 이 설정은 해당 빌드 타입에서 사용하는 서명 구성을 지정한다.
signingConfigs.release는 릴리즈 서명 구성,signingConfigs.debug는 디버그 서명 구성을 의미한다.
📗 minifyEnabled
✔true로 설정되면ProGuard또는R8에 의해 코드 난독화 및 최적화가 수행된다.
이 설정은 앱의 크기를 줄이고 코드 보안을 강화하는 데 사용된다.
📒 shrinkResources
✔true로 설정되면 사용되지 않는 리소스(이미지, XML 등)가 빌드 중에 제거된다.
minifyEnabled가true로 설정되어 있어야 효과가 있다.
📙 proguardFiles
✔ 이 설정은ProGuard또는R8에서 사용할 구성 파일을 지정한다.
📕getDefaultProguardFile('proguard-android-optimize.txt'): 기본ProGuard최적화 파일을 가져온다.
📔'proguard-rules.pro': 프로젝트의 루트 디렉토리에 위치한 사용자 정의ProGuard규칙 파일을 지정한다.
✅ 배포 파일 만들기
📌 build 명령어
flutter build appbundle✔ 배포 명령어를 통해 bundle 파일을 만들어야 한다.
✔ 명령어를 치면 versionCode를 알 수 없다는 오류가 뜬다.
✔ versionCode에 대한 값이 어떻게 설정되어 있는지build.gradle파일을 살펴보면, 두 개의 값을 참조하지 못하고 있다.
✔ 이 값을 하드코딩으로 변경해도 되지만, 그럼 매번 수정을 해줘야 하는 불편함이 있어 반 자동화가 될 수 있도록 설정해줘야 한다.
📌 local.properties 참조
def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { localPropertiesFile.withReader('UTF-8') { reader -> localProperties.load(reader) } } // def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' } // def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { flutterVersionName = '1.0' }✔ local.properties 파일내부를 확인해보면 플러터 버전 네임과 코드가 있는 것을 볼 수 있다.
✔ 이 파일은build될 때 가장 먼저 실행되어 자동으로 파일을 만들어준다.
📌 versionName과 versionCode 설정
defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.devman.flutter_memo_app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutterVersionCode versionName = flutterVersionName }✔ 값을 참조해서 사용하면 문제가 해결된다.
⚠️ minSdkVersion 오류
✔ 오류가 발생한다면minSdkVersion을 21로 설정한다.
📌 app/build.gradle 파일의 minSdkVersion 21로 설정
defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.devman.flutter_memo_app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = 21 targetSdk = flutter.targetSdkVersion versionCode = flutterVersionCode versionName = flutterVersionName }
✅ 구글 플레이스토어 앱 생성
🔹 Google for Developers
구글 개발자 콘솔로 접속하여 구글 로그인으로 연동한다.
🔹Google Play > Play Console 로그인클릭
🔹 개발자 계정을 만든다.
✅ 개발자 등록
🔹
개인 > 시작하기
🔹 25달러 최초 한 번 결제
🔹 개발자 이름 생성(변경 가능)
🔹결제 프로필 만들기 또는 선택입력 후 저장
🔹본인 인증 > 이메일 주소 인증
🔹 입력 항목 작성 후 다음
🔹앱 배포 계획 항목 선택 > 연락 정보 입력 > 약관 동의 > 결제
✅ 앱 생성
🔹
앱 만들기
🔹 좌측 메뉴앱 정보>기본 스토어 등록정보
🔹 등록 정보 입력
✅ 테스트 모드 앱 배포
🔹 좌측 메뉴
테스트 > 내부 테스트 메뉴
🔹테스터 선택
🔹 이메일 추가(최대 100명까지 참여 가능)
🔹새 버전 만들기 > 서명 키 선택 > Google에서 생성한 키 사용
🔹 vsCode에서 flutter build를 통해 생성된 app-release.aab 파일 등록
🔹다음 > 저장 및 출시
✅ 테스트 앱 다운로드 받아 테스트
🔹 좌측 메뉴
내부 테스트 > 테스터 > 링크 복사
🔹링크 접속 > Accept Invite > download it on Google Play
역시 메모앱에 기능을 더하는 것은 어려웠다.
소스 코드 짜는 법이나, 함수에 대한 것 보다는 기능 연결에 집중되어 있다보니, 중급자를 위한 강의란 생각이 들었다.
확실히 앱 배포까지 완료해보니 모든 내용을 완벽하게 이해하지는 못했지만, 흐름을 파악하는데 좋았던 것 같다.
다만 그 과정이 길고 복잡하다 보니 역시 앱 하나 만들고 배포하는데에는 많은 공수가 든다는 생각이 든다.
다음주부터는 본 캠프로 수업 시간은 배로 늘고, 강의도 시작하고, 조 프로젝트도 진행하게 될 텐데 이해를 잘 하고, 수행할 수 있으면 좋겠다.