20201201のiOSに関する記事は14件です。

iOSの証明書周りのお話

はじめまして、Link-UでiOSエンジニアをしている野田佑樹です。
本日はiOSアプリ開発における証明書周りのお話を少ししたいと思います。

概要

私は2020年度にLink-Uに入社しまして、iOSについての諸々は入社後から勉強を始めました。
たくさんある中でなかなか中身について理解するのが難しかったのが証明書の話です。

なので今回は
証明書周りのお話
について話したいと思います。

具体的にこのファイルがあれば開発ができる。というのはわかるのですが、結局そのファイルって何?みたいなことがもう全然わかりませんでした。
これを機に勉強し、証明書周りで焦ることがないように努めていきます。
あと完全に備忘録目的
多分知っている人からすると「何言ってんだこいつ。そんなの常識だろ」と言われそうですが、生暖かい目で見守ってくれれば幸いです。
それではさっそく行きましょう

iOSの開発フローと証明書について

iOS開発でのフローを大雑把ですが以下に示します。

  1. とりあえず開発する
  2. 随所随所で企画側にテストを送って、バグをつぶしていく
  3. App storeにアプリを申請して審査に通れば晴れてリリース

という流れでアプリがリリースされることになるのですが、これらのフロー全てで証明書類がないと詰むようにできています。

たとえば

  • 開発証明書およびプロビジョニングプロファイルに開発用の端末を登録しないと端末にアプリをビルドできない(ただしシミュレータは除く)
  • testflight等でテストする際にも証明書およびプロビジョニングファイルがないとテスト配信ができない(ただしその他のサービスだとこの限りではないかもしれない)
  • もちろん証明書がないと配信(アプリのリリース)ができない

こんなかんじです。

証明書の説明

ここからは前章で出てきた証明書類について詳しく見ていきます。

ちなみに今回登場する証明書類が大雑把に

  • 開発証明書(cerファイル)
  • 開発証明書(p12ファイル)
  • プロビジョニングファイル

上記の3つです。これらが正しく対応されたものを使用しないとビルドができなくなります。

あとは、iOSアプリでよくみる上から出てくる通知。その通知を送るときに必要な通知証明書等もありますが、それはとりあえずここでは置いておきます。

開発証明書(.cer)

開発証明書と呼ばれる証明書は2種類あります。
体感ですがこれがややこしい一因な気もします。

で、このcerファイルですが
こいつの中身は電子署名された公開鍵です

通常、アプリ開発をする際にまず最初に開発証明書を作成するための要求書(CSRファイル)を作成します。CSRファイルを生成する途中で端末側では秘密鍵と公開鍵が生成されます。CSRファイル内にはその公開鍵が付いており、AppleはそのCSRファイルに署名をしてそれを開発証明書としています。

これらはPCをローカルで特定するための証明書です。
中身の公開鍵を見ることで、秘密鍵を持っている人(CSRファイルを送った人)の公開鍵だとわかります。

Untitled Diagram-Page-1.png
雑な絵ですみません

電子署名に関してはここでは説明をしません。
電子署名をすることで何がわかるのかというと、「開発証明書はAppleから確かに送られた証明書で、改竄もされてない」ことがわかります
気になる方は調べてみるといいかもしれません。
ちなみに、私は学生時代もっとセキュリティについて勉強しておけばよかったと今になって切に思います。

開発証明書(.p12)

上記と一緒で証明書とか呼ばれています。ややこしい

こちらの中身は開発証明書(.cer)+秘密鍵です。

上述のcerファイルはCSRを作成したPCでしか認識しません。しかしチームで開発を行っていると不便です。Aさんが書いたコードはAさんのPCでしか実行できないことになります(上図の公開鍵Aと秘密鍵Aがペアになっているため)。またPCが変わればまたCSRの件からやり直さなければいけません。それでは非常に面倒です。

