20200321のiOSに関する記事は12件です。

simulator で動画キャプチャを取ろうとした時にエラーが出る

iOS simulator で動画キャプチャを撮る場合、以下のコマンドで実現できます。

$ xcrun simctl io booted recordVideo output.mp4

ある時、開発中の動作をキャプチャしようとしたら、以下のようなエラーに直面しました。
Screen Shot 2020-03-21 at 10.10.59.png

Stackoverflow で、同じ悩みに直面している人を発見。
どうやら Xcode 11 の known issue とのこと。
Screen Shot 2020-03-21 at 10.10.22.png

会社で使ってるパソコンではコマンドが正常に動いてたので、 Xcode のバージョンを 11.3.1 にあげてみました。
Screen Shot 2020-03-21 at 21.58.19.png

無事、動くようになりました。
Screen Shot 2020-03-21 at 22.05.06.png

ご参考になれば幸いです。

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

2020/3/17にリリースされたGitHubモバイルアプリ

GitHubが3/17にAndroidとiOS向けの公式モバイルアプリをリリース

最近までテスト配信され、ようやくリリースされました。
このアプリはコードの編集はできませんが、Issueの管理やプルリクエストのマージなどができるアプリです。
エンジニアのほとんどがお世話になっているGitHubが一体どんなライブラリを使用して開発したのか気になりました。

iOSの設定アプリにOSSのライセンスが表記されていたので調査してみました。

  • 初期画面

  • ログイン後

  • 謝辞: Acknowledgements

一覧

  • Alamofire
  • AlamofireNetworkActivityIndicator
  • Apollo
  • CocoaLumberjack
  • ContextMenu
  • DropdownTitleView
  • Firebase
    • Crashlytics
    • Fabric
    • FirebaseAnalyticsInterop
    • FirebaseCore
    • FirebaseCoreDiagnostics
    • FirebaseCoreDiagnosticsInterop
    • FirebaseInstanceID
    • FirebaseMessaging
    • GoogleDataTransport
    • GoogleDataTransportCCTSupport
    • GoogleUtilites
    • nanopb
  • FLEX
  • Fuzi
  • Protobuf
  • R.swift
    • R.swift.Library
  • SDWebImage
  • SDWebImageSVGCoder
  • SnapKit
  • SnapshotTesting
  • SQLite.swift
  • SVGKit
  • TUSafariActivity
  • Valet

ライブラリについて

Alamofire

ど定番のHTTP通信ライブラリ

AlamofireNetworkActivityIndicator

Alamofireが提供しているインジケータライブラリ

NetworkActivityIndicatorManager.shared.isEnabled = true

Apollo

GraphQL APIと通信できるライブラリ

CocoaLumberjack

ログフレームワーク
NSLogよりも高速らしい...

ContextMenu

ポップアップのようなコンテキストメニューの表示できるライブラリ
Starはちょっと少なめ

DropdownTitleView

UINavigationItemを加工できるライブラリ
Starはちょっと少なめ

Firebase

Googleのなんでも提供してくれる優秀ツール

FLEX

iOS開発用のアプリ内デバッグツール

Fuzi

XML/HTMLのパーサライブラリ
Webページからの情報を取得、分析するために使っているのでしょう

Protobuf

GoogleのProtocol Buffer( "protobuf")オブジェクトシリアライズツールをappleが提供

R.swift

コード補完でリソースファイルにアクセスすることができる超便利ライブラリ

Before
let icon = UIImage(named: "settings-icon")
let font = UIFont(name: "San Francisco", size: 42)
let color = UIColor(named: "indicator highlight")
let viewController = CustomViewController(nibName: "CustomView", bundle: nil)
let string = String(format: NSLocalizedString("welcome.withName", comment: ""), locale: NSLocale.current, "Arthur Dent")
After
let icon = R.image.settingsIcon()
let font = R.font.sanFrancisco(size: 42)
let color = R.color.indicatorHighlight()
let viewController = CustomViewController(nib: R.nib.customView)
let string = R.string.localizable.welcomeWithName("Arthur Dent")

SDWebImage

かなり昔から使われている定番画像キャッシュライブラリ

SDWebImageSVGCoder

SDWebImageのSVGファイル対応プラグイン

SnapKit

AutoLayoutをソースコードで記述できる一番人気のライブラリ

SnapshotTesting

スナップショットテストライブラリ
View以外もテストできるみたい
これをつかってデグレを検知をしているのでしょう

SVGKit

SVGファイルを使えるようにするライブラリ
iOS13からガッツリSVG対応されるようになったので今後は使用することないかも...

SQLite.swift

データベースSQLiteのライブラリ

TUSafariActivity

「Safariで開く」を簡単に実行できるライブラリ

let URL =  NSURLstring" http://google.com ")!
let activity =  TUSafariActivity()
let activityViewController =  UIActivityViewControlleractivityItems[URL]applicationActivities[activity]

Valet

キーチェーンを簡単に操作できるライブラリ

感想

アプリを使用してみて、かなりシンプルでappleが提供しているようなアプリデザインと思った。
UIがシンプルということでUI関係のライブラリが多くない。
しかし、もともとのサービスがWebサービスということからネットワーク関連のライブラリが7割ほどでした。

意外だったのが、かなりマイナーなライブラリを使っているところですね。正直、TUSafariActivityは普通に実装できる軽量ライブラリですし、Starもかなり少ない。それをあえて導入していたのが驚きでした。

対象OSが12.2~ということでUIKitで開発されたのは間違い無いだろう。
でもUIをみるとデフォルトで提供されているUIパーツだらけなので、今後SwiftUIに移行するを検討しているのでは無いかと予想する。

とにかくライブラリをよくみる私にとっては最高のモバイルアプリである。

今後の予定

ライブラリ説明の充実

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

iOSにおけるバックグラウンド処理の全体感

はじめに

WWDC2019で行われた下記のセッションをベースに、実際に実装してみた結果をまとめます。
https://developer.apple.com/videos/play/wwdc2019/707/

全体感

以下の図に記載しました。
この中の 処理種別 に関して、ナンバリングしてあるものがiOSで利用できるバックグラウンド処理です。
バックグラウンド処理.png

※ Audio, LocationUpdate, VoIP, Bluetoothなどに関しては今回は省いています

各処理の比較

処理種別 実行可能時間 実行可能状態 詳細
1. Background Task Completion 環境依存(※1) フォアグラウンドで実行した直後のみ https://qiita.com/chocoyama/items/8162bf67e452e41b574b
2. Background Notification (Silent Push) 30秒間 ユーザーによって強制終了されていない状態 https://qiita.com/chocoyama/items/56cd3ac2daaf69dffa0f
3-1. Background URL Session 環境依存(※1) https://qiita.com/chocoyama/items/1440b2e3d69856ba8957
3-2. Discretionary Background URL Session https://qiita.com/chocoyama/items/1440b2e3d69856ba8957
4-1. BackgroundTasks (App Refresh Tasks) 30秒間 https://qiita.com/chocoyama/items/d69322932f400a5d012b
4-2. BackgroundTasks (Processing Tasks) 数分間
(※2)
https://qiita.com/chocoyama/items/d69322932f400a5d012b

※1. 環境依存 と記述している箇所はシステムの状態やOSバージョンによって異なるようです。
たとえば、iOS12以前だと180secほど実行できていたバックグラウンド処理が、iOS13では30secまで縮められていました。

※2. 以下のような表記がされているだけで、最長の実行時間については明記がありませんでした。

A request to launch your app in the background to execute a processing task that can take minutes to complete.
https://developer.apple.com/documentation/backgroundtasks/bgprocessingtaskrequest

個人的な感想

全体として

バックグラウンド処理は多くの場合以下のような特徴があるため、フォアグラウンド処理と同等に扱うことはできません。

  • 実行タイミングがシステムに依存している
  • 処理時間が制限されている
  • 実行時点での環境が様々(バッテリー状況、通信状況など)
  • etc...

「必ず実行されるもの」という前提で実装を組んでしまうと、想定した動作にならないことが容易に起きます。
またVoIPプッシュなどの一部の処理を除いて、ユーザーによって強制的にアプリが終了されている場合は、バックグラウンド処理は起動されません。
そのため、これらの処理は基本的に「実行されるとより便利になるもの」といった位置付けで実装するのが現実的だと思います。

1. Background Task Completion

詳細 → https://qiita.com/chocoyama/items/8162bf67e452e41b574b

「処理の途中でアプリをバックグラウンドにされてしまった時の対処」が必要なケースで使えるものです。
WWDCの動画では、メッセージ送信処理を例にしていました。

※ この機能を使って無限にバックグラウンド処理を実行させようという試みを、調査の中で見つけることが出来ました。
バックグラウンド処理の完了後に、再度バックグラウンド処理を開始させる方法です。
実際に掲載されているサンプルコードを試してみましたが、バックグラウンドでの実行可能時間は特に延長されず、期待した動作にはなりませんでした。
そもそも、無限にバックグラウンド処理を行うのはiOSが想定している本来の使い方と異なるため、避けた方が良い気がします。
ユーザーとしても、知らない間にアプリが無限にバックグラウンド処理を行っているのは迷惑な挙動になりうるでしょう。

2. Background Notification (Silent Push)

詳細 → https://qiita.com/chocoyama/items/56cd3ac2daaf69dffa0f

サーバーサイドから、端末に対して特定の処理を動作させることができる貴重な手段の一つです。

ただし、配信の保証ができないことに加えて、即時で実行されないことも多いので、この仕組みに依存した実装を行うのは危険です。
あくまでオプショナルな機能として、より便利にアプリを使えるためのものとして考えておいた方が良さそうです。

例えば、サーバーサイドで何らかのデータ更新が走った際に、Background Notificationを利用してアプリ側に同期を取ろうとする場合を考えます。
通知がうまく配信された場合、「ユーザーがアプリを起動したタイミングではすでに最新のデータが取れている」といった、より良い体験を与えることができます。
しかし、そうでない場合は起動時に最新のデータを取得する必要があるので、結局Background Notificationだけでは十分な実装にはならないでしょう。
必須で行わなけらばならない処理は実装しておきつつ、さらに便利にするために追加で実装する、といった使い方になるような気がします。

また、フォアグランド時にBackground Notificationを受けた場合に限っては、必ずそれをハンドリングできるようです。
この特徴を利用して、「フォアグラウンド時に、画面上には通知を出さずに裏でリアルタイム同期を行う」といった用途にも使えそうです。

3-1. Background URL Session

詳細 → https://qiita.com/chocoyama/items/1440b2e3d69856ba8957

