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
をブリッジしたものを使っていた
が、これをTextFieldにしたら、onEditingChanged:
というクロージャー渡せるところがあって、ここでコントローラーのイベント発火をするとキレイになった
ただよくデバッグしてみると、リターンキーを押さないとonEditingChanged:は呼ばれなかった。
一文字の変更があるたびに呼ばれて欲しかったので、他を探すことにした
で、onReceive(_:perform:)
っていうモディファイアーがあるらしくて、これを使うことにした
ちなみに状態変数にプロパティオブザーバー書いてみたが、これは呼ばれることがなかった
@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)
}
Modelクラス、ObservableObject
にしたいからclassにしてるけど、実際のところstructでも問題ないような気がしてきた
前回のロード結果をいちいちイニシャルしなきゃいけないのがちょっと気になった
AsyncImage
、めちゃくちゃ便利だね
URL叩いてImage取得する
Loaderをasync/awaitで書き直してみた
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
}
}
}
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もプロトコルなので、継承できそうに感じるが、何がダメなんだろう……
ユーザー→リポジトリ一覧への画面遷移で、
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やっていき
@StateObjectがイニシャルできないと思ったら、
struct UserSearchView: View {
@StateObject var viewModel: UserSearchViewModel
init(viewModel: UserSearchViewModel = UserSearchViewModel()) {
_viewModel = StateObject(wrappedValue: viewModel)
}
}
こう書くらしい。@Stateもこれなら外部から更新できるのかな?
ViewModelのprotocolをつくるためにObservableObject
を継承しようとしたらなかなか苦戦してる
SwiftUIが何回レンダリングされてるか知りたいとき、下記が便利
var body: some View {
let temp = "rendering"
let some = print(temp)
let _ = print
にしないのはなんかLintが暴走して勝手にletを消すため
@Publishedのプロパティ更新のたびにムダなレンダリング走るのが気になった
これを試す
↑これを入れた状態で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のシングルトンをEnvironmentValuesにすることでちょっとはマシにできないかな、と思った
ただ環境変数といっても、Viewだけが見られるものになってしまい、その他のクラスが参照できないとしょうがないので、結局.sharedを定義した、普通のシングルトンにする他なさそう
Clean Architectureをやっていくぞ
TCAの解説を読んでいたら、知らない属性が出てきた
@dynamicMemberLookup
`subscript(dynamicMember key: String) -> Any { }' を実装してやることで、動的にプロパティをつけられるらしい
protocolで、protocolに準拠する型に制約をつけたいときは、こんな書き方でイケた模様。
これならObservalObjectのprotocol書けるかな?
XcodeのプレビューがNoBuildableEntriesErrorで全く効かなくなった
NoBuildableEntriesError: active scheme does not build this file
一回Xcode落として、再起動したらなおった。なんだったんだ……
CoordinatorとRouterとの違いってなんだろう?
階層化してるか否か?
そういう認識で良さそう
They are both similar but router manages routing from a single view controller, while coordinator takes care of entire flow.
TCAをやっていく。ラスト!
とりあえずTodoアプリのサンプルを見ている
ここの書き方がよくわからない
ForEachStore(
self.store.scope(state: \.filteredTodos, action: AppAction.todo(id:action:)),
content: TodoView.init(store:)
)
View -> ViewStore -> Reducer -> State更新、という流れなんだけど、Stateってどこで保持してるんだろう……?
実装しよう、となったら、こっちの記事の方がわかりやすいかも
Reducerという言葉の意味がふと気になったが、Action + StateをReduceするから、ということらしい
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の指定ミスってただけだった……)