ninjinkun's diary

ninjinkunの日記

iPhoneアプリの通信エラー処理を考える - iOS Advent Calendar 2011

こんにちは。お仕事でiPhoneアプリを開発しているid:ninjinkunです。このエントリはiOS Advent Calendar 2011 23日目の記事です。今回はあまり注目されることがなさそうなiPhoneアプリのエラー処理を取り上げてみようと思います。

エラー処理と言うとプログラマが粛々とやるものというイメージで、主に内部のエラーハンドリングのことが中心になりがちです。しかしエラー処理はそれをユーザーに通知するところまで考えて初めて完結します。この記事ではユーザー体験の面と内部処理と両方に言及してみようと思います。自分の今までのアプリでもあまり実践できていなかったので、自戒の念も込めて…。

エラーは様々な状況で発生しますが、ここでは主にHTTP通信のエラーを想定します。HTTP通信はiPhoneのようなモバイル端末では高い確率で失敗します。移動中、地下鉄、山の中の中など通信が不安定なヘビーな環境で使われることが想定される為です。また、サーバー側が何らかの障害で応答できない場合もあります。このため、通信のエラーは必ず起こるという前提での設計が必要になります。

さて、理想的なエラー処理のユーザー体験とはどのようなものでしょう。書籍About Face 3のエラーの項目では以下のようなことが言われています。

エラーメッセージボックスは、プログラムがバカなので処理を停止するといっているようなものであり、避けなければならない
エラーメッセージには、究極のアイロニーがある。ユーザーがエラーを犯すのを防いではくれないのだ。

エラーメッセージが散々に言われていますね。確かに正しいのですが、瞬時にリカバリがしにくいHTTP通信ではどうしてもエラーをフィードバックする必要がある場合があります。書籍中ではエラーメッセージは最後の手段として取り上げられていますが、

エラーダイアログは、必ず礼儀正しく、わかりやすく問題点を示し、役に立つものでなければならない。

