Dangerでプルリクエストのチェックを自動化する

こんにちは。

リサーチ・アンド・イノベーションAndroidエンジニアをしている jageishi です。

今回は、弊社で利用している Danger の簡単な説明と導入事例を紹介します。

Dangerとは

Dangerはプルリクエストの形式化や機械的なチェックを自動化するのに使える便利なツールです。

以下のような特徴があります。

  • Dangerfileにルールを記述する
  • ルールはプラグインとして公開されているものがある
  • 様々なコードホスティングサービスをサポートしている
  • Ruby版の他にJavaScript、Kotlin版等が存在する

Dangerの導入

今回はRuby版を使ってGitHub ActionsのワークフローでDangerを実行して、プルリクエストにコメントする方法を例に説明します。

Gemfile

公式からもBundlerの利用が推奨されているため、Gemfileを用意します。

# frozen_string_literal: true

source "https://rubygems.org"

gem "danger"

Dangerfile

次に、Dangerfileをプロジェクト直下のディレクトリに作成します。

message("メッセージ")

DangerfileはRuby DSLで記述します。

今回はメッセージをコメントするだけですが、他にも様々な機能が用意されているため細かいチェックを行うことが可能です。

使用できるAPIについては こちら をご覧ください。

GitHub Actions

pull_requestイベントをトリガーとして実行されるワークフローを作成します。

name: Danger

on:
  pull_request:

jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.0'
          bundler-cache: true
      - run: bundle exec danger
        env:
          DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Rubyを使用するために ruby/setup-ruby を利用しています。 こちらのアクションはデフォルトでBundlerもインストールされるため、別途gem install bundlerを実行する必要はありません。

また、bundler-cache: true を設定することで併せて bundle installが実行され、gemがキャッシュされるようになります。

最後に環境変数 DANGER_GITHUB_API_TOKEN にアクセストークンを設定して bundle exec danger を実行すればOKです。

プルリクエス

以上の設定を行うことで、プルリクエストの作成時に以下のようにコメントされるようになります。

プルリクエストのコメント

このように導入のハードルが低く、手軽に試すことができます。

弊社の導入事例

ここからは弊社での導入事例を紹介します。

CODEのAndroidアプリ開発で利用しているDangerfileの構成は以下の通りです。

danger.import_plugin("danger/plugins/*.rb")

# Android Lint
android_lint.gradle_task = "app:lintFlavorDebug"
android_lint.report_file = "app/build/reports/lint-results-flavorDebug.xml"
android_lint.filtering = true
android_lint.lint

# 画面密度毎に画像リソースが存在するか確認する
image_resource_checker.check

# Roomマイグレーションの有無を確認する
schema_changes_checker.check

かなりシンプルな内容ですが、以下のような流れになっています。

  1. プラグインの読み込み
  2. Android Lintの実行
  3. 画面密度毎の画像リソースのチェック
  4. Roomマイグレーション有無のチェック

1. プラグインの読み込み

danger.import_plugin("danger/plugins/*.rb")

Dangerはプラグインとしてルールを配布できますが、私達はDangerfileの肥大化を防ぐ目的でローカルにプラグインを作成してファイルを分離しています。

こちらはそれらのプラグインを読み込んでDangerfile内で参照できるようにするものです。

プラグインの作成方法については こちら をご覧ください。

2. Android Lintの実行

android_lint.gradle_task = "app:lintFlavorDebug"
android_lint.report_file = "app/build/reports/lint-results-flavorDebug.xml"
android_lint.filtering = true
android_lint.lint

Android Lintの実行には loadsmart/danger-android_lint を利用しています。Androidアプリ開発者にはおなじみかもしれません。

Android版CODEはマルチモジュール構成になっているため、checkDependencies true を設定することで全てのモジュールに対してチェックが行われるようにしています。

android {
    lintOptions {
        checkDependencies true
    }
}

前もって各モジュールに対してLintを実行しておいて、出力された複数のレポートを処理する方法もよく見かけますね。

3. 画面密度毎の画像リソースのチェック

