20201201のSwiftに関する記事は11件です。

[iOS]UIImageViewがタップされた時にイベントをつける

UIImageViewがタップされた時にイベントを発火させたかった。

バージョン

Swift 5.3
Xcode 12.1

実装

UIViewController.swift
import UIKit

class UIImageViewEventViewController: UIViewController {

    let imageView: UIImageView = UIImageView(image: UIImage(named: "cat"))


    override func viewDidLoad() {
        super.viewDidLoad()
        imageView.frame.size = CGSize(width: 100, height: 100)
        imageView.center = view.center
        view.addSubview(imageView)

        imageView.isUserInteractionEnabled = true   
        imageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))
    }

    @objc
    func tapped() {
        imageView.center = CGPoint(x: Double.random(in: 100...200), y: Double.random(in: 100...300))
    }

}

結果

closemodal.gif

画像にイベントをつけることができました。猫がかわいいですね

解説

今回の肝になる部分はここです

imageView.isUserInteractionEnabled = true   
imageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))

まず、isUserInteractionEnabledはUIViewクラスが持っているプロパティで、ユーザーが行う操作(タップ、長押しなど)を無視するかどうかを決めるプロパティみたいです。
addGestureRecognizerの部分はGestureRecognizerを追加するメソッドです。

GestureRecognizerとは

ユーザーからのタップなどの操作を認識し、何かしらの処理を行うために用意されたクラスです。
上のコードで指定しているUITapGestureRecognizerUIGestureRecognizerのサブクラスみたいですね。
その他にピンチされた時(UIPinchGestureRecognizer)やスワイプ(UISwipeGestureRecognizer)された時などにイベントを発火させたり、普段自分たちが使っているアプリのUIはこのクラスをこねくり回して元に作られてそうですね。しらんけど

終わりに

GestureRecognizerを使ったら色々面白いことができそうなので、今後も触っていきたいです。

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Swift]Firestoreのdocument取得を見やすく書けるようにしてみる

アドベントカレンダー1日目! 一番いい場所いただきました! :santa: :santa: :santa: :santa: :santa:

内容

Firestoreのドキュメントでは、データの取得の際に以下のようなコードが紹介されています.
ドキュメントはとってもわかりやすいですが、データ取得の際、毎回こう書くのは面倒だし見にくいなーと思ったので、
Firestoreのdocument取得をみやすく書けるようにしてみました。

方針: 簡単なメソッドチェーンで、自分で定義したモデルクラスに落とし込まれた状態のデータを取得できるようにする

具体的にいうと、このコードを

// 単一データの取得
db.collection("cities").document("SF").getDocument { (document, error) in
    if let document = document, document.exists {
        let data = document.data()
        let city = self.parse(data) // 何らかの方法でparseする
        self.hoge(city) // 取得したデータで何かする
    } else {
        print("Document does not exist")
    }
}

// 複数データの取得
db.collection("cities").getDocuments() { (querySnapshot, err) in
        if let err = err {
            print("Error")
        } else {
            let cities = for document in querySnapshot!.documents {
                let data = document.data()
                return self.parse(data) 
            }
            self.hoge(cities)
        }
}

こういう風に書けるようにします。

db.collection("cities").document("SF").fetch(as: City.self) { city in
  if let city = city {
     self.hoge(city)
  } else {
     print("error")
  }
}


// 複数データの取得
db.collection("cities").fetch(as: City.self) { cities in
  if let cities = cities {
     self.hoge(cities)
  } else {
     print("Error")
  }
}

どうやって?

  1. Codableに適合させたカスタムオブジェクトを作成する。
  2. Firestoreに実装されているクラスを拡張する。

やりかた

1. Codableに適合させたカスタムオブジェクトを作成する。

Codableについては、この記事がとても分かりやすいです(これより良く書けないし、丸投げします :santa: )
Firestoreに登録するドキュメントと、モデルクラスを対応させることで、
data(as:)でFirestoreのデータ構造からモデルオブジェクトに自動的に変換させられます。

// 単一データ取得。documentからデータのparseのところがちょっとわかりやすくなる。
db.collection("cities").document("SF").getDocument() { document, err in
  if let document = document, document.exists {
     do {
        let city = try document.data(as: City.self) // こういう風に書けるようになる
        self.hoge(city)
     } catch let error {
        print("error")
     }
  } else {
     print("error")
  }
}

// 複数データ取得。
db.collection("cities").getDocuments() { document, err in
  if let err = err 
    print("Error")
  } else {
    let cities = querySnapshot!.documents.flatMap { document in
      do {
        return try document.data(as: City.self) // これもこう書けるようになる。
      } catch let error {
        print("error")
      }
    }
    self.hoge(cities)
  }
}

data(as: City.self)を用いることで、コードが直感的に理解し安くなりました。
ここで気になるのは、結局毎回documentをオブジェクトに落とし混む処理を記述する必要があることです。
getDocumentgetDocumentsで返されるものがすでに処理済みのものだったらもっと楽に書けるのに。。。(複数取得の方、長いし) :cry:

2. Firestoreに実装されているクラスを拡張する。

なので、DocumentReferenceQueryを拡張して、いい感じに描けるようにしてみます。
それぞれ、#getDocument(as:)#getDocuments(as:)をラップしただけの関数です。

fetch(as:)を以下のように実装しました。コード!ドーン!!!

import Foundation
import FirebaseFirestore

extension DocumentReference {
    func fetch<T : Decodable> (as type: T.Type, _ completion: @escaping (T?) -> Void) {
        getDocument { (snapshot, error) in
            if let snapshot = snapshot, snapshot.exists {
                do {
                    let obj = try snapshot.data(as: type)
                    completion(obj)
                } catch let error {
                    print("[Error] #data(as:) throw error: \(error)")
                }
            } else if let error = error {
                print("[Error] #getDocument return error response: \(error)")
                completion(nil)
            } else { // !snapshot.existsのとき
                completion(nil)
            }
        }
    }
}

extension Query {
    func fetch<T : Decodable> (as type: T.Type, _ completion: @escaping ([T]?) -> Void) {
        getDocuments() { (snapshot, error) in
            if let error = error {
                print("[Error] #getDocument return error response: \(error)")
                completion(nil)
            } else {
                let docs = snapshot!.documents
                let objs:[T] = docs.compactMap { doc in
                    do {
                        return try doc.data(as: type)
                    } catch let error {
                        print("[Error] #data(as:) throw error: \(error)")
                        return nil
                    }
                }
                completion(objs)
            }
        }
    }
}

シンプルなメソッドチェーンでカスタムオブジェクトを返すことで、
以下のようにみやすく書くことができるようになりました!!! :santa: :santa: :santa: :santa:

// (再掲)
// 単一データの取得
db.collection("cities").document("SF").fetch(as: City.self) { city in
  if let city = city {
     self.hoge(city)
  } else {
     print("error") // 
  }
}

// 複数データの取得。
db.collection("cities").fetch(as: City.self) { cities in
  if let cities = cities {
     self.hoge(cities)
  } else {
     responseError()
  }
}

注意点

注意点として、自分の紹介したコードでは、ラッパーメソッドfetch(as:)内でエラーを食べてしまっています。
自分はエラー種類によるロジック分岐が不要そうだったのでこのようにしていますが、必要な場合はerror戻すように修正いただければ... :bow:

おわり!!

趣味で触っているFirestore:fire: と趣味で触っているSwiftで記事を書きました...
他者に指摘されない環境で学んだため、変な記述があったかもしれません...
万が一、「その書き方おかしいよ、いかんだろ」とかあったら優しく教えてください :santa: :santa: :santa: :santa: :santa:

ありがとうございました!!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

FigmaのデザインをSwiftUIのコードに変換してくれる無料プラグインがあるらしい???

GitHubで便利そうなライブラリを探していたらこんなものを見つけました。

FigmaToCode

https://www.figma.com/community/plugin/842128343887142055/thumbnail

