Zubora Code

Flutterでアイコンをタップしたら音が出るシンプルなアプリを開発してリリースする 1/n アプリを開発する

Flutterでアイコンをタップしたら音が出るシンプルなアプリを開発してリリースするまでをまとめるシリーズの1記事目です

Published: 1 August, 2023
Revised: 1 August, 2023

反省

以前から何かしらアプリを開発してリリースしたかったんですが、アイデアを考えて途中まで開発したものの、本業の仕事が忙しくなってしばらく離れ、段々とやる気がなくなり、中途半端に投げ出す、ということを繰り返してきました。同じような方も多いのではないかなと思います。



ゴール

そこで今回は、開発するアプリ自体はかなりシンプルなものにして、とにかくまず1つリリースすることをゴールとします。アプリの個人開発は特に、最初の1つをリリースするまでのハードルがすごく高く、1つ出している人は2つ3つとどんどん出している、という話をよく聞きます。今回で私もその1つ目のハードルをまず超えることをゴールとします。「なるほどこんなんでいいのね」と思って貰えたら幸いです。


作るアプリの概要

かなりシンプルなアプリにするとは言っても、誰も使い手がいないのは寂しいです。そこで私は、現在1歳1ヶ月の息子向けのアプリを開発することにしました!息子は普段からかなりスマホに興味津々で、いつも奪い取ろうとしてきます。ちょうどそろそろ、指差しの練習をしたいと思っていたところでした。そんな時に使えるこんなアプリを作っていきます。



現時点ではまだデモ段階で、ここに乗り物のページを追加して、タブで切り替えられるようにしようと思ってます。でも、それだけです。データストアも認証も状態管理ライブラリも何も使っていない、アイコンをタップしたら音が出るだけの、非常にシンプルなアプリです。


ちなみに音声は効果音ラボ(@soundeffect_lab)さん、イラストはいらすとや(@irasutoya)さんからお借りしています...! ありがとうございます🙇🏻‍♂️



全ての記事を #first_flutter_tutorial というタグに紐づけます。

ソースコードは全て以下で公開しています。

https://github.com/tkugimot/touch_and_hear



構築

FlutterでHello World の記事に記載したのと同じ要領で新しいアプリを作成してGitHubにPUSHしておきます。アプリ名は「touch_and_hear」にします。

$ flutter create touch-and-hear
$ git init
$ git add .
$ git commit -m 'Initial Commit'
$ git remote add origin git@github.com:tkugimot/touch_and_hear.git
$ git push origin main
$ git checkout -b feature/sound-app


準備

効果音ラボさんから、動物の音声6つ、乗り物の音声6つをダウンロードします。

続いて、いらすとやさんから対応するイラストを12個ダウンロードします。


開発

assetsディレクトリへ画像と音声ファイルを配置

まず、先ほどダウンロードした画像と音声ファイルをルート直下の assets ディレクトリに配置します。


$ git status
On branch feature/sound-app
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   assets/images/bicycle.png
        new file:   assets/images/bike.png
        new file:   assets/images/cat.png
        new file:   assets/images/dog.png
        new file:   assets/images/elephant.png
        new file:   assets/images/fune.png
        new file:   assets/images/horse.png
        new file:   assets/images/kyukyusya.png
        new file:   assets/images/leo.png
        new file:   assets/images/patocar.png
        new file:   assets/images/piyo.png
        new file:   assets/images/super_car.png
        new file:   assets/sounds/bicycle.mp3
        new file:   assets/sounds/bike.mp3
        new file:   assets/sounds/cat.mp3
        new file:   assets/sounds/dog.mp3
        new file:   assets/sounds/elephant.mp3
        new file:   assets/sounds/fune.mp3
        new file:   assets/sounds/horse.mp3
        new file:   assets/sounds/kyukyusya.mp3
        new file:   assets/sounds/leo.mp3
        new file:   assets/sounds/patocar.mp3
        new file:   assets/sounds/piyo.mp3
        new file:   assets/sounds/super_car.mp3

$ git add .
$ git commit -m 'Add assets'


pubspec.yamlの修正

続いて、pubspec.yaml を修正して assets をアプリから読み込めるように設定します。


-  # assets:
-  #   - images/a_dot_burr.jpeg
-  #   - images/a_dot_ham.jpeg
+  assets:
+    - assets/sounds/
+    - assets/images/


soundpool ライブラリの導入

次に、音楽を再生するのに必要な soundpool というライブラリをインストールします。


$ flutter pub add soundpool
$ git add .
$ git commit -m 'Update pubspec.yaml'

※以降はコミットコマンドまでは書かないので、適当なタイミングでコミットしてください。