なので、必要な証明書と秘密鍵をセットにしたものを配布することにしました。
それがp12ファイルです。
このファイルによってチームで開発等をする際にいちいちCSRファイルから作るなんてことをしなくて済みます。
Untitled Diagram-Page-2 (1).png
雑な絵で(ry

プロビジョニングプロファイル

こちらは

  • 開発証明書
  • AppID
  • UDID(実行可能端末)

をまとめたものです。

これらは、

  • 実行する端末がプロビジョニングプロファイル内のUDIDに登録されており、
  • Xcodeで設定しているBundleIDAppID内のBundleIDが一致しており、
  • キーチェーンに保存してある秘密鍵とプロビジョニングプロファイル内の開発証明書

が一致している(ペアになっている)場合のみ端末でビルドできます。

AppID

AppIDはアプリを特定する任意のIDです。
BundleIDとチームIDとかがくっついたものでアプリごとに違うIDが割り振られており、かぶることはありません

UDID

Apple側から端末に対して一意に割り振られているIDのこと。
プロビジョニングプロファイル内のUDIDリストに実行したい端末のUDIDを登録しておきます。

このプロビジョニングプロファイルを雑に説明をするなら
アプリがきちんとしたところのきちんとした人によってビルドされてます
といった証明になるプロファイルです。

最後にバイナリとプロビジョニングプロファイルをまとめた物を秘密鍵で署名をしてアプリは晴れて世に出ます。

まとめ

ざっくりとしたおさらいです。

  • 開発証明書(.cer)はAppleによって電子署名された公開鍵
  • 開発証明書(.p12)は開発証明書(.cer)と対応する秘密鍵のセットになったもの
  • プロビジョニングファイルは開発証明書(.cer)とAppIDとUDIDがセットになったもの

なぜこんな面倒くさいことをするのかというと

一言で言って変なアプリを端末に入れさせないためだと思っています。
つまりこの証明書によってユーザーは信頼できる開発元から出ているアプリのみを入れられることになります。

リリース前になると証明書類で慌てそうなので今回学んだことを思い出して慌てないようにしていきたいですね。

最後まで読んでいただきありがとうございました。

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

Unity as a LibraryによるUIネイティブ化

こんにちは、mizogucheです。2015年9月にクラスターに入ったのでもう6年目です。

この記事はクラスター Advent Calendar 2020 - Qiitaの2日目の記事です。

Unity UIでの課題

UnityでUIを実装すると普段使っているネイティブアプリの操作性と違った触り心地になります。

ネイティブアプリで簡単に実装できるUIの操作性をUnityで再現するコストはかなり高く、たとえばiOSにおける戻るジェスチャ、Androidにおけるバックボタンなど、OS固有の操作はネイティブUIだと何も実装しなくても実現できる1一方で、Unityではそれぞれのプラットフォームごとに独自に実装していく必要があります。

感覚的なものでいうと、スクロールビューのスクロールの感触なんかもUnity、iOS、Androidでそれぞれ違うので、Unity製のアプリだと他のネイティブアプリと比較して触り心地がよくないのが正直なところです。

Unityのランタイムの起動に時間がかかる分、アプリの起動時間が遅くなってしまうというのも大きな問題でした。

Unity as a Library

Unityでビルドしたアプリを起動したらすぐにUnityのランタイムが実行されますが、Unity as a Library(= UaaL)という技術で、アプリの一部分だけでUnityを利用することができます。

これによりUnityでの再現が難しいネイティブアプリのUIの操作感を実現しつつ、必要なところでUnityの表現力を発揮することができます。

メンテナンスコストの増加

すべてがUnity製のアプリであれば1つのコードからiOS/Android両プラットフォームのアプリをビルドすることができます。

しかし、UaaLを使うことでiOS/Android両プラットフォームのネイティブUI部分を実装する必要が生まれます。

つまり、UaaLの採用によって実質的に2つのアプリを開発・メンテナンスする必要が生まれます。

clusterではUaaLをどう利用したか

ネイティブUIのoutroom/Unityのinroom

ワールド・イベントに入るまでの世界をoutroom、ワールド・イベントに入った後のUnityの世界をinroomと呼んでいます2

モバイルアプリ版clusterで、outroomをネイティブ、inroomでUnityを使うようアップデートしたものが11月にリリースした v1.85です。

これにより、Unity UIでの課題を解決してネイティブの触り心地を実現つつ、ワールド・イベントのクリエイティブな領域ではUnityの表現力を使うことができるようになりました。

この動画をご覧いただくと、普通にネイティブUIで実装し直すだけでどれだけ改善されたかがおわかりいただけるかと思います。

今後

outroomをネイティブUIにして体験を改善することができました。

しかしまだまだ改善することは山積みなため、クラスターではiOS/Androidエンジニアを募集しています。

というわけで2日目の記事は以上です。

明日は__0xyさんがなんかかくそうです。どんなことが書かれるんやろ…… ? お楽しみに!!

クラスター Advent Calendar 2020 - Qiita

参考リンク


  1. Androidのバックボタンは遷移先の制御を考えると何も実装しなくてもいいは過言 :innocent:  

  2. roomというのは古来よりクラスター内部で使われてきた用語です。 

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

TODOアプリで比較するAndroid(MVVM)とFlutter(Provider)の違い

最近Flutterを初めて、Android(Kotlin)の書き方よりもずいぶん素早く開発できることに驚いたので、TODOアプリを通して比較してみました。
筆者はJavaもKotlinもFlutterも実務経験はなく、半年程度の経験しかありません。
なので対象読者としては今からスマホのアプリを開発してみたいけど、ネイティブ(Java/Kotlin,Objective-C/Swift)を勉強するかFlutterのようなクロスプラットフォーム開発を勉強しているか悩んでいる人を想定しています。
先にこの記事の主張を述べると、要するに以下のような理由で、FlutterのProviderパターン最高って話です。

  • iOS/Android同時に開発できて嬉しい
  • Kotlinのdata bindingのようなものがnotifyListener()だけでできる。直感的
  • 特にリストのバインドが面倒(アダプターを作る必要がある)

作ったアプリはこんなものです。

Kotlinバージョン

demo_kotlin.gif
GitHubリポジトリ: https://github.com/tokku5552/TODOAppSample-Kotlin

Flutterバージョン

Screenshot_1606635626.png
GitHubリポジトリ: https://github.com/tokku5552/TODOAppSample-Flutter

アーキテクチャ概要

5分くらいで書いたアーキテクチャメモをもとにざっくり説明します。

Kotlin - MVVM

TODOApp.png

MainActivityでは画面遷移のみを行い、2つのフラグメントでTodoリスト一覧と、詳細画面を表示を行っています。
それぞれのフラグメントはMainActivityViewModelを参照していて、画面遷移が伴うアクションの時はMainActivityViewModelを通して画面遷移します。

一覧のTodoItemをクリックすると、MainActivityのclickedItemが呼ばれて詳細画面に各値を入れた状態で表示させます。
もう少し詳しくこちらのブログで解説しています。

https://tokku-engineer.tech/todomvvmkotlin/

バージョン
  • Realm 2.1.1

Flutter - Provider

TODOAppFlutter.png
main.dartで一覧を表示し、TodoItemDetailPageで詳細画面を表示しています。
本当はRealmを使いたかったのですが、FlutterではRealmのいい感じのパッケージが見つからなかったので、
しょうがなくSQLiteを使っています。
基本的に非同期で描画しておいて、MainModel(ViewModelにあたる)で値をとってきたり更新したりした後に更新をnotifyListenerで通知します。
こちらでもう少し詳しく解説しています。
https://tokku-engineer.tech/todoapp-provider-flutter/

パッケージのバージョン
  • provider: 4.3.2+2
  • sqflite: 1.3.2+1
  • shared_preferences: 0.5.12+4

それでは私がなぜFlutterのProviderパターンを推してるかを以下で説明します。

iOS/Android同時に開発できて嬉しい

いきなりそもそもの話になりますが、Flutterはクロスプラットフォーム開発のためのフレームワークなので、1つコードを書くだけで、AndroidとiOSのアプリを同時に作ることが出来ます。
Androidだと、画面を作る際にレイアウト用のxmlと、View(Activity or Fragment)を用意する必要がありますが、Flutterでは1つのdartファイルを書けばよいので非常に効率的です。
また、ホットリロード出来る点も、すぐに結果が確認できてスピードが上がった気がします。

KotlinのDataBindingのようなものがnotifyListener()だけでできる

Androidではdata bindingというjetpack1ライブラリを使って、ViewとViewModelの依存関係の方向を整理しています。
例えば詳細画面のコードを比べてみます。

TodoItemDetailFragment.kt
//詳細画面の表示
    private fun showTask(todoItem: TodoItem) {
        binding.editTitle.setText(todoItem.title, TextView.BufferType.EDITABLE)
        binding.editDetail.setText(todoItem.detail, TextView.BufferType.EDITABLE)
        binding.editCreate.text = todoItem.createDate.toString("yyyy/MM/dd")
        binding.createDate.isVisible = true
        binding.editCreate.isVisible = true
        if (todoItem.createDate != todoItem.updateDate) {
            binding.editUpdate.text = todoItem.updateDate.toString("yyyy/MM/dd")
            binding.editUpdate.isVisible = true
            binding.update.isVisible = true
        }
        binding.buttonLeft.text = "更新"
        binding.buttonLeft.setOnClickListener {
            todoItemDetailFragmentViewModel.updateTask(
                todoItem.id,
                binding.editTitle.text.toString(),
                binding.editDetail.text.toString()
            )
            closeFragment()
        }

    }

Kotlinの場合です。
上記のようにそれぞれのTextViewにbindしているのに加えて、
xmlファイルで以下のようにdatabindingのためのmodelの指定を記載する必要があります。

todo_item_detail_fragment.xml
<data>
    <import type="android.view.View" />
    <import type="androidx.core.content.ContextCompat" />
    <variable
        name="viewmodel"
        type="tech.tokku_engineer.todoappsample_kotlin.viewmodels.TodoItemDetailFragmentViewModel" />
</data>

Flutterの場合はこんな感じになります。

todo_item_detail_page.dart
 body: Consumer<TodoItemDetailModel>(builder: (context, model, child) {
          model.todoTitle = todoItem?.title;
          model.todoBody = todoItem?.body;
          return Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              children: [
                TextField(
                  decoration: InputDecoration(
                    labelText: "タイトル",
                    hintText: "やること",
                  ),
                  onChanged: (title) {
                    model.todoTitle = title;
                  },
                  controller: titleEditingController,
                ),
                SizedBox(
                  height: 16,
                ),
                TextField(
                  decoration: InputDecoration(
                    labelText: "詳細",
                    hintText: "やることの詳細",
                  ),
                  onChanged: (body) {
                    model.todoBody = body;
                  },
                  controller: detailEditingController,
                ),
                SizedBox(
                  height: 16,
                ),
                RaisedButton(
                  child: Text(isUpdate ? "更新する" : "追加する"),
                  onPressed: () async {
                    try {
                      isUpdate
                          ? await model.update(todoItem.id)
                          : await model.add();
                      Navigator.pop(context);
                    } catch (e) {
                      //なんかエラー処理
                              )
                            ],
                          );
                        },
                      );
                    }
                  },
                )
              ],
            ),
          );
         }),