image_resource_checker.check
module Danger
  class ImageResourceChecker < Plugin
    attr_accessor :target_densities, :target_extensions
    def target_densities
      return @target_densities || ["mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"]
    end
    def target_extensions
      return @target_extensions || ["png", "jpg"]
    end
    def check
      renamed_files = git.renamed_files
      target_files = git.added_files \
                       + git.modified_files \
                       + renamed_files.map { |f| f[:after] } \
                       - git.deleted_files \
                       - renamed_files.map { |f| f[:before] }
      regex = /drawable-(#{target_densities.join("|") })\/.*\.(#{target_extensions.join("|")})$/
      target_image_files = target_files.filter { |f| regex.match?(f) }
      checked_files = []
      missing_density_files = []
      target_image_files.each do |f|
        next if checked_files.include?(f)
        target_densities.each do |d|
          image_file = f.gsub(/drawable-(#{target_densities.join("|")})/, "drawable-#{d}")
          missing_density_files << image_file unless File.exists?(image_file)
          checked_files << image_file
        end
      end
      unless missing_density_files.empty?
        header = "### 画像ファイルを追加してください:pray:\n"
        header << "| ファイル |\n"
        header << "| --- |\n"
        message = missing_density_files.map { |f| "| `#{f}` |\n" }.join
        markdown(header + message)
      end
    end
  end
end

こちらでは画像リソースに変更が加えられた際に、画面密度毎に用意されているかをチェックしています。

Android LintにIconDensitiesというissue idがあり、まさに同じようなチェックを行ってくれるのですが、以下の理由から自作することにしました。

  • ディレクトリ毎に検出されるためdanger-android_lintでandroid_lint.filtering = trueを指定して対象を追加、および変更されたファイルに絞っている場合はプルリクエストにコメントされない
  • xxxhdpiがチェック対象外になっている

4. Roomマイグレーション有無のチェック

schema_changes_checker.check
module Danger
  class SchemaChangesChecker < Plugin
    def check
      schema_json_path = "<Your schema location>"
      has_changed_schema = (git.added_files + git.modified_files).filter { |f| f.start_with?(schema_json_path) }.any?
      if has_changed_schema
        markdown_text = "### マイグレーションが必要です\n"
        markdown_text << "- [ ] マイグレーション処理を実装済み\n"
        markdown_text << "- [ ] Google Play公開バージョンからのマイグレーションをテスト済み\n"
        markdown(markdown_text)
      end
    end
  end
end

弊社ではRoomを利用しており、スキーマファイルを出力してリポジトリに含める運用になっています。

こちらはスキーマファイルに変更が加えられた際に、マイグレーションが必要である旨をコメントして確認を促すようなものです。

スキーマが変更されたもののマイグレーション処理が抜けた状態でメインのブランチにマージされてしまい、アプリをアップデートした際にエラーが発生してしまう状態になっているのをリリース前のテストまで気づけなかったことがありました。 そういった経緯もあり、対応漏れを未然に防ぐためにチェックを行っています。

出力内容

出力は以下のようになります。

出力内容

ちなみに、同じプルリクエストでDangerが再度実行された場合は、すでにDangerによって投稿されたコメントが更新されるような挙動になっており、プルリクエストがコメントで溢れかえってしまうようなことはありません。

こういった部分にも配慮されているため、Dangerはとても使い勝手の良いツールだと思います。

まとめ

Dangerと弊社での導入事例について紹介しました。

チェックが自動化されることで、問題の早期発展に繋がったり、コードレビューの負担を減らせていると感じています。

導入済みの方も多いと思いますが、まだ導入していないという方は是非お試しください。その際にこの記事が少しでも参考になれば幸いです!

便利な機能は他にもたくさんありますので、詳しくは 公式ホームページ のガイドやリファレンスをご覧ください。


リサーチ・アンド・イノベーションではエンジニアを募集中です。

興味を持っていただけた方はお気軽にご連絡ください!

採用情報

Jetpack Composeを本番投入しました(画面遷移編)

リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。前回Android版CODEアプリにおけるJetpack Compose使用画面でのマルチモジュール構成について解説しましたが、今回は画面遷移における技術選定について解説いたします。

3部構成

この記事は3部構成の第3部です。

Jetpack Composeにおける画面遷移の方法一覧

DroidKaigi 2021でのセッション「プロダクトレベルで必要になる Jetpack Compose テクニック」では、3種類の画面遷移の方法が紹介されていました。

Activity 画面遷移
複数Activity Activity遷移、Fragment差し替え
1 Activity Fragment差し替え
1 Activity Compose

結論

CODEでは複数のActivityをstartActivityメソッドやActivity Result APIで遷移することで、画面遷移を実現しています。言い換えると1 Activityは1画面分のComposable関数しか持っていません。そのような技術選定になった経緯を次の節から解説します。

複数Activityになった経緯

Jetpack Composeを採用する前の話

Jetpack Composeを採用する前から複数Activityの構成になっていました。2020年にホーム画面をリニューアルしたときに、Jetpack Navigationを一時期採用していましたが、最終的に複数Activityの構成になりました。理由はホーム画面はアプリ内広告のAdMobを設置する画面であったことで、AdMobのバナー広告はアドネットワークからダウンロードしたコンテンツをViewの中に持つ作りに対して、Jetpack Navigationは前の画面に戻ったときもFragment内のViewが再生成される挙動のため、相性が良くなかったからです。Jetpack Navigationを使うと、AdMobバナー広告を持つ画面に戻ったときに再びアドネットワークからコンテンツをダウンロードすることを防ぐために、AdMobのViewを剥がしてまた付けるという分かりにくいコードを含むことになります。それがJetpack Navigationを使うことのメリットを上回ると思わなかったので、結局複数Activityを使い続けていました。

とりあえずNavigation Composeを採用

当初はNavigation Composeを使っていました。前々回の記事で繰り返し一括登録機能の2画面(一覧画面、編集画面)をJetpack Composeで開発したことを解説しましたが、その2画面をこのような構成でNavigation Composeで遷移していました。ViewModelの注入はkoin-androidx-composeで行っていました。

Navigation Compose

/**
 * 一覧画面
 */
private const val LIST_PATH = "list"
/**
 * 編集画面
 */
private const val EDIT_PATH = "edit/{id}"
/**
 * 繰り返し一括登録画面の一覧と編集の2画面を含むComposable関数
 *
 * @param subscriptionViewModel SubscriptionActivityを制御するためのViewModel
 */
@Composable
fun SubscriptionContent(
    subscriptionViewModel: SubscriptionViewModel
) {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = LIST_PATH) {
        composable(LIST_PATH) {
            // 一覧画面
            val viewModel = getViewModel<SubscriptionListViewModel>()
            SubscriptionListScreen(viewModel = viewModel, navigateToEdit = { id ->
                // 編集画面に遷移する
                navController.navigate("edit/$id")
            }) {
                // 前のActivity(家計簿設定画面)に戻る
                subscriptionViewModel.backToHouseholdSetting()
            }
        }
        composable(
            EDIT_PATH,
            arguments = listOf(
                navArgument("id") {
                    type = NavType.LongType
                }
            )
        ) { backStackEntry ->
            // 編集画面
            val id = backStackEntry.arguments?.getLong("id") ?: 0L
            val viewModel = getViewModel<SubscriptionEditViewModel>()
            SubscriptionEditScreen(viewModel = viewModel, id = id) {
                // 一覧画面に戻る
                navController.popBackStack()
            }
        }
    }
}

Firebase Analyticsによる計測を優先して、複数Activityになった

しかし最終的にはこのように1Activityに1画面分のComposable関数を持つ構成となりました。

1Activityに1画面分のComposable関数

そうなった理由はFirebase Analyticsです。Firebase Analyticsには画面遷移の情報を自動で送信する機能があります。イベント名がscreen_viewでActivityクラス名がfirebase_screen_classパラメータで送信されます。このような情報はデータ分析エンジニアやAndroidエンジニアが利用実態を把握して、改修方針を検討するために利用されます。

参考 スクリーン ビューを測定する

BigQueryにエクスポートしたFirebase Analyticsの情報

しかしNavigation Composeでは画面遷移の情報を自動で送信しません。よって当初はNavController.OnDestinationChangedListenerを使用して独自に送信しようと考えていました。

@Composable
fun SubscriptionContent(
    subscriptionViewModel: SubscriptionViewModel
) {
    val navController = rememberNavController()
    navController.addOnDestinationChangedListener { _, destination, _ ->
        when (destination.route) {
            LIST_PATH -> {
                // 一覧画面への遷移イベントの送信
                // screen_view(firebase_screen_class=SubscriptionListScreen)
                subscriptionViewModel.onList()
            }
            EDIT_PATH -> {
                // 編集画面への遷移イベントの送信
                // screen_view(firebase_screen_class=SubscriptionEditScreen)
                subscriptionViewModel.onEdit()
            }
            else -> {
            }
        }
    }
    // 略
}

しかし、編集画面(SubscriptionEditScreen)から、Jetpack Compose導入以前から存在したActivityであるカテゴリ選択画面(CategorySelectActvity)を呼ぶ実装があり、そこで新たな問題が発覚しました。

こちらの動画のような画面遷移を想定します。

  • 一覧画面
  • 編集画面
  • カテゴリ選択画面
  • 編集画面

繰り返し一括登録画面の画面遷移

理想としては、このように画面遷移を送信したかったです。

  • SubscriptionListScreen (一覧画面)
  • SubscriptionEditScreen (編集画面)
  • CategorySelectActivity (カテゴリ選択画面)
  • SubscriptionEditScreen (編集画面)

しかし実際はこのような送信になってしまいました。

  • SubscriptionListScreen (一覧画面)
  • SubscriptionEditScreen (編集画面)
  • CategorySelectActivity (カテゴリ選択画面)
  • SubscriptionActivity (一覧画面と編集画面をNavigation Composeで持つActivity)

SubscriptionEditScreenでCategorySelectActivityから戻ってきた時もSubscriptionEditScreenに遷移したイベントを送ることで解決しようと思いましたが、実装漏れを起こしてデータ分析エンジニアに迷惑をかけてしまう懸念がメンバーから上がりました。よって最終的にはチームとして、計測を重視してNavigation Composeは使わないという結論になりました。

まとめ

今回はCODEにおけるJetpack Compose使用画面の画面遷移について、技術選定の様子を解説しました。これが正解ということでは無く、選択当時のメンバーによるその時点での状況を踏まえたディスカッションの結果、複数Activityによる画面遷移になりました。

これまで3回にわたり、Jetpack Compose本番投入の様子を紹介してきました。本番投入はうまくいったので、新画面ではJetpack Composeを積極的に使用していく方針です。そして、まだまだベストプラクティスを模索しながら実装している状態です。メンバーの増員や新たな課題、Kotlin Multiplatform Mobile導入機運の高まりによっては、また別の技術やアーキテクチャを採用する可能性があります。

Androidエンジニア募集中

弊社リサーチ・アンド・イノベーションでは、例えば技術選定をディスカッションしたいAndroidエンジニアを募集しています。

採用情報 - Androidエンジニア

Jetpack Composeを本番投入しました(マルチモジュール編)

リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。前回Android版CODEアプリにおけるJetpack Compose使用画面でのViewModel設計について解説しましたが、今回はマルチモジュール構成について解説いたします。

3部構成

この記事は3部構成の第2部です。

  • ViewModel編
  • マルチモジュール編(今回)
  • 画面遷移編

Jetpack Composeのプレビューを速くする

Jetpack Composeを採用した画面ではマルチモジュールの構成を変更しているのですが、そのモチベーションはJetpack Composeのプレビューを速くするためです。DroidKaigi/conference-app-2021でも言及されている通り、プレビュー対象のComposable関数の依存モジュールを必要最低限にすることによって、プレビューを速くしています。1度フルビルドが完了した後のプレビューの再表示のためのビルドは、3〜5秒ほどで完了します。(M1 Max, 64GBメモリで計測)

Jetpack Compose不使用画面のモジュール構成

Jetpack Composeを使っていない画面のモジュール構成は前々回に解説したアーキテクチャに沿って、このようになっています。

Jetpack Compose不使用画面のモジュール構成
Jetpack Compose不使用画面のモジュール構成

機能別モジュールにはホーム画面や買い物登録など、大まかに分類された各機能のActivity/Fragment、リソース、ViewModelとUse Caseのクラスを格納しています。各機能で共通して使われるリソースやAPIエラー処理などはviewCommonモジュールに置いています。別モジュールにあるActivityを呼び出すためのActivity aliasもこちらに置いています。 このモジュール構成のまま機能別モジュールにComposable関数を実装すると、プレビューのためのビルドでrepository、localDataStore、remoteDataStoreモジュールが含まれてしまい時間がかかります。よってComposable関数を持つモジュールではそれらを含まないようにしています。

*1

Jetpack Compose使用画面のモジュール構成

Jetpack Composeを使う画面では、機能別モジュールをこのようにpresentation、compose、useCaseの3モジュールに分けました。モジュール名の先頭に付いているsubscriptionは前回説明した繰り返し一括登録機能のモジュール群であることを表し、機能ごとにディレクトリが分かれます。

Jetpack Compose使用画面のモジュール構成
Jetpack Compose使用画面のモジュール構成

composeモジュール

Composable関数とそのプレビューを持つcomposeモジュールは、viewCommonとcomposeCommonモジュールにしか依存しないため、プレビューを速く表示できます。 ViewModelのインターフェースと固定の状態を持つプレビュー用ViewModel実装もこちらに置きます。

composeCommonモジュール

Jetpack Composeで実装する複数の機能で共通して使われるComposable関数はこちらに置きます。テーマやツールバー、エラー表示などがあります。

presentationモジュール

CODEはActivityによる画面遷移を行っているため、このようにComposable関数を使っています。

class SubscriptionListActivity : ComponentActivity() {

    /**
     * ViewModel実装の注入。Koinを使用。
     */
    private val viewModel: SubscriptionListViewModel by viewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            // テーマ
            CodeComposeTheme {
                // 繰り返し一括登録の一覧画面(composeモジュール)
                SubscriptionListScreen(viewModel)
            }
        }
    }
}

