Quantcast
Browsing Latest Articles All 25 Live
Mark channel Not-Safe-For-Work? (0 votes)
Are you the publisher? or about this channel.
No ratings yet.
Articles:

Stack Overflowで最も多く支持されたiOS関連の質問ベスト10(2018年版)

どうも、koogawa です。いよいよアドベントカレンダーが始まりましたね!

今年も昨年に引き続き「今年Stack Overflowに投稿されたiOSに関する質問」の中で、Vote数(投票数)が多かった質問、つまり デベロッパーから支持の多かった質問 をまとめてみました。

それでは1位から順に見ていきましょう!

※2018年12月1日(日本時間)時点での情報を元に集計しています

第1位:Xcode 9.3 から見知らぬ plist ファイルが追加された問題(123票)

ios - New file created in Xcode 9.3, .xcworkspace/xcshareddata/IDEWorkspaceChecks.plist should it be committed? - Stack Overflow

今年1位に輝いたのは3月にリリースされた Xcode 9.3 に関する質問でした。Xcode 9.3 で既存のプロジェクトをリビルドしたらIDEWorkspaceChecks.plist というファイルが追加されたよ!これはバージョン管理に含めるべきなの?という内容です。

この質問に対するベストアンサーはまだ選ばれていません。現時点で最も多くの票を集めているのは、リリースノート

Xcode 9.3 adds a new IDEWorkspaceChecks.plist file to a workspace’s shared data, to store the state of necessary workspace checks. Committing this file to source control will prevent unnecessary rerunning of those checks for each user opening the workspace.

という記載があるから、バージョン管理に含めといたほうが良いよ!という回答です。どうやらこのファイルは Workspace における必要なチェック項目の状態を保存し、無駄に再チェックが走るのを防いでくれるものらしいですね。

参考

第2位:Xcode 9.3 で 'Status bar could not find cached time string image. Rendering in-process’ の警告が出る問題(108票)

ios - Status bar could not find cached time string image. Rendering in-process - Stack Overflow

Xcode を 9.3 にアップグレードしたら

Status bar could not find cached time string image. Rendering in-process.

の警告が出るようになったけど、これは気にしたほうがいいの?直す方法はあるの!?という内容です。

この質問には「私も!」「俺も!」というコメントが殺到したため、現在は protected(一定の reputation を持つメンバーしか回答ができない状態)になっています。

ベストアンサーに選ばれたのは「気にしなくていいと思うよ。新しいバージョンには時々こういったデバッグメッセージが表示されるのさ。そしてこれらはだいたい次のリリースで修正されるんだ」という回答でした。

しかし、Xcode 10.1 がリリースされた今現在もこの警告は出続けているようです🤔

第3位:Xcode 10 beta 5 で Firebase と Crashlytics を一緒にビルドできない問題(106票)

ios - Xcode 10b5 - duplicate symbol linker error, can't compile with Crashlytics - Stack Overflow

Xcode beta 5 において、Firebase と Crashlytics/Fabric を一緒にビルドしようとしたら duplicate symbol linker error が出るようになったよ!という内容です。コメント欄には様々なアドバイスが寄せられ、質問者は

  • pod install 再実行およびキャッシュの削除
  • クリーンビルドおよびXcode再起動
  • -ObjC linker flag 追加

などを試しましたが問題は解決せず。。

しかし、beta 6 がリリースされると、このエラーは嘘のように消え去ってしまいました。
このように beta 版で発生するエラーは Apple 側もしくはサードパーティライブラリ側の原因であることも多いため、開発者は落ち着いてエラーレポートを送ることが大切だと思いました。

第4位:Time Profiler が動かない問題(61票)

ios - Time profiler in instruments is not working - Stack Overflow

Xcode を 9.3 にアップグレードしたら Time Profiler が機能しなくなったよ!という内容です。Life Cycle バーは常に初期状態のままで、

the data volume is too high for a recording mode of "immediate" and some data had to be dropped to move forward.

という警告が大量に出ていたようです。

ベストアンサーに選ばれたのは「Xcode 9.2 にダウングレードしたら解決したよ」という回答でした(これを解決と言ってよいかは微妙ですが😅)。これは Xcode 9.3 のバグだから Apple にレポートしておいたよ!ということでしたが、結局 Xcode 9.4 でも直らなかったようです。

※ちなみに Xcode 10.1 で試したところ動いてました(2018/12/1)

参考

第5位:Xcode 10 にて "A valid provisioning profile for this executable was not found" というエラーが出る問題(58票)

ios - Xcode 10: A valid provisioning profile for this executable was not found - Stack Overflow

Xcode 10 にアップデートしたところ、

A valid provisioning profile for this executable was not found.

というエラーが出るようになり、実機ビルドができなくなったよ!という内容です。

  • クリーンビルド
  • derived data の削除
  • Xcodeをアンインストールし、それに関連する環境設定とファイルを削除
  • まったく異なるMacにXcodeをインストールしてテスト
  • 異なるデバイスでテスト
  • プロビジョニングプロファイルからデバイスを無効にし、Xcodeで再度有効にする
  • 開発者ポータルのすべての証明書を削除して再作成
  • 「Automatically manage signing」のチェックをはずして再度チェックを入れる
  • プロビジョニングプロファイルを破棄し、Xcodeに再作成させる
  • 手動でプロビジョニングプロファイルを作成

はすでに試したけど、問題は解決しなかったようです。

ベストアンサーに選ばれたのは、"Workspace Settings" から Legacy Build System を選んで実行すればうまくいくよ!というシンプルなものでした。

このモードは名前の通り古いビルドシステムでビルドを実行するものなのですが、どこかモヤっとする解決方法ですね。しかも、一度このモードに切り替えたあと、再度 New Build System に戻すとなぜか普通にビルドしてしまう人もいたようです🤔

第6位:App Store にアプリを申請したら "Invalid Document Configuration" という警告が出る問題(51票)

ios - App Store Connect Warns - Invalid Document Configuration - Stack Overflow

Document Based ではないアプリを App Store に提出したのに、次の警告メールが返ってきたよ!という内容です。

Invalid Document Configuration - Document Based Apps should support either the Document Browser (UISupportsDocumentBrowser = YES) or implement Open In Place (LSSupportsOpeningDocumentsInPlace = YES/NO)."

TestFlight によるテストは普通にできるけど、できればこの警告を消したい!とのことです。

この問題は警告に書いてある通り、Info.plist に UISupportsDocumentBrowser もしくは LSSupportsOpeningDocumentsInPlace をセットすれば解決します。

ちなみに、それぞれのキーの意味は次のとおりです。

  • UISupportsDocumentBrowser - 他アプリの Documents ディレクトリから自分のドキュメントへのアクセスを許可するかどうかを指定する
  • LSSupportsOpeningDocumentsInPlace - 他アプリからファイルを渡される際、コピーではなくオリジナルファイルを受け取るかどうかを指定する

2018/12/1 現在、この質問にベストアンサーはついていませんが、もしあなたのアプリがドキュメントを編集したりするものでなければこれらのキーは NO にしたら良いよ、という回答が多くの票を集めていました。

参考

第7位:Xcode でダークモードをオンにするのはどうやるの問題(41票)

ios - How to enable Dark Mode for Xcode 10 - Stack Overflow

こんな質問あり!?という内容ですが、「あり」のようです😅 実際に票も集めています。

ご存知の通り、ダークモードは macOS 10.14 Mojave から追加された機能です。この質問者は macOS 10.13 High Sierra 環境で Xcode 10 を使っていたため、ダークモードを選択できなかったようです。

Mojave 環境でダークモードを選択する方法を図入りで丁寧に解説した回答がベストアンサーに選ばれていました。毎年こういったプログラミング以外の質問もランクインしてますね😅

第8位:Xcode 9.2 が iOS 11.3 をサポートしてない問題(40票)

Xcode not supported for iOS 11.3 by Xcode 9.2 needed 9.3 - Stack Overflow

「問題」というか仕様の問題ですね😅

iOS 11.3 もサポートした Xcode 9.3 にアップデートしてね、と言いたいところなのですが、質問者の環境は Sierra だったため、High Sierra 以上を必要とする Xcode 9.3 はインストールできないとのことでした。

ベストアンサーに選ばれたのは、iOS 11.3 の device support を配布している非公式サイトがあるから、そこからダウンロードして Xcode 9.2 にインストールすれば iOS 11.3 もサポートした環境ができあがるよ!という回答でした(いいのか、それ🤔)。

コメントにも同じような意見がありましたが、個人的には

  • マシンを最新のmacOSにアップデートできない場合は、ハードウェアを更新する(つまり買い替えの時期)
  • アップデートしようとしている iOS バージョンが、自分の開発環境でビルドできるか事前に確認する

べきだと思っています。

第9位:Xcode 10 beta にて "MGIsDeviceOneOfType is not supported on this platform." というエラーが出る問題(36票)

ios - Xcode Error on Simulation: MGIsDeviceOneOfType is not supported on this platform - Stack Overflow

Xcode 10 beta のシミュレーターでアプリをビルドしようとしたところ

libMobileGestalt MobileGestalt.c:875: MGIsDeviceOneOfType is not supported on this platform.

というエラーが出て画面が真っ白になるよ!という内容です。

そもそも beta 版なんだからアップルにバグ報告するべきだよ!等のコメントが寄せられましたが、最も多くの票を集めたのは「Xcode 9.4.1 に戻したら解決したよ」という回答でした😅

この回答自体にも「それでも私は Xcode 10 を使いたいんだよ」「それは回答とは言えない」等のコメントが寄せられ、多くの票を集めていました。

第10位:Xcode 10 に libstdc++6.0.9 が含まれていない問題(35票)

xcode10 - Xcode 10 (iOS 12) does not contain libstdc++6.0.9 - Stack Overflow

Xcode 10 にアップデートしたところ、GNU C++ ライブラリである libstdc++6.0.9 が「Choose frameworks and libraries to add:」のリストから消えてしまったよ!という内容です。Xcode の C++ コンパイラは以前は GNU C++ でしたが、現在は LLVM と Clang に変わっています。Clang の標準 C++ ライブラリは libc++ であり、現在はこちらが Apple の標準となっています。

この質問には未だベストアンサーが付いていませんが、

  • libstdc++ は5年前に deprecated になったよ
  • 最近のプラットフォーム(tvOSやwatchOS)はすでにこのライブラリをサポートしていないよ
  • サポートは iOS 12 のシミュレーターランタイムから削除されたよ(ただし、iOS 12のデバイスランタイムには互換性保持のため残っているよ)
  • 今は libstdc++ の代わりに libc++ を使うべきだよ
  • Xcode 9.4 の SDK をコピーしてくることで一時的には回避できるよ

という内容の回答が最も多くの票を集めていました。

総括

新機能に関する質問は少なめ

昨年と比較すると、今年発表された新機能や新デバイスに関する質問は少ないように感じました。秋にリリースされた iOS 12 は パフォーマンスの向上 がテーマだったこともあり、それが関係しているのかもしれませんね。

Xcode 9 関連の質問が多め

Xcode 10 関連の質問より Xcode 9 関連の質問が多かったのも印象的でした。特に第4位の「Time Profiler が動かない問題」は私の環境では再現しなかったのですが、これに悩まされた開発者も多かったようです。

投票数は全体的に減少傾向

全体的な投票数については今年も減少傾向にあり、昨年の1位(Xcode 9でのワイヤレスデバッグのやり方がわからない問題)が200票以上集めたのに対し、今年の1位は約半分の100票台でした。今年はただ単にクリティカルな問題が少なかっただけかもしれませんが、どこか寂しい気もしますね😢

というわけで、2018年のランキングをお送りしました。
iOS アドベントカレンダー、明日は @TachibanaKaoru さんです!

来年もまたやるかも!?
(誰かAndroid版も書いてください!)

iOSと新しい元号

External article

【iOS】オープンソースSwiftライブラリのつくり方

はじめに

iOS Advent Calendar 2018、3日目担当の@shtnkgmです!先日以下のOSSライブラリをはじめてリリースしました🎉

shtnkgm/ImageTransition
library for smooth animation of images during transitions

そのノウハウとしてiOS向けのSwiftオープンソースライブラリのつくり方をまとめます。

  • ライブラリをつくる(Embedded Frameworkとして作成、デモ用ターゲットの追加)
  • 設定しておくと良いもの(.gitignore、swiftlint、Travis CI、CODEBEAT)
  • パッケージ管理ツールのサポート(Carthage、CocoaPods)
  • ライブラリの説明を書く(LICENSE、README.md)
  • ライブラリの宣伝

例題として、初代の半透明iMacのカラーリングにも使われた「Bondi Blue」の色をUIColorとして提供するだけのライブラリを作成します。

ライブラリを実装する

Embedded Frameworkとして作成

「Cocoa Touch Framework」テンプレートを選択し、新規にXcodeプロジェクトを作成します。ライブラリ本体のコードとして、UIColorのエクステンションを実装しました。
スクリーンショット 2018-12-02 19.37.05.png
アプリ側から利用したいAPIはアクセス修飾子をpublicもしくはopenにしておく必要があります。特に、構造体のメンバーワイズイニシャライザやクラスのデフォルトイニシャライザなど暗黙的に実装されるものはデフォルトでinternalとなっているため、注意が必要です。(過去記事:Swiftとイニシャライザ)

// publicもしくはopenにしておかないと、アプリ側(別モジュール)から利用できない
public extension UIColor {
    static var bondiBlue: UIColor = .init(hex: "0095b6")
}

デモ用ターゲットの追加

File > New > Target...からライブラリのお試し用のDemoターゲットを追加します。

DemoターゲットでLink Binary With LibrariesにBondiBlue.frameworkを追加します。

サンプルコードは以下のようになりました。

import UIKit
import BondiBlue

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .bondiBlue
    }
}

設定しておくと良いもの

その他以下についても設定しておくと良いかと思います。

パッケージ管理ツールのサポート

Carthage

Carthageでの配布を行うためには、TargetがShared Schemeになっている必要があります。
Product > Scheme > Manage Schemes...から作成したframeworkのSharedにチェックが入っていることを確認します。(Xcode10では既にチェックが入っていました)

以下のコマンドをターミナルで実行することで、正しくframeworkが作成されるか確認することもできます。(任意)

# frameworkをビルド
$ carthage build --no-skip-current

# frameworkが生成されていることを確認
$ ls Carthage/Build/iOS/
4705D7A0-13ED-35A6-B5F4-BBA78FEA34A3.bcsymbolmap
BondiBlue.framework
BondiBlue.framework.dSYM

あとはGitHub側でバージョンを切ってリリースするだけです。
Githubリポジトリトップ画面 > releaseタブ > create a new release

CocoaPods

CocoaPodsで配布を行うためにはまずはpodspecファイルを作成します。
プロジェクトのルートディレクトリでpod spec create [ライブラリ名]を実行することで.podspecが作成されます。

# podspecを作成
$ pod spec create BondiBlue

ライブラリの内容に合わせて[ライブラリ名].podspecを編集します。サンプルでは以下のようにしました。

Pod::Spec.new do |spec|
  spec.name           = "BondiBlue"
  spec.version        = "1.0.5"
  spec.summary        = "UIColor Extension of BondiBlue Color"
  spec.homepage       = "https://github.com/shtnkgm/BondiBlue"
  spec.license        = { :type => 'MIT', :file => 'LICENSE' }
  spec.author         = "shtnkgm"
  spec.platform       = :ios, "10.0"
  spec.swift_version  = "4.2"
  spec.source         = { :git => "https://github.com/shtnkgm/BondiBlue.git", :tag => "#{spec.version}" }
  spec.source_files   = "BondiBlue/**/*.swift"
end

書き方のチェックは以下のコマンドで行えます。

# podspecファイルをチェック
$ pod lib lint

lintを行い、以下のような点を指摘されました。

  • ○○行目で形式がおかしい
  • swiftバージョンは.swift-versionが非推奨になったので、swift_version属性に書く
  • プラッフォーム情報を書く(iOSとサポートバージョン)
  • 後述するLICENSEがないので設定する

次にCocoaPodsでの配布を行うためのCocoaPods TrunkというAPIサービスへアカウントの登録を行います。以下のコマンドを実行すると、メールが届くので中のリンクをクリックして登録を完了します。

# CocoaPods Trunkへのアカウント登録
$ pod trunk register メールアドレス '名前'

あとは最後にバージョン管理用のtagをつけ、ライブラリを公開します。

# バージョンを設定
$ git tag 1.0.5
$ git push origin 1.0.5

# ライブラリを公開(警告を無視したい場合は--allow-warningsをつける)
$ pod trunk push BondiBlue.podspec

ライブラリの説明を書く

LICENSEの追加

LICENSEはGitHub上でテンプレートを選択するだけで簡単に作成できます。

  • GitHubリポジトリトップ画面 > Create new fileをクリック
  • LICENSEと入力する
  • 右側に「Choose a license template」が出てくるのでクリック

サンプルではMITライセンスのテンプレートを選択しました。

README.mdの追加

README.mdにライブラリの説明を記述します。世界中の方が見るので、文章は英語で書きます。

バッジの追加
よくあるこんなバッジはshields.ioというサービスでつくることができます。Markdown形式で貼っておくだけで自動で情報を更新してくれたりするので便利です。

READMEに書くこと

  • ライブラリのタイトル(可能ならばかっこいいバナー画像にする)
  • バッジ(サポート環境やCIのステータスなど)
  • ライブラリの使い方
  • インストール方法(Carthage/CocoaPodsなど)
  • サポート環境
  • コントリビューションの方法(プルリクやissueのつくり方)
  • 著者とライセンス情報

サンプル: BondiBlueのREADME

ライブラリの宣伝

作ったライブラリは自分で使うのもいいですが、OSSなのでできてば他の人にも使ってもらいたいかと思います。
以下の方法で宣伝すると良さそうです。

最後に

ライブラリをOSSとして公開すると、海外の方々から意外とstarが貰えて、とてもモチベーションに繋がります🎉もしもOSSへのコントリビューション活動をまだしていないのであればissueや簡単なプルリクなどからでもオススメします!

以上、iOS Advent Calendar 2018 3日目の記事でした!

Xcodeの.pbxproj内のファイルをソートするSwift製コマンドラインツール - SortPbxproj

普段のiOS開発において、ファイルを追加するたびにProject Navigator上のファイルをソートしていると思います。
開発中、ファイル名が正しくソートされていないのを見つけてソートしてみるも、本筋と関係ない project.pbxproj の変更が含まれてレビュアーを困惑させることがあります。
これを解決させるためのスクリプトを作りました。

作ったもの

  • WorldDownTown/SortPbxproj
    • Swift Package Managerでコマンドラインツールを作りました
    • Homebrewからインストールすることができます

インストール

$ brew install WorldDownTown/taps/sort-pbxproj

使い方

$ sort-pbxproj $SRCROOT/Path/To/Project.xcodeproj
or
$ sort-pbxproj $SRCROOT/Path/To/Project.xcodeproj/project.pbxproj

このようにProject Navigator上のファイルやBuild Phase のCompile Sources 内のファイルもソートされます

before after
file_inspector_before.png file_inspector_after.png
compile_sources_before.png compile_sources_after.png

自動化

  • XcodeのRun Script や .git/hooks/pre-commit などに書けば自動的にソートできるようになります
  • プロジェクトで共有する場合はコマンドの有無を確認すると優しいですね
if type sort-pbxproj > /dev/null 2>&1; then
  sort-pbxproj $SRCROOT/Path/To/project.pbxproj
fi

ベースとなっているもの

  • webkit/sort-Xcode-project-file
    • WebKit の開発で使われている Perl スクリプト
    • WebKit 固有の要件に合わせたロジックも含まれています

実装について

ロジック

下記のようなシンプルな流れになっています

  1. pbxprojを一行一行読み込む
  2. 正規表現でソート対象行を特定
  3. ソート対象業ではファイルの名前を基準にソート並べ替える
  4. tmpファイルに書き込んで行く
  5. すべての行を読み込み終わったら、tmpファイルを project.pbxporj にリネームして終了

コマンドライン引数

--no-warnings オプション

  • デフォルトでは 与えられたパスに読み込み可能な project.pbxproj があるかチェックしており、見つからなかった場合には終了スクリプトを終了させます
  • 別名を使っている場合などを考慮して、そのチェックを省略するようなコマンドラインオプション --no-warnings を用意しています
    • 別名を使うことがあるのか正直よくわかりませんが、本家 WebKit版 を踏襲しました
    • コマンドラインオプションを実装したかっただけなので、将来消すかもしれません……

実装

  • コマンドラインの引数とオプションは CommandLine.arguments から取得できますが、型は [String] となっていて、スクリプト名や引数・オプションの区別はないためとても扱いやすいとは言えません
  • kylef/Commander を使うことで簡単に実装することができました
    • Swift Package Manager の dependencies に含めています
  • 引数やオプションを宣言的に書くだけで実装できるうえに、 --help も自動的に実装されるため実装コストがかなり抑えられました
// main.swift
import Commander

private func main(path: String, suppress: Bool) throws {
    // ソート処理
}

command(
    Argument<String>("path", description: "File path to *.xcodeproj or project.pbxproj"),   // 引数
    Flag("no-warnings", description: "ignore file path is valid or not"),                   // オプション
    main).run()

まだやってないこと

  • ユニットテスト
  • ユーザーに優しいエラーメッセージ表示
  • Homebrew の Bottles 対応 (バイナリ形式の配布)
  • Project Navigator のグループの中のグループは上にソート並ぶようなオプション ダウンロード.png

追記

2018/12/05 00:10

Project Navigator のグループの中のグループは上にソート並ぶようなオプション

私の勘違いで webkit/sort-Xcode-project-file でも SortPbxProj でも、オプションではなくデフォルトの機能としてすでに実装されていました😅 (リンク先が該当の処理です)

参照

RomeでCarthageのビルドコストを下げよう

はじめに

iOSアプリ開発を行うとき、ライブラリの導入にCarthageを使うことがあると思います。
Carthageは、CocoaPodsに比べて事前にビルドを行うためコンパイル時間が短い、ワークスペースが弄られないといったメリットがあります。
前者の利用で使っているところも多いのではないでしょうか

Carthageのビルド時間は長い?

Carthageはその特性上、一度全てのライブラリをビルドしてフレームワークを作成する必要があるため、環境構築に時間がかかります。
またSwiftのABI安定化はまだなので、XcodeをアップデートしてSwiftのバージョンが変わると再度ビルドする必要があり、地獄をみます。
ライブラリのアップデートが必要になった時も同じく地獄をみます。

このビルド時間問題で影響を受けるのは、数人〜数十人のエンジニアを抱えているチームだと思っています。
個人開発など、自分だけが再ビルドすれば良いのであれば特に問題にはならないと思いますが、他の方々もビルドする必要があると、その分進捗が死にます。
実際にCarthageのビルドがボトルネックとなったことがあり、割と笑えません。

よくある解決方法として、生成されたフレームワークをGitに含めるという方法もありますが、
使っているライブラリの量によってはウンGBになることもあり、リポジトリが肥大化するという問題が発生します。
またチーム内でXcodeのバージョンを揃えていない場合、前述したSwiftバージョン問題が発生する可能性があります。

…とはいえ、何らかの方法でフレームワークをキャッシュしてうまいことできないかなぁと調べていたところ、Romeというツールがいい感じだったのでご紹介いたします。

Romeとは

Carthageの生成物をオブジェクトストレージに共有できるツールです。
現在Amazon S3, S3互換のあるMinio, Cephに対応しています。

GitHub - blender/Rome: A cache tool for Carthage

使い方

READMEに記載されている通りなのですが、HomebrewかCocoaPods経由で導入ができます。
今回はHomebrewを使います。

brew install blender/homebrew-tap/rome

AWS S3の認証情報を設定する

キャッシュ先としてS3を利用しますので、
バケットへのアクセス権限を持ったユーザーの作成とアクセスキーID, シークレットキーを入手します。

~/.bash_profile
export AWS_ACCESS_KEY_ID=<ACCESS_KEY_ID>
export AWS_SECRET_ACCESS_KEY=<SECRET_ACCESS_KEY>
export AWS_REGION=<REGION>

Romeファイルの作成

次に、プロジェクトのあるディレクトリでRomefileを作成します。中身はYAMLで書いていきます。

Cache(必須)

s3Bucketに作成したバケット名を指定します。ローカルにキャッシュすることも可能です。

Romefile
cache:
  s3Bucket: ios-xxapp-carthage-cache
#    local: ~/Library/Caches/Rome
Repository Map

RomeはCartfile.resolvedを参照するため、リポジトリ名とフレームワーク名が異なる場合うまくキャッシュができません。依存ライブラリに関しても同様です。
Repository Mapでリポジトリ名とフレームワーク名を明記する必要があります。
例えばFacebook-SDK-Swiftですと、フレームワーク名は「FacebookCore」, 「FacebookLogin」, 「FacebookShare」となるので、これらを指定する必要があります。

Romefile
repositoryMap:
- Facebook-SDK-Swift:
    - name: FacebookCore
    - name: FacebookLogin
    - name: FacebookShare
- facebook-objc-sdk:
    - name: FBSDKCoreKit
    - name: FBSDKLoginKit
    - name: FBSDKShareKit
- xxxx-ios:
    - name: XXXX

Ignore Map

キャッシュする必要のない、したくないライブラリを指定できます。

Romefile
ignoreMap:
- xxxxx:
    - name: xxxxx

共有されているライブラリを扱う

S3からダウンロードする
rome download --platform iOS
S3へアップロードする
rome upload --platform iOS
S3に共有されていないライブラリを表示
rome list --missing --platform iOS

