24/7 twenty-four seven

iOS/OS X application programing topics.

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の設定アプリで実際に表示したものが下記になります。

ユビレジのiPadアプリのCI環境をJenkinsからTravis CIに移行したときのまとめ


こちらの記事について、最新のTravis CIの環境(2014/4/15)ではコード署名に失敗する問題があります。
その問題の修正については下記の記事にまとめました。
Travis CIでipaを作るときのCode Signが失敗するのを修正したメモ - 24/7 twenty-four seven


実際は完全に移行したわけではなくて、Travis CIの有料プラン(プライベートリポジトリが使える)のフリートライアルを試しているところなのですが、しばらくはTravis CIでCIを動かすことにしたので、そのときの設定などをまとめます。


もともとは社内のサーバでJenkinsをホストしていて、それがダメということは全然ないのですが、社内でサーバをメンテナンスするのも面倒だし、ビルドスクリプトとかをポータブルな状態にしておくのは手元でサクッと実行できたりいろいろ都合が良さそうだと思い、試しにやってみることにしました。


Travis CIを選んだのは個人のOSSで使っていて慣れてるからと、Xcodeのビルド環境をホストしてくれるCIサービスの選択肢はそれほどないからです。
あと昨日知ったのですがCloudBeesiOS/MacのCIをサポートしてるということなので、こちらも試してみようかなと考えています。


やりたいことは

  • Githubにプッシュしたらそのタイミングでビルド&テストを実行
  • テストが成功したらTestFlightとCrittercismにアップロード
  • Pull Requestに対してもテストをしたい

の3点です。


副産物としてApple Developer Centerから最新のProvisioning Profileを自動的にダウンロードして使用する仕組みを作ったので、デバイスを追加したときに合わせてCI環境のProvisioning Profileをメンテナンスしなくてよくなりました。
ただ、この問題はエンタープライズのDeveloper Program(¥24,800/年)を購入するほうがスマートに解決できます(iOS Developer Enterprise ProgramのProvisioning Profileはデバイスの制限がなくなるので)。

ビルドスクリプトなど

ビルドスクリプトはrakeで書きました。
↓ Rakefileの全体はこちら

↓ .travis.ymlはこちら


Rakefileの設計は下記を参考にしました。


TestFlightへのアップロードは下記を参考にしました。


Crittercismへのアップロードは下記を参考にしました。


Apple Developer CenterからProvisioning Profileをダウンロードするスクリプトは下記を利用しました。


長いので要点だけ解説します。

ユニットテストの実行

テストの実行は54行目からの下記の部分です。

task :test do |t|
  DESTINATIONS.each do |destination|
    options = {
      sdk: 'iphonesimulator',
      workspace: WORKSPACE_DIR,
      scheme: SCHEME,
      configuration: 'Debug',
      destination: destination
    }
    options = join_option(options: options, prefix: "-", seperator: " ")
    sh "xcodebuild #{options} test | xcpretty -tc" do |ok, res|
      fail unless ok
    end
  end
end


コマンドラインで

$ rake test

とすると、下記のようなコマンドが実行されます。
DESTINATIONSにはとりあえずすべてのOSバージョンが書いてあるので、6.0, 6.1, 7.0, 7.0 (64 bit)のそれぞれのiPadシミュレータでテストが実行されます。

xcodebuild -sdk "iphonesimulator" -workspace "/Users/travis/build/ubiregiinc/ubiregi-client/Ubiregi2.xcworkspace" -scheme "Ubiregi2-Release" -configuration "Debug" -destination "name=iPad,OS=6.0" test | xcpretty -tc

アプリケーションのビルド

テストが成功したらTestFlightにAdHoc版としてアップロードするので、アプリケーションをビルドしてipaファイルを作ります。
ビルドは70行目〜、ipaファイルを作る部分にあたるのは147行目からです。

テストの場合とほぼ同じですが、後の工程でビルドされたappパッケージからipaファイルを作ったり、dSYMファイルをzipにしたりするので、OBJROOTとSYMROOTを指定してビルド先のディレクトリを扱いやすくしています。

また、ここでコード署名をしておかないと、ipaを作るときにAdHoc用のProvisioning Profileを埋め込むことができなかったので、署名だけしています。
Provisioning Profileまで指定すると今度はTestFlightにアップロードするときに失敗したのでProvisioning Profileの指定は空にしています。このへんは何度か設定を変えながらやってみたらできたという感じで、よくわかりません。