ViewModel本番実装もprensetationモジュールに持っていて、内部ではUseCaseを呼び出しています。

useCaseモジュール

各種UseCaseクラスを持っています。ViewModel実装とUseCaseは同じモジュールでも良いと思ったのですが、適切な名前が思いつかなかったことと、今後のチームの拡大を考えるとレイヤードアーキテクチャに対する強制力を持たせた方が良いと思い、UseCase用のモジュールを作ることにしました。

まとめ

今回はCODEに対するJetpack Composeの導入について、マルチモジュール構成について説明しました。Composable関数とそのプレビューに必要なクラスのみをモジュールに切り出して、そのモジュールが依存するモジュールを最小限にすることで、許容範囲の3〜5秒ほどのプレビュー表示時間を実現しました。それによってJetpack Composeを導入したことで、開発体験が下がる事態を防ぐことができました。 次回はActivityやJetpack Navigationなど、複数のアプローチがある画面遷移について、CODEはどのような事情があって何を選定したかの記事を公開予定です。

Androidエンジニア募集中

弊社リサーチ・アンド・イノベーションでは、例えば開発体験にもこだわりがあり一緒に向上して頂けるAndroidエンジニアを募集しています。

採用情報 - Androidエンジニア

*1:説明のため一部のモジュール名を実際のソースコードから変更しています

Jetpack Composeを本番投入しました(ViewModel編)

リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。去年、Android版CODEアプリのアーキテクチャと使用ライブラリを執筆した時点ではJetpack Composeを導入してなかったのですが、2月からJetpack Composeによる開発を開始して、6月にそれを使った新機能をリリースできました。そして現在、新機能の開発はすべてJetpack Composeで行っています。この記事ではAndroid版CODEアプリ開発チームにおける、Jetpack Compose導入のモチベーションや最初に採用した機能、技術選定の結果などを紹介します。