音楽の再生処理を担う Sound クラスの実装

次に、音楽の再生処理を担う Sound クラスを作成します。

実装は https://gaprot.jp/2021/09/07/flutter_se/ こちらのサイトを参考にさせて頂きました。ありがとうございます🙇🏻‍♂️

import 'dart:io';

import 'package:flutter/services.dart';
import 'package:soundpool/soundpool.dart';

enum SoundIds {
  DogButton,
  CatButton,
  ElephantButton,
  LeoButton,
  HorseButton,
  PiyoButton,
  BicycleButton,
  BikeButton,
  FuneButton,
  KyuKyuSyaButton,
  PatocarButton,
  SuperCarButton
}

class Sound {
  String os = Platform.operatingSystem;
  bool isIOS = Platform.isIOS;
  late Soundpool _soundPool;

  final Map<SoundIds, int> _seContainer = Map<SoundIds, int>();
  final Map<int, int> _streamContainer = Map<int, int>();

  Sound() {
    this._soundPool = Soundpool.fromOptions(
        options: SoundpoolOptions(
            streamType: StreamType.music, maxStreams: 5 // 5音同時発音に対応させる
            ));
    () async {
      var dogButton = await rootBundle
          .load("assets/sounds/dog.mp3")
          .then((value) => this._soundPool.load(value));
      var catButton = await rootBundle
          .load("assets/sounds/cat.mp3")
          .then((value) => this._soundPool.load(value));
      var elephantButton = await rootBundle
          .load("assets/sounds/elephant.mp3")
          .then((value) => this._soundPool.load(value));
      var leoButton = await rootBundle
          .load("assets/sounds/leo.mp3")
          .then((value) => this._soundPool.load(value));
      var horseButton = await rootBundle
          .load("assets/sounds/horse.mp3")
          .then((value) => this._soundPool.load(value));
      var piyoButton = await rootBundle
          .load("assets/sounds/piyo.mp3")
          .then((value) => this._soundPool.load(value));
      var bicycleButton = await rootBundle
          .load("assets/sounds/bicycle.mp3")
          .then((value) => this._soundPool.load(value));
      var bikeButton = await rootBundle
          .load("assets/sounds/bike.mp3")
          .then((value) => this._soundPool.load(value));
      var funeButton = await rootBundle
          .load("assets/sounds/fune.mp3")
          .then((value) => this._soundPool.load(value));
      var kyukyusyaButton = await rootBundle
          .load("assets/sounds/kyukyusya.mp3")
          .then((value) => this._soundPool.load(value));
      var patocarButton = await rootBundle
          .load("assets/sounds/patocar.mp3")
          .then((value) => this._soundPool.load(value));
      var superCarButton = await rootBundle
          .load("assets/sounds/super_car.mp3")
          .then((value) => this._soundPool.load(value));

      this._seContainer[SoundIds.DogButton] = dogButton;
      this._seContainer[SoundIds.CatButton] = catButton;
      this._seContainer[SoundIds.ElephantButton] = elephantButton;
      this._seContainer[SoundIds.LeoButton] = leoButton;
      this._seContainer[SoundIds.HorseButton] = horseButton;
      this._seContainer[SoundIds.PiyoButton] = piyoButton;
      this._seContainer[SoundIds.BicycleButton] = bicycleButton;
      this._seContainer[SoundIds.BikeButton] = bikeButton;
      this._seContainer[SoundIds.FuneButton] = funeButton;
      this._seContainer[SoundIds.KyuKyuSyaButton] = kyukyusyaButton;
      this._seContainer[SoundIds.PatocarButton] = patocarButton;
      this._seContainer[SoundIds.SuperCarButton] = superCarButton;

      this._streamContainer[dogButton] = 0;
      this._streamContainer[catButton] = 1;
      this._streamContainer[elephantButton] = 2;
      this._streamContainer[leoButton] = 3;
      this._streamContainer[horseButton] = 4;
      this._streamContainer[piyoButton] = 5;
      this._streamContainer[bicycleButton] = 6;
      this._streamContainer[bikeButton] = 7;
      this._streamContainer[funeButton] = 8;
      this._streamContainer[kyukyusyaButton] = 9;
      this._streamContainer[patocarButton] = 10;
      this._streamContainer[superCarButton] = 11;
    }();
  }

  void play(SoundIds ids) async {
    var seId = this._seContainer[ids];
    if (seId != null) {
      var streamId = this._streamContainer[seId] ?? 0;
      if (streamId > 0 && isIOS) {
        await _soundPool.stop(streamId);
      }
      this._streamContainer[seId] = await _soundPool.play(seId);
    } else {
      print("se resource not found! ids: $ids");
    }
  }

