[Flutter] Riverpod (ViewModel)

saewoohan·2024년 4월 24일
0

Flutter

목록 보기
10/12
post-thumbnail

1. 서론

  • 현재 진행하는 프로젝트에서 riverpod을 꽤 깊게 사용하고 있다. 특히, data로직 뿐 아니라 MVVM구조를 사용하며, viewModel로서 riverpod을 사용하고 있다.

  • riverpod의 기능과 전반적인 설명은 공식문서가 더욱 잘되어있기에 관련해서는 포스팅하지 않으려고 한다.

  • riverpod을 어떻게 사용하고 있는지 전반적인 평가와, 또 앞으로 어떻게 사용해 나가면 좋을 지 정리하는 시간을 가지면 좋을 것 같아 글을 한번 정리해보려고 한다.

2. ViewModel

  • viewModel로서의 riverpod의 단점은 소스가 많이 없다.. 사용자가 적지는 않지만 확실히 참고자료는 GetX가 더 많은 느낌이다. (실제로 riverpod 공식 홈페이지에서도 네트워크 요청에 대한 레퍼런스가 더 많다)

  • 하지만, 장점은 꽤 잘되어있는 공식 문서 및 직관적인 문법이다. 나는 riverpod annotation을 사용해서 viewModel을 구성하는데, StateController만 구성하면 간단하게 viewModel Provider를 제공해준다.

  • 또한, 장점인지 단점인지는 단언할 수 없지만, 문서와 참고자료가 적은 만큼 사용자에 따라 성능이 좌우되는 폭이 GetX보다 클 것 같다는 것이다. 근간이 Provider인 만큼 자유도가 꽤 높다.

a. 예시

class HomeTicketManagementScreenState {
  CalendarFormat calendarFormat;
  DateTime? selectedDay;
  DateTime focusedDay;
  CombineTicket? selectedTicket;
  HomeTicketManagementScreenState({
    required this.calendarFormat,
    required this.selectedDay,
    required this.focusedDay,
    required this.selectedTicket,
  });
}


class HomeTicketManagementScreenController
    extends _$HomeTicketManagementScreenController {
  
  HomeTicketManagementScreenState build() {
    return HomeTicketManagementScreenState(
      calendarFormat: CalendarFormat.month,
      selectedDay: DateTime.now(),
      focusedDay: DateTime.now(),
      selectedTicket: null,
    );
  }


  void setState() {
    state = HomeTicketManagementScreenState(
      calendarFormat: state.calendarFormat,
      selectedDay: state.selectedDay,
      focusedDay: state.focusedDay,
      selectedTicket: state.selectedTicket,
    );
  }

  void handleChangecalendarFormat(CalendarFormat calendarFormat) {
    state.calendarFormat = calendarFormat;
    setState();
  }

  void handleDaySelect(DateTime date, DateTime focusedDay) {
    state.selectedDay = date;
    state.focusedDay = focusedDay;

    setState();
  }


 ...
}
  • 위 코드는 실제로 프로젝트에서 사용하는 코드의 일부이다. handler와 비지니스 로직, 특히 state변수를 viewModel에서 관리하면서 view에서는 해당 로직을 신경써도 안되는 장점이 있다. (MVVM 패턴의 의의)

b. state 할당

  • controller와 state선언에 대한 부분은 직관적이여서 상당히 마음에 들었다. 하지만 한가지 문제점이 있었는데, state를 어떻게 재할당 해줄지였다.

  • state class자체를 state에 다시 할당해줘야 변화가 되었음을 알 수 있기에, copyWith를 사용해서 각 상태 변수에 대해서 state를 재할당하는 방식을 사용할까 했다.

  • 생각보다 viewModel의 상태변수가 많아지면 copyWith를 사용하는 것이 의미없이 코드가 길어져서 가독성이 조금 떨어졌다. 그래서 setState라는 함수를 하나 정의해서 state를 재할당해주는 로직을 적용시켜 보았다.
    (flutter의 setState를 안쓰고 자체로 setState를 정의하다니 웃기긴 한 부분이긴 하다.)

  • 동작은 굉장히 잘되었고, 재사용이 좋은 함수이기에 리렌더링 타임을 자유롭게 정할 수 있었다. 최적화 관점에서는 꽝인 로직인 것 같지만, 아직 렌더링 이슈는 없어서 추후 더 좋은 구조를 생각해봐야할 것 같다.

  • 여담이지만 zustand처럼 set이 있어서 자동으로 상태를 업데이트 하는 로직을 선언할 수 있으면 조금 더 깔끔할 것 같은데, 이것은 조금 아쉬운 부분이긴 하다.

