見出し画像

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

サブタイトル:通信で確認するCombineのはたらき

今回はインターネットアクセスをCombine(コンバイン)を使って行います。
SwiftUIとCombineフレームワークその1」のつづきです。
Combineのオペレーターも使い画像やテキストを受信して表示したり、JSONデータを受信しデコードするサンプルを説明します。
初級向けに詳しく解説していますが、タイムアウトやエラー処理まで含む実用的な入門記事です。
さあCombineを本格的に使いましょう。

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

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

お知らせ
電子書籍『Swift5初級ガイド』をAppleのブックストアから出しています。
文字サイズを好みに変更でき、本文を検索可能な電子書籍です。
無料サンプルでご確認ください。
MacでもiPadでもiPhoneでも読めます。
Xcode 12に対応した第7版がダウンロード可能です。(ご購入済みの場合は無料アップデートです)
(2020年10月12日に第7版にアップデートしました)
Swift 5.3は第6版で対応済みです。
ブックストアから一度購入すると今後のアップデートは無料で読めます。

7宣伝store

・・・

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

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

Xcode12のみ一部のソースに追加が必要な部分があります


1 インターネットと通信

通信機能がコンピューターの使い道を爆発的に拡張しました。
そして通信可能で持ち運べるコンピューターとして登場したスマートフォンにとって欠かせない機能が「インターネットとの通信」です。
通信が有益なのはもちろん『インターネット』の世界につながるからです。

この記事も共通の機能を持つ端末からいつでも、どこからでも読むことができます。

ここでは「インターネットとの通信」を画像、テキスト、JSONの各データの取得を例にサンプルで確認します。

インターネットの通信環境については、みなさん日々いろいろ実感されているとおもいます。
通信状況は様々です。
すんなりつながらず、待たされたり、途中で切れたり、結局つかがらなかったりします。

通信でデータをやりとりするコードはこのような状況に対応しなくてはなりません。
SwiftUIで使える通信用のしくみにURLSession(ユー・アール・エル・セッション)があります。

SwiftUIとCombineフレームワークその1」に書きましたが『Combineフレームワークは、時間の経過とともに値を処理するための宣言型Swift APIを提供します。』
通信を扱うのにCombineはうってつけです。
非同期の通信処理をシンプルに扱うために開発されています)

1-1 URLSession

URLSessionはクラスとしてFoundationフレームワークに実装されています。
URLSessionクラスとその関連クラスは、URLで示されたエンドポイントからデータをダウンロードしたり、URLで示されたエンドポイントにデータをアップロードしたりするためのAPIを提供しています。
使いこなしには通信のしくみも含めた広範囲のスキルが必要です。

詳細はドキュメント(英文)を参照してください。

URLSessionはCombineに対応した dataTaskPublisher(for:) メソッドを持ちます。
dataTaskPublisher(for:) を使うとシンプルに通信処理のコードを書くことができます。
こちらは「Processing URL Session Data Task Results with Combine」(英文)に解説が載っています。

この記事では ❶画像をダウンロードして表示する、❷テキストを受信して表示する(HTTPステータスのチェックあり)、❸JSONをテキストとして受信して表示する(タイムアウト処理あり)、❹JSONを受信してデコードしデータを利用する これら4種類のサンプルを詳しく解説します。

1-2 URLSessionのインスタンス

通常クラスのインスタンスはイニシャライザーで生成しますが、URLSessionは共有のシングルトンオブジェクトを利用できます。

// 手軽に使えるURLSessionインスタンスclass var shared: URLSession { get }

クラスプロパティなので URLSession.shared ですぐに使えます。

手軽ですが共有なのでいくつかの制限がありドキュメント(英文)に書かれています。
キャッシュ、クッキー、認証、カスタムネットワークプロトコルなどを使わない場合にはsharedが便利です。

今回のサンプルはすべてURLSession.sharedを使っています。
イニシャライザを使う場合は

// イニシャライザの例
let session = URLSession(configuration: .default)

このように init(configuration:) を使うことができます。

1-3 URL

一般にURLはインターネット上のリソースを特定するための記号の並びです。
書式と利用可能な文字が決まっています。

