본문 링크 (Original Link)

예제로 시작하는 RxSwift #3 – 네트워킹.

2017.10.10

#

by Łukasz Mróz, translated by pilgwon

titleImage

수정 2017년 1월 18일: 이 게시글은 Swift 3과 RxSwift 3.1 그리고 Moya 8.0으로 업데이트 되었습니다

함수형 반응형 프로그래밍(FRP)에 대해 많이 알아봤으니, 오늘은 네트워킹과 데이터를 UI에 연결하는 방법에 대해 배워봅시다. 이해하기 쉽게 진행할 것이니 걱정마세요! 이전의 튜토리얼을 안보셨다면 1편2편을 보고 오시는 것을 추천드립니다!

Rx에는 RxAlamofireMoya를 포함해 매우 많은 네트워킹 익스텐션이 있습니다. 이번 튜토리얼에서는 제가 정말 좋아하는 Moya를 사용해서 진행해 보겠습니다.

Moya

Moya는 보통 우리가 직접 관리해야 하는 모든 네트워킹 관련 요소들 위에 있는 추상적인 레이어입니다. Moya를 사용하면 기본적으로 API와 연결을 바로 할 수 있게 되고, RxSwiftModelMapper로 이루어진 익스테션은 우리의 여정에 필요한 모든 것입니다.

설정

Moya를 설정하려면 스터빙(stubbing), 엔드포인트 클로저 등으로 이루어진 Provider가 필요합니다. 우리같이 간단한 경우엔 실제로 아무것도 필요하지 않습니다. 그래서 이 부분에서는 RxSwift로 Provider를 초기화하는 작업만 하겠습니다. 두 번째로 우리가 할 일은 Endpoint 설정입니다. 이 설정에서는 가능한 엔드포인트 타겟들을 enum할 것입니다. 매우 쉽습니다! 이제 TargetType에 맞는 enum을 만들면 끝입니다. 그런데 TargetType이 뭘까요? TargetType은 url, 메소드, 테스크(리퀘스트/업로드/다운로드), 파라미터 그리고 파라미터 인코딩 (URL 리퀘스트를 위한 매우 기본적인 요소들이죠)을 가지고 있는 프로토콜입니다. 하지만 한 가지 더 있습니다! 마지막 요소는 설정이 필요한 sampleData 입니다. Moya는 테스트에 매우 의존적이기 때문에 테스트 구문을 일등 시민으로 취급합니다. 하지만 Moya와 RxSwift에서의 테스팅은 다음 챕터에서 다룰 것입니다. 지금 당장 알아야 할 일은 모든 리퀘스트마다 서버의 샘플 응답을 지정해야 한다는 것입니다.

예제

좋습니다, 이제 예제 앱으로 가봅시다! 예제 전에 알아야 할 이론이 그렇게 많지 않기 때문에 정의 부분은 넘어갔습니다. 하지만 코딩 부분에서 좀 더 단계적으로 배워보겠습니다. 예제에서는 GitHub API를 사용해서 특정 레포지토리의 이슈를 받아보겠습니다. 약간 복잡하지만, 먼저 레포지토리 오브젝트를 얻고, 존재하는지 확인하고, 그 레포지토리의 이슈를 가져오는 리퀘스트를 날릴 것입니다. 그리고 JSON에서 오브젝트까지 다 엮을 것입니다. 게다가 에러, 중복 리퀘스트, API 스패밍 등을 관리할 것입니다.

걱정마세요, 시리즈 중 1편에서 이미 한 내용들이니까요! 여기서 이해해야 할 내용은 체이닝과 에러 핸들링 그리고 체인 오퍼레이션을 테이블뷰에 연결하는 법입니다. 어렵지 않죠, 그렇죠? 그렇죠?

신경쓰지마세요, 시작해 봅시다. 아래는 오늘 만들 Issue Tracker는 완성된 모습입니다:

apple/swift, apple/cups, moya/moya 등과 같이 레포지토리 이름 전체(레포지토리 소유자 이름과 슬래시 포함)를 입력해야 검색이 되게 만들 것입니다. 레포지토리를 찾으면 (첫 번째 URL 리퀘스트), 그 레포지토리의 이슈를 찾습니다 (두 번째 리퀘스트). 이게 우리의 주요 목표이고 코딩을 시작해 봅시다!

