Flutter 앱 배포의 잡담 + 리팩토링편은 아래에 있다.
https://vapor3965.tistory.com/118
이번글은 Flutter로 개발하면서 경험했던 이슈나 방법 등을 적어보려고 한다 .
조금이나마 도움이 되었으면 좋겠다!
기능 추가
공지사항 기능 만들기
나처럼 1인개발로 한다면, 비용은 최대한 발생하지 않고 싶을거다. 특히나 서버통신없는 앱을 만든다고 한다면 더더욱.
내가 원하는 공지사항은
1. 앱 버전과 상관없이 늘 최신정보를 보여주고 싶었다.
2. 비용이 들지 않았으면 좋겠다.
iOS나 안드로이드 앱은 기능출시할때마다 배포를 해줘야한다.
즉 새로운 기능을 넣으려면, 코드를 새로 짜서, 새로 앱스토어나 플레이스토어에 다시 배포해야한다.
이전 버전에서는 보일수가 없다. (코드가 없으니)
그런데 내가원하는 공지사항은 이전버전에서도 새로운 공지내역이 보였으면 좋겠다.
이렇게 하려면 서버의 도움이 필요하다. 즉 네트워크를 이용하여 어딘가에 정보를 요청해서 매번 새로운 정보를 앱에서 보여주도록 해야한다.
1인개발자, 특히 프론트쪽 개발자라면, 서버 개발의 지식이 부족할테니, 이런 서버를 호스팅해주는 플랫폼이 많다. 파이어베이스, 아마존 등등
하지만, 단순한 공지사항 정도는 html파일만 어딘가에 저장되어있고, 이걸 앱에 받아올수만 있다면, 웹브라우저로 띄우는건 매우 간단할것이다. html파일을 무료로 호스팅해주는곳이 있다! 바로 깃헙 페이지를 이용하면 된다!
Github Pages
요걸 하면서 이제야 알게되었다는게 좀 머쓱하기도하고, 너무 좋았다. ( 이게된다고?! 흥분하면서 만들었던 기억이..ㅎㅎ)
간단히 말하면, 우리가 쓰는 코드를 깃헙이라는 원격 저장소에 올리곤 하는데, 여기에html파일을 올리고,
Github Pages를 이용하면 호스팅되어서 url로 접근이 가능해진다.
방법은 간단하고 구글에 많은 방법이 있으므로 어렵지 않게 할수 있다.
html을 모르더라도, gpt한테 이런이런이런 html짜줘 하면 잘 짜준다. ㅎ 코드도 보면 어렵지 않게 이해할수 있다.
그래서 나는 메인에 리스트형식의 공지사항 html과, 리스트의 셀을 클릭하면 세부 공지사항 html 두개만들어서 구현했다.
html 코드는 아래에서 확인할 수 있다.
https://github.com/gustn3965/saveMoneyNotice
건의사항 기능 만들기
솔직히 필수기능은 아닌데, 유저피드백을 쉽게 받고싶었다. (앱을 사용하는 사람이 거의없긴하겠지만..ㅋ)
건의사항도 어떻게 좀 해보면 무료로 할수 있는 방법이 있지 않을까 해서 gpt에게 물어봤다.
GPT가 구글폼을 이용해보라고 했다. 😮 ( 새삼.. 많은걸 아는게 중요한것 같다 )
Github pages + 구글폼
구글폼 url만 입력해놓아도 되겠지만, 혹시나 추후에 url이 만료되거나 (그럴까?) 변경된다면 앱을 다시 배포해야하니,
위의 GitHub pages를 이용한것처럼 간단한 건의사항 html 만들어놓고 거기에 구글폼 url을 넣어놨다.
이러면 html만 수정해서 깃헙에 올려놓기만 하면 언제든 반영이 되니! 꽤 괜찮은 방법 같다.
감사합니다 Github, Google
아참, WebViewController로 띄워야할텐데, 로딩이 살짝 느린감이 있어서,
onPageStarted, onPageFinished 등 콜백에서 로딩 플래그해서, 인디케이터 보여주도록 하면 좀 더 나은 사용성이 될것 같다 !
late WebViewController controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(const Color(0x00000000))
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
if (progress < 100) {
// 페이지가 아직 로딩 중인 경우
setState(() {
isLoading = true;
});
} else {
// 페이지 로딩이 완료된 경우
setState(() {
isLoading = false;
});
}
},
onPageStarted: (String url) {
setState(() {
isLoading = true; // 페이지 시작 시 로딩 상태를 true로 설정
});
},
onPageFinished: (String url) {
setState(() {
isLoading = false; // 페이지 종료 시 로딩 상태를 false로 설정
});
},
...
@override
Widget build(BuildContext context) {
return StreamBuilder<AppNoticeWebViewModel>(
stream: viewModel.dataStream,
builder: (context, snapshot) {
return Scaffold(
appBar: AppBar(
backgroundColor: appColors.mainColor(),
title: Text(
'공지사항',
style: TextStyle(
color: appColors.blackColor(),
fontSize: 20,
fontStyle: FontStyle.italic,
fontFamily: 'Inter',
fontWeight: FontWeight.w800,
height: 0,
),
),
leading: IconButton(
icon: Icon(Icons.arrow_back),
onPressed: () {
// 사용자 정의 동작을 수행합니다.
viewModel.didClickNavigationPopButton();
}),),
backgroundColor: appColors.whiteColor(),
body: Stack(
children: [
WebViewWidget(controller: controller), // WebView를 표시
if (isLoading) // isLoading이 true일 때 인디케이터를 표시
Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(appColors.mainColor()),
),
),
],
),
);
}
);
빌드 환경 분리
목적
장기적으로 보았을때, 빌드 환경은 분리하는게 좋다.
개인프로젝트는 크게 상관이없겠지만, 나는 분리해보고 싶었다.
나는 크게 페이즈를 3단계로 나누었다. mock, cbt, real
real은 실제로 사용자들이 사용하는 페이즈다.
cbt는 클로즈베타로, real에 가까운 페이즈다. real에 없는 테스트 기능을 추가하기에 좋다.
mock은 말그대로 목업데이터만으로 이용하는 페이즈다. cbt와 마찬가지로 테스트기능을 추가하기에 좋다.
1. 빌드 환경을 나누어서, 각 페이즈별로 앱을 따로 설치하게 할수 있다.
2. 앱에 광고를 추가할때도, 페이즈별로 나누어서, cbt, mock은 test광고Id를 넣고, real에만 진짜광고Id를 넣어줘도된다.
3. 의존성주입도 페이즈별로 나누어서 넣어줄수 있다.
flavor
Xcoce에서는 Configuration을 이용해서 페이즈를 나눌수 있다.
이렇게 프로젝트 단위에서 지원을 해주면 좋을텐데, 다행이도, 23년부터 Flutter에서 기본적으로 flavor 패키지를 지원한다.
구글에 flavor를 이용하는 방법들이 많이 나와있으니 방법은 패스!
크래쉬 이슈
PlatformException(channel-error, Unable to establish connection on channel., null, null)
빌드한상태이고, 패키지를 추가하고 flutter pub get 하고 빌드해보면 크래쉬나는 경우가 있다.
플러터는 잘모르지만.. 그래도 크래쉬로그 보는데, 도통 무슨말이 모르겠어서, 검색해보니까
요런건 앱을 종료후 다시 하면 된다고 한다. ( 역시 뭐든 재부팅이 답인가 ㅎㅎ )
Widget 이슈
나는 iOS, macOS경험이 거의 다라서, 플러터는 정말 모른다. 모르지만 뭐 비슷하겠거니 하고 시작했다.
iOS경험으로 예를들면, Widget이 swiftUI와 굉장히 닮았다.
플러터에서는 Dart라는 언어를 사용하고, async await를 제공하고,
뷰는 Widget이라고 부른다.
Widget은 트리구조로 Widget안에 Widget이 있을수 있다.
swiftUI처럼 Widget도 매번 새로 빌드하게된다.
BottomNavigationBar 클릭시 리빌드 안하도록
이렇게 하단에 네비게이션바를 만들었고, 클릭시마다 해당 화면을 보여주도록 했다.
그런데 계속 Widget - build가 호출돼서 스크롤 해둔위젯이라도 다시 올라가고, 선택들이 다 풀리고, 하는 좋지못한 사용성을 계속 경험했다. 그래서 이걸 어떻게해야하나, 하니.. 검색해보니, 캐싱을 할수있다고 한다.
IndexedStack
내 프로젝트는, Coordinator기반으로 화면을 전환하도록 구현헀고, Coordinator는 Widget의 구현체를 모르도록 했다.
Widget을 업데이트하려면Coordinator는 ViewModel에게 지시를 하고,
ViewModel을 옵저빙하는 Widget이 업데이트되도록 했다.
암튼, Coordinator에서 캐싱을 하게하고 싶지않았고, (하더라도 리빌드 안할지는 모르겠다 )
MainTabWidget에 각 탭바에 해당하는 위젯을 List로 넣어두었고,
원래는 클릭시 인덱스로 List를 접근해서 body에 붙여놓았다.
body를 IndexedStack으로 감싸서 호출하도록 하면 이제 List 인덱스에 해당하는 위젯은 리빌드는 안하게된다!
class MainTabWidget extends StatelessWidget {
final MainTabViewModel viewModel;
late List<Widget> bodyWidgets;
MainTabWidget(this.viewModel, this.bodyWidgets);
@override
Widget build(BuildContext context) {
return StreamBuilder<MainTabViewModel>(
stream: viewModel.dataStream,
builder: (context, snapshot) {
return Scaffold(
backgroundColor: appColors.whiteColor(),
body: IndexedStack(
index: viewModel.bottomNavigationIndex,
children: bodyWidgets,
),
bottomNavigationBar: Theme(
data: Theme.of(context).copyWith(
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
ListView에 데이터 많을때 느린 이슈
트리구조로 위젯안에 위젯을 넣을수 있다보니, 손쉽게 교체가 가능해서 넣다 뺐다 할수도 있고,
암튼 코드가 굉장히 깔끔해지는것 같다.
기본적으로 내 구조는, 하나의 화면당 하나의 Coordinator를 갖도록 했다.
Widget은 ViewModel을 갖도록 했다.
그래서 한화면안에 Widget이 많은 화면이 있게되면, ScrollWidget으로 Row로 여러위젯들을 갖을수있도록 했다.
이렇게되면 의존성도 줄어들고, 끼워넣기도 편할것 같기 때문이다.
그래서 parentScrollWidget은 정말 Widget을 보여주는 용도 이외는 없다.
암튼, ParentScrollWidget이 있는 상태이고, 이안에 C Widget에 ListView를 구현할 생각이였다.
db에서 검색해서, 가져온 데이터를 ListView로 넣어줬다.
그냥 별생각없이 구현했는데, 데이터를 잘가져오고 잘보여주고, 문제없어보였다.
보통 시뮬레이터로 테스트하고, 마지막에만 내 폰에 넣어서 내가 사용하고 그랬는데,
문득 해보니까 엄청 버벅이더라.
알고보니 ListView의 크기가 엄청 컸다. height가 3000을 넘는것 같다.
그니까 디바이스 화면은 작은데, ListView를 엄청 크게 그려놓고 하니까 수많은 셀들을 한번에 가지고 있어서 부하가 생긴듯 하다.
( 셀 재사용도 안할것 같고. )
부모뷰가 ScrollView이고, ListView에 높이를 안주어버리니, ListView가 콘텐츠 셀만큼 무한히 확장해버려 이런 참사가 발생한것.
그래서 ListView의 크기를 화면 크기만큼만 지정해놓고 하니, 이슈 해결... !
다크모드 구현
이 다크모드 정말 귀찮았다.
아니뭐.. Material Theme를 이용하면 간단하게 된다고 하는데, 볼수록 커스텀이 내가원하는 커스텀만큼 안되는것 같다.
그냥 간단하게, 다크모드일때 색을 접근하면 A라는 컬러로, 라이트일때는 B라는 컬러로 줬으면 좋겠다.
근데 Material Theme는 이미 정해진 style이 있는듯 하다. 그안에서만 색을 정해야하는 듯하다.
그래서 Button이나 Text나 배경컬러나, 세부적으로 커스텀이 안되는것 같다. 물론 수많은 프로퍼티가 이미 정해져있긴한데,
모든걸 만족시켜주지는 못하는것 같았다.
장기적으로 봤을때... 여러 위젯들을 일관성있는 디자인으로 관리한다면 괜찮은 방법같은데, 나는 별로였다.
다크모드, 라이트모드 변경 감지 및 구독
암튼 그래서, 결국 고전적인 방법 ? 다크모드 라이트모드를 구분하고, 변경됨을 감지하도록 해서, 리빌드 하도록 했다.
색은 AppColors 클래스로 만들어서 이 색에 접근하도록 했다.
mainRedColor면 텍스트나, 버튼에서도 사용했으면 좋겠으니까!
class AppColors {
// 블루
bool isDarkMode() {
return PlatformDispatcher.instance.platformBrightness == Brightness.dark;
}
// main color
Color mainRedColor() {
if (isDarkMode()) {
return Color(0xFFC90040);
} else {
return Color(0xFFFA0058);
}
}
Color mainLighColor() {
if (isDarkMode()) {
return Color(0xFF1D5BFC);
} else {
return Color(0xFFC0CFF6);
}
}
다크모드, 라이트모드 변경감지는 WidgetsBindingObserver를 상속받아서 didChangePlatformBrightness 를 오버라이드하고,
main() 에서 구독하게 하면 감지가 된다.
WidgetsFlutterBinding.ensureInitialized()
.addObserver(appCoordinator);
abstract class Coordinator extends WidgetsBindingObserver {
...
}
class AppCoordinator extends Coordinator {
...
@override
void didChangePlatformBrightness() {
print(PlatformDispatcher.instance.platformBrightness);
triggerTopUpdateWidget();
}
Widget 화면관리를 Coordinator로 관리하고 있어서, 최상위 AppCoordiantor에서 didChnagePlatformBirghtness() 만 오버라이드해서, 하위 Coordiantor들에게 전파하도록했다.
그래서 Coordinator가 갖는 ViewModel들에게 업데이트하도록 하고,
Widget은 옵저빙하고 있으니, 업데이트되면서 다시 AppColors의 color를 접근하면 다크모드,라이트모드에 맞는 컬러를 선택하도록 했다.
List 위험성 ?
언어를 제대로 공부안하고 하다보니 발생한 이슈였다.
Swift에서는 배열과 비슷해서 생각없이 사용했다.
파라미터로 List를 전달했고, 그 list에 add 하여 특정 수행을 하도록 했는데,
자꾸 값이 증가하길래, 디버그하면서 보니까, 파라미터로 전달후부터 늘어난 상황...
그니까, List타입은 Dart언어에서 참조타입이므로, 파라미터로 전달된 List도 원본을 참조하고 있으므로, 원본에도 영향이 끼친것..
클래스야 당연히 참조타입이니까 이렇게 안하는데, List까지 참조타입일지 몰랐다.
이런경우에는 새로 복사해서 초기화하여 사용하도록 하자..!
'Flutter' 카테고리의 다른 글
Flutter로 만든 앱 배포 후기 (5) | 2024.07.05 |
---|