私は個人的に、この括弧をいくつも改行して表示するスタイルはあまり好きではないですが、ほとんどがレイアウト用のコードになっていて、データを直接modelのフィールドに代入したり取得したりできます。
変更を伝えるには、model側でnotifyListenerするだけです。

todo_item_detail_model.dart
Future<void> update(int id) async {
    await TodoItemRepository.updateTodoItem(
      id: id,
      title: (todoTitle.isEmpty) ? todoTitle = "" : todoTitle,
      body: (todoBody.isEmpty) ? "" : todoBody,
    );
    notifyListeners();
  }

上記の例はDBで更新をかけている例ですが、フィールドに値を代入した後、notifyListeners();とするだけでUI側に更新が通知されます。
非同期処理もasync:awaitで書けて、私にとってはわかりやすかったです。(KotlinもCoroutine使うとずいぶん直感的に書けますが・・・)

特にリストのバインドが面倒(アダプターを作る必要がある)

特に分かりやすさに差が出たのがリストの表示です。Kotlinの場合ListViewAdapterというクラスを作成して、ViewHolderを定義して、onCreateViewHolderをとonBindViewHolderをそれぞれオーバライドして生成⇒bindしてあげて、、、
date bindingしてる場合はexecutePendingBindings()とかを呼ばないとうまくバインドされなくて・・・
と、結構初学者殺しな感じがします。

ですが、Flutterだと、listをmapするだけ、、、なんと直感的

