Java
Android
ffmpeg
MediaCodec
MediaProjection

Androidの画面をPCにミラーリングするソフトを作る1

はじめに

本記事ではAndroidの画面をPCにミラーリングする機能の解説をしています。
この記事の続きに
Androidの画面をPCにミラーリングするソフトを作る2 リアルタイムタッチ編
があります。

こんなものを作ります

capture3.gif

Androidの画面をPCにミラーリングするソフトを作ります。
Gifではわかりませんが、50~60FPSでヌルヌル動きます。
また、Windows,Mac,Linux環境で動きます。

pic.jpg
H.264のビットレート5000Kbps、解像度1/2で遅延は300ms前後なのでそこそこリアルタイムです。設定次第ではもっと短くなるかも。

動機

Androidの画面をミラーリングするソフトにはいくつかあります。

その中でも画質、フレームレートが、優れているソフトにVysorがあります。
普通に60FPSでヌルヌル動くので、初めて使ったときは感動しました。
無料版だと画質に制限があるのですが、課金をすると解除されます。
(サブスクリプション制と買い切り制があります)

ただ、ソフトにお金を出すのは学生の財布的に厳しいので
自分で作ってみることにしました。

ただ、今回作るのはミラーリングする機能だけです。

仕様

fig1.png

Android側でサーバーを建て、PCから接続します。
Wi-Fi経由でも可能ですが、安定させるためにUSB経由で通信を行います。

Android側ですること

Media Projectionを使い画面をキャプチャします。(なので、対応端末は5.0以降になります。)
キャプチャをMedia Codecでエンコードして、PC側に流します。

PC側ですること

サーバーに接続して、ストリームをデコードして表示します。
しかし、今回はffplayに丸投げして、PC側のプログラムは一切作りません(笑)

ffplayとは有名な動画変換ツールFFmpegに同梱されている動画再生ソフトです。
パラメーターを指定することで色々遊ぶことができます。
今回はストリームをデコードしリアルタイムで表示する用途で使用します。
今回冒頭にOSの環境の話をしたのは、このffmpegが様々なOSに対応しているからです。

使用するコーデックについて

Android側でエンコードできるコーデックの一覧はSupported Media Formatsにありますが、結局は端末によってマチマチなようです。
複数台の実機や、エミュレータで試しましたが、全てで動いたのはH.264だけでした。

また、VP8はエンコーダ自体は生成できるものの、取得したバッファに異常があるようでエラーで落ちます。
VP9は[Invalid data found when processing input]となり、ffplayが認識してくれませんでした。
H.265は使用できる端末なら利用可能です。

今回のサンプルではコーデックが指定できるようになっているので、どれが動くかは実機で試してみてください。
VP8や9が使えればライセンスに気を使わなくて楽なのですが、残念です...。

エラーについて、原因は分かり次第追記します。(何が情報があれば教えていただけるとうれしいです。)

コーデックの種類については動画コーデックの種類と違い(H.264・VP9・MPEG・Xvid・DivX・WMV等)【比較】で詳しく紹介されています。

Androidの画面をキャプチャする

Android5.0以降でアプリ側から画面を取得できるようになっています。
具体的にはMedia Projectionを使います。
ANDROID 5.0 アプリからスクリーンショットを撮影する
こちらで詳しく説明されています。

Media Projection使用の流れ

簡単ではありますが、MediaProjectionを使用する際の流れを掲載します。
上の記事のコードを参照しながら見ると良いかもしれません。

使用するクラス

MediaProjectionManager
 ユーザーに画面をキャプチャする許可を求めるダイアログを表示し、許可されたらMediaProjectionを取得する。

MediaProjection
 画面を取得する機能を提供する。正確には、仮想ディスプレイというバッファを作成してそこに画面をミラーリングしてくれる。ミラーリング以外にも幾つかモードがある。

VirtualDisplay(仮想ディスプレイ)
 MediaProjectionが作成、書き込みをするバッファ。内部に書き込むSurfaceを持ち、実際にはそれがバッファ。
 作成時にそのSurfaceを指定できる。
 そのため、ImageReaderのSurfaceを指定すればImageReader経由で画像を取得できるし、
 SurfaceViewのSurfaceを指定すればViewにリアルタイムに表示される。