これを使うと、バックグラウンドになったタイミングでもフォアグラウンドで開始した通信を継続させることができます。
Background Task Completion でも同じようなことは実現できるため、自分は用途があまり思い浮かびませんでした。

3-2. Discretionary Background URL Session

詳細 → https://qiita.com/chocoyama/items/1440b2e3d69856ba8957

アプリがフォアグラウンドの時に、何らかのイベントに応じて通信の予約をしておける機能です。

WWDCの動画では、「ユーザーがサインインした直後、古いコンテンツをパフォーマンスに影響しないタイミングで取得する」といったユースケースを例にしていました。

端末にデータを溜めないようなアプリではあまり使うことがなさそうですが、必要に応じて使うと良さそうです。

4-1. BackgroundTasks (App Refresh Tasks)

詳細 → https://qiita.com/chocoyama/items/d69322932f400a5d012b

ユーザーがよく使うアプリについて、起動を先回りしてデータを取得しておくことができます。
これを利用して細かいアップデートを事前に行っておくことができますが、実行時間は30秒しかないため、重たい処理を行いたい場合はProcessing Tasksを使うことになるでしょう。

基本的にはDBやUserDefaultsなどにデータを書き込むようなユースケースになりそうです。
オンメモリのデータを更新する用途でも使えなくはなさそうですが、タスク実行からアプリ起動までに時間が空いてしまうと、システムによって終了させられる可能性もあるため、あまり有効ではないかなという気はしました。

必ずしも実行される処理ではないので、ここでの処理が行われなくてもフォアグラウンド時に埋め合わせを行えるような実装にしておくことが求められると思います。

4-2. BackgroundTasks (Processing Tasks)

詳細 → https://qiita.com/chocoyama/items/d69322932f400a5d012b

定期的に行いたい処理で、 App Refresh Tasks では対応できない重たい処理を行いたい場合はこれを使うことになります。
CPUMonitorをOFFにできるなどの強力な機能もついているので、この機能で実現できない処理はあまりないように思います。

ただ一般的なバッチ処理とは異なり、「正確な日時を指定することができない」「端末が指定した実行条件に置かれないと実行されない」といった特徴を持っているので、確実に毎日実行させられるとは限らないことは考慮した方が良さそうです。

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

BackgroundTasks(AppRefreshTasks & ProcessingTasks)

この記事は

iOS13で利用可能なBackgroundTasksフレームワークを利用した、アプリのバックグラウンド処理についてのまとめと挙動の検証記事です。

機能概要

  • 特徴
    • 以下のようなメンテナンスタイプのタスクに対して最適な仕組み
      • サーバーとの同期
      • データベースのクリーンアップ
      • クラウドへのバックアップ
    • アプリがフォアグランドでないタイミングで任意の処理を実行させることができる
      • これによりユーザー操作の邪魔をせずに必要なタスクを後回しにして実行させられる
    • 以下の2種類のモードがある
      1. Background App Refresh Tasks (Background Fetch)
      2. Background Processing Tasks

Background App Refresh Tasks (Background Fetch)

  • 特徴
    • iOS7で追加された BackgroundFetch がiOS13でアップデートされたもの
    • ユーザーの普段の利用傾向に合わせて、定期的にアプリの状態を最新化させることができる仕組み
  • 実行タイミング
    • ユーザーの過去の行動からアプリケーションの起動頻度や時間帯が決定される
    • ユーザーが普段よくアプリを使うタイミングの少し前に起動される
    • 頻繁に使わないアプリケーションの場合には起動も少なくなる
    • 最早開始日時の指定は可能だが、1週間以内に設定することが推奨されている
  • 起動可能状態
    • ユーザーにより強制的にkillされていない状態
      • システムによってkillされた場合は、起動し直してくれる
  • 実行可能時間
    • 30秒間
  • 備考
    • iOS12以前で利用していた以下のAPIはdeprecatedになった
      • UIApplication.setMinimumBackgroundFetchInterval(_:)
      • UIApplicationDelegate.application(_:performFetchWithCompletionHandler:)
  • 所感
    • 「ユーザーがアプリを起動した際には、すでに最新のデータが取れている」といった体験を作ることができる
    • ただし、あくまでシステムがよしなに実行してくれるだけで必ずしも実行されるとは限らないので、必須処理をこれで担保するのは危険

Background Processing Tasks

  • 特徴
    • iOS13から利用可能な新機能
    • 以下のような細かい実行条件を設定できる (App Refresh Tasks はできない)
      • Wi-Fi接続あり
      • 充電中
    • CPU Monitor (バックグラウンドでCPUを使いすぎているAppを自動でkillする機能) をOFFにすることができる
      • (「充電中のみ実行可能」にすることで実現可能)
  • 実行タイミング
    • 指定した実行可能条件に応じて、システムが判断して実行する
    • フォアグラウンドでリクエストされた場合や、アプリが最近使用されている場合にタスクが実行される
  • 起動可能状態
    • デバイスがアイドル状態のとき
      • ユーザーがデバイスの使用を開始してしまうと、システムは実行中のバックグラウンド処理タスクを終了させる
      • (AppRefreshTasksは影響を受けない)
    • ユーザーにより強制的にkillされていない状態
      • システムによってkillされた場合は、起動し直してくれる
  • 実行可能時間
    • 数分間に及ぶ実行も可能
  • 所感
    • AppRefreshTasksに担わせることができない重たい処理を行わせるのに良い
    • しかしAppRefreshTasksと同様に確実に実行される保証はないので、実行されることを前提とした実装は避けた方がよさそう

準備

  1. Capabilityの設定

Background App Refresh Tasksの場合は Background fetch をチェックし。
Background Processing Tasksの場合は Background processing をチェックする、

2. Info.plistの設定

Permitted background task scheduler identifiers にタスク毎のIDを設定する。
リバースDNSで他のフレームワークとの衝突を避け、ユニークになるように設定する。
(タスクIDは後述する BGTaskScheduler にタスクを登録・スケジューリングする際に利用する。)

実装

サンプルコード

https://github.com/chocoyama/BackgroundSamples/blob/master/BackgroundSample/Views/BackgroundTasksView.swift

※ 詳細な解説はサンプルコード内にコメント文でも記述しているが、重要な部分のみ抜粋する

1. タスクの登録

タスクの登録は、BackgroundTasksフレームワークが提供する BGTaskScheduler に対して行う。

  • BGTaskScheduler
    • タスクのタイプに応じたTaskRequestオブジェクトを作成することで、アプリ動作中にタスクを登録できる
      • Background App Refresh Tasks => BGAppRefreshTaskRequest
      • Background Processing Tasks => BGProcessingTaskRequest
    • 常に「バッテリー残量」「アプリケーション使用時間」「通信状態」のようなシステム状態を監視する
    • 必要なシステム状態とポリシーが満たされればタスクが実行される
      • タスク実行時は、バックグラウンドでアプリが起動され、対応するBGTaskオブジェクトが届けられる
      • タスクの完了は setTaskCompleted を呼び出すことで行い、これにより起動されたアプリを停止する
    • タスク起動は複数同時に行えるが、実行可能時間の割り当てはタスクごとではなく起動ごとに割り当てられるので注意

taskIdentifierにはInfo.plistに設定したタスクのIDを渡す。
launchHandlerに受け渡すクロージャは、システムによってタスクが起動される際に呼び出される。
そのため、ここにはバックグラウンドで行いたい処理自体を記述する。
(ハンドラを呼び出すキューを明示的に指定したい場合は、第二引数に受け渡す)

import BackgroundTasks

BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil, launchHandler: { task in
    // タスクが実行された時の処理を記述する
}

2. 完了のハンドリング

目的とするタスクが完了したら、速やかに setTaskCompleted を呼び出す。
これを適切に呼び出さないと、次回の起動パフォーマンスに影響を及ぼしてしまう。
また、expirationHandlerが呼ばれた際もこれは呼び出す必要がある。
※ UISceneのアプリは UIApplication.requestSceneSessionRefresh も実行する。

task.setTaskCompleted(success: true)

3. 失効のハンドリング

launchHandlerで受け渡されてくる BGTask オブジェクトには expirationHandler プロパティが存在する。
バックグラウンド処理は、タイムアウトやシステム状態の悪化などにより、タスクを完了させられずに終了する可能性が比較的高い。
そのため、このハンドラを設定してタスクが失効したタイミングの処理を予め設定しておく。

task.expirationHandler = {
    // キャンセル処理を実行
}

4. タスクのスケジューリング

submit メソッドでタスクをスケジューリングする。
この時、register の時と同様にInfo.plistに設定したタスクIDを指定する。

既に同一IDのタスクが未実行状態でキューに溜まっていた場合、以前のタスクリクエストは置き換えられる。
また、最大で「AppRefreshTask x 1」と「ProcessingTask x 10」までしかスケジュールできないので、最大数を超えてスケジューリングしようとするとエラーが発生する。

BGAppRefreshTaskRequestの場合

func schedule() {
    let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)

    // 実行開始時期を遅らせる
    // iOS12以前の、setMinimumBackgroundFetchIntervalと同じ動きをする
    // また、設定値としては1週間以内が推奨されており、遠すぎるとユーザーがその間にアプリを開いた際にタスクが起動しない場合がある
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)

    do {
        try BGTaskScheduler.shared.submit(request)
    } catch {
        print("Could not schedule app refresh: \(error)")
    }
}

BGProcessingTaskRequestの場合

let request = BGProcessingTaskRequest(identifier: taskIdentifier)

// 通信が必要な場合はtrueにする(デフォルトはfalseで、この場合通信がない時間にも起動される)
request.requiresNetworkConnectivity = true

// 充電中に実行したい処理の場合はtrueにする
// これがtrueの時にCPU Monitorが無効になる
request.requiresExternalPower = true

do {
    try BGTaskScheduler.shared.submit(request)
} catch {
    print("Could not schedule database cleaning: \(error)")
}

注意点

※ BGTaskRequestは1度の起動にしか対応していない。
そのため、実行後に自動で次のタスクをスケジューリングしたい場合は、タスク実行完了前に再度スケジューリングしておく必要がある。

BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil, launchHandler: { task in
    self.schedule()

    // タスクが実行された時の処理
    // ...
})

デバッグ

タスクを起動させる

  1. 一度アプリを起動させてタスクをスケジューリングさせる
  2. タスクのスケジューリングを行ったあと、Xcodeの一時停止ボタンをクリックする(フォアグラウンドで行う必要があった)
  3. LLDBで e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"TASK_IDENTIFIER"] を打ち込む
  4. 一時停止を解除する
  5. タスクが実行される

タスクを失効させる

  1. Xcodeの一時停止ボタンをクリックする
  2. LLDBで e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"TASK_IDENTIFIER"] を打ち込む
  3. 一時停止を解除する
  4. タスクが失効される

