こんにちは、エンジニアインターンの佐藤郁弥です。
Google が開発した、iOSとAndroid、Webで動作するアプリケーションを1つのコードで書き切ることができるFlutterというフレームワークを用いて、iOSとAndroidのアプリを作ることをひとまずの目標にしつつ、技術調査をしてみました。
まだ発展途上の技術・言語なので、如何せん、日本語(英語も?)の情報が少ないという状況ですので、誰かの役に立つことを祈りながら、記事を書いてみた次第です。
※ 想定読者:Flutterを勉強し始めた人
※ Flutterのバージョン(Channel)は2.8.1を使用しています。
結論:できました。
今回作成したのは、URLの死活監視を行うアプリケーションでした。同期のインターン生と一緒に二人で作ったのですが、分担として、彼はiOS上での動作を確認するためにXcodeを、私はAndroid上での動作を確認するためにAndroid Studioを用いていました。
お互いに自分のエミュレータ上でアプリが動作するかどうかを確認しながらGitにcommit・pushしていただけなのですが、相手のpushをfetchして自分のエミュレータ上で動作させてみると、いつも滞りなく動作していました。
「君のcommit、私のエミュだと動かないんだけど。。。」
というような状況にはならなかったワケです(笑)
私の素朴な思い込みとして、学習当初、「アプリのUIはともかく、ディレクトリの構造とかはAndroidとiOSで違うんだし、そこはうまく動かなくなるのでは......?」と思っていただけに、本当に一切書き分けることなくアプリが作れたことに驚きました。
例えば、ローカルのデータベース上にアクセスするときはパスを指定する必要がありますよね。Androidなんかだとデータベースのディレクトリが入るのは /data/data/com.hogehoge.hogehoge/databases だったりするわけですが、実際にDartで書くときは
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
String path = join(await getDatabasesPath(), 'hogehoge.db');
final database = openDatabase(path, version: 1);
と書くだけで良いのです。getDatabasesPath() だけで全てを察してうまく機能してくれるので、iOSとAndroidとでコードを書き分ける必要は皆無です!
Streamというのは字面の通り、流れのようなもので、Futureと対を成す(と言っていいはず)オブジェクトです。
Futureは未来のどこかで返り値を持つのに対し、Streamは時事刻々と値を変え続ける性質があります。そのため、例えばプログレスバーを描画したいときはStreamが保持する値を参照しながら動的にバーを表示すれば良いわけです。
例えば、次のようなコードがあるとします。
Stream<int> streamhoge() async* {
for (int i=0; i<3; i++) {
print("streamhogehoge");
yield i;
}
}
Future<void> futurehoge() async {
print("futurehogehoge");
}
void main() async {
streamhoge();
futurehoge();
runApp(const MyApp());
}
この場合、asyncの関数はmain関数内で処理が実行されます。(ぶっちゃけ、内部に複数の処理を持たないのでasyncを使う意味はありません)
一方、async*の関数はfor文の中で、整数値をyield(繰り返しreturnするようなイメージ)します。
が、実は、steamhoge()はmain関数内で呼ばれているにも関わらず、このままでは処理が実行されません。標準出力には一度もstreamhogehogeが出てこないのです。
ここが、僕のタイトルにある「Streamの動作が予想と違う」というポイントです。
原因は公式ドキュメントに書いてあって、不具合とかではなく、それが期待される動作であるから、のようです。
""" You produce a stream by calling an async* function, which then returns a stream. Consuming that stream will lead the function to emit events until it ends, and the stream closes. """
というのもFlutterでは、Streamを返り値に持つ関数は、"Comsuming"されて初めて内部の処理が実行されるという仕様になっています。上述のコードでは、async*関数が呼ばれているのでStreamオブジェクトが準備されてはいるのですが、"Comsuming"されていないので、関数の内部のprint文やyieldが実行されていないわけです。
"Consuming"には、簡単なもので二通りあります。
他にもありますが、当記事では割愛します。
一つ目はリスナを置く方法です。
Stream<int> streamhoge() async* {
for (int i=0; i<3; i++) {
print("streamhogehoge");
yield i;
}
}
void main() async {
var stream = streamhoge();
var sum = 0;
// using listen()
stream.listen((value) {
sum += value;
print("sum : ${sum}");
});
}
""" The more low-level listen method is
what every other method is based on. """
Streamオブジェクトには他にもメソッドがありますが、それらはlistenを使って記述されているらしく、このメソッドはかなり基本的なものであると言えそうです。
2つ目の方法はawait forを使う方法です。
Stream<int> streamhoge() async* {
for (int i=0; i<3; i++) {
print("streamhogehoge");
yield i;
}
}
void main() async {
var stream = streamhoge();
var sum = 0;
// using await for
await for (final value in stream) {
sum += value;
print("sum : ${sum}");
}
}
listen(), await for のいずれを使っても、同じ結果を得ることができました。
streamhogehoge
sum : 0
streamhogehoge
sum : 1
streamhogehoge
sum : 3
とはいえ、明示的に(というか表面的に)listen() も await for もしないような使い方も存在します。今回、死活監視アプリ(もう一人のインターンの子が記事にしてくれています)の製作で頻繁に使ったStreamBuilder()などがその一例です。
StreamBuilder()ではStreamを引数に渡すだけでStreamの値の変化に応じて画面の描画を行うことができるのですが、それがとても便利で、僕自身そうした便利な関数をなんとなく使っているだけだったので、Streamの動作についてよく理解していない節がありました。
今回、そうしたStreamの動作についてまとめの記事を書く過程で、Dart・Flutterの仕様について、また少し理解を深められたかな、と思います。
・「「async*を用いてStreamを返す関数を作る場合、"Consuming"するまで内部の処理が実行されない」」
今回、社内にFlutterを触っている方がいない状況で、インターンの同期の子と一緒に技術調査をしましたが、やや億劫でも公式ドキュメントをきちんと読みこむことが、言語・フレームワーク理解にとって大切なんだな、という素朴な結論に至ることができました。
まだまだエンジニアとして半人前ですが、これからも精進していこうと思います。
※2022年4月11日時点