FigmaのデザインをFluttertailwidcssSwiftUIにコンバートしてくれるらしい。
FigmaはUIKitしかサポートしてくれてないので、使い物になればかなり有り難い...

試しに使ってみた

今回はこちらのデザインをコンバートしてみようと思います。
ニューモーフィズムのサンプルデザインです。

image.png

コンバート結果

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 43) {
            Text("Neumorphism UI Kit")
                .fontWeight(.medium)
                .font(.largeTitle)
                .padding(.vertical, 49)
                .padding(.leading, 116)
                .padding(.trailing, 117)
                .frame(width: 693, height: 155)
                .foregroundColor(Color.init("text"))
                .background(Color.init("background"))
                .cornerRadius(98)
                .shadow(radius: 30)
            Text("Button / Switch / Progress / Pagination/ Selector ... and more")
                .font(.title)
        }
        .background(Color.init("background"))
    }
}

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

image.png

使ってみて

結構、再現度の高いものに仕上がっているのではないでしょうか?
AutoLayoutを再現するにはこのようなポイントに注意しなければいけないようです。
https://github.com/bernaferrari/FigmaToCode/raw/master/assets/examples.png

個人的には画面全体ではなく各パーツを1つずつ変換していく形になると思うので、あまり関係ないかなと。

絶賛開発中のプラグインなので期待していいのでは?

SwiftUIのアプリを来年から新たに開発予定なので、そのタイミングでもっと使ってみようかなって思います!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

強制ダークモード

画面を強制的にダークモードにしたい場合
iOS13以降のみ ViewController 内でこうする

    override func viewDidLoad() {
        super.viewDidLoad()
        if #available(iOS 13.0, *) {
            overrideUserInterfaceStyle = .dark
        } else {
            // Fallback on earlier versions
        }
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftUI学習メモ

『1人でアプリを作る人を支えるSwiftUI開発レシピ』などを読み、SwiftUIを軽く触ったときのメモ。(※理解度がまだ浅いので、間違っている記載がある可能性があります)

Screen Shot 2020-12-01 at 4 17 23 PM

出典 : Xcode - SwiftUI - Apple Developer

概要

  • WWDC2019で発表
  • iOS13以上で利用可能
    • iOS14 SDKからさらにAPI追加されてる。実質使える状態なのはiOS14からかな...
  • iOS14から、フルSwiftUIでアプリ作れるようになった
  • iOS14から登場したWidget機能を実装するには、SwiftUIがマストで必要

チュートリアルが充実してる

特徴

  • 宣言的UI
  • データバインディング (ステートドリブンなシステム)
  • リアルタイムのViewレイアウトプレビュー

ReactやFlutterやってる人には馴染みやすいはず

今までのビュー作成との違い

従来は、命令型 (宣言型の逆) プログラミングでビューを作っていた

let imageView = UIImageView(image: UIImage(named: "unko"))
imageView.contentMode = .scaleAspectFit
imageView.layer.cornerRadius = 10
imageView.clipsToBounds = true
view.addSubview(imageView)

↑こんな感じのコードが、↓以下のように。
宣言的に書いて、View修飾子 (ex. .cornerRadius) をチェーンさせて新しいViewを返していく。

Image("unko")
    .cornerRadius(10)

プレビューもXcodeで出せる!
※ただし、すぐ止まったりする(謎

Screen Shot 2020-12-01 at 2 11 17 PM

また、ビューの重ねていき方のパラダイムも異なる。
SwiftUIでは、親Viewが表示可能領域・子の配置位置を決め、子Viewは自身のView領域を決める。つまり、親は子のサイズ決定には基本的に関与しない。子が自身のサイズを決める

コンポーネント

例えば [Swift] SwiftUIのチートシート - Qiita にUIKitとの対応がけっこう書いてある。

こういうの見て、あとは実際に書いて理解してくしかないかな...

アプリの作成

プロジェクトを新しく作成すると、以下のようなファイルが自動でできる。

import SwiftUI

@main
struct SwiftUISampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

@mainがエントリポイントを表す。

  • App -> Scene (WindowGroup) -> View という構成で作る
  • 今のところiPhoneアプリの場合はSceneが1つ
    • iPadアプリではマルチウィンドウ機能があるのでSceneが2つとかになる
  • アプリの状態検知は、scenePhaseつかう (↓のようなイメージ)
@main
struct SwiftUISampleApp: App {
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup {
            ContentView()
                .onChange(of: scenePhase) { newScenePhase in
                    switch newScenePhase {
                    case .background:
                        ...
                    }
                }
        }
    }
}

データ管理

'Property Wrapper'というやつでデータをViewにバインディングする機能が標準で備わってる

例えばシンプルな例。

struct ToggleView: View {
    @State private var isOn = false

    var body: some View {
        VStack {
            Toggle("スイッチON", isOn: $isOn)
            Text("\(isOn ? "ON" : "OFF")")
        }
        .frame(width: 160, height: 64)
    }
}

SwiftUI_ToggleView

@StateというPropertyWrapperをつけたプロパティに$をつけることで、データがバインディング対象になる

Property Wrapper8種類

データは何か、データはどのように処理するか、データはどこからくるか、で用途を分ける

  • @State
    • 扱うデータが値型 and View自身でデータを保持して更新する場合に使う
  • @Binding
    • 扱うデータが値型 and 親Viewなど外部からデータを渡され更新する場合に使う
    • @Stateのデータの先頭に $ マークをつけることで、 @Bindingのデータに変換可能
  • @Environment
    • 環境値を読み取る場合に使う。KeyPathを指定して読み込み
  • @StateObject
    • ※iOS14以降
    • 扱うデータが参照型 and View自身でデータを保持して更新する場合に使う
    • データ更新→再レンダリングの際も、Viewインスタンスは破棄されない(=最初の一度しか作成されない)
  • @ObservedObject
    • 扱うデータが参照型 and 親Viewからデータを渡され更新する場合に使う
    • こっちはViewインスタンスが再レンダリング時に破棄される
  • @EnvironmentObject
    • 階層を飛び越えてViewにデータオブジェクトを渡せる
    • @ObservedObjectのバケツリレーを避けられる
  • ObservableObjectプロトコル
    • 参照型のデータを監視する場合、対象のオブジェクトはObservableObjectプロトコルに準拠しないといけない
  • @AppStorage
    • 値型のデータを格納できる
    • 格納先はUserDefaults。ライフサイクルがアプリが消されるまで
  • @SceneStorage
    • マルチウインドウをサポートするアプリで、シーンごとに値型のデータを格納できる
    • 使い方は、@AppStorageとほぼ同じ

Combine

こちらもWWDC19で発表されたフレームワーク。

  • データ処理を宣言的に扱えるようになり、非同期イベントのハンドリングがしやすくなる
  • RxSwiftやReactiveSwiftなど、Reactive系のフレームワークに馴染みのある人には扱いやすい?
    • ※まだ慣れてないので調べきれてないが、異なる部分や足りない部分も当然あるはず
  • SwiftUIの備えるデータバインディング機構と相性がいいので、SwiftUIにAPI通信処理etc.を組み込む際はCombineの利用が前提になるはず

登場人物はざっくり3つ

  • Publisher
  • Subscriber
  • Operator

役割分担はこんな感じで、RxSwiftとかの経験あるなら概念は理解しやすいと思う

  • Publisherが発行するデータストリームをSubscriberが受け取る。
    • = 雑にいうとイベント駆動で処理を書ける
  • Operatorは、両者の間でデータストリームの値の変換を行う。

例えば以下のようにCombineを使う。(他にもやり方はある)

import Combine

class Unko {
    var touched = PassthroughSubject<String, Never>()
}

let unko = Unko()
let subscriber = unko.touched.sink { str in
    print("Unko touch: \(str)")
}

unko.touched.send("1回目")
unko.touched.send("2回目")
subscriber.cancel()
unko.touched.send("3回目はない")

出力されるのは、

Unko touch: 1回目
Unko touch: 2回目