main.dart
body: Consumer<MainModel>(builder: (context, model, child) {
          final todoList = model.list;
          return ListView(
            children: todoList
                    ?.map(
                      (todo) => ListTile(
                        leading: Checkbox(
                          value: todo.isDone,
                          onChanged: (bool value) {
                            //なんか処理
                          },
                        ),
                        title: //いい感じの文字
                        ),
                        onTap: () {
                          pushWithReload(context, model, todoItem: todo);
                        },
                      ),
                    )
                    ?.toList() ??
                [
                  ListTile(
                    title: Text(""),
                  )
                ],
          );
        }),

リストがnullだった時の処理を三項演算で書いてしまっているので少しわかりずらいですが、listをmodelに入れて、UI側は単にmapでしこしこウィジェットを表示していくだけで良いです。
もちろん状態が変わってもmodel側でnotifyListenersしてあげれば即座に更新されます。

まとめ

おそらく私の経験や知識が不足していることもあり、いろいろなことが考慮できていなかったり設計が甘かったりするのだと思いますが、それでもFlutterはかなり直感的に書けるという印象でした。
AndroidにしてもFlutterにしても(おそらくiOSも)このようなパターンが生まれるまでにGoogleやAppleの優秀な開発者たちの紆余曲折があったんだと思います。
ちなみに筆者はまだKotlinなんて登場していなくて、Eclipseを使って開発することが主流だった時代に一度Androidアプリの開発にチャレンジしたことがありますが、エミュレータはまともに動かないし、Javaで毎回findViewByIdしてidをとりまくらないといけないしで途中で挫折してしまいました・・・

これからもどんどん書き方が変わっていくだろうと思いますので、素早く優れたパターンを設計に取り入れられるかが、スマホアプリの開発において重要だと感じました。

[参考]

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

XCUITest の最初のテスト

dwango2 Advent Calendar の記事です。四日目は iOS の話です。
こんにちは、 @daichiro です。
ドワンゴには iOS 四天王というのがおり[要出典]、私もその一人でしたが、四天王と揶揄されるのが恥ずかしいので今はその地位を譲りました。
若いエンジニアでも活躍できる、そして四天王が作られるほど iOS エンジニアがいる良い環境であると言えます。

さて、みなさん UITest は書いているでしょうか。
アプリの振る舞いをプログラムで制御し、画面の要素や画面繊維が期待した通りになっているかを調べることができます。
利用されるシーンとしては、アプリのバージョンアップ前に既存機能が壊れていないか調べる リグレッションテスト が多いかと思います。

リグレッションテストは多くのプロジェクトで行われていると信じていますが、それが自動化されているかというと、数えるほどになってしまうという感覚です。

今回は簡単なサンプルアプリに対して UITest をサクッと書くという記事です。
皆さんのプロジェクトの導入のきっかけになれば幸いです。

導入

新しく Xcode Project を作成する際は、 Test を作るチェックマークを入れておけば自動的に作られます。
既存のプロジェクトに追加する場合はいくつか手順が必要です。
これは Qiita に様々な記事があるので参照されると良いと思います。

ここまでできたと仮定して、テストを書いてみましょう。

最初のテスト

UITest で最も初歩的なのは、表示を確認するものだと思います。

  • 画面の要素を確認する
  • 画面遷移を確認する

ログインしているかどうか、プレミアム会員かどうか、というお客様の状態によって画面の要素が変化するというのはよくあるので、画面にある要素の確認のテストは必要ですね。
しかし画面の要素に対して何か操作を行うものの方が UITest をやっている感があるでしょうか。
ということで、画面遷移のテストを書いてみましょう。

コード

import SwiftUI

struct ContentView: View {
    @State private var toDetailView = false
    var body: some View {
        NavigationView {
            VStack {
                Button("Go to detail") {
                    toDetailView = true
                }
                NavigationLink(
                    destination: DetailView(),
                    isActive: $toDetailView,
                    label: { EmptyView() })
            }
            .navigationBarTitle("Content")
        }
    }
}

struct DetailView: View {
    var body: some View {
        VStack {
            Text("This is detail view")
        }
        .navigationBarTitle("Detail")
    }
}

ContentView の中心に存在する Go to detail と書かれたボタンをタップすると、DetailView に遷移するはずです。

テスト

import XCTest

final class ContentViewUITests: XCTestCase {
    func testMoveToDetailView() {
        let app = XCUIApplication()
        XCTContext.runActivity(named: "Launch app") { _ in
            app.launch()
        }
        XCTContext.runActivity(named: "Check content view") { _ in
            XCTAssertTrue(app.navigationBars["Content"].exists)
        }
        XCTContext.runActivity(named: "Tap button and show detail view") { _ in
            app.buttons["Go to detail"].firstMatch.tap()
            XCTAssertTrue(app.navigationBars["Detail"].exists)
            XCTAssertTrue(app.staticTexts["This is detail view"].exists)
        }
    }
}

これを実行すると、シミュレータが起動し、テストが実行されます。

sample.gif

アプリが起動して画面遷移を一瞬のうちに行い、テストは成功です。

XCUIApplication

let app = XCUIApplication()
app.launch()

XCUITest においては XCUIApplication を中心としてコトが進んでいきます。
これは起動するアプリそのものと言っていいでしょう。

navigationBars buttons staticTexts images などのプロパティが生えていますが、これは 現在表示されているスクリーン に対してアクセスできるものが取得できます。
これらは XCUIElementQuery という型になっていて、Label や Identifier を使って特定の要素にアクセスできます。