  Future<void> dispose() async {
    await _soundPool.release();
    _soundPool.dispose();
    return Future.value(0);
  }
}



見た目の実装

最後に、アイコンを表示してタップされたらSoundクラスに実装した再生処理を実行する処理をmain.dartに書きます。


import 'package:flutter/material.dart';
import 'Sound.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ぴよぴよサウンドパーク',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'ぴよぴよサウンドパーク'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

Sound sound = Sound();

class _MyHomePageState extends State<MyHomePage> {
  int _selectedIndex = 0;
  static const TextStyle optionStyle =
      TextStyle(fontSize: 30, fontWeight: FontWeight.bold);
  final List<Widget> _widgetOptions = <Widget>[
    GridView.count(
        // Create a grid with 2 columns. If you change the scrollDirection to
        // horizontal, this produces 2 rows.
        crossAxisCount: 2,
        // Generate 100 widgets that display their index in the List.
        children: [
          IconButton(
              icon: Image.asset('assets/images/dog.png'),
              iconSize: 50,
              onPressed: () {
                sound.play(SoundIds.DogButton);
              }),
          IconButton(
              icon: Image.asset('assets/images/cat.png'),
              iconSize: 50,
              onPressed: () {
                sound.play(SoundIds.CatButton);
              }),
          IconButton(
              icon: Image.asset('assets/images/elephant.png'),
              iconSize: 50,
              onPressed: () {
                sound.play(SoundIds.ElephantButton);
              }),
          IconButton(
              icon: Image.asset('assets/images/leo.png'),
              iconSize: 50,
              onPressed: () {
                sound.play(SoundIds.LeoButton);
              }),
          IconButton(
              icon: Image.asset('assets/images/horse.png'),
              iconSize: 50,
              onPressed: () {
                sound.play(SoundIds.HorseButton);
              }),
          IconButton(
              icon: Image.asset('assets/images/piyo.png'),
              iconSize: 50,
              onPressed: () {
                sound.play(SoundIds.PiyoButton);
              }),
        ]),
    GridView.count(
        // Create a grid with 2 columns. If you change the scrollDirection to
        // horizontal, this produces 2 rows.
        crossAxisCount: 2,
        // Generate 100 widgets that display their index in the List.
        children: [
          IconButton(
              icon: Image.asset('assets/images/bicycle.png'),
              iconSize: 50,
              onPressed: () {
                sound.play(SoundIds.BicycleButton);
              }),
          IconButton(
              icon: Image.asset('assets/images/bike.png'),
              iconSize: 50,
              onPressed: () {
                sound.play(SoundIds.BikeButton);
              }),
          IconButton(
              icon: Image.asset('assets/images/fune.png'),
              iconSize: 50,
              onPressed: () {
                sound.play(SoundIds.FuneButton);
              }),
          IconButton(
              icon: Image.asset('assets/images/kyukyusya.png'),
              iconSize: 50,
              onPressed: () {
                sound.play(SoundIds.KyuKyuSyaButton);
              }),
          IconButton(
              icon: Image.asset('assets/images/patocar.png'),
              iconSize: 50,
              onPressed: () {
                sound.play(SoundIds.PatocarButton);
              }),
          IconButton(
              icon: Image.asset('assets/images/super_car.png'),
              iconSize: 50,
              onPressed: () {
                sound.play(SoundIds.SuperCarButton);
              }),
        ]),
  ];

  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: _widgetOptions.elementAt(_selectedIndex),
      ),
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.pets),
            label: 'どうぶつ',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.directions_car),
            label: 'のりもの',
          ),
        ],
        currentIndex: _selectedIndex,
        selectedItemColor: Colors.amber[800],
        onTap: _onItemTapped,
      ),
    );
  }
}


以上でアプリの開発自体は完了です。一応、以下が今回の修正分のPull Requestです。

https://github.com/tkugimot/touch_and_hear/pull/1?w=1



次回以降の記事ではリリースまでに必要なこととして以下を書いていきます。

  • 多言語対応 (日本語 + 英語)
  • Crashlyticsの導入 (監視のため)
  • AdMobの導入 (広告のため)
  • アイコン/フィーチャーグラフィックの作成・設定
  • サポートサイト/プライバシーポリシーサイトの作成
  • Drawerの追加(アプリの使い方/お問い合わせ/プライバシーポリシーへのリンク)
  • Androidへのリリース
  • iOSへのリリース


Toshimitsu Kugimoto

Software Engineer

仕事では決済やメディアのWebやスマホアプリのBE開発、WebのFE開発をやっています。 JavaとTypeScriptをよく使います。プライベートではFlutterでのアプリ開発にも挑戦中です。