24/7 twenty-four seven

iOS/OS X application programing topics.

コマンドラインから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分くらいで手元で試せるベータ版が届くという環境になっています。


iOS 8でIn-App Purchaseの状態に追加されるSKPaymentTransactionStateDeferredの影響を考える

In-App Purchaseでプロダクトの購入を扱うときにはStoreKitのSKPaymentTransactionStateを使います。
例えばPurchasedなら購入完了なのでプロダクトのダウンロードを始める、Failedなら失敗なのでアラートを出すなどとします。

iOS 8からはその状態に新しくSKPaymentTransactionStateDeferredが追加されます。


2014/8/6時点ではまだドキュメントに解説はありません。
API diffとヘッダには記載されています。
WWDCのセッション218, 303ではそれなりに詳しく解説されていますので参考になると思います。


Deferredという状態は「Ask to Buy」というiOS 8のApp Storeの新機能のために導入されました。
「Ask to Buy」はiOS 8で搭載される「ファミリー共有 (Family Sharing)」の1機能で、子どもがApp Storeから購入する際に親の許可を必要とすることができるというペアレンタルコントロールの強化です。

20140806155004 20140806155004

Apple - iOS 8 - ファミリー共有


「Ask to Buy」が有効になっているアカウントで購入しようとしたとき、購入をいったん保留して許可を求める通知が親のアカウントに届きます。
このとき、購入は「完了(Purchased)」でも「失敗(Failed)」でもなく「Deferred」になります。


いずれDeferredは「完了(Purchased)」か「失敗(Failed)」のどちらかになりますが、それがいつになるかは親のアカウントがいつ判断をするかによるので、場合によっては何日かかかることも考えられます。
そのため、セッション303によると、この状態のときは通常通り(購入前の状態で)アプリケーションが使えるようにしなければならないとのことです。


さて、上記を踏まえて、既存のアプリケーションについてどのような対応が必要になるか考えてみます。


通常In-App Purchaseをハンドリングするオーソドックスな実装は下記のようになっていると思います。

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions
{
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchasing:
                break;
            case SKPaymentTransactionStatePurchased:
                [self sendReceipt:transaction];
                [self completeTransaction:transaction];
                break;
            case SKPaymentTransactionStateFailed:
                [self failedTransaction:transaction];
                break;
            case SKPaymentTransactionStateRestored:
                [self restoreTransaction:transaction];
                break;
            default:
                break;
        }
    }
}


そして、購入を開始してから終了(完了または失敗)までをモーダルに処理しているアプリケーションも多いと思います。
(そのほうがわかりやすいことも多いので、購入開始〜完了までをモーダルにするのは別に悪いUIでは無いと思います)


ただ、その場合は上記の実装をそのままにしておくと、iOS 8でSKPaymentTransactionStateDeferredが返ってきたときに完了にも失敗にもならないので、購入中のモーダルな状態のままになってしまうことになります。

そこで、上記のswitch文にSKPaymentTransactionStateDeferredの分岐を追加します。
おそらくいったん失敗(Failed)と同じ処理をするのが簡単じゃないかと思います。
ただし、おそらく失敗のときとは異なり、トランザクション自体は終了させない(+ [SKPaymentQueue finishTransaction:]を呼ばない)のではないかと思います。
(このあたり、Sandbox環境で試すことはまだできないようですし、ドキュメントも無いので実際にどうなのかは不明です)


もともと、購入中でもモーダルにしてなくて自由に通常の操作ができる、という作りのアプリケーションの場合は特に気にせずゆっくり対応しても大丈夫そうかなと思います。
Deffered以外の既存の購入フローには影響はなさそうなので。

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions
{
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchasing:
                break;
            case SKPaymentTransactionStatePurchased:
                [self sendReceipt:transaction];
                [self completeTransaction:transaction];
                break;
            case SKPaymentTransactionStateFailed:
                [self failedTransaction:transaction];
                break;
            case SKPaymentTransactionStateRestored:
                [self restoreTransaction:transaction];
                break;
            case SKPaymentTransactionStateDeferred: 
                // Deferredの状態に対応する処理を追加する
                // おそらく「失敗(Failed)」と同様にするのが簡単
                // ただし、トランザクションは終了させない(と思う)
                break;
            default:
                break;
        }
    }
}


「ファミリー共有 (Family Sharing)」および「Ask to Buy」の機能がいつ、既存のアプリケーションに対して適用されるのかとかもよくわかりませんが、In-App Purchaseを利用している場合は何らかの対応を検討する必要がありそうだなと思います。

Storyboardを1画面ごとに分割した話

今年の5月くらいの話なのですが、ユビレジのiPadアプリケーションのプロジェクトで使っているStoryboardを基本的に1画面(≒1 View Controller)の単位に分割するということをしました。


1画面1Storyboardメソッドについてはnakiwoさんが書かれた記事も参考になります。

1画面から始めるStoryboard - Cocoaメモ