※Unkoクラスのtouchプロパティに、@Publishedをつけるやり方もある。
これをつけとくと、変数の値が更新されたタイミングで、監視してるSubscriberに更新が伝わる。

RxSwift, RxCocoaとのAPI対応表みたいのがあるので、Rxに慣れてる人はそれ見ながら覚えてくのが良さそう
CombineCommunity/rxswift-to-combine-cheatsheet: RxSwift to Apple’s Combine Cheat Sheet

Widget

  • iOS14の新機能。ホーム画面におけるようになったアレ
  • Widgetに配置できるのはLabelやImageなど表示系のみ。スクロールとかスイッチは無理
  • WidgetのUIイベントはタップのみ
    • アプリを起動できる
    • DeepLinkが設定できるので、特定の画面に遷移させられる

実装

アプリ本体のエクステンション (追加ターゲット) として、作成する

  • エントリポイントで、Widgetプロトコルに準拠
    • WidgetConfigurationプロトコルのインスタンスをbodyとして返す
    • StaticConfiguration : ユーザが設定を編集できない
    • IntentConfiguration : ユーザが設定を編集できる
  • 表示 (更新) のロジックをTimelineProviderプロトコルが担う
    • WidgetはタイムラインにそってViewを更新
    • 3つのメソッドの実装が必要
    • placeholder(in:)
      • 初期表示
    • getSnapshot(in:completion:)
      • ホーム画面に追加されたときや、Widget Gallleryで表示されたとき
    • getTimeline(in:completion:)
      • タイムラインに沿った更新時など?
  • タイムラインの更新ポリシーにも種類がある : TimelineReloadPolicy
    • .atEnd, .after(_:), .never
  • ※ホストアプリからWidgetKitを通して、更新処理をキックすることももちろんできる
    • WidgetCenterを使う

タップ時の処理は、DeepLinkの仕組みでゴニョゴニョする。.widgetURL修飾子を使う

  1. Widget側に.widgetURLを追加して、特定のURLセット
  2. ユーザがタップすると、ホストアプリで.onOpenURLが呼ばれURLを受け取る
  3. ホスト側でURLをハンドリング

雑にまとめ

  • 動きの少ないビューであれば、UIKitで作るよりもサクッと作れそう
    • 応用が効くかどうか。HIGにのっとって作るなら大丈夫かな...?
  • TableViewやCollectionViewのときにメモリを効率的につかってくれるのかがちょい不安
    • iOS14でAPI増えて改善したようなので、たぶん大丈夫...
  • Combineとの組み合わせで、必然的にデータバインディングを利用したリアクティブプログラミングで作ることになる
    • ちゃんと作れば、状態の複雑さに起因するバグが確実に減る
    • (たぶん)デファクトにある程度なってるRxSwift系の流れを汲みつつ、RxSwift依存から離れられる
  • アプリがiOS14以上サポートになるまで(あと1-2年くらい?)は、既存アプリに組み込むのは面倒が増えるだけかも
    • 練習的に、OSバージョンで条件分岐させて簡単な画面作ってみるのはあり。シンプルな設定画面とか
  • 新規アプリで採用するどうかはもう少し使ってみて判断したい。...が、UIKitとのハイブリッド構成もいけるので、困ったらそっちに逃げれる?
  • 既存アプリに突っ込むなら、Widget機能つくるタイミングが一番な気がする
    • そもそもSwiftUI使わないとリリースできないので

とりあえずもっと使ってみたい! (あと、RxSwiftをCombineでリプレイスしたい)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Kotlin どうでしょう

Kotlin コトリン

Kotlin は JET BRAINS社が開発したオブジェクト型指向言語。
2017年に Google がAndroid開発言語として正式サポート。
元々、Android開発環境である「Android Studio」は、JET BRAINS社の IntelliJ ベース。
開発環境の言語サポートが充実している!

言語仕様について

kotoli.png

Javaと相互運用可能!

JavaからKotlin のクラス使える。
逆に、KotlinからJavaのクラスも使用可能。
(Kotlin が JVM上で動作する言語のため。)

JavaコードをAndroid Studio上で 貼り付けすると、
Kotlinコードに変換できる。
(ただし、完璧ではない。ビルドが通らないことも有。)

・総評

記述が簡潔にできるので、積極的に使用したい。
 
Swiftの文法に近いため、
iOS - Android 開発者のスキルトランスファーの敷居は下がると思われる。
(設計の思想は違う。)

Android開発には、Javaを知った上でのKotlin知識が必要なため、
Javaを捨てることはまだできない。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Swiftにおける基本的な型の紹介〜Optional型〜

Optional<Wrapped>型 とは

Optional<Wrapped>型とは、値が存在するか空かのいずれかを表す型です。

swiftでは基本的にnilを許容しないですが、
nilを許容する必要がある場合はOptional<Wrapped>型を利用します。

Optional<Wrapped>型のWrappedとは
プレースホルダ型と言い実際にはWrappedを具体的なInt型やString型に置き換えて使用します。

Optional<Wrapped>のように<>の中にプレースホルダ型を持つ型をジェネリック型と言います。
ジェネリック型については別記事て記載します。

nilを許容する必要がある場合とは、どのような状況か・・・。
twitterのアイコンなどでnilが使えるのではないかなと思います。

twitterのアカウントを作成する際に、
ユーザ名は必須ですが、アイコンは任意だと思います。

その時に、アイコンがnilを許容しない設計だと、
アイコン画像を設定しないといけなくなると思います。

個人的にはそういう時に使うんだろうなーと思いました、

Optional<Wrapped>型の作成方法

Optional型の作成方法は二通りあるらしいのですが、
一般的に使用される方しか覚えてないのでそちらを共有します。

Optional型には、Optional型と、非Optional型があります。
Optional<Wrapped>型を作成する方法はものすごく簡単で、!や?を付けるだけです。

var a: Int    // 非Optional型  -> nilを許容しない   
var b: Int?   // Optional<Int>型  -> nilを許容する  

変数aは、いつも通りの宣言ですね。
変数aには何か値を代入して初期化しないとコンパイルエラーがおきます。

しかし、変数bはOptional型でnilを許容するので
初期化しなくてもコンパイルエラーは起きません。

つまり、変数aはコンパイルエラー、変数bはnilという状態になります。

!や?を語尾に付けるだけでnilを許容するようになるなんてOptionalは便利だ!
と思いますが、実は面倒な部分があります。

var a: Int?   // Optional型

//print関数でログ出力
print(a)   // nil
//type関数で変数aの型を表示
type(of:a)   // Optional<Int>

まだ何も代入していないので、print() でnilが返されるのは想定内ですが、
変数aは、Int型かと思いきやOptional<Int>型になります。

var a: Int?   // Optional型
a = 1

//print関数でログ出力
print(a)   // Optional(1)
//type関数で変数aの型を表示
type(of:a)   // Optional<Int>

さらに、変数aに1を代入すると、
print()関数の結果がOptional(1)
type()関数の結果がOptional<Int>

Optionalってなんぞや!!

当時は訳が分からず5回ぐらい叫びました(笑)

まず、Optional型というのは、nilを許容する型です。
なので1を代入したとしてもまたどこかでnilが代入される可能性があります。

そのような不確かなものをswift様が許す訳もなく、
Int型と同じくくりにせずOptional<Int>としているっぽいです。

なので、Int型とOptional<Int>型の計算は、もちろん出来ません。

var a: Int = 10   // Int型
var b: Int? = 100   // Optional<Int>型

var c = a + b   // 10 + Optional(100)  -> コンパイルエラー

nilを許容出来るようにするのは便利ですが、
このようにOptional型は通常では使えなくなっております。

では、どのようにしてOptional型を扱うのか。
Optional型で宣言した変数はずっと使えないのでは不便です。

しかし解決策があります。
それは、Optional型のアンラップです。

Optional型の変数をアンラップすることにより、
通常のInt型やString型と同じように利用できます。

今回はOptional型の紹介ですので、
アンラップについては別の記事に記載します。

