Flutter でモバイルアプリ

Android / iOS で動くアプリを作るには、別々の言語やフレームワークを覚えなければいけませんでした.
最近は、React Nativeなどの JavaScript ベースの Web の技術でアプリを作れるものや、 Googleが開発したFlutterのようにクロスプラットフォームの開発環境がでてきています.
Flutterでアプリを作るとどんな感じになるのか、実装を見てみたいと思います.

Flutter

モバイルアプリを作るには、 Android なら Kotlin/Java で、 iOS なら Swift/Objective-C のように独自の言語を勉強して、その上で別々の API の仕組みを理解して使いこなしていくというのが必要でした。 また、それぞれの言語や API はかなり考え方や使い方が異なります。 両方とも勉強して使いこなすのはかなり大変で、普通は別のエンジニアやチームでやって、同じ様なことに二重に投資するのが当たり前の世界でした。(アプリのビジネスロジックとか同じものを、違う実装言語のために作り直す様なことはしょっちゅうでした)

それぞれのプラットフォームで実装がどれくらい違うのか、以下のようなものを参照してみてください。

TECHaas - Video Player

一方で Web アプリ (JavaScript) のフレームワークの進化で、最近は色々と凝った UI/UX が実装できる様になってきました。 その辺の技術を使ってネイティブアプリを開発できる React Native のようなものもあります。 ただ、Web ベースの技術は、フローの制御やエラー処理とか、リソース管理が苦手な傾向にあります。 元々がステートレスな Web やブラウザが色々と吸収してくれるところから発展してきたためだと思います。 (この辺はツッコミどころ満載だと思うので、深く追いません)

JavaScript ベースのアプリの利点は、一度作れば、Android でも iOS でも動くところですが、実際にはブラウザの動作の違いで苦労することも多々あるようです。

ネイティブ言語と JS ベースの中間的なところを狙ったんだと思いますが、Google はFlutterを開発しました。 Dartと呼ばれる独自の言語を使いますが、アーキテクチャとしてはreactのような Web ベースのものに近く、かつ、ネイティブアプリで使う様なプラットフォーム寄りの API もきちんと用意されています。 Android と iOS の両方で同じアプリが動きますし、最近は Web アプリの開発もできる様になってきています。

クロスプラットフォームの技術はまだ発展期にあり、今後どの辺に流れていくかわかりません。 実際にアプリを組んだ人ならわかると思いますが、フレームワークやAPIの充実度や安定度は、その上で動くアプリに非常に大きく影響します。 砂上の楼閣じゃありませんが、しっかりしてない基礎の上には何を建ててもダメなものはダメです。 また、APIやエコシステムがしっかりしていないと、アプリ側で多くのコストを掛けてそれをカバーしなきゃなんてことになります。 (コアな部分だけではなく、よく使われる様なライブラリが移植されているかのようなエコシステムの評価は開発コストにもろに影響します)

Flutterは 2018 年にバージョン1.0がリリースされて、今年になり2.0もリリースされました。

かなりこなれてきて、簡単なアプリならそろそろってレベルになってきた感じがします。 (繰り返しますが自分で触って、必要なライブラリ等の成熟度をよく確認してみた方が良いです)

開発環境について

イントロがちょっと長くなりましたが、実際にどんなふうにアプリを作っていくのか見ていきましょう。

まずは開発環境から。 MacOS 使ってます。エディタは、主に VSCode なので、その前提です。

インストール方法については Flutter の開発者情報をみてください。

Flutter インストール

Mac で Homebrew を使っている人は、そっち経由が楽だと思います。 正しくインストールできているかは、 flutter doctor コマンドで確認できます。 (しばらく使ってない時や、開発ツールを更新した時など、doctorを流して開発環境をチェックしてください)

$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 2.2.1, on macOS 11.4 20F71 darwin-x64, locale
    ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 30.0.3)
[✓] Xcode - develop for iOS and macOS
[✓] Chrome - develop for the web
[✓] Android Studio (version 4.2)
[✓] VS Code (version 1.58.2)
[✓] Connected device (1 available)

