iOS | Flux 아키텍쳐를 사용해서 프로젝트를 만들어보자.

iOS | (번역) Flux 에서 Flux 가 어떤 친구인지 알아봤으니
실제로 사용해봐야 어떤느낌인지 파악이 되겠지요 –

이 글의 Flux 컴포넌트에 관한 설명은 iOSアプリ設計パターン入門 의 Flux의 관한 내용을 대부분 참고하였습니다.

🔗 프로젝트 GitHub 리포지토리 링크

https://github.com/unnnyong/FluxTraining/settings



만들어볼 토이 프로젝트에 대하여

로또 번호를 무작위로 만들어주는 프로젝트를 만들어볼거예요.
– 두 개의 Tab으로 구성.
– 번호 만들기 Tab : 무작위로 6자리의 번호를 만들어주는 버튼과 만들어진 번호를 저장하는 버튼 두 가지.
– 저장한 번호 Tab : 번호 만들기 Tab에서 저장한 번호들을 리스트로 보여주는 화면.



프로젝트 구성과 데이터 흐름

이 흐름도는 뭐지 ! 싶으신 분은 iOS | (번역) Flux 를 한 번 보시는 걸 추천해드려용

1. LottoNumberViewController 에서 행운의 번호 보기 버튼을 누르면, ActionCreator가 로또 번호를 만드는 Action을 만듭니다.

2. 로또 번호를 만드는 Action은 Dispatcher로 가고 Dispatcher은 Aciton을 LottoNumberStore로 전달합니당

3. LottoNumberStore는 들어온 Action의 type으로 알맞은 처리를 알아내고, Action의 data로 Store를 업데이트 합니다.

4. LottoNumberViewController는 LottoNumberStore의 업데이트를 구독하고 있기 때문에 업데이트가 발생하면 자동적으로 화면에 업데이트에 대한 내용을 바로 반영합니다.




Xcode 프로젝트 파일 만들기.

LottoNumberMaker 라는 이름으로 프로젝트 파일을 만들어주었어요.




Action(Action Creator) 만들기

Action은 type과 data로 구성되어 있어요.
– type: Store가 Action을 받았을 때, 처리 판단의 기준이 되는 것.
– data: type에 연결되어 있는 data(표시할 것)


Facebook의 Flux 문서를 살펴보면 Action은 Dictionary로 되어야하고,
그 안에는 type을 키 값으로 가지는 type에 대한 내용과 Action이 가져야할 Data들로 구성되어 있어요.

let action: [String: Any] = ["type": "new-number"]

let action: [String: Any] = ["type": "save-current-number",
                             "number": [1, 2, 3, 4, 5 ,6]]

Swift에서 Dictionary를 일일히 작성한다면 ……. 😵
각각의 Type 명을 하나하나 String 으로 작성해야하고 String을 Store 에서도

iOS의 Swift에겐 다양한 case를 나눌수 있는 enum이라는 좋은 친구가 있으니 !
Type은 종류별로 enum의 case로 나누어서 관리하다면 오타의 걱정도 하지 않아도 되고
⭐️자동완성⭐️도 되니 편리하겠쥬?

enum Action {
    case newNumbers([Int])
    case saveNumbers
}

🔗
로또 번호를 만드는 화면에서 필요한 Action 두 가지 case가 포함된 enum이에요.


Action이 만들어지고 영향이 끼치는 부분을 진하게 표시한 데이터 흐름도입니다.

final class ActionCreator {

    private let dispatcher: Dispatcher = .shared

    private let numberMaker = LottoNumberMaker()
    private let numberSaver = LottoNumberSaver()

    func makeNewNumbers() {
        let newNumbers = numberMaker.make()
        dispatcher.dispatch(.newNumbers(newNumbers))
    }

    func saveNumbers(currentNumbers: [Int]) {
        numberSaver.save(currentNumbers: currentNumbers)
        dispatcher.dispatch(.saveNumbers)
    }

}

🔗

이 Aciton을 위한 ActionCreator에게 Action을 Store까지 전달해줄 Dispatcher는 필수 !
1 APP 1 Dispatcher 는 Flux에서 필수이기 때문에 static property예요

numberMaker, numberSaver 는 이름 그대로
로또 번호를 만들고, 로또 번호를 UserDefulat에 저장/불러오기 의 역할을 하는 struct 입니다.


