iOSの怪談・外部アクセサリを装着したまま8時間ほど放置するとアクセサリが"お化け"になり接続できなくなる

この記事の内容は 第64回 Cocoa勉強会関西第76回 Cocoa勉強会 関東 - OS X/iOS開発勉強会 にて発表しました。

cocoa-kansai.connpass.com

connpass.com

iOSが外部アクセサリと接続できなくなるバグ

iOSにはどうやら、外部アクセサリ(ExternalAccessory)を装着したままスリープするなどして8時間程放置すると、外部アクセサリとの接続を管理するEAAccessoryManagerが"お化けアクセサリオブジェクト"を返すようになり、アクセサリとの接続が確立できなくなるバグが有るようです。

下記説明はLightningケーブルで利用するような有線接続タイプのアクセサリを前提としていますが、Bluetoothのアクセサリでも同様の事象は観測されるようです。

外部アクセサリの使い方についておさらい

現象について説明する前にまず外部アクセサリの使い方そのものを簡単に説明します。

ExternalAccessoryFrameworkの追加とプロトコルの指定

外部アクセサリを利用するためにはExternalAccessoryFrameworkをプロジェクトに追加し、接続したい外部アクセサリのプロトコルをアプリのInfo.plistへ記述します。そうすることでアプリから、Info.plistへ記載したプロトコルを持つ外部アクセサリがiOS端末に装着された時、アクセサリとアプリとの間にセッションを張り、制御することが可能になります。

f:id:niwatako:20160111220031j:plain f:id:niwatako:20160111220205j:plain

アプリからプロトコルを指定してセッションを確立する

アクセサリによってはSDKが提供されており、ExternalAccessoryFrameworkの追加とInfo.plistへのプロトコルの記述だけ行えば、あとは外部アクセサリを直接制御するコーディングは意識せずに、SDKに任せてアクセサリが使えるようになる場合も多くあります。

しかしSDKがない場合、またはSDKを作る立場になった場合、自分でExternalAccessoryFrameworkを使ってプログラムを書く必要があります。

f:id:niwatako:20160111220234j:plain

外部アクセサリを制御するためのおおまかなコード上での手順は、まずExternalAccessoryFrameworkが提供するEAAccessoryManagerから、アクセサリ情報(EASession)を取得し、アクセサリのプロトコルを指定してiOS端末とアクセサリの間でセッション(EASession)接続を確立し、セッションのoutputStreamに情報を書き込んだり、inputStreamから情報を読み取ったりして、アクセサリと信号を送受信することでアクセサリを制御します。

f:id:niwatako:20160111220314j:plain

※外部アクセサリがLightningでつながっている装着されただけの状態(EAAccessoryManager の connectedAccessoriesEAAccessory としてリストされている = Connected)と、さらにその装着された外部アクセサリとの間にアプリからセッションを張って制御が可能になった状態(ConnectedなEAAccessoryオブジェクトに対してEASessionを生成し、EASessionInputStream/OutputStreamを通して制御が出来る状態)は別であることに注意。日本語だとどちらも"接続"した状態と表現して混同しがち。

この記事のテーマ、"お化けアクセサリオブジェクト"とは、この過程でEAAccessoryManagerから取得したアクセサリ情報であるEAAccessoryクラスのオブジェクトが、"お化け"になっていて、そのEAAccessoryオブジェクトに対してEASessionが確立出来なくなることがある(そしてどう防ぐか?)という内容です。

怪談〜外部アクセサリが"お化け"になる〜

f:id:niwatako:20160111220348j:plain

外部アクセサリ(ExternalAccessory)を装着したままiOS端末をスリープして8時間程放置すると、EAAccessoryManagerconnectedAccessories から取得した EAAccessory が"お化け"になっていることが有ります。

なお前提として、アクセサリ機器がiOS端末に装着されている時、EAAccessoryManagerconnectedAccessories プロパティからiOS端末に装着されているアクセサリを EAAccessory クラスのオブジェクトとして一覧で取得できます。EAAccessory オブジェクトは、ConnectionID、機器の名前、製造元、モデル番号、シリアルナンバー、ファームウェアバージョン、ハードウェアバージョン、プロトコルといった情報を持っています。

EAAccessoryオブジェクトは、"アクセサリをiOS端末への装着する"毎に、異なるconnectionIDを割り当てられて生成されます。つまり、同じアクセサリでも、一度iOS端末からアクセサリを取り外すと、それまでそのアクセサリを表していたEAAccessoryオブジェクトは無効となり、再び装着すると新しいconnectionIDを割り振られた新しいEAAccessoryオブジェクトとしてEAAccessoryManagerに認識されます。

EAAccessoryManagerconnectedAccessories から取得した 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端末からアクセサリが取り外されたタイミングは、EAAccessoryManagerEAAccessoryDidConnectNotificationEAAccessoryDidDisconnectNotification の通知でそれぞれ検知することが出来ます。このことより、以下ではアクセサリが装着された状態をConnected、アクセサリが取り外された状態をDisconnectedと呼びます。