動作確認をするために、同じ環境にAndroid StudioXcode等のネイティブアプリの開発環境が必要になります。 そちらを先に、単体できちんと動くようにしておいた方が良いかと思います。

Flutter アプリの生成

アプリケーションは、flutterコマンドでスケルトンを作成して、それを修正して作ります。 まずはサンプルが動くことを確認しましょう。 (どこかのレポジトリを clone して開始でも良いですが、サポートファイル等の確認とか面倒なので、flutterコマンドが良いと思います)

$ flutter create start_flutter

セットアップが流れて、アプリの雛形ができます。

iOS アプリを開発、実行するには、 Xcode で署名関連の設定が必要です。 (Android 用は、この段階でなにもしなくても動かせます)

$ cd start_flutter
$ open ios/Runner.xcworkspace

Xcode が開くと思いますので、 TARGETS の Runner の Signing & Capabilities で、自分の Team を選ぶのと、 Bundle Identifier を適当なものに変更します。 (詳しくは書きませんが、Xcode で iOS アプリを開発する時には、Apple Developer に登録が必要です。 登録した ID で都度アプリに署名をしないと実機でテストできません。 また、Bundle ID は、全アプリで固有のものにしておく必要があります。ぶつからない様に、普通 FQDN を逆にしたものとかにします。 com.example.flutter.xxx とか)

Flutter の実行

Visual Studio Codeの場合、ターゲットになるデバイスを、ウィンドウ下にあるステータスバーから選びます。

Chrome (web-javascript)がデフォルトになっていると思います。 クリックすると、エミュレータなどを選択できると思います。(初回は設定が必要です)

アプリケーションのソースは、lib/main.dartです。サンプルアプリは1画面なので、そのファイルだけです。

flutter のアプリでは、lib/以下に Dart ファイルを書いていきます。基本は、画面や部品ごとに、それぞれ dart ファイルを作っていきます。

lib/以外のディレクトリは、flutter がプラットフォーム毎に使う設定などで、プラットフォームの固有機能などを使わなければ、触らなくても良いです。

実行は、lib/main.dartを開いて、メインルーチンの上にある、Run|Debug|Profile のどれかを選べばできます。

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

この辺は使っている統合開発環境によって違うので、 Document などを参考にしてください。

エミュレータで実行するとこんな感じになるかと思います。



アプリを作ってみる

サンプルアプリの実行が確認できたら、それを修正しながら、アプリを作ってみます。 今回は画像を読み込んで順次表示する紙芝居するだけの単純なアプリです。

ソースコードは、以下に置いてあります。

Github - start_flutter

flutter では、アプリケーションからUIの部品まで全部が Widget になります。 UIの画面は、それを組み合わせて作ります。その辺は、 Webアプリに似てます。

Widget には、大きく StatelessWidgetStatefulWidget があります。 違いは、ステートと呼ばれる状態でUIが変化するかどうかです。 StatelessWidget は一度生成されると変化しません。 それに対して、 StatefulWidget では、State が変化するたびに、UI を作り替えることで変化します。

今回のアプリでは、メインは StatelessWidget ですが、そこに配置する HomePage は StatefulWidget にしています。 メインアプリは一度生成したら変化しませんが、画面は、中に表示する画像を変化させるためです。

Widget は、それぞれの build()関数を override して、描画する内容を返す様に実装していきます。 その階層を積み重ねて、アプリを作ります。

MaterialApp

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,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

メインアプリは、 MeterialAppというアプリケーションのスケルトンを生成して、そこにMyHomePageを配置します。

MaterialAppは、マテリアルデザインと呼ばれる Google Android でデフォルトの UI デザインのアプリとなります。 (ちなみに、iOS風のデザインにするなら、 CupertinoAppをベースにすることもできます。ただし、UI デザインは、あくまで見かけを似せるだけなので、完全に同じにはなりません。)

今回表示するのは1画面だけで、画面遷移はしませんので、MyHomePageという画面を作って、homeに設定します。

image assets

