[앱개발후기] Flutter로 2개의 앱을 개발하다 (1편)
Flutter에 관하여서 막연한 관심만 가지고 있다가
직접 경험할 수 있는 좋은 기회가 되어 2개의 앱을 개발하였고 런칭하게 되었습니다.
아무것도 몰랐던 바닥부터 Flutter에 대하여 공부하고, 조사하고
런칭하게 되기까지 개발 후기에 대하여 적어보고자 합니다 🚀
Index
Mybiskit(마이비스킷)
Flutter 첫 번째 앱 Mybiskit 개발 시작
GDG / Flutter Korea MeetUp에서 발표를 보거나, 각종 사이트의 Flutter관련 글을 보며
Flutter가 무엇인지에 대하여 관심과 궁금증이 생기던 2019년 말쯤.. 좋은 기회가 찾아오게 되었습니다.
그건 바로 회사에서 기존에 웹만 존재하던 서비스를 Flutter로 개발하여 앱으로 런칭할지도 모른다는 것!
이것을 계기로 회사 팀 내에서 기존에 서비스 중인 회사 앱을 Flutter로 다시 만들어보고
여러 정보도 공유하는 Flutter 스터디도 진행하게 되었습니다.
개발 전 사전조사
스터디가 끝나고 Flutter로 서비스를 만드는 것이 가능할지 확인하기 위하여
Flutter로 상용 서비스를 하고 있는 다른 앱들을 많이 살펴보았고, 기술적으로 검토가 필요했습니다.
결제가 들어가는 앱이었고, 동영상을 플레이하는 동영상 플레이어가 필요했으며
각종 소셜 로그인, 웹뷰, 메인 아키텍처 결정, 딥링크.. 등등
서비스를 만드는 것에서 꼭 필요한 기능들과, 장애가 될 것 같은 요인들을 정리하였고
샘플 코드를 작성하여 구현이 가능한지 검증하였습니다.
🟠 리스트 성능 이슈
🔵 결제 관련 기능
🟠 동영상 플레이어
🔵 각종 SNS 로그인
🟠 Webview
🔵 MainArchitecture
🟠 Deeplink
🔵 Etc..
당시 플러터로 개발된 구글 애드워즈, 구글 스태디아, 알리바바 - 텐센트 등의 앱들이 있지만
그중 국내에서 유명하였던 지식인 앱이
안드로이드에서 스크롤 퍼포먼스가 떨어져 보이는 현상이 있었어서
Flutter로 개발하는 것이 문제가 될까 마지막까지 염려하였었습니다.
하지만 직접 구현 확인하여보니 다행히 스크롤에는 큰 문제가 없었어서
마지막 확인을 마친 후 2020.03.02부터 개발을 착수하게 되었습니다 🙃
딥링크(랜딩)에 대한 고민
사전 조사를 통하여 기능 구현에 필요한 여러 가지에 대하여 먼저 확인하였지만
이후에 가장 먼저 고민하였던 부분은 바로 딥링크(랜딩)입니다.
딥링크는 특정 주소 혹은 값을 입력하면 앱이 실행되거나 앱 내 특정 화면으로 이동시키는 기능을 말하는데
팀장님이 이것이 가장 먼저 정리되어야 후에 편하다고 말씀해주셔서 딥링크를 가장 첫 번째로 고민하게 되었습니다.
먼저 팀장님과 대략적인 화면 플로우를 정의하였습니다.
대략적인 화면을 정의한 뒤 이 화면들을 URI Scheme으로 모두 이동할 수 있도록 구조를 만들었습니다.
각 화면마다 Path를 정의하였고, URI Scheme과 매칭 하여
예를 들어 myapp://home , myapp://mypage.. 등의 스킴으로 앱을 호출하였을 때 해당 화면으로 이동하도록 하였습니다.
또한 추가로 고민하였던 메인화면(홈 화면)을 거치지 않고 다른 화면으로 바로 이동할 수 있도록
할 수 있는지에 대하여 고민하였습니다.
보통의 앱들은 메인화면을 거친 후에 대부분 다른 랜딩 화면으로 이동합니다.
하지만 메인화면을 거치지 않고 바로 다른 화면으로 이동한다면 여러 가지 이점을 가질 수 있습니다.
진입하였을 때 많은 수의 API 요청을 서버로 보내게 되는데 이것들이 불필요한 요청이 대부분인 경우가 많고
메인화면을 거치지 않는다면 다른 화면으로 랜딩 할 때 불필요한 화면은 거치지 않기 때문에 이동하는 속도적으로도 많은 이점이 있기 때문입니다. 결과적으로 많은 퍼포먼스적인 이점을 얻을 수 있었습니다.
이러한 이점들을 살리고자 모든 화면은 아니지만
주로 쓰이는 클래스 상세화면과 이벤트 상세 화면 등등은
메인화면을 거치지 않도록 화면 랜딩을 구현할 때 각별히 신경을 쓰게 되었습니다.
아키텍처의 대한 고민
마지막까지 고민되었던 부분은 어떻게 아키텍처를 가져가면 좋을 것인지였습니다.
당시 Flutter가 명확한 로드맵이 없었기 때문에
여러 가지 상태 관리 방법과 아키텍처 대하여 많이 고민하였습니다.
🔽🔽 고민해보았던 여러 가지 상태 관리, 아키텍처들..
Bloc | - BLoc 패턴 구현을 돕는 다트 패키지 사용 - 위젯과 비즈니스로직을 분리하여 State로 관리 - 테스트 작성하기 쉬움(위젯은 State만을 신경쓰면 되기때문에 위젯테스트 작성용이,비즈니스로직은 블록을 테스트) - 보일러플레이트가 많음(State,Event,Bloc등 여러파일이 많이만들어 짐) |
Provider | - Flutter 팀에서 권장하는 Provider 패키지와 함께 Flutter 의 ChangeNotifier 클래스를 사용 - State를 따로 관리하지않으며 ChangeNotifier의 notifyListeners()를 호출하여 위젯을갱신 보일러플레이트가 가장 없이 간단하게 구현가능 - ChangeNotifier를 상속하는 클래스가 역할이 많아지면, 다소 복잡해질 수 있음 |
StateRebuilder | - states_rebuilder 라이브러리를 사용하여 앱 상태를 관리하고 위젯을 업데이트합니다 - 보일러플레이트 없이 상태관리가능 - 서비스 로케이터 패턴으로 자체적인 종속성주입 Injector 클래스가 있음 - 위젯의 수명주기 라이프사이클 관리가능 |
Mobx | - Observables, Actions, Reactions 세가지 개념으로 상태관리 - Observables : @observable, @computed 어노테이션으로 관찰가능한 값을 생성 - Actions : @action 어노테이션으로 선언하며 Observable(@observable, @computed로 선언된값)을 변경 - Reactions : Observable(@observable, @computed로 선언된값)이 변경될 때 마다 알림을받음 |
Redux | - react에서 자주 사용하며 데이터를 처리하기에 효과적 - Actions, Reducers, Stores 세가지 개념으로 상태 관리 - 1. store로 action을 전송 - 2. store는 reducer를 호출하고, reducer는 이전상태와 action로reducer는 새로운 앱상태를 리턴하게 된다 - 3. store는 앱상태를 저장하고, listen하고 있는 모든 component에 notify한다 - 4. 앱상태가 notify 되었을때 위젯을 rebuild 한다 - 정리 |
MVP | - Model, View, Presenter - Model : 데이터 영역. 비즈니스 로직을 처리하고 네트워크 및 데이터베이스 계층과의 통신을 담당 - View : UI 레이어. 데이터를 표시하고 Presenter에게 사용자 작업에 대해 알립니다. - Presenter : 모델에서 데이터를 검색하고, UI 논리를 적용하고,보기 상태를 관리하고, 표시 할 항목을 결정하고보기에서 사용자 입력 알림에 반응 |
MVVM | - Model, View, ViewModel - Model : 데이터 영역. 비즈니스 로직을 처리하고 네트워크 및 데이터베이스 계층과의 통신을 담당 - View : UI 레이어. - ViewModel : View와 Model 사이의 매개체 역할, Model 에서 제공받은 데이터를 UI에서 필요한 정보로 가공한 뒤 View가 가져갈 수 있게 데이터 변경에 대한 이벤트를 보냄 |
Clean Architecture |
- UI 관련된 Presentation Layer |
많은 고민 끝에 우선 Clean Architecture + Bloc으로 상태 관리와 아키텍처를 가져가게 되었습니다
스터디 때부터 많이 참고하였던 Flutter 관련 사이트에 Clean Architecture + Bloc 관련한 아티클을 참고하였고
결정하게 된 큰 이유는 TDD(테스트 주도 개발)을 적용하여 개발하기 위함이었습니다.
Clean Architecture + Bloc 아키텍처 적용
Robert C. Martin의 Clean Architecture 계층화된 양파 이미지는 모두 많이들 알고 있을 것입니다.
하지만 프로젝트에 적용된 Clean Architecture는 조금 변형된
프레젠테이션, 도메인, 데이터 3가지 레이어를 사용합니다.
프레젠 테이션 계층은 위젯에서 발생한 사용자 이벤트를 Bloc(Presentation Logic Holders)에 전달합니다
Bloc은 상태를 관리하고 수신합니다. 여기서 Bloc은 많은 일을 하지 않고 모든 작업을 Use cases에 위임합니다
도메인 계층은 다른 모든 레이어와 독립적이며, Use cases 및 Entities가 포함됩니다.
Use cases는 앱의 특정 사용 사례의 모든 비즈니스 로직을 캡슐화하는 클래스입니다.
또한 데이터 계층과 연결을 위하여 추상적인 Repository(레포지토리)를 가지고 있습니다.
데이터 계층은 Repository Impl(레포지토리 구현) 및 DataSource(데이터 소스)가 포함됩니다
네트워크 및 데이터베이스 통신을 담당합니다.
🔽🔽 Clean Architecture + Bloc + TDD 적용기
Clean Architecture 구조를 적용하고 상태 관리를 Bloc으로 하며,
테스트 주도 개발 방법으로 구현하였던 순서를 나열해보았습니다.
도메인 계층
1. entity 작성
2. abstract repository 선언
3. abstract repository를 Mock으로 사용하여 usecase 테스트 작성
4. usecase 구현
데이터 계층
5. 파싱 모델 생성
6. abstract datasource 선언
7. abstract datasource Mock으로 사용하여 repository impl 테스트 작성
8. repository impl 구현
9. data source 테스트 작성
10. datasource impl 구현
프레젠테이션 계층
11. bloc test 작성
12. bloc 구현
도메인 계층
1. 엔티티 생성
class MemberEntity extends Equatable {
final String provider;
final int providerId;
final String nickname;
final String email;
final String birthday;
final String gender;
MemberEntity({
this.provider,
this.providerId,
this.nickname,
this.email,
this.birthday,
this.gender,
});
@override
List<Object> get props => [
provider,
providerId,
nickname,
email,
birthday,
gender,
];
}
2. 추상 Repository 선언
abstract class AuthRepository {
Future<Either<Failure, Member>> signInMember(Provider param);
}
3. usecase 테스트 작성
class MockAuthRepository extends Mock implements AuthRepository {}
main() {
MockAuthRepository repository;
SignInUsecase usecase;
setUp(() {
repository = MockAuthRepository();
usecase = SignInUsecase(repository);
});
MemberEntity member = MemberEntity();
test('로그인 성공', () async {
// given
when(repository.signInMember(any))
.thenAnswer((_) async => Right(member));
//when
final result = await usecase.call(param));
//then
expect(result, Right(member));
verify(repository.signInMember(any));
verifyNoMoreInteractions(repository);
});
}
4. usecase 구현
class SignInUsecase extends UseCase<MemberEntity, SignInParam> {
final AuthRepository repository;
SignInUsecase(this.repository);
@override
Future<Either<Failure, MemberEntity>> call(params) async {
return await repository.signInMember(params.provider);
}
}
데이터 계층
5. 파싱 모델 생성
@JsonSerializable()
class MemberParser extends MemberEntity {
final String provider;
final int providerId;
final String nickname;
final String email;
final String birthday;
final String gender;
MemberParser({
this.provider,
this.providerId,
this.nickname,
this.email,
this.birthday,
this.gender,
}) : super(
provider: provider,
providerId: providerId,
nickname: nickname,
email: email,
birthday: birthday,
gender: gender,
);
factory MemberParser.fromJson(Map<String, dynamic> json) =>
_$MemberModelFromJson(json);
Map<String, dynamic> toJson() => _$MemberModelToJson(this);
}
6. 추상 datasource 선언
abstract class AuthLocalDataSource {
Future<MemberParser> getLastLoginMember();
Future<void> saveLoginMember(MemberParser member);
}
abstract class AuthRemoteDataSource {
Future<MemberParser> requestConcreteMember(Provider proivder);
}
7. repository impl 테스트 작성
class MockAuthLocalDataSource extends Mock implements AuthLocalDataSource {}
class MockAuthRemoteDataSource extends Mock implements AuthRemoteDataSource {}
class MockSesseion extends Mock implements Session {}
main() {
AuthRepositoryImpl repositoryImpl;
MockAuthLocalDataSource mockLocalDataSource;
MockAuthRemoteDataSource mockRemoteDataSource;
MockSesseion mockSesseion;
setUp(() {
mockLocalDataSource = MockAuthLocalDataSource();
mockRemoteDataSource = MockAuthRemoteDataSource();
mockSesseion = MockSesseion();
repositoryImpl = AuthRepositoryImpl(
authLocalDataSource: mockLocalDataSource,
authRemoteDataSource: mockRemoteDataSource,
session: mockSesseion,
);
});
MemberParser memberModel = MemberParser();
Provider param = Provider.kakao(code: 'default_code');
group('로그인', () {
test('세션이 존재하는지 확인해야한다', () {
// given
// when
repositoryImpl.signInMember(param);
// then
verify(mockSesseion.hasSession());
});
test(
'로그인 호출이 성공하면, 로컬로 캐시해야한다.',
() async {
// given
when(mockRemoteDataSource.requestConcreteMember(param))
.thenAnswer((_) async => memberModel);
// when
await repositoryImpl.signInMember(param);
// then
verify(mockRemoteDataSource.requestConcreteMember(param));
verify(mockLocalDataSource.saveLoginMember(memberModel));
},
);
test(
'로그인 호출이 실패하면 서버에러를 반환해야한다',
() async {
// given
when(mockRemoteDataSource.requestConcreteMember(param))
.thenThrow(ServerException());
// when
final result = await repositoryImpl.signInMember(param);
// then
verify(mockRemoteDataSource.requestConcreteMember(param));
verifyZeroInteractions(mockLocalDataSource);
expect(result, equals(Left(ServerFailure())));
},
);
});
}
8. repository impl 구현
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource authRemoteDataSource;
final AuthLocalDataSource authLocalDataSource;
final Session session;
AuthRepositoryImpl({
this.authRemoteDataSource,
this.authLocalDataSource,
this.session,
});
@override
Future<Either<Failure, MemberEntity>> signInMember(Provider provider) async {
if (session.hasSession()) {
try {
final localMember = await authLocalDataSource.getLastLoginMember();
return Right(localMember);
} on CacheException {
return Left(CacheFailure());
}
} else {
try {
final member =
await authRemoteDataSource.requestConcreteMember(provider);
await authLocalDataSource.saveLoginMember(member);
return Right(member);
} on ServerException {
return Left(ServerFailure());
}
}
}
}
9. data source 테스트 작성
class MockSession extends Mock implements Session {}
void main() {
MockSession mockSession;
AuthLocalDataSourceImpl authLocalDataSourceImpl;
setUp(() {
mockSession = MockSession();
authLocalDataSourceImpl = AuthLocalDataSourceImpl(mockSession);
});
MemberParser memberModel = MemberParser();
group(('마지막 로그인유저정보'), () {
test(
'세션에 Member가 있으면 반환한다.',
() async {
// given
when(mockSession.getMember()).thenReturn(memberModel);
// when
final result = await authLocalDataSourceImpl.getLastLoginMember();
// then
verify(mockSession.getMember());
expect(result, equals(memberModel));
},
);
test('세션에 Member가 없을 때 CacheException을 발생한다', () {
// given
when(mockSession.getMember()).thenReturn(null);
// when
final call = authLocalDataSourceImpl.getLastLoginMember;
// then
expect(() => call(), throwsA(TypeMatcher<CacheException>()));
});
});
group('로그인 유저 정보 세션저장', () {
test('로그인 멤버를 세션에 저장한다', () async {
// given
when(mockSession.updateMember(memberModel)).thenAnswer((_) async => true);
// when
await authLocalDataSourceImpl.saveLoginMember(memberModel);
// then
verify(mockSession.updateMember(memberModel));
});
test('로그인 멤버를 세션에 저장실패하면 CacheException을 발생한다', () {
// given
when(mockSession.updateMember(memberModel)).thenAnswer((_) async => false);
// when
final call = authLocalDataSourceImpl.saveLoginMember;
// then
expect(() => call(memberModel), throwsA(TypeMatcher<CacheException>()));
});
});
}
10. datasource impl 구현
class AuthLocalDataSourceImpl implements AuthLocalDataSource {
final Session session;
AuthLocalDataSourceImpl(this.session);
@override
Future<MemberParser> getLastLoginMember() {
final member = session.getMember();
if (member != null) {
return Future.value(member);
} else {
throw CacheException();
}
}
@override
Future<void> saveLoginMember(MemberParser member) async {
final result = await session.updateMember(member);
if (!result) {
throw CacheException();
}
}
}
프레젠테이션 레이어
11. Bloc test 작성
class MockSignInUsecase extends Mock implements SignInUsecase {}
void main() {
MockSignInUsecase mockSignInUsecase;
AuthBloc bloc;
setUp(() {
mockSignInUsecase = MockSignInUsecase();
bloc = AuthBloc(signInUsecase: mockSignInUsecase);
});
Provider param = Provider.kakao(code: 'SFLKWJLKFAL_GZLJAFJAFJ');
group('[Bloc] 로그인(signIn Event)', () {
test('초기 상태는 initial 이여야 한다', () {
expect(bloc.initialState, AuthState.initial());
});
test('signIn 이벤트 발생시 상태[initial -> authenticating -> authenticated] 이여야한다',
() async {
// given
when(mockSignInUsecase(any))
.thenAnswer((_) async => Right(MemberParser()));
final expected = [
AuthState.initial(),
AuthState.authenticating(),
AuthState.authenticated()
];
// when
bloc.add(AuthEvent.signIn(param));
// then
expectLater(bloc, emitsInOrder(expected));
}, timeout: Timeout(Duration(seconds: 5)));
test('signIn 이벤트중 에러발생시 상태는[initial -> authenticating -> error] 이여야한다',
() async {
// given
when(mockSignInUsecase(any))
.thenAnswer((_) async => Left(ServerFailure()));
final expected = [
AuthState.initial(),
AuthState.authenticating(),
AuthState.error(ServerFailure())
];
// when
bloc.add(AuthEvent.signIn(param));
// then
expectLater(bloc, emitsInOrder(expected));
}, timeout: Timeout(Duration(seconds: 5)));
});
}
12. Bloc 구현
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final SignInUsecase signInUsecase;
AuthBloc({this.signInUsecase});
@override
AuthState get initialState => AuthState.initial();
@override
Stream<AuthState> mapEventToState(
AuthEvent event,
) async* {
yield* event.when(
signIn: (Provider provider) => mapEventFromSignIn(provider),
);
}
Stream<AuthState> mapEventFromSignIn(Provider provider) async* {
yield AuthState.authenticating();
final failureOrMember = await signInUsecase(SignInParam(provider));
yield failureOrMember.fold(
(failure) => AuthState.error(failure),
(member) => AuthState.authenticated(),
);
}
}
Clean Architecture는 각 계층 간에 의존성이 거의 없어서
TDD를 적용하기에 참 좋다는 생각이 들었습니다.
TDD는 항상 실천해야지 생각만 하였었지만
실제로 TDD를 적용하며, 기능을 구현할 때 테스트 케이스를 먼저 작성하고, 테스트 케이스를 통과하기 위한 코드를 짜다 보니
명확한 요구사항을 파악하고, 추상적으로 기능을 선언하는 방법,
테스트를 작성하면서 무거워진 함수들을 자연스럽게 나누는 방법 등의 장점이 있었습니다.
또한 퍼즐이 맞춰가는 것 같은 재미를 느꼈고, 작성된 테스트를 기반으로 다시 기능을 수정하기도 수월하였습니다.
하지만, 끝까지 적용하지 못한 Clean Architecture, Bloc
몇 가지 feature를 Clean Architecture + Bloc를 적용하여 개발하였지만
점점 개발할수록 이게 정답일까??라는 의문이 들었고 몇 가지 문제점을 발견하였습니다.
❗⛔문제 1. 복잡한 패키지 구조
개발을 진행할수록 나누어진 패키지 구조가 많다는 생각이 들었고
이에 따라 추가해야 할 파일들이 너무 많았습니다.
예를 들어 간단한 로그인 기능의 usecase를 작성하는 데에
그에 따라오는 파일들이 거의 기본 10개 이상 추가되어야 하다 보니 (entities, models, abstract repository, repository impl, abstract data source, data source impl, bloc, bloc event, bloc state..)
로그아웃, 회원가입 이런 식으로 기능을 추가할 때마다
모든 파일들을 추가 생성하는 것이 부담스럽다고 생각되었습니다.
당시 빠른 개발을 진행하여야 하는 일정이어서 이런 것들이 맞지 않다고 생각되었습니다.
또한 처음 개발에 참여한 팀원들이 아닌
다른 팀원들이 프로젝트를 참가하게 되었을 때에도 복잡하다고 느껴질 것이라 생각하였습니다.
❗⛔문제 2. Bloc의 상태 관리방법 문제
Bloc(프레젠테이션 로직 홀더)을 사용할 때도
간단한 기능을 구현하는 데에 많은 파일들이 필요한 것이 장애물로 다가왔습니다. (보일러 플레이트)
또한 Bloc이 1개의 Event Stream만을 사용하다 보니 이벤트가 발생하였을 때 분기 처리가 많았습니다.
예를 들어 switch를 on / off를 하는 간단한 기능이 있을 때
Bloc Event(스위치 on, 스위치 off), Bloc State(스위치 초기 상태, 스위치 ON상태, 스위치 OFF상태)
이벤트 클래스 2개, 상태 클래스 3개를 생성하였어야 하고
Bloc 로직을 구현할 때도 이벤트 2개에 대한 분기 처리가 들어가야 하였습니다.
간단한 기능을 구현하는데도 많은 부수적인 구현이 필요하다 보니
이 또한 맞지 않다고 생각되었습니다.
이러한 문제점들로 당시에 팀원분들과 말씀을 나누었고
다른 아키텍처, 상태 관리방법을 가져가는 것이 좋을 것 같다는 결론으로 이어졌습니다.
MVVM + MVP + Provider 아키텍처로 변경하기
적용하였던 Clean Architecture + Bloc의 여러 문제점을 발견하고 난 뒤
다시 아키텍처에 대한 고민을 시작하였습니다.
우선 상태 관리 방법으로 Bloc을 사용하지 않고
당시 Flutter에서 권장하던 방법인 Provider를 사용하는 것으로 변경하게 되었습니다.
Provider를 선택하게 된 이유는 Bloc처럼 Event, State 등의 클래스 등을 따로 구현하지 않고(보일러 플레이트가 적게)
ChangeNotifier의 notifyListeners()를 호출하여 간단하게 위젯을 갱신할 수 있다는 점이었습니다.
여기에 MVP와 MVVM의 장점을 섞어서 변형하여 사용하게 되었습니다.
MVVM 패턴처럼 ViewModel이 notify() 할 때마다 위젯이 갱신되도록 하였고,
MVP 패턴에서 Presenter가 View를 알고 있는 것과 유사하게(?) UI 관련 상태들을 저장하는 클래스를
ViewModel에서 알고 있도록 하였습니다.
예) viewmodel
class FavoriteListViewModel extends BaseViewModel {
static const int LIMIT = 30;
final FavoriteRepository favoriteRepository;
FavoriteListViewModel({
@required this.favoriteRepository,
}) {
ui = FavoriteListUI(this);
}
FavoriteView ui;
Future<void> load() async {
await favoriteRepository
.loadFavorite()
.success(ui.onLoadFavoriteList)
.bind(ui, fullscreen: true, retry: load);
}
}
class FavoriteListUI extends BaseUIView {
FavoriteListUI(UiController uiController) : super(uiController: uiController);
FavoriteListState state = FavoriteListState.init();
List<LectureEntity> favoriteList = [];
void onLoadFavoriteList(FavoriteListEntity favoriteListResponse) {
if (favoriteListResponse.favoriteList.isNotEmpty) {
favoriteList = favoriteListResponse.favoriteList;
state = FavoriteListState.loaded(favoriteListResponse.isNextExisted);
} else {
state = FavoriteListState.empty();
}
notifyListeners();
}
}
ViewModel에서는 로직 관련 내용을 처리하고 UI 관련 처리는 View클래스에 위임합니다.
ViewModel에서 로직을 처리하고 View를 갱신하려면 View클래스를 호출하고
View클래스에서는 UI상태를 변경한 뒤 notifyListener()를 호출합니다.
notifyListener()가 호출되면 위젯에서는 Consumer()를 통해 위젯을 다시 그리게 됩니다.
MVP와 MVVM를 섞어서 사용하게 된 이유는
UI까지 Mocking 하여 ViewModel에서는 호출하였는지를 verify() 할 수 있었고
UI 클래스에서도 UI상태에 관련한 테스트를 작성할 수 있는 점에서
이렇게 변형된 구조를 사용하게 되었습니다.
예) viewmodel_test
class MockFavoriteRepository extends Mock implements FavoriteRepositoryImpl {}
class MockFavoriteListUI extends Mock implements FavoriteListUI {}
class MockUiController extends Mock implements UiController {}
main() {
MockFavoriteRepository repository;
MockFavoriteListUI ui;
FavoriteListViewModel viewModel;
setUp(() {
repository = MockFavoriteRepository();
ui = MockFavoriteListUI();
viewModel = FavoriteListViewModel(
favoriteRepository: repository,
);
viewModel.ui = ui;
});
final favoriteList = [
LectureParser(
id: 1,
...,
),
LectureParser(
id: 2,
...
)
];
group('[ViewModel]', () {
test('로드 성공', () async {
// given
when(repository.loadFavorite()).thenAnswer(
(_) async => Right(FavoriteListParser(favoriteList: favoriteList)));
// when
await viewModel.load();
// then
verify(repository.loadFavorite());
verify(viewModel.ui
.onLoadFavoriteList(FavoriteListParser(favoriteList: favoriteList)));
});
test('로드 응답 비어있음', () async {
// given
when(repository.loadFavorite()).thenAnswer(
(_) async => Right(FavoriteListParser(
favoriteList: [],
isNextExisted: false,
)),
);
// when
await viewModel.load();
// then
verify(repository.loadFavorite());
verify(viewModel.ui.onLoadFavoriteList(FavoriteListParser(
favoriteList: [],
isNextExisted: false,
)));
});
test('로드 실패', () async {
// given
final String message = "xxxxxx";
final failure = ServerFailure(error: message);
when(repository.loadFavorite()).thenAnswer((_) async => Left(failure));
// when
await viewModel.load();
// then
verify(repository.loadFavorite());
verify(viewModel.ui.showErrorScreen(failure, viewModel.load));
});
});
group('[UI]', () {
MockUiController mockUiController;
setUp(() {
mockUiController = MockUiController();
viewModel.ui = FavoriteView(mockUiController);
});
test('로드 성공시 UI 목록 비어있지않음', () {
// given
final favoriteListResponse = FavoriteListParser(
favoriteList: favoriteList,
isNextExisted: true,
);
// when
viewModel.ui.onLoadFavoriteList(favoriteListResponse);
// then
expect(viewModel.ui.favoriteList, favoriteListResponse.favoriteList);
expect(viewModel.ui.state,
FavoriteListState.loaded(favoriteListResponse.isNextExisted));
verify(viewModel.ui.uiController.notifyListeners());
});
test('로드 성공시 UI 목록 비어있음', () {
// given
final favoriteListResponse = FavoriteListParser(
favoriteList: [],
isNextExisted: false,
);
// when
viewModel.ui.onLoadFavoriteList(favoriteListResponse);
// then
expect(viewModel.ui.favoriteList, []);
expect(viewModel.ui.state, FavoriteListState.empty());
verify(viewModel.ui.uiController.notifyListeners());
});
});
}
Flutter에 아키텍처가 완벽한 로드맵이 주어지지 않았기 때문에
팀원분들과 많은 고민 끝에 약간 변형하여 프로젝트에 맞추어(테스트를 작성하기 쉬운 구조의) 아키텍처를 적용하게 되었습니다.
개발하며 부딪힌 난관들
2020.03에 개발을 시작하였고 2020.07에 1.0.0 버전을 배포하게 되었습니다.
최초 5월 중순 배포를 목표로 작업하였지만 여러 가지 문제로 미워져 7월 초에 1.0.0 버전을 배포하게 되었습니다.
약 4달 동안 개발을 진행하였고 여러 난관이 있었으니..
❗🛑첫 번째 기존에 웹으로 서비스하던 것을 앱으로 만들다 보니
웹뷰를 많이 사용하게 되었는데 이것이 문제가 되었습니다
강의 상세페이지, 결제화면 등이 모두 웹뷰를 사용하게 되었고..
특히 강의 상세 페이지에서 큰 문제가 있었는데
상세페이지에 스크롤 영역에 소개 웹뷰가 2개 들어가고,
각 웹뷰의 높이가 1000~10000px로 가변적인 높이를 가지는데
웹뷰의 높이가 1500px이 넘어가게 되면 안드로이드에서 휴대폰 전원이 꺼져버리는 문제가 있었습니다.
이는 기획을 변경하여 1500px이 넘어갈 때는 다른 웹뷰 화면으로 랜딩 하여 보여주도록 수정되었습니다.
이 문제로 꽤 골머리를 알았었고..
이외에도 웹뷰 세션이 계속 남아있거나, 페이지를 이동할 때마다 새로 고침 되는 등 여러 문제가 있었습니다
Flutter에서는 웹뷰가 아직 불안정하니
웹뷰는 되도록이면 쓰지 않는 것이 좋을 것이라고 생각하였습니다.
❗🛑두 번째는 개발기간이 길어지게 된 요인중 하나인 까다로운 애플 정책과 심사였습니다.
애플의 까다로움은 소문으로 들어 알고는 있었지만 직접 경험하여 보니
안드로이드는 참 쉽게 앱을 스토어에 개시하고 있었구나 라는 생각을 하였습니다
개발 막바지 무렵 애플의 애플 로그인 변경된 정책이 적용되어 버리면서
급하게 애플 로그인을 추가해야 했습니다.
이로 인해 개발 기간이 몇 주 늘어나게 되었습니다.
이걸로 끝일 것 같았지만
심사에서도 애플의 까다로운 면모를 볼 수 있었으며
로그인 버튼의 텍스트, 디자인까지 정하여 지침에 어긋나면 칼같이 Reject를 날려
2주 동안 2번이나 리젝을 당하게 되었습니다 😥
이때 문에 안드로이드만 먼저 배포하기도 하였습니다.
배포 후에 돌아보니 아쉬웠던 것들
Flutter로 첫 개발을 하면서 아쉬웠던 점들이 많이 있었습니다.
일정에 쫓긴다는 핑계로 더 좋은 방법이 있었었지만 넘어간 부분도 많았고
많은 기술적인 부채들을 남겨놓은 채 첫 번째 앱이 배포되었던 것 같습니다.
그중에서도 크게 아쉬웠던 점은..
🟧Flutter의 장점 중 하나인 애니메이션을 잘 살리지 못했던 점
Flutter는 초당 60 프레임의 훌륭한 애니메이션을 자랑하고
애니메이션을 구현하는 난이도도 낮아서, 여러 가지 애니메이션을 적용하여
사람들의 눈길을 끌 수 있는 Interactive 한 앱을 만들 수 있었을 텐데라는 아쉬움이 남았습니다. 60 프레임의 훌륭한 애니메이션
🟦중구난방 리소스
Android에서는 res 패키지에 리소스들을 모두 관리하지만
Flutter는 리소스를 수동으로 관리하였어야 했습니다.
처음에 문자열, 이미지 등등을 하드코딩으로 넣어놓을 부분들이 많았었고
이들을 후에는 정리하긴 하였지만 아직도 남아있는 부분들이 있습니다.
첫 번째 앱에서 처음부터 잘 정리하고 관리하는 방안을 찾았으면 어땠을지 아쉬움이 남았습니다.
🟥미완성 아키텍처
기존의 Android, IOS에서는 대세를 이루는 Architrcture들이 존재했습니다.
Android와 같은 경우에는 (MVP, MVVM, Clear Architecture) 존재하였고 , IOS와 같은 경우에는 (MVVM, Viper) 등등
주로 많이 사용하는 Architrcture들이 대부분 어느 정도 가닥이 정해져 있었지만,
하지만 Flutter는 Architrcture와 같은 레퍼런스가 적어, 대세를 이루는 Architrcture들을 따라서 구현할 수 없었습니다.
맨 처음 적용하였던 Clean Architecture + Bloc에서 급하게 다른 아키텍처를 찾아서
변경하다 보니 기존에 안드로이드에서 사용하였던 MVP + MVVM이 변형되어 약간 섞이게 되었는데
이것이 정답이 아닐 것 같다는 생각이 많이 들었습니다.
더 좋은 방안이 반드시 있었을 것이라는 아쉬움이 남았었습니다
2편에서 계속...
[앱 개발 후기] Flutter로 2개의 앱을 개발하다 2편 보러 가기
beomseok95.tistory.com/324
앱 구경하러 가기( Mybiskit & Bleet)