システム開発部のTです。
今回は、画面ごとに固有のバックミュージックを流すための実装を書いていきたいと思います。
ここでは画面、アプリケーション、画面遷移のライフサイクルが重要となってきますので、よろしければ、以下のページも参照いただければ幸いです。

Flutterでアプリ開発・ライフサイクルについて

概要

例として、以下の3つの画面が存在しており、それぞれの画面で専用のBGMを流せるようにしようと思います。

また、各画面でアプリがバックグラウンド状態になったとき、別画面に遷移するときBGMを一時停止するという形を想定します。

Androidの場合、画面Aの状態で戻るボタン押下したときはアプリが終了するので、当然BGMも終了させるようにします。

上記の仕様を踏まえて、以降進めていきます。

ライブラリについて

BGMを鳴らすため、以下のライブラリをpubspec.yamlのdependenciesに設定してください。

audioplayers

dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  audioplayers: ^0.19.1  # <-- これを追加

flutter:

  # To add assets to your application, add an assets section, like this:
  assets:
      - assets/bgm/   # <-- これも追加

また、BGMのリソースファイルをどこに置くかも合わせて定義しておきましょう。
本件では、プロジェクト直下に「/assets/bgm/」フォルダを用意したところにファイルを置くので、pubspec.yamlの「flutter:」の「assets:」に「assets/bgm/」を定義しておきます。

これで準備はできました。

BGM用のリソースファイルの準備

本件では、BGM素材としてMP3を利用します。
OGGやAACとかありますが、MP3のほうが両OSでサポートしているので、こちらを利用します。

プロジェクトトップにて「assets」フォルダを作って、更にその下に「bgm」フォルダを作ったなかにMP3ファイルを入れていきます。

上記では3つのMP3ファイルを入れていますね。

プレイヤークラスの実装

早速ですが、BgmPlayerというクラスを作成していきます。

import 'package:audioplayers/audioplayers.dart';
import 'package:audioplayers/audioplayers_api.dart';

class BgmPlayer {

  late AudioCache _cache = AudioCache(fixedPlayer: AudioPlayer());
  AudioPlayer? _player;

  BgmPlayer({required String name, bool isLoop = true}) {
    () async {
      await _player?.stop();
      await _player?.dispose();
      if (isLoop) {
        _player = await _cache.loop(name, mode: PlayerMode.MEDIA_PLAYER);
      } else {
        _player = await _cache.play(name, mode: PlayerMode.MEDIA_PLAYER);
      }
    }();
  }

  void resumeBgm() async {
    _player?.resume();
  }

  void pauseBgm() async {
    await _player?.pause();
  }

  void stopBgm() async {
    await _player?.stop();
  }

  Future<void> disposeBgm() async {
    return await _player?.dispose();
  }

}

上記の内容を簡単に説明します。

コンストラクタでMP3ファイル名と、ループ再生有無を受け取ります。
呼び出しは以下の感じになります。

BgmPlayer bgm = BgmPlayer(name: "bgm/category_bgm.mp3");

気をつける必要があるのは、「assets/bgm/」以下にファイルがある場合、「assets」は省略します。
したがって、「assets」以降の「bgm/」からのパスを渡すことになります。

その後、AudioCacheのplayまたはloopを実行することで、MP3が再生されるしくみです。
再生するだけであれば、そんなに難しいものではありません。

resumeBgm()は、バックグラウンドからフォアグラウンド状態に戻ったとき、または、遷移先の画面から戻ってきたときにコールするメソッドになります。これを行うことで、pauseBgm()で一時停止していたBGMが続けて再生されるようになります。

pauseBgm()は、フォアグラウンドからバックグラウンド状態になったとき、または、別画面に遷移するときに、一時的に停止させたい場合にコールします。その後、画面の状態が戻ったときにresumeBgm()をコールすることで、再生が継続されます。

stopBgm()については、再生している画面をNavigator.pop()で終了させる場合、以降で記載しているdisposeBgm()とともにコールします。

disposeBgm()は、画面終了時の後始末の意味合いでコールします。

以上が、BgmPlayerクラスの説明となります。
以降では、このクラスを利用して、画面ごとの再生を可能にするための実装になります。

main.dartの実装

画面遷移に必要な定義をあらかじめ実装しておきます。

import 'package:flutter/material.dart';

import 'PageA.dart';
import 'PageB.dart';
import 'PageC.dart';

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

// RouteObserverを利用するので、本件ではクラス外にて定義
var routeObserver = RouteObserver<PageRoute>();

