Closed63

iOSアプリのアーキテクチャーを本読みながら実装してく

とりあえずMVCでサンプルアプリつくろうとしてるけど、SwiftUIではじめたのでどちらかというとそっちの勉強になってるのが今

入力: ユーザーのテキスト変更
出力: GiuthubのAPI叩いた結果を表示

という要件を実現したいが、これをSwiftUIでやると、俺が状態管理よくわかってないせいで結構大変だった

ViewからControllerを生成、ContorollerがModelにイベント発火をかけて、Modelの結果をViewが監視する、という原初MVCの構成をつくろうとした。
問題はSwiftUIのViewでどのようにControllerのメソッド叩くのをやればいいか

まずプロパティでやろうとした

    @State private var searchText: String = "" {
    @ObservedObject var model = GithubModel()
    private var controller = UsersController(model: model, query: searchText)

ただこれだと、プロパティから別のプロパティ引数にするというのができないようで、ダメだった

これを回避するために、イニシャライザに入れた。初回だけでよければ、これでも動く。
SwiftUIのViewにイニシャライザ書けるの知らなかった。

テキスト入力のUI部品にUISearchBarをブリッジしたものを使っていた

https://dasuko.hatenadiary.jp/entry/2021/03/02/173000

が、これをTextFieldにしたら、onEditingChanged:というクロージャー渡せるところがあって、ここでコントローラーのイベント発火をするとキレイになった

ただよくデバッグしてみると、リターンキーを押さないとonEditingChanged:は呼ばれなかった。
一文字の変更があるたびに呼ばれて欲しかったので、他を探すことにした

ちなみに状態変数にプロパティオブザーバー書いてみたが、これは呼ばれることがなかった

    @State private var searchText: String = "" {
        didSet {
            UsersController(model: model, query: searchText).loadStart()
        }
    }
TextField("user name", text: $searchText)
                    .onReceive(Just(searchText)) { _ in
                        UsersController(model: model, query: searchText).loadStart()
                        print(searchText)
                    }

これでやってみたところ、呼ばれるは呼ばれるのだが、テキストの変更というか秒間何回というペースで呼ばれてしまって、GithubのAPI制限にかかってしまった……

\"message\":\"API rate limit exceeded for 221.248.242.35. (But here\'s the good news: Authenticated requests get a higher rate limit. Check out the documentation for more

.onChange()っていうそのものズバリなモディファイアーがあった
(最初に教えてくれやこんなん……)

TextField("user name", text: $searchText)
                    .onChange(of: searchText) { _ in
                        UsersController(model: model, query: searchText).loadStart()
                        print(searchText)
                    }

https://www.yururiwork.net/archives/1481

Modelクラス、ObservableObjectにしたいからclassにしてるけど、実際のところstructでも問題ないような気がしてきた
前回のロード結果をいちいちイニシャルしなきゃいけないのがちょっと気になった

Loaderをasync/awaitで書き直してみた

before
class UserModel: ObservableObject {
    private let urlString = "https://api.github.com/search/users?q="
    @Published var users = [User]()
    @Published var isNotFound = false
    @Published var error: ModelError?

    public func fetch(query: String) {
        users = [User]()
        error = nil
        isNotFound = false

        guard let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
            publishError(type: .encodingError)
            return
        }

        guard let url = URL(string: urlString + encodedQuery) else {
            publishError(type: .urlError)
            return
        }

        URLSession.shared.dataTask(with: url) {[weak self](data, _, error) in
            if let error = error {
                self?.publishError(type: .responseError(error))
                return
            }

            guard let data = data else {
                self?.publishError(type: .responseDataEmpty)
                return
            }

            guard let users = try? JSONDecoder().decode(Users.self, from: data) else {
                self?.publishError(type: .jsonParseError(String(data: data, encoding: .utf8) ?? ""))
                return
            }

            DispatchQueue.main.async {
                if users.totalCount == 0 {
                    self?.isNotFound = true
                    self?.users = [User]()
                    return
                }
                self?.users = users.items
            }
        }.resume()
    }

    private func publishError(type: ModelError) {
        DispatchQueue.main.async {[weak self] in
            self?.error = type
        }
    }
}
after
class UserModel: ObservableObject {
    private let urlString = "https://api.github.com/search/users?q="
    @Published var users = [User]()
    @Published var isNotFound = false
    @Published var error: ModelError?

