iOS

곰튀김님의 RxSwift + MVVM (1)

hoonsbrand 2022. 10. 23.

https://www.youtube.com/watch?v=iHKBNYMWd5I&list=PL03rJBlpwTaBrhux_C8RmtWDI_kZSLvdQ 

해당 글은 곰튀김님의 RxSwift 강의 영상을 시청 후 작성한 글입니다.

 

API를 다루는 프로젝트를 하던 도중 RxSwift라는 것을 사용하면 더 편하기 비동기 처리를 할 수 있다고 하여서 같이 공부하는 형에게 곰튀김님의 유튜브를 추천받아 강의영상을 시청했다!

강의를 듣기전에 RxSwift라는 단어를 많이 들어봤는데 그럴때마다 너무 어렵게 느껴져서 내가 과연 배울 수 있을까...라고 생각이 들었다.

하지만 API 프로젝트를 할 때 항상 더 편한 방법을 고안해왔고 RxSwift가 조금이라도 도움이 더 된다면 찍먹(?) 이라도 해보면 좋겠다 생각이 들었다👍

 

RxSwift 적용 전 우선 코드를 먼저 살펴보자.

 

import RxSwift
import SwiftyJSON
import UIKit

let MEMBER_LIST_URL = "https://my.api.mockaroo.com/members_with_avatar.json?key=44ce18f0"

class ViewController: UIViewController {
    @IBOutlet var timerLabel: UILabel!
    @IBOutlet var editView: UITextView!

    override func viewDidLoad() {
        super.viewDidLoad()
        Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
            self?.timerLabel.text = "\(Date().timeIntervalSince1970)"
        }
    }

    private func setVisibleWithAnimation(_ v: UIView?, _ s: Bool) {
        guard let v = v else { return }
        UIView.animate(withDuration: 0.3, animations: { [weak v] in
            v?.isHidden = !s
        }, completion: { [weak self] _ in
            self?.view.layoutIfNeeded()
        })
    }

func downloadJson(_ url: String) -> String? {
        let url = URL(string: url)!
        let data = try! Data(contentsOf: url)
        let json = String(data: data, encoding: .utf8)
        return json
    }

    // MARK: SYNC

    @IBOutlet var activityIndicator: UIActivityIndicatorView!

    @IBAction func onLoad() {
        editView.text = ""
        setVisibleWithAnimation(activityIndicator, true)

        let json = downloadJson(MEMBER_LIST_URL)

        self.editView.text = json
        
        self.setVisibleWithAnimation(self.activityIndicator, false)
    }
}

 

현재는 indicator도 뜨지않고 증가하는 숫자도 멈춘 상태로 데이터를 받아온다.

멈추는 동안 서버에서 json을 받고 있기 때문에 멈춘다. 

멈추는 이유는 현재 코드에서 동기화로 코딩이 되어있어서 다운로드 받는 작업이 동기화로 진행되기 때문이다.

이것을 비동기로 진행하면 타이머는 타이머대로 다운로드는 다운로드대로 그대로 진행할 있다.

 

 

@IBAction func onLoad() {
        editView.text = ""
        DispatchQueue.global().async {
            self.setVisibleWithAnimation(self.activityIndicator, true)

            let json = self.downloadJson(MEMBER_LIST_URL)
            self.editView.text = json
            
            self.setVisibleWithAnimation(self.activityIndicator, false)
        }
    }

global로 비동기 처리를 한 코드이다.

하지만 이대로 실행하면 앱이 해당 에러를 뿜으며 죽어버린다.

'Modifications to the layout engine must not be performed from a background thread after it has been accessed from the main thread.'

에러를 해석해보면 UI변경을 하는데 메인 쓰레드에서 해야한다고 한다.

 

 

 

 

@IBAction func onLoad() {
        editView.text = ""
        self.setVisibleWithAnimation(self.activityIndicator, true)
        
        DispatchQueue.global().async {
            let json = self.downloadJson(MEMBER_LIST_URL)
            
            DispatchQueue.main.async {
                self.editView.text = json
                self.setVisibleWithAnimation(self.activityIndicator, false)
            }
        }
    }

