のらぬこの日常を描く

ノージャンルのお役立ち情報やアニメとゲームの話、ソフトウェア開発に関する話などを中心としたブログです。

【androidアプリ開発】 MediaPlayer コンポーネントの使い勝手を改善する

android の MediaPlayer コンポーネントって割りと糞だと思う的な

androidで音楽やサウンドを再生する際、google謹製のMediaPlayerというコンポーネントを利用することができます。 このコンポーネントには、setDataSource(), play(), pause(), stop() などのメソッドが定義されています。

このコンポーネントはおそらく、androidアプリに音楽再生機能を比較的容易に実装できるようにするという目的で提供されていると思われますが、後述しますが少々罠いところがあります。

なお、MediaPlayerの使い方については、下記の記事などに比較的詳しく書かれています。

Androidで動く携帯Javaアプリ作成入門(30):Androidアプリでマルチメディアを扱うための基礎知識 (1/3) - @IT

この他にも、google で 「android MediaPlayer java」などのキーワードで検索すると、いくつか解説記事が出てきます。

以下、MediaPlayerの使い方はなんとなく知ってる、くらいの知識をお持ちという前提で話を進めます。

MediaPlayer ですが、以下の理由で、僕は正直相当問題のあるコンポーネントだと思っています。

  • MediaPlayer の内部状態がほぼ完全にブラックボックスです。状態取得メソッドなどは用意されていません。

    • その為、MediaPlayerの状態管理を、アプリ側でも二重管理しなければならないという事態が発生します。
  • prepareメソッド(曲の再生準備をするメソッドです。おそらく音楽データをメモリー上にバッファリングする等をしていると推測)の非同期版、prepareAsync というメソッドがありますが、この中で例外が発生すると、異常があったことがアプリ側に全く通知されません。

    • ローカル端末内の曲を扱う分にはおそらくあまり問題にならないのですが、インターネット上の曲をダイレクトに再生しようとした場合に問題になる可能性があります。
    • WebServerが404などのエラーレスポンスを返したとき、prepare() は IOException を投げるのですが、prepareAsync() は例外が発生したことを通知してくれません(onPrepared()やonError() などのリスナーも呼ばれません)。
    • この状態になると、アプリ側からはMediaPlayerの状態の追跡が不可能になります(処理完了待ちなのか、例外発生で処理が終了したのかがわからなくなるため)
  • インターネット上のファイルを再生しようとしたとき、サーバエラーに対してのエラーハンドリングがカスタマイズできません。

    • prepare()、もしくはprepareAsync() 呼び出し時に、ファイルを読みに行くのですが、エラー発生時、サーバが401、404、410、500等、何を返しても、一定時間(それなりに長い)開けて何度かリトライするという動作になっています。
  • android6.0でちょいましなインターフェースができたのですが、バックポートされていないので実質使えません。

ローカル端末の決まったファイルを再生するなどの用途であればエラーハンドリングもそんなに神経使うことも無いですし、そこそこ使いやすいと思います。

しかし、

ネットワークメディアプレーヤーを作ろう!

とか考え出すと

googleに対して、憎悪の念しか持てなくなってしまいます(少なくとも僕は)

文句垂れ流してどうにかなるもんでもないので、とりあえずなんとかしてみよう的な

少し気が収まってきたので(やっと)本題です。

これらの問題を(一部)解決する、MediaPlayerEx という、なんかちょっとWindowsっぽい名前のclassを書いてみたのでここで公開します。

やったこと

  • prepareAsync を再実装して、例外発生時には、OnPrepareAsyncFailedListener が呼び出されるようにしました。
  • 再生準備ができているかを表す状態をclassにもたせました。
  • エラーが発生していたらリセットする resetIfError というメソッドを追加しました
  • setDataSource で設定したファイル名(URL)を後から参照できるようにしました。

やれなかったこと

  • インターネット上のファイルを再生しようとしたとき、サーバエラーに対してのエラーハンドリングがカスタマイズできないのは相変わらず。

というわけで、以下、ソースになります。
プロジェクトの一部を切り取ってきただけなので、そのままビルドするとエラーになるかもしれません。
その際は、過不足部分を適当に補完して上げてください。

public class AudioPlayService extends Service {
    public enum PrepareStatus {
        NotPrepared, Preparing, Prepared, Error
    }
}