3部構成

この記事は3部構成を予定しています。

  • ViewModel編(今回)
  • マルチモジュール編
  • 画面遷移編

導入のモチベーション

品質の向上

Jetpack Composeは宣言的UIであり、UIには状態を持たず、ViewModel(この記事ではUIの状態管理担当クラスをViewModelと呼ぶようにします)にUIの状態を持っています。すでにCODEではViewModelに対して単体テストを書いてCIでチェックする体制ができているので、既存のViewを使うよりもデグレードを防げる範囲を広げることができます。カバレッジJaCoCoCodecovで確認しています。

ViewModelの単体テスト
ViewModelの単体テスト

タスクを分割しやすくする

Jetpack ComposeにはAndroid Studioによるプレビュー機能があります。まだ、データレイヤーやドメインレイヤーがまだ作られていなかったり、新規にジョインしたエンジニアにそれらをいきなりお任せするのは難しいときでも、先行してComposable関数を作って頂くことが可能です。プルリク単位では、Android Studioによるプレビューを持って完了とすることが可能です。

Android Studioによるプレビュー機能
Android Studioによるプレビュー機能

最初にJetpack Composeを採用した機能

CODEには家計簿としての機能があります。

カレンダー グラフ
カレンダー グラフ

さらに、繰り返し一括登録という、例えば毎月スマホの通信費に3000円使っているといった定期的な収入・支出を登録する機能がありますが、長くiOS版にありAndroid版に無い状態が続いていました。開発できるタイミングが来て、新規に2画面作成することになったので、このタイミングで導入しました。

繰り返し一括登録の一覧 繰り返し一括登録の編集
繰り返し一括登録の一覧 繰り返し一括登録の編集

参考にしたOSS

ViewModelの書き方はDroidKaigi/conference-app-2021を参考にしています。すべてが一緒というわけではなく、CODEの歴史的経緯や事情に合わせて、変えている部分があります。

ViewModel

ViewModelの共通インターフェース

Jetpack Composeを使う画面で使用するViewModelの共通インターフェースがこちらになります。

interface UnidirectionalViewModel<EVENT, EFFECT, STATE> {
    /**
     * 初期状態を返却する
     */
    fun createDefaultState(): STATE
    /**
     * 画面の状態
     */
    val state: LiveData<STATE>
    /**
     * 画面に対する効果(画面遷移など)
     */
    val effect: Flow<EFFECT>
    /**
     * 画面で発生したライフサイクルイベントやユーザ操作をオブジェクトとして渡す
     *
     * @param event イベントオブジェクト
     */
    fun event(event: EVENT)
}

DroidKaigi/conference-app-2021ではStateFlowを使用していますが、CODEでは、これまで使用していたViewModelの単体テストにおいてLiveDataの値の変化をチェックするための拡張関数を引き続き使用するために、LiveDataを使用しています。次節から、Jetpack ComposeにおけるLiveDataの使用方法を実装例とともに解説します。

ViewModelの実装例

繰り返し一括登録の一覧画面で登録された情報を削除する部分を例に解説します。

あるユーザは月1000円の動画配信サービスを契約していましたが、解約することにしたので、未来の登録を削除する操作をしました。

繰り返し一括登録の削除
繰り返し一括登録の削除

※ 削除処理は非同期で行われてるため、すぐには一覧に反映されません。

この操作で使用されたViewModelの実装がこのようになります。 削除処理以外の処理は省略しています。

class SubscriptionListViewModelImpl(
    private val subscriptionListDeleteUseCase: SubscriptionListDeleteUseCase
) : SubscriptionListViewModel() {

    override fun createDefaultState() = SubscriptionListState()

    private val _state = MutableLiveData(createDefaultState())

    override val state: LiveData<SubscriptionListState> = _state

    // effectは省略

    /**
     * 削除対象ID
     */
    private var deleteId = 0L

    /**
     * 全期間を削除or未来の登録を削除
     */
    private var deleteAll = false

    override fun event(event: SubscriptionListEvent) {
        viewModelScope.launch {
            when (event) {
                is SubscriptionListEvent.Delete -> {
                    // モーダルボトムシートで削除が選択された
                    // 削除対象ID
                    deleteId = event.id
                    // 全期間を削除 or 未来の登録を削除
                    deleteAll = event.deleteAll
                    // 削除確認ダイアログを表示する
                    _state.update {
                        copy(showConfirmDelete = true)
                    }
                }
                SubscriptionListEvent.OkConfirmDelete -> {
                    // 削除確認ダイアログでOKボタンが押された
                    try {
                        // 削除確認ダイアログを閉じる
                        // 削除プログレスを表示する
                        _state.update {
                            copy(
                                showConfirmDelete = false,
                                progress = SubscriptionListProgress.DELETE
                            )
                        }
                        // 削除処理を実行する
                        val result = subscriptionListDeleteUseCase.execute(
                            deleteId,
                            deleteAll
                        )
                        // プログレスを非表示にする
                        // 一覧を更新する
                        // 削除完了ダイアログを表示する
                        _state.update {
                            copy(
                                progress = SubscriptionListProgress.NO,
                                items = result.items.map {
                                    SubscriptionListStateItem(
                                        it.subscription, it.iconSpendingCategory
                                    )
                                },
                                showEndDelete = true
                            )
                        }
                    } catch (e: Throwable) {
                        // エラー処理は省略
                    }
                }
                SubscriptionListEvent.CancelConfirmDelete -> {
                    // 削除確認ダイアログでキャンセルされた
                    // 削除確認ダイアログを閉じる
                    _state.update {
                        copy(
                            showConfirmDelete = false
                        )
                    }
                }
                SubscriptionListEvent.CloseEndDelete -> {
                    // 削除完了ダイアログが閉じられた
                    _state.update {
                        copy(
                            showEndDelete = false
                        )
                    }
                }
            }
        }
    }
}

updateメソッドはMutableLiveDataの値の一部フィールドをdata classのcopyメソッドで更新するための拡張関数です。

fun <T> MutableLiveData<T>.update(action: T.() -> T) {
    value = requireNotNull(value).action()
}

ViewModelのテストコード

前回の記事と同様にMockKを使って単体テストを記述しています。coEveryメソッド等でUseCaseの返却値を定義したのち、ViewModelのeventメソッドを呼び出し、coVerifySequence メソッド等でUseCaseの呼び出しとLiveDataの値の変化を確認しています。LiveDataの値の変化はObserverのonChangedメソッドの呼び出しを確認する形で検証しています。