(스포주의)
makeNewNumbers(), saveNumbers()는 ViewController에서
로또 번호 만들기, 번호 저장하기 버튼이 눌려졌을 때 실행되는 메소드들이에요.



⭐️
ActionCreator에서는 직접 통신(여기서는 numberMaker, numberSaver)의 역할은 하지않고
Action을 만들어내고 Dispatcher 알려주는 역할만 수행합니다 !







Dispatcher 만들기


Dispatcher는 View에서의 유저 동작으로 만들어진 Aciton을 Store로 전해주는 역할을 해요.
– ActionCreator의 코드에서도 이야기했지만, Dispatcher은 1 App에 1개만 존재할 수 있어요.

1. Dispatcher의 register(callback:) 메소드가 Store에서 실행.

2. 독특한(= 랜덤으로 된) 문자열이 key, key value는 parameter인 callback
Disptacher의 property, var callbacks: [String: (Action) -> ()] 에추가된다.
callbackkey 인 독특한(= 랜덤으로 된) 문자열은 callback을 등록(해제)할 때 구분하기 위해 사용된다.

3. Dispatcher의 dispatch(_:) 메소드가 ActionCreator에서 실행.

4. callbacks에 등록된 모든 callback에게 Action을 전달

5. (callback의 동륵을 해제할 때) Dispatcher의 unregister(_:)를 실행

6. parameter의 token 값에 맞는 callback을 callbacks에서 삭제.

👆 Dispatcher 하는 일을 갖고있는 메소드들과 callback이 등록되고 해제되는 순서로 정리해보았어요.



typealias DispatchToken = String

final class Dispatcher {

    static let shared = Dispatcher()

    let lock: NSLocking
    private var callbacks: [String: (Action) -> ()]

    init() {
        self.lock = NSRecursiveLock()
        self.callbacks = [:]
    }

    func register(callback: @escaping (Action) -> ()) -> DispatchToken {
        lock.lock(); defer { lock.unlock() }

        let token = UUID().uuidString
        callbacks[token] = callback

        return token
    }

    func unregister(_ token: DispatchToken) {
        lock.lock(); defer { lock.unlock() }

        callbacks.removeValue(forKey: token)
    }

    func dispatch(_ action: Action) {
        lock.lock(); defer { lock.unlock() }

        callbacks.forEach { _, callback in
            callback(action)
        }
    }

}

위의 코드는 Web App에서 구현되는 Dispatcher의 코드 구성과 동일해요. (Swift ver.)

func dispatch(_ action: Action)
ActionCreator에서 UserInterface로부터 새로운 터치가 있었을 때 실행되는 함수들에서 실행됐었어요 !
🔗






Store 만들기



Callback이 실행되어서
저는 NotificationCenter를 사용했지만, RxSwift로 하는걸 더 추천드려요 🙏


가장 부모가 되는 Store class가 존재합니다.
그 Store 를 상속한 Store가 VC마다 하나씩 존재하도록 구현합니다.

저는 기준을 화면으로 잡았지만 꼭 화면이 아니라도 괜찮아요.
복잡한 화면을 가진 App이라면 화면이 아니라 역할(서비스) 기준을 나눌 수도 있어요.

typealias Subscription = NSObjectProtocol

class Store {

    private enum NotificationName {
        static let storeChaged = Notification.Name(rawValue: "store-chaged")
    }

    private let notificationCenter = NotificationCenter()

    private let dispatcher: Dispatcher
    private lazy var dispatchToken: DispatchToken = dispatcher.register { [weak self] action in
        self?.onDispatch(action)
    }

    init(dispatcher: Dispatcher) {
        self.dispatcher = dispatcher
        _ = dispatchToken
    }

    func onDispatch(_ action: Action) {
        fatalError("must override")
    }

}

// MARK: final method
extension Store {

    final func emitChange() {
        notificationCenter.post(name: NotificationName.storeChaged, object: nil)
    }

    final func addListener(callback: @escaping () -> ()) -> Subscription {
        let using: (Notification) -> () = { notification in
            if notification.name == NotificationName.storeChaged { callback() }
        }

        return notificationCenter.addObserver(
            forName: NotificationName.storeChaged,
            object: nil,
            queue: nil,
            using: using
        )
    }

    final func removeListener(_ subscription: Subscription) {
        notificationCenter.removeObserver(subscription)
    }

}


🔗 GitHub code link


