複数の Android バージョンにまたがってコントローラをサポートする

ゲームでゲーム コントローラをサポートする場合は、異なるバージョンの Android を搭載したデバイス間でもゲームがコントローラに一貫して反応するようにする必要があります。これにより、より多くのユーザーにリーチでき、プレーヤーは Android デバイスの切り替えやアップグレードを行ったとしても、コントローラでシームレスなゲームプレイ エクスペリエンスを実現できます。

このレッスンでは、Android 4.1 以降で利用可能な API を下位互換性のある方法で使用し、Android 3.1 以降を搭載したデバイスでゲームが次の機能をサポートする方法について説明します。

  • ゲーム コントローラが追加、変更、削除されたかどうかをゲームで検出する。
  • ゲームでゲーム コントローラの機能を照会する。
  • ゲーム コントローラから送信されるモーション イベントをゲームで認識する。

このレッスンの例は、上記でダウンロードできるサンプル ControllerSample.zip に用意されているリファレンス実装に基づいています。このサンプルは、さまざまなバージョンの Android をサポートする InputManagerCompat インターフェースの実装方法を示しています。サンプルをコンパイルするには、Android 4.1(API レベル 16)以降を使用する必要があります。コンパイルされたサンプルアプリは、ビルド ターゲットとして Android 3.1(API レベル 12)以降を搭載しているすべてのデバイスで実行されます。

ゲーム コントローラをサポートするために API を抽象化する準備をする

Android 3.1(API レベル 12)を搭載しているデバイスでゲーム コントローラの接続ステータスが変更されたかどうかを判断できるようにするとします。ただし、API は Android 4.1(API レベル 16)以降でのみ使用できるため、Android 4.1 以降をサポートする実装と、Android 4.0 から Android 3.1 までをサポートするフォールバック メカニズムを提供する必要があります。

以前のバージョンでそのようなフォールバック メカニズムが必要な機能を判断するのに役立つように、Android 3.1(API レベル 12)と 4.1(API レベル 16)におけるゲーム コントローラのサポートの違いを表 1 に示します。

表 1. さまざまな Android バージョンでゲーム コントローラをサポートする API。