例えば、Content View が開かれている時には app.navigationBars["Content"] は一件ヒットします。
逆に言えば、Detail View が開かれている時にはヒットしないので、現在の画面が何であるか、要素が存在するかということのチェックに使えるというわけです。

より詳しく知りたい場合は公式ドキュメントを探ってみてください。

変更に強くする

さて、先ほどのテストで今はうまくいっていますが、そのうち動かなくなることは明白です。
なぜなら、要素に直にアクセスしているからです。

app.buttons["Go to detail"].firstMatch.tap()

この文を例にとると、Go to detail という文字列を直に検索しているので、ここが何かの変更によって書き換えられるとたちまちテストは通らなくなります。
文字列自体に意味があれば別ですが、ここでは Detail View に遷移するボタンさえあれば、ボタンの様子は気にしません。
また、他に Go to detail と書かれたボタンが置かれたら想定しないボタンをタップしてしまうこともあります。

ではどうすれば良いかというと、 AccessibilityIdentifier を使います。

AccessibilityIdentifier

アクセシビリティ?多言語対応?ボイスオーバー?などと思うかもしれませんが、別物です
ユーザーに見える accessibilityLabel とは違って、こちらは開発者向けのプロパティです。(多分)

これを各要素に設定してあげることにより、UITest はその要素の見た目に関わらず同じ振る舞いをすることができます。
ではやってみましょう。

コード

struct ContentView: View {
    @State private var toDetailView = false
    var body: some View {
        NavigationView {
            VStack {
                Button("Go to detail") {
                    toDetailView = true
                }
                .accessibility(identifier: "cloud.mokumoku.uitestsample.contentview.button")
                NavigationLink(
                    destination: DetailView(),
                    isActive: $toDetailView,
                    label: { EmptyView() })
            }
            .navigationBarTitle("Content")
        }
    }
}

struct DetailView: View {
    var body: some View {
        VStack {
            Text("This is detail view")
                .accessibility(identifier: "cloud.mokumoku.uitestsample.detail.text")
        }
        .navigationBarTitle("Detail")
    }
}

テスト

final class ContentViewUITests: XCTestCase {
    func testMoveToDetailView() {
        let app = XCUIApplication()
        XCTContext.runActivity(named: "Launch app") { _ in
            app.launch()
        }
        XCTContext.runActivity(named: "Check content view") { _ in
            XCTAssertTrue(app.navigationBars["Content"].exists)
        }
        XCTContext.runActivity(named: "Tap button and show detail view") { _ in
            app.buttons["cloud.mokumoku.uitestsample.contentview.button"].tap()
            XCTAssertTrue(app.navigationBars["Detail"].exists)
            XCTAssertTrue(app.staticTexts["cloud.mokumoku.uitestsample.detail.text"].exists)
        }
    }
}

SwiftUI はわかりやすいですね。

このようにして AccessibilityIdentifier を設定してあげると文言修正や場所変更に捉われず安定したテストを作ることができます。

そうは言っても

いきなり Identifier を設定するなんて、どこからやったらいいかわからないし、既存のコードがそういう作りになってなくてやりづらいんですけど…

というのが現実かと思われます。

大丈夫です。 Identifier はなくてもいいので、まずは書くこと、カバレッジを 1% でも上げることが大事だと思います。

by 半年 UITest を書いている人より

次回予告

実際のアプリはもっと要素が多くて複雑です。
このままだと一つ一つのテストが長くなり、理解に時間がかかります。
次回はもうちょっと踏み込んで体系的に UITest を書けるようにしていきましょう。

明日は @daichiro です。

  • このエントリーをはてなブックマークに追加
  • 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で続きを読む

開発者ごとでなく、プロジェクトごとにFlutterのバージョンを管理する

Flutterのアップデート、まだまだ激しいですよね。
以前チーム開発していた時も、

  • 他の開発者とのFlutterのバージョン違いによりAPI定義が異なりエラーの嵐→無駄なコミュニケーションの発生
  • 自分のPCのFlutterのバージョンを上げてしまったところ、ビルドできなくなったといった報告の対応

など、プロジェクトごとでなく開発者ごとにFlutterのバージョンを管理しているために起きてしまう問題がいくつかありました。また、OSSや個人開発などでも使用するFlutter SDKのバージョンを固定したいこともあるかと思われます。

本記事では、開発者ごとでなく、プロジェクトごとにFlutterのバージョンを管理する方法を紹介します。

FVMを用いる場合

FVMはFlutter SDKバージョン管理ツールです。

https://github.com/leoafarias/fvm

導入についてはこちらの記事が日本語で分かりやすいので、説明は割愛します。

https://qiita.com/Kurusu_ima/items/2dfd067f6e79f520198f

バージョンをプロジェクトごとに固定する方法

ターミナルを開いてプロジェクトのルートに移動しておいてください。

インストール可能なリリース済みのバージョン一覧を確認しましょう。

$ fvm releases

次に現在インストールされているバージョンの一覧を確認してみましょう。

$ fvm list

お目当てのバージョンがなければ、以下のコマンドでバージョン名またはチャンネル名からFlutter SDKが手に入ります。

$ fvm install <バージョン名またはチャンネル名>

例えばstableを使いたい人は以下のコマンドです。

$ fvm install stable

最後に、以下のコマンドで、プロジェクトのFlutter SDKのバージョンを固定することができます。

$ fvm use <バージョン名またはチャンネル名>

そうすると、プロジェクト内に.fvmが作成されます。.fvm内には以下が入っています。

  • プロジェクトで固定されるFlutter SDKバージョン情報が記載された.fvm/fvm_config.json
  • ~/fvm/versions/<バージョン名>のシンボリックリンクとなっている.fvm/flutter_sdk

