はじめに

この記事は、Androidアプリ開発で動画・音声録音機能を実装しようとしている中級者以上の開発者を対象としています。特にMediaRecorderを使ったことがあるが、なぜかstop()を呼ぶとアプリがクラッシュしてしまうという方に向けて書いています。

この記事を読むことで、MediaRecorder#stop()NullPointerExceptionが発生する仕組みと、それを確実に回避するためのコーディングパターンを翚得できます。サンプルコードはKotlin/Java両方で用意し、実務ですぐに使える形で提供します。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。

  • Android基礎(Activity/Fragmentのライフサイクル)
  • JavaまたはKotlinの基本的な文法
  • MediaRecorderの初期化〜prepareまでの流れをある程度知っていること

MediaRecorderのライフサイクルと例外の関係

MediaRecorderは状態遷移が非常に厳格で、公式ドキュメントに「状態遷移図」が載っています。重要なのは「prepare()してからstart()して録画状態に入り、stop()で録画を終了する」という流れです。

ところが、実務では以下のようなタイミングでNullPointerExceptionが頻発します。

  1. 録画開始前に端末の向きが変わりActivityが再作成された直後
  2. パーミッション未取得・録音デバイス競合でprepare()に失敗したにもかかわらずstart()を呼んでしまった場合
  3. バックグラウンドからフォアグランドに復帰した瞬間にstop()を呼ぶケース

これらの状況でMediaRecorder内部の参照(ネイティブ層のメディアレコーダーオブジェクト)がnullになっており、stop()内でnull.checkをスルーしてしまうため例外が発生します。

実装レベルで確実に回避する方法

ここでは実際にプロダクションコードで使える「防御的なラッパー」を作成します。ポイントは以下3つです。

  • MediaRecorderのラップクラスを作り、自身の状態をenumで管理する
  • stop()呼び出し前にtry-catchで囲み、例外を逃がさない
  • ライフサイクルイベント(onPause/onDestroy)で明示的にrelease()する

Step1:ラッパークラスの設計

まず、録画状態を自前で管理するRecordingStateを定義します。

Java
public enum RecordingState { IDLE, INITIALIZED, PREPARED, STARTED, STOPPED, RELEASED, ERROR }

次にMediaRecorderをラップするSafeMediaRecorderを作成します。

Java
public class SafeMediaRecorder { private MediaRecorder recorder; private RecordingState state = RecordingState.IDLE; private final String outputPath; public SafeMediaRecorder(String outputPath) { this.outputPath = outputPath; } public void prepare() throws IOException { if (state != RecordingState.IDLE) return; recorder = new MediaRecorder(); recorder.setAudioSource(MediaRecorder.AudioSource.MIC); recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); recorder.setOutputFile(outputPath); recorder.prepare(); state = RecordingState.PREPARED; } public void start() { if (state != RecordingState.PREPARED) return; recorder.start(); state = RecordingState.STARTED; } public void stopSafely() { if (state != RecordingState.STARTED) return; try { recorder.stop(); state = RecordingState.STOPPED; } catch (RuntimeException e) { // stopに失敗してもクラッシュさせない state = RecordingState.ERROR; } } public void release() { if (recorder != null) { recorder.release(); recorder = null; } state = RecordingState.RELEASED; } }

Step2:Activity/Fragmentからの利用

SafeMediaRecorderを使う側のコードは非常に簡潔になります。

Kotlin
class RecordActivity : AppCompatActivity() { private var safeRecorder: SafeMediaRecorder? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val output = File(filesDir, "temp.m4a").absolutePath safeRecorder = SafeMediaRecorder(output) } private fun startRecording() { try { safeRecorder?.prepare() safeRecorder?.start() } catch (e: IOException) { Toast.makeText(this, "マイクが利用できません", Toast.LENGTH_SHORT).show() } } private fun stopRecording() { safeRecorder?.stopSafely() } override fun onDestroy() { safeRecorder?.release() super.onDestroy() } }

ハマった点:Exceptionの種類を見極める

MediaRecorder#stop()のドキュメントを読むとIllegalStateExceptionがスローされると書いてあります。しかし、実際にはRuntimeExceptionのサブクラスであるNullPointerExceptionが飛んでくることがあります。これは内部実装依存であり、メーカー/OSバージョンごとに変わるため、キャッチすべき例外はRuntimeExceptionの上位クラスにしておくのが安全です。

解決策:UnitTestで網羅する

上記のラッパーが本当にクラッシュしないことを検証するため、以下のようなEspresso+JUnitテストを書きます。

Kotlin
@Test fun stopWithoutStart_shouldNotCrash() { val recorder = SafeMediaRecorder(outputPath) recorder.stopSafely() // 何も起きないはず assertEquals(RecordingState.IDLE, recorder.state) } @Test fun doubleRelease_shouldNotCrash() { val recorder = SafeMediaRecorder(outputPath) recorder.release() recorder.release() // 2回目もクラッシュしない }

これらをCIで回しておけば、カバレッジ100%にはなりませんが、主要なクラッシュ要因を防げます。

まとめ

本記事ではMediaRecorder#stop()で発生するNullPointerExceptionの背景と、実装レベルで確実に回避するための「防御的ラッパー」パターンを紹介しました。

  • MediaRecorderの状態遷移を自前で管理し、無効なstop()を呼ばない
  • stop()は必ずtry-catchで囲み、例外を握りつぶす
  • ライフサイクルイベントで明示的にrelease()し、ネイティブリソースを逃がさない

この記事を通して、録音・録画機能をクラッシュなく実装できるだけでなく、他のネイティン部リソースを扱う際の「安全なラッパー」の設計パターンも身につけていただければ幸いです。

次回は、CameraX + MediaRecorderを組み合わせた「一発で動作する動画録画モジュール」の作り方を深掘りします。

参考資料