MoneyEasyのAndroidアプリでVisual Regression Testを始めた話(CI/CD編)

こんにちは、株式会社フィノバレーでAndroidエンジニア兼UXリサーチャーをやっております、島本(id: yoi_ko)です。
なお、こちらの記事は弊社の親会社であるiRidgeアドベントカレンダー6日目の記事です。

qiita.com

MoneyEasyのAndroidアプリでは、デザインリニューアルしたver2系のリリースに伴い、ver1系で実装したUI TestからVisual Regression Testへの移行を行いました。 現状、AndroidアプリにおけるVisual Regression Test=VRTの導入事例はあまり多くは見かけません。ですが、CI/CDと組み合わせることでテストのリソースを大幅に削減しつつ品質を担保することができるため、特に小規模な開発組織においては大変有効なテスト方法であると思います。

そんなVRTについて、2回に分けて紹介します。今回の記事は、後編:CI/CD編です。

  • 前編:実装編 VRT導入の経緯、想定ゴール、実装について
  • 後編:CI/CD編 GitLab CIへの取り込み方法について

前編:実装編の記事はこちらです。合わせてご覧ください💁‍♀️

finnovalley.hatenablog.com

スクリーンショットの比較を行いたい:reg-suitの導入

さて、前回の記事でご紹介した通り、約1ヶ月ほどかけてスクリーンショット(SS)を全画面分撮るテストコードの実装を終えました。テスト効率化に最低限必要なことはクリアできました🎉。
とはいえ、やはりせっかくだからすべて自動でやってしまいたいわけです。

f:id:yoi_ko:20201204115358p:plain
←これを こうしたい→

そこで次に着手したのが、reg-suitの導入でした。

reg-viz.github.io f:id:yoi_ko:20201120155556p:plain

reg-suitとは、変更前と変更後のスクリーンショットを比較し、差分を検出してHTML形式のレポートを出力するツールです。上記の画像にある通り、reg-suitはコマンドラインツールなので、CI環境およびローカルで実行することができます。万が一CIに組み込むところまでいけなくても、ローカルで動かせるならそれでもよさそう、ということで選定しました。

事前準備

  1. SSの保存先を決める
    reg-suitは、変更前のSSをAWSあるいはGCSに保存しておくことで比較を可能にしています。MoneyEasyではGCSに専用のバケットを用意しました。reg-suitの設定時に自動で生成されますが、バケット名が reg-publish-bucket-{ランダムな文字列} になるので、指定するほうがベターだと思います。

  2. SlackのWebhookを発行する
    終了時にSlackに通知させたい場合、Webhookを発行しておく必要があります。実際はこんな感じで通知が来ます。便利なので設定しておくことをオススメします。 f:id:yoi_ko:20201120163901p:plain

環境構築

以下の公式ドキュメント通りにコマンドを実行します。
GitHub - reg-viz/reg-suit: Visual Regression Testing tool

reg-suit init を実行した際に、追加するプラグインを選択します。プラグインは大きく3つにカテゴライズできるので、各々の環境に合わせてそれぞれ適当なものを選択します。

  • key-generator plugin
    比較対象のkeyを識別するためのプラグイン。 2つあるうちのどちらか1つを選ぶ

  • publisher plugin
    比較対象(変更前)のSSの取得、(比較実行後)変更後のSSと比較結果を保存するためのプラグインAWS or GCS。

  • notifier plugin
    比較結果を通知するためのプラグインGitHub、GitLab、Slackとの連携が可能。

MoneyEasyでは、以下のプラグインを使っています。

  • reg-simple-keygen-plugin
  • reg-publish-gcs-plugin
  • reg-notify-slack-plugin

以上を実行すると、 regconfig.json という設定ファイルが生成されます。

{
  "core": {
    "workingDir": ".reg",
    "actualDir": "screenshots", // 比較対象(変更後)のSSを配置するフォルダ
    "thresholdRate": 0,
    "ximgdiff": {
      "invocationType": "client"
    }
  },
  "plugins": {
    "reg-simple-keygen-plugin": {
      "expectedKey": "${EXPECTED_KEY}", // 環境変数で指定できるようにしておく
      "actualKey": "${ACTUAL_KEY}" // 環境変数で指定できるようにしておく
    },
    "reg-notify-slack-plugin": {
      "webhookUrl": "指定したWebhookURL"
    },
    "reg-publish-gcs-plugin": {
      "bucketName": "指定したバケット名"
    }
  }
}

reg-suitの環境構築はこれで終わりです。

ローカルで動かす場合

CI/CDにreg-suitの実行を組み込まず、ローカルで動かしたい場合は以下の手順で行います。

  1. Firebase TestLabでSS取得処理実行
  2. テスト完了後、テスト結果の格納されているストレージからSSをローカルにDL
  3. DLしたSSをプロジェクトリポジトリ直下の screenshots フォルダ( actualDir に指定したもの)に格納
  4. SSをリネーム&リサイズ(必要であれば)
  5. reg-suit実行

これで比較結果のレポートがHTML形式で吐き出されます 👏
実際のレポート画面はこんな感じ👇 f:id:yoi_ko:20201203205806p:plain
diffのあった画面は個別に確認できます。

f:id:yoi_ko:20201203210008p:plain
画面タイトル文字列の位置がズレている

しかしこの手順をいちいち踏むのはやっぱりめんどくさいし、いずれ実行しなくなることは目に見えていますね。特に私は短気かつ面倒くさがりなので、耐えられるはずもないわけです。
ということで、速攻これをCI/CDへ組み込む作業へ移りました。

全部自動でスクリーンショットの比較まで終わらせたい:reg-suitの実行をCI/CDへ組み込む

以前の記事でも紹介した通り、MoneyEasyのAndroidアプリはGitLab CIを使ってCI/CDを回しています。
GitLab CIはyaml形式でJobを定義します。VRTジョブは下記のような記述になりました。

# Visual Regression test
vrt:
  stage: acceptance
  # 実行環境にCloud SDK DockerベースのDockerイメージを指定 ※1
  image: <Container RegistryにアップしたDockerImage>
  script:
    # GCSの読み取り/書き込み権限のあるサービスアカウントで認証
    - echo $SERVICE_ACCOUNT > tmp.json
    - base64 -d tmp.json > service-account.json
    - gcloud auth activate-service-account --key-file service-account.json
    - gcloud config set project $CLOUD_PROJECT_ID

    # Firebase TestLabの実行 ※2
    - gcloud firebase test android run --type instrumentation --app <デバッグ用apkのパス> --test <AndroidTest用apkのパス> --device model=blueline,version=28,locale=ja_JP,orientation=portrait --timeout 30m --use-orchestrator --results-bucket="<実行結果を格納するバケット名>" --results-dir="<実行結果を格納するディレクトリ名>"

    # Firebase TestLabで取得したSSをDL
    - gsutil cp -r gs://<実行結果を格納するバケット名>/<実行結果を格納するディレクトリ名>/blueline-28-ja_JP-portrait/artifacts/sdcard/screenshots .

    - npm install
    - export PATH=$PATH:./node_modules/.bin
    - export GOOGLE_APPLICATION_CREDENTIALS="service-account.json"
    - cd screenshots

    # SSの一括リネーム&リサイズ
    - for f in `ls`; do mv $f ${f/UnknownTestClass-unknownTestMethod-/}; done
    - mogrify -format png -resize 480x *.jpg && rm -rf *.jpg && cd ..

    # 比較対象のコミットハッシュを指定し、reg-suit実行 ※3
    - git checkout $CI_COMMIT_REF_NAME || git checkout -b $CI_COMMIT_REF_NAME && git pull
    - export EXPECTED_KEY=$(git rev-parse HEAD~1)
    - export ACTUAL_KEY=$CI_COMMIT_SHA
    - reg-suit run

