Flutterで作るmisocaクライアント
こんにちは、弥生モバイルチームのtijinsです。
公式MisocaアプリはAndroid用、iOS用がありますが、それぞれ別のコードベースで開発されています。
今回はFlutterで開発するとAndroid/iOSの両方で動作するという噂が本当なのか確認してみました。
- Flutterで作るmisocaクライアント
- Flutterについて
- サンプルアプリの実装
- 感想
- 終わりに
Flutterについて
iOS/Androidの両方で動作する事を確認したいので、macOS用のSDKをインストールしました。
Flutterはクロスプラットフォーム開発が可能ですが、iOS用のパッケージを作成するにはmacOS(Xcode)が必要です。
Dart
FlutterアプリはDart言語で開発します。
インタプリタや中間言語でプラットフォーム互換を実現している訳ではなく、ネイティブコードに変換されているようです。
画面レイアウト
ネイティブアプリの開発と一番大きな違いは、画面レイアウトの作成だと感じました。
Flutterの画面レイアウトはDartのコードで行います。
Composeで開発した経験があれば違和感は少ないです。
Scaffold
一般的なAndroidアプリのレイアウトはScaffold
を使うと簡単です。
Scaffold
にはAppBar、 左上アイコン、メニューの表示等が含まれます。
レイアウトの基本
Flutterのレイアウトは各Widgetが持つConstraint(制約)により配置が決定されます。
Constraintは幅・高さの範囲を保持するオブジェクトです。
Widgetツリーのルートから末端まで下記を繰り返しレイアウトを決定します。
- 子Widgetが無い時、親から指定されたConstraintの範囲内で自身のサイズを決定します。
- 子Widgetがある時、親から受け取ったConstraintにPadding等を考慮したConstraintを子Widgetに通知します。
- 子Widgetからサイズ・位置を受け取り、子Widetのサイズを考慮し、自身のサイズを決定します。
Widgetのサイズは種類により決定方法が異なります。
子が1つのレイアウト
Widget | 説明 | 自身のサイズ | 子のサイズ |
---|---|---|---|
Container | 基本のレイアウト | 子が無い時は親と同じ。子が有る時は子を包含する最小サイズ child, alignの指定により大きさが変わる | 0〜自身と同じ, constraintが指定されいる場合は固定値となる |
Align,Center | 右寄せ、左寄せ等ができる | 最大化 | 0〜自身と同じ |
Padding,ColoredBox | パディングを与える | 最小化 | 0〜自身と同じ |
SizedBox | 自身、子のサイズを固定 | 固定値を指定 | 自身と同じ(固定) |
OverflowBox | 親からはみ出して配置 | 任意の値を指定可能 | 0〜自身のサイズ |
LimitedBox | サイズを制限する | ListView内などサイズの制約が無い場合のみ、サイズを指定できる。サイズが指定されている場合は親の制約を引き継ぐ | 0〜自身のサイズ |
SingleChildScrollView | スクロールを可能にする | 最小化 | 幅:自身と同じ、高さ:0〜無限大) |
子が複数のレイアウト
Widget | 説明 | 自身のサイズ | 子のサイズ |
---|---|---|---|
ListView | 子が複数のレイアウト | 最大化 | 幅:自身と同じ、高さ:0〜無限大 |
Column | 複数の子を縦に並べる | 幅:最小化、高さ:MainAxisSizeで指定 | 幅:自身と同じ、高さ:0〜無限大 |
Row | 複数の子を横に並べる | 幅:MainAxisSizeで指定、高さ:最小化 | 幅:0〜無限大、高さ:自身と同じ |
Expanded | Column又はRowの内側で使用する | Column内で使用する時: 高さ方向が最大になる。Row内で使用する時: 幅方向が最大になる。 | 自身と同じ |
Flexible | Column又はRowの内側で使用する | Column内で使用する時: 高さ方向が0〜最大になる。Row内で使用する時: 幅方向が0〜最大になる。 | 0〜自身と同じ |
Constraintsの動作
最小化 包含する子要素を包む最小サイズになる
最大化 親要素と同じ大きさになる
無限大
double.infinity
が指定されている場合を無限大として扱う。 例えばListView内に配置される要素の制約は、縦方向が無限大になっている(スクロールできる為)
LayoutBuilder
レイアウトツリーが動的に変わる場合に使います。
ConstraintLayout
組み込みWidgetではありませんが、ネイティブのConstraintLayoutと同等のレイアウトが可能なライブラリがあります。flutter_constraintlayout
ユーザー入力を受け取るWidget
Widget | 機能 | |
---|---|---|
InkWell | Clickable, Focusableにする | マテリアルデザイン対応のレイアウト内で利用可能です。 |
GestureDetector | タップやスワイプを検出する |
マテリアルデザイン
マテリアルデザイン対応のWidgetは以下です。 https://docs.flutter.dev/development/ui/widgets/material
ElevatedButtonやCard等がマテリアルデザイン対応のWidgetです。
Container等、非マテリアルデザインWidgetにelevationを付ける場合はMaterial()で囲みます。
画面遷移
Named routesを定義し、名前ベースのナビゲーションを行います。
ネイティブアプリのActivityスタックや、Navigationと似た機能が利用可能です。
ナビゲーションのスタックを制御する事で、前画面に戻れなくする事も可能です。
pushNamed ナビゲーションスタックに遷移先の画面を追加します。
バックキーで現在の画面に戻れます。pushReplacementNamed 現在の画面を破棄してから、遷移します。 バックキーを使用しても、現在の画面には戻れません。
pushNamedAndRemoveUntil 第2引数に指定するラムダがtrueを返すまでナビゲーションのスタックを削除してから遷移します。
第2引数に(_) => false
を指定するとスタックがクリアされます。
AppBarのホームボタン
ナビゲーションのスタックに2件以上の画面が積まれている場合、自動的に戻るボタンが表示されます。
スタックが空になると、戻るボタンは消えます。
引数の指定
pushNamed()等を実行する際arguments
に引数を指定可能です。
// 引数を指定 Navigator.of(context).pushNamed("InvoiceDetailPage", arguments: {"invoice": invoice}); // 引数を参照 var args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?;
サンプルアプリの実装
https://github.com/standfirm/misoca-sample-flutter
サンプルアプリを動作させる為には、後述するアプリの登録と、クライアントID、シークレット、コールバックURLの設定が必要です。
使用したライブラリ
- flutter_riverpod : ネイティブのLiveDataのような実装ができる
- dio : https通信
- retrofit : RESTful API通信
- freezed : dartクラスとjsonを相互変換(詳細)
- decimal : 実数
- flutter_pdfview : PDF表示
- logger: ログ
- flutter_svg: SVG形式のアイコンを表示
- flutter_constraintlayout: ConstraintLayout
- share_plus: PDFファイル共有に使用
- url_launcher: ブラウザを起動
- uni_links: DeepLinkからアプリを起動
- shared_preferences: SharedPreferenesに設定を保存
その他はpubspec.yamlを参照
プロジェクト構成
View → ViewModel(StateNotifier) → UseCase → Repository(APIやファイルにアクセスする)の構成にしています。
画面構成
- ログイン画面
- 請求書一覧
- 請求書詳細
- 請求書プレビュー
オプションメニュー等は公式アプリに合わせていますが、編集等は機能しません。 Flutterの調査用に作ったものなので、税率表示等は端折っています。
ログイン画面の作成
ログイン画面では、Oauth2で認証してアクセストークンをSharedPreferencesに保存します
公式アプリに合わせてメールアドレス、パスワード入力フォームを配置していますが、APIv3では使用不可です。
TextFormFieldにInputDecorationを指定することで、ネイティブのTextInputLayoutを再現可能です。
InputDecorationにsuffixIconを指定することで、パスワードトグルを再現可能です。
Misoca API v3
MisocaはRESTful形式のAPIを公開しています。
APIは無料プランでも利用可能です。(ユーザー登録が必要です)
アプリの登録
APIの利用にはアプリケーションID、シークレットが必要です。 Misocaにログイン後、アプリケーション管理画面を表示して、アプリを登録します。
- 名称は任意です。
- コールバックURLは認証完了時にフックされるURLです。
AndroidManifest.xml
のIntent-Filterで捕捉可能なURLを指定します。 サンプルアプリではscheme部の一致で起動しています。
登録するとクライアントID、シークレットが払い出されます。
OAuth2認証
公式アプリはパスワードで認証していますが、APIv3はパスワード認証に対応していない為、一般ユーザーはOAuth2で認証します。
OAuth2の認証フロー(認可コードによる認証)は以下のようになっているので、アプリからブラウザの起動と、ブラウザからアプリの起動に対応する必要があります。
sequenceDiagram FlutterApp ->> WebBrowser: ブラウザを起動(クライアントID、コードバックURI、スコープ) WebBrowser ->> Misoca: ブラウザでアクセス https://app.misoca.jp/oauth2/authorize Misoca ->> WebBrowser: 認証画面を表示 WebBrowser ->> Misoca: ユーザーが許可を選択 Misoca ->> WebBrowser: 認可コード WebBrowser ->> FlutterApp: Intent-Filterによりアプリが起動(認可コード) FlutterApp ->> Misoca: https://app.misoca.jp/oauth2/token(認可コード) Misoca ->> FlutterApp: アクセストークン、リフレッシュトークン
ブラウザの起動
url_launcherを使用してブラウザを起動します。 http://app.misoca.jp/oauth2/authorize に対し、クエリパラメータで以下を指定します。
ブラウザで承認
をクリックすると、再びアプリに戻ります。
name | value | |
---|---|---|
response_type | "code" | 認可コードフローで認証する |
scope | "write" または "read" | |
client_id | アプリ登録時に払い出されたID | |
redirect_uri | アプリ登録時に指定したURL |
認可コードの取得(URLからアプリの起動)
uni_linksを使用してコールバックURLから認可コードを取得します。
コールバックURLからアプリを起動するには、OSの機能を利用する必要がある為、Android、iOSで個別の設定が必要になります。
- Android
AndroidManifest.xmlにintent-filterを設定します。
AndroidManifestはFlutterアプリプロジェクトの以下にあります。
./android/app/src/main/AndroidManifest.xml
サンプルアプリではschemeを利用して起動しますが、ホスト名等も利用可能です。
<application> <activity> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="misocasample" /> </intent-filter> </activity> </application>
- iOS
Info.plistにCFBundleURLTypesを指定します。
Info.plistはFlutterアプリプロジェクトの以下にあります。
./ios/Runner/Info.plist
<plist> <dict> <key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleTypeRole</key> <string>Editor</string> <key>CFBundleURLName</key> <string>jp.misoca.fluttersample</string> <key>CFBundleURLSchemes</key> <array> <string>misocasample</string> </array> </dict> </array> </dict> </plist>
コールバックURLからクエリパラメータを取得する
https://pub.dev/packages/uni_links#usage
アプリが停止している時と、起動済みの時で、コールバックURLの通知を受けるイベントが異なります。
アプリからブラウザに遷移したタイミングでアプリが停止される可能性がある為、両方で受け取れるようにします。
認可コードは、コールバックURLのクエリパラメータ部(name="code")に指定されています。
将来的に多目的のDeepLinkを処理できるように、ログイン画面ではなくMaterialAppのルートにDeepLinkを取得するコードを配置しています。
アクセストークンの取得
認可コードを指定してアクセストークンを取得します。
通信(dio)
flutterには組み込みのhttp通信クラスがありますが、多機能なdioを利用します。 retrofitを使用するとGET, POST等の通信を簡単に実装可能です。
サンプルアプリでは、dioはシングルトンにしてアプリ全体で共有しています。 APIのBaseUrl等は個別にも指定できますが、dioに対して行う事でアプリ全体に適用されます。
アクセストークンの取得
POST http://app.misoca.jp/oauth2/token からアクセストークンを取得します。 BodyのFormデータに以下を指定します。
name | value |
---|---|
client_id | アプリ登録時に払い出されたID |
client_secret | アプリ登録時に払い出されたシークレット |
grant_type | "authorization_code" |
redirect_uri | アプリ登録時に指定したコールバックURL |
code | コールバックURLで受け取ったcode |
レスポンスにはアクセストークン、リフレッシュトークンが含まれます
name | value |
---|---|
access_token | アクセストークン |
refresh_token | リフレッシュトークン |
シリアライズ・デシリアライズ
アクセストークンはJson形式なので、エンティティクラスを作成してデシリアライズします。 Dartクラス⇔Jsonの変換はfreezedで行います。
Dartにはリフレクションが無いため、シリアライズ処理がビルド時に生成されます。
flutter pub run build_runner watch
をバックグラウンドで実行しておくと、ファイルの変更を検知してシリアライズ処理を自動で生成してくれます。
エンティティクラスに対応するコードが生成される前はコンパイルエラーが出た状態になっているので、早めに生成しておく方がよいです。
アクセストークンの永続化
shared_preferencesを使用してアクセストークン、リフレッシュトークンをファイルに保存します。
アクセストークンの更新
アクセストークンには有効期限がある為、有効期限切れ時には、アクセストークンを更新する必要があります。
POST http://app.misoca.jp/oauth2/token から更新されたアクセストークンを取得します。 - Headerに以下を指定します。
name | value |
---|---|
Authorization | "{アプリケーションID}:{シークレット}"をBase64エンコードしたもの |
Request-Type | "refresh" |
- BodyのFormデータに以下を指定します。
name | value |
---|---|
grant_type | "refresh_token" |
refresh_token | 認証時に取得したリフレッシュトークン |
AuthorizationInterceptor
サンプルアプリではDioのInterceptorを利用して、アクセストークンの有効期限が切れた時に、更新しています。
認証完了時の処理
通信など非同期処理の結果を利用する実装ではStateNotifierが便利です。
StateNotifierクラスにあるメソッドの実行
ref.read()
を使用するとstateNotifierのメソッドを実行可能です。
ref.read(stateNotifierProvider.notifier).getData();
StateNotifierをObserveして非同期処理の完了を監視する
ref.watch(), またはref.listen()を使用して状態の変更を監視します。
https://github.com/standfirm/misoca-sample-flutter/blob/main/lib/main/main_app.dart#L46
ref.listen(loginProvider, (previous, next) { if (next.status == NetworkStatus.success) { Navigator.of(context).pushReplacementNamed(InvoiceListPage.routeName); } });
StateNotifierの値が更新されるとlistenに指定したコールバックが実行されます。
請求書一覧画面への遷移
Navigatorクラスを使用して、画面遷移します。
Navigator.of(context).pushReplacementNamed(InvoiceListPage.routeName);
画面更新のコンフリクト
画面更新中に非同期処理の完了が重なった場合に、画面更新がコンフリクトして例外が発生する場合があります。 以下のコードで、処理を描画完了後にスケジューリング可能です
WidgetsBinding.instance.addPostFrameCallback((timeStamp) { // post process });
請求書一覧画面の作成
請求書一覧の取得
アクセストークンの指定
Misocaの各APIの実行にはアクセストークンの指定が必要です。
サンプルアプリではDioのInterceptorを使用してAuthorizationヘッダーを指定しています。
AuthorizationInterceptor
Interceptorの登録
GET /api/v3/invoices
GET http://app.misoca.jp/api/v3/invoices で請求書一覧を取得できます。
サンプルアプリではInvoiceのEntityクラスを作成してデシリアライズしています。
ネストされたクラスも問題なくデシリアライズされます。
通信結果の表示(ListView.builder)
ListView.builder()を使用します
ListView(Widget[])では、行数分のWidgetインスタンスが作成される為、数千行〜数万行あるような画面だと、非常に重くなってしまいます。
ListView.builder()は画面表示されている行のインスタンスのみが生成される為、重くなりません。
一覧画面の作成にはflutter_constraintlayoutを使用しました。Column,Rowで表現し難い画面だとConstraintLayoutは便利です。
区切り線
区切り線は decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Colors.black, width: 1))
で指定します。
ListViewのクリックで画面遷移
FlutterのListViewにはonItemClickのようなイベントがありません。
行に対してonClickイベントを設定するには、行のルートとなるWidgetにイベントを指定します。(ネイティブアプリのRecyclerViewと同じです)
ルート要素はContainerになる事が多いと思いますが、ContainerはonClickイベントに指定していない為、InkWell
Widgetを行のルートにして、InkWellにonTapイベントを指定します。
InkWellで囲んだ要素は自動的にfocusableにもなり、キーボード操作による選択・実行も可能になります。
InkWellはタップやフォーカスによる背景色の制御を行う為、子要素の背景色は指定しないようにします。
請求書詳細画面の作成
- 品目部分が増減する為ListViewにしています。(タイトルや請求先名が表示されている上部〜品目がListViewです)
- 合計金額が表示される下部はMaterialを使用してelavationを付けています。
- サムネイルにはPDFファイルを表示しています。
PDFの表示
flutter_pdfviewを使用してサムネイルの表示を行います。
flutter_pdfviewでは、ファイルをダウンロードし、ローカルのファイルを指定することでPDFが表示されます。
PDFファイルのダウンロード
Retrofitで大きなファイルをダウンロードする場合、ファイル全体をメモリ上に保持する必要があった為Dioのダウンロードを使用しました。
downloadメソッドはoptionでreceiveDataWhenStatusErrorをfalseにしておかないと、ErrorBodyが無い場合にクラッシュします。
サンプルの実装ではトークン更新後のリトライ時にもoptionが引き継がれるようにしています。
PDFファイルの共有
share_plusを使用して共有します。
共有機能はAndroid/iOSのネイティブ機能が実行されます。
APIリファレンスには記載されていませんがmimeType
の指定も可能です。
Share.shareFiles( ["/file/to/invoice.pdf"], text: "${invoice.subject}", mimeType:"application/pdf" );
Android/iOSでネイティブの共有が実行されます
BottomSheetDialogの表示
ネイティブのBottomSheetDialogに相当するものがFlutterにもあります。
builderで任意のWidgetを指定するとモーダルで表示されます。
ダイアログで行った結果(Ok,Cancelなど)は非同期の戻り値として受け取れます。
void openIssueDialog(BuildContext context) async { var result = await showModalBottomSheet(context: context, builder: (_) => InvoiceBottomSheet()); if (result != null) { Fluttertoast.showToast( msg: "$resultを選択しました", toastLength: Toast.LENGTH_SHORT, ); } }
モーダルダイアログはScaffoldのツリーに属さない為、Materialデザインに対応する場合はルート要素をMaterialにする必要があります。
ログアウト
Navigatorによる画面遷移を行う差異、請求書詳細から一覧には戻れますが、ログイン・ログアウトした場合、元の画面には戻れなくしています
サンプルアプリでは、Navigatorの指定を以下のように使い分けています。
- ログイン→請求書一覧: pushReplacementNamed
- 請求書一覧→請求書詳細: pushNamed
- 請求書詳細→請求書プレビュー: pushNamed
- ログアウト: pushNamedAndRemoveUntil
感想
Misocaアプリの主要な機能はFlutterでも実装できそうです。
ネイティブと比べて開発効率が悪い事もないので、Androidアプリ開発の選択肢に入れられる気がしました。
FlutterはWeb, Windows, Linux等にも対応している事になっているのですが、ライブラリはAndroid/iOSのみ対応というのも多い印象です。
終わりに
Misoca APIはFlutter以外のフレームワークからも利用可能です。
オリジナルのMisocaクライアント開発に挑戦してみてくださいね。