この記事の内容は 第64回 Cocoa勉強会関西 と 第76回 Cocoa勉強会 関東 - OS X/iOS開発勉強会 にて発表しました。
iOSが外部アクセサリと接続できなくなるバグ
iOSにはどうやら、外部アクセサリ(ExternalAccessory)を装着したままスリープするなどして8時間程放置すると、外部アクセサリとの接続を管理するEAAccessoryManagerが"お化けアクセサリオブジェクト"を返すようになり、アクセサリとの接続が確立できなくなるバグが有るようです。
下記説明はLightningケーブルで利用するような有線接続タイプのアクセサリを前提としていますが、Bluetoothのアクセサリでも同様の事象は観測されるようです。
外部アクセサリの使い方についておさらい
現象について説明する前にまず外部アクセサリの使い方そのものを簡単に説明します。
ExternalAccessoryFrameworkの追加とプロトコルの指定
外部アクセサリを利用するためにはExternalAccessoryFrameworkをプロジェクトに追加し、接続したい外部アクセサリのプロトコルをアプリのInfo.plistへ記述します。そうすることでアプリから、Info.plistへ記載したプロトコルを持つ外部アクセサリがiOS端末に装着された時、アクセサリとアプリとの間にセッションを張り、制御することが可能になります。
アプリからプロトコルを指定してセッションを確立する
アクセサリによってはSDKが提供されており、ExternalAccessoryFrameworkの追加とInfo.plistへのプロトコルの記述だけ行えば、あとは外部アクセサリを直接制御するコーディングは意識せずに、SDKに任せてアクセサリが使えるようになる場合も多くあります。
しかしSDKがない場合、またはSDKを作る立場になった場合、自分でExternalAccessoryFrameworkを使ってプログラムを書く必要があります。
外部アクセサリを制御するためのおおまかなコード上での手順は、まずExternalAccessoryFrameworkが提供するEAAccessoryManager
から、アクセサリ情報(EASession
)を取得し、アクセサリのプロトコルを指定してiOS端末とアクセサリの間でセッション(EASession
)接続を確立し、セッションのoutputStream
に情報を書き込んだり、inputStream
から情報を読み取ったりして、アクセサリと信号を送受信することでアクセサリを制御します。
※外部アクセサリがLightningでつながっている装着されただけの状態(EAAccessoryManager の connectedAccessories
に EAAccessory
としてリストされている = Connected)と、さらにその装着された外部アクセサリとの間にアプリからセッションを張って制御が可能になった状態(ConnectedなEAAccessory
オブジェクトに対してEASession
を生成し、EASession
のInputStream
/OutputStream
を通して制御が出来る状態)は別であることに注意。日本語だとどちらも"接続"した状態と表現して混同しがち。
この記事のテーマ、"お化けアクセサリオブジェクト"とは、この過程でEAAccessoryManager
から取得したアクセサリ情報であるEAAccessory
クラスのオブジェクトが、"お化け"になっていて、そのEAAccessory
オブジェクトに対してEASession
が確立出来なくなることがある(そしてどう防ぐか?)という内容です。
怪談〜外部アクセサリが"お化け"になる〜
外部アクセサリ(ExternalAccessory
)を装着したままiOS端末をスリープして8時間程放置すると、EAAccessoryManager
の connectedAccessories
から取得した EAAccessory
が"お化け"になっていることが有ります。
なお前提として、アクセサリ機器がiOS端末に装着されている時、EAAccessoryManager
の connectedAccessories
プロパティからiOS端末に装着されているアクセサリを EAAccessory
クラスのオブジェクトとして一覧で取得できます。EAAccessory
オブジェクトは、ConnectionID、機器の名前、製造元、モデル番号、シリアルナンバー、ファームウェアバージョン、ハードウェアバージョン、プロトコルといった情報を持っています。
EAAccessory
オブジェクトは、"アクセサリをiOS端末への装着する"毎に、異なるconnectionID
を割り当てられて生成されます。つまり、同じアクセサリでも、一度iOS端末からアクセサリを取り外すと、それまでそのアクセサリを表していたEAAccessory
オブジェクトは無効となり、再び装着すると新しいconnectionID
を割り振られた新しいEAAccessory
オブジェクトとしてEAAccessoryManager
に認識されます。
EAAccessoryManager
の connectedAccessories
から取得した EAAccessory が"お化け"になっている時というのは、EAAccessory
オブジェクトからプロトコルの情報が欠落する上に、connectionID
が古いものになっています。つまり、"既に実体がない"EAAccessory
オブジェクト(=既に取り外された過去のEAAccessory
)がEAAccessoryManager
より取得されてしまいます。StackOverflowなどで、この実体がないEAAccessory
オブジェクトは"ゴースト"と呼ばれているようです。
EAAccessoryManager
から取得するEAAccessory
オブジェクトが"お化け"(ゴースト)になってしまうと、外部アクセサリとのセッションを確立するために EASession
の イニシャライザ - initWithAccessory:forProtocol:
に EAAccessory
オブジェクトを渡しても、(実体はないため) EASession
が確立できず、"iOS端末にアクセサリが装着されているにも関わらず、アプリが認識出来ず利用できない"事態となります。
8時間の間に起きていることの推理
なぜiOS端末にアクセサリをつけたままスリープ状態にして8時間程度放置すると、EAAccessoryManager
が管理するconnectedAccessories
中の EAAccessory
から魂が抜けてしまうのか。
おそらく、EAAccessoryManager
の中で管理されている、"現在接続されているアクセサリ"のリスト管理が失敗しています。
スリープ中の EAAccessoryManager
に起きること
アクセサリが新たにiOS端末に装着されたタイミング、あるいは、アクセサリがiOS端末からアクセサリが取り外されたタイミングは、EAAccessoryManager
の EAAccessoryDidConnectNotification
と EAAccessoryDidDisconnectNotification
の通知でそれぞれ検知することが出来ます。このことより、以下ではアクセサリが装着された状態をConnected、アクセサリが取り外された状態をDisconnectedと呼びます。
さらに、アプリが非アクティブな状態にある時(バックグラウンドやスリープ時)も、アクセサリはDisconnectedな状態となります。外部アクセサリとアプリは通常、アプリがフォアグラウンドでアクティブな状態にある間しかセッションを張り続けることが出来ません。アプリがバックグラウンドになったり、端末がスリープ状態に入ると、セッションは切断され、アクセサリはDisconnectedな状態となります。
アクセサリを装着しているけれども、それを利用するアプリがフォアグラウンドに無くアクセサリがDisconnectedで待機中となっている間は、時折OSからアクセサリに通電があり、接続を確認されます。この通電の時、新しくiOSからコネクションが張られ一時的にConnectedとなり、iOSがアクセサリの情報を取得し終えると、コネクションが切断されDisconnected状態となります。
ただし、アプリがバックグラウンドまたは端末がスリープ状態の時は、通電の度にこのConnectedとDisconnectedの通知がアプリのプロセスを呼び起こして処理が走るのではなく、次にアプリがフォアグラウンドかつアクティブになったタイミングで、それまでに発生したDisconnectedの通知と、最終的に現在も接続されているアクセサリについてのConnnected通知が発火します(既にDisconnectedとなったコネクションの分のConnected通知は発火しない、ハズだが、以下に続く)。
これらの通知は付帯情報として EAAccessory
を渡してくれるので、アプリはフォアグラウンド・アクティブに戻った時に、それまでの間にどの EAAccessory
が無効となり、新たに有効になったのかを知ることが出来ます。
8時間スリープ状態で放置すると?
8時間程度スリープ状態で放置した後でアプリをアクティブにして、DidConnectedとDidDisconnectedの通知に添えられて届く EAAccossory
オブジェクトを見てみると、Connected通知で届く EAAccessory
オブジェクトが持つconnectedID
と同じものが、Disconnected通知で受信した EAAccessory
オブジェクトの中にも見られます。
本来は、既にDisconnectedとなった死んだConnectionの通知は発火せず、現在もConnectionが張られている生きているconnectionID
を持った EAAccessory
のみが通知されるはずですが、Disconnectedとなった connectionID
を持った EAAccessory
オブジェクトが渡されてしまう上に、通知されるべき最新の生きているConnectionIDを持った EAAccessory
の接続が通知されないわけです。
しかもこの状態に陥ると、EAAccessoryManager
の connectedAccessories
プロパティにアクセスしても、古い connectionID
を持つ EAAccessory
しか返されません。
これはつまり、EAAccessoryManager
自体が"生きているアクセサリとのコネクション"を取り違えて、"既に死んだアクセサリとのコネクション"を保持してしまっていると考えられます。こうなるとEASession
確立のために必要な有効な EAAccessory
オブジェクトを取得する手段はありません。
リセットできないEAAccessoryMangaer
EAAccessoryManager
はシングルトンの永続インスタンスです。EAAccessory
を一度取り違えてしまった EAAccessoryManager
は、二度と正すことは出来ないようです。
EAAccessoryManager
のプライベートメソッドや EAAccessory
の着脱時に発生すると思われるような DarwinNotificaion
を適当に実行・発生させてみましたが、一度取り違えを起こした EAAccessoryManager
の connectedAccessories
をリフレッシュさせることは出来ませんでした。
※ちなみに、この状態でほかのアプリを起動して EAAccessoryManager
から connectedAccessories
を取得すると、最新のconnectionID
を持ち、EASession
が生成可能な EAAccessory
オブジェクトが取得できるので、問題となっている状態は問題が発生したアプリのサンドボックス中に存在する EAAccessoryManager
のシングルトンインスタンスの中で起きていると言えそうです(OS自体は取り違えていない)。
お化け EAAccessory
の対策
この問題は、iOS上に外部アクセサリを利用しているアプリがいない間に、アクセサリに対して繰り返し通電することでConnect/Disconnectの切り替わりが大量に発生する、というプロセスの結果として、EAAccessoryManager
が Connection
を取り違えることで発生していると考えられます。
一度取り違いを起こした EAAccessoryManager
インスタンスをリフレッシュする手段がない上に、EAAccessoryManager
がシングルトンインスタンスであり再生成が出来ない以上は、この原因となるプロセスを回避し、問題の事態に陥ることを避けるしかありません(もちろん、EAAccessoryManager
はAppleが提供するコンパイル済みのフレームワークで、内部実装を見ることが出来ないため外部から EAAccessoryManager
に対して対策を施すことは困難で、内容を改変して修正することも出来ません)。
そこで利用するのが BackgroundMode
です。
Xcodeでプロジェクトを選択し、アプリのターゲットを指定してCapabilityのタブを開くと、Background Mode
という項目があります。ここで External accessory communication
にチェックを入れると、アプリがバックグラウンドや非アクティブな状態になっても、セッションの切断、Disconnectが起きなくなります。
それによって、アプリがバックグラウンド/端末スリープ中にOSが接続を確認する通電の度に発生していたConnection/Disconnectionが発生しなくなり、EAAccessoryManager
が EAAccessory
を取り違える機会を無くすことが出来ます。
Background Mode
で対策できない Keyboard Extension
と問題発生時の復帰手順
上記に Background Mode
を利用してアクセサリのConnect/Disconnectの発生を抑制して問題の発生を回避する方法を紹介しましたが、この方法で対応できないのが Custom Keyboard Extension です。
iOS8から利用可能になった Custom Keyboard Extension では ExternalAccessoryFramework
を利用することが出来ますが、Background Mode
は利用することが出来ません。したがって、上記の方法では問題を回避することが出来ません。
問題が発生してしまった場合は、サンドボックス内に存在する EAAccessoryManager
のシングルトンインスタンスを破棄する必要があるので、手段としては、KeyboardExtensionのプロセスを殺すために、設定>一般>キーボード>キーボード 選択、フルアクセスを許可 を一度OFFにして、ONに戻す、という手順を踏む必要があります。
KeyboardExtensionから ExternalAccessory
を利用するには、必ずフルアクセスの許可が必要なのでONにした状態で利用していることになりますが、この設定のON/OFF切替のタイミングでは、必ずKeyboardExtensionのプロセスが強制終了します。そこで一度OFFにしてからONに戻すことで、プロセスごと EAAccessoryManager
のシングルトンインスタンスを破棄します。