class MyApp extends StatelessWidget {

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      initialRoute: "/pageA",
      routes: {
        "/pageA": (context) => PageA(),
        "/pageB": (context) => PageB(),
        "/pageC": (context) => PageC(),
      },
      navigatorObservers: [
        routeObserver,
      ],
    );
  }
}

共通Stateクラスの定義

画面遷移や、アプリ、画面の状態などはStateクラスで行いますが、各画面で実装となると手間にもなるので、共通クラスを作って、そこに実装していきたいと思います。
今回、BGMの再生、停止などは、この共通クラス上で行います。
また、本件はアプリ、画面、画面遷移それぞれのライフサイクルを活用して実装しています。
ライフサイクルについては、Flutterでアプリ開発・ライフサイクルについてを一読いただければと思います。

import 'package:bgmtestapp/BgmPlayer.dart';
import 'package:flutter/widgets.dart';
import '../../main.dart';

abstract class BasePageState<T extends StatefulWidget> extends State<T> with WidgetsBindingObserver, RouteAware {
  BgmPlayer? _bgm;
  // 自身の画面が表に表示されているかのフラグ
  bool isActive = false;

  // コンストラクタ --------------------------------------------------
  BasePageState({String? fileName}) {
    if (fileName != null && fileName.isNotEmpty) {
      _bgm = BgmPlayer(name: "bgm/$fileName");
    }
  }

  // アプリケーションのライフサイクル start --------------------------------------------------
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      this.onForeground();
    } else if (state == AppLifecycleState.paused) {
      this.onBackground();
    }
  }
  void onBackground() {
    // ホーム画面に切り替わり、バックグラウンド状態のとき、BGMを一時停止する
    this._bgm?.pauseBgm();
  }

  void onForeground() {
    if (this.isActive) {
      // 自分自身の画面がトップに表示されていれば、以降の処理を実施
      // ホーム画面からアプリに戻り、フォアグラウンド状態に戻ったとき、BGMを再生する
      this._bgm?.resumeBgm();
    }
  }
  // アプリケーションのライフサイクル end --------------------------------------------------
  // StatefulWidget(画面)のライフサイクル start --------------------------------------------------
  @override
  void initState() {
    super.initState();
    // アプリの状態遷移をハンドリングできるようにする
    WidgetsBinding.instance?.addObserver(this);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // 画面遷移をハンドリングできるようにする
    routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
  }

  @override
  void dispose() {
    // POP、replace時に画面終了する際、後始末をする
    this.isActive = false;
    this._bgm?.stopBgm();
    this._bgm?.disposeBgm();
    this._bgm = null;
    WidgetsBinding.instance?.removeObserver(this);
    routeObserver.unsubscribe(this);
    super.dispose();
  }
  // StatefulWidget(画面)のライフサイクル end --------------------------------------------------
  // 画面遷移時のライフサイクル start --------------------------------------------------
  @override
  void didPush() {
    // 自身の画面がコールされたときに、isActiveをtrueにする
    this.isActive = true;
    super.didPush();
  }

  @override
  void didPopNext() {
    // 次画面がPOPされ自身の画面に戻ってきたとき、isActiveをtrueにし、BGMを再生させる
    this.isActive = true;
    this._bgm?.resumeBgm();
    super.didPopNext();
  }

  @override
  void didPushNext() {
    // Pushにて次画面に遷移する際、isActiveをfalseにし、BGMを一時停止させる
    this.isActive = false;
    this._bgm?.pauseBgm();
    super.didPushNext();
  }

  // 画面遷移時のライフサイクル end --------------------------------------------------

  // 子クラスにおいては標準のbuildの代わりに実装を強制させる
  // 標準のbuildを利用したい場合、本メソッドの戻り値にnullを設定可能とする
  Widget? buildChildWidget(BuildContext context);

  @override
  Widget build(BuildContext context) {
    return Builder(builder: (context) => WillPopScope(
        child: this.buildChildWidget(context) ?? Text(""), // 上記の抽象メソッドをコール
        onWillPop: () async {
          // 自身の画面がPOPされたときに、Playerクラスの後始末をする
          // Androidでの戻るボタンでアプリを終了させる場合、
          // POP実行前のタイミングで後始末しないとBGMが止まらなくなる・・・
          this.isActive = false;
          this._bgm?.stopBgm();
          await _bgm?.disposeBgm();
          this._bgm = null;
          return true;
        })
    );
  }
}

ちょっと長いコードになってしまいましたが、ご理解ください。
わかりづらくしているところですが、いくつか抑えるポイントがありますので、以下に記載していきます。