Surface
 普通のバッファと違い「画像を扱う」ことに特化したバッファ
 VirtualDisplay以外にも、ゲームを作る時など使うSurfaceViewや動画再生プレイヤーなどの内部でも使用される。

ImageReaderを使ったときのイメージ

実際にはImageReaderにフレームをためておく機構がありますが、こんな感じです。
fig2.png

手順 (コードは後半にあります)

1.getSystemServiceMeidaProjectionManagerを取得
2.ManagercreateScreenCaptureIntentで画面キャプチャの許可を求めるインテントを作成
3.2で作成したインテントを投げてActivityonActivityResultでキャッチ
4.ユーザーが許可をしていればManagergetMediaProjectionMediaProjectionを取得
5.取得したMediaProjectioncreateVirtualDisplayで仮想ディスプレイをミラーリングモードで作成
 このとき、書き込んでほしいSurfaceを指定
6.5で指定したSurfaceにリアルタイムで画面の内容が書き込まれるのでそれを使う。
 

Androidで動画をエンコードする

MediaCodecを使用します。

以下の記事が参考になりました。
公式ドキュメント
MediaCodec クラス概要 和訳
AndroidでMediaCodecを使いFFmpegなしで動画を圧縮する方法(ライブラリあり)
上記記事で紹介されていたもの
EncodeAndMuxTest(やり方は古いものの、手順が参考になりました)

MediaCodec使用の流れ

MediaCodecを使用する際の大まかな手順を説明します。

使用するクラス

MediaCodec
 動画のエンコーダ、デコーダ
MediaFormat
 コーデックやビットレート、フレームレートなど、動画の情報を格納する。
 MediaCodecの設定に使用。

使用イメージ

フレームの入出力にBufferやSurfaceを使用できます。
入力にSurface、出力にBufferなんてことも可能です。

fig3.png

手順 (コードは後半にあります)

1.createEncoderByType/createDecoderByTypeでエンコーダ、デコーダを作成
2.MediaFormatを作成して、エンコード、デコードする動画の設定をする
3.MediaCodecconfigureを実行。2で作成したMediaFormatを指定
4.非同期で処理をする場合、コールバックを設定
5.startで変換開始
6.Inputにエンコード/デコード前のデータを流す
7.Outputから処理後のデータを取り出す

データ入出力時の注意

前述のようにMediaCodecのデータの入出力にはSurfaceやBufferが使用できます。
しかし使うものによって受け渡し方に違いがあります。

Inputの場合

Bufferを使用する際には手動でデータをMediaCodecに渡してやる必要があります。
Surfaceは中身が更新されると自動で渡されます。

Outputの場合

Bufferを使用する際は手動でデータを取得する必要があります。
Srufaceは自動で中身が更新されます。

今回のソフトでは入力にSurface、出力にBufferを使用します。

いざ、実装

レイアウト

fig5.png
レイアウトのxmlはこちら

処理の流れ

スタートボタンがクリックされるところから処理が始まります。
fig4-2.png

コード

MainActivity.javaに全てまとめたので、大掛かりな実装はしていません。
また、一部エラーチェックをしていない部分もありますがご了承ください。
全体のコードはこちら

以下、コードの抜粋ですので全体のコードを参照しながら御覧ください。

1.onClick キャプチャの許可を求めるダイアログを表示

130~155行目のコードです

MainActivity.java
    button_start.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {

                switch (states) {
                    case Stop:
                        //キャプチャ確認用のダイアログを表示
                        manager = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE);
                        startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_CODE);
                        break;
                    case Waiting:
                        //待受をキャンセル
                        Disconnect();
                        break;
                    case Running:
                        //切断
                        Disconnect();
                        break;
                }

            }
        });

このボタンは停止にも使用するため、状態で処理を分岐しています。
Stop時に処理を開始します。
MediaProjectionManagerを取得し、ユーザーにキャプチャ確認用のダイアログを表示しています。

2.onActivityResult ダイアログの結果を処理

162~206行目のコードです。