※1 実行環境にCloud SDK DockerベースのDockerイメージを指定

Firebase TestLabとの連携&reg-suitのSS保存先をGCSにしているため、ジョブの実行環境としてCloud SDK DockerベースのカスタムDockerイメージを指定しています。
Cloud SDK Dockerベースにしておくと、Google Cloud SDKをインストールする手順をまるっと省略できるのでオススメです。
Dockerfileはこんな感じです。nodejsとリサイズ用のImageMagickを予めインストールしているだけですね。

FROM gcr.io/google.com/cloudsdktool/cloud-sdk:latest

RUN apt-get update && apt-get install nodejs npm imagemagick -y

※2 Firebase TestLabの実行

gcloud CLIでTestLabを実行しています。詳細は公式ドキュメントを参考にしていただければと思いますが、色々設定しているので補足します。

  • --type instrumentation : インストゥルメンテーションテストの実施を指定
  • --app <デバッグ用apkのパス> --test <AndroidTest用apkのパス> : インストゥルメンテーションテスト実行に必要なapkを指定
    ※MoneyEasyの場合、VRTより前に実行している unit-test ジョブでapkを作成しているので、その保存先パスを指定しています
  • --device model=blueline,version=28,locale=ja_JP,orientation=portrait : テストを実行する端末を指定(この場合は物理のPixel3、API28、日本語、縦向き)
  • --results-bucket="<実行結果を格納するバケット名>" --results-dir="<実行結果を格納するディレクトリ名>" : TestLabの実行結果を格納する場所を指定
    ※実行完了後にSSをDLするため、ここは必ず指定してください。(指定がない場合、ランダム文字列のバケットが自動生成されます)

Orchestratorは有効にしたほうが良い

--use-orchestrator をつけることで、Android Test Orchestrator を使用することができます。
そもそもOrchestratorってなんぞや?という方向けに簡単に説明すると、要はテストの独立性を高めて、仮に1つのテストケースがコケても他のテストに影響が出ないようにする機能です。
詳しい説明や有効化の方法については、以下の公式ドキュメントをご覧ください。
AndroidJUnitRunner  |  Android デベロッパー  |  Android Developers

MoneyEasyの場合は100画面を超えるSSを取得する都合上、1つのテストが失敗して実行が中断されると影響が大きいため、Orchestratorを有効化しています。
Firebase TestLabでも推奨されているので、規模の大きいアプリの場合は設定しましょう。

f:id:yoi_ko:20201203192052p:plain
Firebase TestLabのコンソール画面(インストゥルメンテーションテスト実行)

※3 比較対象のコミットハッシュを指定し、reg-suit実行

まず前提を説明すると、MoneyEasyではVRT実行タイミングをトピックブランチがdevelopブランチへマージされたときとしています。
Visual REGERESSION Testの性質上、SSの比較対象はdevelopブランチの1つ前のコミット時(下記画像の緑丸)のSSになります。 f:id:yoi_ko:20201203194727p:plain
つまりSSの比較には、この期待値(Expected)のコミットハッシュ/実行時(Actual)のコミットハッシュそれぞれを指定してやる必要があります。

    "reg-simple-keygen-plugin": {
      "expectedKey": "${EXPECTED_KEY}", // 環境変数で指定できるようにしておく <- 期待値(緑丸)のコミットハッシュ
      "actualKey": "${ACTUAL_KEY}" // 環境変数で指定できるようにしておく <- 実行(赤丸)のコミットハッシュ
    }

Actualのコミットハッシュのほうは、GitLabCIの場合はvariableから取得できます。
一方、Expected(=developブランチの1つ前のコミット)のコミットハッシュの取得には注意が必要ですので、少し説明します。

まず前提として、GitLab CIのジョブ実行時、デフォルトでは以下のような状況になっています。

  • Runnerにリポジトリがcloneされる
  • cloneの速度を高めるために shallow clone (ドキュメント: Git shallow clone) が行われている
  • checkoutはbranchではなく、ジョブ実行のトリガーとなったコミットそのものに対して行われる( Detached HEAD状態)

Detached HEADとは、コミットがブランチから切り離されて独立してしまっている状態を指します。ブランチに紐ついていないと、コミットログを取得することができません。
developブランチの1つ前のコミットを取得するには、これをattach状態にしてやる必要があります。GitLabCIの場合は以下のコマンドを実行します。
$ git checkout $CI_COMMIT_REF_NAME || git checkout -b $CI_COMMIT_REF_NAME && git pull
これでattach状態となるので、git rev-parse HEAD~1 の実行結果を EXPECTED_KEY に指定すればOKです。

他のCIサービスでの解消方法については、reg-suitの公式ドキュメントに載っているのでこちらを参考にしてみてください。
reg-suit#run-with-ci-service

ちなみに、reg-suitではプルリクエスト時に比較実行することを推奨しているようです。この場合、key-generatorプラグインには reg-keygen-git-hash-plugin を選んだほうが良さそうです。
reg-keygen-git-hash-plugin#README.md
f:id:yoi_ko:20201203201336p:plain

導入してみての所感

ということで、無事CI/CDにVRTを組み込むことができました🎉
動くようになったのが11月頭くらいだったので、現時点で大体1ヶ月くらい運用したことになります。
運用してみてのざっくりした感想です。

  • 意図しないレイアウト崩れが発生しているかも…という心配が無くなった
  • UI Testに比べるとメンテコストが圧倒的に低い(SS撮るだけなので、せいぜいスクロール操作足すくらい)

特に「メンテコストが圧倒的に低い」点は重要かなと思います。
テストは一度作ったら作りっぱなしでいいわけではなく、プロダクトコードの変化と共に常に見直すものです。そのコストが抑えられると、よりプロダクトコードの実装に集中できますし、同時にデグレも防げるので、嬉しいことしかありません。

前編の記事で書いたように、VRTは規模の小さいチームであればあるほど、そのメリットが活きてくるものだと思います。
Androidエンジニアの数自体が少ないという事情も相まって、少人数で開発を行っているケースは案外多いのではないでしょうか。
今回の2つの記事が、そんな小規模開発を行っている方々(もちろんQAチームのあるような大規模チームの方にも!)にとって、有意義なものになれば幸いです😉

MoneyEasyのAndroidアプリでVisual Regression Testを始めた話(実装編)

こんにちは、株式会社フィノバレーでAndroidエンジニア兼UXリサーチャーをやっております、島本(id: yoi_ko)です。

MoneyEasyのAndroidアプリ(ver1.13.0時点)におけるテスト環境については前回記事でご紹介しましたが、この後デザインの一部リニューアルを行い、ver2系としてリリースしました🎉

これに伴い、MoneyEasyのAndroidアプリでは、ver1系で実装したUI TestからVisual Regression Testへの移行を行いました。
現状、AndroidアプリにおけるVisual Regression Test=VRTの導入事例はあまり多くは見かけません。ですが、CI/CDと組み合わせることでテストのリソースを大幅に削減しつつ品質を担保することができるため、特に小規模な開発組織においては大変有効なテスト方法であると思います。 