먼저, 프로젝트를 만들고 CocoaPods를 설치합니다. 이번 시간엔 몇 개의 팟을 추가해 보겠습니다. 우리는 RxSwift, Moya, RxCocoa, RxOptional, 오브젝트를 매핑하기 위한 Moya의 RxSwift와 ModelMapper를 합친 익스텐션인 Moya-ModelMapper를 사용할 것입니다. 꽤 많네요! 팟을 3개로 줄일 수 있습니다:

platform :ios, '8.0'
use_frameworks!

target 'RxMoyaExample' do

pod 'RxCocoa', '~> 3.0.0'
pod 'Moya-ModelMapper/RxSwift', '~> 4.1.0'
pod 'RxOptional'

end

post_install do |installer|
    installer.pods_project.targets.each do |target|
        target.build_configurations.each do |config|
              config.build_settings['ENABLE_TESTABILITY'] = 'YES'
              config.build_settings['SWIFT_VERSION'] = '3.0'
        end
    end
end

이것은 매우 도움이 되고 그것들에 의해 우리가 할 일이 아주 쉬워졌다는 것을 볼 수 있습니다.

1단계 - 컨트롤러와 Moya 설정하기.

UI부터 시작하겠습니다. UITableViewUISearchBar로 이루어져 있는 아주 간단 구조입니다. 위의 gif를 따라 만드셔도 되고 자신만의 디자인을 만드셔도 됩니다.

그리고 모든 것을 관리할 컨트롤러가 필요합니다. 구조를 만들기 전에 컨트롤러의 역할을 설명하겠습니다.

컨트롤러는 무슨 일을 할까요? 컨트롤러는 검색 바에서 데이터를 받고, 모델에 전달하고, 이슈를 받고, 테이블뷰에 보내주는 역할을 합니다. IssueListViewController 만들기를 시작해 봅시다. IssueListViewController.swift 파일을 만들고, 모듈을 추가하고 기본 설정으로 컨트롤러 준비를 합시다:

import Moya
import Moya_ModelMapper
import UIKit
import RxCocoa
import RxSwift

class IssueListViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var searchBar: UISearchBar!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupRx()
    }

    func setupRx() {
    }
}

코드에서 볼 수 있듯이, 바인딩을 설정해야 하기 때문에 setupRx() 메소드를 이미 준비해두었습니다. 그 전에, Moya의 Endpoint를 설정해야 합니다. 제가 앞에서 두 가지가 필요하다고 말씀드렸던 것을 기억하시나요? 그 두 가지 중 하나는 Provider 이고, 나머지는 Endpoint 입니다. Endpoint부터 시작해 봅시다.

새로운 파일을 하나 만들고 GithubEndpoint.swift라고 이름을 짓습니다. 그리고 몇 가지 가능한 타겟에 대한 enum을 만들겠습니다:

import Foundation
import Moya

enum GitHub {
    case userProfile(username: String)
    case repos(username: String)
    case repo(fullName: String)
    case issues(repositoryFullName: String)
}

“좋습니다, 하지만 TargetType을 따라야 한다고 말했잖아요? 그런데 이건 그냥 enum인걸요.” 네 맞습니다! 그래서 필요한 모든 속성을 포함하고 있는 GitHub enum의 익스텐션을 만들 것입니다. 제가 말씀드렸듯이, 우리는 7가지(6가지가 아니라 7가지인 이유는 URL이 baseURL + path 이기 때문입니다)가 필요합니다. baseURL, path, task과 같이, 또한 .get, .post 등의 리퀘스트 메소드인 method가 있습니다. 그리고 설명이 필요없는 parametersparametersEncoding이 있고, 튜토리얼의 초반에 말씀드렸던 sampleData가 있습니다.

이제 구현해 봅시다! 같은 파일안에서 TargetType에 따르는 GitHub의 익스텐션을 만듭니다:

import Foundation
import Moya

private extension String {
    var URLEscapedString: String {
        return self.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlHostAllowed)!
    }
}

enum GitHub {
    case userProfile(username: String)
    case repos(username: String)
    case repo(fullName: String)
    case issues(repositoryFullName: String)
}