다시 코드를 바꿔서 버튼을 누를 처음 나오는 indicator 밖으로 빼주고, 이후 UI관련 코드인 editView.text inidicator 사라지는 부분을 main 쓰레드로 바꿔준다.

 

 

 

 

 

그럼 우리가 원하는대로 각자 하는 일을 하면서 동시에 진행이 되는 모습을 있다.

 

 

 

 

이제 구현부에 dispatchqueue 부분들을 직접 넣는 것이 아닌 downloadJson 메서드에서 비동기적으로 처리하고싶으면 어떻게 할까?

func downloadJson(_ url: String) -> String? {
        DispatchQueue.global().async {
            let url = URL(string: url)!
            let data = try! Data(contentsOf: url)
            let json = String(data: data, encoding: .utf8)
            return json
        }
    }

문제는 이 코드에서 return을 못해준다는 것이다.

 

 

보통 이럴땐 completion 사용한다.

func downloadJson(_ url: String, _ completion: @escaping (String?) -> Void) {
        DispatchQueue.global().async {
            let url = URL(string: url)!
            let data = try! Data(contentsOf: url)
            let json = String(data: data, encoding: .utf8)
            
            DispatchQueue.main.async {
                completion(json)
            }
        }
    }
    
    
    // MARK: SYNC
    
    @IBOutlet var activityIndicator: UIActivityIndicatorView!
    
    @IBAction func onLoad() {
        editView.text = ""
        self.setVisibleWithAnimation(self.activityIndicator, true)
        
        
        downloadJson(MEMBER_LIST_URL) { json in
            self.editView.text = json
            self.setVisibleWithAnimation(self.activityIndicator, false)
        }
    }

잘보면 completion 앞에 @escaping이라는 키워드가 있다.

무엇을 의미하는 키워드일까?

직역하자면 “탈출” 인데, 말 그대로 scope 외부에서도 사용할 수 있게 해준다.

 

downloadJson 메서드에서 completion은 다운로드 작업 후 나중에 메인에서 UI를 변경해주는데, downloadJson이 끝나버리면 completion에 접근할 수 없다.

함수가 끝나도 이후에 사용할 있게 도와주는 것이 @escaping 키워드이다.

 

 

 

정리해보자면 @escaping 키워드를 사용하면 

completion 수행하는 작업 , 

downloadJson(MEMBER_LIST_URL) { json in
            self.editView.text = json
            self.setVisibleWithAnimation(self.activityIndicator, false)
        }

여기서 사용되는 내용들을 함수가 끝나도 수행한다.

 

참고로 completion: ((String?) -> Void)?)

completion이 옵셔널일 경우에는 @escaping이 default로 설정된다.

 

여기까지가 Swift 에서 사용하는 정상적인 비동기 처리 방식이다.

 

이렇게 사용해도 되지만 복잡하고 많은 비동기 처리를 해야한다면 코드는 길어질 것이고 아래 코드처럼 가독성이 떨어질것이다.

// Ex)
downloadJson(MEMBER_LIST_URL) { json in
            self.editView.text = json
            self.setVisibleWithAnimation(self.activityIndicator, false)
            
            self.downloadJson(MEMBER_LIST_URL) { json in
                self.editView.text = json
                self.setVisibleWithAnimation(self.activityIndicator, false)
                
                self.downloadJson(MEMBER_LIST_URL) { json in
                    self.editView.text = json
                    self.setVisibleWithAnimation(self.activityIndicator, false)
                    
                    self.downloadJson(MEMBER_LIST_URL) { json in
                        self.editView.text = json
                        self.setVisibleWithAnimation(self.activityIndicator, false)
                    }
                }
            }
        }

 

 

downloadJson 함수에서 completion을 전달하는게 아니라 String return값으로 전달하면 더 편할것이다.

let json = downloadJson(MEMBER_LIST_URL) 처럼

 

 

 

비동기로 생기는 데이터를 어떻게 return값으로 만들까?” 

RxSwift 적용 전 한글로 class를 만들어 적용을 해봤다.

class 나중에생기는데이터<T> {
    private let task: (@escaping (T) -> Void) -> Void
    
    init(task: @escaping (@escaping (T) -> Void) -> Void) {
        self.task = task
    }
    