そんなVRTについて、2回に分けて紹介します。今回の記事は、前編:実装編となります。

  • 前編:実装編 VRT導入の経緯、想定ゴール、実装について
  • 後編:CI/CD編 GitLab CIへの取り込み方法について

VRT導入の経緯

手動テストの効率を上げたい(切実)

MoneyEasyはQRコード決済を提供するプラットフォームです。ユーザーのお金を扱うアプリですので、当然バグやデグレは許されず、リリース時には必ず綿密にテストを行う必要があります。この過程で、手動テストは避けて通れません。

一方弊社の方針は「少数先鋭」、つまりそれぞれが高いパフォーマンスを出すことを前提に、人員を絞って業務にあたっています。開発ステップにおいては特に問題はありませんが、ことテストに関してはチームで分担して実施するため、開発メンバーのリソースを割く必要があります。

現在運用しているのはさるぼぼコイン、アクアコインの2環境なので、上記の方法で間に合っていました。ところが、既にプレスリリースが出ている通り、ありがたいことにMoneyEasyは今後さらに拡大していくことが決定しています(長崎県南島原MINAコイン、東京都世田谷区のせたがやPay、子どもの「食」応援クーポン事業「Table for Kids」)。品質を担保しつつ迅速なリリースを実現するには、テスト効率を改善することが必須です。

そうだ、VRTやろう

もともとver1系でUI Testを実装していましたが、デザインリニューアルによりテストコードの大幅な改修が必要でした。また、UI Testはメンテコストがバカにならない(その割に効果が出ているのか疑問)という課題もあり、ver2系では既存のUI Testコードをすべて捨てることにしました。

代わりに検討したのが、スクリーンショット(SS)を使ったテストです。2020年1月に「Payアプリを支える技術」をテーマに開催された Bonfire Android #6 - connpass に参加した折にスクリーンショットテストが紹介されており、そのときから「これはうちに入れると良さそうだぞ」と興味を持っていました。テスト効率を改善するにあたっては、「画面表示がデグレっていないかチェックする」という作業を、手動から自動にしてやるのがもっとも効果的であると考えていました。その目的に対し、非常に有効である印象を受けたのです。

チーム内で検討し、全画面のSSを取得&期待とのdiffの確認までを自動で行うVisual Regression Testという形で導入してみようとの結論を出しました。2020年9月なかばあたりのことです。この時点で、これから導入先が増えることはほぼ見えていたので、それまで(2020年12月頃)に構築し終えることを目標としました。

また、この検討にあたっては以下の記事を大変参考にさせていただきました。この場を借りてお礼申し上げます🙇‍♀️

想定ゴール

f:id:yoi_ko:20201117161720p:plain
MoneyEasyのAndroidアプリは、ver1.13.0時点で既にCI/CD環境がありました。ver2系でもこれを引き継ぎ、構成自体には手を入れずにUI Test→VRTの置き換えのみを行うこととしました。 なぜこの構成になっているかについては、以下の記事をご覧ください。 finnovalley.hatenablog.com

このゴールを設定した際、SSを取得する実装はすんなり出来るだろうけれど、diffを検出するところを自動化するところまでいけるかは未知数でした。仮にdiff検出の自動化が上手く行かなくても、全画面のSSを取得するところまで出来ているだけでも効率化には繋がるため、全画面分のSSの取得処理実装作業を最優先して進める方針としました。

Firebase ScreenShotterを使ったSS取得処理実装

SSの取得にあたっては、FirebaseのScreenShotterライブラリを使うこととしました。ver1系のUI Testで使ったFirebase TestLabとの連携がしやすいことが決め手となっています。

環境構築

以下の公式ドキュメント通りにやれば大丈夫です。aarファイルを直接参照する形式なのがちょっと「うーん…😞」となるポイントではありますが、まあそこは目を瞑りましょう。いまのところ特に問題はないです。

firebase.google.com

SS取得処理の実装

テストでSSを取得する手順は至ってシンプルです。

  1. 該当画面(Activity/Fragment)を立ち上げる
  2. SS取得処理を実行する

実装自体もそう難しいものではありません。実際のコードはこんな感じです。
このコードでは、スプラッシュ画面を立ち上げてSSを取得しています。

@RunWith(AndroidJUnit4::class)
@LargeTest
class SplashTest {

    // ①
    @get:Rule
    var scenarioRule = ActivityScenarioRule(SplashActivity::class.java)

    // ②
    @get:Rule
    var grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
        Manifest.permission.WRITE_EXTERNAL_STORAGE
    )

    @Test
    fun test() {
        // ③
        scenarioRule.scenario.onActivity { activity ->
            ScreenShotter.takeScreenshot("splash", activity)
        }
    }
}

① Activityの起動には ActivityScenarioRule が便利です。
Activity起動時にIntentで値を渡したい場合は以下のようにします。

    @get:Rule
    val scenarioRule = ActivityScenarioRule<HogeActivity>(
        Intent(InstrumentationRegistry.getInstrumentation().targetContext, HogeActivity::class.java).apply {
            putExtra("fuga", "piyo")
        })

②API28以下で実行する場合は WRITE_EXTERNAL_STORAGE が必要です。(公式ドキュメント参照)
GrantPermissionRule を使って予め権限を許可しておきましょう。

③Activityを渡してやる必要があるので、 ActivityScenario.onActivity 内でScreenShotter.takeScreenshot(filename: String, activity: Activity)を実行します。

撮ったSSを確認する

SS取得処理を実装したら、今度は実際に撮れるか&撮ったSSを確認します。

ローカル

エミュレーターや実機を繋ぎ、以下のコマンドを実行します。

$ ./gradrew connectedAndroidTest

取得したSSは、端末の sdcard/screenshots 配下に格納されています。AndroidStudioのDevice File Explorerなどから見てみましょう。

Firebase TestLab

  1. Firebase TestLabでの実行には、デバッグ用のアプリapkとテストapkが必要となります。以下のコマンドを実行してapkをビルドしておきます。
$ ./gradrew assembleDebug
$ ./gradrew assembleAndroidTest
  1. FirebaseのコンソールからTestLab -> インストゥルメンテーション テストの実行を選択します。↓こんな画面が出るので、1. で生成したapkをそれぞれ指定します。 f:id:yoi_ko:20201118105448p:plain

  2. 実行する端末を指定します。仮想端末のほうが料金は抑えられますが、テストでUiAutomatorを使っていると謎エラーが出たりするので、物理端末をオススメします。 [カスタマイズ]でタイムアウト(デフォルト10分)を設定できるので、テスト数が多い場合は適宜変更しておきましょう。 f:id:yoi_ko:20201118105918p:plain

  3. テストの実行が終わると、こんな感じで結果が表示されます。スクリーンショットタブを選択すると、ズラズラッとSSが表示されます。また、右上の「テスト結果」を押すと、テスト結果の格納されているGCPバケットが表示されます。SSは artifact の下にあります。 f:id:yoi_ko:20201118110321p:plain

注意点

FragmentのSS取得について

developer.android.com

Android Developersでは、FragmentScenario を使った方法が紹介されています。この通りに実装すると、Fragmentを単体で立ち上げることができます。 このとき、Fragmentは空のActivityにアタッチされます。つまり、Fragmentが乗っているActivity側でアプリバーの表示処理を行っている場合は、アプリバーなしの状態の画面が立ち上がることになります。MoneyEasyはまさにこのケースですが、アプリバー込みのSSを取得するほうがVRTの目的に沿うため、FragmentScenarioを使ったこの方法は採りませんでした。