すでにFVMによりバージョン固定されているプロジェクトで開発する時

バージョンを指定せず以下のコマンドを実行すると、.fvm/fvm_config.jsonのバージョンをインストールできます。

$ fvm install

固定されたバージョンのFlutter SDKを使用する

flutterコマンドの前にfvmをつけるだけです。例としてflutter runしたい場合は以下のようになります。

$ fvm flutter run

IDEの設定

IDEの操作でも固定したFlutter SDK下で実行できるようにします。デバッガーなどIDEの機能を使いたい時のために必要です。

Android Studioの場合

Preferences > Languages & Frameworks > FlutterのFlutter SDK pathを{対象プロジェクトのルート}/.fvm/flutter_sdkに変更してください。

VSCodeの場合

{対象プロジェクトのルート}/.vscode/settings.jsonに以下を追加してください。

{
    "dart.flutterSdkPath": [".fvm/flutter_sdk"],
}

VSCodeを再起動し、コマンドパレットでFlutter: Change SDKと入力してバージョンを選んでください。

Flutter Wrapperを用いる場合

更新が一年以上止まっているのですが、特定のバージョンのFlutter SDKをダウンロード&実行するシェルスクリプトによってFlutterのバージョンをプロジェクトで固定できるFlutter Wrapperを用いる方法もあります。

https://github.com/passsy/flutter_wrapper

導入

ターミナルでバージョンを固定したいプロジェクトのルートに移動した後、以下を実行するだけです。

$ sh -c "$(curl -fsSL https://raw.githubusercontent.com/passsy/flutter_wrapper/master/install.sh)"

以降はflutterの代わりに./flutterwのコマンドを用いることで、固定されたFlutter SDK下でコマンドを実行することができます。

アップグレード

特に新しいコマンドを覚える必要はありません。これもflutterの代わりに./flutterwになるだけで、いつものFlutterのアップグレードと同じです。

IDEの設定

各IDEで設定されているFlutter SDKのパスを変更しておくことで、FVMの時と同様、IDEの操作でも固定したFlutter SDK下で実行できるようにします。

Android Studioの場合

Preferences > Languages & Frameworks > FlutterのFlutter SDK pathを{対象プロジェクトのルート}/.flutterに変更してください。

VSCodeの場合

{対象プロジェクトのルート}/.vscode/settings.jsonに以下を追加してください。

{
    "dart.flutterSdkPath": ".flutter",
}

まとめ

Flutter Wrapperは更新があまりされていないので、現在のところFVMがスタンダードなのかなという感じです。FVMの方がバージョン切り替えもスムーズかと思います。強いて言うなら、Flutter WrapperはFVMのインストールを必要としないので、コマンド一発で使えるというところがメリットでしょうか。

参考

https://qiita.com/tetsufe/items/8ffa296c22a2dc8b9b51
https://qiita.com/Slowhand0309/items/0767abee120fcb3ba0b4

  • このエントリーをはてなブックマークに追加
  • 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で続きを読む

SwiftUI App ライフサイクル で Watch Connectivity

はじめに

Xcode 12 から、プロジェクト作成時に Lifecycle を選択できるようになり、従来の "UIKit App Delegate" だけではなく、"SwiftUI App" を設定できるようになりました。

このようにして作成したプロジェクトには、AppDelegate.swift と SceneDelegate.swift がありません。
これまで、アプリの初期設定等をここで行っていましたが、"SwiftUI App" でアプリを作成した場合は、初期設定をどこでどのように行えば良いのでしょうか?

ここでは、SwiftUI App に iPhone と Apple Watch 間で通信を行うための、Watch Connectvity を使うための設定方法を説明します。
おそらく、Watch Connectvity 以外にも、AppDelegate.swift で初期設定を行なっていたアプリ全般に適用できるテクニックだと思います。

なお、本稿では、SwiftUI ならびに Watch Connectivity についての基本的な説明は割愛します。

開発環境等

  • MacBook (Retina, 12inch, Early 2015)
  • macOS Catalina Version 10.15.7
  • Xcode Version 12.2(12B45b)
  • iOS 14.2.1(18B121)
  • watchOS 7.1(18R590)

従来の(App Delegate による)設定方法

"UIKit App Delegate" ライフサイクルでは、AppDelegate.swift でこんなふうに設定します(iPhone側)。

AppDelegate.swift
import UIKit
import WatchConnectivity

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, WCSessionDelegate {

    ...<中略>...

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {

        // Watch Connectivity 初期設定
        if WCSession.isSupported() {
            let defaultSession = WCSession.default
            defaultSession.delegate = self
            defaultSession.activate()
        }

        return true
    }

Lifecycle として "SwiftUI App" を選択して作成したプロジェクトでは、AppDelegate.swift がありません。では、Watch Connectivity 周りの初期設定はどこでどう行えば良いのでしょうか?

やはり AppDelegate ?

実は SwiftUI App でも AppDelegate を使うことができます。
@UIApplicationDelegateAdaptor() を使います。

※ここでは、SwiftUIWCTest という名前のアプリを作成しているものとします。SwiftUIWCTestApp.swift というファイルが自動生成されますので、そこに以下のように記述します。

SwiftUIWCTestApp.swift
@main
struct TestApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

class AppDelegate: UIResponder, UIApplicationDelegate, WCSessionDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {

        // Watch Connectivity 初期設定
        if WCSession.isSupported() {
            let defaultSession = WCSession.default
            defaultSession.delegate = self
            defaultSession.activate()
            }

        return true
    }
}