MainActivity.java
    @Override
    public void onActivityResult(int requestCode, int resultCode, Intent intent) {
        if (resultCode != RESULT_OK) {
            Toast.makeText(this, "permission denied", Toast.LENGTH_LONG).show();
            return;
        }

        //ユーザーが画面キャプチャを承認した場合
        //MediaProjectionを取得
        mediaProjection = manager.getMediaProjection(resultCode, intent);


        //仮想ディスプレイのサイズを決定
        double SCALE = seekBar_scale.getProgress() * 0.01;

        DisplayMetrics metrics = getResources().getDisplayMetrics();
        final int WIDTH = (int) (metrics.widthPixels * SCALE);
        final int HEIGHT = (int) (metrics.heightPixels * SCALE);
        final int DENSITY = metrics.densityDpi;


        try {

            PrepareEncoder(
                    WIDTH,
                    HEIGHT,
                    codecs[spinner_codec.getSelectedItemPosition()],
                    seekBar_bitrate.getProgress(),
                    seekBar_fps.getProgress(),
                    10//Iフレームは固定で
            );

            SetupVirtualDisplay(WIDTH, HEIGHT, DENSITY);

            StartServer();



        } catch (Exception ex) {//エンコーダ作成時のエラーとか
            ex.printStackTrace();
            Toast.makeText(this, ex.getMessage(), Toast.LENGTH_LONG).show();
        }


    }

1.で表示させたダイアログをユーザーがタップするとonActivityResultが発生します。
許可された場合、getMediaProjectionMediaProjectionを取得します。
その後、スクリーンサイズを取得し、エンコーダーと仮想ディスプレイの準備をします。

3.PrepareEncoder エンコーダの準備

218~274行目のコードです。

MainActivity.java
//エンコーダの準備
    private void PrepareEncoder(int WIDTH, int HEIGHT, String MIME_TYPE, int BIT_RATE, int FPS, int IFRAME_INTERVAL) throws Exception {

        MediaFormat format = MediaFormat.createVideoFormat(MIME_TYPE, WIDTH, HEIGHT);
        //フォーマットのプロパティを設定
        //最低限のプロパティを設定しないとconfigureでエラーになる
        format.setInteger(MediaFormat.KEY_COLOR_FORMAT,
                MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
        format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE);
        format.setInteger(MediaFormat.KEY_FRAME_RATE, FPS);
        format.setInteger(MediaFormat.KEY_CAPTURE_RATE, FPS);
        format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL);


        //エンコーダの取得
        codec = MediaCodec.createEncoderByType(MIME_TYPE);

        codec.setCallback(new MediaCodec.Callback() {
            @Override
            public void onInputBufferAvailable(@NonNull MediaCodec codec, int index) {
                Log.d("MediaCodec", "onInputBufferAvailable : " + codec.getCodecInfo());

            }

            @Override
            public void onOutputBufferAvailable(@NonNull final MediaCodec codec, final int index, @NonNull MediaCodec.BufferInfo info) {
                Log.d("MediaCodec", "onOutputBufferAvailable : " + info.toString());
                ByteBuffer buffer = codec.getOutputBuffer(index);
                byte[] array = new byte[buffer.limit()];
                buffer.get(array);

                //エンコードされたデータを送信
                Send(array);

                //バッファを解放
                codec.releaseOutputBuffer(index, false);
            }

            @Override
            public void onError(@NonNull MediaCodec codec, @NonNull MediaCodec.CodecException e) {
                Log.d("MediaCodec", "onError : " + e.getMessage());
            }

            @Override
            public void onOutputFormatChanged(@NonNull MediaCodec codec, @NonNull MediaFormat format) {
                Log.d("MediaCodec", "onOutputFormatChanged : " + format.getString(MediaFormat.KEY_MIME));
            }
        });

        //エンコーダを設定
        codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);

        //エンコーダにフレームを渡すのに使うSurfaceを取得
        //configureとstartの間で呼ぶ必要あり
        inputSurface = codec.createInputSurface();

    }

最初にMediaFormatを作成します。そしてエンコードに必要なパラメータの設定をします。
次にcreateEncoderByTypeを利用してMediaCodecを作成します。
そしてconfigureを実行して、MediaFormatを設定しています。

最後にcreateInputSurfaceを呼び出して入力用のSurfaceを取得します。
このSurfaceに画像を書き込むと自動でその内容がエンコードされます。