FragmentScenarioを使わないとなると、Activityを立ち上げた上で目的のFragmentに遷移させる必要があります。MoneyEasyでは一部のFragmentの遷移にAACのNavigationを使っており、その箇所では NavController を使って遷移させています。 例)(起動時)入力画面→確認画面と遷移する場合

@Before
fun navigateToConfirm() {
    scenarioRule.scenario.onActivity { activity ->
        activity.findNavController(R.id.nav_host_fragment).navigate(R.id.action_input_to_confirm)
    }
}

ダイアログのSSは取得できない

ScreenShotterライブラリでは、ダイアログを表示した状態でのSSは取得できません。この制約があることは知っていましたが、元々ダイアログは対象範囲外としていたので特に問題はありませんでした。

ところが想定外だったのが、 BottomSheetDialogFragment も同じくSS取得不可なことでした(考えてみれば、それはそうなのですが…😇)。ver2系ではホーム画面の各種メニュー表示にBottomSheetDialogFragmentを使っているので、できればここもカバーしたかったのですが、どうにかする方法がイマイチ思いつかなかった&ここの表示は手動のシナリオテスト時に必ず確認する範囲ではあるので、VRT対象外としています。 ※知見がある方いたらご教授ください🙇‍♀️

f:id:yoi_ko:20201117185948p:plain
BottomSheetDialogFragment使用箇所

SSのファイル名の付き方にちょっとクセがある

ScreenShotterライブラリのtakeScreenshot() メソッドを実行すると、例えば以下のようなファイル名でSSが生成されます。 f:id:yoi_ko:20201118111143p:plain
また、テストクラスが取得できない場合、ファイル名は UnknownTestClass-unknownTestMethod-${ScreenShotter.takeScreenshot()で指名した名前}.jpg になります。いずれの場合も、ファイル名が長くなってしまうのです。(takeScreenshot()で渡している fileName にしてくれればいいのですが…)
そこで、MoneyEasyではScreenShotter.takeScreenshot()を実行するメソッドをひとつ定義し、これをすべてのテストで叩くようにしました。

CloudTest.kt

/**
 * このメソッドでSSを撮ると、ファイル名は `UnknownTestClass-unknownTestMethod-${fileName}.jpg` になる。
 * 参考: ScreenShotter.getScreenshotFileName()の処理
 */
fun takeScreenshot(fileName: String, activity: Activity) {
    ScreenShotter.takeScreenshot(fileName, activity)
}

すべてのファイル名のプレフィックスを統一しておくと嬉しいのは、取得したSSのリネーム処理のスクリプトが簡単に書ける点です。これはのちにCIに組み込む際に活きてきます。

なんとかできた

といった感じで、SS取得処理自体の実装をガンガン進めていきました。画面数が多い&ユーザーの状態によって表示の変わる箇所もカバーする方針としたため、対象は合計102画面となりました。さすがに量が多かったので、実装完了まで1ヶ月くらいはかかっています。

ただ、この時点でほぼすべての画面のSS取得が可能になったことで、それだけでも割と気が楽になりました。CI/CDに組み込む対応がもしできなくても、これまでのように手動で1画面ずつ確認するよりはだいぶテストの負担は減らせそうです。

とはいえ、やはりせっかくだからすべて自動でやってしまいたい……🤔ということで、後編:CI/CD編へと続きます。

finnovalley.hatenablog.com

MoneyEasyのAndroidアプリ開発にCI/CDを導入した話

こんにちは、株式会社フィノバレーでAndroidエンジニア(兼 自称:駆け出しUXリサーチャー)をやっております、島本(id: yoi_ko)です。

COVID-19感染拡大に伴う在宅勤務が始まって以来散歩を日課にしていますが、この暑さで継続の危機に立たされています。夏は好きですが、夜も未練がましく暑いのはどうかと思います。

今回の記事では、フィノバレーの提供するデジタル地域通貨プラットフォーム「MoneyEasy」のAndroidアプリ開発において、昨年にイチから構築した CI/CD環境の概要とその経緯 について紹介したいと思います。

前提

CI/CDの構成をご紹介する前に、MoneyEasyのAndroid開発の基本的な環境について簡単に紹介します。

  • コード管理:GitLab
  • ブランチ戦略:
    • master(リリース用)
    • develop(開発用)
    • feature(Issueに紐ついている作業用、developにマージしたら消す)
  • APK配布:DeployGate

これは、Androidアプリ開発現場ではよく見られる構成だと思います。

ひとつ特殊なのが、MoneyEasyは プラットフォーム だということです。
弊社では、MoneyEasyを使った地域通貨として既に「さるぼぼコイン」「アクアコイン」を運用しています。
Androidアプリは、それぞれの環境ごとにリポジトリを分けてはいません。つまり、先述の両アプリは同じコードで動いています。
アプリ名やテーマカラーをはじめとして、様々な仕様上の違いがありますが、これは productFlavors を分けることで解決しています。

環境は、大きく分けて社内開発用( DEV環境 )、アプリごとのステージング環境( STG環境 )、アプリごとの商用環境( PRD環境 )があります。
機能の新規開発やバグの修正などはDEV環境で行い、ここで実機テストを行っています。
それぞれのアプリごとの機能・デザインのテスト、およびクライアントでの確認はSTG環境で行います。
ここでのクライアントとは、さるぼぼコイン:飛騨信用組合様、アクアコイン:君津信用組合様を指しています。
最後に、リリースされているアプリが動いているのがPRD環境です。

最終的なCI/CD構成(v1.13.0時点)

まず、以下が現在動いているCI/CD構成の全体図になります。

f:id:yoi_ko:20200820174330p:plain

各ステップについて簡単に説明します。

ローカル環境では、 git hook を使って自動で以下の処理を行っています。

  • commit: 開発者のローカル環境で git commit する際、 ./gradlew ktlintFormat を実行
  • push: ブランチを git push する際、 ./gradlew app:lint{DEV環境用のビルドバリアント名} を実行

そして、GitLabでは以下のタイミングでUnit Test、UI Test、APKのビルドを行っています。

  • feature branch push: pushされたfeatureブランチでUnit Testを実行
  • feature -> develop merge: developにfeatureブランチがマージされた際、Unit TestとUI Testを実行
  • add tag: ルールに沿った名前のタグがつけられた際、そのタグ名に応じたビルドバリアントでAPKを作成

全容についてはざっと掴んでいただけたでしょうか?
この構成に至るまでには、第1弾・第2弾と段階を踏んでいます。
次は、それぞれのステップについての詳細と経緯について、時系列で紹介していきます。

ちなみに、この構築にあたっては、2018年に出版された PEAKS(ピークス)|Androidテスト全書 を全面的に参考にしています。
この本がなければ構築できなかったと言っても過言ではありません。まさにバイブルです。
関係者の方々、本当にありがとうございました。

第1弾 目標:テスト→DeployGate配布の自動化

いきなり現在の姿とは違う目標が掲げられていますね。
理由については後述します。

まず最初にCI/CD構築のIssueが立てられたのは、2018年4月でした。
私はまだ入社しておらず、Androidエンジニアは照井さん(id: hotdrop77)だけでした。
ちょうど、Androidのフルリファクタリングが進められていた時期ですね。

