iOS10におけるプッシュ通知

AppleはiOS10でNotificationのAPIに大きな変更を行いました。PushとLocal Notification両方に影響しています。このセッションでは、その変更や既存のアプリ動作を保ちつつ変更を行う際の注意事項、移行中に良くある落とし穴の回避方法、新しい機能で実現するかっこいいUIの例など、ハイレベルな概要を知ることができます。


イントロダクション (0:00)

私の講演を以前聞いたことある方は知っていると思うのですが、テストについてよく話しています。春にベルリンで行われたUIKonfのときに述べたのですが、AppleがXcode8でXCUIに対して行った変更にとてもワクワクしました。それが理由でこれについて話そうと思いました。WWDCをみて本当にすごいと思ったのです。そのあと、APIのDiffをチェックして、XCTest Frameworkについては大して変更がなかったことが分かって、思わず窓の外に飛び出したくなるぐらい、パニックになりました。嘘ではありません。

もう一つ見つけた大きな変更が、私が本当に求めていた変更でした。それが今回話す内容とつながっています。iOS 10のNotificationです。この変更はとてもすごいと思ったし、開発者に役に立つものだと思いました。SpotHeroという我々のアプリでやっていることをどのように変えていけるか、考えただけでもワクワクします。

このようなGifのアニメーションを作るのもとても楽しいです。SpotHeroでやっているのは、駐車場を確保することです。すみません、私たちはみなさんの駐車を邪魔しています。ユーザーは場所と時間を決めて駐車場を予約します。ユーザーはお金をアプリで支払い、私たちはそのお金を受け取ります。手数料を除いて、駐車場のオーナーにお金を渡します。これを毎日、全米中で何度もやります。

Notification (2:12)

ユーザーがこのアプリを使っている間に、私たちが行っている通知がたくさんあります。ユーザーが支払った駐車場を見つけるのを助けるためです。予約に重要な変更があったときは、プッシュ通知を使って、ユーザーに知らせます。お客様自身がWeb上で更新したり、カスタマーサービスに問い合わせてくださったカスタマーヒーローによって更新された情報をアップデートし、サイレントプッシュ通知を行います。ジオフェンス通知を使うことで、駐車場に車が近づいたという情報をユーザーに伝えることができます。スケジュール通知を使うことで、駐車の開始時間や駐車場所を伝えたり、駐車時間の終了を伝えることもできます。ユーザーに「車を動かすか、予約を延長してください」と言うことができるのです。

iOS 9では、古い方法で通知を処理する必要がありました。UILocalNotificationと UIRemoteNotificationがあり、決して交わることはありませんでした。ローカル通知とリモート通知に対して、同じ処理をしています。プッシュ通知とサイレント通知はリモート通知です。サーバーからアプリへ通知が送られます。ジオフェンス通知やスケジュール通知はローカル通知です。設定した通りに通知されます。ユーザーがアプリを開くまで、リモート通知としては何が来るのかわかりませんでした。何か変更があった場合でも、通知を更新できませんでした。リモートで変更するものを送信する場合は、別々の通知を大量に送信する必要があります。ですが、その大量とは、大まかな見積もりであることに気をつけてください。

これはスポーツアプリのようなアプリにとっては特に厄介です。あなたが最後にiPhoneを見たときからどれくらいスコアが変更されたかを知りたいときは、スコアの変更に関する14個の異なる通知を送信します。あなたは、ランが採点され、採点され、採点され、3つのランが採点されるような20種類の通知に対処したくないでしょう。 Appleもそれがちょっとばかげていることに気づきました。もっとより良くするべきです。今年はWWDCで、たくさんのことが変わりました。最大の変化は、AppleがAppDelegateから2つの新しいフレームワークに多くの処理を移したことでした。


UserNotifications.framework
UserNotificationsUI.framework

UserNotifications(UN)とUserNotificationsUIです。iOSやwatchOS、tvOSでもまたがってまとめて処理できるようになりました。iOS側で何が起こったのかご説明します。

通知を扱える新しく素晴らしい方法は、通知は通知であるという事実から始まります。リモートとローカルの区別はありません。UNNotificationオブジェクトです。

通知の拡張によって、リモート通知から画像やビデオなどの追加コンテンツを取得することができます。リモート通知のペイロードはかなり限られているので、実際にはそんなに多くのデータを送信することはできません。通知の拡張があることによって、サーバーからより多くのデータを取得し、表示することができます。時間の制約はありますが、とてもいいものです。

