エンジニアの体験を改善する

Merpay Advent Calendar 2020 の16日目は、メルペイ Android チームの yhanadaが担当します。

著者のメルカリ/メルペイでのAndroidエンジニアとしてのキャリアは、12月でちょうど4年になりました。チーム内での役割としては、通常の機能開発以外に、DEX(Developer EXperience)改善というプロジェクトで、リファクタリングを行ったり、ルールを考えたり、不要なものを取り除いたり、などエンジニアの負担を減らす活動を行っています。

つまりDEX改善の対象となるのは、リファクタリングなどのコード改善だけではなく、時に他チームとの交渉や調整など組織的な部分もエンジニアリングの対象に含まれます。
本記事では、このDEX改善活動の紹介を行うとともに、過去のブログ記事「マルチモジュールなプロジェクトにおける画面遷移の実装」での残課題であった、マルチモジュールプロジェクトでのモジュールをまたがる画面遷移定義の煩雑さの解消についても伏線を回収したいと思います。

マルチモジュールで分割されている画面間の遷移

過去の記事でも紹介したとおり、メルペイAndroidの各機能はマルチモジュールで設計されているため、例えば:feature_aモジュールのFeatureA画面から異なるモジュールである:feature_bのFeatureB画面への遷移をどちらかのモジュールに定義することはできません。

このモジュールの依存関係を見ると、画面遷移は:appモジュールに定義するのが良さそうです。

マルチモジュールの相互依存構造のイメージ

ここからは簡略化したコードも出てきますので、本記事で出てくる主なコンポーネントと役割を紹介したいと思います。

  • Controller: 画面の実装単位。Conductorで提供されている
  • FeatureXxxController: とある機能の画面実装クラス
  • Navigation: 画面遷移定義のための共通インターフェイス
  • NavigationRegistry: 画面ごとのNavigation実装を管理
  • FeatureXxxNavigation: 各画面(FeatureXxxController)の遷移を定義したインターフェイス。Navigationを継承
  • FeatureXxxNavigator: FeatureXxxNavigationインターフェイスの実装クラス

少し前までは、各画面ごとのNavigation定義と実装クラスは次のようになっていました。

// FeatureAControllerの遷移処理定義
interface FeatureANavigation : Navigation {
    fun navigateToFeatureB(router Router)
}

// FeatureANavigationの実装クラス
class FeatureANavigator : FeatureANavigation {
    override fun navigateToFeatureB(router Router) {
        // …..
    }
}

FeatureANavigatorNavigationの実装クラスです。機能追加などのタイミングで、FeatureA、FeatureB、…と追加することになります。これら追加が必要なNavigatorは実装担当のエンジニアがNavigationRegistryに一つずつ登録していました。

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        NavigationRegistry(
            FeatureANavigator(),
            FeatureBNavigator(),
            // 以下数十個のNavigator...
        )
    }
}

登録する画面が増えるにつれてわかってきた問題

しばらくは前述の手法で開発を進めていたのですが、次第に問題が発生し、チームでは大きく次の3つの観点で課題意識をもちはじめました。

1) NavigatorのインスタンスをNaivgationRegistryに登録し忘れる
2) Navigatorの登録時に多くのエンジニアが同じあたりに行を追加することになり、コンフリクトしやすい(そしてコンクリフトが手作業で解決されるため、いつの間にか必要な登録が消えていた)
3) Debugビルドだけで使いたい画面のNavigatorをどうするか

Navigatorを作るところまでは良いのですが、NavigationRegistryに登録することは忘れがちで、最初の頃はたびたび1)のようなミスを起こしていました。ただ、これはマルチモジュール構造に慣れてくるにつれて減ってきました。

だんだん慣れてくると2)の問題が増えてきました。メルペイには約10人のAndroidエンジニアが所属しており、時期によっては10程度のプロジェクトがあります。このような状況では常に複数の機能開発が並行して行われているため、NavigationRegistryのようにほぼ全員が修正するようなファイルでは頻繁にコンフリクトを起こします。そして、コンフリクト解消に失敗すると、他の人の登録したNavigatorがいつの間にか消えてしまうというミスが2)です。しかも登録が消えてもビルドエラーは発生しないため動かして見て初めて分かるというケースです。

そして個人的には最後に列挙した問題が一番重要と感じています。一部の画面はDebug版のみでしか使われず、Navigatorもそれに伴ってDebug版でしか登録させたくないという3)のケースです。

BuildTypeごとに必要なNavigator

解決策1: BuildTypeごとにNavigatorのインスタンスを返す配列を定義する