戻る操作についての対応

ヘッダーの戻るボタン押下時、およびAndroidの戻るボタンを押下したときの対応も考慮する必要があります。
通常、画面終了時にdispose()がコールされるため、そのタイミングでBGMの終了処理を入れておけば問題ありませんが、Androidの場合は戻るボタン押下時に画面の戻り先が存在しない場合はアプリが終了してしまいます。

アプリが終了するときも、dispose()はコールされるのですが、アプリ終了時にdisposeでBGMの終了処理を実装しても、なぜかBGMが止まらずに、再生が止まらない事象がございました。そのため、実際のPOPされる前に、BGMの終了処理を実装する必要があります。

POPされる前に処理をさせるための実装ですが、本件ではWillPopScopeを利用してPOP前にBGM終了処理を実装しました。それが、以下になります。

  @override
  Widget build(BuildContext context) {
    return Builder(builder: (context) => WillPopScope(  // <---これを実装
        child: this.buildChildWidget(context) ?? Text(""),
        onWillPop: () async {
          // 自身の画面がPOPされたときに、Playerクラスの後始末をする
          // Androidでの戻るボタンでアプリを終了させる場合、
          // POP実行前のタイミングで後始末しないとBGMが止まらなくなる・・・
          this.isActive = false;
          this._bgm?.stopBgm();
          await _bgm?.disposeBgm();
          this._bgm = null;
          return true;
        })
    );
  }

上記のonWillPop内で後始末処理が可能となります。
また、async付きなので、await付きの同期的に処理することができます。
そのため、disposeBgm()が終わったあとに、実際のPOP処理が実行されるので、
アプリ終了時にBGMを止めることができます。

上記により、Androidのアプリ終了処理に対応できます。

isActiveの役割


本件では、画面が3つあることを前提としていますが、では以下のように画面遷移を行った場合・・・、

画面A → 画面B → 画面C

現在画面Cが画面に表示された状態だと思ってください。
その状態のなか、画面AとBはどうなっているでしょうか。
バックグラウンド状態?いやいや、普通にフォアグラウンド状態ですよね?

画面A(フォア) → 画面B(フォア) → 画面C(フォア)
なんですね・・・。

単純に、画面Cが画面AとBの上に表示されているだけの話であり、アプリ的にはフォアグラウンドでしかありません。では、現在表示されている画面Cがアクティブ状態であることをどのように判定するか?結論から記述すると、判定する手段がないようですので、手動でフラグ操作の対応をしたものが、isActiveになります。

これが無いとどうなってしまうのか?
画面A → 画面B → 画面Cと呼び出して、現在画面Cだとします。
画面AもBもBGMが一時停止状態、画面CのみBGMが再生されている状態です。
ここで、ホームボタンを押してバックグラウンドにしたとします。
当然、画面CのBGMが止まります。
今の状態で、アプリを再度フォアグラウンド状態にすると・・・、isActiveが無かった場合、
画面A、B、Cが一斉にフォアグラウンド状態を検知し、盛大に3曲が再生されてしまうのでした・・・。

それを防ぐために、isActiveというフラグ操作を実装し、裏側に隠れている状態では、BGMを再生しないように制御する必要がありました。このフラグのおかげで・・・、

画面A(isActive=false) → 画面B(isActive=false) → 画面C(isActive=true)

アプリをバックグラウンドに切り替え、再度フォアグラウンドにしても・・・、

  void onForeground() {
    if (this.isActive) {
      // 自分自身の画面がトップに表示されていれば、以降の処理を実施
      // ホーム画面からアプリに戻り、フォアグラウンド状態に戻ったとき、BGMを再生する
      this.bgm?.resumeBgm();
    }
  }

isActive=trueの画面CのみがBGM再生されるようになる仕組みになります。

上記以外は難しいところは特になく、コメントの記載どおりになります。

各画面への実装

以降、各画面の実装になります。
(以下は画面Aの実装例になりますが、画面B、Cも同様になります)

import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'BasePageState.dart';

class PageA extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return _PageA();
  }
}

class _PageA extends StatefulWidget {
  @override
  _PageAState createState() {
    return _PageAState();
  }
}

class _PageAState extends BasePageState<_PageA> {  // <-- ここの親クラスを「BasePageState」にする

  _PageAState(): super(fileName: "title_op_bgm.mp3");  // <-- 親クラスのコンストラクタにファイル名設定