照井さん一人によって成し遂げられたフルリファクタリングについては、以下の記事で紹介されています。
finnovalley.hatenablog.com

その後2018年10月に私が入社し、その年末からCI/CD構築作業をスタートしました。
当初の目的は、 「各環境に応じて「テスト → DeployGateへのアップロード」というのを1クリックでできるようにする」でした。
APKのビルドはマシンパワーを使いますし、その間コードの変更などができません。
それをすべて自動化するだけでも、大変な作業効率化に繋がると考えてのことでした。

最初に想定していたゴールはこういった形です。(実際のIssueのスクショ)

f:id:yoi_ko:20200820183240p:plain

この時点で、Unit Testはいくつか存在していましたが、UI Testはまったく書いていませんでした。
このため、第1弾では UI Testの実行は省略しています。

また、検討を進めるなかで「DeployGateへのAPK自動アップロードはしない」という結論に至りました。
前述の通り、アプリをリリースするには、STG環境でクライアントに確認していただく必要があります。
ブランチのマージを契機にビルドしたAPKを自動的にDeployGateへ配布すると、クライアントには予告なくアプリを更新してしまうことになります。
それは避けたほうがよいだろうと考えてのことでした。
このため、APKのビルドは 「ルールに沿った名前のタグがつけられた際」に実行しているのです。

タグ名の命名規則は、さるぼぼコイン:sarubobo-* 、アクアコイン: aqua-* としています。
ハイフンの後ろは、DeployGateにアップする際のVersionを指定する運用にしています。

第1弾での対応をまとめます。

  • ローカル環境での静的解析の自動化
  • push/merge契機でのUnit Testの自動化
  • タグ打ち契機でのAPKビルドの自動化

第2弾 目標:UI Testの自動化

UI Testの自動化は、まずテストを書くところからスタートしました。
それまでUI Testというものを一度も書いたことがなく、またこの時点で画面数が相当な量になっていたため、この「テストを書く」というタスクにかなりの時間を割いています。
実装開始が2019年6月中旬、終了が2019年8月頭なので、ざっと2ヶ月弱でしょうか。

これは結構つらい作業でした。
Issueにも、その片鱗が残されています。

f:id:yoi_ko:20200820190852p:plain
混乱しているのが見て取れるコメント

UI Testを実装するにあたっては、以下の記事を大変参考にさせていただきました。
これらの記事がなければ、途中で諦めていたと思います。深い感謝を捧げます。
Kyash Android で UIテストを導入した時の方針 - Konifar's WIP
RxJavaのアプリをEspressoでテストする簡単な方法 - Speaker Deck

UI Testの実行は、 Firebase Test Lab で行っています。
GitLab CIでUI Test用APK作成 -> Firebase Test Labへ投げる、の流れで処理しています。

ハマりポイントとしては、サービスアカウントの設定です。
Firebase Test Labでの実行には、適切な役割のサービスアカウントが必要になります。
Firebase TestLab管理者という名前の、一見それらしきものがありますが、これだと権限エラーになります。
正しくは 編集者 です。(とても紛らわしい…)

そんなわけで、UI Testの実装に苦しんだり、サービスアカウントの罠にハマったりしながらも、第2弾の目標はなんとか達成することができました。

  • UI Testの実装
  • UI Testの自動化(Firebase Test Lab)

まとめ

このように、第1弾/第2弾と段階を経て、CI/CD環境を構築しました。
DeployGateへの自動アップロードをしていないなど、ちょっとした制限はあるものの、静的解析やテストの自動化・APKのビルド対応によって、開発効率はぐんと上がったのではないかと思います。

とはいえ、APKの作成時は時間が掛かってしまっていたり、UI Test自体のメンテナンスが追いついていなかったりと、まだ課題はあります。
CI/CDのような、品質や効率に関わるタスクはどうしても機能開発に劣後してしまいがちですが、ないがしろにしてはいけないと思います。
今後も、最新の情報をキャッチアップしたり、日々変化する開発を取り巻く現状を見直したりしながら、よりよいCI/CDの形を模索していきたいです。

2020/11/18追記
ver2系以降のテスト事情については、以下記事でご紹介しています。合わせてご覧ください。

finnovalley.hatenablog.com

finnovalley.hatenablog.com

セブン銀行ATM対応のモックアプリをFlutterで作成した話

株式会社フィノバレーでAndroidアプリを開発している照井(id: hotdrop77)です。
弊社MoneyEasyは今年2020年4月、さるぼぼコインとアクアコインでセブン銀行ATMチャージ機能をリリースしました。
プレスリリース記事: https://finnovalley.jp/20200326/1529/

本記事では、そのセブン銀行ATMチャージ機能のサーバーサイド開発を支援するために作成したモックアプリについて書きました。
本当はもっとモックアプリの業務仕様やコードを載せたかったのですが、そうするとセブン銀行ATMの仕様に触れる必要が出てきてしまい、それは守秘義務の関係で公開することができません。
そのため、作成に至った背景やモックアプリの設計概要などを書いています。

背景

セブン銀行ATM対応はサーバーサイド開発がメインでしたが、当時サーバーエンジニアのリソースが不足しており厳しいスケジュールでした。

特に困難だったのがセブン銀行ATMとの疎通テストで、ATMのテスト筐体がポンポン使えるものではなく担当者様とテスト実施日を調整し現地に赴いてテストをする必要があったため、テスト時点である程度の品質を担保して臨む必要がありました。
(少なくとも正常系は一通り動く状態で現地で障害解析もある程度できるレベル)

逆にモバイルアプリ側は簡易な画面を数個作るのみで比較的余裕がありました。
サーバーサイド開発を手伝いたかったのですが自分がGo入門レベルでサーバー側の設計も把握していなかったため期間を考えると邪魔にしかならないことが目に見えていました。

この期間内で有益な支援ができないか考えた結果 「ATMと通信する部分の検証がきつそうなのでそれならモックアプリを用意すれば疎通テストも楽になるのでは?」
と思って私の方でモックアプリを作ることにしました。

モックアプリは当初作成予定はなく、サーバーエンジニアさんが気を遣ってくれる方で
「照井さんもアプリの改修あるし、無理せず時間あったらお願いしますm( )m」
という感じだったので余計に火がついて絶対完成させて負担を軽減してあげようと決意しました。

なお、モックアプリの作成期間はこの話が決まった2019年12月中旬からサーバーがモックアプリでテストを始められるであろう1月中旬くらいの約1ヶ月間でした。

Flutterを採用した経緯について

モックアプリの想定利用者は自分とサーバーエンジニアさんの2名のみであり、完成して使い物になればどんな言語でどう作ろうが問題ありませんでした。

セブン銀行ATM画面の操作をエミュレートするなら画面操作がしやすいものが良く「モックアプリやっぱ無理でしたー」というのは自分の中ではありえないので無難にAndroidで作る想定でいました。

ただ、この頃はFlutterに興味を持っていて趣味でアプリを作成していました。
万が一テストでiPhoneユーザーがこのアプリを使う事態になったり、ブラウザで通信ログやエラーログを見たいという要望が出てきた場合にFlutterならワンチャン対応が可能だと考えました。

この時期はFlutterのWebサポートもそこそこ出来てきており上記の要望に答えられる可能性があったことと、自己学習を兼ねられることからFlutterで作ることに決めました。

モックアプリ概要

