自己紹介
現在、株式会社MG-DXにて、Webフロントエンドエンジニアをやっている植木といいます。一つ前のプロジェクトはOPENRECでiOSエンジニア、その前は、ピグでUnityのエンジニアをしていました。ネイティブアプリを作るのが好きです。MG-DXでは薬急便というオンライン診療・服薬指導がWebだけで行えるサービスを提供しています。新たにアプリをインストールしなくても、SafariやChromeだけで気軽に使えます。
開発の経緯
薬急便は、患者向け、医療機関向けに展開しています。患者から予約が入った場合に、医療機関にはメールやFAXが通知されるようになっています。FAXでは、受信するまでの時間や画質の低下が気になり、コストもかかるので、代替手段で、Webから印刷機能を提供する話が出てきました。
キオスクモードでの印刷や常駐アプリとWebで連携して印刷する海外のアプリもありましたが一長一短で、WebではなくWindows版ネイティブアプリをFlutterで開発しようと提案しました。
FlutterのWindows版のビルドは、2021年10月現在はベータです。今回は、音の通知と印刷に特化したアプリなので、認証とAPIがなんとかなればいけると思って、プロジェクトを開始しました。Flutterの経験は、一昨年に勉強を兼ねて、既存サービスのクローンをFlutterで作れないかと検証していたレベルで、久々に使っています。
今回の記事では、どのように実装したのか機能ごとにまとめてみました。
Windowsビルドの有効化
デフォルトでは、iOS/Androidの開発だけで、デスクトップは無効化されているので、有効にします。
flutter config --enable-windows-desktop
flutter config --enable-macos-desktop
参考) https://flutter.dev/desktop
僕は普段はMacを使っているので、Mac版も有効にして開発を進めました。
Firebase認証
薬急便のログイン機能は、Firebase Authorizationを使っています。Flutterでも、Firebaseのパッケージがあるのですが、Windows版では提供されていないので、dartだけで実装されているfiredartのパッケージを使っています。 APIでリファラーの制限をしている場合は、少しカスタマイズが必要です。
Cloud Storage
薬急便で扱う画像は保険証や処方せんは、認証が必要なCloud Storageに保存しています。処方せんを印刷するためには、認証問題を解決して、画像のリソースを取得する必要があります。これに対応しているパッケージがなく、自前でgetDownloadUrlのREST APIを呼び出して解決しています。
パスに必要な情報の取得
Future<String?> getDownloadURL(String path) async {
final headerParams = <String, String>{};
final storageDomain = 'https://firebasestorage.googleapis.com/v0/b/$projectId.appspot.com/o/';
headerParams['Authorization'] = 'Firebase $idToken';
final encodePath = Uri.encodeQueryComponent(path);
final uri = Uri.tryParse('$_storageDomain$encodePath');
if (uri == null) {
return '';
}
final response = await _client.get(
uri,
headers: headerParams,
);
final meta = FullMetadata(json.decode(response.body));
final queryParams = <QueryParam>[];
queryParams.add(QueryParam('alt', 'media'));
final downloadTokens = meta.downloadTokens;
if (downloadTokens != null) {
queryParams.add(QueryParam('token', downloadTokens));
}
final urlEncodedQueryParams = queryParams.map((param) => '$param');
final queryString = urlEncodedQueryParams.isNotEmpty
? '?${urlEncodedQueryParams.join('&')}'
: '';
return '$storageDomain$encodePath$queryString';
}
OpenAPI
薬急便のAPIは、OpenAPIのスキーマが提供されていて、Webでは、openapi-generatorを使ってコードを生成しています。Flutterでも同じようにして生成して使うことにしました。薬急便では、患者、医療機関、薬局とサービスが分かれているので、それぞれのクライアントを生成するように、openapi-generatorにオプションを設定しています。
openapi-generator-cli generate -t templates/dart2 -g dart -i ./services/$SERVICE_NAME/$API_NAME/api/http/docs/openapi.yaml -o ./lib/apis/$SERVICE_NAME/$API_NAME
--global-property apis,apiDocs=false,apiTests=false,models,modelDocs=false,modelTests=false,supportingFiles
--additional-properties=pubLibrary=$SERVICE_NAME.$API_NAME.api,pubName=${SERVICE_NAME}_${API_NAME}
テンプレートは、dart2を少しカスタマイズして使っています。FirebaseのJWTを付けてAPIを呼び出せるようにAuthの部分を書き換え、JWTの期限切れの際には再送するようにしています。
環境切り替え
開発環境と本番環境があるので、flutter_flavorizrとflutter_flavorを使って、APIの呼び出し先、Firebaseの設定を切り替えるようにしています。main-dev.dart、main-stg.dartと環境ごとのmain.dartを作って、ビルドするときにはファイルを指定しています。また、main-*.dartには、FlavorConfigを環境に合わせて記述しています。
FlavorConfig(name: "dev", variables: {
"FIREBASE_API_KEY": "",
"FIREBASE_DATABASE_URL": "",
"FIREBASE_PROJECT_ID": "",
"API_COUNSELOR_URL": "",
});
先ほど書き出したOpenAPIに接続情報を設定したクラスを用意し、それをProviderが参照するようにしています。
class CounselingRepository {
CounselingApi _counselingApi;
ApiClient _apiClient;
CounselingRepository() {
_apiClient = ApiClient(basePath: FlavorConfig.instance.variables["API_COUNSELOR_URL"]);
_counselingApi = CounselingApi(_apiClient);
}
CounselingApi get counselingApi => _counselingApi;
}
印刷
印刷するには、printingのパッケージを使っています。通常の画面レイアウトとほぼ同等のコードで、印刷用のPDFを作成して、印刷できるので便利です。一部抜粋してますが、PDFを作成して、それをプリンタに送って印刷します。処方せんなどの画像は、先にダウンロードし、MemoryImageにして印刷内容を設定しています。最後のdirectPrintPdfの処理ですが、自動で印刷されるようにprinterを検索して指定してます。
import 'package:pdf/widgets.dart' as pw;
final doc = pw.Document();
doc.addPage(pw.MultiPage(
pageTheme: pw.PageTheme(
pageFormat: PdfPageFormat.a4,
orientation: pw.PageOrientation.portrait,
textDirection: pw.TextDirection.ltr,
theme: pw.ThemeData(
defaultTextStyle: pw.TextStyle(font: _font),
),
),
build: (pw.Context context) {
return _createPage();
}));
final reference = await FireStorage.instance.getDownloadURL(prescriptionPath);
final response = await client.get(Uri.parse(reference));
final image = pw.MemoryImage(response.bodyBytes));
doc.addPage(pw.Page(
pageFormat: PdfPageFormat.a4,
build: (pw.Context context) {
return pw.Image(image);
}));
doc.save();
final output = await getTemporaryDirectory();
final file = File('${output.path}/counseling.pdf');
await file.writeAsBytes(await doc.save());
final printers = await Printing.listPrinters();
if (printers.isEmpty) return;
await Printing.directPrintPdf(
printer: printers[0],
onLayout: (PdfPageFormat format) async => doc.save());
タイトルの設定
アプリのタイトルバーに表示される名前が、プロジェクト名になってしまうので、それを解決するためにwindow_sizeを使って書き換えています。いずれ取り込まれる予定のパッケージのようで、gitを参照するようになっています。
package.yaml
window_size:
git:
url: git://github.com/google/flutter-desktop-embedding.git
path: plugins/window_size/
ref: 最新のハッシュを指定
main.dart
void main() {
WidgetsFlutterBinding.ensureInitialized();
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
setWindowTitle("コントロールパネル");
}
}
パッケージ化
アプリ提供する場合に、ビルドした実行ファイルをそのまま圧縮しても提供できるのですが、今後のアップデートを考えるとインストーラを作った方がいいので、msixでパッケージしています。パッケージ化には、コードサイニングの証明書が必要になります。アプリをビルド後、msix:createを実行するとパッケージ化してくれます。
flutter build windows -t lib/main-prd.dart
flutter pub run msix:create
アップデート対応
アプリはストアで提供している訳ではなく、各医療機関側にインストール作業をする仕様なため、バージョンアップの告知をどうするかが問題となりました。
ネイティブの処理で、パッケージ内容を確認してアップデート機能を実装する方法があったのですが、C++で実装するにはちょっとコストがかかりすぎるので、断念しています。代わりにサーバー上にバージョン情報のJSONを置いて、古ければブラウザを開いて、ダウンロードページを表示する仕様にしました。
最後に
Flutterの経験は薄かったのですが、参考になるサイトも増えている、Reactを経験したのもあって、すんなりとなじんで、アプリ自体は、3週間ほどで開発して、1週間ぐらい修正して完成させました。ビルドが早いので、検証しやすかったのが、よかったです。iOS/Androidで作っているところは多いですが、デスクトップアプリの開発もFlutterはすごく向いていると思います。