2012年12月13日木曜日

UINavigationBarをちゃんと理解する。

UINavigationBarとUINavigationItemとUIBarButtonItem

ナビゲーションバーに関わるクラスだけでこの三つもあり、名前と役割が直感的ではないので、ちゃんと区別しておきたい。

UINavigationBarは単なるバーではなく、「現在の表示」をスタックする機能を持つ。

現在の表示」はUINavigationItemとしてカプセル化されている情報のことである。i文庫のナビゲーションバーを例に取ると、


「my本棚3」がtitle、「本棚」がbackBarButtonItem、「Edit」がrightBarButtonItem、「登録は...」の文字がprompt。これらをプロパティとして持つ情報オブジェクトが、UINavigationItemである。

ここで使われていないプロパティとして、leftBarButtonItemtitleViewがある。

leftBarButtonはrightBarButton同様、ボタン要素(後述するBarButtonItemインスタンス)を設置できる。iOS5以降、left(Right)BarButtonItemsプロパティが追加され、複数個同時に設置できるようになった。ただしコードで記述する場合に限定され、InterfaceBuilderからは一つしか設置できない。

leftBarButtonItemと、backBarButtonItemはleftItemSupplementBackButonプロパティをYESにすることで同時に表示もできる。そうでない場合、setHidesBackButton:animated:メソッドでbackBarButonItemを非表示にしない限りは表示されない…と思う。

titleViewは任意のカスタムビューを表示できるビューである。デフォルトではnilで、何らかのカスタムビューと置き換えると、タイトル文字列が消えて代わりにカスタムビューが表示される。ドキュメントには「leftBarButtonItemと同時に使えない」と書いてあるけど、そんなことはなかった。またスライダーやボタンなどUI要素も使用できる。

UINavigationBarはNavigationItemをスタックする

pushNavigationItem:animated:popNavigationItemAnimated:のメソッドを使うと、UINavigationControllerで良く見慣れた「あの動き」でNavigationItemの表示を切り替えてくれる。

画面遷移を伴いナビゲーションバーを持つコンテナビューコントローラを自作したいとき、一画面内で複数のモードが切り替わるのを明示したいときなどはこのメソッドを使おう。

ナビゲーションバーごと画面遷移したり、タイトル文字が突然書き換わるのは美しさに欠ける。

残るUIBarButtonItemとは

UIBarButtonItemは、UINavigationBar、UITabBar、UIToolbarなどのバー要素で用いられるボタンをカプセル化したものである。target/actionを保持して、ボタンが押されると実行する。

ラベルとして文字か、画像のアルファチャンネル部、およびその両方を表示する。

もしボタンに表示する文字が、状況に応じて変わる可能性がある場合。文字が変わるたびにボタンのサイズも変わってしまう(あるいは文字の一部が入らなくなってしまう)のは美しくない。その対処法として、十分な幅のwidthを設定するという手もあるが、possibleTitlesプロパティを設定する手段もある。タイトルの候補となる文字列をNSSetで渡すと、そのうちで最長の表示となるものに合わせて、ボタンの長さを調整してくれる。

initWithBarButtonSystemItem:target:action:ではプリセットのボタンを選択することができる。barButtonItem同士の間隔を調整するFixibleSpaceおよびFixedSpaceもSystemItemの一つとして定義されている。

それ以外を表示したい場合、例えばフルカラーの画像やスライダーなどのコントロールを表示したい場合は、initWithCustomView:を用いる。カスタムビューモードではBarButtonItemのaction/target機構が無効になるので、ボタンなどのコントロール要素が必要であれば、UIButtonをカスタムビューに指定してBarButtonItemを生成する必要がある。

なお、リファレンスのwidthプロパティの説明文に、「ラジオモードスタイルを使用時にはこのプロパティは無視される」と書いてあるけど、唐突に出てくる「ラジオモードスタイル」とは、UITabBar下で使用することを差すのだと思われる。たぶん。

意外と知らないUINavigationBarの高さ

UINavigationBarの高さは44pxだけではない。

普通の高さ(iPhone/iPod Touchポートレート、及びiPad)は44pxで正しいのだけど、意外にレアなiPhone/iPod touchランドスケープモードでのUINavigationBarの高さは32pxになる。

そしてpromptテキストを表示すると72px(つまりprompt部は30px28px)になる。phoneランドスケープでpromptを表示させるとどうなるんでしょうか。実は調べてないので、32+30pxで62pxになる32+28pxで60pxになるのか、それより小さくなるのか分かりません…。

UINavigationControllerをrootViewControllerにしたときは、画面回転時に自動的に高さが変わるのだが、UINavigationBarをそのまま使った場合にはそうした機能は働かない。heightを処理を自分で書く必要がある。

promptの有無による高さ変化は、UINavigationItemのpromptプロパティを操作したときに自動的に行われる。promptが含まれたUINavigaitionItemをsetしても変更されない(pushやpopはOK)という点に注意。

ランドスケープモードでUINavigationBarの高さが変わると、当然UIBarButtonItemもそれに合わせて縮んでしまう。それによって用意したボタン画像のサイズが不恰好になる場合には、landscapeImagePhoneプロパティに32pxのバーに最適化した画像を設定しておくと、自動的に切り替えてくれる。initWithImage:landscapeImagePhone:style:target:action:というUIBarButtonItemの生成メソッドも用意されている。

InterfaceBuilderには頼れない部分

このUINavigationBar周りは、InterfaceBuilderでは出来ないことが多い。rightBarButtonに複数のアイテムを加える、UIBarButtonItemとしてカスタムビューを使う(なぜかUISegmentedControlのみ可)など。

それはUINavigationControllerを使い、コンテンツビューはIBで配置しつつ、TabBarButtonは生成も含めてコードに記述することを想定しているからではないだろうか。静的なコンテンツに対して、ナビゲーションは画面の動的な部分(画面遷移や、編集などモードの切り替え)を担当するので、その方が合理的に思える。

まとめ
  • UINavigationBarは、UINavigationItemをスタックしてる。
  • UINavigationItemはタイトルやボタンなど、バーの表示をカプセル化してる。
  • UIBarButtonItemはバー系で使うボタンを共通化してる。
  • この辺りを理解するとUINavigationControllerについて理解が深まる。
  • コードでしかできないこと、IBでもできることについて理解しよう。

2012年12月1日土曜日

iOS開発のデバッグ作業(1)

日本語ドキュメント - Apple Developer のInstrumentsガイド
iOS開発ガイド (版が古いけど、最新のものがあるのか不明…)

まずこの辺を読もう。

----

前提知識

ビルドの種類

ビルドには「デバッグビルド」と「リリースビルド」がある。リリースビルドでは最適化が施されるため、両者で生成されるバイナリは微妙に異なります。ごく稀ですが、最適化の結果バグが発生するケースがありえるので、必ずリリースビルドでもデバッグを行いましょう。

ビルドを切り替えるには、[Edit Scheme...]から、[Build Configration]をReleaseにする。デフォルトでは、通常のRunはデバッグビルドで、Instrumentsを常駐させるProfileではリリースビルドで実行するので、ちゃんとこの両者でテストしていれば問題ないです。

なお、NSLogはデバッグビルドでもリリースビルドでも表示されますので、不要なデバッグログ出力は、リリース版を作成する際には出力しないようにすること。専用のログマクロを用意するのが常套手段。

デバッグ用設定を整える

iOSでよくあるバグは、解放済オブジェクトへアクセスして、EXC_BAD_ACCESSが発生すること。XCodeの初期設定ではこのバグの発生箇所をmain()関数としか示さない。

[Edit Scheme...]から、[Diagnostics]を選択し、[Enable Zombie Objects]のチェックを入れると、ゾンビオブジェクト(解放済みオブジェクトへのポインタ)を監視する機能が働いて、こうしたミスの発生箇所を正しく示してくれるようになる。

ただし、KERN_INVALID_ADDRESSを検知できなくなる(本来クラッシュする場所で、クラッシュしなくなる)副作用がありそうなんだけど、この辺どうしてそうなるのか分からないので、明確なことは言えない。念のため、チェックを外した状態でもテストをするのが良さそう。

[Diagnostics]には他にもmallocのログ出力など、いくつかの機能がある。自分は使いこなせてないけど。

例外にブレークポイントを設定する

ナビゲータエリアのBreakPointsペインの左下の+から、[Add Exception Breakpoint...]を選択すると、例外が発生した際に自動的にそこで停止する。

[Exceptions]の対象をAllにすると、Cocoa内部のCやC++で書かれている場所で発生している例外まで全部拾って、いちいち止まって邪魔なときがある(AVAudioPlayerでこの現象を確認)。

[Exceptions]の対象を[Objective-C]にすることで、Objective-C内の例外に限定することができるものの、Cocoaフレームワークの実装はCで書かれていたりするので、例外を見逃すこともありそう。(この辺どーなんだろ)

お薦めなのは(受け売りだけど)、[Actions]に[Log Message]と[Sound]を設定して(Messageは例文通りで問題ないと思う)、[Options]の[Automatically continue after evaluating]にチェックを入れる。すると例外が発生すると指定したメッセージとサウンドを鳴らして例外の発生を通知してくれるが、クラッシュする例外でなければそのまま停止せず次の命令が実行される。

デバッグログを出力する

まず本当にNSLogデバッグが必要なのかどうか考える。NSLogを卒業してデバッガを使いましょう。

どうしても必要ならば、マクロを活用しましょう。C言語のマクロとして、__func__(関数名を表示)、__FILE__(実行中のコードのファイル名を表示)、__LINE__(実行中のコードの行数を表示)が定義されています。これらを使うと、現在どのファイルのどの関数の何行目を実行しているのか表示できます。

自動的にこれらを出力する専用のデバッグログマクロを用意すると捗ります。

が、使いすぎると大量にログが流れてうざったいだけ(特に他人が作ったコンポーネント内でいつ何が呼び出されてるとかこっちは興味ない、どうしても知りたければコードを読む)なので、自分が必要な最小限を記述して不要になったら消す、他人に開示するメリットがあると考えたときのみ残す、とかそういうルールでやるといいと思います。

Objective-Cメソッドの暗黙の変数を利用することもできます。例えばselfに対してNSStringFromClass()でメソッドを呼び出しているオブジェクトのクラス名を取得できます。_cmdに対するNSStringFromSelector()は__func__の下位互換なのでメリットはないです。

あと、[NSThread callStackSymbols]を出力すると現在のメソッドの呼び出し階層を見ることができます。想定されていない場所から呼び出されていないか、などを確認するのに使えます。

ビルド警告や静的アナライザを使用する

ビルド時にエラーほどではない問題は警告として表示されたり、[Analize]で静的アナライザを実行すると、コンパイラがメモリ解放忘れやロジックエラーなどを検出して知らせてくれます。

コンパイルが通るだけで満足せず、ビルド警告やアナライザの警告は極力潰してください。警告の内容は大抵は適切ですし、警告を放置するとどんどん積み重なって、本当に必要な警告が出力されたときにそれに気付けなくなります。

かといってコンパイラに振り回されて、修正する必要のないコードを書き直すのも本末転倒です。もし本当に自分の書いたコードの方が正しく、警告を無視しても問題がないのだと確信できるときは、コンパイラの警告を抑制します。

Clangで警告を抑制する方法はUsers Manualをどうぞ。以下はちょっと自信がないのであれですが…。

Warningを抑制するには、

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-警告の種類"
//ここに無視しても問題ないコードを書く
#pragma clang diagnostic pop

静的アナライザの警告を一時的に無視するにはには次のように書けばいいと思います。

#ifndef __clang_analyzer__
//ここに無視しても問題ないコードを書く
#endif

クラッシュログを読む

iOS端末にはクラッシュ時のログが記録されています。もしMacに繋いでXCodeでデバッグしていなかったときに発生したクラッシュでも、本体のログを読めば原因を追究できます。

[Organizer]から[Devices]タブを選択し、クラッシュログを読みたい端末の[Device Logs]を選択します。すると問題発生時のログ一覧が表示されます。クラッシュは分類がCrashとなっているので、その中で問題のアプリを探し出します。

このログを読み解くにも知識が必要そうですが、とりあえずException Typeで原因の種類と、Crashed Threadでクラッシュが発生したスレッドを見て、そのスレッドのスタックとレースで発生箇所を特定します。分かりやすいクラッシュならばこれで原因を特定できると思います。

iOS端末がクラッシュ原因のアプリを特定できなかった場合など、CrashではなくUnknownに分類されることがあります。この場合のログには、アプリ毎に確保しているPage数(アプリが確保したメモリの仮想領域、詳細はページング方式を。なお1ページは4096バイト)が羅列しています。自分のアプリ名を探して、問題の原因となっていないか確認します。

なお、同画面で[Console]を選択するとその時点でのコンソールログを見ることもできます。デバッグログを出力していれば確認することができるほか、didReceiveMemoryWarningイベントが通知されていないかなども確認できます。

クラッシュログを受け取る

他の端末からクラッシュログを受け取るのは少し面倒です。

「~したらクラッシュした」など漠然とした情報をメールでやりとりしても、それがデバッグに貢献することはほとんどないので、端末に記録されたクラッシュログを利用すべきです。

クラッシュログは端末をiTunesで同期した際に自動的にPCにコピーされます。保存される場所はOSによって異なります(場所は上のiOS開発ガイドを参照)。送る側は保存された.crashファイルを送るだけでOKです。

しかし.crashファイル内の生のスタックトレースは、呼び出したメソッド名ではなくそのアドレスが記されているだけで、そのまま見てもほとんど役に立ちません。なので受け取る側は、dSYMファイルを使ってシンボルを解決する必要があります。

詳細はiOS開発ガイドか、iOSデバイスのクラッシュログを読むには - Awaresoftをどうぞ。

同じアプリケーションでも、ビルド毎に生成されるdSYMファイルの内容は変わりますし、ログとdSYMとでビルドが一致していなければ正しくシンボル解決できません。

---

ここまでのまとめ

NSLogデバッグを卒業しよう

効果的に使えばNSLogデバッグも有効です。デバッガを使えば数秒で済む変数値の確認のために、わざわざログ出力コード書いてビルドしてそれを消して…といった作業を行うのは単に非効率的なだけです。

