SwiftUIとCombineの実装

こんにちは、フリーランスの永田です。年始からSwiftUIを実装予定なので、調査を進めています。今回も面接不要で参画になりました。そういえば、その前もエンド企業様には面接不要で参画した形で、徐々に案件の取得スタイルが独特になってきました。

題名のSwiftUIはiOSの新しいプログラム方式です。

今回のサンプル資料です。

https://gist.github.com/daisukenagata/81fee237dcccf60e19a4f1da2829ac65

少し応用編

https://twitter.com/dbank0208/status/1208355364783083522?s=20

上記の挙動の実装コードです。

//
//  ContentView.swift
//  SampleList
//
//  Created by 永田大祐 on 2019/12/19.
//  Copyright © 2019 永田大祐. All rights reserved.
//

import Combine
import SwiftUI

struct ContentView: View {

    @ObservedObject var viewModel = OrientationModel()

    let first = Restaurant(id: 0 ,score: 0.0, login: "a")
    let second = Restaurant(id: 1 ,score: 0.0, login: "b")
    let third = Restaurant(id: 2 ,score: 0.0, login: "c")

    var body: some View {
        NavigationView {
            VStack {
                if !viewModel.users.isEmpty {
                    List(viewModel.users) { restaurant in
                        RestaurantRow(restaurant: restaurant, model: self.viewModel)
                            .onAppear {
                                self.viewModel.fetchImage(for: restaurant)
                        }
                    }
                } else {
                    List([self.first, self.second, self.third]) { restaurant in
                        RestaurantRow(restaurant: restaurant, model: self.viewModel)
                            .onAppear {
                                self.viewModel.fetchImage(for: restaurant)
                        }
                    }
                }
            }
            .navigationBarItems(trailing:
                Button(action: {
                    self.viewModel.users.removeAll()
                }, label: {
                    Text("Rest")
                })
            )
                .navigationBarTitle(Text("Users"))
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

struct Restaurant: Hashable, Identifiable, Decodable {
    var id: Int64? = nil
    var score: Double
    var login: String? = nil
    var avatar_url: URL? = nil
}

struct RestaurantRow: View {
    var urlPath = "https://api.github.com/search/users"
    var restaurant: Restaurant

    @ObservedObject var model: OrientationModel

    var body: some View {
        Text("GitHub login Name \(String(describing: restaurant.login == "" ? "" : restaurant.login!))" +
            "\n" +
            "GitHub Score \(String(describing:  self.restaurant.score.description == "" ? "" : self.restaurant.score.description))")
            .onTapGesture {
                // API Something
                if self.restaurant.id == 0 {
                    self.model.search(urlPath: self.urlPath, name: self.restaurant.login ?? "")
                } else if self.restaurant.id == 1 {
                    self.model.search(urlPath: self.urlPath, name: self.restaurant.login ?? "")
                } else if self.restaurant.id == 2 {
                    self.model.search(urlPath: self.urlPath, name: self.restaurant.login ?? "")
                }
        }
    }
}

final class OrientationModel: ObservableObject {

    @Published var users = [Restaurant]()
    
    @Published private(set) var userName = [String]()

    private var cancellable: Cancellable? {
        didSet { oldValue?.cancel() }
    }
    
    deinit {
        cancellable?.cancel()
    }
        
    func search(urlPath: String, name: String) {
        guard !name.isEmpty else {
            return users = []
        }
        
        var urlComponents = URLComponents(string: urlPath)!
        urlComponents.queryItems = [
            URLQueryItem(name: "q", value: name)
        ]
        
        var request = URLRequest(url: urlComponents.url!)
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        
        print(request)
        cancellable = URLSession.shared.dataTaskPublisher(for: request)
            .map { $0.data }
            .decode(type: SearchUserResponse.self, decoder: JSONDecoder())
            .map { $0.items }
            .replaceError(with: [])
            .receive(on: RunLoop.main)
            .assign(to: \.users, on: self)
    }
    
    func fetchImage(for user: Restaurant) {
        guard let url = user.avatar_url else { return }
        let request = URLRequest(url: url)
        _ = URLSession.shared.dataTaskPublisher(for: request)
            .map { $0.data}
            .replaceError(with: nil)
            .receive(on: RunLoop.main)
    }
}

struct SearchUserResponse: Decodable {
    var items: [Restaurant]
}

最近の有名ライブラリーの中には、objcスタイルの書き方だったり、Appleは最近の書き方だったり、書き方がSwiftUIにおいては様々です。

効率的な進め方

SwiftUIを進める中で、一つ、一つの動くプログラムを進めながら、自然と身に着ける方法を採用しています。

画像のサンプルコード

gist:ab6a7e176cf05c7494794ae75d43a045

例えば画像の変数は、

OrientationModelと@Publishedの修飾子を使うと表示を司るViewの構造体で、変数の変化が表示できるようになります。これを@Publishedはどういうものかという正しい定義から入るとプログラムを覚える中で、理解しづらい部分があります。

Appleの公式で紹介されている文章を直訳すると、クラスを使用するのは理解できましたが、サイトでは紹介コードも記載されていますが、どのような用途か、すぐにはわからないと思います。

属性でマークされたプロパティを公開するタイプ。@ Published属性を持つプロパティを作成すると、このタイプのパブリッシャーが作成されます。以下に示すように、$演算子を使用してパブリッシャーにアクセスします。@ Published属性はクラス制約されています。構造体などの非クラス型ではなく、クラスのプロパティで使用します。

https://developer.apple.com/documentation/combine/published

有名ライブラリーの使用例やtipsを見ながら、実際に実装を繰り返し、メモリー使用量等のパフォーマンス数値を確認して、実装を進めると、理解しやすいと思います。

題名のCombineは非同期イベントの処理をカスタマイズします。

画像のCancellableの部分がCombineのプログラムです。画面のCellの部分をタップするとAPI通信が発生していましたが、2回目の通信の際にoldValueをクリアーして新しいAPIの情報を取得しています。このような処理がメモリーの使用量のコントロールが出来るようになります。

今回のサンプル資料から、使用しているCombineプログラム。

https://gist.github.com/daisukenagata/81fee237dcccf60e19a4f1da2829ac65

簡単なプログラムの中に応用の効く、ロジックを組み込んでいます。それらの数を増やし、汎用的なSwiftUIのロジックを作成する考えです。

来年も皆様、良い一年になりますように

以上、貴重なお時間お読み下さいまして、誠にありがとうございます。