public class MediaPlayerEx extends MediaPlayer
        implements MediaPlayer.OnPreparedListener,
        MediaPlayer.OnErrorListener {


    public interface OnPrepareAsyncFailedListener {
        void onPrepareAsyncFailed(MediaPlayerEx mp);
    }

    private String lastDataSource;
    private String lastDataSourceOriginalPath;
    private AudioPlayService.PrepareStatus prepareStatus = AudioPlayService.PrepareStatus.NotPrepared;
    private OnPreparedListener preparedListener = null;
    private OnErrorListener errorListener = null;
    private OnPrepareAsyncFailedListener prepareAsyncFailedListener = null;

    public AudioPlayService.PrepareStatus getPrepareStatus() {
        return prepareStatus;
    }

    public MediaPlayerEx(int id) {
        super();
        super.setOnPreparedListener(this);
        super.setOnErrorListener(this);
        this.id = id;
    }

    public String getLastDataSource() {
        return lastDataSource;
    }

    public String getLastDataSourceOriginalPath() {
        return lastDataSourceOriginalPath;
    }

    public void setDataSource(long musicId) throws IOException {
        String path = musicIdToFilePath(musicId);
        if (StringUtils.isNotEmpty(path)) {
            String url = musicFilePathToUrl(path);
            try {
                lastDataSource = url;
                setDataSource(url);
                Log.d(MediaPlayerEx.class.getSimpleName(), "load start :" + url);
                this.lastDataSourceOriginalPath = path;
                this.lastMusicId = musicId;
                try {
                    this.lastDataSource = URLDecoder.decode(url, "UTF-8");
                } catch (IllegalArgumentException e) {
                    this.lastDataSource = url;
                }
            } catch (IOException e) {
                Log.e(MediaPlayerEx.class.getSimpleName(), "cannot set datasource:" + url);
                throw e;
            }
        }
    }

    public long getLastMusicId() {
        return lastMusicId;
    }

    @Override
    public void setDataSource(String source) throws IOException {
        try {
            Log.d(MediaPlayerEx.class.getSimpleName(), String.format("[setDataSource start] %d - %s", this.id, source));
            super.setDataSource(source);
            Log.d(MediaPlayerEx.class.getSimpleName(), String.format("[setDataSource end] %d",
                    this.id));
        } catch (IOException e) {
            Log.e(MediaPlayerEx.class.getSimpleName(),
                    String.format("[setDataSource failed] %d", this.id), e);
            throw e;
        }

    }

    public boolean resetIfError() {
        if (prepareStatus == AudioPlayService.PrepareStatus.Error ||
                (prepareStatus == AudioPlayService.PrepareStatus.Preparing &&
                        System.currentTimeMillis() - prepareStartTime > 15000)) {
            Log.e(MediaPlayerEx.class.getSimpleName(), "prepareError MediaPlayer reset");
            reset();
            prepareStatus = AudioPlayService.PrepareStatus.NotPrepared;
            return true;
        } else {
            return false;
        }
    }

    public void setOnPrepareAsyncFailedListener(OnPrepareAsyncFailedListener listener) {
        this.prepareAsyncFailedListener = listener;
    }

    @Override
    public void setOnPreparedListener(OnPreparedListener listener) {
        this.preparedListener = listener;
    }

    @Override
    public void setOnErrorListener(OnErrorListener listener) {
        this.errorListener = listener;
    }

    @Override
    public void onPrepared(MediaPlayer mp) {
        prepareStatus = AudioPlayService.PrepareStatus.Prepared;
        if (this.preparedListener != null) {
            preparedListener.onPrepared(mp);
        }
    }

    @Override
    public boolean onError(MediaPlayer mp, int what, int extra) {
        if (getPrepareStatus() == AudioPlayService.PrepareStatus.Prepared) {
            Log.e(MediaPlayerEx.class.getSimpleName(), String.format("onError(%d) what:%d extra",
                    this.id, what, extra));
            if (prepareStatus == AudioPlayService.PrepareStatus.Preparing) {
                prepareStatus = AudioPlayService.PrepareStatus.Error;
            }
            return (this.errorListener != null) &&
                    this.errorListener.onError(mp, what, extra);
        } else {
            Log.e(MediaPlayerEx.class.getSimpleName(), String.format("onError(%d) what:%d extra",
                    this.id, what, extra));
            return true;
        }
    }

    @Override
    public void reset() {
        try {
            Log.d(MediaPlayerEx.class.getSimpleName(), String.format("[reset start] %d", this.id));
            super.reset();
        } finally {
            Log.d(MediaPlayerEx.class.getSimpleName(), String.format("[reset end  ] %d", this.id));
        }
        prepareStatus = AudioPlayService.PrepareStatus.NotPrepared;
    }

    @Override
    public void stop() {
        super.stop();
        prepareStatus = AudioPlayService.PrepareStatus.NotPrepared;
    }

    @Override
    public void prepare() throws IOException {
        if (prepareStatus == AudioPlayService.PrepareStatus.NotPrepared) {
            prepareStatus = AudioPlayService.PrepareStatus.Preparing;
            prepareStartTime = System.currentTimeMillis();
            try {
                Log.d(MediaPlayerEx.class.getSimpleName(), String.format("[prepare start] %d", this.id));
                super.prepare();
            } catch (IOException e) {
                prepareStatus = AudioPlayService.PrepareStatus.Error;
                throw e;
            } finally {
                Log.d(MediaPlayerEx.class.getSimpleName(), String.format("[prepare end  ] %d", this.id));
                prepareStartTime = 0;
            }
            prepareStatus = AudioPlayService.PrepareStatus.Prepared;
        }
    }

    @Override
    public void prepareAsync() {
        new Thread(MediaPlayerEx.class.getSimpleName() + "#prepareAsync") {
            @Override
            public void run() {
                long startTimeMillis = System.currentTimeMillis();
                try {
                    prepare();
                } catch (IOException | IllegalStateException e) {
                    Log.e(MediaPlayerEx.class.getSimpleName(),
                            String.format("%d - prepareAsync failed(%s)", MediaPlayerEx.this.id, getLastDataSource()), e);
                    if (prepareAsyncFailedListener != null) {
                        prepareAsyncFailedListener.onPrepareAsyncFailed(MediaPlayerEx.this);
                    }
                }
                if (System.currentTimeMillis() - startTimeMillis > 10000) {
                    Log.w(MediaPlayerEx.class.getSimpleName(),
                            "MediaPlayerEx#prepareAsync() too slow." + String.valueOf(System.currentTimeMillis() - startTimeMillis));
                }
            }
        }.start();
    }
}

こんかいは以上となります。

最後までお読み頂きありがとうございました。