MVVM + Flux

MVVM + Flux

『モバイルアプリ開発エキスパート養成読本』出版記念 Tech Talks
https://connpass.com/event/50979/

QiitaWithFluxSample
https://github.com/marty-suzuki/QiitaWithFluxSample

Ae276805027a01983503c3edafbdb6b2?s=128

Taiki Suzuki

April 26, 2017
Tweet

Transcript

  1. MVVM + Flux 2017/04/26 marty-suzuki

  2. None
  3. ୈ5ষ ݱ৔Ͱ໾ཱͭ ϞόΠϧΞϓϦઃܭɾ։ൃ 5-2 λΠϓηʔϑͰϞμϯͳiOSΞϓϦͷઃܭ

  4. ͦΕͰ͸ૣ଎...

  5. MVVM

  6. MVVM • Model ϏδωεϩδοΫΛ࣋ͭ • View Ϣʔβʔ͔ΒͷೖྗΛड͚औΔ • ViewModel ϓϨθϯςʔγϣϯʹؔ͢ΔϩδοΫɾঢ়ଶΛ࣋ͭ

  7. MVVM Figure 1

  8. Flux

  9. Flux ΞϓϦ಺ͷσʔλϑϩʔͷ୯ํ޲ԽΛ͢Δ • Action ViewͳͲ͔Βͷૢ࡞ʹΑΓΠϕϯτΛൃੜͤ͞Δ • Dispatcher Action͔ΒྲྀΕ͖ͯͨΠϕϯτΛStoreʹྲྀ͢ • Store

    Dispatcher͔ΒྲྀΕ͖ͯͨΠϕϯτ಺ͷ஋Λอ࣋͢Δ
  10. Flux Figure 2

  11. ͜͜·Ͱ͸͓͞Β͍

  12. ຊ୊͸...

  13. MVVM + Flux

  14. MVVM + Flux ϓꙎ ϹωЀϓЄτϴЀϺυϐμΨView͔Β෼཭Ͱ͖Δ • Action • Dispatcher •

    Store • ViewModel ΠϕϯτͷൃՐΛ͠ɺStoreͰͷมߋΛड͚औΔ
  15. MVVM + Flux Figure 3

  16. ඦฉ͸Ұݟʹ೗͔ͣ

  17. ͜ΕΒΛ༻͍ͯ αϯϓϧΞϓϦΛ࡞੒͍͖ͯ͠·͢

  18. ࣮ݱ͍ͨ͜͠ͱ • QiitaʹϩάΠϯ • ΞΫηετʔΫϯ͕੾ΕͨΒɺϩάΠϯը໘ʹ໭͢ • ౤ߘΛݕࡧ • 0.3ඵͷؒʹจࣈʹೖྗ͕ͳ͔ͬͨΒݕࡧ •

    ԼʹεΫϩʔϧ͍ͯ͘͠ͱࣗಈಡࠐ • ౤ߘΛӾཡ
  19. QiitaWithFluxSample https://github.com/marty-suzuki/ QiitaWithFluxSample AbemaTVͰ࠾༻͍ͯ͠Δઃܭʹ͍ۙ͠ ΋ͷʹ͍ͯ͠·͢ɻ

  20. ը໘ߏ੒ • RootViewController • LoginNavigationController • LoginTopViewController • LoginViewController •

    SearchNavigationController • SearchTopViewController
  21. None
  22. Flux ActionͱStoreͷ1ηοτ͝ͱʹɺDispatcher΋1ͭ • Application • ApplicationAction • ApplicationDispatcher • ApplicationStore

    • Route • RouteAction • RouteDispatcher • RouteStore
  23. Application • ApplicationAction func requestAccessToken(withCode code: String) func removeAccessToken() •

    ApplicationStore let accessToken: Property<String?> let accessTokenError: Observable<Error>
  24. Propertyͱ͸ https://github.com/inamiy/RxProperty set΍bind͕Ͱ͖ͳ͍Variable final class ViewModel { let intValue: Property<Int>

    private let _intValue = Variable<Int>(0) public init() { self.intValue = Property(_intValue) } } viewModel.intValue.value = 1 // error // Observable<Int> observable.bind(to: viewModel.intValue) // error
  25. RouteAction • RouteAction func show(loginDisplayType: LoginDisplayType) func show(searchDisplayType: SearchDisplayType) •

    RouteStore let login: Observable<LoginDisplayType?> let search: Observable<SearchDisplayType?>
  26. None
  27. RootViewModel final class RootViewModel { private let disposeBag = DisposeBag()

    let login: Observable<LoginDisplayType?> let search: Observable<SearchDisplayType?> init(applicationStore: ApplicationStore = .shared, routeAction: RouteAction = .shared, routeStore: RouteStore = .shared) { let accessTokenObservable = applicationStore.accessToken.asObservable() // AccessToken͕nilͰͳ͍ͱ͖ɺSearchͷTopΛදࣔ͢ΔΞΫγϣϯΛ౤͛Δ accessTokenObservable.filter { $0 != nil }.map { _ in SearchDisplayType.root } .bind(onNext: routeAction.show).addDisposableTo(disposeBag) // AccessToken͕nilͷͱ͖ʹɺLoginը໘Λදࣔ͢ΔΞΫγϣϯΛ౤͛Δ accessTokenObservable.filter { $0 == nil }.map { _ in LoginDisplayType.root } .bind(onNext: routeAction.show).addDisposableTo(disposeBag) // ViewControllerͰsubscribe͢Δ༻ͷObservable self.login = routeStore.login self.search = routeStore.search } }
  28. RootViewController // ViewController಺ͰViewModelͷobservableΛsubscribe͠ɺը໘ભҠΛߦ͏ // ॳճىಈ࣌͸AccessToken͕ͳ͍ͷͰLoginը໘Λදࣔ͢Δ viewModel.login .observeOn(ConcurrentMainScheduler.instance) .filterNil() .subscribe(onNext: {

    [weak self] displayType in guard let me = self else { return } let loginNC: LoginNavigationController if let nc = me.currentViewController as? LoginNavigationController { loginNC = nc } else { loginNC = LoginNavigationController() me.currentViewController = loginNC } switch displayType { case .root: if loginNC.topViewController is LoginTopViewController { return } loginNC.popToRootViewController(animated: true) case .webView: if loginNC.topViewController is LoginViewController { return } loginNC.pushViewController(LoginViewController.instantiate(), animated: true) } }) .addDisposableTo(disposeBag)
  29. LoginViewModel final class LoginViewModel { let isLoading: Property<Bool> private let

    _isLoading = Variable<Bool>(false) let authorizeRequest: OauthAuthorizeRequest let authorizeUrl: URL private let applicationAction: ApplicationAction private let disposeBag = DisposeBag() init(applicationStore: ApplicationStore = .shared, applicationAction: ApplicationAction = .shared, config: Config = .shared) { self.applicationAction = applicationAction // and so on... } func requestAccessToken(withCode code: String) { // and so on... // AccessTokenͷϦΫΤετΛ͠ɺऔಘ͕׬ྃ͢ΔͱDispatcherΛհ͠Store΁ applicationAction.requestAccessToken(withCode: code) } }
  30. LoginViewController class LoginViewController: UIViewController, Storyboardable { let loadingView = LoadingView(indicatorStyle:

    .whiteLarge) let webView: WKWebView = WKWebView(frame: .zero) private let viewModel = LoginViewModel() private(set) lazy var dataSource: LoginViewDataSource = .init(viewModel: self.viewModel) private let disposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() configureWebView() // and so on... } private func configureWebView() { // navigationDelegateΛLoginViewDataSourceͱ͢Δ webView.navigationDelegate = dataSource // and so on... } }
  31. LoginViewDataSource final class LoginViewDataSource: WKNavigationDelegate { let viewModel: LoginViewModel init(viewModel:

    LoginViewModel) { self.viewModel = viewModel } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Swift.Void) { guard let url = navigationAction.request.url else { decisionHandler(.cancel) return } if url.absoluteString.hasPrefix(Config.shared.redirectUrl) { guard let URLComponents = URLComponents(string: url.absoluteString), // and so on... let code = codeItem.value else { fatalError("can not find \"code\" from URL query") } // viewModel͔ΒAccessTokenΛϦΫΤετ viewModel.requestAccessToken(withCode: code) decisionHandler(.cancel) return } decisionHandler(.allow) } }
  32. RootViewController // AccessTokenΛऔಘͨ͠ޙʹɺݕࡧը໘Λදࣔ͢Δ viewModel.search .observeOn(ConcurrentMainScheduler.instance) .filterNil() .subscribe(onNext: { [weak self]

    displayType in guard let me = self else { return } let searchNC: SearchNavigationController if let nc = me.currentViewController as? SearchNavigationController { searchNC = nc } else { searchNC = SearchNavigationController() me.currentViewController = searchNC } switch displayType { case .root: if searchNC.topViewController is SearchTopViewController { return } searchNC.popToRootViewController(animated: true) case .webView(let url): if searchNC.topViewController is SFSafariViewController { return } searchNC.pushViewController(SFSafariViewController(url: url), animated: true) } }) .addDisposableTo(disposeBag)
  33. SearchTopViewModel // ݕࡧը໘Ͱར༻͢Δσʔλ͸ɺ͜ͷը໘಺Ͱऩ·ΔͷͰSearchͷFluxΦϒδΣΫτ͸࡞Βͳ͍ // ϓϨθϯςʔγϣϯʹؔ͢Δঢ়ଶΛ࣋ͪɺActionΛར༻ͯ͠Apiͱͷ௨৴Λߦ͏ final class SearchTopViewModel { let

    lastItemsRequest: Property<ItemsRequest?> private let _lastItemsRequest = Variable<ItemsRequest?>(nil) let items: Property<[Item]> private let _items = Variable<[Item]>([]) let totalCount: Property<Int> private let _totalCount = Variable<Int>(0) let error: Property<Error?> private let _error = Variable<Error?>(nil) let hasNext: Property<Bool> private let _hasNext = Variable<Bool>(true) let searchAction: Action<ItemsRequest, ElementsResponse<Item>> private let perPage: Int = 20 let noResult: Observable<Bool> let reloadData: Observable<Void> let isFirstLoading: Observable<Bool> let keyboardWillShow: Observable<UIKeyboardInfo> let keyboardWillHide: Observable<UIKeyboardInfo> }
  34. Actionͱ͸ https://github.com/RxSwiftCommunity/Action enable͕trueͷͱ͖ͷΈʹ࣮ߦͰ͖Δ let searchAction = Action<ItemsRequest, ElementsResponse<Item>> { request

    in return QiitaSession.shared.send(request) } let request = ItemsRequest(page: nextPage, perPage: perPage, query: nextQuery) searchAction.execute(request) searchAction.elements .subscribe(onNext: { items: ElementsResponse<Item> in // and so on }) .addDisposableTo(disposeBag) searchAction.errors searchAction.executing searchAction.enabled
  35. SearchTopViewModel // ViewController͔ΒͷΠϕϯτΛड͚औΓɺViewModel಺ͷΞΫγϣϯΛݺͿ func observe(textControlProperty: ControlProperty<String?>, deleteButtonTap: ControlEvent<Void>, reachedBottom: Observable<Void>)

    { externalDisposeBag = DisposeBag() textControlProperty.orEmpty .distinctUntilChanged() .debounce(0.3, scheduler: ConcurrentMainScheduler.instance) .subscribe(onNext: { [weak self] text in self?.search(query: text) }) .addDisposableTo(externalDisposeBag) deleteButtonTap .subscribe(onNext: { [weak self] in self?.removeAccessToken() }) .addDisposableTo(externalDisposeBag) reachedBottom .subscribe(onNext: { [weak self] in self?.search() }) .addDisposableTo(externalDisposeBag) }
  36. ·ͱΊ • ΞϓϦશମͰ࢖͏Α͏ͳσʔλΛѻ͏ MVVM + Flux • 1ը໘Ͱऩ·ΔσʔλΛѻ͏ MVVM

  37. ͝੩ௌ͋Γ͕ͱ͏͍͟͝·ͨ͠ʂ marty-suzuki