    func 나중에오면(_ f: @escaping (T) -> Void) {
        task(f)
    }
}

func downloadJson(_ url: String) -> 나중에생기는데이터<String?> {
        return 나중에생기는데이터() { f in
            DispatchQueue.global().async {
                let url = URL(string: url)!
                let data = try! Data(contentsOf: url)
                let json = String(data: data, encoding: .utf8)
                
                DispatchQueue.main.async {
                    f(json)
                }
            }
        }
    }
    
    
    // MARK: SYNC
    
    @IBOutlet var activityIndicator: UIActivityIndicatorView!
    
    @IBAction func onLoad() {
        editView.text = ""
        self.setVisibleWithAnimation(self.activityIndicator, true)
        
        let json: 나중에생기는데이터<String?> = downloadJson(MEMBER_LIST_URL)
        
        json.나중에오면 { json in
            self.editView.text = json
            self.setVisibleWithAnimation(self.activityIndicator, false)
        }
    }

이렇게 한다면 원하는 동작을 비동기적으로 진행할 수 있으면서 completion을 쓰지 않고 return값으로 처리할 수 있다!

 

이러한 동작을 도와주는 도구들은 PromiseKit, Bolt, RxSwift등이 있는데 이번에는 RxSwift 대해 알아보자!

 

    func downloadJson(_ url: String) -> Observable<String?> {
// 1. 비동기로 생기는 데이터를 Observable로 감싸서 return하는 방법
        return Observable.create() { f in
            DispatchQueue.global().async {
                let url = URL(string: url)!
                let data = try! Data(contentsOf: url)
                let json = String(data: data, encoding: .utf8)
                
                DispatchQueue.main.async {
                    f.onNext(json)
                    f.onCompleted() // 순환참조 해결
                }
            }
            
            return Disposables.create()
        }
    }
    
    
    // MARK: SYNC
    
    @IBOutlet var activityIndicator: UIActivityIndicatorView!
    
    @IBAction func onLoad() {
        editView.text = ""
        self.setVisibleWithAnimation(self.activityIndicator, true)
        
// 2. Observable로 오는 데이터를 받아서 처리하는 방법
        downloadJson(MEMBER_LIST_URL)
            .subscribe { event in
                switch event {
                case .next(let json):
                    self.editView.text = json
                    self.setVisibleWithAnimation(self.activityIndicator, false)
                    
                case .completed:
                    break
                case .error:
                    break
                }
            }
    }

나중에생기는데이터 -> Observable

나중에 오면 -> subscribe

 

나중에생기는데이터와 결국 형태도 똑같고 사용방법도 똑같다.

 

RxSwift의 용도는 비동기적으로 생기는 결과값을 completion같은 클로저로 전달하는 것이 아니라 return값으로 전달하기 위해서 만들어진 도구이다.

 

값을 사용할때는 subscribe(나중에 오면) 사용하면 된다.

 

 

Observable의 생명주기는 5단계로 나눌 수 있다.

  1. Create
  2. Subscribe
  3. onNext    
  4. onCompleted  /  onError
  5. Disposed

Create 되고 subscribe 되면 onNext로 데이터가 전달이 된다.

데이터 전달이 잘 되면 onCompleted, 잘 안되면 onError로 동작이 끝나며  Disposed 된다.

 

여기서 중요한 점은! onCompleted이든, onError든 작업이 끝난 범주에 속한다.

작업이 끝나고 나서 Disposed가 되는 것이다.

추가로 동작이 끝난 Observable 재사용이 불가능하다.

 

다시 간단하게 정리하자면, 

Observable create로 데이터를 받아서 onNext, onComplete, onError를 주어서 데이터를 전달시킬 수 있고 dispose(취소) 됐을 때 해야할 작업도 처리할 수 있다.

 

그 후 데이터를 사용하고 싶은 곳에서 subscribe으로 데이터를 사용하면 된다!

 

 

 

 

 

 

 

 

 

 


간단하게 RxSwift의 기본 동작에 대해 알아보았는데, 강의를 들으면서 필기를 하여 설명이 조금 빈약하다! 😵

글을 더 잘 정리하는 법에 대해 고민해봐야겠다.

댓글