UIプログラムから話がそれますが、ここで表示する画像ファイルをアプリに組み込む方法を見ておきます。 画像ファイルは、 images/ というディレクトリを作って、そこに配置します。 (ピクトグラムなのは時節柄です)

それだけでは、実行時にアプリからアクセスすることはできませんので、設定が必要です。

pubspec.yaml に以下の行を追加します。

flutter:
  ...
  assets:
    - images/

この設定で、images/ 以下のファイルがアプリに取り込まれて、assetを通してアクセスできるようになります。

StatefulWidget

MyHomePageStatefulWidgetです。

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

  final String title;

  final List<String> images = [
    'images/1.jpeg',
    'images/2.jpeg',
    'images/3.jpeg',
    'images/4.jpeg',
    'images/5.jpeg',
    'images/6.jpeg',
  ];

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

StatefulWidget には、対応する State が必要です。

あと、_から始まる名前や定義はファイルローカルです。

State では、管理する状態と、それが変化した時に、呼び出される build()メソッドを定義します。

class _MyHomePageState extends State<MyHomePage> {
  int _imageIndex = 0;
  double _opacity = 1;
  @override
  void initState() {
    Timer.periodic(
      Duration(seconds: 5),
      _onTimer,
    );
    super.initState();
  }

initState() は、Stateの初期化時に、必ず呼び出されます。

定期的に動作するタイマーをセットします。 Timer.periodic()で、Duraion(seconds: 5)毎に_onTimerを呼び出すという設定です。 (periodicは一度設定すると、繰り返し呼び出し続けます)

  void _onTimer(Timer timer) {
    setState(() => _opacity = 0);
    Future.delayed(
        Duration(milliseconds: 500),
        () => setState(() {
              _imageIndex = (_imageIndex < widget.images.length - 1) ? _imageIndex + 1 : 0;
              _opacity = 1;
            }));
  }

タイマーで 5秒毎に呼び出される関数では、 setState() でステート変化を通知します。 setState には、関数を引数として渡します。 上では、匿名関数になっていますが、普通に定義した関数でも平気です。 関数が実行された後に、後で触れる様に、build()が呼び出されます。

500ms (0.5秒) 後に、また setState()をするFuture.delayed()をセットします。 Future の中では、 _imageIndex を1ずつインクリメントする処理をしています。 (そして、最大数になったら0にするので、ずっとループします)

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: AnimatedOpacity(
          opacity: _opacity,
          duration: Duration(milliseconds: 500),
          child: Image.asset(widget.images[_imageIndex]),
        ),
      ),
    );
  }
}

最後に、build() を定義します。開始時と setState() で状態変化が起きた時に、毎回 build() が呼び出されます。 ここでは、 AnimatedOpacity() で透明度のアニメーションをするようにしています。

その中では、Image.asset() でアプリに含んだ画像を表示する感じです。

タイマーで 5秒おきに、 _onTimer() が呼び出されて、 _imageIndex をインクリメントしていますので、その度に 呼び出す画像が変わっていきます。

_onTime()では、最初に _opacity=0 にします。 _opacityは不透明度なので、0 にすると透明になります。 AnimatedOpacity() で呼び出されるので、500ms で、だんだん透明になるアニメーションとなります。 500ms 後の Future.delay() 実行で、今度は _opacity=1 になり、完全に不透明 (つまり全表示) となります。

Image に画像をセットするんじゃなくて、ステートを変化させることで画像が変わっていくというところがキモ (reactive) です。 今回は簡単な例で、小さなアプリなのであまり関係ないですが、そうすることで UI を作る build() の実装とビジネスロジックを分離することができるようになります。 (開発だけじゃなくて、テストなどさまざまなコスト要因に効いてきます)

最後に

実行するとこんな感じになるかと思います。

このまま、Android や iPhone などの実機でも動作させることができます。

また、画面を回転とかしてもきちんとステート管理されているので、そのまま実行が続くと思います。 そういう面倒なところはフレームワーク任せにできるところとか、後出しの利点ですね。