extension GitHub: TargetType {
    var baseURL: URL { return URL(string: "https://api.github.com")! }
    var path: String {
        switch self {
        case .repos(let name):
            return "/users/\(name.URLEscapedString)/repos"
        case .userProfile(let name):
            return "/users/\(name.URLEscapedString)"
        case .repo(let name):
            return "/repos/\(name)"
        case .issues(let repositoryName):
            return "/repos/\(repositoryName)/issues"
        }
    }
    var method: Moya.Method {
        return .get
    }
    var parameters: [String: Any]? {
        return nil
    }
    var sampleData: Data {
        switch self {
        case .repos(_):
            return "}".data(using: .utf8)!
        case .userProfile(let name):
            return "{\"login\": \"\(name)\", \"id\": 100}".data(using: .utf8)!
        case .repo(_):
            return "{\"id\": \"1\", \"language\": \"Swift\", \"url\": \"https://api.github.com/repos/mjacko/Router\", \"name\": \"Router\"}".data(using: .utf8)!
        case .issues(_):
            return "{\"id\": 132942471, \"number\": 405, \"title\": \"Updates example with fix to String extension by changing to Optional\", \"body\": \"Fix it pls.\"}".data(using: .utf8)!
        }
    }
    var task: Task {
        return .request
    }
    var parameterEncoding: ParameterEncoding {
        return JSONEncoding.default
    }
}

모든 GithubEndpoint.swift가 완성됐습니다! 약간 무서워보일 수 있지만 실제로 읽어보면 그렇지 않다는 것을 아실겁니다! 어떤 파라미터도 원하지 않기 때문에, nil을 반환합니다. 그리고 메소드는 이 경우에는 항상 .get이고 baseURL도 같습니다. sampleDatapath만 각 경우에 맞춰서 입력해 줍니다.

만약 다른 타겟을 추가한다면 이 리퀘스트가 필요로 하는 메소드가 어떤 것인지와 파라미터를 필요로 하는지에 따라 switch의 case를 추가해주면 됩니다. 새롭게 추가한 것은 URL의 인코딩에 정말 도움되는 함수인 URLEscapedString 입니다. 그것말고 다른 것들은 명확할 것입니다. 컨트롤러로 돌아갑니다!

이제 구현할 것은 Moya의 Provider 입니다. 또한 셀을 클릭할 경우에 RxSwift로 키보드를 숨기는 것을 구현할 것입니다. 이 과정에선 DisposeBag 도 사용할 것입니다. 추가적으로 새로운 Observable을 만들어서 검색 바의 필터링(중복 삭제, 변경 기다리기 등 1편에서 했던 모든 작업들)된 텍스트를 가져오는데에 사용할 것입니다.

요약하자면 우리는 setupRx() 메소드에 3가지 속성을 구현할 것입니다. 시작해 볼까요!

class IssueListViewController: UIViewController {
    ...
    let disposeBag = DisposeBag()
    var provider: RxMoyaProvider<GitHub>!    
    var latestRepositoryName: Observable<String> {
        return searchBar
            .rx.text
            .orEmpty
            .debounce(0.5, scheduler: MainScheduler.instance)
            .distinctUntilChanged()
    }
    ...
    func setupRx() {
        // 첫 번째, Provider를 만듭니다
        provider = RxMoyaProvider<GitHub>()

        // 유저가 셀을 클릭했을 때 테이블뷰에게 알려줍니다
        // 그리고 키보드가 보여지고 있다면 숨깁니다
        tableView
            .rx.itemSelected
            .subscribe(onNext: { indexPath in
                if self.searchBar.isFirstResponder() == true {
                    self.view.endEditing(true)
                }
            })
            .addDisposableTo(disposeBag)
    }
    ...
}

호우! 첫 번째 마법이 일어났군요. latestRepositoryName 변수의 코드는 시리즈의 1편에서 한 것이기 때문에 여러분에게 친근할 것이라고 믿습니다. 이제 더 흥미로운 것으로 넘어가 봅시다. 먼저 Provider 설정에 대한 궁금증을 풀러 가봅시다. 볼 수 있듯이, 특별한 것이 아닙니다. 그냥 초기화입니다. 그리고 Moya와 RxSwift를 사용하고 있기 때문에, RxMoyaProvider를 사용해야 합니다.

만약 Moya와 ReactiveCocoa를 같이 사용해서 API를 작성하고 싶거나 Moya만으로 작성해보고 싶으시다면, 각 경우에 맞는 Provider가 있습니다. (MoyaProvider는 순수하게 Moya만 사용하는 방법을 위해, ReactiveCocoaMoyaProvider는 ReactiveCocoa + Moya의 경우를 위해)

