2013年9月6日金曜日

UIAppearanceの使い方

iOS6の時点の知識です。ついでに半年ほどAndroidしかやってないです。

iOS7でどう変わるんでしょう。試しにDeveloperプレビュー落としたら、手元のlionでは動かなかった。さすがAppleさん。

---

iOS5からUIAppearance Protocolが追加され、ほとんどのUI部品が準拠しました。

これはAppearanceプロキシの提供を宣言するプロトコルです。Appearanceプロキシに対して背景画像やテキストの属性を指定すると、同一クラスのUI部品全てに変更を加えられます。

フレームワークのUI部品に対して直接使用しない方が無難

チュートリアルではUINavigationBarなどを直接書き換えていますが、これは予期せぬ結果を招くことがあります。

例えば、UINavigationBarを書き換えると、UIPopover下で表示されるNavigationBarも変更されてしまいます。UIPopoverの外観を変更する手段は提供されておらず、残念な見栄えになること請け合いです。

+appearanceWhenContainedIn:メソッドを使用して、UIPopoverとして表示されたときには変更を無かったことにするという手法も考えられます。-setBackgroundImage:を書き換えても、nilで上書きすれば元に戻すことができます。

しかしそんな小細工をするくらいなら、最初からUINavigationBarのカスタムなサブクラスを作り、そのクラスのAppearanceプロキシを操作した方が良いと思います。

カスタムなUINavigationBarを用意したならば、それを利用するUINavigationControllerサブクラスも作成する必要があるのでは?と思うところですが、iOS5からUINavigationControllerの生成メソッドとして、任意のUINavigationBarおよびUIToolBarサブクラスを指定できる-initWithNavigationBarClass:toolbarClass:が追加されています。

またカスタムサブクラスを使うメリットとして、Appearance操作の影響範囲を自分のアプリのみに限定することができます。外部のSDK等が出した画面まで書き換わる事態を防ぐことができます。

UIAppearanceを操作するタイミング

大前提として、Viewが表示される前に操作する必要があります。

UIViewControllerのコールバックメソッド、-viewDidLoadで直接Viewに対して-setTintColor:などを実行した場合、その場で変更が適用されますが、Appearanceプロキシを通して加えた変更は、再度-loadViewが実行されないと適用されません。

そのため、より上位のApplicationのスコープ、-application:willFinishLaunchingWithOptions:のタイミングで行うのが一つ目の選択肢です。常套手段としてはこちらになります。

もう一つの選択肢として、当該のViewクラスがObjective-Cランタイムに読み込まれた時点で行う手があります。つまり+loadまたは+initalizeをオーバーライドして、Appearance操作を行います。

後者の方法には、独自のサブクラスを経由して操作する、という規約を作る場合、そのクラス内部に外観変更操作が隠蔽されるというメリットがあります。

しかし、Appearanceプロキシに対する操作を特定のクラスの責務とした方が、規模が大きくなった場合の混乱が小さいかもしれません。

UIButtonへのAppearance操作について

ボタンのサイズは文字列長に応じて変わるため、通常不定です。

-resizableImageWithCapInsets:resizingMode:でマージンと拡縮方式を指定して背景画像とすることで、リサイズに対応したカスタムボタンを作れます。用意するのは一つの画像と、Appearanceプロキシを操作する数行のコードだけです。

しかもどっかの9Patchと違って、パターンの繰り返しに対応しています。いまや完全にどっかの9patchの上位互換です。(ただしiOS6以上限定)

例えば、HGDetermineButtonやらHGCancelButtonのようなサブクラスを作成し、個々の見栄えを変更することで、-loadViewから装飾のためのコードが一掃されます。生成するクラス名から各ボタンの役割も明確となります。

xibを使っている場合は、UIButonを配置した後で、ボタンのクラスを変更するだけです。

ただしAppearanceプロキシによる変更は実行時に行われるため、InterfaceBuilder上ではただのRoundRectButtonとして表示されるので、xibを見ただけでは実際の表示が分からないという事態を引き起こしますが…。

ボタンのLabelを変更する場合、Appearanceプロキシに対してsetTitleColor:forState:で行います。

---

余談として、UIButton内のラベルはUILabelで表示している、ということを利用して、

[[[UILabel class] appearanceWhenContainedIn:[HogeButton class], nil] setTextColor:[UIColor redColor]];

のような記述ができると考えるかもしれませんが、このコードは無視されます。setTitleColor:forState:で設定された色をボタンに適用するタイミングより、前に実行されてしまうためでしょうか。

