- 投稿日:2020-12-18T19:33:50+09:00
NotificationCenterを用いて、iOSアプリのライフサイクルイベントを検知する
はじめに
iOSアプリがフォアグラウンド、ハックグラウンドに入った等のライフサイクルイベントを、NotificationCenterを用いて検知する方法について書いていきます。このNotificationCenterの使用例として、アプリがバックグランドに入って30分以上経過した状態でフォアグラウンドに戻った時に、API通信を行う方法をRxSwift、ViewModelを使用して説明します。
環境
- Swift:5.3
- Xcode:12.1
iOSアプリのライフサイクル
下記がiOSアプリのライフサイクルの図です。
Managing Your App's Life Cycleより引用。
状態 内容 Unattached アプリが未起動の状態。 Background アプリがバックグラウンドで実行中。 Foreground Inactive アプリがフォアグラウンドで実行中だが、イベントは受信していない。別の状態に切り替わる時に、一瞬この状態になる。 Foreground Active アプリがフォアグラウンドで実行中。アプリを使用している時は基本的にこの状態。 Suspended アプリがバックグラウンドで未実行。 状態遷移時に呼ばれるメソッド
メソッド 呼ばれるタイミング func application(_:willFinishLaunchingWithOptions) アプリ起動後 func application(_:didFinishLaunchingWithOptions:) アプリが画面を表示する直前 func sceneWillEnterForeground(UIScene) フォアグラウンドで実行を開始しようとする時 func sceneDidBecomeActive(UIScene) アクティブになり、イベントを受け取れる状態になった時 func sceneWillResignActive(UIScene) アクティブ状態から離れ、イベントの受信を止めようとしている時 func sceneDidEnterBackground(UIScene) バックグラウンドに入った時 func applicationWillTerminate(UIApplication) アプリが終了する時 アプリ全体で状態遷移のイベントを検知し、何らかのメソッドを実行したい場合はAppDelegate、SceneDelegateにある上記の各メソッド内に記述すれば問題ありません。ただ、場合によってはViewControllerで各状態遷移のイベントを検知したいことがあると思いますので、その方法について記述していきます。
ViewControllerで状態遷移イベントを検知する
状態遷移イベントを検知するNotificationCenterが用意されているので、それを使用します。
他にもありますが、主に下記4つがあります。
Notification 通知するタイミング willEnterForegroundNotification フォアグラウンドで実行を開始しようとする時 didBecomeActiveNotification アクティブになり、イベントを受け取れる状態になった時 willResignActiveNotification アクティブ状態から離れ、イベントの受信を止めようとしている didEnterBackgroundNotification バックグラウンドに入った時 NotificationCenterが通知するタイミング
アプリ起動時
- willEnterForegroundNotification
- didBecomeActiveNotification
アプリをバックグラウンドへ
- willResignActiveNotification
- didEnterBackgroundNotification
コントロールセンターを表示して閉じる
- 表示
- willResignActiveNotification
- 閉じる
- didBecomeActiveNotification
通知センターを表示して閉じる
- 表示
- willResignActiveNotification
- didBecomeActiveNotification(←なぜか呼ばれる)
- willResignActiveNotification(←2度目が呼ばれる)
- 閉じる
- didBecomeActiveNotification
使用例
今回はアプリがバックグランドに入って30分以上経過した状態でフォアグラウンドに戻った時にAPI通信を行うようにします。RxSwiftを使用して、ViewModelとバインディングします。
ViewController.swiftimport UIKit import RxSwift import RxCocoa final class ViewController: UIViewController { private let viewModel = ViewModel() private let disposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() bindOutput() bindIntput() } private func bindOutput() { NotificationCenter.default.rx.notification(UIApplication.willResignActiveNotification) .subscribe(onNext: { [unowned self] _ in // アプリがアクティブではなくなる時の時間を取得 self.viewModel.inputs.willResignActiveTime.onNext(Date()) }) .disposed(by: disposeBag) NotificationCenter.default.rx.notification(UIApplication.didBecomeActiveNotification) .subscribe(onNext: { [unowned self] _ in // アプリがアクティブになった時の時間を取得 self.viewModel.inputs.didBecomeActiveTime.onNext(Date()) }) .disposed(by: disposeBag) } private func bindIntput() { viewModel.outputs.refreshData .subscribe(onNext: { [unowned self] in // API通信する処理を記載 }) .disposed(by: disposeBag) } }Viewmodel.swiftimport RxCocoa import RxSwift protocol ViewModelInput { var willResignActiveTime: PublishSubject<Date> { get } var didBecomeActiveTime: PublishSubject<Date> { get } } protocol ViewModelOutput { var refreshData: PublishSubject<Void> { get } } protocol ViewModelType { var inputs: ViewModelInput { get } var outputs: ViewModelOutput { get } } final class ViewModel: ViewModelType, ViewModelInput, ViewModelOutput { var inputs: ViewModelInput { return self } var outputs: ViewModelOutput { return self } private let disposeBag = DisposeBag() // Inputs var willResignActiveTime = PublishSubject<Date>() var didBecomeActiveTime = PublishSubject<Date>() // Outputs public var refreshData = PublishSubject<Void>() init() { setBind() } private func setBind() { let thirtyMinutesPassed = didBecomeActiveTime .withLatestFrom(willResignActiveTime) { [unowned self] didBecomeActiveTime, willResignActiveTime in self.checkThirtyMinutesPassed(didBecomeActiveTime, willResignActiveTime) } thirtyMinutesPassed .filter { $0 } .subscribe(onNext: { [unowned self] _ in // バックグラウンドに入って30分以上経過した場合更新する self.outputs.refreshData.onNext(()) }) .disposed(by: disposeBag) } // アプリがバックグラウンドに入ってから30分経過したか判定 // バックグラウンドに入った時間とアプリがアクティブになった時間の差が30分(30*60秒=1800秒)以上の時はtrueを返す private func checkThirtyMinutesPassed(_ didBecomeActiveTime: Date, _ willResignActiveTime: Date) -> Bool { let convertedDidEnterBackgroundTime = Int(willResignActiveTime.timeIntervalSince1970) let convertedDidBecomeActiveTime = Int(didBecomeActiveTime.timeIntervalSince1970) return convertedDidBecomeActiveTime - convertedDidEnterBackgroundTime >= 1800 } }今回はアプリがバックグランドに入った時、フォアグラウンドに戻った時の時刻をそれぞれNotificationCenterを使用して取得し、その差が30分以上の場合にAPI通信をするように実装しました。
アプリがフォアグラウンドになった状態検知に
didBecomeActiveNotification
を使用しました。アプリがフォアグラウンドになる時にはwillEnterForegroundNotification
でも通知されますが、まだアプリが完全にアクティブな状態でない時に通知されるため、正常にメソッドが実行されない懸念があります(今回で言うとAPI通信する処理)。そのため、確実にアクティブな状態になった時に通知されるdidBecomeActiveNotification
を使用するのが良いです。アプリがバックグラウンドに入った状態検知には
willResignActiveNotification
を使用しました。didEnterBackgroundNotification
でもバックグラウンドに入ったことを検知できますが、コントロールセンター/通知センターを使用する時に問題が発生します。
その理由は、コントロールセンター/通知センターを表示する際にはdidEnterBackgroundNotification
は通知されませんが、閉じた際にはdidBecomeActiveNotification
が通知されるからです。例えば下記のようなことが発生してしまいます。12:00 バックグラウンドに入る(didEnterBackgroundNotification) 12:31 バックグランドから復帰(didBecomeActiveNotification) 12:32 通知センター/コントロールセンターを開く -> didEnterBackgroundNotificationは呼ばれず、バックグラウンドに入った時刻は12:00のまま 12:33 通知センター/コントロールセンターを閉じる -> didBecomeActiveNotificationが呼ばれ、復帰した時刻は12:33 -> 12:00と12:33で差が30分以上のため、通知センター/コントロールセンターを表示してすぐ閉じた場合でもメソッドが実行される今回はAPI通信処理を実行するので、通知センター/コントロールセンターを表示してすぐ閉じた場合にもメソッドが実行されてしまうと、無駄な通信が発生することになってしまいます。このような事を避けるため、通知センター/コントロールセンターを表示した場合にも通知される
willResignActiveNotification
を使用しました。これで通知センター/コントロールセンターを表示してすぐ閉じた場合にメソッドが実行されるということはなくなります。参考文献
- 投稿日:2020-12-18T17:30:11+09:00
iOS14 で始める CarPlay
はじめに
みなさん、CarPlay をご存知でしょうか?
実はiOS7以降のiOSには CarPlay という機能が搭載されており、対応車種であればカーナビに iPhone を繋いで、カーナビ上で CarPlay 対応された iPhone アプリを操作できるようになっています。
対応車種はこちら
本稿ではその CarPlay 対応アプリのサンプルとしてナビゲーションアプリを取り上げ、地図を表示させるところまで説明します。
準備
実際にカーナビにつなげる CarPlay のアプリの開発を始めるに当たってXcodeを開く前に色々と準備をする必要があります。
1. CarPlay の開発申請
2. Provisioning Profile の設定なお、とりあえず Simulator でいいから作ってみたい、という人はここの項目を飛ばしてもらってCarPlay アプリを実装するの項目に行ってもらって結構です。
CarPlay の開発申請
まず、iOS アプリ開発の基本として実機デバッグするには Apple Developer Program (以下ADP) に試験用端末の登録と Provisioning Profile の登録が必要なのですが CarPlay の場合はその Provisioning Profile に CarPlay 用の設定を行わないといけません。
ですが ADP の Provisioning Profile の設定画面みてもありません。その設定を行うにはまず Apple に申請を ここ から行う必要があります(要 ADP アカウント)。
その際、開発する CarPlay 向けアプリがどのようなものか App Type のセレクトボックスから以下のカテゴリから選びます
1. Audio - 音楽アプリ
2. Automaker - 車両組み込み機能アプリ
3. Communication - チャットアプリ
4. EV Charging - 充電スポット検索アプリ
5. Navigation - カーナビアプリ
5. Parking - 駐車場検索アプリ
6. Quick Food Ordering - レストラン検索アプリ上記カテゴリ以外のアプリは開発は許されておらず、また、2つ以上を組み合わせるアプリを作ることも許されてはいません。
この中で特殊なのが 2 の Automaker です。エアコンの操作やラジオと言った車両に組み込まれてる機能を操作するためのアプリです。
もちろん車両側からそのAPIが提供されている必要があるため実際には開発する際は車両 or 周辺機器メーカーと協業する必要がでるでしょう。なお、申請を行えるのは ADP アカウント保持者のみです。 InHouseバイナリを配布できる法人向けアカウント Apple Developer Enterprise Program(以下 ADEP )アカウントで申請しようとすると、この画面ではなく以下のような権限がないことを伝える画面が表示されます。
実はこれは結構厄介で、
現状では CarPlay 対応させると InHouse ビルドができなくなることを意味しています。業務で iOS アプリを開発してる人のほとんどが社内テスト用に InHouse ビルドしてると思うのですが、
CarPlay 対応の開発をする際はビルドコンフィルグ等をいじって InHouse ビルド時は コンパイル範囲から CarPlay を外すと言った処理が必要になってくるでしょう。(もしくは TestFlight を使うか)Provisioning Profile の設定
CarPlay の申請が通ると Provisioning Profile 作成画面に新たに Entitlement を設定する項目が追加されます
そこの項目に App Type で設定したカテゴリを選択します。
以上で CarPlay を実機デバッグするための事前準備は終了です。次に実際に CarPlay を実装していくために必要な設定及びコードを説明していきます。CarPlay 向けアプリを実装する
では実際にiOSアプリを CarPlay 対応させていきましょう。
CarPlay 対応アプリと書いてしまうと tvOS や watchOS アプリのように専用の Bundle Identifier を用意して
ターゲットを新たに作るというイメージを持つ人がいるかもしれませんが実はそうではなく、すでにある iOS アプリのUIをカーナビ向けに最適化させる作業になります。
どちらかというと iPhone アプリの iPad 対応の方が感覚的に近いです。では、はじめに、で述べたようにナビゲーションアプリを作っていきます
Simulator について
前段で実機デバッグするための準備について書きましたが、ずっと車両やカーナビに繋いで開発していくのも難しいため Simulator を使うことになると思います。
CarPlay の Simulator は実は iPhone Simulator に組み込まれており以下の図の様に I/O → External Display → CarPlay を選択して表示できます
Entitlements ファイルを作成
開発するアプリが CarPlay 対応アプリであるということを設定する必要があります。
Entitlements ファイルを作り Provisioning Profile に設定されたカテゴリIDを有効化させます。
今回作るナビゲーションアプリの場合、カテゴリIDはcom.apple.developer.carplay-maps
になります<dict> <key>com.apple.developer.carplay-maps</key> <true/> </dict>CarPlay 用 SceneDelegate を作る
実は Entitlements ファイルを作成した時点で CarPlay からアプリを呼ぶことが可能になります。
が、この時点で CarPlay 側から呼びだすと CarPlay 用の SceneDelegate が設定されていないためクラッシュします。
そのため、まず以下の様な CarPlay 用の SceneDelegate を用意します。CarPlaySceneDelegate.swiftclass CarPlaySceneDelegate: UIResponder { var interfaceController: CPInterfaceController? var window: CPWindow? } extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate { func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController, to window: CPWindow) { self.interfaceController = interfaceController } }上記が CarPlay 用の SceneDelegate の最小限実装です。
CarPlay から呼ばれるためにCPTemplateApplicationSceneDelagate
を実装します。
CarPlay 上でアプリが呼ばれると上記のtemplateApplicationScene(_ templateApplicationScene:didConnect interfaceController:to window:)
メソッドが呼ばれるのでその中で CarPlay 用の画面を作っていくことになります。CarPlay 用の SceneDelegate を呼び出せるようにする
CarPlay 用の SceneDelegate を作ったら今度は CarPlay から呼び出せるように設定をします。
方法としては、Info.plist でやる方法と AppDelegate でやる方法の2つがあります呼び分け方法その1 - Info.plist でやる方法
Info.plist に
Application Scene Manifest
という項目があります。
XML のキーとしてはUIApplicationSceneManifest
になります。
その中に項目としてUISceneConfigurations
を追加し、iPhone用のSceneDelegate
クラス、 CarPlay 用のSceneDelegate
クラスをそれぞれ以下のように追記します<key>UIApplicationSceneManifest</key> <dict> <key>UISceneConfigurations</key> <dict> <key>UIWindowSceneSessionRoleApplication</key> <!-- "iPhone用のSceneDelegate設定"--> <array> <dict> <key>UISceneConfigurationName</key> <string>iPhone</string> <key>UISceneDelegateClassName</key> <string>$(PRODUCT_MODULE_NAME).IPhoneAppSceneDelegate</string> </dict> </array> <!-- "CarPlay用のSceneDelegate設定"--> <key>CPTemplateApplicationSceneSessionRoleApplication</key> <array> <dict> <key>UISceneConfigurationName</key> <string>CarPlay</string> <key>UISceneDelegateClassName</key> <string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string> </dict> </array> </dict> </dict>これを記述することで自動的に呼び出し元が iPhone 画面なのか CarPlay 画面なのかが判定されそれに合わせた
SceneDelegate
が呼ばれるようになります。呼び分け方法その2 - AppDelegate でやる方法
Info.plist だけでなく AppDelegate 側にコードを記述して出しわけの制御を書くこともできます。
iOS13から AppDelegate に画面情報(UIScene
)を受け取るデリゲートメソッドを定義することができるようになりました。AppDelegate.swift@available(iOS 13.0, *) func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { switch connectingSceneSession.role { case .carTemplateApplication: // CarPlay 用のアプリとして起動された let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) config.delegateClass = CarPlaySceneDelegate.self return config default: // それ以外 let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) config.delegateClass = IPhoneAppSceneDelegate.self return config } }渡ってくる
UISceneSession
のrole
プロパティをみるとアプリがどのシーンから起動されたのかがわかるので、
そこで出しわけをすることになります。
Info.plist
で出しわけをする方がいいのかAppDelegate
で出しわけをする方がいいのか正直悩ましいです。
アプリのデプロイ戦略に応じて変わると考えています。ちなみに、両方書いた場合はどちらかが無視される、というわけではなくどうやら結果が合成されるようです。
これ以上はUIScene
の概念の説明となり本稿の主題からずれるので説明しませんが、omochimetaru さんの記事が詳しいので
以下を読んでいただけるといいかもしれません。
https://qiita.com/omochimetaru/items/31df103ef98a9d84ae6bCarPlay 向けの画面を用意する
では準備が全て終わったので CarPlay アプリを作っていきましょう。
以下のコードが CarPlay から呼び出された際に画面を表示する為の最小限のコードです。
ナビゲーションアプリを作るための UI テンプレートであるCPMapTemplate
をセットし、起動時にそのテンプレートが表示されるようにしています。CarPlaySceneDelegate.Swiftclass CarPlaySceneDelegate: UIResponder { var interfaceController: CPInterfaceController? var window: CPWindow? } extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate { func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController, to window: CPWindow) { self.interfaceController = interfaceController self.window = window let mapTemplate = CPMapTemplate() interfaceController.setRootTemplate(mapTemplate, animated: true) } }CPTemplate とは
ViewController
を起点として画面を構築していく iOS や Mac アプリと違い、CarPlay 対応には基本的にViewController
は出てきません。
代わりにCPTemplateApplicationSceneDelegate
のデリゲートメソッドで渡ってくるCPInterfaceController
にCPTemplate
を継承したクラスをセットして画面表示を行います。
CPTemplate
系クラスそのものには画面遷移を行うメソッドはないです。したがって、画面遷移を行う場合は
templateApplicationScene
で渡ってくるCPInterfaceController
を持ち回して実装することになります。地図を表示する
さて、起動時にナビゲーション用のテンプレートが表示できるようになった、と言ってもテンプレートに何もセットしていないため
起動しても以下の様なただ黒い画面が表示されるだけです。
なので簡単に地図を表示してみます。
前項で CarPlay 対応には基本的にViewController
は出てきません、と書いたのですが例外があります。
ナビーゲーションアプリの開発に使われるCPMapTemplate
は地図表示部分に関してのみViewController
を使うことになります。
具体的にはCPWindow
のrootViewController
プロパティに地図表示用のViewController
をセットすると
地図が表示されるようになります。
(MapViewController
自体のコードは非常に単純なものなので割愛)CarPlaySceneDelegate.Swiftfunc templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController, to window: CPWindow) { self.interfaceController = interfaceController self.window = window let mapTemplate = CPMapTemplate() interfaceController.setRootTemplate(mapTemplate, animated: true) { _,_ in // MapViewController は内部的に MKMapView を保持した ViewController window.rootViewController = MapViewController() } }すると以下のように Apple の地図が表示されるようになります。
よくみると上に黒いバーがあるのがわかると思うのですが、ここにボタンを置いて行って様々なインタラクションを行えるようにしていくことになります。
地図のパンニングやルート表示についてをここで説明すると長くなりすぎるため、興味があるようでしたら
参考文献のGuideとサンプルコードを参照することをお勧めします実装する上での留意点
以上が CarPlay のナビゲーションアプリで地図を表示するまでの実装になります。
様々なテンプレートが用意されているため実装量がすくなく結構簡単に作れることがわかったと思います。
(それまでの Apple とのやりとりの方がコストが高い)ですが、実装していく上で何点か留意すべき点があったため記述します。
タップ範囲が決められてる
CPMapTemplate
はほかのテンプレートと違いViewController
を表示できるのでデザインの自由度は高いように思えます。
(実際、地図以外も表示させることは可能です)
ですが、実はViewController
にタップイベントを伝搬させることはできません。
CPMapTemplate
におかれたボタン経由でしかイベントを伝搬させることしかできず、地図を直接スワイプさせたりすることはできません。
そしてCPMapTemplate
のみならず他のテンプレートもボタンの表示数、領域等に制限があります。
結果的にそれらの条件を考慮する必要があり、自由度の高いデザインができず、どのアプリも UI は似たものになります。カスタムテンプレートを作れない
CPMapTemplate
の基底クラスであるCPTemplate
は他のテンプレートクラスでも使われており画面遷移機能を持たないので
UIView
に近い印象を受けるのですが開発者側のほうで継承させてカスタムテンプレートを作る、ということはできません。Swift の言語仕様上、継承自体はできるのですが
CPInterfaceController
を使って画面に表示させようとすると
以下のエラーを吐いてクラッシュします*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Unsupported object <View.Test: 0x60000047e920> <identifier: F6B164D2-E640-4644-8C06-AAFB57AD7192, userInfo: (null), tabTitle: (null), tabImage: (null), showsTabBadge: 0> passed to setRootTemplate:animated:completion:. Allowed classes: {( CPContactTemplate, CPPointOfInterestTemplate, CPGridTemplate, CPMapTemplate, CPTabBarTemplate, CPInformationTemplate, CPListTemplate, CPNowPlayingTemplate, CPSearchTemplate )}'標準の組み込まれたテンプレートでなければいけない、ということらしいです。
カテゴリによっては使えないテンプレートがある
Apple に申請を出した際、CarPlay 向けにどのような機能を提供するかカテゴリを決める必要があることは申請の項目で説明しましたが、
その選んだカテゴリによって表示できる UI 、つまりCPTemplate
系のクラスが決まります。
対応表は以下のようになっていますこの表をみるとナビゲーションアプリは表示できる UI が多いのですが Now Playing 用と Point of Interest 用のテンプレートである
CPNowPlayingTemplate
とCPPointOfInterestTemplate
が表示できないことになっています。
実際、表示しようとするとTerminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Unsupported objectというエラーを吐いて落ちます。
テンプレートによっては画面遷移の仕方に制限がある(し、公式ドキュメントにずれがある)
基本的に
CPInterfaceController
のpresentTemplate
及びpushTemplate
というメソッドを使って画面遷移をさせていくのですが
presentTemplate
に関してはCarPlay can only present one modal template at a time. templateToPresent must be one of CPActionSheetTemplate, CPAlertTemplate, or CPVoiceControlTemplate.
と制限があり
pushTemplate
に関してはuse pushTemplate with a supported CPTemplate class such as CPGridTemplate, CPListTemplate, CPSearchTemplate, or
CPVoiceControlTemplate.とあり指定されたクラスしか使えないような記述がされています。
が、実はCPMapTemplate
をpushTemplate
を使って表示させることができたりします。
また、All apps are limited to pushing up to 5 templates in depth, including the root template. Quick food ordering
apps are limited to 2 templates in depth.とあり、
pushTemplate
を使って画面遷移したとしても5階層以上いけないようになっている様です。(未検証)CarPlay 対応アプリを呼び出すには
カーナビ上で操作を完結させるために CarPlay 対応アプリから URL スキーム等つかって他の CarPlay アプリを呼び出したい時があると思います(ex. Calendar, music, etc...)
通常の iOS アプリではUIApplication.shared.open(url:options:complicationHandler)
を使ってたと思うのですが、
CarPlay 上でそれを使うと CarPlay 側ではなにも起きずカーナビに繋いでる iPhone 側の方で別アプリが呼び出されます。CarPlay 側でアプリ呼び出しをしたい場合は
CPTemplateApplicationSceneDelegate
のデリゲートメソッドで渡されるtemplateApplicationScene
のopen(url:options:complicationHandler)
の方をつかう必要があります。CarPlaySceneDelegate.swiftfunc templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController, to window: CPWindow) { // こちらを使うと iPhone 側でアプリが起動する UIApplication.shared.open(URL(string: "なんらかの URL スキーマ")!, options: nil, complicationHandler: nil)] // こちらを使うと CarPlay 側のアプリが起動する templateApplicationScene.open(URL(string: "なんらかの URL スキーマ")!, options: nil, complicationHandler: nil) }個人的には iPhone 側の方でも
UIApplication.shared.open
を使うのは基本的にやめてUIScene.open
を使うべきな気はしています。まとめ
以上、CarPlay 対応のナビゲーションアプリの作り方と注意点を紹介してみました。
読んでいただいてわかると思うのですがカーナビという安全に直結するアプリケーションの UI であるため
非常に縛りが多く、自由なレイアウトというのはできない設計になります。とはいえ、主観ではありますが従来の広く販売されているカーナビと比べるとやはりだいぶ使いやすいです。
また、ナビゲーションアプリに関しては ViewController を使って地図を表示してるので
その気になれば動画や音楽を流せる気がしています(未検証)。Apple の審査に落とされそうな気がしてますが審査に出さず個人でやる分には問題ないはずなので
どこかのタイミングで実験して記事にしたいなと考えています。参考文献
- CarPlay App Programing Guide
- 2020年7月更新。CarPlay の概念については一番わかりやすい
- CarPlay Navigation App Programing Guid
- 2018年更新、説明のベースが iOS12 になっておりちょっと古い
- Integrating CarPlay with Your Navigation App
- ナビゲーションアプリの作り方のサンプルコード
- https://qiita.com/koogawa/items/71710027a4ee7af267bc
- koogawa さんの CarPlay の記事
- https://koogawa.hateblo.jp/entry/2019/01/15/003501
- koogawa さんの CarPlay でAudioアプリを作ってみた際の記事
- 投稿日:2020-12-18T15:00:47+09:00
SwiftUIで作るプログレス
はじめに
この記事はDiverse Advent Calendar 2020の18日目の記事です。
業務でいくつかプログレスを作ったので紹介します。
同じようなデザインのプログレスを作る際はぜひご活用ください!GitHubにこの記事のサンプルを上げてます。
↓実際に動きを見たい方はこちら
https://github.com/Masataka-n/SwiftUIProgressRing
struct RingProgressView: View { var value: CGFloat var lineWidth: CGFloat = 6.0 var outerRingColor: Color = Color.black.opacity(0.08) var innerRingColor: Color = Color.orange var body: some View { ZStack { Circle() .stroke(lineWidth: self.lineWidth) .foregroundColor(self.outerRingColor) Circle() .trim(from: 0.0, to: CGFloat(min(self.value, 1.0))) .stroke( style: StrokeStyle( lineWidth: self.lineWidth, lineCap: .square, // プログレスの角を丸くしたい場合は.round lineJoin: .round ) ) .foregroundColor(self.innerRingColor) .rotationEffect(.degrees(-90.0)) } .padding(.all, self.lineWidth / 2) } }@State var value: CGFloat = 0.0 var body: some View { RingProgressView(value: value) .frame(width: 150, height: 150) .onAppear { withAnimation(.linear(duration: 5)) { self.value = 1.0 } } }Pie
Shapeを継承してPieShapeというものを作っています。
Shapeを自作した場合、アニメーションが適用されないのでanimatableData
をoverrideする必要があります。struct PieProgressView: View { var value: CGFloat var body: some View { ZStack { Circle() .fill(Color.black.opacity(0.08)) PieShape(progress: value) .fill(Color.orange) .rotationEffect(.degrees(-90)) } } } struct PieShape: Shape { var value: CGFloat var animatableData: CGFloat { get { value } set { value = newValue } } func path(in rect: CGRect) -> Path { Path { path in let center = CGPoint(x: rect.midX, y: rect.midY) path.move(to: center) path.addArc( center: center, radius: rect.width / 2, startAngle: .degrees(0), endAngle: .degrees(Double(360 * value)), clockwise: false ) path.closeSubpath() } } }@State var value: CGFloat = 0.0 var body: some View { PieProgressView(value: value) .frame(width: 150, height: 150) .onAppear { withAnimation(.linear(duration: 5)) { self.value = 1.0 } } }Square
Rectangleを2つ重ねて上のRectangleのwidthをvalueに応じて変えることで実現してます。
Squareと名付けてますがcornerRadiusを指定して角丸にすることもできます。struct SquareProgressView: View { var value: CGFloat var baseColor: Color = Color.black.opacity(0.08) var progressColor: Color = Color.orange var body: some View { GeometryReader { geometry in VStack(alignment: .trailing) { ZStack(alignment: .leading) { Rectangle() .fill(self.baseColor) Rectangle() .fill(Color.progressColor) .frame(minWidth: 0, idealWidth:self.getProgressBarWidth(geometry: geometry), maxWidth: self.getProgressBarWidth(geometry: geometry)) } } } } func getProgressBarWidth(geometry:GeometryProxy) -> CGFloat { let frame = geometry.frame(in: .global) return frame.size.width * value } }@State var value: CGFloat = 0.0 var body: some View { SquareProgressView(value: value) .frame(height: 20) //.cornerRadius(10) 角丸も可 .onAppear { withAnimation(.linear(duration: 5)) { self.value = 1.0 } } }さいごに
今回は作成したプログレスを3つ紹介しました。
基本的にコピペで使えると思いますのでぜひご活用ください。
- 投稿日:2020-12-18T13:45:36+09:00
[Swift5]'PKHUD'を用いた待機画面の実装方法
PKHUDとは
PKHUDとはHUD(Head-Up Display)を表示するためのライブラリです。
HUDを使用して、何かしらの処理が実行中であることや、完了したことをユーザーに伝えることができる。PKHUDはCocoaPods、Carthageのいずれかを使用してインストールできる。
この記事ではCocoaPodsを用いて紹介します。ライブラリのインストール
//Podfileに以下のpodを記述して'pod install'をターミナルで実行 pod 'PKHUD' //PKHUDを使いたいファイルにimport import PKHUDコードの紹介
================================================================== ①基本的な使い方 //ローディングの表示 HUD.show(.progress) //ローディングの非表示 HUD.hide(animation: true) ================================================================== ================================================================== ②成功/失敗の表示 //成功を表示 HUD.flash(.success) //失敗の表示 HUD.flash(.error) ================================================================== ================================================================== ③ローディング・成功・失敗の表示(テキストあり) //テキストを指定してローディングの表示 HUD.show(.labelProgress(title: "ここにテキストを記述", subtitle: nil)) //テキストを指定して成功を表示 HUD.flash(.labeledSuccess(title: "Success", subtitle: nil)) //テキストを指定して失敗を表示 HUD.flash(.labeledError(title: "Error", subtitle: nil)) ================================================================== ================================================================== ④テキストのみで表示 HUD.show(.label("ここにテキストを記述")) ==================================================================マスクの無効化
デフォルト設定では、HUDの背景ビューは暗くなり、ユーザーはその間アプリの操作ができません。
しかし、ユーザーが操作できるようにしておきたい状況もあると思います。そういった場合の実装方法も消化します。//HUDの背景ビューを操作できるようにする HUD.allowsInteraction = true //HUDの背景ビューを暗くしない HUD.dimsBackground = false参考にしてください!
- 投稿日:2020-12-18T12:23:14+09:00
モバイルチームの成長とKMM導入に向けて
はじめに
株式会社RevCommでモバイルアプリを担当している木下です。
この記事は 2020年のRevCommアドベントカレンダー 19日目の記事になります。18日目は @zomaphone さんの 【既存の解析システムに対して pytest-mock と pydantic を活用してクイックに総合テストを実装した話】 でした。MiiTel Phone Mobile アプリについて
開発している MiiTel Phone Mobile アプリの主な機能はVoIPで、RevComm のメインプロダクトである MiiTel のオプションとしてご利用いただけます。当初は Cordova で開発されたβ版アプリが無料で提供されていましたが、アプリを起動していないと着信が受けられないなどの不都合があり、これらのユーザー体験を改善するためにネイティブ(Swift/Kotlin)で開発し直したものが現行のアプリとなります。
モバイルチームについて
モバイルアプリの開発を担うモバルチームの変遷を簡単に紹介します。
1. 一人チーム時代
約2年前にRevCommにジョインしてから、iOSとAndroidの両アプリを開発・リリースし、サポート対応や機能改善、OSアップデート対応などをほぼ一人でやってきました。アプリの実装だけでなく、モバイルアプリのための 通信サーバー の設定やサーバーアプリの改修なども対応してきました。
2. 新メンバー参入
今年の10月ついにモバイルチームに、新しいメンバーが2名加わりました。ともにiOSアプリとAndroidアプリの開発経験があり、どちらも安心して任せられるメンバーです。
私としては フルリモート・フルフレックスタイム制を導入しているRevComm で社員を迎え入れるのが初めてでした。組織として急成長してきたことや、リモートワークを推奨していることにより、コミュニケーションの不足や取りづらさの課題を以前から感じていたため、この点を特に留意し、新しいメンバーが発言・活動しやすい環境にしてプロダクトの成長に貢献できるようなチーム作りを目指しました。具体的な内容については、また機会があれば紹介したいと思います。3. そしてさらなる成長に向けて
新たなメンバーが加わってチームとして機能するようになり、開発のスピードも上がってきました。これまでのワンオペでは機能を追加するとしても、iOSアプリの実装とリリースをしてから、Androidも同様の対応をするというフローとなり、リリースのタイミングがズレることがほとんどでしたが、メンバーが増えたことで両アプリ同時に新機能をリリースするといったことも可能になってきました。
そして、これからさらにスピードを上げるために導入を検討しているのが、マルチプラットフォーム開発です。
一つの機能を追加するのに、SwiftとKotlinで同様のロジックを実装しなくてはいけない、というのは非効率であると感じており、これを解消するためにもマルチプラットフォーム開発を導入したいと考えています。
React Native、Flutter、KMM(Kotlin Multiplatform Mobile) などありますが、既存のコードを生かしつつ、徐々に共通化が進められそう、かつ言語的に従来のモバイルエンジニアでもスムーズに開発できそうな、KMM が有力であると考えています。私自身 前職 ではAndroidアプリをメインでやっていたこともあり、KotlinでiOSアプリも開発できたら嬉しいなと考えていたところで、 Kotlin Multiplatform Mobile がアルファ段階に移行 というニュースを目にしたのをきっかけに、導入意欲が高まりました。KMM(Kotlin Multiplatform Mobile) 入門
以前から Kotlin/Native という形でマルチプラットフォーム向けの開発環境は提供されていましたが、KMM ではiOSとAndroidのモバイルアプリに特化し Android Studio などの IDEに統合可能な環境となっており、より簡単に扱えるようになったと認識しています。
個人的にFlutterは少し触っている(Widgetの充実具合や高速な Hot reload は素晴らしいですよね)のですが、KMM は未経験でしたのでこの機会(Advent Calendar)に少し触ってみようと思い、KMM Shared Module
を作成して、AndroidアプリとiOSアプリのそれぞれでインポートして動作させる、ことをやってみようと思います。1. 開発環境
公式サイト にも記載されていますが、次のような環境が必要となります。
- Android Studio 4.1 以降
- Xcode 11.3 以降
- (AS) Kotlin plugin 1.4.20 以降
- (AS) Kotlin Multiplatform Mobile plugin
- JDK
2. Androidプロジェクト作成
KotlinNativeAndroid
というプロジェクトを作成しました。
3. KMM Shared Module 作成
KotlinNativeAndroid
を開いた状態でメニューのFile
>New
>New Module...
を選択しKMM Shared Module
を選択してからNext
をクリックします。
次の画面で
Generate packFoxXcode Gradle task
をチェックしてからFinish
をクリックします。(他はデフォルトのままとしました)
以下のような内容となっており、OSのバージョンを含む文字列を返すメソッドがサンプルとして作成されていることが分かります。
commonMain/.../Greeting.ktpackage com.example.kmmsharedmodule class Greeting { fun greeting(): String { return "Hello, ${Platform().platform}!" } }commonMain/.../Platform.ktpackage com.example.kmmsharedmodule expect class Platform() { val platform: String }androidMain/.../Platform.ktpackage com.example.kmmsharedmodule actual class Platform actual constructor() { actual val platform: String = "Android ${android.os.Build.VERSION.SDK_INT}" }iosMain/.../Platform.ktpackage com.example.kmmsharedmodule import platform.UIKit.UIDevice actual class Platform actual constructor() { actual val platform: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion }
KotlinNativeAndroid
プロジェクト内のファイルも自動で変更されます。settings.gradleinclude ':kmmsharedmodule' <- 追加gradle.propertieskotlin.mpp.enableGranularSourceSetsMetadata=true <- 追加 kotlin.native.enableDependencyPropagation=false <- 追加4. Androidプロジェクト修正
app/build.gradledependencies { implementation project(':kmmsharedmodule') <- 追加 }
KMM Shared Module
から文字列を取得して、画面に表示する処理を実装します。MainActivity.ktclass MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = ActivityMainBinding.inflate(layoutInflater) binding.textView.text = Greeting().greeting() setContentView(binding.root) } }ここで実行してみると次のようなエラーが発生しました。
Manifest merger failed : uses-sdk:minSdkVersion 23 cannot be smaller than version 24 declared in library [:kmmsharedmodule] /Users/tkinoshita/kotlin-native-example/KotlinNativeAndroid/kmmsharedmodule/build/intermediates/library_manifest/debug/AndroidManifest.xml as the library might be using APIs not available in 23 Suggestion: use a compatible library with a minSdk of at most 23, or increase this project's minSdk version to at least 24, or use tools:overrideLibrary="com.example.kmmsharedmodule" to force usage (may lead to runtime failures)Moduleの
minSdkVersion
がアプリよりも高いことが原因のようですので、Module のminSdkVersion
をデフォルト値の24
から、アプリの設定と同じ23
に変更します。また compileSdkVersion と targetSdkVersion もアプリに合わせました。build.gradle.ktsandroid { compileSdkVersion(30) defaultConfig { minSdkVersion(23) targetSdkVersion(30) }5. Androidアプリ実行結果
正常に実行されると次のような表示となります。
6. Framework 出力
続いてiOSアプリでもModuleを利用していきますが、iOSアプリ(Xcode)で利用するためには、Framework 形式でライブラリを出力する必要があります。
build.gradle.kts
の下の方に Framework を出力するためのタスクが記述されているので、これを利用します。
Terminal で次のコマンドを実行します。./gradlew :kmmsharedmodule:build成功すると、
kmmsharedmodule/build/xcode-frameworks
内にkmmsharedmodule.framework
フォルダが作成され Framework が出力されます。7. Xcodeプロジェクト作成
次に iOSアプリのプロジェクトを作成していきます。
XcodeでKotlinNativeiOS
という名前のApp
プロジェクトを新規作成します。
8. Xcodeプロジェクト修正
Build Settings
のFramework Search Paths
に Framework のパスを追加します。ここでは$(SRCROOT)/../KotlinNativeAndroid/kmmsharedmodule/build/xcode-frameworks
を設定しています。
アプリに Framework を組み込むための設定を追加します。
KMM Shared Module
から文字列を取得して、画面に表示する処理を実装します。ViewController.swiftimport UIKit import kmmsharedmodule class ViewController: UIViewController { @IBOutlet weak var textLabel: UILabel! override func viewDidLoad() { super.viewDidLoad() textLabel.text = Greeting().greeting() } }9. iOSアプリ実行結果
正常に実行されると次のような表示となります。
10. ソースコード一式
こちらのリポジトリ で公開しています。
おわりに
KMM Shared Moduleの作成から AndroidアプリとiOSアプリで利用する流れが把握でき、おおよその感触がつかめました。どこまでロジックが共通化できるかはまだ未知ですが、期待していたとおり簡単にモジュールの作成と利用ができましたので、これから積極的に採用していきたいと思います。
明日はモバイルバックエンド担当の @rhoboro さんです!!
実は @rhoboro さんもモバイルチームの一員としてバックエンドの開発を担当されています!参考
- 投稿日:2020-12-18T11:09:43+09:00
iOSでmethod swizzlingして、共通の処理を実行する
はじめに
- iOSで共通の処理を実装するときにいろいろなパターンを考えてみましたのでまとめます。今回は
method swizzling
を中心に考えてみます。環境
Xcode 12.3 Build version 12C33 Apple Swift version 5.3.2 (swiftlang-1200.0.45 clang-1200.0.32.28) Target: x86_64-apple-darwin19.6.0method swizzlingとは
- method swizzlingはすでに存在するメソッドの処理を入れ替える機能です
- Objective-C の全てのクラスに対して使用できる。
- 共通の処理を継承せずに実装できる
クラスでの継承との違い
- クラスでの継承とmethod swizzlingでの実装の違いについてまとめました。
⭕️ ❌ method swizzling 入れ替えたクラスに処理が反映される 影響範囲が見えづらいので不具合が起きた時に対処しずらい クラスの継承 継承したクラスのみで処理が実行されるのでコントロールしやすい。 実装が必要なクラスをすべて書き換える必要があるので
- 実装方法には一長一短あることがわかります。使い所よく考える必要があります。
活用例
kickstater
- ViewModelのbindingやstyleを反映させる処理などに活用されています
- https://github.com/kickstarter/ios-oss/blob/master/Library/UIViewController-Preparation.swift
画面のトラッキング
viewWillAppear
の実装を入れ替えて、Firebaseなどを使えば、画面表示のイベントを送る処理が楽に実装できますimport ObjectiveC import UIKit private func swizzle(_ vc: UIViewController.Type) { [ (#selector(vc.viewWillAppear(_:)), #selector(vc.hoge_viewWillAppear(_:))) ] .forEach { original, swizzled in guard let originalMethod = class_getInstanceMethod(vc, original), let swizzledMethod = class_getInstanceMethod(vc, swizzled) else { return } let didAddViewDidLoadMethod = class_addMethod( vc, original, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod) ) if didAddViewDidLoadMethod { class_replaceMethod( vc, swizzled, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod) ) } else { method_exchangeImplementations(originalMethod, swizzledMethod) } } } private var hasSwizzled = false extension UIViewController { public final class func doBadSwizzleStuff() { guard !hasSwizzled else { return } hasSwizzled = true swizzle(self) } @objc internal func hoge_viewWillAppear(_ animated: Bool) { self.hoge_viewWillAppear(animated) let instanceType = type(of: self) let name = String(reflecting: instanceType) print(name) } }まとめ
method swizzling
は便利ではあるが使い方を見極めて、使うことが必要参考リンク
- 投稿日:2020-12-18T11:09:43+09:00
iOSでmethod swizzling使って、共通の処理を実装する
はじめに
- iOSで共通の処理を実装するときにいろいろなパターンを考えてみましたのでまとめます。今回は
method swizzling
を中心に考えてみます。環境
Xcode 12.3 Build version 12C33 Apple Swift version 5.3.2 (swiftlang-1200.0.45 clang-1200.0.32.28) Target: x86_64-apple-darwin19.6.0method swizzlingとは
- method swizzlingはすでに存在するメソッドの処理を入れ替える機能です
- Objective-C の全てのクラスに対して使用できる。
- 共通の処理を継承せずに実装できる
クラスでの継承との違い
- クラスでの継承とmethod swizzlingでの実装の違いについてまとめました。
⭕️ ❌ method swizzling 入れ替えたクラスに処理が反映される 影響範囲が見えづらいので不具合が起きた時に対処しずらい クラスの継承 継承したクラスのみで処理が実行されるのでコントロールしやすい。 実装が必要なクラスをすべて書き換える必要があるので
- 実装方法には一長一短あることがわかります。使い所よく考える必要があります。
活用例
kickstater
- ViewModelのbindingやstyleを反映させる処理などに活用されています
- https://github.com/kickstarter/ios-oss/blob/master/Library/UIViewController-Preparation.swift
画面のトラッキング
viewWillAppear
の実装を入れ替えて、Firebaseなどを使えば、画面表示のイベントを送る処理が楽に実装できますimport ObjectiveC import UIKit private func swizzle(_ vc: UIViewController.Type) { [ (#selector(vc.viewWillAppear(_:)), #selector(vc.hoge_viewWillAppear(_:))) ] .forEach { original, swizzled in guard let originalMethod = class_getInstanceMethod(vc, original), let swizzledMethod = class_getInstanceMethod(vc, swizzled) else { return } let didAddViewDidLoadMethod = class_addMethod( vc, original, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod) ) if didAddViewDidLoadMethod { class_replaceMethod( vc, swizzled, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod) ) } else { method_exchangeImplementations(originalMethod, swizzledMethod) } } } private var hasSwizzled = false extension UIViewController { public final class func doBadSwizzleStuff() { guard !hasSwizzled else { return } hasSwizzled = true swizzle(self) } @objc internal func hoge_viewWillAppear(_ animated: Bool) { self.hoge_viewWillAppear(animated) let instanceType = type(of: self) let name = String(reflecting: instanceType) print(name) } }まとめ
method swizzling
は便利ではあるが使い方を見極めて、使うことが必要参考リンク
- 投稿日:2020-12-18T09:41:41+09:00
中身をくり抜いた図形をMapKitの地図に描画する~MKPolygonを利用~
はじめに
MapKitでは地図上の好きな位置に図形を描画することができます。
これにより、特定のエリアに色をつけて目立たせたりすることができます。
今回は、エリアの一部をくり抜いた図形を表示したかったのでその方法を調べました。完成図
環境
Xcode12.2
iPhone12シミュレータにて動作確認実装
今回は埼玉県あたりを覆う大きな矩形と、その中に包含される大宮あたりの三角形の小さな図形を用意します。
まず、図形を緯度経度で指定しMKPolygonを作成します。
矩形のほうが大きく、埼玉県全体を覆うもので、三角形はその中に包含されます。最後に、作成したMKPolygonをMKMapViewに追加しています。
let mapView = MKMapView() let omiyaArea = [ CLLocationCoordinate2D(latitude: 35.9243073, longitude: 139.4899383), CLLocationCoordinate2D(latitude: 35.9061086, longitude: 139.6257497), CLLocationCoordinate2D(latitude: 35.9800549, longitude: 139.5930384) ] let smallPolygon = MKPolygon(coordinates: omiyaArea, count: omiyaArea.count) let saitamaArea = [ CLLocationCoordinate2D(latitude: 35.7926364, longitude: 139.9048961), CLLocationCoordinate2D(latitude: 36.2841001, longitude: 139.8764339), CLLocationCoordinate2D(latitude: 36.2977199, longitude: 138.6847137), CLLocationCoordinate2D(latitude: 35.8108062, longitude: 138.6762004) ] let saitamaAreaPolygon = MKPolygon(coordinates: saitamaArea, count: saitamaArea.count, interiorPolygons: [smallPolygon]) // 地図に追加 mapView.addOverlay(saitamaAreaPolygon)ポイントはsaitamaAreaPolygonのMKPolygonのinit処理です。interiorPolygonsにsmallPolygonを配列で指定しています。
くり抜きたい図形をinteriorPolygonsに指定することで三角形部分のみ描画が行われないようになりました。
init(coordinates:count:interiorPolygons:)
色の指定
addOverlayのみでは描画は行われないのでデリゲートメソッドを実装する必要があります。
// 別の箇所にて設定しておく mapView.delegate = self func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { if overlay is MKPolygon { let renderer = MKPolygonRenderer(overlay: overlay) renderer.fillColor = .red renderer.alpha = 0.5 return renderer } return MKOverlayRenderer(overlay: overlay) }発展
くり抜いた図形の中の一部は色を描画したい場合はどうすればいいでしょうか。試してみました。
結果
実装
// くり抜いた三角形に包含されるエリア let area = [ CLLocationCoordinate2D(latitude: 35.9543861, longitude: 139.5727745), CLLocationCoordinate2D(latitude: 35.9562351, longitude: 139.582018), CLLocationCoordinate2D(latitude: 35.9492669, longitude: 139.5809517) ] let areaPolygon = MKPolygon(coordinates: area, count: area.count) let omiyaArea = [ CLLocationCoordinate2D(latitude: 35.9243073, longitude: 139.4899383), CLLocationCoordinate2D(latitude: 35.9061086, longitude: 139.6257497), CLLocationCoordinate2D(latitude: 35.9800549, longitude: 139.5930384) ] let smallPolygon = MKPolygon(coordinates: omiyaArea, count: omiyaArea.count) let saitamaArea = [ CLLocationCoordinate2D(latitude: 35.7926364, longitude: 139.9048961), CLLocationCoordinate2D(latitude: 36.2841001, longitude: 139.8764339), CLLocationCoordinate2D(latitude: 36.2977199, longitude: 138.6847137), CLLocationCoordinate2D(latitude: 35.8108062, longitude: 138.6762004) ] // interiorPolygonsにareaPolygonも追加 let saitamaAreaPolygon = MKPolygon(coordinates: saitamaArea, count: saitamaArea.count, interiorPolygons: [smallPolygon, areaPolygon]) mapView.addOverlay(saitamaAreaPolygon)interiorPolygonsに色を描画したい領域を追加することで実現できました。
- 投稿日:2020-12-18T01:13:01+09:00
【iOS】CIFilterを駆使して、ネガフィルムの写真をネガポジ変換する
まだまだ続いているフィルムカメラブーム。デジタルカメラと違い、現像後のデータ化費用が嵩んだり、撮影後のネガフィルムも整理が間に合わず溜まっていくことが多かったり...
専用の機械なども多くありますが、お手軽にiPhoneでなんとか...と考える人は多いはず。実際AppStoreにはいくつかのフィルムスキャナーアプリがリリースされています。
有名どころだとKodak Mobile Film Scannerなどがあります。ただ、これらのアプリってどのようにiOS上でネガポジ変換を実現してるのかと聞かれると、開発を行っている人が少なく実現方法が分かりません。
なので、今回は同様のネガポジ変換を実現してみたいと思います。陰画から陽画を取得する
ネガフィルムは、撮影時の被写体の色が反転して画像が作られます。
白い被写体であれば、ネガフィルム上には真っ黒の被写体となります。
これをネガティブ画像と呼びます。
ネガティブ画像をプリント、データ化するときに色の再反転を行い、撮影時の被写体の色を復元します。
ここで得られる画像をポジティブ画像と呼びます。昔は、データ化などなくフィルムから印画紙にプリントしていました。印画紙にもネガフィルム同様色を反転する感光剤が塗布されているので、ネガフィルムに写っているコマを引き伸ばし機などを用いて印画紙に投影し、印画紙にネガティブ画像を感光させることで色を再反転し、撮影時の被写体の色を復元していました。
デジタル化では、ネガティブ画像を画像処理で色の再反転を行うことで撮影時の被写体の色を復元するということを行っているだけになります。シンプルに考えると、色の再反転を行うことでネガフィルムからポジティブ画像を取得することができるようになるということです。
トーンカーブを反転させる
ネガティブ状態の画像からポジティブ画像を取得するには、色の再反転を行う必要ですが、トーンカーブのを上下反転させることで実現することが可能です。
Swiftでは、CIFilterに用意されたToneCurveFilterを利用します。
CIFilterとは?
CoreImageFrameworkで提供される画像処理フィルターがCIFilterです。
CoreImageFrameworkもiOS5から提供されている歴史あるFrameworkで、現在では100種類以上のフィルターが提供されています。
CIFilterを駆使することができるようになると、リッチな画像編集アプリなどを作ることが可能です。CIFilterの種類は、こちらのドキュメントで確認できます。是非試してみてください。
Core Image Filter ReferenceToneCurveFilterを利用する
サンプルコードは下記のようになります。
class ToneReverse { func demo() { guard // AssetsCatalogに「nega」という名称のネガ画像がある想定で読み込む let negaImage = UIImage(named: "nege"), // CIFilterで処理するため、UIImageからCIImageへ変換する let ciNegaImage = CIImage(image: negaImage), // toneCurve反転を行うメソッドを介して反転後の画像を取得する let toneReversedImage = reverse(with: ciNegaImage) else { return } // CIImageからUIImageへ再変換 let posiImage = UIImage(ciImage: toneReversedImage) } /// Tone Reverse /// - Parameter image: base image /// - Returns: filtered image func reverse(with image: CIImage) -> CIImage? { return image.applyingFilter( "CIToneCurve", parameters: [ "inputPoint0": CIVector(x: 0.0, y: 1.0), "inputPoint1": CIVector(x: 0.25, y: 0.75), "inputPoint2": CIVector(x: 0.5, y: 0.5), "inputPoint3": CIVector(x: 0.75, y: 0.25), "inputPoint4": CIVector(x: 1.0, y: 0.0) ]) } }CIFilterにはinputPoint0からinputPoint4までのToneCurveの座標を設定することができます。
何も触らない状態のトーンカーブは、下記の画像のようになります。
ToneCurveで色反転を行うにはToneCurveの上下反転すれば良いので、Point0からPoint4までのY座標を反転させることで実現できます。
上記図のトーンカーブをCIFilterで行うと下記のような実装になります。
/// Tone Reverse /// - Parameter image: base image /// - Returns: filtered image func reverse(with image: CIImage) -> CIImage? { return image.applyingFilter( "CIToneCurve", parameters: [ "inputPoint0": CIVector(x: 0.0, y: 1.0), "inputPoint1": CIVector(x: 0.25, y: 0.75), "inputPoint2": CIVector(x: 0.5, y: 0.5), "inputPoint3": CIVector(x: 0.75, y: 0.25), "inputPoint4": CIVector(x: 1.0, y: 0.0) ]) }これでネガティブ画像からポジティブ画像を取得することが可能です。
ただ、ポジティブ画像になってはいるものの青味が強すぎてしまい撮影時の被写体の色を再現できていません。
被写体の色を再現するにはここからさらに色調補正を行う必要があります。
※Debug用にカメラ入力をトーン反転のみフィルター適用してライブ表示した場合が上記みたいになります。色調補正を行う
一応ポジティブ画像を取得することはできましたが、このままでは見るに耐えられません。
なので色調補正を行うのですが、CoreImageに用意されているToneCurveFilterはRGB値の合成チャンネルでしかToneCurveを弄ることしか出来ません。
被写体の色を再現するにはRed、Green、Blueの各チャンネルごとの色調量を調整してバランスを保つ必要があります。今回で言うと青みが強いので、青の色調量を下げて相対的なバランスを作っていくことが必要です。ただ、前述の通りCIFilterに用意されたToneCurveは合成チャンネルでしか触ることは出来ません。
ではどうするか...
CIKernelを用いてカスタムフィルターを作ります。
現在CIKernelを利用する際は、Metalを利用します。Metalとは
Metalは、2014年に登場したAppleプラットフォーム向けのグラフィックAPIです。
記述言語は、C++11ベースのMetal Shading Languageで記述します。Metal利用時の設定
Metalを利用する際には、Build SettingsのOther Metal Compiler FlagsとOther Metal Linker Flagsに-fcikernelの指定が必要です。
ここで落とし穴ですが、下記のAppleの公式ドキュメントでは、MELLINKER_FLAGSをuser-definedに追加し-cikernelを設定しろという記述がありますがXcode12.3の環境下ではWarningが表示されます。上記の設定通りにすることが求められるので注意が必要です。
Apple - Type Method kernelWithFunctionName:fromMetalLibraryData:error:Metal側の実装
今回行いたいことは、RGBごとのチャンネルをToneCurveで弄り、各チャンネルの結果を合成して1枚の画像として取得することです。
ToneCurveの操作自体は、CIFliterで各チャンネル向けにCIFilterで行い、得られたCIImageからR、G、Bの各要素だけを抽出し、各要素を掛け合わせた1枚の画像を生成することでRGBチャンネルごとにいじれるToneCurve機能を実現します。
実現にあたり、ページを参考にさせていただきました。
HSL color adjustment filter in an iOS 8.0+ app using CoreImage#include <metal_stdlib> using namespace metal; #include <CoreImage/CoreImage.h> extern "C" { namespace coreimage { float4 rgbChannelCompositing(sample_t red, sample_t green, sample_t blue) { return float4(red.r, green.g, blue.b, 1.0); } } }上記の実装は、任意の名前 + .metalの名称のファイルに記述しておきます。
Swift側の実装
先に全体のコードです。
ToneCurvePointModelのXYに入っている値は、デモ用に調整した値です。
微調整することで寒暖色などに調整することが可能です。class RgbCompositing { private let redToneCurveModel = ToneCurvePointModel(zeroX: 0, zeroY: 0, oneX: 0.38, oneY: 0.07, twoX: 0.63, twoY: 0.24, threeX: 0.78, threeY: 0.49, fourX: 1.0, fourY: 1.0) private let greenToneCurveModel = ToneCurvePointModel(zeroX: 0, zeroY: 0, oneX: 0.67, oneY: 0.07, twoX: 0.85, twoY: 0.19, threeX: 0.92, threeY: 0.39, fourX: 1.0, fourY: 0.90) private let blueToneCurveModel = ToneCurvePointModel(zeroX: 0, zeroY: 0, oneX: 0.69, oneY: 0.06, twoX: 0.90, twoY: 0.20, threeX: 0.96, threeY: 0.44, fourX: 1, fourY: 0.92) func rgbCompositing(with image: CIImage) -> CIImage? { let redImage = image.applyingFilter( "CIToneCurve", parameters: [ "inputPoint0": redToneCurveModel.pointZero, "inputPoint1": redToneCurveModel.pointOne, "inputPoint2": redToneCurveModel.pointTwo, "inputPoint3": redToneCurveModel.pointThree, "inputPoint4": redToneCurveModel.pointFour ]) let greenImage = image.applyingFilter( "CIToneCurve", parameters: [ "inputPoint0": greenToneCurveModel.pointZero, "inputPoint1": greenToneCurveModel.pointOne, "inputPoint2": greenToneCurveModel.pointTwo, "inputPoint3": greenToneCurveModel.pointThree, "inputPoint4": greenToneCurveModel.pointFour ]) let blueImage = image.applyingFilter( "CIToneCurve", parameters: [ "inputPoint0": blueToneCurveModel.pointZero, "inputPoint1": blueToneCurveModel.pointOne, "inputPoint2": blueToneCurveModel.pointTwo, "inputPoint3": blueToneCurveModel.pointThree, "inputPoint4": blueToneCurveModel.pointFour ]) guard let url = Bundle.main.url(forResource: "default", withExtension: "metallib"), let data = try? Data(contentsOf: url), let rgbChannelCompositingKernel = try? CIColorKernel(functionName: "rgbChannelCompositing", fromMetalLibraryData: data) else { return nil } let extent = redImage.extent.union(greenImage.extent.union(blueImage.extent)) let arguments = [redImage, greenImage, blueImage] guard let ciImage = rgbChannelCompositingKernel.apply(extent: extent, arguments: arguments) else { return nil } return ciImage } }少しづつ分解してみていきましょう。
func rgbCompositing(with image: CIImage) -> CIImage? { let redImage = image.applyingFilter( "CIToneCurve", parameters: [ "inputPoint0": redToneCurveModel.pointZero, "inputPoint1": redToneCurveModel.pointOne, "inputPoint2": redToneCurveModel.pointTwo, "inputPoint3": redToneCurveModel.pointThree, "inputPoint4": redToneCurveModel.pointFour ]) let greenImage = image.applyingFilter( "CIToneCurve", parameters: [ "inputPoint0": greenToneCurveModel.pointZero, "inputPoint1": greenToneCurveModel.pointOne, "inputPoint2": greenToneCurveModel.pointTwo, "inputPoint3": greenToneCurveModel.pointThree, "inputPoint4": greenToneCurveModel.pointFour ]) let blueImage = image.applyingFilter( "CIToneCurve", parameters: [ "inputPoint0": blueToneCurveModel.pointZero, "inputPoint1": blueToneCurveModel.pointOne, "inputPoint2": blueToneCurveModel.pointTwo, "inputPoint3": blueToneCurveModel.pointThree, "inputPoint4": blueToneCurveModel.pointFour ])上記の部分では、受け取ったCIImageに対してRed、Green、Blueの各チャンネルの弄りたいToneCurveの値を適応しています。
この時点では、redImage
などに入っているCIImageは、合成チャンネルでトーンカーブを弄った画像になるので理想の色調補正はまだ行われていません。guard let url = Bundle.main.url(forResource: "default", withExtension: "metallib"), let data = try? Data(contentsOf: url), let rgbChannelCompositingKernel = try? CIColorKernel(functionName: "rgbChannelCompositing", fromMetalLibraryData: data) else { return nil }上記部分では、CIColorKernelを生成しています。
Metalファイルは、コンパイル後metallib
形式でrootディレクトリ直下に生成されます。特段設定しなければリソース名はdefault
で書き出される模様です。
Bundle.main.url(forResource:, withExtension:)
で、default.metallib
のパスを取得し、Data(contentsOf:)
でData型としてファイルを取得しています。
最後にCIColorKernel(functionName:, fromMetalLibraryData:)
に対してMetalファイルで定義した画像処理メソッドのメソッド名と取得したdefault.metallib
を引数で渡すと、Metal側で実装したrgbChannelCompositing
が利用可能になります。let extent = redImage.extent.union(greenImage.extent.union(blueImage.extent)) let arguments = [redImage, greenImage, blueImage] guard let ciImage = rgbChannelCompositingKernel.apply(extent: extent, arguments: arguments) else { return nil } return ciImage }続いてCIKernelを用いて、新しい画像を取得します。
extent
は画像の出力範囲です。union
を利用して3つの画像を含むサイズを取得します。今回で言うと元画像が同一であるため基本サイズがずれることはないはずです。
arguments
は、CIKernelに対して渡す元画像の配列です。並び順はrgbです。
extent
とarguments
を引数にし、apply(extent:arguments :)
を実行すると、RGB各チャンネルのToneCurveを調整したCIImageを取得することができます。成果物
こんな感じの画像にネガポジ変換して、色調補正をかけた画像を取得することができます。
Framework化しました
一連の処理を整理してFramework化までしてます。
サンプルコードも一緒にしてあるので気になる方は下記からどうぞ。
(Cocoapods、Carthage対応は追って行います。)
NegaDeveloping - Masami Yamate最後に
探せば世界のどこかでやってる人はいてOSSのFrameworkがすでにあるかもしれませんが、自分で仕組みを辿ってみると楽しいですね。
個人的に使うアプリとして開発していますが、冬休みの課題で間に合えばApp Storeでも現像アプリとして公開するかもしれないので期待せずに待っていただけると嬉しいです。
- 投稿日:2020-12-18T00:56:05+09:00
[GraphQL] Apollo-iOS を使ってみて感じたイケてるところ。イケてないところ。
この記事は iOS #2 Advent Calendar 2020 の18日目の記事です。
概要
今業務でApollo-iOSを導入してます。
ネット上のいろんなサイトを参考し実装することができましたが、思い返すとApollo-iOSの使い方について言及されている記事は多いのですが、実際使ってみての感想はあまり見たことがないので、今回はApollo-iOSのイケてると思った所とイケてないなと思った所について、主観で好き勝手書いてみました。これからGraphQL導入しようか迷っているという方に流し目に読んでいただけると幸いです.
捉え方は要件や環境によると思うので、当てはまらない場合もあると思います。
また、私の知識が及んでいないゆえの間違いなど気づいた点があればTwitter等でコメントいただけると嬉しいです。GraphQLとは
簡単に言うとサーバーとクライアントとの通信をする際のプロトコルのようなもので、特定のライブラリやツールを指すものではありません。
詳しくはすでに様々なサイトで紹介されているので、参考になるであろうリンクを記載しておきます。
GraphQL.org
世のフロントエンドエンジニアにApollo Clientを布教したい
Web API初心者と学ぶGraphQLApollo-iOSとは
iOSアプリからGraphQLでサーバーとやり取りするためのライブラリです。
こちらも参考となるリンクを貼っておきます。
Apollo-iOS
Apollo-iOS Docイケてる所
サーバーエンジニアとの認識齟齬がなくなる
これはApollo-iOSというよりもGraphQLについてですが、GraphiQLやPlayGroundなどのツールを導入することで、ブラウザ上でリクエストを簡単に試したり、付属するDoc機能でAPIの仕様を簡単に把握することができます。
初めてのGraphQLという本でも紹介されていた サンプルでPlayGroundを実際に触ることができます。こうしたGraphQLから提供されたツールを利用することで、API仕様についてのレビューをより効率的に行えるようになり、
先にインターフェースの仕様をかっちりと決めて、その後にサーバーとクライアントがそれぞれ実装に入るというスキーマ駆動がしやすい点がイケてました。また、ページネーションや認証などのよくある処理について、実現パターンがGraphQLコミュニティで策定されており、これを参照することで実装方法や仕様の一貫性を保つことができる点もいいなと思いました。
Interceptorによって通信処理がカスタマイズしやすい
こちらはApollo-iOSについてです。
Apollo-iOSでは、通信、パース、キャッシュの操作などの処理をInterceptorとして個別に定義し、それらを配列にして登録することで順番に処理されるようになります。デフォルトではLegacyInterceptorProviderが定義されていて、この中にInterceptorの配列が定義されています。
Interceptorプロトコルに準拠した構造体を独自の
InterceptorProvider
に組み込むことで、通信前後に任意の処理を行うことができます。例えば、リクエストヘッダーにアクセストークンを付与することも簡単に行なえます。
イケてない所
エラーはスキーマで定義されない
GraphQLでは、エラーはHTTPステータス200として以下のようなJSON形式で返ってきます。
message
やlocations
の値はライブラリによって自動的に格納されます。
extensions
にはサーバーエンジニアが任意で設定した情報が格納されます。{ "errors": [ { "message": "Cannot query field \"n\" on type \"Lift\".", "locations": [ { "line": 3, "column": 5 } ], "extensions": { "code": “HogeError” “message”: “HogeMessage” } } ] }エラーの情報は正常系とは違いスキーマ等には明示されません。
また、GraphQLの仕様としてかっちり仕様がきまっているわけではないようです。認証エラーやその他ビジネスロジックに関わる独自のエラーは
extensions
に格納されて返ってくるはずですが
何をどのタイミングで返すのか、どんな構造なのかはサーバーエンジニアと認識を合わせる必要があります。Apollo-iOSで
extensions
内のcode
にアクセスするには以下のようにします。response?.parsedResponse?.errors?[0].extensions?[“code”] == “HogeError”ここでもし、
extensions
内の構造が想定と違ったり、HogeError
をタイポしたりするとエラーハンドリングに失敗してしまうので、個人的な思いとしてはエラーも可能な限りスキーマで管理できたり、型セーフに扱えたら良かったのにと思った次第です。また、このような200系で返ってくるエラーは
Result.failure
ではなくResult.success
に包まれてハンドラーに返ってくる点にも注意が必要です。各Interceptorのエラー型についてApollo-iOSの内部実装を確認する必要がある
Interceptorにそれぞれ独自のエラーが定義されており( 例: MaxRetryInterceptor)、それぞれの内部実装を確認しながら、Errorをそれぞれの具体的な型にキャストして適したハンドリングをする必要があります。
構造さえ知れば特に不便なこともないんですが、普通にライブラリ内のコードリーディングが求められているので割とハードル高めだなと思った次第です。DDD的な思想とGraphQLの特長がマッチしない部分がある
GraphQLの思想として「(画面ごとに)必要な情報だけ取得できる」という特長がありますが、
DDD的な思想を元にアーキテクチャを組んでいる場合は以下の点でGraphQLの思想とバッティングするんじゃないかと思います。
- 画面ごと構造体を作る == 画面駆動になってしまう。
- 自動生成されたコードをそのままViewで使いたくない。本来データ層だけをApollo-iOSに依存させたい。
- 自動生成されたコードのモックのデータが用意が難しくてドメイン層のテストがしづらい。
- クエリのプロパティをフラグメントで共通化するとコードの構造も変わる。。API都合の変更でView側に修正を加えたくない。
ということで結局は、アプリ内部で使いやすいドメインオブジェクトにマッピングすることになります。その時は複数の画面でも使用できるようにオプショナルな値を持つことになると思います。そしてマッピングのためには扱いづらい自動生成されたコードと向き合う必要があります。
また、マッピングのテストをする場合自動生成された構造体のモックを作る必要があります。
モックをつくるには、以下のようなイニシャライザに渡す引数を用意する必要があります。public init(unsafeResultMap: [String: Any?]) { self.resultMap = unsafeResultMap }引数を作る手順は以下です。
自動生成されたコードに以下のプロパティがあるので、
public private(set) var resultMap: [String: Any?]
実行中にデバッグコンソールでpo print(resultMap)
で値を表示する。1.の出力はそのままSwiftのコードとしても機能するはずなので、
[String: Any]?
の変数として定義すればコンパイルが通るはずです。(膨大な量のコードになるので、型を明示しないとコンパイルが通らないかもしれません。)Interceptorの単体テストは難しい
Interceptorの処理は以下のメソッド内部に書くので、テストするためには引数のモックを用意する必要があります。引数は4つとも独自の型で構成されており、正しいモックを作るには内部のコードを知る必要があり、かなりハードルが高いなと思いました。
func interceptAsync<Operation: GraphQLOperation>( chain: RequestChain, request: HTTPRequest<Operation>, response: HTTPResponse<Operation>?, completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void)ちなみに、Assertionについては、RequestChainを継承したモックを作って引数に渡し、
chain.proceedAsync()
やchain.handleErrorAsync()
などの実行をトラップするのがいいのかなと思います。QueryとMutationで実行するメソッドが違う
GraphQLでは特性に応じて主に3種類のオペレーションが存在します。
その内のQueryはサーバーから値を取得するとき、Mutationはサーバーの値を変える時に使用されます。Apollo-iOSではQueryのリクエストは
fetch
、Mutationのリクエストはperform
を使用する必要があるのですが、
fetch
とperform
を単純に両方使用すると同じような処理を2つ実装する必要がでてくるので、共通化するためにちょっと複雑なコードを書く必要がありました。自動生成したコード内で強制アンラップ祭り
これ原因でクラッシュしたことはありません。ただ、できれば強制アンラップはしてほしくないなって思いました。
- 投稿日:2020-12-18T00:07:47+09:00
[SwiftUI] Qiitaのタグみたいに表示されるTextFieldを作る
SwiftUIのTextFieldはカスタマイズ性に乏しいのですが、見かけだけでもそれっぽくしてみました。
実装
struct TestView: View { @State private var text: String = "" var body: some View { ZStack{ TextField("入力", text: $text) .foregroundColor(.clear) HStack(spacing: 0){ let strings = text.split(separator: " ", omittingEmptySubsequences: false) ForEach(strings.indices, id: \.self){i in Text(strings[i]) .background( Color(.sRGB, red: 0.847, green: 0.917, blue: 0.992) .cornerRadius(5) .overlay( RoundedRectangle(cornerRadius: 5) .stroke(Color(.sRGB, red: 0.745, green: 0.866, blue: 0.988)) ) ) .padding(.horizontal, 2) } Spacer() } } } }ポイント
TextField
は制約が大きすぎるので色をclear
にして見えなくし、その上から好きなように表示します。制約
上からテキストを被せている都合上、カーソルが見えなくなります。これを防ぐためには例えばテキストの背景のカラーを透明色にすればいいのですが、色を合わせるのが大変だったので断念しました。