    @MainActor
    public func fetch(query: String) async {
        users = [User]()
        error = nil
        isNotFound = false

        guard let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
            error =  .encodingError
            return
        }

        guard let url = URL(string: urlString + encodedQuery) else {
            error = .urlError
            return
        }

        do {
            let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url))

            guard let users = try? JSONDecoder().decode(Users.self, from: data) else {
                error = .jsonParseError(String(data: data, encoding: .utf8) ?? "")
                return
            }

            if users.totalCount == 0 {
                isNotFound = true
                self.users = [User]()
                return
            }

            self.users = users.items
        } catch {
            self.error = .responseError(error)
        }
    }
}

do-catch節が長くなっちゃってるので、出したいけど、上手い出し方が思いつかないので、一旦これはこれでいいか

次はMVPで実装する

PresenterからViewを更新させるにあたって、まずこんなのを試した

@State public var isNotFound = true
    override func viewDidLoad() {
    // … //
        Timer.scheduledTimer(timeInterval: 3.0, target: self, selector: #selector(temp), userInfo: nil, repeats: true)
    }

    @objc private func temp() {
        userSearchView.isNotFound = false
    }

ビルド自体は通るけども、SwiftUIの描画更新が走らない。
SwiftUIの状態変数を外部から直接更新するのはダメそう

さらに思いつきで、Viewに

    mutating func temp() {
        self.isNotFound = true
    }

こんなメソッドを生やして、これをPresenterに叩かせたが、描画更新されず。
var body: some View { … } の中で@Stateのプロパティを更新しないと無視される? みたいに見える

ダメな理由わかった。たぶんSwiftUI関連は全部値渡しなので、画面に表示されてるViewとPresenterが参照しているViewは既に別アドレスになっているはず

protocol使ったView - Presenterの連結が上手くいかなかった

/// Presenterの入力になるクラスが準拠する
protocol PresenterInput: View {
    
}

class Presenter: UIViewController {
    private var userSearchView: PresenterInput! // Protocol 'PresenterInput' can only be used as a generic constraint because it has Self or associated type requirements
    private var hostingController: UIHostingController<PresenterInput>!
    private var model: SearchUserModelInput!
    
    public func inject(view: PresenterInput, model: SearchUserModelInput) {
        self.userSearchView = view
        self.model = model
    }

SiwftUIのViewもプロトコルなので、継承できそうに感じるが、何がダメなんだろう……

直接Viewを指定したらこうなった。

Protocol 'View' can only be used as a generic constraint because it has Self or associated type requirements

やはり型定義の問題を解決してやる必要があるみたい。。。

サンプルだとprotocolでモック使えるようにしてるけど、今回はいいや

ユーザー→リポジトリ一覧への画面遷移で、

                    List(users) { user in
                        NavigationLink(destination: delegate?.transitionToRepository(repositoryUrlString: user.reposUrl)) {
                            UserRow(user: user)
                        }
                    }
    /// FIXME: 結果を受け取っても、Viewは更新されないので、画面遷移をまるごと書く必要があった
    func transitionToRepository(repositoryUrlString: String) -> RepositoryView {
        repositoryView = RepositoryView(type: .loading)

        model.fetchRepository(urlString: repositoryUrlString) { [weak self] result in
            switch result {
            case .success(let repositories):
                self?.repositoryView = RepositoryView(type: .display(repositories))
            case .failure(let error):
                self?.repositoryView =  RepositoryView(type: .error(error))
            }
        }
        return repositoryView
    }

としたところ、画面を更新できなかった

回避策として、下記2つを試したが、2つともダメだったので、諦めた。

  • UIHostingViewでラップしてから、addSubViewする
    • 表示まではできるが、変更できない
    • 更新じゃなくて、Modelのcallbackタイミングでremove / addしたらイケるかも
  • UserSearchViewのNavigationViewからpush遷移する
    • NavigationViewがどうやってもPresenter側から取得できなかった

最初からUINavigationViewを使って、Presenterから実装すればできるけど、もはやUIKitで全部書いた方がいいだろう。
UIKitとSwiftUIをブリッジしてるが故の弊害

MVVMやっていき

ViewModelのprotocolをつくるためにObservableObjectを継承しようとしたらなかなか苦戦してる

あ、ダメだ難しい……

protocolとCombineの相性が悪いな……

うーんあんまり安い方法がないのかな

protocolに切り出さなくても、テスト的には方法があるから、ここはまあいいかということにした。
抽象に依存する、というのができなくなるけども……

SwiftUIが何回レンダリングされてるか知りたいとき、下記が便利

    var body: some View {
        let temp = "rendering"
        let some = print(temp)

let _ = printにしないのはなんかLintが暴走して勝手にletを消すため

@Publishedのプロパティ更新のたびにムダなレンダリング走るのが気になった

https://qiita.com/chocoyama/items/a588c3569b8dd89cd223

これを試す

↑これを入れた状態で0,s,i,4,3を検索: 11回

入れない状態: 16回

/// Modelにロード開始を要求する
    public func loadStart(query: String) {
        guard !query.isEmpty else { return }
        users = [User]()
        isNotFound = false
        error = nil

        model.fetchUser(query: query) { [weak self] result in
            switch result {
            case .success(let users):
                if !users.isEmpty {
                    self?.users = users
                } else {
                    self?.isNotFound = true
                }
            case .failure(let error):
                self?.error = error
            }
            //            self?.objectWillChange.send()
        }
    }

こんなコードなので、@Publishedの変数のイニシャルのたびに3回レンダリング走ってる予想だったので、思ったより少なかった

代入といっても、前回と値の変化がなければどうも再レンダリングはしないようになってるみたい。賢い。
手動のPublish通知を入れると2N + 1で、入れないと3N + 1。

Fluxやっていき

公式のこの絵、はじめ見たときは気にしてなかったけど、Dispatcherだけ単数系なのにちゃんと意味があるんだ

Storeは複数あるんちゃう? と思ったけど、あるコンテキストに関するStoreは単一、という意味か
ちょっと厄介な気もする

DispatcherのシングルトンをEnvironmentValuesにすることでちょっとはマシにできないかな、と思った

https://developer.apple.com/documentation/swiftui/environmentvalues

ただ環境変数といっても、Viewだけが見られるものになってしまい、その他のクラスが参照できないとしょうがないので、結局.sharedを定義した、普通のシングルトンにする他なさそう

Clean Architectureをやっていくぞ

protocolで、protocolに準拠する型に制約をつけたいときは、こんな書き方でイケた模様。

これならObservalObjectのprotocol書けるかな?

XcodeのプレビューがNoBuildableEntriesErrorで全く効かなくなった

NoBuildableEntriesError: active scheme does not build this file

一回Xcode落として、再起動したらなおった。なんだったんだ……

TCAをやっていく。ラスト!

View -> ViewStore -> Reducer -> State更新、という流れなんだけど、Stateってどこで保持してるんだろう……?

いや、ごめん。
StoreがState持ってるんだ。
State更新すると、StoreがそれをPublishして、Viewに伝える……はず。
この辺の仕組みをTCAライブラリがやってくれてると思われるが……?

あー、この図の通りなのか

View - ViewStore間でPub/Subになっていて、ViewStore - Store間でもPub/Subなんだな

Reducerという言葉の意味がふと気になったが、Action + StateをReduceするから、ということらしい

https://shgam.hatenadiary.jp/entry/2018/11/10/004819

reduceの原語は「(整理して)量を減らす」という意味で、日本人的には3Rの中に出てくるReduce, Reuse, Recycleが馴染み深いだろう。
それが高階関数のreduceとして一般化して、ReduxのRecuderまで来た、というのが経緯っぽい

結局Loader部分を全面的に書き直す必要が出てきて、

import Foundation
import ComposableArchitecture

struct GithubApi {
    var users: (String) -> Effect<[User], ModelError>
}

extension GithubApi {
    static let live = GithubApi(
        users: { query in
            var components = URLComponents()
            components.scheme = "https"
            components.host = "api.github.com"
            components.path = "/search/users"
            components.queryItems = [URLQueryItem(name: "q", value: query)]

            return URLSession.shared.dataTaskPublisher(for: components.url!)
                .map { data, _ in data }
                .decode(type: Users.self, decoder: JSONDecoder())
                .mapError { error in ModelError.jsonParseError(error.localizedDescription) }
                .map { return $0.items }
                .eraseToEffect()
        }
    )
}

サンプルコード丸パクリでこんな感じで書いた。
デバッグのときにJson Parse Errorになって、高階関数で結んでるとデバッグがキツいなと思った。
(原因は結局URLの指定ミスってただけだった……)

このスクラップは2021/09/30にクローズされました
ログインするとコメントできます