検証

準備

シミュレーターでBGTaskSchedulerのsubmitメソッドを呼び出すと、以下のエラーが発生し挙動を確認できなかった。

Could not schedule app refresh: Error Domain=BGTaskSchedulerErrorDomain Code=1 "(null)"

ドキュメントに 、デバッグは実機のみ可能との記述があったので、検証は実機で行った。

The debug functions work only on devices.

結果 (AppRefreshTasks)

  • LLDBのデバッグコマンドでの動作検証
    • タスクの実行
      • _simulateLaunchForTaskWithIdentifier コマンドを実行すると、 launchHandler が呼び出される挙動を確認できた。
      • launchHandler 内で次回タスクのスケジューリングを行った場合は、続けてデバッグコマンドを打ち込んでも再度ハンドラが呼び出された
        • 追加のスケジューリングを行わなかった場合は、ハンドラは呼び出されなかった
    • タスクの失効
      • 30秒を超える処理を launchHandler 内で行っても処理が完了できた
        • デバッグはアプリをフォアグラウンドにしておかないとできないため、これが原因の可能性がある
        • バックグラウンド状態にしてデバッグコマンドを打つと、Xcodeがフリーズして以降のデバッグを行うことができなくなった
      • 失効用のコマンドを実行した場合は、 expirationHandler が呼び出されるのを確認できた
        • この時、一度 _simulateLaunchForTaskWithIdentifier を呼び出してタスクを起動状態にしておかないと、失効のハンドラは呼び出されなかった
  • 実機での動作検証
    • アプリをバックグラウンド状態にしている際に、AppRefreshTasksが実行されることを確認できた
    • アプリをkillした場合はAppRefreshTasksが実行される挙動は確認できなかった

検証 (ProcessingTasks)

  • LLDBのデバッグコマンドでの動作検証
    • AppRefreshTasksと同様の結果となった
  • 実機での動作検証
    • 指定した条件にしたあと、すぐに実行はされなかった
    • スケジューリング後、2日目あたりからタスクが実行されるようになった
    • 実行されはじめた後は、1日に2回以上動作することもあった
      • (アプリの利用頻度に応じて実行回数は変わりそう)

参考

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

UnityのスクリプトからXcodeのInfo.plistを編集する

言語設定がデフォルトで英語になっているので、毎回ビルド後にXcodeで日本語に設定し直すのが大変でしたので自動で設定するようにしました。

環境

  • Unity 2019.3
  • macOS

方法

plistを読み書きするPlistDocumentクラスが用意されているのでこれを使います。
ビルド後のコールバックで処理しますが、Unity2018ぐらいまでは[PostProcessBuild]属性を使っていたのですが現在は廃止されており、新しくIPostprocessBuildWithReportインターフェイスを継承してビルド後のコールバックを受け取る必要があります。

using System.IO;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEditor.iOS.Xcode;

public class XcodePostprocess : IPostprocessBuildWithReport
{
    public int callbackOrder { get { return 0; } }

    public void OnPostprocessBuild(BuildReport report)
    {
        if (report.summary.platform == BuildTarget.iOS)
        {
                string plistPath = Path.Combine(report.summary.outputPath, "Info.plist");
                PlistDocument plist = new PlistDocument();
                plist.ReadFromFile(plistPath);
                plist.root.SetString("CFBundleDevelopmentRegion", "ja");
                plist.WriteToFile(plistPath);
        }
    }
}

参考

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

【Swift, iOS】iOS13の新機能 LinkPresentation.frameworkの使い方を調べる

iOS13でLinkPresentationという新機能が追加されました。
これによってURLリンク先の情報を
より表現豊かに表示することができるようになるようです。

今回はその新機能について
AppleのWWDC2019の動画と検証結果などから見ていきたいと思います。

Embedding and Sharing Visually Rich Links
https://developer.apple.com/videos/play/wwdc2019/262


あまり情報がなく
検証した結果から記載した部分もありますので
間違っている部分やもっと良い方法ご存知の方いらっしゃいましたらぜひ教えてください??‍♂️

LinkPresentation.frameworkとは?

iOS13で新しく追加された
リンクのプレビューを画像や埋め込み動画、音楽再生と合わせて
リッチに一貫した方法で表示できるようにした
フレームワークです。

iOS10とmacOS Sierraから
Appleのメッセージアプリなどでは先行して
このフレームワークに含まれている機能を利用していたようです。

スクリーンショット 2020-03-21 8.50.12.png

https://developer.apple.com/documentation/linkpresentation

主なクラス

非常にシンプルで主に登場するクラスは3です。

  • LPMetadataProvider
  • LPLinkMetadata
  • LPLinkView

LPMetadataProvider

URLのメタ情報を取得します。
https://developer.apple.com/documentation/linkpresentation/lpmetadataprovider

※ メタ情報とは?

HTMLタグに含まれるタイトルやアイコン、画像、動画などの情報を読み取ります。

特に
OpenGraphというプロトコルを使用した
<meta og:XXX>の情報を優先して読み取ります。

例えば下記のようなものです。

<html prefix="og: http://ogp.me/ns#">
<head>
<title>The Rock (1996)</title>
<meta property="og:title" content="The Rock" />
<meta property="og:type" content="video.movie" />
<meta property="og:url" content="http://www.imdb.com/title/tt0117500/" />
<meta property="og:image" content="http://ia.media-imdb.com/images/rock.jpg" />
...
</head>
...
</html>

詳細は下記をご参照ください。
OpenGraph
https://ogp.me/

LPLinkMetadata

URLのメタ情報を保持するクラスです。
https://developer.apple.com/documentation/linkpresentation/lplinkmetadata

LPLinkView

URLのメタ情報をリッチに表示するUIViewのサブクラスです。
https://developer.apple.com/documentation/linkpresentation/lplinkview

使い方

すごいシンプルです。

  1. LPMetadataProviderstartFetchingMetadataでURLのリンク先からLPLinkMetadataを取得する
  2. LPLinkMetadataLPLinkViewに設定する

SwiftUIでの実装

UIViewRepresentableに適合したクラスの生成

LPLinkViewに対応する
UIViewRepresentableに適合したクラスを生成します。

import SwiftUI
import LinkPresentation

struct LinkPresentationView: UIViewRepresentable {
    typealias UIViewType = LPLinkView

    func makeUIView(context: UIViewRepresentableContext<LinkPresentationView>) -> UIViewType {
    }

    func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext<LinkPresentationView>) {
    }
}

メタ情報の取得

LPMetadataProviderからLPLinkMetadataを取得します。
取得するURLが必要なので初期化時に引数で受け取るように変数を宣言します。

struct LinkPresentationView: UIViewRepresentable {
    let url: URL

    private func fetchMetadata(for url: URL, completion: @escaping (Result<LPLinkMetadata, Error>) -> Void) {
        let provider = LPMetadataProvider()

        provider.startFetchingMetadata(for: url) { metadata, error in
            if let error = error {
                completion(.failure(error))
            } else if let metadata = metadata {
                completion(.success(metadata))
            } else {
                completion(.failure(LPError(.unknown)))
            }
        }
    }
}

エラーが発生した場合はLPErrorが返ってきます。
https://developer.apple.com/documentation/linkpresentation/lperror

ネットワークに繋がっていなかったり
接続が遅すぎてタイムアウトになったり
リクエストがキャンセルされた場合に生じます。

LPLinkViewの生成

次に
makeUIViewの中でLPLinkViewを生成します。

struct LinkPresentationView: UIViewRepresentable {
    var url: URL

    func makeUIView(context: UIViewRepresentableContext<LinkPresentationView>) -> UIViewType {
        let view = LPLinkView(url: url) // ※
        self.fetchMetadata(for: url) { result in
            switch result {
            case .success(let metadata):
                DispatchQueue.main.async {
                    self.update(view: view, with: metadata)
                }
            case .failure:
                let metadata = LPLinkMetadata()
                metadata.title = "Error"
                let url = URL(fileURLWithPath: Bundle.main.path(forResource: "error", ofType: "png")!)
                metadata.iconProvider = NSItemProvider(contentsOf: url)
                self.update(view: view, with: metadata)
            }
        }
        return view
    }

    private func update(view: UIViewType, with metadata: LPLinkMetadata) {
        view.metadata = metadata
        view.sizeToFit()
    }
}


ここは疑問が残っているのですが
ここでurlを引数にLPLinkViewを初期化しないと
画面に何も表示されませんでした。

おそらく内部で取得したLPLinkMetadataのURLと
LPLinkViewのURLを比較しているんじゃないかと思っているのですが
もし何かご存知の方いらっしゃいましたら
教えていただけると嬉しいです??‍♂️

updateメソッドの中で

private func update(view: UIViewType, with metadata: LPLinkMetadata) {
    ....
    view.sizeToFit()
}

としていますが
これは
LPLinkView自体もintrinsic sizeを持っているものの
sizeToFitを使用することで
現在のレイアウトに最適なサイズで表示されるようにするためです。

エラーが起きた時は
その場でLPLinkMetadataを生成して表示することもできます。

case .failure:
    let metadata = LPLinkMetadata()
    metadata.title = "Error"
    let url = URL(fileURLWithPath: Bundle.main.path(forResource: "error", ofType: "png")!)
    metadata.iconProvider = NSItemProvider(contentsOf: url)
    self.update(view: view, with: metadata)

実際に表示してみます。

struct ContentView: View {
    var urls: [URL] = [
        URL(string: "https://www.apple.com/mac")!,
        URL(string: "https://www.apple.com/ipad")!,
        URL(string: "https://youtu.be/V85CQzsyvj4")!,
        URL(string: "https://twitter.com/yuukikikuchi/status/1240946299467259905")!,
    ]
    var body: some View {
        List(self.urls, id: \.self) { url in
            LinkPresentationView(url: url)
        }.onAppear {
            UITableView.appearance().separatorStyle = .none
        }
    }
}

下記の様に表示されます。

Simulator Screen Shot - iPhone 11 Pro Max - 2020-03-21 at 11.22.26.png

適切なサイズで表示がされていません。

これはLPLinkViewの初期化時のサイズのままになっており
取得した画像のサイズなどが反映されていないためです。

そこでLPLinkMetadataの取得が完了した時点で
サイズの再計算を行うように親のViewに伝達するようにします。

struct LinkPresentationView: UIViewRepresentable {
    ...

    @Binding var redraw: Bool

    private func update(view: UIViewType, with metadata: LPLinkMetadata) {
        ...

        redraw.toggle()
    }
}

struct ContentView: View {
    ....

    @State var redraw = false

