[Flutter] MethodChannelでAndroidのボリュームキーを検知する

 スマホアプリは画面をタップして操作するのが基本ですが、次々と画面を切り替えたり、手さぐりで咄嗟に何かしたいときは、物理ボタンを使いたい。スマホに付いてる物理ボタンと言えばボリュームキーだが、これをFlutterからすんなり検知できなくて悩んでた。
 
 ## HardwareKeyboardクラスで困ること
 
 Flutterでキーボード入力を検知するには、HardwareKeyboardクラスを使うのが定番みたいだが、Widgetにフォーカスが当たってないと検知できないことがあって、入力欄を一回タッチしないと反応しない。これが使いにくい。

Android 8あたりでは取れてたようだが、いつからかこうなっていてAndroid15でも同じ動きをする。

 https://www.youtube.com/watch?v=PsWhNdfp2UU

## MethodChannelで検知する

MethodChannelはFlutterとOSのKotlinで同じ名前のChannelオブジェクトを作成し、それを使って互いのメソッドを呼び出す仕組みだ。通常FlutterからOS機能を呼び出すのに使うが、逆にOS側からFlutterを呼び出すこともできる。これを利用して、OS側のKotlinプログラムがキーを検知するたびにFlutterのメソッドを呼び出すように設定してやる。

以下はFlutter新規作成したデフォルトのアプリにMethodChannelを設定する方法の説明。

### Kotlinの設定

package定義はそのままにして、MainActivity.ktを次のように書き換える。

1.FlutterEngine, MethodChannel, FlutterActivityなどのインポートを追加する。
2.MethodChannelオブジェクト変数を定義する。
3.configureFlutterEngine()をオーバーライドして、MethodChannelオブジェクト変数の作成を行う。
4.MainActivityにdispatchKeyEventメソッドを追加して、キーイベントが発生したら、mChannel.invokeMethodでFlutterのメソッドを呼び出すようにする。

結果は次のようになる。

```kotlin
package jp.picpie.keytest  // パッケージはそれぞれに合わせる

import android.view.KeyEvent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {

    private lateinit var mChannel: MethodChannel

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        mChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jp.picpie.keytest/") // Channel名はFlutterと合わせる
    }

    override fun dispatchKeyEvent(event: KeyEvent): Boolean {

		if (event.action == KeyEvent.ACTION_DOWN) {
			if (::mChannel.isInitialized) { // チャネルが初期化されているかを確認
				when (event.keyCode) {
					KeyEvent.KEYCODE_VOLUME_UP -> {
						mChannel.invokeMethod("onVUP", null)
						return true
					}
					KeyEvent.KEYCODE_VOLUME_DOWN -> {
						mChannel.invokeMethod("onVDOWN", null)
						return true
					}
				}
			} else {
				println("MethodChannel is not initialized.")
			}
		}

        return super.dispatchKeyEvent(event)
    }
}
```

### Flutter側の設定

1.import 'package:flutter/services.dart'; を追加。
2.Stateオブジェクトに  static const mChannel = MethodChannel("jp.picpie.keytest/");を追加。MethodChannelのパラメータはMainActivity.ktのチャネル名と同じにする。
3.initStateにmChannel.setMethodCallHandler(platformCallHandler);を追加。
4.disposeをオーバーライドしてmChannel.setMethodCallHandler(null);を追加。
5.以下のメソッドを追加する。
```dart
  void decrementCounter() {
    setState(() { counter--; });
  }
  void incrementCounter() {
    setState(() { counter++; });
  }

  Future<void> platformCallHandler(MethodCall call) async {
    switch (call.method) {
      case 'onVUP':
        decrementCounter();
        break;
      case 'onVDOWN':
        incrementCounter();
        break;
      default:
        debugPrint('Unknowm method ${call.method} ${call.arguments}');
        throw MissingPluginException();
    }
  }
```
platformCallHandlerがKotlinからの呼び出しに応答するメソッドである。MethodCallオブジェクトのパラメータcallに、Kotlin側でinvokeMethodしたときのパラメータが格納されている。call.methodの内容をみれば、ボリュームアップかボリュームダウンかを判定できる。case 'onVUP': case 'onVDOWN':のブロックで、それぞれのキーが押されたときに処理したい内容を書けばよい。上の例ではボリュームアップキーでdecrementCounter()が、ボリュームダウンキーでincrementCounter()が呼び出されている。

最終的なmain.dartの全体は以下のとおり。
```dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  static const mChannel = MethodChannel("jp.picpie.keytest/");

  int counter = 0;

  @override
  void initState() {
    super.initState();
    mChannel.setMethodCallHandler(platformCallHandler);
  }

  @override
  void dispose() {
    mChannel.setMethodCallHandler(null);
    super.dispose();
  }

  void decrementCounter() {
    setState(() { counter--; });
  }
  void incrementCounter() {
    setState(() { counter++; });
  }

  Future<void> platformCallHandler(MethodCall call) async {
    switch (call.method) {
      case 'onVUP':
        decrementCounter();
        break;
      case 'onVDOWN':
        incrementCounter();
        break;
      default:
        debugPrint('Unknowm method ${call.method} ${call.arguments}');
        throw MissingPluginException();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
    );
  }
}
```

これで、Widgetにフォーカスがあるかどうかに関係なく、ボリュームキーを押すとplatformCallHandlerに設定したメソッドが呼び出されるようになる。

## バックグラウンドで動いてしまう

注意しなければならないことは、画面がどうなっていようがボリュームキーを押せば登録されたメソッドが呼び出されてしまうということだ。先のカウントアップダウンの例で言うと、何かの操作でNavigator.push()経由で別の画面が表示されていたとしても、ボリュームキーを押したときに呼び出されるのは、元のplatformCallHandlerである。

別画面に移行してボリュームキーを操作すると、戻ったときにカウンターの値が変っている。これを抑止するには、次のように画面の変わり目でハンドラーを切り替えればいいかと思ったが、やってみたところ画面切り替えから最初のキー入力は切替前のメソッドで処理されてしまった。画面を切り替えてからボリュームキーを何回か押したら、カウンタは一個だけ進んだのである。
```dart
    mChannel.setMethodCallHandler(null);
    await Navigator.push( context,
                    MaterialPageRoute( builder: (context) => SecondPage()));
    mChannel.setMethodCallHandler(platformCallHandler);
```
裏画面で処理されるのは用途によっては便利かもしれないが、そういう挙動をすることは頭に入れておこう。

## 重い処理は避ける

バックグラウンドで動いてしまうので、画面処理が終らないうちにキー処理要求が次々飛んでくることがある。そうするとメインスレッドが応答なしになって、次のような警告がでてしまう。
![アプリ応答なし.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114010/82c6453f-775a-22a3-9ef4-1872fa96bd09.png)
こうなってしまうと、待機を押せばまだしばらく操作できるものの、すぐに同じダイアログがでてしまって、結局ほとんど使えない状態になるので気をつけたい。

MethodChannelによるキーイベントの処理は、重いものは避けた方がいいだろう。