Flutterで作るMisocaクライアント

Flutterで作るmisocaクライアント

こんにちは、弥生モバイルチームのtijinsです。
公式MisocaアプリはAndroid用iOS用がありますが、それぞれ別のコードベースで開発されています。
今回はFlutterで開発するとAndroid/iOSの両方で動作するという噂が本当なのか確認してみました。

Flutterについて

docs.flutter.dev

iOS/Androidの両方で動作する事を確認したいので、macOS用のSDKをインストールしました。
Flutterはクロスプラットフォーム開発が可能ですが、iOS用のパッケージを作成するにはmacOS(Xcode)が必要です。

Dart

FlutterアプリはDart言語で開発します。
インタプリタや中間言語でプラットフォーム互換を実現している訳ではなく、ネイティブコードに変換されているようです。

画面レイアウト

ネイティブアプリの開発と一番大きな違いは、画面レイアウトの作成だと感じました。
Flutterの画面レイアウトはDartのコードで行います。
Composeで開発した経験があれば違和感は少ないです。

Scaffold

一般的なAndroidアプリのレイアウトはScaffoldを使うと簡単です。 ScaffoldにはAppBar、 左上アイコン、メニューの表示等が含まれます。

レイアウトの基本

Flutterのレイアウトは各Widgetが持つConstraint(制約)により配置が決定されます。
Constraintは幅・高さの範囲を保持するオブジェクトです。

Widgetツリーのルートから末端まで下記を繰り返しレイアウトを決定します。

  1. 子Widgetが無い時、親から指定されたConstraintの範囲内で自身のサイズを決定します。
  2. 子Widgetがある時、親から受け取ったConstraintにPadding等を考慮したConstraintを子Widgetに通知します。
  3. 子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の設定が必要です。

使用したライブラリ

その他は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: アクセストークン、リフレッシュトークン

ブラウザの起動

oauthの認可画面

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で行います。

OauthTokenのEntityクラス

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を利用して、アクセストークンの有効期限が切れた時に、更新しています。

AuthorizationInterceptor

認証完了時の処理

通信など非同期処理の結果を利用する実装では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イベントに指定していない為、InkWellWidgetを行のルートにして、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クライアント開発に挑戦してみてくださいね。




herp.careers