そちらをご覧いただけると幸いです。

以上、ありがとうございました!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

FlutterでFirebaseを使わずにSign in with Appleを実装する

この記事はFlutter #2 Advent Calendar 2020の6日目の記事です。

アドベントカレンダーの参加は今年が初めてです。
いつもは個人ブログで記事を投稿していますので、Qiitaへの投稿は久しぶりです。

Flutterはクロスプラットフォーム開発ができる便利なツールですが、まだまだ実務で導入するには見えていない部分があり会社で導入するためにはハードルがあったりします。

例えば、どれだけOS依存の課題に対応できるか、そしてどれだけサーバー連携が絡んだ時に柔軟にカスタムできるかだったりですね。

今回はその企業でも導入する際に検討事項に入りそうな「アカウント周りの情報」にういて深堀りしてみます。

企業としては「ただユーザーがSNSアカウントでログインできたら良いや」というのは絶対ありえなく、ログインしたらユーザー情報を社内のデータベースに保存してセキュアに取り扱いたいというのが本音です。

また絶対にユーザー情報が外部に漏れても駄目ですね。
そんなアカウント周りの情報ですが、Flutterでのログイン機構だとまだまだ見えていない部分の方が多いです。

そこで、今回はAppleIDを使って認証するシステムであるSign in with Appleにおける振る舞いについて見ていきます。

iOSエンジニアがFlutterでSign in with Apple

Flutterを導入できるプロジェクトの場合はだいたいは相性がいいFirebaseも導入してそこら辺はFirebaseが担ってくれる場面が多いのですが、プロジェクトによってはFirebase Authenticationの機能が使えずFirebase Authenticationで連携できない画面もあるかもしれません。

Firebase Authentication

その場合にはFlutterでSign in with Appleでの認証が可能かどうかやっぱり気になります。
なので、今回はFirebase Authenticationが使えない場合を想定してみました。

今回はFlutterでのSign in with Apple認証をするために使うパッケージにsign_in_with_appleをチョイスしてみます。非常にpopularにパッケージになっています。

sign_in_with_apple
URL: https://pub.dev/packages/sign_in_with_apple

このパッケージを使ってFlutterでSign in with Appleを実装してみます。

開発環境

Flutter 1.22.4 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 1aafb3a8b9 (2 weeks ago) • 2020-11-13 09:59:28 -0800
Engine • revision 2c956a31c0
Tools • Dart 2.10.4
  • Xcode 12.1
  • Android Studio 4.0
  • iOS 14.3 (Sign in with Apple は実機で開発した方がスムーズのため)

発生したエラー

Xcodeのバージョンが古い

実機のiOSのバージョンに対してXcodeのバージョンが足りなかった時に発生したエラー。

═══════════════════════════════════════════════════════════════════════════════════
Error launching app. Try launching from within Xcode via:
    open ios/Runner.xcworkspace

Your Xcode version may be too old for your iOS version.
═══════════════════════════════════════════════════════════════════════════════════
2020-11-29 18:23:16.711 ios-deploy[2686:17758351] [ !! ] Error 0xe8000022: The service is invalid. AMDeviceSecureStartService(device, serviceName, NULL, &dbgServiceConnection)
Could not run build/ios/iphoneos/Runner.app on 00008030-000508D21A83802E.
Try launching Xcode and selecting "Product > Run" to fix the problem:
  open ios/Runner.xcworkspace

Error launching application on XXXXX.

おそらくXcodeのバージョンを上げたらいいのかと思いバージョンを上げてみる。

Xcode 12.2 でアプリが起動してくれました?

Flutter で Sign in with Apple の実装まで

それではFlutterでの本実装の解説に入ります。

Xcode側で「Capability」の設定を行う

Xcode側でSign in with Appleの設定を活性化させておきます。

sign_in_with_apple_2.png

これを設定していないと、Apple認証が正しく動作しません。

pubspec.yamlのソースコード

sign_in_with_appleをインストールするためpubspec.yamlファイルを編集します。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter


  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.0
  sign_in_with_apple: ^2.5.4 # 追加する

これでPub getしてインポートします。

ソースコードについて

今回は味気ないですが、単純に初期コードにSign in with Appleのボタンを追加するだけにします。

main.dart
import 'package:flutter/material.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);
  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
        SignInWithAppleButton(
          onPressed: () async {
            final credential = await SignInWithApple.getAppleIDCredential(
              scopes: [
                AppleIDAuthorizationScopes.email,
                AppleIDAuthorizationScopes.fullName,
              ],
            );

            print(credential);
          },
        )
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), 
    );
  }
}

Sampleにある通りにSign in with AppleのウィジェットはSignInWithAppleButtonだそうです。
これを使って実装しました。

これでソースコードをビルドすると次のような画面が表示されます。

IMG_0467.PNG

個人的にボタンのデザインをカスタムにできたら嬉しいなと思っています。ま、多分カスタマイズできると思っています。黒色の「Sign in with Apple」をタップするとApple認証のやつが下から表示されます。

Apple認証はAppleのサーバーにリクエストしてレスポンスとしてUser情報を受け取りますので、非同期処理のためにasync/awaitで対応します。

getAppleIDCredentialを叩いた時にscopes引数があるのはUserのAppleIdに紐付いている

  • 姓名
  • メールアドレス

は任意でリクエストを送らないと取得できないようになっているからです。
ですので、名前とメールアドレスの取得が必要であればこのscopesAppleIDAuthorizationScopes.emailAppleIDAuthorizationScopes.fullNameをセットしないといけません。

受け取れるユーザー情報

で、ここから本題になりますが、この受け取ったUser情報がどれくらいネイティブアプリと比べて取得できるのかを調べます。

今回のソースコードでは、

print(credential);