モックアプリの大まかな機能は以下の通りです。

  1. セブン銀行ATMのチャージ操作機能
    • ここでは「コード読み取り→金額入力→金額投入→チャージ完了」の一連の流れを画面操作で再現するとともにリクエスト/レスポンスが逐次見えるように作成しました。
  2. 暗号通信の検証機能
    • この部分については何も書けませんがこういう機能を作成しました。
  3. エンドポイントや電文項目の設定機能
    • 通常変更されることはないエンドポイントや電文の固定項目などもモックアプリでは「もしここを変更したら正しくチェックが走るか?」をテストできるようにしました。
  4. 履歴機能
    • 過去のリクエスト/レスポンスが閲覧できる機能です。
      生データからデコードしたものまで段階的に見れるように工夫したのですが、情報量が多くて見辛い画面になってしまいました。
  5. テストモード
    • エラーが発生した場合にモックアプリのバグなのかサーバーのバグなのかを切り分ける最低限の情報を得るためこのモードを作成しました。
      このモードはサーバー通信が発生する操作をしたら仕様書に記載されているサンプル電文をそのまま返す仕様となっています。
      最低限のバグ切り分けには使えましたが、実際にはそんな単純ではなく何回か切り分け調査はすることになりました。

トップ画面はこんな感じですが、諸事情により色々伏せているのであまり分からないですね・・雰囲気だけ読み取ってください。

f:id:hotdrop77:20200729122810p:plain
モックアプリのトップ画面

設計概要

画面は8つと小規模なアプリです。設計は一番慣れているAndroid Architecture ComponentsのMVVMチックな設計としています。
状態管理はProviderを使用し、Flutterアプリでよく出てくるBLoCパターンについては採用を見送りました。
個人的にはBLoCは小規模アプリで採用するには過剰実装だと思っていて今回のような小さいモックアプリではProviderだけで十分かなという認識なのですが、とはいえ言うほどBLoCで実装したことないので一瞬使ってみてもいいかなと考え、でも今回は見送りました。

ViewModelChangeNotifierextendsしておりバインド的にフィールドを使用しました。
本当はLiveDataのように個別に購読できると良かったのですが、その場合はStreamRxDartを使う選択になってしまうと思うのでこのアプリではそこまでやりませんでした。
Providerの4系で登場したselectが使えると近しいことができると思うのですが、この当時はまだ3系を使用しており使えませんでした。

UseCase層は作らずViewModelの下はRepository層としました。
いつも通りRepositoryでAPIを実行するためのネットワーク通信処理やローカルDBへの保存/取得処理を行なっています。
それぞれ使用したライブラリはこんな感じです。

  • ネットワーク通信: http
  • 設定系の保存: shared_preferences
  • 履歴データなどの保存: sqflite

ローカルDBのライブラリは使い慣れているsqfliteにするか、この時期ちょっといいなと思っていたhiveにするか迷いました。
この時点ではhiveはまだ冒険すぎたのでこの時はsqfliteを採用しました。
hiveは別の検証アプリで使ったのでそのアプリについて記事にすることがあればそこで書こうと思います。)

苦労した点

苦労した点は順にこんな感じです。

  1. セブン銀行ATMの仕様理解
  2. バイナリデータの扱い
  3. 暗号方式の扱い

1. セブン銀行ATMの仕様理解

難易度が一番高かったのがこの仕様理解です。
実は12月当初は資料が不足しておりそれが無駄に難易度を高めていたのですが、ここでサーバーエンジニアさんとあーだこーだ色々と話して理解を深めていました。
この話し合いの中でネックになりそうな処理や重点的に見ておいたほうがいい処理が大体わかってきたので、そのままモックアプリの仕様に繋げました。

2. バイナリデータの扱い

Dartでバイナリデータを扱うこと自体はさほど難しくなかったのですが、慣れていないためすぐString型で扱おうとしてしまったり型を変換せずにリクエストにのせようとしてデータ長が狂ったりしました。
本当はAPIを投げる直前の出入り口でMapperを用意してそれぞれ扱う型に変換すれば良いのですが、このモックアプリではリクエスト/レスポンスの生データと複合化したデータ両方を画面表示していたためModelクラスに両データを持つ必要がありました。

3. 暗号方式の扱い

とりあえず資料に出てくる用語は概要レベルでも頭に叩き込むのが望ましいのでそれを最初にやりました。
暗号については以前読んだ結城先生の「暗号技術入門」で基本的なことは把握していました。まあ忘れてることが多かったので再度読み直したのですが。。

そして何よりFlutterの暗号ライブラリEncryptが素晴らしくかなり助けられました。これ一つ導入すれば主要な暗号アルゴリズムはだいたい網羅できると思います。
最初はこんな便利なライブラリの想定をしていなかったため、最悪PlatformChannelでネイティブレイヤーまで降りてガリガリコードを書く覚悟でした。

とはいえこの覚悟も無駄ではなく、どうしても無理なところがあって一部Javaコードで解決しました。
そのため、最初はWeb/iOSでも使えるライブラリだけで頑張っていたのですがこのタイミングで断念し、最終的に完成したモックアプリはAndroidでしか動きません。
もちろんSwiftで同じ処理を書けばiOSでも動くと思いますが、結局他のプラットフォームで動かす要望もなさそうだったためそのまま突き進みました。

まとめ

サーバーエンジニアの方が優秀だったこともありモックアプリでいくつかバグを摘出&修正したのち、予定通りテスト筐体での検証はすんなりいってそのままオンスケで本機能をリリースすることができました。
多少モックアプリのバグで足を引っ張った気もしますが、かなり役に立ってくれたようですし無事にリリースできて良かったと思います。

今回モックアプリをFlutterで作成してみて、やはりFlutterもDartも良いものだなと改めて思いました。
Androidの画面開発はxmlに慣れていてJetpack Composeはどうも好きになれないのですが、宣言的UIが嫌いなわけではなくFlutterはWidgetとホットリロードの恩恵によってUI実装がXMLより早く書けるしMaterialDesignも簡単にできて素晴らしいと思います。

Dartは個人的に好きなのですがKotlinに比べると色々惜しくて、でも最近の進化が早くそろそろnullセーフ機能も入りそうですし期待しています。

まだまだエンジニアの確保をはじめとしてプロダクトに導入するには会社的にハードルが高いと思いますので、まずはモックやプロトタイプなどのアプリを素早く作って事業に貢献していければなと考えています。

MoneyEasyのAndroidアプリを大規模リファクタリングした話

はじめに

初投稿となります。株式会社フィノバレーでAndroidアプリを開発している照井(id: hotdrop77)といいます。
私は2018年1月に入社し、以降ずっとMoneyEasyのAndroidアプリ開発を担当してきました。
本記事では、私が入社してから最初に行った大工事であるAndroidアプリのフルリファクタリングについて書いていこうと思います。

入社当時の状況

2017年末にMoneyEasyプラットフォームを使用した最初のサービス「さるぼぼコイン」がリリースされました。
私はこのリリースから1ヶ月後くらいに入社したのですが、当時様々な事情がありAndroidアプリの保守運用は引き継ぎなく私一人で開発することになりました。
この時のAndroidアプリの状態を簡単に表すと

  • FatActivity
  • ドキュメントなし

