2020-02-23

Androidでスクリーン配信をする技術

DroidKaigi 2020 で講演予定のスライドの一部をご紹介します。

おしながき

今回は MediaProjection で使う機能やその応用例についてお話します。

筆者は MediaProjection を使ったライブ配信アプリに携わってきました。そこで得られた知見などもお話できればと思います。

今回、以前の DroidKaigi で他の人が話された内容や、他のセッションと重複する部分はあまりお話しせず、MediaProjection をメインでお話していきます。

もし、MediaProjection 以外の部分で気になったことがあれば、質疑応答や懇親会などでお尋ねください。

よろしくお願いします。

MediaProjection API

MediaProjection API は Android 5.0 より誕生しました。

元々、Apple が既に iOS デバイスに AirPlay という形で画面キャプチャーは実装されていたのですが、Android にはありませんでした。

しかし、Android Auto や Chromecast の出没により、Android でも画面キャプチャーを行う必要が出てきました。

そのため、生まれたのが MediaProjection API です。

MediaProjection API では、デベロッパーがユーザーの許可なく監視することを防ぐために、 REQUEST_MEDIA_PROJECTION と呼ばれる権限が必要となってきます。

また、Android 10 からは android.permission.FOREGROUND_SERVICE も必要となっています。

ですので、ユーザー側は勝手に画面がキャプチャーされる心配はありません。

MediaProjection API のユースケースとして、Bitmap として取得して、スクリーンショットとして活用することも出来ますし、後述しますが SurfaceView にミラーリングすることで、画面を直接動画に変換して、ライブストリーミングすることもできます。

ちなみに Android 11 で実装された画面収録も中身は MediaProjection API をそのまま使っています。

さて、ここからは実際に実装する部分についてお話していきましょう。

MediaProjection Manager

MediaProjection を始める前に、MediaProjectionManager を取得する必要があります。これは画面のキャプチャーの権限の取得に必要になってきます。

実際に画面キャプチャを行う際に、以下のようなダイアログが表示されますが、これは MediaProjectionManager によって表示されています。

これを実現するために、MediaProjectionManager を getSystemService から取得してみましょう。