    var body: some View {
        List(self.urls, id: \.self) { url in
            LinkPresentationView(url: url, redraw: self.$redraw)
        }.onAppear {
            UITableView.appearance().separatorStyle = .none
        }
    }
}

こうするとこんな形で表示されます。
またYoutubeの動画はクリックすると再生することができたり
Twitterの表示も自動で設定してくれます。

(表示時のアニメーションをどうにかしたいですが。。。)

メタ情報をキャッシュする

URLから毎回メタ情報を取得してくるのは
ユーザにとっては通信料がかかってしまいますし
アプリとして同じリンク先から毎回同じ情報を取得するためには
パフォーマンスコストがかかるため
キャッシュをするべきだと
Appleの動画でも言っていました。

そこでローカルにキャッシュするためのクラスを用意します。

LPLinkMetadataはNSSecureCodingに適合しており
信頼性の高い形でシリアライズ可能になっています。

https://developer.apple.com/documentation/foundation/nssecurecoding


今回は実装を簡単にするために
シングルトンのUserDefaults.standardやsharedを使用しています。

final class MetaCache {
    static let shared = MetaCache()
    private init(){}

    private let storage = UserDefaults.standard

    private let key = "Metadata"

    func store(_ metadata: LPLinkMetadata) {
        do {
            let data = try NSKeyedArchiver.archivedData(withRootObject: metadata, requiringSecureCoding: true)
            var metadatas: [String: Data] = storage.dictionary(forKey: key) as? [String: Data] ?? [:]
            metadatas[metadata.originalURL!.absoluteString] = data
            storage.set(metadatas, forKey: key)
        }
        catch {
            print("Failed storing metadata with error \(error as NSError)")
        }
    }

    func metadata(for url: URL) -> LPLinkMetadata? {
        guard let metadatas = storage.dictionary(forKey: key) as? [String: Data] else {
            return nil
        }

        guard let data = metadatas[url.absoluteString] else {
            return nil
        }

        do {
            return try NSKeyedUnarchiver.unarchivedObject(ofClass: LPLinkMetadata.self, from: data)
        } catch {
            print("Failed to unarchive metadata with error \(error)")
            return nil
        }
    }
}

最後にこれを使用します。

    func makeUIView(context: UIViewRepresentableContext<LinkPresentationView>) -> UIViewType {
        let view = LPLinkView(url: url)
        if let cachedData = MetaCache.shared.metadata(for: url) {
            update(view: view, with: cachedData)
        } else {
            self.fetchMetadata(for: url) { result in
                switch result {
                case .success(let metadata):
                    MetaCache.shared.store(metadata)
                case .failure:
                    ...
                }
            }
        }
        return view
    }

注意点

ドキュメントにも記載がありますが
startFetchingMetadata(for:completionHandler:)のcompletionHandlerは
バックグラウンドで実行されるため
UI関連の処理を行う場合はメインキューで実行するようにします。

The completion handler executes on a background queue. 
Dispatch any necessary UI updates back to the main queue. 

また、LPMetadataProviderは一回のリクエストにしか使用できないため
例えば

struct LinkPresentationView: UIViewRepresentable {
    let provider = LPMetadataProvider()

    func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext<LinkPresentationView>) {
        provider.startFetchingMetadata(for: url) { metadata, error in
        }
    }
}

などと実装してみると
ListやForEachを使用した時に

'Trying to start fetching on an LPMetadataProvider that has already started. 
LPMetadataProvider is a one-shot object.'

といったエラーでクラッシュします。

最終的なコード
import SwiftUI
import LinkPresentation

struct LinkPresentationView: UIViewRepresentable {
    typealias UIViewType = LPLinkView

    let url: URL
    @Binding var redraw: Bool

    func makeUIView(context: UIViewRepresentableContext<LinkPresentationView>) -> UIViewType {
        let view = LPLinkView(url: url)

        // 画像を取得するまでの表示されないように設定しています
        view.isHidden = true
        if let cachedData = MetaCache.shared.metadata(for: url) {
            update(view: view, with: cachedData)
        } else {
            self.fetchMetadata(for: url) { result in
                switch result {
                case .success(let metadata):
                    MetaCache.shared.store(metadata)
                    DispatchQueue.main.async {
                        self.update(view: view, with: metadata)
                    }
                case .failure:
                    let metadata = LPLinkMetadata()
                    metadata.title = "Error"
                    let url = URL(fileURLWithPath: Bundle.main.path(forResource: "error", ofType: "png")!)
                    metadata.iconProvider = NSItemProvider(contentsOf: url)
                    self.update(view: view, with: metadata)
                }
            }
        }
        return view
    }

    func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext<LinkPresentationView>) {
    }

    private func fetchMetadata(for url: URL, completion: @escaping (Result<LPLinkMetadata, Error>) -> Void) {
        let provider = LPMetadataProvider()

        provider.startFetchingMetadata(for: url) { metadata, error in
            if let error = error {
                completion(.failure(error))
            } else if let metadata = metadata {
                completion(.success(metadata))
            } else {
                completion(.failure(LPError(.unknown)))
            }
        }
    }

    private func update(view: UIViewType, with metadata: LPLinkMetadata) {
        view.metadata = metadata
        view.sizeToFit()
        redraw.toggle()
        view.isHidden = false
    }
}

struct ContentView: View {
    var urls: [URL] = [
        URL(string: "https://www.apple.com/mac")!,
        URL(string: "https://www.apple.com/ipad")!,
        URL(string: "https://youtu.be/V85CQzsyvj4")!,
        URL(string: "https://twitter.com/yuukikikuchi/status/1240946299467259905")!,
    ]

    @State var redraw = false

    var body: some View {
        List(self.urls, id: \.self) { url in
            LinkPresentationView(url: url, redraw: self.$redraw)
        }.onAppear {
            UITableView.appearance().separatorStyle = .none
        }
    }
}

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

UIViewでの実装

UITableViewでも実装をしてみました。

やっていることはだいたい同じなので
多くの部分は割愛しますが

セルの実装でいくつか疑問に残っている部分があるので
記載します。

まずLPLinkViewは毎回生成しないと
画面に表示されないため
毎回addSubViewをする必要がありました。
そのためprepareForReuseでsubviewsを一旦クリアする必要がありました。

またセルサイズの再計算を行うために
メタ情報取得後にクロージャを実行しています。

final class Cell: UITableViewCell {
    static let identifier = "Cell"

    // ViewControllerにセルサイズを再計算をさせるためにLPLinkMetadata取得したことを伝える
    var onUpdate: (() -> Void)?

    ...

    // 毎回subViewをクリアする必要がある
    override func prepareForReuse() {
        super.prepareForReuse()
        contentView.subviews.forEach { $0.removeFromSuperview() }
    }

    func configure(with url: URL) {
        setLPLinkView(for: url)
    }


    ...

    private func update(_ view: LPLinkView, with metadata: LPLinkMetadata) {
        view.metadata = metadata
        // 毎回addSubViewする必要がある
        addSubView(linkView: view)
        view.sizeToFit()

        // ViewControllerにLPLinkMetadataを取得したことを伝える
        onUpdate?()
    }

    private func addSubView(linkView: LPLinkView) {
        contentView.addSubview(linkView)
        linkView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            linkView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
            linkView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
            linkView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
            linkView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12),
        ])
    }
}

下記のように動きます。

(表示時のアニメーションをどうにかしたいですが。。。)

最終的なコード
import UIKit
import LinkPresentation

class ViewController: UIViewController {
    private var urls: [URL] = [
        URL(string: "https://www.apple.com/mac")!,
        URL(string: "https://www.apple.com/ipad")!,
        URL(string: "https://youtu.be/V85CQzsyvj4")!,
        URL(string: "https://twitter.com/yuukikikuchi/status/1240946299467259905")!,
    ]

    lazy var tableView: UITableView = UITableView()

    private var loadedIndexPaths: Set<IndexPath> = []

    override func viewDidLoad() {
        super.viewDidLoad()
        configureTableView()
    }

    private func configureTableView() {
        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.topAnchor.constraint(equalTo: view.topAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])

        tableView.register(Cell.self, forCellReuseIdentifier: Cell.identifier)
        tableView.dataSource = self
        tableView.separatorStyle = .none
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return urls.count
    }

    func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let url = urls[indexPath.row]
        let cell = tableView.dequeueReusableCell(withIdentifier: Cell.identifier, for: indexPath) as! Cell
        cell.configure(with: url)

        cell.onUpdate = { [weak self] in
            guard let self = self else {
                return
            }

            // LPLinkMetadataが取得されたらreloadする
            if !self.loadedIndexPaths.contains(indexPath) {
                self.loadedIndexPaths.insert(indexPath)
                self.tableView.reloadRows(at: [indexPath], with: .none)
            }
        }
        return cell
    }
}

final class Cell: UITableViewCell {
    static let identifier = "Cell"

    var onUpdate: (() -> Void)?

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        contentView.subviews.forEach { $0.removeFromSuperview() }
    }

    func configure(with url: URL) {
        setLPLinkView(for: url)
    }

    private func setLPLinkView(for url: URL) {
        let linkView = LPLinkView(url: url)
        if let cachedData = MetaCache.shared.metadata(for: url) {
            update(linkView, with: cachedData)
            return
        }
        fetchMetadata(for: url) { result in
            switch result {
            case .success(let metadata):
                MetaCache.shared.store(metadata)
                DispatchQueue.main.async {
                    self.update(linkView, with: metadata)
                }
            case .failure:
                let metadata = LPLinkMetadata()
                metadata.title = "Error"
                let url = URL(fileURLWithPath: Bundle.main.path(forResource: "error", ofType: "png")!)
                metadata.iconProvider = NSItemProvider(contentsOf: url)
                self.update(linkView, with: metadata)
            }
        }
    }

    private func fetchMetadata(for url: URL, completion: @escaping (Result<LPLinkMetadata, Error>) -> Void) {
        let provider = LPMetadataProvider()

        provider.startFetchingMetadata(for: url) { metadata, error in
            if let error = error {
                completion(.failure(error))
            } else if let metadata = metadata {
                completion(.success(metadata))
            } else {
                completion(.failure(LPError(.unknown)))
            }
        }
    }

    private func update(_ view: LPLinkView, with metadata: LPLinkMetadata) {
        view.metadata = metadata
        addSubView(linkView: view)
        view.sizeToFit()

        onUpdate?()
    }

    private func addSubView(linkView: LPLinkView) {
        contentView.addSubview(linkView)
        linkView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            linkView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
            linkView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
            linkView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
            linkView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12),
        ])
    }
}

ShareSheetにLPLinkMetadataを利用する