macOSやiOSプログラミングではインターネットだけでなくデバイス内のファイルアクセスにもURL型インスタンスを利用します。

プログラミングで使うURL型があります。(Objective-Cでも利用可能なNSURLクラスもあります)

イニシャライザーは init(string:) を使います。

// URLのイニシャライザー
init?(string: String)

このイニシャライザーは引数のURL文字列からURLインスタンスを生成します。
文字列が正しいURLでない場合や不正な文字を含む場合は値なし(nil)になります。

URL文字列には漢字や仮名は使えません
%ではじまる文字コードに変換が必要です(エスケープ処理などと呼ばれます)。
なおmacOS(iOS)のファイルシステムでは仮名文字の濁音半濁音の処理にも注意が必要になります(英数字のみであれば問題ありません)。

URLインスタンス作成に成功したとしても、そのURLが示すファイルなどが存在しているとは限らないので注意してください。

1-4 HTTP

HTTPは「Hypertext Transfer Protocol」の略称です。
webブラウザーに入力するURLの先頭にある http:// と https:// でおなじみですね。
クライアントがサーバーにリクエストを送るのに使われます。

クライアントはwebブラウザーアプリや今回のサンプルコードなどです。
サーバーはリクエストにレスポンスを返します

レスポンスには「HTTPステータスコード」が含まれています。
これは3桁の数字で「404」Not Found は有名ですね。
やりとりが成功した場合は「200」OK になります。


2 インターネット上の画像を表示する

最初のサンプルは画像の表示です。

サンプルで使うURLは「https://upload.wikimedia.org/wikipedia/commons/1/1a/Yukichi_Fukuzawa_1891.jpg」です。
ウィキペディアにある福澤諭吉の写真にアクセスしそれを表示しましょう。

通信でデータにアクセスするコードは、接続できない場合をはじめ様々なエラーを考慮しなければなりません。
最初のサンプルでは画像を受信できない場合は何もしません。(ダミーの画像を表示したままの状態です)

iPadやMacのSwift PlaygroundsアプリやXcodeで実行できるサンプルコードです。
【Xcode 12では一部修正が必要です 「2-3 Xcode 12用サンプルコード」を使ってください】

// 01画像表示
import SwiftUI
import Combine
import PlaygroundSupport

class WebModel: ObservableObject {
  @Published var dlImage: UIImage?
  private var cancellableNetwork: AnyCancellable? = nil
  let session = URLSession.shared

  /// 画像の受信
  func fetch() {
     let url = URL(string: "https://upload.wikimedia.org/wikipedia/commons/1/1a/Yukichi_Fukuzawa_1891.jpg")!

     cancellableNetwork = session
        .dataTaskPublisher(for: url)
        .map { data, URLResponse in UIImage(data: data) }
        .receive(on: DispatchQueue.main)
        .replaceError(with: nil)
        .assign(to: \.dlImage, on: self)
  }
}

struct Sample11View: View {
  @EnvironmentObject var eo: WebModel

  var body: some View {
     Group {
        self.eo.dlImage != nil ? Image(uiImage: self.eo.dlImage!) : Image(systemName: "triangle")
     }
     .onAppear {
        // 表示と同時に受信開始
        self.eo.fetch()
     }
  }
}

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