個人開発でないならば、NSLogデバッグを書いた後は、それが他人にとっても必要なのか検討し、必要なければ削除した方が良いでしょう。不要なデバッグログ出力が溜まると、最終的に膨大な量となり、本当に必要なメッセージを見逃すことになりかねません。

コンパイラの警告を活用しよう

コンパイル時に出力される警告や、静的アナライザの解析結果を無視しないで下さい。

可能であれば修正し、もし本当に警告が無視できると確信できるならば、警告を抑制してください。無視ブロックの範囲は極力狭くしておきます。また、無視ブロック内のコードを修正するときには一時的に警告抑制を解除して、新たな警告が出ていないか確認するのを忘れないようにしましょう。

警告を無視し続けるとこれも蓄積して膨大な量となり、本当に必要な警告が見逃されます。

端末のクラッシュログを利用しよう

「~したら落ちた」といったような曖昧な口頭の伝達は、問題解決にほとんど寄与しませんので、クラッシュログを活用しましょう。

XCodeからデバッグ実行したとき以外でも、端末に残されたログから有用な情報が得られます。

2012年11月22日木曜日

UIViewAnimationに渡したブロックはどうなるの?

UIViewAnimationを入れ子構造にしてselfをリークさせる、高度なヒドいコードを修正していたら疑問になったんだけど、UIViewAnimationに渡したanimationブロックはその後どうなるんだろうか。

view.layerに対してremoveAllAnimationsを実行すれば、UIViewアニメーションを中断できることから、animationブロック内部の処理は、CAAnimationに変換されて、viewのlayerに渡されるということは推測できる。

iOS View プログラミングガイドでは、UIViewのアニメーションメソッドが対象とするプロパティはview自身のものに限定されているような書き方をしているのに、実際にはview.layerに対する処理を書いても問題なく実行されることを不思議に思っていたんだけれど、layerにaddAnimationしているならば当然。

するとcompletionブロックはCABasinAnimationのdelegateインスタンスに保持されるんだろう。非形式プロトコルのdelegateメソッド、animationDidStop:finished:から実際のブロック実装が呼び出されるかたちかな。実際はメソッド実装の書き換えとかもっと高度なことをやってそうだけど。

このCore Animationのdelegateメソッドは、Cocoaのメモリ管理のレアな例外で、保持の向きが逆になっている。(Animationがdelegateを保持する)

まとめると、

  • アニメーションブロックはCAAnimationに変換されて、view.layerにaddAnimationされる。
  • 完了ブロックは、CAAnimationのdelegateメソッドを実装したインスタンスに保持されて、さらにdelegateがCAAnimationに保持される。
  • もし完了ブロックにviewを保持するインスタンスをキャプチャした場合、循環参照が発生する。

例えばUIViewControllerにアニメーション処理を記述したならば、UIViewController → view → layer → animation → animationDelegate → completationBlock → UIViewController…という循環が生じるわけだ。

そうなっても、完了ブロックが終了してUIViewControllerが解放されれば、循環は解消するので普通は心配いらない。

---

循環を起こすコードは例えばこんなの。

-(void)animation
{
    [UIView animateWithDuration:1.0f delay:0.0f options:UIViewAnimationCurveEaseInOut animations:^{
        self.theView.transform = CGAffineTransformMakeTranslation(0200);
    } completion:^(BOOL finished) {
        NSLog(@"%@ %@",selfself.theView);
        [UIView animateWithDuration:1.0f delay:0.0f options:UIViewAnimationCurveEaseInOut animations:^{
            self.theView.transform = CGAffineTransformIdentity;
        } completion:^(BOOL finished) {
            NSLog(@"%@ %@",selfself.theView);
            [self animation];
        }];
    }];
}
入れ子にしなくても循環するけど見た目がアニメーションにならないので。

これで別画面に行くとどうなるかというと、二つのcompletationブロックでNSLogが高速に吐き出され続ける。layerの実体を管理してるのはviewではなく、描画システム側だから描画処理自体はスキップされて、延々と呼び出しだけ繰り返される…という感じかな。

ちょっと考えれば再帰呼び出ししてるからアウトだと理解できそうなもんなんだけど、厄介なことに単なる再帰呼び出しと違ってスタックオーバーフローが起きないから発覚を遅らせる。完了ブロックが完了ブロックを呼び出す度に元のブロックは律儀に解放されてるのかな。

こういう変に手の込んだバグコードを書きそうになったら「もっとシンプルな方法があるんじゃない?」と自問すべき。大抵はもっとシンプルで、そしてその方が正しい実装法の書き方が存在する。

上のコードの場合、こう書き直せる。

-(void)animation
{
    [UIView animateWithDuration:1.0f
                          delay:0.0f
                        options:UIViewAnimationOptionCurveEaseInOut UIViewAnimationOptionAutoreverse UIViewAnimationOptionRepeat
                     animations:^{
        self.theView.transform = CGAffineTransformMakeTranslation(0200);
                        } completion:^(BOOL finished){
                            NSLog(@"finished");
    }];
}
UIViewAnimationOptionRepeatを指定すれば、そのアニメーションは繰り返され続けるから、それこそ無限ループを発生させているようで不安になるのかもしれないけど、画面遷移などでlayerが無効になると自動的にアニメーションが停止し、completionに記述された処理が実行される。

UIViewAnimationを入れ子にする処理はAppleのサンプルもやってるけど、Blocksの理解が甘いと変な罠に引っかかるから、真似しない方が良いと思う。

入れ子にしないと実現できないような、複雑なアニメーションをやりたければ、Core Animationを勉強してkey-frame animation辺りで実装した方がシンプルにできる。Core AnimationはCore系の中では最弱なので簡単に覚えられるはず。

----

最近修正してるヒドいコードの数々は、自分より立場の偉い人間が書いてるので、文句が言えない。せめて同じ轍を踏む人間が減りますように。

2012年11月20日火曜日

NSDateFormatterをalloc initのまま使うな!

こんなの入門書に載っているレベルの話なんだけど。

NSDateFormatterは、NSDateを "yyyy年MM月dd日" みたいな表現にするのに使うフォーマッタなんだけど、これをalloc initで生成してそのまま使うと、iOS本体のユーザー設定が反映されるようになっている。

例えば西暦ではなく和暦を設定している場合、yyyy年は2012年ではなく24年になる。ユーザーの本体設定を反映することで、ユーザーにとって違和感なく表示するための仕組みだが、プログラム側では現在の西暦を取得したいときもある。そういうときにalloc initのまま使ってはいけない。

フォーマッタ使うときは、ここまでテンプレとして書くとよさげ。

NSDateFormatter *formatter = [[[NSDateFormatter alloc] init] autorelease];
formatter.calendar = [[[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar] autorelease];
formatter.locale = [[[NSLocale alloc] initWithLocaleIdentifier:@"ja_JP"] autorelease];
formatter.timeZone = [NSTimeZone timeZoneWithAbbreviation:@"JST"];

海外に出すことを考えないアプリ限定になるけれど、カレンダーをグレゴリオ暦(この呼び名に馴染みは薄いけどただの西暦)にして、ロケールを設定(iOSは12時間表記時にユーザーロケールのままだとHHを正しく解釈しない)して、タイムゾーンも設定しておけば、フォーマット書式は確実に指定した通りになる。

iOS6から「YYYYとyyyyを間違える」ときに返ってくる値が変わったみたいでこれもバグの原因になっていたりするみたいだけど、書式文字の意味が分かってたらこんな間違いをしないってゆーか、ちゃんとリファレンスは読みましょうよ…。

---

以下は余談だけど、時間を比較したり、データベースに入れたり、ネットワークに送信するのに、NSDateFormatterで書式化した文字列使うな!と言いたい。

Objective-C内で日時を扱うならNSDateの同値・比較メソッドを使うべき。外部とやりとりするのであればUNIX時間で管理するのが一番良い。

例えば次のメリットがある。
  • データ量が少ない
  • 比較コストも安い
  • NSDateとUNIX時間を相互変換するメソッドが用意されているため復元も容易
  • SQLiteには厳密な時間を表現する型がないが、FMDBを使えば、NSDate←→UNIX時間の相互変換を自動でやってくれる(後述)

そもそも本当にNSDateFormatterを使わなければならないケースなのか考えるべき。あくまで外部に表示するとき、
  • ユーザーの望むフォーマットやロケールで日付を表示したい
  • ユーザーが望むフォーマットで入力した日付文字列を変換したい
  • 明示的に指定したフォーマットで日付文字列を表示しなければならない
こういうときにだけ使うものだと考えた方がいいと思う。

内部で日付計算するとき、外部に日付を書き出すときに使用すべきではない。

どうしても文字列で保存しなければならない、そうしなければ誘拐された娘の命が危ない、とかそういう場合でも、せめてISO8601とか標準書式に則った書き方にするのが無難。

場所によって記法が混在するとか一番ダメなパターン。

----

ちなみにNSDateはクラスクラスタなので、おそらくロケールやカレンダーなどの違いに応じて、それぞれの表現に適した内部データ構造を持っている。

異なる表現の日付型を橋渡しするプリミティブメソッドとしてtimeIntervalSinceReferenceDateが定義されているんだけど、これはUNIX時間ではなくて、グリニッジ標準時で2001年1月1日を起点にしている。NSDateの独自クラスを実装するわけでもなければ気にする必要はないけど、timeIntervalSince1970(UNIX時間)と微妙に紛らわしいので注意。

あと、timeIntervalSince...系のメソッドはdouble型の値を返すけど、それが期間の表現であるとソース上で明示するためにTimeIntervalを使用すること。

----

SQLiteは日付型が用意されていない。仕様上、日付の内部表現として、INTEGER、REAL、TEXTの3方式を選択できることになっている。

iOSアプリで使う場合、TEXTは論外。文字列をパースするコストや容量が無駄なだけ。

残るはREALとINTEGERの2択だけど、「TimeIntervalがdoubleなのだから、REALの方が相応しいのでは?」と思うところだけど、SQLite3の仕様上、REALはユリウス日数(ユリウス暦の紀元前4713年1月1日を起点にした日数)で使用するもので、Unix時間(グレゴリオ暦1970年1月1日を起点にした秒数)を格納するのはINTEGERになる。

こうして格納した日付は、FMDBを使うことでNSDateとして取り出すことができるし、SQLite3の日付関数を使って特定の日時のデータを抽出するクエリを書くこともできる。

2012年11月19日月曜日

条件演算子(三項演算子)の言語の違い

Objective-CでDictionaryの内容をクエリ文字列化するメソッドを、

NSMutableString *result = [NSMutableString string];
__block BOOL firstQuery = YES;
[param enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
 (firstQuery) ? firstQuery = NO : [result appendString:@"&"];
 [result appendFormat:@"%@=%@",key,obj];
}];

こんな風に書いた。(勿論実コードはパーセントエンコードとかちゃんとやってる)

Objective-Cは相変わらず好きになれないけど、for文なしでコレクションの走査ができるブロックの簡便さは良いね。

それで、Javaでも似たことをやろうとしたらコンパイルが通らなかった。

C言語では三項演算子 (条件式) ? 式A : 式B の、式Aと式Bはどんな内容でも評価される。もちろん左辺式が存在する場合に、式Aと式Bのいずれかが代入先の型と違うならば、コンパイルエラーになる。逆に言えば左辺式がなければ、上記みたいに返り値型がvoidのメソッド実行もできる。

対して、Javaでは式Aと式Bの型には互換性がなければならない。式Aと式Bに共通のスーパークラスが存在したり、同一のインターフェースを実装しているなど、オートボクシングによって両者が同一の型とみなせる状況でのみ使用することができる。

C#とかはもうちょっと厳しくて、キャストなしで式Aが式Bに代入できる(またはその逆)場合にしか、三項演算子を使うことができないらしい。

三項演算子がネストした際の挙動が言語ごとにかなり違う…というのは知っていたけど、こんな基本的なところに違いがあるもんなんだねえ。

参考:Java5で条件演算子(三項演算子)に仕様変更があった! - 地平線に行く

2012年11月7日水曜日

User Defined Runtime Attributes

InterfaceBuilderで各要素に対して存在するこの設定項目。

「ユーザー定義の実行時属性」とかいうと分かりにくいけど、やってることは単に実行時にキー値コーディングを用いてプロパティに値を設定するだけ。

これを使用することで見た目に関わるプロパティの設定を、viewDidLoadからxibに移す事ができる。「ビューとロジックを分離させたい」という用途にうってつけのように思える。

しかしインテリセンスみたいな補完は一切仕事しないし、スペルミスってたりプロパティ自体存在してなかったりしたら、実行時にUndefined Keyで落ちる。

 しかも設定できる要素にかなり制限があって、

  • 整数は指定できるけど、小数は指定できない。
  • UIColorは指定できるけど、CGColorは指定できない。

これらの制約によってlayer.cornerRadiusを操作するという用途には向かないし、layer.borderColorは設定できない。角丸にしたり影付けたりみたいなのは、viewDidLoadでやるしかない。ギリギリ使えるのは、UITableViewの背景を透過させるのにbackgroundViewにnilを代入したりとか…。

謎なのが「Localized String」の設定項目の存在で、これを使えばコード上でLocalizedStringsを用いて文字列を書き換えているコードを一掃できるように見える。しかしiOSでもOSXでも機能しないし、調べても「使えない」「ドキュメントもない」という報告ばかり。仮に使えたとしてもgenstringで検出できないので、利用価値はゼロだろう。

「iOS5 プログラミングブック」ではこの属性の存在について触れているんだけど、実際に使った上で書いてるんだろうか?

また「User Defined Runtime Attributes」で設定したプロパティは、実行時にキー値コーディングで設定されるだけなので、当然Interface Builder上に反映されるわけもなく、設定できる内容も中途半端なことからIB上とコード上に処理が分散して、かえって分かりにくくなる可能性の方が高いので、多人数プロジェクトには向かない。

2012年10月23日火曜日

ICS以降のXmlPullParserで起きる不都合とか

androidのXml#newPullParserで取得できるパーサはバージョンによって異なる。

具体的には、
  • Android3.2まではExpatPullParser
  • Android4.0以降はKXmlParser
---

もともとandroid SDKにはこの2種類のパーサが用意されていた。