desc "Build application"
task :build do |t|
  options = {
    sdk: SDK,
    workspace: WORKSPACE_DIR,
    scheme: SCHEME,
    configuration: CONFIGURATION
  }
  options = join_option(options: options, prefix: "-", seperator: " ")
  settings = {
    OBJROOT: BUILD_DIR,
    SYMROOT: BUILD_DIR,
    CODE_SIGN_IDENTITY: DEVELOPER_NAME,
    PROVISIONING_PROFILE: ""
  }
  settings = join_option(options: settings, prefix: "", seperator: "=")
  sh "xcodebuild #{options} #{settings} clean build | xcpretty -c"
end

desc "Create .ipa file"
task :archive do |t|
  archive
end


トライ&エラーの内容を整理すると、

  • CODE_SIGN_IDENTITYを指定してPROVISIONING_PROFILEを空にすると下記のエラーでビルドが失敗しました。
xcodebuild -sdk "iphoneos" -workspace "Ubiregi2.xcworkspace" -scheme "Ubiregi2-Release" -configuration "Release" CODE_SIGN_IDENTITY="iPhone Distribution: Ubiregi Inc. (Y7522692LT)" PROVISIONING_PROFILE="" clean build
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.

CodeSign error: code signing is required for product type 'Application' in SDK 'iOS 7.0'
  • CODE_SIGN_IDENTITYとPROVISIONING_PROFILEを両方とも空にすると下記のエラーでビルドが失敗しました。
xcodebuild -sdk "iphoneos" -workspace "Ubiregi2.xcworkspace" -scheme "Ubiregi2-Release" -configuration "Release" CODE_SIGN_IDENTITY="" PROVISIONING_PROFILE="" clean build
CodeSign error: code signing is required for product type 'Application' in SDK 'iOS 7.0'
  • `CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO`と指定して(ビルド時にコード署名しない)、PackageApplicationコマンドにsignオプションとembedオプションを指定すると、ビルドは成功するが下記のエラーでipaファイルの作成が失敗しました。
xcodebuild -sdk "iphoneos" -workspace "Ubiregi2.xcworkspace" -scheme "Ubiregi2-Release" -configuration "Release" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO clean build  
xcrun -sdk iphoneos PackageApplication -v "./build/Release-iphoneos/Ubiregi2.app" -o "./build/ubiregiinc/ubiregi-client/build/Ubiregi2.ipa" -sign "iPhone Distribution: Ubiregi Inc. (Y7522692LT)" -embed "$HOME/Library/MobileDevice/Provisioning Profiles/Ubiregi AdHoc.mobileprovision"
error: Failed to read entitlements from '/var/folders/9x/jfhnrxj531v5427xj48tsdd00000gn/T/S6ueKkDErz/Payload/Ubiregi2.app'
  • `CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO`と指定して(ビルド時にコード署名しない)、PackageApplicationコマンドにembedオプションだけを指定すると、ipaファイルの作成は成功するが下記のエラーでTestFlightへのアップロードが失敗しました。
xcodebuild -sdk "iphoneos" -workspace "Ubiregi2.xcworkspace" -scheme "Ubiregi2-Release" -configuration "Release" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO clean build  
xcrun -sdk iphoneos PackageApplication -v "./build/Release-iphoneos/Ubiregi2.app" -o "./build/ubiregiinc/ubiregi-client/build/Ubiregi2.ipa" -embed "$HOME/Library/MobileDevice/Provisioning Profiles/Ubiregi AdHoc.mobileprovision"
'Invalid IPA: missing embedded provisioning profile. Are you sure this is an ad hoc?'


上記の結果から考えて、ビルド時にコード署名をして、アーカイブ時にProvisioning Profileを埋め込むという手順でうまくいきました。

ipaファイルの作成

ビルドされたappパッケージからipaファイルを作ります。
「アプリケーションのビルド」のところで述べたように、ここではTestFlightにアップロードするためのAdHoc用のProvisioning Profileを指定します。

def archive
  options = {
    o: IPA_FILE,
    embed: PROVISIONING_PROFILE
  }
  options = join_option(options: options, prefix: "-", seperator: " ")
  sh "xcrun -sdk #{SDK} PackageApplication #{APP_FILE} #{options}"
