24/7 twenty-four seven

iOS/OS X application programing topics.

接続先がATS (App Transport Security)に対応しているか、または例外の設定をnscurlコマンドで簡単に調べる

TL;DR,

$ nscurl --ats-diagnostics --verbose https://kishikawakatsumi.com/のようにnscurlコマンドに--ats-diagnostics --verboseオプションをつけて実行すると、指定したドメインがATSの要件を満たしているかどうかをチェックし、デフォルトの設定でエラーが起こる場合はエラー回避するための設定まで教えてくれます。

developer.apple.com


iOS 9からATS (App Transport Security)の仕組みが導入され、HTTP(HTTPSでない)通信はブロックされ、HTTPSでも接続先がATSの要件を満たしてない通信についてはデフォルトで失敗するように変更されました。

HTTPの通信はブロックされます。

App Transport Security has blocked a cleartext HTTP (http://) resource load since it is insecure. Temporary exceptions can be configured via your app's Info.plist file.
Error Domain=NSURLErrorDomain Code=-1022 "The resource could not be loaded because the App Transport Security policy requires the use of a secure connection." UserInfo={NSUnderlyingError=0x7f9bb4099c00 {Error Domain=kCFErrorDomainCFNetwork Code=-1022 "(null)"}, NSErrorFailingURLStringKey=http://kishikawakatsumi.com/, NSErrorFailingURLKey=http://kishikawakatsumi.com/, NSLocalizedDescription=The resource could not be loaded because the App Transport Security policy requires the use of a secure connection.}


HTTPSの通信でもTLS 1.0を使用しているなど、接続先がATSの要件を満たしていない場合はエラーになります。

CFNetwork SSLHandshake failed (-9824)
NSURLSession/NSURLConnection HTTP load failed (kCFStreamErrorDomainSSL, -9824)
Error Domain=NSURLErrorDomain Code=-1200 "An SSL error has occurred and a secure connection to the server cannot be made." UserInfo={_kCFStreamErrorCodeKey=-9824, NSLocalizedRecoverySuggestion=Would you like to connect to the server anyway?, NSUnderlyingError=0x7fd8e9d09ae0 {Error Domain=kCFErrorDomainCFNetwork Code=-1200 "(null)" UserInfo={_kCFStreamPropertySSLClientCertificateState=0, _kCFNetworkCFStreamSSLErrorOriginalValue=-9824, _kCFStreamErrorDomainKey=3, _kCFStreamErrorCodeKey=-9824}}, NSLocalizedDescription=An SSL error has occurred and a secure connection to the server cannot be made., NSErrorFailingURLKey=https://kishikawakatsumi.com/, NSErrorFailingURLStringKey=https://kishikawakatsumi.com/, _kCFStreamErrorDomainKey=3}


この問題に対応するベストな方法はATSの要件を満たすように接続先を対応することですが、自分が管理しているサイトではないなどの場合は、クライアント側でATSをオフにするか、ドメインごとにATSの例外を設定することで通信できるようになります。

ATSをすべてオフにしてしまうのは乱暴なので、できるだけドメインごとの例外で対処したいところです。 ドメインごとの対応状況を調べて、またATSの例外設定のPlistをどのように記述したらいいのかまで教えてくれるのがnscurlコマンドです。

下記のように--ats-diagnosticsオプションを付けてnscurlコマンドを実行します。

$ nscurl --ats-diagnostics https://kishikawakatsumi.com/


すると、次のように順に設定を変えながら接続をテストして結果が報告されます。 Result : PASSになっていれば、その設定で通信が成功したことを示します。

Configuring ATS Info.plist keys and displaying the result of HTTPS loads to https://kishikawakatsumi.com/.
A test will "PASS" if URLSession:task:didCompleteWithError: returns a nil error.
Use '--verbose' to view the ATS dictionaries used and to display the error received in URLSession:task:didCompleteWithError:.
================================================================================

Default ATS Secure Connection
---
ATS Default Connection
Result : PASS
---

================================================================================

Allowing Arbitrary Loads

---
Allow All Loads
Result : PASS
---

(略)

---
TLSv1.1 with PFS disabled and insecure HTTP allowed
2015-10-19 12:01:31.289 nscurl[18324:1989232] CFNetwork SSLHandshake failed (-9801)
2015-10-19 12:01:31.337 nscurl[18324:1989232] CFNetwork SSLHandshake failed (-9801)
2015-10-19 12:01:31.383 nscurl[18324:1989232] CFNetwork SSLHandshake failed (-9801)
2015-10-19 12:01:31.384 nscurl[18324:1989232] NSURLSession/NSURLConnection HTTP load failed (kCFStreamErrorDomainSSL, -9801)
Result : FAIL
---

---
TLSv1.0 with PFS disabled and insecure HTTP allowed
Result : PASS
---

================================================================================


Result : FAILがある場合、接続先がATSの要件を満たしていないので、そのドメインを例外として設定する必要があります。 このとき、先ほどのコマンドに--verboseオプションを付けて実行すると、例外の設定をPlistにどう書けばいいかを合わせて出力してくれます。

$ nscurl --ats-diagnostics --verbose https://kishikawakatsumi.com/


下記の例だと、NSAllowsArbitraryLoads = trueはATSをオフにすることになるので、当たり前ですが通信に成功します。 順に設定を調べて行って、一番最後の、TLSの最低バージョンを1.0 NSExceptionMinimumTLSVersion = "TLSv1.0" とし、ForwardSecrecyを必須にしない NSExceptionRequiresForwardSecrecy = false とすることで接続できることがわかります。 設定のPlistをそのままダンプした形式で出力されているので、このままアプリケーションの設定に転記すればOKです。

================================================================================

Allowing Arbitrary Loads

---
Allow All Loads
ATS Dictionary:
{
    NSAllowsArbitraryLoads = true;
}
Result : PASS
---

================================================================================

Configuring TLS exceptions for kishikawakatsumi.com

---
TLSv1.2
ATS Dictionary:
{
    NSExceptionDomains =     {
        "kishikawakatsumi.com" =         {
            NSExceptionMinimumTLSVersion = "TLSv1.2";
        };
    };
}
2015-10-19 12:03:48.189 nscurl[18336:1992700] CFNetwork SSLHandshake failed (-9824)
2015-10-19 12:03:48.190 nscurl[18336:1992700] NSURLSession/NSURLConnection HTTP load failed (kCFStreamErrorDomainSSL, -9824)
Result : FAIL

(略)

---
TLSv1.0 with PFS disabled and insecure HTTP allowed
ATS Dictionary:
{
    NSExceptionDomains =     {
        "kishikawakatsumi.com" =         {
            NSExceptionAllowsInsecureHTTPLoads = true;
            NSExceptionMinimumTLSVersion = "TLSv1.0";
            NSExceptionRequiresForwardSecrecy = false;
        };
    };
}
Result : PASS
---

StoryBoardまたはXIBについて、ファイルごとにDeployment Targetを設定する

昨日はじめて知ったのですが、StoryBoardやXIBファイルはプロジェクトやターゲットのDeployment Targetとは別に、各ファイルごとに個別にDeployment Targetを設定することができます。

例えば、iOS 8以上にしか存在しないUIVisualEffectViewや、iOS 9以降でしか使えないUIStackViewをStoryBoardで配置して、プロジェクトのDeployment Targetを7.0(や8.0)にすると、下記のエラーでビルドに失敗します。

Main.storyboard: error: Class Unavailable: UIVisualEffectView prior to iOS 8.0

これを避けるためにはStoryBoardを使うことをあきらめ、コードでOSバージョンを分岐して、コードでUIコンポーネントを配置する必要があると思っていました。

しかし、StoryBoardにはファイルごとにDeployment Targetを設定できるので、新しいUIコンポーネントを配置しているStoryBoardについては、プロジェクトのDeployment Targetよりも高い値を指定します。

StoryBoardを選択して、File Inspectorの「Interface Builder Document」セクションの「Build For」がそれになります。

↑ たとえば、上記の例ではプロジェクトのDeployment Targetは7.0なのでiOS 8以上でしか使えないUIVisualEffectViewをStoryBoardで配置するとビルドエラーになりますが、そのStoryBoardのDeployment Targetを8.0以上にすることでビルドできるようになります。

もちろん、古いOSでStoryBoardを使ってしまうと実行時にクラッシュしてしまうので、OSのバージョンを判断して、ロードするStoryBoardを変更するようにします。

let storyboard: UIStoryboard

if #available(iOS 9, *, *) {
    storyboard = UIStoryboard(name: "Detail-iOS9", bundle: nil)
} else {
    storyboard = UIStoryboard(name: "Detail-iOS8", bundle: nil)
}

if let controller = storyboard.instantiateInitialViewController() {
    self.navigationController?.pushViewController(controller, animated: true)
}

↑ このようにすると、古いOS用のUIと新しいOS用のUIがStoryBoard単位で分けられますし、レイアウトはStoryBoardが使えるので、スッキリした構成になります。

iOS 9ではUIStackViewを使い、古いOSではUICollectionViewを使うように切り替えるなども簡単にできます。

【参考】

stackoverflow.com

WebとiOSアプリでパスワードを共有する

iOS 8からWebサービスとアプリ間でiCloudキーチェーンを通じてパスワードなどアカウント情報を共有できるようになりました。 (ただし、現状ではiCloudキーチェーンを使えるのはSafariのみのため、MacのSafariとiOSアプリの間に限る)

昨今ではそれぞれ別のサービスで同じパスワードを再利用せず、サービスごとに固有のできればランダムなパスワードを登録して、パスワードマネージャで管理することが推奨されています。 Webサービスを使うだけならブラウザのパスワード管理などを利用すればいいのですが、そのサービスのiOSアプリを利用しようとするとパスワードを調べるのが大変でした。

iOS 8では(Safari限定ではありますが)Webサービスで入力してキーチェーンに保存したアカウント情報を、iOSアプリでも利用することができます。

パスワードなど機密性の高い情報を共有するため、どのアプリでも自由に共有できるというわけではありません。 iCloudキーチェーンをアプリから読み出すには、そのアプリがWebサービスと連携しているということを証明する必要があります。 その証明にはWebサービス側と、アプリ側の双方に手続きが必要です。

試験用に今回用意した環境の準備について

SSL

SSLはStartSSLが提供している無料の証明書を利用しました。StartSSLはアップルのTrusted Listに含まれているのでHandoffあるいはShared Credentialに使用できます。

メールサーバ

SSLの証明書を取得するのにドメインと同じホスト名のメールアドレスが必要なので、Zohoのメールサービスをを利用しました。無料です。

HTTPSサーバ

サーバはDigital OceanでUbuntuの一番安いプランを利用しました。時間単位で課金の格安VPSです。

一か月放置したとしても500円程度で済みます。1日、2日利用する程度ならほぼ無料です。

今回のケースでは単にパスワードの入力フィールドがあるSSLの静的なページがひとつあれば十分ですので、nginxにSSLの設定をして、1枚のHTMLファイルを配置するだけにしました。

DNS

メールアドレスのMXレコードを設定するためにDNSが必要なので、DOZENS を利用しました。設定できるレコード数に制限がありますが、1人で使うぶんには普通は無料の範囲で利用できます。

Webサービス側の準備について

Webサービス側に必要な手続きから説明します。

Webサービス側にはiOSアプリのIDを列挙したファイルをWebサイトのルートに決まった名前で配置する必要があります。 アプリのIDはTeam ID + Bundle Identifierです。Team IDはたいてい1ベンダーにつき1つですが、昔は複数持つこともできたので、古くからのデベロッパーの方は注意が必要です。

アプリケーションが複数ある場合は、必要なぶんだけ記入することができます。

{
    "webcredentials": {
        "apps": [ "D3KQX62K1A.com.kishikawakatsumi.Example-iOS",
                  "D3KQX62K1A.com.kishikawakatsumi.Demo-iOS" ]
    }
}

このファイルを関連付けるWebサイトのルートからアクセスできるように配置します。 これでWebサービス側の作業は完了です。

iOSアプリ側の準備について

一方iOSアプリ側では連携するドメインを列挙したEntitlementファイルを用意します。 Xcodeのターゲット>Capabilitiesのところに"Associated Domains"が追加されているのでONにします。 Entitlementファイルが無ければ自動的に追加されて、それを参照するようにビルド設定が更新されます。(すでに存在する場合は追記されます)

「+」ボタンを押して連携するドメインを「サービス:ドメイン」という形式で記述します。 サービス名は"webcredentials"と決まっています。 ドメインが複数ある場合は必要なぶんだけ記入します。サブドメインが異なる場合もキーチェーンには別ドメインとして保存されているので、必要ならすべて記入します。

f:id:KishikawaKatsumi:20150120020454p:plain

iOSアプリ側の作業は以上です。基本的にXcodeがうまくやってくれるので難しいことはありません。

iOSアプリとWebサイトの関連付けについて

Webサイト、アプリの両方の条件が整っていると、アプリケーションのインストール時、アップデート時にアプリとWebサイトの関連付けが行われます。

iOSからのリクエストに対して、Webサーバが200のステータスコードを返し、かつIDが一致し、署名が正しければ、そのアプリケーションはWebサイトと関連があるとみなされます。

ステータスコードが300〜499を返したとき、あるいはIDや署名が正しくない場合は、そのWebサイトと無関係とみなされます。

ステータスコードが500のときは一時的にサーバに障害が発生していると判断され、3時間後に再度リクエストが発行されます。

無事にアプリとWebサイトが関連していると認められれば、アプリからiCloudで同期されたSafariのキーチェーン(Shared Web Credential)を読み出すことができます。

注意事項

この関連付けの処理は、アプリケーションのインストール時、アップデート時にのみ行われます。一度関連付けが済めば、後でWebサイト側の設定を増やしたり、消したりしても、それがアプリのほうで更新されるのは再度アップデートしたときや、一度消してインストールし直したときになります。

また、こちらのクックパッド開発者ブログの記事に書かれているように、Handoffの場合と同様に、自動アップデートがあった場合はそのタイミングで一斉にアクセスがあることが予想されます。もしリクエストを処理しきれないとき、ステータスコード500を返せれば自動的に3時間後にリトライされますが、うまくレスポンスを返せなかった場合は関連付けに失敗したことになるので注意が必要です。

MacからiPhoneに遷移させよう - クックパッド開発者ブログ

iOSアプリからShared Web Credentialの利用するには

認証情報を取得する

Shared Web Credentialから認証情報を取得するにはiOS 8から新たに追加されたAPIを利用します。

func SecRequestSharedWebCredential(
                                   fqdn: CFString!,
                                   account: CFString!,
                                   completionHandler: ((CFArray!, CFError!) -> Void)!
                                  )

fqdn, accountパラメータはそれぞれオプションで指定すると、検索条件として作用します。 (例えばfqdn"kishikawakatsumi.com"と指定すると、"kishikawakatsumi.com"ドメインとして登録された情報のみ対象になります。accountパラメータも同様です。どちらか片方という使い方もできます。)

このメソッドを呼び出してShared Web Credentialから情報が取得できる場合は、OSにより自動的にユーザーに選択肢が表示されます。

f:id:KishikawaKatsumi:20150120023348p:plain

ここでユーザーはShared Web Credentialから情報を取り出すこと自体を拒否することができます。 また、複数のアカウントが存在する場合は、利用するアカウントを選択することができます。

ユーザーが拒否した場合は、コールバックには何も渡ってきません。 Webサイトとアプリが関連していれば好きなように認証情報を使えるわけではなく、あくまでもユーザーが許可した場合のみ利用できる仕組みになっています。

ユーザーがいずれかのアカウントを選択した場合、コールバックにはドメイン、アカウント、パスワードの情報が入ったDictionaryの配列が渡ってきます。 現状、複数のアカウントが存在しても、渡ってくるのはユーザーが選択したひとつだけなので、配列の形式になってはいますが、複数組の認証情報がくることは無いと思います。

アプリとWebサイトの関連づけに問題がある場合は、エラーオブジェクトの内容から問題を調べることができます。

もし、1件も認証情報が見つからなかった場合は、選択肢のUIは表示されず、即座にコールバックが呼ばれます。(そのときのエラーオブジェクトはItem not foundとなります)

無事に認証情報が取得できれば、その情報を使ってログイン処理などを行います。 成功したら認証情報をアプリのキーチェーンに保存し、次からはアプリのキーチェーンの情報を使用します。

認証情報を更新する

もし、アプリからパスワードなど認証情報の変更ができる場合、下記のメソッドを使ってアプリ側から変更をShared Web Credentialに同期することができます。

また、アプリからサインアップが可能な場合も、同じ方法でShared Web Credentialに追加しておくとSafariですぐにログインできて便利です。

func SecAddSharedWebCredential(
                               fqdn: CFString!,
                               account: CFString!,
                               password: CFString!,
                               completionHandler: ((CFError!) -> Void)!
                              )

変更の場合は、OSによって自動的にユーザーに変更の許可を求められます。 ユーザーが許可した場合はShared Web Credentialが更新されiCloudによって同期されます。 追加の場合には何も表示されません。

認証情報を削除する

もし、Shared Web Credentialの情報を削除したい場合は、passwordパラメータにnilを渡すことで削除になります。

削除の際は、変更と同様にユーザーの許可が求められます。

ただし、単にログアウトしたときなどに、削除する必要はありません。削除を行うのはユーザーがサービスを退会したときなどにすべきです。

Shared Web Credentialから情報を取得するコードは下記になります。

SecRequestSharedWebCredential(nil, nil) { (credentials, error) -> () in
    if let error = error {
        return
    }
    
    if CFArrayGetCount(credentials) > 0 {
        let credential: CFDictionary = unsafeBitCast(CFArrayGetValueAtIndex(credentials, 0), CFDictionary.self)
        let domain = CFDictionaryGetValue(credential, unsafeAddressOf(kSecAttrServer))
        let account = CFDictionaryGetValue(credential, unsafeAddressOf(kSecAttrAccount))
        let password = CFDictionaryGetValue(credential, unsafeAddressOf(kSecSharedPassword.takeUnretainedValue()))
        
        println("domain: \(unsafeBitCast(domain, CFString.self)), account: \(unsafeBitCast(account, CFString.self)), password: \(unsafeBitCast(password, CFString.self))")
    }
}

実際には、アプリのキーチェーンから認証情報を探す、見つかったらログイン。 見つからなかった場合はShared Web Credentialに問い合わせる、見つかったらログイン。 見つからなかった場合は、認証画面を表示して入力を受けつける。 入力された認証情報をキーチェーンとShared Web Credentialの両方に保存。

便利なラッパーライブラリについて

キーチェーンのAPIはなかなかに面倒なので、KeychainAccessなどラッパーライブラリを使うと簡単です。

KeychainAccessを使用すると、上記のフローは下のように書けます。

let keychain = Keychain(server: "https://www.kishikawakatsumi.com", protocolType: .HTTPS)

let username = "kishikawakatsumi@mac.com"

if let password = keychain.get(username) {
    // If found password in the Keychain,
    // then log into the server
} else {
    // If not found password in the Keychain,
    // try to read from Shared Web Credentials
    keychain.getSharedPassword(username) { (password, error) -> () in
        if let password = password {
            // If found password in the Shared Web Credentials,
            // then log into the server
            // and save the password to the Keychain

            keychain[username] = password
        } else {
            // If not found password either in the Keychain also Shared Web Credentials,
            // prompt for username and password

            // Log into server

            // If the login is successful,
            // save the credentials to both the Keychain and the Shared Web Credentials.

            keychain[username] = password
            keychain.setSharedPassword(password, account: username)
        }
    }
}

まとめ

以上、Webサービスとアプリ間で認証情報を共有できるShared Web Credentialを紹介しました。 現状ではSafari限定ということもあり、それなりの手間をかけるのに見合わないと思うかもしれません。

しかし逆説的になりますが、対応しているアプリがないがゆえに、iCloudキーチェーンを使うメリットが少なく、さらに普及が遅れるということもあると思います。

私のようにパスワードを完全にSafariの自動生成とキーチェーンで管理してるような者にとっては、ひとつでも対応アプリが増えると非常に便利に感じます。

実際、機種変更時などアプリの再ログインが必要なタイミングでパスワードを探すのが面倒で使わなくなったアプリもあります。

Shared Web Credentialを活用すると、アプリかWebのどちらかひとつでログインすればその後はパスワードの入力が不要になるというスマートな体験を提供することができます。 ぜひ、積極的に使ってみてください。

参考情報

Shared Web Credentials Reference

WWDC 2014 Session 506 - Your App, Your Website, and Safari - ASCIIwwdc

UIKeyCommandを使ってバーコードリーダーの入力を受け取る

TL;DR

  • ●バーコードリーダーは外部キーボードとして扱える
  • UITextFieldなどの入力コンポーネントを使って入力を受け取れる
  • UITextFieldなどを使いたくない場合がある
  • UIKeyCommandを使うと入力コンポーネントを使わずに入力を受け取れる

ユビレジでは商品の入力に市販のバーコードリーダーを利用することができます。

一般的なBluetoothのバーコードリーダーはHID(Human Interface Device)とSPP(Serial Port Profile)の両方のプロファイルに対応しています。

HIDとして接続する場合は外部キーボードと同じ扱いになります。 外部キーボードが繋がっているのと同じなので、UITextFieldUITextViewを使って特別なSDKを必要とせずに入力を受け取ることができます。

ただし、このやり方は簡単なのですが、入力を受け取るにはUITextFieldなどの入力コンポーネントがアクティブになっている必要があるので少し不便です。

ユビレジでは商品点数の多い小売の店舗などではバーコードリーダーメインの入力として使っていますが、飲食店などではバーコードリーダーはまったく使いません。 このように利用者によって、使うところはメインの入力で使うし、使わないところはまったく使わないという状況なので、どちらにも使いやすくなっている必要があります。

そのため、バーコードリーダーの入力を受けるためにはテキスト入力エリアをタップする必要がある、というのは常時バーコードリーダーを利用するユーザーは面倒に感じるでしょう。 かといって常にテキスト入力エリアが自動的にアクティブになるようにすると、バーコードリーダーが接続されてない店舗では常にソフトウェアキーボードが表示されてしまいます。

そこで、ユビレジではバーコードリーダーの入力を受け取るのにUIKeyCommandというiOS 7から新しく追加されたAPIを利用しています。

UIKeyCommandは本来は外部キーボードが接続された状態でキーボードショートカットによる操作をサポートするためのAPIです。 任意のキー入力の組み合わせを受け取ることができるので、入力される可能性のあるすべての文字に反応するようにしておくことで、任意のキー入力を受け取る仕組みとして利用することができます。

UIKeyCommandについては前に別の記事でも書きましたのでそちらもご覧ください。

iOSアプリケーションでキーボードショートカットに対応する - 24/7 twenty-four seven

例えば一般的なバーコードリーダーの入力を受け取るには下記のように実装します。

@interface ViewController ()

@property (nonatomic) NSArray *commands;
@property (nonatomic) NSString *barcode;

@end

@implementation ViewController

- (BOOL)canBecomeFirstResponder {
    return YES;
}

- (NSArray *)keyCommands {
    if (!self.commands) {
        NSMutableArray *commands = [[NSMutableArray alloc] init];
        NSArray *characterSets = @[[NSCharacterSet characterSetWithRange:NSMakeRange(0x20, 0x7f - 0x20)],
                                   [NSCharacterSet newlineCharacterSet]];
        for (unichar i = 0x00; i < 0x7f; i++) {
            for (NSCharacterSet *characterSet in characterSets) {
                if ([characterSet characterIsMember:i]) {
                    NSString *string = [[NSString alloc] initWithCharacters:&i length:1];
                    UIKeyCommand *command = [UIKeyCommand keyCommandWithInput:string modifierFlags:kNilOptions action:@selector(handleKeyCommand:)];
                    [commands addObject:command];
                    break;
                }
            }
        }
        
        self.commands = commands.copy;
    }
    
    return self.commands;
}

- (void)handleKeyCommand:(UIKeyCommand *)command {
    NSString *key = command.input;
    NSCharacterSet *newlineCharacterSet = [NSCharacterSet newlineCharacterSet];
    if ([key rangeOfCharacterFromSet:newlineCharacterSet].location != NSNotFound) {
        NSLog(@"Barcode: %@", self.barcode);

        self.barcode = nil;
    } else {
        if (self.barcode) {
            self.barcode = [self.barcode stringByAppendingString:key];
        } else {
            self.barcode = key;
        }
    }
}

@end

まず、UIKeyCommandでキー入力を受け取るにはcanBecomeFirstResponderメソッドでYESを返す必要があります。 そしてkeyCommandsプロパティをオーバーライドして、受け取りたいキーコンビネーションのUIKeyCommandインスタンスの配列を返すようにします。

上記の例ではすべてのASCII文字と、改行文字に反応します。

これで接続されたバーコードリーダーでバーコードを読み取るたびにhandleKeyCommand:メソッドが呼ばれます。 handleKeyCommand:メソッドは1文字ずつ連続で呼ばれるので、終わりが来るまで読み取った文字列を結合します。 たいていのバーコードリーダーでは終わりに改行文字を送ってくるため、それをバーコードの終わりと判断して、そこまでを一つのバーコードと扱います。

このコードはバーコードリーダーが接続されてない場合にはまったく影響ありません。

このように、UIKeyCommandを利用すると、バーコードリーダーを利用している場合は特別な操作を必要とせずにバーコードリーダーからの入力を受け取ることができ、利用しない場合は今までどおり使えるという先述の問題をキレイに解決することができます。

マイナーなAPIですが、意外と便利で、おそらくバーコードリーダー以外にも応用できる場面はあるのではないかと思います。

Swiftで使いやすいAPIを書くために気をつけていること

先日iOSオールスターズ勉強会でSwiftでライブラリを書く際に良いと思ってることについて話しました。そこで好評だったり同意してもらえた何点かについてあらためてまとめます。

オーバーロードを積極的に活用しよう

Swiftではメソッドのオーバーロード(引数の数、型、および戻り値の型が異なる同じ名前のメソッドが定義できる)が言語仕様としてサポートされています。これは上手に使うと利用する側にとってとても書きやすくなるので積極的に使っていきましょう。

例えば下記のような例では、Objective-Cではデータ型によって複数のメソッドを使い分ける必要がありました。 (引数の型がNSStringNSDataかでメソッドの名前が異なる)

- (BOOL)setString:(NSString *)string forKey:(NSString *)key;
- (BOOL)setData:(NSData *)data forKey:(NSString *)key;

Swiftでは次のように書けるので、利用者は型の違いを意識することなく、setメソッドのみを覚えていればよいことになります。

func set(value: String, key: String) -> NSError?
func set(value: NSData, key: String) -> NSError?

下記は引数の数が違う例です。

convenience init()
convenience init(service: String)
convenience init(accessGroup: String)
convenience init(service: String, accessGroup: String)

必須でない引数を省略したメソッドを用意することで、利用するときは次のように必要なときだけ引数を渡せばよいことになります。

let keychain = Keychain()
let keychain = Keychain(service: "com.example.github-token")
let keychain = Keychain(accessGroup: "12ABCD3E4F.shared")
let keychain = Keychain(service: "com.example.github-token", accessGroup: "12ABCD3E4F.shared")

デフォルト引数とオーバーロードならオーバーロードの方がわかりやすい

Swiftではデフォルト引数(引数を指定しなかった場合、あらかじめ指定したデフォルト値が設定される)もサポートされており、オーバーロードと似たような効果を与えることができます。

例えば下記のように3つの引数を取るメソッドのうち、必須でない2つの引数にデフォルト値を与えます。

convenience init(server: String, 
                 protocolType: ProtocolType = .HTTPS, 
                 authenticationType: AuthenticationType = .HTMLForm)

そうすると次のように必須の引数以外は省略して呼び出すことができます。

let keychain = Keychain(server: "https://github.com")

ただ、私はこのような場合はオーバーロードによって同様のことを実現する方が良いと考えています。

理由は2つあって、まず一つは、Xcodeの入力補完を利用する際に、常にすべての引数を取る補完候補が表示されてしまうので、引数がオプションであることがわかりづらい点です。

もう一つは、オプション引数を省略するケースの方が一般的な使い方の場合、補完された引数を消すという行為が必要になるため書くリズムが崩れる、というのがイマイチ使い勝手を悪くすると思うからです。

↑上記のメソッドは呼び出すときにこのように補完されます。引数を省略するには補完された引数をわざわざ消す必要があります。

↑オーバーロードで同じことを実現した場合はこのように補完されます。引数を省略できるメソッドが用意されてることがひと目でわかりますし、どのように書きたい場合でも補完から選ぶことができます。

さらにデフォルト引数として何が与えられているのかという情報も、利用する段階では欠如してしまうので、省略した場合にデフォルト値として何が指定されるのか明示したい、という用途にも今のところ使えないことになります。

convenience init(server: NSURL, 
                 protocolType: KeychainAccess.ProtocolType = default,
                 authenticationType: KeychainAccess.AuthenticationType = default)

↑デフォルト値はこのようにすべてdefaultと表示されるため何が指定されるのか、わからない。

例外: コールバックとしてのクロージャはデフォルト引数で空の実装を与えよう

非同期メソッドのコールバックとしてクロージャを与えるというインターフェースはよくあります。

この場合については、オーバーロードで省略形を用意するよりは、デフォルト引数として何もしないクロージャを与えるのが良いと思います。

func setSharedPassword(password: String,
                       account: String,
                       completion: (error: NSError?) -> () = { e -> () in })

理由としては、非同期メソッドのコールバックは省略することの方が少なそうであること(そうであるなら常に複数のメソッドが補完されるのは面倒)、省略された場合でも呼び出される方でクロージャがnilかどうかをチェックする必要がないのでスッキリ書ける、などです。

keychain.setSharedPassword(password, account: username)
keychain.setSharedPassword(password, account: username) { (error) -> () in
    println(error)
}

↑上記のメソッドはこのようにコールバックを必要に応じて省略できます。省略した場合でもクロージャはデフォルト引数によって必ず渡されるので、呼び出される側の実装は常にクロージャを呼べばよいです。

戻り値のオーバーロードはあまり便利じゃない

Swiftでは戻り値の型だけが異なるメソッドもオーバーロードとして同じ名前で定義できます。

func get(key: String) -> String?
func get(key: String) -> NSData?

しかし上記のようなメソッドを用意した場合、呼び出すときにどちらのメソッドを呼び出しているのか名前だけでは区別できないため、戻り値の型を指定するか、キャストが必要になってしまいます。

↓下のように左辺の変数の型を指定するか、

let data: NSData? = keychain.get("somedata")

↓戻り値のキャストを書く必要がある。

let data = keychain.get("somedata") as NSData?

たいていの場合、気にせず書いていたらコンパイルエラーが出たので直す、ということになってリズムが狂うので良くないと考えています。

私は上記のケースではオーバーロードは使用せずにメソッドの名前を変えることにしました。

func getString(key: String) -> String?
func getData(key: String) -> NSData?

↑スマートではないですが、補完を働かせることもできるのでこちらのほうが良いと考えました。

メソッドチェーンを上手に使おう

Objective-Cの文法ではメソッドチェーンは非常にやりにくいものでした。 (チェーンするなら、まずチェーンする数のカッコを開く必要がある。足りなかったら最初に戻ってカッコを足すことになる。)

Swiftでは自然な形でメソッドチェーンが利用でき、もちろん補完も働くので、有効に使えるケースでは上手に活用しましょう。

keychain
    .accessibility(.AfterFirstUnlock)
    .synchronizable(true)
    .set("01234567-89ab-...", key: "kishikawakatsumi")
keychain
    .accessibility(.WhenUnlocked)
    .set("01234567-89ab-...", key: "kishikawakatsumi")

エラーはEither型として返すことを検討しよう

Objective-Cでは複数の戻り値を返すことはできなかったので、戻り値を返すメソッドでエラーの情報も返す必要がある場合は、NSErrorのダブルポインタを引数に渡す方法が一般的でした。

- (NSString *)stringForKey:(NSString *)key error:(NSError * __autoreleasing *)error;

Swiftでもinoutパラメータを使えば同様のことができますが、errorの変数をわざわざ用意しなくてはいけなかったり、とても使いにくい仕組みなので、別の方法を考えましょう。

単純に戻り値とエラーをタプルで返すというのもアリですが、Swiftには強力なEnumが存在するので、Enumで実装したEither型を返すことをまず検討しましょう。

Either型とはScalaやHaskellなどの関数型プログラミング言語にはたいてい組み込み型として入っている、2つの別個の型の値のどちらかを返す型です。

戻り値としてEither型を返すメソッドのエラー処理は次のように書けます。

let failable = keychain.getStringOrError("kishikawakatsumi")

switch failable {
case .Success:
    println("token: \(failable.value)")
case .Failure:
    println("error: \(failable.error)")
}

基本的にはエラー処理をしてほしいということを型として明示できますし、正常系とエラー系の分岐を自然に書けます。

タプルで戻り値とエラーの両方を返す場合と異なり、正常時のエラー、あるいはエラー時の戻り値といった条件を考慮する必要もなくなります。

非同期メソッドのコールバックではObjective-Cの時と同様にエラーオブジェクトをコールバックの引数で返せばよいかと思います。

keychain.getSharedPassword(username) { (password, error) -> () in
    if error != nil {

    } else {

    }
}

コードを公開する際にはPlaygroundを同梱しよう

ライブラリを公開する際には、READMEなどのほかにPlaygroundをセットで公開すると、すぐに動かして試すことができて非常にわかりやすいのでオススメです。

SwiftでKeychainを簡単に使うライブラリ "KeychainAccess" を書きました

kishikawakatsumi/KeychainAccess · GitHub

そろそろSwiftをちゃんと勉強しようと思って作りました。 Swiftで書かれたKeychainのラッパーの中ではもっとも高機能でかつ簡単に使えるものができたと思います。

機能としては下記を備えています

  • 簡単に使えるインタフェース
  • アプリ間のキーチェーン共有
  • アクセシビリティ(バックグラウンド動作時の制限など)属性のサポート
  • iCloudによるキーチェーンの同期
  • Touch IDによるキーチェーンの保護(iOS 8〜)
  • iOSとOS Xの両方の動作をサポート

インストール

github "kishikawakatsumi/KeychainAccess"

pod 'KeychainAccess'
CocoaPodsを使う場合、CocoaPodsのバージョンはbeta版の0.36が必要です。
Pod Authors Guide to CocoaPods Frameworks - CocoaPods Blog

  • マニュアルインストール

1. Lib/KeychainAccess.xcodeprojをプロジェクトに追加(ドラッグ&ドロップ)します。
2. KeychainAccess.frameworkをターゲットにリンクします。
3. Build PhasesにCopy Files Build Phaseを追加して、上記のKeychainAccess.frameworkをバンドルのFrameworksディレクトリにコピーするようにします。

使い方

詳しくは READMEを見てください。

基本

値の保存、取得、削除

保存

まずはじめにKeychainのインスタンスを作成します。 サービス名を指定しなかった場合は、自動的にアプリケーションのバンドルIDが設定されます。 そして、作成したキーチェーンのset(value: String, key: String) -> NSError?メソッドでキーと値を設定します。 setメソッドはNSDataを引数に取るメソッドも用意してあります。

Objective-Cでは型の違う引数によるオーバーロードはできなかったので、名前を変える必要がありましたが(例えばsetString:setDataなど)Swiftでは型違いの引数によるメソッドのオーバーロードがサポートされているので、型の違いを気にすることなく自然に書けます。

let keychain = Keychain(service: "com.example.github-token")
keychain.set("01234567-89ab-cdef-0123-456789abcdef", key: "kishikawakatsumi")

Subscriptingも利用できます。Subscriptingについては型違いによるオーバーロードはサポートされていない(Xcode 6.0ではできていた)ので、Stringのみ受け付けます。

keychain["kishikawakatsumi"] = "01234567-89ab-cdef-0123-456789abcdef"

エラー処理

値の保存に失敗した場合はNSErrorオブジェクトが返るので、必要ならエラー処理をします。

if let error = keychain.set("01234567-89ab-cdef-0123-456789abcdef", key: "kishikawakatsumi") {
    println("error: \(error)")
}
取得

値を取り出すには、getで始まるメソッド群を使用します。 戻り値の型の違いによって以下の5つのメソッドが用意されています。

get(key: String) -> String?
getString(key: String) -> String?
getData(key: String) -> NSData?
getStringOrError(key: String) -> KeychainAccess.FailableOf<String>
getDataOrError(key: String) -> KeychainAccess.FailableOf<NSData>

String型として値を取得する場合はgetもしくはgetStringを使用します。

let token = keychain.get("kishikawakatsumi")
let token = keychain.getString("kishikawakatsumi")

NSData型として値を取得する場合はgetDataを使用します。

let data = keychain.getData("kishikawakatsumi")

Swiftでは戻り値の型の違いによるオーバーロードもサポートされていますが、戻り値だけが異なるメソッドのオーバーロードは、受ける側の変数で型を明示するか、戻り値をキャストする必要があるため(そうしないとどちらの呼び出しか区別できない)、却って書きにくくなると思ったので、メソッド名自体を変えることにしました。 入力補完が使えるぶん、この方が書きやすいと思います。

エラー処理

エラーを区別(キーに対応する値が無いのかエラーが起こって取得に失敗したのか)したい場合getStringOrErrorもしくはgetDataOrErrorメソッドを使用します。

このメソッドの戻り値の型はFailableOf<String>またはFailableOf<NSData>になります。 FailableOf<T>SuccessFailureのいずれかの状態を取るenumです(ScalaやHaskellなど他の言語ではEitherとして知られています)。 FailableOf<T>の状態がSuccessなら処理は成功しています。(キーに対応する値が無い場合も成功になります。その場合、値はnilになります。 ) FailableOf<T>の状態がFailureの場合は、何らかの理由によりキーチェーンのアクセスに失敗しました。 このとき、errorオブジェクトにはエラーの詳細がNSErrorオブジェクトとして格納されます。 値は必ずnilになります。

実際の使い方は下記のようになります。

まず、getStringOrError(またはgetDataOrError)メソッドを呼び出します。

let failable = keychain.getStringOrError("kishikawakatsumi")

failableオブジェクトはSuccessまたはFailureのいずれかの状態を取ります。 Successならばvalueプロパティから実際の値が取得できます。(キーに対応する値が無い場合はnilFailureならばerrorプロパティにNSErrorオブジェクトが格納されています。(valueプロパティは必ずnil

switch failable {
case .Success:
  println("token: \(failable.value)")
case .Failure:
  println("error: \(failable.error)")
}

エラー処理についてはかなり悩んだのですが、Swiftにおいて同期処理で正常時には別の戻り値を返す必要があるという場合、Eitherを返すというのがたぶん良い方法だと思います。

非同期処理の場合はObjective-Cと同様に、コールバックのクロージャを分ける(onSuccessとonFailureなど)なり、エラーをクロージャの引数で渡すなどするのが良いかと思います。

単純に戻り値とエラーをタプルで返すという手もありますが、これはあまり使い勝手が良くなかったのでやめたほうが良いと思います。 ただし、下記にあるように現在のSwiftのコンパイラでは単にタプルを返すのに比べて多くのメモリが必要になる、という話もありますので注意が必要なケースもあります。

削除

removeメソッドを使うか、Subscriptingでnilを代入することで削除になります。 エラー処理はsetの時と同様です。

keychain["kishikawakatsumi"] = nil
keychain.remove("kishikawakatsumi")

設定を変えて保存する

アクセシビリティを変更したり、iCloudの同期を指定したりなど、項目によって設定を変えることができます。

下記はフォアグラウンド動作時のみ読み出せるようにして保存しています。 (この項目はバックグラウンド動作時は読み出せません。)

keychain
    .accessibility(.WhenUnlocked)
    .set("01234567-89ab-cdef-0123-456789abcdef", key: "kishikawakatsumi")

下記はiCloudによる同期を有効にする例です。

keychain
    .synchronizable(true)
    .set("01234567-89ab-cdef-0123-456789abcdef", key: "kishikawakatsumi")

メソッドチェーンによる設定の変更は、その時だけ一時的に有効になります。 共通の設定として使用する場合は、チェーンせずに変数に保管して使い回すようにします。

let keychain = Keychain(service: "com.example.github-token")
    .synchronizable(true)

keychain["kishikawakatsumi"] = "01234567-89ab-cdef-0123-456789abcdef"
keychain["hirohamada"] = "..."

Touch IDまたはパスコードで値を保護する(iOS 8〜)

iOS 8からTouch IDを使うAPIが追加されました。 同時にKeychainのAPIにもTouch IDのサポートが追加され、値を読み出すときと更新するときにTouch ID(またはパスコード)による認証を必要とすることができるようになりました。

KeychainAccessでは下記のようにaccessibilityの指定に加えてauthenticationPolicyを指定することで、その値はTouch IDによる保護が有効になります。 (authenticationPolicyで指定できるのは今の所UserPresenceだけです)

認証を必要とする可能性のある処理はバックグラウンドスレッドで実行する必要があります。 Touch ID(またはパスコード)による認証が必要な値にアクセスすると、システムが自動的にTouch IDまたはパスコードによる認証画面を表示します。 このとき、メソッドの実行はブロックされているので、認証画面を出そうとするけど、メインスレッドは止まっているという状態になってアプリケーション全体が止まってしまいます。 そのため、下記のようにバックグラウンドスレッドから実行するようにします。 (値の追加は認証を必要としませんが、すでに値が存在してそれが保護された値の場合、追加ではなく更新になるので認証が必要になります。つまり、削除以外はバックグラウンドスレッドから実行するようにしておくのが安全です。)

let keychain = Keychain(service: "com.example.github-token")

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
    let error = keychain
        .accessibility(.WhenPasscodeSetThisDeviceOnly, authenticationPolicy: .UserPresence)
        .set("01234567-89ab-cdef-0123-456789abcdef", key: "kishikawakatsumi")

    if error != nil {
        // Error handling if needed...
    }
}

保護された値の取得は普通の値を取得するときと同様です。 ただし、認証を必要とするのでバックグラウンドスレッドから実行する必要があります。

let keychain = Keychain(service: "com.example.github-token")

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
    let failable = keychain
        .authenticationPrompt("Authenticate to login to server")
        .getStringOrError("kishikawakatsumi")

    if failable.successed {
        println("value: \(failable.value)")
    } else {
        println("error: \(failable.error?.localizedDescription)")
        // Error handling if needed...
    }
}

削除については認証を必要としないので普通の値のときとまったく同様の書き方になります。

let keychain = Keychain(service: "com.example.github-token")

let error = keychain.remove("kishikawakatsumi")

if error != nil {
    println("error: \(error?.localizedDescription)")
    // Error handling if needed...
}

Swiftについて

Keychain APIはCのインタフェース(しかも使いにくい)しか用意されていなくて、Objective-Cでもラッパーが必要になるので、Swiftで書いたらまったくSwiftっぽくならなくて大変なだけじゃないのと思ってましたが、意外にもObjective-Cで書くよりも簡単に書けました。

例えば値を読み出すコードはObjective-Cだと下記のようになります。

NSMutableDictionary *query = [[NSMutableDictionary alloc] init];
[query setObject:(__bridge id)kSecClassGenericPassword forKey:(__bridge id)kSecClass];
[query setObject:(__bridge id)kCFBooleanTrue forKey:(__bridge id)kSecReturnData];
[query setObject:(__bridge id)kSecMatchLimitOne forKey:(__bridge id)kSecMatchLimit];
[query setObject:service forKey:(__bridge id)kSecAttrService];
[query setObject:key forKey:(__bridge id)kSecAttrGeneric];
[query setObject:key forKey:(__bridge id)kSecAttrAccount];

CFTypeRef data = nil;
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &data);
if (status == errSecSuccess) {
    NSData *ret = [NSData dataWithData:(__bridge NSData *)data];
    if (data) {
        CFRelease(data);
    }
    return ret;
}

Swiftで書いた場合は下記のようになります。

var query = [String: AnyObject]()

query[kSecClass] = kSecClassGenericPassword
query[kSecMatchLimit] = kSecMatchLimitOne
query[kSecReturnData] = kCFBooleanTrue
query[kSecAttrService] = service
query[kSecAttrAccount] = key

var result: AnyObject?
var status = withUnsafeMutablePointer(&result) { SecItemCopyMatching(query, UnsafeMutablePointer($0)) }

switch status {
case errSecSuccess:
    if let data = result as NSData? {
        return data
    }
default: ()
}

変なのはSecItemCopyMatchingでポインタのポインタを引数に与えてるところだけで、それ以外は非常にスッキリと書けています。 SwiftのDictionaryNSDictionaryが透過的に扱えて、NSDictionaryCFDictionaryRefがtoll-freeブリッジということでこのように簡単に書けるのですが、これは私にとっては意外でした。

Optionalについても、このライブラリではUIKitはまったく使ってないので特に困ることはありませんでした。 サンプルコードで少しUIを書きましたが、きちんと考えればOptionalはCocoaと一緒に使っても、パズルを組み立てるような気持ち良さが感じられると思います。

そんな感じで、基本的にはやはりObjective-Cに比べると言語自体の表現力が格段に高いので、少ない記述でキレイに書くことができます。 必然的に外部に公開するインタフェースも使いやすいものが書きやすいと思います。

ただし、現状ではSwiftにはリフレクションの仕組みがほぼサポートされていないので、メタプログラミングによって簡単に使えるAPIを提供する、ということはできません。

また、コンパイラはまだまだ未完成なようで、このライブラリを書いてる間に2つほどコンパイラがクラッシュするコード[1][2]を見つけましたし、本来なら型推論で書かなくて済むはず、というコードが実際は型やパラメータを明示する必要があったり、などという動作が散見されました。

また、コードの書き方によってはリリースビルド(コンパイラの最適化が働く)のときだけキーチェーンの値が取れないという問題もあったので、SwiftがObjective-C並みに安定するまでにはまだしばらくかかりそうです。

しかし、やはり言語による強力な表現のサポートは魅力的で、実際に少ない記述で美しく書けるので、使えるところでは積極的に使っていって、フィードバックをどんどんすべきかと思います。

Swiftの言語仕様はObjective-Cに比べるとそれなりに巨大ですが、まったく新しく設計された言語ということで一貫性もあるし、作りながら1週間もやればだいたいなんとかなるんじゃないでしょうか。

ただ個人的にはObjective-C 3.0としてGenericsと賢いenumとstructを入れる、、、とかから始めたら良かったんじゃないのと思ってたりします。

Travis CIでビルドごとにiTunes ConnectのValidationを自動的に実行する

コマンドラインからiOSアプリケーションをiTunes Connectにアップロードする - 24/7 twenty-four seven

↑ こちらの記事で書いたように、コマンドラインからiTunes Connectへのアップロードや、バリデーションができるのを利用して、Travis CIを使ってビルドするたびに自動的にバリデーションを実行するようにしました。

これにより、プライベートAPIを利用していたり、必須なサイズのアイコンやLaunchImageが無いなどの理由でバリデーションエラーになってアップロードが失敗するということが未然に防げます。

ARCを使う場合、ヘッダに載っていないメソッドを呼ぶのはコンパイルエラーになるので、知らずにプライベートAPIを使ってしまうようなことは現在はほぼありません。 しかし、iTunes Connectのバリデーションはあまり賢くないので(おそらく単純な文字列のマッチング?)たまたまプライベートAPIと同名のメソッドを全然関係ないクラスに定義してしまった場合などでもエラーになることがあります。

通常なら申請する段階になって初めてわかるので、エラーの原因がサードパーティのライブラリにある場合など、すぐには直せずリリースを延期せざるを得ないこともあります。

バリデーションはAdHocの署名では失敗するようなので、AppStoreの署名でビルドしてバリデーションをするタスクを追加することにしました。 実際のコマンドは下記になります。

script:
  - bundle exec rake $(ACTION)
env:
  matrix:
    - ACTION="profile:install certificate:add distribute:CONFIG certificate:remove"
    - ACTION="profile:install certificate:add version:bump:patch validate certificate:remove"
    - ACTION=test

バリデーションのタスクは上から2番目のこのコマンドです。

profile:install certificate:add version:bump:patch validate certificate:remove

プロビジョニングプロファイルに署名をする必要があるので証明書を追加と、既存のバイナリとバージョン番号が異なっている必要があるのでバージョン番号の末尾を自動的にあげる処理を前処理として入れています。

実際のバリデーションを行うコマンドは下記になります。

task :validate do
  build_xcarchive(configuration: "Release")
  clean_ipa
  sh %[xcodebuild -exportArchive -exportFormat IPA -archivePath "#{ARCHIVE_FILE}" -exportPath "#{IPA_FILE}" -exportProvisioningProfile "Ubiregi App Store" | xcpretty -c; exit ${PIPESTATUS[0]}]
  validate_ipa(IPA_FILE)
end
def validate_ipa(ipa_file)
  sh %['/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/Frameworks/ITunesSoftwareService.framework/Support/altool' --validate-app --file "#{ipa_file}" --username ENV["DEV_APPLE_ID"] --password ENV["DEV_PASSWORD"]]
end

xcarchiveからipa形式にエクスポートして、そのipaファイルをaltoolに指定しています。

以下はわざとプライベートAPIを使うようにして失敗させた時のTravis CIのログです。

▸ Compiling UBDashboardCheckoutDetailCell.xib
▸ Compiling UBDataTransferViewController.xib
▸ Processing Ubiregi2-Info.plist
▸ Touching Ubiregi2.app
▸ Signing /Users/travis/Library/Developer/Xcode/DerivedData/Ubiregi2-gavhqalfhdytsueqelwxtefkbbio/Build/Intermediates/ArchiveIntermediates/Ubiregi2-Release/InstallationBuildProductsLocation/Applications/Ubiregi2.app
▸ Touching Ubiregi2.app.dSYM
xcodebuild -exportArchive -exportFormat IPA -archivePath "/Users/travis/build/ubiregiinc/ubiregi-client/build/Ubiregi2.xcarchive" -exportPath "/Users/travis/build/ubiregiinc/ubiregi-client/build/Ubiregi2.ipa" -exportProvisioningProfile "Ubiregi App Store" | xcpretty -c; exit ${PIPESTATUS[0]}
'/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/Frameworks/ITunesSoftwareService.framework/Support/altool' --validate-app --file "/Users/travis/build/ubiregiinc/ubiregi-client/build/Ubiregi2.ipa" --username [DEV_APPLE_ID] --password [DEV_PASSWORD]
2014-11-02 11:16:00.289 altool[4121:d07] *** Error: (
    "Error Domain=ITunesConnectionOperationErrorDomain Code=1350 \"Your app contains non-public API usage. Please review the errors, correct them, and resubmit your application.\" UserInfo=0x7fd788f56f40 {NSLocalizedRecoverySuggestion=Your app contains non-public API usage. Please review the errors, correct them, and resubmit your application., NSLocalizedDescription=Your app contains non-public API usage. Please review the errors, correct them, and resubmit your application., NSLocalizedFailureReason=iTunes Store operation failed.}",
    "Error Domain=ITunesConnectionOperationErrorDomain Code=50 \"The app references non-public selectors in Payload/Ubiregi2.app/Ubiregi2: _terminateWithStatus:\" UserInfo=0x7fd788f39310 {NSLocalizedRecoverySuggestion=The app references non-public selectors in Payload/Ubiregi2.app/Ubiregi2: _terminateWithStatus:, NSLocalizedDescription=The app references non-public selectors in Payload/Ubiregi2.app/Ubiregi2: _terminateWithStatus:, NSLocalizedFailureReason=iTunes Store operation failed.}",
    "Error Domain=ITunesConnectionOperationErrorDomain Code=-19000 \"If you think this message was sent in error and that you have only used Apple-published APIs in accordance with the guidelines, send the app's nine-digit Apple ID, along with detailed information about why you believe the above APIs were incorrectly flagged, to appreview@apple.com. For further information, visit the Technical Support Information page at http://developer.apple.com/support/technical/.\" UserInfo=0x7fd788d88170 {NSLocalizedRecoverySuggestion=If you think this message was sent in error and that you have only used Apple-published APIs in accordance with the guidelines, send the app's nine-digit Apple ID, along with detailed information about why you believe the above APIs were incorrectly flagged, to appreview@apple.com. For further information, visit the Technical Support Information page at http://developer.apple.com/support/technical/., NSLocalizedDescription=If you think this message was sent in error and that you have only used Apple-published APIs in accordance with the guidelines, send the app's nine-digit Apple ID, along with detailed information about why you believe the above APIs were incorrectly flagged, to appreview@apple.com. For further information, visit the Technical Support Information page at http://developer.apple.com/support/technical/., NSLocalizedFailureReason=iTunes Store operation failed.}"
)
rake aborted!
Command failed with status (3): ['/Applications/Xcode.app/Contents/Applicat...]
/Users/travis/build/ubiregiinc/ubiregi-client/Rakefile:202:in `validate_ipa'
/Users/travis/build/ubiregiinc/ubiregi-client/Rakefile:198:in `block in <top (required)>'
Tasks: TOP => validate
(See full trace by running task with --trace)

The command "bundle exec rake $(echo ${ACTION} | sed -e "s/CONFIG/${CONFIG}/g")" exited with 1.

Done. Your build exited with 1.

エラーが3つ返ってきていますが、いずれも非公開のAPIを使用していることについてのエラーです。 エラーのうちのこの部分に直接の原因が書いてあります。

The app references non-public selectors in Payload/Ubiregi2.app/Ubiregi2: _terminateWithStatus:

_terminateWithStatusUIApplicationで定義されているメソッドですが、iTunes Connectはあまり賢くないので、同じ名前のメソッドをうっかり他のクラスに定義してしまったとしても同様のエラーでバリデーションが失敗します。

このように「ついうっかり」プライベートAPIとして検出されるメソッドやプロパティを定義してしまっていたり、サードパーティのライブラリがエラーとして検出されてしまう、などという問題が、申請時になって発覚するのは大変なことなので、事前にわかるのはかなり便利ではないかと思います。

コマンドラインからiOSアプリケーションをiTunes Connectにアップロードする

参考
TL;DR

Xcode(厳密にはApplication Loader)に付属するiTMSTransporterまたはaltoolを使います。 上記のリンク先を見ればだいたいわかります。

altoolのほうが直感的なコマンドで簡単です。

iTMSTransporterはそもそもゲームのアイテムなど大量のIn-App Purchaseのメタデータを一括更新するためのソフトウェアで、POSTするデータはXMLで用意しなければならないなど、単に申請するアプリケーションをアップロードするという目的には少し面倒です。

altoolでアプリケーションをアップロードする

altoolはXcodeに付属しています。 --helpオプションを付けて実行すると表示される使い方を見ればだいたいわかると思います。

$ `/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/Frameworks/ITunesSoftwareService.framework/Support/altool` --help
Copyright (c) 2009-2014, Apple Inc.  Version 1.0

Usage: altool -v -f file -u username -p password
       altool --upload-app -f file -u username -p password

 -f, --file                Filename.
 -u, --username            Username. Required to connect for validation and upload.
 -p, --password            Password. Required if username specified.

 -v, --validate-app        Validate an archived app. The username, password, and file path to app archive are required.
     --upload-app          Uploads the given app.  The username, password, and file path to app archive are required.

 -h, --help                Display this output.

 
ipaファイルをiTunes Connectにアップロードするには下記のようにオプションを構成します。

$ altool --upload-app -f build/AppName.ipa -u USERNAME -p PASSWORD

 
--upload-appオプションを--validate-appまたは-vにすると、検証(プライベートAPIの有無やLaunch ImageやアイコンがDeploymentTargetと矛盾しないかどうかなどのチェック)だけしてアップロードは行いません。

--validate-appはCIなどで自動的に実行するようにしておくと良いと思います。

$ altool -v -f build/AppName.ipa -u USERNAME -p PASSWORD

 

iTMSTransporterでアプリケーションをアップロードする

まずApp Store packageを作る必要があります。 App Store packageは.itmspという拡張子を持つディレクトリです。 App Store packageにはそのアプリケーションのメタデータをmetadata.xmlという名前のファイルで格納します。

上述したIn-App Purchaseのメタデータをアップロードするのは項目がたくさんあって大変なのですが、アプリケーションのアップロードに必要なのは下記のようにAppID、ファイル名とサイズ、およびチェックサムがあればいいようです。

<?xml version="1.0" encoding="UTF-8"?>
<package version="software4.7" xmlns="http://apple.com/itunes/importer">
    <software_assets apple_id="123456789">
        <asset type="bundle">
            <data_file>
                <file_name>AppName.ipa</file_name>
                <checksum type="md5">8ef4f4c855a4d16d5710075c2caa6c85</checksum>
                <size>1174126</size>
            </data_file>
        </asset>
    </software_assets>
</package>

 
ipaファイルはApp Store package、つまりmetadata.xmlと同じ階層に格納します。 App Store packageがmybundle.itmspという名前ならば、ファイル構成は次のようになります。

$ tree mybundle.itmsp/
mybundle.itmsp/
├── AppName.ipa
└── metadata.xml

0 directories, 2 files

 
あとは必要なオプションとともに実行するだけです。 パッケージをアップロードはするには、-mオプションを使って動作モードをアップロードモードに指定します。

'/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/MacOS/itms/bin/iTMSTransporter' -m upload -f mybundle.itmsp -u USERNAME -p PASSWORD -v detailed

 

以前の方法

以前のValidationコマンドを使う方法はXcode 6からはerror: Unknown application extension '.' - expected '.app' or '.ipa'というエラーが出てしまうので使えなくなったようです。

xcrun -sdk iphoneos Validation -online -upload -verbose build/AppName.ipa 

 

iTunes Connectで必要な手続き

以前のiTunes Connectではバイナリのアップロードするには、アプリケーションの状態をReady to Uploadの状態にしておく必要がありましたが、現在のiTunes Connectではバージョン番号とビルド番号(CFBundleShortVersionStringとCFBundleVersion)さえ異なっていればいつでも任意のビルドをアップロードできるようです(審査中でも)。

 
アップルのTestFlightは自動化できないからワークフローに載せにくいと思っていましたが、アップロードが自動化できるのならけっこう使いものになるんじゃないかと考えています。

Travis CIでiOSアプリのリリース作業を自動化する

この記事において利用している.travis.ymlRakefileの全体はGistにて公開しています。


↓ Rakefileの全体はこちら


↓ .travis.ymlはこちら

概要

ユビレジではiOS アプリを申請する際に発生する作業の大部分をCIで自動化しています。

申請の作業としてユビレジでは下記のワークフローを決めています。


1. リリースブランチを作る
2. リリースするバージョンのバイナリをビルドする
3. 2と同等のアプリケーションを社内に配布して最終チェックをする
4. クラッシュレポートのサービスとしてCrittercismを利用しているので、そこにデバッグシンボル(dSYM)をアップロードする
5. 2のバイナリをiTunes Connectにアップロードする


図にすると下記のようになります。
手順としては難しいものではないのですが、定期的に発生する作業としてはかなり面倒な部類のものです。

20141022124901


また、3のチェックで不具合が見つかった場合は修正して再度やり直しになるので、できるだけ簡単にしたいところです。
さらに、リリースビルドが正しい構成でビルドされているかの確認はパッと見ただけではわからないので、各自の環境でビルドしている場合、それがデバッグ構成になってないかどうかなどを気をつけるのがけっこうストレスだったりします。
そして、この作業は手作業でやるぶんには分割しにくいので(TestFlightの登録だけ手伝います、って言われても別に楽にならない)、1人の時間をけっこう取ってしまうというのも問題です。


そこで、ユビレジではこの一連の作業のうち、下記の点線で囲んだ部分、iTunes Connectにアップロードする以外の作業をすべてCIで自動化しています。

20141022130752


そうすると、実際の行われているワークフローは下記のように変わります。


1. リリースするブランチ(普通はmaster)からreleaseで始まる名前のブランチを派生する(例:release/3.0.0)
(Travis CIによって)
a. リリースビルドが作られる
b. デバッグシンボルドがCrittercismにアップロードされる
c. リリースビルドがAdHoc署名されてTestFlightで配信される
d. リリースビルドがGithub Releasesに登録される
2. 該当ののリリースをダウンロードしてXcodeから申請する


ここで実際に手作業で行う必要がある作業は太字で強調した2つだけです。

自動化の仕組み

リリースビルドの作成

ここから実現方法について説明します。
まず、リリースビルドを自動で作る仕組みは.travis.ymlとrakeのスクリプトで実現しています。

(略)
script:
  - '[ ! -z $(echo ${TRAVIS_BRANCH} | grep "^release.*$") ] && CONFIG=release || CONFIG=adhoc'
  - echo ${TRAVIS_BRANCH}
  - echo ${CONFIG}
  - bundle exec rake $(echo ${ACTION} | sed -e "s/CONFIG/${CONFIG}/g")
(略)
  matrix:
    - ACTION="profile:install certificate:add distribute:CONFIG certificate:remove"
    - ACTION=test


.travis.ymlは上記のようになっていて、ブランチ名が"release"で始まるかどうかでCONFIG変数の値が変わります。
CONFIGの値はrakeのタスクにそのまま渡されて、その値によってリリース版かベータ版のどちらの構成でビルドするかが決定されます。


Rakefileの該当部分は下記になります。

def archive(configuration: "Release")
  build_xcarchive(configuration: configuration)

  clean_ipa
  export_ipa(configuration: configuration)

  zip_artifacts

  if configuration == "Release"
    upload_artifacts
  end
end


内容を1つずつ見ていきます。
build_xcarchiveでは下記のコマンドが実行されます。
(見やすさのため絶対パスを相対パスに書き換えています。以下同様)

xcodebuild -sdk "iphoneos"
           -workspace "./Ubiregi2.xcworkspace"
           -scheme "Ubiregi2-Release"
           -configuration "Release"
           CONFIGURATION_BUILD_DIR="./build"
           CONFIGURATION_TEMP_DIR="./build/temp"
           CODE_SIGN_IDENTITY="iPhone Distribution: Ubiregi Inc. (X123456YZ)"
           archive
           -archivePath "./build/Ubiregi2.xcarchive"


これでbuildディレクトリにUbiregi2.xcarchiveが生成されます。
このとき実行されたコマンドはTravis CIのログから後で確認できるので、デバッグ構成でビルドしてないだろうか?などという不安を抱えることはなくなります。

リリースビルドを元にAdHoc版を作成する

clean_ipaは以前のipaファイルがあったら次のコマンドが失敗するので消すメソッドです。手元で実行するときのためのものです。
export_ipaで今作った「Ubiregi2.xcarchive」をipaファイルに変換します。
このipaファイルを作るのは社内確認用にTestFlightにアップロードするためなので、ここで同時にAdHocプロビジョニングプロファイルで再署名します。


export_ipaで実行される実際のコマンドは下記になります。

xcodebuild -exportArchive
           -exportFormat "IPA"
           -archivePath "./build/Ubiregi2.xcarchive"
           -exportPath "./build/Ubiregi2.ipa"
           -exportProvisioningProfile "Ubiregi Ad Hoc"


これで先ほどビルドしたものと全く同じバイナリが、署名だけ変えて作成されます。
「Ubiregi2.xcworkspace」を単にipaに再パッケージしているだけなので、実行バイナリは完全に同一です。
こうすることによって、署名以外はストアで配布されるものと全く同じアプリケーションを社内で確認することができます。

成果物をzipにまとめる

xcarchiveはディレクトリなのでそのままでは不便なので次のzip_artifactsメソッドでzipファイルにまとめられます。
ここでdSYMディレクトリもついでにzipファイルにしてしまいます。

実際のコマンドは下記です。

(cd ./build; zip -ryq Ubiregi2.app.dSYM.zip Ubiregi2.app.dSYM)
mv ./build/Ubiregi2.app.dSYM ./build/Ubiregi2.xcarchive/dSYMs/Ubiregi2.app.dSYM
(cd ./build; zip -ryq Ubiregi2.xcarchive.zip Ubiregi2.xcarchive)
成果物を外部にアップロード

TestFlightとCrittercismへのアップロードについては以前に書いたものと同様なので、リリースビルドをGithubのReleasesにアップロードする部分を紹介します。

upload_artifactsメソッドは下記のコマンドになります。

curl -sSf
     -d "{\"tag_name\":\"v3.0.0-4321_2014-10-22T06-01-18Z\",
         \"target_commitish\":\"master\",
         \"name\":\"v3.0.0-4321_2014-10-22T06-01-18Z\",
         \"body\":\"Build: 3.0.0 (4321)\\nUploaded: 2014/10/22 15:01:18\\n\",
         \"draft\":false,
         \"prerelease\":false}"
         "https://api.github.com/repos/ubiregiinc/client-releases/releases?access_token=GITHUB_ACCESS_TOKEN"

curl -sSf
     -w "%{http_code} %{url_effective}\n"
     -o /dev/null
     -X POST https://uploads.github.com/repos/OWNER/REPO/releases/RELEASE_ID/assets?name=Ubiregi2-3.0.0-4321.xcarchive.zip
     -H "Accept: application/vnd.github.v3+json"
     -H "Authorization: token "
     -H "Content-Type: application/zip"
     --data-binary @"./build/Ubiregi2.xcarchive.zip"


Releasesへのアップロードは2段階で行います。
まず、最初のリクエストで該当のバージョンのReleaseを作成します。

そのリクエストの戻り値から、作成したReleaseのIDが取れるので、そこに向けてzipファイルをアップロードします。


ここまでで、以下の手順が自動的に完了します。

a. リリースビルドが作られる
b. デバッグシンボルドがCrittercismにアップロードされる
c. リリースビルドがAdHoc署名されてTestFlightで配信される
d. リリースビルドがGithub Releasesに登録される


TestFlightで配信されたアプリケーションを社内で検証して、問題があれば、修正して同じブランチにプッシュするだけで自動的にこの手順が再実行されます。

手戻りによる追加の手間はありません。

申請作業について

最終チェックが無事にすんだら申請です。

本当ははここが最も自動化したいところなのですが、iTunes ConnectはAPIなど提供されていないので、仕方なく手作業で行います。

Github Releaseからリリースする版のxcarchiveをダウンロードします。

20141022130752


zipファイルを展開すると、Xcodeで作成したのと同様のxcarchiveパッケージになるので、それをダブルクリックするとXcodeのオーガナイザに表示されます。

20141022130752


20141022130752


あとはSubmitボタンを押して申請すれば完了です。

おまけ(リリースブランチの作成)

ここまで自動化すると、トリガーとなるリリースブランチを作る作業がだんだん面倒に思えてきます。
(ブランチを派生させてInfo.plistのバージョンを書き換えて、など)
ただ、リリースブランチは再度修正が入ることもあるので、ガチガチにしてしまうとかえって面倒になるので、リリースブランチを簡単にプッシュできるrakeタスクを用意するくらいにとどめています。

リリースブランチを作るにはリリースしたいブランチ(普通はmaster)で

$ rake release

というタスクを実行します。
バージョンの更新についての選択肢が表示されるのでどれか1つを選ぶと、Info.plistの更新、リリースブランチの作成、リモートリポジトリにプッシュ、までをやってくれます。
(するとこれをトリガーとして自動的にCIが上述のビルドを開始します)

$ rake release
Select new version [current: 3.0.0 (4146)]:
1. 4.0.0 (4321)
2. 3.1.0 (4321)
3. 3.0.1 (4321)
4. 3.0.0 (4321)
?  

まとめ

リリースを自動化するのは様々なメリットがあります。
繰り返しの手作業が減ることや品質が一定に保てることももちろんですが、誰でもその作業ができるようになるとか、作業を分割できるようになるというのも大きなメリットです。
例えばリリースするコードをFixする作業(リリースブランチの作成)と社内への告知、iTunes Connectへのアップロードはそれぞれ別のひとがやることが可能になります。

自動化しなくても担当を分けることは可能ですが、手作業でやっていると互いに待ちの時間が発生したりバイナリの受け渡しの手間など、分業することで逆に面倒になります。

ということでユビレジで行っている自動化の仕組みを紹介しました。
参考になればいいなと思います。

親指シフトで日本語入力ができるカスタムキーボード「N+Keyboard」をリリースしました

20140918014637

N+Keyboard


20140918014637


20140918014637


iOS 8からサードパーティのキーボードを利用できるようになりました。

以前にも親指シフトキーボードの使えるエディタとしてN+Noteというものをリリースしましたが、当然そのアプリでしか使えないという制限がありました。

しかし、これからはどんなアプリでも好きなキーボードを使うことができるようになります。

正直、現状のAPIを使って日本語変換を実装するのはなかなか困難なのですが、やってやれないことはないのでいろいろなキーボードがリリースされて、APIも機能拡張が進んでいけばいいなと思います。


親指シフトを使ってるひとはぜひ(安い価格ではないですが)ダウンロードして使ってみてください。


カスタムキーボードはアプリケーションをダウンロードするだけでは使えるようになりません。
設定アプリからキーボードを有効にする必要があります。


「設定」アプリから「一般」>「キーボード」>「キーボード」>「新しいキーボードを追加」選択すると、標準のキーボードの他に、利用可能なサードパーティのキーボードが表示されるので選択します。
さらに、現行の版では日本語変換にインターネットアクセスが必要なので、その後で「フルアクセスを許可」というスイッチをONにしてください。
インターネットが不要な日本語変換もメドはついているので、近い将来バージョンアップにて対応したいと考えています。

20140918014449 20140918014449 20140918014449 20140918014449 20140918014449


N+Noteについても引き続き開発していきますのでよろしくお願いします。


20131107010430 N+Note for NICOLA - Katsumi Kishikawa

iOS 8のすべてのエクステンションはオプトイン方式

↓ 昨日Todayウィジェットについて書いた記事についてのコメントに、通知センターがスパムコンテンツだらけになるんじゃないの?と心配されてる方が少しいらっしゃいました。

通知センターから今日やるアニメをサッと確認できる「今日のアニメ」をリリースしました - 24/7 twenty-four seven


現状の仕組みではそれについてはそれほど心配はいらないと思います。
(通知センターに勝手に入る心配はないということであって、スパムアプリが氾濫しないという意味ではないです。
あと、特にTodayというセマンティクスに合ってない単なるランチャーのウィジェットも通っているので今のところ審査はそれほど厳格じゃないんじゃないかなと思います。
私個人としては、通知センターは単にアクセスのよい場所という認識なので、そこに置くのが便利なのであればランチャーでも何でもアリかなと思っています。)


エクステンション(Todayウィジェットに限らずカスタムキーボードや、Photo Editingなどすべてのエクステンション)はオプトイン方式です。


ユーザーが自分でそのエクステンション有効にしない限り、アプリケーションをインストールしたら勝手に通知センターのところに現れるということはありません。(カスタムキーボードなども同じ。ホストアプリケーションをインストールした後で、そのキーボードを有効にする必要がある)

上記以外のエクステンション (Share, Action, Photo Editing, Document Provider) についても、それぞれ微妙に異なりますが、ユーザーが有効にする設定をしないといけないというところは同様です。

そして、どのエクステンションも、有効にする設定はなかなか奥深くにあるので、ユーザーがその操作をしなければならないというハードルを超えて、使ってもらうまでに至るには、かなり魅力的な機能が無いと難しいと思います。


以下、それぞれのエクステンションを有効にするための操作を示します。
アップルが意図的にそうしているのかは不明ですが、どのエクステンションも普通に使ってて自然と気づくような操作にはなってないことがわかると思います。
この設定のハードルのこともあり、ただ便利なものを作っただけでは、使ってもらうのはなかなか厳しそうだと私は思っています。

Today

通知センターを表示して、画面下部の「編集」ボタンを押すとインストールされているウィジェットの一覧が表示されるので、そこから有効にするウィジェットを選択します。

20140918014449 20140918014449

Custom Keyboard

エクステンションの中で最も有効にするまでの操作が複雑です。
「設定」アプリから「一般」>「キーボード」>「キーボード」>「新しいキーボードを追加」選択すると、標準のキーボードの他に、利用可能なサードパーティのキーボードが表示されるので選択します。
さらに、日本語変換にインターネットアクセスが必要な場合は、その後で「フルアクセスを許可」というスイッチをONにしてもらう必要があります。

20140918014449 20140918014449 20140918014449 20140918014449 20140918014449

Photo Editing

「写真」アプリを開いて任意の写真を表示したところで「編集」を押します。
その時、エクステンションが利用可能であれば左上に丸いボタンが表示されているのでそれを押すと、有効になっていればエクステンションが表示されます。
新しくインストールしたエクステンションを有効にするにはさらに「その他」ボタンを押して表示される画面から有効にするエクステンションを選択します。

20140918014449 20140918014449 20140918014449

Share, Action

共有およびアクションメニューの「その他」を選択して表示される画面から有効にする必要があります。

20140918014449 20140918014449 20140918014449

Document Provider

良い例がなかったので割愛

通知センターから今日やるアニメをサッと確認できる「今日のアニメ」をリリースしました

20140918014637

iTunes の App Store で配信中の iPhone、iPod touch、iPad 用 今日のアニメ


※ 通知センターに表示できるのはiOS 8を使っている場合だけです

iOS 8から通知センターに任意のウィジェットを表示することができるようになりました。
通知センターといえば、ロック画面、ホーム画面に次ぐ一等地であり、そこをほぼ自由に使える存在というのはかなりすごいことで(ホーム画面はそもそも開放されてない、ロック画面は制限付き(通知とPassbook, iBeaconなど))、間違いなく戦争が始まるので、今のうちにいろいろ確認しておくべきだろうと考えて作ってみました。


20140918014449


↑ アプリケーションとしては通知センターに今日放映されるアニメ番組表を表示するというものです。
どこからでも(ロック画面からでも)サッと呼び出せるので、まあ必要なひとにはそれなりに便利かと思います。


で、まあせっかく新しいAPIを使うのでいろいろ実験してみました。
詳しいことはおいおい書いていこうと思いますので、ここでは簡単に説明します。

1. 使える画面の高さには限界がある

横の幅はもちろんデバイスのサイズに制約を受けますが、縦の長さは自由にいくらでもできるわけではありません。

iPhone 5/5sで60ポイント前後、iPhone 4sで55ポイント前後です。
正式にドキュメント化されてるわけではないので、若干の余裕をみて設計しておいたほうがよいでしょう。

ちなみにiPhone 6 Plusだとホーム画面が横を向くのでiPadのように横向きで通知センターを出すことができます。
そのときに使用できる縦の長さが実は一番短くて、40ポイント前後です。
なのでiPhone 4でギリギリ表示されるからOKという設計をしてしまうと、iPhone 6 Plusで横向きに表示した時に切れてカッコ悪いことになったりします。
iPadでの表示はサイズを気にするようなことはほぼ起こらないと思うので調べてません。

上記の数字は間違いです。ソースコードを見ながら書いていたのですが、1時間ぶんの長さの定数を書いてしまっていました。
実際にはこのアプリは6時間ぶん+ヘッダを一度に表示するので、計算すると、iPhone 5/5s (4インチ) のタテ画面で約400ポイント、iPhone 4s (3.5インチ) で約360ポイント、iPhone 6 Plusのヨコ画面で約300ポイントが最大の高さになります。

2. スクロールは一切できない

左右のスワイプは通知との切り替えに使用されていることもあってできません。
縦のスクロールはできるんじゃないかと思いましたが、それもできません。
(スクロールビューを置いてもスクロールしない。イベント自体が発生しない?)


ただし、後述しますが、タッチイベントやボタンは一般のビューと同様に使えるので、アクションによって画面を切り替えたりすることで擬似的に画面ぶん以上の情報を表示することは可能です。

3. キーボードの入力はできない

これはドキュメント化されてますしWWDCのセッションでも制限事項として言われていたのでご存知と思います。
テキストを入力させたりはできないので、リードオンリーで設計するか、簡単なボタンだけでできることを考えるべきです。

ただし、タッチイベントは普通に取れるので、すごく上手にハンドリングすれば、フリーハンドで字を書いたり、タップの組み合わせで高度な入力もやってやれないことはないと思います。

4. 使えないコンポーネントがある (MKMapView)

Widgetの作り方は大ざっぱに言うと、Widget用のビューコントローラが提供されるので、その上にビューをおいたり通信してデータを取得したりして、作ります。
そのビューコントローラのビューの上で全部やらなければならない、という以外は普通のアプリを作るのと一緒です。

なので、何でもできるぞ、と思っていろいろ考えてたのですが、使えないコンポーネントがありました。
地図を表示するMKMapViewです。

Beta版で試したときの結果なので、今はもしかしたらできるかもしれませんが、そのときはダメでした。
標準のMapがダメならサードパーティのはどうかと思って、Yahoo Maps SDKを試しましたがそちらもダメでした。
ただ、MKMapViewが何も言わずに異常終了するのに比べて、Yahoo Maps SDKの場合はメモリ不足というエラーがでていたので、モノによっては使えるものがあるかもしれません。

5. WebViewは使える

いろいろ試した中でおもしろかったのは、WebViewが使えることです。

20140918021434


テキスト入力ができないので、実用的にするのはかなり難しいですが、複雑なレイアウトをHTMLで組むというのもアリかもしれません。いちおうリンクもクリックできたりできなかったり微妙な動きでしたが、できなくはなかったです。

6. カスタムフォントが使える

20140918022454


↑ こちらのスクリーンショットで、上半分の番組の表示と下半分の表示で使われているフォントが異なるのがわかりますか?
どうしてもチャンネルが多くなってくるとiPhoneの小さい画面に収めるのが難しくなるのですが、それでもなんとか多くのテキストを表示できるように、幅によって細いフォントで表示するようにしています。

このフォントはもちろん標準では入っていないので、2/3角フォントというのをバンドルしてカスタムフォントとして使用しています。

普通のアプリでカスタムフォントを利用するのと同じやり方で使用できます。

7. マージンをゼロで画面いっぱい使っても審査に通る

このウィジェットは番組表アプリという性格上、小さい領域に非常に多くのテキストを表示する必要がありました。
そのため、標準のマージンを守っていてはまったく実用的ではないと思ったので、思い切ってマージンをゼロにして限界いっぱい画面を利用することにしました。

これについて、どんなアプリでも通るかはわかりませんが、今回は特に問題ありませんでした。

8. ボタンによって画面を切り替えても審査に通る

上述したように画面をめいっぱい使っても24時間の番組表を表示するには足りません。
しょうがないので、24時間はあきらめて、半分の12時間だけを表示するようにしました。
(重要なのは夕方から深夜なので真っ昼間と早朝は捨てた)

ただ、12時間にしてもわかるように表示するのには前述の高さ制限により、高さが足りないため6時間ずつ2画面を切り替えて表示するようにしました。

画面の切替は、上部のボタンを使って行います。
太陽のボタンを押せば夕方(16〜22時)の、月のボタンを押せば深夜(22〜4時)の時間帯の番組表を切り替えて表示します。

20140918022454


これも特に何も言われなかったのでOKなのかなと思います。

ちなみに画面の切り替えに、UIViewのアニメーションを使用するなどは自由にできます。
(フェードでも反転でも何でもOK)

9. コンテナアプリとのデータ共有が可能

これもドキュメント化されていることなので特に詳しく説明はしません。
App Groupを使ってUserDefaultsを共有したり、キーチェーンでホストアプリのログイン情報を使ったり、UIPasteBoardで簡単にやるのもよいでしょう。

10. ウィジェットは土地争い

最初にも述べたようにウィジェットは通知センターという好立地の場所を使えるのが最大のメリットです。
ただし、ウィジェットのプログラミングは非常に簡単で誰にでも作れるので、アイデアとずっとそこに置いてもらえるような価値を提供できるかどうかが勝負になります。
通常のアプリと違ってウィジェットを何十個も並べるというひとはほぼいないと思われるので、限られた土地の争奪戦になります。
(おそらくユーザー1人あたりが常時表示するウィジェットは多くて4,5個でしょう)

定番のウィジェット、というのはまだまだこれからなので、個人でも企業でもいろいろ試行錯誤したりチャレンジすると面白いと思います。

iOS 8 beta5にてポップオーバーをキャンセルするための暗いところを連続でタップすると下にあるモーダルビューも閉じてしまうバグ

一昨日くらいにiOS 8でテストをしていたら見つけました。
問題となる画面構成を多用するアプリケーションにとっては、修正が間に合わなければけっこう致命的と思われるので共有します。
(Base SDKをiOS 8にしてビルドしない限りは起こりません。リリース済みのアプリケーションをiOS 8で動かすぶんには問題無いです。)

↓ バグレポートした内容は下記の記事で公開しています。
重ねてレポートすると優先順位があがるかもしれません。

Radar: When double-tap the dimming view to dismiss the popover, it will also close modal view under - 24/7 twenty-four seven

サンプルコード

↓ バグレポートに添付した問題が再現するサンプルコードです。
Xcode 6でビルドして、iOS 8のiPadで実行すると問題が再現できます。

https://dl.dropboxusercontent.com/u/285673/sample.zip

事象について

事象を簡単にまとめると、モーダルビューの上にポップオーバーを表示している時、暗いところをタップしたらポップオーバーが消える、のが通常だけど、それを2回以上タップすると、下のモーダルビューまで閉じてしまう。

調べると、下のビューのdismissViewController〜メソッドが呼ばれることでポップオーバーが閉じるのだけど、
ダブルタップ以上すると、それが複数回呼ばれるので、ポップオーバー以外のビューも閉じられてしまう、という理屈です。


20140831200047

具体的な影響

このダブルタップの間隔は暗くなっているビューのalphaがゼロになるまでにすれば再現するので、かなりゆっくりでも起こってしまいます。
ちょっと反応が悪いかな?と思ってもう一回タップすると画面全体が消えてしまった、みたいなことが普通に起こります。


やっかいな点は、ポップオーバー形式で表示するビューは何でもこの問題の対象となることです。
iPadでは自動的にポップオーバーで表示されてしまうものや、ポップオーバーで表示しなければならない標準のビューはけっこうあって、UIActionSheetやUIImagePickerController、共有につかうUIActivityViewController、UIDocumentInteractionControllerなどが全部対象になります。


↓ 例えば、下のような画面でモーダルビューでUIWebViewを表示して、そこに共有のためのボタンがある、みたいな画面も対象になります。
(下の状態で暗いところをダブルタップすると、UIWebViewを表示しているモーダルビューも閉じてしまう)


20140831203642


救いなのは、Xcode 6つまりBase SDKをiOS 8にしてビルドしない限りはこの問題は起こらないことです。
(リリース済みのアプリケーションをiOS 8で使うぶんには問題ない)


ただ、満を持してiOS 8のリリースと同時にアップデートしたアプリケーションで問題が起きたらいろいろ大変だと思うので、iPad対応アプリケーションを出してるひとは気をつけてください。

対策

もし、GMで修正されていなかったときのためにいちおう対策を考えました。
標準のUIActionSheetとかで起こるので、呼び出し側で工夫するのは限界があるので、問題の起こっている、暗いところをタップしたときのイベントハンドラをSwizzlingして同じインスタンスの呼び出しなら1度しか実行しない、というようにしてみました。

正直、こういうコードをプロダクトに入れたくはないので、修正が間に合うことを願っています。

static IMP __Original_UIPopoverPresentationController_dimmingViewWasTapped;

void __Swizzle_UIPopoverPresentationController_dimmingViewWasTapped(id self, SEL _cmd, id sender)
{
    static id dimmingView;
    if (dimmingView == sender) {
        return;
    }
    dimmingView = sender;
    __Original_UIPopoverPresentationController_dimmingViewWasTapped(self, _cmd, sender);
}

+ (void)load
{
    Class clazz = NSClassFromString(@"UIPopoverPresentationController");
    SEL selector = @selector(dimmingViewWasTapped:);
    Method method = class_getInstanceMethod(clazz, selector);
    __Original_UIPopoverPresentationController_dimmingViewWasTapped = method_setImplementation(method, (IMP)__Swizzle_UIPopoverPresentationController_dimmingViewWasTapped);
}

Radar: When double-tap the dimming view to dismiss the popover, it will also close modal view under

If you display a popover on a modal view, when performing multiple tap the dimmig view to close popover, modal view will also be closed not just popover.

Tapping dimming view calls the event handler(-[UIPopoverPresentationController#dimmingViewWasTapped:]).
The method calls presenting view controller's `dismissViewController:animated:completion:` method.
So the method called twice or more, `dismissViewController~` method is called multiple times, also close other views not just pop over.

Submitted as rdar://18186956 and to OpenRadar.

Summary:

If showing a pop over on the modal view, double-tap the dimming view to dismiss the popover.
It will also close the modal view together.

Steps to Reproduce:
  1. Open and run the project https://dl.dropboxusercontent.com/u/285673/sample.zip
  2. Push the right bar button (named "Modal") on navigation bar
  3. Shown the table view modally
  4. Push the right bar button (named "Popover") on navigation bar, shown popover
  5. "Double-Tap" the dimming view
Expected Results:

Dismiss the popover

Actual Results:

Dismiss the popover, and also dismiss modal view under

Regression:

iPad Air, 32GB, WiFi
iOS 8.0 (12A4345d)

Note:

Not only UIPopoverController, this occurs in all views to be displayed in a pop-over style.
For example UIAlertControler(ActionSheet), UIPrintInteractionController, and so on.

Travis CI (Pro) の実行をジョブの並列化とBundlerとCocoaPodsのキャッシュで速くした

ユビレジではTravis CIを使って、テストの実行とベータ版のTestFlightへのアップロードを自動化しています。

Pull Requestが送られた時と、マージされた時に自動でマージした結果のベータ版が配布されるので、手元で変更をすぐに試すことができて便利です。


【参考】
Travis CIでiOSアプリのテスト&ベータ版の配信に使っているRakefileを改善したメモ - 24/7 twenty-four seven
ユビレジのiPadアプリのCI環境をJenkinsからTravis CIに移行したときのまとめ - 24/7 twenty-four seven


ただ、これは導入当初からあった問題なのですが、Travis CIにジョブが登録されてから終了するまで、だいたい20〜25分くらいと、少し時間がかかるのが気になっていました。


そこでジョブの並列化と、BundlerとCocoaPodsのキャッシュ2点で高速化を試みました。

まずジョブの並列化は、テストの実行とTestFlightへのアップロードを分けて並列で実行することにしました。
これまでテストがすべて成功した後に再度デバイス用のバイナリをビルドしてTestFlightにアップロードしていました。

それをテストの終了を待たずに、TestFlightのアップロードをするように変更しました。

テストが失敗するビルドがTestFlightで配信されることが起こりますが、テストが通らないビルドがマージされることは無いので、手元で早く確認できるということを優先しました。


変更は簡単で.travis.ymlの呼び出し方法を少し直すだけでした。

↓ もともとこうだったのが、

language: objective-c
script:
- bundle exec rake test
after_success:
- bundle exec rake profile:download
- bundle exec rake certificate:add
- bundle exec rake distribute
- bundle exec rake certificate:remove


↓ まず、Rakeタスクの呼び出しをそれぞれ1行にします。

language: objective-c
script:
  - bundle exec rake test
after_success:
  - bundle exec rake profile:download certificate:add distribute certificate:remove


↓ タスクをBuild Matrixのパラメータに振り分けます。

language: objective-c
script:
  - bundle exec rake ${ACTION}
env:
  matrix:
    - ACTION='profile:download certificate:add distribute certificate:remove'
    - ACTION=test


これだけで、Matrixの上段はTestFlightの配信、下段はテストの実行で並列に実行されます。



次にBundlerとCocoaPodsをキャッシュする設定です。

↓ OSXのビルド環境でもキャッシュの設定が有効になったということで試しました。

The Travis CI Blog: Caching (all the things) on the Mac platform

language: objective-c
cache:
  - bundler
  - cocoapods


↑ のオーソドックスな設定ではなぜかうまく動かなかったので、下のようにディレクトリをキャッシュする設定を使いました。

language: objective-c
cache:
  directories:
    - vendor/bundle
    - Pods
install:
  - bundle install --path=vendor/bundle --binstubs=vendor/bin
  - bundle exec pod install


ビルド時間の半分以上がBundlerとCocoaPodsのインストールにかかる時間だったので、これはかなり有効でした。


最終的に、TestFlightへのアップロードが3〜5分、テストの実行が4〜6分くらいで合計で8〜12分くらい、並列で実行されるのでPull Requestしてからだいたい5分くらいで手元で試せるベータ版が届くという環境になっています。