Xml#newPullParserで取得できるExpatPullParserの挙動にはバグが多いため、XmlPullParserFactory#newPullParser()で取得できるKXmlParserを推奨しているサイトもあった

しかし、ほとんどの書籍やサイトでは両者を区別せず、呼び出し方が簡単としてExpatPullParserを推奨されていた。もちろん自分も今まで全く知りませんでした。(解析器周りは複雑すぎてよく分からないことが多い…)

ICSになって、バグの多いExpatPullParserは削除され、Xml#newpullParserでもKXmlParserのインスタンスが取得できるようになった。結果として、バグったパーサ上で正常に動いていたコードが、逆に動かなくなる…という現象が発生した。


その原因と対策方法はDevelopers Blogでちゃんと解説されていたんだけど、スルーしてたせいで完全にハマってしまった。

---

それとは別に、ICS以降のExpatPullParserでのみ起きる不都合が存在する。


それはxmlにBOM(Byte-Order-Mark)が付与されている場合に発生する。BOMとはエンコード方式を特定するための特殊なバイト列のことで、一部のテキストエディタではUTF-8で保存した場合に先頭にBOMを付与する。


上記はWindows標準メモ帳にてUTF-8で保存したファイルを、バイナリエディタで開いたもの。先頭にEF BB BFのバイト列が埋め込まれていることが分かる。しかし、メモ帳側からはそのことは分からない

ExpatPullParserはこのBOMを解釈できないのか、TEXTノードとみなすのか、文頭でnextTag()を呼ぶと例外が発生してしまう。3.2以前のパーサではBOMが存在しても無視していたので、この点では退化している。

まあandroidが悪いというよりも、後方互換性からBOMを放置するJava悪いという気がする。対処するには、xmlファイルを格納したStringの0文字目を識別する。String#charAt(0)が0xFEFFなら、String#subString(1)で除外。

---

2つの不都合の見分け方は、以下の通り。
  1. 例外の発生箇所がnextText()の直後であり、UnexpectedToken...というエラーメッセージでposition:START_TAGと書かれていた場合、パーサの変化が原因である。
  2. 例外の発生箇所がxml読み込み開始直後であり、UnexpectedToken...というエラーメッセージでposition:TEXTと書かれていた場合、BOM問題が原因である。

2012年10月20日土曜日

NSAutoreleasePoolの書き方三種類。

Objective-Cにはよくあることだけど、やり方が複数あってどれが正しいのか困る。

[NSAutoreleasePool release];

昔のやり方ではこう。

autolereaseメッセージが呼ばれたオブジェクトに、releaseが呼ばれる。

[NSAutoreleasePool drain];

ガベージコレクタとともに追加。

GC環境下ではGC実行開始の要求、MRC環境下では上述のreleaseと同じ効果。

GC環境でのコードとMRC環境のコードが混在する場合のために存在する。一般的にはreleaseではなくdrainを使用するべき、とある。

@autoreleasepoolブロック

ARCでNSAutoreleasePoolの直接使用が禁止になるとともに追加。上述のreleaseと同じ効果を持つ(ドキュメントを見る限りdrainと等価ではない?…んだけど、そもそもARCとGCが混在する環境の話なんざ気にしなくてよさそう)

MRCでも@autoreleasepoolの方が効率が良いために推奨されている。

つまり全て@autoreleaseでOK。

余談

NSAutoreleasePoolでは、initされてからrelease/drainが呼ばれるまでの間に制御が別の場所に移る(つまりrelease/drainが呼ばれないまま放置される)と、そのプールでのオブジェクトがリークする恐れがあったが、@autoreleaseではブロックを抜ける際に必ず遅延releaseが実行されることが保証される。

2012年10月5日金曜日

CAMediaTimingFunctionの謎。

そういえばIntrospectionで得たprivateメソッドの情報って書いていいんでしょうか。本に載ってたりするからたぶんNDA違反じゃないよね。

----

androidでイージング関数の生成の役割を果たすInterpolator(3.0以降はTimeInterpolator)は、独自にオーバライドしてその挙動を自由に操作できます。なのでレガシーアニメーションAPIで、円でもリサージュ曲線でも数学的に軌道を記述可能なアニメーションを簡単に実行できます。

一方のiOSではCAMediaTimingFunctionがそのポジションなんですけど、getInterpolationに相当するメソッドが存在しないので、2点の制御点を用いて表せるベジエ曲線が限界になります。

+functionWithControlPoints::::

普通のAPIだとセレクタにくどい説明ラベルが付いていることが多いのですけど(controlPointX:y:nextPointX:y:みたいな)、通常利用を想定していないのか何も書いてません。

始点を(0,0)、終点を(1,1)としたベジエ曲線の、2つの制御点を指定します。例えばオーバーシュートなら制御点1(0.25,-1.0)、制御点2(0.75, 2.0)とかでしょうか。この値をそのまま並べて、

[CAMediaTimingFunction functionWithControlPoints:0.25:-1.0:0.75:2.0];

とやると多分それっぽいイージングが作れます。イージング関数は調べれば出てきますが、それを制御点に落とし込むのは結構大変な気がします…。

----

getInterpolationに相当するメソッドはない、と書きましたが、実際には存在するのです。

Introspectionでメソッドを洗い出すと、

-(float)_sloveForInput:(float)

というメソッドが見つかります。

しかし実際にアニメーション実行時に呼ばれるのは、getControlPointAtIndex:values:だけです。getControlPointAtIndex:values:は、initで与えた制御点の座標となる4つのfloat値について、0~4のindexに対して、出力引数で返すだけのメソッドみたいです。

MacOS系では使われているものの、iOSでは使われていないメソッドなんでしょうか…。

2012年9月29日土曜日

iOSのレイヤアニメーションメモ

キー値コーディングの知識は、iOSアプリではほとんど無駄だと思っていたけど、Coreフレームワーク群はプロパティの生まれるObjective-C 2.0以前の、つまりキー値コーディングの時代の知識を必要とするドキュメントが多いので、目を通しておくだけでも理解できなかったドキュメントが頭に入るようになると思うのでオススメ。

---

レイヤアニメーション(Core Animation)関連で調べていて躓いた点のまとめなど。

---

レイヤって何?

UIKitではViewが最小の粒度だったが、さらに下位のフレームワークでは最適化のためにViewは描画部分と、ユーザーインタラクションの部分を別の仕組みで扱っている。

このうち描画を担当しているのがレイヤ。

このため、UIViewアニメーションでは当たり判定も同時に移動していたが、レイヤアニメーションでは当たり判定は追従しないという違いがある。

その分レイヤアニメーションの方が性能が高く、出来ることも多い。

---

CAAnimationとかCABasicAnimationとかいろいろあってワケ分からない。

CAのプレフィクスはCoreAnimationフレームワークに属することを意味する。

全てのアニメーションクラスの抽象クラスがCAAnimation、その他はサブクラス。クラスの関係はリファレンスに載っている。

transformやalphaなどのプロパティ値の変化を数学的に補完するCAPropertyAnimationがあり、そのサブクラスとして開始状態と終了状態を指定するCABasicAnimation、配列で値を渡すことで複雑な動きを表現するCAKeyframeAnimationとがある。

それとは別に異なるレイヤへの切り替え処理を行う、トランザクションアニメーションを扱うCATransitionがある。

---

イージングを使いたい

iOSのアニメーションは確か何もしなくてもEaseInEaseOutのイージングが機能していると思うけど、組み込みで使えるイージングはLinear、EaseIn、EaseOut、EaseInEaseOutの4種類しかない。

カスタムなイージング関数を使う場合、レイヤアニメーションのtimingFunctionプロパティを自作のCAMediaTimingFunctionクラスのオブジェクトに置き換える。

「ベジェ曲線の制御点を2点指定する」方法で簡単に作れるが、それ以上の制御点が必要な複雑なアニメーション(例えば振動して収縮する系統など)はサブクラスを作ってgetControlPointAtIndex:values:をオーバーライドすればよさげ。

嘘でした。複雑なアニメーションはKeyFrameAnimationとしてしか作れないみたいです。

---

アニメーション終了時に処理を書きたい

普通にやるならアニメーションオブジェクトのdelegateプロパティを指定して、デリゲートメソッドを実装する。

その辺の処理をラップして、ブロック構文で済ませられるようにすると良さげ。

---

アニメーション終了後処理でアニメーションの識別をしたい

普通にデリゲートメソッドとして書く場合、デリゲートオブジェクトは大抵呼び出し元一つになるので、「どのアニメーションの終了処理なのか?」を知る必要がある。

そういうデリゲートの残念さを回避するのにブロック構文を使うといいんだけど、使わない場合は落とし穴が二つあるので注意。

まず、アニメーションは終了時点で識別するには、引数で渡したアニメーションと、レイヤに与えたアニメーションが同じオブジェクトかを比較するしかない。

しかし、デフォルトではアニメーションが終了した時点で解放されるので、removedOnCompletionプロパティにNOを渡して、解放されないようにしなければならない。

ただしこのままだと与えたアニメーションが保持し続けられてしまうため、同じレイヤに複数のアニメーションを実行させたときに、終了後にレイヤが不可視になるなどの振る舞いが発生してしまう。なので、終了処理でremoveAllAnimationsメソッドなどでアニメーションを手動解放する必要がある。

---

レイヤアニメーションを使うメリットって?

UIViewアニメーションで処理がもたつく場合は、レイヤアニメーションに切り替えることで改善される可能性はある。

カスタマイズしたイージング関数が使える。これはデフォルトで選べる選択肢が少なすぎるのが悪い気がするけど。

また3Dトランスフォームが可能。MacOSXやiPhoneにカバーフロー表示があるのは、偏にCore Animationが3Dトランスフォームアニメーションをサポートしているから。

---

実はUIViewAnimationでもレイヤを操作できる

CAAnimationの低レベルAPIを使用しなくても、レイヤを操作したアニメーション自体はできる。

UIViewのbeginAnimationでも、iOS4以降のanimateWithDuration:animations:でもOK。layerの値を操作するとちゃんと補完され、レイヤアニメーションが実行され、終了後にはViewの判定も移動している。CATransform3Dを使用してもまったく問題なし。

不可能だと書いてある本やサイトも存在するので、iOS5前後で可能になったのでしょうかこれ…。

2012年9月11日火曜日

iOSにおけるFirstResponderについて

ファーストレスポンダとは、Cocoaにおいてフォーカスが当たっているGUI要素のことです。そしてタッチ以外のイベントを、最初に受け取ることになるオブジェクトです。

そんなことは入門書にも腐るほど書いてあるので分かっていると思います。そのはずなのに「InterfaceBuilderのFirstResponderプレースホルダの使い方は分からない」「使ってみたけど期待した動作をしなかった」という人向けの記事です。

---

タッチイベントが発生すると、UIApplicationからUIWindowへ、さらにUIViewControllerからUIViewへと下層に向かってヒットテストが発生します。そして最下層(ユーザーから見ると最前面)のビューがヒットテストビューとなり、受け取ったイベントを処理する責務を負うのです。

もしヒットテストビューがイベントを処理できなかった場合は、替わって上層のUIViewやUIViewControllerが対処に当たります。そして遡り続けても誰も処理できなかった場合、そのイベントは無効とみなされ破棄されます。

---

では「文字入力」や「シェイクモーション」などのタッチ以外のイベントはどのオブジェクトが受け取り、またどうその責務を伝播させればいいのでしょう?

その答えがファーストレスポンダレスポンダチェーンなのです。

特定のオブジェクトにbecomeFirstResponderメッセージを送ると、ファーストレスポンダオブジェクトになります。そしてタッチ以外の発生したイベントは全て、まずこのファーストレスポンダに送信されるのです。

そしてそのオブジェクトがイベントを処理できなかった場合、レスポンダチェーンを辿って他のオブジェクトに横流しされます。レスポンダチェーンは、タッチイベントがビュー階層を上層に遡っていったのと同じ繋がり方をしています。

---

InterfaceBuilderのFirstResponderプレースホルダと関連付けた場合、そのメッセージは現在のファーストレスポンダに送信されることになります。

ただしここには2つ注意すべきことがあります。

---

まず第一に、ファーストレスポンダは一つとは限らないということです。

現在のファーストレスポンダを管理しているオブジェクトは何でしょうか?

その答えはウインドウオブジェクトです。Cocoa Touchではプライベートメソッドになっていますが、UIWindowにfirstResponderメッセージを送ると、現在保持しているファーストレスポンダオブジェクトが何かを教えてくれます。(プライベートメソッドはリジェクト対象になるので、ストアに出すアプリでは使わない方がいいです)

これはMac OSXが一つのアプリが複数のウインドウを持っていたためです。ウインドウという概念の乏しいiOSですが、OSXの名残はいくつか見受けられます。

例えばMac OSXでは複数のウインドウのうち、現在ユーザーと対話しているウインドウのことを、「キーウインドウ」と呼びます。iOSではそれを意識することはありませんが、アプリケーション起動時に必ず実行しなければならないUIWindowのメソッドは、makeKeyAndVisible(対象をキーウインドウにして表示せよ)です。

単一のアプリが複数のウインドウを持つOSXのCocoaの流れを汲むため、「個々のウインドウが現在フォーカスしているGUI要素を管理する」という仕組みも引き継いでいます。つまりWindowの数だけファーストレスポンダが存在しているのです。

しかしiOSは一つのUIWindowの下に全てのビューが収まっているため、関係ないように思います。実は違うのです。


問題が起きるのはキーボードを出現させたときです。ソフトキーボードのViewは、UITextEffectsWindow配下となっていて、UIWindowとは独立しているのです。

ゆえに上の画像のようにinputAccessoryViewに閉じるボタンを設置し、そこからInterfaceBuilderのFirstResponderプレースホルダに対してresignFirstResponderメッセージを送るような処理を書いても、UITextEffectsWindowのファーストレスポンダに送信しようとして失敗するため、キーボードは閉じないのです。

---

まったく無駄な知識ですが、UITextEffectsWindowに何らかのテキスト編集要素を持たせることで、UITextEffectsWindowのファーストレスポンダと、UIWindowのファーストレスポンダの、双方が存在する状態にすることが可能です。