リモート通知は別々の通知を送信するのではなく、ユニークな識別子を持っており、更新できます。サーバーで識別子を設定する必要があるのですが、もう必要なくなった場合に、表示前にキャンセルするか、すでに表示されているものを更新することを容易にします。なので、今までのように野球の得点に関する14個の違う通知ではなく、「最後にiPhoneでスコア見た時から、スコアがどのように変化したか」を一つの通知がお知らせしているのです。

通知のUI/UXを改善することよりも開発者の生活をより良くするための有益な情報もたくさんあります。アプリがフォアグラウンド状態のとき、システムUIを使って通知を表示できるようになりました。アプリを開いている間でも通知を表示するカスタムUIを構築しなければならなかった方は、喜んでいるはずです。これは本当に面倒なことで、ほとんどのアプリではまったく必要ないことなのです。

AppleがUILocalNotificationとUIRemoteNotificationを組み合わせたことによる優れた副作用は、通知をタップしたときに処理するデリゲートメソッドがひとつのみになったことです。ローカル通知とリモート通知のタップを区別するのは意味がありません。Appleはその区別を取り除くことにしたのです。

この変更のもう一ついいところは、UIApplicationDelegateから通知を切り離していることです。開発者は5,000行のAppDelegateになるより、カプセル化してすべての通知処理が移行されることを望んでいます。

面倒な変更はほとんどありません。まず、あなたがExtensionが好きだといいですね。Extensionを利用しなくてもできることはそこそこあるのですが、これを最大限活用するには1つ、いや2つのExtensionを追加する必要があります。ひとつはバックグラウンドフェッチを扱うExtension、もう一つはカスタムUIを更新する処理です。私はまだ、Extensionのシステム自体がよくわかっていないのですが、みなさんは良くおわかりだと思います。

AppDelegateにはまだ役割が残っています。一つは変わっていません。Apple Push Notification Serviceへの登録が成功したか失敗したかの処理です。もう一つはBackground Push Notificationです。これもまだAppDelegateに残っています。これはちょっとした意味のあることで、AppleがUser Notification Frameworkについて、すべてのOSでAPIを統一使用としていることを意味します。AppDelegateに残されたものは、実際にはiOSにのみ関連するものです。試してみるのは面倒ですが。通知に対処しようとしているので、ここにいって、次にここ、ここ、ここ、ここに行って、そしてこれらすべての違うものを処理しなければなりません。これは不愉快です。

これらメソッドはデフォルトではメインスレッドでコールバックされません。かなりの数の処理を、メインスレッドに関与させずに行えるので、これは理にかなっています。面倒なのですが、これはすべて確認する必要があります。これをMain QueueでDispatchするためにラップする必要がありますか?さっきも言いましたが、面倒です。耐えられません。古臭いものと新しくホットなものを同時にサポートするのは本当に不愉快です。

Stupid Swift Tricks (9:18)

今回はSwiftのカンファレンスで、近くにはDavid Lettermanの故郷であるEd Sullivan劇場があるので、このセクションを「Stupid Swift Tricks」※と名付けました。しかし、合理的に考えて、「Sweet Swift Tricks」を呼びましょう。※David Lettermanの「Stupid Human Tricks」という番組になぞらえたもの

プロトコル指向プログラミングが多く含まれていることに驚かされるでしょう。ちょっとこわいのですが、複数のOSをサポートします。これはどうやって行うのでしょう。


protocol VersionSpecificNotificationHandler {
	
	func handleActionWithIdentifier(identified: String?.
									for userInfo: [AnyHashable : Any]?,
									completionHandler: @escaping () -> Swift.Void)

	func hasUserBeenAskedAboutPushNotifications(hasBeenAsked: @escaping (Bool) -> ())

	func requestNotificationPermissionsWithCompletion(permissionsGranted: @escaping (Bool) -> ())

	func arePermissionsGranted(permissionsGranted: @escaping (Bool) -> ())

	func successfullyRegisteredForNotifications(deviceToken: Data)

	func failedToRegisterForNotifications(error: Error)

	func application (_ application: UIApplication,
					didReceiveRemoteNotification userInfo: [AnyHasable : Any],
					fetchCompletionHandler completionHandler: @scaping (UIBackgroundFetchResult) -> Swift. Void)

	func scheduleNotification(for parrot: PartyParrot, delay: TimeInterval)	

}

