アイテムレビュー機能をZOZOTOWN Androidチームはどう開発したか

ZOZOTOWN Androidチームはアイテムレビュー機能をどう開発したか

はじめに

こんにちは、ZOZOTOWN開発1部Android2ブロックの井上晃平(@ねも)です。普段はZOZOTOWN Androidアプリの開発を担当しています。ZOZOTOWN Androidチームでは、以前から商品に対して口コミや評価を投稿・閲覧できる、アイテムレビュー機能を開発していました。そして、2023年11月29日に晴れてアイテムレビュー機能がリリースされました。

アイテムレビュー機能を設計・開発していく中で見えてきた課題を、解決策とともにご紹介します。

そもそもアイテムレビュー機能のことを知りたいという方は、プレスリリースで機能紹介をしているので、あわせてご覧ください。

目次

課題

アイテムレビュー機能の開発における最大の課題はビッグバンリリース問題です。アイテムレビュー機能は開発に長い時間をかけていて、弊社の中ではかなりの大型案件でした。弊社の案件では基本的に1回のリリースで全コード差分を本番コードに取り込みます。しかし、今回のアイテムレビュー機能では機能要件が他と比べて複雑かつ巨大なため、それを実現するコードの量も多いです。そのため普段の機能開発と同じリリース方式を採ってしまうと、とても大きなコンフリクトが発生してしまったり、万が一リリース後に不具合が発生した際は、原因究明が難しくなったりするなどの問題がありました。

解決策

マイルストーン方式

この問題の解決策として、弊チームはマイルストーン方式のリリースを採用しました。開発期間を6分割し、マイルストーン毎にリリースする要件やタスクを決めます。そして、マイルストーンが終わるタイミングでQAを行い実際にプロダクションコードに差分を取り込んでいくというリリース手法です。このマイルストーン方式によって主に3つの成果を得ることができました。

  1. 大規模なコンフリクトの防止

    アイテムレビュー機能の開発を6分割した小さい粒度でプロダクションコードに差分を取り込むので、大規模なコンフリクトが起きにくいです。

  2. アプリ品質の向上

    マイルストーン毎にQAを実施するので、不具合を早期発見できて原因も特定しやすいです。そのため結果的にほとんどの不具合が解消された状態で、アイテムレビュー機能の本番リリース日を迎えることができました。

  3. チームメンバーモチベーションの向上

    マイルストーン毎にリリース時の達成感を味わえるため、長い開発期間の中でチームの士気が落ちてしまうことを防げました。

マイルストーン方式について1つ考えるべきことがあります。それは開発中のアイテムレビュー機能を、リリースビルド上でいかに隠すかという点です。リリースしていない機能のプログラムがリリースビルドで動いてしまうのは問題があります。マイルストーン毎にコードをリリースしますが、実際にアイテムレビュー機能を世に出すのは6回目のマイルストーンが終わったタイミングです。それまでの間はコード差分をプロダクションコードに取り込むものの、実際のリリースビルド上ではそのコードが含まれないもしくは実行されない状態になっている必要がありました。

この問題についてZOZOTOWN Androidチームではモジュール構成の工夫とFeatureFlagを解決策として採用しました。

アイテムレビュー機能周りのコードは以下のように2つに分けることができます。

  1. アイテムレビュー機能のモジュールの内側で閉じているコード
  2. アイテムレビュー機能のモジュールの外側に出ているコード

モジュール構成の工夫

まず1のコードをリリースビルドに含めない方法を紹介します。これを実現するためにモジュール構成を工夫しました。ZOZOTOWN Androidアプリではマルチモジュール構成を採用しています。アイテムレビュー機能の実装においては、このモジュール同士の依存関係を工夫しました。具体的には以下の画像の通りです。

モジュール構成図

これらのモジュールの役割を1つずつ解説していきます。

まずinterfaceモジュールは、その名の通りinterfaceを保持するモジュールです。具体的に言うと以下の内容をinterfaceとして抽象化して保持しています。

  • アイテムレビュー機能のUI
  • モーダルを表示させるための処理群
  • アイテムレビュー機能のCRUD処理

次にcoreとnoopモジュールについてです。これらは両方ともinterfaceモジュールの処理の実装を保持しています。interfaceモジュールにおいて依存関係を逆転しているので、それらの処理の実装をcoreとnoopの2パターン用意できます。具体的なコード例を次に示します。

まずinterfaceモジュールに以下のようなinterfaceを定義します。

interface GetTopItemReviewUseCase {

    suspend operator fun invoke(parameter: Parameter): Result<TopItemReview>

    data class Parameter(
        val goodsId: GoodsId,
    )
}

interface TopItemReview {
    fun findUserReviewById(id: Long): Serializable?
}

そしてその実装をcoreとnoopでそれぞれ用意します。

// coreモジュール
class GetTopItemReviewUseCaseImpl @Inject constructor(
    private val itemReviewRepository: ItemReviewRepository,
) : GetTopItemReviewUseCase {

    override suspend fun invoke(parameter: GetTopItemReviewUseCase.Parameter): Result<TopItemReview> {
        return itemReviewRepository.getTopItemReview(parameter.goodsId)
    }
}
// noopモジュール
class GetTopItemReviewUseCaseImpl : GetTopItemReviewUseCase {
    
    override suspend fun invoke(parameter: GetTopItemReviewUseCase.Parameter): Result<TopItemReview> {
        return Result.success(
            object : TopItemReview {
                override fun findUserReviewById(id: Long): Serializable? {
                    return null
                }
            },
        )
    }
}