class SubscriptionListViewModelTest {
    @Test
    fun deleteFuture() = runBlocking {
        val mockStateObserver = viewModel.state.mockObserver("state")
        // 削除UseCaseの返却を定義する
        coEvery {
            subscriptionListDeleteUseCase.execute(3L, false)
        } returns SubscriptionListDeleteUseCaseResult(/* 省略 */)
        // 削除を選ぶ
        viewModel.event(SubscriptionListEvent.Delete(3L, false))
        // OKボタンを押す
        viewModel.event(SubscriptionListEvent.OkConfirmDelete)
        // 削除完了ダイアログを閉じる
        viewModel.event(SubscriptionListEvent.CloseEndDelete)
        // UseCaseの呼び出しとLiveDataの値の変化を確認する
        coVerifySequence {
            // 初期状態
            mockStateObserver.onChanged(SubscriptionListState())
            // 削除確認ダイアログを表示する
            mockStateObserver.onChanged(SubscriptionListState(showConfirmDelete = true))
            // 削除確認ダイアログを閉じる
            // 削除プログレスを表示する
            mockStateObserver.onChanged(
                SubscriptionListState(
                    progress = SubscriptionListProgress.DELETE,
                    showConfirmDelete = false
                )
            )
            // 削除処理を行う
            subscriptionListDeleteUseCase.execute(3L, false)
            // プログレスを非表示にする
            // 削除完了ダイアログを表示する
            // 一覧を更新する
            mockStateObserver.onChanged(
                SubscriptionListState(
                    progress = SubscriptionListProgress.NO,
                    showEndDelete = true,
                    items = listOf(/* 省略 */)
                )
            )
            // 削除完了ダイアログを非表示にする
            mockStateObserver.onChanged(
                SubscriptionListState(
                    progress = SubscriptionListProgress.NO,
                    items = listOf(/* 省略 */)
                    showEndDelete = false
                )
            )
        }
    }
}

LiveDataをCompose用のStateに変換する

androidx.compose.runtime:runtime-livedata ライブラリを使い、LiveDataをCompose用のStateに変換しています。

val state = viewModel.state.observeAsState(viewModel.createDefaultState()).value

LiveDataとStateFlowは似ていますが、LiveDataは初期状態が必須でないところに違いがあります。 参考 StateFlow、Flow、LiveData

なので LiveData<T>.observeAsState メソッドに初期値を渡さないとnullableな値を持つStateに変換されてしまい使いにくくなってしまいます。そのため、ViewModelの共通インターフェースに初期値としてnon-nullなStateを返すメソッドを定義しています。

Preview用ViewModel

画面全体をAndroid Studioでプレビューするときは、固定の状態を持ったPreview用のViewModelを作成して、Composable関数に渡しています。画面で発生したユーザ操作をEventオブジェクトとして渡す設計は、ユーザ操作ごとにメソッドがある設計とは違い、後で操作が増えてもプレビュー用のViewModel実装の変更が不要な点が良いです。

/**
 * 繰り返し一括登録の一覧画面のプレビュー用ViewModel
 *
 * @param プレビュー用の固定の状態
 */
class SubscriptionListViewModelPreview(
    private val previewState: SubscriptionListState
) : SubscriptionListViewModel() {

    override fun createDefaultState() = previewState

    override val state: LiveData<SubscriptionListState>
        get() = MutableLiveData(createDefaultState())

    override val effect: Flow<SubscriptionListEffect>
        get() = flow { }

    override fun event(event: SubscriptionListEvent) {
    }
}

/**
 * 繰り返し一括登録の一覧画面のプレビュー(0件ケース)
 */
@Preview
@Composable
fun SubscriptionListScreenPreviewEmpty() {
    CodeComposeTheme {
        // 0件の時の状態
        val previewState = SubscriptionListState(
            progress = SubscriptionListProgress.NO
        )
        // 固定の状態を持ったViewModelを作成する
        val viewModel = SubscriptionListViewModelPreview(previewState)
        // そのViewModelを持ってComposable関数を呼び出す
        SubscriptionListScreen(viewModel)
    }
}

まとめ

今回はCODEに対するJetpack Composeの導入について、ViewModelの設計を解説しました。UIの状態はLiveDataで持って、プレビューと単体テストに対応することができました。

次回はJetpack Composeのプレビュー高速化ために設計変更したモジュール構成を説明いたします。

Androidエンジニア募集中

弊社リサーチ・アンド・イノベーションでは例えば一緒にJetpack Composeを書きたいAndroidエンジニアを募集しています。

採用情報 - Androidエンジニア

iOS版CODEアプリのアーキテクチャと使用ライブラリ

リサーチ・アンド・イノベーションの小川です。iOSエンジニアをやっています。弊社ではiOSエンジニアを募集しています。この記事でもAndroidアプリと同様に応募者が弊社で働くイメージを持ちやすくするために、iOS版CODEアプリのアーキテクチャと使用ライブラリを広く簡潔に紹介します。

アーキテクチャ

アーキテクチャは4層構造のレイヤードアーキテクチャになっています。 MVVM+クリーンの改造版的なイメージです。チームで使い勝手を良くするために他のアーキテクチャを参考に制定してみました。

architecture.png

非同期処理

GCD, RxSwiftを使用しています。 今後、Combineを使用予定です。

プレゼンテーション層


  • View, ViewController, ViewModelが置いてある
  • ユーザーからの入力を受けて表示する

アプリケーション層


ドメイン


インフラ層


  • データ取得ロジックが記載されている
  • Repositoryの実装クラス
  • データの永続化

その他モジュール

Utility


  • 全体で使用する便利classが置いてある
  • だいたい何かのExtension

Config


  • アプリケーション全体で使いたい定数などが纏められている

実装について

Repository

データの出し入れを代表するクラスです。単純なラッパーになっているケースが多いですが、REST APIからデータ取得する場合はcacheの管理や取得データをストリームに変換して返す処理を行なっています。

UseCase

ViewModelからRepositoryを使う場合はUseCaseクラスを作成しています。

ViewModel

ユーザのInputに対しOutputを返す存在として実装しています。インターフェースとして以下を採用しています。

protocol ViewModelProtocol {
    associatedtype Input
    associatedtype Output

    func transform(input: Input) -> Output
}

ユーザの操作は全てInputに、UIの変化は全てOutputに集約させるように実装しています。 Input, OutputはRxのストリームで表現していて、リアクティブプログラミングでイベントの実装をしています。 こちらについては一つの方法に拘りがあるわけではなく、チーム内で話してより良い解決策を採用しています。

View

各ViewControllerに対して.xibファイルを作成しています。

ViewController