その場合、UITextEffectsWindowのファーストレスポンダにresignFirstResponderを送信すると、UIWindowのファーストレスポンダにフォーカスが移ります。


なお「UITextEffectsWindowがファーストレスポンダを持っているときは、UIWindowへのタッチ操作は全て無効になる」という興味深い現象が発生します。

---

そしてもう一つは、もしファーストレスポンダが存在しなかったとき、あるいはファーストレスポンダにイベントを送信したものの、レスポンダチェーンを辿っても処理できなかったときです。

その場合、なぜかヒットテストビューに対してメソッドが実行されるのです。

…そんなことはありえない気がするのですが、実際の挙動を見るとそう考えないと辻褄が合いませんので、そうなのでしょう。

ヒットテストビューに該当するメソッドが実装されていなければ、例によって上層に遡っていき、AppDelegateが処理できなければ、そのメソッドは破棄されます。

---

そもそも、InterfaceBuilderのFirstResponderプレースホルダにメソッドを送信するとはどういうことなのでしょうか?

それを知るためには、まず普通にFile's Ownerのメソッドにbindした時の挙動を知る必要があります。

例えばボタンにメソッドをbindして、ボタンをタップすると、File's Ownerの指定されたメソッドが実行されます。この挙動で「ターゲットとセレクタを保持して、performSelectorを用いているんだろう」と当たりは付くと思いますが、もう少し捻りがあります。

InterfaceBuilderでメソッドとbindするか、もしくはコード上でGUI要素にaddTarget:action:forControlEvents:でアクションと紐付けると、GUI要素のスーパークラスであるUIControlは、内部にアクションとセレクタを保持します。

そしてbindしたイベントと合致するイベント(タップなど)が送信されると、sendAction:to:from:forEvent:を実行します。しかしこのメソッド内部で直接処理を行わず、UIApplicationの同名のメソッドに転送します。そしてUIApplicationからperformSelectorメッセージが送信されるのです。

この、一度UIWindowより上層であるUIApplicationを経由するというのがミソです。

---

ではFirstResponderプレースホルダとbindしたときはどうなるのでしょうか?UIControl内部でセレクタが保持されるのは同じです。しかしこのときは、ターゲットは指定されずnilになっているのです。

なので、FirstResponderプレースホルダへのbindと同じことを、コード上でも実現できます。addTarget:action:forControlEvents:のターゲットをnilにすればいいのです。

あるいは直接、UIApplicationのsendAction:to:from:forEvent:を、to(ターゲット)をnilにすることで、現在のファーストレスポンダに対してメソッドを実行することができます。これをnilターゲットアクションと呼びます。

なぜかStackOverflowを見ても知名度が低いのですが、nilターゲットアクションはファーストレスポンダに対して確実にresignFirstResponderを送信するベストプラクティスでもあります。

---

これでなぜUIControlがターゲットアクションでメソッドを起動しているだけなのに、ウインドウをまたがるとファーストレスポンダへメッセージを送信できないのか?という謎も解けます。

単純なターゲットアクションではなく、sendActionメソッドを通じてUIControlから、ウインドウよりさらに上層のアプリケーションオブジェクトに渡されるので、そこから属しているウインドウのファーストレスポンダを探す処理が入っているのでしょう。

---

まとめ


  • 一般に「ファーストレスポンダ」と呼ばれているものは、各々のウインドウが最初に応答するオブジェクトを保持しているものである。
    • ウインドウオブジェクトは通常の画面を表示するUIWindowと、ソフトキーボードを表示するためのUITextEffectsWindowとがあり、それぞれがファーストレスポンダを管理している。
  • InterfaceBuilderの「FirstResponderプレースホルダ」にbindされたメソッドは、nilターゲットアクションとして実行される。
    • 結果としてもし属しているウインドウにファーストレスポンダが存在すればそこを起点にメソッドが実行される。
    • ファーストレスポンダが存在しない、或いはファーストレスポンダとそのレスポンダチェーンが処理を実行できなかったときは、ヒットテストビューを起点にメソッドが実行される。これはnilターゲットアクションとは関係ない謎現象で、なんでそうなってるのか全然理解できない。
参考文献

2012年8月31日金曜日

UITextFieldのキャレットを操作(UITextRangeとUITextPosition)

まず、「キャレット」と「選択範囲」という言葉について。

  キャレ|ットは3文字目の位置にある

こういう文字入力時のカーソルのことを「キャレット」と呼びます。

  2文字目から6文字目が選択されている

複数の文字を選択している場合、キャレットは非表示となり「選択範囲」がハイライト表示されます。

iOSのテキストシステムの内部ではキャレットと選択範囲のハイライトとを、同一の仕組みで扱っているため、以下で「キャレット」と呼んだ場合、「選択範囲」も含むものだと考えてください。

UITextFieldのキャレットの位置を取得するためには、selectedTextRangeプロパティを参照します。このプロパティはUITextRangeクラスのオブジェクトです。

----

Objective-Cには、NSRangeという範囲を保持する構造体があります。NSStringを操作するときに使わされてお世話になってることだと思います。

なぜUITextFieldではUITextRangeをわざわざ使うのでしょうか?文字列の範囲を保持するために、構造体ではなくオブジェクトを使うのは一見無駄に思えます。

その理由は二つです。
  • HTMLのようなネストしたドキュメントにおいて、メタテキストと可視テキストの両方の追跡が求められるため。
  • iPhoneのテキストシステムの基となったWebKitフレームワークで、テキスト始点や終点をオブジェクトで表す必要があった。
後者は完全にAppleの都合ですが、前者については理はあります。例えばHTML文書の「強調」という文字を選択しているとき、可視テキストを見れば「"強調"の2文字選択している」という解釈になりますが、HTMLとしては「"<b>強調</b>"の9文字選択している」のです。

NSRangeのような単純な型では、このようなメタ情報までは保持できないのです。

----

UITextRangeは3つのプロパティを持ちます。

まずstartとendプロパティ、二つのUITextPositionがあります。これらは選択の始点(start)と、終点(end)を保持するものです。

始点と終点の位置が同じならば、選択ではなくキャレットの状態ということになります。これを調べるためのemptyプロパティが存在します。BOOL型なのでisEmptyで参照できます。

UITextRangeは不変オブジェクトであり、startやendの位置を操作できません。UITextPositionもまた、一切のメソッドを宣言しない(アクセサも当然ないから公開プロパティを持たない)クラスであり、特定の位置を示すUITextPositionを生成することもできません。

それはそのはずで、UITextPositionの実体は対象がプレーンテキストか、或いはHTML文書かによって指し示すものが変わるものであり、UITextPositionやUITextRangeは文書の意味構造に関わらず、共通の文書操作のインターフェースを提供するための抽象クラスだからです。

このことは、もし何らかの操作可能な文書を保持するクラスを作るときは、その文書の意味構造に応じて、UITextRangeとUITextPositionをオーバーライドする必要があることを意味します。

----

以上から、UITextFieldのキャレット(すなわちUITextRange)を操作するためには、UITextField自身のメソッドを利用する必要があることが分かります。

UITextRangeやUITextPositionの操作に必要なメソッド群は、UITextInputプロトコルで宣言されているものです。関連したものを以下に列挙します。

プロパティ
  • beginningOfDocument
    • ドキュメントの先頭のUITextPositionを保持
  • endOfDocument
    • ドキュメントの終端のUITextPositionを保持
  • selectedTextRange
    • 現在選択されているテキスト範囲をUITextRangeで保持
メソッド
  • offsetFromPosition:toPosition:
    • 指定した2つのUITextPositionの距離をNSIntegerで取得。
  • positionFromPosition:offset:
    • 指定したUITextPositionから第二引数だけ離れた新しいUITextPositionを取得。
  • positionWithRange:atCharacterOffset:
    • 選択範囲の始点からCharacterOffsetだけ離れたUITextPositionを取得?
  • textInRange:
    • UITextRangeから対応する文字列を取得
  • textRangeFromPosition:toPosition:
    • 指定した2つのUITextPositionを範囲とするUITextRangeを取得
これで全てではありません。その他については、UITextInputのプロトコルリファレンスを参照してください。

----

以下は「UITextFieldにキャレット移動メソッドを追加させてみた」という実例です。

/*
 キャレットをドキュメント始点方向に移動させます
 */
-(void)caretMoveToBackward
{
        UITextRange *currentRange = self.selectedTextRange;
        if([currentRange.start isEqual:self.beginningOfDocument]){
                return;
        }

        UITextPosition *newPosition = 
              [self positionFromPosition:currentRange.start offset:-1];

        UITextRange *newRange;
        if([currentRange isEmpty]){
                newRange = [self textRangeFromPosition:newPosition
                                            toPosition:newPosition];
        }else{
                newRange = [self textRangeFromPosition:newPosition
                                            toPosition:currentRange.end];
        }

        self.selectedTextRange = newRange;
}

見ての通り、死ぬほど面倒です。

まず既に始点ならばガード節で弾きます。UITextFieldに不正なUITextRangeを与えると、問答無用でアプリが落ちてしまうからです。

次に選択範囲の始点から-1の距離を指定した新しいUITextPositionを取得します。

そして現在のNSRangeが、キャレットか範囲選択かに応じて場合分けをします。

ただここで考えなければならないのは、範囲選択時に「キャレットを前へ動かす」というボタンに対して、ユーザーがどういう挙動を期待しているのか、ということです。考えられるものを列挙してみると、
  1. 選択範囲全体が動くべき
  2. 始点だけが動いて選択範囲が広がるべき
  3. 選択を解除してキャレットに戻した上で一文字移動するべき
  4. 選択を解除してキャレットに戻すだけで移動はするべきではない
Windows PCでカーソルキーを押したときの挙動は、意外にも4.なのです。

もっともiOSは範囲を選択しやすいマウスのあるWinPCとは違います。ここでは指で細かい範囲指定を要求されて、発狂する人を助けるための機能として作ったので、2.で実装しました。

----

「使いやすさ」という言葉に対しては様々な議論がありますが、個人的には「ユーザーが期待した動作を実現する」ことが重要だと考えています。

ユーザーの期待の実現、すなわちメンタルモデルに沿わせるための簡単な方法は、標準にあわせることです。

Appleがガイドラインを公開して、それに沿うよう強制するのも、アプリ毎に個々のUIの意味や挙動が変わり、悪い意味で期待を裏切り、思わぬ結果に驚かせるような、ユーザー体験を損ねる挙動を出来るだけ排除するという狙いがあるのでしょう。

なのでそれをする必然な理由が存在しないならば、独自の挙動は出来るだけ回避するべきだと思います。

----

しかしこのAPIはないわー感満載。せめてNSRangeからUITextRangeに変換するメソッドくらいあってもいいのに…。

----

余談。

テキストフィールドのbecomeFirstResponderをコールすると次の流れで処理されます。

  1. UIResponderのbecomeFirstResponderを呼ぶ。
  2. UITextFieldの_becomeFirstResponderが呼び出される。
  3. UIControlのUIControlEventEditingDidBeginイベントにバインドされたメソッドが呼び出される。
  4. UITextFieldのtextfieldDidBeginEditingが呼び出される。
UITextRangeを操作できるのは、textfieldDidBeginEditing以降です。

UIControlのUIControlEventEditingDidBeginイベントにキャレットを操作するメソッドを実行させることはできません。より厳密に言えば、変更がなかったことにされて、endOfDocumentの位置にキャレットが移動されています。

なぜこういう仕様なのかは謎ですが、システムで予約されたプライベートメソッド内でそういう処理をしているのでどうしようもないです。迂闊にいじろうものならリジェクト確実なので疑問を持ってはいけません。

もし「既に入力済みのフィールドを選択したら、選択済み状態にして欲しい」などの機能を実装するときは、デリゲートメソッド以降でキャレットを操作しましょう。

2012年8月29日水曜日

deallocでnil代入すべきではない理由について

XCodeが生成するコードを見ると、viewDidUnloadではnil代入によってプロパティ初期化しているのに、deallocではインスタンス変数を直接releaseしています。

なぜ二つのやり方を使い分けるのか、疑問に思ったことがないですか?

---

オブジェクトの解放は、すべてself.hoge = nilでやるべきだと推奨する人もいます。

なぜかというと、プロパティの実体であるインスタンス変数をreleaseしたあとの、self.hogeが指し示すアドレスは、nil(0番地)ではなくもともとデータが存在していたアドレスです。

そこにアクセスしようとすると、retain忘れや過剰releaseと同様に、忌まわしいEXC_BAD_ACCESSが発生します。

nil代入はオブジェクトを解放しつつ、プロパティのポインタを安全なnilで初期化する良いイディオムなのです。

---

しかしキー値監視が行われている可能性がある場合、話は変わります。

キー値監視とは、その名前の通り、他のオブジェクトのプロパティが変化したとき、コールバックを受け取る仕組みのことです。もしプロパティにキー値監視が行われている場合、nil代入によって通知が行われるかもしれません。

しかしdeallocフェーズではオブジェクトの状態は不安定になり、メソッドの結果は予測不可能になります。

運がよければ何事もなく正しい結果を返します。悪ければ必要なメンバが既に解放されているため、不正なアドレスからデータを読み込もうとしてエラーで落ちます。

ものすごく運が悪いと、メソッドの実行に必要なメンバが解放されているにも関わらず、処理自体は通って不正な値を返すといったことがありえます。後々になってそのことが発覚するような場合、デバッグは困難を極めます。

deallocフェーズでのプロパティ操作は、オブジェクトが不安定な状態でキー値監視通知を発生させる恐れがあるので、XCodeが生成するコードではそれを避けるようになっているのです。

---

キー値監視なんて使ったことないし関係ないじゃん?みたいに思うのですが、CocoaやInterfaceBuilderが裏側で使っているとか使っていないとかみたいです。(iOSでの依存度については、良く分かんないんですが…)

カテゴリでプロパティを実装する(関連参照による擬似インスタンス変数)

mファイルに@interface ClassName()と書くことを、「プライベートカテゴリ」とか「匿名カテゴリ」と呼びますが、多分正確な用語では「クラス拡張」と呼ばれるもので、カテゴリとはクラスの実装をカテゴライズして複数のファイルに分散する機能です。

