Swiftで遊ぼう! on Hatena

あしたさぬきblogでやってた初心者オヤジのiOSプログラミング奮闘記がHatenaに来ました

Swiftで遊ぼう! - 1015 - URLSessionDataTaskPublisherの基礎

URLSessionの基本をまとめたのが5年前...

yataiblue.hatenablog.com

5年経過したので、NSURLは消えてしまったようですが、URLSessionはネットからデータを取り込むために今でも利用されている。しかし、新しいフレームワークであるCombineと組み合わせて利用するのが一般的になってきている。Combineは「関数型プログラミング」を実現するためのフレームワークであり、SwiftUIと組み合わせることで、最も新しいiOS開発環境ということになるだろう。そこで、今回、Combineに関して少々勉強できたので、5年前のデモを新しく書き直してみた。

当初は、上記のリンクの記事を更新するつもりでしたが、SwiftUIとCombineを使って開発をすると、Autolayoutは使わないし、非同期処理も違うので、古い記事はそのままにして、新しい記事を書いてみた。

新しいプロジェクトを作ります。「Simple View Application」を選び、「User Interface」を必ず「SwiftUI」にする。そして、プロジェクト名は適当につけますが、私は「URLSessionDataTaskPublisherDemo」にしました。

SwiftUIの基本的な扱いに関して説明はしません。分からない人はSwiftUIのチュートリアルをこなしてください。SwiftUIの特徴は、MVC開発からMVVM開発への変化でないかと思ってるんですが、まだ私も勉強中で分からないことだらけ。ContentViewでユーザーインターフェイスの開発に集中。そして、Modelで処理させて、Combineなどの通知機能を利用することで自動的にViewと連携させてやるという考えじゃないだろうか。今まで使用していたUIViewContorollerも使いません。私の嫌いなAutolayoutも必要無いので、処理をどうすればいいか開発に集中できるようになっているので、これからiOS環境を勉強し始める人が羨ましいですね。

さて、画面上のボタンを押して、Amazonのサイトから画像をダウンロードさせるだけですが、その処理をContentViewには加えません。新しいModelになるファイルを作りますメニューから「File」→「New」→「File...」を選択します。そして「Swift File」を選択してから名前を「ImageLoader」にしてプロジェクト内に加えます。「import Foundation」しかないので、次のように「SwiftUI」と「Combine」を加えてください

import Foundation
import SwiftUI
import Combine

さて次に、アプリを立ち上げた時に表示するイメージを用意します。アプリのテスト環境は「iPhone 11」にします。すると、プロジェクトで使用するイメージのサイズは2種類でいいんです*1。先ず、プロジェクト・ナビゲータでAssets Xcassetsを選択して、下部にある「+」ボタンを選び、「New Folder」を選択します。フォルダーの名前を「Placeholder」に変更してから「+」ボタンを再び選択して「New Image Set」を選ぶと「1X」「2X」「3X」の空のスロットができます。

次に何でもいいので「png」イメージを用意します。オブジェクトのサイズを「240×240ピクセル」にしているので、1Xはそのまま、2Xは「480×480ピクセル」、そして3Xは「720×720ピクセル」の画像を用意するのですが、ここでは2Xスロットだけセットします。そして、名前を「backgroundImage」に変更します。

f:id:yataiblue:20200329143128j:plain

これで画像は用意できたので、ImageLoaderに初期値として、先ほど用意したbackgroundImageを持つUIImageを持たせるためにコードを次のように書きます。

import Foundation
import SwiftUI
import Combine

class ImageLoader: ObservableObject {
    @Published var image = UIImage(named: "backgroudImage")
    
}

ここで、2つの重要なキーワードが出てきました。ObservableObject@Publishedキーワードです。実はこのObservableObjectですが、Combineに含まれるんです。でも、Combineフレームワークをimportしなくても、SwiftUIのみで使えるため、最初に私は混乱しました。SwiftUIを使って、システム標準の通知機能を使うだけならCombineフレームワークは必要ないのです。外部との通信や、通知機能を自作するためにCombineが用意されています。また、非同期処理もCombineでさせる方が処理させやすいということですが、まだまだ私の知識で使いこなせるとは思ってません。

ここで用意したimgaeをContentViewで表示させてみます。次のコードをContentViewに加えます。

struct ContentView: View {
    @ObservedObject var loader = ImageLoader()
    
    var body: some View {
        VStack {
            Image(uiImage: loader.image!)
                .resizable()
                .frame(width: 240, height: 240)
                .scaledToFit()
        }
    }
}

そしてランしてみましょう。当然プレビューをみてもかまいません。次のようになっているでしょう。f:id:yataiblue:20200329151436p:plain
次にAmazonから画像データをダウンロードさせる関数をModelに用意させるのです。ここでCombineフレームワークを使います。Combineフレームワークを利用するために、Publisher、Subscriber、そしてOperatorを利用しなけらばなりません。自分で作るクラスなら、それぞれのProtocolやExtensionに準拠させないといけないのですが、URLSessionにはCombineが利用できるようになっています。URLSessionで用意されているPublisherがDataTaskPublisherなんです。このDataTaskPublisherを使ってSubsriberを作り、情報処理をOperatorでするっていう流れです。従って、DataTaskPublisherを利用すると、必ずData、URLResponnse、URLErrorの処理をしなければならないので、利用するしないに関係なく対応をしなければなりません。ということで、ImageLoaderクラスに次の変数を持たせます。

class ImageLoader: ObservableObject {
    @Published var image = UIImage(named: "backgroundImage")
    var cancellable: Cancellable? = nil
    var data: Data? = nil
    var response: URLResponse? = nil
    var error: URLError? = nil
    }
}

cancellableがSubscriberですが、Pubulisherの宣言がないですよね。Publisherはシングルトン利用するので、インスタンスは作らないことにします。

次の関数を加えます。

func fetchImage() {
        let url = URL(string: "https://s3.amazonaws.com/
              CoverProject/album/album_david_bowie_lets_dance.png")
        cancellable = URLSession.shared.dataTaskPublisher(for: url!)
            .print("subscriber now")
            .eraseToAnyPublisher()
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    case .finished:
                        break
                    case .failure(let error):
                        fatalError(error.localizedDescription)
                    }
            },receiveValue: {
                let image = UIImage(data: $0.data)!
                self.image = image
            })
    }

これで自分が持っているイメージがダウンロードされたイメージと変わります。では、ContentViewにボタンを加えましょう。

struct ContentView: View {
    @ObservedObject var loader = ImageLoader()
    
    var body: some View {
        VStack {
            Image(uiImage: loader.image!)
                .resizable()
                .frame(width: 240, height: 240)
                .scaledToFit()
            Button(action: loader.ftechImage,
                   label: { Text("Get Image") })
        }
    }
}

じゃあランしてボタンを押してみるといいでしょう。次のようにデビッド・ボウイのジャケットが今も変わらずダウンロードできますよ。
f:id:yataiblue:20200329155509p:plain
実はImageLoaderクラスにUIResponseやUIErrorのプロパティを持たせましたが、$0のデータしか使っていないので、流れてきたデータを殆ど捨ててます。そして、このときのデータの流れがコンソールに出るのですが、色々な情報が紛れていて興味深かったです。まだまだCombineの勉強が必要なので、もう少し勉強します。

*1:理由がわからなければSwiftで遊ぼう! - 249 - iOSの座標システム: 2020年3月 - Swiftで遊ぼう! on Hatenaで説明しているScaling値を確かめましょう。