という「まあよくあるよね」という状態でした。
誤解がないよう補足しておくと、収益が見込めるかまだ分からないサービスに対してはとにかくスピード重視で迅速にサービスをローンチすることが重要だと思うのでこういう状態になってしまうのは仕方ないのかなと思っています。
私自身はエンジニア界隈でも言われている通り品質とスピードは相反するものではないという考えに賛同しますが、一方で「とにかく動くものを今の最適リソースで作るにはこれしかなかった」という事情も理解できるのです。
そんなわけで入社直後はこの状態下で溜まっている様々な課題や機能追加を担当することとなりました。

こんな状態で大丈夫?

私は前職がガチガチのSIerで、Androidといえば趣味&ポートフォリオ用にアプリを作った経験のみでした。(DroidKaigiアプリには毎年お世話になっております。)
そんな私にとって幸か不幸か当時のAndroidアプリのコードは上述の通りViewに「描画処理、ビジネスロジック、Http通信」まで全てが1クラスに集約されており、また使用しているライブラリも最低限であったため構成の把握は比較的容易でした。
中途半端にトリッキーなDIをされていたりオレオレMVXな構成でなかったことは不幸中の幸いだったと思います。
そうはいっても、このコードを今後メンテしていくモチベーションを保つのはなかなか辛いものがありました。
せっかくSIerから転身したしDroidkaigiのアプリを主に見ていた関係でもっとモダンな設計やライブラリを使って開発したかったのです。

そして2ヶ月後

入社して1ヶ月後くらいにDroidkaigi2018が開催されました。2017年まで私は個人参加していましたが、この年から会社のスポンサー枠で参加させていただけることになりました。
ブースの手伝いは最低限で好きなセッションどんどん見て良いよと言われましたが、お金出していただいているしちょっとは手伝いしないと悪いと思って、結局ブースの方が他社のアプリ開発者と話せる機会が圧倒的に多いので半日以上いた記憶があります。
2017年のGoogle I/OAndroidの正式言語にKotlinが含まれたこともあり、当時はよく「そちらのAndroidアプリにはもうKotlin導入しました?」みたいな感じで会話のきっかけを作っていました。
Droidkaigアプリのコードも読んでいてとても面白く、そして羨ましかったことを覚えています。

話はMoneyEasyに戻ります。
比較的大きな機能をリリースした2018年3月くらいでしょうか、開発メンバーから技術的負債返却の声が上がりはじめました。
当然、Androidアプリだけがこんな構成になっている訳ではなくサービス全体が同様の構成になっており、今後需要が高まりそうなサービスでしたので早めに負債を返却することが急務となりました。
この辺りはチーム内でとても色々なやりとりがあったのですが、それだけでこの記事が埋まってしまうので割愛します。 当時サーバーサイドを担当していたメンバーが強烈に後押ししてくれたこととPMが「責任を持ってやってくれれば自由にしていいよ」と理解ある感じだったため自分はAndroidアプリのリファクタリングを行うことになりました。
責任を持ってやるのは当たり前だ!という話もありますが「そんなリスクは許容できない」「全テストが必要なのでダメ」などそもそもチャンスすら与えられない環境が多い中、入社して浅い自分にリファクタリングを任せてもらえたことはとても嬉しかったです。
顧客も金融機関では珍しく技術に理解を示してくれているようで各ステークホルダーに恵まれた環境なのかもしれません。

大規模リファクタリングにあたっての制約

今回のリファクタリングでは一部分ではなく根幹から全て修正することにしました。
リファクタリングを行うにあたっての業務的な制約に加え個人的な制約(誓いに近いです)を念頭に置いて計画を立てました。

  1. 業務的な制約
    1. スケジュールの関係で7末までに完了させること(8月に機能全テストが必要なリリースがあるのでそれに間に合うことが必須)
    2. 既存アプリへの機能拡張&保守運用は最優先で行う
  2. 個人的な誓い
    1. MoneyEasyのAndroidアプリの設計や実装について胸を張って話せるようになる
    2. チームの期待を裏切ることはしない

2-1が私の中で一番大きな要因で、自身が開発しているアプリのことは業務的にもコード的にも胸を張って話したいので出来るだけモダンな設計/実装にしたい想いがありました。
また、PMや開発メンバーが信頼して任せてくれたこととサーバーチームの方々にも設計や方針検討など協力してもらったため、期待通りにやりきることと重大なバグを発生させないことを最優先に考えて取り組みました。

取り組む前の設計/検討

この大規模リファクタリングに当たって考えることは山のようにありました。
コードの改善はもちろんのこと、CI/CDやテストコード導入などやりたいことは多くありましたがリソースとスケジュールの兼ね合いで断念したものも多くありました。
最終的に以下の6項目に分けてそれぞれ設計/検討しました。

  1. アーキテクチャ見直し
  2. 採用言語の検討
  3. 使用ライブラリ検討
  4. リソース設計
  5. ログ設計
  6. エラー設計

これらは一つ一つが1記事書けるくらいになってしまうし、今となってはより良い設計がデファクトスタンダードになっているものもあるため詳細は割愛します。
スケジュールがタイトでしたがいきなりコードを触り始めると絶対失敗するので4月の1ヶ月間はこれらの設計および検討に注力することにしました。
そのため、コードに手を入れる期間はGW明けの5月〜7月末の3ヶ月間となりました。
最終的にKotlinを採用しAndroid Architecture Components(この表現は長いので以下AACとします)を使ったMVVM構成で、各レイヤーの繋ぎこみはRxJavaを使用、DIはDagger2という構成で再構築することにしました。
ちなみに構成はDroidkaigi2018の影響を思いっきり受けています。

具体的な進め方

必要な設計/検討はどれも重要でしたが、それとは別にどうやって進めていくのが最も良いのかを考えることも重要でした。
なにせまだAndroidアプリの仕様全貌はつかめておらず不明な機能もそこそこある状態でした。ドキュメントもテストコードもないため動かして確認するしかありません。
最終的に次の2つを厳守した状態で進めていくことにしました。

  1. 既存アプリとリポジトリを完全に分割
  2. 常に動く状態を保つ

1については既存アプリとそのコードは完全にそのままの状態とし無用な事故を防ぐ目的もありました。
2については本当に細心の注意を払いました。
想定する設計でリファクタリングしてしまうと元コードの状態が跡形もなく消えて無くなるため、ある程度リファクタリングを進めてしまった後にビルドエラーや不思議な挙動でどうにもならなくなった場合、最悪動いていたところまで手戻りが発生し調査に無駄な時間がかかります。
当然、全てリファクタリングした後に動かすなんてリスクが高すぎて取れません。
こういう時テストコードがあれば心がとても安らぐのですがこの状態でテストコードを書くのは不可能に近かかったので(書くならせめてビジネスロジックをViewから分離した後)この時は「常にアプリが動作する状態にする」ことを徹底しました。

「常にアプリが動作する状態にする」にした強い理由がもう一つあって8月頭から全機能テストが始まるため、7末に実装を完了させたところで「アプリを1度も動かしていない状態」ではテスト工程に入れるレベルの品質に達していないのは明白で話になりません。
かといって、7月中旬あたりにリファクタリングを全て終わらせて2週間くらいで動作確認しまくる、というスケジュールも高確率で破綻すると考えました。
それならばチマチマ動作確認をしながら進めることで実装完了とともに簡単な動作確認も完了させてしまうのが最も早そうという結論に達しました。
この進め方にはメリットがあって、この方針でいく場合は機能間の結合度が高いとチマチマ動作確認する意味がなくなりますので可能な限り機能同士が疎結合になることを意識して進めました。
これは改めて意識するまでもなくコード書く上で当たり前のことですが今回は後述するようにある程度元コードを保った状態で進めていくことにしたので油断すると元コードの密結合をそのまま引き継いでしまう恐れがあったのです。それを回避するため改めて意識する必要がありました。