この記事におけるカテゴリは、クラス拡張ではなく、本来の意味のカテゴリを指します。

カテゴリは実装を分散するだけでなく、既に存在するクラスに対して、継承することなく新たなメソッドを追加できる柔軟性を持っていますが、インスタンス変数を保持できない制約が存在します。

それが問題になることは通常では稀です。なぜなら、カテゴリがインスタンス変数を持てなくても、クラス本体の@privateを含めたインスタンス変数にアクセス可能だからです。

(もっとも、XCode4.4の今、privateメンバはクラス拡張でプロパティ宣言することが基本となり、@interfaceで@privateインスタンス変数を宣言することが減ったため、インスタンス変数にアクセスできるメリットはほぼないのですが…。当然、クラス拡張で宣言したプロパティの実体である、@implementationで宣言されるインスタンス変数は直接触ることはできません)

---

カテゴリでプロパティを宣言した場合、@sythesizeは使えません。synthesizeとはインスタンス変数の宣言とsetter/getterの実装を簡略化する糖衣構文に他ならないので、インスタンス変数を宣言できないカテゴリでは、@dynamicを宣言してsetter/getterを自分で書く必要があるのです。

クラス本体のインスタンス変数を使える場合は問題になりません。例えばクラス本体が弧度法(ラジアン)で角度のプロパティを持っているとき、それを度数法に変換して取得できるdynamicプロパティをカテゴリに記述したい、などのケースです。

---

もしカテゴリで実装したい振る舞いにデータの保持が必要で、しかし何らかの理由でクラス本体のヘッダや実装ファイルにインスタンス変数を追加することができない(例えばUKitのクラスにプロパティを持たせたいなど)場合、関連参照というテクニックが使えます。

関連参照については、Appleの公式ドキュメント、「Objective-C プログラミング言語」でもちゃんと解説されているのですが、初学の時点ではおそらく理解できないので、そのままになっているのかもしれません。

「OSX v10.6以降使用可能」とありますが、iOS5現在ちゃんと動作することを確認しています。

---

関連参照とは何かというと、オブジェクトと一意のIDをキーにして、値をストアする仕組みのことです。実質的には、既存クラスに擬似インスタンス変数を追加します。

その値はインスタンスが生存している間有効であることが保証され、オブジェクトを保持(retain)することができ、その場合、インスタンスが解放されると同時に自動的に解放(release)されます。

使い方は簡単です。

まずランタイム関数を使用するので、<objc/runtime.h>をインポートします。

値の設定に使うのはobjc_setAssociatedObjectです。

objc_setAssociatedObject (
   保持したいオブジェクト(selfでOK),
   キー(staticなNSStringでOK),
   値,
   関連ポリシー(プロパティの属性に合わせて選択)
);

保持したいオブジェクトは、プロパティでは自分自身なのでselfでOKです。インスタンスメソッドでのselfは、自身のオブジェクトを指します。(クラスメソッドでのselfはメタなクラスオブジェクトなので注意です)

一意なキーはNSStringをstaticで宣言します(NSStringでなくても、voidポインタならばなんでもOKです)。UITableViewCellのreuseIdentifierと同じような感じです。

値に保持したいデータを指定します。もしintや構造体などのプリミティブ型を保持したい場合は、NSNumberやNSValueでラップする必要があります。

関連ポリシーはプロパティのretain, nonatomicなどの属性に合わせて指定します。例えば通常のオブジェクトであればOBJC_ASSOCIATION_RETAIN_NONATOMIC、文字列であればOBJC_ASSOCIATION_COPY_NONATOMICです。

この関数を実行すると値が保持されます。

値を取得するには、objc_getAssociatedObject関数を使います。引数として、値を保持しているオブジェクトと、キーを指定します。

---

エアコードですがこんな感じでプロパティを実装できます。オブジェクトの保持も、破棄もランタイムが面倒を見てくれるので、すごく簡単です。

hogeClass+AdditionProperty.h

@property (retain, nonatomic) id fuga;

---

hogeClass+AdditionProperty.m

static NSString *theKey = @"fugaKey";

@dynamic fuga;