이제 키보드를 숨기는 설정을 하겠습니다. RxCocoa 덕분에, 누군가가 테이블뷰의 셀을 탭하면 신호를 보내는 tableView.rx.itemSelected 에 접근할 수 있습니다. 당연히 그것을 구독할 수 있고, 우리가 할 일(키보드를 숨기는 일)을 할 수 있습니다. 여기서는 검색 바가 지금 first responder인지 확인하고 (키보드가 보여지고 있으면), 만약 그렇다면 숨깁니다.

여기까지 뷰 컨트롤러와 Moya 설정에 있어서 기본이었습니다. 2단계로 넘어갑시다!

2단계 - 네트워크 모델과 매핑 오브젝트

텍스트에 기반해서 데이터를 넘겨주는 모델이 필요합니다. 하지만 먼저 오브젝트를 어디든 보내기 전에 파싱을 해야 합니다. 이 작업은 ModelMapper에 있는 우리의 친구 덕분에 가능합니다. 우리는 두 가지를 필요로 합니다. 하나는 레포지토리를 위함이고 또 하나는 이슈를 위함입니다. 그것들은 아주 만들기 쉽습니다. Mappable 프로토콜을 따르고 오브젝트 파싱을 시도하면 됩니다. 만들어 봅시다!

import Mapper

struct Repository: Mappable {

    let identifier: Int
    let language: String
    let name: String
    let fullName: String

    init(map: Mapper) throws {
        try identifier = map.from("id")
        try language = map.from("language")
        try name = map.from("name")
        try fullName = map.from("full_name")
    }
}
import Mapper

struct Issue: Mappable {

    let identifier: Int
    let number: Int
    let title: String
    let body: String

    init(map: Mapper) throws {
        try identifier = map.from("id")
        try number = map.from("number")
        try title = map.from("title")
        try body = map.from("body")
    }
}

많은 속성이 필요하지 않지만, GitHub API 문서에 기반해서 더 추가할 수 있습니다.

좋습니다. 이제 이 튜토리얼에서 가장 흥미로운 부분이고 네트워킹의 핵심 부분인 IssueTrackerModel로 가봅시다. 먼저, 모델은 init에서 전달할 Provider 속성을 가져야 합니다. 그리고 Observable 타입을 옵저버블 텍스트를 위한 속성이 필요합니다. 그리고 그것은 뷰 컨트롤러가 전달할 repositoryNames 의 원천이 될 것입니다. 그리고 메소드로부터 뷰 컨트롤러가 테이블뷰를 바인딩하는 데에 사용할 Observable<[Issue]> 와 관찰 가능한 이슈 배열의 시퀀스를 반환하는 메소드가 필요합니다. 그리고 init 을 구현할 필요는 없습니다. 왜냐하면 Swift의 memberwise initializer 덕분입니다.

IssueTrackerModel.swift를 만들어 봅시다:

import Foundation
import Moya
import Mapper
import Moya_ModelMapper
import RxOptional
import RxSwift

struct IssueTrackerModel {

    let provider: RxMoyaProvider<GitHub>
    let repositoryName: Observable<String>

    func trackIssues() -> Observable<[Issue]> {

    }

    internal func findIssues(repository: Repository) -> Observable<[Issue]?> {

    }

    internal func findRepository(name: String) -> Observable<Repository?> {

    }
}

코드에서 볼 수 있듯이 두 개의 함수를 추가했습니다. 하나는 findRepository(_:) 입니다. 이 함수는 옵셔널 레포지토리(리스폰스에서 오브젝트 매핑이 불가능하면 nil, 가능하다면 Repository)를 반환합니다. 다른 하나는 findIssues(_:) 입니다. 이건 당연히 주어진 레포지토리 오브젝트에 기반해서 이슈를 검색하는 함수입니다. 먼저 이 두 메소드를 구현해 봅시다. 정말 까다롭게 생각할 수 있지만, 사실은 매우 놀랍도록 간단합니다:

internal func findIssues(repository: Repository) -> Observable<[Issue]?> {
    return self.provider
        .request(GitHub.issues(repositoryFullName: repository.fullName))
        .debug()
        .mapArrayOptional(Issue.self)
}

internal func findRepository(name: String) -> Observable<Repository?> {
    return self.provider
        .request(GitHub.repo(fullName: name))
        .debug()
        .mapObjectOptional(Repository.self)
}

