tofucodes diary

にほんごのほう

iOS 12で劇的に変わるPush通知の全貌

今更ですがWWDC2018のkeynoteを仕事と銘打って業務中に見まくっています。

WWDC 2018 - Videos - Apple Developer

数ある新機能の中でもiOS 12で劇的に変わりかつ影響範囲が大きい機能といえば「Push通知」ではないでしょうか。

今回はそんなiOS 12のPush通知についてまとめてみます。

  1. Provisional Authorization
  2. Grouped Notification
  3. Customize in App

他にもCritical Alertや通知のUI周りのトピックなどありますが今回は割愛します。

Provisional Authorization

今回紹介する3つの新機能の中でも目玉機能かと個人的には思っています。

今までPush通知の送信許可をユーザに要求する方法といえば、アプリを初めて開いた途端にダイアログを表示して要求したり、何かしらPush通知の内容紹介をしてから要求したりといったケースが一般的でした。

この今までの方法では実際にどんな通知が送られてくるか分からないのにユーザが判断を迫られるというある意味無謀な要求をしていました。

ところがiOS 12ではとうとうこの旧態依然とした機能にメスが入りました。

Provisional Authorizationはユーザにダイアログで許可を要求するのではなく、1通目のPush通知を勝手に送信してその時点で今後も通知を受け取りたいかどうか判断してもらうことができるようになる機能です。

f:id:tofucodes:20180906010511p:plain

この方法ならばユーザが実際に通知を見てから判断するためとても理にかなっています。

もしかしたらPush通知の認可率も今までに比べて向上するかもしれませんね。

developer.apple.com

実装方法

UNUserNotificationCenterのrequestAuthorization(options:)のオプションに.provisinalを追加するだけです。

UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound, .provisional])

Grouped Notification

Push通知がグルーピングされて表示されるようになります。

f:id:tofucodes:20180906010509p:plain

Push通知のペイロードに任意のデータとしてthread-idが追加され、グルーピングはこのID単位で行われます。

thread-idを指定しない場合はアプリ単位でグルーピングが実行されます。

ユースケースとしては例えば重要な通知がグルーピングされて隠れないようにするために別のthread-idを指定したり、通知の種類ごと(メッセージの受信といいね等)にthread-idを分けたりでしょうか。

ただ注意点として、たとえthread-idを指定して通知を送信したとしても、ユーザのiOS設定で「通知のグループ化:App別」になっている場合はアプリ単位でグルーピングされてしまいます。デフォルト設定は「自動」になっているみたいです。

developer.apple.com

実装方法

前述した通り、Push通知のペイロードthread-idパラメータを指定することで実現できます。

Customize in App

iOSの設定アプリもしくは実際の通知からアプリ内の通知設定画面を開けるようになります。

f:id:tofucodes:20180906005251p:plainf:id:tofucodes:20180906005253p:plainf:id:tofucodes:20180906005249p:plain
左:iOS設定アプリから、中:通知から、右:アプリ内の通知設定画面

例えば通知の種類がいくつかありそれぞれに対してON/OFFを選択できるようなサービスにおいて、OFFにするための画面(iOS設定アプリや実際の通知)にアプリ内設定画面への導線を置くことで全ての通知をOFFにされるのを避けられることが期待されます。

developer.apple.com

実装方法

keynoteではDelegateメソッドさえ実装していればあとはOSが勝手に導線を作ると言っていますが、嘘ですlol

まずはUNUserNotificationCenterのrequestAuthorization(options:)のオプションに. providesAppNotificationSettingsを追加します。

UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound, .providesAppNotificationSettings])

そしてUNUserNotificationCenterDelegateのuserNotificationCenter(_:openSettingsFor:)を実装して該当のアプリ画面を表示します。

Cloud Firestore iOS SDKをCarthageで組み込むとクラッシュするあなたへ

コード

SDKの組み込みはCocoaPodsかCarthageかの違いだけで、あとは全て公式ドキュメントの通りに実装していきました。

Get started with Cloud Firestore  |  Firebase

import Firebase

FirebaseApp.configure()

let db = Firestore.firestore()

// Add a new document with a generated ID
var ref: DocumentReference? = nil
ref = db.collection("users").addDocument(data: [  // Crash here!!!
    "first": "Ada",
    "last": "Lovelace",
    "born": 1815
]) { err in
    if let err = err {
        print("Error adding document: \(err)")
    } else {
        print("Document added with ID: \(ref!.documentID)")
    }
}