LPLinkMetadataはShareSheetでも
UIActivityItemSource
activityViewControllerLinkMetadata(_:)から
利用することができ
プレビュー情報をリンク先から取得して表示できるようになりました。

https://developer.apple.com/documentation/uikit/uiactivityitemsource/3144571-activityviewcontrollerlinkmetada

UIActivityItemSourceに適合したクラスを作成

ShareSheetに表示するアイテムを表すクラスを定義します

import UIKit
import LinkPresentation

final class ShareActivityItemSource: NSObject, UIActivityItemSource {
    private let linkMetadata: LPLinkMetadata

    init(_ url: URL) {
        linkMetadata = LPLinkMetadata()
        super.init()
        setPlaceholder(for: url)

        if let cachedData = MetaCache.shared.metadata(for: url) {
            setMetadata(cachedData)
            return
        }

        let metadataProvider = LPMetadataProvider()
        metadataProvider.startFetchingMetadata(for: url) { [weak self] metadata, error in
            guard let self = self, let metadata = metadata else {
                return
            }
            self.setMetadata(metadata)
        }
    }

    private func setPlaceholder(for url: URL) {
        linkMetadata.title = "loading..."
        linkMetadata.originalURL = url
        let loadingImageURL = URL(fileURLWithPath: Bundle.main.path(forResource: "loading", ofType: "png")!)
        linkMetadata.iconProvider = NSItemProvider(contentsOf: loadingImageURL)
    }

    private func setMetadata(_ metadata: LPLinkMetadata) {
        linkMetadata.title = metadata.title
        linkMetadata.url = metadata.url
        linkMetadata.originalURL = metadata.originalURL
        linkMetadata.iconProvider = metadata.iconProvider
        linkMetadata.imageProvider = metadata.imageProvider
    }

    func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
        linkMetadata
    }

    func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
        linkMetadata as Any
    }

    func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
        linkMetadata
    }
}

ここではLPLinkMetadataに事前のデータを設定しておき
データが取得できたらメタ情報を入れ替えるようにします。

SwiftUIでの実装方法

UIActivityViewControllerの機能を有するViewを用意します。

struct ShareSheet: UIViewControllerRepresentable {
    typealias Callback = (_ activityType: UIActivity.ActivityType?, _ completed: Bool, _ returnedItems: [Any]?, _ error: Error?) -> Void

    let activityItems: [Any]
    let applicationActivities: [UIActivity]? = nil
    let excludedActivityTypes: [UIActivity.ActivityType]? = nil
    let callback: Callback? = nil

    func makeUIViewController(context: Context) -> UIActivityViewController {
        let controller = UIActivityViewController(
            activityItems: activityItems,
            applicationActivities: applicationActivities)
        controller.excludedActivityTypes = excludedActivityTypes
        controller.completionWithItemsHandler = callback
        return controller
    }

    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
    }
}

ナビゲーションバーのボタンを押すと
ShareSheetを表示するようにします。

struct ContentView: View {
    var urls: [URL] = [
        URL(string: "https://www.apple.com/mac")!,
        URL(string: "https://www.apple.com/ipad")!,
        URL(string: "https://youtu.be/V85CQzsyvj4")!,
        URL(string: "https://twitter.com/yuukikikuchi/status/1240946299467259905")!,
    ]

    @State var redraw = false
    @State var showShareSheet = false

    var body: some View {
        NavigationView {
            List(self.urls, id: \.self) { url in
                LinkPresentationView(url: url, redraw: self.$redraw)
            }.onAppear {
                UITableView.appearance().separatorStyle = .none
            }
        }.navigationBarItems(trailing:
            Button(action: {
                self.showShareSheet = true
            }) {
                Text("Share").bold()
            }
        ).sheet(isPresented: $showShareSheet) {
            ShareSheet(activityItems: [ShareActivityItemSource(self.urls[Int.random(in: 0..<4)])])
        }
    }
}

ShareSheetactivityItems
ShareActivityItemSourceを渡します。

すると下記のような動きをします。

1回目のShareSheetの表示では
事前に準備した情報がまず表示され
取得後に入れ替わっています。

2回目はメタ情報をすでに取得しているので
最初からメタ情報が表示されています。

UIKitでの実装方法

同じ様にナビゲーションバーのボタンを押すと
ShareSheetが表示されるようにします。

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        ...
        navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Share", style: .plain, target: self, action: #selector(shareTapped))
    }

    @objc func shareTapped() {
        showShareSheet(url: urls[Int.random(in: 0..<4)])
    }
}

extension ViewController {
    func showShareSheet(url: URL) {
        let item = ShareActivityItemSource(url)
        let activity = UIActivityViewController(activityItems: [item], applicationActivities: nil)
        present(activity, animated: true)
    }
}

下記のように動きます。

メタ情報のベストプラクティス

以上のように使い方を見てきましたが
Appleがメタ情報にどういう情報を載せるべきかを
紹介している動画があるので
次に見ていきます。

https://developer.apple.com/videos/play/tech-talks/205

メタ情報にはあらゆる情報を含めることができますが
その中でもAppleの公式動画の中でベストプラクティスを紹介しています。

タイトルについてのベストプラクティス

  • タイトルからリンク先の内容がわかるようにする
  • <head><title>からタイトルを読み取ることもできるがサイト名がURLのドメインなどと重複して表示されるのを避けるために<meta og:title="">を設定する
  • JavaScriptは動かないので動的なタグの生成をしないようにする

アイコンについてのベストプラクティス

<link rel="icon">の情報を読み取るが
<meta og:image="">を指定するとアイコンが表示されなくなるので
アイコンを表示したい場合は<meta og:image="">を指定しないようにする

画像についてのベストプラクティス

  • 興味を引かせるような特定のページの内容を表す画像のみog:imageを設定する
  • 画像が取得できなかった場合の対処としてアイコンを設定する
  • テキストは含めない方が良い(全サイズのデバイスで表示する際にサイズなどがスケールしない)

動画についてのベストプラクティス

  • アイコン、画像、動画合わせて10MBまでなのでサイズに気を付ける
  • 自動再生をするためには直接参照したビデオファイルを使用する(不可能な場合、Youtubeの埋め込み動画のURLを指定すればユーザがタップして再生ができる。Youtube以外のサービスでは不可能)
  • HTMLやプラグインが必要な埋め込み動画のサポートはしていない

まとめ

LinkPresentation.frameworkについて見てみました。

使い方は非常にシンプルで便利ですが
表示時のアニメーションがいまいちであったり
まだ使い方が完全に把握できていないため
今後も試してみて理解を深めていく必要があります。

ここに記載したことはあくまで検証結果に基づいていますので
もし間違いなどございましたらぜひご指摘ください??‍♂️

参照先

https://developer.apple.com/videos/play/wwdc2019/262/
https://developer.apple.com/videos/play/tech-talks/205
https://medium.com/better-programming/ios-13-rich-link-previews-with-swiftui-e61668fa2c69
https://augmentedcode.io/2019/09/15/loading-url-previews-using-linkpresentation-framework-in-swift/
https://nshipster.com/ios-13/
https://www.swiftjectivec.com/linkpresentation-introduction/
https://qiita.com/ezura/items/6036c6e100599b601482
https://forums.developer.apple.com/thread/123951
https://github.com/SDWebImage/SDWebImageLinkPlugin

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

【Solidity】event の半分は優しさで出来ている話

はじめに

イーサリアムのスマートコントラクト開発で利用される Solidity において、イベントの仕様はとてもシンプルです。

イベントは event をつけて宣言します.
event TransferToken( address indexed from, address indexed to, uint id );

イベントは emit をつけて発行します.
emit TransferToken( msg.sender, reciever, tokenId );

発行されたイベントはトランザクションのレシートに記録されます.
indexed のついた引数はフィルタリングに利用でき、特定のイベントを捕捉できます.

めでたし、めでたし.

ネット上の情報や、書籍等での説明はだいたこんな感じです。わかったような気にはなりますが、「発行後のイベントを どうやって処理したらいいの?」という風にモヤモヤします。

この記事では、DApps 開発では避けて通れないのに、Solidity の解説においては多くを語られない、そんな event についてお話ししてみたいと思います。

Solidity はバックエンド向け、 event はフロントエンド向け

バックエンドという言葉があります。
エンジニア視点で見ると「裏方 = ユーザーの目につかないところで データの管理や操作を行うモノ」というイメージです。

DApps 開発におけるバックエンドといえば、イーサリアムブロックチェーンとなります。そして、ブロックチェーン上において、ユーザーの資産やデータを管理するスマートコントラクトも、バックエンドといえるでしょう。

一方、フロントエンドという言葉もあります。
「窓口 = ユーザーと直接やりとりして 裏方とのあいだを取り持つモノ」というイメージです。「顧客」を意味する「クライアント」という言葉が、同じ意味で用いられることもあります。

Solidityevent は バックエンドであるスマートコントラクトから、フロントエンドであるクライアントへ向けたメッセージの役割を担います。event を具体的に説明するには、イベントを受け取るクライアント側の前提がないと始まりません。

では、DApps 開発におけるクライアントとはなんでしょう?

DAppsWeb 向けのサービスを想定しているのであれば、Chrome 等のウェブブラウザとなりそうです。PC 向けのサービスであれば、WindowsMac プログラムになりそうですし、スマホ向けであれば、iOSAndroid アプリとなるでしょう。サービスの提供先に応じてクライアントの選定が変わってきますし、当然、開発環境も大きく変わります。

Solidity の解説において event の発行後について多くを言及されないのは、バックエンドとフロントエンドというスタンスの違いがあるからだと思われます。それに、イベント発行後の説明をしようとなると、クライアント側での実装の話が必要となってきて、収拾がつかなくなりそうです。

「そこまで紙面を割くことはできない」というのが著者さんの本音なのだと思いいます。

イベントはクライアント本位で考える

では、発行後のイベントについて考えてみましょう。

スマートコントラクトはユーザーの現在の残高や、トークン(資産)の現在の所有者といった、「現在の状況」を扱います。一方で、「昨日受け取った ETH の合計」や「誰が誰にどんなトークンを送った」というような「状況の変化」を扱うのは得意ではありません。

DApps を開発する上で、取引履歴や状況変化の通知は、ユーザーの満足度を上げるためにも手厚く実装したいところです。そして、この状況の変化をクライアント側で検出するために活躍するのが、Solidityevent となります。

例えば、トークンの送信に対応した DApp を開発するとしましょう。

この DApp はユーザー間でトークンを送信しあえるのすが、コスト削減を理由に、下記の仕様でGOサインがでてしまったと仮定します。