--cache-prefixについて

そのまま使っていると、s3Bucketで指定した階層にダウンロード/アップロードされてしまうため、チーム内のSwiftのバージョンが統一されていない場合Swiftバージョン問題が発生します。
これを回避するために、Romeではオプションとして--cache-prefixが用意されており、Swiftのバージョンごとにprefixを分けることができます。

rome download --platform iOS --cache-prefix Swift4_2

READMEには、xcrun swift --versionで取得したバージョンを使う方法が記載されていました。
私はこちらを使っています。

--cache-prefix `xcrun swift --version | head -1 | sed 's/.((.)).*/\1/' | tr -d "()" | tr " " "-"`

Gitから引っ張ってきた時とかにいい感じにライブラリを揃える

S3から引っ張ってきた後、未ビルドでアップロードされていないものだけをビルドしてアップロードするようにします。
ライブラリを追加した時や、gitからcheckout/pullした時に呼び出すといい感じにやってくれるはず。

cache_prefix=`xcrun swift --version | head -1 | sed 's/.*\((.*)\).*/\1/' | tr -d "()" | tr " " "-"`
platform='iOS'

rome download --platform $platform --cache-prefix $cache_prefix
rome list --missing --platform $platform --cache-prefix $cache_prefix | awk '{print $1}' | xargs sh -c "carthage update --platform $platform --cache-builds; rome upload --platform $platform --cache-prefix $cache_prefix"

感想

Romeのおかげでビルド地獄から抜け出せそうな気がしてます。ありがとうRome

参考

GitHub - blender/Rome: A cache tool for Carthage

日本語 Codable Enums

この記事は iOS Advent Calendar 2018 の 6日目の記事です。

Codable1 は、Swift 4.0 (3.2) で登場し、今年リリースされた Swift 4.1 / 4.2 のアップデートによって、さらに使いやすくなりました。

この記事では、実際に今年iOSアプリ開発で遭遇したケースを元に2、 enum、 Codable など、 Swift らしい機能を使って JSON を簡潔に扱う例を紹介します。

想定ケース

「やるべき宿題(タスク)を表示してくれるリスト」を表示する画面を作ります。

API からは以下のような、タスクの配列が返ってきます。

{
    [
    id: 1,  // タスクID
    subject_type: 2, // 教科タイプ
    books: [ ... ] ... // やるべき本とかとか
    ], ...
}

宿題には、教科のタイプ(国語、英語とか)が含まれます。

これを、リストの各要素に表示する画面を作ります。教科ごとに色が決まっていて、リストの要素の背景や、文字などに使用します。

また、教科のリストを選んで設定画面も作ります。

まとめると以下のようになります。

  • 課題の配列を、リストに表示する画面がある
  • 教科タイプ (subject_type) は、整数型で返ってくる
  • 教科の情報 (教科名, 色など) があり、クライアントで持っている
  • 教科タイプ (subject_type) をユーザーに選んでもらって、 post してもらう画面がある

JSON のパースには Codable を使うとよさそうですね。使ってみましょう。

実装例

以下のような型を定義しました。

struct Task: Codable {
    let id: Int
    let books: [Book]
    let subjectType: SubjectType
}

struct Book: Codable {
    let id: Int
    let title: Int
    ...
}

enum SubjectType: Int, Codable, CaseIterable, CaseStringConvertible {
    case 数学
    case 英語
    case 倫理
    case 簿記会計
    case 生物基礎

    var color: UIColor {
        switch self {
        case .数学: return .blue
        case .英語: return .red
        case .倫理: return .yellow
        case .簿記会計: return .brown
        case .生物基礎: return .green
        }
    }
}

注目してほしいのは SubjectType です。 Codable で、CaseIterable で、 CaseStringConvertible な Int の enum になっています。らぶるらぶる。

subjectType は単なる Int で指定しても実装できてしまうのですが、enum な型にするのには以下のようなメリットがあります。

  • 他の型でも subjecType が登場するとき、それらが同一のものであることを示せる (型にするメリット)
  • switch 文で使うとき、 default: を使わなければ case が増えたときにビルドエラーになり、もれなく列挙できる (enum を使うメリット)

Int の enum は、何も指定しない場合は 0 から値が割り当てられ、 subject_type の値が 0 なら数学、 1 なら英語に map されます。特定の値を特定の case としたいときには数値を指定することもできます。

    case 数学 = 5
    case 英語

ちなみに、JSONのほうのキーが snake_case になっている場合は、JsonDecoderkeyDecodingStrategy を指定することで camelCase に変換してくれます(Swift 4.1〜)。

let jsonDecoder = JsonDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase

これ以外にも、テクニックというと大げさですが、簡潔に扱うために工夫している部分があるので、ここでは3つほど紹介します。

テクニック1: 日本語をつかう

これは早速好みの分かれそうな方法ですが、case の名前に日本語を使っています。

日本語を使うことにはもちろんデメリットもあります。そのため、辞書を使っていい感じの英語に英訳できる場合は、英語を使うことも多いのですが、今回のような例だと…

    case 数学
    case 英語
    case 倫理
    case 簿記会計
    case 生物基礎

律儀に英訳していくのは結構たいへんではないでしょうか?(特に「簿記・会計」とか…)

この場合は、 case の名前に、無理せず日本語をそのまま使うことで、可読性や保守性を上げることができるケースになるのではないかと思います(そして、後述する副次的な効果もあります)。

都道府県名なども日本固有の名前なので、日本語にしてしまうのがおすすめの例です。

enum Prefecture {
    case 北海道
    case 青森県
    case 岩手県
    ...
}

日本語命名は便利ですが使う場所によってはリスクもあり、たとえば Swift4.2 では、 Storyboard に紐付いている ViewController の名前などに指定していると、インスタンス化に失敗することがあります。しかし enum の case 名であれば、まず問題ないと思います。

テクニック2: String(describing:) をつかう

SubjectTypeCaseStringConvertible というユーザー定義のプロトコルに適合していました。これは、 String(describing: self) 3を返すプロトコルです。

protocol CaseStringConvertible { }

extension CaseStringConvertible {
    var string: String {
        return String(describing: self)
    }
}

hoge.string // それ自身を表現する文字列

String(describing: self) は、それ自身を表現する文字列を返してくれます。たとえば、 Int の enum のインスタンスを渡したときは、case の名前になります。print 関数にインスタンスを与えたときに出力される内容は、これと同じです。

case名を日本語にしたいのはこれを使いたいからでもあります。UIの表示名に合わせておくことで、 UIに表示すべき名前を インスタンス名.string で簡潔に取り出すことができます。

subjectLabel.text = task.type.string // 生物基礎 とかが返ってくる
task.type.rawValue // Int そのものの値が欲しいとき

また、色を返す変数を用意していたので、同じように .color でシンプルに取り出すことができます。

    var color: UIColor {
        switch self {
        case .数学: return .blue
        case .英語: return .red
        case .倫理: return .yellow
        case .簿記会計: return .brown
        case .生物基礎: return .green
        }
    }
}
...

cell.subjectView.backgroundColor = task.type.color

これでリストの要素を簡潔に表現することができそうです。

テクニック3: CaseIterable をつかう

Swift 4.2 で、CaseIterable というプロトコルが増えました4。これに適合させた enum は、 .allCases で case のコレクションが取れるようになりました。

public protocol CaseIterable {
    /// A type that can represent a collection of all values of this type.
    associatedtype AllCases : Collection where Self.AllCases.Element == Self

    /// A collection of all values of this type.
    public static var allCases: Self.AllCases { get }
}

たとえばユーザーに SubjectType を選択してもらうリストを作りたい場合、 .allCases を使って表示するべき内容を指定することができます。

func collectionView(_ collectionView: UICollectionView,
                    cellForItemAt indexPath: IndexPath)
    -> UICollectionViewCell {
    let cell = collectionView
        .dequeueReusableCell(with: MyCell.self, for: indexPath)
    cell.setUp(title: SubjectType.allCases[indexPath.row].string)
}

func collectionView(_ collectionView: UICollectionView,
                    didSelectItemAt indexPath: IndexPath) {
    self.selectedType = SubjectType(rawValue: indexPath.row)!
    collectionView.reloadData()
}

便利ですね。

SubjectType は Codable (= JSON から変換できるし、JSON に変換可能)なので、選ばれた教科をそのまま post することができます。これで教科を選択する画面も作れそうです。

なお、 CaseIterable は Swift4.2 で追加されたプロトコルですが、Swift 4.2 以前でも、以下のように自分で定義して使うことができます5

public protocol EnumEnumerable { associatedtype Case = Self }
public extension EnumEnumerable where Case: Hashable {
    private static var iterator: AnyIterator<Case> {
        var n = 0
        return AnyIterator {
            defer { n += 1 }
            let next = withUnsafePointer(to: &n) {
                UnsafeRawPointer($0).assumingMemoryBound(to: Case.self).pointee
            }
            return next.hashValue == n ? next : nil
        }
    }
    public static var allCases: [Case] {
        return Array(self.iterator)
    }
}

まとめ

Codable に適合した Enum を使うことで、 JSON を返す API とのやりとりを簡潔に Swift らしく表現できます。

また、以下と組み合わせることによって、さらに簡潔に表現できる場合があります。

  • case の名前は無理に英訳せず、UI上の表示名を合わせられないか検討する
  • String(describing:) を使う
  • CaseIterable を使う

2019年も enum と Codable を使ってたのしくコーディングしていきましょう! 🎉

Apple Watch Series 4 の新しい Complication Families の実装を試してみる

はじめに

初となる大きな変更があった新しい Apple Watch が発売してから少し経ちました。珍しく納期が 3-4 週となったのを見た気がします。今回は Apple の時計盤の見せ方の仕方がうまかったのもあるけど需要自体はあるんだなぁと感じました。

complications_00.PNG

今回大きく変わった部分のひとつが色使いが実に美しい新しい Complications の追加でした。今まで以上に時計盤のカスタマイズが可能となり,より個性が出せるようになったと思います。中でも私はグラデーションのあるゲージの見せ方に魅力を感じました。さてどうやったら実装できるんだろうとずっと思っていたもののなかなか機会に恵まれなかったですが今回いい機会なので実装してみます。

Complications に関する資料など

Apple Watch発表後に Tech Talk として解説セッションが用意されましたので参考にしています。

Developing Complications for Apple Watch Series 4
https://developer.apple.com/videos/play/tech-talks/208/

また,デザイン面に関しては Human Interface Guideline に項目があります。

Human Interface Guideline Complications
https://developer.apple.com/design/human-interface-guidelines/watchos/app-architecture/complications/

実際のプログラミングに関しては下記が参考になりました。

Complication Essentials
https://developer.apple.com/library/archive/documentation/General/Conceptual/WatchKitProgrammingGuide/ComplicationEssentials.html

Apple Watch Series 4 から増えたWatch face

Apple Watch Series 4 から増えたのは Infograph Modular と Infograph の 2種類です。

Infograph Modular Infograph
complications_00_2.PNG complications_00_1.PNG

Apple Watch Series 4 から増えた Complication Families

Apple Watch Series 4 から増えた Complication は 4 つです。
Infograph Modular と Infograph の Watch face のみ対応しているので
過去の Watch face との連携を考える必要がない感じですね。

complications_01.png

引用:https://developer.apple.com/videos/play/tech-talks/208/

Graphic Corner

complications_02.png

引用:Human Interface Guideline Complications

Apple Watch Series 4 の Infograph の Watch face 限定で
Watch face のコーナーにフルカラーの画像・テキスト・ゲージを表示できる。
いくつかはマルチカラーのテキストをサポートしている。

Graphic Circular

complications_03.png

引用:Human Interface Guideline Complications

Apple Watch Series 4 の Infograph Modular と Infograph の Watch face 限定。
テキスト・ゲージ,フルカラーの画像を小さな丸い領域に表示できる。
いくつかはマルチカラーのテキストをサポートしている。

Graphic Bezel

complications_04.png

引用:Human Interface Guideline Complications

Apple Watch Series 4 の Infograph の Watch face 限定。
Infograph のベゼルに沿って任意のテキストがラップされた
円形のテンプレートを表示できる。
テキストはベゼルのほぼ180度を満たすことができる。

Graphic Rectangular

complications_05.png

引用:Human Interface Guideline Complications

Rectangular は 長方形のくらいの意味。
Apple Watch Series 4 の Infograph Modular の Watch face 限定。
Infograph Modular の真ん中の大きな長方形の領域に
フルカラーの画像・テキストそしてゲージを表示可能。
グラフみたいな Provider あるのかと思ったけど残念。

Apple Watch Series 4 から増えた Data Providers

Complications に値を提供するクラスは複数ありましたが,
4 つのデータ提供クラスが追加されています。
(全てを同じインデントで記載するのはちょっと書き方よくないですよね)

complications_06.png

引用:https://developer.apple.com/videos/play/tech-talks/208/

CLKFullColorImageProvider

Infograph Modular と Infograph の Watch face ではフルカラーの画像対応になった。
これらの画像はしばしば円形画像または角丸の画像を生成するためにマスクされることが多い。
画像サイズに関しては Apple Watch Human Interface Guidelines で確認。

CLKGaugeProvider

ゲージすべての一般的な動作を提供するスーパークラス。
このクラスのインスタンスを直接作成せず,
代わりに作成しようとしているゲージのタイプに基づいて
サブクラスのインスタンスを作成する。

CLKSimpleGaugeProvider

CLKGaugeProvider のサブクラス。
ゲージプロバイダはシンプルに 0.0〜1.0 の範囲にマップされた値を表示する。
例としては完了したタスクの割合や指定された温度範囲内の現在の温度の表示などが該当します。

CLKTimeIntervalGaugeProvider

CLKGaugeProvider のサブクラス。
指定された時間間隔内に経過した時間を視覚的に表示します。

新しい Watch face と Complications

新しい Watch face のどの部分が新しい Complication なのかを確認します。

Infograph Modular の場合

  • Graphic Circular (4つ)
左上 左下
IM_GraphicCircular_01.PNG IM_GraphicCircular_02.PNG
中央下 右下
IM_GraphicCircular_03.PNG IM_GraphicCircular_04.PNG
  • Graphic Rectangular (1つ)

中央の長形部分

IM_GraphicRectangular.PNG

Infograph

  • Graphic Corner (4つ)
左上 左下
GraphicCorner_01.PNG GraphicCorner_02.PNG
右下 右上
GraphicCorner_03.PNG GraphicCorner_04.PNG
  • Graphic Circular (3つ+1)
中央左 中央下 中央右
GraphicCircular_01.PNG GraphicCircular_02.PNG GraphicCircular_03.PNG
  • Graphic Bezel (1つ)

中央上部のテキスト部分と丸い部分(Graphic Circular)で構成

GraphicBezel.PNG

サンプル実装

今回やること

  • 新しい Complication Families に適当な値を代入して表示を見てみたい

今回やらないこと

  • iPhone と Apple Watch のアプリの実装とデータ連携
  • 実データを用いた実装

開発環境

  • Xcode 10 以上
  • watchOS 5 以上
  • macOS 10.13.6

サンプルコードは GitHub に上げましたので気になる方がいらっしゃいましたらご覧ください。

MilanistaDev/ComplicationsWatchSample
https://github.com/MilanistaDev/ComplicationsWatchSample

Apple Watch 対応 App にして Complication を使えるようにする

詳しくは多くの導入記事があるので省略します。
Complication を使えるようにするには大体下記の通りです。

新しい Target(WatchKit App) を追加し,
その際に Include Complication にチェックを入れます。

includeComplication.png

使いたい Complication にチェック入れます。

complicationconfig.png

Graphic Circular,Graphic Corner,Graphic Bezel,Graphic Rectangular を
それぞれ1つずつ実装しようと思います。
今回はあくまでも表示を見るだけなので時間による更新は考慮しないことにします。
よって CLKComplicationDataSource の下記のメソッドのみを実装します。

CLKComplicationDataSource.h
- (void)getCurrentTimelineEntryForComplication:(CLKComplication *)complication withHandler:(void(^)(CLKComplicationTimelineEntry * __nullable))handler;

Watch face の設定で各 Complication を選択する際の
サンプル値を設定する場合は Optional のメソッドを実装します。
実装は任意ですが,実装しなかった場合はアプリ名が表示されたりするだけで,
どういう情報を表示できるかわからないのでサンプル値など実装しておいた方がいいと感じました。

CLKComplicationDataSource.h
- (void)getLocalizableSampleTemplateForComplication:(CLKComplication *)complication withHandler:(void(^)(CLKComplicationTemplate * __nullable complicationTemplate))handler CLK_AVAILABLE_WATCHOS_IOS(3_0, 10_0);

Graphic Corner

今回は Gauge Text の実装をしました。
使う Template は CLKComplicationTemplateGraphicCornerGaugeText です。
gaugeProviderleadingTextProvider
trailingTextProviderouterTextProvider が必要です。

ゲージの左右に表示させるテキスト,ゲージの色,ゲージの外側に表示するテキストを設定する感じです。
個人的には,ゲージのグラデーションの出し方,現在の値の表示方法が気になっていました。

サンプルとして,Tech Talk でも紹介あった気温をイメージして
最低・最高気温をゲージの端にテキスト表示,
現在の気温をゲージ外のテキスト表示,
最低気温側のゲージをシアン,最高気温側をレッドにしました。

ComplicationController.swift
func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void) {
    switch complication.family {
    case .graphicCorner:
        // Infographのみ
        // gaugeProvider, leadingTextProvider, trailingTextProvider, outerTextProvider が必要
        let cornerTemplate = CLKComplicationTemplateGraphicCornerGaugeText()

        // leadingTextProviderの実装(ゲージの左側に表示するテキスト)
        let leadingText = CLKSimpleTextProvider(text: "9")
        leadingText.tintColor = .cyan
        cornerTemplate.leadingTextProvider = leadingText

        // trailingTextProviderの実装(ゲージの右側に表示するテキスト)
        let trailingText = CLKSimpleTextProvider(text: "24")
        trailingText.tintColor = .red
        cornerTemplate.trailingTextProvider = trailingText

        // outerTextProviderの実装(コーナーに表示するテキスト)
        let outerText = CLKSimpleTextProvider(text: "18")
        outerText.tintColor = .white
        cornerTemplate.outerTextProvider = outerText

        // gaugeProviderの実装
        // ゲージに使用する色
        let gaugeColors = [UIColor.cyan, UIColor.yellow, UIColor.red]
        // ゲージに使用する色の位置合い
        let gaugeColorLocations = [0.0, 0.5, 1.0]
        let gaugeProvider =
            CLKSimpleGaugeProvider(style: .ring,
                                   gaugeColors: gaugeColors,
                                   gaugeColorLocations: gaugeColorLocations as [NSNumber],
                                   fillFraction: 0.75)
        cornerTemplate.gaugeProvider = gaugeProvider

        // 用意したTemplateをセット
        let entry = CLKComplicationTimelineEntry(date: Date(),
                                                 complicationTemplate: cornerTemplate)
        handler(entry)
    default:
        handler(nil)
    }
}

実行して,Watch face に設定したら下記のようになります。

complication_10.png

Graphic Circular

今回は Closed Gauge Text を実装しました。
使う Template は CLKComplicationTemplateGraphicCircularClosedGaugeText です。
gaugeProvider, centerTextProvider が必要です。

サンプルとして,バッテリの残量みたいに
ある割合分ゲージが満たされているようなものにしました。
ゲージの色は東西線スカイブルー,センターの数字は 40 % にしてみました。

ComplicationController.swift
func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void) {
    switch complication.family {
    case .graphicCircular:
        // Infograph Modular, Infographのみ
        // circularTemplateの実装
        // gaugeProvider, centerTextProvider が必要
        let circularClosedGaugeTemplate = CLKComplicationTemplateGraphicCircularClosedGaugeText()

        // centerTextProviderの実装
        let centerText = CLKSimpleTextProvider(text: "40")
        centerText.tintColor = .white
        circularClosedGaugeTemplate.centerTextProvider = centerText

        // gaugeProviderの実装
        let gaugeColor = UIColor(red: 0.0, green: 167.0/255.0, blue: 219.0/255.0, alpha: 1.0)
        let gaugeProvider =
            CLKSimpleGaugeProvider(style: .fill,
                                   gaugeColor: gaugeColor,
                                   fillFraction: 0.4)
        circularClosedGaugeTemplate.gaugeProvider = gaugeProvider
        // 用意したTemplateをセット
        let entry = CLKComplicationTimelineEntry(date: Date(),
                                                 complicationTemplate: circularClosedGaugeTemplate)
        handler(entry)
    default:
        handler(nil)
    }
}

実行して,Watch face に設定したら下記のようになります。

complication_11.png

Graphic Bezel

丸みを帯びたテキストと部分と丸い部分を別々に作る感じになります。

使う Template は CLKComplicationTemplateGraphicBezelCircularText です。
先ほどの circularTemplatetextProvider が必要です。

丸い部分は,Open Gauge Text にしました。
Template は CLKComplicationTemplateGraphicCircularOpenGaugeSimpleText にしました。
gaugeProviderbottomTextProvidercenterTextProvider が必要です。

サンプルとして,丸みを帯びたテキスト部分にはアドベントカレンダーの7日目表示を,
丸い部分には,12/1-25までのゲージを想定し,7日目(25%くらい)であることを表示させます。

ComplicationController.swift
func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void) {
    switch complication.family {
    case .graphicBezel:
        // Infographのみ
        // circularTemplate, textProvider が必要
        let bezelCircularTemplate = CLKComplicationTemplateGraphicBezelCircularText()

        // textProviderの実装
        let bezelText = CLKSimpleTextProvider(text: "Qiita Advent Calendar 7 日目")
        bezelText.tintColor = .white
        bezelCircularTemplate.textProvider = bezelText

        // circularTemplateの実装
        // gaugeProvider, bottomTextProvider, centerTextProvider が必要
        let circularTemplate = CLKComplicationTemplateGraphicCircularOpenGaugeSimpleText()

        // bottomTextProviderの実装
        let bottomText = CLKSimpleTextProvider(text: "DEC")
        bottomText.tintColor = .white
        circularTemplate.bottomTextProvider = bottomText

        // centerTextProviderの実装
        let centerText = CLKSimpleTextProvider(text: "7")
        centerText.tintColor = .white
        circularTemplate.centerTextProvider = centerText

        // gaugeProviderの実装
        let gaugeColors = [UIColor.red, UIColor.yellow, UIColor.green]
        let gaugeColorLocations = [0.0, 0.4, 1.0]
        let gaugeProvider =
            CLKSimpleGaugeProvider(style: .ring,
                                   gaugeColors: gaugeColors,
                                   gaugeColorLocations: gaugeColorLocations as [NSNumber],
                                   fillFraction: 0.3)
        circularTemplate.gaugeProvider = gaugeProvider
        bezelCircularTemplate.circularTemplate = circularTemplate

        // 用意したTemplateをセット
        let entry = CLKComplicationTimelineEntry(date: Date(),
                                                 complicationTemplate: bezelCircularTemplate)
        handler(entry)
    default:
        handler(nil)
    }
}

実行して,Watch face に設定したら下記のようになります。

Complication_12.png

Graphic Rectangular

今回は Text Gauge を実装しました。
使う Template は CLKComplicationTemplateGraphicRectangularTextGauge です。
headerImageProvider(nil可),headerTextProvider
body1TextProvidergaugeProvider が必要です。

サンプルとして
私は今年の Qiita Advent Calendar の投稿は 2 つ行いますが,
全部で 2 件中残りの投稿はひとつという意味でゲージは 50% にします。
ヘッダのテキストカラーとゲージの色は Qiita のグリーンにしました。

ComplicationController.swift
func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void) {
    switch complication.family {
    case .graphicRectangular:
        // Infograph Modularのみ
        // headerImageProvider(nil可), headerTextProvider, body1TextProvider,  gaugeProviderが必要
        let rectangularTemplate = CLKComplicationTemplateGraphicRectangularTextGauge()

        // headerTextProviderを実装
        let headerText = CLKSimpleTextProvider(text: "Qiita 投稿")
        headerText.tintColor = UIColor(red: 116.0/255.0, green: 192.0/255.0, blue: 58.0/255.0, alpha: 1.0)
        rectangularTemplate.headerTextProvider = headerText

        // body1TextProviderを実装
        let bodyText = CLKSimpleTextProvider(text: "残タスク:1")
        bodyText.tintColor = .white
        rectangularTemplate.body1TextProvider = bodyText

        // gaugeProviderの実装
        let gaugeColor = UIColor(red: 116.0/255.0, green: 192.0/255.0, blue: 58.0/255.0, alpha: 1.0)
        let gaugeProvider =
            CLKSimpleGaugeProvider(style: .fill,
                                   gaugeColor: gaugeColor,
                                   fillFraction: 0.5)
        rectangularTemplate.gaugeProvider = gaugeProvider
        // 用意したTemplateをセット
        let entry = CLKComplicationTimelineEntry(date: Date(),
                                                 complicationTemplate: rectangularTemplate)
        handler(entry)
    default:
        handler(nil)
    }
}

実行して, Watch face に設定したら下記のようになります。

complication_13.png

今回作成した Complication たちをできる限り設定したら下記のようになりました。

complication_14.png

おわりに

今回は Apple Watch Series 4 で新しく追加された,
Complication Families の確認とそのサンプル実装をしてみました。
実機にうまくインストールできたりできなかったり不安定だったのが気がかりです。
途中で時間がもったいなかったのでシミュレータの方に切り替えました🤔