この部分の調査になります。まず、credentialはgetAppleIDCredentialを叩いたときに返ってくるものです。このメソッドをググると、

  static Future<AuthorizationCredentialAppleID> getAppleIDCredential({
    @required List<AppleIDAuthorizationScopes> scopes,

    /// Optional parameters for web-based authentication flows on non-Apple platforms
    ///
    /// This parameter is required on Android.
    WebAuthenticationOptions webAuthenticationOptions,

    /// Optional string which, if set, will be be embedded in the resulting `identityToken` field on the [AuthorizationCredentialAppleID].
    ///
    /// This can be used to mitigate replay attacks by using a unique argument per sign-in attempt.
    ///
    /// Can be `null`, in which case no nonce will be passed to the request.
    String nonce,

    /// Data that’s returned to you unmodified in the corresponding [AuthorizationCredentialAppleID.state] after a successful authentication.
    ///
    /// Can be `null`, in which case no state will be passed to the request.
    String state,
  }) async {

というふうにFuture<AuthorizationCredentialAppleID>が返ります。AuthorizationCredentialAppleIDはSign in with Appleを実装したiOSエンジニアならご存知ですがこれがApple認証が成功した時に受け取れるUser情報になります。

以下がcredentialの情報になります。

sign_in_with_apple.png

プロパティ 役割
userIdentifier 一番重要なApple認証後のUser情報
givenName
familyName
email メールアドレス
authorizationCode 実はよくわかりません
identityToken JSON Web Token (JWTです、後述します)
state 状態 (よくわかりません)
print(credential.userIdentifier);
print(credential.givenName);
print(credential.familyName);
print(credential.email);
print(credential.authorizationCode);
print(credential.identityToken);
print(credential.state);

ちなみに2回目以降のApple認証で取得できるUser情報はこちらになります。

スクリーンショット 2020-11-29 21.51.07.png

givenName、familyName、emailが2回目以降取得できないのが再現されています。(それはそう。)
これら3つの情報を何度も取得したい場合は端末のApple認証ステータスをログアウトする必要があります。

「設定アプリ」から「パスワードとセキュリティ」「Apple IDを使用中のApp」の項目へ進んで「Apple IDの使用を停止する」を選択すればAppleIDの使用が停止され再度上記3つのデータを取得できるようになっているはずです。

identityToken の説明

そして、ここからはいつものSign in with Appleの使い方ですが、identityTokenというのはJWTというもので、これは暗号化された文字列になっています。

この情報を解析するためには、

https://jwt.io/

へアクセスして、

image.png

の「Encoded」の部分にidentityTokenをそのままコピーペーストすればデコードされた情報が確認できます。
そして、デコードされた情報の中にcredential.userIdentifierと同じ情報が含まれています。

そのため、例えば、独自のAPIリクエストを使って社内のデータベースと認証して同じユーザーかどうかを確認する場合はこのuserIdentifierを使えば良さそうです。

そんな感じでSwift/iOSでアレだけ面倒だったSign in with Appleがなんと

SignInWithAppleButton(
          onPressed: () async {
            final credential = await SignInWithApple.getAppleIDCredential(
              scopes: [
                AppleIDAuthorizationScopes.email,
                AppleIDAuthorizationScopes.fullName,
              ],
            );

            print(credential);

          },
        )

とこれだけでSign in with Appleの実装ができるのですね。

ここの部分をSwiftで書くとしたら下のようになります。

@objc
func handleAuthorizationAppleIDButtonPress() {
    let appleIDProvider = ASAuthorizationAppleIDProvider()
    let request = appleIDProvider.createRequest()
    request.requestedScopes = [.fullName, .email]

    let authorizationController = ASAuthorizationController(authorizationRequests: [request])
    authorizationController.delegate = self
    authorizationController.presentationContextProvider = self
    authorizationController.performRequests()
}

しかも動作や受け取れるユーザー情報もネイティブのときと同じです。

ただ、ちょっと気になるのがXcode 12.2じゃないとビルドできなかった点ぐらいでしょうか。
iOSが最新バージョンだった影響もあるかもしれません。

(なので、既存のプロジェクトでSign in with Appleを導入する場合は、OSのバージョンに注意したほうが良いかもしれません。)

ネイティブ実装

それではおまけ程度にネイティブでSign in with Appleを実装する方法を解説します。
とはいうもののそんなたいそうな話ではなく既にAppleがサンプルのアプリを用意してくれています。

Implementing User Authentication with Sign in with Apple

ここの「Download」からサンプルプロジェクトをダウンロードできます。
それを見て実装方法を調査すればできます。

ネイティブの場合はリクエストを送信するとDelegateでコールバックでUser情報が返ってきます。
細かいハマリポイントは会社のテックブログでまとめましたので良かったらこちらのページから確認してください。

iOS 版レアジョブアプリが Sign in with Apple に対応した話

こちらの記事では、本記事で取り上げなかった

  • メールアドレスの取り扱い(メールを非公開、にした場合に得られるApple側のアドレスの内容)
  • メール送信機能がある場合の対応方法
  • iOS 13未満のOSに対する取り扱い

について解説しています。

ということで僕からは以上になります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

the Composable Architecture の始め方

iOSDCでのyimajoさんの発表など、the Composable Architecture(以下、TCA)が良さそうという評判を聞いて調べてみました。どこから始めていいのか少し迷ったので、公式レポジトリのREADMEにあるBasic UsageをベースにTCAの始め方を解説してみます。
TCAどころかSwiftUIすら勉強し始めなので、間違いなどあるかもしれません。コメントで教えて頂けると嬉しいです!

TCAって何?

TCAはiOSなどのAppleプラットフォームのアプリケーション開発のためのフレームワークです。
Combineを前提としているため、iOSだとiOS13以上が対象となる制約があります。(ちなみにiOS13未満向けにRxSwift版のforkもあるみたいです)
SwiftUIとの親和性が高く、SwiftUIをより使いやすくする機能が充実しています。

TCAが提供する機能については、READMEのWhat is the Composable Architecture?に以下が挙げられています。

  • State Management
    シンプルな値型によるアプリケーションの状態管理の手段を提供します。複数の画面にまたがって状態は共有され、1つの画面での状態の更新はただちに他の画面にも反映されます。

  • Composition
    巨大な機能を小さなコンポーネントにブレイクダウンして独立したモジュールに分離し、それらを簡単にまとめ直して1つの機能に組み上げる手段を提供します。

  • Side Effects
    可能な限り最もテスタビリティが高く、理解しやすい方法で、副作用を伴うアプリケーションの外側の世界とやり取りする手段を提供します。

  • Testing
    TCAを使って実装した機能をテストするだけでなく、多くの部品で構成された機能の統合テストを書いたり、副作用がアプリケーションにどのように影響するかを把握するためのエンドツーエンドのテストを書く手段を提供します。これにより、ビジネスロジックが期待通りに動作していることを強力に保証することができます。

  • Ergonomics
    コンセプトと部品を可能な限り少なくしたシンプルなAPIにより、上記のすべてを達成する手段を提供します。

アーキテクチャ概要

コードを書き始める前に簡単にTCAのアーキテクチャとしての概要を説明したいと思います。

TCAはReduxやFluxといったアーキテクチャと近い構造を持っていて、Store/State/Action/Reducerという型を持っています。またこれらに加えてEffectとEnvironmentという型も定義しています。

まずState/Action/Reducerについて解説します。

  • State: 1つの機能がそのロジックを実行したり、UIを描画したりするために、必要となるデータを定義する。
  • Action: 1つの機能で発生するすべてのアクション(ユーザからのUI操作やユーザへの通知やデータ層からのデータ受け取りなど)を表現する。
  • Reducer: 受け取ったActionに応じてStateを更新するファンクション。Stateを更新するために発生するあらゆる作用(副作用を含む)に対して処理を行う責務をもち、作用からは Effect 型で値が返却されてくることになる。

上記で出てきたEffectがTCAに特有の考え方になっています。

  • Effect
    • 副作用(Side Effect)を含む作用を表現する型
    • Reduxでは副作用はStore/State/Action/Reducerの枠組みの外で扱うべきとなっている(ActionDispatcherなどが担当することになる)が、TCAではReducerの中で扱うことができるようになっている。

ReducerがEffectを扱うことができることによって、プレゼンテーション層/ドメイン層/データ層の各層で発生するすべてのアクションを単なるActionとして扱うことができるようになっていることが、TCAの特徴の一つだと思います。

次にEnvironmentです。

  • Environment: 1つの機能において必要となる依存を保持する。

いわゆるDI(Dependency Injection)を提供します。
Basic Usageの中ではあまり本質的な使い方がされてなくてよくわかっていない部分があるのですが、Environmentによってロジックの入れ替えが実現可能となり、テスタビリティ向上やState/Action/Reducerの再利用の促進(よりComposableにできる)に繋がっているのかなーと思っています。

最後にStoreです。

  • Store: State/Action/Reducer/Environmentを一つにまとめ、それらの窓口となる。すべてのActionはStoreに対して送信され、それを受けてStoreはReducerを動かす。Reducerの処理結果で発生したStateの変更は、Storeを経由してViewで監視できるようになっている。

StoreをViewにバインドする時は WithViewStore を使い、ViewStoreというViewにバインドできる型になります。

これらを図にまとめると以下のような関係性になっています。
TCAアーキテクチャ
Viewでの操作はすべてActionに変換されます。
ActionをReducerで処理してStateに反映する単方向のデータ制御を実現しており、ViewStoreを経由してViewにStateの変更が反映されるようになっています。
データ層へのアクセスについては、ReducerまたはEnvironmentを経由してEffectを発行し、非同期でデータ操作を行います。結果はEffectからActionを投げることで、Stateに反映されます。

Basic Usage

ここからはTCAを使ってコードを書いていきます。
Basic Usageに記載されている、数字をインクリメント/デクリメントする画面を作成します。
画面イメージ
「+」ボタンでカウンタがインクリメントされ、「-」ボタンでデクリメントされます。
「Number fact」ボタンを押すとアラートを表示します。アラートではカウンタを含んだ文字列が表示されるようにします。

プロジェクト作成からインストール

まずはXcodeで新規プロジェクトを作成します。「Interface」には「SwiftUI」を選択します。
New Project
TCAをインストールします。
TCAはライブラリとして提供されており、Swift Package Managerを使ってインストールします。
SPM

State/Action/Environment/Reducer

TCAの根幹となるState/Action/Environment/Reducerを作ります。

State

import ComposableArchitecture

struct AppState: Equatable {
  var count = 0
  var numberFactAlert: String?
}

count が画面に表示するカウンタの値です。
numberFactAlert には画面の「Number fact」ボタンを押下した時にアラートで表示する文字列が格納されます。

Action

enum AppAction: Equatable {
  case factAlertDismissed
  case decrementButtonTapped
  case incrementButtonTapped
  case numberFactButtonTapped
  case numberFactResponse(Result<String, ApiError>)
}

struct ApiError: Error, Equatable {}

enumでActionを定義します。各ケースは以下のアクションに対応しています。

  • factAlertDismissed: アラートのボタンを押した時のアクション
  • decrementButtonTapped: マイナスボタンを押した時のアクション
  • incrementButtonTapped: プラスボタンを押した時のアクション
  • numberFactButtonTapped: 「Number fact」ボタンを押した時のアクション
  • numberFactResponse: 「Number fact」ボタンにより発生するEffectの戻りのアクション

factAlertDismissed から numberFactButtonTapped までがプレゼンテーション層から発生するアクションです。
numberFactResponse はドメイン層/データ層から発生するアクションです。Basic Usageではデータ層へのアクセスはないため、ドメイン層からのアクションになりますが、データ層にアクセスした場合も似たような形になると思います。成功/失敗に対応できるように Result をAssociated Valueとして受け取れるようになっています。

Envrionment

struct AppEnvironment {
  var mainQueue: AnySchedulerOf<DispatchQueue>
  var numberFact: (Int) -> Effect<String, ApiError>
}

依存対象を切り出しています。
numberFact はカウンタの値を引数にアラートに表示する文字列を作るクロージャです。

Reducer

let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
  switch action {
  case .factAlertDismissed:
    state.numberFactAlert = nil
    return .none

  case .decrementButtonTapped:
    state.count -= 1
    return .none

  case .incrementButtonTapped:
    state.count += 1
    return .none

  case .numberFactButtonTapped:
    return environment.numberFact(state.count)
      .receive(on: environment.mainQueue)
      .catchToEffect()
      .map(AppAction.numberFactResponse)

  case let .numberFactResponse(.success(fact)):
    state.numberFactAlert = fact
    return .none

  case .numberFactResponse(.failure):
    state.numberFactAlert = "Could not load a number fact :("
    return .none
  }
}