end


Provisioning Profileを埋め込むためには証明書と秘密鍵(cerファイルやp12ファイル)が必要なので、それらはキーチェーンからエクスポートして、あらかじめリポジトリに含めておきます。

apple.cerはキーチェーンに登録されている「Apple Worldwide Developer Relations Certification Authority」、dist.cerとdist.p12は今回はUbiregi Inc.の「iPhone Distribution」の証明書と秘密鍵です。


そして、上記のコマンドの前に、一時的なキーチェーンを作成してこれらの各種証明書を追加します。
また、Travis CIで実行する分には後始末は必要ないのですが、手元で実行するときには同じ名前のキーチェーンを作ろうとすると失敗するので、コマンドの実行後には先ほど作ったキーチェーンを削除します。

↓ こちらを参考にしました。
Deploy an iOS app to testflight using Travis CI

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

def remove_certificates
  sh "security delete-keychain #{KEYCHAIN_NAME}"
end

TestFlightとCrittercismにアップロード

ipaファイルをTestFlightに、dSYMファイルをCrittercismにアップロードします。
112行目からのタスクです。

task :crittercism => [DSYM_ZIP_FILE] do |t|
  fields = {
    dsym: "@#{DSYM_ZIP_FILE}",
    key: API_KEY,
  }
  fields = join_option(options: fields, prefix: "-F ", seperator: "=")
  sh "curl -sL -w \"%{http_code} %{url_effective}\\n\" https://api.crittercism.com/api_beta/dsym/#{APP_ID} #{fields} -o /dev/null"
end

desc "Upload IPA file and dSYM file to TestFlight and notify testers"
task :testflight => ["version:set_build_version", IPA_FILE, :crittercism] do |t|
  if 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

  release_date = DateTime.now.strftime("%Y/%m/%d %H:%M:%S")
  release_notes = "Build: #{InfoPlist.marketing_and_build_version}\nUploaded: #{release_date}"

  fields = {
    file: "@#{IPA_FILE}",
    api_token: API_TOKEN,
    team_token: TEAM_TOKEN,
    notes: release_notes,
    notify: true,
    distribution_lists: DISTRIBUTION_LISTS
  }
  fields = join_option(options: fields, prefix: "-F ", seperator: "=")
  sh "curl -sL -w \"%{http_code} %{url_effective}\\n\" http://testflightapp.com/api/builds.json #{fields} -o /dev/null"
end


と、こんな感じでGithubにプッシュすると自動的にテストが実行されて、(masterブランチへのPushなら)TestFlightやCrittercismへのアップロードもやってくれるようになりました。

ビルドスクリプトはそれぞれrakeのタスクとして作ってあるので、手元でも簡単に

rake test

とテストを実行したり、

rake testflight

と簡単にTestFlightにアップロードすることができるようになりました。

注意点

Travis CIのデフォルトのOS Xの環境ではなぜか`pod install`がSegmentation faultで失敗しました。

$ pod install

Analyzing dependencies

/Users/travis/.rvm/gems/ruby-2.0.0-p247/gems/xcodeproj-0.14.1/ext/xcodeproj/xcodeproj_ext.bundle: [BUG] Segmentation fault

ruby 1.8.7 (2012-02-08 patchlevel 358) [universal-darwin12.0]


同様のエラー報告がIssueに登録されていて対処法はとりあえず`rvm: 1.9.3`としてrubyのバージョンを1.9.3に固定するとのことでした。
iOS - Pod Install Erroring Out · Issue #1657 · travis-ci/travis-ci · GitHub


1.9.3だとキーワード引数が使えないとかrakeを書くときにちょっと制限ができちゃいますが、とりあえずはそうしました。早く修正されるといいですね。

おまけ1:Provisioning Profileの自動ダウンロード

Jenkinsを使っているときからもあった問題に、Provisioning Profileのメンテナンスがあります。
デバイスを追加があったときに、CIのマシンが参照しているProvisioning Profile(リポジトリに含めるなどする)も更新する必要があるからです。

今回はそれも何とかしたいと思って、CIの実行時に毎回最新のものをダウンロードするようにしました。
177行目からのタスクになります。

namespace :profile do
  desc "Download provisioning profiles from Apple Developer Center"
  task :download do
    ruby "./scripts/apple_dev_center.rb -C ./scripts/apple_dev_center.config -d profiles -O /dev/null"
  end