プロトコルの宣言から始めます。ここに色々ありますが、通知で処理したいことです。できることの一つにデフォルトのメソッドを実装したExtensionを作ることができます。なので、プロトコルはより一つのOSや他のOSに特化し、OSによって処理をわけることになります。

successfullyRegisteredForNotificationsfailedToRegisterForNotificationsのようなメソッドもあります。これらはOSをまたがっても変わりません。デフォルトのプロトコル実装を使用して、ここがサーバーにトークンを送信していたり、ユーザーに「シミュレータを使用しています。プッシュ通知を受け取ることはできません。」とお知らせしたりする場所になります。

これら2つの処理をどのように行ったか見てみましょう。


	func hasUserBeenAskedAboutPushNotifications(hasBeenAsked: @escaping (Bool) -> ())

	func requestNotificationPermissionsWithCompletion(permissionsGranted: @escaping (Bool) -> ())

通知の表示や許可されているか否か、そしてすでに通知の許可を求められているか否かを知る方法です。一度「プッシュ通知を受け取ることを許可しますか?」というメッセージが表示されて、ユーザーが「いいえ」と答えた場合は、再度表示することはできません。それは一つで終わりです。燃えていますね。

実際にシステムダイアログを表示する前に、ユーザーが「はい」と言うように準備するUXパターンがあります。ユーザーがプライムに対してYesと答えていない場合は、システムレベルのものを起動するビューを表示しないので、ユーザーにプッシュ通知について、機会を何度か与えることができます。


@available(iOS 10.0, *)
extension iOS10NotificationHandler: VersionSpecificNotificationHandler {
	
	private func getAuthStatus(status: @escaping (UNAuthorizationStatus) -> ()) {
	UNUserNotificationCenter
		.current()
		.getNotificationSettings {
			settings in
			status(settings.authorizationStatus)
		}
	}

	func hasUserBeenAskedAboutPushNotifications(hasBeenAsked: @escaping (Bool) -> ()) {
		self.getAuthStatus() {
			status in

			DispatchQueue.main.async {
				switch status {
				case .notDetermined: //Not asked yet
					hasBeenAsked(false)
				case .denied, //Asked and denied
				.authorized: //Asked and accepted
					hasBeenAsked(true)
				}
			}
		}
	}

	func requestNotificationPermissionsWithCompletion(permissionsGranted: @escaping (Bool) -> ()) {
		UNUserNotificationCenter.current()
			.requestAuthorization(options: [.alert, .sound]) {
				granted, error in

				DispatchQueue.main.async {
					if granted {
						permissionsGranted(true)
					} else {
						print("Error or nil: \(error?.localizedDescription ?? "nil")")
						permissionsGranted(false)
					}
				}
			}
	}
}

iOS 10においては、これはかなり簡単です。ここでは、新しいNotification APIを使用して認証ステータスを取得しています。認証ステータスには、拒否、許可、未決定の3つがあります。決まっていないということは、まだ聞かれていないということです。本当に、本当に簡単です。完了ハンドラを渡すことができて、すぐにどのステータスがわかり、その完了ハンドラを起動できます。 iOS 9でこれをやるのはちょっと面倒です。


//Silence warnings from the compiler about classes which are deprecated in iOS 10
@available(iOS, deprecated: 10.0)
extension iOS9AndBelowNotificationHandler: VersionSpecificNotificationHandler {
	
	func hasUserBeenAskedAboutPushNotifications(hasBeenAsked: @escaping (Bool) -> ()) {
		//Theoretically you could query UIApplication.shared.isRegisteredForRemoteNotifications
		//but that will return no if the user has been asked and declined notifications.
		//<10, the only reliable way to track this is to store whther this has been asked.
		hasBeenAsked(UserDefaults.standard.bool(forKey: self.notificationPermissionRequestedKey))
	}

	func requestNotificationPermissionWithCompletion(permissionsGranted: @escaping (Bool) -> ()) {
		self.permissionsCompletionWithGranted = permissionsGranted
		let settings = UIUserNotificationSettings(types: [
													.alert,
													.badge,
													.sound,
												]
												categories: nil)
		UIApplication.shared.registerUserNotificationSettings(settings)

		//Track that the user has been asked.
		UserDefaults.standard.set(true, forKey: self.notificationPermissionRequestedKey)
	}

	func arePermissionsGranted(permissionsGranted: @escaping (Bool) -> ()) {
		guard let settings = UIApplication.shared.currentUserNotificationSettings else {
			permissionsGranted(false)
			return
		}
	}

	permissionGranted(self.areAnyNotificationsEnabled(inL settings))
}