この状態を維持しつつFatActivityからいきなりAACを使用したMVVM構成にするのは難易度が高すぎるので段階的にリファクタリングできるよう4つのポイントを設けることにしました。

4つのポイント

リファクタリング期間3ヶ月を大きく4つのポイントで区切って段階開発していきました。
各ポイントの概要は以下の通りです。

  1. ポイント1: FatActivityからビジネスロジックを切り離しレイヤー構造にする
  2. ポイント2: AAC以外のライブラリ導入
  3. ポイント3: 1機能のみKotlin+AAC対応
  4. ポイント4: 全機能Kotlin+AAC対応

これらはどのポイントでリファクタリングを中断しても一応コードは使える状態であることを意識しています。
6月にいきなり既存アプリへ大きな機能が追加となる可能性も0ではなかったため、その場合は4月〜5月のコストがなるべく無駄にならないように配慮した結果でした。
ポイント4まで到達しないと自分の目的は達成できないのでそういったことがないことを祈りましたが。

ポイント1 FatActivityからビジネスロジックを切り離しレイヤー構造にする

何をやろうにもまずFatActivityだとどうにもならないので、真っ先にやることはViewからビジネスロジックを切り離すとともにクラスやメソッドを意味のある単位で分割しレイヤー構造にすることでした。
具体的には以下のイメージ図のように内部構成を変えました。

f:id:hotdrop77:20200619175138p:plain

このポイント1は5月中旬くらいまでに完了させる予定で行い、ほぼオンスケで完遂できました。

ポイント1での注意点は「内部ロジックは可能な限り変更を加えない」ことでした。
これをやっている間、凄まじくリファクタリング衝動にかられましたが無駄になるかもしれないし余計な混乱を招く恐れが高かったため変数名一つ変えず極力そのままレイヤー分割に徹しました。
ただ、そうはいってもどうしても追加修正しないといけないコードは出てくるのでそういったものは修正しています。
なぜこんな注意点に徹したかという理由ですが、コンパイルエラーやおかしな動作をした時に元コードとの比較が容易になるためです。
たまにおかしな変数がおかしな値を持っていることがあり、そのままにしておくと元コードでの検索が容易になるため何を入れていて何をしていたかの調査が容易でした。
実はSIer時代にこの時とはちょっと違いますが巨大な機能のリファクタリングしたことがあってその時の失敗経験をここで活かすことができました。

ポイント2 AAC以外のライブラリ導入

AACを導入するとViewModelが間に挟まり元コードの名残がだいぶ消えてなくなるので、まずそれ以外のライブラリを順次導入していきました。
ライブラリの選定はとても慎重にやりました。ここはAndroidエンジニアでないと選定が難しいので自分一人で検討することになりますが、ありがたいことにOSS自由に選定して良いという方針でした。
ただ、MoneyEasyはお金を扱うサービスですのでちょっと便利だからという理由でライブラリを入れまくることはしたくありませんでした。
自分がよく知っているもの、デファクトといっていいくらいAndroid界隈では有名なもの、活動が活発でドキュメントが需実しているものなどいくつか基準を設けてかなり絞って選定しました。

導入の際にインパクトが大きかったのはDagger2とRxJavaでした。特にDagger2はApplicationからDIするので部分導入できずほぼ全クラス修正が必要となりなかなか苦戦しました。
また、Dagger2は以前から趣味アプリで使っていましたがComponentsやModuleを理解せず使用していたためこのタイミングで1からDagger2を学習しました。
Rxは自分のお気に入りライブラリトップ3に入るくらい好きで以前から使っていたため学習は不要でしたがこれも導入時は大量に修正が必要でしたので時間がかかりました。
このポイント2までは5末までに完了させる予定でした。実はここでちょっと足が出て6月1週目に食い込んでいます。

ポイント3 1機能のみKotlin+AAC対応

次は1機能だけKotlin+AACで動作させるようリファクタリングしました。
これをやると完全に元コードの面影が消滅するのでエラー解析が今までより面倒になりますが、まずView〜RepositoryまでKotlin化してしまい一度動作確認、それからAACを導入することでUseCase〜Repositoryは動作確認できている状態になるため調査範囲をViewModel&Viewに限定できて効率化できたかなと思います。
Kotlinらしいコードに書き換えるのはこの後にやりました。
ポイント3は6月中旬くらいまでかける想定でしたが、ポイント2でちょっと足が出ていたため予定より期間はありませんでした。
ただここはちょっとバッファを持っていたためリカバリでき、当初予定通り6月中旬ちょっと前にこのポイントを終えることができました。

f:id:hotdrop77:20200619175314p:plain

この最終構成はDroidkaigi2019などで弊社ブースで配布していたポストカードにも載せています。

f:id:hotdrop77:20200619175547j:plain

ポイント4 全機能Kotlin+AAC対応

ここからは7末までひたすら全力全開で全機能リファクタリングをしていきました。当時は大雑把に分割しても25機能でだいたい70画面くらいあったと思います。
ポイント3で大体やり方がわかったのでこのポイントは比較的すんなり進めたのですが、それとは別に「KotlinがJavaと100%互換である」という素晴らしい特性を持っていたことですんなり進めたと思います。このおかげで1機能ずつ「リファクタリング→動作確認」という手段を取れました。

ポイント4は無事7末で完了でき、動作確認も逐一行なっていたためそのままテスト工程へ突入しても差し支えない程度の状態にすることができました。
実際この後、8月に全機能テストをしていただき無事にリリースすることができました。
当然テストではバグがかなり出たのですが致命的なものはほとんどなく、またテストをしっかり行なっていただけたこともあってリリース後に大きなバグも発生しませんでした。

まとめ

細かい点はまだまだ多く苦労した点も面白かった点もたくさんあるのですが、それを書くと長編になってしまうのでまたの機会にしたいと思います。
リファクタリングはエンドユーザーにはほとんど見えない部分ですが実はTextFieldなどはMaterialDesignに準拠して改善していたので所々UIに小さな変化を加えていましたし、全体的にかなり性能改善をすることができました。
特に起動周りとお店一覧は劇的に改善させたのでPMや開発メンバーは気づいてくれて嬉しかったです。

自分にとってこのリファクタリング作業はとても大きな成果でした。
強制的に全コードを読む羽目になったためAndroidアプリの全容を知ることができ、可読性も凄じく向上したためその後の開発効率が格段に上がりました。

それから少ししてAndroidエンジニアがもう一名参画し、ユニットテスト/UIテスト、CI環境などを整備してくれて開発効率がさらに上がりました。
やはり一人では限界があるのでとても感謝しております。

この大工事からそろそろ2年が経ち、少しずつ当時の設計や導入ライブラリが劣化していると思っています。
ライブラリは小まめにバージョンアップしているものの影響範囲が大きいものはなかなか入れ替えが難しいのが現状です。
しかし、継続的なリファクタリングはサービスの寿命も伸ばしますしエンジニアのモチベーションにも開発効率の向上にも繋がるはずです。
次の大きなバージョンアップではCoroutineを導入する予定で、これからも継続的なメンテナンスは大事にしていきたいと思います。