end


下記のスクリプトを実行すると、AppleのWebサイトに接続して、Provisioning Profileをダウンロードしてprofilesディレクトリに保存します。
ipaファイルを作るときにはこのProvisioning Profileが使用されます。

ruby "./scripts/apple_dev_center.rb -C ./scripts/apple_dev_center.config -d profiles -O /dev/null"


Apple Developer CenterからProvisioning Profileをダウンロードするスクリプトは下記を利用しました。
lacostej/apple-dev · GitHub


上記のスクリプトはgemの体裁は整っているのですが、RubyGemsとしては登録されておらず、READMEに載っている使い方もbinのスクリプトを直接利用するように書いてあったので、gemとしてインストールしても使い方がよくわからなかったので、スクリプトごとリポジトリに含めて利用するようにしました。

おまけ2:バージョン番号とビルド番号の設定

TestFlightにアップロードするときにどのビルドかを区別するためにgitのハッシュがビルド番号に設定されるようにします。
257行目です。

desc "Sets build version to last git commit (happens on each build)"
task :set_build_version do
  rev = `git rev-parse --short HEAD`.strip
  puts "Setting build version to: #{rev}"
  InfoPlist.build_version = rev
end

ユビレジではバージョン番号のポリシーとして、「X.XX」という形式で、マイナー番号が偶数がリリース版、奇数ならベータ版という決まりにしているので、申請したらとりあえずマイナーを1つ上げるなどの操作をよくするので、ついでに手元でバージョン番号を簡単に操作できるようにしました。

例えば現在のバージョンが2.56とすると、

rake version:bump:minor

とすると、2.57になります。

rake version:bump:release

とすると、現在のバージョンが2.56でも2.57でも2.58になります。

このとき、ビルド番号はgitのハッシュではなくて、番号っぽくコミット数になるようにしています。
アプリ内にバージョンを表示しているのでこちらのほうが見栄えがなんとなくいいかなと思ってそうしています。

下記を参考にしました。
How to Automatically Update Xcode Build Numbers from Git | Objective C#

Conference with DevelopersでJavaScriptCore.frameworkとObjective-C Runtime APIについて話しました

年に1度のiOSデベロッパーのイベント「Conference with Developers」で話をしました。
JavaScriptCore.frameworkとObjective-C のRuntime APIという非常にマニアックな内容でしたが、まあまあわかるように伝えられたかなと思います。


話の内容は主に以下の3点です。

  • JavaScriptCore.frameworkの概要と使い方
  • Objective-C Runtime APIの活用方法
  • JavaScriptBridgeの紹介


伝えきれなかったことを補足しますと、JavaScriptBridgeはフルスクラッチで最初から最後まですべてJavaScriptでiOSアプリを書く、という用途のために作られたのではありません。


例としてそういうものを示しているのは、単に例は極端なほうがわかりやすいというだけの理由です。


どちらかというと、週ごとに変わるキャンペーン用画面とか、メンテナンス時に一時的に表示される画面など、単純に変更が頻繁なところや、一時的に表示する画面だけど様々なケースが考えられる、というところに部分的に利用するのが適していると思います。


(コンパイル時ではなく)実行時に文字列を評価して実行されるという強力な特性を「気軽に」利用できるという点が大きなメリットです。


というのも、JavaScriptCore.frameworkでJSからObjective-Cを扱う場合は、JSExportというJSに公開するインターフェースをクラスごとに準備する必要があるのと、delegateメソッドやUIViewControllerのviewDidLoadなどのフレームワークから呼ばれるメソッドに応答することができないという制限があるので、通常はコストメリットが割に合わないためです。
(使うクラスすべてのJSExportを用意するのは非常に面倒、delegateその他に応答できないとUIKitをまともに利用できない)


JavaScriptBridgeにはiOS SDKに含まれるほぼすべてのクラスに対してJSExportプロトコルが準備されていて、Runtime APIで適宜JSのメソッドにディスパッチすることでdelegateメソッドへの応答を可能にしているので、このライブラリを導入するだけであとはおもむろにJSで書き始めることができるようになります。


この手軽さこそが唯一最大の利点であると考えています。


