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からデバッグ実行したとき以外でも、端末に残されたログから有用な情報が得られます。