단계별로 보면:

  1. 주어진 enum 케이스로 리퀘스트를 수행할 수 있는 Provider가 있습니다.

  2. 그리고 GitHub.repo 또는 GitHub.issues 를 보냅니다. 그리고 짜잔, 리퀘스트가 완료됐습니다!

  3. 리퀘스트에서 나온 가치있는 정보를 출력하는 debug() 연산자를 사용합니다. 이것은 개발/테스팅할 때 아주 유용합니다.

  4. 그러고 리스폰스를 파싱과 매핑을 수동으로 할 수 있지만, 익스텐션 덕분에 mapObject(), mapArray(), mapObjectOptional() 또는 mapArrayOptional() 과 같은 메소드에 접근할 수 있습니다.

어떤 차이일까요? 옵셔널 메소드는 오브젝트가 파싱이 불가능하면 nil을 반환합니다. 보통의 메소드의 경우는 에러를 던지고 우리가 catch() 함수나 retry()를 사용해야 합니다. 하지만 우리의 옵셔널은 완벽합니다. 그래서 리퀘스트가 실패했을 경우 테이블뷰가 깔끔해지게 만들 수 있습니다.

좋습니다. 이제 우리에겐 두 개의 메소드가 있습니다. 그런데 이 둘을 어떻게 연결할까요? 여기서는 새로운 연산자인 flatMap(), 특히 flatMapLatest()에 대해서 배워야 합니다. 이 연산자들이 하는 일은 하나의 시퀀스에서 다른 시퀀스를 만드는 것입니다. 이것이 왜 필요할까요? 레포지토리의 시퀀스로 바꾸고 싶은 문자열 시퀀스가 하나 있다고 가정해 봅시다. 아니면 레포지토리의 시퀀스를 이슈 시퀀스로 바꾸거나요. 정확히 우리의 경우군요! 이제 이 작업들을 체인 오퍼레이션으로 바꿀 것입니다. 그리고 nil을 반환받았을 때(Repository 오브젝트에서 Repository나 Issues를 가져올 때)는 테이블뷰를 비우기 위해 빈 배열을 반환할 것입니다. 그런데 flatMap()flatMapLatest() 는 무슨 차이가 있을까요? 음, flatMap() 은 하나의 값을 받고 긴 업무를 처리합니다. 그리고 다음 값이 왔을 때, 이전의 업무는 새로운 값이 현재 업무 중간에 도착해도 이전 작업이 계속 완료됩니다. 이것은 우리가 지금 원하는 것이 아닙니다. 왜냐하면 검색 바에서 새로운 텍스트를 얻었을 때, 이전의 업무는 다 취소하고 새롭게 시작하고 싶기 때문입니다. 그게 바로 flatMapLatest() 가 하는 일입니다.

trackIssues 메소드는 아래와 같습니다:

func trackIssues() -> Observable<[Issue]> {
    return repositoryName
        .observeOn(MainScheduler.instance)
        .flatMapLatest { name -> Observable<Repository?> in
            print("Name: \(name)")
            return self
                .findRepository(name)
        }
        .flatMapLatest { repository -> Observable<[Issue]?> in
            guard let repository = repository else { return Observable.just(nil) }

            print("Repository: \(repository.fullName)")
            return self.findIssues(repository)
        }
        .replaceNilWith([])
}

단계별로 보면:

  1. 우리는 MainScheduler를 관찰하고 있는 사실을 확인하고 싶습니다. 왜냐하면 이 모델의 목적이 그것을 UI인 테이블 뷰에 바인딩하는 것이기 때문입니다.

  2. 텍스트(레포지토리 이름)을 관찰 가능한 레포지토리 시퀀스로 바꾸고 만약 오브젝트가 제대로 매핑되지 않았다면 nil로 변환합니다.

  3. 매핑한 레포지토리가 nil인지 아닌지를 확인합니다. 만약 그 값이 nil 이라면, 관찰 가능한 nil 시퀀스를 반환합니다. (레포지토리가 nil일 경우에, 다음 flatMapLatest() 는 빈 배열을 리스폰스로 보낼 것을 보증합니다.) Observable.just(nil) 은 하나의 아이템을 옵저버블로 보낸다는 의미입니다. (우리의 경우엔 그 아이템이 nil 입니다.) 만약 nil이 아니라면 (만약 레포지토리가 이슈를 가지고 있다면), 이슈의 배열로 변환할 것입니다. 여기선 nil을 반환할 수 도, 이슈의 배열을 반환할 수 도 있기 때문에, 옵셔널 배열의 옵저버블을 가지고 있어야 합니다.

  4. replaceNilWith([]) 은 테이블뷰를 비우기 위해 nil을 빈 배열로 변환할 때 도움을 주는 RxOptional 의 익스텐션입니다.