1つのViewControllerに1つのViewModelを結びつけ、ユーザの操作をViewModelへのInputとし、画面変更をViewModelからのOutputとして実装しています。

テスト

ユニットテスト

XCTestを使用しています。RxSwiftに関する部分についてはRxTestを使用しています。 テストカバレッジについてはまだ不十分なので、プロジェクトの課題です。

UIテスト

Appiumにてスクリプトを作成しています。本アプリではカメラの操作がメインアクションなのでまだまだ実験導入の段階となっています。

ワークフロー

主にfastlaneBitriseを使用しています。

CI

Githubでのプルリクエスト作成時・それ以降のPUSH毎にローカル単体テストの実行を行なっています。

CD

TestFlightを使った社内テスト版の配布やAppStoreへのリリースもワークフロー化されています。

分析

Firebase Analyticsで画面遷移やアプリに埋め込んだイベントを送っています。さらにBigQuery Exportを設定して、BigQueryにGoogle DataStudioJupyterからアクセスすることで、柔軟にデータ分析を行っています。データ分析エンジニアが居るのでグロースのためのデータ分析は、ほぼその方が担当しています。Androidチームでは重要機能が適切に動作しているかの確認や、ユーザから報告のあった不具合の調査のための分析を主に行っています。

まとめ

現状の課題

CODEは2013年に開発が開始したプロジェクトで、当初に開発された画面はObjective-Cで書かれていてそれが残っています(アーキテクチャとしてはFluxを採用して、グローバルな単一stateをKVOで監視することで実現しています)。レガシーコードについてはViewController単位で最新の実装方法に切り替えて作り直す方針にしています。

今後の展望

今後は今まで少人数の開発体制だったものを拡大していく予定なので、大人数でもうまくワークする開発体制の整備が必要と考えています。 また、開発効率や安全性の向上のためにも積極的に新しい技術を導入したいと考えております。一緒にチャレンジいただける方のご応募をお待ちしております。 (2022年2月の時点での具体的な検討事項は以下となります)

  • SwiftUI/Combineの導入を検討中
  • GraphQLの導入を検討中
  • E2Eテストの自動化を検討中
  • アーキテクチャの改善

Android版CODEアプリのアーキテクチャと使用ライブラリ

リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。弊社には3人目のAndroidエンジニアを採用する予定があり現在準備中です。この記事では応募者が弊社で働くイメージを持ちやすくするために、Android版CODEアプリのアーキテクチャと使用ライブラリを広く簡潔に紹介します。

アーキテクチャ

アーキテクチャは5層構造のレイヤードアーキテクチャになっています。Android公式のアプリの推奨アーキテクチャのViewModelとRepositoryの間にUseCaseを加えた構造を採用しています。UseCaseを加えた理由はViewModelの単体テストの肥大化を防ぐためです。

f:id:r-n-i:20210830102254p:plain
アーキテクチャ

非同期処理

Kotlin CoroutinesをFlowを含めて使用してます。

RemoteDataStore

サーバサイドはRuby on Railsで作成されたREST APIになります。そのクライアントをRetrofitで作成しています。

LocalDataStore

Roomを使用して複数Activityにまたがるデータを永続化して、プロセスキルからの復帰に対応しています。フローを使用したリアクティブ クエリを使い、他の画面で行われた更新も抜けなくUIに反映するようにしています。(所謂いいね問題への対応)

Repository

上記DataStoreクラスを使ってデータの出し入れを代表するクラスです。単純なラッパーになっているケースが多いですが、fetch○○というメソッドでは、REST APIからデータ取得したあとRoomへの保存します。

UseCase

ViewModelの1メソッドに対してRepositoryを使う場合は1つのUseCaseクラスを作成しています。

ViewModel

DroidKaigi/conference-app-2021UnidirectionalViewModelを参考にして、stateとeffectをひとつずつ持っています。Viewの状態はstateが持ち、ダイアログ、Toast、画面遷移のような単発イベントがeffectになります。

ViewModelはこちらのインターフェースを実装するようにしています。ISingleLiveEventインターフェースはAndroid Architecture BlueprintsのSingleLiveEventのインターフェースです。

interface CodeBaseViewModel<State, Effect> {
 
    val state: LiveData<State>

    val effect: ISingleLiveEvent<Effect>
}

View

レイアウトはXMLで作っています。Jetpack Composeはまだ導入していません。 そして多くの画面でgroupie(RecyclerView)を使用しています。クエスト一覧画面のような、いかにもリストな画面だけでなく、詳細画面を含めて幅広く使用しています。こちらはクックパッドさんの事例と同じ内容になります。

クックパッドマートAndroidアプリの画面実装を最高にした話【連載:クックパッドマート開発の裏側 vol.4】

DI

現在はKoinを使用しています。DI導入時にDagger Hiltは無かったのですが、Koinは設定ミスの検出がビルド時ではなく実行時であるというデメリットを考えると、工数をかけてDagger Hiltに差し替える選択もありだと思います。

マルチモジュール

ビルドの高速化やレイヤードアーキテクチャへの強制力のために、レイヤー別と機能別のマルチモジュールを採用しています。レイヤー別モジュールにはremoteDataStore, localDataStore, repositoryがあります。UseCase以上は機能別モジュールに格納しています。マルチモジュールを導入する前のクラスはlegacyモジュールに格納しています。

規模

関連して規模感も説明します

項目
Activity数 128
アプリコード行数 160,447
テストコード行数 88,472

テスト

弊チームでは自動テストを重視しています。弊社はアジャイルを採用していて、素早く開発して素早く社内テストや一部ユーザを対象としたリリースをしてフィードバック頂いて、素早くアプリを変化させることを良しとしています。しかしデグレの懸念があると、それが障壁になります。

RemoteDataStore

APIクライアントのテストはOkttp3のMockWebServerを使い、インストゥルメント化単体テストを記述しています。

LocalDataStore

Roomのテストもインストゥルメント化単体テストで保存とクエリを確認しています。

Repository、UseCase、ViewModel

Repository、UseCase、ViewModelについてはMockKを使って単体テストを記述しています。coEveryメソッド等でひとつ下のレイヤーの返却値を定義したのち、テスト対象メソッドを呼び出し、coVerifySequence メソッド等でひとつ下のレイヤーに対して呼び出された内容を検証しています。 ViewModelについては、LiveDataの変化も onChanged メソッド呼び出しを確認する形で検証しています。

テストコードの例

ここでは家計簿の収入を登録および編集する画面を例にして、そのViewModelの実装とテストコードを紹介します。

f:id:r-n-i:20210830102337p:plain:w320
収入を登録および編集する画面

※ 近日中にリニューアル予定の画面です。

