Dev

Flutter Redux 사용기

Joo 2019. 1. 7. 01:36

Flutter에서 router를 사용하면 Screen을 전환할 때 데이터를 넘겨줄 수가 없다. 그래서 어쩔 수 없이 redux같은 글로벌 저장소를 사용해야만한다. UI만 만들 때는 별생각없이 만들어도 됐지만 데이터가 들어가면서부터는 삽질에 삽질을 거듭하고 있다. 지금도 모두 해결한 것은 아니지만 일단 지금까지 겪었던 일들을 정리해본다.

Navigation

React에서는 무조건 router를 사용하는 것이 편했다. 아무래도 한곳에서 접근가능한 페이지를 모두 관리하고 있는 편이좋기 때문이었다. 그것은 너무 web 방식이라고 생각했는데 Flutter에서도 router를 비롯한 다양한 방법의 스크린 전환을 안내하고 있다. 자연스럽게 app에 route를 설정해서 사용하게 됐다.

initialRoute: '/',
routes: {
  '/': (context) => IndexScreen(),
  '/post/list': (context) => PostListScreen(),
  '/post/view': (context) => PostViewScreen(),
  '/post/edit': (context) => PostEditScreen(),
  '/auth/tistory': (context) => TistoryAuthScreen()
}

나도 이런 식으로 만들고싶지 않았다. 여느 router에서 쓰던 것처럼 /posts, /post/:postId 이렇게 쓰고 싶었다. 하지만 Flutter MaterialApp에서는 이런 걸 지원하지 않는다. 그리고 전환할 때 파라미터도 없다. Navigator.pushNamed(context, '/post/edit'); 이것이 전부다. 그래서 redux같은 글로벌 저장소를 사용할 수 밖에 없다.

Redux

놀랍게도 React를 통해 이젠 너무나 익숙해진 Redux가 Dart와 Flutter 버전으로도 있다. 물론 Dart와 ES라는 언어차이 때문에 사용법은 무지 다르다. 개념만 같다.

AppState appStateReducer(AppState state, action) => AppState(
  authReducer(state.auth, action),
  userReducer(state.user, action),
  blogListReducer(state.blogList, action),
  selectedBlogReducer(state.selectedBlog, action),
  postListReducer(state.postList, action),
  selectedPostReducer(state.selectedPost, action)
);

root reducer도 Class이고 하위 reducer를 멤버로 모두 정의해줘야한다. 오랜만에 Java말고 이런 하드타입 언어를 쓰니 해줘야할 게 너무 많다. action, constant, reducer 따로 등록하기 귀찮다고 ducks라는거 하던 기억이 떠오른다. 지금 생각하면 저건 귀찮을 거 하나없는 작업이었다.

class IndexScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StoreConnector<AppState, User>(
      converter: (store) => store.state.user,
      builder: (BuildContext context, User user) {
        return Scaffold(
          backgroundColor: Colors.white,
          body: user == null ? ReadyWidget() : IndexWidget()
        );
      },
      onInit: (store) {
        store.dispatch(CheckAuthAction());
      },
    );
  }
}

정의한 reducer들은 flutter_redux의 StoreProvider를 사용해 App에 등록되고 StoreConnector를 통해서 사용할 수 있다. 여기서 converter는 하나의 값만을 가져오도록 설계된 모양새라서 실제로도 그렇게 쓰게 되는데 값을 사용할 최종 Widget에 connector를 붙이는 것이 좋다.

List<Middleware<AppState>> createMiddleware() {
  return [
    TypedMiddleware<AppState, CheckAuthAction>(checkAuth),
    TypedMiddleware<AppState, RemoveAuthAction>(disconnect),
    TypedMiddleware<AppState, LoadPostListAction>(loadPostList),
    TypedMiddleware<AppState, LoadPostContentAction>(loadPostContent),
  ];
}

원격 리소스를 가져와야 하는 경우 모바일에서는 block이 되어버리기 때문에 async로 호출을 해야하는데 (예전 지식) 이때 redux의 middleware를 사용할 수 있다. 처음엔 redux_thunk 의 ThunkAction을 사용했지만 적절하지 않은 것 같았다. middleware를 사용하니 이게 딱 맞았다. middleware에 등록된 Action은 실제 reducer가 사용해도 되지만 원격 리소스를 가져와야할 때는 middleware용 Action을 사용하는 것이 자연스러웠다.

Navigation + Redux

앞서 말했듯 router에서 전혀 파라미터를 사용하지 못하니 redux를 통해서 data를 저장하게 된다. 파라미터용 data를 저장하면서 middleware에서 원격 리소스를 불러와야했다. 이때 middleware에 등록한 Action의 파라미터로 해결할 수 있긴 했는데 문제가 있었다. data는 그런 식으로 되지만 실제 스크린 전환과 싱크가 맞지 않는다. 대략 맞을 수도 있지만 정확한 시점을 보장하지 못하기 때문에 null 체크를 하는 더러운 코드가 마구 추가가 되었다.

onTap: () {
  var store = StoreProvider.of<AppState>(context);
  store.dispatch(SelectPostAction(post));
  Navigator.pushNamed(context, '/post/view');
}

...

return StoreConnector<AppState, SelectedPost>(
  converter: (store) => store.state.selectedPost,
  builder: (context, post) {
    if (post.content.length == 0) {
      return Text('로딩 중');
    }
    ...
  }
);

이 부분은 대충 해결을 한 부분이지만 못내 아쉽다. 좀 더 깔끔하게 해결할 수 있다면 참 좋을텐데... router 에서 파라미터만 넘겨줄 수 있었어도 이러지 않았을텐데... 가만 생각해보면 router 사용하지 않고 직접 Widget을 컨트롤 하는 것도 괜찮겠다는 생각이 든다. 화면이 그렇게 많은 것도 아니니까.


이제 남은 부분은 Webview의 이상동작, Markdown Editor, 사진 첨부 정도가 되겠다. 얼른 발행까지 끝내고 일단 한 숨 돌리고 싶다. 후우... 모바일로 누가 글을 쓴다고 내가 이러고 있나 싶다.

반응형