また、ここでコールバックを設定していますが、使用するのは
エンコードされたデータが利用可能になった際に呼び出されるonOutputBufferAvailableのみです。
エンコードされたデータをバイト配列で取得し、PC側に送信します。

4.SetupVirtualDisplay 仮想ディスプレイの作成

208~216行目のコードです。

MainActivity.java
//仮想ディスプレイのセットアップ
    private void SetupVirtualDisplay(int WIDTH, int HEIGHT, int DENSITY) {

        virtualDisplay = mediaProjection
                .createVirtualDisplay("Capturing Display",
                        WIDTH, HEIGHT, DENSITY,
                        DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
                        inputSurface, null, null);//書き込むSurfaceにエンコーダーから取得したものを使用
    }

仮想ディスプレイを作成しています。
ここでいちばん重要なのは、書き込むSurfaceにエンコーダーから取得したinputSurfaceを設定することです。
こうすることでミラーリングされた画面はエンコーダーのInputSurfaceに直接書き込まれるようになり、
特に何もしなくても画面がエンコードされます。以下にその流れを示します。

fig6.png

5.StartServer サーバースレッドの開始

312~322行目のコードです。

MainAcitvity
//待受用、送信用のスレッドを開始
    private void StartServer() {
        senderThread = new HandlerThread("senderThread");
        senderThread.start();
        senderHandler = new Handler(senderThread.getLooper());

        serverThread = new Thread(this);
        serverThread.start();

        setState(States.Waiting);
    }

送信用のスレッドと待受用のスレッドを開始しています。
待受用スレッドはActivityにRunnableを実装しているのでそこのrun()内の処理を行います。
送信用スレッドはキュー処理ができるようにHandlerThreadを採用しています。

6.サーバー処理 PCからの接続を待ち受ける

324~346行目のコードです。