Complication 周りの実装もまだやったことなかったので雰囲気がつかめてよかったです。
今回はあまり頭が働かずで表示させる情報が考えつきませんでしたが,
より表現力が増した Complication を活かせるようにしたいです。

今後 Apple Watch 用のアプリ開発を個人開発以外で行う機会があるかわかりませんが,
しばらく触れてなかったので色々な復習も兼ねて一通り ClockKit を触ってみようかなと考えています。

ご覧いただきありがとうございました!

つい試したくなる? iOSアプリでオブジェクトトラッキング結果をリアルタイムでAR表示

こちらはiOS Advent Calendarの8日目の記事です。

はじめに

1年以上前くらいにTensorFlow利用でオブジェクトトラッキング+AR表示検証用アプリをこしらえたことがあり、リアルタイムで頑張ろうとすると3,4秒に1回しか画面が更新されないレベルのカックカクで、0.5秒に1回画面のキャプチャ読み込みするくらいじゃないと重たくて使い物にならなかった記憶。

今iOSで標準準備された機械学習のライブラリを使うと、どれだけ楽に同じ要件の代物を実現できるのか?
サンプルのアプリにちょっと手を加えて、ちょっとした電脳ハック感を体験してみましょう。

作るもの

  • カメラ映像を表示
  • 映像に機械学習済みのトラッキング対象が含まれていたら、該当の名前を表示
    • 名前はAR表示

手順

サンプルアプリのダウンロード

以下より。
Recognizing Objects in Live Capture | Apple Developer

カメラ映像を差し替える

AR表示するにはモーションセンサによる奥行きなどの情報も併せて取得している映像が必要なので、Visionで検知する先のカメラ映像を差し替えましょう。

AVFoundationを除去

これを、

ViewController.swift
import UIKit
import AVFoundation
import Vision

こうしましょう。継承先も同じく。

ViewController.swift
import UIKit
import SpriteKit
import ARKit
import Vision

AVFoundationから呼び出していたもろもろを除去

これを、

ViewController.swift
class ViewController: UIViewController, AVCaptureVideoDataOutputSampleBufferDelegate {

    var bufferSize: CGSize = .zero
    var rootLayer: CALayer! = nil

    @IBOutlet weak var previewView: UIView!
    private let session = AVCaptureSession()
    private var previewLayer: AVCaptureVideoPreviewLayer! = nil
    private let videoDataOutput = AVCaptureVideoDataOutput()

    private let videoDataOutputQueue = DispatchQueue(label: "VideoDataOutput", qos: .userInitiated, attributes: [], autoreleaseFrequency: .workItem)

こうします。

ViewController.swift
class ViewController: UIViewController, ARSKViewDelegate, ARSessionDelegate {

    var bufferSize: CGSize = .zero

    @IBOutlet weak private var previewView: ARSKView!

    private let videoDataOutputQueue = DispatchQueue(label: "VideoDataOutput", qos: .userInitiated, attributes: [], autoreleaseFrequency: .workItem)

なお、この際にMain.storyboardにてpreviewViewはUIViewからARSKViewへ差し替える必要があるので、もとのPreview Viewは1回削除してARSKViewを追加し、IBOutletの関連付けをし直します。
そうすると、以下のようになります。

スクリーンショット 2018-12-07 8.38.01.png

エラーをつぶす

setupAVCapture() 周辺にて怒られ始めると思うので、以下のようにARSKViewのセットアップもろもろの処理に差し替える。

ViewController.swift
    func setupAVCapture() {
        previewView.delegate = self
        previewView.session.delegate = self
    }

    func startCaptureSession() {
        let configuration = ARWorldTrackingConfiguration()
        previewView.session.run(configuration)
    }

不要なメソッド削除

teardownAVCapture() はAVFoundation使用時のカメラ映像で逼迫するメモリ解放用メソッドなので削除。
また、captureOutput(_:didOutput:from:)メソッドはARSKViewにて同等の役割を果たせるデリゲートメソッドに差し替える形に実装し直すので、ばっさり削除。

そして、継承先のVisionObjectRecognitionViewControllerにてrootLayer削除で怒られてる箇所であるsetupLayers()メソッドについても、そもそも描画先がCALayerではなくなるのでこちらも削除。

使用するデリゲートメソッドを準備

ViewControllerへ以下を追記して、のちほど継承先で中身を実装。

ViewController.swift
    // MARK: - ARSKViewDelegate, ARSessionDelegate

    func view(_ view: ARSKView, nodeFor anchor: ARAnchor) -> SKNode? {
        return nil
    }

    func session(_ session: ARSession, didUpdate frame: ARFrame) {
    }

オブジェクトトラッキングを実行するデリゲートメソッドを実装

まず、使用するプロパティを定義。

VisionObjectRecognitionViewController.swift
    private var currentBuffer: CVPixelBuffer?
    private var requests = [VNRequest]()

映像が更新されるたびに呼ばれるsession(_:didUpdate:)のメソッドで、以下のように実装。

VisionObjectRecognitionViewController.swift
    override func session(_ session: ARSession, didUpdate frame: ARFrame) {
        guard currentBuffer == nil, case .normal = frame.camera.trackingState else {
            return
        }
        self.currentBuffer = frame.capturedImage
        let exifOrientation = exifOrientationFromDeviceOrientation()
        let imageRequestHandler = VNImageRequestHandler(cvPixelBuffer: currentBuffer!, orientation: exifOrientation, options: [:])
        do {
            defer { self.currentBuffer = nil }
            try imageRequestHandler.perform(self.requests)
        } catch {
            print(error)
        }
    }

VNImageRequestHandler(cvPixelBuffer: currentBuffer!, orientation: exifOrientation, options: [:])にてその瞬間のキャプチャと画面向きを設定して、try imageRequestHandler.perform(self.requests)であらかじめ機械学習済みのモデル(サンプルアプリでいうところのObjectDetector.mlmodelファイルの情報)と比較。
上記の記述のまま実行するとリアルタイムで検知し続ける。

ちなみに、いずれかのモデルと一致するものが含まれていた際に結果が返ってくるのはsetupVision()メソッド内の実装から察せられる通りdrawVisionRequestResults(_:)

トラッキング対象を検知したときに描画するレイヤーを差し替える

検知したモデルの名前を画面に表示するためには、CALayerではなく、SKNodeを描画する。

不要なプロパティ削除

VisionObjectRecognitionViewControllerのdetectionOverlayプロパティは削除。前の手順の方で不要なメソッドを削除し切っていたらdrawVisionRequestResults(_:)でのみエラーが発生するが、このタイミングでいったん放置。

SpriteKitで描画する準備

SKNodeを描画するための準備として、ViewControllerのsetupAVCapture()メソッドを以下のように修正。

ViewController.swift
   func setupAVCapture() {
        let overlayScene = SKScene()
        overlayScene.scaleMode = .aspectFill
        previewView.presentScene(overlayScene)
        previewView.delegate = self
        previewView.session.delegate = self
    }

新しい描画処理を実装

使用するプロパティを定義。

VisionObjectRecognitionViewController.swift
    private var currentAnchor: ARAnchor?
    private var anchorLabels = [UUID: String]()

そして、映像の中に登録済みのモデルが含まれていた場合に実行されるdrawVisionRequestResults(_:)にて、ラベルの描画先となるARAnchorを設定する。

VisionObjectRecognitionViewController.swift
    func drawVisionRequestResults(_ results: [Any]) {
        for observation in results where observation is VNRecognizedObjectObservation {
            guard let objectObservation = results.first as? VNRecognizedObjectObservation else {
                continue
            }
            let hitTestResults = previewView.hitTest(
                previewView.center, types: [.featurePoint, .estimatedHorizontalPlane])
            if let result = hitTestResults.first {
                let anchor = ARAnchor(transform: result.worldTransform)
                if let currentAnchor = self.currentAnchor {
                    previewView.session.remove(anchor: currentAnchor)
                }
                previewView.session.add(anchor: anchor)
                currentAnchor = anchor

                let topLabelObservation = objectObservation.labels[0]
                anchorLabels[anchor.identifier] = topLabelObservation.identifier
                return
            }
        }
    }

今回は画面内に1個、とりあえず画面の中心へ表示する想定。

描画するデリゲートメソッドを実装する

前述でpreviewView.sessionにARAnchorを追加するとview(_:nodeFor:)が実行されるので、描画したい文字列をSKNodeインスタンスで返す。

VisionObjectRecognitionViewController.swift
    override func view(_ view: ARSKView, nodeFor anchor: ARAnchor) -> SKNode? {
        guard let labelText = anchorLabels[anchor.identifier] else {
            fatalError("missing expected associated label for anchor")
        }
        let label = SKLabelNode(fontNamed: "Chalkduster")
        label.text = labelText
        label.fontColor = SKColor.black
        label.horizontalAlignmentMode = .center
        label.verticalAlignmentMode = .center
        label.zPosition = 1
        label.fontSize = 10
        return label
    }

zPositionは親Node(previewView)より手前に表示してね、という設定。
フォントの色やサイズなどはお好みで。

できあがったものと感想

IMG_0082.png

キャプチャは検知時の画像。
iPhoneXですが、動かしてみるとARでリアルタイム検知はやっぱりカクつきます。。
通常のカメラ映像(サンプルに手を加えない状態)だとぬるぬる動いてたので、AR表示の負荷が非常に高いだけでTensolFlowで通常のカメラ映像だと同じような結果になるかも・・・?
検知頻度はゆるやかにしたほうがよさげですが、無論実装コストはCore ML+Visionが圧勝だと思います。

ネイティブアプリ上での機械学習モデルの利用、本当にとっつきやすくなったなぁと今更ながら関心する機会となりました。

参考

タップしたら検知したオブジェクトの名前をAR表示してくれる公式サンプル(こちらを加工したほうが楽だったのではないだろうか)
Using Vision in Real Time with ARKit

SKLabelNode - SpriteKit | Apple Developer

いろんなモデルをてっとり早く試したい!って場合はここに並んでるサンプルアプリから引っこ抜くと良さそう
Vision | Apple Developer

自分で検知したいモデルを作りたい!って方はこちらの記事がとても分かりやすいです
[iOS 11] Core MLで焼き鳥を機械学習させてみた

さいごに

Core ML+Visionでオブジェクトトラッキングするまでの辺りも自分で作ってみるつもりだったんですが、ちゃんと調べてみたらすでにAppleさんがたいへんよくできたものを準備してくれたいたので路線変更しました。。
個人的にはAndroidよりか何かと開発者に不親切なイメージなんですが、案外見るべきところ見落としてるだけかしらと少し反省。

みなさまも是非是非、使えるものはフル活用して快適にアプリケーション開発をやっていきましょう!

アプリのかんたんな作り方

林です。

アプリの開発を続けていると、今の自分を苦しめるのは、過去の自分が書いた当時の「最高の実装」だということに気がつきます。なんでこんなところを複雑に書いてしまったのだという後悔。
未来は予測できないという原則を忘れて、かえって問題を複雑にしてしまうのは、人間の性なのでしょうか。

かんたんな実装は本質的なので、変化に強く、品質も担保しやすい。
分かってはいるけど、かんたんに作るのは難しく、かんたんを保つのはさらに難しい。
iOSアプリをかんたんに作るにはどうすれば良いのでしょう?

壊しやすく、作り直しやすく

未来は予測できず、問題は起こるまで分からない。となれば、壊しやすく変更しやすく設計するのが筋というものです。
アプリの大半はViewで、A/Bテストもあれば、要件もよく変わります。
画面はStoryboardAutoLayoutで、サクッと作ってサクッと捨てるくらいがちょうど良い。

BaseViewControllerや高機能なライブラリ・実装は、深く依存してしまって捨てにくいのでやめましょう。

スタイルガイド・UIコンポーネントは作り込む

スタイルガイド・UIコンポーネントはアプリのアイデンティティです。
ここをStoryboard/AutoLayoutと連携しやすいように作り込むことで、アプリの一貫性を保ったままで画面のスクラップ&ビルドが捗ります

WWDCのセッションと、Appleの公式ドキュメントを読みましょう。
UIKitをしっかり把握すると、UIコンポーネントの実装はそれほど難しくありません。

UI系のライブラリを使うのはあまりおすすめできません。

Easy < Simple

可読性のためと思って作ったEasyな実装が、問題の原因になることがよくあります。

  • 本来不要なプロトコル
  • 普通の関数で良いのにExtensionComputed property
  • プロトコルとジェネリクスが組み合わさった、Swiftyな何か

見た目をよくするために作った実装が、かえって見通しを下げることにつながります。

あるいは、自分が技術をよく理解できないために導入したEasyなWrapperライブラリ。
ある時、問題が起きてデバッグし、内部実装を読んで挙動を理解した頃には、そんなライブラリなんか必要ないことに気がつきます。

結局のところ、ソフトウェア全体の複雑度を下げること(=Simple)が一番Easyだったりします。

凝りすぎない

正しい方向に力を入れることができれば、その実装はアプリの強みになることでしょう。
一方で、間違った方向に凝ってしまった実装は、その後長い間チームを悩ませることになります。

未来は予測できないという原則に立ち返ると、開発初期に頑張ってしまった実装は、つまり長い間チームを悩ませる頭痛の種となることになります。

先回りせず、問題が起きた時に最小限の解決方法で対処するのが、プロジェクトをかんたんに保つコツです。

たいていの場合、MVCで十分

ある程度の複雑度まではMVCでも十分に品質を保てます。
iOS SDKはMVCを前提に作られていて、アプリ開発の初期に適切な別のアーキテクチャを選択し、正しく実装するのは難しいものです。

エンジニアにとっては残念なことに、大抵のアプリはユーザーに使われずに消えていきます。
アーキテクチャではなくユーザーの方を見ましょう。

アプリが商業的にも成功すれば、規模も大きくなり、チームのメンバーも増えて、アーキテクチャについて考える余裕もできます。

データの流れは一方向に

データが複雑に絡み合うと、人間には予想もつかない挙動になってしまいます。
データの流れを一方向に保つことは、変更に強く、処理を追いやすい実装に繋がります。

例えば、ViewがViewに依存する処理は、ありがちですが思わぬ不具合の温床になります。

button.isEnabled =  uiSwitch.isOn

突き詰めてデータの流れの各ステップに名前をつけると「アーキテクチャ」と呼ばれるものになるのですが、問題は起きるまで解決しないのが信条ですから、まずはデータの流れを一方向に保つという原則だけを徹底するくらいが良いのではないでしょうか。

個人アプリ開発を支える技術と開発フロー

iOS Advent Calendar 2018 の 10 日目です。

アプリをいくつかリリースしたり、ハッカソンでアプリを作ってきた中で個人的に定石となってきた開発フローや使っているツールなどをざっくりと時系列順で紹介します。

企画・アイデア

日頃から、何気なくアイデアを考えたりしています。「これ不便だな」と思ったら、どんなツールがあれば良くなるんだろうと考えてアプリのアイデアにしたり、Twitter などで面白い技術を使った動画を見つけたら、「これって他にも応用できないかな」と考えたりしています。

アイデアを考えているだけでは 3 日後には忘れてしまうので、メモをしておきます。
自分がよく使っているのは TrelloSimplenote です。

Trello でボードを作り、ジャンル (ユーティリティ、ゲームなど) ごとにリストを作って、アイデアのコア部分をカードにメモしています。
カードには詳細説明を書いたり、画像を添付したりすることもできるので、思いついたことを全部書いておきます。

Simplenote は雑にメモしたいときに結構便利で、Trello でカード化するほどまとまってなかったりするものはとりあえずここに書いておきます。
自分は主に Mac、 iPhone、iPad、 Windows を使っているので、今使ってる環境でメモをしてすぐに同期ができるのが快適です。(地味に Web で共有する機能や、バージョン管理機能があるのも役立ちます)

アプリのアイデア以外にも LT のネタや勉強会やカンファレンスのメモなどにも使っていてかなりごちゃごちゃですが、タグと全文検索機能があるので、なんとかなってます。
どーでも良いですが、この記事も Simplenote で書きました。(使い勝手は微妙ですがマークダウンもサポートされてます)
(無料のサービスであるのと、自分は数年使っていて起きたことがないですがデータが消えたというレビューを見たこともあるので、大切なことは書いてないです。)

ハッカソンのときは、マインドマップツール (XMind など) を使って無理矢理にアイデアを引き出したりもしてます。

デザイン

デザイナーさんにお願いできるときはお願いします。そのほうが絶対に良いものが出来ます。

自分でデザインをする必要があるときは Sketch を使っています。
いろいろな人がテンプレートや UI を提供してくれているので利用します。

Sketch で作るほどの気力がない時は iPad Pro と Apple Pencil でざっくりとしたデザインや画面のフローを考えたりもしています。
紙と鉛筆では書き直すのが大変ですが、iPad Pro ならボタンの位置を変えたり、画面のフローを整理するのも簡単です。

おすすめの iPad アプリ
- Procreate: 雑にスケッチするのに良い (可愛いキャラの絵を描きたくなるのがデメリット)
- Vectornator: UI パーツが用意されていたり、Icons 8 からアイコンを検索する機能があるのが良い
- Affinity Designer: (まだ使ってないのでわからないですがドロー系の中ではかなり良さそう)

なかなかしっくり来るデザインが思い浮かばないときは App Store でひたすらアプリを落として触ってみたり、PinterestDribbble で アプリ UI デザインを眺めたりしています。

ボタンアイコンなどの素材は flaticonicons8 などから良いものを探したり、Unity でもアプリを作っているので Unity Asset Store で購入した素材を使ったりもしています。

アプリのアイコンは Sketch なら簡単に必要なサイズを Export できますが、大きい画像 1 枚しかない時は appiconmaker で自動でリサイズして生成したりしています。

バックエンドの準備

iOS のアドベントカレンダーなので省略しますが、サーバレスにできるアプリならできるだけそうします。運用が面倒なので...
最近は Firebase にほとんど頼っています。

アプリ初期プロジェクトを利用する

ここからはアプリの開発的な話です。

すばやく開発をスタートするために良い方法は、いつも使うようなものを事前に入れておいた初期プロジェクトを作っておいて、それを clone して開発を始めるという方法です。
モチベーションが高い間に、すぐにコアの部分の開発に取り掛かれて良いです。また、ハッカソンでは時間短縮になります。

自分がよく使っている初期プロジェクトはこちらで公開していますので、使いたい方はどうぞ。

https://github.com/tattn/HackathonStarter

よくある機能をコンポーネント化してすぐに利用できるようにする

課金、カメラ、カメラロールへのアクセス、プッシュ通知などコンポーネント化出来るよく使う機能は、一度作ったら切り出して使える状態にしておくと良いです。
もちろん、自分で作ったものだけでなく、公開されているライブラリなどを利用してもよいです。

自分はこのようなライブラリをよく使っています。

ライブラリ 機能 メモ
Alamofire 通信処理を簡単に扱える
Reachability.swift ネットワークに繋がっているかなどの判定を簡単に扱える
Result API のレスポンスなどのハンドリングがわかりやすくなる 今後標準化されるため要らなくなる
Kingfisher 画像フェッチライブラリ Nuke もおすすめ
SwiftGen リソースの定数などを自動生成してくれる R.Swift もおすすめ
LicensePlist ライブラリのライセンス表示を自動生成してくれる
RxSwift リアクティブプログラミングフレームワーク
SVProgressHUD 通信中のぐるぐる
KeychainAccess Keychain の操作が簡単にできる
SwiftDate 日付の操作が簡単にできる
SwiftyUserDefaults UserDefaults を Swift らしく扱える
SwiftyStoreKit 課金実装を簡単に扱える
SwiftLocation 位置情報を簡単に扱える
SwiftyXMLParser XMLを簡単に扱える
SwiftExtensions Swift に便利な機能を追加できる
Realm アプリ内データベース
Firebase アナリティクスやプッシュ通知、データベース、クラッシュレポートなど

UI 系は Cocoa Controls でアプリにあったものを探すのがおすすめです。

ライブラリはラップして使う

ライブラリを使う際は出来るだけインターフェースを直接利用せず、ラップして使うようにしています。

import Kingfisher

extension UIImageView {
    func setImage(with url: URL) {
        kf.setImage(with: url)
    }
}

このようにすることで、ライブラリの更新でインターフェースが変わっても変更箇所を少なくでき、ライブラリを入れ替えることになった時にも簡単に差し替えができます。

API のレスポンス用の型を簡単に用意する

JSON の API を利用する場合は Swift の Codable でレスポンスの型を作るのがオススメです。
自前で型を用意するのが面倒な時は JSON から型を自動生成してくれるツールを使います。

このツールがオススメです。
https://quicktype.io/

ブラウザで API を叩いて、結果をコピペするだけで型を作ってくれます。

多言語対応

作ったアプリをより多くの人に触ってもらうためには多言語対応はとても大事です。
iOS の場合は Localizable.string を使いますが、もし同様のデザインの Android 版 も作る場合は、一旦、スプレッドシート にまとめておくと、対応が楽になります。

また、googletranslate 関数や Google Apps Script の LanguageApp の機能を使うことで一括で多言語への機械翻訳も出来ます。
Gengo などの翻訳サービスを使う場合もエクセルの形式でエクスポートしたものをそのまま渡せたりするので便利です。

表を作る際はスクリプトで処理しやすそうなフォーマットを意識すると良いです。
そうすることで、iOS の Localizable.strings ファイルや Android の strings.xml をスクリプトで自動生成することもできるようになります。下記のサイトが参考になります。

iOS と Androidで共通のローカライズファイルを作ろう! (iOS Advent Calendar 2016) | DevelopersIO

Apple の審査を突破するための実装を入れる

アプリの本質的な機能が実装できたら、次は Apple の審査を突破するために必要な機能を実装します。
例えば、True Depth API を使う場合はプライバシーポリシーに収集したデータをどのように利用するかなどを記載する必要があったり、アプリ内課金がある場合は課金情報の復元 (Restore Purchase) 機能が必要だったりします。
UGC (ユーザー生成コンテンツ) がある場合は結構注意事項が多いです。

Apple が日本語でもガイドラインを提供しているので、読んでおくことが大切です。
App Store Reviewガイドライン - Apple Developer

個人アプリでプライバシーポリシーを用意するのは大変ですが、プライバシーポリシーを生成してくれるサービスもいくつかあるので、自己責任ですが利用して、生成されたものを必要に応じて書き換えて使用するのも良いかもしれません。

App Privacy Policy Generator

プライバシーポリシーの公開場所に迷う場合はとりあえず、GitHub Pages に置いておくのが楽でおすすめです。

また、アプリ内にお問い合わせ機能も必要です。 (ガイドライン 1.5)
お問い合わせ機能には Google Form がおすすめです。簡単に公開でき、URL パラメータで記入欄の自動入力をすることもできるので便利です。

今後必要になる情報を保存しておく

忘れがちですが、今後のアプリの更新で必要になるかもしれない情報を UserDefaults などに保存しておくと良いです。
例えば、初回起動日時、起動回数、最後に起動したアプリのバージョン、などです。
起動回数によって特別な画面を出したり、アプリ内に保存しているデータのマイグレーションが必要になった際の対応が楽になります。

申請準備

アプリの実装が終わったら、次は申請の準備です。

プロファイルなどの設定

Apple Developer ポータル で、公開用のアプリの AppID や Provisioning Profile を作成します。
最近は Xcode がいろいろと自動でやってくれますが、予期せぬ設定をされてしまうこともあるので、調べながら手動で設定したほうが良いかもしれません。

ストア設定

App Store Connect (以下、ASC) で公開に必要な情報を入力します。
大変ですが、多言語対応しておくと良いです。

ストア用のスクショは fastlane の frameit で書き出すようにすると自動化出来て楽です。
こだわりたいときは Sketch などで作ります。

課金のテストをするためには、ASC でアプリ内課金の設定をする必要があります。値段や説明、審査用の画像 (スクショなど) を設定してアプリの審査よりも先に審査をしてもらいます。
一日くらいで承認済みになり、課金のテストができるようになります。

ASC 内のバージョンのリリースというセクションで「このバージョンを手動でリリースする」という設定項目があります。念の為、手動の方にチェックを入れておくと良いです。
手動にしておくと、アプリの審査が通った後に (時間差はありますが) 自分で公開タイミングを決めることができます。運良く公開前に自分でバグを見つけたときは、そのビルドの公開をキャンセルし、修正後のビルドで再度審査に出すことが出来ます。

ビルドのアップロード

fastlane でビルドのアップロードをできるようにするのをおすすめします。
一度設定をしてしまえば、今後のアップデート時にコマンド一つでアーカイブビルドやアップロードができるようになり非常に楽です。

ストア文言やスクショも fastlane で更新することが出来ます。最初は ASC 上で設定をし、その後 $ fastlane deliver download_metadata$ fastlane deliver download_screenshots コマンドを実行し、設定をファイル化すると安全です。
生成されたファイルは Git にコミットしましょう。コミットしておくと何らかのミスで設定が消えてしまったり、過去の設定を見たくなった際に便利です。

TestFlight

ビルドをアップロードすると TestFlight でアプリを配布できるようになります。パブリックリンクという機能を使うと簡単に自分以外の人がインストールできるリンクを作ることができるので関係者に配布してテストしてもらいましょう。

TestFlight ではテスト課金も可能なため、検証しておきましょう。

申請

テストが完了したら、ついに申請です。
最近は初回のアプリの審査の場合でも 1 日 〜 3 日で終わります。待ちましょう。

イベント用のアプリなどで公開期日が決まっている場合やアップデートしたアプリに致命的なバグが有る場合は、特急申請 (Expedited App Review) をすることで優先的に審査をお願いすることも出来ます。
自分はまだ来たことがないですが、使いすぎると Apple から警告が来るようなので適切に判断をして利用しましょう。

リジェクト

1 発で審査を突破するのはなかなか難しいです。初回はリジェクトされる覚悟で申請しましょう。

リジェクトされた場合は何らかの対応が必要です。
バグの場合は、手順やクラッシュログ、スクショなどが送られてくるのでそれを見て対応します。iPhone アプリなのに iPad でチェックされてクラッシュ報告が来ることもあるので、ユニバーサルアプリでなくても iPad でも動くような実装にしておいたほうが良いです。(ガイドラインの 2.4.1 項)

Apple のレビュアーも人間なので、アプリの挙動や UI を理解してもらえないこともあります (特に日本語のアプリの場合)。
その場合は 問題解決センター (Resolution Center) で返信をしてレビュアーと認識を合わせたり、App Review Board (App 審査委員会) に対して異議申し立てを Contact からしたりします。
そこで解決できれば、ビルドの再アップロードなしに審査を通してもらえることもありました。
1 日 1 通くらいのやりとりになるのでできるだけわかりやすくメッセージを送るようにし、気長に待ちましょう。

Bitrise + DeployGate

fastlane はローカル環境で実行してもよいのですが、公開したアプリを今後も継続的にアップデートしていきたい場合は、CI/CD 環境を整えておくと楽になります。

CI/CD 環境としては無料でお試しができ、綺麗な UI で機能が充実している Bitrise がおすすめです。
また、テストビルドのデプロイ先としては DeployGate がおすすめです。TestFlight よりもすばやく配布ができるため、検証のサイクルを早く出来ます。

長く運用していくアプリの開発ではこのような部分の整備が品質や開発スピードの向上のために非常に大事になります。
1 年前の記事ですが、興味がある方は、こちらに書いた記事も読んでみてください。

乗換案内アプリのCI/CDの取り組みについてのご紹介 - Yahoo! JAPAN Tech Blog

Slack に通知を集約

アプリの公開後はユーザーからどのような反応があったかをチェックしたいです。
いろいろな場所を見に行くのは大変なので Slack にできるだけ集約させています。
Google Apps Script を使ってアプリのレビューを流したり、お問い合わせメールを転送したり、Twitter のエゴサ結果を流したり、などいろいろ活用できます。

まとめ

iOS アプリを公開するためには開発以外にもいろいろと作業が必要で大変ですが、アプリ開発ができてアイデアもある人は、趣味でのアプリリリースにチャレンジしてみてはどうでしょうか?
ダラダラとやっていると エターナってしまう ので、期限を決めたりして短期決戦で臨むのがおすすめです。

また、個人アプリのメンテナンスは大変なので、メンテンナンスしやすい実装にしておくことも大事です。
この点に関しては Swift Advent Calendar 2018 に投稿した、Better Swift に Tips が詰まっているので、興味がある方は見てください。

以上、iOS Advent Calendar 2018 の 10 日目でした。

Protocolを考える。

External article

TraitとBar周りの英単語の語彙が乏しいのでドキュメント読み直した

External article

ReactorKit と apollo-ios を少し効率的に併用する方法

はじめに

iOS Advent Calendar 2018、13日目です。

個人開発のアプリを改修しようと思い、最近気に入ってる設計のFluxを取り入れつつ、GraphQL API を使用したいことから、 ReactorKitapollo-ios を使って開発をしています。

少し、ニッチな話になってしまいますが、ReactorKitapollo-ios を少し効率的に併用する方法について考えたことを紹介します。

ReactorKit と distinctUntilChanged()

ReactorKitでは、stateの一部が変更されただけでも、state全部が流れてくるので、適切にdistinctUntilChanged()を呼ぶ必要があります。

reactor.state.map { $0.elements }
    .distinctUntilChanged()
    .subscribe(onNext: { _ in
         //
    })
    .disposed(by: disposeBag)

distinctUntilChanged()を呼ぶためには流れてくる要素がEquatableに準拠している必要があります。

流れてくる要素が自分で用意した構造体なのであれば、

struct Element: Equatable {
   //
}

↑のようにするだけで, Swift4.1からは暗黙的に==が実装されます。

apollo-ios と Equatable

しかし、今回は、apollo-iosによって自動で生成された構造体が流れてくる場合がほとんどでした。

extension GeneratedObject: Equatable {
    public static func == (lhs: Element, rhs: Element) -> Bool {
        //
    }
}

extensionEquatableを適応させるときには、暗黙的に==が実装されないので、毎回自分で実装を書かなくてはなりません。
毎回、Equatableを意識しなければいけないのは、少し面倒くさいのでもうちょっと効率よくしていきたいところです。

apollo-iosによって生成された構造体は、GraphQLSelectionSetに準拠しています。
GraphQLSelectionSetは 通信で取得した情報を格納したDictionary型のresultMap を持っているので、これを比較すれば値に変更があったかどうか判断することができそうです。

つまり、次のように書くことができれば、解決できそうです。

extension GraphQLSelectionSet: Equatable {
    public static func == (lhs: Element, rhs: Element) -> Bool {
        return lhs.resultMap == rhs.resultMap
    }
}

しかし、GraphQLSelectionSet はProtocolなので、このように書くことはできません。

GraphQLSelectionSetdistinctUntilChanged() に対応させる

そこでObservableTypeの中身がGraphQLSelectionSetのときにのみ適応されるdistinctUntilChanged()を作ってしまう手があります。

extension ObservableType where E: GraphQLSelectionSet {
    func distinctUntilChanged() -> Observable<Self.E> {
        return distinctUntilChanged { $0.resultMap == $1.resultMap }
    }
}

これによって、中身が何であってもapollo-iosによって自動生成された構造体であればdistinctUntilChanged()を呼ぶことが可能になりました。

[GraphQLSelectionSet] の対応

しかし、これですべて解決かと思いきや、これだけで終わると、[GraphQLSelectionSet]に対応できていません。

先ほどと同じように、[GraphQLSelectionSet] 用の distinctUntilChanged() を作成すれば一見解決するように思えますが、結局[[GraphQLSelectionSet]] のような場合に、また同じ問題にぶつかってしまいます。

Equatableの場合は、Swift4.2から、要素がEquatbleに準拠しているときは[Equtable]Equatbleに準拠するようになりました。(Conditional conformances)

少し強引な気もしますが、同じように要素がGraphQLSelectionSetの場合、[GraphQLSelectionSet]GraphQLSelectionSetに準拠するようにすれば、配列であっても対応することができそうです。

extension Array: GraphQLSelectionSet where Element: GraphQLSelectionSet {
    public init(unsafeResultMap: ResultMap) {
        let resultMaps = unsafeResultMap["resultMaps"] as! [ResultMap]
        let sets = resultMaps.map(Element.init)
        self = sets
    }