設定を行ったあとには、次に registerUserNotificationSettings書くでしょう。その後は、手動で追跡しなければなりません。それ以外にいい方法がないからです。試してみましたが、動きませんでした。

これについて本当に面倒なのは、Permissionブロックを保存して、AppDelegateにコールバックするプロセスを開始しなければなりません。iOS9だからです。次に、そのアプリが古いAppDelegateを呼び出すと、AppDelegateがオブジェクトにコールバックして、「許可はもらっています。これが設定です。」と言います。


//Silence warnings from the compiler about classes which are deprecated in iOS 10
@available(iOS, deprecated: 10.0)
class iOS9AndBelowNotificationHandler {
	
	fileprivate let notificationPermissionRequestedKey = ".com.example.HasUserBeenAskedAboutNotifications"

	var permissionsCompletionWithGranted: (Bool) -> ()?

	func grantedPermissions(with settings: UIUserNotificationSettings) {
		guard let permissionsGranted = self.permissionsCompletionWithGranted else {
			//Nothing to do here.
			return
		}

		permissionsGranted(Self.areAnyNotificationsEngabled(in: settings))
	}

	fileprivate func areAnyNotificationsEnabled(in settings: UIUserNotificationSettings) -> Bool {
		return settings.types.contains(.alert)
			|| settings.types.contains(.sound)
			|| settings.types.contains(.badge)
	}
}

それで、そこで必要のある設定があったかどうかを伝えることができます。あなたはAppDelegateに入って、あなたが設定したプロトコルに従うものを実行時に決定させることができます。ここでは、これをVersionSpecificNotificationHandlerと呼んでいます。


@UIApplicationMain
calss AppDelegate: UIResponder, UIApplicationDelegate {
	
	var window: UIWindow?
	var notificationHandler: VersionSpecificNotificationHandler!

	func application(_ application: UIApplication,
					willFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {
		//Assign the appropriate notification handler as early in the app lifecycle as possible.
		if #available(iOS 10.0, *) {
			self.notificationHandler = iOS10NotificationHandler()
		} else {
			self.notificationHandler = iOS9AndBelowNotificationHandler()
		}

		guard let rootVC = self.window?.rootViewController as? ViewController else {
			assertionFailure("VC not found!")
			return false
		}

		rootVC.notificationHandler = self.notificationHandler

		return true
	}
}

今のOSによって、使いたいものを変えることができます。#availableというのがあって、OSに依存させることができ、それぞれに応じたNotification Handlerを持つことになります。この場合、次のView Controllerも渡しています。これがシングルトンのようにAppDelegateにぶらさがらずに動いています。お願いだからAppDelegateをシングルトンのように使わないでください。

まとめ (15:34)

これをするのに何が必要でしょうか。通知を使うなら、ローカルでもリモートでもUNNotification Frameworkを使うのは必須です。iOS 9以下のAppDelegateのメソッドは非推奨になりました。将来のiOSのバージョンでなくなるでしょう。今はAppDelegateで動き続けていますが、これによりより多くのパワーとより多くの情報がユーザーから得られるのに、移行を延期する意味はありません。

NotificationのExtensionをつくりましょう。NotificationのExtensionはより多くのデータのダウンロードができ、古いプッシュ通知よりも多くのコンテキストをユーザーに提供できます。

NotificationのUI Extensionを作りましょう。繰り返しになりますが、今持っているコンテキストを利用し、独自のUIを使用します。あなたの会社のマーケティングをする人たちのために、ブランドを強化することができます。マーケティング指向ではない方のために、できるだけ早く必要な情報をユーザーに提供していることを確認することができます。

最後になりますが、iOS 9のサポートを打ち切るために、特に販売しているアプリのために、プロダクトチームにプレッシャーをかけたいと思っているでしょう。通知は、ユーザーが本当に必要な情報をすばやく得るための本当に便利な方法です。ユーザーがアプリに戻ってくるのを促すことができる方法がもう一つあります。アプリを使い続けてください。会社のお金を投入し続けてください。そのために、会社はあなたにお金を払い続けてください。

参考資料


Ellen Shapiro

Ellen Shapiro

Ellenは、イリノイ州シカゴにあるVokalのシニアiOSエンジニアです。シカゴで[AndroidListener](http://www.meetup.com/AndroidListener-Chicago/)を主催しています。また、暇な時間に、作詞作曲のアプリケーションであるHumを開発したり、raywenderlich.comでiOSのチュートリアルを書いたりしています。