↑ 上記の資料はどちらかというとStoryboardを使い始めるにあたって、1画面単位で少しずつ使っていこうという感じですが、ユビレジではもともとほぼ全部の画面がStoryboardになっていました。

ただ複数人で共同作業をするにあたっては、1画面単位を1ファイルにしておくくらいがメンテナンスしやすいんじゃないかなあという結論になったのでしばらくそういうふうに運用することにしました。


また、XIBと違ってStoryboardは単純にコピー&ペーストで別のファイルにまったく同じ構成のものを移すことができるので試すのも戻すのも非常に簡単なので合わなかったら戻せばよいと考えました。
(XIBだと単純なコピー&ペーストだとアクションの関連が外れたりします。まあXIBに複数のコンポーネントがあってそれを分けたいということは滅多にないのでそれは特に問題ではないですが)


複数のView Controllerを含む大きなStoryboardに比べて、小さなStoryboardのメリットしては、

  • コンフリクトの可能性が減る(大きなStoryboardだと、全く別の画面の修正にもかかわらず同じファイルになるので)
  • Auto LayoutのOn/Offを画面ごとに柔軟に設定できる(Auto Layoutの設定はファイルごとなので)
  • XIBと同様に使える(StoryboardはXIBの上位互換)

があります。

あと大きなStoryboardは作った当人は慣れているので、どこに何があるかすぐ分かるけど、他のひとにとっては目的の画面を探すのもけっこう大変だったりします。


特にユビレジでは4/5月にiOSのエンジニアが増えたので、Storyboardのマージに気を使うことが多くなることを避けたいというのが第一の大きな理由で、次に新しい人が既存のコードを追っていくなかでStoryboardを見るときに迷わないようにしたいという点です。

デメリットとしては、基本的に1つのStoryboardファイルには1つの画面しかないので、画面の遷移にSegueを利用することはできなくなります。

ただその点については、Segueを使うこと自体がそれほどメリットではないということで、トータルで考えるとそれほどデメリットにはならないという判断をしました。


Segueを使う場合であまり良くないと考えていることの1つは、多くのケースでは画面を遷移するときにいくつかデータを受け渡す必要があり、Segueを使う場合は標準のAPIではその部分がどうしても微妙になってしまうという点です。


例えば典型的なSegueを使った画面の遷移は下記のようになります。

- (IBAction)nextButtonTapped:(id)sender
{
    [self performSegueWithIdentifier:NSStringFromClass([UBCheckoutViewController class]) sender:self];
}

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    if ([segue.identifier isEqualToString:NSStringFromClass([UBCheckoutViewController class])]) {
        UBCheckoutViewController *controller = segue.destinationViewController;
        controller.delegate = self;
        controller.checkout = self.checkout;
    }
}

↑ このように、ユーザーのアクションを受けて次の画面に必要なパラメータを渡して遷移するというごく普通のケースを記述するのに、画面を遷移するという処理と、別の処理で遷移先を判断してパラメータをセットするという2つに少なくとも分かれてしまいます。
さらに、Storyboard上で設定するSegueのIDがコードに文字列として(!)現れてしまうというのも残念なところです。


いちおう、SegueのIDは遷移先のビューコントローラの名前と同じにするというルールにして、コード上ではクラス名から取得するという形で、リファクタリングなどがある程度有効になるようにしていますが、Storyboard上でIDを修正し忘れたり、両方を一緒に修正しなければならないというのは結局変わらないので、やっぱりイマイチだなと思います。


1画面1Storyboardの場合は、従来の遷移先のビューコントローラを作ってデータをセットして遷移という形式で書くことができます。

- (IBAction)nextButtonClicked:(id)sender
{
    UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"Checkout" bundle:nil];
    UBCheckoutViewController *controller = [storyboard instantiateInitialViewController];
    controller.delegate = self;
    controller.checkout = self.checkout;
    
    [self presentViewController:controller animated:YES completion:NULL];
}


↑ 上記の場合でもUBCheckoutViewControllerをインスタンス化するには「Checkout」という名前のStoryboardを使うという遷移元のビューコントローラが特に知る必要のない情報が書かれてしまうのが残念なので、少し工夫して下記のように、Storyboardからインスタンス化する部分をそのビューコントローラのクラスメソッドに隠蔽しています。
(ちなみにStoryboardのファイル名もビューコントローラ名にすればいいかと悩みましたが、厳密に1画面だけにするべき、とはしたくなかったのでそれはやめました)

- (IBAction)nextButtonClicked:(id)sender
{
    UBCheckoutViewController *controller = [UBCheckoutViewController checkoutViewController];
    controller.delegate = self;
    controller.checkout = self.checkout;
    
    [self presentViewController:controller animated:YES completion:NULL];
}


初期データを設定するだけのためにプロパティを公開するのは嫌だ、という場合にはイニシャライザで渡すように書くのも簡単です。