Reducerでは各Actionに対応する「Stateへの処理」と「実行するべきEffect」について記述します。
ケース文の最後にreturnしている .none はEmptyのEffectです。これによりそのケースでは実行されるべきEffectがないことを示しています。つまりReducerはState, Action, Environmentを引数にしてEffectを返すクロージャと認識すればよいかなと思います。(正確にはクロージャを引数にした.initを持つstructです)

Effectを使っている case .numberFactButtonTapped: について取り上げます。
「Number fact」ボタンが押された際はEffectで非同期的な処理を実行するようになっています。
AppEnvironment で定義した numberFact クロージャを動かし、戻り値のEffectを最終的にはActionに変換しています。Actionに変換することにより再度Reducerが呼び出されます。ドメイン層/データ層からの戻り値を再びReducerで処理することができるようになっています。
(途中の receive(on:) は実行スレッドの指定、 catchToEffect()receive(on:)Publisher に変換された型を再び Effect に戻しています。)

View

SwiftUIのViewへTCAを組み込みます。プロジェクト新規作成時にデフォルトで存在する ContentView を使うように原本から少し改変しています。

import SwiftUI
import ComposableArchitecture

struct ContentView: View {

  let store: Store<AppState, AppAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      VStack {
        HStack {
          Button("−") { viewStore.send(.decrementButtonTapped) }
          Text("\(viewStore.count)")
          Button("+") { viewStore.send(.incrementButtonTapped) }
        }

        Button("Number fact") { viewStore.send(.numberFactButtonTapped) }
      }
      .alert(
        item: viewStore.binding(
          get: { $0.numberFactAlert.map(FactAlert.init(title:)) },
          send: .factAlertDismissed
        ),
        content: { Alert(title: Text($0.title)) }
      )
    }
  }
}

struct FactAlert: Identifiable {
  var title: String
  var id: String { self.title }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView(
      store: Store(
        initialState: AppState(),
        reducer: appReducer,
        environment: AppEnvironment(
          mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
          numberFact: { number in
            Effect(value: "\(number) is a good number Brent")
          }
        )
      )
    )
  }
}

通常のSwiftUIで出てこないTCA特有の要素について解説していきます。

WithViewStore(self.store) { viewStore in }

WithViewStore でViewにStoreを組み込みます。
WithViewStore はSwiftUIの View を返すので、 var body: some View {} の中で使うことができます。

Button("−") { viewStore.send(.decrementButtonTapped) }

viewStore.send() でActionを送ります。
ここでは「-」ボタンを押した時に .decrementButtonTapped Actionを送るように設定しています。

Text("\(viewStore.count)")

viewStore のプロパティとしてStateのプロパティにアクセスできます。
Stateが更新されれば自動的にViewが更新されます。
SwiftUIだと値をバインドする時にはプロパティラッパーを使うことになると思うのですが、単なるViewStoreのプロパティとして書けるので簡単ですね。
(StoreからStateにプロパティが置き換わっているのがどうやって実現しているのかはまだよくわかっていません...? )

viewStore.binding(get:send:)

viewStore.binding(get:send:) でSwiftUIの Binding を提供して、ViewとViewStoreの間に双方向のバインディングを実現します。ViewStore→View方向のバインディングは get: で指定してStateが引数として渡され、Viewへの出力内容を設定できます。View→ViewStore方向のバインディングは send: で指定し、Viewの操作をトリガーにActionを送ることができます。

ここでは alert(item:content:)item: Binding<FactAlert?> を作るために使われています。
ViewStore→View方向では、Stateの numberFactAlert をAlertで使用する FactAlert struct に変換しています。Stateで保持しているドメイン層のモデルをプレゼンテーション層のモデル( FactAlert )に変換しているイメージかなと思います。
View→ViewStore方向では、アラートでボタンを押した時のアクションを設定しています。

Store(initialState:reducer:environment:)

ContentView_Previews でプレビューできるようにStoreを作っています。 environment: で指定している AppEnvironment(mainQueue:numberFact)numberFact:Effect<String> を返すクロージャを指定し、ようやくここでアラートにどういった文字を表示するのかが決まります。

App

最後にAppでの ContentView 生成箇所を修正して完了です。

import SwiftUI
import ComposableArchitecture

@main
struct TCABasicUsageApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView(
        store: Store(
          initialState: AppState(),
          reducer: appReducer,
          environment: AppEnvironment(
            mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
            numberFact: { number in
              Effect(value: "\(number) is a good number Brent")
            }
          )
        )
      )
    }
  }
}

やっていることはViewの ContentView_Previews と同じです。

これで実行すれば画面が表示できると思います。お疲れ様でした!?

感想

TCA触ってみた感想です。

  • 単方向制御がTCAの中で完結している
    • プレゼンテーション層からの入力も、副作用を伴うデータ層からの入力もActionに変換されるので単方向制御が実現しやすい。
      • ReducerからもEffectを介してActionを発行できるので、ドメイン層内のアクションにも対応できる。
    • 非同期の処理をEffectで表現して、CombineとState/Reducer/Actionの世界をうまく繋げている。
  • SwiftUIとの親和性が高い
    • Viewでの値監視についてプロパティラッパーを意識しないで済むので$アクセスが不要になる。単なるViewStoreのプロパティとしてアクセスすればよくなるのでSwiftUIがより簡単になる。