    public static var selections: [GraphQLSelection] {
        return []
    }

    public var resultMap: ResultMap {
        return ["resultMaps": self.map { $0.resultMap }]
    }
}

これによって、apollo-iosによって生成された構造体をdistinctUntilChanged()したいときに、毎回Equatableを気にする必要がなくなりました!

最後に

相性が微妙なのであれば最初からReactorKit を使わなければいいのでは.. という思いもありましたが、自分でFluxの機構を作ろうとすると色々と大変なことがありました。

もっと良さそうな解決方法がありましたら、ぜひ教えてください。

画像を回転させる処理についてまとめます

External article

エモいTableViewを作るチャレンジ

ちゃちゃっす!iOS Advent Calendar 2018の15日目担当、株式会社ゆめみ新卒iOSエンジニアの山田です!

入社9ヶ月ほど経って色々経験したので、今まで使ったライブラリや技術を利用して出来るだけエモいTableViewを作ってみようチャレンジでした!

できたもの

スクリーンショット 2018-12-15 6.07.12.png

スクリーンショット 2018-12-15 6.04.05.png

ぜひ、実機で動かしてみてください!

何を作ったか

iTunesからトップ映画のxmlを取得してきてリスト表示

作成期間

8時間!

ここがエモい!

スケルトン表示

このライブラリすっごい便利じゃないっすか!!??
入れて見せておくだけでオシャンティになります。

スクリーンショット 2018-12-15 7.06.32.png

(これが見せたいがためにTableViewのリロード三秒止めました😇)

離した時のアニメーション

dumpingアニメーション使っているので、バネっぽく元に戻ります!
気持ちよくないすか!!??
チームの先輩に教えてもらったのですが、すっかり虜になりました。

ScreenRecording_12-15-2018-07-08-38.gif

haptics

最近ハプティクスの使い所を探っているので、使ってみました。
プレスした状態から指を離した時にブルん!ってなります!

気持ちいい!!!
(こちらは触ってみないと分からないので見せられなかったです...)

利用した技術/ハマりどころ

SkeletonView

Facebookが全く同じようなものを利用していますが、主に非同期通信時の読み込み中に表示しておくViewです。

基本的に簡単なのですが、微妙に使い方にクセがあるので慣れが必要です。
UIView, UITableView, UICollectionView のいずれも対応されています。

dumpingアニメーション

UIViewの標準アニメーションを利用して、以下のように実装しました。

animation.swift
private func shrink() {
    let animationScale: CGFloat = 0.90
    UIView.animate(withDuration: 0.1) { [unowned self] in
        self.transform = .init(scaleX: animationScale,
                               y: animationScale)
    }
}

private func expand() {
    let animationScale: CGFloat = 1.10
    UIView.animate(withDuration: 0.1) { [unowned self] in
        self.transform = .init(scaleX: animationScale,
                               y: animationScale)
    }
}

private func restore() {
    UIView.animate(withDuration: 0.1,
                   delay: 0,
                   usingSpringWithDamping: 0.7,
                   initialSpringVelocity: 1,
                   options: [],
                   animations: { [unowned self] in
                    self.transform = CGAffineTransform.identity
    })
}

haptics

こちら、実装は極めて簡単で10行程度で実現できてしまいます。

注意点というのが一つだけあって、フィードバックの発生に遅延が出てしまう可能性があるらしく、処理の数秒前に feedbackGenerator.prepare() を呼ぶのをAppleが推奨しています。

該当部分抜粋してGoogle翻訳先生にお聞きしました。

ジェネレーターを準備することで、フィードバックをトリガーするときの待ち時間を短縮できます。これは、音や映像の合図にフィードバックを一致させるときに特に重要です。
ジェネレータのprepare()メソッドを呼び出すと、Taptic Engineが準備された状態になります。パワーを維持するために、Taptic Engineは短時間(数秒のオーダー)の間、または次回のフィードバックをトリガーするまでこの状態にとどまります。
いつ、どこで発電機を準備するのが最も良いか考えてみましょう。 prepare()を呼び出してすぐにフィードバックをトリガーすると、システムはTaptic Engineを準備状態にするのに十分な時間がなくなり、待ち時間が短縮されることはありません。一方、prepare()をあまりに早く呼び出すと、フィードバックをトリガする前にTaptic Engineが再びアイドル状態になることがあります。

haptics.swift
    private let feedbackGenerator = UIImpactFeedbackGenerator(style: .light)

...

extension FloatingSkeletonTableViewController: FloatingSkeletonTableViewCellDelegate {

    func cellWillStartAnimating() {
        feedbackGenerator.prepare()
    }

    func cellWillEndAnimating() {
        feedbackGenerator.impactOccurred()
    }

}

UITableViewのセルの間のすきま

こんな感じのハックみたいな解決法をよく見かけたのですが、セクションを使いたくなった瞬間死ぬので使いませんでした。
https://stackoverflow.com/questions/6216839/how-to-add-spacing-between-uitableviewcell

  • UITableViewCellのContentViewの上にさらに floatingView というのを乗せて上下左右10ptずつ内側に制約を付ける。
  • floatingView は白背景に、その他背面ViewのbackgroundColorは全て .clear にする。(青色のViewを除く)という対応にしました。

スクリーンショット 2018-12-15 6.59.50.png

やりきれなかったところ

前半は勢いで書きましたが、本来hapticsの利用には最新の注意を払う必要があり、なにかユーザーに注意を促したい時や変化がある時以外は原則使わない方が良いです。

アニメーション+haptics feedbackで状態の変化を表現しようと思ったのですが(トップ画像が入れ替わるなど)残念ながらタイムオーバー。

TableViewCellが自分でリサイズとか、細かい詰めるところは全部すっ飛ばしました。

あとがき

ここまで読んでいただきありがとうございました!
レポジトリは公開しているので興味があれば実機で動かしてみてください!
(プルリクもお待ちしております!)

https://github.com/syatyo/FloatingSkeletonTableView

HealthKitで気をつけるポイント

iOS Advent Calendarの16日目を担当する、あすけんの@sato-shinです。
今日はHealthKitの気をつけるポイントについてご紹介します。

はじめに

みなさん、ヘルスケア App は利用していますか?食事や運動、バイタルデータを記録・閲覧できるハートアイコンのあれです。
Safariなどと同様にiPhoneにプリインされ、削除することのできないアプリなので、iPhoneユーザなら目にしたことのあると思います。

このアプリで管理しているデータを読込・書込を行うのがHealthKitです。
弊社では、ヘルスケアサービスである「あすけん」を開発しているので、HealthKitを利用することが多々あります。
この記事では私がHealthKitを利用する機能を作る上で、気をつけるポイントを紹介します。

HealthKitの利用を開始する

Setting Up HealthKitに詳しく書いていますので、そちらも参照ください。

HealthKitにはカテゴリ(水分摂取量、身長、体重、歩数...)ごとにRead権限とWrite権限を指定します。

  • Read権限とWrite権限の違い
    • Read権限のある項目は他のアプリの書込情報も読み取ることができる
    • Write権限のある項目は書込みと、自分のアプリが書込んだ情報の削除・閲覧ができる

Capabilityの設定

HealthKitの利用にはCameraなどを利用する場合と同じく、CapabilityをONにします。
スクリーンショット 2018-12-16 14.16.16.png

plistに利用する理由を記載する

これもCameraなどを利用する場合と同じく、plistファイルになぜ利用するのか?をユーザ向けの文章で記載します。
不適切な説明はリジェクト対象となります。

<key>NSHealthShareUsageDescription</key>
<string>...Readする理由...</string>
<key>NSHealthUpdateUsageDescription</key>
<string>...Writeする理由...</string>

権限のリクエストを行う

次に実際にアプリで利用するカテゴリのRead/Write権限を要求するには以下のようにします。

plistファイルでは Read権限=Share / Write権限=Update という名称を利用していますが、
コードでは Read権限=Read / Write権限=Share とShareの意味が違うので気をつけましょう。

水摂取量と歩数のread/write権限を要求する
let store = HKHealthStore()
let types: Set<HKSampleType> = [
    HKSampleType.quantityType(forIdentifier: .dietaryWater)!,
    HKSqmpleType.quantityType(forIdentifier: .stepCount)!
]
store.requestAuthorization(toShare: types, read: types) { success, error in
    ... Error Handling など ...
}

クラッシュする権限要求

書込不可のカテゴリや、HKCorrelationType に対して、書込権限リクエストを送るとクラッシュするので注意です。

  • Apple Watchからの書込専用カテゴリ
    • HKQuantityTypeIdentifier.appleExerciseTime
    • HKCategoryTypeIdentifier.appleStandHour
  • Nike Fuelデバイスからの書込専用カテゴリ
    • HKQuantityTypeIdentifier.nikeFuel
書込不可のカテゴリに対して、書込権限リクエストを送るとクラッシュ
let store = HKHealthStore()
let sharedTypes: Set<HKSampleType> = [
    HKSampleType.quantityType(forIdentifier: .appleExerciseTime)!
]
store.requestAuthorization(toShare: sharedTypes, read: nil) { _, _ in }

ちなみにHKSampleTypeを継承していないカテゴリは、Write権限を要求するコードはコンパイルエラーになるので安心です。

情報を操作する

DataTypes

HealthKitのデータを取り扱うには、まずはData Typesについて知っておかなければなりません。
Data TypesはHealthKitで管理できる情報の種類です。
今回はHKCharacteristicType, HKQuantityType, HKCategoryType, HKCorrelationTypeについてのみ説明します。

HKQuantitySample

HKQuantitySampleHKQuantityType のカテゴリオブジェクトを実際に書込んだり読込んだりする時に利用するオブジェクトです。

歩数情報を書き込む
let now = Date()
let quantity = HKQuantity(unit: .count(), doubleValue: 10) // 10歩歩いた
let objects: HKObject = HKQuantitySample(
    type: HKSampleType.quantityType(forIdentifier: .stepCount)!,
    quantity: quantity, start: now, end: now.addingTimeInterval(100))
store.save(objects) { success, error in }

Metadataが必須な場合があり、無いとクラッシュする

HKQuantitySampleの一つである、InsulinDeliveryでは特定のMetadataが無いとクラッシュします。
InsulinDeliveryは名前の通り、主に糖尿病1型の治療に使われるインスリンをいつどのくらいの量なんのために摂取したかを表す情報です。
このなんのためにというのが無いと、書込時にクラッシュします。

InsulinDeliveryはInsulindeliveryReasonが無いとクラッシュ
let now = Date()
let quantity = HKQuantity(unit: .internationalUnit(), doubleValue: 1)
let reason = [HKMetadataKeyInsulinDeliveryReason: HKInsulinDeliveryReason.basal.rawValue] // このメタデータが無いとクラッシュ
let object = HKQuantitySample(type: HKSampleType.quantityType(forIdentifier: .insulinDelivery)!,
    quantity: quantity, start: now, end: now, metadata: reason)
store.save(object) { _, _ in }

HKQuantity で単位を間違うとクラッシュする

HKQuantity オブジェクト単位情報(HKUnit)を間違えるとクラッシュします。
以下の例では、10 gram 歩いたという、ありえない情報を書込もうとしています。

単位を間違えるとクラッシュ
let now = Date()
let quantity = HKQuantity(unit: .gram(), doubleValue: 10) // 10 グラム歩いた???
let objects: HKObject = HKQuantitySample(
    type: HKSampleType.quantityType(forIdentifier: .stepCount)!,
    quantity: quantity, start: now, end: now.addingTimeInterval(100))
store.save(objects) { _, _ in }

単位を組み合わせる

HKQuantityType の中には複数の単位によって、単位を定義しなければならないモノもあります
例えば、最大酸素摂取量(mL/kg*min)や血糖値(mg/dL or millimole/L)では単位を組み合わせることが必須となります。
HKUnit には unitMultiplied(by:), unitDivided(by:), unitRaised(toPower:) といった関数があるので、これを利用して単位を作りましょう。

最大酸素摂取量の単位を作る
var vo2MaxUnit = {
    let denom = HKUnit.gramUnit(with: .kilo).unitMultiplied(by: .minute())
    let numer = HKUnit.literUnit(with: .milli)
    return numer.unitDivided(by: denom)
}

HKCategorySample

HKCategorySampleHKCategoryType のカテゴリオブジェクトを実際に書込んだり読込んだりする時に利用するオブジェクトです。
HKCategorySample の値は HKCategoryValueXXXというような名前で定義されています。
使える値は利用するHKCategoryTypeIdentifierのドキュメントに書いてあります。

下記コードのSleepAnalysis
の場合はドキュメントのDiscussionに

These samples use values from the HKCategoryValueSleepAnalysis enum.

と書いてありますのでそれを利用します。

6時間の間前から現在まで、ベッドに横たわっていたという情報を書き込む
let now = Date()
let sleepState = HKCategoryValueSleepAnalysis.inBed
let object = HKCategorySample(
    type: HKSampleType.categoryType(forIdentifier: .sleepAnalysis)!,
    value: sleepState.rawValue,
    start: now.addingTimeInterval(-60*60*6), end: now)
store.save(object) { _, _ in }

HKCorrelationSample

HKCorrelationSampleHKCorrelationType カテゴリオブジェクトを実際に書込んだり読み込んだ入りする時に利用するオブジェクトです。
HKCorrelationSampleHKObject の組み合わせを表現します。
現在は、血圧と食品の二つのみサポートしています。

血圧では bloodPressureSystolicbloodPressureDiastolicを、食品では栄養素を組み合わせて使います。

血圧情報を登録する
let now = Date()
let diastolic = HKQuantitySample(
    type: HKSampleType.quantityType(forIdentifier: .bloodPressureDiastolic)!,
    quantity: HKQuantity(unit: .millimeterOfMercury(), doubleValue: 80),
    start: now, end: now)
let systolic = HKQuantitySample(
    type: HKSampleType.quantityType(forIdentifier: .bloodPressureSystolic)!,
    quantity: HKQuantity(unit: .millimeterOfMercury(), doubleValue: 120),
    start: now, end: now)
let object = HKCorrelation(type: HKSampleType.correlationType(forIdentifier: .bloodPressure)!,
    start: now, end: now, objects: [diastolic, systolic])
store.save(object) { _, _ in }

アクセス権限要求はHKSampleTypeで行う

HKCorrelationTypeHKObjectの組み合わせであるため、これをアクセス権限要求の際には使えず、クラッシュします。
よって、以下のコードはクラッシュします。

血圧の組合せへのアクセス権限要求はクラッシュする
let store = HKHealthStore()
let types: Set<HKSampleType> = [
    HKSampleType.correlationType(forIdentifier: .bloodPressure)!
]
store.requestAuthorization(toShare: types, read: types) { _, _ in }

これを回避するためには血圧を表す上限と下限のカテゴリに対してアクセス要求を行います。

血圧上限,下限のカテゴリに対してアクセス要求を行う
let store = HKHealthStore()
let types: Set<HKSampleType> = [
    HKSampleType.quantityType(forIdentifier: .bloodPressureSystolic)!,
    HKSampleType.quantityType(forIdentifier: .bloodPressureDiastolic)!
]
store.requestAuthorization(toShare: types, read: types) { _, _ in }

最後に

もしクラッシュしてもログを見ればクラッシュした原因が分かるので、ドキュメントを読んで何が入るのか?を把握して対処しましょう!

iOSでインタラクティブな画面遷移をドロワーメニュー(NavigationDrawer)を例にして実装してみる

3行で

  • インタラクティブに画面を遷移させるには UIViewControllerTransitioningDelegateUIViewControllerAnimatedTransitioningUIPercentDrivenInteractiveTransition の3つが必要です
  • 今回はドロワーメニューを例にしてインタラクティブな画面遷移のサンプルを作成しました
  • UIViewPropertyAnimator でさらに効率的に実装できそうだけど今回は挑戦できませんでした

サンプルについて

インタラクティブな画面遷移を試すためにサンプルを作成してGitHubに公開しました。
サンプルは iOS9 まで対応しています。

NavigationDrawerSampler.gif

サンプルではドロワーメニューを呼び出す画面で NavigationDrawerTransitionCoordinator.swift を初期化してプロパティに保持させています。 NavigationDrawerTransitionCoordinator.swift がドロワーメニューを画面遷移させているクラスです。

ViewController.swift
import UIKit
import NavigationDrawerTransition

final class ViewController: UIViewController {

    private var navigationDrawerTransitionCoordinator: NavigationDrawerTransitionCoordinator?

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationItem.title = "NavigationDrawerSampler"

        navigationDrawerTransitionCoordinator = NavigationDrawerTransitionCoordinator(rootViewController: self)
        navigationDrawerTransitionCoordinator?.setupDrawerNavigationItemLeftBarButton()
    }
}

ドロワーメニューの仕様

ドロワーメニューはiOSのUIというよりは、AndroidのUIです。しかし、Twitterやメルカリなど有名なiOSアプリでも採用されています。

UIKitにはないのでOSSのライブラリを使うか、自分で実装する必要があります。ドロワーメニューのデザインは様々ありますが、おおよそ以下のような仕様になります。

  • 画面遷移が途中で停止して全画面の80%の幅でドロワーメニューが表示される
  • ドロワーメニューの開閉がドラッグでインタラクティブにできる
  • 呼び出し画面(暗い画面)をドラッグまたはタップでドロワーメニューを閉じることができる
  • ドロワーメニューが開くのに合わせて呼び出し画面の全体が暗くなり、閉じるのに合わせて元に戻る

実装に必要なプロトコルとクラス

仕様を満たすために画面遷移をカスタマイズして遷移の開始と終了をインタラクティブにさせます。
Appleの公式ドキュメントだと下記のリンクで具体的な実装方法が説明されています。

ドキュメントを要約すると、必要なものは以下の protocol と class です。

UIViewControllerTransitioningDelegateUIViewControllerAnimatedTransitioning は遷移をカスタマイズするときに使うプロトコルで、 UIPercentDrivenInteractiveTransition は遷移をインタラクティブにしてくれるクラスです。

インタラクティブな開閉ジェスチャーはドラッグで実装するので、 UIPercentDrivenInteractiveTransitionUIPanGestureRecognizer と合わせて使います。

ドロワーメニューの開閉遷移の処理

ドロワーメニューの開閉遷移の開始、終了、キャンセルで呼ばれるメソッドは、インタラクティブである場合とそうでない場合で少し違います。

詳しくは、サンプルをビルドしてブレークポイントを設定するのが一番わかりやすいので、私が実装中にハマった部分のみ説明します。

:black_medium_square: ドロワーメニュー表示中に呼び出し元の画面を表示する

NavigationDrawerAnimationPresenter.swift
  /*
   * 遷移中のViewに遷移元のViewを貼り付けてスライドしてきた感じを演出
   * さらに遷移元のViewにタップとドラッグのジェスチャーを登録したViewを最前面に貼り付けて
   * タップとドラッグの両方で画面を閉じることを可能にする
   */
  containerView.addSubview(rightView)
  self.gestureDarkView?.frame = rightView.frame
  containerView.insertSubview(self.gestureDarkView!, aboveSubview: rightView)

https://github.com/masashi-sutou/NavigationDrawerSampler/blob/master/NavigationDrawerTransition/Transition/NavigationDrawerAnimationPresenter.swift#L74-L76

:black_medium_square: AnimationOptionsをcurveLinearにしないと指と動作が合わない