- (IBAction)nextButtonClicked:(id)sender
{
    UBCheckoutViewController *controller = [UBCheckoutViewController checkoutViewControllerWithCheckout:self.checkout delegate:self];
    
    [self presentViewController:controller animated:YES completion:NULL];
}


こうしておけば、UBCheckoutViewControllerの作成方法は使う側には関係がないので、仮にStoryboardを使わずにコードで生成するように変わったとしても使う側に影響はありません。


他社のプロジェクトで私が見た例だと、Factoryクラスを用意してそれを通してView Controllerを取得するように作っているところもよく見ます。
もちろんケースバイケースですが、1クッション挟むわりには特に抽象度も結合度もそれほど変わらないと思ったので、それなら直接相手のView Controllerを使うほうがわかりやすいと考えて上記の方法を採用しています。


余談ですが、下記の所さんの記事など、ヘルパーライブラリを作って解決する方法もあります。
ただ、我々は画面の遷移という基本的な処理を理解するのに標準でないAPIをまず知らなければならないという状況は、少なくとも人の変化がこれからも頻繁に起こることが考えられる中では避けたいと判断しました。

Storyboardでの画面遷移をスマートにやる方法 - TOKOROM BLOG


また、少なくとも私は上記の記事にある遷移元に遷移先の情報が必要になる(依存関係がある)という状況は特に問題とは考えていないので(逆はかなり問題があります)先に書いたくらいがバランスいいのではと思っています。


別にSegueはダメな子なので使うなというわけではもちろんありません。
新しいものをイチから作っていくときなど、試行錯誤が必要なときで一人で作るぶんには1つのStoryboardでSegueを使っていくのはとても早く作れるので非常に便利ですし、私も新しいものはガンガンSegueで繋いで作っています。


ただ、ある程度UIが落ち着いてきて、細かい改修だったり共同作業が増えてきた段階になると、
細かい単位にStoryboardを分けることで変更がやりやすくなるかどうかを検討してもいいんじゃないかという話です。


参考までに分割前と、後のスクリーンショットを載せておきます。


分割前:
(Storyboardはこれを含めて3つあった中でメインに使っていたものです)
20131117181446


分割後:
(基本的には1画面1Storyboardですが、一部の設定画面やモーダルビューは関連する画面をまとめたまま残しています)

20131117181446

20131117181446

20131117181446

20131117181446

20131117181446

Travis CIでiOSアプリのテスト&ベータ版の配信に使っているRakefileを改善したメモ

↓ コード署名に失敗する問題を直すついでに、今まで運用していく中でいくつか改善したい点があったので少し手を加えました。
Travis CIでipaを作るときのCode Signが失敗するのを修正したメモ - 24/7 twenty-four seven

Provisioning Profileのダウンロードにnomad/cupertinoを使う

これまではapple-devというRubyのスクリプトを使用していたのですが、それをやめて、cupertinoというGemを使うようにしました。


もともとcupertinoの存在は知っていたのですが、READMEを見て対話的な使い方しかできないものだと思ってしまっていたので、CIでは使えないと勘違いしていました。


実はコードを読んでみると、次のように`-u`と`-p`オプションでユーザ名とパスワードを渡すことがどのコマンドについてもサポートされているようだったので試してみるとあっさり成功したのでこれを使うように置き換えました。

global_option('-u', '--username USER', 'Username') { |arg| agent.username = arg unless arg.nil? }
global_option('-p', '--password PASSWORD', 'Password') { |arg| agent.password = arg unless arg.nil? }
global_option('--team TEAM', 'Team') { |arg| agent.team = arg if arg }


apple-devはBundlerでインストールできるようになっていなかったので、リポジトリにスクリプトを含めていましたが、それが不要になったので少しリポジトリがスッキリしました。

Pull Requestに対するビルドについてもTestFlightでベータ版を配信する

これまではmasterブランチにマージしたときだけTestFlightでベータ版を配信していたのですが、Pull Requestを簡単に動かして確認したいという声があったので、試しにPull RequestについてもTestFlightにアップロードするようにしてみました。


↓ この対応についてはTravis CIの実行時に設定される環境変数を見て処理を判断していたところを消しただけです。

task :testflight => ["version:set_build_version", IPA_FILE, :crittercism] do |t|
  iff ENV['TRAVIS_PULL_REQUEST'] != "false"
    puts "This is a pull request. No deployment will be done."
    next
  end
  if ENV['TRAVIS_BRANCH'] != "master"
    puts "Testing on a branch other than master. No deployment will be done."
    next
  end


また、Travis CIにはPull Requestの場合`TRAVIS_PULL_REQUEST`という環境変数にPull Requestの番号を入れてくれてるということなので、ビルド番号にPull Requestの番号を加えて配信することにしました。

task :set_build_version do
  rev = `git rev-parse --short HEAD`.strip

  pr_number = ENV["TRAVIS_PULL_REQUEST"];
  rev << " ##{pr_number}" if pr_number != "false"

  puts "Setting build version to: #{rev}"

  InfoPlist.build_version = rev
end