まずテスト対象のViewModelです。収入を登録する部分以外は省略しています。

data class IncomeEditState(
    val progress: Progress = Progress.NOTHING,
    val price: Int = 0,
    val selectedIncomeCategoryId: Long = 0,
    val date: LocalDate = LocalDate.of(1900, 1, 1),
    val memo: String = ""
) {
    enum class Progress {
        LOADING, PROCESSING, NOTHING
    }
}

sealed class IncomeEditEffect {
    object Close : IncomeEditEffect()
}

class IncomeEditViewModel(
    private val incomeCreateOrUpdateUseCase: IncomeCreateOrUpdateUseCase
) : ViewModel(),
    CodeBaseViewModel<IncomeEditState, IncomeEditEffect> {

    private val _state = MutableLiveData(IncomeEditState())
    override val state: LiveData<IncomeEditState>
        get() = _state

    private val _effect = SingleLiveEvent<IncomeEditEffect>()
    override val effect: ISingleLiveEvent<IncomeEditEffect>
        get() = _effect

    val error = ApiErrorViewModelHelper()

    /**
     * 登録/更新ボタンが押された時に呼ばれる
     *
     * @param id サーバ側ID。0Lの時は登録。
     * @param price 金額
     * @param incomeCategoryId カテゴリID
     * @param date 日付
     * @param memo メモ
     */
    fun submit(
        id: Long,
        price: Int,
        incomeCategoryId: Long,
        date: LocalDate,
        memo: String
    ) = viewModelScope.launch {
        try {
            // 処理中としてプログレスを表示する
            _state.value = state.value?.copy(progress = IncomeEditState.Progress.PROCESSING)
            // 収入を登録するUseCaseを呼び出す
            // UseCase内部では登録API呼び出しと収入一覧の再フェッチが行われている
            incomeCreateOrUpdateUseCase.createOrUpdate(id, price, incomeCategoryId, date, memo)
            // 画面を閉じる
            _effect.value = IncomeEditEffect.Close
        } catch (e: Throwable) {
            // エラー処理
            error.onError(e)
        } finally {
            // プログレスを元に戻す
            _state.value = state.value?.copy(progress = IncomeEditState.Progress.NOTHING)
        }
    }
}

LiveDataの変化をMockKで確認するための拡張関数です。

fun <T> LiveData<T>.mockObserver(name: String = ""): Observer<T> {
    val mockObserver = mockk<Observer<T>>(relaxed = true, name = name)
    observeForever(mockObserver)
    return mockObserver
}

fun <T> ISingleLiveEvent<T>.mockObserver(name: String = ""): Observer<T> {
    val mockObserver = mockk<Observer<T>>(relaxed = true, name = name)
    observeForever(mockObserver)
    return mockObserver
}

ViewModelのテストはこのようになります。

@ExperimentalCoroutinesApi
@Test
fun submit() = testDispatcher.runBlockingTest {
    val stateMockObserver = viewModel.state.mockObserver()
    val effectMockObserver = viewModel.effect.mockObserver()
    viewModel.submit(
        0L,
        1500,
        2L,
        LocalDate.of(2021, 8, 10),
        "メモ"
    )
    // LiveDataの変化とUseCase呼び出しを確認する
    coVerifySequence {
        stateMockObserver.onChanged(IncomeEditState())
        stateMockObserver.onChanged(
            IncomeEditState(
                progress = IncomeEditState.Progress.PROCESSING
            )
        )
        incomeCreateOrUpdateUseCase.createOrUpdate(
            0L, 1500, 2L, LocalDate.of(2021, 8, 10), "メモ"
        )
        effectMockObserver.onChanged(IncomeEditEffect.Close)
        stateMockObserver.onChanged(
            IncomeEditState(
                progress = IncomeEditState.Progress.NOTHING
            )
        )
    }
}

※ 説明のために簡略化しているので、実際のプロダクトのコードとは異なります。

View層

EspressoAppium等を使ったUIの自動テストはほぼ無く、現在の課題です。

ワークフロー

主にAWS CodeBuildGitHub Actionsを使用しています。

CI

プルリクへのPUSH毎に以下の内容を実行しています。

処理時間が25分かかるので、キャッシュの設定やRobolectricの使用などで短くすることを検討中です。

CD

DeployGateを使った社内テスト版の配布やGoogle Playへのリリースもワークフロー化されています。

分析

Firebase Analyticsで画面遷移やアプリに埋め込んだイベントを送っています。さらにBigQuery Exportを設定して、BigQueryにGoogle DataStudioJupyterからアクセスすることで、柔軟にデータ分析を行っています。データ分析エンジニアが居るのでグロースのためのデータ分析は、ほぼその方が担当しています。Androidチームでは重要機能が適切に動作しているかの確認や、ユーザから報告のあった不具合の調査のための分析を主に行っています。

古いコード

前述したアーキテクチャは開発の新しい画面で採用しているもので、以前開発した画面では違う技術やライブラリが使われています。

Fluxアーキテクチャ

少し前に開発した画面ではDroidKaigi/conference-app-2019を参考にしたFluxアーキテクチャを採用していて、View層とRepository層の間はActionCreatorとStoreになっています。こちらは単体テストが記述されているので、改修するときもそのままFluxアーキテクチャを使い続けようと考えています。

レガシーコード

さらに以前に開発された画面はJavaで書かれていて、API呼び出しもAsyncTask + HttpUrlRequestになっています。単体テストは無いです。このようなコードに対する考え方は、動いているコードは弄らず機能改修するときはActivity単位で全部新しく作っています。弊社PdMは技術的負債の解消について理解がある方で助かっています。

まとめ

安全かつ高速にアプリを進化させるために有用となるガイドライン策定や技術の導入は何でもしようと思います。まだ不完全なところがあるので、一緒に頑張って頂ける方を募集する予定です。

ドラッグ可能かつクリック可能なViewを作る

リサーチ・アンド・イノベーションの高田(tfandkusu)です。Androidエンジニアをやっています。昔から独自のUI部品やユニークなアニメーションを作ることにはこだわっていましたが、今回作ったドラッグ可能かつクリック可能なViewもなかなかの自信作なので紹介しようと思います。

FINE演出

CODEの買い物登録では、まずレシートを撮影して頂きますが、撮影されたレシート画像はOCR(光学的文字認識)処理され、日付、合計金額、店舗電話番号が読み取られます。CODEではサードパーティー製のレシートOCRライブラリを使用してOCR処理を行っています。クラウドの力は借りずにリソースの限られたスマホ端末内で完結して処理が行われますが、認識精度と処理速度の両面で高い完成度を持ったライブラリだと思います。とは言っても、レシートが遠すぎたり手ぶれしていたりピンボケしていたりすると読めません。