コントローラの情報 コントローラ向け API API レベル 12 API レベル 16
デバイス ID getInputDeviceIds()  
getInputDevice()  
getVibrator()  
SOURCE_JOYSTICK
SOURCE_GAMEPAD
接続ステータス onInputDeviceAdded()  
onInputDeviceChanged()  
onInputDeviceRemoved()  
入力イベント ID D-pad の押下(KEYCODE_DPAD_UPKEYCODE_DPAD_DOWNKEYCODE_DPAD_LEFTKEYCODE_DPAD_RIGHTKEYCODE_DPAD_CENTER
ゲームパッド ボタンの押下(BUTTON_ABUTTON_BBUTTON_THUMBLBUTTON_THUMBRBUTTON_SELECTBUTTON_STARTBUTTON_R1BUTTON_L1BUTTON_R2BUTTON_L2
ジョイスティックとハットスイッチの動き(AXIS_XAXIS_YAXIS_ZAXIS_RZAXIS_HAT_XAXIS_HAT_Y
アナログ トリガーの押下(AXIS_LTRIGGERAXIS_RTRIGGER

抽象化により、プラットフォーム間で動作するバージョン対応のゲーム コントローラのサポートを構築できます。このアプローチでは次の手順を実施します。

  1. ゲームに必要なゲーム コントローラ機能の実装を抽象化する中間 Java インターフェースを定義します。
  2. Android 4.1 以降で、API を使用するインターフェースのプロキシ実装を作成します。
  3. Android 3.1 から Android 4.0 までの間で利用可能な API を使用するインターフェースのカスタム実装を作成します。
  4. ランタイムにこれらの実装を切り替えるロジックを作成し、ゲームでインターフェースの使用を開始します。

抽象化を使用して、異なるバージョンの Android 間でアプリが下位互換性のある方法で動作できるようにする方法については、下位互換性のある UI の作成をご覧ください。

下位互換性を提供するためのインターフェースを追加する

下位互換性を提供するには、カスタム インターフェースを作成してから、バージョン固有の実装を追加します。このアプローチの利点の一つは、ゲーム コントローラをサポートする Android 4.1(API レベル 16)上で、公開インターフェースをミラーリングできることです。

Kotlin

// The InputManagerCompat interface is a reference example.
// The full code is provided in the ControllerSample.zip sample.
interface InputManagerCompat {
    val inputDeviceIds: IntArray
    fun getInputDevice(id: Int): InputDevice

    fun registerInputDeviceListener(
            listener: InputManager.InputDeviceListener,
            handler: Handler?
    )

    fun unregisterInputDeviceListener(listener:InputManager.InputDeviceListener)

    fun onGenericMotionEvent(event: MotionEvent)

    fun onPause()
    fun onResume()

    interface InputDeviceListener {
        fun onInputDeviceAdded(deviceId: Int)
        fun onInputDeviceChanged(deviceId: Int)
        fun onInputDeviceRemoved(deviceId: Int)
    }
}

Java

// The InputManagerCompat interface is a reference example.
// The full code is provided in the ControllerSample.zip sample.
public interface InputManagerCompat {
    ...
    public InputDevice getInputDevice(int id);
    public int[] getInputDeviceIds();

    public void registerInputDeviceListener(
            InputManagerCompat.InputDeviceListener listener,
            Handler handler);
    public void unregisterInputDeviceListener(
            InputManagerCompat.InputDeviceListener listener);

    public void onGenericMotionEvent(MotionEvent event);

    public void onPause();
    public void onResume();

    public interface InputDeviceListener {
        void onInputDeviceAdded(int deviceId);
        void onInputDeviceChanged(int deviceId);
        void onInputDeviceRemoved(int deviceId);
    }
    ...
}

InputManagerCompat インターフェースには以下のメソッドがあります。

getInputDevice()
getInputDevice() をミラーリングします。ゲーム コントローラの機能を表す InputDevice オブジェクトを取得します。
getInputDeviceIds()
getInputDeviceIds() をミラーリングします。異なる入力デバイスの ID を示す整数の配列を返します。これは、複数のプレーヤーをサポートするゲームを作成しており、接続されているコントローラの数を検出したい場合に役立ちます。
registerInputDeviceListener()
registerInputDeviceListener() をミラーリングします。新しいデバイスが追加、変更、削除されたときに通知を受け取るよう登録できます。
unregisterInputDeviceListener()
unregisterInputDeviceListener() をミラーリングします。入力デバイス リスナーの登録を解除します。
onGenericMotionEvent()
onGenericMotionEvent() をミラーリングします。ジョイスティックの動きやアナログのトリガーの押下などのイベントを表す MotionEvent オブジェクトと軸の値を、ゲームでインターセプトして処理できるようにします。
onPause()
メイン アクティビティが一時停止されたとき、またはゲームにフォーカスがなくなったときに、ゲーム コントローラ イベントのポーリングを停止します。
onResume()
メイン アクティビティが再開されたとき、またはゲームが開始されてフォアグラウンドで実行されるときに、ゲーム コントローラ イベントのポーリングを開始します。
InputDeviceListener
InputManager.InputDeviceListener インターフェースをミラーリングします。ゲーム コントローラが追加、変更、削除されたときに、ゲームに通知します。

次に、さまざまなプラットフォーム バージョンで動作する InputManagerCompat の実装を作成します。ゲームが Android 4.1 以降で実行されている場合、InputManagerCompat メソッドを呼び出すと、プロキシ実装は InputManager の同等のメソッドを呼び出します。ただし、ゲームが Android 3.1 から Android 4.0 までで実行されている場合、カスタム実装は、Android 3.1 以降に導入された API のみを使用して InputManagerCompat メソッドの呼び出しを処理します。どのバージョン固有の実装がランタイムで使用されるかにかかわらず、この実装は呼び出し結果を透過的にゲームに返します。

図 1. インターフェースとバージョン固有の実装のクラス図

Android 4.1 以降にインターフェースを実装する

InputManagerCompatV16 は、メソッド呼び出しを実際の InputManagerInputManager.InputDeviceListener にプロキシする InputManagerCompat インターフェースの実装です。InputManager はシステム Context から取得されます。

Kotlin

// The InputManagerCompatV16 class is a reference implementation.
// The full code is provided in the ControllerSample.zip sample.
public class InputManagerV16(
        context: Context,
        private val inputManager: InputManager =
            context.getSystemService(Context.INPUT_SERVICE) as InputManager,
        private val listeners:
            MutableMap<InputManager.InputDeviceListener, V16InputDeviceListener> = mutableMapOf()
) : InputManagerCompat {
    override val inputDeviceIds: IntArray = inputManager.inputDeviceIds

    override fun getInputDevice(id: Int): InputDevice = inputManager.getInputDevice(id)

    override fun registerInputDeviceListener(
            listener: InputManager.InputDeviceListener,
            handler: Handler?
    ) {
        V16InputDeviceListener(listener).also { v16listener ->
            inputManager.registerInputDeviceListener(v16listener, handler)
            listeners += listener to v16listener
        }
    }

    // Do the same for unregistering an input device listener
    ...

    override fun onGenericMotionEvent(event: MotionEvent) {
        // unused in V16
    }

    override fun onPause() {
        // unused in V16
    }

    override fun onResume() {
        // unused in V16
    }

}

class V16InputDeviceListener(
        private val idl: InputManager.InputDeviceListener
) : InputManager.InputDeviceListener {

    override fun onInputDeviceAdded(deviceId: Int) {
        idl.onInputDeviceAdded(deviceId)
    }
    // Do the same for device change and removal
    ...
}

Java

// The InputManagerCompatV16 class is a reference implementation.
// The full code is provided in the ControllerSample.zip sample.
public class InputManagerV16 implements InputManagerCompat {

    private final InputManager inputManager;
    private final Map<InputManagerCompat.InputDeviceListener,
            V16InputDeviceListener> listeners;

    public InputManagerV16(Context context) {
        inputManager = (InputManager)
                context.getSystemService(Context.INPUT_SERVICE);
        listeners = new HashMap<InputManagerCompat.InputDeviceListener,
                V16InputDeviceListener>();
    }

    @Override
    public InputDevice getInputDevice(int id) {
        return inputManager.getInputDevice(id);
    }

    @Override
    public int[] getInputDeviceIds() {
        return inputManager.getInputDeviceIds();
    }

    static class V16InputDeviceListener implements
            InputManager.InputDeviceListener {
        final InputManagerCompat.InputDeviceListener mIDL;

        public V16InputDeviceListener(InputDeviceListener idl) {
            mIDL = idl;
        }

        @Override
        public void onInputDeviceAdded(int deviceId) {
            mIDL.onInputDeviceAdded(deviceId);
        }

        // Do the same for device change and removal
        ...
    }

    @Override
    public void registerInputDeviceListener(InputDeviceListener listener,
            Handler handler) {
        V16InputDeviceListener v16Listener = new
                V16InputDeviceListener(listener);
        inputManager.registerInputDeviceListener(v16Listener, handler);
        listeners.put(listener, v16Listener);
    }

    // Do the same for unregistering an input device listener
    ...

    @Override
    public void onGenericMotionEvent(MotionEvent event) {
        // unused in V16
    }

    @Override
    public void onPause() {
        // unused in V16
    }

    @Override
    public void onResume() {
        // unused in V16
    }

}

Android 3.1 から Android 4.0 までにインターフェースを実装する

Android 3.1 から Android 4.0 までをサポートする InputManagerCompat の実装を作成するには、以下のオブジェクトを使用します。

  • デバイスに接続されているゲーム コントローラを追跡するためのデバイス ID の SparseArray
  • デバイスのイベントを処理するための Handler。アプリが起動または再開されると、Handler はメッセージを受信して、ゲーム コントローラの切断のポーリングを開始します。Handler は、接続されている既知のゲーム コントローラをそれぞれチェックするループを開始し、デバイス ID が返されたかどうかを確認します。null の戻り値は、ゲーム コントローラが切断されたことを示します。アプリが一時停止されると、Handler はポーリングを停止します。
  • InputManagerCompat.InputDeviceListener オブジェクトの Map。リスナーを使用して、トラッキングされたゲーム コントローラの接続ステータスを更新します。

Kotlin

// The InputManagerCompatV9 class is a reference implementation.
// The full code is provided in the ControllerSample.zip sample.
class InputManagerV9(
        val devices: SparseArray<Array<Long>> = SparseArray(),
        private val listeners:
        MutableMap<InputManager.InputDeviceListener, Handler> = mutableMapOf()
) : InputManagerCompat {
    private val defaultHandler: Handler = PollingMessageHandler(this)
    …
}

Java

// The InputManagerCompatV9 class is a reference implementation.
// The full code is provided in the ControllerSample.zip sample.
public class InputManagerV9 implements InputManagerCompat {
    private final SparseArray<long[]> devices;
    private final Map<InputDeviceListener, Handler> listeners;
    private final Handler defaultHandler;
    …

    public InputManagerV9() {
        devices = new SparseArray<long[]>();
        listeners = new HashMap<InputDeviceListener, Handler>();
        defaultHandler = new PollingMessageHandler(this);
    }
}

Handler を拡張する PollingMessageHandler オブジェクトを実装し、handleMessage() メソッドをオーバーライドします。このメソッドは、接続されたゲーム コントローラが切断されたかどうかを確認し、登録済みのリスナーに通知します。

Kotlin

private class PollingMessageHandler(
        inputManager: InputManagerV9,
        private val mInputManager: WeakReference<InputManagerV9> = WeakReference(inputManager)
) : Handler() {

    override fun handleMessage(msg: Message) {
        super.handleMessage(msg)
        when (msg.what) {
            MESSAGE_TEST_FOR_DISCONNECT -> {
                mInputManager.get()?.also { imv ->
                    val time = SystemClock.elapsedRealtime()
                    val size = imv.devices.size()
                    for (i in 0 until size) {
                        imv.devices.valueAt(i)?.also { lastContact ->
                            if (time - lastContact[0] > CHECK_ELAPSED_TIME) {
                                // check to see if the device has been
                                // disconnected
                                val id = imv.devices.keyAt(i)
                                if (null == InputDevice.getDevice(id)) {
                                    // Notify the registered listeners
                                    // that the game controller is disconnected
                                    imv.devices.remove(id)
                                } else {
                                    lastContact[0] = time
                                }
                            }
                        }
                    }
                    sendEmptyMessageDelayed(MESSAGE_TEST_FOR_DISCONNECT, CHECK_ELAPSED_TIME)
                }
            }
        }
    }
}

Java

private static class PollingMessageHandler extends Handler {
    private final WeakReference<InputManagerV9> inputManager;

    PollingMessageHandler(InputManagerV9 im) {
        inputManager = new WeakReference<InputManagerV9>(im);
    }

    @Override
    public void handleMessage(Message msg) {
        super.handleMessage(msg);
        switch (msg.what) {
            case MESSAGE_TEST_FOR_DISCONNECT:
                InputManagerV9 imv = inputManager.get();
                if (null != imv) {
                    long time = SystemClock.elapsedRealtime();
                    int size = imv.devices.size();
                    for (int i = 0; i < size; i++) {
                        long[] lastContact = imv.devices.valueAt(i);
                        if (null != lastContact) {
                            if (time - lastContact[0] > CHECK_ELAPSED_TIME) {
                                // check to see if the device has been
                                // disconnected
                                int id = imv.devices.keyAt(i);
                                if (null == InputDevice.getDevice(id)) {
                                    // Notify the registered listeners
                                    // that the game controller is disconnected
                                    imv.devices.remove(id);
                                } else {
                                    lastContact[0] = time;
                                }
                            }
                        }
                    }
                    sendEmptyMessageDelayed(MESSAGE_TEST_FOR_DISCONNECT,
                            CHECK_ELAPSED_TIME);
                }
                break;
        }
    }
}

ゲーム コントローラの切断のポーリングを開始および停止するには、次のメソッドをオーバーライドします。

Kotlin

private const val MESSAGE_TEST_FOR_DISCONNECT = 101
private const val CHECK_ELAPSED_TIME = 3000L

class InputManagerV9(
        val devices: SparseArray<Array<Long>> = SparseArray(),
        private val listeners:
        MutableMap<InputManager.InputDeviceListener, Handler> = mutableMapOf()
) : InputManagerCompat {
    ...
    override fun onPause() {
        defaultHandler.removeMessages(MESSAGE_TEST_FOR_DISCONNECT)
    }

    override fun onResume() {
        defaultHandler.sendEmptyMessageDelayed(MESSAGE_TEST_FOR_DISCONNECT, CHECK_ELAPSED_TIME)
    }
    ...
}

Java

private static final int MESSAGE_TEST_FOR_DISCONNECT = 101;
private static final long CHECK_ELAPSED_TIME = 3000L;

@Override
public void onPause() {
    defaultHandler.removeMessages(MESSAGE_TEST_FOR_DISCONNECT);
}

@Override
public void onResume() {
    defaultHandler.sendEmptyMessageDelayed(MESSAGE_TEST_FOR_DISCONNECT,
            CHECK_ELAPSED_TIME);
}

入力デバイスの追加を検出するには、onGenericMotionEvent() メソッドをオーバーライドします。システムからモーション イベントが報告された場合は、そのイベントがすでにトラッキングされているデバイス ID から発生したものか、新しいデバイス ID から発生したものかを確認します。新しいデバイス ID からである場合は、登録済みのリスナーに通知します。

Kotlin

override fun onGenericMotionEvent(event: MotionEvent) {
    // detect new devices
    val id = event.deviceId
    val timeArray: Array<Long> = mDevices.get(id) ?: run {
        // Notify the registered listeners that a game controller is added
        ...
        arrayOf<Long>().also {
            mDevices.put(id, it)
        }
    }
    timeArray[0] = SystemClock.elapsedRealtime()
}

Java

@Override
public void onGenericMotionEvent(MotionEvent event) {
    // detect new devices
    int id = event.getDeviceId();
    long[] timeArray = mDevices.get(id);
    if (null == timeArray) {
        // Notify the registered listeners that a game controller is added
        ...
        timeArray = new long[1];
        mDevices.put(id, timeArray);
    }
    long time = SystemClock.elapsedRealtime();
    timeArray[0] = time;
}

リスナーの通知を実装するには、Handler オブジェクトを使用して、DeviceEvent Runnable オブジェクトをメッセージ キューに送信します。DeviceEvent には InputManagerCompat.InputDeviceListener への参照が含まれています。DeviceEvent が実行されるとリスナーの適切なコールバック メソッドが呼び出され、ゲーム コントローラが追加、変更、削除されたかどうかが通知されます。

Kotlin

class InputManagerV9(
        val devices: SparseArray<Array<Long>> = SparseArray(),
        private val listeners:
        MutableMap<InputManager.InputDeviceListener, Handler> = mutableMapOf()
) : InputManagerCompat {
    ...
    override fun registerInputDeviceListener(
            listener: InputManager.InputDeviceListener,
            handler: Handler?
    ) {
        listeners[listener] = handler ?: defaultHandler
    }

    override fun unregisterInputDeviceListener(listener: InputManager.InputDeviceListener) {
        listeners.remove(listener)
    }

    private fun notifyListeners(why: Int, deviceId: Int) {
        // the state of some device has changed
        listeners.forEach { listener, handler ->
            DeviceEvent.getDeviceEvent(why, deviceId, listener).also {
                handler?.post(it)
            }
        }
    }
    ...
}

private val sObjectQueue: Queue<DeviceEvent> = ArrayDeque<DeviceEvent>()

private class DeviceEvent(
        private var mMessageType: Int,
        private var mId: Int,
        private var mListener: InputManager.InputDeviceListener
) : Runnable {

    companion object {
        fun getDeviceEvent(messageType: Int, id: Int, listener: InputManager.InputDeviceListener) =
                sObjectQueue.poll()?.apply {
                    mMessageType = messageType
                    mId = id
                    mListener = listener
                } ?: DeviceEvent(messageType, id, listener)

    }

    override fun run() {
        when(mMessageType) {
            ON_DEVICE_ADDED -> mListener.onInputDeviceAdded(mId)
            ON_DEVICE_CHANGED -> mListener.onInputDeviceChanged(mId)
            ON_DEVICE_REMOVED -> mListener.onInputDeviceChanged(mId)
            else -> {
                // Handle unknown message type
            }
        }
    }

}

Java

@Override
public void registerInputDeviceListener(InputDeviceListener listener,
        Handler handler) {
    listeners.remove(listener);
    if (handler == null) {
        handler = defaultHandler;
    }
    listeners.put(listener, handler);
}

@Override
public void unregisterInputDeviceListener(InputDeviceListener listener) {
    listeners.remove(listener);
}

private void notifyListeners(int why, int deviceId) {
    // the state of some device has changed
    if (!listeners.isEmpty()) {
        for (InputDeviceListener listener : listeners.keySet()) {
            Handler handler = listeners.get(listener);
            DeviceEvent odc = DeviceEvent.getDeviceEvent(why, deviceId,
                    listener);
            handler.post(odc);
        }
    }
}

private static class DeviceEvent implements Runnable {
    private int mMessageType;
    private int mId;
    private InputDeviceListener mListener;
    private static Queue<DeviceEvent> sObjectQueue =
            new ArrayDeque<DeviceEvent>();
    ...

    static DeviceEvent getDeviceEvent(int messageType, int id,
            InputDeviceListener listener) {
        DeviceEvent curChanged = sObjectQueue.poll();
        if (null == curChanged) {
            curChanged = new DeviceEvent();
        }
        curChanged.mMessageType = messageType;
        curChanged.mId = id;
        curChanged.mListener = listener;
        return curChanged;
    }

    @Override
    public void run() {
        switch (mMessageType) {
            case ON_DEVICE_ADDED:
                mListener.onInputDeviceAdded(mId);
                break;
            case ON_DEVICE_CHANGED:
                mListener.onInputDeviceChanged(mId);
                break;
            case ON_DEVICE_REMOVED:
                mListener.onInputDeviceRemoved(mId);
                break;
            default:
                // Handle unknown message type
                ...
                break;
        }
        // Put this runnable back in the queue
        sObjectQueue.offer(this);
    }
}

InputManagerCompat の実装が 2 つになりました。1 つは Android 4.1 以降を搭載するデバイスで動作し、もう 1 つは Android 3.1 から Android 4.0 までを搭載しているデバイスで動作します。

バージョン固有の実装を使用する

バージョン固有の切り替えロジックは、ファクトリとして機能するクラスに実装されます。

Kotlin

object Factory {
    fun getInputManager(context: Context): InputManagerCompat =
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                InputManagerV16(context)
            } else {
                InputManagerV9()
            }
}

Java

public static class Factory {
    public static InputManagerCompat getInputManager(Context context) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
            return new InputManagerV16(context);
        } else {
            return new InputManagerV9();
        }
    }
}

これで、単純に InputManagerCompat オブジェクトをインスタンス化し、メインの ViewInputManagerCompat.InputDeviceListener を登録できるようになりました。設定したバージョン切り替えロジックにより、デバイスに搭載されている Android のバージョンに適した実装が自動的に使用されます。

Kotlin

class GameView(context: Context) : View(context), InputManager.InputDeviceListener {
    private val inputManager: InputManagerCompat = Factory.getInputManager(context).apply {
        registerInputDeviceListener(this@GameView, null)
        ...
    }
    ...
}

Java

public class GameView extends View implements InputDeviceListener {
    private InputManagerCompat inputManager;
    ...

    public GameView(Context context, AttributeSet attrs) {
        inputManager =
                InputManagerCompat.Factory.getInputManager(this.getContext());
        inputManager.registerInputDeviceListener(this, null);
        ...
    }
}

次に、ゲーム コントローラから MotionEvent を処理するの説明に沿って、メインビューで onGenericMotionEvent() メソッドをオーバーライドします。Android 3.1(API レベル 12)以降を搭載しているデバイスで、ゲーム コントローラ イベントを一貫して処理できるようになりました。

Kotlin

override fun onGenericMotionEvent(event: MotionEvent): Boolean {
    inputManager.onGenericMotionEvent(event)

    // Handle analog input from the controller as normal
    ...
    return super.onGenericMotionEvent(event)
}

Java

@Override
public boolean onGenericMotionEvent(MotionEvent event) {
    inputManager.onGenericMotionEvent(event);

    // Handle analog input from the controller as normal
    ...
    return super.onGenericMotionEvent(event);
}

この互換性コードの完全な実装は、上記でダウンロード可能なサンプル ControllerSample.zip で提供されている GameView クラスにあります。