class MediaProjectionModel : Activity() {
    private lateinit var mediaProjectionManager: MediaProjectionManager

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        mediaProjectionManager = getSystemService(Service.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
        startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), REQUEST_CAPTURE)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode == REQUEST_CAPTURE && resultCode == RESULT_OK && data != null) {
            projection(mediaProjectionManager.getMediaProjection(resultCode, data))
        }
        finish()
    }

    companion object {
        private const val REQUEST_CAPTURE = 1

        private var projection: (MediaProjection?) -> Unit = {}
        val run: (context: Context, (MediaProjection?) -> Unit) -> Unit = { context, callback ->
            projection = callback
            context.startActivity(Intent(context, MediaProjectionModel::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
        }
    }
}

パーミッションを確認するために、MediaProjectionManager の createScreenCaptureIntent で インテント を取得します。

その結果を startActivityForResult で OS 側に渡してあげます。

onActivityResult で、キャプチャが許可されていた場合、 getMediaProjection で MediaProjection を取得して、ここではコールバックとして渡します。

これで MediaProjection を使う準備は完了しました。

次は実際に画面を取得してみましょう。

VirtualDisplay

MediaProjection で画面をキャプチャする際に必要となってくるのが VirtualDisplay です。

VirtualDisplay とは、ミラーリングするために必要な仮想ディスプレイです。MediaProjection では、この VirtualDisplay に画面をミラーリングすることで画面をキャプチャすることができます。

では、実際に画面を取得してみましょう。画像の取得には画像を扱うことに特化した Surface と呼ばれるバッファを作ってあげる方法と、MediaCodec で作った Surface など、予め用意された Surface にミラーリングする方法と 2 種類あります。スライドの後半では、この Surface に OpenGL で作ったフィルターを入れる話も行いますが、まずは基本の Surface についてご説明します。Surface は GPU で処理され、ImageRender はソフトウェアで処理されます。

今回は後者ですが、説明のために、いったん Surface を ImageRender を使って作る方法をご説明します。

class MirrorModel(private val metrics: DisplayMetrics, private val callback: MirrorCallback) : ImageReader.OnImageAvailableListener {
    enum class StatesType { Stop, Running }

    private var mediaProjection: MediaProjection? = null
    private var virtualDisplay: VirtualDisplay? = null

    private lateinit var heepPlane: Image.Plane
    private lateinit var heepBitmap: Bitmap

    private val scale = 0.5 // 端末ごとに調整して下さい

    fun setMediaProjection(mediaProjection: MediaProjection?) {
        this.mediaProjection = mediaProjection
    }

    fun disconnect() {
        runCatching {
            virtualDisplay?.release()
            mediaProjection?.stop()
        }.exceptionOrNull()?.printStackTrace()
        callback.changeState(StatesType.Stop)
    }

    @SuppressLint("WrongConstant")
    fun setupVirtualDisplay(): ImageReader? {
        val width = (metrics.widthPixels * scale).toInt()
        val height = (metrics.heightPixels * scale).toInt()
        val reader = ImageReader.newInstance(width , height, PixelFormat.RGBA_8888, 2).also { it.setOnImageAvailableListener(this, null) }
        virtualDisplay = mediaProjection?.createVirtualDisplay(
            "Capturing Display",
            width,
            height,
            metrics.densityDpi,
            DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
            reader!!.surface,
            null,
            null
        )
        callback.changeState(StatesType.Running)
        return reader
    }

    override fun onImageAvailable(reader: ImageReader) {
        reader.acquireLatestImage().use { img ->
            runCatching {
                heepPlane = img?.planes?.get(0) ?: return@use null
                val width = heepPlane.rowStride / heepPlane.pixelStride
                val height = (metrics.heightPixels * scale).toInt()
                heepBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { copyPixelsFromBuffer(heepPlane.buffer) }
                callback.changeBitmap(heepBitmap)
            }
        }
    }

    interface MirrorCallback {
        fun changeState(states: StatesType)
        fun changeBitmap(image: Bitmap?)
    }
}

setupVirtualDisplay で ImageRender を作ってあげます。

ImageRender は、API19 で追加された、カメラなどの他の Surface から画像を読み取り、コールバックで他に流すことができるクラスです。

ここで重要となってくるのが width と height です。今回は、 DisplayMetrics から 端末の width と height を取得していますが、高 DPI の端末だと、VirtualDisplay のサイズが大きくなり、ミラーリングする際に負荷が高くなってしまいます。

ですので、今回は scale で、半分のサイズで ImageRender を作ってあげることで、VirtualDisplay のサイズを実際の解像度よりも下げています。

ちなみに ImageRender.newInstance で、渡しているピクセルフォーマットについても少しだけお話しておきましょう。

今回は、 PixelFormat.RGBA_8888 にしていますが、これは 24bit TrueColor(RGB)に α チャンネルの 8bit を加えた 32bit のフォーマットです。ただ、アルファチャンネルがいらない場合も出てくると思います。その場合は RGBX_8888 を指定してあげると負荷が若干減ってよいかもしれません。この部分の話は今回省略しますが、気になった方がいれば気軽にお尋ねください。

さて、話を戻しますが、ここで作った ImageRender を createVirtualDisplay で渡してあげます。

ここで重要なのが、第 5 引数のフラグです。ここでは、 DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR に設定しています。

これがどういったものかについてご説明します。

DisplayManager の仮想ディスプレイの表示条件には以下のようなものがあります。

定数名 説明
VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR コンテンツをミラーリング表示する
VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY 独自のコンテンツを表示。ミラーリングしない
VIRTUAL_DISPLAY_FLAG_PRESENTATION プレゼンテーションモード
VIRTUAL_DISPLAY_FLAG_PUBLIC HDMI や Wireless ディスプレイ
VIRTUAL_DISPLAY_FLAG_SECURE 暗号化対策が施されたセキュアなディスプレイ

フラグには、VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR と VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY は排他であるなど細かい条件があります。

MediaProjection は、このフラグをみてもらえれば分かる通り、とても柔軟に使うことができます。

実際に、ディスプレイが存在しない端末(Android TV など)でも、VirtualDisplay を作ることで、そこに描写することも可能です。

これで画面のキャプチャを行うことができました。

次はいよいよ本題の配信について踏み入れていきます。

MediaCodec

先ほどは自分で Surface を作る話をしましたが、MediaCodec でエンコードする場合は MediaCodec で Surface を作って、渡してあげる必要があります。

MediaCodec は、Android で動画のエンコードやデコードを司るクラスです。

MediaCodec の入出力には ByteBuffer のほかに Surface もサポートしています。

今回は MediaCodec で作った Surface を使って、MediaProjection でミラーリングして、エンコードするお話をしたいと思います。

MediaCodec を既に触ったことはある人は分かるかと思いますが、ByteBuffer の場合は queueInputBuffer で都度送ったりする必要があると思いますが、Surface の場合は中身が更新されると自動的に渡されるため、queueInputBuffer を書く必要はありません。

さて、実際に Surface を作って、MediaProjection に渡す実装をみていきましょう。

 fun PrepareEncoder(width: Int, height: Int) {
   val mime = MediaFormat.MIMETYPE_VIDEO_AVC
    val format = MediaFormat.createVideoFormat(mime, width, height)
    //フォーマットのプロパティを設定
//最低限のプロパティを設定しないとconfigureでエラーになる
        format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
        format.setInteger(MediaFormat.KEY_FRAME_RATE, 30) // Lolipopではここは無視される
        format.setInteger(MediaFormat.KEY_BIT_RATE, 1000 * 1024)
        format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10)
        format.setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, 1000000L / 30)
    //エンコーダの取得
    codec = MediaCodec.createEncoderByType(mime)
    codec.setCallback(object: MediaCodec.Callback() {
        override fun onInputBufferAvailable(@NonNull codec: MediaCodec, index: Int) {
            Log.d("MediaCodec", "onInputBufferAvailable : " + codec.codecInfo)
        }

        override fun onOutputBufferAvailable(@NonNull codec: MediaCodec, index: Int, @NonNull info: MediaCodec.BufferInfo) {
            Log.d("MediaCodec", "onOutputBufferAvailable : $info")
            val buffer: ByteBuffer = codec.getOutputBuffer(index)
            val array = ByteArray(buffer.limit())
            buffer.get(array)
            //エンコードされたデータを送信
            Send(array)
            //バッファを解放
            codec.releaseOutputBuffer(index, false)
        }

        override fun onError(@NonNull codec: MediaCodec, @NonNull e: CodecException) {
            Log.d("MediaCodec", "onError : " + e.message)
        }

        override fun onOutputFormatChanged(@NonNull codec: MediaCodec, @NonNull format: MediaFormat) {
            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.createVideoFormat で、エンコードターゲットの MIME Type と width と height を設定してあげます。

その後、エンコードするのに必要なプロパティを設定していきます。

プロパティ 説明
KEY_COLOR_FORMAT カラーフォーマットを指定します。Surface の場合はネイティブ RAW フォーマットである COLOR_FormatSurface を指定します。
KEY_FRAME_RATE フレームレートを指定します。60fps でエンコードすることも出来ますが、古い端末だとカクカクになるので 30fps あたりがお勧めです。
KEY_BIT_RATE ビットレートを指定します。MediaCodec の H.264 エンコーダーは残念ながら圧縮効率がよくないため、高めに設定しないとブロックノイズが出やすいです。
KEY_I_FRAME_INTERVAL キーフレーム(I フレーム)の間隔を設定します。通常は 10 フレーム毎でいいと思いますが、キーフレーム間隔を短くすると遅延が少なくなります。
KEY_REPEAT_PREVIOUS_FRAME_AFTER Surface でフレームがこない場合に、前のフレームを繰り返す間隔を設定します。

さて、ここで重要なのが KEY_REPERT_PREVIOUS_FRAME_AFTER です。このプロパティは Surface 独自のもので、先ほど話したとおり、Surface は基本的に自動的に更新されますが、稀に自動的に更新されない場合もあります。その場合、ブラックアウトせずに前のフレームをそのままエンコーダーに突っ込む仕様になっているのですが、Surface から次のフレームがきたタイミングで、どのタイミングで入れるかを設定するときに関わってくるのがこのプロパティです。

このプロパティは他と違い、setLong なのですが、これはマイクロ秒で設定するためです。通常は 1000000 / FrameRate で計算したものをここに入れると、次のフレームがくる際に必ず I フレームに突っ込むことができます。

さて、次に MediaCodec で使うエンコーダーを設定していきます。createEncoderByType で MIME Type を設定すると自動的に MediaCodec で使うエンコーダーが設定されます。ただ、デバイスによっては使えたり使えなかったりするので、以下のようなコードで、そのデバイスに最適なエンコーダーを設定してあげることも可能です。

        val codecName: String? = if (Build.VERSION_CODES.LOLLIPOP <= Build.VERSION.SDK_INT) {
            MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(mediaFormat)
        } else null
        return if (codecName != null) {
            MediaCodec.createByCodecName(codecName)
        } else {
            MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
        }

ちなみに、Android の場合、ハードウェアエンコーダーとソフトウェアエンコーダーの 2 種類あります。基本的にハードウェアエンコーダーが優先されますが、ハードウェアエンコーダーの場合は SoC によっては特性が異なるため、強制的にソフトウェアエンコーダーを使うことも可能です。

その場合は MediaCodec.createByCodecName("OMX.google.h264.encoder") のような形で、エンコーダーの名前をそのまま設定します。

このエンコーダーのリストは MediaCodecList で取ることができます。

ちなみに、ソフトウェアエンコーダーの場合は、この OMX とよばれるエンコーダーが使われるのですが、OMX は OpenMAX のことです。組み込みの世界ではよく使われるエンジンですね。この OMX の中身を紐解くと、Android の場合は H.264 のエンコーダーには Ittiam H264 という ARM が開発したものが使われるそうです。H.264 の場合はロイヤリティが発生するのですが、おそらく ARM がその部分を払っているのではないのかと思っています。感謝ですね。似たようなものに WebRTC などで使われている OpenH264 と呼ばれる Cisco が開発したエンコーダーもあります。

本題に戻りましょう。エンコーダーもセットしたので、あとはエンコードの処理を書いていくだけです。

MediaCodec には非同期で行うものと、同期的に行う 2 種類のやり方がありますが、非同期で行う方が現在推奨されているやり方です。

今回は、MediaCodec.Callback() を使って、MediaCodec からコールバックを受け取って処理する方法を説明していきます。

onInputBufferAvailable は MediaCodec が作ったバッファが来た時に呼び出されるメソッドです。今回は Surface を入力していますので、ここでは特に記述しません。オーディオのエンコードを行う際は、ここでやってきた ByteBuffer に put する必要があるのですが、その話はスライドの後半の AudioPlaybackCapture でお話します。

onOutputBufferAvailable は MediaCodec でエンコードされたバッファが来た時に呼び出されるメソッドです。今回は H.264 にエンコードされたバッファがやってきます。このバッファを使って、RTMP などで送信します。バッファが来た際には releaseOutputBuffer でそのバッファを受け取ったことを MediaCodec に通知する必要があります。これがないと次のフレームが来ません。

onError は MediaCodec でエラーが発生したときに飛んできます。Timber.e などでキャッチして Crashlytics などに送ってあげたりするとよいでしょう。

onOutputFormatChanged はエンコードターゲットのフォーマットが変わった際に呼ばれます。例えば解像度が切り替わったりした場合などに呼ばれます。基本的にここが呼ばれた際は送信側にフォーマットが切り替わったことを通知してあげると良いでしょう。

最後に、codec.configure で MediaFormat を入れてあげます。MediaCodec.CONFIGURE_FLAG_ENCODE は MediaFormat をエンコーダーに入れるときに使うフラグです。

さて、ここで MediaCodec の準備はできました。codec.createInputSurface() で MediaCodec から Surface を取得します。この Surface を前にご説明した createVirtualDisplay の Surface に入れます。

    fun setupVirtualDisplay(inputSurface: Surface) {
        val width = (metrics.widthPixels * scale).toInt()
        val height = (metrics.heightPixels * scale).toInt()
        virtualDisplay = mediaProjection?.createVirtualDisplay(
            "Capturing Display",
            width,
            height,
            metrics.densityDpi,
            DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
            inputSurface,
            null,
            null
        )
        callback.changeState(StatesType.Running)
    }

これで MediaCodec で作った Surface を、createVirtualDisplay で Surface にミラーリングしたものを書き込んで、MediaCodec が非同期でエンコードすることができました。

Tips: AudioPlaybackCapture

最後に、Android 10 から MediaProjection に AudioPlaybackCapture が入りました。

これは、マイクを使わずに端末の音をそのままキャプチャーすることができるものです。

ちなみにこの機能は Android 10 の自動字幕起こしでも使われています。

AudioPlaybackCapture にはいくつか条件があります。

  • compileSdkVersion 29 のアプリはデフォルトでキャプチャーされる。無効化したい場合は AndroidManifest に android:allowAudioPlaybackCapture="false" が必要
  • compileSdkVersion 28 以前のアプリはデフォルトでキャプチャがブロックされる。キャプチャする場合は android:allowAudioPlaybackCapture="true" を Manifest に入れる必要がある

実はこの機能は Android 5.0 の時点では存在していました。

Android 5.0 の場合は Manifest.permission.CAPTURE_AUDIO_OUTPUTREMOTE_SUBMIX を組み合わせることで端末の音を AudioRecord を使って、キャプチャすることが可能でした。

しかし、この機能は Android 6.0 にて無効化されました。経緯としては、当時盗聴アプリが大流行していたこともあり、ユーザーの許可なしに端末の音が取得されることを恐れたためです。

ただ、Chromecast などの特別なケースの場合のみ、 CAPTURE_AUDIO_OUTPUT は許可されていました。Android 6.0 からこの権限はシステム権限に昇格し、サードパーティの開発者は音声をキャプチャすることができませんでした。

Android 10 になり、この機能は MediaProjection の機能の一つとして復活しました。これにより、ユーザーは音声がキャプチャされる前に確認することができ、セキュリティ的な問題も解消しました。

ただ、AudioPlaybackCapture は、制約が厳しいため、実際まだ使えるかというと微妙です。ただ、とても素晴らしい機能ですので、是非使ってみましょう。

@RequiresApi(api = Build.VERSION_CODES.Q)
fun prepareInternalAudio(
    bitrate: Int,
    sampleRate: Int,
    isStereo: Boolean
): Boolean {
    if (mediaProjection == null) {
        mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data)
    }
    val config =
        AudioPlaybackCaptureConfiguration.Builder(mediaProjection)
            .addMatchingUsage(AudioAttributes.USAGE_MEDIA)
            .addMatchingUsage(AudioAttributes.USAGE_GAME)
            .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
            .build()
    microphoneManager.createInternalMicrophone(config, sampleRate, isStereo)
    prepareAudioRtp(isStereo, sampleRate)
    return audioEncoder.prepareAudioEncoder(
        bitrate, sampleRate, isStereo,
        microphoneManager.getMaxInputSize()
    )
}

fun createInternalMicrophone(
    config: AudioPlaybackCaptureConfiguration?,
    sampleRate: Int,
    isStereo: Boolean
) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        this.sampleRate = sampleRate
        if (!isStereo) channel = AudioFormat.CHANNEL_IN_MONO
        audioRecord = AudioRecord.Builder()
            .setAudioPlaybackCaptureConfig(config!!)
            .setAudioFormat(
                Builder()
                    .setEncoding(audioFormat)
                    .setSampleRate(sampleRate)
                    .setChannelMask(channel)
                    .build()
            )
            .setBufferSizeInBytes(getPcmBufferSize())
            .build()
        audioPostProcessEffect = AudioPostProcessEffect(audioRecord.getAudioSessionId())
        val chl = if (isStereo) "Stereo" else "Mono"
        Log.i(TAG, "Internal microphone created, " + sampleRate + "hz, " + chl)
        created = true
    } else createMicrophone(sampleRate, isStereo, false, false)
}

AudioPlaybackCapture を使うには、 AudioPlaybackCaptureConfiguration を作る必要があります。ここで addMatchingUsage を設定する必要がありますが、これは AudioManager で USAGE_MEDIA のように設定すると思いますが、AudioManager で設定された Arrtibutes とマッチするものだけキャプチャできます。例えば、ここには VOICE_COMMUNICATION は入れていませんが、通話アプリなどで話す場合はキャプチャされません。このようにユースケースによって、キャプチャするかしないかを選ぶことができます。

ここで作った config を AudioRecord.Builder()に渡してあげます。こうすることで、音声のキャプチャを行うことができます。

ちなみにこの場合は AudioSource を指定しても無視されます。AudioPlaybackCapture の場合は、マイクを使うことができないので要注意です。

あとは通常の AudioRecord と同様に read でキャプチャすることができます。

まとめ

MediaProjection を使ったスクリーン配信で簡単に配信アプリを作ることができます。

皆さんも是非試してみてはいかがでしょうか。