三つのimport文と最後のPlaygroundPage.current.setLiveView(...の行はどのサンプルも共通で必要です。
実行すると次のように表示するはずです。(iPadのPlaygroundsの実行画面)

11サンプル1

もしこのように白黒の画像を表示しない場合はインターネットの接続を確認してください。

Sample11ViewとWebModelの二つの型のみのコードです。
WebModelのインスタンスはEnvironmentObjectとして渡しています。

実際に画像データにアクセスし表示可能なUIImage型インスタンスを生成しているのがWebModelです。

2-1 WebModelのコード

最初のサンプルのWebModelを細かく見ていきましょう

class WebModel: ObservableObject {

WebModelはObservableObjectプロトコルに準拠したクラスで、dlImageプロパティの内容が変化した場合Sample11Viewを再表示するようPublished属性にしています。

@Published var dlImage: UIImage?

UIImage型はiOS用UIKitフレームワークの画像のクラスです。
オプショナルにしているので自動的に「値なし」が初期値になります。
正常に受信できない場合や、受信したデータを画像に変換できない場合があるのでオプショナルにしています。

private var cancellableNetwork: AnyCancellable? = nil

cancellableNetworkはCombineフレームワークのAnyCancellable型プロパティです。
オプショナルにしているので自動的に「値なし」が初期値になりますが、ここでは末尾の = nil で初期値を明示しています。
オプショナルの場合はどちらの書き方も同じです。

 let session = URLSession.shared

sessionプロパティはURLSession.sharedで共有のURLSessionインスタンスに初期化しています。
シンプルな画像ファイルのアクセスは共有インスタンスで問題ありません。

func fetch() {

関数fetchは引数なしで値も返さないWebModel型のインスタンスメソッドです。

let url = URL(string: "https://upload.wikimedia.org/wikipedia/commons/1/1a/Yukichi_Fukuzawa_1891.jpg")!

urlはfetchメソッドのローカルなプロパティです。
画像のURLで初期化しています。
行の最後の感嘆符「!」でオプショナルをアンラップしています
init?(string: String)は失敗した場合は値なしになるのでURLのオプショナル型になることを思い出してください。
アンラップしたのでurlはオプショナルではありません。

    cancellableNetwork = session

ここからの6行はコードとしては1行ですがメソッドごとに1行にして見やすくしています。
cancellableNetwork には最後の.assign(to: \.dlImage, on: self)が返す値が入ります。

       .dataTaskPublisher(for: url)

dataTaskPublisher(for:)はURLSessionクラスのCombine用メソッドです。
引数で指定したURLのデータを返すパブリッシャーを生成し返します。

.map { data, URLResponse in UIImage(data: data) }

mapはデータを変換するCombineのオペレーターです。
ここでは受け取ったデータからUIImageインスタンスを生成して返します。
いつものようにトレイリングクロージャーの書き方です。

1行なので return を省略しています。
.map { data, URLResponse in 
      return UIImage(data: data) }
と2行に書くこともできます。

なおUIImageクラスの init?(data: Data) イニシャライザーは失敗のあるイニシャライザーです。
UIImage?型を返します。
このためここからのデータはオプショナルです。
次以降のCombineオペレーターは変換されたデータ(だけ)を受け取ります。

.receive(on: DispatchQueue.main)

SwiftUIとCombineフレームワークその1」でも説明したオペレーターです。
データを受け取りをDispatchQueue.mainに指定しています。

.replaceError(with: nil)

エラー処理を省略するためのオペレーターです。
結果をassignで受け取るために使っています。
もしエラーがあった場合には次へ渡すデータを引数で指定したnil(値なし)に置き換えています。
このオペレーターに渡されるデータはUIImage型のオプショナルなので、ここで値なしを設定することができます。

.assign(to: \.dlImage, on: self)

これも「SwiftUIとCombineフレームワークその1」でも説明したオペレーターです。
受け取ったデータ(mapでUIImageインスタンスに変換済み)をdlImageプロパティにセットします。

dlImageプロパティはPublished属性なのでUIImageインスタンスがセットされるとビューが再表示されます。(これはCombineではなくSwiftUIのしくみです)

2-2 Sample11Viewのコード

ビューはシンプルです。
表示するのは画像だけです。

struct Sample11View: View {
  @EnvironmentObject var eo: WebModel  // 1︎⃣

  var body: some View {
     Group {  // 2︎⃣
        self.eo.dlImage != nil ? Image(uiImage: self.eo.dlImage!) : Image(systemName: "triangle")  //3︎⃣
     }
     .onAppear {
        // 4︎⃣表示と同時に受信開始
        self.eo.fetch()
     }
  }
}

1︎⃣
WebModelインスタンスをEnvironmentObject属性のeoプロパティに設定しています。

2︎⃣
ここでは表示するUIImageインスタンスeo.dlImageが値なしの場合に備えた三項演算子を使っているので Group を入れています。

3︎⃣
eo.dlImageが値なしでなければアンラップしてImageのイニシャライズに使い、値なしであればSF Symbolsの三角形(triangle)を表示します。
実行開始直後から画像データを受信するまでこの三角形を表示しています。
受信した画像か三角形のどちらかを表示します、画像のサイズ調整などは指定していません。

4︎⃣
onAppearモディファイアーでビュー表示と同時にfetch()メソッドを実行します。
fetch()メソッドで画像を受信するとeo.dlImageが値なしから表示可能なインスタンスに変わりビューが再表示されます。

このサンプルではfetch()メソッドのシンプルなコードで画像をインターネットから受信できることが確認できました。

次の「3 Combineとオペレーター」でCombineの簡単な復習とオペレーターを説明します。

2-3 Xcode 12用サンプルコード

Xcode 12(12.1・12.2)のプレイグラウンドはLive Viewの表示サイズの違いとonAppear処理が繰り返されてしまう問題がありました。
Sample11Viewのコードに一部追加が必要です。

/// 01-Xcode12用のSample11View
struct Sample11View: View {
  @EnvironmentObject var eo: WebModel

  var body: some View {
     Group {
        self.eo.dlImage != nil ? Image(uiImage: self.eo.dlImage!) : Image(systemName: "triangle")
     }
     .frame(width: 585, height: 756)   // Xcode12で必要 1)
     .onAppear {
        // 表示と同時に受信開始
        if self.eo.dlImage == nil {    // Xcode12で必要 2)
           self.eo.fetch()
        }
     }
  }
}

変更点はふたつあります。

1)Live Viewの背景が小さい状態のため、表示するImageビューにframeモディファイアーで幅と高さを指定しています。

2).onAppearの処理が繰り返し実行されます。
このため画像データを受信したらfetch()を実行しないようにif文を追加しました。


3 Combineとオペレーター

SwiftUIとCombineフレームワークその1」のまとめにも書きましたがCombineではPublisher(パブリッシャー)が発行したデータをOperator(オペレーター)で変換などを行い、Subscriber(サブスクライバー)が受け取り利用します。

通信でもサブスクライバーは同じものが利用できます。
この記事でもassign(to:on:) とsink(receiveCompletion:receiveValue:)両方を使ったサンプルコードをそれぞれ用意しました。
「SwiftUIとCombineフレームワークその1」で取り上げたTimerはオペレーターはほとんど使いませんでしたが、通信ではエラー処理とデータ変換のためにオペレーターは必須です。

パブリッシャーはURLSession型のdataTaskPublisher(for:)メソッドを使って取得します。

3-1 dataTaskPublisher(for:)メソッド

パブリッシャーを返すdataTaskPublisher(for:)メソッドは渡す引数の型違いの二つがあります。

// URSessionをCombineで使うためのインスタンスメソッド
func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher
func dataTaskPublisher(for url: URL) -> URLSession.DataTaskPublisher

ここでは引数にURL型インスタンスを渡すものを使います。
どちらもURLSession.DataTaskPublisher型のパブリッシャーを返します。

URLSession.DataTaskPublisher型のOutputとFailureそれぞれの型を確認しましょう。

// Outputの型(パブリッシャーが返すデータの型)
typealias URLSession.DataTaskPublisher.Output = (data: Data, response: URLResponse)

Data型のインスタンスと URLResponse型インスタンスの名前付きタプルを返すことがわかります。

Data型はデータの塊を扱うための型です。
「生」のデータであるため、通常は文字・数値・画像などに戻して(変換して)利用します。

URLResponse型はURLロードリクエストに対するレスポンスを表すためのクラスです。
HTTPへ接続の場合はURLResponseを継承したHTTPURLResponse型インスタンスが使われます。
HTTPURLResponseは statusCode プロパティーでHTTPステイタスコードを知ることができます。

// Failureの型
typealias URLSession.DataTaskPublisher.Failure = URLError
URLError型はSwiftではStructureでエラーコード(エラーの番号)だけでなく、付加情報や実行環境向け翻訳済みエラーメッセージをlocalizedDescriptionプロパティーに持ちます。

3-2 map(_:)オペレーター

上流のパブリッシャーからのすべての要素を、提供されたクロージャで変換するオペレーターです。

// Combineのmap(_:)オペレーター
func map<T>(_ transform: @escaping (Self.Output) -> T) -> Publishers.Map<Self, T>

引数は変換処理のためのクロージャーだけです。
クロージャーの引数はパブリッシャーからのOutputです。
トレイリングクロージャーの書き方で記述されると map {... となります。
変換が1行の場合は return 文も省略されます。
変換したデータを返すので別のオペレーターをつなぐことでさらに複雑な処理を行えます。

3-3 replaceError(with:)オペレーター

ストリーム内のエラーを、指定された要素で置き換えるオペレーターです。
上流のパブリッシャがエラーで失敗した場合、replaceError(with:)は引数の要素を放出し、その後正常に終了します。

// CombineのreplaceError(with:)オペレーター【publisherプロトコル】
func replaceError(with output: Self.Output) -> Publishers.ReplaceError<Self>

publisherプロトコルはこのようになっていますが、URLSession.DataTaskPublisher型のreplaceError(with:)は

func replaceError(with output: (data: Data, response: URLResponse)) -> Publishers.ReplaceError<URLSession.DataTaskPublisher>

となっています。
引数にはdataとresponseのタプルを渡します。

サンプルではreplaceError(with:)を使うことでassign(to:on:)を利用可能にしています。
assign(to:on:)はエラーを返さない(つまりFailure が Neverの)パブリッシャーだけで使えるサブスクライバーです。
.replaceError(with: nil)の行だけをコメントにするとエラーを表示します。
実際にエラーメッセージを確認してください。

Combineを使った画像を受信するコードは次の5行です:

cancellableNetwork = session
  .dataTaskPublisher(for: url)       // パブリッシャー
  .map { data, URLResponse in UIImage(data: data) }   // UIImageインスタンスに変換
  .receive(on: DispatchQueue.main)   // メインスレッドで受信
  .replaceError(with: nil)           // エラーをなしする
  .assign(to: \.dlImage, on: self)   // UIImageに変換された画像データを受け取りプロパティに設定する

受信した「生」のデータをUIImageインスタンスに変換し、メインスレッドで受け取ることを明示し、エラーなしにしてdlImageに設定しています。

ここでは .receive(on: DispatchQueue.main) が重要です。
この1行がなければインターネットからのデータ受信が完了しても画像を表示しません

この行をコメントにして実行し確認してください。


4 テキスト(HTML)の受信

通常webページのHTMLデータの受信はWKWebViewクラスで受信から表示(レンダリング)までおこないます。
ここではテキスト受信のサンプルとしてwebページのHTMLデータを使います。

サンプルで使うURLは「https://developer.apple.com/jp/news/?id=17o0h655」です。
AppleのDeveloperサイトの日本語「ニュースとアップデート」のひとつです。

サンプルコードの2つ目です。
【Xcode 12では一部修正が必要です 「4-2 Xcode 12用サンプルコード」を使ってください】

// 02 HTMLを受信して文字列として表示する
class WebModel: ObservableObject {

  @Published var outText: String = ""
  private var cancellableNetwork: AnyCancellable? = nil
  let session = URLSession.shared

  /// 受信開始処理
  func fetch() {
     let url = URL(string: "https://developer.apple.com/jp/news/?id=17o0h655")!

     cancellableNetwork = session
        .dataTaskPublisher(for: url)
        .tryMap { element in
           guard let httpResponse = element.response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
                 throw URLError(.badServerResponse)
           }
           print(" >> statusCode=\(httpResponse.statusCode)")
           return element.data
        }
        .map { String(data:$0, encoding:.utf8) }
        .receive(on: DispatchQueue.main)
        .sink(receiveCompletion: {
           print (" == Received completion: \($0).")
        },
             receiveValue: {
              self.outText = $0 ?? "*** NO DATA ***"
        })
  }
}

struct Sample11View: View {
  @EnvironmentObject var eo: WebModel

  var body: some View {
     VStack {
        Text("\(eo.outText)")
           .background(Color.yellow)
           .padding()
     }
     .onAppear {
        // 表示と同時に受信開始
        self.eo.fetch()
     }
  }
}

import 文と最後の文は共通なので割愛しています。

11サンプル2

Sample11Viewの構成は最初の画像を表示するサンプルと同じです。
結果を表示するビューがTextに変わっただけです。

WebModelクラスは名称は最初のサンプルと同じですが受信の処理は違っています。

4-1 テキスト(HTML)用WebModelのコード

クラス宣言の1行目は class WebModel: ObservableObject { で同じです。

Published属性のプロパティは文字列です。

@Published var outText: String = ""

オプショナルにしていません、からの文字列に初期化しています。

cancellableNetwork と session は最初のサンプルと同じで、働きもいっしょです。

受信処理の func fetch() { もメソッド名は同じにしています。

受信するURLはもちろん違います。

let url = URL(string: "https://developer.apple.com/jp/news/?id=17o0h655")!

アップルのデベロッパー関連サイトで、なるべく行数の少なそうなものにしました。
任意のサイトのURLに置き換えて試すことができます。
行の最後の感嘆符「!」でオプショナルを強制アンラップしているのも最初のサンプルと共通です。

cancellableNetwork = session.dataTaskPublisher(for: url) から実際の受信に関係するコードになっているのも同じです。
二行にわけていますが、通信の開始部分は共通です。

.tryMap { element in

tryMap は map の拡張版オペレーターです。
通信エラーに対する処理を組み込むためにmapではなくtryMapを使っています。

guard let httpResponse = element.response as? HTTPURLResponse,
   httpResponse.statusCode == 200 else {

tryMapの引数のクロージャーの最初の行にはguard文を二行に分けて書いています。
1行目は引数で受け取ったelementのresponseをHTTPURLResponse型インスタンスにダウンキャストしています。

elementは.dataTaskPublisher(for: url)から流れて来る「生」のデータで型は名前付きタプル (data: Data, response: URLResponse) です。
このためelement.data と element.response それぞれにアクセスできます。

ダウンキャストに成功すればこのクロージャー内で定数 httpResponse が使えます。
guard文の2行目でhttpResponse.statusCodeが200の条件でガードしています。
HTTPステータスコード」200が受信成功です。

throw URLError(.badServerResponse)

失敗した場合にはURLErrorをスローしています。
catchのコードは不要でスローされた場合はパブリッシャーがcatchして通信をエラーで終了し、sinkサブスクライバーのreceiveCompletion:引数のクロージャーが実行されます。

print(" >> statusCode=\(httpResponse.statusCode)")

guard文を抜けた後のprint文は確認用です。
コンソールに「HTTPステータスコード」を出力します。
このプリント文は Swift Standard Library の print関数 print(_:separator:terminator:) です。
Swift Playgroundsアプリではこのプリント出力は確認できません、Xcodeのプレイグラウンドでdebug areaを開くと確認できます。

   return element.data
}

クロージャーのreturn文です。
次のオペレーターにはOutputのタプルのうちdataだけを渡します。

この続きをみるには

この続き: 18,754文字 / 画像3枚
この記事が含まれているマガジンを購入する
最新有料記事をまとめて、リーズナブルに読めます。

SwiftUIで初級から脱出するための情報をまとめました。背景を知り基礎を固めましょう。 重要なCombineと情報の少ないドラッグ&ドロ…

または、記事単体で購入する

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

快技庵 高橋政明

500円

この記事が気に入ったら、サポートをしてみませんか?
気軽にクリエイターの支援と、記事のオススメができます!
note.user.nickname || note.user.urlname

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

ありがとう!
2
古くからのMacプログラマ、現在はiOS向けに青空文庫リーダーアプリなどを作っている。「未経験者のための『コードを学ぼう』ガイド」『Swift5初級ガイド』など技術書執筆も行なっています。noteでは主にセミナーの内容を有料記事で公開しています。Twitterは@houhei

こちらでもピックアップされています

SwiftUI次の一歩
SwiftUI次の一歩
  • 7本
  • ¥1,000

SwiftUIで初級から脱出するための情報をまとめました。背景を知り基礎を固めましょう。 重要なCombineと情報の少ないドラッグ&ドロップの記事を含みます。

コメントを投稿するには、 ログイン または 会員登録 をする必要があります。