noopのinvokeメソッドの処理でreturnしているTopItemReview#findUserReviewByIdは、nullを返すだけの空の実装になっています。逆にcoreモジュールの方はItemReviewRepository#getTopItemReviewの処理を呼び出しています。

最後のintergrationモジュールはcoreとnoopモジュールをスイッチするためのモジュールです。プロジェクト内の他のモジュールがアイテムレビュー機能にアクセスしたいときは、このintegrationモジュールに依存させます。integrationモジュールに対して、noopはreleaseImplementationとして、coreはdebugImplementationとして依存させます。そうすることでビルドバリアントを変更するだけで実際のビルドさせるモジュールが切り替わるようになっています。

以上のようにモジュール構成を工夫することによって、リリースビルドでは空の実装が使用され、開発中のコードがリリースビルドに含まれてしまうことを防いでいます。

FeatureFlag

次に2のアイテムレビュー機能のモジュールの外側に出ているコードをリリースビルド上で動作させないための仕組みを解説します。そもそもモジュールの外側に出ているコードとは以下のようなものがあります。

  • アイテムレビュー機能のUI表示処理
  • 既存画面に追加されるアイテムレビュー機能の初期データ取得処理
  • Google Analyticsなどのログ送信処理

これらのコードがリリースビルド上で動かないようにするため、FeatureFlagを導入しました。今回の案件ではサーバーサイドのAPIやFirebase RemoteConfigでフラグを管理する方法ではなく、ローカルで管理する方法を採用しました。実運用されているコードを例に解説します。

まずFeatureFlagのモジュールはfeatureFlag:flag、featureFlag:core、featureFlag:core-noopの3種類存在します。featureFlag:core-noopをリリースビルドで、featureFlag:coreをデバッグビルドでそれぞれ使用します。featureFlag:flagモジュールはビルドバリアント関係なく使用します。

featureFlag:flagモジュールにFeatureFlagを以下のような形で宣言します。titledescriptionを保持することで、そのFeatureFlagがどのような意味を持ち、どのような働きをするのかという内容を明確にします。

// featureFlag:flag

sealed class FeatureFlag(
    val title: String,
    val description: String,
) {
    object ItemReview : FeatureFlag("ItemReview有効化", "ItemReviewの有効/無効を切り替える")

    // 他のFeatureFlag
}

そしてfeatureFlag:core-noopとfeatureFlag:coreのそれぞれに必要なコードを追加します。

// featureFlag:core-noop

// in FeatureFlagEx.kt
fun FeatureFlag.isEnable(): Boolean = false

// in FeatureFlagContainer.kt
object FeatureFlagContainer {
    @JvmStatic
    fun init(context: Context) {
        // no-op
    }
}
// featureFlag:core

// in FeatureFlagEx.kt
fun FeatureFlag.isEnable(): Boolean = FeatureFlagContainer.isEnable(this)

// in FeatureFlagContainer.kt
object FeatureFlagContainer {
    private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "feature_flag")
    private var dataStore: DataStore<Preferences>? = null

    private fun FeatureFlag.toPreferencesKey() = booleanPreferencesKey(javaClass.name)

    private fun store(): DataStore<Preferences> = checkNotNull(dataStore)

    @JvmStatic
    fun init(context: Context) {
        if (dataStore != null) return
        dataStore = context.dataStore
    }

    internal fun isEnable(feature: FeatureFlag): Boolean = runBlocking {
        store().data.first()[feature.toPreferencesKey()] ?: false
    }

    suspend fun set(feature: FeatureFlag, enable: Boolean) {
        store().edit { setting ->
            setting[feature.toPreferencesKey()] = enable
        }
    }
}

リリースビルドではisEnableが常にfalseです。デバッグビルドではJetpackのDataStoreを利用してFeatureFlagを保存しています。そのため必要に応じてisEnabletrue/falseを切り替えることができます。

また、isEnableの処理だけをFeatureFlagの拡張関数として別ファイルに切り出している理由は以下です。

  • FeatureFlag.と入力し、sealed classで定義したFeatureFlagをIDEの一覧サジェストから選択して、.isEnableとスムーズに続けることができるため。
  • isEnabletrue/falseのチェックのみが必要な箇所に対して、不必要に保存処理等までを公開しないようにするため。

FeatureFlagを利用する側ではisEnableを呼び出しその戻り値によって実行する処理を分岐します。今回はアイテム詳細画面において、レビューの平均値を表示する星を出し分けるコードを例に紹介します。下記のようにonViewCreated内部でFeatureFlagを確認し、ItemReview#isEnabletrueになっていれば星を表示して、falseであれば何もしません。結果的にアイテムレビュー機能のON/OFFを切り替えることができるようになります。

override fun onViewCreated(view: View, savedInstanceState: Bundle) {
    super.onViewCreated(view, savedInstanceState)
    if (FeatureFlag.ItemReview.isEnable())) {
        // 星を表示させる処理
    }
}
レビュー機能ON レビュー機能OFF
レビュー機能ON レビュー機能OFF

以上のようなFeatureFlagを用いて、アイテムレビュー機能のモジュールの外側に出ているコードがリリースビルド上で動かないようにできました。

まとめ

本記事ではZOZOTOWN Androidチームのアイテムレビュー機能実装における取り組みを紹介しました。比較的大きな案件でビッグバンリリースを未然に防いだ実績を作ることができました。今回学んだことを活かして次回以降の案件の遂行をより円滑にしようと思います。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

hrmos.co

カテゴリー