↑ これで、Pull Requestから作られたビルドについては、"Ubiregi2 2.67 (5bf4bc9 #590)"のような形でPull Requestの番号が表示されます。

Travis CI Pro(有料版の環境)のRubyが2.0.0でもCocoaPodsの実行に失敗しなくなった

今まではTravis CI Proの環境だとRuby 2.0.0を使うと`pod install`に失敗していたので、わざわざRuby 1.9.3の環境を指定していたのですが、それが直ったのでRuby 2.0.0を使うことができるようになりました。

↓ それで何が良くなったかというと、Rakefileでキーワード引数が使えるようになったので少しシンプルに書けるようになりました。

def join_option(options: {}, prefix: "", seperator: "")
  _options = options.map { |k, v| %(#{prefix}#{k}#{seperator}"#{v}") }
  _options = _options.join(" ")
end


↓ これらの変更を加えたRakefileの全体はこちら
Rakefile for testing, building and uploading to Testflight/Crittercism

Travis CIでipaを作るときのCode Signが失敗するのを修正したメモ

一週間ほど前から(おそらくTravis CIの環境がXcode 5.1に変わってから)Travis CI上でipaファイルの作成に失敗するようになってしまって、TestFlightにベータ版を自動的にアップロードすることができなくなっていたのを昨日ようやく直したのでメモ。


↓ということで以前に書いた記事はちょっと古くなってしまいました。
本文はそのままですが、参照先のgistの内容はアップデートしてあります。
ユビレジのiPadアプリのCI環境をJenkinsからTravis CIに移行したときのまとめ - 24/7 twenty-four seven


失敗している箇所のエラーメッセージは下記の通り。ipaを作る前の、プロジェクトのビルドでコード署名をするところで失敗しているけど、これだけだと原因がよくわからないのでまず手元で同様のメッセージが出る状況を再現することを実行しました。

&#8998; Code Sign error: No matching codesigning identity found: No codesigning identities (i.e. certificate and private key pairs) matching “iPhone Distribution: Ubiregi Inc. (Y7522692LT)” were found.
&#8998; CodeSign error: code signing is required for product type 'Application' in SDK 'iOS 7.1'

** BUILD FAILED **
The following build commands failed:
Check dependencies
(1 failure)
rake aborted!

Command failed with status (65): [xcodebuild -sdk "iphoneos" -workspace "/Us...]


で、手元で新しくプロジェクトを作って状況を変えながら(Provisioning Profileを無くしてみる、秘密鍵をキーチェーンから消してみる、など)コマンドラインでビルドするのを何回かして、キーチェーンに秘密鍵が存在しないときに同様のエラーになることがわかりました。


↓そうすると、下記のキーチェーンに秘密鍵をインポートしてる部分がちゃんとできていないのだろうというところまではすぐにわかったのですが、直すのはよくわからなくて、けっこう時間がかかってしまいました。
というのも手元だと成功してしまうので、ちょっと修正してPushして結果を見て、というのを繰り返す必要がありました。

def add_certificates
  sh "security create-keychain -p travis #{KEYCHAIN_NAME}"
  sh "security import ./certificates/apple.cer -k ~/Library/Keychains/#{KEYCHAIN_NAME} -T /usr/bin/codesign"
  sh "security import ./certificates/dist.cer -k ~/Library/Keychains/#{KEYCHAIN_NAME} -T /usr/bin/codesign"
  sh "security import ./certificates/dist.p12 -k ~/Library/Keychains/#{KEYCHAIN_NAME} -P #{KEY_PASSWORD} -T /usr/bin/codesign"
  sh "mkdir -p \"#{PROFILE_DIR}\""
  sh "cp \"./profiles/#{PROFILE_NAME}.mobileprovision\" \"#{PROFILE_DIR}\""
end


↓結局、下記のように`security list-keychains -s #{KEYCHAIN_NAME}`の1行を追加すると成功するようになりました。

def add_certificates
  sh "security create-keychain -p travis #{KEYCHAIN_NAME}"
  sh "security import ./certificates/apple.cer -k #{KEYCHAIN_NAME} -T /usr/bin/codesign"
  sh "security import ./certificates/dist.cer -k #{KEYCHAIN_NAME} -T /usr/bin/codesign"
  sh "security import ./certificates/dist.p12 -k #{KEYCHAIN_NAME} -P #{KEY_PASSWORD} -T /usr/bin/codesign"
  sh "security list-keychains -s #{KEYCHAIN_NAME}"
end


`list-keychains`はキーチェーンのサーチリストを表示するコマンドですが、`-s`オプションでサーチリストに指定のキーチェーンを追加することができるので、それが有効なのかなと思いました。
これでしばらく運用していましたが、前の記事のコメントid:gin0606さんに以下の情報を教えていただきました。


XCode code signing issue with OS X Mavericks images


↑ のあさりさんの情報によると次の2行を追加して、作成したキーチェーンをデフォルトに指定することと、アンロックすれば直るとのことでした。

security default-keychain -s ios-build.keychain
security unlock-keychain -p travis ios-build.keychain 


アンロックは試してたのですが、デフォルトにするのは試してなかったのでやってみました。
結果は、デフォルトにするのは効果があり、アンロックは特に必要ありませんでした(あってもなくてもよい)。


どちらでも良さそうですが、デフォルトに指定するほうがなんとなく気に入ったので、それを採用しました。
↓ ということで、今は下のようなコードで正常に動いています。

def add_certificates
  sh "security create-keychain -p travis #{KEYCHAIN_NAME}"
  sh "security import ./certificates/apple.cer -k #{KEYCHAIN_NAME} -T /usr/bin/codesign"
  sh "security import ./certificates/dist.cer -k #{KEYCHAIN_NAME} -T /usr/bin/codesign"
  sh "security import ./certificates/dist.p12 -k #{KEYCHAIN_NAME} -P #{KEY_PASSWORD} -T /usr/bin/codesign"
  sh "security default-keychain -s #{KEYCHAIN_NAME}"
end


↓ これらの変更を加えたRakefileの全体はこちら
Rakefile for testing, building and uploading to Testflight/Crittercism


↓ .travis.ymlの全体はこちら
.travis.yml for Ubiregi iPad App

NSArrayやNSDictionaryからNSNullを効率よく取り除く

iOSアプリケーションでWeb APIから返ってきたJSONを処理するのにNSNullの扱いに困っていて、事前にNSNullを取り除いてしまうのが事故を防ぐための確実な方法なのですが、再帰的にすべての要素を検査する以外になにかいい方法がないかと思って考えていたらちょっとおもしろい方法を思いついたので書いてみました。


kishikawakatsumi/CollectionUtils · GitHub

↑ に含まれるCUCompactArrayとCUCompactDictionaryです。


NSArrayとNSDictionaryのサブクラスとして実装されていて、次のようにして生成します。
(普通にalloc/initを使って生成することも可能です)

NSArray *array = @[@"0", @"1", [NSNull null], @"2", [NSNull null], @"3"];
NSArray *compactArray = [array cu_compactArray];
//=> ["0", "1", "2", "3"]
NSDictionary *dictionary = @{@"one": @"1",
                             @"null": [NSNull null],
                             @"two": @"2",
                             @"three": @"3"};
NSDictionary *compactDictionary = [dictionary cu_compactDictionary];
 //=> {"one": "1", "two": "2", "three": "3"}


JSONオブジェクトに対して使われることを想定しているので、ネストしたコレクションに対しても有効です。

NSArray *array = @[@"0",
                   @"1",
                   [NSNull null],
                   @"2",
                   @{@"one": @"1",
                     @"null": [NSNull null],
                     @"two": @"2",
                     @"three": @"3"},
                   @"4"];
NSMutableArray *compactArray = [array cu_compactArray];
//=> ["0", "1", "2", {"one": "1", "two": "2", "three": "3"}, "4"]


動作の仕組みは、まず与えられたNSArrayかNSDictionaryの最初の階層についてだけ、NSNullをチェックして取り除きます。
ここで、チェックされるのはあくまでもネストした最初の階層についてだけです。

- (instancetype)initWithObjects:(const id [])objects count:(NSUInteger)cnt
{
    self = [super init];
    if (self) {
        NSNull *nul = [NSNull null];
        NSMutableArray *mutableArray = [[NSMutableArray alloc] initWithCapacity:cnt];
        for (NSUInteger i = 0; i < cnt; i++) {
            id object = objects[i];
            if (object && object != nul) {
                [mutableArray addObject:object];
            }
        }
        _original = mutableArray;
        
    }
    return self;
}


そして、元のNSArrayかNSDictionaryを保持して同様の振る舞いをしつつ、実際にネストしたNSArray/NSDictionaryにアクセスがあったときに、適宜同じようにNSNullを削除したラッパーを返すことで、最終的にすべてのネストされた要素からNSNullが取り除かれるという結果になります。

#pragma mark - primitive instance methods

- (NSUInteger)count
{
    return self.original.count;
}
- (id)objectAtIndex:(NSUInteger)index
{
    id object = self.original[index];
    if ([object isKindOfClass:[CUCompactArray class]] || [object isKindOfClass:[CUCompactDictionary class]]) {
        return object;
    }
    if ([object isKindOfClass:[NSArray class]]) {
        return [CUCompactArray arrayWithArray:object];
    }
    if ([object isKindOfClass:[NSDictionary class]]) {
        return [CUCompactDictionary dictionaryWithDictionary:object];
    }
    return object;
}


このようにすることで、最初にすべての要素をチェックする方法だとNSNullがたとえ1つも含まれていなかったり、その値にアクセスすることがなくても、同じだけ処理時間がかかっていたのですが、実際にアクセスのあったタイミングで必要なところだけ処理することで、パフォーマンスに対するペナルティは非常に小さくなります。

けっこうおもしろいアイデアかなと思っているのですがいかがでしょうか?

NSDateFormatterのパフォーマンスの話 #potatotips

クックパッド主催の第4回potatotipsでiOSのtipsとして日付のフォーマットをするときのパフォーマンスの話をしました。

きっかけ

きっかけは何気なくgistを眺めていたときに見つけたこれです。 ↓
Compare the date parsing performance of NSDateFormatter against SQLite's date parser for parsing an iOS 8601 date.

NSDateFormatter took  108.163 seconds
strptime_l took        21.656 seconds
sqlite3 took            7.096 seconds


そうそう、NSDateFomatterはけっこう遅いからフォーマットが固定だったりロケール関係ないときはstrptime_l使うよね、アップルのドキュメントにも載ってるし……

For date and times in a fixed, unlocalized format, that are always guaranteed to use the same calendar, it may sometimes be easier and more efficient to use the standard C library functions strptime_l and strftime_l.

Consider Unix Functions for Fixed-Format, Unlocalized Dates


と軽くスルーするところだったんですが、ちょっと気になる結果がありました。

sqlite3 took 7.096 seconds

sqlite3ってなんや!? しかも超速い!?

計測結果

ホンマかいな、ということで気になったので実際に試してみました。
試したコードは記事の最後に載せています。
先のgistに載っている結果は、100万件の日付の変換で(おそらく)シミュレータで実行した結果だと思いますが、私は実際のデバイスでやってみました。
使用したデバイスは、iPhone 4です。なぜiPhone 4で試したかというと、iOS 7の動くもっとも遅いデバイスで、たいていの現場でベンチマークとして(iPhone 4でそこそこ動けばOKみたいな)使われてるであろうという理由です。
件数は10,000件です。(100万件だとすごい時間かかるので。)

あと、CFDateFormatterRefもついでに測りました。

String => Date
(1回目)
NSDateFormatter     3.61784  seconds
CFDateFormatterRef  3.30721  seconds
strptime_l          0.655574 seconds
sqlite3             0.385894 seconds

(2回目)
NSDateFormatter     3.45504  seconds
CFDateFormatterRef  3.13984  seconds
strptime_l          0.654872 seconds
sqlite3             0.396366 seconds

(3回目)
NSDateFormatter     3.90896  seconds
CFDateFormatterRef  3.29913  seconds
strptime_l          0.67286  seconds
sqlite3             0.402761 seconds


同様の結果になりました。
NSDateFormatterとCFDateFormatterRefが一番遅く、strptime_lがそれより5〜6倍くらい速くて、sqlite3はさらに速いです。


NSDateFormatterは他の2つより複雑なことができるとはいえ、ちょっと遅すぎるような気がします。
今回測ったAPIはほぼすべてコードが公開されている(NSDateFormatterは無いけどCFDateFormatterRefは公開されている)ので時間があれば読んでみようかなと思います。

CFDateFormatter.c
strptime.c
SQLite Download Page

実際の使い分けについて

さて、それでは実際のケースでは常にNSDateFormatterを避けるべきかというと、それほど神経質になる必要はないと思います。
よくある例としてUITableViewCellに日付を表示する例を考えます。


↑ このような構成の画面はよくあると思いますが、1つのセルに日付は1つか2つの場合がほとんど(作成日、更新日)で、内部的に別の日付を使っている(並び替えなど)などがあったとしても多くて3つか4つでしょう。
その場合、NSDateFormatterを使ったとしても、1つの日付にかかる時間はiPhone 4で0.0004秒程度なので、60fpsの1フレームの時間が0.017秒くらいと考えると、日付処理だけが原因でスクロールがカクカクしたりすることは無いでしょう。


それよりは、日付表示などは国や地域によっても異なりますし、最近は相対表示がよく使われたりするなど、変わりやすいところなので、変更しやすかったり、他の人が読みやすいという点を優先したほうがいいと思います。

NSDateFormatterを避けたほうが良い場合

では積極的にNSDateFormatterを避けたほうが良いのはどのような場合かというと、ネットワーク系や特定のAPIを処理するライブラリだと思います。

例えば、下記のTwitter APIを使用して取得した200件JSONだと、私の場合512個の日付が含まれていました。

GET https://api.twitter.com/1.1/statuses/user_timeline.json?count=200&include_entities=true

Twitter APIの日付は"Thu Feb 13 04:00:25 +0000 2014"という形式なので、先ほど計測したものとは形が違いますが、
NSDateFormatterで512件の日付を処理するのに0.36秒ほどかかりました。(ちなみに先ほどの計測で使用した形式の日付なら512件で約0.2秒)
strptime_lで0.06秒です。

普通は通信とともにサブスレッドに処理を逃がすので、UIが固まることはないですが、初期表示がだいぶ遅くなってしまうことになるので、こういった場合はstrptime_lなどを使っていったほうが良さそうです。
特にAPIから返ってくる日付フォーマットがコロコロ変わることは普通なく、タイムゾーンはたいていはUTC固定なので、フォーマットを決め打ちできるので適用しやすいです。


参考事例としてもう一つ、MKNetworkKitに来たPull Requestを紹介します。
Replaced `NSDateFormatter` with faster `strptime_l` and `strftime_l` and fixed OS X compile errors. by Bo98 · Pull Request #230 · MugunthKumar/MKNetworkKit · GitHub

NSDateFormatterの処理は遅いのでstrptime_lと、strftime_lを使うように変更する、という内容です。


このような日付の処理が定型的でかつほぼ毎回つかわれるようなライブラリを作る場合は、効果が大きいので検討する価値は大いにあります。

おまけ: iPhone 5sで実行した結果 (10,000件)

参考までに、iPhone 5sで実行したときの結果も載せておきます。
iPhone 4で3秒以上かかっている処理が1秒かからずに終わっています。
次元の違う速さですね。

String => Date
(1回目)
NSDateFormatter     0.588108  seconds
CFDateFormatterRef  0.525327  seconds
strptime_l          0.128674  seconds
sqlite3             0.0242668 seconds

(2回目)
NSDateFormatter     0.57438   seconds
CFDateFormatterRef  0.523905  seconds
strptime_l          0.124121  seconds
sqlite3             0.0244989 seconds

(3回目)
NSDateFormatter     0.572262  seconds
CFDateFormatterRef  0.518393  seconds
strptime_l          0.119203  seconds
sqlite3             0.0247271 seconds

計測に使用したコード

#import "ViewController.h"

#import <mach/mach_time.h>
#import <time.h>
#import <xlocale.h>
#import "sqlite3.h"

typedef int64_t timestamp;

NSUInteger randomNumberInRange(NSUInteger start, NSUInteger end);

// Create a sample date using the ISO-8601 format.
// 2013-04-23T16:29:05Z
NSString *generateSampleDateString();
NSDate *generateSampleDate();

// Create an array of <count> dates in the ISO-8601 format.
NSArray *generateSampleDateStrings(NSUInteger count);

// Parse all given dates using NSDateFormatter
void parseDatesUsingNSDateFormatter(NSArray *dates);
void formatDatesUsingNSDateFormatter(NSArray *dates);

// Parse all given dates using strptime_l
void parseDatesUsingStrptime(NSArray *dates);
void formatDatesUsingStrptime(NSArray *dates);

// Parse all given dates using SQLite's strftime function
void parseStringToDateUsingSQLite(NSArray *dates);
void formatStringToDateUsingSQLite(NSArray *dates);

NSDate *parseDate(NSString *str);

static NSDateFormatter *dateFormatter = nil;
static CFDateFormatterRef dateFormatterRef = NULL;

NSArray *generateSampleDates(NSUInteger count)
{
    NSMutableArray *dates = [NSMutableArray array];
    
    for (int i = 0; i < count; i++) {
        [dates addObject:generateSampleDate()];
    }
    
    return dates;
}

NSArray *generateSampleDateStrings(NSUInteger count)
{
    NSMutableArray *dates = [NSMutableArray array];
    
    for (int i = 0; i < count; i++) {
        [dates addObject:generateSampleDateString()];
    }
    
    return dates;
}

NSString *generateSampleDateString()
{
    NSUInteger year = randomNumberInRange(1980, 2013);
    NSUInteger month = randomNumberInRange(1, 12);
    NSUInteger date = randomNumberInRange(1, 28);
    NSUInteger hour = randomNumberInRange(0, 23);
    NSUInteger minute = randomNumberInRange(0, 59);
    NSUInteger second = randomNumberInRange(0, 59);
    
    NSString *dateString = [NSString stringWithFormat:@"%lu-%02lu-%02luT%02lu:%02lu:%02luZ",
                            (unsigned long)year,
                            (unsigned long)month,
                            (unsigned long)date,
                            (unsigned long)hour,
                            (unsigned long)minute,
                            (unsigned long)second
                            ];
    
    return dateString;
}

NSDate *generateSampleDate()
{
    return parseDate(generateSampleDateString());
}

#pragma mark -

void parseDatesUsingNSDateFormatter(NSArray *dates)
{
    if (dateFormatter == nil) {
        dateFormatter = [[NSDateFormatter alloc] init];
        [dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ss'Z'"];
        [dateFormatter setTimeZone:[NSTimeZone timeZoneForSecondsFromGMT:0]];
    }
    
    for (NSString *dateString in dates) {
        NSDate *date = [dateFormatter dateFromString:dateString];
    }
}

#pragma mark -

void parseDatesUsingCFDateFormatter(NSArray *dates)
{
    if (dateFormatterRef == NULL) {
        dateFormatterRef = CFDateFormatterCreate(NULL, NULL, kCFDateFormatterNoStyle, kCFDateFormatterNoStyle);
        CFDateFormatterSetFormat(dateFormatterRef, CFSTR("yyyy-MM-dd'T'HH:mm:ss'Z'"));
        CFTimeZoneRef timeZone = CFTimeZoneCreateWithTimeIntervalFromGMT(NULL, 0);
        CFDateFormatterSetProperty(dateFormatterRef, kCFDateFormatterTimeZone, timeZone);
    }
    
    for (NSString *dateString in dates) {
        CFDateRef date = CFDateFormatterCreateDateFromString(NULL, dateFormatterRef, (__bridge CFStringRef)dateString, NULL);
    }
}

#pragma mark -

void parseDatesUsingStrptime(NSArray *dates)
{
    struct tm  sometime;
    const char *formatString = "%Y-%m-%dT%H:%M:%SZ";
    for (NSString *dateString in dates) {
        strptime_l(dateString.UTF8String, formatString, &sometime, NULL);
        NSDate *date = [NSDate dateWithTimeIntervalSince1970: timegm(&sometime)];
    }
}

#pragma mark -

void parseDatesUsingSQLite(NSArray *dates)
{
    sqlite3 *db = NULL;
    sqlite3_open(":memory:", &db);
    
    sqlite3_stmt *statement = NULL;
    sqlite3_prepare_v2(db, "SELECT strftime('%s', ?);", -1, &statement, NULL);
    
    for (NSString *dateString in dates) {
        sqlite3_bind_text(statement, 1, dateString.UTF8String, -1, SQLITE_STATIC);
        sqlite3_step(statement);
        timestamp value = sqlite3_column_int64(statement, 0);
        NSDate *date = [NSDate dateWithTimeIntervalSince1970:value];
        
        sqlite3_clear_bindings(statement);
        sqlite3_reset(statement);
    }
}

#pragma mark -

NSDate *parseDate(NSString *str)
{
    struct tm  sometime;
    const char *formatString = "%Y-%m-%dT%H:%M:%SZ";
    
    strptime_l(str.UTF8String, formatString, &sometime, NULL);
    NSDate *date = [NSDate dateWithTimeIntervalSince1970: timegm(&sometime)];
    return date;
}

NSUInteger randomNumberInRange(NSUInteger start, NSUInteger end)
{
    NSUInteger span = end - start;
    return start + arc4random_uniform(span);
}

double MachTimeToSecs(uint64_t time)
{
    mach_timebase_info_data_t timebase;
    mach_timebase_info(&timebase);
    return (double)time * (double)timebase.numer / (double)timebase.denom / 1e9;
}

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    printf("%s\n", [[UIDevice currentDevice] name].UTF8String);
    
    const NSUInteger count = 10000;
    
    printf("%lu count\n", (unsigned long)count);
    
    NSArray *dates;
    
    uint64_t begin;
    uint64_t end;
    
    printf("String => Date\n");
    dates = generateSampleDateStrings(count);
    
    begin = mach_absolute_time();
    parseDatesUsingNSDateFormatter(dates);
    end = mach_absolute_time();
    
    printf("NSDateFormatter     %g seconds\n", MachTimeToSecs(end - begin));
    
    begin = mach_absolute_time();
    parseDatesUsingCFDateFormatter(dates);
    end = mach_absolute_time();
    
    printf("CFDateFormatterRef  %g seconds\n", MachTimeToSecs(end - begin));
    
    begin = mach_absolute_time();
    parseDatesUsingStrptime(dates);
    end = mach_absolute_time();
    
    printf("strptime_l          %g seconds\n", MachTimeToSecs(end - begin));
    
    begin = mach_absolute_time();
    parseDatesUsingSQLite(dates);
    end = mach_absolute_time();
    
    printf("sqlite3             %g seconds\n", MachTimeToSecs(end - begin));
    
}

@end

CocoaPodsで導入しているライブラリのライセンス表記を自動的に作成する

CocoaPodsを利用している場合は、PodsディレクトリにPods/Pods-acknowledgements.plistまたはPods/Pods-acknowledgements.markdownが自動的に作成されていますので、それを利用して使用しているライブラリのライセンス表記を自動化できます。


一番簡単なのは設定アプリに項目を設けて表示することです。アプリケーションに画面を追加するわけではないのでお手軽です。


CocoaPodsのWikiにも同様の方法が載っています。


ライセンス表記は使用しているライブラリの構成が変わったときにだけ更新されればいいので、PodFileのpost_installフックで作成されるようにします。
これで`pod install`あるいは`pod update`したタイミングで自動的に更新されます。

post_install do | installer |
  require 'fileutils'
  FileUtils.cp_r('Pods/Pods-acknowledgements.plist', 'Ubiregi2/Settings.bundle/Acknowledgements.plist', :remove_destination => true)
end


アプリケーションのほうにはあらかじめ、入れ物としてのSettings.bundleをプロジェクトに用意しておきます。
最低限、下記のような構成になっていればいいです。


Acknowledgements.plistはそのまま設定アプリで表示できるように作られているので、Child PaneでAcknowledgements.plistを呼び出す設定にしておくだけです。


iPadの設定アプリで実際に表示したものが下記になります。