・送信者がトークンを送ったら、そのまま黙ってホーム画面へ戻ってよい
(※トランザクションの結果はイーサスキャンで見てくれ!すまん!)
・トークン情報はトークン画面へ遷移した時に読み込めばよい
(※トークンの情報を知りたければトークン画面を開いてくれ!お願い!)

さて、運悪くこの DApp を利用してトークンを送ったAさんは、次のように思うはずです。
「送信完了のポップすらでないけど、本当に送れてるの…?」

同じく、この DApp を利用してトークンを受け取った(ことに気づいていない)Bさんは、次のように思うはずです。
「Aさんからのトークン、なかなか来ないな…」(※ホーム画面を眺めながら)

トークンの送信自体が無事に完了していたとしても、その過程の説明が不足していると、ユーザーを不安にさせてしまいます。この場合、Aさんに対する「送信が完了しました」通知、Bさんに対する「トークンが届きました」通知ぐらいは実装しておきたいところです。

では、この DApp に通知機能を加えるとしたらどうしたらよいでしょう?
イベントによる通知の実装の流れは、おおよそ下記のようになると思います。

1.コントラクトにてトークンの送信完了イベントを定義する(※例えば冒頭に挙げた TransferToken イベント)
2.コントラクトにて、トークンの送信が正常終了したら「送信完了イベント」を発行する(※ TransferTokenemit
3.ブロックチェーン上にて、トランザクションのレシートに「送信完了イベント」が記録される
4.クライアントにて、定期的にブロックをチェックし、ユーザーに関わる「送信完了イベント」がないかを監視する
5.クライアントにて、ユーザーに関わる「送信完了」イベントを見つけたら、適切な表示を行う
(※ユーザーが送信者であれば「送信が完了しました」通知、ユーザーが受信者であれば「トークンが届きました」通知を出す)

これならば、トークンの送信完了後にクライアント上で通知が行われるため、Aさんの不安は解消され、Bさんはホーム画面でトークンの取得に気づいたはずです。

処理の結果が同じだったとしても、その過程を丁寧に通知する DApp と、そうでない DApp では、お客さんに与える安心感に大きな差が出ることでしょう。

「どのような通知があれば利用者が 安心&満足してくれるか?」

クライアント側の実装、とくにイベントの設計において、お客さんへの優しさは欠かせません。

イベント実装の具体例

サンプル DApp により、イベントの流れを具体的に見てみましょう。このサンプルのサービス内容はミニゲーム、クライアントは iOS アプリを想定します。

サービス内容

サービス内容は「みんなで カモったり カモられたりする ゲーム」です。

ゲーム内では、参加するプレイヤーのうち1人がカモにされ、周りのプレイヤーから所持ゴールドを盗まれます。逆に、カモられているプレイヤーは、最後にカモったプレイヤーを容疑者として通報することで示談金を得られます。抜け目なくカモり、仮にカモられても示談金でやり返し、所持ゴールドを高めるのがゲームの目的です。

具体的なルール(タップで開閉します)
・プレイヤーの中から「カモ」と「容疑者」が、それぞれ1人づつ認定される
・ゲームに参加するにはETHを送金してゴールドを購入する必要がある
・ゴールドを購入したプレイヤーは、新たなカモに認定される
・カモ以外のプレイヤーはカモの所持ゴールドの「5%」をカモれる(盗める)
・一番最後にカモった(盗みを働いた)プレイヤーは、新たな容疑者に認定される
・カモは容疑者を通報することで、相手の所持ゴールドの「30%」を示談金として分捕れる
・通報されたプレイヤーはペナルティとして、新たなカモに認定される
・通報したプレイヤーは逆恨みされ、新たな容疑者に認定される

ゲームに必要なデータ

ゲームを構成するためにスマートコントラクト側で管理しないといけない情報としては下記となります。

// 管理データ
mapping( address => bool ) internal valids;   // プレイヤーの有効性
mapping( address => uint ) internal golds;    // プレイヤーの所持ゴールド
address internal targetPlayer;                // 現在のカモ
address internal suspectPlayer;               // 現在の容疑者

これらの情報がブロックチェーン上で保持され、ゲームの流れによって更新されていきます。クライアント側ではこれらの情報をスマートコントラクトから読み込んで、画面へ表示します。

ですが、上記のデータだけでは「誰が誰をカモった」等の状況説明ができません。そのための情報はイベントにより通知することになります。

イベント設計と定義

さて、このゲームのフローにおいてどのようなイベントが必要になるでしょうか?
別の言い方をすると、どのような通知があるとプレイヤーが楽しんでくれそうでしょうか?

まず、自分がカモった時にはいくら盗めたか通知して欲しいですよね。それに、カモられた時は被害額を知りたいです。また、他人同士のカモりカモられも見られた方が、ワイワイやっている感じがして楽しそうです。

この通知に使うイベントを Steal として定義しましょう。

event Steal( address indexed player, address indexed target, uint ammount );

同様に、通報された時の情報も欲しいので、Report イベントも用意しましょう。

event Report( address indexed player, address indexed suspect, uint ammount );

あとは、ゲームに参加(ETH を送ってゴールドを取得)した際に、いくら入手したかのお知らせを出さないと不親切そうです。
このイベントを BuyGold としましょう。

event BuyGold( address indexed player, uint ammount );

加えて、ゴールドを入手したユーザーはカモにされるので、良くも悪くも目立たせてドキドキしてもらいましょう。
このイベントを Target としましょう。

event Target( address player, uint gold );

以上、4つのイベントがあれば、クライアント側でゲームを盛り上げることができそうです。

通知の具体的なイメージとしては、ゲームに参加するとまず、「BuyGold:ゴールドを入手しました」と表示され、続いて「Target:気をつけろ!カモられるぞ!」と表示します。その後は、自分含めた全てのプレイヤーの「Steal:カモった/カモられた」「Report:通報した/通報された」が次々と表示されていくことになります。

メモ:
このサンプルでは、イベントをプレイヤー別にフィルタリングできるよう、addressindexed をつけて宣言しています。が、Target イベントのアドレスには indexed がついていません。これは、Target イベントは全プレイヤーに対して必ず通知されることを想定しており、フィルタリングは不要と考えたからです(ようするにどうでもよいこだわり分です)。

Solidityでの実装

ゲームの肝である、「カモる」、「通報する」、「入金する」ための処理を、「steal 関数」、「report 関数」、「ETH の入金を受け取るフォールバック関数」として用意しましょう。

steal 関数(タップで開閉します)
//--------------------------------
// 盗む(※カモをカモる)
//--------------------------------
function steal() external{
  // 未参加、自身がカモなら失敗
  require( valids[msg.sender], "you need to join the game" );
  require( msg.sender != targetPlayer, "you can not steal your own gold" );

  // 最後に盗みを働いたプレイヤーが容疑者となる
  suspectPlayer = msg.sender;

  // カモの所持ゴールドを5%盗む(※端数入り揚げ)
  uint ammount = (golds[targetPlayer]+19) / 20;
  golds[suspectPlayer] += ammount;
  golds[targetPlayer] -= ammount;
  emit Steal( suspectPlayer, targetPlayer, ammount );
}

report 関数(タップで開閉します)
//--------------------------------
// 通報する(※カモがカモる)
//--------------------------------
function report() external{
  // 未参加、自身がカモでなければ失敗
  require( valids[msg.sender], "you need to join the game" );
  require( msg.sender == targetPlayer, "you can not report your own crime" );

  // 容疑者がカモになり、逆恨みされた通報者が容疑者となる
  targetPlayer = suspectPlayer;
  suspectPlayer = msg.sender;

  // 示談金としてカモ(旧容疑者)が所持ゴールドの30%を支払う(※端数入り揚げ)
  uint ammount = (3*golds[targetPlayer]+9) / 10; 
  golds[suspectPlayer] += ammount;
  golds[targetPlayer] -= ammount;
  emit Report( suspectPlayer, targetPlayer, ammount );
}

入金(フォールバック)関数(タップで開閉します)
//-------------------------------------------
// ETHを送ったら参加 & ゴールドを得る & 狙われる
//-------------------------------------------
function () external payable {
  // 参加費は 1 gwei 以上
  if( !valids[msg.sender] ){
      require( msg.value >= 1000000000, "please send 1 gwei or more, to join the game" );
    valids[msg.sender] = true;
  }

  // ゴールドの入手
  golds[msg.sender] += msg.value;
  emit BuyGold( msg.sender, msg.value );

  // 早速狙われる
  targetPlayer = msg.sender;
  emit Target( targetPlayer, golds[msg.sender] );
}

これらの関数が正常に終了すると、それぞれに対応するイベントが emit にて発行され、クライアント側で検出できるようになります。

また、クライアントが管理データを参照できるように、status 関数も用意しましょう。クライアントがイベントを捕捉した際、ゲームの状況を画面へ反映させるために、この関数が呼ばれる想定です。

status 関数(タップで開閉します)
//--------------------------------
// ゲームの状況
//--------------------------------
function status() external view returns( uint retGold, address retTarget, uint retTargetGold, address retSupsect, uint retSuspectGold ){
  // 未参加なら失敗
  require( valids[msg.sender], "you need to join the game" );

  retGold = golds[msg.sender];            // プレイヤーの所持ゴールド
  retTarget = targetPlayer;               // カモのアドレス
  retTargetGold = golds[targetPlayer];    // カモの所持金
  retSupsect = suspectPlayer;             // 容疑者のアドレス
  retSuspectGold = golds[suspectPlayer];  // 容疑者の所持金
}

status 関数の返値は、クライアントの表示の都合によるものです。クライアント側が要求するのは、プレイーヤーの所持ゴールド、現在のカモと容疑者のプレイヤー、それぞれの所持ゴールドとなります。画面仕様等がかわれば、返値の内容はその都度修正されることになります。

これにて、Solidity 側の実装は終了です。
最終的なコードはこちらとなります。

発行されたイベントの登録先

クライアントからスマートコントラクトの関数が呼ばれるとイベントが発行され、トラザクションのレシートに登録されます。この情報はイーサリアム上に保持され、クライアント側から参照することが可能となります。

保存内容は、イベント定義(イベント名と引数の型の羅列)のハッシュ値と、各引数の値を羅列したものとなります。indexed をつけた引数は Topics 枠に登録され、ついていない引数は Data 枠に登録されます。この時、イベント定義のハッシュ値が Topics[0] に登録され、イベント名によるフィルタリングに対応されます。また、event 定義で indexed で指定された引数は、Topics[0] の後ろから定義順に並ぶことになります。

例えば、steal イベントをイーサスキャンで見ると下記のような内容となります。

Topics
 [0] 0xc4521c77cd8558a7102424919c444912561b76d8973dfc5482ea96932e5a9a47
 [1] 0x00000000000000000000000093231c1b82547b20e85b70b87f1c98dcbd35d88a
 [2] 0x000000000000000000000000431e16c88dc8a449b93fb67d2bb08f1f6695348c

Data
 Hex 00000000000000000000000000000000000000000000000000000000170bb4c9

indexed で指定した playertarget の引数は Topics[1]Topics[2] として登録されています。一方で、indexed 指定のない ammount の引数は Data に登録されています。

さて、indexed をつけることで、この Topics 配列に追加するメリットはというと、指定の引数が添字で参照可能になるため、クライアント側でイベントのフィルタリングに利用できるという点です。

クライアント側の実装

このサンプルでのクライアントは iOS での実装となります。
先にお話した通り、どのプラットフォームをターゲットにするかにより開発環境は大きく変わります。ここでは、iOS 特有の実装には深入りせず、クライアントがどのようにイベントの処理をするかの流れにしぼって説明します。

まず、イベント処理の基本は、新しく発行されるイベントがないかの監視なので、監視ループを開始します(※コードの詳細はわからなくても雰囲気をつかんでくだされば十分です)。

イベント監視ループの開始(タップで開閉します)
// イベントループの開始
let web = helper.getCurWeb3()!
let functionToCall: web3.Eventloop.EventLoopCall = onNewBlock
let monitoredProperty = web3.Eventloop.MonitoredProperty.init(
    name: "onNewBlock",
    queue: web.requestDispatcher.queue,
    calledFunction: functionToCall
)
web.eventLoop.monitoredProperties.append( monitoredProperty )
web.eventLoop.start( 1.0 )

イベントの監視ループでは一定間隔おきに新規ブロックが監視され、新しいブロックが検出されたら、そのブロック中のイベントををフィルタリングします。

イベントのフィルタリング(タップで開閉します)
var filter = EventFilter()
filter.fromBlock = .blockNumber(UInt64(blockNumber))
filter.toBlock = .blockNumber(UInt64(blockNumber))

// フィルター:BuyGold
if eventName == "BuyGold" {
    // 自身のアドレスでフィルタ
    filter.parameterFilters = [
        ([self.helper.getCurAddress()!] as [EventFilterable])
    ]
}

do{
    guard let result = try contract?.getIndexedEvents( eventName:eventName, filter:filter ) else {
                return
            }

サンプルコードでは、BuyGold はプレイヤーのアドレスでフィルタリングしています。これにより、プレイヤーが発行した BuyGold イベントだけが抽出され、プレイヤー自身のゴールド購入通知のみが表示されることになります。

一方で、StealReport イベントはイベント名のみでフィルタリングしています。結果として、全ての「カモられた」、「通報された」イベントが検出されることになりますが、検出後、イベントの引数のプレイヤーが自身であるかどうかにより、表示の割り振りを行なっています。

同様に、Target イベントもイベント名のみでフィルタリングを行い、検出後、自分がターゲットにされたのか否かで表示を割り振っています。

検出したイベントの通知(タップで開閉します)
if result.count > 0 {
    for event in result{
        switch event.eventName {
        case "BuyGold":
            addLog( "購入完了 \(event.decodedResult["1"]!) GOLDを手に入れた!" )
            break;

        case "Target":
            let addressTarget = (event.decodedResult["0"] as? EthereumAddress)!.address
            if( addressTarget != self.helper.getCurEthereumAddress() ){
                addLog( "カモ(\(addressTarget.prefix(10))...)発見! \(event.decodedResult["1"]!) GOLD所持している!" )
            }else{
                addLog( "カモ認定された! 気をつけろ!" )
            }
            break;

        case "Steal":
            let addressPlayer = (event.decodedResult["0"] as? EthereumAddress)!.address
            let addressTarget = (event.decodedResult["1"] as? EthereumAddress)!.address

            if( addressPlayer == self.helper.getCurEthereumAddress()! ){
                addLog( "カモ(\(addressTarget.prefix(10))...)から \(event.decodedResult["2"]!) GOLD 盗んだ!" )
            }else if( addressTarget == self.helper.getCurEthereumAddress()! ){
                addLog( "容疑者(\(addressPlayer.prefix(10))...)に \(event.decodedResult["2"]!) GOLD 盗まれた..." )
            }else{
                addLog( "カモ(\(addressTarget.prefix(10))...)が容疑者(\(addressPlayer.prefix(10))...)にカモられてるw" )
            }
            break;

            case "Report":
                let addressPlayer = (event.decodedResult["0"] as? EthereumAddress)!.address
                let addressTarget = (event.decodedResult["1"] as? EthereumAddress)!.address

                if( addressPlayer == self.helper.getCurEthereumAddress()! ){
                    addLog( "容疑者(\(addressTarget.prefix(10))...)から \(event.decodedResult["2"]!) GOLD の示談金を奪った!" )
                }else if( addressTarget == self.helper.getCurEthereumAddress()! ){
                    addLog( "カモ(\(addressPlayer.prefix(10))...)に \(event.decodedResult["2"]!) GOLD の示談金を奪われた..." )
                }else{
                    addLog( "カモ(\(addressPlayer.prefix(10))...)が容疑者(\(addressTarget.prefix(10))...)を通報してるw" )
                }
                break;

        default:
            addLog( "エラー:知らないイベント \(event.eventName)" )
            break;
        }
    }
}

上記の処理において、Topics のフィルタリングによりイベントを抽出し、Data 連想配列から詳細情報が取り出されて処理する流れを、イメージしていただけたことと思います。

これにて、クライアント側での実装も終了です。
最終的なサンプルプロジェクトはこちらとなります。
(※iOS アプリ上でのイーサリアムへの接続に関してはこちらの記事を参照ください)

ゲームの動作確認

複数台で並べてゲームを遊んでみた様子です。

プレイヤーAの様子:新規参加でカモられている
iPad6.PNG

プレイヤーBの様子:プレイヤーAをカモっている
iPhone7p.PNG

プレイヤーCの様子:両者の様子を眺めつつ、姑息にカモっている
iPhone6.PNG

画面下にでている時間付きのログが、イベントによって通知された情報です。各クライアントによる「カモる/通報する」行為の結果がイベントを発生させ、クライアント毎にフィルタリングされて新しい順に表示されているのが見て取れるかと思います。

このサンプル DApp の通知方法はシンプルなログの垂れ流しですが、イベント処理としては十分な機能が備わっています。あとは、絵的/音的な演出を作り込んでいくことで、ゲームとしての面白みを向上していけると思われます。

まとめ

Solidity の仕様としては ほんの小さな event の存在ですが、クライアント側で果たす役割の大きさを具体的にイメージしていただけたのであれば幸いです。

DApps 開発において、クライアントの使い勝手は、ユーザーの満足度に大きく影響してきます。ユーザー本位のクライアントフローを検討し、優しさあふれるイベント設計を心がけたいものです。

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

�[Firebase]: Firestoreのアクセス期限切れへの対処法

最近サーバとのデータ通信が必要なiOSアプリを作成しているのですが、FirebaseのデータベースサービスであるFirestoreを利用してから数週間経ったある日、こんなメールが、
スクリーンショット 2020-03-21 13.35.03.png

データベースが攻撃に対して脆弱であり、セキュリティルールを厳しくせよとのことでした。

認証されたユーザーからのみアクセスを許可するルールにすれば解決なのですが、ログイン機能を持たせるアプリでもないため、どうしたものか、、、と思案していたところ、Firebaseの匿名認証という機能を発見しました。匿名認証を利用することで、ユーザーにログインを強制することなく、データベースのセキュリティを安全に保つことができます。

Firebase で認証する一時的な匿名アカウントを、Firebase Authentication で作成して使用できます。一時的な匿名アカウントを使用すると、アプリに登録していないユーザーが、セキュリティ ルールで保護されているデータを使用できるようになります。
https://firebase.google.com/docs/auth/ios/anonymous-auth?hl=ja

設定方法は下記の通りです。

匿名認証の実装方法

Authenificaion → ログイン方法 から匿名認証を有効にします。
スクリーンショット 2020-03-21 14.02.34.png

AppDelegaet.swiftにsiginInAnonymously()メソッドを追加すれば、起動時に自動で匿名ユーザーが作成されます。

AppDelegaete.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        FirebaseApp.configure()

        // 匿名認証(下記のメソッドがエラーなく終了すれば、認証完了する)
        Auth.auth().signInAnonymously() { (authResult, error) in
            if error != nil{
                print("Auth Error :\(error!.localizedDescription)")
            }

             // 認証情報の取得
             guard let user = authResult?.user else { return }
             let isAnonymous = user.isAnonymous  // true
             let uid = user.uid
            return
        }
        return true
    }

セキュリティルールの設定方法

認証ユーザーのみ読み取りを許可するよう、Firestoreのセキュリティルールを修正します。

service cloud.firestore {
  match /databases/{database}/documents {
    match/collectionName/{document=**}{
        allow read: if request.auth != null;
    }    
  }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Firestore データの追加 現在時刻を配列に格納する (Swift)

現在時刻を何度も取得して、Firestoreドキュメントの配列に格納していくやり方で困ったので、メモ&共有。

ポイント1. 現在時刻はTimestamp(date: Date())で取得。

Fieldvalue.timestamp() で時刻を取得することもできるんですが、
これをFirestoreのTimestamp配列フィールドに入れようとするとエラーになります。

2020年3月時点では、Fieldvalue.timestamp()は配列に対応していないようです。

よって、公式ドキュメントにさらっと書いてある「Timestamp(date: Date())」で現在時刻を取得してTimestamp配列に入れると、こちらは問題なし。

ポイント2.arrayUnionは配列の新規作成でも使える

公式ドキュメントでは、arrayUnionは「配列がある場合に」と記載されていますが、以下のように、配列がない場合でも1番目の配列要素を作成してくれました。

以上、ご参考まで。

    let historyRef = db.collection("history").document(String(user!.uid))

    historyRef.setData([
        "userid" : String(user!.uid),//ユーザID
        "objectid" : objectid,
        //arrayUnionで、初回配列がない場合でもた1個目の配列要素を自動で追加してくれる
        "history" : FieldValue.arrayUnion([Timestamp(date: Date())])
    ], merge: true
        ) { err in
        if let err = err {
            print("Error adding document: \(err)")
        } else {
            print("Document added with ID")
        }
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

簡単なゲームを作ってみよう(Win or Lose)

簡単なゲームを作ってみよう

ドットインストールはじめてのJavaScriptで「#11 簡単なゲームを作ってみよう」をやっあと、
ふと思い立ったのでFlutterで作ってみました。

仕様

  1. 5つのマスがあり、5つのうちランダムで1つが「Win」、4つが「Lose」になる。
  2. 「Lose」が選択された場合、マスに"Lose!"が表示される。
  3. "Lose!"が表示されるのと同時にマスが小さくなる。
  4. 「Win」が選択された場合、マスに"Win!"が表示される。
  5. "Win!"が表示されるのと同時にマスが青からピンクに変わり、回転しながら四角形から円に変わる。

こんなところでしょうか。

作ってみる

とりあえず、以下がマスを操作するソースコードです。
もっと効率の良い書き方や操作の仕方、お行儀の良い書き方など教えていただければありがたいです。

  • winnerはランダムで「Win」を設定しています。
  • for文でマスをつくり、リストのindexとwinnerとが一致したら「Win」の操作、それ以外は「Lose」の操作をします。
  • RotationTransitionでマスの回転、AnimatedPaddingとAnimatedContainerでマスのサイズ、色の変更、文字の挿入をしています。
class MyCard extends StatefulWidget{
  MyCard({Key key, this.index}) : super(key: key);

  final int win = winner;
  final int index;

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

class _MyCardState extends State<MyCard> with SingleTickerProviderStateMixin {
  Color _mycolor = Colors.blue[500];
  String _mytxt = '';
  double _width = 100, _heigth = 100, _padding = 10, _radius = 0;

  final Tween<double> turnsTween = Tween<double>(
    begin: 1,
    end: 2,
  );
  AnimationController _controller;

  initState() {
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 1),
    );
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return card();
  }

  void _winorlose(){
    setState(() {
      if (widget.index == widget.win) {
        _mycolor = Colors.pink[100];
        _mytxt = 'Win!';
        _radius = 100;
        _controller.forward();
      } else {
        _mytxt = 'Lose!';
        _width = 80;
        _heigth = 80;
        _padding = 20;
      }
    });
  }

  Widget card() {
    return GestureDetector(
      onTap: () {
        _winorlose(); 
      },
      child: RotationTransition(
        turns: turnsTween.animate(_controller),
        child: AnimatedPadding(
          duration: const Duration(milliseconds: 500),
          padding: EdgeInsets.all(_padding),
          child: AnimatedContainer(
            duration: const Duration(milliseconds: 500),
            width: _width,
            height: _heigth,
            child: Center(
              child: Text(_mytxt),
            ),
            decoration: BoxDecoration(
              color: _mycolor,
              borderRadius: BorderRadius.circular(_radius),
            ),
          )
        ),
      ),
    );
  }
}

こんな感じになりました

終わりに

読んでいただきありがとうございました。
そういえば、Google Mapsの操作も放置したままだったな。。。

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

Firestore × iOS(Swift)【環境構築編】

前置き

今回書く事は公式ドキュメントに全て書いてあります。
しかし以前の僕がそうだったように、「実際使ってるところを見ないと分からん!」という人もいると思います。
なので、当時の僕に向けて写真多めで説明していきます。

Firestoreでプロジェクトを作成

スクリーンショット 2020-03-21 3.56.54.png
「プロジェクトを追加」ボタンを押します。

スクリーンショット 2020-03-21 3.57.15.png
名前を付けます。(今回は適当に付けましたが、どのアプリか後々分かるようにした方が良いです)

スクリーンショット 2020-03-21 3.57.45.png
料金プランを確認。使用量が少ないうちは無料です。→参考

スクリーンショット 2020-03-21 3.58.21.png
「続行」を選択。

スクリーンショット 2020-03-21 3.58.30.png
「続行」を選択。

スクリーンショット 2020-03-21 3.58.47.png
「Firebaseを追加」を選択。

スクリーンショット 2020-03-21 3.59.10.png
おめでとうございます!新しいプロジェクトが作成されました。

Xcode側の準備

今回は「FirestoreSample」というプロジェクトを予め作っておきました。
このプロジェクトにFirestoreを紐付けていきます。
スクリーンショット 2020-03-21 4.03.11.png

スクリーンショット 2020-03-21 4.04.07.png
先ほど作成したXcodeプロジェクトのバンドルIDをコピペします。

スクリーンショット 2020-03-21 4.04.34.png
「GoogleService-Info.plist」をダウンロードします。
zipファイルを解凍し、Xcodeプロジェクトに追加します。

スクリーンショット 2020-03-21 4.06.55.png
このようになっていれば正常に追加されています。
(注意)何回もこのファイルをダウンロードしていると「GoogleService-Info(1).plist」のように数字がくっつきます。このファイルでは正常に動作しませんので、一度削除してもう一度ダウンロードしてください。

スクリーンショット 2020-03-21 4.07.24.png
CocoaPodsを使ってFirebaseSDKを追加します。CocoaPodsの使い方はここでは割愛します。

スクリーンショット 2020-03-21 4.09.40.png
Xcodeプロジェクトの「Appdelegate.swift」に「import Firebase」、「FirebaseApp.configure()」を追加します。

スクリーンショット 2020-03-21 4.12.19.png
この位置です。

スクリーンショット 2020-03-21 4.20.51.png
⌘Rでビルドしてみましょう。正常に設定できていればこのような画面になるはずです。
お疲れ様でした。

終わりに

次回はデータの追加や読み取りについて書ければと思います。
間違いがあればご指摘頂けると幸いです。

Twitterやってます→@_4ryunity
未経験からエンジニアへ転職活動中の25歳コンビニ店員です。
時間を見つけて投稿していきます。

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

【Swift】よく分からなかったdelegateについて整理してみた(初心者が初心者のために)

そもそもdelegateってなんだ

言葉を調べてみる

delegateを辞書で調べてみると、

1.委任する
2.代表[代理]に立てる

という意味らしい。

なるほど、言葉の意味は分かった。

Swift的観点で調べる

では、Swiftにおいて、delegateってなんだ?ともう少し調べてみると、

「delegateとは、デザインパターンのひとつです。」

なんて出てきた。

なるほど、分からないようで分からない。

分からんかったからさらに調べる

さらに調べていくと、TECHACADEMYにたどり着いた。
https://techacademy.jp/magazine/15055

ここでは、

『実際のイメージとしては「ある特定の事項について、問い合わせ先を指定する」操作と覚えておくと分かりやすいです。』

と書いてあった。

delegate = 問い合わせ先とも書いてあって、これが一番理解しやすいような気がする。(なんとなく)

まあ、とりあえず実装してみないと分からないイメージつかないので、実装に移りたい。

delegateを実装するために必要なもの

1.protocol

委任する処理をmethodとして定義する。これをデリゲートメソッドと呼ぶ。
言わば「仕様書」と言ったところ。

2.処理を委任するclass

デリゲートメソッドを使用して、処理の流れを記述する。

3.処理を委任されるclass

protocolで定義されたデリゲートメソッドを実装する。

実際に実装してみる

1.protocol

メソッドを定義する。

HorseDataProtocol.swift
protocol horseDataDelegate {

    func horseName() // 競走馬名
    func horseBirthYear() // 競走馬の誕生年
    func horseWinNumber() // 競走馬の勝利数
    func horseIsLeadingSire() // 競走馬の種牡馬状況

}

2.処理を委任するclass

今回はViewController内に実装した。

ViewController.swift
import UIKit

class ViewController: UIViewController {

    var delegate: horseDataDelegate? //delegateを定義

    override func viewDidLoad() {
        super.viewDidLoad()

        //使用するdelegateをここで呼び出す
        delegate = DeepImpact()

        //delegate内のメソッドを呼び出す
        delegate?.horseName()
        delegate?.horseBirthYear()
        delegate?.horseWinNumber()
        delegate?.horseIsLeadingSire()
    }
}

3.処理を委任されるclass

protocolに記載したメソッドを使用して記載する。

DeepImpact.swift
class DeepImpact: horseDataDelegate {

    func horseName() {
        print("Horse Name is Deep Impact")
    }

    func horseBirthYear() {
        print("Horse was born in 2002")
    }

    func horseWinNumber() {
        print("Horse won 12")
    }

    func horseIsLeadingSire() {
        print("Horse is Leading Sire")
    }

}

これでRunすると、こんな感じで出力される。

出力
Horse Name is Deep Impact
Horse was born in 2002
Horse won 12
Horse is Leading Sire

共通なメソッドをprotocolに用意する

ここでまとめても良かったが、もう少し突っ込んでみる。

horseDataDelegateのクラスをもう一つ作成してみた。

Orfevre.swift
class Orfevre: horseDataDelegate {

    func horseName() {
        print("Horse Name is Orfevre")
    }

    func horseBirthYear() {
        print("Horse was born in 2008")
    }

    func horseWinNumber() {
        print("Horse won 12")
    }

}

ViewControllerのdelegateもこれに伴って変更してみる。

ViewController.swift
import UIKit

class ViewController: UIViewController {

    var delegate: horseDataDelegate? //delegateを定義

    override func viewDidLoad() {
        super.viewDidLoad()

        //delegate先を変更
        delegate = Orfevre()

        delegate?.horseName()
        delegate?.horseBirthYear()
        delegate?.horseWinNumber()
        delegate?.horseIsLeadingSire()
    }
}

これでRunすると、Orfevreクラスでエラーが出る。

出力
Type 'Orfevre' does not conform to protocol 'horseDataDelegate'

horseIsLeadingSireメソッドないよ!って言われてる。

基本的にprotocolに記載されているものは全部書かないといけない

が、共通でもいいメソッドがある時は、わざわざ全部のメソッドを書く必要もない.

そこで、protocolを少しだけ修正する。

HorseDataProtocol.swift
protocol horseDataDelegate {

    func horseName() // 競走馬名
    func horseBirthYear() // 競走馬の誕生年
    func horseWinNumber() // 競走馬の勝利数
    func horseIsLeadingSire() // 競走馬の種牡馬状況

}

extension horseDataDelegate {

    // このメソッドはデフォルトで実装しておく
    func horseIsLeadingSire() {
        print("Horse is not Leading Sire")
    }
}

共通で使用するメソッドは、このように、extensionで用意しておけばいい。

DeepImpact.swift内のhorseIsLeadingSire()は、これをオーバーライドしているという形になる。

これでRunが通る。出力はこんな感じ。

出力
Horse Name is Orfevre
Horse was born in 2008
Horse won 12
Horse is not Leading Sire

本当は、もう少し書きたいことがあったけど、長くなりそうなので今回はここまでで。

最後に

今回作成したものはGithubに載せています。
https://github.com/taichi6930/SwiftDelegateSample

初めてQiitaに投稿してみました。
やはり文章を書くのは難しいですね。

とはいえ、自分の頭の中で整理しながら書くことができました。
これからも続けていきたいと思います。

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