🖍 Store는 Action을 가져다주는 Dispatcher가 꼭 있어야해요.

Dispatcher의 register(callback:) 메소드를 사용해서
Store의 자신의 데이터를 업데이트 시킬 callback을 등록해요.

callback의 처리 – Dispatcher 로부터 넘겨받은 Action을 onDispatch(_:) 메소드에서 처리해요.
onDispatch(_:) 메소드는 VC마다 구현되는 자식 Store class에서 override로 필요한 코드를 구현합니다.

addListener(callback:) 메소드는 View가 Store의 변화를 감지하기 위해 필요해요.
removeListener(_:) 메소드는 반대로 View가 더이상 Store의 변화를 감지할 필요가 없을 때 사용하죠.

emitChange() 는 Notification을 송신하기 위해 필요해요 (= Rx에서는 대신에 Subscribe가 필요.)



final class LottoNumberMakerStore: Store {

    static let shared = LottoNumberMakerStore(dispatcher: .shared)

    private(set) var numbers: [Int] = []

    override func onDispatch(_ action: Action) {
        switch action {
        case let .newNumbers(numbers):
            self.numbers = numbers
        default:
            break
        }

        emitChange()
    }

}

🔗 GitHub code link


Store를 상속해서 만든 LottoNumberMakerVC를 위한 LottoNumberMakerStore입니다.

LottoNumberMakerStore 가 가지는 Data
LottoNumberMaker가 만들어서 Aciton이 가져오는 numbers 입니다.


새로운 Action이 왔을 때 Noticiation을 송신할 수 있도록 emitChange()를 함께 사용해주었어요.

Notification을 사용하는 경우, emitChange() 가 없다면 아무리 코드를 잘 구현해도 View가 알 수 없으니
Store의 Data는 업데이트 되더라도 View가 반응하지 않는다면 한 번 살펴보는게 좋겠지유





View 만들기 – LottoNumberMakerViewController

Action(ActionCreator), Dispatcher, Store까지 다 준비되었고,
User가 직접보고 동작시킬 수 있는 View를 구현해볼게요.


🖍
Flux는 단일방향 데이터 흐름이 원칙이기 때문에 View가 Store에 직접적으로 무언가를 전해주면 안돼요.
Store의 데이터가 변경되었을 때에만
Notification(RxSwift subscribe)로 알아채고 View만 업데이트할 수있어요.


View가 받아들이는 User의 탭(글자 입력)과 같은 동작을 Store까지 전달해주기위해서는
ActionCreator에서 User의 동작을 Action으로 만들어달라고 부탁하면
ActionCreator가 Dispatcher까지 전달해쥬ㅓ요. (= View는 ActionCreator의 메소드만 실행가능)



만들어진 로또 번호를 표시할 UILabel과 UIButton 두 개로 UI를 구성해주었어요.



final class LottoNumberMakerViewController: UIViewController {

    @IBOutlet private weak var numbersLabel: UILabel!

    private let store: LottoNumberMakerStore = .shared
    private let actionCreator = ActionCreator()

    private lazy var reloadSubscription: Subscription = {
        store.addListener { [weak self] in
            DispatchQueue.main.async {
                self?.updateLabel()
            }
        }
    }()

    private var numbers: [Int] { store.numbers }

    override func viewDidLoad() {
        super.viewDidLoad()

        _ = reloadSubscription
    }

}

// MARK: IBAction
private extension LottoNumberMakerViewController {

    @IBAction func didTapMakeLottoNumbers(_ sender: UIButton) {
        actionCreator.makeNewNumbers()
    }

    @IBAction func didTapSaveLottoNumbers(_ sender: UIButton) {
        actionCreator.saveNumbers(currentNumbers: numbers)
    }

}

// MARK: Private method
private extension LottoNumberMakerViewController {

    func updateLabel() {
        let numbersText = numbers
            .map { "\($0)" }
            .joined(separator: "  ")

        numbersLabel.text = numbersText
    }

}

🔗 GitHub code link

🖍
subscription의 초기화 타이밍을 꼭꼭 확인해주세요

Action을 아무리 잘 만들고 Store의 NotificationCenter를 완벽하게 구현하더라도
View에서 초기화를 해주지 않거나 타이밍이 맞지않으면 View는 Store의 데이터 변화를 눈치채지도 못하고
데이터 변화로 필요한 View의 업데이트도 이루어지지 않아요 😢










