- 投稿日:2021-06-20T23:02:46+09:00
【AWS Amplify、iOS】"Cannot find 'AmplifyModels' in scope" の原因が Target Membership の設定だった
AWS Amplify の iOS アプリ向けチュートリアルをしていた際、 Initialize Amplify DataStore で次のコードを AppDelegates.swift に追加した時、 let dataStorePlugin = AWSDataStorePlugin(modelRegistration: AmplifyModels())の部分で Cannot find 'AmplifyModels' in scope というエラーが表示されました。 swift func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { do { // AmplifyModels is generated in the previous step let dataStorePlugin = AWSDataStorePlugin(modelRegistration: AmplifyModels()) // ← ここでエラー ( Cannot find 'AmplifyModels' in scope ) try Amplify.add(plugin: dataStorePlugin) try Amplify.configure() print("Amplify configured with DataStore plugin") } catch { print("Failed to initialize Amplify with \(error)") return false } return true } 原因は、 AmplifyModels クラスが定義されている AmplifyModels.swift の Target Membership が [アプリ名]UITest のみだったためでした。 amplify codegen models コマンドでこのファイルをプロジェクトに追加した際、 [アプリ名]UITest のみが Target Membership に設定されていたようです。 バージョン xcode: 12.5 amplify cli: 5.0.1
- 投稿日:2021-06-20T23:02:46+09:00
[AWS Amplify DataStore] [iOS] "Cannot find 'AmplifyModels' in scope" の原因が Target Membership の設定だった
AWS Amplify DataStore の iOS アプリ向けチュートリアルをしていた際、 Initialize Amplify DataStore で次のコードを AppDelegates.swift に追加した時、 let dataStorePlugin = AWSDataStorePlugin(modelRegistration: AmplifyModels())の部分で Cannot find 'AmplifyModels' in scope というエラーが表示されました。 swift func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { do { // AmplifyModels is generated in the previous step let dataStorePlugin = AWSDataStorePlugin(modelRegistration: AmplifyModels()) // ← ここでエラー ( Cannot find 'AmplifyModels' in scope ) try Amplify.add(plugin: dataStorePlugin) try Amplify.configure() print("Amplify configured with DataStore plugin") } catch { print("Failed to initialize Amplify with \(error)") return false } return true } 原因は、 AmplifyModels クラスが定義されている AmplifyModels.swift の Target Membership が [アプリ名]UITest のみだったためでした。 amplify codegen models コマンドでこのファイルをプロジェクトに追加した際、 [アプリ名]UITest のみが Target Membership に設定されていたようです。 バージョン xcode: 12.5 amplify cli: 5.0.1
- 投稿日:2021-06-20T17:49:40+09:00
カスタムクラスのリストを、UserDefaultsに保存する。
はじめに 以下のようなカスタムクラスのリストをUserdefaultsに保存しようとすると、ビルドは通りますがエラーが出てしまいます。 import Foundation class Task: Codable { var body: String var isCompleted: Bool init(body: String, isCompleted: Bool) { self.body = body self.isCompleted = isCompleted } } // インスタンス化して保存 let task = Task(body: "こんにちは", isCompleted: false) UserDefaults.standard.setValue(task, forKey: "hoge") // エラー文 // A default object must be a property list—that is, an instance of (or for collections, // a combination of instances of) NSData, NSString, NSNumber, NSDate, NSArray, or NSDictionary. // If you want to stfore any other type of object, you should typically archive it to create an instance of NSData. これはなぜかというと、UserDefaultsに保存できるのは、エラー分の通りNSData, NSString, NSNumber, NSDate, NSArray, NSDictionaryとなっているからで、カスタムクラスはこれに準拠していないからです。 解決策として、カスタムクラス型をNSData型に変換して保存し、読み出すときはNSData型が読み出されるので、それを再度カスタムクラス型に変換する、ということを考えます。実装したコードは次のとおりです。 実装 まず、先程のカスタムクラスにCodableに準拠させます。名前の通り「NSData型に変換できるよ!」みたいなイメージです。 class Task: Codable { そして、次のファイルを作成します。 JsonEncoder.swift class JsonEncoder { class func saveItemsToUserDefaults<T: Codable>(list: [T], key: String) { let data = list.map { try! JSONEncoder().encode($0) } UserDefaults.standard.set(data as [Any], forKey: key) UserDefaults.standard.synchronize() } class func readItemsFromUserUserDefault<T: Codable>(key: String) -> [T] { guard let items = UserDefaults.standard.array(forKey: key) as? [Data] else { return [T]() } let decodedItems = items.map { try! JSONDecoder().decode(T.self, from: $0) } return decodedItems } } そして次のように呼び出すことで、カスタムクラスのリストをUserdefaultsに保存することが出来ます。 // 保存処理 JsonEncoder.saveItemsToUserDefaults(list: taskList, key: "hoge") // 読み出し処理 let taskList: [Task] = JsonEncoder.readItemsFromUserUserDefault(key: "hoge") 解説 saveItemsToUserDefaultsはカスタムクラスのリストを受け取り、JSONに変換して保存していて、eadItemsFromUserUserDefaultはその逆の処理をしているのがわかると思います。 またT: CodableというふうにTが型みたいになっているのは、これはジェネリクスという概念で、引数の型を複数指定することを可能にします。今回では、「CodableならなんでもOKだよ!」というふうなイメージです。ジェネリクスで共通化をしたので、どのようなカスタムクラスでも保存ができるようになっています。 さらに引数の型にはCodableに準拠したカスタムクラスはもちろん、CodableはEncodableプロトコルとDecodableプロトコルを合わせて作られたので、これに準拠した方も引数に取ることが出来ます。 また読み出し処理において、定義する変数の型を指定してあげないと、ジェネリクス側が型推論できなくなってしまい、コンパイルエラーが起きるので注意してください。
- 投稿日:2021-06-20T17:09:14+09:00
[SwiftUI]APIを叩くだけから@escapingを使用してMVVMを実装する方法
環境 ・ Xcode : 12.5 or 13(async/awaitを試す場合) 記事の内容 ・APIを叩いてデータを取得する ・取得したデータをViewに反映させる ・escapingを使用してMVVMに書き換える ・async/awaitを導入 ・おまけ:非同期のテスト の手順で行う事で今まで全然理解出来ていなかった自分が APIを叩いてjsonを取得する→MVVMへリファクタリングができる→新機能を試す事ができる。 ように成長できたので上記の内容をできる限り分かりやすく紹介したいと思います。 APIを叩いてデータを取得する まずAPIとは何かみたいな話は沢山良記事がありますので割愛させて頂きますが、大まかにAPIを叩いてデータを取得する流れは以下の通りです。 ・アプリ側からrequestを出す ・サーバー側からresponseを受ける これだけです。 では実際に書いてみます。 request guard let getUrl: URL = URL(string: "https://api.github.com/search/repositories?q=\(query)") else { return } let request = URLRequest(url: getUrl) response URLSession.shared.dataTask(with: request) { (data, response, error) in { //ここにデータを受け取った後にして欲しい処理を書く(主に非同期処理など) } GetとFetch 余談ですが、よくrepuestのURLを取得する際、プロパティ名をget〇〇、”APIを叩いてデータを取得する”メソッドをfetch〇〇を使用しているケースを見かけます。 これは ・get=あるものを取得する時に使用 ・fetch=ここに無いものを取得する時に使用 という意味があるので、ファイル内にgetUrlなどプロパティ名を作成し、request用のURLを仕込んでおくケースや、今は無いが、APIを叩いてデータを取得するメソッド名がfetch〜が多いですし、分かりやすいです。(命名は分かりやすくが大事なのでgetやfetchを使い分けて命名する事をおすすめします) requestを出す事とサーバー側からresponseを受ける方法は上記の通りですが、これだけではアプリ側で扱う事ができないので以上に加え、データを受け取れる構造体と受け取ったデータ(今回はjson)をdecodeしていきます。ちなみにfetchメソッドはここまでの流れを指す事が基本であると解釈しています。 その為メソッド内が複雑に見えますが一つ一つ分解してみるとよく分かるので丁寧に一個ずつ潰していくといいかと思います。 ではメソッド全体のコード class FetchUser: ObservableObject { @Published var searchedRepository: [Item] = [] @Published var query = "" func fetcher() { // seawrchedRepositoryを最初に空にしておく事で検索せるようにしています searchedRepository.removeAll() guard let getUrl: URL = URL(string: "https://api.github.com/search/repositories?q=\(query)") else { return } // request let request = URLRequest(url: getUrl) let decoder = JSONDecoder() //スネークケースをキャメルケースへ変換してくれる ※全てキャメルケースに書き換える必要がある decoder.keyDecodingStrategy = .convertFromSnakeCase URLSession.shared.dataTask(with: request) { (data, response, error) in // データを受け取った後の処理 guard let jsonData = data else { return } do { let repositories = try decoder.decode(Repositories.self, from: jsonData) // 非同期処理 DispatchQueue.main.async { self.searchedRepository.append(contentsOf: repositories.items) } } catch { print("error1") } } .resume() } } 構造体 struct Owner: Decodable { var avatarUrl: String? } struct Item: Decodable { var nodeId: String? var fullName: String? var owner: Owner var stargazersCount: Int? var watchersCount: Int? var language: String? var forksCount: Int? var openIssuesCount: Int? } struct Repositories: Decodable { var items: [Item] } オプショナルにする 余談ですが上記の構造体でオプショナルにしています。これはそれぞれのキーが必ずしも入っているとは限らず、nullを返してくるケースがあります。(筆者はここでハマった)よってオプショナルにして、空であった場合は””もしくは0にするなどで安全に処理できるように対応しておくと良いかと思います。 取得したデータをViewに反映させる 先ほど作成したファイルをViewに反映させていきます。 struct RepositoryListView: View { @ObservedObject var fetchUser = FetchUser() var body: some View { NavigationView{ VStack { TextField("input user", text: $fetchUser.query, onCommit: { fetchUser.fetcher() }) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding() List(fetchUser.searchedRepository, id: \.nodeId) { user in Text(user.fullName ?? "") } .navigationTitle("Repository List") } } } } TextFieldに書いた文字をFetchUserクラスのqueryに渡してonCommitにより、エンター時にfetcherメソッドを実行する様にしています。 また実行されたメソッドは DispatchQueue.main.async { self.searchedRepository.append(contentsOf: repositories.items) } によりsearchedRepositoryへ格納されていきます。格納されたデータはリスト表示されItemに準拠したデータを引っ張ってくる事ができます。(ここではfullNameをリスト表示しています) またSwiftUIでは@Published属性を付けたプロパティがSwiftUIの監視対象となり、値が変更されると参照しているViewが自動的に再描画されるので 検索したい文字を打つ→エンターと同時にfetcherメソッドのgetUrlに反映→リクエストを出す→レスポンスが帰ってくる→decodeして配列に追加する→Viewに反映されます。 escapingを使用してMVVMに書き換える ここからは更に個人的にMVVMで書いたり、非同期処理のTestを書いてみたかったので挑戦してみた話です。 まず上記に紹介した書き方はPublishedとfetchメソッドが同じクラスにあるのでこれをModel側とViewModel側に分ける必要があります。(MVVMの詳しい解説は他に良記事が多々あるのでそちらをご覧下さい) しかし現状のままではメソッドを実行した後に取得したデータを他のクラスへ保存することができません。 そこで今回はescapingを使用しました。 ちなみにescaping属性とは 関数に引数として渡されたクロージャが、関数のスコープ外で保持される可能性がある事を示す属性 (引用:Swift実践入門) とあるように関数のスコープ外で保持をするので使用していきます。 Model側 func fetchUserRepository(query: String, completion: @escaping ([Item]) -> Void) { guard let url: URL = URL(string: "https://api.github.com/search/repositories?q=\(query)") else { return print("URL Error") } URLSession.shared.dataTask(with: url) { (data, response, error) in guard let jsonData = data else { return print("Json Error") } do { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase let repositories = try decoder.decode(Repositories.self, from: jsonData) DispatchQueue.main.async { completion(repositories.items) } } catch { print("items Decoder Error") } } .resume() } ViewModel側 // Viewとのバインディング変数プロパティ @Published var itemData: [Item] = [] @Published var query = "" let fetched = FetchUserRepository() func fetchRepository() { itemData.removeAll() self.fetched.fetchUserRepository(query: query) { (items) in self.itemData.append(contentsOf: items) } } またModel側を protocol Fetcher { func fetchUserRepository(query: String, completion: @escaping ([Item]) -> Void) } class FetchUserRepository: Fetcher { func fetchUserRepository(query: String, completion: @escaping ([Item]) -> Void) { guard let url: URL = URL(string: "https://api.github.com/search/repositories?q=\(query)") else { return print("URL Error") } URLSession.shared.dataTask(with: url) { (data, response, error) in guard let jsonData = data else { return print("Json Error") } do { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase let repositories = try decoder.decode(Repositories.self, from: jsonData) DispatchQueue.main.async { completion(repositories.items) } } catch { print("items Decoder Error") } } .resume() } } とする事でFetcherと言うprotocolに準拠させ、更に依存関係をなくす事ができます。 ViewModel側 class SearchViewModel: ObservableObject { // 疎結合 private let fetchUser: Fetcher init(fetchUser: Fetcher) { self.fetchUser = fetchUser } // Viewとのバインディング変数プロパティ @Published var itemData: [Item] = [] @Published var query = "" func fetcher() { let queryText = query fetchUser.fetchUserRepository(query: queryText) {(item) in self.itemData.append(contentsOf: item)} } } async/awaitを導入してみる class FetchUser: ObservableObject { @Published var searchedRepository: [Item] = [] @Published var query = "" // async/await func fetchUsers()async { let url = URL(string: "https://api.github.com/search/repositories?q=\(query)") guard let getURL = url else { return print("urlError") } let session = URLSession(configuration: .default) let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase do{ let task = try await session.data(from: getURL) let repositories = try decoder.decode(Repositories.self, from: task.0) self.searchedRepository.append(contentsOf: repositories.items) print(repositories.items[0].language ?? "") } catch { print("error") } } 使用したいViewで .refreshable (action: { fetchUser.searchedRepository.removeAll() await fetchUser.fetchUsers() }) とするとrefreshableによって非同期処理によく使用するindicatorが表示され、非同期処理されます。 おまけ:非同期のテスト 非同期処理のテストについてはIOSテスト全書をまず見ることをお勧めしますが、待機時間を作ることで、時間内に処理ができたかどうかで判定し、非同期処理ができている事を担保するので、必ず待機時間を作る。 let fetcher = FetchUserRepository() let testQuery = "swift" let itemData:[Item] = [] func testAsync() { let exp = XCTestExpectation(description: testQuery) fetcher.fetchUserRepository(query: testQuery) { item in XCTAssertEqual(item[0].fullName!, "apple/swift") exp.fulfill() } wait(for: [exp], timeout: 5.0) } 本来はモックなどを作成して行うことが良いのだろうが今回は非同期処理のテストにのみ照準を当てたので上記とした。 大事なのは wait(for: [exp], timeout: 5.0) の部分でここでtimeoutに指定した時間内に処理ができているかどうかテストできる。 まとめ サーバー通信を要するアプリって(実務では当然だとは思いますが)結構分かりずらかったり、何となくで処理できたりする部分もあるので色々書き換えを試す事がありませんでしたが、今回記事を書くに辺り学び直せて良い機会でした。とにかくサーバーとの通信なんてリクエスト出してレスポンス受けるだけ。そのデータをどうするかはまた別!と割り切って考えるとサーバー通信の処理のハードルも下がって見え、楽しく試すことができるのでは?と思いました!何事もステップを踏むと高い階段もスムーズに登れると思いますので”ジャンプ”をする事が苦手な方は(筆者がまさにそう)是非少しずつステップを踏んで一緒に学んでいきましょう!
- 投稿日:2021-06-20T10:53:30+09:00
【個人的備忘】Swiftで端末の言語設定に合わせてテキストを切り替える
let langId = Locale.current.identifier.prefix(2) let langInt: Int init() { switch langId { case "ja": self.langInt = 0 case "en": self.langInt = 1 case "fr": self.langInt = 2 default: self.langInt = 1 } } func GetText(textId: Int) -> String { return texts[textId][langInt] }
- 投稿日:2021-06-20T06:29:32+09:00
iOS13から使える@StateObjectを作る
概要 iOS14以降でしか使えない@StateObjectをiOS13でも使えるようにするため同じ挙動をするPropertyWrapperを作成した。 コード StateObservedObject.swift import Combine import SwiftUI @available(iOS 13.0, *) @propertyWrapper public struct StateObservedObject<T: ObservableObject>: DynamicProperty { @State @Lazy private var object: T @ObservedObject private var updater = StateObservedObjectUpdater() @dynamicMemberLookup public struct Wrapper { let value: T let update: () -> Void public subscript<Subject>(dynamicMember keyPath: ReferenceWritableKeyPath<T, Subject>) -> Binding<Subject> { .init( get: { value[keyPath: keyPath] }, set: { value[keyPath: keyPath] = $0 update() } ) } } public var projectedValue: Wrapper { .init(value: object, update: _updater.wrappedValue.objectWillChange.send) } public var wrappedValue: T { get { object } set { object = newValue } } public init(wrappedValue: @autoclosure @escaping () -> T) { self._object = State(wrappedValue: Lazy(wrappedValue)) } // NOTE: - DynamicPropertyのViewが更新される直前に呼ばれるメソッド public mutating func update() { // Stateはupdateの中で直前のインスタンスに置き換えているので、置き換えた後(つまり一番最初に作られれたインスタンス)の中のupdaterのみを置き換え _object.wrappedValue.update = _updater.wrappedValue.objectWillChange.send } } @available(iOS 13.0, *) extension StateObservedObject { @propertyWrapper private class Lazy { let lazyValue: () -> T var cached: T? var update: (() -> Void)? private var cancellableSet: Set<AnyCancellable> = [] init(_ value: @escaping () -> T) { lazyValue = value } var wrappedValue: T { get { if let cached = cached { return cached } cached = lazyValue() cached? .objectWillChange .sink { [weak self] _ in self?.update?() } .store(in: &cancellableSet) return cached! } set { cached = newValue } } } } @available(iOS 13.0, *) private class StateObservedObjectUpdater: ObservableObject { } 解説 まずView外部の値を更新するものなので、DynamicProperty (Viewの外部プロパティを更新する格納変数)に適応させます。 DynamicPropertyはViewのBodyの中身を再計算するより前に値が与えられる。View.bodyの再生成される直前にDynamicProperty.update()が呼ばれるので、Stateなどはここで前回のインスタンスを再代入している。 今回内部に値を保持するためにStateで値を持ち、Viewの更新のためにObservedObjectでupdate用のObservableObjectを保持した。 public struct StateObservedObject<T: ObservableObject>: DynamicProperty { @State @Lazy private var object: T // このStateが前回の値を保持してくれる @ObservedObject private var updater = StateObservedObjectUpdater() // updater.objectWillChange.send()することでViewを更新する。 objectはLazyクラスをvalueとしたStateとして持ち、クロージャで渡すことで余計な評価をしないようにしている。StateObservedObjectのinitはViewが更新されるたびに呼ばれ、その度に新しいLazyクラスをvalueとしたStateが生成されるが、実際にはStateのupdate()関数内で破棄されているので、初回に生成されたもの以外は使用されない。 また、画面更新用のupdaterは都度更新されるので、State内のインスタンスが置き換わった後に内部のupdateクロージャを置き換えるようにしている。 public init(wrappedValue: @autoclosure @escaping () -> T) { self._object = State(wrappedValue: Lazy(wrappedValue)) // <- 初回以外のここで生成されたインスタンスは下記のupdate()時には破棄されている。(正確にはState.update()の中で) } // NOTE: - DynamicPropertyのViewが更新される直前に呼ばれるメソッド public mutating func update() { // Stateはupdateの中で直前のインスタンスに置き換えているので、置き換えた後(つまり一番最初に作られれたインスタンス)の中のupdaterのみを置き換え _object.wrappedValue.update = _updater.wrappedValue.objectWillChange.send } またStateObservedObjectのprojectedValueはStateやObservedObjectと(おそらく)同じように、KeyPathとDynamicMemberLookupを使用し、Bindingとして公開することで参照を子Viewに渡すことできるようになっている。 @dynamicMemberLookup public struct Wrapper { let value: T let update: () -> Void public subscript<Subject>(dynamicMember keyPath: ReferenceWritableKeyPath<T, Subject>) -> Binding<Subject> { .init( get: { value[keyPath: keyPath] }, set: { value[keyPath: keyPath] = $0 update() } ) } } public var projectedValue: Wrapper { .init(value: object, update: _updater.wrappedValue.objectWillChange.send) } 確認用コード @available(iOS 13.0, *) final class Counter: ObservableObject { @Published var number = 0 } @available(iOS 13.0.0, *) struct SwiftUIPlayGroundView: View { @StateObservedObject var counter = Counter() var body: some View { VStack(spacing: 32) { HStack { Text("TopObject") Spacer() Text("\(counter.number)") Button(action: { counter.number += 1 }) { ZStack { Color.blue .frame(width: 32, height: 32, alignment: .center) Text("+") .foregroundColor(.white) .fontWeight(.bold) } } } ObservedCounterView() StateCounterView() BindingCounterView(counter: Counter) } .padding(.all, 16) } } @available(iOS 13.0.0, *) struct ObservedCounterView: View { @ObservedObject var var counter = Counter() var body: some View { HStack { Text("ObservedObject") Spacer() Text("\(counter.number)") Button(action: { counter.number += 1 }) { ZStack { Color.blue .frame(width: 32, height: 32, alignment: .center) Text("+") .foregroundColor(.white) .fontWeight(.bold) } } } } } @available(iOS 13.0.0, *) struct StateCounterView: View { @StateObservedObject var counter = Counter() var body: some View { HStack { Text("StateObject") Spacer() Text("\(counter.number)") Button(action: { counter.number += 1 }) { ZStack { Color.blue .frame(width: 32, height: 32, alignment: .center) Text("+") .foregroundColor(.white) .fontWeight(.bold) } } } } } @available(iOS 13.0.0, *) struct BindingCounterView: View { @ObservedObject var counter: Counter var body: some View { HStack { Text("BindingObject") Spacer() Text("\(counter.number)") Button(action: { counter.number += 1 }) { ZStack { Color.blue .frame(width: 32, height: 32, alignment: .center) Text("+") .foregroundColor(.white) .fontWeight(.bold) } } } } } 実行結果 おわりに 今回こちらの実装を作るにあたってこちらの記事を参考にさせていただきました。筆者のたなたつさん、ありがとうございます。一度この通りに書いてみたところ、値の更新がうまくいかなかったので、内部でobjectWillChangeを監視してupdate()するようにしました。(もしかしたら僕がミスしているだけかもしれないので、こうすればいいよ的な意見があればコメントしていただけると助かります。) 一番やっかいだったのは、Stateの内部で参照している値どのタイミングで変更されているのか、が全然掴めなかったことです。各場面でインスタンスIDを表示して確認することで時間はかかりましたがinit後のupdate直前で更新されていることを確認できました。 SwiftUIに関してはまだまだ手探りな感じですが誰かの役にたてれば幸いです。 参考 WWDC Extended Tokyo 2021: 既存アプリにSwiftUIをどう組み込んでいくか