  • curveLinearにしないとドラッグしている指を移動させていく距離が伸びるにつれて、想像以上に画面遷移が進んでしまいます。
  • イージングに緩急をつけずに等速運動させると回避できそうと思い、curveLinearで対応しました。

:black_medium_square: 画面遷移を途中でキャンセルしたときの処理

  • インタラクティブな遷移なので遷移を途中でキャンセルできます。
  • キャンセル判定の閾値は、NavigationDrawerInteractiveTransition.swiftの percentCompleteThreshold です。
  • finish()percentCompleteThreshold を引いて、キャンセルしたときに元の画面状態に戻る遷移を自然な状態にします。
NavigationDrawerInteractiveTransition.swift
<省略>
  private let percentCompleteThreshold: CGFloat = 0.2

  override func cancel() {
      completionSpeed = percentCompleteThreshold
      super.cancel()
  }

  override func finish() {
      completionSpeed = 1 - percentCompleteThreshold
      super.finish()
  }
<省略>

https://github.com/masashi-sutou/NavigationDrawerSampler/blob/master/NavigationDrawerTransition/Transition/NavigationDrawerInteractiveTransition.swift#L25-L28

  • さらにキャンセルした場合は、遷移を制御している側にもキャンセルしたことを伝える必要があります。
NavigationDrawerAnimationPresenter.swift
  guard !transitionContext.transitionWasCancelled else {
      transitionContext.completeTransition(false)
      return
  }

  transitionContext.completeTransition(true)

https://github.com/masashi-sutou/NavigationDrawerSampler/blob/master/NavigationDrawerTransition/Transition/NavigationDrawerAnimationPresenter.swift#L62-L67

NavigationDrawerAnimationDismisser.swift
  transitionContext.completeTransition(!transitionContext.transitionWasCancelled)

https://github.com/masashi-sutou/NavigationDrawerSampler/blob/master/NavigationDrawerTransition/Transition/NavigationDrawerAnimationDismisser.swift#L48

:black_medium_square: iOS9~11でインタラクティブな画面遷移のとき遷移アニメーションがカクカクする

:black_medium_square: ViewControllerのライフサイクル

  • 遷移をキャンセルして呼び出し画面に戻ると viewDidAppear: などのライフサイクルはしっかりと呼ばれます。
  • 遷移してることに変わりはないのでライフサイクルには注意してください。

最後に

公式ドキュメントを読めばインタラクティブな画面遷移を実装することはできます。でも、頻繁に見かけるUIで試さないと理解しづらいです。必要なプロトコルやクラスが多く、名前も長いし、処理の順番も把握しにくかったです。画面遷移のカスタマイズは難しい 😕

最近では、ショートカットやマップなどの標準アプリでも画面遷移がとてもカスタマイズされており、画面遷移をカスタマイズする機会はさらに増えそうだなと、この記事を準備していたときは思っていましたが、 UIViewPropertyAnimatorを駆使して作ったもの を読むと標準アプリは画面遷移ではなく、 もしや UIViewPropertyAnimator で全て実現しているのではとも思い始めています 🤔

画面遷移とアニメーションの奥は深い 🧐

iosの開発を始めたあの日の僕に伝えたいこと。

0. 自己紹介

entakuです。
元々はSIerでWebエンジニア(JavaとかPHPとか)書いてました。
その前はネットワークエンジニアとかやってました。
今は株式会社LifeSportsでスポーツマッチングアプリ作ってます😀

僕が本格的にios開発を始めたのは、今年初めからです。
swiftアプリはUdemyなどで勉強してなんとなくわかるかなとは思っていたのですが、
学習サイトをみながら作ることと、実際にアプリを作って行くことはかなり違いました。
1年弱経って、1年前の自分に伝えたいことをまとめてみました。

1. ViewControllerのライフサイクルをつかもう!

xcodeでアプリを作成すると最初にviewControllerが作成されています。
viewControllerには処理の順番があらかじめ決まっており、「このタイミングで処理する」というのを意識しておく必要があります。

僕の場合は、viewWillAppearでナビゲーションバーを消したり
viewDidAppearでデータ取得して画面更新かけたりしますかね。

もちろん場合によってなので、こういうことができるんだ!ってことは知ってた方がいいかと。

// 初期表示時に必要な処理を設定します。
// 基本的な初期化はここで
override func viewDidLoad() {
    super.viewDidLoad()
    print("viewDidLoad")
}

// UI 部品を View へセットする場合はこちらをオーバーライドします。
// UIの処理はここで定義しなくてもいいですが、決めておくとわかりやすい!
override func loadView() {
    super.loadView()
    print("loadView")
}

// 画面に表示される直前に呼ばれます。
// viewDidLoadとは異なり毎回呼び出されます。
override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)
    print("viewWillAppear")
}

// 画面に表示された直後に呼ばれます。
// viewDidLoadとは異なり毎回呼び出されます。
override func viewDidAppear(animated: Bool) {
    super.viewDidAppear(animated)
    print("viewDidAppear")
}

// 画面から非表示になる直前に呼ばれます。
// viewDidLoadとは異なり毎回呼び出されます。
override func viewWillDisappear(animated: Bool) {
    super.viewWillDisappear(animated)
    print("viewWillDisappear")
}

// 画面から非表示になる直後に呼ばれます。
// viewDidLoadとは異なり毎回呼び出されます。
override func viewDidDisappear(animated: Bool) {
    super.viewDidDisappear(animated)
    print("viewDidDisappear")
}

UIViewControllerのライフサイクル
https://qiita.com/motokiee/items/0ca628b4cc74c8c5599d

2. Viewを知ろう!

iosで利用されるものにはいくつかの特徴的なViewがあります。
特定のUIには適さないViewがあるのでとても注意が必要です。

2-1. TableViewの場合

例えば、TableViewなんかは下記の特性があります。

TableView
⭕️同じものを同じ大きさで繰り返して表示するのが得意
❌異なる情報を異なる大きさで表示するのは不得意

image.png

TableView
https://developer.apple.com/documentation/uikit/uitableview

2-2. StackViewの場合

部品を並べて簡単にAutoLayoutしたいときに使うのがStackViewです。

StackViewを賢く使ってらくちんAutoLayout
https://qiita.com/yucovin/items/ff58fcbd60ca81de77cb
StackView Apple Developer
https://developer.apple.com/documentation/uikit/uistackview

下記はStackViewで作ったコメント入力用のviewです。
部品の表示非表示をするだけで、StackViewで表示を変えています。

image.png

初期状態
image.png

コメント入力時
image.png

以上のようにviewの特性を知って「ここはこのviewで作るといい感じに作れそう」
とイメージすることで、実装にスムーズに入れます

3. delegateとdatasourceを知ろう!

tableViewやCollectionViewなどではあらかじめ用意されている定義delegate/datasourceがあります。
 このあらかじめ定義された部分に値や処理を書くことで、あとはtableViewやCollectionViewが処理を実行してくれます。

extension viewController: UITableViewDataSource {

    // セクションの数を指定する
    func numberOfSections(in tableView: UITableView) -> Int {
        return sections.count
    }

    // セクション内のCellの数を指定する
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return events[section].count
    }

    //Sectionに表示するheaderViewを定義する
    func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        let headerView = TopParticipateRecommendSectionHeaderView.init(reuseIdentifier: sections[section])
        return headerView
    }



    // Tableに表示するCellを定義する
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueCell(TopParticipateWaitingCell.self, indexPath)

        return cell
    }
}


extension viewController: UITableViewDelegate {

    // cellをクリックした時の動作を定義する
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    }
}

この他にもたくさんのdelegateメソッドがあり、
多くの処理は則って書くだけで実現することができます。

https://developer.apple.com/documentation/uikit/uitableviewdelegate

4. 非同期で実行する方法をしろう!

アプリ開発では「この処理が終わってから次の処理に行きたい」ということがよくあります
多くの非同期処理があるのですが、ここではClosureについて言及します。

4-1. 子ViewController側で戻る処理の定義

まずは子供の定義です。
ここでは「施設を選択したら、親に施設情報を返す」ということを行なっています。

子ViewController

      // Closure
      var selectPlaceCompletionHandler: ((_ facility: Facility?) -> Void)?

      // ボタンクリック時の処理
      @IBAction fileprivate func confirmFacility(_ sender: UIButton) {

          self.dismiss(animated: true) {
              // 施設の情報を親に返す
              self.selectPlaceCompletionHandler?(self.facility)
          }
      }

4-2. 親ViewController側で帰ってきた処理の定義

親側で子から戻ってきた後の処理を書きます。
処理の順番としては
親から子供を表示->子供の中の処理->親で定義している処理の実行
となり、親が子供の処理を待つことができます。

親ViewController

// 子ViewControllerの定義
let vc = FacilitiesListViewController()
vc.selectPlaceCompletionHandler = { (facility) in
      //帰ってきた施設情報をここで使えます
}
let navigation = UINavigationController(rootViewController: vc)
// 子ViewControllerを表示
present(navigation, animated: true, completion: nil)

5. デバッグをしよう!

実際自分の書いたコードが意図通りに動いているか?を確かめたい時はデバッグしましょう。

1. logのデバッグ

debugPrint('処理が実行された!')

2. UIのデバッグ
最終的に「意図した画面表示されること」を確認する場合がアプリ開発では多くなります。
そのため、UIのデバッグをして「意図した画面表示」を確認します

アプリ実行中に下記のボタンを押します
image.png
するとviewが階層になって表示されます

image.png

ここで対象のviewをクリックすると、viewにどんな定義がされているか見ることができます。

6. 色んなswiftの処理の書き方を知ろう!

swift独自じゃないものもありますが、僕がswiftでよく使うと思われる書き方まとめてみました。

6-1. Struct

全体で使いたい値や処理を置く時に格納する。
APIの定義や定数,Modelの宣言に使用する

Modelの宣言例

struct UserEntity: Decodable {
    let id: Int
    let fullname: String
    let picture: String
}

Swift さくっと確認したい基礎文法 [Struct(構造体)]
https://qiita.com/yuinchirn/items/98b568d595650eca3334

6-2. enum

全体のパターンがわかってる時に使うものです。
TableViewやCollectionViewのセクションの数を定義する時によく使います。
その他自社ではスポーツのレベルを定義したい時に使ってますね。

https://qiita.com/hachinobu/items/392c96820588d1c03b0c
swift4.2からallCases使えるの嬉しい😊

  enum SportsLevel: Int {
    case none = 0
    case elementary
    case intermediate
    case advanced

    var title: String {
        switch self {
        case .none: return "未設定"
        case .elementary: return "初級"
        case .intermediate: return "中級"
        case .advanced: return "上級"
        }
    }
  }

6-3. guard let / if let などのnilチェック

nilチェックに使います
swift以外ではみたことない(僕が知らないだけかも。)
nilはアプリ開発の敵なので、'nilじゃない時だけ処理する' を実現する時に使います。

    guard let hoge = hoge else {return}  // hogeがnullだとこれ以降の処理は実行しない

    if let hoge = hoge {
      // hogeがnullじゃない時だけここが実行できる
    }

7. わからないことは質問しよう!

今は質問しようと思えばどこにでも質問できます

1. xcodeやswiftの使い方

xcodeやswiftの使い方は下記の3つがstandardかと。
teratail
https://teratail.com/
stackoverflow
https://stackoverflow.com/
apple developer forum
https://developer.apple.com/devforums/

ライブラリなどはgithubやslackコミュニティーがある場合が多いので質問してみましょう。

以前Realmの使い方がわからなかった時にRealmのSlackコミュニティに質問してみました。
stackoverflowやgithubでもissueなどで質問しています。

Slackコミュニティに質問した時の話
https://qiita.com/entaku19890818/items/f9f75cacbf5209d59207

8. デザインに意見しよう!

デザインが無いと、アプリが作れません。(当たり前)
基本的にはデザイン通りにつくるのですが、構造上難しいものだったり、こんな場合どうする?ってのがあります。
そんな時はデザイナーと議論しましょう

8-1. Viewの特性から外れることをリスクとして伝えよう

 "Viewの特性を知ろう!" に出てきたのですが、iosのViewの特性上どうしても難しいUIというのが出てきます。
 ここはちゃんとデザイナーなど他の人を巻き込んで下記のことをするのが必要だと思っています。
 * なぜ難しいのかを説明する
 * なぜこのUIにするのか意図を確認する
 * 意図に対して代替え案を提示する

8-2. 動作があった時や端末が大きく小さくなった時の動きのイメージを確認しよう

あとはこの記事がとてもとてもわかりやすい。(正直これ読んどけばOK)

フロントエンドエンジニアから、デザイナーさんに意識してほしい10のこと
https://note.mu/pittan/n/n5789d09c5575

9. APIに意見しよう!

APIが無いと、(だいたいの)アプリが作れません!()
アプリ的には「こんな感じでAPIきて欲しいっ!」っていうものがあります。

例えば、あるAPIでuserが返ってくる値が違うとき...

"user": {
  "id": 1,
  "name": "test",
  "address": "東京都",
  "picture": "https://XXXX",
}
"user": {
  "id": 1,
  "name": "test",
  "address": "東京都"
}

こんなときswift側では以下のようにModelを定義しています。

    let id:Int
    let name:String    
    let address:String
    let picture:String?  // いないデータもいるから、optionalで定義。

これくらいならあとでif let するだけでいいんで問題なさそうですが、
毎回毎回userの中身が異なると、何回if letすりゃーいいんだー!!となりかなり開発しずらいです...
swift的には基本「APIのresponseの中身は変えて欲しくない」のです。
この部分はAPI担当と議論して、どうしてもという場合だけにした方がいいです。

10. まとめ

以上つらつらと自分の経験からわかっていた方がいいと思ったことをまとめてみました。
これ以外にもこれはわかってた方がいいよ!だったり、
いやここわかんなかったよ!
ってとこがあればコメントいただければできる限り書きます。

iosの開発始めた時、どんな時に使うのかとかどんな風に考えるのかなど
公式サイトや検索結果ではなかなか出てこない知見が少なかったことに苦労しました。

こんな風に少しずつ自分の型を作っていければと思って今回まとめました😄

どこかのiosアプリ開発者の役に立てば幸いです🤗

WCSession.sendMessageに潜む罠

はじめに

このエントリは iOS Advent Calendar 2018 の19日目の投稿です。(遅くなりましてすみません...)

当方iOSの技術者と言いながら最近iOSの開発があまりできていませんが、頑張って書いていきたいと思います!

やりたいこと

部長「iPhoneとApple Watchで何かしらのメッセージやりとりできるようにしてくんない?」
わたし「めんどくさ(わかりました)」

事の経緯

上記の通りiPhoneからApple Watchにメッセージを送る必要があった。
それっぽいリファレンスを発見できたので、軽い気持ちで見てみたらメソッドを見ていると以下のことがわかった。

  • メッセージは辞書型で必須
  • replyHandlerとerrorHandlerはnilが許容されている。(成功時/失敗時の処理はなくてもいい)
open func sendMessage(_ message: [String : Any], replyHandler: (([String : Any]) -> Swift.Void)?, errorHandler: ((Error) -> Swift.Void)? = nil)

なのでメッセージを送る時に以下のようにした。

ViewController.swift
self.session.sendMessage(
    ["message" : "7/25は高森藍子の誕生日!"],
    replyHandler: nil,
    errorHandler: { (error) in do {
        // エラー発生
        print(error)
    }}
)

そうすると警告文が発生していた。
どうやら実装に不備があったらしい。

2018-06-22 10:00:00.000000 SampleProject WatchKit Extension[17803:213358] [WC] -[WCSession _onqueue_notifyOfMessageError:withErrorHandler:] errorHandler: NO with WCErrorCodeDeliveryFailed

似たような事象にあっている人を見つけた。
Apple Watchで寿司をまわしてGetWildする
replyHandlerはnilを許容しているのに、nilを渡すと動かなくなるらしい。

以下のように実装したら無事送信が成功するようになった。
relpyHandlerがなんでnil許容してんだよ...

ViewController.swift
self.session.sendMessage(
    ["message" : "7/25は高森藍子の誕生日!"],
    replyHandler: { (reply) in do {
        // 空でもいいので置いとく必要があるがせっかくなのでお祝いしないと。
        print("藍子誕生日おめでとう!")
    }},
    errorHandler: { (error) in do {
        // エラー発生
        print(error)
    }}
)

Apple Watch側でもメッセージ受け取ったらreplayHandlerに何かしら返す必要がある。

AppleWatchViewController.swift
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
    // nilを設定すると送り手側のreplyHandlerが呼ばれない=送信完了のステータスにならない。
    // replyHandler(nil)

    // なんでもいいから送っとけ
    replyHandler(["message" : "お祝いしないといけないね!"])
}

ソース全文

ViewController.swift
//
//  ViewController.swift
//  SampleProject
//
//  Created by satoshi baba <s_baba@currentia.co.jp> on 2018/12/15.
//  Copyright © 2018年 Currentia.inc. All rights reserved.
//

import UIKit
import WatchConnectivity

class ViewController: UIViewController {

    /// Apple Watchの接続用のセッション
    let session:WCSession = WCSession.default

    override func viewDidLoad() {
        super.viewDidLoad()
        self.initWCSession()
    }

    func initWCSession() {
        if WCSession.isSupported() {
            self.session.delegate = self
            self.session.activate()
        }
        else{
            // サポートされていない時の処理は省略
        }
    }

    func sendMessage() {
        DispatchQueue.main.async {
            self.session.sendMessage(
                ["message" : "7/25は高森藍子の誕生日!"],
                replyHandler: { (reply) in do {
                    // 空でもいいので置いとく必要がある。
                    print("藍子誕生日おめでとう!")
                }},
                errorHandler: { (error) in do {
                    // エラー発生
                    print(error)
                }}
            )
        }
    }
}

extension ViewController: WCSessionDelegate {
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        switch(activationState){
        case .activated:
            // アクティブになったのでメッセージを送る
            self.sendMessage()
        case .inactive:
            // 省略
        case .notActivated:
            // 省略
        }
    }

    func sessionDidBecomeInactive(_ session: WCSession) {
        // 省略
    }

    func sessionDidDeactivate(_ session: WCSession) {
    // 省略
    }
}

iOSアプリのレイアウトを回転で変更するとき注意していること

回転に対応する際に注意していることを備忘録的にまとめていきます。他に思いついたものや違うやり方があったなってのがあれば後々追記していきたいです。

また、この辺りの仕様はiOS8前後で大きく変わっているため、iOS8以降の開発を想定させてください。(詳細はUIViewControllerのドキュメントのHandling View Rotationsの項目を)

回転方向の設定は、アプリのどの範囲で回転させたいかによって設定場所を決める

回転方向を決定する方法は複数あるため、どこで設定すればいいのか混乱の元になってると思います。自分は以下の理解で判断しています。

  1. iPhone・iPadで共通の場合はプロジェクトファイル内Deployment Infoのチェックボックスで設定する
  2. iPhone・iPadで異なる場合はInfo.plistにSupportedInterfaceOrientationsをデバイスごとに追加する
  3. UIViewController単位で変えたい場合は、UIApplicationDelegateや各UIViewControllerのsupportedInterfaceOrientationsを使う
  4. iPhoneX系ではシステムの設定としてUpsideDownができない

1. iPhone・iPadで共通の場合はプロジェクトファイル内Deployment Infoのチェックボックスで設定したものだけを使う

プロジェクトファイルのDeployment Info > Device Orientationで回転方向を指定できます。
最初はとりあえずあまり深く考えずにこの設定を変更します。すると、アプリ全体で設定が反映されます。
この設定は他の設定に比べて一番優先順位が低く、一旦ここで全体的な設定をしてしまうのがいいです。

スクリーンショット 2018-12-20 22.39.06.png

注意点としてはiPhoneではUpside Downを設定しても反映されません。これは、他の設定方法であるUIViewControllerのsupportedInterfaceOrientationsがデフォルトでUpside Downができないようになっているためです(その設定のほうが優先的に考慮されます)。もしUpsideDownを有向にしたければ、後述するように各ViewController内で全方向へ回転できるよう実装する必要があります。

Override this method to report all of the orientations that the view controller supports. The default values for a view controller'€™s supported interface orientations is set to all for the iPad idiom and allButUpsideDown for the iPhone idiom.

https://developer.apple.com/documentation/uikit/uiviewcontroller/1621435-supportedinterfaceorientations

古いプロジェクトファイル(〜iOS8)を使っている場合は、ここだけでiPhone・iPadの切り替えもできます。

2. iPhone・iPadで異なる場合はInfo.plistにSupportedInterfaceOrientationsをデバイスごとに追加する

Info.plistを使って、iPhone・iPadで回転制御の仕方を変えられます。

iPhoneは

スクリーンショット 2018-12-20 22.42.32.png

iPadは

スクリーンショット 2018-12-20 22.42.42.png

を変更します。
3.のUIVIewController単位での設定をしている場合には、そちらのほうが優先的に適用されます。そのため、iPhone・iPadで全体的に回転状態をコントロールする場合にはとりあえずここの設定をしておくといいでしょう。

これに関しても、iPhoneでUpside Downを設定してもそれは反映されません。

3. UIViewController単位で変えたい場合は、UIApplicationDelegateや各UIViewControllerのsupportedInterfaceOrientationsを使う

UIViewController単位で細かく回転の設定を変えたい場合には

  • UIApplicationDelegateのapplication(_:supportedInterfaceOrientationsFor:)
  • 各UIViewControllerのsupportedInterfaceOrientations

を実装します。

UIApplicationDelegateのメソッドはすべてのViewControllerへ影響を与えます。もしここで個別に制御をしたい場合には、メソッド内で現在の画面状態をチェックして適切な値を返す必要があります。

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
        func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
        return .all
    }
}

一番ベストな方法は、個々のViewController内で実装するやり方でしょう。UIViewControllerに対して実装した場合、そのViewControllerに対してのみ反映されます。

class ViewController: UIViewController {
    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .all
    }
}

注意点としてはaddChild(_:)によるコンテナViewControllerでの回転制御です。その場合、子ViewControllerの回転はコンテナのsupportedInterfaceOrientationsで制御されます。これはデフォルトで存在しているコンテナであるUINavigationControllerなどでも同様です。どのViewControllerが前面にいるのかで変更したい場合は、UINavigationControllerを継承したクラスで、topViewControllersupportedInterfaceOrientationsを返すようにしましょう。

4. iPhoneX系ではシステムの設定としてUpsideDownができない

上記で説明した話とは別にDevice Support Orientationという概念も存在します。
これは何かと言うと、デバイスレベルで始めから決まっている取りうる回転方向のことです。
もっと具体的に言ってしまえば、どのような設定を行っていても、iPhoneX系ではUpsideDownの設定は効きません。
この設定が最優先で適用されます。

The system intersects the view controller'€™s supported orientations with the app's supported orientations (as determined by the Info.plist file or the app delegate's application(_:supportedInterfaceOrientationsFor:) method) and the device's supported orientations to determine whether to rotate. For example, the UIInterfaceOrientation.portraitUpsideDown orientation is not supported on iPhone X.

https://developer.apple.com/documentation/uikit/uiviewcontroller/1621435-supportedinterfaceorientations

LandscapeとPortraitでレイアウトを切り替えたい場合、どのタイミングでサイズが変わるのか注意する

画面仕様としてSize Classでレイアウトを変更できるとバグが入りにくい状態にできます。ただし、回転に対応するならLandscapeとPortraitでUIを変えたいという要件がどうしてもおきてくると思います。

その場合に特に頭を悩ませやすいのがiPadです。Landscape・PortraitのどちらであろうとSize Classは基本的に同じで、Size Classのみでは縦持ち・横持ちに合わせたレイアウト変更ができません。そのかわりに、回転のタイミングでUIViewControllerもしくはUIScreenがどんなサイズに変わるのかを見るようにします。

画面が回転しつつあるのを検知するイベントとしては、viewWillTransition(to:with:)が使えます。

UIViewControllerが、直接所持しているviewのサイズを変更する直前に呼ぶ処理です。引数のsizeには、どんなサイズへ変わろうとしているのかが入ってきます。
もう一つの引数であるcoordinatorへレイアウト変更のコードを入れてやると、回転のアニメーションへ合わせて変更が実行されます。

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        coordinator.animate(alongsideTransition: { _ in
           if size.width > size.height {
               // レイアウト変更処理を書く 
           } else {
               // ...
           }
        }, completion: nil)
    }

これで問題なさそうに見えますが、必ずしもこのタイミングで適切なサイズが取れるとは限りません。次に書くように、回転させた時点では各ViewControllerのviewはスクリーンのサイズに合わせて変更されていないケースがあります。そのため、viewのサイズを使った計算を入れてしまうと、レイアウト崩れを起こす可能性があります。

止む終えない場合は、直下のviewのレイアウトが決定しているviewWillLayoutSubviewのイベントのタイミングで計算させるのもありかなと思います。

また、これも別で書きますが、UICollectionViewのレイアウトを決める場合にはレイアウトクラスを継承して、サイズが変わる時に呼ばれるイベントを使ってハンドリングするのも手だと思っています。

