支援在各種 Android 版本上執行控制器

如果遊戲支援遊戲控制器,您有責任確保遊戲在不同 Android 版本執行的裝置上,都能穩定回應控制器。這能讓遊戲觸及更廣大的客群,玩家即使更換或升級 Android 裝置,也能使用控制器享受流暢的遊戲體驗。

本課程將示範如何以回溯相容的方式使用 Android 4.1 以上版本提供的 API,讓遊戲在搭載 Android 3.1 以上版本的裝置上支援下列功能:

  • 遊戲可以偵測是否新增、變更或移除新的遊戲控制器。
  • 遊戲可以查詢遊戲控制器的功能。
  • 遊戲可以辨識遊戲控制器傳入的動作事件。

準備抽象化遊戲控制器支援的 API

假設您想在搭載 Android 3.1 (API 級別 12) 的裝置上,判斷遊戲控制器的連線狀態是否已變更,不過,這些 API 僅適用於 Android 4.1 (API 級別 16) 以上版本,因此您需要提供支援 Android 4.1 以上版本的實作項目,同時提供支援 Android 3.1 至 Android 4.0 的備援機制。

為協助您判斷哪些功能需要較低版本的備援機制,表 1 列出 Android 3.1 (API 級別 12) 和 4.1 (API 級別 16) 之間,遊戲控制器支援的差異。

表 1. 適用於不同 Android 版本的遊戲控制器支援 API。

控制器資訊 Controller API API 級別 12 API 級別 16
裝置識別 getInputDeviceIds()  
getInputDevice()  
getVibrator()  
SOURCE_JOYSTICK
SOURCE_GAMEPAD
連線狀態 onInputDeviceAdded()  
onInputDeviceChanged()  
onInputDeviceRemoved()  
輸入事件識別 按下 D-Pad ( KEYCODE_DPAD_UPKEYCODE_DPAD_DOWNKEYCODE_DPAD_LEFTKEYCODE_DPAD_RIGHTKEYCODE_DPAD_CENTER)
按下控制器按鈕 ( BUTTON_A, BUTTON_B, BUTTON_THUMBL, BUTTON_THUMBR, BUTTON_SELECT, BUTTON_START, BUTTON_R1, BUTTON_L1, BUTTON_R2, BUTTON_L2)
搖桿和帽形開關移動 ( AXIS_XAXIS_YAXIS_ZAXIS_RZAXIS_HAT_XAXIS_HAT_Y)
類比觸發鍵按壓 ( AXIS_LTRIGGER, AXIS_RTRIGGER)

您可以使用抽象化功能,建構可跨平台運作的遊戲控制器支援功能,並因應版本調整。這種做法包含下列步驟:

  1. 定義中介 Java 介面,將遊戲所需的遊戲控制器功能實作內容抽象化。
  2. 建立介面的 Proxy 實作項目,使用 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()。 Unregisters an input device listener.
onGenericMotionEvent()
鏡子onGenericMotionEvent()。讓遊戲攔截及處理 MotionEvent 物件和軸值,這些物件和值代表搖桿移動和類比觸發按鈕按壓等事件。
onPause()
當主要活動暫停或遊戲不再是焦點時,停止輪詢遊戲控制器事件。
onResume()
在主要活動恢復時,或遊戲啟動並在前台執行時,開始輪詢遊戲控制器事件。
InputDeviceListener
會鏡像 InputManager.InputDeviceListener 介面。讓遊戲瞭解遊戲控制器何時新增、變更或移除。

接著,請為 InputManagerCompat 建立實作項目,以便在不同平台版本中運作。如果遊戲在 Android 4.1 以上版本執行,並呼叫 InputManagerCompat 方法,則 Proxy 實作會呼叫 InputManager 中的對等方法。不過,如果遊戲是在 Android 3.1 至 Android 4.0 上執行,自訂實作程序會使用不晚於 Android 3.1 推出的 API,處理對 InputManagerCompat 方法的呼叫。無論在執行階段使用哪個版本專屬的實作方式,實作方式都會將呼叫結果透明地傳回遊戲。

介面和版本專屬實作項目的類別圖。
圖 1.:介面和版本專屬實作內容的類別圖表。

在 Android 4.1 以上版本實作介面

InputManagerCompat 介面的實作項目,可將方法呼叫作業 Proxy 至實際的 InputManagerInputManager.InputDeviceListenerInputManagerCompatV16InputManager 是從系統 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 實作項目,可以使用下列物件:

  • SparseArray 的裝置 ID,用於追蹤連線至裝置的遊戲控制器。
  • Handler,用於處理裝置事件。 應用程式啟動或繼續執行時,Handler 會收到訊息,開始輪詢遊戲控制器是否已中斷連線。Handler 會開始迴圈,檢查每個已知的已連線遊戲控制器,並查看是否傳回裝置 ID。如果傳回值為 null,表示遊戲控制器已中斷連線。應用程式暫停時, Handler 會停止輪詢。
  • Map 物件的陣列。InputManagerCompat.InputDeviceListener您將使用監聽器更新追蹤遊戲控制器的連線狀態。

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);
    }
}

實作擴充 HandlerPollingMessageHandler 物件,並覆寫 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 實作方式:一種適用於搭載 Android 4.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 物件,並在主要 View 中註冊 InputManagerCompat.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);
        ...
    }
}

接著,在主要檢視區塊中覆寫 onGenericMotionEvent() 方法,如「處理遊戲控制器傳來的 MotionEvent」一節所述。現在,您的遊戲應該能在搭載 Android 3.1 (API 級別) 的裝置上,穩定處理遊戲控制器事件。

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 類別中,找到這段相容性程式碼的完整實作方式,範例 ControllerSample.zip 可供下載。