ところがaddDocument()で実際にDBへ書き込みを行う時点でクラッシュが発生してしまいました。writeではなくreadだったらどうかと思い別のAPIも試しましたが同じ結果でした。エラーログにも特にヒントとなるようなログは見つけられませんでした。

原因

FireStoreのSDKはリソースバンドルを含んでいて、そのバンドルをアプリに組み込む必要があるようです。Firebase iOS SDKgithubレポジトリのCarthage.mdにちゃんと書いてありました。ビルド設定を色々調べたりリンクするライブラリが足りないんじゃないかと悩んで1時間ほど無駄にしたのが情けない。

firebase-ios-sdk/Carthage.md at master · firebase/firebase-ios-sdk · GitHub

If you're including a Firebase component that has resources, copy its bundles into the Xcode project and make sure they're added to the Copy Bundle Resources Build Phase :
- For Firestore:
- ./Carthage/Build/iOS/gRPC.framework/gRPCCertificates.bundle

私と同じようなズボラな方の役に立てれば幸いです。

SideMenuライブラリでツールバーの見た目を変更できない原因と解決方法

github.com

問題

アプリ側のステータスバーの見た目とSideMenuで表示するメニュー側のステータスバーの見た目を変えるために、以下のような実装を行なった。

import SideMenu

let menuLeftNavigationController = UISideMenuNavigationController(rootViewController: MenuViewController())
SideMenuManager.default.menuLeftNavigationController = menuLeftNavigationController
SideMenuManager.default.menuWidth = 280
SideMenuManager.default.menuPresentMode = .viewSlideInOut
class MenuViewController: UIViewController {
    override var preferredStatusBarStyle: UIStatusBarStyle {
        return .lightContent
    }
}

しかしMenuViewControllerのpreferredStatusBarStyleは実行されず、期待した動作にならなかった。

原因