さらに、アプリが非アクティブな状態にある時(バックグラウンドやスリープ時)も、アクセサリはDisconnectedな状態となります。外部アクセサリとアプリは通常、アプリがフォアグラウンドでアクティブな状態にある間しかセッションを張り続けることが出来ません。アプリがバックグラウンドになったり、端末がスリープ状態に入ると、セッションは切断され、アクセサリはDisconnectedな状態となります。

アクセサリを装着しているけれども、それを利用するアプリがフォアグラウンドに無くアクセサリがDisconnectedで待機中となっている間は、時折OSからアクセサリに通電があり、接続を確認されます。この通電の時、新しくiOSからコネクションが張られ一時的にConnectedとなり、iOSがアクセサリの情報を取得し終えると、コネクションが切断されDisconnected状態となります。

ただし、アプリがバックグラウンドまたは端末がスリープ状態の時は、通電の度にこのConnectedとDisconnectedの通知がアプリのプロセスを呼び起こして処理が走るのではなく、次にアプリがフォアグラウンドかつアクティブになったタイミングで、それまでに発生したDisconnectedの通知と、最終的に現在も接続されているアクセサリについてのConnnected通知が発火します(既にDisconnectedとなったコネクションの分のConnected通知は発火しない、ハズだが、以下に続く)。

これらの通知は付帯情報として EAAccessory を渡してくれるので、アプリはフォアグラウンド・アクティブに戻った時に、それまでの間にどの EAAccessory が無効となり、新たに有効になったのかを知ることが出来ます。

8時間スリープ状態で放置すると?

f:id:niwatako:20160111220458j:plain

8時間程度スリープ状態で放置した後でアプリをアクティブにして、DidConnectedとDidDisconnectedの通知に添えられて届く EAAccossory オブジェクトを見てみると、Connected通知で届く EAAccessory オブジェクトが持つconnectedIDと同じものが、Disconnected通知で受信した EAAccessory オブジェクトの中にも見られます。

本来は、既にDisconnectedとなった死んだConnectionの通知は発火せず、現在もConnectionが張られている生きているconnectionID を持った EAAccessory のみが通知されるはずですが、Disconnectedとなった connectionID を持った EAAccessory オブジェクトが渡されてしまう上に、通知されるべき最新の生きているConnectionIDを持った EAAccessory の接続が通知されないわけです。

しかもこの状態に陥ると、EAAccessoryManagerconnectedAccessories プロパティにアクセスしても、古い connectionID を持つ EAAccessory しか返されません。

これはつまり、EAAccessoryManager 自体が"生きているアクセサリとのコネクション"を取り違えて、"既に死んだアクセサリとのコネクション"を保持してしまっていると考えられます。こうなるとEASession 確立のために必要な有効な EAAccessory オブジェクトを取得する手段はありません。

f:id:niwatako:20160111220524j:plain

リセットできないEAAccessoryMangaer

EAAccessoryManager はシングルトンの永続インスタンスです。EAAccessory を一度取り違えてしまった EAAccessoryManager は、二度と正すことは出来ないようです。

EAAccessoryManager のプライベートメソッドEAAccessory の着脱時に発生すると思われるような DarwinNotificaion を適当に実行・発生させてみましたが、一度取り違えを起こした EAAccessoryManagerconnectedAccessories をリフレッシュさせることは出来ませんでした。

※ちなみに、この状態でほかのアプリを起動して EAAccessoryManager から connectedAccessories を取得すると、最新のconnectionID を持ち、EASession が生成可能な EAAccessory オブジェクトが取得できるので、問題となっている状態は問題が発生したアプリのサンドボックス中に存在する EAAccessoryManager のシングルトンインスタンスの中で起きていると言えそうです(OS自体は取り違えていない)。

f:id:niwatako:20160111220554j:plain

お化け EAAccessory の対策

この問題は、iOS上に外部アクセサリを利用しているアプリがいない間に、アクセサリに対して繰り返し通電することでConnect/Disconnectの切り替わりが大量に発生する、というプロセスの結果として、EAAccessoryManagerConnection を取り違えることで発生していると考えられます。

一度取り違いを起こした EAAccessoryManager インスタンスをリフレッシュする手段がない上に、EAAccessoryManager がシングルトンインスタンスであり再生成が出来ない以上は、この原因となるプロセスを回避し、問題の事態に陥ることを避けるしかありません(もちろん、EAAccessoryManagerAppleが提供するコンパイル済みのフレームワークで、内部実装を見ることが出来ないため外部から EAAccessoryManager に対して対策を施すことは困難で、内容を改変して修正することも出来ません)。

f:id:niwatako:20160111220627j:plain

そこで利用するのが BackgroundMode です。

Xcodeでプロジェクトを選択し、アプリのターゲットを指定してCapabilityのタブを開くと、Background Mode という項目があります。ここで External accessory communicationにチェックを入れると、アプリがバックグラウンドや非アクティブな状態になっても、セッションの切断、Disconnectが起きなくなります。

それによって、アプリがバックグラウンド/端末スリープ中にOSが接続を確認する通電の度に発生していたConnection/Disconnectionが発生しなくなり、EAAccessoryManagerEAAccessory を取り違える機会を無くすことが出来ます。

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 のシングルトンインスタンスを破棄します。

f:id:niwatako:20160111220656j:plain