煩雑になりがちな処理・データの流れが明快で、すごく洗練されたアーキテクチャになっているなと思いました。iOS13以上の制約がクリアできるならぜひ使いたいです。

あと、the Composable ArchitechtureのComposableたる所以である combine / pullBack についてはBasic Usageでは使われておらず、この記事では書いていません。時間があればまたどこかで書きたいなと思っています。

最後までご覧頂きありがとうございました?

参考リンク

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS 14からMultipeer ConnectivityがErrorCode -72008で繋がらなくなったときの解決方法

はじめに

LiDARセンサー付きの iPhone がどうしても欲しくて、最近自身のスマホを iPhone 12 Pro に機種変更しました。
早速、LiDARセンサーの性能を試すべく、Multipeer Conectivity で連携できる自作のARアプリをiPhone 12 ProにXcodeでビルドし、あらかじめ用意してあったもう一台のiPhoneとともにアプリを立ち上げたのですが、

あれ?デバイス同士の連携ができない......?

と異変に気づき、Xcode でコンソールを確認してみると、

2020-12-01 00:34:28.593776+0900 MCSample[28078:11026553] [MCNearbyServiceBrowser] NSNetServiceBrowser did not search with error dict [{
    NSNetServicesErrorCode = "-72008";
    NSNetServicesErrorDomain = 10;
}].
2020-12-01 00:34:28.593778+0900 MCSample[28078:11026556] [MCNearbyServiceAdvertiser] Server did not publish: errorDict [{
    NSNetServicesErrorCode = "-72008";
    NSNetServicesErrorDomain = 10;
}].

という今まで見たことのないエラーログが出ているではありませんか...!
「なぁにこれぇ」って武藤遊戯みたいな反応 になったのですが、調べまくってなんとか解決できたので、ここにその方法を残しておこうと思います。

エラーの原因

今回上記のエラーが発生した iPhone 12 Pro は初期搭載OSバージョンが iOS 14.1でした。
手元でさまざまなOSバージョンの端末で試してみたのですが、そもそも上記のエラーは iOS 14から発生していることがわかりました。
そのことから、iOS 14から追加された新機能に絞って調べてみると、WWDC 2020 の Support local network privacy in your app のセッション で、iOS 14からローカルネットワークへの通信にユーザの許可が必要になったことがわかり、そのための追加実装が抜けていたことが今回のエラーの原因だったとわかりました。

解決する方法

このエラーを解決する方法はApple公式ドキュメントである Discovering Peers with MultipeerConnectivity | Apple Developer Documentation の「Add Bonjour Services Plist Keys」の章にちゃんと記載されていました。
が、ドキュメントを読んでいて、解釈が少々わかりづらいポイントがあったので、その点については後述します。

① ローカルネットワークの利用許可ダイアログの説明文を追加する

まず、今回の対応を行うことで表示されるようになる以下のダイアログの説明文をInfo.plistに追加します。

Property Listから追加する場合は、Privacy - Local Network Usage Descriptionの値に 利用許可を求める説明文 を追加しましょう。

スクリーンショット 2020-12-01 1.38.20.png

Source Codeから追加する場合は、以下のように追記しましょう。

<key>NSLocalNetworkUsageDescription</key>
<string>利用許可を求める説明文</string>

② Bonjour サービスで検索するサービス名の追加

Bonjour(ボンジュール)って急にフランス語が出てきて草生えたのですが、
Bonjour とは、Apple社が開発・提供し、Mac/iPhoneに標準装備されているネットワーク機器を何の設定も行わず簡単に接続するための技術 の名称でした。
https://developer.apple.com/bonjour/

iOS 14以降の Multipeer Connectivity では、Bonjour サービスで検索するために、Info.plistに検出してもらうサービス名を追加する必要があります。

ここで、公式ドキュメントである Discovering Peers with MultipeerConnectivity | Apple Developer Documentation の「Add Bonjour Services Plist Keys」の章を見直してみると、NSBonjourServicesに追加する._tcp._udpのプレフィックスはそれぞれ_myAppNameと記載されています。ここが大きなハマりポイントでした。

最初ドキュメントを見たときに、_myAppName って Product Name もしくは Display Name のことかな? と思って試してみたのですが、全然違いました ?
困り果ててググってみると、 Apple Developer Forums で同じ問題に対するQ&Aの回答 を発見し、完全に理解しました。
_myAppNameとは、MCNearbyServiceAdvertiserMCNearbyServiceBrowser の生成時に共通で使用する serviceType: String の先頭に_を加えた文字列のことだったのです。

なので、Info.plistには、

Property Listから追加する場合は、以下のように Bonjour services の Array に
_serviceType._tcp_serviceType._udp の Item をそれぞれ追加しましょう。

スクリーンショット 2020-12-01 2.22.22.png

Source Codeから追加する場合は、以下のように追記しましょう。

<key>NSBonjourServices</key>
<array>
    <string>_serviceType._tcp</string>
    <string>_serviceType._udp</string>
</array>

以上の①、②の対応を行い、再度ビルドすることで無事にエラーが出なくなり、連携できるようになりました。

まとめ

参考リンク一覧

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Web エンジニアが始める Server-side Swift "Vapor"

はじめに

image.png
2014年の WWDC で電撃発表された Swift は、もはや iOS アプリ開発では定番となっていますが、Web 開発界隈のエンジニアにとっては「何それ美味しいの?」という感じであり、ましてや「サーバサイドで Swift が動く!」とか言われても、どうせ最低限の事しかできず「プレステで Linux が動く!」くらいのお遊びだと思われている方も一定数いらっしゃるのでは無いでしょうか?(シランケド)

とか言う私も、当時の WWDC の Swift 発表は興奮して朝まで眠れなかったくらいですが、最近は Swift からは少し離れて Web アプリ開発にどっぷりです。

そんなわけで、今回は、Web 開発者の目線で、Swift の Web アプリケーションフレームワーク Vapor を触ってみたいと思います。

Swift ってどんな言語?

2014年以前は、iOS や macOS のアプリは Objective-C という変態言語での開発が主流でした。WWDC での突然の発表で世に出た Swift ですが、普及するには4、5年はかかると見られていた予想とは裏腹に、あっと言う間に多くのプロジェクトが Swift に移行していきました。一つの iOS プロジェクト内で Objective-C と Swift を混在して書けるというバイナリ互換が後押ししたというのもあるでしょう。

さらに翌年にはオープンソース化して GitHub で公開したことによって、他のプラットフォームへの移植のハードルが下がりました。今では Linux だけでなく Windows でも動作するようになっています。

じゃあ Linux や Windows でも iOS のようなアプリが作れるのかというとそうではなく、あくまでも言語の基礎部分だけがオープンソース化しているに過ぎず、スマートフォンの画面を構成する UiKit や、マルチメディアな機能を扱う AVFoundation など iPhone 環境に特化した様々な非公開フレームワーク群が備わった環境が iOS SDK としてデベロッパーにのみ提供されており、Xcode でのアプリ開発を可能にしているわけです。

そんな Swift さんですが、Objective-C という技術的負債をバッサリ捨てたことにより、何の障壁もなく様々なプログラムバラダイムのトレンドを実装できたという背景もあって、クロージャーやジェネリクス、Optional 型、タイプセーフな構文などは他のナウい言語と共通する部分もあって学びやすい言語だと思います。

Vapor とは?

Vapor はそんな Swift で書かれた Web アプリケーションフレームワークです。Laravel (Lumen) にインスパイアされて作ったとか作らなかったとか(すみません以前公式に書かれてた気がしましたがソースが見つかりませんでした)。Web アプリケーション開発では一般的な MVC の構成で、DB接続 (PostgreSQL / MySQL / SQLite / MongoDB) はもちろん、ORM やテンプレートエンジンなどの Web に必須となる機能もエコシステムとして提供されています。

Vapor は今のところ Mac と Linux で利用可能です。Mac だと Homebrew、Linux では yum や apt などでパッケージが提供されています。あくまでも実行環境ということなので docker や Homestead を使えば Windows でも開発は可能です。

