[Swift project] ReactorKit 예제 : Github Search (Reactor부분)

김영민·2022년 5월 8일
0

처음 reactorkit을 적용했던 Counter는 비교적 간단했는데, Github repository를 search 하는 이번 예제는 구현하는 데 꽤나 복잡했다.

복잡한 만큼 2개의 게시글로 나눌 생각이다.
이번 게시글은 Reactor부분.

코드를 하나하나 보며 이번 기회에 Reactorkit의 흐름을 좀 더 이해해보자.

Reactor 부분

  • reactor의 class 이름은 GitHubReactor로 설정했다.

Action

    enum Action {
        case updateQuery(String?)
        case loadNextPage
    }
  • ViewController의 Action을 받아올 부분이다.
  • updateQuery(String?) : ViewController에 있는 searchController의 String을 받을 Action
  • loadNextPage : 사용자가 스크롤을 내려 더 많은 정보를 원할 때, 다음 정보를 보여주기 위한 Action

Mutation


    enum Mutation {
        case setQuery(String?)
        case setRepos([String],nextPage: Int?)
        case appendRepos([String],nextPage: Int?)
        case setLoadingNextPage(Bool)
    }
    
  • setQuery(String?) : query를 세팅하는 Mutation
  • setRepos([String],nextPage: Int?) : 보여줄 repository 세팅하는 Mutation
  • appendRepos([String],nextPage: Int?) : loadNextPage로 늘어난 repositoy들을 담는 Mutation
  • setLoadingNextPage(Bool) : 다음페이지를 로딩 중인지 아닌 지 표시

State

   struct State {
        var query: String?
        var repos: [String] = []
        var nextPage: Int?
        var isLoadingNextPage: Bool = false
    }
    
    let initialState : State = State()
  • query: 후에 URL에 쓰일 query (searchController를 통해 사용자가 직접 입력) 변수
  • repos : tableView에 쓰일 배열 변수
  • nextPage : 다음 페이지 번호를 저장할 변수
  • isLoadingNextPage: 다음 페이지 로딩 중인지 아닌지를 저장할 변수
  • initialState : 처음 상태를 저장

mutate 메소드

func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case let .updateQuery( query) :
            return Observable.concat([
                Observable.just(Mutation.setQuery(query)),
                
                self.search(query: query, page : 1)
                    .take(until: self.action.filter(Action.isUpdateQueryAction))
                    .map{ Mutation.setRepos($0, nextPage: $1)}
            ])
        case .loadNextPage:
            guard !self.currentState.isLoadingNextPage else {return Observable.empty()}
            
            guard let page = self.currentState.nextPage else {return Observable.empty()}
            
            return Observable.concat([
                Observable.just(Mutation.setLoadingNextPage(true)),
                
                self.search(query: self.currentState.query, page: page)
                    .take(until: self.action.filter(Action.isUpdateQueryAction))
                    .map { Mutation.appendRepos($0, nextPage: $1) },
                
                Observable.just(Mutation.setLoadingNextPage(false))
            ])
        }
    }

updateQuery

  • updateQuery가 action될 때, Mutation의 setQuery와 뒤에 만들 메소드인 search 메소드를 concat하여 return 해준다.
    -> search 메소드는 받은 query와 페이지 1를 setRepos에 저장

loadNextPage

  • 처음에 isLoadingNextPage가 true인 상태이면 액션을 받지 않기 위한 코드가 있다.
  • page는 currentState의 nextPage를 받을 변수
  • setLoadingNextPage를 true로 하고, search 메소드를 통해 appendRepos 에 저장
    setLoadingNextPage를 false로 만들어주고 return한다. (concat하여 observable형태로 반환)

reduce 메소드

 func reduce(state: State, mutation: Mutation) -> State {
        switch mutation {
        case let .setQuery(query):
            var newState = state
            newState.query = query
            return newState
        case let .setRepos(repos, nextPage):
             var newState = state
             newState.repos = repos
             newState.nextPage = nextPage
             return newState

       case let .appendRepos(repos, nextPage):
             var newState = state
             newState.repos.append(contentsOf: repos)
             newState.nextPage = nextPage
             return newState

       case let .setLoadingNextPage(isLoadingNextPage):
             var newState = state
             newState.isLoadingNextPage = isLoadingNextPage
             return newState
           }
        }
    
  • action에서 받은 mutation들을 통해 reduce 메소드에서 State들을 업데이트 해준다.

url 메소드, search 메소드

리팩토링 전 코드

    private func url(for query: String?, page: Int) -> URL? {
        guard let query = query, !query.isEmpty else {return nil}
        return URL(string: "https://api.github.com/search/repositories?q=\(query)&page=\(page)")
    }
    
    
    private func search(query: String?, page: Int) -> Observable<(repos: [String], nextPage: Int?)> {
        let emptyResult: ([String], Int?) = ([], nil)
        guard let url = self.url(for: query, page: page) else {return .just(emptyResult)}
        
        return URLSession.shared.rx.json(url:url)
            .map{ json -> ([String],Int?) in
                guard let dict = json as? [String: Any] else { return emptyResult }
                guard let items = dict["items"] as? [[String: Any]] else { return emptyResult }
                let repos = items.compactMap { $0["full_name"] as? String }
                let nextPage = repos.isEmpty ? nil : page + 1
                return (repos, nextPage)
            }
            .do(onError: {error in
                if case let .some(.httpRequestFailed(response, _)) = error as? RxCocoaURLError, response.statusCode == 403 {
                    print("⚠️ GitHub API rate limit exceeded. Wait for 60 seconds and try again.")
                  }
            })
                .catchAndReturn(emptyResult)
    }
  • url 메소드 : query와 page를 받아 url로 만들어줄 메소드

  • search 메소드 : url을 받아, json 형태로 변형 후 repos 배열과 nextpage를 return
    Observable 형태로 최종 return

  • 에러 처리

리팩토링 후 코드 ! ( Alamofire 기능으로 구현)

    private func url(for query: String?, page: Int) -> URL? {
        guard let query = query, !query.isEmpty else {return nil}
        return URL(string: "https://api.github.com/search/repositories?q=\(query)&page=\(page)")
    }
    
    private var repos : [Repository.RepoName] = []
    private var nextPage : Int?
    
    private func search(query: String?, page: Int) -> Observable<(repos: [Repository.RepoName], nextPage: Int?)> {
        let emptyResult: ([Repository.RepoName], Int?) = ([], nil)
        guard let url = self.url(for: query, page: page) else {return .just(emptyResult)}
        
        AF.request(url,method: .get)
            .responseDecodable(of: Repository.self){[weak self] response in
                guard case .success(let data) = response.result else {return}
                self?.repos = data.items
                self?.nextPage = data.items.isEmpty ? nil : page + 1
                
                
        }
            .resume()
        
        return .just((repos,nextPage))
    }
  • 위의 코드는 익숙치 않아서 평소에 자주 쓰는 alamofire를 사용하여 재구현하였습니다.
  • Repository 구조체를 만들어 데이터를 받아왔습니다.

extension GithubReactor.Action

extension GithubReactor.Action {
  static func isUpdateQueryAction(_ action: GithubReactor.Action) -> Bool {
    if case .updateQuery = action {
      return true
    } else {
      return false
    }
  }
}
  • action이 updateQuery여야만 true 반환
  • 위의 mutate에서 search 메소드 사용 시, isUpdateQueryAction로 filter를 줘서
    updateQuery일 때만 Mapping

이렇게 Reactor부분에 대해서 간단하게 설명을 해보았습니다.
다음 게시글은 ViewController 부분 설명으로 돌아오겠습니다!

0개의 댓글