こんにちは!エンジニアインターンの斉藤です。
Flutterの技術調査も兼ねて、初めてのモバイルアプリ開発に挑戦しました。
今回はそこで得られた知見や、直面した問題とその解決方法などをシェアできればと思います!
Flutterは、Googleによって開発されたモバイルアプリフレームワークです。
主な特徴として以下の3点が挙げられます。
クロスプラットフォーム技術により、Android, iOS, Webのアプリの開発を単一のソースコードで行うことができ、開発にかかる工数を削減できます。
実際、今回はインターン生2人でコラボ開発を行い、僕はiOS、もう一人はAndroidのエミュレータを使用していたのですが、OSの違いから生じる問題は1度も発生しませんでした。
2022年の2月にはWindowsのデスクトップアプリも同時開発が可能となり、macOS,Linuxのデスクトップアプリも今後これに加わることが発表されています。
ホットリロードとは、プログラムの変更を即座にUIへ変更できる機能のことです。細かい変更を待ち時間なく確認することができ、全体として開発に要する時間を削減できます。
Flutterによる開発の際は、Dartというプログラミング言語を使用します。
この言語はクライアントのセキュリティやブラウザのサポートなど、JavaScriptの欠点を解決できるように設計されています。
したがって、JavaScriptの設計を踏襲しているため、JavaScriptを習得している方であれば扱いやすい言語であるといえます。
今回インターン生2人で開発したのは、CyberOwlが管理しているWebサイトの死活監視ができるモバイルアプリです。
ホーム画面ではドメインごとにサーバーレスポンスタイムの平均値が表示されます。
右上の更新ボタンで再度計測ができます。
ボタンのタップでドメインごとの詳細ページへ遷移します。
URLとそのレスポンスタイムが表示されており、ヘッダーのカラム名をタップすることで、ソートすることができます。
また、ドメイン詳細ページの更新ボタンを押すと、表示中のドメインのレスポンスタイムのみ再計測します。
※表示されているレスポンスタイムはデバイスから直接リクエストを送り、レスポンスまでの時間を計測しているため、厳密な値とは異なります。また、使用するデバイス・回線、時間帯によって大きく異なります。
説明が長くなってしまいましたが、ここから記事の本題に入ります。
今回の開発では、ドメイン詳細ページのUI、そこに必要なバックエンドを担当しました。
開発を進める上で理想的にUIが動いてくれず、かなり苦戦しましたので、どんな問題が生じ、どのように解決したのかを説明できればと思います。
どのアプリ開発においても、ユーザーの見ている内容を変化させるにはUIを再描画する必要があります。変数を更新するだけでは書き換えることはできません。
FlutterではFutureBuilderやsetState()など、再描画を助ける様々なクラスやメソッドが用意されていますが、目的や用途に応じて使い分けることができます。
ドメイン詳細ページでは、値がDBに挿入されると同時にUIが再生成されるという挙動が理想になります。
計測が完了していないURLのレスポンスタイムはnullで表示されていますが、バックエンドではレスポンスタイムの計測、その値をローカルDBに挿入するプログラムが動いています。
しかし、DBが更新されただけではレスポンスタイムの表示は更新されません。
そのため、何かしらのアクションを起点としてUIを再描画する必要があります。
//next_page.dart
import 'package:flutter/material.dart';
import 'database.dart';
import 'dart:async';
Stream<List<Site>> getUrlAndResTime(String domain) async*{
var listOfSites = await getSitesByDomain(domain);
yield listOfSites;
while((await getSitesByDomain(widget.domain))[0].resTime == null){
await Future.delayed(Duration(milliseconds:1000));
listOfSites = await getSitesByDomain(domain);
yield listOfSites;
}
}
StreamBuilderを使用し、DBから取り出した値にnullがあれば1秒後に再度取り出しStreamに流す、といった状態です。
//database.dart
import 'dart:async';
import 'dart:io' as io;
import 'package:flutter/material.dart';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import 'package:flutter/widgets.dart';
Future<dynamic> openSiteDatabase() async {
WidgetsFlutterBinding.ensureInitialized();
String path = join(await getDatabasesPath(), 'site_database.db');
bool exist = await io.File(path).exists();
if (exist) {
final database = openDatabase(
path,
version: 1,
);
return database;
} else {
final database = openDatabase(
path,
onCreate: (db, version) {
return db.execute(
'CREATE TABLE Sites(url TEXT PRIMARY KEY, domain TEXT, resTime REAL);',
);
},
version: 1,
);
return database;
}
}
class Site {
final String url;
final String domain;
final double? resTime;
Site({
required this.url,
required this.domain,
required this.resTime,
});
Map<String, dynamic> toMap() {
return {
'url': url,
'domain': domain,
'resTime': resTime,
};
}
}
Future<List<Site>> getSitesByDomain(String domain) async {
final db = await openSiteDatabase();
final List<Map<String, dynamic>> maps = await db.query('Sites', where: "domain=?", whereArgs: [domain],orderBy: "resTime ASC");
return List.generate(maps.length, (i) {
return Site(
url: maps[i]['url'],
domain: maps[i]['domain'],
resTime: maps[i]['resTime'],
);
});
}
Future<void> updateSite(var db, Site site) async {
await db.update(
'Sites',
site.toMap(),
where: 'url = ?',
whereArgs: [site.url],
);
}
DBの操作にはSQLiteを使用しています。
上のコードでも一応UIの更新自体は問題なくできますが、問題点が2つあります。
レスポンスタイムの計測途中の場合、何がなんでも1秒後にはUIを更新してしまいます。
そのため、1秒以内に新しい値がDBに挿入されなかった場合、表示内容が変化しないのにも関わらず、UIを更新してしまいます。
1秒間隔の再描画であればメモリに大した負荷はかからないので、フリーズしたり、落ちてしまったりすることはありませんが、UIを再生成するロジックとして非合理的ですし、何よりスッキリしません。
レスポンスタイムの計測が終わっていない場合に1秒後にUIを再生成する、ということはその1秒間は何もUIに変化がないのです。
そうなるとユーザーがアプリを使った際、サクサク値が更新されているという感覚を与えられず、ストレスになってしまいます。
確かにUI更新の間隔を0.5秒、あるいは0.1秒と短くしていけば動きは素早くなるものの、①で説明した無駄な再描画をしてしまう可能性が高くなります。
DBの更新を知らせるために、今回はDartのEventパッケージを使ってみました。
//database.dart
import 'package:event/event.dart';
var updateEvent = Event();
Future<void> updateSite(var db, Site site) async {
await db.update(
'Sites',
site.toMap(),
where: 'url = ?',
whereArgs: [site.url],
);
updateEvent.broadcast();
}
updateDatabase()内でDBの更新後に、updateEvent.broadcast()によってサブスクライバーにイベントの発生を通知します。
//next_page.dart
Stream<List<Site>> getUrlAndresTime(String domain) async*{
var listOfSites = await getSitesByDomain(domain);
yield listOfSites;
updateEvent.subscribe((args) {
setState(() {});
});
}
updateEvent.subscribe()によってイベントの発生通知を受け取ります。
通知を受け取った後の処理をsetState()とすることで、DBの更新と同時にUIが更新されます。
これによって、リアルタイムで表示が更新され、合理的にUIを描画できるようになりました。
以上、Flutterの概要、成果物の紹介、今回時間を費やした問題解決について書かせていただきました。
最後に初めてのモバイルアプリ開発、チーム開発を通して感じたことをまとめます。
開発に入る前に、仕様書を作成したのですが、UIのことにしか言及できておらず、バックエンドは全く意識できていませんでした。
それゆえ、新しくリサーチすることが多く、開発スピードが落ちたり、インターン生同士でイメージ共有ができず、思っていたものと違うプログラムができてしまったり、ということがありました。
UIのデザインだけでなく、DBのデータ型、バックエンドに使うAPIやパッケージなど、詰められるだけ詰め、開発時には仕様書にしたがって作業するだけの状態が理想であると実感しました。
開発に詰まって社員さんにアドバイスをお願いすると、「こんなやり方があるよ」と様々な解決法を提示していただけました。
実際、今回記事にしたDBの更新をイベント化するというアイデアも社員さんにアドバイスしていただいたものになります。
言語は違えど、「JavaScriptでいう、あの機能だよね」といった解決ができるように、今後の開発で知識を深めていきたいと思います。
JavaやPythonなど、メジャーな言語しか触ってこなかった僕にとって、公式ドキュメントは「形式張っていて読みづらいもの」という認識でした。
公式ドキュメントの重要性については理解はしていたものの、何だかんだstack overflowやQiitaで解決していました。
しかし、発展途上のFlutter,Dartの問題に直面したとき、ピンポイントで解決できる記事がなく、公式ドキュメントを見る癖がつきました。
機能に付随するすべての情報が載っている上に、分類もなされているため、確実な理解に繋がると実感することができました。
改めて振り返ってみると当たり前のことばかりではありますが、これから初めてチーム開発を行う方や、プログラミング初心者の方の参考になれば幸いです。
最後まで読んでいただき、ありがとうございました!