자이언트 펭귄 펭수 펭수 짤

여러분 ! 로또번호를 만드는 화면에서 번호를 저장했으면,
저장한 번호들도 보여주는 화면이 필요하죠,,,,?



SavedLottoNumberListViewController 구현하기

흐름도는 동일해요 다른게 있다면 같은 ActionCreator는 사용하지만 LottoNumberMaker는 불필요해졌죠.

필요한 UIComponent는 UITableView 뿐이에요.

늘 해왔던 UITableView를 IBOutlet으로 VC에 연결해주시고
DataSource도 꼭 추가해주세요 !

final class SavedLottoNumberListViewController: UIViewController {

    @IBOutlet private weak var tableView: UITableView! {
        didSet {
            tableView.delegate = self
            tableView.dataSource = self
        }
    }

    private let store: SavedLottoNumberStore = .shared
    private let actionCreator = ActionCreator()

    private lazy var reloadSubscription: Subscription = {
        store.addListener { [weak self] in
            DispatchQueue.main.async {
                self?.tableView.reloadData()
            }
        }
    }()

    private var savedNumbers: [[Int]] { store.savedNumbers }

    override func viewDidLoad() {
        super.viewDidLoad()

        _ = reloadSubscription
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        actionCreator.loadSavedNumbers()
    }

}

// MARK:
extension SavedLottoNumberListViewController: UITableViewDelegate {}

// MARK:
extension SavedLottoNumberListViewController: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        savedNumbers.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        cell.textLabel?.text = savedNumbers[indexPath.row]
            .map { "\($0)" }
            .joined(separator: "  ")

        return cell
    }

}

🔗 GitHub code link


Store는 VC마다 따로 만들어주었어요 !

🖍
LottoNumberMakerVC와 동일하게 구현할 때의 주의점은 !
subscription의 초기화 타이밍을 꼭꼭 확인해주세요






완성된 프로젝트



저는 LottoNumberMakerViewController와 SavedLottoNumberListViewController를
UITabBar로 구성했어용

이건 취향의 차이이니 여러분께 맡길게요

👇 UITabBar 코드가 궁금하신 분은 링크를 참고해주세요 👇
🔗 FluxTraining/LottoNumberMaker/LottoNumberMaker/Sources/Views/MainViewController.swift (Storeboard 없음)
🔗 SceneDelegate, plist 수정까지 포함된 commit

대세는EBS 펭수 짤 모음 ㅋㅋㅋㅋ.JPG (계속업뎃) : 네이버 블로그




Git으로 코드 링크를 추가하니까 Flux의 구현과 크게 상관 없는 코드가 대체되어서 참 좋네요
Git 최고 ~ !

바로 직전에 다녔던 회사에서 Git 관리 룰이 정해져있었어서,,,
잘못 커밋한걸 push했을 때 commit을 삭제하고 왜 force-push를 했는지도 다 적을때
식은땀이 났던 것들도 다 추억이 되었네요……… (백수의 추억회상)


iOS 개발자 공고에서 MVVM, MVP와 함께 ReactorKit이 있는 곳이 있었어서
ReactorKit의 단방향 흐름과 같은 Flux를 사용해보니 흐름도 그릴때도 일방적이라 참 깔끔하네용

아키텍쳐를 하나둘씩 정리해 갈수록 나중에 아키텍쳐를 결정해야할 순간이 온다면
고민과 장단점을 잘 구분해서 따져야 할 것 같아요

짤로 쓰기 좋은 펭수 짤털이 ♡ - 인스티즈(instiz) 인티포털
(미래의 아키텍쳐 결정 회의의 나)

옛 어른들이 세상은 모르는게 낫다고 그러셨지만,,, (생략)
글 마무리에서 잡담이 많은건 자가격리로 절대 외로워서 그런게 아니예요 😀…….



펭수 짤 모음(최신업데이트) 인사 웃음 화남 슬픔 상황별 이미지 100장 ...



오늘도 부족한글 봐쥬셔서 감사합니다 !!!!!!!
피드백은 늘 환영합니다 ! 댓글 대환영 !

iOS 개발자 횐님들 ,,, 오늘도 즐건 하루되세여 ~~ ! 💜
장미한송이 놓고^^ 떠납니다 ,,,,, @8~~~~~

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Google photo

Google의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

%s에 연결하는 중