あと、懇親会で話してるときに、子ども向けのプログラミング環境をiPadで提供する、というのはとても夢があって実用的な例だと思いました。
(やろうと思えば)Twitterクライアントくらいは作れちゃうわけですし、書いたそばから実行して動かせるというのはとても楽しいし、そもそも現状iOSでiOS Appを書ける環境はほぼないので普通に便利、と実現すればとてもおもしろい試みじゃないかなと思います。

資料

ビデオ(前半)



Video streaming by Ustream

ビデオ(後半)



Video streaming by Ustream

コマンドライン引数(Launch arguments)は思ったより簡単に使える



iOS/AndroidのTips共有会potatotipsでiOSの実行時引数は思ってるより簡単ベンリに扱えるんだよって話をしました。


potatotips (iOS/Android開発Tips共有会) 第3回



↑ 起動時のオプション引数はiOSだと上図のようにXcodeのスキーマで指定します。

int main(int argc, char * argv[])

↑ そして普通のUnixプログラムと同様にmain関数のargcに引数の個数が、argvに文字列の配列で入ってきます。
でもGUIの無いコマンドラインのユーティリティプログラムならともかく、iOSアプリでargvをParseして何かするとか面倒なだけだと思っていませんか?


詳しいことはスライドに書いたので端的に言うと、ある規則にしたがうとこの引数は自動的にParseされてNSUserDefaultsに格納されます。
つまりアプリケーションのどこでも特定のキーで値を取得することができるようになります。

ある規則とは次の2つです。

  • キーとなる値は`-`(ハイフン)で始まる
  • 2語以上の値はクオートで囲む
-key1 value1 -key2 'foo bar'

↑ 例えば上のように書くと、NSUserDefaultsに"key1", "key2"にそれぞれ"value1", "foo bar"という値が入ります。


また、この挙動を利用して既存の値を一時的に上書きすることもできます。

-AppleLanguages (en)

↑ 上記のように起動時引数を指定すると一時的に英語環境で起動されるのはよく知られたデバッグのTipsですが、これはNSUserDefaultsの`AppleLanguages`というキーの値を上書きしているのでそのような挙動になります。


起動時引数の値はNSUserDefaultsに永続化されるわけではありません。あくまでも起動してから終了するまでの一時的なものです。
そのような挙動はNSUserDefaultsの値が「ドメイン」という階層を持っていて、起動時引数はNSArgumentDomainという階層に格納され、その階層が一番最初に検索されるので、同じキーがあれば最初にヒットするので結果的に上書きされたことになるという仕組みです。


そして、意外に知られていないと思われるのが、ArrayやDictionaryのデータ構造を指定できるということです。


例えばArrayは次のようになります。

-arrayArg '( "foo", "bar", "baz" )'


Dictionaryはこうです。

-dictArg '{ "foo" = "bar"; "baz" = "qux"; }'


記法でわかるとおり、プロパティリストの表現形式です。
NSUserDefaultsの永続化先はプロパティリストなので納得ですね。


ArrayとDictionaryの組み合わせで、もっと複雑なデータを渡すこともできます(だいぶややこしくなりますが)。

上記のプロパティリストのフォーマットは古い記法ですが、今どきのXML形式も使えます。

-xmlArg “<dict><key>foo</key><string>bar</string><key>baz</key><string>qux</string></dict>"


起動時のタイミングでXMLのParseがなされているとか、それは本当に必要なのかという気がしますがとにかくそういう仕組みです。


これで、なぜ下のように書くと英語環境になるのかということのトリックがわかります。

-AppleLanguages (en)

第一引数の`-AppleLanguages`はハイフンで始まっているのでNSUserDefaultsにAppleLanguagesというキーで格納されます。
第二引数の`(en)`はプロパティリストの配列です。


NSUserDefaultsにはあらかじめAppleLanguagesというキーで設定で指定した優先順位で言語の配列が格納されています。

NSLog(@"%@", [[NSUserDefaults standardUserDefaults] dictionaryRepresentation]);

=>
{
    ...

    AppleLanguages =     (
        ja,
        en,
        fr,
        ...
    );

    ...

}


その値を上書きするために第一引数はハイフンで始まる必要があり、第二引数にカッコを付けるのは、もともとのAppleLanguagesの値が配列なので配列を渡す必要があり、配列のプロパティリストでの表現形式はカッコで囲む必要があるからです。