-(void)setFuga:(id)fuga
{
    objc_setAssociatedObject (
      self, theKey, fuga, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

-(id)fuga
{
    return  objc_getAssociatedObject(self, theKey);
}

もし値を明示的に破棄したい場合にはnilをsetします。

ただしインスタンスと共に破棄されるので、その必要性に迫られることは稀でしょう。普通のインスタンス変数ですらMRCでは明示的にリリースする必要があることを考えると、擬似インスタンス変数ですが、部分的には本家を超えていますね…。

カテゴリのヘッダファイルもちゃんとimportしないと、警告が出たり、ドット構文でのアクセスができなかったりするので注意して下さい。

2012年8月24日金曜日

InterfaceBuilderを用いて再利用可能なコンポーネントを作る

「こういう風にやればできます」系の画像付き説明は多いんだけど、冗長なやり方してる場所が多い気がする。そもそもObjective-Cの基礎的な言語知識がある程度ないと、やり方を知れても、理解することはできないかもな感じですが。

xibとnibの違い

InterfaceBuilderで生成したレイアウトファイルがxib。この中身はxmlです。

以前書いたけど、xibとは単にグラフィカルレイアウトが記述されているファイルではなくて、実行時にデシリアライズされるオブジェクト群が記述されています。

コンパイル時にオブジェクトがシリアライズされたnibに変換されるのです。

nibのデシリアライズのやり方

nibを使ってViewだけを取り出したいという場合、無名ViewControllerを使うやり方と、Bundleから直接取り出すやり方があります。

1.無名ViewControllerを使う方法

ViewControllerのinitWithNibname:bundle:メソッドを用いると、UIViewControllerクラスがFile's Ownerに指定されているnibを生成することができます。

File's OwnerがUIViewControllerでないnibをこの方法で生成しようとすると、当たり前だけど失敗してエラーで落ちます。

この方法で生成した無名ViewControllerは、viewをマネジメントするためのカスタムコードが記述されていないので何もしません。releaseを忘れると、viewリソースがリークしますので、さっさとreleaseするのが基本です。

何らかのマネジメント(例えば画面回転時にviewの表示をオリエンテーションに対応させたいなど)が必要ならば、ViewControllerを記述して親ViewControllerをコンテナ化させる必要があります。

2.Bundleを使う方法

NSBundleクラスのmainBundleでアプリケーションのメインバンドルを取得できます。

initWithNibname:bundle:でも使うバンドルとは、実行可能ファイルや画像などの各種リソースをパッケージングする仕組みです。メインバンドルとは、アプリケーションの実行ファイルや設定ファイルが格納されたバンドルのことです。

無名ViewControllerなど使わなくても、Bundleから直接nibを生成することができます。そのためのメソッドがloadNibNamed:owner:options:です。

この方法ではownerに指定した任意のオブジェクトをFile's Ownerに指定できるので、ViewControllerがFile's Ownerではないnibも呼び出せます。

返り値はnibのトップレベルオブジェクトが保持されたNSArrayなので、lastObjectを使うとルートビューが取得できます。

3.nibからオブジェクトを呼び出すときの注意

nibを使った場合、使用される初期化メソッドがinitWithCoder:になります。これはオブジェクト永続化のために定められた、NSCodingプロトコルで宣言されているデシリアライズに使うメソッドです。もし追加の初期化処理が必要な場合はここに書きます。

nibから生成されたすべてのオブジェクトは、生成処理が行われたにawakeFromNibがコールされます。時々「InterfaceBuilderを使うときには、awakeFromNibで初期化を行う」という記述を見ますが、これはオブジェクト自身の初期化に使うメソッドではありません

オブジェクトのデシリアライズ順序は保証されませんので、nibからロードされた特定のオブジェクト間を関連付けるような追加の初期化作業が必要な場合に、使うメソッドです。

例えば複数種類の外観を持つnibを、ユーザーが動的に選択するようなケースに使用するメソッドです。

File's Ownerで「動的」に

「動的」はObjective-Cの鍵です。

「静的」はコンパイル時点ですべてが決定されていることを意味します。

無名ViewControllerを使うコードをやたら見ますが、必要なデリゲート処理やアウトレットの保持が、xib内部のオブジェクトで完結しているケースなのでしょう。設計の時点で「静的」に閉じているケースではそれでいいのかもしれません。

「動的」は実行時に決定されることを意味します。

File's Ownerを実行時に関連付けることで、実行時にメソッド実装を切り替えたり、あるいは呼び出すnibを実行時に選択することで、初心者向けの簡単操作バージョンと上級者向けのボタン大量バージョンを、同一のViewControllerで提供できるのです。


例)エレガントにカスタムキーボードを呼び出す

UITextFieldやUITextViewは、カスタムクラスを作り、inputViewをオーバーライドすると、カスタムキーボードを使用することができます。

カスタムキーボードのレイアウトをInterfaceBuilderでやる場合、その処理を誰にデリゲーションすればいいのでしょうか?わざわざViewControllerを用意する必要はありません。

UITextFieldなどのカスタムクラスをFile's Ownerに指定して、カスタムキーボードに必要なメソッドを実装、UIのイベントと紐付けます。

あとはinputViewで、メインバンドルからloadNibNamed:owner:options:を呼び出し、オーナーを自分自身に関連付ければいいのです。

後日、ランドスケープでもカスタムキーボードが使えるようにしたいと仕様変更が入りました。オートサイジングでは見栄えが悪いのでレイアウトを変えたいです。

どうすればいいのか分かりますよね?ランドスケープ用にxibを定義して、画面の傾きに応じてどちらのnibを呼び出すのか動的に変更するようにすればいいのです。

2012年8月22日水曜日

組み込みコンテナビューコントローラをカスタムコンテナビューコントローラに組み込むときの落とし穴

iOS3まではrootViewControllerの概念がなかったので、windowに対して直接addSubviewしていた。このときwindowのboundsから、ステータスバーの長さ20ポイント分だけoriginとheightを操作する必要があった。

iOS4以降はrootViewControllerに指定したViewControllerのviewは、ステータスバーの長さを省いたwindowの大きさに、自動的に調整されるようになった。

---

本来、最も画面の外側(すなわちコンテナの役割を持つ)ViewControllerのViewは、インターフェース要素を加味して、自身の位置を調整する必要があったのだ。

しかも厄介なのは、子viewは親viewのoriginからの相対位置であるローカル座標系で計算するのに対して、最外周ではUKit論理座標系で計算しなければならないということにある。UKit論理座標とは、(画面の向きに関わらず)スクリーン左上が原点(0,0)となる座標系のことである。

例えばビューを平行移動したいとき、ローカル座標系でframeのwidthプロパティを操作すると、デバイスの向きに関わらず左右移動になるのに対して、UKit論理座標系のframeを持つwindowのルートビューのwidthを操作すると、ポートレートでは左右移動になるが、ランドスケープでは上下移動になってしまう。

逆に言えば、ローカル座標系のおかげで画面の向きによってwidthとheightの意味が逆になるといった煩わしい問題から解放されているのである。

(現在はやるべきではないがwindowに対してaddSubviewするときに、frameではなくboundsを使ったのも、frameはUKit論理座標系なのに対して、boundsはwindowのローカル座標系になるので、向きを考慮する必要がなくなるためである)

---

この問題が組み込みビューコンテナビューコントローラとどう繋がるのかと言うと。

NavigationControllerやTabBarControllerは最外周のコンテナとして振舞うことを前提に作られているので、UKit論理座標系として自身のインターフェース要素分のマージンを考慮したframe値を設定しようとするのである。

そのため、組み込みビューコンテナを自作コンテナの子にしてしまうと、ローカル座標系に対してUKit座標系を想定した操作を行うために、以下のような現象が発生する。

  • rootViewControllerがステータスバーのマージンを設定しているにも関わらず、追加で不要なマージンを設定しようとする。
  • 実際の画面の向きと異なる方向にマージンを設定しようとする。
  • widthとheightの意味を取り違えて、ランドスケープモードにも関わらず、縦長のsizeを設定しようとする。

もっとも、実際にはrootViewControllerの特性によって、rootViewControllerのviewプロパティは、windowを満たすようにリサイズされてしまうせいなのか、顕在しにくいですが。

---

簡単に問題に遭遇するならば、自作コンテナViewControllerを作成し、組み込みコンテナビューコントローラを対象にtransitionFromViewController:toViewController:duration:options:animations:completion:で、トランジションではなく、カスタムUIViewアニメーションを実行すると、rootViewControllerの補完が働く前にどのようなframe値が設定されるのか一目瞭然となります。

またその対策として最も簡単と思われる方法は、transitionFromViewController以下略メソッドを封印して、組み込みコンテナビューコントローラのviewを直接addSubviewした上で、正しいframe値を与えることです。正しいframe値の取得は、rootViewControllerのviewのself.view.boundsを使えば問題ないはずです。

---

コンテナviewControllerは自身のviewの位置を調整する責務があり、どこかでviewの位置を調整する処理を行っています。考えられる場所はviewWill(Did)Appearや、iOS5以降ではviewWillLayoutSubviewsあたりです。

上の解決法は、

  • 直接addSubviewを行った場合、viewWill(Did)Appearが呼ばれない。
  • addSubviewによってviewWill(Did)Appearによる調整が行われた後で、frame値を変更しているため、view調整を上書きしている。
のいずれかの現象が起きているのだと当たりをつけて、実証コード(NavigationControlelrのプロトコルでコールバックメソッドをフックしてプロパティを参照)を書いてみたところ、viewWillAppearの時点ではUKit座標系の間違った情報を持っていて、viewDidAppearで修正されていました。

コールバックメソッドが呼ばれていることから1の可能性はなく(iOS5以降はほぼこれらのメソッドはコールされるようです。バグで複数回コールされることも)、Didの時点でframe値の修正が行われていることから、2の可能性も否定されました。

どういうことなんだろう。もうちょっと調査してみるかもしれず。

2012年8月20日月曜日

画面回転時にビューを画面いっぱいに表示させたい

UIViewControllerのクラスリファレンスによれば、
 If a view controller is owned by a window object, it acts as the window’s root view controller. The view controller’s root view is added as a subview of the window and resized to fill the window. If the view controller is owned by another view controller, then the parent view controller determines when and how the child view controller’s contents are displayed.

もしビューコントローラーがwindowオブジェクトによって保持される場合、それはルートビューコントローラとして動作する。windowのサブビューにaddSubviewされたビューコントローラーのルートビューは、windowを満たすようにリサイズされる。

もしビューコントローラーが他のビューコントローラーに保持される場合、親のビューコントローラーはいつどうやって子のビューコントローラーのコンテンツが表示されるかを決定する。

とあります。

----

例えばxibをルートビューコントローラーとなるよう呼び出した場合、ポートレートモードでもランドスケープモードでも、オートサイジングが機能して全画面を満たすように表示されます。

ナビゲーションコントローラーやタブバーコントローラーの子とした場合も、使える領域をフルに使えるよう、自動的にビューのサイズが調整されます。

こういう場合は何も考えなくていいです。

----

問題はカスタムコンテナUIViewControllerを使った場合です。

カスタムコンテナUIViewControllerの子コンテンツビューコントローラーとして、xibを呼び出した場合、そのままでは画面をいっぱいに使ってくれるのは、xibで指定した画面モードだけです。

ポートレートモードで画面を作った場合、ランドスケープモードでは残念なことになります。

xibはあくまで設定した画面モードの見た目だけを保持しているだけなのです。windowを満たそうとするルートビューコントローラーの特性や、ナビゲーションやタブバーが内部でリサイズしてくれるために、「xibで作ったレイアウトは当然画面を回転させれば自動でリサイズされる」と考えてしまいますが、それは間違いです。

----

なぜナビゲーションコントローラーやタブバーコントローラーは、自動的に画面サイズ調整を行なってくれるのでしょう?

それはユーザーがナビゲーションバーやタブバーなど、インターフェース要素のために必要なマージンを計算する手間を省くためでしょう。コンテンツの表示に利用可能な領域を算出する機能を持つならば、その領域にあわせてリサイズする機能も併せ持つことは合理的です。

翻ってカスタムコンテナUIViewControllerについて考えると、カスタムコンテナが他にインターフェース要素やビューを持っていて、どう子ビューをリサイズして配置するべきなのかシステムに知る術はないので、プログラマ側で配置するコードを書く必要があるわけです。

---

androidだとWRAP_CONTENTやMATCH_PARENTみたいに自動で計算してくれるオプションがあるので、iOSは凄く不便だと思う場所ですね…。

androidはリサイズのためにビュー階層を複数回探索しているので、その分速度を犠牲にしてもいます。iOSでは親が子のビューのサイズを算出するような仕組みが存在しないので、テーブルビューやポップオーバーでは専用のメソッドを用いてサイズを伝えなければならず、この辺りの一貫性のなさもあまり好きにはなれません。

----

カスタムコンテナUIViewControllerが、適切に子ビューコントローラーに回転状態を伝播させていれば、willRotateToInterfaceOrientation:duration:メソッドおよび、didRotateFromInterfaceOrientation:メソッドが呼び出されます。

ここで各々の画面の向きに応じて、子ビューがどう配置されるのかを計算して、frameプロパティを設定すればよいのです。

或いはルートビューコントローラーとなる、コンテナUIViewControllerのビューに、オートサイジング設定を施した空のUIViewを保持させて、子ビューコントローラーはそのUIViewにaddSubviewすることでどうにかなるような気もします。未確認。

----

まとめ

  • ルートビューコントローラーは常にウインドウを満たすようにリサイズする特性を持つ。
  • ナビゲーションコントローラー・タブバーコントローラーは、ナビゲーションバーなどインターフェース要素を除いた使用可能な領域を算出し、それを満たすようにリサイズしてくれる。
  • 自身でカスタムUIViewControllerをコンテナ化する場合は、子ビューコントローラーのビューがどう配置されなければならないのか記述する必要がある。画面転回時に自動的にリサイズされないので注意。

2012年8月18日土曜日

コンテナUIViewController

iOS4まではコンテナUIViewControllerは、用意されたNavigtionやTab、Splitを利用するものだったが、iOS5ではユーザーの手でカスタマイズされたコンテナUIViewControllerを作成できるようになった。

利用するパターンは、例えば次のような場合のときである。

  • NavigationやTabでは表現できない階層構造を表現したいとき
  • MasterDetailでは作成できない3ペインのアプリを作りたいとき
  • xibで生成した複数種類のコンポーネントを動的に切り替えたいとき
iOS4以前でも一画面内に複数のカスタムコンテンツUIViewControllerのviewを混在させること自体はできたが、UIViewControllerを階層化できるようになったことで、トランジションエフェクト付きでビューの遷移を行えたり、画面回転イベントなどを子のUIViewControllerに通知できるなど、利便性が大きく改善されている。

---

使い方は、UIViewControllerのクラスリファレンスより、「Implementing a Container View Controller」を読むのが手っ取り早い。

日本語であれば、UIViewControllerのコンテナ機能が、スライド資料だけどサンプルコードも付いているので、具体的な使い方が分かると思う。

英語資料でもよければ、Writing high-quality view controller containersがもっとも情報量が多い。

---

UIViewControllerをコンテナUIViewControllerにするために、特別に何かを行う必要はない。

iOS5以降では、最初からすべてのUIViewControllerはコンテンツとしても、コンテナとしても、あるいはその両方としても振舞うことができる。

---

ビューコントローラーを子要素にするためには、addChildViewControllerを使用する。

あくまでビューコントローラー間に階層が生まれるだけなので、実際に画面にビューを表示するためには、別途addSubviewでビュー間に階層を作る必要がある。

これにより、親のビューコントローラーのNSArrayプロパティ、ChildViewControllersに子ビューコントローラーが格納され、子ビューコントローラーのUIViewControllerプロパティ、parentViewControllerに親のビューコントローラーが格納される。

※parentViewControllerは、iOS4まではモーダル表示元のUIViewControllerが格納されていたプロパティなので混乱に注意。

逆に子要素のビューコントローラーを取り除くには、子のビューコントローラからremoveFromParentViewControllerを呼ぶ。

---

子ビューコントローラ間で表示を切り替えるために、専用のトランジションメソッドが用意されている。

transitionFromViewController:toViewController:duration:options:animations:completion:

fromとtoに子ビューコントローラを指定する。

  • fromのビューコントローラが保持するビューにremoveFromSuperviewが飛ぶ。
  • 変わりにtoのビューコントローラが保持するビューがaddSubviewされる。
    • fromを保持していたビューに対して、addSubviewが行われる。
  • addChildViewController、removeFromParentViewControllerは呼ばれない。
    • fromが必要なくなった場合は、completionブロックでremoveFromParentViewControllerを呼んで解放する。
  • 書く必要のないaddSubviewを書くと、警告が出るので注意。
また、durationでアニメーション時間を、optionsでいくつかのトランジションエフェクトを選択できる。

組み込みのトランジションエフェクトが気に入らなければ、animationsにカスタムアニメーションを記述できる。

記述方法はblock構文によるUIViewアニメーションと同じ。self.viewのプロパティを操作するのではなく、ここではfromとtoのviewのプロパティを操作する。

---

基本的な使い方としては、

  1. [self.childViewControllers lastObject]で直前のビューコントローラの参照を取得する
  2. [self addChildViewController:newViewController]で新しいビューコントローラを子に加える
  3. それぞれをFrom、Toとして遷移アニメーションを実行
  4. completionブロックでFromのビューコントローラを解放

といった感じ。

例えば複数ペインの画面構成など、常時複数のビューコントローラーを子として保持する場合には、lastObjectは使えないので、isKindOfClassでクラス判定を行うか、プロパティとして保持しておくのがいいと思う。

---

注意すべき点

コンテナUIViewControllerを実装する場合、
  • addChildViewController:の直後にdidMoveToParentVieController:
  • removeFromParentViewController:の直前にwillMoveToParentViewController:
を、それぞれ呼び出さなければならない(must)。
  • addChildViewController:の直前にwillMoveToParentViewController:
  • removeFromParentViewController:の直後にdidMoveToParentViewController:
は、デフォルトでは自動的に呼ばれるので、何もしなくて良い。

ただしautomaticallyForwardAppearanceAndRotationMethodsToChildViewControllersをオーバーライドして、NOを返すと上記のメソッドも自動的には呼ばれなくなるので、自分の手で明示的に呼び出す必要がある。

画面転回によるイベントを子ビューコントローラーに自動転送したくない状況が存在する場合を除いては、デフォルト設定のままで問題ないと思う。たぶん。

2012年8月15日水曜日

xibファイルとは

InterfaceBuilderで作成するxibファイルとは、単純にレイアウトを記述するものではなくて、「オブジェクトの配置」と「イベントハンドルのデリゲート」を記述するものである。

オブジェクトの配置とは、UI部品など可視オブジェクトの表示座標だけではなく、例えばテーブルビューと、そこに表示するデータソースとなるオブジェクトとを関連付けることも指す。

そしてObjective-Cの柔軟なデリゲーション機能によって、外部コントローラオブジェクトに、UI部品のイベントハンドルを委譲する。ボタンは純粋にボタンとしての機能だけを持ち、実際にユーザーがボタンを押したことで何が起きるかは、xibがロードされた時に、すなわちボタンアクションがコントローラオブジェクトのデリゲートメソッドに接続されたときに決定される。

----

この仕組みはOSX上で動いていたころを考えるととても美しいと思うんだけど、iOS上ではあまり良い仕組みじゃない気がする。

OSXは複数ウインドウを展開しやすいOSだから、一つのxibを一つのコントローラが管理するプログラムは自然なんだけど、iOSは一つのウインドウしか存在しないから、複数の画面部品(xib)を扱いたいと考えたとき、一つの画面にいくつものコントローラが混在し結合して一つの画面を構成することになり、それらをプログラム上で生存管理しなければならなくなる。

----

…と、ここまで書いて閃いたんだけど。

画面を構成するビューコントローラを一つだけ生成し、複数のxibのFile's Ownerにそのビューコントローラを指定してデリゲーションメソッドを実装、最初のコンポーネントのみawakeFromNibで生成し、その後はloadNibNamedで呼び出してOwnerを関連付けさせれば、不要なビューコントローラーの生成を回避できる気がする。

----

[XCODE] Nibファイルを複数使って一つの画面を作成する

同じことを考えている人がいた。

2012年8月14日火曜日

iOSでの画面遷移メモ

ViewController(前提知識)

iOSではMVCアーキテクチャでいうViewとControllerを一体化させた、その名もViewControllerが、ユーザーインターフェースのキーになっている。

ビューを管理するViewController(コンテンツViewController)

Viewは自身をサブビューにする(Compositeパターン)ことで階層化されており、ViewControllerはその階層化されたViewの、ルートを保持することで、階層化されたView全体を管理する。

具体的に言えば、viewdidload、viewdidunloadなどのコールバックメソッドに応じて、ビューの表示・解放を行うことで、iOSのタイトなリソースをフル活用して、リッチなインターフェースを実現させている。

UIViewControllerを継承して自作するカスタムViewControllerと、表形式でデータを表示することに特化したTableViewControllerがここに入る…のだが、iOS5で事情が変わっている(後述)。

複数のViewControllerを管理するViewController(コンテナViewController)

コンテナViewControllerは、複数のViewControllerを保持して、それらを切り替える。

ViewControllerを階層的に表示するNavigationControllerは、親子となるViewControllerの表示の切り替えを管理し、その表示履歴をスタックで保持することで、複数のレベルに分かれた画面構成を提供する。

TabBarControllerは、「階層構造を持つNavigationController」とは対極的に、独立・並列した複数のViewControllerの切り替えを、タブの操作によって提供する。

PageViewControllerは、NavigationControllerとTabBarControllerやその他画面遷移を理念どおりに使用すると、電子書籍アプリすらまともに作るのも大変という悲しい現実を前に、組み込みで追加されたページ切り替え専用のViewControllerである。

iPadで追加されたいくつかのViewController

SplitViewControllerは画面を二つに分割して、マスタと詳細を表示することでiPadの広い画面を有効活用する…という名目のビューだが、自動的に縦横画面に対応してくれるのはいいものの、制約がひどく使い勝手はよろしくない。

PopoverControllerは他のコンテンツViewControllerの表示を、ポップアップで行うことができる。基本的には、iPadではアクションシートやモーダルによる全画面トランジションは使い勝手が悪いので、その代替として利用するもの。

iOSでできる基本の画面遷移

・NavigationControllerによる階層的な画面遷移

上部にナビゲーションバーが表示され、上位階層に戻るためのボタンが提供される。何も考えなくとも階層的なアプリケーションを作れる。

・TabBarControllerによる並列的な画面遷移

タブバーが表示される。モノトーンの素材を用意するだけでオサレなタブを表示できる反面、タブの外観のカスタマイズは面倒かつ、ダーティな手法が必要。

・モーダルビューによる遷移

「現在の表示に対して割り込みを行い、ユーザーの応答を受け取る」ためにモーダルモードとして別のViewControllerの内容を表示する。本来は応答待ちのための、一時的な割り込みの遷移として存在しているが、それを守らなければもっとも単純に画面遷移を行える。

※あと内臓辞書で単語の意味を調べて表示するUIReferenceLibraryViewControllerとかもある。

iOS5で追加されたセグエの概念

storyboardという時点で、初心者を除いてどうでもいい感じのセグエは、ソースとディスティネーション二つのコンテナViewControllerを関連付けて、それぞれのViewControllerの生成と解放、そして画面遷移のアニメーションを行う。

pushセグエ、modalセグエ、reration ship、カスタムセグエがあり、pushはNavigationController、modalはモーダルビュー、reration ship(正しくはセグエではない)はTabControllerの遷移に名前をつけたもの。

ViewControllerの存在を隠蔽して画面遷移を表に出しているが、「階層ナビゲーション」「タブによる並列表示」「モーダルによる応答処理」の3つが基本であることは変わらない。

カスタムセグエは、UIStoryboaredSegueのサブクラスをカスタムすることで、独自の画面遷移を作れる。サンプルコードを見る限り、実際の画面遷移自体は、モーダルビューの遷移を使っているので、カスタムセグエのコードを書けるレベルならモーダルビューで切り替えるだけだと思う…。

iOS5で追加された自作コンテナViewController

iOSの画面遷移は「階層ナビゲーション」「タブによる並列表示」「モーダルによる応答処理」の3つに関して言えば、少ない労力で高品質なインターフェースを構築できる。

しかし「画面の一部だけを切り替えたい」となるとSplitViewControllerの面倒な制約は邪魔になることが多く、「階層でも独立でもないナビゲーションをしたい」となると、モーダルやaddSubviewの切り替えをそれらしく見せるしかなかった。

が、iOS5ではUIViewControllerが、UIViewController自身を子を持てる(コンテナとして振舞える)階層構造に変化した。

addChildViewControllerで子UIViewControllerを登録すると、viewDidLoadなどのコールバックメソッドが子に伝播するようになる。またchildViewController同士の画面をトランジション付きで切り替えることなどができる。

ページ遷移を行うPageViewControllerは、カスタムコンテナUIViewControllerの実装例に過ぎないと思う。

まとめ
  • iOSの画面遷移の基本は「階層ナビゲーション」「タブによる並列表示」「モーダルによる応答処理」の三つである。
  • ストーリーボードはかっこよく言い換えて煙に巻いているが、基本は変わらない。
  • ただそれとは別にカスタムコンテナUIViewControllerの作成が可能になり、画面遷移の自由度は大幅に増した。
  • iOS5で進化した自由な画面遷移の例として、PageViewControllerがある。

2012年8月10日金曜日

カラー状態リストリソースと、状態Drawableは組み合わせられない

デフォルトのボタンを避けたい時、画像を用意できればいいんだけど、イラストレーターやらPhotoshopを使いこなせる人がいないって時は、さくっとShape Drawableで作るのが常套手段。

「CSS3で実装されたボタン」系のサイトをカラーカタログにするといい感じのボタンが簡単に作れます。

押しても反応がないのはボタンとしては致命的欠陥なので、押されている、有効無効、フォーカスが当たっている、など必要な状態に応じて何パターンか作る必要があります。

しかしボタンの形状はどれも同じなので、色の指定だけが違う大量のxmlを作ることになります。コピペをするとDRY神の教えに反するようでとてもバツが悪い気分です。

----

状態に合わせて異なる色を示すリソースがあれば良い様に思います。

まさにそういう目的で作られているのが、カラー状態リストリソース(Color State List)です。

ボタン画像を作ったら、直接Buttonのbackgroundに指定するのではなくて、stylesリソースを経由すると思うのですが(していないのであれば、ぜひしましょう)、ボタンが無効になっているときは画像だけでなく文字も暗くなっていた方がいいですし、ハイライト時に色が変わるとより映えます。

stylesリソースで、生成したボタン画像を背景に設定する際は、一緒にカラー状態リストで文字色を指定すると、そうした状態による色の違いを簡単に表現できます。

これでボタンの色も変えられるならば、ボタンの形状を示すShapeDrawableのxml1種類と、StateDrawableのxml、そしてカラー状態リストのxmlを必要数用意すればよくなります。

----

しかし、残念ながらその組み合わせはできません。

カラー状態リストとはStateColorListインスタンスを作るための設計書に過ぎないのです。そのStateColorListは実行時に状態に応じて色を返すだけのオブジェクトであり、色として指定できる色リテラルそのものではないのです。

同様に、StateDrawableもStateListDrawableのインスタンスを生成するための設計書に過ぎませんから、xmlからボタンをinflateするその瞬間は、「状態が存在しない状態」なので、機械的にカラー状態リストの先頭の色が選ばれます。

内部構造も見てみると、StateListDrawableのパーサは、個々のDrawableを生成するためにDrawableのパーサを利用しています。Drawableのパーサに引き渡された時点で、「状態」の情報は失われているわけですね。

2012年8月7日火曜日

handleleakがうざったい。

静的解析ツールであるandroid lintは、大量の警告がぐわーっと出てきて大変うざい(特にバージョンアップで仕様変わった後とか…)んですけど、コーディングのミスなどを指摘してくれて、ものによってはクイックフィックスで自動的に解決してくれたり、Explain Issueを選ぶとなぜこの警告を出したのか、どうすれば解決できるのか教えてくれます。…英語ですけど。

iOSが静的解析でメモリリークを撲滅して、ガベージコレクタなしでのメモリ自動管理(なぜか古参開発者は自分で管理するのを好んで使わないARC)を実現しているので、androidおっくれってるー!感はなきにしもあらずですけど、まあ便利です。

---

androidにLintが本格的に組み込まれるようになってから見るようになったこの警告。

This Handler class should be static or leaks might occur

「ハンドラクラスはstaticにせよ、さもないとリークが起きるであろう」…Explain Issueを読むと、「ハンドラはstaticなinnerクラスで、かつouterクラスを弱参照で持つように修正しろ」とあります。

具体的なコードはStackOverflowに載っています。

This Handler class should be static or leaks might occur: IncomingHandler

このLint警告は気にし過ぎなくてもいい気がするんですけどねえ。

以下なんで出るかの解説です。

---

内部クラスには四種類あります。非static内部クラスと、static内部クラス、そして匿名内部クラスです。

これ意外と頭に入らないんですよね。匿名内部クラスはリスナー書いてれば自然と覚えられるとして、デザインパターンが頭に入っていけば、static内部クラスは外部クラスのインスタンスがなくても作れる(=外部クラスのインスタンスのビルダーとして使われる)、非staticは外部クラスのメンバへのアクセス手段を持つ(=アダプタやイテレータとして使われる)として自然に覚えられると思います。

ローカルクラス?覚えなくていいです。

---

非staticな内部クラスは、Effective Javaでは非推奨だったり、一部のコーディングスタイルだと使うな!とか書かれていたりします。(もちろんIteratorなど正当な使用方法の場合は別でしょうけど)

その理由としてJavaの仮想マシンの仕様があります。Java言語仕様上内部クラスと呼ばれているものは、仮想マシン上ではただのクラスとして認識しているのです。

staticを宣言した内部クラスは、外部クラスのインスタンスがなくとも独立して生成できます。なので位置づけとしては特殊なスコープの、普通のクラスです。

一方、非staticの内部クラスは必ず外部クラスのインスタンス(=エンクロージングインスタンス)と紐付けられます。エンクロージングインスタンスへの暗黙の参照(OuterClass.thisってやつです)を持つばかりか、エンクロージングインスタンスのprivateフィールドへも自由にアクセスできます。

匿名クラスはfinalなローカル変数にアクセスできること、名前がないためコンストラクタで生成することができない(例えば空のコンストラクタが絶対必要なフラグメントを匿名クラスで生成すると、再生成できないから画面を傾けるとアプリが死ぬ)という2点を除いて、非static内部クラスと同じです。

――仮想マシンからはただのクラスとしか認識されていならば、外部クラスと内部クラスは別のクラスなのに、どうやってprivateフィールドにアクセスしているのか。

その答えは単純で、コンパイラが外部クラスのprivateフィールドへアクセスするメソッドを、非static内部クラスのために自動的に生成しているのです。

暗黙の参照の存在を理解していないと、リソースリークに繋がりかねませんし、バイトコードに変換されたときに不本意にカプセル化が破られている問題があったり、まあいろいろ嫌われているわけです。

---

Handlerはandroidのキモの一つですね。LayoutInflatorとHandlerが説明できるようになれば中級者、なイメージです。自分的には。

Handlerに関する説明は世の中いくらでも分かりやすいのが転がっているんで、知らなかったら探して読んで欲しいんですけど、アプリケーションのメインループにタスクを渡すためのオブジェクトです。メインループのタスクキューに詰まれたHandlerは、タスクが実行するまで保持され続けます。

----

以上より次の事実が導けます。

  • 非static内部クラス・匿名内部クラスはエンクロージングインスタンスの暗黙の参照を持つ
  • Looperに渡されたHandlerはタスクが消化されるまで保持され続ける

さて、1時間後にタスクが起動するようなHandlerを生成した場合どうなるでしょうか?

そのころにはとっくにアプリを終了させていると思うのですが、Looperに詰まれたHandlerはそんなことを知りませんから、エンクロージングインスタンスへの暗黙の強参照を持ち続け、ガベージコレクタの回収を妨害してしまうのです。

Handlerを内部クラスとして定義したいときは、別スレッドからメインスレッド(UIスレッド)を操作したいというケースなので、エンクロージングインスタンスはメインスレッドを持つアクティビティなのが一般的です。

下手なHandlerの使い方をすると、アクティビティがメモリリークし続けるのです。

----

Android Lintが提案する解決法が理解できたと思います。

staticインナークラスにすることで、外部クラス(おそらくはアクティビティ)への暗黙の参照を持たないようにしつつ、弱参照で保持するように改修する。

そうすれば、エンクロージングインスタンスが不要になった(アクティビティが終了した)時点で、きちんとガベージコレクタの回収対象としてくれるので、Handlerによるリークは起こりえなくなるのです。

しかし、例えば画面の再描画タスクのような、エンクロージングインスタンスと内部クラスとの生存時間がほぼ等しくなるような状況で、いちいち気にして余計なコードを書く必要性はないと思うのですけど…。

2012年8月5日日曜日

androidの標準アニメーションで円を描く(2)

androidの標準アニメーションで円を描くの続きです。

---

レギュレーションを、自作Interpolatorの許可、コードでの合成許可にすると、円運動は非常に簡単に実現できます。

https://github.com/glayash/Madoka/blob/master/src/glay/ash/madoka/MainActivity.java

与えられる0.0f~1.0fの値を、0~2πに変換して、x軸とy軸をそれぞれcosとsinで算出するカスタムInterpolatorを用いて、アニメーションを合成するだけでいけます。

「カスタムInterpolatorはフレームワークが与える入力値を操作・キャッシュできる」のがポイントです。ストップボタンを設置して、それ以降は入力値を捨ててキャッシュ値を使うようにすれば、アニメーション中断が実現できます。

https://github.com/glayash/Madoka/blob/stopAnimation/src/glay/ash/madoka/MainActivity.java

停止した時点の中断値を取得して、そこをアニメーションの始点とすることで、再開も実現できそうな感じですが、大変そうなのでそこまではやりませんでした。

---

頑張れば使える子だと思う標準アニメーションなんですけど、実用方面ではやっぱり難しいところがあります。

移動中・移動後のViewの座標を取得できない(カスタムInterpolatorでフレームワークからの入力値を横取りして算出する、という小細工に頼る必要があります)、動いているのは見た目だけで、判定を動かすアニメーションは3.0以降でしか使えない、などなど。

---

あと落とし穴の一つとして、ビューの表示順序を操作できません。

どのビューが前面に描画されるかは、フレームワークがビュー階層をトラバースする順番によって決定付けられていて、ユーザーが関与することができないのです。

例えば、リストビューの要素はそれぞれ優先順位が違うので、要素同士が交差するスライドアニメーションは、意図した動きにならないと思います。

androidの標準アニメーションで円を描く

結論から言うと惜しくも単独のアニメーションリソースではダメでした…!

現場で使える[逆引き+実践] Androidプログラミングテクニック」という本で、円運動させるのに独自をViewを使っていて、「標準アニメーションは、指定の位置へ一定の時間をかけて直線的に移動することはできます」とDISられてたので、円運動くらいは標準アニメーションにもできるんじゃないの?と試してみたのです。

androidやiOS、jQueryなど手軽にアニメーションを扱えるAPI全般に言えることですが、あれらはすべてプロパティ(座標やカラーコードなど)を数学的に補完しているだけなので、望む動きをどうやって数学的に表現するかというのを考えればいいのです。

---

レギュレーションとして、API Lv1から存在する標準アニメーション(ビューアニメーション)の、最初から存在する機能のみで作ります。

複数のアニメーション要素を重ね合わせられるsetで、x軸とy軸に異なる補完関数を設定したtranslateを重ねれば、円運動くらいは簡単に作れるはずです。

しかしここで想定外の出来事が。translatetranslateを4つ以上重ねると、アニメーションが正常に機能しない…。計算が複雑になりすぎると失敗するんでしょうね…。

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:shareInterpolator="false" >

    <translate
        android:duration="1000"
        android:fromXDelta="-300%"
        android:interpolator=
       "@android:anim/accelerate_decelerate_interpolator"
        android:repeatCount="1"
        android:repeatMode="reverse"
        android:toXDelta="300%" />
    <translate
        android:duration="500"
        android:fromYDelta="0%"
        android:interpolator=
        "@android:anim/decelerate_interpolator"
        android:repeatCount="1"
        android:repeatMode="reverse"
        android:toYDelta="300%" />
    <translate
        android:duration="500"
        android:fromYDelta="0%"
        android:interpolator=
        "@android:anim/decelerate_interpolator"
        android:startOffset="1000"
        android:toYDelta="-300%" />

</set>

上記アニメーションを以下のコードで呼び出して、widthとheightが同じ長さの適当なViewを動かしてみてください。
//適当な正方形のviewを画面中央に配置
final View view = (View)findViewById(R.id.hogeview);

//適当なボタン        
Button button = (Button)findViewById(R.id.button1);
button.setOnClickListener(new OnClickListener() {
  @Override
  public void onClick(View v) {
     Animation animation = AnimationUtils.loadAnimation(MainActivity.this, R.anim.guruguru);
     textView.startAnimation(animation);
  }
});

残念ながら完全な円ではなくて、270度回転になってしまいましたが、単独の標準アニメーションリソースでも、これだけのものは作れます。リスナを使ってアニメーション同士をチェインさせれば完全な円も作れました。

一応解説すると、最初のtranslateがX軸の動きです。AccelerateDecelerateInterpolatorにより、最初と最後に減速するので、ちょうど円運動のX軸の運動になります。

二つ目のtranslateはY軸の180度分の動きです。DecelerateInterpolatorは最後に減速するので、これをreverseで2回アニメーションさせると、半円のY軸の運動になります。

三つ目のtranslateは二つ目のアニメーションの、移動方向を反転させたものです。startOffsetによって開始時間を遅延させることで、入れ替わりで機能するようにします。ここではrepeatは使えません。startOffsetの待機も繰り返してしまうためです。

これで四つ目を加えて正常に動けば、単独のアニメーションリソースで円運動できたんですけどねー。

---

X軸とY軸をバラせるので、理論的にはどんな動きでもできると思います。

特にコード上でアニメーションを生成するならば、自作の補完関数を使える(自作InterpolatorをXML上で呼び出す手段もきっとあるような気がしますが)ので、複雑奇怪な動きもいろいろできるはず。

また「タップした位置をアニメーションの基点とする」「移動先が動的に変化する」ようなアニメーションは、タップ位置を変数として持たないといけないので、コード上でのみ実現できます。

実際にコード上アニメーション生成と自作補完関数を使ってみると、円を描くのももっと簡単にできます。androidの標準アニメーションで円を描く(2)で実際試してます。

---

標準アニメーションは残念な部分も多いのですけども。

おそらくスマートフォンにとって気持ちいいUIの重要性は高いということをGoogleも考えていて、API Lv1の時点でアニメーションフレームワークを練って、端末の多様性や速度など諸々のトレードオフを熟慮しつつこういう仕様になってるんだろうなという感があります。

プロパティアニメーション一本化はまだまだ無理でしょうし、標準アニメーションを上手く使いこなすことが、リッチなアプリケーションを作るうえで重要な課題かなーと思います。

「デザイナーにも簡単に作れるjQuery」によってHTMLでもリッチな体験が可能になった今、アプリはそこを主戦場にしても厳しいのかもしれませんけどね。でもだとしたらアプリでできる差別化ってなんだろー…。

2012年8月4日土曜日

GitHub for Windows

GUIクライアントなんてどうせ使いにくいだろうし、メトロ風のUIだけ見てアンインストールしようと思って入れたんだけど、Power ShellとGit bashも付いてきたのでそのまま常用しています。

さっさと環境構築すればいい話なんですけど、面倒臭がってegitを使ってストレス貯めていたので、Windowsに一発でCUI環境作れるだけでも入れた甲斐あったかも。

GUIクライアントも意外と悪くなかったんですけど、まあそもそもGitHub使ってないので…。

いずれソースコード公開とかやりたいんですけど、小物を作って飽きるの繰り返しで、公開に耐えうるものを作れていないし、そもそも今のリポジトリは全部コミッターが本名なのでずいぶん先の話になりそう。

---

非公開リポジトリが欲しかったので、Assemblaに登録してみました。

GitHub for Windowsで、GitHub以外のサーバーとSSH接続できるのかなと思ったんですけど、Shellで特に問題なくできました。

SSH鍵はインストールした時点で勝手に生成、ユーザーフォルダの.ssh以下に保存され、特に何もしなくてもSSHの設定は終わっています。公開鍵であるgithub_rsa.pubをAssemblaに登録すれば、Git Shellが秘密鍵を使ってSSH通信してくれます。

---

sshの自動設定はPower Shellを使っているときだけなのかな?Git bashは別に設定が要りそうな感じです。デフォルトで日本語も使えて楽なので、Power Shellでいいやって感じですけど。

EclipseにSupportPackageのjavadocを設定する

SupportPackageはデフォルトではjavadocは配置されません。

javadocが読めないこと自体はまだ致命的ではないのですけど、この状態だと仮引数がarg0、arg1…という意味のない羅列になってしまうので、javadocを認識させておいた方がなにかと便利です。

方法

前提として、ADT r20以上である必要があります。r17~r19くらいの間は、ライブラリにjavadocをアタッチできない不都合があったみたいなので、バージョンアップしましょう。


やり方はここで知りました。こちらはMacですが、Winでも手順は同じです。


プロジェクトのlibsディレクトリに、android-support-v4.jar.properties(jarの拡張子も含める点に注意)というファイルを作って、srcにソースディレクトリを絶対、もしくは相対パスで指定します。

  • sdkを保存したパスのextras/android/support/v4/srcがソースディレクトリになります。
  • ディレクトリの区切りは/です。ソースにディレクトリを指定する場合、最後の/は必須です。
  • docの指定は不要です。
    • javadocコマンドで生成して付与するやり方もあるみたいですが、過去バージョンでのお話なのか、現バージョンでは自分は出来ませんでした。
  • 文字コードはUTF-8を指定しました。

これでEclipseを再起動すると、Android Dependenciesのandroid-support-v4.jarにjavadocを保持していることを示すマークが付きます。

ついでに

srcをアタッチすると、javadocだけでなく、当然ソースも見ることができます。デバッガなどでAPI内部に入ったとき「ソースが添付されていません」と出るあれがなくなります。不具合の原因の特定などに非常に便利です。

当然、androidのAPIライブラリであるandroid.jarへのソース添付も可能です。しかもICS前後からはとても簡単になりました。

SDKマネージャーでソースを落とせるので、android.jarを右クリックして、「プロパティ」の「javaソースの添付」より外部ディレクトリから「<SDKのディレクトリ>/sources/android-XX」を指定するだけです。

2012年8月3日金曜日

NotificationCompat.Builderのドキュメント

NotificationCompat.Builder addAction missing?

JerryBeansで追加される通知スタイルも早速サポートしているみたいに書いてあるけれど、未実装みたいで…。(8月1日くらい時点)

ライブラリに収録漏れしたけどドキュメントだけアップデートされるってのも変な話だけど、不都合が見つかって取り下げられたのかな。

日本語でNotificationCompat.Builderでググっても40件しか出てこない…。

---

サービスの使い方を紹介する記事で、未だにonStart(@Deprecated)使ってるコードがあってぐんにょりしたけど、それもしょうがないくらいにandroidの断絶は深刻だなー。

それを解決するはずのSupportPackageが、日本語と英語によって断絶されているせいで、いまいち日本語圏で互換性に関する情報が見つからないような…。探し方が悪いってのもあるんだろうけど。

まあこれくらいならIDEが検出してくれるんだけど、onStartCommand使うと、大昔の端末では認識しないんだっけ…。

一応onStartも書いておき、onStartCommandに@Overrideアノテーションを書かない、というのが神経質に互換性に気を使った場合の、正答だった気がする。

ほんと断絶は深刻だー。

2012年7月30日月曜日

TimeInterpolatorを使ってかんたんEasing。

Easingというのはスクロール時の変化量にアクセントを付ける関数のことで、これがあるのとないのとではUIの気持ちよさが全然変わる。

iOSと比べると、androidは組み込みのEasing関数が充実しているんだけど、あまり有効活用されているイメージがない。

----

Easing関数を扱うインターフェースであるInterpolatorは、基本的にアニメーションフレームワークの一部であって、自作View内の動きにちょっとEasingを効かせたい、というような用途には使えなかった。

しかしandroid3系からは、TimeInterpolatorにgetInterpolationというpublicメソッドが加わった。これは現在の経過時間を0~1.0fで入力すると、Easingによる補完結果を0~1.0f(Overshootなど1.0fを超える値が返ることもある)で返してくれるという超お手軽メソッドである。

private final TimeInterpolator interpolator = new DecelerateInterpolator();
private int alpha 255;
//ScheduledExecutorServiceでの定期処理を想定
@Override
public void run() {  
 alpha -= 4;
 size = 120 * interpolator.getInterpolation(1.0f - alpha/255.0f);

 if(alpha < 0){
  alpha = 255;
 }
 //ハンドラでview#invalidateを呼んでる。
 handler.sendEmptyMessage(0);
}

テキトウなコードだけど、これでEasingできてしまう。動きが気に入らなければInterpolatorを適当なのに変えるだけでいい。複雑なアニメーションロジックは隠蔽され、再利用によってお手軽に気持ちいい動作を実現できる。すばらしい。

しかし3以降限定なので、まだまだ主流の2系をハブることになってしまう。

----

android2系にはgetInterpolation()がないのか?っていうと実はそんなことはなくて、実はInterpolatorインターフェースで宣言されていました。

PropertyAnimationの導入と共にアニメーションフレームワークが書き換えられて、getInterpolation()はInterpolatorからTimeInterpolatorへ、代わりにInterpolatorがTimeInterpolatorを継承することで、今までのコードも互換性を保つように変更されました。

ですので、newした実装クラスをInterpolatorでキャストして、Interpolatorインターフェース経由で操作すれば、android2系でも動くはずです。たぶん。

----

Scroller(フリック時のスクロール量をエミュレーション)とか、Interpolatorみたいなのはフレームワーク内部のクラスだからほとんど話題にされてるのを見ないけど、実は外部からも普通に使えて、意外と便利だったりする。

2012年7月27日金曜日

androidで嘘の位置情報を端末に伝える(擬似ロケーション)

エミュレータの場合

DDMSのエミュレータコントロールのLocation Controlsから、擬似座標を送信することができるので非常に簡単です。Devicesでエミュレータを選択しないとアクティブにならないという点だけ注意。

KMLというXMLベースの記述法で、位置情報のリストを作成し読み込むことができます。これにより複数の位置情報のテストも簡単に行えます。

KMLとはKeyhole Markup Languageの略称です。KeyholeとはGoogle Earthの昔の名前のことです。なので、Google Earthでピンを立てた場所を、KMLとして出力することができます。

ただしGoogle Earthが出力するフォーマットは、DDMSが認識するフォーマットより新しいのか認識しなかったり、マルチバイト文字が含まれると文字化けや読み込み不良を起こす場合があります。

うまく読み込めなかった場合は、Google Earthが出力したKMLをシンプルに整形しましょう。coordinates要素内に、Longitude、Latitude、heightのの順に、コンマ区切りで書けば認識します。順序が緯度経度、ではなく経度緯度なところだけ注意。

実機の場合

実機で擬似位置座標を送る場合は、専用のロジックが必要になります。実装したアプリケーションはマーケットを探せばありますが、以下にどうやって擬似ロケーションを発生させるアプリを作るのか述べます。調べ中の要素があるので以下の記述は不正確な恐れがあります。というか、不正確だと思った方がいいです。

開発者オプションの設定

まず、開発者オプションにて「擬似ロケーションを有効」にします。

「擬似ロケーションを有効」にしない場合、以下に述べる方法で偽の位置情報を発生させても例外が発生して成功しません。

「擬似ロケーションを有効」の設定は、アプリケーション内から確認することができます。Settings.Secureを使います。詳細はhttp://d.hatena.ne.jp/terurou/20100930/1285814213をどうぞ。onResume()でこの設定を確認することで、位置情報偽装対策を行うことができます。(ちゃんと作られているアプリは、この対策を行っているはず)

マニフェストの設定

擬似ロケーションを生成したいアプリのマニフェストで、ACCESS_MOCK_LOCATIONのパーミッションを取得する必要があります。(ACCESS_FINE_LOCATIONもいるかもしれません。)

ACCESS_MOCK_LOCATIONは擬似ロケーションを生成するためのパーミッションです。「エミュレータで擬似情報を送信するためのパーミッション」という記述を見かけたことがありますが、DDMSから位置情報を送信するのにパーミッションは必要ありません。

LocationManagerの設定

LocationManagerをシステムサービスから取得し、addTestProviderで擬似ロケーションプロバイダを作成します。

引数が大量にありますが、重要なのは第一引数のnameだけだと思います。ここに使うプロバイダ名(GPSプロバイダのモックならば、LocationManager.GPS_PROVIDER)を渡します。

name以外の引数は、ネットワークや衛星や電話回線の使用、電力コストや正確性などのプロバイダの属性を意味しています。これらは複数のロケーションプロバイダが存在するときに、端末がどのプロバイダを優先的に使うのか判断するために存在するようです。

とりあえず真理値の部分は全部falseでも動きます。消費電力と正確性はCriteriaクラスのintの定数になので、CriteriaのReferenceを参照してください。

これでLocationManagerに擬似ロケーションのプロバイダが設定されました。

Locationの生成と設定

嘘の位置情報となるLocationオブジェクトを作ります。任意の座標をセットしてください。

そしてLocationManagerのsetTestProviderLocationに、設定したnameと、作成したLocationを渡してセットします。

擬似座標情報の取得

あとは通常の手順で位置情報を取得します。

requestLocationUpdatesに、同じくnameと、位置情報取得間隔の精度を指定するminTime、minDistance、あとはコールバックなどを設定します。

minTimeは最低再取得間隔です。本来は電源節約などのために設定するものですが、0ミリ秒で問題ないと思います。サービスなどで長期に渡って擬似座標を送信するプログラムを作る場合は、大きな数字を与えてください。

minDistanceは最低再取得距離です。単位はメーターで、直近で取得したポイントから指定したメーター以上動かないと、LocationListenerのonLocationChangedが呼ばれません。0の場合は移動なしで呼ばれるかというと、端末によるのかもしれませんが、Nexus Sでは全く同一の座標だと一度しかonLocationChangedは呼ばれませんでした。

繰り返し擬似座標を生成し続ける必要がある場合、擬似乱数でLocationにほんのわずか揺らぎを与えながら、ループなりハンドラで繰り返しrequestLocationUpdatesを呼べば問題ないと思います。

onLocationChangedの引数で受け取るLocationの値が、擬似ロケーションとして与えたダミーの値と一致していれば成功です。

後片付け

最後にremoveUpdates、removeTestProviderで擬似ロケーションプロバイダを取り除きます。

コード

以下は参考になるか不明なコード抜粋。

LocationManager manager = (LocationManager)getSystemService(LOCATION_SERVICE);
manager.addTestProvider(LocationManager.GPS_PROVIDER, false, false, false, false, false, 
 false , false, android.location.Criteria.POWER_LOW, android.location.Criteria.ACCURACY_FINE);

//本当はこの辺は別スレッドでループ回してた。
//別スレッドからLocationManagerを操作する場合、ハンドラを経由するか、
//Looperを渡すオーバロードメソッドを使用します。
Location location = new Location(LocationManager.GPS_PROVIDER);
location.setLatitude(TEST_LATITUDE);
location.setLongitude(TEST_LONGTUDE);
manager.setTestProviderLocation(LocationManager.GPS_PROVIDER, location);
manager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, this);

manager.removeLocationUpdates(LocationManager.GPS_PROVIDER);
manager.removeTestProvider(LocationManager.GPS_PROVIDER);

余談

位置情報を使うアプリを作る場合、位置情報を取得するクラスはインターフェースにしておくとテストが楽だと思います。

そうすれば手の込んだことをしなくても、モックのLocationListenerにテストしたい座標を返却させるだけで済むような気がします。