という注文がついています。そしてその中で、ユーザーに必要な情報を提供した上で、必ず一つは解決策を提示せよ(キリッと言っています。かっこいいですね。痺れますね…。

 

エラー処理プラン

これを元にして私が考えたエラー時の処理プランは以下の3つです。

  • ユーザーの操作をインタラプトせずにレジュームする
  • ユーザーの操作をインタラプトしてレジュームする
  • エラーメッセージを表示するだけ

エラーに気づかせずに粛々と進めるのが最善ですが、それができなければきちんと知らせるという感じです。しかしここでHTTP通信のことを考えると、松プランの実践は結構困難になります。HTTP通信が途切れる場合、しばらく(もしくは長時間)通信が復活しない可能性が考えられます。

また、iPhoneアプリの制約もあります。一度バックグラウンドに入ったアプリのタスクは特殊なものを除いて10分しか存続できないのです(Task Completion機能)。つまり通信を監視してレジュームしようにも、すでにアプリがバックグラウンドに入っていて処理が継続できない場合があるわけです。このような状況から、おそらく現実的な選択肢は竹プランになると思います。 

ここまでの考察を踏まえた上で、ケーススタディに入ってみようと思います。HTTPを使い、何かをPOSTするアプリケーションの中から、よくできていると評価の高いアプリを選んでみました。エラーは通信を遮断された状態を想定しています。機内モードで通信を切った場合と、通信がタイムアウトする場合と両方の環境を試しましたが、たいていのアプリでエラー処理は同じでした。*2以下の例でも両方の環境が混在しています。

Mail

メール送信が失敗すると、一旦送信ボックスに入ります。送信の失敗ダイアログなどは表示されませんが、送信ボックスのカウントが増えるので、まだ未送信のメールがあることは確認できます。このアプリはバックグラウンドにいても、通信が再開してしばらくすると再送信を試みます。純正アプリだからできる挙動ですが、松プランが的確に実装されている例と言えるでしょう。

f:id:ninjinkun:20111223102625j:plainf:id:ninjinkun:20111223102647j:plain

 Twitter

投稿に失敗すると下書きボックスに入ります。そしてアラートが出ます。下書き機能が付いているアプリは下書きをエラー時のキューとして使えるのが良いですね。投稿は特に自動で再送されたりはせず、ユーザーの操作でリカバリする必要があるので、竹プランの実装例になります。

f:id:ninjinkun:20111223094338j:plainf:id:ninjinkun:20111223094315j:plain

余談ですが、最近エラーをLocalNotificationで通知する例が増えてきました。バックグラウンドでアップロードしていても前面に通知が出せる上に、iOS5からの通知センターのお陰でそれほど邪魔ではなくなったからでしょうか。

f:id:ninjinkun:20111223095549j:plain

Facebook

POSTが失敗しても何も出ません。エラーが握りつぶされました。何のフィードバックも無くデータも消えたので、梅プランよりひどいケースであると言えそうです。タイムラインの表示が失敗した場合にリロードボタンが出るのは非常に良いと思えるだけに残念です。

f:id:ninjinkun:20111223101334j:plainf:id:ninjinkun:20111223101348j:plain

一方、Facebookメッセージアプリの方はきちんとエラー処理を実装しています。Belugaの買収が効いているのでしょうか。こちらはSMSアプリなどと同じような感じですね。ユーザーがエラーを意識してリカバリするので竹プランです。

f:id:ninjinkun:20111223100350j:plain

Instagram

アップロードキューがあり、失敗するとそこに失敗が表示されます。リカバリボタンで再投稿、諦めて削除が選べます。竹プランです。複数の写真が失敗しても、個別にリカバリできるのがポイント高いです。リッチですね。

f:id:ninjinkun:20111223104704j:plainf:id:ninjinkun:20111223105549j:plain

Path

驚きです。Pathはネットワークがない状態でも、通常と同じようにPOSTに成功したように見えます。写真はタイムラインに表示され、問題なく処理が継続されます。注意深く見ていましたが、アプリがアクティブな状態でネットワーク接続が回復すると、投稿が自動的に行われます。複数枚の写真を投稿しても問題ないです。POSTというよりはタイムラインの双方向同期のような動き方をします。つまりPathの世界では通信が不可能な状態はエラーではないと言えます。これを実装するのはアプリの内部モデルとサーバーのデータを同期する必要があって相当面倒なはず。天晴と言うしか無いですね。文句なしの松プランです。

f:id:ninjinkun:20111223095534j:plainf:id:ninjinkun:20111223095613j:plain

ネイバーフォトアルバム

国産アプリの例です。アップロードキューが実装されており、複数枚写真のアップロードに失敗しても、個別にリカバリをすることができます。竹プランですね。リッチな作りです。

f:id:ninjinkun:20111223113745j:plainf:id:ninjinkun:20111223114014j:plain

f:id:ninjinkun:20111223113749j:plain

エラー処理の実装

さて、次はこれらの処理を実装する場合を考えてみたいと思います。松プラン難しそうなので、竹プランで考えてみましょう。

iPhoneのエラー処理に使われるオブジェクトにNSExeptionとNSErrorがあります。NSExeptionは例外を通知する際に使われるオブジェクトです。try{}catch(NSExeption *e){}の文脈で使われます。NSExeptionはコード内部での例外を通知するのに使われるので、ユーザー体験に影響を及ぼすところで使うことは想定されていません。また、iOSの例外処理はJavaなどとは違い頻繁にExeptionを投げるような設計にはなっていません。

例外が発生するのはライブラリが想定外の使い方をされている、使い方が間違っている場合がほとんどです。まずはコードを修正して、発生しないようにできないかを検討してみましょう。通信エラーのエラー処理ではNSExeptionに出会うことはほとんどありません。もし例外の発生をリカバリできないようであれば、次のNSErrorに繋げるコードを書くことになります。

ユーザーにエラーを通知する場合はNSEerrorを使います。NSErrorはNSAlertViewというMacOSXでのエラーダイアログにそのまま使える構造になっています。iOSのUIAlertViewはNSErrorを直接渡すようなことはできませんが、似たようなノリで使うことができます。NSErrorのプロパティを覗いてみましょう。

  • -(NSSttring *)domain
    • エラードメインを定義します
    • システムで用意されたドメインの他にユーザー定義のドメインを使うことも出来ます
  • -(int)code
    • エラーの種類を表すコードです
    • ユーザー定義ドメインの場合はまた独自に定義します 
  • -(NSSttring *)description
    • エラーの概要を説明する文章を入れます
  • -(NSSttring *)reason
    • エラーの原因を説明する文章を入れます
  • -(NSDictionary *)userInfo
    • エラーの付随情報を格納するNSDictionaryです
    • ユーザーにエラーを表示する際にはこの中の値が重要になります
  • -(NSSttring *)localizedDescription
    • 翻訳されたエラーの概要です
    • userInfoに格納されたNSLocalizedDescriptionKeyの値へのショートカットになります
  • -(NSSttring *)localizedFailureReason
    • 翻訳されたエラーの理由です
    • userInfoに格納されたNSLocalizedFailureReasonErrorKeyの値へのショートカットになります
  • -(NSSttring *)localizedRecoverySuggestion
    • 翻訳されたエラーを回復する手段の説明です
    • userInfoに格納されたNSLocalizedRecoverySuggestionErrorKeyの値へのショートカットになります
  • -(NSSttring *)localizedRecoveryOptions
    • 翻訳されたエラーを回復するための選択肢です
    • ボタンのタイトルに使われることを想定しています
    • ボタンのタイトルをNSArrayに格納します
    • userInfoに格納されたNSLocalizedRecoveryOptionsErrorKeyの値へのショートカットになります
  • -(id)recoveryAttempter 
    • エラーを回復するために使うオブジェクトです。
    • 上のRecoveryOptionsでリカバリを選択した場合はこのオブジェクトに何かを通知するような設計にするのかな(よくわからない)
  • -(NSString *)helpAnchor;
    • ヘルプへの導線です(これもよくわからない)

 エラー通知に使える値がいろいろと格納されていることがわかります。この値を使ってUIAlertViewを作ってみると、以下のようになります。

f:id:ninjinkun:20111223223523p:plain

このアラートを生成するためにUIAlertViewを少し拡張します。

@implementation UIAlertView (NSError)
-(id)initWithError:(NSError *)error {
    self = [super init];
    if (self) {
        self.title = [error localizedDescription];
        self.message = [[NSArray arrayWithObjects:[error localizedFailureReason], [error localizedRecoverySuggestion], nil] componentsJoinedByString:@"\n"];
        NSArray* optionTitles = [error localizedRecoveryOptions];
        for (NSString *title in optionTitles) {
            [self addButtonWithTitle:title];
        }
    }
    return self;
}
@end

そしてもちろん、このアラートからもう一度再投稿処理を行うために、POST時にリクエストを保存しておく仕組みも必要になります。そのあたりはだいぶ複雑なコードになりますし、実装ごとにだいぶ変わって来ると思うので(タイムリミットも迫ってきたので)しりすぼみな感じですが割愛します。

おわりに

エラー処理とそのフィードバック、そしてリカバリはユーザー体験に大きな影響を与えます。良くできているアプリは細部に凝っているものです。最後の完成度を上げるために、がんばってリッチなエラー処理にトライしてみましょう!