여기까지 모델이었습니다! 다시 생각해보면 매우 쉽다고 생각이 드실 것입니다. 코드를 몇 번 읽고, 연산자를 움직여보고, 변경해보세요. 직접 해보세요!

3단계 - 테이블뷰에 이슈 바인딩하기

마지막 단계는 모델에서 받은 데이터를 받아서 테이블뷰에 연결하는 일입니다. 이 말은 옵저버블을 테이블뷰에 바인딩해야 한다는 뜻입니다.

보통은 뷰 컨트롤러에서 number of rows, cell for row 등과 같은 메소드가 구현되어있는 UITableViewDataSource 를 따라야하기 때문에, 뷰 컨트롤러에 dataSour를 할당합니다.

RxSwift와 함께라면 단 하나의 클로저로 UITableViewDataSource 설정을 할 수 있습니다! 맞습니다, RxCocoa는 클로저에서 보여주고자 하는 셀을 가져가는 rx.itemsWithCellFactory 라는 훌륭한 유틸리티를 제공합니다. 이는 옵저버블과 우리가 제공한 클로저에 기반해서 한꺼번에 이 작업을 실행합니다. 마법이죠! 그리고 코드도 보기 좋아 보입니다!

이제 IssueListViewController 로 돌아가서, setupRx() 메소드를 완성해봅시다:

class IssueListViewController: UIViewController {
    ...
    var issueTrackerModel: IssueTrackerModel!
    ...    
    func setupRx() {
        // First part of the puzzle, create our Provider
        provider = RxMoyaProvider<GitHub>()

        // Now we will setup our model
        issueTrackerModel = IssueTrackerModel(provider: provider, repositoryName: latestRepositoryName)

        // And bind issues to table view
        // Here is where the magic happens, with only one binding
        // we have filled up about 3 table view data source methods
        issueTrackerModel
            .trackIssues()
            .bindTo(tableView.rx.items) { tableView, row, item in
                let cell = tableView.dequeueReusableCell(withIdentifier: "issueCell", for: IndexPath(row: row, section: 0))
                cell.textLabel?.text = item.title

                return cell
            }
            .addDisposableTo(disposeBag)

        // Here we tell table view that if user clicks on a cell,
        // and the keyboard is still visible, hide it
        tableView
            .rx.itemSelected
            .subscribe(onNext: { indexPath in
                if self.searchBar.isFirstResponder == true {
                    self.view.endEditing(true)
                }
            })
            .addDisposableTo(disposeBag)
    }
    ...
}

여기서 새로운 점은, IssueTrackerModel (setupRx()에서 초기화한 적이 있습니다)의 새로운 속성입니다. 그리고 모델의 trackIssues() 메소드를 rx.itemsWithCellFactory 속성에 묶는 새로운 바인딩입니다. 또한 dequeueReusableCell()cellIndentifier 를 바꾸는 것을 잊지 마세요.

끝입니다! 처음에 말했던 모든 것이 완성되었습니다! 프로젝트를 실행하고 결과에 행복해하세요!

지금까지 정말 긴 여정이었고 여기까지 온 것이 정말 자랑스럽습니다. 지금까지 설명이 다 깔끔헀을거라 희망하지만, 질문이나 피드백이 있다면 Twitter메세지나 댓글을 달아주세요. 저는 여러분의 메세지를 정말 사랑하고 다음 에피소드에 관한 것을 물어봐주길 바랍니다 ✌️

전체 소스 코드는 Droids on Roids의 깃헙에서 볼 수 있고 여기서 다른 RxSwift 예제들을 확인할 수 있습니다!

RxSwift에 관련된 다른 게시글을 읽어보세요 (영어 원문)

» 예제로 시작하는 RxSwift #2 – 옵저버블(Observable)과 바인드(Bind)

» 예제로 시작하는 RxSwift #4 – 멀티스레딩.

RxSwift에 관련된 다른 게시글을 읽어보세요 (한글 번역) (일부 번역 예정입니다.)

» 예제로 시작하는 RxSwift #2 – 옵저버블(Observable)과 바인드(Bind)

» 예제로 시작하는 RxSwift #4 – 멀티스레딩.