OCRが読めないレシート画像の例
OCRが読めないレシート画像の例

適切な撮影に誘導する仕組みとして、日付、合計金額、店舗電話番号のすべてが読み取れるとブタさんのキャラクターが褒めてくれる演出を作成しました。社内では「FINE演出」と呼ばれています。

FINE演出
FINE演出

クリックできる

FINE演出は左上のXボタンクリックで閉じることができます。閉じると小さい閉じたFINE演出になります。撮影状態やレシートの記載を確認できるようにするためです。そして閉じたFINE演出をクリックすると再び大きなFINE演出にできます。

クリックできる
クリックできる

ドラッグできる

しかし閉じたFINE演出でもレシートの表示に被ります。そこでドラッグで移動可能にしました。さらにそれによって読み取り結果が合っているかを該当箇所の隣にドラッグして確認することもできます。

ドラッグできる

ドラッグ可能かつクリック可能なViewの作り方

それではドラッグ可能かつクリック可能なViewの作り方を紹介します。それを実現できるライブラリが無いか探してみたのですが、見つけることができませんでした。なのでAndroid SDKの機能を組み合わせて実現しました。

まずレイアウトファイルはタッチイベントをキャッチする担当Viewと閉じたFINE演出に分けます。

<!-- 略 -->
        <FrameLayout
            android:id="@+id/fine_view_close_parent"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1">

            <!-- 閉じたFINE演出(ドラッグ可能かつクリック可能) -->
            <include
                android:id="@+id/fine_view_close"
                layout="@layout/view_receipt_confirm_fine_close" />

            <!-- タッチイベントキャッチ担当(見えない) -->
            <View
                android:id="@+id/fine_close_touch_catcher"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:visibility="invisible" />
        </FrameLayout>
<!-- 略 -->

タッチイベントキャッチ担当と閉じたFINE演出
タッチイベントキャッチ担当と閉じたFINE演出

Android Studio
Android Studioでのプレビュー

下記のコードが閉じたFINE演出find_view_closeをクリック可能かつドラッグ可能にするコードです。ドラッグおよびクリックのイベントはすべてタッチイベントキャッチ担当fine_close_touch_catcherが担当します。取得したタッチイベントを処理した結果をfind_view_closeに反映させています。移動ならばViewの位置を基準の位置からずらすためのtranslationX、translationYプロパティを更新し、クリックならばperformClickメソッドを呼び出します。クリックおよびドラッグの判定はすべてAndroid SDK標準のGestureDetectorにお任せして、何ピクセル以上移動したらクリックでは無くてドラック操作になるといった複雑なタッチイベント判定処理は独自に書きません。また、タッチしたときのリップルエフェクトにもGestureDetectorのonShowPressコールバックメソッドを使って対応しています。

    /**
     * 閉じたFINE演出の移動
     */
    private fun setUpDragCloseView() {
        // View取得の方法はView Binding
        // 閉じたFINE演出高さ
        // 高さは可変長ではないのでここで取得。幅はこのタイミングだとテキスト適用前のデフォルトの幅が取得されてしまう
        val h = binding.fineViewClose.height
        // 可動範囲の大きさ
        val parentWidth = binding.fineCloseTouchCatcher.width
        val parentHeight = binding.fineCloseTouchCatcher.height
        // ドラッグで移動する
        val gestureDetector =
            GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
                override fun onDown(e: MotionEvent): Boolean {
                    // 閉じたFINE演出の上で起きたイベントか確認する
                    val x = binding.fineViewClose.x
                    val y = binding.fineViewClose.y
                    // 閉じたFINE演出幅
                    val w = binding.fineViewClose.width
                    // ここでtrueを返却すると、onScroll、onSingleTapUpイベントが呼ばれるようになる
                    return x <= e.x && y <= e.y && e.x <= x + w && e.y <= y + h
                }

                override fun onShowPress(e: MotionEvent) {
                    // タップ反応エフェクトを表示すべき時に呼ばれる
                    // 閉じたFINE演出の上で起きたイベントか確認する
                    val x = binding.fineViewClose.x
                    val y = binding.fineViewClose.y
                    // 閉じたFINE演出幅
                    val w = binding.fineViewClose.width
                    if (x <= e.x && y <= e.y && e.x <= x + w && e.y <= y + h) {
                        // リップルエフェクトの中心点を設定する
                        binding.fineViewClose.drawableHotspotChanged(
                            e.x - binding.fineViewClose.x,
                            e.y - binding.fineViewClose.y
                        )
                        // タップされたときのエフェクトを表示する
                        binding.fineViewClose.isPressed = true
                    }
                }

                override fun onScroll(
                    e1: MotionEvent,
                    e2: MotionEvent,
                    distanceX: Float,
                    distanceY: Float
                ): Boolean {
                    // タップされたときのエフェクトを解除して
                    binding.fineViewClose.isPressed = false
                    // 移動する
                    binding.fineViewClose.translationX -= distanceX
                    binding.fineViewClose.translationY -= distanceY
                    // はみ出さないようにする
                    // まずはみ出しピクセル数を計算
                    val x = binding.fineViewClose.x
                    val y = binding.fineViewClose.y
                    // 閉じたFINE演出幅
                    val w = binding.fineViewClose.width
                    val leftOver = 0 - x
                    val topOver = 0 - y
                    val rightOver = x + w - parentWidth
                    val bottomOver = y + h - parentHeight
                    // はみ出していたら戻す
                    if (leftOver > 0)
                        binding.fineViewClose.translationX += leftOver
                    if (topOver > 0)
                        binding.fineViewClose.translationY += topOver
                    if (rightOver > 0)
                        binding.fineViewClose.translationX -= rightOver
                    if (bottomOver > 0)
                        binding.fineViewClose.translationY -= bottomOver
                    return true
                }

                override fun onSingleTapUp(e: MotionEvent): Boolean {
                    // タップされたときのイベント処理
                    // タップされたときのエフェクトを解除して
                    binding.fineViewClose.isPressed = false
                    // 閉じたFINE演出をクリックする
                    binding.fineViewClose.performClick()
                    return true
                }
            })
        // ロングタップは無効化
        gestureDetector.setIsLongpressEnabled(false)
        // GestureDetectorにタッチイベントを送る
        binding.fineCloseTouchCatcher.setOnTouchListener { _, event ->
            gestureDetector.onTouchEvent(event)
        }
    }

最後に

前回の記事ではAndroidエンジニアを募集中と締めましたが、おかげさまで採用に成功しました。現在はエンジニア採用を行っていませんが、再び採用を開始することになりましたら、最近リニューアルされたコーポレートサイトや転職サイトに加えてこのブログでも紹介します。