c. autoDispose

  • 해당 annotation을 사용하면 자동으로 autoDispose성질을 띈 provider로 생성을 해준다.
  • 이 패턴의 장점은 구독하고 있는 것이 없다면, 자동으로 메모리 할당을 해제한다는 것이다. riverpod을 사용하는 것의 목적이 caching이 아니라 viewModel에 국한되어 있기에 이는 굉장히 편리한 기능이다.
  • 어떤 스크린이 unMount 된다면 해당 스크린과 연관되어있던 viewModel은 해제되기에, 인스턴스 정리가 굉장히 용이하다.
  • 단점은, 하위 스크린이 아직 마운트 되지 않았다면, 상위 스크린이 하위 스크린의 viewModel에 접근을 할 수 없다는 사실이다. 그 사실도 모르고 처음에는 하위 스크린의 viewModel에 state를 계속 할당해주는 방식으로 라우팅을 구현하다가 매몰차게 실패했던 기억이 있다.

2. View


class HomeTicketManagementScreen extends ConsumerStatefulWidget {
  const HomeTicketManagementScreen({super.key});

  
  ConsumerState<HomeTicketManagementScreen> createState() {
    return _HomeTicketManagementScreenState();
  }
}

class _HomeTicketManagementScreenState
    extends ConsumerState<HomeTicketManagementScreen> {
  
  Widget build(BuildContext context) {
        ref.watch(homeTicketManagementScreenControllerProvider).selectedDay;
    DateTime focusedDay =
        ref.watch(homeTicketManagementScreenControllerProvider).focusedDay;
    CalendarFormat calendarFormat =
        ref.watch(homeTicketManagementScreenControllerProvider).calendarFormat;

    Widget emptyContents(String title, String subtitle) {
      return SliverFillRemaining(
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Png(PngsEnum.SCHOOL, width: 127.w, height: 109.h),
              SizedBox(height: 15.h),
              Text(
                title,
                style: DTypography.H4.copyWith(color: DColor.text_6),
              ),
              Text(
                subtitle,
                style: DTypography.Body7.copyWith(
                  color: DColor.text_6,
                ),
              ),
            ],
          ),
        ),
      );
    }

    return Scaffold(
      backgroundColor: DColor.background2,
      body: NestedScrollView(
        headerSliverBuilder: (context, innerBoxIsScrolled) => [
        ...
 		SliverToBoxAdapter(
            child: ScheduleCalendar(
              selectedDay: selectedDay,
              focusedDay: focusedDay,
              calendarFormat: calendarFormat,
              onFormatChanged: ref
                  .read(homeTicketManagementScreenControllerProvider.notifier)
                  .handleChangecalendarFormat,
              onDaySelected: ref
                  .read(homeTicketManagementScreenControllerProvider.notifier)
                  .handleDaySelect,
              selectedDayPredicate: ref
                  .read(homeTicketManagementScreenControllerProvider.notifier)
                  .handlePredictSelectedDate,
            ),
          ),
          ...

a. state 관리

  • 위의 코드처럼 각 상태변수에 대한 구독이 가능하다. 사실 controller 자체를 watch하는 것과 같은 의미이다.
    var provider = ref.watch(homeTicket...Provider)

  • 어차피 viewModel이 update되려면 새로운state를 할당해 줘야한다. 이 과정에서 해당 controller를 구독하고 있는 모든 watcher들은 다시 호출이 되며, 각 변수들을 따로 watch하는 것과 연관성은 없다.

  • 나는 내가 구독하고 있는 상태 변수를 명시한다는 것에 의의를 두고 다음과 같이 사용하고 있다.

  • 공식 문서에는 state단위로 watch할 수 있는 기능을 제공하기는 한다. 아직까지 사용할 만큼 렌더링 최적화가 필요한 부분은 만나지 못했기에, 사용해보지는 않았다.
    https://riverpod.dev/ko/docs/advanced/select

b. handler 관리

  • handler를 viewModel에서 관리를 하기에, view에서는 단순히 notifier안의 handler만 호출하면 되는 장점이 있다.
  • 이것이 좋은 점이 react나 viewModel을 사용하지 않는 flutter 패턴은 보통 Props를 통해서 nested하게 handler를 넘겨준다.

class HoldUsageTicket extends StatelessWidget {
  const HoldUsageTicket({super.key});

  
  Widget build(BuildContext context) {
    return Consumer(
      builder: (context, ref, child) {
        List<DogTicket> usageCountTickets =
            ref.watch(homeDogSummaryScreenControllerProvider).usageCountTickets;
        return Container(
          width: double.infinity,
          color: DColor.background2,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Container(
                padding: EdgeInsets.only(
                  left: 20.w,
                  right: 20.w,
                  bottom: 24.h,
                ),
                child: Text(AppStrings.holdTicket, style: DTypography.H4),
              ),
              if (usageCountTickets.isNotEmpty)
                ListView.builder(
                  itemCount: usageCountTickets.length,
                  shrinkWrap: true,
                  itemBuilder: (context, index) {
                    return usageCountTicket(
                      title: usageCountTickets[index].title,
                      date: usageCountTickets[index].date,
                      onTap: () => ref
                          .read(homeDogSummaryScreenControllerProvider.notifier)
                          .handleUsageCountTicketCardTap(
                              usageCountTickets[index]),
                    );
                  },
                ),
                ...
  • 이와 같이 사용하는 것이 좋을지는 모르겠지만 Consumer Widget을 제공하기에 하위 위젯에서도 ref를 가질 수 있다. 즉, 하위 위젯들에서 바로 viewModel에 접근이 가능하다는 점이다. (state경우에도 마찬가지)

  • 자주 사용하면 해가될 수도 있는 패턴인 점은, view에 종속되지 않는 컴포넌트에 대해서 사용하는 것은 위험하다.

  • 여러 view에서 사용되는 컴포넌트인데, viewModel에 대한 직접적인 접근을 가진다면, 사실 재사용이 불가능한 컴포넌트가 될 것이다. (viewModel을 가진다는 것은 view에 종속적인 거나 다름없는 얘기이다.)

  • 그래서 우선 나는 view Screen을 세분화 할때, 하위 위젯들에서 사용되는 것이 좋아 보여 해당 기준으로 사용하고 있다. (재사용성이 다분한 컴포넌트 단위에서는 Props로 전달)

3. 총평

  • 전체적으로 처음에 구조를 다듬을 때, 조금 힘들었던 기억이 있다. (viewModel 로서 사용된 레퍼런스가 조금 적기도 했다.)
  • 그래도 riverpod을 사용한 이유는 상태관리 라이브러리로 provider를 가장 처음 접해봤고, 같은 publisher이기에 비슷한 부분이 많아 도입하려고 노력했다.
  • 가장 마음에 들었던 것은 autoDispose이다. 자동 인스턴스 정리를 통해서 controller dispose나 state정리를 해줄 필요가 없다. 지금은 riverpod을 사용할 때,StateNotifierProvider을 주로 거의 사용하고, 다른 종류의 Provider를 사용하고 있지는 않는데, 조금 더 다양하게 통합해볼까 생각을 하고 있다.
  • 여담으로 riverpod 측에서 라이브러리 관련되어서 많은 편리한 서드파티 라이브러리를 제공하고 있다. flutter hooks를 조금 격하게 추천하던데 한번 도입해볼까 고민중이다. (심지어 riverpod이랑 통합하기 쉽도록 자체 서드파티 라이브러리가 있다.)
    react가 익숙한 사람은 처음 flutter배울 때, 해당 라이브러리를 알았으면 정말 빠르게 배웠을 것 같다. react-native와 개발난이도가 비슷하지 않을까 싶다..!
    사실 flutter life cycle함수를 제거하고 react처럼 hooks기반으로 마이그레이션을 해야하는 것인데, 100%신뢰할 수 있는 라이브러리가 아니라고 생각이 들어서 처음에 도입을 하지 않긴 하였다.

1개의 댓글

comment-user-thumbnail
2024년 6월 19일

잘 읽었습니다. copyWith가 최선인가 싶었는데, setState방식이 넘 인상적이네요! 저도 참고해볼까 합니다. 감사합니다~!

답글 달기