  @override
  Widget buildChildWidget(BuildContext context) {  // <-- 通常のbuildメソッドの代わりに実装
    return Material(
        child: Scaffold(
            appBar: AppBar(
              title: Text("画面A"),
              automaticallyImplyLeading: true,
            ),
            body: Container(
                child: ElevatedButton(
                  child: Text("画面Bに進む"),
                  style: ElevatedButton.styleFrom(
                    primary: Colors.orange,
                    onPrimary: Colors.white,
                  ),
                  onPressed: () {
                    Navigator.pushNamed(context, "/pageB");
                  },
                ),
              alignment: Alignment.center,
            )
        )
    );
  }
}

Stateクラスの親クラスを、作成済みのBasePageStateに置き換えてください。
コンストラクタ上にて、親クラスのコンストラクタに再生したいMP3のファイル名を設定してください。
再生が不要の場合、親コンストラクタの呼び出し自体を無くせばOKです。

以下、PageBとPageCの画面コードも載せておきますね。
BGMファイル名については、ご自身で準備いただいたものに置き換えてください。

PageB.dart
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'BasePageState.dart';

class PageB extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return _PageB();
  }
}

class _PageB extends StatefulWidget {
  @override
  _PageBState createState() {
    return _PageBState();
  }
}

class _PageBState extends BasePageState<_PageB> {  // <-- ここの親クラスを「BasePageState」にする

  _PageBState(): super(fileName: "main_bgm.mp3");  // <-- 親クラスのコンストラクタにファイル名設定

  @override
  Widget buildChildWidget(BuildContext context) {  // <-- 通常のbuildメソッドの代わりに実装
    return Material(
        child: Scaffold(
            appBar: AppBar(
              title: Text("画面B"),
              automaticallyImplyLeading: true,
            ),
            body: Container(
              child: ElevatedButton(
                child: Text("画面Cに進む"),
                style: ElevatedButton.styleFrom(
                  primary: Colors.orange,
                  onPrimary: Colors.white,
                ),
                onPressed: () {
                  Navigator.pushNamed(context, "/pageC");
                },
              ),
              alignment: Alignment.center,
            )
        )
    );
  }
}

PageC.dart
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'BasePageState.dart';

class PageC extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return _PageC();
  }
}

class _PageC extends StatefulWidget {
  @override
  _PageCState createState() {
    return _PageCState();
  }
}

class _PageCState extends BasePageState<_PageC> {  // <-- ここの親クラスを「BasePageState」にする

  _PageCState(): super(fileName: "category_bgm.mp3");  // <-- 親クラスのコンストラクタにファイル名設定

  @override
  Widget buildChildWidget(BuildContext context) {  // <-- 通常のbuildメソッドの代わりに実装
    return Material(
        child: Scaffold(
            appBar: AppBar(
              title: Text("画面C"),
              automaticallyImplyLeading: true,
            ),
            body: Container(
              child: ElevatedButton(
                child: Text("画面Bに戻る"),
                style: ElevatedButton.styleFrom(
                  primary: Colors.orange,
                  onPrimary: Colors.white,
                ),
                onPressed: () {
                  Navigator.pop(context);
                },
              ),
              alignment: Alignment.center,
            )
        )
    );
  }
}

動作確認

ここまで実装しましたら、実際にアプリを動かしてみてください。
画面遷移することで、BGMも切り替わるかと思います。
また、バックグラウンド状態のときはBGMが止まり、フォアグラウンドに戻ったときに再度BGMが再生されるかと思います。Androidの場合、アプリ終了とともにBGMも止まることを確認してみてください。

以上がBGMを鳴らすための実装になります。

まとめ

いかがだったでしょうか。
今回、BGMを鳴らすタイミングについては、それぞれのライフサイクルをフル活用することで実現させてみました。ここまでを記事にするときに、私自身も画面遷移したときの動きや、バックグラウンド、フォアグラウンドのそれぞれの状態になるタイミングを調べながらの実装でした。次画面に遷移しても、前画面のBGMが停止しないことなどに調査に時間を要したもので、やっとのことで記事にすることができました。

Androidの場合、アプリ終了でdisposeに終了処理記載しているにも関わらず、BGMの再生が終わらない問題をどう制御しようかで悩んだものですね・・・。どうやら、disposeのタイミングだとdartとjavaのインターフェース(JNI?)の接続が切れてしまっているようで、そのためdart側からのBGM終了命令を受け付けていないように見受けられました。onWillPop()上に終了処理を記載することで解決できましたが、もっと効率のよい方法が見つかったら、今後記事にしていきたいと思います。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる