見出し画像

SwiftUIとCombineフレームワークその1

サブタイトル:Timerで試すCombineの初歩

今回は「SwiftUIのデータフローその3」で使ったTimerを利用するコードをCombine(コンバイン)を使って置き換えます
TimerからCombineフレームワークの初歩を確認しましょう。
繰り返し処理の実行と繰り返しのキャンセルまでをサンプルで確認します。
(メモリ管理によるキャンセルのサンプルも含みます)

※この記事ではSwift言語の基本的な知識を前提にしています。
コード内のキーワードや書式などの不明点は Swift5初級ガイド などを参照してください。

※SwiftUIについて全体的なことは『SwiftUI最初の一歩』、コードの基本とビューについては『SwiftUIの文法 その1 View』を参照してください。

お知らせ
電子書籍『Swift5初級ガイド』をAppleのブックストアから出しています。サンプルは無料です。MacでもiPadでもiPhoneでも読めます。
WWDC2020で発表されたSwift 5.3に対応した第6版がダウンロード可能です。(ご購入済みの場合は無料アップデートです)
(2020年7月4日に第6版にアップデートしました)
ブックストアから一度購入すると今後のアップデートは無料で読めます。

6宣伝store

・・・

・画像クリックで拡大表示できます
・画像を拡大表示中は画像の左右をクリックで画像だけを順に表示できます
・ソースコード部分は横にスクロール表示できます

サンプルはXcode 11.6のPlaygroundで作成しMacとiPadの Playgrounds 3.3.1  Xcode 11.7のPlaygroundで動作を確認しました。
この記事の最後(有料部分)にあるリンクから完全なサンプルをダウンロードできます。


1 Combineフレームワークのとは

Combine(コンバイン)はSwift言語でのみ利用可能な2019年にリリースされたフレームワークです。
iOS 13以降、macOS 10.15以降が必要です。
Appleの公式資料の起点は次のオンラインドキュメントです。

1︎⃣Combineフレームワーク(英文)
(1︎⃣から8︎⃣はオンラインドキュメントを区別するための番号です)

Combineの概要はこのwebページに書かれています。

イベント処理演算子(オペレーター)を組み合わせて、非同期イベントの処理をカスタマイズします。

概要
Combineフレームワークは、時間の経過とともに値を処理するための宣言型Swift APIを提供します。
これらの値は、さまざまな種類の非同期イベントを表すことができます。 Combineは、時間とともに変化する可能性のある値を公開するようにパブリッシャーを宣言し、パブリッシャーからそれらの値を受信するようにサブスクライバーを宣言します。
(Google翻訳の翻訳結果より)

※ CombineはSwiftUIとは独立したフレームワークです。

「宣言型Swift APIを提供します」の部分はSwiftUIと共通しますね。


2 Combineの用語

英単語の“combine”は結合するとか合併する(化学だと化合する)といった意味だそうです。

農機具のコンバインは刈り取り機と脱穀機が合体しているため combine harvester と呼ばれるようです。

1︎⃣Combineフレームワーク(英文)の概要の続きには次のように載っています。

● Publisherプロトコルは、時間の経過とともに一連の値を発行できるタイプを宣言します。
パブリッシャーには、上流のパブリッシャーから受け取った値に基づいて行動し、それらを再発行するオペレーターがあります。

蛇足ですが整理すると
・Publisherはプロトコルで定義されている。
(実際にはプロトコルに準拠した型のインスタンスを使い、それをPublisherと呼ぶ)
・Publisherは値を発行する。(ひとつの場合も複数の場合もある)
・Publisherが発行した値を使って何かする「オペレーター」を持つ。
・「オペレーター」は(受け取った値を加工するなどして)値を再発行する。

● 一連のパブリッシャーの最後に、サブスクライバーは受信した要素に作用します。
パブリッシャーは、サブスクライバーから明示的に要求された場合にのみ値を発行します。
これにより、サブスクライバーコードは、接続しているパブリッシャーからイベントを受信する速度を制御できます。

・パブリッシャーチェーンの最後にサブスクライバーが値を受け取り何かしら作用(行動)する。
・パブリッシャーはサブスクライバーからの合図(要求)で値を発行する。

PublisherOperatorSubscriber 三つの重要な語が出てきました。

TimerなどCombineに対応した型ではパブリッシャーもサブスクライバーも既存のものを使うことができます。

これだけではわかりにくいので、ここではTimerを例にコードで説明します。
その前にCombine関連の資料をまとめました。

翻訳ツールにより“publish”を「配信」などと訳する場合もありましたが、ここでは「発行」に置き換え統一しました。


3 Combineの資料

AppleのCombine関連の公式資料です。

2︎⃣Receiving and Handling Events with Combine(英文)

2019年のWWDCのセッション722「Introducing Combine」、セッション721「Combine in Practice」(Developerアプリで表示される日本語タイトルはそれぞれ「Combineの紹介」「実践Combine」)があります。(日本語字幕あり)

Combineの紹介」は既にアプリを作っている開発者向けに紹介する内容です。
Combineの紹介」と「実践Combine」ともに情報量が多めで、初学者はまずCombineの概要をつかんでから見ることをおすすめします。
一部のプロパティラッパー名がリリース時に変更になっています
 ❶ @BindableObject は @ObservableObject に変更
 ❷ @ObjectBinding は @ObservedObject に変更
(※WWDC2019後のフィードバックにより変更になったと思われます)

WWDC2020にはタイトルにCombineのつくセッションはありません。
(もちろんSwiftUIでは使われています)

WWDC2020開催後に公式ドキュメントが増えました。

3︎⃣Controlling Publishing with Connectable Publishers(英文)

4︎⃣Processing Published Elements with Subscribers(英文)

5︎⃣Routing Notifications to Combine Subscribers(英文)

6︎⃣Replacing Foundation Timers with Timer Publishers(英文)

7︎⃣Performing Key-Value Observing with Combine(英文)

8︎⃣Using Combine for Your App’s Asynchronous Code(英文)

これらのドキュメントは 1︎⃣Combineフレームワークのページからリンクしています。

各Operatorのドキュメントに引数の説明などが載っていない場合は、Publisherプロトコルのドキュメントの同じOperator名を探すと詳しく載っているはずです。


4 TimerとCombine

ちょうど6︎⃣「Replacing Foundation Timers with Timer Publishers」が「データフロー3」のカウントダウンタイマーのコードをCombineで置き換えるのに最適でしたので、以下に紹介します。

TimerはFoundationフレームワークのクラスで、一定時間経過後に指定したメッセージを対象オブジェクトに送信する目的で使われます。

Timerはコンピューターの基本的な機能を実現するための型です。
概要は「iOSアプリのしくみ・メモリ管理とタイマー入門」の「タイマーとは」を参照してください。

6︎⃣「Replacing Foundation Timers with Timer Publishers」の最初のコードから順にPlaygroundで確認しましょう。

// 最初のコード
var timer: Timer?
override func viewDidLoad() {
   super.viewDidLoad()
   timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
       self.myDispatchQueue.async() {
           self.myDataModel.lastUpdated = Date()
       }
   }
}

変数 timer を関数の外で定義しているのは、viewDidLoadメソッドを抜けても保持し続ける(必要により繰り返し処理を終了する)ためです。

Timerクラスの scheduledTimer(withTimeInterval:repeats:block:) タイプメソッドを使っています(「SwiftUIのデータフローその3」のコードと同じです)。
最後のblock引数はトレイリングクロージャの書き方で引数( )の外に出しています。
クロージャ内の処理を1秒ごとに繰り返します。
ここではmyDataModelのlastUpdatedプロパティに現在日時を設定しています。

このコードではタイマーをRunLoopに組み込むため、ほかにスレッドがなければDispatchQueueの指定は不要と思いますが、ここではサンプルのコードをそのまま残しています。

4-1 SwiftUIで従来のTimerを利用するコード

6︎⃣の最初のコードをSwiftUIの時刻表示として組み込んだ例です。
上記コードは func timerStart() にあります。

self.myDataModel.lastUpdated = Date() は self.lastUpdated = Date() としています。
SwiftUIではviewDidLoad()を使わないのでこのようにしました。
myDataModelがTimerModel型インスタンスに相当します。

1秒に一度lastUpdatedプロパティが現在時刻で更新されます。
lastUpdatedプロパティを@Published属性にしているので画面は自動更新されます。

@Published属性・ObservableObjectプロトコル・EnvironmentObjectについては「SwiftUIのデータフローその2」と「SwiftUIのデータフローその3」を参照してください。
// サンプル1
// Replacing Foundation Timers with Timer Publishersのサンプル1
// scheduledTimer(withTimeInterval:repeats:block:) 利用版
import SwiftUI
import PlaygroundSupport

class TimerModel: ObservableObject {

  var timer: Timer?
  @Published var lastUpdated: Date

  init() {
     lastUpdated = Date()
  }

  private let myDispatchQueue = DispatchQueue.main

  /// 実際のスタート処理
  func timerStart() {
     timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {_ in
        self.myDispatchQueue.async() {
           self.lastUpdated = Date()
        }
     }
  }
}

struct Sample08View: View {
  @EnvironmentObject var cd: TimerModel

  private var dateFormatter: DateFormatter {
     let formatter = DateFormatter()
     formatter.timeStyle = .medium
     return formatter
  }

  var body: some View {
     VStack {
        Text("\(cd.lastUpdated, formatter: dateFormatter)")
        .font(Font.title.monospacedDigit())
     }
     .onAppear {
        // 表示と同時にスタート
        self.cd.timerStart()
     }
  }
}

// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(
  Sample08View().environmentObject(TimerModel())
)

先頭の
import SwiftUI
import PlaygroundSupport

と最後の
// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(
   Sample08View().environmentObject(TimerModel())
)
は以下のすべてのコードで必要です。

ドキュメントでviewDidLoadメソッド内の処理をTimerModelクラスの func timerStart()の中で使っています。

このコードを実行するとライブビューに簡易デジタル時計を表示します。
timerStart()メソッドはViewのbodyでonAppearでビューの表示と同時に実行しています。

Date()は日付情報を持った現在の日時ですが、ここでは時刻のみ表示しています。

画像2


5 Timer Publisher を使ってみる

ドキュメント6︎⃣の2つ目のコードが Timer Publisher を使うように置き換えたものです。

TimerクラスはCombineのしくみに対応するように拡張されています。
(Xcode 11以降とCombineに対応したバージョンのOSでの実行が条件です)
SwiftUIが実行可能であれば問題なくCombineも利用できます。
// 「Replacing Foundation Timers with Timer Publishers」二つ目のコード
var cancellable: Cancellable?
override func viewDidLoad() {
   super.viewDidLoad()
   cancellable = Timer.publish(every: 1, on: .main, in: .default)
       .autoconnect()
       .receive(on: myDispatchQueue)
       .assign(to: \.lastUpdated, on: myDataModel)
}

プロパティ cancellable を関数の外で定義しているのは、viewDidLoadメソッドを抜けても保持し続けるためです。