しかし、2011年の10月に書かれたUIAppearance で色や画像を変える - Cocoaの日々ではUILabelの色を操作するとボタンの色も変わると書いてある(iOS6では変わりません)ので、iOS5.0時点だと挙動が変わるかもしれません。

どの属性がUIAppearanceに対応しているのか?

UIAppearanceが使いにくい理由の9割は、Appearanceプロキシに変更を加えたとして、実際にその処理が適用されるかどうかが分からない点です。

「UIAppearance Protocolに準拠している」という事実は+appearanceメソッドでAppearanceプロキシを返却することを宣言しているだけで、実際どのプロパティ、どのメソッドがプロキシ経由の操作を受け付けるのかについては分からないのです。

これについては当該Viewのヘッダを見て、プロパティやメソッドの定義にUI_APPEARANCE_SELECTORが付いているか確認する、という手段しかないと思います。

あれ、さっき使ったsetTitle:forState:にはUI_APPEARANCE_SELECTORは付いてなかったんですけど…。

---

このUI_APPEARANCE_SELECTORは、UI Appearance Proxy APIに参加していることを宣言するだけで、実際に何らかの機能を伴っているわけではないようです。

なぜか抜けているプロパティやメソッドもあるため、書いていないのに機能するケースもあります。

しかもリファレンスにはどのメソッド/プロパティがAppearance Proxy API対応しているのかの記述はなく、またバージョンによって対応箇所が増えている一方、API Diffsでは確認できないようです。

もしiOS5対応が必要な場合はiOS5のframeworkのヘッダを読むわけですが、しかしそのヘッダの情報が正しいという保証もなく、結局のところiOS5実機を用意して確認するのが一番確実でしょう。

UIAppearanceContainerプロトコルの意味は?

UIAppearance準拠クラスを見ると、UIAppearanceContainerプロトコルにも準拠しているものがあると思うんですけど、何も宣言していないんですよね。

何かのマーカーだったと思いますけど、忘れました。

結局のところUIAppearanceは使うべきなのか?

プロトタイプをさくっと完成させるのには超便利だと思います。

しかしプロダクションコードで使うには、リファレンスに書いてないブラックボックスが怖すぎるのと、そもそも国内開発者でこの機能を把握してる人の割合が謎で、多人数開発ではとても怖くて使えないかなぁ…。

2013年9月1日日曜日

Geocoderクラスは確実に使えるとは限らない

Androidにはジオコーディング/逆ジオコーディング(地理座標から住所の逆引きなど)を行うことのできるandroid.location.Geocoderというクラスが用意されています。

Google APIs付きのSDKでビルドするだけで利用できるので、非常にお手軽なのですが、Geocoderはプロキシクラスで、実際の処理を行っているのはバックエンドで動作しているLocationManagerServiceとなります。

このLocationManagerServiceが何らかの理由で死ぬ(メモリや電源が極端に消耗するとシステムが自動的に殺すことがある)と、端末を再起動するまでGeocoderは使用不可能になります。この状況でもGeocoder.isPresent()はtrueを返すので、実行するまで成功するかどうか分かりません。

Service not AvailableというメッセージのIOExceptionで失敗した場合、大体これのせいです。

どうもLocationManagerServiceをServiceManagerに登録にする処理が、端末起動時のSystemServer起動時に一度だけ行われるらしく、LocationManagerServiceが死ぬ事態を想定していないのが原因っぽいのですが…。

自力で復帰させるコードを書くことは可能なんでしょうか。まあ仮にできたとしても、今後Androidの起動プロセスが不変だと保証することができない以上、やるべきではないんでしょうけども。

---

そんなわけで、確実性に欠けるGeocoderクラスは使わない方がいいと思います。

代替として、Web APIとして用意されているThe Google Geocoding APIを使うことができます。

このGeocoding API v3の呼び出しをJavaから行えるラッパーライブラリ、Java API for Google geocoder v3も提供されているのですが、やっていることは結局maps/api/geocode/jsonを叩いて結果のJSONをパースするだけなので、自前で軽量な実装を書いた方がいいと思います。

なお、APIの使用制限として、2500リクエスト/日という制限が書いてありますが、その他に1リクエスト/1~2秒程度の制限もあるようで、2件以上の住所データを同時に変換しようとすると失敗します。もし複数件の住所を処理する必要がある場合、ExecutorService等で制御する必要があります。