image.png

余談ですが、Swift 製の Web アプリケーションフレームワークと言えば当初は IBM が開発する Kitura が有名でしたが、調べたら2020年1月に(IBMとして)開発を終了しているようでした。

サーバサイドを Swift で書くメリット

Swift で iOS アプリを開発しているエンジニアにとっては、馴染みの無い PHP や Ruby に手を出してサーバサイドの実装を行うことは大きなハードルになりますが、サーバサイドも Swift で書けるということで、システム全体を得意言語でカバーできることになります。何より頭の中で言語のコンテキストをスイッチすることが無くなると言うメリットは大きいでしょう。Web エンジニア風に言えば、シングルページアプリケーションを JavaScript で書きつつ、サーバ API は node.js で実装するような「JavaScript ボーダレス」な開発環境と言えば分かりやすいんじゃないでしょうか。

では、PHP などの非コンパイル言語での開発を生業としているエンジニアにとって、Swift でサーバサイドのプログラムを実装するメリットは何でしょうか?
Swift を使ったことがなければ答えは「メリットなし」でしょう。餅は餅屋。得意言語で実装するに越したことはないです。ただ、少しでも Swift を触ったことがあれば、(もしくはこれから学ぼうという意欲があれば)、読みやすい、書きやすいで定評のある Swift を使うメリットはあると思います。

個人的には Xcode での開発ができる点もメリットだと思います。Web 開発の IDE は Electron や Javas ベースのものがほとんどで、Web 開発者の多くは、メモリ食い過ぎ問題や動作もっさり問題に悩まされています。 macOS ネイティブで快適に安定動作する IDE は皆無でした。賛否はありますが、Xcode が使えるのは開発効率的にもメリットだと思います。

環境準備

言語比較やIDEの批評をすると石が飛んでくるらしいので早速 Vapor のインストールに入りましょう。
まずは事前に下記をインストール。

Xcode は初回立ち上げ時に License Agreement に同意しないと Vapor のインストールで怒られるので同意しておいてください。Developer Program への登録は不要なので、AppStore からインストールしてください。

インストール

Vapor 本体は Homebrew でインストールします。下記コマンドで一発です。

$ brew install vapor

完了したら vapor コマンドが使用できるので、バージョンが表示されればOKです。

$ vapor --version
framework: 4.36.0

プロジェクトの作成

では早速 Web アプリケーションを作成しましょう。ここでは ApiTest というプロジェクトを作成します。vapor new コマンドでプロジェクト名を渡します。

$ vapor new ApiTest
Cloning template...
name: ApiTest
Would you like to use Fluent? (--fluent/--no-fluent)
y/n>

Fluent を使用するかどうかを聞かれるので、y か n を入力。
Fluent は Laravel で言うところの Eloquent のようなライブラリみたいです。とりあえず y で入れておきます。

Which database would you like to use? (--fluent.db)
1: Postgres (Recommended)
2: MySQL
3: SQLite
4: Mongo

Fluent を使用すると言うことはその後ろにデータベースがあるわけですので、次でデータベース製品を選択します。PostgreSQL が Recomended なので、1 を入力。

Would you like to use Leaf? (--leaf/--no-leaf)
y/n>

次は Leaf の追加。こちらは Laravel で言うところの Blade テンプレートシステムです。もちろん y を入力。

すると完了メッセージと共に下記のようなアスキーアートが表示されます。

image.png

アスキーアートと言えば、昔アドベントカレンダーで書いた自分の記事を思い出しました。全然関係ないけど。

Xcode の起動

プロジェクトディレクトリに移動して下記コマンドで Xcode が起動します。

$ cd cd ApiTest
$ vapor xcode

初回起動時は依存パッケージがダウンロードされるのでしばらく待って、左上のビルドボタン(再生ボタン)がアクティブになるまで待ってください。
image.png

依存バッケージのダウンロードが完了したら、ビルドを実行してみましょう。ショートカットキー「⌘ + R」で実行できます。
初回は時間がかかりますが、完了すると Xcode のコンソールに下記のようなメッセージが表示されます。

[ WARNING ] No custom working directory set for this scheme, using /Users/shigeta/Library/Developer/Xcode/DerivedData/ApiTest-awtfxnyehqlqwxclpfyylflkbavb/Build/Products/Debug
[ NOTICE ] Server starting on http://127.0.0.1:8080

ちょうど npm run dev したような感じですね。ではブラウザでアクセスしましょう。

image.png

軽く構成を眺める

初期構成のファイル構成を軽く見てみましょう。
image.png
既視感があると言うかなんと言うか、もうフォルダ名のファイル名で大体わかっちゃいますよね。主要なファイルをさくっと紹介。

Resources/Views/index.leaf

Leafのテンプレートファイルです。Webエンジニアであれば説明は不要でしょう。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">

  <title>#(title)</title>
</head>

<body>
  <h1>#(title)</h1>
</body>
</html>

Sources/App/Controllers/TodoController.swift

こちらはコントローラーです。
予め TODO の CRUD 遷移が実装されています。

import Fluent
import Vapor

struct TodoController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        let todos = routes.grouped("todos")
        todos.get(use: index)
        todos.post(use: create)
        todos.group(":todoID") { todo in
            todo.delete(use: delete)
        }
    }

    func index(req: Request) throws -> EventLoopFuture<[Todo]> {
        return Todo.query(on: req.db).all()
    }

    func create(req: Request) throws -> EventLoopFuture<Todo> {
        let todo = try req.content.decode(Todo.self)
        return todo.save(on: req.db).map { todo }
    }

    func delete(req: Request) throws -> EventLoopFuture<HTTPStatus> {
        return Todo.find(req.parameters.get("todoID"), on: req.db)
            .unwrap(or: Abort(.notFound))
            .flatMap { $0.delete(on: req.db) }
            .transform(to: .ok)
    }
}

Migrations/CreateTodo.swift

マイグレーションファイルもありますね。Swift 知らなくても Laravel や Rails 使ってる人であれば直感的に理解できるんじゃないでしょうか。

import Fluent

struct CreateTodo: Migration {
    func prepare(on database: Database) -> EventLoopFuture<Void> {
        return database.schema("todos")
            .id()
            .field("title", .string, .required)
            .create()
    }

    func revert(on database: Database) -> EventLoopFuture<Void> {
        return database.schema("todos").delete()
    }
}

Models/Todo.swift

モデルクラスです。

import Fluent
import Vapor

final class Todo: Model, Content {
    static let schema = "todos"

    @ID(key: .id)
    var id: UUID?

    @Field(key: "title")
    var title: String

    init() { }

    init(id: UUID? = nil, title: String) {
        self.id = id
        self.title = title
    }
}

Sources/App/routes.swift

URLルート定義ファイル。URLのマッピングをここに記載。これも Web アプリケーションフレームワークではおなじみですね。

import Fluent
import Vapor

func routes(_ app: Application) throws {
    app.get { req in
        return req.view.render("index", ["title": "Hello Vapor!"])
    }

    app.get("hello") { req -> String in
        return "Hello, world!"
    }

    try app.register(collection: TodoController())
}

まとめ

Vapor はざっと見た感じ Rails や Laravel に近い構成なので、Web アプリケーションエンジニアにとっては取っ付きやすいのでは無いでしょうか。

Swift は言語的にもとてもスマートで、本当に洗練された無駄の無い言語なので、iOS アプリだけで使うのは勿体無いですが、とは言え、PHP などの軽量言語のエンジニアにとってはまだまだ敷居が高いです。特にブラウザ上で動作するメイン言語がまだまだ JavaScript 全盛なので、Web アプリエンジニアにとってはあまりメリットが感じられないかもしれません。

そうなると願いは一つですね。

いつの日か、Swift がブラウザ上で動作する時代が来ますように!

告知

猫会社 猫会社で有名な qnote は今年もアドベントカレンダーに参加しています!
応援よろしくお願いします!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む