ついでに書くと、iPhoneの5.5インチ端末とXR, XS Maxに関してLandscape時のSize Classは幅がiPadと同じです。
userInterfaceIdiomを使わず、(例えばStoryboard上で)Size Classのみによって判断してしまうと、このケースを見落としてしまうため注意が必要かと思います。

対象のViewControllerが前面に出ていないケースでの回転時挙動を考慮する

UIViewControllerやUIViewは、現在の階層構造によってイベントの実行されるタイミングが変わるように設計されています。そのため、あるViewControllerをいじったらその中だけテストして動作保証できるかと言うと必ずしもそんなことはなく、全体の構造を考慮しなければならない場合があります。

そして、回転時のレイアウト変更がまさにそのケースになります。一つ前の話のようにviewのサイズでレイアウトを切り替える場合、そのviewを所有するViewControllerが階層上のどこにいるのかでレイアウト処理の実行タイミングが微妙に変わります。 

例えば、UINavigationControllerがUIWindowのrootViewControllerにいるとします。そのUINavigationControllerはHogeViewControllerを持っており、回転させるとsubviewsのレイアウト処理が実行され、先程のviewWillTransition(to:with:)coordinator内の処理も実行されます。

ここで、FugaViewControllerをナビゲーションスタックに追加します。すると、回転した時にHogeViewControllerのsubviewsへのレイアウト処理は実行されません。しかし、coordinator内の処理は実行されるためsubviewsのframeは回転前のまま計算が行われることになります。

そのため、HogeViewControllerがアプリの前面にいるときは回転させるとうまくレイアウトされるが、裏側に回っている時に回転させるとレイアウトが崩れるということが起きている可能性があります。

実装後に動作確認するときは、そのViewControllerが前面に出ている場合に加え、ナビゲーションスタックの途中にいる・モーダルで裏に隠れている場合やアプリをバックグラウンドに持っていた状態で回転操作をし、画面を表示させるということをしたほうがいいでしょう。

Viewへのデータバインディングとレイアウト計算は分離する

AutoLayoutを始めに張ってそれだけでレイアウトが作れる場合は考えなくていいのですが、コードで地道にレイアウト計算したり、AutoLayoutのパラメータを変更する場合はこの点に注意が必要かなと思います。

回転に対応するようになると、画面生成やデータを表示させる時などの初期のタイミングとは別に、UIViewControllerのオブジェクトが生きたまま任意のタイミングでレイアウトが変わりえます。

UIViewやUIViewControllerがレイアウト計算のイベントを必要な時に呼ぶので、レイアウトに関する処理はlayoutSubviewupdateConstraintsなどそれぞれ専用のメソッド内で実装しましょう。

自分で呼びたい場合にsetNeedsLaout()setNeesdsUpdateConstraints()などで間接的にレイアウトをフックするのがいいと思っています。

UICollectionViewで回転時のレイアウトを制御したい場合、レイアウトクラスを継承してそこで実装するほうが安全

UITableViewを対応させる場合、

  • 横幅はView全体に広がると決まっている
  • UITableViewCellのcontentviewにAutolayoutを貼っていればよしなに余白を設定してくれる

といった理由で、回転でのレイアウトを深く考える必要がなく比較的楽かと思います。

一方、UICollectionViewを使うと回転でだいたいどこかレイアウト崩れを起こすジンクスが自分にはあります。

viewWillTransition(to:with:)invalidatesLayout() を呼ぶなど、UIViewControllerのライフサイクルで頑張ってレイアウトを制御するのも一つの方法です。

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        coordinator.animate(alongsideTransition: { [weak self] _ in
           self?.collectionView.collectionViewLayout.invalidateLayout()
        }, completion: nil)
    }

ただ、個人的にはレイアウトクラスを継承してそこへ再計算の実装を行うのが好みです。

レイアウトクラス内にboundsが変更されたら必ず呼ばれるメソッドがあり、そこで条件判定をして、invalidateLayoutをすることでレイアウトの再計算をさせます。これだと他の画面まで含めたアクロバティックな回転操作でCollectionViewのレイアウトが崩れてしまったという事態を防ぐことができます。

class HogeCollectionViewFlowLayout: UICollectionViewFlowLayout {
    override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
        let context = super.invalidationContext(forBoundsChange: newBounds)
        guard let flowLayoutContext = context as? UICollectionViewFlowLayoutInvalidationContext else {
            return context
        }
        // collectionViewのサイズが変わったタイミングで必ずinvalidateする
        flowLayoutContext.invalidateFlowLayoutDelegateMetrics = (newBounds.size != collectionView?.bounds.size) 
        return context
    }
}

中の実装方法はもっと効率化したほうがいいケースはあると思いますが、頻繁にCollectionViewのサイズ変更がされない作りのものであれば、無理なく確実にサイズにフィットしたレイアウトを計算できます。

アプリ内の操作で回転させる場合はLadscapeに固定されたカスタムモーダルによって実現可能

例えばユーザーが端末を回転させなくても、ボタンを押すことでLandscapeモードへ移行させるUXにしたいケースはあるでしょう。
デバイスの回転をアプリから行うAPIは残念ながら公開されていません。
もし正攻法に行うのであれば、(フルスクリーン表示の)モーダルを使ってLandscapeに固定されたViewControllerを表示させるのがいいだろうと思っています。UIViewControllerが持つ以下のAPIでモーダル表示のタイミングでのViewControllerの方向を制御できます。

class ViewController: UIViewController {
    override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
        return .landscapeRight
    }
}

もし回転方向をこのまま.landscapeRightに固定したいのであれば、shouldAutorotateをfalseにして、supportedInterfaceOrientationも.landscapeRightのみにします。

class ViewController: UIViewController {
    override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
        return .landscapeRight
    }

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .landscapeRight
    }

    override var shouldAutorotate: Bool {
        return false
    }
}

dismiss時にもこれらのイベントは呼ばれます。通常は.portraitで、特定のモーダルが表示されたときのみlandscapeにするのであれば、以下のようにisBeingPresentedで出し分けるといいでしょう。

class ViewController: UIViewController {
    override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
        if isBeingPresented {
            return .landscapeRight
        } else {
            return .portrait
        }
    }

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        if isBeingPresented {
            return .landscapeRight
        } else {
            return .portrait
        }
    }

    override var shouldAutorotate: Bool {
        return false
    }
}

このままだと、モーダルの出方がかっこよくないため、カスタムトランジションを組み合わせることで画面が回転しながら表示されるよう遷移させると、より良くなります。

また、以下の通知でユーザーがデバイスの向きを変えたタイミングに上記のモーダルを表示させるというのもできます。

ちなみに、デバイスの回転が全くできないのかと言うと、実はそういうわけではありません。
以下のように書けば、ボタンを押した時に強制的に回転させることができます。

UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation")

この書き方から分かる通りキー値コーディングを使ってprivateなプロパティへアクセスしています。推奨はされませんし審査で落とされるリスクがあります。

まとめ

回転制御のTipsを思いつくままに書いてみました。回転はレイアウト崩れを起こしがちな操作です。一方で、UIViewControllerのライフサイクルの中で画面サイズが動的に変わる前提でレイアウトのコードを書くとiPadの画面分割にも対応しやすいというメリットがあります。また、できる限りUIKitの標準的なUIに寄せることの恩恵も受けやすい部分でもあります。

もしこんなやり方を取っているよとかこの方法だとこういうケースでうまく動かないのでこうしたほうがいいなどありましたら、ぜひ教えて下さいm(_ _)m

参考資料

間違いだらけの機種判定/画面判定

最近仕事もプライベートも忙しい lovee です。お久しぶりです。

この記事は業務などでよく見かける機種判定/画面判定のアンチパターンについてお話ししたいと思います。

機種ごとの特殊処理を画面サイズ判定で行う

昔ゲーム業界にいた時に割とよく目にするやり方、言うまでも間違いです。

ゲームでは良く綺麗な画面を作るために複雑な演算が必要になりがちですが、古い機種でそのままやると間違いなくフレーム落ちするので、代わりにちょっと雑だけど早い演算が必要だったりします。そしてその切り替えを機種ごとで切り替えたりする必要があります。ところがそれを画面サイズで判断し、例えば 568 x 320 なら古い機種と判定して雑だけど早い演算に切り替えたりすることがあります。ところが iPhone SE が出た時、画面サイズこそ古いですがなかのスペックはかなり速く、普通に複雑な演算を使ってもいいです;逆に現在はまだ逆のパターンが出ていませんが、いつかアップルが急に「これこそ廉価版」とか言って画面サイズだけ最新のものにするけどスペックを古いものにしてもさほどおかしくないです。

機種ごとの処理を切り分ける際はきちんと UIDevice.current.model で切り分けましょう。画面判定はやめましょう。

レイアウト処理の切り分けを機種判定で行う

これは上の逆のアンチパターンになります。

例えば、もし機種が iPhone X(Xs や XR 含む) なら Status Bar の高さを 44、それ以外なら 20 にしたレイアウトを行う、と言う処理をした時、今後 iPhone が更に違う機種出た時に全てハードコーディングするしかないので非常に不便です。

また、iPhone 専用アプリとして iPad で動作された時に 480 x 320 の画面サイズでレイアウトさせる、と言うのも非常にまずいです。これは仕様変更でいつでも表示サイズが変わってしまう可能性があります。実際今年の iOS 12 では仕様変更で 568 x 320 のサイズで動くようになりましたし。

レイアウト処理を切り分ける際はきちんと UIScreen.main.bounds で切り分けましょう。機種判定はやめましょう。

以上、簡単ですが、iOS Advent Calendar 2018 21 日目の記事でした。

Auto Layoutデバッグ問題集(1)

Auto Layout エラー / ワーニングが発生しているレイアウトを、問題編と解答編に分けて説明する記事になります。問題集ということで、継続して書く気持ちはあります。

今回の内容に関連して、過去にこのような記事も書いています。

Auto Layoutのワーニング解読には「WTF?」が便利
AutoLayoutのデバッグをする(1)
Auto Layoutのデバッグに役立つメソッド/プロパティ集
デバッグのためにView階層を把握する
Debugging your layout ([WWDC2015] Mysteries of Auto Layout, Part 2より)

GitHub

以下の Git リポジトリに問題となっているコードを置いています。 question ブランチに切り替えると、エラー / ワーニングが発生している状態のプロジェクトをいじることができます。今回の対象は「Priority View」という画面です。
https://github.com/akatsuki174/AutoLayoutDebugSample

問題編

こんなレイアウトを作りたいとします。

  • 両端に20のマージン
  • text field と button の間隔は10
  • button の横幅は文字サイズぴったり
  • 残りの横幅は text field で埋める

しかし上記の条件に素直に従って Storyboard 上で制約を付けるとこのようにエラーが発生し…

構わず実行してみると text field が潰れてしまっています。

さてどうすればいいでしょうか?

解答編

まずは Storyboard 上でどのようなエラーが出ているのかを見てみます。

horizontal hugging priority が云々と出ています。ここで hugging priority の説明をします。


Content Hugging Priority

  • 大きくなりにくさ / コンテンツに沿う優先度 を表す
  • Hug = 沿って進むという意味
  • この値が小さいと他の制約に影響されて大きくなりやすくなる
  • 固有サイズより大きなサイズが指定されたときに使われる

Xcode上だとここにあります。


ここで text field, button の Content Hugging Priority を確認すると、どちらも250でした。制約の優先度をわざと低くしない限りはコンテンツの大きさにかかわらず制約が優先されるということもあり、このようになってしまうことがあります。


もう一度今回やりたかったことをおさらいすると、

  • text feild はできるだけ広く幅を取りたい
  • button は最低限の幅が取れていればいい

ということだったので、buttonのハグする力を高めてあげれば良いことになります。ということで button の Content Hugging Priority を 251 に上げます。

これで期待通りのレイアウトを作ることができました🎉

UIViewControllerのライフサイクルとRxSwiftのお話

みなさん、こんにちは。freddiです。

本記事は、iOS Advent Calender 2018の23日目の記事として、UIViewControllerのライフサイクルとRxSwiftのお話をさせていただきます。短い記事ですが、よろしくおねがいします。

本記事では、都合上RxSwiftに関しての入門の説明はほぼ無いので、ご注意ください。1

危険なRxSwiftの使い方

まず先に、UIViewControllerのライフサイクルとRxSwiftが関係するアンチパターンの例を紹介します。

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    someThingObservableValue.flatMap { ... } // someThingObservableValueはObservableな変数
        .subscribe( ... )
        .disposed(by: disposeBag)
}

一見、ちゃんと動くように見えるコードですね、Observableな変数をsubcribeするだけのコードです。

しかし、ここで気にしてほしいのが、subcribeしている関数です。viewWillAppearは、何回も呼ばれる可能性があるライフサイクルの関数です。 2

このコードの例だと、

  • viewWillAppearのような複数回呼ばれる可能性のある関数でsubscribeを行おうとしている3
  • 複数回同じようなストリームをsubcribeをしてしまうことになる
  • 呼ばれた分だけsubscribe処理が働くことになり、subscribeで重い処理などを行っていると大変なことになる

という重大な問題が出てきます。これは、Multiple Subscribingのような名前で呼ばれていることがあります。subscribeで重い処理をするしない関係なく、Multiple Subscribingは避けるべきです

対処法

その1、無難にviewDidLoadに入れる

override func viewDidLoad() {
    super.viewDidLoad()
    someThingObservableValue.flatMap { ... } 
        .subscribe( ... )
        .disposed(by: disposeBag)
}

viewDidLoadは一回しか呼ばれないので、subscribeするものはviewDidLoadに入れたほうが良いです。
ただ、これもこれで、viewDidLoadが非常に大きくなる問題もあるのでご注意ください4

その2、Completeを即流す

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    Observable.just(())
        .withLatestFrom(someThingObservableValue)
        .flatMap { ... } 
        .subscribe( ... )
        .disposed(by: disposeBag)
}

多分ですが、viewDidLoadに入れることがそもそも解決策ではないこともあると思います(viewWillAppearでどうしてもやりたいこととか)。

そんな時、Observableから使える.just(:)などを使えば、引数で与えられた値をストリームに流したあとにCompletedを流します。5
これによりsubscribe後に即ストリームを開放するという事もでき、subscribeする関数を複数回呼び出してもMultiple Subscribingが起こる心配はありません。

ただ、目的の値を流すとなるとwithLatestFromのような関数を利用しなければならず、場合によってはflatMapのネストが無駄に深くなるなどの厄介な点も出てきます

その3、BehaviorRelayがいい説

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    if someBehaviorRelayValue.value ... {
       ...
    }
}

そもそもBehaviorRelayを使えば、asObservable()せず(ストリームも作らず)に値を見ることができます。何回もsubscribeが呼ばれるような場合で、かつBehaviorRelayに置換できそうなら置換したほうがいいです。BehaviorRelaysubscribeできますし・・・。

その4、そもそもRxがいらない説2

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    ... // Rxを使わない方法でゴニャゴニャ
}

これ一番上に持って行きたかったですが、怒られそうなのであえてここに書きました。Rx使わないのも、それでかなり良い選択です6
RxSwiftがかなり根付いているプロジェクトだと、この検討は多分難しいですが・・・。Rx乱用ダメ絶対。

終わりに

私がRx初心者の頃にやらかしたことを記事にしました。ざっくりとした紹介でしたが、みなさんのご参考になりましたでしょうか?
もしTipsや修正すべき点があれば、気軽にリクエストとコメントをよろしくおねがいします。

では今回は失礼します。皆様ありがとうございました。

次の日はFumiya Sakaiさんの記事です!皆さんもお楽しみに!


  1. これは個人的な意見ですが、RxSwiftを触ろうとする前に、ReactiveXについて調べるほうがいいかもしれません。理由はいくつかありますが、私はReactiveXというものを知ることで、他の言語のRxのコードがRxSwiftでも参考になることを知りました。 

  2. https://qiita.com/sgr-ksmt/items/e23e684c5e46ea3e8d08 

  3. 自分で作った関数を呼び出すとき、その中でsubscribeをすれば、そこでMultiple Subscribeの危険性も出てきます。ですが、シングルトンではないかつライフサイクルの短い(一つのスコープのみに存在する)オブジェクトではこの限りでないです。 

  4. この問題は"Fat viewDidLoad"という名前で呼ばれているのをたまに見ます。 

  5. 他にも、fromも値を流したあとにCompletedを流します。この記事ではjustfromに似ている関数について調べることができます。 

  6. RxSwiftは必要に応じて使うようにして、必要無いならばなるべく使わないという方針が後々圧倒的に楽です。  

画面のパスコードロック機能を構築する際における実装例とポイントまとめ

1. はじめに

皆様お疲れ様です。「iOS Advent Calendar」の24日目を担当させて頂きます、fumiyasac(Fumiya Sakai)と申します。何卒よろしくお願い致します。

今回はUI実装に関するトピックスの中でも、簡単な画面のパスコードロック機能を実装したサンプルを作ってみましたので、その際にポイントになる実装部分やUI面で配慮をした部分についても紹介できればと思います。ファイナンス系のアプリをはじめとしたアプリ内でお金のやりとりが発生するものや、ヘルスケアアプリ等でもあまり他人に見られたくないセンシティブな情報を持つようなアプリにおいてはよく見かける機能の1つですが、AppDelegate.swift部分のライフサイクルを利用する点やユーザーの使いやすさを実現するために画面に関する処理にも工夫が必要な部分でもあるので、実際のアプリに導入する際にはこのサンプルだけでは十分ではない部分もあるかと思いますが、実装の参考に少しでもなれば幸いに思います。

Githubでのサンプルコード:

※ こちらのサンプルはPullRequestやIssue等はお気軽にどうぞ!

サンプルの全体的な動きの動画:

※ こちらはiPhoneXでのFaceIDでのパスコードロック解除処理を含む動きになります。

2. 今回の参考資料とサンプル概要について

今回のサンプルに関してはGlobalTabBarController.swift (GlobalTabBar.storyboardで表示される画面) でのコンテンツ表示がベースの画面構成とし「一覧表示画面」と「設定画面」の2つの画面をGlobalTabBarController.swiftの中で表示するような形にしています。※Xcodeでは下図のように一番最初に表示する画面をGlobalTabBar.storyboardを一番最初に表示するように設定しています。

global_tab_bar.png

このサンプル内で設定画面からパスコードロック用のパスコードを設定した後に、ユーザーが下記の動作を実行した場合に、アプリ内のコンテンツ表示前にパスコードロックの画面が表示されるようにします。

  1. アプリをバックグラウンドに持っていった状態から再度フォアグラウンドへアプリを復帰させる場合
  2. アプリを一旦閉じた状態から再度アプリを起動させる場合

TabBarでの表示切り替え時はもちろん、その他モーダル表示を行う画面やUIAlertControllerでのダイアログ表示時にパスコードロック画面をかける場合に、パスコードロックを戻った際に元々表示していた画面が崩れることがないようにする必要があります。

サンプルのキャプチャ画像1:

capture1.png

サンプルのキャプチャ画像2:

capture2.png

環境やバージョンについて:

  • Xcode10.1
  • Swift4.2
  • MacOS Mohave (Ver10.14)

利用しているライブラリ:

ライブラリ名 ライブラリの機能概要
FontAwesome.swift 「Font Awesome」アイコンを利用するためのライブラリ

(補足)画面の上部にくっついて表示されるUICollectionViewの動きについて:

今回紹介しているサンプルのMainViewController.swiftで表示している部分に関してはUICollectionViewFlowLayoutクラスを継承したクラスを別途作成した上で配置しているUICollectionViewへ適用することによって、下図のような形で表示内容をスクロールした際に表示されている情報が上に重なっていくような形の表現を実現しています。

passcode_flow_layout.png

StickyStyleFlowLayout.swift
import Foundation
import UIKit

class StickyStyleFlowLayout: UICollectionViewFlowLayout {

    // 拡大縮小比を変更するための変数(値を変更する必要がある場合のみ利用する)
    var firstItemTransform: CGFloat?

    // 引数で渡された範囲内に表示されているUICollectionViewLayoutAttributesを返す
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {

        // 該当のUICollectionViewLayoutAttributesを取得する
        let items = NSArray(array: super.layoutAttributesForElements(in: rect)!, copyItems: true)

        // 該当のUICollectionViewにへHeaderまたはFooterを
        var headerAttributes: UICollectionViewLayoutAttributes?
        var footerAttributes: UICollectionViewLayoutAttributes?

        // 該当のUICollectionViewLayoutAttributesに対してUICollectionViewLayoutAttributesの更新をする
        items.enumerateObjects(using: { (object, _, _) -> Void in

            let attributes = object as! UICollectionViewLayoutAttributes

            // Header・Footer・セルの場合で場合分けをする
            if attributes.representedElementKind == UICollectionView.elementKindSectionHeader {
                headerAttributes = attributes
            } else if attributes.representedElementKind == UICollectionView.elementKindSectionFooter {
                footerAttributes = attributes
            } else {
                self.updateCellAttributes(attributes, headerAttributes: headerAttributes, footerAttributes: footerAttributes)
            }
        })
        return items as? [UICollectionViewLayoutAttributes]
    }

    // 更新された位置情報からレイアウト処理を再実行するか
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        return true
    }

    // MARK: - Private Function

    // 該当のセルにおけるUICollectionViewLayoutAttributesの値を更新する
    private func updateCellAttributes(_ attributes: UICollectionViewLayoutAttributes, headerAttributes: UICollectionViewLayoutAttributes?, footerAttributes: UICollectionViewLayoutAttributes?) {

        // 配置しているUICollectionViewにおける最大値・最小値を取得しておく
        let minY = collectionView!.bounds.minY + collectionView!.contentInset.top
        var maxY = attributes.frame.origin.y

        // Headerを利用している場合はその分の高さ減算する
        if let headerAttributes = headerAttributes {
            maxY -= headerAttributes.bounds.height
        }

        // Footerを利用している場合はその分の高さ減算する
        if let footerAttributes = footerAttributes {
            maxY -= footerAttributes.bounds.height
        }

        // 該当のUICollectionViewLayoutAttributesの拡大縮小比を調節して表示する
        var origin = attributes.frame.origin

        let finalY = max(minY, maxY)
        let deltaY = (finalY - origin.y) / attributes.frame.height

        if let itemTransform = firstItemTransform {
            let scale = 1 - deltaY * itemTransform
            attributes.transform = CGAffineTransform(scaleX: scale, y: scale)
        }

        origin.y = finalY
        attributes.frame = CGRect(origin: origin, size: attributes.frame.size)
        attributes.zIndex = attributes.indexPath.row
    }
}

このようなUICollectionViewの表現部分に関するカスタマイズ方法については、他に展開している記事の中にもご紹介しているものがありますが、今回の実装部分につきましては主に下記のサンプルや記事を参考にしました。

3. パスコードロック用の画面や細かな動きを実装する際のポイントまとめ

それではパスコードロック機能を実現するための画面実装や機能の実装部分に関するポイントとなりそうな部分に関する解説をしていきます。まずは画面を構成するために必要なView部品に関する処理やパスコードロック機能を実現するためにAppDelegate.swiftのライフサイクルを活用する処理との連動に関する部分等についてまとめています。

★3-1: 4桁のパスコードを入力するためのテンキー部分のViewの実装についてのポイント解説

まずはテンキー用のViewの構造から見てみましょう。下図のような形でUIStackViewを入れ子にして配置して必要なボタンを配置していきます。この際に0~9の数字のボタンについては、tagプロパティの部分に該当の数字を設定した上でOutlet Collectionを利用してViewクラスとの紐付けを行います。

passcode_ten_key.png

そして配置したViewControllerにおいてボタンをタップしたイベント処理との連動ができるように、下記の3つのプロトコルを定義しておきます。

  • 0~9の数字ボタンが押下された場合にその数字を文字列で送る: func inputPasscodeNumber(_ numberOfString: String)
  • 削除ボタンが押下された場合に値を削除する: func deletePasscodeNumber()
  • TouchID/FaceID搭載端末の場合に実行する: func executeLocalAuthentication()

またボタン押下した際には、「アニメーションを伴った表現であってもストレスなく入力できて、かつユーザーの操作した感じを出す」 という観点もUX向上のために気を配っておきたい部分ではあるかと思います。今回のボタンでは押下時にアルファ値が変更される様な形のアニメーションをつけていますが、アニメーションの実行中であったとしてもユーザーの入力を妨げないようにoptions:の値には.allowUserInteractionを忘れずに追加しています(この設定をしていないとアニメーションが終わるまでボタンタップが反応しない)。またボタンの押した感じをよりユーザーへ伝えるために、iOS10から利用可能なHaptic Feedbackをボタン押下時に加えて表現をしています。

Haptic Feedbackに関する参考資料:

以上の点をまとめるとこのViewに対応するクラスのコードは下記のような形となります。

InputPasscodeKeyboardView.swiftにおけるコード実装:

InputPasscodeKeyboardView.swift
import Foundation
import UIKit

// MEMO: このViewに配置しているボタンが押下された場合に値の変更を反映させるためのプロトコル
protocol InputPasscodeKeyboardDelegate: NSObjectProtocol {

    // 0~9の数字ボタンが押下された場合にその数字を文字列で送る
    func inputPasscodeNumber(_ numberOfString: String)

    // 削除ボタンが押下された場合に値を削除する
    func deletePasscodeNumber()

    // TouchID/FaceID搭載端末の場合に実行する
    func executeLocalAuthentication()
}

class InputPasscodeKeyboardView: CustomViewBase {

    weak var delegate: InputPasscodeKeyboardDelegate?