SideMenuのコードを読んでみると、SideMenuManagerにNavigationControllerをセットしたタイミングで、そのNavigationControllerのmodalPresentationStyle.overFullScreenに変更されてしまっていた。

    fileprivate func setupNavigationController(_ forMenu: UISideMenuNavigationController?, leftSide: Bool) {
        guard let forMenu = forMenu else {
            return
        }
        
        forMenu.transitioningDelegate = transition
        forMenu.modalPresentationStyle = .overFullScreen
        forMenu.leftSide = leftSide
        ...

https://github.com/jonkykong/SideMenu/blob/1b6b8c446c10240b6bffb7c0ba1e18424791c74f/Pod/Classes/SideMenuManager.swift#L238

公式ドキュメントによると、ViewControllerをpresent(_:animated:completion:)で表示する時、ステータスバーの見た目のコントロールは表示する側から表示される側に移る。ただし表示される側のmodalPresentationStyle.fullScreenの時だけね🤪

When you present a view controller by calling the present(_:animated:completion:) method, status bar appearance control is transferred from the presenting to the presented view controller only if the presented controller's modalPresentationStyle value is UIModalPresentationStyle.fullScreen.

modalPresentationCapturesStatusBarAppearance - UIViewController | Apple Developer Documentation

解決法

SideMenuのNavigationControllerのmodalPresentationCapturesStatusBarAppearanceをtrueに設定する。

// modalPresentationStyle become .overFullScreen as set to menuLeftNavigationController.
// Set this property true in order to make sure that status bar appearance control
// is transferred from the presenting to the presented view controller.
menuLeftNavigationController.modalPresentationCapturesStatusBarAppearance = true

こうすることでMenuViewControllerのmodalPresentationStyleの値に関わらずoverrideしたpreferredStatusBarStyleが実行されるようになり期待した動作になる。

Travis CIのxcode9.4イメージにbundlerがプリインストールされてない模様

Travis CIのosx_image: xcode9.4がリリースされたので試してみました。

しかしbefore_installでエラー発生。どうやらbundlerが見つかってない模様です。osx_image: xcode9.3までは正常に動作していたのですが、9.4にしただけでビルドがこけるようになってしまいました。

$ sudo bundle install
/Users/travis/.rvm/rubies/ruby-2.4.3/lib/ruby/2.4.0/rubygems.rb:271:in `find_spec_for_exe': can't find gem bundler (>= 0.a) (Gem::GemNotFoundException)
   from /Users/travis/.rvm/rubies/ruby-2.4.3/lib/ruby/2.4.0/rubygems.rb:299:in `activate_bin_path'
  from /Users/travis/.rvm/gems/ruby-2.4.3/bin/bundle:23:in `<main>'
    from /Users/travis/.rvm/gems/ruby-2.4.3/bin/ruby_executable_hooks:15:in `eval'
  from /Users/travis/.rvm/gems/ruby-2.4.3/bin/ruby_executable_hooks:15:in `<main>'

The command "sudo bundle install" failed and exited with 1 during .

公式ドキュメントのプリインストールされてるGem一覧には変わらずbundlerも含まれています。

The OS X Build Environment - Travis CI

  • bundler
  • rake
  • cocoapods

ということでGithubにissue作りました。どんなカンバセーションがされるでしょうか。

github.com

追記

時間がかかりましたが、Travisのissueで返答がありました。

root ユーザではbundlerが使えなかったみたいです。ということでsudo bundle installからbundle installに変更すればokでした。

osx_image: xcode9.4 doesn't have bundler · Issue #9759 · travis-ci/travis-ci · GitHub

Travis CIでCarthageのビルドをキャッシュして幸せになる

目的

  • Travis CIのPull RequestビルドでCarthageを毎回ビルドしない
  • git repositoryにCarthageのビルド成果物をコミットしないで実現する

Pull Requestビルドの概要

公式ドキュメントを覗いてみます。

Caching Dependencies and Directories - Travis CI

Pull Requestのビルドは以下のキャッシュを確認して最初に見つかったものを利用する。

  1. 対象のPull Requestのキャッシュ
  2. 対象のPull Requestのターゲットブランチのキャッシュ
  3. git repositoryのデフォルトブランチのキャッシュ

もし上記すべてのキャッシュが存在しない場合はキャッシュなしでビルドが実行される。

最初のPull Requestビルドが終了した後に、新しくPull Requestキャッシュが作成される。(つまり上記リストの1.はPull Requestの初回ビルドでは存在しない)

やってみる

  • ターゲットブランチ: develop(ブランチキャッシュはすでに存在)
  • デフォルトブランチ: master

.travis.yml

cache:
  directories:
    - Carthage

featureブランチ -> develop へPRを作成します。 キャッシュが効いていたらCarthageのビルドが走らないため6分程度で終わるはずです。

f:id:tofucodes:20180615220107p:plain

...時間がかかり過ぎています。

原因を特定するためにTravisのログを見てみます。

Setting up build cache
$ export CASHER_DIR=$HOME/.casher
$ Installing caching utilities

attempting to download cache archive
fetching PR.118/cache-osx-xcode9.3-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855--rvm-default--gemfile-Gemfile.tgz
fetching PR.118/cache--rvm-default--gemfile-Gemfile.tgz
fetching master/cache-osx-xcode9.3-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855--rvm-default--gemfile-Gemfile.tgz
fetching master/cache--rvm-default--gemfile-Gemfile.tgz

could not download cache

fetching... の内容がキャッシュの存在確認のようですが、Pull Requestキャッシュとmasterブランチキャッシュしか確認してないように見えます。

公式ドキュメントの内容が正だとしたらTravis CIのバグのような気がしますが、とりあえずdevelopブランチをデフォルトブランチに設定して別のPRを試してみます。(同じPRだとすでにPull Requestキャッシュが作成されてしまって検証にならないため)

f:id:tofucodes:20180615220058p:plain

17分から6分に短縮することができました。

Travisのログを見てみてもdevelopブランチのキャッシュを確認していることが分かります。

Setting up build cache
$ export CASHER_DIR=$HOME/.casher
$ Installing caching utilities

attempting to download cache archive
fetching PR.124/cache-osx-xcode9.3-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855--rvm-default--gemfile-Gemfile.tgz
fetching PR.124/cache--rvm-default--gemfile-Gemfile.tgz
fetching develop/cache-osx-xcode9.3-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855--rvm-default--gemfile-Gemfile.tgz

found cache

結論

  • Travis CIのPull Requestビルドはターゲットブランチのブランチキャッシュを確認してくれない
  • 公式ドキュメントが正だとしたら上記の挙動はTravis CIのバグの可能性
  • git repositoryのデフォルトブランチをうまく設定することで、初回ビルドからTravis CIのキャッシュ機構の恩恵を受けることができる(今回の例ではdevelopを利用しましたがmasterでもビルドするような環境の場合はmasterでも大丈夫なはず)

また以前別エントリで記載したように、CI時のビルド時間短縮の観点から、Carthageを利用するアプリでCarthageフォルダをgit repositoryにコミットするか否か迷っていましたが、Travis CIを利用する場合はキャッシュ機構が備わっているためgit repositoryにコミットしなくて済みrepositoryをクリーンに保てるので良いですね。

tofucodes.hatenablog.jp

指定期間のデータを抽出するアンチパターンについて

何かしらのデータのリストから特定の期間のデータのみをフィルタしたいような時があると思います。

今回のサンプルはデータのリストから6月に作成されたデータをフィルタします。

おそらくよくありがちなコードはこんな感じではないでしょうか。

(コードはだいぶデフォルメしてます)

let dataList: [SomeData] = ...
// 2018/06/01 00:00:00 <= x <= 2018/06/30 23:59:59
let filtered = dataList.filter { Date("2018-06-01 00:00:00") <= $0.createdAt && $0.createdAt <= Date("2018-06-30 23:59:59") }

弊社のiOSアプリにもこのようなコードが存在していました(そしてバグがありました)。

これはあまり良くないです。

なぜこれが良くないかというと、比較の後ろのデータが無限に取りうるから(23:59:59.999.... <<< 越えられない壁 <<< 00:00:00)。

それを踏まえたより良い実装はこちら。

let dataList: [SomeData] = ...
// 2018/06/01 00:00:00 <= x < 2018/07/01 00:00:00
let filtered = dataList.filter { Date("2018-06-01 00:00:00") <= $0.createdAt && $0.createdAt < Date("2018-07-01 00:00:00") }

実際にSwiftのDateオブジェクトの比較でどこまで細かい秒数まで使われるか知らないので、もしかしたら問題ないかもしれないですが、

この考え方自体は、些細なことですが言語にかかわらず結構重要な気がします。

Carthageの導入で困った7つのこと

先日仕事で初めてCarthageを利用した際に困った点やつまづいた点。

ラインナップ

  1. Carthage関連ファイルどこまでcommitするか問題
  2. carthageのコマンド多くてどれ使えば良いかよく分からない問題
  3. Fabric/Crashlyticsが公式にサポートしてない問題
  4. Firebaseが公式にサポートしてない問題
  5. Firebase.frameworkがbitcodeサポートしてない問題
  6. App Installation Failed 問題
  7. binaryダウンロードのバージョン解決に不具合がありそう?問題

Carthage関連ファイルどこまでcommitするか問題

CartfileとCartfile.resolvedは必須として以下のファイル群をcommitするのかどうかという話。

  • Carthage/Checkouts
  • Carthage/Build

qiita.com

世間には色々な意見があるようだけど、私個人としては以下を理由としてCheckouts, Build共にcommitしない方針にしました。

  • Cartfile.resolvedがあれば原則的には同じ開発環境を作れる
  • Githubに不要なDiffが出るのが好きじゃない
  • 現状はCIをしていないのでビルド時間にそこまでシビアになる必要がない

仕事ではチームで開発を行ってるため上記のような方針にしましたが、個人開発とかでGithubのPull Request運用じゃない環境だったら2番目の理由は当てはまらなくなるんで、好きなファイルcommitしてしまえば良いんじゃないんですかね。

carthageのコマンド多くてどれ使えば良いかよく分からない問題

$ carthage
Available commands:

   archive           Archives built frameworks into a zip that Carthage can use
   bootstrap         Check out and build the project's dependencies
   build             Build the project's dependencies
   checkout          Check out the project's dependencies
   copy-frameworks   In a Run Script build phase, copies each framework specified by a SCRIPT_INPUT_FILE environment variable into the built app bundle
   fetch             Clones or fetches a Git repository ahead of time
   help              Display general or command-specific help
   outdated          Check for compatible updates to the project's dependencies
   update            Update and rebuild the project's dependencies
   version           Display the current version of Carthage
  • archive = frameworkを提供するときに必要そうな感じ
  • bootstrap = build + checkoutな感じだけどそれぞれがよく分からない
  • build = frameworkをビルドするコマンド?
  • checkout = ?
  • fetch = ??
  • update = ライブラリのバージョンアップしたい時に必要そうな感じ

自分の最初の印象としてはこんな感じでした。 しかもそれぞれのコマンドにさらにたくさんのオプションまで用意されててもう何が何だか....

結論、簡単に使うなら以下くらいで大丈夫な気がしてます。

# Carthage導入時
$ carthage update --platoform iOS

# Carthage導入済みのプロジェクトをcloneしてきて依存ライブラリをビルドする最初のステップ
# Cartfile.resolvedの内容を元にビルドされます
$ carthage bootstrap --platform iOS

# (全ての)依存ライブラリをバージョンアップする時
$ carthage update --platform iOS --cache-builds

# 特定の依存ライブラリをバージョンアップする時
$ carthage update "$LIBRARY_NAME" --platform iOS

FYI: 各コマンドのオプションの詳細は以下のコマンドで見れる

# carthage help <command name>
$ carthage help bootstrap

Fabric/Crashlyticsが公式にサポートしてない問題

FabricとCrashlyticsはCocoaPodsはサポートしてるけどCarthageはサポートされてないので代替手段を検討する必要があります。

github.com

Carthageのバイナリ形式ダウンロードで必要になるjsonファイルを親切な人がメンテしてくれてるので、私はこちらをありがたく利用させてもらいました。

binary "https://raw.githubusercontent.com/Building42/Specs/master/Carthage/Fabric.json"
binary "https://raw.githubusercontent.com/Building42/Specs/master/Carthage/Crashlytics.json"

FYI: メンテされてるレポジトリはここ

github.com

Firebaseが公式にサポートしてない問題

Firebaseも同じく公式にはまだサポートされてないのですが、こちらはバイナリダウンロード用のjsonファイルを公式が試験的にメンテしてくれているようです。フィードバック次第では今後Carthageを公式にサポートする可能性もあるようです。

github.com

binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAnalyticsBinary.json"
binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAuthBinary.json"
binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseDatabaseBinary.json"
binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseDynamicLinksBinary.json"
binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseMessagingBinary.json"

Firebase.frameworkがbitcodeサポートしてない問題

前述のようなCartfileでダウンロードを行うとFirebase.frameworkというframeworkがダウンロードされるので何も考えずにアプリにリンクしたところbitcodeがサポートされてないというエラーになりました。

$ otool -l Carthage/Build/iOS/Firebase.framework/Firebase | grep __LLVM
# 何も出力されない

対応の結論としては、Firebase.framework自体の中身と、CocoaPods時代にこのframeworkをリンクしていなかったことから判断して、Firebase.frameworkはアプリにリンクしませんでした。

App Installation Failed 問題

全ての依存ライブラリをCarthageに移行していざアプリをRunしようとするとエラーが...

f:id:tofucodes:20180421175259p:plain

This application or a bundle it contains has the same bundle identifier as this application or another bundle that it contains. Bundle identifiers must be unique.

原因はFirebaseのframeworkをCarthageのcopy-frameworksの対象にしてたことでした。Firebaseのframeworkはstatic libraryになっており、static libraryはcopy-frameworksの対象にできないようです。以下参照。

github.com

binaryダウンロードのバージョン解決に不具合がありそう?問題

Carthageに移行前CocoaPodsで4.9.0のFirebase SDKを利用していたので、Carthageでも以下のようなバージョン指定でダウンロードを試みました。私の認識が正しければ、以下のようなバージョン指定では4.9.Xの最新版をダウンロードしてくれるものかと思ってました。

binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAnalyticsBinary.json" ~> 4.9.0

しかし実際のところcarthage updateを実行するとダウンロードされたバイナリは4.12.0(4.Xの最新版)でした。これがCarthageのバグなのか私の認識違いなのかまだ不明なので分かる方がいたら教えてほしいです。

もし4.9.0をダウンロードしたい場合は以下のように厳密に指定してあげれば可能です。

binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAnalyticsBinary.json" == 4.9.0

上記の挙動については私の認識違いでした。 CarthageのREADMEをよく見ろと言う話でした。

github.com

Compatibility is determined according to Semantic Versioning. This means that any version greater than or equal to 1.5.1, but less than 2.0, will be considered “compatible” with 1.5.1.

互換性の定義は Semantic Versioning の定義に従い、~> 1.5.1 は 1.5.1以上2.0未満となる。

According to SemVer, any 0.x.y release may completely break the exported API, so it's not safe to consider them compatible with one another. Only patch versions are compatible under 0.x, meaning 0.1.1 is compatible with 0.1.2, but not 0.2. This isn't according to the SemVer spec but keeps ~> useful for 0.x.y versions.

Semantic Versioning によると、0.x.y の場合はすべて互換性が無いものと考える。(しかしCarthageは?)パッチバージョンアップを互換性ありと考えるようにする(0.1.1 -> 0.1.2は互換性あり、0.1.2 -> 0.2 は互換性なし)。これはSemantic Versioningに則ってないけど、0.x.yのバージョンの場合は便利

つまりFirebaseのライブラリを~> 4.9.0と指定しているにも関わらず4.12.0がダウンロードされていたのは正常な動作でした。