ただ、せっかく SwiftUI で AppDelegate を葬り去ったのに、またまた AppDelegate のお世話になるのはあんまり美しくありません。
加えて、Watch Connetctivity を使うには、Apple Watch 側でも設定が必要で、そちらではもともと AppDelegate はありません。どうすれば良いのでしょうか?

SwiftUI App ライフサイクルでの実装

まずは、WCSessionDelegate プロトコルを実装したクラスを用意します(ここでは、クラス名を WCManager とします)。

WCManager.swift
import Foundation
import WatchConnectivity

class WCManager: NSObject, WCSessionDelegate {
    static var shared = WCManager()

    /// 初期化〜有効化
    func activate() {
        // Watch Connectivity
            if (WCSession.isSupported()) {
                let session = WCSession.default
                session.delegate = self
                session.activate()
            } else {
                print("** \(device):  WC is NOT Supported")
            }
        }
    }

    // メッセージ送受信メソッドなどを実装
    ...<中略>...
}

このクラスには、他にメッセージ送受のためのメソッドなどもまとめて実装します。
この辺は、SwiftUI App に限った話ではないのでここでは割愛します。

このうえで、SwiftUIWCTestApp の init() で activate すればOKです。

SwiftUIWCTestApp.swift
@main
struct SwiftUIWCTestApp: App {
    init() {
        // Watch Connectivity 初期設定
        WCManager.shared.activate()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Apple Watch 側でも同様の方法で WCSession の初期設定を行います。
WCManager.swift は、iPhone アプリ側と、WatchKit Extension 側の両方からソースコードを共有できるように設定しておきます(Target Membership で設定)。

Apple Watfch側では、SwiftUIWCTest WatchKit Extension グループ下に、同じく SwiftUIWCTestApp.swift というファイルがあるので、SwiftUIWCTestApp の init() で初期設定します。

SwiftUIWCTestApp.swift
@main
struct SwiftUIWCTestApp: App {

    init() {
        // Watch Connectivity 初期設定
        WCManager.shared.activate()
    }

    @SceneBuilder var body: some Scene {
        WindowGroup {
            ContentView()
        }

        WKNotificationScene(controller: NotificationController.self, category: "myCategory")
    }
}

おわりに

Apple Watch 開発に関する情報はまだまだ少ないように思います。本稿で説明したのは簡単なことですが、個人的に試行錯誤して発見したことですので記録しておきます。
どなたかの参考になれば幸いです。

参考

  • このエントリーをはてなブックマークに追加
  • 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で続きを読む

最新のiOSのバージョンを取得する方法

はじめに

ソフトウェアテストの業界向けに役に立つ話、番外編です。

最新のiOSがリリースされた時に、いちはやくリリースされたことをシェアしたいと思い、botを作りました。今回はチャットサービスに送信する部分は省略して、iOSのバージョン取得部分を紹介します。
スマホを持つ猫

1. アップデートxmlの取得

iOSはアップデートを確認する際、下記アドレスに確認しにいくようです。
http://mesu.apple.com/assets/com_apple_MobileAsset_SoftwareUpdate/com_apple_MobileAsset_SoftwareUpdate.xml

import requests

url = "http://mesu.apple.com/assets/com_apple_MobileAsset_SoftwareUpdate/com_apple_MobileAsset_SoftwareUpdate.xml"
res = requests.get(url)

まずはリクエストを送信し、xmlを変数に格納しましょう。

2. xmlをパースする

import xml.etree.ElementTree as ET

root = ET.fromstring(res.text)

次にxml.etree.ElementTree モジュールを使用し、XML データを扱えるようにします。

3. 調べたいデバイスが何番目にあるか

<plist version="1.0">
<dict>
    <array>
        <dict>
            ...
            <key>OSVersion</key>
            <string>9.9.14.2</string>
        <array>
            ...
        <array>
            <string>iPhone11,6</string>
        <dict>
        ...

本xmlでは、dict > array > dict の中にデバイス毎のアップデート情報が記載されていますが、デバイスの機種毎に提供されている最新OSは異なる為、「iPhone11,6」(iPhone12 Pro Max) など最新OSが提供されてそうなデバイスを指定します。

devices = root.find('dict').find('array').findall('dict')

TARGET_DEVICES = "iPhone11,6"

for number, device in enumerate(devices):
    for keys in device.findall('array'):
        for key in keys:
            if key.text == TARGET_DEVICES:
                return number

ひとまず、「iPhone12 Pro Max」はリストの何番目なのか探します。

4. バージョンを取り出す

device = devices[number]

for i, key in enumerate(device):
    if key.text == 'OSVersion':
        version = device[i + 1].text
        return version
<key>OSVersion</key>
<string>9.9.14.2</string>

前述の節でリストの何番目のデバイス情報を取り出せばいいかわかったので、次はOSVersionというキーの下にある要素を見つけます。

9.9.14.2

これでバージョンを取り出せましたが、謎の9.9表記がついています。

5. 不要な表記を削除する

if version[:4] == '9.9.':
    version = version.replace('9.9.', '')

バージョン情報には必ず頭に「9.9」という表記がついています。これが何の意味があるかはわかりませんが、今回は不要なので取り除きます。

6. 結果

14.2

無事バージョンを綺麗に取得できました。

さいごに

このバージョンをpickleに保存しておけば、前回のバージョンと差があった際にチャットサービスにお知らせするbotが実現できるかと思います。

※ Appleのサーバに負担がかからないように定期実行の頻度は気をつけましょう。

コード全文

import requests
import xml.etree.ElementTree as ET


def get_ios_version() -> str:
    """iOSの最新バージョンを表示するメソッド

    特定デバイスに提供されている最新のiOSのバージョンを表示します。

    Returns:
        str: iOSのバージョン
    """
    def get_version(response_text: str) -> str:
        """バージョンを取り出す為の一連の動作を制御するメソッド

        Args:
            response_text (str): xmlのレスポンスのテキスト
        Returns:
            str: バージョン
        """
        root = ET.fromstring(response_text)
        devices = root.find('dict').find('array').findall('dict')
        number = get_match_number(devices)

        if number is not None:
            version = get_version_text(devices[number])

            if version:
                return format_version(version)

    def format_version(version: str) -> str:
        """バージョンに含まれる不要な表記を取り除くメソッド

        Args:
            version (str): バージョンのテキスト
        Returns:
            str: 通常、不要な表記である「9.9.」が頭に含まれる為、除去
                Example:
                    9.9.14.2 -> 14.2
        """
        if version[:4] == '9.9.':
            return version.replace('9.9.', '')
        else:
            return version

    def get_version_text(device: ET.Element) -> str:
        """バージョンを実際に取り出すメソッド

        Args:
            device (ET.Element): xml内にある、対象デバイスのElement
        Returns:
            str: バージョンのテキスト
        """
        for i, key in enumerate(device):
            if key.text == 'OSVersion':
                version = device[i + 1].text
                return version

    def get_match_number(devices: list) -> int:
        """ターゲットのデバイスを探し、何番目に存在したか番号を返すメソッド

        TARGET_DEVICESの「iPhone11,6」は「iPhone 12 Pro Max」のことです。

        Args:
            devices (list):
        Returns:
            int: 一致したデバイスのリスト番号
        """
        TARGET_DEVICES = "iPhone11,6"

        for number, device in enumerate(devices):
            for keys in device.findall('array'):
                for key in keys:
                    if key.text == TARGET_DEVICES:
                        return number

    url = "http://mesu.apple.com/assets/com_apple_MobileAsset_SoftwareUpdate/com_apple_MobileAsset_SoftwareUpdate.xml"
    try:
        res = requests.get(url)
    except Exception as e:
        print(e)
        return

    if res:
        version = get_version(res.text)
        return version


if __name__ == '__main__':
    version = get_ios_version()
    print(version)
  • このエントリーをはてなブックマークに追加
  • 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で続きを読む

【SwiftUI】AppデザインのためにGeometryReaderを理解する

はじめに

SwiftUIでそれぞれのViewのレイアウトをデザインする時に必要なGeometryReaderについて整理することを目的とする

開発環境

OSX 10.15.7 (Catalina)
Xcode 12.2.0
CocoaPods 1.10.0

SwiftUIのレイアウトシステム

Appをデザインするための基本的な考え方はHStack、VStack、ZStackを組み合わせです。複雑なレイアウトを実装していくためにそれぞれの特徴を理解する必要があります。

  • HStack
    childViewを横一列に並べることができる。
    "H": a horizontal line

  • VStack
    childViewを縦方向に並べることができる。
    "V": a vertical line

  • ZStack
    childViewを重ねることができる。
    "Z": overlap

参考文献
How to use stacks: HStack, VStack and ZStack in SwiftUI (Equivalent of UIStackView in UIKit)

ContentView_swift_—_Edited.png

import SwiftUI

struct ContentView: View {
    var body: some View {

        // a vertical line
        VStack {

            // a vertical line
            VStack {

                // overlap
                ZStack {

                    // a horizontal line
                    HStack{

                        Image(systemName: "doc")
                        Text("First One")
                    }

                }
                // overlap
                ZStack {

                    // a horizontal line
                    HStack{

                        Image(systemName: "doc")
                        Text("Two One")
                    }
                }
            }
        }
    }
}

GeometryReaderの考え方

参考文献

GeometryReaderはViewの一種であり、親のViewのサイズと自身の親のViewに対する位置を知るためのもの

実装の要点

ContentView_swift_—_Edited.png

struct ContentView: View {
    var body: some View {

        // a vertical line
        // parent View
        VStack {
            // クロージャーの"g"は任意で設定可能
            GeometryReader { g in

                // a vertical line
                VStack {

                    // overlap
                    ZStack {

                        // a horizontal line
                        HStack{

                            Image(systemName: "doc")
                            Text("First One")
                        }
                    // parentViewの幅いっぱい、縦は全体の1/2に設定。
                    }.frame(width: g.size.width, height: g.size.height/2, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
                    .background(Color.red)

                    // overlap
                    ZStack {

                        // a horizontal line
                        HStack{

                            Image(systemName: "doc")
                            Text("Two One")
                        }
                    // parentViewの幅いっぱい、縦は全体の1/2に設定。
                    }.frame(width: g.size.width, height: g.size.height/2, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
                    .background(Color.yellow)
                }
            }
        }.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
    }
}

現在、IPhoneは様々な画面サイズのデバイスが生産されています。それらのサイズに適したデザインをするためにはGeometryReaderでサイズを取得し、配置していく必要があります。

以下のイメージは画面サイズを1/3に設定したものです。height: g.size.height/3の"3"は整数だけでなく、小数点で表記しても認識されます。これにより、さらに細かいレイアウトのデザインが可能です。

ContentView_swift_—_Edited.png

実装のイメージ

ログイン・サインアップViewのレイアウトイメージです。
全体の比率を計算して、レイアウトを決めていきます。

loginView_swift.png

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