Androidアプリケーション開発ではDebugビルドだけで使いたい画面などは、BuildTypeごとのフォルダ以下に同じ名前のクラスを提供し、Appクラスからはそれを参照するという方法が一般的です。

  • app/src/debug/java/com/merpay/navigation/NavigatorProvider.kt
  • app/src/release/java/com/merpay/navigation/NavigatorProvider.kt
object NavigatorProvider {
    val navigators: Array<Navigation> = arrayOf(
        FeatureANavigator(),
        FeatureBNavigator(),
        // debugフォルダ以下に置かれている方では
        // 例えばDebugNavigator()が追加されている
    )
}

ただしこの方法では3)の問題は解決しますが、1)や2)の問題はより複雑になります。特にReleaseビルドへの登録を忘れるのは怖いですよね。

解決策2: 必要なNavigatorを抽出するアノテーションプロセッサを作成

メルペイでは解決策1で紹介したBuildTypeを採用して3)の課題に対処しましたが、同じマルチモジュール構成で実装しているメルカリ側では別のアプローチで解決しています。それはメルカリとメルペイで画面を構成するアーキテクチャが異なるため、同じアプローチを採用できなかったためです。

メルカリ側では、モジュールをまたがる遷移先の指定が正しく定義されているかどうかをチェックするために、アノテーションを使って遷移の呼び出し元と呼び出し先の関連付けを行っています。そのため、もし片方から関連が切れるとビルドエラーが発生するようになっています。

メルペイでも当初はアノテーションを使って関連をチェックする仕組みを導入しようとしましたが、そもそも2ヶ所に手を入れるという意味ではあまり変わらないため、アノテーションを付けたNavigatorの一覧を自動的に抽出するアノテーションプロセッサを作成するという方針に変更することにしました。

今回追加したアノテーションは以下のように定義しています。

@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.CLASS)
annotation class ControllerNavigation

このアノテーションはビルド時に抽出するための印として使用するため、クラスファイルに保存する必要はなく、Retentionの値としてAnnotationRetention.SOURCEを指定しています。
また、このアノテーションをつける対象はNaivgaitonの実装クラスだけであるため、TargetとしてAnnotationTarget.CLASSを指定しています。

次にアノテーションプロセッサでやりたいことは大まかには以下の内容になります。

  1. @ControllerNavigationアノテーションのついたクラスを探し
  2. そのクラスのFQCNの配列を
  3. objectのvalとして定義するファイルを生成する

ファイルの自動生成にはKotlinPoet を使いました。

さて、このアノテーションを追加するとどう変わるでしょうか?
エンジニアが直接編集する必要があるのはNavigatorクラスまでです。次のようにアノテーションが追加されています。

@ControllerNavigation // <- 追加
class FeatureANavigator : FeatureANavigation {
    override fun navigateToFeatureB(router Router) {
        // .....
    }
}

アノテーションプロセッサによって生成されたobjectは次のようになります。

object MerpayNavigators {
    val navigators: Array<Navigation> =
arrayOf(
    com.merpay.featureA.FeatureANavigator(),
    com.merpay.featureB.FeatureBNavigator(),
    // ......
    )
}

Appクラスでは、シンプルにこのobjectが提供するNavigator一覧を参照するだけなので、エンジニアが直接変更することはなくなります。

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        NavigationRegistry(*MerpayNavigators.navigators) // スッキリ
    }

    // ......
}

おわりに

DEX改善プロジェクトには、機能開発以外のいろいろな改善提案や問題点が持ち込まれて検討されます。メルペイだけで解決できずメルカリ側にも影響を与えるような課題が発生した場合には、CPG(Client Platform Group)に持ち込んで、共同で解決するようになっています。
またDEX改善チームには専任のQAメンバーもアサインされており、他の機能開発とは独立したリリース計画もたてることができます。
時間がないからやっつけでコードを書いた、という事情がなくても時が経つほど最高にカッコいいと思っていたコードも陳腐化します。著者自身はコードの新陳代謝を進めることで、エンジニアのモチベーションとプロダクト水準の向上を目的としてDEX改善プロジェクトに取り組んでいます。

本記事で紹介したNavigatorを登録するという目的を果たすためには、例えば問題点の1) や 2)のような面倒事を飲み込んでしまえば解決策1でも十分だと思います。
DEX改善プロジェクトは解決策2のように、もっとうまくやるにはどうすればよいだろうか、ということを日々考えている有志達による活動です。個人が感じるちょっとした課題やアイデアなどを組織的に解決するための取り組みだと感じています。

メルペイではミッション・バリューに共感できるAndroidエンジニアを募集しています。一緒に働ける仲間をお待ちしております。
ソフトウェアエンジニア (Android) [Merpay]

明日の Merpay Advent Calendar 2020 は、Backend エンジニアの Robert さんが担当です。引き続きお楽しみください。