コードはSwiftUIのViewモディファイアと同じような書き方をしています。
(.autoconnect()以下の連結したメソッドを各行にわけています

Timerクラスの publish(every:tolerance:on:in:options:) クラスメソッドを使っています。

publish(every:tolerance:on:in:options:)メソッドはTimer.TimerPublisher型インスタンスを返す Combine のしくみを使うためのメソッドです。
ここでは1秒ごとに、.mainのrunLoopで、.defaultのmodeで繰り返す設定です。
(各引数はこの記事の「6 TimerPublisher関連のメソッドと型」で説明します)

.autoconnect() メソッドはタイマーの処理を自動でサブスクライバに接続させるoperatorです。(繰り返し処理を開始させます)

.receive(on:option:)メソッドは最初のコードのself.myDispatchQueue.async()に対応するoperatorです。
(receive(on: myDispatchQueue)はoptionを省略した書き方です)

assign(to:on:) メソッドはTimerPublisherから繰り返し流れてくるデータを指定変数に代入するサブスクライバーです
assign(to:on:)は AnyCancellable 型インスタンスを返すので、これをcancellableプロパティに代入しています。
引数はデータを代入するプロパティをキーパス表記でtoに指定し、インスタンスをonに指定します。
.assign(to: \.lastUpdated, on: myDataModel) はmyDataModel.lastUpdatedにパブリッシャーから届いた最新日時データを代入します。
lastUpdatedプロパティを@Published属性にしているので画面は自動更新されるのは最初のコード(この記事の「4-1 SwiftUIで従来のTimerを利用するコード」)と同じです。

この4行でタイマー処理をCombine版に置き換えています。
TimerPublisherから流れてくる値は最新の日時データなので毎回Date()でインスタンスを作成する必要はありません。

SwiftUIのサンプルに組み込んだコードです。

// サンプル2
import SwiftUI
import Combine
import PlaygroundSupport

class TimerModel: ObservableObject {

  @Published var lastUpdated: Date

  init() {
     lastUpdated = Date()
  }

  private let myDispatchQueue = DispatchQueue.main

  private var cancellable: AnyCancellable?

  /// スタート処理
  func timerStart() {
     cancellable = Timer.publish(every: 1, on: .main, in: .default)
        .autoconnect()
        .receive(on: myDispatchQueue)
        .assign(to: \.lastUpdated, on: self)
  }

  func stop() {
     cancellable?.cancel()
  }
}

struct Sample08View: View {
  @EnvironmentObject var cd: TimerModel

  private var dateFormatter: DateFormatter {
     let formatter = DateFormatter()
     formatter.timeStyle = .medium
     return formatter
  }

  var body: some View {
     VStack {
        Text("\(cd.lastUpdated, formatter: dateFormatter)")
        .font(Font.title.monospacedDigit())
        .padding()
        Button(action: {
           self.cd.stop()
        }, label: { Text("停止") })
        .padding()
     }
     .onAppear {
        // 表示と同時にスタート
        self.cd.timerStart()
     }
  }
}

// playgroundで実行する場合に必要なコード
PlaygroundPage.current.setLiveView(
  Sample08View().environmentObject(TimerModel())
)

import Combine の追加が必要です。
(AnyCancellableクラスやCancellableがCombineフレームワークのため必要)
cancellableプロパティはここではAnyCancellable型にしています。

Cancellableはプロトコルです。
AnyCancellableはCancellableに準拠したクラスです。

Combineを使う場合はTimer型インスタンスは不要なので var timer: Timer? は削除しています。

まずは実行し動作することを確認してください。
画面はiPadのPlaygroundsでの実行例です。

画像3

では、コードを確認しましょう。


6 TimerPublisher関連のメソッドと型

TimerをCombineのしくみで使うには publish(every:tolerance:on:in:options:)メソッドが返すTimerPublisher型インスタンスを利用します。

6-1 Timer.TimerPublisher クラス

現在の日付を指定された間隔で繰り返し発行するパブリッシャーです
ConnectablePublisherプロトコルに準拠したクラスです。

オンラインドキュメントは Timer.TimerPublisherクラス です。(Publisherプロトコルに準拠しているのでオペレーターのメソッドが多数載っています)

ConnectablePublisherプロトコルに準拠しているのでTimer.TimerPublisherはautoconnect()メソッドまたはconnect()メソッドで接続しなければ繰り返し動作を開始しません
ConnectablePublisherプロトコルはPublisherプロトコルを含みます。

続きをみるには

残り 15,458字 / 5画像 / 1ファイル
この記事のみ ¥ 500

今後も記事を増やすつもりです。 サポートしていただけると大変はげみになります。