LoginSignup
18
19

More than 1 year has passed since last update.

[SwiftUI]APIを叩くだけから@escapingを使用してMVVMを実装する方法

Posted at

環境

・ Xcode : 12.5 or 13(async/awaitを試す場合)

記事の内容

・APIを叩いてデータを取得する
・取得したデータをViewに反映させる
・escapingを使用してMVVMに書き換える
・async/awaitを導入
・おまけ:非同期のテスト
の手順で行う事で今まで全然理解出来ていなかった自分が
APIを叩いてjsonを取得する→MVVMへリファクタリングができる→新機能を試す事ができる。
ように成長できたので上記の内容をできる限り分かりやすく紹介したいと思います。

APIを叩いてデータを取得する

まずAPIとは何かみたいな話は沢山良記事がありますので割愛させて頂きますが、大まかにAPIを叩いてデータを取得する流れは以下の通りです。
スクリーンショット 2021-06-20 13.38.47.jpg
・アプリ側から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に反映されます。

Simulator Screen Recording - iPhone 11 Pro - 2021-06-20 at 15.37.15.gif

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に指定した時間内に処理ができているかどうかテストできる。

まとめ

サーバー通信を要するアプリって(実務では当然だとは思いますが)結構分かりずらかったり、何となくで処理できたりする部分もあるので色々書き換えを試す事がありませんでしたが、今回記事を書くに辺り学び直せて良い機会でした。とにかくサーバーとの通信なんてリクエスト出してレスポンス受けるだけ。そのデータをどうするかはまた別!と割り切って考えるとサーバー通信の処理のハードルも下がって見え、楽しく試すことができるのでは?と思いました!何事もステップを踏むと高い階段もスムーズに登れると思いますので”ジャンプ”をする事が苦手な方は(筆者がまさにそう)是非少しずつステップを踏んで一緒に学んでいきましょう!

18
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
19