    // ボタン押下時の軽微な振動を追加する
    private let buttonFeedbackGenerator: UIImpactFeedbackGenerator = {
        let generator: UIImpactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
        generator.prepare()
        return generator
    }()

    // パスコードロック用の数値入力用ボタン
    // MEMO: 「Outlet Collection」を用いて接続しているのでweakはけつけていません
    @IBOutlet private var inputPasscodeNumberButtons: [UIButton]!

    // パスコードロック用のLocalAuthentication実行用ボタン
    @IBOutlet private weak var executeLocalAuthenticationButton: UIButton!

    // パスコードロック用の数値削除用ボタン
    @IBOutlet private weak var deletePasscodeNumberButton: UIButton!

    // MARK: - Initializer

    required init(frame: CGRect) {
        super.init(frame: frame)

        setupInputPasscodeKeyboardView()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        setupInputPasscodeKeyboardView()
    }

    // MARK: - Function

    func shouldEnabledLocalAuthenticationButton(_ result: Bool = true) {
        executeLocalAuthenticationButton.isEnabled = result
        executeLocalAuthenticationButton.superview?.alpha = (result) ? 1.0 : 0.3
    }

    // MARK: - Private Function

    @objc private func inputPasscodeNumberButtonTapped(sender: UIButton) {
        guard let superView = sender.superview else {
            return
        }
        executeButtonAnimation(for: superView)
        buttonFeedbackGenerator.impactOccurred()
        self.delegate?.inputPasscodeNumber(String(sender.tag))
    }

    @objc private func deletePasscodeNumberButtonTapped(sender: UIButton) {
        guard let superView = sender.superview else {
            return
        }
        executeButtonAnimation(for: superView)
        buttonFeedbackGenerator.impactOccurred()
        self.delegate?.deletePasscodeNumber()
    }

    @objc private func executeLocalAuthenticationButtonTapped(sender: UIButton) {
        guard let superView = sender.superview else {
            return
        }
        executeButtonAnimation(for: superView)
        buttonFeedbackGenerator.impactOccurred()
        self.delegate?.executeLocalAuthentication()
    }

    private func setupInputPasscodeKeyboardView() {
        inputPasscodeNumberButtons.enumerated().forEach {
            let button = $0.element
            button.addTarget(self, action: #selector(self.inputPasscodeNumberButtonTapped(sender:)), for: .touchDown)
        }
        deletePasscodeNumberButton.addTarget(self, action: #selector(self.deletePasscodeNumberButtonTapped(sender:)), for: .touchDown)
        executeLocalAuthenticationButton.addTarget(self, action: #selector(self.executeLocalAuthenticationButtonTapped(sender:)), for: .touchDown)
    }

    private func executeButtonAnimation(for targetView: UIView, completionHandler: (() -> ())? = nil) {

        // MEMO: ユーザーの入力レスポンスがアニメーションによって遅延しないような考慮をする
        UIView.animateKeyframes(withDuration: 0.16, delay: 0.0, options: [.allowUserInteraction, .autoreverse], animations: {
            UIView.addKeyframe(withRelativeStartTime: 0.2, relativeDuration: 1.0, animations: {
                targetView.alpha = 0.5
            })
            UIView.addKeyframe(withRelativeStartTime: 1.0, relativeDuration: 1.0, animations: {
                targetView.alpha = 1.0
            })
        }, completion: { finished in
            completionHandler?()
        })
    }
}

★3-2: ユーザーの入力状態を表すViewの実装についてのポイント解説

次にユーザーの入力状態を表すViewの構造から見てみましょう。こちらも下図のような形でUIStackViewの中に4つのUIImageViewを配置します(鍵の形をしたアイコンについては 「FontAwesome.swift」 を利用して表現しています)。この際に左から1~4の数値tagプロパティの部分に該当の数字を設定した上でOutlet Collectionを利用してViewクラスとの紐付けを行います。

passcode_input_display.png

そして配置したViewControllerにおいて現在ユーザーが入力しているパスコードの桁数との連動ができるように、下記の2つのインスタンスメソッドを定義しておきます。

  • 鍵マーク表示部分が増えていくような動きを実現する: func incrementDisplayImagesBy(passcodeStringCount: Int = 0)
  • 鍵マーク表示部分が減っていくような動きを実現する: func decrementDisplayImagesBy(passcodeStringCount: Int = 0)

以上の点をまとめるとこのViewに対応するクラスのコードは下記のような形となります。

InputPasscodeDisplayView.swiftにおけるコード実装:

InputPasscodeDisplayView.swift
import Foundation
import UIKit
import FontAwesome_swift

class InputPasscodeDisplayView: CustomViewBase {

    private let defaultKeyImageAlpha: CGFloat = 0.3
    private let selectedKeyImageAlpha: CGFloat = 1.0

    @IBOutlet private var keyImageViews: [UIImageView]!

    // MARK: - Initializer

    required init(frame: CGRect) {
        super.init(frame: frame)

        setupInputPasscodeDisplayView()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        setupInputPasscodeDisplayView()
    }

    // MARK: - Function

    // 鍵マーク表示部分が増えていくような動きを実現する
    func incrementDisplayImagesBy(passcodeStringCount: Int = 0) {

        keyImageViews.enumerated().forEach {
            let imageView = $0.element

            guard let superView = imageView.superview else {
                return
            }

            // MEMO: 引数で渡された値とタグ値が一致した場合にはアニメーションを実行する
            if imageView.tag == passcodeStringCount {
                imageView.alpha = selectedKeyImageAlpha
                executeKeyImageAnimation(for: superView)
            } else if imageView.tag < passcodeStringCount {
                imageView.alpha = selectedKeyImageAlpha
            } else {
                imageView.alpha = defaultKeyImageAlpha
            }
        }
    }

    // 鍵マーク表示部分が減っていくような動きを実現する
    func decrementDisplayImagesBy(passcodeStringCount: Int = 0) {

        keyImageViews.enumerated().forEach {
            let imageView = $0.element

            // MEMO: 入力した情報を消去する場合はアニメーションは実行しません
            if imageView.tag <= passcodeStringCount {
                imageView.alpha = selectedKeyImageAlpha
            } else {
                imageView.alpha = defaultKeyImageAlpha
            }
        }
    }

    // MARK: - Private Function

    private func setupInputPasscodeDisplayView() {
        keyImageViews.enumerated().forEach {
            let imageView = $0.element
            imageView.image = UIImage.fontAwesomeIcon(name: .key, style: .solid, textColor: .black, size: CGSize(width: 48.0, height: 48.0))
            imageView.alpha = defaultKeyImageAlpha
        }
    }

    // パスコード入力画面用の画像が弾む様なアニメーションをする
    private func executeKeyImageAnimation(for targetView: UIView, completionHandler: (() -> ())? = nil) {

        // アイコン画像用のViewが縮むようにバウンドするアニメーションを付与する
        UIView.animateKeyframes(withDuration: 0.06, delay: 0.0, options: [.autoreverse], animations: {
            UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 1.0, animations: {
                targetView.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
            })
            UIView.addKeyframe(withRelativeStartTime: 1.0, relativeDuration: 1.0, animations: {
                targetView.transform = CGAffineTransform.identity
            })
        }, completion: { finished in
            completionHandler?()
        })
    }
}

★3-3: ユーザーが入力したパスコードを管理するModel部分とPresenter部分での処理に関する実装のポイント解説

次にユーザーが設定したパスコードのデータを取り扱うためのModelクラス及びPresenterクラスに関する実装について見てみましょう。今回のサンプルにおいてパスコードロック画面を構成するPasscodeViewController.swiftに関しては 「MVP(Model - Presenter -View)パターン」 を利用した実装を行なっています。この画面はバックグラウンドから復帰する場合や初回起動時にパスコードロックを表示するための画面はもちろんのこと、設定画面からユーザーがパスコードを設定する画面にも利用できるような形としています。このViewControllerが利用される場合に応じた処理が必要になってくるので、下記のような場合分けを元にしてEnumの設定を行なっています。

  1. 「設定画面からのパスコードの新規登録」 → 「登録したいパスコードの入力画面:.inputForCreate」 → 「登録したいパスコードの入力画面:.retryForCreate」 → 「設定画面へ戻る」
  2. 「設定画面からのパスコードの変更」 → 「変更したいパスコードの入力画面:.inputForUpdate」 → 「変更したいパスコードの入力画面:.retryForUpdate」 → 「設定画面へ戻る」
  3. 「初回起動時またはフォアグラウンド復帰時」 → 「パスコードロック画面の表示:.displayPasscodeLock」 → 「初回起動時に表示したい画面またはバッググラウンドに移行する前に表示していた画面」

パスコードロック画面処理におけるView ⇄ Presenter ⇄ Modelの関連:

passcode_architecture.png

InputPasscodeType.swiftにおけるコード実装:

InputPasscodeType.swift
import Foundation

enum InputPasscodeType {
    case inputForCreate      // パスコードの新規作成
    case retryForCreate      // パスコードの新規作成時の確認
    case inputForUpdate      // パスコードの変更
    case retryForUpdate      // パスコードの変更時の確認
    case displayPasscodeLock // パスコードロック画面の表示時

    // MARK: - Function

    func getTitle() -> String {
        switch self {
        case .inputForCreate, .retryForCreate:
            return "パスコード登録"
        case .inputForUpdate, .retryForUpdate:
            return "パスコード変更"
        case .displayPasscodeLock:
            return "パスコードロック"
        }
    }

    func getMessage() -> String {
        switch self {
        case .inputForCreate:
            return "登録したいパスコードを入力して下さい"
        case .inputForUpdate:
            return "変更したいパスコードを入力して下さい"
        case .retryForCreate, .retryForUpdate:
            return "確認用に再度パスコードを入力して下さい"
        case .displayPasscodeLock:
            return "設定したパスコードを入力して下さい"
        }
    }

    func getNextInputPasscodeType() -> InputPasscodeType? {
        switch self {
        case .inputForCreate:
            return .retryForCreate
        case .inputForUpdate:
            return .retryForUpdate
        default:
            return nil
        }
    }
}

そして前述した場合分けに応じて定義をしたEnum値を踏まえた処理をViewController側で実行するためのPasscodePresenter.swiftクラスとユーザーが設定したパスコードを取り扱うためのPasscodeModel.swiftクラスのコードはそれぞれ下記のような形となります。

PasscodeModel.swiftにおけるコード実装:

PasscodeModel.swift
import Foundation

class PasscodeModel {

    private let userHashedPasscode = "PasscodeModel:userHashedPasscode"

    private var ud: UserDefaults {
        return UserDefaults.standard
    }

    // MARK: - Function

    // ユーザーが入力したパスコードを保存する
    func saveHashedPasscode(_ passcode: String) -> Bool {
        if isValid(passcode) {
            setHashedPasscode(passcode)
            return true
        } else {
            return false
        }
    }

    // ユーザーが入力したパスコードと現在保存されているパスコードを比較する
    func compareSavedPasscodeWith(inputPasscode: String) -> Bool {
        let hashedInputPasscode = getHashedPasscodeByHMAC(inputPasscode)
        let savedPasscode = getHashedPasscode()
        return hashedInputPasscode == savedPasscode
    }

    // ユーザーが入力したパスコードが存在するかを判定する
    func existsHashedPasscode() -> Bool {
        let savedPasscode = getHashedPasscode()
        return !savedPasscode.isEmpty
    }

    // HMAC形式でハッシュ化されたパスコード取得する
    func getHashedPasscode() -> String {
        return ud.string(forKey: userHashedPasscode) ?? ""
    }

    // 現在保存されているパスコードを削除する
    func deleteHashedPasscode() {
        ud.set("", forKey: userHashedPasscode)
    }

    // MARK: - Private Function

    // 引数で受け取ったパスコードをhmacで暗号化した上で保存する
    private func setHashedPasscode(_ passcode: String) {
        let hashedPasscode = getHashedPasscodeByHMAC(passcode)
        ud.set(hashedPasscode, forKey: userHashedPasscode)
    }

    // 引数で受け取った値をhmacで暗号化する
    private func getHashedPasscodeByHMAC(_ passcode: String) -> String {
        return passcode.hmac(algorithm: .SHA256)
    }

    // 引数で受け取った値の形式が正しいかどうかを判定する
    private func isValid(_ passcode: String) -> Bool {
        return isValidLength(passcode) && isValidFormat(passcode)
    }

    // 引数で受け取った値が4文字かを判定する
    private func isValidLength(_ passcode: String) -> Bool {
        return passcode.count == AppConstant.PASSCODE_LENGTH
    }

    // 引数で受け取った値が半角数字かを判定する
    private func isValidFormat(_ passcode: String) -> Bool {
        let regexp = try! NSRegularExpression.init(pattern: "^(?=.*?[0-9])[0-9]{4}$", options: [])
        let targetString = passcode as NSString
        let result = regexp.firstMatch(in: passcode, options: [], range: NSRange.init(location: 0, length: targetString.length))
        return result != nil
    }
}

PasscodePresenter.swiftにおけるコード実装:

PasscodePresenter.swift
import Foundation

protocol PasscodePresenterDelegate: NSObjectProtocol {
    func goNext()
    func dismissPasscodeLock()
    func savePasscode()
    func showError()
}

class PasscodePresenter {

    private let previousPasscode: String?

    weak var delegate: PasscodePresenterDelegate?

    // MARK: - Initializer

    // MEMO: 前の画面で入力したパスコードを利用したい場合は引数に設定する
    init(previousPasscode: String?) {
        self.previousPasscode = previousPasscode
    }

    // MARK: - Function

    // ViewController側でパスコードの入力が完了した場合に実行する処理
    func inputCompleted(_ passcode: String, inputPasscodeType: InputPasscodeType) {
        let passcodeModel = PasscodeModel()

        switch inputPasscodeType {

        case .inputForCreate, .inputForUpdate:

            // 再度パスコードを入力するための確認画面へ遷移する
            self.delegate?.goNext()
            break


        case .retryForCreate, .retryForUpdate:

            // 前画面で入力したパスコードと突き合わせて、同じだったらUserDefaultへ登録する
            if previousPasscode != passcode {
                self.delegate?.showError()
                return
            }
            if passcodeModel.saveHashedPasscode(passcode) {
                self.delegate?.savePasscode()
            } else {
                self.delegate?.showError()
            }
            break


        case .displayPasscodeLock:

            // 保存されているユーザーが設定したパスコードと突き合わせて、同じだったらパスコードロック画面を解除する
            if passcodeModel.compareSavedPasscodeWith(inputPasscode: passcode) {
                self.delegate?.dismissPasscodeLock()
            } else {
                self.delegate?.showError()
            }
            break
        }
    }
}

補足事項としましては、パスコードをUserDefaultへ保存する際にはそのまま4桁の数値の文字列として保存しておくのではなく、パスコードをHMAC-SHA256化して保存しています。HMAC-SHA256化をする際に追加をする必要がある処理やプロジェクト内で設定に関する手順については下記にご紹介するリンクをご参考にして頂ければ幸いです。

参考: HMAC-SHA256化をするための処理:

★3-4: TouchIDやFaceIDを利用してパスコードを解除する機能を実装する際のポイント解説

次にTouchIDやFaceIDによる認証を利用してパスコードを解除するための実装について見てみましょう。今回のサンプルにおいてはパスコードロック画面を表示している場合において、もしユーザーがTouchIDやFaceIDの利用を許可している場合においては前述したInputPasscodeKeyboardView.swiftで定義しているTouchID/FaceIDの認証を実行するためのボタンが有効になるようにしています。またFaceIDが導入されたのはiOS11以降になるので認証状態を判定するための.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)の結果に関してもiOSのバージョンによる場合分けが必要になる点には注意が必要です。今回のサンプルでは、ボタン押下時にTouchIDやFaceIDでの認証用ダイアログを表示するための処理をLocalAuthenticationManager.swiftというひとまとめにした形のクラスとして実装をしています。

LocalAuthenticationType.swiftにおけるコード実装:

LocalAuthenticationType.swift
import Foundation

enum LocalAuthenticationType {
    case authWithFaceID  // FaceIDでのパスコード解除
    case authWithTouchID // TouchIDでのパスコード解除
    case authWithManual  // 手動入力でのパスコード解除

    // MARK: - Function

    func getDescriptionTitle() -> String {
        switch self {
        case .authWithFaceID:
            return "FaceID"
        case .authWithTouchID:
            return "TouchID"
        default:
            return ""
        }
    }

    func getLocalizedReason() -> String {
        switch self {
        case .authWithFaceID, .authWithTouchID:
            return "\(self.getDescriptionTitle())を利用して画面ロックを解除します。"
        default:
            return ""
        }
    }
}

LocalAuthenticationManager.swiftにおけるコード実装:

LocalAuthenticationManager.swift
import Foundation
import LocalAuthentication

class LocalAuthenticationManager {

    // MARK: - Static Function

    static func getDeviceOwnerLocalAuthenticationType() -> LocalAuthenticationType {
        let localAuthenticationContext = LAContext()

        // iOS11以上の場合: FaceID/TouchID/パスコードの3種類
        if #available(iOS 11.0, *) {

            if localAuthenticationContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) {
                switch localAuthenticationContext.biometryType {
                case .faceID:
                    return .authWithFaceID
                case .touchID:
                    return .authWithTouchID
                default:
                    return .authWithManual
                }
            }

        // iOS10以下の場合: TouchID/パスコードの2種類
        } else {

            if localAuthenticationContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) {
                return .authWithTouchID
            } else {
                return .authWithManual
            }

        }
        return .authWithManual
    }

    static func evaluateDeviceOwnerLocalAuthentication(successHandler: (() -> ())? = nil, errorHandler: (() -> ())? = nil) {
        let type = self.getDeviceOwnerLocalAuthenticationType()

        // パスコードでの解除の場合は以降の処理は行わない
        if type == .authWithManual {
            return
        }

        // FaceID/TouchIDでの認証結果に応じて引数のクロージャーに設定した処理を実行する
        let localAuthenticationContext = LAContext()
        localAuthenticationContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: type.getLocalizedReason(), reply: { success, evaluateError in
            if success {
                // 認証成功時の処理を書く
                successHandler?()
                print("認証成功:", type.getDescriptionTitle())
            } else {
                // 認証失敗時の処理を書く
                errorHandler?()
                print("認証失敗:", evaluateError.debugDescription)
            }
        })
    }
}

FaceIDやTouchIDを利用した認証を利用する際に必要な基本的な処理やプロジェクト内で設定に関する手順については下記にご紹介するリンクをご参考にして頂ければ幸いです。

参考: TouchIDやFaceIDを利用するための処理:

また、TouchIDやFaceIDでの認証を実行時に表示されるダイアログを表示した場合にはAppDelegate.swiftapplicationWillResignActiveが実行され、またダイアログを閉じた場合にはAppDelegate.swiftapplicationDidBecomeActiveが実行されますので、開発しているアプリの中で該当のライフサイクルの中で既に実装をしている処理との兼ね合いに関しても配慮をする様にしておくと良いかもしれませんね。

※アプリを新しくインストールした際によく見かけるプッシュ通知の許可ダイアログ表示についても同様な事が起こります。

applicationWillResignActive: フォアグラウンドからバックグラウンドへ移行しようとした時
認証成功: FaceID
applicationDidBecomeActive: アプリの状態がアクティブになった時

★3-5: パスコード入力画面のViewControllerの実装と用途に応じた処理をするPresenterとつなげる実装のポイント解説

次にパスコード入力画面のViewControllerの実装と用途に応じた処理をするPresenterとつなげる実装について見てみましょう。画面全体のハンドリングに関しては前述したPasscodePresenter.swiftを利用し、配置したテンキー状のボタン押下時は前述したInputPasscodeKeyboardView.swiftで定義しているInputPasscodeKeyboardDelegateを利用してボタンの入力との連動ができるようにします。

各種定義したProtocolと連動する処理に関する図解:

passcode_viewcontroller.png

またTouchIDやFaceIDの認証状態に応じた画面の状態変更が必要な部分や認証ダイアログを表示するタイミングについてはLocalAuthenticationManager.swiftにて定義したクラスのメソッドを利用するようにします。

PasscodePresenter.swiftで定義した入力完了時に実行する.inputCompleted(userInputPasscode, inputPasscodeType: inputPasscodeType)メソッドを実行したタイミングで実行されるPasscodePresenterDelegateの処理に関しては、早すぎる入力を行なった際に意図しない画面遷移を実行される現象の対応策 として0.24秒の間は画面操作を受け付けない状態にして、その後に画面遷移等の処理実行する様な形としています。

PasscodeViewController.swiftにおけるコード実装:

PasscodeViewController.swift
import UIKit
import AudioToolbox

class PasscodeViewController: UIViewController {

    // 画面遷移前に引き渡す変数
    private var inputPasscodeType: InputPasscodeType!
    private var presenter: PasscodePresenter!

    private var userInputPasscode: String = ""

    @IBOutlet weak private var inputPasscodeDisplayView: InputPasscodeDisplayView!
    @IBOutlet weak private var inputPasscodeKeyboardView: InputPasscodeKeyboardView!
    @IBOutlet weak private var inputPasscodeMessageLabel: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()

        // MEMO: PasscodePresenterに定義したプロトコルの処理を実行するようにする
        presenter.delegate = self

        setupUserInterface()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        hideTabBarItems()
    }

    // MARK: - Function

    func setTargetPresenter(_ presenter: PasscodePresenter) {
        self.presenter = presenter
    }

    func setTargetInputPasscodeType(_ inputPasscodeType: InputPasscodeType) {
        self.inputPasscodeType = inputPasscodeType
    }

    // MARK: - Private Function

    private func setupUserInterface() {
        setupNavigationItems()
        setupInputPasscodeMessageLabel()
        setupPasscodeNumberKeyboardView()
    }

    private func setupNavigationItems() {
        setupNavigationBarTitle(inputPasscodeType.getTitle())
        removeBackButtonText()
    }

    private func setupInputPasscodeMessageLabel() {
        inputPasscodeMessageLabel.text = inputPasscodeType.getMessage()
    }

    private func setupPasscodeNumberKeyboardView() {
        inputPasscodeKeyboardView.delegate = self

        // MEMO: 利用している端末のFaceIDやTouchIDの状況やどの画面で利用しているか見てボタン状態を判断する
        var isEnabledLocalAuthenticationButton: Bool = false
        if inputPasscodeType == .displayPasscodeLock {
            isEnabledLocalAuthenticationButton = LocalAuthenticationManager.getDeviceOwnerLocalAuthenticationType() != .authWithManual
        }
        inputPasscodeKeyboardView.shouldEnabledLocalAuthenticationButton(isEnabledLocalAuthenticationButton)
    }

    private func hideTabBarItems() {
        if let tabBarVC = self.tabBarController {
            tabBarVC.tabBar.isHidden = true
        }
    }

    private func acceptUserInteraction() {
        self.view.isUserInteractionEnabled = true
    }

    private func refuseUserInteraction() {
        self.view.isUserInteractionEnabled = false
    }

    // 最初の処理Aを実行 → 指定秒数後に次の処理Bを実行するためのラッパー
    // MEMO: 早すぎる入力を行なった際に意図しない画面遷移を実行される現象の対応策として実行している
    private func executeSeriesAction(firstAction: (() -> ())? = nil, deleyedAction: @escaping (() -> ())) {
        // 最初は該当画面のUserInteractionを受け付けない
        self.refuseUserInteraction()
        firstAction?()

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
            // 指定秒数経過後は該当画面のUserInteractionを受け付ける
            self.acceptUserInteraction()
            deleyedAction()
        }
    }
}

// MARK: - PasscodeNumberKeyboardDelegate

extension PasscodeViewController: InputPasscodeKeyboardDelegate {

    func inputPasscodeNumber(_ numberOfString: String) {

        // パスコードが0から3文字の場合はキーボードの押下された数値の文字列を末尾に追加する
        if 0...3 ~= userInputPasscode.count {
            userInputPasscode = userInputPasscode + numberOfString
            inputPasscodeDisplayView.incrementDisplayImagesBy(passcodeStringCount: userInputPasscode.count)
        }

        // パスコードが4文字の場合はPasscodePresenter側に定義した入力完了処理を実行する
        if userInputPasscode.count == AppConstant.PASSCODE_LENGTH {
            presenter.inputCompleted(userInputPasscode, inputPasscodeType: inputPasscodeType)
        }
    }

    func deletePasscodeNumber() {

        // パスコードが1から3文字の場合は数値の文字列の末尾を削除する
        if 1...3 ~= userInputPasscode.count {
            userInputPasscode = String(userInputPasscode.prefix(userInputPasscode.count - 1))
            inputPasscodeDisplayView.decrementDisplayImagesBy(passcodeStringCount: userInputPasscode.count)
        }
    }

    func executeLocalAuthentication() {

        // パスコードロック画面以外では操作を許可しない
        guard inputPasscodeType == .displayPasscodeLock else {
            return
        }

        // TouchID/FaceIDによる認証を実行し、成功した場合にはパスコードロックを解除する
        LocalAuthenticationManager.evaluateDeviceOwnerLocalAuthentication(
            successHandler: {
                DispatchQueue.main.async {
                    self.dismiss(animated: true, completion: nil)
                }
            },
            errorHandler: {}
        )
    }
}

// MARK: - PasscodePresenterProtocol

extension PasscodeViewController: PasscodePresenterDelegate {

