24/7 twenty-four seven

iOS/OS X application programing topics.

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の値が配列なので配列を渡す必要があり、配列のプロパティリストでの表現形式はカッコで囲む必要があるからです。

One-line fix for UITextView on iOS 7

iOS 7のUITextViewのバグを1行で直す裏ワザ

[[NSUserDefaults standardUserDefaults] setBool:NO forKey:@"UIDisableLegacyTextView"];

解説

iOS 7にはバグだらけのUITextViewのほかに_UICompatibilityTextViewというiOS 6以前のUITextView(と思われる)クラスがあり、
メモ(Notes.app)など同様の不具合が再現しない標準アプリは_UICompatibilityTextViewが使われている。


そしておそらくこのフラグによってUITextViewをインスタンス化したときにどちらが使用されるかが変わる。


以下のようにmain.mあたりで設定すると有効になる。

#import <UIKit/UIKit.h>
 
#import "AppDelegate.h"
 
int main(int argc, char * argv[])
{
    @autoreleasepool {
        [[NSUserDefaults standardUserDefaults] setBool:NO forKey:@"UIDisableLegacyTextView"];
        
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

JavaScriptでiOSアプリが書けるライブラリJavaScriptBridgeを公開しました

kishikawakatsumi/JavaScriptBridge · GitHub


前にiOS 7から導入されたJavaScriptCore.frameworkを使ってUIKit標準のクラスを操作する話を書きました。

JavaScriptだけでiOSのUIを書いてみる - 24/7 twenty-four seven


JavaScriptCoreを使ってJavaScriptからObjective-Cのオブジェクトを操作するのは、あらかじめJSExportプロトコルで公開しておく必要があるなど、あまり実用的ではありませんでしたが、このライブラリを導入すうることで、そういった事前準備がすでに済んでいる状態で使いはじめることができます。


また、JavaScriptからObjective-Cのクラスを定義できるなどいくつかの拡張を加えてあり、UIViewControllerのサブクラスで画面を定義したり、デリゲートに応答するオブジェクトを作ったり、UIKitの作法にしたがってアプリケーションを記述することができるようになっています。


使い方を見てもらえればわかりますが、全部JavaScriptで書くことを強制されることはなく、部分的に使用することもできます。
むしろ、ピンポイントで一部の画面だけJavaScriptを使用して書きたい、という場合に力を発揮するのではないかと思います。

応用の方法としては、キャンペーンや期間限定の画面などを増減できるようにしたり、変更可能にしておくといった方法や、プロトタイピングで、再ビルドやアプリケーションを終了することなく、動的に修正を確認したりなどに向いているかと考えています。


まだバージョン0.0.1のベータ版ですが、バグ報告、プルリクエストをお待ちしています。

使い方

もっとも簡単な例は下記になります。

#import <JavaScriptBridge/JavaScriptBridge.h>
...

// Retrieve the prepared context
JSContext *context = [JSBScriptingSupport globalContext];

// Add framework support if needed.
// ('Foundation', 'UIKit', 'QuartzCore' enabled by default.)
[context addScriptingSupport:@"MapKit"];
[context addScriptingSupport:@"MessageUI"];

// Evaluate script
[context evaluateScript:
 @"var window = UIWindow.new();"
 @"window.frame = UIScreen.mainScreen().bounds;"
 @"window.backgroundColor = UIColor.whiteColor();"
 @"window.makeKeyAndVisible();"
];


JSBScriptingSupportから取得したJSContextオブジェクトは標準のUIKitやFoundationのほとんどのクラス群がすでにJSExportプロトコルに適合している状態になっています。

JSContext *context = [JSBScriptingSupport globalContext];


必要に応じてJSExportに適合させるクラスを追加します。
Frameworkごとに名前を指定することでそのFrameworkに属するクラス群がJSExportに適合されます。
例えば下記のようにMapKitを指定すると、MKMapViewなどがJavaScriptから扱えるようになります。

[context addScriptingSupport:@"MapKit"];


必要なことはこれだけです。あとはおもむろにJavaScriptでアプリケーションを書き始めることができます。

// Evaluate script
[context evaluateScript:
 @"var window = UIWindow.new();"
 @"window.frame = UIScreen.mainScreen().bounds;"
 @"window.backgroundColor = UIColor.whiteColor();"
 @"window.makeKeyAndVisible();"
];

文法など

変数宣言

型名を取り除きます。

UILabel *label;
var label;
プロパティ

プロパティはほぼObjective-Cと同様でドットを使います。
Objective-Cではメソッド呼び出しの形式でプロパティにアクセスすることができますが、メソッドによるアクセスは今のところサポートしていません。

UISlider *slider = [[UISlider alloc] initWithFrame:frame];
slider.backgroundColor = [UIColor clearColor];
slider.minimumValue = 0.0;
slider.maximumValue = 100.0;
slider.continuous = YES;
slider.value = 50.0;
var slider = UISlider.alloc().initWithFrame(frame);
slider.backgroundColor = UIColor.clearColor();
slider.minimumValue = 0.0;
slider.maximumValue = 100.0;
slider.continuous = true;
slider.value = 50.0;
メソッド呼び出し

Objective-Cのカッコを使ったメソッド呼び出しは、ドット記法に置き換えます。
パラメータを取るメソッドについては、コロンを取り除き、パラメータのラベルをキャメルケースで連結してメソッド名にします。
そのあと、順番にパラメータを並べます。

UIWindow *window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
var window = UIWindow.alloc().initWithFrame(UIScreen.mainScreen().bounds);
クラス定義

次のように、JSB.defineメソッドを使うとObjective-Cのクラスが作成されます。
第1引数でクラスの宣言、第2引数にハッシュでメソッド名と処理のfunctionオブジェクトを渡します。
これを使うことでUITableViewのデリゲートメソッドに応答したり、UIViewControllerのサブクラスとして画面を作成したりできます。

var MainViewController = JSB.define('MainViewController : UITableViewController <UITableviewDataSource, UITableviewDelegate>', // Declaration
// Instance Method Definitions
{
  viewDidLoad: function() {
    self.navigationItem.title = 'UICatalog';
  },
  tableViewNumberOfRowsInSection: function(tableView, section) {
    return self.menuList.length;
  },
  tableViewCellForRowAtIndexPath: function(tableView, indexPath) {
    var cell = UITableViewCell.alloc().initWithStyleReuseIdentifier(3, 'Cell');
    cell.accessoryType = 1;
    cell.textLabel.text = self.menuList[indexPath.row]['title'];
    cell.detailTextLabel.text = self.menuList[indexPath.row]['explanation'];

    return cell;
  },
  tableViewDidSelectRowAtIndexPath: function(tableView, indexPath) {
    var targetViewController = self.menuList[indexPath.row]['viewController'];
    self.navigationController.pushViewControllerAnimated(targetViewController, true);
  }
});
モジュール

Node.js風の簡単なモジュールシステムを提供しています。
JSB.require('/path/to/module.js') と書くと別のファイルのモジュールが利用可能になります。
モジュールとして公開するにはJSB.exportsにオブジェクトを代入します。

ファイルを分けて書けるのでちょっと大きめのアプリケーションでもJavaScriptだけでけっこう書けます。
↓ このサンプルを見るとよくわかります。
JavaScriptBridge/Examples/UICatalog/UICatalog/js at master · kishikawakatsumi/JavaScriptBridge · GitHub

var ButtonsViewController = JSB.require('buttonsViewController');
var ControlsViewController = JSB.require('controlsViewController');
var WebViewController = JSB.require('webViewController');
var MapViewController = JSB.require('mapViewController');

var MainViewController = JSB.define('MainViewController : UITableViewController', {
  viewDidLoad: function() {
    self.navigationItem.title = 'UICatalog';
    ...
});

JSB.exports = MainViewController;

JavaScriptだけでiOSのUIを書いてみる

この投稿は iOS Advent Calendar 2013 - Qiita の22日目の記事です。

iOS 7から新しく追加されたJavaScriptCore.frameworkを使ってJavaScriptだけでUIを書いてみましょう。

JavaScriptCore.frameworkの基本 (Objective-C -> JavaScript)

まずJavaScriptCore.frameworkの基本的な使い方は次のようになります。

JSContext *context = [[JSContext alloc] init];
[context evaluateScript:@"a = 10;"];

JSValue *value = context[@"a"];
NSLog(@"%d", value.toInt32); // => 10

↑ まずJavaScriptの実行環境としてJSContextのインスタンスを作成します。
contextのevaluateScript:メソッドにスクリプトを渡すと実行されます。
そこで作成したオブジェクトはcontextからキーを指定して取り出すことができます。


このようにObjective-CからJavaScriptを呼び出すのは簡単です。

JavaScriptからObjective-Cを使う

JavaScriptからObjective-Cを呼び出すのはさらに少し準備が必要です。
JavaScriptから呼び出せるメソッドをあらかじめJSExportというプロトコルにしたがって公開しておきます。


例えば、JavaScriptでUIWindowのオブジェクトを操作したいという場合は次のようなプロトコルをあらかじめ定義します。

@protocol JSUIWindow <JSExport>

@property (nonatomic) CGRect frame;
@property (nonatomic) UIColor *backgroundColor;

+ (id)new;
- (void)makeKeyAndVisible;

@end


↓ そして上記のプロトコルに適合するUIWindowのサブクラスを定義します。

@interface JSUIWindow : UIWindow <JSUIWindow>

@end

@implementation JSUIWindow

@end


先ほどのJSUIWindowクラスのクラスオブジェクトをJSContextに登録します。

JSContext *context = [[JSContext alloc] init];
context[@"JSUIWindow"] = [JSUIWindow class];


これで、JavaScriptからJSUIWindowのメソッドを呼び出すことができるようになりました。
先ほどの手順でnewメソッドをJavaScriptから使えるように公開してあるのでJavaScriptからJSUIWindowをインスタンス化できます。

JSContext *context = [[JSContext alloc] init];
context[@"JSUIWindow"] = [JSUIWindow class];
[context evaluateScript:@"var window = JSUIWindow.new();"];

JSValue *value = context[@"window"];
NSLog(@"%@", value.toObject); // => <JSUIWindow: 0x8e2ce40; baseClass = UIWindow; frame = (0 0; 0 0); hidden = YES; gestureRecognizers = <NSArray: 0x8e2d3e0>; layer = <UIWindowLayer: 0x8e2cf60>>


ここまでやってみて、少し、いやかなり面倒だと思われたのではないでしょうか。
いちいちサブクラスを定義することなく直接UIKitの標準クラスをJavaScriptから扱えるようにならないものでしょうか。

標準クラスをJSExportに適合させる

実はObjective-CのランタイムAPIを使えば実行時にクラスをプロトコルに適合させることができます。

class_addProtocol([UIWindow class], @protocol(JSUIWindow));

JSContext *context = [[JSContext alloc] init];
context[@"UIWindow"] = [UIWindow class];
[context evaluateScript:@"var window = UIWindow.new();"];

JSValue *value = context[@"window"];
NSLog(@"%@", value.toObject); // => <UIWindow: 0x8b7ea00; frame = (0 0; 0 0); hidden = YES; gestureRecognizers = <NSArray: 0x8b7efa0>; layer = <UIWindowLayer: 0x8b7eb20>>

↑ 上記のコードは実行時にランタイムAPIの`class_addProtocol`を使ってUIWindowクラスをJSUIWindowプロトコルに適合させています。
contextに登録するクラスオブジェクトもUIWindowクラスです。作成されたインスタンスもUIWindowの直接のインスタンスであることがわかります。


ここまでできると、プロトコルの宣言も無くせないだろうかと考えるのですが、結論からいうとそれは不可能でした。
ランタイムAPIを使えば実行時にプロトコルを作成することもできるのですが、そうやって作成したプロトコルをJSContextに登録しても使えないか、クラッシュしてしまいました。

ただ、本家のWebKitのほうにもこの挙動はバグではないかということで報告されているようです。
Bug 122501 – Dynamically generated JSExport protocols added to a class results in a crash


↓ いちおう試したコードを載せておきます。

Class cls = [UIWindow class];
SEL sel = @selector(new);
Method method = class_getClassMethod(cls, sel);
const char *types = method_getTypeEncoding(method);

Protocol *proto = objc_allocateProtocol("JSUIWindow");
protocol_addProtocol(proto, objc_getProtocol("JSExport"));
protocol_addMethodDescription(proto, sel, types, YES, NO);
objc_registerProtocol(proto);

class_addProtocol(cls, proto);

JSContext *context = [[JSContext alloc] init];
context[@"UIWindow"] = cls;
[context evaluateScript:@"var window = UIWindow.new();"];

JSValue *value = context[@"window"];
NSLog(@"%@", value); => undefined


というわけで、現状ではあらかじめプロトコルを定義しておき、実行時に適合させることでSDKから提供されているクラスについてもJavaScriptから扱うことができるようになります。

JavaScriptだけでUIを書いてみる

↓ そのようにして、JavaScriptだけで記述したコードで作られた画面がこちらです。

20131223022848


全体のコードは下記になります。
一番下のapplication:didFinishLaunchingWithOptions:メソッドのところを読むと、JavaScriptだけで画面が書かれてるのがわかります。

引数のあるメソッドをJavaScriptから呼び出すときはコロンを取り除いて、キャメルケースに結合した名前にします。
(`JSExportAs`というマクロを使って別名を定義することもできます。)
またCGRectなど一部の構造体はハッシュを使って`framex = 10;` や `frame = {x: 20, y: 80, width: 200, height: 80};`のように簡単に設定することができるようになっています。


この例ではJavaScriptはハードコーディングしていますが、外部ファイルから読み込むようにしてもいいでしょう。
準備が少し大変ですが、JavaScriptのコンパイルする必要がないといったスクリプト言語の特性をうまく使うと、便利な場合も多いのではないでしょうか。

#import "AppDelegate.h"

@import JavaScriptCore;
@import ObjectiveC;

@protocol JSNSObject <JSExport>

+ (id)new;

@end

@protocol JSUIView <JSExport>

@property (nonatomic) CGRect frame;
@property (nonatomic) UIColor *backgroundColor;

+ (id)new;
- (void)addSubview:(UIView *)view;

@end

@protocol JSUIWindow <JSExport>

@property (nonatomic) CGRect frame;
@property (nonatomic) UIColor *backgroundColor;
@property (nonatomic) UIViewController *rootViewController;

+ (id)new;
- (void)makeKeyAndVisible;

@end

@protocol JSUILabel <JSExport>

@property (nonatomic) CGRect frame;
@property (nonatomic) UIColor *backgroundColor;
@property (nonatomic) UIColor *textColor;
@property (nonatomic) NSString *text;

+ (id)new;
- (void)addSubview:(UIView *)view;
- (void)sizeToFit;

@end

@protocol JSUIScreen <JSExport>

@property (nonatomic, readonly) CGRect bounds;

+ (UIScreen *)mainScreen;
- (void)makeKeyAndVisible;

@end

@protocol JSUIColor <JSExport>

+ (UIColor *)whiteColor;
+ (UIColor *)redColor;
+ (UIColor *)blueColor;

@end

@protocol JSUIViewController <JSExport>

@property (nonatomic) UIView *view;
@property (nonatomic) UINavigationItem *navigationItem;
@property (nonatomic) UITabBarItem *tabBarItem;

+ (id)new;
- (UIView *)view;

@end

@protocol JSUITabBarController <JSExport>

@property(nonatomic, copy) NSArray *viewControllers;

+ (id)new;

@end

@protocol JSUINavigationController <JSExport>

@property (nonatomic) NSArray *viewControllers;

+ (id)new;

@end

@protocol JSUINavigationItem <JSExport>

@property (nonatomic) NSString *title;

@end

@protocol JSUITabBarItem <JSExport>

+ (id)alloc;
- (id)initWithTabBarSystemItem:(UITabBarSystemItem)systemItem tag:(NSInteger)tag;

@end

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    class_addProtocol([NSObject class], @protocol(JSNSObject));
    class_addProtocol([UIView class], @protocol(JSUIView));
    class_addProtocol([UILabel class], @protocol(JSUILabel));
    class_addProtocol([UIWindow class], @protocol(JSUIWindow));
    class_addProtocol([UIViewController class], @protocol(JSUIViewController));
    class_addProtocol([UINavigationController class], @protocol(JSUINavigationController));
    class_addProtocol([UINavigationItem class], @protocol(JSUINavigationItem));
    class_addProtocol([UITabBarController class], @protocol(JSUITabBarController));
    class_addProtocol([UITabBarItem class], @protocol(JSUITabBarItem));
    class_addProtocol([UIScreen class], @protocol(JSUIScreen));
    class_addProtocol([UIColor class], @protocol(JSUIColor));
    
    JSContext *context = [[JSContext alloc] init];
    context[@"NSObject"] = [NSObject class];
    context[@"UIView"] = [UIView class];
    context[@"UILabel"] = [UILabel class];
    context[@"UIWindow"] = [UIWindow class];
    context[@"UIViewController"] = [UIViewController class];
    context[@"UINavigationController"] = [UINavigationController class];
    context[@"JSUINavigationItem"] = [UINavigationItem class];
    context[@"UITabBarController"] = [UITabBarController class];
    context[@"UITabBarItem"] = [UITabBarItem class];
    context[@"UIScreen"] = [UIScreen class];
    context[@"UIColor"] = [UIColor class];
    
    [context evaluateScript:
     @"var window = UIWindow.new();"
     @"window.frame = UIScreen.mainScreen().bounds;"
     @"window.backgroundColor = UIColor.whiteColor();"
     @""
     @"var navigationController1 = UINavigationController.new();"
     @"var viewController1 = UIViewController.new();"
     @"viewController1.navigationItem.title = 'Make UI with JS';"
     @""
     @"var view = UIView.new();"
     @"view.backgroundColor = UIColor.redColor();"
     @"view.frame = {x: 20, y: 80, width: 200, height: 80};"
     @""
     @"var label = UILabel.new();"
     @"label.backgroundColor = UIColor.blueColor();"
     @"label.textColor = UIColor.whiteColor();"
     @"label.text = 'This is label.';"
     @"label.sizeToFit();"
     @""
     @"var frame = label.frame;"
     @"frame.x = 10;"
     @"frame.y = 10;"
     @"label.frame = frame;"
     @""
     @"view.addSubview(label);"
     @"viewController1.view.addSubview(view);"
     @""
     @"navigationController1.viewControllers = [viewController1];"
     @""
     @"var tabBarItem = UITabBarItem.alloc();"
     @"tabBarItem = tabBarItem.initWithTabBarSystemItemTag(1);"
     @"viewController1.tabBarItem = tabBarItem;"
     @""
     @"var tabBarController = UITabBarController.new();"
     @"tabBarController.viewControllers = [navigationController1];"
     @""
     @"window.rootViewController = tabBarController;"
     @"window.makeKeyAndVisible();"
     ];
    
    return YES;
}

@end

iOSアプリケーションでキーボードショートカットに対応する

↓ より丁寧な記事はこちらで公開しています。


iOS 7のSafariやメールでは外部キーボードを使用した際に利用できるできるショートカットが以前より充実したことが話題になりました。


iOS 7ではキーボードショートカットを実装するためのAPIが追加されているので、サードパーティのアプリケーションもキーボードショートカットに対応することができます。


特定のキーボードショートカットに応答するには下記のプロパティを実装します。

@property(nonatomic, readonly) NSArray *keyCommands


このプロパティは`UIResponder`クラスに`readonly`として宣言されているので、`UIView`のサブクラスか、`UIViewController`のサブクラスでgetterメソッドをオーバーライドするのがオーソドックスなやり方になります。
(ビューコントローラはデフォルトでFirst Responderになれないので、ビューコントローラに実装する場合は`canBecomeFirstResponder`で`YES`を返すのを忘れないようにしましょう。)


ここで返す値は`UIKeyCommand`クラスのインスタンスの配列です。
`UIKeyCommand`クラスはキーの入力の組み合わせを表すクラスでこちらもiOS 7から新たに導入されました。
`UIKeyCommand`のオブジェクトは専用のクラスメソッドを使用して作成します。

+ (UIKeyCommand *)keyCommandWithInput:(NSString *)input modifierFlags:(UIKeyModifierFlags)modifierFlags action:(SEL)action;


例えば「コマンドキー+S」というショートカットに対応するには下記のようにします。

- (NSArray *)keyCommands
{
    return @[[UIKeyCommand keyCommandWithInput:@"s"
                                 modifierFlags:UIKeyModifierCommand
                                        action:@selector(handleCommand:)]];
}


`input`パラメータはキー押した時に入力される文字を指定します。
`modifierFlags`はコマンド(⌘)キーやオプション(⌥)キー、シフトキーなどの修飾キーの組み合わせをビットマスクで指定します。
例えば「コントロールキー+オプションキー+S」というショートカットを表す`UIKeyCommand`のインスタンスは下記になります。

- (NSArray *)keyCommands
{
    return @[[UIKeyCommand keyCommandWithInput:@"s"
                                 modifierFlags:UIKeyModifierControl | UIKeyModifierAlternate
                                        action:@selector(handleCommand:)]];
}


ここで注意すべきなのは`UIKeyModifierControl | UIKeyModifierAlternate`という組み合わせで表現されるのは「コントロールキーとオプションキーを両方押す」という組み合わせであって、コントロールキーまたはオプションキーをどちらか片方だけ押した場合は別のショートカットキーと扱われる点です。


`modifierFlags`を指定しなかった場合は単独のキー入力に対応することができます。
またエスケープシーケンスを利用して`@"\r"`や`@"\t"`をキー入力として指定するとEnterキーやタブキーの入力に応答することができます。


キー入力のうち矢印キーとエスケープキーの特別なキーについては専用の定数が用意されています。

UIKIT_EXTERN NSString *const UIKeyInputUpArrow         NS_AVAILABLE_IOS(7_0);
UIKIT_EXTERN NSString *const UIKeyInputDownArrow       NS_AVAILABLE_IOS(7_0);
UIKIT_EXTERN NSString *const UIKeyInputLeftArrow       NS_AVAILABLE_IOS(7_0);
UIKIT_EXTERN NSString *const UIKeyInputRightArrow      NS_AVAILABLE_IOS(7_0);
UIKIT_EXTERN NSString *const UIKeyInputEscape          NS_AVAILABLE_IOS(7_0);


これらを組み合わせると、例えば矢印キーでテーブルビューを選択したり、エスケープキーでキャンセル、タブキーで移動など一般のビジネスアプリケーションでよくあるキー操作を実装することができます。


下記はサンプルコードとしてほぼすべてのキー入力と修飾キーの組み合わせに反応して、押されたキーの組み合わせを画面に表示するプログラムです。参考にしてください。
https://github.com/kishikawakatsumi/KeyboardShortcuts


20131117181446 20131117181446

20131117181446 20131117181446

iOS 7のエンタープライズ向け新機能 Guided Access(アクセスガイド)のカスタマイズ

20131108031636 20131108031636

アクセスガイドとは

iOS 6から導入された機能にアクセスガイドがあります。
これは一時的にホームボタンを無効にして1つのアプリケーションしか使えないようにしたり(クラッシュしても自動的にそのアプリケーションが再起動する)、タッチ操作を部分的に無効にして触ってほしくないボタンなどを使えなくするなど、いわゆるキオスクモードを実現できる機能です。

展覧会や店舗などでナビゲーション端末として一般のひとに貸し出して使ってもらう場合などに利用されます。


iOS 7では上記の機能に加え、アプリケーションごとに制限を定義できるようになりました。

これによって、これまでの機能でできる制限だと、アプリケーションの作りによってはボタンの位置などでうまく制限がかけられなかったりすることがありましたが、その問題がアプリケーションの対応によって解決されます。
特に企業向けのアプリケーションを提供しているところにとっては魅力的な改善だと思います。

iOS 7から追加されたアクセスガイドのカスタマイズ

アプリケーション側で対応するにはiOS 7から追加されたAPI`UIGuidedAccessRestrictionDelegate`を使います。
`UIGuidedAccessRestrictionDelegate`のメソッドを`UIApplicationDelegate`に実装すると必要に応じてシステムから呼び出されます。


`UIGuidedAccessRestrictionDelegate`の定義は次のようになります。

@required
- (NSArray *)guidedAccessRestrictionIdentifiers;
- (void)guidedAccessRestrictionWithIdentifier:(NSString *)restrictionIdentifier didChangeState:(UIGuidedAccessRestrictionState)newRestrictionState;
- (NSString *)textForGuidedAccessRestrictionWithIdentifier:(NSString *)restrictionIdentifier;

@optional
- (NSString *)detailTextForGuidedAccessRestrictionWithIdentifier:(NSString *)restrictionIdentifier;

@end


このうち、`detailTextForGuidedAccessRestrictionWithIdentifier:`以外のメソッドはすべて実装する必要があります。

`guidedAccessRestrictionIdentifiers`ではアプリケーションで独自に定義した制限を示す識別子を文字列の配列で返します。
それぞれが区別できればどのようなものでも構いません。

例えば、これまでの例と同様に、設定画面へのアクセス、記事のシェア、および削除を禁止したいとします。
それぞれに適当な識別子を決めて下記のようなコードになります。

- (NSArray *)guidedAccessRestrictionIdentifiers
{
    return @[@"allow-settings", @"allow-share", @"allow-delete"];
}


次にそれぞれの制限に対応する簡潔な説明を定義します。

- (NSString *)textForGuidedAccessRestrictionWithIdentifier:(NSString *)restrictionIdentifier
{
    if ([restrictionIdentifier isEqualToString:@"allow-settings"]) {
        return NSLocalizedString(@"Access Settings", nil);
    } else if ([restrictionIdentifier isEqualToString:@"allow-share"]) {
        return NSLocalizedString(@"Allow Share", nil);
    } else if ([restrictionIdentifier isEqualToString:@"allow-delete"]) {
        return NSLocalizedString(@"Allow Delete", nil);
    }
    
    return nil;
}


必要なら詳しい説明を追加します。

- (NSString *)detailTextForGuidedAccessRestrictionWithIdentifier:(NSString *)restrictionIdentifier
{
    if ([restrictionIdentifier isEqualToString:@"allow-settings"]) {
        return NSLocalizedString(@"Allow change settings.", nil);
    } else if ([restrictionIdentifier isEqualToString:@"allow-share"]) {
        return NSLocalizedString(@"Allow share link to other services.", nil);
    } else if ([restrictionIdentifier isEqualToString:@"allow-share"]) {
        return NSLocalizedString(@"Allow delete entries.", nil);
    }
    
    return nil;
}


上記で定義した制限の有効・無効、あるいはアクセスガイド自体のオン・オフが切り替わったときには下記のデリゲートメソッドが呼ばれるので、そこでボタンを消したり、UIを更新したりします。
制限が有効になったのか無効になったのかの判断は`newRestrictionState`の値を使います。
アクセスガイド自体がオフになったときは制限を識別子についてこのメソッドがそれぞれ呼ばれて、`newRestrictionState`の値は`UIGuidedAccessRestrictionStateAllow`になっています。

- (void)guidedAccessRestrictionWithIdentifier:(NSString *)restrictionIdentifier didChangeState:(UIGuidedAccessRestrictionState)newRestrictionState
{
    if ([restrictionIdentifier isEqualToString:@"allow-settings"]) {
        if (newRestrictionState == UIGuidedAccessRestrictionStateAllow) {
            // Settingsボタンを表示する
        } else if (newRestrictionState == UIGuidedAccessRestrictionStateDeny) {
            // Settingsボタンを消す
        }
    } else if ([restrictionIdentifier isEqualToString:@"allow-share"]) {
        if (newRestrictionState == UIGuidedAccessRestrictionStateAllow) {
            // シェアボタンを表示する
        } else if (newRestrictionState == UIGuidedAccessRestrictionStateDeny) {
            // シェアボタンを消す
        }
    } else if ([restrictionIdentifier isEqualToString:@"allow-delete"]) {
        if (newRestrictionState == UIGuidedAccessRestrictionStateAllow) {
            // 削除機能を有効にする
        } else if (newRestrictionState == UIGuidedAccessRestrictionStateDeny) {
            // 削除機能を無効にする
        }
    }
}


また、いつでも`UIGuidedAccessRestrictionStateForIdentifier()`という関数(iOS 7から)を使って任意の制限が有効かどうかを調べることができます。

if (UIGuidedAccessRestrictionStateForIdentifier(@"allow-settings") == UIGuidedAccessRestrictionStateDeny) {
    // 設定画面へのアクセスが無効になっている場合の処理
}


アクセスガイド自体が有効か無効かは`UIAccessibilityIsGuidedAccessEnabled()`という関数でわかりますし、アクセスガイドの状態が変わったときに`UIAccessibilityGuidedAccessStatusDidChangeNotification`の通知を受けることもできます。(どちらもiOS 6から)。


ここまで記述した状態でアクセスガイドの画面を表示して「オプション」で表示される項目が次のようになります。

20131108031636


赤で示した部分が先ほど追加したアプリケーション独自の制限になります。
スイッチがオンの場合は許可、オフの場合は制限をかけるという意味になります。

上記の状態では記事の共有と削除を禁止し、設定画面へのアクセスは許可するということになります。
この状態でアクセスガイドを再開した場合、状態変化のデリゲートメソッドが呼ばれるので制限の変更に応じて適宜UIの更新や対応する処理をします。


以上です。いかがでしょうか。
特にビジネス用途で使われることを想定したアプリケーションでは強力に機能するのではないでしょうか。

親指シフトキーボードが使えるノートアプリ「N+Note」をリリースしました。


入力に親指シフト(NICOLA)配列のキーボードが使えるエディタアプリです。
iOSではシステムキーボードを置き換える`inputView`プロパティというAPIが提供されているので、それを利用しています。
なので、どこでも親指シフトのキーボードが使えるというわけではなく、このアプリを使う場合だけ、ということになります。


親指シフトは「同時シフト」という入力方法が一般的ということで、普通のシフトと違って、「同時に」押すという入力が主流のようです。なので、意外にキーボードのグラフィックを用意してキーを割り当てるだけではダメで、同時判定とかけっこう大変といえば大変でした。


実は私は親指シフトキーボードは使ったことはなくて、調べた以上のことはわからないのでFacebookの親指シフトユーザー会の方々にいろいろとサポートいただきました。なんとかリリースできる形になったのはそのおかげでありとても感謝しています。


作ってるときは勘違いしていたのですが、本格的に入力するにはBluetoothなどの外付けの親指シフトキーボードを使えばいいと思っていて、あくまでもそれと比べて外出先などで少し使う場合に慣れたもので打てればいい、くらいのニーズを満たすものがいいのかなと思ってたのですが、実はBluetoothの親指シフトキーボードは製品として存在しないということで、iOSデバイスで親指シフトが使える環境は他になかったとのことです。そういう話もあるので出せてよかったかなと思います。


現状はまだまだ機能も少ないのでこれから改善していきたいと考えています。
アプリ内に意見を投稿するフォームも用意していますので要望などいただけたらうれしいです。
よろしくお願いします。


20131107010430 N+Note for NICOLA - Katsumi Kishikawa