MainActivity.java
    //サーバースレッド
    //接続は1回きり受け付ける
    public void run() {
        try {
            listener = new ServerSocket();
            listener.setReuseAddress(true);
            listener.bind(new InetSocketAddress(8080));
            System.out.println("Server listening on port 8080...");

            clientSocket = listener.accept();//接続まで待機

            inputStream = clientSocket.getInputStream();
            outputStream = clientSocket.getOutputStream();

            //クライアントが接続されたタイミングでエンコードを開始する必要あり
            codec.start();

            setState(States.Running);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

サーバーソケットを立ち上げて、待機します。
今回、複数のPCに配信する必要はないので接続は1回きり受け付けるようにしています。

接続後、エンコーダを開始しています。
そうしないと、PC側で再生できませんでした。
エンコードを開始して初めのフレームはIフレームであり、今後のデコードに必須です。
Iフレームを初めに受け取れないとPC側で再生ができません。
これが再生できない理由だと思っています。
(違っていたらご指摘ください)

Iフレーム??という方は
キーフレームとは?Iフレーム・Pフレーム・Bフレームの違い【GOP】
を御覧ください。

7.データの送信

348~366行目のコードです

MainActivity.java
    //データを送信
    //順番が入れ替わらないように
    //キューに追加
    private void Send(final byte[] array) {
        senderHandler.post(new Runnable() {
            @Override
            public void run() {

                try {
                    outputStream.write(array);
                } catch (IOException ex) {
                    //送信できなかった場合、切断されたとみなす
                    ex.printStackTrace();
                    Disconnect();
                }

            }
        });
    }

3のエンコーダーに設定しているコールバック内で呼び出しているものです。
コールバックはメインスレッドで動いており、呼び出されたこのメソッドも同様です。
ネットワークに関する処理はメインスレッドで行ってはいけないという制約から
送信用のスレッドで送信処理を行うようにしています。

また、余談ですが、以下のように書くとPC側で表示される画面が乱れることがあります。

MainActivity.java
    private void Send(final byte[] array) {

        new Thread(new Runnable() {
            @Override
            public void run() {

                try {
                    outputStream.write(array);
                } catch (IOException ex) {
                    //送信できなかった場合、切断されたとみなす
                    ex.printStackTrace();
                    Disconnect();
                }

            }
        }).start();
    }

そもそもスレッドをひたすら生成しまくる時点でよろしくないコードですが、
こうすると、送信するフレームの順番が保証されません。
先程のIフレーム解説記事にもありますが、
圧縮されたフレームは前後のフレームの差分を表しているだけですので
前後関係が乱れると、正しくデコードされません。

8.切断、後処理

368~387行目のコードです。

MainActivity.java
//切断処理
    private void Disconnect() {

        try {
            codec.stop();
            codec.release();
            virtualDisplay.release();
            mediaProjection.stop();


            listener.close();
            if (clientSocket != null)
                clientSocket.close();

        } catch (IOException ex) {
            ex.printStackTrace();
        }

        setState(States.Stop);
    }

今まで使用したオブジェクトを停止、開放しています。
こうするとStop状態に戻るので、またボタンを押すと、始めから処理が走り、再度接続できるようになります。

PCとAndroid端末の通信をUSB経由で行う

具体的にはadb-serverをプロキシサーバーのように使うことで実現できます。
adbで遊ぶ
こちらを参考にしました。

コマンド一発で簡単

adb forward tcp:xxxx tcp:yyyy

xxxxにPC側で使うポート番号、yyyyに端末で使うポート番号を指定します。
今回は

adb forward tcp:8080 tcp:8080

で良いと思います。
これでPC側でlocalhost(127.0.0.1)の8080ポートに接続するとUSB経由で端末側の8080とつながります。

余談ですが、なぜlocalhostのIPに127.0.0.1が割り当てられたのか、
ふと気になったので調べてみるとIPv4の歴史的な背景があるようです。
127.0.0.1がローカルホストの理由は?

PCに画面を表示する

この長い記事をここまでご覧下さりありがとうございます。
最後にPCに画面を表示して終わりたいと思います。
記事の頭で紹介したffplayを使用するため、無い方はダウンロードしてください。
Download FFmpeg
ダウンロード後、ファイルを解凍するとbinフォルダがあり、そこに本体が入っています。
FFmpeg同様、ffplayもCUIからパラメータを指定して起動します。

手順

1.Android端末をPCと繋ぎadbに認識されるようにする。
2.adb forward tcp:8080 tcp:8080 を実行
3.Android端末でアプリを立ち上げ、Startを押す
4.コマンドプロンプトor PowerShell起動後、ffplay本体があるディレクトリに移動後、以下を実行

ffplay -framerate 60 -analyzeduration 100 -i tcp://127.0.0.1:8080

これでAndroid側の画面がPCに表示されます。
(表示されない場合は、ステータスバーをおろしたり、ホームに戻るなどして画面が更新されるようにしてください)

Escで終了できます。

パラメータの意味

-framerate 60 は単純にフレームレートを指定しています。キャストアプリの設定と同じにする必要があります。

-analyzeduration 100 ffplayが受信したフレームを解析する時間を制限します。(今回は100ms)ffplayはフレームがある程度溜まってから解析し、表示をするため、このオプションを指定しないと遅れて表示されてしまいます。

-i tcp://127.0.0.1:8080 ストリームを受信するアドレスです。
Wi-Fi経由で試すなら端末のIPを指定してください。
また、ここにファイルパスを指定すると普通に動画再生もできます。

困ってること

個人的に困った問題が発生しました。何か情報がありましたら、教えてください。
サーバーソケットの接続待ちの処理を、別スレッドで行っているのにもかかわらずAndroid8.0で

MainActivity.java
clientSocket = listener.accept();

でUIがブロックされてしまします。
物理ボタンも一切効かなくなり、接続してブロックを解除しないと、しばらくしてシステムUIが再起動します。
エミュレータで再現できるので試してみてください。

8.0で何か仕様変更ありましたか...?
7.1以前だと問題なく動きます。

感想

Vysorの代わりをするにはまだ機能が足りませんが、割りと簡単にミラーリングを実装できたのは驚きでした。
まだ、リアルタイムでのタッチ処理などの機能が足りませんが、今後作っていきたいと思います。

また、スクリプトで端末を自動操作できる機能も作ってみたいと思っています。
それに関して、Windows上で動くC#アプリにスクリプト機能&エディタを組み込む
C#アプリにスクリプティング機能を追加してみる
という記事も公開しているので興味があれば御覧ください。

では、最後までご覧下さりありがとうございました。


Androidの画面をPCにミラーリングするソフトを作る2 リアルタイムタッチ編