    // 次に表示するべき画面へ入力された値を引き継いだ状態で遷移する
    func goNext() {
        executeSeriesAction(
            firstAction: {},
            deleyedAction: {
                // Enum経由で次のアクションで設定すべきEnumの値を取得する
                guard let nextInputPasscodeType = self.inputPasscodeType.getNextInputPasscodeType() else {
                    return
                }
                // 遷移先のViewControllerに関する設定をする
                let sb = UIStoryboard(name: "Passcode", bundle: nil)
                let vc = sb.instantiateInitialViewController() as! PasscodeViewController
                vc.setTargetInputPasscodeType(nextInputPasscodeType)
                vc.setTargetPresenter(PasscodePresenter(previousPasscode: self.userInputPasscode))
                self.navigationController?.pushViewController(vc, animated: true)


                self.userInputPasscode.removeAll()
                self.inputPasscodeDisplayView.decrementDisplayImagesBy()
            }
        )
    }

    // パスコードロック画面を解除する
    func dismissPasscodeLock() {
        executeSeriesAction(
            firstAction: {},
            deleyedAction: {
                self.dismiss(animated: true, completion: nil)
            }
        )
    }

    // ユーザーが入力したパスコードを保存して設定画面へ戻る
    func savePasscode() {
        executeSeriesAction(
            firstAction: {},
            deleyedAction: {
                self.navigationController?.popToRootViewController(animated: true)
            }
        )
    }

    // ユーザーが入力した値が正しくないことをユーザーへ伝える
    func showError() {
        executeSeriesAction(
            // 実行直後はエラーメッセージを表示する & バイブレーションを適用する
            firstAction: {
                self.inputPasscodeMessageLabel.text = "パスコードが一致しませんでした"
                AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
            },
            // 秒数経過後にユーザーが入力したメッセージを空にする & パスコードのハート表示をリセットする
            deleyedAction: {
                self.userInputPasscode.removeAll()
                self.inputPasscodeDisplayView.decrementDisplayImagesBy()
            }
        )
    }
}

このような形で一見すると似た様な構造ではあるけれど、利用される用途が異なる様な画面を構築する場合においては画面用途に応じて振る舞いを切り替えられるような仕組みにしておくと便利ではあるかと思います。今回紹介したサンプルでは 「MVPパターンと使用用途に応じたEnum値を元に判定する」 アーキテクチャを使用した構成となっている点ができるだけ実装の見通しを良くするためのポイントになります。

※ もし自分はこんな形式やアーキテクチャで実装しているよ!という事例をご存知の方がいらっしゃいましたらお教え頂けると嬉しいですm(_ _)m

★3-6: AppDelegate.swiftや一番最初に表示する画面にてパスコードロック画面を表示する機能を実装する際のポイント解説

最後にAppDelegate.swiftや一番最初に表示する画面にてパスコードロック画面を表示する機能の実装について見てみましょう。パスコードロック画面を表示させるタイミングについてまとめると、

  1. アプリをバックグラウンドに持っていった状態から再度フォアグラウンドへアプリを復帰させる場合 → AppDelegate.swiftにおいてapplicationDidEnterBackgroundのタイミングでパスコードロック画面を表示
  2. アプリを一旦閉じた状態から再度アプリを起動させる場合 → GlobalTabBarController.swiftにおいてUIApplication.didFinishLaunchingNotificationを受け取ったタイミングでパスコードロック画面を表示

という2通りの処理が必要になります。

パスコードロック画面を表示する処理の概要図解:

passcode_lock_explain.png

AppDelegate.swiftにおけるコード実装:

AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    ・・・(省略)・・・

    func applicationDidEnterBackground(_ application: UIApplication) {
        print("applicationDidEnterBackground: バックグラウンドへ移行完了した時")

        // パスコードロック画面を表示する
        displayPasscodeLockScreenIfNeeded()
    }

    ・・・(省略)・・・

    // MARK: - Private Function

    private func displayPasscodeLockScreenIfNeeded() {
        let passcodeModel = PasscodeModel()

        // パスコードロックを設定していない場合は何もしない
        if !passcodeModel.existsHashedPasscode() {
            return
        }

        if let rootViewController = UIApplication.shared.keyWindow?.rootViewController {

            // 現在のrootViewControllerにおいて一番上に表示されているViewControllerを取得する
            var topViewController: UIViewController = rootViewController
            while let presentedViewController = topViewController.presentedViewController {
                topViewController = presentedViewController
            }

            // すでにパスコードロック画面がかぶせてあるかを確認する
            let isDisplayedPasscodeLock: Bool = topViewController.children.map{
                return $0 is PasscodeViewController
            }.contains(true)

            // パスコードロック画面がかぶせてなければかぶせる
            if !isDisplayedPasscodeLock {
                let nav = UINavigationController(rootViewController: getPasscodeViewController())
                nav.modalPresentationStyle = .overFullScreen
                nav.modalTransitionStyle   = .crossDissolve
                topViewController.present(nav, animated: true, completion: nil)
            }
        }
    }

    private func getPasscodeViewController() -> PasscodeViewController {
        // 遷移先のViewControllerに関する設定をする
        let sb = UIStoryboard(name: "Passcode", bundle: nil)
        let vc = sb.instantiateInitialViewController() as! PasscodeViewController
        vc.setTargetInputPasscodeType(.displayPasscodeLock)
        vc.setTargetPresenter(PasscodePresenter(previousPasscode: nil))
        return vc
    }
}

この部分の処理においてポイントは、 「現在のrootViewControllerにおいて一番上に表示されているViewControllerの上にモーダルでパスコードロック画面をかぶせる」 ことによってその他の画面を表示している場合においても、パスコードロックを解除した際に以前に表示していた画面へ戻る事ができる様に配慮している点にあるかと思います。またこの様な処理を実装する際には下記でご紹介されているリンクの記事が参考になりました。

参考: 現在表示されている画面において最前面のViewControllerを取得する:

GlobalTabBarController.swiftにおけるコード実装:

GlobalTabBarController.swift
import UIKit
import FontAwesome_swift

class GlobalTabBarController: UITabBarController, UITabBarControllerDelegate {

    override func viewDidLoad() {
        super.viewDidLoad()

        self.delegate = self
        self.viewControllers = [UIViewController(), UIViewController()]

        setupUserInterface()

        // アプリ起動完了時のパスコード画面表示の通知監視
        NotificationCenter.default.addObserver(self, selector: #selector(self.displayPasscodeLockScreenIfNeeded), name: UIApplication.didFinishLaunchingNotification, object: nil)
    }

    // MARK: - Private Function

    @objc private func displayPasscodeLockScreenIfNeeded() {
        let passcodeModel = PasscodeModel()

        // パスコードロックを設定していない場合は何もしない
        if !passcodeModel.existsHashedPasscode() {
            return
        }

        let nav = UINavigationController(rootViewController: getPasscodeViewController())
        nav.modalPresentationStyle = .overFullScreen
        nav.modalTransitionStyle   = .crossDissolve
        self.present(nav, animated: false, completion: nil)
    }

    ・・・(省略)・・・

    private func getPasscodeViewController() -> PasscodeViewController {
        // 遷移先のViewControllerに関する設定をする
        let sb = UIStoryboard(name: "Passcode", bundle: nil)
        let vc = sb.instantiateInitialViewController() as! PasscodeViewController
        vc.setTargetInputPasscodeType(.displayPasscodeLock)
        vc.setTargetPresenter(PasscodePresenter(previousPasscode: nil))
        return vc
    }
}

4. 今回の機能を更によくするためのアイデアや実装の際に気をつけると良い点について

ここでは、今回のサンプルにおいては考慮していませんが、実際に開発しているアプリにこのような機能を追加していく際には更に考慮しておいた方が良さそうな点について簡単に紹介をしていきます。

特に、設定したパスコードをユーザーが忘れてしまった際にアプリのサーバーサイド側で登録されているメールアドレス等の情報等をうまく利用してパスコードロックを解除できる機能の様に、ユーザーに対して優しい配慮ができるような機能を予め追加しておくと更に良いものにできると考えています。

passcode_appendix.png

また実装面での考慮事項としては、今回の実装についてはAppDelegate.swiftのライフサイクルを利用した機能になりますので、この他にもURLスキーム等からのアプリ起動を実行した場合についての考慮を想定しておくと更に良い実装ができるように思います。

5. あとがき

今回のサンプルで実装している機能に関しては、あくまで必要最低限の部分だけの実装になりますので、実際にお使いのアプリの中で実現する場合にはAppDelegete.swift内のライフサイクルに既に実装している処理やアプリの起動経路の中で必要な処理との兼ね合いについての考慮や利用しているユーザーに安心して利用して頂けるようにするための配慮するための機能も追加する必要が出てくるかと思います。また、パスコードロック画面のUI要素の構成についてはシンプルなものではありますが、細かなUIに関する動きや使いやすく・わかりやすくするための細かな工夫を盛り込むことで、更に機能面やUX面に関してもより良いものにできる余地がある部分でもあるかとも感じています。

今後ともiOSアプリに関するUI実装に関連するサンプル開発を通じた知見を数多くご紹介できるように、平素からアンテナを張りながら、どのような局面やアプリの中で活用していくと良いかという視点から常に考察していく姿勢は崩すことの内容にしていきたいと感じている次第です。そして来年もまた引き続き何卒よろしくお願い致します。

2018年のiOSアプリ開発記事を振り返る

はじめに

2018年のiOSアプリ開発の振り返りとして技術記事/動画を勝手に振り返ってみます。
ランキングではなく個人的に何回か読み返した記事となります。
ですのでここにはない分かりやすく有用なTipsなんかも今年多くあったとは思うのですが、それらについては記載してません。

紹介する記事/動画については私の理解でその概要と感想を書いていますが、間違えているかもしれませんね。みなさんもし間違っていたら教えていただけるとありがたいです。

Swift.Decodable + Int64 / iOS 10 = 要注意

https://techlife.cookpad.com/entry/2018/03/06/100121

たぶんこんな事が書いてある

  • 概要
    • サーバサイドのレスポンスを内部のインスタンス化する際にエラーになった
    • 新規(もしくはHimotokiからの移行?)でSwift.Decodableを使ってた
    • Int64とiOS10のデバイスでのみ再現する
  • 経緯
    • 1000000000000000070をInt64のidにDecodeするとiOS10デバイスでエラーする再現ができた
    • SwiftはオープンソースなのでInt64のデコードでエラーがでる部分を調べた
      • そのコードだけでは判定がエラーになることしかわからない
      • iOS11ではNSNumberを使うところがiOS10ではNSDecimalNumberを使っているのが原因と予測
  • 回避策
    • 結果的に値が大きいときはHimotokiを使うことにした

感想

  • 1000000000000000071でエラーにならないとも書いている部分がイマイチよくわからない
    • 1000000000000000070では問題ありエラー
    • 1000000000000000071では問題ないらしい(なぜ値が大きくなったのに?)
    • 1000000000000000080でも問題ありエラー
  • そもそもこれとは別の話だけど関連性のある話題として
    • アプリではユースケースとしてidなどが64bit超えの値を使いたいというのがある
      • サーバサイドでidをAuto Incrementしているのだろう
      • ユーザ参加型のコンテンツは数がめちゃくちゃ増えるので64bit整数が使われる
    • しかしアプリとしたらidはStringでサーバサイドから返ってくればいい
      • レスポンスとして idはStringで返したらいいんじゃない? といつも思う
        • Int64だとか考えなくて良くなるし

Storyboardとの付き合い方 2018

https://blog.ishkawa.org/2018/08/13/1534116670/

たぶんこんな事が書いてある

  • IBDesignableのテクニックとしてモジュール分割する
    • IBDesignableのプレビューのためにそのターゲットのクラスたちがビルドされ時間かかる
      • モジュール分割しておけばビルド対象のコードを減らせる
  • スタイルの指定はクラスが行いIB上で繰り返さない
    • IB上で指定しない
    • IBの「User Defined Runtime Attributes」も使わない
  • 画面遷移でsegueを使わずにファクトリメソッドで遷移する
    • segueの値はコードでも管理しないと二重管理になってしまうから

感想

  • スタイル の指定をIBでやらないのは経験上有りだと思う
    • 「User Defined Runtime Attributes」の文字列での指定はマジしんどいし
      • やっぱりパラメータ指定はコンパイルエラー出してほしい
    • 「User Defined Runtime Attributes」で頑張って指定できるようにしがち
      • 文字列指定からスタイリングの振る舞いを決定するようなコードにしがち
        • コピペミスしても気づけずコンパイルエラーにならないので困りがち
  • segueの二重管理の件はR.swiftなりSwiftGen使えばStoryboardファイルからコードを生成できる
    • そのコード生成したものをファクトリメソッドから利用したら良いとは思う
    • R.swiftは1つのファイルに全部の自動生成コードが入るのがデメリットではある

iOS x GraphQLの嬉しみとつらみ iOSDC2018

https://www.youtube.com/watch?v=g9ZMJrc4fjc

たぶんこんな内容だと思う

  • GraphQLの特徴
    • Facebook作のクエリ言語
    • SQLライクに情報をリクエストすると複数の情報も一度のレスポンスにできる
    • レスポンスの必須/非必須を表現できる
    • エラーを返してくれる
    • ページネーションの仕組みが考えられている
  • Swiftのコードは型情報から自動生成できるライブラリがある(Apollo-iOS)
  • GraphQLが最新のドキュメントになっている
  • クライアントごとにAPIを作り分ける必要がない
  • 短所
    • N+1問題発生しやすい
    • ステータスコードは基本200(ステータスコードでAPIの状態を表現しない)

感想

  • Facebookは昔グラフAPIというのがRESET APIとは別に用意されていたのでそれに関係がありそう
  • GitHubもGraphQLのAPIが用意されてる
  • 何に役に立つかって言うと
    • 1回のリクエストで済ませたいのに2,3回リクエストを送らないといけない場合があるでしょうと
    • 例えばブログサービス作ってAPIを作った
      • 1リクエストのAPIでユーザ情報を返すようにしたがフォロワー数はフォローAPIが必要
    • 必要のない情報はレスポンスに含ませないこともできる
  • Facebookと連携するアプリ作るときにGraphQL対応してるっていうのはでかい
    • しかし自分たちが作ろうとするサービスがあったとしてGraphQL対応しようとすると大変だろうな
    • 世の中サーバサイドでREST API実装したことがない人もまだままだ多い
    • アプリ側がUXにあわせてRESET APIの設計を引っ張らないと結局あとで苦労するのはアプリ側
    • GraphQLならリクエスト数を減らせることなどUXに柔軟に対応できるかもしれない

SSL証明証を検知する iOSDC2018

https://www.youtube.com/watch?v=DjKsy5jBM2w

たぶんこんな内容だと思う

  • HTTPSを使っても安全とは言い切れない
    • MITM攻撃(マン・イン・ザ・ミドル アタック)される
      • 悪意のあるFree WiFiとか
    • 説明としてSSL通信の仕組みのための流れ
      • クライアントがサーバに接続要求
      • サーバがクライアントに公開鍵の送信
      • 共通鍵の暗号化
      • クライアントがサーバに暗号化したデータ送信
    • MITM攻撃は
      • クライアントとサーバの中間に立ちやりとりを仲介する中間者を立てることでクライアントのデータを見られる
    • ピンどめ(SSL Pining)による解決法とは
      • SSLサーバ証明書がクライアントが期待しているものと同一かを検証
        • クライアントには事前に公開鍵が組み込まれているので比較できる
      • ピン留めの種類
        • 種類
          • 証明書ピンどめ
            • SSLサーバ証明書そのものが一致するか検証
            • サーバ側で更新されるものなのでクライアントも更新する必要がある
          • 公開鍵ピンどめ
            • 証明書のもとになる公開鍵が一致するか検証
        • どっちの種類を使うべき?
          • Androidでは
            • Android 7の公式
              • 公開鍵のピンどめをサポート
            • OkHttp
              • 公開鍵のピンどめをサポート
          • iOSのライブラリやるなら
            • APIKit
              • 公式でできるわけではない
              • SessionAdapterプロトコルに適合したクラスを実装すると
                • 通信時のハンドラをカスタマイズできるのでそれ使う
            • Alamofire
              • 標準でサポートされている!!!
              • Kyashはこれを使ってる
      • SSLサーバ証明書の運用
        • サーバの更新に合わせてクライアント側を強制で更新する
        • サーバを先に更新すると困るのでクライアントは先に新旧両方の証明書を更新したものをアップデート
      • よくある課題
        • 強制アップデートも通信するならチェックされるから困るよね
          • 別ホストかFirebaseでアップデートするかどうかをチェックする仕組み
  • 大事なこと
    • アプリチームだけで判断せず相談しよう(別チームにセキュリティチームがいればそれが吉)
  • 質問
    • 公開鍵ピン留めのsha256のハッシュはハードコードとファイルどっちが良いの?
      • どちらでも良い
      • 公開鍵のハッシュはOpenSSLのコマンドで指定したドメインからとれるものなので

感想

  • MITM攻撃は簡単でCharlseなりmitmproxy使って端末にルート証明書入れれば通信は見られる
  • Alamofireのこういうメリットが分かるのはいい
    • Alamofire使ってもAPIKitライクな設計をするのは簡単なのでこういうときAlamofire使うのはいいと思う
  • Youtubeでセキュリティに関する運用の話が見られるのは参考になります

SwiftConf '18 - Shai Mishali: RxSwift: debunking the myth of hard

https://www.youtube.com/watch?v=GdvLP0ZAhhc

たぶんこんな内容だと思う

  • RxSwiftについての発表
    • DisposeBagについて
    • Subject
      • PublishSubject, BehaviourSubject, ReplaySubjectの違いをうっすら
      • それぞれをイラストでも示している
      • Observableとの違い
    • Control
      • ControlEvent, ControlProperty, Binderについてを分類
    • その他
      • Driver
      • CustomBinder
      • Relay
      • Signal

感想

  • 初心者用ではないかもしれない
    • Rxをやったことがある人なら分かる良さがある
    • 類似のものを整理と比較していてとにかくわかりやすい

RxTest、RxBlockingによるテストパターン

https://qiita.com/takehilo/items/09f4a3077e441e5bb9de

たぶんこんな内容だと思う

  • RxSwiftを使ったテストのパターンについて
    • Quick/Nimbleも使ってる

感想

  • RxTestとRxBlockingといいつつQuick/Nimbleも使ってる例になっていて、凄く良い
    • 特にRxTestでexpectedの配列の展開がいい
      • 自分は期待値を別の変数にして初期化したやつを使ってた
    • 逆にQuick/Nimbleを使わない人にとっては読みづらいかもしれない

Kotlin Fest 2018 Kotlin コルーチンを理解しよう

カンファレンス動画(アカウント登録が必要)
https://crash.academy/video/314/1617

発表資料
https://speakerdeck.com/sys1yagi/kotlin-korutinwo-li-jie-siyou

  • コルーチンの歴史
    • 55年前のコンウェイさんの論文
      • COBOLのため
  • Kotlinのコルーチンでは
    • Kotlinで書いたコードをJavaバイトコードにする際にコンパイラがコルーチン用のコードを自動で組み立ててステートマシンにしている
  • コルーチンビルダー(引数にクロージャをとる関数)を使うことで実行スレッドを決定できる

感想

  • Kotlinでは書いたコードをコンパイラがコルーチン用のコードに組み立てられる
    • C#のコルーチンもJVMがバイトコードでコードを勝手にステートマシン化している
  • Swiftもプログラマが書いたコルーチンAPI利用のコードを自動でステートマシン化してくれる
    • Kotlinの方針はSwiftでも参考になるはず
    • Kotlinのsuspend修飾子のやり方はSwiftでのasync/await的なやり方になるだろう
    • Kotlinのasync/awaitもDeferred返すので同時実行できるけど、そのやり方はSwiftでのFuture利用となるだろう
  • Swiftでもコルーチンビルダーがあればいいんだけどなあ
    • スレッドの切り替えはコルーチン実行時にその中で切り替える必要がある
  • KotlinのコルーチンはSwift利用者が欲しかったもののような気がする
    • Futureを使わないSwiftのasync/awaitではsuspend修飾子を使ったもののようになることで、軽量なコルーチンをあれしている

あと、誰が言ったんだかわからないんですが、そもそもなんで大昔からあるコルーチンが最近流行ってんのかっていうのは次のツイートがなんとなく表している気がします

生きた仕様書としてのUIカタログアプリ運用 構想編

https://speakerdeck.com/hiragram/sheng-kitashi-yang-shu-tositefalseuikataroguapuriyun-yong-gou-xiang-bian

感想

  • UIの仕様をカタログとして切り出すのは興味深いアプローチ
    • Storybookっぽいとは思うもののアプリ開発者である私がやりたいのは画面仕様の確認なのでStorybookとは違うかもしれない
  • 仕様をテストで担保するのはよくある
    • テスターがいて手動でテストする工程があるならテスターが作るテスト仕様が実質仕様みたいなことに
      • そうしないとテスターが何が正しいか判断できないからそうするしかない
      • 結局これも確認の仕方は難しい
    • テストコードが書かれていて自動化されている
      • ロジックのテストはできるが見た目的な検証は難しい

Kotlin と比較して理解する Swift 5 で実装されるかもしれない async/await について

https://speakerdeck.com/yimajo/await-nituite

こんな内容

  • Kotlinのコルーチンはasync/awaitがありそれを使いやすくするsuspendがある
    • suspendという概念はユニークなんだけどこれはSwift 5.xでもこんな感じになる
      • なにそれ?
        • SwiftもKotlinも非同期関数を同期的に記述して結果をラッピングせず取り出したい
        • 言い換えるとアンラップした状態で取得したい
    • Kotlinのasync/awaitはDeferred<T>ベース(Future/Promiseみたいなもの)
      • suspendはそれを取り出して取得できる
        • しかしそうすると複数処理の待ち合わせみたいなことはできない
        • そうしたらDeferred<T>使うじゃん
    • Swiftでも基本はsuspendのような感じで、Futureは自分で実装する

感想

  • SwiftのほうはFutureの実装せずコルーチン導入後に議論などを後回しにできるためスモールスタートなんだろう
    • Kotlinは一社で開発して一気に実装してexperimentalでリリースしてという勢いを感じる
  • KotlinのコルーチンがRxとどんなふうに置き換わるかというのは参考になるはず

CodeZineにあるRxSwiftの記事(第4回)に対して自分ならこうするという話

https://qiita.com/yimajo/items/947e1a8fc15f577779af

こんな内容

  • CodeZineのRxSwift記事に対してのツッコミ
    • 「RxSwiftの仕組みを利用して、MVVMモデルを導入しよう - RxSwiftを使った一歩進んだiOSアプリ開発 第4回」
    • CodeZineの記事は おそらく Rxの基礎を知らないまま書いている
      • エラーハンドリングをしていない
        • Rxのエラーはサブスクライブを停止させることを分かっていない
          • UIのイベントをサブスクライブしているとUIが停止する
            • 記事ではそれを回避するためかハンドリングせすrx.tap使ってる...
    • 自分がCodeZineの記事がいまいちだと思うのは
      • Variableを使ってる(使えなくなるってわかってるものを使うのは技術的負債)
      • サンプルコードで必要もないのにAlamofire使ってる
      • 「protocolでテストが用意になります」と書いてあるのにテストコード書いていない
        • 本当にテスト容易になる意味のあるprotocolなのかを示して欲しい
      • サブスクライブでUIイベントを停止させられることを分かっていないから
        • ViewControllerで.rx.tapから3文字以上の条件を書いてそこからObservableシーケンスをSubjectで発行している

感想

  • そもそも正解なんてものはないけど
    • アプリって長く運用保守していくこともあるので基礎が大事
    • 長く運用する予定ないならそもそもRxSwift使わなきゃいい
  • 技術記事を書いてるってことは経験があるんだろうけどそれでも基礎が足りてないように感じる
    • RxSwiftは相当難しい
  • 必要のないAlamofire使いたくなるんだろうけどそれを入門者に強いる前にURLSessionを知る必要がある
  • (RxSwift入門者用には)自分のばりばりの手癖のあるコードを示すのではなく基礎が大事

おわりに

今年はGoogleからasync/awaitが使えるPromiseライブラリ https://github.com/google/promises がリリースされたんですが、使ってるという声を聞かず、記事も見かけません。それが少し残念というかもっと使われても良いのにと思います。このライブラリはもともとのasync/awaitが使えるPromiseライブラリ https://github.com/malcommac/Hydra を参考にしていて、コードベースもかなり似ています。まあスレッドでasync/awaitやろうとしたら方法は限られてくるからなのかもしれませんが、Google Promisesのインターフェースは比較的癖が少なく優等生感がバリバリで、そのパクられ元のHydraは優等生感よりもセンス重視なのがそれぞれの良いところでもあります。

2019年は新規でアプリを作るとして、もしSwiftに慣れていないならそのようなPromise系ライブラリを試したらいいのではないかと思います。他の言語や他プラットフォームでも使われる非同期処理手法であるPromiseでアプリを作っていくことはRxSwiftやReactiveSwiftを使うより断然楽ですし、Swift 5.xで導入される予定のコルーチンによるasync/awaitに先駆けてスレッドベースのasync/awaitを使えます。ちなみにスレッドでどのようにasync/awaitを実現しているかについては「async/await研究読本」 https://booth.pm/ja/items/898239 に書きました。

そもそもSwift 5.xでコルーチンが導入されてasync/awaitが使えるようになってもFuture(もしくはPromise)が導入されるわけではなさそうなため、Futureを自作していくかサードパーティのライブラリで導入することになるんですが、そのときそのFutureライブラリの良さを見極める必要性というのはでてくるわけです。そうなるとやっぱりあらかじめPromise系のライブラリ触ってるというのは必須ではないにしろ良い経験になるんじゃないかなーともおもいます。

Browsing Latest Articles All 25 Live